diff --git a/cli/package.json b/cli/package.json index 7f180db3..c3d419f2 100644 --- a/cli/package.json +++ b/cli/package.json @@ -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", diff --git a/cli/src/api/api.ts b/cli/src/api/api.ts index 758649e7..69f8bf36 100644 --- a/cli/src/api/api.ts +++ b/cli/src/api/api.ts @@ -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 } } diff --git a/cli/src/api/types.ts b/cli/src/api/types.ts index 6a0527f4..9c9dcf7a 100644 --- a/cli/src/api/types.ts +++ b/cli/src/api/types.ts @@ -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 -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 - } - } -} - -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 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() }) }) diff --git a/package.json b/package.json index a0927897..436dbdf7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server/package.json b/server/package.json index b78dfcc1..d3fadde6 100644 --- a/server/package.json +++ b/server/package.json @@ -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", diff --git a/server/src/sync/syncEngine.ts b/server/src/sync/syncEngine.ts index f47fd042..b703cd4a 100644 --- a/server/src/sync/syncEngine.ts +++ b/server/src/sync/syncEngine.ts @@ -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 - -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 +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 } -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 @@ -739,7 +651,7 @@ export class SyncEngine { async setPermissionMode( sessionId: string, - mode: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo' + mode: PermissionMode ): Promise { const session = this.sessions.get(sessionId) if (session) { @@ -748,7 +660,7 @@ export class SyncEngine { } } - async setModelMode(sessionId: string, model: 'default' | 'sonnet' | 'opus'): Promise { + async setModelMode(sessionId: string, model: ModelMode): Promise { 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 { const result = await this.sessionRpc(sessionId, 'set-session-config', config) diff --git a/server/src/sync/todos.ts b/server/src/sync/todos.ts index 8bad4ab2..fe1ac8d7 100644 --- a/server/src/sync/todos.ts +++ b/server/src/sync/todos.ts @@ -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 - -export const TodosSchema = z.array(TodoItemSchema) +export { TodoItemSchema, TodosSchema } +export type { TodoItem } function isObject(value: unknown): value is Record { return Boolean(value) && typeof value === 'object' diff --git a/server/src/web/routes/sessions.ts b/server/src/web/routes/sessions.ts index 53e21443..c6f1f6c0 100644 --- a/server/src/web/routes/sessions.ts +++ b/server/src/web/routes/sessions.ts @@ -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) } diff --git a/shared/package.json b/shared/package.json new file mode 100644 index 00000000..93483249 --- /dev/null +++ b/shared/package.json @@ -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" + } +} diff --git a/shared/src/index.ts b/shared/src/index.ts new file mode 100644 index 00000000..6e9dd740 --- /dev/null +++ b/shared/src/index.ts @@ -0,0 +1,2 @@ +export * from './modes' +export type * from './types' diff --git a/shared/src/modes.ts b/shared/src/modes.ts new file mode 100644 index 00000000..10294c47 --- /dev/null +++ b/shared/src/modes.ts @@ -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 = { + 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 = { + 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) +} diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts new file mode 100644 index 00000000..a868bbb4 --- /dev/null +++ b/shared/src/schemas.ts @@ -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 + +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 + +export const AgentStateRequestSchema = z.object({ + tool: z.string(), + arguments: z.unknown(), + createdAt: z.number().nullish() +}).passthrough() + +export type AgentStateRequest = z.infer + +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 + +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 + +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 + +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 + +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 + +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 diff --git a/shared/src/types.ts b/shared/src/types.ts new file mode 100644 index 00000000..7b7aec34 --- /dev/null +++ b/shared/src/types.ts @@ -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' diff --git a/shared/tsconfig.json b/shared/tsconfig.json new file mode 100644 index 00000000..770a1654 --- /dev/null +++ b/shared/tsconfig.json @@ -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"] +} diff --git a/web/package.json b/web/package.json index 6f2186c7..9ec8f4ee 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 9da94fa2..c3dd7ff7 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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 { + async setPermissionMode(sessionId: string, mode: PermissionMode): Promise { await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/permission-mode`, { method: 'POST', body: JSON.stringify({ mode }) }) } - async setModelMode(sessionId: string, model: 'default' | 'sonnet' | 'opus'): Promise { + async setModelMode(sessionId: string, model: ModelMode): Promise { await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/model`, { method: 'POST', body: JSON.stringify({ model }) diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index 96a941ce..f2fe6021 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -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 = { - 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 = { - default: 'Default', - sonnet: 'Sonnet', - opus: 'Opus' -} - const defaultSuggestionHandler = async (): Promise => [] 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) => { const key = e.key diff --git a/web/src/components/AssistantChat/StatusBar.tsx b/web/src/components/AssistantChat/StatusBar.tsx index ee71c0af..dd21c8dd 100644 --- a/web/src/components/AssistantChat/StatusBar.tsx +++ b/web/src/components/AssistantChat/StatusBar.tsx @@ -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 = { - 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 (
@@ -134,17 +127,17 @@ export function StatusBar(props: { ) : null}
- {shouldShowPermissionMode ? ( + {displayPermissionMode ? ( - {PERMISSION_MODE_LABELS[permissionMode]} + {PERMISSION_MODE_LABELS[displayPermissionMode]} ) : null} diff --git a/web/src/hooks/mutations/useSessionActions.ts b/web/src/hooks/mutations/useSessionActions.ts index 440dccd3..357d7219 100644 --- a/web/src/hooks/mutations/useSessionActions.ts +++ b/web/src/hooks/mutations/useSessionActions.ts @@ -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 archiveSession: () => Promise @@ -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(), }) diff --git a/web/src/types/api.ts b/web/src/types/api.ts index c4ec680f..8a476ef2 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -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 -} - -export type AgentState = { - controlledByUser?: boolean | null - requests?: Record | null - completedRequests?: Record | 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