@integrity-labs/agt-cli 0.15.8 → 0.15.10

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.
@@ -13887,9 +13887,22 @@ var StdioServerTransport = class {
13887
13887
  };
13888
13888
 
13889
13889
  // src/slack-channel.ts
13890
- import { readFileSync as readFileSync2, statSync, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2, chmodSync } from "fs";
13890
+ import {
13891
+ chmodSync,
13892
+ createWriteStream,
13893
+ existsSync,
13894
+ mkdirSync as mkdirSync2,
13895
+ readFileSync as readFileSync2,
13896
+ readdirSync,
13897
+ renameSync,
13898
+ statSync,
13899
+ unlinkSync,
13900
+ watch,
13901
+ writeFileSync as writeFileSync2
13902
+ } from "fs";
13891
13903
  import { basename, join as join2, resolve as resolve2 } from "path";
13892
13904
  import { homedir as homedir2 } from "os";
13905
+ import { createHash, randomUUID } from "crypto";
13893
13906
 
13894
13907
  // src/slack-thread-store.ts
13895
13908
  import { mkdirSync, readFileSync, writeFileSync } from "fs";
@@ -14158,6 +14171,239 @@ var THREAD_AUTO_FOLLOW = process.env.SLACK_THREAD_AUTO_FOLLOW ?? "off";
14158
14171
  var CHANNEL_RESPONSE_MODE = parseResponseMode(process.env.SLACK_CHANNEL_RESPONSE_MODE);
14159
14172
  var RESPONSE_TIMEOUT_MS = 3e5;
14160
14173
  var pendingMessages = /* @__PURE__ */ new Map();
14174
+ var SLACK_AGENT_DIR = AGENT_CODE_NAME ? join2(homedir2(), ".augmented", AGENT_CODE_NAME) : null;
14175
+ var SLACK_PENDING_INBOUND_DIR = SLACK_AGENT_DIR ? join2(SLACK_AGENT_DIR, "slack-pending-inbound") : null;
14176
+ var SLACK_RECOVERY_OUTBOX_DIR = SLACK_AGENT_DIR ? join2(SLACK_AGENT_DIR, "slack-recovery-outbox") : null;
14177
+ var SLACK_MAX_RECOVERY_ATTEMPTS = 3;
14178
+ function redactSlackId(id) {
14179
+ if (!id) return "<none>";
14180
+ return createHash("sha256").update(id).digest("hex").slice(0, 8);
14181
+ }
14182
+ function safeSlackMarkerName(channel, threadTs, messageTs) {
14183
+ const safe = (s) => s.replace(/[^A-Za-z0-9_-]/g, "_");
14184
+ return `${safe(channel)}__${safe(threadTs)}__${safe(messageTs)}.json`;
14185
+ }
14186
+ function slackPendingInboundPath(channel, threadTs, messageTs) {
14187
+ if (!SLACK_PENDING_INBOUND_DIR) return null;
14188
+ return join2(SLACK_PENDING_INBOUND_DIR, safeSlackMarkerName(channel, threadTs, messageTs));
14189
+ }
14190
+ function writeSlackPendingInboundMarker(channel, threadTs, messageTs) {
14191
+ const path = slackPendingInboundPath(channel, threadTs, messageTs);
14192
+ if (!path || !SLACK_PENDING_INBOUND_DIR) return;
14193
+ const marker = {
14194
+ channel,
14195
+ thread_ts: threadTs,
14196
+ message_ts: messageTs,
14197
+ received_at: (/* @__PURE__ */ new Date()).toISOString()
14198
+ };
14199
+ try {
14200
+ mkdirSync2(SLACK_PENDING_INBOUND_DIR, { recursive: true, mode: 448 });
14201
+ writeFileSync2(path, JSON.stringify(marker), { mode: 384 });
14202
+ } catch (err) {
14203
+ process.stderr.write(
14204
+ `slack-channel(${AGENT_CODE_NAME}): pending-inbound marker write failed: ${err.message}
14205
+ `
14206
+ );
14207
+ }
14208
+ }
14209
+ function clearSlackPendingInboundMarker(channel, threadTs, messageTs) {
14210
+ const path = slackPendingInboundPath(channel, threadTs, messageTs);
14211
+ if (!path) return;
14212
+ try {
14213
+ if (existsSync(path)) unlinkSync(path);
14214
+ } catch {
14215
+ }
14216
+ }
14217
+ function clearAllSlackPendingMarkersForThread(channel, threadTs) {
14218
+ if (!SLACK_PENDING_INBOUND_DIR) return;
14219
+ const safeChan = channel.replace(/[^A-Za-z0-9_-]/g, "_");
14220
+ const safeThread = threadTs.replace(/[^A-Za-z0-9_-]/g, "_");
14221
+ const prefix = `${safeChan}__${safeThread}__`;
14222
+ try {
14223
+ for (const f of readdirSync(SLACK_PENDING_INBOUND_DIR)) {
14224
+ if (!f.startsWith(prefix) || !f.endsWith(".json")) continue;
14225
+ try {
14226
+ unlinkSync(join2(SLACK_PENDING_INBOUND_DIR, f));
14227
+ } catch {
14228
+ }
14229
+ }
14230
+ } catch {
14231
+ }
14232
+ }
14233
+ function slackNextRetryName(filename) {
14234
+ const match = filename.match(/^(.*?)(?:\.retry-(\d+))?\.json$/);
14235
+ if (!match) return null;
14236
+ const base = match[1];
14237
+ const prev = match[2] ? parseInt(match[2], 10) : 0;
14238
+ const attempt = prev + 1;
14239
+ if (attempt >= SLACK_MAX_RECOVERY_ATTEMPTS) {
14240
+ return { next: `${base}.retry-${attempt}.poison.json`, attempt };
14241
+ }
14242
+ return { next: `${base}.retry-${attempt}.json`, attempt };
14243
+ }
14244
+ async function processSlackRecoveryOutboxFile(filename) {
14245
+ if (!SLACK_RECOVERY_OUTBOX_DIR) return;
14246
+ if (filename.endsWith(".poison.json") || filename.endsWith(".tmp")) return;
14247
+ const fullPath = join2(SLACK_RECOVERY_OUTBOX_DIR, filename);
14248
+ let payload;
14249
+ try {
14250
+ payload = JSON.parse(readFileSync2(fullPath, "utf-8"));
14251
+ } catch (err) {
14252
+ process.stderr.write(
14253
+ `slack-channel(${AGENT_CODE_NAME}): recovery outbox parse failed (${filename}): ${err.message}
14254
+ `
14255
+ );
14256
+ try {
14257
+ renameSync(fullPath, `${fullPath}.parse-error.poison`);
14258
+ } catch {
14259
+ }
14260
+ return;
14261
+ }
14262
+ if (!payload.channel || !payload.text) {
14263
+ process.stderr.write(
14264
+ `slack-channel(${AGENT_CODE_NAME}): recovery outbox malformed (${filename}): missing channel or text
14265
+ `
14266
+ );
14267
+ try {
14268
+ renameSync(fullPath, `${fullPath}.malformed.poison`);
14269
+ } catch {
14270
+ }
14271
+ return;
14272
+ }
14273
+ const text = `[recovered reply] ${payload.text}`;
14274
+ let sendSucceeded = false;
14275
+ const controller = new AbortController();
14276
+ const timeoutId = setTimeout(() => controller.abort(), 15e3);
14277
+ try {
14278
+ const res = await fetch("https://slack.com/api/chat.postMessage", {
14279
+ method: "POST",
14280
+ signal: controller.signal,
14281
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${BOT_TOKEN}` },
14282
+ body: JSON.stringify({
14283
+ channel: payload.channel,
14284
+ text,
14285
+ ...payload.thread_ts ? { thread_ts: payload.thread_ts } : {}
14286
+ })
14287
+ });
14288
+ const data = await res.json();
14289
+ if (data.ok) {
14290
+ sendSucceeded = true;
14291
+ process.stderr.write(
14292
+ `slack-channel(${AGENT_CODE_NAME}): ghost-reply recovery sent (channel=${redactSlackId(payload.channel)} thread=${redactSlackId(payload.thread_ts)})
14293
+ `
14294
+ );
14295
+ if (payload.thread_ts) {
14296
+ clearAllSlackPendingMarkersForThread(payload.channel, payload.thread_ts);
14297
+ }
14298
+ } else {
14299
+ process.stderr.write(
14300
+ `slack-channel(${AGENT_CODE_NAME}): ghost-reply recovery failed (channel=${redactSlackId(payload.channel)}): ${data.error ?? "unknown"}
14301
+ `
14302
+ );
14303
+ }
14304
+ } catch (err) {
14305
+ const msg = err.name === "AbortError" ? "timed out after 15s" : err.message;
14306
+ process.stderr.write(
14307
+ `slack-channel(${AGENT_CODE_NAME}): ghost-reply recovery error (channel=${redactSlackId(payload.channel)}): ${msg}
14308
+ `
14309
+ );
14310
+ } finally {
14311
+ clearTimeout(timeoutId);
14312
+ }
14313
+ if (sendSucceeded) {
14314
+ try {
14315
+ unlinkSync(fullPath);
14316
+ } catch {
14317
+ }
14318
+ return;
14319
+ }
14320
+ const next = slackNextRetryName(filename);
14321
+ if (next) {
14322
+ try {
14323
+ renameSync(fullPath, join2(SLACK_RECOVERY_OUTBOX_DIR, next.next));
14324
+ if (next.attempt >= SLACK_MAX_RECOVERY_ATTEMPTS) {
14325
+ process.stderr.write(
14326
+ `slack-channel(${AGENT_CODE_NAME}): ghost-reply recovery exhausted retries \u2014 moved to ${next.next}
14327
+ `
14328
+ );
14329
+ }
14330
+ } catch {
14331
+ }
14332
+ }
14333
+ }
14334
+ function isFirstAttemptSlackOutboxFile(filename) {
14335
+ if (!filename.endsWith(".json")) return false;
14336
+ if (filename.includes(".retry-")) return false;
14337
+ if (filename.endsWith(".poison.json") || filename.endsWith(".tmp")) return false;
14338
+ if (filename.startsWith(".")) return false;
14339
+ return true;
14340
+ }
14341
+ var SLACK_RETRY_SCAN_INTERVAL_MS = 6e4;
14342
+ var SLACK_RETRY_BACKOFF_BASE_MS = 6e4;
14343
+ function shouldRetrySlackNow(filename, mtimeMs) {
14344
+ const match = filename.match(/\.retry-(\d+)\.json$/);
14345
+ if (!match) return false;
14346
+ const attempt = parseInt(match[1], 10);
14347
+ return Date.now() - mtimeMs >= attempt * SLACK_RETRY_BACKOFF_BASE_MS;
14348
+ }
14349
+ function scanSlackRecoveryRetries() {
14350
+ if (!SLACK_RECOVERY_OUTBOX_DIR) return;
14351
+ let entries;
14352
+ try {
14353
+ entries = readdirSync(SLACK_RECOVERY_OUTBOX_DIR);
14354
+ } catch {
14355
+ return;
14356
+ }
14357
+ for (const f of entries) {
14358
+ if (!f.endsWith(".json")) continue;
14359
+ if (!f.includes(".retry-") || f.endsWith(".poison.json")) continue;
14360
+ let mtimeMs;
14361
+ try {
14362
+ mtimeMs = statSync(join2(SLACK_RECOVERY_OUTBOX_DIR, f)).mtimeMs;
14363
+ } catch {
14364
+ continue;
14365
+ }
14366
+ if (shouldRetrySlackNow(f, mtimeMs)) {
14367
+ void processSlackRecoveryOutboxFile(f);
14368
+ }
14369
+ }
14370
+ }
14371
+ function startSlackRecoveryOutboxWatcher() {
14372
+ if (!SLACK_RECOVERY_OUTBOX_DIR) return;
14373
+ try {
14374
+ mkdirSync2(SLACK_RECOVERY_OUTBOX_DIR, { recursive: true, mode: 448 });
14375
+ } catch (err) {
14376
+ process.stderr.write(
14377
+ `slack-channel(${AGENT_CODE_NAME}): recovery outbox mkdir failed: ${err.message}
14378
+ `
14379
+ );
14380
+ return;
14381
+ }
14382
+ try {
14383
+ for (const f of readdirSync(SLACK_RECOVERY_OUTBOX_DIR)) {
14384
+ if (isFirstAttemptSlackOutboxFile(f)) void processSlackRecoveryOutboxFile(f);
14385
+ }
14386
+ } catch {
14387
+ }
14388
+ try {
14389
+ const watcher = watch(SLACK_RECOVERY_OUTBOX_DIR, (event, filename) => {
14390
+ if (event !== "rename" || !filename) return;
14391
+ if (!isFirstAttemptSlackOutboxFile(filename)) return;
14392
+ if (existsSync(join2(SLACK_RECOVERY_OUTBOX_DIR, filename))) {
14393
+ void processSlackRecoveryOutboxFile(filename);
14394
+ }
14395
+ });
14396
+ watcher.unref?.();
14397
+ } catch (err) {
14398
+ process.stderr.write(
14399
+ `slack-channel(${AGENT_CODE_NAME}): recovery outbox watch failed: ${err.message}
14400
+ `
14401
+ );
14402
+ }
14403
+ const retryTimer = setInterval(scanSlackRecoveryRetries, SLACK_RETRY_SCAN_INTERVAL_MS);
14404
+ retryTimer.unref?.();
14405
+ }
14406
+ startSlackRecoveryOutboxWatcher();
14161
14407
  function trackPendingMessage(channel, threadTs, messageTs) {
14162
14408
  const key = `${channel}:${threadTs}:${messageTs}`;
14163
14409
  const timer = setTimeout(async () => {
@@ -14182,10 +14428,12 @@ function trackPendingMessage(channel, threadTs, messageTs) {
14182
14428
  });
14183
14429
  } catch {
14184
14430
  }
14185
- process.stderr.write(`slack-channel: Response timeout for message ${messageTs} in ${channel}
14431
+ process.stderr.write(`slack-channel: Response timeout for message ${redactSlackId(messageTs)} in ${redactSlackId(channel)}
14186
14432
  `);
14433
+ clearSlackPendingInboundMarker(channel, threadTs, messageTs);
14187
14434
  }, RESPONSE_TIMEOUT_MS);
14188
14435
  pendingMessages.set(key, timer);
14436
+ writeSlackPendingInboundMarker(channel, threadTs, messageTs);
14189
14437
  }
14190
14438
  function clearPendingMessage(channel, threadTs) {
14191
14439
  for (const [key, timer] of pendingMessages) {
@@ -14194,6 +14442,90 @@ function clearPendingMessage(channel, threadTs) {
14194
14442
  pendingMessages.delete(key);
14195
14443
  }
14196
14444
  }
14445
+ clearAllSlackPendingMarkersForThread(channel, threadTs);
14446
+ }
14447
+ var RESTART_FLAGS_DIR = join2(homedir2(), ".augmented", "restart-flags");
14448
+ function hashChannelId(id) {
14449
+ return createHash("sha256").update(id).digest("hex").slice(0, 8);
14450
+ }
14451
+ async function postSlackMessage(body) {
14452
+ try {
14453
+ const res = await fetch("https://slack.com/api/chat.postMessage", {
14454
+ method: "POST",
14455
+ headers: {
14456
+ "Content-Type": "application/json; charset=utf-8",
14457
+ Authorization: `Bearer ${BOT_TOKEN}`
14458
+ },
14459
+ body: JSON.stringify(body),
14460
+ // Hard deadline so a hung Slack API can't stall the live Socket Mode
14461
+ // event loop or the manager's restart ack path. Matches the timeout
14462
+ // used elsewhere in this file for chat.postMessage.
14463
+ signal: AbortSignal.timeout(SLACK_DOWNLOAD_TIMEOUT_MS)
14464
+ });
14465
+ return await res.json();
14466
+ } catch (err) {
14467
+ const isTimeout = err.name === "TimeoutError" || err.name === "AbortError";
14468
+ return { ok: false, error: isTimeout ? "timeout" : err.message };
14469
+ }
14470
+ }
14471
+ async function handleRestartCommand(opts) {
14472
+ const codeName = AGENT_CODE_NAME ?? "unknown";
14473
+ try {
14474
+ if (!existsSync(RESTART_FLAGS_DIR)) {
14475
+ mkdirSync2(RESTART_FLAGS_DIR, { recursive: true });
14476
+ }
14477
+ const flagPath = join2(RESTART_FLAGS_DIR, `${codeName}.flag`);
14478
+ const flag = {
14479
+ codeName,
14480
+ source: "slack",
14481
+ ts: Date.now(),
14482
+ reply: {
14483
+ channel: opts.channel,
14484
+ ...opts.threadTs ? { thread_ts: opts.threadTs } : {},
14485
+ message_ts: opts.ts
14486
+ }
14487
+ };
14488
+ const tmpPath = `${flagPath}.${process.pid}.${randomUUID()}.tmp`;
14489
+ writeFileSync2(tmpPath, JSON.stringify(flag) + "\n", "utf8");
14490
+ renameSync(tmpPath, flagPath);
14491
+ process.stderr.write(
14492
+ `slack-channel(${codeName}): /restart queued from channel ${hashChannelId(opts.channel)}
14493
+ `
14494
+ );
14495
+ const ack = await postSlackMessage({
14496
+ channel: opts.channel,
14497
+ text: `\u23F3 Restart queued for \`${codeName}\`. The session will reconnect in a few seconds.`,
14498
+ ...opts.threadTs ? { thread_ts: opts.threadTs } : {}
14499
+ });
14500
+ if (!ack.ok) {
14501
+ process.stderr.write(
14502
+ `slack-channel(${codeName}): /restart ack send failed: ${ack.error ?? "unknown"}
14503
+ `
14504
+ );
14505
+ }
14506
+ } catch (err) {
14507
+ process.stderr.write(
14508
+ `slack-channel(${codeName}): /restart flag write failed: ${redactAugmentedPaths(err.message)}
14509
+ `
14510
+ );
14511
+ await postSlackMessage({
14512
+ channel: opts.channel,
14513
+ text: `\u274C Failed to queue restart for ${codeName}. Please try again in a few seconds.`,
14514
+ ...opts.threadTs ? { thread_ts: opts.threadTs } : {}
14515
+ });
14516
+ }
14517
+ }
14518
+ async function denyUnauthorizedRestart(opts) {
14519
+ const codeName = AGENT_CODE_NAME ?? "unknown";
14520
+ process.stderr.write(
14521
+ `slack-channel(${codeName}): /restart denied \u2014 sender not in SLACK_ALLOWED_USERS, channel ${hashChannelId(opts.channel)}
14522
+ `
14523
+ );
14524
+ await postSlackMessage({
14525
+ channel: opts.channel,
14526
+ text: `\u{1F6AB} \`/restart\` denied \u2014 your Slack user is not in the allowlist for \`${codeName}\`.`,
14527
+ ...opts.threadTs ? { thread_ts: opts.threadTs } : {}
14528
+ });
14197
14529
  }
14198
14530
  var trackedThreads = /* @__PURE__ */ new Map();
14199
14531
  var THREAD_STORE_PATH = resolveThreadStorePath();
@@ -14233,6 +14565,28 @@ if (!BOT_TOKEN || !APP_TOKEN) {
14233
14565
  );
14234
14566
  process.exit(1);
14235
14567
  }
14568
+ var slackStderrLogStream = null;
14569
+ if (AGENT_CODE_NAME) {
14570
+ try {
14571
+ const logDir = join2(homedir2(), ".augmented", AGENT_CODE_NAME);
14572
+ mkdirSync2(logDir, { recursive: true });
14573
+ slackStderrLogStream = createWriteStream(join2(logDir, "slack-channel-stderr.log"), {
14574
+ flags: "a",
14575
+ mode: 384
14576
+ });
14577
+ const origWrite = process.stderr.write.bind(process.stderr);
14578
+ process.stderr.write = (chunk, ...rest) => {
14579
+ try {
14580
+ const buf = typeof chunk === "string" ? chunk : String(chunk);
14581
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
14582
+ slackStderrLogStream?.write(`[${ts}] ${buf}${buf.endsWith("\n") ? "" : "\n"}`);
14583
+ } catch {
14584
+ }
14585
+ return origWrite(chunk, ...rest);
14586
+ };
14587
+ } catch {
14588
+ }
14589
+ }
14236
14590
  async function getBotUserId() {
14237
14591
  try {
14238
14592
  const res = await fetch("https://slack.com/api/auth.test", {
@@ -14268,16 +14622,17 @@ var mcp = new Server(
14268
14622
  // Attachments rules live near the top because they get chopped otherwise
14269
14623
  // and the agent silently loses attachment-handling guidance.
14270
14624
  instructions: [
14271
- 'Messages from Slack arrive as <channel source="slack" user="<slack-id>" user_name="<display-name>" channel="..." thread_ts="...">.',
14625
+ // Highest-priority lines first Claude Code truncates this string at
14626
+ // 2048 chars, so anything appended late silently disappears.
14627
+ "CRITICAL: every response to a Slack <channel> tag MUST go through slack.reply. Text in your session WITHOUT a slack.reply call never reaches the user \u2014 the message dies inside the agent process.",
14628
+ 'Messages from Slack arrive as <channel source="slack" user="<slack-id>" user_name="<display-name>" channel="..." thread_ts="...">. Pass channel + thread_ts from the tag to slack.reply; always include thread_ts on threaded replies.',
14272
14629
  selfIdentityInstruction,
14273
- "Inbound attachments: the <channel> tag's `files` attribute is a JSON-serialised array \u2014 JSON.parse it before iterating. If a file entry has a `path`, the image is ALREADY DOWNLOADED locally \u2014 Read that path directly, do NOT call slack.download_attachment. That tool is only for entries with `file_id` but NO `path` (PDF, docx, csv, etc.): pass file_id + channel verbatim (never paraphrase), then Read the returned path. Single-image messages also get a top-level `image_path` convenience attribute; multi-image messages omit it. Never tell the user about internal file-handling failures that don't affect the answer.",
14274
- "Reply via slack.reply passing channel and thread_ts from the tag. Always include thread_ts on threaded replies so the response lands in the same thread.",
14275
- "Address users by user_name, never by raw user ID. In multi-participant threads the CURRENT speaker is the one on the latest <channel> tag \u2014 don't conflate with earlier participants.",
14276
- "Mentioned in a channel \u2192 respond in that thread. DM \u2192 respond directly.",
14277
- 'auto_followed="true" messages are from threads you previously joined: reply only if you have something useful to add. Skip if the conversation moved on, the message is directed at someone else, or your input would not add value.',
14278
- "Reaction taxonomy (use slack.react sparingly \u2014 most messages need a reply, not a reaction): \u{1F440} = ack/working on it (already auto-added on inbound, do not duplicate); \u2705 = action completed successfully. NEVER react to signal failure \u2014 users misread emoji reactions and don't know why something failed. If an action failed, send a slack.reply with one sentence explaining what went wrong (no stack traces, no secrets) so the user understands.",
14279
- `When a message in a thread is not addressed to you (different @-mention, conversation between others, auto_followed catch-up): SILENTLY SKIP \u2014 no reaction, no reply, do nothing. Do NOT post a "this wasn't for me" message and do NOT add a failure reaction; either is misleading.`,
14280
- "To deliver a file, save it under your project dir and call slack.upload_file with path, channel, thread_ts."
14630
+ "Inbound attachments: <channel> `files` is a JSON-serialised array \u2014 JSON.parse it. If an entry has `path`, the image is already downloaded \u2014 Read it directly, do NOT call slack.download_attachment. Use that tool only for entries with `file_id` but NO `path` (PDF, docx, csv): pass file_id + channel verbatim, then Read the returned path. Single-image messages also get a top-level `image_path`. Don't surface internal file-handling errors that don't affect the answer.",
14631
+ "Address users by user_name, never by raw user ID. In multi-participant threads the CURRENT speaker is the one on the latest <channel> tag.",
14632
+ 'Mentioned in a channel \u2192 respond in that thread. DM \u2192 respond directly. auto_followed="true" \u2192 only reply if you have something useful to add.',
14633
+ "Reaction taxonomy (use slack.react sparingly \u2014 prefer a reply): \u{1F440} = ack (already auto-added on inbound, do not duplicate); \u2705 = success. NEVER react to signal failure \u2014 users can't tell why something failed from an emoji. On failure, slack.reply with one sentence explaining what went wrong (no stack traces, no secrets).",
14634
+ `When a thread message is NOT addressed to you (different @-mention, side conversation, auto_followed catch-up): SILENTLY SKIP \u2014 no reaction, no reply, no "this wasn't for me" message.`,
14635
+ "To deliver a file: save under your project dir, call slack.upload_file with path + channel + thread_ts."
14281
14636
  ].join(" ")
14282
14637
  }
14283
14638
  );
@@ -14876,6 +15231,28 @@ async function connectSocketMode() {
14876
15231
  const isThreadReply = !!evt.thread_ts && evt.thread_ts !== evt.ts;
14877
15232
  const trackTs = evt.thread_ts ?? evt.ts ?? "";
14878
15233
  const threadKey = buildThreadKey(evt.channel, trackTs);
15234
+ const rawText = evt.text ?? "";
15235
+ const strippedText = rawText.replace(/^\s*<@[^>]+>\s*/, "").trim();
15236
+ const isRestartCommand = strippedText === "/restart" || strippedText.startsWith("/restart ");
15237
+ if (isRestartCommand) {
15238
+ const senderAllowed = ALLOWED_USERS.size === 0 || evt.user != null && ALLOWED_USERS.has(evt.user);
15239
+ if (!senderAllowed) {
15240
+ await denyUnauthorizedRestart({
15241
+ channel: evt.channel ?? "",
15242
+ threadTs: evt.thread_ts
15243
+ });
15244
+ return;
15245
+ }
15246
+ await handleRestartCommand({
15247
+ channel: evt.channel ?? "",
15248
+ // Only carry thread_ts when the originating message was already
15249
+ // threaded; a top-level command acks in-channel rather than
15250
+ // synthesising a brand-new thread (CodeRabbit feedback).
15251
+ threadTs: evt.thread_ts,
15252
+ ts: evt.ts ?? ""
15253
+ });
15254
+ return;
15255
+ }
14879
15256
  if (ALLOWED_USERS.size > 0 && evt.user && !ALLOWED_USERS.has(evt.user)) return;
14880
15257
  if (evt.type === "app_mention") {
14881
15258
  rememberThread(evt.channel, trackTs, "mentioned");