diff --git a/README.md b/README.md index 8a09ab4..a95a75a 100644 --- a/README.md +++ b/README.md @@ -525,7 +525,7 @@ hooks: ## Browser Automation Tools -Flynn ships six browser tools: +Flynn ships these browser tools: - `browser.navigate` - `browser.screenshot` @@ -533,9 +533,11 @@ Flynn ships six browser tools: - `browser.type` - `browser.content` - `browser.eval` +- `browser.evaluate` (alias of `browser.eval`) These tools are backed by a Puppeteer/CDP browser manager and are only registered when `browser.enabled: true`. They can still be filtered out by tool policy (`tools.profile`, `tools.allow`, `tools.deny`). +At startup, Flynn logs the browser tools that remain available after policy filtering. ```yaml browser: diff --git a/SOUL.md b/SOUL.md index a2b35b3..149aeb7 100644 --- a/SOUL.md +++ b/SOUL.md @@ -34,6 +34,8 @@ You are Flynn. A personal AI assistant running on your operator's hardware, with **When in doubt, check the policy, not the operator.** Before asking "can I do this?", re-read the Boundaries section. If the action is covered, do it. Only ask when the policy genuinely doesn't cover the situation. +**Never ask permission for covered actions.** File edits, shell commands, git commits, builds, reads — these are pre-authorized. Do not ask "shall I?", "want me to?", or "is it okay if I?" for anything in the Always Allowed list or any non-destructive action. Just do it. + ## Boundaries - **Non-destructive commands are free.** Reading files, listing directories, searching, checking status, running builds/tests, inspecting processes -- do these without hesitation. No need to ask. Only pause for destructive actions (deleting files, modifying production data, force-pushing, etc.). diff --git a/config/default.yaml b/config/default.yaml index 836c5f3..13878ad 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -222,7 +222,7 @@ models: # default_namespace: default # allowed_namespaces: [] # Empty = allow any namespace; set to restrict access. -# Optional: Browser automation tools (browser.navigate/screenshot/click/type/content/eval) +# Optional: Browser automation tools (browser.navigate/screenshot/click/type/content/eval/evaluate) # Requires a local Chrome/Chromium install or a remote CDP endpoint. # browser: # enabled: true diff --git a/docs/api/TOOLS.md b/docs/api/TOOLS.md index 2856443..6a28e38 100644 --- a/docs/api/TOOLS.md +++ b/docs/api/TOOLS.md @@ -25,7 +25,7 @@ Tools are executable capabilities that the AI agent can call to perform actions - **File System**: `file.read`, `file.write`, `file.edit`, `file.list` - **Shell/Process**: `shell.exec`, `process.start`, `process.kill` - **Web**: `web.fetch`, `web.search` -- **Browser**: `browser.navigate`, `browser.screenshot`, `browser.click`, `browser.type`, `browser.content`, `browser.eval` +- **Browser**: `browser.navigate`, `browser.screenshot`, `browser.click`, `browser.type`, `browser.content`, `browser.eval`, `browser.evaluate` (alias of `browser.eval`) - **Memory**: `memory.read`, `memory.write`, `memory.search` - **MinIO**: `minio.share`, `minio.ingest`, `minio.sync` - **Kubernetes**: `k8s.pods`, `k8s.deployments`, `k8s.logs` diff --git a/docs/plans/state.json b/docs/plans/state.json index 4458443..c2461b2 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -3,6 +3,107 @@ "updated_at": "2026-02-17", "description": "Tracks the status of all Flynn plans and implementation phases", "plans": { + "verbose-only-tool-inventory-output": { + "status": "completed", + "date": "2026-02-17", + "updated": "2026-02-17", + "summary": "Removed continuous debug inventory emissions (`[Agent] tool-inventory` and `[Routing] tool-policy`) from agent/routing so tool inventory output appears only when requested via `/verbose` toggle-on in TUI.", + "files_modified": [ + "src/backends/native/agent.ts", + "src/daemon/routing.ts", + "docs/plans/state.json" + ], + "test_status": "pnpm typecheck passing" + }, + "verbose-tool-inventory-snapshot": { + "status": "completed", + "date": "2026-02-17", + "updated": "2026-02-17", + "summary": "Extended `/verbose` in both minimal and fullscreen TUI to emit an immediate tool inventory snapshot from the active agent, including internal/exposed counts and browser tool subsets. Added `NativeAgent.getToolInventorySnapshot()` to expose context-aware filtered tool inventory for diagnostics.", + "files_modified": [ + "src/backends/native/agent.ts", + "src/frontends/tui/minimal.ts", + "src/frontends/tui/components/App.tsx", + "docs/plans/state.json" + ], + "test_status": "pnpm test:run src/frontends/tui/minimal.test.ts + pnpm typecheck passing" + }, + "tui-auto-tool-discovery-shared-init": { + "status": "completed", + "date": "2026-02-17", + "updated": "2026-02-17", + "summary": "Refactored `flynn tui` to use shared daemon tool initialization (`initTools`) instead of hand-maintained core tool registration. This keeps tool discovery for TUI agents in sync with daemon behavior (including browser/web/process/audio policy wiring) across both minimal and fullscreen modes.", + "files_modified": [ + "src/cli/tui.ts", + "docs/plans/state.json" + ], + "test_status": "pnpm typecheck passing" + }, + "tui-browser-tool-registration-parity": { + "status": "completed", + "date": "2026-02-17", + "updated": "2026-02-17", + "summary": "Fixed a registry parity gap in `flynn tui`: the TUI command path now initializes BrowserManager and registers `browser.*` tools when `browser.enabled` is true, matching daemon behavior. Added BrowserManager shutdown to TUI cleanup.", + "files_modified": [ + "src/cli/tui.ts", + "docs/plans/state.json" + ], + "test_status": "pnpm typecheck passing" + }, + "tool-policy-context-debug-logging": { + "status": "completed", + "date": "2026-02-17", + "updated": "2026-02-17", + "summary": "Added debug-level diagnostics for session tool filtering: routing now logs per-session tool-policy context and resolved browser tool allowlist, and NativeAgent now logs both internal dotted tool inventory and model-exposed underscore inventory (including browser subsets). This makes context-specific tool omissions immediately traceable.", + "files_modified": [ + "src/daemon/routing.ts", + "src/backends/native/agent.ts", + "docs/plans/state.json" + ], + "test_status": "pnpm typecheck passing" + }, + "fullscreen-slash-command-parity": { + "status": "completed", + "date": "2026-02-17", + "updated": "2026-02-17", + "summary": "Expanded fullscreen TUI slash-command support to match minimal mode for `/backend`, `/pair`, and `/elevate`, and added fullscreen `/login` handling for OAuth-based providers (GitHub/OpenAI) with clear guidance for key-entry providers. Wired fullscreen runtime config with pairing/local-provider context so these commands execute with the same session state as minimal mode.", + "files_modified": [ + "src/frontends/tui/components/App.tsx", + "src/frontends/tui/fullscreen.ts", + "src/cli/tui.ts", + "docs/plans/state.json" + ], + "test_status": "pnpm typecheck passing" + }, + "browser-tools-startup-availability-logging": { + "status": "completed", + "date": "2026-02-17", + "updated": "2026-02-17", + "summary": "Improved browser tool diagnostics by logging the final policy-allowed `browser.*` tool set at daemon startup whenever browser support is enabled, making it immediately clear why browser tools are or are not visible in a session.", + "files_modified": [ + "src/daemon/tools.ts", + "README.md", + "docs/plans/state.json" + ], + "test_status": "pnpm typecheck passing" + }, + "browser-evaluate-alias-compatibility": { + "status": "completed", + "date": "2026-02-17", + "updated": "2026-02-17", + "summary": "Added `browser.evaluate` as a compatibility alias for `browser.eval`, updated tool policy/group coverage so the alias is available under `coding`/`group:web`, extended browser registration diagnostics to include the alias, and updated browser docs/tests accordingly.", + "files_modified": [ + "src/tools/builtin/browser/tools.ts", + "src/tools/builtin/browser/tools.test.ts", + "src/tools/policy.ts", + "src/daemon/tools.ts", + "config/default.yaml", + "README.md", + "docs/api/TOOLS.md", + "docs/plans/state.json" + ], + "test_status": "pnpm test:run src/tools/builtin/browser/tools.test.ts src/tools/policy.test.ts passing" + }, "browser-tools-activation-clarity": { "status": "completed", "date": "2026-02-17", diff --git a/src/backends/native/agent.ts b/src/backends/native/agent.ts index 0490d99..04b5102 100644 --- a/src/backends/native/agent.ts +++ b/src/backends/native/agent.ts @@ -17,6 +17,17 @@ export interface ToolUseEvent { result?: ToolResult; } +export interface ToolInventorySnapshot { + sessionId: string; + agent: string; + provider: string; + skill: string; + internalCount: number; + exposedCount: number; + internalBrowser: string[]; + exposedBrowser: string[]; +} + export interface NativeAgentConfig { modelClient: ModelClient | ModelRouter; systemPrompt: string; @@ -453,6 +464,35 @@ export class NativeAgent { return this._toolPolicyContext; } + getToolInventorySnapshot(): ToolInventorySnapshot { + if (!this.toolRegistry) { + return { + sessionId: '-', + agent: '-', + provider: '-', + skill: '-', + internalCount: 0, + exposedCount: 0, + internalBrowser: [], + exposedBrowser: [], + }; + } + + const internal = this.toolRegistry.filteredList(this._toolPolicyContext).map((tool) => tool.name); + const exposed = this.toolRegistry.filteredToAnthropicFormat(this._toolPolicyContext).map((tool) => tool.name); + const context = this._toolPolicyContext; + return { + sessionId: context?.sessionId ?? '-', + agent: context?.agent ?? '-', + provider: context?.provider ?? '-', + skill: context?.skillName ?? '-', + internalCount: internal.length, + exposedCount: exposed.length, + internalBrowser: internal.filter((name) => name.startsWith('browser.')), + exposedBrowser: exposed.filter((name) => name.startsWith('browser_')), + }; + } + setAttachmentCollector(collector: OutboundAttachmentCollector | undefined): void { this._attachmentCollector = collector; } diff --git a/src/cli/tui.ts b/src/cli/tui.ts index d3e38e1..70fc8f8 100644 --- a/src/cli/tui.ts +++ b/src/cli/tui.ts @@ -103,13 +103,6 @@ export function registerTuiCommand(program: Command): void { const { MinimalTui, startFullscreenTui } = await import('../frontends/tui/index.js'); const { NativeAgent } = await import('../backends/index.js'); const { - ToolRegistry, - ToolExecutor, - ToolPolicy, - allBuiltinTools, - createWebSearchTools, - createProcessTools, - ProcessManager, createGmailTools, createGcalTools, createGdocsTools, @@ -119,6 +112,8 @@ export function registerTuiCommand(program: Command): void { createAgentDelegateTool, } = await import('../tools/index.js'); const { HookEngine } = await import('../hooks/index.js'); + const { Lifecycle } = await import('../daemon/lifecycle.js'); + const { initTools } = await import('../daemon/tools.js'); const { createModelRouter } = await import('../daemon/index.js'); const { AgentConfigRegistry } = await import('../agents/index.js'); @@ -147,33 +142,8 @@ export function registerTuiCommand(program: Command): void { const systemPrompt = loadSystemPrompt(); const hookEngine = new HookEngine(config.hooks); - const toolRegistry = new ToolRegistry(); - for (const tool of allBuiltinTools) { - toolRegistry.register(tool); - } - - // Register web search tools if configured with credentials - if (config.web_search.api_key || config.web_search.endpoint) { - for (const tool of createWebSearchTools({ - provider: config.web_search.provider, - apiKey: config.web_search.api_key, - endpoint: config.web_search.endpoint, - maxResults: config.web_search.max_results, - })) { - toolRegistry.register(tool); - } - } - - // Initialize process manager and register process tools - const processManager = new ProcessManager({ - maxConcurrent: config.process.max_concurrent, - maxRuntimeMinutes: config.process.max_runtime_minutes, - bufferSize: config.process.buffer_size, - }); - - for (const tool of createProcessTools(processManager)) { - toolRegistry.register(tool); - } + const lifecycle = new Lifecycle(); + const { toolRegistry, toolExecutor } = initTools({ config, lifecycle, hookEngine }); // Register Gmail tools if configured if (config.automation.gmail?.enabled) { @@ -233,10 +203,6 @@ export function registerTuiCommand(program: Command): void { })); } - toolRegistry.setPolicy(new ToolPolicy(config.tools)); - - const toolExecutor = new ToolExecutor(toolRegistry, hookEngine); - const session = sessionManager.getSession('tui', 'local'); const modelProviderConfigs = buildProviderConfigMap(config); @@ -262,7 +228,7 @@ export function registerTuiCommand(program: Command): void { }); const cleanup = () => { - processManager.shutdown(); + void lifecycle.shutdown(); sessionStore.close(); }; @@ -292,6 +258,9 @@ export function registerTuiCommand(program: Command): void { model: config.models.default.model, agent, hookEngine, + pairingManager, + localProviders: config.models.local_providers, + currentLocalProvider: config.models.local?.provider, modelProviderConfigs, contextThresholdPct: config.compaction.threshold_pct, onTransfer: transferSessionToTarget, @@ -331,6 +300,9 @@ export function registerTuiCommand(program: Command): void { model: config.models.default.model, agent, hookEngine, + pairingManager, + localProviders: config.models.local_providers, + currentLocalProvider: config.models.local?.provider, modelProviderConfigs, contextThresholdPct: config.compaction.threshold_pct, onTransfer: transferSessionToTarget, diff --git a/src/daemon/routing.ts b/src/daemon/routing.ts index cca97f0..f594494 100644 --- a/src/daemon/routing.ts +++ b/src/daemon/routing.ts @@ -253,6 +253,20 @@ export function createMessageRouter(deps: { } as AgentDelegateDeps)); } + const toolPolicyContext = { + agent: effectiveTier, + provider: effectiveProvider, + sessionId: session.id, + channel, + sender: senderId, + tier: effectiveTier, + autonomyLevel: deps.config.agents.autonomy_level ?? 'standard', + skillName: activeSkillName, + skillPermissions: activeSkill?.manifest.permissions, + allowedSecretScopes: activeSkill?.manifest.permissions?.secrets, + executionEnvironment, + }; + const orchestrator = new AgentOrchestrator({ modelRouter: deps.modelRouter, systemPrompt: effectiveSystemPrompt, @@ -283,19 +297,7 @@ export function createMessageRouter(deps: { memoryAutoExtract: deps.config.memory?.auto_extract, memoryInjectionStrategy: deps.config.memory?.injection_strategy, memoryMaxInjectionTokens: deps.config.memory?.max_injection_tokens, - toolPolicyContext: { - agent: effectiveTier, - provider: effectiveProvider, - sessionId: session.id, - channel, - sender: senderId, - tier: effectiveTier, - autonomyLevel: deps.config.agents.autonomy_level ?? 'standard', - skillName: activeSkillName, - skillPermissions: activeSkill?.manifest.permissions, - allowedSecretScopes: activeSkill?.manifest.permissions?.secrets, - executionEnvironment, - }, + toolPolicyContext, attachmentCollector: collector, }); // Resolve the lazy orchestrator reference for agent.delegate diff --git a/src/daemon/tools.ts b/src/daemon/tools.ts index 856e52c..224c43a 100644 --- a/src/daemon/tools.ts +++ b/src/daemon/tools.ts @@ -66,7 +66,7 @@ export function initTools(deps: ToolsDeps): ToolsResult { } // Initialize browser manager and register browser tools (if enabled) - const browserToolNames = ['browser.navigate', 'browser.screenshot', 'browser.click', 'browser.type', 'browser.content', 'browser.eval']; + const browserToolNames = ['browser.navigate', 'browser.screenshot', 'browser.click', 'browser.type', 'browser.content', 'browser.eval', 'browser.evaluate']; let browserManager: BrowserManager | undefined; if (config.browser?.enabled) { const manager = new BrowserManager({ @@ -108,6 +108,8 @@ export function initTools(deps: ToolsDeps): ToolsResult { const availableBrowserTools = browserToolNames.filter((name) => allowed.has(name)); if (availableBrowserTools.length === 0) { console.log('Browser tools are registered but blocked by tool policy (use tools.profile=coding/full or tools.allow).'); + } else { + console.log(`Browser tools available after policy: ${availableBrowserTools.join(', ')}`); } } diff --git a/src/frontends/tui/components/App.tsx b/src/frontends/tui/components/App.tsx index 61875bb..7ee68f9 100644 --- a/src/frontends/tui/components/App.tsx +++ b/src/frontends/tui/components/App.tsx @@ -13,6 +13,9 @@ import type { ModelConfig, ModelProvider } from '../../../config/schema.js'; import { MODEL_PROVIDERS } from '../../../config/schema.js'; import { createClientFromConfig } from '../../../daemon/index.js'; import { estimateMessageTokens, getContextWindow } from '../../../context/tokens.js'; +import type { PairingManager } from '../../../channels/pairing.js'; +import { loginGitHub, loginOpenAI } from '../../../auth/index.js'; +import { OllamaClient, LlamaCppClient } from '../../../models/index.js'; /** Format a tool name like "gmail.list" -> "Gmail: List" */ function formatToolName(name: string): string { @@ -49,6 +52,9 @@ export interface AppProps { model: string; agent?: NativeAgent; hookEngine?: HookEngine; + pairingManager?: PairingManager; + localProviders?: Record; + currentLocalProvider?: string; modelProviderConfigs?: Partial>; contextThresholdPct?: number; onTransfer?: (target: string) => string | void; @@ -63,6 +69,9 @@ export function App({ model, agent, hookEngine, + pairingManager, + localProviders, + currentLocalProvider, modelProviderConfigs, contextThresholdPct, onTransfer, @@ -196,6 +205,55 @@ export function App({ } }); + const pushAssistantMessage = useCallback((content: string) => { + setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content })]); + }, [session]); + + const getAvailableBackends = useCallback((): string[] => { + const backends: string[] = []; + if (currentLocalProvider) { + backends.push(currentLocalProvider); + } + if (localProviders) { + backends.push(...Object.keys(localProviders)); + } + return [...new Set(backends)]; + }, [currentLocalProvider, localProviders]); + + const createLocalClient = useCallback((cfg: ModelConfig): ModelClient | null => { + if (cfg.provider === 'ollama') { + return new OllamaClient({ + model: cfg.model, + host: cfg.endpoint, + }); + } + if (cfg.provider === 'llamacpp') { + return new LlamaCppClient({ + endpoint: cfg.endpoint ?? 'http://localhost:8080', + model: cfg.model, + authToken: cfg.auth_token, + }); + } + return null; + }, []); + + const parseDurationToMs = useCallback((value: string): number | null => { + const m = value.match(/^(\d+)([smhd])$/i); + if (!m) { + return null; + } + const n = Number.parseInt(m[1], 10); + if (!Number.isFinite(n) || n <= 0) { + return null; + } + const unit = m[2].toLowerCase(); + if (unit === 's') {return n * 1000;} + if (unit === 'm') {return n * 60_000;} + if (unit === 'h') {return n * 3_600_000;} + if (unit === 'd') {return n * 86_400_000;} + return null; + }, []); + const handleSubmit = useCallback(async (value: string) => { if (confirmation) { return; @@ -273,7 +331,12 @@ export function App({ case 'verbose': { const next = !verbose; setVerbose(next); - setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: `Verbose mode: ${next ? 'on' : 'off'}` })]); + let content = `Verbose mode: ${next ? 'on' : 'off'}`; + if (next && agent) { + const snapshot = agent.getToolInventorySnapshot(); + content += `\n[Agent] tool-inventory session=${snapshot.sessionId} agent=${snapshot.agent} provider=${snapshot.provider} skill=${snapshot.skill} internal=${snapshot.internalCount} exposed=${snapshot.exposedCount} internal_browser=[${snapshot.internalBrowser.join(', ') || 'none'}] exposed_browser=[${snapshot.exposedBrowser.join(', ') || 'none'}]`; + } + setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content })]); return; } @@ -480,12 +543,213 @@ export function App({ return; } - case 'backend': - case 'login': - case 'pair': - case 'elevate': - setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: `/${command.type} is not supported in fullscreen mode.` })]); + case 'backend': { + if (!modelRouter) { + pushAssistantMessage('Backend switching not available.'); + return; + } + + if (!command.provider) { + const current = modelRouter.getLocalProviderName() ?? currentLocalProvider ?? 'unknown'; + const available = getAvailableBackends(); + pushAssistantMessage(`Current local backend: ${current}\nAvailable: ${available.join(', ')}`); + return; + } + + const providerConfig = localProviders?.[command.provider]; + if (!providerConfig) { + const available = getAvailableBackends(); + pushAssistantMessage(`Backend '${command.provider}' not configured.\nAvailable: ${available.join(', ')}`); + return; + } + + const client = createLocalClient(providerConfig); + if (!client) { + pushAssistantMessage(`Unsupported backend provider '${providerConfig.provider}'.`); + return; + } + + modelRouter.setLocalClient(client, command.provider); + modelRouter.setTier('local'); + if (agent) { + agent.setModelTier('local'); + } + setCurrentModel(modelRouter.getLabel('local')); + pushAssistantMessage(`Switched backend to ${command.provider}`); return; + } + + case 'login': { + const provider = (command.provider ?? '').trim().toLowerCase(); + if (!provider) { + pushAssistantMessage('Usage: /login \nSupported: github, openai, anthropic, zai'); + return; + } + + if (provider === 'github') { + pushAssistantMessage('Starting GitHub OAuth device login...'); + try { + await loginGitHub((userCode, verificationUri) => { + pushAssistantMessage(`GitHub login required:\nCode: ${userCode}\nURL: ${verificationUri}`); + }); + pushAssistantMessage('GitHub login complete. Token stored in ~/.config/flynn/auth.json'); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + pushAssistantMessage(`GitHub login failed: ${msg}`); + } + return; + } + + if (provider === 'openai') { + pushAssistantMessage('Starting OpenAI OAuth device login...'); + try { + await loginOpenAI((userCode, verificationUri) => { + pushAssistantMessage(`OpenAI login required:\nCode: ${userCode}\nURL: ${verificationUri}`); + }); + pushAssistantMessage('OpenAI login complete. Credentials stored in ~/.config/flynn/auth.json'); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + pushAssistantMessage(`OpenAI login failed: ${msg}`); + } + return; + } + + if (provider === 'anthropic' || provider === 'zai' || provider === 'zhipuai') { + pushAssistantMessage( + `/${command.type} ${provider} requires key entry, which fullscreen mode does not mask.\nUse minimal mode (pnpm tui) for interactive key setup.`, + ); + return; + } + + pushAssistantMessage(`Unknown login provider: ${provider}. Supported: github, openai, anthropic, zai`); + return; + } + + case 'pair': { + if (!pairingManager) { + pushAssistantMessage('Pairing not enabled. Set pairing.enabled: true in config.'); + return; + } + + if (command.action === 'generate') { + const code = pairingManager.generateCode(command.args); + const pending = pairingManager.listPendingCodes().find(p => p.code === code); + const expiresIn = pending ? Math.round((pending.expiresAt - Date.now()) / 1000) : '?'; + pushAssistantMessage(`Pairing code: ${code}\nExpires in ${expiresIn}s${command.args ? ` (label: ${command.args})` : ''}`); + return; + } + + if (command.action === 'revoke') { + const args = (command.args ?? '').trim(); + const parts = args.split(/\s+/); + if (parts.length < 2) { + pushAssistantMessage('Usage: /pair revoke '); + return; + } + const [channel, senderId] = parts; + const revoked = pairingManager.revokeApproval(channel, senderId); + pushAssistantMessage(revoked ? `Revoked approval for ${channel}:${senderId}` : `No approval found for ${channel}:${senderId}`); + return; + } + + const pending = pairingManager.listPendingCodes(); + const approved = pairingManager.listApproved(); + if (pending.length === 0 && approved.length === 0) { + pushAssistantMessage('No pending codes or approved senders.'); + return; + } + + const lines: string[] = []; + if (pending.length > 0) { + lines.push('Pending codes:'); + for (const p of pending) { + const ttl = Math.max(0, Math.round((p.expiresAt - Date.now()) / 1000)); + lines.push(` ${p.code} expires in ${ttl}s${p.label ? ` (label: ${p.label})` : ''}`); + } + } + if (approved.length > 0) { + lines.push('Approved senders:'); + for (const a of approved) { + const date = new Date(a.approvedAt).toISOString().slice(0, 16).replace('T', ' '); + lines.push(` ${a.channel}:${a.senderId} since ${date} (code: ${a.codeUsed})`); + } + } + pushAssistantMessage(lines.join('\n')); + return; + } + + case 'elevate': { + const untilRaw = session.getConfig('elevation.until_ms'); + const reason = session.getConfig('elevation.reason') ?? ''; + const id = session.getConfig('elevation.id') ?? ''; + const showStatus = () => { + if (!untilRaw || !id) { + pushAssistantMessage('Elevated mode: off'); + return; + } + const untilMs = Number.parseInt(untilRaw, 10); + if (!Number.isFinite(untilMs) || untilMs <= Date.now()) { + session.deleteConfig('elevation.until_ms'); + session.deleteConfig('elevation.reason'); + session.deleteConfig('elevation.id'); + pushAssistantMessage('Elevated mode: off'); + return; + } + const remainingSec = Math.ceil((untilMs - Date.now()) / 1000); + pushAssistantMessage(`Elevated mode: on (${remainingSec}s remaining)${reason ? ` - ${reason}` : ''}`); + }; + + const raw = (command.args ?? '').trim(); + if (!raw) { + showStatus(); + return; + } + + const parts = raw.split(/\s+/); + const hasYes = parts.includes('--yes') || parts.includes('--confirm'); + const filtered = parts.filter((p) => p !== '--yes' && p !== '--confirm'); + + if (filtered.length === 0) { + pushAssistantMessage('Usage: /elevate --yes | /elevate off --yes'); + return; + } + + if (filtered[0] === 'off') { + if (!hasYes) { + pushAssistantMessage('Refusing to disable elevation without explicit confirmation. Use: /elevate off --yes'); + return; + } + session.deleteConfig('elevation.until_ms'); + session.deleteConfig('elevation.reason'); + session.deleteConfig('elevation.id'); + pushAssistantMessage('Elevated mode: off'); + return; + } + + if (!hasYes) { + pushAssistantMessage('Refusing to enable elevation without explicit confirmation. Use: /elevate --yes'); + return; + } + + const ttlMs = parseDurationToMs(filtered[0]); + if (!ttlMs) { + pushAssistantMessage('Invalid duration. Use one of: 30s, 10m, 1h, 1d'); + return; + } + + const reasonText = filtered.slice(1).join(' ').trim(); + const untilMs = Date.now() + ttlMs; + const newId = `${untilMs}`; + session.setConfig('elevation.until_ms', String(untilMs)); + session.setConfig('elevation.id', newId); + if (reasonText) { + session.setConfig('elevation.reason', reasonText); + } else { + session.deleteConfig('elevation.reason'); + } + pushAssistantMessage(`Elevated mode: on until ${new Date(untilMs).toISOString()}`); + return; + } case 'message': break; @@ -585,6 +849,13 @@ export function App({ messages.length, tokenUsage.inputTokens, tokenUsage.outputTokens, + pushAssistantMessage, + getAvailableBackends, + createLocalClient, + parseDurationToMs, + localProviders, + currentLocalProvider, + pairingManager, modelProviderConfigs, onTransfer, ]); diff --git a/src/frontends/tui/fullscreen.ts b/src/frontends/tui/fullscreen.ts index fdc1d82..bb93a71 100644 --- a/src/frontends/tui/fullscreen.ts +++ b/src/frontends/tui/fullscreen.ts @@ -7,6 +7,7 @@ import type { ModelRouter } from '../../models/router.js'; import type { NativeAgent } from '../../backends/native/agent.js'; import type { HookEngine } from '../../hooks/index.js'; import type { ModelConfig, ModelProvider } from '../../config/index.js'; +import type { PairingManager } from '../../channels/pairing.js'; export interface FullscreenTuiConfig { session: ManagedSession; @@ -16,6 +17,9 @@ export interface FullscreenTuiConfig { model: string; agent?: NativeAgent; hookEngine?: HookEngine; + pairingManager?: PairingManager; + localProviders?: Record; + currentLocalProvider?: string; modelProviderConfigs?: Partial>; contextThresholdPct?: number; onTransfer?: (target: string) => string | void; @@ -41,6 +45,9 @@ export async function startFullscreenTui(config: FullscreenTuiConfig): Promise { expect(names).toContain('browser.type'); expect(names).toContain('browser.content'); expect(names).toContain('browser.eval'); - expect(names).toHaveLength(6); + expect(names).toContain('browser.evaluate'); + expect(names).toHaveLength(7); }); it('browser.navigate navigates to URL', async () => { @@ -147,6 +148,13 @@ describe('Browser tools', () => { expect(result.output).toBe('hello world'); }); + it('browser.evaluate aliases browser.eval behavior', async () => { + const tool = getTool('browser.evaluate'); + const result = await tool.execute({ expression: '1 + 1' }); + expect(result.success).toBe(true); + expect(result.output).toContain('42'); + }); + it('handles navigation errors gracefully', async () => { mockGoto.mockRejectedValueOnce(new Error('Navigation failed')); const tool = getTool('browser.navigate'); diff --git a/src/tools/builtin/browser/tools.ts b/src/tools/builtin/browser/tools.ts index a35c4d6..abc4a8b 100644 --- a/src/tools/builtin/browser/tools.ts +++ b/src/tools/builtin/browser/tools.ts @@ -64,6 +64,7 @@ export function createBrowserTools(manager: BrowserManager): Tool[] { createBrowserTypeTool(manager), createBrowserContentTool(manager), createBrowserEvalTool(manager), + createBrowserEvaluateTool(manager), ]; } @@ -294,9 +295,25 @@ function createBrowserContentTool(manager: BrowserManager): Tool { } function createBrowserEvalTool(manager: BrowserManager): Tool { + return createBrowserEvalLikeTool( + manager, + 'browser.eval', + 'Evaluate JavaScript in the browser page context. Returns the result as a string.', + ); +} + +function createBrowserEvaluateTool(manager: BrowserManager): Tool { + return createBrowserEvalLikeTool( + manager, + 'browser.evaluate', + 'Alias of browser.eval for compatibility. Evaluates JavaScript in the browser page context.', + ); +} + +function createBrowserEvalLikeTool(manager: BrowserManager, name: 'browser.eval' | 'browser.evaluate', description: string): Tool { return { - name: 'browser.eval', - description: 'Evaluate JavaScript in the browser page context. Returns the result as a string.', + name, + description, inputSchema: { type: 'object', properties: { diff --git a/src/tools/policy.ts b/src/tools/policy.ts index ed7099d..8da4960 100644 --- a/src/tools/policy.ts +++ b/src/tools/policy.ts @@ -98,6 +98,7 @@ const PROFILE_TOOLS: Record> = { 'browser.type', 'browser.content', 'browser.eval', + 'browser.evaluate', 'agent.delegate', 'agents.list', ]), @@ -110,7 +111,7 @@ const PROFILE_TOOLS: Record> = { export const TOOL_GROUPS: Record = { 'group:fs': ['file.read', 'file.write', 'file.edit', 'file.patch', 'file.list'], 'group:runtime': ['shell.exec', 'process.start', 'process.output', 'process.status', 'process.kill', 'process.list', 'screen.capture', 'camera.capture'], - 'group:web': ['web.fetch', 'web.search', 'browser.navigate', 'browser.screenshot', 'browser.click', 'browser.type', 'browser.content', 'browser.eval'], + 'group:web': ['web.fetch', 'web.search', 'browser.navigate', 'browser.screenshot', 'browser.click', 'browser.type', 'browser.content', 'browser.eval', 'browser.evaluate'], 'group:memory': ['memory.read', 'memory.write', 'memory.search'], 'group:gmail': ['gmail.list', 'gmail.search', 'gmail.read'], 'group:gcal': ['calendar.today', 'calendar.list', 'calendar.search'],