@integrity-labs/agt-cli 0.14.12 → 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"));
@@ -13875,21 +13875,129 @@ var StdioServerTransport = class {
13875
13875
  this.onclose?.();
13876
13876
  }
13877
13877
  send(message) {
13878
- return new Promise((resolve2) => {
13878
+ return new Promise((resolve3) => {
13879
13879
  const json = serializeMessage(message);
13880
13880
  if (this._stdout.write(json)) {
13881
- resolve2();
13881
+ resolve3();
13882
13882
  } else {
13883
- this._stdout.once("drain", resolve2);
13883
+ this._stdout.once("drain", resolve3);
13884
13884
  }
13885
13885
  });
13886
13886
  }
13887
13887
  };
13888
13888
 
13889
13889
  // src/slack-channel.ts
13890
- import { readFileSync, statSync } from "fs";
13891
- import { basename, resolve } from "path";
13892
- import { homedir } from "os";
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
+ }
13893
14001
 
13894
14002
  // src/safe-async.ts
13895
14003
  async function runOrRetry(fn, opts) {
@@ -13908,6 +14016,102 @@ async function runOrRetry(fn, opts) {
13908
14016
  }
13909
14017
  }
13910
14018
 
14019
+ // src/channel-attachments.ts
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
+ }
14114
+
13911
14115
  // src/slack-response-mode.ts
13912
14116
  var MODES = [
13913
14117
  "mention_only",
@@ -13988,6 +14192,37 @@ function clearPendingMessage(channel, threadTs) {
13988
14192
  }
13989
14193
  }
13990
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
+ }
13991
14226
  if (!BOT_TOKEN || !APP_TOKEN) {
13992
14227
  console.error(
13993
14228
  "slack-channel: Missing SLACK_BOT_TOKEN or SLACK_APP_TOKEN. Cannot start."
@@ -14001,18 +14236,17 @@ var mcp = new Server(
14001
14236
  experimental: { "claude/channel": {} },
14002
14237
  tools: {}
14003
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.
14004
14242
  instructions: [
14005
14243
  'Messages from Slack arrive as <channel source="slack" user="<slack-id>" user_name="<display-name>" channel="..." thread_ts="...">.',
14006
- "Address users by their user_name (display name), NEVER by the raw user ID.",
14007
- "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.",
14008
- "Reply using the slack.reply tool, passing channel and thread_ts from the tag.",
14009
- "For threaded replies, always include thread_ts so the response appears in the same thread.",
14010
- "When someone @mentions you in a channel, respond helpfully in that thread.",
14011
- "For DMs, respond directly.",
14012
- "Messages with auto_followed=true are from threads you previously participated in.",
14013
- "For auto-followed messages, use relevance judgment: only reply if you have something useful to add.",
14014
- "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.",
14015
- "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."
14016
14250
  ].join(" ")
14017
14251
  }
14018
14252
  );
@@ -14072,6 +14306,24 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
14072
14306
  required: ["channel", "thread_ts"]
14073
14307
  }
14074
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
+ },
14075
14327
  {
14076
14328
  name: "slack.upload_file",
14077
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.",
@@ -14136,12 +14388,8 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
14136
14388
  };
14137
14389
  }
14138
14390
  if (THREAD_AUTO_FOLLOW !== "off") {
14139
- const trackKey = thread_ts ?? data.ts ?? "";
14140
- if (trackKey) {
14141
- if (!trackedThreads.has(trackKey)) {
14142
- trackedThreads.set(trackKey, thread_ts ? "mentioned" : "started");
14143
- }
14144
- }
14391
+ const trackTs = thread_ts ?? data.ts ?? void 0;
14392
+ rememberThread(channel, trackTs, thread_ts ? "mentioned" : "started");
14145
14393
  }
14146
14394
  return { content: [{ type: "text", text: "sent" }] };
14147
14395
  } catch (err) {
@@ -14248,8 +14496,8 @@ ${formatted}` : "Thread is empty or not found."
14248
14496
  isError: true
14249
14497
  };
14250
14498
  }
14251
- const allowedRoot = resolve(homedir(), ".augmented", AGENT_CODE_NAME, "project") + "/";
14252
- const resolvedPath = resolve(path);
14499
+ const allowedRoot = resolve2(homedir2(), ".augmented", AGENT_CODE_NAME, "project") + "/";
14500
+ const resolvedPath = resolve2(path);
14253
14501
  if (!resolvedPath.startsWith(allowedRoot)) {
14254
14502
  return {
14255
14503
  content: [{
@@ -14270,7 +14518,7 @@ ${formatted}` : "Thread is empty or not found."
14270
14518
  };
14271
14519
  }
14272
14520
  size = stat.size;
14273
- bytes = readFileSync(resolvedPath);
14521
+ bytes = readFileSync2(resolvedPath);
14274
14522
  } catch (err) {
14275
14523
  return {
14276
14524
  content: [{ type: "text", text: `Failed to read file: ${err.message}` }],
@@ -14339,8 +14587,186 @@ ${formatted}` : "Thread is empty or not found."
14339
14587
  };
14340
14588
  }
14341
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
+ }
14342
14637
  throw new Error(`Unknown tool: ${name}`);
14343
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
+ }
14344
14770
  await mcp.connect(new StdioServerTransport());
14345
14771
  var botUserId = null;
14346
14772
  async function getBotUserId() {
@@ -14429,21 +14855,22 @@ async function connectSocketMode() {
14429
14855
  }
14430
14856
  const isDirectMessage = evt.channel?.startsWith("D");
14431
14857
  const isThreadReply = !!evt.thread_ts && evt.thread_ts !== evt.ts;
14432
- const threadKey = evt.thread_ts ?? evt.ts ?? "";
14433
- if (evt.type === "app_mention" && threadKey) {
14434
- 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");
14435
14863
  }
14436
14864
  if (evt.type === "message" && evt.channel && !isDirectMessage) {
14437
14865
  if (isThreadReply) {
14438
14866
  if (THREAD_AUTO_FOLLOW === "off") return;
14439
- const threadInvolvement = trackedThreads.get(threadKey);
14867
+ const threadInvolvement = threadKey ? trackedThreads.get(threadKey)?.involvement : void 0;
14440
14868
  if (!threadInvolvement) return;
14441
14869
  if (THREAD_AUTO_FOLLOW === "started" && threadInvolvement !== "started") return;
14442
14870
  } else {
14443
14871
  if (!channelMessageShouldRespond(evt.text ?? "", CHANNEL_RESPONSE_MODE)) return;
14444
14872
  }
14445
14873
  }
14446
- if (ALLOWED_USERS.size > 0 && evt.user && !ALLOWED_USERS.has(evt.user)) return;
14447
14874
  const text = evt.text ?? "";
14448
14875
  const channel = evt.channel ?? "";
14449
14876
  const ts = evt.ts ?? "";
@@ -14460,11 +14887,16 @@ async function connectSocketMode() {
14460
14887
  }).catch(() => {
14461
14888
  });
14462
14889
  }
14463
- const isAutoFollowed = evt.type === "message" && isThreadReply && trackedThreads.has(threadKey);
14890
+ const isAutoFollowed = evt.type === "message" && isThreadReply && !!threadKey && trackedThreads.has(threadKey);
14464
14891
  if (channel && ts) {
14465
14892
  trackPendingMessage(channel, threadTs, ts);
14466
14893
  }
14467
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;
14468
14900
  await mcp.notification({
14469
14901
  method: "notifications/claude/channel",
14470
14902
  params: {
@@ -14475,7 +14907,11 @@ async function connectSocketMode() {
14475
14907
  channel,
14476
14908
  thread_ts: threadTs,
14477
14909
  event_type: evt.type,
14478
- ...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 } : {}
14479
14915
  }
14480
14916
  }
14481
14917
  });
@@ -14498,6 +14934,14 @@ async function connectSocketMode() {
14498
14934
  function shutdown(reason, exitCode = 0) {
14499
14935
  if (isShuttingDown) return;
14500
14936
  isShuttingDown = true;
14937
+ try {
14938
+ threadPersister?.flush(trackedThreads);
14939
+ } catch {
14940
+ }
14941
+ try {
14942
+ threadPersister?.dispose();
14943
+ } catch {
14944
+ }
14501
14945
  process.stderr.write(`slack-channel: ${reason} \u2014 closing Socket Mode and exiting
14502
14946
  `);
14503
14947
  try {
@@ -14516,6 +14960,29 @@ process.on("unhandledRejection", (reason) => {
14516
14960
  process.stderr.write(`slack-channel: unhandledRejection (continuing): ${msg}
14517
14961
  `);
14518
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
+ }
14519
14986
  botUserId = await getBotUserId();
14520
14987
  process.stderr.write(`slack-channel: Bot user ID: ${botUserId}
14521
14988
  `);