@phren/agent 0.1.1 → 0.1.3

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.
@@ -1,6 +1,7 @@
1
1
  import { createSpinner, formatTurnHeader, formatToolCall } from "./spinner.js";
2
2
  import { searchErrorRecovery } from "./memory/error-recovery.js";
3
3
  import { shouldPrune, pruneMessages } from "./context/pruner.js";
4
+ import { estimateMessageTokens } from "./context/token-counter.js";
4
5
  import { withRetry } from "./providers/retry.js";
5
6
  import { createCaptureState, analyzeAndCapture } from "./memory/auto-capture.js";
6
7
  import { AntiPatternTracker } from "./memory/anti-patterns.js";
@@ -9,7 +10,6 @@ import { injectPlanPrompt, requestPlanApproval } from "./plan.js";
9
10
  import { detectLintCommand, detectTestCommand, runPostEditCheck } from "./tools/lint-test.js";
10
11
  import { createCheckpoint } from "./checkpoint.js";
11
12
  const MAX_TOOL_CONCURRENCY = 5;
12
- const MAX_LINT_TEST_RETRIES = 3;
13
13
  export function createSession(contextLimit) {
14
14
  return {
15
15
  messages: [],
@@ -76,7 +76,7 @@ async function consumeStream(stream, costTracker, onTextDelta) {
76
76
  try {
77
77
  input = JSON.parse(jsonStr);
78
78
  }
79
- catch (e) {
79
+ catch {
80
80
  process.stderr.write(`\x1b[33m[warning] Malformed tool_use JSON for ${tool.name} (${tool.id}), skipping block\x1b[0m\n`);
81
81
  continue;
82
82
  }
@@ -105,7 +105,6 @@ export async function runTurn(userInput, session, config, hooks) {
105
105
  const toolDefs = registry.getDefinitions();
106
106
  const spinner = createSpinner();
107
107
  const useStream = typeof provider.chatStream === "function";
108
- const write = hooks?.onTextDelta ?? process.stdout.write.bind(process.stdout);
109
108
  const status = hooks?.onStatus ?? ((msg) => process.stderr.write(msg));
110
109
  // Plan mode: modify system prompt for first turn
111
110
  let planPending = config.plan && session.turns === 0;
@@ -135,9 +134,15 @@ export async function runTurn(userInput, session, config, hooks) {
135
134
  }
136
135
  // Prune context if approaching limit
137
136
  if (shouldPrune(systemPrompt, session.messages, { contextLimit })) {
137
+ const preCount = session.messages.length;
138
+ const preTokens = estimateMessageTokens(session.messages);
138
139
  session.messages = pruneMessages(session.messages, { contextLimit, keepRecentTurns: 6 });
139
- if (verbose)
140
- status("[context pruned]\n");
140
+ const postCount = session.messages.length;
141
+ const postTokens = estimateMessageTokens(session.messages);
142
+ const reduction = preTokens > 0 ? ((1 - postTokens / preTokens) * 100).toFixed(0) : "0";
143
+ const fmtPre = preTokens >= 1000 ? `${(preTokens / 1000).toFixed(1)}k` : String(preTokens);
144
+ const fmtPost = postTokens >= 1000 ? `${(postTokens / 1000).toFixed(1)}k` : String(postTokens);
145
+ status(`\x1b[2m[context pruned: ${preCount} → ${postCount} messages, ~${fmtPre} → ~${fmtPost} tokens, ${reduction}% reduction]\x1b[0m\n`);
141
146
  }
142
147
  // For plan mode first turn, pass empty tools so LLM can't call any
143
148
  const turnTools = planPending ? [] : toolDefs;
@@ -67,37 +67,3 @@ export function createCheckpoint(cwd, label) {
67
67
  return null;
68
68
  }
69
69
  }
70
- /** Rollback to a checkpoint by discarding current changes and applying the stash. */
71
- export function rollbackToCheckpoint(cwd, ref) {
72
- if (!isGitRepo(cwd))
73
- return false;
74
- try {
75
- // Discard current working tree changes
76
- execFileSync("git", ["checkout", "."], {
77
- cwd,
78
- encoding: "utf-8",
79
- stdio: ["ignore", "pipe", "pipe"],
80
- });
81
- // Apply the stash ref
82
- execFileSync("git", ["stash", "apply", ref], {
83
- cwd,
84
- encoding: "utf-8",
85
- stdio: ["ignore", "pipe", "pipe"],
86
- });
87
- return true;
88
- }
89
- catch {
90
- return false;
91
- }
92
- }
93
- /** List stored checkpoints. */
94
- export function listCheckpoints(cwd) {
95
- return loadStore(cwd).checkpoints;
96
- }
97
- /** Get the latest checkpoint ref. */
98
- export function getLatestCheckpoint(cwd) {
99
- const store = loadStore(cwd);
100
- return store.checkpoints.length > 0
101
- ? store.checkpoints[store.checkpoints.length - 1].ref
102
- : null;
103
- }
package/dist/commands.js CHANGED
@@ -4,6 +4,16 @@ import { listPresets, loadPreset, savePreset, deletePreset, formatPreset } from
4
4
  import { renderMarkdown } from "./multi/markdown.js";
5
5
  import { showModelPicker } from "./multi/model-picker.js";
6
6
  import { formatProviderList, formatModelAddHelp, addCustomModel, removeCustomModel } from "./multi/provider-manager.js";
7
+ import { execSync } from "node:child_process";
8
+ import * as fs from "node:fs";
9
+ import * as path from "node:path";
10
+ import * as os from "node:os";
11
+ import { saveSessionMessages } from "./memory/session.js";
12
+ import { buildIndex } from "@phren/cli/shared";
13
+ import { searchKnowledgeRows, rankResults } from "@phren/cli/shared/retrieval";
14
+ import { readFindings } from "@phren/cli/data/access";
15
+ import { readTasks } from "@phren/cli/data/tasks";
16
+ import { addFinding } from "@phren/cli/core/finding";
7
17
  const DIM = "\x1b[2m";
8
18
  const BOLD = "\x1b[1m";
9
19
  const CYAN = "\x1b[36m";
@@ -19,6 +29,17 @@ export function createCommandContext(session, contextLimit) {
19
29
  undoStack: [],
20
30
  };
21
31
  }
32
+ /** Format elapsed milliseconds as human-readable duration. */
33
+ function formatElapsed(ms) {
34
+ const secs = Math.floor(ms / 1000);
35
+ if (secs < 60)
36
+ return `${secs}s`;
37
+ const mins = Math.floor(secs / 60);
38
+ if (mins < 60)
39
+ return `${mins}m ${secs % 60}s`;
40
+ const hrs = Math.floor(mins / 60);
41
+ return `${hrs}h ${mins % 60}m`;
42
+ }
22
43
  /** Truncate text to N lines, appending [+M lines] if overflow. */
23
44
  function truncateText(text, maxLines) {
24
45
  const lines = text.split("\n");
@@ -29,6 +50,7 @@ function truncateText(text, maxLines) {
29
50
  }
30
51
  /**
31
52
  * Try to handle a slash command. Returns true if the input was a command.
53
+ * Returns a Promise<boolean> for async commands like /ask.
32
54
  */
33
55
  export function handleCommand(input, ctx) {
34
56
  const parts = input.trim().split(/\s+/);
@@ -42,15 +64,28 @@ export function handleCommand(input, ctx) {
42
64
  /model remove <id> Remove a custom model
43
65
  /provider Show configured providers + auth status
44
66
  /turns Show turn and tool call counts
45
- /clear Clear conversation history
67
+ /clear Clear conversation history and terminal screen
68
+ /cwd Show current working directory
69
+ /files Quick file tree (max depth 2, first 30 files)
46
70
  /cost Show token usage and estimated cost
47
71
  /plan Show conversation plan (tool calls so far)
48
72
  /undo Undo last user message and response
49
73
  /history [n|full] Show last N messages (default 10) with rich formatting
50
74
  /compact Compact conversation to save context space
75
+ /context Show context window usage and provider info
51
76
  /mode Toggle input mode (steering ↔ queue)
52
77
  /spawn <name> <task> Spawn a background agent
53
78
  /agents List running agents
79
+ /session Show session info (id, duration, stats)
80
+ /session save Save conversation checkpoint
81
+ /session export Export conversation as JSON
82
+ /diff [--staged] Show git diff with syntax highlighting
83
+ /git <cmd> Run common git commands (status, log, stash, stash pop)
84
+ /ask <question> Quick LLM query (no tools, not added to session)
85
+ /mem search <query> Search phren memory directly
86
+ /mem findings [project] Show recent findings
87
+ /mem tasks [project] Show tasks
88
+ /mem add <finding> Quick-add a finding
54
89
  /preset [name|save|delete|list] Config presets
55
90
  /exit Exit the REPL${RESET}\n`);
56
91
  return true;
@@ -129,8 +164,32 @@ export function handleCommand(input, ctx) {
129
164
  ctx.session.turns = 0;
130
165
  ctx.session.toolCalls = 0;
131
166
  ctx.undoStack.length = 0;
167
+ process.stdout.write("\x1b[2J\x1b[H"); // clear terminal screen
132
168
  process.stderr.write(`${DIM}Conversation cleared.${RESET}\n`);
133
169
  return true;
170
+ case "/cwd":
171
+ process.stderr.write(`${DIM}${process.cwd()}${RESET}\n`);
172
+ return true;
173
+ case "/files": {
174
+ try {
175
+ const countRaw = execSync("find . -type f -not -path '*/node_modules/*' -not -path '*/.git/*' -not -path '*/dist/*' | wc -l", { encoding: "utf-8", timeout: 5_000, cwd: process.cwd() }).trim();
176
+ const total = parseInt(countRaw, 10) || 0;
177
+ const listRaw = execSync("find . -maxdepth 2 -type f -not -path '*/node_modules/*' -not -path '*/.git/*' -not -path '*/dist/*' | sort | head -30", { encoding: "utf-8", timeout: 5_000, cwd: process.cwd() }).trim();
178
+ if (!listRaw) {
179
+ process.stderr.write(`${DIM}No files found.${RESET}\n`);
180
+ }
181
+ else {
182
+ const lines = listRaw.split("\n");
183
+ const label = total > lines.length ? `${total} files (showing first ${lines.length})` : `${total} files`;
184
+ process.stderr.write(`${DIM}${label}\n${listRaw}${RESET}\n`);
185
+ }
186
+ }
187
+ catch (err) {
188
+ const e = err;
189
+ process.stderr.write(`${RED}${e.stderr || e.message || "find failed"}${RESET}\n`);
190
+ }
191
+ return true;
192
+ }
134
193
  case "/cost": {
135
194
  const ct = ctx.costTracker;
136
195
  if (ct) {
@@ -234,10 +293,34 @@ export function handleCommand(input, ctx) {
234
293
  return true;
235
294
  }
236
295
  case "/compact": {
237
- const before = ctx.session.messages.length;
296
+ const beforeCount = ctx.session.messages.length;
297
+ const beforeTokens = estimateMessageTokens(ctx.session.messages);
238
298
  ctx.session.messages = pruneMessages(ctx.session.messages, { contextLimit: ctx.contextLimit, keepRecentTurns: 4 });
239
- const after = ctx.session.messages.length;
240
- process.stderr.write(`${DIM}Compacted: ${before} ${after} messages.${RESET}\n`);
299
+ const afterCount = ctx.session.messages.length;
300
+ const afterTokens = estimateMessageTokens(ctx.session.messages);
301
+ const reduction = beforeTokens > 0 ? ((1 - afterTokens / beforeTokens) * 100).toFixed(0) : "0";
302
+ const fmtBefore = beforeTokens >= 1000 ? `${(beforeTokens / 1000).toFixed(1)}k` : String(beforeTokens);
303
+ const fmtAfter = afterTokens >= 1000 ? `${(afterTokens / 1000).toFixed(1)}k` : String(afterTokens);
304
+ process.stderr.write(`${DIM}Compacted: ${beforeCount} → ${afterCount} messages (~${fmtBefore} → ~${fmtAfter} tokens, ${reduction}% reduction)${RESET}\n`);
305
+ return true;
306
+ }
307
+ case "/context": {
308
+ const ctxTokens = estimateMessageTokens(ctx.session.messages);
309
+ const ctxPct = ctx.contextLimit > 0 ? (ctxTokens / ctx.contextLimit) * 100 : 0;
310
+ const ctxPctStr = ctxPct.toFixed(1);
311
+ const ctxWindowK = ctx.contextLimit >= 1000 ? `${(ctx.contextLimit / 1000).toFixed(0)}k` : String(ctx.contextLimit);
312
+ const ctxTokensStr = ctxTokens >= 1000 ? `~${(ctxTokens / 1000).toFixed(1)}k` : `~${ctxTokens}`;
313
+ // Progress bar: 10 chars wide
314
+ const filled = Math.round(ctxPct / 10);
315
+ const bar = "█".repeat(Math.min(filled, 10)) + "░".repeat(Math.max(10 - filled, 0));
316
+ const barColor = ctxPct > 80 ? RED : ctxPct > 50 ? YELLOW : GREEN;
317
+ const providerLabel = ctx.providerName ?? "unknown";
318
+ const modelLabel = ctx.currentModel ?? "default";
319
+ process.stderr.write(`${DIM} Messages: ${ctx.session.messages.length}\n` +
320
+ ` Tokens: ${ctxTokensStr} / ${ctxWindowK} (${ctxPctStr}%)\n` +
321
+ ` Provider: ${providerLabel} (${modelLabel})\n` +
322
+ ` Context window: ${ctxWindowK}\n` +
323
+ ` ${barColor}[${bar}]${RESET}${DIM} ${ctxPctStr}%${RESET}\n`);
241
324
  return true;
242
325
  }
243
326
  case "/spawn": {
@@ -346,6 +429,270 @@ export function handleCommand(input, ctx) {
346
429
  }
347
430
  return true;
348
431
  }
432
+ case "/session": {
433
+ const sub = parts[1]?.toLowerCase();
434
+ if (sub === "save") {
435
+ if (!ctx.phrenPath || !ctx.sessionId) {
436
+ process.stderr.write(`${DIM}No active phren session to save.${RESET}\n`);
437
+ return true;
438
+ }
439
+ try {
440
+ saveSessionMessages(ctx.phrenPath, ctx.sessionId, ctx.session.messages);
441
+ process.stderr.write(`${GREEN}→ Checkpoint saved (${ctx.session.messages.length} messages)${RESET}\n`);
442
+ }
443
+ catch (err) {
444
+ process.stderr.write(`${RED}${err instanceof Error ? err.message : String(err)}${RESET}\n`);
445
+ }
446
+ return true;
447
+ }
448
+ if (sub === "export") {
449
+ const exportDir = path.join(os.homedir(), ".phren-agent", "exports");
450
+ fs.mkdirSync(exportDir, { recursive: true });
451
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
452
+ const exportFile = path.join(exportDir, `session-${ts}.json`);
453
+ try {
454
+ fs.writeFileSync(exportFile, JSON.stringify(ctx.session.messages, null, 2) + "\n");
455
+ process.stderr.write(`${GREEN}→ Exported to ${exportFile}${RESET}\n`);
456
+ }
457
+ catch (err) {
458
+ process.stderr.write(`${RED}${err instanceof Error ? err.message : String(err)}${RESET}\n`);
459
+ }
460
+ return true;
461
+ }
462
+ // Default: show session info
463
+ const duration = ctx.startTime ? formatElapsed(Date.now() - ctx.startTime) : "unknown";
464
+ const lines = [];
465
+ if (ctx.sessionId)
466
+ lines.push(` Session: ${ctx.sessionId}`);
467
+ lines.push(` Turns: ${ctx.session.turns}`);
468
+ lines.push(` Tools: ${ctx.session.toolCalls}`);
469
+ lines.push(` Messages: ${ctx.session.messages.length}`);
470
+ lines.push(` Duration: ${duration}`);
471
+ // Read session state file for findings/tasks counters
472
+ if (ctx.phrenPath && ctx.sessionId) {
473
+ try {
474
+ const stateFile = path.join(ctx.phrenPath, ".runtime", "sessions", `session-${ctx.sessionId}.json`);
475
+ const state = JSON.parse(fs.readFileSync(stateFile, "utf-8"));
476
+ lines.push(` Findings: ${state.findingsAdded ?? 0}`);
477
+ lines.push(` Tasks: ${state.tasksCompleted ?? 0}`);
478
+ }
479
+ catch { /* session file may not exist */ }
480
+ }
481
+ process.stderr.write(`${DIM}${lines.join("\n")}${RESET}\n`);
482
+ return true;
483
+ }
484
+ case "/diff": {
485
+ const staged = parts.includes("--staged") || parts.includes("--cached");
486
+ const cmd = staged ? "git diff --staged" : "git diff";
487
+ try {
488
+ const raw = execSync(cmd, { encoding: "utf-8", timeout: 10_000, cwd: process.cwd() });
489
+ if (!raw.trim()) {
490
+ process.stderr.write(`${DIM}No ${staged ? "staged " : ""}changes.${RESET}\n`);
491
+ }
492
+ else {
493
+ const colored = raw.split("\n").map((line) => {
494
+ if (line.startsWith("diff --git"))
495
+ return `${BOLD}${line}${RESET}`;
496
+ if (line.startsWith("@@"))
497
+ return `${CYAN}${line}${RESET}`;
498
+ if (line.startsWith("+"))
499
+ return `${GREEN}${line}${RESET}`;
500
+ if (line.startsWith("-"))
501
+ return `${RED}${line}${RESET}`;
502
+ return line;
503
+ }).join("\n");
504
+ process.stderr.write(colored + "\n");
505
+ }
506
+ }
507
+ catch (err) {
508
+ const e = err;
509
+ process.stderr.write(`${RED}${e.stderr || e.message || "git diff failed"}${RESET}\n`);
510
+ }
511
+ return true;
512
+ }
513
+ case "/git": {
514
+ const sub = parts.slice(1).join(" ").trim();
515
+ if (!sub) {
516
+ process.stderr.write(`${DIM}Usage: /git <status|log|stash|stash pop>${RESET}\n`);
517
+ return true;
518
+ }
519
+ const allowed = {
520
+ "status": "git status",
521
+ "log": "git log --oneline -5",
522
+ "stash": "git stash",
523
+ "stash pop": "git stash pop",
524
+ };
525
+ const gitCmd = allowed[sub];
526
+ if (!gitCmd) {
527
+ process.stderr.write(`${DIM}Supported: /git status, /git log, /git stash, /git stash pop${RESET}\n`);
528
+ return true;
529
+ }
530
+ try {
531
+ const output = execSync(gitCmd, { encoding: "utf-8", timeout: 10_000, cwd: process.cwd() });
532
+ if (output.trim())
533
+ process.stderr.write(output.endsWith("\n") ? output : output + "\n");
534
+ else
535
+ process.stderr.write(`${DIM}(no output)${RESET}\n`);
536
+ }
537
+ catch (err) {
538
+ const e = err;
539
+ process.stderr.write(`${RED}${e.stderr || e.message || "git command failed"}${RESET}\n`);
540
+ }
541
+ return true;
542
+ }
543
+ case "/mem": {
544
+ const sub = parts[1]?.toLowerCase();
545
+ if (!ctx.phrenCtx) {
546
+ process.stderr.write(`${DIM}No phren context available.${RESET}\n`);
547
+ return true;
548
+ }
549
+ const pCtx = ctx.phrenCtx;
550
+ if (!sub || sub === "help") {
551
+ process.stderr.write(`${DIM}Usage:
552
+ /mem search <query> Search phren memory
553
+ /mem findings [project] Show recent findings
554
+ /mem tasks [project] Show tasks
555
+ /mem add <finding> Quick-add a finding${RESET}\n`);
556
+ return true;
557
+ }
558
+ if (sub === "search") {
559
+ const query = parts.slice(2).join(" ").trim();
560
+ if (!query) {
561
+ process.stderr.write(`${DIM}Usage: /mem search <query>${RESET}\n`);
562
+ return true;
563
+ }
564
+ return (async () => {
565
+ try {
566
+ const db = await buildIndex(pCtx.phrenPath, pCtx.profile);
567
+ const result = await searchKnowledgeRows(db, {
568
+ query,
569
+ maxResults: 10,
570
+ filterProject: pCtx.project || null,
571
+ filterType: null,
572
+ phrenPath: pCtx.phrenPath,
573
+ });
574
+ const ranked = rankResults(result.rows ?? [], query, null, pCtx.project || null, pCtx.phrenPath, db);
575
+ if (ranked.length === 0) {
576
+ process.stderr.write(`${DIM}No results found.${RESET}\n`);
577
+ }
578
+ else {
579
+ const lines = ranked.slice(0, 10).map((r, i) => {
580
+ const snippet = r.content?.slice(0, 200) ?? "";
581
+ return ` ${CYAN}${i + 1}.${RESET} ${DIM}[${r.project}/${r.filename}]${RESET} ${snippet}`;
582
+ });
583
+ process.stderr.write(lines.join("\n") + "\n");
584
+ }
585
+ }
586
+ catch (err) {
587
+ process.stderr.write(`${RED}Search failed: ${err instanceof Error ? err.message : String(err)}${RESET}\n`);
588
+ }
589
+ return true;
590
+ })();
591
+ }
592
+ if (sub === "findings") {
593
+ const project = parts[2] || pCtx.project;
594
+ if (!project) {
595
+ process.stderr.write(`${DIM}Usage: /mem findings <project>${RESET}\n`);
596
+ return true;
597
+ }
598
+ const result = readFindings(pCtx.phrenPath, project);
599
+ if (!result.ok) {
600
+ process.stderr.write(`${RED}${result.error}${RESET}\n`);
601
+ return true;
602
+ }
603
+ const items = result.data ?? [];
604
+ if (items.length === 0) {
605
+ process.stderr.write(`${DIM}No findings for ${project}.${RESET}\n`);
606
+ return true;
607
+ }
608
+ const recent = items.slice(-15);
609
+ const lines = recent.map((f) => ` ${DIM}${f.date}${RESET} ${f.text.slice(0, 120)}${f.text.length > 120 ? "..." : ""}`);
610
+ process.stderr.write(`${DIM}── Findings (${items.length} total, showing last ${recent.length}) ──${RESET}\n`);
611
+ process.stderr.write(lines.join("\n") + "\n");
612
+ return true;
613
+ }
614
+ if (sub === "tasks") {
615
+ const project = parts[2] || pCtx.project;
616
+ if (!project) {
617
+ process.stderr.write(`${DIM}Usage: /mem tasks <project>${RESET}\n`);
618
+ return true;
619
+ }
620
+ const result = readTasks(pCtx.phrenPath, project);
621
+ if (!result.ok) {
622
+ process.stderr.write(`${RED}${result.error}${RESET}\n`);
623
+ return true;
624
+ }
625
+ const sections = [];
626
+ for (const [section, items] of Object.entries(result.data.items)) {
627
+ if (section === "Done")
628
+ continue;
629
+ if (items.length === 0)
630
+ continue;
631
+ const lines = items.map((t) => {
632
+ const icon = t.checked ? `${GREEN}✓${RESET}` : `${DIM}○${RESET}`;
633
+ return ` ${icon} ${t.line}`;
634
+ });
635
+ sections.push(`${BOLD}${section}${RESET}\n${lines.join("\n")}`);
636
+ }
637
+ if (sections.length === 0) {
638
+ process.stderr.write(`${DIM}No active tasks for ${project}.${RESET}\n`);
639
+ }
640
+ else {
641
+ process.stderr.write(sections.join("\n") + "\n");
642
+ }
643
+ return true;
644
+ }
645
+ if (sub === "add") {
646
+ const finding = parts.slice(2).join(" ").trim();
647
+ if (!finding) {
648
+ process.stderr.write(`${DIM}Usage: /mem add <finding text>${RESET}\n`);
649
+ return true;
650
+ }
651
+ const project = pCtx.project;
652
+ if (!project) {
653
+ process.stderr.write(`${DIM}No project context. Cannot add finding without a project.${RESET}\n`);
654
+ return true;
655
+ }
656
+ const result = addFinding(pCtx.phrenPath, project, finding);
657
+ if (result.ok) {
658
+ process.stderr.write(`${GREEN}→ Finding saved to ${project}.${RESET}\n`);
659
+ }
660
+ else {
661
+ process.stderr.write(`${RED}${result.message ?? "Failed to save finding."}${RESET}\n`);
662
+ }
663
+ return true;
664
+ }
665
+ process.stderr.write(`${DIM}Unknown /mem subcommand: ${sub}. Try /mem help${RESET}\n`);
666
+ return true;
667
+ }
668
+ case "/ask": {
669
+ const question = parts.slice(1).join(" ").trim();
670
+ if (!question) {
671
+ process.stderr.write(`${DIM}Usage: /ask <question>${RESET}\n`);
672
+ return true;
673
+ }
674
+ if (!ctx.provider) {
675
+ process.stderr.write(`${DIM}Provider not available for /ask.${RESET}\n`);
676
+ return true;
677
+ }
678
+ const provider = ctx.provider;
679
+ const sysPrompt = ctx.systemPrompt ?? "You are a helpful assistant.";
680
+ return (async () => {
681
+ process.stderr.write(`${DIM}◆ quick answer (no tools):${RESET}\n`);
682
+ try {
683
+ const response = await provider.chat(sysPrompt, [{ role: "user", content: question }], []);
684
+ for (const block of response.content) {
685
+ if (block.type === "text") {
686
+ process.stderr.write(renderMarkdown(block.text) + "\n");
687
+ }
688
+ }
689
+ }
690
+ catch (err) {
691
+ process.stderr.write(`${RED}${err instanceof Error ? err.message : String(err)}${RESET}\n`);
692
+ }
693
+ return true;
694
+ })();
695
+ }
349
696
  case "/exit":
350
697
  case "/quit":
351
698
  case "/q":
package/dist/config.js CHANGED
@@ -11,7 +11,8 @@ Options:
11
11
  --max-output <n> Max output tokens per response (default: auto per model)
12
12
  --budget <dollars> Max spend in USD (aborts when exceeded)
13
13
  --plan Plan mode: show plan before executing tools
14
- --permissions <mode> Permission mode: suggest, auto-confirm, full-auto (default: auto-confirm)
14
+ --permissions <mode> Permission mode: suggest (default), auto-confirm, full-auto
15
+ --yolo Full-auto permissions — no confirmations (alias for --permissions full-auto)
15
16
  --interactive, -i Interactive REPL mode (multi-turn conversation)
16
17
  --resume Resume last session's conversation
17
18
  --lint-cmd <cmd> Override auto-detected lint command
@@ -46,7 +47,7 @@ Examples:
46
47
  export function parseArgs(argv) {
47
48
  const args = {
48
49
  task: "",
49
- permissions: "auto-confirm",
50
+ permissions: "suggest",
50
51
  maxTurns: 50,
51
52
  budget: null,
52
53
  plan: false,
@@ -120,6 +121,9 @@ export function parseArgs(argv) {
120
121
  else if (arg === "--budget" && argv[i + 1]) {
121
122
  args.budget = parseFloat(argv[++i]) || null;
122
123
  }
124
+ else if (arg === "--yolo") {
125
+ args.permissions = "full-auto";
126
+ }
123
127
  else if (arg === "--permissions" && argv[i + 1]) {
124
128
  const mode = argv[++i];
125
129
  if (mode === "suggest" || mode === "auto-confirm" || mode === "full-auto") {
package/dist/index.js CHANGED
@@ -8,6 +8,9 @@ import { editFileTool } from "./tools/edit-file.js";
8
8
  import { shellTool } from "./tools/shell.js";
9
9
  import { globTool } from "./tools/glob.js";
10
10
  import { grepTool } from "./tools/grep.js";
11
+ import { createWebFetchTool } from "./tools/web-fetch.js";
12
+ import { createWebSearchTool } from "./tools/web-search.js";
13
+ import { createPhrenAddTaskTool } from "./tools/phren-add-task.js";
11
14
  import { createPhrenSearchTool } from "./tools/phren-search.js";
12
15
  import { createPhrenFindingTool } from "./tools/phren-finding.js";
13
16
  import { createPhrenGetTasksTool, createPhrenCompleteTaskTool } from "./tools/phren-tasks.js";
@@ -84,7 +87,10 @@ export async function runAgentCli(raw) {
84
87
  contextSnippet += `\n\n## Agent context (${phrenCtx.project})\n\n${projectCtx}`;
85
88
  }
86
89
  }
87
- const systemPrompt = buildSystemPrompt(contextSnippet, priorSummary);
90
+ const systemPrompt = buildSystemPrompt(contextSnippet, priorSummary, {
91
+ name: provider.name,
92
+ model: provider.model,
93
+ });
88
94
  // Dry run: print system prompt and exit
89
95
  if (args.dryRun) {
90
96
  console.log("=== System Prompt ===");
@@ -111,8 +117,11 @@ export async function runAgentCli(raw) {
111
117
  registry.register(createPhrenFindingTool(phrenCtx, sessionId));
112
118
  registry.register(createPhrenGetTasksTool(phrenCtx));
113
119
  registry.register(createPhrenCompleteTaskTool(phrenCtx, sessionId));
120
+ registry.register(createPhrenAddTaskTool(phrenCtx, sessionId));
114
121
  }
115
- // Git tools
122
+ // Web tools
123
+ registry.register(createWebFetchTool());
124
+ registry.register(createWebSearchTool());
116
125
  registry.register(gitStatusTool);
117
126
  registry.register(gitDiffTool);
118
127
  registry.register(gitCommitTool);
@@ -156,6 +165,7 @@ export async function runAgentCli(raw) {
156
165
  costTracker,
157
166
  plan: args.plan,
158
167
  lintTestConfig,
168
+ sessionId,
159
169
  };
160
170
  // Multi-agent TUI mode
161
171
  if (args.multi || args.team) {
@@ -85,7 +85,6 @@ export function showModelPicker(provider, currentModel, w) {
85
85
  const m = models[i];
86
86
  const selected = i === cursor;
87
87
  const arrow = selected ? s.cyan("▸") : " ";
88
- const label = selected ? s.bold(m.label) : s.dim(m.label);
89
88
  const padded = m.label + " ".repeat(maxLabel - m.label.length);
90
89
  const labelStr = selected ? s.bold(padded) : s.dim(padded);
91
90
  const meter = renderReasoningMeter(reasoningState[i], m.reasoningRange);
@@ -97,7 +96,6 @@ export function showModelPicker(provider, currentModel, w) {
97
96
  // Initial draw
98
97
  drawPicker();
99
98
  return new Promise((resolve) => {
100
- const wasRaw = process.stdin.isRaw;
101
99
  function onKey(_ch, key) {
102
100
  if (!key)
103
101
  return;
@@ -92,34 +92,11 @@ export function removeCustomModel(id) {
92
92
  export function getCustomModels() {
93
93
  return loadConfig().customModels;
94
94
  }
95
- /** Get all models for a provider (built-in + custom). */
96
- export async function getAllModelsForProvider(provider, currentModel) {
97
- // Import dynamically to avoid circular dep
98
- const { getAvailableModels } = await import("./model-picker.js");
99
- const builtIn = getAvailableModels(provider, currentModel);
100
- // Add custom models for this provider
101
- const custom = getCustomModels().filter((m) => m.provider === provider);
102
- for (const c of custom) {
103
- if (!builtIn.some((b) => b.id === c.id)) {
104
- builtIn.push({
105
- id: c.id,
106
- provider: provider,
107
- label: c.label + " ★",
108
- reasoning: c.reasoning,
109
- reasoningRange: c.reasoningRange,
110
- contextWindow: c.contextWindow,
111
- });
112
- }
113
- }
114
- return builtIn;
115
- }
116
95
  // ── Format helpers for CLI display ──────────────────────────────────────────
117
96
  const DIM = "\x1b[2m";
118
97
  const BOLD = "\x1b[1m";
119
98
  const GREEN = "\x1b[32m";
120
99
  const RED = "\x1b[31m";
121
- const CYAN = "\x1b[36m";
122
- const YELLOW = "\x1b[33m";
123
100
  const RESET = "\x1b[0m";
124
101
  export function formatProviderList() {
125
102
  const statuses = getProviderStatuses();
@@ -28,6 +28,7 @@ const ENV_FORWARD_KEYS = [
28
28
  "PHREN_PROFILE",
29
29
  "PHREN_DEBUG",
30
30
  "HOME",
31
+ "USERPROFILE",
31
32
  "PATH",
32
33
  "NODE_EXTRA_CA_CERTS",
33
34
  ];
@@ -160,7 +161,7 @@ export class AgentSpawner extends EventEmitter {
160
161
  // Give it a moment to clean up, then force kill
161
162
  setTimeout(() => {
162
163
  if (this.processes.has(agentId)) {
163
- child.kill("SIGTERM");
164
+ child.kill();
164
165
  }
165
166
  }, 5000);
166
167
  const agent = this.agents.get(agentId);
@@ -207,7 +208,7 @@ export class AgentSpawner extends EventEmitter {
207
208
  setTimeout(() => {
208
209
  // Force kill remaining
209
210
  for (const [id, child] of this.processes) {
210
- child.kill("SIGKILL");
211
+ child.kill();
211
212
  this.processes.delete(id);
212
213
  }
213
214
  resolve();
@@ -6,7 +6,6 @@
6
6
  const ESC = "\x1b[";
7
7
  const RESET = `${ESC}0m`;
8
8
  const BOLD = `${ESC}1m`;
9
- const DIM = `${ESC}2m`;
10
9
  const MAGENTA = `${ESC}35m`;
11
10
  const GREEN = `${ESC}32m`;
12
11
  const YELLOW = `${ESC}33m`;