omni-notify-mcp 1.3.286 → 1.3.288
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/gateway/chatPage.js +10 -430
- package/dist/gateway/conversations.js +52 -19
- package/dist/gateway/mcpDoor.js +12 -12
- package/dist/gateway/restDoor.js +5 -6
- package/dist/gateway/sessionDriver.js +7 -15
- package/dist/gateway/terminalDoor.js +6 -6
- package/dist/gateway/terminalPage.js +72 -54
- package/dist/shared/webhost/webviewShim.js +10 -2
- package/package.json +1 -1
package/dist/gateway/chatPage.js
CHANGED
|
@@ -42,199 +42,6 @@ export function servingLine() {
|
|
|
42
42
|
const line = `BN ${me.build} · pid ${me.pid} · started ${storedStamp(me.startedAt)}`;
|
|
43
43
|
return `<div id="serving" class="idbar" data-be-tip="The process that answered this page — the build its own code was loaded from, its process id, and when that process started — and when this page's sign-in expires"><span class="srv">${line}</span><small id="expires" hidden></small></div>`;
|
|
44
44
|
}
|
|
45
|
-
/** A reply body drawn as Markdown, node by node: every character of the source reaches the page through
|
|
46
|
-
* `textContent`, so raw HTML in a reply stays literal text and nothing it names can run, load or navigate. A link is
|
|
47
|
-
* an anchor only for http, https and mailto; any other scheme stays the literal source. A table is a real table
|
|
48
|
-
* inside its own sideways scroller, so a wide one never widens the bubble that holds it. Plain text is unchanged:
|
|
49
|
-
* the body keeps `white-space: pre-wrap`, so a reply with no Markdown reads exactly as it did. */
|
|
50
|
-
export function markdownBody(doc, source) {
|
|
51
|
-
const body = doc.createElement("div");
|
|
52
|
-
body.className = "body";
|
|
53
|
-
const text = (parent, value) => { parent.append(doc.createTextNode(value)); };
|
|
54
|
-
const inline = (parent, source) => {
|
|
55
|
-
let at = 0;
|
|
56
|
-
while (at < source.length) {
|
|
57
|
-
const rest = source.slice(at);
|
|
58
|
-
let m = /^`([^`\n]+)`/.exec(rest);
|
|
59
|
-
if (m !== null) {
|
|
60
|
-
const code = doc.createElement("code");
|
|
61
|
-
text(code, m[1]);
|
|
62
|
-
parent.append(code);
|
|
63
|
-
at += m[0].length;
|
|
64
|
-
continue;
|
|
65
|
-
}
|
|
66
|
-
m = /^!\[([^\]\n]*)\]\([^)\s]*\)/.exec(rest);
|
|
67
|
-
if (m !== null) {
|
|
68
|
-
text(parent, m[1]);
|
|
69
|
-
at += m[0].length;
|
|
70
|
-
continue;
|
|
71
|
-
}
|
|
72
|
-
m = /^\[([^\]\n]+)\]\(([^)\s]+)\)/.exec(rest);
|
|
73
|
-
if (m !== null) {
|
|
74
|
-
const target = m[2];
|
|
75
|
-
if (/^(https?:|mailto:)/i.test(target)) {
|
|
76
|
-
const link = doc.createElement("a");
|
|
77
|
-
link.setAttribute("href", target);
|
|
78
|
-
link.setAttribute("target", "_blank");
|
|
79
|
-
link.setAttribute("rel", "noopener noreferrer");
|
|
80
|
-
inline(link, m[1]);
|
|
81
|
-
parent.append(link);
|
|
82
|
-
}
|
|
83
|
-
else
|
|
84
|
-
text(parent, m[0]);
|
|
85
|
-
at += m[0].length;
|
|
86
|
-
continue;
|
|
87
|
-
}
|
|
88
|
-
m = /^(\*\*|__)(?=\S)([\s\S]*?\S)\1/.exec(rest);
|
|
89
|
-
if (m !== null) {
|
|
90
|
-
const strong = doc.createElement("strong");
|
|
91
|
-
inline(strong, m[2]);
|
|
92
|
-
parent.append(strong);
|
|
93
|
-
at += m[0].length;
|
|
94
|
-
continue;
|
|
95
|
-
}
|
|
96
|
-
m = /^(\*|_)(?=\S)([\s\S]*?\S)\1/.exec(rest);
|
|
97
|
-
if (m !== null) {
|
|
98
|
-
const em = doc.createElement("em");
|
|
99
|
-
inline(em, m[2]);
|
|
100
|
-
parent.append(em);
|
|
101
|
-
at += m[0].length;
|
|
102
|
-
continue;
|
|
103
|
-
}
|
|
104
|
-
const stop = rest.slice(1).search(/[`*_!\[]/);
|
|
105
|
-
const take = stop === -1 ? rest.length : stop + 1;
|
|
106
|
-
text(parent, rest.slice(0, take));
|
|
107
|
-
at += take;
|
|
108
|
-
}
|
|
109
|
-
};
|
|
110
|
-
const cells = (line) => {
|
|
111
|
-
let row = line.trim();
|
|
112
|
-
if (row.startsWith("|"))
|
|
113
|
-
row = row.slice(1);
|
|
114
|
-
if (row.endsWith("|"))
|
|
115
|
-
row = row.slice(0, -1);
|
|
116
|
-
return row.split("|").map((cell) => cell.trim());
|
|
117
|
-
};
|
|
118
|
-
const isRow = (line) => line.trim() !== "" && line.indexOf("|") !== -1;
|
|
119
|
-
const isRule = (line) => /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/.test(line);
|
|
120
|
-
const isBlock = (line) => /^\s*(#{1,6}\s|```|>|\||([-*+]|\d+[.)])\s)/.test(line);
|
|
121
|
-
const lines = String(source ?? "").split(/\r?\n/);
|
|
122
|
-
let i = 0;
|
|
123
|
-
while (i < lines.length) {
|
|
124
|
-
const line = lines[i];
|
|
125
|
-
if (line.trim() === "") {
|
|
126
|
-
i++;
|
|
127
|
-
continue;
|
|
128
|
-
}
|
|
129
|
-
if (/^\s*```/.test(line)) {
|
|
130
|
-
const fenced = [];
|
|
131
|
-
i++;
|
|
132
|
-
while (i < lines.length && !/^\s*```/.test(lines[i])) {
|
|
133
|
-
fenced.push(lines[i]);
|
|
134
|
-
i++;
|
|
135
|
-
}
|
|
136
|
-
i++;
|
|
137
|
-
const pre = doc.createElement("pre");
|
|
138
|
-
const code = doc.createElement("code");
|
|
139
|
-
text(code, fenced.join("\n"));
|
|
140
|
-
pre.append(code);
|
|
141
|
-
body.append(pre);
|
|
142
|
-
continue;
|
|
143
|
-
}
|
|
144
|
-
if (isRow(line) && i + 1 < lines.length && isRule(lines[i + 1])) {
|
|
145
|
-
const head = doc.createElement("thead");
|
|
146
|
-
const headRow = doc.createElement("tr");
|
|
147
|
-
cells(line).forEach((cell) => { const th = doc.createElement("th"); inline(th, cell); headRow.append(th); });
|
|
148
|
-
head.append(headRow);
|
|
149
|
-
const rows = doc.createElement("tbody");
|
|
150
|
-
i += 2;
|
|
151
|
-
while (i < lines.length && isRow(lines[i])) {
|
|
152
|
-
const tr = doc.createElement("tr");
|
|
153
|
-
cells(lines[i]).forEach((cell) => { const td = doc.createElement("td"); inline(td, cell); tr.append(td); });
|
|
154
|
-
rows.append(tr);
|
|
155
|
-
i++;
|
|
156
|
-
}
|
|
157
|
-
const table = doc.createElement("table");
|
|
158
|
-
table.append(head, rows);
|
|
159
|
-
const box = doc.createElement("div");
|
|
160
|
-
box.className = "tbl";
|
|
161
|
-
box.append(table);
|
|
162
|
-
body.append(box);
|
|
163
|
-
continue;
|
|
164
|
-
}
|
|
165
|
-
const heading = /^\s*(#{1,6})\s+(.*)$/.exec(line);
|
|
166
|
-
if (heading !== null) {
|
|
167
|
-
const h = doc.createElement("h" + Math.min(heading[1].length + 2, 6));
|
|
168
|
-
inline(h, heading[2].replace(/\s+#+\s*$/, ""));
|
|
169
|
-
body.append(h);
|
|
170
|
-
i++;
|
|
171
|
-
continue;
|
|
172
|
-
}
|
|
173
|
-
if (/^\s*([-*_])\s*(\1\s*){2,}$/.test(line)) {
|
|
174
|
-
body.append(doc.createElement("hr"));
|
|
175
|
-
i++;
|
|
176
|
-
continue;
|
|
177
|
-
}
|
|
178
|
-
if (/^\s*>/.test(line)) {
|
|
179
|
-
const quoted = [];
|
|
180
|
-
while (i < lines.length && /^\s*>/.test(lines[i])) {
|
|
181
|
-
quoted.push(lines[i].replace(/^\s*>\s?/, ""));
|
|
182
|
-
i++;
|
|
183
|
-
}
|
|
184
|
-
const quote = doc.createElement("blockquote");
|
|
185
|
-
markdownBodyInto(doc, quote, quoted.join("\n"));
|
|
186
|
-
body.append(quote);
|
|
187
|
-
continue;
|
|
188
|
-
}
|
|
189
|
-
if (/^\s*([-*+]|\d+[.)])\s+/.test(line)) {
|
|
190
|
-
const items = [];
|
|
191
|
-
while (i < lines.length && (/^\s*([-*+]|\d+[.)])\s+/.test(lines[i]) || (items.length > 0 && /^\s{2,}\S/.test(lines[i]) && lines[i].trim() !== ""))) {
|
|
192
|
-
const item = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(lines[i]);
|
|
193
|
-
if (item === null) {
|
|
194
|
-
items[items.length - 1].source += " " + lines[i].trim();
|
|
195
|
-
i++;
|
|
196
|
-
continue;
|
|
197
|
-
}
|
|
198
|
-
items.push({ depth: Math.floor(item[1].length / 2), ordered: /\d/.test(item[2]), source: item[3] });
|
|
199
|
-
i++;
|
|
200
|
-
}
|
|
201
|
-
const lists = [];
|
|
202
|
-
const containerFor = (depth, ordered) => {
|
|
203
|
-
while (lists.length > 0 && lists[lists.length - 1].depth > depth)
|
|
204
|
-
lists.pop();
|
|
205
|
-
if (lists.length === 0 || lists[lists.length - 1].depth < depth) {
|
|
206
|
-
const made = doc.createElement(ordered ? "ol" : "ul");
|
|
207
|
-
if (lists.length === 0)
|
|
208
|
-
body.append(made);
|
|
209
|
-
else
|
|
210
|
-
lists[lists.length - 1].node.lastElementChild.appendChild(made);
|
|
211
|
-
lists.push({ depth, node: made });
|
|
212
|
-
}
|
|
213
|
-
return lists[lists.length - 1].node;
|
|
214
|
-
};
|
|
215
|
-
items.forEach((item) => {
|
|
216
|
-
const list = containerFor(item.depth, item.ordered);
|
|
217
|
-
const li = doc.createElement("li");
|
|
218
|
-
inline(li, item.source);
|
|
219
|
-
list.append(li);
|
|
220
|
-
});
|
|
221
|
-
continue;
|
|
222
|
-
}
|
|
223
|
-
const paragraph = [];
|
|
224
|
-
while (i < lines.length && lines[i].trim() !== "" && (paragraph.length === 0 || !isBlock(lines[i]))) {
|
|
225
|
-
paragraph.push(lines[i]);
|
|
226
|
-
i++;
|
|
227
|
-
}
|
|
228
|
-
const para = doc.createElement("p");
|
|
229
|
-
inline(para, paragraph.join("\n"));
|
|
230
|
-
body.append(para);
|
|
231
|
-
}
|
|
232
|
-
return body;
|
|
233
|
-
}
|
|
234
|
-
/** The same drawing for a nested block (a blockquote's own paragraphs and lists), appending into `into`. */
|
|
235
|
-
export function markdownBodyInto(doc, into, source) {
|
|
236
|
-
markdownBody(doc, source).childNodes.forEach((child) => into.append(child));
|
|
237
|
-
}
|
|
238
45
|
/** The look every page of the door shares (#2873): BN's page and each service page the public gateway serves. */
|
|
239
46
|
export const PAGE_STYLE = `:root{--bg:#fff;--fg:#111;--mut:#667;--line:#d8dce2;--me:#0b5fff;--agent:#eef1f5;--bad:#a40000}
|
|
240
47
|
@media(prefers-color-scheme:dark){:root{--bg:#14171c;--fg:#e8eaed;--mut:#9aa3b0;--line:#2b313b;--agent:#222831;--me:#2f6df0;--bad:#ff8a80}}
|
|
@@ -382,13 +189,13 @@ async function resumeSignIn() {
|
|
|
382
189
|
}
|
|
383
190
|
function startSignIn() { if (token) resumeSignIn().catch(function (err) { signIn("Could not check sign-in: " + err.message); }); else signIn(""); }`;
|
|
384
191
|
/**
|
|
385
|
-
* The conversations half of BN's one page: the list he manages
|
|
386
|
-
*
|
|
387
|
-
*
|
|
388
|
-
*
|
|
389
|
-
*
|
|
390
|
-
*
|
|
391
|
-
* `
|
|
192
|
+
* The conversations half of BN's one page: the list he manages — the table, its per-row actions and the project picker.
|
|
193
|
+
* It is carried from the deleted Conversations page (#3401) into the native CLI page because that capability lives
|
|
194
|
+
* nowhere else. The transcript view is NOT carried and is gone (#3640): every conversation now opens on the provider's
|
|
195
|
+
* own full interactive CLI, so what was the record's reading view is the terminal itself. Its ONE creation control (the
|
|
196
|
+
* New form and `POST /v1/conversations`) is NOT carried either: the native CLI page's New Agent is the one way to
|
|
197
|
+
* create an agent. The functions here rely on the page's shared `el`, `api`, `base`, `main`, `show` and `storedStamp`,
|
|
198
|
+
* provided by the page's own script, and on the header buttons it builds (`btnBack`, `btnShelf`).
|
|
392
199
|
*/
|
|
393
200
|
export const CONVERSATIONS_VIEWS = `
|
|
394
201
|
/** The one way this page puts text on the clipboard, calling back with what happened. */
|
|
@@ -685,7 +492,7 @@ async function listView(shelf) {
|
|
|
685
492
|
var actrow = el("div", "actrow");
|
|
686
493
|
var acts = el("div", "acts");
|
|
687
494
|
var open = el("button", "", "📂 Open");
|
|
688
|
-
open.onclick = function (e) { e.stopPropagation(); go("
|
|
495
|
+
open.onclick = function (e) { e.stopPropagation(); go("term", node.c.id); };
|
|
689
496
|
acts.append(open);
|
|
690
497
|
// A turn conversation is STOPPED — its agent runs no further turn and a later message resumes it. A console
|
|
691
498
|
// session's own CLI is CLOSED — that process ends and the record stays as history. Never the same label.
|
|
@@ -713,8 +520,8 @@ async function listView(shelf) {
|
|
|
713
520
|
tr.append(cell);
|
|
714
521
|
node.cells.push({ td: cell, col: col });
|
|
715
522
|
});
|
|
716
|
-
tr.onclick = function (e) { if (e.shiftKey) { e.preventDefault(); extendRange(node); return; } go("
|
|
717
|
-
tr.onkeydown = function (e) { if (e.target === tr && (e.key === "Enter" || e.key === " ")) { e.preventDefault(); go("
|
|
523
|
+
tr.onclick = function (e) { if (e.shiftKey) { e.preventDefault(); extendRange(node); return; } go("term", node.c.id); };
|
|
524
|
+
tr.onkeydown = function (e) { if (e.target === tr && (e.key === "Enter" || e.key === " ")) { e.preventDefault(); go("term", node.c.id); } };
|
|
718
525
|
return node;
|
|
719
526
|
};
|
|
720
527
|
var updateNode = function (node, c) {
|
|
@@ -919,233 +726,6 @@ async function listView(shelf) {
|
|
|
919
726
|
window.addEventListener("resize", listResize);
|
|
920
727
|
run();
|
|
921
728
|
}
|
|
922
|
-
|
|
923
|
-
function chatView(meta, why) {
|
|
924
|
-
var mine = show("chat", meta.project ? base(meta.project) : "No project");
|
|
925
|
-
var shelved = meta.shelf === "archived";
|
|
926
|
-
var badge = el("span", "badge", "");
|
|
927
|
-
var banner = el("div", "rowmeta", "");
|
|
928
|
-
var facts = el("div", "convmeta");
|
|
929
|
-
facts.append(idLine(meta.id), timeLine(meta), el("div", "rowmeta", agentLine(meta)), el("span", "origin", originOf(meta)), badge, banner);
|
|
930
|
-
main.append(facts);
|
|
931
|
-
var after = 0;
|
|
932
|
-
text.value = ""; text.style.height = "";
|
|
933
|
-
var closed = meta.state === "closed";
|
|
934
|
-
var following = false;
|
|
935
|
-
// ONE console: the stored record and the turn still arriving are the same surface, so the only thing that marks a
|
|
936
|
-
// streamed line is that it is provisional — it is taken back the moment the turn's own line lands in the record.
|
|
937
|
-
var clearLive = function () { Array.prototype.forEach.call(main.querySelectorAll(".line.tail"), function (row) { row.remove(); }); };
|
|
938
|
-
var liveTail = function (kind, who, text) {
|
|
939
|
-
var row = el("div", "line tail " + kind);
|
|
940
|
-
row.append(el("span", "stamp", ""), el("span", "who", who), el("span", "say", ""));
|
|
941
|
-
row.lastElementChild.textContent = text;
|
|
942
|
-
main.append(row);
|
|
943
|
-
main.scrollTop = main.scrollHeight;
|
|
944
|
-
return row;
|
|
945
|
-
};
|
|
946
|
-
// The agent's own words and its own reasoning, the two told apart, exactly as the stream names them (#3611), each
|
|
947
|
-
// written into the one console as it arrives (#3613) — deltas of the same kind run on into the line already open.
|
|
948
|
-
var liveText = function (kind, text) {
|
|
949
|
-
if (shelved || closed) return;
|
|
950
|
-
|
|
951
|
-
var last = main.lastElementChild;
|
|
952
|
-
var open = last !== null && last.className === "line tail " + kind ? last.lastElementChild : null;
|
|
953
|
-
if (open === null) open = liveTail(kind, kind === "thinking" ? "…" : "agent", "").lastElementChild;
|
|
954
|
-
open.textContent += text;
|
|
955
|
-
if (kind === "thinking") open.parentNode.className = "line tail thinking";
|
|
956
|
-
main.scrollTop = main.scrollHeight;
|
|
957
|
-
};
|
|
958
|
-
var liveDoing = function (text) {
|
|
959
|
-
if (shelved || closed) return;
|
|
960
|
-
|
|
961
|
-
var last = main.lastElementChild;
|
|
962
|
-
if (last !== null && last.className === "line tail doing" && last.lastElementChild.textContent === text) return;
|
|
963
|
-
liveTail("doing", "⚙", text);
|
|
964
|
-
};
|
|
965
|
-
var badged = function (state) {
|
|
966
|
-
var running = !shelved && state !== "closed";
|
|
967
|
-
badge.className = "badge " + (running ? "live" : "history");
|
|
968
|
-
badge.textContent = running ? "🟢 LIVE" : "⏸ HISTORY";
|
|
969
|
-
badge.title = running ? "A CLI on this machine answers messages sent here" : "Nothing is running for this conversation — this is its stored record";
|
|
970
|
-
banner.textContent = running
|
|
971
|
-
? "A CLI on this machine is behind this conversation. What it is thinking, what it is writing and the tool it is on are written into this console as they arrive; each turn then stays here as its own stored line. This is the conversation's console, not the CLI's own terminal screen."
|
|
972
|
-
: "Read-only replay of this conversation's stored record: what was sent, what the agent answered and what BN reported. No CLI is running for it and its terminal screen was not kept, so nothing here is live — a message sent from the box below resumes it.";
|
|
973
|
-
// Why this conversation is on its record rather than on a CLI of its own: the refusal is said here, ON this
|
|
974
|
-
// conversation, in the CLI's own absence — never a pane that looks the same whether the CLI is there or not (#3620).
|
|
975
|
-
if (why !== undefined && why !== "") banner.textContent = why + " " + banner.textContent;
|
|
976
|
-
if (!running) clearLive();
|
|
977
|
-
};
|
|
978
|
-
var sync = function (state) { closed = state === "closed"; btnStop.disabled = closed; text.placeholder = !closed ? "Message" : shelved ? "Archived - send a message to resume it" : "Stopped - send a message to resume"; badged(state); if (!following && !shelved && !closed) { following = true; follow(); } };
|
|
979
|
-
var stick = function () { main.scrollTop = main.scrollHeight; };
|
|
980
|
-
var notice = el("div", "err");
|
|
981
|
-
var asked = {};
|
|
982
|
-
var ENDS = { answer: 1, failed: 1, ask: 1 };
|
|
983
|
-
// The turn is over — its own line is in the record, so the lines the stream had only provisionally written are given back.
|
|
984
|
-
var ENDED = { answer: 1, failed: 1, stopped: 1 };
|
|
985
|
-
var took = function (ms) {
|
|
986
|
-
var s = Math.max(0, Math.round(ms / 1000)), m = Math.floor(s / 60), h = Math.floor(m / 60);
|
|
987
|
-
return h > 0 ? h + "h " + (m % 60) + "m " + (s % 60) + "s" : m > 0 ? m + "m " + (s % 60) + "s" : s + "s";
|
|
988
|
-
};
|
|
989
|
-
var queuedText = function (t, show, why) {
|
|
990
|
-
return t.queuedMs !== undefined ? show(t.queuedMs) + " queued" + (why || "") : t.queued === false ? "not queued" : "queue not measured";
|
|
991
|
-
};
|
|
992
|
-
var activeText = function (t, show, why) {
|
|
993
|
-
return t.agentMs !== undefined ? show(t.agentMs) + " active" + (why || "") : "agent time not measured";
|
|
994
|
-
};
|
|
995
|
-
var add = function (event, got) {
|
|
996
|
-
var role = event.role === "user" ? "user" : event.role === "agent" ? "agent" : "system";
|
|
997
|
-
var who = role === "user" ? "›" : role === "agent" ? "⏺" : "·";
|
|
998
|
-
var when = storedStamp(event.ts);
|
|
999
|
-
if (role === "user") asked[event.n] = event.ts;
|
|
1000
|
-
else if (ENDS[event.kind] && asked[event.turn] !== undefined) {
|
|
1001
|
-
var t = event.timing, wall = took(Date.parse(event.ts) - Date.parse(asked[event.turn]));
|
|
1002
|
-
var endToEnd = t === undefined ? wall + " end to end (no layer timing stored for this one: queue and agent time are not measured)"
|
|
1003
|
-
: t.bnMs !== undefined ? took(t.bnMs) + " end to end"
|
|
1004
|
-
: wall + " end to end (the door's arrival was not measured)";
|
|
1005
|
-
when += " - " + endToEnd + (t === undefined ? "" : " · " + queuedText(t, took) + " · " + activeText(t, took));
|
|
1006
|
-
}
|
|
1007
|
-
var row = el("div", "line " + role);
|
|
1008
|
-
row.append(el("span", "stamp", when), el("span", "who", who), el("span", "say", event.text));
|
|
1009
|
-
main.append(row);
|
|
1010
|
-
if (event.timing || (role === "agent" && ENDS[event.kind])) timed(row, event, got);
|
|
1011
|
-
after = Math.max(after, event.n);
|
|
1012
|
-
};
|
|
1013
|
-
var ms = function (n) { return Math.round(n).toLocaleString() + " ms"; };
|
|
1014
|
-
var timed = function (box, event, got) {
|
|
1015
|
-
var t = event.timing || {}, s = sent[meta.id + ":" + event.turn];
|
|
1016
|
-
var tm = el("details", "tm"), head = el("summary"), wrap = el("div", "box"), table = el("table");
|
|
1017
|
-
var line = function (layer, value, cls) {
|
|
1018
|
-
var tr = el("tr", cls || "");
|
|
1019
|
-
tr.append(el("td", "", layer), el("td", "ms", value === undefined ? "unmeasured" : ms(value)));
|
|
1020
|
-
table.append(tr);
|
|
1021
|
-
};
|
|
1022
|
-
var traced = event.trace ? " · trace " + event.trace : "";
|
|
1023
|
-
if (typeof t.totalMs !== "number") {
|
|
1024
|
-
head.textContent = "⏱ no measured timing for this response";
|
|
1025
|
-
tm.append(head);
|
|
1026
|
-
box.append(tm);
|
|
1027
|
-
return;
|
|
1028
|
-
}
|
|
1029
|
-
var bn = t.bnMs;
|
|
1030
|
-
var hold = got.servedAt - Date.parse(event.ts);
|
|
1031
|
-
var summary = (bn === undefined
|
|
1032
|
-
? "⏱ end to end not measured for this turn: its door measured no arrival to count from"
|
|
1033
|
-
: "⏱ " + ms(bn) + " end to end (request arrived → answer stored)")
|
|
1034
|
-
+ " · " + queuedText(t, ms, " (message stored → turn started)")
|
|
1035
|
-
+ " · " + activeText(t, ms, " (the agent CLI's own share)")
|
|
1036
|
-
+ traced;
|
|
1037
|
-
if (bn !== undefined) line("BN: request arrived → answer stored (bnMs)", bn, "sum");
|
|
1038
|
-
t.rows.forEach(function (r) { line(r.layer, r.ms, r.part ? "part" : ""); });
|
|
1039
|
-
if (s) line("BN: answer stored → served to this page's read", hold);
|
|
1040
|
-
line("BN's own total, message stored → answer stored (inside the end to end above, never the agent's time)", t.totalMs, "sum");
|
|
1041
|
-
var copyNote = el("span", "copynote");
|
|
1042
|
-
var copy = el("button", "copy", "Copy");
|
|
1043
|
-
copy.onclick = function (e) {
|
|
1044
|
-
e.preventDefault(); e.stopPropagation();
|
|
1045
|
-
var lines = [head.textContent || ""];
|
|
1046
|
-
table.querySelectorAll("tr").forEach(function (tr) {
|
|
1047
|
-
var cells = tr.querySelectorAll("td");
|
|
1048
|
-
lines.push(cells[0].textContent + "\\t" + cells[1].textContent);
|
|
1049
|
-
});
|
|
1050
|
-
copyText(lines.join("\\n"),
|
|
1051
|
-
function () { copyNote.textContent = "Copied"; setTimeout(function () { copyNote.textContent = ""; }, 1500); },
|
|
1052
|
-
function () { copyNote.textContent = "Copy failed — the rows are above to select by hand"; });
|
|
1053
|
-
};
|
|
1054
|
-
var bar = el("div", "tmbar"); bar.append(copyNote, copy);
|
|
1055
|
-
tm.append(head, wrap); wrap.append(bar, table); box.append(tm);
|
|
1056
|
-
head.textContent = summary;
|
|
1057
|
-
if (!s) {
|
|
1058
|
-
line("page and network: not measured — this message was not sent from this page view", undefined);
|
|
1059
|
-
return;
|
|
1060
|
-
}
|
|
1061
|
-
requestAnimationFrame(function () {
|
|
1062
|
-
var shown = performance.now(), render = shown - got.at, total = shown - s.clickAt;
|
|
1063
|
-
line("page: answer received → shown", render);
|
|
1064
|
-
line("↳ outside BN: this page → BN → this page — UNMEASURED (measured by neither end, and never worked out by subtracting BN's figures from this page's total)", undefined, "part");
|
|
1065
|
-
line("total, Send pressed → answer shown — this page's own clock, BN's rows above inside it", total, "sum");
|
|
1066
|
-
head.textContent = summary + " · this page " + ms(total) + " from Send to shown";
|
|
1067
|
-
});
|
|
1068
|
-
};
|
|
1069
|
-
var poll = async function () {
|
|
1070
|
-
while (mine === view) {
|
|
1071
|
-
try {
|
|
1072
|
-
var data = await api("GET", "/v1/conversations/" + meta.id + "/events?after=" + after + "&wait=25");
|
|
1073
|
-
if (mine !== view) return;
|
|
1074
|
-
var got = { at: performance.now(), servedAt: data.servedAt };
|
|
1075
|
-
notice.remove();
|
|
1076
|
-
data.events.forEach(function (event) { add(event, got); });
|
|
1077
|
-
if (data.events.length > 0) stick();
|
|
1078
|
-
sync(data.state);
|
|
1079
|
-
} catch (err) {
|
|
1080
|
-
if (err.message === "signed out") return;
|
|
1081
|
-
if (mine === view) { notice.textContent = err.message; main.append(notice); stick(); }
|
|
1082
|
-
await new Promise(function (r) { setTimeout(r, 4000); });
|
|
1083
|
-
}
|
|
1084
|
-
}
|
|
1085
|
-
};
|
|
1086
|
-
/**
|
|
1087
|
-
* The live stream of this conversation (#3611): the agent's own reasoning, its own words and the tool it is on, as they
|
|
1088
|
-
* arrive, read from the conversation's own stream on the same door. A turn frame that ends or pauses the turn means its
|
|
1089
|
-
* record line is written, so the live box empties and the record takes over; a turn's own start and its "still working"
|
|
1090
|
-
* progress do not, or the reasoning already on screen would be wiped mid-turn. A drop mid-stream re-attaches from the
|
|
1091
|
-
* last frame read, so nothing is shown twice and nothing is missed, and a first read that failed is retried by the next
|
|
1092
|
-
* pass of the events poll rather than giving the view up.
|
|
1093
|
-
*/
|
|
1094
|
-
var follow = async function () {
|
|
1095
|
-
var at = null;
|
|
1096
|
-
for (;;) {
|
|
1097
|
-
if (mine !== view) return;
|
|
1098
|
-
var control = new AbortController();
|
|
1099
|
-
stopStream = function () { control.abort(); };
|
|
1100
|
-
try {
|
|
1101
|
-
var response = await fetch("/v1/conversations/" + meta.id + "/stream" + (at === null ? "" : "?after=" + at), { headers: { authorization: "Bearer " + token }, signal: control.signal });
|
|
1102
|
-
if (response.status === 401) { signIn("Sign in again to reach your CLI."); return; }
|
|
1103
|
-
if (!response.ok || response.body === null) throw Error("the gateway answered " + response.status);
|
|
1104
|
-
notice.remove();
|
|
1105
|
-
var reader = response.body.getReader(), decoder = new TextDecoder(), buffer = "";
|
|
1106
|
-
for (;;) {
|
|
1107
|
-
var chunk = await reader.read();
|
|
1108
|
-
if (chunk.done) { await new Promise(function (r) { setTimeout(r, 1000); }); break; }
|
|
1109
|
-
if (mine !== view) { reader.cancel(); return; }
|
|
1110
|
-
buffer += decoder.decode(chunk.value, { stream: true });
|
|
1111
|
-
var lines = buffer.split("\\n");
|
|
1112
|
-
buffer = lines.pop();
|
|
1113
|
-
lines.forEach(function (line) {
|
|
1114
|
-
if (line.indexOf("data: ") !== 0) return;
|
|
1115
|
-
var frame = JSON.parse(line.slice(6));
|
|
1116
|
-
at = frame.seq;
|
|
1117
|
-
if (frame.kind === "turn") { if (ENDED[frame.event]) clearLive(); }
|
|
1118
|
-
else if (frame.kind === "doing") liveDoing(frame.text);
|
|
1119
|
-
else liveText(frame.kind, frame.text);
|
|
1120
|
-
});
|
|
1121
|
-
}
|
|
1122
|
-
} catch (err) {
|
|
1123
|
-
if (mine !== view || control.signal.aborted) return;
|
|
1124
|
-
// OWNER: this view's own feed of the agent's stream — a failed read is emptied and restarted here, never left to reach the record or any other view.
|
|
1125
|
-
if (!(err instanceof Error)) throw err;
|
|
1126
|
-
clearLive();
|
|
1127
|
-
if (at === null) {
|
|
1128
|
-
notice.textContent = "This conversation's live stream could not be read — " + err.message + ". Its record below is unaffected and the stream is tried again.";
|
|
1129
|
-
if (!notice.isConnected) main.append(notice);
|
|
1130
|
-
following = false;
|
|
1131
|
-
return;
|
|
1132
|
-
}
|
|
1133
|
-
await new Promise(function (r) { setTimeout(r, 3000); });
|
|
1134
|
-
}
|
|
1135
|
-
}
|
|
1136
|
-
};
|
|
1137
|
-
sync(meta.state);
|
|
1138
|
-
btnStop.onclick = async function () { btnStop.disabled = true; try { var stopped = await api("DELETE", "/v1/conversations/" + meta.id); if (mine === view) sync(stopped.state); } catch (err) { btnStop.disabled = false; main.append(el("div", "err", err.message)); } };
|
|
1139
|
-
btnSend.onclick = async function () {
|
|
1140
|
-
var body = text.value;
|
|
1141
|
-
if (!body.trim()) return;
|
|
1142
|
-
btnSend.disabled = true;
|
|
1143
|
-
try { var clickAt = performance.now(); var posted = await api("POST", "/v1/conversations/" + meta.id + "/messages", { text: body }); sent[meta.id + ":" + posted.n] = { clickAt: clickAt }; text.value = ""; text.style.height = ""; shelved = false; if (closed && mine === view) sync("idle"); }
|
|
1144
|
-
catch (err) { main.append(el("div", "err", err.message)); }
|
|
1145
|
-
btnSend.disabled = false;
|
|
1146
|
-
};
|
|
1147
|
-
poll();
|
|
1148
|
-
}
|
|
1149
729
|
`;
|
|
1150
730
|
/** What a service page adds to the shared look: one card per VS Code window, with its screens. */
|
|
1151
731
|
const SERVICE_STYLE = `.win{border:1px solid var(--line);border-radius:10px;margin:0 0 8px;padding:10px}
|
|
@@ -460,6 +460,32 @@ export function resolveProject(project, text = "") {
|
|
|
460
460
|
throw new GatewayError(400, `the request names no project and the home project ${home} under ${workspaceRoot()} holds no BACKLOG.md; name a project (${(names.length > 0 ? names : allowedProjects()).join(", ")}) or set NOTIFY_AGENT_HOME_PROJECT`);
|
|
461
461
|
return join(workspaceRoot(), chosen);
|
|
462
462
|
}
|
|
463
|
+
/**
|
|
464
|
+
* The workspace a session is CREATED in, from the name its creation supplied (#3640): exactly one of the allowed project
|
|
465
|
+
* names, resolved to its folder. A creation that names none is refused here by name with what to send — nothing is ever
|
|
466
|
+
* minted on a folder the caller did not choose, and no request's own text stands in for it.
|
|
467
|
+
*/
|
|
468
|
+
export function namedProject(project) {
|
|
469
|
+
if (typeof project !== "string" || project.trim() === "")
|
|
470
|
+
throw new GatewayError(400, `a new session needs the workspace it runs in: send project as exactly one of ${allowedProjects().join(", ")}`);
|
|
471
|
+
return projectFolder(project);
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* The folder a session is CREATED in, proved to be one of the allowed projects' folders: a creation that supplies no
|
|
475
|
+
* folder at all is refused by name, exactly as one that supplies a wrong one is (#3640) — this is the record's own
|
|
476
|
+
* guard, so no door can mint a record on a folder nobody chose.
|
|
477
|
+
*/
|
|
478
|
+
export function createdFolder(project) {
|
|
479
|
+
if (typeof project !== "string" || project.trim() === "")
|
|
480
|
+
throw new GatewayError(400, `a new session needs the workspace it runs in: send project as exactly one of ${allowedProjects().join(", ")}`);
|
|
481
|
+
return allowedFolder(project);
|
|
482
|
+
}
|
|
483
|
+
/** The agent CLI a session is CREATED on, from the name its creation supplied (#3640): nothing is minted on a provider the caller never named. */
|
|
484
|
+
export function namedProvider(provider) {
|
|
485
|
+
if (typeof provider !== "string" || providerFor(provider) === undefined)
|
|
486
|
+
throw new GatewayError(400, `a new session needs the agent CLI it runs: send provider as exactly one of ${PROVIDER_NAMES.join(", ")}`);
|
|
487
|
+
return provider;
|
|
488
|
+
}
|
|
463
489
|
/** Every project folder under the workspace root, by name, with whether it is a Git repository. */
|
|
464
490
|
export function listProjects() {
|
|
465
491
|
const root = workspaceRoot();
|
|
@@ -472,7 +498,7 @@ export function listProjects() {
|
|
|
472
498
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
473
499
|
return { root, projects };
|
|
474
500
|
}
|
|
475
|
-
/** The record every new conversation starts from: one id, one unstarted session of its own, no turn run and nothing spent.
|
|
501
|
+
/** The record every new conversation starts from: one id, one unstarted session of its own, no turn run and nothing spent. Its workspace and its agent CLI are required and are bound HERE, in the one write that mints it (#3640): a record cannot exist without either, so no door can leave one to be filled in later. A console session carries its mode, its owner and its notice here too, so the record is never on disk describing itself as a turn conversation the session driver would read its events for. */
|
|
476
502
|
function newConversation(input) {
|
|
477
503
|
mkdirSync(agentDir(), { recursive: true });
|
|
478
504
|
const now = new Date().toISOString();
|
|
@@ -508,30 +534,35 @@ export function openInteractiveConversation(project, provider, model, owner) {
|
|
|
508
534
|
/** Every door measures its own wait: `receivedMs` is the first half of the turn's `bnMs`, so a door that had none could not name BN's whole time. */
|
|
509
535
|
export function startConversation(input) {
|
|
510
536
|
requireConversationEnabled();
|
|
511
|
-
const project =
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
const meta = newConversation({ platform: input.platform, technical: input.technical, chat: input.chat, slack: input.slack, email: input.email, project, provider: input.provider, model: input.model, state: "queued" });
|
|
537
|
+
const project = createdFolder(input.project);
|
|
538
|
+
const provider = namedProvider(input.provider);
|
|
539
|
+
const meta = newConversation({ platform: input.platform, technical: input.technical, chat: input.chat, slack: input.slack, email: input.email, project, provider, model: input.model, state: "queued" });
|
|
515
540
|
appendEvent(meta.id, { role: "user", text: input.text, receivedMs: input.receivedMs, ...input.arrival });
|
|
516
541
|
return meta;
|
|
517
542
|
}
|
|
518
543
|
/**
|
|
519
|
-
* Opens a conversation with nothing in it: no message
|
|
520
|
-
*
|
|
521
|
-
*
|
|
544
|
+
* Opens a conversation with nothing in it: no message and no agent work (#2723). Nothing is asked, so no user event is
|
|
545
|
+
* stored and no process is started, and opening one costs nothing. Its workspace — the folder, resolved from the name
|
|
546
|
+
* the call supplied — and its agent CLI are named BY THE CALL that opens it (#3640), so it is never an empty
|
|
547
|
+
* conversation waiting for either; it keeps its id and its whole transcript across a later switch of the folder.
|
|
522
548
|
*/
|
|
523
549
|
export function openConversation(input) {
|
|
524
550
|
requireConversationEnabled();
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
return newConversation({ platform: input.platform, technical: input.technical, chat: input.chat, project, provider: input.provider, model: input.model, state: "idle" });
|
|
551
|
+
const project = createdFolder(input.project);
|
|
552
|
+
const provider = namedProvider(input.provider);
|
|
553
|
+
return newConversation({ platform: input.platform, technical: input.technical, chat: input.chat, project, provider, model: input.model, state: "idle" });
|
|
529
554
|
}
|
|
530
555
|
/**
|
|
531
|
-
* Sets or switches the folder the conversation's agent works in (#2723)
|
|
532
|
-
*
|
|
533
|
-
*
|
|
534
|
-
*
|
|
556
|
+
* Sets or switches the folder the conversation's agent works in (#2723), reached by every door that offers it: the Web
|
|
557
|
+
* UI's per-row picker and the agent's own `agent_project`. THE AGENT IS NOT REPLACED. A session is resumed by its id from
|
|
558
|
+
* any folder — measured: a session written in one folder resumed from another recalled its earlier turn and worked in the
|
|
559
|
+
* new folder — so the record keeps its session id and the next turn carries the same agent on in the new folder with its
|
|
560
|
+
* whole history. The provider process is not moved either: its folder is fixed at its start, so the driver ends it and
|
|
561
|
+
* opens a new one where the record now points, exactly as it does for a resume after an idle spell.
|
|
562
|
+
*
|
|
563
|
+
* A change is refused at two safe boundaries, both because a running process is never moved under itself: while a turn is
|
|
564
|
+
* in flight, and while a CLI of the browser's own holds the conversation — that CLI runs in a folder of its own and no
|
|
565
|
+
* door can move it.
|
|
535
566
|
*/
|
|
536
567
|
export function setConversationProject(id, project) {
|
|
537
568
|
requireConversationEnabled();
|
|
@@ -543,13 +574,15 @@ export function setConversationProject(id, project) {
|
|
|
543
574
|
// NULL-OK: field turn is OPTIONAL and therefore it is ok to check for NULL
|
|
544
575
|
if (turn !== undefined)
|
|
545
576
|
throw new GatewayError(409, `turn ${turn.turn} is ${turn.started ? "running" : "waiting to start"} since ${clockOf(turn.startedAt)}, so the project cannot change until it ends — a running process is never moved under itself`);
|
|
577
|
+
if (cliOwns(meta))
|
|
578
|
+
throw new GatewayError(409, "a CLI is running this conversation's own session, and a running CLI cannot be moved to another folder — close the CLI, change the project, then open it again");
|
|
546
579
|
const folder = projectFolder(project);
|
|
547
580
|
// NULL-OK: field project is OPTIONAL and therefore it is ok to check for NULL
|
|
548
581
|
if (meta.project !== undefined && sameFolder(meta.project, folder))
|
|
549
582
|
return meta;
|
|
550
|
-
// The record is written first: the folder the next turn runs in comes from it, so a transcript line may never claim a switch the record does not hold.
|
|
551
|
-
const switched = updateConversation(id, { project: folder,
|
|
552
|
-
appendEvent(id, { role: "system", kind: "status", text: `📁 Project set to ${basename(folder)} — the next turn
|
|
583
|
+
// The record is written first: the folder the next turn runs in comes from it, so a transcript line may never claim a switch the record does not hold. `providerArchived` is cleared because a later finish must confirm the provider's archive again rather than trust a handshake from before this work.
|
|
584
|
+
const switched = updateConversation(id, { project: folder, providerArchived: undefined });
|
|
585
|
+
appendEvent(id, { role: "system", kind: "status", text: `📁 Project set to ${basename(folder)} — the next turn carries this same agent on in that folder.` });
|
|
553
586
|
return switched;
|
|
554
587
|
}
|
|
555
588
|
/** Appends the message; a stopped conversation is resumed first, so its next turn picks the agent's own session up where it stopped. A message stored while a turn runs gets its place in line at once, so nobody waits behind it in silence. `receivedMs` is the door's own measured wait, as in `startConversation`. */
|
package/dist/gateway/mcpDoor.js
CHANGED
|
@@ -17,8 +17,8 @@ import { performance } from "perf_hooks";
|
|
|
17
17
|
import { z } from "zod";
|
|
18
18
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
19
19
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
20
|
-
import { GatewayError, MAX_WAIT_SECONDS, archiveConversation, deleteConversation, describeConversation, lastEventNumber, listProjects, openConversation, readConversation, routeAsk, requireConversationEnabled,
|
|
21
|
-
import {
|
|
20
|
+
import { GatewayError, MAX_WAIT_SECONDS, archiveConversation, deleteConversation, describeConversation, lastEventNumber, listProjects, namedProject, namedProvider, openConversation, readConversation, routeAsk, requireConversationEnabled, sendToConversation, setConversationProject, shelvedConversations, stampWaitReturn, startConversation, stopConversation, unarchiveConversation, waitForEvents, waitForTurn, workspaceRoot, allowedProjects, } from "./conversations.js";
|
|
21
|
+
import { PROVIDER_NAMES } from "./providers.js";
|
|
22
22
|
import { iconsFor } from "./iconDoor.js";
|
|
23
23
|
import { grantClient, publicBase } from "./oauthDoor.js";
|
|
24
24
|
import { bearer, personId, sessionSignIn, tokenProves } from "./passkeyDoor.js";
|
|
@@ -41,8 +41,8 @@ async function act(run) {
|
|
|
41
41
|
}
|
|
42
42
|
const INSTRUCTIONS = `BullseyeNotify (BN) runs a Claude Code agent on this machine, with access to the user's files, git repositories, shell, MCP servers, skills and rules.
|
|
43
43
|
agent_prompt sends the user's request to that agent and returns its answer. It covers questions about the user's projects, files, code, builds and servers.
|
|
44
|
-
A
|
|
45
|
-
agent_open opens
|
|
44
|
+
A NEW conversation names the folder it works in and the agent CLI it runs, in the same call that creates it: project and provider are both required, and a call that leaves either out is refused with what to send — nothing is ever started in a folder or on a CLI the caller did not choose. project is exactly one of ${allowedProjects().join(", ")}, under the workspace root (${workspaceRoot()}), never a path, and agent_projects lists them with their paths; provider is exactly one of ${PROVIDER_NAMES.join(", ")}. A call that goes on with a conversation that already exists needs neither, because both were bound when it was created.
|
|
45
|
+
agent_open opens a conversation with no message and starts nothing: it names its project and provider as above, spends nothing and returns the id of a session to ask with agent_send and read with agent_wait. The folder of an existing conversation is switched with agent_project, which never starts a new agent and never makes a new conversation.
|
|
46
46
|
An agent works on the project it runs in: its own folder is the board it writes to, so work belonging to another project is never done from this agent — tell the user in one line which project the work belongs to, then start that project's own agent: agent_start with project naming the project and text carrying the request (agent_prompt with project for a quick answer), read its answer with agent_wait and relay it.
|
|
47
47
|
A request that changes a project is a Story, and the Story belongs on that project's own board: the agent of that project — this conversation's own agent when the work is this project's, the agent this conversation starts with agent_start as above when it belongs to another project — files it or joins it with the bullseye-backlog MCP server that project's folder declares, before any work is done on it. Once a Story is on the board the normal BB → FleetView workflow owns its execution and dispatches the subagents that run it, so BN stays free for the user's questions and follows the Story's progress on the board. A short read-only question or a status check is answered directly. Every agent respects the shared claim store, the one claims file this machine's doors write, so no Story is executed twice: a message naming a Story claims that Story in its conversation's own name for the length of its turn — a read-only question or a status check included — and a message whose Story another session already claims fails that turn in the claims file's own words, naming the holder. The long task in the example below goes the same way: agent_start and agent_wait are how such a request is sent and read, and the Story it becomes is filed and run as above.
|
|
48
48
|
Each agent_prompt goes on with the same agent as the previous one from this connection, also when a different project is named; new=true starts a fresh agent. With no conversation named, BN also starts a fresh agent by itself when the newest agent is busy, is not waiting for an answer to a question it asked, and the ask does not point at the work already under way — so the ask never waits behind that turn. An ask that does point at the work under way (a follow-up, "are you done?", "stop") goes to the agent that is doing that work, even when a later independent ask opened another agent. A stopped conversation resumes on its next message.
|
|
@@ -133,8 +133,8 @@ function buildServer(caller, base) {
|
|
|
133
133
|
text: z.string().min(1).describe("The user's request, as the user wrote it"),
|
|
134
134
|
conversation: z.string().min(1).optional().describe("A 'conversation' id an earlier agent_prompt returned, to address that agent; without it the call goes on with this connection's current agent"),
|
|
135
135
|
new: z.boolean().optional().describe("true starts a fresh agent instead of going on with the current one; with a conversation named it is ignored, because that conversation is addressed as usual"),
|
|
136
|
-
project: z.enum(allowedProjects()).optional().describe(`The
|
|
137
|
-
provider: z.string().min(1).optional(),
|
|
136
|
+
project: z.enum(allowedProjects()).optional().describe(`The workspace of a NEW conversation: exactly one of ${allowedProjects().join(" or ")}, resolved under ${workspaceRoot()}; no path is accepted. Required whenever this call creates one — no conversation named and no agent open to go on with, or new=true — and never taken from the request text`),
|
|
137
|
+
provider: z.string().min(1).optional().describe(`The agent CLI a NEW conversation runs: exactly one of ${PROVIDER_NAMES.join(", ")}. Required whenever this call creates one, beside project`),
|
|
138
138
|
model: z.string().min(1).optional(),
|
|
139
139
|
}, ({ text, conversation, new: fresh, project, provider, model }) => act(async () => {
|
|
140
140
|
const call = randomUUID().slice(0, 8);
|
|
@@ -155,7 +155,7 @@ function buildServer(caller, base) {
|
|
|
155
155
|
}
|
|
156
156
|
// NULL-OK: field target is OPTIONAL and therefore it is ok to check for NULL
|
|
157
157
|
const sent = target === undefined
|
|
158
|
-
? { id: startConversation({ platform: "mcp", technical: true, chat: caller, project:
|
|
158
|
+
? { id: startConversation({ platform: "mcp", technical: true, chat: caller, project: namedProject(project), provider: namedProvider(provider), model, text, receivedMs: Math.round(performance.now() - arrived), arrival }).id, turn: 1 }
|
|
159
159
|
: { id: target, turn: sendToConversation(target, text, Math.round(performance.now() - arrived), arrival).n };
|
|
160
160
|
const { events, ended, turn } = await waitForTurn(sent.id, sent.turn, MAX_WAIT_SECONDS);
|
|
161
161
|
const meta = readConversation(sent.id);
|
|
@@ -183,14 +183,14 @@ function buildServer(caller, base) {
|
|
|
183
183
|
return { ...head, working: true, waitedMs, events: others, after, next: `call agent_wait with id="${sent.id}", after=${after}, wait=${MAX_WAIT_SECONDS} until an event of kind answer, ask, failed or stopped arrives` };
|
|
184
184
|
}));
|
|
185
185
|
server.tool("agent_projects", `The project folders on this machine (the folders under ${workspaceRoot()}), each with its absolute path and whether it is a git repository.`, {}, () => act(() => listProjects()));
|
|
186
|
-
server.tool("agent_start", `Creates a new conversation AND submits text as its first task (turn 1) in one call, and returns {id, state} at once without waiting for the agent. The way to run a long or autonomous task: read its progress and answer with agent_wait {id, after:1, wait:${MAX_WAIT_SECONDS}}, again with the after each reply returned, until an event of kind answer, ask or failed with turn 1, or of kind stopped, arrives. The task is already submitted, so do not also agent_send the same text, and do not call agent_start again for it — each call starts another agent on the task. agent_prompt sends and waits in one blocking call instead.`, { project: z.enum(allowedProjects()).optional().describe(`
|
|
186
|
+
server.tool("agent_start", `Creates a new conversation AND submits text as its first task (turn 1) in one call, and returns {id, state} at once without waiting for the agent. The way to run a long or autonomous task: read its progress and answer with agent_wait {id, after:1, wait:${MAX_WAIT_SECONDS}}, again with the after each reply returned, until an event of kind answer, ask or failed with turn 1, or of kind stopped, arrives. The task is already submitted, so do not also agent_send the same text, and do not call agent_start again for it — each call starts another agent on the task. agent_prompt sends and waits in one blocking call instead.`, { project: z.enum(allowedProjects()).optional().describe(`The workspace this conversation runs in: exactly one of ${allowedProjects().join(" or ")}, resolved under ${workspaceRoot()}; no path is accepted. Required here, because this call creates the conversation — it is never resolved from the text`), text: z.string().min(1).describe("The first task, submitted by this call itself"), provider: z.string().min(1).optional().describe(`The agent CLI this conversation runs: exactly one of ${PROVIDER_NAMES.join(", ")}. Required here, beside project`), model: z.string().min(1).optional() }, ({ project, text, provider, model }) => act(() => {
|
|
187
187
|
const arrived = performance.now();
|
|
188
188
|
const arrival = arrivalOf(requestIdOf(), randomUUID().slice(0, 8));
|
|
189
|
-
const meta = startConversation({ platform: "mcp", technical: true, chat: caller, project:
|
|
189
|
+
const meta = startConversation({ platform: "mcp", technical: true, chat: caller, project: namedProject(project), provider: namedProvider(provider), model, text, receivedMs: Math.round(performance.now() - arrived), arrival });
|
|
190
190
|
return { id: meta.id, state: meta.state };
|
|
191
191
|
}));
|
|
192
|
-
server.tool("agent_open", `Opens a new
|
|
193
|
-
const meta = openConversation({ platform: "mcp", technical: true, chat: caller, project, provider: provider
|
|
192
|
+
server.tool("agent_open", `Opens a new conversation with no message and starts no agent: no text, no process started and nothing spent. It returns {id, state} at once. It names the workspace and the agent CLI the conversation opens on — project and provider are required here, because a conversation is never minted on either without the caller choosing it. Ask it with agent_send and read it with agent_wait; the folder can be switched afterwards with agent_project, and the conversation keeps its id and its whole transcript.`, { project: z.enum(allowedProjects()).optional().describe(`Exactly one of ${allowedProjects().join(" or ")}, resolved under ${workspaceRoot()}; no path is accepted. Required here, because this call creates the conversation — it is never resolved from anything else`), provider: z.string().min(1).optional().describe(`The agent CLI the conversation runs: exactly one of ${PROVIDER_NAMES.join(", ")}. Required here, beside project`), model: z.string().min(1).optional() }, ({ project, provider, model }) => act(() => {
|
|
193
|
+
const meta = openConversation({ platform: "mcp", technical: true, chat: caller, project: namedProject(project), provider: namedProvider(provider), model });
|
|
194
194
|
return { id: meta.id, state: meta.state };
|
|
195
195
|
}));
|
|
196
196
|
server.tool("agent_send", `Sends a message to a conversation that already exists (agent_start or agent_prompt made it) and returns {n} at once without waiting for the agent. n is the message's position in the log and the number of the turn it starts: read its answer with agent_wait {id, after:n, wait:${MAX_WAIT_SECONDS}}. A message sent while a turn runs is queued behind that turn, not an interrupt; one sent after the agent asked a question is the answer to it, and that turn goes on under the asking turn's number. A stopped or archived conversation resumes. Each call submits its text once more, so do not repeat a send that returned.`, { id: z.string().min(1).describe("The conversation id agent_start returned, or the 'conversation' agent_prompt returned"), text: z.string().min(1).describe("The follow-up message; the first task belongs in agent_start") }, ({ id, text }) => act(async () => {
|
|
@@ -236,7 +236,7 @@ function buildServer(caller, base) {
|
|
|
236
236
|
}
|
|
237
237
|
}).then((result) => { row(outcome ?? `refused why=${result.content[0]?.text ?? "the call was refused"}`); return result; }, (err) => { row(outcome ?? `refused why=${err instanceof Error ? err.message : String(err)}`); throw err; });
|
|
238
238
|
});
|
|
239
|
-
server.tool("agent_project", `Sets or switches the project folder a conversation's agent works in — before its first message, or between any two turns, also after work in another project. The next turn's agent runs in that folder and writes to its board, while the conversation's id
|
|
239
|
+
server.tool("agent_project", `Sets or switches the project folder a conversation's agent works in — before its first message, or between any two turns, also after work in another project. The next turn's agent runs in that folder and writes to its board, while the conversation's id, its whole transcript and the agent itself stay: the same session carries on in the new folder with its earlier turns in hand, so this NEVER starts a new agent and never makes a new conversation. A switch is refused at two safe boundaries — while a turn is running, named with the running turn's number, and while a browser CLI is running the conversation's own session, because a running process is never moved under itself. Either refusal leaves the record on its old project.`, { id: z.string().min(1).describe("The conversation id agent_open or agent_start returned, or the 'conversation' agent_prompt returned"), project: z.enum(allowedProjects()).describe(`Exactly one of ${allowedProjects().join(" or ")}, resolved under ${workspaceRoot()}; no path is accepted`) }, ({ id, project }) => act(() => setConversationProject(id, project)));
|
|
240
240
|
server.tool("agent_status", "One conversation's project (absent until one is chosen), provider, model and state, with its title (the name its session carries in Claude's session list), its lifecycle (active while a turn runs, is queued or waits on the user's answer; idle when open with no turn; completed once stopped or archived) and beside (the conversation of the same connection it was opened next to, if any).", { id: z.string().min(1) }, ({ id }) => act(() => describeConversation(readConversation(id))));
|
|
241
241
|
server.tool("agent_stream", `Where to attach to a conversation's LIVE stream: the agent's own thinking and its output AS THEY ARRIVE, not only the finished reply that agent_prompt and agent_wait carry. It returns {conversation, url, after, console?}. Attach to url with a plain HTTP GET; the answer is a Server-Sent Events stream, one 'data: <frame>' line per frame, and a frame is {seq, ts, kind, text, turn} where kind is thinking (the agent's reasoning), output (the agent's own words), doing (the tool the turn is on) or turn (a start, status, question, answer or failure line). Reconnect with ?after=<seq> of the last frame you read and you are handed every frame past it: nothing you already received is repeated and nothing new is missed. It is a read — it starts no agent, spends nothing and changes nothing — and it is the live feed of the agent that agent_start or agent_send is running. When the id names a CLI console session (a full CLI opened in the browser and mirrored at /terminal), the url is that SAME session's console output at /v1/terminals/<id>/console (frame {seq, ts, kind:"output", text, reset}), and console is true: agent_send and agent_prompt type into that live CLI and no second CLI is created.`, { id: z.string().min(1).describe("The conversation id agent_start, agent_prompt or agent_open returned"), after: z.number().int().min(0).optional().describe("The last frame seq you already have; the stream resumes past it") }, ({ id, after }) => act(() => {
|
|
242
242
|
// A CLI console session (one opened in the browser) streams the CLI's OWN output (#3434) — the SAME session, not a
|
package/dist/gateway/restDoor.js
CHANGED
|
@@ -9,14 +9,13 @@
|
|
|
9
9
|
* once a passkey is enrolled the gateway token is honoured only on a request from this machine.
|
|
10
10
|
*/
|
|
11
11
|
import { createServer } from "http";
|
|
12
|
-
import { GatewayError, archiveConversation, deleteConversation, listProjects, monotonicMs, openConversation, readConversation, requireConversationEnabled,
|
|
12
|
+
import { GatewayError, archiveConversation, deleteConversation, listProjects, monotonicMs, namedProject, namedProvider, openConversation, readConversation, requireConversationEnabled, sendToConversation, setConversationProject, shelvedConversations, startConversation, stopConversation, unarchiveConversation, waitForEvents, } from "./conversations.js";
|
|
13
13
|
import { CHAT_HEADERS } from "./chatPage.js";
|
|
14
14
|
import { isIconPath, serveIcon } from "./iconDoor.js";
|
|
15
15
|
import { handleMcp } from "./mcpDoor.js";
|
|
16
16
|
import { MCP_PATH, accessGranted, challenge, handleOAuth, isOAuthPath, publicBase } from "./oauthDoor.js";
|
|
17
17
|
import { bearer, bearerAdmitted, handlePasskey, isPasskeyPath, localOnly, personId } from "./passkeyDoor.js";
|
|
18
18
|
import { agentStreamFor } from "./agentStream.js";
|
|
19
|
-
import { DEFAULT_PROVIDER } from "./provider.js";
|
|
20
19
|
import { terminalConsole, terminalRoute, terminalSockets } from "./terminalDoor.js";
|
|
21
20
|
import { conversationSwitchRead, panelState, saveConversationSwitch, saveSetting } from "./panel.js";
|
|
22
21
|
import { PANEL_PAGES, panelHtml } from "./panelPage.js";
|
|
@@ -136,16 +135,16 @@ async function route(req, url, arrivedAt) {
|
|
|
136
135
|
return { status: 200, body: { conversations: shelvedConversations(shelfOf(url)) } };
|
|
137
136
|
if (method === "POST") {
|
|
138
137
|
const body = await readJson(req);
|
|
139
|
-
const provider =
|
|
138
|
+
const provider = namedProvider(body.provider);
|
|
140
139
|
const model = typeof body.model === "string" && body.model.length > 0 ? body.model : undefined;
|
|
141
140
|
const receivedMs = monotonicMs() - arrivedAt;
|
|
142
|
-
const project =
|
|
141
|
+
const project = namedProject(body.project);
|
|
143
142
|
// A text that is left out opens an empty conversation (#2723); one that is sent must carry something, so a blank text is still refused.
|
|
144
143
|
const text = body.text === undefined ? undefined : requiredText(body, "text");
|
|
145
|
-
//
|
|
144
|
+
// The workspace and the agent CLI are named by this call and bound by it (#3640): neither is resolved from the text and neither has a default.
|
|
146
145
|
const meta = text === undefined
|
|
147
146
|
? openConversation({ platform: "rest", project, provider, model })
|
|
148
|
-
: startConversation({ platform: "rest", project
|
|
147
|
+
: startConversation({ platform: "rest", project, provider, model, text, receivedMs });
|
|
149
148
|
return { status: 201, body: { id: meta.id, state: meta.state, receivedMs } };
|
|
150
149
|
}
|
|
151
150
|
}
|
|
@@ -27,7 +27,7 @@ import { DEFAULT_CAPACITY } from "../shared/agentSession.js";
|
|
|
27
27
|
import { AgentTurnFailed } from "./provider.js";
|
|
28
28
|
import { PROVIDER_NAMES, providerFor } from "./providers.js";
|
|
29
29
|
import { agentStream, dropAgentStream } from "./agentStream.js";
|
|
30
|
-
import { GatewayError, agentDir, appendEvent, archiveConversation, conversationRefusal, eventsSize, listConversations, monotonicMs, purgeConversation, allowedFolder, isAllowedFolder, readConversation, readEvents, reopenConversation,
|
|
30
|
+
import { GatewayError, agentDir, appendEvent, archiveConversation, conversationRefusal, eventsSize, listConversations, monotonicMs, purgeConversation, allowedFolder, isAllowedFolder, readConversation, readEvents, reopenConversation, titleOf, turnInFlight, hasBoard, resumedInLog, shelfInLog, storeChanged, stopConversation, stoppedInLog, updateConversation, } from "./conversations.js";
|
|
31
31
|
// GATE: Meni's ruling 4A — a conversation silent this long frees its process; its next message resumes the session.
|
|
32
32
|
export const IDLE_MS = 30 * 60_000;
|
|
33
33
|
// GATE: Meni's ruling 6A — a turn that has said nothing for this long posts one status line, and another after each such spell.
|
|
@@ -631,10 +631,12 @@ export class SessionDriver {
|
|
|
631
631
|
// NULL-OK: field next is OPTIONAL and therefore it is ok to check for NULL
|
|
632
632
|
if (next === undefined)
|
|
633
633
|
return;
|
|
634
|
-
//
|
|
635
|
-
let project
|
|
634
|
+
// The record's own folder is the folder the turn runs in (#3640): it was bound by the call that created the conversation, so nothing is resolved from the message and nothing is defaulted here. A record left without one — one minted before the workspace became mandatory — fails its own turn and is consumed, so the next beat takes the next message.
|
|
635
|
+
let project;
|
|
636
636
|
try {
|
|
637
|
-
|
|
637
|
+
if (c.project === undefined)
|
|
638
|
+
throw new GatewayError(409, "this conversation has no workspace, and a session runs in the folder it was created with — set one with agent_project, then send the message again, or start a new session naming project and provider");
|
|
639
|
+
project = allowedFolder(c.project);
|
|
638
640
|
}
|
|
639
641
|
catch (err) {
|
|
640
642
|
if (!(err instanceof GatewayError))
|
|
@@ -694,16 +696,6 @@ export class SessionDriver {
|
|
|
694
696
|
this.driving.delete(id);
|
|
695
697
|
}
|
|
696
698
|
}
|
|
697
|
-
/**
|
|
698
|
-
* The folder a message to a conversation opened with no project runs in, stored on the record before its turn starts:
|
|
699
|
-
* the project the message names, else the home project. A refusal it throws is answered by the loop that asked with the
|
|
700
|
-
* message consumed, so nothing sits in the log waiting for ever.
|
|
701
|
-
*/
|
|
702
|
-
projectFor(c, message) {
|
|
703
|
-
const project = resolveProject(undefined, message.text);
|
|
704
|
-
updateConversation(c.id, { project });
|
|
705
|
-
return project;
|
|
706
|
-
}
|
|
707
699
|
/**
|
|
708
700
|
* The hold this conversation takes on the story its message is about, or none when the message names no story: the story is read
|
|
709
701
|
* from the message's own text the same way its project is, and held in this conversation's own name so every door names it as the holder.
|
|
@@ -743,7 +735,7 @@ export class SessionDriver {
|
|
|
743
735
|
process.stderr.write(`[agent-gateway] the hold of ${conversationId} on #${hold.storyId} could not be released: ${err.message}\n`);
|
|
744
736
|
}
|
|
745
737
|
}
|
|
746
|
-
/** The session's process, started when it has none: a free slot, else the slot of the longest-idle session, else a place in line. A held process started in another folder is ended first —
|
|
738
|
+
/** The session's process, started when it has none: a free slot, else the slot of the longest-idle session, else a place in line. A held process started in another folder is ended first — a process's folder cannot change — and the next one runs there on the SAME session id, so the same agent carries on in the folder the record now points at. */
|
|
747
739
|
slot(c, turn, provider, project) {
|
|
748
740
|
const own = this.lives.get(c.id);
|
|
749
741
|
if (own !== undefined) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { WebSocketServer, WebSocket } from "ws";
|
|
2
|
-
import { GatewayError, INTERACTIVE_ONLY, allowedFolder, listConversations, readConversation, updateConversation, requireConversationEnabled
|
|
2
|
+
import { GatewayError, INTERACTIVE_ONLY, allowedFolder, listConversations, namedProject, namedProvider, readConversation, updateConversation, requireConversationEnabled } from "./conversations.js";
|
|
3
3
|
import { attachNativeTerminal, closeNativeTerminal, nativeTerminal, nativeTerminals, openNativeTerminal } from "./nativeTerminal.js";
|
|
4
4
|
import { bearer, bearerAdmitted, personId, sessionExpiresAt } from "./passkeyDoor.js";
|
|
5
5
|
import { PROVIDER_NAMES, providerFor } from "./providers.js";
|
|
@@ -188,7 +188,7 @@ export async function terminalRoute(req, url, readJson) {
|
|
|
188
188
|
throw new GatewayError(409, `This conversation's agent is "${meta.provider}", which this gateway does not run, so no CLI can be opened on it.`);
|
|
189
189
|
// NULL-OK: field project is OPTIONAL and therefore it is ok to check for NULL
|
|
190
190
|
if (meta.project === undefined)
|
|
191
|
-
throw new GatewayError(409, "This conversation has no
|
|
191
|
+
throw new GatewayError(409, "This conversation has no workspace: it was made before one was required, and a CLI runs in a folder. Set one with agent_project, then open the CLI.");
|
|
192
192
|
const terminal = attachNativeTerminal(owner, { provider: attachedProvider, project: allowedFolder(meta.project), model: meta.model, conversation: meta.id, ...dimensions(body) });
|
|
193
193
|
const refusal = await terminal.started;
|
|
194
194
|
if (refusal !== undefined) {
|
|
@@ -197,10 +197,10 @@ export async function terminalRoute(req, url, readJson) {
|
|
|
197
197
|
}
|
|
198
198
|
return { status: 201, body: terminal.info() };
|
|
199
199
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
const project =
|
|
200
|
+
// A New Agent session names BOTH: which CLI runs, and the folder it runs in (#3640). Neither is defaulted and
|
|
201
|
+
// neither is taken from anything else the request carries, so a form that posts an unset select is refused here.
|
|
202
|
+
const provider = providerFor(namedProvider(body.provider));
|
|
203
|
+
const project = namedProject(body.project);
|
|
204
204
|
const model = typeof body.model === "string" && body.model.trim() ? body.model.trim() : undefined;
|
|
205
205
|
if (model !== undefined && !/^[a-zA-Z0-9._:/-]+$/.test(model))
|
|
206
206
|
throw new GatewayError(400, "Model must be its CLI name, without spaces or shell characters.");
|
|
@@ -11,11 +11,12 @@ function asset(name, installed) {
|
|
|
11
11
|
return readFileSync(existsSync(packaged) ? fileURLToPath(packaged) : require.resolve(installed), "utf8");
|
|
12
12
|
}
|
|
13
13
|
/**
|
|
14
|
-
* BN's ONE page: the Conversations list and the
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
14
|
+
* BN's ONE page: the Conversations list and the terminal each conversation opens in. Every conversation opens on the
|
|
15
|
+
* provider's own full interactive CLI — its slash commands, prompts and menus answered by the real CLI — whether MCP or
|
|
16
|
+
* this page started the session, and the list labels it by where it came from. There is no record view and no message
|
|
17
|
+
* box: a conversation's record is not terminal output, and MCP reaches the SAME CLI by the same id. ▶ New Agent is the
|
|
18
|
+
* one control that starts a CLI agent. Browser history drives the views: each view is a history entry, Back pops it, and
|
|
19
|
+
* Conversations unwinds to the list at the root entry.
|
|
19
20
|
*/
|
|
20
21
|
export function terminalHtml() {
|
|
21
22
|
const js = asset("xterm.js", "@xterm/xterm/lib/xterm.js").replace(/<\/script/gi, "<\\/script");
|
|
@@ -23,7 +24,9 @@ export function terminalHtml() {
|
|
|
23
24
|
const css = asset("xterm.css", "@xterm/xterm/css/xterm.css");
|
|
24
25
|
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>BN native CLI</title>${ICON_HEAD}<style>${PAGE_STYLE}
|
|
25
26
|
${css}
|
|
26
|
-
body{height:100dvh;margin:0;display:flex;flex-direction:column}header{flex-wrap:wrap}main{flex:1;min-height:0;padding:12px;overflow:hidden}main.scroll{overflow-y:auto}#terminal{height:100%;width:100%}.setup{display:flex;gap:8px;flex-wrap:wrap}.setup select,.setup input{font:inherit;padding:8px}
|
|
27
|
+
body{height:100dvh;margin:0;display:flex;flex-direction:column}header{flex-wrap:wrap}main{flex:1;min-height:0;padding:12px;overflow:hidden}main.scroll{overflow-y:auto}#terminal{height:100%;width:100%}.setup{display:flex;gap:8px;flex-wrap:wrap}.setup select,.setup input{font:inherit;padding:8px}
|
|
28
|
+
/* The one control that moves the view: the way back to the newest output after reading up through the scrollback. */
|
|
29
|
+
button.latest{border-color:#3a4150;background:#1b202a;color:#d6dbe6}button.latest[hidden]{display:none}#status{color:var(--mut);padding:4px 12px;font-size:13px;overflow-wrap:anywhere}a{color:inherit}.origin{font-size:12px;border:1px solid var(--line);border-radius:4px;padding:0 6px;margin-left:6px;white-space:nowrap}main.cli-style{background:#101217;color:#d6dbe6;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}main.cli-style .origin{border-color:#3a4150;color:#d6dbe6}
|
|
27
30
|
/* The console is one surface: a line is text on the console's own background, never a banded card, whatever class the page's own stylesheet gives a message. */
|
|
28
31
|
main.cli-style .line{background:none;border:0;border-radius:0;margin:0;max-width:none;padding:1px 0;color:inherit}
|
|
29
32
|
main.cli-style .convmeta{border-color:#2a303c}main.cli-style .copyid{background:#1b202a;border-color:#3a4150;color:#d6dbe6}main.cli-style .err{color:#ff9a8f}
|
|
@@ -46,14 +49,13 @@ main.cli-style .badge.history{color:#c3cad6;border-color:#3a4150;background:tran
|
|
|
46
49
|
.line .tm table{border-collapse:collapse;width:100%}
|
|
47
50
|
.line .tm td{padding:1px 4px;border-top:1px solid #2a303c;vertical-align:top;overflow-wrap:anywhere}
|
|
48
51
|
main.cli-style p.system{color:var(--mut)}
|
|
49
|
-
</style></head><body><header><button id="back" hidden>Back</button><h1 id="title">Conversations</h1><a href="/settings" style="color:inherit">⚙️ Settings</a><a href="/public-access" style="color:inherit">🌐 Public access</a><button id="newagent" hidden>+ New Agent</button><button id="convs" hidden title="Back to the live Conversations list">📋 Conversations</button><button id="shelf" hidden title="Show the archived conversations">🗂️ Archived</button><button id="
|
|
52
|
+
</style></head><body><header><button id="back" hidden>Back</button><h1 id="title">Conversations</h1><a href="/settings" style="color:inherit">⚙️ Settings</a><a href="/public-access" style="color:inherit">🌐 Public access</a><button id="newagent" hidden>+ New Agent</button><button id="convs" hidden title="Back to the live Conversations list">📋 Conversations</button><button id="shelf" hidden title="Show the archived conversations">🗂️ Archived</button><button id="logout" hidden>🚪 Logout</button></header>
|
|
50
53
|
${servingLine()}<div id="status" role="status" hidden></div><main id="main"></main>
|
|
51
|
-
<footer id="bar" hidden><textarea id="text" placeholder="Message"></textarea><button id="send" class="go">Send</button></footer>
|
|
52
54
|
<script>${js}
|
|
53
55
|
${fit}</script><script>${PASSKEY_SCRIPT}
|
|
54
56
|
${SIGN_IN_SCRIPT}
|
|
55
|
-
var main=document.getElementById("main"),note=document.getElementById("status"),title=document.getElementById("title")
|
|
56
|
-
var
|
|
57
|
+
var main=document.getElementById("main"),note=document.getElementById("status"),title=document.getElementById("title");
|
|
58
|
+
var btnBack=document.getElementById("back"),btnShelf=document.getElementById("shelf");
|
|
57
59
|
var btnNew=document.getElementById("newagent"),btnConvs=document.getElementById("convs");
|
|
58
60
|
var view=0,sent={},listResize=null,terminal,fitAddon,socket,reconnect,observer,arrival=null,stopStream=null;
|
|
59
61
|
var AGENTS = ${JSON.stringify(PROVIDER_CHOICES)};
|
|
@@ -64,7 +66,7 @@ var carried=/(?:^#|&)token=([^&]+)/.exec(location.hash);
|
|
|
64
66
|
if(carried){token=decodeURIComponent(carried[1]);keep(token);history.replaceState(history.state,"",location.pathname+location.search);}
|
|
65
67
|
// A link may name the view to open on arrival: /terminal?view=term&id=<id> or ?view=chat&id=<id>.
|
|
66
68
|
var deepLink=new URLSearchParams(location.search);
|
|
67
|
-
if(
|
|
69
|
+
if(deepLink.get("view")&&deepLink.get("id")){arrival={view:deepLink.get("view"),id:deepLink.get("id")};}
|
|
68
70
|
|
|
69
71
|
function el(tag,cls,content){var node=document.createElement(tag);if(cls)node.className=cls;if(content!==undefined)node.textContent=content;return node;}
|
|
70
72
|
|
|
@@ -82,13 +84,11 @@ function show(name,heading){
|
|
|
82
84
|
btnConvs.hidden=name==="signin"||name==="list";
|
|
83
85
|
// The shelf control is shown only where it changes the shelf; on the archived shelf it would be a button that does nothing.
|
|
84
86
|
btnShelf.hidden=name!=="list";
|
|
85
|
-
btnStop.hidden=name!=="chat";
|
|
86
|
-
bar.hidden=name!=="chat";
|
|
87
87
|
note.hidden=name!=="terminal"&&name!=="new";
|
|
88
88
|
btnLogout.hidden=name==="signin";
|
|
89
89
|
expiryNotice.hidden=name==="signin";
|
|
90
90
|
main.classList.toggle("scroll",name!=="terminal");
|
|
91
|
-
main.classList.toggle("cli-style",name==="
|
|
91
|
+
main.classList.toggle("cli-style",name==="terminal"||name==="gone");
|
|
92
92
|
document.body.classList.toggle("wide",inList);
|
|
93
93
|
main.replaceChildren();
|
|
94
94
|
return view;
|
|
@@ -125,31 +125,28 @@ function render(state){
|
|
|
125
125
|
if(state.view==="new")return newAgentView();
|
|
126
126
|
return openRecordView(state.view,state.id);
|
|
127
127
|
}
|
|
128
|
-
/** Opens a conversation in its
|
|
128
|
+
/** Opens a conversation in its terminal — the ONLY session view there is. There is no record view and no message box:
|
|
129
|
+
* a session reached from this page is the provider's own full interactive CLI, the same experience as running Claude,
|
|
130
|
+
* Codex or Copilot in a local terminal, with its slash commands, its prompts and its menus answered by the real CLI. */
|
|
129
131
|
async function openRecordView(name,id){
|
|
130
132
|
var mine=view;
|
|
131
133
|
try{
|
|
132
134
|
var meta=await api("GET","/v1/conversations/"+id);
|
|
133
135
|
if(mine!==view)return;
|
|
134
|
-
|
|
135
|
-
return cliOrRecord(meta);
|
|
136
|
+
return openCli(meta);
|
|
136
137
|
}catch(error){if(mine===view){show("new","Conversation");note.textContent=error.message;}}
|
|
137
138
|
}
|
|
138
|
-
/**
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
|
|
139
|
+
/** ONE path for every conversation, whatever made it (#3620, #3640): a session started over MCP and one started by
|
|
140
|
+
* + New Agent open the same way, on a CLI of the conversation's OWN session — the CLI runs that session, so this page,
|
|
141
|
+
* MCP and the CLI's own transcript are one session with one history. A CLI already running for it is attached; one that
|
|
142
|
+
* is not is started here and attached. Nothing is drawn from the conversation's record: its record is not terminal
|
|
143
|
+
* output, and MCP reaches the SAME CLI by the same id (agent_send types into it, agent_stream reads its console).
|
|
144
|
+
* A conversation the CLI cannot be opened on says why in plain words — never a transcript with a message box. */
|
|
145
|
+
async function openCli(meta){
|
|
143
146
|
var mine=view,alive=true;
|
|
144
147
|
try{await api("GET","/v1/terminals/"+meta.id);}catch(error){if(error.status!==404)throw error;alive=false;}
|
|
145
148
|
if(mine!==view)return;
|
|
146
149
|
if(alive)return cliAttach(meta.id,originOf(meta));
|
|
147
|
-
// A conversation with nothing running takes a CLI of its own when its OWN session can be run (#3620): an open one, and
|
|
148
|
-
// an MCP conversation whose session its agent has already written — BN hands an MCP conversation's turn back and shelves
|
|
149
|
-
// it as history the moment it ends, so an MCP session that has answered is a shelved record that is still exactly the
|
|
150
|
-
// session a resume runs. Anything else is history: no live shell is ever invented for a session that has none.
|
|
151
|
-
var resumable=meta.platform==="mcp"&&meta.started===true;
|
|
152
|
-
if((meta.shelf!==undefined||meta.state==="closed")&&!resumable)return chatView(meta);
|
|
153
150
|
try{
|
|
154
151
|
var started=await api("POST","/v1/terminals",{conversation:meta.id,cols:100,rows:30});
|
|
155
152
|
if(mine!==view)return;
|
|
@@ -157,28 +154,21 @@ async function cliOrRecord(meta){
|
|
|
157
154
|
}catch(error){
|
|
158
155
|
if(mine!==view)return;
|
|
159
156
|
if(error.status===404)return goneView(meta);
|
|
160
|
-
|
|
157
|
+
goneView(meta,error.message);
|
|
161
158
|
}
|
|
162
159
|
}
|
|
163
|
-
/** A
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
|
|
168
|
-
async function cliOrGone(meta){
|
|
169
|
-
var mine=view,alive=true;
|
|
170
|
-
try{await api("GET","/v1/terminals/"+meta.id);}catch(error){if(error.status!==404)throw error;alive=false;}
|
|
171
|
-
if(mine!==view)return;
|
|
172
|
-
if(alive)return cliAttach(meta.id,originOf(meta));
|
|
173
|
-
return goneView(meta);
|
|
174
|
-
}
|
|
175
|
-
/** A console session whose CLI this machine no longer holds. Nothing is replayed: its terminal screen was never stored. */
|
|
176
|
-
function goneView(meta){
|
|
160
|
+
/** A conversation whose CLI is not there and cannot be opened right now, in plain words: the reason is the refusal when
|
|
161
|
+
* the attach was tried and said no, and absent when the CLI this machine held for it is simply gone — it ended, or BN
|
|
162
|
+
* was restarted past it (terminalDoor closes every console session as the gateway comes up). Its screen was never
|
|
163
|
+
* stored, so nothing is replayed: it says so rather than drawing a black pane that cannot answer (#3611). */
|
|
164
|
+
function goneView(meta,why){
|
|
177
165
|
show("gone","CLI session");
|
|
178
166
|
var facts=el("div","convmeta");
|
|
179
|
-
facts.append(idLine(meta.id),timeLine(meta),el("div","rowmeta",agentLine(meta)),el("span","origin",originOf(meta)),el("span","badge history","⏸
|
|
167
|
+
facts.append(idLine(meta.id),timeLine(meta),el("div","rowmeta",agentLine(meta)),el("span","origin",originOf(meta)),el("span","badge history","⏸ NO CLI"));
|
|
180
168
|
main.append(facts);
|
|
181
|
-
main.append(el("p","system",
|
|
169
|
+
main.append(el("p","system",why===undefined
|
|
170
|
+
?"This session's CLI is not running on this machine any more — it ended, or BN was restarted past it. Its terminal screen was never kept, so there is nothing to attach to and nothing to replay."
|
|
171
|
+
:"This session's terminal could not be opened: "+why));
|
|
182
172
|
main.append(el("p","system","MCP and REST still reach the same session by this id, and + New Agent starts a fresh CLI."));
|
|
183
173
|
}
|
|
184
174
|
/** Back: the previous view when this page has one, otherwise the Conversations list. */
|
|
@@ -192,23 +182,53 @@ function toList(){
|
|
|
192
182
|
}
|
|
193
183
|
|
|
194
184
|
async function newAgentView(){
|
|
195
|
-
var mine=show("new","New Agent");
|
|
185
|
+
var mine=show("new","New Agent");
|
|
196
186
|
var setup=el("div","setup"),provider=el("select"),project=el("select"),model=el("input"),open=el("button","go","▶ New Agent");
|
|
197
187
|
provider.setAttribute("aria-label","CLI provider");project.setAttribute("aria-label","Project");model.setAttribute("aria-label","Model");model.placeholder="Model (CLI default if empty)";
|
|
188
|
+
// Both selects open on an empty "choose" option and ▶ New Agent is pressed only once BOTH carry a real choice (#3640):
|
|
189
|
+
// nothing is posted while either is unset, so no session is ever started in a folder or on a CLI that was not chosen,
|
|
190
|
+
// and the notice says in plain words what is still to be chosen.
|
|
191
|
+
var cliPlaceholder=el("option","","Choose a CLI…"),folderPlaceholder=el("option","","Choose a workspace…");
|
|
192
|
+
cliPlaceholder.value="";folderPlaceholder.value="";provider.append(cliPlaceholder);project.append(folderPlaceholder);
|
|
198
193
|
setup.append(provider,project,model,open);main.append(setup);
|
|
199
|
-
|
|
194
|
+
var folders=[],catalog=[];
|
|
195
|
+
function sync(){
|
|
196
|
+
var chosen=catalog.filter(function(entry){return entry.name===provider.value;})[0];
|
|
197
|
+
model.hidden=!(chosen&&chosen.model);if(model.hidden)model.value="";
|
|
198
|
+
var missing=[];
|
|
199
|
+
if(catalog.length===0)missing.push("a CLI to run (this gateway lists none)");else if(provider.value==="")missing.push("the CLI to run");
|
|
200
|
+
if(folders.length===0)missing.push("a workspace to run in (no project here holds a BACKLOG.md)");else if(project.value==="")missing.push("the workspace to run in");
|
|
201
|
+
open.disabled=missing.length>0;
|
|
202
|
+
note.textContent=missing.length>0
|
|
203
|
+
?"Choose "+missing.join(" and ")+" before starting a session."
|
|
204
|
+
:"Starts a session with "+(chosen.label||chosen.name)+" in "+project.value+". Its conversation then opens in the same view.";
|
|
205
|
+
}
|
|
206
|
+
try{var listed=await api("GET","/v1/projects");if(mine!==view)return;folders=listed.projects.filter(function(p){return p.board;});folders.forEach(function(p){var option=el("option","",p.name);option.value=p.name;project.append(option);});
|
|
200
207
|
var sessions=await api("GET","/v1/terminals");if(mine!==view)return;
|
|
201
|
-
|
|
208
|
+
catalog=sessions.providers||[];
|
|
202
209
|
catalog.forEach(function(entry){var option=el("option","",entry.label||entry.name);option.value=entry.name;provider.append(option);});
|
|
203
|
-
function syncModel(){var chosen=catalog.filter(function(entry){return entry.name===provider.value;})[0];model.hidden=!(chosen&&chosen.model);if(model.hidden)model.value="";}
|
|
204
|
-
provider.onchange=syncModel;syncModel();
|
|
205
210
|
}catch(error){fail(error);}
|
|
206
|
-
|
|
211
|
+
provider.onchange=sync;project.onchange=sync;sync();
|
|
212
|
+
open.onclick=async function(){
|
|
213
|
+
// The one place that posts a New Agent request: an unset field is never sent for a door to fill in.
|
|
214
|
+
if(provider.value===""||project.value===""){sync();return;}
|
|
215
|
+
open.disabled=true;
|
|
216
|
+
try{var s=await api("POST","/v1/terminals",{provider:provider.value,project:project.value,model:model.hidden?"":model.value,cols:100,rows:30});if(mine===view)swap("term",s.id);}catch(error){fail(error);open.disabled=false;}
|
|
217
|
+
};
|
|
207
218
|
}
|
|
208
219
|
|
|
209
220
|
async function cliAttach(id,origin){
|
|
210
|
-
var mine=show("terminal","Native CLI · "+id),toolbar=el("div","setup"),back=el("button","","↩ Back"),reattach=el("button","","🔌 Reattach"),copy=el("button","","⧉ Copy selection"),close=el("button","","⏹ Close CLI"),host=el("div");host.id="terminal";main.style.display="flex";main.style.flexDirection="column";host.style.flex="1";host.style.minHeight="0";toolbar.append(back,reattach,copy,close,el("span","origin",origin));main.append(toolbar,host);
|
|
221
|
+
var mine=show("terminal","Native CLI · "+id),toolbar=el("div","setup"),back=el("button","","↩ Back"),reattach=el("button","","🔌 Reattach"),copy=el("button","","⧉ Copy selection"),latest=el("button","latest","↓ Newest"),close=el("button","","⏹ Close CLI"),host=el("div");host.id="terminal";main.style.display="flex";main.style.flexDirection="column";host.style.flex="1";host.style.minHeight="0";toolbar.append(back,reattach,copy,latest,close,el("span","origin",origin));main.append(toolbar,host);
|
|
211
222
|
terminal=new Terminal({cursorBlink:true,theme:{background:"#101217"},allowProposedApi:true});fitAddon=new FitAddon.FitAddon();terminal.loadAddon(fitAddon);terminal.open(host);terminal.focus();var term=terminal;
|
|
223
|
+
// Reading back through the scrollback is the reader's own act, so it is never taken away from them: output arriving
|
|
224
|
+
// while they are scrolled up leaves the view exactly where they put it, and ↓ Newest is the deliberate way back to the
|
|
225
|
+
// newest line. The control is shown only when there is somewhere to go back to, so it is never a button that does
|
|
226
|
+
// nothing, and it goes the instant they are at the newest output again.
|
|
227
|
+
var atNewest=function(){var b=term.buffer.active;return b.viewportY>=b.baseY;};
|
|
228
|
+
var syncLatest=function(){latest.hidden=atNewest();};
|
|
229
|
+
latest.onclick=function(){term.scrollToBottom();term.focus();syncLatest();};
|
|
230
|
+
term.onScroll(syncLatest);
|
|
231
|
+
term.onWriteParsed(syncLatest);
|
|
212
232
|
// xterm turns every keystroke into the CLI's input, so Ctrl+C and Ctrl+V never reach the browser's own clipboard.
|
|
213
233
|
// Returning false hands that one key back to the browser: Ctrl+C copies the selected text and does not interrupt
|
|
214
234
|
// the CLI unless nothing is selected, and Ctrl+V pastes the clipboard into the CLI.
|
|
@@ -274,8 +294,6 @@ ${CONVERSATIONS_VIEWS}
|
|
|
274
294
|
btnBack.onclick=goBack;
|
|
275
295
|
btnConvs.onclick=toList;
|
|
276
296
|
btnNew.onclick=function(){go("new");};
|
|
277
|
-
text.oninput=function(){text.style.height="44px";text.style.height=Math.min(text.scrollHeight+2,140)+"px";};
|
|
278
|
-
text.onkeydown=function(e){if(e.key!=="Enter"||e.isComposing)return;e.preventDefault();if(e.ctrlKey||e.metaKey||e.shiftKey){text.setRangeText("\\n",text.selectionStart,text.selectionEnd,"end");text.oninput();return;}btnSend.click();};
|
|
279
297
|
/** Where the page lands once signed in: the Conversations list, or the view a link named on arrival. */
|
|
280
298
|
function home(){
|
|
281
299
|
var arrived=history.state&&history.state.bn;
|
|
@@ -11,6 +11,14 @@ const PICKER_SOURCE = String.raw `
|
|
|
11
11
|
document.body.appendChild(bar);
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
function refused(text) {
|
|
15
|
+
notice("__bullseye-workspace-refused", text);
|
|
16
|
+
var shown = document.getElementById("__bullseye-workspace-refused");
|
|
17
|
+
if (shown !== null) {
|
|
18
|
+
shown.textContent = text;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
14
22
|
var peekHome = new URLSearchParams(window.location.search).get("bullseye-peek");
|
|
15
23
|
var peeking = peekable && peekHome !== null;
|
|
16
24
|
var moved = "bullseye-workspace-moved";
|
|
@@ -90,14 +98,14 @@ const PICKER_SOURCE = String.raw `
|
|
|
90
98
|
}
|
|
91
99
|
window.fetch("/__bullseye/workspaces", { method: "POST", body: JSON.stringify({ label: picked }) }).then(function (answer) {
|
|
92
100
|
if (!answer.ok) {
|
|
93
|
-
|
|
101
|
+
refused("⛔ That workspace could not be picked (" + answer.status + ") — this page stays where it is");
|
|
94
102
|
undo();
|
|
95
103
|
return;
|
|
96
104
|
}
|
|
97
105
|
window.sessionStorage.setItem(moved, picked);
|
|
98
106
|
window.location.assign(served.workspaces[picked][page]);
|
|
99
107
|
}, function () {
|
|
100
|
-
|
|
108
|
+
refused("⛔ That workspace could not be picked — this page stays where it is");
|
|
101
109
|
undo();
|
|
102
110
|
});
|
|
103
111
|
}, "no workspace named"));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omni-notify-mcp",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.288",
|
|
4
4
|
"description": "Reach your AI agents from anywhere — and let them reach you. One stdio process serves the whole tool surface to Claude Code, GitHub Copilot Chat and Codex: desktop, Telegram, Slack, ntfy, Discord, Teams and SMS are configured in one file, and agents notify you when long work finishes or a decision is needed, ask you a question and wait for your answer, and receive and reply to the messages you send them — with Do Not Disturb and idle detection deciding what actually gets through.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "module",
|