diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/scheduled-tasks/NewScheduledTaskDialog.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/scheduled-tasks/NewScheduledTaskDialog.tsx index c2709efdb..5b9e5c046 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/scheduled-tasks/NewScheduledTaskDialog.tsx +++ b/packages/browseros-agent/apps/agent/entrypoints/app/scheduled-tasks/NewScheduledTaskDialog.tsx @@ -57,6 +57,7 @@ const formSchema = z scheduleTime: z.string().optional(), scheduleInterval: z.number().int().min(1).max(60).optional(), providerId: z.string().optional(), + runSilently: z.boolean(), enabled: z.boolean(), }) .superRefine((data, ctx) => { @@ -107,6 +108,7 @@ export const NewScheduledTaskDialog: FC = ({ scheduleTime: '09:00', scheduleInterval: 1, providerId: undefined, + runSilently: true, enabled: true, }, }) @@ -144,6 +146,7 @@ export const NewScheduledTaskDialog: FC = ({ scheduleTime: initialValues.scheduleTime || '09:00', scheduleInterval: initialValues.scheduleInterval || 1, providerId: initialValues.providerId, + runSilently: initialValues.runSilently ?? true, enabled: initialValues.enabled, }) } else { @@ -154,6 +157,7 @@ export const NewScheduledTaskDialog: FC = ({ scheduleTime: '09:00', scheduleInterval: 1, providerId: undefined, + runSilently: true, enabled: true, }) } @@ -253,6 +257,7 @@ export const NewScheduledTaskDialog: FC = ({ scheduleInterval: values.scheduleType !== 'daily' ? values.scheduleInterval : undefined, providerId: values.providerId, + runSilently: values.runSilently, enabled: values.enabled, }) form.reset() @@ -458,6 +463,28 @@ export const NewScheduledTaskDialog: FC = ({ )} + ( + + + + +
+ Run silently + + Use a hidden background page without opening or focusing a + browser window. + +
+
+ )} + /> + = ({ )} + + + {job.runSilently === false ? 'Visible run' : 'Silent run'} + {job.lastRunAt && ( <> diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/scheduled-tasks/ScheduledTasksPage.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/scheduled-tasks/ScheduledTasksPage.tsx index 1ecfa97e9..c4bf0f24f 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/scheduled-tasks/ScheduledTasksPage.tsx +++ b/packages/browseros-agent/apps/agent/entrypoints/app/scheduled-tasks/ScheduledTasksPage.tsx @@ -71,6 +71,7 @@ export const ScheduledTasksPage: FC = () => { 'daily', scheduleTime: searchParams.get('scheduleTime') ?? '09:00', scheduleInterval: 1, + runSilently: true, enabled: true, createdAt: '', updatedAt: '', diff --git a/packages/browseros-agent/apps/agent/entrypoints/background/scheduledJobRuns.ts b/packages/browseros-agent/apps/agent/entrypoints/background/scheduledJobRuns.ts index 7fc8cf45f..3840598b4 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/background/scheduledJobRuns.ts +++ b/packages/browseros-agent/apps/agent/entrypoints/background/scheduledJobRuns.ts @@ -118,6 +118,7 @@ export const scheduledJobRuns = async () => { message: job.query, signal: abortController.signal, providerId: job.providerId, + runSilently: job.runSilently ?? true, }) await updateJobRun(jobRun.id, { diff --git a/packages/browseros-agent/apps/agent/entrypoints/newtab/index/tips.ts b/packages/browseros-agent/apps/agent/entrypoints/newtab/index/tips.ts index f307529b7..d5a7520ee 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/newtab/index/tips.ts +++ b/packages/browseros-agent/apps/agent/entrypoints/newtab/index/tips.ts @@ -37,7 +37,7 @@ export const TIPS: Tip[] = [ }, { id: 'background-tasks', - text: 'Scheduled tasks run in a separate window so they never interrupt your browsing.', + text: 'Scheduled tasks can run silently in the background without opening a new browser window.', }, { id: 'claude-code-mcp', diff --git a/packages/browseros-agent/apps/agent/lib/messaging/server/buildChatRequestBody.test.ts b/packages/browseros-agent/apps/agent/lib/messaging/server/buildChatRequestBody.test.ts index a44c84149..363174602 100644 --- a/packages/browseros-agent/apps/agent/lib/messaging/server/buildChatRequestBody.test.ts +++ b/packages/browseros-agent/apps/agent/lib/messaging/server/buildChatRequestBody.test.ts @@ -81,4 +81,16 @@ describe('buildChatRequestBody', () => { expect(body.toolApprovalConfig).toBeUndefined() }) + + it('passes scheduled task silent mode through to the server', () => { + const body = buildChatRequestBody({ + conversationId: '6ff46e3b-e45a-40a4-9157-ca520e800f43', + provider, + isScheduledTask: true, + runSilently: false, + }) + + expect(body.isScheduledTask).toBe(true) + expect(body.runSilently).toBe(false) + }) }) diff --git a/packages/browseros-agent/apps/agent/lib/messaging/server/buildChatRequestBody.ts b/packages/browseros-agent/apps/agent/lib/messaging/server/buildChatRequestBody.ts index f247d9b2c..854ac8f1b 100644 --- a/packages/browseros-agent/apps/agent/lib/messaging/server/buildChatRequestBody.ts +++ b/packages/browseros-agent/apps/agent/lib/messaging/server/buildChatRequestBody.ts @@ -53,6 +53,7 @@ interface ChatRequestBodyParams { toolApprovalConfig?: ToolApprovalConfig toolApprovalResponses?: ApprovalResponseData[] isScheduledTask?: boolean + runSilently?: boolean } export const toRequestToolApprovalConfig = ( @@ -81,6 +82,7 @@ export const buildChatRequestBody = ({ toolApprovalConfig, toolApprovalResponses, isScheduledTask, + runSilently, }: ChatRequestBodyParams) => ({ message, provider: provider.type, @@ -112,4 +114,5 @@ export const buildChatRequestBody = ({ toolApprovalConfig: toRequestToolApprovalConfig(toolApprovalConfig), toolApprovalResponses, isScheduledTask, + runSilently, }) diff --git a/packages/browseros-agent/apps/agent/lib/schedules/getChatServerResponse.ts b/packages/browseros-agent/apps/agent/lib/schedules/getChatServerResponse.ts index abdbed7ce..ea1921876 100644 --- a/packages/browseros-agent/apps/agent/lib/schedules/getChatServerResponse.ts +++ b/packages/browseros-agent/apps/agent/lib/schedules/getChatServerResponse.ts @@ -27,6 +27,7 @@ interface ChatServerRequest { activeTab?: ActiveTab signal?: AbortSignal providerId?: string + runSilently?: boolean } interface ChatServerResponse { @@ -137,6 +138,7 @@ export async function getChatServerResponse( userSystemPrompt: `${personalization}\n${scheduleSystemPrompt}`, supportsImages: provider.supportsImages, isScheduledTask: true, + runSilently: request.runSilently ?? true, }), }), }) diff --git a/packages/browseros-agent/apps/agent/lib/schedules/scheduleTypes.ts b/packages/browseros-agent/apps/agent/lib/schedules/scheduleTypes.ts index 54b139d47..6268ca8fe 100644 --- a/packages/browseros-agent/apps/agent/lib/schedules/scheduleTypes.ts +++ b/packages/browseros-agent/apps/agent/lib/schedules/scheduleTypes.ts @@ -6,6 +6,7 @@ export interface ScheduledJob { scheduleTime?: string scheduleInterval?: number enabled: boolean + runSilently?: boolean providerId?: string createdAt: string updatedAt: string diff --git a/packages/browseros-agent/apps/agent/lib/schedules/syncSchedulesToBackend.ts b/packages/browseros-agent/apps/agent/lib/schedules/syncSchedulesToBackend.ts index 1deffd89b..84d33e9ca 100644 --- a/packages/browseros-agent/apps/agent/lib/schedules/syncSchedulesToBackend.ts +++ b/packages/browseros-agent/apps/agent/lib/schedules/syncSchedulesToBackend.ts @@ -26,7 +26,7 @@ type RemoteScheduledJob = { lastRunAt: string | null } -const IGNORED_FIELDS = ['id', 'createdAt', 'lastRunAt'] as const +const IGNORED_FIELDS = ['id', 'createdAt', 'lastRunAt', 'runSilently'] as const function toComparable(job: ScheduledJob) { const data = omit(job, IGNORED_FIELDS) @@ -63,6 +63,7 @@ function remoteToLocal(remote: RemoteScheduledJob): ScheduledJob { scheduleTime: remote.scheduleTime ?? undefined, scheduleInterval: remote.scheduleInterval ?? undefined, enabled: remote.enabled, + runSilently: true, providerId: remote.llmProviderId ?? undefined, createdAt: normalizeTimestamp(remote.createdAt), updatedAt: normalizeTimestamp(remote.updatedAt), diff --git a/packages/browseros-agent/apps/server/src/agent/ai-sdk-agent.ts b/packages/browseros-agent/apps/server/src/agent/ai-sdk-agent.ts index 737c7bf6e..198487a42 100644 --- a/packages/browseros-agent/apps/server/src/agent/ai-sdk-agent.ts +++ b/packages/browseros-agent/apps/server/src/agent/ai-sdk-agent.ts @@ -224,6 +224,7 @@ export class AiSdkAgent { userSystemPrompt: config.resolvedConfig.userSystemPrompt, exclude: excludeSections, isScheduledTask: config.resolvedConfig.isScheduledTask, + scheduledTaskRunSilently: config.resolvedConfig.scheduledTaskRunSilently, scheduledTaskPageId: config.browserContext?.activeTab?.pageId, workspaceDir: config.resolvedConfig.workingDir, soulContent, diff --git a/packages/browseros-agent/apps/server/src/agent/prompt.ts b/packages/browseros-agent/apps/server/src/agent/prompt.ts index 52be6842c..9e34931aa 100644 --- a/packages/browseros-agent/apps/server/src/agent/prompt.ts +++ b/packages/browseros-agent/apps/server/src/agent/prompt.ts @@ -48,8 +48,11 @@ You do not have a filesystem workspace in this session. Return all results direc // Mode-aware framing if (options?.isScheduledTask) { - role += - '\n\nYou are running as a scheduled background task on a system-managed hidden page. Complete the task autonomously and report results.' + const location = + options.scheduledTaskRunSilently !== false + ? ' on a system-managed hidden page' + : '' + role += `\n\nYou are running as a scheduled background task${location}. Complete the task autonomously and report results.` } else if (options?.chatMode) { role += '\n\nYou are in read-only chat mode. You can observe pages but cannot interact with them, modify files, or store memories.' @@ -659,9 +662,13 @@ function getUserContext( if (!options?.chatMode) { let pageCtx = '' + const isSilentScheduledTask = + options?.isScheduledTask && options.scheduledTaskRunSilently !== false + if (options?.isScheduledTask) { - pageCtx += - '\nYou are running as a **scheduled background task** on a system-managed hidden page.' + pageCtx += isSilentScheduledTask + ? '\nYou are running as a **scheduled background task** on a system-managed hidden page.' + : '\nYou are running as a **scheduled background task**.' } pageCtx += @@ -671,14 +678,21 @@ function getUserContext( const pageRef = options.scheduledTaskPageId ? `\`${options.scheduledTaskPageId}\`` : 'the page ID from the Browser Context' - pageCtx += `\n2. **Use starting page ID ${pageRef} directly.** For additional browsing, prefer \`new_hidden_page\` so the work stays invisible to the user.` - pageCtx += - '\n3. **Do NOT close your starting hidden page** (via `close_page` on that page ID). It is managed by the system and will be cleaned up automatically.' - pageCtx += - '\n4. **Do NOT create new windows** (via `create_window` or `create_hidden_window`). Use hidden pages instead.' - pageCtx += - '\n5. **Close extra hidden pages when you are done with them** unless you explicitly reveal them with `show_page`.' - pageCtx += '\n6. Complete the task end-to-end and report results.' + if (isSilentScheduledTask) { + pageCtx += `\n2. **Use starting page ID ${pageRef} directly.** For additional browsing, prefer \`new_hidden_page\` so the work stays invisible to the user.` + pageCtx += + '\n3. **Do NOT close your starting hidden page** (via `close_page` on that page ID). It is managed by the system and will be cleaned up automatically.' + pageCtx += + '\n4. **Do NOT create new windows** (via `create_window` or `create_hidden_window`). Use hidden pages instead.' + pageCtx += + '\n5. **Close extra hidden pages when you are done with them** unless you explicitly reveal them with `show_page`.' + pageCtx += '\n6. Complete the task end-to-end and report results.' + } else { + pageCtx += `\n2. **Use starting page ID ${pageRef} directly.** For additional browsing, prefer \`new_page\` with background mode so the task does not steal focus.` + pageCtx += + '\n3. **Do NOT create new windows** unless the scheduled task explicitly requires a separate window.' + pageCtx += '\n4. Complete the task end-to-end and report results.' + } } pageCtx += '\n' @@ -739,6 +753,7 @@ export interface BuildSystemPromptOptions { userSystemPrompt?: string exclude?: string[] isScheduledTask?: boolean + scheduledTaskRunSilently?: boolean scheduledTaskPageId?: number workspaceDir?: string soulContent?: string diff --git a/packages/browseros-agent/apps/server/src/agent/types.ts b/packages/browseros-agent/apps/server/src/agent/types.ts index 764704274..11316a4c6 100644 --- a/packages/browseros-agent/apps/server/src/agent/types.ts +++ b/packages/browseros-agent/apps/server/src/agent/types.ts @@ -45,6 +45,8 @@ export interface ResolvedAgentConfig { chatMode?: boolean /** Scheduled task mode - disables tab grouping. Defaults to false. */ isScheduledTask?: boolean + /** Scheduled task silent mode - runs on hidden pages without visible windows. Defaults to true. */ + scheduledTaskRunSilently?: boolean /** Apps the user previously declined to connect via MCP (chose "do it manually"). */ declinedApps?: string[] /** Where the chat session originates from — determines navigation behavior. */ diff --git a/packages/browseros-agent/apps/server/src/api/services/chat-service.ts b/packages/browseros-agent/apps/server/src/api/services/chat-service.ts index 7cba8ea91..915273314 100644 --- a/packages/browseros-agent/apps/server/src/api/services/chat-service.ts +++ b/packages/browseros-agent/apps/server/src/api/services/chat-service.ts @@ -62,6 +62,7 @@ export class ChatService { supportsImages: request.supportsImages, chatMode: request.mode === 'chat', isScheduledTask: request.isScheduledTask, + scheduledTaskRunSilently: request.runSilently, origin: request.origin, declinedApps: request.declinedApps, browserosId: this.deps.browserosId, @@ -186,7 +187,7 @@ export class ChatService { this.deps.browser, request.browserContext, ) - if (request.isScheduledTask) { + if (request.isScheduledTask && request.runSilently !== false) { try { hiddenPageId = await this.deps.browser.newPage('about:blank', { hidden: true, diff --git a/packages/browseros-agent/apps/server/src/api/types.ts b/packages/browseros-agent/apps/server/src/api/types.ts index f3dd53c4b..5dd270570 100644 --- a/packages/browseros-agent/apps/server/src/api/types.ts +++ b/packages/browseros-agent/apps/server/src/api/types.ts @@ -41,6 +41,7 @@ export const ChatRequestSchema = AgentLLMConfigSchema.extend({ browserContext: BrowserContextSchema.optional(), userSystemPrompt: z.string().optional(), isScheduledTask: z.boolean().optional().default(false), + runSilently: z.boolean().optional().default(true), userWorkingDir: z.string().min(1).optional(), supportsImages: z.boolean().optional().default(true), mode: z.enum(['chat', 'agent']).optional().default('agent'), diff --git a/packages/browseros-agent/apps/server/src/browser/browser.ts b/packages/browseros-agent/apps/server/src/browser/browser.ts index e4a45cd83..d98271347 100644 --- a/packages/browseros-agent/apps/server/src/browser/browser.ts +++ b/packages/browseros-agent/apps/server/src/browser/browser.ts @@ -517,45 +517,15 @@ export class Browser { return null } - private async resolveWindowIdForNewPage(opts?: { - hidden?: boolean - windowId?: number - }): Promise { - if (!opts?.hidden) { - return opts?.windowId - } - - if (opts.windowId !== undefined) { - const windows = await this.listWindows() - const targetWindow = windows.find( - (window) => window.windowId === opts.windowId, - ) - if (targetWindow && !targetWindow.isVisible) { - return targetWindow.windowId - } - if (targetWindow?.isVisible) { - logger.warn( - 'Requested hidden page target window is visible, creating a new hidden window instead', - { - requestedWindowId: opts.windowId, - }, - ) - } - } - - const hiddenWindow = await this.createWindow({ hidden: true }) - return hiddenWindow.windowId - } - async newPage( url: string, opts?: { hidden?: boolean; background?: boolean; windowId?: number }, ): Promise { - const windowId = await this.resolveWindowIdForNewPage(opts) const createResult = await this.cdp.Browser.createTab({ url, ...(opts?.background !== undefined && { background: opts.background }), - ...(windowId !== undefined && { windowId }), + ...(opts?.hidden !== undefined && { hidden: opts.hidden }), + ...(opts?.windowId !== undefined && { windowId: opts.windowId }), }) const tabId = (createResult.tab as TabInfo).tabId @@ -583,7 +553,7 @@ export class Browser { loadProgress: tabInfo.loadProgress, isPinned: tabInfo.isPinned, isHidden: tabInfo.isHidden, - windowId: tabInfo.windowId ?? windowId, + windowId: tabInfo.windowId ?? opts?.windowId, index: tabInfo.index, groupId: tabInfo.groupId, }) diff --git a/packages/browseros-agent/apps/server/tests/agent/prompt.test.ts b/packages/browseros-agent/apps/server/tests/agent/prompt.test.ts index b5917bde3..66ff39600 100644 --- a/packages/browseros-agent/apps/server/tests/agent/prompt.test.ts +++ b/packages/browseros-agent/apps/server/tests/agent/prompt.test.ts @@ -326,6 +326,14 @@ describe('mode-aware framing', () => { expect(prompt).toContain('Do NOT create new windows') expect(prompt).toContain('Close extra hidden pages') }) + + it('visible scheduled task mode excludes hidden page rules', () => { + const prompt = buildScheduled({ scheduledTaskRunSilently: false }) + expect(prompt).toContain('scheduled background task') + expect(prompt).not.toContain('system-managed hidden page') + expect(prompt).not.toContain('Do NOT close your starting hidden page') + expect(prompt).toContain('new_page') + }) }) // --------------------------------------------------------------------------- diff --git a/packages/browseros-agent/apps/server/tests/api/services/chat-service.test.ts b/packages/browseros-agent/apps/server/tests/api/services/chat-service.test.ts index 72d997502..6c457adcb 100644 --- a/packages/browseros-agent/apps/server/tests/api/services/chat-service.test.ts +++ b/packages/browseros-agent/apps/server/tests/api/services/chat-service.test.ts @@ -291,6 +291,68 @@ describe('ChatService scheduled task hidden page lifecycle', () => { }) expect(browser.closePage).toHaveBeenCalledWith(88) }) + + it('uses the provided visible context when a scheduled task disables silent mode', async () => { + const fakeAgent = createFakeAgent() + agentToReturn = fakeAgent + streamResponseHandler = async ({ onFinish, uiMessages }) => { + await onFinish({ messages: uiMessages ?? fakeAgent.messages }) + return new Response('ok') + } + + const browser = { + newPage: mock(async () => 99), + listPages: mock(async () => []), + closePage: mock(async () => {}), + resolveTabIds: mock(async () => new Map([[3, 103]])), + } + const sessionStore = createSessionStore() + const service = new ChatService({ + sessionStore: sessionStore as never, + klavisRef: { handle: null }, + browser: browser as never, + registry: {} as never, + }) + + await service.processMessage( + { + conversationId: crypto.randomUUID(), + message: 'Run the scheduled task visibly', + isScheduledTask: true, + runSilently: false, + mode: 'agent', + origin: 'sidepanel', + browserContext: { + activeTab: { + id: 3, + url: 'https://example.com', + title: 'Example', + }, + }, + } as never, + new AbortController().signal, + ) + + expect(browser.newPage).not.toHaveBeenCalled() + expect(browser.closePage).not.toHaveBeenCalled() + + const createArgs = createAgentSpy.mock.calls.at(-1)?.[0] as { + browserContext?: { + activeTab?: { + id: number + pageId?: number + url: string + title: string + } + } + } + expect(createArgs.browserContext?.activeTab).toEqual({ + id: 3, + pageId: 103, + url: 'https://example.com', + title: 'Example', + }) + }) }) describe('ChatService Klavis session rebuilds', () => { diff --git a/packages/browseros-agent/apps/server/tests/browser/browser.test.ts b/packages/browseros-agent/apps/server/tests/browser/browser.test.ts new file mode 100644 index 000000000..44d86483c --- /dev/null +++ b/packages/browseros-agent/apps/server/tests/browser/browser.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, mock } from 'bun:test' + +mock.module('@browseros/shared/constants/limits', () => ({ + CONTENT_LIMITS: { + CONSOLE_BUFFER_MAX_ENTRIES: 500, + CONSOLE_DEFAULT_LIMIT: 50, + CONSOLE_MAX_LIMIT: 200, + CONSOLE_META_CHAR: 1_000, + }, +})) + +mock.module('../../src/lib/logger', () => ({ + logger: { + debug: mock(() => {}), + error: mock(() => {}), + info: mock(() => {}), + warn: mock(() => {}), + }, +})) + +const { Browser } = await import('../../src/browser/browser') + +describe('Browser', () => { + it('creates hidden pages as hidden tabs without opening a new window', async () => { + const tab = { + tabId: 10, + targetId: 'target-10', + url: 'about:blank', + title: '', + isActive: false, + isLoading: false, + loadProgress: 1, + isPinned: false, + isHidden: true, + index: 0, + } + const cdp = { + isConnected: () => true, + onSessionEvent: mock(() => () => {}), + Target: { + on: mock(() => {}), + }, + Browser: { + createTab: mock(async () => ({ tab })), + getTabInfo: mock(async () => ({ tab })), + createWindow: mock(async () => { + throw new Error('createWindow should not be called for hidden pages') + }), + }, + } + + const browser = new Browser(cdp as never) + const pageId = await browser.newPage('about:blank', { + hidden: true, + background: true, + }) + + expect(pageId).toBe(1) + expect(cdp.Browser.createTab).toHaveBeenCalledWith({ + url: 'about:blank', + background: true, + hidden: true, + }) + expect(cdp.Browser.createWindow).not.toHaveBeenCalled() + }) +})