') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); emrg: ConnManager open/close/get — GUI multi-session rant P2 slice 2 by argszero · Pull Request #624 · argszero/emrg · GitHub
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` (113: 31 daemon_client + 22 app-commands + 32 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py`
- Unit tests `npm test` (118: 31 daemon_client + 5 conn-manager + 22 app-commands + 32 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config); 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` (113: 31 daemon_client + 22 app-commands + 32 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` (118: 31 daemon_client + 5 conn-manager + 22 app-commands + 32 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config) — 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 测试(113 项:31 daemon_client + 22 app-commands + 32 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config;集成测试在 CI 跑,本地可 npm run test:integration)
npm test # 运行 Node 测试(118 项:31 daemon_client + 5 conn-manager + 22 app-commands + 32 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config;集成测试在 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 (113: 31 daemon_client + 22 app-commands + 32 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 (118: 31 daemon_client + 5 conn-manager + 22 app-commands + 32 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config; 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
87 changes: 87 additions & 0 deletions emrg/gui/conn-manager.js
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
// conn-manager.js — P2 of the GUI multi-session rant (2026-08-10T15:07:19)
//
// Connection manager = daemon lifecycle unique owner + one DaemonClient per
// open session (each session = one independent websocket connection, aligned
// with the TUI multi-open model).
//
// This slice establishes the open/close/get contract and the daemon-ownership
// bootstrap. main.js rewiring to use this manager (single-session no-regression
// target) lands in a later slice — until then main.js keeps its single client.
//
// Design notes (from the rant):
// - Each open session = one independent ws connection → natural isolation, no
// event routing.
// - connManager ensures the daemon is ready (spawn if missing) before opening;
// session connections then use ensureConnected({ skipStart: true }) — they
// only connect to the already-running daemon, never spawn.
// - resume_session(sid, cwd=projectPath) auto-subscribes the connection.
// - Already-open sid → reuse the existing connection (no duplicate).

const { DaemonClient } = require("./daemon_client.js");

class ConnManager {
constructor({ projectDir, logger = console, isPackaged = false } = {}) {
this.projectDir = projectDir;
this.logger = logger;
this.isPackaged = isPackaged;
this._conns = new Map(); // sid -> { conn, projectPath }
}

// 确保 daemon 已运行(connManager = daemon 生命周期唯一 owner)。
// 引导 client ensureConnected():port 文件缺失 → spawn;已运行 → 直连。
// 连接后立即关闭引导连接——会话连接统一走 skipStart(只连不拉)。
async _ensureDaemon() {
const boot = new DaemonClient({
projectDir: this.projectDir,
logger: this.logger,
isPackaged: this.isPackaged,
});
try {
await boot.ensureConnected();
} finally {
boot.close();
}
}

// open(sid, projectPath):创建 DaemonClient → ensureConnected(skipStart) →
// resume_session(自动订阅)。已打开的 sid 直接复用现有连接。
async open(sid, projectPath) {
const existing = this._conns.get(sid);
if (existing) return existing.conn;
await this._ensureDaemon();
const conn = new DaemonClient({
projectDir: this.projectDir,
logger: this.logger,
isPackaged: this.isPackaged,
});
await conn.ensureConnected({ skipStart: true }); // daemon 已就绪 → 只连不拉
await conn.sendCommandAndWait("resume_session", { session_id: sid, cwd: projectPath }, 5000);
this._conns.set(sid, { conn, projectPath });
return conn;
}

// close(sid):conn.close(断开 ws)→ 移除。返回是否有关闭对象。
close(sid) {
const entry = this._conns.get(sid);
if (!entry) return false;
entry.conn.close();
this._conns.delete(sid);
return true;
}

// get(sid):路由到对应实例;未打开返回 null。
get(sid) {
const entry = this._conns.get(sid);
return entry ? entry.conn : null;
}

all() {
return [...this._conns.keys()];
}

closeAll() {
for (const sid of [...this._conns.keys()]) this.close(sid);
}
}

module.exports = { ConnManager };
177 changes: 177 additions & 0 deletions emrg/gui/test/conn-manager.test.js
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
// conn-manager.test.js — P2 (rant 2026-08-10T15:07:19) connection manager contract
// open/close/get: daemon bootstrap + skipStart session connections + resume_session.

"use strict";

const assert = require("node:assert");
const { test, beforeEach, afterEach } = require("node:test");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const Module = require("node:module");

// ── MockWs(与 daemon_client.test.js 同款)──────────────────────────────
let currentMockWs = null;

class MockWs {
constructor(url) {
this.url = url;
this.sent = [];
this._listeners = {};
currentMockWs = this;
}
on(ev, cb) { (this._listeners[ev] = this._listeners[ev] || []).push(cb); return this; }
once(ev, cb) {
const wrap = (...a) => { this.off(ev, wrap); cb(...a); };
wrap._orig = cb;
return this.on(ev, wrap);
}
off(ev, cb) {
if (!this._listeners[ev]) return this;
this._listeners[ev] = this._listeners[ev].filter((l) => l !== cb && l._orig !== cb);
return this;
}
emit(ev, ...a) { for (const cb of [...(this._listeners[ev] || [])]) cb(...a); return this; }
send(data) { this.sent.push(typeof data === "string" ? data : JSON.stringify(data)); }
close() { this.emit("close"); }
destroy() { this.emit("close"); }
}

const origLoad = Module._load;
Module._load = function (request, parent, isMain) {
if (request === "ws") return MockWs;
return origLoad.apply(this, arguments);
};

const { ConnManager } = require("../conn-manager.js");
const { DaemonClient } = require("../daemon_client.js");

let tmpHome = null;
let origHome = null;
let origUserProfile = null;

function setupTempHome() {
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "emrg-conn-test-"));
fs.mkdirSync(path.join(tmpHome, ".emrg"), { recursive: true });
origHome = process.env.HOME;
origUserProfile = process.env.USERPROFILE;
process.env.HOME = tmpHome;
process.env.USERPROFILE = tmpHome;
// 预写 port 文件(模拟已运行 daemon)——路径必须落在 tmpHome 内
const portFile = path.join(tmpHome, ".emrg", "emrgd.port");
assert.ok(
path.resolve(portFile).startsWith(path.resolve(tmpHome) + path.sep),
"port file escapes tmpHome",
);
fs.writeFileSync(portFile, "41234\nseekrit-token");
}

function teardownTempHome() {
if (origHome !== undefined) process.env.HOME = origHome; else delete process.env.HOME;
if (origUserProfile !== undefined) process.env.USERPROFILE = origUserProfile; else delete process.env.USERPROFILE;
if (tmpHome) fs.rmSync(tmpHome, { recursive: true, force: true });
tmpHome = null;
}

async function waitForWs(predicate = () => currentMockWs) {
const deadline = Date.now() + 2000;
while (Date.now() < deadline) {
if (predicate()) return currentMockWs;
await new Promise((r) => setTimeout(r, 5));
}
assert.ok(false, "ws not created within timeout");
}

async function waitForAuthSent(ws) {
const deadline = Date.now() + 2000;
while (ws.sent.length === 0 && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 5));
}
assert.ok(ws.sent.length > 0, "auth frame should be sent");
}

/** 驱动一条连接完成 auth(open → auth 帧 → auth_ok) */
async function driveAuth(ws) {
ws.emit("open");
await waitForAuthSent(ws);
const authFrame = JSON.parse(ws.sent[0]);
assert.strictEqual(authFrame.type, "auth");
ws.emit("message", Buffer.from(JSON.stringify({ type: "auth_ok" })));
}

/** 驱动 manager.open 的完整两段流程:引导连接 + 会话连接(含 resume_session 响应) */
async function driveOpen(manager, sid, projectPath) {
const p = manager.open(sid, projectPath);
// ① 引导 client:连接 → auth → 关闭
let bootWs = await waitForWs();
await driveAuth(bootWs);
// 等引导 close(ensureConnected resolve → close 触发 ws.close → 新连接可能已建)
await new Promise((r) => setTimeout(r, 10));
// ② 会话 client:连接 → auth → resume_session → resume_result
let sessionWs = await waitForWs(() => currentMockWs !== bootWs);
await driveAuth(sessionWs);
// 等 resume_session 帧发出
const deadline = Date.now() + 2000;
while (sessionWs.sent.length < 2 && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 5));
}
const resumeFrame = JSON.parse(sessionWs.sent.at(-1));
assert.strictEqual(resumeFrame.type, "resume_session", "session open must resume_session");
assert.strictEqual(resumeFrame.session_id, sid);
assert.strictEqual(resumeFrame.cwd, projectPath);
sessionWs.emit("message", Buffer.from(JSON.stringify({ type: "resume_result", session_id: sid })));
const conn = await p;
return { conn, sessionWs };
}

beforeEach(() => {
setupTempHome();
currentMockWs = null;
});
afterEach(() => {
teardownTempHome();
currentMockWs = null;
});

test("P2 open: 引导 daemon + skipStart 会话连接 + resume_session 自动订阅", async () => {
const manager = new ConnManager({ projectDir: tmpHome });
const { conn, sessionWs } = await driveOpen(manager, "sess-1", "/proj/a");

assert.ok(conn instanceof DaemonClient);
assert.strictEqual(conn.connected, true);
assert.strictEqual(manager.get("sess-1"), conn);
assert.deepStrictEqual(manager.all(), ["sess-1"]);
// 会话连接 url = 预写 port 文件端口(daemon 未重启)
assert.strictEqual(sessionWs.url, "ws://127.0.0.1:41234");
});

test("P2 open: 已打开 sid → 复用连接,不重复 resume_session", async () => {
const manager = new ConnManager({ projectDir: tmpHome });
const first = await driveOpen(manager, "sess-1", "/proj/a");
const sentBefore = first.sessionWs.sent.length;
const second = await manager.open("sess-1", "/proj/a");
assert.strictEqual(second, first.conn, "same sid must reuse the same conn");
assert.strictEqual(first.sessionWs.sent.length, sentBefore, "no second resume_session");
});

test("P2 get: 未打开 sid → null", async () => {
const manager = new ConnManager({ projectDir: tmpHome });
assert.strictEqual(manager.get("nope"), null);
});

test("P2 close: 断开并移除;再 get → null;重复 close → false", async () => {
const manager = new ConnManager({ projectDir: tmpHome });
const { conn } = await driveOpen(manager, "sess-1", "/proj/a");
assert.strictEqual(manager.close("sess-1"), true);
assert.strictEqual(manager.get("sess-1"), null);
assert.strictEqual(conn.connected, false, "close must disconnect the ws");
assert.strictEqual(manager.close("sess-1"), false, "second close returns false");
});

test("P2 closeAll: 全部关闭", async () => {
const manager = new ConnManager({ projectDir: tmpHome });
await driveOpen(manager, "sess-1", "/proj/a");
await driveOpen(manager, "sess-2", "/proj/b");
manager.closeAll();
assert.strictEqual(manager.all().length, 0);
});
Loading