mulmoclaude 0.6.2 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +26 -0
  2. package/bin/mulmoclaude.js +11 -1
  3. package/client/assets/chunk-D8eiyYIV-CW0rPbG2.js +1 -0
  4. package/client/assets/{html2canvas-CDGcmOD3-Bkf2uOth.js → html2canvas-CDGcmOD3-BjwfzAN8.js} +1 -1
  5. package/client/assets/index-Bp1owZ-i.js +5101 -0
  6. package/client/assets/index-c63H1pnd.css +2 -0
  7. package/client/assets/{index.es-DqtpmBm8-D9mAh_KQ.js → index.es-DqtpmBm8-DudYPW7R.js} +1 -1
  8. package/client/assets/material-symbols-outlined-C0dZ3SlO.woff2 +0 -0
  9. package/client/assets/runtime-protocol-vue-BUk5WXSy.js +1 -0
  10. package/client/assets/{runtime-vue-BVUzgYGA.js → runtime-vue-fFYhnNg3.js} +1 -1
  11. package/client/assets/{vue-C8UuIO9J.js → vue-Kqzpl9Vx.js} +1 -1
  12. package/client/assets/vue.runtime.esm-bundler-BTyIdNAI.js +4 -0
  13. package/client/index.html +9 -11
  14. package/package.json +5 -4
  15. package/server/agent/backend/claude-code.ts +34 -0
  16. package/server/agent/backend/fake-echo.ts +370 -0
  17. package/server/agent/backend/index.ts +16 -1
  18. package/server/agent/config.ts +8 -1
  19. package/server/agent/mcpFailureMonitor.ts +167 -0
  20. package/server/agent/mcpPreflight.ts +185 -0
  21. package/server/agent/stream.ts +12 -1
  22. package/server/api/routes/mulmo-script.ts +19 -1
  23. package/server/api/routes/schedulerHandlers.ts +52 -4
  24. package/server/api/routes/sessions.ts +15 -0
  25. package/server/api/routes/skills.ts +263 -0
  26. package/server/events/notifications.ts +19 -91
  27. package/server/index.ts +87 -9
  28. package/server/notifier/macosReminderAdapter.ts +30 -0
  29. package/server/system/announceOptionalDeps.ts +50 -0
  30. package/server/system/config.ts +8 -1
  31. package/server/system/docker.ts +14 -6
  32. package/server/system/env.ts +18 -5
  33. package/server/system/optionalDeps.ts +129 -0
  34. package/server/utils/cli-flags.d.mts +14 -0
  35. package/server/utils/cli-flags.mjs +53 -0
  36. package/server/utils/time.ts +6 -0
  37. package/server/workspace/helps/business.md +2 -2
  38. package/server/workspace/helps/mulmoscript.md +3 -3
  39. package/server/workspace/helps/sandbox.md +2 -2
  40. package/server/workspace/hooks/dispatcher.mjs +1 -1
  41. package/server/workspace/paths.ts +13 -4
  42. package/server/workspace/skills/catalog.ts +355 -0
  43. package/server/workspace/skills/external/catalog.ts +283 -0
  44. package/server/workspace/skills/external/clone.ts +129 -0
  45. package/server/workspace/skills/external/id.ts +194 -0
  46. package/server/workspace/skills/external/install.ts +417 -0
  47. package/server/workspace/skills/external/presets.ts +50 -0
  48. package/server/workspace/skills-preset.ts +29 -17
  49. package/server/workspace/workspace.ts +10 -5
  50. package/src/App.vue +19 -8
  51. package/src/components/RightSidebar.vue +19 -0
  52. package/src/components/StackView.vue +10 -1
  53. package/src/config/apiRoutes.ts +0 -6
  54. package/src/config/roles.ts +2 -0
  55. package/src/lang/de.ts +50 -1
  56. package/src/lang/en.ts +49 -1
  57. package/src/lang/es.ts +49 -1
  58. package/src/lang/fr.ts +49 -1
  59. package/src/lang/ja.ts +49 -1
  60. package/src/lang/ko.ts +49 -1
  61. package/src/lang/pt-BR.ts +49 -1
  62. package/src/lang/zh.ts +49 -1
  63. package/src/plugins/manageSkills/View.vue +795 -30
  64. package/src/plugins/manageSkills/categories.ts +125 -0
  65. package/src/plugins/manageSkills/meta.ts +30 -0
  66. package/src/plugins/markdown/definition.ts +3 -3
  67. package/src/plugins/meta-types.ts +5 -0
  68. package/src/plugins/presentMulmoScript/Preview.vue +3 -3
  69. package/src/plugins/presentMulmoScript/View.vue +157 -33
  70. package/src/plugins/presentMulmoScript/meta.ts +4 -0
  71. package/src/plugins/scheduler/View.vue +45 -9
  72. package/src/plugins/scheduler/calendarDefinition.ts +6 -2
  73. package/src/plugins/scheduler/multiDayHelpers.ts +95 -0
  74. package/src/plugins/spreadsheet/View.vue +3 -3
  75. package/src/types/notification.ts +1 -1
  76. package/src/types/session.ts +6 -0
  77. package/src/types/sse.ts +5 -0
  78. package/src/types/toolCallHistory.ts +7 -0
  79. package/src/utils/agent/eventDispatch.ts +26 -5
  80. package/src/utils/agent/mcpHint.ts +50 -0
  81. package/src/utils/session/sessionEntries.ts +8 -32
  82. package/client/assets/PluginScopedRoot-YjvQq0Nn.js +0 -3
  83. package/client/assets/chunk-CernVdwh.js +0 -1
  84. package/client/assets/chunk-D8eiyYIV-CAXpUwLd.js +0 -1
  85. package/client/assets/index-BwrlMMHr.js +0 -5005
  86. package/client/assets/index-CvvNuegU.css +0 -2
  87. package/client/assets/material-symbols-outlined-BOZVWuR3.woff2 +0 -0
  88. package/client/assets/runtime-protocol-vue-C1To4M3t.js +0 -1
  89. package/client/assets/vue.runtime.esm-bundler-DQ8Kjjui.js +0 -4
  90. package/server/api/routes/notifications.ts +0 -195
  91. package/server/notifier/legacy-adapters.ts +0 -76
  92. package/src/composables/useSelectedResult.ts +0 -49
  93. /package/client/assets/{purify.es-Fx1Nqyry-Dwtk-9WZ.js → purify.es-Fx1Nqyry-B3aL7Uvj.js} +0 -0
  94. /package/client/assets/{typeof-DBp4T-Ny-CSr8wx1e.js → typeof-DBp4T-Ny-Bef7RiR_.js} +0 -0
@@ -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
+ }
@@ -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
  }
@@ -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;
@@ -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 });