scream-code 0.14.9 → 0.15.1

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.
@@ -575,7 +575,7 @@ function createToolMessage(toolCallId, output) {
575
575
  }
576
576
  //#endregion
577
577
  //#region ../../packages/ltod/src/rate-limit-utils.ts
578
- const QUOTA_EXHAUSTED_BACKOFF_MS = 1800 * 1e3;
578
+ const QUOTA_EXHAUSTED_BACKOFF_MS = 30 * 1e3;
579
579
  const RATE_LIMIT_EXCEEDED_BACKOFF_MS = 30 * 1e3;
580
580
  const MODEL_CAPACITY_BASE_MS = 45 * 1e3;
581
581
  const MODEL_CAPACITY_JITTER_MS = 30 * 1e3;
@@ -605,9 +605,9 @@ function parseRateLimitReason(errorMessage) {
605
605
  /**
606
606
  * Backoff in ms for a given reason. MODEL_CAPACITY gets jitter to avoid
607
607
  * thundering herd. UNKNOWN (a classified 429 with no recognized reason)
608
- * uses the short 20s SERVER_ERROR backoff — a 30min x ~9 attempt hang
609
- * would be far worse than a failed turn, and UNKNOWN is not evidence of
610
- * quota exhaustion.
608
+ * uses the short 20s SERVER_ERROR backoff — a long quota backoff on an
609
+ * unrecoverable error would hang a turn for far longer than the value of
610
+ * retrying, and UNKNOWN is not evidence of quota exhaustion.
611
611
  */
612
612
  function calculateRateLimitBackoffMs(reason) {
613
613
  switch (reason) {
@@ -765,14 +765,9 @@ var APIEmptyResponseError = class extends ChatProviderError {
765
765
  function isRetryableGenerateError(error) {
766
766
  if (error instanceof APIConnectionError$3 || error instanceof APITimeoutError) return true;
767
767
  if (error instanceof APIEmptyResponseError) return true;
768
- if (error instanceof APIProviderRateLimitError) return error.reason !== "QUOTA_EXHAUSTED";
769
- return error instanceof APIStatusError && [
770
- 429,
771
- 500,
772
- 502,
773
- 503,
774
- 504
775
- ].includes(error.statusCode);
768
+ if (error instanceof APIProviderRateLimitError) return true;
769
+ if (error instanceof APIStatusError) return error.statusCode === 429 || error.statusCode === 408 || error.statusCode >= 500 && error.statusCode < 600;
770
+ return false;
776
771
  }
777
772
  const CONTEXT_OVERFLOW_MESSAGE_PATTERNS = [
778
773
  /context[ _-]?length/,
@@ -47489,12 +47484,9 @@ async function chatWithRetry(input) {
47489
47484
  logRequestFailure(input, error, attempt, maxAttempts);
47490
47485
  throw error;
47491
47486
  }
47492
- if (error instanceof APIProviderRateLimitError && error.reason === "QUOTA_EXHAUSTED") {
47493
- logRequestFailure(input, error, attempt, maxAttempts);
47494
- throw error;
47495
- }
47496
- if (attempt >= maxAttempts || !input.llm.isRetryableError(error)) {
47497
- logRequestFailure(input, error, attempt, maxAttempts);
47487
+ const attemptLimit = error instanceof APIProviderRateLimitError && error.reason === "QUOTA_EXHAUSTED" ? Math.min(3, maxAttempts) : maxAttempts;
47488
+ if (attempt >= attemptLimit || !input.llm.isRetryableError(error)) {
47489
+ logRequestFailure(input, error, attempt, attemptLimit);
47498
47490
  throw error;
47499
47491
  }
47500
47492
  const delayMs = computeDelayMs(error, delays, attempt);
@@ -47506,7 +47498,7 @@ async function chatWithRetry(input) {
47506
47498
  stepUuid: input.stepUuid,
47507
47499
  failedAttempt: attempt,
47508
47500
  nextAttempt: attempt + 1,
47509
- maxAttempts,
47501
+ maxAttempts: attemptLimit,
47510
47502
  delayMs,
47511
47503
  ...retryErrorFields(error)
47512
47504
  });
@@ -65283,7 +65275,7 @@ var import_yauzl = (/* @__PURE__ */ __commonJSMin(((exports) => {
65283
65275
  var EventEmitter$3 = __require("events").EventEmitter;
65284
65276
  var Transform$1 = __require("stream").Transform;
65285
65277
  var PassThrough$2 = __require("stream").PassThrough;
65286
- var Writable = __require("stream").Writable;
65278
+ var Writable$1 = __require("stream").Writable;
65287
65279
  exports.fromBuffer = fromBuffer;
65288
65280
  function fromBuffer(buffer, options, callback) {
65289
65281
  if (typeof options === "function") {
@@ -65807,7 +65799,7 @@ var import_yauzl = (/* @__PURE__ */ __commonJSMin(((exports) => {
65807
65799
  start: position,
65808
65800
  end: position + length
65809
65801
  });
65810
- var writeStream = new Writable();
65802
+ var writeStream = new Writable$1();
65811
65803
  var written = 0;
65812
65804
  writeStream._write = function(chunk, encoding, cb) {
65813
65805
  chunk.copy(buffer, offset + written, 0, chunk.length);
@@ -65976,9 +65968,9 @@ async function hasManifest(dir) {
65976
65968
  const dirManifest = path.join(dir, ".scream-plugin", "plugin.json");
65977
65969
  const claudeDirManifest = path.join(dir, ".claude-plugin", "plugin.json");
65978
65970
  const skillMd = path.join(dir, "SKILL.md");
65979
- return await isFile$2(rootManifest) || await isFile$2(dirManifest) || await isFile$2(claudeDirManifest) || await isFile$2(skillMd);
65971
+ return await isFile$3(rootManifest) || await isFile$3(dirManifest) || await isFile$3(claudeDirManifest) || await isFile$3(skillMd);
65980
65972
  }
65981
- async function isFile$2(p) {
65973
+ async function isFile$3(p) {
65982
65974
  try {
65983
65975
  return (await stat(p)).isFile();
65984
65976
  } catch {
@@ -66441,12 +66433,12 @@ async function parseManifest(pluginRoot) {
66441
66433
  const rootJsonPath = path.join(pluginRoot, SCREAM_PLUGIN_ROOT_PATH);
66442
66434
  const dirJsonPath = path.join(pluginRoot, SCREAM_PLUGIN_DIR_PATH);
66443
66435
  const claudeDirJsonPath = path.join(pluginRoot, CLAUDE_PLUGIN_DIR_PATH);
66444
- const rootJsonExists = await isFile$1(rootJsonPath);
66445
- const dirJsonExists = await isFile$1(dirJsonPath);
66446
- const claudeDirJsonExists = await isFile$1(claudeDirJsonPath);
66436
+ const rootJsonExists = await isFile$2(rootJsonPath);
66437
+ const dirJsonExists = await isFile$2(dirJsonPath);
66438
+ const claudeDirJsonExists = await isFile$2(claudeDirJsonPath);
66447
66439
  if (!rootJsonExists && !dirJsonExists && !claudeDirJsonExists) {
66448
66440
  const skillMdPath = path.join(pluginRoot, BARE_SKILL_PATH);
66449
- if (await isFile$1(skillMdPath)) return {
66441
+ if (await isFile$2(skillMdPath)) return {
66450
66442
  manifest: {
66451
66443
  name: path.basename(pluginRoot),
66452
66444
  skills: [pluginRoot]
@@ -66524,7 +66516,7 @@ async function parseManifest(pluginRoot) {
66524
66516
  }
66525
66517
  let skills = await resolveSkillsField(pluginRoot, raw["skills"], diagnostics);
66526
66518
  if (raw["skills"] === void 0) {
66527
- if (await isFile$1(path.join(pluginRoot, "SKILL.md"))) skills = [pluginRoot];
66519
+ if (await isFile$2(path.join(pluginRoot, "SKILL.md"))) skills = [pluginRoot];
66528
66520
  }
66529
66521
  const skillInstructions = typeof raw["skillInstructions"] === "string" ? raw["skillInstructions"] : void 0;
66530
66522
  const config = typeof raw["config"] === "object" && raw["config"] !== null && !Array.isArray(raw["config"]) ? raw["config"] : void 0;
@@ -66856,7 +66848,7 @@ async function discoverSkillDirs(root, maxDepth) {
66856
66848
  for (const entry of entries) {
66857
66849
  if (!entry.isDirectory()) continue;
66858
66850
  const child = path.join(dir, entry.name);
66859
- if (await isFile$1(path.join(child, BARE_SKILL_PATH))) found.push(child);
66851
+ if (await isFile$2(path.join(child, BARE_SKILL_PATH))) found.push(child);
66860
66852
  await walk(child, depth + 1);
66861
66853
  }
66862
66854
  }
@@ -66866,7 +66858,7 @@ async function discoverSkillDirs(root, maxDepth) {
66866
66858
  for (const dir of sorted) if (!result.some((parent) => dir.startsWith(parent + path.sep))) result.push(dir);
66867
66859
  return result;
66868
66860
  }
66869
- async function isFile$1(p) {
66861
+ async function isFile$2(p) {
66870
66862
  try {
66871
66863
  return (await stat(p)).isFile();
66872
66864
  } catch {
@@ -67863,7 +67855,10 @@ async function keepNewestBackupOnly(backupsForId) {
67863
67855
  async function recordFrom(input) {
67864
67856
  const { parsed } = input;
67865
67857
  const hasError = parsed.diagnostics.some((d) => d.severity === "error");
67866
- const skills = hasError || parsed.manifest === void 0 ? [] : await discoverPluginSkills(input.id, parsed.manifest);
67858
+ const discovery = hasError || parsed.manifest === void 0 ? {
67859
+ skills: [],
67860
+ diagnostics: []
67861
+ } : await discoverPluginSkills(input.id, parsed.manifest);
67867
67862
  return {
67868
67863
  id: input.id,
67869
67864
  root: input.root,
@@ -67875,13 +67870,13 @@ async function recordFrom(input) {
67875
67870
  originalSource: input.originalSource,
67876
67871
  capabilities: input.capabilities,
67877
67872
  github: input.github,
67878
- skills,
67879
- skillCount: skills.length,
67873
+ skills: discovery.skills,
67874
+ skillCount: discovery.skills.length,
67880
67875
  manifest: parsed.manifest,
67881
67876
  manifestKind: parsed.manifestKind,
67882
67877
  manifestPath: parsed.manifestPath,
67883
67878
  shadowedManifestPath: parsed.shadowedManifestPath,
67884
- diagnostics: parsed.diagnostics,
67879
+ diagnostics: [...parsed.diagnostics, ...discovery.diagnostics],
67885
67880
  skillInstructions: parsed.manifest?.skillInstructions
67886
67881
  };
67887
67882
  }
@@ -67911,11 +67906,26 @@ async function discoverPluginSkills(pluginId, manifest) {
67911
67906
  instructions: manifest?.skillInstructions
67912
67907
  }
67913
67908
  }));
67914
- if (roots.length === 0) return [];
67915
- return (await discoverSkills({ roots })).map((skill) => ({
67916
- name: skill.name,
67917
- description: skill.description
67918
- }));
67909
+ if (roots.length === 0) return {
67910
+ skills: [],
67911
+ diagnostics: []
67912
+ };
67913
+ const diagnostics = [];
67914
+ return {
67915
+ skills: (await discoverSkills({
67916
+ roots,
67917
+ onWarning: (message) => {
67918
+ diagnostics.push({
67919
+ severity: "warn",
67920
+ message
67921
+ });
67922
+ }
67923
+ })).map((skill) => ({
67924
+ name: skill.name,
67925
+ description: skill.description
67926
+ })),
67927
+ diagnostics
67928
+ };
67919
67929
  }
67920
67930
  function recordToInfo(record) {
67921
67931
  return {
@@ -77142,7 +77152,7 @@ function describeError(error) {
77142
77152
  }
77143
77153
  //#endregion
77144
77154
  //#region ../../packages/agent-core/src/tools/builtin/state/inspect-own-assets.md
77145
- var inspect_own_assets_default = "Use this tool to inspect the agent's own persistent assets: skills, MCP server declarations, configuration files, the memory store, and the knowledge base. It reports what exists, where it lives, and whether it looks valid.\n\n**When to use:**\n- The user asks \"what skills do you have?\", \"show me your mcp config\", \"how is your memory set up?\", \"where is your knowledge base?\"\n- Auditing your own configuration and data (e.g. checking whether mcp.json parses, whether skill frontmatter is intact)\n\n**Inventory questions — this tool is the single answering view.** When the user asks what you have, in ANY wording (skills / plugins / capabilities / MCP / tools / 技能 / 插件 / 能力), treat it as one intent and answer from this inventory: it reports the skill packages in the three standard skill scopes (user, plugin-managed, project) with live `invocable` status from the skill registry, plus MCP declarations. Skills discovered from non-standard locations (nested directories, `.agents/skills`) may not appear here. Pair it with the system-prompt skills section for the \"callable right now\" view. Division of labor: **InspectOwnAssets = inventory (read-only)**, **ManagePlugin = changes (install/enable/remove)**, **Skill tool = invocation**.\n\nYou must NOT modify any of these assets unless the user explicitly asks you to — this tool is strictly read-only.\n\n**When NOT to use:**\n- Reading the user's workspace files — use `read` / `glob` / `grep` instead\n- Writing or editing anything — this tool is strictly read-only\n\n**How to use:**\n- Call with no arguments (or `scope: \"all\"`) to inspect everything\n- Narrow with `scope: \"skills\"` / `\"mcp\"` / `\"config\"` / `\"memory\"` / `\"knowledge\"` to inspect a single category\n\nThis tool never writes, creates, or modifies any file.\n";
77155
+ var inspect_own_assets_default = "Use this tool to inspect the agent's own persistent assets: skills, MCP server declarations, configuration files, the memory store, and the knowledge base. It reports what exists, where it lives, and whether it looks valid.\n\n**When to use:**\n- The user asks \"what skills do you have?\", \"show me your mcp config\", \"how is your memory set up?\", \"where is your knowledge base?\", \"检查技能/插件状态\"\n- Auditing your own configuration and data (e.g. checking whether mcp.json parses, why a skill is not usable, which plugin directories are unregistered)\n\n**Inventory questions — this tool is the single answering view.** When the user asks what you have, in ANY wording (skills / plugins / capabilities / MCP / tools / 技能 / 插件 / 能力), treat it as one intent and answer from this inventory: it reports the skill packages in the three standard skill scopes (user, plugin-managed, project) with live `invocable` status from the skill registry, plus MCP declarations. Every \"not invocable\" entry carries its concrete reason (registered but not invocable / broken frontmatter with the parse message / unregistered), and plugin-managed entries show whether their directory is registered in the plugin table, with unregistered directories listed separately — so a status question is answered by this single call, no need to consult ManagePlugin for the same facts. Skills discovered from non-standard locations (nested directories, `.agents/skills`) may not appear here. Pair it with the system-prompt skills section for the \"callable right now\" view. Division of labor: **InspectOwnAssets = inventory (read-only)**, **ManagePlugin = changes (install/enable/remove)**, **Skill tool = invocation**.\n\nYou must NOT modify any of these assets unless the user explicitly asks you to — this tool is strictly read-only.\n\n**When NOT to use:**\n- Reading the user's workspace files — use `read` / `glob` / `grep` instead\n- Writing or editing anything — this tool is strictly read-only\n\n**How to use:**\n- Call with no arguments (or `scope: \"all\"`) to inspect everything\n- Narrow with `scope: \"skills\"` / `\"mcp\"` / `\"config\"` / `\"memory\"` / `\"knowledge\"` to inspect a single category\n\nThis tool never writes, creates, or modifies any file.\n";
77146
77156
  const InspectOwnAssetsInputSchema = z.object({ scope: z.enum([
77147
77157
  "all",
77148
77158
  "skills",
@@ -77151,8 +77161,14 @@ const InspectOwnAssetsInputSchema = z.object({ scope: z.enum([
77151
77161
  "memory",
77152
77162
  "knowledge"
77153
77163
  ]).optional().describe("Which self-assets to inspect: 'all' (default) reports everything; narrow to 'skills', 'mcp', 'config', 'memory', or 'knowledge'.") });
77154
- /** Bytes to read from the head of a file when checking frontmatter. */
77155
- const FRONTMATTER_READ_LIMIT = 32 * 1024;
77164
+ /**
77165
+ * Skills larger than this are reported as broken rather than parsed. Real
77166
+ * skill bundles are a few KB; the cap exists only to guard against reading a
77167
+ * pathological file (e.g. a multi-GB binary misnamed SKILL.md) in one shot.
77168
+ */
77169
+ const SKILL_FILE_READ_LIMIT = 4 * 1024 * 1024;
77170
+ /** Recursion bound for counting nested SKILL.md files inside unregistered dirs. */
77171
+ const ORPHAN_SCAN_DEPTH = 4;
77156
77172
  /**
77157
77173
  * Common documentation files shipped inside skill/plugin bundles are not
77158
77174
  * skills; matched case-insensitively against top-level flat `.md` entries
@@ -77192,34 +77208,73 @@ function describeFile(info) {
77192
77208
  if (!info.exists) return "missing";
77193
77209
  return `${info.size} bytes`;
77194
77210
  }
77195
- /** Frontmatter check (bounded read): starts with `---` and contains a `name:` line. */
77196
- async function checkFrontmatter(path) {
77197
- let handle;
77211
+ async function isFile$1(path) {
77198
77212
  try {
77199
- handle = await open(path, "r");
77200
- const buffer = Buffer.alloc(FRONTMATTER_READ_LIMIT);
77201
- const { bytesRead } = await handle.read(buffer, 0, FRONTMATTER_READ_LIMIT, 0);
77202
- const head = buffer.subarray(0, bytesRead).toString("utf-8").split("\n").slice(0, 25);
77203
- if (head[0]?.trim() !== "---") return "missing";
77204
- return head.some((line) => /^name\s*:/.test(line)) ? "ok" : "broken";
77213
+ return (await stat(path)).isFile();
77205
77214
  } catch {
77206
- return "missing";
77207
- } finally {
77208
- await handle?.close().catch(() => {});
77215
+ return false;
77209
77216
  }
77210
77217
  }
77211
- /** True if a directory is a directory-based skill (contains SKILL.md). */
77212
- async function isSkillDir(dir) {
77218
+ /**
77219
+ * Parse a skill file the same way the registry does (`skill/parser`), so the
77220
+ * inventory's status and names agree with what is actually invocable: a file
77221
+ * whose frontmatter fails YAML parsing is `broken` with the real message, not
77222
+ * a heuristic "ok" based on the presence of a `name:` line.
77223
+ */
77224
+ async function parseSkillEntry(path, fallbackName, requireFrontmatter) {
77225
+ let info;
77213
77226
  try {
77214
- return (await stat(join$1(dir, "SKILL.md"))).isFile();
77227
+ info = await stat(path);
77215
77228
  } catch {
77216
- return false;
77229
+ return {
77230
+ name: fallbackName,
77231
+ frontmatter: "missing"
77232
+ };
77233
+ }
77234
+ if (info.size > SKILL_FILE_READ_LIMIT) return {
77235
+ name: fallbackName,
77236
+ frontmatter: "ok"
77237
+ };
77238
+ let text;
77239
+ try {
77240
+ text = await readFile(path, "utf8");
77241
+ } catch {
77242
+ return {
77243
+ name: fallbackName,
77244
+ frontmatter: "missing"
77245
+ };
77246
+ }
77247
+ if (text.split(/\r?\n/, 1)[0]?.trim() !== "---") {
77248
+ if (requireFrontmatter) return {
77249
+ name: fallbackName,
77250
+ frontmatter: "missing"
77251
+ };
77252
+ return {
77253
+ name: fallbackName,
77254
+ frontmatter: "ok"
77255
+ };
77256
+ }
77257
+ try {
77258
+ return {
77259
+ name: (await parseSkillFromFile({
77260
+ skillMdPath: path,
77261
+ skillDirName: fallbackName,
77262
+ source: "user"
77263
+ })).name,
77264
+ frontmatter: "ok"
77265
+ };
77266
+ } catch (error) {
77267
+ return {
77268
+ name: fallbackName,
77269
+ frontmatter: "broken",
77270
+ reason: error instanceof SkillParseError ? error.message : error instanceof Error ? error.message : String(error)
77271
+ };
77217
77272
  }
77218
77273
  }
77219
77274
  /**
77220
- * List skill entries under a managed skills directory, mirroring the loader's
77221
- * rules: skip dot-entries, node_modules and README.md; directory skills must
77222
- * contain SKILL.md; flat skills are non-README `.md` files.
77275
+ * List skill entries under a skills directory, mirroring the loader's rules:
77276
+ * skip dot-entries, node_modules and README.md; directory skills must contain
77277
+ * SKILL.md; flat skills are non-README `.md` files (frontmatter optional).
77223
77278
  */
77224
77279
  async function listSkills(dir) {
77225
77280
  let entries;
@@ -77232,36 +77287,141 @@ async function listSkills(dir) {
77232
77287
  for (const entry of entries) {
77233
77288
  if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
77234
77289
  if (entry.isDirectory()) {
77235
- if (!await isSkillDir(join$1(dir, entry.name))) continue;
77236
77290
  const skillMd = join$1(dir, entry.name, "SKILL.md");
77237
- const fm = await checkFrontmatter(skillMd);
77238
- const skillName = await readSkillName(skillMd) ?? entry.name;
77291
+ if (!await isFile$1(skillMd)) continue;
77292
+ const parsed = await parseSkillEntry(skillMd, entry.name, true);
77239
77293
  out.push({
77240
- name: skillName,
77294
+ ...parsed,
77241
77295
  path: skillMd,
77242
- kind: "dir",
77243
- frontmatter: fm
77296
+ kind: "dir"
77244
77297
  });
77245
77298
  } else if (entry.isFile() && entry.name.endsWith(".md")) {
77246
77299
  if (DOCUMENTATION_MARKDOWN_LOWER.has(entry.name.toLowerCase())) continue;
77247
77300
  const path = join$1(dir, entry.name);
77248
- const skillName = await readSkillName(path) ?? entry.name.slice(0, -3);
77301
+ const parsed = await parseSkillEntry(path, entry.name.slice(0, -3), false);
77249
77302
  out.push({
77250
- name: skillName,
77303
+ ...parsed,
77251
77304
  path,
77252
- kind: "flat",
77253
- frontmatter: "ok"
77305
+ kind: "flat"
77254
77306
  });
77255
77307
  }
77256
77308
  }
77257
77309
  return out.toSorted((a, b) => a.name.localeCompare(b.name));
77258
77310
  }
77259
- function buildInvocableNameSet(agent) {
77260
- return new Set((agent.skills?.registry.listInvocableSkills() ?? []).map((s) => s.name));
77311
+ /** Count every SKILL.md reachable under `dirPath` (inclusive), depth-limited. */
77312
+ async function countNestedSkillFiles(dirPath, depth) {
77313
+ if (depth > ORPHAN_SCAN_DEPTH) return 0;
77314
+ let entries;
77315
+ try {
77316
+ entries = await readdir(dirPath, { withFileTypes: true });
77317
+ } catch {
77318
+ return 0;
77319
+ }
77320
+ let count = 0;
77321
+ for (const entry of entries) {
77322
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
77323
+ if (entry.isDirectory()) count += await countNestedSkillFiles(join$1(dirPath, entry.name), depth + 1);
77324
+ else if (entry.name === "SKILL.md") count++;
77325
+ }
77326
+ return count;
77261
77327
  }
77262
- function formatSkillEntry(entry, invocable) {
77328
+ /**
77329
+ * Describe an unregistered directory under the managed plugins root so the
77330
+ * inventory can surface things the plugin table cannot see: orphan skill
77331
+ * bundles, nested skill trees, and dormant code plugins (entryPoint) that
77332
+ * would run arbitrary code on install+activate.
77333
+ */
77334
+ async function describeUnregisteredDir(dirPath) {
77335
+ const parts = [];
77336
+ const topLevelSkillMd = await isFile$1(join$1(dirPath, "SKILL.md"));
77337
+ const totalSkills = await countNestedSkillFiles(dirPath, 0);
77338
+ if (topLevelSkillMd) parts.push(totalSkills > 1 ? `skill bundle (${totalSkills} SKILL.md)` : "skill bundle");
77339
+ else if (totalSkills > 0) parts.push(`${totalSkills} nested skills (not registered)`);
77340
+ try {
77341
+ const manifestText = await readFile(join$1(dirPath, "scream.plugin.json"), "utf8");
77342
+ let hasEntryPoint = false;
77343
+ try {
77344
+ const manifest = JSON.parse(manifestText);
77345
+ hasEntryPoint = typeof manifest.entryPoint === "string" && manifest.entryPoint.length > 0;
77346
+ } catch {
77347
+ parts.push("scream.plugin.json (unparseable)");
77348
+ return parts.join("; ");
77349
+ }
77350
+ parts.push(hasEntryPoint ? "code plugin (entryPoint)" : "manifest but not registered");
77351
+ } catch {}
77352
+ if (parts.length === 0) parts.push("no manifest or skill files");
77353
+ return parts.join("; ");
77354
+ }
77355
+ /**
77356
+ * Plugin-managed skills live under `<managedDir>/<plugin>/SKILL.md`. When the
77357
+ * plugin table (via `agent.toolServices.plugins`) is reachable, entries carry
77358
+ * their registration status and unregistered directories are reported in a
77359
+ * dedicated section; without it, the listing falls back to a pure file scan.
77360
+ */
77361
+ async function listManagedSkills(managedDir, records) {
77362
+ let plugins;
77363
+ try {
77364
+ plugins = await readdir(managedDir, { withFileTypes: true });
77365
+ } catch {
77366
+ return {
77367
+ entries: [],
77368
+ orphanLines: []
77369
+ };
77370
+ }
77371
+ const entries = [];
77372
+ const orphanLines = [];
77373
+ for (const plugin of plugins) {
77374
+ if (!plugin.isDirectory() || plugin.name.startsWith(".")) continue;
77375
+ const dirPath = join$1(managedDir, plugin.name);
77376
+ const skillMd = join$1(dirPath, "SKILL.md");
77377
+ const record = records?.get(plugin.name);
77378
+ if (records === void 0 || record === void 0) {
77379
+ if (records === void 0) {
77380
+ if (!await isFile$1(skillMd)) continue;
77381
+ const parsed = await parseSkillEntry(skillMd, plugin.name, true);
77382
+ entries.push({
77383
+ ...parsed,
77384
+ path: skillMd,
77385
+ kind: "dir",
77386
+ plugin: plugin.name
77387
+ });
77388
+ } else if (await isFile$1(skillMd)) orphanLines.push(`- ${plugin.name} — ${await describeUnregisteredDir(dirPath)} — \`${dirPath}\``);
77389
+ else if (await isFile$1(join$1(dirPath, "scream.plugin.json"))) orphanLines.push(`- ${plugin.name} — ${await describeUnregisteredDir(dirPath)} — \`${dirPath}\``);
77390
+ else if (await countNestedSkillFiles(dirPath, 0) > 0) orphanLines.push(`- ${plugin.name} — ${await describeUnregisteredDir(dirPath)} — \`${dirPath}\``);
77391
+ continue;
77392
+ }
77393
+ if (await isFile$1(skillMd)) {
77394
+ const parsed = await parseSkillEntry(skillMd, plugin.name, true);
77395
+ entries.push({
77396
+ ...parsed,
77397
+ path: skillMd,
77398
+ kind: "dir",
77399
+ plugin: plugin.name,
77400
+ registered: true
77401
+ });
77402
+ }
77403
+ }
77404
+ return {
77405
+ entries,
77406
+ orphanLines
77407
+ };
77408
+ }
77409
+ /**
77410
+ * Traffic-light status for a skill entry. The registry is the single source of
77411
+ * truth for what is callable; everything else is a disk-side explanation.
77412
+ */
77413
+ function classify(entry, invocableNames, registeredNames) {
77414
+ const variants = [entry.name];
77415
+ if (entry.plugin !== void 0) variants.push(`${entry.plugin}:${entry.name}`);
77416
+ if (variants.some((v) => invocableNames.has(v))) return "invocable";
77417
+ if (variants.some((v) => registeredNames.has(v))) return "not invocable — registered but not invocable";
77418
+ if (entry.frontmatter === "broken") return `not invocable — broken: ${entry.reason ?? "frontmatter failed to parse"}`;
77419
+ return "not invocable — unregistered";
77420
+ }
77421
+ function formatSkillEntry(entry, status) {
77263
77422
  const origin = entry.plugin === void 0 ? "" : ` — plugin: ${entry.plugin}`;
77264
- return `- ${entry.name} — ${entry.kind === "dir" ? "dir" : "flat"} ${entry.frontmatter} — ${invocable ? "invocable" : "not invocable"}${origin} — \`${entry.path}\``;
77423
+ const registration = entry.registered === true ? " — registered" : "";
77424
+ return `- ${entry.name} — ${entry.kind} — ${entry.frontmatter} — ${status}${origin}${registration} — \`${entry.path}\``;
77265
77425
  }
77266
77426
  async function inspectConfig(home, userHome) {
77267
77427
  const items = [
@@ -77282,88 +77442,45 @@ async function inspectSkills(home, userHome, cwd, agent) {
77282
77442
  userHomeDir: userHome,
77283
77443
  workDir: cwd
77284
77444
  });
77285
- const invocableNames = buildInvocableNameSet(agent);
77445
+ const registry = agent.skills?.registry;
77446
+ const invocableNames = new Set((registry?.listInvocableSkills() ?? []).map((s) => s.name));
77447
+ const registeredNames = new Set((registry?.listSkills?.() ?? []).map((s) => s.name));
77448
+ const manager = agent.toolServices?.plugins;
77449
+ const pluginRecords = manager === void 0 ? void 0 : new Map(manager.list().map((r) => [r.id, r]));
77286
77450
  const sections = ["## Skills", ""];
77287
77451
  let invocableCount = 0;
77288
77452
  let totalCount = 0;
77289
- const isInvocable = (entry) => invocableNames.has(entry.name) || entry.plugin !== void 0 && invocableNames.has(`${entry.plugin}:${entry.name}`);
77290
- const track = (entry, invocable) => {
77453
+ const track = (entry) => {
77291
77454
  totalCount++;
77292
- if (invocable) invocableCount++;
77293
- return formatSkillEntry(entry, invocable);
77455
+ if (classify(entry, invocableNames, registeredNames) === "invocable") invocableCount++;
77456
+ return formatSkillEntry(entry, classify(entry, invocableNames, registeredNames));
77294
77457
  };
77295
77458
  const userEntries = await listSkills(userDir);
77296
77459
  sections.push(`User skills (${userDir}): ${userEntries.length === 0 ? "none" : ""}`);
77297
- sections.push(...userEntries.map((e) => track(e, isInvocable(e))));
77460
+ sections.push(...userEntries.map(track));
77298
77461
  const extraDir = join$1(home, "plugins", "managed");
77299
- const extraEntries = await listManagedSkills(extraDir);
77462
+ const managed = await listManagedSkills(extraDir, pluginRecords);
77300
77463
  sections.push("");
77301
- sections.push(`Plugin-managed skills (${extraDir}): ${extraEntries.length === 0 ? "none" : ""}`);
77302
- sections.push(...extraEntries.map((e) => track(e, isInvocable(e))));
77464
+ sections.push(`Plugin-managed skills (${extraDir}): ${managed.entries.length === 0 ? "none" : ""}`);
77465
+ sections.push(...managed.entries.map(track));
77466
+ if (pluginRecords !== void 0) for (const record of pluginRecords.values()) {
77467
+ const warns = record.diagnostics.filter((d) => d.severity === "warn");
77468
+ if (warns.length === 0) continue;
77469
+ sections.push(`plugin ${record.id}: warnings: [${warns.map((d) => d.message).join(" | ")}]`);
77470
+ }
77471
+ if (managed.orphanLines.length > 0) {
77472
+ sections.push("");
77473
+ sections.push(`Unregistered plugin dirs (${extraDir}):`);
77474
+ sections.push(...managed.orphanLines);
77475
+ }
77303
77476
  const projectEntries = await listSkills(projectDir);
77304
77477
  sections.push("");
77305
77478
  sections.push(`Project skills (${projectDir}): ${projectEntries.length === 0 ? "none" : ""}`);
77306
- sections.push(...projectEntries.map((e) => track(e, isInvocable(e))));
77479
+ sections.push(...projectEntries.map((e) => track(e)));
77307
77480
  sections.push("");
77308
- sections.push(`Invocable now: ${invocableCount}/${totalCount} (against the live skill registry; entries marked "not invocable" exist on disk but are disabled, shadowed, renamed, or broken)`);
77481
+ sections.push(`Invocable now: ${invocableCount}/${totalCount} (against the live skill registry; entries marked "not invocable" carry their reason — registered-but-not, broken frontmatter, or unregistered)`);
77309
77482
  return sections.join("\n");
77310
77483
  }
77311
- /**
77312
- * Extracts the skill name from SKILL.md frontmatter (best effort) so the
77313
- * inventory name matches what the skill registry holds; returns null when
77314
- * frontmatter is missing or has no name line.
77315
- */
77316
- async function readSkillName(path) {
77317
- let handle;
77318
- try {
77319
- handle = await open(path, "r");
77320
- const buffer = Buffer.alloc(FRONTMATTER_READ_LIMIT);
77321
- const { bytesRead } = await handle.read(buffer, 0, FRONTMATTER_READ_LIMIT, 0);
77322
- const head = buffer.subarray(0, bytesRead).toString("utf-8").split("\n").slice(0, 25);
77323
- if (head[0]?.trim() !== "---") return null;
77324
- for (const line of head.slice(1)) {
77325
- if (line.trim() === "---") break;
77326
- const m = /^name\s*:\s*(.+)$/.exec(line);
77327
- if (m !== null) {
77328
- const value = m[1].trim().replaceAll(/^["']|["']$/g, "");
77329
- return value.length > 0 ? value : null;
77330
- }
77331
- }
77332
- return null;
77333
- } catch {
77334
- return null;
77335
- } finally {
77336
- await handle?.close().catch(() => {});
77337
- }
77338
- }
77339
- /**
77340
- * List plugin-managed skills: each `<dir>/SKILL.md` under a managed plugin
77341
- * directory is a skill entry (Extra source, mirroring plugin/manager.ts).
77342
- */
77343
- async function listManagedSkills(managedDir) {
77344
- let plugins;
77345
- try {
77346
- plugins = await readdir(managedDir, { withFileTypes: true });
77347
- } catch {
77348
- return [];
77349
- }
77350
- const out = [];
77351
- for (const plugin of plugins) {
77352
- if (!plugin.isDirectory() || plugin.name.startsWith(".")) continue;
77353
- const skillMd = join$1(managedDir, plugin.name, "SKILL.md");
77354
- if (!await isSkillDir(join$1(managedDir, plugin.name))) continue;
77355
- const fm = await checkFrontmatter(skillMd);
77356
- const skillName = await readSkillName(skillMd) ?? plugin.name;
77357
- out.push({
77358
- name: skillName,
77359
- path: skillMd,
77360
- kind: "dir",
77361
- frontmatter: fm,
77362
- plugin: plugin.name
77363
- });
77364
- }
77365
- return out.toSorted((a, b) => a.name.localeCompare(b.name));
77366
- }
77367
77484
  /** mcp.json files larger than this are reported as oversize and not parsed. */
77368
77485
  const MCP_CONFIG_SIZE_LIMIT = 1024 * 1024;
77369
77486
  async function inspectMcp(home, cwd) {
@@ -122932,24 +123049,28 @@ var ScreamCore = class {
122932
123049
  return this.config;
122933
123050
  }
122934
123051
  async setScreamConfig(input) {
122935
- const config = mergeConfigPatch(readConfigFile(this.configPath), input);
122936
- await writeConfigFile(this.configPath, config);
122937
- return this.config = loadRuntimeConfig(this.configPath);
123052
+ return this.enqueueConfigMutation(async () => {
123053
+ const config = mergeConfigPatch(readConfigFile(this.configPath), input);
123054
+ await writeConfigFile(this.configPath, config);
123055
+ return this.config = loadRuntimeConfig(this.configPath);
123056
+ });
122938
123057
  }
122939
123058
  async removeScreamProvider(input) {
122940
- const config = readConfigFile(this.configPath);
122941
- delete config.providers[input.providerId];
122942
- let removedDefault = false;
122943
- const existingModels = config.models ?? {};
122944
- for (const [key, model] of Object.entries(existingModels)) if (typeof model === "object" && model !== null && !Array.isArray(model) && model["provider"] === input.providerId) {
122945
- delete existingModels[key];
122946
- if (config.defaultModel === key) removedDefault = true;
122947
- }
122948
- config.models = existingModels;
122949
- if (removedDefault) config.defaultModel = void 0;
122950
- if (config.defaultProvider === input.providerId) config.defaultProvider = void 0;
122951
- await writeConfigFile(this.configPath, config);
122952
- return this.config = loadRuntimeConfig(this.configPath);
123059
+ return this.enqueueConfigMutation(async () => {
123060
+ const config = readConfigFile(this.configPath);
123061
+ delete config.providers[input.providerId];
123062
+ let removedDefault = false;
123063
+ const existingModels = config.models ?? {};
123064
+ for (const [key, model] of Object.entries(existingModels)) if (typeof model === "object" && model !== null && !Array.isArray(model) && model["provider"] === input.providerId) {
123065
+ delete existingModels[key];
123066
+ if (config.defaultModel === key) removedDefault = true;
123067
+ }
123068
+ config.models = existingModels;
123069
+ if (removedDefault) config.defaultModel = void 0;
123070
+ if (config.defaultProvider === input.providerId) config.defaultProvider = void 0;
123071
+ await writeConfigFile(this.configPath, config);
123072
+ return this.config = loadRuntimeConfig(this.configPath);
123073
+ });
122953
123074
  }
122954
123075
  setRuntimeSystemPrompt({ sessionId, ...payload }) {
122955
123076
  return this.sessionApi(sessionId).setRuntimeSystemPrompt(payload);
@@ -123491,6 +123612,23 @@ var ScreamCore = class {
123491
123612
  reloadProviderManager() {
123492
123613
  return this.config = loadRuntimeConfig(this.configPath);
123493
123614
  }
123615
+ /**
123616
+ * Serialize config-file mutations. Both setScreamConfig and
123617
+ * removeScreamProvider are read-modify-write cycles that read the file
123618
+ * synchronously and then await the write; two calls issued together (e.g.
123619
+ * cycling thinking levels plus confirming a model in the picker fire
123620
+ * back-to-back setConfig patches) would otherwise read the SAME stale
123621
+ * snapshot, and the later write silently reverts the earlier patch — a
123622
+ * lost update that makes e.g. a just-selected default model flip back to
123623
+ * the old one. The queue guarantees each cycle re-reads after the
123624
+ * previous write completed.
123625
+ */
123626
+ configMutationQueue = Promise.resolve();
123627
+ enqueueConfigMutation(mutate) {
123628
+ const run = this.configMutationQueue.then(mutate, mutate);
123629
+ this.configMutationQueue = run.catch(() => void 0);
123630
+ return run;
123631
+ }
123494
123632
  async refreshSessionRuntimeConfig(session, config) {
123495
123633
  const api = new SessionAPIImpl(session);
123496
123634
  const requested = (await api.getModel({ agentId: "main" })).trim();
@@ -130679,11 +130817,10 @@ async function changeThinkingLevel(host, alias, level) {
130679
130817
  }
130680
130818
  const prevLevel = host.state.appState.thinkingLevel;
130681
130819
  const isActiveModel = alias === host.state.appState.model;
130820
+ const session = host.session;
130682
130821
  if (isActiveModel && level !== prevLevel) {
130683
- const session = host.session;
130684
130822
  try {
130685
- if (session === void 0) await host.authFlow.activateModelAfterLogin(alias, level);
130686
- else await session.setThinking(level);
130823
+ if (session !== void 0) await session.setThinking(level);
130687
130824
  } catch (error) {
130688
130825
  const msg = formatErrorMessage(error);
130689
130826
  host.showError(`Failed to set thinking: ${msg}`);
@@ -130699,7 +130836,7 @@ async function changeThinkingLevel(host, alias, level) {
130699
130836
  host.showError(`Failed to save thinking default: ${msg}`);
130700
130837
  return;
130701
130838
  }
130702
- const status = isActiveModel && level !== prevLevel ? `Thinking set to ${level} for ${alias}.` : persisted ? `Saved thinking ${level} as default for ${alias}.` : `Thinking already ${level} for ${alias}.`;
130839
+ const status = isActiveModel && level !== prevLevel ? session === void 0 ? persisted ? `No active session - thinking ${level} for ${alias} saved as default.` : `No active session - thinking ${level} was NOT saved (${alias} is not the default model, so its level applies only when a new session selects it).` : `Thinking set to ${level} for ${alias}.` : persisted ? `Saved thinking ${level} as default for ${alias}.` : `Thinking already ${level} for ${alias}.`;
130703
130840
  host.showStatus(status, host.state.theme.colors.success);
130704
130841
  }
130705
130842
  async function persistThinkingDefault(host, alias, level) {
@@ -130728,18 +130865,16 @@ async function performModelSwitch(host, alias, thinkingLevel) {
130728
130865
  const prevThinkingLevel = host.state.appState.thinkingLevel;
130729
130866
  const modelChanged = alias !== prevModel;
130730
130867
  const thinkingChanged = thinkingLevel !== prevThinkingLevel;
130731
- const needsSessionActivation = modelChanged || thinkingChanged;
130732
- const overflow = alias !== prevModel ? contextOverflowForModel(host.state.appState, alias) : null;
130868
+ const session = host.session;
130869
+ const overflow = session !== void 0 && alias !== prevModel ? contextOverflowForModel(host.state.appState, alias) : null;
130733
130870
  if (overflow !== null) {
130734
130871
  host.showNotice("Storm Breaker(风暴守护者)", `无法切换到模型「${alias}」:当前会话上下文 ${formatTokenCount$1(overflow.currentTokens)} 已超出该模型上限 ${formatTokenCount$1(overflow.maxContextTokens)}。建议先执行 /compact 压缩上下文,或选择上下文窗口更大的模型。`);
130735
130872
  return;
130736
130873
  }
130737
- const session = host.session;
130738
130874
  let effectiveAlias = alias;
130739
130875
  let effectiveThinking = thinkingLevel;
130740
130876
  try {
130741
- if (session === void 0 && needsSessionActivation) await host.authFlow.activateModelAfterLogin(alias, thinkingLevel);
130742
- else if (session !== void 0) {
130877
+ if (session !== void 0) {
130743
130878
  if (modelChanged) await session.setModel(alias);
130744
130879
  if (thinkingChanged) await session.setThinking(thinkingLevel);
130745
130880
  const confirmed = await session.getStatus().catch(() => null);
@@ -130762,12 +130897,13 @@ async function performModelSwitch(host, alias, thinkingLevel) {
130762
130897
  persisted = await persistModelSelection(host, alias, thinkingLevel);
130763
130898
  } catch (error) {
130764
130899
  const msg = formatErrorMessage(error);
130765
- host.showError(`Switched to ${effectiveAlias}, but failed to save default: ${msg}`);
130900
+ host.showError(`${session === void 0 ? "Selected" : "Switched to"} ${effectiveAlias}, but failed to save default: ${msg}`);
130766
130901
  return;
130767
130902
  }
130768
130903
  const hasHistory = host.state.appState.contextTokens > 0;
130769
130904
  const cacheWarning = modelChanged && hasHistory ? " Note: switching models invalidates the existing prompt cache - use /new to avoid extra token costs." : "";
130770
130905
  const status = (() => {
130906
+ if (session === void 0 && (modelChanged || thinkingChanged)) return `No active session - ${effectiveAlias} (thinking ${effectiveThinking}) saved as default. It applies when you start a new session.`;
130771
130907
  if (modelChanged) return `Switched to ${effectiveAlias} with thinking ${effectiveThinking}.${cacheWarning}`;
130772
130908
  if (thinkingChanged) return `Thinking set to ${effectiveThinking} for ${effectiveAlias}.`;
130773
130909
  if (persisted) return `Saved ${effectiveAlias} with thinking ${effectiveThinking} as default.`;