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.
- package/README.md +26 -0
- package/bin/mulmoclaude.js +11 -1
- package/client/assets/JsonEditor-D6WBWLoa.js +10 -0
- package/client/assets/JsonEditor-Di5xGeZY.css +1 -0
- package/client/assets/_plugin-vue_export-helper-BOai-rQB.js +1 -0
- package/client/assets/chunk-D8eiyYIV-LcKZGJv5.js +1 -0
- package/client/assets/{html2canvas-CDGcmOD3-Bkf2uOth.js → html2canvas-CDGcmOD3-XVrO-eyz.js} +1 -1
- package/client/assets/index-CyBr8Mkr.css +2 -0
- package/client/assets/index-zZIqEbNX.js +5106 -0
- package/client/assets/{index.es-DqtpmBm8-D9mAh_KQ.js → index.es-DqtpmBm8-DHT6q10o.js} +1 -1
- package/client/assets/material-symbols-outlined-DtIK7AQn.woff2 +0 -0
- package/client/assets/runtime-protocol-vue-D6kcV0wa.js +1 -0
- package/client/assets/{runtime-vue-BVUzgYGA.js → runtime-vue-fFYhnNg3.js} +1 -1
- package/client/assets/{vue-C8UuIO9J.js → vue-D4w8THF_.js} +1 -1
- package/client/assets/vue-i18n-CQbxVmNs.js +3 -0
- package/client/assets/vue.runtime.esm-bundler-BTyIdNAI.js +4 -0
- package/client/index.html +10 -10
- package/package.json +9 -8
- package/server/agent/backend/claude-code.ts +34 -0
- package/server/agent/backend/fake-echo.ts +370 -0
- package/server/agent/backend/index.ts +16 -1
- package/server/agent/config.ts +74 -24
- package/server/agent/index.ts +104 -80
- package/server/agent/mcpFailureMonitor.ts +167 -0
- package/server/agent/mcpPreflight.ts +185 -0
- package/server/agent/prompt.ts +50 -359
- package/server/agent/stdioHttpShim.ts +171 -0
- package/server/agent/stream.ts +12 -1
- package/server/api/routes/encore.ts +55 -0
- package/server/api/routes/files.ts +22 -0
- package/server/api/routes/mulmo-script.ts +19 -1
- package/server/api/routes/schedulerHandlers.ts +52 -4
- package/server/api/routes/sessions.ts +15 -0
- package/server/api/routes/skills.ts +263 -0
- package/server/build/dispatcher.mjs +299 -0
- package/server/encore/INVARIANTS.md +272 -0
- package/server/encore/boot.ts +39 -0
- package/server/encore/closure.ts +36 -0
- package/server/encore/cycle.ts +276 -0
- package/server/encore/dispatch.ts +103 -0
- package/server/encore/handlers/amend.ts +99 -0
- package/server/encore/handlers/appendNote.ts +74 -0
- package/server/encore/handlers/defineEncore.ts +42 -0
- package/server/encore/handlers/listTickets.ts +107 -0
- package/server/encore/handlers/markStepDone.ts +41 -0
- package/server/encore/handlers/markTargetSkipped.ts +33 -0
- package/server/encore/handlers/query.ts +138 -0
- package/server/encore/handlers/recordValues.ts +44 -0
- package/server/encore/handlers/resolveNotification.ts +121 -0
- package/server/encore/handlers/setup.ts +81 -0
- package/server/encore/handlers/shared.ts +137 -0
- package/server/encore/handlers/snooze.ts +87 -0
- package/server/encore/handlers/startObligationChat.ts +64 -0
- package/server/encore/handlers/startSetupChat.ts +50 -0
- package/server/encore/lock.ts +61 -0
- package/server/encore/notifier.ts +123 -0
- package/server/encore/obligation.ts +25 -0
- package/server/encore/paths.ts +78 -0
- package/server/encore/reconcile.ts +661 -0
- package/server/encore/tick.ts +191 -0
- package/server/encore/yaml-fm.ts +63 -0
- package/server/events/notifications.ts +19 -91
- package/server/index.ts +94 -9
- package/server/notifier/engine.ts +102 -1
- package/server/notifier/macosReminderAdapter.ts +30 -0
- package/server/notifier/runtime-api.ts +41 -1
- package/server/notifier/types.ts +15 -2
- package/server/plugins/runtime.ts +11 -2
- package/server/prompts/index.ts +39 -0
- package/server/prompts/system/journal-pointer.md +12 -0
- package/server/prompts/system/memory-management-atomic.md +33 -0
- package/server/prompts/system/memory-management-topic.md +60 -0
- package/server/prompts/system/news-concierge.md +24 -0
- package/server/prompts/system/sandbox-tools.md +10 -0
- package/server/prompts/system/sources-context.md +16 -0
- package/server/prompts/system/system.md +91 -0
- package/server/system/announceOptionalDeps.ts +57 -0
- package/server/system/appVersion.ts +34 -0
- package/server/system/config.ts +17 -1
- package/server/system/docker.ts +14 -6
- package/server/system/env.ts +18 -5
- package/server/system/optionalDeps.ts +129 -0
- package/server/utils/cli-flags.d.mts +14 -0
- package/server/utils/cli-flags.mjs +53 -0
- package/server/utils/files/encore-io.ts +111 -0
- package/server/utils/time.ts +6 -0
- package/server/workspace/helps/business.md +2 -2
- package/server/workspace/helps/encore-dsl.md +482 -0
- package/server/workspace/helps/index.md +15 -13
- package/server/workspace/helps/mulmoscript.md +3 -3
- package/server/workspace/helps/sandbox.md +2 -2
- package/server/workspace/hooks/dispatcher.ts +7 -5
- package/server/workspace/hooks/provision.ts +6 -3
- package/server/workspace/paths.ts +13 -4
- package/server/workspace/skills/catalog.ts +355 -0
- package/server/workspace/skills/external/catalog.ts +283 -0
- package/server/workspace/skills/external/clone.ts +129 -0
- package/server/workspace/skills/external/id.ts +194 -0
- package/server/workspace/skills/external/install.ts +417 -0
- package/server/workspace/skills/external/presets.ts +50 -0
- package/server/workspace/skills-preset.ts +29 -17
- package/server/workspace/workspace.ts +10 -5
- package/src/App.vue +37 -8
- package/src/components/FileContentRenderer.vue +102 -9
- package/src/components/JsonEditor.vue +160 -0
- package/src/components/NotificationBell.vue +35 -3
- package/src/components/PluginLauncher.vue +20 -41
- package/src/components/RightSidebar.vue +19 -0
- package/src/components/SettingsMcpTab.vue +58 -11
- package/src/components/SettingsModal.vue +22 -1
- package/src/components/StackView.vue +10 -1
- package/src/components/TodoExplorer.vue +16 -0
- package/src/components/todo/TodoKanbanView.vue +34 -6
- package/src/composables/useNotifications.ts +21 -1
- package/src/config/apiRoutes.ts +0 -6
- package/src/config/mcpCatalog.ts +12 -7
- package/src/config/mcpTypes.ts +5 -0
- package/src/config/roles.ts +52 -15
- package/src/config/systemFileDescriptors.ts +12 -0
- package/src/lang/de.ts +108 -12
- package/src/lang/en.ts +105 -11
- package/src/lang/es.ts +106 -11
- package/src/lang/fr.ts +106 -11
- package/src/lang/ja.ts +104 -11
- package/src/lang/ko.ts +105 -11
- package/src/lang/pt-BR.ts +106 -11
- package/src/lang/zh.ts +103 -11
- package/src/main.ts +1 -0
- package/src/plugins/_generated/metas.ts +4 -0
- package/src/plugins/_generated/registrations.ts +2 -0
- package/src/plugins/_generated/server-bindings.ts +5 -0
- package/src/plugins/encore/EncoreDashboard.vue +504 -0
- package/src/plugins/encore/EncoreRedirect.vue +116 -0
- package/src/plugins/encore/View.vue +36 -0
- package/src/plugins/encore/defineEncoreDefinition.ts +74 -0
- package/src/plugins/encore/defineEncoreMeta.ts +13 -0
- package/src/plugins/encore/index.ts +93 -0
- package/src/plugins/encore/manageEncoreDefinition.ts +100 -0
- package/src/plugins/encore/manageEncoreMeta.ts +36 -0
- package/src/plugins/manageSkills/View.vue +832 -30
- package/src/plugins/manageSkills/categories.ts +125 -0
- package/src/plugins/manageSkills/meta.ts +30 -0
- package/src/plugins/markdown/definition.ts +3 -3
- package/src/plugins/meta-types.ts +5 -0
- package/src/plugins/presentMulmoScript/Preview.vue +3 -3
- package/src/plugins/presentMulmoScript/View.vue +157 -33
- package/src/plugins/presentMulmoScript/meta.ts +4 -0
- package/src/plugins/scheduler/View.vue +45 -9
- package/src/plugins/scheduler/calendarDefinition.ts +6 -2
- package/src/plugins/scheduler/multiDayHelpers.ts +95 -0
- package/src/plugins/skill/View.vue +1 -5
- package/src/plugins/spreadsheet/View.vue +3 -3
- package/src/plugins/spreadsheet/definition.ts +1 -1
- package/src/plugins/textResponse/Preview.vue +14 -1
- package/src/plugins/textResponse/View.vue +39 -24
- package/src/plugins/wiki/components/WikiPageBody.vue +4 -0
- package/src/router/index.ts +11 -0
- package/src/router/pageRoutes.ts +1 -0
- package/src/types/encore-dsl/at-expression.ts +120 -0
- package/src/types/encore-dsl/at-resolver.ts +32 -0
- package/src/types/encore-dsl/cadence.ts +289 -0
- package/src/types/encore-dsl/schema.ts +288 -0
- package/src/types/notification.ts +2 -1
- package/src/types/session.ts +6 -0
- package/src/types/sse.ts +5 -0
- package/src/types/toolCallHistory.ts +7 -0
- package/src/utils/agent/eventDispatch.ts +26 -5
- package/src/utils/agent/mcpHint.ts +50 -0
- package/src/utils/image/htmlSrcAttrs.ts +117 -13
- package/src/utils/session/sessionEntries.ts +8 -32
- package/client/assets/PluginScopedRoot-YjvQq0Nn.js +0 -3
- package/client/assets/chunk-CernVdwh.js +0 -1
- package/client/assets/chunk-D8eiyYIV-CAXpUwLd.js +0 -1
- package/client/assets/index-BwrlMMHr.js +0 -5005
- package/client/assets/index-CvvNuegU.css +0 -2
- package/client/assets/material-symbols-outlined-BOZVWuR3.woff2 +0 -0
- package/client/assets/runtime-protocol-vue-C1To4M3t.js +0 -1
- package/client/assets/vue.runtime.esm-bundler-DQ8Kjjui.js +0 -4
- package/server/api/routes/notifications.ts +0 -195
- package/server/notifier/legacy-adapters.ts +0 -76
- package/server/workspace/hooks/dispatcher.mjs +0 -300
- 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
|
+
}
|
package/server/agent/stream.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
|
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;
|