@phren/agent 0.1.2 → 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";
@@ -133,9 +134,15 @@ export async function runTurn(userInput, session, config, hooks) {
133
134
  }
134
135
  // Prune context if approaching limit
135
136
  if (shouldPrune(systemPrompt, session.messages, { contextLimit })) {
137
+ const preCount = session.messages.length;
138
+ const preTokens = estimateMessageTokens(session.messages);
136
139
  session.messages = pruneMessages(session.messages, { contextLimit, keepRecentTurns: 6 });
137
- if (verbose)
138
- 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`);
139
146
  }
140
147
  // For plan mode first turn, pass empty tools so LLM can't call any
141
148
  const turnTools = planPending ? [] : toolDefs;
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
@@ -165,6 +165,7 @@ export async function runAgentCli(raw) {
165
165
  costTracker,
166
166
  plan: args.plan,
167
167
  lintTestConfig,
168
+ sessionId,
168
169
  };
169
170
  // Multi-agent TUI mode
170
171
  if (args.multi || args.team) {
@@ -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();
@@ -11,6 +11,14 @@ const DANGEROUS_PATTERNS = [
11
11
  { pattern: /\bnohup\b/i, reason: "Detached process may outlive session", severity: "block" },
12
12
  { pattern: /\bdisown\b/i, reason: "Detached process may outlive session", severity: "block" },
13
13
  { pattern: /\bsetsid\b/i, reason: "Detached process may outlive session", severity: "block" },
14
+ // Block: Windows-specific destructive commands
15
+ { pattern: /\bformat\s+[a-z]:/i, reason: "Disk format command", severity: "block" },
16
+ { pattern: /\bdel\s+\/[sq]/i, reason: "Recursive or quiet delete", severity: "block" },
17
+ { pattern: /\brd\s+\/s/i, reason: "Recursive directory removal", severity: "block" },
18
+ { pattern: /\brmdir\s+\/s/i, reason: "Recursive directory removal", severity: "block" },
19
+ { pattern: /\breg\s+delete\b/i, reason: "Registry deletion", severity: "block" },
20
+ { pattern: /\bpowershell\b.*\b-enc\b/i, reason: "Encoded PowerShell command (obfuscation)", severity: "block" },
21
+ { pattern: /\bcmd\b.*\/c.*\bdel\s+\/[sq]/i, reason: "Recursive or quiet delete via cmd", severity: "block" },
14
22
  // Warn: potentially dangerous
15
23
  { pattern: /\beval\b/i, reason: "Dynamic code execution via eval", severity: "warn" },
16
24
  { pattern: /\$\(.*\)/, reason: "Command substitution", severity: "warn" },
@@ -4,28 +4,15 @@ export class AnthropicProvider {
4
4
  maxOutputTokens;
5
5
  apiKey;
6
6
  model;
7
- constructor(apiKey, model, maxOutputTokens) {
7
+ cacheEnabled;
8
+ constructor(apiKey, model, maxOutputTokens, cacheEnabled = true) {
8
9
  this.apiKey = apiKey;
9
10
  this.model = model ?? "claude-sonnet-4-20250514";
10
11
  this.maxOutputTokens = maxOutputTokens ?? 8192;
12
+ this.cacheEnabled = cacheEnabled;
11
13
  }
12
14
  async chat(system, messages, tools) {
13
- const body = {
14
- model: this.model,
15
- system,
16
- messages: messages.map((m) => ({
17
- role: m.role,
18
- content: m.content,
19
- })),
20
- max_tokens: this.maxOutputTokens,
21
- };
22
- if (tools.length > 0) {
23
- body.tools = tools.map((t) => ({
24
- name: t.name,
25
- description: t.description,
26
- input_schema: t.input_schema,
27
- }));
28
- }
15
+ const body = this.buildRequestBody(system, messages, tools);
29
16
  const res = await fetch("https://api.anthropic.com/v1/messages", {
30
17
  method: "POST",
31
18
  headers: {
@@ -45,6 +32,7 @@ export class AnthropicProvider {
45
32
  : data.stop_reason === "max_tokens" ? "max_tokens"
46
33
  : "end_turn";
47
34
  const usage = data.usage;
35
+ logCacheUsage(usage);
48
36
  return {
49
37
  content,
50
38
  stop_reason: stop_reason,
@@ -52,20 +40,8 @@ export class AnthropicProvider {
52
40
  };
53
41
  }
54
42
  async *chatStream(system, messages, tools) {
55
- const body = {
56
- model: this.model,
57
- system,
58
- messages: messages.map((m) => ({ role: m.role, content: m.content })),
59
- max_tokens: this.maxOutputTokens,
60
- stream: true,
61
- };
62
- if (tools.length > 0) {
63
- body.tools = tools.map((t) => ({
64
- name: t.name,
65
- description: t.description,
66
- input_schema: t.input_schema,
67
- }));
68
- }
43
+ const body = this.buildRequestBody(system, messages, tools);
44
+ body.stream = true;
69
45
  const res = await fetch("https://api.anthropic.com/v1/messages", {
70
46
  method: "POST",
71
47
  headers: {
@@ -129,6 +105,7 @@ export class AnthropicProvider {
129
105
  else if (type === "message_start") {
130
106
  const u = data.message?.usage;
131
107
  if (u) {
108
+ logCacheUsage(u);
132
109
  usage = {
133
110
  input_tokens: u.input_tokens ?? 0,
134
111
  output_tokens: u.output_tokens ?? 0,
@@ -138,6 +115,66 @@ export class AnthropicProvider {
138
115
  }
139
116
  yield { type: "done", stop_reason: stopReason, usage };
140
117
  }
118
+ /** Build the request body with optional prompt caching breakpoints. */
119
+ buildRequestBody(system, messages, tools) {
120
+ const cache = { cache_control: { type: "ephemeral" } };
121
+ // System prompt: use content array format with cache_control on the text block
122
+ const systemValue = this.cacheEnabled
123
+ ? [{ type: "text", text: system, ...cache }]
124
+ : system;
125
+ const mappedMessages = messages.map((m) => ({ role: m.role, content: m.content }));
126
+ // Mark the last 2 user messages with cache_control for recent-context caching
127
+ if (this.cacheEnabled) {
128
+ let marked = 0;
129
+ for (let i = mappedMessages.length - 1; i >= 0 && marked < 2; i--) {
130
+ if (mappedMessages[i].role !== "user")
131
+ continue;
132
+ const c = mappedMessages[i].content;
133
+ if (typeof c === "string") {
134
+ mappedMessages[i] = {
135
+ role: "user",
136
+ content: [{ type: "text", text: c, ...cache }],
137
+ };
138
+ }
139
+ else if (Array.isArray(c) && c.length > 0) {
140
+ // Add cache_control to the last block of the content array
141
+ const blocks = [...c];
142
+ blocks[blocks.length - 1] = { ...blocks[blocks.length - 1], ...cache };
143
+ mappedMessages[i] = { role: "user", content: blocks };
144
+ }
145
+ marked++;
146
+ }
147
+ }
148
+ const body = {
149
+ model: this.model,
150
+ system: systemValue,
151
+ messages: mappedMessages,
152
+ max_tokens: this.maxOutputTokens,
153
+ };
154
+ if (tools.length > 0) {
155
+ const mappedTools = tools.map((t) => ({
156
+ name: t.name,
157
+ description: t.description,
158
+ input_schema: t.input_schema,
159
+ }));
160
+ // Cache the last tool definition — Anthropic uses it as the breakpoint for the entire tools block
161
+ if (this.cacheEnabled) {
162
+ mappedTools[mappedTools.length - 1] = { ...mappedTools[mappedTools.length - 1], ...cache };
163
+ }
164
+ body.tools = mappedTools;
165
+ }
166
+ return body;
167
+ }
168
+ }
169
+ /** Log cache hit/creation stats to stderr (visible in verbose mode). */
170
+ function logCacheUsage(usage) {
171
+ if (!usage)
172
+ return;
173
+ const created = usage.cache_creation_input_tokens;
174
+ const read = usage.cache_read_input_tokens;
175
+ if (created || read) {
176
+ process.stderr.write(`[cache] created=${created ?? 0} read=${read ?? 0} input=${usage.input_tokens ?? 0}\n`);
177
+ }
141
178
  }
142
179
  /** Parse SSE stream from a fetch Response. */
143
180
  async function* parseSSE(res) {