rulesync 40.1.0 → 40.2.0

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.
@@ -23496,6 +23496,62 @@ function findOpencodeV2McpContainers(mcp) {
23496
23496
  return [...envelope !== null && Object.keys(envelope).length > 0 ? ["mcp.servers"] : [], ...isOpencodeV2GlobalTimeout(mcp.timeout) && Object.keys(mcp.timeout).length > 0 ? ["mcp.timeout"] : []];
23497
23497
  }
23498
23498
  /**
23499
+ * Returns the non-empty V2 global `mcp.timeout` object of an OpenCode `mcp`
23500
+ * value, or `undefined` when there is none.
23501
+ */
23502
+ function getOpencodeV2GlobalMcpTimeout(mcp) {
23503
+ return isRecord$1(mcp) && findOpencodeV2McpContainers(mcp).includes("mcp.timeout") ? mcp.timeout : void 0;
23504
+ }
23505
+ /**
23506
+ * Returns the raw entry an OpenCode `mcp` value holds for a server: the one
23507
+ * under the V2 `mcp.servers` envelope, or else the flat V1 entry.
23508
+ */
23509
+ function getOpencodeMcpServerEntry(mcp, name) {
23510
+ if (!isRecord$1(mcp)) return;
23511
+ const envelope = getOpencodeV2McpEnvelope(mcp);
23512
+ if (envelope !== null && Object.hasOwn(envelope, name)) return envelope[name];
23513
+ return Object.hasOwn(mcp, name) ? mcp[name] : void 0;
23514
+ }
23515
+ const OPENCODE_V1_TO_V2_OAUTH_KEYS = new Map([...OPENCODE_V2_OAUTH_KEYS].map(([v2, v1]) => [v1, v2]));
23516
+ /**
23517
+ * Raises one V1-shaped OpenCode MCP server (`local` or `remote`) to the V2
23518
+ * spelling — the inverse of {@link lowerOpencodeV2Server}:
23519
+ *
23520
+ * - `enabled` is dropped and `disabled: true` written only for a disabled server;
23521
+ * - a number `timeout` becomes `{ catalog, execution }`, and camelCase `oauth`
23522
+ * keys become snake_case.
23523
+ *
23524
+ * Values Rulesync's canonical form cannot hold are taken from the entry the
23525
+ * file already has for the server (`existing`): its `timeout` object and its
23526
+ * `oauth` object are kept verbatim whenever lowering them gives back exactly
23527
+ * what the canonical server carries, so a V2 file survives import and
23528
+ * generate unchanged. When the canonical `timeout` differs, the existing
23529
+ * `startup` value is still kept.
23530
+ */
23531
+ function raiseOpencodeServerToV2(server, existing) {
23532
+ const { enabled, timeout, oauth, ...rest } = server;
23533
+ const raised = { ...rest };
23534
+ if (enabled === false) raised.disabled = true;
23535
+ const existingTimeout = isRecord$1(existing) && isRecord$1(existing.timeout) ? existing.timeout : null;
23536
+ if (existingTimeout !== null && lowerOpencodeV2Timeout(existingTimeout) === timeout) raised.timeout = existingTimeout;
23537
+ else if (typeof timeout === "number") {
23538
+ const startup = existingTimeout?.startup;
23539
+ raised.timeout = {
23540
+ ...startup !== void 0 && { startup },
23541
+ catalog: timeout,
23542
+ execution: timeout
23543
+ };
23544
+ } else if (timeout !== void 0) raised.timeout = timeout;
23545
+ const existingOauth = isRecord$1(existing) && isRecord$1(existing.oauth) ? existing.oauth : null;
23546
+ if (existingOauth !== null && isRecord$1(oauth) && (0, node_util.isDeepStrictEqual)(lowerOpencodeV2OAuth(existingOauth, ""), oauth)) raised.oauth = existingOauth;
23547
+ else if (isRecord$1(oauth)) {
23548
+ const snakeOauth = {};
23549
+ for (const [key, value] of Object.entries(oauth)) setOwnEntry(snakeOauth, OPENCODE_V1_TO_V2_OAUTH_KEYS.get(key) ?? key, value);
23550
+ raised.oauth = snakeOauth;
23551
+ } else if (oauth !== void 0) raised.oauth = oauth;
23552
+ return raised;
23553
+ }
23554
+ /**
23499
23555
  * Lowers OpenCode V2 `mcp` spellings found in a V1 config to the flat V1 map,
23500
23556
  * the way OpenCode V1 itself does since v1.18.24 (`normalizeMcp`):
23501
23557
  *
@@ -33957,6 +34013,69 @@ var JunieHooks = class JunieHooks extends ToolHooks {
33957
34013
  }
33958
34014
  };
33959
34015
  //#endregion
34016
+ //#region src/features/hooks/hook-block-reason.ts
34017
+ /**
34018
+ * Source lines of a `sanitizeBlockReason(raw)` helper, emitted into generated
34019
+ * hook plugins that hand a hook command's output to the agent as the reason a
34020
+ * call was blocked (the Pi / oh-my-pi extension and the OpenCode-style plugin).
34021
+ * It returns the sanitized, length-capped text, or `""` when nothing survives
34022
+ * so the caller can substitute its own fallback.
34023
+ *
34024
+ * The text is sanitized because a hook command's output can relay third-party
34025
+ * content (linter output, matched file lines) into a terminal or, in RPC mode,
34026
+ * into an external client. Escape sequences, C1 and other control characters,
34027
+ * and format characters such as bidirectional overrides and zero-width joiners
34028
+ * are stripped so the reason cannot repaint or reorder what the user is shown,
34029
+ * and carriage returns are folded into newlines so it cannot overwrite an
34030
+ * already printed line. The format-character pass is a superset of the bidi
34031
+ * controls listed in `CONTROL_CHARACTERS_PATTERN`
34032
+ * (`src/utils/control-characters.ts`): a blocked-hook reason is short
34033
+ * diagnostic text, so dropping every `Cf` character is safer than enumerating
34034
+ * the harmful ones and missing `U+061C`, `U+FEFF`, or a future addition.
34035
+ *
34036
+ * Sanitization runs before truncation so a reason that begins with a progress
34037
+ * banner is still reported by its content. The raw text is only sliced to a
34038
+ * generous backstop first: a hook command may emit a large amount of output,
34039
+ * and the slice bounds the work even if a later edit reintroduces a pattern
34040
+ * that is not linear. A slice that actually fired is reported as truncated, so
34041
+ * the text is never silently cut.
34042
+ *
34043
+ * Both slices cut on UTF-16 code units, so each drops a trailing lone high
34044
+ * surrogate: the reason may travel as JSON, and half a surrogate pair is not
34045
+ * text a strict consumer has to accept.
34046
+ *
34047
+ * `typed` emits TypeScript parameter and return annotations (for the Pi
34048
+ * extension, a `.ts` module); without it the lines are plain JavaScript.
34049
+ */
34050
+ function buildBlockReasonSanitizerLines({ typed }) {
34051
+ const str = typed ? ": string" : "";
34052
+ return [
34053
+ "const MAX_BLOCK_REASON_LENGTH = 2000;",
34054
+ "const MAX_SCANNED_REASON_LENGTH = 128_000;",
34055
+ "",
34056
+ `function dropTrailingLoneSurrogate(text${str})${str} {`,
34057
+ " return text.replace(/[\\ud800-\\udbff]$/, \"\");",
34058
+ "}",
34059
+ "",
34060
+ `function sanitizeBlockReason(raw${str})${str} {`,
34061
+ " const scannedTruncated = raw.length > MAX_SCANNED_REASON_LENGTH;",
34062
+ " const scanned = scannedTruncated",
34063
+ " ? dropTrailingLoneSurrogate(raw.slice(0, MAX_SCANNED_REASON_LENGTH))",
34064
+ " : raw;",
34065
+ " const sanitized = scanned",
34066
+ " .replace(/\\r\\n?/g, \"\\n\")",
34067
+ " .replace(/\\u001b\\[[0-9;?]*[\\u0020-\\u002f]*[\\u0040-\\u007e]/g, \"\")",
34068
+ " .replace(/\\u001b\\][^\\u0007\\u001b]{0,256}(?:\\u0007|\\u001b\\\\)/g, \"\")",
34069
+ " .replace(/[\\u0000-\\u0008\\u000b\\u000c\\u000e-\\u001f\\u007f-\\u009f]/g, \"\")",
34070
+ " .replace(/[\\p{Cf}\\p{Zl}\\p{Zp}]/gu, \"\")",
34071
+ " .trim();",
34072
+ " if (!sanitized) return \"\";",
34073
+ " if (sanitized.length <= MAX_BLOCK_REASON_LENGTH && !scannedTruncated) return sanitized;",
34074
+ " return `${dropTrailingLoneSurrogate(sanitized.slice(0, MAX_BLOCK_REASON_LENGTH))}...`;",
34075
+ "}"
34076
+ ];
34077
+ }
34078
+ //#endregion
33960
34079
  //#region src/features/hooks/opencode-style-generator.ts
33961
34080
  /**
33962
34081
  * Tool events emitted as named `(input, ...)` hooks rather than through the
@@ -34027,6 +34146,87 @@ const SHELL_EVENT_TOOL_GATES = {
34027
34146
  tool: "bash"
34028
34147
  }
34029
34148
  };
34149
+ /**
34150
+ * Canonical events whose command receives a Claude Code-style JSON payload on
34151
+ * stdin and whose result can deny the call, mapped to the `hook_event_name`
34152
+ * the payload carries. Only `preToolUse` (`tool.execute.before`) takes part:
34153
+ * it is the one named hook where throwing aborts the call — OpenCode rejects
34154
+ * the tool call and returns the error message to the model (the `.env`
34155
+ * protection example in the plugins doc).
34156
+ *
34157
+ * The payload mirrors Claude Code's `PreToolUse` input: `hook_event_name`,
34158
+ * `session_id` (`input.sessionID`), `tool_use_id` (`input.callID`),
34159
+ * `tool_name` (the raw OpenCode tool name — `bash`, `edit`, `<server>_<tool>`
34160
+ * for MCP — the same subject a `matcher` is tested against), `tool_input`
34161
+ * (`output.args`) and `cwd` (the plugin context's `directory`, falling back to
34162
+ * `process.cwd()`). It is serialized lazily, on the first handler whose
34163
+ * matcher matches, so a tool call no hook applies to pays nothing for it.
34164
+ *
34165
+ * Bun Shell has no stdin option on a command and rejects redirections on a
34166
+ * subshell, so the payload is piped in by the shell's builtin `echo`
34167
+ * (`echo ${payload} | <command>`); Bun escapes the interpolated value, so the
34168
+ * payload never reaches the shell as syntax. The pipe feeds the first
34169
+ * pipeline of the command — a compound `a && b` or `a; b` gives the payload
34170
+ * to `a` only, so multi-step guards belong in a script.
34171
+ *
34172
+ * @see https://opencode.ai/docs/plugins/
34173
+ * @see https://bun.sh/docs/runtime/shell
34174
+ * @see https://code.claude.com/docs/en/hooks
34175
+ */
34176
+ const STDIN_PAYLOAD_EVENTS = { preToolUse: "PreToolUse" };
34177
+ /**
34178
+ * Name of the helper the generated plugin defines when a `preToolUse` handler
34179
+ * is present. It interprets the command's result like Claude Code does for a
34180
+ * `PreToolUse` hook, throwing (which aborts the OpenCode tool call) when the
34181
+ * hook denies it:
34182
+ * - exit code 2 → deny, with the command's stderr as the reason;
34183
+ * - exit code 0 with JSON stdout whose `hookSpecificOutput.permissionDecision`
34184
+ * is `"deny"` → deny, with `permissionDecisionReason` as the reason;
34185
+ * - exit code 0 otherwise → allow;
34186
+ * - any other non-zero exit → deny too, with stderr or the exit code.
34187
+ *
34188
+ * Every reason goes through the `sanitizeBlockReason` helper shared with the
34189
+ * Pi extension (`hook-block-reason.ts`): control and format characters are
34190
+ * stripped and the text is capped at 2000 characters before the model sees it.
34191
+ *
34192
+ * The other non-zero exit case is deliberate. Claude Code treats that as a
34193
+ * non-blocking error, but before this payload contract existed the plugin
34194
+ * ran the command through Bun Shell's default throwing mode, so every
34195
+ * non-zero exit aborted the call. Keeping that preserves guards that block
34196
+ * with `exit 1`, rather than silently failing them open.
34197
+ */
34198
+ const PRE_TOOL_USE_RESULT_HELPER = "__rulesyncEnforcePreToolUseResult";
34199
+ const PRE_TOOL_USE_RESULT_HELPER_LINES = [
34200
+ ...buildBlockReasonSanitizerLines({ typed: false }),
34201
+ "",
34202
+ `const ${PRE_TOOL_USE_RESULT_HELPER} = (result) => {`,
34203
+ " const stderr = result.stderr.toString();",
34204
+ " if (result.exitCode === 0) {",
34205
+ " let parsed;",
34206
+ " try {",
34207
+ " parsed = JSON.parse(result.stdout.toString());",
34208
+ " } catch {",
34209
+ " return;",
34210
+ " }",
34211
+ " const specific = parsed && typeof parsed === \"object\" ? parsed.hookSpecificOutput : undefined;",
34212
+ " if (specific && typeof specific === \"object\" && specific.permissionDecision === \"deny\") {",
34213
+ " throw new Error(",
34214
+ " (typeof specific.permissionDecisionReason === \"string\" &&",
34215
+ " sanitizeBlockReason(specific.permissionDecisionReason)) ||",
34216
+ " \"Blocked by a preToolUse hook.\",",
34217
+ " );",
34218
+ " }",
34219
+ " return;",
34220
+ " }",
34221
+ " if (result.exitCode === 2) {",
34222
+ " throw new Error(sanitizeBlockReason(stderr) || \"Blocked by a preToolUse hook.\");",
34223
+ " }",
34224
+ " throw new Error(",
34225
+ " sanitizeBlockReason(stderr) ||",
34226
+ " \"preToolUse hook command failed with exit code \" + result.exitCode + \".\",",
34227
+ " );",
34228
+ "};"
34229
+ ];
34030
34230
  function escapeForTemplateLiteral(command) {
34031
34231
  return command.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
34032
34232
  }
@@ -34041,6 +34241,17 @@ function validateAndSanitizeMatcher(matcher) {
34041
34241
  }
34042
34242
  return sanitized.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
34043
34243
  }
34244
+ /** The per-canonical-event gates and payload flag every handler of it carries. */
34245
+ function handlerExtrasFor(canonicalEvent) {
34246
+ const shellGate = SHELL_EVENT_TOOL_GATES[canonicalEvent];
34247
+ const propertyGate = GENERIC_EVENT_PROPERTY_GATES[canonicalEvent];
34248
+ const stdinEvent = shellGate ? void 0 : STDIN_PAYLOAD_EVENTS[canonicalEvent];
34249
+ return {
34250
+ ...shellGate ? { toolGate: shellGate.tool } : {},
34251
+ ...propertyGate ? { propertyGate } : {},
34252
+ ...stdinEvent ? { stdinEvent } : {}
34253
+ };
34254
+ }
34044
34255
  /**
34045
34256
  * Group the effective hooks into named (tool.execute.*) and generic event
34046
34257
  * handler groups, keyed by tool event name. Mutates the supplied groups.
@@ -34048,7 +34259,7 @@ function validateAndSanitizeMatcher(matcher) {
34048
34259
  function collectOpencodeStyleHandlers({ effectiveHooks, eventMap, namedEventHandlers, genericEventHandlers }) {
34049
34260
  for (const [canonicalEvent, definitions] of Object.entries(effectiveHooks)) {
34050
34261
  const shellGate = SHELL_EVENT_TOOL_GATES[canonicalEvent];
34051
- const propertyGate = GENERIC_EVENT_PROPERTY_GATES[canonicalEvent];
34262
+ const extras = handlerExtrasFor(canonicalEvent);
34052
34263
  const toolEvent = shellGate?.toolEvent ?? eventMap[canonicalEvent];
34053
34264
  if (!toolEvent) continue;
34054
34265
  const matcherSupported = !shellGate && Object.hasOwn(NAMED_HOOK_MATCHER_SUBJECTS, toolEvent) && NAMED_HOOK_MATCHER_SUBJECTS[toolEvent] !== null;
@@ -34060,8 +34271,7 @@ function collectOpencodeStyleHandlers({ effectiveHooks, eventMap, namedEventHand
34060
34271
  handlers.push({
34061
34272
  command: def.command,
34062
34273
  matcher: def.matcher ? def.matcher : void 0,
34063
- ...shellGate ? { toolGate: shellGate.tool } : {},
34064
- ...propertyGate ? { propertyGate } : {}
34274
+ ...extras
34065
34275
  });
34066
34276
  }
34067
34277
  if (handlers.length > 0) {
@@ -34094,46 +34304,79 @@ function buildGenericEventBodyLines(genericEventHandlers) {
34094
34304
  bodyLines.push(" },");
34095
34305
  return bodyLines;
34096
34306
  }
34307
+ function stdinPayloadVariable(stdinEvent) {
34308
+ return `__rulesync${stdinEvent}Input`;
34309
+ }
34310
+ /**
34311
+ * The statement that runs a handler's command. A handler with a stdin payload
34312
+ * pipes the payload in and hands the result to the deny-aware helper; every
34313
+ * other handler keeps Bun Shell's default mode, where a non-zero exit throws.
34314
+ */
34315
+ function buildRunStatement(handler) {
34316
+ const escapedCommand = escapeForTemplateLiteral(handler.command);
34317
+ if (handler.stdinEvent) {
34318
+ const payload = stdinPayloadVariable(handler.stdinEvent);
34319
+ return `${PRE_TOOL_USE_RESULT_HELPER}(await $\`echo \${${payload}()} | ${escapedCommand}\`.nothrow().quiet());`;
34320
+ }
34321
+ return `await $\`${escapedCommand}\`;`;
34322
+ }
34097
34323
  /** Emit the named (`tool.execute.*`) handler blocks. */
34098
34324
  function buildNamedEventBodyLines(namedEventHandlers) {
34099
34325
  const bodyLines = [];
34100
34326
  for (const [eventName, handlers] of Object.entries(namedEventHandlers)) {
34101
34327
  const matcherSubject = NAMED_HOOK_MATCHER_SUBJECTS[eventName] ?? null;
34102
- bodyLines.push(` "${eventName}": async (input) => {`);
34328
+ const stdinEvents = [...new Set(handlers.flatMap((handler) => handler.stdinEvent ? [handler.stdinEvent] : []))];
34329
+ bodyLines.push(` "${eventName}": async (${stdinEvents.length > 0 ? "input, output" : "input"}) => {`);
34330
+ for (const stdinEvent of stdinEvents) {
34331
+ const payload = stdinPayloadVariable(stdinEvent);
34332
+ bodyLines.push(` let ${payload}Cache;`);
34333
+ bodyLines.push(` const ${payload} = () =>`);
34334
+ bodyLines.push(` (${payload}Cache ??= JSON.stringify({`);
34335
+ bodyLines.push(` hook_event_name: "${stdinEvent}",`);
34336
+ bodyLines.push(" session_id: input.sessionID,");
34337
+ bodyLines.push(" tool_use_id: input.callID,");
34338
+ bodyLines.push(" tool_name: input.tool,");
34339
+ bodyLines.push(" tool_input: output?.args,");
34340
+ bodyLines.push(" cwd: directory ?? process.cwd(),");
34341
+ bodyLines.push(" }));");
34342
+ }
34103
34343
  for (const handler of handlers) {
34104
- const escapedCommand = escapeForTemplateLiteral(handler.command);
34344
+ const runStatement = buildRunStatement(handler);
34105
34345
  if (handler.toolGate) {
34106
34346
  bodyLines.push(` if (input.tool === "${handler.toolGate}") {`);
34107
- bodyLines.push(` await $\`${escapedCommand}\`;`);
34347
+ bodyLines.push(` ${runStatement}`);
34108
34348
  bodyLines.push(" }");
34109
34349
  } else if (handler.matcher && matcherSubject !== null) {
34110
34350
  const safeMatcher = validateAndSanitizeMatcher(handler.matcher);
34111
34351
  bodyLines.push(" {");
34112
34352
  bodyLines.push(` const __re = new RegExp("${safeMatcher}");`);
34113
34353
  bodyLines.push(` if (__re.test(${matcherSubject})) {`);
34114
- bodyLines.push(` await $\`${escapedCommand}\`;`);
34354
+ bodyLines.push(` ${runStatement}`);
34115
34355
  bodyLines.push(" }");
34116
34356
  bodyLines.push(" }");
34117
- } else bodyLines.push(` await $\`${escapedCommand}\`;`);
34357
+ } else bodyLines.push(` ${runStatement}`);
34118
34358
  }
34119
34359
  bodyLines.push(" },");
34120
34360
  }
34121
34361
  return bodyLines;
34122
34362
  }
34123
34363
  /** Wrap the handler body lines in the requested export shape. */
34124
- function wrapInExportShape({ bodyLines, exportStyle }) {
34364
+ function wrapInExportShape({ preambleLines, bodyLines, exportStyle }) {
34365
+ const pluginContextPattern = preambleLines.length > 0 ? "{ $, directory }" : "{ $ }";
34125
34366
  const lines = [];
34126
34367
  if (exportStyle === "default") {
34127
34368
  lines.push("export default {");
34128
34369
  lines.push(" id: \"rulesync-hooks\",");
34129
- lines.push(" server: async ({ $ }) => {");
34370
+ lines.push(` server: async (${pluginContextPattern}) => {`);
34371
+ for (const line of preambleLines) lines.push(line === "" ? "" : ` ${line}`);
34130
34372
  lines.push(" return {");
34131
34373
  for (const line of bodyLines) lines.push(line === "" ? "" : ` ${line}`);
34132
34374
  lines.push(" };");
34133
34375
  lines.push(" },");
34134
34376
  lines.push("};");
34135
34377
  } else {
34136
- lines.push("export const RulesyncHooksPlugin = async ({ $ }) => {");
34378
+ lines.push(`export const RulesyncHooksPlugin = async (${pluginContextPattern}) => {`);
34379
+ for (const line of preambleLines) lines.push(line === "" ? "" : ` ${line}`);
34137
34380
  lines.push(" return {");
34138
34381
  lines.push(...bodyLines);
34139
34382
  lines.push(" };");
@@ -34158,8 +34401,10 @@ function generateOpencodeStylePluginCode(config, supportedEvents, toolConfigKey,
34158
34401
  namedEventHandlers,
34159
34402
  genericEventHandlers
34160
34403
  });
34404
+ const bodyLines = [...buildGenericEventBodyLines(genericEventHandlers), ...buildNamedEventBodyLines(namedEventHandlers)];
34161
34405
  return wrapInExportShape({
34162
- bodyLines: [...buildGenericEventBodyLines(genericEventHandlers), ...buildNamedEventBodyLines(namedEventHandlers)],
34406
+ preambleLines: Object.values(namedEventHandlers).some((handlers) => handlers.some((handler) => handler.stdinEvent !== void 0)) ? PRE_TOOL_USE_RESULT_HELPER_LINES : [],
34407
+ bodyLines,
34163
34408
  exportStyle
34164
34409
  }).join("\n");
34165
34410
  }
@@ -35304,36 +35549,12 @@ function failureLinesByMode(dialect) {
35304
35549
  * `PI_SETTLE_BLOCKING_EVENT`): such a failure is rethrown for Pi to report
35305
35550
  * instead of continuing the agent.
35306
35551
  *
35307
- * The chosen text is sanitized because a hook command's output can relay
35308
- * third-party content (linter output, matched file lines) into a terminal or,
35309
- * in RPC mode, into an external client. Escape sequences, C1 and other control
35310
- * characters, and format characters such as bidirectional overrides and
35311
- * zero-width joiners are stripped so the reason cannot repaint or reorder what
35312
- * the user is shown, and carriage returns are folded into newlines so it cannot
35313
- * overwrite an already printed line. The format-character pass is a superset of
35314
- * the bidi controls listed in `CONTROL_CHARACTERS_PATTERN`
35315
- * (`src/utils/control-characters.ts`): a blocked-hook reason is short
35316
- * diagnostic text, so dropping every `Cf` character is safer than enumerating
35317
- * the harmful ones and missing `U+061C`, `U+FEFF`, or a future addition.
35318
- *
35319
- * Sanitization runs before truncation so a reason that begins with a progress
35320
- * banner is still reported by its content. The raw text is only sliced to a
35321
- * generous backstop first: a hook command may emit up to `exec`'s full
35322
- * `maxBuffer`, and the slice bounds the work even if a later edit reintroduces
35323
- * a pattern that is not linear. A slice that actually fired is reported as
35324
- * truncated, so the text is never silently cut.
35325
- *
35326
- * Both slices cut on UTF-16 code units, so each drops a trailing lone high
35327
- * surrogate: the reason travels to an RPC client as JSON, and half a surrogate
35328
- * pair is not text a strict consumer has to accept.
35552
+ * The chosen text goes through `sanitizeBlockReason`, shared with the
35553
+ * OpenCode-style plugin (see `hook-block-reason.ts`): control and format
35554
+ * characters are stripped and the result is capped at 2000 characters.
35329
35555
  */
35330
35556
  const BLOCK_REASON_HELPER_LINES = [
35331
- "const MAX_BLOCK_REASON_LENGTH = 2000;",
35332
- "const MAX_SCANNED_REASON_LENGTH = 128_000;",
35333
- "",
35334
- "function dropTrailingLoneSurrogate(text: string): string {",
35335
- " return text.replace(/[\\ud800-\\udbff]$/, \"\");",
35336
- "}",
35557
+ ...buildBlockReasonSanitizerLines({ typed: true }),
35337
35558
  "",
35338
35559
  "function toBlockReason(error: unknown): string {",
35339
35560
  " const result = error as { stdout?: unknown; stderr?: unknown; code?: unknown } | null;",
@@ -35347,20 +35568,7 @@ const BLOCK_REASON_HELPER_LINES = [
35347
35568
  " : error instanceof Error",
35348
35569
  " ? error.message",
35349
35570
  " : String(error));",
35350
- " const scannedTruncated = raw.length > MAX_SCANNED_REASON_LENGTH;",
35351
- " const scanned = scannedTruncated",
35352
- " ? dropTrailingLoneSurrogate(raw.slice(0, MAX_SCANNED_REASON_LENGTH))",
35353
- " : raw;",
35354
- " const sanitized = scanned",
35355
- " .replace(/\\r\\n?/g, \"\\n\")",
35356
- " .replace(/\\u001b\\[[0-9;?]*[\\u0020-\\u002f]*[\\u0040-\\u007e]/g, \"\")",
35357
- " .replace(/\\u001b\\][^\\u0007\\u001b]{0,256}(?:\\u0007|\\u001b\\\\)/g, \"\")",
35358
- " .replace(/[\\u0000-\\u0008\\u000b\\u000c\\u000e-\\u001f\\u007f-\\u009f]/g, \"\")",
35359
- " .replace(/[\\p{Cf}\\p{Zl}\\p{Zp}]/gu, \"\")",
35360
- " .trim();",
35361
- " if (!sanitized) return \"Hook command failed.\";",
35362
- " if (sanitized.length <= MAX_BLOCK_REASON_LENGTH && !scannedTruncated) return sanitized;",
35363
- " return `${dropTrailingLoneSurrogate(sanitized.slice(0, MAX_BLOCK_REASON_LENGTH))}...`;",
35571
+ " return sanitizeBlockReason(raw) || \"Hook command failed.\";",
35364
35572
  "}"
35365
35573
  ];
35366
35574
  /**
@@ -48979,12 +49187,18 @@ function warnAboutToggleDroppedKeys(serverName, serverConfig, logger) {
48979
49187
  * - enabledTools/disabledTools -> top-level tools map (with server name prefix)
48980
49188
  * - OpenCode-supported extras (timeout, oauth, protocol, codemode) -> passed through verbatim
48981
49189
  */
48982
- function convertServerToOpencodeFormat(serverName, serverConfig, existingEntry, logger) {
49190
+ function convertServerToOpencodeFormat(serverName, serverConfig, existingEntry, configShape, logger) {
48983
49191
  const serverRecord = serverConfig;
48984
49192
  const passthrough = {};
48985
49193
  for (const key of OPENCODE_PASSTHROUGH_SERVER_FIELDS) if (serverRecord[key] !== void 0) passthrough[key] = serverRecord[key];
48986
49194
  const enabled = serverConfig.disabled !== void 0 ? !serverConfig.disabled : true;
48987
49195
  if (declaresNoTransport(serverConfig)) {
49196
+ if (configShape === "v2") return warnAndSkipMcpServer({
49197
+ toolName: "OpenCode",
49198
+ serverName,
49199
+ reason: "no transport, and the OpenCode V2 shape (\"configShape\": \"v2\") has no toggle-only entry",
49200
+ logger
49201
+ });
48988
49202
  if (serverConfig.disabled === void 0) {
48989
49203
  if (existingEntry !== void 0 && !isOpencodeTransportServer(existingEntry)) {
48990
49204
  warnAboutToggleDroppedKeys(serverName, serverConfig, logger);
@@ -49032,11 +49246,11 @@ function convertServerToOpencodeFormat(serverName, serverConfig, existingEntry,
49032
49246
  ...serverConfig.cwd && { cwd: serverConfig.cwd }
49033
49247
  };
49034
49248
  }
49035
- function convertToOpencodeFormat(mcpServers, existingMcp, logger) {
49249
+ function convertToOpencodeFormat(mcpServers, existingMcp, configShape, logger) {
49036
49250
  const tools = {};
49037
49251
  return {
49038
49252
  mcp: Object.fromEntries(Object.entries(mcpServers).map(([serverName, serverConfig]) => {
49039
- const converted = convertServerToOpencodeFormat(serverName, serverConfig, existingMcp[serverName], logger);
49253
+ const converted = convertServerToOpencodeFormat(serverName, serverConfig, existingMcp[serverName], configShape, logger);
49040
49254
  if (serverConfig.enabledTools) for (const tool of serverConfig.enabledTools) tools[`${serverName}_${tool}`] = true;
49041
49255
  if (serverConfig.disabledTools) for (const tool of serverConfig.disabledTools) tools[`${serverName}_${tool}`] = false;
49042
49256
  return converted === null ? null : [serverName, converted];
@@ -49044,6 +49258,7 @@ function convertToOpencodeFormat(mcpServers, existingMcp, logger) {
49044
49258
  tools
49045
49259
  };
49046
49260
  }
49261
+ const OpencodeMcpFeatureOptionsSchema = zod_mini.z.looseObject({ configShape: zod_mini.z.optional(zod_mini.z.enum(["v1", "v2"])) });
49047
49262
  var OpencodeMcp = class extends ToolMcp {
49048
49263
  /** Directory layout; OpenCode forks (MiMo Code) override it. */
49049
49264
  static layout = OPENCODE_LAYOUT;
@@ -49082,6 +49297,19 @@ var OpencodeMcp = class extends ToolMcp {
49082
49297
  return this.json;
49083
49298
  }
49084
49299
  /**
49300
+ * Reads the `configShape` option of the target's `mcp` feature options
49301
+ * (`targets.opencode.mcp.configShape`). Only a layout that reads the V2
49302
+ * spellings back can be given the V2 shape.
49303
+ */
49304
+ static resolveConfigShape(options) {
49305
+ if (!options) return "v1";
49306
+ const parsed = OpencodeMcpFeatureOptionsSchema.safeParse(options);
49307
+ if (!parsed.success) throw new Error(`Invalid options for the ${this.layout.toolTarget} mcp feature: \`configShape\` must be either "v1" or "v2".`);
49308
+ const configShape = parsed.data.configShape ?? "v1";
49309
+ if (configShape === "v2" && !this.layout.readsV2ConfigSpellings) throw new Error(`Invalid options for the ${this.layout.toolTarget} mcp feature: \`configShape: "v2"\` is supported only for OpenCode.`);
49310
+ return configShape;
49311
+ }
49312
+ /**
49085
49313
  * opencode.json may contain other settings, so it should not be deleted.
49086
49314
  */
49087
49315
  isDeletable() {
@@ -49125,7 +49353,8 @@ var OpencodeMcp = class extends ToolMcp {
49125
49353
  logger
49126
49354
  });
49127
49355
  }
49128
- static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, global = false, logger }) {
49356
+ static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, global = false, logger, featureOptions }) {
49357
+ const configShape = this.resolveConfigShape(featureOptions);
49129
49358
  const basePaths = this.getSettablePaths({ global });
49130
49359
  const jsonDir = (0, node_path.join)(outputRoot, basePaths.relativeDirPath);
49131
49360
  let fileContent = null;
@@ -49143,16 +49372,21 @@ var OpencodeMcp = class extends ToolMcp {
49143
49372
  });
49144
49373
  const existingJson = (0, jsonc_parser.parse)(fileContent || "{}");
49145
49374
  const existingMcp = OpencodeConfigSchema.safeParse(this.lowerConfig(existingJson));
49146
- if (this.layout.readsV2ConfigSpellings && isRecord$1(existingJson)) {
49147
- const containers = findOpencodeV2McpContainers(existingJson.mcp);
49375
+ const existingRawMcp = isRecord$1(existingJson) ? existingJson.mcp : void 0;
49376
+ if (configShape === "v1" && this.layout.readsV2ConfigSpellings) {
49377
+ const containers = findOpencodeV2McpContainers(existingRawMcp);
49148
49378
  if (containers.length > 0) logger?.warn(`OpenCode MCP: the whole "mcp" key is rewritten in the flat V1 shape, so ${containers.map(quoteValueForWarning).join(" and ")} ${containers.length === 1 ? "is" : "are"} not kept.`);
49149
49379
  }
49150
- const { mcp: convertedMcp, tools: mcpTools } = convertToOpencodeFormat(transformedServers, (existingMcp.success ? existingMcp.data.mcp : void 0) ?? {}, logger);
49151
- const generatedMcp = undefinedWhenEmpty(convertedMcp);
49380
+ const { mcp: convertedMcp, tools: mcpTools } = convertToOpencodeFormat(transformedServers, (existingMcp.success ? existingMcp.data.mcp : void 0) ?? {}, configShape, logger);
49381
+ const generatedServers = undefinedWhenEmpty(convertedMcp);
49382
+ const generatedMcp = configShape === "v2" ? this.toV2Mcp({
49383
+ servers: convertedMcp,
49384
+ existingRawMcp
49385
+ }) : generatedServers;
49152
49386
  const generatedTools = Object.keys(mcpTools).length > 0 ? mcpTools : void 0;
49153
49387
  warnOpencodeDotDirOverrides({
49154
49388
  generated: {
49155
- mcp: generatedMcp,
49389
+ mcp: generatedServers,
49156
49390
  tools: generatedTools
49157
49391
  },
49158
49392
  overlays: (await readOpencodeDotDirConfigLayers({
@@ -49188,6 +49422,28 @@ var OpencodeMcp = class extends ToolMcp {
49188
49422
  });
49189
49423
  }
49190
49424
  /**
49425
+ * Builds the V2 `mcp` value: each server raised to the V2 spelling under the
49426
+ * `mcp.servers` envelope, keeping what the existing file has that Rulesync
49427
+ * cannot express (see {@link raiseOpencodeServerToV2}), plus the existing
49428
+ * global `mcp.timeout`, which has no canonical counterpart.
49429
+ */
49430
+ static toV2Mcp({ servers, existingRawMcp }) {
49431
+ const v2Servers = {};
49432
+ for (const [name, server] of Object.entries(servers)) Object.defineProperty(v2Servers, name, {
49433
+ value: raiseOpencodeServerToV2(server, getOpencodeMcpServerEntry(existingRawMcp, name)),
49434
+ enumerable: true,
49435
+ configurable: true,
49436
+ writable: true
49437
+ });
49438
+ const globalTimeout = getOpencodeV2GlobalMcpTimeout(existingRawMcp);
49439
+ const hasServers = Object.keys(v2Servers).length > 0;
49440
+ if (!hasServers && globalTimeout === void 0) return;
49441
+ return {
49442
+ ...globalTimeout !== void 0 && { timeout: globalTimeout },
49443
+ ...hasServers && { servers: v2Servers }
49444
+ };
49445
+ }
49446
+ /**
49191
49447
  * Register additional instruction file paths into the shared opencode config
49192
49448
  * (`opencode.json` / `opencode.jsonc`) under the `instructions` key.
49193
49449
  *
@@ -54907,7 +55163,8 @@ var McpProcessor = class extends FeatureProcessor {
54907
55163
  toolTarget;
54908
55164
  global;
54909
55165
  getFactory;
54910
- constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, getFactory = defaultGetFactory$3, dryRun = false, logger }) {
55166
+ featureOptions;
55167
+ constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, getFactory = defaultGetFactory$3, dryRun = false, logger, featureOptions }) {
54911
55168
  super({
54912
55169
  outputRoot,
54913
55170
  inputRoots,
@@ -54919,6 +55176,7 @@ var McpProcessor = class extends FeatureProcessor {
54919
55176
  this.toolTarget = result.data;
54920
55177
  this.global = global;
54921
55178
  this.getFactory = getFactory;
55179
+ this.featureOptions = featureOptions;
54922
55180
  }
54923
55181
  /**
54924
55182
  * Implementation of abstract method from FeatureProcessor
@@ -55000,7 +55258,8 @@ var McpProcessor = class extends FeatureProcessor {
55000
55258
  outputRoot: this.outputRoot,
55001
55259
  rulesyncMcp: filteredRulesyncMcp,
55002
55260
  global: this.global,
55003
- logger: this.logger
55261
+ logger: this.logger,
55262
+ featureOptions: this.featureOptions
55004
55263
  });
55005
55264
  }));
55006
55265
  const auxiliaryFiles = await factory.class.getAuxiliaryFiles?.({
@@ -57781,12 +58040,14 @@ var CodewhaleSkill = class CodewhaleSkill extends ToolSkill {
57781
58040
  const CodexCliSkillFrontmatterSchema = zod_mini.z.looseObject({
57782
58041
  name: zod_mini.z.string(),
57783
58042
  description: zod_mini.z.string(),
57784
- metadata: zod_mini.z.optional(zod_mini.z.looseObject({ "short-description": zod_mini.z.optional(zod_mini.z.string()) }))
58043
+ metadata: zod_mini.z.optional(zod_mini.z.looseObject({ "short-description": zod_mini.z.optional(zod_mini.z.string()) })),
58044
+ "disable-model-invocation": zod_mini.z.optional(zod_mini.z.boolean())
57785
58045
  });
57786
58046
  /**
57787
58047
  * Relative path (within a skill directory) of the Codex `agents/openai.yaml` sidecar.
57788
58048
  * Codex CLI reads UI metadata, invocation policy, and tool dependencies from this file;
57789
- * `SKILL.md` frontmatter only carries `name` and `description`.
58049
+ * from `SKILL.md` frontmatter it reads only `name`, `description` and
58050
+ * `metadata.short-description`, ignoring any other key.
57790
58051
  * @see https://developers.openai.com/codex/skills.md
57791
58052
  */
57792
58053
  const CODEX_OPENAI_YAML_RELATIVE_PATH = CODEXCLI_OPENAI_YAML_RELATIVE_PATH;
@@ -57876,6 +58137,40 @@ function openaiYamlToCodexcliSection(parsed) {
57876
58137
  return Object.keys(section).length > 0 ? section : void 0;
57877
58138
  }
57878
58139
  /**
58140
+ * Build the `SKILL.md` `metadata` map: the root-level rulesync `metadata` with
58141
+ * the legacy `codexcli.short-description` merged in. The tool-specific value wins
58142
+ * over a root `metadata.short-description`, as a target section overrides the
58143
+ * root default everywhere else. Values are stringified because the Agent Skills
58144
+ * spec types `metadata` as a string-to-string map, and `.agents/skills/` is read
58145
+ * by other spec clients too; Codex itself deserializes `short-description` as a
58146
+ * string.
58147
+ * @see https://agentskills.io/specification
58148
+ */
58149
+ function buildSkillMetadata({ rootMetadata, shortDescription }) {
58150
+ const merged = {
58151
+ ...rootMetadata,
58152
+ ...shortDescription !== void 0 && { "short-description": shortDescription }
58153
+ };
58154
+ return Object.keys(merged).length > 0 ? toStringMetadata(merged) : void 0;
58155
+ }
58156
+ /**
58157
+ * Drop `policy.allow_implicit_invocation: false` from an imported sidecar section
58158
+ * when the `SKILL.md` frontmatter already carries `disable-model-invocation: true`,
58159
+ * since the root flag regenerates exactly that value. Keeping both would store the
58160
+ * same setting twice in `.rulesync/skills/`.
58161
+ */
58162
+ function dropPolicyImpliedByRootFlag(section, disableModelInvocation) {
58163
+ const policy = section?.policy;
58164
+ if (!section || disableModelInvocation !== true || policy === null || typeof policy !== "object" || Array.isArray(policy) || policy.allow_implicit_invocation !== false) return section;
58165
+ const { allow_implicit_invocation: _implied, ...restPolicy } = policy;
58166
+ const { policy: _policy, ...restSection } = section;
58167
+ const result = {
58168
+ ...restSection,
58169
+ ...Object.keys(restPolicy).length > 0 && { policy: restPolicy }
58170
+ };
58171
+ return Object.keys(result).length > 0 ? result : void 0;
58172
+ }
58173
+ /**
57879
58174
  * Represents a Codex CLI skill directory.
57880
58175
  * Codex CLI supports skills in both project mode (under $CWD/.agents/skills)
57881
58176
  * and global mode (under ~/.agents/skills — Rulesync resolves the home
@@ -57926,16 +58221,20 @@ var CodexCliSkill = class CodexCliSkill extends ToolSkill {
57926
58221
  }
57927
58222
  toRulesyncSkill() {
57928
58223
  const frontmatter = this.getFrontmatter();
58224
+ const disableModelInvocation = frontmatter["disable-model-invocation"];
57929
58225
  const { parsed, rest } = extractOpenaiYamlFile(this.getOtherFiles());
57930
- const openaiSection = openaiYamlToCodexcliSection(parsed);
58226
+ const openaiSection = dropPolicyImpliedByRootFlag(openaiYamlToCodexcliSection(parsed), disableModelInvocation);
58227
+ const { "short-description": shortDescription, ...rootMetadata } = frontmatter.metadata ?? {};
57931
58228
  const codexcliSection = {
57932
- ...frontmatter.metadata?.["short-description"] && { "short-description": frontmatter.metadata["short-description"] },
58229
+ ...shortDescription !== void 0 && { "short-description": shortDescription },
57933
58230
  ...openaiSection
57934
58231
  };
57935
58232
  const rulesyncFrontmatter = {
57936
58233
  name: frontmatter.name,
57937
58234
  description: frontmatter.description,
57938
58235
  targets: ["*"],
58236
+ ...disableModelInvocation !== void 0 && { "disable-model-invocation": disableModelInvocation },
58237
+ ...Object.keys(rootMetadata).length > 0 && { metadata: rootMetadata },
57939
58238
  ...Object.keys(codexcliSection).length > 0 && { codexcli: codexcliSection }
57940
58239
  };
57941
58240
  return new RulesyncSkill({
@@ -57952,10 +58251,16 @@ var CodexCliSkill = class CodexCliSkill extends ToolSkill {
57952
58251
  static fromRulesyncSkill({ outputRoot = process.cwd(), rulesyncSkill, validate = true, global = false }) {
57953
58252
  const settablePaths = CodexCliSkill.getSettablePaths({ global });
57954
58253
  const rulesyncFrontmatter = rulesyncSkill.getFrontmatter();
58254
+ const metadata = buildSkillMetadata({
58255
+ rootMetadata: rulesyncFrontmatter.metadata,
58256
+ shortDescription: rulesyncFrontmatter.codexcli?.["short-description"]
58257
+ });
58258
+ const disableModelInvocation = rulesyncFrontmatter["disable-model-invocation"];
57955
58259
  const codexFrontmatter = {
57956
58260
  name: rulesyncFrontmatter.name,
57957
58261
  description: rulesyncFrontmatter.description,
57958
- ...rulesyncFrontmatter.codexcli?.["short-description"] && { metadata: { "short-description": rulesyncFrontmatter.codexcli["short-description"] } }
58262
+ ...disableModelInvocation !== void 0 && { "disable-model-invocation": disableModelInvocation },
58263
+ ...metadata !== void 0 && { metadata }
57959
58264
  };
57960
58265
  const target = toPosixPath(CODEX_OPENAI_YAML_RELATIVE_PATH);
57961
58266
  const baseOtherFiles = rulesyncSkill.getOtherFiles().filter((file) => toPosixPath(file.relativeFilePathToDirPath) !== target);
@@ -67443,6 +67748,16 @@ var CopilotSubagent = class CopilotSubagent extends ToolSubagent {
67443
67748
  * (`preferred` lets a `subagents` settings override or the `/subagents`
67444
67749
  * picker replace the model; `required` locks dispatch to the authored
67445
67750
  * list). CLI v1.0.81+; the shared reference page still lists `model` only.
67751
+ * - `model` may also be a list of models in preference order. The Copilot
67752
+ * CLI changelog for v1.0.83 (2026-09-04) says "Custom agents can list
67753
+ * several models in `model`, tried in order until one is available to
67754
+ * you"; the CLI command reference and the shared configuration reference
67755
+ * still document `model` as a string, so older CLI versions may not read
67756
+ * the list form (a string is the safe form there). The VS Code Copilot
67757
+ * harness documents the same array spelling. Preserve a list as authored
67758
+ * instead of rewriting it to the separate `models` field.
67759
+ * https://github.com/github/copilot-cli/blob/main/changelog.md#1083---2026-09-04
67760
+ * https://github.com/microsoft/vscode-docs/blob/main/docs/agents/reference/custom-agent-file-reference.md
67446
67761
  *
67447
67762
  * `looseObject` is used so that future additional fields are passed through
67448
67763
  * unchanged (consistent with the rest of rulesync's frontmatter schemas).
@@ -67452,7 +67767,7 @@ const CopilotCliSubagentFrontmatterSchema = zod_mini.z.looseObject({
67452
67767
  name: zod_mini.z.optional(zod_mini.z.string()),
67453
67768
  target: zod_mini.z.optional(zod_mini.z.string()),
67454
67769
  tools: zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())])),
67455
- model: zod_mini.z.optional(zod_mini.z.string()),
67770
+ model: zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())])),
67456
67771
  models: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())),
67457
67772
  modelPolicy: zod_mini.z.optional(zod_mini.z.enum(["preferred", "required"])),
67458
67773
  "disable-model-invocation": zod_mini.z.optional(zod_mini.z.boolean()),
@@ -107241,7 +107556,8 @@ function buildMcpStrategy(ctx) {
107241
107556
  toolTarget,
107242
107557
  global,
107243
107558
  dryRun,
107244
- logger
107559
+ logger,
107560
+ featureOptions: config.getFeatureOptions(toolTarget, "mcp")
107245
107561
  }),
107246
107562
  loadSource: (p) => p.loadToolFiles(),
107247
107563
  toRulesync: (p, files) => p.convertToolFilesToRulesyncFiles(files),
@@ -108971,7 +109287,8 @@ async function generateMcpCore(params) {
108971
109287
  toolTarget,
108972
109288
  global: config.getGlobal(),
108973
109289
  dryRun: config.isPreviewMode(),
108974
- logger
109290
+ logger,
109291
+ featureOptions: config.getFeatureOptions(toolTarget, "mcp")
108975
109292
  });
108976
109293
  const result = await processFeatureWithRulesyncFiles({
108977
109294
  config,