mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-07 14:06:42 +02:00
cdd6f7865d
* test: add 16 failing tests for 6 community fixes
Tests-first for all fixes in this PR wave:
- #594 discoverability: gstack tag in descriptions, 120-char first line
- #573 feature signals: ship/SKILL.md Step 4 detection
- #510 context warnings: no preemptive warnings in generated files
- #474 Safety Net: no find -delete in generated files
- #467 telemetry: JSONL writes gated by _TEL conditional
- #584 sidebar: Write in allowedTools, stderr capture
- #578 relink: prefixed/flat symlinks, cleanup, error, config hook
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: replace find -delete with find -exec rm for Safety Net (#474)
-delete is a non-POSIX extension that fails on Safety Net environments.
-exec rm {} + is POSIX-compliant and works everywhere.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: gate local JSONL writes by telemetry setting (#467)
When telemetry is off, nothing is written anywhere — not just remote,
but local JSONL too. Clean trust contract: off means off everywhere.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: remove preemptive context warnings from plan-eng-review (#510)
The system handles context compaction automatically. Preemptive warnings
waste tokens and create false urgency. Skills should not warn about
context limits — just describe the compression priority order.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: add (gstack) tag to skill descriptions for discoverability (#594)
Every SKILL.md.tmpl description now contains "gstack" on the last line,
making skills findable in Claude Code's command palette. First-line hooks
stay under 120 chars. Split ship description to fix wrapping.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: auto-relink skill symlinks on prefix config change (#578)
New bin/gstack-relink creates prefixed (gstack-*) or flat symlinks
based on skill_prefix config. gstack-config auto-triggers relink
when skill_prefix changes. Setup guards against recursive calls
with GSTACK_SETUP_RUNNING env var.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: add feature signal detection to version bump heuristic (#573)
/ship Step 4 now checks for feature signals (new routes, migrations,
test+source pairs, feat/ branches) when deciding version bumps.
PATCH requires no feature signals. MINOR asks the user if any signal
is detected or 500+ lines changed.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: sidebar Write tool, stderr capture, cross-platform URL opener (#584)
Add Write to sidebar allowedTools (both sidebar-agent.ts and server.ts).
Write doesn't expand attack surface beyond what Bash already provides.
Replace empty stderr handler with buffer capture for better error
diagnostics. New bin/gstack-open-url for cross-platform URL opening.
Does NOT include Search Before Building intro flow (deferred).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: update sidebar-security test for Write tool addition
The fallback allowedTools string now includes Write, matching the
sidebar-agent.ts change from commit 68dc957.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* chore: bump version and changelog (v0.13.5.0)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: prevent gstack-relink from double-prefixing gstack-upgrade
gstack-relink now checks if a skill directory is already named gstack-*
before prepending the prefix. Previously, setting skill_prefix=true would
create gstack-gstack-upgrade, breaking the /gstack-upgrade command.
Matches setup script behavior (setup:260) which already has this guard.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* chore: add double-prefix fix to changelog
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* chore: remove .factory/ from git tracking and add to .gitignore
Generated Factory Droid skills are build output, same as .agents/.
They should not be committed to the repo.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
297 lines
11 KiB
TypeScript
297 lines
11 KiB
TypeScript
/**
|
|
* Sidebar Agent — polls agent-queue from server, spawns claude -p for each
|
|
* message, streams live events back to the server via /sidebar-agent/event.
|
|
*
|
|
* This runs as a NON-COMPILED bun process because compiled bun binaries
|
|
* cannot posix_spawn external executables. The server writes to the queue
|
|
* file, this process reads it and spawns claude.
|
|
*
|
|
* Usage: BROWSE_BIN=/path/to/browse bun run browse/src/sidebar-agent.ts
|
|
*/
|
|
|
|
import { spawn } from 'child_process';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
|
|
const QUEUE = process.env.SIDEBAR_QUEUE_PATH || path.join(process.env.HOME || '/tmp', '.gstack', 'sidebar-agent-queue.jsonl');
|
|
const SERVER_PORT = parseInt(process.env.BROWSE_SERVER_PORT || '34567', 10);
|
|
const SERVER_URL = `http://127.0.0.1:${SERVER_PORT}`;
|
|
const POLL_MS = 500; // Fast polling — server already did the user-facing response
|
|
const B = process.env.BROWSE_BIN || path.resolve(__dirname, '../../.claude/skills/gstack/browse/dist/browse');
|
|
|
|
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> {
|
|
// Read token from state file (same-user, mode 0o600) instead of /health
|
|
try {
|
|
const stateFile = process.env.BROWSE_STATE_FILE ||
|
|
path.join(process.env.HOME || '/tmp', '.gstack', 'browse.json');
|
|
const data = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
|
|
authToken = data.token || null;
|
|
return authToken;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ─── Event relay to server ──────────────────────────────────────
|
|
|
|
async function sendEvent(event: Record<string, any>): Promise<void> {
|
|
if (!authToken) await refreshToken();
|
|
if (!authToken) return;
|
|
|
|
try {
|
|
await fetch(`${SERVER_URL}/sidebar-agent/event`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${authToken}`,
|
|
},
|
|
body: JSON.stringify(event),
|
|
});
|
|
} catch (err) {
|
|
console.error('[sidebar-agent] Failed to send event:', err);
|
|
}
|
|
}
|
|
|
|
// ─── Claude subprocess ──────────────────────────────────────────
|
|
|
|
function shorten(str: string): string {
|
|
return str
|
|
.replace(new RegExp(B.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '$B')
|
|
.replace(/\/Users\/[^/]+/g, '~')
|
|
.replace(/\/conductor\/workspaces\/[^/]+\/[^/]+/g, '')
|
|
.replace(/\.claude\/skills\/gstack\//g, '')
|
|
.replace(/browse\/dist\/browse/g, '$B');
|
|
}
|
|
|
|
function summarizeToolInput(tool: string, input: any): string {
|
|
if (!input) return '';
|
|
if (tool === 'Bash' && input.command) {
|
|
let cmd = shorten(input.command);
|
|
return cmd.length > 80 ? cmd.slice(0, 80) + '…' : cmd;
|
|
}
|
|
if (tool === 'Read' && input.file_path) return shorten(input.file_path);
|
|
if (tool === 'Edit' && input.file_path) return shorten(input.file_path);
|
|
if (tool === 'Write' && input.file_path) return shorten(input.file_path);
|
|
if (tool === 'Grep' && input.pattern) return `/${input.pattern}/`;
|
|
if (tool === 'Glob' && input.pattern) return input.pattern;
|
|
try { return shorten(JSON.stringify(input)).slice(0, 60); } catch { return ''; }
|
|
}
|
|
|
|
async function handleStreamEvent(event: any): Promise<void> {
|
|
if (event.type === 'system' && event.session_id) {
|
|
// Relay claude session ID for --resume support
|
|
await sendEvent({ type: 'system', claudeSessionId: event.session_id });
|
|
}
|
|
|
|
if (event.type === 'assistant' && event.message?.content) {
|
|
for (const block of event.message.content) {
|
|
if (block.type === 'tool_use') {
|
|
await sendEvent({ type: 'tool_use', tool: block.name, input: summarizeToolInput(block.name, block.input) });
|
|
} else if (block.type === 'text' && block.text) {
|
|
await sendEvent({ type: 'text', text: block.text });
|
|
}
|
|
}
|
|
}
|
|
|
|
if (event.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
|
|
await sendEvent({ type: 'tool_use', tool: event.content_block.name, input: summarizeToolInput(event.content_block.name, event.content_block.input) });
|
|
}
|
|
|
|
if (event.type === 'content_block_delta' && event.delta?.type === 'text_delta' && event.delta.text) {
|
|
await sendEvent({ type: 'text_delta', text: event.delta.text });
|
|
}
|
|
|
|
if (event.type === 'result') {
|
|
await sendEvent({ type: 'result', text: event.result || '' });
|
|
}
|
|
}
|
|
|
|
async function askClaude(queueEntry: any): Promise<void> {
|
|
const { prompt, args, stateFile, cwd } = queueEntry;
|
|
|
|
isProcessing = true;
|
|
await sendEvent({ type: 'agent_start' });
|
|
|
|
return new Promise((resolve) => {
|
|
// Use args from queue entry (server sets --model, --allowedTools, prompt framing).
|
|
// Fall back to defaults only if queue entry has no args (backward compat).
|
|
// Write doesn't expand attack surface beyond what Bash already provides.
|
|
// The security boundary is the localhost-only message path, not the tool allowlist.
|
|
let claudeArgs = args || ['-p', prompt, '--output-format', 'stream-json', '--verbose',
|
|
'--allowedTools', 'Bash,Read,Glob,Grep,Write'];
|
|
|
|
// Validate cwd exists — queue may reference a stale worktree
|
|
let effectiveCwd = cwd || process.cwd();
|
|
try { fs.accessSync(effectiveCwd); } catch { effectiveCwd = process.cwd(); }
|
|
|
|
const proc = spawn('claude', claudeArgs, {
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
cwd: effectiveCwd,
|
|
env: { ...process.env, BROWSE_STATE_FILE: stateFile || '' },
|
|
});
|
|
|
|
proc.stdin.end();
|
|
|
|
let buffer = '';
|
|
|
|
proc.stdout.on('data', (data: Buffer) => {
|
|
buffer += data.toString();
|
|
const lines = buffer.split('\n');
|
|
buffer = lines.pop() || '';
|
|
for (const line of lines) {
|
|
if (!line.trim()) continue;
|
|
try { handleStreamEvent(JSON.parse(line)); } catch {}
|
|
}
|
|
});
|
|
|
|
let stderrBuffer = '';
|
|
proc.stderr.on('data', (data: Buffer) => {
|
|
stderrBuffer += data.toString();
|
|
});
|
|
|
|
proc.on('close', (code) => {
|
|
if (buffer.trim()) {
|
|
try { handleStreamEvent(JSON.parse(buffer)); } catch {}
|
|
}
|
|
const doneEvent: Record<string, any> = { type: 'agent_done' };
|
|
if (code !== 0 && stderrBuffer.trim()) {
|
|
doneEvent.stderr = stderrBuffer.trim().slice(-500);
|
|
}
|
|
sendEvent(doneEvent).then(() => {
|
|
isProcessing = false;
|
|
resolve();
|
|
});
|
|
});
|
|
|
|
proc.on('error', (err) => {
|
|
const errorMsg = stderrBuffer.trim()
|
|
? `${err.message}\nstderr: ${stderrBuffer.trim().slice(-500)}`
|
|
: err.message;
|
|
sendEvent({ type: 'agent_error', error: errorMsg }).then(() => {
|
|
isProcessing = false;
|
|
resolve();
|
|
});
|
|
});
|
|
|
|
// Timeout (default 300s / 5 min — multi-page tasks need time)
|
|
const timeoutMs = parseInt(process.env.SIDEBAR_AGENT_TIMEOUT || '300000', 10);
|
|
setTimeout(() => {
|
|
try { proc.kill(); } catch {}
|
|
const timeoutMsg = stderrBuffer.trim()
|
|
? `Timed out after ${timeoutMs / 1000}s\nstderr: ${stderrBuffer.trim().slice(-500)}`
|
|
: `Timed out after ${timeoutMs / 1000}s`;
|
|
sendEvent({ type: 'agent_error', error: timeoutMsg }).then(() => {
|
|
isProcessing = false;
|
|
resolve();
|
|
});
|
|
}, timeoutMs);
|
|
});
|
|
}
|
|
|
|
// ─── Poll loop ───────────────────────────────────────────────────
|
|
|
|
function countLines(): number {
|
|
try {
|
|
return fs.readFileSync(QUEUE, 'utf-8').split('\n').filter(Boolean).length;
|
|
} catch { return 0; }
|
|
}
|
|
|
|
function readLine(n: number): string | null {
|
|
try {
|
|
const lines = fs.readFileSync(QUEUE, 'utf-8').split('\n').filter(Boolean);
|
|
return lines[n - 1] || null;
|
|
} catch { return null; }
|
|
}
|
|
|
|
async function poll() {
|
|
if (isProcessing) return; // One at a time — server handles queuing
|
|
|
|
const current = countLines();
|
|
if (current <= lastLine) return;
|
|
|
|
while (lastLine < current && !isProcessing) {
|
|
lastLine++;
|
|
const line = readLine(lastLine);
|
|
if (!line) continue;
|
|
|
|
let entry: any;
|
|
try { entry = JSON.parse(line); } catch { continue; }
|
|
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) {
|
|
console.error(`[sidebar-agent] Error:`, err);
|
|
await sendEvent({ type: 'agent_error', error: String(err) });
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Main ────────────────────────────────────────────────────────
|
|
|
|
async function main() {
|
|
const dir = path.dirname(QUEUE);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
if (!fs.existsSync(QUEUE)) fs.writeFileSync(QUEUE, '');
|
|
|
|
lastLine = countLines();
|
|
await refreshToken();
|
|
|
|
console.log(`[sidebar-agent] Started. Watching ${QUEUE} from line ${lastLine}`);
|
|
console.log(`[sidebar-agent] Server: ${SERVER_URL}`);
|
|
console.log(`[sidebar-agent] Browse binary: ${B}`);
|
|
|
|
setInterval(poll, POLL_MS);
|
|
}
|
|
|
|
main().catch(console.error);
|