diff --git a/docs/design/2026-09-14-cross-session-messaging-default-on.md b/docs/design/2026-09-14-cross-session-messaging-default-on.md new file mode 100644 index 00000000000..cba26aec122 --- /dev/null +++ b/docs/design/2026-09-14-cross-session-messaging-default-on.md @@ -0,0 +1,111 @@ +# Cross-session messaging: on by default + +[English](2026-09-14-cross-session-messaging-default-on.md) | [简体中文](2026-09-14-cross-session-messaging-default-on.zh-CN.md) + +Status: implemented alongside this document. + +## Problem + +`agents.crossSessionMessaging` has been opt-in since the feature landed. +Every session started with it off: invisible to the other sessions of +the same user, unreachable by them, and unable to address them. That was +the right posture while the pieces that make a reachable session safe +were still arriving, one pull request at a time. + +They have arrived. An inbox authenticates every connection; a message +from another session is delivered only when both sessions are in the +same review class and is held for the user otherwise; a repository can +make a session more cautious but never less; a sender that outruns the +session is dropped and told; a program the user trusts presents a token +the user minted. With all of that in place, the switch being off by +default no longer protects anything — it only means the feature is not +there for the people it was built for. A voice front-end that joins +through the SDK's peer endpoint cannot see a session whose user never +found the setting, and two sessions in one repository cannot tell each +other a build finished. + +## Design + +**The schema default becomes `true`.** Nothing else about the setting +changes: `false` still turns the feature off for that session, restart is +still required, and the workspace may still only tighten. + +**An unset key is read as on, in one place.** Merged settings carry only +what some scope actually wrote, so the schema default never reaches the +code as a value — every reader saw `undefined` and compared it to `true`. +The four readers (interactive startup, the ACP agent, `/peers`, and the +inbox-failure notice) now ask one helper, which answers on for `true` and +for `undefined`, and off for `false` and for anything it does not +recognize. The last case is deliberate: a value the reader cannot +interpret must not open a socket. + +**The workspace ranking follows.** A workspace value is kept only when it +is stricter than what the user or the platform set, and "what they set" +was compared as if unset meant off. With unset meaning on, a repository's +`false` would have ranked equal to an unset user scope and been dropped +— a repository could never turn messaging off. Unset now ranks with +`true`, so `false` is a tightening and is kept; a workspace `true` repeats +the default and is dropped without a warning. + +**The words change with the default.** `/peers` on a session with no inbox +no longer tells the user to enable a setting that is already on; it +distinguishes "turned off in settings" from "the inbox could not start". +The controller-token hint, the `send_message` error, the settings +reference, the SDK guides and the messaging section of the commands page +say what turning the feature _off_ looks like instead of how to turn it +on. + +## Trade-offs + +**No first-run notice.** A line in the transcript saying "other sessions +can now see this one" would need a per-home marker file to show once, and +would repeat for every scoped home. The documentation and `/peers` carry +the explanation; the review rules hold what another session sends for the +user to decide, with the two exceptions below. + +**Two senders are delivered without review, and the default now reaches +them.** A process this session starts inherits its child token and is +delivered as the session's own: that is what lets a hook or a build script +report back, and it is documented as such. Review-class parity compares a +class the sender asserts, and nothing authenticates that assertion, so a +same-user process that can read the registry can claim the receiving +session's own class. Both were already true for anyone who turned +messaging on; on by default extends them to users who never looked at the +setting. Neither widens the boundary — both need code already running as +the same user inside the `0700`/`0600` directories, and such code can do +anything that user can — so they are recorded here, and in the setting's +description, rather than gated. Gating the child-token export on an +explicit opt-in would take a documented hook pattern away from default +users. A user who wants every such message reviewed sets +`agents.crossSessionInbound` to `hold`. + +**Windows gets no inbox, and says so only when asked.** Automatic inbox +paths are not available there, and the inbox degrades to "unsupported +platform". Startup stays quiet unless someone wrote `true` in their user +settings or this workspace's — an operator's system-defaults file does not +count. `/peers` answers that messaging is not available on this platform, +as information rather than an error. An ACP process stops trying to bind +after its first hosted session learns this, instead of retrying for every +session. Nothing else changes for those users until named pipes land. + +**An unrecognized value is off, not on.** The previous readers treated +anything but `true` as off, and the workspace ranking already counted an +unrecognized value as the strict one. Keeping that for the new default +means a typo cannot open a session by accident. + +## Files + +- `packages/cli/src/peerMessaging/enabled.ts` — the one reader of the + switch. +- `packages/cli/src/config/settingsSchema.ts` — the default and its + description; the generated JSON schema follows. +- `packages/cli/src/config/settingsUtils.ts` — unset ranks as on. +- `packages/cli/src/ui/startInteractiveUI.tsx`, + `packages/cli/src/acp-integration/acpAgent.ts`, + `packages/cli/src/ui/commands/peers-command.ts` — read through the + helper. +- `packages/cli/src/commands/sessions/controllers.ts`, + `packages/core/src/tools/send-message.ts` — wording. +- `docs/users/features/commands.md`, `docs/users/configuration/settings.md`, + `docs/developers/sdk-typescript.md`, `packages/sdk-typescript/README.md` + — the default, and how to turn it off. diff --git a/docs/design/2026-09-14-cross-session-messaging-default-on.zh-CN.md b/docs/design/2026-09-14-cross-session-messaging-default-on.zh-CN.md new file mode 100644 index 00000000000..84e9b1af2d8 --- /dev/null +++ b/docs/design/2026-09-14-cross-session-messaging-default-on.zh-CN.md @@ -0,0 +1,40 @@ +# 跨会话消息:默认开启 + +[English](2026-09-14-cross-session-messaging-default-on.md) | [简体中文](2026-09-14-cross-session-messaging-default-on.zh-CN.md) + +状态:随本文档一起实现。 + +## 问题 + +`agents.crossSessionMessaging` 自落地以来一直是 opt-in。每个会话启动时都是关着的:同一用户的其它会话看不见它、联系不到它,它也联系不到别人。在让"可达的会话"变得安全的那些零件还在一个个 PR 地到来时,这是正确的姿态。 + +现在它们都到了。inbox 对每个连接做认证;来自另一个会话的消息只在两个会话处于同一审查类别时投递,否则 hold 给用户;仓库可以让会话更谨慎,但不能更宽松;发得比会话能接的快的发送方会被丢弃并被告知;用户信任的程序出示用户亲手铸造的令牌。这些都到位之后,默认关闭不再保护任何东西——它只意味着这个功能对它本来要服务的人不存在。通过 SDK peer 端点接入的语音前端看不到一个用户从没找到这个设置的会话;同一仓库里的两个会话也没法互相说一句"构建完成了"。 + +## 设计 + +**schema 默认值改为 `true`。** 这个设置的其它方面不变:`false` 仍然关闭该会话的功能,仍然需要重启,工作区仍然只能收紧。 + +**未设置的键读作开,只在一处。** 合并后的设置只包含某个作用域确实写了的内容,所以 schema 默认值从不会以值的形式到达代码——每个读取点看到的都是 `undefined`,然后拿它和 `true` 比较。四个读取点(交互式启动、ACP agent、`/peers`、inbox 失败提示)现在都问同一个辅助函数:`true` 和 `undefined` 答开,`false` 和任何不认识的值答关。最后一条是刻意的:读取方无法解释的值不能打开 socket。 + +**工作区排名跟着变。** 工作区的值只在严于用户或平台所设时才保留,而"他们所设"此前是按"未设置即关"来比较的。未设置改为开之后,仓库的 `false` 会和未设置的用户作用域同级而被丢弃——仓库将永远无法关闭消息。现在未设置与 `true` 同级,所以 `false` 是收紧、会保留;工作区的 `true` 只是重复默认值,无警告地丢弃。 + +**措辞随默认值改。** `/peers` 在没有 inbox 的会话上不再让用户去启用一个已经开着的设置,而是区分"在设置里关掉了"和"inbox 没能启动"。controller 令牌的提示、`send_message` 的错误、设置参考、SDK 指南和 commands 页的消息章节都改成说明"关掉"是什么样子,而不是怎么打开。 + +## 取舍 + +**不做首次提示。** 转录里一行"其它会话现在能看到本会话"需要按 home 放标记文件才能只显示一次,而且每个 scoped home 都会再来一次。文档和 `/peers` 承担解释;审查规则会把其它会话发来的内容 hold 给用户决定,下面两种情况除外。 + +**有两类发送方不经审查就投递,默认开启后它们覆盖到了所有人。** 本会话启动的进程继承它的子进程令牌,会被当作本会话自己的消息投递:hook 或构建脚本能回报结果靠的就是这一点,文档里也写明了。审查类别 parity 比较的是发送方自己声明的类别,而这个声明没有任何认证,所以能读取注册表的同用户进程可以声称自己和接收会话同类。这两点对手动开启过的用户早就成立;默认开启把它们扩展到了从没看过这个设置的用户。两者都没有扩大边界——都需要已经以同一用户身份在 `0700`/`0600` 目录内运行的代码,而这样的代码本来就能做该用户能做的任何事——所以在这里和设置描述里记录下来,而不是加闸。把子进程令牌的导出限制为显式开启,会让默认用户失去一种文档里写明的 hook 用法。想让这类消息全部经过审查的用户,把 `agents.crossSessionInbound` 设为 `hold`。 + +**Windows 没有 inbox,只在被问到时才说明。** 那里没有自动 inbox 路径,inbox 降级为"平台不支持"。除非有人在自己的用户设置或本工作区设置里写了 `true`,否则启动时不提示——运维方的 system-defaults 文件不算。`/peers` 以普通信息而非错误的形式回答"本平台不支持跨会话消息"。ACP 进程在第一个托管会话得知这一点后就不再尝试绑定,不会每个会话都重试一遍。在命名管道落地之前,这些用户没有其它变化。 + +**不认识的值是关,不是开。** 此前的读取点把 `true` 之外的一切当作关,工作区排名也已经把不认识的值算作严格的一方。新默认值下保持这一点,意味着一个笔误不会意外打开一个会话。 + +## 文件 + +- `packages/cli/src/peerMessaging/enabled.ts` — 开关的唯一读取方。 +- `packages/cli/src/config/settingsSchema.ts` — 默认值及描述;生成的 JSON schema 随之更新。 +- `packages/cli/src/config/settingsUtils.ts` — 未设置与开同级。 +- `packages/cli/src/ui/startInteractiveUI.tsx`、`packages/cli/src/acp-integration/acpAgent.ts`、`packages/cli/src/ui/commands/peers-command.ts` — 通过辅助函数读取。 +- `packages/cli/src/commands/sessions/controllers.ts`、`packages/core/src/tools/send-message.ts` — 措辞。 +- `docs/users/features/commands.md`、`docs/users/configuration/settings.md`、`docs/developers/sdk-typescript.md`、`packages/sdk-typescript/README.md` — 默认值,以及如何关闭。 diff --git a/docs/developers/sdk-typescript.md b/docs/developers/sdk-typescript.md index d8487f70bff..26d4401a5fb 100644 --- a/docs/developers/sdk-typescript.md +++ b/docs/developers/sdk-typescript.md @@ -239,9 +239,9 @@ await endpoint.send({ Things to know: - A session has an inbox only while its `agents.crossSessionMessaging` setting - is on, and the setting is off by default. Without it the session does not - appear in `list()`, and its own `list_agents` and `send_message` cannot see - or reach the program either. `qwen sessions ps` lists the program regardless. + is on, which it is by default. A session that turned it off does not appear + in `list()`, and its own `list_agents` and `send_message` cannot see or + reach the program either. `qwen sessions ps` lists the program regardless. - A message is delivered without review in exactly two cases: the send presents a controller token (`controller: true`), or its `fromMode` names the receiving session's own review class. `fromMode` is a claim nothing diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 9dcb315a63a..d8f11a24e58 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -418,13 +418,13 @@ See [Memory](../features/memory) for details on how auto-memory works and how to #### agents -| Setting | Type | Description | Default | -| ------------------------------ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- | -| `agents.builtin.exploreModel` | string | Model selector for the built-in Explore subagent. Use `inherit` for the main session model, `fast` for `fastModel`, a model ID, or an `authType:model-id` selector. A custom same-name Explore agent keeps its own model configuration. Requires restart. | `inherit` | -| `agents.modelGrades` | object | Maps semantic grade names exposed to the Agent tool to model selectors. Requires restart. | `undefined` | -| `agents.allowedGrades` | array of strings | Optional whitelist of configured model grades the Agent tool may use. Requires restart. | `undefined` | -| `agents.crossSessionMessaging` | boolean | Experimental. Let Qwen Code sessions on this machine send each other messages over a per-session local socket. Turning it on opens this session to peer messages, makes it discoverable to others, and lets its model address them from `send_message`. Requires restart. A workspace may set this to `false` only; a workspace `true` is ignored, with a warning only when it would loosen the operator-set or default value. | `false` | -| `agents.crossSessionInbound` | enum | What happens to inbound cross-session messages: `accept` delivers them, `hold` parks them for `/peers` review without letting the model act, and `refuse` opts this session out. Unset means [user-minted controllers](../features/commands.md#trusted-controllers) and this session's own child processes auto-deliver, while other sessions use [review-class parity](../features/commands.md#6-messaging-another-running-session); other messages are held for review. A workspace may only tighten this (`hold` or `refuse`, when stricter than the operator-set value or the unset default); an effective unrecognized value holds every message. | `undefined` | +| Setting | Type | Description | Default | +| ------------------------------ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | +| `agents.builtin.exploreModel` | string | Model selector for the built-in Explore subagent. Use `inherit` for the main session model, `fast` for `fastModel`, a model ID, or an `authType:model-id` selector. A custom same-name Explore agent keeps its own model configuration. Requires restart. | `inherit` | +| `agents.modelGrades` | object | Maps semantic grade names exposed to the Agent tool to model selectors. Requires restart. | `undefined` | +| `agents.allowedGrades` | array of strings | Optional whitelist of configured model grades the Agent tool may use. Requires restart. | `undefined` | +| `agents.crossSessionMessaging` | boolean | Let Qwen Code sessions on this machine send each other messages over a per-session local socket. On by default: this session is discoverable by the others, takes peer messages under the review rules of `agents.crossSessionInbound`, and its model can address them from `send_message`. Two senders are delivered without review unless `agents.crossSessionInbound` is `hold` or `refuse`: processes this session starts, which inherit its child token, and a same-user process that claims this session's own review class, which nothing authenticates. Set to `false` to keep this session invisible and unreachable. Requires restart. A workspace may set this to `false` only; a workspace `true` is ignored, with a warning only when it would loosen an operator-set `false`. | `true` | +| `agents.crossSessionInbound` | enum | What happens to inbound cross-session messages: `accept` delivers them, `hold` parks them for `/peers` review without letting the model act, and `refuse` opts this session out. Unset means [user-minted controllers](../features/commands.md#trusted-controllers) and this session's own child processes auto-deliver, while other sessions use [review-class parity](../features/commands.md#6-messaging-another-running-session); other messages are held for review. A workspace may only tighten this (`hold` or `refuse`, when stricter than the operator-set value or the unset default); an effective unrecognized value holds every message. | `undefined` | #### permissions diff --git a/docs/users/features/commands.md b/docs/users/features/commands.md index 9ce2a7c9fb4..c2500f8e6e5 100644 --- a/docs/users/features/commands.md +++ b/docs/users/features/commands.md @@ -821,14 +821,17 @@ qwen sessions ps --json | jq -r .cwd ## 6. Messaging Another Running Session Two interactive sessions on the same machine can send each other -messages. The feature is experimental and **off by default**; turn it on -in `settings.json` and restart: +messages. The feature is **on by default**: a session is discoverable by +the other sessions of the same user on the machine, and what they send +it goes through the review rules below before its model sees any of it. +To keep a session invisible and unreachable, turn it off in +`settings.json` and restart: ```json -{ "agents": { "crossSessionMessaging": true } } +{ "agents": { "crossSessionMessaging": false } } ``` -Once on, the model in one session can discover the others with +With it on, the model in one session can discover the others with `list_agents` — each appears under `sessions` with the `name` that `qwen sessions ps --json` records (the table view may truncate long names) — and address one with `send_message` using @@ -860,8 +863,10 @@ A repository can make sessions opened in it more cautious, never less: a workspace `.qwen/settings.json` may set `agents.crossSessionInbound` to `hold` or `refuse`, or `agents.crossSessionMessaging` to `false`, and that value wins over a looser one in your user settings. A workspace -value that would loosen your setting (`accept`, or `true` for the -switch) is ignored with a warning, and a value the CLI does not +value that would loosen an operator-set one (`accept`, or `true` for the +switch when a user or system scope set it to `false`) is ignored with a +warning; a workspace `true` where nothing else set the switch merely +repeats the default and is dropped silently. A value the CLI does not recognize holds every message whenever it is the effective value. System settings override all of this, as they do for every setting. @@ -1051,8 +1056,8 @@ driven session's behalf; a sender is told at once instead of waiting out an expiry. Where a held message should surface for those sessions is not settled yet. -A session registers only while its own settings have -`agents.crossSessionMessaging` on. With it off it stays invisible, +A session registers unless its own settings turn +`agents.crossSessionMessaging` off. Turned off, it stays invisible, because the only reason to list a session nobody can message would be to advertise an address that never answers. diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 0162c983161..e86eb26ba26 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -49,6 +49,9 @@ const mockRegisterSession = vi.hoisted(() => { }); }); const mockPeerMessagingStart = vi.hoisted(() => vi.fn()); +const mockGetLastPeerInboxFailure = vi.hoisted(() => + vi.fn((): { cause: string } | null => null), +); vi.mock('../peerMessaging/peer-messaging.js', () => ({ PeerMessaging: { start: (...args: unknown[]) => mockPeerMessagingStart(...args), @@ -259,6 +262,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({ }, INVOCATION_CONTEXT_META_KEY: 'qwen-code/invocation', registerSession: mockRegisterSession, + getLastPeerInboxFailure: () => mockGetLastPeerInboxFailure(), PRIVATE_ACP_CAPABILITY_ENV: 'QWEN_CODE_PRIVATE_ACP_CAPABILITY', PRIVATE_PARENT_CAPABILITY_META_KEY: 'qwen-code/private-parent-capability', parseInvocationContext: vi.fn( @@ -4931,8 +4935,15 @@ describe('QwenAgent MCP SSE/HTTP support', () => { } } + // Messaging is on by default, and on it binds an inbox and registers + // every new session through mocks most suites here do not stage. The + // default settings turn it off; the daemon-session suite says on or off + // explicitly, which is the only place the switch is under test. function makeSessionSettings( - merged: Record = { mcpServers: {} }, + merged: Record = { + mcpServers: {}, + agents: { crossSessionMessaging: false }, + }, ) { return { merged, @@ -5201,7 +5212,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { } describe('daemon-managed sessions in the session registry', () => { - /** Settings with cross-session messaging on, which is off by default. */ + /** Settings with cross-session messaging explicitly on. */ function messagingOn() { return makeSessionSettings({ mcpServers: {}, @@ -5209,12 +5220,34 @@ describe('QwenAgent MCP SSE/HTTP support', () => { }); } + // Off has to be said: the switch is on by default, so settings that + // never mention the key are the *on* case. + function messagingOff() { + return makeSessionSettings({ + mcpServers: {}, + agents: { crossSessionMessaging: false }, + }); + } + + // Settings that never mention the switch: the on-by-default case. + function messagingUnset() { + return makeSessionSettings({ mcpServers: {} }); + } + beforeEach(() => { mockRegisterSession.mockClear(); mockPeerMessagingStart.mockReset(); mockPeerMessagingStart.mockResolvedValue({ close: vi.fn().mockResolvedValue(undefined), }); + mockGetLastPeerInboxFailure.mockReset(); + mockGetLastPeerInboxFailure.mockReturnValue(null); + }); + + // Messaging is on by default, so suites after this one bind an inbox + // too; hand them back the bare mock rather than this suite's fake. + afterEach(() => { + mockPeerMessagingStart.mockReset(); }); it('gives each hosted session a record of its own, and the shared inbox address', async () => { @@ -5291,9 +5324,9 @@ describe('QwenAgent MCP SSE/HTTP support', () => { // A record with no inbox behind it would advertise an address that // never answers, which is worse than not being listed at all. await setupSessionMocks('hosted-off'); - const { agent, agentPromise } = await bootInitializedAcpAgent( - makeSessionSettings(), - ); + vi.mocked(loadSettings).mockReturnValue(messagingOff()); + const { agent, agentPromise } = + await bootInitializedAcpAgent(messagingOff()); await agent.newSession({ cwd: '/tmp', mcpServers: [] }); expect(mockPeerMessagingStart).not.toHaveBeenCalled(); @@ -5353,6 +5386,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { // contract the docs state is about the session, and a session that // turned messaging off must not appear in any peer's listing. await setupSessionMocks('hosted-off'); + vi.mocked(loadSettings).mockReturnValue(messagingOff()); const { agent, agentPromise } = await bootInitializedAcpAgent(messagingOn()); await agent.newSession({ cwd: '/tmp', mcpServers: [] }); @@ -5384,8 +5418,60 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); + it('registers a hosted session whose own settings never mention the switch', async () => { + // On by default: an explicit true and false read the same through + // the old comparison and the helper alike, so only an unset key + // pins that a daemon-hosted session joins without a settings edit. + // The gate reads the session's settings, so the key is left out of + // those too, not only the process's boot settings. + await setupSessionMocks('hosted-unset'); + vi.mocked(loadSettings).mockReturnValue(messagingUnset()); + const { agent, agentPromise } = + await bootInitializedAcpAgent(messagingUnset()); + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + + await vi.waitFor(() => expect(mockPeerMessagingStart).toHaveBeenCalled()); + expect(mockRegisterSession).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'hosted-unset', slot: 'own' }), + ); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('does not retry the bind on a platform with no inbox transport', async () => { + // That refusal holds for every candidate path, so a second hosted + // session could never succeed where the first did not. + mockPeerMessagingStart.mockResolvedValue(null); + mockGetLastPeerInboxFailure.mockReturnValue({ + cause: 'unsupported_platform', + }); + await setupSessionMocks('hosted-platform-a'); + vi.mocked(loadSettings).mockReturnValue(messagingUnset()); + const { agent, agentPromise } = + await bootInitializedAcpAgent(messagingUnset()); + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + await vi.waitFor(() => + expect(mockPeerMessagingStart).toHaveBeenCalledTimes(1), + ); + // Let the settled null land before the next session asks. + await new Promise((resolve) => setTimeout(resolve, 0)); + + await setupSessionMocks('hosted-platform-b'); + vi.mocked(loadSettings).mockReturnValue(messagingUnset()); + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(mockPeerMessagingStart).toHaveBeenCalledTimes(1); + + mockConnectionState.resolve(); + await agentPromise; + }); + it('retries the inbox bind for the next session after a failed start', async () => { mockPeerMessagingStart.mockResolvedValue(null); + // Any cause but a missing transport is worth a retry: a runtime + // directory can be fixed, or free up, while the process runs. + mockGetLastPeerInboxFailure.mockReturnValue({ cause: 'permission' }); await setupSessionMocks('hosted-retry-a'); vi.mocked(loadSettings).mockReturnValue(messagingOn()); const { agent, agentPromise } = @@ -23826,7 +23912,10 @@ describe('QwenAgent session-management routing (rename / delete / list / branch function makeAcpSettings() { return { - merged: { mcpServers: {} }, + // Messaging is on by default, and on it registers every new session + // through a config these tests do not stage; this suite is about + // session routing, so it turns the switch off. + merged: { mcpServers: {}, agents: { crossSessionMessaging: false } }, getUserHooks: vi.fn().mockReturnValue({}), getProjectHooks: vi.fn().mockReturnValue({}), } as unknown as LoadedSettings; @@ -30029,6 +30118,11 @@ describe('sessionLanguage multi-session propagation', () => { getModes: vi.fn().mockReturnValue([]), getApprovalMode: vi.fn().mockReturnValue('default'), getSessionId: vi.fn().mockReturnValue('sid'), + // Messaging is on by default, so every published session registers + // itself through these; the registry itself is mocked at module level. + getCliVersion: vi.fn().mockReturnValue('9.9.9'), + trackSessionRegistration: vi.fn(), + updateSessionRegistryIpcPath: vi.fn().mockResolvedValue(undefined), getAuthType: vi.fn().mockReturnValue('api-key'), getAllConfiguredModels: vi.fn().mockReturnValue([]), getLlmClient: vi.fn().mockReturnValue({ diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index b4c79e7e76e..0f2bc694155 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -152,6 +152,7 @@ import { qualifySkillName, sessionIdContext, registerSession, + getLastPeerInboxFailure, SessionSourceService, SessionSourceError, } from '@qwen-code/qwen-code-core'; @@ -338,6 +339,7 @@ import { ACP_ERROR_CODES } from './errorCodes.js'; import { registerCleanup, runExitCleanup } from '../utils/cleanup.js'; import { QWEN_CODE_SERVE_ENV } from '../config/acp-channel-fallback.js'; import { PeerMessaging } from '../peerMessaging/peer-messaging.js'; +import { isCrossSessionMessagingEnabled } from '../peerMessaging/enabled.js'; import { startNonInteractiveOpenAILogHousekeeping } from '../services/housekeeping/scheduler.js'; import { appEvents, AppEvent } from '../utils/events.js'; import { @@ -4717,7 +4719,16 @@ class QwenAgent implements Agent { }); // A bind that could not start is not "started": the next hosted // session retries rather than the process staying dark until exit. - if (messaging === null) this.peerMessagingStart = null; + // Except for a platform with no inbox transport. That refusal is + // decided before any filesystem call and holds for every candidate + // path, so no later session in this process can succeed; keep the + // settled null so the attempt, and its log line, happen once. + if ( + messaging === null && + getLastPeerInboxFailure()?.cause !== 'unsupported_platform' + ) { + this.peerMessagingStart = null; + } return messaging; } catch (error) { debugLogger.error( @@ -4767,7 +4778,7 @@ class QwenAgent implements Agent { // from more than one workspace, and a record exists to be addressed, // so it is written only when that session's settings turn messaging // on. The process's startup settings answer for nobody else. - if (settings.merged.agents?.crossSessionMessaging !== true) return; + if (!isCrossSessionMessagingEnabled(settings.merged)) return; // Bound by the first session that needs it rather than at startup: an // ACP process with no session has nothing to advertise and nobody to // receive for, and this is also the first moment the agent exists. diff --git a/packages/cli/src/acp-integration/acpAgent.worktree.test.ts b/packages/cli/src/acp-integration/acpAgent.worktree.test.ts index 80f90430369..04096ed4247 100644 --- a/packages/cli/src/acp-integration/acpAgent.worktree.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.worktree.test.ts @@ -108,8 +108,8 @@ const { mockRestoreWorktreeContext, mockWithDaemonSpan } = vi.hoisted(() => { }); // The agent imports the peer-messaging transport statically; its own core -// imports would reach past this suite's exhaustive core mock, and nothing -// here turns messaging on. +// imports would reach past this suite's exhaustive core mock. Messaging is +// on by default, so the session settings below turn it off. vi.mock('../peerMessaging/peer-messaging.js', () => ({ PeerMessaging: { start: vi.fn() }, })); @@ -430,7 +430,10 @@ describe('QwenAgent loadSession — Phase C worktree context restore', () => { function makeSessionSettings() { return { - merged: { mcpServers: {} }, + // Messaging is on by default, and on it registers every loaded + // session through a config this suite does not stage. The suite is + // about worktree restore, so it turns the switch off. + merged: { mcpServers: {}, agents: { crossSessionMessaging: false } }, getUserHooks: vi.fn().mockReturnValue({}), getProjectHooks: vi.fn().mockReturnValue({}), } as unknown as LoadedSettings; diff --git a/packages/cli/src/commands/sessions/controllers.test.ts b/packages/cli/src/commands/sessions/controllers.test.ts index 75608ca103d..b2004b97baf 100644 --- a/packages/cli/src/commands/sessions/controllers.test.ts +++ b/packages/cli/src/commands/sessions/controllers.test.ts @@ -103,8 +103,8 @@ describe('controllers add', () => { addPeerController.mockResolvedValue({ record: record(), token: TOKEN }); await handleAdd({ label: 'voice bridge' }); const out = stdout.join('\n'); - expect(out).toContain('agents.crossSessionMessaging enabled'); - expect(out).toContain('restarted after enabling it'); + expect(out).toContain('agents.crossSessionMessaging turned off'); + expect(out).toContain('restarted after changing that setting'); expect(out).toContain('without per-message review'); expect(out).toContain('agents.crossSessionInbound'); }); diff --git a/packages/cli/src/commands/sessions/controllers.ts b/packages/cli/src/commands/sessions/controllers.ts index e7f1ae825c5..3b8d99c38c2 100644 --- a/packages/cli/src/commands/sessions/controllers.ts +++ b/packages/cli/src/commands/sessions/controllers.ts @@ -114,7 +114,7 @@ async function handleAdd(argv: AddArgs): Promise { ); writeStdoutLine(` {"msgV":1,"type":"auth","token":"${token}"}`); writeStdoutLine( - 'The target session must have agents.crossSessionMessaging enabled and be restarted after enabling it.', + 'The target session must not have agents.crossSessionMessaging turned off (it is on by default), and must be restarted after changing that setting.', ); writeStdoutLine( 'Messages it sends are delivered without per-message review, unless agents.crossSessionInbound is "hold" or "refuse".', diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index e528a3bc629..3dcf806f4a8 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -3853,15 +3853,17 @@ describe('Settings Loading and Merging', () => { expect(settings.merged.agents?.maxParallelAgents).toBe(4); const warnings = getSettingsWarnings(settings); - for (const key of [ - 'agents.crossSessionMessaging', - 'agents.crossSessionInbound', - ]) { - const warning = warnings.find((w) => w.includes(key)); - expect(warning).toBeDefined(); - expect(warning).toContain('would loosen the default value'); - expect(warning).toContain('only make this setting stricter'); - } + const inboundWarning = warnings.find((w) => + w.includes('agents.crossSessionInbound'), + ); + expect(inboundWarning).toBeDefined(); + expect(inboundWarning).toContain('would loosen the default value'); + expect(inboundWarning).toContain('only make this setting stricter'); + // The switch is on by default, so a workspace `true` only repeats + // what is already in force: dropped, but nothing to warn about. + expect( + warnings.some((w) => w.includes('agents.crossSessionMessaging')), + ).toBe(false); }); it('honors a workspace value that tightens the user value', () => { @@ -3934,7 +3936,9 @@ describe('Settings Loading and Merging', () => { const settings = loadSettings(MOCK_WORKSPACE_DIR); expect(settings.merged.agents?.crossSessionInbound).toBe('hold'); - expect(settings.merged.agents?.crossSessionMessaging).toBeUndefined(); + // The switch defaults to on, so a workspace `false` is a tightening + // against an unset user scope and is kept — also without a warning. + expect(settings.merged.agents?.crossSessionMessaging).toBe(false); expect( getSettingsWarnings(settings).some((w) => w.includes('crossSession')), ).toBe(false); @@ -3962,6 +3966,31 @@ describe('Settings Loading and Merging', () => { expect(warning).toContain('would loosen the User value'); }); + it('warns when a workspace true would reopen a switch the user turned off', () => { + // The one warning path left for this key: an operator scope's false + // outranks a workspace true. An unset operator scope would not reach + // it — a workspace true then repeats the default and drops silently. + (mockFsExistsSync as Mock).mockReturnValue(true); + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify({ + agents: { crossSessionMessaging: false }, + }); + if (p === MOCK_WORKSPACE_SETTINGS_PATH) + return JSON.stringify({ agents: { crossSessionMessaging: true } }); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + expect(settings.merged.agents?.crossSessionMessaging).toBe(false); + const warning = getSettingsWarnings(settings).find((w) => + w.includes('agents.crossSessionMessaging'), + ); + expect(warning).toContain('would loosen the User value'); + }); + it('lets System scope override a stricter workspace value, with a warning', () => { const systemSettingsPath = '/mock/system/settings.json'; process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH'] = systemSettingsPath; diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index ebc4e878a2a..78a4a4af39f 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -360,14 +360,16 @@ describe('SettingsSchema', () => { expect(exploreModel.showInDialog).toBe(false); }); - it('should keep cross-session messaging off by default', () => { - // The default is the entire security posture of the feature: shipping - // it flipped on would open every session on the box to peer messages. + it('should keep cross-session messaging on by default', () => { + // On by default since docs/design/2026-09-14-cross-session-messaging-default-on.md. + // This value is not what bounds a peer: the inbound gate is (review-class + // parity, an explicit hold or refuse), with the tighten-only workspace + // ranking and the per-session inbox token around it. const crossSessionMessaging = getSettingsSchema().agents.properties.crossSessionMessaging; expect(crossSessionMessaging.type).toBe('boolean'); - expect(crossSessionMessaging.default).toBe(false); + expect(crossSessionMessaging.default).toBe(true); expect(crossSessionMessaging.requiresRestart).toBe(true); expect(crossSessionMessaging.showInDialog).toBe(false); }); diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 076d226a9c6..1fa101e86a5 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -3534,9 +3534,9 @@ const SETTINGS_SCHEMA = { label: 'Cross-Session Messaging', category: 'Advanced', requiresRestart: true, - default: false, + default: true, description: - 'Experimental. Let Qwen Code sessions on this machine send each other messages over a per-session local socket. Off by default; turning it on opens this session to peer messages, makes it discoverable to others, and lets its model address them from send_message.', + 'Let Qwen Code sessions on this machine send each other messages over a per-session local socket. On by default: this session is discoverable by the others, takes peer messages under the review rules of agents.crossSessionInbound, and its model can address them from send_message. Two senders are delivered without review unless agents.crossSessionInbound is "hold" or "refuse": processes this session starts, which inherit its child token, and a same-user process that claims this session\'s own review class, which nothing authenticates. Set to false to keep this session invisible and unreachable.', showInDialog: false, }, crossSessionInbound: { diff --git a/packages/cli/src/config/settingsUtils.test.ts b/packages/cli/src/config/settingsUtils.test.ts index ec0ab9a8f5f..30ef7ff6b63 100644 --- a/packages/cli/src/config/settingsUtils.test.ts +++ b/packages/cli/src/config/settingsUtils.test.ts @@ -1278,13 +1278,15 @@ describe('WORKSPACE_TIGHTEN_ONLY_SETTINGS', () => { expect(messaging.strictness({})).toBe(messaging.strictness(false)); }); - it('ranks the switch off as stricter than on, and unset as off', () => { + it('ranks the switch off as stricter than on, and unset as on', () => { + // The switch defaults to on, so a workspace `false` must outrank an + // unset user scope or a repository could never turn messaging off. const messaging = WORKSPACE_TIGHTEN_ONLY_SETTINGS.find( ({ key }) => key === 'crossSessionMessaging', )!; expect(messaging.strictness(true)).toBeLessThan( messaging.strictness(false), ); - expect(messaging.strictness(undefined)).toBe(messaging.strictness(false)); + expect(messaging.strictness(undefined)).toBe(messaging.strictness(true)); }); }); diff --git a/packages/cli/src/config/settingsUtils.ts b/packages/cli/src/config/settingsUtils.ts index c75fcd59987..b11bd9be33a 100644 --- a/packages/cli/src/config/settingsUtils.ts +++ b/packages/cli/src/config/settingsUtils.ts @@ -310,7 +310,10 @@ export const WORKSPACE_TIGHTEN_ONLY_SETTINGS = [ { section: 'agents', key: 'crossSessionMessaging', - strictness: (value: unknown): number => (value === true ? 0 : 1), + // Unset means on — the default — so it ranks with `true`. Anything the + // reader does not recognize keeps the socket closed, like `false`. + strictness: (value: unknown): number => + value === true || value === undefined ? 0 : 1, }, { section: 'agents', diff --git a/packages/cli/src/llm.test.tsx b/packages/cli/src/llm.test.tsx index da2e938f021..4efa7d640d8 100644 --- a/packages/cli/src/llm.test.tsx +++ b/packages/cli/src/llm.test.tsx @@ -3717,6 +3717,10 @@ describe('startInteractiveUI', () => { ui: { hideWindowTitle: false, }, + // Messaging is on by default, and on it binds an inbox and arms its + // own exit cleanup. These tests are about startup order and render + // options; the messaging path is covered in startInteractiveUI.test. + agents: { crossSessionMessaging: false }, }, getUserHooks: () => undefined, getProjectHooks: () => undefined, @@ -4049,6 +4053,8 @@ describe('startInteractiveUI', () => { ui: { hideWindowTitle: false, }, + // Off for the reason `mockSettings` gives: this is not about messaging. + agents: { crossSessionMessaging: false }, }, } as LoadedSettings; @@ -4397,7 +4403,11 @@ describe('startInteractiveUI', () => { getMemoryPressureMonitor: () => ({ performCheck }), } as unknown as Config; const settings = { - merged: { ui: { hideWindowTitle: true } }, + // Off for the reason `mockSettings` gives: this is not about messaging. + merged: { + ui: { hideWindowTitle: true }, + agents: { crossSessionMessaging: false }, + }, } as unknown as LoadedSettings; await startInteractiveUI( @@ -4428,7 +4438,11 @@ describe('startInteractiveUI', () => { getMemoryPressureMonitor: () => ({ performCheck }), } as unknown as Config; const settings = { - merged: { ui: { hideWindowTitle: true } }, + // Off for the reason `mockSettings` gives: this is not about messaging. + merged: { + ui: { hideWindowTitle: true }, + agents: { crossSessionMessaging: false }, + }, } as unknown as LoadedSettings; // An earlier describe's vi.restoreAllMocks() wipes the shared ink // render mock's return value in the full run, so re-arm it here. diff --git a/packages/cli/src/peerMessaging/enabled.test.ts b/packages/cli/src/peerMessaging/enabled.test.ts new file mode 100644 index 00000000000..3bf08b5be18 --- /dev/null +++ b/packages/cli/src/peerMessaging/enabled.test.ts @@ -0,0 +1,150 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { + crossSessionMessagingOffScope, + isCrossSessionMessagingEnabled, + isCrossSessionMessagingOptedIn, +} from './enabled.js'; + +describe('isCrossSessionMessagingEnabled', () => { + it('is on when nothing set the key', () => { + expect(isCrossSessionMessagingEnabled(undefined)).toBe(true); + expect(isCrossSessionMessagingEnabled({})).toBe(true); + expect(isCrossSessionMessagingEnabled({ agents: {} })).toBe(true); + expect( + isCrossSessionMessagingEnabled({ + agents: { crossSessionMessaging: undefined }, + }), + ).toBe(true); + }); + + it('is on when set to true', () => { + expect( + isCrossSessionMessagingEnabled({ + agents: { crossSessionMessaging: true }, + }), + ).toBe(true); + }); + + it('is off when set to false', () => { + expect( + isCrossSessionMessagingEnabled({ + agents: { crossSessionMessaging: false }, + }), + ).toBe(false); + }); + + it('fails closed on a value it does not recognize', () => { + for (const value of ['yes', 'true', 1, null, {}, []]) { + expect( + isCrossSessionMessagingEnabled({ + agents: { crossSessionMessaging: value }, + }), + ).toBe(false); + } + }); +}); + +const file = (crossSessionMessaging: unknown) => ({ + settings: { agents: { crossSessionMessaging } }, +}); + +describe('isCrossSessionMessagingOptedIn', () => { + it('counts a true the user wrote', () => { + expect( + isCrossSessionMessagingOptedIn({ + merged: { agents: { crossSessionMessaging: true } }, + user: file(true), + }), + ).toBe(true); + }); + + it('counts a true in this workspace only while its settings are in force', () => { + const scopes = { + merged: {}, + workspace: file(true), + }; + expect(isCrossSessionMessagingOptedIn(scopes)).toBe(false); + expect( + isCrossSessionMessagingOptedIn({ + ...scopes, + isTrusted: true, + workspaceSettingsActive: true, + }), + ).toBe(true); + }); + + it('does not count an operator-provided true', () => { + // A fleet's system-defaults file was not written by the person at + // this session; the merged value is true all the same. + for (const operator of ['system', 'systemDefaults'] as const) { + expect( + isCrossSessionMessagingOptedIn({ + merged: { agents: { crossSessionMessaging: true } }, + [operator]: file(true), + }), + ).toBe(false); + } + }); +}); + +describe('crossSessionMessagingOffScope', () => { + const off = { agents: { crossSessionMessaging: false } }; + + it('is undefined while messaging is on', () => { + expect(crossSessionMessagingOffScope({ merged: {} })).toBeUndefined(); + }); + + it('names System whenever System sets the key', () => { + expect( + crossSessionMessagingOffScope({ + merged: off, + system: file(false), + user: file(false), + }), + ).toBe('system'); + }); + + it('names the user scope when its value is the one in force', () => { + expect( + crossSessionMessagingOffScope({ merged: off, user: file(false) }), + ).toBe('user'); + // An unrecognized value is off too, and is still the user's. + expect( + crossSessionMessagingOffScope({ + merged: { agents: { crossSessionMessaging: 'yes' } }, + user: file('yes'), + }), + ).toBe('user'); + }); + + it('keeps system defaults apart from System', () => { + expect( + crossSessionMessagingOffScope({ + merged: off, + systemDefaults: file(false), + }), + ).toBe('system-defaults'); + }); + + it('names the workspace when a kept workspace false outranks a user true', () => { + expect( + crossSessionMessagingOffScope({ + merged: off, + user: file(true), + workspace: file(false), + isTrusted: true, + workspaceSettingsActive: true, + }), + ).toBe('workspace'); + }); + + it('cannot trace a value when only merged settings are known', () => { + expect(crossSessionMessagingOffScope({ merged: off })).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/peerMessaging/enabled.ts b/packages/cli/src/peerMessaging/enabled.ts new file mode 100644 index 00000000000..74549783e35 --- /dev/null +++ b/packages/cli/src/peerMessaging/enabled.ts @@ -0,0 +1,112 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { isDeepStrictEqual } from 'node:util'; + +/** + * Whether this session takes part in cross-session messaging. + * + * On unless the user turned it off. The schema defaults the setting to + * `true`, but merged settings carry only what some scope actually wrote, + * so an unset key reaches every reader as `undefined` and has to be read + * as the default here rather than as "off". Anything that is not a + * boolean fails closed: a value the reader does not recognize must not + * open a socket, which is also the rank `WORKSPACE_TIGHTEN_ONLY_SETTINGS` + * gives it. + * + * The one place this question is answered, so the interactive UI, the + * ACP agent and `/peers` cannot drift on what "on" means. + */ +export function isCrossSessionMessagingEnabled( + settings: { agents?: { crossSessionMessaging?: unknown } } | undefined, +): boolean { + const value = settings?.agents?.crossSessionMessaging; + return value === undefined || value === true; +} + +/** One settings file, as the scope readers below see it. */ +interface CrossSessionScopeFile { + settings: { agents?: { crossSessionMessaging?: unknown } }; +} + +/** + * The per-scope view of settings these readers need. `LoadedSettings` + * satisfies it; tests pass only the scopes they care about. + */ +export interface CrossSessionSettingsScopes { + merged: { agents?: { crossSessionMessaging?: unknown } }; + system?: CrossSessionScopeFile; + systemDefaults?: CrossSessionScopeFile; + user?: CrossSessionScopeFile; + workspace?: CrossSessionScopeFile; + isTrusted?: boolean; + workspaceSettingsActive?: boolean; +} + +function switchIn(file: CrossSessionScopeFile | undefined): unknown { + return file?.settings.agents?.crossSessionMessaging; +} + +function workspaceCounts(settings: CrossSessionSettingsScopes): boolean { + return ( + settings.isTrusted === true && settings.workspaceSettingsActive === true + ); +} + +/** + * Whether a person wrote `true` for the switch: in their own user + * settings, or in this workspace's settings when those are in force. + * + * A different question from {@link isCrossSessionMessagingEnabled}, which an + * unset key also answers yes. Merged settings cannot answer it: they have + * already folded in the operator scopes, and a fleet's system-defaults file + * that says `true` was not written by the person sitting at this session. + * Only the scopes a user edits count. + */ +export function isCrossSessionMessagingOptedIn( + settings: CrossSessionSettingsScopes, +): boolean { + if (switchIn(settings.user) === true) return true; + return workspaceCounts(settings) && switchIn(settings.workspace) === true; +} + +/** + * Which scope turned messaging off, so the remedy can name the file. + * + * `system-defaults` is kept apart from `system` because the remedy differs: + * a user-scope `true` overrides a system default, but nothing overrides + * System settings, and a workspace value may only tighten, so a user `true` + * cannot undo a workspace `false`. Scopes are checked in the order the + * merge lets them win, and a scope counts only when its value is the one in + * force — the same reading `inboundPolicyScope` gives the sibling setting. + * + * Undefined when messaging is on, or when the value in force cannot be + * traced to a scope (a context that carries merged settings only). + */ +export type CrossSessionMessagingOffScope = + | 'system' + | 'system-defaults' + | 'user' + | 'workspace'; + +export function crossSessionMessagingOffScope( + settings: CrossSessionSettingsScopes, +): CrossSessionMessagingOffScope | undefined { + if (isCrossSessionMessagingEnabled(settings.merged)) return undefined; + const merged = settings.merged.agents?.crossSessionMessaging; + if (switchIn(settings.system) !== undefined) return 'system'; + if (isDeepStrictEqual(switchIn(settings.user), merged)) return 'user'; + if (isDeepStrictEqual(switchIn(settings.systemDefaults), merged)) { + return 'system-defaults'; + } + if ( + workspaceCounts(settings) && + isDeepStrictEqual(switchIn(settings.workspace), merged) + ) { + return 'workspace'; + } + return undefined; +} diff --git a/packages/cli/src/ui/commands/peers-command.test.ts b/packages/cli/src/ui/commands/peers-command.test.ts index 22111fd9b66..da8f4813311 100644 --- a/packages/cli/src/ui/commands/peers-command.test.ts +++ b/packages/cli/src/ui/commands/peers-command.test.ts @@ -129,12 +129,13 @@ interface Fake { function makeContext( peerMessaging: Fake | null, - crossSessionMessaging?: boolean, + crossSessionMessaging?: unknown, + scopes: Record = {}, ): CommandContext { return { services: { peerMessaging, - settings: { merged: { agents: { crossSessionMessaging } } }, + settings: { merged: { agents: { crossSessionMessaging } }, ...scopes }, }, } as unknown as CommandContext; } @@ -142,10 +143,11 @@ function makeContext( async function run( peerMessaging: Fake | null, args: string, - crossSessionMessaging?: boolean, + crossSessionMessaging?: unknown, + scopes?: Record, ): Promise<{ messageType: string; content: string }> { const result = await peersCommand.action!( - makeContext(peerMessaging, crossSessionMessaging), + makeContext(peerMessaging, crossSessionMessaging, scopes), args, ); if (!result || result.type !== 'message') { @@ -383,11 +385,63 @@ describe('formatHeldList', () => { }); describe('/peers', () => { - it('explains how to turn the feature on when it is off', async () => { - const result = await run(null, ''); + it('explains how to turn the feature back on when it is off', async () => { + const result = await run(null, '', false); + expect(result.messageType).toBe('info'); + expect(result.content).toContain('Cross-session messaging is off'); expect(result.content).toContain('crossSessionMessaging'); }); + it('does not claim the feature is off when nothing set the key', async () => { + // Unset means on: the null inbox is then a startup or bind problem, + // and telling the user to enable a setting that is already on sends + // them nowhere. + const result = await run(null, ''); + expect(result.content).not.toContain('Cross-session messaging is off'); + expect(result.content).toContain('no inbox'); + }); + + it('names the repository when a workspace false outranks a user true', async () => { + // A workspace may only tighten, so writing true in user settings + // changes nothing here; the remedy has to point at the repository. + const result = await run(null, '', false, { + user: { settings: { agents: { crossSessionMessaging: true } } }, + workspace: { settings: { agents: { crossSessionMessaging: false } } }, + isTrusted: true, + workspaceSettingsActive: true, + }); + expect(result.content).toContain('.qwen/settings.json'); + expect(result.content).toContain('cannot turn it back on here'); + expect(result.content).not.toContain('Set it to true'); + }); + + it('does not claim a value it cannot see', async () => { + const result = await run(null, '', 'yes', { + user: { settings: { agents: { crossSessionMessaging: 'yes' } } }, + }); + expect(result.content).toContain('your user settings'); + expect(result.content).not.toMatch(/: ?false/); + }); + + it('says an unsupported platform as information, without advice to disable', async () => { + inboxFailure.current = { + cause: 'unsupported_platform', + socketPath: 'C:\\Users\\me\\qwen-socks\\1.sock', + detail: 'automatic peer inbox paths are not supported on Windows', + hint: 'Disable cross-session messaging for this session.', + attempts: 1, + }; + try { + const result = await run(null, ''); + expect(result.messageType).toBe('info'); + expect(result.content).toContain('not available on this platform'); + expect(result.content).not.toContain('failed to bind its socket'); + expect(result.content).not.toContain('Disable'); + } finally { + inboxFailure.current = null; + } + }); + it('does not tell a user to enable a setting they already enabled', async () => { // Same null inbox, different cause: registration or the bind failed. // "Turn it on" would send them back to a setting that is already on. @@ -395,7 +449,8 @@ describe('/peers', () => { const result = await run(null, '', true); expect(result.messageType).toBe('error'); expect(result.content).toContain('failed to register'); - expect(result.content).not.toContain('Enable it with'); + expect(result.content).not.toContain('Cross-session messaging is off'); + expect(result.content).not.toContain('Remove that entry'); }); it('repeats the bind failure and what to change when the inbox could not bind', async () => { @@ -415,7 +470,8 @@ describe('/peers', () => { // ever existed in this file's stub. expect(result.content).toContain('belongs to another user'); expect(result.content).toContain('XDG_RUNTIME_DIR'); - expect(result.content).not.toContain('Enable it with'); + expect(result.content).not.toContain('Cross-session messaging is off'); + expect(result.content).not.toContain('Remove that entry'); } finally { inboxFailure.current = null; } @@ -900,7 +956,9 @@ describe('/peers controllers', () => { ]; const out = await run(null, 'controllers', false); expect(out.content).toContain('c_0123abcd'); - expect(out.content).not.toContain('Cross-session messaging is off'); + // The whole off-notice, not one phrasing of it: the listing is served + // before the off-check and must not carry it. + expect(out.content).not.toContain('Cross-session messaging'); }); it('reports a registry it cannot read as a line, not a crash', async () => { diff --git a/packages/cli/src/ui/commands/peers-command.ts b/packages/cli/src/ui/commands/peers-command.ts index 60fae11c769..88308a6ef54 100644 --- a/packages/cli/src/ui/commands/peers-command.ts +++ b/packages/cli/src/ui/commands/peers-command.ts @@ -26,6 +26,11 @@ import { removePeerController, } from '@qwen-code/qwen-code-core'; import type { SlashCommand, SlashCommandActionReturn } from './types.js'; +import { + crossSessionMessagingOffScope, + type CrossSessionMessagingOffScope, + isCrossSessionMessagingEnabled, +} from '../../peerMessaging/enabled.js'; import { t } from '../../i18n/index.js'; import { CommandKind } from './types.js'; @@ -302,18 +307,35 @@ export const peersCommand: SlashCommand = { // setting they already enabled sends them nowhere: the inbox is also // absent when the session failed to register or the socket failed to // bind (path too long, unwritable runtime dir). - const enabled = - context.services.settings?.merged?.agents?.crossSessionMessaging === - true; - const failure = enabled ? getLastPeerInboxFailure() : null; + const settings = context.services.settings; + if (!isCrossSessionMessagingEnabled(settings?.merged)) { + return { + type: 'message', + messageType: 'info', + content: describeMessagingOff( + settings ? crossSessionMessagingOffScope(settings) : undefined, + ), + }; + } + const failure = getLastPeerInboxFailure(); + // A platform with no inbox transport is not a fault in this session, + // and the switch is on by default, so most people who see this never + // turned anything on: say what is true, as information, without the + // failure's own advice to disable a setting they never enabled. + if (failure?.cause === 'unsupported_platform') { + return { + type: 'message', + messageType: 'info', + content: + 'Cross-session messaging is not available on this platform, so this session has no inbox and other sessions cannot reach it.', + }; + } return { type: 'message', - messageType: enabled ? 'error' : 'info', - content: !enabled - ? 'Cross-session messaging is off. Enable it with "agents.crossSessionMessaging": true in settings.json, then restart.' - : failure - ? `Cross-session messaging is on, but this session has no inbox — it failed to bind its socket: ${describePeerInboxFailure(failure)}` - : 'Cross-session messaging is on, but this session has no inbox: it failed to register in the session registry, or the inbox is still starting. Re-run with DEBUG=1 to see the registration error.', + messageType: 'error', + content: failure + ? `Cross-session messaging is on, but this session has no inbox — it failed to bind its socket: ${describePeerInboxFailure(failure)}` + : 'Cross-session messaging is on, but this session has no inbox: it failed to register in the session registry, or the inbox is still starting. Re-run with DEBUG=1 to see the registration error.', }; } @@ -459,3 +481,26 @@ export const peersCommand: SlashCommand = { }; }, }; + +/** + * Why messaging is off, with the remedy that works for the scope that + * turned it off. Never states the value itself: anything but `true` or an + * unset key reads as off, so "set to false" would be a claim about a file + * the user may open and find something else in. + */ +function describeMessagingOff( + scope: CrossSessionMessagingOffScope | undefined, +): string { + switch (scope) { + case 'workspace': + return 'Cross-session messaging is off: this repository\'s .qwen/settings.json turns "agents.crossSessionMessaging" off. A workspace may only make that setting stricter, so your user settings cannot turn it back on here. Remove the entry from that file, then restart.'; + case 'system': + return 'Cross-session messaging is off: system settings set "agents.crossSessionMessaging", and system settings override every other scope. Whoever manages them can remove the entry.'; + case 'system-defaults': + return 'Cross-session messaging is off: the system defaults turn "agents.crossSessionMessaging" off. Set it to true in your user settings, then restart.'; + case 'user': + return 'Cross-session messaging is off: your user settings turn "agents.crossSessionMessaging" off (only true, or leaving it unset, turns it on). Remove that entry, then restart.'; + default: + return 'Cross-session messaging is off because of the "agents.crossSessionMessaging" setting (only true, or leaving it unset, turns it on). Remove that entry, then restart.'; + } +} diff --git a/packages/cli/src/ui/startInteractiveUI.test.tsx b/packages/cli/src/ui/startInteractiveUI.test.tsx index 6e9dfb3054d..ff002bbdaab 100644 --- a/packages/cli/src/ui/startInteractiveUI.test.tsx +++ b/packages/cli/src/ui/startInteractiveUI.test.tsx @@ -168,7 +168,17 @@ function makeConfig(): TestConfig { } as unknown as TestConfig; } +// Messaging is on by default, and on it adds its own teardown entry; these +// suites count the registry pair alone, so they turn it off explicitly. +// The cross-session suite below is where "unset" is exercised. const settings = { + merged: { + ui: { hideWindowTitle: true }, + agents: { crossSessionMessaging: false }, + }, +} as unknown as LoadedSettings; + +const settingsWithoutTheKey = { merged: { ui: { hideWindowTitle: true } }, } as unknown as LoadedSettings; @@ -249,11 +259,14 @@ describe('startInteractiveUI session registration', () => { }); describe('startInteractiveUI cross-session messaging', () => { + // Turned on by hand: the user scope wrote it, so the merged value is + // true and the opt-in reader sees it too. const enabledSettings = { merged: { ui: { hideWindowTitle: true }, agents: { crossSessionMessaging: true }, }, + user: { settings: { agents: { crossSessionMessaging: true } } }, } as unknown as LoadedSettings; beforeEach(() => { @@ -399,7 +412,7 @@ describe('startInteractiveUI cross-session messaging', () => { expect(options.getSessionId()).toBe('session-after-clear'); }); - it('does not bind an inbox unless the setting is on', async () => { + it('does not bind an inbox when the setting is off', async () => { const config = makeConfig(); await start(config); @@ -413,6 +426,17 @@ describe('startInteractiveUI cross-session messaging', () => { expect(registerCleanup).toHaveBeenCalledTimes(2); }); + it('binds an inbox when nothing set the key: on is the default', async () => { + // Merged settings carry only what a scope wrote, so the schema default + // never reaches this code as a value. An unset key has to mean on here, + // or the default would be on in the docs and off in practice. + const config = makeConfig(); + + await start(config, settingsWithoutTheKey); + + await vi.waitFor(() => expect(peerMessagingStart).toHaveBeenCalledTimes(1)); + }); + it('waits for registration to be queued before binding', async () => { // The inbox advertises itself by patching the session's registry // record, and a patch against a record that does not exist yet is @@ -467,6 +491,85 @@ describe('startInteractiveUI cross-session messaging', () => { } }); + async function observeFailureAfterStart( + used: LoadedSettings, + ): Promise { + await start(makeConfig(), used); + await vi.waitFor(() => expect(peerMessagingStart).toHaveBeenCalled()); + const appTree = inkRender.mock.calls[0]?.[0] as ReactElement; + const mounted = renderDom(appTree); + try { + // Give the provider a turn to publish whatever it decided to. + await new Promise((resolve) => setTimeout(resolve, 20)); + return observedPeerInboxFailure.value; + } finally { + mounted.unmount(); + } + } + + const unsupportedPlatform = { + cause: 'unsupported_platform', + socketPath: 'C:\\Users\\me\\qwen-socks\\1.sock', + detail: 'automatic peer inbox paths are not supported on Windows', + hint: 'Disable cross-session messaging for this session.', + attempts: 1, + }; + + it('keeps an unsupported platform quiet when nothing set the key', async () => { + // On by default means every user of a platform without a transport + // would otherwise be greeted with a failure about a feature they + // never asked for. + lastPeerInboxFailure.value = unsupportedPlatform; + peerMessagingStart.mockResolvedValue(null); + + expect(await observeFailureAfterStart(settingsWithoutTheKey)).toBeNull(); + }); + + it('says an unsupported platform to a user who turned the switch on', async () => { + lastPeerInboxFailure.value = unsupportedPlatform; + peerMessagingStart.mockResolvedValue(null); + + expect(await observeFailureAfterStart(enabledSettings)).toEqual( + unsupportedPlatform, + ); + }); + + it('keeps an unsupported platform quiet when only an operator default turned the switch on', async () => { + // The merged value is true, but nobody at this session wrote it: a + // fleet's system-defaults file is not an opt-in by hand. + lastPeerInboxFailure.value = unsupportedPlatform; + peerMessagingStart.mockResolvedValue(null); + const operatorOn = { + merged: { + ui: { hideWindowTitle: true }, + agents: { crossSessionMessaging: true }, + }, + systemDefaults: { + settings: { agents: { crossSessionMessaging: true } }, + }, + } as unknown as LoadedSettings; + + expect(await observeFailureAfterStart(operatorOn)).toBeNull(); + }); + + it('says a real bind failure even when nothing set the key', async () => { + // Unreachable is unreachable whoever asked for the inbox: this line + // is the only place the user learns their session cannot be messaged. + const failure = { + cause: 'permission', + socketPath: '/run/user/1000/qwen-socks/1.sock', + detail: 'EACCES', + hint: 'Choose a directory you own.', + attempts: 3, + }; + lastPeerInboxFailure.value = failure; + peerMessagingStart.mockResolvedValue(null); + + expect(await observeFailureAfterStart(settingsWithoutTheKey)).toEqual( + failure, + ); + }); + it('closes the inbox from exit cleanup', async () => { const close = vi.fn().mockResolvedValue(undefined); peerMessagingStart.mockResolvedValue({ close }); diff --git a/packages/cli/src/ui/startInteractiveUI.tsx b/packages/cli/src/ui/startInteractiveUI.tsx index cd1bf30949e..42d73666f95 100644 --- a/packages/cli/src/ui/startInteractiveUI.tsx +++ b/packages/cli/src/ui/startInteractiveUI.tsx @@ -25,6 +25,10 @@ import { PeerMessagingContext, } from '../peerMessaging/PeerMessagingContext.js'; import { inboundPolicyScope } from '../peerMessaging/inbound-policy-scope.js'; +import { + isCrossSessionMessagingEnabled, + isCrossSessionMessagingOptedIn, +} from '../peerMessaging/enabled.js'; import type { LoadedSettings } from '../config/settings.js'; import { isValidSessionId } from '../config/config.js'; import type { InitializationResult } from '../core/initializer.js'; @@ -182,7 +186,7 @@ export async function startInteractiveUI( ? installTerminalResizeReflow(process.stdout, { virtualViewport: useVP }) : { restore: () => {}, repaint: () => {} }; - // Cross-session messaging (experimental, off by default). The inbox is + // Cross-session messaging (on by default; see peerMessaging/enabled.ts). The inbox is // owned outside React — bound once per process by the block at the end of // this function — and this promise is how the bound instance (or null, // when the feature is off or the socket could not be bound) reaches the @@ -217,9 +221,26 @@ export async function startInteractiveUI( // cause is what the user needs, not the null. if ( messaging === null && - settings.merged.agents?.crossSessionMessaging === true + isCrossSessionMessagingEnabled(settings.merged) ) { - setPeerInboxFailure(getLastPeerInboxFailure()); + const failure = getLastPeerInboxFailure(); + // A different question from the helper above: not "is messaging + // on", which an unset key also answers yes, but "did a person + // write `true`" — in their user settings or this workspace's, not + // in an operator's defaults, which merged settings cannot tell + // apart. The switch is on by default, so a platform with no inbox + // transport would otherwise greet every one of its users with a + // failure about a feature they never asked for; that one is said + // only to someone who opted in by hand. A bind that failed where + // it should have worked is said to everyone: they are + // unreachable, and this line is the only place they learn it. + const optedInByHand = isCrossSessionMessagingOptedIn(settings); + if ( + failure !== null && + (failure.cause !== 'unsupported_platform' || optedInByHand) + ) { + setPeerInboxFailure(failure); + } } }); return () => { @@ -452,7 +473,7 @@ export async function startInteractiveUI( // registry record, and `patchSessionRecord` no-ops when there is no record // yet, so binding any earlier would publish the socket path into nothing. // Not awaited — startup must never block on binding a socket. - if (settings.merged.agents?.crossSessionMessaging !== true) { + if (!isCrossSessionMessagingEnabled(settings.merged)) { publishPeerMessaging(null); } else { let exiting = false; diff --git a/packages/core/src/tools/send-message.ts b/packages/core/src/tools/send-message.ts index e1d677b1bd9..32cd9d3302e 100644 --- a/packages/core/src/tools/send-message.ts +++ b/packages/core/src/tools/send-message.ts @@ -455,7 +455,7 @@ class SendMessageInvocation extends BaseToolInvocation< if (!teamManager) { const msg = this.peerMessagingOff - ? `No active team and no task_id, and cross-session messaging is not enabled in this session (agents.crossSessionMessaging), so "${to}" cannot be another session. ` + + ? `No active team and no task_id, and cross-session messaging is not active in this session (agents.crossSessionMessaging is off, or its inbox did not start), so "${to}" cannot be another session. ` + 'Create a team, or pass `task_id` to message a background task.' : `No active team, no task_id, and no reachable session named "${to}". ` + 'Create a team, pass `task_id` to message a background task, or use ' + @@ -486,7 +486,7 @@ class SendMessageInvocation extends BaseToolInvocation< // that the name it wants could belong to a session this setting // is hiding. errMsg += this.peerMessagingOff - ? ` Cross-session messaging is not enabled in this session (agents.crossSessionMessaging), so another session could not have taken that name either.` + ? ` Cross-session messaging is not active in this session (agents.crossSessionMessaging is off, or its inbox did not start), so another session could not have taken that name either.` : ` No reachable session has that name either; use list_agents to see who is reachable.`; } return { diff --git a/packages/sdk-typescript/README.md b/packages/sdk-typescript/README.md index 08ef42f9ea5..a1b76b9e1fc 100644 --- a/packages/sdk-typescript/README.md +++ b/packages/sdk-typescript/README.md @@ -255,9 +255,9 @@ await endpoint.send({ Things to know: - A session has an inbox only while its `agents.crossSessionMessaging` setting - is on, and the setting is off by default. Without it the session does not - appear in `list()`, and its own `list_agents` and `send_message` cannot see - or reach the program either. `qwen sessions ps` lists the program regardless. + is on, which it is by default. A session that turned it off does not appear + in `list()`, and its own `list_agents` and `send_message` cannot see or + reach the program either. `qwen sessions ps` lists the program regardless. - A message is delivered without review in exactly two cases: the send presents a controller token (`controller: true`), or its `fromMode` names the receiving session's own review class. `fromMode` is a claim nothing diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index 0cda9865760..b7311333135 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -1682,9 +1682,9 @@ } }, "crossSessionMessaging": { - "description": "Experimental. Let Qwen Code sessions on this machine send each other messages over a per-session local socket. Off by default; turning it on opens this session to peer messages, makes it discoverable to others, and lets its model address them from send_message.", + "description": "Let Qwen Code sessions on this machine send each other messages over a per-session local socket. On by default: this session is discoverable by the others, takes peer messages under the review rules of agents.crossSessionInbound, and its model can address them from send_message. Two senders are delivered without review unless agents.crossSessionInbound is \"hold\" or \"refuse\": processes this session starts, which inherit its child token, and a same-user process that claims this session's own review class, which nothing authenticates. Set to false to keep this session invisible and unreachable.", "type": "boolean", - "default": false + "default": true }, "crossSessionInbound": { "description": "What happens to inbound cross-session messages. \"accept\" delivers them; \"hold\" parks them for your review without letting the model act; \"refuse\" opts this session out. Unset means user-minted controllers and this session's own child processes auto-deliver, while other sessions use review-class parity: both must review every action, or both must declare a mode that can apply actions without per-action review. Other messages are held for you to review. Options: accept, hold, refuse",