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
@@ -281,10 +281,10 @@ function applyCompletedPatch(updated: TodoItem, items: TodoItem[], columns: Stat
281
281
  }
282
282
  }
283
283
 
284
- export function handlePatch(items: TodoItem[], columns: StatusColumn[], id: string, input: PatchInput): ItemsActionResult {
285
- const target = items.find((i) => i.id === id);
284
+ export function handlePatch(items: TodoItem[], columns: StatusColumn[], itemId: string, input: PatchInput): ItemsActionResult {
285
+ const target = items.find((item) => item.id === itemId);
286
286
  if (!target) {
287
- return { kind: "error", status: 404, error: `item not found: ${id}` };
287
+ return { kind: "error", status: 404, error: `item not found: ${itemId}` };
288
288
  }
289
289
  const updated: TodoItem = { ...target };
290
290
 
@@ -305,7 +305,7 @@ export function handlePatch(items: TodoItem[], columns: StatusColumn[], id: stri
305
305
  if (err) return err;
306
306
  }
307
307
 
308
- const next = items.map((item) => (item.id === id ? updated : item));
308
+ const next = items.map((item) => (item.id === itemId ? updated : item));
309
309
  return { kind: "success", items: next, item: updated };
310
310
  }
311
311
 
@@ -323,10 +323,10 @@ export interface MoveInput {
323
323
  position?: number;
324
324
  }
325
325
 
326
- export function handleMove(items: TodoItem[], columns: StatusColumn[], id: string, input: MoveInput): ItemsActionResult {
327
- const target = items.find((i) => i.id === id);
326
+ export function handleMove(items: TodoItem[], columns: StatusColumn[], itemId: string, input: MoveInput): ItemsActionResult {
327
+ const target = items.find((item) => item.id === itemId);
328
328
  if (!target) {
329
- return { kind: "error", status: 404, error: `item not found: ${id}` };
329
+ return { kind: "error", status: 404, error: `item not found: ${itemId}` };
330
330
  }
331
331
  const validStatusIds = new Set(columns.map((column) => column.id));
332
332
  const newStatus = input.status ?? target.status ?? defaultStatusId(columns);
@@ -345,7 +345,7 @@ export function handleMove(items: TodoItem[], columns: StatusColumn[], id: strin
345
345
  };
346
346
  // Re-collect the items in the target column with the moving item
347
347
  // pulled out, then splice item back in at `position`.
348
- const others = items.filter((item) => item.id !== id && item.status === newStatus).sort((left, right) => (left.order ?? 0) - (right.order ?? 0));
348
+ const others = items.filter((item) => item.id !== itemId && item.status === newStatus).sort((left, right) => (left.order ?? 0) - (right.order ?? 0));
349
349
  const insertAt = clampPosition(input.position, others.length);
350
350
  const reordered = [...others];
351
351
  reordered.splice(insertAt, 0, updatedSelf);
@@ -354,7 +354,7 @@ export function handleMove(items: TodoItem[], columns: StatusColumn[], id: strin
354
354
  reordered.forEach((item, i) => reorderedById.set(item.id, (i + 1) * ORDER_STEP));
355
355
  const nextItems = items.map((item): TodoItem => {
356
356
  const newOrder = reorderedById.get(item.id);
357
- if (item.id === id) {
357
+ if (item.id === itemId) {
358
358
  const out: TodoItem = {
359
359
  ...updatedSelf,
360
360
  order: newOrder ?? updatedSelf.order ?? ORDER_STEP,
@@ -364,7 +364,7 @@ export function handleMove(items: TodoItem[], columns: StatusColumn[], id: strin
364
364
  if (newOrder !== undefined) return { ...item, order: newOrder };
365
365
  return item;
366
366
  });
367
- const finalSelf = nextItems.find((item) => item.id === id)!;
367
+ const finalSelf = nextItems.find((item) => item.id === itemId)!;
368
368
  return { kind: "success", items: nextItems, item: finalSelf };
369
369
  }
370
370
 
@@ -377,10 +377,10 @@ function clampPosition(raw: number | undefined, max: number): number {
377
377
 
378
378
  // ── Delete ────────────────────────────────────────────────────────
379
379
 
380
- export function handleDeleteItem(items: TodoItem[], id: string): ItemsActionResult {
381
- const target = items.find((i) => i.id === id);
380
+ export function handleDeleteItem(items: TodoItem[], itemId: string): ItemsActionResult {
381
+ const target = items.find((item) => item.id === itemId);
382
382
  if (!target) {
383
- return { kind: "error", status: 404, error: `item not found: ${id}` };
383
+ return { kind: "error", status: 404, error: `item not found: ${itemId}` };
384
384
  }
385
- return { kind: "success", items: items.filter((item) => item.id !== id) };
385
+ return { kind: "success", items: items.filter((item) => item.id !== itemId) };
386
386
  }
@@ -4,6 +4,7 @@ import { WORKSPACE_PATHS } from "../../workspace/paths.js";
4
4
  import { readTextSafeSync, readTextSafe } from "../../utils/files/safe.js";
5
5
  import { getPageIndex } from "./wiki/pageIndex.js";
6
6
  import { badRequest } from "../../utils/httpError.js";
7
+ import { getOptionalStringQuery } from "../../utils/request.js";
7
8
  import { API_ROUTES } from "../../../src/config/apiRoutes.js";
8
9
 
9
10
  const router = Router();
@@ -132,21 +133,34 @@ async function resolvePagePath(pageName: string): Promise<string | null> {
132
133
 
133
134
  const slug = wikiSlugify(pageName);
134
135
 
135
- const exact = slugs.get(slug);
136
- if (exact) return path.join(dir, exact);
136
+ if (slug.length > 0) {
137
+ const exact = slugs.get(slug);
138
+ if (exact) return path.join(dir, exact);
137
139
 
138
- // Fuzzy: same `includes` semantics as the old sync path — iterate
139
- // the index's keys, no filesystem access.
140
- for (const [key, file] of slugs) {
141
- if (slug.includes(key) || key.includes(slug)) {
142
- return path.join(dir, file);
140
+ // Fuzzy: same `includes` semantics as the old sync path — iterate
141
+ // the index's keys, no filesystem access.
142
+ for (const [key, file] of slugs) {
143
+ if (slug.includes(key) || key.includes(slug)) {
144
+ return path.join(dir, file);
145
+ }
143
146
  }
144
147
  }
148
+
149
+ // Non-ASCII page names (e.g. Japanese [[wiki links]]) produce empty
150
+ // slugs after slugification. Fall back to matching by title in the
151
+ // wiki index so the link resolves to its page file.
152
+ const indexContent = readFileOrEmpty(indexFile());
153
+ const entries = parseIndexEntries(indexContent);
154
+ const titleMatch = entries.find((entry) => entry.title === pageName);
155
+ if (titleMatch && slugs.has(titleMatch.slug)) {
156
+ return path.join(dir, slugs.get(titleMatch.slug)!);
157
+ }
158
+
145
159
  return null;
146
160
  }
147
161
 
148
162
  router.get(API_ROUTES.wiki.base, async (req: Request, res: Response<WikiResponse | ErrorResponse>) => {
149
- const slug = typeof req.query.slug === "string" ? req.query.slug : undefined;
163
+ const slug = getOptionalStringQuery(req, "slug");
150
164
  if (slug) {
151
165
  const filePath = await resolvePagePath(slug);
152
166
  const content = filePath ? readFileOrEmpty(filePath) : "";
@@ -59,6 +59,6 @@ export function buildSandboxStatus(params: BuildSandboxStatusParams): SandboxSta
59
59
 
60
60
  return {
61
61
  sshAgent,
62
- mounts: parsed.resolved.map((m) => m.name),
62
+ mounts: parsed.resolved.map((mount) => mount.name),
63
63
  };
64
64
  }
@@ -32,8 +32,8 @@ export interface NotificationDeps {
32
32
 
33
33
  let deps: NotificationDeps | null = null;
34
34
 
35
- export function initNotifications(d: NotificationDeps): void {
36
- deps = d;
35
+ export function initNotifications(injected: NotificationDeps): void {
36
+ deps = injected;
37
37
  }
38
38
 
39
39
  // ── In-memory store ─────────────────────────────────────────────
@@ -97,10 +97,10 @@ export function publishNotification(opts: PublishNotificationOpts): void {
97
97
  }
98
98
  }
99
99
 
100
- function formatBridgeMessage(p: NotificationPayload): string {
101
- const icon = p.kind === NOTIFICATION_KINDS.agent ? "\u2705" : "\u{1F514}";
102
- const parts = [icon, p.title];
103
- if (p.body) parts.push(p.body);
100
+ function formatBridgeMessage(payload: NotificationPayload): string {
101
+ const icon = payload.kind === NOTIFICATION_KINDS.agent ? "\u2705" : "\u{1F514}";
102
+ const parts = [icon, payload.title];
103
+ if (payload.body) parts.push(payload.body);
104
104
  return parts.join(" ");
105
105
  }
106
106
 
@@ -13,7 +13,7 @@ export interface IPubSub {
13
13
  // itself, which is why we switched off raw ws.
14
14
 
15
15
  export function createPubSub(server: http.Server): IPubSub {
16
- const io = new IOServer(server, {
16
+ const ioServer = new IOServer(server, {
17
17
  path: "/ws/pubsub",
18
18
  // Server binds to 127.0.0.1 only, so CORS is moot — but
19
19
  // socket.io defaults to rejecting cross-origin upgrade
@@ -28,7 +28,7 @@ export function createPubSub(server: http.Server): IPubSub {
28
28
  transports: ["websocket"],
29
29
  });
30
30
 
31
- io.on("connection", (socket) => {
31
+ ioServer.on("connection", (socket) => {
32
32
  socket.on("subscribe", (channel: unknown) => {
33
33
  if (typeof channel === "string") socket.join(channel);
34
34
  });
@@ -39,7 +39,7 @@ export function createPubSub(server: http.Server): IPubSub {
39
39
 
40
40
  return {
41
41
  publish(channel: string, data: unknown): void {
42
- io.to(channel).emit("data", { channel, data });
42
+ ioServer.to(channel).emit("data", { channel, data });
43
43
  },
44
44
  };
45
45
  }
@@ -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
  }
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/*`
@@ -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,23 +255,35 @@ 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");
@@ -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);
@@ -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 {
@@ -6,7 +6,7 @@
6
6
  // Moved from server/utils/file.ts (issue #366 Phase 1). The old
7
7
  // file re-exports these for backwards compat.
8
8
 
9
- import fs from "fs";
9
+ import { mkdirSync, promises, renameSync, unlinkSync, writeFileSync } from "fs";
10
10
  import path from "path";
11
11
  import { randomUUID } from "crypto";
12
12
 
@@ -50,7 +50,7 @@ function isTransientRenameError(err: unknown): boolean {
50
50
  async function renameWithWindowsRetry(fromPath: string, toPath: string): Promise<void> {
51
51
  for (const delayMs of RENAME_RETRY_DELAYS_MS) {
52
52
  try {
53
- await fs.promises.rename(fromPath, toPath);
53
+ await promises.rename(fromPath, toPath);
54
54
  return;
55
55
  } catch (err) {
56
56
  if (!isTransientRenameError(err)) throw err;
@@ -58,7 +58,7 @@ async function renameWithWindowsRetry(fromPath: string, toPath: string): Promise
58
58
  }
59
59
  }
60
60
  // Final attempt — let any error propagate.
61
- await fs.promises.rename(fromPath, toPath);
61
+ await promises.rename(fromPath, toPath);
62
62
  }
63
63
 
64
64
  // Sync sleep that parks the thread instead of burning CPU. Only
@@ -73,14 +73,14 @@ function sleepSync(millis: number): void {
73
73
  function renameSyncWithWindowsRetry(fromPath: string, toPath: string): void {
74
74
  for (const delayMs of RENAME_RETRY_DELAYS_MS) {
75
75
  try {
76
- fs.renameSync(fromPath, toPath);
76
+ renameSync(fromPath, toPath);
77
77
  return;
78
78
  } catch (err) {
79
79
  if (!isTransientRenameError(err)) throw err;
80
80
  sleepSync(delayMs);
81
81
  }
82
82
  }
83
- fs.renameSync(fromPath, toPath);
83
+ renameSync(fromPath, toPath);
84
84
  }
85
85
 
86
86
  /**
@@ -90,15 +90,15 @@ function renameSyncWithWindowsRetry(fromPath: string, toPath: string): void {
90
90
  */
91
91
  export async function writeFileAtomic(filePath: string, content: string, opts: WriteAtomicOptions = {}): Promise<void> {
92
92
  const tmp = opts.uniqueTmp ? `${filePath}.${randomUUID()}.tmp` : `${filePath}.tmp`;
93
- await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
93
+ await promises.mkdir(path.dirname(filePath), { recursive: true });
94
94
  try {
95
- await fs.promises.writeFile(tmp, content, {
95
+ await promises.writeFile(tmp, content, {
96
96
  encoding: "utf-8",
97
97
  mode: opts.mode,
98
98
  });
99
99
  await renameWithWindowsRetry(tmp, filePath);
100
100
  } catch (err) {
101
- await fs.promises.unlink(tmp).catch(() => {});
101
+ await promises.unlink(tmp).catch(() => {});
102
102
  throw err;
103
103
  }
104
104
  }
@@ -110,13 +110,13 @@ export async function writeFileAtomic(filePath: string, content: string, opts: W
110
110
  */
111
111
  export function writeFileAtomicSync(filePath: string, content: string, opts: WriteAtomicOptions = {}): void {
112
112
  const tmp = opts.uniqueTmp ? `${filePath}.${randomUUID()}.tmp` : `${filePath}.tmp`;
113
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
113
+ mkdirSync(path.dirname(filePath), { recursive: true });
114
114
  try {
115
- fs.writeFileSync(tmp, content, { encoding: "utf-8", mode: opts.mode });
115
+ writeFileSync(tmp, content, { encoding: "utf-8", mode: opts.mode });
116
116
  renameSyncWithWindowsRetry(tmp, filePath);
117
117
  } catch (err) {
118
118
  try {
119
- fs.unlinkSync(tmp);
119
+ unlinkSync(tmp);
120
120
  } catch {
121
121
  // best-effort cleanup
122
122
  }