diff --git a/.agents/skills/gstack-browse/SKILL.md b/.agents/skills/gstack-browse/SKILL.md index 69ddbd6f..9cd87a4e 100644 --- a/.agents/skills/gstack-browse/SKILL.md +++ b/.agents/skills/gstack-browse/SKILL.md @@ -458,6 +458,7 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`. |---------|-------------| | `chain` | Run commands from JSON stdin. Format: [["cmd","arg1",...],...] | | `inbox [--clear]` | List messages from sidebar scout inbox | +| `watch [stop]` | Passive observation — periodic snapshots while user browses | ### Tabs | Command | Description | diff --git a/.agents/skills/gstack/SKILL.md b/.agents/skills/gstack/SKILL.md index d450f81d..c4343dbc 100644 --- a/.agents/skills/gstack/SKILL.md +++ b/.agents/skills/gstack/SKILL.md @@ -586,6 +586,7 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`. |---------|-------------| | `chain` | Run commands from JSON stdin. Format: [["cmd","arg1",...],...] | | `inbox [--clear]` | List messages from sidebar scout inbox | +| `watch [stop]` | Passive observation — periodic snapshots while user browses | ### Tabs | Command | Description | diff --git a/SKILL.md b/SKILL.md index b1c1a189..34594906 100644 --- a/SKILL.md +++ b/SKILL.md @@ -592,6 +592,7 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`. |---------|-------------| | `chain` | Run commands from JSON stdin. Format: [["cmd","arg1",...],...] | | `inbox [--clear]` | List messages from sidebar scout inbox | +| `watch [stop]` | Passive observation — periodic snapshots while user browses | ### Tabs | Command | Description | diff --git a/browse/SKILL.md b/browse/SKILL.md index d6a1236d..b3bc5646 100644 --- a/browse/SKILL.md +++ b/browse/SKILL.md @@ -464,6 +464,7 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`. |---------|-------------| | `chain` | Run commands from JSON stdin. Format: [["cmd","arg1",...],...] | | `inbox [--clear]` | List messages from sidebar scout inbox | +| `watch [stop]` | Passive observation — periodic snapshots while user browses | ### Tabs | Command | Description | diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index 40f10c40..ef1086f7 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -61,12 +61,44 @@ export class BrowserManager { private isHeaded: boolean = false; private consecutiveFailures: number = 0; + // ─── Watch Mode ───────────────────────────────────────── + private watching = false; + public watchInterval: ReturnType | null = null; + private watchSnapshots: string[] = []; + private watchStartTime: number = 0; + // ─── Headed State ──────────────────────────────────────── private connectionMode: 'launched' | 'headed' = 'launched'; private intentionalDisconnect = false; getConnectionMode(): 'launched' | 'headed' { return this.connectionMode; } + // ─── Watch Mode Methods ───────────────────────────────── + isWatching(): boolean { return this.watching; } + + startWatch(): void { + this.watching = true; + this.watchSnapshots = []; + this.watchStartTime = Date.now(); + } + + stopWatch(): { snapshots: string[]; duration: number } { + this.watching = false; + if (this.watchInterval) { + clearInterval(this.watchInterval); + this.watchInterval = null; + } + const snapshots = this.watchSnapshots; + const duration = Date.now() - this.watchStartTime; + this.watchSnapshots = []; + this.watchStartTime = 0; + return { snapshots, duration }; + } + + addWatchSnapshot(snapshot: string): void { + this.watchSnapshots.push(snapshot); + } + /** * Find the gstack Chrome extension directory. * Checks: repo root /extension, global install, dev install. diff --git a/browse/src/commands.ts b/browse/src/commands.ts index 2376c958..754c7afe 100644 --- a/browse/src/commands.ts +++ b/browse/src/commands.ts @@ -33,6 +33,7 @@ export const META_COMMANDS = new Set([ 'handoff', 'resume', 'connect', 'disconnect', 'focus', 'inbox', + 'watch', ]); export const ALL_COMMANDS = new Set([...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS]); @@ -106,6 +107,8 @@ export const COMMAND_DESCRIPTIONS: Record 0 ? result.snapshots[result.snapshots.length - 1] : '(none)', + ].join('\n'); + } + + if (bm.isWatching()) return 'Already watching. Run `$B watch stop` to stop.'; + if (bm.getConnectionMode() !== 'headed') { + return 'watch requires headed mode. Run `$B connect` first.'; + } + + bm.startWatch(); + return 'WATCHING — observing user browsing. Periodic snapshots every 5s.\nRun `$B watch stop` to stop and get summary.'; + } + // ─── Inbox ────────────────────────────────────────── case 'inbox': { const { execSync } = await import('child_process'); diff --git a/browse/src/server.ts b/browse/src/server.ts index c82734fc..28b2fbc7 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -19,7 +19,7 @@ import { handleWriteCommand } from './write-commands'; import { handleMetaCommand } from './meta-commands'; import { handleCookiePickerRoute } from './cookie-picker-routes'; import { COMMAND_DESCRIPTIONS } from './commands'; -import { SNAPSHOT_FLAGS } from './snapshot'; +import { handleSnapshot, SNAPSHOT_FLAGS } from './snapshot'; import { resolveConfig, ensureStateDir, readVersionHash } from './config'; import { emitActivity, subscribe, getActivityAfter, getActivityHistory, getSubscriberCount } from './activity'; // Bun.spawn used instead of child_process.spawn (compiled bun binaries @@ -599,6 +599,16 @@ async function handleCommand(body: any): Promise { }); } + // Block mutation commands while watching (read-only observation mode) + if (browserManager.isWatching() && WRITE_COMMANDS.has(command)) { + return new Response(JSON.stringify({ + error: 'Cannot run mutation commands while watching. Run `$B watch stop` first.', + }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + // Activity: emit command_start const startTime = Date.now(); emitActivity({ @@ -619,6 +629,22 @@ async function handleCommand(body: any): Promise { result = await handleWriteCommand(command, args, browserManager); } else if (META_COMMANDS.has(command)) { result = await handleMetaCommand(command, args, browserManager, shutdown); + // Start periodic snapshot interval when watch mode begins + if (command === 'watch' && args[0] !== 'stop' && browserManager.isWatching()) { + const watchInterval = setInterval(async () => { + if (!browserManager.isWatching()) { + clearInterval(watchInterval); + return; + } + try { + const snapshot = await handleSnapshot(['-i'], browserManager); + browserManager.addWatchSnapshot(snapshot); + } catch { + // Page may be navigating — skip this snapshot + } + }, 5000); + browserManager.watchInterval = watchInterval; + } } else if (command === 'help') { const helpText = generateHelpText(); return new Response(helpText, { @@ -683,6 +709,8 @@ async function shutdown() { isShuttingDown = true; console.log('[browse] Shutting down...'); + // Stop watch mode if active + if (browserManager.isWatching()) browserManager.stopWatch(); killAgent(); messageQueue = []; saveSession(); // Persist chat history before exit