mulmoclaude 0.6.2 → 0.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -0
- package/bin/mulmoclaude.js +11 -1
- package/client/assets/chunk-D8eiyYIV-CW0rPbG2.js +1 -0
- package/client/assets/{html2canvas-CDGcmOD3-Bkf2uOth.js → html2canvas-CDGcmOD3-BjwfzAN8.js} +1 -1
- package/client/assets/index-Bp1owZ-i.js +5101 -0
- package/client/assets/index-c63H1pnd.css +2 -0
- package/client/assets/{index.es-DqtpmBm8-D9mAh_KQ.js → index.es-DqtpmBm8-DudYPW7R.js} +1 -1
- package/client/assets/material-symbols-outlined-C0dZ3SlO.woff2 +0 -0
- package/client/assets/runtime-protocol-vue-BUk5WXSy.js +1 -0
- package/client/assets/{runtime-vue-BVUzgYGA.js → runtime-vue-fFYhnNg3.js} +1 -1
- package/client/assets/{vue-C8UuIO9J.js → vue-Kqzpl9Vx.js} +1 -1
- package/client/assets/vue.runtime.esm-bundler-BTyIdNAI.js +4 -0
- package/client/index.html +9 -11
- package/package.json +5 -4
- 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 +8 -1
- package/server/agent/mcpFailureMonitor.ts +167 -0
- package/server/agent/mcpPreflight.ts +185 -0
- package/server/agent/stream.ts +12 -1
- 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/events/notifications.ts +19 -91
- package/server/index.ts +87 -9
- package/server/notifier/macosReminderAdapter.ts +30 -0
- package/server/system/announceOptionalDeps.ts +50 -0
- package/server/system/config.ts +8 -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/time.ts +6 -0
- package/server/workspace/helps/business.md +2 -2
- package/server/workspace/helps/mulmoscript.md +3 -3
- package/server/workspace/helps/sandbox.md +2 -2
- package/server/workspace/hooks/dispatcher.mjs +1 -1
- 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 +19 -8
- package/src/components/RightSidebar.vue +19 -0
- package/src/components/StackView.vue +10 -1
- package/src/config/apiRoutes.ts +0 -6
- package/src/config/roles.ts +2 -0
- package/src/lang/de.ts +50 -1
- package/src/lang/en.ts +49 -1
- package/src/lang/es.ts +49 -1
- package/src/lang/fr.ts +49 -1
- package/src/lang/ja.ts +49 -1
- package/src/lang/ko.ts +49 -1
- package/src/lang/pt-BR.ts +49 -1
- package/src/lang/zh.ts +49 -1
- package/src/plugins/manageSkills/View.vue +795 -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/spreadsheet/View.vue +3 -3
- package/src/types/notification.ts +1 -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/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/src/composables/useSelectedResult.ts +0 -49
- /package/client/assets/{purify.es-Fx1Nqyry-Dwtk-9WZ.js → purify.es-Fx1Nqyry-B3aL7Uvj.js} +0 -0
- /package/client/assets/{typeof-DBp4T-Ny-CSr8wx1e.js → typeof-DBp4T-Ny-Bef7RiR_.js} +0 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Boot-time + per-agent-run preflight for external MCP servers
|
|
2
|
+
// (#1352).
|
|
3
|
+
//
|
|
4
|
+
// Built-in MCP-only tools have always done this via
|
|
5
|
+
// `isMcpToolEnabled` + `logMcpStatus` (server/index.ts:750) — when an
|
|
6
|
+
// env var listed in `requiredEnv` is unset, the tool drops out of
|
|
7
|
+
// the list and the operator sees an info log explaining why. External
|
|
8
|
+
// MCP servers (the `mcp.json` ones — Notion / GitHub / Linear /…)
|
|
9
|
+
// had no equivalent, so a half-configured catalog entry would still
|
|
10
|
+
// spawn a subprocess and every tool call would fail silently with
|
|
11
|
+
// 401. This module is the parity fix.
|
|
12
|
+
//
|
|
13
|
+
// The catalog (`src/config/mcpCatalog.ts`) declares which config
|
|
14
|
+
// fields are `required: true`. The user's saved `mcp.json` holds
|
|
15
|
+
// resolved values. Cross-referencing the two tells us which servers
|
|
16
|
+
// are ready to boot and which should be excluded from the config
|
|
17
|
+
// handed to Claude Code.
|
|
18
|
+
|
|
19
|
+
import type { McpServerSpec } from "../system/config.js";
|
|
20
|
+
import { findCatalogEntry, requiredKeysOf, type McpCatalogEntry } from "../../src/config/mcpCatalog.js";
|
|
21
|
+
import { log } from "../system/logger/index.js";
|
|
22
|
+
|
|
23
|
+
export interface McpPreflightResult {
|
|
24
|
+
/** Servers that passed preflight, keyed by the same id used in
|
|
25
|
+
* the input. Safe to pass straight into `prepareUserServers` /
|
|
26
|
+
* `buildMcpConfig`. */
|
|
27
|
+
ready: Record<string, McpServerSpec>;
|
|
28
|
+
/** Servers excluded by preflight, with the catalog field keys
|
|
29
|
+
* whose values were unset / unresolved. */
|
|
30
|
+
skipped: { serverId: string; missing: string[] }[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const PLACEHOLDER_PATTERN = /\$\{([A-Z0-9_]+)\}/g;
|
|
34
|
+
const SINGLE_PLACEHOLDER = /^\$\{([A-Z0-9_]+)\}$/;
|
|
35
|
+
|
|
36
|
+
/** Returns the catalog field keys whose values are unresolved in
|
|
37
|
+
* the user's saved spec — `""`, missing, or still carrying a
|
|
38
|
+
* `${KEY}` placeholder.
|
|
39
|
+
*
|
|
40
|
+
* Mapping goes: catalog `configSchema[].key` → spec env key, via
|
|
41
|
+
* the catalog template's env value. E.g. catalog template
|
|
42
|
+
* `env: { NOTION_TOKEN: "${NOTION_API_KEY}" }` binds the field
|
|
43
|
+
* `NOTION_API_KEY` to the env key `NOTION_TOKEN`. We then check
|
|
44
|
+
* the user's saved spec's `env.NOTION_TOKEN`.
|
|
45
|
+
*
|
|
46
|
+
* HTTP-type catalog entries currently have no required fields
|
|
47
|
+
* (deepwiki is empty) — they fall through with `[]`. When a
|
|
48
|
+
* required HTTP header lands in the catalog, extend this helper. */
|
|
49
|
+
export function findMissingRequiredEnv(entry: McpCatalogEntry, spec: McpServerSpec): string[] {
|
|
50
|
+
// Transport mismatch (e.g. catalog stdio entry but user pointed
|
|
51
|
+
// the same id at an HTTP URL) means the catalog's env template
|
|
52
|
+
// doesn't apply to this user spec — see `preflightUserServers`'s
|
|
53
|
+
// header comment for the rationale. Guard here too so callers that
|
|
54
|
+
// skip the wrapper still get the correct answer.
|
|
55
|
+
if (entry.spec.type !== spec.type) return [];
|
|
56
|
+
if (entry.spec.type !== "stdio" || !entry.spec.env) return [];
|
|
57
|
+
const fieldToEnvKey = buildFieldToEnvKeyMap(entry.spec.env);
|
|
58
|
+
const userEnv = spec.type === "stdio" ? spec.env : undefined;
|
|
59
|
+
const required = requiredKeysOf(entry);
|
|
60
|
+
const missing: string[] = [];
|
|
61
|
+
for (const fieldKey of required) {
|
|
62
|
+
const envKey = fieldToEnvKey.get(fieldKey);
|
|
63
|
+
if (envKey === undefined) continue;
|
|
64
|
+
const value = userEnv?.[envKey];
|
|
65
|
+
if (!isResolved(value)) missing.push(fieldKey);
|
|
66
|
+
}
|
|
67
|
+
return missing;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function buildFieldToEnvKeyMap(templateEnv: Record<string, string>): Map<string, string> {
|
|
71
|
+
const out = new Map<string, string>();
|
|
72
|
+
for (const [envKey, value] of Object.entries(templateEnv)) {
|
|
73
|
+
const match = SINGLE_PLACEHOLDER.exec(value);
|
|
74
|
+
if (match) out.set(match[1], envKey);
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isResolved(value: string | undefined): boolean {
|
|
80
|
+
if (typeof value !== "string") return false;
|
|
81
|
+
// Trim before the empty check — `" "` (whitespace-only) is just
|
|
82
|
+
// as misconfigured as `""` and would otherwise let preflight
|
|
83
|
+
// greenlight a server that can't actually authenticate (Codex
|
|
84
|
+
// review on #1355).
|
|
85
|
+
if (value.trim().length === 0) return false;
|
|
86
|
+
PLACEHOLDER_PATTERN.lastIndex = 0;
|
|
87
|
+
return !PLACEHOLDER_PATTERN.test(value);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Filter user MCP servers by checking the catalog's required
|
|
91
|
+
* fields. Servers without a catalog match (= user-added custom
|
|
92
|
+
* servers) pass through — we have no metadata to validate them
|
|
93
|
+
* against.
|
|
94
|
+
*
|
|
95
|
+
* Two other shapes also pass through unvalidated:
|
|
96
|
+
*
|
|
97
|
+
* - `enabled: false` entries (CodeRabbit review on #1355). They're
|
|
98
|
+
* intentionally disabled by the user; running them through
|
|
99
|
+
* preflight produces spurious "missing required config" warnings
|
|
100
|
+
* AND skews the boot summary's `started` count. The downstream
|
|
101
|
+
* `prepareUserServers` already drops disabled entries before
|
|
102
|
+
* spawning anything, so we just forward them.
|
|
103
|
+
*
|
|
104
|
+
* - Type-mismatched catalog hits. If the user's mcp.json has
|
|
105
|
+
* `gmail: { type: "http", url: ... }` but the catalog's `gmail`
|
|
106
|
+
* entry is `type: "stdio"` with env templates, the catalog's
|
|
107
|
+
* requirement list doesn't apply to the user's spec — they've
|
|
108
|
+
* pointed `gmail` at a different transport, effectively making it
|
|
109
|
+
* a custom server. Treat as custom (no preflight) rather than
|
|
110
|
+
* false-flagging missing env. */
|
|
111
|
+
export function preflightUserServers(userServers: Record<string, McpServerSpec> | undefined | null): McpPreflightResult {
|
|
112
|
+
const ready: Record<string, McpServerSpec> = {};
|
|
113
|
+
const skipped: McpPreflightResult["skipped"] = [];
|
|
114
|
+
// Defensive default (Sourcery review on #1355): a malformed
|
|
115
|
+
// mcp.json — or a future refactor that nulls `mcpServers` — would
|
|
116
|
+
// otherwise throw `Object.entries(null)` at boot.
|
|
117
|
+
for (const [serverId, spec] of Object.entries(userServers ?? {})) {
|
|
118
|
+
if (spec.enabled === false) {
|
|
119
|
+
ready[serverId] = spec;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const entry = findCatalogEntry(serverId);
|
|
123
|
+
if (entry === null || entry.spec.type !== spec.type) {
|
|
124
|
+
ready[serverId] = spec;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const missing = findMissingRequiredEnv(entry, spec);
|
|
128
|
+
if (missing.length > 0) {
|
|
129
|
+
skipped.push({ serverId, missing: missing.sort() });
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
ready[serverId] = spec;
|
|
133
|
+
}
|
|
134
|
+
return { ready, skipped };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Snapshot of the previous run's skipped set so per-agent-run logging
|
|
138
|
+
// only fires on state changes. Boot-time logging always fires (clean
|
|
139
|
+
// startup signal) and seeds the snapshot for subsequent runs.
|
|
140
|
+
//
|
|
141
|
+
// The earlier shape — a monotonic Set that only ever grew — would
|
|
142
|
+
// swallow a `missing → fixed → missing again` regression: the second
|
|
143
|
+
// "missing" emitted no warning because the key had already been
|
|
144
|
+
// logged on the first one (Codex review on #1355). Snapshot diffing
|
|
145
|
+
// fixes that without losing the dedup property: identical state
|
|
146
|
+
// across turns still logs at most once.
|
|
147
|
+
let lastSkippedKeys = new Set<string>();
|
|
148
|
+
|
|
149
|
+
function dedupKey(entry: { serverId: string; missing: string[] }): string {
|
|
150
|
+
return `${entry.serverId}:${entry.missing.join(",")}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Emit structured logs for the preflight outcome.
|
|
154
|
+
* - `source: "boot"` — runs once at startup; always logs and
|
|
155
|
+
* seeds the snapshot.
|
|
156
|
+
* - `source: "agent-run"` — runs per agent invocation; logs only
|
|
157
|
+
* entries that are new vs the previous run's snapshot. A server
|
|
158
|
+
* that re-enters a broken state after being fixed will log again
|
|
159
|
+
* because the key is absent from the snapshot. */
|
|
160
|
+
export function logPreflightResult(result: McpPreflightResult, source: "boot" | "agent-run"): void {
|
|
161
|
+
const isBoot = source === "boot";
|
|
162
|
+
const currentKeys = new Set(result.skipped.map(dedupKey));
|
|
163
|
+
for (const entry of result.skipped) {
|
|
164
|
+
const key = dedupKey(entry);
|
|
165
|
+
if (!isBoot && lastSkippedKeys.has(key)) continue;
|
|
166
|
+
log.warn("mcp", "preflight: skipping server — missing required config", {
|
|
167
|
+
source,
|
|
168
|
+
serverId: entry.serverId,
|
|
169
|
+
missing: entry.missing,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
lastSkippedKeys = currentKeys;
|
|
173
|
+
if (isBoot) {
|
|
174
|
+
log.info("mcp", "preflight summary", {
|
|
175
|
+
started: Object.keys(result.ready).length,
|
|
176
|
+
skipped: result.skipped.length,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Test seam — reset the snapshot between tests so each case sees a
|
|
182
|
+
* fresh logging state. */
|
|
183
|
+
export function _resetPreflightLogCache(): void {
|
|
184
|
+
lastSkippedKeys = new Set();
|
|
185
|
+
}
|
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
|
}
|
|
@@ -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;
|
|
@@ -15,6 +15,20 @@
|
|
|
15
15
|
import { Router, Request, Response } from "express";
|
|
16
16
|
import { deleteProjectSkill, discoverSkills, saveProjectSkill, updateProjectSkill } from "../../workspace/skills/index.js";
|
|
17
17
|
import type { Skill, SkillSummary } from "../../workspace/skills/index.js";
|
|
18
|
+
import {
|
|
19
|
+
isCatalogSource,
|
|
20
|
+
listCatalogEntries,
|
|
21
|
+
readCatalogEntryDetail,
|
|
22
|
+
readExternalDetailAsCatalog,
|
|
23
|
+
starCatalogEntry,
|
|
24
|
+
starExternalAsCatalog,
|
|
25
|
+
type CatalogEntry,
|
|
26
|
+
type CatalogEntryDetail,
|
|
27
|
+
type CatalogDetailResult,
|
|
28
|
+
type StarResult,
|
|
29
|
+
} from "../../workspace/skills/catalog.js";
|
|
30
|
+
import { installExternalRepo, listInstalledRepos, uninstallExternalRepo, type InstalledRepo } from "../../workspace/skills/external/install.js";
|
|
31
|
+
import { EXTERNAL_PRESETS, type ExternalPresetSuggestion } from "../../workspace/skills/external/presets.js";
|
|
18
32
|
import { workspacePath } from "../../workspace/workspace.js";
|
|
19
33
|
import { API_ROUTES } from "../../../src/config/apiRoutes.js";
|
|
20
34
|
import { bindRoute } from "../../utils/router.js";
|
|
@@ -65,6 +79,255 @@ bindRoute(router, API_ROUTES.skills.list, async (_req: Request, res: Response<Sk
|
|
|
65
79
|
});
|
|
66
80
|
});
|
|
67
81
|
|
|
82
|
+
// Catalog endpoints (#1335 PR-B). Reads from
|
|
83
|
+
// `<workspace>/data/skills/catalog/<source>/<slug>/` (populated by
|
|
84
|
+
// `syncPresetSkills`); the star endpoint copies catalog entries
|
|
85
|
+
// into `.claude/skills/<slug>/` so Claude Code's discovery picks
|
|
86
|
+
// them up. Catalog entries themselves are NOT in `.claude/skills/`
|
|
87
|
+
// by design — that's the prompt-bloat fix from #1335.
|
|
88
|
+
//
|
|
89
|
+
// Route ordering matters: these `/catalog*` routes register
|
|
90
|
+
// BEFORE `GET /:name` below because Express matches in
|
|
91
|
+
// registration order. A request for `/catalog` would otherwise
|
|
92
|
+
// land in the detail handler with `req.params.name = "catalog"`
|
|
93
|
+
// and 404. Keep all `/catalog*` specifics ahead of any `:name`
|
|
94
|
+
// parameter route in this file.
|
|
95
|
+
|
|
96
|
+
interface CatalogListResponse {
|
|
97
|
+
entries: CatalogEntry[];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface StarResponse {
|
|
101
|
+
starred: true;
|
|
102
|
+
slug: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
bindRoute(router, API_ROUTES.skills.catalogList, async (_req: Request, res: Response<CatalogListResponse>) => {
|
|
106
|
+
const entries = await listCatalogEntries();
|
|
107
|
+
log.info("skills", "catalog list: ok", { count: entries.length });
|
|
108
|
+
res.json({ entries });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
interface CatalogPreviewQuery {
|
|
112
|
+
source?: unknown;
|
|
113
|
+
slug?: unknown;
|
|
114
|
+
/** External-source only — repoId + skillFolder identify the entry
|
|
115
|
+
* in place of `slug`. */
|
|
116
|
+
repoId?: unknown;
|
|
117
|
+
skillFolder?: unknown;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
interface CatalogPreviewResponse {
|
|
121
|
+
detail: CatalogEntryDetail;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function previewResponse(result: CatalogDetailResult, source: string, ident: string, res: Response<CatalogPreviewResponse | ErrorResponse>): void {
|
|
125
|
+
if (result.kind === "ok") {
|
|
126
|
+
log.info("skills", "catalog preview: ok", { source, slug: result.detail.slug });
|
|
127
|
+
res.json({ detail: result.detail });
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (result.kind === "not-found") {
|
|
131
|
+
log.warn("skills", "catalog preview: not found", { source, ident });
|
|
132
|
+
notFound(res, `catalog entry not found: ${result.source}/${result.slug}`);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
log.warn("skills", "catalog preview: invalid slug", { ident });
|
|
136
|
+
badRequest(res, `invalid slug: ${result.slug}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
bindRoute(
|
|
140
|
+
router,
|
|
141
|
+
API_ROUTES.skills.catalogPreview,
|
|
142
|
+
async (req: Request<object, unknown, unknown, CatalogPreviewQuery>, res: Response<CatalogPreviewResponse | ErrorResponse>) => {
|
|
143
|
+
const { source, slug, repoId, skillFolder } = req.query;
|
|
144
|
+
if (!isCatalogSource(source)) {
|
|
145
|
+
badRequest(res, "source must be a known catalog source");
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (source === "external") {
|
|
149
|
+
if (typeof repoId !== "string" || repoId.length === 0 || typeof skillFolder !== "string" || skillFolder.length === 0) {
|
|
150
|
+
badRequest(res, "repoId and skillFolder are required for external preview");
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const result = await readExternalDetailAsCatalog(repoId, skillFolder);
|
|
154
|
+
previewResponse(result, source, `${repoId}/${skillFolder}`, res);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (typeof slug !== "string" || slug.length === 0) {
|
|
158
|
+
badRequest(res, "slug is required");
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const result = await readCatalogEntryDetail(source, slug);
|
|
162
|
+
previewResponse(result, source, slug, res);
|
|
163
|
+
},
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
function starResponse(result: StarResult, source: string, ident: string, res: Response<StarResponse | ErrorResponse>): void {
|
|
167
|
+
if (result.kind === "starred") {
|
|
168
|
+
log.info("skills", "catalog star: ok", { source, slug: result.slug });
|
|
169
|
+
res.json({ starred: true, slug: result.slug });
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (result.kind === "already-active") {
|
|
173
|
+
log.info("skills", "catalog star: already-active", { source, slug: result.slug });
|
|
174
|
+
conflict(res, `skill "${result.slug}" is already active`);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (result.kind === "not-found") {
|
|
178
|
+
log.warn("skills", "catalog star: not found", { source, ident });
|
|
179
|
+
notFound(res, `catalog entry not found: ${result.source}/${result.slug}`);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
log.warn("skills", "catalog star: invalid slug", { ident });
|
|
183
|
+
badRequest(res, `invalid slug: ${result.slug}`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
interface ExternalStarBody {
|
|
187
|
+
source?: unknown;
|
|
188
|
+
repoId?: unknown;
|
|
189
|
+
skillFolder?: unknown;
|
|
190
|
+
slug?: unknown;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
bindRoute(router, API_ROUTES.skills.catalogStar, async (req: Request<object, unknown, ExternalStarBody>, res: Response<StarResponse | ErrorResponse>) => {
|
|
194
|
+
const { source, slug, repoId, skillFolder } = req.body;
|
|
195
|
+
if (!isCatalogSource(source)) {
|
|
196
|
+
badRequest(res, "source must be a known catalog source");
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (source === "external") {
|
|
200
|
+
if (typeof repoId !== "string" || repoId.length === 0 || typeof skillFolder !== "string" || skillFolder.length === 0) {
|
|
201
|
+
badRequest(res, "repoId and skillFolder are required for external star");
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const result = await starExternalAsCatalog(repoId, skillFolder);
|
|
205
|
+
starResponse(result, source, `${repoId}/${skillFolder}`, res);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (typeof slug !== "string" || slug.length === 0) {
|
|
209
|
+
badRequest(res, "slug is required");
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
const result = await starCatalogEntry(source, slug);
|
|
213
|
+
starResponse(result, source, slug, res);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// External-repo lifecycle endpoints (#1383 / #1335 PR-C). They live
|
|
217
|
+
// under `/api/skills/external/*` so they sort cleanly alongside the
|
|
218
|
+
// catalog endpoints AND register BEFORE the `/:name` detail handler
|
|
219
|
+
// below. Express matches in registration order — a `:name` route
|
|
220
|
+
// declared first would swallow `/external/...` as a skill named
|
|
221
|
+
// "external".
|
|
222
|
+
|
|
223
|
+
interface ExternalSuggestionsResponse {
|
|
224
|
+
suggestions: readonly ExternalPresetSuggestion[];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
interface ExternalReposResponse {
|
|
228
|
+
repos: InstalledRepo[];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
interface InstallRepoBody {
|
|
232
|
+
url?: unknown;
|
|
233
|
+
subpath?: unknown;
|
|
234
|
+
ref?: unknown;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
interface InstallRepoResponse {
|
|
238
|
+
installed: true;
|
|
239
|
+
repoId: string;
|
|
240
|
+
url: string;
|
|
241
|
+
sha: string;
|
|
242
|
+
skillCount: number;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
interface UninstallRepoResponse {
|
|
246
|
+
uninstalled: true;
|
|
247
|
+
repoId: string;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
bindRoute(router, API_ROUTES.skills.externalSuggestions, (_req: Request, res: Response<ExternalSuggestionsResponse>) => {
|
|
251
|
+
res.json({ suggestions: EXTERNAL_PRESETS });
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
bindRoute(router, API_ROUTES.skills.externalReposList, async (_req: Request, res: Response<ExternalReposResponse>) => {
|
|
255
|
+
const repos = await listInstalledRepos();
|
|
256
|
+
log.info("skills", "external repos list: ok", { count: repos.length });
|
|
257
|
+
res.json({ repos });
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
bindRoute(
|
|
261
|
+
router,
|
|
262
|
+
API_ROUTES.skills.externalReposInstall,
|
|
263
|
+
async (req: Request<object, unknown, InstallRepoBody>, res: Response<InstallRepoResponse | ErrorResponse>) => {
|
|
264
|
+
const { url, subpath, ref } = req.body ?? {};
|
|
265
|
+
if (typeof url !== "string" || url.length === 0) {
|
|
266
|
+
badRequest(res, "url is required");
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (subpath !== undefined && typeof subpath !== "string") {
|
|
270
|
+
badRequest(res, "subpath must be a string when provided");
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (ref !== undefined && typeof ref !== "string") {
|
|
274
|
+
badRequest(res, "ref must be a string when provided");
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const result = await installExternalRepo({ url, subpath, ref });
|
|
278
|
+
if (result.kind === "installed") {
|
|
279
|
+
log.info("skills", "external install: ok", { repoId: result.detail.repoId, skillCount: result.detail.skillCount });
|
|
280
|
+
res.json({
|
|
281
|
+
installed: true,
|
|
282
|
+
repoId: result.detail.repoId,
|
|
283
|
+
url: result.detail.url,
|
|
284
|
+
sha: result.detail.sha,
|
|
285
|
+
skillCount: result.detail.skillCount,
|
|
286
|
+
});
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (result.kind === "no-skills") {
|
|
290
|
+
log.warn("skills", "external install: no skills discovered", { repoId: result.repoId });
|
|
291
|
+
res.status(422).json({ error: `no SKILL.md found in repo (${result.repoId})` });
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (result.kind === "invalid-url") {
|
|
295
|
+
log.warn("skills", "external install: invalid url", { url: result.url });
|
|
296
|
+
badRequest(res, "url must be a github.com HTTPS URL: https://github.com/<owner>/<repo>");
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (result.kind === "invalid-subpath") {
|
|
300
|
+
log.warn("skills", "external install: invalid subpath", { subpath: result.subpath });
|
|
301
|
+
badRequest(res, "subpath must be a relative path with no '..', leading '/', or backslash segments");
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (result.kind === "id-collision") {
|
|
305
|
+
log.warn("skills", "external install: repoId collision", { repoId: result.repoId, existingUrl: result.existingUrl });
|
|
306
|
+
conflict(res, `repo id "${result.repoId}" is already in use by ${result.existingUrl}. Uninstall it first if you intend to replace it.`);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
log.warn("skills", "external install: error", { reason: result.reason });
|
|
310
|
+
res.status(502).json({ error: `external install failed: ${result.reason}` });
|
|
311
|
+
},
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
bindRoute(router, API_ROUTES.skills.externalReposRemove, async (req: Request<{ repoId: string }>, res: Response<UninstallRepoResponse | ErrorResponse>) => {
|
|
315
|
+
const { repoId } = req.params;
|
|
316
|
+
const result = await uninstallExternalRepo(repoId);
|
|
317
|
+
if (result.kind === "uninstalled") {
|
|
318
|
+
log.info("skills", "external uninstall: ok", { repoId: result.repoId });
|
|
319
|
+
res.json({ uninstalled: true, repoId: result.repoId });
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
if (result.kind === "not-found") {
|
|
323
|
+
log.warn("skills", "external uninstall: not found", { repoId: result.repoId });
|
|
324
|
+
notFound(res, `external repo not installed: ${result.repoId}`);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
log.warn("skills", "external uninstall: invalid repoId", { repoId: result.repoId });
|
|
328
|
+
badRequest(res, `invalid repoId: ${result.repoId}`);
|
|
329
|
+
});
|
|
330
|
+
|
|
68
331
|
bindRoute(router, API_ROUTES.skills.detail, async (req: Request<{ name: string }>, res: Response<SkillDetailResponse | ErrorResponse>) => {
|
|
69
332
|
log.info("skills", "detail: start", { name: req.params.name });
|
|
70
333
|
const skills = await discoverSkills({ workspaceRoot: workspacePath });
|