feat: $B watch — passive observation mode

Claude enters read-only mode and captures periodic snapshots (every 5s)
while the user browses. Mutation commands (click, fill, etc.) are
blocked during watch. $B watch stop exits and returns a summary with
the last snapshot.

Requires headed mode ($B connect). This is the inverse of the scout
pattern — the workspace agent watches through the browser instead of
the sidebar relaying to it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-03-22 21:35:30 -07:00
parent 07b1ca36f0
commit 3ec3cb360f
8 changed files with 91 additions and 1 deletions
+1
View File
@@ -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 |
+1
View File
@@ -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 |
+1
View File
@@ -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 |
+1
View File
@@ -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 |
+32
View File
@@ -61,12 +61,44 @@ export class BrowserManager {
private isHeaded: boolean = false;
private consecutiveFailures: number = 0;
// ─── Watch Mode ─────────────────────────────────────────
private watching = false;
public watchInterval: ReturnType<typeof setInterval> | 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.
+3
View File
@@ -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<string, { category: string; descriptio
'focus': { category: 'Server', description: 'Bring headed browser window to foreground (macOS)', usage: 'focus [@ref]' },
// Inbox
'inbox': { category: 'Meta', description: 'List messages from sidebar scout inbox', usage: 'inbox [--clear]' },
// Watch
'watch': { category: 'Meta', description: 'Passive observation — periodic snapshots while user browses', usage: 'watch [stop]' },
};
// Load-time validation: descriptions must cover exactly the command sets
+23
View File
@@ -327,6 +327,29 @@ export async function handleMetaCommand(
}
}
// ─── Watch ──────────────────────────────────────────
case 'watch': {
if (args[0] === 'stop') {
if (!bm.isWatching()) return 'Not currently watching.';
const result = bm.stopWatch();
const durationSec = Math.round(result.duration / 1000);
return [
`WATCH STOPPED (${durationSec}s, ${result.snapshots.length} snapshots)`,
'',
'Last snapshot:',
result.snapshots.length > 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');
+29 -1
View File
@@ -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<Response> {
});
}
// 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<Response> {
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