← back to Ticket System
agent-to-agent DMs: tk dm/inbox/reply/thread + @mention auto-routing + board DIRECT MESSAGES panel + /api/messages (TK-00067)
eac02d5bb41626213d20e531861cb0bcc9dcd152 · 2026-07-26 20:25:57 -0700 · Steve Abrams
Files touched
Diff
commit eac02d5bb41626213d20e531861cb0bcc9dcd152
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sun Jul 26 20:25:57 2026 -0700
agent-to-agent DMs: tk dm/inbox/reply/thread + @mention auto-routing + board DIRECT MESSAGES panel + /api/messages (TK-00067)
---
lib.js | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
server.js | 30 +++++++++++++++++++++++--
tk | 46 ++++++++++++++++++++++++++++++++++++--
3 files changed, 147 insertions(+), 5 deletions(-)
diff --git a/lib.js b/lib.js
index e10e777..b61f328 100644
--- a/lib.js
+++ b/lib.js
@@ -53,6 +53,79 @@ function tickets() {
return map;
}
+// ── Agent-to-agent direct messages (DMs) ────────────────────────────────────
+// Same append-only event log. A `dm` event is an addressed message between
+// agents; `to` may be a specific agent, or 'all'/'*' for a broadcast. Replies
+// carry `re` (the parent mid) so a conversation threads. A `read` event marks a
+// message seen by an agent (per-agent read state; the human board shows all).
+function nextMid() {
+ let max = 0;
+ for (const ev of readEvents()) if (ev.type === 'dm') { const n = parseInt(String(ev.mid).replace(/^M-/, ''), 10); if (n > max) max = n; }
+ return 'M-' + String(max + 1).padStart(5, '0');
+}
+const isBroadcast = to => to === 'all' || to === '*' || to === 'everyone';
+
+// Fold dm/read events into message objects: { mid, from, to, text, ticket, re, ts, reads:[agent] }
+function messages() {
+ const map = new Map();
+ for (const ev of readEvents()) {
+ if (ev.type === 'dm') map.set(ev.mid, { mid: ev.mid, from: ev.from || ev.agent || '', to: ev.to || '', text: ev.text || '', ticket: ev.ticket || '', re: ev.re || '', ts: ev.ts, reads: [] });
+ else if (ev.type === 'read') { const m = map.get(ev.mid); if (m && ev.agent && !m.reads.includes(ev.agent)) m.reads.push(ev.agent); }
+ }
+ return map;
+}
+
+// Walk re-links up to the conversation root mid.
+function threadRootMid(mid, mm) { let id = mid, c = mm.get(mid); while (c && c.re && mm.get(c.re)) { id = c.re; c = mm.get(id); } return id; }
+
+// Every agent that has sent or been addressed in a message's conversation.
+function threadParticipants(mid, mm) {
+ const root = threadRootMid(mid, mm);
+ const set = new Set();
+ for (const m of mm.values()) { if (threadRootMid(m.mid, mm) !== root) continue; if (m.from) set.add(m.from); if (m.to && !isBroadcast(m.to)) set.add(m.to); }
+ return set;
+}
+
+// Messages addressed to `agent` (direct, broadcast, or any thread it's part of).
+// unreadOnly (default true) hides ones the agent already read or sent itself.
+function inbox(agent, { unreadOnly = true } = {}) {
+ const mm = messages();
+ const out = [];
+ for (const m of mm.values()) {
+ if (m.from === agent) continue;
+ const addressed = m.to === agent || isBroadcast(m.to) || threadParticipants(m.mid, mm).has(agent);
+ if (!addressed) continue;
+ if (unreadOnly && m.reads.includes(agent)) continue;
+ out.push(m);
+ }
+ return out.sort((a, b) => a.ts < b.ts ? -1 : 1);
+}
+
+// Full conversation (root + replies) for any mid, chronological.
+function thread(mid) {
+ const mm = messages();
+ if (!mm.has(mid)) return [];
+ const root = threadRootMid(mid, mm);
+ return [...mm.values()].filter(m => threadRootMid(m.mid, mm) === root).sort((a, b) => a.ts < b.ts ? -1 : 1);
+}
+
+// Resolve any reference to a stored mid (M-4, m-00004, 4, or full M-00004).
+function resolveMid(ref, mm) {
+ if (!ref) return null;
+ const m = mm || messages();
+ let r = String(ref).toUpperCase(); if (!r.startsWith('M-')) r = 'M-' + r;
+ const n = parseInt(r.replace(/^M-/, ''), 10);
+ for (const id of m.keys()) if (id === r || parseInt(id.replace(/^M-/, ''), 10) === n) return id;
+ return null;
+}
+
+// Pull @agent mentions out of comment/note text so they route to inboxes too.
+function parseMentions(text) {
+ const out = new Set();
+ for (const m of String(text || '').matchAll(/@([A-Za-z0-9][\w.\-:@]*)/g)) out.add(m[1].replace(/[.:,]+$/, ''));
+ return [...out];
+}
+
// 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
@@ -83,4 +156,5 @@ function resolveId(ref, map) {
return null;
}
-module.exports = { withLock, append, tickets, nextId, resolveId, idNum, slugify, STATUSES, EVENTS };
+module.exports = { withLock, append, tickets, nextId, resolveId, idNum, slugify, STATUSES, EVENTS,
+ nextMid, messages, inbox, thread, resolveMid, threadParticipants, parseMentions, isBroadcast };
diff --git a/server.js b/server.js
index a968020..fa9705a 100644
--- a/server.js
+++ b/server.js
@@ -1,6 +1,6 @@
// Ticket board viewer — kanban over the shared ticket store. :9794, basic-auth admin/DW2024!, open /healthz.
const http = require('http');
-const { tickets, STATUSES } = require('./lib.js');
+const { tickets, STATUSES, messages } = require('./lib.js');
const PORT = process.env.PORT || 9794;
const AUTH = 'Basic ' + Buffer.from(process.env.TK_AUTH || 'admin:DW2024!').toString('base64');
@@ -20,6 +20,19 @@ function page() {
<div class="thread">${t.comments.map(c => `<div class="c k-${c.kind}"><b>${esc(c.agent)}</b> <i>${esc(c.kind)}</i> ${esc(c.text)}<span class="cts">${new Date(c.ts).toLocaleString()}</span></div>`).join('')}
${t.actions.map(a => `<div class="c k-action"><b>${esc(a.agent)}</b> <i>action</i> ${esc(a.text)}<span class="cts">${new Date(a.ts).toLocaleString()}</span></div>`).join('') || ''}
${!t.comments.length && !t.actions.length ? '<div class="c none">no comments yet</div>' : ''}</div></div>`;
+ // ── Direct-message conversations, grouped into threads ──
+ const mm = messages();
+ const rootOf = mid => { let id = mid, c = mm.get(mid); while (c && c.re && mm.get(c.re)) { id = c.re; c = mm.get(id); } return id; };
+ const threads = new Map();
+ for (const m of mm.values()) { const r = rootOf(m.mid); (threads.get(r) || threads.set(r, []).get(r)).push(m); }
+ const convos = [...threads.values()].map(ms => ms.sort((a, b) => a.ts < b.ts ? -1 : 1))
+ .sort((a, b) => a[a.length - 1].ts < b[b.length - 1].ts ? 1 : -1).slice(0, 40);
+ const dmLine = m => `<div class="dm"><b>${esc(m.from)}</b> <span class="arw">→</span> <b>${esc(m.to)}</b>${m.ticket ? `<span class="dtk">${esc(m.ticket.replace(/^(TK-\d+).*/, '$1'))}</span>` : ''}
+ <span class="dts">${new Date(m.ts).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}</span>
+ <div class="dtx">${esc(m.text)}</div></div>`;
+ const convoCard = ms => `<div class="convo"><div class="chd">${esc([...new Set(ms.flatMap(m => [m.from, m.to]))].filter(Boolean).join(' ⇄ '))}<span class="cnt">${ms.length} msg${ms.length > 1 ? 's' : ''}</span></div>${ms.map(dmLine).join('')}</div>`;
+ const dmPanel = `<details class="dms" open><summary>DIRECT MESSAGES <small>${mm.size} total · ${convos.length} conversations · agents talk via <code>tk dm / reply / @mention</code></small></summary>
+ <div class="convos">${convos.length ? convos.map(convoCard).join('') : '<div class="c none">no direct messages yet</div>'}</div></details>`;
return `<!doctype html><meta charset="utf-8"><title>Fleet Tickets</title><meta http-equiv="refresh" content="30">
<style>
body{margin:0;font:14px -apple-system,sans-serif;background:#0f1115;color:#e6e6e6}
@@ -39,8 +52,20 @@ function page() {
.c.k-win{color:#34d399}.c.k-challenge{color:#e06c75}.c.k-cody{color:#f59e0b}
.cts{display:block;font-size:10px;color:#5b6270}
.col-doing .card{border-left:3px solid #6db3f2}.col-blocked .card{border-left:3px solid #e06c75}.col-done .card{opacity:.55}
+ .dms{margin:0 14px 6px;background:#141821;border:1px solid #262a33;border-radius:8px}
+ .dms>summary{cursor:pointer;padding:10px 14px;font-size:12px;text-transform:uppercase;letter-spacing:.1em;color:#c9a4f2}
+ .dms>summary small{text-transform:none;letter-spacing:0;color:#8a93a5;margin-left:8px;font-size:11px}
+ .dms code{color:#c9a4f2;background:#1c2130;padding:1px 4px;border-radius:4px}
+ .convos{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:10px;padding:4px 14px 14px}
+ .convo{background:#171b22;border:1px solid #262a33;border-left:3px solid #a06ef2;border-radius:8px;padding:8px 10px}
+ .chd{font-size:11px;color:#c9a4f2;font-weight:600;margin-bottom:6px}.chd .cnt{float:right;color:#5b6270;font-weight:400}
+ .dm{font-size:12px;margin:5px 0;padding-top:5px;border-top:1px dashed #2c3140}.dm:first-of-type{border-top:0}
+ .dm .arw{color:#8a93a5;margin:0 3px}.dm b{color:#cdd4e0}
+ .dm .dtk{color:#6db3f2;font-size:10px;margin-left:6px}.dm .dts{float:right;color:#5b6270;font-size:10px}
+ .dm .dtx{color:#c6ccd8;margin-top:2px}
</style>
-<h1>FLEET TICKETS<small>every agent action rides a ticket — tk new / comment / note / log / win / challenge / cody / take / done</small></h1>
+<h1>FLEET TICKETS<small>every agent action rides a ticket — tk new / comment / note / log / dm / inbox / reply / @mention / take / done</small></h1>
+${dmPanel}
<div class="board">${STATUSES.map(s => `<div class="col col-${s}"><h2>${s} (${cols[s].length})</h2>${cols[s].map(card).join('') || '<div class="c none">empty</div>'}</div>`).join('')}</div>`;
}
@@ -48,5 +73,6 @@ http.createServer((req, res) => {
if (req.url === '/healthz') { res.writeHead(200); return res.end('ok'); }
if (req.headers.authorization !== AUTH) { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="tickets"' }); return res.end('auth'); }
if (req.url === '/api/tickets') { res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify([...tickets().values()])); }
+ if (req.url === '/api/messages') { res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify([...messages().values()])); }
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(page());
}).listen(PORT, '127.0.0.1', () => console.log('ticket board on :' + PORT));
diff --git a/tk b/tk
index 8d87d53..d26c098 100755
--- a/tk
+++ b/tk
@@ -15,8 +15,15 @@
// tk done TK-3 [-a agent] # shorthand for status done
// tk list [--status s] [--project p] [--agent a] [--all]
// tk show TK-3
+// --- agent-to-agent direct messages ---
+// tk dm <to-agent> "text" [-t TK-3] [-a from] # DM another agent (to='all' broadcasts)
+// tk inbox [-a agent] [--all] [--peek] # my unread DMs (--all=incl read, --peek=don't mark read)
+// tk reply <mid> "text" [-a agent] # reply in a DM thread (routes back to sender)
+// tk thread <mid> # print a whole DM conversation
+// 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 } = require(require('path').join(__dirname, 'lib.js'));
+const { withLock, append, tickets, nextId, resolveId, STATUSES,
+ nextMid, messages, inbox, thread, resolveMid, parseMentions } = require(require('path').join(__dirname, 'lib.js'));
const argv = process.argv.slice(2);
const cmd = argv.shift();
@@ -40,7 +47,11 @@ if (cmd === 'new') {
} else if (cmd === 'comment' || cmd === 'note' || cmd === 'win' || cmd === 'challenge' || cmd === 'cody') {
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 }); console.log(`${cmd} added to ${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);
+ 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(', ') : ''}`);
} 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);
@@ -72,6 +83,37 @@ if (cmd === 'new') {
console.log(fmt(t)); console.log('created ' + t.created_at + ' updated ' + t.updated_at);
for (const c of t.comments) console.log(` [${c.kind}] ${c.ts} ${c.agent}: ${c.text}`);
for (const a of t.actions) console.log(` [action] ${a.ts} ${a.agent}: ${a.text}`);
+} else if (cmd === 'dm') {
+ const to = argv.shift(); const ticketRef = opt('-t') || opt('--ticket');
+ const text = argv.join(' ').trim();
+ if (!to || !text) die('usage: tk dm <to-agent> "text" [-t TK-3]');
+ const ticket = ticketRef ? norm(ticketRef) : '';
+ const mid = withLock(() => { const m = nextMid(); append({ ts, type: 'dm', mid: m, from: agent, to, text, ticket }); return m; });
+ console.log(`${mid} · ${agent} → ${to}${ticket ? ' {' + ticket + '}' : ''}`);
+} else if (cmd === 'reply') {
+ const ref = argv.shift(); const mid0 = resolveMid(ref); if (!mid0) die('no such message ' + ref);
+ const parent = messages().get(mid0);
+ const text = argv.join(' ').trim(); if (!text) die('missing reply text');
+ const to = parent.from === agent ? parent.to : parent.from; // route back to the other party
+ const mid = withLock(() => { const m = nextMid(); append({ ts, type: 'dm', mid: m, from: agent, to, text, ticket: parent.ticket || '', re: mid0 }); return m; });
+ // reading + replying implies I've seen the parent
+ append({ ts, type: 'read', mid: mid0, agent });
+ console.log(`${mid} · ${agent} → ${to} (re ${mid0})`);
+} else if (cmd === 'inbox') {
+ const who = agent; const showAll = flagSet('--all'); const peek = flagSet('--peek');
+ const msgs = inbox(who, { unreadOnly: !showAll });
+ if (!msgs.length) { console.log(`(inbox empty for ${who})`); }
+ else {
+ for (const m of msgs) {
+ const seen = m.reads.includes(who) ? ' ✓' : ' •';
+ console.log(`${seen} ${m.mid}${m.re ? ' (re ' + m.re + ')' : ''} ${m.ticket ? '{' + m.ticket + '} ' : ''}${m.from} → ${m.to}: ${m.text} [${new Date(m.ts).toLocaleString()}]`);
+ }
+ if (!peek) for (const m of msgs) if (!m.reads.includes(who)) append({ ts, type: 'read', mid: m.mid, agent: who });
+ if (!peek) console.error(`(${msgs.filter(m => !m.reads.includes(who)).length} marked read — use --peek to keep unread)`);
+ }
+} else if (cmd === 'thread') {
+ const mid0 = resolveMid(argv.shift()); if (!mid0) die('no such message');
+ for (const m of thread(mid0)) console.log(` ${m.mid}${m.re ? ' (re ' + m.re + ')' : ''} ${m.ticket ? '{' + m.ticket + '} ' : ''}${m.from} → ${m.to}: ${m.text} [${new Date(m.ts).toLocaleString()}]`);
} else {
die('unknown command "' + cmd + '" — see header of this file for usage');
}
← a547850 ticket ids: 5-digit zero-padded + title slug (TK-00025-style
·
back to Ticket System
·
harden @mention routing: only DM known agents (or broadcast) fb0737f →