Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -112,7 +112,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design:
pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg
```

Python: `uv run pytest tests/ -v` (688) — import check: `uv run python -c "from emrg.client.app import run_client"`
Python: `uv run pytest tests/ -v` (694) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (187: 43 daemon_client + 19 conn-manager + 22 app-commands + 67 renderer smoke + 15 i18n + 7 integration + 3 commands + 4 build-config + 7 gui-state) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js`
CI: `uv run pytest` + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文)
Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响)
Expand Down
2 changes: 1 addition & 1 deletion README.cn.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -144,7 +144,7 @@ EMRG 不只是追赶——它自己追上来。

贡献指南、源码安装、架构、详细 FAQ → [DEVELOPMENT.md](DEVELOPMENT.md)。

快速检查:`uv run pytest tests/ -v`(当前 688 项)· `cd emrg/gui && npm test`(187 项:43 daemon_client + 19 conn-manager + 22 app-commands + 67 renderer smoke + 15 i18n + 7 integration + 3 commands + 4 build-config + 7 gui-state)
快速检查:`uv run pytest tests/ -v`(当前 694 项)· `cd emrg/gui && npm test`(188 项:43 daemon_client + 19 conn-manager + 22 app-commands + 67 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state)

---

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -144,7 +144,7 @@ They're products. EMRG is an experiment in *closing the loop* — the AI improve

Contributing, source installs, architecture, and the full FAQ → [DEVELOPMENT.md](DEVELOPMENT.md).

Quick checks: `uv run pytest tests/ -v` (currently 688 items) · `cd emrg/gui && npm test` (187: 43 daemon_client + 19 conn-manager + 22 app-commands + 67 renderer smoke + 15 i18n + 7 integration + 3 commands + 4 build-config + 7 gui-state)
Quick checks: `uv run pytest tests/ -v` (currently 694 items) · `cd emrg/gui && npm test` (188: 43 daemon_client + 19 conn-manager + 22 app-commands + 67 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state)

---

Expand Down
4 changes: 3 additions & 1 deletion emrg/gui/daemon_client.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -67,6 +67,8 @@ const RESPONSE_TYPES = {
github_connect: "github_connect_result", // Windows GCM rant Stage 2:PAT 授权(daemon.py github_connect)
github_disconnect: "github_disconnect_result", // Windows GCM rant Stage 2:断开(daemon.py github_disconnect)
github_connect_web: "github_connect_web_result", // Stage 2b:device flow(daemon.py github_connect_web)
list_files: "files_list", // 右栏工作区面板 P1:目录树(daemon.py list_files)
read_file: "file_content", // 右栏工作区面板 P1:文件查看器(daemon.py read_file)
};

class DaemonClient {
Expand DownExpand Up@@ -599,7 +601,7 @@ class DaemonClient {
this._emit("cancelled", frame);
return;
}
if (["sessions_list", "models_list", "history_list", "tasks_list"].includes(frame.type)) {
if (["sessions_list", "models_list", "history_list", "tasks_list", "files_list"].includes(frame.type)) {
this._emit("list_result", frame);
return;
}
Expand Down
19 changes: 19 additions & 0 deletions emrg/gui/main.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -443,6 +443,25 @@ vision = false
return frame.memory || { id: memoryId, content: "" };
});

// 右栏工作区面板 P1(rant 2026-08-11T12:20:35):list_files / read_file 透传
// 走 requireConn() = 当前会话连接天然认证;daemon list_files → files_list
ipcMain.handle("emrg:listFiles", async (_e, { path: p } = {}) => {
if (typeof p !== "string" || !p.trim()) throw new Error("invalid path");
const frame = await requireConn().sendCommandAndWait("list_files", { path: p.trim() }, 10000);
if (frame.error) throw new Error(frame.error);
return { entries: frame.entries || [], truncated: !!frame.truncated };
});

ipcMain.handle("emrg:readFile", async (_e, { path: p, startLine, lineLimit } = {}) => {
if (typeof p !== "string" || !p.trim()) throw new Error("invalid path");
const params = { path: p.trim() };
if (startLine !== undefined) params.start_line = startLine;
if (lineLimit !== undefined) params.line_limit = lineLimit;
const frame = await requireConn().sendCommandAndWait("read_file", params, 10000);
if (frame.error) throw new Error(frame.error);
return { content: frame.content || "", binary: !!frame.binary, truncated: !!frame.truncated, totalLines: frame.total_lines };
});

ipcMain.handle("emrg:listSkills", async () => {
// GUI / 指令 P3:/skills — 读取技能列表(TUI 本地 load_skills 等价物,daemon 无协议)
// 技能在 ~/.emrg/skills/*.md(user)与 <projectDir>/.emrg/skills/*.md(project)
Expand Down
2 changes: 2 additions & 0 deletions emrg/gui/preload.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,8 @@ const api = {
rewindSession: (payload) => ipcRenderer.invoke("emrg:rewindSession", payload),
listMemories: (payload) => ipcRenderer.invoke("emrg:listMemories", payload),
readMemory: (payload) => ipcRenderer.invoke("emrg:readMemory", payload),
listFiles: (payload) => ipcRenderer.invoke("emrg:listFiles", payload), // 右栏工作区 P1:目录树
readFile: (payload) => ipcRenderer.invoke("emrg:readFile", payload), // 右栏工作区 P1:文件查看器
listSkills: () => ipcRenderer.invoke("emrg:listSkills"),
listProjects: () => ipcRenderer.invoke("emrg:listProjects"),
listProjectSessions: (payload) => ipcRenderer.invoke("emrg:listProjectSessions", payload),
Expand Down
20 changes: 20 additions & 0 deletions emrg/gui/test/build-config.test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -91,3 +91,23 @@ test("every main.js/preload.js local require is covered by files whitelist", ()
);
});


test("preload exposes workspace-panel APIs (listFiles/readFile)", () => {
// 右栏工作区面板 P1(rant 2026-08-11T12:20:35 P1.2):preload 桥必须暴露两个新 API,
// 否则 renderer 无法触达 daemon 的 list_files/read_file 命令。
const preload = fs.readFileSync(path.join(GUI_ROOT, "preload.js"), "utf-8");
for (const [api, channel] of [
["listFiles", "emrg:listFiles"],
["readFile", "emrg:readFile"],
]) {
assert.match(
preload,
new RegExp(`${api}: \\((payload)?\\) => ipcRenderer\\.invoke\\("${channel}"`),
`preload.js must expose ${api} → ${channel}`
);
}
const main = fs.readFileSync(path.join(GUI_ROOT, "main.js"), "utf-8");
for (const channel of ["emrg:listFiles", "emrg:readFile"]) {
assert.match(main, new RegExp(`ipcMain\\.handle\\("${channel}"`), `main.js must handle ${channel}`);
}
});
17 changes: 16 additions & 1 deletion emrg/gui/test/daemon_client.test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -592,14 +592,15 @@ test("帧分类(G21+G58):各帧事件分发正确", async () => {
send({ error: "boom" });
send({ identity: { instance_id: "i1" }, uptime_seconds: 10, evolution_count: 3, model: "m1" });
send({ type: "sessions_list", sessions: [] });
send({ type: "files_list", path: "/tmp", entries: [] }); // 右栏工作区 P1:list_result 白名单
send({ type: "resume_result", session_id: "s1" });
send({ type: "model_set", model: "m2" });
send({ type: "session_deleted", session_id: "s1" });

const types = seen.map(([t]) => t);
assert.deepStrictEqual(types, [
"tool_started", "tool_finished", "message_delta", "group_cleared", "done", "cancelled", "error", "pong",
"list_result", "command_result", "command_result", "command_result",
"list_result", "list_result", "command_result", "command_result", "command_result",
]);
// pong 帧数据
const pong = seen.find(([t]) => t === "pong")[1];
Expand DownExpand Up@@ -676,6 +677,20 @@ test("RESPONSE_TYPES 映射表与 daemon 命令名一致(修正 clear/rename/t
const r8 = await p8;
assert.strictEqual(r8.type, "github_connect_web_result");
assert.strictEqual(r8.code, "ABCD-1234");
// list_files → files_list(右栏工作区面板 P1,rant 2026-08-11T12:20:35)
const p9 = client.sendCommandAndWait("list_files", { path: "/tmp" }, 2000);
await new Promise((r) => setTimeout(r, 10));
currentMockWs.emit("message", Buffer.from(JSON.stringify({ type: "files_list", path: "/tmp", entries: [], truncated: false })));
const r9 = await p9;
assert.strictEqual(r9.type, "files_list");
assert.deepStrictEqual(r9.entries, []);
// read_file → file_content(右栏工作区面板 P1)
const p10 = client.sendCommandAndWait("read_file", { path: "/tmp/a.txt" }, 2000);
await new Promise((r) => setTimeout(r, 10));
currentMockWs.emit("message", Buffer.from(JSON.stringify({ type: "file_content", path: "/tmp/a.txt", content: "hi", binary: false })));
const r10 = await p10;
assert.strictEqual(r10.type, "file_content");
assert.strictEqual(r10.content, "hi");
});

test("命令-响应配对超时 → reject(G93)", async () => {
Expand Down
171 changes: 171 additions & 0 deletions emrg/server/daemon.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -1243,6 +1243,33 @@ async def _process_message(

await self._handle_read_memory(scope, memory_id, session_id, cwd, ws)

elif msg_type == "list_files":
# GUI right-panel workspace file browser (rant 2026-08-11T12:20:35 P1.1)
path_str = msg.get("path", "")
if not path_str:
await self._send(ws, {
"type": "files_list",
"error": "list_files requires path",
})
return
await self._handle_list_files(path_str, ws)

elif msg_type == "read_file":
# GUI right-panel file viewer (rant 2026-08-11T12:20:35 P1.1)
path_str = msg.get("path", "")
if not path_str:
await self._send(ws, {
"type": "file_content",
"error": "read_file requires path",
})
return
await self._handle_read_file(
path_str,
msg.get("start_line"),
msg.get("line_limit"),
ws,
)

elif msg_type == "rant":
# Store user rant/feedback for evolution analysis
rant_message = msg.get("message", "").strip()
Expand DownExpand Up@@ -2770,6 +2797,150 @@ async def _handle_resume_session(
},
})

# ── GUI workspace panel: list_files / read_file (rant 2026-08-11T12:20:35 P1.1) ──
# 单目录条目上限:超出截断 + truncated 提示(与 ReadTool 的防爆理念一致)
_MAX_LIST_ENTRIES = 5000
# read_file 1MB 上限:更大文件引导用系统工具打开(避免 WebSocket 帧过大)
_MAX_READ_FILE_SIZE = 1 * 1024 * 1024
# 显式 line_limit 的上限(对齐 ReadTool.MAX_LINES)
_MAX_READ_LINES = 2000

async def _handle_list_files(self, path_str: str, ws) -> None:
"""List one directory level (workspace file browser data source).

Returns {"type": "files_list", path, entries: [{name, path, type}],
truncated?} — fields deliberately limited to name/type/path (no
size/mtime: per-entry stat is expensive on 5000-entry directories).
"""
raw = Path(path_str).expanduser()
if not raw.is_absolute():
await self._send(ws, {
"type": "files_list",
"error": "path must be absolute",
})
return
path = raw.resolve()
try:
if not path.exists():
await self._send(ws, {
"type": "files_list",
"error": f"path not found: {path}",
})
return
if not path.is_dir():
await self._send(ws, {
"type": "files_list",
"error": f"not a directory: {path}",
})
return
entries = sorted(
path.iterdir(),
# 目录在前、按名排序(对齐 ReadTool);符号链接不跟随 →
# is_dir(follow_symlinks=False) 为 False → 归入 file 类不可展开
key=lambda p: (not p.is_dir(follow_symlinks=False), p.name),
)
result = []
truncated = False
for e in entries[: self._MAX_LIST_ENTRIES]:
is_dir = e.is_dir(follow_symlinks=False)
result.append({
"name": e.name,
"path": str(e),
"type": "dir" if is_dir else "file",
})
if len(entries) > self._MAX_LIST_ENTRIES:
truncated = True
await self._send(ws, {
"type": "files_list",
"path": str(path),
"entries": result,
"truncated": truncated,
})
except OSError as exc:
await self._send(ws, {
"type": "files_list",
"error": f"cannot list directory: {exc}",
})

async def _handle_read_file(
self, path_str: str, start_line, line_limit, ws
) -> None:
"""Read a text file (workspace panel viewer data source).

Returns {"type": "file_content", path, content, truncated?, binary?,
error?}. Binary files report binary=True with empty content (image
preview is rendered via file:// URL in the renderer, no base64).
"""
raw = Path(path_str).expanduser()
if not raw.is_absolute():
await self._send(ws, {
"type": "file_content",
"error": "path must be absolute",
})
return
path = raw.resolve()
try:
if not path.exists():
await self._send(ws, {
"type": "file_content",
"error": f"file not found: {path}",
})
return
if path.is_dir():
await self._send(ws, {
"type": "file_content",
"error": f"is a directory: {path}",
})
return
file_size = path.stat().st_size
if file_size > self._MAX_READ_FILE_SIZE:
await self._send(ws, {
"type": "file_content",
"path": str(path),
"error": "文件过大,用系统工具打开",
})
return
try:
text = path.read_text(encoding="utf-8")
except UnicodeDecodeError:
await self._send(ws, {
"type": "file_content",
"path": str(path),
"binary": True,
})
return
except OSError as exc:
await self._send(ws, {
"type": "file_content",
"error": f"cannot read file: {exc}",
})
return

all_lines = text.split("\n")
total = len(all_lines)
try:
start = max(1, int(start_line or 1))
except (TypeError, ValueError):
start = 1
try:
limit = int(line_limit) if line_limit is not None else None
except (TypeError, ValueError):
limit = None
if limit is not None:
limit = min(limit, self._MAX_READ_LINES)
selected = all_lines[start - 1 : start - 1 + limit]
truncated = start - 1 + limit < total
else:
selected = all_lines[start - 1 :]
truncated = False
await self._send(ws, {
"type": "file_content",
"path": str(path),
"content": "\n".join(selected),
"truncated": truncated,
"total_lines": total,
})

async def _handle_list_memories(
self, scope: str, session_id: str, cwd: str, ws
) -> None:
Expand Down
Loading
Loading