diff --git a/apps/hook/server/cli.ts b/apps/hook/server/cli.ts index bd7af752d1..e4512c568e 100644 --- a/apps/hook/server/cli.ts +++ b/apps/hook/server/cli.ts @@ -316,9 +316,9 @@ export const INBOX_COMMAND_USAGE = [ " browser tab) and print its URL. A run published by", " --tailscale is published again.", " plannotator inbox --notes Add the Inbox note to these agents' instructions", - " files (claude-code, codex, gemini, pi, opencode,", - " comma-separated). Only with the person's yes;", - " starts nothing.", + " files (claude-code, codex, gemini, qwen, pi,", + " opencode, comma-separated). Only with the", + " person's yes; starts nothing.", "", "Over the tailnet at every start: the Inbox's Settings, PLANNOTATOR_INBOX_TAILSCALE=1,", "or { \"inboxTailscale\": true } in config.json. Agents and MCP stay on this computer;", diff --git a/apps/hook/server/inbox-cli.test.ts b/apps/hook/server/inbox-cli.test.ts index a0d0105958..8f0e7abc0c 100644 --- a/apps/hook/server/inbox-cli.test.ts +++ b/apps/hook/server/inbox-cli.test.ts @@ -24,7 +24,7 @@ import { INBOX_MAX_REQUEST_BYTES } from "@plannotator/shared/inbox/connection"; import { INBOX_BRIDGE_POLL_PATH, parseInboxBridgeCommands } from "@plannotator/shared/inbox/connection"; import { INBOX_AGENT_NAME, INBOX_HOST } from "../hooks/mod/inbox"; import { OPENCODE_INBOX_AGENT_NAME, OPENCODE_INBOX_HOST } from "../../opencode-plugin/inbox"; -import { fillInboxToolArguments, inboxHostSessionFromEnv, INBOX_SESSION_HOST_NAMES, runInboxMcpShim } from "./inbox-mcp-shim"; +import { fillInboxToolArguments, inboxCallHostSession, inboxHostSessionFromEnv, inboxQwenShimContext, INBOX_SESSION_HOST_NAMES, runInboxMcpShim } from "./inbox-mcp-shim"; import { removeStubbedHtml, stubHookDistHtml } from "../../../tests/helpers/hook-dist-stub"; import { type InboxEntry, inboxEntryOf, mustSignalInbox, signalProvenInbox, stopProvenInboxes } from "../../../tests/helpers/inbox-process"; @@ -594,6 +594,127 @@ describe("plannotator inbox mcp inside an agent session", () => { }, 90_000); }); +describe("plannotator inbox mcp inside a Qwen Code session", () => { + const CLAUDE_SESSION = "0f3c2a51-7d4e-4b8a-9e21-5c6d7e8f9a0b"; + const QWEN_A = "8e016be8-5b48-4c13-ad22-1f5326ae64ac"; + const QWEN_B = "3c9d1f20-7a4b-4e6c-9d8e-0a1b2c3d4e5f"; + const QWEN_STALE = "11111111-2222-4333-8444-555555555555"; + const claude = { host: "claude-code", session: CLAUDE_SESSION, name: "Claude Code" }; + const qwen = (session: string) => ({ host: "qwen", session, name: "Qwen Code" }); + /** The name a Qwen Code session's MCP client gives (`qwen-cli-mcp-client-`, Qwen 0.26). */ + const QWEN_CLIENT = "qwen-cli-mcp-client-plannotator-inbox"; + + /** A Qwen home with the registry record Qwen writes for a session (cross-session protocol, schema 1). */ + function qwenHomeWith(pid: number, sessionId: string, extra: Record = {}): string { + const root = realpathSync(mkdtempSync(join(tmpdir(), "plannotator-qwen-home-"))); + roots.push(root); + writeRecord(root, pid, sessionId, extra); + return root; + } + function writeRecord(home: string, pid: number, sessionId: string, extra: Record = {}, name = `${pid}.json`): void { + mkdirSync(join(home, "sessions"), { recursive: true }); + writeFileSync(join(home, "sessions", name), JSON.stringify({ schemaVersion: 1, pid, sessionId, cwd: "/work", kind: "tui", startedAt: 1, ...extra })); + } + + test("Qwen's session variable is the session for a command Qwen's shell runs; another host's variable comes first; a malformed one is ignored", () => { + expect(inboxHostSessionFromEnv({ QWEN_CODE_SESSION_ID: QWEN_A })).toEqual(qwen(QWEN_A)); + expect(inboxHostSessionFromEnv({ QWEN_CODE_SESSION_ID: QWEN_A, PLANNOTATOR_SESSION_TAG: `claude-code:${CLAUDE_SESSION}` })).toEqual(claude); + for (const bad of ["", "abc", "../../etc", `${QWEN_A} `, "--yolo"]) expect(inboxHostSessionFromEnv({ QWEN_CODE_SESSION_ID: bad })).toBeNull(); + }); + + test("a call from Qwen is the session Qwen's registry has for the shim's parent now: /clear and /resume move it; without a record, the variable", () => { + const pid = 41337; + const home = qwenHomeWith(pid, QWEN_A); + const context = inboxQwenShimContext({ QWEN_HOME: home, QWEN_CODE_SESSION_ID: QWEN_STALE }, "/nowhere", "/work", pid); + expect(context).toEqual({ home, parentPid: pid, envSession: QWEN_STALE }); + const env = inboxHostSessionFromEnv({ QWEN_CODE_SESSION_ID: QWEN_STALE }); + expect(inboxCallHostSession(env, context, undefined, QWEN_CLIENT)).toEqual(qwen(QWEN_A)); + // /clear: Qwen patches the record under the same pid; the next call reads the new id. + writeRecord(home, pid, QWEN_B); + expect(inboxCallHostSession(env, context, undefined, QWEN_CLIENT)).toEqual(qwen(QWEN_B)); + // A record that is not usable is never guessed from: the variable Qwen started the shim with. + for (const bad of [{ pid: pid + 1 }, { sessionId: "../x" }, { schemaVersion: 2 }]) { + writeRecord(home, pid, QWEN_B, bad); + expect(inboxCallHostSession(env, context, undefined, QWEN_CLIENT)).toEqual(qwen(QWEN_STALE)); + } + // A `kind` of the wrong shape is only that field: Qwen reads the record as a terminal session's, and so does the shim. + writeRecord(home, pid, QWEN_B, { kind: "NOT A KIND" }); + expect(inboxCallHostSession(env, context, undefined, QWEN_CLIENT)).toEqual(qwen(QWEN_B)); + rmSync(join(home, "sessions"), { recursive: true }); + expect(inboxCallHostSession(env, context, undefined, QWEN_CLIENT)).toEqual(qwen(QWEN_STALE)); + // A process hosting several sessions (`-<8 hex>.json`): one record is that session; two, only the call can say. + writeRecord(home, pid, QWEN_A, {}, `${pid}-0a1b2c3d.json`); + expect(inboxCallHostSession(env, context, undefined, QWEN_CLIENT)).toEqual(qwen(QWEN_A)); + writeRecord(home, pid, QWEN_B, {}, `${pid}-ffffffff.json`); + expect(inboxCallHostSession(env, context, undefined, QWEN_CLIENT)).toEqual(qwen(QWEN_STALE)); + // A daemon-run session names itself on the call: that wins. + const meta = { "qwen-code/invocation": { version: 1, sessionId: QWEN_B, promptId: "p1" } }; + expect(inboxCallHostSession(env, context, meta, QWEN_CLIENT)).toEqual(qwen(QWEN_B)); + }); + + test("Qwen's client with no registry record (cross-session messaging off, bare mode, `qwen -p`): the variable Qwen started the shim with", () => { + const home = realpathSync(mkdtempSync(join(tmpdir(), "plannotator-qwen-home-"))); + roots.push(home); + const context = inboxQwenShimContext({ QWEN_HOME: home, QWEN_CODE_SESSION_ID: QWEN_A }, "/nowhere", "/work", 61234); + const env = inboxHostSessionFromEnv({ QWEN_CODE_SESSION_ID: QWEN_A }); + expect(inboxCallHostSession(env, context, undefined, QWEN_CLIENT)).toEqual(qwen(QWEN_A)); + // Qwen's older one-shot client name counts too; another client whose name only starts alike does not. + expect(inboxCallHostSession(env, context, undefined, "qwen-code-mcp-client")).toEqual(qwen(QWEN_A)); + expect(inboxCallHostSession(env, context, undefined, "qwen-cli-mcp-client-")).toBeNull(); + expect(inboxCallHostSession(claude, context, undefined, QWEN_CLIENT)).toEqual(qwen(QWEN_A)); + }); + + test("Qwen's client wins over another host's variable; the variable alone does not, nor when another client named itself", () => { + const pid = 51234; + const home = qwenHomeWith(pid, QWEN_A); + const withRecord = inboxQwenShimContext({ QWEN_HOME: home, QWEN_CODE_SESSION_ID: QWEN_STALE }, "/nowhere", "/work", pid); + const noRecord = inboxQwenShimContext({ QWEN_HOME: home, QWEN_CODE_SESSION_ID: QWEN_STALE }, "/nowhere", "/work", pid + 7); + expect(inboxCallHostSession(claude, withRecord, undefined, QWEN_CLIENT)).toEqual(qwen(QWEN_A)); + // The parent is a Qwen process (its record is there), whatever the client called itself. + expect(inboxCallHostSession(claude, withRecord, undefined, "some-client")).toEqual(qwen(QWEN_A)); + // A variable inherited by another client's process (no record for the parent): not Qwen's. + expect(inboxCallHostSession(qwen(QWEN_STALE), noRecord, undefined, "cursor-vscode")).toBeNull(); + expect(inboxCallHostSession(claude, noRecord, undefined, "cursor-vscode")).toEqual(claude); + // No client name yet and no record: the environment's own order (another host first). + expect(inboxCallHostSession(claude, noRecord, undefined, null)).toEqual(claude); + expect(inboxCallHostSession(qwen(QWEN_STALE), noRecord, undefined, null)).toEqual(qwen(QWEN_STALE)); + // Not in Qwen at all: unchanged. + const none = inboxQwenShimContext({}, "/nowhere", "/work", pid + 7); + expect(inboxCallHostSession(claude, none, undefined, "codex-mcp-client")).toEqual(claude); + expect(inboxCallHostSession(null, none, undefined, null)).toBeNull(); + }); + + test("the shim Qwen starts sends as Qwen's current session: the author follows a /clear between two calls", async () => { + const box = sandbox(); + const project = gitProject(box); + const registry = (await background(box)).registry!; + // The shim's parent is this test process (the transport spawns it directly), so Qwen's record is keyed by our pid. + const home = qwenHomeWith(process.pid, QWEN_A); + const client = new Client({ name: QWEN_CLIENT, version: "0.26.2" }); + await client.connect( + new StdioClientTransport({ + command: process.execPath, + args: ["run", entry, "inbox", "mcp"], + cwd: project, + env: { ...box.env, QWEN_HOME: home, QWEN_CODE_SESSION_ID: QWEN_STALE }, + stderr: "pipe", + }), + ); + cleanups.push(() => client.close()); + const author = async (threadId: string, index: number) => { + const { thread } = await (await fetch(`http://127.0.0.1:${registry.port}/api/inbox/threads/${threadId}`)).json(); + return thread.messages[index].author as { kind: string; host: string | null; session: string | null; name: string | null }; + }; + const first = (await client.callTool({ name: "send_message", arguments: { body: "Before /clear.", idempotency_key: "qwen-1" } })).structuredContent as { thread_id: string }; + expect(await author(first.thread_id, 0)).toEqual({ kind: "agent", host: "qwen", session: QWEN_A, name: "Qwen Code" }); + writeRecord(home, process.pid, QWEN_B); + const second = (await client.callTool({ name: "send_message", arguments: { body: "After /clear.", idempotency_key: "qwen-2" } })).structuredContent as { thread_id: string }; + expect(await author(second.thread_id, 0)).toEqual({ kind: "agent", host: "qwen", session: QWEN_B, name: "Qwen Code" }); + // A new session starts its own thread. + expect(second.thread_id).not.toBe(first.thread_id); + }, 90_000); +}); + describe("plannotator inbox mcp: large messages", () => { test("a send_message past 4 MiB through the stdio shim lands whole, quickly", async () => { const box = sandbox(); diff --git a/apps/hook/server/inbox-command.ts b/apps/hook/server/inbox-command.ts index 839ea0dbde..8e5258ae5c 100644 --- a/apps/hook/server/inbox-command.ts +++ b/apps/hook/server/inbox-command.ts @@ -33,6 +33,7 @@ import { spawn } from "node:child_process"; import { closeSync, existsSync, mkdirSync, openSync, rmSync, statSync, utimesSync } from "node:fs"; +import { homedir } from "node:os"; import { join } from "node:path"; import { getPlannotatorDataDir } from "@plannotator/shared/data-dir"; import { isNewerStableVersion } from "@plannotator/shared/update-check"; @@ -58,7 +59,7 @@ import { addInboxNote, INBOX_NOTE_BODY, inboxNotesDisabled, markNotesAsked } fro import { INBOX_AGENT_LABELS, INBOX_AUTO_SEND_AFTER_MS, INBOX_NOTE_AGENT_IDS, type InboxConnectReport, type InboxNoteAgentId } from "@plannotator/core/inbox-types"; import { writeUrlQr } from "@plannotator/server/qr"; import { getCliVersion } from "./cli"; -import { inboxHostSessionFromEnv, runInboxMcpShim } from "./inbox-mcp-shim"; +import { inboxHostSessionFromEnv, inboxQwenShimContext, runInboxMcpShim } from "./inbox-mcp-shim"; export const INBOX_LOG_FILE = "inbox.log"; /** Set by a starter that holds the start lock for the detached Inbox it spawns. */ @@ -601,6 +602,7 @@ export async function runInboxCommand(args: readonly string[], assets: InboxComm cwd: process.env.PLANNOTATOR_CWD || process.cwd(), ensureRunning: () => ensureInboxRunning(dataDir), hostSession: inboxHostSessionFromEnv(process.env), + qwen: inboxQwenShimContext(process.env, homedir(), process.cwd(), process.ppid), }); process.exit(0); } diff --git a/apps/hook/server/inbox-help.ts b/apps/hook/server/inbox-help.ts index 1757b84b6b..2ec1064ec6 100644 --- a/apps/hook/server/inbox-help.ts +++ b/apps/hook/server/inbox-help.ts @@ -324,7 +324,7 @@ export function formatInboxHelp(options: InboxHelpOptions): string { `- **Pi** with the Plannotator extension (\`pi install npm:@plannotator/pi-extension\` if it is missing), or **OpenCode** with the Plannotator plugin (\`@plannotator/opencode@latest\` in opencode.json: \`"plugin": [...]\` on OpenCode 1, \`"plugins": [{ "package": ... }]\` on OpenCode 2): the inbox tool is off by default there. Turn it on for your host by merging ${code(`{ "inboxTool": { "pi": true } }`)} (or \`"opencode"\`) into ${code(configPath)}, keeping its other keys, or with the switch in the Inbox's Settings. Then start a new Pi session (or \`/reload\`), or restart OpenCode. Pi and OpenCode send the tool with every request: ${inboxToolCostText("pi")} each (the size measured on both; the token count depends on the model), so tell the person before you turn it on.`, "- **Anything else**: register the stdio server; \"Connecting other agents\" below has the line for your host.", "", - "When the person runs `plannotator inbox` themselves, it already connects the terminal agents it finds (Codex, Gemini CLI, Cursor, Windsurf/Devin, Amp, Kiro, Pi 0.99+, OpenCode with its plugin) and the Claude app, so check before adding a second entry: a `plannotator-inbox` server that runs this binary is already the connection.", + "When the person runs `plannotator inbox` themselves, it already connects the terminal agents it finds (Codex, Gemini CLI, Qwen Code, Cursor, Windsurf/Devin, Amp, Kiro, Pi 0.99+, OpenCode with its plugin) and the Claude app, so check before adding a second entry: a `plannotator-inbox` server that runs this binary is already the connection.", "", `A connection takes effect in the next session, never in the one already running. Until then, send through the stdio server directly: ${code(mcp)}.`, "", diff --git a/apps/hook/server/inbox-mcp-shim.ts b/apps/hook/server/inbox-mcp-shim.ts index 5776e0b3fd..82443ee596 100644 --- a/apps/hook/server/inbox-mcp-shim.ts +++ b/apps/hook/server/inbox-mcp-shim.ts @@ -20,15 +20,33 @@ * on every tool call (`params._meta.threadId`, from the `codex-mcp-client`) * and in the commands its shell runs (`CODEX_THREAD_ID`), and the shim sends * as `agent_host: "codex"` with that id, so the Inbox can queue the person's - * reply into that thread (packages/server/inbox-codex-wake.ts). Otherwise it is one `ses_` id per shim process, + * reply into that thread (packages/server/inbox-codex-wake.ts). A Qwen Code + * session is read at every call (`inboxCallHostSession`): Qwen's registry + * record for the shim's parent process, since `/clear` and `/resume` change + * the id the shim was started with. Otherwise it is one `ses_` id per shim process, * and an MCP client that wants its threads across restarts passes * `agent_session` itself. * * stdout carries JSON-RPC only; anything else goes to stderr. */ -import { INBOX_CODEX_HOST, INBOX_CODEX_NAME, INBOX_HOST_NAMES, INBOX_WAKING_HOSTS, codexThreadOfCall, inboxId, isCodexThreadId } from "@plannotator/core/inbox-types"; +import { + INBOX_CODEX_HOST, + INBOX_CODEX_NAME, + INBOX_HOST_NAMES, + INBOX_QWEN_HOST, + INBOX_QWEN_NAME, + INBOX_QWEN_SESSION_ENV, + INBOX_WAKING_HOSTS, + codexThreadOfCall, + inboxId, + isCodexThreadId, + isQwenMcpClient, + isQwenSessionId, + qwenSessionOfCall, +} from "@plannotator/core/inbox-types"; import { inboxStatus, type InboxRegistryEntry } from "@plannotator/shared/inbox/registry"; +import { qwenHome, qwenSessionOfProcess } from "@plannotator/shared/inbox/qwen-sessions"; import { INBOX_MAX_REQUEST_BYTES, INBOX_WAKES_META_KEY, inboxRequestTooLargeMessage } from "@plannotator/shared/inbox/connection"; const FILLED_TOOLS = new Set(["send_message", "read_thread", "wait_for_reply", "submit_guide", "card"]); @@ -93,8 +111,68 @@ export function inboxHostSessionFromEnv(env: Record) if (typeof pi === "string" && HOST_SESSION_ID.test(pi)) return { host: "pi", session: pi, name: INBOX_SESSION_HOST_NAMES.pi! }; const codex = env.CODEX_THREAD_ID; if (isCodexThreadId(codex)) return { host: INBOX_CODEX_HOST, session: codex, name: INBOX_CODEX_NAME }; + const qwen = env[INBOX_QWEN_SESSION_ENV]; + if (isQwenSessionId(qwen)) return { host: INBOX_QWEN_HOST, session: qwen, name: INBOX_QWEN_NAME }; return null; } + +/** + * Where the shim looks for the Qwen Code session it runs in: Qwen's home + * (`$QWEN_HOME`, else `~/.qwen`), the shim's parent process (the Qwen process + * that started it as an MCP server), and the session id Qwen set when it + * started the shim (`QWEN_CODE_SESSION_ID`; stale after `/clear` or `/resume`). + */ +export interface InboxQwenShimContext { + home: string; + parentPid: number; + envSession: string | null; +} + +export function inboxQwenShimContext(env: Record, homeDir: string, cwd: string, parentPid: number): InboxQwenShimContext { + const session = env[INBOX_QWEN_SESSION_ENV]; + return { home: qwenHome(env, homeDir, cwd), parentPid, envSession: isQwenSessionId(session) ? session : null }; +} + +const qwenHostSession = (session: string): InboxHostSession => ({ host: INBOX_QWEN_HOST, session, name: INBOX_QWEN_NAME }); + +/** + * The host session a tool call belongs to, with Qwen Code's read at the call: + * + * - a daemon-run Qwen session names itself on the call + * (`_meta["qwen-code/invocation"].sessionId`); + * - otherwise, when the caller is Qwen (its MCP client name, or Qwen's + * session variable in this process's environment), the session is the one + * Qwen's registry has now for the parent process + * (`$QWEN_HOME/sessions/.json`, read on every call, so `/clear` + * and `/resume` move the shim to the new session), else that variable; + * - a variable another MCP client's process inherited (the client named + * itself, and the registry has no record for the parent) is not Qwen's. + * + * Qwen wins over another host's variable only when Qwen's client or registry + * says so; on the variable alone, the environment's own order holds + * (`inboxHostSessionFromEnv`). + */ +export function inboxCallHostSession( + hostSession: InboxHostSession | null, + qwen: InboxQwenShimContext | null | undefined, + meta: unknown, + clientName: string | null, +): InboxHostSession | null { + const named = qwenSessionOfCall(meta); + if (named) return qwenHostSession(named); + const fromEnv = qwen?.envSession ?? null; + const qwenClient = isQwenMcpClient(clientName); + const other = hostSession && hostSession.host !== INBOX_QWEN_HOST ? hostSession : null; + if (!qwen || (!qwenClient && fromEnv === null)) return hostSession; + const record = qwenSessionOfProcess(qwen.home, qwen.parentPid); + if (qwenClient || record) { + const session = record?.sessionId ?? fromEnv; + return session ? qwenHostSession(session) : other; + } + // The variable alone: another client that named itself is not Qwen; with no name, the environment's order. + if (clientName !== null) return other; + return other ?? qwenHostSession(fromEnv!); +} /** The 2026-07-28 per-request envelope's protocol-version key. */ const PROTOCOL_VERSION_META_KEY = "io.modelcontextprotocol/protocolVersion"; @@ -104,6 +182,8 @@ export interface InboxMcpShimOptions { ensureRunning: () => Promise; /** The agent host session the shim runs inside (`inboxHostSessionFromEnv`); absent: one `ses_` id per process. */ hostSession?: InboxHostSession | null; + /** Where a Qwen Code session is read at each call (`inboxQwenShimContext`); absent: Qwen only through `hostSession`. */ + qwen?: InboxQwenShimContext | null; /** Default: process stdin / stdout. */ input?: ReadableStream; write?: (line: string) => void; @@ -200,7 +280,9 @@ export async function runInboxMcpShim(options: InboxMcpShimOptions): Promise inbox mcp`. The agent uses it from it - An existing `plannotator-inbox` entry that runs something else is never replaced. - In a JSON file only that one entry is added; every other byte of the file stays as it was, and removing it gives the file back exactly as it was (an empty file stays empty; a file Plannotator created is deleted). A file Plannotator cannot edit that way (comments, a duplicate key) is left as it was, and the terminal says why. - Editors (VS Code, Zed, JetBrains, Goose) are never changed. Settings has a one-click link or a snippet for each. +- Qwen Code's `settings.json` may hold comments: Plannotator keeps every one, and adds only its own entry. It never uses `qwen mcp add`, which writes your other servers back with their `${VAR}` values filled in (a token kept in your environment would end up in the file). A file that is not valid JSON or is read-only is left as it was, and the terminal says why. A relative `QWEN_HOME` names a different folder in each project, so you connect Qwen Code by hand then. If the Inbox starts with another `QWEN_HOME` than the one Qwen Code was connected in, Plannotator leaves both folders as they are and tells you once. **Settings → Agents** shows Qwen Code off there, with the folder it is connected in; turn it on to move it. If the new folder's settings.json can't take it, nothing changes and the reason is shown. - `PLANNOTATOR_INBOX_CONNECT=0` skips this step. - `plannotator uninstall` removes every entry it recorded adding (never one you added yourself). `--dry-run` lists them first. If its record (`~/.plannotator/inbox/agents.json`) is gone, it removes none: an entry that runs this Plannotator is named with how to remove it, since you may have added it by hand. @@ -95,6 +97,7 @@ The first time you open the Inbox after it connected your agents, a dialog asks | Claude Code | `~/.claude/rules/plannotator-inbox.md` (a file of its own) | | Codex | `~/.codex/AGENTS.override.md` when you have one, else `~/.codex/AGENTS.md` | | Gemini CLI | `~/.gemini/GEMINI.md` (or your `context.fileName`) | +| Qwen Code | `~/.qwen/QWEN.md` (or your `context.fileName`; in `$QWEN_HOME` when set) | | Pi | the one instructions file Pi reads in `~/.pi/agent/` | | OpenCode | `~/.config/opencode/AGENTS.md`, only when you already have it: creating it would stop OpenCode 1 reading your `~/.claude/CLAUDE.md` | | Cursor | Customize → Rules in the Cursor app (you copy it by hand; Cursor has no global rules file) | @@ -306,7 +309,7 @@ claude mcp add --scope user plannotator-inbox -- plannotator inbox mcp codex mcp add plannotator-inbox -- plannotator inbox mcp ``` -The Inbox's first-run screen and **Settings** give the exact command or install link for each client, with the absolute path of your `plannotator` binary filled in. The list covers Claude Code, the Claude app, Codex, Cursor, VS Code, Windsurf / Devin, Gemini CLI, Goose, Amp, Cline, Pi, OpenCode, Zed, Kiro, JetBrains, and any other MCP client. Apps started from the Dock may not see your shell's `PATH`, which is why the commands use the full path. +The Inbox's first-run screen and **Settings** give the exact command or install link for each client, with the absolute path of your `plannotator` binary filled in. The list covers Claude Code, the Claude app, Codex, Cursor, VS Code, Windsurf / Devin, Gemini CLI, Qwen Code, Goose, Amp, Cline, Pi, OpenCode, Zed, Kiro, JetBrains, and any other MCP client. Apps started from the Dock may not see your shell's `PATH`, which is why the commands use the full path. **Codex** needs no plugin: with Codex 0.149 or later, connected through `plannotator inbox mcp`, your reply arrives in the Codex thread that asked as a new turn by itself (in the TUI, the Codex app and the IDE extension; a one-shot `codex exec` run is not woken, its agent waits for the reply instead). The Inbox puts it there with `codex queue`: an idle thread starts the turn within about a second, a busy one gets it when its current turn ends (never in the middle of one), and a thread whose Codex is closed gets it when you resume it. The Inbox finds `codex` on its `PATH`, else the ChatGPT or Codex app's own Codex. While it queues, your reply is an argument of the `codex queue` command, so for that moment it shows in the process list. The line under your reply says "Queued for Codex; it gets this as its next turn." until the thread's next turn uses the Inbox; there is no **Send now** for Codex. If Codex does not know the thread (it was deleted, or Codex uses another `CODEX_HOME` than the Inbox), the reply is saved for the next agent, as for a session that has ended. If handing it to Codex took too long (30 seconds), the Inbox does not try again, so it never arrives twice, and the line says the reply is saved for the agent's next read. If the Inbox finds no `codex` at all, the reply waits, and goes the next time something happens in the Inbox once a Codex is there. diff --git a/docs/agent-guides/environment.md b/docs/agent-guides/environment.md index 689211c4f9..76b2fe9cc6 100644 --- a/docs/agent-guides/environment.md +++ b/docs/agent-guides/environment.md @@ -21,6 +21,7 @@ | `PLANNOTATOR_INBOX_TOOL` | Switch for the Plannotator Inbox's `plannotator_inbox` agent tool and its reply wake on the hosts that register it (the Claude Code mod, see "The Plannotator Inbox" in the mod section; Pi and OpenCode, see "Agent connections" in "Plannotator Inbox"; OpenCode 1 gets the tool without the wake). On Pi and OpenCode only where `inbox/inbox.json` exists in the data dir (the person ran the Inbox once); otherwise nothing is registered and nothing polls there. The Claude Code mod registers the tool even before the Inbox ever ran (from the list bundled with the mod; the first call starts the Inbox), and stays silent (no HTTP, no files) until an Inbox exists. The same switch also gates the session-start reminder (config-only `inboxReminder`, below): never without the tool. Default per host `INBOX_TOOL_DEFAULTS` (= `AGENT_TOOL_DEFAULTS`: on for the Claude Code mod, off on Pi and OpenCode 2). `1` / `true` / `on` and `0` / `false` / `off` / `disabled` decide every host; empty or unrecognized counts as unset. Also `~/.plannotator/config.json` `inboxTool`: a boolean for every host, or one per host (`{ "inboxTool": { "pi": true } }`), which the Inbox's own Settings writes (`POST /api/inbox/settings`). The env var wins (`resolveInboxTool(config, env, host)` in `packages/shared/config.ts`; the mod's mirror `resolveInboxToolEnabled` in `apps/hook/hooks/mod/enabled.ts`, kept equal to the claude-code entry by `enabled.test.ts`). Independent of `PLANNOTATOR_AGENT_TOOL`. Read once per session start and never mid-session (the tool list is part of the prompt prefix), so a change applies to the next session: Pi, the next session or `/reload`; OpenCode, the next OpenCode start; Claude Code, the next Claude Code start (the mod decides at the process's first `session.start`). Documented for users in `apps/marketing/src/content/docs/reference/inbox.md`. | | `PLANNOTATOR_INBOX_TAILSCALE` | Publish the Plannotator Inbox over your tailnet at every start ("Over your tailnet", `packages/server/inbox-tailscale.ts`): `tailscale serve --bg --https= http://127.0.0.1:`, HTTPS, serve and never funnel, for the Tailscale login that owns this machine only. `1` / `true` / `on` and `0` / `false` / `off` / `disabled` decide; empty or unrecognized counts as unset. Also `~/.plannotator/config.json` `inboxTailscale` (boolean), which the Inbox's Settings switch writes (`POST /api/inbox/settings` `{ tailscale: { on } }`; the switch is locked while the env var is set). Precedence: env (a hard switch both ways: `0` keeps it off even against `--tailscale` and the control route) > `plannotator inbox --tailscale` (this run only; the flag only turns it on) > config > off (`resolveInboxTailscale` in `packages/shared/config.ts`). Read at each start and at each switch change. Tailscale missing, stopped or signed out never stops the Inbox: Settings, `inbox.json` `tailscale.error` and stderr say why. Other logins: config-only `inboxTailscaleAllow`. See "Over your tailnet" under "Plannotator Inbox". Default: off. | | `PLANNOTATOR_INBOX_NOTES` | Set to `0` / `false` / `off` / `disabled` to hide the Inbox window's note dialog (the offer to add a note to each agent's instructions), show a note row in Settings → Agents only for a note already written (so it can still be switched off), and refuse new notes (`plannotator inbox --notes` too). Notes already written stay until switched off or uninstalled. Default: on. See "The note in each agent's instructions" under "Plannotator Inbox". | +| `QWEN_HOME` | **Qwen Code's own variable, read, never set.** Qwen Code's folder itself (not a parent of `.qwen`, unlike Gemini's `GEMINI_CLI_HOME`): a leading `~` expanded, empty taken as unset, not trimmed; when it is not in the environment, read from `~/.qwen/.env` then `~/.env` as Qwen does (`qwenHomeSetting`); default `~/.qwen`. A relative one depends on the folder Qwen runs in: the shim resolves it against its own working folder (Qwen's), and the connect step leaves Qwen to the person. The Inbox's connect step reads Qwen's `settings.json` there, the note goes in its `context.fileName` there (default `QWEN.md`), and the stdio shim reads Qwen's session registry at `$QWEN_HOME/sessions/.json` (`qwenHome` in `packages/shared/inbox/qwen-sessions.ts`). `QWEN_CODE_SESSION_ID`, which Qwen sets in the processes it starts, names the session for the shim and for a command Qwen's shell runs. | | `PLANNOTATOR_INBOX_CONNECT` | Set to `0` / `false` / `off` / `disabled` to skip the step in which a person's `plannotator inbox` connects the agents on this computer to the Inbox (see "Connecting the agents on this computer" under "Plannotator Inbox"). Default: on. Read from the shell that runs `plannotator inbox` and from the Inbox's own environment; either one off skips it. Dotfiles and managed setups use it; the test preload, `worldEnv` and every test sandbox that spawns the CLI set it to `0` so no test ever runs a real agent's CLI. No config.json key. | | `PLANNOTATOR_FEEDBACK_URL` | Where product feedback is sent (the Inbox's Feedback dialog through its server, and `plannotator feedback`): an `http` or `https` URL; anything else is ignored. Default: `PRODUCT_FEEDBACK_URL` in `packages/core/product-feedback.ts` (the owner's `apps/feedback-service` Worker). The test preload (`tests/setup/feedback-archive-off.ts`) and the e2e setup point it at a closed loopback port, so no test reaches the live Worker (`tests/feedback-live-url.test.ts`); a test that sends serves a local fake. Nothing is sent without the person's click or command. | | `PLANNOTATOR_HOST_RESULT_FILE` | Set by a host that starts the CLI detached (the Claude Code mod): when a review, annotate, annotate-last or `claude-mod-plan` session settles, the CLI writes one JSON decision record there atomically (mode 0600), with the agent message composed from the configured prompts. Only a path named `result.json` inside `/claude-code-mod/` is accepted (`isAllowedHostResultPath`); anything else is ignored with a stderr warning, so the variable cannot make the CLI create or replace an arbitrary file. Taken at startup and removed from the environment. Stdout is unchanged. See "Claude Code mod". | diff --git a/docs/agent-guides/inbox-foundation.md b/docs/agent-guides/inbox-foundation.md index a252e5e6d3..89d7a78c7a 100644 --- a/docs/agent-guides/inbox-foundation.md +++ b/docs/agent-guides/inbox-foundation.md @@ -14,6 +14,7 @@ One long-lived local server per machine. Agents leave messages, `:::question` bl - It re-reads `inbox/inbox.json` on every message and starts a stopped Inbox through the `--background` path (no tab). - When the agent leaves them out, it fills `project_path` (its working folder, or `PLANNOTATOR_CWD`) and `agent_session`. - The session is the agent host's own when the shim runs inside one (`inboxHostSessionFromEnv`): a well-formed `PLANNOTATOR_SESSION_TAG` (`:`, host `claude-code`, `pi` or `opencode`, id `[A-Za-z0-9][A-Za-z0-9._-]{0,127}`; anything else is ignored), else Pi's own `PI_SESSION_ID` (Pi sets it on each bash tool call), else Codex's `CODEX_THREAD_ID` (Codex sets it in the commands its shell runs; a UUID, anything else ignored). A tool call from Codex itself is its thread's, whatever the environment says: when the MCP client is Codex (`initialize`'s `clientInfo.name` is `codex-mcp-client`, or the call carries Codex's `x-codex-turn-metadata`) and the call names a thread id (`params._meta.threadId`, which Codex puts on every tool call), the shim sends as `agent_host: "codex"`, `agent_session: `, `agent_name: "Codex"` (`codexThreadOfCall`, `packages/core/inbox-codex.ts`), and leaves `_meta` on the call (see "The Codex wake" under "Agent connections and the knob"). The host session is adopted only when the agent named no `agent_host` or that same host; an agent that names another host gets the shim's own `ses_` id, so a reply to it never wakes the session. On `send_message` and `submit_guide` it then also fills `agent_host` and `agent_name` (`Claude Code`, `Pi`, `OpenCode`; the name only when the agent gave none), so the author is byte for byte what that host's `plannotator_inbox` tool writes. A script or command a Claude Code or Pi session runs therefore sends as that session: the same default thread, `asked_by: "me"` and `wait_for_reply` see the tool's threads, and the person's reply wakes the session through its connection (`pendingReplies` matches on `author.session`). An `agent_session` the agent passes always wins. A tag inherited by a long-lived process started from an agent's shell (tmux, nohup) keeps naming that session; pass `agent_session` explicitly to override. + - Qwen Code (`inboxCallHostSession`, read at every tool call; pure parts in `packages/core/inbox-qwen.ts`, the registry read in `packages/shared/inbox/qwen-sessions.ts`). Qwen sets `QWEN_CODE_SESSION_ID` in the processes it starts, but `/clear` and `/resume` give the session a new id under the same process while the MCP server keeps the old one, so the shim asks Qwen's own session registry each time: `$QWEN_HOME/sessions/.json` (else the parent's single `-<8 hex>.json`; two or more: only the call can say), checked as Qwen's protocol says (schema version 1, `pid` equal to the file name's, a UUID session id; a `kind` of the wrong shape reads as `tui`, as Qwen reads it), never written. Order: a daemon-run session's own `_meta["qwen-code/invocation"].sessionId` on the call; then, when the caller is Qwen (`clientInfo.name` `qwen-cli-mcp-client-`, or the older `qwen-code-mcp-client`; or a registry record for the parent), the record's id, else the variable; a variable alone counts only while no other client named itself (an MCP client's process may inherit it from a Qwen shell) and after another host's own variable (`PLANNOTATOR_SESSION_TAG`, `PI_SESSION_ID`, `CODEX_THREAD_ID`). `inboxHostSessionFromEnv` takes the variable last, so a command Qwen's shell runs (`plannotator card`) sends as that session too. The author is `agent_host: "qwen"`, `agent_name: "Qwen Code"`. The reply does not wake a Qwen session yet: it is MCP-only ("call wait_for_reply"). - Otherwise it is one `ses_` id per shim process, so an MCP client's identity ends with its MCP server process; a client that wants its threads across restarts passes `agent_session` itself. Replies to a session that ended are never handed to another session. - OpenCode exports no session variable to the commands it runs (OpenCode 2's `shell.create.before` hook carries no session id; OpenCode 1's `shell.env` does but OpenCode 1 has no wake), so there a command still gets a fresh id. - `plannotator inbox status | stop | restart` (`statusCommand`, `stopCommand`, `restartCommand` in `inbox-command.ts`; ported from rmohta's fork, branch feat/plan-library, and rebuilt on today's stop and start paths; owner approval 2026-10-27). @@ -60,14 +61,14 @@ One long-lived local server per machine. Agents leave messages, `:::question` bl **Connecting the agents on this computer** (`packages/server/inbox-agents.ts`; approved design record "inbox-agent-awareness", not in this repo; decisions 1, 2, 3, 7). - When: a person's `plannotator inbox` (with or without `--no-open`), never `--background`, the shim, a connection that starts a stopped Inbox, or the window's Restart (`spawnDetachedInbox` marks those `PLANNOTATOR_INBOX_DETACHED=1`, scrubbed at start; `PLANNOTATOR_INBOX_LOCK_HELD=1` alone counts as detached too, because an Inbox of 0.28.17 or older spawns its Restart into an updated binary, and every other detached start, with only that marker). Starting the Inbox: `inbox.connectAgents()` once it listens, off the request path, its lines on stderr when done. An Inbox already running (an agent started it): the command opens the window, then `POST /api/inbox/control/connect` (connection-route guards: loopback Host, no Origin, the registry token; an older Inbox answers 404 and nothing happens); the step then runs in that Inbox's process, with the environment (PATH, home) the agent started it with. `PLANNOTATOR_INBOX_CONNECT=0` in either environment skips it. One run at a time (`connectAgents` is single-flight); stdout is unchanged. -- What: every agent found (the install script's rules: its command on PATH, or its folder; Cursor also by `cursor` / `cursor-agent`, Kiro by `kiro-cli`, Codex by `$CODEX_HOME` holding config, a relative one resolved against the working folder as Codex does) with no record in `inbox/agents.json` gets a `plannotator-inbox` MCP server running ` inbox mcp`, through its own `mcp add` where it has one (Codex `codex mcp add … --`, Gemini `gemini mcp add -s user` (`$GEMINI_CLI_HOME` honored), Windsurf/Devin `devin mcp add -s user … --`, Amp, Pi 0.99+ without the extension `pi mcp add`), each only after its `--version` line has that CLI's own shape (`VERSION_SHAPES`: a program that merely shares the name, cln's `pi`, amp.rs, is never handed it; a build older than its `mcp add` is left to the person: Pi before 0.99, which would read the words as a chat prompt, Codex CLI before 0.35.0 and Gemini CLI before 0.1.19), else one key in a plain-JSON file (`mcpServers` in `~/.cursor/mcp.json`, `~/.kiro/settings/mcp.json`, the Claude desktop app's `claude_desktop_config.json`; created when missing). JSON edits are lossless (`packages/server/json-edit.ts`: only that member's text is inserted or cut, in the file's own style and dominant line ending, BOM kept; a file that is not strict JSON or has a duplicate key on the path is refused with the reason). Add then remove gives the file back byte for byte: the record keeps the add's exact text change (`undo: { before, after }` on the agent's record in `agents.json`), and removal puts `before` back while `after` is still in the file untouched, so an empty file stays empty, a final newline or its absence stays, and a container Plannotator made goes; a file Plannotator created (`created`) is deleted once only the empty containers are left; an entry the person edited is cut alone; written at a symlink's target, atomic, a no-op when nothing changes (agent-files.ts). Codex's config.toml is read with `Bun.TOML.parse` (every TOML form). Pi with Plannotator's extension (Pi's `settings.json` `packages`) and OpenCode with its plugin (the global `opencode.json(c)` `plugin`, or `plugins` strings or `{ package }`): `inboxTool. = true` in config.json, unless config.json already decides it (false is recorded `off`) or `PLANNOTATOR_INBOX_TOOL` does (nothing written). Claude Code: nothing (said "already on" when the plugin is enabled). Left to the person (`manual`): Codex or Gemini found without its CLI, Pi before 0.99, a CLI whose version line is not its own, Cline (its `mcp add` is not verified), OpenCode without the plugin. Editors (VS Code, Zed, JetBrains, Goose) are never written. The agent CLIs run with no stdin in their own process group; the time limit kills the group, and the answer comes at exit even if a grandchild keeps the output open. +- What: every agent found (the install script's rules: its command on PATH, or its folder; Cursor also by `cursor` / `cursor-agent`, Kiro by `kiro-cli`, Codex by `$CODEX_HOME` holding config, a relative one resolved against the working folder as Codex does) with no record in `inbox/agents.json` gets a `plannotator-inbox` MCP server running ` inbox mcp`, through its own `mcp add` where it has one (Codex `codex mcp add … --`, Gemini `gemini mcp add -s user` (`$GEMINI_CLI_HOME` honored), Windsurf/Devin `devin mcp add -s user … --`, Amp, Pi 0.99+ without the extension `pi mcp add`), each only after its `--version` line has that CLI's own shape (`VERSION_SHAPES`: a program that merely shares the name, cln's `pi`, amp.rs, is never handed it; a build older than its `mcp add` is left to the person: Pi before 0.99, which would read the words as a chat prompt, Codex CLI before 0.35.0 and Gemini CLI before 0.1.19), else one key in a JSON file (`mcpServers` in `~/.cursor/mcp.json`, `~/.kiro/settings/mcp.json`, the Claude desktop app's `claude_desktop_config.json`, and Qwen Code's `$QWEN_HOME/settings.json`; created when missing). JSON edits are lossless (`packages/server/json-edit.ts`: only that member's text is inserted or cut, in the file's own style and dominant line ending, BOM kept; a file that is not strict JSON or has a duplicate key on the path is refused with the reason; Qwen's settings.json is read as JSON with comments, as Qwen reads it, and every comment stays: a new member goes after a comment that ends the last member's line, a removal cuts the member and one comma only). Add then remove gives the file back byte for byte: the record keeps the add's exact text change (`undo: { before, after }` on the agent's record in `agents.json`), and removal puts `before` back while `after` is still in the file untouched, so an empty file stays empty, a final newline or its absence stays, and a container Plannotator made goes; a file Plannotator created (`created`) is deleted once only the empty containers are left; an entry the person edited is cut alone; written at a symlink's target, atomic, a no-op when nothing changes (agent-files.ts). Codex's config.toml is read with `Bun.TOML.parse` (every TOML form). Pi with Plannotator's extension (Pi's `settings.json` `packages`) and OpenCode with its plugin (the global `opencode.json(c)` `plugin`, or `plugins` strings or `{ package }`): `inboxTool. = true` in config.json, unless config.json already decides it (false is recorded `off`) or `PLANNOTATOR_INBOX_TOOL` does (nothing written). Claude Code: nothing (said "already on" when the plugin is enabled). Left to the person (`manual`): Codex or Gemini found without its CLI, Qwen with a relative `QWEN_HOME`, Pi before 0.99, a CLI whose version line is not its own, Cline (its `mcp add` is not verified), OpenCode without the plugin. Editors (VS Code, Zed, JetBrains, Goose) are never written. Qwen Code is never connected through `qwen mcp add` / `remove`: Qwen writes the whole `mcpServers` back as it resolved it, `$VAR` / `${VAR}` filled in from its environment, so a person's token would land in the file. Plannotator edits the file itself (`qwenWay`, `QWEN_SETTINGS_RULES`): a file that does not parse or is read-only is left as it is (said with the reason, tried again next run). An entry Plannotator added is checked and removed in the file its record names (`recordedFile`), not the one the environment names now. Places are compared as the file system names them (`samePlace`: the real path of the deepest existing folder, the rest as written), so a symlinked `~/.qwen`, `/tmp` and `/private/tmp`, a trailing slash, or another case on a volume that ignores case (the real path has the case on disk) are one place. Case is never folded by Plannotator: on a case-sensitive volume (Linux, or one so formatted on macOS) `qwen` and `Qwen` are two folders. When `QWEN_HOME` names another place than the add (set in one shell, not in another: an accident of how the Inbox was started), a run reads the recorded file only: an entry gone from it is removed outside, as anywhere; one still there is left with both files untouched, and said once per pair of places (`elsewhere` in the report, `elsewhere_said`, kept when a run from the matching shell comes between, cleared when Settings acts; a run whose `QWEN_HOME` matches drops the other shell's line from the band, and so does any Settings switch of that agent). Settings → Agents shows it off, state `elsewhere`: "Connected in . Turn on to move it here." Uninstall cleans the recorded file. Only the person moves it, by turning it on: the entry is added in the new home first (a relative `QWEN_HOME`, another program named `qwen`, a new settings.json that does not parse or is read-only: refused, nothing changed), then taken out of the recorded file; when that cannot be done (read-only, no longer parses), the new entry is taken back out, so only the recorded one is ever left (if even that fails, both stay, said, and the new one is kept on the record as a `stray` that uninstall removes too). Strays stay recorded through later Settings switches: turning it on where a stray is records that entry as added (the old one moved out), turning it off removes the strays too (one that cannot go stays recorded, said), and uninstall forgets an agent's record only when every entry of it went. A stray is checked as the agent's own entry is: one gone is nothing to do, one the person replaced with their own is theirs (named by uninstall, never touched, no longer recorded). A `qwen` on PATH is still asked its `--version` (any Qwen Code version will do; another program named `qwen` is left to the person), run in the home folder (`cwd`), so Qwen never migrates or resets the Inbox's working folder's `.qwen/settings.json`. The agent CLIs run with no stdin in their own process group; the time limit kills the group, and the answer comes at exit even if a grandchild keeps the output open. - Rules: an agent with a record is never added again by itself (turned off, removed outside Plannotator: `removed_outside_at`, checked read-only each run; foreign). An entry is ours when it runs exactly this CLI's command or the one its record says Plannotator wrote; for "already connected", also any `plannotator` binary or checkout running `inbox mcp` (`isPlannotatorInboxCommand`: the program's name is exactly `plannotator`, not `my-plannotator-proxy`). Anything else named `plannotator-inbox` is never replaced and is recorded `foreign`. A refused file or a failing or hung `mcp add` is tried again next run and said again only when its reason changes (`failures` in agents.json). Limits: 10 s per agent, 30 s in all (agents still going are `unfinished`). A test run (PLANNOTATOR_TEST_SANDBOX) whose home is not a scratch folder connects nothing (`agentHomeAllowed`). - Said once: the first run "Connected Codex, Gemini CLI and Cursor to the Inbox (Claude Code was already on)." plus "Agents use it from their next session. PLANNOTATOR_INBOX_CONNECT=0 skips this step; plannotator uninstall removes what it added."; a later run "Connected Amp to the Inbox (new on this computer)."; failures and foreign entries name the file and reason (`formatConnectReport`); an agent only a person can connect (no CLI on PATH, Pi before 0.99, Cline) is named with its reason ("Pi needs connecting by hand: …"), once per reason (`manual_said` in `agents.json`, `manual_why` on the report); time limits are said as they are (`formatSeconds`: "1.5 s"). The run's report is kept in `agents.json` (`last_report`, `seen: false` when it has news) and served at `GET /api/inbox/agents` (local only; records with `~` paths). Uninstall removes only entries Plannotator recorded as `added` that still run the command it wrote; with no record at all (`agents.json` lost) nothing is removed, and an entry that runs exactly the binary being uninstalled is named in a warning with how to remove it, since the person may have added it by hand; one the person added (`found`) is left. -- Tests: `packages/server/inbox-agents.test.ts` (fake agent CLIs on a scratch PATH and HOME, `tests/helpers/fake-agents.ts`; the real CLIs are never run), `packages/server/inbox-connect-route.test.ts`, `apps/hook/server/inbox-connect-cli.test.ts` (the CLI as processes), the uninstall block in `packages/server/uninstall.test.ts`. The guard test also fails on a path from the real home to an agent's folder and on connecting agents with no agent environment (`agentHomeFindings`, `agentConnectFindings`). +- Tests: `packages/server/inbox-agents.test.ts` (fake agent CLIs on a scratch PATH and HOME, `tests/helpers/fake-agents.ts`; the real CLIs are never run), `packages/server/inbox-agents-qwen.test.ts` (Qwen: comments and a person's `${VAR}` kept byte for byte, `qwen --version` run in the home folder, `QWEN_HOME` from the environment or Qwen's `.env` files, a relative one, a foreign `qwen`, turned off, a malformed, trailing-comma or read-only settings.json, removal), `packages/server/inbox-connect-route.test.ts`, `apps/hook/server/inbox-connect-cli.test.ts` (the CLI as processes), the uninstall block in `packages/server/uninstall.test.ts`. The guard test also fails on a path from the real home to an agent's folder and on connecting agents with no agent environment (`agentHomeFindings`, `agentConnectFindings`). **The note in each agent's instructions** (`packages/server/inbox-agent-notes.ts`; decision 5). - Written only with the person's yes: the note dialog's Add, a Settings → Agents switch, or `plannotator inbox --notes ` (the install prompt runs it with only the agents the person agreed to in chat; it starts nothing, refuses an agent not found on this computer, and marks the question answered only when a note is there now). Never added by a start, the shim or a connection (a start only brings an installed, untouched note up to date: below). `PLANNOTATOR_INBOX_NOTES=0` hides the offer, refuses new notes, and lists a note row only for a note already written, so it can be switched off; notes already written stay until switched off or uninstalled. -- Where: Claude Code `~/.claude/rules/plannotator-inbox.md` (a file Plannotator owns; `CLAUDE_CONFIG_DIR`), Codex `AGENTS.override.md` when it exists else `AGENTS.md` (`CODEX_HOME`; created only without an override), OpenCode `$OPENCODE_CONFIG_DIR` / `$XDG_CONFIG_HOME/opencode/AGENTS.md` ONLY when it exists (creating it would stop OpenCode 1 reading `~/.claude/CLAUDE.md`; state `unavailable` otherwise), Pi the first that exists of `AGENTS.override.md`, `AGENTS.md`, `AGENTS.MD`, `CLAUDE.md`, `CLAUDE.MD` in `PI_CODING_AGENT_DIR` (Pi reads only one; `AGENTS.md` created only when none does), Gemini CLI `~/.gemini/` (default `GEMINI.md`). Copilot CLI and Mistral Vibe are not offered (their MCP connection is out of scope; Copilot's file is not verified). Kiro has no file. +- Where: Claude Code `~/.claude/rules/plannotator-inbox.md` (a file Plannotator owns; `CLAUDE_CONFIG_DIR`), Codex `AGENTS.override.md` when it exists else `AGENTS.md` (`CODEX_HOME`; created only without an override), OpenCode `$OPENCODE_CONFIG_DIR` / `$XDG_CONFIG_HOME/opencode/AGENTS.md` ONLY when it exists (creating it would stop OpenCode 1 reading `~/.claude/CLAUDE.md`; state `unavailable` otherwise), Pi the first that exists of `AGENTS.override.md`, `AGENTS.md`, `AGENTS.MD`, `CLAUDE.md`, `CLAUDE.MD` in `PI_CODING_AGENT_DIR` (Pi reads only one; `AGENTS.md` created only when none does), Gemini CLI `~/.gemini/` (default `GEMINI.md`), Qwen Code `$QWEN_HOME/` (default `~/.qwen/QWEN.md`; the same `context.fileName` reading, `contextFileName`). Copilot CLI and Mistral Vibe are not offered (their MCP connection is out of scope; Copilot's file is not verified). Kiro has no file. - Cursor (#1991, #2153, #2162): its global rules live in the app (Customize → Rules), with no file to write, so its note is copied by hand. It is a note agent (`INBOX_NOTE_AGENT_IDS`) whose status is always `state: "manual"`, `path: null` (`noteTarget`, reason `CURSOR_NOTE_REASON`): nothing is written, recorded, refreshed or removed (`refreshInboxNotes` skips it even if a record names it; uninstall has nothing to remove, `candidatePaths` is empty), and it never counts as having the note (`noteHolderIds`). Only when Cursor is found here (`agentSpec("cursor").detect`, as for every agent): the dialog's answer reports it as `{ ok: true, action: "manual", path: null }`, and `plannotator inbox --notes cursor` prints the note body (`INBOX_NOTE_BODY`, never the marker lines, which promise a removal that never happens there) to stdout to copy, with where to paste it on stderr, exit 0; not found, it is refused like any other agent ("Cursor: not added (Cursor was not found on this computer).", exit 1, nothing printed). Settings' note switch refuses it (`422`). Every copy the window shows for it is the body without the markers (`noteWithoutMarkers`), and the words say copy, never add (see the dialog below). - Edits work on the file's own text: every byte outside the block stays, new lines take the file's dominant line ending (more CRLF than lone LF: CRLF), a UTF-8 BOM is kept, and a re-add that finds the block unchanged never rewrites the file. Records hold the RESOLVED path (a symlinked file, or a symlinked folder such as `~/.claude` -> dotfiles), so switching off and uninstall find the very file a note created. A file two agents share (Pi's `AGENTS.md` a link to Codex's) holds one section, recorded for both; turning one off keeps it while the other is on (`shared_with` on the status, said on the card and in Settings). - The block: `INBOX_NOTE_START` (``), the DESIGN.md §2.3 note, ``, appended at the END after one blank line. The file's last byte decides how the block ends, so removal gives the file back byte for byte with or without the record: a file that ended with a line break gets the block plus one; a file that ended without (`abc`) gets the two line breaks before the block and none after, so the block is its last bytes; whitespace-only text is kept, with the block after it (an empty file, or one ending in a line break, gets the block plus a line break; one like " " that does not end in a line break gets one line break first, so the start marker begins its own line, and none after the block). Exactly one start before exactly one end is ours; none appends; anything else is `damaged` and nothing is written. `inbox/agent-notes.json` keeps each block's SHA-256: a newer version is written only over an untouched block, a block the person edited (`edited`) is never overwritten, and switching it off removes it as they left it (plus the blank line the writer added). A file Plannotator created is deleted when the note was all it held; a file the person had never is. Line endings and mode kept, symlinks written at their target, atomic, no write when nothing changes (agent-files.ts). diff --git a/packages/core/inbox-qwen.ts b/packages/core/inbox-qwen.ts new file mode 100644 index 0000000000..161959ab6b --- /dev/null +++ b/packages/core/inbox-qwen.ts @@ -0,0 +1,127 @@ +/** + * Qwen Code's pure parts for the Inbox: how a Qwen session is named, what its + * session registry record holds, and how an MCP call says which session it + * comes from. Browser-safe, no I/O (the file reads are in + * packages/shared/inbox/qwen-sessions.ts). + * + * Qwen Code reaches the Inbox through MCP (the stdio shim, which the connect + * step adds to Qwen's settings.json). It tells the processes it starts + * which session they belong to (`QWEN_CODE_SESSION_ID`), and each running + * session publishes a record at `$QWEN_HOME/sessions/.json` whose + * `sessionId` follows `/clear` and `/resume` (Qwen's cross-session protocol, + * schema version 1). Sessions a daemon runs (`qwen serve`) also name their + * session on every tool call (`_meta["qwen-code/invocation"].sessionId`). + */ + +/** The host id a Qwen Code session is recorded under. */ +export const INBOX_QWEN_HOST = "qwen"; + +/** How the person sees a Qwen Code session. */ +export const INBOX_QWEN_NAME = "Qwen Code"; + +/** + * Qwen Code's MCP client names (`initialize`'s `clientInfo.name`): a session + * connects as `qwen-cli-mcp-client-` + * (`qwen-cli-mcp-client-plannotator-inbox` here); its older one-shot + * connection is `qwen-code-mcp-client`. + */ +export const INBOX_QWEN_MCP_CLIENT_PREFIX = "qwen-cli-mcp-client-"; +export const INBOX_QWEN_MCP_CLIENT_LEGACY = "qwen-code-mcp-client"; + +/** Whether an MCP client name is Qwen Code's. */ +export function isQwenMcpClient(name: string | null | undefined): boolean { + return typeof name === "string" && (name === INBOX_QWEN_MCP_CLIENT_LEGACY || (name.startsWith(INBOX_QWEN_MCP_CLIENT_PREFIX) && name.length > INBOX_QWEN_MCP_CLIENT_PREFIX.length)); +} + +/** The environment variable Qwen Code sets to its session id in the processes it starts (MCP servers, hooks, shell commands). */ +export const INBOX_QWEN_SESSION_ENV = "QWEN_CODE_SESSION_ID"; + +/** The `_meta` key a daemon-run Qwen session puts its invocation (with `sessionId`) under on every MCP tool call. */ +export const INBOX_QWEN_INVOCATION_META_KEY = "qwen-code/invocation"; + +/** The registry schema this reads; a record with a higher version is skipped (Qwen's protocol §1). */ +export const INBOX_QWEN_REGISTRY_SCHEMA = 1; + +/** + * A Qwen session id: a UUID (Qwen mints them with randomUUID), optionally with + * the suffix Qwen's Arena sessions carry (`-agent-…`). Nothing else is ever + * taken as one: it names a socket frame's recipient and a claim folder. + */ +const QWEN_SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?:-[A-Za-z0-9_]{1,32}(?:-[A-Za-z0-9_]{1,32}){0,3})?$/i; + +export function isQwenSessionId(value: unknown): value is string { + return typeof value === "string" && value.length <= 160 && QWEN_SESSION_ID.test(value); +} + +/** The session a daemon-run Qwen tool call names (`_meta["qwen-code/invocation"]`, version 1), or null. */ +export function qwenSessionOfCall(meta: unknown): string | null { + if (!meta || typeof meta !== "object" || Array.isArray(meta)) return null; + const invocation = (meta as Record)[INBOX_QWEN_INVOCATION_META_KEY]; + if (!invocation || typeof invocation !== "object" || Array.isArray(invocation)) return null; + const record = invocation as Record; + if (record.version !== 1) return null; + return isQwenSessionId(record.sessionId) ? record.sessionId : null; +} + +/** What one registry record says, once checked. */ +export interface InboxQwenSessionRecord { + pid: number; + sessionId: string; + /** `tui`, `headless`, `serve`, `external`, or another label Qwen adds later; `tui` when the record predates the field. */ + kind: string; + /** The session's inbox socket, present only while it is bound. */ + ipcPath: string | null; + /** What a connection to `ipcPath` presents on its auth line (64 hex); null when the inbox needs none. */ + ipcToken: string | null; + /** `:` on Linux, null elsewhere. */ + procStart: string | null; + /** Inode of the writer's pid namespace on Linux, null elsewhere. */ + pidNs: number | null; + /** Epoch ms. */ + startedAt: number; + /** The record's file had the `-<8 hex>` form: a process hosting several sessions (ACP, the daemon). */ + shared: boolean; +} + +/** + * A registry file name's pid: `.json` or `-<8 lowercase hex>.json`, + * the pid in canonical decimal (a zero-padded name agrees with nothing). + */ +export function qwenRecordFilePid(name: string): { pid: number; shared: boolean } | null { + const match = /^([1-9]\d{0,9})(-[0-9a-f]{8})?\.json$/.exec(name); + if (!match) return null; + const pid = Number(match[1]); + return Number.isSafeInteger(pid) ? { pid, shared: match[2] !== undefined } : null; +} + +/** + * One registry record, checked as Qwen's readers check it: schema version 1, + * `pid` equal to the file name's, a session id of the right shape. Fields of + * the wrong shape are dropped (a bad `ipcToken` makes the record unreachable, + * never an auth line with junk in it; a bad `kind` reads as `tui`, as Qwen + * reads it). Null when the record is not usable. + */ +export function parseQwenSessionRecord(value: unknown, file: { pid: number; shared: boolean }): InboxQwenSessionRecord | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const record = value as Record; + if (record.schemaVersion !== INBOX_QWEN_REGISTRY_SCHEMA) return null; + if (record.pid !== file.pid) return null; + if (!isQwenSessionId(record.sessionId)) return null; + // As Qwen's own reader: a `kind` of the wrong shape is dropped, and a record without one is a terminal session. + const kind = typeof record.kind === "string" && /^[a-z][a-z0-9-]{0,15}$/.test(record.kind) ? record.kind : "tui"; + const ipcPath = typeof record.ipcPath === "string" && record.ipcPath.startsWith("/") && record.ipcPath.length < 1024 && !record.ipcPath.includes("\u0000") ? record.ipcPath : null; + const ipcToken = typeof record.ipcToken === "string" && /^[0-9a-f]{64}$/i.test(record.ipcToken) ? record.ipcToken : null; + // A token of the wrong shape: the inbox wants one, and this one would never pass, so the record cannot be messaged. + const tokenBroken = record.ipcToken !== undefined && record.ipcToken !== null && ipcToken === null; + return { + pid: file.pid, + sessionId: record.sessionId, + kind, + ipcPath: tokenBroken ? null : ipcPath, + ipcToken, + procStart: typeof record.procStart === "string" && record.procStart.length < 256 ? record.procStart : null, + pidNs: typeof record.pidNs === "number" && Number.isSafeInteger(record.pidNs) ? record.pidNs : null, + startedAt: typeof record.startedAt === "number" && Number.isFinite(record.startedAt) ? record.startedAt : 0, + shared: file.shared, + }; +} diff --git a/packages/core/inbox-thread-subject.test.ts b/packages/core/inbox-thread-subject.test.ts index 0ab0ed12dc..973eb14db1 100644 --- a/packages/core/inbox-thread-subject.test.ts +++ b/packages/core/inbox-thread-subject.test.ts @@ -49,6 +49,8 @@ describe("derivePersonThreadSubject: a thread the person starts gets a short nam expect(derivePersonThreadSubject("So, I want a board view for the inbox.")).toBe("I want a board view for the inbox"); expect(derivePersonThreadSubject("okay so um the toolbar covers the first line of the file.")).toBe("The toolbar covers the first line of the file"); expect(derivePersonThreadSubject("hey can you rerun the e2e suite")).toBe("Can you rerun the e2e suite"); + // An agent's name needs no comma after it (Qwen Code is one the Inbox connects). + expect(derivePersonThreadSubject("Hey Qwen can you rerun the e2e suite")).toBe("Can you rerun the e2e suite"); // "So" is kept where it is part of what the person says. expect(derivePersonThreadSubject("So many tests fail on CI today.")).toBe("So many tests fail on CI today"); // A greeting alone is still a name. diff --git a/packages/core/inbox-thread-subject.ts b/packages/core/inbox-thread-subject.ts index da0492cf1c..7f3a65799e 100644 --- a/packages/core/inbox-thread-subject.ts +++ b/packages/core/inbox-thread-subject.ts @@ -175,7 +175,7 @@ function plainSubjectLine(input: string, images: { alt: string; src: string }[]) const FILLER_WORD = "(?:so|ok|okay|alright|all right|well|right|anyway|anyways|yeah|basically)"; /** An agent's name: "Hey Claude can you…" needs no comma after it. */ -const AGENT_NAME = "(?:claude|codex|pi|opencode|amp|gemini|cursor|droid)"; +const AGENT_NAME = "(?:claude|codex|pi|opencode|amp|gemini|qwen|cursor|droid)"; /** A word that is an addressee only before punctuation: "Hey there, …", never "Hey there is a bug". */ const ADDRESSEE_WORD = "(?:there|team|buddy|friend|folks|all|everyone)"; /** diff --git a/packages/core/inbox-types.ts b/packages/core/inbox-types.ts index ebdf6f754a..14333ec817 100644 --- a/packages/core/inbox-types.ts +++ b/packages/core/inbox-types.ts @@ -26,6 +26,7 @@ export * from "./inbox-attachments"; export * from "./inbox-open-in"; export * from "./inbox-delivery"; export * from "./inbox-codex"; +export * from "./inbox-qwen"; // The Board's cards (approval "inbox-board"), the same way. export * from "./inbox-cards"; // The surface bridge's messages (the phone, PLAN step S1), the same way. @@ -264,6 +265,7 @@ export const INBOX_HOST_NAMES: Readonly> = { codex: "Codex", cursor: "Cursor", grok: "Grok Build", + qwen: "Qwen Code", }; /** @@ -1542,6 +1544,7 @@ export type InboxAgentId = | "claude-code" | "codex" | "gemini" + | "qwen" | "cursor" | "windsurf" | "amp" @@ -1555,6 +1558,7 @@ export const INBOX_AGENT_IDS: readonly InboxAgentId[] = [ "claude-code", "codex", "gemini", + "qwen", "cursor", "windsurf", "amp", @@ -1569,6 +1573,7 @@ export const INBOX_AGENT_LABELS: Readonly> = { "claude-code": "Claude Code", codex: "Codex", gemini: "Gemini CLI", + qwen: "Qwen Code", cursor: "Cursor", windsurf: "Windsurf / Devin", amp: "Amp", @@ -1586,6 +1591,13 @@ export function inboxAgentList(ids: readonly InboxAgentId[]): string { return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`; } +/** An agent connected in another file than this Inbox's environment names (`InboxConnectReport.elsewhere`): where, and how the person moves it. */ +export function inboxElsewhereText(moved: { agent: InboxAgentId; path: string | null; now: string | null }): string { + const label = INBOX_AGENT_LABELS[moved.agent]; + const why = `${moved.agent === "qwen" ? "this Inbox has another QWEN_HOME, so it" : "this Inbox"} looks in ${moved.now ?? "another file"}`; + return `${label} is connected in ${moved.path ?? "another file"}, but ${why}. Plannotator left both as they are; Settings → Agents shows it off here, and turning it on moves it.`; +} + /** What one `plannotator inbox` run did to the agents on this computer. */ export interface InboxConnectReport { at: string; @@ -1605,6 +1617,12 @@ export interface InboxConnectReport { manual: InboxAgentId[]; /** Why each manual agent needs a person, said once per reason (the terminal prints these). */ manual_why?: { agent: InboxAgentId; why: string }[]; + /** + * Connected in another file than this run's environment names (`QWEN_HOME` + * differs from when the entry was added): left where it is, said once; + * turning the agent on in Settings → Agents moves it. + */ + elsewhere?: { agent: InboxAgentId; path: string | null; now: string | null }[]; /** Did not finish within the time limits; tried again at the next run. */ unfinished: InboxAgentId[]; /** Claude Code's plugin gives it the `plannotator_inbox` tool already. */ @@ -1612,8 +1630,8 @@ export interface InboxConnectReport { } /** The agents whose own instructions file can carry the Inbox note (packages/server/inbox-agent-notes.ts). */ -export type InboxNoteAgentId = "claude-code" | "codex" | "opencode" | "pi" | "gemini" | "cursor"; -export const INBOX_NOTE_AGENT_IDS: readonly InboxNoteAgentId[] = ["claude-code", "codex", "gemini", "pi", "opencode", "cursor"]; +export type InboxNoteAgentId = "claude-code" | "codex" | "opencode" | "pi" | "gemini" | "qwen" | "cursor"; +export const INBOX_NOTE_AGENT_IDS: readonly InboxNoteAgentId[] = ["claude-code", "codex", "gemini", "qwen", "pi", "opencode", "cursor"]; /** * Where an agent's note would go and how it stands: @@ -1650,7 +1668,7 @@ export interface InboxAgentRow { reach: { kind: "tool" | "mcp" | "manual" | "none"; on: boolean; - state: "connected" | "off" | "removed" | "foreign" | "not_connected" | "manual" | "unreadable"; + state: "connected" | "off" | "removed" | "foreign" | "not_connected" | "manual" | "unreadable" | "elsewhere"; path: string | null; /** PLANNOTATOR_INBOX_TOOL decides it. */ locked: boolean; diff --git a/packages/inbox/agents.test.ts b/packages/inbox/agents.test.ts index 68cbbe6781..3ac49a38fd 100644 --- a/packages/inbox/agents.test.ts +++ b/packages/inbox/agents.test.ts @@ -7,7 +7,7 @@ */ import { expect, test } from 'bun:test'; import type { InboxAgentRow, InboxAgentsModel } from '@plannotator/core/inbox-types'; -import { cardReport, notePrimaryAction, noteQuestionRows, noteWithoutMarkers, primaryAction, sentence, splitPath, tidyReason } from './agents'; +import { bandLines, cardReport, notePrimaryAction, noteQuestionRows, noteWithoutMarkers, primaryAction, sentence, splitPath, tidyReason } from './agents'; function row(id: InboxAgentRow['id'], over: Partial = {}): InboxAgentRow { return { @@ -64,6 +64,17 @@ test('the band reads only unseen news, and never a skipped run', () => { expect(cardReport(model({ last_report: { ...report, added: [] } }))).toBeNull(); }); +test('an agent connected in another QWEN_HOME is news for the band: where it is, and that Settings moves it', () => { + const report = { at: '', first: false, added: [], already: [], failed: [], foreign: [], manual: [], unfinished: [], claude_code: false }; + const elsewhere = [{ agent: 'qwen' as const, path: '~/.qwen/settings.json', now: '/srv/q/settings.json' }]; + expect(cardReport(model({ last_report: report }))).toBeNull(); + expect(cardReport(model({ last_report: { ...report, elsewhere } }))).not.toBeNull(); + const [line] = bandLines({ ...report, elsewhere }).elsewhere; + expect(line!.text).toContain('~/.qwen/settings.json'); + expect(line!.text).toContain('/srv/q/settings.json'); + expect(line!.text).toContain('Settings → Agents'); +}); + test('the primary: up to three names, then a count; nothing ticked closes without adding', () => { expect(primaryAction(['Codex'])).toEqual({ label: 'Add to Codex', add: true }); expect(primaryAction(['Codex', 'Gemini CLI', 'Claude Code'])).toEqual({ label: 'Add to Codex, Gemini CLI and Claude Code', add: true }); diff --git a/packages/inbox/agents.ts b/packages/inbox/agents.ts index 3a68f6a5d7..d2a86a7b2d 100644 --- a/packages/inbox/agents.ts +++ b/packages/inbox/agents.ts @@ -6,6 +6,7 @@ import { INBOX_AGENT_LABELS, INBOX_NOTE_AGENT_IDS, + inboxElsewhereText, type InboxAgentId, type InboxAgentRow, type InboxAgentsModel, @@ -48,7 +49,7 @@ export function noteQuestionOpen(model: InboxAgentsModel): boolean { export function cardReport(model: InboxAgentsModel): InboxConnectReport | null { const report = model.last_report; if (!report || report.seen || report.skipped) return null; - const news = report.added.length + report.failed.length + report.foreign.length + (report.manual_why?.length ?? 0); + const news = report.added.length + report.failed.length + report.foreign.length + (report.manual_why?.length ?? 0) + (report.elsewhere?.length ?? 0); return news > 0 ? report : null; } @@ -58,6 +59,8 @@ export interface BandLines { byHand: string | null; failed: { agent: InboxAgentId; text: string }[]; foreign: { agent: InboxAgentId; text: string }[]; + /** Connected in another file than this Inbox's environment names (`QWEN_HOME` differs): left there; Settings moves it. */ + elsewhere: { agent: InboxAgentId; text: string }[]; } export function bandLines(report: InboxConnectReport): BandLines { @@ -68,6 +71,7 @@ export function bandLines(report: InboxConnectReport): BandLines { byHand: manual.length > 0 ? `${joinNames(manual.map(label))} ${manual.length === 1 ? 'needs' : 'need'} connecting by hand:` : null, failed: report.failed.map((f) => ({ agent: f.agent, text: `Couldn't connect ${label(f.agent)}: ${sentence(tidyReason(f.reason))}` })), foreign: report.foreign.map((f) => ({ agent: f.agent, text: `${label(f.agent)} has its own plannotator-inbox entry${f.path ? ` in ${f.path}` : ''}; Plannotator left it alone.` })), + elsewhere: (report.elsewhere ?? []).map((moved) => ({ agent: moved.agent, text: inboxElsewhereText(moved) })), }; } diff --git a/packages/inbox/agentsSettings.test.tsx b/packages/inbox/agentsSettings.test.tsx index c9a0396e52..c64cb444a2 100644 --- a/packages/inbox/agentsSettings.test.tsx +++ b/packages/inbox/agentsSettings.test.tsx @@ -162,6 +162,28 @@ test.skipIf(!hasDom)('a switch saves that one change and says it applies to the expect(group(el, 'cursor').querySelector('.ib-cbox p')?.textContent).toBe("Copy this text into Customize → Rules in Cursor. Plannotator can't see, update or remove that copy."); }); +test.skipIf(!hasDom)('connected under another QWEN_HOME: shown off, where it is, and turning it on asks to move it here', async () => { + const qwen = mcp('qwen', 'Qwen Code', '~/.qwen/settings.json', { + reach: { kind: 'mcp', on: false, state: 'elsewhere', path: '~/.qwen/settings.json', locked: false, reason: null }, + }); + const el = mount(model({ agents: [qwen] })); + const reach = rows(group(el, 'qwen'))[0]!; + expect(reach.sw).toBe('false'); + expect(reach.v).toBe('Connected in ~/.qwen/settings.json. Turn on to move it here.'); + expect(group(el, 'qwen').textContent).toContain('Connected in another folder'); + const sent: unknown[] = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + sent.push(JSON.parse(String(init?.body))); + return new Response(JSON.stringify({ ...model({ agents: [qwen] }), error: null }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + }) as typeof fetch; + const button = group(el, 'qwen').querySelector('[role="switch"]') as HTMLButtonElement; + await act(async () => { + button.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + expect(sent).toEqual([{ agent: 'qwen', reach: true }]); +}); + test.skipIf(!hasDom)('PLANNOTATOR_INBOX_TOOL locks the tool and reminder switches and says why', () => { const locked = model({ tool_env: false }); locked.agents[0] = tool('claude-code', 'Claude Code', { diff --git a/packages/inbox/components/AgentNoteDialog.tsx b/packages/inbox/components/AgentNoteDialog.tsx index 14fafd4a06..40d4d6226d 100644 --- a/packages/inbox/components/AgentNoteDialog.tsx +++ b/packages/inbox/components/AgentNoteDialog.tsx @@ -171,7 +171,7 @@ export function AgentNoteDialog({ rows, report, noteText, onModel, onSettings, o className="ib-anote" > {/* The band is this run's news before the answer; the results view drops it, as in the approved mock. */} - {!done && band && (band.connected || band.byHand || band.failed.length > 0 || band.foreign.length > 0) && ( + {!done && band && (band.connected || band.byHand || band.failed.length > 0 || band.foreign.length > 0 || band.elsewhere.length > 0) && (
{band.connected && (

@@ -201,6 +201,17 @@ export function AgentNoteDialog({ rows, report, noteText, onModel, onSettings, o

)} + {band.elsewhere.map((line) => ( +

+ + + {line.text}{' '} + + +

+ ))} {band.foreign.map((line) => (

diff --git a/packages/inbox/components/AgentsSettings.tsx b/packages/inbox/components/AgentsSettings.tsx index 5da5e742c0..cf2e652acf 100644 --- a/packages/inbox/components/AgentsSettings.tsx +++ b/packages/inbox/components/AgentsSettings.tsx @@ -251,6 +251,8 @@ function stateOf(row: InboxAgentRow): { text: string; tone: 'on' | 'off' | 'bad' return { text: 'Connected', tone: 'on' }; case 'removed': return { text: 'Removed outside Plannotator', tone: 'bad' }; + case 'elsewhere': + return { text: 'Connected in another folder', tone: 'off' }; case 'foreign': return { text: 'Has its own entry', tone: 'bad' }; case 'unreadable': @@ -339,7 +341,11 @@ function AgentGroup({ the plannotator_inbox tool ) : reach.kind === 'mcp' ? ( - reach.state === 'removed' ? ( + reach.state === 'elsewhere' ? ( + + Connected in {reach.path}. Turn on to move it here. + + ) : reach.state === 'removed' ? ( <> plannotator-inbox is no longer in {reach.path}. Plannotator won't add it back on its own; turn this on to add it. diff --git a/packages/inbox/harnesses.test.ts b/packages/inbox/harnesses.test.ts index f718495356..7396de5be7 100644 --- a/packages/inbox/harnesses.test.ts +++ b/packages/inbox/harnesses.test.ts @@ -38,6 +38,17 @@ describe('connect snippets', () => { expect(goose.searchParams.getAll('arg')).toEqual(['inbox', 'mcp']); }); + // After NonoElRobot's fork (c6a02a0): the settings.json entry, never `qwen mcp add` (it writes the other servers' ${VAR} values out). + test('the Qwen Code panel gives the settings.json entry with the stdio command, and no qwen mcp add', () => { + const panel = harnessPanel('qwen', ctx('/Users/a b/.local/bin/plannotator')); + const entry = panel.artefacts[0]; + expect(entry?.kind).toBe('code'); + if (entry?.kind === 'code') { + expect(JSON.parse(entry.text)).toEqual({ mcpServers: { 'plannotator-inbox': { command: '/Users/a b/.local/bin/plannotator', args: ['inbox', 'mcp'] } } }); + } + expect(JSON.stringify(panel)).not.toContain('qwen mcp add'); + }); + test('every harness has a panel, and every JSON snippet parses', () => { for (const harness of HARNESSES) { const panel = harnessPanel(harness.id, ctx('/opt/plannotator')); diff --git a/packages/inbox/harnesses.ts b/packages/inbox/harnesses.ts index 44015f0758..b891fa756a 100644 --- a/packages/inbox/harnesses.ts +++ b/packages/inbox/harnesses.ts @@ -15,6 +15,7 @@ export type HarnessId = | 'vscode' | 'windsurf' | 'gemini' + | 'qwen' | 'goose' | 'amp' | 'cline' @@ -39,6 +40,7 @@ export const HARNESSES: readonly Harness[] = [ { id: 'vscode', label: 'VS Code' }, { id: 'windsurf', label: 'Windsurf / Devin' }, { id: 'gemini', label: 'Gemini CLI' }, + { id: 'qwen', label: 'Qwen Code' }, { id: 'goose', label: 'Goose' }, { id: 'amp', label: 'Amp' }, { id: 'cline', label: 'Cline' }, @@ -194,6 +196,13 @@ export function harnessPanel(id: HarnessId, ctx: ConnectContext): HarnessPanel { artefacts: [{ kind: 'code', text: `gemini mcp add -s user ${SERVER} ${cmd}` }], note: 'Adds it to Gemini CLI in every project.', }; + case 'qwen': + // After NonoElRobot's fork (c6a02a0): the settings.json entry. Not `qwen mcp add`: it writes the + // other servers back with their ${VAR} values filled in, so a token kept in the environment lands in the file. + return { + artefacts: [{ kind: 'code', label: '~/.qwen/settings.json', text: mcpServersSnippet(ctx) }], + note: "Merge it into mcpServers in Qwen Code's settings.json ($QWEN_HOME/settings.json when you set QWEN_HOME). It applies in every project.", + }; case 'goose': return { artefacts: [{ kind: 'link', label: 'Add to Goose', href: gooseInstallLink(ctx) }], diff --git a/packages/inbox/icons.tsx b/packages/inbox/icons.tsx index 6aac07289b..557b7ec75a 100644 --- a/packages/inbox/icons.tsx +++ b/packages/inbox/icons.tsx @@ -171,6 +171,8 @@ const TAB_GLYPHS: Partial> = { cursor: 'M12 2.5 20.5 7.25v9.5L12 21.5 3.5 16.75v-9.5Z M12 12l8.5-4.75 M12 12v9.5 M12 12 3.5 7.25', vscode: 'M17 3.5 7.5 12l9.5 8.5 3.5-1.7V5.2Z M17 3.5v17 M7.5 12 3.5 9 M7.5 12 3.5 15', gemini: 'M12 2.5c.9 5 3.6 8.6 9.5 9.5-5.9.9-8.6 4.5-9.5 9.5-.9-5-3.6-8.6-9.5-9.5 5.9-.9 8.6-4.5 9.5-9.5Z', + // Qwen Code: its hexagon around a turned triangle, three bands as one pinwheel. + qwen: 'M12 2.5 20.23 7.25v9.5L12 21.5 3.77 16.75v-9.5Z M8.97 10.25h6.06L12 15.5Z M8.97 10.25 12 2.5 M15.03 10.25l5.2 6.5 M12 15.5l-8.23 1.25', zed: 'M4 4h16v16H4Z M8 8.5h8l-8 7h8', }; @@ -195,6 +197,7 @@ export function HostMark({ host, big }: { host: string | null | undefined; big?: mark = ; break; case 'gemini': + case 'qwen': case 'zed': case 'vscode': mark = ( diff --git a/packages/server/agent-files.ts b/packages/server/agent-files.ts index b86bec3fea..acfafaaeb5 100644 --- a/packages/server/agent-files.ts +++ b/packages/server/agent-files.ts @@ -19,7 +19,7 @@ import { randomBytes } from "node:crypto"; import { chmodSync, lstatSync, mkdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; -import { dirname, isAbsolute, join, resolve } from "node:path"; +import { basename, dirname, isAbsolute, join, resolve } from "node:path"; /** Files larger than this are never edited. */ export const AGENT_FILE_MAX_BYTES = 1024 * 1024; @@ -124,6 +124,36 @@ export function writeAgentFileAtomic(target: string, text: string, mode: number, } } +/** + * A path as the file system names it, so one place spelled two ways is one + * place: the real path of its deepest existing folder (a symlinked folder, + * `/tmp` and `/private/tmp`, another case on a volume that ignores case: the + * real path has the case on disk), the rest as written, normalized. Never + * case-folded: on a case-sensitive volume (Linux, or one formatted so on + * macOS) `qwen` and `Qwen` are two folders. + */ +export function placeKey(path: string): string { + let head = resolve(path); + const rest: string[] = []; + for (;;) { + try { + head = realpathSync.native(head); + break; + } catch { + const parent = dirname(head); + if (parent === head) break; + rest.unshift(basename(head)); + head = parent; + } + } + return join(head, ...rest); +} + +/** Whether two paths name one place (`placeKey`). */ +export function samePlace(a: string, b: string): boolean { + return a === b || placeKey(a) === placeKey(b); +} + /** A short, plain reason for a file-system error. */ export function describeFsError(error: unknown): string { const code = (error as NodeJS.ErrnoException | undefined)?.code; diff --git a/packages/server/inbox-agent-notes.test.ts b/packages/server/inbox-agent-notes.test.ts index 8944221d1c..f9b18b061d 100644 --- a/packages/server/inbox-agent-notes.test.ts +++ b/packages/server/inbox-agent-notes.test.ts @@ -86,6 +86,25 @@ describe("where each agent's note goes", () => { write(file(".gemini", "settings.json"), '{ // mine\n "context": { "fileName": ["AGENTS.md", "GEMINI.md"] } }'); expect(noteTarget("gemini", e).path).toBe(file(".gemini", "AGENTS.md")); }); + + test("Qwen Code: its context.fileName, QWEN.md by default, in $QWEN_HOME (the folder itself)", () => { + expect(noteTarget("qwen", e)).toEqual({ path: file(".qwen", "QWEN.md"), create: true, reason: null }); + write(file(".qwen", "settings.json"), '{ // mine\n "context": { "fileName": "CONTEXT.md" } }'); + expect(noteTarget("qwen", e).path).toBe(file(".qwen", "CONTEXT.md")); + // A name with a folder in it is not a file name: the default. + write(file(".qwen", "settings.json"), '{ "context": { "fileName": ["../escape.md"] } }'); + expect(noteTarget("qwen", e).path).toBe(file(".qwen", "QWEN.md")); + // QWEN_HOME replaces ~/.qwen (Gemini's GEMINI_CLI_HOME is a parent folder; Qwen's is not). + const home = join(fake.root, "qwen-home"); + const moved = createAgentEnvironmentFor({ platform: "linux", homeDir: fake.home, env: { ...fake.env, QWEN_HOME: home } }); + write(join(home, "settings.json"), '{ "context": { "fileName": ["AGENTS.md", "QWEN.md"] } }'); + expect(noteTarget("qwen", moved).path).toBe(join(home, "AGENTS.md")); + expect(addInboxNote("qwen", dataDir, moved)).toMatchObject({ ok: true, action: "created" }); + expect(read(join(home, "AGENTS.md"))).toBe(`${INBOX_NOTE_BLOCK}\n`); + expect(existsSync(file(".qwen", "AGENTS.md"))).toBe(false); + expect(removeInboxNote("qwen", dataDir, moved)).toMatchObject({ ok: true, action: "deleted" }); + expect(existsSync(join(home, "AGENTS.md"))).toBe(false); + }); }); describe("the marked section", () => { @@ -154,6 +173,7 @@ describe("the marked section", () => { const targets: [InboxNoteAgentId, string][] = [ ["codex", file(".codex", "AGENTS.md")], ["gemini", file(".gemini", "GEMINI.md")], + ["qwen", file(".qwen", "QWEN.md")], ["pi", file(".pi", "agent", "AGENTS.md")], ["opencode", file(".config", "opencode", "AGENTS.md")], ]; @@ -353,15 +373,19 @@ test("uninstall: every recorded note, plus a file with our markers whose record write(file(".codex", "AGENTS.md"), "Mine.\n"); addInboxNote("codex", dataDir, e); addInboxNote("gemini", dataDir, e); + // Qwen's note whose record is gone: found by its markers in the file Qwen reads. + write(file(".qwen", "QWEN.md"), `Qwen rules\n\n${INBOX_NOTE_BLOCK}\n`); write(file(".pi", "agent", "CLAUDE.md"), `Pi rules\n\n${INBOX_NOTE_BLOCK}\n`); const { items } = planInboxNoteRemoval(dataDir, e, INBOX_AGENT_LABELS); expect(items.map((item) => item.describe)).toEqual([ "Note for Codex (~/.codex/AGENTS.md, marked section only)", "Note for Gemini CLI (~/.gemini/GEMINI.md, created by Plannotator: file removed if empty)", + "Note for Qwen Code (~/.qwen/QWEN.md, marked section only)", "Note for Pi (~/.pi/agent/CLAUDE.md, marked section only)", ]); for (const item of items) expect(item.apply().ok).toBe(true); expect(read(file(".codex", "AGENTS.md"))).toBe("Mine.\n"); + expect(read(file(".qwen", "QWEN.md"))).toBe("Qwen rules\n"); expect(existsSync(file(".gemini", "GEMINI.md"))).toBe(false); expect(read(file(".pi", "agent", "CLAUDE.md"))).toBe("Pi rules\n"); }); diff --git a/packages/server/inbox-agent-notes.ts b/packages/server/inbox-agent-notes.ts index 9895032ba6..8c98b8e341 100644 --- a/packages/server/inbox-agent-notes.ts +++ b/packages/server/inbox-agent-notes.ts @@ -10,7 +10,8 @@ * Plannotator wrote (refreshInboxNotes). * * Where (honouring CLAUDE_CONFIG_DIR, CODEX_HOME, OPENCODE_CONFIG_DIR / - * XDG_CONFIG_HOME, PI_CODING_AGENT_DIR and Gemini's `context.fileName`): + * XDG_CONFIG_HOME, PI_CODING_AGENT_DIR, QWEN_HOME and Gemini's and Qwen's + * `context.fileName`): * - Claude Code: `~/.claude/rules/plannotator-inbox.md`, a file Plannotator owns. * - Codex: `AGENTS.override.md` when it exists, else `AGENTS.md` (created only without an override). * - OpenCode: `~/.config/opencode/AGENTS.md`, ONLY when it exists: creating it @@ -19,6 +20,7 @@ * `AGENTS.MD`, `CLAUDE.md`, `CLAUDE.MD` (Pi reads only one); `AGENTS.md` * created only when none does. * - Gemini CLI: `~/.gemini/`, default `GEMINI.md`. + * - Qwen Code: `$QWEN_HOME/` (default folder `~/.qwen`), default `QWEN.md`. * - Cursor: copied by hand into Customize → Rules in the app; Cursor documents no global rules file, so nothing is written. * * The block sits between markers at the END of the file. Exactly one start @@ -49,6 +51,7 @@ import { codexHome, displayPath, geminiDir, + qwenDir, openCodeConfigDir, inboxAgentReach, piAgentDir, @@ -217,9 +220,13 @@ interface NoteTarget { reason: string | null; } -/** Gemini's instruction file name (`context.fileName`, a string or a list; the first), default GEMINI.md. */ -function geminiContextFile(e: AgentEnvironment): string { - const read = readAgentFile(join(geminiDir(e), "settings.json")); +/** + * The instruction file name an agent's settings.json names (`context.fileName`, + * a string or a list; the first), else `fallback`. Gemini CLI and Qwen Code + * (a Gemini CLI fork) read it the same way. + */ +function contextFileName(settingsPath: string, fallback: string): string { + const read = readAgentFile(settingsPath); if (read.kind === "file") { try { const context = (JSON.parse(stripJsonComments(read.text)) as { context?: { fileName?: unknown } }).context; @@ -229,7 +236,7 @@ function geminiContextFile(e: AgentEnvironment): string { // The default. } } - return "GEMINI.md"; + return fallback; } /** Claude Code's rules file: Plannotator's own (it holds only the note). */ @@ -263,7 +270,11 @@ export function noteTarget(agent: InboxNoteAgentId, e: AgentEnvironment): NoteTa return { path: join(dir, "AGENTS.md"), create: true, reason: null }; } case "gemini": { - const file = join(geminiDir(e), geminiContextFile(e)); + const file = join(geminiDir(e), contextFileName(join(geminiDir(e), "settings.json"), "GEMINI.md")); + return { path: file, create: !exists(file), reason: null }; + } + case "qwen": { + const file = join(qwenDir(e), contextFileName(join(qwenDir(e), "settings.json"), "QWEN.md")); return { path: file, create: !exists(file), reason: null }; } } diff --git a/packages/server/inbox-agents-qwen.test.ts b/packages/server/inbox-agents-qwen.test.ts new file mode 100644 index 0000000000..81b41fc112 --- /dev/null +++ b/packages/server/inbox-agents-qwen.test.ts @@ -0,0 +1,601 @@ +/** + * Qwen Code's connection to the Inbox (inbox-agents.ts), with a fake `qwen` + * on a scratch PATH and a scratch home (tests/helpers/fake-agents.ts; the real + * qwen is never run and the real ~/.qwen never read). Plannotator edits Qwen's + * settings.json itself (JSON with comments), never through `qwen mcp add`, + * which writes the person's other servers back with their `${VAR}` values + * filled in (the fake does too). What is guarded here: only Plannotator's + * entry ever changes, comments and `${VAR}` included, and a file that does not + * parse or is read-only is left as it is. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { samePlace } from "./agent-files"; +import { createFakeAgents, type FakeAgents } from "../../tests/helpers/fake-agents"; +import { + agentSpec, + connectInboxAgents, + createAgentEnvironmentFor, + formatConnectReport, + inboxAgentReach, + planInboxAgentRemoval, + qwenDir, + readAgentEntry, + readInboxAgents, + setInboxAgentReach, + setMoveRollbackHookForTests, + type AgentEnvironment, +} from "./inbox-agents"; + +const SELF = ["/opt/plannotator/bin/plannotator"]; + +/** Whether the scratch volume ignores case (macOS and Windows by default; Linux never), looked at, never assumed. */ +const IGNORES_CASE = (() => { + const probe = mkdtempSync(join(tmpdir(), "plannotator-case-")); + try { + mkdirSync(join(probe, "q")); + return existsSync(join(probe, "Q")); + } finally { + rmSync(probe, { recursive: true, force: true }); + } +})(); +const IS_ROOT = process.getuid?.() === 0; +const OURS = [...SELF, "inbox", "mcp"]; + +let fake: FakeAgents; +let dataDir: string; +let savedDataDir: string | undefined; +let cwdLog: string; + +beforeEach(() => { + fake = createFakeAgents("plannotator-qwen-"); + dataDir = join(fake.root, "data"); + mkdirSync(dataDir, { recursive: true }); + cwdLog = join(fake.root, "cwd.log"); + savedDataDir = process.env.PLANNOTATOR_DATA_DIR; + process.env.PLANNOTATOR_DATA_DIR = dataDir; +}); +afterEach(() => { + process.env.PLANNOTATOR_DATA_DIR = savedDataDir; + fake.remove(); +}); + +function environment(extra: Record = {}): AgentEnvironment { + return createAgentEnvironmentFor({ platform: "linux", homeDir: fake.home, env: { ...fake.env, FAKE_AGENT_CWD_LOG: cwdLog, ...extra } }); +} +const options = (extra: Record = {}) => ({ dataDir, selfCommand: SELF, environment: environment(extra) }); +const connect = (extra: Record = {}) => connectInboxAgents(options(extra)); +/** Every run of the fake qwen, `--version` included, with the folder it ran in. */ +const qwenRuns = (): { args: string[]; cwd: string }[] => + existsSync(cwdLog) + ? readFileSync(cwdLog, "utf8") + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as { name: string; args: string[]; cwd: string }) + .filter((run) => run.name === "qwen") + : []; +const settingsPath = () => join(fake.home, ".qwen", "settings.json"); +const writeSettings = (text: string, path = settingsPath()) => { + mkdirSync(join(path, ".."), { recursive: true }); + writeFileSync(path, text); +}; +const ENTRY = { command: OURS[0], args: ["inbox", "mcp"] }; +/** + * A person's settings.json as Qwen keeps it: comments, `$version`, and a + * server whose token comes from the environment (`${GITHUB_TOKEN}`). + */ +const MINE = `{ + // Mine: the token stays in my environment. + "$version": 3, + "general": { "vimMode": true }, + "mcpServers": { + "github": { "command": "gh-mcp", "args": ["--stdio"], "env": { "GITHUB_TOKEN": "\${GITHUB_TOKEN}" } } // keep + } +} +`; +const SECRET = { GITHUB_TOKEN: "ghp_not_a_real_token_123" }; + +describe("connecting Qwen Code", () => { + test("its settings.json gets the entry and nothing else: comments and the person's ${VAR} stay as written, qwen mcp is never run", async () => { + fake.install("qwen"); + writeSettings(MINE); + const report = await connect(SECRET); + expect(report.added).toEqual(["qwen"]); + const text = readFileSync(settingsPath(), "utf8"); + // Byte for byte, the person's text is all still there; the only new text is the entry. + expect(text).toContain('"GITHUB_TOKEN": "${GITHUB_TOKEN}" } }, // keep'); + expect(text).toContain("// Mine: the token stays in my environment."); + expect(text).not.toContain(SECRET.GITHUB_TOKEN); + expect(readAgentEntry("qwen", environment(), [OURS])).toEqual({ kind: "ours", argv: OURS }); + // `qwen mcp add` writes every server back resolved (the fake does as Qwen 0.26 does): it is never run. + expect(fake.calls().filter((call) => call.name === "qwen")).toEqual([]); + expect(readInboxAgents(dataDir).agents.qwen).toMatchObject({ state: "added", reach: "mcp", path: settingsPath() }); + expect(formatConnectReport(report)[0]).toContain("Qwen Code"); + + const again = await connect(SECRET); + expect(again.added).toEqual([]); + expect(again.already).toEqual(["qwen"]); + expect(readFileSync(settingsPath(), "utf8")).toBe(text); + }); + + test("qwen is asked its version in the home folder, never in the Inbox's working folder (Qwen rewrites that folder's .qwen/settings.json)", async () => { + fake.install("qwen"); + await connect(); + const runs = qwenRuns(); + expect(runs.map((run) => run.args)).toEqual([["--version"]]); + expect(runs[0]!.cwd).toBe(fake.home); + }); + + test("found by its folder alone (no qwen on PATH): connected the same way", async () => { + writeSettings(MINE); + const report = await connect(); + expect(report.added).toEqual(["qwen"]); + expect(readAgentEntry("qwen", environment(), [OURS])).toEqual({ kind: "ours", argv: OURS }); + }); + + test("QWEN_HOME is the folder itself: the entry goes in $QWEN_HOME/settings.json, never ~/.qwen", async () => { + fake.install("qwen"); + const home = join(fake.root, "elsewhere"); + const report = await connect({ QWEN_HOME: home }); + expect(report.added).toEqual(["qwen"]); + expect(JSON.parse(readFileSync(join(home, "settings.json"), "utf8")).mcpServers["plannotator-inbox"]).toEqual(ENTRY); + expect(existsSync(join(fake.home, ".qwen"))).toBe(false); + expect(readInboxAgents(dataDir).agents.qwen?.path).toBe(join(home, "settings.json")); + }); + + test("QWEN_HOME as Qwen reads it: `~` expanded, untrimmed, empty is unset, and from ~/.qwen/.env or ~/.env when not in the environment", () => { + expect(qwenDir(environment({ QWEN_HOME: "~/q" }))).toBe(join(fake.home, "q")); + expect(qwenDir(environment({ QWEN_HOME: "" }))).toBe(join(fake.home, ".qwen")); + expect(qwenDir(environment({ QWEN_HOME: "/x/q " }))).toBe("/x/q "); + writeFileSync(join(fake.home, ".env"), "OTHER=1\nQWEN_HOME=~/from-home-env\n"); + expect(qwenDir(environment())).toBe(join(fake.home, "from-home-env")); + mkdirSync(join(fake.home, ".qwen"), { recursive: true }); + writeFileSync(join(fake.home, ".qwen", ".env"), "# Qwen's own\nexport QWEN_HOME=\"/srv/qwen\" # moved\n"); + expect(qwenDir(environment())).toBe("/srv/qwen"); + // The environment wins over both files, even empty. + expect(qwenDir(environment({ QWEN_HOME: "/env/q" }))).toBe("/env/q"); + expect(qwenDir(environment({ QWEN_HOME: "" }))).toBe(join(fake.home, ".qwen")); + }); + + test("a relative QWEN_HOME names a different folder in every project: left to the person, nothing written", async () => { + fake.install("qwen"); + writeSettings(MINE); + const report = await connect({ QWEN_HOME: "rel/q" }); + expect(report.manual).toEqual(["qwen"]); + expect(report.manual_why?.[0]?.why).toContain("QWEN_HOME is a relative path"); + expect(readFileSync(settingsPath(), "utf8")).toBe(MINE); + expect(existsSync(join(fake.home, "rel"))).toBe(false); + }); + + test("a program that only shares the name is never taken for Qwen Code; any Qwen Code version is (a nightly too)", async () => { + fake.install("qwen"); + const foreign = await connect({ FAKE_QWEN_VERSION: "qwen (image viewer) 3.1" }); + expect(foreign.manual_why).toEqual([{ agent: "qwen", why: "this qwen did not answer like Qwen Code" }]); + expect(existsSync(settingsPath())).toBe(false); + const old = await connect({ FAKE_QWEN_VERSION: "0.0.7" }); + expect(old.added).toEqual(["qwen"]); + const nightly = createFakeAgents("plannotator-qwen-nightly-"); + try { + nightly.install("qwen"); + const report = await connectInboxAgents({ + dataDir: join(nightly.root, "data"), + selfCommand: SELF, + environment: createAgentEnvironmentFor({ platform: "linux", homeDir: nightly.home, env: { ...nightly.env, FAKE_QWEN_VERSION: "0.27.0-nightly.20261027.a1b2c3d" } }), + }); + expect(report.added).toEqual(["qwen"]); + } finally { + nightly.remove(); + } + }); + + test("turned off in Settings: only the entry goes (the file back byte for byte), and a later run never adds it back", async () => { + fake.install("qwen"); + writeSettings(MINE); + await connect(SECRET); + expect(await setInboxAgentReach("qwen", false, options(SECRET))).toBeNull(); + expect(readFileSync(settingsPath(), "utf8")).toBe(MINE); + expect(readInboxAgents(dataDir).agents.qwen?.state).toBe("off"); + const later = await connect(SECRET); + expect(later.added).toEqual([]); + expect(readFileSync(settingsPath(), "utf8")).toBe(MINE); + }); +}); + +describe("QWEN_HOME is not the same as when the entry was added (set in one shell, not in another)", () => { + const other = () => join(fake.root, "other-qwen"); + const otherSettings = () => join(other(), "settings.json"); + /** Connected in ~/.qwen with the person's settings, as the shell without QWEN_HOME left it. */ + const connectedInA = async () => { + fake.install("qwen"); + writeSettings(MINE); + await connect(); + return { text: readFileSync(settingsPath(), "utf8"), record: readInboxAgents(dataDir).agents.qwen! }; + }; + + test("uninstall removes the entry from the file it was written in, byte for byte, and touches nothing in the other home", async () => { + await connectedInA(); + const plan = planInboxAgentRemoval(dataDir, environment({ QWEN_HOME: other() }), OURS); + expect(plan.skipped).toEqual([]); + expect(plan.items.map((item) => item.describe)).toEqual(["MCP server plannotator-inbox from Qwen Code (~/.qwen/settings.json, that entry only)"]); + expect(await plan.items[0]!.apply()).toBeNull(); + expect(readFileSync(settingsPath(), "utf8")).toBe(MINE); + expect(existsSync(other())).toBe(false); + }); + + test("a run under another QWEN_HOME touches neither file and moves nothing: said once, with how to move it; Settings still shows where it is", async () => { + const a = await connectedInA(); + const run = await connect({ QWEN_HOME: other() }); + expect(run.added).toEqual([]); + expect(run.failed).toEqual([]); + expect(run.elsewhere).toEqual([{ agent: "qwen", path: "~/.qwen/settings.json", now: otherSettings() }]); + const said = formatConnectReport(run).join("\n"); + expect(said).toContain("~/.qwen/settings.json"); + expect(said).toContain("Settings → Agents"); + expect(readFileSync(settingsPath(), "utf8")).toBe(a.text); + expect(existsSync(other())).toBe(false); + expect(readInboxAgents(dataDir).agents.qwen).toEqual(a.record); + expect((await connect({ QWEN_HOME: other() })).elsewhere ?? []).toEqual([]); + // Off here, where it is, and turning it on moves it. + expect(await inboxAgentReach("qwen", options({ QWEN_HOME: other() }))).toMatchObject({ on: false, state: "elsewhere", path: "~/.qwen/settings.json" }); + expect(await inboxAgentReach("qwen", options())).toMatchObject({ on: true, state: "connected", path: "~/.qwen/settings.json" }); + }); + + test("said once per change of home: a run from the matching shell keeps it said, and never leaves the other shell's line in this Inbox's band", async () => { + await connectedInA(); + const said: number[] = []; + for (const env of [{ QWEN_HOME: other() }, {}, { QWEN_HOME: other() }, {}, { QWEN_HOME: other() }]) { + said.push((await connect(env)).elsewhere?.length ?? 0); + // An Inbox whose QWEN_HOME is the recorded one never shows "this Inbox looks in …". + if (!("QWEN_HOME" in env)) expect(readInboxAgents(dataDir).last_report?.elsewhere ?? []).toEqual([]); + } + expect(said).toEqual([1, 0, 0, 0, 0]); + }); + + test("said again after Settings acts: the entry moved here and back, then a run elsewhere", async () => { + await connectedInA(); + expect((await connect({ QWEN_HOME: other() })).elsewhere).toHaveLength(1); + expect(await setInboxAgentReach("qwen", true, options({ QWEN_HOME: other() }))).toBeNull(); + expect(await setInboxAgentReach("qwen", true, options())).toBeNull(); + expect(readInboxAgents(dataDir).agents.qwen?.path).toBe(settingsPath()); + expect((await connect({ QWEN_HOME: other() })).elsewhere).toHaveLength(1); + }); + + test("one folder spelled two ways is one place: a symlink, a trailing slash; no move is offered, and turning it on there keeps the entry", async () => { + fake.install("qwen"); + const real = join(fake.root, "real-qwen"); + mkdirSync(real, { recursive: true }); + symlinkSync(real, join(fake.home, ".qwen")); + writeSettings(MINE, join(real, "settings.json")); + await connect(); + const record = readInboxAgents(dataDir).agents.qwen!; + expect(record.path).toBe(settingsPath()); + for (const env of [{ QWEN_HOME: real }, { QWEN_HOME: `${real}/` }, { QWEN_HOME: join(fake.home, ".qwen") + "/" }]) { + expect((await connect(env)).elsewhere ?? []).toEqual([]); + expect(await inboxAgentReach("qwen", options(env))).toMatchObject({ on: true, state: "connected" }); + } + // Removed by hand, then turned on through the other spelling: an entry is left. + writeFileSync(join(real, "settings.json"), MINE); + await connect(); + expect(await setInboxAgentReach("qwen", true, options({ QWEN_HOME: real }))).toBeNull(); + expect(readAgentEntry("qwen", environment(), [OURS])).toEqual({ kind: "ours", argv: OURS }); + }); + + test.if(IGNORES_CASE)("on a volume that ignores case (macOS, Windows), another case is the same folder: the case on disk is what counts", async () => { + fake.install("qwen"); + await connect(); + for (const platform of ["darwin", "linux"] as const) { + const env = createAgentEnvironmentFor({ platform, homeDir: fake.home, env: { ...fake.env, QWEN_HOME: join(fake.home, ".QWEN") } }); + expect((await connectInboxAgents({ dataDir, selfCommand: SELF, environment: env })).elsewhere ?? []).toEqual([]); + } + }); + + test.if(!IGNORES_CASE)("on a case-sensitive volume (Linux), two homes that differ only by case are two homes: a run in the other says where it is, Settings shows it off there", async () => { + fake.install("qwen"); + const lower = join(fake.root, "qwen"); + const upper = join(fake.root, "Qwen"); + mkdirSync(lower); + mkdirSync(upper); + for (const platform of ["darwin", "linux"] as const) { + expect(samePlace(lower, upper)).toBe(false); + const at = (home: string) => ({ dataDir, selfCommand: SELF, environment: createAgentEnvironmentFor({ platform, homeDir: fake.home, env: { ...fake.env, QWEN_HOME: home } }) }); + rmSync(join(dataDir, "inbox"), { recursive: true, force: true }); + rmSync(join(lower, "settings.json"), { force: true }); + expect((await connectInboxAgents(at(lower))).added).toEqual(["qwen"]); + const run = await connectInboxAgents(at(upper)); + expect(run.elsewhere).toEqual([{ agent: "qwen", path: join(lower, "settings.json"), now: join(upper, "settings.json") }]); + expect(existsSync(join(upper, "settings.json"))).toBe(false); + expect(await inboxAgentReach("qwen", at(upper))).toMatchObject({ on: false, state: "elsewhere" }); + } + }); + + test("Plannotator never folds case itself: paths that differ only by case and are not made yet compare as written (on a case-sensitive volume they are two folders)", () => { + const parent = join(fake.root, "not-made"); + expect(samePlace(join(parent, "qwen"), join(parent, "Qwen"))).toBe(false); + expect(samePlace(join(parent, "qwen"), join(parent, "qwen", "."))).toBe(true); + }); + + test("an entry the person removed by hand: a run under another QWEN_HOME never says it is connected, and never adds it again", async () => { + await connectedInA(); + writeFileSync(settingsPath(), MINE); + const run = await connect({ QWEN_HOME: other() }); + expect(run.elsewhere ?? []).toEqual([]); + expect(formatConnectReport(run).join("\n")).not.toContain("is connected in"); + expect(readInboxAgents(dataDir).agents.qwen?.removed_outside_at).toEqual(expect.any(String)); + expect(existsSync(other())).toBe(false); + expect(readFileSync(settingsPath(), "utf8")).toBe(MINE); + expect((await connect()).added).toEqual([]); + expect(readFileSync(settingsPath(), "utf8")).toBe(MINE); + }); + + test("an entry a move could not take back out is recorded with the old one, and uninstall removes both", async () => { + await connectedInA(); + // What a failed rollback leaves (it cannot be forced here: the add and its undo write the same file). + writeSettings('{\n "mcpServers": {\n "plannotator-inbox": { "command": "/opt/plannotator/bin/plannotator", "args": ["inbox", "mcp"] }\n }\n}\n', otherSettings()); + const agents = JSON.parse(readFileSync(join(dataDir, "inbox", "agents.json"), "utf8")); + agents.agents.qwen.strays = [{ path: otherSettings(), command: OURS }]; + writeFileSync(join(dataDir, "inbox", "agents.json"), JSON.stringify(agents)); + const plan = planInboxAgentRemoval(dataDir, environment(), OURS); + expect(plan.items.map((item) => item.describe)).toEqual([ + "MCP server plannotator-inbox from Qwen Code (~/.qwen/settings.json, that entry only)", + `MCP server plannotator-inbox from Qwen Code (${otherSettings()}, that entry only)`, + ]); + for (const item of plan.items) expect(await item.apply()).toBeNull(); + expect(readFileSync(settingsPath(), "utf8")).toBe(MINE); + expect(JSON.parse(readFileSync(otherSettings(), "utf8")).mcpServers).toEqual({}); + }); + + /** + * A move whose rollback failed, made for real: the old file is read-only (its entry cannot go), and the new one + * turns read-only just before the move takes its entry back out. Both entries are left, the new one recorded. + */ + const leaveStray = async () => { + const a = await connectedInA(); + chmodSync(settingsPath(), 0o444); + setMoveRollbackHookForTests(() => chmodSync(otherSettings(), 0o444)); + try { + const problem = await setInboxAgentReach("qwen", true, options({ QWEN_HOME: other() })); + return { ...a, problem }; + } finally { + setMoveRollbackHookForTests(null); + chmodSync(settingsPath(), 0o644); + if (existsSync(otherSettings())) chmodSync(otherSettings(), 0o644); + } + }; + + test.skipIf(IS_ROOT)("a move that could not take its new entry back out: said plainly, both entries kept and recorded", async () => { + const a = await leaveStray(); + expect(a.problem).toContain("could not be taken back out"); + expect(a.problem).toContain("plannotator uninstall removes both"); + expect(readAgentEntry("qwen", environment(), [OURS])).toEqual({ kind: "ours", argv: OURS }); + expect(JSON.parse(readFileSync(otherSettings(), "utf8")).mcpServers["plannotator-inbox"]).toEqual(ENTRY); + const record = readInboxAgents(dataDir).agents.qwen!; + expect(record).toMatchObject({ state: "added", path: settingsPath() }); + expect(record.strays).toEqual([expect.objectContaining({ path: otherSettings(), command: OURS, created: true })]); + }); + + test.skipIf(IS_ROOT)("the stray entry stays recorded when Settings turns it on where it is: recorded as added there, the old one moved out, uninstall removes it", async () => { + await leaveStray(); + expect(await inboxAgentReach("qwen", options({ QWEN_HOME: other() }))).toMatchObject({ on: false, state: "elsewhere" }); + expect(await setInboxAgentReach("qwen", true, options({ QWEN_HOME: other() }))).toBeNull(); + expect(readFileSync(settingsPath(), "utf8")).toBe(MINE); + const record = readInboxAgents(dataDir).agents.qwen!; + expect(record).toMatchObject({ state: "added", path: otherSettings() }); + expect(record.strays).toBeUndefined(); + const plan = planInboxAgentRemoval(dataDir, environment(), OURS); + expect(plan.items.map((item) => item.describe)).toEqual([`MCP server plannotator-inbox from Qwen Code (${otherSettings()}, that entry only)`]); + expect(await plan.items[0]!.apply()).toBeNull(); + // Plannotator created that file: it goes with its entry. + expect(existsSync(otherSettings())).toBe(false); + }); + + test.skipIf(IS_ROOT)("Settings off removes the stray entry too; one that cannot go stays recorded, said, and uninstall removes it later", async () => { + await leaveStray(); + writeFileSync(otherSettings(), "{ not json"); + const problem = await setInboxAgentReach("qwen", false, options()); + expect(problem).toContain(`Plannotator's entry in ${otherSettings()} could not be removed`); + expect(readFileSync(settingsPath(), "utf8")).toBe(MINE); + expect(readInboxAgents(dataDir).agents.qwen).toMatchObject({ state: "off", strays: [expect.objectContaining({ path: otherSettings() })] }); + writeSettings('{\n "mcpServers": {\n "plannotator-inbox": { "command": "/opt/plannotator/bin/plannotator", "args": ["inbox", "mcp"] }\n }\n}\n', otherSettings()); + const plan = planInboxAgentRemoval(dataDir, environment(), OURS); + expect(plan.items.map((item) => item.describe)).toEqual([`MCP server plannotator-inbox from Qwen Code (${otherSettings()}, that entry only)`]); + expect(await plan.items[0]!.apply()).toBeNull(); + expect(existsSync(otherSettings())).toBe(false); + }); + + test.skipIf(IS_ROOT)("a stray entry the person replaced with their own is theirs: never touched, named once by uninstall, and no longer recorded", async () => { + await leaveStray(); + const theirs = '{\n "mcpServers": {\n "plannotator-inbox": { "command": "/my/own" }\n }\n}\n'; + writeFileSync(otherSettings(), theirs); + const plan = planInboxAgentRemoval(dataDir, environment(), OURS); + expect(plan.items.map((item) => item.describe)).toEqual(["MCP server plannotator-inbox from Qwen Code (~/.qwen/settings.json, that entry only)"]); + expect(plan.skipped).toEqual([`Qwen Code's "plannotator-inbox" in ${otherSettings()} now runs something else (changed by hand), so it is yours`]); + expect(await setInboxAgentReach("qwen", false, options())).toBeNull(); + expect(readInboxAgents(dataDir).agents.qwen?.strays).toBeUndefined(); + expect(readFileSync(otherSettings(), "utf8")).toBe(theirs); + expect(readFileSync(settingsPath(), "utf8")).toBe(MINE); + }); + + test.skipIf(IS_ROOT)("a stray entry the person removed by hand is nothing to do: no item, no longer recorded", async () => { + await leaveStray(); + writeFileSync(otherSettings(), '{\n "mcpServers": {}\n}\n'); + expect(planInboxAgentRemoval(dataDir, environment(), OURS).items.map((item) => item.describe)).toEqual(["MCP server plannotator-inbox from Qwen Code (~/.qwen/settings.json, that entry only)"]); + expect(await setInboxAgentReach("qwen", false, options())).toBeNull(); + expect(readInboxAgents(dataDir).agents.qwen?.strays).toBeUndefined(); + }); + + test.skipIf(IS_ROOT)("a move out of a file whose entry the person changed never takes the stray entry instead: it stays, and stays recorded", async () => { + await leaveStray(); + const theirs = `${MINE.replace('"mcpServers": {', '"mcpServers": {\n "plannotator-inbox": { "command": "/my/own" },')}`; + writeFileSync(settingsPath(), theirs); + const third = join(fake.root, "third-qwen"); + expect(await setInboxAgentReach("qwen", true, options({ QWEN_HOME: third }))).toBeNull(); + expect(JSON.parse(readFileSync(otherSettings(), "utf8")).mcpServers["plannotator-inbox"]).toEqual(ENTRY); + expect(readFileSync(settingsPath(), "utf8")).toBe(theirs); + const record = readInboxAgents(dataDir).agents.qwen!; + expect(record).toMatchObject({ state: "added", path: join(third, "settings.json") }); + expect(record.strays).toEqual([expect.objectContaining({ path: otherSettings() })]); + }); + + test.skipIf(IS_ROOT)("Settings off with both entries removable: both go, nothing left to uninstall", async () => { + await leaveStray(); + expect(await setInboxAgentReach("qwen", false, options())).toBeNull(); + expect(readFileSync(settingsPath(), "utf8")).toBe(MINE); + expect(existsSync(otherSettings())).toBe(false); + expect(readInboxAgents(dataDir).agents.qwen?.strays).toBeUndefined(); + expect(planInboxAgentRemoval(dataDir, environment(), OURS).items).toEqual([]); + }); + + test("turning Qwen Code on in Settings under the new QWEN_HOME moves it: added there, then out of the old file (back byte for byte)", async () => { + await connectedInA(); + await connect({ QWEN_HOME: other() }); + expect(readInboxAgents(dataDir).last_report?.elsewhere).toHaveLength(1); + expect(await setInboxAgentReach("qwen", true, options({ QWEN_HOME: other() }))).toBeNull(); + // The band no longer says to move it. + expect(readInboxAgents(dataDir).last_report?.elsewhere ?? []).toEqual([]); + expect(readFileSync(settingsPath(), "utf8")).toBe(MINE); + expect(JSON.parse(readFileSync(otherSettings(), "utf8")).mcpServers["plannotator-inbox"]).toEqual(ENTRY); + expect(readInboxAgents(dataDir).agents.qwen).toMatchObject({ state: "added", path: otherSettings() }); + // Only the new file's entry is left for uninstall, from whichever shell it runs. + expect(await planInboxAgentRemoval(dataDir, environment(), OURS).items[0]!.apply()).toBeNull(); + expect(existsSync(otherSettings())).toBe(false); + expect(readFileSync(settingsPath(), "utf8")).toBe(MINE); + }); + + const cannotTake: [string, () => Record, () => void][] = [ + ["its settings.json has a trailing comma", () => ({ QWEN_HOME: other() }), () => writeSettings('{\n "mcpServers": { "x": { "command": "y" }, },\n}\n', otherSettings())], + ["its settings.json is read-only", () => ({ QWEN_HOME: other() }), () => { + writeSettings('{\n "mcpServers": {}\n}\n', otherSettings()); + chmodSync(otherSettings(), 0o444); + }], + ["QWEN_HOME is relative", () => ({ QWEN_HOME: "rel/q" }), () => {}], + ["the qwen on PATH is not Qwen Code", () => ({ QWEN_HOME: other(), FAKE_QWEN_VERSION: "qwen (image viewer) 3.1" }), () => {}], + ]; + for (const [what, env, prepare] of cannotTake) { + test.skipIf(process.getuid?.() === 0 && what.includes("read-only"))(`a new home that cannot take the entry (${what}): the old entry and its record stay, the run and Settings say why`, async () => { + const a = await connectedInA(); + prepare(); + const otherBefore = existsSync(otherSettings()) ? readFileSync(otherSettings(), "utf8") : null; + try { + await connect(env()); + const problem = await setInboxAgentReach("qwen", true, options(env())); + expect(problem).not.toBeNull(); + expect(readFileSync(settingsPath(), "utf8")).toBe(a.text); + expect(readInboxAgents(dataDir).agents.qwen).toEqual(a.record); + expect(existsSync(otherSettings()) ? readFileSync(otherSettings(), "utf8") : null).toBe(otherBefore); + expect(existsSync(join(fake.home, "rel"))).toBe(false); + } finally { + if (existsSync(otherSettings())) chmodSync(otherSettings(), 0o644); + } + }); + } + + test("an old file the person broke is never cut around: the new entry is taken back out, the broken file stays as it is, the record kept", async () => { + const a = await connectedInA(); + const broken = `${a.text.trimEnd()},\n`; + writeFileSync(settingsPath(), broken); + const problem = await setInboxAgentReach("qwen", true, options({ QWEN_HOME: other() })); + expect(problem).toContain("can't be read"); + expect(problem).toContain("stays connected in ~/.qwen/settings.json"); + expect(readFileSync(settingsPath(), "utf8")).toBe(broken); + // The entry it had added there is taken back out (the file it created with it, too). + expect(existsSync(otherSettings())).toBe(false); + expect(readInboxAgents(dataDir).agents.qwen).toEqual(a.record); + }); + + test.skipIf(process.getuid?.() === 0)("an old file that cannot be written: the new entry is taken back out, so only the recorded one is ever left", async () => { + const a = await connectedInA(); + chmodSync(settingsPath(), 0o444); + try { + const problem = await setInboxAgentReach("qwen", true, options({ QWEN_HOME: other() })); + expect(problem).toContain("could not be removed"); + expect(problem).toContain("stays connected in ~/.qwen/settings.json"); + expect(readFileSync(settingsPath(), "utf8")).toBe(a.text); + expect(existsSync(otherSettings())).toBe(false); + expect(readInboxAgents(dataDir).agents.qwen).toEqual(a.record); + } finally { + chmodSync(settingsPath(), 0o644); + } + }); + + test("Settings off under another QWEN_HOME removes it from the recorded file", async () => { + await connectedInA(); + expect(await setInboxAgentReach("qwen", false, options({ QWEN_HOME: other() }))).toBeNull(); + expect(readFileSync(settingsPath(), "utf8")).toBe(MINE); + expect(existsSync(other())).toBe(false); + expect(readInboxAgents(dataDir).agents.qwen?.state).toBe("off"); + }); +}); + +describe("Qwen's settings.json is never left broken", () => { + test("a settings.json that is not valid JSON: left byte for byte, the reason said", async () => { + fake.install("qwen"); + const broken = '{ "mcpServers": { "github": { "command": "gh-mcp" }, \n'; + writeSettings(broken); + const report = await connect(); + expect(report.failed).toEqual([{ agent: "qwen", reason: "~/.qwen/settings.json can't be read (it is not plain JSON), so Plannotator left it as it was", path: "~/.qwen/settings.json" }]); + expect(readFileSync(settingsPath(), "utf8")).toBe(broken); + expect(readInboxAgents(dataDir).agents.qwen).toBeUndefined(); + }); + + test("a trailing comma (which Qwen itself refuses) is not valid either: left as it is", async () => { + const trailing = '{\n "mcpServers": { "github": { "command": "gh-mcp" }, },\n}\n'; + writeSettings(trailing); + const report = await connect(); + expect(report.failed).toHaveLength(1); + expect(report.failed[0]!.reason).toContain("so Plannotator left it as it was"); + expect(readFileSync(settingsPath(), "utf8")).toBe(trailing); + }); + + test.skipIf(process.getuid?.() === 0)("a read-only settings.json: left as it is, the reason said, and a later run tries again", async () => { + fake.install("qwen"); + writeSettings(MINE); + chmodSync(settingsPath(), 0o444); + try { + const report = await connect(); + expect(report.failed).toEqual([{ agent: "qwen", reason: "~/.qwen/settings.json is read-only, so Plannotator left it as it was", path: "~/.qwen/settings.json" }]); + expect(readFileSync(settingsPath(), "utf8")).toBe(MINE); + } finally { + chmodSync(settingsPath(), 0o644); + } + const later = await connect(); + expect(later.added).toEqual(["qwen"]); + }); + + test("uninstall removes only Plannotator's entry; an entry Qwen reformatted around is cut alone, comments kept", async () => { + fake.install("qwen"); + writeSettings(MINE); + await connect(); + // Qwen rewrote the file meanwhile (its own format-keeping writer: another key, the entry where it was). + const rewritten = readFileSync(settingsPath(), "utf8").replace('"$version": 3,', '"$version": 4,\n "ui": { "theme": "dark" }, // new'); + writeFileSync(settingsPath(), rewritten); + const plan = planInboxAgentRemoval(dataDir, environment(), OURS); + expect(plan.items.map((item) => item.describe)).toEqual(["MCP server plannotator-inbox from Qwen Code (~/.qwen/settings.json, that entry only)"]); + expect(await plan.items[0]!.apply()).toBeNull(); + expect(readFileSync(settingsPath(), "utf8")).toBe(MINE.replace('"$version": 3,', '"$version": 4,\n "ui": { "theme": "dark" }, // new')); + expect(fake.calls().filter((call) => call.name === "qwen")).toEqual([]); + }); + + test("a settings.json Plannotator created is deleted again on removal once only the empty containers are left", async () => { + fake.install("qwen"); + await connect(); + expect(readInboxAgents(dataDir).agents.qwen?.created).toBe(true); + expect(await planInboxAgentRemoval(dataDir, environment(), OURS).items[0]!.apply()).toBeNull(); + expect(existsSync(settingsPath())).toBe(false); + }); + + test("an entry the person changed to run something else is theirs: never replaced, never removed", async () => { + fake.install("qwen"); + writeSettings(JSON.stringify({ mcpServers: { "plannotator-inbox": { command: "my-proxy", args: [] } } })); + const report = await connect(); + expect(report.foreign).toEqual([{ agent: "qwen", path: "~/.qwen/settings.json" }]); + expect(planInboxAgentRemoval(dataDir, environment(), OURS).items).toEqual([]); + }); + + test("the spec names Qwen's own folder and file (read with comments, as Qwen reads it)", () => { + writeSettings('{\n // mine\n "mcpServers": { "plannotator-inbox": { "command": "/opt/plannotator/bin/plannotator", "args": ["inbox", "mcp"] } }\n}\n'); + expect(agentSpec("qwen").file(environment())).toBe(settingsPath()); + expect(readAgentEntry("qwen", environment(), [OURS])).toEqual({ kind: "ours", argv: OURS }); + }); +}); diff --git a/packages/server/inbox-agents-settings.test.ts b/packages/server/inbox-agents-settings.test.ts index c293681f5f..3a10f0e1b9 100644 --- a/packages/server/inbox-agents-settings.test.ts +++ b/packages/server/inbox-agents-settings.test.ts @@ -201,6 +201,43 @@ test("reach off removes Plannotator's entry and stays off at the next run; on ad expect(row(on, "codex").reach.state).toBe("connected"); }); +test("Qwen Code connected under another QWEN_HOME: Settings shows it off with where it is; turning it on into a settings.json Qwen would refuse keeps the old entry; fixed, it moves", async () => { + fake.install("qwen"); + const mine = '{\n // mine\n "mcpServers": {}\n}\n'; + mkdirSync(join(fake.home, ".qwen"), { recursive: true }); + writeFileSync(join(fake.home, ".qwen", "settings.json"), mine); + const first = await start(); + await first.connectAgents(); + const connected = readFileSync(join(fake.home, ".qwen", "settings.json"), "utf8"); + expect(connected).toContain("plannotator-inbox"); + first.stop(); + + // The Inbox started from a shell with another QWEN_HOME, whose settings.json has a trailing comma. + const other = join(fake.root, "other-qwen"); + mkdirSync(other, { recursive: true }); + const trailing = '{\n "mcpServers": { "x": { "command": "y" }, },\n}\n'; + writeFileSync(join(other, "settings.json"), trailing); + const s = await start({ QWEN_HOME: other }); + await s.connectAgents(); + const shown = await get(s); + expect(row(shown, "qwen").reach).toMatchObject({ kind: "mcp", on: false, state: "elsewhere", path: "~/.qwen/settings.json" }); + + // The switch: on (as the UI sends it), refused, nothing changed anywhere. + const refused = (await (await post(s, "/api/inbox/agents", { agent: "qwen", reach: true })).json()) as Model; + expect(refused.error).toContain("stays connected in ~/.qwen/settings.json"); + expect(readFileSync(join(fake.home, ".qwen", "settings.json"), "utf8")).toBe(connected); + expect(readFileSync(join(other, "settings.json"), "utf8")).toBe(trailing); + expect(row(refused, "qwen").reach.state).toBe("elsewhere"); + + // The person fixes it: on moves it, and the old file is theirs again, byte for byte. + writeFileSync(join(other, "settings.json"), '{\n "mcpServers": { "x": { "command": "y" } }\n}\n'); + const moved = (await (await post(s, "/api/inbox/agents", { agent: "qwen", reach: true })).json()) as Model; + expect(moved.error).toBeNull(); + expect(row(moved, "qwen").reach).toMatchObject({ on: true, state: "connected" }); + expect(readFileSync(join(fake.home, ".qwen", "settings.json"), "utf8")).toBe(mine); + expect(readFileSync(join(other, "settings.json"), "utf8")).toContain("plannotator-inbox"); +}); + test("the reminder switch per host, and the tool switch for Pi's extension", async () => { mkdirSync(join(fake.home, ".pi", "agent"), { recursive: true }); writeFileSync(join(fake.home, ".pi", "agent", "settings.json"), JSON.stringify({ packages: ["npm:@plannotator/pi-extension"] })); diff --git a/packages/server/inbox-agents.test.ts b/packages/server/inbox-agents.test.ts index 6894064df4..39c03ad531 100644 --- a/packages/server/inbox-agents.test.ts +++ b/packages/server/inbox-agents.test.ts @@ -5,7 +5,7 @@ * touched, what a later run does, and what uninstall removes. */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, lstatSync, mkdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { loadConfig } from "@plannotator/shared/config"; import { createFakeAgents, type FakeAgents } from "../../tests/helpers/fake-agents"; @@ -262,6 +262,22 @@ describe("agents connected by one key in a plain-JSON file", () => { expect(statSync(file).mtimeMs).toBe(mtime); }); + test("a write is atomic: a new file renamed over the old one (never rewritten in place), the file's mode kept, no temp file left", () => { + // An agent reading its file mid-write sees the old text or the new, never half; shared by every JSON file Plannotator edits. + const dir = join(fake.home, "atomic"); + mkdirSync(dir, { recursive: true }); + const file = join(dir, "mcp.json"); + writeFileSync(file, '{\n "mcpServers": {}\n}\n', { mode: 0o640 }); + chmodSync(file, 0o640); + for (const value of [{ command: "/p/plannotator", args: ["inbox", "mcp"] }, undefined]) { + const inode = statSync(file).ino; + expect(writeJsonEntry(file, ["mcpServers"], value, [["/p/plannotator", "inbox", "mcp"]])).toMatchObject({ ok: true, changed: true }); + expect(statSync(file).ino).not.toBe(inode); + expect(statSync(file).mode & 0o777).toBe(0o640); + expect(readdirSync(dir)).toEqual(["mcp.json"]); + } + }); + test("a file that is not plain JSON is refused and left as it was", async () => { const dir = join(fake.home, ".cursor"); mkdirSync(dir, { recursive: true }); diff --git a/packages/server/inbox-agents.ts b/packages/server/inbox-agents.ts index 0a4359a4c1..fb61025513 100644 --- a/packages/server/inbox-agents.ts +++ b/packages/server/inbox-agents.ts @@ -31,7 +31,7 @@ * CLIs; the real agent CLIs are never run by a test. */ -import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs"; +import { accessSync, constants as fsConstants, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { spawn as spawnChild } from "node:child_process"; import { basename, isAbsolute, join, resolve, sep } from "node:path"; @@ -40,6 +40,7 @@ import { INBOX_AGENT_IDS, INBOX_AGENT_LABELS, inboxAgentList, + inboxElsewhereText, type InboxAgentId, type InboxConnectReport, } from "@plannotator/core/inbox-types"; @@ -53,10 +54,12 @@ import { type InboxToolHost, } from "@plannotator/shared/config"; import { inboxDir } from "@plannotator/shared/inbox/schema"; +import { qwenHome, qwenHomeIsRelative } from "@plannotator/shared/inbox/qwen-sessions"; import { addJsonMember, removeJsonMember } from "./json-edit"; import { describeFsError, readAgentFile, + samePlace, stripJsonComments, withBom, writeAgentFileAtomic, @@ -93,8 +96,9 @@ export interface AgentEnvironment { /** * Run argv with no stdin, the environment's env, killed (with its process * group) after `timeoutMs`, when `signal` aborts, or when this process exits. + * `cwd`: the folder it runs in (default: this process's). */ - run(argv: readonly string[], options: { timeoutMs: number; signal?: AbortSignal }): Promise; + run(argv: readonly string[], options: { timeoutMs: number; signal?: AbortSignal; cwd?: string }): Promise; } /** The real boundary: this process's home, environment and PATH. */ @@ -114,7 +118,7 @@ export function createAgentEnvironmentFor(input: { homeDir: input.homeDir, env, which: (command) => Bun.which(command, { PATH: env.PATH ?? "" }), - run: (argv, options) => runWithTimeout(argv, env, options.timeoutMs, options.signal), + run: (argv, options) => runWithTimeout(argv, env, options.timeoutMs, options.signal, options.cwd), }; } @@ -149,6 +153,7 @@ function runWithTimeout( env: Readonly>, timeoutMs: number, signal?: AbortSignal, + cwd?: string, ): Promise { return new Promise((done) => { if (signal?.aborted) { @@ -160,6 +165,7 @@ function runWithTimeout( child = spawnChild(argv[0]!, argv.slice(1), { stdio: ["ignore", "pipe", "pipe"], env: { ...env } as NodeJS.ProcessEnv, + ...(cwd ? { cwd } : {}), detached: process.platform !== "win32", windowsHide: true, }); @@ -288,6 +294,16 @@ export function openCodeConfigDir(e: AgentEnvironment): string { export function geminiDir(e: AgentEnvironment): string { return join(envDir(e, "GEMINI_CLI_HOME") ?? e.homeDir, ".gemini"); } +/** + * Qwen Code's folder: `QWEN_HOME` itself when set (in the environment, else + * in `~/.qwen/.env` or `~/.env`, as Qwen reads it), else `~/.qwen`. A relative + * `QWEN_HOME` depends on the folder Qwen runs in; it is resolved against the + * home folder, where Plannotator runs `qwen` (and the connect leaves it to the + * person). + */ +export function qwenDir(e: AgentEnvironment): string { + return qwenHome(e.env, e.homeDir, e.homeDir); +} /** The Claude desktop app's settings folder on this platform. */ export function claudeAppDir(e: AgentEnvironment): string { if (e.platform === "darwin") return join(e.homeDir, "Library", "Application Support", "Claude"); @@ -439,8 +455,8 @@ export function readCodexEntry(path: string, ours: readonly (readonly string[])[ type ConnectWay = /** Run the agent's own `mcp add`; remove with its `mcp remove`. */ | { kind: "cli"; bin: string; add: (command: readonly string[]) => string[]; remove: string[] } - /** One key in a plain-JSON file. */ - | { kind: "json"; keys: readonly string[] } + /** One key in a JSON file (`json`: how it is edited). */ + | { kind: "json"; keys: readonly string[]; json?: JsonFileRules } /** `inboxTool` on for Plannotator's own extension/plugin. */ | { kind: "tool"; host: InboxToolHost } /** Only a person can connect it; `why` says how. */ @@ -466,6 +482,16 @@ interface AgentSpec { remove?: { bin: string; args: string[] }; } +/** + * How Plannotator edits an agent's JSON file itself. `comments`: the file is + * JSON with comments (json-edit.ts keeps every one). `keepReadOnly`: a + * read-only file is left as it is (the person locked it), never replaced. + */ +export interface JsonFileRules { + comments?: boolean; + keepReadOnly?: boolean; +} + const NAME = INBOX_MCP_SERVER_NAME; /** Whether Plannotator's Pi extension is installed (Pi's settings.json `packages`). */ @@ -672,7 +698,9 @@ export function claudeCodeInboxToolOn(e: AgentEnvironment): boolean { * build older than its `mcp add` is left to the person: Pi before 0.99 (no * `pi mcp`: it would read the words as a chat prompt and run a model turn), * Codex CLI before 0.35.0 (`codex mcp add` landed in rust-v0.35.0, Sep 2025) - * and Gemini CLI before 0.1.19 (`gemini mcp add`, Aug 2025). Read with no + * and Gemini CLI before 0.1.19 (`gemini mcp add`, Aug 2025). Qwen Code's + * settings.json is edited by Plannotator itself, so any Qwen Code version + * will do; the line only tells Qwen Code from another `qwen`. Read with no * stdin, under the limit. */ const VERSION_SHAPES: Record = { @@ -688,6 +716,10 @@ const VERSION_SHAPES: Record { const path = e.which(bin); if (!path) return { kind: "manual", why: `${bin} is not on PATH` }; - const shape = VERSION_SHAPES[bin]; - if (shape && probe) { - const ran = await e.run([path, "--version"], { timeoutMs: Math.min(timeoutMs, 5_000) }); - const line = ran.stdout.split(/\r?\n/).map((l) => l.trim()).find(Boolean) ?? ""; - const match = ran.exitCode === 0 && !ran.timedOut ? shape.line.exec(line) : null; - if (!match) return { kind: "manual", why: shape.manual }; - if (shape.min) { - const version = [Number(match[1]), Number(match[2]), Number(match[3])]; - const [a, b, c] = shape.min; - const older = version[0]! < a || (version[0] === a && (version[1]! < b || (version[1] === b && version[2]! < c))); - if (older) return { kind: "manual", why: shape.older ?? shape.manual }; - } + if (probe) { + const refused = await versionRefusal(e, bin, path, timeoutMs); + if (refused) return { kind: "manual", why: refused }; } return { kind: "cli", bin, add, remove }; } +/** + * Ask a CLI its `--version` (VERSION_SHAPES) before anything is written for + * it: why it is left to the person (not the CLI it is named for, or older + * than its `mcp add`), or null. `cwd`: the folder it runs in. + */ +async function versionRefusal(e: AgentEnvironment, bin: string, path: string, timeoutMs: number, cwd?: string): Promise { + const shape = VERSION_SHAPES[bin]; + if (!shape) return null; + const ran = await e.run([path, "--version"], { timeoutMs: Math.min(timeoutMs, 5_000), ...(cwd ? { cwd } : {}) }); + const line = ran.stdout.split(/\r?\n/).map((l) => l.trim()).find(Boolean) ?? ""; + const match = ran.exitCode === 0 && !ran.timedOut ? shape.line.exec(line) : null; + if (!match) return shape.manual; + if (shape.min) { + const version = [Number(match[1]), Number(match[2]), Number(match[3])]; + const [a, b, c] = shape.min; + const older = version[0]! < a || (version[0] === a && (version[1]! < b || (version[1] === b && version[2]! < c))); + if (older) return shape.older ?? shape.manual; + } + return null; +} + +/** Qwen Code's settings.json: JSON with comments, edited by Plannotator itself; a read-only one is left alone. */ +const QWEN_SETTINGS_RULES: JsonFileRules = { comments: true, keepReadOnly: true }; + +/** + * Qwen Code is connected by editing its user settings.json directly, never + * with `qwen mcp add` / `remove`: those write back every MCP server with its + * `$VAR` / `${VAR}` values expanded (a token kept in the environment would + * land in the file as plain text). A `qwen` on PATH is asked its version + * first (run in the home folder, so Qwen never touches a project's + * `.qwen/settings.json`), so another program named `qwen` is not taken for + * it. A relative `QWEN_HOME` names a different folder in every project, so + * that is left to the person. + */ +async function qwenWay(e: AgentEnvironment, probe: boolean): Promise { + if (qwenHomeIsRelative(e.env, e.homeDir)) { + return { kind: "manual", why: "QWEN_HOME is a relative path, so Qwen Code's folder depends on where it runs; add plannotator-inbox to its settings.json by hand" }; + } + const path = e.which("qwen"); + if (path && probe) { + const refused = await versionRefusal(e, "qwen", path, INBOX_CONNECT_AGENT_TIMEOUT_MS, e.homeDir); + if (refused) return { kind: "manual", why: refused }; + } + return { kind: "json", keys: ["mcpServers"], json: QWEN_SETTINGS_RULES }; +} + export const AGENT_SPECS: readonly AgentSpec[] = [ { id: "claude-code", @@ -748,6 +817,13 @@ export const AGENT_SPECS: readonly AgentSpec[] = [ raw: (e) => rawJsonEntry(join(geminiDir(e), "settings.json"), ["mcpServers"], true), remove: { bin: "gemini", args: ["mcp", "remove", "-s", "user", NAME] }, }, + { + id: "qwen", + detect: (e) => !!e.which("qwen") || isDir(qwenDir(e)), + way: (e, probe) => qwenWay(e, probe === true), + file: (e) => join(qwenDir(e), "settings.json"), + raw: (e) => rawJsonEntry(join(qwenDir(e), "settings.json"), ["mcpServers"], true), + }, { id: "cursor", detect: (e) => isDir(join(e.homeDir, ".cursor")) || !!e.which("cursor") || !!e.which("cursor-agent"), @@ -855,6 +931,17 @@ function deleteIfOnlyOurs(path: string, keys: readonly string[]): void { } } +/** Whether a file may be written: its mode has the owner's write bit and this process may write it. */ +function writableFile(target: string, mode: number): boolean { + if ((mode & 0o200) === 0) return false; + try { + accessSync(target, fsConstants.W_OK); + return true; + } catch { + return false; + } +} + /** The smallest middle span that differs between two texts. */ function textChange(original: string, next: string): JsonEntryUndo { let start = 0; @@ -869,14 +956,14 @@ function textChange(original: string, next: string): JsonEntryUndo { * untouched) and putting `before` back leaves a file without the entry. Null * when that cannot be done; the caller then cuts the entry alone. */ -function undoJsonEntry(text: string, undo: JsonEntryUndo | undefined, keys: readonly string[]): string | null { +function undoJsonEntry(text: string, undo: JsonEntryUndo | undefined, keys: readonly string[], comments: boolean): string | null { if (!undo || !undo.after || !undo.after.includes(JSON.stringify(NAME))) return null; const first = text.indexOf(undo.after); if (first < 0 || text.indexOf(undo.after, first + 1) >= 0) return null; const restored = text.slice(0, first) + undo.before + text.slice(first + undo.after.length); if (!restored.trim()) return restored; try { - return at(JSON.parse(restored), [...keys, NAME]) === undefined ? restored : null; + return at(JSON.parse(comments ? stripJsonComments(restored) : restored), [...keys, NAME]) === undefined ? restored : null; } catch { return null; } @@ -884,11 +971,11 @@ function undoJsonEntry(text: string, undo: JsonEntryUndo | undefined, keys: read /** * Add (`value`) or remove (`undefined`) `plannotator-inbox` at `keys` in a - * plain-JSON file, losslessly (json-edit.ts: only that member's text - * changes; anything else is refused, with the reason), atomically, written at - * a symlink's target, a no-op when nothing changes. `ours`: the commands a + * JSON file, losslessly (json-edit.ts: only that member's text changes; + * anything else is refused, with the reason), atomically, written at a + * symlink's target, a no-op when nothing changes. `ours`: the commands a * removal may take away; an entry running anything else is never replaced or - * removed. + * removed. `rules`: comments allowed, a read-only file kept. */ export function writeJsonEntry( path: string, @@ -896,7 +983,9 @@ export function writeJsonEntry( value: { command: string; args: string[] } | undefined, ours: readonly (readonly string[])[] = value ? [[value.command, ...value.args]] : [], undo?: JsonEntryUndo, + rules: JsonFileRules = {}, ): WriteOutcome { + const comments = rules.comments === true; const read = readAgentFile(path); if (read.kind === "refused") return { ok: false, reason: `${path} can't be edited (${read.reason})` }; if (read.kind === "missing" && value === undefined) return { ok: true, changed: false }; @@ -904,9 +993,9 @@ export function writeJsonEntry( if (original.trim()) { let parsed: unknown; try { - parsed = JSON.parse(original); + parsed = JSON.parse(comments ? stripJsonComments(original) : original); } catch { - return { ok: false, reason: `${path} is not plain JSON, so Plannotator left it as it was` }; + return { ok: false, reason: `${path} is not ${comments ? "valid" : "plain"} JSON, so Plannotator left it as it was` }; } const current = at(parsed, [...keys, NAME]); if (current !== undefined) { @@ -915,10 +1004,18 @@ export function writeJsonEntry( if (!owned) return { ok: false, reason: `${path} has a ${NAME} entry Plannotator did not write` }; } else if (value === undefined) return { ok: true, changed: false }; } - const undone = value === undefined ? undoJsonEntry(original, undo, keys) : null; - const edit = undone !== null ? { ok: true as const, changed: true, text: undone } : value === undefined ? removeJsonMember(original, keys, NAME) : addJsonMember(original, keys, NAME, value); + const undone = value === undefined ? undoJsonEntry(original, undo, keys, comments) : null; + const edit = + undone !== null + ? { ok: true as const, changed: true, text: undone } + : value === undefined + ? removeJsonMember(original, keys, NAME, { comments }) + : addJsonMember(original, keys, NAME, value, { comments }); if (!edit.ok) return { ok: false, reason: `${path} can't be edited without reshaping it (${edit.reason}), so Plannotator left it as it was` }; if (!edit.changed) return { ok: true, changed: false }; + if (rules.keepReadOnly && read.kind === "file" && !writableFile(read.target, read.mode)) { + return { ok: false, reason: `${path} is read-only, so Plannotator left it as it was` }; + } try { writeAgentFileAtomic(read.target, withBom(edit.text, read.kind === "file" && read.bom), read.kind === "file" ? read.mode : 0o600); } catch (error) { @@ -945,6 +1042,8 @@ export interface InboxAgentRecord { command?: string[]; /** A JSON add's exact change, so removal gives the file back byte for byte (writeJsonEntry). */ undo?: JsonEntryUndo; + /** Entries Plannotator added in other files that a move could not take back out: uninstall removes them too. */ + strays?: { path: string; command?: string[]; undo?: JsonEntryUndo; created?: boolean }[]; /** Plannotator created the JSON file: removal deletes it when nothing but the empty containers is left. */ created?: boolean; at: string; @@ -963,6 +1062,8 @@ export interface InboxAgentsFile { failures?: Partial>; /** Each agent's last "connect it by hand" reason, so it is said once (said again when it changes). */ manual_said?: Partial>; + /** Each agent last said to be connected in another file than the environment names (`recorded -> now`): said once. */ + elsewhere_said?: Partial>; } export function inboxAgentsPath(dataDir: string): string { @@ -1081,7 +1182,7 @@ async function connectOne(spec: AgentSpec, options: ConnectOptions, at: string): if (way.kind === "json") { const [cmd, ...args] = command; const created = readAgentFile(file!).kind === "missing"; - const result = writeJsonEntry(file!, way.keys, { command: cmd!, args }); + const result = writeJsonEntry(file!, way.keys, { command: cmd!, args }, undefined, undefined, way.json); if (!result.ok) return { kind: "failed", reason: result.reason.split(e.homeDir).join("~"), path: file }; return { kind: "added", record: { state: "added", reach: "mcp", path: file, command, at, ...(result.undo ? { undo: result.undo } : {}), ...(created ? { created: true } : {}) } }; } @@ -1100,16 +1201,6 @@ async function connectOne(spec: AgentSpec, options: ConnectOptions, at: string): return { kind: "added", record: { state: "added", reach: "mcp", path: file, command, at } }; } -/** - * Whether a recorded MCP entry is still there (read only). Null: the agent's - * file is not known or not readable, so nothing can be said. - */ -export function entryStillThere(id: InboxAgentId, e: AgentEnvironment, ours: readonly (readonly string[])[]): boolean | null { - const read = readAgentEntry(id, e, ours, true); - if (!read || read.kind === "unreadable") return null; - return read.kind === "ours"; -} - /** * Connect every agent found that has no record yet. Never throws: a failure * is reported per agent and tried again at the next run (said again only @@ -1132,7 +1223,30 @@ export async function connectInboxAgents(options: ConnectOptions): Promise ${now}`; + if (file.elsewhere_said?.[spec.id] !== key) (report.elsewhere ??= []).push({ agent: spec.id, path, now }); + (file.elsewhere_said ??= {})[spec.id] = key; + continue; + } + // This run's environment names the recorded file: a line from a run elsewhere is not this Inbox's to show. + if (file.last_report?.elsewhere?.some((moved) => moved.agent === spec.id)) { + const elsewhere = file.last_report.elsewhere.filter((moved) => moved.agent !== spec.id); + file.last_report = { ...file.last_report, elsewhere }; + } } // An agent connected through MCP before its plugin was installed (Pi before `pi install`), whose plugin's tool is // on now: the MCP entry Plannotator added goes, so the agent never carries both. Kept when it cannot be removed. @@ -1229,7 +1343,7 @@ export async function connectInboxAgents(options: ConnectOptions): Promise 0 && !file.first_connected_at) file.first_connected_at = at; - const news = report.added.length > 0 || report.failed.length > 0 || report.foreign.length > 0; + const news = report.added.length > 0 || report.failed.length > 0 || report.foreign.length > 0 || (report.elsewhere?.length ?? 0) > 0; file.last_report = news ? { ...report, seen: false } : file.last_report; try { writeInboxAgents(options.dataDir, file); @@ -1264,6 +1378,7 @@ export function formatConnectReport(report: InboxConnectReport): string[] { for (const foreign of report.foreign) { lines.push(`${INBOX_AGENT_LABELS[foreign.agent]} has its own ${INBOX_MCP_SERVER_NAME} entry${foreign.path ? ` in ${foreign.path}` : ""}; left alone.`); } + for (const moved of report.elsewhere ?? []) lines.push(inboxElsewhereText(moved)); if (report.added.length > 0 && report.first) { lines.push(`Agents use it from their next session. ${INBOX_CONNECT_ENV}=0 skips this step; plannotator uninstall removes what it added.`); } @@ -1279,6 +1394,8 @@ export interface RemovalPlanItem { describe: string; /** Perform it; returns an error reason, or null. */ apply: () => Promise; + /** An entry a move left in another file (the record's `strays`): its path. Not the agent's own entry. */ + stray?: string; } export interface RemovalPlan { @@ -1300,19 +1417,46 @@ export interface RemovalPlan { export function planInboxAgentRemoval(dataDir: string, e: AgentEnvironment, ourCommand: readonly string[]): RemovalPlan { const file = readInboxAgents(dataDir); const items: RemovalPlanItem[] = []; + /** After every agent's own entry, so a caller looking for an agent's item finds that one first. */ + const strays: RemovalPlanItem[] = []; const skipped: string[] = []; if (!agentHomeAllowed(e)) return { items, skipped }; for (const spec of AGENT_SPECS) { const record = file.agents[spec.id]; - if (record && (record.reach !== "mcp" || record.state !== "added")) continue; - const raw = spec.raw(e); const label = INBOX_AGENT_LABELS[spec.id]; + // Entries a move left in another file (its new one could not be taken back out): removed too, that entry only, + // whatever the record says of the agent's own entry now (turned off since, say). + const strayJson = jsonFileFor(spec.id); + for (const stray of strayJson ? (record?.strays ?? []) : []) { + // Checked as the agent's own entry is: one gone is nothing to do, one the person replaced is theirs (named + // once, then no longer recorded); one that can't be read is tried, so the reason is said. + const strayOurs = stray.command ? [stray.command] : record?.command ? [record.command] : []; + const strayRead = classify(rawJsonEntry(stray.path, strayJson!.keys, strayJson!.rules.comments === true), strayOurs, false); + if (strayRead.kind === "absent") continue; + if (strayRead.kind === "other") { + skipped.push(`${label}'s "${NAME}" in ${displayPath(stray.path, e)} now runs something else (changed by hand), so it is yours`); + continue; + } + strays.push({ + agent: spec.id, + stray: stray.path, + describe: `MCP server ${NAME} from ${label} (${displayPath(stray.path, e)}, that entry only)`, + apply: async () => { + const result = writeJsonEntry(stray.path, strayJson!.keys, undefined, strayOurs, stray.undo, strayJson!.rules); + if (result.ok && stray.created) deleteIfOnlyOurs(stray.path, strayJson!.keys); + return result.ok ? null : result.reason; + }, + }); + } + if (record && (record.reach !== "mcp" || record.state !== "added")) continue; + // A recorded entry is removed where it was written, whatever the environment names now. + const raw = record ? recordedRaw(spec, e, record) : spec.raw(e); if (!record) { // No record says Plannotator added it: never removed, only named when it runs this binary. const unrecorded = raw === null ? null : classify(raw, [[...ourCommand]], false); if (unrecorded?.kind === "ours") { const at = displayPath(spec.file(e), e); - const how = jsonKeysFor(spec.id) ? "delete that entry by hand" : spec.remove ? `run \`${spec.remove.bin} ${spec.remove.args.join(" ")}\`` : "remove it in the agent's settings"; + const how = jsonFileFor(spec.id) ? "delete that entry by hand" : spec.remove ? `run \`${spec.remove.bin} ${spec.remove.args.join(" ")}\`` : "remove it in the agent's settings"; skipped.push(`${label}'s "${NAME}"${at ? ` in ${at}` : ""} runs this Plannotator, but no record says Plannotator added it, so it may be yours (to remove it, ${how})`); } continue; @@ -1324,16 +1468,16 @@ export function planInboxAgentRemoval(dataDir: string, e: AgentEnvironment, ourC continue; } if (read && read.kind !== "ours") continue; - const path = spec.file(e) ?? record.path ?? null; + const path = recordedFile(spec, e, record) ?? record.path ?? null; const shown = displayPath(path, e); - const jsonKeys = jsonKeysFor(spec.id); - if (jsonKeys && path) { + const json = jsonFileFor(spec.id); + if (json && path) { items.push({ agent: spec.id, describe: `MCP server ${NAME} from ${label} (${shown}, that entry only)`, apply: async () => { - const result = writeJsonEntry(path, jsonKeys, undefined, ours, record.undo); - if (result.ok && record.created) deleteIfOnlyOurs(path, jsonKeys); + const result = writeJsonEntry(path, json.keys, undefined, ours, record.undo, json.rules); + if (result.ok && record.created) deleteIfOnlyOurs(path, json.keys); return result.ok ? null : result.reason; }, }); @@ -1357,11 +1501,39 @@ export function planInboxAgentRemoval(dataDir: string, e: AgentEnvironment, ourC }, }); } - return { items, skipped }; + return { items: [...items, ...strays], skipped }; +} + +/** The agents whose JSON file Plannotator edits itself: where their entry is and how the file is edited. */ +function jsonFileFor(id: InboxAgentId): { keys: readonly string[]; rules: JsonFileRules } | null { + if (id === "qwen") return { keys: ["mcpServers"], rules: QWEN_SETTINGS_RULES }; + return id === "cursor" || id === "kiro" || id === "claude-app" ? { keys: ["mcpServers"], rules: {} } : null; } -function jsonKeysFor(id: InboxAgentId): readonly string[] | null { - return id === "cursor" || id === "kiro" || id === "claude-app" ? ["mcpServers"] : null; +/** + * The file an agent's recorded entry is in. For an agent whose JSON file + * Plannotator edits itself, the file the record names: the place it may move + * with the environment (`QWEN_HOME` set in one shell and not in another), and + * the entry is where it was written. Otherwise the file the environment names. + */ +function recordedFile(spec: AgentSpec, e: AgentEnvironment, record: InboxAgentRecord | undefined): string | null { + const recorded = record?.reach === "mcp" && (record.state === "added" || record.state === "found") ? record.path : null; + return jsonFileFor(spec.id) && recorded ? recorded : spec.file(e); +} + +/** The recorded entry's raw value (`recordedFile`), read only. */ +function recordedRaw(spec: AgentSpec, e: AgentEnvironment, record: InboxAgentRecord | undefined): RawEntry | null { + const path = recordedFile(spec, e, record); + const json = jsonFileFor(spec.id); + if (!json || !path || path === spec.file(e)) return spec.raw(e); + return rawJsonEntry(path, json.keys, json.rules.comments === true); +} + +/** Whether the environment now names another file than the one the agent's recorded entry is in. */ +function recordedElsewhere(spec: AgentSpec, e: AgentEnvironment, record: InboxAgentRecord | undefined): boolean { + const recorded = recordedFile(spec, e, record); + const now = spec.file(e); + return recorded !== null && now !== null && !samePlace(recorded, now); } // --------------------------------------------------------------------------- @@ -1371,7 +1543,7 @@ export interface InboxAgentReachState { /** tool: Plannotator's plugin/extension (`inboxTool`); mcp: a plannotator-inbox entry; manual: only a person can add it; none: nothing to switch. */ kind: "tool" | "mcp" | "manual" | "none"; on: boolean; - state: "connected" | "off" | "removed" | "foreign" | "not_connected" | "manual" | "unreadable"; + state: "connected" | "off" | "removed" | "foreign" | "not_connected" | "manual" | "unreadable" | "elsewhere"; /** Where the entry is (`~` for home). */ path: string | null; /** PLANNOTATOR_INBOX_TOOL decides the tool switch. */ @@ -1435,13 +1607,19 @@ export async function inboxAgentReach(id: InboxAgentId, options: ConnectOptions) const e = options.environment; const spec = agentSpec(id); const way: ConnectWay = id === "claude-code" ? claudeCodeWay(e) : await spec.way(e); - const path = displayPath(spec.file(e), e); const record = readInboxAgents(options.dataDir).agents[id]; + // Where its recorded entry is (`QWEN_HOME` may name another file now): that is what Settings shows and switches. + const path = displayPath(recordedFile(spec, e, record), e); // Claude Code connected by hand through MCP, where the plugin gives it no tool (not installed, disabled, or its mod off): // it reaches the Inbox (without the wake). Checked before the agent is called off. if (id === "claude-code" && (way.kind === "manual" || !claudeCodeModOn(e)) && claudeCodeMcpEntry(e, ourCommands(options, record)).kind === "ours") { return { kind: "mcp", on: true, state: "connected", path: displayPath(join(envDir(e, "CLAUDE_CONFIG_DIR") ?? e.homeDir, ".claude.json"), e), locked: false, reason: null }; } + // Connected in another file than the environment names (`QWEN_HOME` differs): off here, the switch moves it. + if (record && recordedElsewhere(spec, e, record)) { + const raw = recordedRaw(spec, e, record); + if (raw && classify(raw, ourCommands(options, record), true).kind === "ours") return { kind: "mcp", on: false, state: "elsewhere", path, locked: false, reason: null }; + } if (way.kind === "tool") { const locked = parseInboxToolEnv(e.env as NodeJS.ProcessEnv) !== undefined; // Claude Code's tool comes from the plugin's mod: with the mod off it has none, whatever inboxTool says. @@ -1451,7 +1629,8 @@ export async function inboxAgentReach(id: InboxAgentId, options: ConnectOptions) } if (way.kind === "manual") return { kind: "manual", on: false, state: "manual", path, locked: false, reason: way.why }; if (way.kind === "none") return { kind: "none", on: false, state: "manual", path, locked: false, reason: null }; - const read = readAgentEntry(id, e, ourCommands(options, record), true); + const raw = recordedRaw(spec, e, record); + const read = raw === null ? null : classify(raw, ourCommands(options, record), true); if (read?.kind === "ours") return { kind: "mcp", on: true, state: "connected", path, locked: false, reason: null }; if (read?.kind === "other") return { kind: "mcp", on: false, state: "foreign", path, locked: false, reason: null }; if (read?.kind === "unreadable") return { kind: "mcp", on: false, state: "unreadable", path, locked: false, reason: read.reason }; @@ -1495,10 +1674,45 @@ export const CLAUDE_CODE_MOD_OFF_REASON = "Plannotator's Claude Code mod is off */ async function removeOurMcpEntry(id: InboxAgentId, options: ConnectOptions, record: InboxAgentRecord | undefined): Promise { if (record?.reach !== "mcp" || record.state !== "added") return null; - const item = planInboxAgentRemoval(options.dataDir, options.environment, ourCommands(options, record)[0]!).items.find((candidate) => candidate.agent === id); + const item = planInboxAgentRemoval(options.dataDir, options.environment, ourCommands(options, record)[0]!).items.find((candidate) => candidate.agent === id && !candidate.stray); return item ? item.apply() : null; } +/** + * Once the entry is added where the environment names now (the person turned + * the agent on in Settings): take the one Plannotator added out of the file + * its record names (that entry only). A found entry is the person's and stays; + * one gone or changed by hand is left too. Returns an error reason (the old + * file cannot be read or written: the caller takes the new entry back out), or null. + */ +async function moveRecordedEntry(spec: AgentSpec, options: ConnectOptions, record: InboxAgentRecord): Promise { + if (record.reach !== "mcp" || record.state !== "added") return null; + const raw = recordedRaw(spec, options.environment, record); + if (raw?.kind === "unreadable") return `${displayPath(record.path, options.environment)} can't be read (${raw.reason})`; + return removeOurMcpEntry(spec.id, options, record); +} + +/** + * The record once Settings turned an agent on: entries a move left elsewhere + * (`strays`) stay recorded, so uninstall still removes them. A stray where it + * connects now is the entry it found there: Plannotator's, recorded as added. + */ +function keepStrays(previous: InboxAgentRecord | undefined, next: InboxAgentRecord): InboxAgentRecord { + const strays = previous?.strays ?? []; + const here = next.reach === "mcp" && next.state === "found" && next.path ? strays.find((stray) => samePlace(stray.path, next.path!)) : undefined; + const record: InboxAgentRecord = here + ? { ...next, state: "added", ...(here.command ? { command: here.command } : {}), ...(here.undo ? { undo: here.undo } : {}), ...(here.created ? { created: true } : {}) } + : next; + const rest = strays.filter((stray) => stray !== here); + return rest.length > 0 ? { ...record, strays: rest } : record; +} + +let beforeMoveRollback: (() => void) | null = null; +/** Tests only: runs just before a move takes its new entry back out, so a test can make that fail. */ +export function setMoveRollbackHookForTests(hook: (() => void) | null): void { + beforeMoveRollback = hook; +} + export async function setInboxAgentReach(id: InboxAgentId, on: boolean, options: ConnectOptions): Promise { const e = options.environment; if (!agentHomeAllowed(e)) return "not in a test's scratch home"; @@ -1506,7 +1720,11 @@ export async function setInboxAgentReach(id: InboxAgentId, on: boolean, options: const spec = agentSpec(id); const way: ConnectWay = id === "claude-code" ? claudeCodeWay(e) : await spec.way(e, true); const file = readInboxAgents(options.dataDir); - if (way.kind === "manual") return `${INBOX_AGENT_LABELS[id]} is connected by hand: ${way.why}`; + let strayProblem: string | null = null; + // The person acted: the "connected elsewhere" line is said again only for a new pair of places. + if (file.elsewhere_said?.[id]) delete file.elsewhere_said[id]; + const there = file.agents[id] && recordedElsewhere(spec, e, file.agents[id]) ? displayPath(file.agents[id]!.path, e) : null; + if (way.kind === "manual") return `${INBOX_AGENT_LABELS[id]} is connected by hand: ${way.why}${there ? `, so it stays connected in ${there}` : ""}`; if (way.kind === "none") return `${INBOX_AGENT_LABELS[id]} is connected by hand`; if (way.kind === "tool") { // Claude Code's tool comes from the plugin's mod: with the mod off a switch here would change nothing. @@ -1522,26 +1740,67 @@ export async function setInboxAgentReach(id: InboxAgentId, on: boolean, options: file.agents[id] = { state: on ? "added" : "off", reach: "tool", path: null, at }; } else if (on) { const previous = file.agents[id]; + // Its entry is in another file than the environment names now (`QWEN_HOME` changed): the person moves it here. + // The old entry stays until the new file has one, so a problem in the new home never leaves the agent with none. + const moving = previous !== undefined && recordedElsewhere(spec, e, previous); + const old = moving ? displayPath(previous!.path, e) : null; + const stays = moving ? `, so ${INBOX_AGENT_LABELS[id]} stays connected in ${old}` : ""; const outcome = await connectOne(spec, options, at); - if (outcome.kind === "failed") return outcome.reason; - if (outcome.kind === "foreign") return `${displayPath(outcome.path, e) ?? INBOX_AGENT_LABELS[id]} has its own ${NAME} entry`; - if (outcome.kind === "manual") return `${INBOX_AGENT_LABELS[id]} is connected by hand: ${outcome.why}`; - if ("record" in outcome && outcome.record) file.agents[id] = outcome.record; + if (outcome.kind === "failed") return `${outcome.reason}${stays}`; + if (outcome.kind === "foreign") return `${displayPath(outcome.path, e) ?? INBOX_AGENT_LABELS[id]} has its own ${NAME} entry${stays}`; + if (outcome.kind === "manual") return `${INBOX_AGENT_LABELS[id]} is connected by hand: ${outcome.why}${stays}`; + if (moving) { + const moved = await moveRecordedEntry(spec, options, previous!); + if (moved) { + // The old one cannot go: the new one is taken back out, so only the recorded entry is ever left. + const json = jsonFileFor(id); + const added = outcome.kind === "added" ? outcome.record : null; + if (json && added?.path) { + beforeMoveRollback?.(); + const undone = writeJsonEntry(added.path, json.keys, undefined, added.command ? [added.command] : [], added.undo, json.rules); + if (undone.ok && added.created) deleteIfOnlyOurs(added.path, json.keys); + if (!undone.ok) { + // Both stay: the new one is recorded too, so uninstall removes it. + file.agents[id] = { ...previous!, strays: [...(previous!.strays ?? []), { path: added.path, ...(added.command ? { command: added.command } : {}), ...(added.undo ? { undo: added.undo } : {}), ...(added.created ? { created: true } : {}) }] }; + writeInboxAgents(options.dataDir, file); + return `Plannotator's entry in ${old} could not be removed (${moved}), and the one it had just added in ${displayPath(added.path, e)} could not be taken back out (${undone.reason}). ${INBOX_AGENT_LABELS[id]} has both now; plannotator uninstall removes both`; + } + } + return `Plannotator's entry in ${old} could not be removed (${moved})${stays}`; + } + } + if ("record" in outcome && outcome.record) file.agents[id] = keepStrays(previous, outcome.record); else if (previous) delete file.agents[id]; } else { const record = file.agents[id]; const ours = ourCommands(options, record); - const item = planInboxAgentRemoval(options.dataDir, e, ours[0]!).items.find((candidate) => candidate.agent === id); + const items = planInboxAgentRemoval(options.dataDir, e, ours[0]!).items.filter((candidate) => candidate.agent === id); + const item = items.find((candidate) => !candidate.stray); if (item) { const problem = await item.apply(); if (problem) return problem; } - file.agents[id] = { state: "off", reach: "mcp", path: spec.file(e), at }; + // Entries a move left elsewhere go too; one that cannot stays recorded, so uninstall tries it again. + const left: NonNullable = []; + for (const stray of items.filter((candidate) => candidate.stray)) { + const problem = await stray.apply(); + if (!problem) continue; + strayProblem ??= `Plannotator's entry in ${displayPath(stray.stray!, e)} could not be removed (${problem}); plannotator uninstall tries it again`; + left.push(...(record?.strays ?? []).filter((kept) => kept.path === stray.stray)); + } + file.agents[id] = { state: "off", reach: "mcp", path: spec.file(e), at, ...(left.length > 0 ? { strays: left } : {}) }; } if (file.failures) delete file.failures[id]; - if (file.last_report) file.last_report = { ...file.last_report, failed: file.last_report.failed.filter((failure) => failure.agent !== id) }; + // The band stops naming it: neither a failure nor "connected in another folder" holds once the person acted. + if (file.last_report) { + file.last_report = { + ...file.last_report, + failed: file.last_report.failed.filter((failure) => failure.agent !== id), + ...(file.last_report.elsewhere ? { elsewhere: file.last_report.elsewhere.filter((moved) => moved.agent !== id) } : {}), + }; + } writeInboxAgents(options.dataDir, file); - return null; + return strayProblem; } /** After a plain uninstall removed entries: forget their records, so a reinstall connects them again. */ diff --git a/packages/server/inbox.ts b/packages/server/inbox.ts index d3c0388513..6447b5ea08 100644 --- a/packages/server/inbox.ts +++ b/packages/server/inbox.ts @@ -1250,10 +1250,10 @@ export async function startInboxServer(options: InboxServerOptions = {}): Promis const isNoteList = (value: unknown) => Array.isArray(value) && value.every((id) => typeof id === "string" && (INBOX_NOTE_AGENT_IDS as readonly string[]).includes(id)); const add = body.add; if (!isNoteList(add)) { - throw new InboxError("validation_error", "add: a list of agents (claude-code, codex, gemini, pi, opencode, cursor).", { field: "add" }); + throw new InboxError("validation_error", `add: a list of agents (${INBOX_NOTE_AGENT_IDS.join(", ")}).`, { field: "add" }); } if (body.asked !== undefined && !isNoteList(body.asked)) { - throw new InboxError("validation_error", "asked: a list of agents (claude-code, codex, gemini, pi, opencode, cursor).", { field: "asked" }); + throw new InboxError("validation_error", `asked: a list of agents (${INBOX_NOTE_AGENT_IDS.join(", ")}).`, { field: "asked" }); } if (body.seen !== undefined && typeof body.seen !== "boolean") throw new InboxError("validation_error", "seen: a boolean.", { field: "seen" }); const adding = [...new Set(add as InboxNoteAgentId[])]; diff --git a/packages/server/json-edit.test.ts b/packages/server/json-edit.test.ts index f916fdd1ef..9f736bf178 100644 --- a/packages/server/json-edit.test.ts +++ b/packages/server/json-edit.test.ts @@ -3,7 +3,7 @@ * changes, in the file's own style, and anything that cannot be placed * safely is refused. */ -import { expect, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { addJsonMember, removeJsonMember } from "./json-edit"; const V = { command: "/p", args: ["inbox", "mcp"] }; @@ -50,3 +50,40 @@ test("removal of the first, a middle and the only member keeps everything else", expect(removeJsonMember('{"m":{"a":1,"k":2,"b":3}}', ["m"], "k")).toEqual({ ok: true, changed: true, text: '{"m":{"a":1,"b":3}}' }); expect(removeJsonMember('{"m":{\n "k": 1\n}}', ["m"], "k")).toEqual({ ok: true, changed: true, text: '{"m":{}}' }); }); + +describe("JSON with comments (Qwen Code's settings.json)", () => { + const C = { comments: true }; + + test("every comment stays: the comma goes before a comment ending the last member's line, the member after it; removal gives the file back", () => { + const before = '{\n // mine\n "mcpServers": {\n "a": { "env": { "T": "${T}" } } // keep\n },\n /* x */ "z": "http://a//b"\n}\n'; + const added = addJsonMember(before, ["mcpServers"], "k", 1, C); + expect(added).toEqual({ ok: true, changed: true, text: '{\n // mine\n "mcpServers": {\n "a": { "env": { "T": "${T}" } }, // keep\n "k": 1\n },\n /* x */ "z": "http://a//b"\n}\n' }); + expect(removeJsonMember(added.ok ? added.text : "", ["mcpServers"], "k", C)).toEqual({ ok: true, changed: true, text: before }); + // A comment inside an empty object stays, after the new member. + expect(addJsonMember('{\n "m": {\n // none yet\n }\n}', ["m"], "k", 1, C)).toEqual({ ok: true, changed: true, text: '{\n "m": {\n "k": 1\n // none yet\n }\n}' }); + }); + + test("a CRLF file keeps CRLF everywhere: a line comment ending the last member's line keeps its line ending", () => { + const before = '{\r\n "mcpServers": {\r\n "a": 1 // keep\r\n }\r\n}\r\n'; + const added = addJsonMember(before, ["mcpServers"], "k", { c: "x" }, C); + expect(added).toEqual({ ok: true, changed: true, text: '{\r\n "mcpServers": {\r\n "a": 1, // keep\r\n "k": {\r\n "c": "x"\r\n }\r\n }\r\n}\r\n' }); + expect(removeJsonMember(added.ok ? added.text : "", ["mcpServers"], "k", C)).toEqual({ ok: true, changed: true, text: before }); + }); + + test("a one-line file keeps a comment inside an empty object", () => { + expect(addJsonMember('{"mcpServers":{/* keep me */}}', ["mcpServers"], "k", 1, C)).toEqual({ ok: true, changed: true, text: '{"mcpServers":{"k":1/* keep me */}}' }); + expect(addJsonMember("{/* only */}", ["mcpServers"], "k", 1, C)).toEqual({ ok: true, changed: true, text: '{"mcpServers":{"k":1}/* only */}' }); + }); + + test("a removal next to a comment cuts only the member and one comma", () => { + expect(removeJsonMember('{"m": {\n // ours\n "k": 1,\n "b": 2 // b\n}}', ["m"], "k", C)).toEqual({ ok: true, changed: true, text: '{"m": {\n // ours\n "b": 2 // b\n}}' }); + expect(removeJsonMember('{"m": {\n "a": 1, // a\n "k": 2 /* k */\n}}', ["m"], "k", C)).toEqual({ ok: true, changed: true, text: '{"m": {\n "a": 1 // a /* k */\n}}' }); + expect(removeJsonMember('{"m": {\n /* only */\n "k": 1\n}}', ["m"], "k", C)).toEqual({ ok: true, changed: true, text: '{"m": {\n /* only */\n}}' }); + }); + + test("refused as Qwen refuses them: a trailing comma, an unterminated comment; and comments stay refused without the option", () => { + expect(addJsonMember('{"a": 1,}', ["m"], "k", 1, C)).toEqual({ ok: false, reason: "it is not valid JSON" }); + expect(addJsonMember('{"a": 1 /* x', ["m"], "k", 1, C)).toEqual({ ok: false, reason: "it is not valid JSON" }); + expect(addJsonMember("{ // x\n}", ["m"], "k", 1)).toEqual({ ok: false, reason: "it is not plain JSON" }); + }); +}); diff --git a/packages/server/json-edit.ts b/packages/server/json-edit.ts index fb72df94a5..a6a50956c5 100644 --- a/packages/server/json-edit.ts +++ b/packages/server/json-edit.ts @@ -10,9 +10,20 @@ * duplicate key on the path, the path not an object) is refused with a * reason, and the caller tells the person what to add by hand. * + * `{ comments: true }`: the file is JSON with `//` and block comments (Qwen + * Code's settings.json, read as Qwen reads it: comments stripped, then plain + * JSON, so no trailing commas). Every comment stays where it is: a new member + * goes after a comment that ends the last member's line, and a removal cuts + * the member and one comma, never a comment beside them. + * * Pure. */ +export interface JsonEditOptions { + /** The text may hold `//` and block comments (JSONC without trailing commas). */ + comments?: boolean; +} + export type JsonEdit = { ok: true; text: string; changed: boolean } | { ok: false; reason: string }; interface Member { @@ -33,11 +44,82 @@ interface ObjectSpan { class ScanError extends Error {} -function skipWs(t: string, i: number): number { - while (i < t.length && (t[i] === " " || t[i] === "\t" || t[i] === "\n" || t[i] === "\r")) i += 1; +/** The end of the comment at `i` (exclusive), or `i` when none starts there. */ +function commentEnd(t: string, i: number): number { + if (t[i] !== "/") return i; + if (t[i + 1] === "/") { + const nl = t.indexOf("\n", i + 2); + // A CRLF line's `\r` is the line ending's, not the comment's. + if (nl === -1) return t.length; + return t[nl - 1] === "\r" ? nl - 1 : nl; + } + if (t[i + 1] === "*") { + const end = t.indexOf("*/", i + 2); + if (end === -1) throw new ScanError("unterminated comment"); + return end + 2; + } return i; } +/** + * Past whitespace and comments. A strict-JSON text has no comments (it was + * parsed as plain JSON first), so for it this skips whitespace only. + */ +function skipWs(t: string, i: number): number { + for (;;) { + while (i < t.length && (t[i] === " " || t[i] === "\t" || t[i] === "\n" || t[i] === "\r")) i += 1; + const end = commentEnd(t, i); + if (end === i) return i; + i = end; + } +} + +/** Past spaces, tabs and comments that end on this line (a line comment, a block comment with no line break). */ +function skipLineTrailer(t: string, i: number): number { + for (;;) { + while (i < t.length && (t[i] === " " || t[i] === "\t")) i += 1; + const end = commentEnd(t, i); + if (end === i || t.slice(i, end).includes("\n")) return i; + i = end; + } +} + +/** Only whitespace (no comment). */ +function blank(text: string): boolean { + return /^\s*$/.test(text); +} + +/** + * `t` with every comment outside strings turned into spaces (line breaks + * kept), as Qwen's `strip-json-comments` reads it; throws on an unterminated + * block comment. + */ +function stripComments(t: string): string { + let out = ""; + let i = 0; + while (i < t.length) { + if (t[i] === '"') { + const end = scanString(t, i); + out += t.slice(i, end); + i = end; + continue; + } + const end = commentEnd(t, i); + if (end > i) { + out += t.slice(i, end).replace(/[^\r\n]/g, " "); + i = end; + continue; + } + out += t[i]; + i += 1; + } + return out; +} + +function parseText(t: string, comments: boolean): unknown { + return JSON.parse(comments ? stripComments(t) : t); +} + function scanString(t: string, i: number): number { if (t[i] !== '"') throw new ScanError("expected a string"); i += 1; @@ -142,19 +224,38 @@ function insertMember(t: string, span: ObjectSpan, key: string, value: unknown, if (span.members.length > 0) { const first = span.members[0]!; const last = span.members[span.members.length - 1]!; - const sep = t.slice(span.open + 1, first.start); - const colon = t.slice(scanString(t, first.start), first.valueStart); + const sepText = t.slice(span.open + 1, first.start); + const colonText = t.slice(scanString(t, first.start), first.valueStart); + // A comment beside the first key is not copied: its line break and indent (or one space) are. + let sep = blank(sepText) ? sepText : sepText.includes("\n") ? `${nl}${lineIndent(t, first.start)}` : " "; + const colon = /^\s*:\s*$/.test(colonText) ? colonText : ": "; + // A comment that ends the last member's line stays on it: the comma goes before it, the new member after it. + const after = skipLineTrailer(t, last.valueEnd); + const trailer = t.slice(last.valueEnd, after); + if (trailer.includes("//") && !sep.includes("\n")) sep = `${nl}${lineIndent(t, last.start)}`; const pretty = sep.includes("\n"); const indent = pretty ? sep.slice(sep.lastIndexOf("\n") + 1) : ""; const member = `${keyText}${colon}${render(value, pretty, unit, indent, nl)}`; - return `${t.slice(0, last.valueEnd)},${pretty ? sep : sep || ""}${member}${t.slice(last.valueEnd)}`; + if (blank(trailer)) return `${t.slice(0, last.valueEnd)},${sep}${member}${t.slice(last.valueEnd)}`; + return `${t.slice(0, last.valueEnd)},${trailer}${sep}${member}${t.slice(after)}`; } // An empty object: pretty when the file is (it holds a line break), one line otherwise. const pretty = t.includes("\n"); const outer = lineIndent(t, span.open); - if (!pretty) return `${t.slice(0, span.open + 1)}${keyText}:${JSON.stringify(value)}${t.slice(span.close)}`; + if (!pretty) { + // One line: a comment inside the empty object stays, after the new member. + const interior = t.slice(span.open + 1, span.close); + return `${t.slice(0, span.open + 1)}${keyText}:${JSON.stringify(value)}${blank(interior) ? "" : interior}${t.slice(span.close)}`; + } const inner = outer + unit; - return `${t.slice(0, span.open + 1)}${nl}${inner}${keyText}: ${render(value, true, unit, inner, nl)}${nl}${outer}${t.slice(span.close)}`; + const member = `${nl}${inner}${keyText}: ${render(value, true, unit, inner, nl)}`; + const interior = t.slice(span.open + 1, span.close); + // Comments inside the empty object stay, after the new member. + if (!blank(interior)) { + const breakFirst = interior.startsWith("\n") || interior.startsWith("\r") ? "" : nl; + return `${t.slice(0, span.open + 1)}${member}${breakFirst}${interior}${t.slice(span.close)}`; + } + return `${t.slice(0, span.open + 1)}${member}${nl}${outer}${t.slice(span.close)}`; } function sameJson(a: unknown, b: unknown): boolean { @@ -169,25 +270,45 @@ function sortKeys(value: unknown): unknown { return value; } -function prepare(text: string): { bom: string; body: string; start: number } | { error: string } { +function prepare(text: string, comments: boolean): { bom: string; body: string; start: number } | { error: string } { const bom = text.charCodeAt(0) === 0xfeff ? "" : ""; const body = bom ? text.slice(1) : text; try { - JSON.parse(body); + parseText(body, comments); } catch { - return { error: "it is not plain JSON" }; + return { error: notValid(comments) }; } return { bom, body, start: skipWs(body, 0) }; } +function notValid(comments: boolean): string { + return comments ? "it is not valid JSON" : "it is not plain JSON"; +} + +/** Where a member's cut starts: back over the spaces before it, and the line break before them when the key starts its line. */ +function cutStart(t: string, start: number): number { + let i = start; + while (i > 0 && (t[i - 1] === " " || t[i - 1] === "\t")) i -= 1; + if (t[i - 1] === "\n") return t[i - 2] === "\r" ? i - 2 : i - 1; + return i; +} + +/** The comma after `i`, past whitespace and comments (it must be there). */ +function commaAfter(t: string, i: number): number { + const at = skipWs(t, i); + if (t[at] !== ",") throw new ScanError("expected a comma"); + return at; +} + /** `(text with path + key = value)` when `key` is absent at `path`; unchanged when present (any value). */ -export function addJsonMember(text: string, path: readonly string[], key: string, value: unknown): JsonEdit { +export function addJsonMember(text: string, path: readonly string[], key: string, value: unknown, options: JsonEditOptions = {}): JsonEdit { + const comments = options.comments === true; if (!text.trim()) { let next: unknown = { [key]: value }; for (const name of [...path].reverse()) next = { [name]: next }; return { ok: true, text: `${JSON.stringify(next, null, 2)}\n`, changed: true }; } - const prep = prepare(text); + const prep = prepare(text, comments); if ("error" in prep) return { ok: false, reason: prep.error }; let out: string; try { @@ -209,10 +330,10 @@ export function addJsonMember(text: string, path: readonly string[], key: string out = insertMember(lf, scanned.chain[scanned.missingAt]!, path[scanned.missingAt]!, nested, unit); } } catch (error) { - return { ok: false, reason: error instanceof ScanError ? "it is not plain JSON" : String(error) }; + return { ok: false, reason: error instanceof ScanError ? notValid(comments) : String(error) }; } // The edit must be exactly the one member: anything else is refused. - const before = JSON.parse(prep.body) as Record; + const before = parseText(prep.body, comments) as Record; let parent: Record = before; for (const name of path) { parent[name] ??= {}; @@ -221,18 +342,19 @@ export function addJsonMember(text: string, path: readonly string[], key: string parent[key] = value; let after: unknown; try { - after = JSON.parse(out); + after = parseText(out, comments); } catch { - return { ok: false, reason: "the edit would not stay plain JSON" }; + return { ok: false, reason: comments ? "the edit would not stay valid JSON" : "the edit would not stay plain JSON" }; } if (!sameJson(after, before)) return { ok: false, reason: "the edit would change more than one entry" }; return { ok: true, text: prep.bom + out, changed: true }; } /** `text` without `key` at `path`; unchanged when it is not there. */ -export function removeJsonMember(text: string, path: readonly string[], key: string): JsonEdit { +export function removeJsonMember(text: string, path: readonly string[], key: string, options: JsonEditOptions = {}): JsonEdit { + const comments = options.comments === true; if (!text.trim()) return { ok: true, text, changed: false }; - const prep = prepare(text); + const prep = prepare(text, comments); if ("error" in prep) return { ok: false, reason: prep.error }; let out: string; try { @@ -245,21 +367,39 @@ export function removeJsonMember(text: string, path: readonly string[], key: str if (target.members.filter((m) => m.key === key).length > 1) return { ok: false, reason: `it has "${key}" twice` }; const member = target.members[index]!; const t = prep.body; - if (target.members.length === 1) out = `${t.slice(0, target.open + 1)}${t.slice(target.close)}`; - else if (index > 0) out = `${t.slice(0, target.members[index - 1]!.valueEnd)}${t.slice(member.valueEnd)}`; - else out = `${t.slice(0, member.start)}${t.slice(target.members[1]!.start)}`; + // The cut: the member and its comma, with the whitespace between them. + const [from, to] = + target.members.length === 1 + ? [target.open + 1, target.close] + : index > 0 + ? [target.members[index - 1]!.valueEnd, member.valueEnd] + : [member.start, target.members[1]!.start]; + const beside = t.slice(from, member.start) + t.slice(member.valueEnd, Math.max(member.valueEnd, to)); + if (/^[\s,]*$/.test(beside)) out = `${t.slice(0, from)}${t.slice(to)}`; + else { + // A comment beside the member stays: only the member (from its line's start) and one comma go. + const start = cutStart(t, member.start); + if (target.members.length === 1) out = `${t.slice(0, start)}${t.slice(member.valueEnd)}`; + else if (index > 0) { + const comma = commaAfter(t, target.members[index - 1]!.valueEnd); + out = `${t.slice(0, comma)}${t.slice(comma + 1, start)}${t.slice(member.valueEnd)}`; + } else { + const comma = commaAfter(t, member.valueEnd); + out = `${t.slice(0, start)}${t.slice(member.valueEnd, comma)}${t.slice(comma + 1)}`; + } + } } catch (error) { - return { ok: false, reason: error instanceof ScanError ? "it is not plain JSON" : String(error) }; + return { ok: false, reason: error instanceof ScanError ? notValid(comments) : String(error) }; } - const before = JSON.parse(prep.body) as Record; + const before = parseText(prep.body, comments) as Record; let parent: Record = before; for (const name of path) parent = parent[name] as Record; delete parent[key]; let after: unknown; try { - after = JSON.parse(out); + after = parseText(out, comments); } catch { - return { ok: false, reason: "the edit would not stay plain JSON" }; + return { ok: false, reason: comments ? "the edit would not stay valid JSON" : "the edit would not stay plain JSON" }; } if (!sameJson(after, before)) return { ok: false, reason: "the edit would change more than one entry" }; return { ok: true, text: prep.bom + out, changed: true }; diff --git a/packages/server/uninstall.test.ts b/packages/server/uninstall.test.ts index bf8a10464d..1f785350db 100644 --- a/packages/server/uninstall.test.ts +++ b/packages/server/uninstall.test.ts @@ -2226,6 +2226,30 @@ describe("the Inbox's agent connections (inbox-agents.ts)", () => { expect((readJson(join(fixture.dataDir, "inbox", "agents.json")).agents as Record).cursor).toBeUndefined(); }); + test("an agent with one entry left (of two a move left) keeps its whole record, so the next uninstall tries again", async () => { + const fixture = createFixture(); + const stray = join(fixture.homeDir, "elsewhere", "mcp.json"); + writeJson(stray, { mcpServers: { "plannotator-inbox": ours } }); + // Its own entry cannot go: the key is there twice, which Plannotator never cuts around (on every platform, + // unlike a read-only folder, which Windows ignores). The other one can. + const twice = `{\n "mcpServers": {\n "plannotator-inbox": ${JSON.stringify(ours)},\n "plannotator-inbox": ${JSON.stringify(ours)}\n }\n}\n`; + writeText(cursorFile(fixture), twice); + const record = { + state: "added", + reach: "mcp", + path: cursorFile(fixture), + command: ["/old/bin/plannotator", "inbox", "mcp"], + strays: [{ path: stray, command: ["/old/bin/plannotator", "inbox", "mcp"] }], + at: "2026-10-12T00:00:00.000Z", + }; + writeJson(join(fixture.dataDir, "inbox", "agents.json"), { v: 1, agents: { cursor: record } }); + const result = await runPlannotatorUninstall({ purge: false, dryRun: false }, fixture.environment); + expect(readJson(stray).mcpServers).toEqual({}); + expect(readFileSync(cursorFile(fixture), "utf8")).toBe(twice); + expect(result.warnings.some((warning) => warning.startsWith("Could not remove MCP server plannotator-inbox from Cursor"))).toBe(true); + expect((readJson(join(fixture.dataDir, "inbox", "agents.json")).agents as Record).cursor).toEqual(record); + }); + test("the notes: only the marked section, from a file the record no longer names too", async () => { const fixture = createFixture(); const note = [ diff --git a/packages/server/uninstall.ts b/packages/server/uninstall.ts index 2fc57a799d..8831254c15 100644 --- a/packages/server/uninstall.ts +++ b/packages/server/uninstall.ts @@ -591,17 +591,20 @@ async function removeInboxAgentConnections( else state.warnings.push(`Could not remove the note in ${note.path}: ${result.reason}.`); } const removed: InboxAgentId[] = []; + const kept = new Set(); for (const item of plan.items) { const problem = await item.apply(); if (problem) { state.warnings.push(`Could not remove ${item.describe}: ${problem}.`); + kept.add(item.agent); continue; } state.removed.push(item.describe); removed.push(item.agent); } - // A plain uninstall keeps the data: forget these records so a reinstall connects them again. - if (!request.purge) forgetInboxAgents(state.dataDir, removed); + // A plain uninstall keeps the data: forget these records so a reinstall connects them again. An agent with an + // entry left (one of two a move left could not go) keeps its whole record, so the next uninstall tries again. + if (!request.purge) forgetInboxAgents(state.dataDir, removed.filter((id) => !kept.has(id))); } function formatUninstallRetryCommand(request: UninstallRequest): string { diff --git a/packages/shared/inbox/qwen-sessions.ts b/packages/shared/inbox/qwen-sessions.ts new file mode 100644 index 0000000000..481ab90c8a --- /dev/null +++ b/packages/shared/inbox/qwen-sessions.ts @@ -0,0 +1,148 @@ +/** + * Qwen Code's session registry, read only: `$QWEN_HOME/sessions/.json` + * (or `-<8 hex>.json` for a process hosting several sessions), one + * record per running session, written by Qwen itself (its cross-session + * protocol, schema version 1). The stdio shim reads its parent's record on + * every tool call, because `/clear` and `/resume` give the session a new id + * under the same process while the MCP server keeps the environment it was + * started with. Plannotator never writes or deletes a record. + */ + +import { lstatSync, readdirSync, readFileSync } from "node:fs"; +import { isAbsolute, join, resolve } from "node:path"; +import { parseQwenSessionRecord, qwenRecordFilePid, type InboxQwenSessionRecord } from "@plannotator/core/inbox-types"; + +/** A record is a few hundred bytes; anything far larger is not one. */ +const RECORD_MAX_BYTES = 64 * 1024; + +/** A `.env` file is a few lines; anything far larger is not read. */ +const DOTENV_MAX_BYTES = 256 * 1024; + +/** dotenv's line rule (the `dotenv` package Qwen uses): `[export ]KEY=value`, quoted or bare, `#` comments. */ +const DOTENV_LINE = /(?:^|^)\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?(?:$|$)/gm; + +/** One key of a `.env` file as `dotenv.parse` reads it (the last line wins); undefined when the file or key is missing. */ +export function dotenvValue(path: string, key: string): string | undefined { + let text: string; + try { + const stat = lstatSync(path); + if (!stat.isFile() || stat.size > DOTENV_MAX_BYTES) return undefined; + text = readFileSync(path, "utf8"); + } catch { + return undefined; + } + let found: string | undefined; + for (const match of text.replace(/\r\n?/gm, "\n").matchAll(DOTENV_LINE)) { + if (match[1] !== key) continue; + let value = (match[2] ?? "").trim(); + const quote = value[0]; + value = value.replace(/^(['"`])([\s\S]*)\1$/gm, "$2"); + if (quote === '"') value = value.replace(/\\n/g, "\n").replace(/\\r/g, "\r"); + found = value; + } + return found; +} + +/** + * `QWEN_HOME` as a Qwen process started with `env` sees it: the variable when + * it is in the environment (even empty), else the first of + * `~/.qwen/.env` and `~/.env` that sets it (Qwen reads both before anything + * else); empty or absent: unset. Not trimmed (Qwen does not trim it). + */ +export function qwenHomeSetting(env: Readonly>, homeDir: string): string | null { + if (Object.hasOwn(env, "QWEN_HOME") && env.QWEN_HOME !== undefined) return env.QWEN_HOME || null; + for (const file of [join(homeDir, ".qwen", ".env"), join(homeDir, ".env")]) { + const value = dotenvValue(file, "QWEN_HOME"); + if (value) return value; + } + return null; +} + +/** A `QWEN_HOME` value with `~` expanded as Qwen does (`~`, `~/…`, `~\…`); still relative when it was. */ +function expandQwenHome(value: string, homeDir: string): string { + if (value === "~" || value.startsWith("~/") || value.startsWith("~\\")) { + return join(homeDir, ...(value === "~" ? [] : value.slice(2).split(/[/\\]+/).filter(Boolean))); + } + return value; +} + +/** + * Whether Qwen's folder depends on the folder Qwen runs in (`QWEN_HOME` is a + * relative path), so no one folder is Qwen's for every session. + */ +export function qwenHomeIsRelative(env: Readonly>, homeDir: string): boolean { + const value = qwenHomeSetting(env, homeDir); + return value !== null && !isAbsolute(expandQwenHome(value, homeDir)); +} + +/** + * Qwen Code's home folder as Qwen resolves it (its `Storage.getGlobalQwenDir`): + * `QWEN_HOME` (`qwenHomeSetting`: the environment, else Qwen's `.env` files) + * IS the folder (not a parent of `.qwen`, unlike Gemini's `GEMINI_CLI_HOME`), + * a leading `~` expanded, a relative one resolved against `cwd` (the folder + * the Qwen process runs in); else `~/.qwen`. + */ +export function qwenHome(env: Readonly>, homeDir: string, cwd: string): string { + const value = qwenHomeSetting(env, homeDir); + if (value === null) return join(homeDir, ".qwen"); + const expanded = expandQwenHome(value, homeDir); + return isAbsolute(expanded) ? resolve(expanded) : resolve(cwd, expanded); +} + +export function qwenSessionsDir(home: string): string { + return join(home, "sessions"); +} + +/** One record file, read and checked; null when it is missing, not a plain file, too large, not JSON or not usable. */ +export function readQwenSessionRecord(path: string, file: { pid: number; shared: boolean }): InboxQwenSessionRecord | null { + try { + const stat = lstatSync(path); + if (!stat.isFile() || stat.size > RECORD_MAX_BYTES) return null; + return parseQwenSessionRecord(JSON.parse(readFileSync(path, "utf8")) as unknown, file); + } catch { + return null; + } +} + +/** Every usable record in the registry (none when the folder is missing). */ +export function listQwenSessionRecords(home: string): InboxQwenSessionRecord[] { + const dir = qwenSessionsDir(home); + let names: string[]; + try { + names = readdirSync(dir); + } catch { + return []; + } + const records: InboxQwenSessionRecord[] = []; + for (const name of names) { + const file = qwenRecordFilePid(name); + if (!file) continue; + const record = readQwenSessionRecord(join(dir, name), file); + if (record) records.push(record); + } + return records; +} + +/** + * The session the Qwen process `pid` runs now: its `.json`, else its one + * `-<8 hex>.json`. Null when it has none, or several (a process hosting + * several sessions: only the call itself can say which). + */ +export function qwenSessionOfProcess(home: string, pid: number): InboxQwenSessionRecord | null { + if (!Number.isSafeInteger(pid) || pid <= 1) return null; + const dir = qwenSessionsDir(home); + const bare = readQwenSessionRecord(join(dir, `${pid}.json`), { pid, shared: false }); + if (bare) return bare; + let names: string[]; + try { + names = readdirSync(dir); + } catch { + return null; + } + const mine = names.filter((name) => name.startsWith(`${pid}-`)).flatMap((name) => { + const file = qwenRecordFilePid(name); + const record = file && file.pid === pid ? readQwenSessionRecord(join(dir, name), file) : null; + return record ? [record] : []; + }); + return mine.length === 1 ? mine[0]! : null; +} diff --git a/packages/shared/package.json b/packages/shared/package.json index 7606d483a4..8ed512418d 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -138,6 +138,7 @@ "./inbox/project-batch": "./inbox/project-batch.ts", "./inbox/search": "./inbox/search.ts", "./inbox/registry": "./inbox/registry.ts", + "./inbox/qwen-sessions": "./inbox/qwen-sessions.ts", "./inbox/devices": "./inbox/devices.ts", "./inbox/attachments": "./inbox/attachments.ts", "./inbox/video": "./inbox/video.ts", diff --git a/tests/helpers/fake-agents.ts b/tests/helpers/fake-agents.ts index 7821fe23ae..3995ad4a88 100644 --- a/tests/helpers/fake-agents.ts +++ b/tests/helpers/fake-agents.ts @@ -26,7 +26,7 @@ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSyn import { tmpdir } from "node:os"; import { join } from "node:path"; -export const FAKE_AGENT_BINS = ["codex", "gemini", "devin", "amp", "cline", "pi", "opencode", "cursor-agent", "kiro-cli"] as const; +export const FAKE_AGENT_BINS = ["codex", "gemini", "qwen", "devin", "amp", "cline", "pi", "opencode", "cursor-agent", "kiro-cli"] as const; export type FakeAgentBin = (typeof FAKE_AGENT_BINS)[number]; export interface FakeAgents { @@ -67,13 +67,15 @@ const list = (v) => (v || "").split(",").filter(Boolean); if (args[0] === "--fake-grandchild") setTimeout(() => {}, 30000); else start(); function start() { + // FAKE_AGENT_CWD_LOG: every run (--version too) with the folder it ran in. + if (process.env.FAKE_AGENT_CWD_LOG) fs.appendFileSync(process.env.FAKE_AGENT_CWD_LOG, JSON.stringify({ name, args, cwd: process.cwd() }) + "\n"); // Each real CLI's --version line (FAKE__VERSION overrides it), answered before anything is recorded. if (args[0] === "--version") { if (list(process.env.FAKE_AGENT_HANG_VERSION).includes(name)) { setInterval(() => {}, 1000); return; } - const versions = { codex: "codex-cli 0.160.0", gemini: "0.43.0", devin: "devin 2026.7.16 (4723f912)", amp: "0.0.1781669741-g230fdd (released 2026-06-17)", pi: "0.99.1" }; + const versions = { codex: "codex-cli 0.160.0", gemini: "0.43.0", qwen: "0.26.2", devin: "devin 2026.7.16 (4723f912)", amp: "0.0.1781669741-g230fdd (released 2026-06-17)", pi: "0.99.1" }; const override = process.env["FAKE_" + name.toUpperCase().replace(/-/g, "_") + "_VERSION"]; process.stdout.write((override !== undefined ? override : versions[name] || "1.0.0") + "\n"); process.exit(0); @@ -157,6 +159,30 @@ function main() { case "gemini": jsonAgent(path.join(fakeHome, ".gemini", "settings.json"), "mcpServers", 4, 5); break; + case "qwen": { + // As Qwen Code 0.26 does: it writes back every server as it resolved them, $VAR and \${VAR} filled in from + // its environment (so Plannotator never runs it on settings.json), and logs a failed write and exits 0. + const home = process.env.QWEN_HOME ? path.resolve(process.env.QWEN_HOME) : path.join(fakeHome, ".qwen"); + const file = path.join(home, "settings.json"); + const config = readJson(file); + const resolve = (v) => + typeof v === "string" + ? v.replace(/\$\{(\w+)\}|\$(\w+)/g, (m, a, b) => process.env[a || b] ?? m) + : Array.isArray(v) ? v.map(resolve) : v && typeof v === "object" ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, resolve(x)])) : v; + const servers = resolve(config.mcpServers || {}); + if (args[1] === "add") { + const words = args.slice(5); + servers[args[4]] = { command: words[0], args: words.slice(1) }; + } else if (args[1] === "remove") delete servers[args[args.length - 1]]; + config.mcpServers = servers; + try { + fs.mkdirSync(home, { recursive: true }); + fs.writeFileSync(file, JSON.stringify(config, null, 2) + "\n"); + } catch (error) { + process.stderr.write("Error saving user settings file: " + error.message + "\n"); + } + break; + } case "devin": jsonAgent(path.join(xdg, "devin", "config.json"), "mcpServers", 4, 5); break; diff --git a/tests/helpers/real-inbox-scan.ts b/tests/helpers/real-inbox-scan.ts index 6fdb0a8bbf..0e0ee309ef 100644 --- a/tests/helpers/real-inbox-scan.ts +++ b/tests/helpers/real-inbox-scan.ts @@ -150,7 +150,7 @@ export function homePathFindings(source: string): string[] { } /** The agents' own folders the Inbox writes to (packages/server/inbox-agents.ts, its notes). */ -const AGENT_DIR = String.raw`(?:\.claude|\.codex|\.gemini|\.cursor|\.kiro|\.pi|\.vibe|\.copilot|\.config["'\`]?\s*,\s*["'\`](?:opencode|amp|devin|Claude)|\.config[/\\](?:opencode|amp|devin|Claude)|Library[/\\]Application Support[/\\]Claude|Library["'\`]\s*,\s*["'\`]Application Support)(?![\w.-])`; +const AGENT_DIR = String.raw`(?:\.claude|\.codex|\.gemini|\.qwen|\.cursor|\.kiro|\.pi|\.vibe|\.copilot|\.config["'\`]?\s*,\s*["'\`](?:opencode|amp|devin|Claude)|\.config[/\\](?:opencode|amp|devin|Claude)|Library[/\\]Application Support[/\\]Claude|Library["'\`]\s*,\s*["'\`]Application Support)(?![\w.-])`; /** * A path from the real home directory to an agent's own folder (`~/.codex`, diff --git a/tests/never-touch-the-real-inbox.test.ts b/tests/never-touch-the-real-inbox.test.ts index c6ad36ae14..ebc13fef1c 100644 --- a/tests/never-touch-the-real-inbox.test.ts +++ b/tests/never-touch-the-real-inbox.test.ts @@ -135,6 +135,9 @@ describe("the source scan", () => { expect(agentHomeFindings('const file = join(homedir(), ".codex", "config.toml");')).toHaveLength(1); expect(agentHomeFindings('const home = os.homedir();\nconst file = join(home, ".config", "opencode", "AGENTS.md");')).toHaveLength(1); expect(agentHomeFindings('writeFileSync("~/.cursor/mcp.json", "{}");')).toHaveLength(1); + // Qwen Code's folder holds its settings, session registry and controller tokens. + expect(agentHomeFindings('const file = join(homedir(), ".qwen", "settings.json");')).toHaveLength(1); + expect(agentHomeFindings('const file = join(fake.home, ".qwen", "settings.json");')).toEqual([]); expect(agentHomeFindings('const file = join(fake.home, ".codex", "config.toml");')).toEqual([]); expect(agentConnectFindings('await fetch(`${url}/api/inbox/control/connect`, { method: "POST" });')).toHaveLength(1); expect(agentConnectFindings('startInboxServer({ dataDir, agentEnvironment: env });\nawait s.connectAgents();')).toEqual([]);