@vigent/cli 0.2.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +85 -27
- package/dist/agents.js +29 -0
- package/dist/app.js +521 -20
- package/dist/attach.js +102 -0
- package/dist/choices.js +4 -1
- package/dist/cli-engines.js +15 -5
- package/dist/cli.js +25 -15
- package/dist/config.js +26 -2
- package/dist/diffview.js +1 -1
- package/dist/engines.js +18 -11
- package/dist/float.js +1 -1
- package/dist/hunk-review.js +218 -0
- package/dist/hunks.js +60 -0
- package/dist/icons.js +1 -0
- package/dist/keys.js +3 -1
- package/dist/main.js +16 -0
- package/dist/markdown.js +1 -1
- package/dist/notify.js +33 -0
- package/dist/pi-sdk.js +45 -0
- package/dist/pi-ui.js +78 -0
- package/dist/picker.js +21 -11
- package/dist/serve-page.js +247 -0
- package/dist/serve.js +267 -0
- package/dist/session.js +122 -11
- package/dist/settings.js +1 -1
- package/dist/templates.js +125 -0
- package/dist/theme.js +2 -0
- package/dist/tui.js +13 -0
- package/package.json +4 -4
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
const esc = (s) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
2
|
+
export function servePage(project, palette) {
|
|
3
|
+
const p = {
|
|
4
|
+
...palette,
|
|
5
|
+
bg_dark: palette.bg_dark ?? palette.bg,
|
|
6
|
+
bg_alt: palette.bg_alt ?? palette.bg,
|
|
7
|
+
selection: palette.selection ?? palette.blue,
|
|
8
|
+
muted: palette.muted ?? palette.fg,
|
|
9
|
+
subtle: palette.subtle ?? palette.fg,
|
|
10
|
+
accent: palette.accent ?? palette.yellow,
|
|
11
|
+
};
|
|
12
|
+
return `<!doctype html>
|
|
13
|
+
<html lang="en">
|
|
14
|
+
<head>
|
|
15
|
+
<meta charset="utf-8">
|
|
16
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
17
|
+
<meta name="theme-color" content="${esc(p.bg_dark)}">
|
|
18
|
+
<title>vigent · ${esc(project)}</title>
|
|
19
|
+
<style>
|
|
20
|
+
:root {
|
|
21
|
+
--bg: ${p.bg}; --bg-dark: ${p.bg_dark}; --bg-alt: ${p.bg_alt}; --sel: ${p.selection};
|
|
22
|
+
--fg: ${p.fg}; --muted: ${p.muted}; --subtle: ${p.subtle}; --accent: ${p.accent};
|
|
23
|
+
--blue: ${p.blue}; --green: ${p.green}; --yellow: ${p.yellow}; --red: ${p.red}; --cyan: ${p.cyan}; --magenta: ${p.magenta};
|
|
24
|
+
--mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
|
|
25
|
+
--sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
26
|
+
}
|
|
27
|
+
* { box-sizing: border-box; }
|
|
28
|
+
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--fg); font: 15px/1.5 var(--sans); -webkit-text-size-adjust: 100%; }
|
|
29
|
+
button, textarea, input { font: inherit; color: inherit; }
|
|
30
|
+
header { position: sticky; top: 0; z-index: 2; display: flex; align-items: center; gap: 10px; padding: 12px 16px; padding-top: max(12px, env(safe-area-inset-top)); background: var(--bg-dark); border-bottom: 1px solid var(--sel); }
|
|
31
|
+
header h1 { margin: 0; font: 600 16px/1.2 var(--mono); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
32
|
+
header .project { color: var(--muted); font-weight: 400; }
|
|
33
|
+
.back { border: 0; background: none; color: var(--blue); font-size: 22px; line-height: 1; padding: 4px 8px 4px 0; cursor: pointer; }
|
|
34
|
+
.dot { width: 9px; height: 9px; border-radius: 50%; background: var(--subtle); flex: none; }
|
|
35
|
+
.dot.live { background: var(--green); }
|
|
36
|
+
main { padding: 12px 16px 160px; max-width: 760px; margin: 0 auto; }
|
|
37
|
+
.empty, .note { color: var(--muted); text-align: center; padding: 40px 12px; }
|
|
38
|
+
.card { display: block; width: 100%; text-align: left; border: 1px solid var(--sel); background: var(--bg-dark); border-radius: 12px; padding: 12px 14px; margin: 0 0 10px; cursor: pointer; }
|
|
39
|
+
.card:active { background: var(--bg-alt); }
|
|
40
|
+
.row { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
|
41
|
+
.name { font: 600 15px/1.3 var(--mono); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
42
|
+
.meta { color: var(--muted); font: 12px/1.4 var(--mono); margin-top: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
43
|
+
.last { color: var(--fg); opacity: .85; font-size: 13px; margin-top: 6px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
|
44
|
+
.badge { font: 600 11px/1 var(--mono); padding: 4px 7px; border-radius: 999px; background: var(--bg-alt); color: var(--muted); flex: none; text-transform: lowercase; }
|
|
45
|
+
.badge.running { color: var(--bg); background: var(--green); }
|
|
46
|
+
.badge.waiting { color: var(--bg); background: var(--yellow); }
|
|
47
|
+
.badge.error { color: var(--bg); background: var(--red); }
|
|
48
|
+
.badge.paused { color: var(--bg); background: var(--subtle); }
|
|
49
|
+
.new { color: var(--yellow); font: 600 12px var(--mono); }
|
|
50
|
+
.add { color: var(--green); } .del { color: var(--red); }
|
|
51
|
+
.item { margin: 14px 0; }
|
|
52
|
+
.user { font: 600 14px/1.5 var(--mono); color: var(--accent); white-space: pre-wrap; word-break: break-word; }
|
|
53
|
+
.user::before { content: "❯ "; color: var(--blue); }
|
|
54
|
+
.agent { white-space: pre-wrap; word-break: break-word; }
|
|
55
|
+
.agent code { font: 13px var(--mono); background: var(--bg-alt); padding: 1px 5px; border-radius: 4px; color: var(--green); }
|
|
56
|
+
.agent pre, details pre { font: 12.5px/1.45 var(--mono); background: var(--bg-dark); border: 1px solid var(--sel); border-radius: 8px; padding: 10px 12px; overflow-x: auto; white-space: pre; margin: 8px 0; }
|
|
57
|
+
.agent pre code { background: none; padding: 0; color: var(--fg); }
|
|
58
|
+
.agent .h { font-weight: 700; color: var(--accent); }
|
|
59
|
+
.streaming::after { content: "▍"; color: var(--accent); animation: blink 1s steps(1) infinite; }
|
|
60
|
+
@keyframes blink { 50% { opacity: 0; } }
|
|
61
|
+
details { font: 13px/1.4 var(--mono); }
|
|
62
|
+
summary { color: var(--muted); cursor: pointer; list-style: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
63
|
+
summary::-webkit-details-marker { display: none; }
|
|
64
|
+
summary::before { content: "▸ "; } details[open] summary::before { content: "▾ "; }
|
|
65
|
+
summary b { color: var(--cyan); font-weight: 600; }
|
|
66
|
+
.info { color: var(--muted); font-size: 13px; font-style: italic; white-space: pre-wrap; }
|
|
67
|
+
.composer { position: fixed; left: 0; right: 0; bottom: 0; z-index: 3; background: var(--bg-dark); border-top: 1px solid var(--sel); padding: 10px 12px; padding-bottom: max(10px, env(safe-area-inset-bottom)); }
|
|
68
|
+
.composer .inner { max-width: 760px; margin: 0 auto; display: flex; gap: 8px; align-items: flex-end; }
|
|
69
|
+
textarea { flex: 1; min-height: 44px; max-height: 40vh; resize: none; border: 1px solid var(--sel); background: var(--bg); border-radius: 12px; padding: 10px 12px; font: 15px/1.4 var(--sans); outline: none; }
|
|
70
|
+
textarea:focus, input:focus { border-color: var(--blue); }
|
|
71
|
+
.btn { border: 0; border-radius: 12px; padding: 0 16px; height: 44px; font-weight: 600; cursor: pointer; background: var(--blue); color: var(--bg); flex: none; }
|
|
72
|
+
.btn.stop { background: var(--red); }
|
|
73
|
+
.btn:disabled { opacity: .5; }
|
|
74
|
+
.login { max-width: 360px; margin: 18vh auto 0; padding: 0 20px; text-align: center; }
|
|
75
|
+
.login h2 { font: 700 26px var(--mono); margin: 0 0 6px; color: var(--accent); }
|
|
76
|
+
.login p { color: var(--muted); margin: 0 0 22px; }
|
|
77
|
+
.login input { width: 100%; text-align: center; letter-spacing: 3px; font: 600 22px var(--mono); padding: 12px; border-radius: 12px; border: 1px solid var(--sel); background: var(--bg-dark); outline: none; }
|
|
78
|
+
.login .btn { width: 100%; margin-top: 12px; }
|
|
79
|
+
.err { color: var(--red); min-height: 1.5em; margin-top: 10px; font-size: 14px; }
|
|
80
|
+
.toast { position: fixed; left: 50%; transform: translateX(-50%); bottom: 96px; background: var(--bg-alt); border: 1px solid var(--sel); padding: 8px 14px; border-radius: 999px; font-size: 13px; z-index: 4; }
|
|
81
|
+
</style>
|
|
82
|
+
</head>
|
|
83
|
+
<body>
|
|
84
|
+
<div id="app"></div>
|
|
85
|
+
<script>
|
|
86
|
+
"use strict";
|
|
87
|
+
const store = { get(k) { try { return sessionStorage.getItem(k); } catch { return null; } }, set(k, v) { try { sessionStorage.setItem(k, v); } catch {} } };
|
|
88
|
+
let code = store.get("vigent-code") || "";
|
|
89
|
+
if (location.hash.length > 1) { code = decodeURIComponent(location.hash.slice(1)); store.set("vigent-code", code); history.replaceState(null, "", location.pathname); }
|
|
90
|
+
const state = { agents: [], open: null, items: [], version: -1, live: false, project: ${JSON.stringify(project)}, sending: false };
|
|
91
|
+
const $app = document.getElementById("app");
|
|
92
|
+
const h = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => "&#" + c.charCodeAt(0) + ";");
|
|
93
|
+
const tok = (n) => n >= 1e6 ? (n / 1e6).toFixed(1) + "M" : n >= 1e3 ? (n / 1e3).toFixed(1) + "k" : String(n);
|
|
94
|
+
const usd = (c) => c === undefined || c === null ? "" : " · $" + (c > 0 && c < 0.01 ? c.toFixed(3) : c.toFixed(2));
|
|
95
|
+
|
|
96
|
+
async function api(path, body) {
|
|
97
|
+
const r = await fetch(path, { method: body ? "POST" : "GET", headers: { authorization: "Bearer " + code, ...(body ? { "content-type": "application/json" } : {}) }, body: body ? JSON.stringify(body) : undefined, cache: "no-store" });
|
|
98
|
+
if (r.status === 401 || r.status === 423) { const e = await r.json().catch(() => ({})); throw Object.assign(new Error(e.error || "wrong access code"), { auth: true }); }
|
|
99
|
+
const data = await r.json().catch(() => ({}));
|
|
100
|
+
if (!r.ok) throw new Error(data.error || r.statusText);
|
|
101
|
+
return data;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function toast(text) {
|
|
105
|
+
const t = document.createElement("div"); t.className = "toast"; t.textContent = text; document.body.appendChild(t);
|
|
106
|
+
setTimeout(() => t.remove(), 2200);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function markdown(text) {
|
|
110
|
+
return text.split(/(\\x60\\x60\\x60[^\\n]*\\n[\\s\\S]*?(?:\\x60\\x60\\x60|$))/).map((part) => {
|
|
111
|
+
const fence = /^\\x60\\x60\\x60([^\\n]*)\\n([\\s\\S]*?)(?:\\x60\\x60\\x60)?$/.exec(part);
|
|
112
|
+
if (fence) return "<pre><code>" + h(fence[2].replace(/\\n$/, "")) + "</code></pre>";
|
|
113
|
+
return h(part)
|
|
114
|
+
.replace(/^(#{1,4}) (.*)$/gm, (_m, _hashes, t) => '<span class="h">' + t + "</span>")
|
|
115
|
+
.replace(/\\x60([^\\x60\\n]+)\\x60/g, "<code>$1</code>")
|
|
116
|
+
.replace(/\\*\\*([^*\\n]+)\\*\\*/g, "<strong>$1</strong>");
|
|
117
|
+
}).join("");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function renderLogin(error) {
|
|
121
|
+
$app.innerHTML = '<div class="login"><h2>vigent</h2><p>Type the access code vigent shows after <code>:serve</code>.</p>' +
|
|
122
|
+
'<form id="f"><input id="c" autocomplete="one-time-code" autocapitalize="none" spellcheck="false" placeholder="xxxx-xxxx" value="' + h(code) + '"><button class="btn">Follow</button></form>' +
|
|
123
|
+
'<div class="err">' + h(error || "") + "</div></div>";
|
|
124
|
+
document.getElementById("f").onsubmit = (e) => { e.preventDefault(); code = document.getElementById("c").value.trim().toLowerCase(); store.set("vigent-code", code); start(); };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function header(title, back) {
|
|
128
|
+
return "<header>" + (back ? '<button class="back" id="back" aria-label="All agents">‹</button>' : "") +
|
|
129
|
+
"<h1>" + title + '</h1><span class="dot' + (state.live ? " live" : "") + '" title="' + (state.live ? "live" : "reconnecting") + '"></span></header>';
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function meta(a) {
|
|
133
|
+
return h(a.engineName) + " · " + h(a.model || "default") + (a.effort ? " · " + h(a.effort) : "") +
|
|
134
|
+
(a.branch ? " · ⎇ " + h(a.branch) : "") +
|
|
135
|
+
(a.added || a.removed ? ' · <span class="add">+' + a.added + '</span> <span class="del">−' + a.removed + "</span>" : "") +
|
|
136
|
+
(a.tokens ? " · " + tok(a.tokens) + " tok" + usd(a.cost) : "");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function composer(placeholder, running) {
|
|
140
|
+
return '<div class="composer"><form class="inner" id="send"><textarea id="t" rows="1" placeholder="' + h(placeholder) + '"></textarea>' +
|
|
141
|
+
(running ? '<button type="button" class="btn stop" id="stop">Stop</button>' : "") + '<button class="btn" id="go">Send</button></form></div>';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function wireComposer(onSend) {
|
|
145
|
+
const t = document.getElementById("t");
|
|
146
|
+
const grow = () => { t.style.height = "auto"; t.style.height = Math.min(t.scrollHeight, innerHeight * 0.4) + "px"; };
|
|
147
|
+
t.oninput = grow;
|
|
148
|
+
t.onkeydown = (e) => { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); document.getElementById("send").requestSubmit(); } };
|
|
149
|
+
document.getElementById("send").onsubmit = async (e) => {
|
|
150
|
+
e.preventDefault();
|
|
151
|
+
const text = t.value.trim();
|
|
152
|
+
if (!text || state.sending) return;
|
|
153
|
+
state.sending = true; document.getElementById("go").disabled = true;
|
|
154
|
+
try { await onSend(text); t.value = ""; grow(); } catch (err) { toast(err.message); }
|
|
155
|
+
state.sending = false; const go = document.getElementById("go"); if (go) go.disabled = false;
|
|
156
|
+
};
|
|
157
|
+
const stop = document.getElementById("stop");
|
|
158
|
+
if (stop) stop.onclick = async () => { try { await api("/api/agents/" + state.open + "/stop", {}); toast("stopped"); } catch (err) { toast(err.message); } };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function renderList() {
|
|
162
|
+
const draft = document.getElementById("t")?.value || "";
|
|
163
|
+
const cards = state.agents.map((a) =>
|
|
164
|
+
'<button class="card" data-id="' + a.id + '"><div class="row"><span class="name">' + h(a.name) + "</span>" +
|
|
165
|
+
(a.unread ? '<span class="new">● new</span>' : "") + '<span class="badge ' + h(a.state) + '">' + h(a.state) + (a.queued ? " +" + a.queued : "") + "</span></div>" +
|
|
166
|
+
'<div class="meta">' + meta(a) + "</div>" + (a.last ? '<div class="last">' + (a.last.kind === "user" ? "❯ " : "") + h(a.last.text) + "</div>" : "") + "</button>").join("");
|
|
167
|
+
$app.innerHTML = header('vigent <span class="project">· ' + h(state.project) + "</span>") +
|
|
168
|
+
"<main>" + (cards || '<div class="empty">No agents yet. Describe a task below to start one.</div>') + "</main>" + composer("New agent: describe a task", false);
|
|
169
|
+
for (const c of document.querySelectorAll(".card")) c.onclick = () => openAgent(Number(c.dataset.id));
|
|
170
|
+
document.getElementById("t").value = draft;
|
|
171
|
+
wireComposer(async (text) => { const r = await api("/api/agents", { text }); toast("started"); openAgent(r.id); });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function itemHtml(i, last) {
|
|
175
|
+
if (i.kind === "user") return '<div class="item user">' + h(i.text) + "</div>";
|
|
176
|
+
if (i.kind === "agent") return '<div class="item agent' + (i.streaming && last ? " streaming" : "") + '">' + markdown(i.text) + "</div>";
|
|
177
|
+
if (i.kind === "tool" && i.tool) return '<details class="item"><summary><b>' + h(i.tool.name) + "</b> " + h(i.tool.summary) + "</summary>" + (i.tool.output ? "<pre>" + h(i.tool.output) + "</pre>" : "") + "</details>";
|
|
178
|
+
return '<div class="item info">' + h(i.text) + "</div>";
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function renderAgent() {
|
|
182
|
+
const a = state.agents.find((x) => x.id === state.open);
|
|
183
|
+
if (!a) { state.open = null; return renderList(); }
|
|
184
|
+
const nearBottom = innerHeight + scrollY >= document.body.scrollHeight - 80;
|
|
185
|
+
const open = new Set([...document.querySelectorAll("details[open]")].map((d) => d.dataset.i));
|
|
186
|
+
const draft = document.getElementById("t")?.value || "";
|
|
187
|
+
const hadFocus = document.activeElement?.id === "t";
|
|
188
|
+
$app.innerHTML = header(h(a.name), true) +
|
|
189
|
+
'<main><div class="meta" style="white-space:normal">' + meta(a) + ' · <span class="badge ' + h(a.state) + '">' + h(a.state) + "</span></div>" +
|
|
190
|
+
(state.items.length ? state.items.map((i, n) => itemHtml(i, n === state.items.length - 1).replace("<details", '<details data-i="' + n + '"' + (open.has(String(n)) ? " open" : ""))).join("") : '<div class="note">Nothing yet.</div>') +
|
|
191
|
+
"</main>" + composer(a.state === "running" ? "Queue a follow-up…" : "Follow up…", a.state === "running");
|
|
192
|
+
document.getElementById("back").onclick = () => { state.open = null; history.back(); };
|
|
193
|
+
const t = document.getElementById("t"); t.value = draft; if (hadFocus) t.focus();
|
|
194
|
+
wireComposer(async (text) => { const r = await api("/api/agents/" + a.id + "/prompt", { text }); toast(r.queued ? "queued" : "sent"); });
|
|
195
|
+
if (nearBottom) scrollTo(0, document.body.scrollHeight);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
let loading = null;
|
|
199
|
+
async function loadAgent() {
|
|
200
|
+
if (state.open === null) return;
|
|
201
|
+
if (loading) { loading.again = true; return; }
|
|
202
|
+
loading = { again: false };
|
|
203
|
+
try {
|
|
204
|
+
const r = await api("/api/agents/" + state.open);
|
|
205
|
+
state.items = r.items; state.version = r.agent.version;
|
|
206
|
+
if (state.open === r.agent.id) renderAgent();
|
|
207
|
+
} catch (err) { if (err.auth) return renderLogin(err.message); toast(err.message); }
|
|
208
|
+
const again = loading.again; loading = null;
|
|
209
|
+
if (again) loadAgent();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function openAgent(id) {
|
|
213
|
+
state.open = id; state.items = []; state.version = -1;
|
|
214
|
+
history.pushState({ id }, "", "#agent-" + id);
|
|
215
|
+
renderAgent(); scrollTo(0, 0); loadAgent();
|
|
216
|
+
}
|
|
217
|
+
addEventListener("popstate", () => { if (state.open !== null) { state.open = null; renderList(); } });
|
|
218
|
+
|
|
219
|
+
let source;
|
|
220
|
+
function listen() {
|
|
221
|
+
source?.close();
|
|
222
|
+
source = new EventSource("/api/events?code=" + encodeURIComponent(code));
|
|
223
|
+
source.onopen = () => { state.live = true; document.querySelector(".dot")?.classList.add("live"); };
|
|
224
|
+
source.onerror = () => { state.live = false; document.querySelector(".dot")?.classList.remove("live"); };
|
|
225
|
+
source.addEventListener("agents", (e) => {
|
|
226
|
+
state.agents = JSON.parse(e.data).agents;
|
|
227
|
+
if (state.open === null) return renderList();
|
|
228
|
+
const a = state.agents.find((x) => x.id === state.open);
|
|
229
|
+
if (!a) { state.open = null; return renderList(); }
|
|
230
|
+
if (a.version !== state.version) loadAgent(); else renderAgent();
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function start() {
|
|
235
|
+
if (!code) return renderLogin();
|
|
236
|
+
try {
|
|
237
|
+
const r = await api("/api/state");
|
|
238
|
+
state.project = r.project; state.agents = r.agents;
|
|
239
|
+
renderList(); listen();
|
|
240
|
+
} catch (err) { renderLogin(err.auth ? err.message : "can't reach vigent: " + err.message); }
|
|
241
|
+
}
|
|
242
|
+
start();
|
|
243
|
+
</script>
|
|
244
|
+
</body>
|
|
245
|
+
</html>
|
|
246
|
+
`;
|
|
247
|
+
}
|
package/dist/serve.js
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `:serve`: follow your agents from your phone.
|
|
3
|
+
*
|
|
4
|
+
* A small HTTP server on your machine: a page that lists the agents and shows
|
|
5
|
+
* a transcript as it streams, where you can send a prompt or stop an agent.
|
|
6
|
+
* It starts only when you ask (`:serve`, or `vigent --serve`) and stops with
|
|
7
|
+
* `:serve stop` or when vigent quits.
|
|
8
|
+
*
|
|
9
|
+
* Every API call needs the access code shown in vigent (8 characters, new
|
|
10
|
+
* each time). Prompts and stops need it in a header, so another web page
|
|
11
|
+
* can't send them from your browser. After 10 wrong codes the server stops
|
|
12
|
+
* answering until you start it again. It's plain HTTP: use it on a network
|
|
13
|
+
* you trust (home Wi-Fi, a VPN such as Tailscale), not on public Wi-Fi.
|
|
14
|
+
*/
|
|
15
|
+
import { randomInt, timingSafeEqual } from "node:crypto";
|
|
16
|
+
import * as http from "node:http";
|
|
17
|
+
import * as os from "node:os";
|
|
18
|
+
import { ENGINE_NAMES } from "./engines.js";
|
|
19
|
+
import { servePage } from "./serve-page.js";
|
|
20
|
+
const CODE_ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789"; // no 0/o, 1/l/i: easy to type on a phone
|
|
21
|
+
const MAX_FAILURES = 10;
|
|
22
|
+
const MAX_PROMPT = 20_000;
|
|
23
|
+
/** A fresh access code, like "k3x9-q2m7". */
|
|
24
|
+
export function newAccessCode() {
|
|
25
|
+
const pick = () => CODE_ALPHABET[randomInt(CODE_ALPHABET.length)];
|
|
26
|
+
return `${Array.from({ length: 4 }, pick).join("")}-${Array.from({ length: 4 }, pick).join("")}`;
|
|
27
|
+
}
|
|
28
|
+
/** This machine's addresses a phone on the same network can reach (IPv4, not loopback). */
|
|
29
|
+
export function lanAddresses() {
|
|
30
|
+
const out = [];
|
|
31
|
+
for (const list of Object.values(os.networkInterfaces())) {
|
|
32
|
+
for (const a of list ?? [])
|
|
33
|
+
if (a.family === "IPv4" && !a.internal)
|
|
34
|
+
out.push(a.address);
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
function same(a, b) {
|
|
39
|
+
const x = Buffer.from(a);
|
|
40
|
+
const y = Buffer.from(b);
|
|
41
|
+
return x.length === y.length && timingSafeEqual(x, y);
|
|
42
|
+
}
|
|
43
|
+
function agentSummary(s, unread) {
|
|
44
|
+
const st = s.worktree?.stat;
|
|
45
|
+
const last = [...s.items].reverse().find((i) => i.kind === "agent" || i.kind === "user");
|
|
46
|
+
return {
|
|
47
|
+
id: s.id,
|
|
48
|
+
name: s.name,
|
|
49
|
+
engine: s.engine,
|
|
50
|
+
engineName: ENGINE_NAMES[s.engine],
|
|
51
|
+
model: s.model,
|
|
52
|
+
effort: s.effort,
|
|
53
|
+
state: s.state,
|
|
54
|
+
branch: s.worktree?.branch,
|
|
55
|
+
added: st?.added ?? 0,
|
|
56
|
+
removed: st?.removed ?? 0,
|
|
57
|
+
tokens: s.tokens,
|
|
58
|
+
cost: s.cost,
|
|
59
|
+
queued: s.queue.length,
|
|
60
|
+
unread,
|
|
61
|
+
version: s.version,
|
|
62
|
+
last: last ? { kind: last.kind, text: preview(last.text) } : undefined,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/** a reply as one plain line for the agent list: no markdown marks, no code fences */
|
|
66
|
+
function preview(text) {
|
|
67
|
+
return text
|
|
68
|
+
.replace(/\x60\x60\x60[^\n]*\n?/g, " ")
|
|
69
|
+
.replace(/\*\*|__|\x60/g, "")
|
|
70
|
+
.replace(/^#+ /gm, "")
|
|
71
|
+
.replace(/\s+/g, " ")
|
|
72
|
+
.trim()
|
|
73
|
+
.slice(0, 160);
|
|
74
|
+
}
|
|
75
|
+
function itemJson(i) {
|
|
76
|
+
return {
|
|
77
|
+
kind: i.kind,
|
|
78
|
+
text: i.text.length > 50_000 ? `${i.text.slice(0, 50_000)}\n…` : i.text,
|
|
79
|
+
streaming: i.streaming || undefined,
|
|
80
|
+
tool: i.tool
|
|
81
|
+
? { name: i.tool.name, summary: i.tool.summary, output: i.tool.output.length > 4000 ? `${i.tool.output.slice(0, 4000)}\n…` : i.tool.output }
|
|
82
|
+
: undefined,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
export function startServer(host, opts = {}) {
|
|
86
|
+
const code = opts.code ?? newAccessCode();
|
|
87
|
+
const bind = opts.host ?? "0.0.0.0";
|
|
88
|
+
let failures = 0;
|
|
89
|
+
let locked = false;
|
|
90
|
+
const streams = new Set();
|
|
91
|
+
const watching = new Map();
|
|
92
|
+
let pending;
|
|
93
|
+
let everConnected = false;
|
|
94
|
+
const broadcast = () => {
|
|
95
|
+
if (pending)
|
|
96
|
+
return;
|
|
97
|
+
// coalesce: a streaming reply changes many times a second
|
|
98
|
+
pending = setTimeout(() => {
|
|
99
|
+
pending = undefined;
|
|
100
|
+
const data = JSON.stringify({ agents: host.sessions().map((s) => agentSummary(s, host.isUnread(s))) });
|
|
101
|
+
for (const res of streams)
|
|
102
|
+
res.write(`event: agents\ndata: ${data}\n\n`);
|
|
103
|
+
}, 120);
|
|
104
|
+
};
|
|
105
|
+
const sync = () => {
|
|
106
|
+
const now = new Set(host.sessions());
|
|
107
|
+
for (const [s, off] of watching) {
|
|
108
|
+
if (!now.has(s)) {
|
|
109
|
+
off();
|
|
110
|
+
watching.delete(s);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
for (const s of now)
|
|
114
|
+
if (!watching.has(s))
|
|
115
|
+
watching.set(s, s.onChange(broadcast));
|
|
116
|
+
broadcast();
|
|
117
|
+
};
|
|
118
|
+
const authorized = (req, url, headerOnly) => {
|
|
119
|
+
if (locked)
|
|
120
|
+
return false;
|
|
121
|
+
const header = /^Bearer (.+)$/.exec(req.headers.authorization ?? "")?.[1];
|
|
122
|
+
const given = header ?? (headerOnly ? undefined : (url.searchParams.get("code") ?? undefined));
|
|
123
|
+
if (given && same(given.trim().toLowerCase(), code))
|
|
124
|
+
return true;
|
|
125
|
+
if (given !== undefined && ++failures >= MAX_FAILURES) {
|
|
126
|
+
locked = true;
|
|
127
|
+
host.notify(`:serve stopped answering after ${MAX_FAILURES} wrong access codes (:serve stop, then :serve, for a new one)`);
|
|
128
|
+
}
|
|
129
|
+
return false;
|
|
130
|
+
};
|
|
131
|
+
const send = (res, status, body) => {
|
|
132
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
133
|
+
res.end(JSON.stringify(body));
|
|
134
|
+
};
|
|
135
|
+
const readBody = (req) => new Promise((resolve, reject) => {
|
|
136
|
+
let size = 0;
|
|
137
|
+
const chunks = [];
|
|
138
|
+
req.on("data", (c) => {
|
|
139
|
+
size += c.length;
|
|
140
|
+
if (size > MAX_PROMPT * 4) {
|
|
141
|
+
reject(new Error("too large"));
|
|
142
|
+
req.destroy();
|
|
143
|
+
}
|
|
144
|
+
else
|
|
145
|
+
chunks.push(c);
|
|
146
|
+
});
|
|
147
|
+
req.on("end", () => {
|
|
148
|
+
try {
|
|
149
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}"));
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
reject(new Error("not JSON"));
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
req.on("error", reject);
|
|
156
|
+
});
|
|
157
|
+
const server = http.createServer(async (req, res) => {
|
|
158
|
+
const url = new URL(req.url ?? "/", "http://vigent");
|
|
159
|
+
const route = `${req.method} ${url.pathname}`;
|
|
160
|
+
try {
|
|
161
|
+
if (route === "GET /" || route === "GET /index.html") {
|
|
162
|
+
res.writeHead(200, {
|
|
163
|
+
"content-type": "text/html; charset=utf-8",
|
|
164
|
+
"cache-control": "no-store",
|
|
165
|
+
"content-security-policy": "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; img-src data:",
|
|
166
|
+
"referrer-policy": "no-referrer",
|
|
167
|
+
});
|
|
168
|
+
res.end(servePage(host.project(), host.palette()));
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (!url.pathname.startsWith("/api/"))
|
|
172
|
+
return send(res, 404, { error: "not found" });
|
|
173
|
+
// reads may carry the code in the URL (EventSource can't set headers); changes need the header
|
|
174
|
+
const writes = req.method !== "GET";
|
|
175
|
+
if (!authorized(req, url, writes))
|
|
176
|
+
return send(res, locked ? 423 : 401, { error: locked ? "locked: start :serve again in vigent" : "wrong access code" });
|
|
177
|
+
if (route === "GET /api/state") {
|
|
178
|
+
return send(res, 200, { project: host.project(), agents: host.sessions().map((s) => agentSummary(s, host.isUnread(s))) });
|
|
179
|
+
}
|
|
180
|
+
if (route === "GET /api/events") {
|
|
181
|
+
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive" });
|
|
182
|
+
res.write(": vigent\n\n");
|
|
183
|
+
streams.add(res);
|
|
184
|
+
if (!everConnected) {
|
|
185
|
+
everConnected = true;
|
|
186
|
+
host.notify("a phone is following (:serve)");
|
|
187
|
+
}
|
|
188
|
+
const beat = setInterval(() => res.write(": beat\n\n"), 15_000);
|
|
189
|
+
req.on("close", () => {
|
|
190
|
+
clearInterval(beat);
|
|
191
|
+
streams.delete(res);
|
|
192
|
+
});
|
|
193
|
+
broadcast();
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (route === "POST /api/agents") {
|
|
197
|
+
const body = await readBody(req);
|
|
198
|
+
const text = typeof body.text === "string" ? body.text.trim() : "";
|
|
199
|
+
if (!text || text.length > MAX_PROMPT)
|
|
200
|
+
return send(res, 400, { error: "a task, up to 20000 characters" });
|
|
201
|
+
const s = host.startAgent(text);
|
|
202
|
+
if (!s)
|
|
203
|
+
return send(res, 409, { error: "couldn't start an agent (see vigent)" });
|
|
204
|
+
s.submit(text);
|
|
205
|
+
host.notify(`'${s.name}' started from your phone`);
|
|
206
|
+
sync();
|
|
207
|
+
return send(res, 200, { id: s.id });
|
|
208
|
+
}
|
|
209
|
+
const m = /^\/api\/agents\/(\d+)(\/prompt|\/stop)?$/.exec(url.pathname);
|
|
210
|
+
const s = m ? host.sessions().find((x) => x.id === Number(m[1])) : undefined;
|
|
211
|
+
if (!m || !s)
|
|
212
|
+
return send(res, 404, { error: "no such agent" });
|
|
213
|
+
if (req.method === "GET" && !m[2]) {
|
|
214
|
+
return send(res, 200, { agent: agentSummary(s, host.isUnread(s)), items: s.items.map(itemJson) });
|
|
215
|
+
}
|
|
216
|
+
if (req.method === "POST" && m[2] === "/prompt") {
|
|
217
|
+
const body = await readBody(req);
|
|
218
|
+
const text = typeof body.text === "string" ? body.text.trim() : "";
|
|
219
|
+
if (!text || text.length > MAX_PROMPT)
|
|
220
|
+
return send(res, 400, { error: "a prompt, up to 20000 characters" });
|
|
221
|
+
const waiting = s.submit(text);
|
|
222
|
+
host.notify(`'${s.name}' got a prompt from your phone${waiting > 0 ? ` (queued, ${waiting} waiting)` : ""}`);
|
|
223
|
+
return send(res, 200, { queued: waiting });
|
|
224
|
+
}
|
|
225
|
+
if (req.method === "POST" && m[2] === "/stop") {
|
|
226
|
+
if (s.state === "running")
|
|
227
|
+
s.abort();
|
|
228
|
+
return send(res, 200, { state: s.state });
|
|
229
|
+
}
|
|
230
|
+
return send(res, 405, { error: "not allowed" });
|
|
231
|
+
}
|
|
232
|
+
catch (err) {
|
|
233
|
+
if (!res.headersSent)
|
|
234
|
+
send(res, 400, { error: String(err.message ?? err) });
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
return new Promise((resolve, reject) => {
|
|
238
|
+
server.once("error", reject);
|
|
239
|
+
server.listen(opts.port ?? 8377, bind, () => {
|
|
240
|
+
server.off("error", reject);
|
|
241
|
+
const port = server.address().port;
|
|
242
|
+
const local = bind === "127.0.0.1" || bind === "localhost";
|
|
243
|
+
const urls = [...(local ? [] : lanAddresses().map((a) => `http://${a}:${port}/`)), `http://127.0.0.1:${port}/`];
|
|
244
|
+
sync();
|
|
245
|
+
resolve({
|
|
246
|
+
port,
|
|
247
|
+
host: bind,
|
|
248
|
+
code,
|
|
249
|
+
urls,
|
|
250
|
+
clients: () => streams.size,
|
|
251
|
+
sync,
|
|
252
|
+
close: () => new Promise((done) => {
|
|
253
|
+
if (pending)
|
|
254
|
+
clearTimeout(pending);
|
|
255
|
+
for (const off of watching.values())
|
|
256
|
+
off();
|
|
257
|
+
watching.clear();
|
|
258
|
+
for (const r of streams)
|
|
259
|
+
r.end();
|
|
260
|
+
streams.clear();
|
|
261
|
+
server.close(() => done());
|
|
262
|
+
server.closeAllConnections?.();
|
|
263
|
+
}),
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
}
|