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.
- package/bin/mulmoclaude.js +7 -24
- package/client/assets/html2canvas-Cx501zZr-Cv5snK9D.js +5 -0
- package/client/assets/index-CubzmCVK.css +2 -0
- package/client/assets/{index-eHWB79u5.js → index-DtcyExH9.js} +80 -61
- package/client/assets/{index.es-D4YyL_Dg-BfRHLTZV.js → index.es-D4YyL_Dg-DnizuhIY.js} +5 -5
- package/client/index.html +2 -4
- package/package.json +13 -13
- package/server/agent/attachmentConverter.ts +2 -2
- package/server/agent/index.ts +9 -3
- package/server/agent/mcp-tools/index.ts +6 -6
- package/server/agent/mcp-tools/x.ts +2 -1
- package/server/agent/prompt.ts +187 -26
- package/server/agent/resumeFailover.ts +5 -5
- package/server/agent/sandboxMounts.ts +3 -3
- package/server/api/auth/bearerAuth.ts +3 -3
- package/server/api/auth/token.ts +2 -2
- package/server/api/routes/agent.ts +21 -3
- package/server/api/routes/config.ts +1 -1
- package/server/api/routes/files.ts +13 -12
- package/server/api/routes/html.ts +2 -2
- package/server/api/routes/image.ts +7 -7
- package/server/api/routes/mulmo-script.ts +33 -31
- package/server/api/routes/pdf.ts +2 -2
- package/server/api/routes/plugins.ts +16 -6
- package/server/api/routes/roles.ts +2 -2
- package/server/api/routes/scheduler.ts +8 -6
- package/server/api/routes/schedulerTasks.ts +5 -3
- package/server/api/routes/sessions.ts +2 -2
- package/server/api/routes/sessionsCursor.ts +4 -4
- package/server/api/routes/skills.ts +5 -5
- package/server/api/routes/sources.ts +3 -3
- package/server/api/routes/todosHandlers.ts +1 -1
- package/server/api/routes/todosItemsHandlers.ts +14 -14
- package/server/api/routes/wiki.ts +22 -8
- package/server/api/sandboxStatus.ts +1 -1
- package/server/events/notifications.ts +6 -6
- package/server/events/pub-sub/index.ts +3 -3
- package/server/events/relay-client.ts +17 -16
- package/server/index.ts +40 -46
- package/server/system/config.ts +5 -5
- package/server/system/credentials.ts +7 -5
- package/server/system/env.ts +5 -5
- package/server/utils/files/atomic.ts +11 -11
- package/server/utils/files/image-store.ts +17 -6
- package/server/utils/files/journal-io.ts +2 -2
- package/server/utils/files/json.ts +5 -5
- package/server/utils/files/markdown-store.ts +4 -4
- package/server/utils/files/reference-dirs-io.ts +3 -3
- package/server/utils/files/roles-io.ts +4 -4
- package/server/utils/files/safe.ts +14 -14
- package/server/utils/files/scheduler-overrides-io.ts +2 -2
- package/server/utils/files/spreadsheet-store.ts +5 -5
- package/server/utils/files/workspace-io.ts +12 -12
- package/server/utils/gemini.ts +2 -2
- package/server/utils/gitignore.ts +9 -9
- package/server/utils/json.ts +5 -5
- package/server/utils/logBackgroundError.ts +12 -3
- package/server/utils/markdown.ts +5 -5
- package/server/utils/port.d.mts +6 -0
- package/server/utils/port.mjs +48 -0
- package/server/utils/request.ts +12 -6
- package/server/utils/spawn.ts +1 -1
- package/server/utils/types.ts +2 -2
- package/server/workspace/chat-index/summarizer.ts +4 -4
- package/server/workspace/custom-dirs.ts +5 -5
- package/server/workspace/journal/diff.ts +2 -2
- package/server/workspace/journal/index.ts +4 -4
- package/server/workspace/journal/optimizationPass.ts +2 -2
- package/server/workspace/journal/state.ts +6 -6
- package/server/workspace/paths.ts +3 -3
- package/server/workspace/reference-dirs.ts +3 -3
- package/server/workspace/skills/parser.ts +6 -6
- package/server/workspace/skills/scheduler.ts +3 -3
- package/server/workspace/skills/writer.ts +3 -3
- package/server/workspace/sources/arxivDiscovery.ts +2 -2
- package/server/workspace/sources/fetchers/rss.ts +5 -5
- package/server/workspace/sources/fetchers/rssParser.ts +4 -4
- package/server/workspace/sources/interests.ts +3 -3
- package/server/workspace/sources/paths.ts +6 -6
- package/server/workspace/sources/pipeline/fetch.ts +36 -13
- package/server/workspace/sources/pipeline/index.ts +2 -7
- package/server/workspace/sources/pipeline/notify.ts +3 -3
- package/server/workspace/sources/pipeline/plan.ts +11 -9
- package/server/workspace/sources/pipeline/write.ts +5 -5
- package/server/workspace/sources/rateLimiter.ts +1 -1
- package/server/workspace/sources/sourceState.ts +9 -4
- package/server/workspace/sources/types.ts +9 -0
- package/server/workspace/sources/urls.ts +1 -1
- package/server/workspace/tool-trace/classify.ts +4 -4
- package/server/workspace/workspace.ts +7 -7
- package/src/App.vue +286 -112
- package/src/components/CanvasViewToggle.vue +10 -7
- package/src/components/ChatInput.vue +60 -26
- package/src/components/FileContentHeader.vue +7 -4
- package/src/components/FileContentRenderer.vue +20 -6
- package/src/components/FileTree.vue +6 -3
- package/src/components/FileTreePane.vue +11 -8
- package/src/components/FilesView.vue +5 -3
- package/src/components/LockStatusPopup.vue +15 -12
- package/src/components/NotificationBell.vue +14 -5
- package/src/components/NotificationToast.vue +4 -1
- package/src/components/PluginLauncher.vue +19 -56
- package/src/components/RightSidebar.vue +13 -10
- package/src/components/SessionHistoryPanel.vue +33 -29
- package/src/components/SessionTabBar.vue +8 -10
- package/src/components/SettingsMcpTab.vue +43 -30
- package/src/components/SettingsModal.vue +21 -19
- package/src/components/SettingsReferenceDirsTab.vue +29 -24
- package/src/components/SettingsWorkspaceDirsTab.vue +32 -22
- package/src/components/SidebarHeader.vue +25 -4
- package/src/components/StackView.vue +4 -1
- package/src/components/SuggestionsPanel.vue +5 -2
- package/src/components/TodoExplorer.vue +26 -15
- package/src/components/ToolResultsPanel.vue +27 -13
- package/src/components/todo/TodoAddDialog.vue +17 -12
- package/src/components/todo/TodoEditDialog.vue +7 -2
- package/src/components/todo/TodoEditPanel.vue +15 -10
- package/src/components/todo/TodoKanbanView.vue +10 -5
- package/src/components/todo/TodoListView.vue +5 -2
- package/src/components/todo/TodoTableView.vue +5 -2
- package/src/composables/useAppApi.ts +9 -0
- package/src/composables/useDynamicFavicon.ts +172 -37
- package/src/composables/useEventListeners.ts +7 -8
- package/src/composables/useFaviconState.ts +13 -2
- package/src/composables/useFileSelection.ts +24 -6
- package/src/composables/useLayoutMode.ts +32 -0
- package/src/composables/useSessionHistory.ts +7 -17
- package/src/composables/useViewLayout.ts +20 -34
- package/src/lang/de.ts +536 -0
- package/src/lang/en.ts +558 -0
- package/src/lang/es.ts +543 -0
- package/src/lang/fr.ts +536 -0
- package/src/lang/ja.ts +536 -0
- package/src/lang/ko.ts +540 -0
- package/src/lang/pt-BR.ts +534 -0
- package/src/lang/zh.ts +537 -0
- package/src/lib/vue-i18n.ts +97 -0
- package/src/main.ts +2 -0
- package/src/plugins/canvas/View.vue +102 -186
- package/src/plugins/canvas/definition.ts +0 -8
- package/src/plugins/chart/Preview.vue +1 -1
- package/src/plugins/chart/View.vue +9 -4
- package/src/plugins/manageRoles/Preview.vue +4 -1
- package/src/plugins/manageRoles/View.vue +59 -43
- package/src/plugins/manageSkills/Preview.vue +8 -3
- package/src/plugins/manageSkills/View.vue +26 -22
- package/src/plugins/manageSource/Preview.vue +1 -1
- package/src/plugins/manageSource/View.vue +73 -52
- package/src/plugins/markdown/Preview.vue +1 -1
- package/src/plugins/markdown/View.vue +24 -34
- package/src/plugins/presentHtml/Preview.vue +1 -1
- package/src/plugins/presentHtml/View.vue +7 -4
- package/src/plugins/presentMulmoScript/Preview.vue +1 -1
- package/src/plugins/presentMulmoScript/View.vue +36 -26
- package/src/plugins/scheduler/Preview.vue +7 -4
- package/src/plugins/scheduler/TasksTab.vue +53 -24
- package/src/plugins/scheduler/View.vue +28 -19
- package/src/plugins/scheduler/formatSchedule.ts +93 -0
- package/src/plugins/spreadsheet/Preview.vue +8 -3
- package/src/plugins/spreadsheet/View.vue +21 -12
- package/src/plugins/textResponse/Preview.vue +15 -58
- package/src/plugins/textResponse/View.vue +27 -7
- package/src/plugins/todo/Preview.vue +11 -6
- package/src/plugins/todo/View.vue +27 -13
- package/src/plugins/ui-image/ImagePreview.vue +6 -3
- package/src/plugins/ui-image/ImageView.vue +7 -4
- package/src/plugins/wiki/Preview.vue +5 -2
- package/src/plugins/wiki/View.vue +202 -81
- package/src/plugins/wiki/route.ts +112 -0
- package/src/router/guards.ts +42 -24
- package/src/router/index.ts +41 -26
- package/src/types/vue-i18n.d.ts +20 -0
- package/src/utils/agent/request.ts +19 -0
- package/src/utils/canvas/layoutMode.ts +26 -0
- package/src/utils/image/cacheBust.ts +16 -0
- package/src/utils/image/resolve.ts +16 -0
- package/src/utils/path/workspaceLinkRouter.ts +81 -0
- package/src/vite-env.d.ts +9 -0
- package/client/assets/chunk-vKJrgz-R-C_I3GbVV.js +0 -1
- package/client/assets/html2canvas-Cx501zZr-BF5dYYkY.js +0 -5
- package/client/assets/index-Bm70FDU2.css +0 -1
- package/client/assets/typeof-DBp4T-Ny-BC0P-2DM.js +0 -1
- package/src/composables/useCanvasViewMode.ts +0 -121
- package/src/utils/canvas/viewMode.ts +0 -46
- /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[],
|
|
285
|
-
const target = items.find((
|
|
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: ${
|
|
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 ===
|
|
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[],
|
|
327
|
-
const target = items.find((
|
|
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: ${
|
|
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 !==
|
|
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 ===
|
|
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 ===
|
|
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[],
|
|
381
|
-
const target = items.find((
|
|
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: ${
|
|
383
|
+
return { kind: "error", status: 404, error: `item not found: ${itemId}` };
|
|
384
384
|
}
|
|
385
|
-
return { kind: "success", items: items.filter((item) => item.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
|
-
|
|
136
|
-
|
|
136
|
+
if (slug.length > 0) {
|
|
137
|
+
const exact = slugs.get(slug);
|
|
138
|
+
if (exact) return path.join(dir, exact);
|
|
137
139
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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 =
|
|
163
|
+
const slug = getOptionalStringQuery(req, "slug");
|
|
150
164
|
if (slug) {
|
|
151
165
|
const filePath = await resolvePagePath(slug);
|
|
152
166
|
const content = filePath ? readFileOrEmpty(filePath) : "";
|
|
@@ -32,8 +32,8 @@ export interface NotificationDeps {
|
|
|
32
32
|
|
|
33
33
|
let deps: NotificationDeps | null = null;
|
|
34
34
|
|
|
35
|
-
export function initNotifications(
|
|
36
|
-
deps =
|
|
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(
|
|
101
|
-
const icon =
|
|
102
|
-
const parts = [icon,
|
|
103
|
-
if (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 `
|
|
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
|
|
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
|
-
|
|
107
|
+
socket = new WebSocket(buildUrl());
|
|
107
108
|
} catch (err) {
|
|
108
109
|
logger.error(LOG_PREFIX, "failed to create WebSocket", {
|
|
109
|
-
error:
|
|
110
|
+
error: errorMessage(err),
|
|
110
111
|
});
|
|
111
112
|
scheduleReconnect();
|
|
112
113
|
return;
|
|
113
114
|
}
|
|
114
115
|
|
|
115
|
-
|
|
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
|
-
|
|
122
|
+
socket.on("message", (data) => {
|
|
122
123
|
handleMessage(String(data));
|
|
123
124
|
});
|
|
124
125
|
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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:
|
|
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 (!
|
|
231
|
+
if (!socket || socket.readyState !== WebSocket.OPEN) return false;
|
|
231
232
|
try {
|
|
232
|
-
|
|
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 (!
|
|
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 (
|
|
278
|
-
|
|
279
|
-
|
|
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
|
|
38
|
-
import
|
|
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 =
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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(
|
|
274
|
-
if (
|
|
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((
|
|
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((
|
|
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
|
|
360
|
-
log.info("server", "listening", { 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((
|
|
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((
|
|
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
|
|
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(
|
|
539
|
-
startRuntimeServices(httpServer);
|
|
532
|
+
const httpServer = app.listen(port, "127.0.0.1", () => {
|
|
533
|
+
startRuntimeServices(httpServer, port);
|
|
540
534
|
});
|
|
541
535
|
})();
|
|
542
536
|
|
package/server/system/config.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 [
|
|
185
|
-
if (!isMcpServerId(
|
|
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(([
|
|
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
|
|
125
|
-
if (
|
|
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 =
|
|
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
|
-
|
|
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) {
|
package/server/system/env.ts
CHANGED
|
@@ -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
|
|
23
|
-
if (!Number.isInteger(
|
|
24
|
-
if (opts.min !== undefined &&
|
|
25
|
-
if (opts.max !== undefined &&
|
|
26
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
93
|
+
await promises.mkdir(path.dirname(filePath), { recursive: true });
|
|
94
94
|
try {
|
|
95
|
-
await
|
|
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
|
|
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
|
-
|
|
113
|
+
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
114
114
|
try {
|
|
115
|
-
|
|
115
|
+
writeFileSync(tmp, content, { encoding: "utf-8", mode: opts.mode });
|
|
116
116
|
renameSyncWithWindowsRetry(tmp, filePath);
|
|
117
117
|
} catch (err) {
|
|
118
118
|
try {
|
|
119
|
-
|
|
119
|
+
unlinkSync(tmp);
|
|
120
120
|
} catch {
|
|
121
121
|
// best-effort cleanup
|
|
122
122
|
}
|