@phren/agent 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/agent-loop.js +1 -1
- package/dist/commands.js +68 -0
- package/dist/config.js +4 -0
- package/dist/context/pruner.js +97 -8
- package/dist/cost.js +35 -0
- package/dist/index.js +1 -1
- package/dist/multi/agent-colors.js +0 -5
- package/dist/multi/child-entry.js +1 -2
- package/dist/multi/markdown.js +11 -1
- package/dist/multi/model-picker.js +154 -0
- package/dist/multi/provider-manager.js +151 -0
- package/dist/multi/syntax-highlight.js +188 -0
- package/dist/multi/tui-multi.js +0 -9
- package/dist/permissions/allowlist.js +4 -1
- package/dist/permissions/prompt.js +36 -22
- package/dist/permissions/shell-safety.js +2 -0
- package/dist/providers/anthropic.js +5 -3
- package/dist/providers/codex-auth.js +1 -1
- package/dist/providers/codex.js +4 -2
- package/dist/providers/ollama.js +5 -1
- package/dist/providers/openrouter.js +10 -6
- package/dist/providers/resolve.js +16 -7
- package/dist/repl.js +1 -36
- package/dist/settings.js +42 -0
- package/dist/system-prompt.js +11 -0
- package/dist/tools/edit-file.js +13 -0
- package/dist/tools/git.js +13 -0
- package/dist/tools/write-file.js +12 -0
- package/dist/tui.js +209 -83
- package/package.json +7 -7
- package/dist/multi/progress.js +0 -32
package/dist/repl.js
CHANGED
|
@@ -5,9 +5,9 @@ import * as path from "node:path";
|
|
|
5
5
|
import * as os from "node:os";
|
|
6
6
|
import { createSession, runTurn } from "./agent-loop.js";
|
|
7
7
|
import { handleCommand } from "./commands.js";
|
|
8
|
+
import { loadInputMode, saveInputMode, savePermissionMode } from "./settings.js";
|
|
8
9
|
const HISTORY_DIR = path.join(os.homedir(), ".phren-agent");
|
|
9
10
|
const HISTORY_FILE = path.join(HISTORY_DIR, "repl-history.txt");
|
|
10
|
-
const SETTINGS_FILE = path.join(HISTORY_DIR, "settings.json");
|
|
11
11
|
const MAX_HISTORY = 500;
|
|
12
12
|
const CYAN = "\x1b[36m";
|
|
13
13
|
const RED = "\x1b[31m";
|
|
@@ -30,41 +30,6 @@ function saveHistory(lines) {
|
|
|
30
30
|
}
|
|
31
31
|
catch { /* ignore */ }
|
|
32
32
|
}
|
|
33
|
-
function loadInputMode() {
|
|
34
|
-
try {
|
|
35
|
-
const data = JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf-8"));
|
|
36
|
-
if (data.inputMode === "queue")
|
|
37
|
-
return "queue";
|
|
38
|
-
}
|
|
39
|
-
catch { /* ignore */ }
|
|
40
|
-
return "steering";
|
|
41
|
-
}
|
|
42
|
-
function saveInputMode(mode) {
|
|
43
|
-
try {
|
|
44
|
-
fs.mkdirSync(HISTORY_DIR, { recursive: true });
|
|
45
|
-
let data = {};
|
|
46
|
-
try {
|
|
47
|
-
data = JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf-8"));
|
|
48
|
-
}
|
|
49
|
-
catch { /* fresh */ }
|
|
50
|
-
data.inputMode = mode;
|
|
51
|
-
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(data, null, 2) + "\n");
|
|
52
|
-
}
|
|
53
|
-
catch { /* ignore */ }
|
|
54
|
-
}
|
|
55
|
-
function savePermissionMode(mode) {
|
|
56
|
-
try {
|
|
57
|
-
fs.mkdirSync(HISTORY_DIR, { recursive: true });
|
|
58
|
-
let data = {};
|
|
59
|
-
try {
|
|
60
|
-
data = JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf-8"));
|
|
61
|
-
}
|
|
62
|
-
catch { /* fresh */ }
|
|
63
|
-
data.permissionMode = mode;
|
|
64
|
-
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(data, null, 2) + "\n");
|
|
65
|
-
}
|
|
66
|
-
catch { /* ignore */ }
|
|
67
|
-
}
|
|
68
33
|
export async function startRepl(config) {
|
|
69
34
|
const contextLimit = config.provider.contextWindow ?? 200_000;
|
|
70
35
|
const session = createSession(contextLimit);
|
package/dist/settings.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** Shared settings persistence for agent TUI and REPL. */
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import * as os from "os";
|
|
5
|
+
export const SETTINGS_FILE = path.join(os.homedir(), ".phren-agent", "settings.json");
|
|
6
|
+
export function loadInputMode() {
|
|
7
|
+
try {
|
|
8
|
+
const data = JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf-8"));
|
|
9
|
+
if (data.inputMode === "queue")
|
|
10
|
+
return "queue";
|
|
11
|
+
}
|
|
12
|
+
catch { }
|
|
13
|
+
return "steering";
|
|
14
|
+
}
|
|
15
|
+
export function saveInputMode(mode) {
|
|
16
|
+
try {
|
|
17
|
+
const dir = path.dirname(SETTINGS_FILE);
|
|
18
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
19
|
+
let data = {};
|
|
20
|
+
try {
|
|
21
|
+
data = JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf-8"));
|
|
22
|
+
}
|
|
23
|
+
catch { }
|
|
24
|
+
data.inputMode = mode;
|
|
25
|
+
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(data, null, 2) + "\n");
|
|
26
|
+
}
|
|
27
|
+
catch { }
|
|
28
|
+
}
|
|
29
|
+
export function savePermissionMode(mode) {
|
|
30
|
+
try {
|
|
31
|
+
const dir = path.dirname(SETTINGS_FILE);
|
|
32
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
33
|
+
let data = {};
|
|
34
|
+
try {
|
|
35
|
+
data = JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf-8"));
|
|
36
|
+
}
|
|
37
|
+
catch { }
|
|
38
|
+
data.permissionMode = mode;
|
|
39
|
+
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(data, null, 2) + "\n");
|
|
40
|
+
}
|
|
41
|
+
catch { }
|
|
42
|
+
}
|
package/dist/system-prompt.js
CHANGED
|
@@ -15,6 +15,17 @@ export function buildSystemPrompt(phrenContext, priorSummary) {
|
|
|
15
15
|
"- `phren_add_finding` saves insights for future sessions. Good findings: non-obvious patterns, decisions with rationale, error resolutions, architecture constraints. Bad findings: narration of what you did, obvious facts, secrets.",
|
|
16
16
|
"- `phren_get_tasks` shows tracked work items. Complete tasks with `phren_complete_task` when done.",
|
|
17
17
|
"",
|
|
18
|
+
"## Self-configuration",
|
|
19
|
+
"You ARE phren-agent. You can configure phren itself via shell commands:",
|
|
20
|
+
"- `phren init` — set up phren (MCP server, hooks, profiles)",
|
|
21
|
+
"- `phren add <path>` — register a project directory",
|
|
22
|
+
"- `phren config proactivity <level>` — set proactivity (high/medium/low)",
|
|
23
|
+
"- `phren config policy set <key> <value>` — configure retention, TTL, decay",
|
|
24
|
+
"- `phren hooks enable <tool>` — enable hooks for claude/copilot/cursor/codex",
|
|
25
|
+
"- `phren doctor --fix` — diagnose and self-heal",
|
|
26
|
+
"- `phren status` — check health",
|
|
27
|
+
"If the user asks you to configure phren, set up a project, or fix their install, use the shell tool to run these commands.",
|
|
28
|
+
"",
|
|
18
29
|
"## Rules",
|
|
19
30
|
"- Never write secrets, API keys, or PII to files or findings.",
|
|
20
31
|
"- Prefer `edit_file` over `write_file` for existing files.",
|
package/dist/tools/edit-file.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
2
3
|
import { encodeDiffPayload } from "../multi/diff-renderer.js";
|
|
4
|
+
import { checkSensitivePath, validatePath } from "../permissions/sandbox.js";
|
|
3
5
|
export const editFileTool = {
|
|
4
6
|
name: "edit_file",
|
|
5
7
|
description: "Edit a file by replacing an exact string match. Preferred over write_file for modifying existing files — only changes what's needed. The old_string must appear exactly once; include surrounding lines for uniqueness if needed.",
|
|
@@ -16,6 +18,17 @@ export const editFileTool = {
|
|
|
16
18
|
const filePath = input.path;
|
|
17
19
|
const oldStr = input.old_string;
|
|
18
20
|
const newStr = input.new_string;
|
|
21
|
+
// Defense-in-depth: sensitive path check
|
|
22
|
+
const resolved = path.resolve(filePath);
|
|
23
|
+
const sensitive = checkSensitivePath(resolved);
|
|
24
|
+
if (sensitive.sensitive) {
|
|
25
|
+
return { output: `Access denied: ${sensitive.reason}`, is_error: true };
|
|
26
|
+
}
|
|
27
|
+
// Defense-in-depth: sandbox check
|
|
28
|
+
const sandboxResult = validatePath(filePath, process.cwd(), []);
|
|
29
|
+
if (!sandboxResult.ok) {
|
|
30
|
+
return { output: `Path outside sandbox: ${sandboxResult.error}`, is_error: true };
|
|
31
|
+
}
|
|
19
32
|
if (!fs.existsSync(filePath))
|
|
20
33
|
return { output: `File not found: ${filePath}`, is_error: true };
|
|
21
34
|
const oldContent = fs.readFileSync(filePath, "utf-8");
|
package/dist/tools/git.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execFileSync } from "child_process";
|
|
2
|
+
import { validatePath } from "../permissions/sandbox.js";
|
|
2
3
|
function git(args, cwd) {
|
|
3
4
|
return execFileSync("git", args, {
|
|
4
5
|
cwd,
|
|
@@ -20,6 +21,10 @@ export const gitStatusTool = {
|
|
|
20
21
|
},
|
|
21
22
|
async execute(input) {
|
|
22
23
|
const cwd = input.cwd || process.cwd();
|
|
24
|
+
const cwdCheck = validatePath(cwd, process.cwd(), []);
|
|
25
|
+
if (!cwdCheck.ok) {
|
|
26
|
+
return { output: `Git cwd outside sandbox: ${cwdCheck.error}`, is_error: true };
|
|
27
|
+
}
|
|
23
28
|
try {
|
|
24
29
|
const output = git(["status", "--short"], cwd);
|
|
25
30
|
return { output: output || "(working tree clean)" };
|
|
@@ -43,6 +48,10 @@ export const gitDiffTool = {
|
|
|
43
48
|
},
|
|
44
49
|
async execute(input) {
|
|
45
50
|
const cwd = input.cwd || process.cwd();
|
|
51
|
+
const cwdCheck = validatePath(cwd, process.cwd(), []);
|
|
52
|
+
if (!cwdCheck.ok) {
|
|
53
|
+
return { output: `Git cwd outside sandbox: ${cwdCheck.error}`, is_error: true };
|
|
54
|
+
}
|
|
46
55
|
const args = ["diff"];
|
|
47
56
|
if (input.cached)
|
|
48
57
|
args.push("--cached");
|
|
@@ -75,6 +84,10 @@ export const gitCommitTool = {
|
|
|
75
84
|
},
|
|
76
85
|
async execute(input) {
|
|
77
86
|
const cwd = input.cwd || process.cwd();
|
|
87
|
+
const cwdCheck = validatePath(cwd, process.cwd(), []);
|
|
88
|
+
if (!cwdCheck.ok) {
|
|
89
|
+
return { output: `Git cwd outside sandbox: ${cwdCheck.error}`, is_error: true };
|
|
90
|
+
}
|
|
78
91
|
const message = input.message;
|
|
79
92
|
const files = input.files || [];
|
|
80
93
|
try {
|
package/dist/tools/write-file.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
2
|
import * as path from "path";
|
|
3
3
|
import { encodeDiffPayload } from "../multi/diff-renderer.js";
|
|
4
|
+
import { checkSensitivePath, validatePath } from "../permissions/sandbox.js";
|
|
4
5
|
export const writeFileTool = {
|
|
5
6
|
name: "write_file",
|
|
6
7
|
description: "Write content to a file, creating parent directories as needed. Use for new files only — prefer edit_file for modifying existing files. Overwrites existing content entirely.",
|
|
@@ -15,6 +16,17 @@ export const writeFileTool = {
|
|
|
15
16
|
async execute(input) {
|
|
16
17
|
const filePath = input.path;
|
|
17
18
|
const content = input.content;
|
|
19
|
+
// Defense-in-depth: sensitive path check
|
|
20
|
+
const resolved = path.resolve(filePath);
|
|
21
|
+
const sensitive = checkSensitivePath(resolved);
|
|
22
|
+
if (sensitive.sensitive) {
|
|
23
|
+
return { output: `Access denied: ${sensitive.reason}`, is_error: true };
|
|
24
|
+
}
|
|
25
|
+
// Defense-in-depth: sandbox check
|
|
26
|
+
const sandboxResult = validatePath(filePath, process.cwd(), []);
|
|
27
|
+
if (!sandboxResult.ok) {
|
|
28
|
+
return { output: `Path outside sandbox: ${sandboxResult.error}`, is_error: true };
|
|
29
|
+
}
|
|
18
30
|
const oldContent = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf-8") : "";
|
|
19
31
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
20
32
|
fs.writeFileSync(filePath, content);
|
package/dist/tui.js
CHANGED
|
@@ -8,21 +8,29 @@ import { createSession, runTurn } from "./agent-loop.js";
|
|
|
8
8
|
import { handleCommand } from "./commands.js";
|
|
9
9
|
import { renderMarkdown } from "./multi/markdown.js";
|
|
10
10
|
import { decodeDiffPayload, renderInlineDiff, DIFF_MARKER } from "./multi/diff-renderer.js";
|
|
11
|
-
import * as fs from "fs";
|
|
12
|
-
import * as path from "path";
|
|
13
11
|
import * as os from "os";
|
|
12
|
+
import { execSync } from "node:child_process";
|
|
13
|
+
import { loadInputMode, saveInputMode, savePermissionMode } from "./settings.js";
|
|
14
|
+
import { createRequire } from "node:module";
|
|
15
|
+
const _require = createRequire(import.meta.url);
|
|
16
|
+
const AGENT_VERSION = _require("../package.json").version;
|
|
14
17
|
// ── ANSI helpers ─────────────────────────────────────────────────────────────
|
|
15
18
|
const ESC = "\x1b[";
|
|
16
19
|
const s = {
|
|
17
20
|
reset: `${ESC}0m`,
|
|
18
21
|
bold: (t) => `${ESC}1m${t}${ESC}0m`,
|
|
19
22
|
dim: (t) => `${ESC}2m${t}${ESC}0m`,
|
|
23
|
+
italic: (t) => `${ESC}3m${t}${ESC}0m`,
|
|
20
24
|
cyan: (t) => `${ESC}36m${t}${ESC}0m`,
|
|
21
25
|
green: (t) => `${ESC}32m${t}${ESC}0m`,
|
|
22
26
|
yellow: (t) => `${ESC}33m${t}${ESC}0m`,
|
|
23
27
|
red: (t) => `${ESC}31m${t}${ESC}0m`,
|
|
28
|
+
blue: (t) => `${ESC}34m${t}${ESC}0m`,
|
|
29
|
+
magenta: (t) => `${ESC}35m${t}${ESC}0m`,
|
|
24
30
|
gray: (t) => `${ESC}90m${t}${ESC}0m`,
|
|
25
31
|
invert: (t) => `${ESC}7m${t}${ESC}0m`,
|
|
32
|
+
// Gradient-style brand text
|
|
33
|
+
brand: (t) => `${ESC}1;35m${t}${ESC}0m`,
|
|
26
34
|
};
|
|
27
35
|
function cols() {
|
|
28
36
|
return process.stdout.columns || 80;
|
|
@@ -38,23 +46,28 @@ const PERMISSION_LABELS = {
|
|
|
38
46
|
"auto-confirm": "auto",
|
|
39
47
|
"full-auto": "full-auto",
|
|
40
48
|
};
|
|
41
|
-
function formatPermissionMode(mode) {
|
|
42
|
-
const label = PERMISSION_LABELS[mode];
|
|
43
|
-
switch (mode) {
|
|
44
|
-
case "suggest": return s.cyan(`[${label}]`);
|
|
45
|
-
case "auto-confirm": return s.green(`[${label}]`);
|
|
46
|
-
case "full-auto": return s.yellow(`[${label}]`);
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
49
|
// ── Status bar ───────────────────────────────────────────────────────────────
|
|
50
50
|
function renderStatusBar(provider, project, turns, cost, permMode, agentCount) {
|
|
51
|
-
const
|
|
52
|
-
const agentTag = agentCount && agentCount > 0 ? `
|
|
53
|
-
|
|
54
|
-
const
|
|
51
|
+
const modeLabel = permMode ? PERMISSION_LABELS[permMode] : "";
|
|
52
|
+
const agentTag = agentCount && agentCount > 0 ? ` A${agentCount}` : "";
|
|
53
|
+
// Left: brand + provider + project
|
|
54
|
+
const parts = [" ◆ phren", provider];
|
|
55
|
+
if (project)
|
|
56
|
+
parts.push(project);
|
|
57
|
+
const left = parts.join(" · ");
|
|
58
|
+
// Right: mode + agents + cost + turns
|
|
59
|
+
const rightParts = [];
|
|
60
|
+
if (modeLabel)
|
|
61
|
+
rightParts.push(modeLabel);
|
|
62
|
+
if (agentTag)
|
|
63
|
+
rightParts.push(agentTag.trim());
|
|
64
|
+
if (cost)
|
|
65
|
+
rightParts.push(cost);
|
|
66
|
+
rightParts.push(`T${turns}`);
|
|
67
|
+
const right = rightParts.join(" ") + " ";
|
|
55
68
|
const w = cols();
|
|
56
|
-
const pad = Math.max(0, w -
|
|
57
|
-
return s.invert(
|
|
69
|
+
const pad = Math.max(0, w - left.length - right.length);
|
|
70
|
+
return s.invert(left + " ".repeat(pad) + right);
|
|
58
71
|
}
|
|
59
72
|
function stripAnsi(t) {
|
|
60
73
|
return t.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "");
|
|
@@ -70,17 +83,34 @@ function formatDuration(ms) {
|
|
|
70
83
|
const secs = Math.round((ms % 60_000) / 1000);
|
|
71
84
|
return `${mins}m ${secs}s`;
|
|
72
85
|
}
|
|
86
|
+
function formatToolInput(name, input) {
|
|
87
|
+
// Show the most relevant input field for each tool type
|
|
88
|
+
switch (name) {
|
|
89
|
+
case "read_file": return input.file_path ?? "";
|
|
90
|
+
case "write_file": return input.file_path ?? "";
|
|
91
|
+
case "edit_file": return input.file_path ?? "";
|
|
92
|
+
case "shell": return (input.command ?? "").slice(0, 60);
|
|
93
|
+
case "glob": return input.pattern ?? "";
|
|
94
|
+
case "grep": return `/${input.pattern ?? ""}/ ${input.path ?? ""}`;
|
|
95
|
+
case "git_commit": return (input.message ?? "").slice(0, 50);
|
|
96
|
+
case "phren_search": return input.query ?? "";
|
|
97
|
+
case "phren_add_finding": return (input.finding ?? "").slice(0, 50);
|
|
98
|
+
default: return JSON.stringify(input).slice(0, 60);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
73
101
|
function renderToolCall(name, input, output, isError, durationMs) {
|
|
74
|
-
const
|
|
102
|
+
const preview = formatToolInput(name, input);
|
|
75
103
|
const dur = formatDuration(durationMs);
|
|
76
|
-
const icon = isError ? s.red("✗") : s.green("
|
|
77
|
-
const header =
|
|
104
|
+
const icon = isError ? s.red("✗") : s.green("→");
|
|
105
|
+
const header = ` ${icon} ${s.bold(name)} ${s.gray(preview)} ${s.dim(dur)}`;
|
|
78
106
|
// Compact: show first 3 lines only, with overflow count
|
|
79
107
|
const allLines = output.split("\n").filter(Boolean);
|
|
108
|
+
if (allLines.length === 0)
|
|
109
|
+
return header;
|
|
80
110
|
const shown = allLines.slice(0, COMPACT_LINES);
|
|
81
|
-
const body = shown.map((l) => s.dim(`
|
|
111
|
+
const body = shown.map((l) => s.dim(` ${l.slice(0, cols() - 6)}`)).join("\n");
|
|
82
112
|
const overflow = allLines.length - COMPACT_LINES;
|
|
83
|
-
const more = overflow > 0 ? `\n${s.dim(`
|
|
113
|
+
const more = overflow > 0 ? `\n${s.dim(` ... +${overflow} lines`)}` : "";
|
|
84
114
|
return `${header}\n${body}${more}`;
|
|
85
115
|
}
|
|
86
116
|
// ── Menu mode helpers ────────────────────────────────────────────────────────
|
|
@@ -118,6 +148,7 @@ export async function startTui(config, spawner) {
|
|
|
118
148
|
let menuListCount = 0;
|
|
119
149
|
let menuFilterActive = false;
|
|
120
150
|
let menuFilterBuf = "";
|
|
151
|
+
let ctrlCCount = 0;
|
|
121
152
|
// ── Menu rendering ─────────────────────────────────────────────────────
|
|
122
153
|
async function renderMenu() {
|
|
123
154
|
const mod = await loadMenuModule();
|
|
@@ -144,7 +175,7 @@ export async function startTui(config, spawner) {
|
|
|
144
175
|
menuFilterBuf = "";
|
|
145
176
|
w.write("\x1b[?1049l"); // leave alternate screen (restores chat)
|
|
146
177
|
statusBar();
|
|
147
|
-
prompt();
|
|
178
|
+
prompt(true); // skip newline — alt screen restore already positioned cursor
|
|
148
179
|
}
|
|
149
180
|
// Print status bar
|
|
150
181
|
function statusBar() {
|
|
@@ -153,10 +184,31 @@ export async function startTui(config, spawner) {
|
|
|
153
184
|
const bar = renderStatusBar(config.provider.name, config.phrenCtx?.project ?? null, session.turns, costStr, config.registry.permissionConfig.mode, spawner?.listAgents().length);
|
|
154
185
|
w.write(`${ESC}s${ESC}H${bar}${ESC}u`); // save cursor, move to top, print, restore
|
|
155
186
|
}
|
|
156
|
-
// Print prompt
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
187
|
+
// Print prompt — bordered input bar at bottom
|
|
188
|
+
let bashMode = false;
|
|
189
|
+
function prompt(skipNewline = false) {
|
|
190
|
+
if (!isTTY)
|
|
191
|
+
return;
|
|
192
|
+
const mode = config.registry.permissionConfig.mode;
|
|
193
|
+
const modeIcon = mode === "full-auto" ? "●" : mode === "auto-confirm" ? "◐" : "○";
|
|
194
|
+
const modeColor = mode === "full-auto" ? s.yellow : mode === "auto-confirm" ? s.green : s.cyan;
|
|
195
|
+
const rows = process.stdout.rows || 24;
|
|
196
|
+
const c = cols();
|
|
197
|
+
if (!skipNewline)
|
|
198
|
+
w.write("\n");
|
|
199
|
+
// Separator line + prompt on last 2 rows
|
|
200
|
+
const permLabel = PERMISSION_LABELS[mode];
|
|
201
|
+
const separator = s.dim("─".repeat(c));
|
|
202
|
+
const rightHints = s.dim(`${bashMode ? "! bash" : permLabel} · shift+tab to cycle · esc to interrupt`);
|
|
203
|
+
const rightLen = stripAnsi(rightHints).length;
|
|
204
|
+
const sepLine = s.dim("─".repeat(Math.max(1, c - rightLen - 1))) + " " + rightHints;
|
|
205
|
+
w.write(`${ESC}${rows - 1};1H${ESC}2K${sepLine}`);
|
|
206
|
+
if (bashMode) {
|
|
207
|
+
w.write(`${ESC}${rows};1H${ESC}2K${s.yellow("!")} `);
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
w.write(`${ESC}${rows};1H${ESC}2K${modeColor(modeIcon)} ${s.dim("▸")} `);
|
|
211
|
+
}
|
|
160
212
|
}
|
|
161
213
|
// Terminal cleanup: restore state on exit
|
|
162
214
|
function cleanupTerminal() {
|
|
@@ -169,13 +221,50 @@ export async function startTui(config, spawner) {
|
|
|
169
221
|
}
|
|
170
222
|
}
|
|
171
223
|
process.on("exit", cleanupTerminal);
|
|
172
|
-
// Setup:
|
|
224
|
+
// Setup: clear screen, status bar at top, content area clean
|
|
173
225
|
if (isTTY) {
|
|
174
|
-
w.write(
|
|
175
|
-
w.write(`${ESC}1;1H`); // move to top
|
|
226
|
+
w.write(`${ESC}2J${ESC}H`); // clear entire screen + home
|
|
176
227
|
statusBar();
|
|
177
228
|
w.write(`${ESC}2;1H`); // move below status bar
|
|
178
|
-
|
|
229
|
+
// Startup banner
|
|
230
|
+
const project = config.phrenCtx?.project;
|
|
231
|
+
const cwd = process.cwd().replace(os.homedir(), "~");
|
|
232
|
+
const permMode = config.registry.permissionConfig.mode;
|
|
233
|
+
const modeColor = permMode === "full-auto" ? s.yellow : permMode === "auto-confirm" ? s.green : s.cyan;
|
|
234
|
+
// Try to show the phren character art alongside info
|
|
235
|
+
let artLines = [];
|
|
236
|
+
try {
|
|
237
|
+
const { PHREN_ART } = await import("@phren/cli/phren-art");
|
|
238
|
+
artLines = PHREN_ART.filter((l) => l.trim());
|
|
239
|
+
}
|
|
240
|
+
catch { /* art not available */ }
|
|
241
|
+
if (artLines.length > 0) {
|
|
242
|
+
// Art on left, info on right
|
|
243
|
+
const info = [
|
|
244
|
+
`${s.brand("◆ phren agent")} ${s.dim("v${AGENT_VERSION}")}`,
|
|
245
|
+
`${s.dim(config.provider.name)}${project ? s.dim(` · ${project}`) : ""}`,
|
|
246
|
+
`${s.dim(cwd)}`,
|
|
247
|
+
``,
|
|
248
|
+
`${modeColor(`${permMode === "full-auto" ? "●" : permMode === "auto-confirm" ? "◐" : "○"} ${permMode}`)} ${s.dim("permissions (shift+tab to cycle)")}`,
|
|
249
|
+
``,
|
|
250
|
+
`${s.dim("Tab")} memory ${s.dim("Shift+Tab")} perms ${s.dim("/help")} cmds ${s.dim("Ctrl+D")} exit`,
|
|
251
|
+
];
|
|
252
|
+
const maxArtWidth = 26; // phren art is ~24 chars wide
|
|
253
|
+
for (let i = 0; i < Math.max(artLines.length, info.length); i++) {
|
|
254
|
+
const artPart = i < artLines.length ? artLines[i] : "";
|
|
255
|
+
const infoPart = i < info.length ? info[i] : "";
|
|
256
|
+
const artPadded = artPart + " ".repeat(Math.max(0, maxArtWidth - stripAnsi(artPart).length));
|
|
257
|
+
w.write(`${artPadded}${infoPart}\n`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
// Fallback: text-only banner
|
|
262
|
+
w.write(`\n ${s.brand("◆ phren agent")} ${s.dim("v${AGENT_VERSION}")}\n`);
|
|
263
|
+
w.write(` ${s.dim(config.provider.name)}${project ? s.dim(` · ${project}`) : ""} ${s.dim(cwd)}\n`);
|
|
264
|
+
w.write(` ${modeColor(`${permMode === "full-auto" ? "●" : permMode === "auto-confirm" ? "◐" : "○"} ${permMode}`)} ${s.dim("permissions (shift+tab to cycle)")}\n\n`);
|
|
265
|
+
w.write(` ${s.dim("Tab")} memory ${s.dim("Shift+Tab")} perms ${s.dim("/help")} cmds ${s.dim("Ctrl+D")} exit\n\n`);
|
|
266
|
+
}
|
|
267
|
+
w.write("\n");
|
|
179
268
|
}
|
|
180
269
|
// Raw stdin for steering
|
|
181
270
|
if (process.stdin.isTTY) {
|
|
@@ -239,13 +328,12 @@ export async function startTui(config, spawner) {
|
|
|
239
328
|
process.stdin.on("keypress", (_ch, key) => {
|
|
240
329
|
if (!key)
|
|
241
330
|
return;
|
|
242
|
-
// Ctrl+D —
|
|
331
|
+
// Ctrl+D — clean exit
|
|
243
332
|
if (key.ctrl && key.name === "d") {
|
|
244
333
|
if (tuiMode === "menu")
|
|
245
|
-
w.write("\x1b[?1049l");
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
w.write(s.dim("\nSession ended.\n"));
|
|
334
|
+
w.write("\x1b[?1049l");
|
|
335
|
+
cleanupTerminal();
|
|
336
|
+
w.write(`\n${s.dim(`${session.turns} turns, ${session.toolCalls} tool calls.`)}\n`);
|
|
249
337
|
resolve(session);
|
|
250
338
|
return;
|
|
251
339
|
}
|
|
@@ -255,7 +343,9 @@ export async function startTui(config, spawner) {
|
|
|
255
343
|
const next = nextPermissionMode(current);
|
|
256
344
|
config.registry.setPermissions({ ...config.registry.permissionConfig, mode: next });
|
|
257
345
|
savePermissionMode(next);
|
|
258
|
-
|
|
346
|
+
const modeColor = next === "full-auto" ? s.yellow : next === "auto-confirm" ? s.green : s.cyan;
|
|
347
|
+
const modeIcon = next === "full-auto" ? "●" : next === "auto-confirm" ? "◐" : "○";
|
|
348
|
+
w.write(` ${modeColor(`${modeIcon} ${next}`)}\n`);
|
|
259
349
|
statusBar();
|
|
260
350
|
if (!running)
|
|
261
351
|
prompt();
|
|
@@ -277,16 +367,57 @@ export async function startTui(config, spawner) {
|
|
|
277
367
|
return;
|
|
278
368
|
}
|
|
279
369
|
// ── Chat mode keys ──────────────────────────────────────────────────
|
|
280
|
-
//
|
|
370
|
+
// Escape — exit bash mode, or clear input
|
|
371
|
+
if (key.name === "escape") {
|
|
372
|
+
if (bashMode) {
|
|
373
|
+
bashMode = false;
|
|
374
|
+
inputLine = "";
|
|
375
|
+
prompt(true);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
if (inputLine) {
|
|
379
|
+
inputLine = "";
|
|
380
|
+
prompt(true);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
// Ctrl+C — progressive: cancel → warn → quit
|
|
281
385
|
if (key.ctrl && key.name === "c") {
|
|
282
386
|
if (running) {
|
|
387
|
+
// Cancel current agent turn
|
|
283
388
|
pendingInput = null;
|
|
284
389
|
w.write(s.yellow("\n [interrupted]\n"));
|
|
390
|
+
ctrlCCount = 0;
|
|
391
|
+
return;
|
|
285
392
|
}
|
|
286
|
-
|
|
393
|
+
if (bashMode) {
|
|
394
|
+
bashMode = false;
|
|
287
395
|
inputLine = "";
|
|
288
|
-
|
|
289
|
-
|
|
396
|
+
prompt(true);
|
|
397
|
+
ctrlCCount = 0;
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
if (inputLine) {
|
|
401
|
+
// Clear input
|
|
402
|
+
inputLine = "";
|
|
403
|
+
prompt(true);
|
|
404
|
+
ctrlCCount = 0;
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
// Nothing to cancel — progressive quit
|
|
408
|
+
ctrlCCount++;
|
|
409
|
+
if (ctrlCCount === 1) {
|
|
410
|
+
w.write(s.dim("\n Press Ctrl+C again to exit.\n"));
|
|
411
|
+
prompt(true);
|
|
412
|
+
// Reset after 2 seconds
|
|
413
|
+
setTimeout(() => { ctrlCCount = 0; }, 2000);
|
|
414
|
+
}
|
|
415
|
+
else {
|
|
416
|
+
// Actually quit
|
|
417
|
+
if (process.stdin.isTTY)
|
|
418
|
+
process.stdin.setRawMode(false);
|
|
419
|
+
w.write(s.dim("\nSession ended.\n"));
|
|
420
|
+
resolve(session);
|
|
290
421
|
}
|
|
291
422
|
return;
|
|
292
423
|
}
|
|
@@ -299,6 +430,25 @@ export async function startTui(config, spawner) {
|
|
|
299
430
|
prompt();
|
|
300
431
|
return;
|
|
301
432
|
}
|
|
433
|
+
// Bash mode: ! prefix runs shell directly
|
|
434
|
+
if (line.startsWith("!") || bashMode) {
|
|
435
|
+
const cmd = bashMode ? line : line.slice(1).trim();
|
|
436
|
+
bashMode = false;
|
|
437
|
+
if (cmd) {
|
|
438
|
+
try {
|
|
439
|
+
const output = execSync(cmd, { encoding: "utf-8", timeout: 30_000, cwd: process.cwd(), stdio: ["ignore", "pipe", "pipe"] });
|
|
440
|
+
w.write(output);
|
|
441
|
+
if (!output.endsWith("\n"))
|
|
442
|
+
w.write("\n");
|
|
443
|
+
}
|
|
444
|
+
catch (err) {
|
|
445
|
+
const e = err;
|
|
446
|
+
w.write(s.red(e.stderr || e.message || "Command failed") + "\n");
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
prompt();
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
302
452
|
// Slash commands
|
|
303
453
|
if (line === "/mode") {
|
|
304
454
|
inputMode = inputMode === "steering" ? "queue" : "steering";
|
|
@@ -307,7 +457,14 @@ export async function startTui(config, spawner) {
|
|
|
307
457
|
prompt();
|
|
308
458
|
return;
|
|
309
459
|
}
|
|
310
|
-
if (handleCommand(line, {
|
|
460
|
+
if (handleCommand(line, {
|
|
461
|
+
session,
|
|
462
|
+
contextLimit,
|
|
463
|
+
undoStack: [],
|
|
464
|
+
providerName: config.provider.name,
|
|
465
|
+
currentModel: config.provider.model,
|
|
466
|
+
spawner,
|
|
467
|
+
})) {
|
|
311
468
|
prompt();
|
|
312
469
|
return;
|
|
313
470
|
}
|
|
@@ -332,6 +489,12 @@ export async function startTui(config, spawner) {
|
|
|
332
489
|
}
|
|
333
490
|
// Regular character
|
|
334
491
|
if (key.sequence && !key.ctrl && !key.meta) {
|
|
492
|
+
// ! at start of empty input toggles bash mode
|
|
493
|
+
if (key.sequence === "!" && inputLine === "" && !bashMode) {
|
|
494
|
+
bashMode = true;
|
|
495
|
+
prompt(true);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
335
498
|
inputLine += key.sequence;
|
|
336
499
|
w.write(key.sequence);
|
|
337
500
|
}
|
|
@@ -360,9 +523,11 @@ export async function startTui(config, spawner) {
|
|
|
360
523
|
if (!text.endsWith("\n"))
|
|
361
524
|
w.write("\n");
|
|
362
525
|
},
|
|
363
|
-
onToolStart: (name,
|
|
526
|
+
onToolStart: (name, input, count) => {
|
|
364
527
|
flushTextBuffer();
|
|
365
|
-
|
|
528
|
+
const preview = formatToolInput(name, input);
|
|
529
|
+
const countLabel = count > 1 ? s.dim(` (${count} tools)`) : "";
|
|
530
|
+
w.write(`${ESC}2K ${s.dim("◌")} ${s.gray(name)} ${s.dim(preview)}${countLabel}\r`);
|
|
366
531
|
},
|
|
367
532
|
onToolEnd: (name, input, output, isError, dur) => {
|
|
368
533
|
w.write(`${ESC}2K\r`);
|
|
@@ -386,7 +551,7 @@ export async function startTui(config, spawner) {
|
|
|
386
551
|
};
|
|
387
552
|
async function runAgentTurn(userInput) {
|
|
388
553
|
running = true;
|
|
389
|
-
w.write(s.dim("
|
|
554
|
+
w.write(`${ESC}2K ${s.dim("◌ thinking...")}\r`);
|
|
390
555
|
try {
|
|
391
556
|
await runTurn(userInput, session, config, tuiHooks);
|
|
392
557
|
statusBar();
|
|
@@ -410,42 +575,3 @@ export async function startTui(config, spawner) {
|
|
|
410
575
|
prompt();
|
|
411
576
|
return done;
|
|
412
577
|
}
|
|
413
|
-
// ── Settings persistence ─────────────────────────────────────────────────────
|
|
414
|
-
const SETTINGS_FILE = path.join(os.homedir(), ".phren-agent", "settings.json");
|
|
415
|
-
function loadInputMode() {
|
|
416
|
-
try {
|
|
417
|
-
const data = JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf-8"));
|
|
418
|
-
if (data.inputMode === "queue")
|
|
419
|
-
return "queue";
|
|
420
|
-
}
|
|
421
|
-
catch { }
|
|
422
|
-
return "steering";
|
|
423
|
-
}
|
|
424
|
-
function saveInputMode(mode) {
|
|
425
|
-
try {
|
|
426
|
-
const dir = path.dirname(SETTINGS_FILE);
|
|
427
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
428
|
-
let data = {};
|
|
429
|
-
try {
|
|
430
|
-
data = JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf-8"));
|
|
431
|
-
}
|
|
432
|
-
catch { }
|
|
433
|
-
data.inputMode = mode;
|
|
434
|
-
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(data, null, 2) + "\n");
|
|
435
|
-
}
|
|
436
|
-
catch { }
|
|
437
|
-
}
|
|
438
|
-
function savePermissionMode(mode) {
|
|
439
|
-
try {
|
|
440
|
-
const dir = path.dirname(SETTINGS_FILE);
|
|
441
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
442
|
-
let data = {};
|
|
443
|
-
try {
|
|
444
|
-
data = JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf-8"));
|
|
445
|
-
}
|
|
446
|
-
catch { }
|
|
447
|
-
data.permissionMode = mode;
|
|
448
|
-
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(data, null, 2) + "\n");
|
|
449
|
-
}
|
|
450
|
-
catch { }
|
|
451
|
-
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phren/agent",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "Coding agent with persistent memory — powered by phren",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -12,12 +12,8 @@
|
|
|
12
12
|
"files": [
|
|
13
13
|
"dist"
|
|
14
14
|
],
|
|
15
|
-
"scripts": {
|
|
16
|
-
"build": "tsc -p tsconfig.json",
|
|
17
|
-
"test": "echo 'Run tests from repo root: pnpm -w test'"
|
|
18
|
-
},
|
|
19
15
|
"dependencies": {
|
|
20
|
-
"@phren/cli": "
|
|
16
|
+
"@phren/cli": "0.1.0"
|
|
21
17
|
},
|
|
22
18
|
"engines": {
|
|
23
19
|
"node": ">=20.0.0"
|
|
@@ -35,5 +31,9 @@
|
|
|
35
31
|
"type": "git",
|
|
36
32
|
"url": "git+https://github.com/alaarab/phren.git",
|
|
37
33
|
"directory": "packages/agent"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsc -p tsconfig.json",
|
|
37
|
+
"test": "echo 'Run tests from repo root: pnpm -w test'"
|
|
38
38
|
}
|
|
39
|
-
}
|
|
39
|
+
}
|