agent-coord-mcp 0.4.6 → 0.4.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/server.js +0 -0
- package/package.json +1 -1
- package/scripts/coord-chat.mjs +137 -37
package/dist/server.js
CHANGED
|
File without changes
|
package/package.json
CHANGED
package/scripts/coord-chat.mjs
CHANGED
|
@@ -44,6 +44,33 @@ const args = parseArgs(process.argv.slice(2));
|
|
|
44
44
|
const ID = args.id ?? process.env.USER ?? "human";
|
|
45
45
|
const ROOT = args.dir ?? process.env.AGENT_COORD_DIR ?? path.join(homedir(), "agent-coord");
|
|
46
46
|
|
|
47
|
+
// Message-rendering state + helpers. Declared up here (above the top-level
|
|
48
|
+
// printRecent() call) so they're initialized before first use — const/let
|
|
49
|
+
// don't hoist the way function declarations do.
|
|
50
|
+
|
|
51
|
+
// Consecutive messages from the same sender within this window are visually
|
|
52
|
+
// grouped: the second one drops its header/blank line and just continues the
|
|
53
|
+
// gutter, Slack-style.
|
|
54
|
+
const GROUP_WINDOW = 2 * 60 * 1000;
|
|
55
|
+
let lastBlock = { who: null, ts: 0, kind: null };
|
|
56
|
+
|
|
57
|
+
// Matches "@<this agent>" not followed by a name char, so we can flag messages
|
|
58
|
+
// that ping the current user. ID may contain regex metachars — escape it.
|
|
59
|
+
const SELF_MENTION_RE = new RegExp(
|
|
60
|
+
"@" + ID.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "(?![A-Za-z0-9._-])",
|
|
61
|
+
);
|
|
62
|
+
const mentionsSelf = (text) => SELF_MENTION_RE.test(text ?? "");
|
|
63
|
+
|
|
64
|
+
// Recency at a glance: "now" / "5m" for fresh messages, falling back to a wall
|
|
65
|
+
// clock for anything over an hour (a stale "63m" reads worse than "08:34").
|
|
66
|
+
function relTime(ts) {
|
|
67
|
+
const mins = Math.floor((Date.now() - ts) / 60000);
|
|
68
|
+
if (mins < 1) return "now";
|
|
69
|
+
if (mins < 60) return `${mins}m`;
|
|
70
|
+
const d = new Date(ts);
|
|
71
|
+
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
47
74
|
const INBOX_DIR = path.join(ROOT, "inbox");
|
|
48
75
|
const CURSOR_DIR = path.join(ROOT, "cursors");
|
|
49
76
|
const TRANSPORT_DIR = path.join(ROOT, "transports");
|
|
@@ -136,14 +163,12 @@ function completer(line) {
|
|
|
136
163
|
const mentionMatch = line.match(/@([A-Za-z0-9._-]*)$/);
|
|
137
164
|
if (mentionMatch) {
|
|
138
165
|
const partial = mentionMatch[1];
|
|
139
|
-
const
|
|
140
|
-
const ids = Object.keys(reg).filter((id) => id.startsWith(partial));
|
|
166
|
+
const ids = onlineAgentIds().filter((id) => id !== ID && id.startsWith(partial));
|
|
141
167
|
hits = ids.map((id) => `@${id} `);
|
|
142
168
|
substr = mentionMatch[0]; // tell readline to replace just the @partial part
|
|
143
169
|
} else if (line.startsWith("/dm ")) {
|
|
144
170
|
const partial = line.slice(4);
|
|
145
|
-
const
|
|
146
|
-
const ids = Object.keys(reg).filter((id) => id !== ID && id.startsWith(partial));
|
|
171
|
+
const ids = onlineAgentIds().filter((id) => id !== ID && id.startsWith(partial));
|
|
147
172
|
hits = ids.map((id) => `/dm ${id} `);
|
|
148
173
|
} else if (line.startsWith("/")) {
|
|
149
174
|
hits = SLASH_COMMANDS.filter((c) => c.startsWith(line));
|
|
@@ -171,6 +196,17 @@ const rl = readline.createInterface({
|
|
|
171
196
|
completer,
|
|
172
197
|
});
|
|
173
198
|
|
|
199
|
+
// Auto-offer the logged-in agents the instant "@" is typed (editor-style),
|
|
200
|
+
// so you don't have to press Tab to discover who's reachable. We only observe
|
|
201
|
+
// keypresses — readline still owns input. setImmediate lets readline insert
|
|
202
|
+
// the "@" into its line buffer before we inspect it.
|
|
203
|
+
if (process.stdin.isTTY) {
|
|
204
|
+
readline.emitKeypressEvents(process.stdin);
|
|
205
|
+
process.stdin.on("keypress", (str) => {
|
|
206
|
+
if (str === "@") setImmediate(showMentionPicker);
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
174
210
|
// Banner — printed once on launch. Keep it tight; this is a CLI, not a poster.
|
|
175
211
|
printBanner();
|
|
176
212
|
// Show recent context (last 3 messages from inbox + room) then fast-forward
|
|
@@ -662,30 +698,40 @@ async function drainAndPrint() {
|
|
|
662
698
|
}
|
|
663
699
|
|
|
664
700
|
function printMsg(kind, m, opts = {}) {
|
|
665
|
-
const d = new Date(m.ts);
|
|
666
|
-
const t = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
667
701
|
const who = m.from ?? "?";
|
|
668
702
|
const color = agentColor(who);
|
|
669
703
|
const gutter = color("▎");
|
|
670
|
-
const tag = kind === "DM" ? A.bold(A.cyan("DM")) : A.dim("room");
|
|
671
|
-
const nick = A.bold(color(who));
|
|
672
|
-
const meta = `${A.dim(t)} ${tag} ${nick}`;
|
|
673
704
|
const prefix = opts.history ? A.dim(" ") : "";
|
|
674
705
|
|
|
675
|
-
//
|
|
676
|
-
//
|
|
677
|
-
// colored gutter prepends every wrapped line — terminal auto-wrap
|
|
678
|
-
// would lose it.
|
|
706
|
+
// Body wraps manually under a continuous gutter — terminal auto-wrap would
|
|
707
|
+
// lose the colored gutter on continuation lines.
|
|
679
708
|
const prefixW = visibleLength(prefix);
|
|
680
|
-
const
|
|
681
|
-
const restChrome = prefixW + visibleLength(`▎ `);
|
|
682
|
-
const firstWidth = Math.max(20, COLS - firstChrome);
|
|
683
|
-
const restWidth = Math.max(20, COLS - restChrome);
|
|
684
|
-
|
|
709
|
+
const bodyWidth = Math.max(20, COLS - prefixW - visibleLength(`▎ `));
|
|
685
710
|
const text = (m.text ?? "").split("\n").map(formatBody).join("\n");
|
|
686
|
-
const lines =
|
|
687
|
-
|
|
688
|
-
|
|
711
|
+
const lines = wrapBody(text, bodyWidth);
|
|
712
|
+
|
|
713
|
+
// Group onto the previous block when it's the same live sender within the
|
|
714
|
+
// window — skip the blank line + header, just keep the gutter going.
|
|
715
|
+
const grouped = !opts.history
|
|
716
|
+
&& lastBlock.who === who && lastBlock.kind === kind
|
|
717
|
+
&& (m.ts - lastBlock.ts) < GROUP_WINDOW;
|
|
718
|
+
|
|
719
|
+
if (!grouped) {
|
|
720
|
+
// Header on its own line so the sender is a scannable anchor and the body
|
|
721
|
+
// always starts at a fixed column. "room" is the default and stays
|
|
722
|
+
// implied; only DMs get a badge. A ping to the current user brightens the
|
|
723
|
+
// gutter and adds a ► marker so it pops out of the firehose.
|
|
724
|
+
const pinged = mentionsSelf(m.text);
|
|
725
|
+
const badge = kind === "DM" ? A.bold(A.cyan("DM ")) : "";
|
|
726
|
+
const marker = pinged ? A.bold(A.yellow("► ")) : "";
|
|
727
|
+
const headGutter = pinged ? A.bold(color("▌")) : gutter;
|
|
728
|
+
const header = `${marker}${badge}${A.bold(color(who))} ${A.dim(`· ${relTime(m.ts)}`)}`;
|
|
729
|
+
say("");
|
|
730
|
+
say(`${prefix}${headGutter} ${header}`);
|
|
731
|
+
}
|
|
732
|
+
for (const line of lines) say(`${prefix}${gutter} ${line}`);
|
|
733
|
+
|
|
734
|
+
if (!opts.history) lastBlock = { who, ts: m.ts, kind };
|
|
689
735
|
}
|
|
690
736
|
|
|
691
737
|
function visibleLength(s) {
|
|
@@ -693,24 +739,31 @@ function visibleLength(s) {
|
|
|
693
739
|
return s.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
694
740
|
}
|
|
695
741
|
|
|
696
|
-
|
|
697
|
-
|
|
742
|
+
// Wrap one message's text, preserving list/indent structure: a leading bullet
|
|
743
|
+
// ("- ", "* ", "1. ", "2) ") or whitespace indent is detected so wrapped
|
|
744
|
+
// continuation lines hang-indent under the text rather than re-flowing as flat
|
|
745
|
+
// prose.
|
|
746
|
+
function wrapBody(text, width) {
|
|
747
|
+
if (width <= 0) return [text];
|
|
698
748
|
const out = [];
|
|
699
|
-
for (const
|
|
700
|
-
const
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
749
|
+
for (const raw of text.split("\n")) {
|
|
750
|
+
const mk = raw.match(/^(\s*(?:[-*•]\s+|\d+[.)]\s+)?)([\s\S]*)$/);
|
|
751
|
+
const lead = mk ? mk[1] : "";
|
|
752
|
+
const body = mk ? mk[2] : raw;
|
|
753
|
+
const indent = " ".repeat(visibleLength(lead));
|
|
754
|
+
const words = body.length ? body.split(/\s+/) : [];
|
|
755
|
+
if (!words.length) { out.push(lead.trimEnd()); continue; }
|
|
756
|
+
let line = lead + words[0];
|
|
757
|
+
for (let i = 1; i < words.length; i++) {
|
|
758
|
+
const proposed = line + " " + words[i];
|
|
759
|
+
if (visibleLength(proposed) > width) {
|
|
706
760
|
out.push(line);
|
|
707
|
-
line =
|
|
708
|
-
currentWidth = restWidth;
|
|
761
|
+
line = indent + words[i];
|
|
709
762
|
} else {
|
|
710
763
|
line = proposed;
|
|
711
764
|
}
|
|
712
765
|
}
|
|
713
|
-
|
|
766
|
+
out.push(line);
|
|
714
767
|
}
|
|
715
768
|
return out;
|
|
716
769
|
}
|
|
@@ -736,16 +789,31 @@ function formatBody(text) {
|
|
|
736
789
|
// non-asterisk neighbors)
|
|
737
790
|
s = s.replace(/(?<![*\w])\*([^*\n]+)\*(?![*\w])/g, (_, t) => `\x1b[3m${t}\x1b[0m`);
|
|
738
791
|
s = s.replace(/(?<![_\w])_([^_\n]+)_(?![_\w])/g, (_, t) => `\x1b[3m${t}\x1b[0m`);
|
|
739
|
-
// [text](url) — show text underlined with a dim trailing (url)
|
|
792
|
+
// [text](url) — show text underlined with a dim, shortened trailing (url)
|
|
740
793
|
s = s.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, (_, t, u) =>
|
|
741
|
-
`\x1b[4m${t}\x1b[0m${A.dim(` (${u})`)}`,
|
|
794
|
+
`\x1b[4m${t}\x1b[0m${A.dim(` (${shortenUrl(u)})`)}`,
|
|
742
795
|
);
|
|
743
|
-
// Bare URLs — underline only the URL itself
|
|
744
|
-
s = s.replace(/\bhttps?:\/\/[^\s)]+/g, (u) => `\x1b[4m${u}\x1b[0m`);
|
|
796
|
+
// Bare URLs — underline only the URL itself, shortened if long
|
|
797
|
+
s = s.replace(/\bhttps?:\/\/[^\s)]+/g, (u) => `\x1b[4m${shortenUrl(u)}\x1b[0m`);
|
|
745
798
|
return s;
|
|
746
799
|
}).join("");
|
|
747
800
|
}
|
|
748
801
|
|
|
802
|
+
// Long URLs eat a whole wrapped line. Collapse to "host/…/last-segment" so the
|
|
803
|
+
// link stays recognizable without dominating the message. Short URLs are left
|
|
804
|
+
// intact (and remain copy-pasteable).
|
|
805
|
+
function shortenUrl(u) {
|
|
806
|
+
if (u.length <= 48) return u;
|
|
807
|
+
try {
|
|
808
|
+
const { host, pathname } = new URL(u);
|
|
809
|
+
const tail = pathname.split("/").filter(Boolean).pop() ?? "";
|
|
810
|
+
const short = tail ? `${host}/…/${tail}` : host;
|
|
811
|
+
return short.length < u.length ? short : u.slice(0, 45) + "…";
|
|
812
|
+
} catch {
|
|
813
|
+
return u.slice(0, 45) + "…";
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
749
817
|
async function printAgents() {
|
|
750
818
|
const reg = readJsonSafe(AGENTS_FILE, {});
|
|
751
819
|
const now = Date.now();
|
|
@@ -781,6 +849,38 @@ function pidAlive(pid) {
|
|
|
781
849
|
catch (e) { return e?.code === "EPERM"; }
|
|
782
850
|
}
|
|
783
851
|
|
|
852
|
+
// Agents considered "logged in": a live transport process, or a heartbeat
|
|
853
|
+
// within the stale window. Shared by the @mention picker and completer so we
|
|
854
|
+
// only ever offer reachable agents.
|
|
855
|
+
function onlineAgentIds() {
|
|
856
|
+
const reg = readJsonSafe(AGENTS_FILE, {});
|
|
857
|
+
const now = Date.now();
|
|
858
|
+
const STALE = 5 * 60 * 1000;
|
|
859
|
+
return Object.keys(reg)
|
|
860
|
+
.filter((id) => {
|
|
861
|
+
const a = reg[id];
|
|
862
|
+
const marker = readJsonSafe(path.join(TRANSPORT_DIR, `${sanitize(id)}.json`), null);
|
|
863
|
+
const live = marker && marker.pid && pidAlive(marker.pid);
|
|
864
|
+
return live || now - (a?.lastHeartbeat ?? 0) < STALE;
|
|
865
|
+
})
|
|
866
|
+
.sort();
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// Pop the list of logged-in agents the moment "@" starts a mention token, so
|
|
870
|
+
// you can see who's reachable without hunting through /list. The list is
|
|
871
|
+
// dim/cosmetic and re-renders above the preserved input line.
|
|
872
|
+
function showMentionPicker() {
|
|
873
|
+
if (typeof rl === "undefined") return;
|
|
874
|
+
const before = (rl.line ?? "").slice(0, rl.cursor ?? (rl.line ?? "").length);
|
|
875
|
+
// Only when the just-typed "@" opens a fresh token (start of line or after
|
|
876
|
+
// whitespace) — avoids firing inside emails or mid-word.
|
|
877
|
+
if (!/(^|\s)@$/.test(before)) return;
|
|
878
|
+
const ids = onlineAgentIds().filter((id) => id !== ID);
|
|
879
|
+
if (!ids.length) return;
|
|
880
|
+
const list = ids.map((id) => A.green("●") + agentColor(id)(`@${id}`)).join(" ");
|
|
881
|
+
say(A.dim(" ┄ ") + list + A.dim(" · Tab to complete"));
|
|
882
|
+
}
|
|
883
|
+
|
|
784
884
|
function readJsonl(file) {
|
|
785
885
|
if (!existsSync(file)) return [];
|
|
786
886
|
return readFileSync(file, "utf8").split("\n").filter(Boolean).map((l) => {
|