mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
feat(agy): add Antigravity as an interactive PTY agent (#1320)
This commit is contained in:
+16
-1
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ export const RPC_METHODS = {
|
||||
ListCopilotModelsForCwd: 'listCopilotModelsForCwd',
|
||||
ListCopilotModels: 'listCopilotModels',
|
||||
ListOpencodeReasoningEffortOptions: 'listOpencodeReasoningEffortOptions',
|
||||
ListAgyModels: 'listAgyModels',
|
||||
ForkConversation: 'fork-conversation',
|
||||
RewindConversation: 'rewind-conversation',
|
||||
} as const
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ export { AGENT_MESSAGE_PAYLOAD_TYPE } from './modes'
|
||||
|
||||
export type {
|
||||
AgentFlavor,
|
||||
AgyPermissionMode,
|
||||
ClaudePermissionMode,
|
||||
CodexCollaborationMode,
|
||||
CodexCollaborationModeOption,
|
||||
|
||||
Reference in New Issue
Block a user