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:
@@ -209,8 +209,8 @@ export async function fixLSConnection(): Promise<boolean> {
|
||||
|
||||
// Find the line whose workspace_id matches our workspace (case-insensitive)
|
||||
let matchedLine: string | null = null;
|
||||
let fallbackLine: string | null = null; // v15: LS without workspace_id (AG restart)
|
||||
for (const line of lines) {
|
||||
const lower = line.toLowerCase();
|
||||
// Match workspace_id arg against our hint
|
||||
const wsMatch = line.match(/--workspace_id[= ](\S+)/i);
|
||||
if (wsMatch) {
|
||||
@@ -219,12 +219,25 @@ export async function fixLSConnection(): Promise<boolean> {
|
||||
matchedLine = line;
|
||||
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) {
|
||||
logToFile(`[LS-FIX] No LS process matched hint="${hint}" (${lines.length} processes)`);
|
||||
return false;
|
||||
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)`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract port and csrf_token from matched line
|
||||
|
||||
@@ -259,32 +259,40 @@ function _handlePending(req: any, res: any, ctx: HttpBridgeContext) {
|
||||
// ── v12: Command enrichment FIRST — extract actual command from description ──
|
||||
// Must run before filters so "Always run" with useful description isn't filtered out
|
||||
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;
|
||||
let enrichedCmd = rawCmd;
|
||||
let enrichedDesc = rawDesc;
|
||||
if (GENERIC_BTN_RE.test(rawCmd) && rawDesc.length > 10 && rawDesc !== rawCmd) {
|
||||
// Extract the actual command from description (often includes terminal prompt)
|
||||
// Pattern: "…\project_name > actual_command"
|
||||
let extracted = rawDesc;
|
||||
const promptMatch = rawDesc.match(/[>»]\s*(.+)/);
|
||||
if (promptMatch && promptMatch[1].trim().length > 3) {
|
||||
extracted = promptMatch[1].trim();
|
||||
}
|
||||
// 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*$/;
|
||||
if (!PROMPT_ONLY_RE.test(extracted) && extracted.length > 3) {
|
||||
enrichedCmd = extracted.substring(0, 200);
|
||||
enrichedDesc = `[${rawCmd}] ${rawDesc}`;
|
||||
ctx.logToFile(`[HTTP] command enriched: "${rawCmd}" → "${enrichedCmd.substring(0, 60)}"`);
|
||||
const extracted = promptMatch[1].trim();
|
||||
// v16: Validate extracted text is not just a prompt fragment or path
|
||||
const PROMPT_ONLY_RE = /^.*[>»$#]\s*$/;
|
||||
const TERMINAL_PROMPT_RE = /^[^\n]*\\[^\\>]+\s*[>»]\s*$/;
|
||||
if (!PROMPT_ONLY_RE.test(extracted) && !TERMINAL_PROMPT_RE.test(extracted)) {
|
||||
enrichedCmd = extracted.substring(0, 200);
|
||||
enrichedDesc = `[${rawCmd}] ${rawDesc}`;
|
||||
ctx.logToFile(`[HTTP] command enriched: "${rawCmd}" → "${enrichedCmd.substring(0, 60)}"`);
|
||||
} else {
|
||||
// Prompt-only extraction — filter
|
||||
ctx.logToFile(`[HTTP] enrichment skipped (prompt-only): "${rawCmd}" desc="${rawDesc.substring(0, 60)}"`);
|
||||
ctx.logToFile(`[HTTP] filtered generic+prompt-only: "${rawCmd}"`);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: false, filtered: true, reason: 'generic_btn_prompt_only' }));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// v13: Enrichment failed — description is prompt-only or empty
|
||||
// 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] filtered generic+prompt-only: "${rawCmd}"`);
|
||||
// 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: 'generic_btn_prompt_only' }));
|
||||
res.end(JSON.stringify({ ok: false, filtered: true, reason: 'terminal_output' }));
|
||||
return;
|
||||
}
|
||||
} else if (GENERIC_BTN_RE.test(rawCmd) && (rawDesc.length <= 10 || rawDesc === rawCmd)) {
|
||||
@@ -307,13 +315,23 @@ function _handlePending(req: any, res: any, ctx: HttpBridgeContext) {
|
||||
// v14: Server-side junk content filter — CSS, source code, icon glue
|
||||
// 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 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)) {
|
||||
ctx.logToFile(`[HTTP] filtered junk content: "${cmd.substring(0, 80)}"`);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: false, filtered: true, reason: 'junk_content' }));
|
||||
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
|
||||
// Only filter when step_probe IS actively tracking AND cmd is still generic button text
|
||||
if (/^(?:Always\s*)?Run\b/i.test(cmd)) {
|
||||
|
||||
@@ -203,6 +203,7 @@ function setupMonitor() {
|
||||
let pendingModifiedFilePaths: string[] = []; // full paths for diff review
|
||||
let pendingEditStepIndices: number[] = []; // step indices for AcknowledgeCascadeCodeEdit
|
||||
let lastResponseCaptureStep = -1; // dedup: don't capture same response twice
|
||||
let lastLSFixPoll = 0; // v15: track last fixLSConnection() attempt for periodic retry
|
||||
|
||||
setInterval(async () => {
|
||||
pollCount++;
|
||||
@@ -303,6 +304,34 @@ function setupMonitor() {
|
||||
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 ──
|
||||
// PRIMARY: Use trajectoryMetadata.workspaces URI to match sessions to this workspace.
|
||||
// FALLBACK: Use bridge/register/ files for sessions without metadata.
|
||||
@@ -422,13 +451,44 @@ function setupMonitor() {
|
||||
const delta = currentCount - lastKnownStepCount;
|
||||
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}"`);
|
||||
|
||||
// Real-time response capture: fetch latest steps on every delta>0
|
||||
if (currentCount > lastResponseCaptureStep && ctx.sdk) {
|
||||
// Real-time response capture: fetch latest steps on every delta>0 or heartbeat
|
||||
if (lastKnownStepCount > lastResponseCaptureStep && ctx.sdk) {
|
||||
try {
|
||||
const rtOffset = Math.max(0, currentCount - 3);
|
||||
const rtOffset = Math.max(0, lastKnownStepCount - 3);
|
||||
const rtResp = await ctx.sdk.ls.rawRPC('GetCascadeTrajectorySteps', {
|
||||
cascadeId: bestSessionId,
|
||||
stepOffset: rtOffset,
|
||||
|
||||
Reference in New Issue
Block a user