Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
c5e58a0
Inbox: connect Qwen Code and tag its sessions
backnotprop Oct 28, 2026
edea5c4
Merge remote-tracking branch 'origin/main' into feat/inbox-qwen-connect
backnotprop Oct 28, 2026
694d747
Merge remote-tracking branch 'origin/main' into feat/inbox-qwen-connect
backnotprop Oct 28, 2026
877518b
Inbox: edit Qwen's settings.json directly, fix its client name
backnotprop Oct 28, 2026
1ec24ee
Docs: a Qwen record's bad kind reads as tui
backnotprop Oct 28, 2026
ce799a2
Merge remote-tracking branch 'origin/main' into feat/inbox-qwen-connect
backnotprop Oct 28, 2026
8a6e062
Inbox: act on the recorded Qwen settings file; writer fixes
backnotprop Oct 28, 2026
27720ee
Merge remote-tracking branch 'origin/main' into feat/inbox-qwen-connect
backnotprop Oct 28, 2026
c61e75f
Inbox: move Qwen's entry to a new QWEN_HOME only from Settings
backnotprop Oct 28, 2026
dc7ce5f
Merge remote-tracking branch 'origin/main' into feat/inbox-qwen-connect
backnotprop Oct 28, 2026
cebe8b7
Merge remote-tracking branch 'origin/main' into feat/inbox-qwen-connect
backnotprop Oct 28, 2026
149b099
Inbox: Qwen connected in another home shows off in Settings
backnotprop Oct 28, 2026
28bb7f9
Merge remote-tracking branch 'origin/main' into feat/inbox-qwen-connect
backnotprop Oct 28, 2026
6f5336a
Inbox: never fold case for Qwen homes; keep a move's stray entry thro…
backnotprop Oct 28, 2026
4578f97
Merge remote-tracking branch 'origin/main' into feat/inbox-qwen-connect
backnotprop Oct 28, 2026
73d25ad
Inbox: a stray Qwen entry is checked before uninstall acts; the recor…
backnotprop Oct 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions apps/hook/server/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ids> 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;",
Expand Down
123 changes: 122 additions & 1 deletion apps/hook/server/inbox-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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-<server name>`, 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, unknown> = {}): 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<string, unknown> = {}, 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 (`<pid>-<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();
Expand Down
4 changes: 3 additions & 1 deletion apps/hook/server/inbox-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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. */
Expand Down Expand Up @@ -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);
}
Expand Down
2 changes: 1 addition & 1 deletion apps/hook/server/inbox-help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}.`,
"",
Expand Down
Loading
Loading