@agentconnect.md/daemon 1.0.0-rc.308 → 1.0.0-rc.309

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.
@@ -6366,6 +6366,12 @@ var LocalStore = class {
6366
6366
  effortOverride TEXT, permissionModeOverride TEXT, fastModeOverride INTEGER,
6367
6367
  outputModeOverride TEXT
6368
6368
  );
6369
+ -- A !stop can arrive while a cold session is still materializing, before the
6370
+ -- sessions row exists. Keep the mute independently keyed so that stop survives a
6371
+ -- daemon restart and is applied when the session row is eventually created.
6372
+ CREATE TABLE IF NOT EXISTS session_mutes (
6373
+ key TEXT PRIMARY KEY
6374
+ );
6369
6375
  -- Platform id → human display name (Slack channel/user names, daemon-resolved
6370
6376
  -- and cached here so session read-back can label ids without a Slack call).
6371
6377
  CREATE TABLE IF NOT EXISTS display_names (
@@ -6411,9 +6417,22 @@ var LocalStore = class {
6411
6417
  integrationId TEXT,
6412
6418
  callMeta TEXT,
6413
6419
  isQueueCmd INTEGER,
6420
+ loopGuardCounted INTEGER NOT NULL DEFAULT 0,
6414
6421
  enqueuedAt TEXT NOT NULL
6415
6422
  );
6416
6423
  CREATE INDEX IF NOT EXISTS inbox_fifo ON inbox (sessionKey, enqueuedAt);
6424
+ -- Conversation-wide spam/feedback-loop circuit. Unlike the in-memory dedup
6425
+ -- caches, this latch survives a daemon restart, so durable inbox replay cannot
6426
+ -- re-ignite a conversation that was already stopped by loop protection.
6427
+ CREATE TABLE IF NOT EXISTS loop_guard (
6428
+ scopeKey TEXT PRIMARY KEY,
6429
+ windowStartedAt INTEGER NOT NULL,
6430
+ totalCount INTEGER NOT NULL,
6431
+ automaticWindowStartedAt INTEGER NOT NULL,
6432
+ automaticCount INTEGER NOT NULL,
6433
+ trippedAt INTEGER,
6434
+ reason TEXT
6435
+ );
6417
6436
  -- §3.4/§6.8 main-agent orchestration: a main agent fans out N subtasks to
6418
6437
  -- worker agents, then waits asynchronously and summarizes. The record is
6419
6438
  -- persisted BEFORE any delivery (record-first — a fast worker's reply must
@@ -6458,8 +6477,26 @@ var LocalStore = class {
6458
6477
  ON orchestration_subtask (orchestrationId, idx);
6459
6478
  `);
6460
6479
  this.migrateSessionUsage();
6480
+ this.migrateSessionMutes();
6481
+ this.migrateInboxLoopGuardCounted();
6461
6482
  this.migrateTranscriptToolBody();
6462
6483
  }
6484
+ /** Backfill the durable mute tombstones from the legacy sessions.muted column.
6485
+ * Both writes in setSessionMuted stay transactional, so this idempotent sync cannot
6486
+ * resurrect a mute that was explicitly cleared. */
6487
+ migrateSessionMutes() {
6488
+ this.db.exec(`
6489
+ CREATE TABLE IF NOT EXISTS session_mutes (key TEXT PRIMARY KEY);
6490
+ INSERT OR IGNORE INTO session_mutes (key) SELECT key FROM sessions WHERE muted = 1;
6491
+ UPDATE sessions SET muted = 1 WHERE key IN (SELECT key FROM session_mutes);
6492
+ `);
6493
+ }
6494
+ /** Rows written before loop protection had no per-delivery accounting marker. They
6495
+ * start at 0 so the first owner that can replay them charges them exactly once. */
6496
+ migrateInboxLoopGuardCounted() {
6497
+ if (!this.db.prepare("PRAGMA table_info(inbox)").all().some((c) => c.name === "loopGuardCounted")) this.db.exec("ALTER TABLE inbox ADD COLUMN loopGuardCounted INTEGER NOT NULL DEFAULT 0");
6498
+ else this.db.exec("UPDATE inbox SET loopGuardCounted = 0 WHERE loopGuardCounted IS NULL");
6499
+ }
6463
6500
  /** Add the `transcript.tool_call_id` + `transcript.body` columns (and the partial
6464
6501
  * unique index) to a pre-existing DB. CREATE TABLE IF NOT EXISTS above adds them for
6465
6502
  * fresh DBs but never alters an existing table, so upgrade in place. No-op once the
@@ -6551,11 +6588,19 @@ var LocalStore = class {
6551
6588
  };
6552
6589
  }
6553
6590
  upsertSession(rec) {
6554
- this.db.prepare(`INSERT INTO sessions (key, agentId, platform, channel, thread, acpSessionId, state, lastDeliveredTs, updatedAt, triggeredBy)
6555
- VALUES (@key, @agentId, @platform, @channel, @thread, @acpSessionId, @state, @lastDeliveredTs, @updatedAt, @triggeredBy)
6591
+ this.db.prepare(`INSERT INTO sessions
6592
+ (key, agentId, platform, channel, thread, acpSessionId, state, lastDeliveredTs, updatedAt, muted, triggeredBy)
6593
+ VALUES
6594
+ (@key, @agentId, @platform, @channel, @thread, @acpSessionId, @state, @lastDeliveredTs, @updatedAt,
6595
+ CASE WHEN EXISTS (SELECT 1 FROM session_mutes WHERE key = @key) THEN 1 ELSE NULL END,
6596
+ @triggeredBy)
6556
6597
  ON CONFLICT(key) DO UPDATE SET
6557
6598
  acpSessionId=excluded.acpSessionId, state=excluded.state,
6558
6599
  lastDeliveredTs=excluded.lastDeliveredTs, updatedAt=excluded.updatedAt,
6600
+ muted=CASE
6601
+ WHEN EXISTS (SELECT 1 FROM session_mutes WHERE key = excluded.key) THEN 1
6602
+ ELSE sessions.muted
6603
+ END,
6559
6604
  triggeredBy=COALESCE(sessions.triggeredBy, excluded.triggeredBy)`).run({
6560
6605
  key: rec.key,
6561
6606
  agentId: rec.agentId,
@@ -6575,11 +6620,20 @@ var LocalStore = class {
6575
6620
  setSessionState(key, state, updatedAt) {
6576
6621
  this.db.prepare("UPDATE sessions SET state = ?, updatedAt = ? WHERE key = ?").run(state, updatedAt, key);
6577
6622
  }
6578
- /** `!stop` thread mute: while set, the router must not auto-dispatch this session's
6579
- * thread to the agent (thread affinity / keyword / auto); an explicit @mention
6580
- * clears it. No-op on an unknown key. */
6623
+ /** `!stop` thread mute: the tombstone is written even before a session row exists,
6624
+ * then mirrored into sessions.muted for existing readers. An explicit @mention
6625
+ * clears both atomically. */
6581
6626
  setSessionMuted(key, muted) {
6582
- this.db.prepare("UPDATE sessions SET muted = ? WHERE key = ?").run(muted ? 1 : 0, key);
6627
+ this.db.exec("BEGIN");
6628
+ try {
6629
+ if (muted) this.db.prepare("INSERT OR IGNORE INTO session_mutes (key) VALUES (?)").run(key);
6630
+ else this.db.prepare("DELETE FROM session_mutes WHERE key = ?").run(key);
6631
+ this.db.prepare("UPDATE sessions SET muted = ? WHERE key = ?").run(muted ? 1 : 0, key);
6632
+ this.db.exec("COMMIT");
6633
+ } catch (err) {
6634
+ this.db.exec("ROLLBACK");
6635
+ throw err;
6636
+ }
6583
6637
  }
6584
6638
  /** Human-facing session title from the runtime's `session_info_update` (latest
6585
6639
  * wins; null clears per ACP semantics). No-op on an unknown key. */
@@ -6587,7 +6641,11 @@ var LocalStore = class {
6587
6641
  this.db.prepare("UPDATE sessions SET title = ? WHERE key = ?").run(title, key);
6588
6642
  }
6589
6643
  isSessionMuted(key) {
6590
- return this.db.prepare("SELECT muted FROM sessions WHERE key = ?").get(key)?.muted === 1;
6644
+ return this.db.prepare(`SELECT CASE
6645
+ WHEN EXISTS (SELECT 1 FROM session_mutes WHERE key = ?)
6646
+ OR EXISTS (SELECT 1 FROM sessions WHERE key = ? AND muted = 1)
6647
+ THEN 1 ELSE 0
6648
+ END AS muted`).get(key, key).muted === 1;
6591
6649
  }
6592
6650
  /** The session-scoped model override (set via the console's in-session model switch),
6593
6651
  * or undefined if the session runs on the agent's default. Sticky across turns and
@@ -6811,11 +6869,15 @@ var LocalStore = class {
6811
6869
  return this.db.prepare("SELECT lastRunAt FROM cron_runs WHERE key = ?").get(key)?.lastRunAt;
6812
6870
  }
6813
6871
  /** §6.9 #353 durable inbox: persist an admitted message BEFORE its admission ACK. Keyed
6814
- * by the stable id (deliveryId/msgId) with INSERT OR IGNORE, so a re-append of the same
6815
- * delivery (P2 retry / replay) is a no-op — idempotent against the admission maps. */
6872
+ * by the stable id (deliveryId/msgId); a re-append preserves the original payload/FIFO
6873
+ * position and may only advance the durable loop-accounting marker from 0 to 1. */
6816
6874
  appendInbox(row) {
6817
- this.db.prepare(`INSERT OR IGNORE INTO inbox (id, sessionKey, agentId, msg, integrationId, callMeta, isQueueCmd, enqueuedAt)
6818
- VALUES (@id, @sessionKey, @agentId, @msg, @integrationId, @callMeta, @isQueueCmd, @enqueuedAt)`).run({
6875
+ this.db.prepare(`INSERT INTO inbox
6876
+ (id, sessionKey, agentId, msg, integrationId, callMeta, isQueueCmd, loopGuardCounted, enqueuedAt)
6877
+ VALUES
6878
+ (@id, @sessionKey, @agentId, @msg, @integrationId, @callMeta, @isQueueCmd, @loopGuardCounted, @enqueuedAt)
6879
+ ON CONFLICT(id) DO UPDATE SET
6880
+ loopGuardCounted=MAX(inbox.loopGuardCounted, excluded.loopGuardCounted)`).run({
6819
6881
  id: row.id,
6820
6882
  sessionKey: row.sessionKey,
6821
6883
  agentId: row.agentId,
@@ -6823,6 +6885,7 @@ var LocalStore = class {
6823
6885
  integrationId: row.integrationId ?? null,
6824
6886
  callMeta: row.callMeta ?? null,
6825
6887
  isQueueCmd: row.isQueueCmd ?? null,
6888
+ loopGuardCounted: row.loopGuardCounted ?? 0,
6826
6889
  enqueuedAt: row.enqueuedAt
6827
6890
  });
6828
6891
  }
@@ -6836,6 +6899,129 @@ var LocalStore = class {
6836
6899
  listInboxBySessionKeyFifo() {
6837
6900
  return this.db.prepare("SELECT * FROM inbox ORDER BY sessionKey ASC, enqueuedAt ASC").all();
6838
6901
  }
6902
+ hasInbox(id) {
6903
+ return this.db.prepare("SELECT 1 FROM inbox WHERE id = ?").get(id) !== void 0;
6904
+ }
6905
+ /** Remove every durable turn owned by an agent. Agent pause is an operator stop,
6906
+ * not a drain/retry signal: work purged here must never resurrect after unpause +
6907
+ * restart. Returns the removed ids so the daemon can clear its live-id cache. */
6908
+ removeInboxByAgentId(agentId) {
6909
+ const rows = this.db.prepare("SELECT id FROM inbox WHERE agentId = ?").all(agentId);
6910
+ this.db.prepare("DELETE FROM inbox WHERE agentId = ?").run(agentId);
6911
+ return rows.map((row) => row.id);
6912
+ }
6913
+ /** Record one turn admission against a conversation-wide fixed window. A trusted
6914
+ * human boundary resets only the consecutive automatic counter; the total-rate
6915
+ * backstop deliberately keeps counting so a platform bug that misclassifies its
6916
+ * own events as human still eventually opens the circuit. */
6917
+ recordLoopGuardTurn(scopeKey, now, automatic, limits) {
6918
+ this.db.prepare(`DELETE FROM loop_guard
6919
+ WHERE trippedAt IS NULL AND windowStartedAt <= @cutoff AND automaticWindowStartedAt <= @cutoff`).run({ cutoff: now - limits.windowMs });
6920
+ const current = this.getLoopGuard(scopeKey);
6921
+ if (current?.trippedAt !== null && current?.trippedAt !== void 0) return {
6922
+ allowed: false,
6923
+ trippedNow: false,
6924
+ totalCount: current.totalCount,
6925
+ automaticCount: current.automaticCount,
6926
+ ...current.reason ? { reason: current.reason } : {}
6927
+ };
6928
+ const totalWindowExpired = !current || now - current.windowStartedAt >= limits.windowMs;
6929
+ const automaticWindowExpired = !current || now - current.automaticWindowStartedAt >= limits.windowMs;
6930
+ const windowStartedAt = totalWindowExpired ? now : current.windowStartedAt;
6931
+ const totalCount = totalWindowExpired ? 1 : current.totalCount + 1;
6932
+ const automaticWindowStartedAt = automaticWindowExpired || !automatic ? now : current.automaticWindowStartedAt;
6933
+ const automaticCount = automatic ? automaticWindowExpired ? 1 : current.automaticCount + 1 : 0;
6934
+ const reason = automaticCount > limits.maxAutomatic ? "automatic_turn_burst" : totalCount > limits.maxTotal ? "turn_rate_burst" : void 0;
6935
+ const trippedAt = reason ? now : null;
6936
+ this.db.prepare(`INSERT INTO loop_guard
6937
+ (scopeKey, windowStartedAt, totalCount, automaticWindowStartedAt, automaticCount, trippedAt, reason)
6938
+ VALUES (@scopeKey, @windowStartedAt, @totalCount, @automaticWindowStartedAt, @automaticCount, @trippedAt, @reason)
6939
+ ON CONFLICT(scopeKey) DO UPDATE SET
6940
+ windowStartedAt=excluded.windowStartedAt,
6941
+ totalCount=excluded.totalCount,
6942
+ automaticWindowStartedAt=excluded.automaticWindowStartedAt,
6943
+ automaticCount=excluded.automaticCount,
6944
+ trippedAt=excluded.trippedAt,
6945
+ reason=excluded.reason`).run({
6946
+ scopeKey,
6947
+ windowStartedAt,
6948
+ totalCount,
6949
+ automaticWindowStartedAt,
6950
+ automaticCount,
6951
+ trippedAt,
6952
+ reason: reason ?? null
6953
+ });
6954
+ return {
6955
+ allowed: reason === void 0,
6956
+ trippedNow: reason !== void 0,
6957
+ totalCount,
6958
+ automaticCount,
6959
+ ...reason ? { reason } : {}
6960
+ };
6961
+ }
6962
+ /** Charge a migrated inbox delivery and advance its marker in one SQLite transaction.
6963
+ * A crash can therefore neither lose the charge nor charge the same retained row again
6964
+ * after ownership moves. A tripping delivery is intentionally left at marker 0: the
6965
+ * newly-open durable circuit makes its whole scope terminal and replay purges it. */
6966
+ recordLoopGuardTurnForInbox(inboxId, scopeKey, now, automatic, limits) {
6967
+ this.db.exec("BEGIN");
6968
+ try {
6969
+ const verdict = this.recordLoopGuardTurn(scopeKey, now, automatic, limits);
6970
+ if (verdict.allowed) {
6971
+ if (this.db.prepare("UPDATE inbox SET loopGuardCounted = 1 WHERE id = ?").run(inboxId).changes !== 1) throw new Error(`inbox delivery disappeared while charging loop guard: ${inboxId}`);
6972
+ }
6973
+ this.db.exec("COMMIT");
6974
+ return verdict;
6975
+ } catch (err) {
6976
+ this.db.exec("ROLLBACK");
6977
+ throw err;
6978
+ }
6979
+ }
6980
+ /** Open a loop circuit immediately for a structurally-invalid platform event. */
6981
+ tripLoopGuard(scopeKey, now, reason) {
6982
+ const current = this.getLoopGuard(scopeKey);
6983
+ if (current?.trippedAt !== null && current?.trippedAt !== void 0) return {
6984
+ allowed: false,
6985
+ trippedNow: false,
6986
+ totalCount: current.totalCount,
6987
+ automaticCount: current.automaticCount,
6988
+ ...current.reason ? { reason: current.reason } : {}
6989
+ };
6990
+ const row = current ?? {
6991
+ scopeKey,
6992
+ windowStartedAt: now,
6993
+ totalCount: 0,
6994
+ automaticWindowStartedAt: now,
6995
+ automaticCount: 0
6996
+ };
6997
+ this.db.prepare(`INSERT INTO loop_guard
6998
+ (scopeKey, windowStartedAt, totalCount, automaticWindowStartedAt, automaticCount, trippedAt, reason)
6999
+ VALUES (@scopeKey, @windowStartedAt, @totalCount, @automaticWindowStartedAt, @automaticCount, @trippedAt, @reason)
7000
+ ON CONFLICT(scopeKey) DO UPDATE SET trippedAt=excluded.trippedAt, reason=excluded.reason`).run({
7001
+ ...row,
7002
+ trippedAt: now,
7003
+ reason
7004
+ });
7005
+ return {
7006
+ allowed: false,
7007
+ trippedNow: true,
7008
+ totalCount: row.totalCount,
7009
+ automaticCount: row.automaticCount,
7010
+ reason
7011
+ };
7012
+ }
7013
+ getLoopGuard(scopeKey) {
7014
+ return this.db.prepare("SELECT * FROM loop_guard WHERE scopeKey = ?").get(scopeKey);
7015
+ }
7016
+ isLoopGuardOpen(scopeKey) {
7017
+ const row = this.getLoopGuard(scopeKey);
7018
+ return row?.trippedAt !== null && row?.trippedAt !== void 0;
7019
+ }
7020
+ /** Explicit operator/user reset. Purged inbox rows stay purged; reset only lets a
7021
+ * future fresh message start a new window. Returns whether a guard existed. */
7022
+ resetLoopGuard(scopeKey) {
7023
+ return this.db.prepare("DELETE FROM loop_guard WHERE scopeKey = ?").run(scopeKey).changes > 0;
7024
+ }
6839
7025
  /**
6840
7026
  * RECORD-FIRST (§3.4): persist the orchestration header + all subtask rows (status
6841
7027
  * 'pending') in ONE transaction, BEFORE any delivery. A fast worker's reply that
@@ -7004,6 +7190,30 @@ function attachmentMention(attachments) {
7004
7190
  }
7005
7191
  //#endregion
7006
7192
  //#region src/session/session-manager.ts
7193
+ function interrupted(signal) {
7194
+ return signal.reason instanceof Error ? signal.reason : new Error(typeof signal.reason === "string" ? signal.reason : "session initialization interrupted");
7195
+ }
7196
+ /** Await one initialization seam with a turn-scoped abort fence. The underlying I/O may
7197
+ * finish later, but its continuation cannot resume SessionManager.handle after abort. */
7198
+ function abortable(start, signal) {
7199
+ if (!signal) return Promise.resolve().then(start);
7200
+ if (signal.aborted) return Promise.reject(interrupted(signal));
7201
+ return new Promise((resolve, reject) => {
7202
+ const cleanup = () => signal.removeEventListener("abort", onAbort);
7203
+ const onAbort = () => {
7204
+ cleanup();
7205
+ reject(interrupted(signal));
7206
+ };
7207
+ signal.addEventListener("abort", onAbort, { once: true });
7208
+ Promise.resolve().then(start).then((result) => {
7209
+ cleanup();
7210
+ resolve(result);
7211
+ }, (err) => {
7212
+ cleanup();
7213
+ reject(err);
7214
+ });
7215
+ });
7216
+ }
7007
7217
  var SessionManager = class {
7008
7218
  deps;
7009
7219
  constructor(deps) {
@@ -7015,7 +7225,7 @@ var SessionManager = class {
7015
7225
  const dormant = this.deps.store.closedSessionAgents(channel, thread);
7016
7226
  return dormant.length === 1 ? dormant[0] : null;
7017
7227
  }
7018
- async handle(agentId, msg) {
7228
+ async handle(agentId, msg, signal) {
7019
7229
  const agent = this.deps.agentById(agentId);
7020
7230
  if (!agent) throw new Error(`unknown agent ${agentId}`);
7021
7231
  this.deps.memory.ensure({ agentId }, agent.name);
@@ -7032,9 +7242,9 @@ var SessionManager = class {
7032
7242
  text: mention ? `${msg.text}\n${mention}`.trim() : msg.text
7033
7243
  });
7034
7244
  let rec = this.deps.store.getSession(key);
7035
- const host = await this.deps.hostFor(agentId);
7245
+ const host = await abortable(() => this.deps.hostFor(agentId), signal);
7036
7246
  const effortOverride = this.deps.store.getEffortOverride(key);
7037
- const memoryIndex = (await this.deps.memory.injectAtSessionStart({ agentId })).trim();
7247
+ const memoryIndex = (await abortable(() => this.deps.memory.injectAtSessionStart({ agentId }), signal)).trim();
7038
7248
  const memoryAppend = memoryIndex ? `# Your memory\nThis is your memory INDEX from earlier sessions. Read a linked topic file with the \`readMemory\` tool when relevant; revise memory with \`writeMemory\`.\n\n${memoryIndex}` : "";
7039
7249
  const channelName = this.deps.store.getDisplayNames([msg.channel]).get(msg.channel);
7040
7250
  const agentMeta = [
@@ -7051,14 +7261,14 @@ var SessionManager = class {
7051
7261
  const metaContext = usesMeta ? sessionContext || void 0 : void 0;
7052
7262
  let created = false;
7053
7263
  if (!rec || !rec.acpSessionId) {
7054
- const cwd = await prepareWorkspace(agent);
7264
+ const cwd = await abortable(() => prepareWorkspace(agent), signal);
7055
7265
  const mcpServers = this.deps.mcpServersFor?.({
7056
7266
  agent,
7057
7267
  platform: msg.platform,
7058
7268
  channel: msg.channel,
7059
7269
  thread
7060
7270
  }) ?? [];
7061
- const acpSessionId = await host.newSession(cwd, mcpServers, effortOverride, metaContext);
7271
+ const acpSessionId = await abortable(() => host.newSession(cwd, mcpServers, effortOverride, metaContext), signal);
7062
7272
  created = true;
7063
7273
  rec = {
7064
7274
  key,
@@ -7074,7 +7284,8 @@ var SessionManager = class {
7074
7284
  };
7075
7285
  this.deps.store.upsertSession(rec);
7076
7286
  } else if (host.hasSession?.(rec.acpSessionId) === false) {
7077
- const cwd = await prepareWorkspace(agent);
7287
+ const persistedSessionId = rec.acpSessionId;
7288
+ const cwd = await abortable(() => prepareWorkspace(agent), signal);
7078
7289
  const mcpServers = this.deps.mcpServersFor?.({
7079
7290
  agent,
7080
7291
  platform: msg.platform,
@@ -7085,12 +7296,14 @@ var SessionManager = class {
7085
7296
  if (host.loadSupported?.()) {
7086
7297
  this.deps.store.setSessionState(key, "resuming", Date.now());
7087
7298
  try {
7088
- await host.loadSession(rec.acpSessionId, cwd, mcpServers, effortOverride, usesMeta ? agentMeta : void 0);
7299
+ await abortable(() => host.loadSession(persistedSessionId, cwd, mcpServers, effortOverride, usesMeta ? agentMeta : void 0), signal);
7089
7300
  resumed = true;
7090
- } catch {}
7301
+ } catch {
7302
+ if (signal?.aborted) throw interrupted(signal);
7303
+ }
7091
7304
  }
7092
7305
  if (!resumed) {
7093
- const acpSessionId = await host.newSession(cwd, mcpServers, effortOverride, metaContext);
7306
+ const acpSessionId = await abortable(() => host.newSession(cwd, mcpServers, effortOverride, metaContext), signal);
7094
7307
  created = true;
7095
7308
  rec = {
7096
7309
  ...rec,
@@ -7104,7 +7317,7 @@ var SessionManager = class {
7104
7317
  }
7105
7318
  if (rec.lastDeliveredTs === null && thread !== ts && msg.platform !== "webchat" && this.deps.fetchThreadHistory) {
7106
7319
  if (!this.deps.store.transcriptSince(msg.channel, thread, null).some((e) => e.ts === thread)) {
7107
- const history = await this.deps.fetchThreadHistory(agentId, msg.channel, thread);
7320
+ const history = await abortable(() => this.deps.fetchThreadHistory(agentId, msg.channel, thread), signal);
7108
7321
  for (const h of history) this.deps.store.appendTranscript({
7109
7322
  channel: msg.channel,
7110
7323
  thread,
@@ -7133,11 +7346,11 @@ var SessionManager = class {
7133
7346
  text: msg.text
7134
7347
  });
7135
7348
  if (msg.attachments?.length) {
7136
- const attBlocks = await buildAttachmentBlocks(msg.attachments, {
7349
+ const attBlocks = await abortable(() => buildAttachmentBlocks(msg.attachments, {
7137
7350
  download: (att) => this.deps.downloadAttachment?.(agentId, att) ?? Promise.resolve(null),
7138
7351
  supports: (kind) => host.promptSupports?.(kind) ?? false,
7139
7352
  ...this.deps.attachmentMaxBytes !== void 0 ? { maxBytes: this.deps.attachmentMaxBytes } : {}
7140
- });
7353
+ }), signal);
7141
7354
  blocks.push(...attBlocks);
7142
7355
  }
7143
7356
  if (created && !usesMeta && sessionContext) blocks.unshift({
@@ -7293,6 +7506,7 @@ function guessMimeFromUrl(url) {
7293
7506
  * missing connection — the caller turns that into an MCP `isError` result.
7294
7507
  */
7295
7508
  async function executeTool(ctx, name, args, deps) {
7509
+ if (deps.canRun && !deps.canRun(ctx)) throw new Error("this agent turn has been stopped");
7296
7510
  if (name === "listMemory" || name === "readMemory" || name === "writeMemory") {
7297
7511
  const scope = { agentId: ctx.agentId };
7298
7512
  try {
@@ -11167,6 +11381,7 @@ function routeRules(msg, rules, threadOwner, explicitAgentId) {
11167
11381
  const COMMAND_PREFIXES = ["!", "/"];
11168
11382
  const STOP_WORDS = /* @__PURE__ */ new Set(["stop"]);
11169
11383
  const CANCEL_WORDS = /* @__PURE__ */ new Set(["cancel"]);
11384
+ const RESUME_WORDS = /* @__PURE__ */ new Set(["resume"]);
11170
11385
  const QUEUE_WORDS = /* @__PURE__ */ new Set(["queue"]);
11171
11386
  const STATUS_WORDS = /* @__PURE__ */ new Set(["status"]);
11172
11387
  const FAST_WORDS = /* @__PURE__ */ new Set(["fast"]);
@@ -11195,6 +11410,7 @@ function parseCommand(raw) {
11195
11410
  const arg = (m[2] ?? "").trim();
11196
11411
  if (STOP_WORDS.has(word)) return { kind: "stop" };
11197
11412
  if (CANCEL_WORDS.has(word)) return { kind: "cancel" };
11413
+ if (RESUME_WORDS.has(word)) return { kind: "resume" };
11198
11414
  if (QUEUE_WORDS.has(word)) return {
11199
11415
  kind: "queue",
11200
11416
  text: arg
@@ -68467,6 +68683,19 @@ function consolidateShared(agents) {
68467
68683
  }
68468
68684
  return groups;
68469
68685
  }
68686
+ /** Only user-authored chat shapes enter routing. Slack emits edits, deletes,
68687
+ * assistant-thread metadata/title updates, and system records through the same generic
68688
+ * `message` listener. In particular, `message_changed` keeps the actual sender and
68689
+ * text in a nested `message` object; normalizing that outer wrapper would turn it
68690
+ * into an anonymous empty DM and could feed our own update back into the agent.
68691
+ * `me_message` and thread broadcasts still carry genuine user text and must remain
68692
+ * routable. Keep this allowlist aligned with the relay's shared Slack ingest. */
68693
+ function isRoutableMessageSubtype(subtype) {
68694
+ return subtype === void 0 || subtype === "file_share" || subtype === "me_message" || subtype === "thread_broadcast" || subtype === "reply_broadcast";
68695
+ }
68696
+ function isRoutableMessageEvent(ev) {
68697
+ return Boolean(ev.user) && !ev.bot_id && !ev.hidden && ev.message === void 0 && isRoutableMessageSubtype(ev.subtype);
68698
+ }
68470
68699
  /** Cap on members enriched per `listChannelMembers` call (bounds users.info fan-out). */
68471
68700
  const MEMBER_ENRICH_CAP = 50;
68472
68701
  /**
@@ -68558,8 +68787,8 @@ var SlackConnection = class {
68558
68787
  };
68559
68788
  this.app.message(async ({ message }) => {
68560
68789
  const ev = message;
68561
- if (ev.type !== "message" || !ev.channel) {
68562
- log?.debug(`slack: inbound event ignored (type=${ev.type}, channel=${ev.channel ?? "none"})`);
68790
+ if (ev.type !== "message" || !ev.channel || !isRoutableMessageEvent(ev)) {
68791
+ log?.debug(`slack: inbound event ignored (type=${ev.type}, subtype=${ev.subtype ?? "none"}, channel=${ev.channel ?? "none"})`);
68563
68792
  return;
68564
68793
  }
68565
68794
  deliver(ev, "message");
@@ -118741,7 +118970,7 @@ function normalizeTelegramMessage(msg, ctx) {
118741
118970
  * like Slack Socket Mode. One connection per bot token.
118742
118971
  *
118743
118972
  * Control surface: unlike Slack (interactive status bar + modal), Telegram exposes
118744
- * session state and controls via slash commands — `/status`, `/stop`, `/cancel`,
118973
+ * session state and controls via slash commands — `/status`, `/stop`, `/cancel`, `/resume`,
118745
118974
  * `/fast`, `/queue`, parsed by the daemon like any inbound text. start() registers
118746
118975
  * them with BotFather (setMyCommands) so they autocomplete in the Telegram UI.
118747
118976
  */
@@ -118761,6 +118990,10 @@ const BOT_COMMANDS = [
118761
118990
  command: "cancel",
118762
118991
  description: "Cancel the current turn (keep the session live)"
118763
118992
  },
118993
+ {
118994
+ command: "resume",
118995
+ description: "Reset loop protection and unmute this conversation"
118996
+ },
118764
118997
  {
118765
118998
  command: "fast",
118766
118999
  description: "Toggle fast mode — /fast on | off"
@@ -195408,6 +195641,10 @@ const DISCORD_APP_COMMANDS = [
195408
195641
  name: "cancel",
195409
195642
  description: "Cancel the in-flight turn (session stays live)"
195410
195643
  },
195644
+ {
195645
+ name: "resume",
195646
+ description: "Reset loop protection and unmute this conversation"
195647
+ },
195411
195648
  {
195412
195649
  name: "queue",
195413
195650
  description: "Queue a message to run once the agent is idle",
@@ -214877,6 +215114,9 @@ function formatErr(err) {
214877
215114
  return e?.stack ?? String(err);
214878
215115
  }
214879
215116
  const MAX_QUEUED_PER_SESSION = 10;
215117
+ const LOOP_GUARD_WINDOW_MS = 6e4;
215118
+ const MAX_AUTOMATIC_TURNS_PER_WINDOW = 8;
215119
+ const MAX_TOTAL_TURNS_PER_WINDOW = 60;
214880
215120
  /** Thrown to a `dispatch()` caller when the per-session admission queue is at its depth
214881
215121
  * cap (§4.4 backpressure): the message is fast-failed, not buffered. Carries a stable
214882
215122
  * `reason` so an agent-call source (P1/P2) can surface a typed `queue_full`. */
@@ -214937,6 +215177,38 @@ const DEFAULT_WEB_APP_URL = "https://app.agentconnect.md";
214937
215177
  /** Cap on agent→agent hop depth (design §2.4/§4.5) — reject a `messageAgent` that
214938
215178
  * would push the outgoing hopCount past this, so an A↔B wake loop can't run away. */
214939
215179
  const MAX_AGENT_CALL_HOPS = 8;
215180
+ /** One durable loop-guard scope shared by every agent in a conversation. DMs are
215181
+ * keyed at channel level because malformed platform wrappers may lose thread
215182
+ * coordinates; threaded channel conversations retain their canonical thread. */
215183
+ function loopGuardScopeFromCoords(platform, channel, thread, isDm) {
215184
+ return `${platform}:${channel}:${isDm ? "dm" : thread}`;
215185
+ }
215186
+ function slackTopLevelLoopGuardScope(channel) {
215187
+ return `slack:${channel}:top-level`;
215188
+ }
215189
+ function loopGuardScope(msg) {
215190
+ if (msg.platform === "slack" && !msg.isDm) {
215191
+ const prefix = `slack:${msg.channel}:`;
215192
+ const eventTs = msg.msgId.startsWith(prefix) ? msg.msgId.slice(prefix.length) : void 0;
215193
+ if (eventTs !== void 0 && msg.thread === eventTs) return slackTopLevelLoopGuardScope(msg.channel);
215194
+ }
215195
+ return loopGuardScopeFromCoords(msg.platform, msg.channel, msg.thread ?? msg.msgId, msg.isDm);
215196
+ }
215197
+ function isTrustedHumanTurn(msg) {
215198
+ return msg.source === "user" && !msg.sender.isBot && msg.sender.id !== "unknown";
215199
+ }
215200
+ /** The coarse rate circuit protects platform chat ingress. Agent calls have an exact
215201
+ * trusted hop cap; cron/hooks are operator automation; webchat has a separate sync ACK
215202
+ * contract and no in-band !resume surface. */
215203
+ function usesLoopGuard(msg) {
215204
+ return msg.source === "user" && msg.platform !== "webchat";
215205
+ }
215206
+ /** The exact poison shape produced when a Slack message_changed/assistant metadata
215207
+ * wrapper was normalized as if it were a real message. No supported platform has a
215208
+ * legitimate anonymous, empty, attachment-less user turn, so fail closed and latch. */
215209
+ function isMalformedPlatformTurn(msg) {
215210
+ return msg.platform === "slack" && msg.source === "user" && !msg.sender.isBot && msg.sender.id === "unknown" && msg.text.trim() === "" && (msg.attachments?.length ?? 0) === 0;
215211
+ }
214940
215212
  /** Build the wire SessionKey (protocol §5) for a pending turn — what `drain/done`
214941
215213
  * reports as released so the CP may reassign it. Uses the real `thread` (absent for
214942
215214
  * a channel-root message), NOT `statusThread` (which falls back to msgId): the CP
@@ -214990,6 +215262,8 @@ var Daemon = class Daemon {
214990
215262
  tgConnByIntegration = /* @__PURE__ */ new Map();
214991
215263
  dcConnByIntegration = /* @__PURE__ */ new Map();
214992
215264
  hostStarts = /* @__PURE__ */ new Map();
215265
+ hostStartGeneration = /* @__PURE__ */ new Map();
215266
+ hostStartAborts = /* @__PURE__ */ new Map();
214993
215267
  watcher;
214994
215268
  debounceTimer;
214995
215269
  agentsDir = "";
@@ -215020,16 +215294,21 @@ var Daemon = class Daemon {
215020
215294
  requestExit;
215021
215295
  draining = false;
215022
215296
  drainingAgents = /* @__PURE__ */ new Set();
215297
+ safetyDrainingAgents = /* @__PURE__ */ new Set();
215298
+ safetyDrainWaits = /* @__PURE__ */ new Map();
215299
+ safetyDrainRuns = /* @__PURE__ */ new Map();
215023
215300
  moveStagedAgents = /* @__PURE__ */ new Set();
215024
215301
  moveStageMetadata = /* @__PURE__ */ new Map();
215025
215302
  activatingAgents = /* @__PURE__ */ new Set();
215026
215303
  agentMoveTails = /* @__PURE__ */ new Map();
215027
215304
  agentMoveInFlight = /* @__PURE__ */ new Map();
215028
215305
  activeDispatchesByAgent = /* @__PURE__ */ new Map();
215306
+ activeDispatchDoneByKey = /* @__PURE__ */ new Map();
215029
215307
  activeReplyConnectionUses = /* @__PURE__ */ new Map();
215030
215308
  hostStopping = /* @__PURE__ */ new Map();
215031
215309
  idleSweepTimer;
215032
215310
  cancelTimers = /* @__PURE__ */ new Map();
215311
+ coldCancelTimers = /* @__PURE__ */ new Map();
215033
215312
  slackRetryTimers = /* @__PURE__ */ new Map();
215034
215313
  slackRetryRuns = /* @__PURE__ */ new Map();
215035
215314
  constructor(opts = {}) {
@@ -215144,6 +215423,11 @@ var Daemon = class Daemon {
215144
215423
  socketPath: mcpSocketPath(root),
215145
215424
  log: this.log,
215146
215425
  now: () => Date.now(),
215426
+ canRun: (ctx) => {
215427
+ const key = sessionKey(ctx.platform, ctx.channel, ctx.thread, ctx.agentId);
215428
+ const active = this.activeGateEntries.get(key);
215429
+ return !this.paused(ctx.agentId) && active !== void 0 && !active.cancelledReason;
215430
+ },
215147
215431
  gatewayFor: (integrationId) => this.connByIntegration.get(integrationId) ?? this.tgConnByIntegration.get(integrationId) ?? this.dcConnByIntegration.get(integrationId),
215148
215432
  channelAgents: (req) => {
215149
215433
  const client = this.cpClient;
@@ -215333,33 +215617,34 @@ var Daemon = class Daemon {
215333
215617
  }).join(", ")}]`);
215334
215618
  else this.log.debug(`reconcile: no changes (${desired.length} desired agent(s))`);
215335
215619
  for (const id of toStop) {
215336
- const host = this.hosts.get(id);
215337
- if (host) {
215338
- await host.stop();
215339
- this.hosts.delete(id);
215340
- }
215341
- this.hostStarts.delete(id);
215620
+ const wasDraining = this.drainingAgents.has(id);
215621
+ this.drainingAgents.add(id);
215622
+ this.interruptAgentTurns(id, "stop");
215342
215623
  this.agents.delete(id);
215343
215624
  this.scheduler.unregister(id);
215625
+ await this.stopHost(id);
215626
+ if (!wasDraining && !this.moveStagedAgents.has(id)) this.drainingAgents.delete(id);
215344
215627
  }
215345
215628
  let connectionsDirty = toStart.length > 0 || toStop.length > 0;
215346
215629
  for (const change of toChange) {
215347
215630
  const a = change.agent;
215631
+ const wasPaused = this.agents.get(a.id)?.pause === true;
215348
215632
  this.agents.set(a.id, a);
215633
+ if (!wasPaused && a.pause) this.interruptAgentTurns(a.id, "pause");
215349
215634
  this.scheduler.sync(a.id, a.crons);
215350
215635
  if (change.hostRespawn || change.workspace) {
215351
- const host = this.hosts.get(a.id);
215352
- if (host) {
215353
- await host.stop();
215354
- this.hosts.delete(a.id);
215355
- }
215356
- this.hostStarts.delete(a.id);
215636
+ const wasDraining = this.drainingAgents.has(a.id);
215637
+ this.drainingAgents.add(a.id);
215638
+ this.interruptAgentTurns(a.id, "stop");
215639
+ await this.stopHost(a.id);
215640
+ if (!wasDraining) this.drainingAgents.delete(a.id);
215357
215641
  }
215358
215642
  if (change.workspace) this.prefetchClone(a);
215359
215643
  if (change.integrations) connectionsDirty = true;
215360
215644
  }
215361
215645
  for (const a of toStart) {
215362
215646
  this.agents.set(a.id, a);
215647
+ if (a.pause) this.purgeAgentInbox(a.id);
215363
215648
  this.prefetchClone(a);
215364
215649
  this.scheduler.sync(a.id, a.crons);
215365
215650
  }
@@ -215912,6 +216197,10 @@ var Daemon = class Daemon {
215912
216197
  this.canonicalizeTelegramThread(msg);
215913
216198
  const command = parseCommand(msg.text);
215914
216199
  if (command) {
216200
+ if (command.kind === "resume" && !isTrustedHumanTurn(msg)) {
216201
+ this.log.warn(`loop guard: ignored unauthenticated resume for ${loopGuardScope(msg)}`);
216202
+ return;
216203
+ }
215915
216204
  this.handleCommand(command, msg);
215916
216205
  return;
215917
216206
  }
@@ -215926,13 +216215,13 @@ var Daemon = class Daemon {
215926
216215
  return;
215927
216216
  }
215928
216217
  const muteKey = sessionKey(msg.platform, msg.channel, msg.thread ?? msg.msgId, result.agentId);
215929
- if (this.store.isSessionMuted(muteKey)) {
216218
+ if (this.isSessionMuted(muteKey)) {
215930
216219
  if (result.via !== "mention") {
215931
216220
  this.recordUnrouted(msg);
215932
216221
  this.log.debug(`routing: dropping ${msg.msgId} for agent "${result.agentId}" (muted by !stop; awaiting @mention)`);
215933
216222
  return;
215934
216223
  }
215935
- this.store.setSessionMuted(muteKey, false);
216224
+ this.setSessionMuted(muteKey, false);
215936
216225
  this.log.info(`routing: agent "${result.agentId}" un-muted in ch=${msg.channel} (explicit @mention)`);
215937
216226
  }
215938
216227
  this.log.info(`routing: ch=${msg.channel} → agent "${result.agentId}" (integration ${result.integrationId})`);
@@ -215983,7 +216272,7 @@ var Daemon = class Daemon {
215983
216272
  reason: "no_agent"
215984
216273
  };
215985
216274
  }
215986
- if (this.agents.get(result.agentId)?.pause) {
216275
+ if (this.paused(result.agentId)) {
215987
216276
  this.log.info(`webchat: agent "${result.agentId}" is paused — rejecting turn`);
215988
216277
  return {
215989
216278
  accepted: false,
@@ -215991,6 +216280,14 @@ var Daemon = class Daemon {
215991
216280
  reason: "paused"
215992
216281
  };
215993
216282
  }
216283
+ if (this.safetyDrainingAgents.has(result.agentId)) {
216284
+ this.log.info(`webchat: agent "${result.agentId}" is stopping an interrupted turn — rejecting turn`);
216285
+ return {
216286
+ accepted: false,
216287
+ turnId,
216288
+ reason: "busy"
216289
+ };
216290
+ }
215994
216291
  if (this.draining || this.drainingAgents.has(result.agentId)) {
215995
216292
  this.log.info(`webchat: agent "${result.agentId}" is draining — rejecting turn`);
215996
216293
  return {
@@ -216014,6 +216311,15 @@ var Daemon = class Daemon {
216014
216311
  isDm: true,
216015
216312
  trigger: "dm"
216016
216313
  };
216314
+ const key = this.webchatSessionKey(chatId, result.agentId);
216315
+ if (this.inflight.has(key) && (this.serialQueue.get(key)?.length ?? 0) >= MAX_QUEUED_PER_SESSION) {
216316
+ this.log.warn(`webchat: queue full for session ${key} — rejecting turn`);
216317
+ return {
216318
+ accepted: false,
216319
+ turnId,
216320
+ reason: "busy"
216321
+ };
216322
+ }
216017
216323
  this.dispatch(result.agentId, msg, void 0, {
216018
216324
  conversationId: chatId,
216019
216325
  turnId,
@@ -216134,6 +216440,10 @@ var Daemon = class Daemon {
216134
216440
  this.interruptTurn(p.agentId, p.sessionKey, "cancel", acpSessionId);
216135
216441
  return;
216136
216442
  }
216443
+ for (const [key, entry] of this.activeGateEntries) if (entry.webchat?.conversationId === conversationId) {
216444
+ this.interruptTurn(entry.agentId, key, "cancel");
216445
+ return;
216446
+ }
216137
216447
  for (const [key, entries] of this.serialQueue) {
216138
216448
  const hit = entries.find((e) => e.webchat?.conversationId === conversationId);
216139
216449
  if (hit) {
@@ -216187,6 +216497,31 @@ var Daemon = class Daemon {
216187
216497
  };
216188
216498
  }
216189
216499
  const normalized = msg.payload;
216500
+ const command = parseCommand(normalized.text);
216501
+ if (command) {
216502
+ if (command.kind === "resume" && !isTrustedHumanTurn(normalized)) {
216503
+ this.log.warn(`loop guard: ignored unauthenticated relay resume for ${loopGuardScope(normalized)}`);
216504
+ return {
216505
+ msgId: msg.msgId,
216506
+ accepted: false,
216507
+ reason: "unauthorized"
216508
+ };
216509
+ }
216510
+ const target = this.resolveExplicitCommandTarget(msg.agentId, msg.integrationId, normalized);
216511
+ if (!target) {
216512
+ this.log.warn(`relay: unauthorized command from ${normalized.sender.id} for agent ${msg.agentId}`);
216513
+ return {
216514
+ msgId: msg.msgId,
216515
+ accepted: false,
216516
+ reason: "unauthorized"
216517
+ };
216518
+ }
216519
+ this.handleCommand(command, normalized, target);
216520
+ return {
216521
+ msgId: msg.msgId,
216522
+ accepted: true
216523
+ };
216524
+ }
216190
216525
  this.dispatch(msg.agentId, normalized, msg.integrationId).catch((err) => this.log.error(`relay im dispatch failed for agent "${msg.agentId}": ${formatErr(err)}`));
216191
216526
  return {
216192
216527
  msgId: msg.msgId,
@@ -216294,6 +216629,16 @@ var Daemon = class Daemon {
216294
216629
  this.agentCallDeliveries.set(deliveryId, result);
216295
216630
  return result;
216296
216631
  };
216632
+ const callerKey = sessionKey(platform, req.callerChannel, req.callerThread, req.callerAgentId);
216633
+ const inbound = this.activeTurnCallMeta.get(callerKey);
216634
+ const sourceHopCount = inbound?.hopCount ?? 0;
216635
+ if (inbound !== void 0 && sourceHopCount >= MAX_AGENT_CALL_HOPS) return record({
216636
+ delivered: false,
216637
+ targetSession,
216638
+ reason: "hop_limit"
216639
+ });
216640
+ const isReply = inbound !== void 0 && req.toAgentId === inbound.callFrom;
216641
+ const correlationId = req.correlationId !== void 0 ? req.correlationId : isReply ? inbound.correlationId : void 0;
216297
216642
  const target = this.agents.get(req.toAgentId);
216298
216643
  if (!target) {
216299
216644
  const coordPlatform = platform === "hook" ? "slack" : platform;
@@ -216302,7 +216647,9 @@ var Daemon = class Daemon {
216302
216647
  channel,
216303
216648
  thread,
216304
216649
  deliveryId,
216305
- targetSession
216650
+ targetSession,
216651
+ sourceHopCount,
216652
+ correlationId
216306
216653
  }));
216307
216654
  }
216308
216655
  if (target.callPolicy === "selected" && !target.allowedCallerAgentIds.includes(req.callerAgentId)) {
@@ -216313,16 +216660,7 @@ var Daemon = class Daemon {
216313
216660
  reason: "not_allowed"
216314
216661
  });
216315
216662
  }
216316
- const callerKey = sessionKey(platform, req.callerChannel, req.callerThread, req.callerAgentId);
216317
- const inbound = this.activeTurnCallMeta.get(callerKey);
216318
216663
  const hopCount = inbound ? inbound.hopCount + 1 : 0;
216319
- if (hopCount > MAX_AGENT_CALL_HOPS) return record({
216320
- delivered: false,
216321
- targetSession,
216322
- reason: "hop_limit"
216323
- });
216324
- const isReply = inbound !== void 0 && req.toAgentId === inbound.callFrom;
216325
- const correlationId = req.correlationId !== void 0 ? req.correlationId : isReply ? inbound.correlationId : void 0;
216326
216664
  const callMeta = {
216327
216665
  callFrom: req.callerAgentId,
216328
216666
  ...correlationId !== void 0 ? { correlationId } : {},
@@ -216359,9 +216697,8 @@ var Daemon = class Daemon {
216359
216697
  * §2.3/§6.2/§6.4, P2) and map the relay's typed admission verdict to a
216360
216698
  * {@link MessageAgentResult}. The `claimedFromAgentId` is our trusted caller — the
216361
216699
  * relay re-validates it against our authenticated daemonId (a forged claim is
216362
- * rejected there). `hopCount` is the source turn's depth (0 in P1's minimal
216363
- * cross-turn model); the relay increments it (+1, cap 8). The body never touches
216364
- * the CP — only the relay.
216700
+ * rejected there). `sourceHopCount` is the trusted source turn's depth; the relay
216701
+ * increments it (+1, cap 8). The body never touches the CP — only the relay.
216365
216702
  */
216366
216703
  async routeAgentMsgCrossDaemon(req, ctx) {
216367
216704
  if (!this.relays) return {
@@ -216379,8 +216716,8 @@ var Daemon = class Daemon {
216379
216716
  channel: ctx.channel,
216380
216717
  ...ctx.thread !== void 0 ? { thread: ctx.thread } : {}
216381
216718
  },
216382
- ...req.correlationId !== void 0 ? { correlationId: req.correlationId } : {},
216383
- hopCount: 0,
216719
+ ...ctx.correlationId !== void 0 ? { correlationId: ctx.correlationId } : {},
216720
+ hopCount: ctx.sourceHopCount,
216384
216721
  deliveryId: ctx.deliveryId
216385
216722
  });
216386
216723
  if (ack.delivered) return {
@@ -216651,7 +216988,7 @@ var Daemon = class Daemon {
216651
216988
  reason: "no_agent"
216652
216989
  };
216653
216990
  }
216654
- if (this.agents.get(msg.agentId)?.pause) {
216991
+ if (this.paused(msg.agentId)) {
216655
216992
  this.log.info(`hook: agent "${msg.agentId}" is paused — rejecting fire ${msg.msgId}`);
216656
216993
  return {
216657
216994
  msgId: msg.msgId,
@@ -216659,6 +216996,14 @@ var Daemon = class Daemon {
216659
216996
  reason: "paused"
216660
216997
  };
216661
216998
  }
216999
+ if (this.safetyDrainingAgents.has(msg.agentId)) {
217000
+ this.log.info(`hook: agent "${msg.agentId}" is stopping an interrupted turn — rejecting fire ${msg.msgId}`);
217001
+ return {
217002
+ msgId: msg.msgId,
217003
+ accepted: false,
217004
+ reason: "busy"
217005
+ };
217006
+ }
216662
217007
  if (this.draining || this.drainingAgents.has(msg.agentId)) {
216663
217008
  this.log.info(`hook: agent "${msg.agentId}" is draining — rejecting fire ${msg.msgId}`);
216664
217009
  return {
@@ -216877,10 +217222,20 @@ var Daemon = class Daemon {
216877
217222
  }
216878
217223
  inflight = /* @__PURE__ */ new Set();
216879
217224
  serialQueue = /* @__PURE__ */ new Map();
217225
+ /** Current head for every owned serial gate, including the cold pre-Pending phase.
217226
+ * Lets pause/loop trip latch cancellation onto work that cannot yet be found through
217227
+ * `pending` and would otherwise revive after a quick reset. */
217228
+ activeGateEntries = /* @__PURE__ */ new Map();
216880
217229
  /** §6.9 #353: stable ids of messages currently backed by a durable inbox row (admitted,
216881
217230
  * not yet terminal). Guards startup replay from re-admitting a row whose entry is already
216882
217231
  * live in the gate (idempotency — a duplicate/in-flight id is not double-processed). */
216883
217232
  liveInboxIds = /* @__PURE__ */ new Set();
217233
+ isSessionMuted(key) {
217234
+ return this.store.isSessionMuted(key);
217235
+ }
217236
+ setSessionMuted(key, muted) {
217237
+ this.store.setSessionMuted(key, muted);
217238
+ }
216884
217239
  /**
216885
217240
  * Resolve a command's target from the channel's latest session when the routing ladder
216886
217241
  * couldn't (no mention entity / thread / dm rule matched — e.g. a group `/status@bot`).
@@ -216902,25 +217257,71 @@ var Daemon = class Daemon {
216902
217257
  via: "thread"
216903
217258
  };
216904
217259
  }
217260
+ /** Validate the relay-arbitrated command target against the local agent spec. Shared
217261
+ * bot IMs bypass routeRules' arbitration, but must retain its bot rejection and
217262
+ * per-integration allowedUserIds authorization before executing a control command. */
217263
+ resolveExplicitCommandTarget(agentId, integrationId, msg) {
217264
+ if (!this.commandSenderAllowed(agentId, integrationId, msg)) return null;
217265
+ return {
217266
+ agentId,
217267
+ integrationId,
217268
+ via: "thread"
217269
+ };
217270
+ }
217271
+ /** Recovery path for a channel-wide Slack top-level loop latch. The triggering
217272
+ * message itself was rejected, so its warning thread may have no thread owner or
217273
+ * latest session to route a bare `!resume` through. Select only an integration
217274
+ * that independently authorizes this human, preferring an explicitly mentioned bot. */
217275
+ resolveTopLevelResumeTarget(msg) {
217276
+ if (msg.platform !== "slack" || msg.isDm || !this.store.isLoopGuardOpen(slackTopLevelLoopGuardScope(msg.channel))) return null;
217277
+ const candidates = [];
217278
+ for (const [agentId, agent] of this.agents) for (const integration of agent.integrations) {
217279
+ if (integration.platform !== "slack" || !this.commandSenderAllowed(agentId, integration.id, msg)) continue;
217280
+ const mentioned = integration.slack.botUserId !== void 0 && msg.mentionedBots.includes(integration.slack.botUserId);
217281
+ candidates.push({
217282
+ agentId,
217283
+ integrationId: integration.id,
217284
+ via: mentioned ? "mention" : "auto",
217285
+ mentioned
217286
+ });
217287
+ }
217288
+ candidates.sort((a, b) => Number(b.mentioned) - Number(a.mentioned) || a.agentId.localeCompare(b.agentId) || a.integrationId.localeCompare(b.integrationId));
217289
+ return candidates[0] ?? null;
217290
+ }
217291
+ /** Final command authorization at the concrete integration. CP routing rules do not
217292
+ * carry allowedUserIds themselves, so routing success alone is not an auth verdict. */
217293
+ commandSenderAllowed(agentId, integrationId, msg) {
217294
+ if (msg.sender.isBot) return false;
217295
+ const integration = this.agents.get(agentId)?.integrations.find((candidate) => candidate.id === integrationId && candidate.platform === msg.platform);
217296
+ if (!integration) return false;
217297
+ const allowed = integration.platform === "telegram" ? integration.telegram.allowedUserIds : integration.platform === "discord" ? integration.discord.allowedUserIds : integration.slack.allowedUserIds;
217298
+ return allowed.length === 0 || allowed.includes(msg.sender.id);
217299
+ }
216905
217300
  /**
216906
217301
  * Handle an in-conversation control command. Resolves the target agent via the
216907
217302
  * same routing ladder as a normal message (so thread-affinity + per-integration
216908
217303
  * `allowedUserIds` authz apply), then acts on that agent's session in this
216909
217304
  * (channel, thread).
216910
217305
  */
216911
- handleCommand(command, msg) {
216912
- let target = routeRules(msg, this.mergedRules(), (c, t) => this.sessions.threadOwner(c, t));
217306
+ handleCommand(command, msg, explicitTarget) {
217307
+ let target = explicitTarget ?? routeRules(msg, this.mergedRules(), (c, t) => this.sessions.threadOwner(c, t));
216913
217308
  if (!target) target = this.resolveCommandTargetFromLatest(msg);
217309
+ if (!target && command.kind === "resume") target = this.resolveTopLevelResumeTarget(msg);
216914
217310
  if (!target) {
216915
217311
  this.log.debug(`command: '${command.kind}' in ch=${msg.channel} — no agent resolved, ignoring`);
216916
217312
  return;
216917
217313
  }
217314
+ if (!this.commandSenderAllowed(target.agentId, target.integrationId, msg)) {
217315
+ this.log.warn(`command: '${command.kind}' rejected for unauthorized sender ${msg.sender.id}`);
217316
+ return;
217317
+ }
216918
217318
  const conn = this.replyConnFor(target.agentId, target.integrationId);
216919
217319
  const replyThread = msg.thread ?? msg.msgId;
216920
217320
  let thread = replyThread;
216921
217321
  let key = sessionKey(msg.platform, msg.channel, thread, target.agentId);
216922
217322
  let rec = this.store.getSession(key);
216923
- if (!rec) {
217323
+ const directGateActive = this.inflight.has(key) || this.activeGateEntries.has(key) || (this.serialQueue.get(key)?.length ?? 0) > 0;
217324
+ if (!rec && !directGateActive) {
216924
217325
  const latest = this.store.latestSession(target.agentId, msg.channel);
216925
217326
  if (latest) {
216926
217327
  rec = latest;
@@ -216936,8 +217337,28 @@ var Daemon = class Daemon {
216936
217337
  if (msg.platform === "telegram") conn.postMessage(msg.channel, text, replyThread, tgReplyTo);
216937
217338
  else conn.postMessage(msg.channel, text, replyThread);
216938
217339
  };
217340
+ if (command.kind === "resume") {
217341
+ const directScope = thread === replyThread ? loopGuardScope(msg) : loopGuardScopeFromCoords(msg.platform, msg.channel, thread, msg.isDm);
217342
+ const topLevelScope = msg.platform === "slack" && !msg.isDm ? slackTopLevelLoopGuardScope(msg.channel) : void 0;
217343
+ const scope = topLevelScope && this.store.isLoopGuardOpen(topLevelScope) ? topLevelScope : directScope;
217344
+ if ([...this.activeGateEntries.values()].some((entry) => entry.cancelledReason === "loop protection" && loopGuardScope(entry.msg) === scope) || [...this.pending.values()].some((pending) => {
217345
+ return pending.outputSuppressed === "loop protection" && pending.loopGuardScope === scope;
217346
+ })) {
217347
+ reply("Loop protection is still stopping the previous turn. Try `!resume` again in a moment.");
217348
+ return;
217349
+ }
217350
+ const wasOpen = this.store.isLoopGuardOpen(scope);
217351
+ this.store.resetLoopGuard(scope);
217352
+ const wasMuted = this.isSessionMuted(key);
217353
+ if (wasMuted) this.setSessionMuted(key, false);
217354
+ if (wasOpen || wasMuted) {
217355
+ this.log.info(`loop guard: explicitly reset ${scope} by ${msg.sender.id}`);
217356
+ reply("▶️ Resumed. Loop protection is reset; send a new message to continue.");
217357
+ } else reply("Loop protection is not active in this conversation.");
217358
+ return;
217359
+ }
216939
217360
  if (command.kind === "stop") {
216940
- if (rec) this.store.setSessionMuted(key, true);
217361
+ if (rec || inflight) this.setSessionMuted(key, true);
216941
217362
  const muteNote = "Muted in this thread — @mention me to resume.";
216942
217363
  if (!inflight) {
216943
217364
  reply(rec ? `🔇 Nothing is running. ${muteNote}` : "Nothing is running to stop.");
@@ -217245,6 +217666,7 @@ var Daemon = class Daemon {
217245
217666
  integrationId: entry.integrationId ?? null,
217246
217667
  callMeta: entry.callMeta ? JSON.stringify(entry.callMeta) : null,
217247
217668
  isQueueCmd: entry.isQueueCmd ? 1 : null,
217669
+ loopGuardCounted: 1,
217248
217670
  enqueuedAt: monotonicTs()
217249
217671
  });
217250
217672
  entry.inboxId = id;
@@ -217266,6 +217688,94 @@ var Daemon = class Daemon {
217266
217688
  this.log.warn(`durable inbox: remove failed for ${entry.inboxId}: ${err.message}`);
217267
217689
  }
217268
217690
  }
217691
+ /** Pause is an explicit operator stop, so admitted work for that agent becomes
217692
+ * terminal instead of surviving as restart replay. */
217693
+ purgeAgentInbox(agentId) {
217694
+ try {
217695
+ const ids = this.store.removeInboxByAgentId(agentId);
217696
+ for (const id of ids) this.liveInboxIds.delete(id);
217697
+ if (ids.length > 0) this.log.warn(`durable inbox: purged ${ids.length} row(s) for paused agent "${agentId}"`);
217698
+ return ids.length;
217699
+ } catch (err) {
217700
+ this.log.error(`durable inbox: failed to purge paused agent "${agentId}": ${err.message}`);
217701
+ return 0;
217702
+ }
217703
+ }
217704
+ /** Inbox rows intentionally keep the complete normalized message instead of a
217705
+ * duplicated conversation column. A loop trip is rare, so scanning this small,
217706
+ * already-bounded backlog keeps the persisted schema compatible while purging every
217707
+ * agent/session in the affected conversation. */
217708
+ purgeLoopScopeInbox(scope) {
217709
+ let removed = 0;
217710
+ try {
217711
+ for (const row of this.store.listInboxBySessionKeyFifo()) {
217712
+ let msg;
217713
+ try {
217714
+ msg = JSON.parse(row.msg);
217715
+ } catch {
217716
+ continue;
217717
+ }
217718
+ if (loopGuardScope(msg) !== scope) continue;
217719
+ this.store.removeInbox(row.id);
217720
+ this.liveInboxIds.delete(row.id);
217721
+ removed++;
217722
+ }
217723
+ } catch (err) {
217724
+ this.log.error(`loop guard: failed to purge inbox for ${scope}: ${err.message}`);
217725
+ }
217726
+ return removed;
217727
+ }
217728
+ isLoopGuardOpen(msg) {
217729
+ return usesLoopGuard(msg) && this.store.isLoopGuardOpen(loopGuardScope(msg));
217730
+ }
217731
+ /** First-open side effects for a durable conversation circuit: purge restart work,
217732
+ * drop every matching serial queue, cancel live ACP turns across ALL agents sharing
217733
+ * the conversation, and emit exactly one operator-facing warning. */
217734
+ onLoopGuardTripped(scope, reason, trigger) {
217735
+ const purged = this.purgeLoopScopeInbox(scope);
217736
+ const targets = /* @__PURE__ */ new Map();
217737
+ for (const [key, entry] of this.activeGateEntries) {
217738
+ if (loopGuardScope(entry.msg) !== scope) continue;
217739
+ entry.cancelledReason = "loop protection";
217740
+ targets.set(key, { agentId: entry.agentId });
217741
+ }
217742
+ for (const [acpSessionId, pending] of this.pending) if (pending.loopGuardScope === scope) targets.set(pending.sessionKey, {
217743
+ agentId: pending.agentId,
217744
+ acpSessionId
217745
+ });
217746
+ for (const [key, queued] of this.serialQueue) {
217747
+ const entry = queued.find((candidate) => loopGuardScope(candidate.msg) === scope);
217748
+ if (entry && !targets.has(key)) targets.set(key, { agentId: entry.agentId });
217749
+ }
217750
+ for (const [key, target] of targets) this.interruptTurn(target.agentId, key, "loop protection", target.acpSessionId, { dropQueued: true });
217751
+ this.log.warn(`loop guard: OPEN ${scope} reason=${reason}; interrupted=${targets.size}, purgedInbox=${purged}; explicit !resume required`);
217752
+ if (trigger.msg.headless) return;
217753
+ const conn = this.replyConnFor(trigger.agentId, trigger.integrationId);
217754
+ if (!conn) return;
217755
+ const warningThread = reason === "malformed_platform_event" && trigger.msg.isDm ? void 0 : trigger.msg.thread;
217756
+ conn.postMessage(trigger.msg.channel, "⚠️ Loop protection stopped this conversation and discarded its queued backlog. Send `!resume` when it is safe to continue.", warningThread).catch((err) => this.log.warn(`loop guard: warning post failed for ${scope}: ${err.message}`));
217757
+ }
217758
+ /** Count a genuine admission and run first-open side effects if its fixed-window
217759
+ * budget is exhausted. Returns false when this incoming turn must not enter the
217760
+ * serial gate. */
217761
+ admitLoopGuardTurn(agentId, msg, integrationId, inboxReplayId) {
217762
+ if (!usesLoopGuard(msg)) return true;
217763
+ const scope = loopGuardScope(msg);
217764
+ const limits = {
217765
+ windowMs: LOOP_GUARD_WINDOW_MS,
217766
+ maxTotal: MAX_TOTAL_TURNS_PER_WINDOW,
217767
+ maxAutomatic: MAX_AUTOMATIC_TURNS_PER_WINDOW
217768
+ };
217769
+ const verdict = inboxReplayId ? this.store.recordLoopGuardTurnForInbox(inboxReplayId, scope, this.clock.now(), !isTrustedHumanTurn(msg), limits) : this.store.recordLoopGuardTurn(scope, this.clock.now(), !isTrustedHumanTurn(msg), limits);
217770
+ if (verdict.allowed) return true;
217771
+ if (verdict.trippedNow) this.onLoopGuardTripped(scope, verdict.reason ?? "turn_burst", {
217772
+ agentId,
217773
+ msg,
217774
+ integrationId
217775
+ });
217776
+ else this.purgeLoopScopeInbox(scope);
217777
+ return false;
217778
+ }
217269
217779
  /**
217270
217780
  * Terminate a QUEUED entry's webchat sink when it is gate-dropped/rejected without ever
217271
217781
  * entering dispatchOne (drain/pause gate-drop, fail-stop of the queued rest, `!cancel`/`!stop`
@@ -217275,7 +217785,8 @@ var Daemon = class Daemon {
217275
217785
  * entries. Pass an `error` for reject/cancel/shutdown; omit it for a clean gate-drop.
217276
217786
  */
217277
217787
  terminateQueuedSink(entry, error) {
217278
- if (!entry.webchat) return;
217788
+ if (!entry.webchat || entry.webchat.doneSent) return;
217789
+ entry.webchat.doneSent = true;
217279
217790
  entry.webchat.sink.done({
217280
217791
  conversationId: entry.webchat.conversationId,
217281
217792
  turnId: entry.webchat.turnId,
@@ -217289,15 +217800,54 @@ var Daemon = class Daemon {
217289
217800
  resolve(null);
217290
217801
  return;
217291
217802
  }
217292
- if (this.agents.get(agentId).pause) {
217803
+ if (this.paused(agentId)) {
217293
217804
  this.log.info(`dispatch: skipped for agent "${agentId}" (paused)`);
217294
217805
  resolve(null);
217295
217806
  return;
217296
217807
  }
217808
+ if (this.safetyDrainingAgents.has(agentId)) {
217809
+ if (opts?.fromInboxReplay) this.waitForSafetyDrain(agentId).then(() => {
217810
+ if (this.draining || !this.agents.has(agentId)) {
217811
+ resolve(null);
217812
+ return;
217813
+ }
217814
+ if (opts.inboxReplayId !== void 0 && !this.store.hasInbox(opts.inboxReplayId)) {
217815
+ resolve(null);
217816
+ return;
217817
+ }
217818
+ this.dispatch(agentId, msg, integrationId, webchat, callMeta, opts, githubReply).then(resolve, reject);
217819
+ }).catch(reject);
217820
+ else {
217821
+ this.log.info(`dispatch: skipped for agent "${agentId}" (stopping interrupted work)`);
217822
+ resolve(null);
217823
+ }
217824
+ return;
217825
+ }
217297
217826
  const key = sessionKey(msg.platform, msg.channel, msg.thread ?? msg.msgId, agentId);
217827
+ const loopScope = loopGuardScope(msg);
217828
+ if (usesLoopGuard(msg) && this.store.isLoopGuardOpen(loopScope)) {
217829
+ if (opts?.fromInboxReplay) this.purgeLoopScopeInbox(loopScope);
217830
+ this.log.warn(`dispatch: skipped ${msg.msgId}; loop guard is open for ${loopScope}`);
217831
+ resolve(null);
217832
+ return;
217833
+ }
217834
+ if (isMalformedPlatformTurn(msg)) {
217835
+ if (msg.isDm) {
217836
+ const verdict = this.store.tripLoopGuard(loopScope, this.clock.now(), "malformed_platform_event");
217837
+ if (verdict.trippedNow) this.onLoopGuardTripped(loopScope, verdict.reason ?? "malformed_platform_event", {
217838
+ agentId,
217839
+ msg,
217840
+ integrationId
217841
+ });
217842
+ else this.purgeLoopScopeInbox(loopScope);
217843
+ } else this.log.warn(`dispatch: dropped malformed Slack platform event ${msg.msgId}`);
217844
+ resolve(null);
217845
+ return;
217846
+ }
217298
217847
  const entry = {
217299
217848
  agentId,
217300
217849
  msg,
217850
+ initAbort: new AbortController(),
217301
217851
  ...integrationId !== void 0 ? { integrationId } : {},
217302
217852
  ...webchat ? { webchat } : {},
217303
217853
  ...callMeta ? { callMeta } : {},
@@ -217313,12 +217863,20 @@ var Daemon = class Daemon {
217313
217863
  reject(new QueueFullError(key));
217314
217864
  return;
217315
217865
  }
217866
+ if (!opts?.replay && !this.admitLoopGuardTurn(agentId, msg, integrationId, opts?.inboxReplayId)) {
217867
+ resolve(null);
217868
+ return;
217869
+ }
217316
217870
  q.push(entry);
217317
217871
  this.serialQueue.set(key, q);
217318
217872
  this.persistInbox(entry, key);
217319
217873
  this.log.debug(`dispatch: queued behind in-flight turn for session ${key} (depth ${q.length})`);
217320
217874
  return;
217321
217875
  }
217876
+ if (!opts?.replay && !this.admitLoopGuardTurn(agentId, msg, integrationId, opts?.inboxReplayId)) {
217877
+ resolve(null);
217878
+ return;
217879
+ }
217322
217880
  this.persistInbox(entry, key);
217323
217881
  this.inflight.add(key);
217324
217882
  this.runLoop(key, entry);
@@ -217336,18 +217894,63 @@ var Daemon = class Daemon {
217336
217894
  paused(agentId) {
217337
217895
  return this.agents.get(agentId)?.pause === true;
217338
217896
  }
217339
- beginActiveDispatch(agentId) {
217897
+ /** Await a transient interrupt drain without broadening its target set. The wait set is
217898
+ * additive, so loop until the runner removes the admission latch; a second interrupt
217899
+ * that joins while we wait is therefore included automatically. */
217900
+ async waitForSafetyDrain(agentId) {
217901
+ while (this.safetyDrainingAgents.has(agentId)) await Promise.all([...this.safetyDrainWaits.get(agentId) ?? []]);
217902
+ }
217903
+ /** Gate new work until the selected active dispatches have fully unwound. Waits are
217904
+ * additive: overlapping interrupts on different keys of one agent join the same drain
217905
+ * instead of letting the first completion reopen admission too early. */
217906
+ beginSafetyDrain(agentId, reason, keys) {
217907
+ const selected = keys === void 0 ? [...this.activeDispatchesByAgent.get(agentId) ?? []] : [...keys].map((key) => this.activeDispatchDoneByKey.get(key)).filter((done) => done !== void 0);
217908
+ if (selected.length === 0) return;
217909
+ const waits = this.safetyDrainWaits.get(agentId) ?? /* @__PURE__ */ new Set();
217910
+ for (const done of selected) waits.add(done);
217911
+ this.safetyDrainWaits.set(agentId, waits);
217912
+ this.safetyDrainingAgents.add(agentId);
217913
+ if (this.safetyDrainRuns.has(agentId)) return;
217914
+ const token = Symbol(agentId);
217915
+ this.safetyDrainRuns.set(agentId, token);
217916
+ (async () => {
217917
+ try {
217918
+ while (true) {
217919
+ const pending = [...this.safetyDrainWaits.get(agentId) ?? []];
217920
+ if (pending.length === 0) break;
217921
+ await Promise.all(pending);
217922
+ const current = this.safetyDrainWaits.get(agentId);
217923
+ for (const done of pending) current?.delete(done);
217924
+ }
217925
+ } finally {
217926
+ if (this.safetyDrainRuns.get(agentId) !== token) return;
217927
+ this.safetyDrainWaits.delete(agentId);
217928
+ this.safetyDrainRuns.delete(agentId);
217929
+ this.safetyDrainingAgents.delete(agentId);
217930
+ this.log.info(`${reason}: interrupted turns fully stopped for agent "${agentId}"`);
217931
+ }
217932
+ })();
217933
+ }
217934
+ dispatchGateReason(entry) {
217935
+ if (entry.cancelledReason) return entry.cancelledReason;
217936
+ if (this.paused(entry.agentId)) return "pause";
217937
+ if (this.isLoopGuardOpen(entry.msg)) return "loop protection";
217938
+ }
217939
+ beginActiveDispatch(agentId, key) {
217340
217940
  let resolveDone;
217341
217941
  const done = new Promise((resolve) => resolveDone = resolve);
217342
217942
  const active = this.activeDispatchesByAgent.get(agentId) ?? /* @__PURE__ */ new Set();
217343
217943
  active.add(done);
217344
217944
  this.activeDispatchesByAgent.set(agentId, active);
217945
+ this.activeDispatchDoneByKey.set(key, done);
217345
217946
  let released = false;
217346
217947
  return () => {
217347
217948
  if (released) return;
217348
217949
  released = true;
217349
217950
  active.delete(done);
217350
217951
  if (active.size === 0) this.activeDispatchesByAgent.delete(agentId);
217952
+ this.clearColdCancelBackstop(key, done);
217953
+ if (this.activeDispatchDoneByKey.get(key) === done) this.activeDispatchDoneByKey.delete(key);
217351
217954
  resolveDone();
217352
217955
  };
217353
217956
  }
@@ -217377,53 +217980,64 @@ var Daemon = class Daemon {
217377
217980
  }
217378
217981
  async runLoop(key, firstEntry) {
217379
217982
  let entry = firstEntry;
217380
- while (entry) {
217381
- if (entry !== firstEntry && (this.draining || this.drainingAgents.has(entry.agentId) || this.paused(entry.agentId))) {
217382
- this.terminateQueuedSink(entry);
217383
- if (!this.draining) this.removeInbox(entry);
217384
- entry.resolve(null);
217385
- const rest = this.serialQueue.get(key) ?? [];
217386
- this.serialQueue.delete(key);
217387
- for (const e of rest) {
217388
- this.terminateQueuedSink(e);
217389
- if (!this.draining) this.removeInbox(e);
217390
- e.resolve(null);
217391
- }
217392
- this.inflight.delete(key);
217393
- return;
217394
- }
217395
- try {
217396
- const releaseDispatch = this.beginActiveDispatch(entry.agentId);
217397
- let sessionId;
217983
+ try {
217984
+ while (entry) {
217985
+ this.activeGateEntries.set(key, entry);
217986
+ if (entry !== firstEntry && (this.draining || this.drainingAgents.has(entry.agentId) || this.paused(entry.agentId))) {
217987
+ this.terminateQueuedSink(entry);
217988
+ if (!this.draining) this.removeInbox(entry);
217989
+ entry.resolve(null);
217990
+ const rest = this.serialQueue.get(key) ?? [];
217991
+ this.serialQueue.delete(key);
217992
+ for (const e of rest) {
217993
+ this.terminateQueuedSink(e);
217994
+ if (!this.draining) this.removeInbox(e);
217995
+ e.resolve(null);
217996
+ }
217997
+ this.inflight.delete(key);
217998
+ return;
217999
+ }
217398
218000
  try {
217399
- sessionId = await this.dispatchOne(entry, key);
217400
- } finally {
217401
- releaseDispatch();
218001
+ const releaseDispatch = this.beginActiveDispatch(entry.agentId, key);
218002
+ let sessionId;
218003
+ try {
218004
+ sessionId = await this.dispatchOne(entry, key);
218005
+ } finally {
218006
+ releaseDispatch();
218007
+ }
218008
+ if (!(this.draining && entry.cancelledReason === "shutdown")) this.removeInbox(entry);
218009
+ entry.resolve(sessionId);
218010
+ } catch (err) {
218011
+ if (!this.draining) this.removeInbox(entry);
218012
+ entry.reject(err);
218013
+ const rest = this.serialQueue.get(key) ?? [];
218014
+ this.serialQueue.delete(key);
218015
+ for (const e of rest) {
218016
+ const failStop = new FailStopError(key);
218017
+ this.terminateQueuedSink(e, failStop.message);
218018
+ if (!this.draining) this.removeInbox(e);
218019
+ e.reject(failStop);
218020
+ }
218021
+ this.inflight.delete(key);
218022
+ return;
217402
218023
  }
217403
- this.removeInbox(entry);
217404
- entry.resolve(sessionId);
217405
- } catch (err) {
217406
- if (!this.draining) this.removeInbox(entry);
217407
- entry.reject(err);
217408
- const rest = this.serialQueue.get(key) ?? [];
217409
- this.serialQueue.delete(key);
217410
- for (const e of rest) {
217411
- const failStop = new FailStopError(key);
217412
- this.terminateQueuedSink(e, failStop.message);
217413
- if (!this.draining) this.removeInbox(e);
217414
- e.reject(failStop);
217415
- }
217416
- this.inflight.delete(key);
217417
- return;
218024
+ const q = this.serialQueue.get(key);
218025
+ entry = q?.shift();
218026
+ if (q && q.length === 0) this.serialQueue.delete(key);
217418
218027
  }
217419
- const q = this.serialQueue.get(key);
217420
- entry = q?.shift();
217421
- if (q && q.length === 0) this.serialQueue.delete(key);
218028
+ this.inflight.delete(key);
218029
+ } finally {
218030
+ this.activeGateEntries.delete(key);
217422
218031
  }
217423
- this.inflight.delete(key);
217424
218032
  }
217425
218033
  async dispatchOne(entry, key) {
217426
218034
  const { agentId, msg, integrationId, webchat, callMeta, githubReply } = entry;
218035
+ const initialGate = this.dispatchGateReason(entry);
218036
+ if (initialGate) {
218037
+ this.terminateQueuedSink(entry);
218038
+ this.log.info(`dispatch: skipped admitted turn ${msg.msgId} (${initialGate})`);
218039
+ return null;
218040
+ }
217427
218041
  if (callMeta?.correlationId !== void 0) this.recordWorkerReport(key, callMeta, msg.text);
217428
218042
  const agent = this.agents.get(agentId);
217429
218043
  const rec = new TranscriptRecorder();
@@ -217436,11 +218050,15 @@ var Daemon = class Daemon {
217436
218050
  const releaseReplyConn = this.holdReplyConnection(replyConn);
217437
218051
  let handled;
217438
218052
  try {
217439
- handled = await this.sessions.handle(agentId, msg);
218053
+ handled = await this.sessions.handle(agentId, msg, entry.initAbort.signal);
217440
218054
  } catch (err) {
218055
+ const interrupted = this.dispatchGateReason(entry);
217441
218056
  try {
217442
218057
  this.store.setSessionState(key, "idle", this.clock.now());
217443
- this.surfaceTurnFailure(err, {
218058
+ if (interrupted) {
218059
+ this.terminateQueuedSink(entry, interrupted);
218060
+ this.showActivity(replyConn, msg.channel, statusThread, "");
218061
+ } else this.surfaceTurnFailure(err, {
217444
218062
  agentId,
217445
218063
  webchat,
217446
218064
  replyConn,
@@ -217451,9 +218069,27 @@ var Daemon = class Daemon {
217451
218069
  } finally {
217452
218070
  releaseReplyConn();
217453
218071
  }
218072
+ if (interrupted) return null;
217454
218073
  throw err;
217455
218074
  }
217456
218075
  const { sessionId, blocks, created } = handled;
218076
+ const initializedGate = this.dispatchGateReason(entry);
218077
+ if (initializedGate) {
218078
+ const interruptedSession = this.store.getSession(key);
218079
+ if (created && interruptedSession?.acpSessionId === sessionId) this.store.upsertSession({
218080
+ ...interruptedSession,
218081
+ acpSessionId: null,
218082
+ state: "idle",
218083
+ lastDeliveredTs: null,
218084
+ updatedAt: this.clock.now()
218085
+ });
218086
+ else this.store.setSessionState(key, "idle", this.clock.now());
218087
+ this.showActivity(replyConn, msg.channel, statusThread, "");
218088
+ this.terminateQueuedSink(entry);
218089
+ releaseReplyConn();
218090
+ this.log.info(`dispatch: skipped initialized turn ${msg.msgId} (${initializedGate})`);
218091
+ return null;
218092
+ }
217457
218093
  if (created) this.emitSessionMetadataSnapshot({
217458
218094
  sessionId,
217459
218095
  agentId,
@@ -217464,12 +218100,17 @@ var Daemon = class Daemon {
217464
218100
  });
217465
218101
  let resolveDone;
217466
218102
  const done = new Promise((r) => resolveDone = r);
218103
+ const pendingWebchat = webchat ? Object.assign(webchat, {
218104
+ index: 0,
218105
+ replyText: ""
218106
+ }) : void 0;
217467
218107
  const p = {
217468
218108
  conv,
217469
218109
  rec,
217470
218110
  agentId,
217471
218111
  platform: msg.platform,
217472
218112
  isDm: msg.isDm,
218113
+ loopGuardScope: loopGuardScope(msg),
217473
218114
  sessionKey: key,
217474
218115
  acpSessionId: sessionId,
217475
218116
  channel: msg.channel,
@@ -217481,11 +218122,7 @@ var Daemon = class Daemon {
217481
218122
  done,
217482
218123
  resolveDone,
217483
218124
  ...callMeta ? { callMeta } : {},
217484
- ...webchat ? { webchat: {
217485
- ...webchat,
217486
- index: 0,
217487
- replyText: ""
217488
- } } : {},
218125
+ ...pendingWebchat ? { webchat: pendingWebchat } : {},
217489
218126
  ...githubReply ? { github: {
217490
218127
  ...this.makeGithubReply(agentId, githubReply, sessionId),
217491
218128
  deferredFinalTranscript: false
@@ -217504,9 +218141,21 @@ var Daemon = class Daemon {
217504
218141
  if (effortOverride) await host.setSessionEffort(sessionId, effortOverride).catch((err) => this.log.debug(`effort override "${effortOverride}" not applied: ${err.message}`));
217505
218142
  const fastOverride = this.store.getFastModeOverride(key);
217506
218143
  if (fastOverride !== void 0) await host.setSessionFastMode(sessionId, fastOverride).catch((err) => this.log.debug(`fast-mode override ${fastOverride} not applied: ${err.message}`));
218144
+ const readyGate = this.dispatchGateReason(entry);
218145
+ if (readyGate) {
218146
+ this.showActivity(replyConn, msg.channel, statusThread, "");
218147
+ this.terminateQueuedSink(entry);
218148
+ if (readyGate === "loop protection") finalPhase = "problem";
218149
+ this.log.info(`dispatch: skipped ready turn ${msg.msgId} (${readyGate})`);
218150
+ return null;
218151
+ }
217507
218152
  if (!wasRunning) this.showActivity(replyConn, msg.channel, statusThread, "is thinking…");
217508
218153
  this.emitStatusBar(p);
217509
218154
  const { stopReason, usage } = await host.prompt(sessionId, blocks);
218155
+ if (p.outputSuppressed) {
218156
+ if (p.outputSuppressed === "loop protection") finalPhase = "problem";
218157
+ return null;
218158
+ }
217510
218159
  if (usage) this.store.setTokenUsage(key, {
217511
218160
  totalTokens: usage.totalTokens,
217512
218161
  inputTokens: usage.inputTokens,
@@ -217539,12 +218188,15 @@ var Daemon = class Daemon {
217539
218188
  kind: "text",
217540
218189
  text: p.webchat.replyText
217541
218190
  });
217542
- p.webchat.sink.done({
217543
- conversationId: p.webchat.conversationId,
217544
- turnId: p.webchat.turnId,
217545
- ...stopReason ? { stopReason } : {},
217546
- ...usage?.totalTokens !== void 0 ? { usage: { used: usage.totalTokens } } : {}
217547
- });
218191
+ if (!p.webchat.doneSent) {
218192
+ p.webchat.doneSent = true;
218193
+ p.webchat.sink.done({
218194
+ conversationId: p.webchat.conversationId,
218195
+ turnId: p.webchat.turnId,
218196
+ ...stopReason ? { stopReason } : {},
218197
+ ...usage?.totalTokens !== void 0 ? { usage: { used: usage.totalTokens } } : {}
218198
+ });
218199
+ }
217548
218200
  } else {
217549
218201
  const link = this.sessionLink(sessionId);
217550
218202
  const finals = conv instanceof TelegramConverger || conv instanceof DiscordConverger ? conv.onFinal(link) : conv.onFinal(link, this.webAppBase(), this.sharedFooterFor(agentId, integrationId));
@@ -217554,6 +218206,10 @@ var Daemon = class Daemon {
217554
218206
  await p.applyChain;
217555
218207
  } catch (err) {
217556
218208
  this.clearIdle(p);
218209
+ if (p.outputSuppressed) {
218210
+ if (p.outputSuppressed === "loop protection") finalPhase = "problem";
218211
+ return null;
218212
+ }
217557
218213
  finalPhase = "problem";
217558
218214
  if (p.webchat) {
217559
218215
  this.surfaceTurnFailure(err, {
@@ -217588,13 +218244,14 @@ var Daemon = class Daemon {
217588
218244
  }
217589
218245
  throw err;
217590
218246
  } finally {
218247
+ this.clearCancelBackstop(sessionId);
218248
+ if (p.outputSuppressed) await p.applyChain.catch(() => {});
217591
218249
  releaseReplyConn();
217592
218250
  this.clearIdle(p);
217593
218251
  this.releasePermissions(sessionId);
217594
218252
  this.releaseElicits(sessionId);
217595
218253
  if (callMeta) this.activeTurnCallMeta.delete(key);
217596
- this.clearCancelBackstop(sessionId);
217597
- const githubFinal = p.github && finalPhase === "end" ? p.github.collector.finalText(true) : void 0;
218254
+ const githubFinal = p.github && !p.outputSuppressed && finalPhase === "end" ? p.github.collector.finalText(true) : void 0;
217598
218255
  if (p.github?.deferredFinalTranscript && githubFinal?.trim()) this.store.appendTranscript({
217599
218256
  channel: p.channel,
217600
218257
  thread: p.statusThread,
@@ -217603,7 +218260,7 @@ var Daemon = class Daemon {
217603
218260
  kind: "text",
217604
218261
  text: githubFinal
217605
218262
  });
217606
- if (p.github) await p.github.poster.publish(githubFinal).catch((err) => this.log.warn(`github poster: final publish failed (${formatErr(err)})`));
218263
+ if (p.github && !p.outputSuppressed) await p.github.poster.publish(githubFinal).catch((err) => this.log.warn(`github poster: final publish failed (${formatErr(err)})`));
217607
218264
  this.pending.delete(sessionId);
217608
218265
  this.store.setSessionState(key, "idle", this.clock.now());
217609
218266
  this.emitSessionMetadataSnapshot({
@@ -217618,44 +218275,91 @@ var Daemon = class Daemon {
217618
218275
  }
217619
218276
  return sessionId;
217620
218277
  }
217621
- /** Interrupt a session by its LOGICAL sessionKey (shared by `!stop`, `!cancel`, and
217622
- * webchat cancel): reject+drop anything queued behind it in the serial gate, then — if
218278
+ /** Interrupt a session by its LOGICAL sessionKey (shared by pause, `!stop`, `!cancel`,
218279
+ * and webchat cancel): settle+drop anything queued behind it in the serial gate, then — if
217623
218280
  * a turn is actually in flight (ACP session live) — release any unanswered cards, mark
217624
218281
  * it `cancelling`, send ACP `session/cancel`, and arm the force backstop. Works for a
217625
218282
  * queued-but-not-yet-started or cold session too (§6.9 #390): draining the queue is
217626
218283
  * enough, no ACP id required. Muting is the CALLER's concern — `!stop` mutes,
217627
218284
  * `!cancel`/webchat-cancel do not. */
217628
- interruptTurn(agentId, key, reason, acpSessionId) {
218285
+ interruptTurn(agentId, key, reason, acpSessionId, opts = {}) {
218286
+ this.beginSafetyDrain(agentId, reason, [key]);
218287
+ const activeEntry = this.activeGateEntries.get(key);
218288
+ if (activeEntry) {
218289
+ activeEntry.cancelledReason ??= reason;
218290
+ this.removeInbox(activeEntry);
218291
+ }
217629
218292
  const queued = this.serialQueue.get(key);
217630
218293
  if (queued && queued.length > 0) {
217631
218294
  this.serialQueue.delete(key);
217632
218295
  for (const e of queued) {
217633
- this.terminateQueuedSink(e, reason);
218296
+ this.terminateQueuedSink(e, opts.dropQueued ? void 0 : reason);
217634
218297
  this.removeInbox(e);
217635
- e.reject(new FailStopError(key));
218298
+ if (opts.dropQueued) e.resolve(null);
218299
+ else e.reject(new FailStopError(key));
218300
+ }
218301
+ }
218302
+ const livePair = acpSessionId ? [acpSessionId, this.pending.get(acpSessionId)] : [...this.pending.entries()].find(([, pending]) => pending.sessionKey === key);
218303
+ const liveSessionId = livePair?.[0];
218304
+ const live = livePair?.[1];
218305
+ if (live) {
218306
+ live.outputSuppressed ??= reason;
218307
+ this.clearIdle(live);
218308
+ this.showActivity(live.conn, live.channel, live.statusThread, "");
218309
+ if (live.webchat && !live.webchat.doneSent) {
218310
+ live.webchat.doneSent = true;
218311
+ live.webchat.sink.done({
218312
+ conversationId: live.webchat.conversationId,
218313
+ turnId: live.webchat.turnId,
218314
+ error: reason
218315
+ });
217636
218316
  }
218317
+ } else if (activeEntry) {
218318
+ this.terminateQueuedSink(activeEntry, reason);
218319
+ this.showActivity(this.replyConnFor(activeEntry.agentId, activeEntry.integrationId), activeEntry.msg.channel, activeEntry.msg.thread ?? activeEntry.msg.msgId, "");
217637
218320
  }
217638
- this.log.info(`command: ${reason} → agent "${agentId}" session ${key}${acpSessionId ? ` (${acpSessionId})` : ""}`);
217639
- if (!acpSessionId || !this.pending.has(acpSessionId)) return;
217640
- this.releasePermissions(acpSessionId);
217641
- this.releaseElicits(acpSessionId);
218321
+ this.log.info(`command: ${reason} → agent "${agentId}" session ${key}${liveSessionId ? ` (${liveSessionId})` : ""}`);
218322
+ if (!liveSessionId || !live) {
218323
+ const active = this.activeDispatchDoneByKey.get(key);
218324
+ if (activeEntry && active) this.armColdCancelBackstop(agentId, key, reason, active);
218325
+ return;
218326
+ }
218327
+ this.releasePermissions(liveSessionId);
218328
+ this.releaseElicits(liveSessionId);
217642
218329
  this.store.setSessionState(key, "cancelling", this.clock.now());
217643
- this.hosts.get(agentId)?.cancel(acpSessionId).catch((err) => this.log.error(`command ${reason}: cancel failed: ${err.message}`));
217644
- this.armCancelBackstop(agentId, acpSessionId, key);
217645
- }
217646
- /** After `!stop` sends session/cancel, give the agent `cancelBackstopMs` to yield.
218330
+ this.hosts.get(agentId)?.cancel(liveSessionId).catch((err) => this.log.error(`command ${reason}: cancel failed: ${err.message}`));
218331
+ this.armCancelBackstop(agentId, liveSessionId, key, reason);
218332
+ }
218333
+ /** Interrupt every logical session owned by an agent for a lifecycle gate (pause,
218334
+ * removal, or host respawn). Active ACP turns are cancelled; queued turns are
218335
+ * cleanly gate-dropped. A cold head is latched so it cannot resume after teardown. */
218336
+ interruptAgentTurns(agentId, reason) {
218337
+ this.beginSafetyDrain(agentId, reason);
218338
+ this.purgeAgentInbox(agentId);
218339
+ const targets = /* @__PURE__ */ new Map();
218340
+ for (const [key, entry] of this.activeGateEntries) {
218341
+ if (entry.agentId !== agentId) continue;
218342
+ entry.cancelledReason = reason;
218343
+ targets.set(key, void 0);
218344
+ }
218345
+ for (const [acpSessionId, p] of this.pending) if (p.agentId === agentId) targets.set(p.sessionKey, acpSessionId);
218346
+ for (const [key, queued] of this.serialQueue) if (queued.some((entry) => entry.agentId === agentId) && !targets.has(key)) targets.set(key, this.store.getSession(key)?.acpSessionId ?? void 0);
218347
+ if (targets.size > 0) this.log.info(`${reason}: interrupting ${targets.size} active session(s) for agent "${agentId}"`);
218348
+ for (const [key, acpSessionId] of targets) this.interruptTurn(agentId, key, reason, acpSessionId, { dropQueued: true });
218349
+ }
218350
+ /** After an interrupt sends session/cancel, give the agent `cancelBackstopMs` to yield.
217647
218351
  * If the turn is still in flight when the timer fires, the agent ignored the
217648
218352
  * cancel — force-stop its host (the only hard kill available) so the session
217649
218353
  * can't be stuck in `cancelling` forever. dispatch's finally clears this timer
217650
218354
  * the moment the turn yields on its own. */
217651
- armCancelBackstop(agentId, acpSessionId, key) {
218355
+ armCancelBackstop(agentId, acpSessionId, key, reason) {
217652
218356
  this.clearCancelBackstop(acpSessionId);
217653
218357
  const ms = this.cfg.limits.cancelBackstopMs;
217654
218358
  this.cancelTimers.set(acpSessionId, this.clock.setTimeout(() => {
217655
218359
  this.cancelTimers.delete(acpSessionId);
217656
218360
  if (!this.pending.has(acpSessionId)) return;
217657
- this.log.warn(`command stop: agent "${agentId}" ignored session/cancel for ${ms}ms — force-stopping host (session ${acpSessionId})`);
217658
- this.stopHost(agentId, 0);
218361
+ this.log.warn(`${reason}: agent "${agentId}" ignored session/cancel for ${ms}ms — force-stopping host (session ${acpSessionId})`);
218362
+ this.stopHost(agentId, 0).catch((err) => this.log.error(`${reason}: force-stop failed for agent "${agentId}": ${formatErr(err)}`));
217659
218363
  this.store.setSessionState(key, "idle", this.clock.now());
217660
218364
  }, ms));
217661
218365
  }
@@ -217666,6 +218370,37 @@ var Daemon = class Daemon {
217666
218370
  this.cancelTimers.delete(acpSessionId);
217667
218371
  }
217668
218372
  }
218373
+ armColdCancelBackstop(agentId, key, reason, active) {
218374
+ this.clearColdCancelBackstop(key);
218375
+ const ms = this.cfg.limits.cancelBackstopMs;
218376
+ const timer = this.clock.setTimeout(() => {
218377
+ const current = this.coldCancelTimers.get(key);
218378
+ if (!current || current.active !== active) return;
218379
+ this.coldCancelTimers.delete(key);
218380
+ if (this.activeDispatchDoneByKey.get(key) !== active) return;
218381
+ const entry = this.activeGateEntries.get(key);
218382
+ if (!entry?.cancelledReason) return;
218383
+ this.log.warn(`${reason}: agent "${agentId}" did not finish cold initialization within ${ms}ms — force-stopping host (${key})`);
218384
+ const stopped = this.stopHost(agentId, 0).catch((err) => {
218385
+ this.log.error(`${reason}: cold force-stop failed for agent "${agentId}": ${formatErr(err)}`);
218386
+ return new Promise(() => {});
218387
+ });
218388
+ const waits = this.safetyDrainWaits.get(agentId) ?? /* @__PURE__ */ new Set();
218389
+ waits.add(stopped);
218390
+ this.safetyDrainWaits.set(agentId, waits);
218391
+ entry.initAbort.abort(new Error(reason));
218392
+ }, ms);
218393
+ this.coldCancelTimers.set(key, {
218394
+ timer,
218395
+ active
218396
+ });
218397
+ }
218398
+ clearColdCancelBackstop(key, active) {
218399
+ const current = this.coldCancelTimers.get(key);
218400
+ if (!current || active !== void 0 && current.active !== active) return;
218401
+ this.clock.clearTimeout(current.timer);
218402
+ this.coldCancelTimers.delete(key);
218403
+ }
217669
218404
  /**
217670
218405
  * Apply one converger action against the session's Slack connection:
217671
218406
  * - set-status → assistant.threads.setStatus (best-effort; '' clears)
@@ -218057,6 +218792,7 @@ var Daemon = class Daemon {
218057
218792
  * demand via `/status` (see handleCommand) — so it's skipped here. Headless no-ops in
218058
218793
  * applyAction (no connection), which is fine. */
218059
218794
  emitStatusBar(p) {
218795
+ if (p.outputSuppressed) return;
218060
218796
  const info = this.buildStatusInfo(p);
218061
218797
  const key = JSON.stringify(info);
218062
218798
  if (key === p.lastStatusBar) return;
@@ -218093,7 +218829,11 @@ var Daemon = class Daemon {
218093
218829
  * remembered message ts (two concurrent `progress` actions both posting). Routes to
218094
218830
  * the platform's applier by the Pending's platform tag. */
218095
218831
  enqueueApply(p, action) {
218096
- p.applyChain = p.applyChain.then(() => (p.platform === "telegram" ? this.applyTelegramAction(p, action) : p.platform === "discord" ? this.applyDiscordAction(p, action) : this.applySlackAction(p, action)).catch((err) => this.log.error(`apply failed: ${formatErr(err)}`)));
218832
+ if (p.outputSuppressed) return;
218833
+ p.applyChain = p.applyChain.then(() => {
218834
+ if (p.outputSuppressed) return;
218835
+ return (p.platform === "telegram" ? this.applyTelegramAction(p, action) : p.platform === "discord" ? this.applyDiscordAction(p, action) : this.applySlackAction(p, action)).catch((err) => this.log.error(`apply failed: ${formatErr(err)}`));
218836
+ });
218097
218837
  }
218098
218838
  /** (Re)arm the ~2s idle-flush timer when body text is buffered (§9.1 text-buffer):
218099
218839
  * a long pure-text stream posts in steps instead of all at turn end. */
@@ -218134,22 +218874,34 @@ var Daemon = class Daemon {
218134
218874
  async onAcpPermission(_agentId, sessionId, params) {
218135
218875
  const p = this.pending.get(sessionId);
218136
218876
  if (!p) return void 0;
218877
+ if (p.outputSuppressed) return { outcome: { outcome: "cancelled" } };
218137
218878
  const conn = p.conn;
218138
218879
  if (p.platform !== "slack" || !(conn instanceof SlackConnection)) return void 0;
218139
218880
  if (!params.options?.length) return void 0;
218140
218881
  const requestId = `perm-${++this.permSeq}`;
218141
218882
  const fallback = `Permission requested: ${params.toolCall?.title ?? "a tool call"}`;
218142
- const ts = await conn.postBlocks(p.channel, buildPermissionCard(requestId, params), fallback, p.statusThread).catch(() => void 0);
218143
- return await new Promise((resolve) => {
218144
- this.pendingPerms.set(requestId, {
218145
- sessionId,
218146
- params,
218147
- conn,
218148
- channel: p.channel,
218149
- ts,
218150
- resolve
218151
- });
218883
+ let resolveResult;
218884
+ const result = new Promise((resolve) => resolveResult = resolve);
218885
+ this.pendingPerms.set(requestId, {
218886
+ sessionId,
218887
+ params,
218888
+ conn,
218889
+ channel: p.channel,
218890
+ resolve: resolveResult
218152
218891
  });
218892
+ const ts = await conn.postBlocks(p.channel, buildPermissionCard(requestId, params), fallback, p.statusThread).catch(() => void 0);
218893
+ const live = this.pendingPerms.get(requestId);
218894
+ if (!live) {
218895
+ if (ts) conn.updateBlocks(p.channel, ts, buildPermissionResolvedCard(params, "Cancelled", void 0), "Permission cancelled").catch(() => {});
218896
+ return await result;
218897
+ }
218898
+ if (!ts) {
218899
+ this.pendingPerms.delete(requestId);
218900
+ live.resolve({ outcome: { outcome: "cancelled" } });
218901
+ return await result;
218902
+ }
218903
+ live.ts = ts;
218904
+ return await result;
218153
218905
  }
218154
218906
  /** A tapped permission-card button (SlackDeps.onPermissionChoice): resolve the pending
218155
218907
  * ACP request with the chosen option and edit the card in place to its resolved state
@@ -218189,6 +218941,7 @@ var Daemon = class Daemon {
218189
218941
  async onAcpElicit(_agentId, sessionId, params) {
218190
218942
  const p = this.pending.get(sessionId);
218191
218943
  if (!p) return void 0;
218944
+ if (p.outputSuppressed) return { action: "cancel" };
218192
218945
  const conn = p.conn;
218193
218946
  if (p.platform !== "slack" || !(conn instanceof SlackConnection)) return void 0;
218194
218947
  const target = elicitTarget(params);
@@ -218197,19 +218950,30 @@ var Daemon = class Daemon {
218197
218950
  const blocks = buildElicitationCard(requestId, params);
218198
218951
  if (!blocks) return void 0;
218199
218952
  const fallback = params.message ?? "The agent needs your input";
218200
- const ts = await conn.postBlocks(p.channel, blocks, fallback, p.statusThread).catch(() => void 0);
218201
- return await new Promise((resolve) => {
218202
- this.pendingElicits.set(requestId, {
218203
- sessionId,
218204
- params,
218205
- propName: target.propName,
218206
- kind: target.kind,
218207
- conn,
218208
- channel: p.channel,
218209
- ts,
218210
- resolve
218211
- });
218953
+ let resolveResult;
218954
+ const result = new Promise((resolve) => resolveResult = resolve);
218955
+ this.pendingElicits.set(requestId, {
218956
+ sessionId,
218957
+ params,
218958
+ propName: target.propName,
218959
+ kind: target.kind,
218960
+ conn,
218961
+ channel: p.channel,
218962
+ resolve: resolveResult
218212
218963
  });
218964
+ const ts = await conn.postBlocks(p.channel, blocks, fallback, p.statusThread).catch(() => void 0);
218965
+ const live = this.pendingElicits.get(requestId);
218966
+ if (!live) {
218967
+ if (ts) conn.updateBlocks(p.channel, ts, buildElicitationResolvedCard(params, ":hourglass: Cancelled"), "Cancelled").catch(() => {});
218968
+ return await result;
218969
+ }
218970
+ if (!ts) {
218971
+ this.pendingElicits.delete(requestId);
218972
+ live.resolve({ action: "cancel" });
218973
+ return await result;
218974
+ }
218975
+ live.ts = ts;
218976
+ return await result;
218213
218977
  }
218214
218978
  /** A tapped elicitation-card button (SlackDeps.onElicitChoice): resolve the pending ACP
218215
218979
  * request — `accept` with the chosen value (under the field name), or `decline` for the
@@ -218247,6 +219011,7 @@ var Daemon = class Daemon {
218247
219011
  onAcpUpdate(_agentId, sessionId, update) {
218248
219012
  const p = this.pending.get(sessionId);
218249
219013
  if (!p) return;
219014
+ if (p.outputSuppressed) return;
218250
219015
  if (update?.sessionUpdate === "usage_update") {
218251
219016
  this.store.setUsageSnapshot(p.sessionKey, {
218252
219017
  contextUsed: update.used,
@@ -218374,15 +219139,37 @@ var Daemon = class Daemon {
218374
219139
  text: ev.text
218375
219140
  });
218376
219141
  }
218377
- async ensureHostAsync(agentId) {
219142
+ invalidateHostStart(agentId) {
219143
+ this.hostStartGeneration.set(agentId, (this.hostStartGeneration.get(agentId) ?? 0) + 1);
219144
+ this.hostStarts.delete(agentId);
219145
+ this.hostStartAborts.get(agentId)?.abort(/* @__PURE__ */ new Error(`host start superseded for ${agentId}`));
219146
+ this.hostStartAborts.delete(agentId);
219147
+ }
219148
+ async ensureHostAsync(agentId, opts = {}) {
219149
+ const assertStartAllowed = () => {
219150
+ if (this.draining) throw new Error(`host start blocked while daemon is draining (${agentId})`);
219151
+ if (this.safetyDrainingAgents.has(agentId) && !this.hostStarts.has(agentId)) throw new Error(`host start blocked while interrupted turns are stopping (${agentId})`);
219152
+ if (!opts.allowAgentDrain && this.drainingAgents.has(agentId)) throw new Error(`host start blocked while agent is draining (${agentId})`);
219153
+ };
219154
+ assertStartAllowed();
218378
219155
  const stopping = this.hostStopping.get(agentId);
218379
- if (stopping) await stopping;
219156
+ if (stopping) {
219157
+ await stopping;
219158
+ assertStartAllowed();
219159
+ }
218380
219160
  let p = this.hostStarts.get(agentId);
218381
219161
  if (!p) {
218382
- p = this.startHostWithRetry(agentId);
219162
+ const generation = (this.hostStartGeneration.get(agentId) ?? 0) + 1;
219163
+ this.hostStartGeneration.set(agentId, generation);
219164
+ const startAbort = new AbortController();
219165
+ this.hostStartAborts.set(agentId, startAbort);
219166
+ p = this.startHostWithRetry(agentId, generation, startAbort.signal);
218383
219167
  this.hostStarts.set(agentId, p);
218384
- p.catch(() => {
219168
+ p.then(() => {
219169
+ if (this.hostStartAborts.get(agentId) === startAbort) this.hostStartAborts.delete(agentId);
219170
+ }, () => {
218385
219171
  if (this.hostStarts.get(agentId) === p) this.hostStarts.delete(agentId);
219172
+ if (this.hostStartAborts.get(agentId) === startAbort) this.hostStartAborts.delete(agentId);
218386
219173
  });
218387
219174
  }
218388
219175
  return await p;
@@ -218392,7 +219179,7 @@ var Daemon = class Daemon {
218392
219179
  * builds a FRESH host; a failed start()'s half-spawned child is reaped before the
218393
219180
  * next try. On success the started host is left memoized in `this.hosts`; when every
218394
219181
  * attempt fails the last error is thrown (dispatch surfaces it to the session). */
218395
- async startHostWithRetry(agentId) {
219182
+ async startHostWithRetry(agentId, generation, signal) {
218396
219183
  const attempts = Math.max(1, this.cfg.limits.agentStartAttempts);
218397
219184
  let ghToken;
218398
219185
  const agentSpec = this.agents.get(agentId);
@@ -218401,30 +219188,46 @@ var Daemon = class Daemon {
218401
219188
  } catch (err) {
218402
219189
  this.log.warn(`gitcred: GH_TOKEN unavailable for agent "${agentId}" — spawning without it (${formatErr(err)})`);
218403
219190
  }
219191
+ if (this.hostStartGeneration.get(agentId) !== generation) throw new Error(`host start superseded for ${agentId}`);
218404
219192
  let lastErr;
218405
219193
  for (let i = 1; i <= attempts; i++) {
219194
+ if (this.hostStartGeneration.get(agentId) !== generation) throw new Error(`host start superseded for ${agentId}`);
218406
219195
  const host = this.ensureHost(agentId, this.cfg, ghToken);
218407
219196
  try {
218408
219197
  await host.start();
219198
+ if (this.hostStartGeneration.get(agentId) !== generation) throw new Error(`host start superseded for ${agentId}`);
218409
219199
  return host;
218410
219200
  } catch (err) {
218411
219201
  lastErr = err;
218412
- this.hosts.delete(agentId);
219202
+ if (this.hosts.get(agentId) === host) this.hosts.delete(agentId);
218413
219203
  await host.stop().catch(() => {});
219204
+ if (this.hostStartGeneration.get(agentId) !== generation) throw err;
218414
219205
  if (i < attempts) {
218415
219206
  const backoff = this.cfg.limits.agentStartBackoffMs;
218416
219207
  this.log.warn(`acp: agent "${agentId}" start attempt ${i}/${attempts} failed (${err.message}) — retrying in ${backoff}ms`);
218417
- await this.sleep(backoff);
219208
+ await this.sleep(backoff, signal);
218418
219209
  } else this.log.error(`acp: agent "${agentId}" failed to start after ${attempts} attempt(s): ${formatErr(err)}`);
218419
219210
  }
218420
219211
  }
218421
219212
  throw lastErr;
218422
219213
  }
218423
219214
  /** Clock-driven delay (so a FakeClock stays deterministic in tests). */
218424
- sleep(ms) {
219215
+ sleep(ms, signal) {
218425
219216
  const delay = Math.min(Math.max(0, ms), 2147483647);
218426
219217
  return new Promise((resolve) => {
218427
- this.clock.setTimeout(() => resolve(), delay);
219218
+ if (signal?.aborted) {
219219
+ resolve();
219220
+ return;
219221
+ }
219222
+ const timer = this.clock.setTimeout(() => {
219223
+ signal?.removeEventListener("abort", onAbort);
219224
+ resolve();
219225
+ }, delay);
219226
+ const onAbort = () => {
219227
+ this.clock.clearTimeout(timer);
219228
+ resolve();
219229
+ };
219230
+ signal?.addEventListener("abort", onAbort, { once: true });
218428
219231
  });
218429
219232
  }
218430
219233
  replyConnFor(agentId, integrationId) {
@@ -218444,12 +219247,17 @@ var Daemon = class Daemon {
218444
219247
  * is registered in `hostStopping` so a concurrent ensureHostAsync waits for it
218445
219248
  * instead of spawning a second live child. */
218446
219249
  async stopHost(agentId, deadlineMs) {
219250
+ this.invalidateHostStart(agentId);
219251
+ const alreadyStopping = this.hostStopping.get(agentId);
219252
+ if (alreadyStopping) {
219253
+ await alreadyStopping;
219254
+ this.invalidateHostStart(agentId);
219255
+ }
218447
219256
  const host = this.hosts.get(agentId);
218448
219257
  this.hosts.delete(agentId);
218449
- this.hostStarts.delete(agentId);
218450
219258
  this.hostStartedAt.delete(agentId);
218451
219259
  if (!host) return;
218452
- const stop = host.stop(deadlineMs).finally(() => {
219260
+ const stop = Promise.resolve(host.stop(deadlineMs)).finally(() => {
218453
219261
  if (this.hostStopping.get(agentId) === stop) this.hostStopping.delete(agentId);
218454
219262
  });
218455
219263
  this.hostStopping.set(agentId, stop);
@@ -218632,13 +219440,44 @@ var Daemon = class Daemon {
218632
219440
  * to `shutdownDrainMs`, cancelling stragglers. Safe to call repeatedly. */
218633
219441
  async drainForShutdown() {
218634
219442
  this.draining = true;
218635
- const live = [...this.pending.values()];
218636
- if (live.length > 0) {
219443
+ const active = [...new Set([...this.activeDispatchesByAgent.values()].flatMap((runs) => [...runs]))];
219444
+ const pendingKeys = new Set([...this.pending.values()].map((p) => p.sessionKey));
219445
+ const coldAgents = /* @__PURE__ */ new Set();
219446
+ for (const [key, entry] of this.activeGateEntries) {
219447
+ if (pendingKeys.has(key)) continue;
219448
+ entry.cancelledReason ??= "shutdown";
219449
+ entry.initAbort.abort(/* @__PURE__ */ new Error("daemon shutting down"));
219450
+ coldAgents.add(entry.agentId);
219451
+ }
219452
+ const stopFailClosed = (agentId) => this.stopHost(agentId, 0).catch((err) => {
219453
+ this.log.error(`shutdown: force-stop failed for agent "${agentId}": ${formatErr(err)}`);
219454
+ return new Promise(() => {});
219455
+ });
219456
+ const coldStops = [...coldAgents].map(stopFailClosed);
219457
+ const work = Promise.all([...active, ...coldStops]);
219458
+ if (active.length > 0 || coldStops.length > 0) {
218637
219459
  const deadlineMs = this.cfg.limits.shutdownDrainMs;
218638
- this.log.info(`shutdown: draining ${live.length} in-flight turn(s) (deadline ${Math.round(deadlineMs / 1e3)}s)`);
218639
- if (await this.raceDeadline(Promise.all(live.map((p) => p.done)), deadlineMs) === "timeout") {
218640
- this.log.warn(`shutdown: deadline hit with ${this.pending.size} turn(s) still in flight — cancelling`);
218641
- for (const [sid, p] of this.pending) await this.hosts.get(p.agentId)?.cancel(sid).catch(() => {});
219460
+ this.log.info(`shutdown: draining ${active.length} active dispatch(es) (deadline ${Math.round(deadlineMs / 1e3)}s)`);
219461
+ if (await this.raceDeadline(work, deadlineMs) === "timeout") {
219462
+ this.log.warn(`shutdown: deadline hit with ${this.pending.size} ACP turn(s) still in flight — cancelling`);
219463
+ const forceAgents = /* @__PURE__ */ new Set();
219464
+ for (const [, entry] of this.activeGateEntries) {
219465
+ entry.cancelledReason ??= "shutdown";
219466
+ entry.initAbort.abort(/* @__PURE__ */ new Error("daemon shutdown deadline"));
219467
+ forceAgents.add(entry.agentId);
219468
+ }
219469
+ for (const [sid, p] of this.pending) {
219470
+ p.outputSuppressed ??= "shutdown";
219471
+ this.releasePermissions(sid);
219472
+ this.releaseElicits(sid);
219473
+ this.hosts.get(p.agentId)?.cancel(sid).catch(() => {});
219474
+ }
219475
+ const forceStops = [...forceAgents].filter((id) => !coldAgents.has(id)).map(stopFailClosed);
219476
+ await Promise.all([
219477
+ ...active,
219478
+ ...coldStops,
219479
+ ...forceStops
219480
+ ]);
218642
219481
  }
218643
219482
  }
218644
219483
  this.settleQueuedForShutdown();
@@ -218675,12 +219514,21 @@ var Daemon = class Daemon {
218675
219514
  }
218676
219515
  if (rows.length === 0) return;
218677
219516
  let replayed = 0;
219517
+ const purgedPausedAgents = /* @__PURE__ */ new Set();
219518
+ const purgedLoopScopes = /* @__PURE__ */ new Set();
218678
219519
  for (const row of rows) {
218679
219520
  if (this.liveInboxIds.has(row.id)) continue;
218680
219521
  if (!this.agents.has(row.agentId)) {
218681
219522
  this.log.warn(`durable inbox: skipping replay of ${row.id} — unknown agent "${row.agentId}"`);
218682
219523
  continue;
218683
219524
  }
219525
+ if (this.paused(row.agentId)) {
219526
+ if (!purgedPausedAgents.has(row.agentId)) {
219527
+ purgedPausedAgents.add(row.agentId);
219528
+ this.purgeAgentInbox(row.agentId);
219529
+ }
219530
+ continue;
219531
+ }
218684
219532
  let msg;
218685
219533
  let callMeta;
218686
219534
  try {
@@ -218690,7 +219538,24 @@ var Daemon = class Daemon {
218690
219538
  this.log.warn(`durable inbox: skipping corrupt row ${row.id}: ${err.message}`);
218691
219539
  continue;
218692
219540
  }
218693
- this.dispatch(row.agentId, msg, row.integrationId ?? void 0, void 0, callMeta, row.isQueueCmd ? { isQueueCmd: true } : void 0).catch(() => {});
219541
+ if (isMalformedPlatformTurn(msg) && !msg.isDm) {
219542
+ this.store.removeInbox(row.id);
219543
+ continue;
219544
+ }
219545
+ const loopScope = loopGuardScope(msg);
219546
+ if (usesLoopGuard(msg) && this.store.isLoopGuardOpen(loopScope)) {
219547
+ if (!purgedLoopScopes.has(loopScope)) {
219548
+ purgedLoopScopes.add(loopScope);
219549
+ this.purgeLoopScopeInbox(loopScope);
219550
+ }
219551
+ continue;
219552
+ }
219553
+ this.dispatch(row.agentId, msg, row.integrationId ?? void 0, void 0, callMeta, {
219554
+ ...row.isQueueCmd ? { isQueueCmd: true } : {},
219555
+ fromInboxReplay: true,
219556
+ inboxReplayId: row.id,
219557
+ replay: row.loopGuardCounted === 1
219558
+ }).catch(() => {});
218694
219559
  replayed++;
218695
219560
  }
218696
219561
  if (replayed) this.log.info(`durable inbox: replayed ${replayed} admitted message(s) through the serial gate`);
@@ -218860,7 +219725,7 @@ var Daemon = class Daemon {
218860
219725
  };
218861
219726
  }
218862
219727
  try {
218863
- await this.ensureHostAsync(agentId);
219728
+ await this.ensureHostAsync(agentId, { allowAgentDrain: true });
218864
219729
  } catch (err) {
218865
219730
  this.moveStagedAgents.add(agentId);
218866
219731
  await this.stopHost(agentId).catch(() => {});
@@ -218952,6 +219817,10 @@ var Daemon = class Daemon {
218952
219817
  * in dispatch. Resolves with dispatch's sessionId (null = gate-dropped).
218953
219818
  */
218954
219819
  async fireTrigger(agentId, msg, target, anchorText, label, githubReply) {
219820
+ if (this.draining || this.drainingAgents.has(agentId) || this.paused(agentId) || this.safetyDrainingAgents.has(agentId)) {
219821
+ this.log.info(`${label}: skipped for agent "${agentId}" (paused or draining)`);
219822
+ return null;
219823
+ }
218955
219824
  if (target?.channel) {
218956
219825
  const conn = this.replyConnFor(agentId, target.integrationId);
218957
219826
  if (!conn) this.log.warn(`${label}: agent "${agentId}" has no live platform connection — running without output`);
@@ -219216,6 +220085,8 @@ var Daemon = class Daemon {
219216
220085
  }
219217
220086
  for (const t of this.cancelTimers.values()) this.clock.clearTimeout(t);
219218
220087
  this.cancelTimers.clear();
220088
+ for (const { timer } of this.coldCancelTimers.values()) this.clock.clearTimeout(timer);
220089
+ this.coldCancelTimers.clear();
219219
220090
  for (const t of this.slackRetryTimers.values()) this.clock.clearTimeout(t);
219220
220091
  this.slackRetryTimers.clear();
219221
220092
  for (const t of this.orchestrationDeadlines.values()) this.clock.clearTimeout(t);
@@ -219232,7 +220103,14 @@ var Daemon = class Daemon {
219232
220103
  for (const c of this.sharedSlackConns.values()) await Promise.resolve(c.stop()).catch((e) => errors.push(e));
219233
220104
  for (const c of this.telegramConns) await Promise.resolve(c.stop()).catch((e) => errors.push(e));
219234
220105
  for (const c of this.discordConns) await Promise.resolve(c.stop()).catch((e) => errors.push(e));
219235
- for (const h of this.hosts.values()) await Promise.resolve(h.stop()).catch((e) => errors.push(e));
220106
+ const hostStarts = [...this.hostStarts.values()];
220107
+ const hostIds = /* @__PURE__ */ new Set([
220108
+ ...this.hosts.keys(),
220109
+ ...this.hostStarts.keys(),
220110
+ ...this.hostStopping.keys()
220111
+ ]);
220112
+ for (const agentId of hostIds) await this.stopHost(agentId).catch((e) => errors.push(e));
220113
+ await Promise.allSettled(hostStarts);
219236
220114
  await Promise.resolve(this.mcp?.stop()).catch((e) => errors.push(e));
219237
220115
  this.gitCredServer?.stop();
219238
220116
  this.store?.close();
@@ -219272,4 +220150,4 @@ async function runForeground(opts, deps = {}) {
219272
220150
  //#endregion
219273
220151
  export { ClientTransport as i, CP_SUBPROTOCOL as n, CP_WS_PATH as r, runForeground as t };
219274
220152
 
219275
- //# sourceMappingURL=run-foreground-DrYgPjW_.js.map
220153
+ //# sourceMappingURL=run-foreground-DoM1PkHn.js.map