fix(bridge): v16 terminal output filter + v15 stale LS auto-fix + heartbeat probe (v0.5.50) #task-619

- http-bridge v16: Block terminal OUTPUT as enriched cmd — if description has no prompt marker (> » $ #), it's stdout from code block, not an actual command. Prevents 'No extension.log found' etc. from reaching Discord.
- step-probe v15: Stale LS auto-detection — if all sessions are >5min old, periodically retry fixLSConnection(). Heartbeat probe every 10 polls to detect step changes when summary API returns frozen stepCount.
- extension.ts v15: fixLSConnection() fallback — match LS processes without --workspace_id (common after AG restart)
This commit is contained in:
Variet Worker
2026-04-16 04:58:05 +09:00
parent 66233bd9cb
commit 7ade31e4cf
6 changed files with 135 additions and 29 deletions

View File

@@ -243,8 +243,8 @@ async function fixLSConnection() {
logToFile(`[LS-FIX] found ${lines.length} LS process(es), hint="${hint}"`); logToFile(`[LS-FIX] found ${lines.length} LS process(es), hint="${hint}"`);
// Find the line whose workspace_id matches our workspace (case-insensitive) // Find the line whose workspace_id matches our workspace (case-insensitive)
let matchedLine = null; let matchedLine = null;
let fallbackLine = null; // v15: LS without workspace_id (AG restart)
for (const line of lines) { for (const line of lines) {
const lower = line.toLowerCase();
// Match workspace_id arg against our hint // Match workspace_id arg against our hint
const wsMatch = line.match(/--workspace_id[= ](\S+)/i); const wsMatch = line.match(/--workspace_id[= ](\S+)/i);
if (wsMatch) { if (wsMatch) {
@@ -254,11 +254,26 @@ async function fixLSConnection() {
break; break;
} }
} }
else {
// v15: LS without --workspace_id (new AG main LS after restart)
// Skip --enable_lsp processes (secondary/old LSP instances)
if (!line.includes('--enable_lsp') && !fallbackLine) {
fallbackLine = line;
logToFile(`[LS-FIX] found fallback LS (no workspace_id): PID=${line.split('|')[0]?.trim()}`);
}
}
} }
if (!matchedLine) { if (!matchedLine) {
if (fallbackLine) {
// v15: Use workspace_id-less LS as fallback (common after AG restart)
logToFile(`[LS-FIX] No workspace_id match — using fallback LS`);
matchedLine = fallbackLine;
}
else {
logToFile(`[LS-FIX] No LS process matched hint="${hint}" (${lines.length} processes)`); logToFile(`[LS-FIX] No LS process matched hint="${hint}" (${lines.length} processes)`);
return false; return false;
} }
}
// Extract port and csrf_token from matched line // Extract port and csrf_token from matched line
const csrfMatch = matchedLine.match(/--csrf_token[= ](\S+)/); const csrfMatch = matchedLine.match(/--csrf_token[= ](\S+)/);
const extPortMatch = matchedLine.match(/--extension_server_port[= ](\d+)/); const extPortMatch = matchedLine.match(/--extension_server_port[= ](\d+)/);

File diff suppressed because one or more lines are too long

View File

@@ -2,7 +2,7 @@
"name": "gravity-bridge", "name": "gravity-bridge",
"displayName": "Gravity Bridge", "displayName": "Gravity Bridge",
"description": "Discord-based unified approval system for Antigravity AI interactions.", "description": "Discord-based unified approval system for Antigravity AI interactions.",
"version": "0.5.47", "version": "0.5.50",
"publisher": "variet", "publisher": "variet",
"engines": { "engines": {
"vscode": "^1.100.0" "vscode": "^1.100.0"

View File

@@ -209,8 +209,8 @@ export async function fixLSConnection(): Promise<boolean> {
// Find the line whose workspace_id matches our workspace (case-insensitive) // Find the line whose workspace_id matches our workspace (case-insensitive)
let matchedLine: string | null = null; let matchedLine: string | null = null;
let fallbackLine: string | null = null; // v15: LS without workspace_id (AG restart)
for (const line of lines) { for (const line of lines) {
const lower = line.toLowerCase();
// Match workspace_id arg against our hint // Match workspace_id arg against our hint
const wsMatch = line.match(/--workspace_id[= ](\S+)/i); const wsMatch = line.match(/--workspace_id[= ](\S+)/i);
if (wsMatch) { if (wsMatch) {
@@ -219,13 +219,26 @@ export async function fixLSConnection(): Promise<boolean> {
matchedLine = line; matchedLine = line;
break; break;
} }
} else {
// v15: LS without --workspace_id (new AG main LS after restart)
// Skip --enable_lsp processes (secondary/old LSP instances)
if (!line.includes('--enable_lsp') && !fallbackLine) {
fallbackLine = line;
logToFile(`[LS-FIX] found fallback LS (no workspace_id): PID=${line.split('|')[0]?.trim()}`);
}
} }
} }
if (!matchedLine) { if (!matchedLine) {
if (fallbackLine) {
// v15: Use workspace_id-less LS as fallback (common after AG restart)
logToFile(`[LS-FIX] No workspace_id match — using fallback LS`);
matchedLine = fallbackLine;
} else {
logToFile(`[LS-FIX] No LS process matched hint="${hint}" (${lines.length} processes)`); logToFile(`[LS-FIX] No LS process matched hint="${hint}" (${lines.length} processes)`);
return false; return false;
} }
}
// Extract port and csrf_token from matched line // Extract port and csrf_token from matched line
const csrfMatch = matchedLine.match(/--csrf_token[= ](\S+)/); const csrfMatch = matchedLine.match(/--csrf_token[= ](\S+)/);

View File

@@ -259,34 +259,42 @@ function _handlePending(req: any, res: any, ctx: HttpBridgeContext) {
// ── v12: Command enrichment FIRST — extract actual command from description ── // ── v12: Command enrichment FIRST — extract actual command from description ──
// Must run before filters so "Always run" with useful description isn't filtered out // Must run before filters so "Always run" with useful description isn't filtered out
const rawCmd = (data.command || '').trim(); const rawCmd = (data.command || '').trim();
const rawDesc = (data.description || '').trim(); // v15: Strip Material icon names from description BEFORE enrichment
// DOM textContent concatenates icon text (e.g. "content_copy") without separators
const ICON_STRIP_RE = /\b(chevron_right|chevron_left|arrow_drop_down|arrow_drop_up|arrow_right|arrow_left|arrow_forward|arrow_back|expand_more|expand_less|more_horiz|more_vert|content_copy|content_paste|check_circle|check|keyboard_arrow_up|keyboard_arrow_down|keyboard_arrow_left|keyboard_arrow_right|slow_motion_video|open_in_new|alternate_email)\b/g;
const rawDesc = (data.description || '').replace(ICON_STRIP_RE, '').replace(/\s{2,}/g, ' ').trim();
const GENERIC_BTN_RE = /^(?:Always\s*)?(?:Run|Allow|Accept|Approve)$/i; const GENERIC_BTN_RE = /^(?:Always\s*)?(?:Run|Allow|Accept|Approve)$/i;
let enrichedCmd = rawCmd; let enrichedCmd = rawCmd;
let enrichedDesc = rawDesc; let enrichedDesc = rawDesc;
if (GENERIC_BTN_RE.test(rawCmd) && rawDesc.length > 10 && rawDesc !== rawCmd) { if (GENERIC_BTN_RE.test(rawCmd) && rawDesc.length > 10 && rawDesc !== rawCmd) {
// Extract the actual command from description (often includes terminal prompt) // Extract the actual command from description (often includes terminal prompt)
// Pattern: "…\project_name > actual_command" // Pattern: "…\project_name > actual_command"
let extracted = rawDesc;
const promptMatch = rawDesc.match(/[>»]\s*(.+)/); const promptMatch = rawDesc.match(/[>»]\s*(.+)/);
if (promptMatch && promptMatch[1].trim().length > 3) { if (promptMatch && promptMatch[1].trim().length > 3) {
extracted = promptMatch[1].trim(); const extracted = promptMatch[1].trim();
} // v16: Validate extracted text is not just a prompt fragment or path
// v12: Validate extracted text is not just a prompt fragment
// Skip enrichment only if it looks like a bare prompt (e.g. "\\gravity_control >")
const PROMPT_ONLY_RE = /^.*[>»$#]\s*$/; const PROMPT_ONLY_RE = /^.*[>»$#]\s*$/;
if (!PROMPT_ONLY_RE.test(extracted) && extracted.length > 3) { const TERMINAL_PROMPT_RE = /^[^\n]*\\[^\\>]+\s*[>»]\s*$/;
if (!PROMPT_ONLY_RE.test(extracted) && !TERMINAL_PROMPT_RE.test(extracted)) {
enrichedCmd = extracted.substring(0, 200); enrichedCmd = extracted.substring(0, 200);
enrichedDesc = `[${rawCmd}] ${rawDesc}`; enrichedDesc = `[${rawCmd}] ${rawDesc}`;
ctx.logToFile(`[HTTP] command enriched: "${rawCmd}" → "${enrichedCmd.substring(0, 60)}"`); ctx.logToFile(`[HTTP] command enriched: "${rawCmd}" → "${enrichedCmd.substring(0, 60)}"`);
} else { } else {
// v13: Enrichment failed — description is prompt-only or empty // Prompt-only extraction — filter
// If cmd is still generic button text, unconditionally filter it
ctx.logToFile(`[HTTP] enrichment skipped (prompt-only): "${rawCmd}" desc="${rawDesc.substring(0, 60)}"`); ctx.logToFile(`[HTTP] enrichment skipped (prompt-only): "${rawCmd}" desc="${rawDesc.substring(0, 60)}"`);
ctx.logToFile(`[HTTP] filtered generic+prompt-only: "${rawCmd}"`); ctx.logToFile(`[HTTP] filtered generic+prompt-only: "${rawCmd}"`);
res.writeHead(200, { 'Content-Type': 'application/json' }); res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, filtered: true, reason: 'generic_btn_prompt_only' })); res.end(JSON.stringify({ ok: false, filtered: true, reason: 'generic_btn_prompt_only' }));
return; return;
} }
} else {
// v16: No prompt marker (> » $ #) found in description — this is terminal OUTPUT, not a command
// Observer extracted stdout text from code block (e.g. "No extension.log found", "Log found: ...")
ctx.logToFile(`[HTTP] filtered terminal output (no prompt marker): "${rawDesc.substring(0, 60)}"`);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, filtered: true, reason: 'terminal_output' }));
return;
}
} else if (GENERIC_BTN_RE.test(rawCmd) && (rawDesc.length <= 10 || rawDesc === rawCmd)) { } else if (GENERIC_BTN_RE.test(rawCmd) && (rawDesc.length <= 10 || rawDesc === rawCmd)) {
// v13: Generic button with no useful description (observer prompt-only context) // v13: Generic button with no useful description (observer prompt-only context)
ctx.logToFile(`[HTTP] filtered generic button no-context: "${rawCmd}" desc="${rawDesc.substring(0, 30)}"`); ctx.logToFile(`[HTTP] filtered generic button no-context: "${rawCmd}" desc="${rawDesc.substring(0, 30)}"`);
@@ -307,13 +315,23 @@ function _handlePending(req: any, res: any, ctx: HttpBridgeContext) {
// v14: Server-side junk content filter — CSS, source code, icon glue // v14: Server-side junk content filter — CSS, source code, icon glue
// This is the last line of defense regardless of observer version // This is the last line of defense regardless of observer version
const JUNK_CONTENT_RE = /(!important|::selection|background-color:|var\(--|font-size:|border-[a-z]+:|padding:|margin:|display:\s|===|!==|\|\||\.\btest\(|\.\bmatch\(|\.\breplace\(|_RE[.\s]|\brawDesc\b|\brawCmd\b|\benrichedCmd\b|\bquerySelector\b|\.code-block|\.code-line|\.line-content|\{\s*--|integration\.build)/; const JUNK_CONTENT_RE = /(!important|::selection|background-color:|var\(--|font-size:|border-[a-z]+:|padding:|margin:|display:\s|===|!==|\|\||\.\btest\(|\.\bmatch\(|\.\breplace\(|_RE[.\s]|\brawDesc\b|\brawCmd\b|\benrichedCmd\b|\bquerySelector\b|\.code-block|\.code-line|\.line-content|\{\s*--|integration\.build)/;
const ICON_GLUE_RE = /(alternate_email|content_copy|content_paste|check_circle|chevron_right|chevron_left|keyboard_arrow|arrow_drop_down|arrow_drop_up|more_horiz|more_vert|expand_more|expand_less)[a-zA-Z]/; // v15: ICON_GLUE_RE now also catches standalone icon names (no trailing [a-zA-Z] required)
const ICON_GLUE_RE = /\b(alternate_email|content_copy|content_paste|check_circle|chevron_right|chevron_left|keyboard_arrow|arrow_drop_down|arrow_drop_up|more_horiz|more_vert|expand_more|expand_less)\b/;
// v15: Terminal prompt pattern — catches bare prompts like "…\project >" or "PS C:\path>"
const BARE_PROMPT_RE = /^[^\n]{0,60}[>»$#]\s*$/;
if (JUNK_CONTENT_RE.test(cmd) || ICON_GLUE_RE.test(cmd)) { if (JUNK_CONTENT_RE.test(cmd) || ICON_GLUE_RE.test(cmd)) {
ctx.logToFile(`[HTTP] filtered junk content: "${cmd.substring(0, 80)}"`); ctx.logToFile(`[HTTP] filtered junk content: "${cmd.substring(0, 80)}"`);
res.writeHead(200, { 'Content-Type': 'application/json' }); res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, filtered: true, reason: 'junk_content' })); res.end(JSON.stringify({ ok: false, filtered: true, reason: 'junk_content' }));
return; return;
} }
// v15: Final bare prompt filter — catches any enriched cmd that's just a terminal prompt
if (BARE_PROMPT_RE.test(cmd) && cmd.length < 80) {
ctx.logToFile(`[HTTP] filtered bare prompt: "${cmd.substring(0, 80)}"`);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, filtered: true, reason: 'bare_prompt' }));
return;
}
// "Run" button → step_probe handles these with full command detail // "Run" button → step_probe handles these with full command detail
// Only filter when step_probe IS actively tracking AND cmd is still generic button text // Only filter when step_probe IS actively tracking AND cmd is still generic button text
if (/^(?:Always\s*)?Run\b/i.test(cmd)) { if (/^(?:Always\s*)?Run\b/i.test(cmd)) {

View File

@@ -203,6 +203,7 @@ function setupMonitor() {
let pendingModifiedFilePaths: string[] = []; // full paths for diff review let pendingModifiedFilePaths: string[] = []; // full paths for diff review
let pendingEditStepIndices: number[] = []; // step indices for AcknowledgeCascadeCodeEdit let pendingEditStepIndices: number[] = []; // step indices for AcknowledgeCascadeCodeEdit
let lastResponseCaptureStep = -1; // dedup: don't capture same response twice let lastResponseCaptureStep = -1; // dedup: don't capture same response twice
let lastLSFixPoll = 0; // v15: track last fixLSConnection() attempt for periodic retry
setInterval(async () => { setInterval(async () => {
pollCount++; pollCount++;
@@ -303,6 +304,34 @@ function setupMonitor() {
return; return;
} }
// ── v15: Stale LS detection — periodic fixLSConnection() ──
// If the best session's lastModifiedTime hasn't changed for >5min,
// the extension might be connected to a stale LS. Retry fixLSConnection().
if (ctx.fixLSConnection && (pollCount - lastLSFixPoll) >= 60) {
// Check if current best session has stale data
const bestEntries = Object.entries(allTraj.trajectorySummaries) as [string, any][];
const hasStaleData = bestEntries.every(([, data]) => {
const mod = data.lastModifiedTime || '';
if (!mod) return true;
const modDate = new Date(mod);
return (Date.now() - modDate.getTime()) > 300_000; // 5 min
});
if (hasStaleData) {
lastLSFixPoll = pollCount;
ctx.logToFile(`[LS-AUTO-FIX] All sessions stale (>5min) — attempting fixLSConnection()`);
try {
const fixed = await ctx.fixLSConnection();
if (fixed) {
ctx.logToFile(`[LS-AUTO-FIX] ✅ Reconnected to new LS — next poll should have fresh data`);
} else {
ctx.logToFile(`[LS-AUTO-FIX] No better LS found`);
}
} catch (e: any) {
ctx.logToFile(`[LS-AUTO-FIX] error: ${e.message?.substring(0, 100)}`);
}
}
}
// ── Filter to sessions owned by THIS window ── // ── Filter to sessions owned by THIS window ──
// PRIMARY: Use trajectoryMetadata.workspaces URI to match sessions to this workspace. // PRIMARY: Use trajectoryMetadata.workspaces URI to match sessions to this workspace.
// FALLBACK: Use bridge/register/ files for sessions without metadata. // FALLBACK: Use bridge/register/ files for sessions without metadata.
@@ -422,13 +451,44 @@ function setupMonitor() {
const delta = currentCount - lastKnownStepCount; const delta = currentCount - lastKnownStepCount;
lastKnownStepCount = currentCount; lastKnownStepCount = currentCount;
if (delta > 0) { // ── v15: Heartbeat probe — detect step changes when summary API is stale ──
// GetAllCascadeTrajectories can return frozen stepCount/lastModifiedTime,
// preventing delta>0 from ever firing. Every 10 polls (~50s), directly
// probe GetCascadeTrajectorySteps to get the REAL latest step count.
if (delta === 0 && ctx.sdk && ctx.activeSessionId && pollCount % 10 === 0) {
try {
const hbOffset = Math.max(0, currentCount - 1);
const hbResp = await ctx.sdk.ls.rawRPC('GetCascadeTrajectorySteps', {
cascadeId: ctx.activeSessionId,
stepOffset: hbOffset,
verbosity: 0, // minimal — just need step count
});
if (hbResp?.steps?.length > 0) {
const realStepCount = hbOffset + hbResp.steps.length;
if (realStepCount > lastKnownStepCount) {
ctx.logToFile(`[HEARTBEAT] summary stale! reported=${lastKnownStepCount} real=${realStepCount} — correcting`);
lastKnownStepCount = realStepCount;
// Trigger RT-CAPTURE by re-entering delta>0 path below
// We set currentCount so delta recalculation works
}
}
} catch (hbErr: any) {
// Non-critical — will retry next heartbeat
if (pollCount <= 30) ctx.logToFile(`[HEARTBEAT] probe error: ${hbErr.message?.substring(0, 60)}`);
}
}
// Recalculate delta after heartbeat correction
const effectiveDelta = lastKnownStepCount - (currentCount > 0 ? currentCount : lastKnownStepCount);
const hasDelta = delta > 0 || effectiveDelta > 0;
if (hasDelta) {
console.log(`Gravity Bridge: [POLL#${pollCount}] +${delta} steps (${currentCount}) "${currentTitle}"`); console.log(`Gravity Bridge: [POLL#${pollCount}] +${delta} steps (${currentCount}) "${currentTitle}"`);
// Real-time response capture: fetch latest steps on every delta>0 // Real-time response capture: fetch latest steps on every delta>0 or heartbeat
if (currentCount > lastResponseCaptureStep && ctx.sdk) { if (lastKnownStepCount > lastResponseCaptureStep && ctx.sdk) {
try { try {
const rtOffset = Math.max(0, currentCount - 3); const rtOffset = Math.max(0, lastKnownStepCount - 3);
const rtResp = await ctx.sdk.ls.rawRPC('GetCascadeTrajectorySteps', { const rtResp = await ctx.sdk.ls.rawRPC('GetCascadeTrajectorySteps', {
cascadeId: bestSessionId, cascadeId: bestSessionId,
stepOffset: rtOffset, stepOffset: rtOffset,