mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
TurnRuntime now publishes every turn's events (durable ones tagged with their 1-based file offset, deltas without) to an injected TurnEventHub, regardless of who started the turn. Main forwards durable events to all windows on one turns:events channel; the renderer's new useTurn(turnId) hook joins live turns gap/duplicate-free via the offset protocol. The sub-agent card drops its 1s polling for push updates, and the nine per-channel window fan-out loops collapse into one broadcastToWindows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2201 lines
62 KiB
TypeScript
2201 lines
62 KiB
TypeScript
import { z } from 'zod';
|
||
import { RelPath, Encoding, Stat, DirEntry, ReaddirOptions, ReadFileResult, WorkspaceChangeEvent, WriteFileOptions, WriteFileResult, RemoveOptions } from './workspace.js';
|
||
import { ListToolsResponse } from './mcp.js';
|
||
import { AskHumanResponsePayload, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload } from './runs.js';
|
||
import { LlmModelConfig, LlmProvider, ModelOverride, ModelRef } from './models.js';
|
||
import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js';
|
||
import { AgentScheduleState } from './agent-schedule-state.js';
|
||
import { ServiceEvent } from './service-events.js';
|
||
import { LiveNoteAgentEvent, LiveNoteSchema } from './live-note.js';
|
||
import {
|
||
BackgroundTaskAgentEvent,
|
||
BackgroundTaskSchema,
|
||
BackgroundTaskSummarySchema,
|
||
TriggersSchema,
|
||
} from './background-task.js';
|
||
import { UserMessage, UserMessageContent } from './message.js';
|
||
import { RequestedAgent, type TurnBusEvent, type TurnEvent } from './turns.js';
|
||
import type { SessionBusEvent, SessionIndexEntry, SessionState } from './sessions.js';
|
||
import { RowboatApiConfig } from './rowboat-account.js';
|
||
import { ZListToolkitsResponse } from './composio.js';
|
||
import { AppSummarySchema, RegistryRecordSchema, RowboatAppManifestSchema } from './rowboat-app.js';
|
||
import { BrowserStateSchema, HttpAuthRequestSchema } from './browser-control.js';
|
||
import { BillingInfoSchema } from './billing.js';
|
||
import { EmailBlockSchema, GmailThreadSchema } from './blocks.js';
|
||
import { PermissionDecision, ApprovalPolicy, CodingAgent, type CodeRunFeedEvent } from './code-mode.js';
|
||
import { NotificationSettingsSchema } from './notification-settings.js';
|
||
import { CodeProject, CodeSession, CodeSessionMode, CodeSessionStatus, GitRepoInfo, GitStatusFile, CodeAgentModelOptions } from './code-sessions.js';
|
||
import { ChannelsConfig, ChannelsStatus } from './channels.js';
|
||
|
||
// ============================================================================
|
||
// Runtime Validation Schemas (Single Source of Truth)
|
||
// ============================================================================
|
||
|
||
const KnowledgeSourceScopeSchema = z.object({
|
||
type: z.string(),
|
||
id: z.string(),
|
||
name: z.string().optional(),
|
||
workspaceUrl: z.string().optional(),
|
||
});
|
||
|
||
// Mirrors AgentSlackErrorKind in @x/core/slack/agent-slack-exec. Kept as a
|
||
// standalone enum so the renderer can branch on failure cause without
|
||
// importing core.
|
||
const SlackErrorKindSchema = z.enum([
|
||
'not_installed', 'timeout', 'parse_error',
|
||
'not_authed', 'rate_limited', 'network', 'bad_channel', 'unknown',
|
||
]);
|
||
|
||
const KnowledgeSourceConfigSchema = z.object({
|
||
id: z.string(),
|
||
provider: z.enum(['gmail', 'meeting', 'voice_memo', 'slack', 'github', 'linear']),
|
||
enabled: z.boolean(),
|
||
artifactDir: z.string(),
|
||
syncMode: z.enum(['file', 'poll', 'event', 'manual']).default('file'),
|
||
intervalMs: z.number().int().positive().optional(),
|
||
scopes: z.array(KnowledgeSourceScopeSchema).default([]),
|
||
instructions: z.string().optional(),
|
||
filters: z.record(z.string(), z.unknown()).optional(),
|
||
});
|
||
|
||
const ipcSchemas = {
|
||
'app:getVersions': {
|
||
req: z.null(),
|
||
res: z.object({
|
||
chrome: z.string(),
|
||
node: z.string(),
|
||
electron: z.string(),
|
||
}),
|
||
},
|
||
'analytics:bootstrap': {
|
||
req: z.null(),
|
||
res: z.object({
|
||
installationId: z.string(),
|
||
apiUrl: z.string(),
|
||
appVersion: z.string(),
|
||
}),
|
||
},
|
||
'workspace:getRoot': {
|
||
req: z.null(),
|
||
res: z.object({
|
||
root: z.string(),
|
||
}),
|
||
},
|
||
'workspace:exists': {
|
||
req: z.object({
|
||
path: RelPath,
|
||
}),
|
||
res: z.object({
|
||
exists: z.boolean(),
|
||
}),
|
||
},
|
||
'workspace:stat': {
|
||
req: z.object({
|
||
path: RelPath,
|
||
}),
|
||
res: Stat,
|
||
},
|
||
'workspace:readdir': {
|
||
req: z.object({
|
||
path: z.string(), // Empty string allowed for root directory
|
||
opts: ReaddirOptions.optional(),
|
||
}),
|
||
res: z.array(DirEntry),
|
||
},
|
||
'workspace:readFile': {
|
||
req: z.object({
|
||
path: RelPath,
|
||
encoding: Encoding.optional(),
|
||
}),
|
||
res: ReadFileResult,
|
||
},
|
||
'workspace:writeFile': {
|
||
req: z.object({
|
||
path: RelPath,
|
||
data: z.string(),
|
||
opts: WriteFileOptions.optional(),
|
||
}),
|
||
res: WriteFileResult,
|
||
},
|
||
'workspace:mkdir': {
|
||
req: z.object({
|
||
path: RelPath,
|
||
recursive: z.boolean().optional(),
|
||
}),
|
||
res: z.object({
|
||
ok: z.literal(true),
|
||
}),
|
||
},
|
||
'workspace:rename': {
|
||
req: z.object({
|
||
from: RelPath,
|
||
to: RelPath,
|
||
overwrite: z.boolean().optional(),
|
||
}),
|
||
res: z.object({
|
||
ok: z.literal(true),
|
||
}),
|
||
},
|
||
'workspace:copy': {
|
||
req: z.object({
|
||
from: RelPath,
|
||
to: RelPath,
|
||
overwrite: z.boolean().optional(),
|
||
}),
|
||
res: z.object({
|
||
ok: z.literal(true),
|
||
}),
|
||
},
|
||
'workspace:remove': {
|
||
req: z.object({
|
||
path: RelPath,
|
||
opts: RemoveOptions.optional(),
|
||
}),
|
||
res: z.object({
|
||
ok: z.literal(true),
|
||
}),
|
||
},
|
||
'workspace:didChange': {
|
||
req: WorkspaceChangeEvent,
|
||
res: z.null(),
|
||
},
|
||
'gmail:getImportant': {
|
||
req: z.object({
|
||
cursor: z.string().optional(),
|
||
limit: z.number().int().min(1).max(100).optional(),
|
||
}),
|
||
res: z.object({
|
||
threads: z.array(GmailThreadSchema),
|
||
nextCursor: z.string().nullable(),
|
||
}),
|
||
},
|
||
'gmail:getEverythingElse': {
|
||
req: z.object({
|
||
cursor: z.string().optional(),
|
||
limit: z.number().int().min(1).max(100).optional(),
|
||
}),
|
||
res: z.object({
|
||
threads: z.array(GmailThreadSchema),
|
||
nextCursor: z.string().nullable(),
|
||
}),
|
||
},
|
||
'gmail:triggerSync': {
|
||
req: z.object({}),
|
||
res: z.object({}),
|
||
},
|
||
'gmail:sendReply': {
|
||
req: z.object({
|
||
threadId: z.string().min(1).optional(),
|
||
to: z.string().min(1),
|
||
cc: z.string().optional(),
|
||
bcc: z.string().optional(),
|
||
subject: z.string(),
|
||
bodyHtml: z.string(),
|
||
bodyText: z.string(),
|
||
inReplyTo: z.string().optional(),
|
||
references: z.string().optional(),
|
||
attachments: z
|
||
.array(
|
||
z.object({
|
||
filename: z.string(),
|
||
mimeType: z.string(),
|
||
contentBase64: z.string(),
|
||
}),
|
||
)
|
||
.optional(),
|
||
}),
|
||
res: z.object({
|
||
messageId: z.string().optional(),
|
||
error: z.string().optional(),
|
||
}),
|
||
},
|
||
'gmail:saveDraft': {
|
||
req: z.object({
|
||
// Existing Gmail draft to update; omitted on first save (creates a new one).
|
||
draftId: z.string().min(1).optional(),
|
||
threadId: z.string().min(1).optional(),
|
||
// Recipients may be blank for a draft (unlike a send).
|
||
to: z.string().optional(),
|
||
cc: z.string().optional(),
|
||
bcc: z.string().optional(),
|
||
subject: z.string(),
|
||
bodyHtml: z.string(),
|
||
bodyText: z.string(),
|
||
inReplyTo: z.string().optional(),
|
||
references: z.string().optional(),
|
||
attachments: z
|
||
.array(
|
||
z.object({
|
||
filename: z.string(),
|
||
mimeType: z.string(),
|
||
contentBase64: z.string(),
|
||
}),
|
||
)
|
||
.optional(),
|
||
}),
|
||
res: z.object({
|
||
draftId: z.string().optional(),
|
||
error: z.string().optional(),
|
||
}),
|
||
},
|
||
'gmail:deleteDraft': {
|
||
req: z.object({ draftId: z.string().min(1) }),
|
||
res: z.object({ ok: z.boolean(), error: z.string().optional() }),
|
||
},
|
||
'gmail:getDrafts': {
|
||
req: z.object({}),
|
||
res: z.object({
|
||
threads: z.array(GmailThreadSchema),
|
||
error: z.string().optional(),
|
||
}),
|
||
},
|
||
'gmail:search': {
|
||
req: z.object({
|
||
query: z.string(),
|
||
limit: z.number().int().positive().optional(),
|
||
}),
|
||
res: z.object({
|
||
threads: z.array(GmailThreadSchema),
|
||
error: z.string().optional(),
|
||
}),
|
||
},
|
||
'gmail:getConnectionStatus': {
|
||
req: z.object({}),
|
||
res: z.object({
|
||
connected: z.boolean(),
|
||
hasRequiredScope: z.boolean(),
|
||
missingScopes: z.array(z.string()),
|
||
email: z.string().nullable(),
|
||
}),
|
||
},
|
||
'gmail:getAccountEmail': {
|
||
req: z.object({}),
|
||
res: z.object({
|
||
email: z.string().nullable(),
|
||
}),
|
||
},
|
||
'gmail:getAccountName': {
|
||
req: z.object({}),
|
||
res: z.object({
|
||
name: z.string().nullable(),
|
||
}),
|
||
},
|
||
// User explicitly flips a thread's importance verdict. Sticky on the thread
|
||
// (re-classification never overrides) and recorded as a correction the
|
||
// importance classifier learns from.
|
||
'gmail:setImportance': {
|
||
req: z.object({
|
||
threadId: z.string().min(1),
|
||
importance: z.enum(['important', 'other']),
|
||
}),
|
||
res: z.object({
|
||
ok: z.boolean(),
|
||
previous: z.enum(['important', 'other']).optional(),
|
||
error: z.string().optional(),
|
||
}),
|
||
},
|
||
'gmail:archiveThread': {
|
||
req: z.object({ threadId: z.string().min(1) }),
|
||
res: z.object({ ok: z.boolean(), error: z.string().optional() }),
|
||
},
|
||
'gmail:trashThread': {
|
||
req: z.object({ threadId: z.string().min(1) }),
|
||
res: z.object({ ok: z.boolean(), error: z.string().optional() }),
|
||
},
|
||
'gmail:markThreadRead': {
|
||
req: z.object({ threadId: z.string().min(1), read: z.boolean().optional() }),
|
||
res: z.object({ ok: z.boolean(), error: z.string().optional() }),
|
||
},
|
||
'gmail:downloadAttachment': {
|
||
req: z.object({
|
||
messageId: z.string().min(1),
|
||
savedPath: z.string().min(1),
|
||
attachmentId: z.string().optional(),
|
||
}),
|
||
res: z.object({ ok: z.boolean(), error: z.string().optional() }),
|
||
},
|
||
'gmail:saveMessageHeight': {
|
||
req: z.object({
|
||
threadId: z.string().min(1),
|
||
messageId: z.string().min(1),
|
||
height: z.number().int().positive(),
|
||
}),
|
||
res: z.object({}),
|
||
},
|
||
'gmail:searchContacts': {
|
||
req: z.object({
|
||
query: z.string(),
|
||
limit: z.number().int().positive().optional(),
|
||
excludeEmails: z.array(z.string()).optional(),
|
||
}),
|
||
res: z.object({
|
||
contacts: z.array(z.object({
|
||
name: z.string(),
|
||
email: z.string(),
|
||
count: z.number(),
|
||
lastSeenMs: z.number(),
|
||
})),
|
||
}),
|
||
},
|
||
'mcp:listTools': {
|
||
req: z.object({
|
||
serverName: z.string(),
|
||
cursor: z.string().optional(),
|
||
}),
|
||
res: ListToolsResponse,
|
||
},
|
||
'mcp:executeTool': {
|
||
req: z.object({
|
||
serverName: z.string(),
|
||
toolName: z.string(),
|
||
input: z.record(z.string(), z.unknown()),
|
||
}),
|
||
res: z.object({
|
||
result: z.unknown(),
|
||
}),
|
||
},
|
||
'runs:create': {
|
||
req: CreateRunOptions,
|
||
res: Run,
|
||
},
|
||
'runs:createMessage': {
|
||
req: z.object({
|
||
runId: z.string(),
|
||
message: UserMessageContent,
|
||
voiceInput: z.boolean().optional(),
|
||
voiceOutput: z.enum(['summary', 'full']).optional(),
|
||
searchEnabled: z.boolean().optional(),
|
||
codeMode: z.enum(['claude', 'codex']).optional(),
|
||
// Code-section sessions pin the coding agent's working directory and
|
||
// approval policy for the whole turn (see code_agent_run overrides).
|
||
codeCwd: z.string().optional(),
|
||
codePolicy: ApprovalPolicy.optional(),
|
||
middlePaneContext: z.discriminatedUnion('kind', [
|
||
z.object({
|
||
kind: z.literal('note'),
|
||
path: z.string(),
|
||
content: z.string(),
|
||
}),
|
||
z.object({
|
||
kind: z.literal('browser'),
|
||
url: z.string(),
|
||
title: z.string(),
|
||
}),
|
||
]).optional(),
|
||
}),
|
||
res: z.object({
|
||
messageId: z.string(),
|
||
}),
|
||
},
|
||
'runs:authorizePermission': {
|
||
req: z.object({
|
||
runId: z.string(),
|
||
authorization: ToolPermissionAuthorizePayload,
|
||
}),
|
||
res: z.object({
|
||
success: z.literal(true),
|
||
}),
|
||
},
|
||
'runs:provideHumanInput': {
|
||
req: z.object({
|
||
runId: z.string(),
|
||
reply: AskHumanResponsePayload,
|
||
}),
|
||
res: z.object({
|
||
success: z.literal(true),
|
||
}),
|
||
},
|
||
'runs:stop': {
|
||
req: z.object({
|
||
runId: z.string(),
|
||
force: z.boolean().optional().default(false),
|
||
}),
|
||
res: z.object({
|
||
success: z.literal(true),
|
||
}),
|
||
},
|
||
'runs:fetch': {
|
||
req: z.object({
|
||
runId: z.string(),
|
||
}),
|
||
res: Run,
|
||
},
|
||
'runs:list': {
|
||
req: z.object({
|
||
cursor: z.string().optional(),
|
||
}),
|
||
res: ListRunsResponse,
|
||
},
|
||
'runs:listByWorkDir': {
|
||
req: z.object({
|
||
dir: z.string(),
|
||
}),
|
||
res: ListRunsResponse,
|
||
},
|
||
'runs:delete': {
|
||
req: z.object({
|
||
runId: z.string(),
|
||
}),
|
||
res: z.object({ success: z.boolean() }),
|
||
},
|
||
'runs:downloadLog': {
|
||
req: z.object({
|
||
runId: z.string().min(1),
|
||
}),
|
||
res: z.object({
|
||
success: z.boolean(),
|
||
error: z.string().optional(),
|
||
}),
|
||
},
|
||
'runs:events': {
|
||
req: z.null(),
|
||
res: z.null(),
|
||
},
|
||
// Ephemeral code-run stream (CodeRunFeed): per-event broadcast of a
|
||
// code_agent_run's live ACP activity, keyed by toolCallId. Never persisted —
|
||
// the durable record is the code-run-events-batch tool progress written when
|
||
// the run settles. Typed via z.custom like the other broadcast feeds.
|
||
'codeRun:events': {
|
||
req: z.custom<CodeRunFeedEvent>(),
|
||
res: z.null(),
|
||
},
|
||
// ── New runtime: sessions + turns (session-design.md) ────────────────────
|
||
// Turn-mutating calls return quickly; the renderer follows progress through
|
||
// the sessions:events feed and the shared reduceTurn reducer.
|
||
'sessions:create': {
|
||
req: z.object({ title: z.string().optional() }),
|
||
res: z.object({ sessionId: z.string() }),
|
||
},
|
||
'sessions:list': {
|
||
req: z.object({}),
|
||
res: z.object({ sessions: z.array(z.custom<SessionIndexEntry>()) }),
|
||
},
|
||
'sessions:get': {
|
||
req: z.object({ sessionId: z.string() }),
|
||
res: z.custom<SessionState>(),
|
||
},
|
||
'sessions:getTurn': {
|
||
// Events are strictly validated at the repository read; typed via
|
||
// z.custom to avoid re-validating potentially large logs per IPC hop.
|
||
req: z.object({ turnId: z.string() }),
|
||
res: z.custom<{ turnId: string; events: Array<z.infer<typeof TurnEvent>> }>(),
|
||
},
|
||
'sessions:sendMessage': {
|
||
req: z.object({
|
||
sessionId: z.string(),
|
||
input: UserMessage,
|
||
config: z.object({
|
||
agent: RequestedAgent,
|
||
autoPermission: z.boolean().optional(),
|
||
maxModelCalls: z.number().int().positive().optional(),
|
||
}),
|
||
}),
|
||
res: z.object({ turnId: z.string() }),
|
||
},
|
||
'sessions:respondToPermission': {
|
||
req: z.object({
|
||
turnId: z.string(),
|
||
toolCallId: z.string(),
|
||
decision: z.enum(['allow', 'deny']),
|
||
metadata: z.json().optional(),
|
||
}),
|
||
res: z.object({ success: z.literal(true) }),
|
||
},
|
||
'sessions:respondToAskHuman': {
|
||
req: z.object({
|
||
turnId: z.string(),
|
||
toolCallId: z.string(),
|
||
answer: z.string(),
|
||
}),
|
||
res: z.object({ success: z.literal(true) }),
|
||
},
|
||
'sessions:stopTurn': {
|
||
req: z.object({
|
||
turnId: z.string(),
|
||
reason: z.string().optional(),
|
||
}),
|
||
res: z.object({ success: z.literal(true) }),
|
||
},
|
||
'sessions:resumeTurn': {
|
||
req: z.object({ sessionId: z.string() }),
|
||
res: z.object({ success: z.literal(true) }),
|
||
},
|
||
'sessions:setTitle': {
|
||
req: z.object({ sessionId: z.string(), title: z.string() }),
|
||
res: z.object({ success: z.literal(true) }),
|
||
},
|
||
'sessions:downloadLog': {
|
||
// Concatenates the session's turn logs into one JSONL for debugging.
|
||
req: z.object({ sessionId: z.string() }),
|
||
res: z.object({
|
||
success: z.boolean(),
|
||
error: z.string().optional(),
|
||
}),
|
||
},
|
||
'sessions:delete': {
|
||
req: z.object({ sessionId: z.string() }),
|
||
res: z.object({ success: z.literal(true) }),
|
||
},
|
||
'sessions:events': {
|
||
// Typed via z.custom so the renderer's `on` handler is typed without
|
||
// runtime validation (the broadcast path bypasses preload validation,
|
||
// like runs:events).
|
||
req: z.custom<SessionBusEvent>(),
|
||
res: z.null(),
|
||
},
|
||
// Process-wide turn event spine: every turn's durable events (with file
|
||
// offsets), regardless of who started the turn — session chat, headless
|
||
// background/knowledge runners, spawned sub-agents. Deltas are not
|
||
// broadcast here in v1; session chat streams them via sessions:events.
|
||
'turns:events': {
|
||
req: z.custom<TurnBusEvent>(),
|
||
res: z.null(),
|
||
},
|
||
'services:events': {
|
||
req: ServiceEvent,
|
||
res: z.null(),
|
||
},
|
||
'live-note-agent:events': {
|
||
req: LiveNoteAgentEvent,
|
||
res: z.null(),
|
||
},
|
||
'bg-task-agent:events': {
|
||
req: BackgroundTaskAgentEvent,
|
||
res: z.null(),
|
||
},
|
||
'models:list': {
|
||
req: z.null(),
|
||
res: z.object({
|
||
providers: z.array(z.object({
|
||
id: z.string(),
|
||
name: z.string(),
|
||
models: z.array(z.object({
|
||
id: z.string(),
|
||
name: z.string().optional(),
|
||
release_date: z.string().optional(),
|
||
})),
|
||
})),
|
||
lastUpdated: z.string().optional(),
|
||
}),
|
||
},
|
||
'models:test': {
|
||
req: LlmModelConfig,
|
||
res: z.object({
|
||
success: z.boolean(),
|
||
error: z.string().optional(),
|
||
// Capability caveats from the local-model probe (tool support, context
|
||
// window) — the connection still succeeded.
|
||
warnings: z.array(z.string()).optional(),
|
||
capabilities: z.object({
|
||
supportsTools: z.boolean().optional(),
|
||
maxContextLength: z.number().optional(),
|
||
}).optional(),
|
||
}),
|
||
},
|
||
'models:listForProvider': {
|
||
req: z.object({
|
||
provider: LlmProvider,
|
||
}),
|
||
res: z.object({
|
||
success: z.boolean(),
|
||
models: z.array(z.string()).optional(),
|
||
error: z.string().optional(),
|
||
}),
|
||
},
|
||
'llm:getDefaultModel': {
|
||
req: z.null(),
|
||
res: z.object({
|
||
model: z.string(),
|
||
provider: z.string(),
|
||
}),
|
||
},
|
||
'llm:generate': {
|
||
req: z.object({
|
||
prompt: z.string().min(1),
|
||
system: z.string().optional(),
|
||
model: z.string().optional(),
|
||
provider: z.string().optional(),
|
||
}),
|
||
res: z.object({
|
||
text: z.string().optional(),
|
||
model: z.string().optional(),
|
||
provider: z.string().optional(),
|
||
error: z.string().optional(),
|
||
}),
|
||
},
|
||
'models:saveConfig': {
|
||
req: LlmModelConfig,
|
||
res: z.object({
|
||
success: z.literal(true),
|
||
}),
|
||
},
|
||
// Partial top-level merge into models.json — used by hybrid (signed-in +
|
||
// BYOK) settings to set the default selection / category overrides without
|
||
// clobbering the BYOK provider config that saveConfig owns. Omitted keys
|
||
// are untouched; null clears a key back to its default.
|
||
'models:updateConfig': {
|
||
req: z.object({
|
||
defaultSelection: ModelRef.nullable().optional(),
|
||
knowledgeGraphModel: ModelOverride.nullable().optional(),
|
||
meetingNotesModel: ModelOverride.nullable().optional(),
|
||
liveNoteAgentModel: ModelOverride.nullable().optional(),
|
||
autoPermissionDecisionModel: ModelOverride.nullable().optional(),
|
||
deferBackgroundTasks: z.boolean().nullable().optional(),
|
||
}),
|
||
res: z.object({
|
||
success: z.literal(true),
|
||
}),
|
||
},
|
||
'oauth:connect': {
|
||
req: z.object({
|
||
provider: z.string(),
|
||
clientId: z.string().optional(),
|
||
clientSecret: z.string().optional(),
|
||
}),
|
||
res: z.object({
|
||
success: z.boolean(),
|
||
error: z.string().optional(),
|
||
}),
|
||
},
|
||
'oauth:disconnect': {
|
||
req: z.object({
|
||
provider: z.string(),
|
||
}),
|
||
res: z.object({
|
||
success: z.boolean(),
|
||
}),
|
||
},
|
||
'oauth:list-providers': {
|
||
req: z.null(),
|
||
res: z.object({
|
||
providers: z.array(z.string()),
|
||
}),
|
||
},
|
||
'oauth:getState': {
|
||
req: z.null(),
|
||
res: z.object({
|
||
config: z.record(z.string(), z.object({
|
||
connected: z.boolean(),
|
||
error: z.string().nullable().optional(),
|
||
userId: z.string().optional(),
|
||
clientId: z.string().nullable().optional(),
|
||
})),
|
||
}),
|
||
},
|
||
'account:getRowboat': {
|
||
req: z.null(),
|
||
res: z.object({
|
||
signedIn: z.boolean(),
|
||
accessToken: z.string().nullable(),
|
||
config: RowboatApiConfig.nullable(),
|
||
}),
|
||
},
|
||
'oauth:didConnect': {
|
||
req: z.object({
|
||
provider: z.string(),
|
||
success: z.boolean(),
|
||
error: z.string().optional(),
|
||
userId: z.string().optional(),
|
||
}),
|
||
res: z.null(),
|
||
},
|
||
'app:openUrl': {
|
||
req: z.object({
|
||
url: z.string(),
|
||
}),
|
||
res: z.null(),
|
||
},
|
||
// Bring the main app window to the foreground (e.g. the assistant navigated
|
||
// the UI during a call while the user was in another app).
|
||
'app:focusMainWindow': {
|
||
req: z.null(),
|
||
res: z.object({}),
|
||
},
|
||
'app:takeMeetingNotes': {
|
||
req: z.object({
|
||
// Pass the raw calendar event JSON through; renderer adapts to its existing flow.
|
||
event: z.unknown(),
|
||
// When true, the renderer should also open the meeting URL (Zoom/Meet/etc.)
|
||
// in addition to triggering the take-notes flow.
|
||
openMeeting: z.boolean().optional(),
|
||
}),
|
||
res: z.null(),
|
||
},
|
||
'app:consumePendingDeepLink': {
|
||
req: z.null(),
|
||
res: z.object({
|
||
url: z.string().nullable(),
|
||
}),
|
||
},
|
||
'granola:getConfig': {
|
||
req: z.null(),
|
||
res: z.object({
|
||
enabled: z.boolean(),
|
||
}),
|
||
},
|
||
'codeMode:getConfig': {
|
||
req: z.null(),
|
||
res: z.object({
|
||
enabled: z.boolean(),
|
||
approvalPolicy: ApprovalPolicy.optional(),
|
||
}),
|
||
},
|
||
'codeMode:setConfig': {
|
||
req: z.object({
|
||
enabled: z.boolean(),
|
||
approvalPolicy: ApprovalPolicy.optional(),
|
||
}),
|
||
res: z.object({
|
||
success: z.literal(true),
|
||
}),
|
||
},
|
||
// Answer a mid-run permission request from a code_agent_run coding turn.
|
||
'codeRun:resolvePermission': {
|
||
req: z.object({
|
||
requestId: z.string(),
|
||
decision: PermissionDecision,
|
||
}),
|
||
res: z.object({
|
||
success: z.literal(true),
|
||
}),
|
||
},
|
||
'codeMode:checkAgentStatus': {
|
||
req: z.null(),
|
||
res: z.object({
|
||
claude: z.object({ installed: z.boolean(), signedIn: z.boolean() }),
|
||
codex: z.object({ installed: z.boolean(), signedIn: z.boolean() }),
|
||
}),
|
||
},
|
||
// Download + install an agent's native engine (the Settings "Enable" action).
|
||
// Streams progress over the 'codeMode:engineProgress' push channel while it runs.
|
||
'codeMode:provisionEngine': {
|
||
req: z.object({ agent: z.enum(['claude', 'codex']) }),
|
||
res: z.object({ success: z.boolean(), error: z.string().optional() }),
|
||
},
|
||
// Push (main -> renderer): engine provisioning progress for the Settings UI.
|
||
'codeMode:engineProgress': {
|
||
req: z.object({
|
||
agent: z.enum(['claude', 'codex']),
|
||
phase: z.enum(['download', 'verify', 'extract', 'done']),
|
||
receivedBytes: z.number().optional(),
|
||
totalBytes: z.number().optional(),
|
||
}),
|
||
res: z.null(),
|
||
},
|
||
// ==========================================================================
|
||
// Code section: project registry + coding sessions
|
||
// ==========================================================================
|
||
'codeProject:add': {
|
||
req: z.object({
|
||
path: z.string(),
|
||
}),
|
||
res: z.object({
|
||
project: CodeProject,
|
||
git: GitRepoInfo,
|
||
}),
|
||
},
|
||
'codeProject:remove': {
|
||
req: z.object({
|
||
projectId: z.string(),
|
||
}),
|
||
res: z.object({
|
||
success: z.literal(true),
|
||
}),
|
||
},
|
||
'codeProject:list': {
|
||
req: z.null(),
|
||
res: z.object({
|
||
projects: z.array(z.object({
|
||
project: CodeProject,
|
||
git: GitRepoInfo,
|
||
})),
|
||
}),
|
||
},
|
||
'codeSession:create': {
|
||
req: z.object({
|
||
projectId: z.string(),
|
||
title: z.string().optional(),
|
||
agent: CodingAgent,
|
||
mode: CodeSessionMode,
|
||
policy: ApprovalPolicy,
|
||
isolation: z.enum(['in-repo', 'worktree']),
|
||
// LLM for Rowboat-mode turns. Unset = the configured default. Like any
|
||
// chat, the model is fixed once the session's run exists.
|
||
model: z.string().optional(),
|
||
provider: z.string().optional(),
|
||
// The coding agent's own model + reasoning effort (ACP engine). Unlike the
|
||
// Rowboat model these are re-applied each turn, so they stay editable.
|
||
agentModel: z.string().optional(),
|
||
agentEffort: z.string().optional(),
|
||
}),
|
||
res: z.object({
|
||
session: CodeSession,
|
||
}),
|
||
},
|
||
'codeSession:list': {
|
||
req: z.null(),
|
||
res: z.object({
|
||
sessions: z.array(CodeSession),
|
||
statuses: z.record(z.string(), CodeSessionStatus),
|
||
}),
|
||
},
|
||
'codeSession:update': {
|
||
req: z.object({
|
||
sessionId: z.string(),
|
||
patch: CodeSession.pick({ title: true, mode: true, policy: true, agent: true, agentModel: true, agentEffort: true }).partial(),
|
||
}),
|
||
res: z.object({
|
||
session: CodeSession,
|
||
}),
|
||
},
|
||
// Live model + effort choices for a coding agent, discovered from the engine
|
||
// (cached per agent in the main process). Mirrors what `/model` would show.
|
||
'codeMode:listModelOptions': {
|
||
req: z.object({ agent: CodingAgent }),
|
||
res: CodeAgentModelOptions,
|
||
},
|
||
'codeSession:delete': {
|
||
req: z.object({
|
||
sessionId: z.string(),
|
||
removeWorktree: z.boolean().optional(),
|
||
deleteBranch: z.boolean().optional(),
|
||
}),
|
||
res: z.object({
|
||
success: z.literal(true),
|
||
}),
|
||
},
|
||
// Direct-drive: send the user's message straight to the session's ACP agent
|
||
// (no copilot LLM in between). Streams back over `runs:events`.
|
||
'codeSession:sendMessage': {
|
||
req: z.object({
|
||
sessionId: z.string(),
|
||
text: z.string().min(1),
|
||
}),
|
||
res: z.object({
|
||
accepted: z.boolean(),
|
||
error: z.string().optional(),
|
||
}),
|
||
},
|
||
'codeSession:stop': {
|
||
req: z.object({
|
||
sessionId: z.string(),
|
||
}),
|
||
res: z.object({
|
||
success: z.literal(true),
|
||
}),
|
||
},
|
||
'codeSession:gitStatus': {
|
||
req: z.object({
|
||
sessionId: z.string(),
|
||
}),
|
||
res: z.object({
|
||
isRepo: z.boolean(),
|
||
branch: z.string().nullable(),
|
||
hasCommits: z.boolean(),
|
||
files: z.array(GitStatusFile),
|
||
}),
|
||
},
|
||
'codeSession:fileDiff': {
|
||
req: z.object({
|
||
sessionId: z.string(),
|
||
path: z.string(),
|
||
}),
|
||
res: z.object({
|
||
oldText: z.string(),
|
||
newText: z.string(),
|
||
isBinary: z.boolean(),
|
||
tooLarge: z.boolean(),
|
||
}),
|
||
},
|
||
'codeSession:readdir': {
|
||
req: z.object({
|
||
sessionId: z.string(),
|
||
relPath: z.string(),
|
||
}),
|
||
res: z.object({
|
||
entries: z.array(z.object({
|
||
name: z.string(),
|
||
kind: z.enum(['file', 'dir']),
|
||
size: z.number().optional(),
|
||
})),
|
||
}),
|
||
},
|
||
'codeSession:readFile': {
|
||
req: z.object({
|
||
sessionId: z.string(),
|
||
relPath: z.string(),
|
||
}),
|
||
res: z.object({
|
||
content: z.string(),
|
||
isBinary: z.boolean(),
|
||
tooLarge: z.boolean(),
|
||
}),
|
||
},
|
||
'codeSession:mergeBack': {
|
||
req: z.object({
|
||
sessionId: z.string(),
|
||
}),
|
||
res: z.object({
|
||
ok: z.boolean(),
|
||
conflict: z.boolean().optional(),
|
||
message: z.string(),
|
||
}),
|
||
},
|
||
'codeSession:cleanupWorktree': {
|
||
req: z.object({
|
||
sessionId: z.string(),
|
||
deleteBranch: z.boolean(),
|
||
}),
|
||
res: z.object({
|
||
success: z.boolean(),
|
||
error: z.string().optional(),
|
||
}),
|
||
},
|
||
// main → renderer: live session status transitions from the status tracker.
|
||
'codeSession:status': {
|
||
req: z.object({
|
||
sessionId: z.string(),
|
||
status: CodeSessionStatus,
|
||
}),
|
||
res: z.null(),
|
||
},
|
||
// ==========================================================================
|
||
// Embedded terminal (Code section): one PTY per coding session
|
||
// ==========================================================================
|
||
// Create-or-attach. Returns the scrollback backlog so a remounted view can
|
||
// repaint what happened while it was closed.
|
||
'terminal:ensure': {
|
||
req: z.object({
|
||
id: z.string(),
|
||
cwd: z.string(),
|
||
cols: z.number().int().positive(),
|
||
rows: z.number().int().positive(),
|
||
}),
|
||
res: z.object({
|
||
backlog: z.string(),
|
||
running: z.boolean(),
|
||
}),
|
||
},
|
||
'terminal:input': {
|
||
req: z.object({
|
||
id: z.string(),
|
||
data: z.string(),
|
||
}),
|
||
res: z.object({ success: z.literal(true) }),
|
||
},
|
||
'terminal:resize': {
|
||
req: z.object({
|
||
id: z.string(),
|
||
cols: z.number().int().positive(),
|
||
rows: z.number().int().positive(),
|
||
}),
|
||
res: z.object({ success: z.literal(true) }),
|
||
},
|
||
'terminal:dispose': {
|
||
req: z.object({ id: z.string() }),
|
||
res: z.object({ success: z.literal(true) }),
|
||
},
|
||
// main → renderer streams
|
||
'terminal:data': {
|
||
req: z.object({ id: z.string(), data: z.string() }),
|
||
res: z.null(),
|
||
},
|
||
'terminal:exit': {
|
||
req: z.object({ id: z.string(), exitCode: z.number() }),
|
||
res: z.null(),
|
||
},
|
||
'granola:setConfig': {
|
||
req: z.object({
|
||
enabled: z.boolean(),
|
||
}),
|
||
res: z.object({
|
||
success: z.literal(true),
|
||
}),
|
||
},
|
||
// ── Caffeinate (keep system awake, like macOS `caffeinate`) ──
|
||
'power:getCaffeinate': {
|
||
req: z.null(),
|
||
res: z.object({ enabled: z.boolean() }),
|
||
},
|
||
'power:setCaffeinate': {
|
||
req: z.object({ enabled: z.boolean() }),
|
||
res: z.object({ enabled: z.boolean() }),
|
||
},
|
||
// Push: main → renderer when caffeinate state changes, so indicators stay live.
|
||
'power:caffeinateChanged': {
|
||
req: z.object({ enabled: z.boolean() }),
|
||
res: z.null(),
|
||
},
|
||
// ── Mobile channels (WhatsApp / Telegram bridge) ─────────────
|
||
'channels:getConfig': {
|
||
req: z.null(),
|
||
res: ChannelsConfig,
|
||
},
|
||
'channels:setConfig': {
|
||
req: ChannelsConfig,
|
||
res: z.object({ success: z.literal(true) }),
|
||
},
|
||
'channels:getStatus': {
|
||
req: z.null(),
|
||
res: ChannelsStatus,
|
||
},
|
||
'channels:whatsappLogout': {
|
||
req: z.null(),
|
||
res: z.object({ success: z.literal(true) }),
|
||
},
|
||
// Push: main → renderer status updates (QR rotation, connect/disconnect).
|
||
'channels:status': {
|
||
req: ChannelsStatus,
|
||
res: z.null(),
|
||
},
|
||
'slack:getConfig': {
|
||
req: z.null(),
|
||
res: z.object({
|
||
enabled: z.boolean(),
|
||
workspaces: z.array(z.object({ url: z.string(), name: z.string() })),
|
||
}),
|
||
},
|
||
'slack:setConfig': {
|
||
req: z.object({
|
||
enabled: z.boolean(),
|
||
workspaces: z.array(z.object({ url: z.string(), name: z.string() })),
|
||
}),
|
||
res: z.object({
|
||
success: z.literal(true),
|
||
}),
|
||
},
|
||
'slack:cliStatus': {
|
||
req: z.null(),
|
||
res: z.object({
|
||
available: z.boolean(),
|
||
version: z.string().optional(),
|
||
source: z.enum(['bundled', 'global', 'path']).optional(),
|
||
}),
|
||
},
|
||
'slack:listWorkspaces': {
|
||
req: z.null(),
|
||
res: z.object({
|
||
workspaces: z.array(z.object({ url: z.string(), name: z.string() })),
|
||
error: z.string().optional(),
|
||
errorKind: SlackErrorKindSchema.optional(),
|
||
}),
|
||
},
|
||
'slack:importDesktopAuth': {
|
||
req: z.null(),
|
||
res: z.object({
|
||
ok: z.boolean(),
|
||
workspaces: z.array(z.object({ url: z.string(), name: z.string() })),
|
||
error: z.string().optional(),
|
||
errorKind: SlackErrorKindSchema.optional(),
|
||
}),
|
||
},
|
||
'slack:quitAndImportDesktop': {
|
||
req: z.null(),
|
||
res: z.object({
|
||
ok: z.boolean(),
|
||
workspaces: z.array(z.object({ url: z.string(), name: z.string() })),
|
||
error: z.string().optional(),
|
||
errorKind: SlackErrorKindSchema.optional(),
|
||
}),
|
||
},
|
||
'slack:parseCurlAuth': {
|
||
req: z.object({ curl: z.string() }),
|
||
res: z.object({
|
||
ok: z.boolean(),
|
||
workspaces: z.array(z.object({ url: z.string(), name: z.string() })),
|
||
error: z.string().optional(),
|
||
errorKind: SlackErrorKindSchema.optional(),
|
||
}),
|
||
},
|
||
'slack:knowledgeStatus': {
|
||
req: z.null(),
|
||
res: z.object({
|
||
cli: z.object({
|
||
available: z.boolean(),
|
||
version: z.string().optional(),
|
||
source: z.enum(['bundled', 'global', 'path']).optional(),
|
||
}),
|
||
sources: z.array(z.object({
|
||
id: z.string(),
|
||
enabled: z.boolean(),
|
||
lastSyncAt: z.string().optional(),
|
||
lastStatus: z.enum(['ok', 'error']).optional(),
|
||
lastError: z.object({ kind: z.string(), message: z.string() }).optional(),
|
||
nextDueAt: z.string().optional(),
|
||
})),
|
||
}),
|
||
},
|
||
'slack:listChannels': {
|
||
req: z.object({
|
||
workspaceUrl: z.string(),
|
||
}),
|
||
res: z.object({
|
||
channels: z.array(z.object({
|
||
id: z.string(),
|
||
name: z.string(),
|
||
isPrivate: z.boolean().optional(),
|
||
isMember: z.boolean().optional(),
|
||
})),
|
||
error: z.string().optional(),
|
||
}),
|
||
},
|
||
'slack:getRecentMessages': {
|
||
req: z.object({
|
||
limit: z.number().int().positive().max(20).optional(),
|
||
}),
|
||
res: z.object({
|
||
enabled: z.boolean(),
|
||
messages: z.array(z.object({
|
||
id: z.string(),
|
||
workspaceName: z.string().optional(),
|
||
workspaceUrl: z.string().optional(),
|
||
channelId: z.string().optional(),
|
||
channelName: z.string().optional(),
|
||
author: z.string().optional(),
|
||
text: z.string(),
|
||
ts: z.string(),
|
||
url: z.string().optional(),
|
||
})),
|
||
error: z.string().optional(),
|
||
errorKind: SlackErrorKindSchema.optional(),
|
||
}),
|
||
},
|
||
'knowledgeSources:getConfig': {
|
||
req: z.null(),
|
||
res: z.object({
|
||
sources: z.array(KnowledgeSourceConfigSchema),
|
||
}),
|
||
},
|
||
'knowledgeSources:upsert': {
|
||
req: KnowledgeSourceConfigSchema,
|
||
res: z.object({
|
||
sources: z.array(KnowledgeSourceConfigSchema),
|
||
}),
|
||
},
|
||
'onboarding:getStatus': {
|
||
req: z.null(),
|
||
res: z.object({
|
||
showOnboarding: z.boolean(),
|
||
}),
|
||
},
|
||
'onboarding:markComplete': {
|
||
req: z.null(),
|
||
res: z.object({
|
||
success: z.literal(true),
|
||
}),
|
||
},
|
||
// Composio integration channels
|
||
'composio:is-configured': {
|
||
req: z.null(),
|
||
res: z.object({
|
||
configured: z.boolean(),
|
||
}),
|
||
},
|
||
'composio:set-api-key': {
|
||
req: z.object({
|
||
apiKey: z.string(),
|
||
}),
|
||
res: z.object({
|
||
success: z.boolean(),
|
||
error: z.string().optional(),
|
||
}),
|
||
},
|
||
'composio:initiate-connection': {
|
||
req: z.object({
|
||
toolkitSlug: z.string(),
|
||
}),
|
||
res: z.object({
|
||
success: z.boolean(),
|
||
redirectUrl: z.string().optional(),
|
||
connectedAccountId: z.string().optional(),
|
||
error: z.string().optional(),
|
||
}),
|
||
},
|
||
'composio:get-connection-status': {
|
||
req: z.object({
|
||
toolkitSlug: z.string(),
|
||
}),
|
||
res: z.object({
|
||
isConnected: z.boolean(),
|
||
status: z.string().optional(),
|
||
}),
|
||
},
|
||
'composio:sync-connection': {
|
||
req: z.object({
|
||
toolkitSlug: z.string(),
|
||
connectedAccountId: z.string(),
|
||
}),
|
||
res: z.object({
|
||
status: z.string(),
|
||
}),
|
||
},
|
||
'composio:disconnect': {
|
||
req: z.object({
|
||
toolkitSlug: z.string(),
|
||
}),
|
||
res: z.object({
|
||
success: z.boolean(),
|
||
}),
|
||
},
|
||
'composio:list-connected': {
|
||
req: z.null(),
|
||
res: z.object({
|
||
toolkits: z.array(z.string()),
|
||
}),
|
||
},
|
||
'migration:check-composio-google': {
|
||
req: z.null(),
|
||
res: z.object({
|
||
shouldShow: z.boolean(),
|
||
}),
|
||
},
|
||
// Rowboat Apps (spec §13) — M1 local channels.
|
||
'apps:serverStatus': {
|
||
req: z.object({}),
|
||
res: z.object({
|
||
running: z.boolean(),
|
||
error: z.string().optional(),
|
||
}),
|
||
},
|
||
'apps:list': {
|
||
req: z.object({}),
|
||
res: z.object({
|
||
serverRunning: z.boolean(),
|
||
serverError: z.string().optional(),
|
||
apps: z.array(AppSummarySchema),
|
||
}),
|
||
},
|
||
'apps:get': {
|
||
req: z.object({ folder: z.string() }),
|
||
res: z.object({
|
||
app: AppSummarySchema,
|
||
readme: z.string().optional(),
|
||
rollbackAvailable: z.boolean(),
|
||
}),
|
||
},
|
||
'apps:create': {
|
||
req: z.object({ folder: z.string(), name: z.string(), description: z.string() }),
|
||
res: z.object({ app: AppSummarySchema }),
|
||
},
|
||
'apps:delete': {
|
||
req: z.object({ folder: z.string() }),
|
||
res: z.object({ ok: z.literal(true) }),
|
||
},
|
||
'apps:setTheme': {
|
||
req: z.object({ theme: z.enum(['light', 'dark']) }),
|
||
res: z.object({ ok: z.literal(true) }),
|
||
},
|
||
// Catalog + install/update (spec §12–13).
|
||
'apps:catalogIndex': {
|
||
req: z.object({ force: z.boolean().optional() }),
|
||
res: z.object({ records: z.array(RegistryRecordSchema), stale: z.boolean(), fetchedAt: z.string() }),
|
||
},
|
||
'apps:catalogSearch': {
|
||
req: z.object({ query: z.string() }),
|
||
res: z.object({ records: z.array(RegistryRecordSchema) }),
|
||
},
|
||
// GitHub star counts (catalog ranking) + the signed-in user's starred set.
|
||
'apps:catalogStars': {
|
||
req: z.object({ repos: z.array(z.string()) }),
|
||
res: z.object({
|
||
stars: z.record(z.string(), z.number()),
|
||
starred: z.record(z.string(), z.boolean()),
|
||
}),
|
||
},
|
||
'apps:star': {
|
||
req: z.object({ repo: z.string(), star: z.boolean() }),
|
||
res: z.object({ starred: z.boolean() }),
|
||
},
|
||
'apps:catalogDetail': {
|
||
req: z.object({ name: z.string() }),
|
||
res: z.object({
|
||
record: RegistryRecordSchema,
|
||
manifest: RowboatAppManifestSchema.optional(),
|
||
readme: z.string().optional(),
|
||
installedFolder: z.string().optional(),
|
||
}),
|
||
},
|
||
'apps:install': {
|
||
req: z.object({ name: z.string(), confirmed: z.boolean().optional() }),
|
||
res: z.object({
|
||
status: z.enum(['preview', 'installed']),
|
||
name: z.string().optional(),
|
||
version: z.string().optional(),
|
||
description: z.string().optional(),
|
||
capabilities: z.array(z.string()).optional(),
|
||
agents: z.array(z.string()).optional(),
|
||
app: AppSummarySchema.optional(),
|
||
}),
|
||
},
|
||
'apps:installFromUrl': {
|
||
req: z.object({ url: z.string(), confirmed: z.boolean() }),
|
||
res: z.object({
|
||
status: z.enum(['preview', 'installed']),
|
||
name: z.string().optional(),
|
||
version: z.string().optional(),
|
||
description: z.string().optional(),
|
||
capabilities: z.array(z.string()).optional(),
|
||
agents: z.array(z.string()).optional(),
|
||
updateSource: z.enum(['github', 'none']).optional(),
|
||
app: AppSummarySchema.optional(),
|
||
}),
|
||
},
|
||
'apps:uninstall': {
|
||
req: z.object({ folder: z.string() }),
|
||
res: z.object({ ok: z.literal(true) }),
|
||
},
|
||
'apps:checkUpdate': {
|
||
req: z.object({ folder: z.string() }),
|
||
res: z.object({ current: z.string(), latest: z.string(), updateAvailable: z.boolean() }),
|
||
},
|
||
'apps:update': {
|
||
req: z.object({
|
||
folder: z.string(),
|
||
confirmOverwriteModified: z.boolean().optional(),
|
||
confirmNewCapabilities: z.boolean().optional(),
|
||
}),
|
||
res: z.object({ app: AppSummarySchema }),
|
||
},
|
||
'apps:rollback': {
|
||
req: z.object({ folder: z.string() }),
|
||
res: z.object({ app: AppSummarySchema }),
|
||
},
|
||
// Advisory progress pushes for long-running app operations (§13).
|
||
'apps:progress': {
|
||
req: z.object({ folder: z.string(), step: z.string(), detail: z.string().optional() }),
|
||
res: z.null(),
|
||
},
|
||
'apps:publish': {
|
||
req: z.object({ folder: z.string() }),
|
||
res: z.object({
|
||
status: z.enum(['published', 'pending']),
|
||
repoUrl: z.string(),
|
||
releaseUrl: z.string(),
|
||
prUrl: z.string().optional(),
|
||
}),
|
||
},
|
||
'apps:publishUpdate': {
|
||
req: z.object({ folder: z.string(), increment: z.enum(['patch', 'minor', 'major']) }),
|
||
res: z.object({ version: z.string(), releaseUrl: z.string() }),
|
||
},
|
||
'apps:registerExisting': {
|
||
req: z.object({ name: z.string(), repo: z.string() }),
|
||
res: z.object({ status: z.enum(['published', 'pending']), prUrl: z.string() }),
|
||
},
|
||
// GitHub auth (device flow) — required only for publishing apps (spec §10).
|
||
'githubAuth:start': {
|
||
req: z.object({}),
|
||
res: z.object({ userCode: z.string(), verificationUri: z.string(), expiresIn: z.number() }),
|
||
},
|
||
'githubAuth:poll': {
|
||
req: z.object({}),
|
||
res: z.object({
|
||
status: z.enum(['pending', 'authorized', 'expired', 'denied']),
|
||
login: z.string().optional(),
|
||
}),
|
||
},
|
||
'githubAuth:status': {
|
||
req: z.object({}),
|
||
res: z.object({ signedIn: z.boolean(), login: z.string().optional() }),
|
||
},
|
||
'githubAuth:signOut': {
|
||
req: z.object({}),
|
||
res: z.object({ ok: z.literal(true) }),
|
||
},
|
||
'composio:didConnect': {
|
||
req: z.object({
|
||
toolkitSlug: z.string(),
|
||
success: z.boolean(),
|
||
error: z.string().optional(),
|
||
}),
|
||
res: z.null(),
|
||
},
|
||
// Composio Tools Library channels
|
||
'composio:list-toolkits': {
|
||
req: z.object({}),
|
||
res: ZListToolkitsResponse,
|
||
},
|
||
// Mini Apps: execute a Composio tool by slug (scoped to a connected toolkit).
|
||
'composio:execute-tool': {
|
||
req: z.object({
|
||
toolkitSlug: z.string(),
|
||
toolSlug: z.string(),
|
||
arguments: z.record(z.string(), z.unknown()).optional(),
|
||
}),
|
||
res: z.object({
|
||
successful: z.boolean(),
|
||
data: z.unknown().optional(),
|
||
error: z.string().optional(),
|
||
}),
|
||
},
|
||
// Mini Apps: search Composio tools within a toolkit (returns slugs + schemas).
|
||
'composio:search-tools': {
|
||
req: z.object({
|
||
toolkitSlug: z.string(),
|
||
query: z.string(),
|
||
}),
|
||
res: z.object({
|
||
tools: z.array(z.object({
|
||
slug: z.string(),
|
||
name: z.string(),
|
||
description: z.string().optional(),
|
||
})),
|
||
error: z.string().optional(),
|
||
}),
|
||
},
|
||
// Agent schedule channels
|
||
'agent-schedule:getConfig': {
|
||
req: z.null(),
|
||
res: AgentScheduleConfig,
|
||
},
|
||
'agent-schedule:getState': {
|
||
req: z.null(),
|
||
res: AgentScheduleState,
|
||
},
|
||
'agent-schedule:updateAgent': {
|
||
req: z.object({
|
||
agentName: z.string(),
|
||
entry: AgentScheduleEntry,
|
||
}),
|
||
res: z.object({
|
||
success: z.literal(true),
|
||
}),
|
||
},
|
||
'agent-schedule:deleteAgent': {
|
||
req: z.object({
|
||
agentName: z.string(),
|
||
}),
|
||
res: z.object({
|
||
success: z.literal(true),
|
||
}),
|
||
},
|
||
// Shell integration channels
|
||
'shell:openPath': {
|
||
req: z.object({ path: z.string() }),
|
||
res: z.object({ error: z.string().optional() }),
|
||
},
|
||
'shell:showItemInFolder': {
|
||
req: z.object({ path: z.string() }),
|
||
res: z.object({ success: z.literal(true) }),
|
||
},
|
||
'shell:readFileBase64': {
|
||
req: z.object({ path: z.string() }),
|
||
res: z.object({ data: z.string(), mimeType: z.string(), size: z.number() }),
|
||
},
|
||
// Native dialog channels
|
||
'dialog:openDirectory': {
|
||
req: z.object({
|
||
defaultPath: z.string().optional(),
|
||
title: z.string().optional(),
|
||
}),
|
||
res: z.object({
|
||
path: z.string().nullable(),
|
||
}),
|
||
},
|
||
'dialog:openFiles': {
|
||
req: z.object({
|
||
defaultPath: z.string().optional(),
|
||
title: z.string().optional(),
|
||
}),
|
||
res: z.object({
|
||
paths: z.array(z.string()),
|
||
}),
|
||
},
|
||
// Knowledge version history channels
|
||
'knowledge:history': {
|
||
req: z.object({ path: RelPath }),
|
||
res: z.object({
|
||
commits: z.array(z.object({
|
||
oid: z.string(),
|
||
message: z.string(),
|
||
timestamp: z.number(),
|
||
author: z.string(),
|
||
})),
|
||
}),
|
||
},
|
||
'knowledge:fileAtCommit': {
|
||
req: z.object({ path: RelPath, oid: z.string() }),
|
||
res: z.object({ content: z.string() }),
|
||
},
|
||
'knowledge:restore': {
|
||
req: z.object({ path: RelPath, oid: z.string() }),
|
||
res: z.object({ ok: z.literal(true) }),
|
||
},
|
||
'knowledge:didCommit': {
|
||
req: z.object({}),
|
||
res: z.null(),
|
||
},
|
||
// Google Docs linked knowledge files
|
||
'google-docs:getStatus': {
|
||
req: z.null(),
|
||
res: z.object({
|
||
connected: z.boolean(),
|
||
hasRequiredScopes: z.boolean(),
|
||
missingScopes: z.array(z.string()),
|
||
}),
|
||
},
|
||
'google-docs:import': {
|
||
req: z.object({
|
||
fileId: z.string().min(1),
|
||
targetFolder: RelPath,
|
||
}),
|
||
res: z.object({
|
||
path: RelPath,
|
||
doc: z.object({
|
||
id: z.string(),
|
||
name: z.string(),
|
||
url: z.string(),
|
||
modifiedTime: z.string().nullable(),
|
||
owner: z.string().nullable(),
|
||
}),
|
||
}),
|
||
},
|
||
// Managed OAuth-redirect Picker: the Rowboat backend runs the pick with the
|
||
// company Google client; the desktop opens the start URL, waits for the deep
|
||
// link, and imports with the existing managed token. No API key or BYOK creds.
|
||
'google-docs:pickViaManaged': {
|
||
req: z.object({
|
||
targetFolder: RelPath,
|
||
}),
|
||
res: z.object({
|
||
path: RelPath,
|
||
doc: z.object({
|
||
id: z.string(),
|
||
name: z.string(),
|
||
url: z.string(),
|
||
modifiedTime: z.string().nullable(),
|
||
owner: z.string().nullable(),
|
||
}),
|
||
}).nullable(),
|
||
},
|
||
'google-docs:refreshSnapshot': {
|
||
req: z.object({
|
||
path: RelPath,
|
||
}),
|
||
res: z.object({
|
||
ok: z.literal(true),
|
||
syncedAt: z.string(),
|
||
}),
|
||
},
|
||
'google-docs:sync': {
|
||
req: z.object({
|
||
path: RelPath,
|
||
// Overwrite the Google Doc even if it changed remotely since last sync.
|
||
force: z.boolean().optional(),
|
||
// Legacy field from the markdown-link path; ignored by the .docx sync.
|
||
markdown: z.string().optional(),
|
||
}),
|
||
res: z.object({
|
||
synced: z.boolean(),
|
||
syncedAt: z.string().optional(),
|
||
// True when a remote edit was detected and the push was held back.
|
||
conflict: z.boolean().optional(),
|
||
error: z.string().optional(),
|
||
}),
|
||
},
|
||
// Is this local .docx linked to a Google Doc? Drives the sync UI in the viewer.
|
||
'google-docs:getLink': {
|
||
req: z.object({
|
||
path: RelPath,
|
||
}),
|
||
res: z.object({
|
||
link: z.object({
|
||
id: z.string(),
|
||
url: z.string(),
|
||
title: z.string(),
|
||
syncedAt: z.string(),
|
||
remoteModifiedTime: z.string().optional(),
|
||
}).nullable(),
|
||
}),
|
||
},
|
||
// Search channels
|
||
'search:query': {
|
||
req: z.object({
|
||
query: z.string(),
|
||
limit: z.number().optional(),
|
||
types: z.array(z.enum(['knowledge', 'chat'])).optional(),
|
||
}),
|
||
res: z.object({
|
||
results: z.array(z.object({
|
||
type: z.enum(['knowledge', 'chat']),
|
||
title: z.string(),
|
||
preview: z.string(),
|
||
path: z.string(),
|
||
})),
|
||
}),
|
||
},
|
||
// Voice mode channels
|
||
'voice:getConfig': {
|
||
req: z.null(),
|
||
res: z.object({
|
||
deepgram: z.object({ apiKey: z.string() }).nullable(),
|
||
elevenlabs: z.object({ apiKey: z.string(), voiceId: z.string().optional() }).nullable(),
|
||
}),
|
||
},
|
||
'voice:synthesize': {
|
||
req: z.object({
|
||
text: z.string(),
|
||
}),
|
||
res: z.object({
|
||
audioBase64: z.string(),
|
||
mimeType: z.string(),
|
||
}),
|
||
},
|
||
// Streaming TTS: main starts the synthesis and pushes audio chunks over
|
||
// 'voice:tts-chunk' as they arrive, so playback can begin on the first
|
||
// chunk instead of after the full body (~0.5-1s earlier first-audio).
|
||
'voice:synthesizeStreamStart': {
|
||
req: z.object({
|
||
requestId: z.string(),
|
||
text: z.string(),
|
||
}),
|
||
res: z.object({
|
||
ok: z.boolean(),
|
||
error: z.string().optional(),
|
||
}),
|
||
},
|
||
'voice:synthesizeStreamCancel': {
|
||
req: z.object({ requestId: z.string() }),
|
||
res: z.object({}),
|
||
},
|
||
// Push channel: main → renderer with streaming TTS audio. `done: true`
|
||
// (possibly with a final chunk) ends the stream; `error` aborts it.
|
||
'voice:tts-chunk': {
|
||
req: z.object({
|
||
requestId: z.string(),
|
||
chunkBase64: z.string().optional(),
|
||
done: z.boolean(),
|
||
error: z.string().optional(),
|
||
}),
|
||
res: z.null(),
|
||
},
|
||
// Ensures the OS-level microphone permission is settled before capturing.
|
||
// On first-ever use (macOS) the permission is 'not-determined'; resolving
|
||
// the native prompt up front prevents the in-flight getUserMedia from
|
||
// rejecting on the first mic click.
|
||
'voice:ensureMicAccess': {
|
||
req: z.null(),
|
||
res: z.object({
|
||
granted: z.boolean(),
|
||
}),
|
||
},
|
||
// Same as ensureMicAccess but for the camera — settles the macOS TCC
|
||
// permission before video mode calls getUserMedia({ video: true }).
|
||
'voice:ensureCameraAccess': {
|
||
req: z.null(),
|
||
res: z.object({
|
||
granted: z.boolean(),
|
||
}),
|
||
},
|
||
// Video-mode popout: show/hide the small always-on-top window (user +
|
||
// mascot tiles) that floats over everything for the duration of a screen
|
||
// share, Meet-style.
|
||
'video:setPopout': {
|
||
req: z.object({ show: z.boolean() }),
|
||
res: z.object({}),
|
||
},
|
||
// Main-window renderer pushes the current call state; the main process
|
||
// caches it and relays to the popout window (replayed on popout load).
|
||
'video:popoutState': {
|
||
req: z.object({
|
||
ttsState: z.enum(['idle', 'synthesizing', 'speaking']),
|
||
status: z.enum(['listening', 'thinking', 'speaking']).nullable(),
|
||
cameraOn: z.boolean(),
|
||
// User mute: mic audio and frame capture are both paused.
|
||
micMuted: z.boolean(),
|
||
screenSharing: z.boolean(),
|
||
// Live transcript of the in-progress utterance.
|
||
interimText: z.string().nullable(),
|
||
}),
|
||
res: z.object({}),
|
||
},
|
||
// Popout window → fetch the latest cached call state on mount. The
|
||
// did-finish-load replay can race the React listener registration, and the
|
||
// popout must never guess (a wrong camera-on default flashes the user's
|
||
// video before the first state push corrects it).
|
||
'video:getPopoutState': {
|
||
req: z.null(),
|
||
res: z.object({
|
||
state: z
|
||
.object({
|
||
ttsState: z.enum(['idle', 'synthesizing', 'speaking']),
|
||
status: z.enum(['listening', 'thinking', 'speaking']).nullable(),
|
||
cameraOn: z.boolean(),
|
||
micMuted: z.boolean(),
|
||
screenSharing: z.boolean(),
|
||
interimText: z.string().nullable(),
|
||
})
|
||
.nullable(),
|
||
}),
|
||
},
|
||
// Popout control bar → main process → relayed to the app window, which
|
||
// executes the action on the live call. 'expand' additionally focuses the
|
||
// main app window (handled in the main process).
|
||
'video:popoutAction': {
|
||
req: z.object({
|
||
action: z.enum(['toggle-mic', 'toggle-camera', 'toggle-share', 'stop-speaking', 'end-call', 'expand']),
|
||
}),
|
||
res: z.object({}),
|
||
},
|
||
// Push channel: main → popout window with the latest call state.
|
||
'video:popout-state': {
|
||
req: z.object({
|
||
ttsState: z.enum(['idle', 'synthesizing', 'speaking']),
|
||
status: z.enum(['listening', 'thinking', 'speaking']).nullable(),
|
||
cameraOn: z.boolean(),
|
||
micMuted: z.boolean(),
|
||
screenSharing: z.boolean(),
|
||
interimText: z.string().nullable(),
|
||
}),
|
||
res: z.null(),
|
||
},
|
||
// Push channel: main → app window with a popout control-bar action.
|
||
'video:popout-action': {
|
||
req: z.object({
|
||
action: z.enum(['toggle-mic', 'toggle-camera', 'toggle-share', 'stop-speaking', 'end-call', 'expand']),
|
||
}),
|
||
res: z.null(),
|
||
},
|
||
'meeting:checkScreenPermission': {
|
||
req: z.null(),
|
||
res: z.object({
|
||
granted: z.boolean(),
|
||
}),
|
||
},
|
||
'meeting:openScreenRecordingSettings': {
|
||
req: z.null(),
|
||
res: z.object({ success: z.boolean() }),
|
||
},
|
||
'meeting:summarize': {
|
||
req: z.object({
|
||
transcript: z.string(),
|
||
meetingStartTime: z.string().optional(),
|
||
calendarEventJson: z.string().optional(),
|
||
}),
|
||
res: z.object({
|
||
notes: z.string(),
|
||
}),
|
||
},
|
||
// Resolve a meeting's attendees against the knowledge base — returns each
|
||
// attendee's existing person note (or null). Deterministic, no LLM; powers
|
||
// the ambient "Next up" prep card.
|
||
'meeting-prep:resolve': {
|
||
req: z.object({
|
||
attendees: z.array(z.object({
|
||
email: z.string().optional(),
|
||
displayName: z.string().optional(),
|
||
self: z.boolean().optional(),
|
||
})),
|
||
// When provided, the response includes any pre-generated prep note for
|
||
// this calendar event (matched by the eventId stamped in frontmatter).
|
||
eventId: z.string().optional(),
|
||
}),
|
||
res: z.object({
|
||
attendees: z.array(z.object({
|
||
label: z.string(),
|
||
email: z.string().optional(),
|
||
displayName: z.string().optional(),
|
||
note: z.object({
|
||
path: z.string(),
|
||
name: z.string(),
|
||
role: z.string().optional(),
|
||
organization: z.string().optional(),
|
||
markdown: z.string(),
|
||
}).nullable(),
|
||
})),
|
||
organizations: z.array(z.object({
|
||
path: z.string(),
|
||
name: z.string(),
|
||
markdown: z.string(),
|
||
})),
|
||
// The pre-generated prep note (brief + path), if one exists for eventId.
|
||
prepNote: z.object({
|
||
path: z.string(),
|
||
brief: z.string(),
|
||
}).nullable(),
|
||
matchedCount: z.number().int().nonnegative(),
|
||
unmatchedCount: z.number().int().nonnegative(),
|
||
}),
|
||
},
|
||
// Inline task schedule classification
|
||
'export:note': {
|
||
req: z.object({
|
||
markdown: z.string(),
|
||
format: z.enum(['md', 'pdf', 'docx']),
|
||
title: z.string(),
|
||
}),
|
||
res: z.object({
|
||
success: z.boolean(),
|
||
error: z.string().optional(),
|
||
}),
|
||
},
|
||
'inline-task:classifySchedule': {
|
||
req: z.object({
|
||
instruction: z.string(),
|
||
}),
|
||
res: z.object({
|
||
schedule: z.union([
|
||
z.object({ type: z.literal('cron'), expression: z.string(), startDate: z.string(), endDate: z.string(), label: z.string() }),
|
||
z.object({ type: z.literal('window'), cron: z.string(), startTime: z.string(), endTime: z.string(), startDate: z.string(), endDate: z.string(), label: z.string() }),
|
||
z.object({ type: z.literal('once'), runAt: z.string(), label: z.string() }),
|
||
]).nullable(),
|
||
}),
|
||
},
|
||
'inline-task:process': {
|
||
req: z.object({
|
||
instruction: z.string(),
|
||
noteContent: z.string(),
|
||
notePath: z.string(),
|
||
}),
|
||
res: z.object({
|
||
instruction: z.string(),
|
||
schedule: z.union([
|
||
z.object({ type: z.literal('cron'), expression: z.string(), startDate: z.string(), endDate: z.string() }),
|
||
z.object({ type: z.literal('window'), cron: z.string(), startTime: z.string(), endTime: z.string(), startDate: z.string(), endDate: z.string() }),
|
||
z.object({ type: z.literal('once'), runAt: z.string() }),
|
||
]).nullable(),
|
||
scheduleLabel: z.string().nullable(),
|
||
response: z.string().nullable(),
|
||
}),
|
||
},
|
||
// Live-note channels
|
||
'live-note:run': {
|
||
req: z.object({
|
||
filePath: z.string(),
|
||
context: z.string().optional(),
|
||
}),
|
||
res: z.object({
|
||
success: z.boolean(),
|
||
runId: z.string().nullable().optional(),
|
||
action: z.enum(['replace', 'no_update']).optional(),
|
||
summary: z.string().nullable().optional(),
|
||
contentAfter: z.string().nullable().optional(),
|
||
error: z.string().optional(),
|
||
}),
|
||
},
|
||
'live-note:get': {
|
||
req: z.object({
|
||
filePath: z.string(),
|
||
}),
|
||
res: z.object({
|
||
success: z.boolean(),
|
||
// Fresh, authoritative live-note object from frontmatter, or null when
|
||
// the note is passive. Renderer should use this for display/edit —
|
||
// never a stale cached copy.
|
||
live: LiveNoteSchema.nullable().optional(),
|
||
error: z.string().optional(),
|
||
}),
|
||
},
|
||
'live-note:set': {
|
||
req: z.object({
|
||
filePath: z.string(),
|
||
live: LiveNoteSchema,
|
||
}),
|
||
res: z.object({
|
||
success: z.boolean(),
|
||
live: LiveNoteSchema.nullable().optional(),
|
||
error: z.string().optional(),
|
||
}),
|
||
},
|
||
'live-note:setActive': {
|
||
req: z.object({
|
||
filePath: z.string(),
|
||
active: z.boolean(),
|
||
}),
|
||
res: z.object({
|
||
success: z.boolean(),
|
||
live: LiveNoteSchema.nullable().optional(),
|
||
error: z.string().optional(),
|
||
}),
|
||
},
|
||
'live-note:delete': {
|
||
req: z.object({
|
||
filePath: z.string(),
|
||
}),
|
||
res: z.object({
|
||
success: z.boolean(),
|
||
error: z.string().optional(),
|
||
}),
|
||
},
|
||
'live-note:stop': {
|
||
req: z.object({
|
||
filePath: z.string(),
|
||
}),
|
||
res: z.object({
|
||
success: z.boolean(),
|
||
error: z.string().optional(),
|
||
}),
|
||
},
|
||
'live-note:listNotes': {
|
||
req: z.null(),
|
||
res: z.object({
|
||
notes: z.array(z.object({
|
||
path: RelPath,
|
||
createdAt: z.string().nullable(),
|
||
lastRunAt: z.string().nullable(),
|
||
isActive: z.boolean(),
|
||
objective: z.string(),
|
||
})),
|
||
}),
|
||
},
|
||
// Background-task channels
|
||
'bg-task:run': {
|
||
req: z.object({
|
||
slug: z.string(),
|
||
context: z.string().optional(),
|
||
}),
|
||
res: z.object({
|
||
success: z.boolean(),
|
||
runId: z.string().nullable().optional(),
|
||
summary: z.string().nullable().optional(),
|
||
error: z.string().optional(),
|
||
}),
|
||
},
|
||
'bg-task:get': {
|
||
req: z.object({
|
||
slug: z.string(),
|
||
}),
|
||
res: z.object({
|
||
success: z.boolean(),
|
||
task: BackgroundTaskSchema.nullable().optional(),
|
||
error: z.string().optional(),
|
||
}),
|
||
},
|
||
'bg-task:patch': {
|
||
req: z.object({
|
||
slug: z.string(),
|
||
partial: BackgroundTaskSchema.partial(),
|
||
}),
|
||
res: z.object({
|
||
success: z.boolean(),
|
||
task: BackgroundTaskSchema.nullable().optional(),
|
||
error: z.string().optional(),
|
||
}),
|
||
},
|
||
'bg-task:create': {
|
||
req: z.object({
|
||
name: z.string(),
|
||
instructions: z.string(),
|
||
triggers: TriggersSchema.optional(),
|
||
projectId: z.string().optional(),
|
||
model: z.string().optional(),
|
||
provider: z.string().optional(),
|
||
}),
|
||
res: z.object({
|
||
success: z.boolean(),
|
||
slug: z.string().optional(),
|
||
error: z.string().optional(),
|
||
}),
|
||
},
|
||
'bg-task:delete': {
|
||
req: z.object({
|
||
slug: z.string(),
|
||
}),
|
||
res: z.object({
|
||
success: z.boolean(),
|
||
error: z.string().optional(),
|
||
}),
|
||
},
|
||
'bg-task:stop': {
|
||
req: z.object({
|
||
slug: z.string(),
|
||
}),
|
||
res: z.object({
|
||
success: z.boolean(),
|
||
error: z.string().optional(),
|
||
}),
|
||
},
|
||
'bg-task:list': {
|
||
req: z.object({
|
||
offset: z.number().int().nonnegative().optional(),
|
||
limit: z.number().int().positive().optional(),
|
||
sort: z.enum(['createdAt:desc', 'createdAt:asc', 'name:asc']).optional(),
|
||
}),
|
||
res: z.object({
|
||
items: z.array(BackgroundTaskSummarySchema),
|
||
total: z.number().int().nonnegative(),
|
||
}),
|
||
},
|
||
// Returns the runIds recorded in `bg-tasks/<slug>/runs.log` (newest first).
|
||
// The renderer turns each id into a full Run via the existing `runs:fetch`
|
||
// channel — bg-task transcripts now live at the global $WorkDir/runs/.
|
||
'bg-task:listRunIds': {
|
||
req: z.object({
|
||
slug: z.string(),
|
||
limit: z.number().int().positive().optional(),
|
||
}),
|
||
res: z.object({
|
||
runIds: z.array(z.string()),
|
||
}),
|
||
},
|
||
// Embedded browser (WebContentsView) channels
|
||
'browser:setBounds': {
|
||
req: z.object({
|
||
x: z.number().int(),
|
||
y: z.number().int(),
|
||
width: z.number().int().nonnegative(),
|
||
height: z.number().int().nonnegative(),
|
||
}),
|
||
res: z.object({ ok: z.literal(true) }),
|
||
},
|
||
'browser:setVisible': {
|
||
req: z.object({ visible: z.boolean() }),
|
||
res: z.object({ ok: z.literal(true) }),
|
||
},
|
||
'browser:newTab': {
|
||
req: z.object({
|
||
url: z.string().min(1).refine(
|
||
(u) => {
|
||
const lower = u.trim().toLowerCase();
|
||
if (lower.startsWith('javascript:')) return false;
|
||
if (lower.startsWith('file://')) return false;
|
||
if (lower.startsWith('chrome://')) return false;
|
||
if (lower.startsWith('chrome-extension://')) return false;
|
||
return true;
|
||
},
|
||
{ message: 'Unsafe URL scheme' },
|
||
).optional(),
|
||
}),
|
||
res: z.object({
|
||
ok: z.boolean(),
|
||
tabId: z.string().optional(),
|
||
error: z.string().optional(),
|
||
}),
|
||
},
|
||
'browser:switchTab': {
|
||
req: z.object({ tabId: z.string().min(1) }),
|
||
res: z.object({ ok: z.boolean() }),
|
||
},
|
||
'browser:closeTab': {
|
||
req: z.object({ tabId: z.string().min(1) }),
|
||
res: z.object({ ok: z.boolean() }),
|
||
},
|
||
'browser:navigate': {
|
||
req: z.object({
|
||
url: z.string().min(1).refine(
|
||
(u) => {
|
||
const lower = u.trim().toLowerCase();
|
||
if (lower.startsWith('javascript:')) return false;
|
||
if (lower.startsWith('file://')) return false;
|
||
if (lower.startsWith('chrome://')) return false;
|
||
if (lower.startsWith('chrome-extension://')) return false;
|
||
return true;
|
||
},
|
||
{ message: 'Unsafe URL scheme' },
|
||
),
|
||
}),
|
||
res: z.object({
|
||
ok: z.boolean(),
|
||
error: z.string().optional(),
|
||
}),
|
||
},
|
||
'browser:back': {
|
||
req: z.null(),
|
||
res: z.object({ ok: z.boolean() }),
|
||
},
|
||
'browser:forward': {
|
||
req: z.null(),
|
||
res: z.object({ ok: z.boolean() }),
|
||
},
|
||
'browser:reload': {
|
||
req: z.null(),
|
||
res: z.object({ ok: z.literal(true) }),
|
||
},
|
||
'browser:getState': {
|
||
req: z.null(),
|
||
res: BrowserStateSchema,
|
||
},
|
||
'browser:didUpdateState': {
|
||
req: BrowserStateSchema,
|
||
res: z.null(),
|
||
},
|
||
// HTTP basic/proxy auth challenge from a page in the embedded browser
|
||
// (main → renderer push). The renderer shows a credential prompt and
|
||
// answers via browser:httpAuthResponse.
|
||
'browser:httpAuthRequest': {
|
||
req: HttpAuthRequestSchema,
|
||
res: z.null(),
|
||
},
|
||
// Main → renderer: a pending auth challenge was resolved without the
|
||
// renderer answering (timed out, or its tab/window was destroyed), so the
|
||
// renderer must drop the corresponding dialog from its queue.
|
||
'browser:httpAuthResolved': {
|
||
req: z.object({ requestId: z.string() }),
|
||
res: z.null(),
|
||
},
|
||
// Renderer → main. Omit username to cancel the challenge; provide it (even
|
||
// empty, for token-style auth) to submit credentials.
|
||
'browser:httpAuthResponse': {
|
||
req: z.object({
|
||
requestId: z.string(),
|
||
username: z.string().optional(),
|
||
password: z.string().optional(),
|
||
}),
|
||
res: z.object({ ok: z.boolean() }),
|
||
},
|
||
// Billing channels
|
||
'billing:getInfo': {
|
||
req: z.null(),
|
||
res: BillingInfoSchema,
|
||
},
|
||
// Notification settings channels
|
||
'notifications:getSettings': {
|
||
req: z.null(),
|
||
res: NotificationSettingsSchema,
|
||
},
|
||
'notifications:setSettings': {
|
||
req: NotificationSettingsSchema,
|
||
res: z.object({
|
||
success: z.literal(true),
|
||
}),
|
||
},
|
||
} as const;
|
||
|
||
// ============================================================================
|
||
// Type Helpers
|
||
// ============================================================================
|
||
|
||
export type IPCChannels = {
|
||
[K in keyof typeof ipcSchemas]: {
|
||
req: z.infer<typeof ipcSchemas[K]['req']>;
|
||
res: z.infer<typeof ipcSchemas[K]['res']>;
|
||
};
|
||
};
|
||
|
||
/**
|
||
* Channels that use invoke/handle (request/response pattern)
|
||
* These are channels with non-null responses
|
||
*/
|
||
export type InvokeChannels = {
|
||
[K in keyof IPCChannels]:
|
||
IPCChannels[K]['res'] extends null ? never : K
|
||
}[keyof IPCChannels];
|
||
|
||
/**
|
||
* Channels that use send/on (fire-and-forget pattern)
|
||
* These are channels with null responses (no response expected)
|
||
*/
|
||
export type SendChannels = {
|
||
[K in keyof IPCChannels]:
|
||
IPCChannels[K]['res'] extends null ? K : never
|
||
}[keyof IPCChannels];
|
||
|
||
// ============================================================================
|
||
// Type Guards
|
||
// ============================================================================
|
||
|
||
export function validateRequest<K extends keyof IPCChannels>(
|
||
channel: K,
|
||
data: unknown
|
||
): IPCChannels[K]['req'] {
|
||
const schema = ipcSchemas[channel].req;
|
||
return schema.parse(data) as IPCChannels[K]['req'];
|
||
}
|
||
|
||
export function validateResponse<K extends keyof IPCChannels>(
|
||
channel: K,
|
||
data: unknown
|
||
): IPCChannels[K]['res'] {
|
||
const schema = ipcSchemas[channel].res;
|
||
return schema.parse(data) as IPCChannels[K]['res'];
|
||
}
|