Unify protocol types and modes

This commit is contained in:
weishu
2026-01-03 14:43:59 +08:00
parent bfca058967
commit e2028b6914
20 changed files with 360 additions and 393 deletions
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+1
View File
@@ -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",
+11 -99
View File
@@ -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)
+4 -11
View File
@@ -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'
+8 -7
View File
@@ -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)
}
+18
View File
@@ -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"
}
}
+2
View File
@@ -0,0 +1,2 @@
export * from './modes'
export type * from './types'
+62
View File
@@ -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)
}
+165
View File
@@ -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>
+19
View File
@@ -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'
+14
View File
@@ -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"]
}
+1
View File
@@ -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",
+4 -2
View File
@@ -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
+12 -19
View File
@@ -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>
+2 -19
View File
@@ -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
View File
@@ -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