@vigent/cli 0.0.0-stage → 0.2.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.
@@ -0,0 +1,537 @@
1
+ /**
2
+ * Agents run by a coding-agent CLI you already have: Claude Code, Codex or
3
+ * OpenCode.
4
+ *
5
+ * vigent starts the CLI once per prompt in the agent's worktree, in its
6
+ * non-interactive JSON mode, and turns the events it prints into the
7
+ * transcript. Each CLI keeps its own conversation; vigent only remembers the
8
+ * id it prints and passes it back to resume. The CLI uses its own login:
9
+ * vigent never reads its credentials.
10
+ */
11
+ import { spawn } from "node:child_process";
12
+ import * as fs from "node:fs";
13
+ import { ENGINE_BINS, ENGINE_NAMES } from "./engines.js";
14
+ import { Session } from "./session.js";
15
+ // ---------------------------------------------------------------------------
16
+ // Claude Code: claude -p --output-format stream-json
17
+ // ---------------------------------------------------------------------------
18
+ export const claudeAdapter = {
19
+ id: "claude",
20
+ bin: ENGINE_BINS.claude,
21
+ // -p can't ask before a tool runs; edits are allowed, other tools follow your Claude Code settings
22
+ defaultArgs: ["--permission-mode", "acceptEdits"],
23
+ args(prompt, o) {
24
+ return [
25
+ "-p",
26
+ "--output-format",
27
+ "stream-json",
28
+ "--verbose",
29
+ "--include-partial-messages",
30
+ ...(o.extraArgs ?? this.defaultArgs),
31
+ ...(o.model ? ["--model", o.model] : []),
32
+ ...(o.effort ? ["--effort", o.effort] : []),
33
+ ...(o.resumeId ? ["--resume", o.resumeId] : []),
34
+ "--",
35
+ prompt,
36
+ ];
37
+ },
38
+ parser() {
39
+ return (e) => {
40
+ if (!e || typeof e !== "object")
41
+ return [];
42
+ // a subagent's own messages: only its result reaches the transcript
43
+ if (e.parent_tool_use_id)
44
+ return [];
45
+ const out = [];
46
+ switch (e.type) {
47
+ case "system":
48
+ if (e.subtype === "init") {
49
+ if (e.session_id)
50
+ out.push({ type: "session", id: e.session_id });
51
+ if (e.model)
52
+ out.push({ type: "model", model: e.model });
53
+ }
54
+ break;
55
+ case "stream_event": {
56
+ const ev = e.event ?? {};
57
+ if (ev.type === "content_block_delta" && ev.delta?.type === "text_delta" && ev.delta.text) {
58
+ out.push({ type: "text_delta", text: ev.delta.text });
59
+ }
60
+ break;
61
+ }
62
+ case "assistant":
63
+ for (const b of contentOf(e.message)) {
64
+ if (b?.type === "text" && b.text)
65
+ out.push({ type: "text", text: b.text });
66
+ else if (b?.type === "tool_use")
67
+ out.push({ type: "tool", id: b.id, name: b.name, summary: summarize(b.input) });
68
+ }
69
+ break;
70
+ case "user":
71
+ for (const b of contentOf(e.message)) {
72
+ if (b?.type === "tool_result")
73
+ out.push({ type: "tool_result", id: b.tool_use_id, output: textOf(b.content) });
74
+ }
75
+ break;
76
+ case "result": {
77
+ const u = e.usage ?? {};
78
+ const tokens = (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.output_tokens ?? 0);
79
+ if (tokens)
80
+ out.push({ type: "usage", tokens });
81
+ if (e.session_id)
82
+ out.push({ type: "session", id: e.session_id });
83
+ const ok = !e.is_error && e.subtype === "success";
84
+ out.push({ type: "done", ok, ...(ok ? {} : { error: String(e.result ?? e.errors?.join?.("; ") ?? e.subtype ?? "failed") }) });
85
+ break;
86
+ }
87
+ }
88
+ return out;
89
+ };
90
+ },
91
+ };
92
+ // ---------------------------------------------------------------------------
93
+ // Codex: codex exec --json
94
+ // ---------------------------------------------------------------------------
95
+ export const codexAdapter = {
96
+ id: "codex",
97
+ bin: ENGINE_BINS.codex,
98
+ // edits and commands inside the worktree, nothing outside it
99
+ defaultArgs: ["-c", 'sandbox_mode="workspace-write"'],
100
+ args(prompt, o) {
101
+ return [
102
+ "exec",
103
+ ...(o.resumeId ? ["resume"] : []),
104
+ "--json",
105
+ "--skip-git-repo-check",
106
+ ...(o.extraArgs ?? this.defaultArgs),
107
+ ...(o.model ? ["-m", o.model] : []),
108
+ ...(o.effort ? ["-c", `model_reasoning_effort="${o.effort}"`] : []),
109
+ ...(o.resumeId ? [o.resumeId] : []),
110
+ "--",
111
+ prompt,
112
+ ];
113
+ },
114
+ parser() {
115
+ let lastError;
116
+ const started = new Set();
117
+ const tool = (item) => {
118
+ switch (item.type) {
119
+ case "command_execution":
120
+ return { type: "tool", id: item.id, name: "bash", summary: shellCommand(item.command) };
121
+ case "file_change":
122
+ return {
123
+ type: "tool",
124
+ id: item.id,
125
+ name: "edit",
126
+ summary: (item.changes ?? []).map((c) => `${c.kind === "add" ? "+" : c.kind === "delete" ? "-" : ""}${c.path}`).join(", "),
127
+ };
128
+ case "mcp_tool_call":
129
+ return { type: "tool", id: item.id, name: [item.server, item.tool].filter(Boolean).join("."), summary: summarize(item.arguments) };
130
+ case "web_search":
131
+ return { type: "tool", id: item.id, name: "web_search", summary: String(item.query ?? "") };
132
+ }
133
+ return undefined;
134
+ };
135
+ return (e) => {
136
+ if (!e || typeof e !== "object")
137
+ return [];
138
+ const out = [];
139
+ switch (e.type) {
140
+ case "thread.started":
141
+ if (e.thread_id)
142
+ out.push({ type: "session", id: e.thread_id });
143
+ break;
144
+ case "item.started": {
145
+ const t = e.item && tool(e.item);
146
+ if (t) {
147
+ started.add(e.item.id);
148
+ out.push(t);
149
+ }
150
+ break;
151
+ }
152
+ case "item.completed": {
153
+ const item = e.item ?? {};
154
+ if (item.type === "agent_message" && item.text) {
155
+ out.push({ type: "text", text: item.text });
156
+ break;
157
+ }
158
+ const t = tool(item);
159
+ if (!t)
160
+ break;
161
+ if (!started.has(item.id))
162
+ out.push(t);
163
+ const output = item.type === "command_execution"
164
+ ? `${String(item.aggregated_output ?? "").trimEnd()}${item.exit_code ? `\n(exit ${item.exit_code})` : ""}`.trim()
165
+ : item.type === "mcp_tool_call"
166
+ ? textOf(item.result?.content ?? item.error?.message ?? "")
167
+ : item.status === "failed"
168
+ ? "failed"
169
+ : "";
170
+ out.push({ type: "tool_result", id: item.id, output });
171
+ break;
172
+ }
173
+ case "turn.completed": {
174
+ const u = e.usage ?? {};
175
+ const tokens = (u.input_tokens ?? 0) + (u.output_tokens ?? 0);
176
+ if (tokens)
177
+ out.push({ type: "usage", tokens });
178
+ out.push({ type: "done", ok: true });
179
+ break;
180
+ }
181
+ case "turn.failed":
182
+ out.push({ type: "done", ok: false, error: e.error?.message ?? lastError ?? "turn failed" });
183
+ break;
184
+ case "error":
185
+ // "Reconnecting... 2/5" is codex retrying; only the final error matters
186
+ if (typeof e.message === "string" && !/^Reconnecting\.\.\./.test(e.message))
187
+ lastError = e.message;
188
+ break;
189
+ }
190
+ return out;
191
+ };
192
+ },
193
+ };
194
+ // ---------------------------------------------------------------------------
195
+ // OpenCode: opencode run --format json
196
+ // ---------------------------------------------------------------------------
197
+ export const opencodeAdapter = {
198
+ id: "opencode",
199
+ bin: ENGINE_BINS.opencode,
200
+ defaultArgs: [],
201
+ args(prompt, o) {
202
+ return [
203
+ "run",
204
+ "--format",
205
+ "json",
206
+ "--dir",
207
+ o.cwd,
208
+ ...(o.extraArgs ?? this.defaultArgs),
209
+ ...(o.model ? ["-m", o.model] : []),
210
+ ...(o.effort ? ["--variant", o.effort] : []),
211
+ ...(o.resumeId ? ["--session", o.resumeId] : []),
212
+ "--",
213
+ prompt,
214
+ ];
215
+ },
216
+ parser() {
217
+ let sessionId;
218
+ return (e) => {
219
+ if (!e || typeof e !== "object")
220
+ return [];
221
+ const out = [];
222
+ if (e.sessionID && e.sessionID !== sessionId) {
223
+ sessionId = e.sessionID;
224
+ out.push({ type: "session", id: e.sessionID });
225
+ }
226
+ const part = e.part ?? {};
227
+ switch (e.type) {
228
+ case "text":
229
+ if (part.text?.trim())
230
+ out.push({ type: "text", text: part.text.trim() });
231
+ break;
232
+ case "tool_use": {
233
+ const st = part.state ?? {};
234
+ out.push({ type: "tool", id: part.callID ?? part.id, name: part.tool ?? "tool", summary: st.title || summarize(st.input) });
235
+ out.push({ type: "tool_result", id: part.callID ?? part.id, output: String(st.output ?? st.error ?? "") });
236
+ break;
237
+ }
238
+ case "step_finish": {
239
+ const t = part.tokens ?? {};
240
+ const tokens = (t.input ?? 0) + (t.output ?? 0) + (t.reasoning ?? 0) + (t.cache?.read ?? 0) + (t.cache?.write ?? 0);
241
+ if (tokens)
242
+ out.push({ type: "usage", tokens });
243
+ break;
244
+ }
245
+ case "error": {
246
+ const err = e.error ?? {};
247
+ out.push({ type: "error", message: String(err.data?.message ?? err.message ?? err.name ?? "error") });
248
+ break;
249
+ }
250
+ }
251
+ return out;
252
+ };
253
+ },
254
+ };
255
+ export const CLI_ADAPTERS = {
256
+ claude: claudeAdapter,
257
+ codex: codexAdapter,
258
+ opencode: opencodeAdapter,
259
+ };
260
+ export class CliSession extends Session {
261
+ opts;
262
+ cwd;
263
+ resumeId;
264
+ adapter;
265
+ child;
266
+ aborted = false;
267
+ /** the model the user asked for; undefined = the CLI's own default */
268
+ requestedModel;
269
+ constructor(name, engine, model, opts) {
270
+ super(name, model ?? ENGINE_NAMES[engine], opts.worktree);
271
+ this.opts = opts;
272
+ this.engine = engine;
273
+ this.adapter = CLI_ADAPTERS[engine];
274
+ this.cwd = opts.cwd;
275
+ this.requestedModel = model;
276
+ this.effort = opts.effort;
277
+ this.resumeId = opts.resumeId;
278
+ if (opts.resumeId) {
279
+ this.pushInfo(`continuing the ${ENGINE_NAMES[engine]} conversation ${opts.resumeId.slice(0, 8)}… (earlier messages live in ${ENGINE_NAMES[engine]}'s history)`);
280
+ this.refreshStat(0);
281
+ }
282
+ }
283
+ get engineName() {
284
+ return ENGINE_NAMES[this.engine];
285
+ }
286
+ /** The model for the next prompt; undefined (or "default") = the CLI's own. */
287
+ setModel(model) {
288
+ this.requestedModel = model && model !== "default" ? model : undefined;
289
+ this.model = this.requestedModel ?? ENGINE_NAMES[this.engine];
290
+ this.emit();
291
+ }
292
+ /** Reasoning effort for the next prompt; undefined = the CLI's own. */
293
+ setEffort(effort) {
294
+ this.effort = effort && effort !== "default" ? effort : undefined;
295
+ this.emit();
296
+ }
297
+ /** what was asked for (vs. what the CLI reported it used) */
298
+ get chosenModel() {
299
+ return this.requestedModel;
300
+ }
301
+ send(text) {
302
+ this.pushUser(text);
303
+ this.setState("running");
304
+ this.aborted = false;
305
+ const args = this.adapter.args(text, {
306
+ cwd: this.cwd,
307
+ model: this.requestedModel,
308
+ effort: this.effort,
309
+ resumeId: this.resumeId,
310
+ extraArgs: this.opts.extraArgs,
311
+ });
312
+ let child;
313
+ try {
314
+ child = spawn(this.opts.bin ?? this.adapter.bin, args, {
315
+ cwd: this.cwd,
316
+ env: this.opts.env ?? process.env,
317
+ stdio: ["ignore", "pipe", "pipe"],
318
+ });
319
+ }
320
+ catch (err) {
321
+ this.fail(`couldn't start ${this.adapter.bin}: ${err?.message ?? err}`);
322
+ return;
323
+ }
324
+ this.child = child;
325
+ const parse = this.adapter.parser();
326
+ let buf = "";
327
+ let stderr = "";
328
+ let result;
329
+ let lastError;
330
+ const tools = new Map();
331
+ const handle = (ev) => {
332
+ switch (ev.type) {
333
+ case "session":
334
+ if (ev.id !== this.resumeId) {
335
+ this.resumeId = ev.id;
336
+ this.opts.onResumeId?.(ev.id);
337
+ }
338
+ break;
339
+ case "model":
340
+ if (!this.requestedModel)
341
+ this.model = ev.model;
342
+ this.emit();
343
+ break;
344
+ case "text_delta": {
345
+ const last = this.items.at(-1);
346
+ if (last?.kind === "agent" && last.streaming)
347
+ last.text += ev.text;
348
+ else
349
+ this.items.push({ kind: "agent", text: ev.text, streaming: true });
350
+ this.emit();
351
+ break;
352
+ }
353
+ case "text": {
354
+ const last = this.items.at(-1);
355
+ if (last?.kind === "agent" && last.streaming) {
356
+ last.text = ev.text;
357
+ last.streaming = false;
358
+ }
359
+ else
360
+ this.items.push({ kind: "agent", text: ev.text });
361
+ this.emit();
362
+ break;
363
+ }
364
+ case "tool": {
365
+ this.endStreaming();
366
+ const summary = this.relative(ev.summary).slice(0, 80);
367
+ const item = { kind: "tool", text: "", tool: { name: ev.name, summary, output: "", folded: true } };
368
+ this.items.push(item);
369
+ if (ev.id)
370
+ tools.set(ev.id, item);
371
+ this.emit();
372
+ break;
373
+ }
374
+ case "tool_result": {
375
+ const item = (ev.id && tools.get(ev.id)) || [...this.items].reverse().find((i) => i.kind === "tool");
376
+ if (item?.tool)
377
+ item.tool.output = ev.output;
378
+ this.emit();
379
+ this.refreshStat();
380
+ break;
381
+ }
382
+ case "usage":
383
+ this.tokens += ev.tokens;
384
+ this.emit();
385
+ break;
386
+ case "error":
387
+ lastError = ev.message;
388
+ break;
389
+ case "done":
390
+ result = ev;
391
+ break;
392
+ }
393
+ };
394
+ child.stdout.setEncoding("utf8");
395
+ child.stdout.on("data", (chunk) => {
396
+ buf += chunk;
397
+ let nl;
398
+ while ((nl = buf.indexOf("\n")) >= 0) {
399
+ const line = buf.slice(0, nl).trim();
400
+ buf = buf.slice(nl + 1);
401
+ if (!line.startsWith("{"))
402
+ continue;
403
+ let json;
404
+ try {
405
+ json = JSON.parse(line);
406
+ }
407
+ catch {
408
+ continue; // not an event (a stray log line)
409
+ }
410
+ for (const ev of parse(json))
411
+ handle(ev);
412
+ }
413
+ });
414
+ child.stderr.setEncoding("utf8");
415
+ child.stderr.on("data", (chunk) => {
416
+ stderr = (stderr + chunk).slice(-4000);
417
+ });
418
+ child.on("error", (err) => {
419
+ this.child = undefined;
420
+ this.fail(err.code === "ENOENT"
421
+ ? `${this.adapter.bin} isn't installed (or isn't on PATH) · :checkhealth`
422
+ : `${this.adapter.bin}: ${err.message}`);
423
+ });
424
+ child.on("close", (code, signal) => {
425
+ if (this.child !== child)
426
+ return; // already failed to start
427
+ this.child = undefined;
428
+ this.endStreaming();
429
+ this.refreshStat(0);
430
+ if (this.aborted) {
431
+ this.setState("idle");
432
+ return;
433
+ }
434
+ const ok = result ? result.ok : code === 0 && !lastError;
435
+ if (ok) {
436
+ this.setState("idle");
437
+ return;
438
+ }
439
+ const why = result?.error ??
440
+ lastError ??
441
+ lastLines(stderr) ??
442
+ (signal ? `stopped by ${signal}` : `exited with code ${code}`);
443
+ this.fail(`${this.engineName}: ${why}`);
444
+ });
445
+ }
446
+ /** paths inside the agent's folder, shown relative to it */
447
+ relative(text) {
448
+ let out = text;
449
+ for (const dir of new Set([this.cwd, safeRealpath(this.cwd)])) {
450
+ if (dir)
451
+ out = out.split(`${dir.replace(/\/+$/, "")}/`).join("");
452
+ }
453
+ return out;
454
+ }
455
+ endStreaming() {
456
+ const last = this.items.at(-1);
457
+ if (last?.kind === "agent" && last.streaming) {
458
+ last.streaming = false;
459
+ if (!last.text)
460
+ this.items.pop();
461
+ }
462
+ }
463
+ fail(message) {
464
+ this.endStreaming();
465
+ this.pushInfo(`error: ${message}`);
466
+ this.setState("error");
467
+ }
468
+ abort() {
469
+ const child = this.child;
470
+ if (child) {
471
+ this.aborted = true;
472
+ child.kill("SIGINT");
473
+ const t = setTimeout(() => {
474
+ if (child.exitCode === null && child.signalCode === null)
475
+ child.kill("SIGTERM");
476
+ }, 2000);
477
+ t.unref();
478
+ }
479
+ this.setState("idle");
480
+ }
481
+ dispose() {
482
+ super.dispose();
483
+ if (this.child) {
484
+ this.aborted = true;
485
+ this.child.kill("SIGTERM");
486
+ }
487
+ }
488
+ }
489
+ // ---------------------------------------------------------------------------
490
+ // helpers
491
+ // ---------------------------------------------------------------------------
492
+ function safeRealpath(p) {
493
+ try {
494
+ return fs.realpathSync(p);
495
+ }
496
+ catch {
497
+ return undefined;
498
+ }
499
+ }
500
+ function contentOf(message) {
501
+ return Array.isArray(message?.content) ? message.content : [];
502
+ }
503
+ function textOf(content) {
504
+ if (typeof content === "string")
505
+ return content;
506
+ if (!Array.isArray(content))
507
+ return content == null ? "" : JSON.stringify(content);
508
+ return content
509
+ .filter((b) => b?.type === "text" && typeof b.text === "string")
510
+ .map((b) => b.text)
511
+ .join("\n");
512
+ }
513
+ function summarize(args) {
514
+ if (!args)
515
+ return "";
516
+ if (typeof args === "string")
517
+ return args.slice(0, 80);
518
+ const v = args.command ?? args.file_path ?? args.filePath ?? args.path ?? args.pattern ?? args.url ?? args.description ?? JSON.stringify(args);
519
+ return String(v).slice(0, 80);
520
+ }
521
+ /** codex reports commands as `/bin/bash -lc "npm test"` (or an argv array); show the command itself. */
522
+ function shellCommand(cmd) {
523
+ const parts = Array.isArray(cmd) ? cmd.map(String) : undefined;
524
+ if (parts && parts.length >= 3 && /(^|\/)(ba|z)?sh$/.test(parts[0]) && /^-l?c$/.test(parts[1]))
525
+ return parts.slice(2).join(" ");
526
+ const s = parts ? parts.join(" ") : String(cmd ?? "");
527
+ const m = /^\S*\/(?:ba|z)?sh\s+-l?c\s+(['"])([\s\S]*)\1$/.exec(s);
528
+ return m ? m[2] : s;
529
+ }
530
+ function lastLines(stderr) {
531
+ const lines = stderr
532
+ .replace(/\x1b\[[0-9;]*m/g, "")
533
+ .split("\n")
534
+ .map((l) => l.trim())
535
+ .filter((l) => l && !/^Reading additional input from stdin/.test(l) && !/^Warning: no stdin data/.test(l));
536
+ return lines.length ? lines.slice(-2).join(" · ") : undefined;
537
+ }