claude-threads 1.21.1 → 1.21.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.21.2] - 2026-08-07
9
+
10
+ ### Added
11
+ - **Decision bridge: plan approvals and question answers now flow through one surface.** On modern Claude CLIs (verified 2.1.223), `ExitPlanMode` and `AskUserQuestion` block on the MCP permission prompt — making it the authoritative gate — while the rich UI (plan post with 👍/👎, question posts with option reactions) lives in the main bot. Users saw two competing prompts, and reacting only on the bot's UI let the MCP prompt time out into a plan denial. The bot now opens a per-session local socket (a named pipe on Windows); the MCP server forwards plan/question permission requests over it and waits (default 1h, `DECISION_BRIDGE_TIMEOUT_MS`), and the user's reaction on the bot's existing UI resolves them: plans as allow/deny, question answers riding back in the permission response's `updatedInput.answers` — the CLI then tells Claude "Your questions have been answered" / "User has approved your plan" itself (both verified against the real CLI end-to-end, including through the real built `mcp-server.js`). The stdin sends (`'approved'`/answer JSON) are suppressed when a bridge request consumed the decision, and remain as the fallback for older CLIs that don't route these tools through the permission prompt. The bridge degrades gracefully everywhere: creation failure, connect failure, or timeout falls back to the previous behavior (generic prompt for plans, auto-allow for questions). The bridge is session-scoped — it survives `!cd`/`!permissions` respawns and closes with the session; pending decisions are denied on session end or fresh-CLI restart so the MCP child is never stranded. A manual real-CLI verification script ships in `tests/e2e-real-cli/decision-bridge-e2e.ts` (not run in CI — needs live credentials).
12
+ - **Decision-bridge hardening (from two adversarial review rounds over this change).** `!approve`/`!yes` resolves a pending bridge request instead of sending a stdin message that would queue behind the blocked permission call (previously it converted an approval into an hour-long hang). The bridge server aborts the bot-side pending when the requesting MCP client disconnects (timeout, cancelled tool call, dead child), so a stale pending can never swallow the user's later reaction — it falls back to stdin instead. Claude respawns deny pending decisions unconditionally (the MCP child always dies, resume or not; previously only fresh-session respawns did). The bridge socket lives in a fresh `0700` directory (other local users can't connect regardless of umask) with a short path (safe against macOS's 104-byte `sun_path` truncation), and is closed on every teardown path including start/resume failures. multiSelect questions bypass the bridge (single-select answer format is the only one verified against the real CLI) and take the legacy path. Operator knob `DECISION_BRIDGE_TIMEOUT_MS` is now actually forwarded to the MCP child.
13
+ - **The CLI is spawned with `MCP_TOOL_TIMEOUT=3600000` when a decision bridge is configured.** Delayed-decision testing against the real CLI (2.1.223) showed it abandons a pending MCP permission call after ~2 minutes — one retry, then an error — silently capping how long a plan approval or question answer could wait, regardless of the bridge's 1-hour window. With the flag set, decisions held for 150 seconds complete end-to-end (verified empirically); 1 hour matches the bridge's `DECISION_BRIDGE_TIMEOUT_MS` default, and setting `MCP_TOOL_TIMEOUT` in the bot's own env overrides it, like the other forwarded tuning flags. Should the CLI still give up (older CLI, operator override), the bridge's disconnect-abort clears the bot-side pending state so the user's late reaction falls back to the stdin flow instead of being swallowed.
14
+
8
15
  ## [1.21.1] - 2026-08-07
9
16
 
10
17
  ### Fixed
package/dist/index.js CHANGED
@@ -14177,7 +14177,7 @@ var require_minimist = __commonJS((exports, module) => {
14177
14177
  // node_modules/rc/index.js
14178
14178
  var require_rc = __commonJS((exports, module) => {
14179
14179
  var cc = require_utils();
14180
- var join10 = __require("path").join;
14180
+ var join11 = __require("path").join;
14181
14181
  var deepExtend = require_deep_extend();
14182
14182
  var etc = "/etc";
14183
14183
  var win = process.platform === "win32";
@@ -14203,15 +14203,15 @@ var require_rc = __commonJS((exports, module) => {
14203
14203
  }
14204
14204
  if (!win)
14205
14205
  [
14206
- join10(etc, name, "config"),
14207
- join10(etc, name + "rc")
14206
+ join11(etc, name, "config"),
14207
+ join11(etc, name + "rc")
14208
14208
  ].forEach(addConfigFile);
14209
14209
  if (home)
14210
14210
  [
14211
- join10(home, ".config", name, "config"),
14212
- join10(home, ".config", name),
14213
- join10(home, "." + name, "config"),
14214
- join10(home, "." + name + "rc")
14211
+ join11(home, ".config", name, "config"),
14212
+ join11(home, ".config", name),
14213
+ join11(home, "." + name, "config"),
14214
+ join11(home, "." + name + "rc")
14215
14215
  ].forEach(addConfigFile);
14216
14216
  addConfigFile(cc.find("." + name + "rc"));
14217
14217
  if (env3.config)
@@ -55960,7 +55960,7 @@ function cleanupBrowserBridgeSockets() {
55960
55960
  log8.debug(`Browser bridge cleanup failed: ${err}`);
55961
55961
  }
55962
55962
  }
55963
- function buildClaudeChildEnv(parentEnv, account) {
55963
+ function buildClaudeChildEnv(parentEnv, account, opts) {
55964
55964
  const env = { ...parentEnv };
55965
55965
  if (env.MCP_CONNECTION_NONBLOCKING === undefined) {
55966
55966
  env.MCP_CONNECTION_NONBLOCKING = "true";
@@ -55968,6 +55968,9 @@ function buildClaudeChildEnv(parentEnv, account) {
55968
55968
  if (env.ENABLE_PROMPT_CACHING_1H === undefined) {
55969
55969
  env.ENABLE_PROMPT_CACHING_1H = "true";
55970
55970
  }
55971
+ if (opts?.decisionBridge && env.MCP_TOOL_TIMEOUT === undefined) {
55972
+ env.MCP_TOOL_TIMEOUT = "3600000";
55973
+ }
55971
55974
  if (account?.home) {
55972
55975
  env.HOME = account.home;
55973
55976
  env.USERPROFILE = account.home;
@@ -56016,6 +56019,12 @@ function buildPermissionArgs(opts) {
56016
56019
  PERMISSION_TIMEOUT_MS: String(opts.permissionTimeoutMs),
56017
56020
  SESSION_OWNER_USERNAME: opts.sessionOwnerUsername || ""
56018
56021
  };
56022
+ if (opts.decisionBridgePath) {
56023
+ mcpEnv.DECISION_BRIDGE_PATH = opts.decisionBridgePath;
56024
+ if (process.env.DECISION_BRIDGE_TIMEOUT_MS) {
56025
+ mcpEnv.DECISION_BRIDGE_TIMEOUT_MS = process.env.DECISION_BRIDGE_TIMEOUT_MS;
56026
+ }
56027
+ }
56019
56028
  if (opts.platformConfig.appToken) {
56020
56029
  mcpEnv.PLATFORM_APP_TOKEN = opts.platformConfig.appToken;
56021
56030
  }
@@ -56156,7 +56165,8 @@ class ClaudeCli extends EventEmitter2 {
56156
56165
  workingDir: this.options.workingDir,
56157
56166
  uploadDir: this.options.uploadDir,
56158
56167
  outboundFiles: this.options.outboundFiles,
56159
- sessionOwnerUsername: this.options.sessionOwnerUsername
56168
+ sessionOwnerUsername: this.options.sessionOwnerUsername,
56169
+ decisionBridgePath: this.options.decisionBridgePath
56160
56170
  });
56161
56171
  args.push(...permResult.args);
56162
56172
  this.mcpConfigTempFile = permResult.tempFile;
@@ -56390,7 +56400,9 @@ class ClaudeCli extends EventEmitter2 {
56390
56400
  return true;
56391
56401
  }
56392
56402
  buildChildEnv() {
56393
- return buildClaudeChildEnv(process.env, this.options.account);
56403
+ return buildClaudeChildEnv(process.env, this.options.account, {
56404
+ decisionBridge: this.options.decisionBridgePath !== undefined
56405
+ });
56394
56406
  }
56395
56407
  getMcpServerPath() {
56396
56408
  const __filename2 = fileURLToPath3(import.meta.url);
@@ -57243,6 +57255,109 @@ function isAuthorizedForSession(check) {
57243
57255
  return sessionAllowedUsers?.has(username) ?? false;
57244
57256
  }
57245
57257
 
57258
+ // src/mcp/decision-bridge.ts
57259
+ import { createServer, createConnection } from "node:net";
57260
+ import { tmpdir as tmpdir2 } from "node:os";
57261
+ import { join as join9 } from "node:path";
57262
+ import { randomUUID as randomUUID2 } from "node:crypto";
57263
+ import { mkdtempSync } from "node:fs";
57264
+ import { rm as rm2 } from "node:fs/promises";
57265
+
57266
+ class BridgeUnavailableError extends Error {
57267
+ }
57268
+ function bridgeSocketPath() {
57269
+ if (process.platform === "win32") {
57270
+ return `\\\\.\\pipe\\ctb-${randomUUID2()}`;
57271
+ }
57272
+ const dir = mkdtempSync(join9(tmpdir2(), "ctb-"));
57273
+ return join9(dir, "b.sock");
57274
+ }
57275
+
57276
+ class DecisionBridgeServer {
57277
+ server;
57278
+ path;
57279
+ liveSockets = new Set;
57280
+ constructor(server, path2) {
57281
+ this.server = server;
57282
+ this.path = path2;
57283
+ }
57284
+ static async create(handler) {
57285
+ const path2 = bridgeSocketPath();
57286
+ const liveSockets = new Set;
57287
+ const server = createServer((socket) => {
57288
+ liveSockets.add(socket);
57289
+ socket.once("close", () => liveSockets.delete(socket));
57290
+ let buffer = "";
57291
+ const aborter = new AbortController;
57292
+ let responded = false;
57293
+ socket.on("data", (chunk) => {
57294
+ buffer += chunk.toString("utf8");
57295
+ const newline = buffer.indexOf(`
57296
+ `);
57297
+ if (newline === -1)
57298
+ return;
57299
+ const line = buffer.slice(0, newline);
57300
+ buffer = "";
57301
+ let request;
57302
+ try {
57303
+ request = JSON.parse(line);
57304
+ } catch {
57305
+ responded = true;
57306
+ socket.end(JSON.stringify({ behavior: "deny", message: "Malformed bridge request" }) + `
57307
+ `);
57308
+ return;
57309
+ }
57310
+ handler(request, aborter.signal).then((response) => {
57311
+ responded = true;
57312
+ socket.end(JSON.stringify(response) + `
57313
+ `);
57314
+ }).catch((err) => {
57315
+ responded = true;
57316
+ if (err instanceof BridgeUnavailableError) {
57317
+ socket.destroy();
57318
+ return;
57319
+ }
57320
+ socket.end(JSON.stringify({
57321
+ behavior: "deny",
57322
+ message: `Bridge handler failed: ${err instanceof Error ? err.message : String(err)}`
57323
+ }) + `
57324
+ `);
57325
+ });
57326
+ });
57327
+ socket.on("close", () => {
57328
+ if (!responded)
57329
+ aborter.abort();
57330
+ });
57331
+ socket.on("error", () => {});
57332
+ });
57333
+ try {
57334
+ await new Promise((resolve4, reject) => {
57335
+ server.once("error", reject);
57336
+ server.listen(path2, () => {
57337
+ server.removeListener("error", reject);
57338
+ resolve4();
57339
+ });
57340
+ });
57341
+ } catch (err) {
57342
+ if (process.platform !== "win32") {
57343
+ await rm2(join9(path2, ".."), { recursive: true, force: true }).catch(() => {});
57344
+ }
57345
+ throw err;
57346
+ }
57347
+ const bridge = new DecisionBridgeServer(server, path2);
57348
+ bridge.liveSockets = liveSockets;
57349
+ return bridge;
57350
+ }
57351
+ async close() {
57352
+ for (const socket of this.liveSockets)
57353
+ socket.destroy();
57354
+ await new Promise((resolve4) => this.server.close(() => resolve4()));
57355
+ if (process.platform !== "win32") {
57356
+ await rm2(join9(this.path, ".."), { recursive: true, force: true }).catch(() => {});
57357
+ }
57358
+ }
57359
+ }
57360
+
57246
57361
  // src/commands/registry.ts
57247
57362
  var COMMAND_REGISTRY = [
57248
57363
  {
@@ -58183,7 +58298,7 @@ ${avoidCommands.map((c) => `- \`!${c.command}\` - ${c.reason}`).join(`
58183
58298
  `.trim();
58184
58299
  }
58185
58300
  // src/session/lifecycle.ts
58186
- import { randomUUID as randomUUID4 } from "crypto";
58301
+ import { randomUUID as randomUUID5 } from "crypto";
58187
58302
  import { existsSync as existsSync12 } from "fs";
58188
58303
 
58189
58304
  // src/utils/keep-alive.ts
@@ -58549,23 +58664,23 @@ function updateLastMessage(session, post2) {
58549
58664
 
58550
58665
  // src/operations/streaming/handler.ts
58551
58666
  init_logger();
58552
- import { lstat, mkdir as mkdir2, mkdtemp, rm as rm2, writeFile as writeFile2 } from "fs/promises";
58553
- import { tmpdir as tmpdir2 } from "os";
58554
- import { join as join9 } from "path";
58667
+ import { lstat, mkdir as mkdir2, mkdtemp, rm as rm3, writeFile as writeFile2 } from "fs/promises";
58668
+ import { tmpdir as tmpdir3 } from "os";
58669
+ import { join as join10 } from "path";
58555
58670
  var log19 = createLogger("streaming");
58556
58671
  var UPLOAD_ROOT_DIR = "claude-threads-uploads";
58557
58672
  function safeIdSegment(id) {
58558
58673
  return id.replace(/[^A-Za-z0-9._-]/g, "_");
58559
58674
  }
58560
58675
  function getSessionUploadDir(platformId, threadId) {
58561
- return join9(tmpdir2(), UPLOAD_ROOT_DIR, `${safeIdSegment(platformId)}-${safeIdSegment(threadId)}`);
58676
+ return join10(tmpdir3(), UPLOAD_ROOT_DIR, `${safeIdSegment(platformId)}-${safeIdSegment(threadId)}`);
58562
58677
  }
58563
58678
  async function cleanupSessionUploads(platformId, threadId) {
58564
58679
  if (!platformId || !threadId)
58565
58680
  return;
58566
58681
  const dir = getSessionUploadDir(platformId, threadId);
58567
58682
  try {
58568
- await rm2(dir, { recursive: true, force: true });
58683
+ await rm3(dir, { recursive: true, force: true });
58569
58684
  } catch (err) {
58570
58685
  log19.debug(`Upload cleanup for ${platformId}:${threadId} failed (ignored): ${err}`);
58571
58686
  }
@@ -58591,13 +58706,13 @@ async function saveFilesToUploadDir(platform, uploadDir, files, debug = false) {
58591
58706
  log19.error(`Upload dir is a symlink, refusing all writes: ${uploadDir}`);
58592
58707
  return { saved, skipped };
58593
58708
  }
58594
- const messageDir = await mkdtemp(join9(uploadDir, `${Date.now().toString(36)}-`));
58709
+ const messageDir = await mkdtemp(join10(uploadDir, `${Date.now().toString(36)}-`));
58595
58710
  const usedNames = new Set;
58596
58711
  for (const file of files) {
58597
58712
  try {
58598
58713
  const buffer = await platform.downloadFile(file.id);
58599
58714
  const safeName = dedupeFilename(sanitizeFilename(file.name), usedNames);
58600
- const absolutePath = join9(messageDir, safeName);
58715
+ const absolutePath = join10(messageDir, safeName);
58601
58716
  await writeFile2(absolutePath, buffer, { mode: 384, flag: "wx" });
58602
58717
  saved.push({
58603
58718
  originalName: file.name,
@@ -58680,12 +58795,13 @@ function buildRestartCliOptions(session, ctx) {
58680
58795
  account: ctx.account,
58681
58796
  uploadDir: getSessionUploadDir(session.platformId, session.threadId),
58682
58797
  outboundFiles: platformMcpConfig.outboundFiles,
58683
- sessionOwnerUsername: session.startedBy
58798
+ sessionOwnerUsername: session.startedBy,
58799
+ decisionBridgePath: session.decisionBridge?.path
58684
58800
  };
58685
58801
  }
58686
58802
 
58687
58803
  // src/operations/commands/handler.ts
58688
- import { randomUUID as randomUUID3 } from "crypto";
58804
+ import { randomUUID as randomUUID4 } from "crypto";
58689
58805
  import { resolve as resolve5 } from "path";
58690
58806
  import { existsSync as existsSync11, statSync as statSync3 } from "fs";
58691
58807
 
@@ -62103,8 +62219,8 @@ init_emoji();
62103
62219
  // src/operations/bug-report/handler.ts
62104
62220
  import { execSync as execSync2 } from "child_process";
62105
62221
  import { writeFileSync as writeFileSync6, unlinkSync as unlinkSync3 } from "fs";
62106
- import { tmpdir as tmpdir3 } from "os";
62107
- import { join as join10 } from "path";
62222
+ import { tmpdir as tmpdir4 } from "os";
62223
+ import { join as join11 } from "path";
62108
62224
 
62109
62225
  // node_modules/@redactpii/node/lib/index.mjs
62110
62226
  class Redactor {
@@ -62684,7 +62800,7 @@ async function createGitHubIssue(title, body, workingDir) {
62684
62800
  if (!ghStatus.installed || !ghStatus.authenticated) {
62685
62801
  throw new Error(ghStatus.error);
62686
62802
  }
62687
- const bodyFile = join10(tmpdir3(), `bug-body-${Date.now()}.md`);
62803
+ const bodyFile = join11(tmpdir4(), `bug-body-${Date.now()}.md`);
62688
62804
  try {
62689
62805
  writeFileSync6(bodyFile, body, "utf-8");
62690
62806
  const cmd = `gh issue create --repo "${GITHUB_REPO}" --title "${escapeShell(title)}" --body-file "${bodyFile}"`;
@@ -66717,6 +66833,8 @@ class MessageManager {
66717
66833
  emitSessionUpdateCallback;
66718
66834
  toolStartTimes = new Map;
66719
66835
  taskTracker = new TaskTracker;
66836
+ pendingBridgePlan = null;
66837
+ pendingBridgeQuestion = null;
66720
66838
  flushTimer = null;
66721
66839
  static DEFAULT_FLUSH_DELAY_MS = 500;
66722
66840
  flushDelayMs;
@@ -67178,9 +67296,66 @@ class MessageManager {
67178
67296
  logger.debug("Reaction not handled by any executor");
67179
67297
  return false;
67180
67298
  }
67299
+ handleBridgeRequest(request, signal) {
67300
+ if (request.kind === "plan_approval") {
67301
+ this.pendingBridgePlan?.resolve({ behavior: "deny", message: "Superseded by a newer plan" });
67302
+ return new Promise((resolve5) => {
67303
+ const pending = { resolve: resolve5, input: request.input };
67304
+ this.pendingBridgePlan = pending;
67305
+ signal?.addEventListener("abort", () => {
67306
+ if (this.pendingBridgePlan === pending)
67307
+ this.pendingBridgePlan = null;
67308
+ });
67309
+ });
67310
+ }
67311
+ if (request.kind === "question") {
67312
+ this.pendingBridgeQuestion?.resolve({ behavior: "deny", message: "Superseded by newer questions" });
67313
+ return new Promise((resolve5) => {
67314
+ const pending = { resolve: resolve5, input: request.input };
67315
+ this.pendingBridgeQuestion = pending;
67316
+ signal?.addEventListener("abort", () => {
67317
+ if (this.pendingBridgeQuestion === pending)
67318
+ this.pendingBridgeQuestion = null;
67319
+ });
67320
+ });
67321
+ }
67322
+ return Promise.resolve({ behavior: "deny", message: `Unknown bridge request kind` });
67323
+ }
67324
+ resolveBridgePlan(approved) {
67325
+ const pending = this.pendingBridgePlan;
67326
+ if (!pending)
67327
+ return false;
67328
+ this.pendingBridgePlan = null;
67329
+ pending.resolve(approved ? { behavior: "allow", updatedInput: pending.input } : { behavior: "deny", message: "The user rejected the plan." });
67330
+ return true;
67331
+ }
67332
+ resolveBridgeQuestion(answers) {
67333
+ const pending = this.pendingBridgeQuestion;
67334
+ if (!pending)
67335
+ return false;
67336
+ this.pendingBridgeQuestion = null;
67337
+ const questions = Array.isArray(pending.input.questions) ? pending.input.questions : [];
67338
+ const record = {};
67339
+ for (const a of answers) {
67340
+ const match = questions.find((q) => q.header === a.header);
67341
+ record[match?.question ?? a.header] = a.answer;
67342
+ }
67343
+ pending.resolve({
67344
+ behavior: "allow",
67345
+ updatedInput: { ...pending.input, answers: record }
67346
+ });
67347
+ return true;
67348
+ }
67349
+ denyPendingBridgeRequests(reason) {
67350
+ this.pendingBridgePlan?.resolve({ behavior: "deny", message: reason });
67351
+ this.pendingBridgePlan = null;
67352
+ this.pendingBridgeQuestion?.resolve({ behavior: "deny", message: reason });
67353
+ this.pendingBridgeQuestion = null;
67354
+ }
67181
67355
  clearClaudeSessionState() {
67182
67356
  this.toolStartTimes.clear();
67183
67357
  this.taskTracker.clear();
67358
+ this.denyPendingBridgeRequests("Claude was restarted before a decision was made");
67184
67359
  }
67185
67360
  reset() {
67186
67361
  this.cancelScheduledFlush();
@@ -67196,6 +67371,7 @@ class MessageManager {
67196
67371
  this.systemExecutor.reset();
67197
67372
  }
67198
67373
  dispose() {
67374
+ this.denyPendingBridgeRequests("Session ended before a decision was made");
67199
67375
  this.cancelScheduledFlush();
67200
67376
  this.postTracker.clearSession(this.sessionId);
67201
67377
  this.events.removeAllListeners();
@@ -67976,7 +68152,7 @@ async function suggestBranchNames(workingDir, userMessage) {
67976
68152
 
67977
68153
  // src/operations/worktree/handler.ts
67978
68154
  init_worktree();
67979
- import { randomUUID as randomUUID2 } from "crypto";
68155
+ import { randomUUID as randomUUID3 } from "crypto";
67980
68156
  init_logger();
67981
68157
  var log25 = createLogger("worktree");
67982
68158
  var sessionLog2 = createSessionLog(log25);
@@ -68223,7 +68399,7 @@ async function createAndSwitchToWorktree(session, branch, username, options) {
68223
68399
  transitionTo(session, "restarting");
68224
68400
  await session.claude.kill();
68225
68401
  await options.flush(session);
68226
- const newSessionId = randomUUID2();
68402
+ const newSessionId = randomUUID3();
68227
68403
  session.claudeSessionId = newSessionId;
68228
68404
  const needsTitlePrompt = !session.sessionTitle;
68229
68405
  const cliOptions = {
@@ -68320,7 +68496,7 @@ ${fmt.formatItalic("Claude Code restarted in the worktree")}`);
68320
68496
  transitionTo(session, "restarting");
68321
68497
  await session.claude.kill();
68322
68498
  await options.flush(session);
68323
- const newSessionId = randomUUID2();
68499
+ const newSessionId = randomUUID3();
68324
68500
  session.claudeSessionId = newSessionId;
68325
68501
  const needsTitlePrompt = !session.sessionTitle;
68326
68502
  const cliOptions = {
@@ -69244,6 +69420,7 @@ async function restartClaudeSession(session, cliOptions, ctx, actionName) {
69244
69420
  transitionTo(session, "restarting");
69245
69421
  await session.claude.kill();
69246
69422
  await ctx.ops.flush(session);
69423
+ session.messageManager?.denyPendingBridgeRequests("Claude was restarted before a decision was made");
69247
69424
  if (!cliOptions.resume) {
69248
69425
  session.messageManager?.clearClaudeSessionState();
69249
69426
  }
@@ -69325,12 +69502,18 @@ async function approvePendingPlan(session, username, ctx) {
69325
69502
  }
69326
69503
  const { postId } = pendingApproval;
69327
69504
  sessionLog5(session).info(`✅ Plan approved by @${username} via command`);
69505
+ const viaBridge = session.messageManager?.resolveBridgePlan(true) ?? false;
69328
69506
  const formatter = session.platform.getFormatter();
69329
69507
  const statusMessage = `${formatter.formatBold("Plan approved")} by ${formatter.formatUserMention(username)} - starting implementation...`;
69330
69508
  await updatePostSuccess(session, postId, statusMessage);
69331
69509
  session.messageManager?.clearPendingApproval();
69332
69510
  session.messageManager?.clearPendingQuestionSet();
69333
69511
  session.planApproved = true;
69512
+ if (viaBridge) {
69513
+ sessionLog5(session).info("Plan approved via decision bridge (!approve)");
69514
+ ctx.ops.startTyping(session);
69515
+ return;
69516
+ }
69334
69517
  if (session.claude.isRunning()) {
69335
69518
  session.claude.sendMessage("Plan approved! Please proceed with the implementation.");
69336
69519
  ctx.ops.startTyping(session);
@@ -69393,7 +69576,7 @@ async function changeDirectory(session, newDir, username, ctx) {
69393
69576
  sessionLog5(session).debug(`Stored work summary for context preservation`);
69394
69577
  }
69395
69578
  session.workingDir = absoluteDir;
69396
- const newSessionId = randomUUID3();
69579
+ const newSessionId = randomUUID4();
69397
69580
  session.claudeSessionId = newSessionId;
69398
69581
  const appendSystemPrompt = await buildAppendSystemPrompt(session.platform, session.platformId, absoluteDir, session.threadId, session.startedBy, session.sessionAllowedUsers, CHAT_PLATFORM_PROMPT, ctx.state.githubEmailsStore, { userAttribution: session.userAttribution });
69399
69582
  const cliOptions = {
@@ -69910,6 +70093,8 @@ async function cleanupSession(session, ctx, options = {}) {
69910
70093
  await closeThreadLogger(session, action, details);
69911
70094
  }
69912
70095
  session.messageManager?.dispose();
70096
+ session.decisionBridge?.close();
70097
+ session.decisionBridge = undefined;
69913
70098
  ctx.ops.emitSessionRemove(session.sessionId);
69914
70099
  mutableSessions(ctx).delete(session.sessionId);
69915
70100
  if (doCleanupPostIndex) {
@@ -69927,6 +70112,8 @@ function releaseAccountIfHeld(session, ctx) {
69927
70112
  }
69928
70113
  function removeFromRegistry(session, ctx) {
69929
70114
  session.messageManager?.dispose();
70115
+ session.decisionBridge?.close();
70116
+ session.decisionBridge = undefined;
69930
70117
  ctx.ops.emitSessionRemove(session.sessionId);
69931
70118
  mutableSessions(ctx).delete(session.sessionId);
69932
70119
  cleanupPostIndex(ctx, session.threadId);
@@ -69952,6 +70139,20 @@ function findPersistedByThreadId(persisted, threadId) {
69952
70139
  }
69953
70140
  return;
69954
70141
  }
70142
+ async function createSessionDecisionBridge(ref) {
70143
+ try {
70144
+ return await DecisionBridgeServer.create(async (request, signal) => {
70145
+ const messageManager = ref.current?.messageManager;
70146
+ if (!messageManager) {
70147
+ throw new BridgeUnavailableError("Session is not ready for decisions yet");
70148
+ }
70149
+ return messageManager.handleBridgeRequest(request, signal);
70150
+ });
70151
+ } catch (err) {
70152
+ log31.warn(`Decision bridge unavailable — falling back to legacy MCP prompts: ${err}`);
70153
+ return null;
70154
+ }
70155
+ }
69955
70156
  function createMessageManager(session, ctx) {
69956
70157
  const postTracker = new PostTracker;
69957
70158
  const messageManager = new MessageManager({
@@ -69982,10 +70183,20 @@ function createMessageManager(session, ctx) {
69982
70183
  flushDelayMs: ctx.config.flushDelayMs
69983
70184
  });
69984
70185
  messageManager.events.on("question:complete", ({ toolUseId: _toolUseId, answers }) => {
70186
+ if (messageManager.resolveBridgeQuestion(answers)) {
70187
+ sessionLog6(session).info("Question answered via decision bridge");
70188
+ ctx.ops.startTyping(session);
70189
+ return;
70190
+ }
69985
70191
  const answerJson = JSON.stringify(answers);
69986
70192
  session.claude.sendMessage(answerJson);
69987
70193
  });
69988
70194
  messageManager.events.on("approval:complete", ({ toolUseId: _toolUseId, approved }) => {
70195
+ if (messageManager.resolveBridgePlan(approved)) {
70196
+ sessionLog6(session).info(`Plan ${approved ? "approved" : "denied"} via decision bridge`);
70197
+ ctx.ops.startTyping(session);
70198
+ return;
70199
+ }
69989
70200
  const response = approved ? "approved" : "denied";
69990
70201
  session.claude.sendMessage(response);
69991
70202
  });
@@ -70251,7 +70462,7 @@ async function startSession(options, username, displayName, replyToPostId, platf
70251
70462
  const actualThreadId = replyToPostId || (startPost ? startPost.id : "");
70252
70463
  const sessionId = ctx.ops.getSessionId(platformId, actualThreadId);
70253
70464
  platform.sendTyping(actualThreadId);
70254
- const claudeSessionId = randomUUID4();
70465
+ const claudeSessionId = randomUUID5();
70255
70466
  let workingDir = ctx.config.workingDir;
70256
70467
  let permissionMode = ctx.config.permissionMode;
70257
70468
  let forceInteractivePermissions = false;
@@ -70305,6 +70516,8 @@ async function startSession(options, username, displayName, replyToPostId, platf
70305
70516
  if (claudeAccount) {
70306
70517
  log31.info(`Session ${sessionId.substring(0, 20)} reserved Claude account "${claudeAccount.id}"`);
70307
70518
  }
70519
+ const bridgeSessionRef = {};
70520
+ const decisionBridge = await createSessionDecisionBridge(bridgeSessionRef);
70308
70521
  const cliOptions = {
70309
70522
  workingDir,
70310
70523
  threadId: actualThreadId,
@@ -70319,9 +70532,16 @@ async function startSession(options, username, displayName, replyToPostId, platf
70319
70532
  account: claudeAccount ? { id: claudeAccount.id, home: claudeAccount.home, apiKey: claudeAccount.apiKey } : undefined,
70320
70533
  uploadDir: getSessionUploadDir(platformId, actualThreadId),
70321
70534
  outboundFiles: platformMcpConfig.outboundFiles,
70322
- sessionOwnerUsername: username
70535
+ sessionOwnerUsername: username,
70536
+ decisionBridgePath: decisionBridge?.path
70323
70537
  };
70324
- const claude = new ClaudeCli(cliOptions);
70538
+ let claude;
70539
+ try {
70540
+ claude = new ClaudeCli(cliOptions);
70541
+ } catch (err) {
70542
+ decisionBridge?.close();
70543
+ throw err;
70544
+ }
70325
70545
  const session = {
70326
70546
  platformId,
70327
70547
  threadId: actualThreadId,
@@ -70355,7 +70575,9 @@ async function startSession(options, username, displayName, replyToPostId, platf
70355
70575
  enabled: ctx.config.threadLogsEnabled ?? true
70356
70576
  })
70357
70577
  };
70578
+ session.decisionBridge = decisionBridge ?? undefined;
70358
70579
  session.messageManager = createMessageManager(session, ctx);
70580
+ bridgeSessionRef.current = session;
70359
70581
  session.threadLogger?.logLifecycle("start", {
70360
70582
  username,
70361
70583
  workingDir: ctx.config.workingDir
@@ -70382,6 +70604,8 @@ async function startSession(options, username, displayName, replyToPostId, platf
70382
70604
  await logAndNotify(err, { action: "Start Claude", session });
70383
70605
  ctx.ops.stopTyping(session);
70384
70606
  session.messageManager?.dispose();
70607
+ session.decisionBridge?.close();
70608
+ session.decisionBridge = undefined;
70385
70609
  ctx.ops.emitSessionRemove(session.sessionId);
70386
70610
  mutableSessions(ctx).delete(session.sessionId);
70387
70611
  releaseAccountIfHeld(session, ctx);
@@ -70465,6 +70689,8 @@ Please start a new session.`), { action: "Post resume failure notification" });
70465
70689
  if (state.claudeAccountId && !claudeAccount) {
70466
70690
  log31.warn(`Persisted session referenced Claude account "${state.claudeAccountId}" ` + `which is no longer configured — resuming under default env`);
70467
70691
  }
70692
+ const resumeBridgeRef = {};
70693
+ const resumeBridge = await createSessionDecisionBridge(resumeBridgeRef);
70468
70694
  const cliOptions = {
70469
70695
  workingDir: state.workingDir,
70470
70696
  threadId: state.threadId,
@@ -70479,9 +70705,16 @@ Please start a new session.`), { action: "Post resume failure notification" });
70479
70705
  account: claudeAccount ? { id: claudeAccount.id, home: claudeAccount.home, apiKey: claudeAccount.apiKey } : undefined,
70480
70706
  uploadDir: getSessionUploadDir(platformId, state.threadId),
70481
70707
  outboundFiles: platformMcpConfig.outboundFiles,
70482
- sessionOwnerUsername: state.startedBy
70708
+ sessionOwnerUsername: state.startedBy,
70709
+ decisionBridgePath: resumeBridge?.path
70483
70710
  };
70484
- const claude = new ClaudeCli(cliOptions);
70711
+ let claude;
70712
+ try {
70713
+ claude = new ClaudeCli(cliOptions);
70714
+ } catch (err) {
70715
+ resumeBridge?.close();
70716
+ throw err;
70717
+ }
70485
70718
  const session = {
70486
70719
  platformId,
70487
70720
  threadId: state.threadId,
@@ -70527,6 +70760,7 @@ Please start a new session.`), { action: "Post resume failure notification" });
70527
70760
  enabled: ctx.config.threadLogsEnabled ?? true
70528
70761
  })
70529
70762
  };
70763
+ session.decisionBridge = resumeBridge ?? undefined;
70530
70764
  if (!session.worktreeInfo) {
70531
70765
  const detected = await detectWorktreeInfo(session.workingDir);
70532
70766
  if (detected) {
@@ -70539,6 +70773,7 @@ Please start a new session.`), { action: "Post resume failure notification" });
70539
70773
  }
70540
70774
  }
70541
70775
  session.messageManager = createMessageManager(session, ctx);
70776
+ resumeBridgeRef.current = session;
70542
70777
  await session.messageManager.restoreTaskListFromPersistence({
70543
70778
  tasksPostId: state.tasksPostId,
70544
70779
  lastTasksContent: state.lastTasksContent,
@@ -70594,6 +70829,8 @@ ${sessionFormatter.formatItalic("Reconnected to Claude session. You can continue
70594
70829
  } catch (err) {
70595
70830
  log31.error(`Failed to resume session ${shortId}`, err instanceof Error ? err : undefined);
70596
70831
  session.messageManager?.dispose();
70832
+ session.decisionBridge?.close();
70833
+ session.decisionBridge = undefined;
70597
70834
  ctx.ops.emitSessionRemove(sessionId);
70598
70835
  mutableSessions(ctx).delete(sessionId);
70599
70836
  ctx.state.sessionStore.remove(sessionId);
@@ -82588,7 +82825,7 @@ class UpdateInstaller {
82588
82825
  init_logger();
82589
82826
  import { spawn as spawn5 } from "child_process";
82590
82827
  import { existsSync as existsSync15, statSync as statSync4 } from "fs";
82591
- import { delimiter, join as join11 } from "path";
82828
+ import { delimiter, join as join12 } from "path";
82592
82829
  var log39 = createLogger("respawn");
82593
82830
  function decideRespawn(env5 = process.env, isTTY = !!process.stdout.isTTY) {
82594
82831
  if (env5.CLAUDE_THREADS_BIN) {
@@ -82614,16 +82851,16 @@ function resolveClaudeThreadsBin(_env = process.env, _existsSync = existsSync15,
82614
82851
  const path10 = _env.PATH || _env.Path || "";
82615
82852
  const dirs = path10.split(delimiter).filter(Boolean);
82616
82853
  const home = _env.HOME || _env.USERPROFILE;
82617
- const bunRoot = _env.BUN_INSTALL || (home ? join11(home, ".bun") : null);
82854
+ const bunRoot = _env.BUN_INSTALL || (home ? join12(home, ".bun") : null);
82618
82855
  if (bunRoot) {
82619
- const bunBin = join11(bunRoot, "bin");
82856
+ const bunBin = join12(bunRoot, "bin");
82620
82857
  if (!dirs.includes(bunBin)) {
82621
82858
  dirs.push(bunBin);
82622
82859
  }
82623
82860
  }
82624
82861
  for (const dir of dirs) {
82625
82862
  for (const name of names) {
82626
- const candidate = join11(dir, name);
82863
+ const candidate = join12(dir, name);
82627
82864
  if (_existsSync(candidate) && _isFileExecutable(candidate)) {
82628
82865
  return candidate;
82629
82866
  }
@@ -51569,6 +51569,8 @@ class MessageManager {
51569
51569
  emitSessionUpdateCallback;
51570
51570
  toolStartTimes = new Map;
51571
51571
  taskTracker = new TaskTracker;
51572
+ pendingBridgePlan = null;
51573
+ pendingBridgeQuestion = null;
51572
51574
  flushTimer = null;
51573
51575
  static DEFAULT_FLUSH_DELAY_MS = 500;
51574
51576
  flushDelayMs;
@@ -52030,9 +52032,66 @@ class MessageManager {
52030
52032
  logger.debug("Reaction not handled by any executor");
52031
52033
  return false;
52032
52034
  }
52035
+ handleBridgeRequest(request, signal) {
52036
+ if (request.kind === "plan_approval") {
52037
+ this.pendingBridgePlan?.resolve({ behavior: "deny", message: "Superseded by a newer plan" });
52038
+ return new Promise((resolve2) => {
52039
+ const pending = { resolve: resolve2, input: request.input };
52040
+ this.pendingBridgePlan = pending;
52041
+ signal?.addEventListener("abort", () => {
52042
+ if (this.pendingBridgePlan === pending)
52043
+ this.pendingBridgePlan = null;
52044
+ });
52045
+ });
52046
+ }
52047
+ if (request.kind === "question") {
52048
+ this.pendingBridgeQuestion?.resolve({ behavior: "deny", message: "Superseded by newer questions" });
52049
+ return new Promise((resolve2) => {
52050
+ const pending = { resolve: resolve2, input: request.input };
52051
+ this.pendingBridgeQuestion = pending;
52052
+ signal?.addEventListener("abort", () => {
52053
+ if (this.pendingBridgeQuestion === pending)
52054
+ this.pendingBridgeQuestion = null;
52055
+ });
52056
+ });
52057
+ }
52058
+ return Promise.resolve({ behavior: "deny", message: `Unknown bridge request kind` });
52059
+ }
52060
+ resolveBridgePlan(approved) {
52061
+ const pending = this.pendingBridgePlan;
52062
+ if (!pending)
52063
+ return false;
52064
+ this.pendingBridgePlan = null;
52065
+ pending.resolve(approved ? { behavior: "allow", updatedInput: pending.input } : { behavior: "deny", message: "The user rejected the plan." });
52066
+ return true;
52067
+ }
52068
+ resolveBridgeQuestion(answers) {
52069
+ const pending = this.pendingBridgeQuestion;
52070
+ if (!pending)
52071
+ return false;
52072
+ this.pendingBridgeQuestion = null;
52073
+ const questions = Array.isArray(pending.input.questions) ? pending.input.questions : [];
52074
+ const record4 = {};
52075
+ for (const a of answers) {
52076
+ const match = questions.find((q) => q.header === a.header);
52077
+ record4[match?.question ?? a.header] = a.answer;
52078
+ }
52079
+ pending.resolve({
52080
+ behavior: "allow",
52081
+ updatedInput: { ...pending.input, answers: record4 }
52082
+ });
52083
+ return true;
52084
+ }
52085
+ denyPendingBridgeRequests(reason) {
52086
+ this.pendingBridgePlan?.resolve({ behavior: "deny", message: reason });
52087
+ this.pendingBridgePlan = null;
52088
+ this.pendingBridgeQuestion?.resolve({ behavior: "deny", message: reason });
52089
+ this.pendingBridgeQuestion = null;
52090
+ }
52033
52091
  clearClaudeSessionState() {
52034
52092
  this.toolStartTimes.clear();
52035
52093
  this.taskTracker.clear();
52094
+ this.denyPendingBridgeRequests("Claude was restarted before a decision was made");
52036
52095
  }
52037
52096
  reset() {
52038
52097
  this.cancelScheduledFlush();
@@ -52048,6 +52107,7 @@ class MessageManager {
52048
52107
  this.systemExecutor.reset();
52049
52108
  }
52050
52109
  dispose() {
52110
+ this.denyPendingBridgeRequests("Session ended before a decision was made");
52051
52111
  this.cancelScheduledFlush();
52052
52112
  this.postTracker.clearSession(this.sessionId);
52053
52113
  this.events.removeAllListeners();
@@ -55751,6 +55811,148 @@ class UsernameAnonymizer {
55751
55811
  return anonymized;
55752
55812
  }
55753
55813
  }
55814
+ // src/mcp/decision-bridge.ts
55815
+ import { createServer, createConnection } from "node:net";
55816
+ import { tmpdir } from "node:os";
55817
+ import { join as join3 } from "node:path";
55818
+ import { randomUUID } from "node:crypto";
55819
+ import { mkdtempSync } from "node:fs";
55820
+ import { rm } from "node:fs/promises";
55821
+
55822
+ class BridgeUnavailableError extends Error {
55823
+ }
55824
+ function bridgeSocketPath() {
55825
+ if (process.platform === "win32") {
55826
+ return `\\\\.\\pipe\\ctb-${randomUUID()}`;
55827
+ }
55828
+ const dir = mkdtempSync(join3(tmpdir(), "ctb-"));
55829
+ return join3(dir, "b.sock");
55830
+ }
55831
+
55832
+ class DecisionBridgeServer {
55833
+ server;
55834
+ path;
55835
+ liveSockets = new Set;
55836
+ constructor(server, path) {
55837
+ this.server = server;
55838
+ this.path = path;
55839
+ }
55840
+ static async create(handler2) {
55841
+ const path = bridgeSocketPath();
55842
+ const liveSockets = new Set;
55843
+ const server = createServer((socket) => {
55844
+ liveSockets.add(socket);
55845
+ socket.once("close", () => liveSockets.delete(socket));
55846
+ let buffer = "";
55847
+ const aborter = new AbortController;
55848
+ let responded = false;
55849
+ socket.on("data", (chunk) => {
55850
+ buffer += chunk.toString("utf8");
55851
+ const newline = buffer.indexOf(`
55852
+ `);
55853
+ if (newline === -1)
55854
+ return;
55855
+ const line = buffer.slice(0, newline);
55856
+ buffer = "";
55857
+ let request;
55858
+ try {
55859
+ request = JSON.parse(line);
55860
+ } catch {
55861
+ responded = true;
55862
+ socket.end(JSON.stringify({ behavior: "deny", message: "Malformed bridge request" }) + `
55863
+ `);
55864
+ return;
55865
+ }
55866
+ handler2(request, aborter.signal).then((response) => {
55867
+ responded = true;
55868
+ socket.end(JSON.stringify(response) + `
55869
+ `);
55870
+ }).catch((err) => {
55871
+ responded = true;
55872
+ if (err instanceof BridgeUnavailableError) {
55873
+ socket.destroy();
55874
+ return;
55875
+ }
55876
+ socket.end(JSON.stringify({
55877
+ behavior: "deny",
55878
+ message: `Bridge handler failed: ${err instanceof Error ? err.message : String(err)}`
55879
+ }) + `
55880
+ `);
55881
+ });
55882
+ });
55883
+ socket.on("close", () => {
55884
+ if (!responded)
55885
+ aborter.abort();
55886
+ });
55887
+ socket.on("error", () => {});
55888
+ });
55889
+ try {
55890
+ await new Promise((resolve4, reject) => {
55891
+ server.once("error", reject);
55892
+ server.listen(path, () => {
55893
+ server.removeListener("error", reject);
55894
+ resolve4();
55895
+ });
55896
+ });
55897
+ } catch (err) {
55898
+ if (process.platform !== "win32") {
55899
+ await rm(join3(path, ".."), { recursive: true, force: true }).catch(() => {});
55900
+ }
55901
+ throw err;
55902
+ }
55903
+ const bridge = new DecisionBridgeServer(server, path);
55904
+ bridge.liveSockets = liveSockets;
55905
+ return bridge;
55906
+ }
55907
+ async close() {
55908
+ for (const socket of this.liveSockets)
55909
+ socket.destroy();
55910
+ await new Promise((resolve4) => this.server.close(() => resolve4()));
55911
+ if (process.platform !== "win32") {
55912
+ await rm(join3(this.path, ".."), { recursive: true, force: true }).catch(() => {});
55913
+ }
55914
+ }
55915
+ }
55916
+ function requestBridgeDecision(path, request, timeoutMs) {
55917
+ return new Promise((resolve4, reject) => {
55918
+ const socket = createConnection(path);
55919
+ let buffer = "";
55920
+ let settled = false;
55921
+ const fail = (err) => {
55922
+ if (settled)
55923
+ return;
55924
+ settled = true;
55925
+ clearTimeout(timer);
55926
+ socket.destroy();
55927
+ reject(err);
55928
+ };
55929
+ const timer = setTimeout(() => fail(new Error(`Bridge decision timed out after ${timeoutMs}ms`)), timeoutMs);
55930
+ socket.on("connect", () => {
55931
+ socket.write(JSON.stringify(request) + `
55932
+ `);
55933
+ });
55934
+ socket.on("data", (chunk) => {
55935
+ buffer += chunk.toString("utf8");
55936
+ const newline = buffer.indexOf(`
55937
+ `);
55938
+ if (newline === -1)
55939
+ return;
55940
+ if (settled)
55941
+ return;
55942
+ settled = true;
55943
+ clearTimeout(timer);
55944
+ socket.destroy();
55945
+ try {
55946
+ resolve4(JSON.parse(buffer.slice(0, newline)));
55947
+ } catch {
55948
+ reject(new Error("Malformed bridge response"));
55949
+ }
55950
+ });
55951
+ socket.on("error", (err) => fail(err));
55952
+ socket.on("close", () => fail(new Error("Bridge connection closed before a decision arrived")));
55953
+ });
55954
+ }
55955
+
55754
55956
  // src/utils/spawn.ts
55755
55957
  import { spawn as nodeSpawn, spawnSync as nodeSpawnSync } from "child_process";
55756
55958
  var isWindows = process.platform === "win32";
@@ -55769,8 +55971,8 @@ import { EventEmitter as EventEmitter2 } from "events";
55769
55971
  import { resolve as resolve4, dirname as dirname5 } from "path";
55770
55972
  import { fileURLToPath as fileURLToPath3 } from "url";
55771
55973
  import { existsSync as existsSync4, readFileSync as readFileSync3, watchFile, unwatchFile, unlinkSync, statSync, readdirSync, writeFileSync } from "fs";
55772
- import { tmpdir } from "os";
55773
- import { join as join3 } from "path";
55974
+ import { tmpdir as tmpdir2 } from "os";
55975
+ import { join as join4 } from "path";
55774
55976
 
55775
55977
  // src/mcp/outbound-env.ts
55776
55978
  var OUTBOUND_ENV = {
@@ -55864,11 +56066,11 @@ function parseRateLimitEvent(event, now = Date.now()) {
55864
56066
  var log8 = createLogger("claude");
55865
56067
  function cleanupBrowserBridgeSockets() {
55866
56068
  try {
55867
- const tempDir = tmpdir();
56069
+ const tempDir = tmpdir2();
55868
56070
  const files = readdirSync(tempDir);
55869
56071
  for (const file2 of files) {
55870
56072
  if (file2.startsWith("claude-mcp-browser-bridge-")) {
55871
- const filePath = join3(tempDir, file2);
56073
+ const filePath = join4(tempDir, file2);
55872
56074
  try {
55873
56075
  const stats = statSync(filePath);
55874
56076
  if (stats.isSocket()) {
@@ -55882,7 +56084,7 @@ function cleanupBrowserBridgeSockets() {
55882
56084
  log8.debug(`Browser bridge cleanup failed: ${err}`);
55883
56085
  }
55884
56086
  }
55885
- function buildClaudeChildEnv(parentEnv, account) {
56087
+ function buildClaudeChildEnv(parentEnv, account, opts) {
55886
56088
  const env = { ...parentEnv };
55887
56089
  if (env.MCP_CONNECTION_NONBLOCKING === undefined) {
55888
56090
  env.MCP_CONNECTION_NONBLOCKING = "true";
@@ -55890,6 +56092,9 @@ function buildClaudeChildEnv(parentEnv, account) {
55890
56092
  if (env.ENABLE_PROMPT_CACHING_1H === undefined) {
55891
56093
  env.ENABLE_PROMPT_CACHING_1H = "true";
55892
56094
  }
56095
+ if (opts?.decisionBridge && env.MCP_TOOL_TIMEOUT === undefined) {
56096
+ env.MCP_TOOL_TIMEOUT = "3600000";
56097
+ }
55893
56098
  if (account?.home) {
55894
56099
  env.HOME = account.home;
55895
56100
  env.USERPROFILE = account.home;
@@ -55913,8 +56118,8 @@ function materializeMcpConfig(config3, sessionId, opts = {}) {
55913
56118
  if (opts.inline) {
55914
56119
  return { mode: "inline", value: JSON.stringify(config3) };
55915
56120
  }
55916
- const dir = opts.tmpDirOverride ?? tmpdir();
55917
- const path = join3(dir, `claude-threads-mcp-${sessionId ?? process.pid}-${Date.now()}.json`);
56121
+ const dir = opts.tmpDirOverride ?? tmpdir2();
56122
+ const path = join4(dir, `claude-threads-mcp-${sessionId ?? process.pid}-${Date.now()}.json`);
55918
56123
  writeFileSync(path, JSON.stringify(config3), { mode: 384 });
55919
56124
  return { mode: "file", path };
55920
56125
  }
@@ -55938,6 +56143,12 @@ function buildPermissionArgs(opts) {
55938
56143
  PERMISSION_TIMEOUT_MS: String(opts.permissionTimeoutMs),
55939
56144
  SESSION_OWNER_USERNAME: opts.sessionOwnerUsername || ""
55940
56145
  };
56146
+ if (opts.decisionBridgePath) {
56147
+ mcpEnv.DECISION_BRIDGE_PATH = opts.decisionBridgePath;
56148
+ if (process.env.DECISION_BRIDGE_TIMEOUT_MS) {
56149
+ mcpEnv.DECISION_BRIDGE_TIMEOUT_MS = process.env.DECISION_BRIDGE_TIMEOUT_MS;
56150
+ }
56151
+ }
55941
56152
  if (opts.platformConfig.appToken) {
55942
56153
  mcpEnv.PLATFORM_APP_TOKEN = opts.platformConfig.appToken;
55943
56154
  }
@@ -56078,7 +56289,8 @@ class ClaudeCli extends EventEmitter2 {
56078
56289
  workingDir: this.options.workingDir,
56079
56290
  uploadDir: this.options.uploadDir,
56080
56291
  outboundFiles: this.options.outboundFiles,
56081
- sessionOwnerUsername: this.options.sessionOwnerUsername
56292
+ sessionOwnerUsername: this.options.sessionOwnerUsername,
56293
+ decisionBridgePath: this.options.decisionBridgePath
56082
56294
  });
56083
56295
  args.push(...permResult.args);
56084
56296
  this.mcpConfigTempFile = permResult.tempFile;
@@ -56089,7 +56301,7 @@ class ClaudeCli extends EventEmitter2 {
56089
56301
  args.push("--append-system-prompt", this.options.appendSystemPrompt);
56090
56302
  }
56091
56303
  if (this.options.sessionId) {
56092
- this.statusFilePath = join3(tmpdir(), `claude-threads-status-${this.options.sessionId}.json`);
56304
+ this.statusFilePath = join4(tmpdir2(), `claude-threads-status-${this.options.sessionId}.json`);
56093
56305
  const statusLineWriterPath = this.getStatusLineWriterPath();
56094
56306
  const statusLineSettings = {
56095
56307
  statusLine: {
@@ -56312,7 +56524,9 @@ class ClaudeCli extends EventEmitter2 {
56312
56524
  return true;
56313
56525
  }
56314
56526
  buildChildEnv() {
56315
- return buildClaudeChildEnv(process.env, this.options.account);
56527
+ return buildClaudeChildEnv(process.env, this.options.account, {
56528
+ decisionBridge: this.options.decisionBridgePath !== undefined
56529
+ });
56316
56530
  }
56317
56531
  getMcpServerPath() {
56318
56532
  const __filename2 = fileURLToPath3(import.meta.url);
@@ -57005,10 +57219,10 @@ init_emoji();
57005
57219
 
57006
57220
  // src/persistence/github-emails-store.ts
57007
57221
  import { homedir as homedir4 } from "os";
57008
- import { join as join5 } from "path";
57222
+ import { join as join6 } from "path";
57009
57223
  var log18 = createLogger("gh-emails");
57010
- var DEFAULT_CONFIG_DIR = join5(homedir4(), ".config", "claude-threads");
57011
- var DEFAULT_FILE = join5(DEFAULT_CONFIG_DIR, "github-emails.yaml");
57224
+ var DEFAULT_CONFIG_DIR = join6(homedir4(), ".config", "claude-threads");
57225
+ var DEFAULT_FILE = join6(DEFAULT_CONFIG_DIR, "github-emails.yaml");
57012
57226
 
57013
57227
  // src/operations/commands/handler.ts
57014
57228
  var log19 = createLogger("commands");
@@ -58380,6 +58594,8 @@ var PLATFORM_CHANNEL_ID = process.env.PLATFORM_CHANNEL_ID || "";
58380
58594
  var PLATFORM_THREAD_ID = process.env.PLATFORM_THREAD_ID || "";
58381
58595
  var ALLOWED_USERS = (process.env.ALLOWED_USERS || "").split(",").map((u) => u.trim()).filter((u) => u.length > 0);
58382
58596
  var PERMISSION_TIMEOUT_MS = parseInt(process.env.PERMISSION_TIMEOUT_MS || "120000", 10);
58597
+ var DECISION_BRIDGE_PATH = process.env.DECISION_BRIDGE_PATH || "";
58598
+ var DECISION_BRIDGE_TIMEOUT_MS = parseInt(process.env.DECISION_BRIDGE_TIMEOUT_MS || "3600000", 10);
58383
58599
  var SESSION_OWNER_USERNAME = process.env.SESSION_OWNER_USERNAME || "";
58384
58600
  var SESSION_WORKING_DIR = process.env[OUTBOUND_ENV.SESSION_WORKING_DIR] || "";
58385
58601
  var SESSION_UPLOAD_DIR = process.env[OUTBOUND_ENV.SESSION_UPLOAD_DIR] || "";
@@ -58442,6 +58658,21 @@ async function handlePermissionWith(toolName, toolInput, cfg) {
58442
58658
  mcpLogger.debug(`Skipping standard prompt for ${toolName} (handler enforces its own gate)`);
58443
58659
  return { behavior: "allow", updatedInput: toolInput };
58444
58660
  }
58661
+ const hasMultiSelect = toolName === "AskUserQuestion" && Array.isArray(toolInput.questions) && toolInput.questions.some((q) => q?.multiSelect === true);
58662
+ if ((toolName === "ExitPlanMode" || toolName === "AskUserQuestion") && cfg.decisionBridge && !hasMultiSelect) {
58663
+ const kind = toolName === "ExitPlanMode" ? "plan_approval" : "question";
58664
+ try {
58665
+ const decision = await (cfg.decisionBridge.request ?? requestBridgeDecision)(cfg.decisionBridge.path, { kind, toolName, input: toolInput }, cfg.decisionBridge.timeoutMs);
58666
+ mcpLogger.info(`${toolName} decided via bridge: ${decision.behavior}`);
58667
+ return {
58668
+ behavior: decision.behavior,
58669
+ updatedInput: decision.updatedInput ?? toolInput,
58670
+ message: decision.message
58671
+ };
58672
+ } catch (err) {
58673
+ mcpLogger.warn(`Decision bridge unavailable for ${toolName} (${err}) — legacy fallback`);
58674
+ }
58675
+ }
58445
58676
  if (toolName === "AskUserQuestion") {
58446
58677
  mcpLogger.debug("Auto-allowing AskUserQuestion (question UI is handled by the main bot)");
58447
58678
  return { behavior: "allow", updatedInput: toolInput };
@@ -58528,7 +58759,8 @@ async function handlePermission(toolName, toolInput) {
58528
58759
  getAllowAll: () => allowAllSession,
58529
58760
  setAllowAll: (v) => {
58530
58761
  allowAllSession = v;
58531
- }
58762
+ },
58763
+ decisionBridge: DECISION_BRIDGE_PATH ? { path: DECISION_BRIDGE_PATH, timeoutMs: DECISION_BRIDGE_TIMEOUT_MS } : undefined
58532
58764
  });
58533
58765
  }
58534
58766
  var permissionInputSchema = {
@@ -211,6 +211,7 @@ Exactly one of `home` or `apiKey` should be set per account. Persisted sessions
211
211
  | `SESSION_TIMEOUT_MS` | Idle timeout in milliseconds. Legacy fallback for `limits.sessionTimeoutMinutes`. | `1800000` (30 min) |
212
212
  | `DEBUG` | Enable verbose logging | - |
213
213
  | `CLAUDE_PATH` | Path to the `claude` binary. Overrides the PATH lookup and the common install locations. | `claude` (from PATH) |
214
+ | `DECISION_BRIDGE_TIMEOUT_MS` | How long the MCP permission server waits for a plan approval or question answer routed through the decision bridge (the bot's reaction UI) before falling back to the legacy behavior (generic prompt for plans, auto-allow for questions). | `3600000` (1 h) |
214
215
  | `CLAUDE_CODE_SUBPROCESS_ENV_SCRUB` | Strip `ANTHROPIC_*`, `AWS_*_TOKEN`, `CLAUDE_CODE_OAUTH_TOKEN`, `GOOGLE_APPLICATION_CREDENTIALS`, and similar from Bash, hook, and stdio-MCP subprocesses Claude spawns. Bot-specific vars like `PLATFORM_TOKEN` pass through. **Also forces permission mode to `default`**; `--dangerously-skip-permissions` will be rejected. Requires Claude CLI 2.1.83+. | - |
215
216
  | `CLAUDE_THREADS_SESSIONS_PATH` | Override the path to the persisted sessions file (default `~/.config/claude-threads/sessions.json`). | - |
216
217
  | `CLAUDE_THREADS_GITHUB_EMAILS_PATH` | Override the path to the GitHub-emails store used for commit attribution. | - |
@@ -218,15 +219,16 @@ Exactly one of `home` or `apiKey` should be set per account. Persisted sessions
218
219
 
219
220
  ### Forwarded to Claude CLI automatically
220
221
 
221
- The bot sets two tuning flags on the Claude child process when they aren't
222
+ The bot sets these tuning flags on the Claude child process when they aren't
222
223
  already present in the bot's environment:
223
224
 
224
225
  | Variable | Effect | Requires |
225
226
  |----------|--------|----------|
226
227
  | `MCP_CONNECTION_NONBLOCKING=true` | Caps `--mcp-config` connects at 5s so a slow MCP server never delays startup | Claude CLI 2.1.89+ |
227
228
  | `ENABLE_PROMPT_CACHING_1H=true` | Opts into 1-hour prompt cache TTL, cutting re-caching cost on long-lived threads | Claude CLI 2.1.108+ |
229
+ | `MCP_TOOL_TIMEOUT=3600000` | Only set when the session has a decision bridge. Without it the CLI abandons a pending MCP permission call after ~2 minutes — far too short for plan approvals and question answers that wait on a human reaction. One hour matches the bridge's own `DECISION_BRIDGE_TIMEOUT_MS` default. Verified against CLI 2.1.223. | — |
228
230
 
229
- Export either with a different value in the bot's own env to disable.
231
+ Export any of them with a different value in the bot's own env to override.
230
232
 
231
233
  ## CLI Options
232
234
 
package/docs/MCP-TOOLS.md CHANGED
@@ -15,7 +15,7 @@ The permission handler the Claude CLI calls before running a tool that needs app
15
15
  | `tool_name` | string | Name of the tool requesting permission. |
16
16
  | `input` | object | The tool's input parameters, shown to the user in the prompt. |
17
17
 
18
- **Guardrail:** Only reactions from users on the session allowlist count. The bot's own reactions (the option emoji it adds) are ignored. If no reaction arrives within `limits.permissionTimeoutSeconds` (default 120), the request is denied.
18
+ **Guardrail:** Only reactions from users on the session allowlist count. The bot's own reactions (the option emoji it adds) are ignored. If no reaction arrives within `limits.permissionTimeoutSeconds` (default 120), the request is denied. Exception: on modern CLIs, `ExitPlanMode` and `AskUserQuestion` don't post a generic prompt at all — they are forwarded over the session's decision bridge to the bot's plan/question reaction UI and wait up to `DECISION_BRIDGE_TIMEOUT_MS` (default 1 hour) for a decision there. To make that hour real, the bot spawns the CLI with `MCP_TOOL_TIMEOUT=3600000` whenever a bridge is configured — without it the CLI abandons a pending permission call after ~2 minutes (verified against CLI 2.1.223). Multi-select questions and any bridge failure fall back to the legacy behavior: generic prompt for plans, auto-allow for questions.
19
19
 
20
20
  ## send_file
21
21
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-threads",
3
- "version": "1.21.1",
3
+ "version": "1.21.2",
4
4
  "description": "Run Claude Code from Slack or Mattermost. Sessions stream live into threads where your whole team can watch and steer.",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",