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 numberDiff line numberDiff 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` (169: 43 daemon_client + 17 conn-manager + 22 app-commands + 52 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config + 7 gui-state); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py`
- Unit tests `npm test` (172: 43 daemon_client + 17 conn-manager + 22 app-commands + 55 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 DownExpand 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` (169: 43 daemon_client + 17 conn-manager + 22 app-commands + 52 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`
GUI: `cd emrg/gui && npm test` (172: 43 daemon_client + 17 conn-manager + 22 app-commands + 55 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 numberDiff line numberDiff 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 测试(169 项:43 daemon_client + 17 conn-manager + 22 app-commands + 52 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config + 7 gui-state;集成测试在 CI 跑,本地可 npm run test:integration)
npm test # 运行 Node 测试(172 项:43 daemon_client + 17 conn-manager + 22 app-commands + 55 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 numberDiff line numberDiff 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 (169: 43 daemon_client + 17 conn-manager + 22 app-commands + 52 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config + 7 gui-state; integration runs in CI, local: npm run test:integration)
npm test # run Node tests (172: 43 daemon_client + 17 conn-manager + 22 app-commands + 55 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
44 changes: 39 additions & 5 deletions emrg/gui/main.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -275,17 +275,19 @@ vision = false
throw new Error("invalid request_id"); // G143:renderer 预生成 id 的格式护栏
}
// P2:每会话独立连接——首条消息前自动打开(新会话不 resume,daemon 隐式订阅)
// P5 slice 2:cwd 取该会话所属项目(跨项目会话用其项目路径,非全局 projectDir)
const sessionCwd = openSessions.get(sessionId)?.projectPath || projectDir;
let conn = connManager?.get(sessionId);
if (!conn || !conn.connected) {
conn = await openSession(sessionId, projectDir, { resume: false });
conn = await openSession(sessionId, sessionCwd, { resume: false });
}
markSessionActive(sessionId); // P4:发送 = 会话活动 → lastActive + 持久化
let rid;
try {
// G143:renderer 预生成 requestId(send 前标记自有流,消除 IPC 往返竞态窗口)
// WorkBuddy P2:mode="ask" → 纯对话(daemon 不启用工具)
// G65:conn.sendTask 内部标记 ownStream(每连接独立锁)
rid = conn.sendTask({ sessionId, cwd: projectDir, prompt: text, stream: true, requestId, mode });
rid = conn.sendTask({ sessionId, cwd: sessionCwd, prompt: text, stream: true, requestId, mode });
} catch (e) {
conn._releaseOwnStream(); // sendTask 抛异常(ws.send 失败)→ 释放锁,防 G65 锁泄漏
throw e;
Expand All@@ -295,15 +297,17 @@ vision = false

ipcMain.handle("emrg:listSessions", async () => listSessions());

ipcMain.handle("emrg:switchSession", async (_e, { sessionId}) => {
ipcMain.handle("emrg:switchSession", async (_e, { sessionId, projectPath } = {}) => {
if (!validateSessionId(sessionId)) throw new Error("invalid session_id");
// G65:自有流运行中禁止切会话(每连接独立锁,查当前激活连接)
if (connManager?.get(currentSessionId)?.ownStream) throw new Error("stream in progress — cannot switch");
const prevSid = currentSessionId;
// G110:切会话清空旧连接分组缓存(含 timer),防广播"幽灵"残留
connManager?.get(prevSid)?.clearGroups();
// P5 slice 2:跨项目打开——用该项目路径 resume(非全局 projectDir)
const targetPath = projectPath || openSessions.get(sessionId)?.projectPath || projectDir;
try {
await openSession(sessionId, projectDir); // 打开(新)会话连接 + resume_session 自动订阅
await openSession(sessionId, targetPath); // 打开(新)会话连接 + resume_session 自动订阅
} catch (e) {
// G106:被动删除恢复——resume error → 刷新列表 + 切最近
if (/not found|error/i.test(e.message)) {
Expand DownExpand Up@@ -354,12 +358,14 @@ vision = false
activeSid: currentSessionId,
}));

ipcMain.handle("emrg:newSession", async () => {
ipcMain.handle("emrg:newSession", async (_e, { projectPath } = {}) => {
// G14/G81:本地生成 session_id(无 new_session 消息)
const sid = generateSessionId();
// 同步 main 侧会话状态:重连后 resume 正确会话(G41)+ 窗口标题(G109)
currentSessionId = sid;
win.setTitle(`EMRG — ${sid}`);
// P5 slice 2:新会话指定项目 → 先记簿记(发送时用其 cwd 建连接)
if (projectPath) touchOpenSession(sid, projectPath);
// P4:新会话在首条消息前不建连接(sendMessage 自动 open)——此处仅标记激活
// 并刷新持久化(activeSid 前进;openSessions 条目随 openSession 落簿记)
schedulePersistGuiState();
Expand DownExpand Up@@ -475,6 +481,34 @@ vision = false
return { ok: true, path: p };
});

// P5 slice 2:删除项目 = 只删 projects.yml 条目(保留磁盘数据)+ 关闭该项目已打开会话
// 受保护项目不可删除(内置 project emrg / 内置 task emrg-task → 演化依赖,删了悬空);
// `.emrg` 是 _touch_project 历史记录**非内置 → 可删**(再次访问重新注册)。
// 返回 { ok, removed, closed: [sids] };renderer 收到后负责切换激活会话。
ipcMain.handle("emrg:removeProject", async (_e, { name, path: p } = {}) => {
if (typeof name !== "string" || !name.trim()) throw new Error("invalid project name");
// 受保护项目:内置 project `emrg` + 内置 task `emrg-task`
if (name === "emrg" || name === "emrg-task") {
return { ok: false, protected: true, error: "protected system project" };
}
// 该项目已打开的会话 → 关闭连接 + 移除 + 写盘(激活中先切相邻由 renderer 处理)
const closed = [];
for (const [sid, v] of [...openSessions.entries()]) {
if (v.projectPath === p || v.projectName === name) {
connManager?.close(sid);
openSessions.delete(sid);
closed.push(sid);
}
}
if (currentSessionId && closed.includes(currentSessionId)) {
currentSessionId = null; // 激活会话被关 → 无激活(renderer 切相邻)
}
const frame = await requireConn().sendCommandAndWait("remove_project", { name: name.trim() }, 5000);
schedulePersistGuiState();
broadcastOpenSessions();
return { ok: true, removed: Boolean(frame.removed), closed, name };
});

ipcMain.handle("emrg:listTasks", async () => {
// GUI / 指令 P4:/trigger — daemon list_tasks → tasks_list
const frame = await requireConn().sendCommandAndWait("list_tasks", {}, 5000);
Expand Down
3 changes: 2 additions & 1 deletion emrg/gui/preload.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,7 +11,7 @@ const api = {
sendMessage: (payload) => ipcRenderer.invoke("emrg:sendMessage", payload),
listSessions: () => ipcRenderer.invoke("emrg:listSessions"),
switchSession: (payload) => ipcRenderer.invoke("emrg:switchSession", payload),
newSession: () => ipcRenderer.invoke("emrg:newSession"),
newSession: (payload) => ipcRenderer.invoke("emrg:newSession", payload),
deleteSession: (payload) => ipcRenderer.invoke("emrg:deleteSession", payload),
closeSession: (payload) => ipcRenderer.invoke("emrg:closeSession", payload),
getOpenSessions: () => ipcRenderer.invoke("emrg:getOpenSessions"),
Expand All@@ -27,6 +27,7 @@ const api = {
listProjects: () => ipcRenderer.invoke("emrg:listProjects"),
listProjectSessions: (payload) => ipcRenderer.invoke("emrg:listProjectSessions", payload),
registerProject: (payload) => ipcRenderer.invoke("emrg:registerProject", payload),
removeProject: (payload) => ipcRenderer.invoke("emrg:removeProject", payload),
listTasks: () => ipcRenderer.invoke("emrg:listTasks"),
triggerTask: (payload) => ipcRenderer.invoke("emrg:triggerTask", payload),
sendRant: (payload) => ipcRenderer.invoke("emrg:sendRant", payload),
Expand Down
14 changes: 14 additions & 0 deletions emrg/gui/renderer/index.html
Original file line numberDiff line numberDiff line change
Expand Up@@ -275,12 +275,26 @@ <h2 id="open-session-title" data-i18n="openSession.title">打开会话</h2>
<p style="color:var(--text-2);font-size:var(--fs-secondary);margin:0 0 var(--sp-3);" id="open-session-desc" data-i18n="openSession.desc">选择项目后选择要打开的会话(跨项目多开)。</p>
<div id="open-session-list" class="help-list"></div>
<div class="dialog-actions">
<button type="button" id="open-session-new-session" class="btn btn-ghost" data-i18n="newSession.openButton">+ 新建会话…</button>
<button type="button" id="open-session-new" class="btn btn-ghost" data-i18n="openSession.newProject">+ 新建项目…</button>
<button type="button" id="open-session-cancel" class="btn btn-ghost" data-i18n="help.close">关闭</button>
</div>
</div>
</dialog>

<!-- P5 slice 2:新建会话对话框(选已有项目 → 新建;或新建项目 → 新建) -->
<dialog id="new-session-dialog">
<div class="dialog-card" style="min-width:440px;">
<h2 data-i18n="newSession.title">新建会话</h2>
<p style="color:var(--text-2);font-size:var(--fs-secondary);margin:0 0 var(--sp-3);" data-i18n="newSession.desc">选择项目新建会话(跨项目多开)。</p>
<div id="new-session-list" class="help-list"></div>
<div class="dialog-actions">
<button type="button" id="new-session-new" class="btn btn-ghost" data-i18n="newSession.newProject">+ 新建项目…</button>
<button type="button" id="new-session-cancel" class="btn btn-ghost" data-i18n="help.close">关闭</button>
</div>
</div>
</dialog>

<!-- 历史回退对话框(/rewind,GUI / 指令 P2) -->
<dialog id="rewind-dialog">
<div class="dialog-card" style="min-width:480px;">
Expand Down
8 changes: 5 additions & 3 deletions emrg/gui/renderer/js/app.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -600,7 +600,7 @@ const App = (() => {
return;
}
try {
const res = await window.emrg.switchSession({ sessionId: sid });
const res = await window.emrg.switchSession({ sessionId: sid, projectPath: opts.projectPath });
state.sessionId = sid;
// P3 slice 1:切换只移动激活指针——每会话条目保留自己的 busy/ownStreamRequestId
// P3 slice 2:激活该会话容器(状态保留,不 Chat.clear——切回继续看到原消息/流式现场)
Expand DownExpand Up@@ -632,13 +632,13 @@ const App = (() => {
}
}

async function newSession() {
async function newSession(opts = {}) {
if (state.busy) {
Chat.addSystemMessage(EMRG_Copy.COPY.sessionBusy);
return;
}
try {
const res = await window.emrg.newSession();
const res = await window.emrg.newSession({ projectPath: opts.projectPath });
state.sessionId = res.session_id;
activateSessionView(state.sessionId); // P3 slice 2:新会话独立容器(空)
Chat.clear(state.sessionId); // 新会话从空开始(容器可能被复用)
Expand DownExpand Up@@ -1278,6 +1278,8 @@ const App = (() => {
$("stop-btn").addEventListener("click", () => window.emrg.cancel().catch(() => {}));
$("new-chat-btn").addEventListener("click", newSession);
Dialogs.initOpenSessionDialog(); // P5:打开会话对话框绑定
Dialogs.initNewSessionDialog(); // P5 slice 2:新建会话对话框绑定
$("open-session-new-session")?.addEventListener("click", () => Dialogs.showNewSessionDialog()); // P5 slice 2:打开弹窗 → 新建会话
$("settings-btn").addEventListener("click", () => {
loadEvolutionSummary(); // WorkBuddy P3(#502):打开设置时加载最近改进
Dialogs.showSettings();
Expand Down
Loading
Loading