`, add:
+
+```jsx
+ {dropActive ? (
+
+
+ 松开上传到当前消息
+
+ ) : null}
+```
+
+- [ ] **Step 6: Run focused tests and build**
+
+Run:
+
+```powershell
+node --test client/src/upload-inputs.test.mjs
+npm run build
+```
+
+Expected: helper tests pass and Vite build succeeds.
+
+- [ ] **Step 7: Commit the composer upload wiring**
+
+Run:
+
+```powershell
+git add client/src/App.jsx
+git commit -m "feat: support composer paste and drop uploads"
+```
+
+---
+
+### Task 3: Desktop Drawer Collapse State
+
+**Files:**
+- Modify: `client/src/App.jsx`
+
+- [ ] **Step 1: Add desktop drawer state**
+
+In `App`, near the existing drawer state:
+
+```js
+ const [drawerOpen, setDrawerOpen] = useState(false);
+```
+
+add:
+
+```js
+ const [desktopDrawerCollapsed, setDesktopDrawerCollapsed] = useState(false);
+```
+
+- [ ] **Step 2: Add a menu handler that opens mobile drawer or toggles desktop drawer**
+
+Near the existing `handleOpenDocsAuth` function and before `shellClass`, add:
+
+```js
+ function handleShellMenu() {
+ if (window.matchMedia?.('(min-width: 1024px)').matches) {
+ setDesktopDrawerCollapsed((value) => !value);
+ return;
+ }
+ setDrawerOpen(true);
+ }
+```
+
+- [ ] **Step 3: Update shell class generation**
+
+Replace:
+
+```js
+ const shellClass = useMemo(() => (drawerOpen ? 'app-shell drawer-active' : 'app-shell'), [drawerOpen]);
+```
+
+with:
+
+```js
+ const shellClass = useMemo(
+ () => [
+ 'app-shell',
+ drawerOpen ? 'drawer-active' : '',
+ desktopDrawerCollapsed ? 'desktop-drawer-collapsed' : ''
+ ].filter(Boolean).join(' '),
+ [desktopDrawerCollapsed, drawerOpen]
+ );
+```
+
+- [ ] **Step 4: Wire `TopBar` to the new menu handler**
+
+Change the `TopBar` prop from:
+
+```jsx
+ onMenu={() => setDrawerOpen(true)}
+```
+
+to:
+
+```jsx
+ onMenu={handleShellMenu}
+```
+
+- [ ] **Step 5: Keep drawer close behavior mobile-safe**
+
+Leave the existing `Drawer` props unchanged:
+
+```jsx
+ open={drawerOpen}
+ onClose={() => setDrawerOpen(false)}
+```
+
+The desktop docked/collapsed visual state will be controlled by `.app-shell.desktop-drawer-collapsed` in CSS. Mobile overlay behavior continues to rely on `drawerOpen`.
+
+- [ ] **Step 6: Build to verify React compiles**
+
+Run:
+
+```powershell
+npm run build
+```
+
+Expected: Vite build succeeds.
+
+- [ ] **Step 7: Commit the shell state wiring**
+
+Run:
+
+```powershell
+git add client/src/App.jsx
+git commit -m "feat: add adaptive drawer shell state"
+```
+
+---
+
+### Task 4: Adaptive Desktop Layout CSS
+
+**Files:**
+- Modify: `client/src/styles.css`
+
+- [ ] **Step 1: Preserve the existing medium-width phone frame only below desktop**
+
+Change:
+
+```css
+@media (min-width: 820px) {
+```
+
+to:
+
+```css
+@media (min-width: 820px) and (max-width: 1023.98px) {
+```
+
+- [ ] **Step 2: Add the desktop adaptive shell media query**
+
+Add this new block before the existing `@media (max-width: 480px)` section:
+
+```css
+@media (min-width: 1024px) {
+ body {
+ display: block;
+ background: var(--surface);
+ }
+
+ #root {
+ width: 100%;
+ height: var(--app-height, 100dvh);
+ border: 0;
+ box-shadow: none;
+ }
+
+ .app-shell {
+ position: fixed;
+ display: grid;
+ grid-template-columns: 320px minmax(0, 1fr);
+ grid-template-rows: auto minmax(0, 1fr) auto;
+ grid-template-areas:
+ "drawer top"
+ "drawer chat"
+ "drawer composer";
+ width: 100vw;
+ max-width: 100vw;
+ height: var(--app-height, 100dvh);
+ transition: grid-template-columns 180ms ease;
+ }
+
+ .app-shell.desktop-drawer-collapsed {
+ grid-template-columns: 0 minmax(0, 1fr);
+ }
+
+ .top-bar {
+ grid-area: top;
+ padding: 12px 24px 10px;
+ }
+
+ .drawer-backdrop {
+ display: none;
+ }
+
+ .drawer {
+ grid-area: drawer;
+ position: relative;
+ top: auto;
+ bottom: auto;
+ left: auto;
+ width: 100%;
+ min-width: 0;
+ height: 100%;
+ padding: 18px 16px;
+ border-right: 1px solid var(--hairline);
+ background: rgba(250, 251, 250, 0.94);
+ box-shadow: none;
+ transform: none;
+ transition: opacity 140ms ease;
+ z-index: 15;
+ }
+
+ [data-theme="dark"] .drawer {
+ background: rgba(26, 26, 27, 0.96);
+ }
+
+ .app-shell.desktop-drawer-collapsed .drawer {
+ width: 0;
+ padding-right: 0;
+ padding-left: 0;
+ border-right: 0;
+ opacity: 0;
+ pointer-events: none;
+ }
+
+ .drawer-header .icon-button,
+ .drawer-subheader .icon-button:last-child {
+ display: none;
+ }
+
+ .chat-pane {
+ grid-area: chat;
+ max-width: none;
+ padding: 24px 24px 18px;
+ }
+
+ .chat-content {
+ max-width: 1040px;
+ }
+
+ .message-stack {
+ max-width: min(78%, 760px);
+ }
+
+ .message-row.is-assistant .message-stack {
+ max-width: 100%;
+ }
+
+ .composer-wrap {
+ grid-area: composer;
+ max-width: none;
+ padding: 8px 24px 18px;
+ }
+
+ .composer-wrap > .queued-drafts-panel,
+ .composer-wrap > .composer-run-status,
+ .composer-wrap > .composer {
+ width: min(1040px, 100%);
+ margin-right: auto;
+ margin-left: auto;
+ }
+
+ .shortcut-menu {
+ right: max(24px, calc((100vw - 1040px) / 2));
+ left: max(24px, calc((100vw - 1040px) / 2));
+ }
+
+ .attach-menu {
+ left: max(24px, calc((100vw - 1040px) / 2));
+ }
+
+ .permission-menu {
+ left: max(66px, calc((100vw - 1040px) / 2 + 42px));
+ }
+
+ .skill-menu {
+ left: max(118px, calc((100vw - 1040px) / 2 + 94px));
+ }
+
+ .model-menu,
+ .send-mode-menu {
+ right: max(24px, calc((100vw - 1040px) / 2));
+ }
+
+ .context-popover {
+ right: max(108px, calc((100vw - 1040px) / 2 + 84px));
+ }
+}
+```
+
+- [ ] **Step 3: Add drop overlay styles**
+
+Near the existing `.composer-wrap` / `.composer` styles, add:
+
+```css
+.composer-drop-overlay {
+ position: absolute;
+ inset: 7px 12px calc(env(safe-area-inset-bottom, 0px) + 8px);
+ z-index: 70;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ border: 1px dashed rgba(37, 99, 235, 0.48);
+ border-radius: 24px;
+ color: var(--accent);
+ background: rgba(231, 239, 255, 0.88);
+ box-shadow: var(--soft-shadow);
+ pointer-events: none;
+}
+
+[data-theme="dark"] .composer-drop-overlay {
+ background: rgba(37, 99, 235, 0.18);
+}
+```
+
+Inside the `@media (min-width: 1024px)` block, add:
+
+```css
+ .composer-drop-overlay {
+ inset: 8px max(24px, calc((100vw - 1040px) / 2)) 18px;
+ width: min(1040px, calc(100% - 48px));
+ margin-right: auto;
+ margin-left: auto;
+ }
+```
+
+- [ ] **Step 4: Build to catch CSS/JS regressions**
+
+Run:
+
+```powershell
+npm run build
+```
+
+Expected: Vite build succeeds.
+
+- [ ] **Step 5: Commit the adaptive CSS**
+
+Run:
+
+```powershell
+git add client/src/styles.css
+git commit -m "feat: add adaptive desktop shell layout"
+```
+
+---
+
+### Task 5: Full Verification
+
+**Files:**
+- Verify: `client/src/upload-inputs.test.mjs`
+- Verify: existing `client/src/*.test.mjs`
+- Verify: built app in browser
+
+- [ ] **Step 1: Run all existing client unit tests**
+
+Run:
+
+```powershell
+node --test client/src/*.test.mjs
+```
+
+Expected: all tests pass.
+
+- [ ] **Step 2: Run production build**
+
+Run:
+
+```powershell
+npm run build
+```
+
+Expected: Vite build succeeds and writes `client/dist`.
+
+- [ ] **Step 3: Start the app for browser checks**
+
+Run:
+
+```powershell
+npm start
+```
+
+Expected: server starts on the configured port, usually `http://127.0.0.1:3321`.
+
+If port `3321` is already in use, stop the existing CodexMobile server or use the port printed by the running server.
+
+- [ ] **Step 4: Verify desktop layout in browser**
+
+Open the app at desktop width `>=1024px` and confirm:
+
+- The app fills the browser viewport instead of appearing as a centered `430px` phone shell.
+- The drawer is docked on the left.
+- Pressing the menu button collapses the left drawer.
+- Pressing the menu button again restores the left drawer.
+- The chat content and composer are visually aligned around a `1040px` main column.
+- Long assistant messages and code blocks do not appear cramped.
+
+- [ ] **Step 5: Verify mobile layout in browser**
+
+Open the app at mobile width around `390px` and confirm:
+
+- The drawer starts hidden.
+- Pressing the menu button opens the overlay drawer.
+- The backdrop appears and closes the drawer.
+- Composer controls still fit without overlap.
+
+- [ ] **Step 6: Verify upload interactions manually**
+
+In desktop browser:
+
+- Copy an image to the clipboard and paste into the composer.
+- Confirm an attachment chip appears.
+- Drag an image file into the chat/composer area.
+- Confirm the drop overlay appears while dragging.
+- Drop the file and confirm an attachment chip appears.
+- Drag a non-image file such as `.txt` or `.xlsx` into the chat/composer area.
+- Confirm it uploads as an attachment chip.
+
+- [ ] **Step 7: Final git status check**
+
+Run:
+
+```powershell
+git status --short
+```
+
+Expected: clean except for unrelated pre-existing `server/codex-app-server.js` if it remains intentionally uncommitted.
+
+If verification required small fixes, commit those fixes:
+
+```powershell
+git add client/src/App.jsx client/src/styles.css client/src/upload-inputs.js client/src/upload-inputs.test.mjs
+git commit -m "fix: polish adaptive shell verification issues"
+```
+
+---
+
+## Self-Review
+
+Spec coverage:
+
+- Desktop full-width shell: Task 4.
+- Docked/collapsible drawer: Tasks 3 and 4.
+- Mobile overlay preserved: Tasks 3, 4, and Task 5 manual checks.
+- Wider chat/composer: Task 4.
+- Paste image upload: Tasks 1 and 2.
+- Drag/drop image and file upload: Tasks 1 and 2.
+- Existing upload API reuse: Task 2 uses `onUploadFiles`, no backend task exists.
+- Build/tests/browser verification: Task 5.
+
+Type and naming consistency:
+
+- Helper names are `filesFromClipboardEvent`, `filesFromDropEvent`, and `dragEventHasFiles` in tests, implementation, and `App.jsx`.
+- Shell class is `desktop-drawer-collapsed` in React and CSS.
+- Drop overlay class is `composer-drop-overlay` in JSX and CSS.
+
+Scope check:
+
+- The plan does not add a right panel, backend endpoint, visual rebrand, or broad `App.jsx` split.
diff --git a/docs/superpowers/plans/2026-05-08-codexmobile-plan-mode-v1.md b/docs/superpowers/plans/2026-05-08-codexmobile-plan-mode-v1.md
new file mode 100644
index 0000000..24f940d
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-08-codexmobile-plan-mode-v1.md
@@ -0,0 +1,1748 @@
+# CodexMobile Plan Mode v1 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:** Add native Codex Plan mode to CodexMobile with a visible composer switch, official `collaborationMode` start payloads, plan progress rendering, and in-chat user-input cards.
+
+**Architecture:** Use small protocol helpers for collaboration mode and user-input request normalization, then thread those helpers through the existing chat service, Codex app-server runner, WebSocket updates, and composer UI. Keep the large `App.jsx` intact for v1, but isolate testable normalization logic outside the component where practical.
+
+**Tech Stack:** Node.js ESM, `node:test`, React 18, Vite, Codex app-server protocol, Codex Desktop IPC bridge.
+
+---
+
+## Scope Check
+
+This plan covers one cohesive feature with two surfaces:
+
+- Backend protocol support: Plan mode start payloads and `item/tool/requestUserInput` response flow.
+- Frontend user experience: composer mode switch, slash shortcuts, plan progress, and user-input cards.
+
+The desktop IPC user-input channel has one known uncertainty: `thread-follower-submit-user-input` is advertised by the local IPC method map, but the exact payload is not generated in the app-server TypeScript schema. This plan gates that work behind tests and a manual probe. The headless/app-server path must work even if desktop IPC answering needs a follow-up adjustment after probing.
+
+## File Structure
+
+- Create `shared/collaboration-mode.js`: shared normalization for composer mode and app-server `collaborationMode`.
+- Test `shared/collaboration-mode.test.mjs`: pure protocol tests.
+- Create `server/user-input-requests.js`: pending request store and answer normalization.
+- Test `server/user-input-requests.test.mjs`: pending-store behavior and official answer shape.
+- Modify `server/codex-app-server.js`: export default request fallback so the runner can combine custom user-input handling with existing safe defaults.
+- Modify `server/codex-runner.js`: accept `collaborationMode` and `onUserInputRequest`; forward Plan mode to `turn/start`; emit plan updates; await user-input answers.
+- Modify `server/chat-service.js`: normalize request mode, retain it in queue jobs, pass it into runner and desktop IPC starts, expose answer endpoint method.
+- Modify `server/desktop-ipc-client.js`: add wrappers for collaboration mode and user-input submission, with frame-shape tests.
+- Modify `server/index.js`: add `POST /api/chat/user-input/respond`; include pending inputs in public status if useful for reconnects.
+- Modify `client/src/composer-shortcuts.js`: add `/plan` and `/计划` as mode-switch shortcuts.
+- Modify `client/src/App.jsx`: add session-scoped composer mode, send payload wiring, WebSocket handling for user-input requests, card rendering, and composer switch.
+- Modify `client/src/styles.css`: style the mode switch and user-input cards without crowding the composer.
+- Modify `client/src/notification-events.js`: treat explicit user-input request events as notifications.
+
+## Task 1: Shared Collaboration Mode Helper
+
+**Files:**
+- Create: `shared/collaboration-mode.js`
+- Create: `shared/collaboration-mode.test.mjs`
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `shared/collaboration-mode.test.mjs`:
+
+```js
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import {
+ collaborationModeForComposer,
+ normalizeCollaborationMode,
+ normalizeComposerMode
+} from './collaboration-mode.js';
+
+test('normalizeComposerMode only preserves plan explicitly', () => {
+ assert.equal(normalizeComposerMode('plan'), 'plan');
+ assert.equal(normalizeComposerMode('chat'), 'chat');
+ assert.equal(normalizeComposerMode(''), 'chat');
+ assert.equal(normalizeComposerMode('default'), 'chat');
+});
+
+test('collaborationModeForComposer returns null for chat mode', () => {
+ assert.equal(collaborationModeForComposer({
+ composerMode: 'chat',
+ model: 'gpt-5.5',
+ reasoningEffort: 'xhigh'
+ }), null);
+});
+
+test('collaborationModeForComposer builds the official plan payload', () => {
+ assert.deepEqual(collaborationModeForComposer({
+ composerMode: 'plan',
+ model: 'gpt-5.5',
+ reasoningEffort: 'xhigh'
+ }), {
+ mode: 'plan',
+ settings: {
+ model: 'gpt-5.5',
+ reasoning_effort: 'xhigh',
+ developer_instructions: null
+ }
+ });
+});
+
+test('normalizeCollaborationMode rejects unsupported modes', () => {
+ assert.throws(
+ () => normalizeCollaborationMode({ mode: 'review' }, { model: 'gpt-5.5', reasoningEffort: 'medium' }),
+ /Unsupported collaboration mode/
+ );
+});
+
+test('normalizeCollaborationMode fills settings from selected send options', () => {
+ assert.deepEqual(normalizeCollaborationMode({
+ mode: 'plan',
+ settings: { model: '', reasoning_effort: null, developer_instructions: 'ignored for v1' }
+ }, {
+ model: 'gpt-5.4',
+ reasoningEffort: 'high'
+ }), {
+ mode: 'plan',
+ settings: {
+ model: 'gpt-5.4',
+ reasoning_effort: 'high',
+ developer_instructions: null
+ }
+ });
+});
+```
+
+- [ ] **Step 2: Run the failing test**
+
+Run: `node --test shared/collaboration-mode.test.mjs`
+
+Expected: FAIL with a module-not-found error for `shared/collaboration-mode.js`.
+
+- [ ] **Step 3: Add the helper**
+
+Create `shared/collaboration-mode.js`:
+
+```js
+const VALID_COLLABORATION_MODES = new Set(['plan', 'default']);
+const VALID_REASONING_EFFORTS = new Set(['none', 'minimal', 'low', 'medium', 'high', 'xhigh']);
+
+export function normalizeComposerMode(value) {
+ return String(value || '').trim().toLowerCase() === 'plan' ? 'plan' : 'chat';
+}
+
+function normalizeReasoningEffort(value) {
+ const effort = String(value || '').trim();
+ return VALID_REASONING_EFFORTS.has(effort) ? effort : null;
+}
+
+export function normalizeCollaborationMode(value, {
+ model = '',
+ reasoningEffort = ''
+} = {}) {
+ if (!value) {
+ return null;
+ }
+ const mode = String(value.mode || '').trim();
+ if (!VALID_COLLABORATION_MODES.has(mode)) {
+ throw new Error(`Unsupported collaboration mode: ${mode || 'empty'}`);
+ }
+ if (mode === 'default') {
+ return null;
+ }
+ const settings = value.settings && typeof value.settings === 'object' ? value.settings : {};
+ const selectedModel = String(settings.model || model || '').trim();
+ if (!selectedModel) {
+ throw new Error('Plan mode requires a model');
+ }
+ return {
+ mode: 'plan',
+ settings: {
+ model: selectedModel,
+ reasoning_effort: normalizeReasoningEffort(settings.reasoning_effort || reasoningEffort),
+ developer_instructions: null
+ }
+ };
+}
+
+export function collaborationModeForComposer({
+ composerMode = 'chat',
+ model = '',
+ reasoningEffort = ''
+} = {}) {
+ if (normalizeComposerMode(composerMode) !== 'plan') {
+ return null;
+ }
+ return normalizeCollaborationMode({
+ mode: 'plan',
+ settings: {
+ model,
+ reasoning_effort: reasoningEffort,
+ developer_instructions: null
+ }
+ }, { model, reasoningEffort });
+}
+```
+
+- [ ] **Step 4: Run helper tests**
+
+Run: `node --test shared/collaboration-mode.test.mjs`
+
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add shared/collaboration-mode.js shared/collaboration-mode.test.mjs
+git commit -m "feat: add collaboration mode helper"
+```
+
+## Task 2: Pending User Input Store
+
+**Files:**
+- Create: `server/user-input-requests.js`
+- Create: `server/user-input-requests.test.mjs`
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `server/user-input-requests.test.mjs`:
+
+```js
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import {
+ PendingUserInputRequests,
+ normalizeUserInputAnswers,
+ normalizeUserInputRequest
+} from './user-input-requests.js';
+
+const requestMessage = {
+ id: 7,
+ method: 'item/tool/requestUserInput',
+ params: {
+ threadId: 'thread-1',
+ turnId: 'turn-1',
+ itemId: 'input-1',
+ questions: [{
+ id: 'choice',
+ header: '方案',
+ question: '选择一个方案',
+ isOther: true,
+ isSecret: false,
+ options: [{ label: 'A', description: '快速实现' }]
+ }]
+ }
+};
+
+test('normalizeUserInputRequest keeps only protocol fields the browser needs', () => {
+ assert.deepEqual(normalizeUserInputRequest(requestMessage), {
+ threadId: 'thread-1',
+ turnId: 'turn-1',
+ itemId: 'input-1',
+ questions: [{
+ id: 'choice',
+ header: '方案',
+ question: '选择一个方案',
+ isOther: true,
+ isSecret: false,
+ options: [{ label: 'A', description: '快速实现' }]
+ }]
+ });
+});
+
+test('normalizeUserInputAnswers returns the official response envelope', () => {
+ assert.deepEqual(normalizeUserInputAnswers({
+ choice: { answers: ['A'] },
+ note: ['继续'],
+ empty: { answers: [] }
+ }), {
+ answers: {
+ choice: { answers: ['A'] },
+ note: { answers: ['继续'] },
+ empty: { answers: [] }
+ }
+ });
+});
+
+test('PendingUserInputRequests resolves a stored request once', async () => {
+ const store = new PendingUserInputRequests({ now: () => 123 });
+ let resolved = null;
+ const pending = store.add(requestMessage, (result) => {
+ resolved = result;
+ });
+
+ assert.equal(pending.key, 'thread-1:turn-1:input-1');
+ assert.equal(store.list().length, 1);
+
+ const answered = store.answer({
+ threadId: 'thread-1',
+ turnId: 'turn-1',
+ itemId: 'input-1',
+ answers: { choice: { answers: ['A'] } }
+ });
+
+ assert.equal(answered.ok, true);
+ assert.deepEqual(resolved, { answers: { choice: { answers: ['A'] } } });
+ assert.equal(store.list().length, 0);
+});
+
+test('PendingUserInputRequests reports missing requests', () => {
+ const store = new PendingUserInputRequests();
+ const result = store.answer({
+ threadId: 'thread-1',
+ turnId: 'turn-1',
+ itemId: 'missing',
+ answers: {}
+ });
+
+ assert.equal(result.ok, false);
+ assert.equal(result.reason, 'not-found');
+});
+```
+
+- [ ] **Step 2: Run the failing test**
+
+Run: `node --test server/user-input-requests.test.mjs`
+
+Expected: FAIL with a module-not-found error for `server/user-input-requests.js`.
+
+- [ ] **Step 3: Add the store**
+
+Create `server/user-input-requests.js`:
+
+```js
+function stringOrEmpty(value) {
+ return String(value || '').trim();
+}
+
+export function userInputRequestKey({ threadId, turnId, itemId } = {}) {
+ return [threadId, turnId, itemId].map(stringOrEmpty).join(':');
+}
+
+function normalizeOptions(options) {
+ return Array.isArray(options)
+ ? options.map((option) => ({
+ label: String(option?.label || ''),
+ description: String(option?.description || '')
+ }))
+ : null;
+}
+
+export function normalizeUserInputRequest(message = {}) {
+ const params = message.params || {};
+ const request = {
+ threadId: stringOrEmpty(params.threadId),
+ turnId: stringOrEmpty(params.turnId),
+ itemId: stringOrEmpty(params.itemId),
+ questions: Array.isArray(params.questions)
+ ? params.questions.map((question) => ({
+ id: stringOrEmpty(question?.id),
+ header: String(question?.header || ''),
+ question: String(question?.question || ''),
+ isOther: Boolean(question?.isOther),
+ isSecret: Boolean(question?.isSecret),
+ options: normalizeOptions(question?.options)
+ })).filter((question) => question.id)
+ : []
+ };
+ if (!request.threadId || !request.turnId || !request.itemId || !request.questions.length) {
+ throw new Error('Malformed user input request');
+ }
+ return request;
+}
+
+export function normalizeUserInputAnswers(value = {}) {
+ const source = value.answers && typeof value.answers === 'object' ? value.answers : value;
+ const answers = {};
+ for (const [questionId, answerValue] of Object.entries(source || {})) {
+ const rawAnswers = Array.isArray(answerValue)
+ ? answerValue
+ : Array.isArray(answerValue?.answers)
+ ? answerValue.answers
+ : [];
+ answers[questionId] = {
+ answers: rawAnswers.map((answer) => String(answer)).filter((answer) => answer.length > 0)
+ };
+ }
+ return { answers };
+}
+
+export class PendingUserInputRequests {
+ constructor({ now = () => Date.now() } = {}) {
+ this.now = now;
+ this.records = new Map();
+ }
+
+ add(message, resolve) {
+ const request = normalizeUserInputRequest(message);
+ const key = userInputRequestKey(request);
+ const record = {
+ key,
+ request,
+ resolve,
+ createdAt: this.now(),
+ completed: false
+ };
+ this.records.set(key, record);
+ return { key, request };
+ }
+
+ list() {
+ return [...this.records.values()].map((record) => ({
+ ...record.request,
+ key: record.key,
+ createdAt: record.createdAt
+ }));
+ }
+
+ answer({ threadId, turnId, itemId, answers }) {
+ const key = userInputRequestKey({ threadId, turnId, itemId });
+ const record = this.records.get(key);
+ if (!record) {
+ return { ok: false, reason: 'not-found' };
+ }
+ this.records.delete(key);
+ record.completed = true;
+ record.resolve(normalizeUserInputAnswers(answers || {}));
+ return { ok: true, request: record.request };
+ }
+
+ clearForTurn({ threadId, turnId } = {}) {
+ for (const [key, record] of this.records.entries()) {
+ if (
+ (!threadId || record.request.threadId === threadId) &&
+ (!turnId || record.request.turnId === turnId)
+ ) {
+ this.records.delete(key);
+ }
+ }
+ }
+}
+```
+
+- [ ] **Step 4: Run store tests**
+
+Run: `node --test server/user-input-requests.test.mjs`
+
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add server/user-input-requests.js server/user-input-requests.test.mjs
+git commit -m "feat: track pending user input requests"
+```
+
+## Task 3: Backend Plan Mode Send Path
+
+**Files:**
+- Modify: `server/chat-service.js`
+- Modify: `server/codex-runner.js`
+- Modify: `server/chat-service.test.mjs`
+
+- [ ] **Step 1: Add failing chat-service tests**
+
+Append these tests to `server/chat-service.test.mjs`:
+
+```js
+test('sendChat forwards plan collaboration mode to headless Codex turns', async () => {
+ let runPayload = null;
+ const { service } = makeChatService({
+ getDesktopBridgeStatus: async () => ({
+ strict: false,
+ connected: true,
+ mode: 'headless-local',
+ capabilities: { read: true, createThread: true, sendToOpenDesktopThread: false }
+ }),
+ runCodexTurn: async (payload, emit) => {
+ runPayload = payload;
+ emit({ type: 'chat-complete', sessionId: payload.sessionId, turnId: payload.turnId });
+ return payload.sessionId;
+ }
+ });
+
+ await service.sendChat({
+ projectId: 'project-1',
+ sessionId: 'thread-1',
+ clientTurnId: 'turn-plan-1',
+ message: '先做计划',
+ model: 'gpt-5.5',
+ reasoningEffort: 'xhigh',
+ collaborationMode: { mode: 'plan', settings: {} }
+ });
+
+ assert.deepEqual(runPayload.collaborationMode, {
+ mode: 'plan',
+ settings: {
+ model: 'gpt-5.5',
+ reasoning_effort: 'xhigh',
+ developer_instructions: null
+ }
+ });
+});
+
+test('sendChat does not forward plan collaboration mode for steer', async () => {
+ let steerPayload = null;
+ const { service } = makeChatService({
+ steerCodexTurn: async (identifier, payload) => {
+ steerPayload = { identifier, payload };
+ return { accepted: true, delivery: 'steered', sessionId: 'thread-1', turnId: 'active-turn' };
+ }
+ });
+
+ await service.sendChat({
+ projectId: 'project-1',
+ sessionId: 'thread-1',
+ message: '这个补充发到当前任务',
+ sendMode: 'steer',
+ collaborationMode: { mode: 'plan', settings: {} }
+ });
+
+ assert.equal(steerPayload.payload.collaborationMode, undefined);
+});
+```
+
+- [ ] **Step 2: Run the failing chat-service tests**
+
+Run: `node --test server/chat-service.test.mjs`
+
+Expected: FAIL because `collaborationMode` is not normalized or forwarded.
+
+- [ ] **Step 3: Import and normalize collaboration mode in `chat-service.js`**
+
+At the top of `server/chat-service.js`, add:
+
+```js
+import { normalizeCollaborationMode } from '../shared/collaboration-mode.js';
+```
+
+Inside `sendChat`, after `selectedSkills` is computed, add:
+
+```js
+ const selectedModel = session?.model || body.model || config.model || 'gpt-5.5';
+ const selectedReasoningEffort = body.reasoningEffort || defaultReasoningEffort;
+ const collaborationMode = sendMode === 'steer'
+ ? null
+ : normalizeCollaborationMode(body.collaborationMode, {
+ model: selectedModel,
+ reasoningEffort: selectedReasoningEffort
+ });
+```
+
+Then replace repeated model/reasoning expressions in queue and send calls with `selectedModel` and `selectedReasoningEffort`, and include `collaborationMode` only for start/queue paths:
+
+```js
+ model: selectedModel,
+ reasoningEffort: selectedReasoningEffort,
+ collaborationMode,
+```
+
+In `sendViaDesktopIpc` parameters, add:
+
+```js
+ collaborationMode
+```
+
+Add it to `baseTurnStartParams`:
+
+```js
+ collaborationMode
+```
+
+For `steerDesktopFollowerTurn`, keep `restoreMessage.context.collaborationMode: null`.
+
+- [ ] **Step 4: Pass collaboration mode through queued jobs**
+
+In `enqueueChatJob` call sites, store:
+
+```js
+ collaborationMode
+```
+
+In `runNextQueuedChat`, when calling `runCodexTurn`, include:
+
+```js
+ collaborationMode: job.collaborationMode || null,
+```
+
+- [ ] **Step 5: Update `runCodexTurn` signature and turn/start payload**
+
+Change the `runCodexTurn` signature in `server/codex-runner.js`:
+
+```js
+export async function runCodexTurn({
+ sessionId,
+ draftSessionId,
+ projectPath,
+ message,
+ attachments = [],
+ selectedSkills = [],
+ model,
+ reasoningEffort,
+ permissionMode,
+ collaborationMode = null,
+ turnId: providedTurnId
+}, emit) {
+```
+
+Update `turn/start` params:
+
+```js
+ const turnStartParams = {
+ threadId: currentSessionId,
+ input: buildCodexTurnInput({
+ message,
+ attachments,
+ selectedSkills,
+ larkInstruction: larkCliContext.enabled ? larkCliContext.instruction : ''
+ }),
+ cwd: workingDirectory,
+ approvalPolicy,
+ sandboxPolicy: sandboxPolicyFromMode(sandboxMode, { networkAccess: larkCliContext.enabled }),
+ model: model || null,
+ effort: modelReasoningEffort || null
+ };
+ if (collaborationMode) {
+ turnStartParams.collaborationMode = collaborationMode;
+ }
+ const turnResponse = await client.request('turn/start', turnStartParams, { timeoutMs: 30_000 });
+```
+
+- [ ] **Step 6: Run chat-service tests**
+
+Run: `node --test server/chat-service.test.mjs`
+
+Expected: PASS.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add server/chat-service.js server/codex-runner.js server/chat-service.test.mjs
+git commit -m "feat: forward plan collaboration mode"
+```
+
+## Task 4: App-Server Plan Updates and User Input Requests
+
+**Files:**
+- Modify: `server/codex-app-server.js`
+- Modify: `server/codex-runner.js`
+- Modify: `server/chat-service.js`
+- Modify: `server/codex-app-server.test.mjs`
+- Modify: `server/chat-service.test.mjs`
+
+- [ ] **Step 1: Add failing tests for user-input service methods**
+
+Append to `server/chat-service.test.mjs`:
+
+```js
+test('chat service stores and answers pending user input requests', async () => {
+ const { service, broadcasts } = makeChatService();
+ const requestMessage = {
+ id: 9,
+ method: 'item/tool/requestUserInput',
+ params: {
+ threadId: 'thread-1',
+ turnId: 'turn-1',
+ itemId: 'input-1',
+ questions: [{
+ id: 'choice',
+ header: '方案',
+ question: '选哪个?',
+ isOther: false,
+ isSecret: false,
+ options: [{ label: 'A', description: '推荐' }]
+ }]
+ }
+ };
+
+ let resolved = null;
+ const pending = service.handleUserInputRequest(requestMessage, (answer) => {
+ resolved = answer;
+ });
+
+ assert.equal(pending.key, 'thread-1:turn-1:input-1');
+ assert.equal(broadcasts.some((payload) => payload.type === 'user-input-request'), true);
+
+ const result = service.respondToUserInput({
+ threadId: 'thread-1',
+ turnId: 'turn-1',
+ itemId: 'input-1',
+ answers: { choice: { answers: ['A'] } }
+ });
+
+ assert.equal(result.ok, true);
+ assert.deepEqual(resolved, { answers: { choice: { answers: ['A'] } } });
+});
+```
+
+- [ ] **Step 2: Run the failing test**
+
+Run: `node --test server/chat-service.test.mjs`
+
+Expected: FAIL because the service has no `handleUserInputRequest` or `respondToUserInput`.
+
+- [ ] **Step 3: Export the app-server fallback**
+
+In `server/codex-app-server.js`, change:
+
+```js
+function defaultServerRequestResult(message) {
+```
+
+to:
+
+```js
+export function defaultServerRequestResult(message) {
+```
+
+Leave the existing fallback decisions unchanged for now. The runner will intercept user-input requests before the fallback is used.
+
+- [ ] **Step 4: Add pending input store to chat service**
+
+At the top of `server/chat-service.js`, import:
+
+```js
+import { PendingUserInputRequests } from './user-input-requests.js';
+```
+
+Inside `createChatService`, create the store:
+
+```js
+ const pendingUserInputs = new PendingUserInputRequests();
+```
+
+Add functions before the returned object:
+
+```js
+ function handleUserInputRequest(message, resolve) {
+ const pending = pendingUserInputs.add(message, resolve);
+ broadcast({
+ type: 'user-input-request',
+ ...pending.request,
+ key: pending.key,
+ timestamp: new Date().toISOString()
+ });
+ broadcast({
+ type: 'status-update',
+ projectId: null,
+ sessionId: pending.request.threadId,
+ turnId: pending.request.turnId,
+ kind: 'turn',
+ status: 'running',
+ label: '等待你的选择',
+ detail: pending.request.questions[0]?.question || '',
+ timestamp: new Date().toISOString()
+ });
+ return pending;
+ }
+
+ function respondToUserInput(body = {}) {
+ const result = pendingUserInputs.answer(body);
+ if (result.ok) {
+ broadcast({
+ type: 'user-input-resolved',
+ threadId: body.threadId,
+ sessionId: body.threadId,
+ turnId: body.turnId,
+ itemId: body.itemId,
+ timestamp: new Date().toISOString()
+ });
+ }
+ return result;
+ }
+```
+
+Expose them:
+
+```js
+ handleUserInputRequest,
+ respondToUserInput,
+```
+
+- [ ] **Step 5: Wire runner user-input handling**
+
+In `server/codex-runner.js`, import the fallback:
+
+```js
+import { createCodexAppServerClient, defaultServerRequestResult } from './codex-app-server.js';
+```
+
+Change the `runCodexTurn` signature to accept:
+
+```js
+ onUserInputRequest = null,
+```
+
+In `createCodexAppServerClient({ ... })`, add:
+
+```js
+ onServerRequest: async (appMessage) => {
+ resetTurnInactivityTimeout();
+ if (appMessage.method === 'item/tool/requestUserInput' && onUserInputRequest) {
+ return await new Promise((resolve) => {
+ onUserInputRequest(appMessage, resolve);
+ });
+ }
+ return defaultServerRequestResult(appMessage);
+ },
+```
+
+Keep `onNotification` as it is.
+
+- [ ] **Step 6: Pass the service handler into headless/background runs**
+
+In `server/chat-service.js`, when calling `runCodexTurn`, include:
+
+```js
+ onUserInputRequest: handleUserInputRequest,
+```
+
+- [ ] **Step 7: Emit structured plan updates**
+
+In `server/codex-runner.js`, add handling near the top of `emitAppServerNotification`:
+
+```js
+ if (method === 'turn/plan/updated') {
+ const steps = Array.isArray(params.plan) ? params.plan : [];
+ emit({
+ type: 'plan-update',
+ sessionId,
+ turnId,
+ explanation: params.explanation || '',
+ plan: steps.map((step, index) => ({
+ id: `${params.turnId || turnId}-plan-${index}`,
+ step: String(step?.step || ''),
+ status: step?.status || 'pending'
+ })),
+ timestamp: new Date().toISOString()
+ });
+ emitActivity(emit, {
+ sessionId,
+ turnId,
+ messageId: `${turnId}-plan`,
+ kind: 'plan',
+ status: 'running',
+ item: {
+ id: `${turnId}-plan`,
+ type: 'plan',
+ message: params.explanation || steps.map((step) => step?.step).filter(Boolean).join('\n')
+ }
+ });
+ return;
+ }
+```
+
+For `item/plan/delta`, add a lightweight activity update:
+
+```js
+ if (method === 'item/plan/delta') {
+ emit({
+ type: 'activity-update',
+ sessionId,
+ turnId,
+ messageId: params.itemId || `${turnId}-plan-delta`,
+ kind: 'plan',
+ status: 'running',
+ label: '正在规划',
+ detail: String(params.delta || ''),
+ timestamp: new Date().toISOString()
+ });
+ return;
+ }
+```
+
+- [ ] **Step 8: Run backend tests**
+
+Run:
+
+```bash
+node --test server/user-input-requests.test.mjs server/chat-service.test.mjs server/codex-app-server.test.mjs
+```
+
+Expected: PASS.
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add server/codex-app-server.js server/codex-runner.js server/chat-service.js server/codex-app-server.test.mjs server/chat-service.test.mjs
+git commit -m "feat: handle plan user input requests"
+```
+
+## Task 5: HTTP Endpoint and Desktop IPC Wrappers
+
+**Files:**
+- Modify: `server/index.js`
+- Modify: `server/desktop-ipc-client.js`
+- Modify: `server/desktop-ipc-client.test.mjs`
+
+- [ ] **Step 1: Add desktop IPC wrapper tests**
+
+Update the method-version test in `server/desktop-ipc-client.test.mjs`:
+
+```js
+ assert.equal(desktopIpcMethodVersion('thread-follower-set-collaboration-mode'), 1);
+ assert.equal(desktopIpcMethodVersion('thread-follower-submit-user-input'), 1);
+```
+
+Add a frame test for exported wrapper shape:
+
+```js
+test('desktop follower user input wrapper sends expected frame shape', async () => {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'codexmobile-ipc-test-'));
+ const socketPath = path.join(dir, 'ipc.sock');
+ const server = net.createServer();
+ await new Promise((resolve) => server.listen(socketPath, resolve));
+
+ const accepted = new Promise((resolve) => server.once('connection', resolve));
+ const client = new DesktopIpcClient({ clientType: 'codexmobile-test', socketPath });
+ const connected = client.connect({ timeoutMs: 1000 });
+ const socket = await accepted;
+ const init = await readFrame(socket);
+ socket.write(frameFor({
+ type: 'response',
+ requestId: init.requestId,
+ resultType: 'success',
+ method: 'initialize',
+ result: { clientId: 'client-1' }
+ }));
+ await connected;
+
+ const pending = client.request('thread-follower-submit-user-input', {
+ conversationId: 'thread-1',
+ threadId: 'thread-1',
+ turnId: 'turn-1',
+ itemId: 'input-1',
+ response: { answers: { choice: { answers: ['A'] } } }
+ });
+ const request = await readFrame(socket);
+
+ assert.equal(request.type, 'request');
+ assert.equal(request.method, 'thread-follower-submit-user-input');
+ assert.equal(request.version, 1);
+ assert.equal(request.params.conversationId, 'thread-1');
+ assert.deepEqual(request.params.response.answers.choice.answers, ['A']);
+
+ socket.write(frameFor({
+ type: 'response',
+ requestId: request.requestId,
+ resultType: 'success',
+ method: 'thread-follower-submit-user-input',
+ result: { accepted: true }
+ }));
+ await pending;
+
+ client.close();
+ server.close();
+ await fs.rm(dir, { recursive: true, force: true });
+});
+```
+
+- [ ] **Step 2: Run the failing IPC tests**
+
+Run: `node --test server/desktop-ipc-client.test.mjs`
+
+Expected: FAIL until wrappers or direct helper exports are added as needed.
+
+- [ ] **Step 3: Add desktop IPC wrappers**
+
+In `server/desktop-ipc-client.js`, add:
+
+```js
+export async function setDesktopFollowerCollaborationMode(conversationId, collaborationMode, options = {}) {
+ return requestDesktopFollower('thread-follower-set-collaboration-mode', {
+ conversationId,
+ collaborationMode
+ }, options);
+}
+
+export async function submitDesktopFollowerUserInput(conversationId, { threadId, turnId, itemId, response }, options = {}) {
+ return requestDesktopFollower('thread-follower-submit-user-input', {
+ conversationId,
+ threadId,
+ turnId,
+ itemId,
+ response
+ }, options);
+}
+```
+
+When later probing against the real desktop app, if the desktop rejects this payload shape, adjust only this wrapper and its test fixture.
+
+- [ ] **Step 4: Add HTTP endpoint**
+
+In `server/index.js`, after `/api/chat/send`, add:
+
+```js
+ if (method === 'POST' && pathname === '/api/chat/user-input/respond') {
+ const body = await readBody(req);
+ try {
+ const result = chatService.respondToUserInput(body);
+ sendJson(res, result.ok ? 200 : 404, result.ok ? { accepted: true } : { error: 'User input request not found' });
+ } catch (error) {
+ sendJson(res, error.statusCode || 500, { error: error.message || 'Failed to submit user input' });
+ }
+ return;
+ }
+```
+
+- [ ] **Step 5: Run server tests**
+
+Run:
+
+```bash
+node --test server/desktop-ipc-client.test.mjs server/chat-service.test.mjs
+```
+
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add server/index.js server/desktop-ipc-client.js server/desktop-ipc-client.test.mjs
+git commit -m "feat: expose user input response endpoint"
+```
+
+## Task 6: Slash Shortcuts and Composer Mode State
+
+**Files:**
+- Modify: `client/src/composer-shortcuts.js`
+- Modify: `client/src/composer-shortcuts.test.mjs`
+- Modify: `client/src/App.jsx`
+
+- [ ] **Step 1: Add failing shortcut tests**
+
+In `client/src/composer-shortcuts.test.mjs`, extend the import:
+
+```js
+import {
+ SLASH_COMMANDS,
+ detectComposerToken,
+ filteredSlashCommands,
+ replaceComposerToken
+} from './composer-shortcuts.js';
+```
+
+Append:
+
+```js
+test('plan slash command is a mode switch', () => {
+ const command = SLASH_COMMANDS.find((item) => item.id === 'plan');
+ assert.equal(command.action, 'set-mode');
+ assert.equal(command.mode, 'plan');
+ assert.equal(filteredSlashCommands('plan')[0].id, 'plan');
+ assert.equal(filteredSlashCommands('计划')[0].id, 'plan');
+});
+```
+
+- [ ] **Step 2: Run failing shortcut test**
+
+Run: `node --test client/src/composer-shortcuts.test.mjs`
+
+Expected: FAIL because the plan shortcut is not registered.
+
+- [ ] **Step 3: Add the slash command**
+
+In `client/src/composer-shortcuts.js`, add after `status`:
+
+```js
+ {
+ id: 'plan',
+ token: '/计划',
+ aliases: ['/plan'],
+ title: '计划模式',
+ description: '让 Codex 先规划并等待你选择方案',
+ action: 'set-mode',
+ mode: 'plan'
+ },
+```
+
+- [ ] **Step 4: Wire `runSlashCommand` to mode changes**
+
+In `Composer` props in `client/src/App.jsx`, add:
+
+```js
+ composerMode,
+ onSelectComposerMode,
+```
+
+In `runSlashCommand(command)`, before the `insert-prompt` behavior:
+
+```js
+ if (command.action === 'set-mode') {
+ onSelectComposerMode?.(command.mode || 'chat');
+ replaceToken('');
+ setOpenMenu(null);
+ return;
+ }
+```
+
+- [ ] **Step 5: Add session-scoped mode state**
+
+Near the other top-level `App` state in `client/src/App.jsx`, add:
+
+```js
+ const [composerModesBySession, setComposerModesBySession] = useState({});
+ const selectedComposerMode = composerModesBySession[selectedSession?.id || ''] || 'chat';
+```
+
+Add:
+
+```js
+ function setSelectedComposerMode(mode) {
+ const normalized = mode === 'plan' ? 'plan' : 'chat';
+ const sessionKey = selectedSessionRef.current?.id || selectedSession?.id || '';
+ if (!sessionKey) {
+ return;
+ }
+ setComposerModesBySession((current) => ({
+ ...current,
+ [sessionKey]: normalized
+ }));
+ }
+```
+
+In `handleNewConversation`, after clearing attachments:
+
+```js
+ setComposerModesBySession((current) => ({ ...current, [draft.id]: 'chat' }));
+```
+
+Pass into `Composer`:
+
+```jsx
+ composerMode={selectedComposerMode}
+ onSelectComposerMode={setSelectedComposerMode}
+```
+
+- [ ] **Step 6: Run shortcut tests**
+
+Run: `node --test client/src/composer-shortcuts.test.mjs`
+
+Expected: PASS.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add client/src/composer-shortcuts.js client/src/composer-shortcuts.test.mjs client/src/App.jsx
+git commit -m "feat: add plan mode composer state"
+```
+
+## Task 7: Send Plan Payload From the Browser
+
+**Files:**
+- Modify: `client/src/App.jsx`
+- Optional create: `client/src/plan-mode-client.js`
+- Optional test: `client/src/plan-mode-client.test.mjs`
+
+- [ ] **Step 1: Add optional pure send-payload helper test**
+
+If keeping send payload logic testable outside `App.jsx`, create `client/src/plan-mode-client.test.mjs`:
+
+```js
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { buildClientCollaborationMode } from './plan-mode-client.js';
+
+test('buildClientCollaborationMode returns plan payload only for new plan turns', () => {
+ assert.deepEqual(buildClientCollaborationMode({
+ composerMode: 'plan',
+ sendMode: 'start',
+ model: 'gpt-5.5',
+ reasoningEffort: 'xhigh'
+ }), {
+ mode: 'plan',
+ settings: {
+ model: 'gpt-5.5',
+ reasoning_effort: 'xhigh',
+ developer_instructions: null
+ }
+ });
+ assert.equal(buildClientCollaborationMode({
+ composerMode: 'plan',
+ sendMode: 'steer',
+ model: 'gpt-5.5',
+ reasoningEffort: 'xhigh'
+ }), null);
+});
+```
+
+- [ ] **Step 2: Add optional helper**
+
+Create `client/src/plan-mode-client.js`:
+
+```js
+import { collaborationModeForComposer } from '../../shared/collaboration-mode.js';
+
+export function buildClientCollaborationMode({
+ composerMode = 'chat',
+ sendMode = 'start',
+ model = '',
+ reasoningEffort = ''
+} = {}) {
+ if (sendMode === 'steer') {
+ return null;
+ }
+ return collaborationModeForComposer({ composerMode, model, reasoningEffort });
+}
+```
+
+- [ ] **Step 3: Run helper test**
+
+Run: `node --test client/src/plan-mode-client.test.mjs`
+
+Expected: PASS.
+
+- [ ] **Step 4: Use the helper in `submitCodexMessage`**
+
+In `client/src/App.jsx`, import:
+
+```js
+import { buildClientCollaborationMode } from './plan-mode-client.js';
+```
+
+In `submitCodexMessage`, before the `apiFetch('/api/chat/send')` call, compute:
+
+```js
+ const modelForTurn = selectedModel || status.model;
+ const reasoningForTurn = selectedReasoningEffort || status.reasoningEffort || DEFAULT_REASONING_EFFORT;
+ const collaborationMode = buildClientCollaborationMode({
+ composerMode: selectedComposerMode,
+ sendMode,
+ model: modelForTurn,
+ reasoningEffort: reasoningForTurn
+ });
+```
+
+Then change the body fields:
+
+```js
+ model: modelForTurn,
+ reasoningEffort: reasoningForTurn,
+ collaborationMode,
+```
+
+- [ ] **Step 5: Run client helper tests**
+
+Run:
+
+```bash
+node --test client/src/plan-mode-client.test.mjs client/src/composer-shortcuts.test.mjs
+```
+
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add client/src/App.jsx client/src/plan-mode-client.js client/src/plan-mode-client.test.mjs
+git commit -m "feat: send plan collaboration payload"
+```
+
+## Task 8: User Input Cards in the Browser
+
+**Files:**
+- Modify: `client/src/App.jsx`
+- Modify: `client/src/styles.css`
+- Modify: `client/src/notification-events.js`
+- Modify: `client/src/notification-events.test.mjs`
+
+- [ ] **Step 1: Add notification test**
+
+Append to `client/src/notification-events.test.mjs`:
+
+```js
+test('notificationFromPayload warns for explicit user input requests', () => {
+ const notification = notificationFromPayload({
+ type: 'user-input-request',
+ questions: [{ question: '选择方案' }]
+ });
+
+ assert.equal(notification.level, 'warning');
+ assert.equal(notification.title, '需要处理');
+});
+```
+
+- [ ] **Step 2: Update notification logic**
+
+In `client/src/notification-events.js`, before the status/activity branch:
+
+```js
+ if (payload.type === 'user-input-request') {
+ return {
+ level: 'warning',
+ title: '需要处理',
+ body: payload.questions?.[0]?.question || 'Codex 正在等待你的选择。'
+ };
+ }
+```
+
+- [ ] **Step 3: Add pending input state**
+
+In `App`, add:
+
+```js
+ const [pendingUserInputs, setPendingUserInputs] = useState({});
+```
+
+Add helper:
+
+```js
+ function userInputKey(payload) {
+ return [payload.threadId || payload.sessionId, payload.turnId, payload.itemId].filter(Boolean).join(':');
+ }
+```
+
+In WebSocket `onmessage`, add before generic status handling:
+
+```js
+ if (payload.type === 'user-input-request') {
+ notifyFromPayload(payload);
+ const key = userInputKey(payload);
+ setPendingUserInputs((current) => ({
+ ...current,
+ [key]: { ...payload, key, status: 'pending', error: '' }
+ }));
+ if (payloadMatchesCurrentConversation({ ...payload, sessionId: payload.threadId || payload.sessionId })) {
+ setMessages((current) => upsertUserInputMessage(current, { ...payload, key }));
+ }
+ return;
+ }
+ if (payload.type === 'user-input-resolved') {
+ const key = userInputKey(payload);
+ setPendingUserInputs((current) => {
+ const next = { ...current };
+ delete next[key];
+ return next;
+ });
+ setMessages((current) => markUserInputMessageResolved(current, payload));
+ return;
+ }
+```
+
+- [ ] **Step 4: Add message upsert helpers**
+
+Near other message helper functions in `client/src/App.jsx`, add:
+
+```js
+function userInputMessageId(payload) {
+ return `user-input-${[payload.threadId || payload.sessionId, payload.turnId, payload.itemId].filter(Boolean).join('-')}`;
+}
+
+function upsertUserInputMessage(current, payload) {
+ const id = userInputMessageId(payload);
+ const existingIndex = current.findIndex((message) => message.id === id);
+ const nextMessage = {
+ id,
+ role: 'user_input_request',
+ sessionId: payload.threadId || payload.sessionId || null,
+ threadId: payload.threadId || payload.sessionId || null,
+ turnId: payload.turnId || null,
+ itemId: payload.itemId || null,
+ questions: Array.isArray(payload.questions) ? payload.questions : [],
+ status: payload.status || 'pending',
+ timestamp: payload.timestamp || new Date().toISOString(),
+ error: payload.error || ''
+ };
+ if (existingIndex >= 0) {
+ const next = [...current];
+ next[existingIndex] = { ...current[existingIndex], ...nextMessage };
+ return next;
+ }
+ return [...current, nextMessage];
+}
+
+function markUserInputMessageResolved(current, payload) {
+ const id = userInputMessageId(payload);
+ return current.map((message) =>
+ message.id === id
+ ? { ...message, status: 'answered', error: '' }
+ : message
+ );
+}
+```
+
+- [ ] **Step 5: Add the card component**
+
+In `client/src/App.jsx`, add:
+
+```jsx
+function UserInputRequestMessage({ message, onSubmit }) {
+ const [answers, setAnswers] = useState({});
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState('');
+ const answered = message.status === 'answered';
+ const questions = Array.isArray(message.questions) ? message.questions : [];
+
+ function setQuestionAnswer(questionId, value) {
+ setAnswers((current) => ({
+ ...current,
+ [questionId]: { answers: value ? [value] : [] }
+ }));
+ }
+
+ async function submit(nextAnswers) {
+ setBusy(true);
+ setError('');
+ try {
+ await onSubmit?.(message, nextAnswers);
+ } catch (submitError) {
+ setError(submitError.message || '提交失败');
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ return (
+
+
+
+
+ {answered ? '已提交选择' : '等待你的选择'}
+
+ {questions.map((question) => (
+
+ {question.header ?
{question.header} : null}
+
{question.question}
+ {Array.isArray(question.options) && question.options.length ? (
+
+ {question.options.map((option) => (
+
+ ))}
+
+ ) : null}
+ {question.isOther || !question.options?.length ? (
+
setQuestionAnswer(question.id, event.target.value)}
+ />
+ ) : null}
+
+ ))}
+ {error || message.error ?
{error || message.error}
: null}
+ {!answered ? (
+
+
+
+
+ ) : null}
+
+
+ );
+}
+```
+
+Confirm `HelpCircle`, `Check`, `X`, and `Loader2` are already imported from `lucide-react`; add missing imports if needed.
+
+- [ ] **Step 6: Render the card in `ChatMessage`**
+
+Change `ChatMessage` signature:
+
+```js
+function ChatMessage({ message, now, onPreviewImage, onDeleteMessage, onSubmitUserInput }) {
+```
+
+Before the activity branch:
+
+```js
+ if (message.role === 'user_input_request') {
+ return
;
+ }
+```
+
+Pass `onSubmitUserInput` through `ChatPane` to `ChatMessage`.
+
+- [ ] **Step 7: Add submit handler**
+
+In `App`, add:
+
+```js
+ async function submitUserInput(message, answers) {
+ await apiFetch('/api/chat/user-input/respond', {
+ method: 'POST',
+ body: {
+ projectId: selectedProjectRef.current?.id || selectedProject?.id || null,
+ sessionId: message.threadId || message.sessionId,
+ threadId: message.threadId || message.sessionId,
+ turnId: message.turnId,
+ itemId: message.itemId,
+ answers
+ }
+ });
+ }
+```
+
+Pass to `ChatPane`:
+
+```jsx
+ onSubmitUserInput={submitUserInput}
+```
+
+- [ ] **Step 8: Add CSS**
+
+In `client/src/styles.css`, add:
+
+```css
+.user-input-card {
+ display: grid;
+ gap: 12px;
+ border-color: rgba(57, 104, 220, 0.22);
+}
+
+.user-input-card-head,
+.user-input-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.user-input-card-head {
+ color: #344ec8;
+ font-weight: 700;
+}
+
+.user-input-question {
+ display: grid;
+ gap: 8px;
+}
+
+.user-input-question p {
+ margin: 0;
+ color: var(--text);
+}
+
+.user-input-options {
+ display: grid;
+ gap: 8px;
+}
+
+.user-input-options button {
+ display: grid;
+ gap: 3px;
+ width: 100%;
+ padding: 10px 12px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: var(--surface);
+ color: var(--text);
+ text-align: left;
+}
+
+.user-input-options button.is-selected {
+ border-color: #4967e8;
+ background: rgba(73, 103, 232, 0.08);
+}
+
+.user-input-options small {
+ color: var(--muted);
+}
+
+.user-input-question input {
+ min-height: 40px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 0 10px;
+ background: var(--surface);
+ color: var(--text);
+}
+
+.user-input-actions button {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ min-height: 34px;
+ padding: 0 12px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: var(--surface);
+ color: var(--text);
+}
+
+.user-input-error {
+ color: #b42318;
+ font-size: 13px;
+}
+```
+
+- [ ] **Step 9: Run notification tests and build**
+
+Run:
+
+```bash
+node --test client/src/notification-events.test.mjs
+npm run build
+```
+
+Expected: PASS and successful Vite build.
+
+- [ ] **Step 10: Commit**
+
+```bash
+git add client/src/App.jsx client/src/styles.css client/src/notification-events.js client/src/notification-events.test.mjs
+git commit -m "feat: render plan user input cards"
+```
+
+## Task 9: Composer Mode Switch UI
+
+**Files:**
+- Modify: `client/src/App.jsx`
+- Modify: `client/src/styles.css`
+
+- [ ] **Step 1: Add the mode control JSX**
+
+Inside `Composer`, in `.control-left` after the permission pill, add:
+
+```jsx
+
+
+
+
+```
+
+This is intentionally text rather than icons because the state must be explicit and readable on mobile.
+
+- [ ] **Step 2: Add mode CSS**
+
+In `client/src/styles.css`, near composer controls:
+
+```css
+.composer-mode-toggle {
+ display: inline-flex;
+ align-items: center;
+ height: 30px;
+ padding: 2px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: rgba(127, 137, 155, 0.08);
+ flex: 0 0 auto;
+}
+
+.composer-mode-toggle button {
+ height: 24px;
+ min-width: 44px;
+ padding: 0 8px;
+ border: 0;
+ border-radius: 6px;
+ background: transparent;
+ color: #60666e;
+ font-weight: 650;
+}
+
+.composer-mode-toggle button.is-selected {
+ background: var(--surface);
+ color: var(--text);
+ box-shadow: 0 1px 3px rgba(15, 23, 42, 0.12);
+}
+
+@media (max-width: 380px) {
+ .composer-mode-toggle button {
+ min-width: 38px;
+ padding: 0 6px;
+ }
+}
+```
+
+- [ ] **Step 3: Check narrow control widths**
+
+Run: `npm run build`
+
+Expected: PASS. Then use the in-app browser at `http://127.0.0.1:3321/` or the active local port and inspect 380px width manually. The composer controls should not wrap over the send button.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add client/src/App.jsx client/src/styles.css
+git commit -m "feat: add visible plan mode switch"
+```
+
+## Task 10: Full Verification
+
+**Files:**
+- No planned source changes unless verification exposes a defect.
+
+- [ ] **Step 1: Run focused tests**
+
+Run:
+
+```bash
+node --test shared/collaboration-mode.test.mjs server/user-input-requests.test.mjs server/chat-service.test.mjs server/codex-app-server.test.mjs server/desktop-ipc-client.test.mjs client/src/composer-shortcuts.test.mjs client/src/plan-mode-client.test.mjs client/src/notification-events.test.mjs
+```
+
+Expected: PASS.
+
+- [ ] **Step 2: Run all existing tests**
+
+Run: `node --test client/src/*.test.mjs server/*.test.mjs shared/*.test.mjs`
+
+Expected: PASS.
+
+- [ ] **Step 3: Build**
+
+Run: `npm run build`
+
+Expected: Vite build succeeds.
+
+- [ ] **Step 4: Manual app-server Plan probe**
+
+Start the app if it is not running:
+
+```bash
+npm run start:bg
+```
+
+Open the current local app URL. Select `Plan`, send a small planning request in a session that uses headless/background Codex, and verify:
+
+- The browser request body contains `collaborationMode.mode = "plan"`.
+- A `turn/plan/updated` event appears as plan activity.
+- A synthetic or real `item/tool/requestUserInput` request appears as an answer card.
+- Submitting an answer calls `/api/chat/user-input/respond`.
+- The blocked turn continues after the answer.
+
+- [ ] **Step 5: Desktop IPC Plan probe**
+
+With Codex Desktop open on an existing thread, select `Plan` in CodexMobile and send a new turn to that thread. Verify:
+
+- `thread-follower-start-turn` receives `turnStartParams.collaborationMode.mode = "plan"`.
+- If the desktop app exposes a user-input request to CodexMobile, the answer wrapper works.
+- If the desktop app does not expose request details over IPC, record that v1 supports Plan start on desktop IPC but user-input cards require the app-server/headless path until a desktop request-notification channel is available.
+
+- [ ] **Step 6: Final commit if verification fixes were needed**
+
+If verification required fixes:
+
+```bash
+git add
+git commit -m "fix: stabilize plan mode v1"
+```
+
+If no fixes were needed, do not create an empty commit.
+
+## Self-Review Notes
+
+- Spec coverage: The plan includes the explicit UI switch, `/plan` shortcuts, new-turn-only `collaborationMode`, structured plan updates, `requestUserInput` pending flow, answer endpoint, and focused tests.
+- Desktop IPC uncertainty: The plan includes wrappers and a manual probe, and does not claim complete desktop user-input parity until the live desktop IPC behavior is verified.
+- Scope control: Command approvals, file approvals, permission approvals, and MCP elicitation remain outside v1.
diff --git a/docs/superpowers/specs/2026-05-07-codexmobile-adaptive-shell-design.md b/docs/superpowers/specs/2026-05-07-codexmobile-adaptive-shell-design.md
new file mode 100644
index 0000000..92ce103
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-07-codexmobile-adaptive-shell-design.md
@@ -0,0 +1,169 @@
+# CodexMobile Adaptive Shell Design
+
+Date: 2026-05-07
+Status: Approved for implementation planning
+
+## Goal
+
+Build a v1 adaptive desktop shell for CodexMobile while preserving the existing iPhone-first PWA experience. The first release should remove the 430px desktop phone-frame constraint, make the left project/session drawer efficient on desktop, widen the chat/composer reading surface, and add desktop-friendly image/file input through paste and drag/drop.
+
+## Approved Direction
+
+The selected direction is "v1 adaptive shell" implemented with a CSS-first layout change plus small React interaction additions. This intentionally avoids a broad component split or a full three-pane desktop redesign in the first pass.
+
+## Scope
+
+In scope:
+
+- At desktop width `>=1024px`, use the full browser viewport instead of centering a `430px` mobile shell.
+- Dock the left drawer by default on desktop.
+- Keep the desktop drawer collapsible so the chat area can take the full width when needed.
+- Keep mobile and narrow tablet behavior as the current overlay drawer.
+- Increase desktop chat readability by widening the main content column and composer.
+- Support `Ctrl+V` / `Cmd+V` image paste in the composer.
+- Support dragging images and files into the chat/composer area.
+- Reuse the existing `/api/uploads` endpoint and `attachments` state.
+
+Out of scope for this first pass:
+
+- A full three-pane layout with a persistent right details panel.
+- A broad split of `App.jsx` into many files.
+- Reworking the visual brand, theme palette, or message rendering model.
+- New backend upload APIs.
+- Rich clipboard HTML import.
+
+## Architecture
+
+The existing front-end component model remains in place. `TopBar`, `Drawer`, `ChatPane`, and `Composer` continue to live in `client/src/App.jsx`; `client/src/styles.css` owns the responsive shell behavior.
+
+Desktop layout is controlled through media queries and a small amount of shell state:
+
+- Mobile/narrow layout remains `grid-template-rows: auto 1fr auto`.
+- Desktop layout becomes a two-column app shell when the drawer is docked: left drawer plus main chat stack.
+- The main chat stack contains top bar, chat pane, and composer.
+- Drawer collapse state changes the desktop grid columns without changing the mobile drawer behavior.
+
+Upload input normalization should be separated into a small pure helper so paste/drop extraction can be tested without rendering the full React app.
+
+## Component Design
+
+### App Shell
+
+`App` will keep the existing `drawerOpen` state for mobile overlay behavior and add a desktop-oriented drawer state such as `desktopDrawerCollapsed`. The shell class can expose the state through class names such as:
+
+- `app-shell`
+- `drawer-active`
+- `desktop-drawer-collapsed`
+
+CSS should decide which states matter at each viewport width. At mobile widths, `drawerOpen` continues to control the overlay drawer. At desktop widths, the drawer is docked unless collapsed.
+
+### TopBar
+
+On mobile, the menu button continues to open the drawer.
+
+On desktop, the same control becomes a drawer collapse/expand toggle. The rest of the top-bar actions remain in the existing menu model for v1.
+
+### Drawer
+
+On mobile, `Drawer` stays fixed, hidden by default, and opened over a backdrop.
+
+On desktop, `Drawer` is visually docked:
+
+- No backdrop.
+- No slide-in transform for the open state.
+- Width target: about `300px` to `340px`.
+- Height follows the app viewport.
+- Scrolling stays inside the drawer.
+
+When collapsed, the drawer should not block the chat. The collapse affordance must remain discoverable through the top-bar menu button or a narrow rail.
+
+### ChatPane
+
+Desktop `ChatPane` should use the available viewport and keep the reading column constrained for readability. The target desktop content max width is `960px` to `1100px`, wider than the current `820px` but not full-bleed text.
+
+Assistant messages remain a wide reading flow. User messages remain right-aligned bubbles.
+
+### Composer
+
+`Composer` gains paste and drag/drop handlers while reusing the current upload pathway.
+
+Paste behavior:
+
+- Inspect `clipboardData.items` / `clipboardData.files`.
+- Extract only file items with MIME type `image/*`.
+- If image files are found, prevent the browser's default image paste handling and call `onUploadFiles(files)`.
+- If no image file is found, do not prevent default so normal text paste works.
+
+Drag/drop behavior:
+
+- When the drag payload includes files, show a lightweight drop overlay.
+- Prevent the browser from navigating to dropped files.
+- On drop, call `onUploadFiles(files)` with all dropped files.
+- Hide the overlay on successful drop, cancel, or leaving the drop zone.
+
+## Data Flow
+
+The upload data flow stays unchanged:
+
+1. `Composer` receives pasted or dropped `File` objects.
+2. `Composer` calls `onUploadFiles(files)`.
+3. `App.handleUploadFiles` sets `uploading`.
+4. Each file is posted to `/api/uploads` as `multipart/form-data`.
+5. Successful uploads append `result.upload` to `attachments`.
+6. The existing send flow includes `attachments` with the message.
+
+No backend changes are required for v1.
+
+## Error Handling
+
+Upload failures continue to use the existing activity-message error path in `handleUploadFiles`.
+
+Drag/drop should avoid destructive browser defaults. Dropping files onto the app should not navigate away from the current thread. Dropping non-file data should leave the app unchanged.
+
+Paste should be conservative. Plain text paste, file mention text, and slash/skill typing behavior must continue to work.
+
+## Testing
+
+Automated tests:
+
+- Add a unit test for extracting image files from paste events.
+- Add a unit test that paste without image files is ignored.
+- Add a unit test for extracting multiple files from a drop event.
+- Keep existing composer shortcut tests passing.
+
+Verification commands:
+
+- Run the existing front-end/unit tests with the repository's Node test pattern.
+- Run `npm run build`.
+
+Manual/browser checks:
+
+- Desktop width: app fills the viewport instead of showing a centered 430px shell.
+- Desktop width: drawer is docked by default and can be collapsed.
+- Desktop width: chat and composer widths feel usable for long messages and code blocks.
+- Mobile width: drawer still behaves as an overlay and the composer remains usable.
+- Paste an image into the composer and confirm it appears in the attachment tray.
+- Drag an image or file into the chat/composer area and confirm it appears in the attachment tray.
+
+## Implementation Notes
+
+Primary files:
+
+- `client/src/App.jsx` for shell state, top-bar action wiring, and composer paste/drop handlers.
+- `client/src/styles.css` for responsive desktop shell, docked drawer, wider chat column, and drop overlay styling.
+- A small helper file under `client/src/` for paste/drop file extraction, with matching `.test.mjs`.
+
+Repository hygiene:
+
+- `.superpowers/` is ignored because visual companion mockups and browser selection state are local planning artifacts.
+- Existing unrelated modifications, especially `server/codex-app-server.js`, are outside this UI pass and must not be reverted or included accidentally.
+
+## Acceptance Criteria
+
+- At `>=1024px`, CodexMobile uses a desktop-width adaptive shell rather than a phone-frame shell.
+- At mobile widths, the current PWA drawer and composer behavior remain intact.
+- The desktop drawer is docked by default and can be collapsed.
+- Chat and composer are visibly wider and aligned as one main working column.
+- Image paste uploads through the current attachment system.
+- Drag/drop uploads images and files through the current attachment system.
+- Build and relevant tests pass.
diff --git a/docs/superpowers/specs/2026-05-08-codexmobile-plan-mode-v1-design.md b/docs/superpowers/specs/2026-05-08-codexmobile-plan-mode-v1-design.md
new file mode 100644
index 0000000..68ceb5e
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-08-codexmobile-plan-mode-v1-design.md
@@ -0,0 +1,317 @@
+# CodexMobile Plan Mode v1 Design
+
+Date: 2026-05-08
+Status: Approved for implementation planning
+
+## Goal
+
+Add native Codex Plan mode support to CodexMobile. The feature should make Plan mode visible in the UI, start new turns with the official Codex collaboration mode payload, render plan progress clearly, and let the user answer Plan mode questions such as option selection, confirmation, or rejection inside the chat.
+
+## Approved Direction
+
+The selected direction is "Plan Mode v1": a scoped implementation of the interactions required for Plan mode, without building a full generic approval console.
+
+The mode is exposed through a clear composer UI switch and through `/plan` slash shortcuts. The UI switch is the primary interaction. Slash commands are convenience shortcuts that set the same mode state.
+
+## Findings
+
+The public OpenAI Codex docs do not currently expose detailed app-server protocol documentation for Plan mode. The authoritative interface available in this workspace is the official schema generated by the installed Codex CLI:
+
+- `codex --version`: `codex-cli 0.125.0`
+- `codex app-server generate-ts`
+
+The generated schema defines:
+
+- `TurnStartParams.collaborationMode`
+- `CollaborationMode.mode`: `"plan" | "default"`
+- `CollaborationMode.settings.model`
+- `CollaborationMode.settings.reasoning_effort`
+- `CollaborationMode.settings.developer_instructions`
+- `turn/plan/updated`
+- `item/plan/delta`
+- `ThreadItem` type `"plan"`
+- `item/tool/requestUserInput`
+- `ThreadActiveFlag`: `"waitingOnApproval" | "waitingOnUserInput"`
+
+`TurnSteerParams` does not include `collaborationMode`, so v1 treats Plan mode as a setting for starting a new turn, not for steering an already running turn.
+
+## Scope
+
+In scope:
+
+- Add a composer mode control with `Chat` and `Plan` states.
+- Keep the mode scoped to the active session in front-end state.
+- Default new sessions to `Chat`.
+- Add `/plan` and `/计划` slash shortcuts that switch the composer into `Plan` mode.
+- Send `collaborationMode.mode = "plan"` for new turns started while the composer is in Plan mode.
+- Send normal/default mode for new turns started while the composer is in Chat mode.
+- Render structured plan updates from `turn/plan/updated`.
+- Preserve existing display support for final `ThreadItem` plan items.
+- Treat `item/plan/delta` as streaming display only and not as the authoritative final plan.
+- Replace automatic empty answers for `item/tool/requestUserInput` with a pending request flow.
+- Push pending user-input requests to the browser over the existing real-time update channel.
+- Render user-input cards in the chat with question text, options, free-form input where needed, and submit/cancel controls.
+- Submit answers back to Codex from the UI.
+- Show waiting state when a turn is blocked on user input.
+- Add focused tests for request mapping and user-input answer submission.
+
+Out of scope for v1:
+
+- A complete generic approval inbox.
+- Command execution approval UI.
+- File change approval UI.
+- Permission approval UI.
+- MCP elicitation forms.
+- Multi-turn Plan mode editing beyond answering Codex-provided questions.
+- Persisting Plan mode as a global user preference.
+- Changing the model picker or reasoning picker beyond using their current values in collaboration mode settings.
+
+## UX Design
+
+### Composer Mode Control
+
+The composer gets an explicit mode control near the existing permission, skill, context, and model controls.
+
+Preferred layout:
+
+- Desktop and wider mobile: a compact segmented control with `Chat` and `Plan`.
+- Narrow mobile: a single pill that shows the active mode and opens a small mode menu.
+
+The active mode should be visually obvious but restrained. `Plan` should not look like a dangerous permission state; it is a collaboration mode, not an execution mode.
+
+The placeholder may remain generic. The mode control itself should carry the state so the composer does not rely on instructional placeholder text.
+
+### Slash Shortcuts
+
+`/plan` and `/计划` are shortcuts for changing mode, not prompt insertion.
+
+Behavior:
+
+- Selecting the slash command replaces the slash token with an empty string.
+- The composer mode changes to `Plan`.
+- Existing typed message content remains intact.
+- The user can still switch back to `Chat` before sending.
+
+### User Input Cards
+
+When Codex requests user input, the chat shows a card attached to the relevant turn.
+
+The card renders:
+
+- Question header.
+- Question body.
+- One or more options when provided.
+- Option descriptions when provided.
+- A free-form input when `isOther` is true or when no options are provided.
+- Password-style input when `isSecret` is true.
+- Submit and cancel/decline controls.
+
+The official response shape supports an array of answers per question. v1 will use one selected answer per question for option lists, while keeping the internal representation as an array so the protocol can support multiple answers later if Codex uses them.
+
+## Protocol Design
+
+### Starting Plan Mode
+
+When the user sends a new turn in Plan mode, the browser request to the CodexMobile server includes a collaboration mode field:
+
+```json
+{
+ "collaborationMode": {
+ "mode": "plan",
+ "settings": {
+ "model": "selected model",
+ "reasoning_effort": "selected effort",
+ "developer_instructions": null
+ }
+ }
+}
+```
+
+`developer_instructions: null` intentionally asks Codex to use the built-in instructions for the selected collaboration mode.
+
+For Chat mode, CodexMobile either omits `collaborationMode` or sends `null`, matching the existing default behavior.
+
+### Plan Updates
+
+`turn/plan/updated` is the authoritative structured plan event. CodexMobile should normalize it into message activity with:
+
+- `explanation`
+- `plan[]`
+- per-step `step`
+- per-step `status`: `pending`, `inProgress`, `completed`
+
+`item/plan/delta` may be shown as streaming text for responsiveness, but it must not be used as the final source of truth because the generated schema warns that concatenated deltas are not guaranteed to match completed plan item content.
+
+### User Input Requests
+
+When the app-server sends `item/tool/requestUserInput`, CodexMobile stores a pending request and sends a real-time event to the browser.
+
+The request contains:
+
+- `threadId`
+- `turnId`
+- `itemId`
+- `questions[]`
+- question `id`
+- question `header`
+- question `question`
+- question `isOther`
+- question `isSecret`
+- question `options`
+
+The response shape is:
+
+```json
+{
+ "answers": {
+ "question_id": {
+ "answers": ["selected or typed answer"]
+ }
+ }
+}
+```
+
+If the user cancels, v1 sends an empty `answers` object for that request. The UI should make this explicit as a cancellation, not silently submit it.
+
+## Backend Design
+
+### Chat Send Path
+
+`/api/chat/send` accepts an optional `collaborationMode` value from the browser.
+
+The server validates and normalizes it:
+
+- Only `mode: "plan"` and `mode: "default"` are accepted.
+- Settings use the currently selected model and reasoning effort.
+- `developer_instructions` is kept `null` for Plan mode v1.
+
+For background/headless app-server turns, the normalized value is forwarded to `turn/start`.
+
+For desktop-followed turns, CodexMobile should use the desktop IPC methods already advertised in `server/desktop-ipc-client.js`:
+
+- `thread-follower-set-collaboration-mode`
+- `thread-follower-start-turn`
+- `thread-follower-submit-user-input`
+
+The exact payload for `thread-follower-submit-user-input` is the main implementation risk. The method is advertised by the desktop IPC version map, but its request body is not generated in the app-server TypeScript schema. Implementation should verify the shape by a small probe or by matching the desktop follower request conventions before relying on it.
+
+### Pending User Input Store
+
+The server keeps pending user-input requests in memory by request identity.
+
+Recommended key:
+
+- `threadId`
+- `turnId`
+- `itemId`
+
+The store owns:
+
+- Original app-server request resolver.
+- Request payload for replay to newly connected browsers.
+- Created timestamp.
+- Completion state.
+
+Pending requests are removed when answered, cancelled, expired, or when the turn ends.
+
+### Response Endpoint
+
+Add a server endpoint for the browser to answer:
+
+- `POST /api/chat/user-input/respond`
+
+Expected body:
+
+- `projectId`
+- `sessionId`
+- `threadId`
+- `turnId`
+- `itemId`
+- `answers`
+
+The endpoint returns a normal success response after the answer has been accepted by the active backend path.
+
+## Frontend Design
+
+### State
+
+Add session-scoped composer mode state:
+
+- `chat`
+- `plan`
+
+The state should reset to `chat` when there is no selected session. It can be remembered per selected session while the app is open.
+
+### Sending
+
+When sending a new turn:
+
+- If mode is `plan`, include the normalized Plan collaboration mode in the `/api/chat/send` body.
+- If mode is `chat`, omit the value or send `null`.
+- If the user is steering an existing running turn, do not send Plan collaboration mode. The UI should keep the selected mode but it only affects the next new turn.
+
+### Rendering
+
+Plan updates should render as a compact checklist inside the message stream. It should use the existing activity styling where possible.
+
+User-input cards should render in the message stream and also be discoverable from the composer area while waiting. If the user is scrolled away, the existing notification model can point them back to the pending card.
+
+### Disabled States
+
+While a user-input request is pending:
+
+- The user can answer the card.
+- Normal send may remain available for unrelated messages only if the current runtime allows it.
+- The primary waiting state should clearly indicate that Codex is blocked on the answer.
+
+## Error Handling
+
+- If Plan mode is selected but the backend path does not support collaboration mode, show a user-visible error and do not silently fall back to a normal chat turn.
+- If a user-input request expires before answer submission, mark the card expired.
+- If answer submission fails, keep the card open and show an inline error.
+- If the browser reconnects while a request is pending, replay the pending request state.
+- If Codex sends a malformed user-input request, log it and show a compact unsupported-request error instead of auto-answering.
+
+## Testing
+
+Backend tests:
+
+- Normalizes Plan collaboration mode for `turn/start`.
+- Rejects invalid collaboration mode payloads.
+- Stores pending `item/tool/requestUserInput` requests instead of auto-answering.
+- Resolves a pending request with the official answer shape.
+- Cleans up pending requests after completion.
+
+Frontend tests:
+
+- Mode control toggles between Chat and Plan.
+- `/plan` and `/计划` switch the mode to Plan.
+- Sending in Plan mode includes collaboration mode payload.
+- Sending in Chat mode does not include Plan collaboration mode.
+- User-input cards render options and descriptions.
+- Submitting an option posts the expected answer shape.
+- Cancelling sends an empty answer object and marks the card complete.
+
+Manual QA:
+
+- Start a normal Chat turn and confirm behavior is unchanged.
+- Start a Plan turn and confirm Codex receives `collaborationMode.mode = "plan"`.
+- Trigger a Plan mode option request and answer it from the browser.
+- Confirm the turn continues after answer submission.
+- Confirm mobile layout keeps the mode control usable at narrow widths.
+- Confirm desktop layout shows the mode clearly without crowding existing controls.
+
+## Implementation Boundaries
+
+The implementation should keep changes focused around:
+
+- `client/src/App.jsx`
+- `client/src/composer-shortcuts.js`
+- `client/src/styles.css`
+- `server/chat-service.js`
+- `server/codex-runner.js`
+- `server/codex-app-server.js`
+- `server/desktop-ipc-client.js`
+- focused tests for any existing test harness in the repo
+
+Large component extraction is not required for v1. If a small helper makes request normalization or card rendering testable, add it only where it removes clear complexity.
diff --git a/server/chat-service.js b/server/chat-service.js
index b1154f0..b7487fc 100644
--- a/server/chat-service.js
+++ b/server/chat-service.js
@@ -14,6 +14,9 @@ import {
registerProjectlessThread as registerProjectlessThreadInCodexState
} from './codex-config.js';
import { registerMobileSession as registerMobileSessionInIndex } from './mobile-session-index.js';
+import { normalizeCollaborationMode } from '../shared/collaboration-mode.js';
+import { normalizeServiceTier } from '../shared/service-tier.js';
+import { PendingUserInputRequests } from './user-input-requests.js';
const MAX_RECENT_TURNS = 80;
@@ -112,6 +115,7 @@ export function createChatService({
const sessionQueueKeys = new Map();
const recentImagePromptsByProject = new Map();
const activeImageRuns = new Map();
+ const pendingUserInputs = new PendingUserInputRequests();
function rememberTurn(turnId, patch) {
if (!turnId) {
@@ -398,6 +402,7 @@ export function createChatService({
attachments: Array.isArray(job.attachments) ? job.attachments : [],
selectedSkills: Array.isArray(job.selectedSkills) ? job.selectedSkills : [],
fileMentions: Array.isArray(job.fileMentions) ? job.fileMentions : [],
+ serviceTier: job.serviceTier || null,
createdAt: job.createdAt || new Date().toISOString(),
sessionId: job.selectedSessionId || null,
draftSessionId: job.draftSessionId || null
@@ -456,6 +461,7 @@ export function createChatService({
attachments: draft.attachments,
selectedSkills: draft.selectedSkills,
fileMentions: draft.fileMentions,
+ serviceTier: draft.serviceTier,
sendMode: 'steer'
});
}
@@ -553,7 +559,11 @@ export function createChatService({
selectedSkills: job.selectedSkills,
model: job.model,
reasoningEffort: job.reasoningEffort,
+ serviceTier: job.serviceTier,
+ collaborationMode: job.collaborationMode,
permissionMode: job.permissionMode,
+ onUserInputRequest: handleUserInputRequest,
+ onUserInputCleanup: clearUserInputRequestsForTurn,
turnId: job.turnId
},
(payload) => {
@@ -668,6 +678,8 @@ export function createChatService({
selectedSkills,
model,
reasoningEffort,
+ serviceTier,
+ collaborationMode,
permissionMode
}) {
if (!selectedSessionId) {
@@ -693,6 +705,12 @@ export function createChatService({
effort: reasoningEffort || null,
attachments: []
};
+ if (serviceTier) {
+ baseTurnStartParams.serviceTier = serviceTier;
+ }
+ if (collaborationMode !== null) {
+ baseTurnStartParams.collaborationMode = collaborationMode;
+ }
rememberTurn(turnId, {
projectId: project.id,
@@ -801,6 +819,15 @@ export function createChatService({
const turnId = String(body.clientTurnId || '').trim() || crypto.randomUUID();
const sendMode = String(body.sendMode || body.mode || 'start').trim();
const config = getCacheSnapshot().config || {};
+ const selectedModel = session?.model || body.model || config.model || 'gpt-5.5';
+ const selectedReasoningEffort = body.reasoningEffort || defaultReasoningEffort;
+ const selectedServiceTier = normalizeServiceTier(body.serviceTier);
+ const collaborationMode = sendMode === 'steer'
+ ? null
+ : normalizeCollaborationMode(body.collaborationMode, {
+ model: selectedModel,
+ reasoningEffort: selectedReasoningEffort
+ });
const selectedSkills = normalizeSelectedSkills(body.selectedSkills, config.skills);
const displayMessage = message || '请查看附件。';
const visibleMessage = withImageAttachmentPreviews(displayMessage, attachments);
@@ -834,8 +861,10 @@ export function createChatService({
attachments,
selectedSkills,
fileMentions,
- model: session?.model || body.model || config.model || 'gpt-5.5',
- reasoningEffort: body.reasoningEffort || defaultReasoningEffort,
+ model: selectedModel,
+ reasoningEffort: selectedReasoningEffort,
+ serviceTier: selectedServiceTier,
+ collaborationMode,
permissionMode: body.permissionMode || 'bypassPermissions'
}, { forceQueued: true, autoStart: false });
return {
@@ -865,8 +894,10 @@ export function createChatService({
visibleMessage,
attachments,
selectedSkills,
- model: session?.model || body.model || config.model || 'gpt-5.5',
- reasoningEffort: body.reasoningEffort || defaultReasoningEffort,
+ model: selectedModel,
+ reasoningEffort: selectedReasoningEffort,
+ serviceTier: selectedServiceTier,
+ collaborationMode,
permissionMode: body.permissionMode || 'bypassPermissions'
});
} catch (error) {
@@ -1069,8 +1100,10 @@ export function createChatService({
attachments,
selectedSkills,
fileMentions,
- model: session?.model || body.model || config.model || 'gpt-5.5',
- reasoningEffort: body.reasoningEffort || defaultReasoningEffort,
+ model: selectedModel,
+ reasoningEffort: selectedReasoningEffort,
+ serviceTier: selectedServiceTier,
+ collaborationMode,
permissionMode: body.permissionMode || 'bypassPermissions'
});
@@ -1090,6 +1123,64 @@ export function createChatService({
return abortCodexTurn(body.turnId || body.sessionId);
}
+ function handleUserInputRequest(message, resolve) {
+ const { key, request } = pendingUserInputs.add(message, resolve);
+ const timestamp = new Date().toISOString();
+ broadcast({
+ type: 'user-input-request',
+ ...request,
+ key,
+ timestamp
+ });
+ broadcast({
+ type: 'status-update',
+ sessionId: request.threadId,
+ turnId: request.turnId,
+ kind: 'turn',
+ status: 'running',
+ label: '等待你的选择',
+ detail: request.questions[0]?.question || '',
+ timestamp
+ });
+ return { key, request };
+ }
+
+ function clearUserInputRequestsForTurn({ threadId, turnId } = {}) {
+ const cleared = pendingUserInputs.clearForTurn({ threadId, turnId });
+ const timestamp = new Date().toISOString();
+ for (const request of cleared) {
+ broadcast({
+ type: 'user-input-resolved',
+ threadId: request.threadId,
+ sessionId: request.threadId,
+ turnId: request.turnId,
+ itemId: request.itemId,
+ status: 'cancelled',
+ reason: 'turn-cleanup',
+ timestamp
+ });
+ }
+ return cleared;
+ }
+
+ function respondToUserInput(body = {}) {
+ const result = pendingUserInputs.answer(body);
+ if (!result.ok) {
+ return result;
+ }
+ const timestamp = new Date().toISOString();
+ const request = result.request;
+ broadcast({
+ type: 'user-input-resolved',
+ threadId: request.threadId,
+ sessionId: request.threadId,
+ turnId: request.turnId,
+ itemId: request.itemId,
+ timestamp
+ });
+ return result;
+ }
+
return {
abortChat,
getActiveImageRuns,
@@ -1099,8 +1190,10 @@ export function createChatService({
loadRecentImagePrompts,
listQueue,
removeQueuedDraft,
+ respondToUserInput,
restoreQueuedDraft,
sendChat,
+ handleUserInputRequest,
sessionHasActiveWork,
steerQueuedDraft
};
diff --git a/server/chat-service.test.mjs b/server/chat-service.test.mjs
index 2758622..41be7f4 100644
--- a/server/chat-service.test.mjs
+++ b/server/chat-service.test.mjs
@@ -32,6 +32,142 @@ async function flushQueuedWork() {
await new Promise((resolve) => setImmediate(resolve));
}
+test('chat service stores and answers pending user input requests', async () => {
+ const { service, broadcasts } = makeChatService();
+ const requestMessage = {
+ id: 9,
+ method: 'item/tool/requestUserInput',
+ params: {
+ threadId: 'thread-1',
+ turnId: 'turn-1',
+ itemId: 'input-1',
+ questions: [{
+ id: 'choice',
+ header: '方案',
+ question: '选哪个?',
+ isOther: false,
+ isSecret: false,
+ options: [{ label: 'A', description: '推荐' }]
+ }]
+ }
+ };
+
+ let resolved = null;
+ const pending = service.handleUserInputRequest(requestMessage, (answer) => {
+ resolved = answer;
+ });
+
+ assert.equal(pending.key, 'thread-1:turn-1:input-1');
+ const requestBroadcast = broadcasts.find((payload) => payload.type === 'user-input-request');
+ assert.equal(requestBroadcast.itemId, 'input-1');
+ assert.equal(requestBroadcast.questions[0].question, '选哪个?');
+ assert.equal(broadcasts.some((payload) => payload.type === 'status-update' && payload.label === '等待你的选择'), true);
+
+ const result = service.respondToUserInput({
+ threadId: 'thread-1',
+ turnId: 'turn-1',
+ itemId: 'input-1',
+ answers: { choice: { answers: ['A'] } }
+ });
+
+ assert.equal(result.ok, true);
+ assert.equal(result.request.itemId, 'input-1');
+ assert.equal(result.itemId, undefined);
+ assert.deepEqual(resolved, { answers: { choice: { answers: ['A'] } } });
+ assert.equal(broadcasts.some((payload) => payload.type === 'user-input-resolved'), true);
+});
+
+test('chat service reports missing pending user input requests', () => {
+ const { service } = makeChatService();
+
+ const result = service.respondToUserInput({
+ threadId: 'thread-1',
+ turnId: 'turn-1',
+ itemId: 'missing',
+ answers: {}
+ });
+
+ assert.equal(result.ok, false);
+ assert.equal(result.reason, 'not-found');
+});
+
+test('chat service clears pending user input requests when turn cleanup runs', async () => {
+ let cleanupType = null;
+ let resolved = null;
+ const requestMessage = {
+ id: 10,
+ method: 'item/tool/requestUserInput',
+ params: {
+ threadId: 'thread-1',
+ turnId: 'turn-1',
+ itemId: 'input-1',
+ questions: [{
+ id: 'choice',
+ header: '方案',
+ question: '继续吗?',
+ isOther: false,
+ isSecret: false,
+ options: [{ label: '继续', description: '' }]
+ }]
+ }
+ };
+ const { service, broadcasts } = makeChatService({
+ getDesktopBridgeStatus: async () => ({
+ strict: false,
+ connected: true,
+ mode: 'headless-local',
+ capabilities: { read: true, createThread: true, sendToOpenDesktopThread: false }
+ }),
+ runCodexTurn: async (payload, emit) => {
+ cleanupType = typeof payload.onUserInputCleanup;
+ payload.onUserInputRequest(requestMessage, (answer) => {
+ resolved = answer;
+ });
+ if (typeof payload.onUserInputCleanup === 'function') {
+ payload.onUserInputCleanup({ threadId: 'thread-1', turnId: 'turn-1' });
+ }
+ emit({ type: 'chat-error', sessionId: 'thread-1', turnId: 'turn-1', error: 'cancelled' });
+ return 'thread-1';
+ }
+ });
+
+ await service.sendChat({
+ projectId: 'project-1',
+ sessionId: 'thread-1',
+ clientTurnId: 'turn-1',
+ message: '需要选择'
+ });
+ await flushQueuedWork();
+
+ const late = service.respondToUserInput({
+ threadId: 'thread-1',
+ turnId: 'turn-1',
+ itemId: 'input-1',
+ answers: { choice: { answers: ['继续'] } }
+ });
+
+ assert.equal(cleanupType, 'function');
+ assert.deepEqual(
+ broadcasts.filter((payload) => payload.type === 'user-input-resolved').map((payload) => ({
+ threadId: payload.threadId,
+ turnId: payload.turnId,
+ itemId: payload.itemId,
+ status: payload.status,
+ reason: payload.reason
+ })),
+ [{
+ threadId: 'thread-1',
+ turnId: 'turn-1',
+ itemId: 'input-1',
+ status: 'cancelled',
+ reason: 'turn-cleanup'
+ }]
+ );
+ assert.equal(late.ok, false);
+ assert.equal(late.reason, 'not-found');
+ assert.equal(resolved, null);
+});
+
test('sendChat routes running input through desktop turn/steer', async () => {
let steerPayload = null;
const { service, broadcasts } = makeChatService({
@@ -124,17 +260,44 @@ test('sendChat sends existing desktop-ipc threads through the desktop follower b
const result = await service.sendChat({
projectId: 'project-1',
sessionId: 'thread-1',
- message: '从手机发到桌面已有线程'
+ message: '从手机发到桌面已有线程',
+ serviceTier: 'fast'
});
assert.equal(result.delivery, 'started');
assert.equal(result.sessionId, 'thread-1');
assert.equal(result.turnId, 'desktop-turn-1');
assert.equal(started.conversationId, 'thread-1');
+ assert.equal(started.params.serviceTier, 'fast');
assert.equal(started.params.input[0].type, 'text');
assert.equal(started.params.input[0].text, '从手机发到桌面已有线程');
});
+test('sendChat desktop follower start omits collaboration mode by default', async () => {
+ let started = null;
+ const { service } = makeChatService({
+ getDesktopBridgeStatus: async () => ({
+ strict: true,
+ connected: true,
+ mode: 'desktop-ipc',
+ reason: null,
+ capabilities: { sendToOpenDesktopThread: true, createThread: false }
+ }),
+ startDesktopFollowerTurn: async (conversationId, params) => {
+ started = { conversationId, params };
+ return { result: { turn: { id: 'desktop-turn-default' } } };
+ }
+ });
+
+ await service.sendChat({
+ projectId: 'project-1',
+ sessionId: 'thread-1',
+ message: '正常发送到桌面'
+ });
+
+ assert.equal(Object.hasOwn(started.params, 'collaborationMode'), false);
+});
+
test('sendChat falls back to headless local when desktop-ipc has no thread owner', async () => {
let runPayload = null;
const { service } = makeChatService({
@@ -271,7 +434,7 @@ test('sendChat registers new projectless background threads for mobile and deskt
assert.equal(result.accepted, true);
assert.equal(runPayload.draftSessionId, 'draft-projectless-1');
- assert.match(runPayload.projectPath, /\/tmp\/codex-projectless\/\d{4}-\d{2}-\d{2}\/mobile-chat-/);
+ assert.match(runPayload.projectPath.replaceAll('\\', '/'), /\/tmp\/codex-projectless\/\d{4}-\d{2}-\d{2}\/mobile-chat-/);
assert.deepEqual(desktopRegistration, {
threadId: 'projectless-thread-1',
workspaceRoot: '/tmp/codex-projectless'
@@ -303,17 +466,111 @@ test('sendChat starts a headless local Codex turn when desktop bridge is in head
projectId: 'project-1',
draftSessionId: 'draft-project-1-1',
clientTurnId: 'client-turn',
- message: '桌面端没开也跑一下'
+ message: '桌面端没开也跑一下',
+ serviceTier: 'fast'
});
assert.equal(result.accepted, true);
assert.equal(result.delivery, 'started');
assert.equal(result.desktopBridge.mode, 'headless-local');
assert.equal(runPayload.draftSessionId, 'draft-project-1-1');
+ assert.equal(runPayload.serviceTier, 'fast');
assert.match(runPayload.message, /桌面端没开也跑一下/);
assert.equal(broadcasts.some((payload) => payload.type === 'user-message'), true);
});
+test('sendChat forwards plan collaboration mode to headless Codex turns', async () => {
+ let runPayload = null;
+ const { service } = makeChatService({
+ getDesktopBridgeStatus: async () => ({
+ strict: false,
+ connected: true,
+ mode: 'headless-local',
+ capabilities: { read: true, createThread: true, sendToOpenDesktopThread: false }
+ }),
+ runCodexTurn: async (payload, emit) => {
+ runPayload = payload;
+ emit({ type: 'chat-complete', sessionId: payload.sessionId, turnId: payload.turnId });
+ return payload.sessionId;
+ }
+ });
+
+ await service.sendChat({
+ projectId: 'project-1',
+ sessionId: 'thread-1',
+ clientTurnId: 'turn-plan-1',
+ message: '先做计划',
+ model: 'gpt-5.5',
+ reasoningEffort: 'xhigh',
+ collaborationMode: { mode: 'plan', settings: {} }
+ });
+
+ assert.deepEqual(runPayload.collaborationMode, {
+ mode: 'plan',
+ settings: {
+ model: 'gpt-5.5',
+ reasoning_effort: 'xhigh',
+ developer_instructions: null
+ }
+ });
+});
+
+test('sendChat forwards plan collaboration mode to desktop follower starts', async () => {
+ let started = null;
+ const { service } = makeChatService({
+ getDesktopBridgeStatus: async () => ({
+ strict: true,
+ connected: true,
+ mode: 'desktop-ipc',
+ reason: null,
+ capabilities: { sendToOpenDesktopThread: true, createThread: false }
+ }),
+ startDesktopFollowerTurn: async (conversationId, params) => {
+ started = { conversationId, params };
+ return { result: { turn: { id: 'desktop-turn-plan' } } };
+ }
+ });
+
+ await service.sendChat({
+ projectId: 'project-1',
+ sessionId: 'thread-1',
+ message: '桌面先做计划',
+ model: 'gpt-5.5',
+ reasoningEffort: 'high',
+ collaborationMode: { mode: 'plan', settings: {} }
+ });
+
+ assert.equal(started.conversationId, 'thread-1');
+ assert.deepEqual(started.params.collaborationMode, {
+ mode: 'plan',
+ settings: {
+ model: 'gpt-5.5',
+ reasoning_effort: 'high',
+ developer_instructions: null
+ }
+ });
+});
+
+test('sendChat does not forward plan collaboration mode for steer', async () => {
+ let steerPayload = null;
+ const { service } = makeChatService({
+ steerCodexTurn: async (identifier, payload) => {
+ steerPayload = { identifier, payload };
+ return { accepted: true, delivery: 'steered', sessionId: 'thread-1', turnId: 'active-turn' };
+ }
+ });
+
+ await service.sendChat({
+ projectId: 'project-1',
+ sessionId: 'thread-1',
+ message: '这个补充发到当前任务',
+ sendMode: 'steer',
+ collaborationMode: { mode: 'plan', settings: {} }
+ });
+
+ assert.equal(steerPayload.payload.collaborationMode, undefined);
+});
+
test('queue drafts can be listed, deleted, and restored without auto starting during active work', async () => {
const { service } = makeChatService({
getActiveRuns: () => [{ sessionId: 'thread-1', status: 'running' }],
diff --git a/server/codex-app-server.js b/server/codex-app-server.js
index 2338440..9edc765 100644
--- a/server/codex-app-server.js
+++ b/server/codex-app-server.js
@@ -111,7 +111,7 @@ function unavailableBridgeError(transport) {
return error;
}
-function defaultServerRequestResult(message) {
+export function defaultServerRequestResult(message) {
switch (message?.method) {
case 'item/commandExecution/requestApproval':
return { decision: 'decline' };
diff --git a/server/codex-runner.js b/server/codex-runner.js
index 2428a48..08672f5 100644
--- a/server/codex-runner.js
+++ b/server/codex-runner.js
@@ -1,10 +1,11 @@
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
-import { createCodexAppServerClient } from './codex-app-server.js';
+import { createCodexAppServerClient, defaultServerRequestResult } from './codex-app-server.js';
import { buildCodexTurnInput, imageMarkdownFromCodexImageGeneration } from './codex-native-images.js';
import { buildCodexLarkCliContext } from './lark-cli.js';
import { detectFeishuSkillKeys } from './feishu-skills.js';
+import { normalizeServiceTier } from '../shared/service-tier.js';
const activeRuns = new Map();
const NON_ASCII_PATH_PATTERN = /[^\u0000-\u007F]/;
@@ -521,6 +522,16 @@ function tokenUsagePayload(tokenUsage = {}) {
};
}
+function normalizePlanItems(plan = []) {
+ if (!Array.isArray(plan)) {
+ return [];
+ }
+ return plan.map((item) => ({
+ step: String(item?.step || item?.text || '').trim(),
+ status: String(item?.status || 'pending').trim() || 'pending'
+ })).filter((item) => item.step);
+}
+
function errorTextFromNotification(params = {}) {
return params.error?.message || params.message || params.error || 'Codex turn failed';
}
@@ -627,6 +638,47 @@ function emitAppServerNotification(message, sessionId, turnId, emit, state) {
return;
}
+ if (method === 'turn/plan/updated') {
+ const timestamp = new Date().toISOString();
+ const plan = normalizePlanItems(params.plan);
+ const explanation = String(params.explanation || '');
+ emit({
+ type: 'plan-update',
+ sessionId,
+ turnId,
+ explanation,
+ plan,
+ timestamp
+ });
+ emit({
+ type: 'activity-update',
+ sessionId,
+ turnId,
+ messageId: params.itemId || `${turnId}-plan`,
+ kind: 'plan',
+ label: statusLabel('plan', 'running'),
+ status: 'running',
+ detail: explanation,
+ timestamp
+ });
+ return;
+ }
+
+ if (method === 'item/plan/delta') {
+ emit({
+ type: 'activity-update',
+ sessionId,
+ turnId,
+ messageId: params.itemId || `${turnId}-plan-delta`,
+ kind: 'plan',
+ label: '正在规划',
+ status: 'running',
+ detail: String(params.delta || ''),
+ timestamp: new Date().toISOString()
+ });
+ return;
+ }
+
if (method === 'item/agentMessage/delta') {
const messageId = params.itemId || `${turnId}-agent-message`;
const previous = state.agentMessages.get(messageId) || '';
@@ -706,11 +758,12 @@ function abortError() {
return error;
}
-export async function runCodexTurn({ sessionId, draftSessionId, projectPath, message, attachments = [], selectedSkills = [], model, reasoningEffort, permissionMode, turnId: providedTurnId }, emit) {
+export async function runCodexTurn({ sessionId, draftSessionId, projectPath, message, attachments = [], selectedSkills = [], model, reasoningEffort, serviceTier, collaborationMode = null, permissionMode, onUserInputRequest = null, onUserInputCleanup = null, turnId: providedTurnId }, emit) {
const workingDirectory = await ensureAsciiWorkingDirectory(projectPath);
const { sandboxMode, approvalPolicy } = mapPermissionMode(permissionMode);
const feishuSkillKeys = detectFeishuSkillKeys(message);
const normalizedReasoningEffort = normalizeReasoningEffort(reasoningEffort);
+ const normalizedServiceTier = normalizeServiceTier(serviceTier);
const modelReasoningEffort =
feishuSkillKeys.length && normalizedReasoningEffort === 'xhigh' ? 'low' : normalizedReasoningEffort;
const larkCliContext = await buildCodexLarkCliContext(message).catch((error) => {
@@ -755,6 +808,28 @@ export async function runCodexTurn({ sessionId, draftSessionId, projectPath, mes
let turnTimeoutTimer = null;
let turnInactivityTimeoutTimer = null;
let resetTurnInactivityTimeout = () => {};
+ const pendingUserInputServerRequests = new Map();
+
+ function userInputServerRequestKey(request) {
+ return [request?.threadId, request?.turnId, request?.itemId].filter(Boolean).join(':');
+ }
+
+ async function cancelPendingUserInputServerRequests() {
+ if (!pendingUserInputServerRequests.size) {
+ return;
+ }
+ const fallback = defaultServerRequestResult({ method: 'item/tool/requestUserInput' });
+ for (const [key, pending] of pendingUserInputServerRequests.entries()) {
+ pendingUserInputServerRequests.delete(key);
+ try {
+ onUserInputCleanup?.(pending.request);
+ } catch (error) {
+ console.warn('[codex] Failed to clear pending user input request:', error.message);
+ }
+ pending.resolve(fallback);
+ }
+ await Promise.resolve();
+ }
try {
if (larkCliContext.enabled && larkCliContext.env) {
@@ -767,6 +842,29 @@ export async function runCodexTurn({ sessionId, draftSessionId, projectPath, mes
cwd: workingDirectory,
clientInfo: { name: 'CodexMobile', title: null, version: '0.1.0' },
allowHeadlessLocal: true,
+ onServerRequest: (appMessage) => {
+ resetTurnInactivityTimeout();
+ if (appMessage.method === 'item/tool/requestUserInput' && onUserInputRequest) {
+ return new Promise((resolve) => {
+ let key = null;
+ const resolveOnce = (result) => {
+ if (key) {
+ pendingUserInputServerRequests.delete(key);
+ }
+ resolve(result);
+ };
+ const pending = onUserInputRequest(appMessage, resolveOnce);
+ key = userInputServerRequestKey(pending?.request);
+ if (key) {
+ pendingUserInputServerRequests.set(key, {
+ request: pending.request,
+ resolve: resolveOnce
+ });
+ }
+ });
+ }
+ return defaultServerRequestResult(appMessage);
+ },
onNotification: (appMessage) => {
resetTurnInactivityTimeout();
const params = appMessage.params || {};
@@ -823,6 +921,9 @@ export async function runCodexTurn({ sessionId, draftSessionId, projectPath, mes
config: modelReasoningEffort ? { model_reasoning_effort: modelReasoningEffort } : null,
serviceName: 'CodexMobile'
};
+ if (normalizedServiceTier) {
+ threadParams.serviceTier = normalizedServiceTier;
+ }
const threadResponse = sessionId
? await client.request('thread/resume', { threadId: sessionId, ...threadParams }, { timeoutMs: 30_000 })
: await client.request('thread/start', threadParams, { timeoutMs: 30_000 });
@@ -843,7 +944,7 @@ export async function runCodexTurn({ sessionId, draftSessionId, projectPath, mes
});
emitStatus(emit, { sessionId: currentSessionId, turnId, kind: 'reasoning', status: 'running', label: '正在思考' });
- const turnResponse = await client.request('turn/start', {
+ const turnStartParams = {
threadId: currentSessionId,
input: buildCodexTurnInput({
message,
@@ -856,7 +957,14 @@ export async function runCodexTurn({ sessionId, draftSessionId, projectPath, mes
sandboxPolicy: sandboxPolicyFromMode(sandboxMode, { networkAccess: larkCliContext.enabled }),
model: model || null,
effort: modelReasoningEffort || null
- }, { timeoutMs: 30_000 });
+ };
+ if (normalizedServiceTier) {
+ turnStartParams.serviceTier = normalizedServiceTier;
+ }
+ if (collaborationMode !== null) {
+ turnStartParams.collaborationMode = collaborationMode;
+ }
+ const turnResponse = await client.request('turn/start', turnStartParams, { timeoutMs: 30_000 });
if (turnResponse?.turn?.id) {
run.appTurnId = turnResponse.turn.id;
}
@@ -961,6 +1069,7 @@ export async function runCodexTurn({ sessionId, draftSessionId, projectPath, mes
if (turnInactivityTimeoutTimer) {
clearTimeout(turnInactivityTimeoutTimer);
}
+ await cancelPendingUserInputServerRequests();
if (client) {
client.close();
}
diff --git a/server/desktop-ipc-client.js b/server/desktop-ipc-client.js
index 8b4bd9a..91c71e0 100644
--- a/server/desktop-ipc-client.js
+++ b/server/desktop-ipc-client.js
@@ -247,7 +247,7 @@ export async function probeDesktopIpc({ timeoutMs = 3000 } = {}) {
}
async function requestDesktopFollower(method, params, options = {}) {
- const client = new DesktopIpcClient();
+ const client = new DesktopIpcClient({ socketPath: options.socketPath });
try {
await client.connect({ timeoutMs: options.timeoutMs || DEFAULT_TIMEOUT_MS });
const response = await client.request(method, params, options);
@@ -284,6 +284,23 @@ export async function interruptDesktopFollowerTurn(conversationId, options = {})
}, options);
}
+export async function setDesktopFollowerCollaborationMode(conversationId, collaborationMode, options = {}) {
+ return requestDesktopFollower('thread-follower-set-collaboration-mode', {
+ conversationId,
+ collaborationMode
+ }, options);
+}
+
+export async function submitDesktopFollowerUserInput(conversationId, { threadId, turnId, itemId, response }, options = {}) {
+ return requestDesktopFollower('thread-follower-submit-user-input', {
+ conversationId,
+ threadId,
+ turnId,
+ itemId,
+ response
+ }, options);
+}
+
export async function broadcastDesktopThreadArchived(conversationId, { hostId = 'local', cwd = null, timeoutMs = 1500 } = {}) {
const client = new DesktopIpcClient({ clientType: 'codexmobile-archive-sync' });
try {
diff --git a/server/desktop-ipc-client.test.mjs b/server/desktop-ipc-client.test.mjs
index a30c97f..d168cc9 100644
--- a/server/desktop-ipc-client.test.mjs
+++ b/server/desktop-ipc-client.test.mjs
@@ -4,7 +4,11 @@ import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
-import { DesktopIpcClient, desktopIpcMethodVersion } from './desktop-ipc-client.js';
+import {
+ DesktopIpcClient,
+ desktopIpcMethodVersion,
+ submitDesktopFollowerUserInput
+} from './desktop-ipc-client.js';
test('desktop follower IPC methods use the current desktop protocol version', () => {
assert.equal(desktopIpcMethodVersion('initialize'), 0);
@@ -12,6 +16,8 @@ test('desktop follower IPC methods use the current desktop protocol version', ()
assert.equal(desktopIpcMethodVersion('thread-follower-start-turn'), 1);
assert.equal(desktopIpcMethodVersion('thread-follower-steer-turn'), 1);
assert.equal(desktopIpcMethodVersion('thread-follower-interrupt-turn'), 1);
+ assert.equal(desktopIpcMethodVersion('thread-follower-set-collaboration-mode'), 1);
+ assert.equal(desktopIpcMethodVersion('thread-follower-submit-user-input'), 1);
});
function frameFor(payload) {
@@ -42,9 +48,25 @@ function readFrame(socket) {
});
}
-test('sendBroadcast writes desktop IPC broadcast frames', async () => {
+async function createSocketEndpoint() {
+ if (process.platform === 'win32') {
+ return {
+ socketPath: String.raw`\\.\pipe\codexmobile-ipc-test-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
+ cleanup: async () => {}
+ };
+ }
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'codexmobile-ipc-test-'));
- const socketPath = path.join(dir, 'ipc.sock');
+ return {
+ socketPath: path.join(dir, 'ipc.sock'),
+ cleanup: async () => {
+ await fs.rm(dir, { recursive: true, force: true });
+ }
+ };
+}
+
+test('sendBroadcast writes desktop IPC broadcast frames', async () => {
+ const endpoint = await createSocketEndpoint();
+ const socketPath = endpoint.socketPath;
const server = net.createServer();
await new Promise((resolve) => server.listen(socketPath, resolve));
@@ -81,5 +103,56 @@ test('sendBroadcast writes desktop IPC broadcast frames', async () => {
client.close();
server.close();
- await fs.rm(dir, { recursive: true, force: true });
+ await endpoint.cleanup();
+});
+
+test('submitDesktopFollowerUserInput writes the desktop IPC request frame', async () => {
+ const endpoint = await createSocketEndpoint();
+ const socketPath = endpoint.socketPath;
+ const server = net.createServer();
+ await new Promise((resolve) => server.listen(socketPath, resolve));
+
+ const accepted = new Promise((resolve) => server.once('connection', resolve));
+ const submitted = submitDesktopFollowerUserInput('conversation-1', {
+ threadId: 'thread-1',
+ turnId: 'turn-1',
+ itemId: 'item-1',
+ response: { answers: { choice: { answers: ['A'] } } }
+ }, { socketPath, timeoutMs: 1000 });
+ const socket = await accepted;
+ const init = await readFrame(socket);
+ socket.write(frameFor({
+ type: 'response',
+ requestId: init.requestId,
+ resultType: 'success',
+ method: 'initialize',
+ result: { clientId: 'client-1' }
+ }));
+
+ const request = await readFrame(socket);
+ socket.write(frameFor({
+ type: 'response',
+ requestId: request.requestId,
+ resultType: 'success',
+ method: 'thread-follower-submit-user-input',
+ result: { accepted: true }
+ }));
+ const result = await submitted;
+
+ assert.equal(request.type, 'request');
+ assert.equal(request.method, 'thread-follower-submit-user-input');
+ assert.equal(request.sourceClientId, 'client-1');
+ assert.equal(request.version, 1);
+ assert.deepEqual(request.params, {
+ conversationId: 'conversation-1',
+ threadId: 'thread-1',
+ turnId: 'turn-1',
+ itemId: 'item-1',
+ response: { answers: { choice: { answers: ['A'] } } }
+ });
+ assert.deepEqual(result, { accepted: true });
+
+ socket.destroy();
+ server.close();
+ await endpoint.cleanup();
});
diff --git a/server/index.js b/server/index.js
index 553a52c..ab004f1 100644
--- a/server/index.js
+++ b/server/index.js
@@ -34,6 +34,7 @@ import { abortCodexTurn, getActiveRuns, runCodexTurn, steerCodexTurn } from './c
import {
interruptDesktopFollowerTurn,
startDesktopFollowerTurn,
+ submitDesktopFollowerUserInput,
steerDesktopFollowerTurn
} from './desktop-ipc-client.js';
import { GENERATED_ROOT, isImageRequest, runImageTurn } from './image-generator.js';
@@ -44,6 +45,7 @@ import { publicVoiceSpeechStatus, synthesizeSpeech } from './voice-speaker.js';
import { publicVoiceRealtimeStatus, startVoiceRealtimeProxy } from './realtime-voice.js';
import { maybeAutoNameSession } from './session-title-generator.js';
import { createChatService } from './chat-service.js';
+import { normalizeDesktopUserInputSubmission } from './user-input-requests.js';
import { searchProjectFiles } from './file-search.js';
import { htmlEscape, readBody, sendHtml, sendJson } from './http-utils.js';
import { createPushService } from './push-service.js';
@@ -926,6 +928,28 @@ async function handleApi(req, res, url) {
return;
}
+ if (method === 'POST' && pathname === '/api/chat/user-input/respond') {
+ const body = await readBody(req);
+ try {
+ const result = chatService.respondToUserInput(body);
+ if (result.ok) {
+ sendJson(res, 200, { accepted: true });
+ return;
+ }
+ const desktopSubmission = normalizeDesktopUserInputSubmission(body);
+ if (desktopSubmission) {
+ const { conversationId, ...request } = desktopSubmission;
+ await submitDesktopFollowerUserInput(conversationId, request);
+ sendJson(res, 200, { accepted: true, delivery: 'desktop-ipc' });
+ return;
+ }
+ sendJson(res, 404, { error: 'User input request not found' });
+ } catch (error) {
+ sendJson(res, error.statusCode || 500, { error: error.message || 'Failed to submit user input' });
+ }
+ return;
+ }
+
if (method === 'POST' && pathname === '/api/chat/abort') {
const body = await readBody(req);
const aborted = chatService.abortChat(body, { remoteAddress: remoteAddress(req) });
diff --git a/server/user-input-requests.js b/server/user-input-requests.js
new file mode 100644
index 0000000..f315727
--- /dev/null
+++ b/server/user-input-requests.js
@@ -0,0 +1,140 @@
+function stringOrEmpty(value) {
+ return String(value || '').trim();
+}
+
+export function userInputRequestKey({ threadId, turnId, itemId } = {}) {
+ return [threadId, turnId, itemId].map(stringOrEmpty).join(':');
+}
+
+function normalizeOptions(options) {
+ return Array.isArray(options)
+ ? options.map((option) => ({
+ label: String(option?.label || ''),
+ description: String(option?.description || '')
+ }))
+ : null;
+}
+
+export function normalizeUserInputRequest(message = {}) {
+ const params = message.params || {};
+ const request = {
+ threadId: stringOrEmpty(params.threadId),
+ turnId: stringOrEmpty(params.turnId),
+ itemId: stringOrEmpty(params.itemId),
+ questions: Array.isArray(params.questions)
+ ? params.questions.map((question) => ({
+ id: stringOrEmpty(question?.id),
+ header: String(question?.header || ''),
+ question: String(question?.question || ''),
+ isOther: Boolean(question?.isOther),
+ isSecret: Boolean(question?.isSecret),
+ options: normalizeOptions(question?.options)
+ })).filter((question) => question.id)
+ : []
+ };
+ const conversationId = stringOrEmpty(params.conversationId || message.conversationId);
+ if (conversationId) {
+ request.conversationId = conversationId;
+ }
+ const transport = stringOrEmpty(params.transport || message.transport);
+ if (transport) {
+ request.transport = transport;
+ }
+ const delivery = stringOrEmpty(params.delivery || message.delivery);
+ if (delivery) {
+ request.delivery = delivery;
+ }
+ if (!request.threadId || !request.turnId || !request.itemId || !request.questions.length) {
+ throw new Error('Malformed user input request');
+ }
+ return request;
+}
+
+export function normalizeUserInputAnswers(value = {}) {
+ const source = value.answers && typeof value.answers === 'object' ? value.answers : value;
+ const answers = {};
+ for (const [questionId, answerValue] of Object.entries(source || {})) {
+ const rawAnswers = Array.isArray(answerValue)
+ ? answerValue
+ : Array.isArray(answerValue?.answers)
+ ? answerValue.answers
+ : [];
+ answers[questionId] = {
+ answers: rawAnswers.map((answer) => String(answer)).filter((answer) => answer.length > 0)
+ };
+ }
+ return { answers };
+}
+
+export function normalizeDesktopUserInputSubmission(body = {}) {
+ const conversationId = stringOrEmpty(body.conversationId);
+ if (!conversationId) {
+ return null;
+ }
+ return {
+ conversationId,
+ threadId: stringOrEmpty(body.threadId || body.sessionId),
+ turnId: stringOrEmpty(body.turnId),
+ itemId: stringOrEmpty(body.itemId),
+ response: normalizeUserInputAnswers(body.answers || body.response || {})
+ };
+}
+
+export class PendingUserInputRequests {
+ constructor({ now = () => Date.now() } = {}) {
+ this.now = now;
+ this.records = new Map();
+ }
+
+ add(message, resolve) {
+ const request = normalizeUserInputRequest(message);
+ const key = userInputRequestKey(request);
+ const record = {
+ key,
+ request,
+ resolve,
+ createdAt: this.now(),
+ completed: false
+ };
+ this.records.set(key, record);
+ return { key, request };
+ }
+
+ list() {
+ return [...this.records.values()].map((record) => ({
+ ...record.request,
+ key: record.key,
+ createdAt: record.createdAt
+ }));
+ }
+
+ answer({ threadId, turnId, itemId, answers }) {
+ const key = userInputRequestKey({ threadId, turnId, itemId });
+ const record = this.records.get(key);
+ if (!record) {
+ return { ok: false, reason: 'not-found' };
+ }
+ this.records.delete(key);
+ record.completed = true;
+ record.resolve(normalizeUserInputAnswers(answers || {}));
+ return { ok: true, request: record.request };
+ }
+
+ clearForTurn({ threadId, turnId } = {}) {
+ const cleared = [];
+ for (const [key, record] of this.records.entries()) {
+ if (
+ (!threadId || record.request.threadId === threadId) &&
+ (!turnId || record.request.turnId === turnId)
+ ) {
+ this.records.delete(key);
+ cleared.push({
+ ...record.request,
+ key: record.key,
+ createdAt: record.createdAt
+ });
+ }
+ }
+ return cleared;
+ }
+}
diff --git a/server/user-input-requests.test.mjs b/server/user-input-requests.test.mjs
new file mode 100644
index 0000000..72f64de
--- /dev/null
+++ b/server/user-input-requests.test.mjs
@@ -0,0 +1,123 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import {
+ PendingUserInputRequests,
+ normalizeDesktopUserInputSubmission,
+ normalizeUserInputAnswers,
+ normalizeUserInputRequest
+} from './user-input-requests.js';
+
+const requestMessage = {
+ id: 7,
+ method: 'item/tool/requestUserInput',
+ params: {
+ threadId: 'thread-1',
+ turnId: 'turn-1',
+ itemId: 'input-1',
+ questions: [{
+ id: 'choice',
+ header: '方案',
+ question: '选择一个方案',
+ isOther: true,
+ isSecret: false,
+ options: [{ label: 'A', description: '快速实现' }]
+ }]
+ }
+};
+
+test('normalizeUserInputRequest keeps only protocol fields the browser needs', () => {
+ assert.deepEqual(normalizeUserInputRequest(requestMessage), {
+ threadId: 'thread-1',
+ turnId: 'turn-1',
+ itemId: 'input-1',
+ questions: [{
+ id: 'choice',
+ header: '方案',
+ question: '选择一个方案',
+ isOther: true,
+ isSecret: false,
+ options: [{ label: 'A', description: '快速实现' }]
+ }]
+ });
+});
+
+test('normalizeUserInputAnswers returns the official response envelope', () => {
+ assert.deepEqual(normalizeUserInputAnswers({
+ choice: { answers: ['A'] },
+ note: ['继续'],
+ empty: { answers: [] }
+ }), {
+ answers: {
+ choice: { answers: ['A'] },
+ note: { answers: ['继续'] },
+ empty: { answers: [] }
+ }
+ });
+});
+
+test('normalizeDesktopUserInputSubmission builds an explicit desktop fallback payload', () => {
+ assert.deepEqual(normalizeDesktopUserInputSubmission({
+ conversationId: 'conversation-1',
+ threadId: 'thread-1',
+ turnId: 'turn-1',
+ itemId: 'input-1',
+ answers: { choice: { answers: ['A'] } }
+ }), {
+ conversationId: 'conversation-1',
+ threadId: 'thread-1',
+ turnId: 'turn-1',
+ itemId: 'input-1',
+ response: { answers: { choice: { answers: ['A'] } } }
+ });
+ assert.equal(normalizeDesktopUserInputSubmission({ threadId: 'thread-1' }), null);
+});
+
+test('PendingUserInputRequests resolves a stored request once', async () => {
+ const store = new PendingUserInputRequests({ now: () => 123 });
+ let resolved = null;
+ const pending = store.add(requestMessage, (result) => {
+ resolved = result;
+ });
+
+ assert.equal(pending.key, 'thread-1:turn-1:input-1');
+ assert.equal(store.list().length, 1);
+
+ const answered = store.answer({
+ threadId: 'thread-1',
+ turnId: 'turn-1',
+ itemId: 'input-1',
+ answers: { choice: { answers: ['A'] } }
+ });
+
+ assert.equal(answered.ok, true);
+ assert.deepEqual(resolved, { answers: { choice: { answers: ['A'] } } });
+ assert.equal(store.list().length, 0);
+});
+
+test('PendingUserInputRequests clearForTurn returns cleared requests', () => {
+ const store = new PendingUserInputRequests({ now: () => 123 });
+ store.add(requestMessage, () => {});
+ store.add({
+ ...requestMessage,
+ params: { ...requestMessage.params, itemId: 'input-2' }
+ }, () => {});
+
+ const cleared = store.clearForTurn({ threadId: 'thread-1', turnId: 'turn-1' });
+
+ assert.deepEqual(cleared.map((request) => request.itemId), ['input-1', 'input-2']);
+ assert.equal(cleared[0].key, 'thread-1:turn-1:input-1');
+ assert.equal(store.list().length, 0);
+});
+
+test('PendingUserInputRequests reports missing requests', () => {
+ const store = new PendingUserInputRequests();
+ const result = store.answer({
+ threadId: 'thread-1',
+ turnId: 'turn-1',
+ itemId: 'missing',
+ answers: {}
+ });
+
+ assert.equal(result.ok, false);
+ assert.equal(result.reason, 'not-found');
+});
diff --git a/shared/collaboration-mode.js b/shared/collaboration-mode.js
new file mode 100644
index 0000000..ba160b4
--- /dev/null
+++ b/shared/collaboration-mode.js
@@ -0,0 +1,58 @@
+const VALID_COLLABORATION_MODES = new Set(['plan', 'default']);
+const VALID_REASONING_EFFORTS = new Set(['none', 'minimal', 'low', 'medium', 'high', 'xhigh']);
+
+export function normalizeComposerMode(value) {
+ return String(value || '').trim().toLowerCase() === 'plan' ? 'plan' : 'chat';
+}
+
+function normalizeReasoningEffort(value) {
+ const effort = String(value || '').trim();
+ return VALID_REASONING_EFFORTS.has(effort) ? effort : null;
+}
+
+export function normalizeCollaborationMode(value, {
+ model = '',
+ reasoningEffort = ''
+} = {}) {
+ if (!value) {
+ return null;
+ }
+ const mode = String(value.mode || '').trim();
+ if (!VALID_COLLABORATION_MODES.has(mode)) {
+ throw new Error(`Unsupported collaboration mode: ${mode || 'empty'}`);
+ }
+ if (mode === 'default') {
+ return null;
+ }
+ const settings = value.settings && typeof value.settings === 'object' ? value.settings : {};
+ const selectedModel = String(settings.model || model || '').trim();
+ if (!selectedModel) {
+ throw new Error('Plan mode requires a model');
+ }
+ return {
+ mode: 'plan',
+ settings: {
+ model: selectedModel,
+ reasoning_effort: normalizeReasoningEffort(settings.reasoning_effort || reasoningEffort),
+ developer_instructions: null
+ }
+ };
+}
+
+export function collaborationModeForComposer({
+ composerMode = 'chat',
+ model = '',
+ reasoningEffort = ''
+} = {}) {
+ if (normalizeComposerMode(composerMode) !== 'plan') {
+ return null;
+ }
+ return normalizeCollaborationMode({
+ mode: 'plan',
+ settings: {
+ model,
+ reasoning_effort: reasoningEffort,
+ developer_instructions: null
+ }
+ }, { model, reasoningEffort });
+}
diff --git a/shared/collaboration-mode.test.mjs b/shared/collaboration-mode.test.mjs
new file mode 100644
index 0000000..5174671
--- /dev/null
+++ b/shared/collaboration-mode.test.mjs
@@ -0,0 +1,89 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import {
+ collaborationModeForComposer,
+ normalizeCollaborationMode,
+ normalizeComposerMode
+} from './collaboration-mode.js';
+
+test('normalizeComposerMode only preserves plan explicitly', () => {
+ assert.equal(normalizeComposerMode('plan'), 'plan');
+ assert.equal(normalizeComposerMode('chat'), 'chat');
+ assert.equal(normalizeComposerMode(''), 'chat');
+ assert.equal(normalizeComposerMode('default'), 'chat');
+});
+
+test('collaborationModeForComposer returns null for chat mode', () => {
+ assert.equal(collaborationModeForComposer({
+ composerMode: 'chat',
+ model: 'gpt-5.5',
+ reasoningEffort: 'xhigh'
+ }), null);
+});
+
+test('collaborationModeForComposer builds the official plan payload', () => {
+ assert.deepEqual(collaborationModeForComposer({
+ composerMode: 'plan',
+ model: 'gpt-5.5',
+ reasoningEffort: 'xhigh'
+ }), {
+ mode: 'plan',
+ settings: {
+ model: 'gpt-5.5',
+ reasoning_effort: 'xhigh',
+ developer_instructions: null
+ }
+ });
+});
+
+test('normalizeCollaborationMode rejects unsupported modes', () => {
+ assert.throws(
+ () => normalizeCollaborationMode({ mode: 'review' }, { model: 'gpt-5.5', reasoningEffort: 'medium' }),
+ /Unsupported collaboration mode/
+ );
+});
+
+test('normalizeCollaborationMode returns null for default mode', () => {
+ assert.equal(normalizeCollaborationMode(
+ { mode: 'default' },
+ { model: 'gpt-5.5', reasoningEffort: 'medium' }
+ ), null);
+});
+
+test('normalizeCollaborationMode requires a model for plan mode', () => {
+ assert.throws(
+ () => normalizeCollaborationMode({ mode: 'plan' }, { reasoningEffort: 'medium' }),
+ /Plan mode requires a model/
+ );
+});
+
+test('normalizeCollaborationMode fills settings from selected send options', () => {
+ assert.deepEqual(normalizeCollaborationMode({
+ mode: 'plan',
+ settings: { model: '', reasoning_effort: null, developer_instructions: 'ignored for v1' }
+ }, {
+ model: 'gpt-5.4',
+ reasoningEffort: 'high'
+ }), {
+ mode: 'plan',
+ settings: {
+ model: 'gpt-5.4',
+ reasoning_effort: 'high',
+ developer_instructions: null
+ }
+ });
+});
+
+test('normalizeCollaborationMode normalizes invalid reasoning effort to null', () => {
+ assert.deepEqual(normalizeCollaborationMode({
+ mode: 'plan',
+ settings: { model: 'gpt-5.5', reasoning_effort: 'turbo' }
+ }), {
+ mode: 'plan',
+ settings: {
+ model: 'gpt-5.5',
+ reasoning_effort: null,
+ developer_instructions: null
+ }
+ });
+});
diff --git a/shared/service-tier.js b/shared/service-tier.js
new file mode 100644
index 0000000..1a6b261
--- /dev/null
+++ b/shared/service-tier.js
@@ -0,0 +1,6 @@
+const SERVICE_TIERS = new Set(['fast', 'flex']);
+
+export function normalizeServiceTier(value) {
+ const serviceTier = String(value || '').trim();
+ return SERVICE_TIERS.has(serviceTier) ? serviceTier : null;
+}