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
4 changes: 2 additions & 2 deletions Agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ EMRG is a self-evolving AI agent architecture experiment. Python implementation,
- Streaming chat with delta rendering (16ms batching), markdown on done (marked + DOMPurify + local highlight.js subset), tool call status cards (2000-char truncation + expand)
- Session list/switch/new/delete + right-click rename (context menu, #423) synced with daemon; own-stream busy lock (G65); broadcast streams from other clients tagged "来自其他客户端"
- Disconnect/reconnect: red status dot, auto daemon respawn (stale-port detection), session resume, input bar restored on disconnect (no 30s fake-timeout)
- Unit tests `npm test` (156: 43 daemon_client + 17 conn-manager + 22 app-commands + 46 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py`
- Unit tests `npm test` (163: 43 daemon_client + 17 conn-manager + 22 app-commands + 46 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config + 7 gui-state); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py`
- **Auto project tracking** — Automatically detects and records working directories; project-scoped sessions
- **Rant-driven evolution** — User feedback via `/rant` drives automatic self-improvement cycles
- **Headless GitHub auth** — Non-interactive evolution auto-extracts `GH_TOKEN` from git credential store (osxkeychain / credential helper); PR comment/LGTM queries fall back to REST API (GraphQL needs `read:org` scope)
Expand Down Expand Up @@ -94,7 +94,7 @@ pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg
```

Python: `uv run pytest tests/ -v` (680) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (156: 43 daemon_client + 17 conn-manager + 22 app-commands + 46 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js`
GUI: `cd emrg/gui && npm test` (163: 43 daemon_client + 17 conn-manager + 22 app-commands + 46 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 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 number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ uv run python -m emrg # 启动 TUI
cd emrg/gui
npm ci # 安装依赖(生产模式可 --omit=dev)
npm start # 启动 GUI(自动拉起 daemon)
npm test # 运行 Node 测试(156 项:43 daemon_client + 17 conn-manager + 22 app-commands + 46 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config;集成测试在 CI 跑,本地可 npm run test:integration)
npm test # 运行 Node 测试(163 项:43 daemon_client + 17 conn-manager + 22 app-commands + 46 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config + 7 gui-state;集成测试在 CI 跑,本地可 npm run test:integration)
```

CI 通过 GitHub Actions 自动运行测试并检查冲突标记(`.github/workflows/test.yml`)。
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ uv run python -m emrg # launch TUI
cd emrg/gui
npm ci # install deps (production: --omit=dev)
npm start # launch GUI (auto-starts daemon)
npm test # run Node tests (156: 43 daemon_client + 17 conn-manager + 22 app-commands + 46 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config; integration runs in CI, local: npm run test:integration)
npm test # run Node tests (163: 43 daemon_client + 17 conn-manager + 22 app-commands + 46 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config + 7 gui-state; integration runs in CI, local: npm run test:integration)
```

CI runs tests and checks for conflict markers automatically via GitHub Actions (`.github/workflows/test.yml`).
Expand Down
2 changes: 1 addition & 1 deletion emrg/gui/conn-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ class ConnManager {
});
this._conns.set(sid, { conn, projectPath });
for (const cb of this._openHooks) {
try { cb(sid, conn); } catch (e) { this.logger.warn(`[gui] connManager onOpen hook error: ${e.message}`); }
try { cb(sid, conn, projectPath); } catch (e) { this.logger.warn(`[gui] connManager onOpen hook error: ${e.message}`); }
}
return conn;
}
Expand Down
46 changes: 46 additions & 0 deletions emrg/gui/gui-state.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// gui-state.js — P4 of the GUI multi-session rant (2026-08-10T15:07:19)
//
// gui_state.json persistence: { openSessions: [{sid, projectName, projectPath,
// lastActive}], activeSid }.
//
// This slice (P4 slice 1) covers the WRITE path (main.js keeps open sessions
// alive across switches, persists them, and exposes close/get IPC). The READ
// path (restore on boot + sidebar UI) lands in P4 slice 2.
//
// Design notes (from the rant):
// - Open sessions = cross-project tabs (aligned with the TUI multi-open model).
// - Cap 20: never persist more than the cap; restore (slice 2) takes the most
// recent 20 by lastActive.
// - Write is atomic (.tmp + rename) so a crash mid-write never corrupts state.
"use strict";

const fs = require("fs");
const path = require("path");

const DEFAULT_CAP = 20;

function guiStatePath(homeDir) {
return path.join(homeDir, ".emrg", "gui_state.json");
}

// 清洗 openSessions:跳过失效条目(缺 sid/projectPath),按 lastActive 倒序,
// 截断到 cap(默认 20)。写盘前与恢复(slice 2)共用同一规则。
function sanitizeOpenSessions(list, cap = DEFAULT_CAP) {
const valid = (list || []).filter(
(s) => s && typeof s.sid === "string" && s.sid && typeof s.projectPath === "string" && s.projectPath
);
valid.sort((a, b) => String(b.lastActive || "").localeCompare(String(a.lastActive || "")));
return valid.slice(0, cap);
}

// 原子写:tmp + rename(镜像 install-info.json 原子写 #569 模式)。
// 目录缺失 → 创建。写失败 → 抛(调用方捕获并记录,不阻断主流程)。
function saveGuiState(homeDir, state) {
const p = guiStatePath(homeDir);
fs.mkdirSync(path.dirname(p), { recursive: true });
const tmp = p + ".tmp";
fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n", "utf8");
fs.renameSync(tmp, p);
}

module.exports = { guiStatePath, sanitizeOpenSessions, saveGuiState, DEFAULT_CAP };
114 changes: 109 additions & 5 deletions emrg/gui/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const { spawn } = require("child_process");
const { parse: parseToml, stringify: stringifyToml } = require("smol-toml");
const { generateSessionId, SESSION_ID_RE } = require("./daemon_client");
const { ConnManager } = require("./conn-manager");
const { guiStatePath, sanitizeOpenSessions, saveGuiState } = require("./gui-state");
const APP_VERSION = require("./package.json").version;

// ── 单实例锁(G85/G120:第二个实例退出并 focus 已有窗口)──
Expand All @@ -39,6 +40,11 @@ function main() {
// (对称 TUI app.py _throttle_warned,PR #594)。成功连接后复位。
let daemonStoppedNotified = false;
let stopping = false;
// P4(rant 15:07:19):跨项目打开的会话状态——sid → {projectName, projectPath,
// lastActive}。写盘防抖 1s(打开/关闭/切换时更新,镜像 rant 设计)。
let openSessions = new Map();
let guiStateTimer = null;
const GUI_STATE_DEBOUNCE_MS = 1000;

// ── 窗口 ────────────────────────────────────────────────

Expand Down Expand Up @@ -269,6 +275,7 @@ vision = false
if (!conn || !conn.connected) {
conn = await openSession(sessionId, projectDir, { resume: false });
}
markSessionActive(sessionId); // P4:发送 = 会话活动 → lastActive + 持久化
let rid;
try {
// G143:renderer 预生成 requestId(send 前标记自有流,消除 IPC 往返竞态窗口)
Expand Down Expand Up @@ -306,15 +313,18 @@ vision = false
}
currentSessionId = sessionId;
win.setTitle(`EMRG — ${sessionId}`); // G109
// 单会话模型:切走即关闭旧会话连接(每会话一条连接;多会话 openSessions 由 P4 管理)
if (prevSid && prevSid !== sessionId) connManager?.close(prevSid);
// P4(rant 15:07:19):多会话保持——切走**不再关闭**旧会话连接(浏览器 tab
// 效果:切回继续生成/看现场)。关闭走 emrg:closeSession(P4 slice 2 侧边栏)。
markSessionActive(sessionId); // 更新 lastActive + activeSid 防抖写盘
return {};
});

ipcMain.handle("emrg:deleteSession", async (_e, { sessionId }) => {
if (!validateSessionId(sessionId)) throw new Error("invalid session_id");
await requireConn().sendCommandAndWait("delete_session", { session_id: sessionId, cwd: projectDir }, 5000);
connManager?.close(sessionId); // P2:删除会话 → 关闭该会话连接(若打开)
openSessions.delete(sessionId); // P4:删除(删数据)→ 一并移出打开会话簿记
schedulePersistGuiState();
return { ok: true };
});

Expand All @@ -326,12 +336,28 @@ vision = false
return { ok: true, title: frame.title || clean };
});

// P4(rant 15:07:19):关闭会话 = 断开连接 + 移除 + 持久化,**保留磁盘数据**
// (与 delete_session 区分:关闭留数据 / 删除删数据)。
ipcMain.handle("emrg:closeSession", async (_e, { sessionId }) => {
if (!validateSessionId(sessionId)) throw new Error("invalid session_id");
return closeSession(sessionId);
});

// P4:跨项目打开会话列表(侧边栏数据源,slice 2 消费)
ipcMain.handle("emrg:getOpenSessions", async () => ({
openSessions: openSessionsList(),
activeSid: currentSessionId,
}));

ipcMain.handle("emrg:newSession", async () => {
// G14/G81:本地生成 session_id(无 new_session 消息)
const sid = generateSessionId();
// 同步 main 侧会话状态:重连后 resume 正确会话(G41)+ 窗口标题(G109)
currentSessionId = sid;
win.setTitle(`EMRG — ${sid}`);
// P4:新会话在首条消息前不建连接(sendMessage 自动 open)——此处仅标记激活
// 并刷新持久化(activeSid 前进;openSessions 条目随 openSession 落簿记)
schedulePersistGuiState();
return { session_id: sid };
});

Expand Down Expand Up @@ -625,8 +651,10 @@ vision = false
if (connManager) return connManager;
connManager = new ConnManager({ projectDir, logger, isPackaged: app.isPackaged });
// 每个新会话连接建立时挂 renderer 事件桥(附带 sid;含 recoverAll 重开路径)
connManager.onOpen((sid, conn) => {
connManager.onOpen((sid, conn, projectPath) => {
conn.onEvent((type, data) => {
// P4:消息活动刷新该会话 lastActive(激活/发送/done 更新,写盘防抖 1s)
if (type === "done" || type === "message_delta") touchOpenSession(sid, projectPath);
if (win && !win.isDestroyed()) {
// 主动关闭(切走/删除)不触发断线横幅——真断连/daemon 重启照常转发
if (type === "disconnected" && conn._intentionalClose) return;
Expand Down Expand Up @@ -669,8 +697,83 @@ vision = false
// 打开(或复用)会话连接;事件桥由 onOpen 钩子统一挂(含 recoverAll 重开)。
async function openSession(sid, projectPath, { resume = true } = {}) {
const existing = connManager.get(sid);
if (existing && existing.connected) return existing;
return connManager.open(sid, projectPath, { resume });
if (existing && existing.connected) {
touchOpenSession(sid, projectPath); // P4:复用连接也刷新打开会话状态
return existing;
}
const conn = await connManager.open(sid, projectPath, { resume });
touchOpenSession(sid, projectPath);
return conn;
}

// ── P4 openSessions 簿记 + gui_state.json 持久化 ──────────────────

function projectNameOf(projectPath) {
return path.basename(projectPath) || projectPath;
}

// 记录/刷新一个打开会话(复用连接、恢复重开、recoverAll 均刷新)
function touchOpenSession(sid, projectPath) {
openSessions.set(sid, {
projectName: projectNameOf(projectPath),
projectPath,
lastActive: new Date().toISOString(),
});
}

// 激活会话变化(切换/新会话/发送)→ 更新 lastActive + activeSid → 防抖写盘
function markSessionActive(sid) {
if (sid && openSessions.has(sid)) {
openSessions.get(sid).lastActive = new Date().toISOString();
}
schedulePersistGuiState();
}

function schedulePersistGuiState() {
if (guiStateTimer) clearTimeout(guiStateTimer);
guiStateTimer = setTimeout(() => {
guiStateTimer = null;
persistGuiStateNow();
}, GUI_STATE_DEBOUNCE_MS);
guiStateTimer.unref?.();
}

function persistGuiStateNow() {
try {
const entries = [...openSessions.entries()].map(([sid, v]) => ({
sid,
projectName: v.projectName,
projectPath: v.projectPath,
lastActive: v.lastActive,
}));
const state = {
openSessions: sanitizeOpenSessions(entries), // 写盘侧也守上限 20
activeSid: currentSessionId,
};
saveGuiState(os.homedir(), state);
} catch (e) {
logger.warn(`[gui] gui_state.json persist failed: ${e.message}`);
}
}

// 关闭会话(保留磁盘数据):断连 + 移除 + 持久化。P4 slice 2 侧边栏"关闭"入口用。
async function closeSession(sid) {
const entry = openSessions.get(sid);
connManager?.close(sid); // 主动关闭(_intentionalClose 抑制断线横幅)
openSessions.delete(sid);
schedulePersistGuiState();
if (currentSessionId === sid) currentSessionId = null; // 关闭激活会话 → 无激活
return { ok: true, closed: !!entry };
}

function openSessionsList() {
return [...openSessions.entries()]
.map(([sid, v]) => ({ sid, projectName: v.projectName, projectPath: v.projectPath, lastActive: v.lastActive }))
.sort((a, b) => String(b.lastActive || "").localeCompare(String(a.lastActive || "")));
}

function guiStateFilePath() {
return guiStatePath(os.homedir());
}

async function ensureConnected() {
Expand Down Expand Up @@ -814,6 +917,7 @@ vision = false
app.on("window-all-closed", () => {
stopping = true;
cancelReconnect();
persistGuiStateNow(); // P4:退出前冲刷未落盘的打开会话状态(防抖 timer 取消)
connManager?.closeAll(); // P2:关闭全部会话连接 + daemon 级连接
if (process.platform !== "darwin") app.quit();
});
Expand Down
2 changes: 2 additions & 0 deletions emrg/gui/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ const api = {
switchSession: (payload) => ipcRenderer.invoke("emrg:switchSession", payload),
newSession: () => ipcRenderer.invoke("emrg:newSession"),
deleteSession: (payload) => ipcRenderer.invoke("emrg:deleteSession", payload),
closeSession: (payload) => ipcRenderer.invoke("emrg:closeSession", payload),
getOpenSessions: () => ipcRenderer.invoke("emrg:getOpenSessions"),
renameSession: (payload) => ipcRenderer.invoke("emrg:renameSession", payload),
setModel: (payload) => ipcRenderer.invoke("emrg:setModel", payload),
clearSession: (payload) => ipcRenderer.invoke("emrg:clearSession", payload),
Expand Down
84 changes: 84 additions & 0 deletions emrg/gui/test/gui-state.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// gui-state.test.js — P4 slice 1(rant 2026-08-10T15:07:19)
// gui_state.json 持久化模块:路径、清洗(上限 20 + 失效条目跳过 + lastActive 倒序)、原子写。
const { test } = require("node:test");
const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { guiStatePath, sanitizeOpenSessions, saveGuiState, DEFAULT_CAP } = require("../gui-state.js");

function tmpHome() {
return fs.mkdtempSync(path.join(os.tmpdir(), "emrg-gui-state-"));
}

test("gui-state: 路径固定在 <home>/.emrg/gui_state.json", () => {
const h = tmpHome();
assert.strictEqual(guiStatePath(h), path.join(h, ".emrg", "gui_state.json"));
});

test("gui-state: sanitize 跳过失效条目(缺 sid/projectPath)", () => {
const list = [
{ sid: "s1", projectPath: "/a", lastActive: "2026-08-10T10:00:00" },
{ projectPath: "/b" }, // 缺 sid
{ sid: "s2" }, // 缺 projectPath
{ sid: "", projectPath: "/c" }, // 空 sid
null,
"junk",
{ sid: "s3", projectPath: "/d", lastActive: "2026-08-10T09:00:00" },
];
const out = sanitizeOpenSessions(list);
assert.deepStrictEqual(out.map((s) => s.sid), ["s1", "s3"], "invalid entries dropped, order preserved");
});

test("gui-state: sanitize 按 lastActive 倒序 + 上限 20", () => {
const list = Array.from({ length: 25 }, (_, i) => ({
sid: `s${i}`,
projectPath: `/p/${i}`,
lastActive: `2026-08-10T${String(i).padStart(2, "0")}:00:00`,
}));
const out = sanitizeOpenSessions(list);
assert.strictEqual(out.length, DEFAULT_CAP, "capped at 20");
assert.strictEqual(out[0].sid, "s24", "most recent first (lastActive desc)");
assert.strictEqual(out[19].sid, "s5", "oldest kept is the 20th");
});

test("gui-state: sanitize 缺失 lastActive 视作最旧(字符串比较稳定)", () => {
const out = sanitizeOpenSessions([
{ sid: "no-ts", projectPath: "/x" },
{ sid: "new", projectPath: "/y", lastActive: "2026-08-10T12:00:00" },
]);
assert.strictEqual(out[0].sid, "new");
assert.strictEqual(out[1].sid, "no-ts");
});

test("gui-state: saveGuiState 原子写 + 目录自动创建", () => {
const h = tmpHome();
const state = { openSessions: [{ sid: "s1", projectName: "a", projectPath: "/a", lastActive: "t" }], activeSid: "s1" };
saveGuiState(h, state);
const p = guiStatePath(h);
assert.ok(fs.existsSync(p), "file written");
assert.deepStrictEqual(JSON.parse(fs.readFileSync(p, "utf8")), state, "content round-trips");
assert.ok(!fs.existsSync(p + ".tmp"), "no tmp residue after rename");
});

test("gui-state: saveGuiState 覆盖旧内容(原子替换)", () => {
const h = tmpHome();
saveGuiState(h, { openSessions: [], activeSid: null });
saveGuiState(h, { openSessions: [{ sid: "x", projectName: "p", projectPath: "/p", lastActive: "t" }], activeSid: "x" });
const parsed = JSON.parse(fs.readFileSync(guiStatePath(h), "utf8"));
assert.strictEqual(parsed.openSessions.length, 1);
assert.strictEqual(parsed.activeSid, "x");
});

test("gui-state: 写盘侧上限——persist 前先 sanitize(主流程调用方模式)", () => {
const entries = Array.from({ length: 30 }, (_, i) => ({
sid: `s${i}`,
projectName: `p${i}`,
projectPath: `/p/${i}`,
lastActive: `2026-08-10T${String(59 - i).padStart(2, "0")}:00:00`, // s0 最新(59 > 58 > …)
}));
const out = sanitizeOpenSessions(entries);
assert.strictEqual(out.length, DEFAULT_CAP, "never persists more than 20");
assert.strictEqual(out[0].sid, "s0", "most recent first");
assert.strictEqual(out[19].sid, "s19", "20th kept");
});
Loading