@vietor/easy-agent 0.4.3 → 0.4.4

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/README.md CHANGED
@@ -73,17 +73,17 @@ A stdio server omits `type` (or sets `"stdio"`); only `command` is required, `ar
73
73
 
74
74
  Easy Agent reads instructions files and appends them to the system prompt, so you can set persistent rules, conventions, or preferences. It looks in two places:
75
75
 
76
- 1. **Global** — your home directory, applied to every conversation. Checks `~/.agents/AGENTS.md` first, then `~/.claude/CLAUDE.md`; uses the first one found.
76
+ 1. **Global** — your home directory, applied to every conversation. Checks `~/.easy-agent/AGENTS.md` first, then `~/.claude/CLAUDE.md`; uses the first one found.
77
77
  2. **Project** — your current working directory, applied per-project. Checks `./AGENTS.md` first, then `./CLAUDE.md`; uses the first one found.
78
78
 
79
79
  Both global and project files are loaded and concatenated into the system prompt if they exist.
80
80
 
81
81
  ### Skills (optional)
82
82
 
83
- Skills are reusable prompts that register themselves as slash commands. Create a subdirectory for each skill under `~/.agents/skills/` (or `~/.claude/skills/`) with a `SKILL.md` file:
83
+ Skills are reusable prompts that register themselves as slash commands. Create a subdirectory for each skill under `~/.easy-agent/skills/` (or `~/.claude/skills/`) with a `SKILL.md` file:
84
84
 
85
85
  ```
86
- ~/.claude/skills/
86
+ ~/.easy-agent/skills/
87
87
  deploy/
88
88
  SKILL.md
89
89
  review/
@@ -108,11 +108,16 @@ Only `name` is required; if omitted the directory name is used. The body is the
108
108
  Launch the TUI:
109
109
 
110
110
  ```bash
111
- easy-agent
111
+ easy-agent # start a new session
112
+ easy-agent --continue # resume the most recent session
113
+ easy-agent --resume <id> # resume a specific session by ID
114
+ easy-agent --resume # list all saved sessions for this directory
112
115
  ```
113
116
 
114
117
  Type a prompt and press Enter. The agent streams its reply and calls tools as needed, showing each tool call and a one-line preview of its result. It iterates until the task is done (capped at 50 tool rounds per turn).
115
118
 
119
+ The TUI automatically adapts to your terminal width. A status bar at the bottom shows the current context token count, with hints for keyboard shortcuts (<kbd>ESC</kbd> to abort a running task, <kbd>/</kbd> for commands).
120
+
116
121
  ### Built-in tools
117
122
 
118
123
  - **Shell** — run shell commands using this platform's native syntax.
package/dist/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { main } from "./main.js";
3
- main().catch((e) => {
3
+ main(process.argv.slice(2)).catch((e) => {
4
4
  console.error(e);
5
5
  process.exit(1);
6
6
  });
package/dist/main.js CHANGED
@@ -1,9 +1,11 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { homedir } from "node:os";
2
3
  import { join } from "node:path";
3
4
  import { loadConfig } from "./config.js";
4
5
  import { tryLoadSkills, tryReadFileText, createSession, } from "@vietor/easy-agent-core";
5
6
  import { builtinCommands } from "./cmds/builtin.js";
6
7
  import { startApp } from "./tui/App.js";
8
+ import { FileSessionPersistence } from "./util/sessionStore.js";
7
9
  const SYSTEM_PROMPT_BASE = [
8
10
  "You are Easy Agent, an autonomous assistant. You complete tasks by calling tools, inspecting their results, and iterating until the work is done.",
9
11
  `Output:
@@ -16,11 +18,64 @@ const SYSTEM_PROMPT_BASE = [
16
18
  `Decision making:
17
19
  - When a decision belongs to the user, call AskUser and wait for the answer rather than listing options in prose. Ask when there are multiple reasonable approaches, an irreversible or consequential action, or the request is ambiguous; when you have enough to proceed, act without asking.`,
18
20
  ].join("\n\n");
19
- export async function main() {
21
+ function parseArgs(argv) {
22
+ let mode = "new";
23
+ let id;
24
+ for (let i = 0; i < argv.length; i++) {
25
+ const a = argv[i];
26
+ if (a === "-c" || a === "--continue") {
27
+ mode = "continue";
28
+ }
29
+ else if (a === "-r" || a === "--resume") {
30
+ mode = "resume";
31
+ const next = argv[i + 1];
32
+ if (next && !next.startsWith("-")) {
33
+ id = next;
34
+ i++;
35
+ }
36
+ }
37
+ }
38
+ return { mode, id };
39
+ }
40
+ async function listSessions(store) {
41
+ const sessions = await store.listSessions();
42
+ if (!sessions.length) {
43
+ console.log("No previous sessions found in this directory.");
44
+ return;
45
+ }
46
+ console.log("Previous sessions (most recent first):");
47
+ for (const s of sessions) {
48
+ const title = s.title ? ` ${s.title}` : "";
49
+ console.log(` ${s.id} ${new Date(s.updatedAt).toLocaleString()}${title}`);
50
+ }
51
+ console.log("\nResume with: easy-agent --resume <id>");
52
+ }
53
+ export async function main(argv = []) {
54
+ const { mode, id } = parseArgs(argv);
55
+ const store = new FileSessionPersistence(process.cwd());
56
+ if (mode === "resume" && !id) {
57
+ await listSessions(store);
58
+ return;
59
+ }
20
60
  const config = loadConfig();
21
- const globalSkills = tryLoadSkills(join(homedir(), ".agents", "skills"))
61
+ let sessionId;
62
+ let resume = false;
63
+ if (mode === "continue") {
64
+ const sessions = await store.listSessions();
65
+ if (sessions.length) {
66
+ sessionId = sessions[0].id;
67
+ resume = true;
68
+ }
69
+ }
70
+ else if (mode === "resume" && id) {
71
+ sessionId = id;
72
+ resume = true;
73
+ }
74
+ if (!sessionId)
75
+ sessionId = randomUUID();
76
+ const globalSkills = tryLoadSkills(join(homedir(), ".easy-agent", "skills"))
22
77
  ?? tryLoadSkills(join(homedir(), ".claude", "skills"));
23
- const globalPrompt = tryReadFileText(join(homedir(), ".agents", "AGENTS.md"))
78
+ const globalPrompt = tryReadFileText(join(homedir(), ".easy-agent", "AGENTS.md"))
24
79
  ?? tryReadFileText(join(homedir(), ".claude", "CLAUDE.md"));
25
80
  const projectPrompt = tryReadFileText(join(process.cwd(), "AGENTS.md"))
26
81
  ?? tryReadFileText(join(process.cwd(), "CLAUDE.md"));
@@ -36,8 +91,12 @@ export async function main() {
36
91
  builtinTools: {
37
92
  askUser: true,
38
93
  todoWrite: true
39
- }
94
+ },
95
+ sessionId,
96
+ persistence: store,
40
97
  });
98
+ if (resume)
99
+ await session.restore();
41
100
  const app = startApp(session);
42
101
  await app.waitUntilExit().finally(() => session.dispose());
43
102
  }
package/dist/tui/App.js CHANGED
@@ -1,17 +1,18 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
3
- import { Box, render, Text, useApp, useInput } from "ink";
3
+ import { Box, render, useApp, useInput, useWindowSize } from "ink";
4
4
  import { Markdown } from "./components/Markdown.js";
5
- import { LogList } from "./LogList.js";
5
+ import { TimelineList } from "./TimelineList.js";
6
6
  import { TodoView } from "./TodoView.js";
7
7
  import { AppHeader } from "./AppHeader.js";
8
8
  import { PromptOrCommandInput } from "./PromptOrCommandInput.js";
9
9
  import { QuestionView } from "./QuestionView.js";
10
10
  import { Spinner } from "./Spinner.js";
11
- import { compactDisplay } from "../util/format.js";
11
+ import { StatusBar } from "./StatusBar.js";
12
12
  const STREAM_FRAME_MS = 120;
13
13
  export function App({ session }) {
14
14
  const { exit } = useApp();
15
+ const { columns } = useWindowSize();
15
16
  const view = useSyncExternalStore(session.subscribe, session.getSnapshot);
16
17
  const [runState, setRunState] = useState({ running: false, elapsed: 0, promptTokens: 0, completionTokens: 0 });
17
18
  const [streamingText, setStreamingText] = useState("");
@@ -20,13 +21,22 @@ export function App({ session }) {
20
21
  const allCmds = useMemo(() => session.commandSchemas, [session]);
21
22
  const pendingQuestion = session.getPendingQuestion();
22
23
  useEffect(() => {
23
- session.setRunHandler({
24
- onStream: (text) => {
25
- streamingRef.current = text;
26
- scheduleStreamingRender();
27
- },
28
- onState: setRunState,
24
+ const unsub = session.subscribeEvents((e) => {
25
+ switch (e.type) {
26
+ case "assistant_delta":
27
+ streamingRef.current += e.text;
28
+ scheduleStreamingRender();
29
+ break;
30
+ case "assistant":
31
+ streamingRef.current = "";
32
+ setStreamingText("");
33
+ break;
34
+ case "state":
35
+ setRunState(e);
36
+ break;
37
+ }
29
38
  });
39
+ return unsub;
30
40
  }, []);
31
41
  const scheduleStreamingRender = () => {
32
42
  if (renderTimerRef.current)
@@ -54,7 +64,7 @@ export function App({ session }) {
54
64
  });
55
65
  async function handleCommand(name, args) {
56
66
  await session.executeCommand(name, args);
57
- if (session.localStore.get("exitRequested"))
67
+ if (session.localStore.get("exitRequested") != null)
58
68
  exit();
59
69
  }
60
70
  async function handlePrompt(text) {
@@ -73,13 +83,13 @@ export function App({ session }) {
73
83
  runningView = (_jsx(QuestionView, { question: pendingQuestion, onAnswer: (ans) => session.submitAnswer(pendingQuestion.id, ans) }));
74
84
  }
75
85
  else if (streamingText) {
76
- runningView = (_jsx(Box, { marginTop: 1, paddingLeft: 1, paddingRight: 1, children: _jsx(Markdown, { color: "green", children: streamingText }) }));
86
+ runningView = (_jsx(Box, { marginTop: 1, paddingLeft: 1, paddingRight: 1, borderStyle: "single", borderTop: false, borderRight: false, borderBottom: false, borderColor: "gray", children: _jsx(Markdown, { color: "green", children: streamingText }) }));
77
87
  }
78
88
  else {
79
89
  runningView = (_jsx(Box, { marginTop: 1, paddingLeft: 1, children: _jsx(Spinner, { label: "thinking", elapsed: runState.elapsed, promptTokens: runState.promptTokens, completionTokens: runState.completionTokens }) }));
80
90
  }
81
91
  }
82
- return (_jsxs(Box, { flexDirection: "column", minWidth: 80, children: [_jsx(AppHeader, {}), _jsx(LogList, { session: session }), view.todos.length > 0 ? _jsx(TodoView, { todos: view.todos }) : null, runningView, !runState.running ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { dimColor: true, children: ["[CTX ", compactDisplay(session.contextTokens), "] \u00B7 ESC to stop \u00B7 type / for commands"] }), _jsx(PromptOrCommandInput, { commands: allCmds, onCommand: handleCommand, onPrompt: handlePrompt })] })) : null] }));
92
+ return (_jsxs(Box, { width: columns, flexDirection: "column", children: [_jsx(AppHeader, {}), _jsx(TimelineList, { session: session }), view.todos.length > 0 ? _jsx(TodoView, { todos: view.todos }) : null, runningView, !runState.running ? (_jsxs(_Fragment, { children: [_jsx(StatusBar, { contextTokens: session.contextTokens }), _jsx(PromptOrCommandInput, { commands: allCmds, onCommand: handleCommand, onPrompt: handlePrompt })] })) : null] }));
83
93
  }
84
94
  export function startApp(session) {
85
95
  process.stdout.write("");
@@ -1,8 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { memo } from "react";
3
- import { Box, Text } from "ink";
3
+ import { Box, Text, useWindowSize } from "ink";
4
4
  import { getPackageInfo } from "../util/package.js";
5
5
  export const AppHeader = memo(function AppHeader() {
6
+ const { columns } = useWindowSize();
6
7
  const pkginfo = getPackageInfo();
7
- return (_jsxs(Box, { flexDirection: "column", paddingLeft: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: "red", bold: true, children: "Easy Agent" }), _jsxs(Text, { dimColor: true, children: [" v", pkginfo.version] })] }), _jsx(Text, { dimColor: true, children: process.cwd() })] }));
8
+ return (_jsxs(Box, { width: columns, paddingX: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Easy Agent" }), _jsxs(Text, { dimColor: true, children: [" v", pkginfo.version] })] }), _jsx(Text, { dimColor: true, children: process.cwd() })] }));
8
9
  });
@@ -61,5 +61,5 @@ export function PromptOrCommandInput({ commands, onCommand, onPrompt }) {
61
61
  return (_jsxs(Box, { flexDirection: "column", children: [showMenu && filtered.length > 0 ? (_jsx(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", children: visibleItems.items.map((cmd, i) => {
62
62
  const realIdx = visibleItems.start + i;
63
63
  return (_jsxs(Box, { children: [_jsxs(Text, { color: realIdx === selectedIndex ? "cyan" : undefined, children: [realIdx === selectedIndex ? "▸ " : " ", "/", cmd.name] }), _jsxs(Text, { dimColor: true, children: [" ", cmd.description] })] }, cmd.name));
64
- }) })) : null, _jsxs(Box, { borderStyle: "single", borderLeft: false, borderRight: false, borderColor: "gray", children: [_jsx(Text, { color: "gray", children: "\u276F " }), _jsx(TextInput, { value: input, onChange: setInput, onSubmit: onSubmit })] })] }));
64
+ }) })) : null, _jsxs(Box, { borderStyle: "single", borderLeft: false, borderRight: false, borderColor: "gray", children: [_jsx(Text, { color: "gray", children: "> " }), _jsx(TextInput, { value: input, onChange: setInput, onSubmit: onSubmit })] })] }));
65
65
  }
@@ -0,0 +1,8 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { memo } from "react";
3
+ import { Box, Text, useWindowSize } from "ink";
4
+ import { compactDisplay } from "../util/format.js";
5
+ export const StatusBar = memo(function StatusBar({ contextTokens }) {
6
+ const { columns } = useWindowSize();
7
+ return (_jsx(Box, { width: columns, paddingX: 1, flexDirection: "row", children: _jsxs(Text, { dimColor: true, children: ["Context: ", compactDisplay(contextTokens), " tokens \u00B7 ESC to stop \u00B7 / for commands"] }) }));
8
+ });
@@ -1,9 +1,9 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { memo, useSyncExternalStore } from "react";
3
3
  import { Box } from "ink";
4
- import { LogView } from "./LogView.js";
5
- export const LogList = memo(function LogList({ session }) {
4
+ import { TimelineView } from "./TimelineView.js";
5
+ export const TimelineList = memo(function TimelineList({ session }) {
6
6
  const view = useSyncExternalStore(session.subscribe, session.getSnapshot);
7
- const entries = view.logEntries;
8
- return (_jsx(Box, { flexDirection: "column", paddingLeft: 1, paddingRight: 1, children: entries.map((entry, i) => (_jsx(LogView, { entry: entry }, i))) }));
7
+ const entries = view.timeline;
8
+ return (_jsx(Box, { flexDirection: "column", paddingLeft: 1, paddingRight: 1, children: entries.map((entry, i) => (_jsx(TimelineView, { entry: entry }, i))) }));
9
9
  });
@@ -11,7 +11,7 @@ function preview(isError, text) {
11
11
  const byteCount = Buffer.byteLength(text, "utf-8");
12
12
  return `Result: ${byteCount} bytes, ${lineCount} lines`;
13
13
  }
14
- export const LogView = memo(function Entry({ entry }) {
14
+ export const TimelineView = memo(function Entry({ entry }) {
15
15
  switch (entry.kind) {
16
16
  case "user":
17
17
  return (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: "\u276F " }), entry.text] }) }));
@@ -20,11 +20,11 @@ export const LogView = memo(function Entry({ entry }) {
20
20
  case "assistant":
21
21
  return (_jsx(Box, { marginTop: 1, children: _jsx(Markdown, { color: "green", children: entry.text }) }));
22
22
  case "tool":
23
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { color: "yellow", children: "\u25CF " }), _jsx(Text, { color: "yellow", bold: true, children: entry.name }), entry.summary ? _jsxs(Text, { dimColor: true, children: [" ", entry.summary] }) : null] }), entry.result !== null ? (_jsx(Text, { color: entry.isError ? "red" : "gray", children: ` ${preview(entry.isError ?? false, entry.result)}` })) : null] }));
23
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: "yellow", children: "\u25CF " }), _jsx(Text, { color: "yellow", bold: true, children: entry.name }), entry.summary ? _jsxs(Text, { dimColor: true, children: [" ", entry.summary] }) : null] }), entry.result !== null ? (_jsx(Text, { color: entry.isError ? "red" : "gray", children: ` ${preview(entry.isError ?? false, entry.result)}` })) : null] }));
24
24
  case "retry":
25
25
  return (_jsx(Box, { children: _jsxs(Text, { children: [_jsx(Text, { color: "yellow", children: "\u21BB " }), _jsxs(Text, { dimColor: true, children: ["retry ", entry.attempt, "/", entry.max] })] }) }));
26
26
  case "error":
27
- return (_jsx(Box, { children: _jsxs(Text, { color: "red", children: [_jsx(Text, { bold: true, children: "\u2717 " }), entry.text] }) }));
27
+ return (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "red", children: [_jsx(Text, { bold: true, children: "\u2717 " }), entry.text] }) }));
28
28
  case "interrupted":
29
29
  return (_jsx(Box, { children: _jsxs(Text, { children: [_jsx(Text, { color: "yellow", children: "\u25FC " }), _jsx(Text, { dimColor: true, children: "interrupted" })] }) }));
30
30
  case "question":
@@ -0,0 +1,104 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ function encodeCwd(cwd) {
5
+ return cwd.replace(/[\/\\:]/g, "-");
6
+ }
7
+ export class FileSessionPersistence {
8
+ cwd;
9
+ dir;
10
+ constructor(cwd) {
11
+ this.cwd = cwd;
12
+ this.dir = join(homedir(), ".easy-agent", "projects", encodeCwd(cwd));
13
+ }
14
+ file(sessionId) {
15
+ return join(this.dir, `${sessionId}.jsonl`);
16
+ }
17
+ ensureDir() {
18
+ if (!existsSync(this.dir))
19
+ mkdirSync(this.dir, { recursive: true });
20
+ }
21
+ async load(sessionId) {
22
+ const path = this.file(sessionId);
23
+ if (!existsSync(path))
24
+ return null;
25
+ const messages = [];
26
+ let todos = [];
27
+ for (const line of readFileSync(path, "utf-8").split("\n")) {
28
+ if (!line.trim())
29
+ continue;
30
+ let rec;
31
+ try {
32
+ rec = JSON.parse(line);
33
+ }
34
+ catch {
35
+ continue;
36
+ }
37
+ const r = rec;
38
+ if (r.t === "m" && r.m)
39
+ messages.push(r.m);
40
+ else if (r.t === "todo" && r.todos)
41
+ todos = r.todos;
42
+ }
43
+ return { messages, todos };
44
+ }
45
+ async saveAll(sessionId, state) {
46
+ this.ensureDir();
47
+ const lines = state.messages
48
+ .map((m) => JSON.stringify({ t: "m", m }))
49
+ .concat([JSON.stringify({ t: "todo", todos: state.todos })]);
50
+ writeFileSync(this.file(sessionId), lines.join("\n") + "\n", "utf-8");
51
+ }
52
+ async listSessions() {
53
+ if (!existsSync(this.dir))
54
+ return [];
55
+ const out = [];
56
+ for (const name of readdirSync(this.dir)) {
57
+ if (!name.endsWith(".jsonl"))
58
+ continue;
59
+ const id = name.slice(0, -6);
60
+ const path = join(this.dir, name);
61
+ try {
62
+ const stat = statSync(path);
63
+ out.push({
64
+ id,
65
+ title: this.readTitle(path),
66
+ createdAt: stat.birthtimeMs || stat.mtimeMs,
67
+ updatedAt: stat.mtimeMs,
68
+ cwd: this.cwd,
69
+ });
70
+ }
71
+ catch { }
72
+ }
73
+ return out.sort((a, b) => b.updatedAt - a.updatedAt);
74
+ }
75
+ async delete(sessionId) {
76
+ const path = this.file(sessionId);
77
+ if (existsSync(path))
78
+ unlinkSync(path);
79
+ }
80
+ readTitle(path) {
81
+ const first = this.readFirstUser(path);
82
+ if (!first)
83
+ return undefined;
84
+ const oneline = first.replace(/\s+/g, " ").trim();
85
+ return oneline.length > 60 ? oneline.slice(0, 60) + "…" : oneline;
86
+ }
87
+ readFirstUser(path) {
88
+ for (const line of readFileSync(path, "utf-8").split("\n")) {
89
+ if (!line.trim())
90
+ continue;
91
+ let rec;
92
+ try {
93
+ rec = JSON.parse(line);
94
+ }
95
+ catch {
96
+ continue;
97
+ }
98
+ const r = rec;
99
+ if (r.t === "m" && r.m && r.m.role === "user" && typeof r.m.content === "string")
100
+ return r.m.content;
101
+ }
102
+ return undefined;
103
+ }
104
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vietor/easy-agent",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "type": "module",
5
5
  "main": "dist/cli.js",
6
6
  "bin": {
@@ -22,7 +22,7 @@
22
22
  "react": "^19.2.7",
23
23
  "string-width": "^8.2.1",
24
24
  "zod": "^4.4.3",
25
- "@vietor/easy-agent-core": "0.4.3"
25
+ "@vietor/easy-agent-core": "0.4.4"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/node": "^22.0.0",