@integrity-labs/agt-cli 0.14.10 → 0.14.14

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.
@@ -2980,7 +2980,7 @@ var require_compile = __commonJS({
2980
2980
  const schOrFunc = root.refs[ref];
2981
2981
  if (schOrFunc)
2982
2982
  return schOrFunc;
2983
- let _sch = resolve2.call(this, root, ref);
2983
+ let _sch = resolve3.call(this, root, ref);
2984
2984
  if (_sch === void 0) {
2985
2985
  const schema = (_a = root.localRefs) === null || _a === void 0 ? void 0 : _a[ref];
2986
2986
  const { schemaId } = this.opts;
@@ -3007,7 +3007,7 @@ var require_compile = __commonJS({
3007
3007
  function sameSchemaEnv(s1, s2) {
3008
3008
  return s1.schema === s2.schema && s1.root === s2.root && s1.baseId === s2.baseId;
3009
3009
  }
3010
- function resolve2(root, ref) {
3010
+ function resolve3(root, ref) {
3011
3011
  let sch;
3012
3012
  while (typeof (sch = this.refs[ref]) == "string")
3013
3013
  ref = sch;
@@ -3582,7 +3582,7 @@ var require_fast_uri = __commonJS({
3582
3582
  }
3583
3583
  return uri;
3584
3584
  }
3585
- function resolve2(baseURI, relativeURI, options) {
3585
+ function resolve3(baseURI, relativeURI, options) {
3586
3586
  const schemelessOptions = options ? Object.assign({ scheme: "null" }, options) : { scheme: "null" };
3587
3587
  const resolved = resolveComponent(parse3(baseURI, schemelessOptions), parse3(relativeURI, schemelessOptions), schemelessOptions, true);
3588
3588
  schemelessOptions.skipEscape = true;
@@ -3809,7 +3809,7 @@ var require_fast_uri = __commonJS({
3809
3809
  var fastUri = {
3810
3810
  SCHEMES,
3811
3811
  normalize,
3812
- resolve: resolve2,
3812
+ resolve: resolve3,
3813
3813
  resolveComponent,
3814
3814
  equal,
3815
3815
  serialize,
@@ -12619,7 +12619,7 @@ var Protocol = class {
12619
12619
  return;
12620
12620
  }
12621
12621
  const pollInterval = task2.pollInterval ?? this._options?.defaultTaskPollInterval ?? 1e3;
12622
- await new Promise((resolve2) => setTimeout(resolve2, pollInterval));
12622
+ await new Promise((resolve3) => setTimeout(resolve3, pollInterval));
12623
12623
  options?.signal?.throwIfAborted();
12624
12624
  }
12625
12625
  } catch (error2) {
@@ -12636,7 +12636,7 @@ var Protocol = class {
12636
12636
  */
12637
12637
  request(request, resultSchema, options) {
12638
12638
  const { relatedRequestId, resumptionToken, onresumptiontoken, task, relatedTask } = options ?? {};
12639
- return new Promise((resolve2, reject) => {
12639
+ return new Promise((resolve3, reject) => {
12640
12640
  const earlyReject = (error2) => {
12641
12641
  reject(error2);
12642
12642
  };
@@ -12714,7 +12714,7 @@ var Protocol = class {
12714
12714
  if (!parseResult.success) {
12715
12715
  reject(parseResult.error);
12716
12716
  } else {
12717
- resolve2(parseResult.data);
12717
+ resolve3(parseResult.data);
12718
12718
  }
12719
12719
  } catch (error2) {
12720
12720
  reject(error2);
@@ -12975,12 +12975,12 @@ var Protocol = class {
12975
12975
  }
12976
12976
  } catch {
12977
12977
  }
12978
- return new Promise((resolve2, reject) => {
12978
+ return new Promise((resolve3, reject) => {
12979
12979
  if (signal.aborted) {
12980
12980
  reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled"));
12981
12981
  return;
12982
12982
  }
12983
- const timeoutId = setTimeout(resolve2, interval);
12983
+ const timeoutId = setTimeout(resolve3, interval);
12984
12984
  signal.addEventListener("abort", () => {
12985
12985
  clearTimeout(timeoutId);
12986
12986
  reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled"));
@@ -13769,6 +13769,31 @@ var Server = class extends Protocol {
13769
13769
  }
13770
13770
  };
13771
13771
 
13772
+ // src/slack-inbound-filter.ts
13773
+ var ALLOWED_MESSAGE_SUBTYPES = /* @__PURE__ */ new Set([
13774
+ void 0,
13775
+ "file_share",
13776
+ "thread_broadcast"
13777
+ ]);
13778
+ function decideSlackMessageForward(evt) {
13779
+ if (evt.type !== "message") {
13780
+ return { forward: true };
13781
+ }
13782
+ if (!ALLOWED_MESSAGE_SUBTYPES.has(evt.subtype)) {
13783
+ return { forward: false, reason: "subtype" };
13784
+ }
13785
+ if (!evt.user) {
13786
+ return { forward: false, reason: "no_user" };
13787
+ }
13788
+ const hasText = typeof evt.text === "string" && evt.text.trim().length > 0;
13789
+ const hasFiles = Array.isArray(evt.files) && evt.files.length > 0;
13790
+ const hasBlocks = Array.isArray(evt.blocks) && evt.blocks.length > 0;
13791
+ if (!hasText && !hasFiles && !hasBlocks) {
13792
+ return { forward: false, reason: "empty" };
13793
+ }
13794
+ return { forward: true };
13795
+ }
13796
+
13772
13797
  // ../../node_modules/.pnpm/@modelcontextprotocol+sdk@1.27.1_zod@3.25.76/node_modules/@modelcontextprotocol/sdk/dist/esm/server/stdio.js
13773
13798
  import process2 from "process";
13774
13799
 
@@ -13850,21 +13875,242 @@ var StdioServerTransport = class {
13850
13875
  this.onclose?.();
13851
13876
  }
13852
13877
  send(message) {
13853
- return new Promise((resolve2) => {
13878
+ return new Promise((resolve3) => {
13854
13879
  const json = serializeMessage(message);
13855
13880
  if (this._stdout.write(json)) {
13856
- resolve2();
13881
+ resolve3();
13857
13882
  } else {
13858
- this._stdout.once("drain", resolve2);
13883
+ this._stdout.once("drain", resolve3);
13859
13884
  }
13860
13885
  });
13861
13886
  }
13862
13887
  };
13863
13888
 
13864
13889
  // src/slack-channel.ts
13865
- import { readFileSync, statSync } from "fs";
13866
- import { basename, resolve } from "path";
13890
+ import { readFileSync as readFileSync2, statSync, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2, chmodSync } from "fs";
13891
+ import { basename, join as join2, resolve as resolve2 } from "path";
13892
+ import { homedir as homedir2 } from "os";
13893
+
13894
+ // src/slack-thread-store.ts
13895
+ import { mkdirSync, readFileSync, writeFileSync } from "fs";
13896
+ import { dirname } from "path";
13897
+ var FILE_VERSION = 1;
13898
+ var DEFAULT_TTL_DAYS = 30;
13899
+ var DEFAULT_MIN_INTERVAL_MS = 1e3;
13900
+ function loadThreadStore(filePath, opts = {}) {
13901
+ const now = opts.now ?? /* @__PURE__ */ new Date();
13902
+ const ttlDays = opts.ttlDays ?? DEFAULT_TTL_DAYS;
13903
+ const ttlMs = ttlDays * 24 * 60 * 60 * 1e3;
13904
+ let raw;
13905
+ try {
13906
+ raw = readFileSync(filePath, "utf-8");
13907
+ } catch {
13908
+ return { threads: /* @__PURE__ */ new Map(), pruned: 0 };
13909
+ }
13910
+ let parsed;
13911
+ try {
13912
+ parsed = JSON.parse(raw);
13913
+ } catch (err) {
13914
+ opts.onError?.(
13915
+ `slack-thread-store: malformed JSON at ${filePath} (${err.message}) \u2014 starting with empty tracking state`
13916
+ );
13917
+ return { threads: /* @__PURE__ */ new Map(), pruned: 0 };
13918
+ }
13919
+ if (!isThreadStoreShape(parsed)) {
13920
+ opts.onError?.(
13921
+ `slack-thread-store: unexpected shape at ${filePath} \u2014 starting with empty tracking state`
13922
+ );
13923
+ return { threads: /* @__PURE__ */ new Map(), pruned: 0 };
13924
+ }
13925
+ const threads = /* @__PURE__ */ new Map();
13926
+ let pruned = 0;
13927
+ for (const [key, entry] of Object.entries(parsed.threads)) {
13928
+ if (!isThreadEntry(entry)) continue;
13929
+ const seenTs = Date.parse(entry.last_seen_at);
13930
+ if (Number.isFinite(seenTs) && now.getTime() - seenTs > ttlMs) {
13931
+ pruned++;
13932
+ continue;
13933
+ }
13934
+ threads.set(key, { involvement: entry.involvement, last_seen_at: entry.last_seen_at });
13935
+ }
13936
+ return { threads, pruned };
13937
+ }
13938
+ function serializeThreadStore(threads) {
13939
+ const out = { version: FILE_VERSION, threads: {} };
13940
+ for (const [key, entry] of threads) out.threads[key] = entry;
13941
+ return JSON.stringify(out, null, 2);
13942
+ }
13943
+ function createThreadPersister(opts) {
13944
+ const interval = opts.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS;
13945
+ let lastWriteAt = 0;
13946
+ let pendingTimer = null;
13947
+ let pendingSnapshot = null;
13948
+ const writeNow = (snap) => {
13949
+ try {
13950
+ mkdirSync(dirname(opts.filePath), { recursive: true });
13951
+ writeFileSync(opts.filePath, serializeThreadStore(snap), "utf-8");
13952
+ lastWriteAt = Date.now();
13953
+ } catch (err) {
13954
+ opts.onError?.(
13955
+ `slack-thread-store: failed to persist ${opts.filePath}: ${err.message}`
13956
+ );
13957
+ }
13958
+ };
13959
+ return {
13960
+ schedule(threads) {
13961
+ pendingSnapshot = new Map(threads);
13962
+ const sinceLast = Date.now() - lastWriteAt;
13963
+ if (sinceLast >= interval) {
13964
+ const snap = pendingSnapshot;
13965
+ pendingSnapshot = null;
13966
+ writeNow(snap);
13967
+ return;
13968
+ }
13969
+ if (pendingTimer) return;
13970
+ pendingTimer = setTimeout(() => {
13971
+ pendingTimer = null;
13972
+ const snap = pendingSnapshot;
13973
+ pendingSnapshot = null;
13974
+ if (snap) writeNow(snap);
13975
+ }, interval - sinceLast);
13976
+ pendingTimer.unref?.();
13977
+ },
13978
+ flush(threads) {
13979
+ if (pendingTimer) {
13980
+ clearTimeout(pendingTimer);
13981
+ pendingTimer = null;
13982
+ pendingSnapshot = null;
13983
+ }
13984
+ writeNow(new Map(threads));
13985
+ },
13986
+ dispose() {
13987
+ if (pendingTimer) clearTimeout(pendingTimer);
13988
+ pendingTimer = null;
13989
+ pendingSnapshot = null;
13990
+ }
13991
+ };
13992
+ }
13993
+ function isThreadStoreShape(value) {
13994
+ return typeof value === "object" && value !== null && "threads" in value && typeof value.threads === "object" && value.threads !== null;
13995
+ }
13996
+ function isThreadEntry(value) {
13997
+ if (!value || typeof value !== "object") return false;
13998
+ const v = value;
13999
+ return (v.involvement === "started" || v.involvement === "mentioned") && typeof v.last_seen_at === "string";
14000
+ }
14001
+
14002
+ // src/safe-async.ts
14003
+ async function runOrRetry(fn, opts) {
14004
+ try {
14005
+ await fn();
14006
+ } catch (err) {
14007
+ opts.onError(err);
14008
+ if (!opts.shouldRetry()) return;
14009
+ const schedule = opts.scheduleRetry ?? ((f, ms) => {
14010
+ const t = setTimeout(f, ms);
14011
+ t.unref?.();
14012
+ });
14013
+ schedule(() => {
14014
+ void runOrRetry(fn, opts);
14015
+ }, opts.retryDelayMs);
14016
+ }
14017
+ }
14018
+
14019
+ // src/channel-attachments.ts
13867
14020
  import { homedir } from "os";
14021
+ import { join, resolve, sep } from "path";
14022
+ function resolveChannelInboundDir(codeName, channelSlug) {
14023
+ const base = join(homedir(), ".augmented");
14024
+ const allowedSegment = /^[A-Za-z0-9_-]+$/;
14025
+ if (!allowedSegment.test(codeName) || !allowedSegment.test(channelSlug)) {
14026
+ throw new Error(
14027
+ `Refusing to resolve inbound dir \u2014 invalid codeName/channelSlug (got ${JSON.stringify({ codeName, channelSlug })})`
14028
+ );
14029
+ }
14030
+ const candidate = resolve(base, codeName, channelSlug);
14031
+ if (!isPathInside(candidate, base)) {
14032
+ throw new Error(`Refusing inbound dir outside ${base} (got ${candidate})`);
14033
+ }
14034
+ return candidate;
14035
+ }
14036
+ function classifyMimetype(mimetype) {
14037
+ if (typeof mimetype === "string" && mimetype.startsWith("image/")) return "image";
14038
+ return "attachment";
14039
+ }
14040
+ function buildSafeInboundPath(root, fileId, mimetype) {
14041
+ const safeId = fileId.replace(/[^A-Za-z0-9_-]/g, "");
14042
+ if (!safeId) throw new Error("Refusing to build inbound path for empty/invalid file id");
14043
+ const ext = extensionForMimetype(mimetype);
14044
+ const candidate = resolve(root, `${safeId}${ext}`);
14045
+ if (!isPathInside(candidate, root)) {
14046
+ throw new Error(`Refusing to build inbound path outside agent dir (got ${candidate})`);
14047
+ }
14048
+ return candidate;
14049
+ }
14050
+ function extensionForMimetype(mimetype) {
14051
+ if (!mimetype) return ".bin";
14052
+ switch (mimetype) {
14053
+ // Images
14054
+ case "image/png":
14055
+ return ".png";
14056
+ case "image/jpeg":
14057
+ case "image/jpg":
14058
+ return ".jpg";
14059
+ case "image/gif":
14060
+ return ".gif";
14061
+ case "image/webp":
14062
+ return ".webp";
14063
+ case "image/svg+xml":
14064
+ return ".svg";
14065
+ // Docs
14066
+ case "application/pdf":
14067
+ return ".pdf";
14068
+ case "text/plain":
14069
+ return ".txt";
14070
+ case "text/csv":
14071
+ return ".csv";
14072
+ case "application/json":
14073
+ return ".json";
14074
+ case "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
14075
+ return ".docx";
14076
+ case "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":
14077
+ return ".xlsx";
14078
+ // Audio (Telegram voice notes are typically audio/ogg; regular
14079
+ // audio messages are audio/mpeg)
14080
+ case "audio/ogg":
14081
+ return ".ogg";
14082
+ case "audio/mpeg":
14083
+ return ".mp3";
14084
+ case "audio/mp4":
14085
+ return ".m4a";
14086
+ // Video
14087
+ case "video/mp4":
14088
+ return ".mp4";
14089
+ case "video/quicktime":
14090
+ return ".mov";
14091
+ default:
14092
+ return ".bin";
14093
+ }
14094
+ }
14095
+ function isPathInside(target, root) {
14096
+ const normalizedRoot = resolve(root) + sep;
14097
+ const normalizedTarget = resolve(target);
14098
+ return normalizedTarget === resolve(root) || normalizedTarget.startsWith(normalizedRoot);
14099
+ }
14100
+
14101
+ // src/slack-inbound-files.ts
14102
+ function classifySlackFile(file) {
14103
+ if (!file || typeof file.id !== "string" || !file.id) return null;
14104
+ const mimetype = typeof file.mimetype === "string" ? file.mimetype : "application/octet-stream";
14105
+ const filename = typeof file.name === "string" && file.name ? file.name : file.id;
14106
+ return { kind: classifyMimetype(mimetype), id: file.id, filename, mimetype };
14107
+ }
14108
+ function resolveSlackInboundDir(codeName) {
14109
+ return resolveChannelInboundDir(codeName, "slack-inbound");
14110
+ }
14111
+ function buildSafeInboundPath2(codeName, fileId, mimetype) {
14112
+ return buildSafeInboundPath(resolveSlackInboundDir(codeName), fileId, mimetype);
14113
+ }
13868
14114
 
13869
14115
  // src/slack-response-mode.ts
13870
14116
  var MODES = [
@@ -13946,6 +14192,37 @@ function clearPendingMessage(channel, threadTs) {
13946
14192
  }
13947
14193
  }
13948
14194
  var trackedThreads = /* @__PURE__ */ new Map();
14195
+ var THREAD_STORE_PATH = resolveThreadStorePath();
14196
+ var THREAD_STORE_TTL_DAYS = parseTtlDays(process.env.SLACK_THREAD_FOLLOW_TTL_DAYS);
14197
+ var threadPersister = null;
14198
+ function resolveThreadStorePath() {
14199
+ if (!AGENT_CODE_NAME) return null;
14200
+ return join2(homedir2(), ".augmented", AGENT_CODE_NAME, "slack-tracked-threads.json");
14201
+ }
14202
+ function parseTtlDays(raw) {
14203
+ if (!raw) return void 0;
14204
+ const n = Number(raw);
14205
+ return Number.isFinite(n) && n > 0 ? n : void 0;
14206
+ }
14207
+ function buildThreadKey(channel, threadTs) {
14208
+ if (!channel || !threadTs) return null;
14209
+ return `${channel}:${threadTs}`;
14210
+ }
14211
+ function rememberThread(channel, threadTs, involvement, opts = {}) {
14212
+ const key = buildThreadKey(channel, threadTs);
14213
+ if (!key) return;
14214
+ const existing = trackedThreads.get(key);
14215
+ const next = {
14216
+ involvement: opts.override || !existing ? involvement : existing.involvement,
14217
+ last_seen_at: (/* @__PURE__ */ new Date()).toISOString()
14218
+ };
14219
+ trackedThreads.set(key, next);
14220
+ if (isShuttingDown) {
14221
+ threadPersister?.flush(trackedThreads);
14222
+ } else {
14223
+ threadPersister?.schedule(trackedThreads);
14224
+ }
14225
+ }
13949
14226
  if (!BOT_TOKEN || !APP_TOKEN) {
13950
14227
  console.error(
13951
14228
  "slack-channel: Missing SLACK_BOT_TOKEN or SLACK_APP_TOKEN. Cannot start."
@@ -13959,18 +14236,17 @@ var mcp = new Server(
13959
14236
  experimental: { "claude/channel": {} },
13960
14237
  tools: {}
13961
14238
  },
14239
+ // NOTE: Claude Code truncates MCP server instructions at 2048 chars.
14240
+ // Attachments rules live near the top because they get chopped otherwise
14241
+ // and the agent silently loses attachment-handling guidance.
13962
14242
  instructions: [
13963
14243
  'Messages from Slack arrive as <channel source="slack" user="<slack-id>" user_name="<display-name>" channel="..." thread_ts="...">.',
13964
- "Address users by their user_name (display name), NEVER by the raw user ID.",
13965
- "In threads with multiple participants, the CURRENT speaker is always the one in the <channel> tag on the latest message \u2014 do not conflate them with other participants who spoke earlier.",
13966
- "Reply using the slack.reply tool, passing channel and thread_ts from the tag.",
13967
- "For threaded replies, always include thread_ts so the response appears in the same thread.",
13968
- "When someone @mentions you in a channel, respond helpfully in that thread.",
13969
- "For DMs, respond directly.",
13970
- "Messages with auto_followed=true are from threads you previously participated in.",
13971
- "For auto-followed messages, use relevance judgment: only reply if you have something useful to add.",
13972
- "Do NOT reply to every auto-followed message \u2014 skip if the conversation has moved on, the message is directed at someone else, or your input would not add value.",
13973
- "To deliver a file (PDF, image, report), save it under your project dir and call slack.upload_file with path, channel, and thread_ts."
14244
+ "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.",
14245
+ "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.",
14246
+ "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.",
14247
+ "Mentioned in a channel \u2192 respond in that thread. DM \u2192 respond directly.",
14248
+ '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.',
14249
+ "To deliver a file, save it under your project dir and call slack.upload_file with path, channel, thread_ts."
13974
14250
  ].join(" ")
13975
14251
  }
13976
14252
  );
@@ -14030,6 +14306,24 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
14030
14306
  required: ["channel", "thread_ts"]
14031
14307
  }
14032
14308
  },
14309
+ {
14310
+ name: "slack.download_attachment",
14311
+ description: "Download an inbound Slack attachment on demand. Use this for non-image files (PDFs, docx, csv, etc.) that arrived via a <channel> tag \u2014 look for `file_id` in the `files` meta. Requires the `channel` attribute from the same <channel> tag so the tool can verify the attachment was shared in the current conversation. Returns the local path where the file was written (inside ~/.augmented/<codeName>/slack-inbound/). Images are auto-downloaded on receipt and don't need this tool.",
14312
+ inputSchema: {
14313
+ type: "object",
14314
+ properties: {
14315
+ file_id: {
14316
+ type: "string",
14317
+ description: "Slack file id (from the `file_id` field on the `files` meta array of the inbound <channel> tag)"
14318
+ },
14319
+ channel: {
14320
+ type: "string",
14321
+ description: "Slack channel ID from the `channel` attribute of the same inbound <channel> tag that surfaced the file. The tool only downloads attachments that were shared in this channel."
14322
+ }
14323
+ },
14324
+ required: ["file_id", "channel"]
14325
+ }
14326
+ },
14033
14327
  {
14034
14328
  name: "slack.upload_file",
14035
14329
  description: "Upload a file from the agent project dir to a Slack channel or thread. Use this for PDFs, images, reports, and any binary deliverables the agent generates. Path must be inside the agent's project directory.",
@@ -14094,12 +14388,8 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
14094
14388
  };
14095
14389
  }
14096
14390
  if (THREAD_AUTO_FOLLOW !== "off") {
14097
- const trackKey = thread_ts ?? data.ts ?? "";
14098
- if (trackKey) {
14099
- if (!trackedThreads.has(trackKey)) {
14100
- trackedThreads.set(trackKey, thread_ts ? "mentioned" : "started");
14101
- }
14102
- }
14391
+ const trackTs = thread_ts ?? data.ts ?? void 0;
14392
+ rememberThread(channel, trackTs, thread_ts ? "mentioned" : "started");
14103
14393
  }
14104
14394
  return { content: [{ type: "text", text: "sent" }] };
14105
14395
  } catch (err) {
@@ -14206,8 +14496,8 @@ ${formatted}` : "Thread is empty or not found."
14206
14496
  isError: true
14207
14497
  };
14208
14498
  }
14209
- const allowedRoot = resolve(homedir(), ".augmented", AGENT_CODE_NAME, "project") + "/";
14210
- const resolvedPath = resolve(path);
14499
+ const allowedRoot = resolve2(homedir2(), ".augmented", AGENT_CODE_NAME, "project") + "/";
14500
+ const resolvedPath = resolve2(path);
14211
14501
  if (!resolvedPath.startsWith(allowedRoot)) {
14212
14502
  return {
14213
14503
  content: [{
@@ -14228,7 +14518,7 @@ ${formatted}` : "Thread is empty or not found."
14228
14518
  };
14229
14519
  }
14230
14520
  size = stat.size;
14231
- bytes = readFileSync(resolvedPath);
14521
+ bytes = readFileSync2(resolvedPath);
14232
14522
  } catch (err) {
14233
14523
  return {
14234
14524
  content: [{ type: "text", text: `Failed to read file: ${err.message}` }],
@@ -14297,8 +14587,186 @@ ${formatted}` : "Thread is empty or not found."
14297
14587
  };
14298
14588
  }
14299
14589
  }
14590
+ if (name === "slack.download_attachment") {
14591
+ const { file_id, channel } = args;
14592
+ if (typeof file_id !== "string" || !file_id.trim()) {
14593
+ return {
14594
+ content: [{ type: "text", text: "slack.download_attachment requires a string `file_id`." }],
14595
+ isError: true
14596
+ };
14597
+ }
14598
+ if (typeof channel !== "string" || !channel.trim()) {
14599
+ return {
14600
+ content: [{ type: "text", text: "slack.download_attachment requires a string `channel` (from the inbound <channel> tag)." }],
14601
+ isError: true
14602
+ };
14603
+ }
14604
+ if (!AGENT_CODE_NAME) {
14605
+ return {
14606
+ content: [{
14607
+ type: "text",
14608
+ text: "Download refused: AGT_AGENT_CODE_NAME not set in the MCP server env. Reprovision the slack channel so the download tool can scope paths."
14609
+ }],
14610
+ isError: true
14611
+ };
14612
+ }
14613
+ const trimmedFileId = file_id.trim();
14614
+ const trimmedChannel = channel.trim();
14615
+ if (!isDownloadableFileId(trimmedFileId, trimmedChannel)) {
14616
+ return {
14617
+ content: [{
14618
+ type: "text",
14619
+ text: "Download refused: file_id was not surfaced in a recent inbound `files` meta entry for this channel. You can only download attachments the originating conversation has shared with you."
14620
+ }],
14621
+ isError: true
14622
+ };
14623
+ }
14624
+ try {
14625
+ const savedPath = await downloadSlackFile(trimmedFileId, AGENT_CODE_NAME);
14626
+ return { content: [{ type: "text", text: `Downloaded to ${savedPath}` }] };
14627
+ } catch (err) {
14628
+ return {
14629
+ content: [{
14630
+ type: "text",
14631
+ text: `Failed to download attachment: ${redactAugmentedPaths(err.message)}`
14632
+ }],
14633
+ isError: true
14634
+ };
14635
+ }
14636
+ }
14300
14637
  throw new Error(`Unknown tool: ${name}`);
14301
14638
  });
14639
+ var MAX_INBOUND_FILE_BYTES = 25 * 1024 * 1024;
14640
+ var SLACK_DOWNLOAD_TIMEOUT_MS = 15e3;
14641
+ var DOWNLOAD_ALLOWLIST_TTL_MS = 60 * 60 * 1e3;
14642
+ var downloadAllowlist = /* @__PURE__ */ new Map();
14643
+ function pruneExpiredAllowlist(now) {
14644
+ for (const [fileId, entry] of downloadAllowlist) {
14645
+ if (now > entry.expiresAt) downloadAllowlist.delete(fileId);
14646
+ }
14647
+ }
14648
+ function registerDownloadableFileId(fileId, channel) {
14649
+ const now = Date.now();
14650
+ pruneExpiredAllowlist(now);
14651
+ downloadAllowlist.set(fileId, {
14652
+ channel,
14653
+ expiresAt: now + DOWNLOAD_ALLOWLIST_TTL_MS
14654
+ });
14655
+ }
14656
+ function isDownloadableFileId(fileId, channel) {
14657
+ const entry = downloadAllowlist.get(fileId);
14658
+ if (!entry) return false;
14659
+ if (Date.now() > entry.expiresAt) {
14660
+ downloadAllowlist.delete(fileId);
14661
+ return false;
14662
+ }
14663
+ return entry.channel === channel;
14664
+ }
14665
+ function redactAugmentedPaths(msg) {
14666
+ return msg.replaceAll(
14667
+ new RegExp(`${homedir2().replace(/[.*+?^${}()|[\\]\\\\]/g, "\\\\$&")}/\\.augmented/[^\\s'"\`]*`, "g"),
14668
+ "<augmented-path>"
14669
+ );
14670
+ }
14671
+ async function downloadSlackFile(fileId, codeName) {
14672
+ const infoRes = await fetch(
14673
+ `https://slack.com/api/files.info?file=${encodeURIComponent(fileId)}`,
14674
+ {
14675
+ headers: { Authorization: `Bearer ${BOT_TOKEN}` },
14676
+ signal: AbortSignal.timeout(SLACK_DOWNLOAD_TIMEOUT_MS)
14677
+ }
14678
+ );
14679
+ const infoData = await infoRes.json();
14680
+ if (!infoData.ok || !infoData.file) {
14681
+ throw new Error(`files.info failed: ${infoData.error ?? "unknown"}`);
14682
+ }
14683
+ const file = infoData.file;
14684
+ if (typeof file.size === "number" && file.size > MAX_INBOUND_FILE_BYTES) {
14685
+ throw new Error(
14686
+ `file ${fileId} is ${file.size} bytes \u2014 exceeds the ${MAX_INBOUND_FILE_BYTES}-byte inbound cap`
14687
+ );
14688
+ }
14689
+ const url = file.url_private_download ?? file.url_private;
14690
+ if (!url) throw new Error(`file ${fileId} has no url_private`);
14691
+ const res = await fetch(url, {
14692
+ headers: { Authorization: `Bearer ${BOT_TOKEN}` },
14693
+ signal: AbortSignal.timeout(SLACK_DOWNLOAD_TIMEOUT_MS)
14694
+ });
14695
+ if (!res.ok) {
14696
+ throw new Error(`download failed: HTTP ${res.status}`);
14697
+ }
14698
+ const contentLengthHeader = res.headers.get("content-length");
14699
+ if (contentLengthHeader) {
14700
+ const contentLength = Number(contentLengthHeader);
14701
+ if (Number.isFinite(contentLength) && contentLength > MAX_INBOUND_FILE_BYTES) {
14702
+ throw new Error(
14703
+ `file ${fileId} transport reports ${contentLength} bytes \u2014 exceeds the ${MAX_INBOUND_FILE_BYTES}-byte inbound cap`
14704
+ );
14705
+ }
14706
+ }
14707
+ const bytes = Buffer.from(await res.arrayBuffer());
14708
+ if (bytes.byteLength > MAX_INBOUND_FILE_BYTES) {
14709
+ throw new Error(
14710
+ `file ${fileId} downloaded ${bytes.byteLength} bytes \u2014 exceeds the ${MAX_INBOUND_FILE_BYTES}-byte inbound cap`
14711
+ );
14712
+ }
14713
+ const savedPath = buildSafeInboundPath2(codeName, fileId, file.mimetype);
14714
+ const dir = resolveSlackInboundDir(codeName);
14715
+ if (!isPathInside(savedPath, dir)) {
14716
+ throw new Error(`refusing to write ${savedPath} outside ${dir}`);
14717
+ }
14718
+ mkdirSync2(dir, { recursive: true });
14719
+ writeFileSync2(savedPath, bytes, { mode: 384 });
14720
+ try {
14721
+ chmodSync(savedPath, 384);
14722
+ } catch {
14723
+ }
14724
+ return savedPath;
14725
+ }
14726
+ async function buildInboundFileMeta(rawFiles, codeName, channel) {
14727
+ if (!Array.isArray(rawFiles) || rawFiles.length === 0) return [];
14728
+ if (!channel) return [];
14729
+ const out = [];
14730
+ for (const raw of rawFiles) {
14731
+ const classified = classifySlackFile(
14732
+ raw ?? null
14733
+ );
14734
+ if (!classified) continue;
14735
+ if (classified.kind === "image" && codeName) {
14736
+ try {
14737
+ const path = await downloadSlackFile(classified.id, codeName);
14738
+ out.push({
14739
+ kind: "image",
14740
+ file_id: classified.id,
14741
+ filename: classified.filename,
14742
+ mimetype: classified.mimetype,
14743
+ path
14744
+ });
14745
+ } catch (err) {
14746
+ process.stderr.write(
14747
+ `slack-channel: image auto-download failed for ${classified.id}: ${redactAugmentedPaths(err.message)}
14748
+ `
14749
+ );
14750
+ registerDownloadableFileId(classified.id, channel);
14751
+ out.push({
14752
+ kind: "attachment",
14753
+ file_id: classified.id,
14754
+ filename: classified.filename,
14755
+ mimetype: classified.mimetype
14756
+ });
14757
+ }
14758
+ } else {
14759
+ registerDownloadableFileId(classified.id, channel);
14760
+ out.push({
14761
+ kind: "attachment",
14762
+ file_id: classified.id,
14763
+ filename: classified.filename,
14764
+ mimetype: classified.mimetype
14765
+ });
14766
+ }
14767
+ }
14768
+ return out;
14769
+ }
14302
14770
  await mcp.connect(new StdioServerTransport());
14303
14771
  var botUserId = null;
14304
14772
  async function getBotUserId() {
@@ -14333,6 +14801,16 @@ async function resolveUserName(userId) {
14333
14801
  }
14334
14802
  var currentWs = null;
14335
14803
  var isShuttingDown = false;
14804
+ function connectSocketModeSafely() {
14805
+ void runOrRetry(connectSocketMode, {
14806
+ retryDelayMs: 1e4,
14807
+ onError: (err) => process.stderr.write(
14808
+ `slack-channel: Socket Mode connect failed: ${err.message} \u2014 retrying in 10s
14809
+ `
14810
+ ),
14811
+ shouldRetry: () => !isShuttingDown
14812
+ });
14813
+ }
14336
14814
  async function connectSocketMode() {
14337
14815
  if (isShuttingDown) return;
14338
14816
  const res = await fetch("https://slack.com/api/apps.connections.open", {
@@ -14344,7 +14822,7 @@ async function connectSocketMode() {
14344
14822
  if (!data.ok || !data.url) {
14345
14823
  process.stderr.write(`slack-channel: Socket Mode connection failed: ${data.error}
14346
14824
  `);
14347
- if (!isShuttingDown) setTimeout(connectSocketMode, 1e4);
14825
+ if (!isShuttingDown) setTimeout(connectSocketModeSafely, 1e4).unref?.();
14348
14826
  return;
14349
14827
  }
14350
14828
  const ws = new WebSocket(data.url);
@@ -14369,23 +14847,30 @@ async function connectSocketMode() {
14369
14847
  const evt = msg.payload.event;
14370
14848
  if (evt.user === botUserId) return;
14371
14849
  if (evt.type !== "app_mention" && evt.type !== "message") return;
14850
+ const filterDecision = decideSlackMessageForward(evt);
14851
+ if (!filterDecision.forward) {
14852
+ process.stderr.write(`slack-channel: dropped message event (reason=${filterDecision.reason}, subtype=${evt.subtype ?? "none"}, ts=${evt.ts ?? "n/a"})
14853
+ `);
14854
+ return;
14855
+ }
14372
14856
  const isDirectMessage = evt.channel?.startsWith("D");
14373
14857
  const isThreadReply = !!evt.thread_ts && evt.thread_ts !== evt.ts;
14374
- const threadKey = evt.thread_ts ?? evt.ts ?? "";
14375
- if (evt.type === "app_mention" && threadKey) {
14376
- trackedThreads.set(threadKey, trackedThreads.get(threadKey) ?? "mentioned");
14858
+ const trackTs = evt.thread_ts ?? evt.ts ?? "";
14859
+ const threadKey = buildThreadKey(evt.channel, trackTs);
14860
+ if (ALLOWED_USERS.size > 0 && evt.user && !ALLOWED_USERS.has(evt.user)) return;
14861
+ if (evt.type === "app_mention") {
14862
+ rememberThread(evt.channel, trackTs, "mentioned");
14377
14863
  }
14378
14864
  if (evt.type === "message" && evt.channel && !isDirectMessage) {
14379
14865
  if (isThreadReply) {
14380
14866
  if (THREAD_AUTO_FOLLOW === "off") return;
14381
- const threadInvolvement = trackedThreads.get(threadKey);
14867
+ const threadInvolvement = threadKey ? trackedThreads.get(threadKey)?.involvement : void 0;
14382
14868
  if (!threadInvolvement) return;
14383
14869
  if (THREAD_AUTO_FOLLOW === "started" && threadInvolvement !== "started") return;
14384
14870
  } else {
14385
14871
  if (!channelMessageShouldRespond(evt.text ?? "", CHANNEL_RESPONSE_MODE)) return;
14386
14872
  }
14387
14873
  }
14388
- if (ALLOWED_USERS.size > 0 && evt.user && !ALLOWED_USERS.has(evt.user)) return;
14389
14874
  const text = evt.text ?? "";
14390
14875
  const channel = evt.channel ?? "";
14391
14876
  const ts = evt.ts ?? "";
@@ -14402,11 +14887,16 @@ async function connectSocketMode() {
14402
14887
  }).catch(() => {
14403
14888
  });
14404
14889
  }
14405
- const isAutoFollowed = evt.type === "message" && isThreadReply && trackedThreads.has(threadKey);
14890
+ const isAutoFollowed = evt.type === "message" && isThreadReply && !!threadKey && trackedThreads.has(threadKey);
14406
14891
  if (channel && ts) {
14407
14892
  trackPendingMessage(channel, threadTs, ts);
14408
14893
  }
14409
14894
  const userName = await resolveUserName(user);
14895
+ const fileMeta = await buildInboundFileMeta(evt.files, AGENT_CODE_NAME, channel);
14896
+ const downloadedImages = fileMeta.filter(
14897
+ (f) => f.kind === "image" && typeof f.path === "string"
14898
+ );
14899
+ const imagePath = downloadedImages.length === 1 ? downloadedImages[0].path : void 0;
14410
14900
  await mcp.notification({
14411
14901
  method: "notifications/claude/channel",
14412
14902
  params: {
@@ -14417,7 +14907,11 @@ async function connectSocketMode() {
14417
14907
  channel,
14418
14908
  thread_ts: threadTs,
14419
14909
  event_type: evt.type,
14420
- ...isAutoFollowed ? { auto_followed: true } : {}
14910
+ ...isAutoFollowed ? { auto_followed: "true" } : {},
14911
+ // Only set these when we actually have attachments to avoid
14912
+ // bloating every notification with empty metadata.
14913
+ ...fileMeta.length > 0 ? { files: JSON.stringify(fileMeta) } : {},
14914
+ ...imagePath ? { image_path: imagePath } : {}
14421
14915
  }
14422
14916
  }
14423
14917
  });
@@ -14430,7 +14924,7 @@ async function connectSocketMode() {
14430
14924
  if (currentWs === ws) currentWs = null;
14431
14925
  if (isShuttingDown) return;
14432
14926
  process.stderr.write("slack-channel: Socket Mode disconnected, reconnecting...\n");
14433
- setTimeout(connectSocketMode, 5e3);
14927
+ setTimeout(connectSocketModeSafely, 5e3).unref?.();
14434
14928
  };
14435
14929
  ws.onerror = (err) => {
14436
14930
  process.stderr.write(`slack-channel: WebSocket error: ${err}
@@ -14440,6 +14934,14 @@ async function connectSocketMode() {
14440
14934
  function shutdown(reason, exitCode = 0) {
14441
14935
  if (isShuttingDown) return;
14442
14936
  isShuttingDown = true;
14937
+ try {
14938
+ threadPersister?.flush(trackedThreads);
14939
+ } catch {
14940
+ }
14941
+ try {
14942
+ threadPersister?.dispose();
14943
+ } catch {
14944
+ }
14443
14945
  process.stderr.write(`slack-channel: ${reason} \u2014 closing Socket Mode and exiting
14444
14946
  `);
14445
14947
  try {
@@ -14453,7 +14955,35 @@ process.stdin.on("end", () => shutdown("stdin ended"));
14453
14955
  process.on("SIGTERM", () => shutdown("SIGTERM"));
14454
14956
  process.on("SIGINT", () => shutdown("SIGINT"));
14455
14957
  process.on("SIGHUP", () => shutdown("SIGHUP"));
14958
+ process.on("unhandledRejection", (reason) => {
14959
+ const msg = reason instanceof Error ? reason.message : String(reason);
14960
+ process.stderr.write(`slack-channel: unhandledRejection (continuing): ${msg}
14961
+ `);
14962
+ });
14963
+ if (THREAD_STORE_PATH) {
14964
+ const threadStoreLabel = "slack-tracked-threads.json";
14965
+ const redact = (msg) => msg.replaceAll(THREAD_STORE_PATH, threadStoreLabel);
14966
+ const { threads, pruned } = loadThreadStore(THREAD_STORE_PATH, {
14967
+ ttlDays: THREAD_STORE_TTL_DAYS,
14968
+ onError: (msg) => process.stderr.write(`${redact(msg)}
14969
+ `)
14970
+ });
14971
+ for (const [key, entry] of threads) trackedThreads.set(key, entry);
14972
+ threadPersister = createThreadPersister({
14973
+ filePath: THREAD_STORE_PATH,
14974
+ onError: (msg) => process.stderr.write(`${redact(msg)}
14975
+ `)
14976
+ });
14977
+ process.stderr.write(
14978
+ `slack-channel: hydrated ${trackedThreads.size} tracked thread(s)${pruned > 0 ? ` (pruned ${pruned} stale)` : ""} from ${threadStoreLabel}
14979
+ `
14980
+ );
14981
+ } else {
14982
+ process.stderr.write(
14983
+ "slack-channel: AGT_AGENT_CODE_NAME not set \u2014 running without thread-follow persistence\n"
14984
+ );
14985
+ }
14456
14986
  botUserId = await getBotUserId();
14457
14987
  process.stderr.write(`slack-channel: Bot user ID: ${botUserId}
14458
14988
  `);
14459
- connectSocketMode();
14989
+ connectSocketModeSafely();