From 5cf77c8b4056b891f7c7e802f5ee0e9b62ccaae7 Mon Sep 17 00:00:00 2001 From: Patrice Huetz Date: Fri, 29 May 2026 01:16:38 +0200 Subject: [PATCH 01/29] feat(cowork): pilot engine slash commands & operator surfaces from Cowork Make Code Buddy's engine commands and Hermes-parity surfaces drivable from the Cowork GUI instead of only the CLI (audit roadmap S0-S8). All routing goes through Cowork-native surfaces (never the headless CLI handlers) to avoid the cross-realm singleton trap. - S0 headless slash: core executeHeadlessSlashToken seam + default-deny allowlist; ChatView routes /-commands to real engine output, ui_effects or an honest "not yet pilotable" denial instead of a placeholder token toast. - S1 multi-agent: /swarm, /parallel, /agents, /fleet route to the native orchestrator/launcher/Fleet panels and show live in SubAgentPanel. - S2 browser operator: stream browser tool actions as browser.action events into a live BrowserOperatorOverlay with a STOP control. - S3 user model: "Infer from session" triggers runUserDialecticInference; proposed observations are reviewed/accepted in UserModelPanel. - S4 plan mode: /plan enters read-only plan permission mode. - S6 mobile supervision: MobileSupervisionPanel manages the pairing code and follow-up approval queue over the embedded server loopback routes, supervision-only (approval never dispatches work). - S7 compaction lineage: record a guarded fork run at the compaction boundary (no-op without an active observability run, never throws). - S8 long-tail: /lessons and /team open their Cowork panels. ~70 dedicated tests added; full Cowork suite (1414) green; both typechecks clean; core and Cowork bundles build. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/main/commands/slash-command-bridge.ts | 229 +++++++++++++++--- cowork/src/main/engine/browser-action.ts | 100 ++++++++ .../main/engine/codebuddy-engine-runner.ts | 16 ++ cowork/src/main/index.ts | 2 + cowork/src/main/ipc/mobile-supervision-ipc.ts | 72 ++++++ cowork/src/main/ipc/user-model-ipc.ts | 34 +++ .../main/server/mobile-supervision-client.ts | 116 +++++++++ cowork/src/preload/index.ts | 43 +++- cowork/src/renderer/App.tsx | 6 + .../commands/slash-command-actions.ts | 164 +++++++++++++ .../components/BrowserOperatorOverlay.tsx | 142 +++++++++++ cowork/src/renderer/components/ChatView.tsx | 83 +++---- .../components/MobileSupervisionPanel.tsx | 200 +++++++++++++++ .../renderer/components/ShellNavigation.tsx | 11 + .../renderer/components/UserModelPanel.tsx | 36 ++- .../components/user-model-inference.ts | 29 +++ cowork/src/renderer/hooks/useIPC.ts | 5 + cowork/src/renderer/store/index.ts | 39 +++ cowork/src/renderer/types/hermes.ts | 9 + cowork/src/renderer/types/index.ts | 20 ++ cowork/tests/browser-action.test.ts | 63 +++++ .../tests/mobile-supervision-client.test.ts | 71 ++++++ cowork/tests/slash-command-actions.test.ts | 186 ++++++++++++++ .../slash-command-bridge-headless.test.ts | 141 +++++++++++ cowork/tests/user-model-inference.test.ts | 50 ++++ src/agent/execution/agent-executor.ts | 9 + src/agent/tool-handler.ts | 5 + src/commands/headless-slash.ts | 112 +++++++++ src/context/compaction-fork.ts | 40 +++ .../headless-slash-integration.test.ts | 30 +++ tests/commands/headless-slash.test.ts | 83 +++++++ tests/context/compaction-fork.test.ts | 35 +++ 32 files changed, 2097 insertions(+), 84 deletions(-) create mode 100644 cowork/src/main/engine/browser-action.ts create mode 100644 cowork/src/main/ipc/mobile-supervision-ipc.ts create mode 100644 cowork/src/main/server/mobile-supervision-client.ts create mode 100644 cowork/src/renderer/commands/slash-command-actions.ts create mode 100644 cowork/src/renderer/components/BrowserOperatorOverlay.tsx create mode 100644 cowork/src/renderer/components/MobileSupervisionPanel.tsx create mode 100644 cowork/src/renderer/components/user-model-inference.ts create mode 100644 cowork/tests/browser-action.test.ts create mode 100644 cowork/tests/mobile-supervision-client.test.ts create mode 100644 cowork/tests/slash-command-actions.test.ts create mode 100644 cowork/tests/slash-command-bridge-headless.test.ts create mode 100644 cowork/tests/user-model-inference.test.ts create mode 100644 src/commands/headless-slash.ts create mode 100644 src/context/compaction-fork.ts create mode 100644 tests/commands/headless-slash-integration.test.ts create mode 100644 tests/commands/headless-slash.test.ts create mode 100644 tests/context/compaction-fork.test.ts diff --git a/cowork/src/main/commands/slash-command-bridge.ts b/cowork/src/main/commands/slash-command-bridge.ts index 0c397e66..c70ff35c 100644 --- a/cowork/src/main/commands/slash-command-bridge.ts +++ b/cowork/src/main/commands/slash-command-bridge.ts @@ -33,19 +33,38 @@ export interface SlashCommandDef { arguments?: SlashCommandArg[]; } +/** + * Renderer-side effects for presentation-only slash commands that have no + * headless engine behaviour (they map to a Cowork equivalent instead). + */ +export type SlashUiEffectKind = + | 'open_model_picker' + | 'run_orchestrator' + | 'open_orchestrator_launcher' + | 'open_fleet' + | 'set_plan_mode' + | 'open_lessons' + | 'open_team'; + export interface SlashCommandExecuteResult { success: boolean; /** Text that should be injected as the user prompt (if any) */ prompt?: string; - /** Free-form message shown in the chat (e.g. "Cleared", "Switched model") */ + /** Free-form message shown as a transient toast (e.g. "Cleared", errors) */ message?: string; + /** Engine command output to render as an assistant chat message (not a toast) */ + output?: string; error?: string; /** True when the command handled everything itself (no LLM round needed) */ handled?: boolean; action?: { - type: 'open_schedule' | 'create_schedule'; + type: 'open_schedule' | 'create_schedule' | 'ui_effect'; draft?: SlashScheduleDraft; createInput?: SlashScheduleCreateInput; + /** For type 'ui_effect': which Cowork-side effect the renderer should apply */ + uiEffect?: SlashUiEffectKind; + /** Parsed args, forwarded so the renderer can parameterize the effect */ + args?: string[]; }; } @@ -311,6 +330,132 @@ async function loadSlashModule(): Promise { return mod; } +type HeadlessSlashResult = { + handled: boolean; + output?: string; + prompt?: string; + passToAI?: boolean; + denied?: boolean; + reason?: string; +}; + +type CoreHeadlessModule = { + executeHeadlessSlashToken: ( + token: string, + args: string[], + allow: ReadonlySet, + ctx?: { conversationHistory?: unknown; client?: unknown } + ) => Promise; +}; + +let cachedHeadlessModule: CoreHeadlessModule | null = null; + +async function loadHeadlessModule(): Promise { + if (cachedHeadlessModule) return cachedHeadlessModule; + const mod = await loadCoreModule('commands/headless-slash.js'); + if (mod) { + cachedHeadlessModule = mod; + log('[SlashCommandBridge] Core headless-slash module loaded'); + } else { + logWarn('[SlashCommandBridge] Core headless-slash module unavailable'); + } + return mod; +} + +/** + * Slice S0 allowlist: tokens that are safe to run headlessly from Cowork **today**. + * + * Scope is deliberately limited to info / read-only commands. Their worst-case + * failure mode is benign — if the bridge's core module instance and the engine + * adapter's instance ever resolve to different `dist/` realms (core-loader tries + * several candidate roots), a read just returns empty/default data; it never + * lies about having changed state. + * + * Deliberately excluded until their realm/context is positively confirmed: + * - **mutating** (would silently no-op + falsely report success if realms differ): + * __YOLO_MODE__, __AUTONOMY__, __SELF_HEALING__, __DRY_RUN__, __PROMPT_CACHE__, + * __CACHE__. These must route through the engine session, not a bridge-side + * singleton — they graduate once realm-sharing is verified (S1+). + * - **wrong-context**: __WORKSPACE__ reads `process.cwd()`, which in the Cowork + * main process is the Electron app dir, not the session's project. + * - **history/client-dependent**: __COMPACT__, __SAVE_CONVERSATION__, __EXPORT__, + * __CONTEXT__ (stats), __AI_TEST__ — would run against an empty history today. + * - **orchestration (S1)**: __SWARM__, __TEAM__, __AGENTS__, __PARALLEL__, + * __BATCH__, __FLEET__ — spawn real work whose value is the live panel. + */ +const COWORK_HEADLESS_ALLOW: ReadonlySet = new Set([ + '__HELP__', + '__STATS__', + '__COST__', + '__TOOLS__', + '__WHOAMI__', + '__STATUS__', + '__FEATURES__', +]); + +type UiEffectResolution = + | { uiEffect: SlashUiEffectKind; args: string[] } + | 'deny' + | undefined; + +/** + * Map a token (+ its args) to a renderer-side Cowork effect, an honest denial, + * or undefined (fall through to the headless engine path). + * + * S1: multi-agent commands route to Cowork-NATIVE orchestration + * (`orchestrator.run` / launcher / fleet panel), NOT the headless CLI handlers — + * only the native path emits the `subagent.*` events the SubAgentPanel observes + * live (the OrchestratorBridge owns the event forwarding, so visibility does not + * depend on which realm the MultiAgentSystem instance lives in). Subcommands we + * don't drive yet are denied honestly rather than silently opening a launcher. + * + * `/clear` is intentionally absent: "clear chat" in a persistent, multi-session + * GUI is ambiguous (clear the view vs. start a new session) and deserves its own + * decision — it falls through to the honest "not yet pilotable" path. + */ +function resolveUiEffectAction(token: string, args: string[]): UiEffectResolution { + switch (token) { + case '__CHANGE_MODEL__': + return { uiEffect: 'open_model_picker', args }; + case '__PLAN_MODE__': + // `/plan` → enter read-only plan permission mode (S4). + return { uiEffect: 'set_plan_mode', args: [] }; + case '__SWARM__': + case '__PARALLEL__': + // `/swarm ` launches immediately (parallel strategy); bare `/swarm` + // opens the launcher (mirrors the CLI's accidental-trigger guard). + return args.length > 0 + ? { uiEffect: 'run_orchestrator', args } + : { uiEffect: 'open_orchestrator_launcher', args: [] }; + case '__AGENTS__': + // Bare `/agents` = the native multi-agent cockpit. Subcommands + // (run/status/stop/metrics) are not driven from Cowork yet. + return args.length === 0 ? { uiEffect: 'open_orchestrator_launcher', args: [] } : 'deny'; + case '__FLEET__': + // Bare `/fleet` opens the Fleet Command Center; subcommands + // (listen/route/chat/...) are not driven from Cowork yet. + return args.length === 0 ? { uiEffect: 'open_fleet', args: [] } : 'deny'; + case '__TEAM__': + // S8: bare `/team` opens the Cowork Team panel; subcommands + // (start/add/status/...) are not driven from Cowork yet. + return args.length === 0 ? { uiEffect: 'open_team', args: [] } : 'deny'; + case '__LESSONS__': + // S8: `/lessons` opens the lesson candidate review panel. + return { uiEffect: 'open_lessons', args: [] }; + default: + return undefined; + } +} + +/** Resolve a natural-language prompt command's text (substitute `{{args}}` or append). */ +function resolvePromptCommandText(prompt: string, args: string[]): string { + const joined = args.join(' ').trim(); + if (prompt.includes('{{args}}')) { + return prompt.replace(/\{\{args\}\}/g, joined); + } + return joined ? `${prompt}\n\n${joined}` : prompt; +} + export class SlashCommandBridge { /** List built-in + user-defined slash commands (flat). */ async listCommands(): Promise { @@ -407,35 +552,66 @@ export class SlashCommandBridge { }; } - const joined = args.join(' ').trim(); - - // Handle special tokens the renderer can resolve directly. + // Special tokens (`__FOO__`): split between renderer-side presentation + // effects and real headless engine behaviour. We no longer surface the raw + // token as a toast — that was discovery-without-piloting. if (cmd.prompt.startsWith('__') && cmd.prompt.endsWith('__')) { + const token = cmd.prompt; + + // 1. Renderer-side Cowork effect / honest denial / fall-through to engine. + const resolution = resolveUiEffectAction(token, args); + if (resolution === 'deny') { + return { + success: true, + handled: true, + message: `/${name} n'est pas encore pilotable depuis Cowork (à venir dans une prochaine étape).`, + }; + } + if (resolution) { + return { + success: true, + handled: true, + action: { type: 'ui_effect', uiEffect: resolution.uiEffect, args: resolution.args }, + }; + } + + // 2. Engine behaviour → run headlessly via the shared handler (default-deny). + const headlessMod = await loadHeadlessModule(); + if (!headlessMod) { + return { success: true, handled: true, message: `/${name} indisponible (moteur non chargé).` }; + } + const res = await headlessMod.executeHeadlessSlashToken(token, args, COWORK_HEADLESS_ALLOW); + if (res.denied) { + return { + success: true, + handled: true, + message: `/${name} n'est pas encore pilotable depuis Cowork (à venir dans une prochaine étape).`, + }; + } + if (res.passToAI && res.prompt) { + return { success: true, prompt: res.prompt, handled: false }; + } + if (res.output) { + return { success: true, handled: true, output: res.output }; + } return { success: true, handled: true, - message: cmd.prompt, // the renderer switches on this + message: res.reason ? `/${name}: ${res.reason}` : `/${name} exécuté.`, }; } // Natural-language prompt commands: substitute {{args}} or append. - let resolved = cmd.prompt; - if (resolved.includes('{{args}}')) { - resolved = resolved.replace(/\{\{args\}\}/g, joined); - } else if (joined) { - resolved = `${resolved}\n\n${joined}`; - } - return { success: true, - prompt: resolved, + prompt: resolvePromptCommandText(cmd.prompt, args), handled: false, }; } async executeRemoteInput( rawInput: string, - sessionId?: string + _sessionId?: string ): Promise { const trimmed = rawInput.trim(); if (!trimmed.startsWith('/')) { @@ -448,21 +624,20 @@ export class SlashCommandBridge { return { allowed: false, message: 'Empty slash command is not available remotely.' }; } - const result = await this.execute(name, args, sessionId); - if (!result.success) { - return { - allowed: false, - message: result.error ?? `/${name} is not available in remote sessions.`, - }; + // Classify from the catalog WITHOUT executing. A remote (mobile) input must + // never trigger engine command side effects as a byproduct of deciding to + // block it — only forwardable natural-language prompt commands are allowed. + const all = await this.listCommands(); + const cmd = all.find((c) => c.name === name); + if (!cmd) { + return { allowed: false, message: `/${name} is not available in remote sessions.` }; } - if (result.handled || !result.prompt) { - return { - allowed: false, - message: `/${name} is not available in remote sessions.`, - }; + const isToken = cmd.prompt.startsWith('__') && cmd.prompt.endsWith('__'); + if (isToken || cmd.name === 'schedule') { + return { allowed: false, message: `/${name} is not available in remote sessions.` }; } - return { allowed: true, prompt: result.prompt }; + return { allowed: true, prompt: resolvePromptCommandText(cmd.prompt, args) }; } } diff --git a/cowork/src/main/engine/browser-action.ts b/cowork/src/main/engine/browser-action.ts new file mode 100644 index 00000000..2b612162 --- /dev/null +++ b/cowork/src/main/engine/browser-action.ts @@ -0,0 +1,100 @@ +/** + * Browser Operator action events (S2). + * + * The engine adapter streams tool lifecycle events; when a browser-automation + * tool finishes we translate it into a `browser.action` ServerEvent that the + * BrowserOperatorOverlay renders live (mirrors the Computer Use `gui.action` + * pipeline). Pure helpers live here so they can be unit-tested without the + * whole engine runner. + * + * @module main/engine/browser-action + */ + +import type { BrowserActionEvent } from '../../renderer/types'; + +/** Detect browser-automation tool names so we can stream their actions live. */ +export function isBrowserOperatorTool(name: string): boolean { + if (!name) return false; + const lower = name.toLowerCase(); + return ( + lower === 'browser' || + lower.startsWith('browser_') || + lower === 'internet_scout' || + lower === 'browser_search' + ); +} + +function tryParseInput(raw: string | undefined): Record { + if (!raw) return {}; + try { + const parsed = JSON.parse(raw); + return parsed && typeof parsed === 'object' ? (parsed as Record) : {}; + } catch { + return {}; + } +} + +/** Extract a screenshot data URI / file path from a tool output data blob. */ +function extractScreenshot(data: unknown): string | undefined { + if (!data || typeof data !== 'object') return undefined; + const obj = data as Record; + const candidate = obj.screenshot ?? obj.image ?? obj.imagePath ?? obj.screenshotPath; + if (typeof candidate === 'string' && candidate.length > 0) { + if (!candidate.startsWith('data:image/') && !candidate.startsWith('file://')) { + if (/^[A-Za-z0-9+/=]+$/.test(candidate.substring(0, 50))) { + return `data:image/png;base64,${candidate}`; + } + } + return candidate; + } + return undefined; +} + +const EVIDENCE_MAX = 280; + +export interface BrowserActionInput { + sessionId: string; + toolUseId: string; + toolName: string; + rawInput?: string; + data?: unknown; + output?: string; + /** Injectable for deterministic tests; defaults to Date.now(). */ + now?: number; +} + +/** Build the `browser.action` payload from a finished browser tool call. */ +export function buildBrowserActionPayload(args: BrowserActionInput): BrowserActionEvent { + const input = tryParseInput(args.rawInput); + const action = + typeof input.action === 'string' + ? input.action + : typeof input.command === 'string' + ? input.command + : args.toolName; + const url = typeof input.url === 'string' ? input.url : undefined; + const target = + typeof input.selector === 'string' + ? input.selector + : typeof input.text === 'string' + ? input.text + : typeof input.query === 'string' + ? input.query + : undefined; + const evidence = + typeof args.output === 'string' && args.output.length > 0 + ? args.output.slice(0, EVIDENCE_MAX) + : undefined; + + return { + sessionId: args.sessionId, + toolUseId: args.toolUseId, + action, + url, + target, + evidence, + screenshot: extractScreenshot(args.data), + details: input, + timestamp: args.now ?? Date.now(), + }; +} diff --git a/cowork/src/main/engine/codebuddy-engine-runner.ts b/cowork/src/main/engine/codebuddy-engine-runner.ts index f8e3cc46..703e188c 100644 --- a/cowork/src/main/engine/codebuddy-engine-runner.ts +++ b/cowork/src/main/engine/codebuddy-engine-runner.ts @@ -10,6 +10,7 @@ import { v4 as uuidv4 } from 'uuid'; import { log, logError } from '../utils/logger'; +import { isBrowserOperatorTool, buildBrowserActionPayload } from './browser-action'; import { getReasoningBridge } from '../reasoning/reasoning-bridge'; import { createReasoningCapture } from '../reasoning/reasoning-capture'; import type { @@ -266,6 +267,21 @@ export class CodeBuddyEngineRunner { event.tool.data ); } + + // S2: emit browser.action events for the Browser Operator overlay. + if (isBrowserOperatorTool(event.tool.name)) { + sendToRenderer({ + type: 'browser.action', + payload: buildBrowserActionPayload({ + sessionId: session.id, + toolUseId: event.tool.id, + toolName: event.tool.name, + rawInput: event.tool.input, + data: event.tool.data, + output: event.tool.output, + }), + }); + } } break; diff --git a/cowork/src/main/index.ts b/cowork/src/main/index.ts index d40d32ea..7d028982 100644 --- a/cowork/src/main/index.ts +++ b/cowork/src/main/index.ts @@ -45,6 +45,7 @@ import { registerCommandIpcHandlers } from './ipc/command-ipc'; import { registerSkillMdIpcHandlers } from './ipc/skill-md-ipc'; import { registerKnowledgeIpcHandlers } from './ipc/knowledge-ipc'; import { registerLessonCandidateIpcHandlers } from './ipc/lessons-candidate-ipc'; +import { registerMobileSupervisionIpcHandlers } from './ipc/mobile-supervision-ipc'; import { registerUserModelIpcHandlers } from './ipc/user-model-ipc'; import { registerCompanionIpcHandlers } from './ipc/companion-ipc'; import { registerSpecIpcHandlers } from './ipc/spec-ipc'; @@ -2334,6 +2335,7 @@ registerKnowledgeIpcHandlers(knowledgeService, projectManager); // projectManager getter (set during async boot, like fleetBridge above). registerLessonCandidateIpcHandlers(() => projectManager); registerUserModelIpcHandlers(() => projectManager); +registerMobileSupervisionIpcHandlers(); registerCompanionIpcHandlers(() => projectManager); registerSpecIpcHandlers(() => projectManager, configStore); registerSpecNextIpcHandlers(() => projectManager); diff --git a/cowork/src/main/ipc/mobile-supervision-ipc.ts b/cowork/src/main/ipc/mobile-supervision-ipc.ts new file mode 100644 index 00000000..4b575239 --- /dev/null +++ b/cowork/src/main/ipc/mobile-supervision-ipc.ts @@ -0,0 +1,72 @@ +/** + * Mobile supervision IPC (S6). + * + * Surfaces the supervision-only mobile gateway in Cowork: read the pairing code + * + follow-up review queue and approve/cancel queued drafts. All calls go to the + * embedded Code Buddy server's loopback-gated `/api/mobile` routes via the + * ServerBridge; nothing here dispatches work — approval stays a review marker. + * + * @module main/ipc/mobile-supervision-ipc + */ + +import { ipcMain } from 'electron'; +import { logError } from '../utils/logger'; +import { getServerBridge } from '../server/server-bridge'; +import { + fetchMobileSupervision, + approveFollowupDraft, + cancelFollowupDraft, + rotatePairingCode, +} from '../server/mobile-supervision-client'; + +const SERVER_DOWN = 'Embedded server is not running — start it to manage mobile supervision.'; + +async function port(): Promise { + const status = await getServerBridge().status(); + return status.running ? status.port : null; +} + +export function registerMobileSupervisionIpcHandlers(): void { + ipcMain.handle('mobileSupervision.status', async () => { + try { + const status = await getServerBridge().status(); + return await fetchMobileSupervision(status.port, status.running, fetch as never); + } catch (err) { + logError('[mobileSupervision.status] failed:', err); + return { running: false, port: null, error: err instanceof Error ? err.message : String(err) }; + } + }); + + ipcMain.handle('mobileSupervision.approve', async (_e, id: string, reviewer?: string) => { + const p = await port(); + if (p == null) return { ok: false as const, error: SERVER_DOWN }; + try { + await approveFollowupDraft(p, id, reviewer, fetch as never); + return { ok: true as const }; + } catch (err) { + return { ok: false as const, error: err instanceof Error ? err.message : String(err) }; + } + }); + + ipcMain.handle('mobileSupervision.cancel', async (_e, id: string) => { + const p = await port(); + if (p == null) return { ok: false as const, error: SERVER_DOWN }; + try { + await cancelFollowupDraft(p, id, fetch as never); + return { ok: true as const }; + } catch (err) { + return { ok: false as const, error: err instanceof Error ? err.message : String(err) }; + } + }); + + ipcMain.handle('mobileSupervision.rotateCode', async () => { + const p = await port(); + if (p == null) return { ok: false as const, error: SERVER_DOWN }; + try { + const res = await rotatePairingCode(p, fetch as never); + return { ok: true as const, pairingCode: res.pairingCode as string | undefined }; + } catch (err) { + return { ok: false as const, error: err instanceof Error ? err.message : String(err) }; + } + }); +} diff --git a/cowork/src/main/ipc/user-model-ipc.ts b/cowork/src/main/ipc/user-model-ipc.ts index 3aafc1ad..c9329cb3 100644 --- a/cowork/src/main/ipc/user-model-ipc.ts +++ b/cowork/src/main/ipc/user-model-ipc.ts @@ -63,6 +63,14 @@ interface UserModelLike { type UserModelMod = { getUserModel: (workDir?: string) => UserModelLike; + /** + * S3: dialectic inference over a transcript. Proposes pending observations + * (privacy-screened) and returns them; never writes the active model. + */ + runUserDialecticInference?: ( + chatHistory: Array<{ type: string; content: string }>, + workDir?: string, + ) => Promise; }; const NO_PROJECT = 'NO_ACTIVE_PROJECT'; @@ -156,4 +164,30 @@ export function registerUserModelIpcHandlers(projectManagerSource: ProjectManage } }, ); + + // S3: run dialectic inference over a session transcript → pending observations. + // Proposes only (review-gated); the existing `accept` is still the only write + // path. Uses the core provider auto-detection (env) when no client is wired. + ipcMain.handle( + 'userModel.runInference', + async (_e, chatHistory: Array<{ type: string; content: string }>, projectId?: string) => { + const empty: UserObservation[] = []; + const workDir = resolveWorkDir(projectManagerSource, projectId); + if (!workDir) return { ok: false as const, error: NO_PROJECT, items: empty }; + if (!Array.isArray(chatHistory) || chatHistory.length === 0) { + return { ok: false as const, error: 'No conversation history to analyze.', items: empty }; + } + const mod = await loadCoreModule('memory/user-model.js'); + if (!mod?.runUserDialecticInference) { + return { ok: false as const, error: 'core user-model inference unavailable', items: empty }; + } + try { + const proposed = await mod.runUserDialecticInference(chatHistory, workDir); + return { ok: true as const, items: proposed }; + } catch (err) { + logError('[userModel.runInference] failed:', err); + return { ok: false as const, error: errorMessage(err), items: empty }; + } + }, + ); } diff --git a/cowork/src/main/server/mobile-supervision-client.ts b/cowork/src/main/server/mobile-supervision-client.ts new file mode 100644 index 00000000..b805dd83 --- /dev/null +++ b/cowork/src/main/server/mobile-supervision-client.ts @@ -0,0 +1,116 @@ +/** + * Mobile supervision loopback client (S6). + * + * The mobile gateway (`src/server/routes/mobile.ts`) is supervision-only: a + * paired phone can read snapshots and *propose* prompts, but those land as + * `needs_local_operator` review drafts that NEVER auto-execute. Approval is a + * local-operator-only (loopback) action. + * + * Cowork is the local operator. When the embedded Code Buddy server is running, + * Cowork's main process is on loopback relative to it, so it can read the + * pairing code + follow-up queue and approve/cancel drafts through the same + * loopback-gated routes. These pure helpers take an injected `fetch` so they + * unit-test without a live server. Approval here remains a review marker — it + * does not dispatch work (the route guarantees that). + * + * @module main/server/mobile-supervision-client + */ + +export interface FollowupDraft { + id: string; + prompt: string; + status: 'needs_local_operator' | 'approved' | 'cancelled'; + source: 'mobile_device' | 'draft_only'; + createdAt: number; + approvedBy?: string; + approvedAt?: number; + cancelledAt?: number; +} + +export interface MobileSupervisionSnapshot { + running: boolean; + port: number | null; + pairingCode?: string; + devices?: string[]; + drafts?: FollowupDraft[]; + error?: string; +} + +type FetchLike = (url: string, init?: { method?: string; headers?: Record; body?: string }) => Promise<{ + ok: boolean; + status: number; + json: () => Promise; +}>; + +export function loopbackBaseUrl(port: number): string { + return `http://127.0.0.1:${port}`; +} + +async function getJson(fetchImpl: FetchLike, url: string): Promise> { + const res = await fetchImpl(url); + const body = (await res.json()) as Record; + if (!res.ok || body?.ok === false) { + throw new Error(typeof body?.error === 'string' ? body.error : `Request failed (${res.status})`); + } + return body; +} + +async function postJson( + fetchImpl: FetchLike, + url: string, + payload: Record = {}, +): Promise> { + const res = await fetchImpl(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const body = (await res.json()) as Record; + if (!res.ok || body?.ok === false) { + throw new Error(typeof body?.error === 'string' ? body.error : `Request failed (${res.status})`); + } + return body; +} + +/** Read the pairing code + devices + follow-up review queue from the gateway. */ +export async function fetchMobileSupervision( + port: number | null, + running: boolean, + fetchImpl: FetchLike, +): Promise { + if (!running || port == null) { + return { running: false, port: null }; + } + const base = loopbackBaseUrl(port); + try { + const [pairing, drafts] = await Promise.all([ + getJson(fetchImpl, `${base}/api/mobile/pairing-status`), + getJson(fetchImpl, `${base}/api/mobile/followup-drafts`), + ]); + return { + running: true, + port, + pairingCode: typeof pairing.pairingCode === 'string' ? pairing.pairingCode : undefined, + devices: Array.isArray(pairing.activeDevices) ? (pairing.activeDevices as string[]) : [], + drafts: Array.isArray(drafts.drafts) ? (drafts.drafts as FollowupDraft[]) : [], + }; + } catch (err) { + return { running: true, port, error: err instanceof Error ? err.message : String(err) }; + } +} + +/** Local-operator approve. Review marker only — never dispatches work. */ +export function approveFollowupDraft(port: number, id: string, reviewer: string | undefined, fetchImpl: FetchLike) { + return postJson(fetchImpl, `${loopbackBaseUrl(port)}/api/mobile/followup-draft/${encodeURIComponent(id)}/approve`, { + reviewer: reviewer?.trim() || undefined, + }); +} + +export function cancelFollowupDraft(port: number, id: string, fetchImpl: FetchLike) { + return postJson(fetchImpl, `${loopbackBaseUrl(port)}/api/mobile/followup-draft/${encodeURIComponent(id)}/cancel`); +} + +/** Rotate the pairing code (local-operator only). */ +export function rotatePairingCode(port: number, fetchImpl: FetchLike) { + return postJson(fetchImpl, `${loopbackBaseUrl(port)}/api/mobile/pairing-code`); +} diff --git a/cowork/src/preload/index.ts b/cowork/src/preload/index.ts index 6abdec4e..c7ba26c6 100644 --- a/cowork/src/preload/index.ts +++ b/cowork/src/preload/index.ts @@ -2466,10 +2466,13 @@ contextBridge.exposeInMainWorld('electronAPI', { success: boolean; prompt?: string; message?: string; + output?: string; error?: string; handled?: boolean; action?: { - type: 'open_schedule' | 'create_schedule'; + type: 'open_schedule' | 'create_schedule' | 'ui_effect'; + uiEffect?: 'open_model_picker' | 'run_orchestrator' | 'open_orchestrator_launcher' | 'open_fleet' | 'set_plan_mode' | 'open_lessons' | 'open_team'; + args?: string[]; draft?: { prompt: string; cwd?: string; @@ -2616,6 +2619,19 @@ contextBridge.exposeInMainWorld('electronAPI', { input: { reviewedBy?: string; reason?: string }, projectId?: string ) => ipcRenderer.invoke('userModel.discard', id, input, projectId), + runInference: ( + chatHistory: Array<{ type: string; content: string }>, + projectId?: string + ) => ipcRenderer.invoke('userModel.runInference', chatHistory, projectId), + }, + + // S6: supervision-only mobile gateway management (loopback to embedded server) + mobileSupervision: { + status: () => ipcRenderer.invoke('mobileSupervision.status'), + approve: (id: string, reviewer?: string) => + ipcRenderer.invoke('mobileSupervision.approve', id, reviewer), + cancel: (id: string) => ipcRenderer.invoke('mobileSupervision.cancel', id), + rotateCode: () => ipcRenderer.invoke('mobileSupervision.rotateCode'), }, spec: { @@ -4607,10 +4623,13 @@ declare global { success: boolean; prompt?: string; message?: string; + output?: string; error?: string; handled?: boolean; action?: { - type: 'open_schedule' | 'create_schedule'; + type: 'open_schedule' | 'create_schedule' | 'ui_effect'; + uiEffect?: 'open_model_picker' | 'run_orchestrator' | 'open_orchestrator_launcher' | 'open_fleet' | 'set_plan_mode' | 'open_lessons' | 'open_team'; + args?: string[]; draft?: { prompt: string; cwd?: string; @@ -4697,6 +4716,26 @@ declare global { }; lessonCandidate: LessonCandidateApi; userModel: UserModelApi; + mobileSupervision: { + status: () => Promise<{ + running: boolean; + port: number | null; + pairingCode?: string; + devices?: string[]; + drafts?: Array<{ + id: string; + prompt: string; + status: 'needs_local_operator' | 'approved' | 'cancelled'; + source: 'mobile_device' | 'draft_only'; + createdAt: number; + approvedBy?: string; + }>; + error?: string; + }>; + approve: (id: string, reviewer?: string) => Promise<{ ok: boolean; error?: string }>; + cancel: (id: string) => Promise<{ ok: boolean; error?: string }>; + rotateCode: () => Promise<{ ok: boolean; pairingCode?: string; error?: string }>; + }; spec: SpecApi; skillsHub: { list: (projectId?: string) => Promise; diff --git a/cowork/src/renderer/App.tsx b/cowork/src/renderer/App.tsx index 28e4989b..45fa3c05 100644 --- a/cowork/src/renderer/App.tsx +++ b/cowork/src/renderer/App.tsx @@ -34,6 +34,7 @@ import { GlobalSearchDialog } from './components/GlobalSearchDialog'; import { FilePreviewPane } from './components/FilePreviewPane'; import { ArtifactPanel } from './components/ArtifactPanel'; import { ComputerUseOverlay } from './components/ComputerUseOverlay'; +import { BrowserOperatorOverlay } from './components/BrowserOperatorOverlay'; import { ApprovalDialog } from './components/ApprovalDialog'; import { ActivityFeed } from './components/ActivityFeed'; import { SessionInsightsPanel } from './components/SessionInsightsPanel'; @@ -57,6 +58,7 @@ import { TeamPanel } from './components/TeamPanel'; import { LessonCandidatePanel } from './components/LessonCandidatePanel'; import { UserModelPanel } from './components/UserModelPanel'; import { SpecPanel } from './components/SpecPanel'; +import { MobileSupervisionPanel } from './components/MobileSupervisionPanel'; import { CompanionPanel } from './components/CompanionPanel'; import { OnboardingWizard } from './components/OnboardingWizard'; import { SubAgentDashboard } from './components/SubAgentDashboard'; @@ -576,6 +578,9 @@ function App() { {/* Computer Use Overlay — Phase 2 step 13 */} + {/* Browser Operator Overlay — S2 */} + + {/* Workflow approval modal — driven by store.pendingApprovals */} @@ -638,6 +643,7 @@ function App() { + ); diff --git a/cowork/src/renderer/commands/slash-command-actions.ts b/cowork/src/renderer/commands/slash-command-actions.ts new file mode 100644 index 00000000..1164eea3 --- /dev/null +++ b/cowork/src/renderer/commands/slash-command-actions.ts @@ -0,0 +1,164 @@ +/** + * Renderer-side application of slash-command results. + * + * The SlashCommandBridge (main process) decides *what* a slash command does — + * render engine output, forward a prompt to the LLM, or apply a presentation-only + * `ui_effect`. This module applies that decision inside the Cowork renderer. It + * deliberately does NOT re-implement command behaviour: the engine behaviour + * already ran headlessly in the main process, and we only render / forward / + * apply the small set of presentation effects here. + * + * @module renderer/commands/slash-command-actions + */ + +import { useAppStore } from '../store'; +import type { Message, TextContent } from '../types'; + +/** Mirror of the SlashCommandBridge execute result (see preload `command.execute`). */ +export interface SlashExecuteResult { + success: boolean; + prompt?: string; + message?: string; + output?: string; + error?: string; + handled?: boolean; + action?: { + type: 'open_schedule' | 'create_schedule' | 'ui_effect'; + uiEffect?: 'open_model_picker' | 'run_orchestrator' | 'open_orchestrator_launcher' | 'open_fleet' | 'set_plan_mode' | 'open_lessons' | 'open_team'; + args?: string[]; + }; +} + +export interface SlashActionContext { + /** Command name without the leading slash (for notice prefixes). */ + commandName: string; + /** Active session id, required to render engine output as a chat message. */ + activeSessionId: string | null; + /** Forwards a resolved prompt to the LLM (ChatView's continueSession closure). */ + continueWithPrompt: (prompt: string) => void | Promise; +} + +function notice(type: 'info' | 'success' | 'error', message: string): void { + useAppStore.getState().setGlobalNotice({ id: `slash-${type}-${Date.now()}`, type, message }); +} + +/** Render engine command output as a local assistant message in the active session. */ +function renderOutput(sessionId: string, output: string): void { + const message: Message = { + id: crypto.randomUUID(), + sessionId, + role: 'assistant', + content: [{ type: 'text', text: output } as TextContent], + timestamp: Date.now(), + }; + useAppStore.getState().addMessage(sessionId, message); +} + +/** + * Launch a multi-agent run via Cowork's NATIVE orchestrator bridge. This is the + * path whose `subagent.*` events the always-mounted SubAgentPanel observes, so + * the agents appear live in the chat. (The headless CLI `handleSwarm` would + * spawn into a separate, terminal-only MultiAgentSystem the panel never sees.) + */ +function runOrchestrator(goal: string, ctx: SlashActionContext): void { + if (!ctx.activeSessionId) { + notice('error', 'Aucune session active pour lancer un swarm.'); + return; + } + if (!goal) { + useAppStore.getState().setShowOrchestratorLauncher(true); + return; + } + const maxRounds = useAppStore.getState().lastOrchestratorOptions?.maxRounds ?? 3; + // `/swarm` and `/parallel` both imply the parallel strategy (matches the CLI). + void window.electronAPI?.orchestrator + ?.run(ctx.activeSessionId, goal, { strategy: 'parallel', maxRounds }) + .catch((err: unknown) => { + notice('error', `Swarm échoué : ${err instanceof Error ? err.message : String(err)}`); + }); + notice('success', `Swarm lancé (parallel) : ${goal}`); +} + +function applyUiEffect(result: SlashExecuteResult, ctx: SlashActionContext): void { + const action = result.action; + if (!action || action.type !== 'ui_effect') return; + + switch (action.uiEffect) { + case 'open_model_picker': { + const target = action.args?.[0]; + if (target) { + void window.electronAPI?.model?.switch(target); + const cfg = useAppStore.getState().appConfig; + if (cfg) useAppStore.getState().setAppConfig({ ...cfg, model: target }); + notice('success', `Modèle : ${target}`); + } else { + notice('info', 'Choisis un modèle via le sélecteur en haut, ou utilise /model .'); + } + break; + } + case 'run_orchestrator': + runOrchestrator((action.args ?? []).join(' ').trim(), ctx); + break; + case 'open_orchestrator_launcher': + useAppStore.getState().setShowOrchestratorLauncher(true); + break; + case 'open_fleet': + useAppStore.getState().setShowFleetCommandCenter(true); + break; + case 'set_plan_mode': + void window.electronAPI?.permission?.setMode('plan'); + useAppStore.getState().setPermissionMode('plan'); + notice('success', 'Mode plan activé (lecture seule).'); + break; + case 'open_lessons': + useAppStore.getState().setShowLessonCandidatePanel(true); + break; + case 'open_team': + useAppStore.getState().setShowTeamPanel(true); + break; + } +} + +/** + * Apply a non-schedule slash-command result. Schedule actions + * (`open_schedule` / `create_schedule`) are handled by ChatView directly, + * because they depend on ChatView-local state. + * + * @returns true when the result was fully applied here (caller should clear the + * input and stop), false when there was nothing to do (caller falls through). + */ +export function applySlashCommandResult(result: SlashExecuteResult, ctx: SlashActionContext): boolean { + // 1. Presentation-only effect (model switch, orchestrator launch, panels). + if (result.action?.type === 'ui_effect') { + applyUiEffect(result, ctx); + return true; + } + + // 2. Engine output → render as an assistant chat message. + if (result.output && ctx.activeSessionId) { + renderOutput(ctx.activeSessionId, result.output); + return true; + } + + // 3. Handled with only a toast (info / denied / "not yet pilotable"). + if (result.handled) { + if (result.message) { + notice('info', ctx.commandName ? `/${ctx.commandName}: ${result.message}` : result.message); + } + return true; + } + + // 4. A prompt to forward to the LLM. + if (result.success && result.prompt) { + void ctx.continueWithPrompt(result.prompt); + return true; + } + + // 5. Error. + if (result.error) { + notice('error', result.error); + return true; + } + + return false; +} diff --git a/cowork/src/renderer/components/BrowserOperatorOverlay.tsx b/cowork/src/renderer/components/BrowserOperatorOverlay.tsx new file mode 100644 index 00000000..498d6b31 --- /dev/null +++ b/cowork/src/renderer/components/BrowserOperatorOverlay.tsx @@ -0,0 +1,142 @@ +/** + * BrowserOperatorOverlay — S2 (Browser Operator pilotability) + * + * Floating, retractable panel that shows the live browser-automation action log + * executed by the agent (navigate / click / type / extract / screenshot …), + * with the latest page screenshot when available and a panic STOP control. + * + * Auto-opens when a `browser.action` event arrives (store.appendBrowserAction). + * Mirrors ComputerUseOverlay but for the browser tool; positioned bottom-LEFT so + * the two operator overlays don't overlap. + * + * @module renderer/components/BrowserOperatorOverlay + */ + +import React, { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Globe, X, Minimize2, Maximize2, StopCircle } from 'lucide-react'; +import { useAppStore } from '../store'; +import { useIPC } from '../hooks/useIPC'; + +export const BrowserOperatorOverlay: React.FC = () => { + const { t } = useTranslation(); + const { stopSession } = useIPC(); + const browserActions = useAppStore((s) => s.browserActions); + const show = useAppStore((s) => s.showBrowserOperatorOverlay); + const setShow = useAppStore((s) => s.setShowBrowserOperatorOverlay); + const activeSessionId = useAppStore((s) => s.activeSessionId); + + const [minimized, setMinimized] = useState(false); + + const sessionActions = useMemo(() => { + if (!activeSessionId) return browserActions; + return browserActions.filter((a) => a.sessionId === activeSessionId); + }, [browserActions, activeSessionId]); + + if (!show || sessionActions.length === 0) return null; + + const latest = sessionActions[sessionActions.length - 1]; + const screenshotSrc = latest?.screenshot?.startsWith('data:') + ? latest.screenshot + : latest?.screenshot + ? `file://${latest.screenshot.replace(/\\/g, '/')}` + : undefined; + + if (minimized) { + return ( + + ); + } + + return ( +
+ {/* Header */} +
+
+ + + {t('browserOperator.title', { defaultValue: 'Browser Operator' })} + + + {t('browserOperator.count', { + count: sessionActions.length, + defaultValue: `${sessionActions.length} actions`, + })} + +
+
+ + + +
+
+ + {/* Latest screenshot (if any) */} + {screenshotSrc && ( +
+ browser-screenshot +
+ )} + + {/* Live action log (latest last) */} +
+ {sessionActions.map((a, idx) => ( +
+
+ + {a.action} + + {a.url && ( + + {a.url} + + )} +
+ {a.target && ( +
+ → {a.target} +
+ )} + {a.evidence && ( +
{a.evidence}
+ )} +
+ ))} +
+
+ ); +}; diff --git a/cowork/src/renderer/components/ChatView.tsx b/cowork/src/renderer/components/ChatView.tsx index 1db8cc87..c04fe0ab 100644 --- a/cowork/src/renderer/components/ChatView.tsx +++ b/cowork/src/renderer/components/ChatView.tsx @@ -11,6 +11,7 @@ import { useAppConfig, } from '../store/selectors'; import { useAppStore } from '../store'; +import { applySlashCommandResult } from '../commands/slash-command-actions'; import { useIPC } from '../hooks/useIPC'; import { MessageCard } from './MessageCard'; import { ModelSwitcher } from './ModelSwitcher'; @@ -805,43 +806,22 @@ export function ChatView() { return; } - if (result.handled) { - if (result.message) { - setGlobalNotice({ - id: `slash-info-${Date.now()}`, - type: 'info', - message: commandName ? `/${commandName}: ${result.message}` : result.message, - }); - } - setPrompt(''); - if (textareaRef.current) { - textareaRef.current.value = ''; - } - return; - } - - if (result.success && result.prompt) { - await continueSession(activeSessionId, [ - { - type: 'text', - text: result.prompt, - }, - ]); + // All remaining cases (engine output, prompt-forward, ui_effect, + // toast/denied, error) are applied by the shared dispatcher. Schedule + // actions above are kept inline because they use ChatView-local state. + const handledLocally = applySlashCommandResult(result, { + commandName, + activeSessionId: activeSessionId ?? null, + continueWithPrompt: (p) => + continueSession(activeSessionId, [{ type: 'text', text: p }]), + }); + if (handledLocally) { setPrompt(''); if (textareaRef.current) { textareaRef.current.value = ''; } return; } - - if (result.error) { - setGlobalNotice({ - id: `slash-error-${Date.now()}`, - type: 'error', - message: result.error, - }); - return; - } } // Build content blocks @@ -1429,30 +1409,25 @@ export function ChatView() { setSettingsTab('schedule'); setShowSettings(true); setPrompt(''); - } else if (result.handled && result.message) { - // Built-in tokens (__CLEAR_CHAT__, __HELP__, etc.) require - // additional wiring in subsequent Phase 2 steps. For now, - // surface them as informational notices. - useAppStore.getState().setGlobalNotice?.({ - id: `slash-info-${Date.now()}`, - type: 'info', - message: `/${item.name}: ${item.description}`, - }); - setPrompt(''); - } else if (result.success && result.prompt) { - // Replace the slash text with the resolved prompt - setPrompt(result.prompt); - setTimeout(() => { - textareaRef.current?.focus(); - const end = result.prompt?.length ?? 0; - textareaRef.current?.setSelectionRange(end, end); - }, 0); - } else if (result.error) { - useAppStore.getState().setGlobalNotice?.({ - id: `slash-error-${Date.now()}`, - type: 'error', - message: result.error, + } else { + // Engine output, ui_effect, toast/denied and prompt-forward are + // applied by the shared dispatcher. In the palette, a + // prompt-resolving command fills the textarea for editing rather + // than sending immediately, so we keep that UX in the callback. + let promptFilled = false; + applySlashCommandResult(result, { + commandName: item.name, + activeSessionId: activeSessionId ?? null, + continueWithPrompt: (p) => { + promptFilled = true; + setPrompt(p); + setTimeout(() => { + textareaRef.current?.focus(); + textareaRef.current?.setSelectionRange(p.length, p.length); + }, 0); + }, }); + if (!promptFilled) setPrompt(''); } } catch (err) { console.error('[ChatView] Slash command execute failed:', err); diff --git a/cowork/src/renderer/components/MobileSupervisionPanel.tsx b/cowork/src/renderer/components/MobileSupervisionPanel.tsx new file mode 100644 index 00000000..27b96fb2 --- /dev/null +++ b/cowork/src/renderer/components/MobileSupervisionPanel.tsx @@ -0,0 +1,200 @@ +/** + * MobileSupervisionPanel — S6. + * + * Local-operator management surface for the supervision-only mobile gateway: + * shows the pairing code + paired devices and the follow-up review queue from a + * phone, with approve/cancel. Approval is a REVIEW MARKER ONLY — it never + * dispatches work (the gateway guarantees that). Requires the embedded server to + * be running; otherwise shows an honest "start the server" state. + * + * @module renderer/components/MobileSupervisionPanel + */ + +import { useCallback, useEffect, useState } from 'react'; +import { X, Smartphone, Check, Trash2, AlertCircle, RefreshCw, KeyRound } from 'lucide-react'; +import { useAppStore } from '../store'; +import { EmptyState } from './LessonCandidatePanel'; + +type Snapshot = Awaited['mobileSupervision']['status']>>; +type Draft = NonNullable[number]; + +export function MobileSupervisionPanel() { + const show = useAppStore((s) => s.showMobileSupervisionPanel); + const setShow = useAppStore((s) => s.setShowMobileSupervisionPanel); + + const [snap, setSnap] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [reviewer, setReviewer] = useState(''); + const [busyId, setBusyId] = useState(null); + + const refresh = useCallback(async () => { + setLoading(true); + setError(null); + const res = await window.electronAPI.mobileSupervision.status(); + setLoading(false); + setSnap(res); + if (res.error) setError(res.error); + }, []); + + useEffect(() => { + if (show) void refresh(); + }, [show, refresh]); + + const approve = async (d: Draft) => { + setBusyId(d.id); + setError(null); + const res = await window.electronAPI.mobileSupervision.approve(d.id, reviewer.trim() || undefined); + setBusyId(null); + if (!res.ok) return setError(res.error ?? 'Approve failed'); + await refresh(); + }; + + const cancel = async (d: Draft) => { + setBusyId(d.id); + setError(null); + const res = await window.electronAPI.mobileSupervision.cancel(d.id); + setBusyId(null); + if (!res.ok) return setError(res.error ?? 'Cancel failed'); + await refresh(); + }; + + const rotate = async () => { + setError(null); + const res = await window.electronAPI.mobileSupervision.rotateCode(); + if (!res.ok) return setError(res.error ?? 'Rotate failed'); + await refresh(); + }; + + if (!show) return null; + + const pending = (snap?.drafts ?? []).filter((d) => d.status === 'needs_local_operator'); + const reviewed = (snap?.drafts ?? []).filter((d) => d.status !== 'needs_local_operator'); + + return ( +
+
+
+
+ +

Mobile supervision

+
+
+ + +
+
+ +
+ Supervision-only: a paired phone can read and propose. Approving here is a review + marker — it never runs work automatically. +
+ + {error && ( +
+ + {error} +
+ )} + + {!snap?.running ? ( +
+ } + title="Embedded server not running" + hint="Start the embedded server (titlebar) to pair a phone and review its proposals." + /> +
+ ) : ( + <> +
+
+
+ + Pairing code + + {snap.pairingCode ?? '——————'} + +
+ +
+ {!!snap.devices?.length && ( +
Paired: {snap.devices.join(', ')}
+ )} +
+ +
+ +
+ +
+ {pending.length === 0 && reviewed.length === 0 ? ( + } + title={loading ? 'Loading…' : 'No proposals'} + hint="Prompts submitted from a paired phone appear here for local approval." + /> + ) : ( + [...pending, ...reviewed].map((d) => ( +
+
+ {d.source} + {new Date(d.createdAt).toLocaleString()} +
+

{d.prompt}

+ {d.status !== 'needs_local_operator' ? ( +

+ {d.status} + {d.approvedBy ? ` by ${d.approvedBy}` : ''} +

+ ) : ( +
+ + +
+ )} +
+ )) + )} +
+ + )} +
+
+ ); +} diff --git a/cowork/src/renderer/components/ShellNavigation.tsx b/cowork/src/renderer/components/ShellNavigation.tsx index 01568b76..4ca4b007 100644 --- a/cowork/src/renderer/components/ShellNavigation.tsx +++ b/cowork/src/renderer/components/ShellNavigation.tsx @@ -10,6 +10,7 @@ import { Focus, GraduationCap, ListChecks, + Smartphone, MessageSquare, Network, Package, @@ -72,6 +73,8 @@ export function ShellNavigation() { const setShowLessonCandidatePanel = useAppStore((s) => s.setShowLessonCandidatePanel); const setShowUserModelPanel = useAppStore((s) => s.setShowUserModelPanel); const setShowSpecPanel = useAppStore((s) => s.setShowSpecPanel); + const showMobileSupervisionPanel = useAppStore((s) => s.showMobileSupervisionPanel); + const setShowMobileSupervisionPanel = useAppStore((s) => s.setShowMobileSupervisionPanel); const setShowCompanionPanel = useAppStore((s) => s.setShowCompanionPanel); const setShowBookmarksPanel = useAppStore((s) => s.setShowBookmarksPanel); const setShowActivityFeed = useAppStore((s) => s.setShowActivityFeed); @@ -255,6 +258,14 @@ export function ShellNavigation() { onClick: () => setShowSpecPanel(true), testId: 'spec-panel-button', }, + { + id: 'mobile-supervision', + label: t('mobileSupervision.title', 'Mobile supervision'), + icon: Smartphone, + active: showMobileSupervisionPanel, + onClick: () => setShowMobileSupervisionPanel(true), + testId: 'mobile-supervision-button', + }, { id: 'companion', label: t('shell.companion', 'Buddy companion'), diff --git a/cowork/src/renderer/components/UserModelPanel.tsx b/cowork/src/renderer/components/UserModelPanel.tsx index 451bb83b..372aa272 100644 --- a/cowork/src/renderer/components/UserModelPanel.tsx +++ b/cowork/src/renderer/components/UserModelPanel.tsx @@ -11,8 +11,10 @@ */ import { useCallback, useEffect, useState } from 'react'; -import { X, UserCog, Check, Trash2, AlertCircle, FolderOpen, RefreshCw } from 'lucide-react'; +import { X, UserCog, Check, Trash2, AlertCircle, FolderOpen, RefreshCw, Sparkles } from 'lucide-react'; import { useAppStore } from '../store'; +import { useActiveSessionMessages } from '../store/selectors'; +import { toInferenceHistory } from './user-model-inference'; import { EmptyState } from './LessonCandidatePanel'; import { NO_ACTIVE_PROJECT, @@ -47,6 +49,8 @@ export function UserModelPanel() { const [noProject, setNoProject] = useState(false); const [reviewer, setReviewer] = useState(''); const [busyId, setBusyId] = useState(null); + const [inferring, setInferring] = useState(false); + const activeMessages = useActiveSessionMessages(); const refresh = useCallback(async () => { setLoading(true); @@ -89,6 +93,26 @@ export function UserModelPanel() { await refresh(); }; + const runInference = async () => { + const history = toInferenceHistory(activeMessages); + if (history.length === 0) { + setError('No conversation history in this session to analyze.'); + return; + } + setInferring(true); + setError(null); + // Dialectic inference proposes pending observations only — accept is still + // the only write path, and the core privacy screen drops sensitive content. + const res = await window.electronAPI.userModel.runInference(history); + setInferring(false); + if (!res.ok) { + setError(res.error ?? 'Inference failed'); + return; + } + setTab('pending'); + await refresh(); + }; + const discard = async (o: UserObservation) => { setBusyId(o.id); setError(null); @@ -115,6 +139,16 @@ export function UserModelPanel() {

User model

+
); diff --git a/cowork/src/renderer/commands/slash-command-actions.ts b/cowork/src/renderer/commands/slash-command-actions.ts index 1164eea3..1df44bff 100644 --- a/cowork/src/renderer/commands/slash-command-actions.ts +++ b/cowork/src/renderer/commands/slash-command-actions.ts @@ -24,7 +24,7 @@ export interface SlashExecuteResult { handled?: boolean; action?: { type: 'open_schedule' | 'create_schedule' | 'ui_effect'; - uiEffect?: 'open_model_picker' | 'run_orchestrator' | 'open_orchestrator_launcher' | 'open_fleet' | 'set_plan_mode' | 'open_lessons' | 'open_team'; + uiEffect?: 'open_model_picker' | 'run_orchestrator' | 'open_orchestrator_launcher' | 'open_fleet' | 'set_plan_mode' | 'open_lessons' | 'open_team' | 'open_companion' | 'open_spec' | 'open_settings' | 'open_panel'; args?: string[]; }; } @@ -116,9 +116,41 @@ function applyUiEffect(result: SlashExecuteResult, ctx: SlashActionContext): voi case 'open_team': useAppStore.getState().setShowTeamPanel(true); break; + case 'open_companion': + useAppStore.getState().setShowCompanionPanel(true); + break; + case 'open_spec': + useAppStore.getState().setShowSpecPanel(true); + break; + case 'open_settings': { + const tab = action.args?.[0]; + if (tab) useAppStore.getState().setSettingsTab(tab); + useAppStore.getState().setShowSettings(true); + break; + } + case 'open_panel': { + const key = action.args?.[0]; + const setter = key ? PANEL_OPENERS[key] : undefined; + if (setter) setter(true); + break; + } } } +/** + * Generic panel openers keyed by panel id (lets the bridge route many slash + * commands through a single `open_panel` ui_effect). Each maps to a confirmed + * store show-flag setter. + */ +const PANEL_OPENERS: Record void> = { + global_search: (s) => useAppStore.getState().setShowGlobalSearch(s), + shortcuts: (s) => useAppStore.getState().setShowShortcutsDialog(s), + persona: (s) => useAppStore.getState().setShowPersonaSwitcher(s), + session_insights: (s) => useAppStore.getState().setShowSessionInsights(s), + memory: (s) => useAppStore.getState().setShowMemoryEditor(s), + identity: (s) => useAppStore.getState().setShowIdentityPanel(s), +}; + /** * Apply a non-schedule slash-command result. Schedule actions * (`open_schedule` / `create_schedule`) are handled by ChatView directly, diff --git a/cowork/src/renderer/components/IdentityPanel.tsx b/cowork/src/renderer/components/IdentityPanel.tsx new file mode 100644 index 00000000..67c42b48 --- /dev/null +++ b/cowork/src/renderer/components/IdentityPanel.tsx @@ -0,0 +1,193 @@ +/** + * IdentityPanel — C3. Browse & edit the project's agent identity files + * (SOUL.md, USER.md, AGENTS.md, …) via the `identity.*` IPC (core + * IdentityManager). Project `.codebuddy/` markdown; project overrides global. + * + * @module renderer/components/IdentityPanel + */ + +import { useCallback, useEffect, useState } from 'react'; +import { X, Fingerprint, Save, RefreshCw, AlertCircle, FilePlus } from 'lucide-react'; +import { useAppStore } from '../store'; +import { EmptyState } from './LessonCandidatePanel'; + +interface IdentityFile { + name: string; + content: string; + source: 'project' | 'global'; + path: string; + lastModified: number; +} + +const KNOWN_FILES = ['SOUL.md', 'USER.md', 'AGENTS.md', 'TOOLS.md', 'IDENTITY.md', 'INSTRUCTIONS.md']; + +export function IdentityPanel() { + const show = useAppStore((s) => s.showIdentityPanel); + const setShow = useAppStore((s) => s.setShowIdentityPanel); + + const [items, setItems] = useState([]); + const [selected, setSelected] = useState(null); + const [draft, setDraft] = useState(''); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + setLoading(true); + setError(null); + const res = await window.electronAPI.identityFiles.list(); + setLoading(false); + if (!res.ok) { + setError(res.error ?? 'Failed to load identity files'); + setItems([]); + return; + } + setItems(res.items); + if (res.items.length && !selected) { + setSelected(res.items[0].name); + setDraft(res.items[0].content); + } + }, [selected]); + + useEffect(() => { + if (show) void refresh(); + }, [show, refresh]); + + const select = (f: IdentityFile) => { + setSelected(f.name); + setDraft(f.content); + setError(null); + }; + + const createNew = (name: string) => { + setSelected(name); + setDraft(''); + setError(null); + }; + + const save = async () => { + if (!selected) return; + setSaving(true); + setError(null); + const res = await window.electronAPI.identityFiles.set(selected, draft); + setSaving(false); + if (!res.ok) { + setError(res.error ?? 'Save failed'); + return; + } + await refresh(); + }; + + if (!show) return null; + + const existingNames = new Set(items.map((i) => i.name)); + const creatable = KNOWN_FILES.filter((n) => !existingNames.has(n)); + + return ( +
+
+
+
+ +

Agent identity

+
+
+ + +
+
+ + {error && ( +
+ + {error} +
+ )} + +
+ {/* file list */} +
+ {items.length === 0 && !loading ? ( + } + title="No identity files" + hint="Create SOUL.md to define the agent's personality." + /> + ) : ( + items.map((f) => ( + + )) + )} + {creatable.length > 0 && ( +
+
Create
+ {creatable.map((n) => ( + + ))} +
+ )} +
+ + {/* editor */} +
+ {selected ? ( + <> +
+ {selected} + +
+