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
8 changes: 8 additions & 0 deletions .release-notes/on-demand-session-search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# 完全退出桌面后也能检索本机会话

<!-- release-target: v2 -->

## 新增功能

- ✨ 终端和 MCP Gateway 可在不打开桌面窗口的情况下搜索、读取本机会话,后台启动后持续运行,退出桌面后仍会定期更新索引,查询时立即补齐。
- 支持单独启动搜索后台、查看状态和手动停止;继续遵守会话来源与工具开关。
2 changes: 1 addition & 1 deletion apps/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,4 @@ agentrecall status --project backend

团队可使用 `work-config list/preview/install` 选择一组 Skill。安装后可用 `work-config installed/status/uninstall` 查看归属和整组卸载,共用 Skill 与原有独立安装会保留。整组升级使用 `work-config diff/update` 并指定新旧版本;共享内容冲突会阻止更新。工作配置组合当前仅包含 Skills;其他资源随团队同步分发,跨组合协调升级尚未提供。

会话和资源分别通过 `session search/get` 与 `resource search/get` 查询,使用正在运行的 V2 本地服务,详见 [分开搜索会话与资源](../../docs/v2/cli.md#分开搜索会话与资源)。这组命令不自动 Pull 或上传。
会话和资源分别通过 `session search/get` 与 `resource search/get` 查询,复用 V2 本地服务;本机会话在桌面未运行时可通过已安装的新版本 V2 按需启动后台,团队和资源仍要求桌面,详见 [分开搜索会话与资源](../../docs/v2/cli.md#分开搜索会话与资源)。这组命令不自动 Pull 或上传。
70 changes: 68 additions & 2 deletions apps/cli/src/search-client.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,89 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { execFile, type ExecFileException } from "node:child_process";
import { WorkspaceError } from "@agentrecall/workspace-core";

async function installedV2Launcher(): Promise<string> {
const environmentPath = Object.entries(process.env).find(([key]) => key.toUpperCase() === "PATH")?.[1] || "";
for (const entry of environmentPath.split(path.delimiter).filter(Boolean)) {
const directory = entry.replace(/^"(.*)"$/, "$1");
for (const candidate of [path.join(directory, "agent-recall-v2"), path.join(directory, "node_modules", "agent-recall-v2", "bin", "agent-recall.cjs"), path.join(directory, "..", "lib", "node_modules", "agent-recall-v2", "bin", "agent-recall.cjs")]) {
try {
const launcher = await fs.realpath(candidate);
if (path.basename(launcher) !== "agent-recall.cjs") continue;
const metadata: unknown = JSON.parse(await fs.readFile(path.join(path.dirname(launcher), "..", "package.json"), "utf8"));
if (metadata && typeof metadata === "object" && "name" in metadata && metadata.name === "agent-recall-v2") return launcher;
} catch (error) {
// PATH can contain missing entries, inaccessible executables or unrelated wrappers.
if (error instanceof SyntaxError || error instanceof Error && "code" in error && ["ENOENT", "ENOTDIR", "EACCES"].includes(String(error.code))) continue;
throw error;
}
}
}
throw new WorkspaceError("V2_UNAVAILABLE", "未找到 AgentRecall V2,请先安装或更新 agent-recall-v2,再使用无窗口会话搜索。");
}

async function backgroundSession(action: "search" | "get", args: Record<string, unknown>): Promise<unknown> {
const fields = action === "search" ? { source: "source", project: "project", limit: "limit" } : { offset: "offset", maxMessages: "limit" };
const valueField = action === "search" ? "query" : "sessionKey";
if (Object.keys(args).some((key) => key !== valueField && key !== "scope" && !Object.hasOwn(fields, key))) {
throw new WorkspaceError("DESKTOP_REQUIRED", "所选会话读取选项仍需运行 AgentRecall V2 桌面;无窗口模式支持基础搜索及 offset/limit 分页读取。");
}
const launcher = await installedV2Launcher();
try { await fs.access(path.join(path.dirname(launcher), "session-backend-client.cjs")); }
catch { throw new WorkspaceError("V2_UPDATE_REQUIRED", "已安装的 AgentRecall V2 尚不支持无窗口搜索,请更新 agent-recall-v2 后重试。"); }
const argv = ["session", action, String(args[valueField]), "--json"];
for (const [field, flag] of Object.entries(fields)) if (args[field] !== undefined) argv.push(`--${flag}`, String(args[field]));
const execution = await new Promise<{ stdout: string; error: ExecFileException | null }>((resolve) => {
execFile(process.execPath, [launcher, ...argv], { encoding: "utf8", timeout: 120_000, maxBuffer: 1024 * 1024, windowsHide: true }, (error, stdout) => resolve({ stdout, error }));
});
if (execution.error?.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") throw new WorkspaceError("RESULT_TOO_LARGE", "搜索结果过大,请缩小范围。");
if (execution.error?.killed) throw new WorkspaceError("BACKEND_TIMEOUT", "会话搜索后台启动或请求超时,请检查 V2 后台状态后重试。");
let result: unknown;
try { result = JSON.parse(execution.stdout); }
catch { throw new WorkspaceError("V2_UPDATE_REQUIRED", "已安装的 AgentRecall V2 未返回无窗口搜索协议,请更新 agent-recall-v2 后重试。"); }
if (!result || typeof result !== "object" || !("ok" in result)) throw new WorkspaceError("INVALID_RESPONSE", "V2 会话后台返回格式无效,请更新 V2 后重试。");
if (result.ok === false && "error" in result && result.error && typeof result.error === "object" && "message" in result.error && typeof result.error.message === "string") {
const code = "code" in result.error && typeof result.error.code === "string" ? result.error.code : "SEARCH_FAILED";
throw new WorkspaceError(code, result.error.message.slice(0, 2000));
}
if (execution.error || result.ok !== true || !("data" in result)) throw new WorkspaceError("SEARCH_FAILED", "V2 会话后台未能完成请求,请检查后台状态。");
return result.data;
}

function connectionRefused(error: unknown): boolean {
if (!(error instanceof Error)) return false;
if ("code" in error && error.code === "ECONNREFUSED") return true;
return error.cause !== undefined && connectionRefused(error.cause);
}

export async function desktopSearch(kind: "session" | "resource", action: "search" | "get", args: Record<string, unknown>): Promise<unknown> {
const localSession = kind === "session" && args.scope !== "team"
&& !(typeof args.sessionKey === "string" && args.sessionKey.startsWith("shared:"));
const config = process.platform === "darwin" ? path.join(os.homedir(), "Library", "Application Support")
: process.platform === "win32" ? process.env.APPDATA || os.homedir() : process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
const file = process.env.AGENT_RECALL_MCP_BRIDGE || path.join(config, "agent-recall-v2", "automation-mcp-bridge.json");
let value: unknown;
try { value = JSON.parse(await fs.readFile(file, "utf8")); }
catch { throw new WorkspaceError("DESKTOP_UNAVAILABLE", "请先启动 AgentRecall V2,再执行搜索或读取。"); }
catch (error) {
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
if (localSession) return backgroundSession(action, args);
throw new WorkspaceError("DESKTOP_UNAVAILABLE", "团队或资源检索需要先启动 AgentRecall V2 桌面。");
}
throw new WorkspaceError("INVALID_BRIDGE", "本地搜索连接信息无法读取或格式无效,请重启 V2。");
}
if (!value || typeof value !== "object") throw new WorkspaceError("INVALID_BRIDGE", "本地搜索连接信息无效,请重启 V2。");
const record = value as Record<string, unknown>;
if (record.host !== "127.0.0.1" || !Number.isInteger(record.port) || Number(record.port) < 1 || Number(record.port) > 65535
|| typeof record.token !== "string" || !/^[a-f0-9]{64}$/.test(record.token)) throw new WorkspaceError("INVALID_BRIDGE", "本地搜索连接信息无效,请重启 V2。");
let response: Response;
try { response = await fetch(`http://127.0.0.1:${record.port}/mcp/gateway/${kind === "session" ? "sessions" : "resources"}/${action}`, {
method: "POST", redirect: "error", signal: AbortSignal.timeout(30000), headers: { authorization: `Bearer ${record.token}`, "content-type": "application/json" }, body: JSON.stringify(args),
}); } catch { throw new WorkspaceError("DESKTOP_UNAVAILABLE", "本地搜索连接失败或超时,请确认 V2 正在运行。"); }
}); } catch (error) {
if (localSession && connectionRefused(error)) return backgroundSession(action, args);
throw new WorkspaceError("DESKTOP_UNAVAILABLE", "本地搜索连接失败或超时,请确认 V2 正在运行。");
}
const reader = response.body?.getReader();
if (!reader) throw new WorkspaceError("INVALID_RESPONSE", "本地搜索未返回数据。");
const chunks: Uint8Array[] = []; let bytes = 0;
Expand Down
102 changes: 101 additions & 1 deletion apps/cli/test/search-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,32 @@ import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import http from "node:http";
import test from "node:test";
import test, { type TestContext } from "node:test";
import { cli, fixture } from "./fixtures.js";
import { desktopSearch } from "../src/search-client.js";

async function backgroundFixture(t: TestContext, layout: "npm" | "posix" = "npm") {
const { root, home } = await fixture(t);
const bin = path.join(root, "prefix", "bin");
await fs.mkdir(bin, { recursive: true });
const packagePath = layout === "npm" ? path.join(bin, "node_modules", "agent-recall-v2") : path.join(root, "prefix", "lib", "node_modules", "agent-recall-v2");
const launcher = path.join(packagePath, "bin", "agent-recall.cjs");
await fs.mkdir(path.dirname(launcher), { recursive: true });
await fs.writeFile(path.join(packagePath, "package.json"), JSON.stringify({ name: "agent-recall-v2" }));
await fs.writeFile(path.join(path.dirname(launcher), "session-backend-client.cjs"), "// Synthetic package capability marker.\n");
const marker = path.join(root, "invocations");
await fs.writeFile(launcher, `const fs = require('node:fs'); fs.appendFileSync(${JSON.stringify(marker)}, 'called\\n'); console.log(JSON.stringify({ok:true,data:{argv:process.argv.slice(2),home:process.env.HOME}}));\n`);
const environment = { PATH: bin, HOME: home, USERPROFILE: home, APPDATA: path.join(home, "appdata"), XDG_CONFIG_HOME: path.join(home, "config"), AGENT_RECALL_MCP_BRIDGE: path.join(root, "bridge.json") };
const original = Object.fromEntries(Object.keys(environment).map(key => [key, process.env[key]]));
Object.assign(process.env, environment);
t.after(() => { for (const [key, value] of Object.entries(original)) { if (value === undefined) delete process.env[key]; else process.env[key] = value; } });
return { root, home, bin, launcher, marker, bridge: environment.AGENT_RECALL_MCP_BRIDGE };
}

function resultArguments(value: unknown): string[] {
assert.ok(value && typeof value === "object" && "argv" in value && Array.isArray(value.argv));
return value.argv;
}

test("CLI routes session/resource search and reads to the authenticated local V2 service", async t => {
const { root, home } = await fixture(t);
Expand Down Expand Up @@ -33,3 +57,79 @@ test("CLI routes session/resource search and reads to the authenticated local V2
assert.equal((await cli(home, root, ["session", "search", "x"])).result.error?.code, "INVALID_BRIDGE");
assert.equal(seen.length, 4);
});

test("missing desktop bridge uses an installed V2 launcher for search/get without a shell", async t => {
const f = await backgroundFixture(t);
const query = 'quotes " ; $(touch should-not-exist) `whoami` 中文';
const searched = await desktopSearch("session", "search", { query, source: "deepseek-cli", project: "/projects/a b", limit: 7, scope: "local" });
assert.deepEqual(resultArguments(searched), ["session", "search", query, "--json", "--source", "deepseek-cli", "--project", "/projects/a b", "--limit", "7"]);
const read = await desktopSearch("session", "get", { sessionKey: "codex:session with spaces", offset: 4, maxMessages: 8 });
assert.deepEqual(resultArguments(read), ["session", "get", "codex:session with spaces", "--json", "--offset", "4", "--limit", "8"]);
assert.ok(searched && typeof searched === "object" && "home" in searched);
assert.equal(searched.home, f.home);
assert.equal(await fs.readFile(f.marker, "utf8"), "called\ncalled\n");
});

test("POSIX PATH symlinks resolve the actual V2 JavaScript launcher", { skip: process.platform === "win32" }, async t => {
const f = await backgroundFixture(t, "posix");
await fs.symlink(f.launcher, path.join(f.bin, "agent-recall-v2"));
assert.deepEqual(resultArguments(await desktopSearch("session", "search", { query: "linked" })), ["session", "search", "linked", "--json"]);
});

test("a refused desktop connection falls back but team and resources still require desktop", async t => {
const f = await backgroundFixture(t);
const server = http.createServer();
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
const address = server.address(); assert.ok(address && typeof address !== "string");
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
await fs.writeFile(f.bridge, JSON.stringify({ host: "127.0.0.1", port: address.port, token: "a".repeat(64) }));
assert.deepEqual(resultArguments(await desktopSearch("session", "search", { query: "offline" })), ["session", "search", "offline", "--json"]);
await fs.rm(f.marker);
for (const [kind, args] of [["session", { query: "team", scope: "team", teamId: "dev" }], ["resource", { query: "skill" }]] as const) {
await assert.rejects(desktopSearch(kind, "search", args), { code: "DESKTOP_UNAVAILABLE" });
}
await fs.rm(f.bridge);
await assert.rejects(desktopSearch("session", "search", { query: "team", scope: "team", teamId: "dev" }), { code: "DESKTOP_UNAVAILABLE" });
await assert.rejects(desktopSearch("resource", "search", { query: "skill" }), { code: "DESKTOP_UNAVAILABLE" });
await assert.rejects(desktopSearch("session", "get", { sessionKey: "shared:fixture" }), { code: "DESKTOP_UNAVAILABLE" });
await assert.rejects(fs.readFile(f.marker), { code: "ENOENT" });
});

test("invalid discovery, HTTP failures and timeouts do not bypass the desktop bridge", async t => {
const f = await backgroundFixture(t);
for (const contents of ["{", JSON.stringify({ host: "example.com", port: 12345, token: "a".repeat(64) })]) {
await fs.writeFile(f.bridge, contents);
await assert.rejects(desktopSearch("session", "search", { query: "x" }), { code: "INVALID_BRIDGE" });
}
let status = 403;
const server = http.createServer((_request, response) => { response.writeHead(status); response.end(JSON.stringify({ error: "not allowed" })); });
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
t.after(() => new Promise<void>(resolve => { server.closeAllConnections(); server.close(() => resolve()); }));
const address = server.address(); assert.ok(address && typeof address !== "string");
await fs.writeFile(f.bridge, JSON.stringify({ host: "127.0.0.1", port: address.port, token: "a".repeat(64) }));
for (status of [403, 500]) await assert.rejects(desktopSearch("session", "search", { query: "x" }), { code: "SEARCH_FAILED" });
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => { throw new DOMException("Timed out", "TimeoutError"); };
try { await assert.rejects(desktopSearch("session", "search", { query: "x" }), { code: "DESKTOP_UNAVAILABLE" }); }
finally { globalThis.fetch = originalFetch; }
await assert.rejects(fs.readFile(f.marker), { code: "ENOENT" });
});

test("missing, old and failing V2 installations report actionable errors", async t => {
const f = await backgroundFixture(t);
await fs.rm(path.dirname(path.dirname(f.launcher)), { recursive: true });
await assert.rejects(desktopSearch("session", "search", { query: "x" }), error => error instanceof Error && "code" in error && error.code === "V2_UNAVAILABLE" && /安装或更新/.test(error.message));
await fs.mkdir(path.dirname(f.launcher), { recursive: true });
await fs.writeFile(path.join(path.dirname(f.launcher), "..", "package.json"), JSON.stringify({ name: "unrelated" }));
await fs.writeFile(f.launcher, "console.log('starting Electron');\n");
await assert.rejects(desktopSearch("session", "search", { query: "x" }), { code: "V2_UNAVAILABLE" });
await fs.writeFile(path.join(path.dirname(f.launcher), "..", "package.json"), JSON.stringify({ name: "agent-recall-v2" }));
await assert.rejects(desktopSearch("session", "search", { query: "x" }), { code: "V2_UPDATE_REQUIRED" });
await fs.writeFile(path.join(path.dirname(f.launcher), "session-backend-client.cjs"), "// Synthetic package capability marker.\n");
await assert.rejects(desktopSearch("session", "search", { query: "x" }), { code: "V2_UPDATE_REQUIRED" });
await fs.writeFile(f.launcher, "console.log(JSON.stringify({ok:false,error:{code:'INDEX_FAILED',message:'fixture index failed'}})); process.exitCode=1;\n");
await assert.rejects(desktopSearch("session", "get", { sessionKey: "codex:x" }), { code: "INDEX_FAILED", message: "fixture index failed" });
await fs.writeFile(f.launcher, "process.stdout.write('x'.repeat(2 * 1024 * 1024));\n");
await assert.rejects(desktopSearch("session", "search", { query: "large" }), { code: "RESULT_TOO_LARGE" });
await assert.rejects(desktopSearch("session", "get", { sessionKey: "codex:x", maxChars: 50 }), { code: "DESKTOP_REQUIRED" });
});
5 changes: 5 additions & 0 deletions apps/main-2.0/bin/agent-recall.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ function launchApp() {
}

async function main() {
const argv = process.argv.slice(2);
if (argv[0] === "session" || argv[0] === "backend") {
process.exitCode = await require("./session-backend-cli.cjs").runSessionBackendCommand(argv);
return;
}
const args = new Set(process.argv.slice(2));
const waitPidIndex = process.argv.indexOf("--wait-pid");
const waitPid = Number(waitPidIndex >= 0 ? process.argv[waitPidIndex + 1] : undefined);
Expand Down
10 changes: 9 additions & 1 deletion apps/main-2.0/bin/apply-update.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
const fs = require("node:fs/promises");
const path = require("node:path");
const { assertEmbeddedPostgresRuntime } = require("./embedded-postgres-runtime.cjs");
const { acquireSessionBackendMaintenance } = require("./session-backend-client.cjs");
const {
acquireUpdateLock,
clearInstallStatus,
Expand Down Expand Up @@ -154,13 +155,17 @@ async function main() {
const waitPid = Number(argumentValue("--wait-pid"));
if (!manifestPath && !stagedPath) throw new Error("--manifest or --staged is required.");
let lock = null;
let releaseBackend = null;
try {
lock = await acquireUpdateLock();
if (Number.isInteger(waitPid) && waitPid > 0 && waitPid !== process.pid) await waitForProcessExit(waitPid, 30_000);
if (process.argv.includes("--stop-app")) {
process.stdout.write("正在等待应用退出…\n");
await stopRunningApp();
}
// A desktop quit releases its lease; the shared backend has its own lifetime.
// Refuse to replace its executable while another client is using it.
releaseBackend = await acquireSessionBackendMaintenance();
let version;
if (stagedPath) {
const staged = JSON.parse(await fs.readFile(stagedPath, "utf8"));
Expand All @@ -185,9 +190,12 @@ async function main() {
await clearInstallStatus().catch(() => undefined);
}
process.stdout.write(`AgentRecall v${version} 安装完成,正在重新启动。\n`);
await releaseBackend();
releaseBackend = null;
await relaunchInstalledApp();
} finally {
await lock?.release().catch(() => undefined);
try { await releaseBackend?.(); }
finally { await lock?.release().catch(() => undefined); }
const controlPath = stagedPath || manifestPath;
await fs.rm(path.dirname(controlPath), { recursive: true, force: true }).catch(() => undefined);
}
Expand Down
Loading
Loading