feat: file drop relay + $B inbox command

Sidebar agent now writes structured messages to .context/sidebar-inbox/
when processing user input. The workspace agent can read these via
$B inbox to see what the user reported from the browser.

File drop format:
  .context/sidebar-inbox/{timestamp}-observation.json
  { type, timestamp, page: {url}, userMessage, sidebarSessionId }

Atomic writes (tmp + rename) prevent partial reads. $B inbox --clear
removes messages after display.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-03-22 21:32:09 -07:00
parent 0ae5838eb4
commit 07b1ca36f0
7 changed files with 109 additions and 0 deletions
+1
View File
@@ -457,6 +457,7 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`.
| Command | Description |
|---------|-------------|
| `chain` | Run commands from JSON stdin. Format: [["cmd","arg1",...],...] |
| `inbox [--clear]` | List messages from sidebar scout inbox |
### Tabs
| Command | Description |
+1
View File
@@ -585,6 +585,7 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`.
| Command | Description |
|---------|-------------|
| `chain` | Run commands from JSON stdin. Format: [["cmd","arg1",...],...] |
| `inbox [--clear]` | List messages from sidebar scout inbox |
### Tabs
| Command | Description |
+1
View File
@@ -591,6 +591,7 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`.
| Command | Description |
|---------|-------------|
| `chain` | Run commands from JSON stdin. Format: [["cmd","arg1",...],...] |
| `inbox [--clear]` | List messages from sidebar scout inbox |
### Tabs
| Command | Description |
+1
View File
@@ -463,6 +463,7 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`.
| Command | Description |
|---------|-------------|
| `chain` | Run commands from JSON stdin. Format: [["cmd","arg1",...],...] |
| `inbox [--clear]` | List messages from sidebar scout inbox |
### Tabs
| Command | Description |
+3
View File
@@ -32,6 +32,7 @@ export const META_COMMANDS = new Set([
'url', 'snapshot',
'handoff', 'resume',
'connect', 'disconnect', 'focus',
'inbox',
]);
export const ALL_COMMANDS = new Set([...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS]);
@@ -103,6 +104,8 @@ export const COMMAND_DESCRIPTIONS: Record<string, { category: string; descriptio
'connect': { category: 'Server', description: 'Launch headed Chromium with Chrome extension', usage: 'connect' },
'disconnect': { category: 'Server', description: 'Disconnect headed browser, return to headless mode' },
'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]' },
};
// Load-time validation: descriptions must cover exactly the command sets
+60
View File
@@ -327,6 +327,66 @@ export async function handleMetaCommand(
}
}
// ─── Inbox ──────────────────────────────────────────
case 'inbox': {
const { execSync } = await import('child_process');
let gitRoot: string;
try {
gitRoot = execSync('git rev-parse --show-toplevel', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
} catch {
return 'Not in a git repository — cannot locate inbox.';
}
const inboxDir = path.join(gitRoot, '.context', 'sidebar-inbox');
if (!fs.existsSync(inboxDir)) return 'Inbox empty.';
const files = fs.readdirSync(inboxDir)
.filter(f => f.endsWith('.json') && !f.startsWith('.'))
.sort()
.reverse(); // newest first
if (files.length === 0) return 'Inbox empty.';
const messages: { timestamp: string; url: string; userMessage: string }[] = [];
for (const file of files) {
try {
const data = JSON.parse(fs.readFileSync(path.join(inboxDir, file), 'utf-8'));
messages.push({
timestamp: data.timestamp || '',
url: data.page?.url || 'unknown',
userMessage: data.userMessage || '',
});
} catch {
// Skip malformed files
}
}
if (messages.length === 0) return 'Inbox empty.';
const lines: string[] = [];
lines.push(`SIDEBAR INBOX (${messages.length} message${messages.length === 1 ? '' : 's'})`);
lines.push('────────────────────────────────');
for (const msg of messages) {
const ts = msg.timestamp ? `[${msg.timestamp}]` : '[unknown]';
lines.push(`${ts} ${msg.url}`);
lines.push(` "${msg.userMessage}"`);
lines.push('');
}
lines.push('────────────────────────────────');
// Handle --clear flag
if (args.includes('--clear')) {
for (const file of files) {
try { fs.unlinkSync(path.join(inboxDir, file)); } catch {}
}
lines.push(`Cleared ${files.length} message${files.length === 1 ? '' : 's'}.`);
}
return lines.join('\n');
}
default:
throw new Error(`Unknown meta command: ${command}`);
}
+42
View File
@@ -23,6 +23,46 @@ let lastLine = 0;
let authToken: string | null = null;
let isProcessing = false;
// ─── File drop relay ──────────────────────────────────────────
function getGitRoot(): string | null {
try {
const { execSync } = require('child_process');
return execSync('git rev-parse --show-toplevel', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
} catch {
return null;
}
}
function writeToInbox(message: string, pageUrl?: string, sessionId?: string): void {
const gitRoot = getGitRoot();
if (!gitRoot) {
console.error('[sidebar-agent] Cannot write to inbox — not in a git repo');
return;
}
const inboxDir = path.join(gitRoot, '.context', 'sidebar-inbox');
fs.mkdirSync(inboxDir, { recursive: true });
const now = new Date();
const timestamp = now.toISOString().replace(/:/g, '-');
const filename = `${timestamp}-observation.json`;
const tmpFile = path.join(inboxDir, `.${filename}.tmp`);
const finalFile = path.join(inboxDir, filename);
const inboxMessage = {
type: 'observation',
timestamp: now.toISOString(),
page: { url: pageUrl || 'unknown', title: '' },
userMessage: message,
sidebarSessionId: sessionId || 'unknown',
};
fs.writeFileSync(tmpFile, JSON.stringify(inboxMessage, null, 2));
fs.renameSync(tmpFile, finalFile);
console.log(`[sidebar-agent] Wrote inbox message: ${filename}`);
}
// ─── Auth ────────────────────────────────────────────────────────
async function refreshToken(): Promise<string | null> {
@@ -203,6 +243,8 @@ async function poll() {
if (!entry.message && !entry.prompt) continue;
console.log(`[sidebar-agent] Processing: "${entry.message}"`);
// Write to inbox so workspace agent can pick it up
writeToInbox(entry.message || entry.prompt, entry.pageUrl, entry.sessionId);
try {
await askClaude(entry);
} catch (err) {