# AI Chat

> Claude Code agent chat: real tool use, permission prompts, session resume and a working folder — plus a plain Claude API fallback.

| | |
|---|---|
| Version | 2.4.18 |
| Category | AI & Agents |
| Permissions | network.remote, shell.exec |

# AI Chat

## Mục đích

Plugin chat với AI, chạy như một **Claude Code agent thực thụ** (giống app AgentSupport): làm việc trong một thư mục chọn được, hiện tool use (Read/Edit/Bash…) và kết quả, hỏi quyền Allow/Deny, thinking + thanh token context + slash commands. **Danh sách chat là cây workspace → session đọc trực tiếp từ các phiên Claude trên đĩa** (`~/.claude/projects`), không phải danh sách lưu trong plugin settings. Vẫn giữ nhánh Claude API (text thuần) làm dự phòng.

## File liên quan

- `plugins-library/ai-chat/manifest.json`
- `plugins-library/ai-chat/main/index.ts` — đăng ký IPC `plugin:ai-chat:*`
- `plugins-library/ai-chat/main/agent-session.ts` — bọc Claude Agent SDK (`query()` streaming input)
- `plugins-library/ai-chat/main/chat-manager.ts` — quản lý hội thoại, điều phối agent/API, lưu transcript
- `plugins-library/ai-chat/main/omp-cli.ts`, `omp-session.ts`, `omp-sessions.ts` — động cơ thứ hai **omp** (oh-my-pi):
  tự tải Bun + omp, chạy `omp --mode rpc` và đổi sự kiện sang cùng giao thức, đọc file phiên omp cho sidebar.
  Xem `.claude/features/omp-engine.md`.
- `plugins-library/ai-chat/main/plugin-control-mcp.ts` — MCP tool server chạy trong-process (Dev Mode
  only), cho AI agent của chính ai-chat list/toggle/đọc-ghi plugin KHÁC. Xem
  `.claude/features/plugin-mcp-control.md` cho toàn bộ kiến trúc (host-side + quy ước cho plugin mới).
- `plugins-library/ai-chat/package.json` — thêm dependency `zod` (dùng cho input schema của MCP tool)
- `plugins-library/ai-chat/main/providers/{base,registry,claude-code,claude}.ts`
- `plugins-library/ai-chat/widget.html`, `js/widget.js`, `css/widget.css` — gồm cả màn Settings nhúng
  (`#settingsScreen`, mở qua nút ⚙️ trong sidebar chat) — đây là giao diện Settings DUY NHẤT của
  ai-chat còn dùng được. (Mục "🛠️ Theo dõi dữ liệu gửi/nhận với Claude" đã bị gỡ — xem mục riêng
  bên dưới.) Màn này hiện là **sidebar bên trái**, mỗi section (AI Provider / Defaults / Instructions / Notifications / Snippets / Live Chat / Claude CLI / RTK / Codex / Backup) là một trang — dựng lúc runtime bởi `setupSettingsSidebar()` trong `js/widget.js` (gom mỗi `.settings-title` cấp cao nhất trong `.fullscreen-body` thành `.settings-page`; hẹp hơn ~520px thu thành icon rail), CSS scoped `#settingsScreen` trong `css/widget.css`. Xem `.claude/features/global-instructions.md`.
- `plugins-library/ai-chat/settings.html`, `js/settings.js`, `css/settings.css` — bản cũ, KHÔNG còn
  nơi nào load nữa (nút ⚙️ Settings trong mục Installed của Kazuo đã bị xóa, xem
  `.claude/features/plugin-settings-bridge.md`). Giữ nguyên file, không xóa, phòng khi cần dùng lại.
  Giao diện accordion mới của nó (commit restyle) đã được port sang màn Settings nhúng ở trên.
- `src/lib/plugin-bridge.ts` — `getPluginSettingsBridgeScript()` không còn được gọi ở đâu trong `src/`
  nữa (dead export, giữ lại). `src/config/PluginManager.svelte` không còn nút/panel Settings cho bất kỳ
  plugin nào — mỗi plugin tự lo giao diện Settings trong widget của chính nó.
- `plugins-library/ai-chat/locales/{en,vi}.json5` — chỉ có nhãn trạng thái kết nối (`chat.connection.*`), phần UI còn lại vẫn hardcode tiếng Việt
- `plugins-library/_shared/plugin-host-types.d.ts` — thêm `modules.claudeAgent`
- `electron/plugin-host.ts` — expose `@anthropic-ai/claude-agent-sdk` qua `ctx.modules.claudeAgent`
- `electron/plugin-ipc.ts` — `plugin:open-file-dialog` tôn trọng `options.properties` (để chọn thư mục)
- `src/components/WidgetLayer.svelte` — `ALWAYS_ALIVE_PLUGIN_IDS` (giữ webview ai-chat sống khi minimized)
- `src/components/PluginWidget.svelte` — prop `minimized` (ẩn bằng CSS thay vì unmount)
- `package.json` — dependency `@anthropic-ai/claude-agent-sdk`

## Cách hoạt động

- **Provider claude-code (mặc định)**: mỗi hội thoại gắn một `AgentSession`. Session mở một `query()` streaming-input sống lâu; mỗi lượt người dùng được push vào hàng đợi async, SDK stream trả về assistant/tool/result.
  - `AgentSession` serialize message SDK thành event đơn giản rồi phát qua `ctx.broadcast('plugin:ai-chat:event')`. Widget lắng nghe qua `window['kazuo-ai'].on('plugin-event')`.
  - Event: `connected` (sessionId + slash_commands), `stream_text`, `stream_thinking`, `assistant` (text/thinking/tool_use/tool_result), `tool_response`, `permission_request`, `result`, `context_window`, `done`, `error`, `mode_changed`, `slash_commands`, `task_status`.
  - **Quyền**: `canUseTool` chỉ được gọi ở chế độ cần duyệt (`default`/`plan`), không gọi ở `bypassPermissions` (auto). Nó phát `permission_request`, chờ widget trả lời qua `respondPermission`.
  - **Resume**: `sessionId` lưu trong conversation. Khi kết nối lại, nếu checkpoint `~/.claude/projects/<cwd-sanitized>/<id>.jsonl` tồn tại thì truyền `resume`.
  - `bypassPermissions` cần `allowDangerouslySkipPermissions: true`.
  - CLI: luôn dùng `claude` của hệ thống qua `pathToClaudeCodeExecutable` (bản bundled của SDK bị `npmPrune` xóa khỏi kho npm). Plugin tự dò/cài/nâng CLI — xem `.claude/features/claude-cli-install.md`.
  - **Thinking bị redact mặc định — đã verify thực tế**: nếu không set `thinking.display`, SDK vẫn phát `stream_thinking`/block `thinking` nhưng nội dung **rỗng** (chỉ là ping đếm token ẩn, xem `SDKThinkingTokensMessage`). Phải set `thinking: { type: 'adaptive', display: 'summarized' }` mới có tóm tắt suy nghĩ thật hiển thị được (verify: 0 ký tự → 160 ký tự khi bật `display:'summarized'`). `AgentStartConfig.thinking` nhận `'adaptive'` (mặc định) hoặc `'disabled'`.
  - **Subagent (Task tool) — đã verify thực tế**: `forwardSubagentText: true` được bật cứng trong `agent-session.ts` — mặc định SDK chỉ forward tool_use/tool_result của subagent (không text/thinking); bật cờ này để có luôn text/thinking subagent báo cáo. Message của subagent mang `parent_tool_use_id` + `subagent_type` (khớp `tool_use_id` của lệnh gọi Task) — `agent-session.ts` đính kèm 2 field này (`parentToolUseId`/`subagentType`) vào mọi event `assistant`/`tool_response`/`stream_text`/`stream_thinking` liên quan, để widget nhóm riêng.
  - **Task/subagent lifecycle**: xử lý system subtype `task_started`/`task_progress`/`task_updated`/`task_notification` → gộp thành event `task_status` (`{taskId, toolUseId, subagentType, description, status, summary, usage}`). `task_id → tool_use_id` được nhớ (`taskToToolUse` map) vì `task_updated` chỉ mang `task_id`. Bỏ qua khi `skip_transcript: true` (task nội bộ/ambient, CLI khuyến nghị ẩn khỏi transcript).
- **Động cơ omp (oh-my-pi) — chọn theo phiên**: ngoài Claude Code, mỗi tab có thể chạy bằng omp.
  - Chọn ở dòng "Động cơ" trong popup cài đặt phiên. Động cơ bị khoá sau tin nhắn đầu tiên.
  - omp chỉ chạy với **hồ sơ router** (có `ANTHROPIC_BASE_URL` + token). Hồ sơ "Mặc định" (gói Claude) bị chặn,
    vì omp chỉ đi được gói Claude bằng cách giả danh Claude Code (rủi ro khoá tài khoản).
  - `OmpSession` có cùng các hàm với `AgentSession` và phát ra cùng bộ event, nên widget không cần render riêng.
    Đổi model thì omp đổi ngay trên tiến trình đang chạy, không kết nối lại.
  - Phiên omp nằm chung sidebar (nhãn "omp"), xem lịch sử, mở lại, đổi tên và xoá được.
    Chưa hỗ trợ rẽ nhánh và xuất phiên.
  - `run-agent`/headless, review chỉ-đọc, agent-role có bộ tool riêng: **luôn chạy Claude Code**.
  - Tool của plugin đã gắn (phiên / thư mục / agent-role) dùng được với omp: ai-chat đăng ký chúng với omp qua lệnh RPC `set_host_tools` và tự chạy khi omp gọi.
  - Agent con của omp hiện như viên trạng thái subagent. Cảnh báo, lượt thử lại và lần rút gọn hội thoại của omp hiện thành dòng ghi chú trong khung chat.
  - Lý do + số đo (omp nhanh và tiết kiệm token hơn 2–4 lần với việc dài qua router, chất lượng như nhau):
    `plans/2026-09-26-omp-engine/reports/benchmark.md`.
- **Provider claude (API)**: đi qua `provider.sendMessageStream`, bắc cầu sang cùng bộ event (`stream_text`/`done`) để widget dùng chung một giao thức. Không có tool/quyền/session. Provider này có `modelSwitchMode: 'runtime'`, nên đổi model trong tab hiện tại vẫn hoạt động như trước.
- **Danh sách chat = session trên đĩa**: `configSources()` gom mọi config dir có `projects`: `~/.claude` của máy + `CLAUDE_CONFIG_DIR`/`CLAUDE_EXTRA_CONFIG_DIRS`. **Không còn tự dò WSL qua UNC** `\wsl.localhost\<distro>\home\*\.claude`: đó là share 9p của một máy ảo thường đang ngủ, đọc bằng lệnh **đồng bộ** trên tiến trình chính Electron — đo được ~250ms cho một lần đọc thư mục nguội, vài lần mỗi đợt quét cộng thêm một lần kiểm tra tồn tại cho mỗi phiên WSL, làm **đơ mọi cửa sổ tới 2,5 giây sau mỗi lượt chat**. Ai vẫn cần nguồn WSL thì khai báo thẳng đường dẫn đó trong `CLAUDE_EXTRA_CONFIG_DIRS`. Với mỗi nguồn, `withConfigDir()` set tạm `CLAUDE_CONFIG_DIR` rồi gọi `sdk.listSessions()`. `toLocalPath()` map cwd chéo nền tảng (`/mnt/c/…`→`C:\…`, `/home/…`→UNC). Gom theo cwd đã map (một thư mục có thể có phiên cả Win lẫn WSL), mỗi phiên mang `source` (Win/WSL) + `configDir`. Chọn phiên → `sdk.getSessionMessages()` (pin `CLAUDE_CONFIG_DIR` theo nguồn) để replay. Xóa qua `sdk.deleteSession()`.
- **Workspace ghim mặc định**: `workspaces()` luôn chèn 1 entry `pinned: true` với `cwd = process.cwd()` — thư mục root của dự án đang chạy app (plugin main chạy chung tiến trình Electron, không sandbox, nên `process.cwd()` trong plugin CHÍNH LÀ cwd của host). Luôn hiện đầu danh sách kể cả chưa có session nào; nếu đã có session thật ở đúng thư mục đó thì gộp chung (không tạo trùng). Widget: viền/nền vàng riêng + icon 📌, mặc định được chọn khi mở widget lần đầu (trước khi user bấm gì), nút `+` trên mỗi folder (kể cả 0 session) để mở phiên ngay.
- **Giới hạn resume WSL**: CLI Windows định vị phiên theo cwd đã sanitize; tên project dir của phiên WSL (`-mnt-c-…`) không khớp đường Windows nên **không live-resume phiên WSL từ Windows được**. Xử lý: phiên WSL **xem được lịch sử**; khi tiếp tục thì tạo **phiên mới cùng thư mục** (config Windows), không báo lỗi. Phiên Windows resume bình thường.

- **Provider/model metadata theo session**: khi Claude Code phát `connected` và có `sessionId`, `chat-manager.ts` lưu snapshot `{provider, model, contextSuffix, envProfileId}` vào `sessionMeta` cùng với `systemPrompt`. **`model` và `contextSuffix` là 2 field riêng** (base id + hậu tố `[..]`, vd `kimi-k3` + `[1m]`) — không bao giờ lưu id đã ghép vào metadata, chỉ ghép đúng 1 lần khi `agent.start()` (tránh session cũ chết khi proxy không còn nhận id ghép, vd lỗi "selected model may not exist"). Metadata cũ dạng `model: 'x[1m]'` được tách tự động khi đọc (migrate lướt dần). `workspaces()` merge metadata này vào mỗi session để sidebar hiện đúng provider/model; session cũ chưa có metadata fallback về `claude-code` + `sonnet` + `auto`.
- **Model switch runtime vs fixed-session**: provider expose `modelSwitchMode` qua `listProviders()`. Provider `claude`/`claude-code` mặc định vẫn runtime. Riêng profile proxy/WSL (`envProfileId === 'wsl-proxy'`, gồm cả `auto` trên session WSL khi profile này tồn tại) được widget coi là model cố định theo session: chọn model khác không đổi tab hiện tại mà hỏi tạo session mới cùng workspace với model mới.
- **Đa-tab song song**: `chat-manager` giữ `Map<tabId, TabState>`, **mỗi tab một `AgentSession` sống riêng** (một tiến trình `claude`) → chạy nhiều phiên cùng lúc. Mọi event mang `tabId` để widget route vào đúng tab (kể cả tab nền đang stream). Permission route theo `tabId` (map `permId→tabId`). IPC: `open-tab`/`close-tab`/`send`/`interrupt`/`set-*`/`permission` đều nhận `tabId`.
- **Kết nối CLI lười (lazy-connect) + chỉ báo trạng thái**: `open-tab` (IPC) giờ chỉ đăng ký state của tab (cwd/session/model/mode), **không** spawn tiến trình `claude` nữa (trước đây connect ngay khi mở tab). Tiến trình chỉ thực sự khởi động khi:
  1. Widget gọi IPC mới `connect-tab` — trigger duy nhất: người dùng **focus lần đầu** vào ô nhập (`#input`, hàm `onInputFocus` trong widget.js) trong khi tab đang ở trạng thái `disconnected`. Focus lại sau đó (đã connecting/connected) là no-op.
  2. Hoặc fallback có sẵn từ trước: `send()` (chat-manager.ts) tự gọi `connectAgentFor` nếu `t.agent` chưa có (vd. lỡ bấm Send mà chưa từng focus ô nhập).
  - `connectAgentFor` phát event `connecting` ngay trước khi tạo `AgentSession`; event `connected` có sẵn (system/init) coi như đã kết nối xong. Đổi model (`set-model`) / system prompt (`set-system-prompt`) vẫn dispose agent như cũ (không tự emit event) — widget tự set lại `disconnected` phía client ngay sau khi gọi các IPC này (`onModelChange`/`saveSysPrompt`) để tránh chỉ báo bị "kẹt" ở Connected trong khi tiến trình thật đã bị huỷ.
  - **UI**: chấm tròn + nhãn trong `subbar` (`#connStatus`/`#connDot`/`#connLabel`, ngay trên khung tin nhắn) — xám khi `disconnected`, vàng nhấp nháy khi `connecting`, xanh khi `connected`. Ẩn hẳn với tab dùng provider `claude` (API) vì loại này không có tiến trình sống để theo dõi. Mỗi tab giữ `connStatus` riêng (`t.connStatus`), phản ánh lại đúng khi chuyển tab (`activateTab` → `renderConnStatus()`).
  - Nhãn hiển thị đọc qua `window['kazuo-ai'].i18n` (khoá `chat.connection.connecting/connected/disconnected`, file `locales/{en,vi}.json5`), theo đúng ngôn ngữ đang chọn ở Settings > General — đây là chỗ đầu tiên trong ai-chat dùng cơ chế i18n chung của app; phần UI còn lại (placeholder, nút, hint...) vẫn hardcode tiếng Việt như trước, chưa refactor toàn bộ.
  - Chỉ ảnh hưởng tốc độ có sẵn danh sách slash-commands: trước đây connect ngay khi mở tab nên gõ `/` gần như luôn có sẵn danh sách lệnh; giờ nếu người dùng gõ `/` mà chưa từng focus ô nhập trong phiên đó, danh sách lệnh chỉ có sau khi kết nối xong (đã đánh đổi có chủ đích, xem thảo luận lúc thêm tính năng).
- **Đổi tên tab = đổi tên phiên thật**: `rename-session` gọi `sdk.renameSession()` (lưu `customTitle`, hiện luôn ở cây workspace). Sửa inline trên tab (double-click).
- **Rẽ nhánh hội thoại (bản widget của `/branch`)**: `fork-session` gọi `sdk.forkSession()` — copy transcript sang session mới, cắt tại một lượt hỏi nếu muốn, rồi mở thành tab mới. Bốn lối vào: nút 🌱 khi rê chuột lên bong bóng tin nhắn của mình (cắt tại đúng chỗ đó), chuột phải session ở sidebar, chuột phải tab, và gõ `/branch`. Session gốc không đổi. Chi tiết + các điểm dễ vấp: `.claude/features/branch-session.md`.
- **Bug đã sửa: minimize plugin xoá mất session/tiến trình.** `WidgetLayer.svelte` trước đây lọc bỏ MỌI plugin đang `minimized` khỏi danh sách render → Svelte unmount hẳn `<PluginWidget>` → cleanup của `onMount` xoá luôn thẻ `<webview>` (`plugins-library` chạy trong đó) → mất tiến trình renderer + toàn bộ state UI (tab đang mở, workspace đang chọn), dù CLI `claude` phía main process có thể vẫn sống (orphan, không dispose vì minimize không gọi `deactivatePlugin`). Widget thường (`WidgetConfig`) đã có cờ `keepAliveOnMinimize` để tuỳ chọn giữ sống, nhưng plugin thì không có cơ chế tương đương. Đã sửa: `WidgetLayer.svelte` hard-code `ai-chat` vào tập `ALWAYS_ALIVE_PLUGIN_IDS` — plugin này không bao giờ bị lọc khỏi render dù đang minimized; thay vào đó truyền prop `minimized` xuống `PluginWidget.svelte`, ẩn bằng `display:none` (webview vẫn sống) giống hệt cách `Widget.svelte`/`TerminalWidget.svelte` đã làm cho widget có `keepAliveOnMinimize`. Các plugin khác không đổi hành vi (vẫn unmount khi minimize).
- **Bug đã sửa: chạy lại plugin (tắt/bật, đóng app, đóng cửa sổ pop-out) không mở lại workspace/session đang mở.** `widget.js` chỉ giữ `tabs`/`selectedWs`/`activeTabId` trong biến JS của trang, không lưu ở đâu; `init()` cũ luôn khởi động với 0 tab mở. Đã sửa: mỗi lần `activateTab()` đổi lựa chọn (tab/workspace), widget gọi `persistLastActive()` → IPC `plugin:ai-chat:save-last-active` lưu `{cwd, tab: {cwd, originalCwd, configDir, sessionId, systemPrompt, model, contextSuffix, mode, provider, title, source}}` vào plugin settings (`chat-manager.ts`: `getLastActive`/`saveLastActive`, key `lastActive`, cùng cơ chế với `sessionMeta`). `init()` gọi `refreshTree().then(restoreLastActive)`: đọc lại qua IPC `plugin:ai-chat:last-active`, nếu workspace đã lưu còn tồn tại trên đĩa thì `selectWorkspace()` lại đúng workspace đó, và nếu có `sessionId` thì `reopenPersistedTab()` (giống hệt `openTabForSession`) mở lại đúng tab đó (resume qua checkpoint có sẵn). Chỉ khôi phục **workspace + tab active gần nhất** (không khôi phục toàn bộ tab từng mở) — các tab khác vẫn còn nguyên trong sidebar, bấm vào để mở lại khi cần. Biến `suppressPersist` chặn việc `activateTab(null)` (placeholder lúc boot) ghi đè state đã lưu trước khi `restoreLastActive()` kịp đọc.
- **Bug đã sửa: chuyển tab không cache/nạp lại nội dung đang gõ dở.** Mọi tab dùng chung một ô soạn tin nhắn (`#input`), nhưng `activateTab()` trước đây không hề đụng tới nó khi đổi tab — nội dung gõ dở của tab cũ vẫn còn nguyên trong ô, "dính" theo sang tab mới thay vì được cache lại theo đúng tab. Đã sửa: thêm field `draft` cho mỗi tab; `activateTab()` giờ lưu `$('input').value` vào `prev.draft` của tab đang rời đi (đọc qua `activeTab()` trước khi đổi `activeTabId`), rồi nạp `t.draft || ''` vào ô input của tab mới (kèm `autoResize`/`updateCharCount`/`hideAutocomplete`).
- **System prompt riêng mỗi phiên**: lưu ở plugin settings key `sessionMeta` ({[sessionId]: {systemPrompt}}) — thứ duy nhất plugin tự lưu; còn lại đọc từ đĩa.
- **CLI**: dùng `claude` hệ thống, thứ tự `KAZUO_CLAUDE_CLI` → `claudeCli.binaryPath` (máy tự ghi) → `~/.local/bin/claude[.exe]`. Chỉ nhận file **spawn trực tiếp được**, từ chối `.cmd`/`.bat` (Node ném EINVAL trên Windows) — nên KHÔNG dò trần tên `claude` trên PATH. Ô "CLI Path" trong Settings đã bị xóa; xem `.claude/features/claude-cli-install.md`.
- **Chọn thư mục**: widget gọi `window['kazuo-ai'].openFileDialog({properties:['openDirectory']})` (native dialog). Không cần modal browse như AgentSupport.
- **Gửi ảnh trong khung chat (chỉ provider claude-code)**: `sendMessage()` (widget.js) gom `pendingImages` (mảng `{mimeType, data, previewUrl, name}`, thêm qua nút 📎/paste/kéo-thả) và gọi `invoke('send', tabId, text, images)` với `images = [{mimeType, data}]`. `agent-session.ts` → `send(text, images)` đóng gói thành content block chuẩn Anthropic Messages API: `[{type:'image', source:{type:'base64', media_type, data}}, ..., {type:'text', text}]` (bỏ qua block text nếu rỗng — cho phép gửi ảnh không kèm chữ). Không hỗ trợ provider `claude` (API) — `pickImages()`/`sendMessage()` chặn và báo lỗi nếu tab đang dùng provider đó.
  - Đọc file thành base64 hoàn toàn ở renderer qua `FileReader.readAsDataURL()` — không cần IPC đọc file mới (khác với `openFileDialog` chỉ trả về path).
  - Giới hạn 8MB/ảnh (`MAX_IMAGE_BYTES` trong widget.js), không giới hạn số lượng ảnh mỗi lượt gửi.
  - Lịch sử: `sessionHistory()` (chat-manager.ts) đã nhận diện block `{type:'image'}` nằm trực tiếp trong content của tin nhắn user (không chỉ trong tool_result — xem bug đã sửa bên dưới) để ảnh đã gửi hiện lại đúng khi mở lại phiên cũ.

## IPC channels (`plugin:ai-chat:`)

Động cơ omp: `set-engine` (tabId, engine — `{ok, error?}`, bị khoá khi tab đã có sessionId), `omp-status` (trạng thái cài đặt), `omp-install` (tải Bun + omp, phát `omp_install_progress`/`omp_install_done`; từ chối khi đang có phiên omp chạy). `open-tab` nhận thêm `engine`.

`workspaces`, `session-history` (sessionId, configDir), `session-meta` (sessionId), `session-delete` (sessionId, configDir), `workspace-delete` (cwd đã map), `rename-session` (sessionId, title, configDir), `fork-session` (sessionId, configDir, upToUserIndex?, title? — trả `{sessionId}` của nhánh mới), `open-tab` (tabId, cfg — chỉ đăng ký, không connect), `connect-tab` (tabId — kết nối CLI lười, gọi khi focus ô nhập lần đầu), `close-tab` (tabId), `send` (tabId, text, images? — `{mimeType, data}[]`), `interrupt` (tabId), `permission` (tabId, id, allow), `set-mode` (tabId, mode), `set-model` (tabId, provider, model, contextSuffix?), `set-system-prompt` (tabId, prompt), `providers`, `reconfigure`, `last-active` (đọc workspace/tab đã lưu), `save-last-active` (cwd, tab).

## Chạy agent thay cho plugin khác (headless / visible execution)

Ngoài UI chat bình thường, ai-chat expose 2 cách cho **plugin khác** chạy 1 lượt Claude Code agent
theo lập trình, không qua widget:

- **Headless** (`runAgent()`/`plugin:ai-chat:run-agent`) — chạy 1 lượt rồi luôn tự đóng tab, không ai
  theo dõi được giữa chừng. Dùng cho automation im lặng (vd `agent-review`'s review mã nguồn). Chi
  tiết: `.claude/features/headless-run-agent.md`.
- **Visible** (`createExecutionService`'s `startExecution`/`executionState`/`revealExecution`) — mở
  tab và **giữ tab sống** sau khi dispatch để một widget mở sau có thể "bắt sống" (attach) lại, thay
  vì tự đóng. Dùng khi cần người dùng thấy được tiến trình đang chạy. Chi tiết:
  `.claude/features/visible-execution-service.md`.

Cả hai đều chỉ lộ qua **global service main→main** `globalThis.__kazuoAiChat` (`invoke` của Electron
chỉ đi renderer→main, nên main process của plugin khác không gọi thẳng IPC handler của ai-chat được;
mọi plugin backend chạy chung 1 tiến trình main nên ai-chat publish hàm lên biến global, guard
`version === 1` khi gỡ ở `deactivate()`). Cờ `capabilities.readOnlyReview: true` trên service này báo
cho bên gọi biết chế độ **chỉ-đọc có giới hạn quyền cứng** (`main/tool-policy.ts`'s
`buildReadOnlyReviewPolicy` — allowlist tool tường minh + chặn hết MCP tool + hook kiểm tra đường dẫn
đã resolve symlink) đã sẵn sàng cho các lượt chạy truyền `readOnlyScopeRoots`; thiếu cờ này (ai-chat
bản cũ) là lý do các bên gọi như `agent-review` từ chối chạy và yêu cầu khởi động lại app thay vì âm
thầm chạy không giới hạn quyền.

## UI

- **Dock engine**: Golden Layout v2, vendor bản UMD offline vào `js/vendor/golden-layout.min.js` + `goldenlayout-base.css` + `goldenlayout-dark-theme.css` (không CDN). Build lại khi cần nâng version: `npm i golden-layout@2 && npm run build:bundles` trong thư mục tạm, copy `dist/bundle/umd/golden-layout.js` rồi commit đè 3 file trên. Chi tiết: `.claude/features/dockable-ui.md`.
- Sidebar: cây **workspace (thư mục) → session**; mỗi folder mở/đóng, có số phiên, nút `+` tạo phiên mới, và menu chuột phải (`📂 Mở thư mục` — mở workspace trong File Explorer qua IPC `open-folder`/`shell.openPath`; `🎨 Đặt màu`/`🏷️ Đặt nhãn` — gắn màu accent + nhãn để phân biệt, lưu qua `set-session-deco`/`set-workspace-deco`; `Delete` để xóa workspace/session). Mỗi session hiện **badge nguồn (Win/WSL)** + tiêu đề + dòng phụ **provider · model** (ưu tiên metadata từ tab đang mở, fallback metadata đã lưu theo `sessionId`). Nút 📁 (mở thư mục mới) và ↻ (làm mới) ở đầu sidebar.
- **Thanh tab theo workspace**: tab bar chỉ hiện tab của **workspace đang chọn** (`selectedWs`). Click folder ở sidebar = chọn workspace → load lại danh sách tab đang mở của nó (nhớ tab active gần nhất qua `lastActiveByWs`); tab các workspace khác vẫn chạy nền. Workspace đang chọn được tô sáng + badge số tab mở. Mỗi tab: title, dấu ● khi đang chạy, nút × đóng; **double-click để đổi tên** (inline, lưu qua rename-session); **chuột phải mở menu** với `Export conversation` (mở UI chọn `Full` hoặc `Optimize`, rồi chọn nơi lưu file `.json`), `Import conversation` (xem mục riêng bên dưới) và `Đóng tab`. `Full` ghi raw session messages; `Optimize` chỉ giữ `{role, content}` từ text block user/assistant, bỏ thinking/tool_use/tool_result/tool nội bộ. Chọn phiên ở sidebar → mở tab mới (hoặc focus tab đã mở), tự chọn workspace của phiên. Nhiều tab chạy song song.
- **Import conversation** (menu chuột phải tab, cạnh `Export conversation`): chọn 1 file `.json` do chính tính năng Export tạo ra (mode `full` hoặc `optimize`), xác nhận qua `confirm()`, rồi tạo 1 tab **mới cùng thư mục** với tab đang chuột-phải vào và tự động gửi lại toàn bộ nội dung cũ (`role`/`content` đã chuẩn hoá qua `optimizedSessionMessages()`) làm **1 tin nhắn mở đầu** để Claude đọc ngữ cảnh rồi tiếp tục. **Không phải resume session thật** — file export không mang `sessionId` sống nên không cấy được vào 1 phiên CLI mới (giới hạn tương tự phần "Giới hạn resume WSL" ở trên); Claude chỉ "đọc lại" nội dung cũ dưới dạng text bình thường, không phân biệt turn như lịch sử thật. IPC: `import-conversation` (filePath) → `{ok, messages?, error?}` (`chat-manager.ts`'s `importConversationFile`).
- Topbar: sidebar toggle, tiêu đề là session title của tab hiện tại (tên workspace nằm trên dock tab của khung chat), nút `+` (phiên mới cùng thư mục), ✎ (system prompt). Nút mở thư mục mới và nút `/` skills không còn nằm trong topbar vì đã thừa với sidebar/input.
- Subbar: **chỉ báo trạng thái kết nối CLI** (chấm + nhãn, xem mục "Kết nối CLI lười" ở trên), đường dẫn cwd, chọn model, chọn chế độ quyền (Tự động / Tự sửa file / Hỏi trước / Kế hoạch). Responsive: cwd-label tự co/ellipsis, ẩn hẳn ở ≤640px và các select co lại — xem `.claude/features/subbar-layout.md`.
- Thanh context window (token) dưới subbar.
- Messages: bong bóng user/assistant, khối thinking (nay có nội dung thật, không rỗng), khối tool (bấm để mở input/kết quả), prompt quyền, dòng result (thời gian · token · cost).
- **Subagent turn** (`.subagent-turn` trong widget.js): text/thinking/tool_use/tool_result mang `parentToolUseId` được `ensureSubagentGroup()` gom vào một khối lồng riêng (viền xanh, nhãn "🤖 Subagent: <type>") thay vì trộn vào luồng chính — tái dùng nguyên `renderAssistant`/`renderToolResponse`/`appendStreamText`/`appendStreamThinking` bằng cách truyền một "sink" object cùng shape với tab (`{msgEl, assistant, thinking, lastToolName}`).
- **Subagent status pill** (`.subagent-status`, `updateTaskStatus()`): 1 dòng cập nhật tại chỗ theo `taskId`, chuyển "Đang chạy" → "Xong"/"Lỗi" khi có event `task_status`.
- **Raw Data** (`pinRaw`/`rawHostFrom`/`toggleMsgRaw` trong widget.js): chuột phải một mục bất kỳ trong transcript (bong bóng assistant, thẻ tool, kết quả tool, khối thinking, ảnh, dòng result, bảng tác vụ) → `{ } Dữ liệu thô` bung JSON gốc mà CLI gửi, ngay trong chính mục đó. Object được ghim vào phần tử lúc vẽ (`node._rawData`) nên không phải dựng lại từ HTML. Bong bóng `.msg.user` cố ý không có (đã có menu lưu câu mẫu / sao chép riêng), thẻ subagent cũng không (đã có nút `{ }` riêng). Chi tiết: `.claude/features/raw-data-context-menu.md`.
- **Markdown đầy đủ** (`renderMarkdown` trong widget.js): heading, **bảng** (`| a | b |` + `|---|`), list (ul/ol), blockquote, hr, fenced code (tách ra placeholder `@@CODEn@@` trước để không bị mangle), inline (code/bold/italic/strikethrough/link). Text đã stream cũng được convert markdown khi `assistant` event tới (trước đây bong bóng stream giữ nguyên plain text → bảng không hiện). CSS: `.md-table/.md-h/.md-list/.md-quote/.md-hr`.
- `handlePluginEvent` bọc try/catch → `console.error('[ai-chat] ...')` nếu một event render lỗi, để không làm chết handler cho các event sau + dễ chẩn đoán qua DevTools.
- Autocomplete slash khi gõ `/`; modal skills; modal system prompt.
- **Đính ảnh / tệp vào tin nhắn** (nút 📎 `#attachBtn`, `#attachPreview`, `#imageFileInput` trong widget.html): 3 cách nạp dùng chung `handleAttachedFiles()` — định tuyến theo loại file: (a) **ảnh** → `attachImageFile()` → `readImageAsDataUrl()` (FileReader) đẩy vào `pendingImages` **và** chèn token tham chiếu `[image#N]` vào ô nhập tại con trỏ (`insertComposerToken()`); (b) **tệp khác** → `insertFilePath()` lấy đường dẫn tuyệt đối rồi chèn vào ô nhập (bọc nháy kép nếu có khoảng trắng) để Claude Code tự đọc từ đĩa. 3 nguồn nạp: (1) bấm 📎 mở `<input type="file" accept="image/*" multiple">` ẩn, (2) paste (Ctrl+V) trên `#input` — bắt mọi `clipboardData.items` loại `file`, (3) kéo-thả file vào `#inputArea` (`dragover`/`drop`, class `.drag-over`). Ảnh chờ gửi hiện thumbnail 52×52 trong `#attachPreview` (nút × bỏ từng ảnh, `removePendingImage()`); gửi xong ảnh hiện trong bong bóng bằng `renderImage()`.
  - **Đánh số `[image#N]` theo từng tin nhắn**: số = vị trí ảnh trong `pendingImages` của tin nhắn hiện tại; gửi xong reset về `[image#1]`. Xóa 1 ảnh (`removePendingImage` → `removeImageTokenAt` + `renumberImageTokens`) thì gỡ token đó + đánh lại dãy `1..K` liền mạch. Agent ghép token với image block theo thứ tự trong lượt nói.
  - **Tô màu tham chiếu** (`renderRefText()`, CSS `.ref-chip`): tin nhắn user đã gửi tô xanh `#6495ed` cho `[image#N]`, URL, đường dẫn file/thư mục (drive/UNC/quoted/`/a/b`). Chỉ ở bong bóng chat — ô nhập (textarea) không tô được.
  - **Lấy đường dẫn đầy đủ** (`resolveFilePath()` → `window.__dwNative.getPathForFile`): Electron 32 đã gỡ `File.path`, nên host gắn preload tối thiểu `electron/webview-preload.ts` (expose `webUtils.getPathForFile`) vào mọi plugin webview qua thuộc tính `preload` (URL lấy từ `electronAPI.webviewPreloadUrl` — xem `PluginWidget.svelte`). Thiếu (chế độ web dashboard) thì fallback về tên tệp + cảnh báo `attach.pathUnavailable`.
- **Đã test render bằng jsdom** (harness ngoài): mô phỏng chuỗi event thật (stream_text → assistant có tool_use → tool_response) và xác nhận DOM sinh ra đúng `.tool` (Read/Bash/kết quả) + `.md-table/.md-h/.md-list`. Đây là cách đúng để verify tầng widget (trước đây chỉ verify tiến trình main phát event, KHÔNG đủ).
- **Xếp cặp tool_use ↔ tool_result** (`appendToolBlock`/`renderToolResult`/`appendInlineResult` trong widget.js): mỗi thẻ tool_use lưu chính nó vào `t.toolCards[block.id]`. Khi tool_result tới, nếu `tool_use_id` khớp một thẻ đã lưu thì **nối kết quả vào bên trong chính thẻ đó** (`.tool-result-inline`) thay vì tạo khối rời phía sau — đúng khi nhiều tool chạy song song và kết quả trả về không theo thứ tự gọi. Không tìm thấy thì fallback về khối rời như cũ (`appendResultBlock`). Đã verify jsdom: 2 tool_use (A, B) + result B đến trước result A → mỗi card vẫn chứa đúng kết quả của nó (không lẫn), không sinh khối rời.
- **Bug đã sửa: câu hỏi `AskUserQuestion` trong lịch sử cũ hiện JSON thô, sau đó làm cho tương tác lại được.** `renderToolUse` (widget.js) có case riêng cho từng loại tool (TodoWrite, Task, Edit, Write, Read, ExitPlanMode...) nhưng thiếu case cho `AskUserQuestion` → khi mở lại phiên cũ có câu hỏi này, nó rơi vào fallback chung và in JSON thô của `input.questions`. Đã thêm `renderAskUserQuestionHistory(t, questions)` xử lý riêng.
  - **Đã verify thực tế bằng 3 script probe gọi thẳng SDK (đã xoá sau khi verify xong)**: (1) khi tiến trình agent bị đóng/abort lúc `AskUserQuestion` còn treo, chính SDK tự ghi 1 `tool_result` lỗi vào transcript tại thời điểm abort (không phải do code plugin) — nên câu hỏi treo hầu như luôn có sẵn tool_result (dù là lỗi), trừ khi tiến trình bị **kill cứng thật sự** (SIGKILL, đã dựng lại bằng `child_process.spawn` + `child.kill('SIGKILL')` để verify) mới ra transcript hoàn toàn không có tool_result. (2) Dù ở tình huống nào, **resume KHÔNG bao giờ tự gọi lại `canUseTool`** cho lời gọi tool đã treo đó — kể cả khi (3) resume xong gửi thêm 1 tin nhắn mới ngay sau đó (model chỉ lờ đi và trả lời tin mới bình thường). Kết luận: promise chờ trả lời chỉ sống trong bộ nhớ tiến trình cũ, chết theo tiến trình — **không có cách nào làm SDK tự hỏi lại y hệt câu đó**, dù resume kiểu gì.
  - **Vì vậy hướng đi**: không cố "hồi sinh" đúng lời gọi cũ (bất khả thi, verify ở trên) — `renderAskUserQuestionHistory` render lại UI nút chọn/option y hệt bản live (tái dùng cấu trúc `renderAskUserQuestion`), nhưng khi bấm "Gửi câu trả lời" thì gọi `sendAsFollowupMessage(tabId, text)` — gửi 1 **tin nhắn chat mới bình thường** dạng `Trả lời câu hỏi trước đó:\n- <câu hỏi>: <trả lời>` (đã verify: gửi tin nhắn mới sau resume hoạt động bình thường). Có ghi chú nhỏ (`.perm-history-note`) trong khối để minh bạch với người dùng đây là tin nhắn mới, không phải trả lời trực tiếp cho lời gọi tool cũ. `sendAsFollowupMessage` là bản rút gọn của phần đuôi `sendMessage()` (thêm bong bóng user + set streaming + gọi IPC `send`), dùng `findTab(tabId)` vì có thể được gọi từ trong 1 nhóm subagent lồng (`sink !== t`) nhưng vẫn cần tab thật để gửi/theo dõi streaming.
- **Bug đã sửa: ảnh trong kết quả tool (tool_result) hiện JSON thô khi mở lại phiên cũ.** Live path (`agent-session.ts` → `serializeToolResult`) tách ảnh (`content` là mảng `[{type:'image', source:{...}}]`, ví dụ `Read` trên file ảnh) thành event `type:'image'` để widget vẽ `<img>` thật. Nhưng `chat-manager.ts` → `sessionHistory()` (đọc lịch sử tĩnh từ đĩa) thiếu case này, `JSON.stringify()` toàn bộ content không phải string → hiện khối JSON base64 khổng lồ thay vì tấm ảnh. Đã thêm cùng logic nhận diện ảnh vào `sessionHistory()`, đẩy ra `{type:'image', mimeType, data}` — widget đã có sẵn `renderImage()` xử lý đúng loại này trong `renderHistory()`.
- **Bug đã sửa: tool_result trong lịch sử cũ bị bỏ qua hoàn toàn.** `renderHistory` trước đây chỉ đọc `m.tools` khi `m.role==='assistant'` — nhưng tool_result nằm ở message `role==='user'`, nên khi mở lại phiên cũ, mọi kết quả tool biến mất, tool_use trông "mồ côi". Đã sửa: xử lý `m.tools` cho cả hai role.
- **Thanh ghim tin nhắn user = điều hướng lùi qua từng tin đã gửi** (`.pinned-user-bar`/`pinnedUserText`, `updatePinnedUserBar`/`scrollToLastUserMessage` trong widget.js): mỗi tab giữ `userMsgEls` (mảng phần tử DOM tin nhắn user top-level, cũ→mới) + `userMsgCursor` (index tin sẽ được cuộn tới ở lần bấm kế tiếp). Bấm thanh ghim: cuộn tới `userMsgEls[userMsgCursor]` (căn giữa khung chat), rồi lùi `userMsgCursor` về 1 tin cũ hơn và đổi luôn chữ hiển thị trên thanh ghim theo tin đó — bấm lặp lại để đi ngược qua toàn bộ tin đã gửi trong tab. Hết tin cũ hơn (`userMsgCursor < 0`) thì thanh ghim tự ẩn. Gửi tin mới hoặc mở lại lịch sử phiên (`renderHistory`) đều reset `userMsgCursor` về tin mới nhất. Đổi tab thì thanh ghim phản ánh đúng con trỏ riêng của tab đó. **Tự hiện lại khi cuộn về đáy** (`handleMsgScroll`/`scrollActiveToBottom`): cuộn tay (hoặc bấm nút cuộn-xuống-đáy) chạm đáy khung chat (`autoScroll` bật lại) sẽ reset `userMsgCursor` về tin mới nhất + gọi lại `updatePinnedUserBar` — tránh bug thanh ghim bị "kẹt ẩn" mãi sau khi đã lùi hết vòng, chỉ chờ tin mới mới hiện lại.
- **Bug đã sửa: chiều cao tool/message co lại thành 1 đường ngang sau nhiều lượt.** Nguyên nhân: `.messages`/`.subagent-turn-body` là flex column + `overflow-y:auto`; theo spec flexbox, container có scroll thì `min-height` tự động của con = 0 (không phải theo nội dung) — cộng `flex-shrink:1` mặc định khiến trình duyệt **co từng khối** để nhồi vừa thay vì cuộn, càng nhiều tool/message thì co càng mạnh. Fix: `flex-shrink: 0` cho `.msg, .tool, .perm, .subagent-turn, .subagent-status, .tool-image` — ép giữ chiều cao tự nhiên, để container cuộn (đúng ý muốn).

## Format hiển thị tool use / permission / option (giống claude-web-ui)

Port từ dự án tham chiếu `claude-web-ui` (`static/js/render/{tools,perms,results,todo}.js`), điều chỉnh cho đúng hành vi SDK Node đã verify thực tế:

- **Tool use theo từng loại** (`renderToolUse` trong widget.js): TodoWrite (checklist mở sẵn, nhóm theo trạng thái), Edit/MultiEdit (khối old/new đỏ/xanh), Write (preview nội dung), Read (đường dẫn + offset/limit), Task/Agent (badge loại subagent + prompt), ExitPlanMode (thẻ tóm tắt kế hoạch, không tương tác), fallback chung cho Bash/Glob/Grep/WebFetch/WebSearch/MCP (dòng tóm tắt + JSON thô).
- **Tool result**: nhận diện lỗi qua field `is_error` thật của SDK (đã verify `block.is_error` có trên tool_result — đáng tin hơn dò chuỗi `<tool_use_error>`), tự nhận diện diff (unified diff → tô màu +/-), tự nhận diện payload TodoWrite dạng JSON → render checklist thay vì JSON thô.
- **Permission — AskUserQuestion ("option")**: hiện nút chọn theo từng câu hỏi (label/description) + ô nhập "Khác..." tự do, nút "Gửi câu trả lời" gộp mọi câu. **Giao thức đã verify khác với Python reference**: SDK Node **không** áp dụng `updatedInput: {answers,...}` (model báo "chưa trả lời"). Cách đúng đã verify: `respondPermission` với AskUserQuestion luôn trả về `{behavior:'deny', message:'User answered:\n- <câu hỏi>: <trả lời>'}` — model đọc message và tiếp tục đúng.
- **Permission — ExitPlanMode**: thẻ riêng (markdown kế hoạch + đường dẫn file), nút Duyệt/Từ chối + phím tắt y/n (chỉ tác dụng khi tab đó đang active — tránh xung đột phím tắt giữa nhiều tab). Duyệt xong tự chuyển mode tab đó sang `bypassPermissions` (khớp hành vi reference `_switchToAutoAfterPlan`) để không bị hỏi lại từng tool sau khi kế hoạch được duyệt.
- **Permission — thường + suggestions**: nút Allow/Deny + phím tắt y/n (tab-scoped), cộng nút gợi ý "luôn cho phép/luôn từ chối theo rule" từ `canUseTool`'s `options.suggestions` (SDK `PermissionUpdate[]`) — bấm gửi `{allow:true, updatedPermissions:[su]}`.
- `agent-session.ts`: `makeCanUseTool()` giờ nhận tham số thứ 3 (`opts`) để lấy `suggestions`; phát hiện `AskUserQuestion` để đính `question_message`/`question_options`/`all_questions` vào event `permission_request`. `respondPermission(id, response)` nhận `boolean | answersMap | {allow, updatedPermissions}` tùy loại.

## Theo dõi traffic gửi/nhận với Claude (backend-only, UI đã gỡ)

> **Lưu ý**: mục Settings "🛠️ Theo dõi dữ liệu gửi/nhận với Claude (Dev Mode)" **đã bị gỡ**
> khỏi cả `widget.html` (màn Settings nhúng) lẫn `settings.html`. Phần ghi log backend bên dưới
> **vẫn chạy** nhưng hiện **không còn giao diện nào tiêu thụ** — giữ lại IPC/ring buffer để có thể
> debug hoặc dựng lại UI sau này.

- **Chỉ áp dụng cho provider `claude-code`** (CLI Agent) — đây là provider duy nhất có khái niệm
  workspace/session thật; provider `claude` (API) không có session nên không được theo dõi.
- **Ghi log**: `chat-manager.ts` có 1 ring buffer `traffic` (tối đa 300 dòng, cùng quy ước với
  `pluginLogs` phía host) — ghi LUÔN. Ghi ở 2 điểm: `send()` (hướng `send`, mỗi lượt user gửi
  tin/ảnh) và `handleAgentEvent()` (hướng `receive`, chỉ với các loại event có ý nghĩa —
  `connected`/`assistant`/`tool_response`/`permission_request`/`result`/`error`/`done` — bỏ qua các
  delta stream token-by-token `stream_text`/`stream_thinking` và event thuần UI để log không bị rác).
  Mỗi dòng ghi kèm `tabId`/`cwd`/`sessionId` tại thời điểm đó.
- **Lấy dữ liệu**: `getTraffic()` + IPC `plugin:ai-chat:traffic` (trong `main/index.ts`) và broadcast
  `emit({type:'traffic', entry})` qua kênh `plugin:ai-chat:event` vẫn còn nhưng không còn listener nào
  ở frontend.

## Test

```bash
cd plugins-library/ai-chat
node --check js/widget.js
node --test "test/**/*.test.js"
```

Sửa `main/*.ts` thì phải build lại **trước** khi chạy test (test main-process nạp `main/*.js`):

```bash
cd ../..            # thư mục gốc drawwindow
npx tsc -p tsconfig.plugins.json
```

⚠️ Phải có dấu nháy quanh glob. Từ Node 22, `node --test test/` coi tham số là **glob**, không phải thư mục
→ lỗi `MODULE_NOT_FOUND`. `node --test` không tham số thì nhặt cả `test/pretooluse-hook.demo.mjs` và fail.

| File | Phạm vi |
|---|---|
| `test/attach-filter.test.js`, `test/fs-index.test.js`, `test/rtk-*.test.js` | tiến trình main (`main/*.js` — **sản phẩm biên dịch** của `tsc -p tsconfig.plugins.json`; sửa `main/*.ts` thì phải build lại trước khi chạy test) |
| `test/widget-harness.js` | factory dựng `widget.html` + `js/widget.js` thật trong jsdom với bridge `window['kazuo-ai']` giả — không phải file test |
| `test/widget-pane.test.js` | 50 test tầng widget: T1-T18 characterization (Đợt 1), T19-T24 pane component, T25-T35 nhiều pane, T36-T41 kéo-thả tab, T42-T50 lưu/khôi phục + phím tắt + báo hiệu |

`jsdom` là devDependency ở **root** `drawwindow/package.json` (Node tự tìm ngược lên) — cố ý **không**
thêm vào `plugins-library/ai-chat/package.json`, vì file đó được cài vào `userData/plugins/` của người dùng.

**Harness giả lập 5 thứ jsdom không có** — mọi thứ đi qua chúng phải verify bằng tay, không tin test:
`ResizeObserver` (thiếu nó thì Golden Layout luôn ném và widget luôn rơi `dockFallback`),
`Element.scrollIntoView` (widget gọi **không bọc try** ở js:1394/3729/3851/5723), `Element.scrollTo`,
`window.matchMedia`, `window.__dwNative` (bridge clipboard do host tiêm).

Test có nhãn `[LOCK]` phải xanh vĩnh viễn. Nhãn `[CHANGED IN Pn]` là hành vi mà kế hoạch multi-pane
(`plans/2026-07-28-multi-pane-chat-sessions/`) **đã cố ý** đổi ở đợt n:

- **T14′** — phím `y`/`n`/`Escape` của hộp xin quyền không còn ăn khi con trỏ đang ở ô nhập
  (`canTakeHotkey`). Với nhiều khung, hành vi cũ khiến gõ chữ "n" ở khung này từ chối quyền của khung kia.
- **T2′ / T6′ / T8′** — "đang hiện" và bộ lọc workspace giờ là **theo từng khung** (`pane.activeTabId`,
  `pane.filterWs`), không còn là biến toàn cục.

T17 giữ nguyên: heading markdown `# input` vẫn sinh `id="input"` toàn cục, nhưng không còn hại — không
call site nào trong widget đọc `$('input')` nữa, mọi tra cứu trong khung đi qua `pe(pane, 'input')`.

## Kiến trúc tầng widget: chat panes

Vùng chat không còn là một khối duy nhất. `<template id="chatPaneTpl">` được nhân bản
thành N **pane**, mỗi pane có dải tab, ô nhập, dropdown và bộ lọc thư mục riêng; `tabs[]`
vẫn phẳng và mỗi tab mang `t.paneId`. Truy cập DOM trong pane đi qua `pe(pane, name)`
(theo `data-el`), không dùng `getElementById`. Chi tiết đầy đủ — 6 quy tắc kiến trúc,
kéo-thả, lưu/khôi phục, 2 IPC backend mới (`session-exists`, `open-tab` trả `false`) —
xem `.claude/features/multi-pane-chat.md`.

## Giới hạn / lưu ý

- **MCP tool điều khiển plugin khác (Dev Mode only)**: `connectAgentFor` (chat-manager.ts) gọi
  `buildPluginControlMcpServers(ctx)` mỗi lần 1 tab kết nối agent (`plugin-control-mcp.ts`) — trả về
  `undefined` nếu `ctx.store.get('devMode')` false, nên người dùng thường không cho AI đụng vào plugin
  khác. Khi Dev Mode bật, agent có thêm 8 tool chung (`list_plugins`, `get_plugin_manifest`,
  `toggle_plugin`, `get_plugin_settings`, `set_plugin_settings`, `read_plugin_source`,
  `write_plugin_source` — đọc/ghi CẢ source code plugin khác, `get_plugin_logs` — đọc log gần đây plugin
  đó tự gửi qua `ctx.log()`) cộng tool riêng của
  từng plugin đang active có export `getMcpTools(ctx)` (gom qua `electron/plugin-host.ts`'s
  `collectPluginMcpTools()`, tên tự thêm tiền tố `<pluginId>_`). Bật/tắt Dev Mode giữa chừng 1 phiên
  chat đang mở KHÔNG áp dụng ngay — cần agent kết nối lại (giống đổi model/system prompt).
- Cần CLI `claude` đã đăng nhập (dùng subscription local) hoặc auth qua env.
- Với proxy/WSL profile (`wsl-proxy`), model bị cố định theo session tại thời điểm tạo/kết nối. Muốn dùng model khác thì tạo session mới; session cũ vẫn giữ provider/model đã lưu.
- **Model theo proxy**: mỗi profile ngoài `default` có thể khai báo danh sách model nó hỗ trợ (nhập tay, mỗi dòng 1 model, ô riêng trong Settings). Khi có, dropdown model chỉ hiện các model này cho tab dùng profile đó (thay list Claude mặc định). Chỉ là chuyện UI — backend vẫn gửi model đã chọn cho CLI như cũ. Xem `.claude/features/env-profiles.md`.
- Mỗi tab một `AgentSession` sống riêng (xem mục Đa-tab); trong MỘT tab, đổi model/system prompt vẫn dispose agent của tab đó rồi kết nối lại (resume giữ ngữ cảnh).
- Lịch sử replay đọc từ đĩa: gộp text + tool blocks + `thinking` mỗi message. Subagent transcript (Task tool) KHÔNG được replay lồng khi mở lại phiên cũ — `getSessionMessages()` mặc định chỉ trả message luồng chính; muốn có cần `listSubagents`/`getSubagentMessages` riêng (chưa làm, biết giới hạn này).
- **omp**:
  - Lần đầu phải tải khoảng **1GB** (Bun ~82MB + omp ~918MB, không tỉa gói tuỳ chọn) vào `userData/ai-chat/omp`.
    Xoá thư mục đó là gỡ sạch.
  - Phiên bản được **ghim** (`OMP_VERSION` trong `main/omp-cli.ts`, hiện 18.3.2). Không tự cập nhật,
    vì giao thức RPC của omp đổi nhanh.
  - omp vẫn ghi cache/log vào `~/.omp` dù đã đặt `PI_CODING_AGENT_DIR`. Log request lỗi đã kiểm, không chứa token.
  - Token router chỉ truyền qua biến môi trường, không ghi ra `models.yml`.
- Plugin chạy từ bản đã cài `userData/plugins/ai-chat/`. Sau khi sửa `plugins-library/ai-chat`, phải build lại (`tsc -p tsconfig.plugins.json`) và cài lại/đồng bộ plugin để bản chạy cập nhật.
