[object Object]

← back to Ticket System

harden @mention routing: only DM known agents (or broadcast); ignore stray @tokens with a notice (TK-00067)

fb0737fa9f727bd466a0ad60eec3ce01220a89b0 · 2026-07-26 20:38:30 -0700 · Steve Abrams

Files touched

Diff

commit fb0737fa9f727bd466a0ad60eec3ce01220a89b0
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Jul 26 20:38:30 2026 -0700

    harden @mention routing: only DM known agents (or broadcast); ignore stray @tokens with a notice (TK-00067)
---
 lib.js | 11 ++++++++++-
 tk     | 12 ++++++++----
 2 files changed, 18 insertions(+), 5 deletions(-)

diff --git a/lib.js b/lib.js
index b61f328..da46fe3 100644
--- a/lib.js
+++ b/lib.js
@@ -126,6 +126,15 @@ function parseMentions(text) {
   return [...out];
 }
 
+// Every agent identity that has ever acted (create/comment/action/assign) or been
+// party to a DM (from/to). Used to gate @mention routing so a stray "@agent" in
+// prose doesn't create a dead-letter DM to a nonexistent recipient.
+function knownAgents() {
+  const set = new Set();
+  for (const ev of readEvents()) { for (const k of ['agent', 'from', 'to']) if (ev[k] && !isBroadcast(ev[k])) set.add(ev[k]); }
+  return set;
+}
+
 // Ticket ids are 5-digit zero-padded + a slug of the title (Steve, 2026-07-26):
 //   TK-00025-ga4-attribution-prep
 // The numeric part is the tracking key; legacy short ids (TK-3, TK-17) remain
@@ -157,4 +166,4 @@ function resolveId(ref, map) {
 }
 
 module.exports = { withLock, append, tickets, nextId, resolveId, idNum, slugify, STATUSES, EVENTS,
-  nextMid, messages, inbox, thread, resolveMid, threadParticipants, parseMentions, isBroadcast };
+  nextMid, messages, inbox, thread, resolveMid, threadParticipants, parseMentions, isBroadcast, knownAgents };
diff --git a/tk b/tk
index d26c098..4b6a830 100755
--- a/tk
+++ b/tk
@@ -23,7 +23,7 @@
 //   Also: an @agent mention inside a comment/note auto-DMs that agent, linked to the ticket.
 // Agent identity defaults to $TK_AGENT, else "claude@<tty-or-pid>".
 const { withLock, append, tickets, nextId, resolveId, STATUSES,
-  nextMid, messages, inbox, thread, resolveMid, parseMentions } = require(require('path').join(__dirname, 'lib.js'));
+  nextMid, messages, inbox, thread, resolveMid, parseMentions, knownAgents, isBroadcast } = require(require('path').join(__dirname, 'lib.js'));
 
 const argv = process.argv.slice(2);
 const cmd = argv.shift();
@@ -48,10 +48,14 @@ if (cmd === 'new') {
   const id = norm(argv.shift()); const text = argv.join(' ').trim(); if (!text) die('missing text');
   if (!tickets().has(id)) die('no such ticket ' + id);
   append({ ts, type: 'comment', id, kind: cmd, agent, text });
-  // Route any @agent mentions to their inbox as a ticket-linked DM.
-  const mentioned = parseMentions(text).filter(a => a !== agent);
+  // Route @agent mentions to their inbox as a ticket-linked DM — but only to KNOWN
+  // agents (or a broadcast), so a stray "@agent"/"@here" in prose isn't a dead letter.
+  const known = knownAgents();
+  const all = parseMentions(text).filter(a => a !== agent);
+  const mentioned = all.filter(a => isBroadcast(a) || known.has(a));
+  const skipped = all.filter(a => !mentioned.includes(a));
   for (const to of mentioned) append({ ts, type: 'dm', mid: withLock(() => nextMid()), from: agent, to, text, ticket: id });
-  console.log(`${cmd} added to ${id}${mentioned.length ? ' · dm→ ' + mentioned.join(', ') : ''}`);
+  console.log(`${cmd} added to ${id}${mentioned.length ? ' · dm→ ' + mentioned.join(', ') : ''}${skipped.length ? ' · (ignored @' + skipped.join(', @') + ' — unknown agent)' : ''}`);
 } else if (cmd === 'log') {
   const id = norm(argv.shift()); const text = argv.join(' ').trim(); if (!text) die('missing action text');
   if (!tickets().has(id)) die('no such ticket ' + id);

← eac02d5 agent-to-agent DMs: tk dm/inbox/reply/thread + @mention auto  ·  back to Ticket System  ·  chore: harden DM thread-walk against cyclic re-chains (cycle fda28cb →