agent-coord-mcp 0.4.8 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -13
- package/dist/server.js +10 -4
- package/dist/server.js.map +1 -1
- package/dist/store.js +79 -1
- package/dist/store.js.map +1 -1
- package/dist/tools.js +236 -33
- package/dist/tools.js.map +1 -1
- package/hooks/peek-coord.mjs +66 -8
- package/hooks/tmux-pusher.mjs +81 -7
- package/package.json +1 -1
- package/scripts/coord-chat.mjs +491 -66
- package/src/server.ts +57 -3
- package/src/store.ts +90 -1
- package/src/tools.ts +263 -32
package/scripts/coord-chat.mjs
CHANGED
|
@@ -41,7 +41,7 @@ import lockfile from "proper-lockfile";
|
|
|
41
41
|
// ---------- args ----------
|
|
42
42
|
|
|
43
43
|
const args = parseArgs(process.argv.slice(2));
|
|
44
|
-
|
|
44
|
+
let ID = args.id ?? process.env.USER ?? "human"; // reassignable so /nick can rebind it
|
|
45
45
|
const ROOT = args.dir ?? process.env.AGENT_COORD_DIR ?? path.join(homedir(), "agent-coord");
|
|
46
46
|
|
|
47
47
|
// Message-rendering state + helpers. Declared up here (above the top-level
|
|
@@ -54,9 +54,14 @@ const ROOT = args.dir ?? process.env.AGENT_COORD_DIR ?? path.join(homedir(), "ag
|
|
|
54
54
|
const GROUP_WINDOW = 2 * 60 * 1000;
|
|
55
55
|
let lastBlock = { who: null, ts: 0, kind: null };
|
|
56
56
|
|
|
57
|
+
// Whether the ephemeral suggestion hint is currently occupying the slot above
|
|
58
|
+
// the prompt (see drawHint/clearHint). Declared early so say(), called during
|
|
59
|
+
// startup replay, can reference it without tripping the const/let TDZ.
|
|
60
|
+
let hintActive = false;
|
|
61
|
+
|
|
57
62
|
// Matches "@<this agent>" not followed by a name char, so we can flag messages
|
|
58
63
|
// that ping the current user. ID may contain regex metachars — escape it.
|
|
59
|
-
|
|
64
|
+
let SELF_MENTION_RE = new RegExp(
|
|
60
65
|
"@" + ID.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "(?![A-Za-z0-9._-])",
|
|
61
66
|
);
|
|
62
67
|
const mentionsSelf = (text) => SELF_MENTION_RE.test(text ?? "");
|
|
@@ -76,11 +81,81 @@ const CURSOR_DIR = path.join(ROOT, "cursors");
|
|
|
76
81
|
const TRANSPORT_DIR = path.join(ROOT, "transports");
|
|
77
82
|
const AGENTS_FILE = path.join(ROOT, "agents.json");
|
|
78
83
|
const ROOM_FILE = path.join(ROOT, "room.jsonl");
|
|
79
|
-
const
|
|
80
|
-
const
|
|
84
|
+
const ROOMS_DIR = path.join(ROOT, "rooms");
|
|
85
|
+
const ROOMS_FILE = path.join(ROOT, "rooms.json");
|
|
86
|
+
let INBOX_FILE = path.join(INBOX_DIR, `${sanitize(ID)}.jsonl`);
|
|
87
|
+
let CURSOR_FILE = path.join(CURSOR_DIR, `${sanitize(ID)}.json`);
|
|
81
88
|
|
|
82
89
|
mkdirSync(INBOX_DIR, { recursive: true });
|
|
83
90
|
mkdirSync(CURSOR_DIR, { recursive: true });
|
|
91
|
+
mkdirSync(ROOMS_DIR, { recursive: true });
|
|
92
|
+
|
|
93
|
+
// ---------- channels ----------
|
|
94
|
+
// Mirrors src/store.ts: `general` keeps using room.jsonl + the flat roomOffset
|
|
95
|
+
// cursor key (hook/back-compat); other channels live in rooms/<chan>.jsonl with
|
|
96
|
+
// their offset under cursor.roomOffsets[chan]. currentRoom is the focused
|
|
97
|
+
// channel that plain text posts to; we tail every channel we've joined.
|
|
98
|
+
const DEFAULT_ROOM = "general";
|
|
99
|
+
let currentRoom = DEFAULT_ROOM;
|
|
100
|
+
const ignored = new Set(); // session-local /ignore — agentIds whose msgs we hide
|
|
101
|
+
|
|
102
|
+
function normalizeRoom(name) {
|
|
103
|
+
if (!name) return DEFAULT_ROOM;
|
|
104
|
+
const n = String(name).trim().replace(/^#+/, "").toLowerCase().replace(/[^a-z0-9._-]/g, "");
|
|
105
|
+
return n || DEFAULT_ROOM;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function roomFile(chan) {
|
|
109
|
+
const c = normalizeRoom(chan);
|
|
110
|
+
return c === DEFAULT_ROOM ? ROOM_FILE : path.join(ROOMS_DIR, `${sanitize(c)}.jsonl`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function getRoomOffset(cursor, chan) {
|
|
114
|
+
const c = normalizeRoom(chan);
|
|
115
|
+
return c === DEFAULT_ROOM ? cursor.roomOffset ?? 0 : cursor.roomOffsets?.[c] ?? 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function setRoomOffset(cursor, chan, n) {
|
|
119
|
+
const c = normalizeRoom(chan);
|
|
120
|
+
if (c === DEFAULT_ROOM) cursor.roomOffset = n;
|
|
121
|
+
else (cursor.roomOffsets ??= {})[c] = n;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function getRooms() {
|
|
125
|
+
const reg = readJsonSafe(ROOMS_FILE, {});
|
|
126
|
+
if (!reg[DEFAULT_ROOM]) reg[DEFAULT_ROOM] = { createdAt: 0, createdBy: "system", members: [] };
|
|
127
|
+
return reg;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Channels this agent has joined (always includes the default channel).
|
|
131
|
+
function joinedRooms() {
|
|
132
|
+
const reg = getRooms();
|
|
133
|
+
const out = new Set([DEFAULT_ROOM]);
|
|
134
|
+
for (const [chan, e] of Object.entries(reg)) {
|
|
135
|
+
if (e.members?.includes(ID)) out.add(chan);
|
|
136
|
+
}
|
|
137
|
+
return [...out];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function updateRooms(mutate) {
|
|
141
|
+
await withLock(ROOMS_FILE, async () => {
|
|
142
|
+
const reg = readJsonSafe(ROOMS_FILE, {});
|
|
143
|
+
mutate(reg);
|
|
144
|
+
writeJsonAtomic(ROOMS_FILE, reg);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const watchedRooms = new Set();
|
|
149
|
+
function watchRoom(chan) {
|
|
150
|
+
const f = roomFile(chan);
|
|
151
|
+
if (watchedRooms.has(f)) return;
|
|
152
|
+
try {
|
|
153
|
+
watch(f, () => void drainAndPrint());
|
|
154
|
+
watchedRooms.add(f);
|
|
155
|
+
} catch {
|
|
156
|
+
// file may not exist yet; the 1s interval drain covers it until it does
|
|
157
|
+
}
|
|
158
|
+
}
|
|
84
159
|
|
|
85
160
|
// ---------- ANSI helpers ----------
|
|
86
161
|
|
|
@@ -141,8 +216,10 @@ if (TTY) {
|
|
|
141
216
|
}
|
|
142
217
|
|
|
143
218
|
const SLASH_COMMANDS = [
|
|
144
|
-
"/dm", "/
|
|
145
|
-
"/me", "/status", "/
|
|
219
|
+
"/dm", "/msg", "/list", "/who", "/whoami", "/whois", "/last", "/find",
|
|
220
|
+
"/clear", "/cls", "/me", "/status", "/away", "/back", "/ignore", "/unignore",
|
|
221
|
+
"/nick", "/join", "/part", "/leave", "/rooms", "/channels", "/topic", "/motd",
|
|
222
|
+
"/rules", "/prune", "/kick", "/wipe-room",
|
|
146
223
|
"/help", "/?", "/quit", "/exit",
|
|
147
224
|
];
|
|
148
225
|
|
|
@@ -170,12 +247,29 @@ function completer(line) {
|
|
|
170
247
|
const partial = line.slice(4);
|
|
171
248
|
const ids = onlineAgentIds().filter((id) => id !== ID && id.startsWith(partial));
|
|
172
249
|
hits = ids.map((id) => `/dm ${id} `);
|
|
173
|
-
} else if (line
|
|
174
|
-
|
|
250
|
+
} else if (/^\/whois\s/.test(line)) {
|
|
251
|
+
const partial = line.replace(/^\/whois\s+/, "");
|
|
252
|
+
hits = onlineAgentIds().filter((id) => id.startsWith(partial)).map((id) => `/whois ${id} `);
|
|
253
|
+
} else {
|
|
254
|
+
// Channel-name completion for the channel-taking commands.
|
|
255
|
+
const cm = line.match(/^\/(join|part|leave|msg)\s+#?(\S*)$/);
|
|
256
|
+
if (cm) {
|
|
257
|
+
const cmd = cm[1];
|
|
258
|
+
const partial = cm[2];
|
|
259
|
+
const chans = Object.keys(getRooms()).filter((c) => c.startsWith(partial));
|
|
260
|
+
hits = chans.map((c) => `/${cmd} #${c} `);
|
|
261
|
+
} else if (line.startsWith("/")) {
|
|
262
|
+
hits = SLASH_COMMANDS.filter((c) => c.startsWith(line));
|
|
263
|
+
}
|
|
175
264
|
}
|
|
176
|
-
if (hits.length > 1
|
|
265
|
+
if (hits.length > 1) {
|
|
266
|
+
// Show the options as an ephemeral hint and hand readline only the common
|
|
267
|
+
// prefix — a single-element completion means its native multi-column dump
|
|
268
|
+
// never lands in scrollback. Tab still advances to the shared prefix.
|
|
177
269
|
const display = hits.map((h) => h.trim()).join(" ");
|
|
178
|
-
|
|
270
|
+
drawHint(A.dim(" ┄ " + display));
|
|
271
|
+
const cp = commonPrefix(hits);
|
|
272
|
+
return [[cp.length >= substr.length ? cp : substr], substr];
|
|
179
273
|
}
|
|
180
274
|
return [hits, substr];
|
|
181
275
|
}
|
|
@@ -202,8 +296,14 @@ const rl = readline.createInterface({
|
|
|
202
296
|
// the "@" into its line buffer before we inspect it.
|
|
203
297
|
if (process.stdin.isTTY) {
|
|
204
298
|
readline.emitKeypressEvents(process.stdin);
|
|
205
|
-
process.stdin.on("keypress", (str) => {
|
|
206
|
-
|
|
299
|
+
process.stdin.on("keypress", (str, key) => {
|
|
300
|
+
// setImmediate lets readline mutate its line buffer first, then we inspect
|
|
301
|
+
// it / redraw the slot above the prompt.
|
|
302
|
+
if (str === "@") { setImmediate(showMentionPicker); return; }
|
|
303
|
+
// Tab is the completer's — it draws/keeps the hint itself. Every other key
|
|
304
|
+
// dismisses a showing hint so the view snaps back to a clean separator.
|
|
305
|
+
if (key && key.name === "tab") return;
|
|
306
|
+
if (hintActive) setImmediate(clearHint);
|
|
207
307
|
});
|
|
208
308
|
}
|
|
209
309
|
|
|
@@ -221,21 +321,32 @@ process.stdout.write(sepLine() + "\n");
|
|
|
221
321
|
lastLineWasSep = true;
|
|
222
322
|
|
|
223
323
|
try { watch(INBOX_FILE, () => void drainAndPrint()); } catch {}
|
|
224
|
-
|
|
324
|
+
for (const chan of joinedRooms()) watchRoom(chan);
|
|
225
325
|
try { watch(AGENTS_FILE, () => refreshPrompt()); } catch {}
|
|
226
326
|
setInterval(() => void drainAndPrint(), 1000);
|
|
227
327
|
setInterval(refreshPrompt, 5000);
|
|
228
328
|
|
|
229
329
|
rl.prompt();
|
|
230
330
|
|
|
231
|
-
|
|
331
|
+
// Serialize line handling: readline fires 'line' events back-to-back for
|
|
332
|
+
// pasted/piped input, and our handlers are async (channel switches, file RMW).
|
|
333
|
+
// Chaining them guarantees e.g. "/join #x" fully completes — currentRoom set —
|
|
334
|
+
// before the next line posts, so a message can't leak into the old channel.
|
|
335
|
+
let lineChain = Promise.resolve();
|
|
336
|
+
rl.on("line", (line) => {
|
|
337
|
+
lineChain = lineChain.then(() => handleLine(line)).catch(() => {});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
async function handleLine(line) {
|
|
232
341
|
const text = line.trim();
|
|
233
342
|
if (!text) return rl.prompt();
|
|
234
343
|
// The user's typed-and-submitted line is now in scrollback; it is NOT a
|
|
235
344
|
// separator slot we own. Sync output from commands should write naturally.
|
|
236
345
|
lastLineWasSep = false;
|
|
237
346
|
try {
|
|
238
|
-
if (text === "/quit" || text === "/exit") {
|
|
347
|
+
if (text === "/quit" || text === "/exit" || text.startsWith("/quit ") || text.startsWith("/exit ")) {
|
|
348
|
+
const msg = text.replace(/^\/(quit|exit)\s*/, "").trim();
|
|
349
|
+
for (const chan of joinedRooms()) await sendSystem(chan, msg ? `has quit (${msg})` : "has quit");
|
|
239
350
|
await unregister();
|
|
240
351
|
teardownFooter();
|
|
241
352
|
process.stdout.write(A.dim("bye.\n"));
|
|
@@ -244,6 +355,36 @@ rl.on("line", async (line) => {
|
|
|
244
355
|
printHelp();
|
|
245
356
|
} else if (text === "/list" || text === "/who") {
|
|
246
357
|
await printAgents();
|
|
358
|
+
} else if (text === "/rooms" || text === "/channels") {
|
|
359
|
+
listRooms();
|
|
360
|
+
} else if (text.startsWith("/join ") || text === "/join") {
|
|
361
|
+
const chan = text.slice(5).trim();
|
|
362
|
+
if (!chan) say(A.red("usage: /join <#channel>"));
|
|
363
|
+
else await joinRoom(chan);
|
|
364
|
+
} else if (text === "/part" || text.startsWith("/part ") || text === "/leave" || text.startsWith("/leave ")) {
|
|
365
|
+
await partRoom(text.replace(/^\/(part|leave)\s*/, "").trim());
|
|
366
|
+
} else if (text === "/topic" || text.startsWith("/topic ")) {
|
|
367
|
+
await setTopic(text.slice(6).trim());
|
|
368
|
+
} else if (text === "/motd" || text.startsWith("/motd ") || text === "/rules" || text.startsWith("/rules ")) {
|
|
369
|
+
await setMotd(text.replace(/^\/(motd|rules)\s*/, "").trim());
|
|
370
|
+
} else if (text.startsWith("/msg ")) {
|
|
371
|
+
const m = text.match(/^\/msg\s+(\S+)\s+([\s\S]+)$/);
|
|
372
|
+
if (!m) say(A.red("usage: /msg <#channel> <text>"));
|
|
373
|
+
else { await sendRoom(m[2], m[1]); say(A.dim(`→ sent to #${normalizeRoom(m[1])}`)); }
|
|
374
|
+
} else if (text.startsWith("/whois ")) {
|
|
375
|
+
const target = text.slice(7).trim();
|
|
376
|
+
if (!target) say(A.red("usage: /whois <agent>"));
|
|
377
|
+
else whois(target);
|
|
378
|
+
} else if (text === "/away" || text.startsWith("/away ")) {
|
|
379
|
+
await setAway(text.slice(5).trim());
|
|
380
|
+
} else if (text === "/back") {
|
|
381
|
+
await setBack();
|
|
382
|
+
} else if (text.startsWith("/ignore")) {
|
|
383
|
+
ignoreAgent(text.slice(7).trim());
|
|
384
|
+
} else if (text.startsWith("/unignore")) {
|
|
385
|
+
unignoreAgent(text.slice(9).trim());
|
|
386
|
+
} else if (text === "/nick" || text.startsWith("/nick ")) {
|
|
387
|
+
await nick(text.slice(5).trim());
|
|
247
388
|
} else if (text === "/whoami") {
|
|
248
389
|
await printWhoami();
|
|
249
390
|
} else if (text === "/clear" || text === "/cls") {
|
|
@@ -295,7 +436,7 @@ rl.on("line", async (line) => {
|
|
|
295
436
|
process.stdout.write(sepLine() + "\n");
|
|
296
437
|
lastLineWasSep = true;
|
|
297
438
|
rl.prompt();
|
|
298
|
-
}
|
|
439
|
+
}
|
|
299
440
|
|
|
300
441
|
process.on("SIGINT", async () => {
|
|
301
442
|
try { await unregister(); } catch {}
|
|
@@ -358,7 +499,28 @@ function agentColor(id) {
|
|
|
358
499
|
return AGENT_COLORS[idx];
|
|
359
500
|
}
|
|
360
501
|
|
|
502
|
+
// Ephemeral suggestion line (the @mention / completion picker). It borrows the
|
|
503
|
+
// separator slot directly above the prompt: drawn there, then wiped back to a
|
|
504
|
+
// real separator on the next keystroke — so it never piles up in scrollback.
|
|
505
|
+
function drawHint(content) {
|
|
506
|
+
if (typeof rl === "undefined" || !lastLineWasSep) return;
|
|
507
|
+
process.stdout.write("\x1b[1A\r\x1b[2K"); // up to the slot, clear it
|
|
508
|
+
process.stdout.write(content + "\n"); // draw the hint in place of the sep
|
|
509
|
+
rl.prompt(true); // redraw prompt + preserved input
|
|
510
|
+
hintActive = true;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function clearHint() {
|
|
514
|
+
if (typeof rl === "undefined" || !lastLineWasSep) { hintActive = false; return; }
|
|
515
|
+
if (!hintActive) return;
|
|
516
|
+
process.stdout.write("\x1b[1A\r\x1b[2K"); // up to the hint slot, clear it
|
|
517
|
+
process.stdout.write(sepLine() + "\n"); // restore the separator
|
|
518
|
+
rl.prompt(true);
|
|
519
|
+
hintActive = false;
|
|
520
|
+
}
|
|
521
|
+
|
|
361
522
|
function say(line) {
|
|
523
|
+
hintActive = false; // any real output reclaims the slot the hint borrowed
|
|
362
524
|
if (lastLineWasSep) {
|
|
363
525
|
// Async path: a separator we own sits directly above the prompt; we own
|
|
364
526
|
// that line. Replace it with the incoming message, drop a new sep, and
|
|
@@ -390,7 +552,8 @@ function makePrompt() {
|
|
|
390
552
|
if (live || now - a.lastHeartbeat < STALE) online++;
|
|
391
553
|
}
|
|
392
554
|
const peers = online === 1 ? "1 peer" : `${online} peers`;
|
|
393
|
-
|
|
555
|
+
const chan = A.dim("#") + normalizeRoom(currentRoom);
|
|
556
|
+
return `${agentColor(ID)(ID)} ${agentColor(currentRoom)(chan)} ${A.dim(`(${peers})`)}${A.dim(">")} `;
|
|
394
557
|
}
|
|
395
558
|
|
|
396
559
|
// No-op stubs kept so the exit paths don't reference deleted functions.
|
|
@@ -415,22 +578,35 @@ function printBanner() {
|
|
|
415
578
|
|
|
416
579
|
function printHelp() {
|
|
417
580
|
const rows = [
|
|
418
|
-
["<text>", "post to the
|
|
581
|
+
["<text>", "post to the current channel"],
|
|
419
582
|
["/dm <agent> <text>", "send a direct message"],
|
|
583
|
+
["/msg <#chan> <text>", "post to a channel without switching to it"],
|
|
420
584
|
["/me <action>", "post an IRC-style action (* you wave)"],
|
|
421
585
|
["/status <text>", "post to the status broadcast channel"],
|
|
586
|
+
[A.dim("--- channels ---"), ""],
|
|
587
|
+
["/join <#chan>", "join (and switch to) a channel, creating it if new"],
|
|
588
|
+
["/part [#chan]", "leave the current (or named) channel"],
|
|
589
|
+
["/rooms", "list all channels (topic + members)"],
|
|
590
|
+
["/topic [text]", "show or set the current channel's topic"],
|
|
591
|
+
["/motd [text]", "show or set the channel rules (MOTD)"],
|
|
592
|
+
[A.dim("--- people ---"), ""],
|
|
422
593
|
["/list, /who", "show registered agents + transports"],
|
|
594
|
+
["/whois <agent>", "show an agent's detail (role, channels, status)"],
|
|
423
595
|
["/whoami", "show your registration + transport"],
|
|
596
|
+
["/nick <name>", "rename yourself (migrates inbox/history)"],
|
|
597
|
+
["/away [msg], /back", "set or clear your away status"],
|
|
598
|
+
["/ignore <agent>", "mute an agent for this session (/unignore to undo)"],
|
|
599
|
+
[A.dim("--- history ---"), ""],
|
|
424
600
|
["/last [n]", "show last n messages (default 20)"],
|
|
425
|
-
["/find <text>", "search recent inbox +
|
|
601
|
+
["/find <text>", "search recent inbox + channel history"],
|
|
426
602
|
["/clear", "clear the screen"],
|
|
427
603
|
[A.dim("--- admin ---"), ""],
|
|
428
604
|
["/prune [days]", "drop messages older than N days (default 7)"],
|
|
429
605
|
["/kick <agent>", "unregister an agent + kill their pusher"],
|
|
430
|
-
["/wipe-room", "truncate the
|
|
606
|
+
["/wipe-room", "truncate the current channel (destructive)"],
|
|
431
607
|
[A.dim("---"), ""],
|
|
432
608
|
["/help, /?", "this list"],
|
|
433
|
-
["/quit, /exit",
|
|
609
|
+
["/quit [msg], /exit", "unregister and leave"],
|
|
434
610
|
];
|
|
435
611
|
say(A.bold("commands:"));
|
|
436
612
|
for (const [cmd, desc] of rows) {
|
|
@@ -458,14 +634,19 @@ function fastForwardCursors() {
|
|
|
458
634
|
// new messages going forward.
|
|
459
635
|
const cur = readJsonSafe(CURSOR_FILE, {});
|
|
460
636
|
cur.inboxOffset = readJsonl(INBOX_FILE).length;
|
|
461
|
-
cur
|
|
637
|
+
for (const chan of joinedRooms()) setRoomOffset(cur, chan, readJsonl(roomFile(chan)).length);
|
|
462
638
|
writeJsonAtomic(CURSOR_FILE, cur);
|
|
463
639
|
}
|
|
464
640
|
|
|
465
641
|
async function printRecent(n) {
|
|
466
642
|
const inbox = readJsonl(INBOX_FILE).slice(-n).map((m) => ({ ...m, _kind: "DM" }));
|
|
467
|
-
|
|
468
|
-
const
|
|
643
|
+
let rooms = [];
|
|
644
|
+
for (const chan of joinedRooms()) {
|
|
645
|
+
rooms = rooms.concat(
|
|
646
|
+
readJsonl(roomFile(chan)).slice(-n).map((m) => ({ ...m, _kind: "room", room: m.room ?? chan })),
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
const all = [...inbox, ...rooms].sort((a, b) => a.ts - b.ts).slice(-n);
|
|
469
650
|
if (!all.length) return say(A.dim("(no history)"));
|
|
470
651
|
say(A.bold(`last ${all.length} message(s):`));
|
|
471
652
|
for (const m of all) printMsg(m._kind, m, { history: true });
|
|
@@ -497,9 +678,15 @@ async function register() {
|
|
|
497
678
|
role: existing?.role ?? "human",
|
|
498
679
|
registeredAt: existing?.registeredAt ?? now,
|
|
499
680
|
lastHeartbeat: now,
|
|
681
|
+
away: existing?.away,
|
|
500
682
|
};
|
|
501
683
|
writeJsonAtomic(AGENTS_FILE, reg);
|
|
502
684
|
});
|
|
685
|
+
// Record default-channel membership so /rooms + the hooks see us there.
|
|
686
|
+
await updateRooms((reg) => {
|
|
687
|
+
const e = (reg[DEFAULT_ROOM] ??= { createdAt: 0, createdBy: "system", members: [] });
|
|
688
|
+
if (!e.members.includes(ID)) e.members.push(ID);
|
|
689
|
+
});
|
|
503
690
|
}
|
|
504
691
|
|
|
505
692
|
async function unregister() {
|
|
@@ -508,6 +695,11 @@ async function unregister() {
|
|
|
508
695
|
delete reg[ID];
|
|
509
696
|
writeJsonAtomic(AGENTS_FILE, reg);
|
|
510
697
|
});
|
|
698
|
+
await updateRooms((reg) => {
|
|
699
|
+
for (const e of Object.values(reg)) {
|
|
700
|
+
if (e.members?.includes(ID)) e.members = e.members.filter((m) => m !== ID);
|
|
701
|
+
}
|
|
702
|
+
});
|
|
511
703
|
}
|
|
512
704
|
|
|
513
705
|
async function sendDm(to, text) {
|
|
@@ -526,18 +718,17 @@ async function postStatus(status) {
|
|
|
526
718
|
async function pruneOld(days) {
|
|
527
719
|
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
|
|
528
720
|
let total = 0;
|
|
529
|
-
// Per-
|
|
530
|
-
// offsets — without this, every other agent's
|
|
531
|
-
//
|
|
532
|
-
//
|
|
533
|
-
|
|
721
|
+
// Per-channel removal counts so we can shift the corresponding cursor
|
|
722
|
+
// offsets — without this, every other agent's offsets would point past the
|
|
723
|
+
// now-shorter file and they'd silently miss messages until enough new ones
|
|
724
|
+
// piled up to overtake the stale offset.
|
|
725
|
+
const roomRemovedByChan = {};
|
|
534
726
|
let statusRemoved = 0;
|
|
535
727
|
const inboxRemoved = {}; // agentId → count
|
|
536
728
|
|
|
537
|
-
const files = [
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
];
|
|
729
|
+
const files = [];
|
|
730
|
+
for (const chan of Object.keys(getRooms())) files.push({ path: roomFile(chan), kind: "room", chan });
|
|
731
|
+
files.push({ path: STATUS_FILE_PATH, kind: "status" });
|
|
541
732
|
if (existsSync(INBOX_DIR)) {
|
|
542
733
|
for (const n of readdirSync(INBOX_DIR)) {
|
|
543
734
|
if (n.endsWith(".jsonl")) {
|
|
@@ -554,29 +745,32 @@ async function pruneOld(days) {
|
|
|
554
745
|
const body = kept.length ? kept.map((e) => JSON.stringify(e)).join("\n") + "\n" : "";
|
|
555
746
|
writeFileSync(f.path, body);
|
|
556
747
|
total += removed;
|
|
557
|
-
if (f.kind === "room")
|
|
558
|
-
|
|
748
|
+
if (f.kind === "room") {
|
|
749
|
+
const c = normalizeRoom(f.chan);
|
|
750
|
+
roomRemovedByChan[c] = (roomRemovedByChan[c] ?? 0) + removed;
|
|
751
|
+
} else if (f.kind === "status") statusRemoved += removed;
|
|
559
752
|
else inboxRemoved[f.agentId] = (inboxRemoved[f.agentId] ?? 0) + removed;
|
|
560
753
|
}
|
|
561
754
|
}
|
|
562
|
-
if (
|
|
563
|
-
shiftAllCursors({
|
|
755
|
+
if (Object.keys(roomRemovedByChan).length || statusRemoved || Object.keys(inboxRemoved).length) {
|
|
756
|
+
shiftAllCursors({ roomRemovedByChan, statusRemoved, inboxRemoved });
|
|
564
757
|
}
|
|
565
758
|
say(A.dim(`→ pruned ${total} entries older than ${days}d (cursors adjusted)`));
|
|
566
759
|
}
|
|
567
760
|
|
|
568
761
|
async function wipeRoom() {
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
//
|
|
573
|
-
|
|
574
|
-
|
|
762
|
+
const chan = normalizeRoom(currentRoom);
|
|
763
|
+
await ensureFile(roomFile(chan));
|
|
764
|
+
writeFileSync(roomFile(chan), "");
|
|
765
|
+
// Reset every agent's offset for this channel so they re-read from the start
|
|
766
|
+
// of the (now empty) file rather than pointing past EOF.
|
|
767
|
+
resetRoomOffsets(chan);
|
|
768
|
+
say(A.dim(`→ #${chan} wiped (channel cursors reset)`));
|
|
575
769
|
}
|
|
576
770
|
|
|
577
771
|
// Walk every cursor file and shift offsets down by the per-channel removed
|
|
578
772
|
// counts. Mirrors what the MCP prune tool does server-side.
|
|
579
|
-
function shiftAllCursors({
|
|
773
|
+
function shiftAllCursors({ roomRemovedByChan = {}, statusRemoved = 0, inboxRemoved = {} }) {
|
|
580
774
|
if (!existsSync(CURSOR_DIR)) return;
|
|
581
775
|
for (const name of readdirSync(CURSOR_DIR)) {
|
|
582
776
|
if (!name.endsWith(".json")) continue;
|
|
@@ -584,9 +778,13 @@ function shiftAllCursors({ roomRemoved = 0, statusRemoved = 0, inboxRemoved = {}
|
|
|
584
778
|
const cur = readJsonSafe(cursorPath, {});
|
|
585
779
|
const id = name.replace(/\.json$/, "");
|
|
586
780
|
let touched = false;
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
781
|
+
for (const [chan, removed] of Object.entries(roomRemovedByChan)) {
|
|
782
|
+
if (removed <= 0) continue;
|
|
783
|
+
const has = chan === DEFAULT_ROOM ? cur.roomOffset !== undefined : cur.roomOffsets?.[chan] !== undefined;
|
|
784
|
+
if (has) {
|
|
785
|
+
setRoomOffset(cur, chan, Math.max(0, getRoomOffset(cur, chan) - removed));
|
|
786
|
+
touched = true;
|
|
787
|
+
}
|
|
590
788
|
}
|
|
591
789
|
if (cur.statusOffset !== undefined && statusRemoved > 0) {
|
|
592
790
|
cur.statusOffset = Math.max(0, cur.statusOffset - statusRemoved);
|
|
@@ -601,14 +799,15 @@ function shiftAllCursors({ roomRemoved = 0, statusRemoved = 0, inboxRemoved = {}
|
|
|
601
799
|
}
|
|
602
800
|
}
|
|
603
801
|
|
|
604
|
-
function
|
|
802
|
+
function resetRoomOffsets(chan) {
|
|
605
803
|
if (!existsSync(CURSOR_DIR)) return;
|
|
804
|
+
const c = normalizeRoom(chan);
|
|
606
805
|
for (const name of readdirSync(CURSOR_DIR)) {
|
|
607
806
|
if (!name.endsWith(".json")) continue;
|
|
608
807
|
const cursorPath = path.join(CURSOR_DIR, name);
|
|
609
808
|
const cur = readJsonSafe(cursorPath, {});
|
|
610
|
-
if (cur
|
|
611
|
-
cur
|
|
809
|
+
if (getRoomOffset(cur, c) !== 0) {
|
|
810
|
+
setRoomOffset(cur, c, 0);
|
|
612
811
|
writeJsonAtomic(cursorPath, cur);
|
|
613
812
|
}
|
|
614
813
|
}
|
|
@@ -649,8 +848,11 @@ async function kickAgent(target) {
|
|
|
649
848
|
async function findInHistory(term) {
|
|
650
849
|
const t = term.toLowerCase();
|
|
651
850
|
const inbox = readJsonl(INBOX_FILE).map((m) => ({ ...m, _kind: "DM" }));
|
|
652
|
-
|
|
653
|
-
const
|
|
851
|
+
let rooms = [];
|
|
852
|
+
for (const chan of joinedRooms()) {
|
|
853
|
+
rooms = rooms.concat(readJsonl(roomFile(chan)).map((m) => ({ ...m, _kind: "room", room: m.room ?? chan })));
|
|
854
|
+
}
|
|
855
|
+
const matches = [...inbox, ...rooms]
|
|
654
856
|
.filter((m) => (m.text ?? "").toLowerCase().includes(t))
|
|
655
857
|
.sort((a, b) => a.ts - b.ts);
|
|
656
858
|
if (!matches.length) return say(A.dim(`(no matches for "${term}")`));
|
|
@@ -658,8 +860,214 @@ async function findInHistory(term) {
|
|
|
658
860
|
for (const m of matches.slice(-20)) printMsg(m._kind, m, { history: true });
|
|
659
861
|
}
|
|
660
862
|
|
|
661
|
-
|
|
662
|
-
|
|
863
|
+
// ---------- channel commands ----------
|
|
864
|
+
|
|
865
|
+
function showRoomBanner(chan) {
|
|
866
|
+
const c = normalizeRoom(chan);
|
|
867
|
+
const e = getRooms()[c];
|
|
868
|
+
say(A.bold(agentColor(c)(`#${c}`)) + (e?.topic ? A.dim(" — " + e.topic) : ""));
|
|
869
|
+
if (e?.motd) say(A.dim(" rules: ") + e.motd);
|
|
870
|
+
const members = e?.members ?? [];
|
|
871
|
+
if (members.length) say(A.dim(` members: ${members.join(", ")}`));
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
async function joinRoom(arg) {
|
|
875
|
+
const chan = normalizeRoom(arg);
|
|
876
|
+
await updateRooms((reg) => {
|
|
877
|
+
const e = (reg[chan] ??= { createdAt: Date.now(), createdBy: ID, members: [] });
|
|
878
|
+
if (!e.members.includes(ID)) e.members.push(ID);
|
|
879
|
+
});
|
|
880
|
+
// Fast-forward this channel's offset so we don't replay its whole backlog.
|
|
881
|
+
const cur = readJsonSafe(CURSOR_FILE, {});
|
|
882
|
+
setRoomOffset(cur, chan, readJsonl(roomFile(chan)).length);
|
|
883
|
+
writeJsonAtomic(CURSOR_FILE, cur);
|
|
884
|
+
await sendSystem(chan, `joined #${chan}`);
|
|
885
|
+
currentRoom = chan;
|
|
886
|
+
watchRoom(chan);
|
|
887
|
+
refreshPrompt();
|
|
888
|
+
say(A.dim("→ now in ") + A.bold(agentColor(chan)(`#${chan}`)));
|
|
889
|
+
showRoomBanner(chan);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
async function partRoom(arg) {
|
|
893
|
+
const chan = normalizeRoom(arg || currentRoom);
|
|
894
|
+
if (chan === DEFAULT_ROOM) return say(A.red("cannot leave #general"));
|
|
895
|
+
if (!joinedRooms().includes(chan)) return say(A.red(`not in #${chan}`));
|
|
896
|
+
await sendSystem(chan, `left #${chan}`);
|
|
897
|
+
await updateRooms((reg) => {
|
|
898
|
+
if (reg[chan]) reg[chan].members = (reg[chan].members ?? []).filter((m) => m !== ID);
|
|
899
|
+
});
|
|
900
|
+
if (normalizeRoom(currentRoom) === chan) {
|
|
901
|
+
currentRoom = DEFAULT_ROOM;
|
|
902
|
+
refreshPrompt();
|
|
903
|
+
}
|
|
904
|
+
say(A.dim("→ left ") + A.bold(`#${chan}`));
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function listRooms() {
|
|
908
|
+
const reg = getRooms();
|
|
909
|
+
const joined = new Set(joinedRooms());
|
|
910
|
+
const names = Object.keys(reg).sort();
|
|
911
|
+
say(A.bold(`channels (${names.length}):`));
|
|
912
|
+
for (const c of names) {
|
|
913
|
+
const e = reg[c];
|
|
914
|
+
const here =
|
|
915
|
+
normalizeRoom(currentRoom) === c ? A.green("*") : joined.has(c) ? A.dim("·") : " ";
|
|
916
|
+
const count = readJsonl(roomFile(c)).length;
|
|
917
|
+
const topic = e.topic ? A.dim(" — " + e.topic) : "";
|
|
918
|
+
say(` ${here} ${agentColor(c)(("#" + c).padEnd(16))} ${A.dim(`${(e.members ?? []).length} member(s), ${count} msg`)}${topic}`);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
async function setTopic(arg) {
|
|
923
|
+
const chan = normalizeRoom(currentRoom);
|
|
924
|
+
if (!arg) {
|
|
925
|
+
const e = getRooms()[chan];
|
|
926
|
+
return say(e?.topic ? A.bold(`#${chan} topic: `) + e.topic : A.dim(`#${chan} has no topic`));
|
|
927
|
+
}
|
|
928
|
+
await updateRooms((reg) => {
|
|
929
|
+
(reg[chan] ??= { createdAt: Date.now(), createdBy: ID, members: [] }).topic = arg;
|
|
930
|
+
});
|
|
931
|
+
await sendSystem(chan, `changed topic to: ${arg}`);
|
|
932
|
+
say(A.dim(`→ topic set for #${chan}`));
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
async function setMotd(arg) {
|
|
936
|
+
const chan = normalizeRoom(currentRoom);
|
|
937
|
+
if (!arg) {
|
|
938
|
+
const e = getRooms()[chan];
|
|
939
|
+
return say(e?.motd ? A.bold(`#${chan} rules: `) + e.motd : A.dim(`#${chan} has no rules (MOTD)`));
|
|
940
|
+
}
|
|
941
|
+
await updateRooms((reg) => {
|
|
942
|
+
(reg[chan] ??= { createdAt: Date.now(), createdBy: ID, members: [] }).motd = arg;
|
|
943
|
+
});
|
|
944
|
+
await sendSystem(chan, `updated the room rules (MOTD)`);
|
|
945
|
+
say(A.dim(`→ rules (MOTD) set for #${chan}`));
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// ---------- phase-1 parity commands ----------
|
|
949
|
+
|
|
950
|
+
function whois(target) {
|
|
951
|
+
const reg = readJsonSafe(AGENTS_FILE, {});
|
|
952
|
+
const a = reg[target];
|
|
953
|
+
if (!a) return say(A.red(`no such agent: ${target}`));
|
|
954
|
+
const marker = readJsonSafe(path.join(TRANSPORT_DIR, `${sanitize(target)}.json`), null);
|
|
955
|
+
const live = marker && marker.pid && pidAlive(marker.pid);
|
|
956
|
+
const online = live || Date.now() - a.lastHeartbeat < 5 * 60 * 1000;
|
|
957
|
+
const rooms = Object.entries(getRooms())
|
|
958
|
+
.filter(([, e]) => e.members?.includes(target))
|
|
959
|
+
.map(([c]) => `#${c}`);
|
|
960
|
+
say(A.bold("whois ") + agentColor(target)(target) + ":");
|
|
961
|
+
say(` ${A.cyan("status")} ${online ? A.green("online") : A.dim("offline")}${a.away ? A.yellow(` (away: ${a.away})`) : ""}`);
|
|
962
|
+
say(` ${A.cyan("role")} ${a.role ?? "-"}`);
|
|
963
|
+
say(` ${A.cyan("seen")} ${A.dim(relTime(a.lastHeartbeat))}`);
|
|
964
|
+
say(` ${A.cyan("channels")} ${rooms.length ? rooms.join(" ") : A.dim("(general)")}`);
|
|
965
|
+
say(` ${A.cyan("transport")} ${live ? A.green(marker.transport) : A.dim("none")}`);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
async function setAway(msg) {
|
|
969
|
+
await withLock(AGENTS_FILE, async () => {
|
|
970
|
+
const reg = readJsonSafe(AGENTS_FILE, {});
|
|
971
|
+
if (reg[ID]) {
|
|
972
|
+
reg[ID].away = msg || "away";
|
|
973
|
+
writeJsonAtomic(AGENTS_FILE, reg);
|
|
974
|
+
}
|
|
975
|
+
});
|
|
976
|
+
say(A.dim(`→ marked away${msg ? ": " + msg : ""}`));
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
async function setBack() {
|
|
980
|
+
await withLock(AGENTS_FILE, async () => {
|
|
981
|
+
const reg = readJsonSafe(AGENTS_FILE, {});
|
|
982
|
+
if (reg[ID]) {
|
|
983
|
+
delete reg[ID].away;
|
|
984
|
+
writeJsonAtomic(AGENTS_FILE, reg);
|
|
985
|
+
}
|
|
986
|
+
});
|
|
987
|
+
say(A.dim("→ welcome back (away cleared)"));
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function ignoreAgent(target) {
|
|
991
|
+
if (!target) return say(A.red("usage: /ignore <agent>"));
|
|
992
|
+
ignored.add(target);
|
|
993
|
+
say(A.dim(`→ ignoring ${target} (this session)`));
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
function unignoreAgent(target) {
|
|
997
|
+
if (target) {
|
|
998
|
+
ignored.delete(target);
|
|
999
|
+
say(A.dim(`→ no longer ignoring ${target}`));
|
|
1000
|
+
} else {
|
|
1001
|
+
ignored.clear();
|
|
1002
|
+
say(A.dim("→ cleared ignore list"));
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
function moveFileSync(from, to) {
|
|
1007
|
+
if (!existsSync(from) || from === to) return;
|
|
1008
|
+
try {
|
|
1009
|
+
renameSync(from, to);
|
|
1010
|
+
} catch {
|
|
1011
|
+
try {
|
|
1012
|
+
writeFileSync(to, readFileSync(from));
|
|
1013
|
+
unlinkSync(from);
|
|
1014
|
+
} catch {
|
|
1015
|
+
/* best effort */
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// Full rename (NICK): migrate registry, channel membership, inbox, cursor,
|
|
1021
|
+
// transport marker, and color, then rebind the in-session identity. Mirrors
|
|
1022
|
+
// the MCP rename_agent tool.
|
|
1023
|
+
async function nick(arg) {
|
|
1024
|
+
const oldId = ID;
|
|
1025
|
+
const newId = (arg || "").trim();
|
|
1026
|
+
if (!newId || newId === oldId) return say(A.red("usage: /nick <newname>"));
|
|
1027
|
+
if (readJsonSafe(AGENTS_FILE, {})[newId]) return say(A.red(`'${newId}' already exists`));
|
|
1028
|
+
const joined = joinedRooms();
|
|
1029
|
+
|
|
1030
|
+
await withLock(AGENTS_FILE, async () => {
|
|
1031
|
+
const r = readJsonSafe(AGENTS_FILE, {});
|
|
1032
|
+
if (r[oldId]) {
|
|
1033
|
+
r[newId] = { ...r[oldId], agentId: newId };
|
|
1034
|
+
delete r[oldId];
|
|
1035
|
+
}
|
|
1036
|
+
writeJsonAtomic(AGENTS_FILE, r);
|
|
1037
|
+
});
|
|
1038
|
+
await updateRooms((r) => {
|
|
1039
|
+
for (const e of Object.values(r)) {
|
|
1040
|
+
if (e.members?.includes(oldId)) e.members = e.members.map((m) => (m === oldId ? newId : m));
|
|
1041
|
+
}
|
|
1042
|
+
});
|
|
1043
|
+
moveFileSync(path.join(INBOX_DIR, `${sanitize(oldId)}.jsonl`), path.join(INBOX_DIR, `${sanitize(newId)}.jsonl`));
|
|
1044
|
+
moveFileSync(path.join(CURSOR_DIR, `${sanitize(oldId)}.json`), path.join(CURSOR_DIR, `${sanitize(newId)}.json`));
|
|
1045
|
+
moveFileSync(path.join(TRANSPORT_DIR, `${sanitize(oldId)}.json`), path.join(TRANSPORT_DIR, `${sanitize(newId)}.json`));
|
|
1046
|
+
const cmap = readJsonSafe(COLOR_MAP_FILE, {});
|
|
1047
|
+
if (cmap[oldId] !== undefined && cmap[newId] === undefined) {
|
|
1048
|
+
cmap[newId] = cmap[oldId];
|
|
1049
|
+
writeJsonAtomic(COLOR_MAP_FILE, cmap);
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// Rebind in-session identity, then broadcast under the new name.
|
|
1053
|
+
ID = newId;
|
|
1054
|
+
INBOX_FILE = path.join(INBOX_DIR, `${sanitize(ID)}.jsonl`);
|
|
1055
|
+
CURSOR_FILE = path.join(CURSOR_DIR, `${sanitize(ID)}.json`);
|
|
1056
|
+
SELF_MENTION_RE = new RegExp("@" + ID.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "(?![A-Za-z0-9._-])");
|
|
1057
|
+
for (const chan of joined) await sendSystem(chan, `is now known as ${newId} (was ${oldId})`);
|
|
1058
|
+
refreshPrompt();
|
|
1059
|
+
say(A.dim("→ you are now ") + agentColor(ID)(ID));
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
async function sendRoom(text, chan = currentRoom) {
|
|
1063
|
+
const c = normalizeRoom(chan);
|
|
1064
|
+
await appendMessage(roomFile(c), { from: ID, room: c, text });
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// Post a system notice (join/part/topic/nick) to a channel.
|
|
1068
|
+
async function sendSystem(chan, text) {
|
|
1069
|
+
const c = normalizeRoom(chan);
|
|
1070
|
+
await appendMessage(roomFile(c), { from: ID, room: c, text, system: true });
|
|
663
1071
|
}
|
|
664
1072
|
|
|
665
1073
|
async function appendMessage(file, partial) {
|
|
@@ -676,22 +1084,25 @@ async function drainAndPrint() {
|
|
|
676
1084
|
const inboxOff = cursor.inboxOffset ?? 0;
|
|
677
1085
|
for (let i = inboxOff; i < inboxAll.length; i++) {
|
|
678
1086
|
const m = inboxAll[i];
|
|
679
|
-
if (m && m.from !== ID) printMsg("DM", m);
|
|
1087
|
+
if (m && m.from !== ID && !ignored.has(m.from)) printMsg("DM", m);
|
|
680
1088
|
}
|
|
681
1089
|
if (inboxAll.length > inboxOff) {
|
|
682
1090
|
cursor.inboxOffset = inboxAll.length;
|
|
683
1091
|
changed = true;
|
|
684
1092
|
}
|
|
685
1093
|
|
|
686
|
-
|
|
687
|
-
const
|
|
688
|
-
|
|
689
|
-
const
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
1094
|
+
// Drain every joined channel against its own per-channel offset.
|
|
1095
|
+
for (const chan of joinedRooms()) {
|
|
1096
|
+
const all = readJsonl(roomFile(chan));
|
|
1097
|
+
const off = getRoomOffset(cursor, chan);
|
|
1098
|
+
for (let i = off; i < all.length; i++) {
|
|
1099
|
+
const m = all[i];
|
|
1100
|
+
if (m && m.from !== ID && !ignored.has(m.from)) printMsg("room", { ...m, room: m.room ?? chan });
|
|
1101
|
+
}
|
|
1102
|
+
if (all.length > off) {
|
|
1103
|
+
setRoomOffset(cursor, chan, all.length);
|
|
1104
|
+
changed = true;
|
|
1105
|
+
}
|
|
695
1106
|
}
|
|
696
1107
|
|
|
697
1108
|
if (changed) writeJsonAtomic(CURSOR_FILE, cursor);
|
|
@@ -703,6 +1114,20 @@ function printMsg(kind, m, opts = {}) {
|
|
|
703
1114
|
const gutter = color("▎");
|
|
704
1115
|
const prefix = opts.history ? A.dim(" ") : "";
|
|
705
1116
|
|
|
1117
|
+
// A channel tag when the message isn't from the focused channel, so cross-
|
|
1118
|
+
// channel traffic stays legible without cluttering the common single-room case.
|
|
1119
|
+
const otherChan = kind === "room" && m.room && normalizeRoom(m.room) !== normalizeRoom(currentRoom);
|
|
1120
|
+
const chanTag = otherChan ? agentColor(m.room)(`#${normalizeRoom(m.room)}`) + " " : "";
|
|
1121
|
+
|
|
1122
|
+
// System notices (join/part/topic/nick) render as a dim italic one-liner.
|
|
1123
|
+
if (m.system) {
|
|
1124
|
+
const tag = chanTag ? `#${normalizeRoom(m.room)} ` : "";
|
|
1125
|
+
say("");
|
|
1126
|
+
say(`${prefix}\x1b[2;3m — ${tag}${who} ${m.text ?? ""} —\x1b[0m`);
|
|
1127
|
+
if (!opts.history) lastBlock = { who: null, ts: m.ts, kind: null };
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
706
1131
|
// Body wraps manually under a continuous gutter — terminal auto-wrap would
|
|
707
1132
|
// lose the colored gutter on continuation lines.
|
|
708
1133
|
const prefixW = visibleLength(prefix);
|
|
@@ -725,7 +1150,7 @@ function printMsg(kind, m, opts = {}) {
|
|
|
725
1150
|
const badge = kind === "DM" ? A.bold(A.cyan("DM ")) : "";
|
|
726
1151
|
const marker = pinged ? A.bold(A.yellow("► ")) : "";
|
|
727
1152
|
const headGutter = pinged ? A.bold(color("▌")) : gutter;
|
|
728
|
-
const header = `${marker}${badge}${A.bold(color(who))} ${A.dim(`· ${relTime(m.ts)}`)}`;
|
|
1153
|
+
const header = `${marker}${badge}${chanTag}${A.bold(color(who))} ${A.dim(`· ${relTime(m.ts)}`)}`;
|
|
729
1154
|
say("");
|
|
730
1155
|
say(`${prefix}${headGutter} ${header}`);
|
|
731
1156
|
}
|
|
@@ -878,7 +1303,7 @@ function showMentionPicker() {
|
|
|
878
1303
|
const ids = onlineAgentIds().filter((id) => id !== ID);
|
|
879
1304
|
if (!ids.length) return;
|
|
880
1305
|
const list = ids.map((id) => A.green("●") + agentColor(id)(`@${id}`)).join(" ");
|
|
881
|
-
|
|
1306
|
+
drawHint(A.dim(" ┄ ") + list + A.dim(" · Tab to complete"));
|
|
882
1307
|
}
|
|
883
1308
|
|
|
884
1309
|
function readJsonl(file) {
|