Support Antigravity CLI sessions#73
Conversation
b22da1a to
f4e050e
Compare
There was a problem hiding this comment.
Devin Review found 8 potential issues.
⚠️ 1 issue in files not directly in the diff
⚠️ REVIEW.md rule violation: parser logic changed without updating fixture data in fixtures/index.ts (src/__tests__/fixtures/index.ts:968-998)
REVIEW.md mandates: "PRs that change parser logic must include or update fixture data in src/__tests__/fixtures/index.ts." This PR adds significant new parser logic (CLI SQLite conversation discovery/extraction, new readCliSteps, extractFromCliDbSteps, etc.) but does not update createAntigravityFixture() in src/__tests__/fixtures/index.ts to include CLI conversation data. As a result, the conversion tests in unit-conversions.test.ts do not exercise the new CLI conversation code path at all — they still only test the brain-artifact offline path.
| const uriMatch = text.match(/file:\/\/[^\s"')]+/u)?.[0]; | ||
| if (uriMatch) { | ||
| const cwd = decodeFileUri(uriMatch); | ||
| if (cwd) return path.dirname(cwd); |
There was a problem hiding this comment.
🟡 extractCliCwdFromStrings incorrectly applies path.dirname to decoded file URIs, stripping one directory level
The new extractCliCwdFromStrings function at src/parsers/antigravity.ts:912 calls path.dirname(cwd) on the result of decodeFileUri, which strips one directory level from the path. If a file:// URI references a workspace root (e.g. file:///home/user/project), the function returns /home/user instead of /home/user/project. This is inconsistent with every other URI-to-CWD function in the same file: inferCwdFromBrain (src/parsers/antigravity.ts:1365), extractFolderFromSummaryProto (src/parsers/antigravity.ts:656), and normalizeCwd (src/parsers/antigravity.ts:289) all return the decoded URI directly without calling path.dirname. The bug is mitigated in practice because extractCliCwdFromStrings is a fallback behind loadCliLastConversationCwds, but when the fallback is reached, the CWD will be one level too high.
| if (cwd) return path.dirname(cwd); | |
| if (cwd) return cwd; |
Was this helpful? React with 👍 or 👎 to provide feedback.
| [record.conversationPath, record.cliConversationPath, ...artifactPaths].filter(isNonEmptyString), | ||
| ); | ||
| const cliSteps = record.cliConversationPath ? readCliSteps(record.cliConversationPath).slice(0, 40) : []; | ||
| const cliCwds = record.cliConversationPath ? await loadCliLastConversationCwds() : new Map<string, string>(); |
There was a problem hiding this comment.
📝 Info: loadCliLastConversationCwds() is re-read from disk for every CLI conversation record during session discovery
In buildSessionFromRecord at src/parsers/antigravity.ts:1414, loadCliLastConversationCwds() reads and JSON-parses antigravity-cli/cache/last_conversations.json for each record that has a cliConversationPath. If there are N CLI conversations, the same file is read N times. This could be hoisted to parseAntigravitySessions() and passed into buildSessionFromRecord, or cached at module level with a TTL. Not a correctness bug, but worth noting for repos with many CLI conversations.
Was this helpful? React with 👍 or 👎 to provide feedback.
| const cliSteps = record.cliConversationPath ? readCliSteps(record.cliConversationPath).slice(0, 40) : []; | ||
| const cliCwds = record.cliConversationPath ? await loadCliLastConversationCwds() : new Map<string, string>(); | ||
| const cliStrings = cliSteps.flatMap(stringsFromStep); | ||
| const cliCwd = cliCwds.get(record.id) ?? extractCliCwdFromStrings(cliStrings); | ||
| const title = recordPreferredTitle(record) ?? (await titleFromBrain(record.brainDir)) ?? titleFromCliSteps(cliSteps); | ||
| const cwd = record.live?.cwd ?? record.state?.cwd ?? (await inferCwdFromBrain(record.brainDir)) ?? cliCwd ?? ''; | ||
| const createdAt = | ||
| record.live?.createdAt ?? record.state?.createdAt ?? stats.createdAt ?? stats.updatedAt ?? new Date(0); | ||
| const updatedAt = record.live?.updatedAt ?? record.state?.updatedAt ?? stats.updatedAt ?? createdAt; | ||
| const summary = cleanSummary(title ?? `Antigravity conversation ${record.id.slice(0, 8)}`, 80); | ||
| const originalPath = record.conversationPath ?? record.brainDir ?? getAntigravityRoot(); | ||
| const originalPath = record.cliConversationPath ?? record.conversationPath ?? record.brainDir ?? getAntigravityRoot(); | ||
|
|
||
| return { | ||
| id: record.id, | ||
| source: SOURCE_NAME, | ||
| cwd, | ||
| repo: extractRepoFromCwd(cwd), | ||
| lines: record.live?.stepCount ?? record.state?.stepCount ?? artifactPaths.length, | ||
| lines: | ||
| record.live?.stepCount ?? | ||
| record.state?.stepCount ?? | ||
| (record.cliConversationPath ? readCliStepCount(record.cliConversationPath) : undefined) ?? | ||
| artifactPaths.length, |
There was a problem hiding this comment.
📝 Info: readCliSteps reads all rows with all blob columns during session discovery, then slices to 40
At src/parsers/antigravity.ts:1413, readCliSteps(record.cliConversationPath).slice(0, 40) opens the conversation DB synchronously, reads ALL step rows (including large blob columns: metadata, task_details, render_info, step_payload), returns them all, then the caller slices to 40. Additionally, readCliStepCount at line 1433 opens the same DB again to SELECT COUNT(*). The query in readCliSteps could use LIMIT 40 to avoid loading unbounded data, and the count could be derived from cliSteps.length if the full set had already been read (or vice versa). The synchronous SQLite usage is consistent with the pre-existing readStateValue/openDb pattern, so it's not a new anti-pattern, but the double-open and full-table-scan-then-slice is worth optimizing for conversations with many steps.
Was this helpful? React with 👍 or 👎 to provide feedback.
| function mapAntigravityFlags(context: ForwardFlagMapContext): ForwardMapResult { | ||
| const args: string[] = []; | ||
| const warnings: string[] = []; | ||
|
|
||
| const autoOccurrences = collectAutoApproveOccurrences(context); | ||
| const fullAutoOccurrences = context.all('fullAuto'); | ||
| const askOccurrences = context.all('askForApproval'); | ||
| const approvalModeOccurrences = context.all('approvalMode'); | ||
| const permissionModeOccurrences = context.all('permissionMode'); | ||
| const approvalMode = context.latestString('approvalMode')?.toLowerCase(); | ||
| const askForApproval = context.latestString('askForApproval')?.toLowerCase(); | ||
|
|
||
| if ( | ||
| autoOccurrences.length > 0 || | ||
| fullAutoOccurrences.length > 0 || | ||
| approvalMode === 'yolo' || | ||
| askForApproval === 'never' | ||
| ) { | ||
| context.consume( | ||
| ...autoOccurrences, | ||
| ...fullAutoOccurrences, | ||
| ...askOccurrences, | ||
| ...approvalModeOccurrences, | ||
| ...permissionModeOccurrences, | ||
| ); | ||
| args.push('--dangerously-skip-permissions'); | ||
|
|
||
| if (approvalModeOccurrences.length > 0 && approvalMode && approvalMode !== 'yolo') { | ||
| warnings.push('Antigravity: mapped auto-approve behavior overrides unsupported approval-mode values.'); | ||
| } | ||
| } else if (askOccurrences.length > 0 || approvalModeOccurrences.length > 0 || permissionModeOccurrences.length > 0) { | ||
| context.consume(...askOccurrences, ...approvalModeOccurrences, ...permissionModeOccurrences); | ||
| warnings.push('Antigravity: approval and permission mode forwarding flags are not supported and were ignored.'); | ||
| } | ||
|
|
||
| const sandbox = context.latest('sandbox'); | ||
| if (sandbox) { | ||
| context.consumeKeys('sandbox'); | ||
| const normalized = String(sandbox.value).toLowerCase(); | ||
| if (sandbox.value === true || ['true', '1', 'yes', 'on', 'enabled'].includes(normalized)) { | ||
| args.push('--sandbox=true'); | ||
| } else if (['false', '0', 'no', 'off', 'disabled', 'none'].includes(normalized)) { | ||
| args.push('--sandbox=false'); | ||
| } else { | ||
| args.push('--sandbox', String(sandbox.value)); | ||
| } | ||
| } | ||
|
|
||
| const model = context.latestString('model'); | ||
| if (model) { | ||
| context.consumeKeys('model'); | ||
| args.push('--model', model); | ||
| } | ||
|
|
||
| return { mappedArgs: args, warnings }; | ||
| } |
There was a problem hiding this comment.
📝 Info: Antigravity flag mapping correctly gates --dangerously-skip-permissions behind explicit auto-approve signals
The mapAntigravityFlags function at src/parsers/registry.ts:212-267 only emits --dangerously-skip-permissions when the user explicitly passed auto-approve flags (--yolo, --allow-all, --dangerously-*), --full-auto, --approval-mode yolo, or --ask-for-approval never. Non-yolo approval modes and permission modes are consumed with a warning. This is consistent with REVIEW.md's security requirement that dangerous flags are "only set when the source session explicitly requested auto-approve behavior." The sandbox forwarding correctly normalizes boolean-like values into --sandbox=true/--sandbox=false.
Was this helpful? React with 👍 or 👎 to provide feedback.
| function immutableSqliteUri(dbPath: string): string { | ||
| const url = pathToFileURL(dbPath); | ||
| url.searchParams.set('mode', 'ro'); | ||
| url.searchParams.set('immutable', '1'); | ||
| return url.toString(); | ||
| } | ||
|
|
||
| function openCliConversationDb(dbPath: string): { db: SqliteDatabase; close: () => void } | null { | ||
| try { | ||
| const require = createRequire(import.meta.url); | ||
| const sqliteModule = require('node:sqlite') as { | ||
| DatabaseSync: new (database: string, options?: { open?: boolean; readOnly?: boolean }) => SqliteDatabase; | ||
| }; | ||
| const db = new sqliteModule.DatabaseSync(immutableSqliteUri(dbPath), { open: true, readOnly: true }); | ||
| return { db, close: () => db.close() }; | ||
| } catch (err) { | ||
| logger.debug('antigravity: failed to open cli conversation database', dbPath, err); | ||
| return null; | ||
| } | ||
| } |
There was a problem hiding this comment.
📝 Info: immutableSqliteUri relies on Node.js node:sqlite supporting URI filenames
The openCliConversationDb function at src/parsers/antigravity.ts:467-479 passes a file://...?mode=ro&immutable=1 URI to DatabaseSync. This works because Node.js 22's node:sqlite wraps sqlite3_open_v2 with the SQLITE_OPEN_URI flag enabled. The immutable=1 parameter tells SQLite the DB won't change during the connection, enabling read optimizations (no rollback journal checks). This is a nice safety net on top of the readOnly: true option. If Node.js ever changes its URI support, the catch block at line 475 would gracefully degrade to returning null.
Was this helpful? React with 👍 or 👎 to provide feedback.
| const updatedAt = record.live?.updatedAt ?? record.state?.updatedAt ?? stats.updatedAt ?? createdAt; | ||
| const summary = cleanSummary(title ?? `Antigravity conversation ${record.id.slice(0, 8)}`, 80); | ||
| const originalPath = record.conversationPath ?? record.brainDir ?? getAntigravityRoot(); | ||
| const originalPath = record.cliConversationPath ?? record.conversationPath ?? record.brainDir ?? getAntigravityRoot(); |
There was a problem hiding this comment.
🚩 CLI conversation path takes precedence over IDE path for originalPath, changing extraction behavior for dual-source sessions
At src/parsers/antigravity.ts:1423, originalPath prefers cliConversationPath over conversationPath. In extractAntigravityContext at line 2069, extractCliDbContext checks session.originalPath.endsWith('.db') to decide whether to use CLI DB extraction. This means if a session UUID exists in both ~/.gemini/antigravity-cli/conversations/<id>.db AND ~/.gemini/antigravity/conversations/<id>.pb, the CLI DB extraction is used and live RPC extraction from the IDE is skipped entirely. This is likely intentional (CLI data is directly readable without requiring the IDE), but it's a behavioral choice worth documenting if dual-source sessions are possible.
Was this helpful? React with 👍 or 👎 to provide feedback.
| function textLooksUseful(value: string): boolean { | ||
| if (value.length < 8) return false; | ||
| if (UUIDISH_RE.test(value.replace(/^\$/u, ''))) return false; | ||
| if (UUIDISH_ANYWHERE_RE.test(value)) return false; | ||
| if (value.includes('sessionID')) return false; | ||
| if (/\b(command|execute_url|read_url|mcp)\(\*\)/u.test(value)) return false; | ||
| if (/^(sessionID|trajectory_id|model_enum|toolAction|toolSummary|CommandLine|Cwd|DirectoryPath)$/u.test(value)) { | ||
| return false; | ||
| } | ||
| if (/^[A-Za-z_]+\(.\)$/u.test(value)) return false; | ||
| if (/^[A-Za-z0-9_-]{8,}$/u.test(value) && !value.includes(' ')) return false; | ||
| return /[A-Za-z][A-Za-z]/u.test(value); | ||
| } |
There was a problem hiding this comment.
📝 Info: textLooksUseful aggressively filters strings containing UUIDs anywhere
At src/parsers/antigravity.ts:852, UUIDISH_ANYWHERE_RE.test(value) rejects any string containing a UUID-like pattern anywhere in it. This could filter out meaningful messages that happen to reference a UUID (e.g., "Fixed handler for session abc12345-..."). The filter is part of best-effort protobuf string extraction where false positives (protobuf field IDs that look like UUIDs) are more common than false negatives, so the aggressive filtering is a reasonable tradeoff for the CLI conversation decoding use case.
Was this helpful? React with 👍 or 👎 to provide feedback.
Summary
Validation
Note: pnpm run check still reports pre-existing unrelated Biome diagnostics outside this change set.
Summary by cubic
Adds first-class support for Antigravity CLI conversations from SQLite, extracting messages and tool activity without launching the IDE. Also switches the adapter to prefer
agywith native--conversationresume and clearer flag mapping.New Features
~/.gemini/antigravity-cli/conversations/*.dband the CLI brain dir; IDEconversations/*.pband state summaries remain supported.node:sqlite; decode protobuf/JSON strings to messages and tool activity; infer CWD fromcache/last_conversations.jsonor file:// URIs; derive titles and step counts.agy(fallbackantigravity), support--conversation <id>, map auto-approve →--dangerously-skip-permissions, normalize--sandbox, and forward--modelwith warnings for unsupported approval/permission modes; registry now acceptsANTIGRAVITY_CLI_HOME. Docs updated for CLI storage/access; tests added for CLI parser and flag forwarding.Migration
agyis on PATH;antigravityremains a fallback.ANTIGRAVITY_CLI_HOME(parser also respectsGEMINI_CLI_HOME).agy --conversation <conversation-id>(orantigravity --conversation <conversation-id>).Written for commit f4e050e. Summary will update on new commits.