@mrrlin-dev/mcp 0.3.13 → 0.3.15

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 (3) hide show
  1. package/README.md +2 -1
  2. package/dist/bin.cjs +429 -38
  3. package/package.json +3 -3
package/README.md CHANGED
@@ -10,7 +10,8 @@ The bridge auto-picks up new `@mrrlin-dev/mcp` versions after `npm install -g`.
10
10
 
11
11
  - **L1 (self-update loop):** every 15 minutes (env-tunable) the bridge hashes its own `bin.cjs` and, on change, drains the current Codex turn cleanly and exits via `bridgeShutdown`. PM2 restarts. New code loads.
12
12
  - **L2 (postinstall nudge):** `npm install -g @mrrlin-dev/mcp@latest` triggers a `SIGUSR2` to every running PM2 bridge, collapsing the L1 wait to seconds.
13
- - **L3 (in-chat update offer):** every 6 hours the bridge checks `https://registry.npmjs.org/@mrrlin-dev/mcp/latest`. When a newer version is published, on your next chat turn the Director asks: *"Бачу що bridge на X, на npm уже Yоновити зараз?"*. Say "yes" Director runs `npm install -g @mrrlin-dev/mcp@latest` itself. 48h cooldown if you decline.
13
+ - **L3 (in-chat update offer):** every 6 hours the bridge checks `https://registry.npmjs.org/@mrrlin-dev/mcp/latest`. When a newer version is published, on your next chat turn the Director explains *why* to update (latest fixes/improvements, changelog link) and offers a choice let it run the update, or run `npm install -g @mrrlin-dev/mcp@latest` yourself. Say "yes" → Director runs the install itself. 48h cooldown if you decline.
14
+ - **L3.3 (inbox mirror):** the same fire also files a persistent `question` in the active project's inbox ("A newer version of the mrrlin bridge is available…", with the changelog link), so the update isn't only an ephemeral per-turn aside. The body is version-agnostic on purpose: the inbox dedup collapses it to one open row per project (reused across versions), and it re-appears at most once per 48h cooldown after you decide it. It's an opportunistic per-project signal, not a guaranteed cross-project notice.
14
15
  - **L4 (process hygiene):** the bridge refuses to start a second instance on the same port and warns about orphan `mrrlin-mcp director-bridge` processes at startup.
15
16
 
16
17
  ### Env tuning
package/dist/bin.cjs CHANGED
@@ -20799,9 +20799,8 @@ function renderBlock(block) {
20799
20799
  lines2.push("");
20800
20800
  return lines2.join("\n");
20801
20801
  }
20802
- function tablesEqual(actual, expected) {
20803
- if (typeof actual !== "object" || actual === null) return false;
20804
- const candidate = actual;
20802
+ var KNOWN_MRRLIN_ENV_KEYS = /* @__PURE__ */ new Set(["MRRLIN_STAGING", "MRRLIN_API_BASE_URL", "MRRLIN_API_ALLOW_REMOTE"]);
20803
+ function commandArgsTimeoutEqual(candidate, expected) {
20805
20804
  if (candidate.command !== expected.command) return false;
20806
20805
  if (!Array.isArray(candidate.args)) return false;
20807
20806
  if (candidate.args.length !== expected.args.length) return false;
@@ -20811,6 +20810,12 @@ function tablesEqual(actual, expected) {
20811
20810
  if (candidate.startup_timeout_sec !== void 0 && candidate.startup_timeout_sec !== expected.startup_timeout_sec) {
20812
20811
  return false;
20813
20812
  }
20813
+ return true;
20814
+ }
20815
+ function tablesEqual(actual, expected) {
20816
+ if (typeof actual !== "object" || actual === null) return false;
20817
+ const candidate = actual;
20818
+ if (!commandArgsTimeoutEqual(candidate, expected)) return false;
20814
20819
  const expectedEnv = expected.env ?? {};
20815
20820
  const actualEnv = candidate.env && typeof candidate.env === "object" ? candidate.env : {};
20816
20821
  const expectedKeys = Object.keys(expectedEnv);
@@ -20820,6 +20825,34 @@ function tablesEqual(actual, expected) {
20820
20825
  }
20821
20826
  return true;
20822
20827
  }
20828
+ function isCoherentMrrlinEnv(env) {
20829
+ const keys = Object.keys(env);
20830
+ const staging = env.MRRLIN_STAGING;
20831
+ const url2 = env.MRRLIN_API_BASE_URL;
20832
+ const allowRemote = env.MRRLIN_API_ALLOW_REMOTE;
20833
+ const hasUrl = typeof url2 === "string" && url2.trim() !== "";
20834
+ if (keys.length === 1 && staging === "1") return true;
20835
+ if (keys.length === 1 && hasUrl) return true;
20836
+ if (keys.length === 2 && hasUrl && allowRemote === "1") return true;
20837
+ return false;
20838
+ }
20839
+ function classifyExistingBlock(actual, expected) {
20840
+ if (tablesEqual(actual, expected)) return "equal";
20841
+ if (typeof actual !== "object" || actual === null) return "conflict";
20842
+ const candidate = actual;
20843
+ if (expected.env && Object.keys(expected.env).length > 0) return "conflict";
20844
+ if (!commandArgsTimeoutEqual(candidate, expected)) return "conflict";
20845
+ const env = candidate.env && typeof candidate.env === "object" ? candidate.env : {};
20846
+ const keys = Object.keys(env);
20847
+ if (keys.length === 0) return "conflict";
20848
+ if (!keys.every((key) => KNOWN_MRRLIN_ENV_KEYS.has(key))) return "conflict";
20849
+ if (!isCoherentMrrlinEnv(env)) return "conflict";
20850
+ return "preserve-env-noop";
20851
+ }
20852
+ function renderEnvPreservedNotice(blockId, envKeys) {
20853
+ const keys = [...envKeys].sort().join(", ");
20854
+ return `[mrrlin-mcp install-codex] kept your existing [mcp_servers.${blockId}] env keys (${keys}) so your API target is preserved. Re-run with --force to replace or remove it.`;
20855
+ }
20823
20856
  async function pathExists(target) {
20824
20857
  try {
20825
20858
  await import_promises.default.access(target);
@@ -20845,7 +20878,7 @@ async function installCodex(options) {
20845
20878
  if (!await pathExists(configPath)) {
20846
20879
  const content2 = blocks.map(renderBlock).join("").replace(/^\n/, "");
20847
20880
  await import_promises.default.writeFile(configPath, content2, { mode: 384 });
20848
- return { action: "created", configPath, prompts: await writePrompts() };
20881
+ return { action: "created", configPath, prompts: await writePrompts(), preservedEnvBlocks: [] };
20849
20882
  }
20850
20883
  const realPath = await resolveRealConfigPath(configPath);
20851
20884
  let content = await import_promises.default.readFile(realPath, "utf8");
@@ -20862,10 +20895,19 @@ async function installCodex(options) {
20862
20895
  throw Object.assign(new Error(err.detail), err);
20863
20896
  }
20864
20897
  let action = "noop";
20898
+ let mutated = false;
20899
+ const preservedEnvBlocks = [];
20865
20900
  for (const block of blocks) {
20866
20901
  const existingTable = parsed?.mcp_servers?.[block.id];
20867
20902
  if (existingTable !== void 0) {
20868
- if (tablesEqual(existingTable, block.expected)) continue;
20903
+ const classification = classifyExistingBlock(existingTable, block.expected);
20904
+ if (classification === "equal") continue;
20905
+ if (classification === "preserve-env-noop") {
20906
+ const env = existingTable.env ?? {};
20907
+ preservedEnvBlocks.push({ blockId: block.id, envKeys: Object.keys(env).sort() });
20908
+ if (action === "noop") action = "env-preserved";
20909
+ continue;
20910
+ }
20869
20911
  if (!options.force) {
20870
20912
  const err = {
20871
20913
  code: "CODEX_CONFIG_CONFLICT",
@@ -20876,15 +20918,17 @@ async function installCodex(options) {
20876
20918
  }
20877
20919
  content = replaceBlock(content, block.blockRe, renderBlock(block));
20878
20920
  action = "replaced";
20921
+ mutated = true;
20879
20922
  } else {
20880
20923
  content = appendBlock(content, renderBlock(block));
20881
20924
  if (action !== "replaced") action = "appended";
20925
+ mutated = true;
20882
20926
  }
20883
20927
  }
20884
- if (action !== "noop") {
20928
+ if (mutated) {
20885
20929
  await import_promises.default.writeFile(realPath, content, { mode: 384 });
20886
20930
  }
20887
- return { action, configPath: realPath, prompts: await writePrompts() };
20931
+ return { action, configPath: realPath, prompts: await writePrompts(), preservedEnvBlocks };
20888
20932
  }
20889
20933
  async function installPrompts(codexHome, prompts, forcePrompts) {
20890
20934
  if (prompts.length === 0) return [];
@@ -38336,6 +38380,7 @@ var DEFAULT_STARTUP_JITTER_MS = 30 * 1e3;
38336
38380
  var DEFAULT_COOLDOWN_MS = 48 * 60 * 60 * 1e3;
38337
38381
  var REGISTRY_URL_DEFAULT = "https://registry.npmjs.org/@mrrlin-dev/mcp/latest";
38338
38382
  var STRICT_SEMVER = /^\d+\.\d+\.\d+(-[A-Za-z0-9.-]+)?(\+[A-Za-z0-9.-]+)?$/;
38383
+ var UPDATE_INBOX_BODY = "A newer version of the mrrlin bridge is available. Updating pulls the latest fixes and improvements. Changelog: https://mrrlin-changelog.s3.eu-central-1.amazonaws.com/index.html . Ask the Director to update, or run `npm install -g @mrrlin-dev/mcp@latest` yourself.";
38339
38384
  function isDisabled() {
38340
38385
  return process.env.MRRLIN_DIRECTOR_BRIDGE_SELF_UPDATE === "0";
38341
38386
  }
@@ -38382,9 +38427,15 @@ function startSelfUpdate(deps) {
38382
38427
  });
38383
38428
  return `[bridge:update] An update for @mrrlin-dev/mcp is available: ${pkgVersion} -> ${latestVersion}.
38384
38429
 
38385
- This turn, at a natural pause, ASK the operator a yes/no question IN THE OPERATOR'S LANGUAGE (mirror the language they are chatting in) - e.g. in English: "The bridge is on ${pkgVersion} but npm already has ${latestVersion} - update now? (I'll run \`npm install -g @mrrlin-dev/mcp@latest\`)". Do NOT install without explicit confirmation.
38430
+ This turn, at a natural pause, RAISE this with the operator IN THEIR LANGUAGE (mirror the language they are chatting in). Frame it as a short offer with a choice, and ALWAYS explain WHY it is worth doing:
38386
38431
 
38387
- If the operator confirms: immediately run \`npm install -g @mrrlin-dev/mcp@latest\` via your shell tool and report the outcome in chat. After a successful install, the bridge auto-restarts via its self-update loop within a couple of minutes - you do NOT need to call pm2.
38432
+ 1. WHY (never skip this): give a one-line reason the update matters - do NOT present a bare version bump. Prefer specifics: if you can read the release notes, summarize in one short sentence what is new/fixed. The changelog is at https://mrrlin-changelog.s3.eu-central-1.amazonaws.com/index.html. If you cannot get specifics, fall back to a generic but honest reason, e.g. "it pulls the latest bug fixes and improvements for the bridge".
38433
+
38434
+ 2. THE CHOICE: offer both paths and let the operator pick - "want me to update it for you now?" OR "you can run it yourself: \`npm install -g @mrrlin-dev/mcp@latest\`". Phrase it as a yes/no, e.g. in English: "The bridge is on ${pkgVersion} but npm already has ${latestVersion}. It brings <why> - want me to run \`npm install -g @mrrlin-dev/mcp@latest\`, or would you rather run it yourself?".
38435
+
38436
+ ASK the operator first - do NOT install without explicit confirmation.
38437
+
38438
+ If the operator confirms you should do it: immediately run \`npm install -g @mrrlin-dev/mcp@latest\` via your shell tool and report the outcome in chat. After a successful install, the bridge auto-restarts via its self-update loop within a couple of minutes - you do NOT need to call pm2.
38388
38439
 
38389
38440
  If the operator declines / says "later": acknowledge briefly and do not bring it up again this turn. The bridge has a cooldown and will re-offer in 48h.
38390
38441
 
@@ -38392,6 +38443,12 @@ If the install fails (permission denied, registry pinned, sudo required, network
38392
38443
 
38393
38444
  Do NOT derail the current task - if the operator has an in-flight question, answer that first; the update offer is a brief aside, not the main subject.`;
38394
38445
  }
38446
+ function updateInboxBody() {
38447
+ if (disabled) return null;
38448
+ if (latestVersion === null) return null;
38449
+ if (!import_semver.default.gt(latestVersion, pkgVersion)) return null;
38450
+ return UPDATE_INBOX_BODY;
38451
+ }
38395
38452
  let startupBinSha = null;
38396
38453
  try {
38397
38454
  startupBinSha = sha256(readFile2(deps.binPath));
@@ -38545,12 +38602,13 @@ Do NOT derail the current task - if the operator has an in-flight question, answ
38545
38602
  getDrainOnQuiet: () => drainOnQuiet,
38546
38603
  getLatestVersion: () => latestVersion,
38547
38604
  isStale: () => latestVersion !== null && import_semver.default.gt(latestVersion, pkgVersion),
38548
- maybeUpdateOffer
38605
+ maybeUpdateOffer,
38606
+ updateInboxBody
38549
38607
  };
38550
38608
  }
38551
38609
 
38552
38610
  // src/_generated/version.ts
38553
- var PKG_VERSION = "0.3.13";
38611
+ var PKG_VERSION = "0.3.15";
38554
38612
 
38555
38613
  // src/api-base-url.ts
38556
38614
  var DEFAULT_API_BASE_URL = "http://127.0.0.1:8787";
@@ -38764,10 +38822,42 @@ function withTimeout(promise2, ms, message) {
38764
38822
  });
38765
38823
  }
38766
38824
  async function awaitTurnWithDeadline(opts) {
38825
+ const hasIdle = opts.idleTimeoutMs != null && opts.idleTimeoutMessage != null;
38826
+ const overallDeadlineAt = Date.now() + opts.timeoutMs;
38827
+ let overallTimer = null;
38828
+ let idleTimer = null;
38829
+ let settled = false;
38830
+ const clearTimers = () => {
38831
+ settled = true;
38832
+ if (overallTimer) clearTimeout(overallTimer);
38833
+ if (idleTimer) clearTimeout(idleTimer);
38834
+ overallTimer = null;
38835
+ idleTimer = null;
38836
+ };
38837
+ const activity = { touch: () => {
38838
+ } };
38839
+ const idleTimeoutMs = opts.idleTimeoutMs;
38840
+ const idleTimeoutMessage = opts.idleTimeoutMessage;
38841
+ const deadline = new Promise((_, reject) => {
38842
+ overallTimer = setTimeout(() => reject(new Error(opts.timeoutMessage)), opts.timeoutMs);
38843
+ if (idleTimeoutMs != null && idleTimeoutMessage != null) {
38844
+ const armIdle = () => {
38845
+ if (settled) return;
38846
+ if (idleTimer) clearTimeout(idleTimer);
38847
+ idleTimer = setTimeout(() => {
38848
+ reject(new Error(Date.now() >= overallDeadlineAt ? opts.timeoutMessage : idleTimeoutMessage));
38849
+ }, idleTimeoutMs);
38850
+ };
38851
+ activity.touch = armIdle;
38852
+ armIdle();
38853
+ }
38854
+ });
38767
38855
  try {
38768
- await withTimeout(opts.run(), opts.timeoutMs, opts.timeoutMessage);
38856
+ await Promise.race([opts.run(activity), deadline]);
38769
38857
  } catch (error51) {
38770
- const isDeadline = error51 instanceof Error && error51.message === opts.timeoutMessage;
38858
+ clearTimers();
38859
+ const message = error51 instanceof Error ? error51.message : String(error51);
38860
+ const isDeadline = message === opts.timeoutMessage || hasIdle && message === opts.idleTimeoutMessage;
38771
38861
  if (isDeadline) {
38772
38862
  try {
38773
38863
  await withTimeout(
@@ -38781,6 +38871,7 @@ async function awaitTurnWithDeadline(opts) {
38781
38871
  }
38782
38872
  throw error51;
38783
38873
  }
38874
+ clearTimers();
38784
38875
  }
38785
38876
 
38786
38877
  // src/worktree.ts
@@ -38973,16 +39064,41 @@ ${lines2.join("\n")}`;
38973
39064
  var DEFAULT_STALE_SECONDS = 300;
38974
39065
  var DEFAULT_TOUCH_INTERVAL_MS = 6e4;
38975
39066
  var DEFAULT_CLAIM_INTERVAL_SECONDS = 30;
38976
- var DEFAULT_EXEC_TURN_TIMEOUT_SECONDS = 900;
39067
+ var DEFAULT_EXEC_TURN_IDLE_TIMEOUT_SECONDS = 600;
39068
+ var DEFAULT_EXEC_RUN_MAX_SECONDS = 3600;
38977
39069
  var DEFAULT_CONTEXT_MAX_ARTIFACTS = 50;
39070
+ var EXECUTION_TURN_TIMEOUT_PREFIX = "Execution turn did not complete within";
39071
+ var EXECUTION_TURN_STALL_PREFIX = "Execution turn stalled: no Codex activity within";
39072
+ var EXECUTION_TURN_BUDGET_PREFIX = "Execution turn exceeded the run wall-clock budget";
39073
+ var EXECUTION_TURN_TIMEOUT_RETRY_CODE = "execution_turn_timeout_retry";
39074
+ var EXECUTION_TURN_TIMEOUT_RETRY_SUMMARY = "Execution turn timed out; requeued for one automatic retry.";
39075
+ var EXECUTION_TURN_TIMEOUT_RETRY_CURSOR = "execution_turn_timeout_retry:1";
38978
39076
  function readPositiveIntEnv(name, fallback) {
38979
39077
  const raw = (process.env[name] ?? "").trim();
38980
39078
  if (!raw) return fallback;
38981
39079
  const parsed = Number.parseInt(raw, 10);
38982
39080
  return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
38983
39081
  }
38984
- function execTurnTimeoutMs() {
38985
- return readPositiveIntEnv("MRRLIN_DIRECTOR_BRIDGE_EXEC_TURN_TIMEOUT_SECONDS", DEFAULT_EXEC_TURN_TIMEOUT_SECONDS) * 1e3;
39082
+ function execTurnIdleTimeoutMs() {
39083
+ return readPositiveIntEnv("MRRLIN_DIRECTOR_BRIDGE_EXEC_TURN_IDLE_TIMEOUT_SECONDS", DEFAULT_EXEC_TURN_IDLE_TIMEOUT_SECONDS) * 1e3;
39084
+ }
39085
+ function execRunMaxMs() {
39086
+ return readPositiveIntEnv("MRRLIN_DIRECTOR_BRIDGE_EXEC_RUN_MAX_SECONDS", DEFAULT_EXEC_RUN_MAX_SECONDS) * 1e3;
39087
+ }
39088
+ function explicitLegacyTurnTimeoutMs() {
39089
+ const raw = (process.env.MRRLIN_DIRECTOR_BRIDGE_EXEC_TURN_TIMEOUT_SECONDS ?? "").trim();
39090
+ if (!raw) return null;
39091
+ const parsed = Number.parseInt(raw, 10);
39092
+ return Number.isInteger(parsed) && parsed > 0 ? parsed * 1e3 : null;
39093
+ }
39094
+ function executionTurnStallMessage(idleMs) {
39095
+ return `${EXECUTION_TURN_STALL_PREFIX} ${idleMs}ms.`;
39096
+ }
39097
+ function executionTurnBudgetMessage(budgetMs) {
39098
+ return `${EXECUTION_TURN_BUDGET_PREFIX} (${budgetMs}ms).`;
39099
+ }
39100
+ function isExecutionTurnTimeoutReason(reason) {
39101
+ return reason.startsWith(EXECUTION_TURN_TIMEOUT_PREFIX) || reason.startsWith(EXECUTION_TURN_STALL_PREFIX);
38986
39102
  }
38987
39103
  function contextMaxArtifacts() {
38988
39104
  return readPositiveIntEnv("MRRLIN_DIRECTOR_BRIDGE_CONTEXT_MAX_ARTIFACTS", DEFAULT_CONTEXT_MAX_ARTIFACTS);
@@ -39135,11 +39251,15 @@ async function runHasLease(deps, projectSlug, runId, leaseId) {
39135
39251
  return false;
39136
39252
  }
39137
39253
  }
39138
- async function driveTurn(deps, codex, projectSlug, runId, leaseId, threadId, inputText) {
39254
+ async function driveTurn(deps, codex, projectSlug, runId, leaseId, threadId, inputText, overallMs, phase) {
39139
39255
  const mapper = new CodexBridgeEventMapper();
39140
39256
  mapper.reset();
39141
39257
  let turnError = null;
39142
39258
  let interruptFailed = false;
39259
+ let touch = () => {
39260
+ };
39261
+ const turnStartedAt = Date.now();
39262
+ let lastActivityAt = turnStartedAt;
39143
39263
  const persist = async (event) => {
39144
39264
  if (event.type === "error") turnError = event.content;
39145
39265
  const kind = artifactKindForEvent(event);
@@ -39161,6 +39281,11 @@ async function driveTurn(deps, codex, projectSlug, runId, leaseId, threadId, inp
39161
39281
  });
39162
39282
  let acceptedTurnId = null;
39163
39283
  const unsub = codex.onNotification((method, params) => {
39284
+ const notifThreadId = params?.threadId;
39285
+ if (!notifThreadId || notifThreadId === threadId) {
39286
+ lastActivityAt = Date.now();
39287
+ touch();
39288
+ }
39164
39289
  const events = mapper.mapNotification(
39165
39290
  { method, params },
39166
39291
  threadId
@@ -39168,9 +39293,8 @@ async function driveTurn(deps, codex, projectSlug, runId, leaseId, threadId, inp
39168
39293
  for (const event of events) pending.push(persist(event));
39169
39294
  if (method === "turn/completed") {
39170
39295
  const p = params;
39171
- const notifThreadId = p?.threadId;
39172
39296
  const notifTurnId = p?.turn?.id;
39173
- const threadMatches = !notifThreadId || notifThreadId === threadId;
39297
+ const threadMatches = !p?.threadId || p.threadId === threadId;
39174
39298
  const turnMatches = !acceptedTurnId || !notifTurnId || notifTurnId === acceptedTurnId;
39175
39299
  if (threadMatches && turnMatches) resolveTurnEnded?.();
39176
39300
  }
@@ -39180,10 +39304,11 @@ async function driveTurn(deps, codex, projectSlug, runId, leaseId, threadId, inp
39180
39304
  });
39181
39305
  }, deps.touchIntervalMs);
39182
39306
  touchTimer.unref?.();
39307
+ const idleMs = execTurnIdleTimeoutMs();
39183
39308
  try {
39184
- const timeoutMs = execTurnTimeoutMs();
39185
39309
  await awaitTurnWithDeadline({
39186
- run: async () => {
39310
+ run: async (activity) => {
39311
+ touch = activity.touch;
39187
39312
  const accepted = await codex.turn.start({
39188
39313
  threadId,
39189
39314
  input: [{ type: "text", text: inputText }]
@@ -39192,8 +39317,10 @@ async function driveTurn(deps, codex, projectSlug, runId, leaseId, threadId, inp
39192
39317
  await turnEnded;
39193
39318
  },
39194
39319
  interrupt: () => codex.turn.interrupt({ threadId }),
39195
- timeoutMs,
39196
- timeoutMessage: `Execution turn did not complete within ${timeoutMs}ms.`,
39320
+ timeoutMs: overallMs,
39321
+ timeoutMessage: executionTurnBudgetMessage(overallMs),
39322
+ idleTimeoutMs: idleMs,
39323
+ idleTimeoutMessage: executionTurnStallMessage(idleMs),
39197
39324
  onInterruptFailed: () => {
39198
39325
  interruptFailed = true;
39199
39326
  }
@@ -39202,6 +39329,19 @@ async function driveTurn(deps, codex, projectSlug, runId, leaseId, threadId, inp
39202
39329
  await Promise.all(pending);
39203
39330
  } catch (error51) {
39204
39331
  turnError = error51 instanceof Error ? error51.message : String(error51);
39332
+ if (turnError.startsWith(EXECUTION_TURN_STALL_PREFIX) || turnError.startsWith(EXECUTION_TURN_BUDGET_PREFIX)) {
39333
+ console.warn(
39334
+ JSON.stringify({
39335
+ event: "execution_turn_deadline",
39336
+ projectSlug,
39337
+ runId,
39338
+ phase,
39339
+ kind: turnError.startsWith(EXECUTION_TURN_BUDGET_PREFIX) ? "budget" : "stall",
39340
+ elapsedMs: Date.now() - turnStartedAt,
39341
+ sinceLastActivityMs: Date.now() - lastActivityAt
39342
+ })
39343
+ );
39344
+ }
39205
39345
  } finally {
39206
39346
  clearInterval(touchTimer);
39207
39347
  unsub();
@@ -39223,6 +39363,12 @@ async function driveRun(deps, projectSlug, runId) {
39223
39363
  return;
39224
39364
  }
39225
39365
  const leaseId = claimed.leaseId;
39366
+ const attemptDeadlineMs = Date.now() + execRunMaxMs();
39367
+ const remainingTurnBudgetMs = () => {
39368
+ const remaining = attemptDeadlineMs - Date.now();
39369
+ const legacy = explicitLegacyTurnTimeoutMs();
39370
+ return legacy != null ? Math.min(legacy, remaining) : remaining;
39371
+ };
39226
39372
  const task = await deps.client.getTask(projectSlug, claimed.taskId);
39227
39373
  const compactEnabled = (process.env.MRRLIN_MCP_COMPACT_DISABLE ?? "").trim() !== "1";
39228
39374
  const context = await gatherContext(deps.client, projectSlug, task, runId);
@@ -39260,6 +39406,7 @@ async function driveRun(deps, projectSlug, runId) {
39260
39406
  runId,
39261
39407
  defaultBranch: await getDefaultBranch(projectSlug)
39262
39408
  });
39409
+ if (!await runHasLease(deps, projectSlug, runId, leaseId)) return;
39263
39410
  await deps.client.updateExecutionRun(projectSlug, runId, {
39264
39411
  worktreeId: worktreePath,
39265
39412
  branch: worktreeBranch(runId)
@@ -39310,11 +39457,17 @@ async function driveRun(deps, projectSlug, runId) {
39310
39457
  ephemeral: null
39311
39458
  },
39312
39459
  onThreadIdChanged: async (newId) => {
39460
+ if (!await runHasLease(deps, projectSlug, runId, leaseId)) return;
39313
39461
  await deps.client.updateTask(projectSlug, task.id, { directorThreadId: newId });
39314
39462
  }
39315
39463
  });
39316
39464
  if (!resumed) {
39317
- const primed = await driveTurn(deps, codex, projectSlug, runId, leaseId, threadId, bundle);
39465
+ const primingBudgetMs = remainingTurnBudgetMs();
39466
+ if (primingBudgetMs <= 0) {
39467
+ await failRun(deps, projectSlug, task, runId, leaseId, executionTurnBudgetMessage(execRunMaxMs()));
39468
+ return;
39469
+ }
39470
+ const primed = await driveTurn(deps, codex, projectSlug, runId, leaseId, threadId, bundle, primingBudgetMs, "priming");
39318
39471
  if (!primed.ok) {
39319
39472
  await resetWedgedThread(deps, projectSlug, task.id, primed.interruptFailed);
39320
39473
  await failRun(deps, projectSlug, task, runId, leaseId, primed.error);
@@ -39324,7 +39477,12 @@ async function driveRun(deps, projectSlug, runId) {
39324
39477
  const executionPrompt = resumed ? `${bundle}
39325
39478
 
39326
39479
  Continue executing task ${task.id}. Make the next concrete increment of progress and record artifacts/judgements via MCP tools.` : `Continue executing task ${task.id}. Make the next concrete increment of progress and record artifacts/judgements via MCP tools.`;
39327
- const outcome = await driveTurn(deps, codex, projectSlug, runId, leaseId, threadId, executionPrompt);
39480
+ const workBudgetMs = remainingTurnBudgetMs();
39481
+ if (workBudgetMs <= 0) {
39482
+ await failRun(deps, projectSlug, task, runId, leaseId, executionTurnBudgetMessage(execRunMaxMs()));
39483
+ return;
39484
+ }
39485
+ const outcome = await driveTurn(deps, codex, projectSlug, runId, leaseId, threadId, executionPrompt, workBudgetMs, "work");
39328
39486
  if (!outcome.ok) {
39329
39487
  await resetWedgedThread(deps, projectSlug, task.id, outcome.interruptFailed);
39330
39488
  await failRun(deps, projectSlug, task, runId, leaseId, outcome.error);
@@ -39452,7 +39610,36 @@ async function resetWedgedThread(deps, projectSlug, taskId, interruptFailed) {
39452
39610
  });
39453
39611
  }
39454
39612
  async function failRun(deps, projectSlug, task, runId, leaseId, reason) {
39455
- if (!await runHasLease(deps, projectSlug, runId, leaseId)) return;
39613
+ let currentRun;
39614
+ try {
39615
+ currentRun = await deps.client.getExecutionRun(projectSlug, runId);
39616
+ } catch {
39617
+ return;
39618
+ }
39619
+ if (currentRun.status !== "running" || currentRun.leaseId !== leaseId) return;
39620
+ if (isExecutionTurnTimeoutReason(reason) && currentRun.checkpointCursor !== EXECUTION_TURN_TIMEOUT_RETRY_CURSOR) {
39621
+ try {
39622
+ await deps.client.updateExecutionRun(projectSlug, runId, {
39623
+ status: "pending",
39624
+ leaseId: null,
39625
+ checkpointCursor: EXECUTION_TURN_TIMEOUT_RETRY_CURSOR
39626
+ });
39627
+ await deps.client.appendExecutionArtifact(projectSlug, runId, {
39628
+ id: newArtifactId(),
39629
+ kind: "error",
39630
+ payload: {
39631
+ code: EXECUTION_TURN_TIMEOUT_RETRY_CODE,
39632
+ source: "execution-consumer",
39633
+ attempt: 1,
39634
+ reason,
39635
+ summary: EXECUTION_TURN_TIMEOUT_RETRY_SUMMARY
39636
+ }
39637
+ }).catch(() => {
39638
+ });
39639
+ return;
39640
+ } catch {
39641
+ }
39642
+ }
39456
39643
  try {
39457
39644
  await deps.client.createExecutionRunHandoff(projectSlug, runId, { reason });
39458
39645
  } catch {
@@ -44092,6 +44279,9 @@ function buildThreadStartParams(msg, config2, desiredModel) {
44092
44279
  "request behind the task (pure system bookkeeping) \u2014 note that omitting it makes the gate fail-closed.",
44093
44280
  "",
44094
44281
  "Spec authoring contract \u2014 when creating or updating a `Specs/{id}` wiki page:",
44282
+ "- BEGIN the page with YAML frontmatter naming the gated task: a first line `---`, then",
44283
+ " `task: {taskId}` (the task this spec gates, e.g. `task: GT-1023`), then `---`. Never change it",
44284
+ " to another task id \u2014 it is the machine-readable identity the consensus gate validates against.",
44095
44285
  "- In `## Specification`, write each requirement as `### Requirement: <name>` followed by one",
44096
44286
  " SHALL sentence stating the externally visible behavior.",
44097
44287
  "- Give every requirement at least one `#### Scenario: <name>` with `- WHEN ...` / `- THEN ...`",
@@ -44136,6 +44326,24 @@ function buildThreadStartParams(msg, config2, desiredModel) {
44136
44326
  "- Do not create a handoff for transient provider quota/runtime failures, retriable tool",
44137
44327
  " errors, or vague uncertainty; retry cheaply or explain the transient issue in chat.",
44138
44328
  "",
44329
+ "Deferred-verification contract \u2014 when the RESULT can only be judged later:",
44330
+ "- When a task's implementation is DONE but its outcome cannot be confirmed now because it",
44331
+ " depends on a delay you do not control \u2014 SEO ranking/traffic effects after (re)indexing,",
44332
+ " ad-campaign performance data accumulating, cache/DNS propagation, a scheduled external",
44333
+ " event \u2014 do NOT close the task and do NOT leave it `in_progress`. Move it to",
44334
+ " `awaiting_verification` via `update_task` and set `verificationDueAt` to the ISO-8601",
44335
+ " datetime when the effect should be measurable (e.g. ~2 weeks out for an SEO article).",
44336
+ " Use `followUpAt` instead when it is a softer check-in rather than a hard verification.",
44337
+ "- In the same `update_task`, or in the task notes, record WHAT to measure and HOW so a later",
44338
+ " turn can verify objectively (the metric, the baseline, the success threshold).",
44339
+ "- Do NOT use `awaiting_verification` for results you CAN confirm now \u2014 verify immediately",
44340
+ " (browser/HTTP/tests) and close or advance the task instead. It is only for genuinely",
44341
+ " time-delayed evidence, not for avoiding verification work.",
44342
+ "- When the task is later woken (its `verificationDueAt`/`followUpAt` has passed), gather the",
44343
+ " evidence, then close it \u2014 `closed_done` if the outcome met the bar, or `closed_no_result`",
44344
+ " if it did not (that state REQUIRES a linked Evidence wiki page); or, if the effect still",
44345
+ " is not measurable, re-set `verificationDueAt` to a new date with a reason.",
44346
+ "",
44139
44347
  "File artifacts: when you produce a file for the operator (report, HTML, screenshot),",
44140
44348
  "upload it with `upload_artifact` (class=temp for one-off downloads, class=durable when a",
44141
44349
  "spec/evidence page references it; pass taskId when known) and paste the returned markdown",
@@ -45016,6 +45224,10 @@ function createBridgeMessageHandler(deps) {
45016
45224
  const effectiveText = inputText.length > 0 ? inputText : imageDataUri ? DEFAULT_IMAGE_ONLY_PROMPT : "";
45017
45225
  if (effectiveText.length > 0) {
45018
45226
  const updateOffer = deps.maybeUpdateOffer ? deps.maybeUpdateOffer() : null;
45227
+ const updateOfferProjectSlug = msg.context?.projectSlug?.trim();
45228
+ if (updateOffer !== null && updateOfferProjectSlug && deps.fileUpdateInboxItem) {
45229
+ void deps.fileUpdateInboxItem(updateOfferProjectSlug);
45230
+ }
45019
45231
  const composedPrefix = updateOffer ? contextPrefix ? `${updateOffer}
45020
45232
 
45021
45233
  ${contextPrefix}` : updateOffer : contextPrefix;
@@ -45433,7 +45645,32 @@ async function startDirectorBridge() {
45433
45645
  sessionSinks.unbind(directorSessionId, send);
45434
45646
  },
45435
45647
  getSessionSink: (directorSessionId) => sessionSinks.get(directorSessionId),
45436
- maybeUpdateOffer: () => selfUpdateHandle.maybeUpdateOffer()
45648
+ maybeUpdateOffer: () => selfUpdateHandle.maybeUpdateOffer(),
45649
+ fileUpdateInboxItem: async (projectSlug) => {
45650
+ const body = selfUpdateHandle.updateInboxBody();
45651
+ if (!body) return;
45652
+ const id = `II-MCPUPD-${(0, import_node_crypto5.randomUUID)()}`;
45653
+ try {
45654
+ const row = await client.createInboxItem(projectSlug, { id, kind: "question", body });
45655
+ process.stderr.write(
45656
+ `[mrrlin-mcp director-bridge] info bridge.self_update.inbox_filed ${JSON.stringify({
45657
+ projectSlug,
45658
+ itemId: row.id,
45659
+ deduped: row.id !== id
45660
+ })}
45661
+ `
45662
+ );
45663
+ } catch (err) {
45664
+ process.stderr.write(
45665
+ `[mrrlin-mcp director-bridge] warn bridge.self_update.inbox_file_failed ${JSON.stringify({
45666
+ projectSlug,
45667
+ errorClass: "inbox_write_failed",
45668
+ message: String(err?.message ?? err)
45669
+ })}
45670
+ `
45671
+ );
45672
+ }
45673
+ }
45437
45674
  };
45438
45675
  const bridgeLogger = createBridgeLogger(config2.stateDir, process.env);
45439
45676
  let bridgeBinShaShort = "unknown00000";
@@ -49524,6 +49761,71 @@ ${dismissedFwd.slice(-20).join("\n")}` : "";
49524
49761
  return { outcome: "CONSENSUS_BLOCKED", rounds: input.rounds, finalArtifact: artifact, history };
49525
49762
  }
49526
49763
 
49764
+ // src/consensus/spec-identity.ts
49765
+ function validateSpecIdentity(markdown, taskId) {
49766
+ const target = taskId.toUpperCase();
49767
+ const fieldToken = frontmatterTaskToken(markdown);
49768
+ if (fieldToken !== null && fieldToken.toUpperCase() !== target) return false;
49769
+ const headingToken = firstHeadingToken(markdown);
49770
+ if (headingToken !== null && headingToken.toUpperCase() !== target) return false;
49771
+ return true;
49772
+ }
49773
+ var TASK_ID_TOKEN = /\b[A-Za-z]{1,12}-\d{1,10}\b/;
49774
+ var ATX_HEADING = /^ {0,3}#{1,6}\s+(.*)$/;
49775
+ var FRONTMATTER_TASK = /^task(?:_id)?:\s*(.+)$/i;
49776
+ function frontmatterTaskToken(markdown) {
49777
+ const lines2 = markdown.split("\n");
49778
+ const firstContent = lines2.findIndex((l) => l.trim() !== "");
49779
+ if (firstContent === -1 || lines2[firstContent]?.trim() !== "---") return null;
49780
+ for (let i = firstContent + 1; i < lines2.length; i++) {
49781
+ const trimmed = (lines2[i] ?? "").trim();
49782
+ if (trimmed === "---") break;
49783
+ const m = FRONTMATTER_TASK.exec(trimmed);
49784
+ if (m) return firstTaskIdToken(m[1] ?? "");
49785
+ }
49786
+ return null;
49787
+ }
49788
+ function firstHeadingToken(markdown) {
49789
+ const heading = firstHeading(markdown);
49790
+ return heading === null ? null : firstTaskIdToken(heading);
49791
+ }
49792
+ function firstHeading(markdown) {
49793
+ const lines2 = markdown.split("\n");
49794
+ let i = 0;
49795
+ const firstContent = lines2.findIndex((l) => l.trim() !== "");
49796
+ if (firstContent !== -1 && lines2[firstContent]?.trim() === "---") {
49797
+ i = firstContent + 1;
49798
+ while (i < lines2.length && lines2[i]?.trim() !== "---") i++;
49799
+ i++;
49800
+ }
49801
+ let inFence = false;
49802
+ let inComment = false;
49803
+ for (; i < lines2.length; i++) {
49804
+ const line = lines2[i] ?? "";
49805
+ const trimmed = line.trim();
49806
+ if (inComment) {
49807
+ if (trimmed.includes("-->")) inComment = false;
49808
+ continue;
49809
+ }
49810
+ if (trimmed.startsWith("```") || trimmed.startsWith("~~~")) {
49811
+ inFence = !inFence;
49812
+ continue;
49813
+ }
49814
+ if (inFence) continue;
49815
+ if (trimmed.startsWith("<!--")) {
49816
+ if (!trimmed.includes("-->")) inComment = true;
49817
+ continue;
49818
+ }
49819
+ const m = ATX_HEADING.exec(line);
49820
+ if (m) return m[1] ?? "";
49821
+ }
49822
+ return null;
49823
+ }
49824
+ function firstTaskIdToken(text) {
49825
+ const m = TASK_ID_TOKEN.exec(text);
49826
+ return m ? m[0] : null;
49827
+ }
49828
+
49527
49829
  // src/consensus/spec-gate.ts
49528
49830
  var SPEC_HEADS = ["plan-reviewer", "architect", "security-analyst", "scope-analyst"];
49529
49831
  async function runSpecGate(_target, deps, opts) {
@@ -49545,6 +49847,7 @@ async function runSpecGate(_target, deps, opts) {
49545
49847
  const heads = [...SPEC_HEADS, ...extraHeads.filter((h) => !SPEC_HEADS.includes(h))];
49546
49848
  const spec = await deps.readSpec();
49547
49849
  let currentHash = spec.contentHash;
49850
+ let currentMarkdown = spec.markdownBody;
49548
49851
  let conflicted = false;
49549
49852
  const result = await runConsensus({
49550
49853
  artifact: spec.markdownBody,
@@ -49556,12 +49859,20 @@ async function runSpecGate(_target, deps, opts) {
49556
49859
  revise: async (artifact, accepted) => {
49557
49860
  const revised = await deps.runReviser(artifact, accepted);
49558
49861
  if (!revised.ok) return artifact;
49862
+ if (intent.taskId !== null && !validateSpecIdentity(revised.text, intent.taskId)) {
49863
+ process.stderr.write(
49864
+ `[spec-gate] discarding drifted revision for ${spec.pageId}: heading does not identify ${intent.taskId}
49865
+ `
49866
+ );
49867
+ return artifact;
49868
+ }
49559
49869
  const cas = await deps.casUpdateSpec(revised.text, currentHash);
49560
49870
  if (!cas.ok) {
49561
49871
  conflicted = true;
49562
49872
  throw new Error("SPEC_CHANGED_DURING_REVIEW");
49563
49873
  }
49564
49874
  currentHash = cas.contentHash;
49875
+ currentMarkdown = revised.text;
49565
49876
  return revised.text;
49566
49877
  }
49567
49878
  // Only OUR conflict throw is swallowed (→ null). Anything else is a real
@@ -49571,6 +49882,22 @@ async function runSpecGate(_target, deps, opts) {
49571
49882
  await deps.writeEvidence({ specPageId: spec.pageId, outcome: "SPEC_CHANGED_DURING_REVIEW" });
49572
49883
  return { outcome: "SPEC_CHANGED_DURING_REVIEW" };
49573
49884
  }
49885
+ if (result.outcome === "CONVERGED" && intent.taskId !== null && !validateSpecIdentity(currentMarkdown, intent.taskId)) {
49886
+ process.stderr.write(
49887
+ `[spec-gate] blocking promotion for ${spec.pageId}: converged spec heading does not identify ${intent.taskId}
49888
+ `
49889
+ );
49890
+ return {
49891
+ outcome: "CONSENSUS_BLOCKED",
49892
+ clarifications: [
49893
+ {
49894
+ question: `SPEC_IDENTITY_MISMATCH: this spec page gates ${intent.taskId}, but its heading identifies a different task \u2014 rewrite the spec heading and content for ${intent.taskId}, or re-link the task.`,
49895
+ whyBlocking: "converged spec content belongs to a different task than the one it gates",
49896
+ category: "scope"
49897
+ }
49898
+ ]
49899
+ };
49900
+ }
49574
49901
  await deps.writeEvidence({
49575
49902
  specPageId: spec.pageId,
49576
49903
  outcome: result.outcome,
@@ -49788,6 +50115,21 @@ var import_node_fs11 = __toESM(require("node:fs"), 1);
49788
50115
  var import_node_path13 = __toESM(require("node:path"), 1);
49789
50116
  var import_node_url2 = require("node:url");
49790
50117
 
50118
+ // src/consensus/resolve-gated-task.ts
50119
+ async function resolveGatedTaskId(client, projectSlug, page) {
50120
+ const tasks = await client.listTasks(projectSlug);
50121
+ const linking = tasks.find((t) => t.specWikiPageId === page.id);
50122
+ if (linking) {
50123
+ if (page.taskId !== linking.id) {
50124
+ throw new Error(
50125
+ `spec page ${page.id} is linked by task ${linking.id} (tasks.spec_wiki_page_id) but its wiki_pages.task_id back-pointer is ${page.taskId === null ? "null" : page.taskId} \u2014 refusing to gate with a drifted identity`
50126
+ );
50127
+ }
50128
+ return linking.id;
50129
+ }
50130
+ return page.taskId;
50131
+ }
50132
+
49791
50133
  // src/consensus/code-gate-git.ts
49792
50134
  var import_node_child_process6 = require("node:child_process");
49793
50135
  var DIFF_MAX_BYTES = 2e5;
@@ -50032,6 +50374,23 @@ function loadPersona(name) {
50032
50374
  personaCache.set(name, content);
50033
50375
  return content;
50034
50376
  }
50377
+ function specHeadIntentBlock(taskId, title, seed) {
50378
+ const identity = taskId ? `--- TASK IDENTITY (the spec MUST be about THIS task; if its scope or direction contradicts this identity, raise a \`scope\` issue instead of steering the spec elsewhere) ---
50379
+ TASK ${taskId}: ${title ?? ""}
50380
+
50381
+ ` : "";
50382
+ const intent = seed ? `--- OPERATOR INTENT (the operator's own words \u2014 judge the spec against THIS) ---
50383
+ ${seed}
50384
+
50385
+ ` : "";
50386
+ return `${identity}${intent}`;
50387
+ }
50388
+ function reviserIdentityInstructions(taskId, title) {
50389
+ const lock = taskId ? `This spec gates task ${taskId}${title ? ` ("${title}")` : ""}. You MUST NOT change the spec's task identity or heading, retarget it to a different task id, or invert its direction (e.g. live-submission vs preparation-only). Preserve the leading \`task: ${taskId}\` frontmatter EXACTLY; never change it to another task id. Address ONLY the accepted issues.
50390
+
50391
+ ` : "";
50392
+ return `${lock}You revise a spec to address accepted issues. Return ONLY the full revised markdown, no commentary.`;
50393
+ }
50035
50394
  function buildSpecGateDeps(args) {
50036
50395
  const { client, projectSlug, specPageId, cwd, codexExecutable, extraHeadCount = 0 } = args;
50037
50396
  const rounds = Number(process.env["MRRLIN_CONSENSUS_ROUNDS"] ?? 3);
@@ -50042,10 +50401,16 @@ function buildSpecGateDeps(args) {
50042
50401
  if (!intentPromise) {
50043
50402
  intentPromise = (async () => {
50044
50403
  const page = await client.getWikiPage(projectSlug, specPageId);
50045
- const taskId = page?.taskId ?? null;
50046
- if (taskId === null) return { taskId: null, seed: null };
50404
+ if (!page) {
50405
+ throw new Error(`spec page ${projectSlug}/${specPageId} not found \u2014 cannot gate`);
50406
+ }
50407
+ const taskId = await resolveGatedTaskId(client, projectSlug, { id: page.id, taskId: page.taskId ?? null });
50408
+ if (taskId === null) return { taskId: null, seed: null, title: null };
50047
50409
  const task = await client.getTask(projectSlug, taskId);
50048
- return { taskId, seed: task?.operatorSeedRequest ?? null };
50410
+ if (!task) {
50411
+ throw new Error(`gated task ${projectSlug}/${taskId} not found for spec ${specPageId} \u2014 cannot gate`);
50412
+ }
50413
+ return { taskId, seed: task.operatorSeedRequest ?? null, title: task.title ?? "" };
50049
50414
  })();
50050
50415
  }
50051
50416
  return intentPromise;
@@ -50070,11 +50435,8 @@ function buildSpecGateDeps(args) {
50070
50435
  },
50071
50436
  async runHead(personaName, artifact, dismissed) {
50072
50437
  const persona = loadPersona(personaName);
50073
- const { seed } = await loadIntent();
50074
- const intentBlock = seed ? `--- OPERATOR INTENT (the operator's own words \u2014 judge the spec against THIS) ---
50075
- ${seed}
50076
-
50077
- ` : "";
50438
+ const { taskId, title, seed } = await loadIntent();
50439
+ const intentBlock = specHeadIntentBlock(taskId, title, seed);
50078
50440
  return runCodexExec(
50079
50441
  {
50080
50442
  prompt: `Review this spec against the operator's intent.
@@ -50105,6 +50467,7 @@ ${artifact}`,
50105
50467
  },
50106
50468
  async runReviser(artifact, accepted) {
50107
50469
  const acceptedList = accepted.map((a) => `- (${a.category}) ${a.text}`).join("\n");
50470
+ const { taskId, title } = await loadIntent();
50108
50471
  return runCodexExec(
50109
50472
  {
50110
50473
  prompt: `Accepted issues:
@@ -50112,7 +50475,7 @@ ${acceptedList}
50112
50475
 
50113
50476
  --- SPEC ---
50114
50477
  ${artifact}`,
50115
- developerInstructions: "You revise a spec to address accepted issues. Return ONLY the full revised markdown, no commentary.",
50478
+ developerInstructions: reviserIdentityInstructions(taskId, title),
50116
50479
  cwd,
50117
50480
  timeoutMs,
50118
50481
  sandbox: "read-only",
@@ -50263,6 +50626,13 @@ async function cachedResult(client, input) {
50263
50626
  if (!page) return null;
50264
50627
  const review = await client.getSpecConsensusReview(input.projectSlug, input.pageId, page.contentHash);
50265
50628
  if (review?.outcome === "CONVERGED") {
50629
+ const gatedTaskId = await resolveGatedTaskId(client, input.projectSlug, {
50630
+ id: page.id,
50631
+ taskId: page.taskId ?? null
50632
+ });
50633
+ if (gatedTaskId !== null && !validateSpecIdentity(page.markdownBody, gatedTaskId)) {
50634
+ return null;
50635
+ }
50266
50636
  return { status: "already_converged", payload: { outcome: "CONVERGED", contentHash: page.contentHash } };
50267
50637
  }
50268
50638
  if (review?.outcome === "CONSENSUS_BLOCKED") {
@@ -52485,6 +52855,13 @@ Usage:
52485
52855
  non-loopback URL (see \`mrrlin-mcp serve\` above).
52486
52856
  URL and MRRLIN_STAGING are mutually exclusive at
52487
52857
  bake time \u2014 URL wins, mirrors serve's resolver.
52858
+ Re-running BARE (no env vars) over a block you
52859
+ already pinned to staging/a remote target is safe:
52860
+ it keeps your env verbatim (reported as
52861
+ 'env-preserved') instead of conflicting, so the
52862
+ same setup command doubles as an update command.
52863
+ To CHANGE or REMOVE the baked target, re-run with
52864
+ --force (or the new env vars).
52488
52865
  --force replaces an existing conflicting block.
52489
52866
  --force-prompts overwrites prompt files that
52490
52867
  already exist with different content. WITHOUT it,
@@ -52531,8 +52908,18 @@ Usage:
52531
52908
  - MRRLIN_DIRECTOR_BRIDGE_CODEX_IDLE_SECONDS (default 300;
52532
52909
  how long the warm codex app-server is kept alive
52533
52910
  between turns before it is released)
52534
- - MRRLIN_DIRECTOR_BRIDGE_EXEC_TURN_TIMEOUT_SECONDS (default 900;
52535
- whole-turn deadline for one autonomous execution turn)
52911
+ - MRRLIN_DIRECTOR_BRIDGE_EXEC_TURN_IDLE_TIMEOUT_SECONDS (default 600;
52912
+ an execution turn is cut only after this long with NO codex
52913
+ signal at all \u2014 long-but-active turns keep running. A stall
52914
+ earns one automatic retry)
52915
+ - MRRLIN_DIRECTOR_BRIDGE_EXEC_RUN_MAX_SECONDS (default 3600;
52916
+ overall wall-clock budget for the active-turn portion of one
52917
+ claim attempt, measured from claim. Exceeding it hands off with
52918
+ NO retry. Setup/post-turn hangs are bounded by the stale reaper,
52919
+ not by this budget)
52920
+ - MRRLIN_DIRECTOR_BRIDGE_EXEC_TURN_TIMEOUT_SECONDS (DEPRECATED;
52921
+ unset by default. When set, an extra per-turn hard cap: the
52922
+ effective turn deadline becomes min(this, remaining run budget))
52536
52923
  - MRRLIN_DIRECTOR_BRIDGE_CONTEXT_MAX_ARTIFACTS (default 50;
52537
52924
  most-recent artifacts kept in the execution context bundle)
52538
52925
 
@@ -52647,6 +53034,10 @@ async function main() {
52647
53034
  });
52648
53035
  process.stderr.write(`[mrrlin-mcp install-codex] ${result.action} ${result.configPath}
52649
53036
  `);
53037
+ for (const preserved of result.preservedEnvBlocks) {
53038
+ process.stderr.write(`${renderEnvPreservedNotice(preserved.blockId, preserved.envKeys)}
53039
+ `);
53040
+ }
52650
53041
  for (const p of result.prompts) {
52651
53042
  process.stderr.write(`[mrrlin-mcp install-codex] prompt ${p.name}: ${p.action} ${p.promptPath}
52652
53043
  `);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrrlin-dev/mcp",
3
- "version": "0.3.13",
3
+ "version": "0.3.15",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mrrlin-mcp": "dist/bin.cjs"
@@ -22,11 +22,11 @@
22
22
  "@types/ws": "^8.18.1",
23
23
  "esbuild": "^0.24.0",
24
24
  "tsx": "^4.22.3",
25
+ "@mrrlin/client": "0.0.0",
25
26
  "@mrrlin/codex-client": "0.0.0",
26
27
  "@mrrlin/director-e2e": "0.0.0",
27
- "@mrrlin/client": "0.0.0",
28
- "@mrrlin/schemas": "0.0.0",
29
28
  "@mrrlin/tsconfig": "0.0.0",
29
+ "@mrrlin/schemas": "0.0.0",
30
30
  "@mrrlin/wiki": "0.0.0"
31
31
  },
32
32
  "dependencies": {