diff --git a/docs/superpowers/plans/2026-04-06-device-gui-cli-capability-and-conflict.md b/docs/superpowers/plans/2026-04-06-device-gui-cli-capability-and-conflict.md new file mode 100644 index 0000000..1c29596 --- /dev/null +++ b/docs/superpowers/plans/2026-04-06-device-gui-cli-capability-and-conflict.md @@ -0,0 +1,859 @@ +# 设备 GUI+CLI 双能力接入与并行冲突控制 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 让 Boss 支持同一台 Mac/Windows 设备同时接入 Codex GUI 与 CLI,并在同项目 GUI/CLI 并行写入风险出现时默认阻断,由用户在当前异常项目/文件夹级别选择禁止、允许本次或永久放行。 + +**Architecture:** 继续沿用现有单设备模型,不拆成两个虚拟设备。设备层新增 `gui/cli` 双能力状态和默认执行模式;项目/文件夹层新增并行冲突状态与放行策略;执行链在进入 CLI 写入任务前先做规则层冲突检测,命中后返回结构化风险卡,等待用户确认。 + +**Tech Stack:** Next.js App Router、TypeScript、文件型状态存储 `data/boss-state.json`、现有 `local-agent` heartbeat 链路、Android 原生客户端、Node test runner、Gradle unit tests + +--- + +## 文件结构 + +### 状态模型与执行冲突检测 + +- Modify: `/Users/kris/code/boss/src/lib/boss-data.ts` +- Test: `/Users/kris/code/boss/tests/device-gui-cli-capabilities.test.ts` +- Test: `/Users/kris/code/boss/tests/device-execution-conflict.test.ts` + +职责: +- 给 `Device` 增加 `gui/cli` 能力状态 +- 给项目/文件夹增加并行冲突策略与状态 +- 在进入 CLI 写入型任务前做规则层冲突检测 +- 把 `禁止 / 允许本次 / 永久放行` 限定在当前异常项目/文件夹 + +### 设备详情与设备 API + +- Modify: `/Users/kris/code/boss/src/lib/boss-projections.ts` +- Modify: `/Users/kris/code/boss/src/app/api/v1/devices/[deviceId]/route.ts` +- Test: `/Users/kris/code/boss/tests/device-detail-capabilities-route.test.ts` + +职责: +- 把设备的 GUI/CLI 双能力与默认执行模式暴露给 Web/Android +- 允许设备详情页更新 `preferredExecutionMode` + +### 本地 agent 心跳能力上报 + +- Modify: `/Users/kris/code/boss/local-agent/server.mjs` +- Modify: `/Users/kris/code/boss/local-agent/config.example.json` +- Test: `/Users/kris/code/boss/tests/local-agent-heartbeat-capabilities.test.mjs` + +职责: +- heartbeat 上报当前设备的 `gui/cli` 能力状态 +- 不改变现有 `local-agent -> codex exec resume` 主链 + +### Android 设备详情与冲突提示 + +- Modify: `/Users/kris/code/boss/android/app/src/main/java/com/hyzq/boss/BossApiClient.java` +- Modify: `/Users/kris/code/boss/android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java` +- Modify: `/Users/kris/code/boss/android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java` +- Test: `/Users/kris/code/boss/android/app/src/test/java/com/hyzq/boss/DeviceDetailActivityTest.java` +- Test: `/Users/kris/code/boss/android/app/src/test/java/com/hyzq/boss/BossApiClientDeviceModeTest.java` + +职责: +- 在设备详情页显示 `GUI / CLI` 状态 +- 增加默认执行模式切换 +- 展示当前项目/文件夹级冲突状态和放行策略 + +### Web 设备详情与冲突提示 + +- Modify: `/Users/kris/code/boss/src/components/app-ui.tsx` +- Modify: `/Users/kris/code/boss/src/app/devices/page.tsx` +- Test: `/Users/kris/code/boss/tests/device-detail-capabilities-route.test.ts` + +职责: +- Web 端设备详情显示双能力与默认执行模式 +- 当前异常项目/文件夹的冲突卡三动作可操作 + +### 文档与回归 + +- Modify: `/Users/kris/code/boss/README.md` +- Modify: `/Users/kris/code/boss/docs/architecture/current_runtime_and_deploy_status_cn.md` + +职责: +- 记录 GUI/CLI 双能力设备模型 +- 记录项目/文件夹级冲突控制行为 + +--- + +### Task 1: 先定义设备双能力与项目级冲突状态 + +**Files:** +- Modify: `/Users/kris/code/boss/src/lib/boss-data.ts` +- Test: `/Users/kris/code/boss/tests/device-gui-cli-capabilities.test.ts` + +- [ ] **Step 1: 写失败测试,锁住设备双能力默认值与局部冲突策略** + +```ts +import test from "node:test"; +import assert from "node:assert/strict"; +import { readState, writeStateForTests } from "@/lib/boss-data"; + +test("device stores gui and cli capabilities without splitting the physical device", async () => { + const state = await readState(); + const device = state.devices.find((item) => item.id === "mac-studio"); + assert.ok(device); + assert.equal(device.capabilities?.gui?.connected, true); + assert.equal(device.capabilities?.cli?.connected, true); + assert.equal(device.preferredExecutionMode, "cli"); +}); + +test("conflict policy is scoped to the active folder instead of the whole device", async () => { + const state = await readState(); + state.projectExecutionPolicies = [ + { + deviceId: "mac-studio", + folderKey: "mac-studio:boss", + projectId: "thread-ui", + allowPolicy: "allow_always", + conflictState: "warning", + updatedAt: "2026-04-06T10:00:00.000Z", + }, + ]; + await writeStateForTests(state); + + const nextState = await readState(); + const bossPolicy = nextState.projectExecutionPolicies.find((item) => item.folderKey === "mac-studio:boss"); + const otherPolicy = nextState.projectExecutionPolicies.find((item) => item.folderKey === "mac-studio:talking"); + assert.equal(bossPolicy?.allowPolicy, "allow_always"); + assert.equal(otherPolicy, undefined); +}); +``` + +- [ ] **Step 2: 跑测试,确认先失败** + +Run: + +```bash +npx --yes tsx --test /Users/kris/code/boss/tests/device-gui-cli-capabilities.test.ts +``` + +Expected: +- FAIL,提示 `Device` 没有 `capabilities` 或 `preferredExecutionMode` + +- [ ] **Step 3: 在状态模型里增加设备能力与冲突策略类型** + +在 `/Users/kris/code/boss/src/lib/boss-data.ts` 增加最小实现: + +```ts +export type DeviceExecutionMode = "gui" | "cli"; +export type ProjectConflictAllowPolicy = "forbid" | "allow_once" | "allow_always"; +export type ProjectConflictState = "none" | "warning" | "blocked"; + +export interface DeviceCapabilityState { + connected: boolean; + lastSeenAt?: string; + lastActiveProjectId?: string; +} + +export interface DeviceCapabilities { + gui: DeviceCapabilityState; + cli: DeviceCapabilityState; +} + +export interface ProjectExecutionPolicy { + deviceId: string; + folderKey?: string; + projectId: string; + allowPolicy: ProjectConflictAllowPolicy; + conflictState: ProjectConflictState; + activeCliExecution?: boolean; + recentExternalActivityAt?: string; + updatedAt: string; +} + +export interface Device { + id: string; + name: string; + avatar: string; + account: string; + source: DeviceSource; + status: DeviceStatus; + projects: string[]; + quota5h: number; + quota7d: number; + lastSeenAt: string; + endpoint?: string; + token?: string; + note?: string; + capabilities?: DeviceCapabilities; + preferredExecutionMode?: DeviceExecutionMode; +} +``` + +- [ ] **Step 4: 给初始种子设备补默认值** + +在 `/Users/kris/code/boss/src/lib/boss-data.ts` 的种子设备里补: + +```ts +capabilities: { + gui: { connected: true, lastSeenAt: "2026-04-06T09:00:00+08:00", lastActiveProjectId: "master-agent" }, + cli: { connected: true, lastSeenAt: "2026-04-06T09:00:00+08:00", lastActiveProjectId: "master-agent" }, +}, +preferredExecutionMode: "cli", +``` + +Windows demo 设备补: + +```ts +capabilities: { + gui: { connected: true, lastSeenAt: "2026-04-06T08:50:00+08:00", lastActiveProjectId: "audit-collab" }, + cli: { connected: false, lastSeenAt: "2026-04-06T08:40:00+08:00", lastActiveProjectId: "" }, +}, +preferredExecutionMode: "gui", +``` + +- [ ] **Step 5: 给状态恢复逻辑补兼容默认值** + +在 `readState()` 的 normalize 阶段补: + +```ts +capabilities: { + gui: { + connected: Boolean(device.capabilities?.gui?.connected), + lastSeenAt: device.capabilities?.gui?.lastSeenAt || device.lastSeenAt, + lastActiveProjectId: device.capabilities?.gui?.lastActiveProjectId || "", + }, + cli: { + connected: Boolean(device.capabilities?.cli?.connected), + lastSeenAt: device.capabilities?.cli?.lastSeenAt || device.lastSeenAt, + lastActiveProjectId: device.capabilities?.cli?.lastActiveProjectId || "", + }, +}, +preferredExecutionMode: + device.preferredExecutionMode === "gui" || device.preferredExecutionMode === "cli" + ? device.preferredExecutionMode + : "cli", +``` + +- [ ] **Step 6: 重跑测试,确认通过** + +Run: + +```bash +npx --yes tsx --test /Users/kris/code/boss/tests/device-gui-cli-capabilities.test.ts +``` + +Expected: +- PASS + +- [ ] **Step 7: Commit** + +```bash +git add /Users/kris/code/boss/src/lib/boss-data.ts /Users/kris/code/boss/tests/device-gui-cli-capabilities.test.ts +git commit -m "feat: add gui cli device capability state" +``` + +### Task 2: 在规则层实现项目/文件夹级并行冲突检测 + +**Files:** +- Modify: `/Users/kris/code/boss/src/lib/boss-data.ts` +- Test: `/Users/kris/code/boss/tests/device-execution-conflict.test.ts` + +- [ ] **Step 1: 写失败测试,锁住默认阻断、允许本次、永久放行都只作用于当前 folder** + +```ts +import test from "node:test"; +import assert from "node:assert/strict"; +import { + applyProjectConflictDecision, + detectProjectExecutionConflict, + readState, + writeStateForTests, +} from "@/lib/boss-data"; + +test("detectProjectExecutionConflict blocks cli execution when the same folder has new external activity", async () => { + const state = await readState(); + state.projectExecutionPolicies = []; + await writeStateForTests(state); + + const result = await detectProjectExecutionConflict({ + deviceId: "mac-studio", + folderKey: "mac-studio:boss", + projectId: "thread-ui", + executionMode: "cli", + activityAt: "2026-04-06T10:05:00.000Z", + externalActivityAt: "2026-04-06T10:04:00.000Z", + }); + + assert.equal(result.blocked, true); + assert.equal(result.policy.allowPolicy, "forbid"); +}); + +test("allow_once only clears the active folder conflict after a single execution", async () => { + await applyProjectConflictDecision({ + deviceId: "mac-studio", + folderKey: "mac-studio:boss", + projectId: "thread-ui", + decision: "allow_once", + }); + + let result = await detectProjectExecutionConflict({ + deviceId: "mac-studio", + folderKey: "mac-studio:boss", + projectId: "thread-ui", + executionMode: "cli", + activityAt: "2026-04-06T10:10:00.000Z", + externalActivityAt: "2026-04-06T10:09:00.000Z", + }); + assert.equal(result.blocked, false); + + result = await detectProjectExecutionConflict({ + deviceId: "mac-studio", + folderKey: "mac-studio:boss", + projectId: "thread-ui", + executionMode: "cli", + activityAt: "2026-04-06T10:20:00.000Z", + externalActivityAt: "2026-04-06T10:19:00.000Z", + }); + assert.equal(result.blocked, true); +}); +``` + +- [ ] **Step 2: 跑测试,确认先失败** + +Run: + +```bash +npx --yes tsx --test /Users/kris/code/boss/tests/device-execution-conflict.test.ts +``` + +Expected: +- FAIL,提示函数不存在或策略未生效 + +- [ ] **Step 3: 增加冲突检测与决策函数** + +在 `/Users/kris/code/boss/src/lib/boss-data.ts` 增加: + +```ts +export async function detectProjectExecutionConflict(input: { + deviceId: string; + folderKey?: string; + projectId: string; + executionMode: DeviceExecutionMode; + activityAt: string; + externalActivityAt?: string; +}) { + const state = await readState(); + const policy = matchProjectExecutionPolicy(state, input); + const hasConflict = + input.executionMode === "cli" && + Boolean(input.externalActivityAt) && + input.externalActivityAt! <= input.activityAt; + + if (!hasConflict) { + return { blocked: false, policy }; + } + + if (policy?.allowPolicy === "allow_always") { + return { blocked: false, policy }; + } + + if (policy?.allowPolicy === "allow_once") { + await clearAllowOncePolicy(input); + return { blocked: false, policy }; + } + + const nextPolicy = await upsertProjectExecutionPolicy({ + deviceId: input.deviceId, + folderKey: input.folderKey, + projectId: input.projectId, + allowPolicy: "forbid", + conflictState: "blocked", + activeCliExecution: true, + recentExternalActivityAt: input.externalActivityAt, + }); + return { blocked: true, policy: nextPolicy }; +} + +export async function applyProjectConflictDecision(input: { + deviceId: string; + folderKey?: string; + projectId: string; + decision: ProjectConflictAllowPolicy; +}) { + return upsertProjectExecutionPolicy({ + deviceId: input.deviceId, + folderKey: input.folderKey, + projectId: input.projectId, + allowPolicy: input.decision, + conflictState: input.decision === "forbid" ? "blocked" : "warning", + }); +} +``` + +- [ ] **Step 4: 确保匹配范围只落在当前异常项目/文件夹** + +匹配函数必须先按: + +```ts +deviceId + folderKey +``` + +再退化到: + +```ts +deviceId + projectId +``` + +不得在设备级匹配整个设备的默认策略。 + +- [ ] **Step 5: 重跑测试,确认通过** + +Run: + +```bash +npx --yes tsx --test /Users/kris/code/boss/tests/device-execution-conflict.test.ts +``` + +Expected: +- PASS + +- [ ] **Step 6: Commit** + +```bash +git add /Users/kris/code/boss/src/lib/boss-data.ts /Users/kris/code/boss/tests/device-execution-conflict.test.ts +git commit -m "feat: add folder scoped gui cli conflict guard" +``` + +### Task 3: 让设备详情 API 与投影视图暴露 GUI/CLI 双能力 + +**Files:** +- Modify: `/Users/kris/code/boss/src/lib/boss-projections.ts` +- Modify: `/Users/kris/code/boss/src/app/api/v1/devices/[deviceId]/route.ts` +- Test: `/Users/kris/code/boss/tests/device-detail-capabilities-route.test.ts` + +- [ ] **Step 1: 写失败测试,锁住设备详情返回 GUI/CLI 能力与默认执行模式** + +```ts +import test from "node:test"; +import assert from "node:assert/strict"; +import { GET, PATCH } from "@/app/api/v1/devices/[deviceId]/route"; + +test("device detail exposes gui cli capabilities and preferred mode", async () => { + const response = await GET(new Request("http://localhost/api/v1/devices/mac-studio"), { + params: Promise.resolve({ deviceId: "mac-studio" }), + }); + const data = await response.json(); + const device = data.workspace.selectedDevice; + assert.equal(device.capabilities.gui.connected, true); + assert.equal(device.capabilities.cli.connected, true); + assert.equal(device.preferredExecutionMode, "cli"); +}); + +test("device detail patch updates preferred execution mode only", async () => { + const response = await PATCH( + new Request("http://localhost/api/v1/devices/mac-studio", { + method: "PATCH", + body: JSON.stringify({ preferredExecutionMode: "gui" }), + headers: { "Content-Type": "application/json" }, + }), + { params: Promise.resolve({ deviceId: "mac-studio" }) }, + ); + const data = await response.json(); + assert.equal(data.device.preferredExecutionMode, "gui"); +}); +``` + +- [ ] **Step 2: 跑测试,确认先失败** + +Run: + +```bash +npx --yes tsx --test /Users/kris/code/boss/tests/device-detail-capabilities-route.test.ts +``` + +Expected: +- FAIL,提示返回字段缺失 + +- [ ] **Step 3: 在投影视图里暴露 GUI/CLI 状态** + +在 `/Users/kris/code/boss/src/lib/boss-projections.ts` 的 `DeviceWorkspaceView` 构造里把: + +```ts +selectedDevice: { + ...device, + capabilities: device.capabilities, + preferredExecutionMode: device.preferredExecutionMode ?? "cli", +}, +``` + +稳定输出。 + +- [ ] **Step 4: 在设备 PATCH 路由里支持只更新默认执行模式** + +在 `/Users/kris/code/boss/src/app/api/v1/devices/[deviceId]/route.ts` 收窄允许字段: + +```ts +const body = await request.json(); +const payload = { + preferredExecutionMode: + body.preferredExecutionMode === "gui" || body.preferredExecutionMode === "cli" + ? body.preferredExecutionMode + : undefined, +}; +const device = await updateDevice(deviceId, payload); +``` + +- [ ] **Step 5: 重跑测试,确认通过** + +Run: + +```bash +npx --yes tsx --test /Users/kris/code/boss/tests/device-detail-capabilities-route.test.ts +``` + +Expected: +- PASS + +- [ ] **Step 6: Commit** + +```bash +git add /Users/kris/code/boss/src/lib/boss-projections.ts /Users/kris/code/boss/src/app/api/v1/devices/[deviceId]/route.ts /Users/kris/code/boss/tests/device-detail-capabilities-route.test.ts +git commit -m "feat: expose gui cli device capability details" +``` + +### Task 4: 让 local-agent heartbeat 上报 CLI 能力 + +**Files:** +- Modify: `/Users/kris/code/boss/local-agent/server.mjs` +- Modify: `/Users/kris/code/boss/local-agent/config.example.json` +- Test: `/Users/kris/code/boss/tests/local-agent-heartbeat-capabilities.test.mjs` + +- [ ] **Step 1: 写失败测试,锁住 heartbeat 载荷包含 `capabilities.cli`** + +```js +import test from "node:test"; +import assert from "node:assert/strict"; +import { buildHeartbeatPayload } from "../local-agent/server.mjs"; + +test("heartbeat payload includes cli capability state", async () => { + const payload = await buildHeartbeatPayload({ + deviceId: "mac-studio", + name: "Mac Studio", + cliConnected: true, + guiConnected: false, + }); + + assert.equal(payload.device.capabilities.cli.connected, true); + assert.equal(payload.device.capabilities.gui.connected, false); +}); +``` + +- [ ] **Step 2: 跑测试,确认先失败** + +Run: + +```bash +node --test /Users/kris/code/boss/tests/local-agent-heartbeat-capabilities.test.mjs +``` + +Expected: +- FAIL,提示 heartbeat payload 未包含该字段 + +- [ ] **Step 3: 在 heartbeat 载荷里新增双能力状态** + +在 `/Users/kris/code/boss/local-agent/server.mjs` 的 heartbeat payload 里补: + +```js +capabilities: { + gui: { + connected: Boolean(config.guiConnected), + lastSeenAt: now, + lastActiveProjectId: "", + }, + cli: { + connected: true, + lastSeenAt: now, + lastActiveProjectId: "", + }, +}, +preferredExecutionMode: config.preferredExecutionMode || "cli", +``` + +- [ ] **Step 4: 在示例配置中补默认模式** + +在 `/Users/kris/code/boss/local-agent/config.example.json` 补: + +```json +"preferredExecutionMode": "cli", +"guiConnected": false +``` + +- [ ] **Step 5: 重跑测试,确认通过** + +Run: + +```bash +node --test /Users/kris/code/boss/tests/local-agent-heartbeat-capabilities.test.mjs +``` + +Expected: +- PASS + +- [ ] **Step 6: Commit** + +```bash +git add /Users/kris/code/boss/local-agent/server.mjs /Users/kris/code/boss/local-agent/config.example.json /Users/kris/code/boss/tests/local-agent-heartbeat-capabilities.test.mjs +git commit -m "feat: report gui cli capabilities in heartbeat" +``` + +### Task 5: Android 设备详情页显示 GUI/CLI 双能力并支持默认模式切换 + +**Files:** +- Modify: `/Users/kris/code/boss/android/app/src/main/java/com/hyzq/boss/BossApiClient.java` +- Modify: `/Users/kris/code/boss/android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java` +- Modify: `/Users/kris/code/boss/android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java` +- Test: `/Users/kris/code/boss/android/app/src/test/java/com/hyzq/boss/DeviceDetailActivityTest.java` +- Test: `/Users/kris/code/boss/android/app/src/test/java/com/hyzq/boss/BossApiClientDeviceModeTest.java` + +- [ ] **Step 1: 写失败测试,锁住设备详情显示 GUI/CLI 状态与默认模式** + +```java +@Test +public void renderDeviceShowsGuiCliCapabilitiesAndPreferredMode() { + TestDeviceDetailActivity activity = Robolectric + .buildActivity(TestDeviceDetailActivity.class, new Intent() + .putExtra(DeviceDetailActivity.EXTRA_DEVICE_ID, "device-1") + .putExtra(DeviceDetailActivity.EXTRA_DEVICE_NAME, "Mac Studio")) + .setup() + .get(); + + View content = activity.findViewById(R.id.screen_content); + assertTrue(viewTreeContainsText(content, "GUI")); + assertTrue(viewTreeContainsText(content, "CLI")); + assertTrue(viewTreeContainsText(content, "默认执行模式")); + assertTrue(viewTreeContainsText(content, "CLI")); +} +``` + +- [ ] **Step 2: 跑测试,确认先失败** + +Run: + +```bash +cd /Users/kris/code/boss/android && ./gradlew testDebugUnitTest --tests com.hyzq.boss.DeviceDetailActivityTest --no-daemon +``` + +Expected: +- FAIL,提示页面文本不存在 + +- [ ] **Step 3: 给 Android client 增加更新默认模式接口** + +在 `/Users/kris/code/boss/android/app/src/main/java/com/hyzq/boss/BossApiClient.java` 加: + +```java +public ApiResponse updateDeviceExecutionMode(String deviceId, String mode) throws IOException, JSONException { + JSONObject payload = new JSONObject(); + payload.put("preferredExecutionMode", mode); + return patch("/api/v1/devices/" + encodePathSegment(deviceId), payload); +} +``` + +- [ ] **Step 4: 在设备详情页新增能力状态与模式切换** + +在 `/Users/kris/code/boss/android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java` 的 `renderDevice(...)` 中追加: + +```java +JSONObject capabilities = device.optJSONObject("capabilities"); +JSONObject gui = capabilities == null ? null : capabilities.optJSONObject("gui"); +JSONObject cli = capabilities == null ? null : capabilities.optJSONObject("cli"); +String preferredExecutionMode = device.optString("preferredExecutionMode", "cli"); + +appendContent(BossUi.buildWechatMenuRow( + this, + "GUI", + gui != null && gui.optBoolean("connected", false) ? "已连接" : "未连接", + null, + null, + null +)); +appendContent(BossUi.buildWechatMenuRow( + this, + "CLI", + cli != null && cli.optBoolean("connected", false) ? "已连接" : "未连接", + null, + null, + null +)); +appendContent(BossUi.buildMenuRow( + this, + "默认执行模式", + "当前:" + ("gui".equals(preferredExecutionMode) ? "GUI" : "CLI"), + null, + v -> openExecutionModeDialog(preferredExecutionMode) +)); +``` + +- [ ] **Step 5: 实现切换弹窗和保存** + +在 `openExecutionModeDialog(...)` 里: + +```java +String[] items = new String[] { "GUI", "CLI" }; +new AlertDialog.Builder(this) + .setTitle("默认执行模式") + .setSingleChoiceItems(items, "gui".equals(currentMode) ? 0 : 1, null) + .setNegativeButton("取消", null) + .setPositiveButton("保存", (dialog, which) -> saveExecutionMode(selectedMode)) + .show(); +``` + +- [ ] **Step 6: 重跑测试,确认通过** + +Run: + +```bash +cd /Users/kris/code/boss/android && ./gradlew testDebugUnitTest --tests com.hyzq.boss.DeviceDetailActivityTest --tests com.hyzq.boss.BossApiClientDeviceModeTest --no-daemon +``` + +Expected: +- PASS + +- [ ] **Step 7: Commit** + +```bash +git add /Users/kris/code/boss/android/app/src/main/java/com/hyzq/boss/BossApiClient.java /Users/kris/code/boss/android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java /Users/kris/code/boss/android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java /Users/kris/code/boss/android/app/src/test/java/com/hyzq/boss/DeviceDetailActivityTest.java /Users/kris/code/boss/android/app/src/test/java/com/hyzq/boss/BossApiClientDeviceModeTest.java +git commit -m "feat: surface gui cli capabilities on android devices" +``` + +### Task 6: Web 设备详情页显示双能力并支持冲突策略 + +**Files:** +- Modify: `/Users/kris/code/boss/src/components/app-ui.tsx` +- Modify: `/Users/kris/code/boss/src/app/devices/page.tsx` +- Test: `/Users/kris/code/boss/tests/device-detail-capabilities-route.test.ts` + +- [ ] **Step 1: 写失败测试,锁住设备详情展示 GUI/CLI 与默认模式** + +```ts +test("device page renders gui cli capability badges and preferred mode", async () => { + const view = getDeviceWorkspaceView(await readState(), "mac-studio"); + assert.equal(view.selectedDevice?.capabilities?.gui.connected, true); + assert.equal(view.selectedDevice?.capabilities?.cli.connected, true); + assert.equal(view.selectedDevice?.preferredExecutionMode, "cli"); +}); +``` + +- [ ] **Step 2: 跑测试,确认先失败** + +Run: + +```bash +npx --yes tsx --test /Users/kris/code/boss/tests/device-detail-capabilities-route.test.ts +``` + +Expected: +- FAIL + +- [ ] **Step 3: 在 Web 设备详情区域渲染能力与默认模式** + +在 `/Users/kris/code/boss/src/components/app-ui.tsx` 的设备详情块加入: + +```tsx +