@cotal-ai/cli 0.9.0 → 0.10.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/dist/command.d.ts +12 -3
- package/dist/command.d.ts.map +1 -1
- package/dist/command.js +58 -11
- package/dist/command.js.map +1 -1
- package/dist/commands/agents.d.ts +64 -0
- package/dist/commands/agents.d.ts.map +1 -0
- package/dist/commands/agents.js +83 -0
- package/dist/commands/agents.js.map +1 -0
- package/dist/commands/channels.d.ts +2 -1
- package/dist/commands/channels.d.ts.map +1 -1
- package/dist/commands/channels.js +3 -16
- package/dist/commands/channels.js.map +1 -1
- package/dist/commands/completion.d.ts +6 -5
- package/dist/commands/completion.d.ts.map +1 -1
- package/dist/commands/completion.js +37 -14
- package/dist/commands/completion.js.map +1 -1
- package/dist/commands/console.d.ts +2 -1
- package/dist/commands/console.d.ts.map +1 -1
- package/dist/commands/console.js +2 -12
- package/dist/commands/console.js.map +1 -1
- package/dist/commands/down.d.ts +3 -2
- package/dist/commands/down.d.ts.map +1 -1
- package/dist/commands/down.js +4 -12
- package/dist/commands/down.js.map +1 -1
- package/dist/commands/ext.d.ts +18 -0
- package/dist/commands/ext.d.ts.map +1 -0
- package/dist/commands/ext.js +220 -0
- package/dist/commands/ext.js.map +1 -0
- package/dist/commands/feedback.d.ts +2 -2
- package/dist/commands/feedback.d.ts.map +1 -1
- package/dist/commands/feedback.js +4 -283
- package/dist/commands/feedback.js.map +1 -1
- package/dist/commands/history.d.ts +2 -1
- package/dist/commands/history.d.ts.map +1 -1
- package/dist/commands/history.js +3 -13
- package/dist/commands/history.js.map +1 -1
- package/dist/commands/join.d.ts +2 -1
- package/dist/commands/join.d.ts.map +1 -1
- package/dist/commands/join.js +2 -18
- package/dist/commands/join.js.map +1 -1
- package/dist/commands/meshes.d.ts +2 -1
- package/dist/commands/meshes.d.ts.map +1 -1
- package/dist/commands/meshes.js +1 -1
- package/dist/commands/meshes.js.map +1 -1
- package/dist/commands/mint.d.ts +2 -1
- package/dist/commands/mint.d.ts.map +1 -1
- package/dist/commands/mint.js +3 -14
- package/dist/commands/mint.js.map +1 -1
- package/dist/commands/personas.d.ts +2 -2
- package/dist/commands/personas.d.ts.map +1 -1
- package/dist/commands/personas.js +23 -20
- package/dist/commands/personas.js.map +1 -1
- package/dist/commands/send.d.ts +2 -2
- package/dist/commands/send.d.ts.map +1 -1
- package/dist/commands/send.js +21 -24
- package/dist/commands/send.js.map +1 -1
- package/dist/commands/setup.d.ts +30 -8
- package/dist/commands/setup.d.ts.map +1 -1
- package/dist/commands/setup.js +151 -316
- package/dist/commands/setup.js.map +1 -1
- package/dist/commands/spawn.d.ts +117 -2
- package/dist/commands/spawn.d.ts.map +1 -1
- package/dist/commands/spawn.js +137 -48
- package/dist/commands/spawn.js.map +1 -1
- package/dist/commands/topology.d.ts +2 -1
- package/dist/commands/topology.d.ts.map +1 -1
- package/dist/commands/topology.js +4 -8
- package/dist/commands/topology.js.map +1 -1
- package/dist/commands/up.d.ts +9 -4
- package/dist/commands/up.d.ts.map +1 -1
- package/dist/commands/up.js +96 -56
- package/dist/commands/up.js.map +1 -1
- package/dist/commands/use.d.ts +2 -2
- package/dist/commands/use.d.ts.map +1 -1
- package/dist/commands/use.js +2 -2
- package/dist/commands/use.js.map +1 -1
- package/dist/ext-loader.d.ts +13 -0
- package/dist/ext-loader.d.ts.map +1 -0
- package/dist/ext-loader.js +121 -0
- package/dist/ext-loader.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +203 -70
- package/dist/index.js.map +1 -1
- package/dist/lib/attach-client.d.ts +20 -0
- package/dist/lib/attach-client.d.ts.map +1 -0
- package/dist/lib/attach-client.js +218 -0
- package/dist/lib/attach-client.js.map +1 -0
- package/dist/lib/completion.d.ts +8 -0
- package/dist/lib/completion.d.ts.map +1 -0
- package/dist/lib/completion.js +66 -0
- package/dist/lib/completion.js.map +1 -0
- package/dist/lib/connect.d.ts +1 -73
- package/dist/lib/connect.d.ts.map +1 -1
- package/dist/lib/connect.js +4 -90
- package/dist/lib/connect.js.map +1 -1
- package/dist/lib/control.d.ts +34 -0
- package/dist/lib/control.d.ts.map +1 -0
- package/dist/lib/control.js +65 -0
- package/dist/lib/control.js.map +1 -0
- package/dist/lib/delivery-proc.d.ts +2 -0
- package/dist/lib/delivery-proc.d.ts.map +1 -1
- package/dist/lib/manager-proc.d.ts +5 -13
- package/dist/lib/manager-proc.d.ts.map +1 -1
- package/dist/lib/manager-proc.js +3 -42
- package/dist/lib/manager-proc.js.map +1 -1
- package/dist/lib/manifest/live.d.ts.map +1 -1
- package/dist/lib/manifest/live.js +4 -1
- package/dist/lib/manifest/live.js.map +1 -1
- package/dist/lib/status.d.ts +8 -4
- package/dist/lib/status.d.ts.map +1 -1
- package/dist/lib/status.js +21 -5
- package/dist/lib/status.js.map +1 -1
- package/dist/ui.d.ts +1 -12
- package/dist/ui.d.ts.map +1 -1
- package/dist/ui.js +4 -12
- package/dist/ui.js.map +1 -1
- package/package.json +8 -6
- package/dist/commands/demo.d.ts +0 -2
- package/dist/commands/demo.d.ts.map +0 -1
- package/dist/commands/demo.js +0 -163
- package/dist/commands/demo.js.map +0 -1
- package/dist/commands/web.d.ts +0 -32
- package/dist/commands/web.d.ts.map +0 -1
- package/dist/commands/web.js +0 -307
- package/dist/commands/web.js.map +0 -1
- package/dist/web/app.js +0 -823
- package/dist/web/graph.html +0 -207
- package/dist/web/graph.js +0 -512
- package/dist/web/index.html +0 -350
package/dist/web/app.js
DELETED
|
@@ -1,823 +0,0 @@
|
|
|
1
|
-
// Cotal observability client: a read-only god-view of one space. Presence + channel
|
|
2
|
-
// list + DM history come over HTTP; the live stream (roster + every chat/unicast/anycast
|
|
3
|
-
// message) arrives via SSE (/feed). This page never publishes to the mesh.
|
|
4
|
-
//
|
|
5
|
-
// Three centre views, one consistent skeleton (left = navigation, centre = content,
|
|
6
|
-
// right = NEEDS YOU, always): the Monitor (all-activity feed), a Channel view (message
|
|
7
|
-
// list; members fold into the header), and a Direct-messages lens (per-peer roll-up in
|
|
8
|
-
// the sidebar → a thread in the centre). `?demo` renders the fixed reference scene.
|
|
9
|
-
|
|
10
|
-
const $ = (id) => document.getElementById(id);
|
|
11
|
-
const STATUS = ["working", "waiting", "idle", "offline"];
|
|
12
|
-
// Status as shape *and* colour (never colour alone).
|
|
13
|
-
const GLYPH = { working: "●", waiting: "◐", idle: "○", offline: "⊘" };
|
|
14
|
-
const MODES = ["chat", "unicast", "anycast"];
|
|
15
|
-
const isDemo = new URLSearchParams(location.search).has("demo");
|
|
16
|
-
|
|
17
|
-
let roster = [];
|
|
18
|
-
let channels = new Map(); // name -> total message count
|
|
19
|
-
let unread = new Map(); // name -> messages seen since last viewed
|
|
20
|
-
let dms = []; // raw DM messages (god-view), grouped client-side
|
|
21
|
-
let selected = "*"; // "*" = all activity, else a channel name (null when a DM is open)
|
|
22
|
-
let dmSel = null; // { peer, with } when a Direct-messages thread is open
|
|
23
|
-
let agentSel = null; // peer id when an Agent Detail drill-down is open (else selected/dmSel drive the view)
|
|
24
|
-
let activity = []; // {mode, msg} ring buffer for the all-activity view
|
|
25
|
-
let channelMsgs = []; // messages for the selected channel
|
|
26
|
-
let modes = new Set(MODES); // delivery modes currently shown
|
|
27
|
-
let paused = false; // freeze auto-scroll so a value can be read
|
|
28
|
-
|
|
29
|
-
const esc = (s) =>
|
|
30
|
-
String(s).replace(/[&<>]/g, (ch) => ({ "&": "&", "<": "<", ">": ">" })[ch]);
|
|
31
|
-
const time = (ts) => new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
32
|
-
const bodyText = (msg) =>
|
|
33
|
-
(msg.parts || []).map((p) => (p.kind === "text" ? p.text : JSON.stringify(p.data))).join(" ");
|
|
34
|
-
function ago(ts) {
|
|
35
|
-
const s = Math.max(0, (Date.now() - ts) / 1000);
|
|
36
|
-
if (s < 45) return "just now";
|
|
37
|
-
if (s < 3600) return `${Math.round(s / 60)}m`;
|
|
38
|
-
if (s < 86400) return `${Math.round(s / 3600)}h`;
|
|
39
|
-
return `${Math.round(s / 86400)}d`;
|
|
40
|
-
}
|
|
41
|
-
const agoShort = (ts) => (ago(ts) === "just now" ? "now" : ago(ts));
|
|
42
|
-
const plural = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
|
|
43
|
-
|
|
44
|
-
// ── Harness (host connector) branding ─────────────────────────────────────────
|
|
45
|
-
// A brand colour + logo (drawn in currentColor) per connector, keyed by the card's meta.connector.
|
|
46
|
-
// Claude and OpenCode use their official marks (public-domain SVG data from Simple Icons, CC0);
|
|
47
|
-
// Hermes/Nous Research has no clean official mark, so it gets a custom messenger glyph. Unknown
|
|
48
|
-
// connectors degrade to a neutral badge with the raw name (the compact roster form omits the icon).
|
|
49
|
-
// Icons are inline SVG — no network, no extra files.
|
|
50
|
-
const HARNESS = {
|
|
51
|
-
claude: {
|
|
52
|
-
label: "Claude Code",
|
|
53
|
-
color: "#d97757", // official Claude clay
|
|
54
|
-
icon: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="m4.7144 15.9555 4.7174-2.6471.079-.2307-.079-.1275h-.2307l-.7893-.0486-2.6956-.0729-2.3375-.0971-2.2646-.1214-.5707-.1215-.5343-.7042.0546-.3522.4797-.3218.686.0608 1.5179.1032 2.2767.1578 1.6514.0972 2.4468.255h.3886l.0546-.1579-.1336-.0971-.1032-.0972L6.973 9.8356l-2.55-1.6879-1.3356-.9714-.7225-.4918-.3643-.4614-.1578-1.0078.6557-.7225.8803.0607.2246.0607.8925.686 1.9064 1.4754 2.4893 1.8336.3643.3035.1457-.1032.0182-.0728-.164-.2733-1.3539-2.4467-1.445-2.4893-.6435-1.032-.17-.6194c-.0607-.255-.1032-.4674-.1032-.7285L6.287.1335 6.6997 0l.9957.1336.419.3642.6192 1.4147 1.0018 2.2282 1.5543 3.0296.4553.8985.2429.8318.091.255h.1579v-.1457l.1275-1.706.2368-2.0947.2307-2.6957.0789-.7589.3764-.9107.7468-.4918.5828.2793.4797.686-.0668.4433-.2853 1.8517-.5586 2.9021-.3643 1.9429h.2125l.2429-.2429.9835-1.3053 1.6514-2.0643.7286-.8196.85-.9046.5464-.4311h1.0321l.759 1.1293-.34 1.1657-1.0625 1.3478-.8804 1.1414-1.2628 1.7-.7893 1.36.0729.1093.1882-.0183 2.8535-.607 1.5421-.2794 1.8396-.3157.8318.3886.091.3946-.3278.8075-1.967.4857-2.3072.4614-3.4364.8136-.0425.0304.0486.0607 1.5482.1457.6618.0364h1.621l3.0175.2247.7892.522.4736.6376-.079.4857-1.2142.6193-1.6393-.3886-3.825-.9107-1.3113-.3279h-.1822v.1093l1.0929 1.0686 2.0035 1.8092 2.5075 2.3314.1275.5768-.3218.4554-.34-.0486-2.2039-1.6575-.85-.7468-1.9246-1.621h-.1275v.17l.4432.6496 2.3436 3.5214.1214 1.0807-.17.3521-.6071.2125-.6679-.1214-1.3721-1.9246L14.38 17.959l-1.1414-1.9428-.1397.079-.674 7.2552-.3156.3703-.7286.2793-.6071-.4614-.3218-.7468.3218-1.4753.3886-1.9246.3157-1.53.2853-1.9004.17-.6314-.0121-.0425-.1397.0182-1.4328 1.9672-2.1796 2.9446-1.7243 1.8456-.4128.164-.7164-.3704.0667-.6618.4008-.5889 2.386-3.0357 1.4389-1.882.929-1.0868-.0062-.1579h-.0546l-6.3385 4.1164-1.1293.1457-.4857-.4554.0608-.7467.2307-.2429 1.9064-1.3114Z"/></svg>`,
|
|
55
|
-
},
|
|
56
|
-
opencode: {
|
|
57
|
-
label: "OpenCode",
|
|
58
|
-
color: "#cdd6e0", // OpenCode is monochrome by brand; rendered light on the dark UI
|
|
59
|
-
icon: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M22 24H2V0h20zM17 4.8H7v14.4h10z"/></svg>`,
|
|
60
|
-
},
|
|
61
|
-
hermes: {
|
|
62
|
-
label: "Hermes",
|
|
63
|
-
color: "#a78bfa", // no official mark — custom messenger glyph, violet
|
|
64
|
-
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 3 3 10.5l7 2.5 2.5 7L21 3Z"/><path d="M21 3 10 13"/></svg>`,
|
|
65
|
-
},
|
|
66
|
-
};
|
|
67
|
-
// Render the harness badge: a branded pill (icon + label) by default, or `{compact:true}` for a
|
|
68
|
-
// bare colour icon (the roster row). The inline style only ever takes a value from HARNESS, never
|
|
69
|
-
// raw card input, so meta.connector can't inject CSS.
|
|
70
|
-
function harnessBadge(connector, opts = {}) {
|
|
71
|
-
if (!connector) return "";
|
|
72
|
-
const h = HARNESS[connector];
|
|
73
|
-
const color = h ? h.color : "var(--dim)";
|
|
74
|
-
const text = h ? h.label : connector;
|
|
75
|
-
if (opts.compact)
|
|
76
|
-
return h ? `<span class="harness-ico" style="color:${color}" title="harness · ${esc(text)}">${h.icon}</span>` : "";
|
|
77
|
-
return `<span class="harness-badge" style="--hc:${color}" title="agent harness · ${esc(text)}">${h ? h.icon : ""}<span class="hl">${esc(text)}</span></span>`;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function setConn(live) {
|
|
81
|
-
const el = $("conn");
|
|
82
|
-
el.className = "pill" + (live ? "" : " down");
|
|
83
|
-
el.querySelector(".t").textContent = live ? "live" : "disconnected";
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// ── Header: golden-signal tiles ───────────────────────────────────────────────
|
|
87
|
-
function renderTiles(counts, oldest) {
|
|
88
|
-
const tiles = [
|
|
89
|
-
["working", counts.working],
|
|
90
|
-
["waiting", counts.waiting],
|
|
91
|
-
["idle", counts.idle],
|
|
92
|
-
["offline", counts.offline],
|
|
93
|
-
["oldest", oldest, "oldest unattended"],
|
|
94
|
-
];
|
|
95
|
-
$("tiles").innerHTML = tiles
|
|
96
|
-
.map(
|
|
97
|
-
([k, n, lbl]) => `<div class="tile ${k}">
|
|
98
|
-
<span class="bar"></span>
|
|
99
|
-
<div class="c"><span class="n">${n}</span><span class="lbl">${lbl ?? k}</span></div>
|
|
100
|
-
</div>`,
|
|
101
|
-
)
|
|
102
|
-
.join("");
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// ── Sidebar: roster ───────────────────────────────────────────────────────────
|
|
106
|
-
function peerRow(p) {
|
|
107
|
-
// A peer with an id is a click-through into its Agent Detail card; demo rows have no id.
|
|
108
|
-
const nav = p.id ? ` nav${p.id === agentSel ? " sel" : ""}` : "";
|
|
109
|
-
const attrs = p.id ? ` data-agent="${esc(p.id)}" tabindex="0" role="button"` : "";
|
|
110
|
-
return `<div class="peer ${p.status}${nav}"${attrs}>
|
|
111
|
-
<span class="dot ${p.status}">${GLYPH[p.status] ?? "○"}</span>
|
|
112
|
-
<div class="c">
|
|
113
|
-
<div class="l1">
|
|
114
|
-
<span class="name">${esc(p.name)}</span>
|
|
115
|
-
${p.harness ? harnessBadge(p.harness, { compact: true }) : ""}
|
|
116
|
-
${p.role ? `<span class="role">${esc(p.role)}</span>` : ""}
|
|
117
|
-
${p.tag ? `<span class="tag">${esc(p.tag)}</span>` : ""}
|
|
118
|
-
</div>
|
|
119
|
-
${p.act ? `<div class="act" title="${esc(p.act)}">${esc(p.act)}</div>` : ""}
|
|
120
|
-
</div>
|
|
121
|
-
</div>`;
|
|
122
|
-
}
|
|
123
|
-
function renderRoster(list) {
|
|
124
|
-
$("roster").innerHTML = list.length
|
|
125
|
-
? list.map(peerRow).join("")
|
|
126
|
-
: `<div class="empty">no peers</div>`;
|
|
127
|
-
for (const el of $("roster").querySelectorAll(".peer[data-agent]")) {
|
|
128
|
-
el.onclick = () => selectAgent(el.dataset.agent);
|
|
129
|
-
el.onkeydown = (e) => {
|
|
130
|
-
if (e.key === "Enter" || e.key === " ") (e.preventDefault(), selectAgent(el.dataset.agent));
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
// The online roster, shaped for the sidebar: offline peers drop out (their count still rides the
|
|
135
|
-
// header tiles), live peers sort by status then name. Re-rendered on its own when a selection
|
|
136
|
-
// changes so the row highlight tracks the open Agent Detail.
|
|
137
|
-
function rosterRows() {
|
|
138
|
-
return [...roster]
|
|
139
|
-
.filter((p) => p.status !== "offline")
|
|
140
|
-
.sort(
|
|
141
|
-
(a, b) =>
|
|
142
|
-
STATUS.indexOf(a.status) - STATUS.indexOf(b.status) ||
|
|
143
|
-
a.card.name.localeCompare(b.card.name),
|
|
144
|
-
)
|
|
145
|
-
.map((p) => ({
|
|
146
|
-
id: p.card.id,
|
|
147
|
-
name: p.card.name,
|
|
148
|
-
role: p.card.role,
|
|
149
|
-
status: p.status,
|
|
150
|
-
act: p.activity,
|
|
151
|
-
harness: p.card.meta?.connector,
|
|
152
|
-
tag: p.status === "waiting" ? "needs input" : null,
|
|
153
|
-
}));
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
// ── Sidebar: channels ─────────────────────────────────────────────────────────
|
|
157
|
-
function chanRow(ch) {
|
|
158
|
-
const sel = !dmSel && ch.key === selected;
|
|
159
|
-
const lead = ch.all ? `<span class="glyph">✸</span>` : `<span class="hash">#</span>`;
|
|
160
|
-
return `<div class="chan${sel ? " sel" : ""}${ch.muted ? " muted" : ""}" data-ch="${esc(ch.key)}">
|
|
161
|
-
<span class="l">${lead}<span class="name">${esc(ch.label)}</span></span>
|
|
162
|
-
${ch.mention ? `<span class="mention">${ch.mention}</span>` : ""}
|
|
163
|
-
<span class="count">${ch.count}</span>
|
|
164
|
-
</div>`;
|
|
165
|
-
}
|
|
166
|
-
function renderChannels() {
|
|
167
|
-
const names = [...channels.keys()]; // insertion order (curated in demo, server order live)
|
|
168
|
-
const total = [...channels.values()].reduce((a, b) => a + b, 0);
|
|
169
|
-
const rows = [{ key: "*", all: true, label: "all activity", count: total }].concat(
|
|
170
|
-
names.map((n) => ({
|
|
171
|
-
key: n,
|
|
172
|
-
label: n,
|
|
173
|
-
count: channels.get(n),
|
|
174
|
-
mention: unread.get(n) || 0,
|
|
175
|
-
muted: channels.get(n) === 0,
|
|
176
|
-
})),
|
|
177
|
-
);
|
|
178
|
-
$("channels").innerHTML = rows.map(chanRow).join("");
|
|
179
|
-
for (const el of $("channels").querySelectorAll(".chan")) el.onclick = () => select(el.dataset.ch);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// ── Sidebar: direct messages (per-peer roll-up → drill) ───────────────────────
|
|
183
|
-
const SEP = "";
|
|
184
|
-
function roleOf(name) {
|
|
185
|
-
const r = roster.find((x) => x.card?.name === name);
|
|
186
|
-
return r?.card?.role;
|
|
187
|
-
}
|
|
188
|
-
function rosterStatus(name) {
|
|
189
|
-
const r = roster.find((x) => x.card?.name === name);
|
|
190
|
-
return r ? r.status : "offline";
|
|
191
|
-
}
|
|
192
|
-
// id→name resolution shared by the DM lens and the all-activity feed. `from` carries a full
|
|
193
|
-
// card (id+name), but a unicast `to` is the bare recipient identity id (a pubkey) — without this
|
|
194
|
-
// it renders raw. Sources: live roster cards, DM senders, and feed senders we've seen.
|
|
195
|
-
function nameIndex() {
|
|
196
|
-
const idName = new Map();
|
|
197
|
-
for (const p of roster) if (p.card?.id) idName.set(p.card.id, p.card.name);
|
|
198
|
-
for (const m of dms) if (m.from?.id && m.from?.name) idName.set(m.from.id, m.from.name);
|
|
199
|
-
for (const e of activity) if (e.msg?.from?.id && e.msg.from.name) idName.set(e.msg.from.id, e.msg.from.name);
|
|
200
|
-
return idName;
|
|
201
|
-
}
|
|
202
|
-
// Resolve an EndpointRef object or a bare id to a display name; an unknown id shrinks to a short
|
|
203
|
-
// prefix, and a string that isn't an identity (already a name) passes through unchanged.
|
|
204
|
-
function displayNameOf(x, idx) {
|
|
205
|
-
if (!x) return "?";
|
|
206
|
-
if (typeof x === "object") return x.name || displayNameOf(x.id, idx);
|
|
207
|
-
if (idx.has(x)) return idx.get(x);
|
|
208
|
-
return /^[A-Z2-7]{32,}$/.test(x) ? x.slice(0, 6) + "…" : x; // unknown identity → short id
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
// Group raw DMs into per-peer rows; each peer lists its counterparties (conversations).
|
|
212
|
-
// O(peers-with-DMs), never the n² pair cross-product — only pairs that actually talked.
|
|
213
|
-
function dmPeers() {
|
|
214
|
-
if (isDemo) return DEMO.dmPeers;
|
|
215
|
-
const idx = nameIndex();
|
|
216
|
-
const nameOf = (x) => displayNameOf(x, idx);
|
|
217
|
-
const conv = new Map();
|
|
218
|
-
for (const m of dms) {
|
|
219
|
-
const a = nameOf(m.from),
|
|
220
|
-
b = nameOf(m.to);
|
|
221
|
-
if (a === "?" || b === "?" || a === b) continue;
|
|
222
|
-
const key = [a, b].sort().join(SEP);
|
|
223
|
-
if (!conv.has(key)) conv.set(key, { parts: [a, b].sort(), msgs: [] });
|
|
224
|
-
conv.get(key).msgs.push({ ts: time(m.ts), who: a, status: rosterStatus(a), body: bodyText(m), _ts: m.ts });
|
|
225
|
-
}
|
|
226
|
-
const peers = new Map();
|
|
227
|
-
for (const c of conv.values()) {
|
|
228
|
-
c.msgs.sort((x, y) => x._ts - y._ts);
|
|
229
|
-
const last = c.msgs.length ? c.msgs[c.msgs.length - 1]._ts : 0;
|
|
230
|
-
for (const p of c.parts) {
|
|
231
|
-
const other = c.parts[0] === p ? c.parts[1] : c.parts[0];
|
|
232
|
-
if (!peers.has(p)) peers.set(p, { name: p, conversations: [], last: 0 });
|
|
233
|
-
const pe = peers.get(p);
|
|
234
|
-
pe.conversations.push({ with: other, role: roleOf(other), status: rosterStatus(other), unread: 0, last, msgs: c.msgs });
|
|
235
|
-
pe.last = Math.max(pe.last, last);
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
return [...peers.values()]
|
|
239
|
-
.map((p) => ({
|
|
240
|
-
name: p.name,
|
|
241
|
-
role: roleOf(p.name),
|
|
242
|
-
status: rosterStatus(p.name),
|
|
243
|
-
unread: 0,
|
|
244
|
-
threads: p.conversations.length,
|
|
245
|
-
conversations: p.conversations.sort((a, b) => b.last - a.last),
|
|
246
|
-
last: p.last,
|
|
247
|
-
}))
|
|
248
|
-
.sort((a, b) => b.last - a.last);
|
|
249
|
-
}
|
|
250
|
-
function dmPeerRow(p, expanded) {
|
|
251
|
-
return `<div class="dm${expanded ? " sel" : ""}" data-dm="${esc(p.name)}">
|
|
252
|
-
<span class="caret">${expanded ? "▾" : "▸"}</span>
|
|
253
|
-
<span class="l">
|
|
254
|
-
<span class="dot ${p.status}">${GLYPH[p.status] ?? "○"}</span>
|
|
255
|
-
<span class="nm">${esc(p.name)}</span>
|
|
256
|
-
${p.role ? `<span class="role">${esc(p.role)}</span>` : ""}
|
|
257
|
-
</span>
|
|
258
|
-
${p.unread ? `<span class="mention">${p.unread}</span>` : ""}
|
|
259
|
-
${expanded ? "" : `<span class="threads">${plural(p.threads, "thread")}</span>`}
|
|
260
|
-
</div>`;
|
|
261
|
-
}
|
|
262
|
-
function dmSubRow(peer, c) {
|
|
263
|
-
const sel = dmSel && dmSel.peer === peer && dmSel.with === c.with;
|
|
264
|
-
return `<div class="dm sub${sel ? " sel" : ""}" data-dm="${esc(peer)}${SEP}${esc(c.with)}">
|
|
265
|
-
<span class="ln">↳</span>
|
|
266
|
-
<span class="l">
|
|
267
|
-
<span class="dot ${c.status}">${GLYPH[c.status] ?? "○"}</span>
|
|
268
|
-
<span class="nm">${esc(c.with)}</span>
|
|
269
|
-
${c.role ? `<span class="role">${esc(c.role)}</span>` : ""}
|
|
270
|
-
</span>
|
|
271
|
-
${c.unread ? `<span class="mention">${c.unread}</span>` : ""}
|
|
272
|
-
</div>`;
|
|
273
|
-
}
|
|
274
|
-
function renderDMs() {
|
|
275
|
-
const peers = dmPeers();
|
|
276
|
-
if (!peers.length) {
|
|
277
|
-
$("dms").innerHTML = `<div class="empty">no direct messages</div>`;
|
|
278
|
-
return;
|
|
279
|
-
}
|
|
280
|
-
let html = "";
|
|
281
|
-
for (const p of peers) {
|
|
282
|
-
const expanded = !!dmSel && dmSel.peer === p.name;
|
|
283
|
-
html += dmPeerRow(p, expanded);
|
|
284
|
-
if (expanded) for (const c of p.conversations) html += dmSubRow(p.name, c);
|
|
285
|
-
}
|
|
286
|
-
$("dms").innerHTML = html;
|
|
287
|
-
for (const el of $("dms").querySelectorAll("[data-dm]")) {
|
|
288
|
-
el.onclick = () => {
|
|
289
|
-
const [peer, w] = el.dataset.dm.split(SEP);
|
|
290
|
-
selectDM(peer, w || null);
|
|
291
|
-
};
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
function renderSidebarNav() {
|
|
295
|
-
renderChannels();
|
|
296
|
-
renderDMs();
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// ── Feed rows (all-activity) ──────────────────────────────────────────────────
|
|
300
|
-
function rowHTML(e) {
|
|
301
|
-
if (e.type === "sys") return `<div class="sys">${esc(e.text)}</div>`;
|
|
302
|
-
if (e.type === "rollup")
|
|
303
|
-
return `<div class="rollup"><span class="ar">⌄</span><span class="t">${esc(e.text)}</span></div>`;
|
|
304
|
-
const intent = e.type === "intent";
|
|
305
|
-
const badgeClass = intent ? "intent" : e.mode;
|
|
306
|
-
const badgeText = intent ? "⟶ intent" : e.mode;
|
|
307
|
-
const tgt = intent ? e.note : e.target;
|
|
308
|
-
return `<div class="msg${intent ? " intent" : ""}">
|
|
309
|
-
<span class="ts">${esc(e.ts)}</span>
|
|
310
|
-
<span class="badge ${badgeClass}">${esc(badgeText)}</span>
|
|
311
|
-
<div class="c">
|
|
312
|
-
<div class="l1">
|
|
313
|
-
<span class="who">${esc(e.who)}</span>
|
|
314
|
-
${e.role ? `<span class="role">${esc(e.role)}</span>` : ""}
|
|
315
|
-
${tgt ? `<span class="tgt">${esc(tgt)}</span>` : ""}
|
|
316
|
-
${e.sub ? `<span class="subpill">${esc(e.sub)}</span>` : ""}
|
|
317
|
-
</div>
|
|
318
|
-
<div class="body">${esc(e.body)}</div>
|
|
319
|
-
</div>
|
|
320
|
-
</div>`;
|
|
321
|
-
}
|
|
322
|
-
function liveEntry(mode, msg, idx) {
|
|
323
|
-
const target =
|
|
324
|
-
mode === "chat"
|
|
325
|
-
? `#${msg.channel ?? ""}`
|
|
326
|
-
: mode === "unicast"
|
|
327
|
-
? `→ ${displayNameOf(msg.to, idx)}`
|
|
328
|
-
: `→ @${msg.toService ?? ""}`;
|
|
329
|
-
return {
|
|
330
|
-
type: "msg",
|
|
331
|
-
mode,
|
|
332
|
-
ts: time(msg.ts),
|
|
333
|
-
who: msg.from?.name ?? "?",
|
|
334
|
-
role: msg.from?.role,
|
|
335
|
-
target,
|
|
336
|
-
body: bodyText(msg),
|
|
337
|
-
};
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
function renderAllActivity() {
|
|
341
|
-
const center = $("center");
|
|
342
|
-
const prev = center.querySelector(".feed");
|
|
343
|
-
const atBottom = prev ? prev.scrollHeight - prev.scrollTop - prev.clientHeight < 40 : true;
|
|
344
|
-
const idx = nameIndex();
|
|
345
|
-
const rows = (isDemo ? DEMO.activity : activity.map((e) => liveEntry(e.mode, e.msg, idx))).filter(
|
|
346
|
-
(e) => !e.mode || modes.has(e.mode),
|
|
347
|
-
);
|
|
348
|
-
const sub = isDemo ? "112 recent · live" : `${rows.length} recent · live`;
|
|
349
|
-
center.innerHTML = `
|
|
350
|
-
<div class="feed-head">
|
|
351
|
-
<span class="h">✸ All activity</span>
|
|
352
|
-
<span class="sub">${esc(sub)}</span>
|
|
353
|
-
<span class="ctrls">
|
|
354
|
-
${MODES.map((m) => `<span class="chip mode${modes.has(m) ? " on" : ""}" data-mode="${m}">${m}</span>`).join("")}
|
|
355
|
-
<span class="chip pause${paused ? " on" : ""}" id="pause">${paused ? "▶ resume" : "⏸ pause"}</span>
|
|
356
|
-
<span class="chip static">muted · 2</span>
|
|
357
|
-
</span>
|
|
358
|
-
</div>
|
|
359
|
-
<div class="feed">${rows.length ? rows.map(rowHTML).join("") : `<div class="empty">waiting for messages…</div>`}</div>`;
|
|
360
|
-
for (const chip of center.querySelectorAll(".chip[data-mode]"))
|
|
361
|
-
chip.onclick = () => {
|
|
362
|
-
const m = chip.dataset.mode;
|
|
363
|
-
modes.has(m) ? modes.delete(m) : modes.add(m);
|
|
364
|
-
renderAllActivity();
|
|
365
|
-
};
|
|
366
|
-
const pause = center.querySelector("#pause");
|
|
367
|
-
if (pause) pause.onclick = () => ((paused = !paused), renderAllActivity());
|
|
368
|
-
const feed = center.querySelector(".feed");
|
|
369
|
-
if (atBottom && !paused) feed.scrollTop = feed.scrollHeight;
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
// ── Channel view (centre; members fold into the header) ───────────────────────
|
|
373
|
-
function cmsgHTML(m) {
|
|
374
|
-
if (m.type === "unread")
|
|
375
|
-
return `<div class="unread-mark"><span class="line"></span><span class="t">${esc(m.text)}</span><span class="line"></span></div>`;
|
|
376
|
-
return `<div class="cmsg">
|
|
377
|
-
<span class="ts">${esc(m.ts)}</span>
|
|
378
|
-
<span class="dot ${m.status}">${GLYPH[m.status] ?? "●"}</span>
|
|
379
|
-
<div class="c">
|
|
380
|
-
<div class="l1"><span class="who">${esc(m.who)}</span>${m.role ? `<span class="role">${esc(m.role)}</span>` : ""}</div>
|
|
381
|
-
<div class="body">${esc(m.body)}</div>
|
|
382
|
-
${m.thread ? `<span class="thread">${esc(m.thread)}</span>` : ""}
|
|
383
|
-
</div>
|
|
384
|
-
</div>`;
|
|
385
|
-
}
|
|
386
|
-
function channelMembers(msgs) {
|
|
387
|
-
const seen = new Map();
|
|
388
|
-
for (const msg of msgs) {
|
|
389
|
-
const n = msg.from?.name;
|
|
390
|
-
if (!n || seen.has(n)) continue;
|
|
391
|
-
seen.set(n, { name: n, role: msg.from?.role, status: rosterStatus(n) });
|
|
392
|
-
}
|
|
393
|
-
return [...seen.values()];
|
|
394
|
-
}
|
|
395
|
-
function renderChannel() {
|
|
396
|
-
const name = selected;
|
|
397
|
-
let items, memberCount, msgCount, desc;
|
|
398
|
-
if (isDemo) {
|
|
399
|
-
items = DEMO.cv.messages;
|
|
400
|
-
memberCount = DEMO.cv.members.length;
|
|
401
|
-
msgCount = channels.get(name) ?? 51;
|
|
402
|
-
desc = name === "team.backend" ? "Backend coordination — channels, endpoint, NATS. · " : "";
|
|
403
|
-
} else {
|
|
404
|
-
items = channelMsgs.map((msg) => ({
|
|
405
|
-
ts: time(msg.ts),
|
|
406
|
-
status: rosterStatus(msg.from?.name),
|
|
407
|
-
who: msg.from?.name ?? "?",
|
|
408
|
-
role: msg.from?.role,
|
|
409
|
-
body: bodyText(msg),
|
|
410
|
-
}));
|
|
411
|
-
memberCount = channelMembers(channelMsgs).length;
|
|
412
|
-
msgCount = channels.get(name) ?? items.length;
|
|
413
|
-
desc = "";
|
|
414
|
-
}
|
|
415
|
-
const sub = name.includes(".") ? `subtree of ${name.split(".")[0]}.>` : "";
|
|
416
|
-
$("center").innerHTML = `
|
|
417
|
-
<div class="ch-head">
|
|
418
|
-
<div class="row">
|
|
419
|
-
<div class="title"><span class="h"># ${esc(name)}</span>${sub ? `<span class="sub">${esc(sub)}</span>` : ""}</div>
|
|
420
|
-
<div class="ctrls">
|
|
421
|
-
<span class="chip mode on">👥 ${plural(memberCount, "member")}</span>
|
|
422
|
-
<span class="chip mode on">✦ summarize</span>
|
|
423
|
-
<span class="chip static">🔕 mute</span>
|
|
424
|
-
${isDemo ? "" : `<span class="chip danger" id="ch-del" title="Delete this channel and all its messages">🗑 delete</span>`}
|
|
425
|
-
</div>
|
|
426
|
-
</div>
|
|
427
|
-
<div class="purpose">${esc(desc)}${plural(memberCount, "member")} · ${plural(msgCount, "message")}</div>
|
|
428
|
-
</div>
|
|
429
|
-
<div class="clist">${items.length ? items.map(cmsgHTML).join("") : `<div class="empty">no messages</div>`}</div>`;
|
|
430
|
-
const list = $("center").querySelector(".clist");
|
|
431
|
-
list.scrollTop = list.scrollHeight;
|
|
432
|
-
const del = $("center").querySelector("#ch-del");
|
|
433
|
-
if (del) del.onclick = () => deleteChannel(name);
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
// Delete the channel and its content (steward action). Confirm first — purging the chat
|
|
437
|
-
// stream is irreversible. On success the channel drops from the sidebar and the view falls
|
|
438
|
-
// back to all-activity; a stray live message would recreate it (deletion clears, not bans).
|
|
439
|
-
async function deleteChannel(name) {
|
|
440
|
-
if (!name || name === "*") return;
|
|
441
|
-
if (!confirm(`Delete #${name} and all its messages?\n\nThis purges the channel's history and cannot be undone.`)) return;
|
|
442
|
-
try {
|
|
443
|
-
const r = await fetch("/api/channel/delete", {
|
|
444
|
-
method: "POST",
|
|
445
|
-
headers: { "content-type": "application/json" },
|
|
446
|
-
body: JSON.stringify({ channel: name }),
|
|
447
|
-
});
|
|
448
|
-
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `HTTP ${r.status}`);
|
|
449
|
-
channels.delete(name);
|
|
450
|
-
unread.delete(name);
|
|
451
|
-
activity = activity.filter((e) => e.msg.channel !== name); // drop its rows from all-activity
|
|
452
|
-
select("*");
|
|
453
|
-
} catch (e) {
|
|
454
|
-
alert(`Couldn't delete #${name}: ${e.message}`);
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
// ── Direct-messages thread (centre) ───────────────────────────────────────────
|
|
459
|
-
function dmMsgHTML(m, peer, withName) {
|
|
460
|
-
const to = m.who === peer ? withName : peer;
|
|
461
|
-
return `<div class="cmsg">
|
|
462
|
-
<span class="ts">${esc(m.ts)}</span>
|
|
463
|
-
<span class="dot ${m.status}">${GLYPH[m.status] ?? "●"}</span>
|
|
464
|
-
<div class="c">
|
|
465
|
-
<div class="l1"><span class="who">${esc(m.who)}</span><span class="dir">→ ${esc(to)}</span></div>
|
|
466
|
-
<div class="body">${esc(m.body)}</div>
|
|
467
|
-
</div>
|
|
468
|
-
</div>`;
|
|
469
|
-
}
|
|
470
|
-
function renderDMThread() {
|
|
471
|
-
const peer = dmSel.peer;
|
|
472
|
-
const pe = dmPeers().find((p) => p.name === peer);
|
|
473
|
-
const conv = pe && (pe.conversations.find((c) => c.with === dmSel.with) || pe.conversations[0]);
|
|
474
|
-
const withName = conv ? conv.with : dmSel.with;
|
|
475
|
-
const msgs = conv ? conv.msgs : []; // display-ready (ts, who, status, body) for demo + live
|
|
476
|
-
$("center").innerHTML = `
|
|
477
|
-
<div class="ch-head">
|
|
478
|
-
<div class="row">
|
|
479
|
-
<div class="title"><span class="h">${esc(peer)} ↔ ${esc(withName)}</span><span class="dtag">direct</span></div>
|
|
480
|
-
<div class="ctrls"><span class="chip static">🔕 mute</span></div>
|
|
481
|
-
</div>
|
|
482
|
-
<div class="purpose">unicast · private to the two of them · ${plural(msgs.length, "message")}</div>
|
|
483
|
-
</div>
|
|
484
|
-
<div class="clist">${msgs.length ? msgs.map((m) => dmMsgHTML(m, peer, withName)).join("") : `<div class="empty">no messages</div>`}</div>`;
|
|
485
|
-
const list = $("center").querySelector(".clist");
|
|
486
|
-
list.scrollTop = list.scrollHeight;
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
// ── NEEDS YOU rail (always on the right) ──────────────────────────────────────
|
|
490
|
-
function cardHTML(c) {
|
|
491
|
-
const nav = c.id ? ` nav${c.id === agentSel ? " sel" : ""}` : "";
|
|
492
|
-
return `<div class="card tone-${c.tone}${nav}"${c.id ? ` data-agent="${esc(c.id)}"` : ""}>
|
|
493
|
-
<div class="top">
|
|
494
|
-
<div class="cat-l"><span class="cdot"></span><span class="cat">${esc(c.cat)}</span></div>
|
|
495
|
-
<span class="age">${esc(c.age)}</span>
|
|
496
|
-
</div>
|
|
497
|
-
<div class="title">${esc(c.title)}${c.role ? `<span class="crole">${esc(c.role)}</span>` : ""}</div>
|
|
498
|
-
<div class="desc">${esc(c.desc)}</div>
|
|
499
|
-
${c.primary ? `<div class="btns"><span class="btn primary">${esc(c.primary)}</span>${c.secondary ? `<span class="btn secondary">${esc(c.secondary)}</span>` : ""}</div>` : ""}
|
|
500
|
-
</div>`;
|
|
501
|
-
}
|
|
502
|
-
function waitingCards() {
|
|
503
|
-
return roster
|
|
504
|
-
.filter((p) => p.status === "waiting")
|
|
505
|
-
.sort((a, b) => b.ts - a.ts)
|
|
506
|
-
.map((p) => ({
|
|
507
|
-
tone: "amber",
|
|
508
|
-
cat: "WAITING",
|
|
509
|
-
age: ago(p.ts),
|
|
510
|
-
title: `${p.card.name} is waiting`,
|
|
511
|
-
role: p.card.role,
|
|
512
|
-
// p.activity is the Claude Code Notification text (the actual blocking prompt/permission).
|
|
513
|
-
desc: p.activity || "waiting for input",
|
|
514
|
-
id: p.card.id, // makes the card a clickable drill-down into the Agent Detail view
|
|
515
|
-
}));
|
|
516
|
-
}
|
|
517
|
-
function renderRail() {
|
|
518
|
-
const cards = isDemo ? DEMO.cards : waitingCards();
|
|
519
|
-
$("rail").className = "rail";
|
|
520
|
-
$("rail").innerHTML =
|
|
521
|
-
`<div class="rail-head"><span class="t">NEEDS YOU</span>${cards.length ? `<span class="n">${cards.length}</span>` : ""}</div>` +
|
|
522
|
-
(cards.length
|
|
523
|
-
? cards.map(cardHTML).join("") +
|
|
524
|
-
`<div class="rail-foot">Everything else stays quiet in the feed.</div>`
|
|
525
|
-
: `<div class="empty">nothing waiting — all clear ✓</div>`);
|
|
526
|
-
for (const el of $("rail").querySelectorAll(".card[data-agent]"))
|
|
527
|
-
el.onclick = () => selectAgent(el.dataset.agent);
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
// ── Agent Detail drill-down (centre) — per-agent frame, rendered from the peer's card (docs/web.md) ──
|
|
531
|
-
function selectAgent(id) {
|
|
532
|
-
agentSel = id;
|
|
533
|
-
dmSel = null;
|
|
534
|
-
selected = null;
|
|
535
|
-
renderSidebarNav();
|
|
536
|
-
renderRoster(rosterRows()); // light up the clicked peer (only reached live — demo rows have no id)
|
|
537
|
-
renderCenter();
|
|
538
|
-
renderRail();
|
|
539
|
-
}
|
|
540
|
-
function renderAgentDetail() {
|
|
541
|
-
const p = roster.find((x) => x.card.id === agentSel);
|
|
542
|
-
if (!p) {
|
|
543
|
-
$("center").innerHTML = `<div class="detail"><div class="empty">agent no longer present — pick another from the roster or NEEDS YOU.</div></div>`;
|
|
544
|
-
return;
|
|
545
|
-
}
|
|
546
|
-
// The AgentCard is the legibility contract: who, in what role, what it can do, on what harness.
|
|
547
|
-
// Render only fields the card actually carries (skills/protocolVersion aren't populated yet).
|
|
548
|
-
const card = p.card;
|
|
549
|
-
const meta = card.meta || {};
|
|
550
|
-
const waiting = p.status === "waiting";
|
|
551
|
-
const who = card.role ? `${esc(card.name)}<span class="crole">${esc(card.role)}</span>` : esc(card.name);
|
|
552
|
-
const since = waiting ? `waiting ${esc(ago(p.ts))}` : `${esc(p.status)} · ${esc(ago(p.ts))}`;
|
|
553
|
-
|
|
554
|
-
const badges = [
|
|
555
|
-
card.kind ? `<span class="d-badge">${esc(card.kind)}</span>` : "",
|
|
556
|
-
harnessBadge(meta.connector),
|
|
557
|
-
meta.model ? `<span class="d-badge model" title="model">${esc(meta.model)}</span>` : "",
|
|
558
|
-
].join("");
|
|
559
|
-
const desc = card.description ? `<div class="d-desc">${esc(card.description)}</div>` : "";
|
|
560
|
-
const blocked = waiting
|
|
561
|
-
? `<div class="d-label">Blocked on</div><div class="d-block">${esc(p.activity || "waiting for input")}</div>`
|
|
562
|
-
: `<div class="d-label">Activity</div><div class="d-block muted">${esc(p.activity || "no current activity")}</div>`;
|
|
563
|
-
|
|
564
|
-
const sec = (label, body) => (body ? `<div class="d-sec"><div class="d-label">${esc(label)}</div>${body}</div>` : "");
|
|
565
|
-
const tags = (card.tags || []).length
|
|
566
|
-
? `<div class="d-chips">${card.tags.map((t) => `<span class="d-chip">${esc(t)}</span>`).join("")}</div>`
|
|
567
|
-
: "";
|
|
568
|
-
const skills = (card.skills || []).length
|
|
569
|
-
? `<div class="d-skills">${card.skills
|
|
570
|
-
.map((s) => `<div class="d-skill"><span class="nm">${esc(s.name || s.id)}</span>${s.description ? `<span class="dsc">${esc(s.description)}</span>` : ""}</div>`)
|
|
571
|
-
.join("")}</div>`
|
|
572
|
-
: "";
|
|
573
|
-
// Any other meta beyond the badges (connector → harness, model), generically rendered (escaped, key-sorted).
|
|
574
|
-
const extra = Object.entries(meta).filter(([k]) => k !== "connector" && k !== "model").sort(([a], [b]) => a.localeCompare(b));
|
|
575
|
-
const metaKv = extra.length
|
|
576
|
-
? `<div class="d-kv">${extra
|
|
577
|
-
.map(([k, v]) => `<div class="row"><span class="k">${esc(k)}</span><span class="v">${esc(typeof v === "string" ? v : JSON.stringify(v))}</span></div>`)
|
|
578
|
-
.join("")}</div>`
|
|
579
|
-
: "";
|
|
580
|
-
const proto = card.protocolVersion ? `<span class="d-foot-item">protocol ${esc(card.protocolVersion)}</span>` : "";
|
|
581
|
-
|
|
582
|
-
$("center").innerHTML = `
|
|
583
|
-
<div class="detail${waiting ? " amber" : ""}">
|
|
584
|
-
<div class="d-head">
|
|
585
|
-
<span class="dot ${p.status}">${GLYPH[p.status] ?? "●"}</span>
|
|
586
|
-
<span class="d-status">${esc(waiting ? "WAITING" : p.status)}</span>
|
|
587
|
-
<span class="d-age">${since}</span>
|
|
588
|
-
</div>
|
|
589
|
-
<div class="d-who">${who}</div>
|
|
590
|
-
${badges ? `<div class="d-badges">${badges}</div>` : ""}
|
|
591
|
-
${desc}
|
|
592
|
-
${blocked}
|
|
593
|
-
${sec("Tags", tags)}
|
|
594
|
-
${sec("Skills", skills)}
|
|
595
|
-
${sec("Metadata", metaKv)}
|
|
596
|
-
<div class="d-foot"><span class="d-foot-item id">${esc(card.id)}</span>${proto}</div>
|
|
597
|
-
</div>`;
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
// ── View dispatch ─────────────────────────────────────────────────────────────
|
|
601
|
-
function renderCenter() {
|
|
602
|
-
if (agentSel) return renderAgentDetail();
|
|
603
|
-
if (dmSel) return renderDMThread();
|
|
604
|
-
if (selected === "*") return renderAllActivity();
|
|
605
|
-
return renderChannel();
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
function refreshDerived() {
|
|
609
|
-
const counts = { working: 0, waiting: 0, idle: 0, offline: 0 };
|
|
610
|
-
for (const p of roster) counts[p.status] = (counts[p.status] ?? 0) + 1;
|
|
611
|
-
const waiting = roster.filter((p) => p.status === "waiting");
|
|
612
|
-
const oldest = waiting.length ? agoShort(Math.min(...waiting.map((p) => p.ts))) : "—";
|
|
613
|
-
renderTiles(counts, oldest);
|
|
614
|
-
$("online-c").textContent = roster.filter((p) => p.status !== "offline").length;
|
|
615
|
-
renderRoster(rosterRows()); // online list; offline peers drop out but still ride the header tiles
|
|
616
|
-
renderDMs(); // peer statuses may have changed
|
|
617
|
-
renderRail();
|
|
618
|
-
if (agentSel) renderCenter(); // keep an open Agent Detail live as the peer's status/activity changes
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
let loadSeq = 0;
|
|
622
|
-
async function select(key) {
|
|
623
|
-
agentSel = null;
|
|
624
|
-
dmSel = null;
|
|
625
|
-
selected = key;
|
|
626
|
-
if (key !== "*") unread.set(key, 0);
|
|
627
|
-
renderSidebarNav();
|
|
628
|
-
if (!isDemo) renderRoster(rosterRows()); // clear any stale Agent Detail highlight
|
|
629
|
-
if (isDemo) return (renderCenter(), renderRail());
|
|
630
|
-
if (key !== "*") {
|
|
631
|
-
const seq = ++loadSeq;
|
|
632
|
-
channelMsgs = [];
|
|
633
|
-
renderCenter();
|
|
634
|
-
const msgs = await (await fetch(`/api/channels/${encodeURIComponent(key)}/history?limit=200`)).json();
|
|
635
|
-
if (seq !== loadSeq) return;
|
|
636
|
-
channelMsgs = msgs;
|
|
637
|
-
}
|
|
638
|
-
renderCenter();
|
|
639
|
-
renderRail();
|
|
640
|
-
}
|
|
641
|
-
function selectDM(peer, w) {
|
|
642
|
-
agentSel = null;
|
|
643
|
-
const pe = dmPeers().find((p) => p.name === peer);
|
|
644
|
-
dmSel = { peer, with: w || (pe && pe.conversations[0] ? pe.conversations[0].with : null) };
|
|
645
|
-
selected = null;
|
|
646
|
-
renderSidebarNav();
|
|
647
|
-
if (!isDemo) renderRoster(rosterRows()); // clear any stale Agent Detail highlight
|
|
648
|
-
renderCenter();
|
|
649
|
-
renderRail();
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
async function refresh() {
|
|
653
|
-
roster = await (await fetch("/api/roster")).json();
|
|
654
|
-
refreshDerived();
|
|
655
|
-
const list = await (await fetch("/api/channels")).json();
|
|
656
|
-
channels = new Map(list.map((c) => [c.channel, c.messages]));
|
|
657
|
-
dms = await (await fetch("/api/dms?limit=500")).json();
|
|
658
|
-
renderSidebarNav();
|
|
659
|
-
if (agentSel) {
|
|
660
|
-
renderCenter();
|
|
661
|
-
} else if (dmSel) {
|
|
662
|
-
renderCenter();
|
|
663
|
-
} else if (selected !== "*") {
|
|
664
|
-
select(selected);
|
|
665
|
-
} else {
|
|
666
|
-
activity = await (await fetch("/api/activity?limit=200")).json();
|
|
667
|
-
renderCenter();
|
|
668
|
-
}
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
function onMessage(entry) {
|
|
672
|
-
const { mode, msg } = entry;
|
|
673
|
-
if (!activity.some((e) => e.msg.id === msg.id)) {
|
|
674
|
-
activity.push(entry);
|
|
675
|
-
if (activity.length > 500) activity.shift();
|
|
676
|
-
}
|
|
677
|
-
if (mode === "unicast" && !dms.some((m) => m.id === msg.id)) {
|
|
678
|
-
dms.push(msg);
|
|
679
|
-
renderDMs();
|
|
680
|
-
}
|
|
681
|
-
if (msg.channel) {
|
|
682
|
-
channels.set(msg.channel, (channels.get(msg.channel) ?? 0) + 1);
|
|
683
|
-
if (!dmSel && selected === msg.channel) {
|
|
684
|
-
channelMsgs.push(msg);
|
|
685
|
-
if (channelMsgs.length > 500) channelMsgs.shift();
|
|
686
|
-
} else {
|
|
687
|
-
unread.set(msg.channel, (unread.get(msg.channel) ?? 0) + 1);
|
|
688
|
-
}
|
|
689
|
-
renderChannels();
|
|
690
|
-
}
|
|
691
|
-
if (dmSel ? mode === "unicast" : selected === "*" || selected === msg.channel) renderCenter();
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
function connect() {
|
|
695
|
-
const es = new EventSource("/feed");
|
|
696
|
-
es.addEventListener("open", () => {
|
|
697
|
-
setConn(true);
|
|
698
|
-
refresh();
|
|
699
|
-
});
|
|
700
|
-
es.addEventListener("roster", (e) => {
|
|
701
|
-
roster = JSON.parse(e.data);
|
|
702
|
-
refreshDerived();
|
|
703
|
-
});
|
|
704
|
-
es.addEventListener("message", (e) => onMessage(JSON.parse(e.data)));
|
|
705
|
-
es.addEventListener("error", () => setConn(false));
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
// ── Demo scene (the Penpot reference frames) ──────────────────────────────────
|
|
709
|
-
const ab = [
|
|
710
|
-
{ ts: "10:47", who: "alice", status: "waiting", body: "can you take the API-key wiring while I'm blocked?" },
|
|
711
|
-
{ ts: "10:48", who: "bob", status: "working", body: "on it — grabbing the OPENAI_API_KEY wiring now" },
|
|
712
|
-
{ ts: "10:50", who: "alice", status: "waiting", body: "🙏 thanks — I'll keep drafting the auth outline" },
|
|
713
|
-
];
|
|
714
|
-
const ad = [
|
|
715
|
-
{ ts: "10:42", who: "dave", status: "working", body: "want me to stub the key so you can keep planning?" },
|
|
716
|
-
{ ts: "10:43", who: "alice", status: "waiting", body: "yes please — a no-op stub is perfect for now" },
|
|
717
|
-
];
|
|
718
|
-
const as = [{ ts: "10:40", who: "scout", status: "idle", body: "logged your block in #incidents" }];
|
|
719
|
-
const bd = [
|
|
720
|
-
{ ts: "10:09", who: "bob", status: "working", body: "merged your filter-subjects change" },
|
|
721
|
-
{ ts: "10:10", who: "dave", status: "working", body: "ty — running the suite now" },
|
|
722
|
-
];
|
|
723
|
-
const lm = [{ ts: "10:15", who: "maya", status: "idle", body: "sent the NATS v3 notes your way" }];
|
|
724
|
-
const DEMO = {
|
|
725
|
-
roster: [
|
|
726
|
-
{ name: "alice", role: "planner", status: "waiting", tag: "needs input", act: "blocked — needs OPENAI_API_KEY", harness: "claude" },
|
|
727
|
-
{ name: "linus", role: "reviewer", status: "working", act: "reviewing PR #42 · auth guards", harness: "opencode" },
|
|
728
|
-
{ name: "bob", role: "builder", status: "working", act: "writing tests · channels.ts", harness: "claude" },
|
|
729
|
-
{ name: "dave", role: "builder", status: "working", act: "refactoring endpoint.ts", harness: "hermes" },
|
|
730
|
-
{ name: "maya", role: "researcher", status: "idle", act: "—", harness: "opencode" },
|
|
731
|
-
{ name: "scout", role: "observer", status: "idle", act: "watching #team.>", harness: "claude" },
|
|
732
|
-
],
|
|
733
|
-
activity: [
|
|
734
|
-
{ type: "sys", text: "— scout joined · observer —" },
|
|
735
|
-
{ type: "msg", mode: "chat", ts: "10:38", who: "dave", role: "builder", target: "#general", body: "anyone else hit the flaky CI test on channels.ts?" },
|
|
736
|
-
{ type: "rollup", text: "14 status updates · bob, dave, linus, maya" },
|
|
737
|
-
{ type: "msg", mode: "chat", ts: "10:41", who: "bob", role: "builder", target: "#team.backend", body: "pushed channels.ts tests — 12 green ✓" },
|
|
738
|
-
{ type: "intent", ts: "10:46", who: "linus", note: "about to act", body: "will merge PR #42 once the review check passes" },
|
|
739
|
-
{ type: "msg", mode: "unicast", ts: "10:47", who: "alice", role: "planner", target: "→ bob", body: "can you take the API-key wiring while I'm blocked?" },
|
|
740
|
-
{ type: "msg", mode: "anycast", ts: "10:49", who: "—", target: "→ @reviewer", sub: "unclaimed · 3m", body: "review needed on PR #51 (channels hierarchy)" },
|
|
741
|
-
{ type: "msg", mode: "chat", ts: "10:51", who: "linus", role: "reviewer", target: "#team.review", body: "left 2 comments on PR #42 — small nits" },
|
|
742
|
-
],
|
|
743
|
-
cards: [
|
|
744
|
-
{ tone: "amber", cat: "WAITING", age: "4m", title: "alice is blocked", desc: "Needs OPENAI_API_KEY to keep planning the auth module.", primary: "Provide key", secondary: "Open thread" },
|
|
745
|
-
{ tone: "red", cat: "FAILED", age: "1m", title: "bob's task failed", desc: "2 tests failing in channels.ts after the refactor.", primary: "Inspect", secondary: "Retry" },
|
|
746
|
-
{ tone: "orange", cat: "UNCLAIMED", age: "3m", title: "Anycast request unhandled", desc: "@reviewer · review PR #51 — no peer has claimed it.", primary: "Assign…", secondary: "Claim" },
|
|
747
|
-
{ tone: "blue", cat: "APPROVAL", age: "just now", title: "dave requests approval", desc: "Wants to force-push to main — irreversible.", primary: "Approve", secondary: "Deny" },
|
|
748
|
-
],
|
|
749
|
-
cv: {
|
|
750
|
-
messages: [
|
|
751
|
-
{ ts: "09:58", status: "working", who: "bob", role: "builder", body: "scaffolded the hierarchical channel matcher — wildcard subtree works" },
|
|
752
|
-
{ ts: "10:05", status: "working", who: "dave", role: "builder", body: "endpoint.ts: collapsed the filter subjects, tests pass", thread: "💬 3 replies · last 2m" },
|
|
753
|
-
{ ts: "10:12", status: "idle", who: "maya", role: "researcher", body: "NATS v3 split transports cleanly — notes in #planning" },
|
|
754
|
-
{ type: "unread", text: "new since you were away · 4 messages" },
|
|
755
|
-
{ ts: "10:39", status: "working", who: "dave", role: "builder", body: "anyone seen the flaky CI test on channels.ts?" },
|
|
756
|
-
{ ts: "10:41", status: "working", who: "bob", role: "builder", body: "pushed channels.ts tests — 12 green ✓" },
|
|
757
|
-
{ ts: "10:44", status: "waiting", who: "alice", role: "planner", body: "drafted the auth outline; blocked on the API key though" },
|
|
758
|
-
],
|
|
759
|
-
members: [
|
|
760
|
-
{ status: "working", name: "bob", role: "builder" },
|
|
761
|
-
{ status: "working", name: "dave", role: "builder" },
|
|
762
|
-
{ status: "waiting", name: "alice", role: "planner" },
|
|
763
|
-
{ status: "idle", name: "maya", role: "researcher" },
|
|
764
|
-
{ status: "idle", name: "scout", role: "observer" },
|
|
765
|
-
],
|
|
766
|
-
},
|
|
767
|
-
dmPeers: [
|
|
768
|
-
{ name: "alice", role: "planner", status: "waiting", unread: 2, threads: 3, conversations: [
|
|
769
|
-
{ with: "bob", role: "builder", status: "working", unread: 0, msgs: ab },
|
|
770
|
-
{ with: "dave", role: "builder", status: "working", unread: 1, msgs: ad },
|
|
771
|
-
{ with: "scout", role: "observer", status: "idle", unread: 1, msgs: as },
|
|
772
|
-
] },
|
|
773
|
-
{ name: "bob", role: "builder", status: "working", unread: 0, threads: 2, conversations: [
|
|
774
|
-
{ with: "alice", role: "planner", status: "waiting", unread: 0, msgs: ab },
|
|
775
|
-
{ with: "dave", role: "builder", status: "working", unread: 0, msgs: bd },
|
|
776
|
-
] },
|
|
777
|
-
{ name: "dave", role: "builder", status: "working", unread: 1, threads: 2, conversations: [
|
|
778
|
-
{ with: "alice", role: "planner", status: "waiting", unread: 1, msgs: ad },
|
|
779
|
-
{ with: "bob", role: "builder", status: "working", unread: 0, msgs: bd },
|
|
780
|
-
] },
|
|
781
|
-
{ name: "linus", role: "reviewer", status: "working", unread: 0, threads: 1, conversations: [
|
|
782
|
-
{ with: "maya", role: "researcher", status: "idle", unread: 0, msgs: lm },
|
|
783
|
-
] },
|
|
784
|
-
{ name: "maya", role: "researcher", status: "idle", unread: 0, threads: 1, conversations: [
|
|
785
|
-
{ with: "linus", role: "reviewer", status: "working", unread: 0, msgs: lm },
|
|
786
|
-
] },
|
|
787
|
-
],
|
|
788
|
-
};
|
|
789
|
-
|
|
790
|
-
function renderDemo() {
|
|
791
|
-
$("space").textContent = "· demo";
|
|
792
|
-
setConn(true);
|
|
793
|
-
renderTiles({ working: 4, waiting: 1, idle: 2, offline: 1 }, "6m");
|
|
794
|
-
$("online-c").textContent = "6";
|
|
795
|
-
renderRoster(DEMO.roster);
|
|
796
|
-
// Counts sum to 112 → the "all activity" total matches the reference.
|
|
797
|
-
channels = new Map([
|
|
798
|
-
["general", 24],
|
|
799
|
-
["planning", 12],
|
|
800
|
-
["team.backend", 51],
|
|
801
|
-
["team.frontend", 18],
|
|
802
|
-
["team.review", 7],
|
|
803
|
-
["incidents", 0],
|
|
804
|
-
]);
|
|
805
|
-
unread = new Map([["planning", 2], ["team.review", 1]]);
|
|
806
|
-
renderSidebarNav();
|
|
807
|
-
renderCenter();
|
|
808
|
-
renderRail();
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
if (isDemo) {
|
|
812
|
-
document.title = "Cotal · demo";
|
|
813
|
-
renderDemo();
|
|
814
|
-
} else {
|
|
815
|
-
fetch("/api/meta")
|
|
816
|
-
.then((r) => r.json())
|
|
817
|
-
.then((m) => {
|
|
818
|
-
$("space").textContent = `· ${m.space}`;
|
|
819
|
-
document.title = `Cotal · ${m.space}`;
|
|
820
|
-
});
|
|
821
|
-
refresh();
|
|
822
|
-
connect();
|
|
823
|
-
}
|