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.
@@ -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 and the transcript he reads and resumes. It is carried
386
- * verbatim from the deleted Conversations page (#3401) into the native CLI page — the table, its per-row actions, the
387
- * project picker and the transcript/timing view — because that capability lives nowhere else. Its ONE creation
388
- * control (the New form and `POST /v1/conversations`) is NOT carried: the native CLI page's New Agent is the one way
389
- * to create an agent. The functions here rely on the page's shared `el`, `api`, `base`, `main`, `show`, `sent` and
390
- * `storedStamp`, provided by the page's own script, and on the header buttons it builds (`btnBack`,
391
- * `btnShelf`, `btnStop`).
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("chat", node.c.id); };
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("chat", node.c.id); };
717
- tr.onkeydown = function (e) { if (e.target === tr && (e.key === "Enter" || e.key === " ")) { e.preventDefault(); go("chat", node.c.id); } };
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. A console session carries its mode, its owner and its notice HERE, in the one write that mints it, so the record is never on disk describing itself as a turn conversation the session driver would read its events for. */
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 = allowedFolder(input.project);
512
- if (providerFor(input.provider) === undefined)
513
- throw new GatewayError(400, `provider must be one of ${PROVIDER_NAMES.join(", ")}`);
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, no project and no agent work (#2723). Nothing is asked, so no user
520
- * event is stored and no process is started, and opening one costs nothing. Its project is picked now or at any time later,
521
- * or resolved by its first message's own text; the conversation keeps its id and its whole transcript across a switch.
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
- if (providerFor(input.provider) === undefined)
526
- throw new GatewayError(400, `provider must be one of ${PROVIDER_NAMES.join(", ")}`);
527
- const project = input.project === undefined ? undefined : projectFolder(input.project);
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). A Claude session id belongs to the folder it was
532
- * created in, so the record gets a new session id and an unstarted session while BN's own conversation id and transcript
533
- * stay: the next turn creates its session in the new folder, never resuming the old folder's one against the new cwd. A
534
- * change while a turn is in flight is refused, because a running process is never moved out from under its own work.
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, sessionId: randomUUID(), started: false, providerArchived: undefined });
552
- appendEvent(id, { role: "system", kind: "status", text: `📁 Project set to ${basename(folder)} — the next turn starts a fresh agent session there.` });
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`. */
@@ -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, resolveProject, sendToConversation, setConversationProject, shelvedConversations, stampWaitReturn, startConversation, stopConversation, unarchiveConversation, waitForEvents, waitForTurn, workspaceRoot, allowedProjects, } from "./conversations.js";
21
- import { DEFAULT_PROVIDER } from "./provider.js";
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 fresh agent starts in the project folder the request is about: the project named in the request text, else a board-bearing default (never a folder without a BACKLOG.md, unless project names that folder explicitly), under the workspace root (${workspaceRoot()}). The only projects are ${allowedProjects().join(" and ")}; project accepts exactly those names, never a path. agent_prompt needs no folder path and no conversation id; pass project only to pick the project yourself. agent_projects lists the projects.
45
- agent_open opens an empty conversation and starts nothing: it takes no text and no project, spends nothing and returns the id of a session whose project is picked with agent_project and then asked with agent_send. A conversation opened with no project resolves one from its first message's own text, else the default project.
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 project a fresh agent starts in: exactly one of ${allowedProjects().join(" or ")}, resolved under ${workspaceRoot()}; no path is accepted. Without it the agent starts in the project the request text names, else a default project that holds a board. Used only when a fresh agent starts`),
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: resolveProject(project, text), provider: provider ?? DEFAULT_PROVIDER, model, text, receivedMs: Math.round(performance.now() - arrived), arrival }).id, turn: 1 }
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(`Exactly one of ${allowedProjects().join(" or ")}, resolved under ${workspaceRoot()}; no path is accepted. Omit it to run in the project the text names, else a default project that holds a board`), text: z.string().min(1).describe("The first task, submitted by this call itself"), provider: z.string().min(1).optional(), model: z.string().min(1).optional() }, ({ project, text, provider, model }) => act(() => {
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: resolveProject(project, text), provider: provider ?? DEFAULT_PROVIDER, model, text, receivedMs: Math.round(performance.now() - arrived), arrival });
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, empty conversation and starts no agent: no text, no project, no process started and nothing spent. It returns {id, state} at once. A conversation opened with no project gets one from agent_project, or from its first message's own text (else the default project); its id and its whole transcript stay across a later switch. Ask it with agent_send and read it with agent_wait.`, { project: z.enum(allowedProjects()).optional().describe(`Exactly one of ${allowedProjects().join(" or ")}, resolved under ${workspaceRoot()}; no path is accepted. Omit it to leave the project unset until one is picked or the first message names one`), provider: z.string().min(1).optional(), model: z.string().min(1).optional() }, ({ project, provider, model }) => act(() => {
193
- const meta = openConversation({ platform: "mcp", technical: true, chat: caller, project, provider: provider ?? DEFAULT_PROVIDER, model });
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 and its whole transcript stay. A Claude session id belongs to one folder, so the switch starts the next turn as a fresh session there. A switch while a turn is running is refused with the running turn's number, and the record keeps 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)));
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
@@ -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, resolveProject, sendToConversation, setConversationProject, shelvedConversations, startConversation, stopConversation, unarchiveConversation, waitForEvents, } from "./conversations.js";
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 = typeof body.provider === "string" && body.provider.length > 0 ? body.provider : DEFAULT_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 = typeof body.project === "string" && body.project.trim().length > 0 ? body.project : undefined;
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
- // Neither field is required: a body with no text opens an empty conversation and starts nothing, and a project left out is resolved from the text, else the home project.
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: resolveProject(project, text), provider, model, text, receivedMs });
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, resolveProject, titleOf, turnInFlight, hasBoard, resumedInLog, shelfInLog, storeChanged, stopConversation, stoppedInLog, updateConversation, } from "./conversations.js";
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
- // A conversation opened with no project gets one the moment its message arrives, stored on the record before the turn starts; a message whose project cannot be resolved fails its own turn and is consumed, so the next beat takes the next message.
635
- let project = c.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
- project = project === undefined ? this.projectFor(c, next) : allowedFolder(project);
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 — its session id belongs to that folder, so this turn opens a fresh one where the record now points. */
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, resolveProject } from "./conversations.js";
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 project yet, and a CLI runs in a folder. Set one with agent_project, or send it a message so its own text picks one, then open the CLI.");
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
- const provider = providerFor(String(body.provider));
201
- if (!provider)
202
- throw new GatewayError(400, "Choose Claude, Codex or Copilot.");
203
- const project = resolveProject(typeof body.project === "string" ? body.project : undefined);
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 view each conversation opens in. Every conversation opens in the same
15
- * CLI-style view, labelled MCP or User by where it came from. A live CLI session attaches to its terminal; a message
16
- * conversation shows its structured record, which is not terminal output. ▶ New Agent is the one control that starts a
17
- * CLI agent. Browser history drives the views: each view is a history entry, Back pops it, and Conversations unwinds to
18
- * the list at the root entry.
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}#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
+ 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="stop" hidden>Stop</button><button id="logout" hidden>🚪 Logout</button></header>
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"),bar=document.getElementById("bar");
56
- var text=document.getElementById("text"),btnSend=document.getElementById("send"),btnBack=document.getElementById("back"),btnShelf=document.getElementById("shelf"),btnStop=document.getElementById("stop");
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((deepLink.get("view")==="term"||deepLink.get("view")==="chat")&&deepLink.get("id")){arrival={view:deepLink.get("view"),id:deepLink.get("id")};}
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==="chat"||name==="terminal"||name==="gone");
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 console view: a live CLI attaches, a turn conversation shows its record and its live stream. */
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
- if(name==="term"||meta.mode==="interactive-only")return cliOrGone(meta);
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
- /** An existing conversation opens on a CLI of its OWN (#3620): the CLI runs that conversation's own agent session, so the
139
- * page, MCP and the CLI's own transcript are one session with one history — the same native CLI a New Agent session gets.
140
- * One whose CLI is already running is simply attached; one the CLI cannot take over — a turn in flight, no project yet, a
141
- * provider whose CLI keeps no resumable session — opens on its record with the reason in plain words, never a dead pane. */
142
- async function cliOrRecord(meta){
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
- chatView(meta,error.message);
157
+ goneView(meta,error.message);
161
158
  }
162
159
  }
163
- /** A console session opens on the CLI this machine is running for it; one whose CLI is gone — it ended, or BN was
164
- * restarted past it (terminalDoor closes every console session as the gateway comes up) — is never drawn as a terminal
165
- * that cannot answer. Its screen was never kept, so it says so in plain words instead of a black pane reattaching (#3611).
166
- * Only the door's own 404 says the CLI is gone: any other failure is a read that did not happen, and a failed read never
167
- * means "does not exist", so it is raised rather than reported as a session that has ended. */
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","⏸ 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","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."));
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");note.textContent="Starts a CLI agent on this machine. Its conversation then opens in the same view.";
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
- try{var folders=await api("GET","/v1/projects");if(mine!==view)return;folders.projects.filter(function(p){return p.board;}).forEach(function(p){var option=el("option","",p.name);option.value=p.name;project.append(option);});
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
- var catalog=sessions.providers||[];
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
- open.onclick=async function(){open.disabled=true;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;}};
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
- notice("__bullseye-workspace-refused", "⛔ That workspace could not be picked (" + answer.status + ") — this page stays where it is");
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
- notice("__bullseye-workspace-refused", "⛔ That workspace could not be picked — this page stays where it is");
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.286",
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",