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,171 @@
1
+ // stdio↔HTTP shim for the Docker-sandbox opt-in path (#1421 Phase B).
2
+ //
3
+ // The Docker sandbox image can't host arbitrary stdio MCP runtimes
4
+ // (npx / python / …), so stdio servers are dropped by default
5
+ // (server/agent/config.ts). A user can opt a specific server into
6
+ // `hostExecInDocker` — the explicit, UI-acknowledged escape hatch:
7
+ // the stdio server runs on the HOST behind `supergateway` (a
8
+ // battle-tested stdio→SSE MCP bridge) and the sandboxed agent
9
+ // reaches it over `host.docker.internal`.
10
+ //
11
+ // `supergateway` (rather than a hand-rolled adapter) is deliberate:
12
+ // MCP transport correctness is delegated to a maintained tool — for
13
+ // a sandbox-escaping feature, protocol-correct + battle-tested beats
14
+ // zero-dependency.
15
+ //
16
+ // SECURITY: every started shim runs UNSANDBOXED with host
17
+ // privileges. That is the acknowledged trade-off of the opt-in;
18
+ // callers MUST gate on `spec.hostExecInDocker === true` and the UI
19
+ // MUST surface the risk. This module never decides policy — it only
20
+ // executes an already-authorized opt-in.
21
+
22
+ import { spawn, type ChildProcess } from "node:child_process";
23
+ import type { Readable } from "node:stream";
24
+
25
+ import { findAvailablePort } from "../utils/port.mjs";
26
+ import { log } from "../system/logger/index.js";
27
+ import { ONE_SECOND_MS } from "../utils/time.js";
28
+ import type { McpStdioSpec } from "../system/config.js";
29
+
30
+ export interface ShimHandle {
31
+ /** Host URL the (sandboxed) agent's MCP client connects to. */
32
+ url: string;
33
+ /** Tear down the gateway + its stdio child. Idempotent. */
34
+ close: () => void;
35
+ }
36
+
37
+ // Pinned: this is an UNSANDBOXED host-exec path, so `npx -y` must not
38
+ // resolve a mutable upstream version on every run (supply-chain +
39
+ // reproducibility). Bump deliberately after reviewing the diff.
40
+ const SUPERGATEWAY_VERSION = "3.4.3";
41
+
42
+ const SHIM_PORT_RANGE_START = 39_100;
43
+ const SHIM_READY_TIMEOUT_MS = 15 * ONE_SECOND_MS;
44
+ const SHIM_READY_POLL_MS = ONE_SECOND_MS / 4;
45
+
46
+ // POSIX single-quote escaping. supergateway runs the `--stdio` value
47
+ // through `spawn(..., { shell: true })`, so the string is parsed by a
48
+ // shell — whitespace-only quoting would let metacharacters
49
+ // (`$ ; | & ( )`, backticks, quotes) be expanded and diverge from the
50
+ // non-shim `spawn(command, args)` path. Wrapping every token in single
51
+ // quotes (and `'\''`-escaping embedded single quotes) neutralises ALL
52
+ // shell metacharacters, which matters because this path runs
53
+ // UNSANDBOXED on the host. This is POSIX-shell-specific; on Windows
54
+ // the default shell is cmd.exe where single-quote semantics differ,
55
+ // so `startStdioHttpShim` refuses to run there (safe default: drop)
56
+ // rather than risk a misparse on a sandbox-escaping path.
57
+ function shellQuote(token: string): string {
58
+ const escaped = token.replace(/'/g, "'\\''");
59
+ return `'${escaped}'`;
60
+ }
61
+
62
+ export function buildStdioCommand(spec: McpStdioSpec): string {
63
+ return [spec.command, ...(spec.args ?? [])].map(shellQuote).join(" ");
64
+ }
65
+
66
+ const SHIM_PROBE_TIMEOUT_MS = 2 * ONE_SECOND_MS;
67
+
68
+ async function probeOnce(port: number): Promise<boolean> {
69
+ // Per-request timeout: a half-open TCP connection (port accepts but
70
+ // never responds) would otherwise hang past the overall deadline.
71
+ const controller = new AbortController();
72
+ const timer = setTimeout(() => controller.abort(), SHIM_PROBE_TIMEOUT_MS);
73
+ try {
74
+ const res = await fetch(`http://127.0.0.1:${port}/sse`, { method: "GET", signal: controller.signal });
75
+ // 405 / 400 = server is up but rejects a bare GET on the SSE
76
+ // endpoint — that still proves the gateway is listening.
77
+ return res.ok || res.status === 405 || res.status === 400;
78
+ } catch {
79
+ return false;
80
+ } finally {
81
+ clearTimeout(timer);
82
+ }
83
+ }
84
+
85
+ async function waitUntilListening(child: ChildProcess, port: number, hasSpawnFailed: () => boolean): Promise<boolean> {
86
+ const deadline = Date.now() + SHIM_READY_TIMEOUT_MS;
87
+ while (Date.now() < deadline) {
88
+ // Bail immediately on a dead/failed child instead of burning the
89
+ // full timeout: spawn 'error' can fire after the caller's initial
90
+ // check, so re-test the flag (and exit/signal) every iteration.
91
+ if (hasSpawnFailed() || child.exitCode !== null || child.signalCode !== null) return false;
92
+ if (await probeOnce(port)) return true;
93
+ await new Promise((resolve) => setTimeout(resolve, SHIM_READY_POLL_MS));
94
+ }
95
+ return false;
96
+ }
97
+
98
+ function drainToDebug(child: ChildProcess, serverId: string): void {
99
+ // supergateway / the wrapped server can be chatty. We never parse
100
+ // this output (readiness is an HTTP probe), but unread `pipe`
101
+ // buffers fill and block the child, so the streams MUST be
102
+ // consumed. Forward to debug so failures are still diagnosable.
103
+ const forward = (stream: Readable | null, channel: "stdout" | "stderr") => {
104
+ if (!stream) return;
105
+ stream.on("data", (chunk: Buffer) => {
106
+ log.debug("mcp-shim", `${channel}: ${chunk.toString().trimEnd()}`, { serverId });
107
+ });
108
+ };
109
+ forward(child.stdout, "stdout");
110
+ forward(child.stderr, "stderr");
111
+ }
112
+
113
+ /** Start a host-side stdio↔HTTP gateway for an opted-in stdio
114
+ * server. Returns a handle, or `null` when the gateway failed to
115
+ * come up (caller falls back to the safe default: drop the server).
116
+ * Never throws — a shim failure must not abort the agent turn.
117
+ *
118
+ * `workspacePath` is the chat workspace; the shim runs with it as
119
+ * cwd so relative args / config-file discovery match the normal
120
+ * (non-Docker) stdio execution semantics. */
121
+ export async function startStdioHttpShim(serverId: string, spec: McpStdioSpec, workspacePath: string): Promise<ShimHandle | null> {
122
+ // Windows host: the shell escaping below is POSIX-only and
123
+ // supergateway runs `--stdio` via cmd.exe here, so refuse rather
124
+ // than risk a misparse on an UNSANDBOXED path. Falls through to the
125
+ // safe default (server dropped + logged by the caller).
126
+ if (process.platform === "win32") {
127
+ log.warn("mcp-shim", "host-exec stdio shim is unsupported on Windows — dropping server", { serverId });
128
+ return null;
129
+ }
130
+
131
+ const port = await findAvailablePort(SHIM_PORT_RANGE_START);
132
+ if (port === null) {
133
+ log.warn("mcp-shim", "no free port for stdio→http shim — dropping server", { serverId });
134
+ return null;
135
+ }
136
+
137
+ const child = spawn("npx", ["-y", `supergateway@${SUPERGATEWAY_VERSION}`, "--stdio", buildStdioCommand(spec), "--port", String(port)], {
138
+ // Run from the chat workspace (parity with the non-Docker stdio
139
+ // path) so relative args / config lookups resolve identically.
140
+ cwd: workspacePath,
141
+ // Merge the spec env so the stdio child sees its required vars
142
+ // (API keys etc.); inherit the host env for npx/node resolution.
143
+ env: { ...process.env, ...(spec.env ?? {}) },
144
+ stdio: ["ignore", "pipe", "pipe"],
145
+ });
146
+ drainToDebug(child, serverId);
147
+ // Without an error listener a spawn failure (npx missing) would be
148
+ // an unhandled 'error' event → process crash. Same lesson as the
149
+ // claude-code backend's spawn guard.
150
+ let spawnFailed = false;
151
+ child.once("error", (err) => {
152
+ spawnFailed = true;
153
+ log.warn("mcp-shim", "supergateway spawn failed", { serverId, error: err instanceof Error ? err.message : String(err) });
154
+ });
155
+
156
+ const close = () => {
157
+ if (!child.killed) child.kill("SIGTERM");
158
+ };
159
+
160
+ const ready = !spawnFailed && (await waitUntilListening(child, port, () => spawnFailed));
161
+ if (!ready) {
162
+ close();
163
+ log.warn("mcp-shim", "stdio→http shim did not become ready — dropping server", { serverId, port });
164
+ return null;
165
+ }
166
+
167
+ log.info("mcp-shim", "stdio→http shim ready (host-exec, escapes sandbox)", { serverId, port });
168
+ // Loopback URL; the caller rewrites localhost→host.docker.internal
169
+ // for the in-container MCP config.
170
+ return { url: `http://127.0.0.1:${port}/sse`, close };
171
+ }
@@ -15,6 +15,11 @@ export type AgentEvent =
15
15
  type: typeof EVENT_TYPES.toolCallResult;
16
16
  toolUseId: string;
17
17
  content: string;
18
+ /** Anthropic's `tool_result` block carries `is_error: true` when
19
+ * the MCP server (or other tool) reported an error. Surfaced
20
+ * here so the failure monitor (#1353) can attribute repeated
21
+ * errors to a specific MCP server and warn / notify. */
22
+ isError?: boolean;
18
23
  }
19
24
  | { type: typeof EVENT_TYPES.claudeSessionId; id: string };
20
25
 
@@ -27,6 +32,10 @@ export interface ClaudeContentBlock {
27
32
  content?: unknown;
28
33
  /** Text content — present in `text` type blocks. */
29
34
  text?: string;
35
+ /** Tool-result error flag from the Anthropic API. Present on
36
+ * `tool_result` blocks when the tool itself reported failure
37
+ * (MCP server returned an error, 401, ECONNREFUSED, …). */
38
+ is_error?: boolean;
30
39
  }
31
40
 
32
41
  export interface ClaudeMessage {
@@ -73,11 +82,13 @@ export function blockToEvent(block: ClaudeContentBlock): AgentEvent | null {
73
82
  if (block.type === "tool_result" && block.tool_use_id) {
74
83
  const raw = block.content;
75
84
  const content = typeof raw === "string" ? raw : raw === undefined ? "" : JSON.stringify(raw);
76
- return {
85
+ const event: AgentEvent = {
77
86
  type: EVENT_TYPES.toolCallResult,
78
87
  toolUseId: block.tool_use_id,
79
88
  content,
80
89
  };
90
+ if (block.is_error === true) event.isError = true;
91
+ return event;
81
92
  }
82
93
  return null;
83
94
  }
@@ -0,0 +1,55 @@
1
+ // REST endpoint for the Encore plugin. Single POST dispatch route
2
+ // with a `kind` discriminator — matches the accounting / scheduler
3
+ // convention so the MCP bridge (which posts the tool args verbatim)
4
+ // and the click-handler page (`src/plugins/encore/View.vue`) plug in
5
+ // without translation.
6
+ //
7
+ // All business logic lives in `src/plugins/encore/server.ts`; this
8
+ // file is just the Express adapter (request validation, error
9
+ // mapping, logging).
10
+ //
11
+ // See plans/feat-encore-as-builtin.md.
12
+
13
+ import { Router, Request, Response } from "express";
14
+
15
+ import { dispatch, EncoreError, type EncoreDispatchBody } from "../../encore/dispatch.js";
16
+ import { API_ROUTES } from "../../../src/config/apiRoutes.js";
17
+ import { bindRoute } from "../../utils/router.js";
18
+ import { log } from "../../system/logger/index.js";
19
+ import { asyncHandler } from "../../utils/asyncHandler.js";
20
+
21
+ const router = Router();
22
+
23
+ interface EncoreErrorResponse {
24
+ error: string;
25
+ details?: unknown;
26
+ }
27
+
28
+ bindRoute(
29
+ router,
30
+ API_ROUTES.encore.dispatch,
31
+ asyncHandler<Request<object, unknown, EncoreDispatchBody>, Response<unknown | EncoreErrorResponse>>("encore", "encore dispatch failed", async (req, res) => {
32
+ const { body } = req;
33
+ if (!body || typeof body !== "object" || typeof body.kind !== "string") {
34
+ log.warn("encore", "POST dispatch: invalid body");
35
+ res.status(400).json({ error: "request body must be an object with a string `kind` field" });
36
+ return;
37
+ }
38
+ const { kind } = body;
39
+ log.info("encore", "POST dispatch: start", { kind });
40
+ try {
41
+ const result = await dispatch(body);
42
+ log.info("encore", "POST dispatch: ok", { kind, ok: result.ok });
43
+ res.json(result);
44
+ } catch (err) {
45
+ if (err instanceof EncoreError) {
46
+ log.warn("encore", "POST dispatch: error", { kind, status: err.status, message: err.message });
47
+ res.status(err.status).json({ error: err.message, details: err.details });
48
+ return;
49
+ }
50
+ throw err;
51
+ }
52
+ }),
53
+ );
54
+
55
+ export default router;
@@ -816,6 +816,22 @@ async function writeFileContent(absPath: string, content: string): Promise<void>
816
816
  }
817
817
  }
818
818
 
819
+ // JSON config files are editable from the Files Explorer (#833 Phase
820
+ // 1), but a hand-edit that breaks JSON syntax would corrupt a file the
821
+ // app (or the agent) parses on read. Reject a malformed save before it
822
+ // hits disk so the editor can surface the parser error inline. `.jsonl`
823
+ // is intentionally excluded — each line is its own document, not one
824
+ // JSON value, so `JSON.parse` of the whole file would always fail.
825
+ function jsonSyntaxError(relPath: string, content: string): string | null {
826
+ if (!relPath.toLowerCase().endsWith(".json")) return null;
827
+ try {
828
+ JSON.parse(content);
829
+ return null;
830
+ } catch (err) {
831
+ return `Invalid JSON: ${errorMessage(err)}`;
832
+ }
833
+ }
834
+
819
835
  // Write the body of an existing text file. Only text-classified files
820
836
  // (per `classify`) are editable — binary, image, audio, etc. are
821
837
  // refused so the endpoint can't be used to ship arbitrary uploads.
@@ -836,6 +852,12 @@ router.put(API_ROUTES.files.content, async (req: Request<object, unknown, WriteC
836
852
  else badRequest(res, resolved.message);
837
853
  return;
838
854
  }
855
+ const jsonError = jsonSyntaxError(relPath, content);
856
+ if (jsonError !== null) {
857
+ log.warn("files", "PUT content: invalid JSON", { pathPreview: previewSnippet(relPath) });
858
+ badRequest(res, jsonError);
859
+ return;
860
+ }
839
861
  try {
840
862
  await writeFileContent(resolved.absPath, content);
841
863
  } catch (err) {
@@ -27,7 +27,8 @@ import { mulmoScriptSchema, type MulmoBeat, type MulmoImagePromptMedia } from "@
27
27
  import { slugify } from "../../utils/slug.js";
28
28
  import { resolveWithinRoot } from "../../utils/files/safe.js";
29
29
  import { errorMessage } from "../../utils/errors.js";
30
- import { badRequest, notFound, serverError } from "../../utils/httpError.js";
30
+ import { badRequest, notFound, sendError, serverError } from "../../utils/httpError.js";
31
+ import { depStatus } from "../../system/optionalDeps.js";
31
32
  import { getOptionalStringQuery, getSessionQuery } from "../../utils/request.js";
32
33
  import { log } from "../../system/logger/index.js";
33
34
  import { validateUpdateBeatBody, validateUpdateScriptBody } from "./mulmoScriptValidate.js";
@@ -37,6 +38,20 @@ import { publishGeneration } from "../../events/session-store/index.js";
37
38
  import { GENERATION_KINDS } from "../../../src/types/events.js";
38
39
 
39
40
  const router = Router();
41
+
42
+ // mulmocast shells out to ffmpeg for movie / beat rendering. When
43
+ // ffmpeg is absent the optional-deps probe (#1385) marks it
44
+ // unavailable; intercept here with a clear 503 instead of letting
45
+ // the library throw an opaque spawn ENOENT mid-stream. `undefined`
46
+ // means the boot probe hasn't completed — assume available so a
47
+ // brief startup window never blocks a render.
48
+ function ffmpegUnavailable(res: Response): boolean {
49
+ if (depStatus("ffmpeg")?.available === false) {
50
+ sendError(res, 503, "ffmpeg is not installed — movie and beat rendering are unavailable. Install ffmpeg and restart the server.");
51
+ return true;
52
+ }
53
+ return false;
54
+ }
40
55
  const storiesDir = path.resolve(WORKSPACE_PATHS.stories);
41
56
 
42
57
  // The downloadMovie handler expects "stories/<rel>" (historical
@@ -591,6 +606,7 @@ bindRoute(router, API_ROUTES.mulmoScript.renderBeat, async (req: Request<object,
591
606
  badRequest(res, "filePath and beatIndex are required");
592
607
  return;
593
608
  }
609
+ if (ffmpegUnavailable(res)) return;
594
610
 
595
611
  const key = String(beatIndex);
596
612
  publishGeneration(chatSessionId, GENERATION_KINDS.beatImage, filePath, key, false);
@@ -693,6 +709,8 @@ bindRoute(router, API_ROUTES.mulmoScript.generateMovie, async (req: Request<obje
693
709
  return;
694
710
  }
695
711
 
712
+ if (ffmpegUnavailable(res)) return;
713
+
696
714
  const absoluteFilePath = resolveStoryPath(filePath, res);
697
715
  if (!absoluteFilePath) return;
698
716
 
@@ -28,6 +28,34 @@ export type SchedulerActionResult =
28
28
  jsonData: Record<string, unknown>;
29
29
  };
30
30
 
31
+ // Coerces the untrusted `props` payload into a safe-to-store
32
+ // object. Two responsibilities:
33
+ //
34
+ // 1. Non-object input (a number / string / array / null from
35
+ // untrusted JSON) becomes an empty props object — `"endDate"
36
+ // in 1` would otherwise throw a TypeError and surface as a
37
+ // 500 via the asyncHandler.
38
+ // 2. Non-string `endDate` (number / array / object) is dropped —
39
+ // downstream comparisons (`end < start`) only make sense for
40
+ // strings, and a stray number triggers a coercion bug.
41
+ //
42
+ // Notably we DO preserve malformed `endDate` STRINGS (e.g. "next
43
+ // Friday", "2026-05-25" before a start of "2026-05-27"). The view
44
+ // surfaces those as a "broken range" chip so the user/LLM gets
45
+ // visible feedback instead of having the bad data silently erased.
46
+ export function sanitizeProps(props: unknown): ScheduledItem["props"] {
47
+ if (typeof props !== "object" || props === null || Array.isArray(props)) {
48
+ return {};
49
+ }
50
+ const record = props as ScheduledItem["props"];
51
+ if (!("endDate" in record)) return record;
52
+ if (typeof record.endDate === "string") return record;
53
+ if (record.endDate === null) return record;
54
+ const next = { ...record };
55
+ Reflect.deleteProperty(next, "endDate");
56
+ return next;
57
+ }
58
+
31
59
  export function sortItems(items: ScheduledItem[]): ScheduledItem[] {
32
60
  return [...items].sort((left, right) => {
33
61
  const leftDate = typeof left.props.date === "string" ? left.props.date : null;
@@ -57,7 +85,7 @@ export function handleAdd(items: ScheduledItem[], input: SchedulerActionInput):
57
85
  id: makeId("sched"),
58
86
  title: input.title,
59
87
  createdAt: Date.now(),
60
- props: input.props ?? {},
88
+ props: sanitizeProps(input.props ?? {}),
61
89
  };
62
90
  const next = sortItems([...items, item]);
63
91
  return {
@@ -107,10 +135,11 @@ export function handleUpdate(items: ScheduledItem[], input: SchedulerActionInput
107
135
  jsonData: {},
108
136
  };
109
137
  }
138
+ const mergedProps = input.props !== undefined ? applyPropPatch(target.props, input.props) : target.props;
110
139
  const updated: ScheduledItem = {
111
140
  ...target,
112
141
  title: input.title !== undefined ? input.title : target.title,
113
- props: input.props !== undefined ? applyPropPatch(target.props, input.props) : target.props,
142
+ props: sanitizeProps(mergedProps),
114
143
  };
115
144
  const next = sortItems(items.map((i) => (i.id === input.id ? updated : i)));
116
145
  return {
@@ -121,16 +150,35 @@ export function handleUpdate(items: ScheduledItem[], input: SchedulerActionInput
121
150
  };
122
151
  }
123
152
 
153
+ // `replace` accepts an arbitrary array from untrusted JSON, so
154
+ // every item needs the same shape narrowing the in-memory store
155
+ // guarantees: a non-empty string `id` (the dispatch primary key
156
+ // AND the input to the per-event colour-hash, both of which crash
157
+ // or misbehave on non-strings), a string `title`, a numeric
158
+ // `createdAt`, and a sanitised `props`. Non-object items are
159
+ // dropped; objects with missing/malformed required fields get a
160
+ // safe default (newly minted id, empty title, current timestamp)
161
+ // rather than failing the whole replace.
162
+ export function sanitizeItem(raw: unknown): ScheduledItem | null {
163
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
164
+ const obj = raw as Partial<ScheduledItem>;
165
+ const itemId = typeof obj.id === "string" && obj.id.length > 0 ? obj.id : makeId("sched");
166
+ const title = typeof obj.title === "string" ? obj.title : "";
167
+ const createdAt = typeof obj.createdAt === "number" && Number.isFinite(obj.createdAt) ? obj.createdAt : Date.now();
168
+ return { id: itemId, title, createdAt, props: sanitizeProps(obj.props) };
169
+ }
170
+
124
171
  export function handleReplace(_items: ScheduledItem[], input: SchedulerActionInput): SchedulerActionResult {
125
172
  if (!Array.isArray(input.items)) {
126
173
  return { kind: "error", status: 400, error: "items array required" };
127
174
  }
128
- const next = sortItems(input.items);
175
+ const sanitized = input.items.map(sanitizeItem).filter((item): item is ScheduledItem => item !== null);
176
+ const next = sortItems(sanitized);
129
177
  return {
130
178
  kind: "success",
131
179
  items: next,
132
180
  message: `Replaced all items (${next.length} total)`,
133
- jsonData: { count: next.length },
181
+ jsonData: { count: next.length, dropped: input.items.length - next.length },
134
182
  };
135
183
  }
136
184
 
@@ -79,7 +79,18 @@ export interface SessionSummary {
79
79
  isBookmarked?: boolean;
80
80
  // Live state from the in-memory session store. Absent when the
81
81
  // session has no active entry in the store (i.e. idle / historical).
82
+ //
83
+ // `isRunning` is the BROAD predicate: agent turn live OR any
84
+ // background generation (image/audio/movie) still pending. Drives
85
+ // the sidebar "busy" indicator that must stay lit across nav.
86
+ //
87
+ // `liveIsRunning` is the NARROW predicate: exactly the
88
+ // `DELETE /api/sessions/:id` 409 gate (`getSession()?.isRunning`).
89
+ // Exposed for cleanup-style callers (e2e-live `waitForSessionIdle`)
90
+ // that need to poll "is DELETE accepted yet" without over-waiting
91
+ // on lingering pendingGenerations. See issue #1195.
82
92
  isRunning?: boolean;
93
+ liveIsRunning?: boolean;
83
94
  hasUnread?: boolean;
84
95
  statusMessage?: string;
85
96
  }
@@ -149,6 +160,10 @@ function buildSessionSummary(
149
160
  // "busy" even when the agent turn has ended, so the sidebar
150
161
  // indicator stays lit across view navigation.
151
162
  summary.isRunning = live.isRunning || Object.keys(live.pendingGenerations).length > 0;
163
+ // Narrow predicate — must stay byte-identical to the DELETE
164
+ // 409 gate (`getSession(sessionId)?.isRunning`) so a caller
165
+ // polling this can trust "false ⇒ DELETE will be accepted".
166
+ summary.liveIsRunning = live.isRunning;
152
167
  summary.statusMessage = live.statusMessage;
153
168
  }
154
169
  return summary;