fix(bridge): rawRPC direct polling + SDK analysis docs + trial-and-error log

- Root cause: getDiagnostics.lastStepIndex is stale, SDK EventMonitor cannot detect real-time step changes
- Fix: Direct rawRPC('GetCascadeTrajectorySteps') polling every 5s
- Relay: PLANNER_RESPONSE, NOTIFY_USER, TASK_BOUNDARY, WAITING steps
- Added: docs/discord-bridge-analysis.md (full SDK architecture analysis)
- Added: docs/devlog/entries/20260308-003.md (trial-and-error history)
- Added: antigravity-sdk-main/ source reference
- Vikunja: #252 done, #253 created, #251 commented
This commit is contained in:
2026-03-08 07:08:25 +09:00
parent 731dad35bf
commit c3964f8e7a
40 changed files with 11086 additions and 25 deletions

View File

@@ -0,0 +1,21 @@
/**
* Integration module — re-exports.
* @module integration
*/
export { IntegrationManager } from './integration-manager';
export { IntegrityManager } from './integrity-manager';
export { TitleManager } from './title-manager';
export { IntegrationPoint } from './types';
export type {
IntegrationConfig,
IIntegrationManager,
IButtonIntegration,
ITurnMetaIntegration,
IUserBadgeIntegration,
IBotActionIntegration,
IDropdownIntegration,
ITitleIntegration,
IToastConfig,
IToastRow,
TurnMetric,
} from './types';

View File

@@ -0,0 +1,704 @@
/**
* Integration Manager — Public API for UI integration into Agent View.
*
* Orchestrates ScriptGenerator and WorkbenchPatcher to provide
* a clean, developer-friendly API.
*
* @module integration/integration-manager
*
* @example
* ```typescript
* import { IntegrationManager, IntegrationPoint } from 'antigravity-sdk';
*
* const integrator = new IntegrationManager();
*
* integrator.register({
* id: 'myStats',
* point: IntegrationPoint.TOP_BAR,
* icon: '📊',
* tooltip: 'Show Stats',
* toast: {
* title: 'My Extension Stats',
* rows: [{ key: 'turns:', value: 'Dynamic data here' }],
* },
* });
*
* integrator.register({
* id: 'turnInfo',
* point: IntegrationPoint.TURN_METADATA,
* metrics: ['turnNumber', 'userCharCount', 'separator', 'aiCharCount', 'codeBlocks'],
* });
*
* await integrator.install();
* // Restart Antigravity to see changes
* ```
*/
import * as fs from 'fs';
import { IDisposable } from '../core/disposable';
import { Logger } from '../core/logger';
import {
IntegrationConfig,
IntegrationPoint,
IIntegrationManager,
IButtonIntegration,
ITurnMetaIntegration,
IUserBadgeIntegration,
IBotActionIntegration,
IDropdownIntegration,
ITitleIntegration,
IToastConfig,
} from './types';
import { ScriptGenerator } from './script-generator';
import { WorkbenchPatcher } from './workbench-patcher';
import { IntegrityManager } from './integrity-manager';
import { TitleManager } from './title-manager';
import { generateTitleProxyCode } from './title-proxy';
const log = new Logger('IntegrationManager');
/**
* Manages UI integrations into the Antigravity Agent View.
*
* Provides a declarative API to register integration points,
* generates a self-contained JavaScript file, and installs it
* into Antigravity's workbench.
*
* Features:
* - **Theme-aware**: Adapts to dark/light mode automatically
* - **Auto-repair**: Watches workbench.html and re-patches after updates
* - **Dynamic update**: Re-generate script without re-patching workbench.html
*/
export class IntegrationManager implements IIntegrationManager, IDisposable {
private readonly _configs: Map<string, IntegrationConfig> = new Map();
private readonly _generator = new ScriptGenerator();
private readonly _patcher: WorkbenchPatcher;
private readonly _integrity: IntegrityManager;
private readonly _titles = new TitleManager();
private readonly _namespace: string;
private _watcher: fs.FSWatcher | null = null;
private _autoRepairDebounce: ReturnType<typeof setTimeout> | null = null;
private _titleProxyEnabled = false;
/**
* @param namespace - Unique slug that isolates this extension's files.
* Derived automatically from `context.extension.id` when using AntigravitySDK.
* Multiple SDK-based extensions can coexist without conflicts.
*/
constructor(namespace: string = 'default') {
this._namespace = namespace;
this._patcher = new WorkbenchPatcher(namespace);
this._integrity = new IntegrityManager(
this._patcher.getWorkbenchDir(),
namespace,
);
}
// ─── Registration ──────────────────────────────────────────────────
/**
* Register a single integration point.
*
* @throws If an integration with the same ID already exists
*/
register(config: IntegrationConfig): void {
if (this._configs.has(config.id)) {
throw new Error(`Integration '${config.id}' is already registered`);
}
this._configs.set(config.id, config);
log.debug(`Registered integration: ${config.id} (${config.point})`);
}
/**
* Register multiple integration points at once.
*/
registerMany(configs: IntegrationConfig[]): void {
for (const c of configs) {
this.register(c);
}
}
/**
* Remove a registered integration by ID.
*/
unregister(id: string): void {
this._configs.delete(id);
log.debug(`Unregistered integration: ${id}`);
}
/**
* Get all registered integrations.
*/
getRegistered(): ReadonlyArray<IntegrationConfig> {
return Array.from(this._configs.values());
}
// ─── Convenience methods (fluent API) ──────────────────────────────
/**
* Add a button to the top bar (near +, refresh icons).
*/
addTopBarButton(id: string, icon: string, tooltip?: string, toast?: IToastConfig): this {
this.register({
id,
point: IntegrationPoint.TOP_BAR,
icon,
tooltip,
toast,
} as IButtonIntegration);
return this;
}
/**
* Add a button to the top-right corner (before X).
*/
addTopRightButton(id: string, icon: string, tooltip?: string, toast?: IToastConfig): this {
this.register({
id,
point: IntegrationPoint.TOP_RIGHT,
icon,
tooltip,
toast,
} as IButtonIntegration);
return this;
}
/**
* Add a button next to the send/voice buttons.
*/
addInputButton(id: string, icon: string, tooltip?: string, toast?: IToastConfig): this {
this.register({
id,
point: IntegrationPoint.INPUT_AREA,
icon,
tooltip,
toast,
} as IButtonIntegration);
return this;
}
/**
* Add an icon to the bottom icon row (file, terminal, etc.).
*/
addBottomIcon(id: string, icon: string, tooltip?: string, toast?: IToastConfig): this {
this.register({
id,
point: IntegrationPoint.BOTTOM_ICONS,
icon,
tooltip,
toast,
} as IButtonIntegration);
return this;
}
/**
* Enable per-turn metadata display.
*/
addTurnMetadata(id: string, metrics: ITurnMetaIntegration['metrics'], clickable = true): this {
this.register({
id,
point: IntegrationPoint.TURN_METADATA,
metrics,
clickable,
} as ITurnMetaIntegration);
return this;
}
/**
* Add character count badges to user messages.
*/
addUserBadges(id: string, display: IUserBadgeIntegration['display'] = 'charCount'): this {
this.register({
id,
point: IntegrationPoint.USER_BADGE,
display,
} as IUserBadgeIntegration);
return this;
}
/**
* Add an action button next to Good/Bad feedback.
*/
addBotAction(id: string, icon: string, label: string, toast?: IToastConfig): this {
this.register({
id,
point: IntegrationPoint.BOT_ACTION,
icon,
label,
toast,
} as IBotActionIntegration);
return this;
}
/**
* Add item(s) to the 3-dot dropdown menu.
*/
addDropdownItem(id: string, label: string, icon?: string, toast?: IToastConfig, separator = false): this {
this.register({
id,
point: IntegrationPoint.DROPDOWN_MENU,
label,
icon,
toast,
separator,
} as IDropdownIntegration);
return this;
}
/**
* Enable chat title interaction.
*/
addTitleInteraction(id: string, interaction: ITitleIntegration['interaction'] = 'dblclick', hint?: string, toast?: IToastConfig): this {
this.register({
id,
point: IntegrationPoint.CHAT_TITLE,
interaction,
hint,
toast,
} as ITitleIntegration);
return this;
}
// ─── Title Proxy ─────────────────────────────────────────────────
/**
* Enable the title proxy feature.
*
* Adds renderer-side code that intercepts the summaries provider
* and injects custom chat titles. Uses structural matching to find
* the provider (obfuscation-safe).
*
* After enabling, call `install()` or `updateScript()` to apply.
*
* @example
* ```typescript
* const sdk = new AntigravitySDK(context);
* await sdk.initialize();
*
* sdk.integration.enableTitleProxy();
* await sdk.integration.install();
*
* // Now rename from extension host:
* sdk.integration.titles.rename(cascadeId, 'My Custom Title');
* ```
*/
enableTitleProxy(): this {
this._titleProxyEnabled = true;
if (this._patcher.isAvailable()) {
this._titles.initialize(this._patcher.getWorkbenchDir(), this._namespace);
}
log.info('Title proxy enabled');
return this;
}
/**
* Access the title manager for programmatic title control.
*
* Requires `enableTitleProxy()` to be called first.
*
* @example
* ```typescript
* sdk.integration.titles.rename(cascadeId, 'My Title');
* sdk.integration.titles.remove(cascadeId);
* const all = sdk.integration.titles.getAll();
* ```
*/
get titles(): TitleManager {
if (!this._titleProxyEnabled) {
log.warn('Title proxy not enabled. Call enableTitleProxy() first.');
}
return this._titles;
}
// ─── Build & Install ───────────────────────────────────────────────
/**
* Generate the integration script from all registered configs.
*
* If title proxy is enabled, appends the title proxy renderer code.
*
* @returns Complete JavaScript code as a string
*/
build(): string {
const configs = Array.from(this._configs.values());
if (configs.length === 0 && !this._titleProxyEnabled) {
throw new Error('No integration points registered and title proxy not enabled');
}
let script = '';
if (configs.length > 0) {
log.info(`Building script for ${configs.length} integration(s)`);
script = this._generator.generate(configs);
}
if (this._titleProxyEnabled) {
log.info('Appending title proxy code');
script += '\n' + generateTitleProxyCode(this._namespace);
}
return script;
}
/**
* Install the generated script into workbench.html.
*
* For seamless hot-reload behavior, use `installSeamless()` instead.
*
* @returns true if the script content actually changed on disk
*/
async install(): Promise<boolean> {
if (!this._patcher.isAvailable()) {
throw new Error('Antigravity workbench not found. Is Antigravity installed?');
}
const script = this.build();
// Read existing script to detect changes
const scriptPath = this._patcher.getScriptPath();
let oldContent = '';
try {
if (fs.existsSync(scriptPath)) {
oldContent = fs.readFileSync(scriptPath, 'utf8');
}
} catch { /* ignore */ }
this._patcher.install(script);
this._integrity.suppressCheck();
this._patcher.writeHeartbeat();
const changed = oldContent !== script;
log.info(
`Installed integration (${this._configs.size} points, titleProxy: ${this._titleProxyEnabled}) -> ${scriptPath} [${changed ? 'CHANGED' : 'unchanged'}]`,
);
return changed;
}
/**
* Seamless install — handles everything automatically.
*
* This is the **recommended** install method for extension developers.
* It handles the entire lifecycle:
*
* 1. **First install:** Writes script + patches HTML + prompts user to reload
* 2. **Update:** Compares content, if changed → auto-reloads window (no prompt)
* 3. **No change:** Does nothing
*
* The developer never needs to think about reload.
*
* @param executeCommand - Function to execute VS Code commands
* (pass `vscode.commands.executeCommand` or equivalent)
*
* @example
* ```typescript
* const sdk = new AntigravitySDK(context);
* await sdk.initialize();
*
* sdk.integration.enableTitleProxy();
* // That's it. SDK handles install, reload, everything.
* await sdk.integration.installSeamless(
* (cmd) => vscode.commands.executeCommand(cmd),
* (msg, ...items) => vscode.window.showInformationMessage(msg, ...items),
* );
* ```
*/
async installSeamless(
executeCommand: (command: string) => Thenable<any>,
showMessage?: (message: string, ...items: string[]) => Thenable<string | undefined>,
): Promise<void> {
const wasInstalled = this._patcher.isInstalled();
// Snapshot old content before install
const scriptPath = this._patcher.getScriptPath();
let oldContent = '';
try {
if (fs.existsSync(scriptPath)) {
oldContent = fs.readFileSync(scriptPath, 'utf8');
}
} catch { /* ignore */ }
const changed = await this.install();
if (!wasInstalled) {
// First install: prompt user
log.info('First install. Prompting for reload.');
if (showMessage) {
const action = await showMessage(
'Better Antigravity installed. Reload to activate.',
'Reload Now',
);
if (action === 'Reload Now') {
await executeCommand('workbench.action.reloadWindow');
}
}
} else if (changed) {
// Update: auto-reload (no prompt)
log.info('Script changed on disk. Auto-reloading window...');
// Small delay to let extension finish activation
setTimeout(() => executeCommand('workbench.action.reloadWindow'), 500);
} else {
log.debug('Script unchanged. No reload needed.');
}
}
/**
* Remove the integration from workbench.html.
*
* ⚠️ Requires Antigravity restart to take effect.
*/
async uninstall(): Promise<void> {
this._patcher.uninstall();
this._integrity.releaseCheck();
this._patcher.removeHeartbeat();
this.disableAutoRepair();
log.info('Uninstalled integration. Restart Antigravity to apply.');
}
/**
* Check if an integration is currently installed.
*/
isInstalled(): boolean {
return this._patcher.isInstalled();
}
/**
* Signal that the extension is active.
*
* Call this in your extension's `activate()` function.
* The integration script checks for this heartbeat;
* if it's missing or stale (>48h), the script won't start.
*
* This prevents orphaned integrations from running after
* an extension is disabled or uninstalled.
*
* @example
* ```typescript
* export function activate(context: vscode.ExtensionContext) {
* const sdk = new AntigravitySDK(context);
* sdk.integration.signalActive();
* // ...
* }
* ```
*/
signalActive(): void {
this._patcher.writeHeartbeat();
log.debug('Heartbeat refreshed');
}
// ─── Dynamic Update ─────────────────────────────────────────────────
/**
* Re-generate and overwrite the integration script without re-patching workbench.html.
*
* Use this after registering/unregistering integration points at runtime.
* The script file is updated in-place; the next Antigravity restart
* will pick up the changes. workbench.html <script> tag is unchanged.
*
* @returns true if script was updated
*/
updateScript(): boolean {
if (!this._patcher.isInstalled()) {
log.warn('Cannot update script — integration is not installed');
return false;
}
try {
const script = this.build();
fs.writeFileSync(this._patcher.getScriptPath(), script, 'utf8');
log.info(`Script updated (${this._configs.size} points)`);
return true;
} catch (err) {
log.error('Failed to update script', err);
return false;
}
}
// ─── Auto-Repair ────────────────────────────────────────────────────
/**
* Enable auto-repair: watches workbench.html for changes
* and automatically re-applies the integration patch.
*
* This handles Antigravity updates that overwrite workbench.html.
* The watcher detects when the file changes and re-patches it
* if the integration marker is missing.
*
* @example
* ```typescript
* const integrator = new IntegrationManager();
* integrator.useDemoPreset();
* await integrator.install();
* integrator.enableAutoRepair(); // Survive Antigravity updates
* ```
*/
enableAutoRepair(): void {
if (this._watcher) return;
const htmlPath = this._patcher.getWorkbenchDir() + '\\workbench.html';
if (!fs.existsSync(htmlPath)) {
log.warn('Cannot enable auto-repair — workbench.html not found');
return;
}
try {
this._watcher = fs.watch(htmlPath, (eventType) => {
if (eventType !== 'change') return;
// Debounce — Antigravity may write multiple times
if (this._autoRepairDebounce) clearTimeout(this._autoRepairDebounce);
this._autoRepairDebounce = setTimeout(() => {
this._tryRepair();
}, 2000);
});
log.info('Auto-repair enabled — watching workbench.html');
} catch (err) {
log.error('Failed to enable auto-repair', err);
}
}
/**
* Disable auto-repair watcher.
*/
disableAutoRepair(): void {
if (this._watcher) {
this._watcher.close();
this._watcher = null;
log.info('Auto-repair disabled');
}
if (this._autoRepairDebounce) {
clearTimeout(this._autoRepairDebounce);
this._autoRepairDebounce = null;
}
}
/**
* Whether auto-repair is active.
*/
get isAutoRepairEnabled(): boolean {
return this._watcher !== null;
}
private _tryRepair(): void {
try {
if (this._patcher.isInstalled()) {
log.debug('Auto-repair: integration still present, no action needed');
return;
}
if (this._configs.size === 0) {
log.debug('Auto-repair: no configs registered, skipping');
return;
}
log.info('Auto-repair: integration lost (Antigravity update?), re-patching...');
const script = this.build();
this._patcher.install(script);
this._integrity.repair();
log.info('Auto-repair: re-patched successfully. Restart Antigravity.');
} catch (err) {
log.error('Auto-repair failed', err);
}
}
// ─── Preset ────────────────────────────────────────────────────────
/**
* Register the Demo preset — a complete demo of all 9 integration points.
* Useful for testing and as a reference implementation.
*/
useDemoPreset(): this {
this.addTopBarButton('demo_overview', '\u{1F4E1}', 'SDK: Session Overview', {
title: 'Session Overview',
badge: { text: 'TOP_BAR', bgColor: 'rgba(79,195,247,.2)', textColor: '#4fc3f7' },
rows: [
{ key: 'location:', value: 'Header icon bar' },
{ key: 'use case:', value: 'Session overview, navigation' },
],
});
this.addTopRightButton('demo_perf', '\u26A1', 'SDK: Performance', {
title: 'Performance',
badge: { text: 'TOP_RIGHT', bgColor: 'rgba(255,193,7,.2)', textColor: '#ffd54f' },
rows: [
{ key: 'location:', value: 'Top right, before close' },
{ key: 'use case:', value: 'Status indicator' },
],
});
this.addInputButton('demo_stats', '\u{1F4CA}', 'SDK: Stats', {
title: 'Input Stats',
badge: { text: 'INPUT_AREA', bgColor: 'rgba(76,175,80,.2)', textColor: '#81c784' },
rows: [
{ key: 'location:', value: 'Next to send button' },
{ key: 'use case:', value: 'Token counter, analytics' },
],
});
this.addBottomIcon('demo_actions', '\u2630', 'SDK: Quick Actions', {
title: 'Quick Actions',
badge: { text: 'BOTTOM_ICONS', bgColor: 'rgba(255,152,0,.2)', textColor: '#ffb74d' },
rows: [
{ key: 'location:', value: 'Bottom icon row' },
{ key: 'use case:', value: 'Mode switches, quick actions' },
],
});
this.addTurnMetadata('demo_turns', [
'turnNumber',
'userCharCount',
'separator',
'aiCharCount',
'codeBlocks',
'thinkingIndicator',
]);
this.addUserBadges('demo_ubadge', 'charCount');
this.addBotAction('demo_inspect', '\u{1F50D}', 'inspect', {
title: 'Response Inspector',
badge: { text: 'BOT_ACTION', bgColor: 'rgba(156,39,176,.2)', textColor: '#ce93d8' },
rows: [
{ key: 'location:', value: 'Next to Good/Bad' },
{ key: 'use case:', value: 'Response analysis' },
],
});
this.addDropdownItem('demo_menu_stats', 'SDK Stats', '\u{1F4CA}', {
title: 'Extended Stats',
badge: { text: 'DROPDOWN', bgColor: 'rgba(233,30,99,.2)', textColor: '#f48fb1' },
rows: [
{ key: 'location:', value: '3-dot dropdown menu' },
{ key: 'use case:', value: 'Extended actions' },
],
}, true);
this.addDropdownItem('demo_menu_debug', 'SDK Debug', '\u{1F9EA}', {
title: 'Debug Info',
badge: { text: 'DEBUG', bgColor: 'rgba(255,87,34,.2)', textColor: '#ff8a65' },
rows: [
{ key: 'location:', value: '3-dot dropdown menu' },
{ key: 'use case:', value: 'Debug, diagnostics' },
],
});
this.addTitleInteraction('demo_title', 'dblclick', 'dblclick', {
title: 'Chat Title',
badge: { text: 'TITLE', bgColor: 'rgba(0,150,136,.2)', textColor: '#80cbc4' },
rows: [
{ key: 'location:', value: 'Conversation title' },
{ key: 'use case:', value: 'Rename, bookmark' },
],
});
return this;
}
// ─── Dispose ───────────────────────────────────────────────────────
dispose(): void {
this.disableAutoRepair();
this._configs.clear();
this._titles.dispose();
}
}

View File

@@ -0,0 +1,270 @@
/**
* Integrity Manager — Suppress Antigravity's "corrupt installation" warnings.
*
* When the SDK patches workbench files, Antigravity's IntegrityService detects
* checksum mismatches and shows two warnings:
* 1. Console WARN ("Installation has been modified on disk")
* 2. UI Notification ("Your Antigravity installation appears to be corrupt")
*
* This class updates ALL mismatched SHA256 hashes in product.json, so
* IntegrityService sees isPure=true and produces no warnings at all.
*
* Handles not just workbench.html but also workbench.desktop.main.js (auto-run fix),
* workbench-jetski-agent.html (agent manager patching), and any other modified files.
*
* Multi-extension coordination: a registry file (.ag-sdk-integrity.json)
* in the workbench directory tracks active SDK namespaces and the original
* hashes, so the last extension to uninstall restores the original state.
*
* @module integration/integrity-manager
*
* @internal
*/
import * as crypto from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import { Logger } from '../core/logger';
const log = new Logger('IntegrityManager');
/** Coordination registry stored in the workbench directory. */
interface IIntegrityRegistry {
/** Active SDK namespace slugs. */
namespaces: string[];
/** Original product.json hashes for ALL checksummed files (before any patching). */
originalHashes: Record<string, string>;
}
/** Registry filename — lives next to workbench.html. */
const REGISTRY_FILENAME = '.ag-sdk-integrity.json';
/**
* Manages integrity check suppression for Antigravity's IntegrityService.
*
* Call `suppressCheck()` after any file patching (workbench.html, main.js, etc.).
* It scans ALL files listed in product.json checksums, recomputes hashes for
* any that have changed, and updates product.json. IntegrityService will see
* `isPure = true` on next restart, producing zero warnings.
*/
export class IntegrityManager {
private readonly _productJsonPath: string;
private readonly _appOutDir: string;
private readonly _registryPath: string;
private readonly _namespace: string;
/**
* @param workbenchDir — Absolute path to the workbench directory
* (e.g. `%LOCALAPPDATA%/Programs/Antigravity/resources/app/out/vs/code/electron-browser/workbench/`)
* @param namespace — Unique slug for this extension (e.g. 'kanezal-better-antigravity')
*/
constructor(workbenchDir: string, namespace: string) {
this._namespace = namespace;
this._registryPath = path.join(workbenchDir, REGISTRY_FILENAME);
// product.json is at resources/app/product.json
// workbenchDir is resources/app/out/vs/code/electron-browser/workbench/
const appDir = path.resolve(workbenchDir, '..', '..', '..', '..', '..');
this._productJsonPath = path.join(appDir, 'product.json');
this._appOutDir = path.join(appDir, 'out');
}
/**
* Suppress the integrity check by updating ALL mismatched hashes in product.json.
*
* Scans every file listed in product.json checksums, recomputes SHA256 for each,
* and updates any that have changed. This handles not just workbench.html but also
* workbench.desktop.main.js (auto-run fix), jetskiAgent files, etc.
*
* Call this after any file patching. Safe to call multiple times.
*/
suppressCheck(): void {
try {
if (!fs.existsSync(this._productJsonPath)) {
log.warn(`product.json not found at ${this._productJsonPath}`);
return;
}
const productJson = JSON.parse(fs.readFileSync(this._productJsonPath, 'utf8'));
if (!productJson.checksums) {
log.debug('No checksums in product.json — nothing to update');
return;
}
// 1. Load or create registry, register this namespace
const registry = this._readRegistry();
if (!registry.namespaces.includes(this._namespace)) {
registry.namespaces.push(this._namespace);
}
// 2. Scan ALL checksummed files, save originals & update mismatches
let updatedCount = 0;
for (const [relPath, storedHash] of Object.entries(productJson.checksums) as [string, string][]) {
const filePath = path.join(this._appOutDir, relPath);
let actualHash: string;
try {
const content = fs.readFileSync(filePath);
actualHash = this._computeHash(content);
} catch {
// File not found — skip (don't break other checks)
continue;
}
if (actualHash !== storedHash) {
// Save original hash if we haven't already
if (!(relPath in registry.originalHashes)) {
registry.originalHashes[relPath] = storedHash;
log.debug(`Saved original hash for ${relPath}`);
}
productJson.checksums[relPath] = actualHash;
updatedCount++;
log.info(`Updated hash: ${relPath} (${storedHash.substring(0, 8)}... -> ${actualHash.substring(0, 8)}...)`);
}
}
// 3. Write registry
this._writeRegistry(registry);
// 4. Write product.json if anything changed
if (updatedCount > 0) {
fs.writeFileSync(this._productJsonPath, JSON.stringify(productJson, null, '\t'), 'utf8');
log.info(`Updated ${updatedCount} hash(es) in product.json`);
} else {
log.debug('All hashes already match — no update needed');
}
} catch (err) {
log.error('Failed to suppress integrity check', err);
}
}
/**
* Release the integrity check suppression.
*
* Call this when uninstalling the integration. If no other SDK namespaces
* remain active, restores all original hashes in product.json.
*/
releaseCheck(): void {
try {
const registry = this._readRegistry();
// Remove this namespace
registry.namespaces = registry.namespaces.filter(ns => ns !== this._namespace);
this._writeRegistry(registry);
if (registry.namespaces.length > 0) {
// Other SDK extensions still active — recompute all hashes
log.debug(`${registry.namespaces.length} other namespace(s) still active, recomputing hashes`);
this.suppressCheck();
return;
}
// Last extension uninstalling — restore ALL original hashes
if (Object.keys(registry.originalHashes).length > 0) {
this._restoreOriginalHashes(registry.originalHashes);
log.info(`Restored ${Object.keys(registry.originalHashes).length} original hash(es)`);
}
// Clean up registry file
this._deleteRegistry();
} catch (err) {
log.error('Failed to release integrity check', err);
}
}
/**
* Re-apply integrity suppression after auto-repair.
*
* Call this after auto-repair has re-patched files
* (e.g. after an AG update that overwrote workbench files).
*/
repair(): void {
log.info('Repairing integrity check suppression...');
this.suppressCheck();
}
// ── Private helpers ─────────────────────────────────────────────
/**
* Compute SHA256 hash matching Antigravity's ChecksumService format:
* base64 WITHOUT trailing '=' padding.
*/
private _computeHash(content: Buffer): string {
return crypto.createHash('sha256')
.update(content)
.digest('base64')
.replace(/=+$/, '');
}
/**
* Restore all original hashes in product.json.
*/
private _restoreOriginalHashes(originalHashes: Record<string, string>): void {
if (!fs.existsSync(this._productJsonPath)) return;
const productJson = JSON.parse(fs.readFileSync(this._productJsonPath, 'utf8'));
if (!productJson.checksums) return;
for (const [relPath, hash] of Object.entries(originalHashes)) {
if (relPath in productJson.checksums) {
productJson.checksums[relPath] = hash;
}
}
fs.writeFileSync(this._productJsonPath, JSON.stringify(productJson, null, '\t'), 'utf8');
}
/**
* Read the coordination registry from disk.
*/
private _readRegistry(): IIntegrityRegistry {
try {
if (fs.existsSync(this._registryPath)) {
const raw = fs.readFileSync(this._registryPath, 'utf8');
const data = JSON.parse(raw);
// Migrate from old format (single originalHash) to new (originalHashes map)
let originalHashes: Record<string, string> = {};
if (data.originalHashes && typeof data.originalHashes === 'object') {
originalHashes = data.originalHashes;
} else if (typeof data.originalHash === 'string') {
// Legacy v1.5.0 format: single hash for workbench.html
originalHashes['vs/code/electron-browser/workbench/workbench.html'] = data.originalHash;
}
return {
namespaces: Array.isArray(data.namespaces) ? data.namespaces : [],
originalHashes,
};
}
} catch {
// Corrupt or inaccessible — start fresh
}
return { namespaces: [], originalHashes: {} };
}
/**
* Write the coordination registry to disk.
*/
private _writeRegistry(registry: IIntegrityRegistry): void {
try {
fs.writeFileSync(this._registryPath, JSON.stringify(registry, null, 2), 'utf8');
} catch (err) {
log.error('Failed to write integrity registry', err);
}
}
/**
* Delete the coordination registry file.
*/
private _deleteRegistry(): void {
try {
if (fs.existsSync(this._registryPath)) {
fs.unlinkSync(this._registryPath);
}
} catch {
// Ignore
}
}
}

View File

@@ -0,0 +1,554 @@
/**
* Script Generator — Builds self-contained JS from integration configs.
*
* Generates a Trusted Types-safe integration script that:
* - Uses ONLY createElement/textContent (no innerHTML)
* - Uses MutationObserver for dynamic content
* - Is fully self-contained (runs in renderer, no Node.js APIs)
*
* @module integration/script-generator
*
* @internal
*/
import { Selectors, AG_PREFIX, AG_DATA_ATTR } from './selectors';
import {
IntegrationConfig,
IntegrationPoint,
IToastConfig,
IToastRow,
TurnMetric,
IButtonIntegration,
ITurnMetaIntegration,
IUserBadgeIntegration,
IBotActionIntegration,
IDropdownIntegration,
ITitleIntegration,
} from './types';
/**
* Generates a self-contained JavaScript integration script
* from an array of IntegrationConfig objects.
*/
export class ScriptGenerator {
/**
* Generate the complete integration script.
*
* @param configs — Registered integration configurations
* @returns — Complete JS code as a string
*/
generate(configs: IntegrationConfig[]): string {
const parts: string[] = [];
parts.push(this._header());
parts.push(this._css(configs));
parts.push(this._helpers());
parts.push(this._toast());
parts.push(this._stats());
// Generate code for each integration point
const grouped = this._groupByPoint(configs);
for (const [point, cfgs] of Object.entries(grouped)) {
parts.push(this._generatePoint(point as IntegrationPoint, cfgs));
}
parts.push(this._mainLoop(Object.keys(grouped) as IntegrationPoint[]));
parts.push(this._footer());
return parts.join('\n');
}
// ─── Grouping ──────────────────────────────────────────────────────
private _groupByPoint(configs: IntegrationConfig[]): Record<string, IntegrationConfig[]> {
const groups: Record<string, IntegrationConfig[]> = {};
for (const c of configs) {
if (c.enabled === false) continue;
if (!groups[c.point]) groups[c.point] = [];
groups[c.point].push(c);
}
return groups;
}
// ─── Code Sections ────────────────────────────────────────────────
private _header(): string {
return `(function agSDK(){
'use strict';
if(window.__agSDK)return;
window.__agSDK=true;
// ─── Theme Detection ───
var _isDark=document.body.classList.contains('vscode-dark')||document.body.classList.contains('vscode-high-contrast');
var _theme={
bg:_isDark?'rgba(25,25,30,.95)':'rgba(245,245,250,.95)',
fg:_isDark?'#ccc':'#333',
fgDim:_isDark?'rgba(200,200,200,.45)':'rgba(80,80,80,.5)',
fgHover:_isDark?'rgba(200,200,200,.8)':'rgba(40,40,40,.9)',
accent:_isDark?'#4fc3f7':'#0288d1',
accentBg:_isDark?'rgba(79,195,247,.12)':'rgba(2,136,209,.08)',
success:_isDark?'#81c784':'#388e3c',
successBg:_isDark?'rgba(76,175,80,.1)':'rgba(56,142,60,.06)',
warn:_isDark?'#ffb74d':'#e65100',
border:_isDark?'rgba(79,195,247,.06)':'rgba(0,0,0,.06)',
borderHover:_isDark?'rgba(79,195,247,.2)':'rgba(2,136,209,.15)',
sep:_isDark?'rgba(255,255,255,.06)':'rgba(0,0,0,.06)',
shadow:_isDark?'rgba(0,0,0,.5)':'rgba(0,0,0,.15)',
metaBg:_isDark?'linear-gradient(135deg,rgba(79,195,247,.03),rgba(156,39,176,.02))':'linear-gradient(135deg,rgba(2,136,209,.03),rgba(123,31,162,.02))',
metaBgHover:_isDark?'linear-gradient(135deg,rgba(79,195,247,.07),rgba(156,39,176,.05))':'linear-gradient(135deg,rgba(2,136,209,.07),rgba(123,31,162,.05))'
};
// Watch for theme changes (VS Code toggles body classes)
new MutationObserver(function(){var newDark=document.body.classList.contains('vscode-dark');if(newDark!==_isDark){location.reload();}}).observe(document.body,{attributes:true,attributeFilter:['class']});
`;
}
private _footer(): string {
// The heartbeat file is in the same directory as the script.
// We use sync XHR (allowed in renderer since we're in a script tag,
// not a module) to check the file before starting.
// Max age: 48 hours (172800000ms) — enough to survive normal restarts
// but catches disabled extensions reliably.
return `
var _heartbeatMaxAge=172800000;
function checkHeartbeat(){
try{
var xhr=new XMLHttpRequest();
xhr.open('GET','./ag-sdk-heartbeat?t='+Date.now(),false);
xhr.send();
if(xhr.status!==200)return false;
var ts=parseInt(xhr.responseText,10);
if(isNaN(ts))return false;
return(Date.now()-ts)<_heartbeatMaxAge;
}catch(e){return false;}
}
function boot(){
if(!checkHeartbeat()){
console.log('[AG SDK] Heartbeat missing or stale — extension disabled? Skipping.');
return;
}
if(document.readyState==='complete')setTimeout(start,3000);
else window.addEventListener('load',function(){setTimeout(start,3000);});
}
boot();
})();`;
}
private _css(configs: IntegrationConfig[]): string {
// Only include CSS for points that are actually used
const points = new Set(configs.map(c => c.point));
// All colors now use _theme variables for light/dark mode support
// CSS is generated as a JS template that reads _theme at runtime
return `
// ─── Theme-Aware CSS ───
var _cssRules=[
'.${AG_PREFIX}meta{padding:3px 8px;background:'+_theme.metaBg+';border-top:1px solid '+_theme.border+';font-family:"Cascadia Code","Fira Code",monospace;font-size:9px;color:'+_theme.fgDim+';display:flex;align-items:center;gap:5px;flex-wrap:wrap;transition:all .2s;cursor:default;user-select:none;margin-top:2px;border-radius:0 0 6px 6px}',
'.${AG_PREFIX}meta:hover{background:'+_theme.metaBgHover+';color:'+_theme.fgHover+'}',
'.${AG_PREFIX}t{padding:1px 4px;border-radius:3px;font-size:8px;font-weight:700;letter-spacing:.3px}',
'.${AG_PREFIX}u{background:'+_theme.successBg+';color:'+_theme.success+'}',
'.${AG_PREFIX}b{background:'+_theme.accentBg+';color:'+_theme.accent+'}',
'.${AG_PREFIX}k{color:'+_theme.fgDim+';font-size:8px}',
'.${AG_PREFIX}v{color:'+_theme.fg+';font-size:8px;opacity:.55}',
'.${AG_PREFIX}hi{color:'+_theme.accent+'}',
'.${AG_PREFIX}w{color:'+_theme.warn+'}',
'.${AG_PREFIX}s{color:'+_theme.sep+'}',
// Toast
'.${AG_PREFIX}toast{position:fixed;bottom:80px;right:20px;background:'+_theme.bg+';border:1px solid '+_theme.borderHover+';border-radius:8px;padding:10px 14px;font-family:"Cascadia Code",monospace;font-size:10px;color:'+_theme.fg+';z-index:99999;max-width:320px;backdrop-filter:blur(10px);box-shadow:0 4px 24px '+_theme.shadow+';animation:${AG_PREFIX}fade .25s ease}',
'@keyframes ${AG_PREFIX}fade{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}',
'.${AG_PREFIX}toast-t{color:'+_theme.accent+';font-weight:700;margin-bottom:5px;font-size:11px;display:flex;align-items:center;gap:6px}',
'.${AG_PREFIX}toast-r{display:flex;gap:8px;margin:1px 0}',
'.${AG_PREFIX}toast-k{color:'+_theme.fgDim+';min-width:70px}',
'.${AG_PREFIX}toast-v{color:'+_theme.fg+'}',
'.${AG_PREFIX}toast-badge{font-size:8px;padding:1px 5px;border-radius:3px;font-weight:700}',
// Buttons
'.${AG_PREFIX}hdr{display:inline-flex;align-items:center;gap:3px;padding:1px 6px;border-radius:4px;cursor:pointer;color:'+_theme.fgDim+';font-size:9px;font-family:"Cascadia Code",monospace;transition:all .15s;user-select:none}',
'.${AG_PREFIX}hdr:hover{background:'+_theme.accentBg+';color:'+_theme.accent+'}',
'.${AG_PREFIX}inp{display:inline-flex;align-items:center;justify-content:center;min-width:18px;height:18px;border-radius:4px;cursor:pointer;color:'+_theme.fgDim+';font-size:11px;transition:all .15s;flex-shrink:0;padding:0 4px;font-family:"Cascadia Code",monospace}',
'.${AG_PREFIX}inp:hover{background:'+_theme.accentBg+';color:'+_theme.accent+'}',
'.${AG_PREFIX}menu{padding:4px 8px;cursor:pointer;font-size:11px;color:'+_theme.fg+';opacity:.7;transition:all .12s;display:flex;align-items:center;gap:6px;white-space:nowrap}',
'.${AG_PREFIX}menu:hover{background:'+_theme.accentBg+';color:'+_theme.accent+';opacity:1}',
'.${AG_PREFIX}vote{display:inline-flex;align-items:center;gap:3px;padding:1px 6px;border-radius:3px;cursor:pointer;color:'+_theme.fgDim+';font-size:9px;font-family:"Cascadia Code",monospace;transition:all .15s;margin-left:4px}',
'.${AG_PREFIX}vote:hover{background:'+_theme.accentBg+';color:'+_theme.accent+'}',
'.${AG_PREFIX}ubadge{display:inline-flex;align-items:center;gap:2px;padding:1px 5px;border-radius:3px;background:'+_theme.successBg+';cursor:pointer;color:'+_theme.success+';opacity:.4;font-size:8px;font-family:"Cascadia Code",monospace;transition:all .15s;margin-left:3px}',
'.${AG_PREFIX}ubadge:hover{background:'+_theme.successBg+';color:'+_theme.success+';opacity:1}',
'.${AG_PREFIX}title-hint{position:absolute;right:0;top:50%;transform:translateY(-50%);font-size:8px;color:'+_theme.accent+';opacity:.3;pointer-events:none;font-family:"Cascadia Code",monospace;transition:opacity .2s}',
'.${AG_PREFIX}title-wrap:hover .${AG_PREFIX}title-hint{opacity:1}'
];
var css=document.createElement('style');
css.textContent=_cssRules.join('\\n');
document.head.appendChild(css);
`;
}
private _helpers(): string {
return `
function mk(tag,cls,txt){var e=document.createElement(tag);if(cls)e.className=cls;if(txt!==undefined)e.textContent=txt;return e;}
function fmt(n){return n>=1000?(n/1000).toFixed(1)+'k':''+n;}
`;
}
private _toast(): string {
return `
var _toastT=0;
function toast(title,badge,rows){
var old=document.querySelector('.${AG_PREFIX}toast');if(old)old.remove();
var t=mk('div','${AG_PREFIX}toast');
var hdr=mk('div','${AG_PREFIX}toast-t');
hdr.appendChild(document.createTextNode(title));
if(badge){var b=mk('span','${AG_PREFIX}toast-badge');b.textContent=badge[0];b.style.background=badge[1];b.style.color=badge[2];hdr.appendChild(b);}
t.appendChild(hdr);
rows.forEach(function(r){var row=mk('div','${AG_PREFIX}toast-r');row.appendChild(mk('span','${AG_PREFIX}toast-k',r[0]));row.appendChild(mk('span','${AG_PREFIX}toast-v',r[1]));t.appendChild(row);});
document.body.appendChild(t);
clearTimeout(_toastT);_toastT=setTimeout(function(){if(t.parentNode)t.remove();},6000);
t.addEventListener('click',function(){t.remove();});
}
`;
}
private _stats(): string {
return `
function getStats(){
var c=document.querySelector(${JSON.stringify(Selectors.TURNS_CONTAINER)});
if(!c)return null;
var turns=0,uC=0,bC=0,code=0;
Array.from(c.children).forEach(function(ch){
if(ch.getAttribute('${AG_DATA_ATTR}')||ch.children.length<1)return;
turns++;
uC+=(ch.children[0]?.textContent?.trim()||'').length;
bC+=(ch.children[1]?.textContent?.trim()||'').length;
code+=(ch.children[1]?.querySelectorAll('pre')?.length||0);
});
return{turns:turns,u:uC,b:bC,code:code};
}
`;
}
// ─── Point generators ─────────────────────────────────────────────
private _generatePoint(point: IntegrationPoint, configs: IntegrationConfig[]): string {
switch (point) {
case IntegrationPoint.TOP_BAR:
return this._genTopBar(configs as IButtonIntegration[]);
case IntegrationPoint.TOP_RIGHT:
return this._genTopRight(configs as IButtonIntegration[]);
case IntegrationPoint.INPUT_AREA:
return this._genInputArea(configs as IButtonIntegration[]);
case IntegrationPoint.BOTTOM_ICONS:
return this._genBottomIcons(configs as IButtonIntegration[]);
case IntegrationPoint.TURN_METADATA:
return this._genTurnMeta(configs as ITurnMetaIntegration[]);
case IntegrationPoint.USER_BADGE:
return this._genUserBadge(configs as IUserBadgeIntegration[]);
case IntegrationPoint.BOT_ACTION:
return this._genBotAction(configs as IBotActionIntegration[]);
case IntegrationPoint.DROPDOWN_MENU:
return this._genDropdown(configs as IDropdownIntegration[]);
case IntegrationPoint.CHAT_TITLE:
return this._genTitle(configs as ITitleIntegration[]);
default:
return `// Unknown point: ${point}`;
}
}
private _genToastCall(toast?: IToastConfig): string {
if (!toast) return '';
const badge = toast.badge
? `[${JSON.stringify(toast.badge.text)},${JSON.stringify(toast.badge.bgColor)},${JSON.stringify(toast.badge.textColor)}]`
: 'null';
const rows = toast.rows
.map(r => {
if (r.dynamic) {
return `[${JSON.stringify(r.key)},${r.value}]`;
}
return `[${JSON.stringify(r.key)},${JSON.stringify(r.value)}]`;
})
.join(',');
return `toast(${JSON.stringify(toast.title)},${badge},[${rows}]);`;
}
private _genTopBar(configs: IButtonIntegration[]): string {
const buttons = configs.map(c => {
const toastCall = this._genToastCall(c.toast);
return ` var btn_${c.id}=mk('a','${AG_PREFIX}hdr ${AG_PREFIX}${c.id}');
btn_${c.id}.textContent=${JSON.stringify(c.icon)};
btn_${c.id}.title=${JSON.stringify(c.tooltip || '')};
btn_${c.id}.addEventListener('click',function(){${toastCall}});
iconsArea.insertBefore(btn_${c.id},iconsArea.children[1]);`;
});
return `
function integrateTopBar(){
var p=document.querySelector(${JSON.stringify(Selectors.PANEL)});if(!p)return;
var topBar=p.querySelector(${JSON.stringify(Selectors.TOP_BAR)});if(!topBar)return;
var iconsArea=topBar.querySelector(${JSON.stringify(Selectors.TOP_ICONS)});
if(!iconsArea||iconsArea.querySelector('.${AG_PREFIX}${configs[0].id}'))return;
${buttons.join('\n')}
}
`;
}
private _genTopRight(configs: IButtonIntegration[]): string {
const buttons = configs.map(c => {
const toastCall = this._genToastCall(c.toast);
return ` var btn_${c.id}=mk('a','${AG_PREFIX}hdr ${AG_PREFIX}${c.id}');
btn_${c.id}.textContent=${JSON.stringify(c.icon)};
btn_${c.id}.title=${JSON.stringify(c.tooltip || '')};
btn_${c.id}.addEventListener('click',function(){${toastCall}});
iconsArea.insertBefore(btn_${c.id},iconsArea.lastElementChild);`;
});
return `
function integrateTopRight(){
var p=document.querySelector(${JSON.stringify(Selectors.PANEL)});if(!p)return;
var topBar=p.querySelector(${JSON.stringify(Selectors.TOP_BAR)});if(!topBar)return;
var iconsArea=topBar.querySelector(${JSON.stringify(Selectors.TOP_ICONS)});
if(!iconsArea||iconsArea.querySelector('.${AG_PREFIX}${configs[0].id}'))return;
${buttons.join('\n')}
}
`;
}
private _genInputArea(configs: IButtonIntegration[]): string {
const buttons = configs.map(c => {
const toastCall = this._genToastCall(c.toast);
return ` var btn=mk('div','${AG_PREFIX}inp ${AG_PREFIX}${c.id}');
btn.textContent=${JSON.stringify(c.icon)};
btn.title=${JSON.stringify(c.tooltip || '')};
btn.addEventListener('click',function(){${toastCall}});
btnRow.insertBefore(btn,btnRow.firstChild);`;
});
return `
function integrateInputArea(){
var ib=document.querySelector(${JSON.stringify(Selectors.INPUT_BOX)});
if(!ib||ib.querySelector('.${AG_PREFIX}${configs[0].id}'))return;
var allBtns=ib.querySelectorAll('button,[role="button"]');
if(allBtns.length===0)return;
var btnRow=allBtns[allBtns.length-1].parentElement;if(!btnRow)return;
${buttons.join('\n')}
}
`;
}
private _genBottomIcons(configs: IButtonIntegration[]): string {
const buttons = configs.map(c => {
const toastCall = this._genToastCall(c.toast);
return ` var btn=mk('div','${AG_PREFIX}inp ${AG_PREFIX}${c.id}');
btn.textContent=${JSON.stringify(c.icon)};
btn.title=${JSON.stringify(c.tooltip || '')};
btn.addEventListener('click',function(){${toastCall}});
row.appendChild(btn);`;
});
return `
function integrateBottomIcons(){
var ib=document.querySelector(${JSON.stringify(Selectors.INPUT_BOX)});
if(!ib||ib.querySelector('.${AG_PREFIX}${configs[0].id}'))return;
var rows=ib.querySelectorAll('.flex.items-center');
var row=null;
for(var i=0;i<rows.length;i++){if(rows[i].querySelectorAll('svg').length>=2){row=rows[i];}}
if(!row)return;
${buttons.join('\n')}
}
`;
}
private _genTurnMeta(configs: ITurnMetaIntegration[]): string {
// Take first config for metrics (single turn metadata style)
const cfg = configs[0];
const metricParts: string[] = [];
for (const m of cfg.metrics) {
switch (m) {
case 'turnNumber':
metricParts.push(`meta.appendChild(mk('span','${AG_PREFIX}t ${AG_PREFIX}b','T'+tI));`);
break;
case 'userCharCount':
metricParts.push(`if(uL>0){meta.appendChild(mk('span','${AG_PREFIX}t ${AG_PREFIX}u','USER'));meta.appendChild(mk('span','${AG_PREFIX}k',fmt(uL)));}`);
break;
case 'separator':
metricParts.push(`if(uL>0&&bL>0)meta.appendChild(mk('span','${AG_PREFIX}s','\\u2502'));`);
break;
case 'aiCharCount':
metricParts.push(`if(bL>0){meta.appendChild(mk('span','${AG_PREFIX}t ${AG_PREFIX}b','AI'));meta.appendChild(mk('span','${AG_PREFIX}k',fmt(bL)));}`);
break;
case 'codeBlocks':
metricParts.push(`if(codes>0){meta.appendChild(mk('span','${AG_PREFIX}k','code:'));meta.appendChild(mk('span','${AG_PREFIX}v ${AG_PREFIX}w',''+codes));}`);
break;
case 'thinkingIndicator':
metricParts.push(`if(brain)meta.appendChild(mk('span','${AG_PREFIX}v','\\u{1F9E0}'));`);
break;
case 'ratio':
metricParts.push(`if(uL>0&&bL>0){meta.appendChild(mk('span','${AG_PREFIX}k',(bL/uL).toFixed(1)+'x'));}`);
break;
}
}
const clickHandler = cfg.clickable !== false
? `meta.addEventListener('click',function(){toast('Turn '+tI,null,[['user:',fmt(uL)],['AI:',fmt(bL)],['ratio:',uL>0?(bL/uL).toFixed(1)+'x':'\\u2014']]);});`
: '';
return `
function integrateTurnMeta(){
var c=document.querySelector(${JSON.stringify(Selectors.TURNS_CONTAINER)});if(!c)return;
var tI=0;
Array.from(c.children).forEach(function(turn){
if(turn.getAttribute('${AG_DATA_ATTR}')||turn.children.length<1)return;
turn.setAttribute('${AG_DATA_ATTR}','1');
tI++;var uL=(turn.children[0]?.textContent?.trim()||'').length;
var bL=(turn.children[1]?.textContent?.trim()||'').length;
if(uL===0&&bL===0)return;
var codes=turn.children[1]?.querySelectorAll('pre')?.length||0;
var brain=(turn.children[1]?.textContent||'').includes('Thought');
var meta=mk('div','${AG_PREFIX}meta');
${metricParts.join('\n ')}
${clickHandler}
turn.appendChild(meta);
});
}
`;
}
private _genUserBadge(configs: IUserBadgeIntegration[]): string {
const cfg = configs[0];
let displayExpr = 'fmt(uLen)+" ch"';
if (cfg.display === 'wordCount') {
displayExpr = '(txt.split(/\\\\s+/).length)+" w"';
} else if (cfg.display === 'custom' && cfg.customFormat) {
displayExpr = cfg.customFormat;
}
return `
function integrateUserBadges(){
var c=document.querySelector(${JSON.stringify(Selectors.TURNS_CONTAINER)});if(!c)return;
Array.from(c.children).forEach(function(turn,i){
if(turn.getAttribute('${AG_DATA_ATTR}u')||turn.children.length<1)return;
var bubble=turn.children[0]?.querySelector(${JSON.stringify(Selectors.USER_BUBBLE)});
if(!bubble)return;
var txt=turn.children[0]?.textContent?.trim()||'';
var uLen=txt.length;if(uLen<5)return;
turn.setAttribute('${AG_DATA_ATTR}u','1');
var row=turn.children[0]?.querySelector('.flex.w-full,.flex.flex-row')||turn.children[0];
var badge=mk('span','${AG_PREFIX}ubadge');
badge.textContent=${displayExpr};
badge.title='SDK: User message';
row.appendChild(badge);
});
}
`;
}
private _genBotAction(configs: IBotActionIntegration[]): string {
const items = configs.map(c => {
const toastCall = this._genToastCall(c.toast);
return `var b=mk('span','${AG_PREFIX}vote');b.textContent=${JSON.stringify(c.icon + ' ' + c.label)};
b.addEventListener('click',function(ev){ev.stopPropagation();${toastCall}});
row.appendChild(b);`;
});
return `
function integrateBotAction(){
var c=document.querySelector(${JSON.stringify(Selectors.TURNS_CONTAINER)});if(!c)return;
c.querySelectorAll('span,button,a,div').forEach(function(el){
if(el.getAttribute('${AG_DATA_ATTR}v'))return;
var txt=el.textContent?.trim();
if(txt==='Good'||txt==='Bad'){
var row=el.parentElement;if(!row||row.querySelector('.${AG_PREFIX}vote'))return;
el.setAttribute('${AG_DATA_ATTR}v','1');
${items.join('\n ')}
}
});
}
`;
}
private _genDropdown(configs: IDropdownIntegration[]): string {
const markers = JSON.stringify(Selectors.DROPDOWN_MARKER_TEXT);
const items = configs.map(c => {
const toastCall = this._genToastCall(c.toast);
const sep = c.separator
? `var sep=mk('div','');sep.style.cssText='height:1px;background:rgba(255,255,255,.06);margin:4px 8px';dd.appendChild(sep);`
: '';
return `${sep}
var mi=mk('div','${AG_PREFIX}menu');
${c.icon ? `mi.appendChild(mk('span','',${JSON.stringify(c.icon)}));` : ''}
mi.appendChild(document.createTextNode(${JSON.stringify(c.label)}));
mi.addEventListener('click',function(){${toastCall}});
dd.appendChild(mi);`;
});
return `
function integrateDropdown(){
var dds=document.querySelectorAll('.rounded-bg.py-1,.rounded-lg.py-1');
dds.forEach(function(dd){
if(dd.getAttribute('${AG_DATA_ATTR}m'))return;
var items=dd.querySelectorAll(${JSON.stringify(Selectors.DROPDOWN_ITEM)});
var markers=${markers};
var found=false;
items.forEach(function(it){markers.forEach(function(m){if((it.textContent||'').includes(m))found=true;});});
if(!found)return;
dd.setAttribute('${AG_DATA_ATTR}m','1');
${items.join('\n ')}
});
}
`;
}
private _genTitle(configs: ITitleIntegration[]): string {
const cfg = configs[0];
const toastCall = this._genToastCall(cfg.toast);
const event = cfg.interaction || 'dblclick';
return `
function integrateTitle(){
var p=document.querySelector(${JSON.stringify(Selectors.PANEL)});if(!p)return;
var el=p.querySelector(${JSON.stringify(Selectors.TITLE)});
if(!el||el.getAttribute('${AG_DATA_ATTR}t'))return;
el.setAttribute('${AG_DATA_ATTR}t','1');
el.style.cursor='pointer';
el.classList.add('${AG_PREFIX}title-wrap');
el.style.position='relative';
${cfg.hint ? `var hint=mk('span','${AG_PREFIX}title-hint',${JSON.stringify(cfg.hint)});el.appendChild(hint);` : ''}
el.addEventListener(${JSON.stringify(event)},function(){
var title=el.textContent?.replace(${JSON.stringify(cfg.hint || '')},'')?.trim()||'';
${toastCall || `toast('Chat',null,[['title:',title],['chars:',''+title.length]]);`}
});
}
`;
}
// ─── Main loop ────────────────────────────────────────────────────
private _mainLoop(points: IntegrationPoint[]): string {
const fnMap: Record<string, string> = {
[IntegrationPoint.TOP_BAR]: 'integrateTopBar',
[IntegrationPoint.TOP_RIGHT]: 'integrateTopRight',
[IntegrationPoint.INPUT_AREA]: 'integrateInputArea',
[IntegrationPoint.BOTTOM_ICONS]: 'integrateBottomIcons',
[IntegrationPoint.TURN_METADATA]: 'integrateTurnMeta',
[IntegrationPoint.USER_BADGE]: 'integrateUserBadges',
[IntegrationPoint.BOT_ACTION]: 'integrateBotAction',
[IntegrationPoint.DROPDOWN_MENU]: 'integrateDropdown',
[IntegrationPoint.CHAT_TITLE]: 'integrateTitle',
};
const calls = points.map(p => ` ${fnMap[p]}();`).join('\n');
return `
function fullScan(){
${calls}
}
var _timer=0;
function debounced(){clearTimeout(_timer);_timer=setTimeout(function(){requestAnimationFrame(fullScan);},400);}
function start(){
var p=document.querySelector(${JSON.stringify(Selectors.PANEL)});
if(!p){setTimeout(start,1000);return;}
fullScan();
new MutationObserver(debounced).observe(p,{childList:true,subtree:true});
setInterval(fullScan,8000);
console.log('[AG SDK] Active \\u2014 ${points.length} integration points');
}
`;
}
}

View File

@@ -0,0 +1,56 @@
/**
* DOM Selectors — Single source of truth for all Agent View selectors.
*
* Verified against Antigravity v1.107.0 DOM (2026-02-28).
* If Antigravity updates break selectors, only THIS file needs updating.
*
* @module integration/selectors
*
* @internal
*/
export const Selectors = {
/** The entire agent side panel container */
PANEL: '.antigravity-agent-side-panel',
/** Top bar with title and action icons */
TOP_BAR: '.flex.items-center.justify-between',
/** Icons area in top bar (contains +, refresh, ..., X) */
TOP_ICONS: '.flex.items-center.gap-2',
/** Chat title element */
TITLE: '.flex.min-w-0.items-center.overflow-hidden',
/** Main conversation scroll area */
CONVERSATION: '#conversation',
/** Message turns container (direct children are turns) */
TURNS_CONTAINER: '#conversation .gap-y-3',
/** User message bubble (inside turn) */
USER_BUBBLE: '.rounded-lg',
/** Input box container */
INPUT_BOX: '#antigravity\\.agentSidePanelInputBox',
/** 3-dot dropdown menu (appears dynamically) */
DROPDOWN_MARKER_TEXT: ['Customization', 'Export'],
/** Dropdown menu item class pattern */
DROPDOWN_ITEM: '.cursor-pointer',
/** Good/Bad feedback text markers */
FEEDBACK_MARKERS: ['Good', 'Bad'],
} as const;
/**
* CSS class prefixes used by SDK integrations.
* Used to identify and clean up integrated elements.
*/
export const AG_PREFIX = 'ag-';
/**
* Data attribute used to mark processed elements.
*/
export const AG_DATA_ATTR = 'data-ag-sdk';

View File

@@ -0,0 +1,171 @@
/**
* Title Manager — Extension-host API for managing chat titles.
*
* Allows extensions to programmatically rename conversations
* by writing to a data file that the renderer-side title proxy reads.
*
* Also provides a direct localStorage synchronization mechanism
* via the integration script's window.__agSDKTitles API.
*
* @module integration/title-manager
*
* @example
* ```typescript
* const sdk = new AntigravitySDK(context);
* await sdk.initialize();
*
* // Rename via extension host (writes data file, renderer picks up on next poll)
* sdk.titles.rename('cascade-uuid', 'My Custom Title');
*
* // Get all custom titles
* const titles = sdk.titles.getAll();
*
* // Remove a custom title (reverts to auto-generated summary)
* sdk.titles.remove('cascade-uuid');
* ```
*/
import * as fs from 'fs';
import * as path from 'path';
import { Logger } from '../core/logger';
import { IDisposable } from '../core/disposable';
import { getTitlesDataFile } from './title-proxy';
const log = new Logger('TitleManager');
/**
* Manages custom conversation titles from the extension host.
*
* Titles are persisted in a JSON file in the workbench directory.
* The renderer-side title proxy reads this file and merges with localStorage.
*/
export class TitleManager implements IDisposable {
private _titles: Record<string, string> = {};
private _dataPath: string = '';
private _initialized = false;
/**
* Initialize with the workbench directory path.
*
* @param workbenchDir - Path to workbench directory where data file is stored
* @param namespace - Extension namespace for file isolation
*/
initialize(workbenchDir: string, namespace: string = 'default'): void {
this._dataPath = path.join(workbenchDir, getTitlesDataFile(namespace));
this._load();
this._initialized = true;
log.info(`Initialized, ${Object.keys(this._titles).length} custom titles loaded`);
}
/**
* Check if the manager is initialized.
*/
get isInitialized(): boolean {
return this._initialized;
}
/**
* Set a custom title for a conversation.
*
* The title will be displayed in the Agent View title bar
* and conversation list instead of the auto-generated summary.
*
* @param cascadeId - The conversation's cascade ID (UUID)
* @param title - The custom title to display
*
* @example
* ```typescript
* // Rename the active conversation
* const id = sdk.titles.getActiveCascadeId();
* sdk.titles.rename(id, 'Project Alpha Discussion');
* ```
*/
rename(cascadeId: string, title: string): void {
if (!cascadeId) {
log.warn('rename: cascadeId is required');
return;
}
if (!title || !title.trim()) {
log.warn('rename: title cannot be empty');
return;
}
this._titles[cascadeId] = title.trim();
this._save();
log.debug(`Renamed ${cascadeId.substring(0, 8)}... -> "${title.trim()}"`);
}
/**
* Get the custom title for a conversation.
*
* @param cascadeId - The conversation's cascade ID
* @returns The custom title, or undefined if no custom title is set
*/
getTitle(cascadeId: string): string | undefined {
return this._titles[cascadeId];
}
/**
* Get all custom titles.
*
* @returns A copy of the titles map (cascadeId -> title)
*/
getAll(): Readonly<Record<string, string>> {
return { ...this._titles };
}
/**
* Remove a custom title, reverting to the auto-generated summary.
*
* @param cascadeId - The conversation's cascade ID
*/
remove(cascadeId: string): void {
if (this._titles[cascadeId]) {
delete this._titles[cascadeId];
this._save();
log.debug(`Removed title for ${cascadeId.substring(0, 8)}...`);
}
}
/**
* Remove all custom titles.
*/
clear(): void {
this._titles = {};
this._save();
log.debug('Cleared all custom titles');
}
/**
* Get the number of custom titles.
*/
get count(): number {
return Object.keys(this._titles).length;
}
/** Load titles from the data file */
private _load(): void {
try {
if (fs.existsSync(this._dataPath)) {
const content = fs.readFileSync(this._dataPath, 'utf8');
this._titles = JSON.parse(content) || {};
}
} catch (err) {
log.warn(`Failed to load titles: ${err}`);
this._titles = {};
}
}
/** Save titles to the data file */
private _save(): void {
if (!this._dataPath) return;
try {
fs.writeFileSync(this._dataPath, JSON.stringify(this._titles, null, 2), 'utf8');
} catch (err) {
log.warn(`Failed to save titles: ${err}`);
}
}
dispose(): void {
// Nothing to clean up - titles persist on disk
}
}

View File

@@ -0,0 +1,292 @@
/**
* Title Proxy — Renderer-side code for intercepting chat summaries.
*
* Generates JavaScript that runs in the workbench renderer process.
* Uses Preact VNode context walk to find the summaries provider,
* wraps getState() to inject custom titles from localStorage,
* and captures onDidChange listeners for forced re-renders.
*
* All identifiers used here are STRUCTURALLY MATCHED, not hardcoded
* minified variable names — this survives obfuscation changes.
*
* @module integration/title-proxy
* @internal
*/
/** localStorage key prefix for custom titles */
const TITLES_STORAGE_PREFIX = 'ag-sdk-titles';
/** Data file prefix for extension-host to set titles */
const TITLES_DATA_PREFIX = 'ag-sdk-titles';
/**
* Generate the renderer-side title proxy JavaScript.
*
* This code:
* 1. BFS walks the Preact VNode tree (limit 3000, arrays not counted)
* 2. Finds summaries provider via structural matching
* 3. Wraps provider.getState() to inject custom titles
* 4. Captures onDidChange listeners for forced re-renders
* 5. Reads custom titles from localStorage + data file
* 6. Exposes window.__agSDKTitles API for inline rename
*
* @param dataFilePath - Relative path to the JSON data file (for extension-host titles)
* @returns JavaScript source code
*/
export function generateTitleProxyCode(namespace: string = 'default'): string {
const slug = namespace.replace(/[^a-zA-Z0-9-]/g, '-');
const storageKey = `${TITLES_STORAGE_PREFIX}-${slug}`;
const dataFile = `./${TITLES_DATA_PREFIX}-${slug}.json`;
return `
// ── AG SDK: Title Proxy ──────────────────────────────────────────
// Intercepts summaries provider to inject custom chat titles.
// Uses structural matching (obfuscation-safe).
(function initTitleProxy(){
var PANEL_SEL='.antigravity-agent-side-panel';
var TITLE_SEL='.flex.min-w-0.items-center.overflow-hidden';
var STORAGE_KEY='${storageKey}';
var DATA_FILE='${dataFile}';
var _provider=null;
var _origGetState=null;
var _listeners=[];
var _customTitles={};
var _searchTime=0;
// ── Load / Save ────────────────────────────────────────────────
function loadTitles(){
// Step 1: Load from localStorage (sync, fast)
try{_customTitles=JSON.parse(localStorage.getItem(STORAGE_KEY)||'{}');}catch(e){_customTitles={};}
// Step 2: Merge extension-host titles from data file (async fetch)
fetch(DATA_FILE).then(function(r){
if(!r.ok)return;
return r.text();
}).then(function(text){
if(!text)return;
try{
var extTitles=JSON.parse(text);
if(extTitles&&typeof extTitles==='object'){
for(var k in extTitles){_customTitles[k]=extTitles[k];}
saveTitles();
notifyListeners();
}
}catch(e){}
}).catch(function(){});
}
function saveTitles(){
try{localStorage.setItem(STORAGE_KEY,JSON.stringify(_customTitles));}catch(e){}
}
// ── Notify ─────────────────────────────────────────────────────
function notifyListeners(){
for(var i=0;i<_listeners.length;i++){try{_listeners[i]();}catch(e){}}
}
// ── Provider Wrapping ──────────────────────────────────────────
function wrapProvider(provider){
if(provider.__agSDKWrapped)return;
provider.__agSDKWrapped=true;
_provider=provider;
var origFn=provider.getState;
_origGetState=origFn;
// Wrap getState to inject custom titles
provider.getState=function(){
var state=origFn.call(provider);
if(!state||!state.summaries)return state;
var hasOverrides=false;
for(var cid in _customTitles){if(state.summaries[cid]){hasOverrides=true;break;}}
if(!hasOverrides)return state;
var ns={};
for(var k in state.summaries)ns[k]=state.summaries[k];
for(var cid in _customTitles){
if(ns[cid]){
var copy={};for(var p in ns[cid])copy[p]=ns[cid][p];
copy.summary=_customTitles[cid];
ns[cid]=copy;
}
}
var newState={};for(var sk in state)newState[sk]=state[sk];
newState.summaries=ns;
return newState;
};
// Intercept onDidChange to capture listeners
var origOnDidChange=provider.onDidChange;
provider.onDidChange=function(callback){
_listeners.push(callback);
var origDispose=origOnDidChange.call(this,callback);
return{dispose:function(){
var idx=_listeners.indexOf(callback);
if(idx>=0)_listeners.splice(idx,1);
origDispose.dispose();
}};
};
console.log('[AG SDK] Title proxy active, custom titles:', Object.keys(_customTitles).length);
// Force re-render so custom titles appear immediately
// (without waiting for next native summaries update)
setTimeout(function(){notifyListeners();},50);
}
// ── VNode BFS Walk ─────────────────────────────────────────────
function findProvider(){
if(_provider)return;
var panel=document.querySelector(PANEL_SEL);
if(!panel||!panel.__k)return;
// Throttle only AFTER confirming panel exists (don't block retries when panel isn't mounted)
var now=Date.now();
if(_searchTime&&now-_searchTime<30000)return;
_searchTime=now;
var queue=[panel.__k],visited=0;
while(queue.length>0&&visited<3000){
var node=queue.shift();
if(!node)continue;
if(Array.isArray(node)){
for(var ai=0;ai<node.length;ai++){if(node[ai])queue.push(node[ai]);}
continue;
}
visited++;
var comp=node.__c;
if(comp&&comp.context&&typeof comp.context==='object'){
for(var key in comp.context){
try{
var ctx=comp.context[key];
if(!ctx||!ctx.props||!ctx.props.value)continue;
var val=ctx.props.value;
// Structural match: {provider: {getState() -> {summaries}}}
if(val.provider&&typeof val.provider.getState==='function'){
var ts=val.provider.getState();
if(ts&&ts.summaries){wrapProvider(val.provider);return;}
}
// Structural match: {trajectorySummariesProvider: {getState() -> {summaries}}}
if(val.trajectorySummariesProvider&&typeof val.trajectorySummariesProvider.getState==='function'){
var ts2=val.trajectorySummariesProvider.getState();
if(ts2&&ts2.summaries){wrapProvider(val.trajectorySummariesProvider);return;}
}
}catch(e){}
}
}
// Direct props match
if(comp&&comp.props&&comp.props.trajectorySummariesProvider){
var tsp=comp.props.trajectorySummariesProvider;
if(typeof tsp.getState==='function'){
try{var ts3=tsp.getState();
if(ts3&&ts3.summaries){wrapProvider(tsp);return;}
}catch(e){}
}
}
if(node.__k){
if(Array.isArray(node.__k)){for(var ki=0;ki<node.__k.length;ki++){if(node.__k[ki])queue.push(node.__k[ki]);}}
else{queue.push(node.__k);}
}
}
}
// ── CascadeId Resolution ───────────────────────────────────────
function findCascadeIdByTitle(text){
if(!_origGetState)return '';
try{
var state=_origGetState.call(_provider);
if(!state||!state.summaries)return '';
// Reverse lookup custom titles first
for(var cid in _customTitles){if(_customTitles[cid]===text)return cid;}
// Match original summaries
var bestId='',bestTime=0;
for(var cid in state.summaries){
var e=state.summaries[cid];
if(e&&e.summary===text){
var t=0;try{t=new Date(e.lastModifiedTime).getTime();}catch(e){}
if(!bestId||t>bestTime){bestId=cid;bestTime=t;}
}
}
return bestId;
}catch(e){return '';}
}
// ── Public API ─────────────────────────────────────────────────
window.__agSDKTitles={
rename:function(cascadeId,title){
if(!cascadeId||!title)return false;
_customTitles[cascadeId]=title;
saveTitles();
notifyListeners();
return true;
},
renameByCurrentTitle:function(currentTitle,newTitle){
var cid=findCascadeIdByTitle(currentTitle);
if(!cid)return false;
return this.rename(cid,newTitle);
},
remove:function(cascadeId){
delete _customTitles[cascadeId];
saveTitles();
notifyListeners();
},
getTitle:function(cascadeId){return _customTitles[cascadeId]||null;},
getAll:function(){var copy={};for(var k in _customTitles)copy[k]=_customTitles[k];return copy;},
getActiveCascadeId:function(){
var panel=document.querySelector(PANEL_SEL);
if(!panel)return '';
var titleEl=panel.querySelector(TITLE_SEL);
if(!titleEl)return '';
var text='';
function findText(el){
for(var i=0;i<el.childNodes.length;i++){
var n=el.childNodes[i];
if(n.nodeType===3&&n.textContent.trim().length>0)return n.textContent.trim();
if(n.nodeType===1){var found=findText(n);if(found)return found;}
}
return '';
}
text=findText(titleEl);
return text?findCascadeIdByTitle(text):'';
},
isReady:function(){return !!_provider;},
reload:function(){loadTitles();notifyListeners();}
};
// ── Init ───────────────────────────────────────────────────────
loadTitles();
function poll(){
findProvider();
}
// Poll until provider found, then every 30s for recovery
var pollTimer=setInterval(function(){poll();},2000);
// Initial attempt after DOM is ready
if(document.querySelector(PANEL_SEL)){
poll();
}
})();
`;
}
/**
* Get the data file name for extension-host titles.
*/
export function getTitlesDataFile(namespace: string = 'default'): string {
const slug = namespace.replace(/[^a-zA-Z0-9-]/g, '-');
return `${TITLES_DATA_PREFIX}-${slug}.json`;
}
/**
* Get the localStorage key used by the renderer.
*/
export function getTitlesStorageKey(namespace: string = 'default'): string {
const slug = namespace.replace(/[^a-zA-Z0-9-]/g, '-');
return `${TITLES_STORAGE_PREFIX}-${slug}`;
}

View File

@@ -0,0 +1,213 @@
/**
* Integration module types — standardized UI integration points
* for the Antigravity Agent View.
*
* @module integration/types
*/
// ─── Integration Points ──────────────────────────────────────────────────
/**
* Standardized integration points in the Agent View UI.
*
* Each point corresponds to a specific DOM location in the
* Antigravity chat interface (verified 2026-02-28).
*/
export enum IntegrationPoint {
/** Top bar — next to +, refresh, ... icons */
TOP_BAR = 'topBar',
/** Top right corner — before the X (close) button */
TOP_RIGHT = 'topRight',
/** Input area — next to voice/send buttons */
INPUT_AREA = 'inputArea',
/** Bottom icon row — file, terminal, artifact, chrome icons */
BOTTOM_ICONS = 'bottomIcons',
/** Per-turn metadata — appended inside each conversation turn */
TURN_METADATA = 'turnMeta',
/** User message badge — small badge inside user message bubbles */
USER_BADGE = 'userBadge',
/** Bot response action — button next to Good/Bad feedback */
BOT_ACTION = 'botAction',
/** 3-dot dropdown menu — extra items in the overflow menu */
DROPDOWN_MENU = 'dropdownMenu',
/** Chat title bar — interaction on conversation title */
CHAT_TITLE = 'chatTitle',
}
// ─── Configuration Interfaces ──────────────────────────────────────────
/**
* Base configuration for all integration points.
*/
export interface IIntegrationBase {
/** Unique ID for this integration (prevents duplicates) */
id: string;
/** Which integration point to target */
point: IntegrationPoint;
/** Whether this integration is enabled (default: true) */
enabled?: boolean;
}
/**
* Configuration for button-type integrations (top bar, input area, etc.).
*/
export interface IButtonIntegration extends IIntegrationBase {
point:
| IntegrationPoint.TOP_BAR
| IntegrationPoint.TOP_RIGHT
| IntegrationPoint.INPUT_AREA
| IntegrationPoint.BOTTOM_ICONS;
/** Icon (emoji or text glyph) */
icon: string;
/** Tooltip text */
tooltip?: string;
/** Toast to show on click */
toast?: IToastConfig;
/** CSS class override */
className?: string;
}
/**
* Configuration for turn-level metadata integration.
*/
export interface ITurnMetaIntegration extends IIntegrationBase {
point: IntegrationPoint.TURN_METADATA;
/** Which metrics to display */
metrics: TurnMetric[];
/** Whether turns are clickable to show details toast */
clickable?: boolean;
}
/**
* Configuration for user message badges.
*/
export interface IUserBadgeIntegration extends IIntegrationBase {
point: IntegrationPoint.USER_BADGE;
/** What to show in the badge */
display: 'charCount' | 'wordCount' | 'custom';
/** Custom formatter function body (receives `textLength` as arg) */
customFormat?: string;
}
/**
* Configuration for bot response action buttons.
*/
export interface IBotActionIntegration extends IIntegrationBase {
point: IntegrationPoint.BOT_ACTION;
/** Icon */
icon: string;
/** Label text */
label: string;
/** Toast config on click */
toast?: IToastConfig;
}
/**
* Configuration for dropdown menu items.
*/
export interface IDropdownIntegration extends IIntegrationBase {
point: IntegrationPoint.DROPDOWN_MENU;
/** Menu item icon */
icon?: string;
/** Menu item label */
label: string;
/** Add separator before this item */
separator?: boolean;
/** Toast config on click */
toast?: IToastConfig;
}
/**
* Configuration for chat title interaction.
*/
export interface ITitleIntegration extends IIntegrationBase {
point: IntegrationPoint.CHAT_TITLE;
/** Interaction type */
interaction: 'click' | 'dblclick' | 'hover';
/** Hint text shown on hover */
hint?: string;
/** Toast config on interaction */
toast?: IToastConfig;
}
/**
* Toast popup configuration.
*/
export interface IToastConfig {
/** Toast title */
title: string;
/** Badge label and colors */
badge?: {
text: string;
bgColor: string;
textColor: string;
};
/** Key-value rows to display */
rows: IToastRow[];
/** Auto-dismiss after N milliseconds (default: 6000) */
duration?: number;
}
/**
* A row in a toast popup.
*/
export interface IToastRow {
/** Label (left side) */
key: string;
/**
* Value (right side).
* Can be a static string or a dynamic expression.
* Dynamic expressions are JS code that runs in the renderer,
* with access to `getStats()` which returns conversation stats.
*/
value: string;
/** If true, `value` is treated as a JS expression */
dynamic?: boolean;
}
/**
* Metrics available for turn metadata display.
*/
export type TurnMetric =
| 'turnNumber'
| 'userCharCount'
| 'aiCharCount'
| 'codeBlocks'
| 'thinkingIndicator'
| 'ratio'
| 'separator';
/**
* Union type of all integration configurations.
*/
export type IntegrationConfig =
| IButtonIntegration
| ITurnMetaIntegration
| IUserBadgeIntegration
| IBotActionIntegration
| IDropdownIntegration
| ITitleIntegration;
// ─── Manager Interface ────────────────────────────────────────────────
/**
* Public interface for the Integration Manager.
*/
export interface IIntegrationManager {
/** Register a single integration point */
register(config: IntegrationConfig): void;
/** Register multiple integration points at once */
registerMany(configs: IntegrationConfig[]): void;
/** Remove a registered integration by ID */
unregister(id: string): void;
/** Get all registered integrations */
getRegistered(): ReadonlyArray<IntegrationConfig>;
/** Generate the integration script from all registered configs */
build(): string;
/** Install the generated script into workbench.html. Returns true if content changed. */
install(): Promise<boolean>;
/** Remove the integration from workbench.html */
uninstall(): Promise<void>;
/** Check if an integration is currently installed */
isInstalled(): boolean;
}

View File

@@ -0,0 +1,257 @@
/**
* Workbench Patcher — Install/uninstall integration scripts into workbench.html.
*
* Handles the file-level modification of Antigravity's workbench.html
* to include/remove custom script tags.
*
* @module integration/workbench-patcher
*
* @internal
*/
import * as fs from 'fs';
import * as path from 'path';
/** Default prefix for generated files */
const FILE_PREFIX = 'ag-sdk';
/**
* Manages patching/unpatching of Antigravity's workbench.html.
*/
export class WorkbenchPatcher {
private readonly _workbenchDir: string;
private readonly _workbenchHtml: string;
private readonly _scriptPath: string;
private readonly _heartbeatPath: string;
private readonly _slug: string;
private readonly _markerStart: string;
private readonly _markerEnd: string;
/**
* @param namespace - Unique slug for this extension (e.g. 'kanezal-better-antigravity').
* Used to namespace all generated files and HTML markers so multiple
* SDK-based extensions can coexist without conflicts.
*/
constructor(namespace: string = 'default') {
// Resolve Antigravity install path
const appData = process.env.LOCALAPPDATA || '';
this._workbenchDir = path.join(
appData,
'Programs',
'Antigravity',
'resources',
'app',
'out',
'vs',
'code',
'electron-browser',
'workbench',
);
this._workbenchHtml = path.join(this._workbenchDir, 'workbench.html');
this._slug = namespace.replace(/[^a-zA-Z0-9-]/g, '-');
this._scriptPath = path.join(this._workbenchDir, `${FILE_PREFIX}-${this._slug}.js`);
this._heartbeatPath = path.join(this._workbenchDir, `${FILE_PREFIX}-${this._slug}-heartbeat`);
this._markerStart = `<!-- AG SDK [${this._slug}] -->`;
this._markerEnd = `<!-- /AG SDK [${this._slug}] -->`;
}
/**
* Check if workbench.html exists and is accessible.
*/
isAvailable(): boolean {
return fs.existsSync(this._workbenchHtml);
}
/**
* Check if our integration is currently installed.
*/
isInstalled(): boolean {
if (!this.isAvailable()) return false;
try {
const content = fs.readFileSync(this._workbenchHtml, 'utf8');
return content.includes(this._markerStart);
} catch {
return false;
}
}
/**
* Install the integration script.
*
* 1. Writes the script file to the workbench directory
* 2. Patches workbench.html to include a <script> tag
*
* @param scriptContent — The generated JavaScript code
*/
install(scriptContent: string): void {
if (!this.isAvailable()) {
throw new Error(`Workbench not found at: ${this._workbenchDir}`);
}
// First uninstall any previous integration for THIS namespace
if (this.isInstalled()) {
this.uninstall();
}
// Clean up legacy files from previous versions (non-namespaced)
this._cleanupLegacyFiles();
// Write the script file
fs.writeFileSync(this._scriptPath, scriptContent, 'utf8');
// Patch workbench.html
let html = fs.readFileSync(this._workbenchHtml, 'utf8');
// Insert before </html>
const scriptBasename = path.basename(this._scriptPath);
const scriptTag = [
this._markerStart,
`<script src="./${scriptBasename}"></script>`,
this._markerEnd,
].join('\n');
html = html.replace('</html>', `${scriptTag}\n</html>`);
fs.writeFileSync(this._workbenchHtml, html, 'utf8');
// Create empty titles JSON if it doesn't exist (prevents console 404)
const titlesPath = path.join(this._workbenchDir, `ag-sdk-titles-${this._slug}.json`);
if (!fs.existsSync(titlesPath)) {
fs.writeFileSync(titlesPath, '{}', 'utf8');
}
}
/**
* Remove the integration.
*
* 1. Removes the <script> tag from workbench.html
* 2. Deletes the script file
*/
uninstall(): void {
if (!this.isAvailable()) return;
// Remove from workbench.html
try {
let html = fs.readFileSync(this._workbenchHtml, 'utf8');
const regex = new RegExp(
`\\n?${escapeRegex(this._markerStart)}[\\s\\S]*?${escapeRegex(this._markerEnd)}\\n?`,
'g',
);
html = html.replace(regex, '');
fs.writeFileSync(this._workbenchHtml, html, 'utf8');
} catch {
// Ignore errors during cleanup
}
// Remove script file
try {
if (fs.existsSync(this._scriptPath)) {
fs.unlinkSync(this._scriptPath);
}
} catch {
// Ignore
}
}
/**
* Write/refresh the heartbeat marker file.
*
* The generated script checks this file's modification time
* to determine if the extension is still active. If the file
* is missing or stale, the script will not start.
*/
writeHeartbeat(): void {
try {
fs.writeFileSync(this._heartbeatPath, Date.now().toString(), 'utf8');
} catch {
// Ignore — workbench dir may not be writable
}
}
/**
* Remove the heartbeat marker file.
*/
removeHeartbeat(): void {
try {
if (fs.existsSync(this._heartbeatPath)) {
fs.unlinkSync(this._heartbeatPath);
}
} catch {
// Ignore
}
}
/**
* Get the path to the heartbeat file.
*/
getHeartbeatPath(): string {
return this._heartbeatPath;
}
/**
* Get the path to the workbench directory.
*/
getWorkbenchDir(): string {
return this._workbenchDir;
}
/**
* Get the path to the script file.
*/
getScriptPath(): string {
return this._scriptPath;
}
/**
* Clean up legacy files from previous SDK versions.
*
* Removes non-namespaced files (from before namespace support)
* and files with wrong namespace (e.g. 'undefined').
*/
private _cleanupLegacyFiles(): void {
// Legacy file names that may exist from older versions
const legacyFiles = [
'ag-sdk-integrate.js',
'ag-sdk-heartbeat',
'ag-sdk-titles.json',
'ag-sdk-titles-undefined.json',
'ag-sdk-titles-default.json',
];
for (const name of legacyFiles) {
const p = path.join(this._workbenchDir, name);
try {
if (fs.existsSync(p)) fs.unlinkSync(p);
} catch { /* ignore */ }
}
// Remove legacy script tags from workbench.html
try {
let html = fs.readFileSync(this._workbenchHtml, 'utf8');
let changed = false;
// Remove bare <script src="./ag-sdk-integrate.js"></script> lines
const legacyTagRegex = /<script src="\.\/ag-sdk-integrate\.js"><\/script>\n?/g;
if (legacyTagRegex.test(html)) {
html = html.replace(legacyTagRegex, '');
changed = true;
}
// Remove old X-Ray SDK markers with no namespace
const xrayRegex = /<!-- X-Ray SDK Integration -->\n?<script[^>]*ag-sdk-integrate[^>]*><\/script>\n?<!-- \/X-Ray SDK Integration -->\n?/g;
if (xrayRegex.test(html)) {
html = html.replace(xrayRegex, '');
changed = true;
}
if (changed) {
fs.writeFileSync(this._workbenchHtml, html, 'utf8');
}
} catch { /* ignore */ }
}
}
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}