mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
Unify protocol types and modes
This commit is contained in:
@@ -50,6 +50,7 @@
|
||||
"release-all": "bun run scripts/release-all.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hapi/protocol": "workspace:*",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"@types/cross-spawn": "^6.0.6",
|
||||
"@types/ps-list": "^6.2.1",
|
||||
|
||||
+7
-1
@@ -55,6 +55,7 @@ export class ApiClient {
|
||||
|
||||
return {
|
||||
id: raw.id,
|
||||
namespace: raw.namespace,
|
||||
seq: raw.seq,
|
||||
createdAt: raw.createdAt,
|
||||
updatedAt: raw.updatedAt,
|
||||
@@ -63,7 +64,12 @@ export class ApiClient {
|
||||
metadata,
|
||||
metadataVersion: raw.metadataVersion,
|
||||
agentState,
|
||||
agentStateVersion: raw.agentStateVersion
|
||||
agentStateVersion: raw.agentStateVersion,
|
||||
thinking: raw.thinking,
|
||||
thinkingAt: raw.thinkingAt,
|
||||
todos: raw.todos,
|
||||
permissionMode: raw.permissionMode,
|
||||
modelMode: raw.modelMode
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+13
-139
@@ -1,3 +1,5 @@
|
||||
import { AgentStateSchema, MetadataSchema, ModelModeSchema, PermissionModeSchema, TodosSchema } from '@hapi/protocol/schemas'
|
||||
import type { ModelMode, PermissionMode } from '@hapi/protocol/types'
|
||||
import { z } from 'zod'
|
||||
import { UsageSchema } from '@/claude/types'
|
||||
import type {
|
||||
@@ -13,145 +15,11 @@ import type {
|
||||
|
||||
export type Usage = z.infer<typeof UsageSchema>
|
||||
|
||||
export type ClaudePermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan'
|
||||
export type CodexPermissionMode = 'default' | 'read-only' | 'safe-yolo' | 'yolo'
|
||||
export type SessionPermissionMode = ClaudePermissionMode | CodexPermissionMode
|
||||
export type SessionModelMode = 'default' | 'sonnet' | 'opus'
|
||||
export type { AgentState, ClaudePermissionMode, CodexPermissionMode, Metadata, Session } from '@hapi/protocol/types'
|
||||
export type SessionPermissionMode = PermissionMode
|
||||
export type SessionModelMode = ModelMode
|
||||
|
||||
export type Metadata = {
|
||||
path: string
|
||||
host: string
|
||||
version?: string
|
||||
name?: string
|
||||
os?: string
|
||||
summary?: {
|
||||
text: string
|
||||
updatedAt: number
|
||||
}
|
||||
machineId?: string
|
||||
claudeSessionId?: string
|
||||
codexSessionId?: string
|
||||
tools?: string[]
|
||||
slashCommands?: string[]
|
||||
homeDir: string
|
||||
happyHomeDir: string
|
||||
happyLibDir: string
|
||||
happyToolsDir: string
|
||||
startedFromDaemon?: boolean
|
||||
hostPid?: number
|
||||
startedBy?: 'daemon' | 'terminal'
|
||||
lifecycleState?: 'running' | 'archiveRequested' | 'archived' | string
|
||||
lifecycleStateSince?: number
|
||||
archivedBy?: string
|
||||
archiveReason?: string
|
||||
flavor?: string
|
||||
worktree?: {
|
||||
basePath: string
|
||||
branch: string
|
||||
name: string
|
||||
worktreePath?: string
|
||||
createdAt?: number
|
||||
}
|
||||
}
|
||||
|
||||
export const MetadataSchema = z.object({
|
||||
path: z.string(),
|
||||
host: z.string(),
|
||||
version: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
os: z.string().optional(),
|
||||
summary: z.object({
|
||||
text: z.string(),
|
||||
updatedAt: z.number()
|
||||
}).optional(),
|
||||
machineId: z.string().optional(),
|
||||
claudeSessionId: z.string().optional(),
|
||||
codexSessionId: z.string().optional(),
|
||||
tools: z.array(z.string()).optional(),
|
||||
slashCommands: z.array(z.string()).optional(),
|
||||
homeDir: z.string(),
|
||||
happyHomeDir: z.string(),
|
||||
happyLibDir: z.string(),
|
||||
happyToolsDir: z.string(),
|
||||
startedFromDaemon: z.boolean().optional(),
|
||||
hostPid: z.number().optional(),
|
||||
startedBy: z.enum(['daemon', 'terminal']).optional(),
|
||||
lifecycleState: z.string().optional(),
|
||||
lifecycleStateSince: z.number().optional(),
|
||||
archivedBy: z.string().optional(),
|
||||
archiveReason: z.string().optional(),
|
||||
flavor: z.string().optional(),
|
||||
worktree: z.object({
|
||||
basePath: z.string(),
|
||||
branch: z.string(),
|
||||
name: z.string(),
|
||||
worktreePath: z.string().optional(),
|
||||
createdAt: z.number().optional()
|
||||
}).optional()
|
||||
}).passthrough()
|
||||
|
||||
export type AgentState = {
|
||||
controlledByUser?: boolean | null | undefined
|
||||
requests?: {
|
||||
[id: string]: {
|
||||
tool: string
|
||||
arguments?: unknown
|
||||
createdAt?: number | null | undefined
|
||||
}
|
||||
}
|
||||
completedRequests?: {
|
||||
[id: string]: {
|
||||
tool: string
|
||||
arguments?: unknown
|
||||
createdAt?: number | null | undefined
|
||||
completedAt?: number | null | undefined
|
||||
status: 'canceled' | 'denied' | 'approved'
|
||||
reason?: string
|
||||
mode?: string
|
||||
decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort'
|
||||
allowTools?: string[]
|
||||
answers?: Record<string, string[]>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const AgentStateSchema = z.object({
|
||||
controlledByUser: z.boolean().nullish(),
|
||||
requests: z.record(z.string(), z.object({
|
||||
tool: z.string(),
|
||||
arguments: z.unknown(),
|
||||
createdAt: z.number().nullish()
|
||||
})).optional(),
|
||||
completedRequests: z.record(z.string(), z.object({
|
||||
tool: z.string(),
|
||||
arguments: z.unknown(),
|
||||
createdAt: z.number().nullish(),
|
||||
completedAt: z.number().nullish(),
|
||||
status: z.enum(['canceled', 'denied', 'approved']),
|
||||
reason: z.string().optional(),
|
||||
mode: z.string().optional(),
|
||||
decision: z.enum(['approved', 'approved_for_session', 'denied', 'abort']).optional(),
|
||||
allowTools: z.array(z.string()).optional(),
|
||||
answers: z.record(z.string(), z.array(z.string())).optional()
|
||||
})).optional()
|
||||
}).passthrough()
|
||||
|
||||
export type Session = {
|
||||
id: string
|
||||
seq: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
active: boolean
|
||||
activeAt: number
|
||||
metadata: Metadata | null
|
||||
metadataVersion: number
|
||||
agentState: AgentState | null
|
||||
agentStateVersion: number
|
||||
thinking?: boolean
|
||||
thinkingAt?: number
|
||||
permissionMode?: SessionPermissionMode
|
||||
modelMode?: SessionModelMode
|
||||
}
|
||||
export { AgentStateSchema, MetadataSchema }
|
||||
|
||||
export const MachineMetadataSchema = z.object({
|
||||
host: z.string(),
|
||||
@@ -256,6 +124,7 @@ export type CliMessagesResponse = z.infer<typeof CliMessagesResponseSchema>
|
||||
export const CreateSessionResponseSchema = z.object({
|
||||
session: z.object({
|
||||
id: z.string(),
|
||||
namespace: z.string(),
|
||||
seq: z.number(),
|
||||
createdAt: z.number(),
|
||||
updatedAt: z.number(),
|
||||
@@ -264,7 +133,12 @@ export const CreateSessionResponseSchema = z.object({
|
||||
metadata: z.unknown().nullable(),
|
||||
metadataVersion: z.number(),
|
||||
agentState: z.unknown().nullable(),
|
||||
agentStateVersion: z.number()
|
||||
agentStateVersion: z.number(),
|
||||
thinking: z.boolean(),
|
||||
thinkingAt: z.number(),
|
||||
todos: TodosSchema.optional(),
|
||||
permissionMode: PermissionModeSchema.optional(),
|
||||
modelMode: ModelModeSchema.optional()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "hapi",
|
||||
"private": true,
|
||||
"workspaces": ["cli", "server", "web", "website", "docs"],
|
||||
"workspaces": ["cli", "shared", "server", "web", "website", "docs"],
|
||||
"scripts": {
|
||||
"build:site": "bun run --cwd website build && bun run --cwd docs docs:build && cp -r docs/.vitepress/dist website/dist/public/docs",
|
||||
"dev": "concurrently \"bun run dev:server\" \"bun run dev:web\" --kill-others-on-exit",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"generate:embedded-web-assets": "bun run scripts/generate-embedded-web-assets.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hapi/protocol": "workspace:*",
|
||||
"@socket.io/bun-engine": "^0.1.0",
|
||||
"grammy": "^1.38.4",
|
||||
"hono": "^4.11.2",
|
||||
|
||||
@@ -7,61 +7,17 @@
|
||||
* - No E2E encryption; data is stored as JSON in SQLite
|
||||
*/
|
||||
|
||||
import { AgentStateSchema, MetadataSchema } from '@hapi/protocol/schemas'
|
||||
import type { ClaudePermissionMode, DecryptedMessage, ModelMode, PermissionMode, Session, SyncEvent } from '@hapi/protocol/types'
|
||||
import { z } from 'zod'
|
||||
import type { Server } from 'socket.io'
|
||||
import type { Store } from '../store'
|
||||
import type { RpcRegistry } from '../socket/rpcRegistry'
|
||||
import type { SSEManager } from '../sse/sseManager'
|
||||
import { extractTodoWriteTodosFromMessageContent, TodosSchema, type TodoItem } from './todos'
|
||||
import { extractTodoWriteTodosFromMessageContent, TodosSchema } from './todos'
|
||||
|
||||
export type ConnectionStatus = 'disconnected' | 'connected'
|
||||
|
||||
export const MetadataSchema = z.object({
|
||||
path: z.string(),
|
||||
host: z.string(),
|
||||
version: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
os: z.string().optional(),
|
||||
summary: z.object({
|
||||
text: z.string(),
|
||||
updatedAt: z.number()
|
||||
}).optional(),
|
||||
machineId: z.string().optional(),
|
||||
tools: z.array(z.string()).optional(),
|
||||
flavor: z.string().nullish(),
|
||||
worktree: z.object({
|
||||
basePath: z.string(),
|
||||
branch: z.string(),
|
||||
name: z.string(),
|
||||
worktreePath: z.string().optional(),
|
||||
createdAt: z.number().optional()
|
||||
}).optional()
|
||||
}).passthrough()
|
||||
|
||||
export type Metadata = z.infer<typeof MetadataSchema>
|
||||
|
||||
export const AgentStateSchema = z.object({
|
||||
controlledByUser: z.boolean().nullish(),
|
||||
requests: z.record(z.string(), z.object({
|
||||
tool: z.string(),
|
||||
arguments: z.unknown(),
|
||||
createdAt: z.number().nullish()
|
||||
}).passthrough()).nullish(),
|
||||
completedRequests: z.record(z.string(), z.object({
|
||||
tool: z.string(),
|
||||
arguments: z.unknown(),
|
||||
createdAt: z.number().nullish(),
|
||||
completedAt: z.number().nullish(),
|
||||
status: z.enum(['canceled', 'denied', 'approved']),
|
||||
reason: z.string().optional(),
|
||||
mode: z.string().optional(),
|
||||
decision: z.enum(['approved', 'approved_for_session', 'denied', 'abort']).optional(),
|
||||
allowTools: z.array(z.string()).optional(),
|
||||
answers: z.record(z.string(), z.array(z.string())).optional()
|
||||
}).passthrough()).nullish()
|
||||
}).passthrough()
|
||||
|
||||
export type AgentState = z.infer<typeof AgentStateSchema>
|
||||
export type { Session, SyncEvent } from '@hapi/protocol/types'
|
||||
|
||||
const machineMetadataSchema = z.object({
|
||||
host: z.string().optional(),
|
||||
@@ -70,25 +26,6 @@ const machineMetadataSchema = z.object({
|
||||
displayName: z.string().optional()
|
||||
}).passthrough()
|
||||
|
||||
export interface Session {
|
||||
id: string
|
||||
namespace: string
|
||||
seq: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
active: boolean
|
||||
activeAt: number
|
||||
metadata: Metadata | null
|
||||
metadataVersion: number
|
||||
agentState: AgentState | null
|
||||
agentStateVersion: number
|
||||
thinking: boolean
|
||||
thinkingAt: number
|
||||
todos?: TodoItem[]
|
||||
permissionMode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo'
|
||||
modelMode?: 'default' | 'sonnet' | 'opus'
|
||||
}
|
||||
|
||||
export interface Machine {
|
||||
id: string
|
||||
namespace: string
|
||||
@@ -109,14 +46,6 @@ export interface Machine {
|
||||
daemonStateVersion: number
|
||||
}
|
||||
|
||||
export interface DecryptedMessage {
|
||||
id: string
|
||||
seq: number
|
||||
localId: string | null
|
||||
content: unknown
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
export type FetchMessagesResult =
|
||||
| { ok: true; messages: DecryptedMessage[] }
|
||||
| { ok: false; status: number | null; error: string }
|
||||
@@ -139,23 +68,6 @@ export type RpcPathExistsResponse = {
|
||||
exists: Record<string, boolean>
|
||||
}
|
||||
|
||||
export type SyncEventType =
|
||||
| 'session-added'
|
||||
| 'session-updated'
|
||||
| 'session-removed'
|
||||
| 'message-received'
|
||||
| 'machine-updated'
|
||||
| 'connection-changed'
|
||||
|
||||
export interface SyncEvent {
|
||||
type: SyncEventType
|
||||
namespace?: string
|
||||
sessionId?: string
|
||||
machineId?: string
|
||||
data?: unknown
|
||||
message?: DecryptedMessage
|
||||
}
|
||||
|
||||
export type SyncEventListener = (event: SyncEvent) => void
|
||||
|
||||
function clampAliveTime(t: number): number | null {
|
||||
@@ -383,8 +295,8 @@ export class SyncEngine {
|
||||
time: number
|
||||
thinking?: boolean
|
||||
mode?: 'local' | 'remote'
|
||||
permissionMode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo'
|
||||
modelMode?: 'default' | 'sonnet' | 'opus'
|
||||
permissionMode?: PermissionMode
|
||||
modelMode?: ModelMode
|
||||
}): void {
|
||||
const t = clampAliveTime(payload.time)
|
||||
if (!t) return
|
||||
@@ -697,7 +609,7 @@ export class SyncEngine {
|
||||
async approvePermission(
|
||||
sessionId: string,
|
||||
requestId: string,
|
||||
mode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan',
|
||||
mode?: ClaudePermissionMode,
|
||||
allowTools?: string[],
|
||||
decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort',
|
||||
answers?: Record<string, string[]>
|
||||
@@ -739,7 +651,7 @@ export class SyncEngine {
|
||||
|
||||
async setPermissionMode(
|
||||
sessionId: string,
|
||||
mode: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo'
|
||||
mode: PermissionMode
|
||||
): Promise<void> {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (session) {
|
||||
@@ -748,7 +660,7 @@ export class SyncEngine {
|
||||
}
|
||||
}
|
||||
|
||||
async setModelMode(sessionId: string, model: 'default' | 'sonnet' | 'opus'): Promise<void> {
|
||||
async setModelMode(sessionId: string, model: ModelMode): Promise<void> {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (session) {
|
||||
session.modelMode = model
|
||||
@@ -810,8 +722,8 @@ export class SyncEngine {
|
||||
async applySessionConfig(
|
||||
sessionId: string,
|
||||
config: {
|
||||
permissionMode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo'
|
||||
modelMode?: 'default' | 'sonnet' | 'opus'
|
||||
permissionMode?: PermissionMode
|
||||
modelMode?: ModelMode
|
||||
}
|
||||
): Promise<void> {
|
||||
const result = await this.sessionRpc(sessionId, 'set-session-config', config)
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
import { z } from 'zod'
|
||||
import { TodoItemSchema, TodosSchema } from '@hapi/protocol/schemas'
|
||||
import type { TodoItem } from '@hapi/protocol/types'
|
||||
|
||||
export const TodoItemSchema = z.object({
|
||||
content: z.string(),
|
||||
status: z.enum(['pending', 'in_progress', 'completed']),
|
||||
priority: z.enum(['high', 'medium', 'low']),
|
||||
id: z.string()
|
||||
}).passthrough()
|
||||
|
||||
export type TodoItem = z.infer<typeof TodoItemSchema>
|
||||
|
||||
export const TodosSchema = z.array(TodoItemSchema)
|
||||
export { TodoItemSchema, TodosSchema }
|
||||
export type { TodoItem }
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object'
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { isModelModeAllowedForFlavor, isPermissionModeAllowedForFlavor } from '@hapi/protocol'
|
||||
import { ModelModeSchema, PermissionModeSchema } from '@hapi/protocol/schemas'
|
||||
import { Hono } from 'hono'
|
||||
import { z } from 'zod'
|
||||
import type { ModelMode } from '@hapi/protocol/types'
|
||||
import type { SyncEngine, Session } from '../../sync/syncEngine'
|
||||
import type { WebAppEnv } from '../middleware/auth'
|
||||
import { requireSessionFromParam, requireSyncEngine } from './guards'
|
||||
@@ -27,7 +30,7 @@ type SessionSummary = {
|
||||
metadata: SessionSummaryMetadata | null
|
||||
todoProgress: { completed: number; total: number } | null
|
||||
pendingRequestsCount: number
|
||||
modelMode?: 'default' | 'sonnet' | 'opus'
|
||||
modelMode?: ModelMode
|
||||
}
|
||||
|
||||
function toSessionSummary(session: Session): SessionSummary {
|
||||
@@ -60,11 +63,11 @@ function toSessionSummary(session: Session): SessionSummary {
|
||||
}
|
||||
|
||||
const permissionModeSchema = z.object({
|
||||
mode: z.enum(['default', 'acceptEdits', 'bypassPermissions', 'plan', 'read-only', 'safe-yolo', 'yolo'])
|
||||
mode: PermissionModeSchema
|
||||
})
|
||||
|
||||
const modelModeSchema = z.object({
|
||||
model: z.enum(['default', 'sonnet', 'opus'])
|
||||
model: ModelModeSchema
|
||||
})
|
||||
|
||||
const renameSessionSchema = z.object({
|
||||
@@ -181,14 +184,12 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho
|
||||
|
||||
const flavor = sessionResult.session.metadata?.flavor ?? 'claude'
|
||||
const mode = parsed.data.mode
|
||||
const claudeModes = new Set(['default', 'acceptEdits', 'bypassPermissions', 'plan'])
|
||||
const codexModes = new Set(['default', 'read-only', 'safe-yolo', 'yolo'])
|
||||
|
||||
if (flavor === 'gemini') {
|
||||
return c.json({ error: 'Permission mode not supported for Gemini sessions' }, 400)
|
||||
}
|
||||
|
||||
if (flavor === 'codex' ? !codexModes.has(mode) : !claudeModes.has(mode)) {
|
||||
if (!isPermissionModeAllowedForFlavor(mode, flavor)) {
|
||||
return c.json({ error: 'Invalid permission mode for session flavor' }, 400)
|
||||
}
|
||||
|
||||
@@ -219,7 +220,7 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho
|
||||
}
|
||||
|
||||
const flavor = sessionResult.session.metadata?.flavor ?? 'claude'
|
||||
if (flavor !== 'claude') {
|
||||
if (!isModelModeAllowedForFlavor(parsed.data.model, flavor)) {
|
||||
return c.json({ error: 'Model mode is only supported for Claude sessions' }, 400)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@hapi/protocol",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./modes": "./src/modes.ts",
|
||||
"./schemas": "./src/schemas.ts",
|
||||
"./types": "./src/types.ts"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"dependencies": {
|
||||
"zod": "^4.2.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './modes'
|
||||
export type * from './types'
|
||||
@@ -0,0 +1,62 @@
|
||||
export const CLAUDE_PERMISSION_MODES = ['default', 'acceptEdits', 'bypassPermissions', 'plan'] as const
|
||||
export type ClaudePermissionMode = typeof CLAUDE_PERMISSION_MODES[number]
|
||||
|
||||
export const CODEX_PERMISSION_MODES = ['default', 'read-only', 'safe-yolo', 'yolo'] as const
|
||||
export type CodexPermissionMode = typeof CODEX_PERMISSION_MODES[number]
|
||||
|
||||
export const PERMISSION_MODES = [
|
||||
'default',
|
||||
'acceptEdits',
|
||||
'bypassPermissions',
|
||||
'plan',
|
||||
'read-only',
|
||||
'safe-yolo',
|
||||
'yolo'
|
||||
] as const
|
||||
export type PermissionMode = typeof PERMISSION_MODES[number]
|
||||
|
||||
export const MODEL_MODES = ['default', 'sonnet', 'opus'] as const
|
||||
export type ModelMode = typeof MODEL_MODES[number]
|
||||
|
||||
export type AgentFlavor = 'claude' | 'codex' | 'gemini'
|
||||
|
||||
export const PERMISSION_MODE_LABELS: Record<PermissionMode, string> = {
|
||||
default: 'Default',
|
||||
acceptEdits: 'Accept Edits',
|
||||
plan: 'Plan Mode',
|
||||
bypassPermissions: 'Yolo',
|
||||
'read-only': 'Read Only',
|
||||
'safe-yolo': 'Safe Yolo',
|
||||
yolo: 'Yolo'
|
||||
}
|
||||
|
||||
export const MODEL_MODE_LABELS: Record<ModelMode, string> = {
|
||||
default: 'Default',
|
||||
sonnet: 'Sonnet',
|
||||
opus: 'Opus'
|
||||
}
|
||||
|
||||
export function getPermissionModesForFlavor(flavor?: string | null): readonly PermissionMode[] {
|
||||
if (flavor === 'codex') {
|
||||
return CODEX_PERMISSION_MODES
|
||||
}
|
||||
if (flavor === 'gemini') {
|
||||
return []
|
||||
}
|
||||
return CLAUDE_PERMISSION_MODES
|
||||
}
|
||||
|
||||
export function isPermissionModeAllowedForFlavor(mode: PermissionMode, flavor?: string | null): boolean {
|
||||
return getPermissionModesForFlavor(flavor).includes(mode)
|
||||
}
|
||||
|
||||
export function getModelModesForFlavor(flavor?: string | null): readonly ModelMode[] {
|
||||
if (flavor === 'codex' || flavor === 'gemini') {
|
||||
return []
|
||||
}
|
||||
return MODEL_MODES
|
||||
}
|
||||
|
||||
export function isModelModeAllowedForFlavor(mode: ModelMode, flavor?: string | null): boolean {
|
||||
return getModelModesForFlavor(flavor).includes(mode)
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { z } from 'zod'
|
||||
import { MODEL_MODES, PERMISSION_MODES } from './modes'
|
||||
|
||||
export const PermissionModeSchema = z.enum(PERMISSION_MODES)
|
||||
export const ModelModeSchema = z.enum(MODEL_MODES)
|
||||
|
||||
const MetadataSummarySchema = z.object({
|
||||
text: z.string(),
|
||||
updatedAt: z.number()
|
||||
})
|
||||
|
||||
export const WorktreeMetadataSchema = z.object({
|
||||
basePath: z.string(),
|
||||
branch: z.string(),
|
||||
name: z.string(),
|
||||
worktreePath: z.string().optional(),
|
||||
createdAt: z.number().optional()
|
||||
}).passthrough()
|
||||
|
||||
export type WorktreeMetadata = z.infer<typeof WorktreeMetadataSchema>
|
||||
|
||||
export const MetadataSchema = z.object({
|
||||
path: z.string(),
|
||||
host: z.string(),
|
||||
version: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
os: z.string().optional(),
|
||||
summary: MetadataSummarySchema.optional(),
|
||||
machineId: z.string().optional(),
|
||||
claudeSessionId: z.string().optional(),
|
||||
codexSessionId: z.string().optional(),
|
||||
tools: z.array(z.string()).optional(),
|
||||
slashCommands: z.array(z.string()).optional(),
|
||||
homeDir: z.string().optional(),
|
||||
happyHomeDir: z.string().optional(),
|
||||
happyLibDir: z.string().optional(),
|
||||
happyToolsDir: z.string().optional(),
|
||||
startedFromDaemon: z.boolean().optional(),
|
||||
hostPid: z.number().optional(),
|
||||
startedBy: z.enum(['daemon', 'terminal']).optional(),
|
||||
lifecycleState: z.string().optional(),
|
||||
lifecycleStateSince: z.number().optional(),
|
||||
archivedBy: z.string().optional(),
|
||||
archiveReason: z.string().optional(),
|
||||
flavor: z.string().nullish(),
|
||||
worktree: WorktreeMetadataSchema.optional()
|
||||
}).passthrough()
|
||||
|
||||
export type Metadata = z.infer<typeof MetadataSchema>
|
||||
|
||||
export const AgentStateRequestSchema = z.object({
|
||||
tool: z.string(),
|
||||
arguments: z.unknown(),
|
||||
createdAt: z.number().nullish()
|
||||
}).passthrough()
|
||||
|
||||
export type AgentStateRequest = z.infer<typeof AgentStateRequestSchema>
|
||||
|
||||
export const AgentStateCompletedRequestSchema = z.object({
|
||||
tool: z.string(),
|
||||
arguments: z.unknown(),
|
||||
createdAt: z.number().nullish(),
|
||||
completedAt: z.number().nullish(),
|
||||
status: z.enum(['canceled', 'denied', 'approved']),
|
||||
reason: z.string().optional(),
|
||||
mode: z.string().optional(),
|
||||
decision: z.enum(['approved', 'approved_for_session', 'denied', 'abort']).optional(),
|
||||
allowTools: z.array(z.string()).optional(),
|
||||
answers: z.record(z.string(), z.array(z.string())).optional()
|
||||
}).passthrough()
|
||||
|
||||
export type AgentStateCompletedRequest = z.infer<typeof AgentStateCompletedRequestSchema>
|
||||
|
||||
export const AgentStateSchema = z.object({
|
||||
controlledByUser: z.boolean().nullish(),
|
||||
requests: z.record(z.string(), AgentStateRequestSchema).nullish(),
|
||||
completedRequests: z.record(z.string(), AgentStateCompletedRequestSchema).nullish()
|
||||
}).passthrough()
|
||||
|
||||
export type AgentState = z.infer<typeof AgentStateSchema>
|
||||
|
||||
export const TodoItemSchema = z.object({
|
||||
content: z.string(),
|
||||
status: z.enum(['pending', 'in_progress', 'completed']),
|
||||
priority: z.enum(['high', 'medium', 'low']),
|
||||
id: z.string()
|
||||
}).passthrough()
|
||||
|
||||
export type TodoItem = z.infer<typeof TodoItemSchema>
|
||||
|
||||
export const TodosSchema = z.array(TodoItemSchema)
|
||||
|
||||
export const DecryptedMessageSchema = z.object({
|
||||
id: z.string(),
|
||||
seq: z.number().nullable(),
|
||||
localId: z.string().nullable(),
|
||||
content: z.unknown(),
|
||||
createdAt: z.number()
|
||||
}).passthrough()
|
||||
|
||||
export type DecryptedMessage = z.infer<typeof DecryptedMessageSchema>
|
||||
|
||||
export const SessionSchema = z.object({
|
||||
id: z.string(),
|
||||
namespace: z.string(),
|
||||
seq: z.number(),
|
||||
createdAt: z.number(),
|
||||
updatedAt: z.number(),
|
||||
active: z.boolean(),
|
||||
activeAt: z.number(),
|
||||
metadata: MetadataSchema.nullable(),
|
||||
metadataVersion: z.number(),
|
||||
agentState: AgentStateSchema.nullable(),
|
||||
agentStateVersion: z.number(),
|
||||
thinking: z.boolean(),
|
||||
thinkingAt: z.number(),
|
||||
todos: TodosSchema.optional(),
|
||||
permissionMode: PermissionModeSchema.optional(),
|
||||
modelMode: ModelModeSchema.optional()
|
||||
}).passthrough()
|
||||
|
||||
export type Session = z.infer<typeof SessionSchema>
|
||||
|
||||
const SessionEventBaseSchema = z.object({
|
||||
namespace: z.string().optional()
|
||||
})
|
||||
|
||||
const SessionChangedSchema = SessionEventBaseSchema.extend({
|
||||
sessionId: z.string()
|
||||
})
|
||||
|
||||
const MachineChangedSchema = SessionEventBaseSchema.extend({
|
||||
machineId: z.string()
|
||||
})
|
||||
|
||||
export const SyncEventSchema = z.discriminatedUnion('type', [
|
||||
SessionChangedSchema.extend({
|
||||
type: z.literal('session-added'),
|
||||
data: z.unknown().optional()
|
||||
}),
|
||||
SessionChangedSchema.extend({
|
||||
type: z.literal('session-updated'),
|
||||
data: z.unknown().optional()
|
||||
}),
|
||||
SessionEventBaseSchema.extend({
|
||||
type: z.literal('session-removed'),
|
||||
sessionId: z.string()
|
||||
}),
|
||||
SessionChangedSchema.extend({
|
||||
type: z.literal('message-received'),
|
||||
message: DecryptedMessageSchema
|
||||
}),
|
||||
MachineChangedSchema.extend({
|
||||
type: z.literal('machine-updated'),
|
||||
data: z.unknown().optional()
|
||||
}),
|
||||
SessionEventBaseSchema.extend({
|
||||
type: z.literal('connection-changed'),
|
||||
data: z.object({
|
||||
status: z.string()
|
||||
}).optional()
|
||||
})
|
||||
])
|
||||
|
||||
export type SyncEvent = z.infer<typeof SyncEventSchema>
|
||||
@@ -0,0 +1,19 @@
|
||||
export type {
|
||||
AgentState,
|
||||
AgentStateCompletedRequest,
|
||||
AgentStateRequest,
|
||||
DecryptedMessage,
|
||||
Metadata,
|
||||
Session,
|
||||
SyncEvent,
|
||||
TodoItem,
|
||||
WorktreeMetadata
|
||||
} from './schemas'
|
||||
|
||||
export type {
|
||||
AgentFlavor,
|
||||
ClaudePermissionMode,
|
||||
CodexPermissionMode,
|
||||
ModelMode,
|
||||
PermissionMode
|
||||
} from './modes'
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["es2020", "es2022"],
|
||||
"rootDir": "src",
|
||||
"noEmit": true,
|
||||
"types": ["node", "bun-types"],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules"],
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts"]
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hapi/protocol": "workspace:*",
|
||||
"@assistant-ui/react": "^0.11.53",
|
||||
"@assistant-ui/react-markdown": "^0.11.9",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
|
||||
@@ -6,6 +6,8 @@ import type {
|
||||
MachinePathsExistsResponse,
|
||||
MachinesResponse,
|
||||
MessagesResponse,
|
||||
ModelMode,
|
||||
PermissionMode,
|
||||
PushSubscriptionPayload,
|
||||
PushUnsubscribePayload,
|
||||
PushVapidPublicKeyResponse,
|
||||
@@ -253,14 +255,14 @@ export class ApiClient {
|
||||
})
|
||||
}
|
||||
|
||||
async setPermissionMode(sessionId: string, mode: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo'): Promise<void> {
|
||||
async setPermissionMode(sessionId: string, mode: PermissionMode): Promise<void> {
|
||||
await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/permission-mode`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ mode })
|
||||
})
|
||||
}
|
||||
|
||||
async setModelMode(sessionId: string, model: 'default' | 'sonnet' | 'opus'): Promise<void> {
|
||||
async setModelMode(sessionId: string, model: ModelMode): Promise<void> {
|
||||
await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/model`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ model })
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getPermissionModesForFlavor, MODEL_MODE_LABELS, MODEL_MODES, PERMISSION_MODE_LABELS } from '@hapi/protocol'
|
||||
import { ComposerPrimitive, useAssistantApi, useAssistantState } from '@assistant-ui/react'
|
||||
import {
|
||||
type ChangeEvent as ReactChangeEvent,
|
||||
@@ -26,25 +27,6 @@ export interface TextInputState {
|
||||
selection: { start: number; end: number }
|
||||
}
|
||||
|
||||
const CLAUDE_PERMISSION_MODES = ['default', 'acceptEdits', 'plan', 'bypassPermissions'] as const
|
||||
const CODEX_PERMISSION_MODES = ['default', 'read-only', 'safe-yolo', 'yolo'] as const
|
||||
const PERMISSION_MODE_LABELS: Record<string, string> = {
|
||||
default: 'Default',
|
||||
acceptEdits: 'Accept Edits',
|
||||
plan: 'Plan Mode',
|
||||
bypassPermissions: 'Yolo',
|
||||
'read-only': 'Read Only',
|
||||
'safe-yolo': 'Safe Yolo',
|
||||
yolo: 'Yolo'
|
||||
}
|
||||
|
||||
const MODEL_MODES = ['default', 'sonnet', 'opus'] as const
|
||||
const MODEL_MODE_LABELS: Record<string, string> = {
|
||||
default: 'Default',
|
||||
sonnet: 'Sonnet',
|
||||
opus: 'Opus'
|
||||
}
|
||||
|
||||
const defaultSuggestionHandler = async (): Promise<Suggestion[]> => []
|
||||
|
||||
export function HappyComposer(props: {
|
||||
@@ -225,15 +207,10 @@ export function HappyComposer(props: {
|
||||
}
|
||||
}, [switchDisabled, onSwitchToRemote, haptic])
|
||||
|
||||
const permissionModes = useMemo(() => {
|
||||
if (agentFlavor === 'codex') {
|
||||
return CODEX_PERMISSION_MODES as readonly PermissionMode[]
|
||||
}
|
||||
if (agentFlavor === 'gemini') {
|
||||
return [] as readonly PermissionMode[]
|
||||
}
|
||||
return CLAUDE_PERMISSION_MODES as readonly PermissionMode[]
|
||||
}, [agentFlavor])
|
||||
const permissionModes = useMemo(
|
||||
() => getPermissionModesForFlavor(agentFlavor),
|
||||
[agentFlavor]
|
||||
)
|
||||
|
||||
const handleKeyDown = useCallback((e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
|
||||
const key = e.key
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
import { PERMISSION_MODE_LABELS } from '@hapi/protocol'
|
||||
import { useMemo } from 'react'
|
||||
import type { AgentState, ModelMode, PermissionMode } from '@/types/api'
|
||||
import { getContextBudgetTokens } from '@/chat/modelConfig'
|
||||
|
||||
const PERMISSION_MODE_LABELS: Record<string, string> = {
|
||||
default: 'Default',
|
||||
acceptEdits: 'Accept Edits',
|
||||
plan: 'Plan Mode',
|
||||
bypassPermissions: 'Yolo',
|
||||
'read-only': 'Read Only',
|
||||
'safe-yolo': 'Safe Yolo',
|
||||
yolo: 'Yolo'
|
||||
}
|
||||
|
||||
// Vibing messages for thinking state
|
||||
const VIBING_MESSAGES = [
|
||||
"Accomplishing", "Actioning", "Actualizing", "Baking", "Booping", "Brewing",
|
||||
@@ -112,9 +103,11 @@ export function StatusBar(props: {
|
||||
)
|
||||
|
||||
const permissionMode = props.permissionMode
|
||||
const shouldShowPermissionMode = props.agentFlavor !== 'gemini'
|
||||
const displayPermissionMode = props.agentFlavor !== 'gemini'
|
||||
&& permissionMode
|
||||
&& permissionMode !== 'default'
|
||||
? permissionMode
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-2 pb-1">
|
||||
@@ -134,17 +127,17 @@ export function StatusBar(props: {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{shouldShowPermissionMode ? (
|
||||
{displayPermissionMode ? (
|
||||
<span className={`text-xs ${
|
||||
permissionMode === 'acceptEdits' ? 'text-amber-500' :
|
||||
permissionMode === 'bypassPermissions' ? 'text-red-500' :
|
||||
permissionMode === 'plan' ? 'text-blue-500' :
|
||||
permissionMode === 'read-only' ? 'text-amber-500' :
|
||||
permissionMode === 'safe-yolo' ? 'text-amber-500' :
|
||||
permissionMode === 'yolo' ? 'text-red-500' :
|
||||
displayPermissionMode === 'acceptEdits' ? 'text-amber-500' :
|
||||
displayPermissionMode === 'bypassPermissions' ? 'text-red-500' :
|
||||
displayPermissionMode === 'plan' ? 'text-blue-500' :
|
||||
displayPermissionMode === 'read-only' ? 'text-amber-500' :
|
||||
displayPermissionMode === 'safe-yolo' ? 'text-amber-500' :
|
||||
displayPermissionMode === 'yolo' ? 'text-red-500' :
|
||||
'text-[var(--app-hint)]'
|
||||
}`}>
|
||||
{PERMISSION_MODE_LABELS[permissionMode]}
|
||||
{PERMISSION_MODE_LABELS[displayPermissionMode]}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -3,23 +3,6 @@ import type { ApiClient } from '@/api/client'
|
||||
import type { ModelMode, PermissionMode } from '@/types/api'
|
||||
import { queryKeys } from '@/lib/query-keys'
|
||||
|
||||
type PermissionModeValue = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo'
|
||||
type ModelModeValue = 'default' | 'sonnet' | 'opus'
|
||||
|
||||
function toPermissionMode(mode: PermissionMode): PermissionModeValue {
|
||||
if (mode === 'acceptEdits' || mode === 'bypassPermissions' || mode === 'plan' || mode === 'read-only' || mode === 'safe-yolo' || mode === 'yolo') {
|
||||
return mode
|
||||
}
|
||||
return 'default'
|
||||
}
|
||||
|
||||
function toModelMode(mode: ModelMode): ModelModeValue {
|
||||
if (mode === 'sonnet' || mode === 'opus') {
|
||||
return mode
|
||||
}
|
||||
return 'default'
|
||||
}
|
||||
|
||||
export function useSessionActions(api: ApiClient | null, sessionId: string | null): {
|
||||
abortSession: () => Promise<void>
|
||||
archiveSession: () => Promise<void>
|
||||
@@ -73,7 +56,7 @@ export function useSessionActions(api: ApiClient | null, sessionId: string | nul
|
||||
if (!api || !sessionId) {
|
||||
throw new Error('Session unavailable')
|
||||
}
|
||||
await api.setPermissionMode(sessionId, toPermissionMode(mode))
|
||||
await api.setPermissionMode(sessionId, mode)
|
||||
},
|
||||
onSuccess: () => void invalidateSession(),
|
||||
})
|
||||
@@ -83,7 +66,7 @@ export function useSessionActions(api: ApiClient | null, sessionId: string | nul
|
||||
if (!api || !sessionId) {
|
||||
throw new Error('Session unavailable')
|
||||
}
|
||||
await api.setModelMode(sessionId, toModelMode(mode))
|
||||
await api.setModelMode(sessionId, mode)
|
||||
},
|
||||
onSuccess: () => void invalidateSession(),
|
||||
})
|
||||
|
||||
+10
-67
@@ -1,13 +1,12 @@
|
||||
export type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo'
|
||||
export type ModelMode = 'default' | 'sonnet' | 'opus'
|
||||
import type {
|
||||
DecryptedMessage as ProtocolDecryptedMessage,
|
||||
ModelMode,
|
||||
Session,
|
||||
SyncEvent as ProtocolSyncEvent,
|
||||
WorktreeMetadata
|
||||
} from '@hapi/protocol/types'
|
||||
|
||||
export type WorktreeMetadata = {
|
||||
basePath: string
|
||||
branch: string
|
||||
name: string
|
||||
worktreePath?: string
|
||||
createdAt?: number
|
||||
}
|
||||
export type { AgentState, ModelMode, PermissionMode, Session, TodoItem, WorktreeMetadata } from '@hapi/protocol/types'
|
||||
|
||||
export type SessionMetadataSummary = {
|
||||
path: string
|
||||
@@ -22,51 +21,6 @@ export type SessionMetadataSummary = {
|
||||
worktree?: WorktreeMetadata
|
||||
}
|
||||
|
||||
export type AgentStateRequest = {
|
||||
tool: string
|
||||
arguments: unknown
|
||||
createdAt?: number | null
|
||||
}
|
||||
|
||||
export type AgentStateCompletedRequest = {
|
||||
tool: string
|
||||
arguments: unknown
|
||||
createdAt?: number | null
|
||||
completedAt?: number | null
|
||||
status: 'canceled' | 'denied' | 'approved'
|
||||
reason?: string
|
||||
mode?: string
|
||||
decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort'
|
||||
allowTools?: string[]
|
||||
answers?: Record<string, string[]>
|
||||
}
|
||||
|
||||
export type AgentState = {
|
||||
controlledByUser?: boolean | null
|
||||
requests?: Record<string, AgentStateRequest> | null
|
||||
completedRequests?: Record<string, AgentStateCompletedRequest> | null
|
||||
}
|
||||
|
||||
export type TodoItem = {
|
||||
content: string
|
||||
status: 'pending' | 'in_progress' | 'completed'
|
||||
priority: 'high' | 'medium' | 'low'
|
||||
id: string
|
||||
}
|
||||
|
||||
export type Session = {
|
||||
id: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
active: boolean
|
||||
thinking: boolean
|
||||
metadata: SessionMetadataSummary | null
|
||||
agentState: AgentState | null
|
||||
todos?: TodoItem[]
|
||||
permissionMode?: PermissionMode
|
||||
modelMode?: ModelMode
|
||||
}
|
||||
|
||||
export type SessionSummaryMetadata = {
|
||||
name?: string
|
||||
path: string
|
||||
@@ -89,12 +43,7 @@ export type SessionSummary = {
|
||||
|
||||
export type MessageStatus = 'sending' | 'sent' | 'failed'
|
||||
|
||||
export type DecryptedMessage = {
|
||||
id: string
|
||||
seq: number | null
|
||||
localId: string | null
|
||||
content: unknown
|
||||
createdAt: number
|
||||
export type DecryptedMessage = ProtocolDecryptedMessage & {
|
||||
status?: MessageStatus
|
||||
originalText?: string
|
||||
}
|
||||
@@ -216,10 +165,4 @@ export type PushVapidPublicKeyResponse = {
|
||||
publicKey: string
|
||||
}
|
||||
|
||||
export type SyncEvent =
|
||||
| { type: 'session-added'; sessionId: string; data?: unknown; namespace?: string }
|
||||
| { type: 'session-updated'; sessionId: string; data?: unknown; namespace?: string }
|
||||
| { type: 'session-removed'; sessionId: string; namespace?: string }
|
||||
| { type: 'message-received'; sessionId: string; message: DecryptedMessage; namespace?: string }
|
||||
| { type: 'machine-updated'; machineId: string; data?: unknown; namespace?: string }
|
||||
| { type: 'connection-changed'; data?: { status: string }; namespace?: string }
|
||||
export type SyncEvent = ProtocolSyncEvent
|
||||
|
||||
Reference in New Issue
Block a user