mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-04 00:01:41 +00:00
* feat(web,hub,cli): show machine load in session sidebar Runners attach OS health snapshots to machine-alive heartbeats; the hub caches them and the web session list renders load or CPU between the machine label and session count. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web,cli): show CPU and RAM pressure in machine health badge Sidebar label now combines CPU and RAM percentages for overload signaling; load stays in the tooltip on Unix. Prime CPU sampling so the first heartbeat includes usage, not just memory. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(web): visual machine health meters with tooltip Replace bare CPU/RAM text with labeled mini bar gauges, chip border tint by severity, and a HoverTooltip explaining capacity and overload guidance. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): widen machine health tooltip with horizontal layout Allow a generous popover width and lay CPU/RAM/load out side by side so the capacity tooltip reads wider and less tall than the chip. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): anchor machine health tooltip to row left edge Wide tooltip was align=end on the chip, so it grew left off-screen. Use row-span positioning on the machine tile button instead. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(web): machine host card with OS label and inline health Turn the session sidebar machine row into a bordered host panel with OS metadata and side-by-side CPU/RAM meters embedded in the tile instead of a flat label line matching project rows. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): keep machine host tile single-row height Collapse the machine header back to one py-1.5 row with OS and compact inline health beside the name, and restore the original project indent without the extra nested rail or second header line. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(web): show CPU core count in machine health tooltip When the runner reports cpuCount, the tooltip reads "CPU across all 6 cores" instead of the generic all-cores label. Co-authored-by: Cursor <cursoragent@cursor.com> * docs: add machine health sidebar screenshots Dogfood captures for the session sidebar machine tile and capacity tooltip, for upstream PR review. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): clear machine-alive priming timeout on disconnect Track the 50ms CPU priming setTimeout and clear it in stopKeepAlive so disconnect/shutdown during the delay cannot leave a stray interval alive. Co-authored-by: Cursor <cursoragent@cursor.com> * chore: drop dogfood screenshots from upstream PR diff Review evidence lives in the PR discussion only; no need to ship PNGs in the repo long-term. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): truncate long machine OS/host metadata in sidebar row Bound the metadata span so a long hostname cannot push the health chip or session count off-screen in narrow sidebars. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): reveal machine health tooltip on keyboard row focus Wire MACHINE_ROW_TOOLTIP_FOCUS_CLASS and aria-describedby on the machine header button so keyboard users can read the health tooltip like session rows. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): use MemAvailable for Linux RAM pressure on Bun Bun's os.freemem() reflects MemFree (~1% on cache-heavy hosts), which made sidebar RAM read ~99% while btop showed ~40% used. Parse /proc/meminfo MemAvailable instead so used percent matches operator tools. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(web,cli): show machine uptime in sidebar tiles and tooltip Collect os.uptime() as uptimeSeconds on keepalive and render compact up 1h 54m in the machine meta row plus an Uptime line in the health tooltip. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): anchor machine health tooltip to chip not row align=row positioned the tooltip below the full machine header button, so the collapsible project panel painted over it on hover. Use align=end with a min-width panel so mouse and keyboard tooltips stay visible. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
408 lines
14 KiB
TypeScript
408 lines
14 KiB
TypeScript
import { z } from 'zod'
|
|
import { CODEX_COLLABORATION_MODES, PERMISSION_MODES } from './modes'
|
|
|
|
export const PermissionModeSchema = z.enum(PERMISSION_MODES)
|
|
export const CodexCollaborationModeSchema = z.enum(CODEX_COLLABORATION_MODES)
|
|
export const SessionEndReasonSchema = z.enum(['completed', 'terminated', 'error', 'handoff'])
|
|
export type SessionEndReason = z.infer<typeof SessionEndReasonSchema>
|
|
|
|
const MetadataSummarySchema = z.object({
|
|
text: z.string(),
|
|
updatedAt: z.number()
|
|
})
|
|
|
|
const SessionCapabilitiesSchema = z.object({
|
|
terminal: z.boolean().optional()
|
|
})
|
|
|
|
export const WorktreeMetadataSchema = z.object({
|
|
basePath: z.string(),
|
|
branch: z.string(),
|
|
name: z.string(),
|
|
worktreePath: z.string().optional(),
|
|
createdAt: z.number().optional()
|
|
})
|
|
|
|
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(),
|
|
geminiSessionId: z.string().optional(),
|
|
opencodeSessionId: z.string().optional(),
|
|
cursorSessionId: z.string().optional(),
|
|
cursorSessionProtocol: z.enum(['acp', 'stream-json']).optional(),
|
|
// Drives the web `CursorMigrationBanner`:
|
|
// 'in_progress' = legacy-to-ACP transplant currently running; banner shows spinner + "Upgrading..."
|
|
// 'ambiguous' = migrator refused to transplant (ambiguous source drawer OR size mismatch);
|
|
// banner switches to "Manual review needed" until the operator resolves on disk.
|
|
// undefined = no migration in flight; banner hidden.
|
|
// tiann/hapi#873.
|
|
cursorMigrationState: z.enum(['in_progress', 'ambiguous']).optional(),
|
|
kimiSessionId: z.string().optional(),
|
|
piSessionId: 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(),
|
|
startedFromRunner: z.boolean().optional(),
|
|
hostPid: z.number().optional(),
|
|
hapiMcpUrl: z.string().url().optional(),
|
|
startedBy: z.enum(['runner', 'terminal']).optional(),
|
|
lifecycleState: z.string().optional(),
|
|
lifecycleStateSince: z.number().optional(),
|
|
archivedBy: z.string().optional(),
|
|
archiveReason: z.string().optional(),
|
|
preferredPermissionMode: PermissionModeSchema.optional(),
|
|
flavor: z.string().nullish(),
|
|
capabilities: SessionCapabilitiesSchema.optional(),
|
|
worktree: WorktreeMetadataSchema.optional(),
|
|
// Cached Pi model list — written by CLI, read by web (inactive session fallback).
|
|
// Minimal shape: each entry must have modelId; other fields (provider, name, etc.) pass through.
|
|
piAvailableModels: z.array(z.object({ modelId: z.string() }).passthrough()).optional(),
|
|
// Pi-selected model with provider identity. The legacy `session.model`
|
|
// field stores only modelId (shared across all flavors); this preserves
|
|
// the provider so web can resolve the exact model when two providers
|
|
// share a modelId.
|
|
piSelectedModel: z.object({ provider: z.string(), modelId: z.string() }).nullable().optional()
|
|
})
|
|
|
|
export type Metadata = z.infer<typeof MetadataSchema>
|
|
|
|
export const AgentStateRequestSchema = z.object({
|
|
tool: z.string(),
|
|
arguments: z.unknown(),
|
|
createdAt: z.number().nullish()
|
|
})
|
|
|
|
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(),
|
|
// Flat format: Record<string, string[]> (AskUserQuestion)
|
|
// Nested format: Record<string, { answers: string[] }> (request_user_input)
|
|
answers: z.union([
|
|
z.record(z.string(), z.array(z.string())),
|
|
z.record(z.string(), z.object({ answers: z.array(z.string()) }))
|
|
]).optional()
|
|
})
|
|
|
|
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()
|
|
})
|
|
|
|
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']).optional().default('medium'),
|
|
id: z.string().optional().default(''),
|
|
activeForm: z.string().optional()
|
|
})
|
|
|
|
export type TodoItem = z.infer<typeof TodoItemSchema>
|
|
|
|
export const TodosSchema = z.array(TodoItemSchema)
|
|
|
|
export const TeamMemberSchema = z.object({
|
|
name: z.string(),
|
|
agentType: z.string().optional(),
|
|
status: z.enum(['active', 'idle', 'shutdown']).optional()
|
|
})
|
|
|
|
export type TeamMember = z.infer<typeof TeamMemberSchema>
|
|
|
|
export const TeamTaskSchema = z.object({
|
|
id: z.string(),
|
|
title: z.string(),
|
|
description: z.string().optional(),
|
|
status: z.enum(['pending', 'in_progress', 'completed', 'blocked']).optional(),
|
|
owner: z.string().optional()
|
|
})
|
|
|
|
export type TeamTask = z.infer<typeof TeamTaskSchema>
|
|
|
|
export const TeamMessageSchema = z.object({
|
|
from: z.string(),
|
|
to: z.string(),
|
|
summary: z.string(),
|
|
type: z.enum(['message', 'broadcast', 'shutdown_request', 'shutdown_response']),
|
|
timestamp: z.number()
|
|
})
|
|
|
|
export type TeamMessage = z.infer<typeof TeamMessageSchema>
|
|
|
|
export const TeamStateSchema = z.object({
|
|
teamName: z.string(),
|
|
description: z.string().optional(),
|
|
members: z.array(TeamMemberSchema).optional(),
|
|
tasks: z.array(TeamTaskSchema).optional(),
|
|
messages: z.array(TeamMessageSchema).optional(),
|
|
updatedAt: z.number().optional()
|
|
})
|
|
|
|
export type TeamState = z.infer<typeof TeamStateSchema>
|
|
|
|
export const ThreadGoalStatusSchema = z.enum(['active', 'paused', 'budgetLimited', 'complete'])
|
|
export type ThreadGoalStatus = z.infer<typeof ThreadGoalStatusSchema>
|
|
|
|
export const ThreadGoalSchema = z.object({
|
|
threadId: z.string(),
|
|
objective: z.string(),
|
|
status: ThreadGoalStatusSchema,
|
|
tokenBudget: z.number().nullable().optional(),
|
|
tokensUsed: z.number().optional().default(0),
|
|
timeUsedSeconds: z.number().optional().default(0),
|
|
createdAt: z.number().optional().default(0),
|
|
updatedAt: z.number().optional().default(0)
|
|
})
|
|
|
|
export type ThreadGoal = z.infer<typeof ThreadGoalSchema>
|
|
|
|
export const AttachmentMetadataSchema = z.object({
|
|
id: z.string(),
|
|
filename: z.string(),
|
|
mimeType: z.string(),
|
|
size: z.number(),
|
|
path: z.string(),
|
|
previewUrl: z.string().optional()
|
|
})
|
|
|
|
export type AttachmentMetadata = z.infer<typeof AttachmentMetadataSchema>
|
|
|
|
export const DecryptedMessageSchema = z.object({
|
|
id: z.string(),
|
|
seq: z.number().nullable(),
|
|
localId: z.string().nullable(),
|
|
content: z.unknown(),
|
|
createdAt: z.number(),
|
|
invokedAt: z.number().nullable().optional(),
|
|
scheduledAt: z.number().nullable().optional()
|
|
})
|
|
|
|
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(),
|
|
backgroundTaskCount: z.number().optional(),
|
|
todos: TodosSchema.optional(),
|
|
teamState: TeamStateSchema.optional(),
|
|
model: z.string().nullable().optional().default(null),
|
|
modelReasoningEffort: z.string().nullable().optional().default(null),
|
|
effort: z.string().nullable().optional().default(null),
|
|
serviceTier: z.string().nullable().optional().default(null),
|
|
permissionMode: PermissionModeSchema.optional(),
|
|
collaborationMode: CodexCollaborationModeSchema.optional()
|
|
})
|
|
|
|
export type Session = z.infer<typeof SessionSchema>
|
|
|
|
export const SessionPatchSchema = z.object({
|
|
active: z.boolean().optional(),
|
|
thinking: z.boolean().optional(),
|
|
activeAt: z.number().optional(),
|
|
updatedAt: z.number().optional(),
|
|
model: z.string().nullable().optional(),
|
|
modelReasoningEffort: z.string().nullable().optional(),
|
|
effort: z.string().nullable().optional(),
|
|
serviceTier: z.string().nullable().optional(),
|
|
permissionMode: PermissionModeSchema.optional(),
|
|
collaborationMode: CodexCollaborationModeSchema.optional(),
|
|
backgroundTaskCount: z.number().optional()
|
|
}).strict()
|
|
|
|
export type SessionPatch = z.infer<typeof SessionPatchSchema>
|
|
|
|
export const MachineMetadataSchema = z.object({
|
|
host: z.string(),
|
|
platform: z.string(),
|
|
happyCliVersion: z.string(),
|
|
displayName: z.string().optional(),
|
|
homeDir: z.string().optional(),
|
|
happyHomeDir: z.string().optional(),
|
|
happyLibDir: z.string().optional(),
|
|
workspaceRoots: z.array(z.string()).optional()
|
|
})
|
|
|
|
export type MachineMetadata = z.infer<typeof MachineMetadataSchema>
|
|
|
|
export const RunnerStateSchema = z.object({
|
|
status: z.union([z.enum(['running', 'shutting-down']), z.string()]),
|
|
pid: z.number().optional(),
|
|
httpPort: z.number().optional(),
|
|
startedAt: z.number().optional(),
|
|
shutdownRequestedAt: z.number().optional(),
|
|
shutdownSource: z.union([z.enum(['mobile-app', 'cli', 'os-signal', 'unknown']), z.string()]).optional(),
|
|
lastSpawnError: z.object({
|
|
message: z.string(),
|
|
pid: z.number().optional(),
|
|
exitCode: z.number().nullable().optional(),
|
|
signal: z.string().nullable().optional(),
|
|
at: z.number()
|
|
}).nullable().optional()
|
|
})
|
|
|
|
export type RunnerState = z.infer<typeof RunnerStateSchema>
|
|
|
|
export const MachineHealthSchema = z.object({
|
|
collectedAt: z.number(),
|
|
cpuCount: z.number().int().positive().optional(),
|
|
load1m: z.number().nonnegative().optional(),
|
|
cpuPercent: z.number().min(0).max(100).optional(),
|
|
memoryPercent: z.number().min(0).max(100).optional(),
|
|
uptimeSeconds: z.number().nonnegative().optional()
|
|
}).strict()
|
|
|
|
export type MachineHealth = z.infer<typeof MachineHealthSchema>
|
|
|
|
export const MachineSchema = z.object({
|
|
id: z.string(),
|
|
namespace: z.string(),
|
|
seq: z.number(),
|
|
createdAt: z.number(),
|
|
updatedAt: z.number(),
|
|
active: z.boolean(),
|
|
activeAt: z.number(),
|
|
metadata: MachineMetadataSchema.nullable(),
|
|
metadataVersion: z.number(),
|
|
runnerState: RunnerStateSchema.nullable(),
|
|
runnerStateVersion: z.number(),
|
|
health: MachineHealthSchema.nullable().optional()
|
|
})
|
|
|
|
export type Machine = z.infer<typeof MachineSchema>
|
|
|
|
export const MachinePatchSchema = z.object({
|
|
active: z.boolean().optional(),
|
|
activeAt: z.number().optional(),
|
|
updatedAt: z.number().optional()
|
|
}).strict()
|
|
|
|
export type MachinePatch = z.infer<typeof MachinePatchSchema>
|
|
|
|
export const SessionUpdatedDataSchema = z.union([SessionSchema, SessionPatchSchema])
|
|
export type SessionUpdatedData = z.infer<typeof SessionUpdatedDataSchema>
|
|
|
|
export const MachineUpdatedDataSchema = z.union([MachineSchema, MachinePatchSchema, z.null()])
|
|
export type MachineUpdatedData = z.infer<typeof MachineUpdatedDataSchema>
|
|
|
|
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: SessionUpdatedDataSchema.optional()
|
|
}),
|
|
SessionEventBaseSchema.extend({
|
|
type: z.literal('session-removed'),
|
|
sessionId: z.string()
|
|
}),
|
|
SessionChangedSchema.extend({
|
|
type: z.literal('message-received'),
|
|
message: DecryptedMessageSchema
|
|
}),
|
|
SessionChangedSchema.extend({
|
|
type: z.literal('messages-invalidated')
|
|
}),
|
|
SessionChangedSchema.extend({
|
|
type: z.literal('scheduled-matured')
|
|
}),
|
|
SessionChangedSchema.extend({
|
|
type: z.literal('session-ended'),
|
|
reason: SessionEndReasonSchema.optional()
|
|
}),
|
|
MachineChangedSchema.extend({
|
|
type: z.literal('machine-updated'),
|
|
data: MachineUpdatedDataSchema.optional()
|
|
}),
|
|
SessionEventBaseSchema.extend({
|
|
type: z.literal('toast'),
|
|
data: z.object({
|
|
title: z.string(),
|
|
body: z.string(),
|
|
sessionId: z.string(),
|
|
url: z.string()
|
|
})
|
|
}),
|
|
SessionChangedSchema.extend({
|
|
type: z.literal('messages-consumed'),
|
|
localIds: z.array(z.string()),
|
|
invokedAt: z.number()
|
|
}),
|
|
SessionChangedSchema.extend({
|
|
type: z.literal('message-cancelled'),
|
|
messageId: z.string(),
|
|
localId: z.string().optional()
|
|
}),
|
|
SessionEventBaseSchema.extend({
|
|
type: z.literal('heartbeat'),
|
|
data: z.object({
|
|
timestamp: z.number()
|
|
}).optional()
|
|
}),
|
|
SessionEventBaseSchema.extend({
|
|
type: z.literal('connection-changed'),
|
|
data: z.object({
|
|
status: z.string(),
|
|
subscriptionId: z.string().optional()
|
|
}).optional()
|
|
})
|
|
])
|
|
|
|
export type SyncEvent = z.infer<typeof SyncEventSchema>
|
|
|
|
export const CancelMessageResponseSchema = z.discriminatedUnion('status', [
|
|
z.object({ status: z.literal('cancelled'), localId: z.string().nullable() }),
|
|
z.object({ status: z.literal('invoked'), message: DecryptedMessageSchema }),
|
|
])
|
|
|
|
export type CancelMessageResponse = z.infer<typeof CancelMessageResponseSchema>
|