mulmoclaude 0.6.2 → 0.6.3

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 (94) hide show
  1. package/README.md +26 -0
  2. package/bin/mulmoclaude.js +11 -1
  3. package/client/assets/chunk-D8eiyYIV-CW0rPbG2.js +1 -0
  4. package/client/assets/{html2canvas-CDGcmOD3-Bkf2uOth.js → html2canvas-CDGcmOD3-BjwfzAN8.js} +1 -1
  5. package/client/assets/index-Bp1owZ-i.js +5101 -0
  6. package/client/assets/index-c63H1pnd.css +2 -0
  7. package/client/assets/{index.es-DqtpmBm8-D9mAh_KQ.js → index.es-DqtpmBm8-DudYPW7R.js} +1 -1
  8. package/client/assets/material-symbols-outlined-C0dZ3SlO.woff2 +0 -0
  9. package/client/assets/runtime-protocol-vue-BUk5WXSy.js +1 -0
  10. package/client/assets/{runtime-vue-BVUzgYGA.js → runtime-vue-fFYhnNg3.js} +1 -1
  11. package/client/assets/{vue-C8UuIO9J.js → vue-Kqzpl9Vx.js} +1 -1
  12. package/client/assets/vue.runtime.esm-bundler-BTyIdNAI.js +4 -0
  13. package/client/index.html +9 -11
  14. package/package.json +5 -4
  15. package/server/agent/backend/claude-code.ts +34 -0
  16. package/server/agent/backend/fake-echo.ts +370 -0
  17. package/server/agent/backend/index.ts +16 -1
  18. package/server/agent/config.ts +8 -1
  19. package/server/agent/mcpFailureMonitor.ts +167 -0
  20. package/server/agent/mcpPreflight.ts +185 -0
  21. package/server/agent/stream.ts +12 -1
  22. package/server/api/routes/mulmo-script.ts +19 -1
  23. package/server/api/routes/schedulerHandlers.ts +52 -4
  24. package/server/api/routes/sessions.ts +15 -0
  25. package/server/api/routes/skills.ts +263 -0
  26. package/server/events/notifications.ts +19 -91
  27. package/server/index.ts +87 -9
  28. package/server/notifier/macosReminderAdapter.ts +30 -0
  29. package/server/system/announceOptionalDeps.ts +50 -0
  30. package/server/system/config.ts +8 -1
  31. package/server/system/docker.ts +14 -6
  32. package/server/system/env.ts +18 -5
  33. package/server/system/optionalDeps.ts +129 -0
  34. package/server/utils/cli-flags.d.mts +14 -0
  35. package/server/utils/cli-flags.mjs +53 -0
  36. package/server/utils/time.ts +6 -0
  37. package/server/workspace/helps/business.md +2 -2
  38. package/server/workspace/helps/mulmoscript.md +3 -3
  39. package/server/workspace/helps/sandbox.md +2 -2
  40. package/server/workspace/hooks/dispatcher.mjs +1 -1
  41. package/server/workspace/paths.ts +13 -4
  42. package/server/workspace/skills/catalog.ts +355 -0
  43. package/server/workspace/skills/external/catalog.ts +283 -0
  44. package/server/workspace/skills/external/clone.ts +129 -0
  45. package/server/workspace/skills/external/id.ts +194 -0
  46. package/server/workspace/skills/external/install.ts +417 -0
  47. package/server/workspace/skills/external/presets.ts +50 -0
  48. package/server/workspace/skills-preset.ts +29 -17
  49. package/server/workspace/workspace.ts +10 -5
  50. package/src/App.vue +19 -8
  51. package/src/components/RightSidebar.vue +19 -0
  52. package/src/components/StackView.vue +10 -1
  53. package/src/config/apiRoutes.ts +0 -6
  54. package/src/config/roles.ts +2 -0
  55. package/src/lang/de.ts +50 -1
  56. package/src/lang/en.ts +49 -1
  57. package/src/lang/es.ts +49 -1
  58. package/src/lang/fr.ts +49 -1
  59. package/src/lang/ja.ts +49 -1
  60. package/src/lang/ko.ts +49 -1
  61. package/src/lang/pt-BR.ts +49 -1
  62. package/src/lang/zh.ts +49 -1
  63. package/src/plugins/manageSkills/View.vue +795 -30
  64. package/src/plugins/manageSkills/categories.ts +125 -0
  65. package/src/plugins/manageSkills/meta.ts +30 -0
  66. package/src/plugins/markdown/definition.ts +3 -3
  67. package/src/plugins/meta-types.ts +5 -0
  68. package/src/plugins/presentMulmoScript/Preview.vue +3 -3
  69. package/src/plugins/presentMulmoScript/View.vue +157 -33
  70. package/src/plugins/presentMulmoScript/meta.ts +4 -0
  71. package/src/plugins/scheduler/View.vue +45 -9
  72. package/src/plugins/scheduler/calendarDefinition.ts +6 -2
  73. package/src/plugins/scheduler/multiDayHelpers.ts +95 -0
  74. package/src/plugins/spreadsheet/View.vue +3 -3
  75. package/src/types/notification.ts +1 -1
  76. package/src/types/session.ts +6 -0
  77. package/src/types/sse.ts +5 -0
  78. package/src/types/toolCallHistory.ts +7 -0
  79. package/src/utils/agent/eventDispatch.ts +26 -5
  80. package/src/utils/agent/mcpHint.ts +50 -0
  81. package/src/utils/session/sessionEntries.ts +8 -32
  82. package/client/assets/PluginScopedRoot-YjvQq0Nn.js +0 -3
  83. package/client/assets/chunk-CernVdwh.js +0 -1
  84. package/client/assets/chunk-D8eiyYIV-CAXpUwLd.js +0 -1
  85. package/client/assets/index-BwrlMMHr.js +0 -5005
  86. package/client/assets/index-CvvNuegU.css +0 -2
  87. package/client/assets/material-symbols-outlined-BOZVWuR3.woff2 +0 -0
  88. package/client/assets/runtime-protocol-vue-C1To4M3t.js +0 -1
  89. package/client/assets/vue.runtime.esm-bundler-DQ8Kjjui.js +0 -4
  90. package/server/api/routes/notifications.ts +0 -195
  91. package/server/notifier/legacy-adapters.ts +0 -76
  92. package/src/composables/useSelectedResult.ts +0 -49
  93. /package/client/assets/{purify.es-Fx1Nqyry-Dwtk-9WZ.js → purify.es-Fx1Nqyry-B3aL7Uvj.js} +0 -0
  94. /package/client/assets/{typeof-DBp4T-Ny-CSr8wx1e.js → typeof-DBp4T-Ny-Bef7RiR_.js} +0 -0
@@ -2,7 +2,7 @@
2
2
  // wrapper over the new notifier engine (PR 4 of feat-encore).
3
3
  //
4
4
  // The signature is preserved so the existing host call sites
5
- // (`server/agent/mcp-tools/notify.ts`, `server/api/routes/notifications.ts`,
5
+ // (`server/agent/mcp-tools/notify.ts`,
6
6
  // `server/workspace/sources/pipeline/notify.ts`,
7
7
  // `server/plugins/diagnostics.ts`) keep working without source changes.
8
8
  // Internally it now:
@@ -16,14 +16,17 @@
16
16
  // legacy entries publish with `lifecycle: "fyi"`, so the
17
17
  // `action`-lifecycle rules don't apply and clicking still routes).
18
18
  // 4. Stashes the legacy fields (`kind`, `priority`, `action`,
19
- // `i18n`, `transportId`, `sessionId`, the caller-supplied dedup
20
- // `id`) on `pluginData` so the bell can preserve icon, i18n
21
- // localization, and transport routing.
19
+ // `i18n`, `sessionId`, the caller-supplied dedup `id`) on
20
+ // `pluginData` so the bell can preserve icon, i18n localization,
21
+ // and dedup.
22
22
  //
23
- // Bridge push (`chat-service.pushToBridge`) and macOS Reminder push
24
- // no longer happen inline here they're owned by adapters that
25
- // subscribe to the `notifier` pubsub channel. See
26
- // `server/notifier/legacy-adapters.ts`.
23
+ // macOS Reminder push happens via the `macosReminderAdapter` listener
24
+ // subscribed to the notifier pubsub channel. The previous bridge
25
+ // fan-out path was removed (#1351 follow-up): the only callers
26
+ // setting `transportId` were the PoC `/api/notifications/test` route
27
+ // and `scheduleTestNotification`, both deleted in the same change.
28
+ // Production callers never set `transportId`, so the entire bridge
29
+ // side-channel was dead code.
27
30
 
28
31
  import { PAGE_ROUTES } from "../../src/router/pageRoutes.js";
29
32
  import {
@@ -38,7 +41,6 @@ import {
38
41
  } from "../../src/types/notification.js";
39
42
  import { publish as notifierPublish } from "../notifier/engine.js";
40
43
  import type { NotifierSeverity } from "../notifier/types.js";
41
- import { ONE_SECOND_MS, MAX_NOTIFICATION_DELAY_SEC } from "../utils/time.js";
42
44
  import { log } from "../system/logger/index.js";
43
45
  import { makeUuid } from "../utils/id.js";
44
46
 
@@ -51,7 +53,6 @@ export interface PublishNotificationOpts {
51
53
  action?: NotificationAction;
52
54
  priority?: NotificationPriority;
53
55
  sessionId?: string;
54
- transportId?: string;
55
56
  /** Override the auto-generated UUID with a caller-supplied stable
56
57
  * id. Used by the plugin-meta diagnostics: the same diagnostic
57
58
  * id is returned from `/api/plugins/diagnostics`, and `pluginData`
@@ -60,7 +61,7 @@ export interface PublishNotificationOpts {
60
61
  id?: string;
61
62
  /** vue-i18n keys + params for clients to localize the title/body.
62
63
  * Server-side `title` / `body` stay set as English fallbacks for
63
- * logs, macOS Reminder push, and bridge push paths. */
64
+ * logs and the macOS Reminder push. */
64
65
  i18n?: NotificationI18n;
65
66
  }
66
67
 
@@ -79,7 +80,6 @@ export interface LegacyNotifierPluginData {
79
80
  priority: NotificationPriority;
80
81
  action: NotificationAction;
81
82
  i18n?: NotificationI18n;
82
- transportId?: string;
83
83
  sessionId?: string;
84
84
  }
85
85
 
@@ -155,9 +155,7 @@ function buildChatTarget(target: Extract<NavigateTarget, { view: typeof NOTIFICA
155
155
  // redirect is worse UX than a non-clickable entry. Dot-segment
156
156
  // sessionId would normalize off /chat, so drop too.
157
157
  if (!target.sessionId || !isSafePathComponent(target.sessionId)) return undefined;
158
- const sessionPath = `/${PAGE_ROUTES.chat}/${encodeURIComponent(target.sessionId)}`;
159
- if (target.resultUuid) return `${sessionPath}?result=${encodeURIComponent(target.resultUuid)}`;
160
- return sessionPath;
158
+ return `/${PAGE_ROUTES.chat}/${encodeURIComponent(target.sessionId)}`;
161
159
  }
162
160
 
163
161
  function buildFilesTarget(target: Extract<NavigateTarget, { view: typeof NOTIFICATION_VIEWS.files }>): string {
@@ -233,12 +231,12 @@ export function legacyActionToNavigateTarget(action: NotificationAction | undefi
233
231
  * to the calling plugin so plugins cannot impersonate each other.
234
232
  *
235
233
  * This wrapper exists for host-side callers (`server/agent/`,
236
- * `server/api/routes/`, `server/workspace/`, `server/plugins/diagnostics.ts`)
237
- * that don't have a `PluginRuntime` to hand. It forwards into
238
- * `notifier.publish` with `lifecycle: "fyi"` and stashes legacy fields
239
- * on `pluginData` so the bell can preserve icon / i18n / transport
240
- * semantics. Bridge + macOS push are owned by separate adapters
241
- * subscribed to the `notifier` pubsub channel, not by this function.
234
+ * `server/workspace/`, `server/plugins/diagnostics.ts`) that don't
235
+ * have a `PluginRuntime` to hand. It forwards into `notifier.publish`
236
+ * with `lifecycle: "fyi"` and stashes legacy fields on `pluginData`
237
+ * so the bell can preserve icon / i18n / dedup semantics. macOS
238
+ * Reminder push is owned by the adapter subscribed to the notifier
239
+ * pubsub channel, not by this function.
242
240
  */
243
241
  export function publishNotification(opts: PublishNotificationOpts): void {
244
242
  const legacyId = opts.id ?? makeUuid();
@@ -250,7 +248,6 @@ export function publishNotification(opts: PublishNotificationOpts): void {
250
248
  priority: opts.priority ?? NOTIFICATION_PRIORITIES.normal,
251
249
  action,
252
250
  i18n: opts.i18n,
253
- transportId: opts.transportId,
254
251
  sessionId: opts.sessionId,
255
252
  };
256
253
  // Fire-and-forget — the engine queues writes through its own
@@ -269,72 +266,3 @@ export function publishNotification(opts: PublishNotificationOpts): void {
269
266
  log.warn("notifications", "publish failed", { error: String(err), legacyId, kind: opts.kind });
270
267
  });
271
268
  }
272
-
273
- // ── Legacy test notification (kept for PoC endpoint) ────────────
274
-
275
- export const DEFAULT_NOTIFICATION_MESSAGE = "Test notification";
276
- export const DEFAULT_NOTIFICATION_TRANSPORT_ID = "cli";
277
-
278
- export interface ScheduleNotificationOptions {
279
- message?: string;
280
- body?: string;
281
- delaySeconds?: number;
282
- transportId?: string;
283
- // Optional deep-link action — lets dev-side callers fire a
284
- // notification that navigates to a specific permalink when
285
- // clicked. Without this the fired notification has no click
286
- // behaviour (same as before #762).
287
- action?: NotificationAction;
288
- // Optional kind override — lets the manual-test helper fire a
289
- // representative notification for every NotificationKind (todo,
290
- // scheduler, agent, …) so the bell's icons can be eyeballed.
291
- kind?: NotificationKind;
292
- }
293
-
294
- export interface ScheduledNotification {
295
- firesAt: string;
296
- delaySeconds: number;
297
- cancel: () => void;
298
- }
299
-
300
- /** PoC: schedules one `publishNotification` after a delay. Bridge
301
- * push is no longer sent inline here — the bridge adapter (subscribed
302
- * to the notifier channel) fans out to bridges based on the entry's
303
- * `pluginData.transportId`. */
304
- export function scheduleTestNotification(opts: ScheduleNotificationOptions): ScheduledNotification {
305
- const message = opts.message ?? DEFAULT_NOTIFICATION_MESSAGE;
306
- const transportId = opts.transportId ?? DEFAULT_NOTIFICATION_TRANSPORT_ID;
307
- const delaySeconds = clampDelay(opts.delaySeconds);
308
- const delayMs = delaySeconds * ONE_SECOND_MS;
309
- const kind = opts.kind ?? NOTIFICATION_KINDS.push;
310
-
311
- const firesAt = new Date(Date.now() + delayMs).toISOString();
312
-
313
- const timer = setTimeout(() => {
314
- publishNotification({
315
- kind,
316
- title: message,
317
- body: opts.body,
318
- priority: NOTIFICATION_PRIORITIES.normal,
319
- action: opts.action,
320
- transportId,
321
- });
322
- }, delayMs);
323
-
324
- return {
325
- firesAt,
326
- delaySeconds,
327
- cancel: () => clearTimeout(timer),
328
- };
329
- }
330
-
331
- const DEFAULT_DELAY_SECONDS = 60;
332
-
333
- function clampDelay(raw: number | undefined): number {
334
- if (typeof raw !== "number" || !Number.isFinite(raw)) {
335
- return DEFAULT_DELAY_SECONDS;
336
- }
337
- if (raw < 0) return 0;
338
- if (raw > MAX_NOTIFICATION_DELAY_SEC) return MAX_NOTIFICATION_DELAY_SEC;
339
- return Math.floor(raw);
340
- }
package/server/index.ts CHANGED
@@ -36,8 +36,9 @@ import { loadPresetPlugins } from "./plugins/preset-loader.js";
36
36
  import { registerRuntimePlugins } from "./plugins/runtime-registry.js";
37
37
  import { makePluginRuntime } from "./plugins/runtime.js";
38
38
  import { MCP_PLUGIN_NAMES } from "./agent/plugin-names.js";
39
- import { createNotificationsRouter } from "./api/routes/notifications.js";
40
- import { startLegacyAdapters } from "./notifier/legacy-adapters.js";
39
+ import { setActiveBackend } from "./agent/backend/index.js";
40
+ import { fakeEchoBackend } from "./agent/backend/fake-echo.js";
41
+ import { startMacosReminderAdapter } from "./notifier/macosReminderAdapter.js";
41
42
  import notifierRoutes from "./api/routes/notifier.js";
42
43
  import { initNotifier } from "./notifier/engine.js";
43
44
  import { registerSaveAttachmentHook } from "./utils/files/attachment-store.js";
@@ -45,6 +46,7 @@ import { capturePhotoLocation } from "./workspace/photo-locations/index.js";
45
46
  import { createJournalRouter } from "./api/routes/journal.js";
46
47
  import { createTranslationRouter } from "./api/routes/translation.js";
47
48
  import { announcePluginMetaDiagnostics } from "./plugins/diagnostics.js";
49
+ import { announceOptionalDeps } from "./system/announceOptionalDeps.js";
48
50
  import { createChatService } from "@mulmobridge/chat-service";
49
51
  import { readSessionJsonl } from "./utils/files/session-io.js";
50
52
  import { onSessionEvent, initSessionStore } from "./events/session-store/index.js";
@@ -56,6 +58,8 @@ import { WORKSPACE_PATHS } from "./workspace/paths.js";
56
58
  import { serverError } from "./utils/httpError.js";
57
59
  import { makeUuid } from "./utils/id.js";
58
60
  import { mcpToolsRouter, mcpTools, isMcpToolEnabled } from "./agent/mcp-tools/index.js";
61
+ import { preflightUserServers, logPreflightResult } from "./agent/mcpPreflight.js";
62
+ import { loadMcpConfig } from "./system/config.js";
59
63
  import { initWorkspace, workspacePath } from "./workspace/workspace.js";
60
64
  import { runMemoryMigrationOnce } from "./workspace/memory/run.js";
61
65
  import { runTopicMigrationOnce } from "./workspace/memory/topic-run.js";
@@ -91,7 +95,7 @@ import { EVENT_TYPES } from "../src/types/events.js";
91
95
  import { SESSION_ORIGINS } from "../src/types/session.js";
92
96
  import { buildHtmlPreviewCsp } from "../src/utils/html/previewCsp.js";
93
97
  import { readAndInjectHtmlArtifact } from "./utils/html/htmlArtifactSplicer.js";
94
- import { ONE_SECOND_MS, ONE_MINUTE_MS, ONE_HOUR_MS, STARTUP_FAILURE_FORCE_EXIT_MS } from "./utils/time.js";
98
+ import { ONE_SECOND_MS, ONE_MINUTE_MS, ONE_HOUR_MS, STARTUP_FAILURE_FORCE_EXIT_MS, FATAL_LOG_FLUSH_MS } from "./utils/time.js";
95
99
  import { isPortFree, findAvailablePort, MAX_PORT_PROBES } from "./utils/port.mjs";
96
100
  import { SCHEDULE_TYPES, MISSED_RUN_POLICIES } from "@receptron/task-scheduler";
97
101
 
@@ -102,6 +106,46 @@ const __dirname = path.dirname(__filename);
102
106
 
103
107
  const debugMode = process.argv.includes("--debug");
104
108
 
109
+ // Global crash diagnostics (#1364). These handlers log loudly so a
110
+ // fatal failure is triagable, then EXIT — keeping the loop running
111
+ // after an uncaught exception is process-unsafe per the Node docs
112
+ // (invariants may already be broken). The launcher / supervisor
113
+ // (Electron wrapper, systemd, etc.) is responsible for restart.
114
+ //
115
+ // The canonical failure this PR set out to fix — missing `claude`
116
+ // on PATH crashing the server via spawn's `error` event — is now
117
+ // caught at the local boundary in `server/agent/backend/claude-code.ts`
118
+ // (an explicit `error` listener turns ENOENT into an AgentEvent).
119
+ // These handlers are the BACKSTOP for anything we missed, not a
120
+ // substitute for local error handling. (Codex review on #1364.)
121
+ //
122
+ // `process.exit(1)` is non-zero so supervisors that branch on exit
123
+ // code treat the bounce as an error condition.
124
+ process.on("uncaughtException", (err) => {
125
+ log.error("uncaughtException", err instanceof Error ? err.message : String(err), {
126
+ stack: err instanceof Error ? err.stack : undefined,
127
+ });
128
+ // Tiny grace so the log line flushes to disk before we exit.
129
+ setTimeout(() => process.exit(1), FATAL_LOG_FLUSH_MS);
130
+ });
131
+ process.on("unhandledRejection", (reason) => {
132
+ log.error("unhandledRejection", reason instanceof Error ? reason.message : String(reason), {
133
+ stack: reason instanceof Error ? reason.stack : undefined,
134
+ });
135
+ setTimeout(() => process.exit(1), FATAL_LOG_FLUSH_MS);
136
+ });
137
+
138
+ // Test-seam: CI runs without a Claude CLI / API key set the
139
+ // MULMOCLAUDE_FAKE_AGENT env var, which swaps in an echo-stub
140
+ // backend so the chat flow still completes. Decided once at boot;
141
+ // the orchestrator reads the active backend with zero per-call
142
+ // overhead. Production callers never trip this branch (no runtime
143
+ // import-time cost beyond the small fake-echo module itself).
144
+ if (process.env.MULMOCLAUDE_FAKE_AGENT === "1") {
145
+ setActiveBackend(fakeEchoBackend);
146
+ log.info("agent", "MULMOCLAUDE_FAKE_AGENT=1 — active backend = fake-echo");
147
+ }
148
+
105
149
  initWorkspace();
106
150
 
107
151
  // Fire-and-forget memory migrations: legacy `memory.md` → atomic
@@ -653,7 +697,6 @@ app.use(chatService.router);
653
697
  // `startRuntimeServices` has it. Calls that arrive before fill-in
654
698
  // (impossible in practice — the HTTP server isn't listening yet)
655
699
  // would no-op on publish but still queue the bridge push.
656
- app.use(createNotificationsRouter());
657
700
  app.use(notifierRoutes);
658
701
  app.use(createJournalRouter());
659
702
  app.use(createTranslationRouter());
@@ -768,6 +811,29 @@ function logMcpStatus(): void {
768
811
  const names = disabledMcpTools.map((toolDef) => `${toolDef.definition.name} (${(toolDef.requiredEnv ?? []).join(", ")})`).join(", ");
769
812
  log.info("mcp", "Unavailable (missing env)", { tools: names });
770
813
  }
814
+ logExternalMcpPreflight();
815
+ }
816
+
817
+ // External MCP servers (the `mcp.json` ones — Notion / GitHub /…)
818
+ // get a separate preflight pass that mirrors the built-in
819
+ // `Available / Unavailable` summary above. Servers with catalog
820
+ // entries whose `required: true` fields are unset are excluded from
821
+ // the config handed to Claude Code (filtered inside
822
+ // `prepareUserServers`); this boot-time log gives the operator one
823
+ // clear startup signal (#1352).
824
+ function logExternalMcpPreflight(): void {
825
+ try {
826
+ const userMcpRaw = loadMcpConfig().mcpServers;
827
+ const preflight = preflightUserServers(userMcpRaw);
828
+ logPreflightResult(preflight, "boot");
829
+ } catch (err) {
830
+ // Best-effort: a broken mcp.json shouldn't take down boot. The
831
+ // per-agent-run path will still attempt the preflight and surface
832
+ // any genuine issue when the user actually starts a chat.
833
+ log.warn("mcp", "preflight at boot failed; will retry per-agent-run", {
834
+ error: err instanceof Error ? err.message : String(err),
835
+ });
836
+ }
771
837
  }
772
838
 
773
839
  function maybeForceJournalRun(): void {
@@ -807,11 +873,9 @@ async function startRuntimeServices(httpServer: ReturnType<typeof app.listen>, p
807
873
  // is forwarded in here so the rest of `startRuntimeServices` can
808
874
  // share the same instance.
809
875
 
810
- // --- Legacy adapters (bridge + macOS Reminder push) ---
811
- // Subscribe in-process to the engine so any `notifier.publish` —
812
- // legacy wrapper or plugin-runtime — triggers the same fan-out the
813
- // legacy `publishNotification()` did inline before PR 4.
814
- startLegacyAdapters({ pushToBridge: chatService.pushToBridge });
876
+ // macOS Reminder adapter wiring lives in the `app.listen` callback,
877
+ // alongside `initNotifier`, so it's subscribed before the first
878
+ // await opens a publish-can-fire-but-no-one's-listening window.
815
879
 
816
880
  // --- Plugin META aggregator diagnostics ---
817
881
  // After the notifier engine is initialized so the wrapper has a
@@ -820,6 +884,12 @@ async function startRuntimeServices(httpServer: ReturnType<typeof app.listen>, p
820
884
  // notification.
821
885
  await announcePluginMetaDiagnostics();
822
886
 
887
+ // --- Optional host-dependency probe (#1385) ---
888
+ // Probes docker / ffmpeg / … once, warns (log + bell) for any
889
+ // missing one so a feature degrading is visible instead of a
890
+ // later opaque crash. Never throws.
891
+ await announceOptionalDeps();
892
+
823
893
  // --- Chat socket transport (Phase A of #268) ---
824
894
  chatService.attachSocket(httpServer);
825
895
 
@@ -1123,6 +1193,14 @@ process.on("SIGTERM", () => {
1123
1193
  initNotifier({
1124
1194
  publish: (channel, payload) => earlyPubsub.publish(channel, payload),
1125
1195
  });
1196
+ // Subscribe the macOS Reminder side-channel BEFORE the first
1197
+ // await below — `initNotifier` opens the engine to publishes,
1198
+ // and any boot-time diagnostic that lands during the
1199
+ // `.server-port` write / `startRuntimeServices` setup would
1200
+ // otherwise miss the Reminder fan-out (CodeRabbit review on
1201
+ // PR #1358). The adapter is sync + no-op outside darwin, so
1202
+ // wiring it here costs nothing.
1203
+ startMacosReminderAdapter();
1126
1204
 
1127
1205
  // Publish the actually-bound port so the hook script can
1128
1206
  // address us — the requested PORT may have walked forward
@@ -0,0 +1,30 @@
1
+ // macOS Reminder side-channel adapter for the notifier engine.
2
+ //
3
+ // Subscribes to the engine's `published` events and fires
4
+ // `pushToMacosReminder` for each one — that helper is itself a no-op
5
+ // outside darwin / when `DISABLE_MACOS_REMINDER_NOTIFICATIONS=1`, so
6
+ // the adapter is safe to start unconditionally.
7
+ //
8
+ // History: this file used to be `legacy-adapters.ts` and carried a
9
+ // second branch that fanned out to chat-service bridges based on a
10
+ // `transportId` field on `pluginData`. That branch was dead code —
11
+ // the only callers setting `transportId` were the PoC
12
+ // `/api/notifications/test` route and `scheduleTestNotification`,
13
+ // both removed in the same change. Real production publishers
14
+ // (mcp-tools/notify, sources/pipeline/notify, plugins/diagnostics,
15
+ // mcpFailureMonitor) never set the field, so no behaviour changed.
16
+ // If a future use case wants bridge fan-out it should arrive with a
17
+ // concrete caller and a designed API, not as latent scaffolding.
18
+
19
+ import { onEvent } from "./engine.js";
20
+ import { pushToMacosReminder } from "../system/macosNotify.js";
21
+
22
+ /** Wire the macOS Reminder sink as an in-process listener on the
23
+ * notifier engine. Returns an unsubscribe function for tests /
24
+ * teardown. */
25
+ export function startMacosReminderAdapter(): () => void {
26
+ return onEvent((event) => {
27
+ if (event.type !== "published") return;
28
+ void pushToMacosReminder(event.entry.title, event.entry.body);
29
+ });
30
+ }
@@ -0,0 +1,50 @@
1
+ // Boot-time graceful-degradation announcement for missing optional
2
+ // host binaries (#1385). Probes the registry, then for each missing
3
+ // dependency emits one structured log.warn plus a deduped bell
4
+ // notification naming the affected feature/plugins. Never throws —
5
+ // degradation is the whole point.
6
+
7
+ import { BUILT_IN_PLUGIN_METAS } from "../../src/plugins/metas.js";
8
+ import type { PluginMeta } from "../../src/plugins/meta-types.js";
9
+ import { NOTIFICATION_PRIORITIES } from "../../src/types/notification.js";
10
+ import { log } from "./logger/index.js";
11
+ import { publishNotification } from "../events/notifications.js";
12
+ import { probeOptionalDeps, optionalDeps } from "./optionalDeps.js";
13
+
14
+ function pluginsRequiring(depId: string): string[] {
15
+ const metas: readonly PluginMeta[] = Object.values(BUILT_IN_PLUGIN_METAS);
16
+ return metas.filter((meta) => meta.requires?.includes(depId)).map((meta) => meta.toolName);
17
+ }
18
+
19
+ export async function announceOptionalDeps(): Promise<void> {
20
+ const statuses = await probeOptionalDeps();
21
+ for (const dep of optionalDeps()) {
22
+ const status = statuses[dep.id];
23
+ if (!status || status.available) continue;
24
+ const affectedPlugins = pluginsRequiring(dep.id);
25
+ // `not-on-path` → install it; `probe-failed` → it's installed
26
+ // but not responding (e.g. the docker daemon is down). The
27
+ // remediation differs, so the copy must be reason-aware rather
28
+ // than always saying "not found" (Codex review).
29
+ const notFound = status.reason === "not-on-path";
30
+ log.warn("deps", `optional dependency '${dep.command}' unavailable — ${dep.enables} degraded`, {
31
+ depId: dep.id,
32
+ reason: status.reason,
33
+ affectedPlugins,
34
+ });
35
+ publishNotification({
36
+ id: `optional-dep-missing:${dep.id}`,
37
+ kind: "system",
38
+ priority: NOTIFICATION_PRIORITIES.normal,
39
+ title: "Optional dependency unavailable",
40
+ body: notFound
41
+ ? `${dep.command} not found — some features are disabled. Install it and restart.`
42
+ : `${dep.command} is installed but not responding — some features are disabled. Start it and restart.`,
43
+ i18n: {
44
+ titleKey: "optionalDeps.title",
45
+ bodyKey: notFound ? "optionalDeps.notFound" : "optionalDeps.notResponding",
46
+ bodyParams: { command: dep.command },
47
+ },
48
+ });
49
+ }
50
+ }
@@ -292,10 +292,17 @@ export function isMcpServerSpec(value: unknown): value is McpServerSpec {
292
292
 
293
293
  // Workspace id must be slug-shaped so it survives being used as the
294
294
  // mcpServers map key and in the `mcp__<id>__<tool>` tool naming.
295
+ //
296
+ // Consecutive `__` is forbidden inside the id because `__` is the
297
+ // delimiter in the tool-name encoding — a server id like `foo__bar`
298
+ // produces `mcp__foo__bar__tool`, which is ambiguous between server
299
+ // `foo` (tool `bar__tool`) and server `foo__bar` (tool `tool`).
300
+ // Forbidding `__` in the id keeps the convention unambiguous
301
+ // everywhere (Codex review on #1356).
295
302
  const MCP_ID_RE = /^[a-z][a-z0-9_-]{0,63}$/;
296
303
 
297
304
  export function isMcpServerId(value: unknown): value is string {
298
- return typeof value === "string" && MCP_ID_RE.test(value);
305
+ return typeof value === "string" && MCP_ID_RE.test(value) && !value.includes("__");
299
306
  }
300
307
 
301
308
  export function isMcpConfigFile(value: unknown): value is McpConfigFile {
@@ -41,18 +41,26 @@ function assertClaudeFiles(): void {
41
41
  }
42
42
  }
43
43
 
44
- export async function isDockerAvailable(): Promise<boolean> {
45
- if (env.disableSandbox) return false;
46
- if (_dockerEnabled !== null) return _dockerEnabled;
47
- assertClaudeFiles();
44
+ /** Pure daemon-liveness probe: `docker ps -q` succeeds only when the
45
+ * client is installed AND the daemon is reachable. No config or
46
+ * caching concerns the optional-deps registry owns the PATH check
47
+ * and caching; this is just the liveness half. */
48
+ export async function isDockerLive(): Promise<boolean> {
48
49
  try {
49
50
  await execFileAsync("docker", ["ps", "-q"], {
50
51
  timeout: SUBPROCESS_PROBE_TIMEOUT_MS,
51
52
  });
52
- _dockerEnabled = true;
53
+ return true;
53
54
  } catch {
54
- _dockerEnabled = false;
55
+ return false;
55
56
  }
57
+ }
58
+
59
+ export async function isDockerAvailable(): Promise<boolean> {
60
+ if (env.disableSandbox) return false;
61
+ if (_dockerEnabled !== null) return _dockerEnabled;
62
+ assertClaudeFiles();
63
+ _dockerEnabled = await isDockerLive();
56
64
  return _dockerEnabled;
57
65
  }
58
66
 
@@ -15,6 +15,8 @@
15
15
  // `docs/developer.md` lists every env var and what it does; this
16
16
  // module is the runtime side of that table.
17
17
 
18
+ import { CLI_FLAGS } from "../utils/cli-flags.mjs";
19
+
18
20
  // ── Type coercion helpers ───────────────────────────────────────────
19
21
 
20
22
  function asInt(value: string | undefined, fallback: number, opts: { min?: number; max?: number } = {}): number {
@@ -33,6 +35,17 @@ function asFlag(value: string | undefined): boolean {
33
35
  return value === "1";
34
36
  }
35
37
 
38
+ // Env vars also switched on by a CLI flag on this process's argv.
39
+ // The npx launcher injects the env var into the spawned server, so
40
+ // its path doesn't rely on this; this covers a direct
41
+ // `tsx server/index.ts` / `yarn dev --<flag>` run. Computed once at
42
+ // module load — same lifetime as the env snapshot below. (#1089.)
43
+ const argvEnabledEnv = new Set<string>(CLI_FLAGS.filter(({ flag }) => process.argv.includes(flag)).map(({ env: envName }) => envName));
44
+
45
+ function flagOf(envName: string): boolean {
46
+ return asFlag(process.env[envName]) || argvEnabledEnv.has(envName);
47
+ }
48
+
36
49
  function asCsv(value: string | undefined): readonly string[] {
37
50
  return Object.freeze(
38
51
  (value ?? "")
@@ -57,13 +70,13 @@ export const env = Object.freeze({
57
70
  isProduction: process.env.NODE_ENV === "production",
58
71
 
59
72
  // Sandbox / Docker
60
- disableSandbox: asFlag(process.env.DISABLE_SANDBOX),
73
+ disableSandbox: flagOf("DISABLE_SANDBOX"),
61
74
  // Debug aid: also persist `tool_call` events to the session
62
75
  // jsonl (the `tool_result` side already lands on disk). Off by
63
76
  // default because args can be large and may carry payload bytes
64
77
  // the user didn't expect to land in this exact form. See
65
78
  // plans/done/feat-persist-tool-calls.md / issue #1096.
66
- persistToolCalls: asFlag(process.env.PERSIST_TOOL_CALLS),
79
+ persistToolCalls: flagOf("PERSIST_TOOL_CALLS"),
67
80
  // Host-credential opt-ins for the Docker sandbox (#259). Both off
68
81
  // by default. See docs/sandbox-credentials.md for the contract.
69
82
  sandboxSshAgentForward: asFlag(process.env.SANDBOX_SSH_AGENT_FORWARD),
@@ -89,8 +102,8 @@ export const env = Object.freeze({
89
102
  // Debug-only force-run flags. Off by default; `=1` triggers an
90
103
  // immediate run on startup instead of waiting for the scheduled
91
104
  // interval.
92
- journalForceRunOnStartup: asFlag(process.env.JOURNAL_FORCE_RUN_ON_STARTUP),
93
- chatIndexForceRunOnStartup: asFlag(process.env.CHAT_INDEX_FORCE_RUN_ON_STARTUP),
105
+ journalForceRunOnStartup: flagOf("JOURNAL_FORCE_RUN_ON_STARTUP"),
106
+ chatIndexForceRunOnStartup: flagOf("CHAT_INDEX_FORCE_RUN_ON_STARTUP"),
94
107
 
95
108
  // macOS Reminder notification sink (#789). Darwin-only; iCloud
96
109
  // Reminders sync mirrors the entry to the user's iPhone, which
@@ -100,7 +113,7 @@ export const env = Object.freeze({
100
113
  // `DISABLE_MACOS_REMINDER_NOTIFICATIONS=1` to opt out (e.g. for
101
114
  // a shared dev box where the iPhone owner shouldn't get pinged).
102
115
  // Mirrors the `DISABLE_SANDBOX` convention.
103
- disableMacosReminderNotifications: asFlag(process.env.DISABLE_MACOS_REMINDER_NOTIFICATIONS),
116
+ disableMacosReminderNotifications: flagOf("DISABLE_MACOS_REMINDER_NOTIFICATIONS"),
104
117
 
105
118
  // MulmoBridge Relay (#520). Optional — when both are set the server
106
119
  // connects to the Relay via WebSocket and forwards bridge messages.