feat(agy): add Antigravity as an interactive PTY agent (#1320)

This commit is contained in:
Junmo Kim
2026-08-04 10:50:03 +08:00
committed by GitHub
parent 3ce73769c7
commit e35c06b36a
149 changed files with 14651 additions and 462 deletions
+16 -1
View File
@@ -510,7 +510,8 @@ export const SpawnSessionRequestSchema = z.object({
worktreeName: z.string().optional(),
serviceTier: z.enum(['fast', 'standard']).optional(),
collaborationMode: CodexCollaborationModeSchema.optional(),
copilotAgentMode: CopilotAgentModeSchema.optional()
copilotAgentMode: CopilotAgentModeSchema.optional(),
startingMode: z.enum(['remote', 'pty']).optional()
})
export type SpawnSessionRequest = z.infer<typeof SpawnSessionRequestSchema>
@@ -702,6 +703,20 @@ export type OpencodeReasoningEffortResponse = {
error?: string
}
export type AgyModelSummary = {
modelId: string
name?: string
}
export type AgyModelsResponse = {
success: boolean
availableModels?: AgyModelSummary[]
currentModelId?: string | null
error?: string
}
export type ListAgyModelsResponse = AgyModelsResponse
export type CursorModelSummary = OpencodeModelSummary
export type CursorModelsResponse = OpencodeModelsResponse
+8
View File
@@ -10,6 +10,11 @@ import {
} from './flavors'
describe('hasCapability', () => {
test('agy supports model-change but not effort', () => {
expect(hasCapability('agy', Capabilities.ModelChange)).toBe(true)
expect(hasCapability('agy', Capabilities.Effort)).toBe(false)
})
test('claude supports model-change', () => {
expect(hasCapability('claude', Capabilities.ModelChange)).toBe(true)
})
@@ -70,6 +75,7 @@ describe('hasCapability', () => {
describe('getFlavorLabel', () => {
test('known flavors return display names', () => {
expect(getFlavorLabel('agy')).toBe('Antigravity')
expect(getFlavorLabel('claude')).toBe('Claude')
expect(getFlavorLabel('gemini')).toBe('Gemini')
expect(getFlavorLabel('codex')).toBe('Codex')
@@ -93,6 +99,7 @@ describe('getFlavorLabel', () => {
describe('isKnownFlavor', () => {
test('returns true for registered flavors', () => {
expect(isKnownFlavor('agy')).toBe(true)
expect(isKnownFlavor('claude')).toBe(true)
expect(isKnownFlavor('gemini')).toBe(true)
expect(isKnownFlavor('codex')).toBe(true)
@@ -117,6 +124,7 @@ describe('convenience functions', () => {
})
test('supportsModelChange matches hasCapability', () => {
expect(supportsModelChange('agy')).toBe(true)
expect(supportsModelChange('claude')).toBe(true)
expect(supportsModelChange('gemini')).toBe(true)
expect(supportsModelChange('codex')).toBe(true)
+2
View File
@@ -10,6 +10,7 @@ export type Capability = typeof Capabilities[keyof typeof Capabilities]
// --- Per-flavor capability sets ---
const FLAVOR_CAPS: Record<AgentFlavor, ReadonlySet<Capability>> = {
agy: new Set([Capabilities.ModelChange]),
claude: new Set([Capabilities.ModelChange, Capabilities.Effort]),
gemini: new Set([Capabilities.ModelChange]),
kimi: new Set([Capabilities.ModelChange]),
@@ -23,6 +24,7 @@ const FLAVOR_CAPS: Record<AgentFlavor, ReadonlySet<Capability>> = {
// --- Flavor display names ---
const FLAVOR_LABELS: Record<AgentFlavor, string> = {
agy: 'Antigravity',
claude: 'Claude',
gemini: 'Gemini',
kimi: 'Kimi',
+29
View File
@@ -22,6 +22,35 @@ export type GeminiModelPreset = keyof typeof GEMINI_MODEL_LABELS
export const GEMINI_MODEL_PRESETS = Object.keys(GEMINI_MODEL_LABELS) as GeminiModelPreset[]
export const DEFAULT_GEMINI_MODEL: GeminiModelPreset = 'gemini-2.5-pro'
// Order and labels mirror `agy models` output (the agy CLI's own listing) so the
// HAPI picker matches what users see in the terminal. IDs follow agy's
// `<model>-<effort>` convention (e.g. `gemini-3.5-flash-low` verified accepted by
// `agy --model`). NOTE: agy fetches the live list server-side and `agy models`
// needs an interactive keyring unlock, so this stays a hand-maintained mirror —
// update it when agy's listing changes.
export const AGY_MODEL_LABELS = {
'gemini-3.6-flash-high': 'Gemini 3.6 Flash (High)',
'gemini-3.6-flash-medium': 'Gemini 3.6 Flash (Medium)',
'gemini-3.6-flash-low': 'Gemini 3.6 Flash (Low)',
'gemini-3.5-flash-medium': 'Gemini 3.5 Flash (Medium)',
'gemini-3.5-flash-high': 'Gemini 3.5 Flash (High)',
'gemini-3.5-flash-low': 'Gemini 3.5 Flash (Low)',
'gemini-3.1-pro-low': 'Gemini 3.1 Pro (Low)',
'gemini-3.1-pro-high': 'Gemini 3.1 Pro (High)',
'claude-sonnet-4-6': 'Claude Sonnet 4.6 (Thinking)',
'claude-opus-4-6-thinking': 'Claude Opus 4.6 (Thinking)',
'gpt-oss-120b-medium': 'GPT-OSS 120B (Medium)',
} as const
export type AgyModelPreset = keyof typeof AGY_MODEL_LABELS
export const AGY_MODEL_PRESETS = Object.keys(AGY_MODEL_LABELS) as AgyModelPreset[]
export function getAgyModelLabel(model: string): string | null {
const trimmedModel = model.trim()
if (!trimmedModel) return null
return AGY_MODEL_LABELS[trimmedModel as AgyModelPreset] ?? null
}
export function isClaudeModelPreset(model: string | null | undefined): model is ClaudeModelPreset {
return typeof model === 'string' && Object.hasOwn(CLAUDE_MODEL_LABELS, model)
}
+16 -4
View File
@@ -7,7 +7,7 @@ import { z } from 'zod'
*/
export const AGENT_MESSAGE_PAYLOAD_TYPE = 'codex' as const
export const AGENT_FLAVORS = ['claude', 'codex', 'copilot', 'cursor', 'gemini', 'grok', 'kimi', 'opencode', 'pi'] as const
export const AGENT_FLAVORS = ['agy', 'claude', 'codex', 'copilot', 'cursor', 'gemini', 'grok', 'kimi', 'opencode', 'pi'] as const
export type AgentFlavor = typeof AGENT_FLAVORS[number]
export const AgentFlavorSchema = z.enum(AGENT_FLAVORS)
@@ -19,6 +19,9 @@ export const CREATABLE_AGENT_FLAVORS: readonly AgentFlavor[] = AGENT_FLAVORS.fil
(flavor) => flavor !== 'gemini'
)
export const AGY_PERMISSION_MODES = ['request-review', 'always-proceed'] as const
export type AgyPermissionMode = typeof AGY_PERMISSION_MODES[number]
export const CLAUDE_PERMISSION_MODES = ['default', 'acceptEdits', 'auto', 'bypassPermissions', 'plan'] as const
export type ClaudePermissionMode = typeof CLAUDE_PERMISSION_MODES[number]
@@ -57,7 +60,9 @@ export const PERMISSION_MODES = [
'autoReview',
'read-only',
'safe-yolo',
'yolo'
'yolo',
'request-review',
'always-proceed'
] as const
export type PermissionMode = typeof PERMISSION_MODES[number]
@@ -73,7 +78,9 @@ export const PERMISSION_MODE_LABELS: Record<PermissionMode, string> = {
bypassPermissions: 'Yolo',
'read-only': 'Read Only',
'safe-yolo': 'Safe Yolo',
yolo: 'Yolo'
yolo: 'Yolo',
'request-review': 'Request Review',
'always-proceed': 'Always Proceed'
}
export type PermissionModeTone = 'neutral' | 'info' | 'warning' | 'danger'
@@ -89,7 +96,9 @@ export const PERMISSION_MODE_TONES: Record<PermissionMode, PermissionModeTone> =
bypassPermissions: 'danger',
'read-only': 'warning',
'safe-yolo': 'warning',
yolo: 'danger'
yolo: 'danger',
'request-review': 'neutral',
'always-proceed': 'danger'
}
export type PermissionModeOption = {
@@ -139,6 +148,9 @@ export function getPermissionModesForFlavor(flavor?: string | null): readonly Pe
if (flavor === 'opencode') {
return OPENCODE_PERMISSION_MODES
}
if (flavor === 'agy') {
return AGY_PERMISSION_MODES
}
if (flavor === 'cursor') {
return CURSOR_PERMISSION_MODES
}
+1
View File
@@ -40,6 +40,7 @@ export const RPC_METHODS = {
ListCopilotModelsForCwd: 'listCopilotModelsForCwd',
ListCopilotModels: 'listCopilotModels',
ListOpencodeReasoningEffortOptions: 'listOpencodeReasoningEffortOptions',
ListAgyModels: 'listAgyModels',
ForkConversation: 'fork-conversation',
RewindConversation: 'rewind-conversation',
} as const
+13
View File
@@ -70,6 +70,7 @@ export const MetadataSchema = z.object({
geminiSessionId: z.string().optional(),
opencodeSessionId: z.string().optional(),
grokSessionId: z.string().optional(),
agySessionId: z.string().optional(),
cursorSessionId: z.string().optional(),
cursorSessionProtocol: z.enum(['acp', 'stream-json']).optional(),
// Drives the web `CursorMigrationBanner`:
@@ -94,6 +95,11 @@ export const MetadataSchema = z.object({
archiveReason: z.string().optional(),
}).optional(),
}).optional(),
ptyResumeAttempt: z.object({
state: z.enum(['resuming', 'quarantined']),
machineId: z.string(),
startedAt: z.number(),
}).optional(),
tools: z.array(z.string()).optional(),
slashCommands: z.array(z.string()).optional(),
homeDir: z.string().optional(),
@@ -116,6 +122,9 @@ export const MetadataSchema = z.object({
preferredPermissionMode: PermissionModeSchema.optional(),
preferredCopilotAgentMode: CopilotAgentModeSchema.optional(),
flavor: z.string().nullish(),
// Launch mode, surfaced so the web can show the agent-terminal toggle only
// for PTY sessions (a 'remote'/SDK session has no agent PTY to view).
startingMode: z.enum(['local', 'remote', 'pty']).nullish(),
capabilities: SessionCapabilitiesSchema.optional(),
conversationHistoryPoints: z.record(z.string(), z.literal(true)).optional(),
// Native locators for historical fork/rewind (e.g. Grok prompt indexes).
@@ -168,6 +177,10 @@ export type AgentStateCompletedRequest = z.infer<typeof AgentStateCompletedReque
export const AgentStateSchema = z.object({
controlledByUser: z.boolean().nullish(),
// The mode the session was started in. Persisted so reopen/resume can
// re-spawn in the same mode — notably 'pty', which has no agent terminal
// otherwise (a reopened PTY session would silently fall back to 'remote').
startingMode: z.enum(['local', 'remote', 'pty']).nullish(),
requests: z.record(z.string(), AgentStateRequestSchema).nullish(),
completedRequests: z.record(z.string(), AgentStateCompletedRequestSchema).nullish()
})
+2
View File
@@ -114,6 +114,7 @@ const AGENT_SESSION_ID_FIELD_BY_FLAVOR = {
gemini: 'geminiSessionId',
opencode: 'opencodeSessionId',
grok: 'grokSessionId',
agy: 'agySessionId',
cursor: 'cursorSessionId',
kimi: 'kimiSessionId',
copilot: 'copilotSessionId',
@@ -136,6 +137,7 @@ function getSummaryAgentSessionId(metadata: Metadata): string | undefined {
?? metadata.geminiSessionId
?? metadata.opencodeSessionId
?? metadata.grokSessionId
?? metadata.agySessionId
?? metadata.cursorSessionId
?? metadata.kimiSessionId
?? metadata.copilotSessionId
+46 -1
View File
@@ -32,6 +32,35 @@ export const TerminalResizePayloadSchema = z.object({
export type TerminalResizePayload = z.infer<typeof TerminalResizePayloadSchema>
// Read-only agent-terminal viewer controls (no terminalId — the agent PTY is the
// session's single TUI, keyed by sessionId). `resize` repaints the agent TUI at a
// given size; `refresh` forces a repaint of the current screen so a freshly
// (re)subscribed viewer sees the live state instead of a stale/black buffer.
export const AgentTerminalResizePayloadSchema = z.object({
sessionId: z.string().min(1),
cols: z.number().int().positive(),
rows: z.number().int().positive()
})
export type AgentTerminalResizePayload = z.infer<typeof AgentTerminalResizePayloadSchema>
export const AgentTerminalRefreshPayloadSchema = z.object({
sessionId: z.string().min(1)
})
export type AgentTerminalRefreshPayload = z.infer<typeof AgentTerminalRefreshPayloadSchema>
// Raw keystroke(s) typed into the agent TUI from a web viewer — the agent PTY is
// the session's single TUI, keyed by sessionId (no terminalId). Lets a remote
// viewer navigate TUI screens (e.g. answer/escape a /usage or /model dialog) that
// the structured chat composer cannot express.
export const AgentTerminalInputPayloadSchema = z.object({
sessionId: z.string().min(1),
data: z.string().min(1)
})
export type AgentTerminalInputPayload = z.infer<typeof AgentTerminalInputPayloadSchema>
export const TerminalClosePayloadSchema = z.object({
sessionId: z.string().min(1),
terminalId: z.string().min(1)
@@ -197,11 +226,23 @@ export interface ServerToClientEvents {
'terminal:write': (data: TerminalWritePayload) => void
'terminal:resize': (data: TerminalResizePayload) => void
'terminal:close': (data: TerminalClosePayload) => void
'agent-terminal:resize': (data: AgentTerminalResizePayload) => void
'agent-terminal:refresh': (data: AgentTerminalRefreshPayload) => void
// Raw keystroke(s) from a web viewer, relayed to the CLI to write into the
// agent PTY (interactive TUI navigation; see AgentTerminalInputPayload).
'agent-terminal:input': (data: AgentTerminalInputPayload) => void
// Sent to the CLI when the last agent-terminal viewer leaves, so it stops
// streaming PTY output to the hub until someone subscribes again.
'agent-terminal:idle': (data: AgentTerminalRefreshPayload) => void
error: (data: { message: string; code?: SocketErrorReason; scope?: 'session' | 'machine'; id?: string }) => void
}
export interface ClientToServerEvents {
message: (data: { sid: string; message: unknown; localId?: string }) => void
// createdAt: optional client-provided timestamp (epoch ms) for the entry's
// own origin time (e.g. a Claude transcript entry's timestamp). Currently
// only honored by the hub for agent messages (no localId) — see
// messages.ts addMessage. Absent -> hub falls back to Date.now().
message: (data: { sid: string; message: unknown; localId?: string; createdAt?: number }) => void
'session-alive': (data: {
sid: string
time: number
@@ -230,6 +271,10 @@ export interface ClientToServerEvents {
'terminal:output': (data: TerminalOutputPayload) => void
'terminal:exit': (data: TerminalExitPayload) => void
'terminal:error': (data: TerminalErrorPayload) => void
'agent-terminal:output': (data: TerminalOutputPayload) => void
// Drop the hub's scrollback buffer for this session (a new agent PTY just
// spawned, e.g. after archive→restart, so old output must not replay).
'agent-terminal:reset': (data: { sessionId: string }) => void
ping: (callback: () => void) => void
'usage-report': (data: unknown) => void
}
+1
View File
@@ -31,6 +31,7 @@ export { AGENT_MESSAGE_PAYLOAD_TYPE } from './modes'
export type {
AgentFlavor,
AgyPermissionMode,
ClaudePermissionMode,
CodexCollaborationMode,
CodexCollaborationModeOption,