@vigent/cli 0.2.2 → 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 +86 -28
- package/dist/agents.js +29 -0
- package/dist/app.js +565 -21
- 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 +4 -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 +92 -47
- package/dist/serve-page.js +247 -0
- package/dist/serve.js +267 -0
- package/dist/session.js +145 -10
- 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
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
|
+
}
|
package/dist/session.js
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session layer: one Session per agent.
|
|
3
|
+
*
|
|
4
|
+
* - PiSession runs a real pi coding agent in-process, with its read / write /
|
|
5
|
+
* edit / bash tools rooted in the agent's own git worktree.
|
|
6
|
+
* - DemoSession replays a scripted transcript so the UI can be explored
|
|
7
|
+
* without API keys.
|
|
8
|
+
*/
|
|
9
|
+
import { expandPrompt } from "./attach.js";
|
|
10
|
+
import { loadPiAi, loadPiSdk } from "./pi-sdk.js";
|
|
11
|
+
import { extensionUi, piResources } from "./pi-ui.js";
|
|
1
12
|
import { flattenTree } from "./tree.js";
|
|
2
13
|
import { diffStat } from "./worktree.js";
|
|
3
14
|
export const STATE_GLYPH = {
|
|
@@ -20,6 +31,8 @@ export class Session {
|
|
|
20
31
|
state = "idle";
|
|
21
32
|
items = [];
|
|
22
33
|
tokens = 0;
|
|
34
|
+
/** money spent, in US dollars, as the engine reports it; undefined = the engine doesn't say (Codex) */
|
|
35
|
+
cost;
|
|
23
36
|
version = 0; // bumped on every change, used for render caching
|
|
24
37
|
listeners = new Set();
|
|
25
38
|
statTimer;
|
|
@@ -244,7 +257,7 @@ export class PiSession extends Session {
|
|
|
244
257
|
this.ready = this.init();
|
|
245
258
|
}
|
|
246
259
|
async init() {
|
|
247
|
-
const sdk = await
|
|
260
|
+
const sdk = await loadPiSdk();
|
|
248
261
|
const sessionManager = this.opts.sessionDir
|
|
249
262
|
? sdk.SessionManager.continueRecent(this.cwd, this.opts.sessionDir)
|
|
250
263
|
: sdk.SessionManager.inMemory();
|
|
@@ -263,22 +276,28 @@ export class PiSession extends Session {
|
|
|
263
276
|
...(this.opts.agentDir ? { agentDir: this.opts.agentDir } : {}),
|
|
264
277
|
});
|
|
265
278
|
this.session = session;
|
|
279
|
+
await this.bindExtensions(sdk, session);
|
|
266
280
|
if (resuming) {
|
|
267
281
|
const restored = itemsFromMessages(session.messages ?? []);
|
|
268
282
|
this.items.push(...restored.items);
|
|
269
283
|
this.tokens += restored.tokens;
|
|
284
|
+
if (restored.cost !== undefined)
|
|
285
|
+
this.cost = (this.cost ?? 0) + restored.cost;
|
|
270
286
|
this.pushInfo(`resumed: ${restored.items.filter((i) => i.kind === "user").length} earlier prompts`);
|
|
271
287
|
this.refreshStat(0);
|
|
272
288
|
}
|
|
273
289
|
session.subscribe((event) => this.onEvent(event));
|
|
274
290
|
}
|
|
291
|
+
/** the run ended on a failed request (shown as an error, not as silence) */
|
|
292
|
+
failed = false;
|
|
275
293
|
onEvent(event) {
|
|
276
294
|
switch (event.type) {
|
|
277
295
|
case "agent_start":
|
|
296
|
+
this.failed = false;
|
|
278
297
|
this.setState("running");
|
|
279
298
|
break;
|
|
280
299
|
case "agent_end":
|
|
281
|
-
this.setState("idle");
|
|
300
|
+
this.setState(this.failed ? "error" : "idle");
|
|
282
301
|
this.refreshStat(0);
|
|
283
302
|
break;
|
|
284
303
|
case "message_start": {
|
|
@@ -303,12 +322,21 @@ export class PiSession extends Session {
|
|
|
303
322
|
last.text = extractText(event.message.content);
|
|
304
323
|
last.streaming = false;
|
|
305
324
|
const usage = event.message?.usage;
|
|
306
|
-
if (usage)
|
|
307
|
-
this.tokens += (usage
|
|
325
|
+
if (usage) {
|
|
326
|
+
this.tokens += usageTokens(usage);
|
|
327
|
+
if (typeof usage.cost?.total === "number")
|
|
328
|
+
this.cost = (this.cost ?? 0) + usage.cost.total;
|
|
329
|
+
}
|
|
308
330
|
if (!last.text)
|
|
309
331
|
this.items.pop(); // a turn that only called tools
|
|
310
332
|
this.emit();
|
|
311
333
|
}
|
|
334
|
+
// a failed request ends the reply with an error instead of throwing: say so
|
|
335
|
+
const err = piError(event.message);
|
|
336
|
+
if (err) {
|
|
337
|
+
this.failed = true;
|
|
338
|
+
this.pushInfo(err);
|
|
339
|
+
}
|
|
312
340
|
break;
|
|
313
341
|
}
|
|
314
342
|
case "tool_execution_start": {
|
|
@@ -368,15 +396,93 @@ export class PiSession extends Session {
|
|
|
368
396
|
this.emit();
|
|
369
397
|
return { editorText: r.editorText };
|
|
370
398
|
}
|
|
399
|
+
/** where this agent's extensions show dialogs and messages (set by the app) */
|
|
400
|
+
uiHost;
|
|
401
|
+
/** statuses extensions keep up to date (ctx.ui.setStatus), shown in the winbar */
|
|
402
|
+
extensionStatus = new Map();
|
|
403
|
+
/**
|
|
404
|
+
* Hand pi's extensions their UI and start them (session_start), as pi's
|
|
405
|
+
* own modes do. Without this, an extension's confirm() always answers no
|
|
406
|
+
* and its notify() goes nowhere.
|
|
407
|
+
*/
|
|
408
|
+
async bindExtensions(sdk, session) {
|
|
409
|
+
const THEME = Symbol.for("@mariozechner/pi-coding-agent:theme");
|
|
410
|
+
try {
|
|
411
|
+
if (!globalThis[THEME])
|
|
412
|
+
sdk.initTheme?.(undefined, false);
|
|
413
|
+
}
|
|
414
|
+
catch {
|
|
415
|
+
// extensions that style text get plain text
|
|
416
|
+
}
|
|
417
|
+
const ui = extensionUi(() => this.uiHost ?? this.fallbackUi, () => globalThis[THEME]);
|
|
418
|
+
try {
|
|
419
|
+
await session.bindExtensions({
|
|
420
|
+
uiContext: ui,
|
|
421
|
+
commandContextActions: {
|
|
422
|
+
waitForIdle: () => session.agent.waitForIdle(),
|
|
423
|
+
// one conversation per agent in vigent: :new starts another agent
|
|
424
|
+
newSession: async () => ({ cancelled: true }),
|
|
425
|
+
fork: async () => ({ cancelled: true }),
|
|
426
|
+
switchSession: async () => ({ cancelled: true }),
|
|
427
|
+
navigateTree: async (targetId) => {
|
|
428
|
+
const r = await session.navigateTree(targetId, {});
|
|
429
|
+
return { cancelled: r.cancelled };
|
|
430
|
+
},
|
|
431
|
+
reload: async () => {
|
|
432
|
+
await session.reload();
|
|
433
|
+
},
|
|
434
|
+
},
|
|
435
|
+
onError: (err) => this.pushInfo(`extension error (${shortExtPath(err?.extensionPath)}${err?.event ? `, ${err.event}` : ""}): ${err?.error ?? err}`),
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
catch (err) {
|
|
439
|
+
this.pushInfo(`pi extensions didn't start: ${String(err?.message ?? err)}`);
|
|
440
|
+
}
|
|
441
|
+
const failed = piResources(session).extensions.filter((e) => e.error);
|
|
442
|
+
for (const e of failed)
|
|
443
|
+
this.pushInfo(`extension ${shortExtPath(e.path)} didn't load: ${e.error}`);
|
|
444
|
+
}
|
|
445
|
+
/** with no screen to ask on: messages still reach the transcript, dialogs answer no */
|
|
446
|
+
fallbackUi = {
|
|
447
|
+
select: async () => undefined,
|
|
448
|
+
confirm: async () => false,
|
|
449
|
+
input: async () => undefined,
|
|
450
|
+
notify: (message) => this.pushInfo(message),
|
|
451
|
+
setStatus: (key, text) => this.setExtensionStatus(key, text),
|
|
452
|
+
};
|
|
453
|
+
setExtensionStatus(key, text) {
|
|
454
|
+
if (text)
|
|
455
|
+
this.extensionStatus.set(key, text);
|
|
456
|
+
else
|
|
457
|
+
this.extensionStatus.delete(key);
|
|
458
|
+
this.emit();
|
|
459
|
+
}
|
|
460
|
+
/** What pi loaded for this agent: extensions, their commands, skills, prompt templates, AGENTS.md files. */
|
|
461
|
+
resources() {
|
|
462
|
+
return this.session ? piResources(this.session) : undefined;
|
|
463
|
+
}
|
|
371
464
|
send(text) {
|
|
372
465
|
this.pushUser(text);
|
|
373
466
|
this.setState("running");
|
|
467
|
+
const attached = expandPrompt(text, "pi", this.cwd);
|
|
468
|
+
if (attached.skipped.length)
|
|
469
|
+
this.pushInfo(`not attached: ${attached.skipped.join(", ")}`);
|
|
374
470
|
this.ready
|
|
375
471
|
// pi reports agent_end a moment before it accepts the next prompt (queued prompts hit this)
|
|
376
472
|
.then(() => this.session.agent?.waitForIdle?.())
|
|
377
|
-
.then(() => this.session.prompt(
|
|
473
|
+
.then(() => this.session.prompt(attached.prompt))
|
|
474
|
+
// an extension command (/name) or a handled input runs no turn: nothing else will end it
|
|
475
|
+
.then(() => {
|
|
476
|
+
if (this.state === "running")
|
|
477
|
+
this.setState("idle");
|
|
478
|
+
})
|
|
378
479
|
.catch((err) => {
|
|
379
|
-
|
|
480
|
+
const text = String(err?.message ?? err);
|
|
481
|
+
// pi's own advice (/login, its docs paths) is for pi's TUI, not vigent: keep its first line
|
|
482
|
+
const key = /api key|login|auth/i.test(text);
|
|
483
|
+
this.pushInfo(`error: ${key ? text.split("\n")[0] : text}`);
|
|
484
|
+
if (key)
|
|
485
|
+
this.pushInfo(PI_KEY_HINT);
|
|
380
486
|
this.setState("error");
|
|
381
487
|
});
|
|
382
488
|
}
|
|
@@ -394,6 +500,7 @@ export function itemsFromMessages(messages) {
|
|
|
394
500
|
const items = [];
|
|
395
501
|
const byCallId = new Map();
|
|
396
502
|
let tokens = 0;
|
|
503
|
+
let cost;
|
|
397
504
|
for (const m of messages) {
|
|
398
505
|
if (m?.role === "user") {
|
|
399
506
|
const text = extractText(m.content);
|
|
@@ -417,8 +524,14 @@ export function itemsFromMessages(messages) {
|
|
|
417
524
|
if (b.id)
|
|
418
525
|
byCallId.set(b.id, item);
|
|
419
526
|
}
|
|
420
|
-
if (m.usage)
|
|
421
|
-
tokens += (m.usage
|
|
527
|
+
if (m.usage) {
|
|
528
|
+
tokens += usageTokens(m.usage);
|
|
529
|
+
if (typeof m.usage.cost?.total === "number")
|
|
530
|
+
cost = (cost ?? 0) + m.usage.cost.total;
|
|
531
|
+
}
|
|
532
|
+
const err = piError(m);
|
|
533
|
+
if (err)
|
|
534
|
+
items.push({ kind: "info", text: err });
|
|
422
535
|
}
|
|
423
536
|
else if (m?.role === "toolResult") {
|
|
424
537
|
const item = byCallId.get(m.toolCallId);
|
|
@@ -426,7 +539,24 @@ export function itemsFromMessages(messages) {
|
|
|
426
539
|
item.tool.output = extractText(m.content);
|
|
427
540
|
}
|
|
428
541
|
}
|
|
429
|
-
return { items, tokens };
|
|
542
|
+
return { items, tokens, cost };
|
|
543
|
+
}
|
|
544
|
+
/** pi's usage: every token the request read or wrote, cached ones included (like Claude Code's count) */
|
|
545
|
+
function usageTokens(u) {
|
|
546
|
+
return u.totalTokens ?? (u.input ?? 0) + (u.output ?? 0) + (u.cacheRead ?? 0) + (u.cacheWrite ?? 0);
|
|
547
|
+
}
|
|
548
|
+
/** What to do when pi can't get through: this agent stays on pi, so start one elsewhere. */
|
|
549
|
+
export const PI_KEY_HINT = "this agent runs on pi, and pi has no working API key. Agents keep the engine they started on: :new <task> starts one on the default engine (:engine picks it, e.g. Claude Code or Codex with your existing login), and :kill removes this one.";
|
|
550
|
+
/**
|
|
551
|
+
* The error of a reply whose request failed (pi ends it with stopReason
|
|
552
|
+
* "error" and an errorMessage), with a hint when the key or login was refused.
|
|
553
|
+
*/
|
|
554
|
+
export function piError(message) {
|
|
555
|
+
if (message?.role !== "assistant" || message.stopReason !== "error")
|
|
556
|
+
return undefined;
|
|
557
|
+
const text = String(message.errorMessage ?? "").trim() || "the request failed";
|
|
558
|
+
const auth = /\b(401|403)\b|auth|api[ _-]?key|unauthori[sz]ed|forbidden|credential|expired|login/i.test(text);
|
|
559
|
+
return auth ? `error: ${text}\n${PI_KEY_HINT}` : `error: ${text}`;
|
|
430
560
|
}
|
|
431
561
|
function extractText(content) {
|
|
432
562
|
if (typeof content === "string")
|
|
@@ -448,7 +578,7 @@ function summarizeArgs(args) {
|
|
|
448
578
|
}
|
|
449
579
|
/** A pi-ai model from "provider/model", or a bare id (Anthropic's). Undefined when pi doesn't know it. */
|
|
450
580
|
export async function piModel(id) {
|
|
451
|
-
const ai = await
|
|
581
|
+
const ai = await loadPiAi();
|
|
452
582
|
const slash = id.indexOf("/");
|
|
453
583
|
const [provider, modelId] = slash > 0 ? [id.slice(0, slash), id.slice(slash + 1)] : ["anthropic", id];
|
|
454
584
|
try {
|
|
@@ -458,3 +588,8 @@ export async function piModel(id) {
|
|
|
458
588
|
return undefined;
|
|
459
589
|
}
|
|
460
590
|
}
|
|
591
|
+
/** ~/.pi/agent/extensions/guard.ts → extensions/guard.ts */
|
|
592
|
+
function shortExtPath(p) {
|
|
593
|
+
const parts = String(p ?? "extension").split(/[\\/]/);
|
|
594
|
+
return parts.slice(-2).join("/");
|
|
595
|
+
}
|
package/dist/settings.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* Changes apply at once. A key row waits for the new keys: type them, then
|
|
8
8
|
* Enter (Esc cancels).
|
|
9
9
|
*/
|
|
10
|
-
import { truncateToWidth, visibleWidth } from "
|
|
10
|
+
import { truncateToWidth, visibleWidth } from "./tui.js";
|
|
11
11
|
import { keyFromInput } from "./keys.js";
|
|
12
12
|
import { RESET } from "./theme.js";
|
|
13
13
|
export class SettingsFloat {
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session templates: a prompt you start agents with often, with the engine,
|
|
3
|
+
* model and effort it should run on.
|
|
4
|
+
*
|
|
5
|
+
* [templates.fix]
|
|
6
|
+
* prompt = "Find and fix this bug: {task}. Reproduce it with a failing test first."
|
|
7
|
+
* engine = "claude"
|
|
8
|
+
* effort = "high"
|
|
9
|
+
*
|
|
10
|
+
* `:new -t fix the cart total is off` starts an agent and sends the prompt,
|
|
11
|
+
* with `{task}` replaced by what you typed. A few are built in; a template
|
|
12
|
+
* in config.toml with the same name replaces the built-in one.
|
|
13
|
+
*/
|
|
14
|
+
import { EFFORTS } from "./choices.js";
|
|
15
|
+
import { isEngineId } from "./engines.js";
|
|
16
|
+
export const BUILTIN_TEMPLATES = [
|
|
17
|
+
{
|
|
18
|
+
name: "fix",
|
|
19
|
+
description: "reproduce a bug with a failing test, then fix it",
|
|
20
|
+
prompt: "Find and fix this bug: {task}\n\nFirst reproduce it with a failing test. Then make the smallest change that makes the test pass, and run the whole test suite before you finish.",
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: "test",
|
|
24
|
+
description: "write tests in the project's own style",
|
|
25
|
+
prompt: "Write tests for {task}. Cover the usual cases and the edge cases, follow the style of the tests already in the project, and run them. Don't change the code under test; if a test finds a bug, say so.",
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: "review",
|
|
29
|
+
description: "review this branch's work; changes nothing",
|
|
30
|
+
prompt: "Review the work on this branch: compare it with the branch it started from using git. Look for bugs, missing tests and code that's hard to follow.{task}\n\nList what you find by file and line, most serious first. Don't change any files.",
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: "explain",
|
|
34
|
+
description: "how something works and where it lives; changes nothing",
|
|
35
|
+
prompt: "Explain {task}: how it works, where it lives in the code, and what would break if it changed. Don't change any files.",
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
/** Whether the prompt has a place for the task. */
|
|
39
|
+
export function needsTask(t) {
|
|
40
|
+
return t.prompt.includes("{task}");
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The prompt for a task. `{task}` takes the task; a template without one gets
|
|
44
|
+
* the task added after it. The review template's `{task}` is optional focus.
|
|
45
|
+
*/
|
|
46
|
+
export function fillTemplate(t, task) {
|
|
47
|
+
const text = task.trim();
|
|
48
|
+
if (!needsTask(t))
|
|
49
|
+
return text ? `${t.prompt}\n\n${text}` : t.prompt;
|
|
50
|
+
if (t.name === "review" && t.builtin)
|
|
51
|
+
return t.prompt.replace("{task}", text ? ` Focus on: ${text}.` : "");
|
|
52
|
+
return t.prompt.split("{task}").join(text);
|
|
53
|
+
}
|
|
54
|
+
/** Built-ins first, then yours (yours replace a built-in of the same name), in config order. */
|
|
55
|
+
export function allTemplates(user) {
|
|
56
|
+
const out = BUILTIN_TEMPLATES.map((t) => user[t.name] ?? { ...t, builtin: true });
|
|
57
|
+
for (const t of Object.values(user))
|
|
58
|
+
if (!BUILTIN_TEMPLATES.some((b) => b.name === t.name))
|
|
59
|
+
out.push(t);
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
/** `[templates]` from config.toml; problems become warnings, never errors. */
|
|
63
|
+
export function parseTemplates(value, warnings) {
|
|
64
|
+
const out = {};
|
|
65
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
66
|
+
warnings.push("config: [templates] should be a table");
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
for (const [name, raw] of Object.entries(value)) {
|
|
70
|
+
const where = `config [templates.${name}]`;
|
|
71
|
+
if (!/^[\w-]+$/.test(name)) {
|
|
72
|
+
warnings.push(`${where}: a template name is letters, digits, - and _`);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
76
|
+
warnings.push(`${where} should be a table`);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const r = raw;
|
|
80
|
+
if (typeof r.prompt !== "string" || !r.prompt.trim()) {
|
|
81
|
+
warnings.push(`${where}: 'prompt' is required`);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const t = { name, prompt: r.prompt.trim() };
|
|
85
|
+
for (const [k, v] of Object.entries(r)) {
|
|
86
|
+
switch (k) {
|
|
87
|
+
case "prompt":
|
|
88
|
+
break;
|
|
89
|
+
case "description":
|
|
90
|
+
case "model":
|
|
91
|
+
if (typeof v === "string")
|
|
92
|
+
t[k] = v;
|
|
93
|
+
else
|
|
94
|
+
warnings.push(`${where}: '${k}' should be a string`);
|
|
95
|
+
break;
|
|
96
|
+
case "engine":
|
|
97
|
+
if (typeof v === "string" && isEngineId(v))
|
|
98
|
+
t.engine = v;
|
|
99
|
+
else
|
|
100
|
+
warnings.push(`${where}: 'engine' should be "pi", "claude", "codex" or "opencode"`);
|
|
101
|
+
break;
|
|
102
|
+
case "worktree":
|
|
103
|
+
if (typeof v === "boolean")
|
|
104
|
+
t.worktree = v;
|
|
105
|
+
else
|
|
106
|
+
warnings.push(`${where}: 'worktree' should be true or false`);
|
|
107
|
+
break;
|
|
108
|
+
case "effort":
|
|
109
|
+
if (typeof v === "string")
|
|
110
|
+
t.effort = v;
|
|
111
|
+
else
|
|
112
|
+
warnings.push(`${where}: 'effort' should be a string`);
|
|
113
|
+
break;
|
|
114
|
+
default:
|
|
115
|
+
warnings.push(`${where}: unknown option '${k}'`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (t.effort && t.engine && !EFFORTS[t.engine].includes(t.effort)) {
|
|
119
|
+
warnings.push(`${where}: 'effort' should be one of ${EFFORTS[t.engine].join(", ")}`);
|
|
120
|
+
delete t.effort;
|
|
121
|
+
}
|
|
122
|
+
out[name] = t;
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
package/dist/theme.js
CHANGED
|
@@ -105,6 +105,8 @@ const LINKS = {
|
|
|
105
105
|
VigentSidebar: "NeoTreeNormal",
|
|
106
106
|
VigentIndent: "NeoTreeIndentMarker",
|
|
107
107
|
VigentUser: "Title",
|
|
108
|
+
/** `@path` in a prompt */
|
|
109
|
+
VigentMention: "Directory",
|
|
108
110
|
VigentTool: "Function",
|
|
109
111
|
VigentToolOutput: "Comment",
|
|
110
112
|
VigentSpinner: "WarningMsg",
|
package/dist/tui.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The parts of pi-tui vigent uses, imported from their own modules.
|
|
3
|
+
*
|
|
4
|
+
* pi-tui's index also loads its editor, markdown (marked), image and
|
|
5
|
+
* autocomplete components, none of which vigent draws; skipping them makes
|
|
6
|
+
* every start faster. Everything from pi-tui goes through this file, so a
|
|
7
|
+
* change in its layout is fixed here once.
|
|
8
|
+
*/
|
|
9
|
+
export { fuzzyFilter } from "@mariozechner/pi-tui/dist/fuzzy.js";
|
|
10
|
+
export { parseKey } from "@mariozechner/pi-tui/dist/keys.js";
|
|
11
|
+
export { ProcessTerminal } from "@mariozechner/pi-tui/dist/terminal.js";
|
|
12
|
+
export { TUI } from "@mariozechner/pi-tui/dist/tui.js";
|
|
13
|
+
export { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui/dist/utils.js";
|