mulmoclaude 0.6.2 → 0.6.4

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 (182) hide show
  1. package/README.md +26 -0
  2. package/bin/mulmoclaude.js +11 -1
  3. package/client/assets/JsonEditor-D6WBWLoa.js +10 -0
  4. package/client/assets/JsonEditor-Di5xGeZY.css +1 -0
  5. package/client/assets/_plugin-vue_export-helper-BOai-rQB.js +1 -0
  6. package/client/assets/chunk-D8eiyYIV-LcKZGJv5.js +1 -0
  7. package/client/assets/{html2canvas-CDGcmOD3-Bkf2uOth.js → html2canvas-CDGcmOD3-XVrO-eyz.js} +1 -1
  8. package/client/assets/index-CyBr8Mkr.css +2 -0
  9. package/client/assets/index-zZIqEbNX.js +5106 -0
  10. package/client/assets/{index.es-DqtpmBm8-D9mAh_KQ.js → index.es-DqtpmBm8-DHT6q10o.js} +1 -1
  11. package/client/assets/material-symbols-outlined-DtIK7AQn.woff2 +0 -0
  12. package/client/assets/runtime-protocol-vue-D6kcV0wa.js +1 -0
  13. package/client/assets/{runtime-vue-BVUzgYGA.js → runtime-vue-fFYhnNg3.js} +1 -1
  14. package/client/assets/{vue-C8UuIO9J.js → vue-D4w8THF_.js} +1 -1
  15. package/client/assets/vue-i18n-CQbxVmNs.js +3 -0
  16. package/client/assets/vue.runtime.esm-bundler-BTyIdNAI.js +4 -0
  17. package/client/index.html +10 -10
  18. package/package.json +9 -8
  19. package/server/agent/backend/claude-code.ts +34 -0
  20. package/server/agent/backend/fake-echo.ts +370 -0
  21. package/server/agent/backend/index.ts +16 -1
  22. package/server/agent/config.ts +74 -24
  23. package/server/agent/index.ts +104 -80
  24. package/server/agent/mcpFailureMonitor.ts +167 -0
  25. package/server/agent/mcpPreflight.ts +185 -0
  26. package/server/agent/prompt.ts +50 -359
  27. package/server/agent/stdioHttpShim.ts +171 -0
  28. package/server/agent/stream.ts +12 -1
  29. package/server/api/routes/encore.ts +55 -0
  30. package/server/api/routes/files.ts +22 -0
  31. package/server/api/routes/mulmo-script.ts +19 -1
  32. package/server/api/routes/schedulerHandlers.ts +52 -4
  33. package/server/api/routes/sessions.ts +15 -0
  34. package/server/api/routes/skills.ts +263 -0
  35. package/server/build/dispatcher.mjs +299 -0
  36. package/server/encore/INVARIANTS.md +272 -0
  37. package/server/encore/boot.ts +39 -0
  38. package/server/encore/closure.ts +36 -0
  39. package/server/encore/cycle.ts +276 -0
  40. package/server/encore/dispatch.ts +103 -0
  41. package/server/encore/handlers/amend.ts +99 -0
  42. package/server/encore/handlers/appendNote.ts +74 -0
  43. package/server/encore/handlers/defineEncore.ts +42 -0
  44. package/server/encore/handlers/listTickets.ts +107 -0
  45. package/server/encore/handlers/markStepDone.ts +41 -0
  46. package/server/encore/handlers/markTargetSkipped.ts +33 -0
  47. package/server/encore/handlers/query.ts +138 -0
  48. package/server/encore/handlers/recordValues.ts +44 -0
  49. package/server/encore/handlers/resolveNotification.ts +121 -0
  50. package/server/encore/handlers/setup.ts +81 -0
  51. package/server/encore/handlers/shared.ts +137 -0
  52. package/server/encore/handlers/snooze.ts +87 -0
  53. package/server/encore/handlers/startObligationChat.ts +64 -0
  54. package/server/encore/handlers/startSetupChat.ts +50 -0
  55. package/server/encore/lock.ts +61 -0
  56. package/server/encore/notifier.ts +123 -0
  57. package/server/encore/obligation.ts +25 -0
  58. package/server/encore/paths.ts +78 -0
  59. package/server/encore/reconcile.ts +661 -0
  60. package/server/encore/tick.ts +191 -0
  61. package/server/encore/yaml-fm.ts +63 -0
  62. package/server/events/notifications.ts +19 -91
  63. package/server/index.ts +94 -9
  64. package/server/notifier/engine.ts +102 -1
  65. package/server/notifier/macosReminderAdapter.ts +30 -0
  66. package/server/notifier/runtime-api.ts +41 -1
  67. package/server/notifier/types.ts +15 -2
  68. package/server/plugins/runtime.ts +11 -2
  69. package/server/prompts/index.ts +39 -0
  70. package/server/prompts/system/journal-pointer.md +12 -0
  71. package/server/prompts/system/memory-management-atomic.md +33 -0
  72. package/server/prompts/system/memory-management-topic.md +60 -0
  73. package/server/prompts/system/news-concierge.md +24 -0
  74. package/server/prompts/system/sandbox-tools.md +10 -0
  75. package/server/prompts/system/sources-context.md +16 -0
  76. package/server/prompts/system/system.md +91 -0
  77. package/server/system/announceOptionalDeps.ts +57 -0
  78. package/server/system/appVersion.ts +34 -0
  79. package/server/system/config.ts +17 -1
  80. package/server/system/docker.ts +14 -6
  81. package/server/system/env.ts +18 -5
  82. package/server/system/optionalDeps.ts +129 -0
  83. package/server/utils/cli-flags.d.mts +14 -0
  84. package/server/utils/cli-flags.mjs +53 -0
  85. package/server/utils/files/encore-io.ts +111 -0
  86. package/server/utils/time.ts +6 -0
  87. package/server/workspace/helps/business.md +2 -2
  88. package/server/workspace/helps/encore-dsl.md +482 -0
  89. package/server/workspace/helps/index.md +15 -13
  90. package/server/workspace/helps/mulmoscript.md +3 -3
  91. package/server/workspace/helps/sandbox.md +2 -2
  92. package/server/workspace/hooks/dispatcher.ts +7 -5
  93. package/server/workspace/hooks/provision.ts +6 -3
  94. package/server/workspace/paths.ts +13 -4
  95. package/server/workspace/skills/catalog.ts +355 -0
  96. package/server/workspace/skills/external/catalog.ts +283 -0
  97. package/server/workspace/skills/external/clone.ts +129 -0
  98. package/server/workspace/skills/external/id.ts +194 -0
  99. package/server/workspace/skills/external/install.ts +417 -0
  100. package/server/workspace/skills/external/presets.ts +50 -0
  101. package/server/workspace/skills-preset.ts +29 -17
  102. package/server/workspace/workspace.ts +10 -5
  103. package/src/App.vue +37 -8
  104. package/src/components/FileContentRenderer.vue +102 -9
  105. package/src/components/JsonEditor.vue +160 -0
  106. package/src/components/NotificationBell.vue +35 -3
  107. package/src/components/PluginLauncher.vue +20 -41
  108. package/src/components/RightSidebar.vue +19 -0
  109. package/src/components/SettingsMcpTab.vue +58 -11
  110. package/src/components/SettingsModal.vue +22 -1
  111. package/src/components/StackView.vue +10 -1
  112. package/src/components/TodoExplorer.vue +16 -0
  113. package/src/components/todo/TodoKanbanView.vue +34 -6
  114. package/src/composables/useNotifications.ts +21 -1
  115. package/src/config/apiRoutes.ts +0 -6
  116. package/src/config/mcpCatalog.ts +12 -7
  117. package/src/config/mcpTypes.ts +5 -0
  118. package/src/config/roles.ts +52 -15
  119. package/src/config/systemFileDescriptors.ts +12 -0
  120. package/src/lang/de.ts +108 -12
  121. package/src/lang/en.ts +105 -11
  122. package/src/lang/es.ts +106 -11
  123. package/src/lang/fr.ts +106 -11
  124. package/src/lang/ja.ts +104 -11
  125. package/src/lang/ko.ts +105 -11
  126. package/src/lang/pt-BR.ts +106 -11
  127. package/src/lang/zh.ts +103 -11
  128. package/src/main.ts +1 -0
  129. package/src/plugins/_generated/metas.ts +4 -0
  130. package/src/plugins/_generated/registrations.ts +2 -0
  131. package/src/plugins/_generated/server-bindings.ts +5 -0
  132. package/src/plugins/encore/EncoreDashboard.vue +504 -0
  133. package/src/plugins/encore/EncoreRedirect.vue +116 -0
  134. package/src/plugins/encore/View.vue +36 -0
  135. package/src/plugins/encore/defineEncoreDefinition.ts +74 -0
  136. package/src/plugins/encore/defineEncoreMeta.ts +13 -0
  137. package/src/plugins/encore/index.ts +93 -0
  138. package/src/plugins/encore/manageEncoreDefinition.ts +100 -0
  139. package/src/plugins/encore/manageEncoreMeta.ts +36 -0
  140. package/src/plugins/manageSkills/View.vue +832 -30
  141. package/src/plugins/manageSkills/categories.ts +125 -0
  142. package/src/plugins/manageSkills/meta.ts +30 -0
  143. package/src/plugins/markdown/definition.ts +3 -3
  144. package/src/plugins/meta-types.ts +5 -0
  145. package/src/plugins/presentMulmoScript/Preview.vue +3 -3
  146. package/src/plugins/presentMulmoScript/View.vue +157 -33
  147. package/src/plugins/presentMulmoScript/meta.ts +4 -0
  148. package/src/plugins/scheduler/View.vue +45 -9
  149. package/src/plugins/scheduler/calendarDefinition.ts +6 -2
  150. package/src/plugins/scheduler/multiDayHelpers.ts +95 -0
  151. package/src/plugins/skill/View.vue +1 -5
  152. package/src/plugins/spreadsheet/View.vue +3 -3
  153. package/src/plugins/spreadsheet/definition.ts +1 -1
  154. package/src/plugins/textResponse/Preview.vue +14 -1
  155. package/src/plugins/textResponse/View.vue +39 -24
  156. package/src/plugins/wiki/components/WikiPageBody.vue +4 -0
  157. package/src/router/index.ts +11 -0
  158. package/src/router/pageRoutes.ts +1 -0
  159. package/src/types/encore-dsl/at-expression.ts +120 -0
  160. package/src/types/encore-dsl/at-resolver.ts +32 -0
  161. package/src/types/encore-dsl/cadence.ts +289 -0
  162. package/src/types/encore-dsl/schema.ts +288 -0
  163. package/src/types/notification.ts +2 -1
  164. package/src/types/session.ts +6 -0
  165. package/src/types/sse.ts +5 -0
  166. package/src/types/toolCallHistory.ts +7 -0
  167. package/src/utils/agent/eventDispatch.ts +26 -5
  168. package/src/utils/agent/mcpHint.ts +50 -0
  169. package/src/utils/image/htmlSrcAttrs.ts +117 -13
  170. package/src/utils/session/sessionEntries.ts +8 -32
  171. package/client/assets/PluginScopedRoot-YjvQq0Nn.js +0 -3
  172. package/client/assets/chunk-CernVdwh.js +0 -1
  173. package/client/assets/chunk-D8eiyYIV-CAXpUwLd.js +0 -1
  174. package/client/assets/index-BwrlMMHr.js +0 -5005
  175. package/client/assets/index-CvvNuegU.css +0 -2
  176. package/client/assets/material-symbols-outlined-BOZVWuR3.woff2 +0 -0
  177. package/client/assets/runtime-protocol-vue-C1To4M3t.js +0 -1
  178. package/client/assets/vue.runtime.esm-bundler-DQ8Kjjui.js +0 -4
  179. package/server/api/routes/notifications.ts +0 -195
  180. package/server/notifier/legacy-adapters.ts +0 -76
  181. package/server/workspace/hooks/dispatcher.mjs +0 -300
  182. package/src/composables/useSelectedResult.ts +0 -49
@@ -0,0 +1,191 @@
1
+ // Encore tick — time-driven invoker for the reconciler.
2
+ //
3
+ // The tick used to carry its own copy of trim/escalate/publish logic.
4
+ // That moved into `reconcile.ts` (the sole owner of bell state). The
5
+ // tick is now a thin walker:
6
+ //
7
+ // 1. For every obligation directory, reconcile the latest cycle.
8
+ // 2. Sweep stuck tickets — reconcile any (obligationId, cycleId)
9
+ // pair found in tickets/ that step 1 didn't cover. This
10
+ // retries clear failures on closed/non-latest cycles, which
11
+ // step 1 alone would skip (it only picks the latest cycle).
12
+ // 3. Prune orphan tickets older than 30 days.
13
+ //
14
+ // `Ticket` still lives here because dispatch.ts, reconcile.ts, and
15
+ // the host UI all import the type — keeping it adjacent to the on-disk
16
+ // shape (the only consumer is `tickets/*.json`) makes the locus of
17
+ // schema changes obvious.
18
+
19
+ import path from "node:path";
20
+
21
+ import { log as defaultLog } from "../system/logger/index.js";
22
+ import { ONE_DAY_MS } from "../utils/time.js";
23
+ import type { Severity } from "../../src/types/encore-dsl/schema.js";
24
+ import { TICKETS_DIRNAME, OBLIGATIONS_DIRNAME } from "./paths.js";
25
+ import { readDir, readDirSubdirs, readTextOrNull, unlink } from "../utils/files/encore-io.js";
26
+ import * as encoreNotifier from "./notifier.js";
27
+ import { reconcileCycleNotifications } from "./reconcile.js";
28
+
29
+ const ORPHAN_TICKET_AGE_MS = 30 * ONE_DAY_MS;
30
+
31
+ export interface TickDeps {
32
+ now: Date;
33
+ log?: typeof defaultLog;
34
+ }
35
+
36
+ /** Shape of a ticket on disk. Authoritative record of every live
37
+ * Encore bell entry: which obligation+cycle+step it belongs to,
38
+ * which targets it covers, what severity it was published at
39
+ * (used for escalation diff), and the seed prompt
40
+ * resolveNotification will use to start the chat on user click.
41
+ *
42
+ * A ticket's existence asserts that a matching bell is alive and
43
+ * awaiting a `clear` operation. Tickets are regeneratable cache
44
+ * for everything except `chatSessionId` (the binding to a user's
45
+ * open chat); a sweep of `tickets/*.json` plus the matching bell
46
+ * entries leaves the system in a recoverable state — the
47
+ * reconciler rebuilds from cycle files on the next tick. */
48
+ export interface Ticket {
49
+ pendingId: string;
50
+ obligationId: string;
51
+ cycleId: string;
52
+ notificationId: string;
53
+ stepId: string;
54
+ /** Target ids covered by this bundled notification. */
55
+ targets: string[];
56
+ /** Severity at last publish — used as the escalation-diff
57
+ * baseline. The cycle file used to carry
58
+ * `lastPublishedSeverity`; that moved here when status flags
59
+ * were removed. */
60
+ severity: Severity;
61
+ /** Title and body as last rendered to the bell — the drift
62
+ * baseline. Trim path compares these against `bundleTitle` /
63
+ * `bundleBody` recomputed from the current DSL; if either has
64
+ * drifted (most commonly via `amendDefinition` editing a
65
+ * `displayName`), the path calls `notifier.update` in place
66
+ * instead of clear-then-publish. Optional on the wire so
67
+ * tickets written by pre-update-API builds load cleanly — they
68
+ * are treated as "always drifted" on first sight, which causes
69
+ * an idempotent in-place update to the bell and a ticket
70
+ * rewrite that backfills the fields. */
71
+ title?: string;
72
+ body?: string;
73
+ seedPrompt: string;
74
+ createdAt: string;
75
+ /** Filled by resolveNotification on first bell click. Subsequent
76
+ * clicks reuse it (idempotent). */
77
+ chatSessionId?: string;
78
+ }
79
+
80
+ export async function runTick(deps: TickDeps): Promise<void> {
81
+ const log = deps.log ?? defaultLog;
82
+ const obligationIds = await readDirSubdirs(OBLIGATIONS_DIRNAME);
83
+ for (const obligationId of obligationIds) {
84
+ try {
85
+ await reconcileCycleNotifications({ obligationId, now: deps.now, log });
86
+ } catch (err) {
87
+ log.warn("encore", "tick: reconcile failed", { obligationId, error: err instanceof Error ? err.message : String(err) });
88
+ }
89
+ }
90
+ await sweepStuckTickets(new Set(obligationIds), deps.now, log);
91
+ await pruneOrphanTickets(deps.now, log);
92
+ }
93
+
94
+ // ── stuck-ticket sweep (Phase 2) ──────────────────────────────────
95
+ //
96
+ // The per-obligation reconcile above only touches the obligation's
97
+ // latest cycle (and its just-provisioned successor on cycle-close).
98
+ // A ticket on a NON-latest cycle never gets revisited there — which
99
+ // matters because `safeClearBell` failures are intentionally
100
+ // non-destructive: when the clear fails, the reconciler keeps the
101
+ // ticket so a later attempt can retry. Without this sweep, that
102
+ // "later attempt" would never come for tickets on closed cycles
103
+ // (until the 30-day age-based prune).
104
+ //
105
+ // This sweep collects every (obligationId, cycleId) pair present in
106
+ // tickets/ and reconciles each. It overlaps Phase 1 for the
107
+ // latest cycle, but reconcile is idempotent (proven by the
108
+ // "idempotency" test in test_encore_reconcile.ts) so the redundancy
109
+ // is cheap and the contract stays simple.
110
+
111
+ async function sweepStuckTickets(knownObligationIds: Set<string>, now: Date, log: typeof defaultLog): Promise<void> {
112
+ const entries = await readDir(TICKETS_DIRNAME);
113
+ const pairsToReconcile = await collectStuckCyclePairs(entries, knownObligationIds);
114
+ for (const pair of pairsToReconcile) {
115
+ try {
116
+ await reconcileCycleNotifications({ obligationId: pair.obligationId, cycleId: pair.cycleId, now, log });
117
+ } catch (err) {
118
+ log.warn("encore", "tick: stuck-ticket sweep failed", {
119
+ obligationId: pair.obligationId,
120
+ cycleId: pair.cycleId,
121
+ error: err instanceof Error ? err.message : String(err),
122
+ });
123
+ }
124
+ }
125
+ }
126
+
127
+ async function collectStuckCyclePairs(entries: string[], knownObligationIds: Set<string>): Promise<{ obligationId: string; cycleId: string }[]> {
128
+ const seen = new Set<string>();
129
+ const out: { obligationId: string; cycleId: string }[] = [];
130
+ for (const entry of entries) {
131
+ if (!entry.endsWith(".json")) continue;
132
+ const raw = await readTextOrNull(path.join(TICKETS_DIRNAME, entry));
133
+ if (!raw) continue;
134
+ let ticket: Ticket;
135
+ try {
136
+ ticket = JSON.parse(raw) as Ticket;
137
+ } catch {
138
+ continue;
139
+ }
140
+ // Skip tickets pointing at obligations that no longer exist on
141
+ // disk — those get cleaned up by the age-based prune below.
142
+ if (!knownObligationIds.has(ticket.obligationId)) continue;
143
+ const key = `${ticket.obligationId}::${ticket.cycleId}`;
144
+ if (seen.has(key)) continue;
145
+ seen.add(key);
146
+ out.push({ obligationId: ticket.obligationId, cycleId: ticket.cycleId });
147
+ }
148
+ return out;
149
+ }
150
+
151
+ // ── orphan ticket sweep (time-driven, lives here not in reconcile) ──
152
+ //
153
+ // Orphan tickets are records older than 30 days that somehow weren't
154
+ // trimmed by reconcile (e.g. the cycle file was deleted manually,
155
+ // or a host crash left a ticket without its bell counterpart). The
156
+ // sweep is age-based, not state-based, so it belongs with the
157
+ // time-driven tick rather than the state-driven reconciler.
158
+
159
+ async function pruneOneTicket(rel: string, raw: string, now: Date, log: typeof defaultLog): Promise<void> {
160
+ let ticket: Ticket;
161
+ try {
162
+ ticket = JSON.parse(raw) as Ticket;
163
+ } catch {
164
+ await unlink(rel);
165
+ return;
166
+ }
167
+ const ageMs = now.getTime() - new Date(ticket.createdAt).getTime();
168
+ if (ageMs <= ORPHAN_TICKET_AGE_MS) return;
169
+ log.info("encore", "tick: pruning orphan ticket", { pendingId: ticket.pendingId, ageMs });
170
+ // Clear the host bell entry BEFORE unlinking the ticket. Otherwise
171
+ // the bell entry stays visible but the ticket is gone — next tick
172
+ // treats the step as un-fired and publishes a duplicate while the
173
+ // stale entry is still up.
174
+ try {
175
+ await encoreNotifier.clear(ticket.notificationId);
176
+ } catch (err) {
177
+ log.warn("encore", "tick: prune-bell-clear failed", { notificationId: ticket.notificationId, error: err instanceof Error ? err.message : String(err) });
178
+ }
179
+ await unlink(rel);
180
+ }
181
+
182
+ async function pruneOrphanTickets(now: Date, log: typeof defaultLog): Promise<void> {
183
+ const entries = await readDir(TICKETS_DIRNAME);
184
+ for (const entry of entries) {
185
+ if (!entry.endsWith(".json")) continue;
186
+ const rel = path.join(TICKETS_DIRNAME, entry);
187
+ const raw = await readTextOrNull(rel);
188
+ if (!raw) continue;
189
+ await pruneOneTicket(rel, raw, now, log);
190
+ }
191
+ }
@@ -0,0 +1,63 @@
1
+ // Encore-local YAML-frontmatter parser/serializer.
2
+ //
3
+ // The host's `server/utils/markdown/frontmatter.ts` uses
4
+ // `yaml.FAILSAFE_SCHEMA` on load, which keeps every scalar as a
5
+ // string. That's the right call for wiki/topic files where
6
+ // `'1.20'` / `'true'` need to round-trip verbatim, but it breaks
7
+ // Encore's DSL which expects real numbers (`version: 1`) and
8
+ // booleans (`required: true`) for Zod validation on re-parse.
9
+ //
10
+ // We use js-yaml's default schema (CORE_SCHEMA) on both load and
11
+ // dump so numbers + booleans + nulls round-trip natively. Output
12
+ // is byte-compatible with the host's serializer for the same
13
+ // `---\n...\n---\n\nbody` envelope shape.
14
+
15
+ import yaml from "js-yaml";
16
+
17
+ const FRONTMATTER_OPEN = /^---\r?\n/;
18
+ const FRONTMATTER_CLOSE = /(?:^|\r?\n)---\s*(?:\r?\n|$)/;
19
+
20
+ export interface ParsedMarkdown {
21
+ meta: Record<string, unknown>;
22
+ body: string;
23
+ hasHeader: boolean;
24
+ }
25
+
26
+ function safeLoad(text: string): Record<string, unknown> | null {
27
+ try {
28
+ const loaded = yaml.load(text);
29
+ if (loaded === null || loaded === undefined) return null;
30
+ if (typeof loaded !== "object" || Array.isArray(loaded)) return null;
31
+ return loaded as Record<string, unknown>;
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ /** Parse a markdown document, splitting frontmatter from body. */
38
+ export function parseEncoreFrontmatter(raw: string): ParsedMarkdown {
39
+ if (!FRONTMATTER_OPEN.test(raw)) {
40
+ return { meta: {}, body: raw, hasHeader: false };
41
+ }
42
+ const afterOpen = raw.replace(FRONTMATTER_OPEN, "");
43
+ const closeMatch = FRONTMATTER_CLOSE.exec(afterOpen);
44
+ if (!closeMatch || closeMatch.index === undefined) {
45
+ return { meta: {}, body: raw, hasHeader: false };
46
+ }
47
+ const yamlText = afterOpen.slice(0, closeMatch.index);
48
+ const body = afterOpen.slice(closeMatch.index + closeMatch[0].length);
49
+ const meta = safeLoad(yamlText);
50
+ if (meta === null) {
51
+ return { meta: {}, body: raw, hasHeader: false };
52
+ }
53
+ return { meta, body, hasHeader: true };
54
+ }
55
+
56
+ /** Serialize a meta object + body back into the canonical
57
+ * `---\n...\n---\n\nbody` shape. Empty `meta` returns the body
58
+ * alone. */
59
+ export function serializeEncoreFrontmatter(meta: Record<string, unknown>, body: string): string {
60
+ if (Object.keys(meta).length === 0) return body;
61
+ const yamlText = yaml.dump(meta, { lineWidth: -1, noRefs: true }).trimEnd();
62
+ return `---\n${yamlText}\n---\n\n${body}`;
63
+ }
@@ -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
@@ -4,6 +4,7 @@ import path from "path";
4
4
  import { fileURLToPath } from "url";
5
5
  import agentRoutes, { startChat } from "./api/routes/agent.js";
6
6
  import accountingRoutes from "./api/routes/accounting.js";
7
+ import encoreRoutes from "./api/routes/encore.js";
7
8
  import photoLocationsRoutes from "./api/routes/photo-locations.js";
8
9
  import schedulerRoutes from "./api/routes/scheduler.js";
9
10
  import sessionsRoutes, { loadAllSessions } from "./api/routes/sessions.js";
@@ -36,8 +37,9 @@ import { loadPresetPlugins } from "./plugins/preset-loader.js";
36
37
  import { registerRuntimePlugins } from "./plugins/runtime-registry.js";
37
38
  import { makePluginRuntime } from "./plugins/runtime.js";
38
39
  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";
40
+ import { setActiveBackend } from "./agent/backend/index.js";
41
+ import { fakeEchoBackend } from "./agent/backend/fake-echo.js";
42
+ import { startMacosReminderAdapter } from "./notifier/macosReminderAdapter.js";
41
43
  import notifierRoutes from "./api/routes/notifier.js";
42
44
  import { initNotifier } from "./notifier/engine.js";
43
45
  import { registerSaveAttachmentHook } from "./utils/files/attachment-store.js";
@@ -45,6 +47,8 @@ import { capturePhotoLocation } from "./workspace/photo-locations/index.js";
45
47
  import { createJournalRouter } from "./api/routes/journal.js";
46
48
  import { createTranslationRouter } from "./api/routes/translation.js";
47
49
  import { announcePluginMetaDiagnostics } from "./plugins/diagnostics.js";
50
+ import { announceOptionalDeps } from "./system/announceOptionalDeps.js";
51
+ import { APP_VERSION } from "./system/appVersion.js";
48
52
  import { createChatService } from "@mulmobridge/chat-service";
49
53
  import { readSessionJsonl } from "./utils/files/session-io.js";
50
54
  import { onSessionEvent, initSessionStore } from "./events/session-store/index.js";
@@ -56,6 +60,8 @@ import { WORKSPACE_PATHS } from "./workspace/paths.js";
56
60
  import { serverError } from "./utils/httpError.js";
57
61
  import { makeUuid } from "./utils/id.js";
58
62
  import { mcpToolsRouter, mcpTools, isMcpToolEnabled } from "./agent/mcp-tools/index.js";
63
+ import { preflightUserServers, logPreflightResult } from "./agent/mcpPreflight.js";
64
+ import { loadMcpConfig } from "./system/config.js";
59
65
  import { initWorkspace, workspacePath } from "./workspace/workspace.js";
60
66
  import { runMemoryMigrationOnce } from "./workspace/memory/run.js";
61
67
  import { runTopicMigrationOnce } from "./workspace/memory/topic-run.js";
@@ -86,12 +92,13 @@ import { logBackgroundError } from "./utils/logBackgroundError.js";
86
92
  import { errorMessage } from "./utils/errors.js";
87
93
  import { registerScheduledSkills } from "./workspace/skills/scheduler.js";
88
94
  import { registerUserTasks } from "./workspace/skills/user-tasks.js";
95
+ import { registerEncoreTick } from "./encore/boot.js";
89
96
  import { API_ROUTES } from "../src/config/apiRoutes.js";
90
97
  import { EVENT_TYPES } from "../src/types/events.js";
91
98
  import { SESSION_ORIGINS } from "../src/types/session.js";
92
99
  import { buildHtmlPreviewCsp } from "../src/utils/html/previewCsp.js";
93
100
  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";
101
+ import { ONE_SECOND_MS, ONE_MINUTE_MS, ONE_HOUR_MS, STARTUP_FAILURE_FORCE_EXIT_MS, FATAL_LOG_FLUSH_MS } from "./utils/time.js";
95
102
  import { isPortFree, findAvailablePort, MAX_PORT_PROBES } from "./utils/port.mjs";
96
103
  import { SCHEDULE_TYPES, MISSED_RUN_POLICIES } from "@receptron/task-scheduler";
97
104
 
@@ -102,6 +109,46 @@ const __dirname = path.dirname(__filename);
102
109
 
103
110
  const debugMode = process.argv.includes("--debug");
104
111
 
112
+ // Global crash diagnostics (#1364). These handlers log loudly so a
113
+ // fatal failure is triagable, then EXIT — keeping the loop running
114
+ // after an uncaught exception is process-unsafe per the Node docs
115
+ // (invariants may already be broken). The launcher / supervisor
116
+ // (Electron wrapper, systemd, etc.) is responsible for restart.
117
+ //
118
+ // The canonical failure this PR set out to fix — missing `claude`
119
+ // on PATH crashing the server via spawn's `error` event — is now
120
+ // caught at the local boundary in `server/agent/backend/claude-code.ts`
121
+ // (an explicit `error` listener turns ENOENT into an AgentEvent).
122
+ // These handlers are the BACKSTOP for anything we missed, not a
123
+ // substitute for local error handling. (Codex review on #1364.)
124
+ //
125
+ // `process.exit(1)` is non-zero so supervisors that branch on exit
126
+ // code treat the bounce as an error condition.
127
+ process.on("uncaughtException", (err) => {
128
+ log.error("uncaughtException", err instanceof Error ? err.message : String(err), {
129
+ stack: err instanceof Error ? err.stack : undefined,
130
+ });
131
+ // Tiny grace so the log line flushes to disk before we exit.
132
+ setTimeout(() => process.exit(1), FATAL_LOG_FLUSH_MS);
133
+ });
134
+ process.on("unhandledRejection", (reason) => {
135
+ log.error("unhandledRejection", reason instanceof Error ? reason.message : String(reason), {
136
+ stack: reason instanceof Error ? reason.stack : undefined,
137
+ });
138
+ setTimeout(() => process.exit(1), FATAL_LOG_FLUSH_MS);
139
+ });
140
+
141
+ // Test-seam: CI runs without a Claude CLI / API key set the
142
+ // MULMOCLAUDE_FAKE_AGENT env var, which swaps in an echo-stub
143
+ // backend so the chat flow still completes. Decided once at boot;
144
+ // the orchestrator reads the active backend with zero per-call
145
+ // overhead. Production callers never trip this branch (no runtime
146
+ // import-time cost beyond the small fake-echo module itself).
147
+ if (process.env.MULMOCLAUDE_FAKE_AGENT === "1") {
148
+ setActiveBackend(fakeEchoBackend);
149
+ log.info("agent", "MULMOCLAUDE_FAKE_AGENT=1 — active backend = fake-echo");
150
+ }
151
+
105
152
  initWorkspace();
106
153
 
107
154
  // Fire-and-forget memory migrations: legacy `memory.md` → atomic
@@ -522,6 +569,7 @@ app.get(API_ROUTES.health, (_req: Request, res: Response) => {
522
569
  const cores = cpus().length;
523
570
  res.json({
524
571
  status: "OK",
572
+ version: APP_VERSION,
525
573
  geminiAvailable: isGeminiAvailable(),
526
574
  sandboxEnabled,
527
575
  cpu: { load1, cores },
@@ -549,6 +597,7 @@ app.get(API_ROUTES.sandbox, (_req: Request, res: Response) => {
549
597
  // the `/api` literal into each `router.post(API_ROUTES.…)` call.
550
598
  app.use(agentRoutes);
551
599
  app.use(accountingRoutes);
600
+ app.use(encoreRoutes);
552
601
  app.use(photoLocationsRoutes);
553
602
  // todosRoutes removed (#1145) — todo is now a runtime plugin
554
603
  // (`@mulmoclaude/todo-plugin`); the dispatch route is generated by
@@ -653,7 +702,6 @@ app.use(chatService.router);
653
702
  // `startRuntimeServices` has it. Calls that arrive before fill-in
654
703
  // (impossible in practice — the HTTP server isn't listening yet)
655
704
  // would no-op on publish but still queue the bridge push.
656
- app.use(createNotificationsRouter());
657
705
  app.use(notifierRoutes);
658
706
  app.use(createJournalRouter());
659
707
  app.use(createTranslationRouter());
@@ -768,6 +816,29 @@ function logMcpStatus(): void {
768
816
  const names = disabledMcpTools.map((toolDef) => `${toolDef.definition.name} (${(toolDef.requiredEnv ?? []).join(", ")})`).join(", ");
769
817
  log.info("mcp", "Unavailable (missing env)", { tools: names });
770
818
  }
819
+ logExternalMcpPreflight();
820
+ }
821
+
822
+ // External MCP servers (the `mcp.json` ones — Notion / GitHub /…)
823
+ // get a separate preflight pass that mirrors the built-in
824
+ // `Available / Unavailable` summary above. Servers with catalog
825
+ // entries whose `required: true` fields are unset are excluded from
826
+ // the config handed to Claude Code (filtered inside
827
+ // `prepareUserServers`); this boot-time log gives the operator one
828
+ // clear startup signal (#1352).
829
+ function logExternalMcpPreflight(): void {
830
+ try {
831
+ const userMcpRaw = loadMcpConfig().mcpServers;
832
+ const preflight = preflightUserServers(userMcpRaw);
833
+ logPreflightResult(preflight, "boot");
834
+ } catch (err) {
835
+ // Best-effort: a broken mcp.json shouldn't take down boot. The
836
+ // per-agent-run path will still attempt the preflight and surface
837
+ // any genuine issue when the user actually starts a chat.
838
+ log.warn("mcp", "preflight at boot failed; will retry per-agent-run", {
839
+ error: err instanceof Error ? err.message : String(err),
840
+ });
841
+ }
771
842
  }
772
843
 
773
844
  function maybeForceJournalRun(): void {
@@ -807,11 +878,9 @@ async function startRuntimeServices(httpServer: ReturnType<typeof app.listen>, p
807
878
  // is forwarded in here so the rest of `startRuntimeServices` can
808
879
  // share the same instance.
809
880
 
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 });
881
+ // macOS Reminder adapter wiring lives in the `app.listen` callback,
882
+ // alongside `initNotifier`, so it's subscribed before the first
883
+ // await opens a publish-can-fire-but-no-one's-listening window.
815
884
 
816
885
  // --- Plugin META aggregator diagnostics ---
817
886
  // After the notifier engine is initialized so the wrapper has a
@@ -820,6 +889,12 @@ async function startRuntimeServices(httpServer: ReturnType<typeof app.listen>, p
820
889
  // notification.
821
890
  await announcePluginMetaDiagnostics();
822
891
 
892
+ // --- Optional host-dependency probe (#1385) ---
893
+ // Probes docker / ffmpeg / … once, warns (log + bell) for any
894
+ // missing one so a feature degrading is visible instead of a
895
+ // later opaque crash. Never throws.
896
+ await announceOptionalDeps();
897
+
823
898
  // --- Chat socket transport (Phase A of #268) ---
824
899
  chatService.attachSocket(httpServer);
825
900
 
@@ -1027,6 +1102,8 @@ async function startRuntimeServices(httpServer: ReturnType<typeof app.listen>, p
1027
1102
  })
1028
1103
  .catch(logBackgroundError("user-tasks", "failed to register user tasks"));
1029
1104
 
1105
+ registerEncoreTick(taskManager);
1106
+
1030
1107
  taskManager.start();
1031
1108
 
1032
1109
  maybeForceJournalRun();
@@ -1123,6 +1200,14 @@ process.on("SIGTERM", () => {
1123
1200
  initNotifier({
1124
1201
  publish: (channel, payload) => earlyPubsub.publish(channel, payload),
1125
1202
  });
1203
+ // Subscribe the macOS Reminder side-channel BEFORE the first
1204
+ // await below — `initNotifier` opens the engine to publishes,
1205
+ // and any boot-time diagnostic that lands during the
1206
+ // `.server-port` write / `startRuntimeServices` setup would
1207
+ // otherwise miss the Reminder fan-out (CodeRabbit review on
1208
+ // PR #1358). The adapter is sync + no-op outside darwin, so
1209
+ // wiring it here costs nothing.
1210
+ startMacosReminderAdapter();
1126
1211
 
1127
1212
  // Publish the actually-bound port so the hook script can
1128
1213
  // address us — the requested PORT may have walked forward