mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-21 20:28:24 +08: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",...],...] |
|
| `chain` | Run commands from JSON stdin. Format: [["cmd","arg1",...],...] |
|
||||||
| `inbox [--clear]` | List messages from sidebar scout inbox |
|
| `inbox [--clear]` | List messages from sidebar scout inbox |
|
||||||
|
| `watch [stop]` | Passive observation — periodic snapshots while user browses |
|
||||||
|
|
||||||
### Tabs
|
### Tabs
|
||||||
| Command | Description |
|
| 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",...],...] |
|
| `chain` | Run commands from JSON stdin. Format: [["cmd","arg1",...],...] |
|
||||||
| `inbox [--clear]` | List messages from sidebar scout inbox |
|
| `inbox [--clear]` | List messages from sidebar scout inbox |
|
||||||
|
| `watch [stop]` | Passive observation — periodic snapshots while user browses |
|
||||||
|
|
||||||
### Tabs
|
### Tabs
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|
|||||||
1
SKILL.md
1
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",...],...] |
|
| `chain` | Run commands from JSON stdin. Format: [["cmd","arg1",...],...] |
|
||||||
| `inbox [--clear]` | List messages from sidebar scout inbox |
|
| `inbox [--clear]` | List messages from sidebar scout inbox |
|
||||||
|
| `watch [stop]` | Passive observation — periodic snapshots while user browses |
|
||||||
|
|
||||||
### Tabs
|
### Tabs
|
||||||
| Command | Description |
|
| 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",...],...] |
|
| `chain` | Run commands from JSON stdin. Format: [["cmd","arg1",...],...] |
|
||||||
| `inbox [--clear]` | List messages from sidebar scout inbox |
|
| `inbox [--clear]` | List messages from sidebar scout inbox |
|
||||||
|
| `watch [stop]` | Passive observation — periodic snapshots while user browses |
|
||||||
|
|
||||||
### Tabs
|
### Tabs
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|
|||||||
@@ -61,12 +61,44 @@ export class BrowserManager {
|
|||||||
private isHeaded: boolean = false;
|
private isHeaded: boolean = false;
|
||||||
private consecutiveFailures: number = 0;
|
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 ────────────────────────────────────────
|
// ─── Headed State ────────────────────────────────────────
|
||||||
private connectionMode: 'launched' | 'headed' = 'launched';
|
private connectionMode: 'launched' | 'headed' = 'launched';
|
||||||
private intentionalDisconnect = false;
|
private intentionalDisconnect = false;
|
||||||
|
|
||||||
getConnectionMode(): 'launched' | 'headed' { return this.connectionMode; }
|
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.
|
* Find the gstack Chrome extension directory.
|
||||||
* Checks: repo root /extension, global install, dev install.
|
* Checks: repo root /extension, global install, dev install.
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export const META_COMMANDS = new Set([
|
|||||||
'handoff', 'resume',
|
'handoff', 'resume',
|
||||||
'connect', 'disconnect', 'focus',
|
'connect', 'disconnect', 'focus',
|
||||||
'inbox',
|
'inbox',
|
||||||
|
'watch',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const ALL_COMMANDS = new Set([...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS]);
|
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]' },
|
'focus': { category: 'Server', description: 'Bring headed browser window to foreground (macOS)', usage: 'focus [@ref]' },
|
||||||
// Inbox
|
// Inbox
|
||||||
'inbox': { category: 'Meta', description: 'List messages from sidebar scout inbox', usage: 'inbox [--clear]' },
|
'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
|
// 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 ──────────────────────────────────────────
|
// ─── Inbox ──────────────────────────────────────────
|
||||||
case 'inbox': {
|
case 'inbox': {
|
||||||
const { execSync } = await import('child_process');
|
const { execSync } = await import('child_process');
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { handleWriteCommand } from './write-commands';
|
|||||||
import { handleMetaCommand } from './meta-commands';
|
import { handleMetaCommand } from './meta-commands';
|
||||||
import { handleCookiePickerRoute } from './cookie-picker-routes';
|
import { handleCookiePickerRoute } from './cookie-picker-routes';
|
||||||
import { COMMAND_DESCRIPTIONS } from './commands';
|
import { COMMAND_DESCRIPTIONS } from './commands';
|
||||||
import { SNAPSHOT_FLAGS } from './snapshot';
|
import { handleSnapshot, SNAPSHOT_FLAGS } from './snapshot';
|
||||||
import { resolveConfig, ensureStateDir, readVersionHash } from './config';
|
import { resolveConfig, ensureStateDir, readVersionHash } from './config';
|
||||||
import { emitActivity, subscribe, getActivityAfter, getActivityHistory, getSubscriberCount } from './activity';
|
import { emitActivity, subscribe, getActivityAfter, getActivityHistory, getSubscriberCount } from './activity';
|
||||||
// Bun.spawn used instead of child_process.spawn (compiled bun binaries
|
// 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
|
// Activity: emit command_start
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
emitActivity({
|
emitActivity({
|
||||||
@@ -619,6 +629,22 @@ async function handleCommand(body: any): Promise<Response> {
|
|||||||
result = await handleWriteCommand(command, args, browserManager);
|
result = await handleWriteCommand(command, args, browserManager);
|
||||||
} else if (META_COMMANDS.has(command)) {
|
} else if (META_COMMANDS.has(command)) {
|
||||||
result = await handleMetaCommand(command, args, browserManager, shutdown);
|
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') {
|
} else if (command === 'help') {
|
||||||
const helpText = generateHelpText();
|
const helpText = generateHelpText();
|
||||||
return new Response(helpText, {
|
return new Response(helpText, {
|
||||||
@@ -683,6 +709,8 @@ async function shutdown() {
|
|||||||
isShuttingDown = true;
|
isShuttingDown = true;
|
||||||
|
|
||||||
console.log('[browse] Shutting down...');
|
console.log('[browse] Shutting down...');
|
||||||
|
// Stop watch mode if active
|
||||||
|
if (browserManager.isWatching()) browserManager.stopWatch();
|
||||||
killAgent();
|
killAgent();
|
||||||
messageQueue = [];
|
messageQueue = [];
|
||||||
saveSession(); // Persist chat history before exit
|
saveSession(); // Persist chat history before exit
|
||||||
|
|||||||
Reference in New Issue
Block a user