@vigent/cli 0.2.3 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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 import("@mariozechner/pi-coding-agent");
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,10 +276,13 @@ 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
  }
@@ -306,8 +322,11 @@ export class PiSession extends Session {
306
322
  last.text = extractText(event.message.content);
307
323
  last.streaming = false;
308
324
  const usage = event.message?.usage;
309
- if (usage)
310
- this.tokens += (usage.input ?? 0) + (usage.output ?? 0);
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
+ }
311
330
  if (!last.text)
312
331
  this.items.pop(); // a turn that only called tools
313
332
  this.emit();
@@ -377,15 +396,93 @@ export class PiSession extends Session {
377
396
  this.emit();
378
397
  return { editorText: r.editorText };
379
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
+ }
380
464
  send(text) {
381
465
  this.pushUser(text);
382
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(", ")}`);
383
470
  this.ready
384
471
  // pi reports agent_end a moment before it accepts the next prompt (queued prompts hit this)
385
472
  .then(() => this.session.agent?.waitForIdle?.())
386
- .then(() => this.session.prompt(text))
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
+ })
387
479
  .catch((err) => {
388
- this.pushInfo(`error: ${err?.message ?? err}`);
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);
389
486
  this.setState("error");
390
487
  });
391
488
  }
@@ -403,6 +500,7 @@ export function itemsFromMessages(messages) {
403
500
  const items = [];
404
501
  const byCallId = new Map();
405
502
  let tokens = 0;
503
+ let cost;
406
504
  for (const m of messages) {
407
505
  if (m?.role === "user") {
408
506
  const text = extractText(m.content);
@@ -426,8 +524,11 @@ export function itemsFromMessages(messages) {
426
524
  if (b.id)
427
525
  byCallId.set(b.id, item);
428
526
  }
429
- if (m.usage)
430
- tokens += (m.usage.input ?? 0) + (m.usage.output ?? 0);
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
+ }
431
532
  const err = piError(m);
432
533
  if (err)
433
534
  items.push({ kind: "info", text: err });
@@ -438,8 +539,14 @@ export function itemsFromMessages(messages) {
438
539
  item.tool.output = extractText(m.content);
439
540
  }
440
541
  }
441
- return { items, tokens };
542
+ return { items, tokens, cost };
442
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.";
443
550
  /**
444
551
  * The error of a reply whose request failed (pi ends it with stopReason
445
552
  * "error" and an errorMessage), with a hint when the key or login was refused.
@@ -449,8 +556,7 @@ export function piError(message) {
449
556
  return undefined;
450
557
  const text = String(message.errorMessage ?? "").trim() || "the request failed";
451
558
  const auth = /\b(401|403)\b|auth|api[ _-]?key|unauthori[sz]ed|forbidden|credential|expired|login/i.test(text);
452
- const hint = auth ? " · pi's API key or login was refused; :checkhealth shows the engines, and :new <task> --engine claude starts one on Claude Code" : "";
453
- return `error: ${text}${hint}`;
559
+ return auth ? `error: ${text}\n${PI_KEY_HINT}` : `error: ${text}`;
454
560
  }
455
561
  function extractText(content) {
456
562
  if (typeof content === "string")
@@ -472,7 +578,7 @@ function summarizeArgs(args) {
472
578
  }
473
579
  /** A pi-ai model from "provider/model", or a bare id (Anthropic's). Undefined when pi doesn't know it. */
474
580
  export async function piModel(id) {
475
- const ai = await import("@mariozechner/pi-ai");
581
+ const ai = await loadPiAi();
476
582
  const slash = id.indexOf("/");
477
583
  const [provider, modelId] = slash > 0 ? [id.slice(0, slash), id.slice(slash + 1)] : ["anthropic", id];
478
584
  try {
@@ -482,3 +588,8 @@ export async function piModel(id) {
482
588
  return undefined;
483
589
  }
484
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 "@mariozechner/pi-tui";
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";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vigent/cli",
3
- "version": "0.2.3",
3
+ "version": "0.3.1",
4
4
  "description": "A vim-style terminal app for running coding agents side by side, each in its own git worktree. Built on pi.",
5
5
  "keywords": [
6
6
  "vim",
@@ -15,7 +15,7 @@
15
15
  "llm",
16
16
  "cli"
17
17
  ],
18
- "homepage": "https://github.com/farming-labs/vigent#readme",
18
+ "homepage": "https://vigent.farming-labs.dev",
19
19
  "bugs": {
20
20
  "url": "https://github.com/farming-labs/vigent/issues"
21
21
  },
@@ -27,7 +27,7 @@
27
27
  "author": "KinfeMichael Tariku",
28
28
  "type": "module",
29
29
  "bin": {
30
- "vigent": "./dist/cli.js"
30
+ "vigent": "./dist/main.js"
31
31
  },
32
32
  "files": [
33
33
  "dist",
@@ -48,8 +48,6 @@
48
48
  "node": ">=20"
49
49
  },
50
50
  "dependencies": {
51
- "@mariozechner/pi-ai": "^0.73.1",
52
- "@mariozechner/pi-coding-agent": "^0.73.1",
53
51
  "@mariozechner/pi-tui": "^0.73.1",
54
52
  "smol-toml": "^1.9.1"
55
53
  },
@@ -58,6 +56,8 @@
58
56
  "typescript": "^7.0.2"
59
57
  },
60
58
  "optionalDependencies": {
59
+ "@mariozechner/pi-ai": "^0.73.1",
60
+ "@mariozechner/pi-coding-agent": "^0.73.1",
61
61
  "riftri": "^0.6.7"
62
62
  },
63
63
  "publishConfig": {