From 957d6e118b90ef8def498e795593762ed71f638a Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Thu, 22 Oct 2026 18:11:06 +0800 Subject: [PATCH 1/2] emrg: ask the daemon where a GUI session lives before opening it --- Agent.md | 2 +- emrg/gui/daemon_client.js | 25 ++++++++++++- emrg/gui/main.js | 48 ++++++++++++++++++++++-- emrg/gui/test/boot-contract.test.js | 58 +++++++++++++++++++++++++++++ emrg/gui/test/daemon_client.test.js | 57 +++++++++++++++++++++++++++- 5 files changed, 183 insertions(+), 7 deletions(-) diff --git a/Agent.md b/Agent.md index ce4c8e41..83256a1b 100644 --- a/Agent.md +++ b/Agent.md @@ -54,7 +54,7 @@ What EMRG does today — capability statements, not a changelog. One line per command: what it answers. Run the daemon in the foreground with `python -m emrg`. - Python: `uv run pytest tests/ -v` — the number is **measured, never stored**: `uv run --no-sync python3 scripts/check-doc-count.py --measure`. Import check: `uv run python -c "from emrg.client.app import run_client"` -GUI: `cd emrg/gui && npm test` (161: 98 daemon_client + 20 conn-manager + 7 integration + 7 nav-policy + 7 gui-state + 9 build-config + 6 boot-contract + 3 preload-api + 2 theme-guard + 2 image-hash) — syntax: `node --check main.js preload.js daemon_client.js` +GUI: `cd emrg/gui && npm test` (166: 100 daemon_client + 20 conn-manager + 7 integration + 7 nav-policy + 7 gui-state + 9 build-config + 9 boot-contract + 3 preload-api + 2 theme-guard + 2 image-hash) — syntax: `node --check main.js preload.js daemon_client.js` Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (716: 5 snapshot-store + 13 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 13 markdown + 29 transcript + 28 TranscriptView + 22 history + 25 historyReplay + 40 composer + 75 Composer + 6 LinkDialog + 16 sidebar + 18 Sidebar + 9 fileTree + 9 FileTree + 17 resultPanel + 8 ResultPanel + 27 workspaceView + 13 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 56 daemonBridge + 9 DaemonBridgeProvider + 48 Shell + 24 DialogHost + 26 SettingsPanel + 6 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown + 6 ApprovalDialog + 14 SandboxRootsDialog) - CI: pytest on ubuntu **and windows-2025**, the GUI suites, and an actionlint gate over `.github/workflows/`; re-trigger a dropped push with `scripts/re-trigger-ci.sh `, never an empty commit - Merge gates, run before merging — each asks what no other gate asks: `uv run --no-sync python3 scripts/check-vote-count.py` (approvals still valid) · `scripts/check-pr-base.py` (base reaches master) · `scripts/check-merge-freshness.py` (that green CI is about the landing tree) · `scripts/check-merge-order.py` (dirt and what it lands) · `scripts/check-merge-landing-diff.py` (what the landing changes) · `scripts/check-merge-pairs.py` / `scripts/check-merge-sequence.py` (a pair, or a whole order) · `scripts/check-merge-tree-health.py` (the merged tree passes one guard) · `scripts/check-merge-plan-suite.py` (the plan's final tree passes the suite) — all under `uv run --no-sync python3` diff --git a/emrg/gui/daemon_client.js b/emrg/gui/daemon_client.js index a48988ba..789f8f48 100644 --- a/emrg/gui/daemon_client.js +++ b/emrg/gui/daemon_client.js @@ -133,6 +133,9 @@ const RESPONSE_TYPES = { list_rants: "rants_list", list_memories: "memories_list", resume_session: "resume_result", + // issue #2484:`resolve_session_cwd` 由 daemon 以 `session_cwd_result` 回答 + // (daemon.py `_process_message`)。缺这一行,GUI 连"会话到底在哪个项目"都问不出去。 + resolve_session_cwd: "session_cwd_result", delete_session: "session_deleted", set_model: "model_set", clear_session: "clear_result", // 修正:daemon 命令名是 clear_session(原 clear 匹配不上) @@ -157,6 +160,26 @@ const RESPONSE_TYPES = { task_template_delete: "template_result", }; +/** The cwd a `session_cwd_result` frame names, or the caller's own candidate. + * + * The daemon answers where a session lives so that a client does not have to + * guess (issue #2484). Two answers are deliberately not the same value: `index` + * carries the project the global session index holds, and `unknown` carries + * `null` — the daemon cannot vouch for that session's project, so the requested + * cwd comes back only as a candidate and must not be relabelled as a fact. + * + * So everything except a non-empty `index` cwd answers the caller's fallback, + * a frame this cannot read included: a client that treats a guess as a fact is + * the defect the command exists to remove. + */ +function pickSessionCwd(frame, fallbackCwd = null) { + if (!frame || typeof frame !== "object") return fallbackCwd; + if (frame.source !== "index") return fallbackCwd; + const cwd = frame.cwd; + if (typeof cwd !== "string" || !cwd.trim()) return fallbackCwd; + return cwd; +} + class DaemonClient { constructor({ logger = console, authTimeoutMs = AUTH_TIMEOUT_MS, isPackaged = false, deltaBatchMs = 0 } = {}) { this.logger = logger; @@ -1297,4 +1320,4 @@ function generateSessionId(seed) { return sid; } -module.exports = { DaemonClient, generateSessionId, isSessionId, TOKEN_FILE, MAX_PAYLOAD, EMRGD_PORT, EMRGD_START_ERR }; +module.exports = { DaemonClient, generateSessionId, isSessionId, pickSessionCwd, TOKEN_FILE, MAX_PAYLOAD, EMRGD_PORT, EMRGD_START_ERR }; diff --git a/emrg/gui/main.js b/emrg/gui/main.js index 9806051a..291d7c14 100644 --- a/emrg/gui/main.js +++ b/emrg/gui/main.js @@ -13,7 +13,7 @@ const { pathToFileURL } = require("url"); const { spawn } = require("child_process"); const crypto = require("crypto"); const { parse: parseToml, stringify: stringifyToml } = require("smol-toml"); -const { generateSessionId, isSessionId } = require("./daemon_client"); +const { generateSessionId, isSessionId, pickSessionCwd } = require("./daemon_client"); const { ConnManager } = require("./conn-manager"); const { guiStatePath, sanitizeOpenSessions, saveGuiState, DEFAULT_CAP } = require("./gui-state"); const { externalNavPolicy } = require("./nav-policy"); @@ -80,6 +80,34 @@ function main() { } catch { /* index 缺失/损坏 → 未知(null) */ } return null; } + + // 会话的真实项目目录:先问 daemon,问不到才退回上面那套本地推断(issue #2484)。 + // + // 此前 GUI 只读本地 `sessions_index.json`,读不到就由调用方 `|| DEFAULT_CWD` 兜到 + // homedir —— daemon 的 `_handle_resume_session` 早把这条写进注释:cwd 落错的客户端 + // 会在 `~/.emrg/sessions//` 误建 0 消息幽灵会话,真会话反而打不开。而 daemon + // 本来就持有这个事实(全局会话索引),并且有专门的 `resolve_session_cwd` 命令: + // `index` 带回项目路径,`unknown` 只把请求值当候选回显(不冒充事实)。TUI 每次打开 + // 都先问(app.py 的 /resume 与会话选择器两处),GUI 此前一处都没有。 + // + // 返回 null 表示「daemon 也说不准」,候选值由调用方决定 —— 这里绝不兜 homedir。 + async function askSessionCwd(sessionId, candidateCwd) { + const conn = connManager?.daemonConn(); + if (!conn || !conn.connected) return null; + try { + const frame = await conn.sendCommandAndWait( + "resolve_session_cwd", + { session_id: sessionId, cwd: candidateCwd || DEFAULT_CWD }, + 3000, + ); + return pickSessionCwd(frame, null); + } catch (e) { + // 超时或错误帧:与 daemon 的 unknown 同义 —— 客户端保留自己的候选 + logger.warn(`[gui] resolve_session_cwd failed sid=${sessionId}: ${e.message}`); + return null; + } + } + // P2.3(rant 12:20:35):HTML 预览 WebContentsView——懒创建、单实例复用、右对齐 bounds 同步。 // renderer 崩溃 reload 后由 renderer 侧拉取(emrg:getPreviewState)恢复(main 是真相源)。 let previewView = null; // WebContentsView 实例(首次打开 HTML tab 才创建) @@ -382,9 +410,16 @@ vision = false } // P2:每会话独立连接——首条消息前自动打开(新会话不 resume,daemon 隐式订阅) // P5 slice 2:cwd 取该会话所属项目(跨项目会话用其项目路径,非全局 projectDir) - const sessionCwd = resolveSessionCwd(sessionId) || DEFAULT_CWD; + // issue #2484:候选 cwd 先交给 daemon 判定 —— 它说 index 就以它为准(那是事实), + // 说 unknown 或问不到才用调用方给的 projectPath,最后才退回本地索引推断。 + let sessionCwd = resolveSessionCwd(sessionId) || DEFAULT_CWD; let conn = connManager?.get(sessionId); if (!conn || !conn.connected) { + // issue #2484:这次打开带 resume:false,daemon 的幽灵会话校验 + // (_handle_resume_session:meta 缺失/0 消息时按全局索引核对 cwd)没有机会跑, + // 随后的 sendTask 就把这里的 cwd 交给了 daemon 的取/建路径。所以打开前先问准。 + const asked = await askSessionCwd(sessionId, DEFAULT_CWD); + if (asked) sessionCwd = asked; conn = await openSession(sessionId, sessionCwd, { resume: false }); } markSessionActive(sessionId); // P4:发送 = 会话活动 → lastActive + 持久化 @@ -562,8 +597,13 @@ vision = false // Rant 2026-08-25T17:38:56 根因 2:fallback 链去掉 DEFAULT_CWD——未知会话 // 交 daemon not_found 处理(下方已有 fallback 到最近会话),绝不隐式回退 // homedir 误建幽灵会话。 - const targetPath = projectPath || resolveSessionCwd(sessionId); - logger.info(`[gui:switch] openSession start sid=${sessionId} targetPath=${targetPath}`); + // issue #2484:先问 daemon「这个会话在哪个项目」,再退回本地推断。 + // 顺序即优先级:daemon 的 index 答案是事实,projectPath 是调用方的候选, + // 本地索引读不到才轮到它 —— 三者都没有就交 null 给 daemon 回 not_found, + // 绝不隐式回退 homedir(那正是幽灵会话的来源)。 + const asked = await askSessionCwd(sessionId, projectPath || DEFAULT_CWD); + const targetPath = asked || projectPath || resolveSessionCwd(sessionId); + logger.info(`[gui:switch] openSession start sid=${sessionId} targetPath=${targetPath} asked=${asked}`); try { await openSession(sessionId, targetPath); // 打开(新)会话连接 + resume_session 自动订阅 logger.info(`[gui:switch] openSession OK sid=${sessionId} targetPath=${targetPath}`); diff --git a/emrg/gui/test/boot-contract.test.js b/emrg/gui/test/boot-contract.test.js index c7f1ef0a..89a08ca9 100644 --- a/emrg/gui/test/boot-contract.test.js +++ b/emrg/gui/test/boot-contract.test.js @@ -229,3 +229,61 @@ test("device flow: the emrg:githubConnectWeb handler forwards the frame's user", ); assert.strictEqual(already.code, null, "a short-circuit carries no device code"); }); + +// ── issue #2484:GUI 打开会话前,必须先问 daemon「它在哪个项目」 ── +// +// main.js 这一层没有运行时测试(renderer 测试全 mock window.emrg,integration 测试 +// 直接打 daemon_client),所以用与本文件同款的静态钉子。被钉住的是一条因果链: +// 本地 sessions_index.json 读不到 → 调用方 `|| os.homedir()` → 带 resume:false 的 +// 预打开绕过 daemon 的幽灵会话校验(_handle_resume_session 只在 resume 时核对 cwd) +// → daemon 照单创建 `~/.emrg/sessions//`,真会话(另一个项目下的同名 id)再也打不开。 + +function handlerRegion(src, name) { + const start = src.indexOf(`ipcMain.handle("${name}"`); + assert.ok(start >= 0, `${name} handler 必须存在于 main.js`); + const next = src.indexOf("ipcMain.handle(", start + 10); + return src.slice(start, next === -1 ? undefined : next); +} + +test("issue #2484: emrg:switchSession asks the daemon before it opens a session", () => { + const region = handlerRegion(read("main.js"), "emrg:switchSession"); + assert.match( + region, + /await askSessionCwd\(/, + "switchSession must ask the daemon where the session lives: its own index lookup " + + "answers null for a session it cannot place, and the caller then opens at the home directory.", + ); + assert.match( + region, + /asked \|\| projectPath \|\| resolveSessionCwd\(sessionId\)/, + "precedence must be daemon answer > the caller's project > the local index guess, so a " + + "session from another project opens where it really lives rather than where we guessed.", + ); +}); + +test("issue #2484: the send path's pre-open asks the daemon first, and does so before opening", () => { + const region = handlerRegion(read("main.js"), "emrg:sendMessage"); + const openAt = region.indexOf("openSession(sessionId, sessionCwd, { resume: false })"); + assert.ok(openAt > 0, "send path still pre-opens with resume:false (the window this guards)"); + const beforeOpen = region.slice(0, openAt); + assert.match( + beforeOpen, + /await askSessionCwd\(/, + "the ask must happen BEFORE the resume:false open: that open skips the daemon's ghost " + + "check, so the cwd it carries is the cwd the session gets created at.", + ); +}); + +test("issue #2484: askSessionCwd asks, judges, and never falls back to the home directory", () => { + const src = read("main.js"); + const start = src.indexOf("async function askSessionCwd("); + assert.ok(start > 0, "askSessionCwd must exist"); + const body = src.slice(start, src.indexOf("\n }\n", start) + 4); + assert.match(body, /sendCommandAndWait\(\s*"resolve_session_cwd"/, "it must send the daemon's own command"); + assert.match(body, /pickSessionCwd\(frame, null\)/, "an answer that is not `index` must not become a fact"); + assert.match(body, /return null;/, "an unanswerable question returns null for the caller to decide"); + assert.ok( + !/DEFAULT_CWD/.test(body.split("candidateCwd || DEFAULT_CWD")[1] || ""), + "DEFAULT_CWD may be the candidate it sends, never the value it returns", + ); +}); diff --git a/emrg/gui/test/daemon_client.test.js b/emrg/gui/test/daemon_client.test.js index 61063814..500dcc69 100644 --- a/emrg/gui/test/daemon_client.test.js +++ b/emrg/gui/test/daemon_client.test.js @@ -66,7 +66,7 @@ Module._load = function (request, parent, isMain) { if (request === "ws") return MockWs; return origLoad.apply(this, arguments); }; -const { DaemonClient, generateSessionId, isSessionId, TOKEN_FILE, EMRGD_PORT, EMRGD_START_ERR } = require("../daemon_client.js"); +const { DaemonClient, generateSessionId, isSessionId, pickSessionCwd, TOKEN_FILE, EMRGD_PORT, EMRGD_START_ERR } = require("../daemon_client.js"); let tmpHome = null; let origHome = null; let origUserProfile = null; @@ -2136,3 +2136,58 @@ test("#1283 startDaemon:标记在 spawn 之前取,失败信息只含本次 require.cache[cacheKey] = originalModule; } }); + +// ── issue #2484:先问 daemon「这个会话在哪个项目」 ── +// +// GUI 此前只读本地 sessions_index.json,读不到就用 `|| os.homedir()` 兜底, +// 而 daemon 的幽灵会话校验只挂在 resume_session 上 —— 发送路径的预打开带 +// resume:false,于是 cwd 直接进了 daemon 的取/建路径。这两条测试钉住 GUI 侧 +// 问得出去、答得回来,以及「答得不准时不冒充事实」。 + +test("resolve_session_cwd 一问一答(issue #2484:GUI 侧此前无此命令)", async () => { + const client = new DaemonClient(); + await connectClient(client); + const p = client.sendCommandAndWait( + "resolve_session_cwd", + { session_id: "feishu-inbox", cwd: "/Users/argszero" }, + 2000, + ); + await new Promise((r) => setTimeout(r, 10)); + const sent = JSON.parse(currentMockWs.sent.at(-1)); + assert.strictEqual(sent.type, "resolve_session_cwd", "命令名即帧 type"); + assert.strictEqual(sent.session_id, "feishu-inbox"); + assert.strictEqual(sent.cwd, "/Users/argszero", "客户端自己的 cwd 只作候选发出"); + currentMockWs.emit("message", Buffer.from(JSON.stringify({ + type: "session_cwd_result", + session_id: "feishu-inbox", + cwd: "/Users/argszero/.emrg", + source: "index", + }))); + const res = await p; + assert.strictEqual(res.type, "session_cwd_result", "映射表认得出响应帧(缺映射会超时)"); + assert.strictEqual(pickSessionCwd(res, null), "/Users/argszero/.emrg", "index 答案即事实"); +}); + +test("pickSessionCwd:只有 index 答案是事实,其余一律退回调用方候选(issue #2484)", () => { + const fallback = "/proj/caller"; + // index + 非空 cwd → daemon 说了算(跨项目会话的唯一正解) + assert.strictEqual(pickSessionCwd({ source: "index", cwd: "/proj/real" }, fallback), "/proj/real"); + // unknown → daemon 无法保证;回显的 requested_cwd 只是候选,绝不采信 + assert.strictEqual( + pickSessionCwd({ source: "unknown", cwd: null, requested_cwd: "/proj/caller" }, fallback), + fallback, + ); + // 声称 index 却是空/非字符串 → 不冒充事实 + assert.strictEqual(pickSessionCwd({ source: "index", cwd: "" }, fallback), fallback); + assert.strictEqual(pickSessionCwd({ source: "index", cwd: " " }, fallback), fallback); + assert.strictEqual(pickSessionCwd({ source: "index", cwd: null }, fallback), fallback); + assert.strictEqual(pickSessionCwd({ source: "index", cwd: 42 }, fallback), fallback); + // 缺 source、不可读的帧 → 候选 + assert.strictEqual(pickSessionCwd({ cwd: "/proj/x" }, fallback), fallback); + assert.strictEqual(pickSessionCwd(null, fallback), fallback); + assert.strictEqual(pickSessionCwd(undefined, fallback), fallback); + assert.strictEqual(pickSessionCwd("session_cwd_result", fallback), fallback); + // 默认候选是 null —— 兜底由调用方决定,这里绝不自己变出 homedir + assert.strictEqual(pickSessionCwd({ source: "unknown", cwd: null }), null); + assert.strictEqual(pickSessionCwd({ source: "index", cwd: "/proj/real" }), "/proj/real"); +}); From b816c66d45bf0bdda52d632b6a6a3f0d8c55e790 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Thu, 22 Oct 2026 18:27:53 +0800 Subject: [PATCH 2/2] emrg: the boot-contract reader normalises the line ending before a body is sliced --- emrg/gui/test/boot-contract.test.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/emrg/gui/test/boot-contract.test.js b/emrg/gui/test/boot-contract.test.js index 89a08ca9..d1f660b8 100644 --- a/emrg/gui/test/boot-contract.test.js +++ b/emrg/gui/test/boot-contract.test.js @@ -31,7 +31,13 @@ const path = require("node:path"); const GUI_ROOT = path.join(__dirname, ".."); function read(rel) { - return fs.readFileSync(path.join(GUI_ROOT, rel), "utf8"); + // Normalise the line ending here, once, so every assertion in this file reads + // text whose newlines are "\n" whatever the checkout did. A sentinel written as + // "\n }\n" is a property of the file's content, not of the platform, but a CRLF + // checkout makes indexOf answer -1 and the slice collapse to "" — measured + // 2026-10-22: the same assertion passed on Linux and macOS and failed on Windows + // with `actual: ''`. The assertions themselves are unchanged. + return fs.readFileSync(path.join(GUI_ROOT, rel), "utf8").replace(/\r\n/g, "\n"); } test("boot chain: main.js emrg:init handler must call ensureConnected()", () => {