@agentconnect.md/daemon 1.0.0-rc.31 → 1.0.0-rc.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -7353,6 +7353,7 @@ const RuntimeDefSchema = object({
7353
7353
  const ConfigSchema = object({
7354
7354
  version: literal(1),
7355
7355
  daemonId: string().optional(),
7356
+ webAppUrl: string().optional(),
7356
7357
  controlPlane: object({
7357
7358
  enabled: boolean().default(true),
7358
7359
  url: string().optional(),
@@ -7374,11 +7375,13 @@ const ConfigSchema = object({
7374
7375
  limits: object({
7375
7376
  maxAgents: number().int().default(8),
7376
7377
  maxConcurrentSessions: number().int().default(32),
7377
- agentIdleTimeoutMs: number().int().default(9e5)
7378
+ agentIdleTimeoutMs: number().int().default(9e5),
7379
+ maxAttachmentBytes: number().int().default(8 * 1024 * 1024)
7378
7380
  }).default({
7379
7381
  maxAgents: 8,
7380
7382
  maxConcurrentSessions: 32,
7381
- agentIdleTimeoutMs: 9e5
7383
+ agentIdleTimeoutMs: 9e5,
7384
+ maxAttachmentBytes: 8 * 1024 * 1024
7382
7385
  })
7383
7386
  });
7384
7387
  //#endregion
@@ -16877,6 +16880,7 @@ var AcpHost = class {
16877
16880
  live = /* @__PURE__ */ new Set();
16878
16881
  loadingSessions = /* @__PURE__ */ new Set();
16879
16882
  canLoad = false;
16883
+ promptCaps = {};
16880
16884
  constructor(runtime, opts) {
16881
16885
  this.runtime = runtime;
16882
16886
  this.opts = opts;
@@ -16917,7 +16921,13 @@ var AcpHost = class {
16917
16921
  } }
16918
16922
  });
16919
16923
  this.canLoad = init.agentCapabilities?.loadSession ?? false;
16920
- this.opts.log?.debug(`acp: agent initialized (loadSession capability=${this.canLoad})`);
16924
+ this.promptCaps = init.agentCapabilities?.promptCapabilities ?? {};
16925
+ this.opts.log?.debug(`acp: agent initialized (loadSession=${this.canLoad}, prompt caps: image=${!!this.promptCaps.image} audio=${!!this.promptCaps.audio} embeddedContext=${!!this.promptCaps.embeddedContext})`);
16926
+ }
16927
+ /** Whether the agent advertised support for a gated prompt content-block kind.
16928
+ * `text` and `resource_link` are baseline and always return true. */
16929
+ promptSupports(kind) {
16930
+ return Boolean(this.promptCaps[kind]);
16921
16931
  }
16922
16932
  async newSession(cwd, mcpServers = []) {
16923
16933
  const res = await this.conn.agent.request(methods.agent.session.new, {
@@ -23265,6 +23275,12 @@ var LocalStore = class {
23265
23275
  openSessionAgents(channel, thread) {
23266
23276
  return this.db.prepare("SELECT agentId FROM sessions WHERE channel = ? AND thread = ? AND state != 'closed'").all(channel, thread).map((r) => r.agentId);
23267
23277
  }
23278
+ /** Count non-closed sessions in (channel, thread) touched at/after `sinceTs`
23279
+ * (epoch ms). Used to bound unrouted-transcript growth to recently-active
23280
+ * threads, since there is no session-`closed` lifecycle yet. */
23281
+ activeSessionCountSince(channel, thread, sinceTs) {
23282
+ return this.db.prepare("SELECT COUNT(*) AS n FROM sessions WHERE channel = ? AND thread = ? AND state != 'closed' AND updatedAt >= ?").get(channel, thread, sinceTs)?.n ?? 0;
23283
+ }
23268
23284
  getCpRouting() {
23269
23285
  return this.db.prepare("SELECT routingEpoch, assignments, globalRules FROM cp_routing WHERE id = 1").get();
23270
23286
  }
@@ -23281,6 +23297,67 @@ var LocalStore = class {
23281
23297
  }
23282
23298
  };
23283
23299
  //#endregion
23300
+ //#region src/session/attachment-block.ts
23301
+ /**
23302
+ * Turn one Slack attachment into the richest ACP content block the agent can
23303
+ * accept (§9.2):
23304
+ * - image/* + promptCapabilities.image → an inline `image` block (base64).
23305
+ * - downloaded + promptCapabilities.embeddedContext → an embedded `resource`
23306
+ * (text bytes for text/*, else a base64 blob).
23307
+ * - otherwise → a baseline `resource_link` pointer (always supported), which is
23308
+ * also the fallback when the download failed or the agent opted out.
23309
+ */
23310
+ function attachmentToBlock(att, bytes, supports) {
23311
+ const isImage = att.mimeType.startsWith("image/");
23312
+ if (bytes && isImage && supports("image")) return {
23313
+ type: "image",
23314
+ data: bytes.toString("base64"),
23315
+ mimeType: att.mimeType,
23316
+ uri: att.sourceUrl
23317
+ };
23318
+ if (bytes && supports("embeddedContext")) {
23319
+ if (att.mimeType.startsWith("text/")) return {
23320
+ type: "resource",
23321
+ resource: {
23322
+ text: bytes.toString("utf8"),
23323
+ uri: att.sourceUrl,
23324
+ mimeType: att.mimeType
23325
+ }
23326
+ };
23327
+ return {
23328
+ type: "resource",
23329
+ resource: {
23330
+ blob: bytes.toString("base64"),
23331
+ uri: att.sourceUrl,
23332
+ mimeType: att.mimeType
23333
+ }
23334
+ };
23335
+ }
23336
+ return {
23337
+ type: "resource_link",
23338
+ name: att.name,
23339
+ uri: att.sourceUrl,
23340
+ mimeType: att.mimeType,
23341
+ description: `Slack file. You cannot fetch this URL directly — call the readSlackFile tool with this uri (mimeType: ${att.mimeType}) to view its contents.`,
23342
+ ...typeof att.size === "number" ? { size: att.size } : {}
23343
+ };
23344
+ }
23345
+ /** Download + convert every attachment, skipping nothing (failed/over-cap
23346
+ * downloads degrade to resource_link rather than being dropped). A file whose
23347
+ * declared size already exceeds maxBytes is never fetched. */
23348
+ async function buildAttachmentBlocks(attachments, deps) {
23349
+ const cap = deps.maxBytes ?? Infinity;
23350
+ return Promise.all(attachments.map(async (att) => {
23351
+ return attachmentToBlock(att, typeof att.size === "number" && att.size > cap ? null : await deps.download(att).catch(() => null), deps.supports);
23352
+ }));
23353
+ }
23354
+ /** One-line human summary of attachments for the transcript text (so §8.5
23355
+ * catch-up replay at least notes a file was shared, since bytes aren't stored). */
23356
+ function attachmentMention(attachments) {
23357
+ if (!attachments?.length) return "";
23358
+ return `[attached: ${attachments.map((a) => `${a.name} (${a.mimeType})`).join(", ")}]`;
23359
+ }
23360
+ //#endregion
23284
23361
  //#region src/session/session-manager.ts
23285
23362
  var SessionManager = class {
23286
23363
  deps;
@@ -23294,14 +23371,15 @@ var SessionManager = class {
23294
23371
  async handle(agentId, msg) {
23295
23372
  const agent = this.deps.agentById(agentId);
23296
23373
  if (!agent) throw new Error(`unknown agent ${agentId}`);
23297
- const thread = msg.thread ?? msg.msgId;
23374
+ const { thread, ts } = transcriptCoords(msg);
23298
23375
  const key = sessionKey(msg.platform, msg.channel, thread, agentId);
23376
+ const mention = attachmentMention(msg.attachments);
23299
23377
  this.deps.store.appendTranscript({
23300
23378
  channel: msg.channel,
23301
23379
  thread,
23302
- ts: tsOf(msg),
23380
+ ts,
23303
23381
  sender: msg.sender.id,
23304
- text: msg.text
23382
+ text: mention ? `${msg.text}\n${mention}`.trim() : msg.text
23305
23383
  });
23306
23384
  let rec = this.deps.store.getSession(key);
23307
23385
  const host = await this.deps.hostFor(agentId);
@@ -23349,21 +23427,44 @@ var SessionManager = class {
23349
23427
  this.deps.store.upsertSession(rec);
23350
23428
  }
23351
23429
  }
23430
+ if (rec.lastDeliveredTs === null && thread !== ts && this.deps.fetchThreadHistory) {
23431
+ if (!this.deps.store.transcriptSince(msg.channel, thread, null).some((e) => e.ts === thread)) {
23432
+ const history = await this.deps.fetchThreadHistory(agentId, msg.channel, thread);
23433
+ for (const h of history) this.deps.store.appendTranscript({
23434
+ channel: msg.channel,
23435
+ thread,
23436
+ ts: h.ts,
23437
+ sender: h.sender,
23438
+ text: h.text
23439
+ });
23440
+ }
23441
+ }
23352
23442
  const gap = this.deps.store.transcriptSince(msg.channel, thread, rec.lastDeliveredTs);
23353
23443
  const blocks = [];
23354
- const context = gap.slice(0, -1).filter((e) => e.sender !== agentId);
23444
+ const allContext = gap.filter((e) => e.ts !== ts).filter((e) => e.sender !== agentId);
23445
+ const context = allContext.slice(-50);
23355
23446
  if (context.length > 0) {
23447
+ const elided = allContext.length - context.length;
23448
+ const head = elided > 0 ? `(thread context you may have missed — ${elided} earlier message(s) elided)` : "(thread context you may have missed)";
23356
23449
  const ctxText = context.map((e) => `[${e.sender}] ${e.text}`).join("\n");
23357
23450
  blocks.push({
23358
23451
  type: "text",
23359
- text: `(thread context you may have missed)\n${ctxText}`
23452
+ text: `${head}\n${ctxText}`
23360
23453
  });
23361
23454
  }
23362
23455
  blocks.push({
23363
23456
  type: "text",
23364
23457
  text: msg.text
23365
23458
  });
23366
- rec.lastDeliveredTs = tsOf(msg);
23459
+ if (msg.attachments?.length) {
23460
+ const attBlocks = await buildAttachmentBlocks(msg.attachments, {
23461
+ download: (att) => this.deps.downloadAttachment?.(agentId, att) ?? Promise.resolve(null),
23462
+ supports: (kind) => host.promptSupports?.(kind) ?? false,
23463
+ ...this.deps.attachmentMaxBytes !== void 0 ? { maxBytes: this.deps.attachmentMaxBytes } : {}
23464
+ });
23465
+ blocks.push(...attBlocks);
23466
+ }
23467
+ rec.lastDeliveredTs = ts;
23367
23468
  rec.state = "prompting";
23368
23469
  rec.updatedAt = Date.now();
23369
23470
  this.deps.store.upsertSession(rec);
@@ -23373,9 +23474,17 @@ var SessionManager = class {
23373
23474
  };
23374
23475
  }
23375
23476
  };
23376
- function tsOf(msg) {
23477
+ /** Canonical transcript primary-key coordinates for a normalized message. Used by
23478
+ * BOTH the session manager and the daemon's unrouted-append path so a message
23479
+ * recorded from either site lands on the same (thread, ts) PK and dedups via
23480
+ * INSERT OR IGNORE — never a divergent double row. */
23481
+ function transcriptCoords(msg) {
23482
+ const thread = msg.thread ?? msg.msgId;
23377
23483
  const parts = msg.msgId.split(":");
23378
- return parts[parts.length - 1] ?? "0";
23484
+ return {
23485
+ thread,
23486
+ ts: parts[parts.length - 1] ?? "0"
23487
+ };
23379
23488
  }
23380
23489
  //#endregion
23381
23490
  //#region src/mcp/ipc.ts
@@ -23411,6 +23520,25 @@ function decodeFrames(buf, onError) {
23411
23520
  }
23412
23521
  //#endregion
23413
23522
  //#region src/mcp/ops.ts
23523
+ const DEFAULT_MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024;
23524
+ /** Best-effort MIME guess from a Slack file URL's extension (used when the caller
23525
+ * doesn't pass a mimeType hint). */
23526
+ function guessMimeFromUrl(url) {
23527
+ const ext = url.split("?")[0]?.split(".").pop()?.toLowerCase();
23528
+ return ext ? {
23529
+ png: "image/png",
23530
+ jpg: "image/jpeg",
23531
+ jpeg: "image/jpeg",
23532
+ gif: "image/gif",
23533
+ webp: "image/webp",
23534
+ bmp: "image/bmp",
23535
+ svg: "image/svg+xml",
23536
+ txt: "text/plain",
23537
+ md: "text/markdown",
23538
+ json: "application/json",
23539
+ csv: "text/csv"
23540
+ }[ext] : void 0;
23541
+ }
23414
23542
  /**
23415
23543
  * Execute one tool call inside the daemon and return a plain result object (the
23416
23544
  * bridge wraps it into an MCP `CallToolResult`). Throws on bad input or a
@@ -23454,6 +23582,26 @@ async function executeTool(ctx, name, args, deps) {
23454
23582
  const user = requireString(args, "user");
23455
23583
  return await gw.getUserProfile(user);
23456
23584
  }
23585
+ case "readSlackFile": {
23586
+ const url = requireString(args, "url");
23587
+ const max = deps.maxAttachmentBytes ?? DEFAULT_MAX_ATTACHMENT_BYTES;
23588
+ const bytes = await gw.downloadFile(url, max);
23589
+ if (!bytes) throw new Error(`could not download the Slack file at ${url} — the bot may lack the files:read scope, or the file is inaccessible / larger than ${max} bytes`);
23590
+ const mimeType = optionalString(args, "mimeType") ?? guessMimeFromUrl(url) ?? "application/octet-stream";
23591
+ if (mimeType.startsWith("image/")) return { mcpContent: [{
23592
+ type: "image",
23593
+ data: bytes.toString("base64"),
23594
+ mimeType
23595
+ }] };
23596
+ if (mimeType.startsWith("text/") || mimeType === "application/json" || mimeType === "text/csv") return { mcpContent: [{
23597
+ type: "text",
23598
+ text: bytes.toString("utf8")
23599
+ }] };
23600
+ return { mcpContent: [{
23601
+ type: "text",
23602
+ text: `Downloaded ${bytes.byteLength} bytes of ${mimeType} (binary — not shown inline).`
23603
+ }] };
23604
+ }
23457
23605
  default: throw new Error(`unknown tool: ${name}`);
23458
23606
  }
23459
23607
  }
@@ -23665,6 +23813,20 @@ const SLACK_TOOLS = [
23665
23813
  type: "string",
23666
23814
  description: "User ID (e.g. U0123ABC)."
23667
23815
  } }, ["user"])
23816
+ },
23817
+ {
23818
+ name: "readSlackFile",
23819
+ description: "Fetch the contents of a file shared in Slack, using the bot credentials. You do NOT have direct network access to Slack's private file URLs (they require the bot token) — use this tool instead of curl/fetch. Pass the file's `url` (the `url_private` / `uri` from a shared attachment or resource link). Images are returned as viewable image content; text files as text. Supply `mimeType` when known for correct handling.",
23820
+ inputSchema: obj({
23821
+ url: {
23822
+ type: "string",
23823
+ description: "The file's url_private (or url_private_download) / resource-link uri."
23824
+ },
23825
+ mimeType: {
23826
+ type: "string",
23827
+ description: "Optional MIME type hint, e.g. image/png or text/plain."
23828
+ }
23829
+ }, ["url"])
23668
23830
  }
23669
23831
  ];
23670
23832
  SLACK_TOOLS.map((t) => t.name);
@@ -79691,9 +79853,25 @@ var import_dist = /* @__PURE__ */ __toESM((/* @__PURE__ */ __commonJSMin(((expor
79691
79853
  });
79692
79854
  })))(), 1);
79693
79855
  const MENTION_RE = /<@([A-Z0-9]+)>/g;
79856
+ /** Map a Slack file element to a NormalizedMessage Attachment, dropping any
79857
+ * without a usable id + fetch URL (e.g. external/tombstoned files). Tolerates a
79858
+ * malformed (null / non-object) element off the wire without throwing. */
79859
+ function toAttachment(f) {
79860
+ if (!f || typeof f !== "object") return null;
79861
+ const sourceUrl = f.url_private_download ?? f.url_private;
79862
+ if (!f.id || !sourceUrl) return null;
79863
+ return {
79864
+ id: f.id,
79865
+ name: f.name ?? f.title ?? f.id,
79866
+ mimeType: f.mimetype ?? "application/octet-stream",
79867
+ ...typeof f.size === "number" ? { size: f.size } : {},
79868
+ sourceUrl
79869
+ };
79870
+ }
79694
79871
  function normalizeSlackEvent(event, ctx) {
79695
79872
  const text = event.text ?? "";
79696
79873
  const mentionedBots = [...text.matchAll(MENTION_RE)].map((m) => m[1]);
79874
+ const attachments = (event.files ?? []).map(toAttachment).filter((a) => a !== null);
79697
79875
  return {
79698
79876
  msgId: `slack:${event.channel}:${event.ts}`,
79699
79877
  traceId: ctx.traceId,
@@ -79707,10 +79885,75 @@ function normalizeSlackEvent(event, ctx) {
79707
79885
  },
79708
79886
  text,
79709
79887
  mentionedBots,
79888
+ ...attachments.length ? { attachments } : {},
79710
79889
  isDm: event.channel_type === "im"
79711
79890
  };
79712
79891
  }
79713
79892
  //#endregion
79893
+ //#region src/slack/send-queue.ts
79894
+ /**
79895
+ * §9.1 SlackSendQueue: serialize outbound Slack Web API writes (chat.postMessage
79896
+ * / chat.update / assistant.threads.setStatus) per connection and space them by a
79897
+ * minimum interval so streamed edits don't trip Slack's Tier-3 rate limit
79898
+ * (chat.postMessage ~50 rpm). FIFO: preserves the order the converger emits.
79899
+ *
79900
+ * The first task runs immediately; each subsequent task waits until at least
79901
+ * `minIntervalMs` after the previous one *started*. `enqueue` resolves/rejects
79902
+ * with the task's own result, so callers can still await a posted message ts.
79903
+ *
79904
+ * Each task is bounded by `taskTimeoutMs`: if a single call hangs (e.g. a Slack
79905
+ * API stall during a socket reconnect), it is abandoned so the queue keeps moving
79906
+ * and a best-effort status update can never block real message delivery. The
79907
+ * abandoned call's promise still settles in the background; the caller just sees a
79908
+ * timeout rejection (which, for a progress/plan post, the daemon treats as "no ts").
79909
+ */
79910
+ var SlackSendQueue = class {
79911
+ minIntervalMs;
79912
+ now;
79913
+ sleep;
79914
+ taskTimeoutMs;
79915
+ chain = Promise.resolve();
79916
+ lastStart = Number.NEGATIVE_INFINITY;
79917
+ constructor(minIntervalMs = 350, now = () => Date.now(), sleep = (ms) => new Promise((r) => setTimeout(r, ms)), taskTimeoutMs = 3e4) {
79918
+ this.minIntervalMs = minIntervalMs;
79919
+ this.now = now;
79920
+ this.sleep = sleep;
79921
+ this.taskTimeoutMs = taskTimeoutMs;
79922
+ }
79923
+ enqueue(task) {
79924
+ const run = this.chain.then(async () => {
79925
+ const wait = this.minIntervalMs - (this.now() - this.lastStart);
79926
+ if (wait > 0) await this.sleep(wait);
79927
+ this.lastStart = this.now();
79928
+ return this.withTimeout(task());
79929
+ });
79930
+ this.chain = run.then(() => void 0, () => void 0);
79931
+ return run;
79932
+ }
79933
+ withTimeout(p) {
79934
+ if (!Number.isFinite(this.taskTimeoutMs) || this.taskTimeoutMs <= 0) return p;
79935
+ return new Promise((resolve, reject) => {
79936
+ let settled = false;
79937
+ const timer = setTimeout(() => {
79938
+ if (settled) return;
79939
+ settled = true;
79940
+ reject(/* @__PURE__ */ new Error(`SlackSendQueue: task exceeded ${this.taskTimeoutMs}ms — abandoned`));
79941
+ }, this.taskTimeoutMs);
79942
+ p.then((v) => {
79943
+ if (settled) return;
79944
+ settled = true;
79945
+ clearTimeout(timer);
79946
+ resolve(v);
79947
+ }, (e) => {
79948
+ if (settled) return;
79949
+ settled = true;
79950
+ clearTimeout(timer);
79951
+ reject(e);
79952
+ });
79953
+ });
79954
+ }
79955
+ };
79956
+ //#endregion
79714
79957
  //#region src/slack/connection.ts
79715
79958
  const { App, LogLevel } = import_dist.default;
79716
79959
  /** §6.1: one Slack Socket Mode connection per unique appToken. */
@@ -79737,15 +79980,21 @@ const MEMBER_ENRICH_CAP = 50;
79737
79980
  var SlackConnection = class {
79738
79981
  deps;
79739
79982
  app;
79983
+ queue;
79740
79984
  botUserId = "";
79741
79985
  /** The appToken this socket is keyed by (one socket per unique appToken). */
79742
79986
  appToken;
79743
79987
  /** The botToken this socket authenticated with (used to detect a same-appToken swap). */
79744
79988
  botToken;
79989
+ botId = "";
79745
79990
  constructor(deps, factory = (o) => new App({
79746
79991
  token: o.token,
79747
79992
  appToken: o.appToken,
79748
79993
  socketMode: true,
79994
+ clientOptions: {
79995
+ timeout: 1e4,
79996
+ retryConfig: { retries: 2 }
79997
+ },
79749
79998
  ...deps.boltDebug ? { logLevel: LogLevel.DEBUG } : {}
79750
79999
  })) {
79751
80000
  this.deps = deps;
@@ -79755,13 +80004,15 @@ var SlackConnection = class {
79755
80004
  token: deps.group.botToken,
79756
80005
  appToken: deps.group.appToken
79757
80006
  });
80007
+ this.queue = new SlackSendQueue(deps.sendIntervalMs ?? 350);
79758
80008
  }
79759
80009
  async start() {
79760
80010
  const log = this.deps.log;
79761
80011
  log?.debug("slack: auth.test → resolving bot identity (HTTPS)…");
79762
80012
  const auth = await this.app.client.auth.test();
79763
80013
  this.botUserId = auth.user_id ?? "";
79764
- log?.debug(`slack: auth.test ok → bot user ${this.botUserId}`);
80014
+ this.botId = auth.bot_id ?? "";
80015
+ log?.debug(`slack: auth.test ok → bot user ${this.botUserId} (bot_id ${this.botId || "n/a"})`);
79765
80016
  const deliver = (ev, kind) => {
79766
80017
  const msg = normalizeSlackEvent(ev, { traceId: this.deps.newTraceId() });
79767
80018
  log?.debug(`slack: inbound ${kind} ch=${msg.channel} user=${msg.sender.id} isBot=${msg.sender.isBot} isDm=${msg.isDm} mentions=[${msg.mentionedBots.join(",")}] text=${JSON.stringify(msg.text.slice(0, 80))}`);
@@ -79785,11 +80036,97 @@ var SlackConnection = class {
79785
80036
  log?.debug("slack: app.start resolved → socket established");
79786
80037
  }
79787
80038
  async postMessage(channel, text, threadTs) {
79788
- return (await this.app.client.chat.postMessage({
79789
- channel,
79790
- text,
79791
- thread_ts: threadTs
79792
- }))?.ts;
80039
+ return this.queue.enqueue(async () => {
80040
+ return (await this.app.client.chat.postMessage({
80041
+ channel,
80042
+ text,
80043
+ thread_ts: threadTs
80044
+ }))?.ts;
80045
+ });
80046
+ }
80047
+ /** Edit a previously-posted message in place (chat.update) — the §9.1 "就地更新"
80048
+ * primitive for the main progress / plan message. Best-effort: swallows errors. */
80049
+ async updateMessage(channel, ts, text) {
80050
+ await this.queue.enqueue(async () => {
80051
+ try {
80052
+ await this.app.client.chat.update({
80053
+ channel,
80054
+ ts,
80055
+ text
80056
+ });
80057
+ } catch (err) {
80058
+ this.deps.log?.debug(`slack: chat.update failed (ch=${channel} ts=${ts}): ${err.message}`);
80059
+ }
80060
+ });
80061
+ }
80062
+ /**
80063
+ * Pull a Slack thread's full history (conversations.replies, cursor-paginated)
80064
+ * for §8.4/§9.2 mid-thread context. Returns root + replies in Slack ts order;
80065
+ * best-effort (returns what it fetched, [] on error). Bot/system frames keep
80066
+ * their bot_id as the sender so the caller can attribute them.
80067
+ */
80068
+ async getThreadReplies(channel, threadTs, maxMessages = 200) {
80069
+ const out = [];
80070
+ let cursor;
80071
+ try {
80072
+ do {
80073
+ const res = await this.app.client.conversations.replies({
80074
+ channel,
80075
+ ts: threadTs,
80076
+ limit: 200,
80077
+ ...cursor ? { cursor } : {}
80078
+ });
80079
+ for (const m of res.messages ?? []) {
80080
+ if (!m.ts) continue;
80081
+ out.push({
80082
+ sender: m.user ?? m.bot_id ?? "unknown",
80083
+ ts: m.ts,
80084
+ text: m.text ?? "",
80085
+ isBot: Boolean(m.bot_id),
80086
+ attachments: (m.files ?? []).map(toAttachment).filter((a) => a !== null)
80087
+ });
80088
+ if (out.length >= maxMessages) return out;
80089
+ }
80090
+ cursor = res.has_more ? res.response_metadata?.next_cursor : void 0;
80091
+ } while (cursor);
80092
+ } catch (err) {
80093
+ this.deps.log?.debug(`slack: conversations.replies failed (ch=${channel} thread=${threadTs}): ${err.message}`);
80094
+ }
80095
+ return out;
80096
+ }
80097
+ /**
80098
+ * Download an auth-gated Slack file (url_private[_download]) with the bot token,
80099
+ * up to `maxBytes` (bounds daemon RSS + the inlined prompt frame). Returns the
80100
+ * bytes, or null on any failure / over-cap (best-effort — a failed or oversized
80101
+ * attachment degrades to a resource_link, never breaks the prompt). §9.2: bytes
80102
+ * stay daemon-local.
80103
+ */
80104
+ async downloadFile(sourceUrl, maxBytes = 8 * 1024 * 1024) {
80105
+ try {
80106
+ const res = await fetch(sourceUrl, { headers: { Authorization: `Bearer ${this.deps.group.botToken}` } });
80107
+ if (!res.ok) {
80108
+ this.deps.log?.debug(`slack: downloadFile ${sourceUrl} → HTTP ${res.status}`);
80109
+ return null;
80110
+ }
80111
+ const declared = Number(res.headers.get("content-length"));
80112
+ if (Number.isFinite(declared) && declared > maxBytes) {
80113
+ this.deps.log?.debug(`slack: downloadFile ${sourceUrl} skipped — ${declared} bytes > cap ${maxBytes}`);
80114
+ return null;
80115
+ }
80116
+ if ((res.headers.get("content-type") ?? "").includes("text/html")) {
80117
+ this.deps.log?.debug(`slack: downloadFile ${sourceUrl} got text/html (login page?) — treating as inaccessible`);
80118
+ return null;
80119
+ }
80120
+ const buf = Buffer.from(await res.arrayBuffer());
80121
+ if (buf.byteLength > maxBytes) {
80122
+ this.deps.log?.debug(`slack: downloadFile ${sourceUrl} discarded — ${buf.byteLength} bytes > cap ${maxBytes}`);
80123
+ return null;
80124
+ }
80125
+ return buf;
80126
+ } catch (err) {
80127
+ this.deps.log?.debug(`slack: downloadFile ${sourceUrl} failed: ${err.message}`);
80128
+ return null;
80129
+ }
79793
80130
  }
79794
80131
  async getChannelInfo(channel) {
79795
80132
  const c = (await this.app.client.conversations.info({ channel })).channel ?? {};
@@ -79837,22 +80174,103 @@ var SlackConnection = class {
79837
80174
  * Pass status='' to clear. Never throws into dispatch.
79838
80175
  */
79839
80176
  async setStatus(channel, threadTs, status, loadingMessages) {
79840
- try {
79841
- await this.app.client.assistant.threads.setStatus({
79842
- channel_id: channel,
79843
- thread_ts: threadTs,
79844
- status,
79845
- ...loadingMessages ? { loading_messages: loadingMessages } : {}
79846
- });
79847
- } catch (err) {
79848
- this.deps.log?.debug(`slack: setStatus failed (ch=${channel} thread=${threadTs}): ${err.message}`);
79849
- }
80177
+ await this.queue.enqueue(async () => {
80178
+ try {
80179
+ await this.app.client.assistant.threads.setStatus({
80180
+ channel_id: channel,
80181
+ thread_ts: threadTs,
80182
+ status,
80183
+ ...loadingMessages ? { loading_messages: loadingMessages } : {}
80184
+ });
80185
+ } catch (err) {
80186
+ this.deps.log?.debug(`slack: setStatus failed (ch=${channel} thread=${threadTs}): ${err.message}`);
80187
+ }
80188
+ });
79850
80189
  }
79851
80190
  async stop() {
79852
80191
  await this.app.stop();
79853
80192
  }
79854
80193
  };
79855
80194
  //#endregion
80195
+ //#region src/slack/formatter.ts
80196
+ /**
80197
+ * Last-mile Slack formatting (§9.1 / §9.3). Two pure helpers:
80198
+ * - markdownToMrkdwn: convert the agent's CommonMark-ish output to Slack mrkdwn.
80199
+ * - splitIntoSections: break a message into <= maxLen sections (Slack ~3000/section).
80200
+ * Both are platform-specific; the upstream convergence (ACP->IR) stays platform-agnostic.
80201
+ */
80202
+ /** Slack section soft cap (§9.3: Slack splits at ~3000 chars per section). */
80203
+ const SLACK_SECTION_LIMIT = 3e3;
80204
+ const CODE_SENTINEL = "\0";
80205
+ const BOLD = "";
80206
+ /** Replace fenced ```code``` and inline `code` with placeholders so inline
80207
+ * formatting rules never fire inside them; returns the masked text + restorer. */
80208
+ function protectCode(input) {
80209
+ const spans = [];
80210
+ const masked = input.replace(/```[\s\S]*?```|`[^`\n]*`/g, (m) => {
80211
+ return `${CODE_SENTINEL}${spans.push(m) - 1}${CODE_SENTINEL}`;
80212
+ });
80213
+ const restore = (s) => s.replace(new RegExp(`${CODE_SENTINEL}(\\d+)${CODE_SENTINEL}`, "g"), (_, i) => spans[Number(i)] ?? "");
80214
+ return {
80215
+ masked,
80216
+ restore
80217
+ };
80218
+ }
80219
+ /**
80220
+ * Convert common Markdown to Slack mrkdwn. Handled: bold (`**`/`__` -> `*`),
80221
+ * italic (`*` -> `_`; bare `_` is already valid mrkdwn italic and left alone to
80222
+ * avoid mangling snake_case), strikethrough (`~~` -> `~`), links (`[t](u)` ->
80223
+ * `<u|t>`), ATX headings (`# H` -> `*H*`), and `-`/`*`/`+` bullets -> `•`. Code
80224
+ * spans and fences are preserved verbatim.
80225
+ *
80226
+ * Best-effort, not a full CommonMark parser: pathological overlapping emphasis
80227
+ * (e.g. `***a** b*`, or a lone `*` inside `**bold**`) can still render imperfectly.
80228
+ * Such inputs are rare in agent output and only affect cosmetics — never content.
80229
+ */
80230
+ function markdownToMrkdwn(input) {
80231
+ if (!input) return input;
80232
+ const { masked, restore } = protectCode(input);
80233
+ let out = masked;
80234
+ out = out.replace(/^[ \t]*#{1,6}[ \t]+(.+?)[ \t]*#*$/gm, (_m, h) => `${BOLD}${h.replace(/\*+|__/g, "")}${BOLD}`);
80235
+ out = out.replace(/^([ \t]*)[-*+][ \t]+/gm, "$1• ");
80236
+ out = out.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, "<$2|$1>");
80237
+ out = out.replace(/\*\*\*(?!\s)([^\n]+?)(?<!\s)\*\*\*/g, `${BOLD}_$1_${BOLD}`);
80238
+ out = out.replace(/___(?!\s)([^\n]+?)(?<!\s)___/g, `${BOLD}_$1_${BOLD}`);
80239
+ out = out.replace(/\*\*(?!\s)([^\n]+?)(?<!\s)\*\*/g, `${BOLD}$1${BOLD}`);
80240
+ out = out.replace(/__(?!\s)([^\n]+?)(?<!\s)__/g, `${BOLD}$1${BOLD}`);
80241
+ out = out.replace(/\*(?!\s)([^*\n]+?)(?<!\s)\*/g, "_$1_");
80242
+ out = out.split(BOLD).join("*");
80243
+ out = out.replace(/~~([^\n]+?)~~/g, "~$1~");
80244
+ return restore(out);
80245
+ }
80246
+ /**
80247
+ * Split text into sections no longer than maxLen, preferring line boundaries;
80248
+ * a single line longer than maxLen is hard-cut. Whitespace-only input yields no
80249
+ * sections. Content is never lost across sections.
80250
+ */
80251
+ function splitIntoSections(text, maxLen = SLACK_SECTION_LIMIT) {
80252
+ if (!text.trim()) return [];
80253
+ if (text.length <= maxLen) return [text];
80254
+ const sections = [];
80255
+ let cur = "";
80256
+ const flush = () => {
80257
+ if (cur.length) sections.push(cur);
80258
+ cur = "";
80259
+ };
80260
+ for (const line of text.split("\n")) {
80261
+ const piece = cur.length ? `\n${line}` : line;
80262
+ if (cur.length + piece.length <= maxLen) {
80263
+ cur += piece;
80264
+ continue;
80265
+ }
80266
+ flush();
80267
+ if (line.length <= maxLen) cur = line;
80268
+ else for (let i = 0; i < line.length; i += maxLen) sections.push(line.slice(i, i + maxLen));
80269
+ }
80270
+ flush();
80271
+ return sections;
80272
+ }
80273
+ //#endregion
79856
80274
  //#region src/slack/render.ts
79857
80275
  const THINKING = "is thinking…";
79858
80276
  const MAX_ACTIVITY = 10;
@@ -79861,6 +80279,15 @@ function clampLabel(s) {
79861
80279
  const t = s.trim();
79862
80280
  return t.length > MAX_LABEL ? `${t.slice(0, MAX_LABEL - 1)}…` : t;
79863
80281
  }
80282
+ function planIcon(status) {
80283
+ if (status === "completed") return ":white_check_mark:";
80284
+ if (status === "in_progress") return ":hourglass_flowing_sand:";
80285
+ return ":white_large_square:";
80286
+ }
80287
+ /** Render an ACP plan (full entry list, resent on every update) as a compact summary. */
80288
+ function renderPlan(entries) {
80289
+ return [":clipboard: *Plan*", ...entries.map((e) => `${planIcon(e.status)} ${clampLabel(e.content ?? "")}`)].join("\n");
80290
+ }
79864
80291
  var OutputConverger = class {
79865
80292
  mode;
79866
80293
  buf = "";
@@ -79869,17 +80296,24 @@ var OutputConverger = class {
79869
80296
  constructor(mode) {
79870
80297
  this.mode = mode;
79871
80298
  }
80299
+ /** True while body text is buffered — the daemon uses this to (re)arm the ~2s
80300
+ * idle-flush timer (§9.1 text-buffer) so a long pure-text stream posts in steps. */
80301
+ hasBuffered() {
80302
+ return this.buf.trim().length > 0;
80303
+ }
80304
+ /** Flush the buffered body: markdown→mrkdwn, then split into ≤3000-char sections,
80305
+ * one `post` per section (§9.1/§9.3). Public so the daemon's idle timer can call it. */
80306
+ flushBuffered() {
80307
+ return this.flush();
80308
+ }
79872
80309
  flush() {
79873
- if (!this.buf.trim()) {
79874
- this.buf = "";
79875
- return [];
79876
- }
79877
80310
  const text = this.buf;
79878
80311
  this.buf = "";
79879
- return [{
80312
+ if (!text.trim()) return [];
80313
+ return splitIntoSections(markdownToMrkdwn(text)).map((t) => ({
79880
80314
  kind: "post",
79881
- text
79882
- }];
80315
+ text: t
80316
+ }));
79883
80317
  }
79884
80318
  /**
79885
80319
  * Record an activity label and build the loading-status action carrying the rolling
@@ -79921,13 +80355,12 @@ var OutputConverger = class {
79921
80355
  ...this.flush(),
79922
80356
  ...status,
79923
80357
  {
79924
- kind: "update-main",
79925
- text: `_thinking: ${content?.text ?? ""}_`
80358
+ kind: "progress",
80359
+ text: `:thought_balloon: ${clampLabel(content?.text ?? "thinking…")}`
79926
80360
  }
79927
80361
  ];
79928
80362
  }
79929
- if (this.mode === "low") return [...this.flush(), ...status];
79930
- return status;
80363
+ return [...this.flush(), ...status];
79931
80364
  }
79932
80365
  case "tool_call":
79933
80366
  case "tool_call_update": {
@@ -79938,28 +80371,42 @@ var OutputConverger = class {
79938
80371
  ...this.flush(),
79939
80372
  ...status,
79940
80373
  {
79941
- kind: "update-main",
80374
+ kind: "progress",
79942
80375
  text: `:hammer_and_wrench: ${label}`
79943
80376
  }
79944
80377
  ];
79945
80378
  }
80379
+ case "plan": {
80380
+ const entries = update.entries ?? [];
80381
+ if (this.mode === "low") return [...this.flush(), ...this.pushActivity("planning…")];
80382
+ return [...this.flush(), {
80383
+ kind: "plan",
80384
+ text: renderPlan(entries)
80385
+ }];
80386
+ }
79946
80387
  case "usage_update": return [];
79947
80388
  default: return [];
79948
80389
  }
79949
80390
  }
80391
+ /**
80392
+ * Turn end (§9.1 stopReason): flush remaining body, clear the loading status, and
80393
+ * — in medium/high — append a footer linking to the Web App session detail. The
80394
+ * link is omitted entirely when none is configured (no `local://` placeholder).
80395
+ */
79950
80396
  onFinal(link) {
79951
80397
  const clear = {
79952
80398
  kind: "set-status",
79953
80399
  text: ""
79954
80400
  };
79955
80401
  if (this.mode === "low") return [...this.flush(), clear];
80402
+ const footer = link ? [{
80403
+ kind: "post",
80404
+ text: `:white_check_mark: done — <${link}|details>`
80405
+ }] : [];
79956
80406
  return [
79957
80407
  ...this.flush(),
79958
80408
  clear,
79959
- {
79960
- kind: "post",
79961
- text: `:white_check_mark: done — <${link}|details>`
79962
- }
80409
+ ...footer
79963
80410
  ];
79964
80411
  }
79965
80412
  };
@@ -81440,6 +81887,7 @@ function formatErr(err) {
81440
81887
  return e?.stack ?? String(err);
81441
81888
  }
81442
81889
  const MAX_QUEUED_PER_SESSION = 10;
81890
+ const IDLE_FLUSH_MS = 2e3;
81443
81891
  var Daemon = class {
81444
81892
  opts;
81445
81893
  store;
@@ -81530,7 +81978,8 @@ var Daemon = class {
81530
81978
  ts,
81531
81979
  sender: ctx.agentId,
81532
81980
  text
81533
- })
81981
+ }),
81982
+ maxAttachmentBytes: cfg.limits.maxAttachmentBytes
81534
81983
  });
81535
81984
  await this.mcp.start();
81536
81985
  const cliEntry = process.argv[1] ?? "";
@@ -81554,7 +82003,10 @@ var Daemon = class {
81554
82003
  token,
81555
82004
  cliEntry
81556
82005
  });
81557
- }
82006
+ },
82007
+ downloadAttachment: (agentId, att) => this.replyConnFor(agentId)?.downloadFile?.(att.sourceUrl, this.cfg.limits.maxAttachmentBytes) ?? Promise.resolve(null),
82008
+ attachmentMaxBytes: cfg.limits.maxAttachmentBytes,
82009
+ fetchThreadHistory: (agentId, channel, threadTs) => this.fetchThreadHistory(agentId, channel, threadTs)
81558
82010
  });
81559
82011
  this.scheduler = new Scheduler({
81560
82012
  onFire: (agentId, msg) => void this.dispatch(agentId, msg).catch((err) => this.log.error(`cron dispatch failed for agent "${agentId}": ${formatErr(err)}`)),
@@ -81786,12 +82238,63 @@ var Daemon = class {
81786
82238
  }
81787
82239
  const result = routeRules(msg, this.mergedRules(), (c, t) => this.sessions.threadOwner(c, t));
81788
82240
  if (!result) {
82241
+ this.recordUnrouted(msg);
81789
82242
  this.log.debug(`routing: dropped message in ch=${msg.channel} (no agent matched — not a mention of a known bot, not a subscribed 'all' channel, not a thread/DM hit)`);
81790
82243
  return;
81791
82244
  }
81792
82245
  this.log.info(`routing: ch=${msg.channel} → agent "${result.agentId}" (integration ${result.integrationId})`);
81793
82246
  this.dispatch(result.agentId, msg, result.integrationId).catch((err) => this.log.error(`dispatch failed for agent "${result.agentId}": ${formatErr(err)}`));
81794
82247
  }
82248
+ /** All of our own connections' bot identities (both `U…` user ids and `B…`
82249
+ * bot_ids), so we recognize self-echoes regardless of which field Slack sends. */
82250
+ ownBotIdentities() {
82251
+ const ids = /* @__PURE__ */ new Set();
82252
+ for (const conn of this.connByIntegration.values()) {
82253
+ if (conn.botUserId) ids.add(conn.botUserId);
82254
+ if (conn.botId) ids.add(conn.botId);
82255
+ }
82256
+ return ids;
82257
+ }
82258
+ /** Record an unrouted inbound message into the transcript iff a session is
82259
+ * *recently active* in its thread (§8.5 catch-up). Skips our own bot's echoes
82260
+ * (recordOutbound already logs those under the agentId). The recency gate bounds
82261
+ * transcript growth to threads with live work — without it, a thread that ever
82262
+ * held a session would record forever (no session-`closed` lifecycle yet). */
82263
+ recordUnrouted(msg) {
82264
+ if (this.ownBotIdentities().has(msg.sender.id)) return;
82265
+ const { thread, ts } = transcriptCoords(msg);
82266
+ const sinceTs = Date.now() - this.cfg.limits.agentIdleTimeoutMs;
82267
+ const recentlyActive = this.store.activeSessionCountSince(msg.channel, thread, sinceTs) > 0;
82268
+ const inFlight = [...this.pending.values()].some((p) => p.channel === msg.channel && p.statusThread === thread);
82269
+ if (!recentlyActive && !inFlight) return;
82270
+ this.store.appendTranscript({
82271
+ channel: msg.channel,
82272
+ thread,
82273
+ ts,
82274
+ sender: msg.sender.id,
82275
+ text: msg.text
82276
+ });
82277
+ this.log.debug(`transcript: recorded unrouted msg ch=${msg.channel} thread=${thread} ts=${ts} (live session)`);
82278
+ }
82279
+ /**
82280
+ * Pull real Slack thread history for a cold mid-thread @ (§8.4/§9.2), relabeling
82281
+ * THIS agent's own past bot frames to its agentId (so the §8.5 own-message filter
82282
+ * still suppresses them) and folding any attachment metadata into the text (so
82283
+ * cold replay matches the live transcript's `[attached: …]` mention).
82284
+ */
82285
+ async fetchThreadHistory(agentId, channel, threadTs) {
82286
+ const conn = this.replyConnFor(agentId);
82287
+ if (!conn?.getThreadReplies) return [];
82288
+ const ours = new Set([conn.botUserId, conn.botId].filter(Boolean));
82289
+ return (await conn.getThreadReplies(channel, threadTs)).map((r) => {
82290
+ const mention = attachmentMention(r.attachments);
82291
+ return {
82292
+ sender: r.isBot && ours.has(r.sender) ? agentId : r.sender,
82293
+ ts: r.ts,
82294
+ text: mention ? `${r.text}\n${mention}`.trim() : r.text
82295
+ };
82296
+ });
82297
+ }
81795
82298
  queued = /* @__PURE__ */ new Map();
81796
82299
  /**
81797
82300
  * Handle an in-conversation control command. Resolves the target agent via the
@@ -81885,36 +82388,87 @@ var Daemon = class {
81885
82388
  const statusThread = msg.thread ?? msg.msgId;
81886
82389
  replyConn?.setStatus(msg.channel, statusThread, wasRunning ? "is thinking…" : "is starting up…");
81887
82390
  const { sessionId, blocks } = await this.sessions.handle(agentId, msg);
81888
- this.pending.set(sessionId, {
82391
+ const p = {
81889
82392
  conv,
81890
82393
  channel: msg.channel,
81891
82394
  thread: msg.thread,
81892
- conn: replyConn
81893
- });
82395
+ statusThread,
82396
+ conn: replyConn,
82397
+ applyChain: Promise.resolve()
82398
+ };
82399
+ this.pending.set(sessionId, p);
81894
82400
  try {
81895
82401
  const host = await this.ensureHostAsync(agentId);
81896
82402
  if (!wasRunning) replyConn?.setStatus(msg.channel, statusThread, "is thinking…");
81897
82403
  await host.prompt(sessionId, blocks);
81898
- for (const action of conv.onFinal(`local://session/${sessionId}`)) await this.applyAction(action, replyConn, msg.channel, statusThread);
82404
+ this.clearIdle(p);
82405
+ const link = this.cfg.webAppUrl ? `${this.cfg.webAppUrl.replace(/\/$/, "")}/sessions/${sessionId}` : void 0;
82406
+ for (const action of conv.onFinal(link)) this.enqueueApply(p, action);
82407
+ await p.applyChain;
81899
82408
  } finally {
82409
+ this.clearIdle(p);
81900
82410
  this.pending.delete(sessionId);
81901
82411
  }
81902
82412
  this.flushQueued(agentId, sessionId, integrationId);
81903
82413
  }
81904
- /** Route a converger action: set-status → setStatus (status + rotating loading_messages;
81905
- * '' clears); else postMessage. */
81906
- async applyAction(action, conn, channel, thread) {
81907
- if (action.kind === "set-status") {
81908
- if (conn && thread) await conn.setStatus(channel, thread, action.text, action.loadingMessages);
81909
- return;
82414
+ /**
82415
+ * Apply one converger action against the session's Slack connection:
82416
+ * - set-status → assistant.threads.setStatus (best-effort; '' clears)
82417
+ * - post → a new thread message
82418
+ * - progress / plan the single in-place message of that kind, posted once
82419
+ * then chat.update-ed (§9.1 就地更新). The first post's ts is remembered on `p`.
82420
+ */
82421
+ async applyAction(p, action) {
82422
+ const conn = p.conn;
82423
+ if (!conn) return;
82424
+ switch (action.kind) {
82425
+ case "set-status":
82426
+ if (p.statusThread) await conn.setStatus(p.channel, p.statusThread, action.text, action.loadingMessages);
82427
+ return;
82428
+ case "post":
82429
+ await conn.postMessage(p.channel, action.text, p.thread);
82430
+ return;
82431
+ case "progress":
82432
+ if (p.progressTs) await conn.updateMessage(p.channel, p.progressTs, action.text);
82433
+ else if (!p.progressAttempted) {
82434
+ p.progressAttempted = true;
82435
+ p.progressTs = await conn.postMessage(p.channel, action.text, p.thread);
82436
+ }
82437
+ return;
82438
+ case "plan":
82439
+ if (p.planTs) await conn.updateMessage(p.channel, p.planTs, action.text);
82440
+ else if (!p.planAttempted) {
82441
+ p.planAttempted = true;
82442
+ p.planTs = await conn.postMessage(p.channel, action.text, p.thread);
82443
+ }
82444
+ return;
81910
82445
  }
81911
- await conn?.postMessage(channel, action.text, thread);
82446
+ }
82447
+ /** Serialize action application per session so in-place edits never race on the
82448
+ * remembered message ts (two concurrent `progress` actions both posting). */
82449
+ enqueueApply(p, action) {
82450
+ p.applyChain = p.applyChain.then(() => this.applyAction(p, action).catch((err) => this.log.error(`slack apply failed: ${formatErr(err)}`)));
82451
+ }
82452
+ /** (Re)arm the ~2s idle-flush timer when body text is buffered (§9.1 text-buffer):
82453
+ * a long pure-text stream posts in steps instead of all at turn end. */
82454
+ armIdle(p) {
82455
+ this.clearIdle(p);
82456
+ if (!p.conv.hasBuffered()) return;
82457
+ p.idleTimer = setTimeout(() => {
82458
+ p.idleTimer = void 0;
82459
+ for (const action of p.conv.flushBuffered()) this.enqueueApply(p, action);
82460
+ }, IDLE_FLUSH_MS);
82461
+ }
82462
+ clearIdle(p) {
82463
+ if (p.idleTimer) clearTimeout(p.idleTimer);
82464
+ p.idleTimer = void 0;
81912
82465
  }
81913
82466
  pending = /* @__PURE__ */ new Map();
81914
82467
  onAcpUpdate(_agentId, sessionId, update) {
81915
82468
  const p = this.pending.get(sessionId);
81916
82469
  if (!p) return;
81917
- for (const action of p.conv.onUpdate(update)) this.applyAction(action, p.conn, p.channel, p.thread).catch((err) => console.error("slack post failed:", err));
82470
+ for (const action of p.conv.onUpdate(update)) this.enqueueApply(p, action);
82471
+ this.armIdle(p);
81918
82472
  }
81919
82473
  async ensureHostAsync(agentId) {
81920
82474
  const host = this.ensureHost(agentId, this.cfg);
@@ -82302,7 +82856,7 @@ program.command("run").description("Run the daemon in the foreground").action(as
82302
82856
  }
82303
82857
  });
82304
82858
  program.command("mcp-bridge", { hidden: true }).description("internal: stdio MCP bridge to the running daemon").action(async () => {
82305
- const { runBridge } = await import("./bridge-Dj8U1Jp7.js");
82859
+ const { runBridge } = await import("./bridge-hZMpl3k6.js");
82306
82860
  await runBridge();
82307
82861
  });
82308
82862
  const controller = () => resolveController({ root: program.opts().root });