mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-02 11:45:20 +02:00
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:
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user