Merge dogfood/v0 into master

Zopu dogfood v0 loop: project chat -> Signal -> Work routing; Orb runtime
(Docker + AgentOS + OpenCode with proven gateway model turn); project-manager
orchestration over the Orb; Work OS web UI; single-node runtime deployment.

Lanes: Signals orchestrator, Orb runtime, Web Work OS, runtime deployment,
project-manager Orb wiring. Three-stage Orb proof passes end to end.

# Conflicts:
#	apps/web/src/components/projects/project-workspace-page.tsx
#	apps/web/src/hooks/use-personal-organization.ts
#	apps/web/src/hooks/use-project-workspace.ts
#	apps/web/src/routes/_app.tsx
#	apps/web/src/routes/_auth.tsx
#	packages/backend/convex/projectIssues.ts
#	packages/backend/convex/projects.ts
#	packages/primitives/package.json
#	packages/primitives/src/index.ts
This commit is contained in:
-Puter
2026-07-25 02:03:30 +05:30
156 changed files with 21151 additions and 873 deletions

View File

@@ -12,7 +12,7 @@
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"components": "@code/ui/components",
"utils": "@code/ui/lib/utils",
"ui": "@code/ui/components",
"lib": "@/lib",

View File

@@ -7,12 +7,13 @@
"dev": "react-router dev",
"dev:tailscale": "react-router dev --host 0.0.0.0",
"start": "react-router-serve ./build/server/index.js",
"typecheck": "react-router typegen && tsc"
"check-types": "react-router typegen && tsc --noEmit"
},
"dependencies": {
"@code/auth": "workspace:*",
"@code/backend": "workspace:*",
"@code/env": "workspace:*",
"@code/primitives": "workspace:*",
"@code/ui": "workspace:*",
"@flue/react": "1.0.0-beta.9",
"@flue/sdk": "1.0.0-beta.9",
@@ -40,7 +41,7 @@
"tailwindcss": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vitest": "catalog:",
"vite-tsconfig-paths": "^6.1.1"
"vite-tsconfig-paths": "^6.1.1",
"vitest": "catalog:"
}
}

View File

@@ -1,5 +1,4 @@
import { MessageHeader } from "@code/ui/components/message";
import { Sparkles } from "lucide-react";
import { MobileChatAssistantLabel } from "@code/ui/components/mobile-chat";
import type { AssistantResponseState } from "@/lib/chat/types";
@@ -8,20 +7,18 @@ interface AssistantIdentityProps {
}
export const AssistantIdentity = ({ state }: AssistantIdentityProps) => (
<MessageHeader className="mb-1.5 gap-2.5 px-0 text-xs font-medium text-foreground/70">
<span className="zopu-mark flex size-6 items-center justify-center rounded-lg">
<Sparkles className="size-3.5" />
</span>
Zopu
{state && (
<span className="response-state" data-state={state}>
<span aria-hidden="true" className="response-state-dots">
<i />
<i />
<i />
<MobileChatAssistantLabel
state={
state ? (
<span className="response-state" data-state={state}>
<span aria-hidden="true" className="response-state-dots">
<i />
<i />
<i />
</span>
{state === "thinking" ? "Thinking" : "Writing"}
</span>
{state === "thinking" ? "Thinking" : "Writing"}
</span>
)}
</MessageHeader>
) : undefined
}
/>
);

View File

@@ -1,10 +1,5 @@
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupTextarea,
} from "@code/ui/components/input-group";
import { CircleAlert, LoaderCircle, Send } from "lucide-react";
import { MobileChatComposer } from "@code/ui/components/mobile-chat";
import { LoaderCircle } from "lucide-react";
import type { ReactNode } from "react";
import { useChatComposer } from "@/hooks/chat/use-chat-composer";
@@ -15,90 +10,31 @@ export const ChatComposer = ({ error, onSend, status }: ChatComposerProps) => {
status === "connecting" || status === "submitted" || status === "streaming";
const composer = useChatComposer({ busy, onSend });
let statusContent: ReactNode = (
<span className="hidden sm:inline">
Enter to send · Shift + Enter for a new line
</span>
);
let statusMessage: ReactNode;
if (busy) {
statusContent = (
<>
<LoaderCircle className="size-3.5 animate-spin" />
statusMessage = (
<span className="inline-flex items-center gap-1.5">
<LoaderCircle className="size-3 animate-spin" />
{status === "connecting" ? "Preparing Zopu" : "Zopu is responding"}
</>
);
}
if (error) {
statusContent = (
<span
id="composer-error"
className="flex items-center gap-1.5 text-destructive"
>
<CircleAlert className="size-3.5" />
{error.message || "Message failed to send"}
</span>
);
}
return (
<div className="composer-dock shrink-0 pb-[env(safe-area-inset-bottom)]">
<form
aria-label="Send a message"
className="mx-auto w-full max-w-3xl px-3 pb-3 sm:px-5 sm:pb-5"
onSubmit={composer.handleSubmit}
>
<InputGroup
className={`composer-shell overflow-hidden rounded-[1.5rem] border-border/70 bg-card/95 shadow-[0_16px_50px_-18px_rgb(0_0_0/0.38)] backdrop-blur-xl transition-[border-color,box-shadow] focus-within:border-foreground/20 focus-within:shadow-[0_18px_60px_-20px_rgb(0_0_0/0.48)] dark:bg-card/90 ${
composer.isFocused
? "composer-shell-expanded"
: "composer-shell-compact"
}`}
data-disabled={busy || undefined}
>
<InputGroupTextarea
{...composer.inputProps}
aria-describedby={error ? "composer-error" : undefined}
aria-invalid={error ? true : undefined}
aria-label="Message Zopu"
className={`max-h-40 px-4 text-base leading-6 transition-[min-height,padding] duration-200 placeholder:text-muted-foreground/70 sm:px-5 ${
composer.isFocused
? "min-h-24 pb-2 pt-4"
: "min-h-14 py-[0.95rem] pr-2"
}`}
disabled={busy}
placeholder="Ask anything…"
rows={1}
/>
<InputGroupAddon
align="inline-end"
className={`shrink-0 self-end pr-2 transition-[padding] duration-200 ${
composer.isFocused ? "pb-2.5" : "pb-1.5"
}`}
>
<InputGroupButton
aria-label="Send message"
className="size-11 shrink-0 rounded-2xl bg-foreground text-background transition-all hover:scale-[1.03] hover:bg-foreground/85 active:scale-95 disabled:bg-muted disabled:text-muted-foreground sm:size-10 sm:rounded-xl"
disabled={!composer.canSend}
size="icon-sm"
type="submit"
variant="default"
>
{busy ? (
<LoaderCircle className="size-5 animate-spin" />
) : (
<Send className="size-5" />
)}
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
<div className="mt-2 flex min-h-4 items-center justify-center px-2 text-center text-[10px] text-muted-foreground/80 sm:text-[11px]">
{composer.isFocused || busy || error ? (
<span className="flex items-center gap-2">{statusContent}</span>
) : (
<span>Zopu can make mistakes. Check important information.</span>
)}
</div>
</form>
</div>
<MobileChatComposer
busy={busy}
canSend={composer.canSend}
errorMessage={
error ? error.message || "Message failed to send" : undefined
}
onSubmit={composer.handleSubmit}
statusMessage={statusMessage}
textareaProps={{
...composer.inputProps,
"aria-describedby": error ? "composer-error" : undefined,
"aria-invalid": error ? true : undefined,
disabled: busy,
}}
/>
);
};

View File

@@ -1,15 +1,6 @@
import { ConversationEmptyState } from "@code/ui/components/ai-elements/conversation";
import { Button } from "@code/ui/components/button";
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@code/ui/components/empty";
import { MessageGroup } from "@code/ui/components/message";
import { MessageScrollerItem } from "@code/ui/components/message-scroller";
import { Sparkles } from "lucide-react";
import { MobileChatMark } from "@code/ui/components/mobile-chat";
import { SUGGESTIONS } from "@/lib/chat/constants";
import type { ChatConversationProps } from "@/lib/chat/types";
@@ -25,23 +16,21 @@ export const ChatConversation = ({
}: ChatConversationProps) => {
if (historyReady && messages.length === 0) {
return (
<Empty className="min-h-full items-start justify-end px-1 pb-7 pt-20 sm:items-center sm:justify-center sm:px-6 sm:py-20">
<EmptyMedia className="zopu-mark size-11 rounded-2xl" variant="icon">
<Sparkles className="size-5" />
</EmptyMedia>
<EmptyHeader className="items-start text-left sm:items-center sm:text-center">
<EmptyTitle className="text-2xl font-semibold tracking-[-0.035em] sm:text-3xl">
<ConversationEmptyState className="min-h-full items-start justify-end px-0 pt-14 pb-5 text-left">
<MobileChatMark className="size-10 rounded-[12px] [&_svg]:size-5" />
<div className="space-y-1">
<h2 className="text-[22px] font-semibold tracking-[-0.035em]">
What can I help with?
</EmptyTitle>
<EmptyDescription className="max-w-md text-[15px] leading-6">
</h2>
<p className="max-w-[320px] text-[14px] leading-5 text-muted-foreground">
Think, write, plan, or explore an idea with Zopu.
</EmptyDescription>
</EmptyHeader>
<EmptyContent className="mt-3 grid w-full max-w-xl gap-2 sm:grid-cols-3">
</p>
</div>
<div className="mt-3 grid w-full gap-2">
{SUGGESTIONS.map(([title, prompt]) => (
<Button
key={title}
className="group h-auto min-h-16 w-full flex-col items-start justify-center gap-0.5 rounded-2xl border-border/60 bg-card/60 px-4 py-3 text-left shadow-sm transition-all hover:-translate-y-0.5 hover:border-foreground/20 hover:bg-card hover:shadow-md sm:min-h-24"
className="group h-auto min-h-14 w-full flex-col items-start justify-center gap-0.5 rounded-[16px] border-[#e7e7e4] bg-[#f7f7f5] px-4 py-3 text-left shadow-none transition-colors active:bg-[#eeeeeb]"
onClick={() => onSuggestion(prompt)}
variant="outline"
>
@@ -51,27 +40,17 @@ export const ChatConversation = ({
</span>
</Button>
))}
</EmptyContent>
</Empty>
</div>
</ConversationEmptyState>
);
}
return (
<MessageGroup className="gap-8 pb-5 md:gap-10 md:pb-8">
{messages.map((message, index) => (
<MessageScrollerItem
key={message.id}
messageId={message.id}
scrollAnchor={index === messages.length - 1}
>
<ChatMessage message={message} />
</MessageScrollerItem>
<div className="flex min-w-0 flex-col gap-5 pb-5">
{messages.map((message) => (
<ChatMessage key={message.id} message={message} />
))}
{status === "submitted" && (
<MessageScrollerItem scrollAnchor>
<ChatThinkingResponse />
</MessageScrollerItem>
)}
</MessageGroup>
{status === "submitted" && <ChatThinkingResponse />}
</div>
);
};

View File

@@ -1,32 +1,11 @@
import { Sparkles } from "lucide-react";
import { MobileChatHeader } from "@code/ui/components/mobile-chat";
import { MODEL_LABEL, STATUS_COPY } from "@/lib/chat/constants";
import { getStatusDotClass } from "@/lib/chat/transforms";
import { STATUS_COPY } from "@/lib/chat/constants";
import type { ChatHeaderProps } from "@/lib/chat/types";
export const ChatHeader = ({ status }: ChatHeaderProps) => (
<header className="chat-header flex h-14 shrink-0 items-center border-b border-border/60 px-4 sm:h-16 sm:px-6">
<div className="mx-auto flex w-full max-w-5xl items-center justify-between">
<div className="flex min-w-0 items-center gap-3">
<div className="zopu-mark flex size-9 shrink-0 items-center justify-center rounded-xl">
<Sparkles className="size-4" />
</div>
<div className="min-w-0">
<h1 className="truncate text-sm font-semibold tracking-tight sm:text-[15px]">
Zopu
</h1>
<p className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
<span
aria-hidden="true"
className={`size-1.5 rounded-full ${getStatusDotClass(status)}`}
/>
<span aria-live="polite">{STATUS_COPY[status]}</span>
</p>
</div>
</div>
<p className="rounded-full border border-border/60 bg-card/50 px-2.5 py-1 text-[10px] font-medium tracking-wide text-muted-foreground sm:text-[11px]">
{MODEL_LABEL}
</p>
</div>
</header>
<MobileChatHeader
active={status !== "connecting" && status !== "error"}
statusLabel={STATUS_COPY[status]}
/>
);

View File

@@ -1,7 +1,13 @@
import { Bubble, BubbleContent } from "@code/ui/components/bubble";
import { Message, MessageContent } from "@code/ui/components/message";
import {
Message,
MessageContent,
MessageResponse,
} from "@code/ui/components/ai-elements/message";
import {
MobileChatBubble,
MobileChatMessage,
} from "@code/ui/components/mobile-chat";
import type { ReactNode } from "react";
import { Streamdown } from "streamdown";
import { getMessageText, isMessageStreaming } from "@/lib/chat/transforms";
import type { ChatMessageProps } from "@/lib/chat/types";
@@ -21,13 +27,13 @@ export const ChatMessage = ({ message }: ChatMessageProps) => {
let content: ReactNode = text;
if (!isUser) {
content = text ? (
<Streamdown
<MessageResponse
caret={isStreaming ? "block" : undefined}
className="chat-markdown min-w-0"
isAnimating={isStreaming}
>
{text}
</Streamdown>
</MessageResponse>
) : (
<div className="thinking-line" aria-label="Zopu is preparing a response">
<span />
@@ -37,41 +43,29 @@ export const ChatMessage = ({ message }: ChatMessageProps) => {
);
}
const sender = isUser ? "user" : "assistant";
return (
<Message
align={isUser ? "end" : "start"}
className="chat-message text-[15px] md:text-base"
>
<MessageContent
className={
isUser
? "max-w-[88%] sm:max-w-[76%] md:max-w-[68%]"
: "max-w-full md:max-w-[92%]"
}
>
{!isUser && (
<AssistantIdentity state={isStreaming ? "writing" : undefined} />
)}
<Bubble
align={isUser ? "end" : "start"}
variant={isUser ? "secondary" : "ghost"}
className="max-w-full"
>
<BubbleContent
className={
isUser
? "rounded-[1.35rem] rounded-br-md border border-border/60 bg-secondary/80 px-4 py-2.5 leading-6 text-foreground shadow-sm whitespace-pre-wrap dark:bg-secondary/70"
: "w-full max-w-none overflow-visible leading-7 text-foreground/95"
}
>
{message.parts.map((part) =>
part.type === "dynamic-tool" ? (
<ChatToolCall key={part.toolCallId} part={part} />
) : null
<Message className="max-w-full gap-0" from={message.role}>
<MessageContent className="w-full max-w-none gap-0 overflow-visible rounded-none bg-transparent p-0 group-[.is-user]:rounded-none group-[.is-user]:bg-transparent group-[.is-user]:p-0">
<MobileChatMessage className="chat-message" sender={sender}>
<div className={isUser ? "max-w-full" : "w-full min-w-0"}>
{!isUser && (
<AssistantIdentity state={isStreaming ? "writing" : undefined} />
)}
{content}
</BubbleContent>
</Bubble>
<MobileChatBubble
className={isUser ? "whitespace-pre-wrap" : undefined}
sender={sender}
>
{message.parts.map((part) =>
part.type === "dynamic-tool" ? (
<ChatToolCall key={part.toolCallId} part={part} />
) : null
)}
{content}
</MobileChatBubble>
</div>
</MobileChatMessage>
</MessageContent>
</Message>
);

View File

@@ -1,10 +1,8 @@
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerProvider,
MessageScrollerViewport,
} from "@code/ui/components/message-scroller";
Conversation,
ConversationContent,
ConversationScrollButton,
} from "@code/ui/components/ai-elements/conversation";
import { useChatAgent } from "@/hooks/chat/use-chat-agent";
@@ -17,36 +15,24 @@ export const ChatPage = () => {
const handleSendMessage = (message: string) => agent.sendMessage(message);
return (
<section className="chat-surface flex h-full min-h-0 flex-col bg-background">
<section className="chat-surface flex h-full min-h-0 flex-col bg-[#fefefe]">
<ChatHeader status={agent.status} />
<main aria-label="Conversation" className="min-h-0 flex-1">
<MessageScrollerProvider
autoScroll
defaultScrollPosition="end"
scrollEdgeThreshold={96}
scrollMargin={24}
>
<MessageScroller>
<MessageScrollerViewport aria-label="Chat messages">
<MessageScrollerContent className="mx-auto w-full max-w-3xl px-4 pb-8 pt-7 sm:px-6 sm:pb-10 sm:pt-10">
<ChatConversation
historyReady={agent.historyReady}
messages={agent.messages}
onSuggestion={(suggestion) =>
void handleSendMessage(suggestion)
}
status={agent.status}
/>
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton
aria-label="Scroll to latest message"
className="bottom-4 size-10 rounded-full border border-border/70 bg-card/95 shadow-lg backdrop-blur"
direction="end"
<Conversation className="ai-conversation-mobile h-full">
<ConversationContent className="min-h-full w-full gap-0 px-4 pt-4 pb-6">
<ChatConversation
historyReady={agent.historyReady}
messages={agent.messages}
onSuggestion={(suggestion) => void handleSendMessage(suggestion)}
status={agent.status}
/>
</MessageScroller>
</MessageScrollerProvider>
</ConversationContent>
<ConversationScrollButton
aria-label="Scroll to latest message"
className="bottom-3 size-9 rounded-full border border-[#dededb] bg-[#fefefe]/95 shadow-sm backdrop-blur"
/>
</Conversation>
</main>
<ChatComposer

View File

@@ -1,16 +1,29 @@
import { Message, MessageContent } from "@code/ui/components/message";
import {
Message,
MessageContent,
} from "@code/ui/components/ai-elements/message";
import {
MobileChatBubble,
MobileChatMessage,
} from "@code/ui/components/mobile-chat";
import { AssistantIdentity } from "./assistant-identity";
export const ChatThinkingResponse = () => (
<Message align="start" className="chat-message">
<MessageContent className="max-w-full md:max-w-[92%]">
<AssistantIdentity state="thinking" />
<div className="thinking-line" aria-label="Zopu is thinking">
<span />
<span />
<span />
</div>
<Message className="max-w-full gap-0" from="assistant">
<MessageContent className="w-full max-w-none gap-0 overflow-visible p-0">
<MobileChatMessage className="chat-message" sender="assistant">
<div className="w-full min-w-0">
<AssistantIdentity state="thinking" />
<MobileChatBubble sender="assistant">
<div className="thinking-line" aria-label="Zopu is thinking">
<span />
<span />
<span />
</div>
</MobileChatBubble>
</div>
</MobileChatMessage>
</MessageContent>
</Message>
);

View File

@@ -1,4 +1,11 @@
import { Check, LoaderCircle, Terminal, TriangleAlert } from "lucide-react";
import {
Tool,
ToolContent,
ToolInput,
ToolOutput,
} from "@code/ui/components/ai-elements/tool";
import { MobileChatToolCall } from "@code/ui/components/mobile-chat";
import { Search, SquareTerminal } from "lucide-react";
import type { ChatToolCallProps } from "@/lib/chat/types";
@@ -7,35 +14,47 @@ export const ChatToolCall = ({ part }: ChatToolCallProps) => {
typeof part.input === "string"
? part.input
: JSON.stringify(part.input, null, 2);
let Icon = LoaderCircle;
let status = "Running";
let status = "running";
let tone: "error" | "neutral" | "success" = "neutral";
if (part.state === "output-available") {
detail =
typeof part.output === "string"
? part.output
: JSON.stringify(part.output, null, 2);
Icon = Check;
status = "Completed";
status = "done";
tone = "success";
} else if (part.state === "output-error") {
detail = part.errorText;
Icon = TriangleAlert;
status = "Failed";
status = "failed";
tone = "error";
}
const isRunning = part.state === "input-available";
const isSearch = part.toolName.toLowerCase().includes("search");
const output = part.state === "output-available" ? part.output : undefined;
const errorText = part.state === "output-error" ? part.errorText : undefined;
return (
<details className="group/tool overflow-hidden rounded-xl border border-border/60 bg-card/45 text-xs">
<summary className="flex cursor-pointer list-none items-center gap-2 px-3 py-2 text-muted-foreground marker:hidden">
<Terminal className="size-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate font-medium text-foreground/85">
{part.toolName}
</span>
<span>{status}</span>
<Icon className={`size-3.5 ${isRunning ? "animate-spin" : ""}`} />
</summary>
<pre className="max-h-72 overflow-auto border-t border-border/50 bg-background/60 px-3 py-2 font-mono text-[11px] leading-5 whitespace-pre-wrap text-muted-foreground">
{detail}
</pre>
</details>
<Tool className="mb-0 w-full border-0" defaultOpen>
<ToolContent className="space-y-0 p-0">
<MobileChatToolCall
detail={detail}
icon={
isSearch ? (
<Search className="size-3" strokeWidth={2.2} />
) : (
<SquareTerminal className="size-3" strokeWidth={2.2} />
)
}
status={status}
tone={tone}
toolName={part.toolName}
/>
<div className="sr-only">
<ToolInput input={part.input} />
<ToolOutput errorText={errorText} output={output} />
</div>
</ToolContent>
</Tool>
);
};

View File

@@ -0,0 +1,102 @@
import type { Id } from "@code/backend/convex/_generated/dataModel";
import {
MobileAssistantChatScreen,
MobileExpandedWorkScreen,
MobileHomeScreen,
MobileWorkListScreen,
MobileWorkUnitDetailScreen,
} from "@code/ui/components/mobile-product";
import { useNavigate, useParams } from "react-router";
import { useMobileProjectWorkspace } from "@/hooks/use-mobile-project-workspace";
import { useMobileWorkspace } from "@/hooks/use-mobile-workspace";
export type MobileFlowScreen =
| "assistant-chat"
| "home"
| "work-list"
| "work-unit-detail";
interface MobileFlowPageProps {
screen: MobileFlowScreen;
}
export const MobileFlowPage = ({ screen }: MobileFlowPageProps) => {
const navigate = useNavigate();
const { workUnitId } = useParams();
const projectWorkspace = useMobileProjectWorkspace({
selectedWorkUnitId: workUnitId,
});
const workspace = useMobileWorkspace({
onCreateIssue: (title, body) =>
projectWorkspace.raiseIssue({ body, title }),
onSend: projectWorkspace.sendMessage,
});
const workPath = "/work";
const chatPath = "/chat";
const handleOpenUnit = (selectedWorkUnitId?: string) => {
const targetId =
selectedWorkUnitId ?? projectWorkspace.data.selectedWorkUnit?.id;
if (!targetId) {
navigate("/dashboard");
return;
}
if (screen === "work-list" && !workspace.expanded) {
workspace.setExpanded(true);
return;
}
navigate(`/work/${targetId}`);
};
const screenProps = {
composerValue: workspace.composerValue,
createIssueBody: workspace.issueBody,
createIssueTitle: workspace.issueTitle,
data: projectWorkspace.data,
onBack: () => navigate(workPath),
onComposerChange: workspace.handleComposerChange,
onComposerSubmit: workspace.handleComposerSubmit,
onCreateBodyChange: workspace.setIssueBody,
onCreateIssue: workspace.handleCreateIssueSubmit,
onCreateIssueFromSignal: projectWorkspace.data.latestSignal?.projectId
? () =>
void projectWorkspace.raiseIssueFromSignal(
projectWorkspace.data.latestSignal?.id ?? ""
)
: undefined,
onCreateTitleChange: workspace.setIssueTitle,
onManageProjects: () => navigate("/dashboard"),
onOpenAssistant: () => navigate(chatPath),
onOpenUnit: handleOpenUnit,
onProjectSelect: (projectId: string) =>
projectWorkspace.projectWorkspace.setSelectedProjectId(
projectId as Id<"projects">
),
onReviewUnit: (reviewUrl: string) => {
window.open(reviewUrl, "_blank", "noopener,noreferrer");
},
onStartUnit: (selectedIssueId: string) => {
void projectWorkspace.startWorkUnit(
selectedIssueId as Id<"projectIssues">
);
},
onViewWork: () => navigate(workPath),
pendingAction: projectWorkspace.projectWorkspace.pendingAction,
statusMessage: workspace.statusMessage ?? projectWorkspace.error?.message,
};
if (screen === "assistant-chat") {
return <MobileAssistantChatScreen {...screenProps} />;
}
if (screen === "home") {
return <MobileHomeScreen {...screenProps} />;
}
if (screen === "work-unit-detail") {
return <MobileWorkUnitDetailScreen {...screenProps} />;
}
if (workspace.expanded) {
return <MobileExpandedWorkScreen {...screenProps} />;
}
return <MobileWorkListScreen {...screenProps} />;
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,53 @@
import {
Conversation,
ConversationContent,
} from "@code/ui/components/ai-elements/conversation";
import { LoaderCircle, MessagesSquare } from "lucide-react";
import { ChatMessage } from "@/components/chat/chat-message";
import { ChatThinkingResponse } from "@/components/chat/chat-thinking-response";
import type { ChatAgentState } from "@/lib/chat/types";
interface ConversationPanelProps {
readonly agent: ChatAgentState;
readonly title: string;
readonly emptyHint: string;
}
export const ConversationPanel = ({
agent,
emptyHint,
title,
}: ConversationPanelProps) => {
if (agent.status === "connecting" && !agent.historyReady) {
return (
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
<LoaderCircle className="mr-2 size-4 animate-spin" />
Loading conversation
</div>
);
}
if (agent.historyReady && agent.messages.length === 0) {
return (
<div className="flex flex-1 flex-col items-center justify-center px-6 text-center">
<MessagesSquare className="size-6 text-muted-foreground/50" />
<p className="mt-3 text-sm font-medium">{title}</p>
<p className="mt-1 max-w-xs text-xs leading-5 text-muted-foreground">
{emptyHint}
</p>
</div>
);
}
return (
<Conversation className="min-h-0 flex-1">
<ConversationContent className="min-h-full gap-4 px-4 py-4">
{agent.messages.map((message) => (
<ChatMessage key={message.id} message={message} />
))}
{agent.status === "submitted" ? <ChatThinkingResponse /> : null}
</ConversationContent>
</Conversation>
);
};

View File

@@ -0,0 +1,54 @@
import { Bot, FolderGit2 } from "lucide-react";
interface EmptyStateProps {
readonly onConnectRepository: () => void;
readonly busy: boolean;
readonly repository: string;
readonly onRepositoryChange: (value: string) => void;
}
export const EmptyState = ({
busy,
onConnectRepository,
onRepositoryChange,
repository,
}: EmptyStateProps) => (
<div className="grid min-h-[60vh] place-items-center">
<div className="max-w-md text-center">
<div className="mx-auto grid size-14 place-items-center rounded-2xl bg-foreground text-background">
<FolderGit2 className="size-6" />
</div>
<h2 className="mt-6 text-2xl font-semibold tracking-tight">
Connect a project to begin
</h2>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
Import a repository to start the Work OS loop. Turn a clear outcome into
a Work Unit that Zopu can start, verify, and review.
</p>
<form
className="mx-auto mt-6 max-w-sm space-y-3"
onSubmit={(event) => {
event.preventDefault();
onConnectRepository();
}}
>
<input
aria-label="Public Git repository URL"
className="w-full rounded-xl border border-border/40 bg-card/40 px-4 py-2.5 text-sm outline-none placeholder:text-muted-foreground/60 focus-visible:border-foreground/20"
onChange={(event) => onRepositoryChange(event.target.value)}
placeholder="https://github.com/owner/repo"
required
value={repository}
/>
<button
className="inline-flex w-full items-center justify-center gap-2 rounded-xl bg-foreground px-4 py-2.5 text-sm font-medium text-background transition-opacity hover:opacity-90 disabled:opacity-60"
disabled={busy}
type="submit"
>
<Bot className="size-4" />
{busy ? "Connecting" : "Connect repository"}
</button>
</form>
</div>
</div>
);

View File

@@ -0,0 +1,71 @@
import { Radio } from "lucide-react";
import type { SignalItem } from "@/hooks/work-os/use-work-os";
import { formatRelativeTime } from "@/lib/work-os/work-unit-projection";
interface SignalsPanelProps {
readonly signals: readonly SignalItem[];
readonly loading: boolean;
}
export const SignalsPanel = ({ loading, signals }: SignalsPanelProps) => {
if (loading) {
return (
<section className="rounded-2xl border border-border/30 bg-card/30 p-4">
<div className="mb-3 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
<Radio className="size-3" />
Signals
</div>
<p className="text-xs text-muted-foreground">Loading signals</p>
</section>
);
}
if (signals.length === 0) {
return (
<section className="rounded-2xl border border-border/30 bg-card/30 p-4">
<div className="mb-3 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
<Radio className="size-3" />
Signals
</div>
<p className="text-xs text-muted-foreground">
No Signals detected for this project yet.
</p>
</section>
);
}
return (
<section className="rounded-2xl border border-border/30 bg-card/30 p-4">
<div className="mb-3 flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
<Radio className="size-3" />
Signals
</div>
<span className="text-[10px] text-muted-foreground/60">
{signals.length} total
</span>
</div>
<div className="space-y-3">
{signals.map((signal) => (
<div
className="rounded-xl border border-border/20 bg-background/30 p-3"
key={`${signal.title}-${signal.createdAt}`}
>
<p className="text-sm font-medium">{signal.title}</p>
<p className="mt-1 line-clamp-2 text-xs leading-5 text-muted-foreground">
{signal.summary}
</p>
<div className="mt-2 flex items-center gap-3 text-[10px] text-muted-foreground/60">
<span>
{signal.sourceCount} source
{signal.sourceCount === 1 ? "" : "s"}
</span>
<span>{formatRelativeTime(signal.createdAt)}</span>
</div>
</div>
))}
</div>
</section>
);
};

View File

@@ -0,0 +1,143 @@
import { Button } from "@code/ui/components/button";
import { LoaderCircle, Send, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { ComposerMode } from "@/hooks/work-os/use-work-os";
interface WorkOsComposerProps {
readonly mode: ComposerMode;
readonly onModeChange: (mode: ComposerMode) => void;
readonly onSend: (message: string) => Promise<void>;
readonly busy: boolean;
readonly error?: Error;
readonly workUnitTitle?: string;
readonly placeholder?: string;
}
export const WorkOsComposer = ({
busy,
error,
mode,
onModeChange,
onSend,
placeholder,
workUnitTitle,
}: WorkOsComposerProps) => {
const [draft, setDraft] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const canSend = draft.trim().length > 0 && !busy;
useEffect(() => {
if (!busy) {
textareaRef.current?.focus();
}
}, [busy, mode]);
const handleSubmit = async () => {
const message = draft.trim();
if (!message || busy) {
return;
}
setDraft("");
try {
await onSend(message);
} finally {
textareaRef.current?.focus();
}
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (
event.key === "Enter" &&
!event.shiftKey &&
!event.nativeEvent.isComposing
) {
event.preventDefault();
void handleSubmit();
}
};
const scopeLabel =
mode === "project" ? "Project" : (workUnitTitle ?? "Work Unit");
return (
<div className="border-t border-border/40 bg-background/80 px-4 py-3 backdrop-blur-xl sm:px-6">
{/* Mode switcher */}
<div className="mb-2 flex items-center gap-2">
<div className="flex items-center gap-0.5 rounded-lg bg-muted/60 p-0.5">
<button
className={`rounded-md px-2.5 py-1 text-[11px] font-medium transition-colors ${
mode === "project"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => onModeChange("project")}
type="button"
>
Project
</button>
<button
className={`flex items-center gap-1 rounded-md px-2.5 py-1 text-[11px] font-medium transition-colors ${
mode === "work-unit"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
disabled={!workUnitTitle}
onClick={() => onModeChange("work-unit")}
type="button"
>
Work Unit
</button>
</div>
{mode === "work-unit" && workUnitTitle ? (
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<X
className="size-3 cursor-pointer"
onClick={() => onModeChange("project")}
/>
<span className="max-w-40 truncate">{workUnitTitle}</span>
</span>
) : null}
<span className="ml-auto text-[10px] text-muted-foreground/50">
{scopeLabel}
</span>
</div>
{error ? (
<p className="mb-2 text-[11px] text-destructive" id="composer-error">
{error.message || "Message failed to send"}
</p>
) : null}
<div className="flex items-end gap-2.5">
<textarea
aria-describedby={error ? "composer-error" : undefined}
aria-invalid={error ? true : undefined}
aria-label={`Message ${scopeLabel}`}
className="min-h-11 max-h-32 flex-1 resize-none rounded-2xl border border-border/40 bg-card/40 px-4 py-2.5 text-sm leading-5 outline-none transition-colors placeholder:text-muted-foreground/60 focus-visible:border-foreground/20 disabled:cursor-not-allowed disabled:opacity-60"
disabled={busy}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder ?? `Message ${scopeLabel}`}
ref={textareaRef}
rows={1}
value={draft}
/>
<Button
aria-label="Send message"
className="size-11 shrink-0 rounded-full"
disabled={!canSend}
onClick={() => void handleSubmit()}
size="icon"
type="button"
>
{busy ? (
<LoaderCircle className="size-4 animate-spin" />
) : (
<Send className="size-4" />
)}
</Button>
</div>
</div>
);
};

View File

@@ -0,0 +1,112 @@
import { Button } from "@code/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@code/ui/components/dropdown-menu";
import { ChevronDown, Circle, FolderGit2, Zap } from "lucide-react";
import UserMenu from "@/components/user-menu";
interface ProjectOption {
readonly id: string;
readonly name: string;
readonly host?: string;
}
interface WorkOsHeaderProps {
readonly projects: readonly ProjectOption[] | undefined;
readonly selectedProject: ProjectOption | null;
readonly onSelectProject: (id: string) => void;
readonly connected: boolean;
readonly onBack?: () => void;
readonly showBack: boolean;
}
const StatusDot = ({ connected }: { readonly connected: boolean }) => (
<span className="inline-flex items-center gap-1.5 text-[11px] text-muted-foreground">
<Circle
className={`size-2 ${connected ? "fill-emerald-500 text-emerald-500" : "fill-amber-500 text-amber-500"}`}
/>
{connected ? "Connected" : "Connecting"}
</span>
);
export const WorkOsHeader = ({
connected,
onBack,
onSelectProject,
projects,
selectedProject,
showBack,
}: WorkOsHeaderProps) => (
<header className="flex items-center justify-between gap-3 border-b border-border/40 bg-background/80 px-4 py-3 backdrop-blur-xl sm:px-6">
<div className="flex min-w-0 items-center gap-3">
{showBack && onBack ? (
<Button
className="shrink-0"
onClick={onBack}
size="sm"
type="button"
variant="ghost"
>
<ChevronDown className="size-4 rotate-90" />
</Button>
) : null}
<div className="grid size-9 shrink-0 place-items-center rounded-xl bg-foreground text-background">
<Zap className="size-4 fill-current" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold tracking-tight">Zopu</p>
<StatusDot connected={connected} />
</div>
{selectedProject ? (
<p className="truncate text-[11px] text-muted-foreground">
{selectedProject.name}
</p>
) : (
<p className="text-[11px] text-muted-foreground">No project</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
{projects && projects.length > 1 ? (
<DropdownMenu>
<DropdownMenuTrigger>
<Button size="sm" type="button" variant="ghost">
<FolderGit2 className="size-3.5" />
<span className="max-w-32 truncate">
{selectedProject?.name ?? "Select"}
</span>
<ChevronDown className="size-3.5 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Projects</DropdownMenuLabel>
<DropdownMenuSeparator />
{projects.map((project) => (
<DropdownMenuItem
key={project.id}
onClick={() => onSelectProject(project.id)}
>
<FolderGit2 className="size-3.5" />
<span className="truncate">{project.name}</span>
{project.host ? (
<span className="ml-auto text-[10px] text-muted-foreground">
{project.host}
</span>
) : null}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<UserMenu />
</div>
</header>
);

View File

@@ -0,0 +1,401 @@
import { CircleAlert, GitFork, LoaderCircle, Plus } from "lucide-react";
import { useState } from "react";
import { ConversationPanel } from "@/components/work-os/conversation-panel";
import { EmptyState } from "@/components/work-os/empty-state";
import { SignalsPanel } from "@/components/work-os/signals-panel";
import { WorkOsComposer } from "@/components/work-os/work-os-composer";
import { WorkOsHeader } from "@/components/work-os/work-os-header";
import { WorkUnitCard } from "@/components/work-os/work-unit-card";
import { WorkUnitDetail } from "@/components/work-os/work-unit-detail";
import { useWorkOs } from "@/hooks/work-os/use-work-os";
import type { WorkOsState } from "@/hooks/work-os/use-work-os";
type CardData = WorkOsState["workUnitCards"][number];
type IssueId = WorkOsState["selectedIssueId"];
interface ProjectOption {
readonly host?: string;
readonly id: string;
readonly name: string;
}
const toProjectOption = (project: {
readonly id: string;
readonly name: string;
readonly sources: readonly { readonly host?: string }[];
}): ProjectOption => ({
host: project.sources[0]?.host,
id: project.id,
name: project.name,
});
const LoadingLine = ({ label }: { readonly label: string }) => (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<LoaderCircle className="size-3.5 animate-spin" />
{label}
</div>
);
const isAgentBusy = (status: string): boolean =>
status === "connecting" || status === "submitted" || status === "streaming";
const CONVERSATION_COPY = {
projectHint:
"Describe what you want Zopu to work on. Work Units created from the conversation will appear in the right panel.",
projectTitle: "Project conversation",
workUnitHint:
"Send a message to continue this Work Unit. The project manager agent will pick up the existing context.",
workUnitTitle: "Work Unit conversation",
} as const;
interface IssueComposerProps {
readonly body: string;
readonly busy: boolean;
readonly onBodyChange: (value: string) => void;
readonly onSubmit: () => void;
readonly onTitleChange: (value: string) => void;
readonly title: string;
}
const IssueComposer = ({
body,
busy,
onBodyChange,
onSubmit,
onTitleChange,
title,
}: IssueComposerProps) => (
<form
className="rounded-2xl border border-border/40 bg-card/40 p-4"
onSubmit={(event) => {
event.preventDefault();
onSubmit();
}}
>
<div className="mb-3 flex items-center gap-2">
<div className="grid size-7 place-items-center rounded-lg bg-foreground text-background">
<Plus className="size-3.5" />
</div>
<p className="text-sm font-semibold">Create a Work Unit</p>
</div>
<div className="space-y-2">
<input
aria-label="Work Unit title"
className="w-full rounded-xl border border-border/40 bg-background/40 px-3 py-2 text-sm outline-none placeholder:text-muted-foreground/60 focus-visible:border-foreground/20"
onChange={(event) => onTitleChange(event.target.value)}
placeholder="Outcome title"
required
value={title}
/>
<textarea
aria-label="Work Unit description"
className="min-h-20 w-full resize-none rounded-xl border border-border/40 bg-background/40 px-3 py-2 text-sm outline-none placeholder:text-muted-foreground/60 focus-visible:border-foreground/20"
onChange={(event) => onBodyChange(event.target.value)}
placeholder="Describe the desired result, constraints, or evidence."
required
rows={3}
value={body}
/>
</div>
<button
className="mt-3 inline-flex items-center gap-2 rounded-xl bg-foreground px-4 py-2 text-sm font-medium text-background transition-opacity hover:opacity-90 disabled:opacity-60"
disabled={busy}
type="submit"
>
{busy ? (
<LoaderCircle className="size-4 animate-spin" />
) : (
<Plus className="size-4" />
)}
{busy ? "Creating" : "Create Work Unit"}
</button>
</form>
);
const WorkUnitCards = ({
cards,
onSelect,
selectedId,
}: {
readonly cards: readonly CardData[];
readonly onSelect: (issueId: IssueId) => void;
readonly selectedId: IssueId;
}) => {
if (cards.length === 0) {
return (
<div className="rounded-2xl border border-dashed border-border/40 p-6 text-center">
<GitFork className="mx-auto size-4 text-muted-foreground" />
<p className="mt-2 text-sm font-medium">No Work Units yet</p>
<p className="mt-1 text-xs text-muted-foreground">
Create one above, or describe what you need in the conversation.
</p>
</div>
);
}
return (
<div className="space-y-2">
{cards.map((card) => (
<WorkUnitCard
card={card}
key={card.issueId}
onSelect={() => onSelect(card.issueId)}
selected={selectedId === card.issueId}
/>
))}
</div>
);
};
const MobileWorkUnitList = ({
cards,
onSelect,
selectedId,
}: {
readonly cards: readonly CardData[];
readonly onSelect: (issueId: IssueId) => void;
readonly selectedId: IssueId;
}) => {
if (cards.length === 0) {
return (
<div className="rounded-2xl border border-dashed border-border/40 p-4 text-center">
<p className="text-xs text-muted-foreground">
No Work Units yet. Create one or chat with Zopu.
</p>
</div>
);
}
return (
<div className="space-y-2">
{cards.map((card) => (
<WorkUnitCard
card={card}
key={card.issueId}
onSelect={() => onSelect(card.issueId)}
selected={selectedId === card.issueId}
/>
))}
</div>
);
};
interface NoProjectProps {
readonly busy: boolean;
readonly onConnectRepository: () => Promise<void>;
readonly onRepositoryChange: (value: string) => void;
readonly projects: WorkOsState["projects"];
readonly repository: string;
}
const NoProjectView = ({
busy,
onConnectRepository,
onRepositoryChange,
projects,
repository,
}: NoProjectProps) => {
if (projects === undefined) {
return (
<div className="grid h-full place-items-center">
<LoadingLine label="Loading projects" />
</div>
);
}
return (
<EmptyState
busy={busy}
onConnectRepository={onConnectRepository}
onRepositoryChange={onRepositoryChange}
repository={repository}
/>
);
};
export const WorkOsPage = () => {
const os = useWorkOs();
const [mobileDetailOpen, setMobileDetailOpen] = useState(false);
const [issueTitle, setIssueTitle] = useState("");
const [issueBody, setIssueBody] = useState("");
const handleSelectIssue = (issueId: IssueId) => {
os.selectIssue(issueId);
setMobileDetailOpen(issueId !== null);
};
const handleStart = () => {
if (!os.selectedIssue) {
return;
}
void os.startIssue(
os.selectedIssue._id,
os.selectedIssue.number,
os.selectedIssue.title
);
};
const handleRaiseIssue = () => {
void os.raiseIssue({ body: issueBody, title: issueTitle });
setIssueTitle("");
setIssueBody("");
};
const handleSend = (message: string): Promise<void> =>
os.composerMode === "work-unit" && os.selectedIssueId
? os.workUnitAgent.sendMessage(message)
: os.projectAgent.sendMessage(message);
const handleModeChange = (mode: "project" | "work-unit") =>
os.setComposerMode(mode);
const handleConnectRepository = os.connectRepository;
const handleRepositoryChange = os.setRepository;
const isWorkUnitMode =
os.composerMode === "work-unit" && !!os.selectedIssueId;
const activeAgent = isWorkUnitMode ? os.workUnitAgent : os.projectAgent;
const conversationTitle = isWorkUnitMode
? CONVERSATION_COPY.workUnitTitle
: CONVERSATION_COPY.projectTitle;
const conversationHint = isWorkUnitMode
? CONVERSATION_COPY.workUnitHint
: CONVERSATION_COPY.projectHint;
const selectedProjectOption = os.selectedProject
? toProjectOption(os.selectedProject)
: null;
const projectOptions = os.projects?.map(toProjectOption) ?? undefined;
const cards = os.workUnitCards ?? [];
const startPending = os.pendingAction === `issue:${os.selectedIssue?._id}`;
const showConversation = !(mobileDetailOpen && os.selectedDetail);
return (
<div className="flex h-svh flex-col bg-background text-foreground">
<WorkOsHeader
connected={!!os.selectedProject}
onBack={() => {
os.selectIssue(null);
setMobileDetailOpen(false);
}}
onSelectProject={() => os.selectIssue(null)}
projects={projectOptions}
selectedProject={selectedProjectOption}
showBack={mobileDetailOpen && !!os.selectedIssue}
/>
{os.error ? (
<div className="flex items-start gap-3 border-b border-destructive/30 bg-destructive/10 px-4 py-2 text-sm text-destructive">
<CircleAlert className="mt-0.5 size-4 shrink-0" />
<p>{os.error}</p>
</div>
) : null}
{os.selectedProject ? (
<main className="flex min-h-0 flex-1">
{/* Left column: conversation + composer */}
<div className="flex min-w-0 flex-1 flex-col">
<div
className={`min-h-0 flex-1 ${showConversation ? "block" : "hidden md:block"}`}
>
<ConversationPanel
agent={activeAgent}
emptyHint={conversationHint}
title={conversationTitle}
/>
</div>
{/* Mobile: detail overlay */}
{mobileDetailOpen && os.selectedDetail ? (
<div className="min-h-0 flex-1 overflow-y-auto md:hidden">
<WorkUnitDetail
detail={os.selectedDetail}
onOpenPullRequest={(url) =>
window.open(url, "_blank", "noopener,noreferrer")
}
onStart={handleStart}
startPending={startPending}
/>
</div>
) : null}
<WorkOsComposer
busy={isAgentBusy(activeAgent.status)}
error={
os.composerMode === "work-unit"
? os.workUnitAgent.error
: os.projectAgent.error
}
mode={os.composerMode}
onModeChange={handleModeChange}
onSend={handleSend}
workUnitTitle={os.selectedIssue?.title}
/>
</div>
{/* Right column: Work Units, signals, detail */}
<aside className="hidden w-[400px] shrink-0 flex-col overflow-y-auto border-l border-border/40 bg-background/50 p-4 md:flex lg:w-[440px]">
<IssueComposer
body={issueBody}
busy={os.pendingAction === "issue"}
onBodyChange={setIssueBody}
onSubmit={handleRaiseIssue}
onTitleChange={setIssueTitle}
title={issueTitle}
/>
<div className="mt-4">
<SignalsPanel
loading={os.signals === undefined}
signals={os.signals}
/>
</div>
{os.selectedDetail ? (
<div className="mt-4">
<WorkUnitDetail
detail={os.selectedDetail}
onOpenPullRequest={(url) =>
window.open(url, "_blank", "noopener,noreferrer")
}
onStart={handleStart}
startPending={startPending}
/>
</div>
) : null}
<div className="mt-4">
<p className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
Work Units
</p>
<WorkUnitCards
cards={cards}
onSelect={handleSelectIssue}
selectedId={os.selectedIssueId}
/>
</div>
</aside>
{/* Mobile: Work Unit cards list */}
<div className="border-t border-border/40 p-4 md:hidden">
<p className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
Work Units
</p>
<MobileWorkUnitList
cards={cards}
onSelect={handleSelectIssue}
selectedId={os.selectedIssueId}
/>
</div>
</main>
) : (
<main className="flex-1 overflow-y-auto">
<NoProjectView
busy={os.pendingAction === "connect"}
onConnectRepository={handleConnectRepository}
onRepositoryChange={handleRepositoryChange}
projects={os.projects}
repository={os.repository}
/>
</main>
)}
</div>
);
};

View File

@@ -0,0 +1,131 @@
import { Badge } from "@code/ui/components/badge";
import {
Activity,
ChevronRight,
FileText,
GitPullRequest,
Layers,
Radio,
TriangleAlert,
} from "lucide-react";
import { formatRelativeTime } from "@/lib/work-os/work-unit-projection";
import type { WorkUnitCard as WorkUnitCardData } from "@/lib/work-os/work-unit-projection";
const STATUS_LABEL: Record<WorkUnitCardData["status"], string> = {
completed: "Completed",
failed: "Failed",
"needs-input": "Needs input",
open: "Ready",
queued: "Queued",
working: "In progress",
};
const STATUS_TONE: Record<WorkUnitCardData["status"], string> = {
completed: "bg-emerald-500/15 text-emerald-400",
failed: "bg-destructive/15 text-destructive",
"needs-input": "bg-amber-500/15 text-amber-400",
open: "bg-muted text-muted-foreground",
queued: "bg-violet-500/15 text-violet-400",
working: "bg-blue-500/15 text-blue-400",
};
interface IndicatorProps {
readonly icon: React.ReactNode;
readonly label: string;
readonly value: number;
readonly tone?: string;
}
const Indicator = ({ icon, label, tone, value }: IndicatorProps) => (
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<span className={tone}>{icon}</span>
{value}
<span className="sr-only">{label}</span>
</span>
);
interface WorkUnitCardProps {
readonly card: WorkUnitCardData;
readonly selected: boolean;
readonly onSelect: () => void;
}
export const WorkUnitCard = ({
card,
onSelect,
selected,
}: WorkUnitCardProps) => (
<button
className={`w-full cursor-pointer rounded-2xl border p-4 text-left transition-all duration-200 ${
selected
? "border-foreground/20 bg-foreground/5"
: "border-border/40 bg-card/40 hover:border-border/70 hover:bg-card/60"
}`}
onClick={onSelect}
type="button"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="text-[11px] font-medium text-muted-foreground">
#{card.number}
</p>
<h3 className="mt-0.5 text-sm font-semibold leading-5 tracking-tight">
{card.title}
</h3>
</div>
<Badge
className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium ${STATUS_TONE[card.status]}`}
variant="secondary"
>
{STATUS_LABEL[card.status]}
</Badge>
</div>
<p className="mt-2 line-clamp-2 text-xs leading-5 text-muted-foreground">
{card.summary}
</p>
<div className="mt-3 flex flex-wrap items-center gap-3 border-t border-border/30 pt-3">
{card.currentActivity ? (
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<Activity className="size-3" />
{card.currentActivity}
</span>
) : null}
<Indicator
icon={<Radio className="size-3" />}
label="signals"
value={card.signalCount}
/>
<Indicator
icon={<Layers className="size-3" />}
label="steps"
value={card.stepCount}
/>
<Indicator
icon={<FileText className="size-3" />}
label="artifacts"
value={card.artifactCount}
/>
{card.hasPullRequest ? (
<span className="inline-flex items-center gap-1 text-[11px] text-emerald-400">
<GitPullRequest className="size-3" />
PR
</span>
) : null}
{card.needsInput ? (
<span className="inline-flex items-center gap-1 text-[11px] text-amber-400">
<TriangleAlert className="size-3" />
Input needed
</span>
) : null}
<span className="ml-auto inline-flex items-center gap-1">
<span className="text-[11px] text-muted-foreground/70">
{formatRelativeTime(card.updatedAt)}
</span>
<ChevronRight className="size-3.5 text-muted-foreground/50" />
</span>
</div>
</button>
);

View File

@@ -0,0 +1,237 @@
import { Button } from "@code/ui/components/button";
import {
ArrowUpRight,
Bot,
ExternalLink,
FileText,
GitPullRequest,
Layers,
Radio,
TriangleAlert,
} from "lucide-react";
import { formatRelativeTime } from "@/lib/work-os/work-unit-projection";
import type { WorkUnitDetail as WorkUnitDetailData } from "@/lib/work-os/work-unit-projection";
const formatStatus = (
status: WorkUnitDetailData["issue"]["status"]
): string => {
if (status === "needs-input") {
return "Needs input";
}
if (status === "working") {
return "In progress";
}
return status.charAt(0).toUpperCase() + status.slice(1);
};
interface WorkUnitDetailProps {
readonly detail: WorkUnitDetailData;
readonly onOpenPullRequest: (url: string) => void;
readonly onStart: () => void;
readonly startPending: boolean;
}
const Section = ({
children,
icon,
title,
}: {
readonly children: React.ReactNode;
readonly icon: React.ReactNode;
readonly title: string;
}) => (
<section className="rounded-2xl border border-border/30 bg-card/30 p-4">
<div className="mb-3 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{icon}
{title}
</div>
{children}
</section>
);
export const WorkUnitDetail = ({
detail,
onOpenPullRequest,
onStart,
startPending,
}: WorkUnitDetailProps) => {
const { issue } = detail;
const canStart = issue.status === "open" || issue.status === "failed";
return (
<div className="space-y-4">
{/* Objective */}
<section className="rounded-2xl border border-border/40 bg-card/40 p-5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-[11px] font-medium text-muted-foreground">
Work Unit #{issue.number}
</p>
<h2 className="mt-1 text-lg font-semibold leading-7 tracking-tight">
{issue.title}
</h2>
</div>
<span className="shrink-0 rounded-full bg-muted px-2.5 py-1 text-[11px] font-medium">
{formatStatus(issue.status)}
</span>
</div>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
{issue.body}
</p>
{canStart ? (
<Button
className="mt-4 w-full"
disabled={startPending}
onClick={onStart}
type="button"
>
<Bot className="size-4" />
{startPending ? "Starting" : "Start work"}
</Button>
) : null}
</section>
{/* Needs input alert */}
{detail.needsInput ? (
<div className="flex items-start gap-3 rounded-2xl border border-amber-500/30 bg-amber-500/10 p-4">
<TriangleAlert className="mt-0.5 size-4 shrink-0 text-amber-400" />
<div>
<p className="text-sm font-medium text-amber-300">
This work unit needs your input
</p>
<p className="mt-1 text-xs leading-5 text-amber-400/70">
Resolve the open question or decision to unblock the agent.
</p>
</div>
</div>
) : null}
{/* Latest summary */}
{detail.latestSummary ? (
<Section icon={<ArrowUpRight className="size-3" />} title="Latest">
<p className="text-sm leading-6">{detail.latestSummary}</p>
</Section>
) : null}
{/* Linked Signals — authenticated via signalIssueAttachments */}
<Section
icon={<Radio className="size-3" />}
title={`Signals (${detail.signalCount})`}
>
{detail.linkedSignals.length > 0 ? (
<div className="space-y-3">
{detail.linkedSignals.map((signal) => (
<div key={String(signal.signalId)}>
<p className="text-sm font-medium">{signal.title}</p>
<p className="mt-0.5 line-clamp-2 text-xs leading-5 text-muted-foreground">
{signal.summary}
</p>
<p className="mt-1 text-[10px] text-muted-foreground/60">
{signal.sourceCount} source
{signal.sourceCount === 1 ? "" : "s"} ·{" "}
{formatRelativeTime(signal.createdAt)}
</p>
</div>
))}
</div>
) : (
<p className="text-xs leading-5 text-muted-foreground">
No Signals linked to this Work Unit.
</p>
)}
</Section>
{/* Activity timeline */}
<Section
icon={<Layers className="size-3" />}
title={`Timeline (${detail.stepCount} steps)`}
>
{detail.activity.length > 0 ? (
<div className="space-y-4">
{detail.activity.map((item) => (
<div
className="flex gap-3"
key={`${item.kind}-${item.createdAt}`}
>
<div className="mt-1.5 size-1.5 shrink-0 rounded-full bg-foreground/40" />
<div className="min-w-0">
<p className="text-xs font-medium">{item.label}</p>
<p className="mt-0.5 text-xs leading-5 text-muted-foreground">
{item.detail}
</p>
<p className="mt-0.5 text-[10px] text-muted-foreground/60">
{item.time}
</p>
</div>
</div>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">
No activity recorded yet.
</p>
)}
</Section>
{/* Artifacts */}
<Section
icon={<FileText className="size-3" />}
title={`Artifacts (${detail.artifactCount})`}
>
{detail.artifactPaths.length > 0 ? (
<div className="flex flex-wrap gap-2">
{detail.artifactPaths.map((path) => (
<span
className="inline-flex items-center gap-1.5 rounded-lg bg-muted/60 px-2.5 py-1 text-[11px]"
key={path}
>
<FileText className="size-3 text-muted-foreground" />
{path}
</span>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">
No artifacts produced yet.
</p>
)}
</Section>
{/* Pull request */}
{detail.hasPullRequest ? (
<Section
icon={<GitPullRequest className="size-3" />}
title="Pull request"
>
{detail.pullRequestUrl ? (
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/5 p-3">
<p className="text-sm font-medium text-emerald-300">
{detail.pullRequestNumber
? `PR #${detail.pullRequestNumber} ready for review`
: "Pull request ready"}
</p>
<Button
className="mt-3"
onClick={() =>
detail.pullRequestUrl &&
onOpenPullRequest(detail.pullRequestUrl)
}
size="sm"
type="button"
variant="outline"
>
<ExternalLink className="size-3.5" />
Review changes
</Button>
</div>
) : (
<p className="text-xs text-muted-foreground">
A pull request was opened but the URL is unavailable.
</p>
)}
</Section>
) : null}
</div>
);
};

View File

@@ -1,13 +1,13 @@
import { useFlueAgent } from "@flue/react";
import { usePersonalOrganization } from "@/hooks/use-personal-organization";
import type { PersonalOrganizationState } from "@/hooks/use-personal-organization";
import { CHAT_AGENT } from "@/lib/chat/constants";
import type { ChatAgentState } from "@/lib/chat/types";
export const useChatAgent = (): ChatAgentState => {
// The agent session is not created until the authenticated user's personal
// organization has been ensured and its id is available.
const organization = usePersonalOrganization();
export const useOrganizationChatAgent = (
organization: PersonalOrganizationState
): ChatAgentState => {
const agent = useFlueAgent({
...CHAT_AGENT,
id: organization.organizationId,
@@ -36,3 +36,6 @@ export const useChatAgent = (): ChatAgentState => {
status,
};
};
export const useChatAgent = (): ChatAgentState =>
useOrganizationChatAgent(usePersonalOrganization());

View File

@@ -0,0 +1,91 @@
import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel";
import type {
MobileAssistantMessageView,
MobileAssistantView,
} from "@code/ui/components/mobile-product";
import { useQuery } from "convex/react";
import { useOrganizationChatAgent } from "@/hooks/chat/use-chat-agent";
import { usePersonalOrganization } from "@/hooks/use-personal-organization";
import { useProjectWorkspace } from "@/hooks/use-project-workspace";
import { STATUS_COPY } from "@/lib/chat/constants";
import { getMessageText } from "@/lib/chat/transforms";
import { buildMobileWorkspaceView } from "@/lib/mobile-workspace/build-mobile-workspace-view";
const toAssistantMessages = (
messages: ReturnType<typeof useOrganizationChatAgent>["messages"]
): MobileAssistantMessageView[] =>
messages.flatMap((message) => {
if (message.role !== "assistant" && message.role !== "user") {
return [];
}
const text = getMessageText(message);
return text
? [
{
id: message.id,
role: message.role,
text,
},
]
: [];
});
const getStatusTone = (
status: ReturnType<typeof useOrganizationChatAgent>["status"]
): MobileAssistantView["statusTone"] => {
if (status === "error") {
return "red";
}
return status === "connecting" ? "amber" : "green";
};
interface UseMobileProjectWorkspaceOptions {
readonly selectedWorkUnitId?: string;
}
export const useMobileProjectWorkspace = ({
selectedWorkUnitId,
}: UseMobileProjectWorkspaceOptions) => {
const organization = usePersonalOrganization();
const projectWorkspace = useProjectWorkspace();
const chatAgent = useOrganizationChatAgent(organization);
const signals = useQuery(
api.signals.list,
organization.organizationId
? { organizationId: organization.organizationId }
: "skip"
);
const assistant: MobileAssistantView = {
isBusy:
chatAgent.status === "connecting" ||
chatAgent.status === "streaming" ||
chatAgent.status === "submitted",
messages: toAssistantMessages(chatAgent.messages),
statusLabel: STATUS_COPY[chatAgent.status],
statusTone: getStatusTone(chatAgent.status),
};
return {
data: buildMobileWorkspaceView({
artifacts: projectWorkspace.artifacts,
assistant,
events: projectWorkspace.events,
issues: projectWorkspace.issues,
organizationLabel: organization.organizationName,
projects: projectWorkspace.projects,
selectedProject: projectWorkspace.selectedProject,
selectedWorkUnitId,
signals,
}),
error: organization.error ?? chatAgent.error,
organization,
projectWorkspace,
raiseIssue: projectWorkspace.raiseIssue,
raiseIssueFromSignal: (signalId: string) =>
projectWorkspace.raiseIssueFromSignal(signalId as Id<"signals">),
sendMessage: chatAgent.sendMessage,
startWorkUnit: projectWorkspace.startWorkUnit,
} as const;
};

View File

@@ -0,0 +1,82 @@
import type { FormEvent } from "react";
import { useState } from "react";
interface UseMobileWorkspaceOptions {
readonly initialExpanded?: boolean;
readonly onCreateIssue: (title: string, body: string) => Promise<void>;
readonly onSend: (message: string) => Promise<void>;
}
const errorMessage = (error: unknown) =>
error instanceof Error ? error.message : "Message could not be sent";
export const useMobileWorkspace = ({
initialExpanded = false,
onCreateIssue,
onSend,
}: UseMobileWorkspaceOptions) => {
const [composerValue, setComposerValue] = useState("");
const [issueBody, setIssueBody] = useState("");
const [issueTitle, setIssueTitle] = useState("");
const [expanded, setExpanded] = useState(initialExpanded);
const [statusMessage, setStatusMessage] = useState<string>();
const handleComposerChange = (value: string) => {
setComposerValue(value);
setStatusMessage(undefined);
};
const handleComposerSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const message = composerValue.trim();
if (!message) {
return;
}
setStatusMessage("Sending to Zopu…");
const sendMessage = async () => {
try {
await onSend(message);
setComposerValue("");
setStatusMessage("Sent to Zopu");
} catch (error) {
setStatusMessage(errorMessage(error));
}
};
void sendMessage();
};
const handleCreateIssueSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const title = issueTitle.trim();
const body = issueBody.trim();
if (!title || !body) {
return;
}
setStatusMessage("Creating project work…");
const createIssue = async () => {
try {
await onCreateIssue(title, body);
setIssueTitle("");
setIssueBody("");
setStatusMessage("Project work created");
} catch (error) {
setStatusMessage(errorMessage(error));
}
};
void createIssue();
};
return {
composerValue,
expanded,
handleComposerChange,
handleComposerSubmit,
handleCreateIssueSubmit,
issueBody,
issueTitle,
setExpanded,
setIssueBody,
setIssueTitle,
statusMessage,
};
};

View File

@@ -7,6 +7,7 @@ import { useEffect, useState } from "react";
export interface PersonalOrganizationState {
readonly error?: Error;
readonly organizationId?: Id<"organizations">;
readonly organizationName?: string;
}
/** Ensure the authenticated user has its personal tenancy boundary. */
@@ -16,10 +17,12 @@ export const usePersonalOrganization = (): PersonalOrganizationState => {
const ensurePersonalOrganization = useMutation(
api.organizations.ensurePersonalOrganization
);
const [organizationId, setOrganizationId] = useState<
Id<"organizations"> | undefined
>();
const [error, setError] = useState<Error | undefined>();
const [state, setState] = useState<{
readonly error?: Error;
readonly organizationId?: Id<"organizations">;
readonly organizationName?: string;
readonly userId: string;
}>();
useEffect(() => {
if (!userId) {
@@ -31,16 +34,21 @@ export const usePersonalOrganization = (): PersonalOrganizationState => {
try {
const organization = await ensurePersonalOrganization();
if (active) {
setOrganizationId(organization._id);
setError(undefined);
setState({
organizationId: organization._id,
organizationName: organization.name,
userId,
});
}
} catch (caughtError: unknown) {
if (active) {
setError(
caughtError instanceof Error
? caughtError
: new Error(String(caughtError))
);
setState({
error:
caughtError instanceof Error
? caughtError
: new Error(String(caughtError)),
userId,
});
}
}
};
@@ -51,9 +59,13 @@ export const usePersonalOrganization = (): PersonalOrganizationState => {
};
}, [ensurePersonalOrganization, userId]);
if (!userId) {
if (!userId || state?.userId !== userId) {
return {};
}
return { error, organizationId };
return {
error: state.error,
organizationId: state.organizationId,
organizationName: state.organizationName,
};
};

View File

@@ -4,6 +4,11 @@ import { useFlueClient } from "@flue/react";
import { useAction, useMutation, useQuery } from "convex/react";
import { useState } from "react";
import {
buildProjectLoopView,
summarizeProjectIssues,
} from "@/lib/projects/project-evidence";
const errorMessage = (error: unknown) =>
error instanceof Error ? error.message : String(error);
@@ -15,10 +20,13 @@ export const useProjectWorkspace = () => {
const [repository, setRepository] = useState("");
const [issueTitle, setIssueTitle] = useState("");
const [issueBody, setIssueBody] = useState("");
const [selectedIssueId, setSelectedIssueId] =
useState<Id<"projectIssues"> | null>(null);
const [pendingAction, setPendingAction] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const importPublicGit = useAction(api.projects.importPublicGit);
const createIssue = useMutation(api.projectIssues.create);
const createIssueFromSignal = useMutation(api.projectIssues.createFromSignal);
const beginIssue = useMutation(api.projectIssues.begin);
const markDispatchFailed = useMutation(api.projectIssues.markDispatchFailed);
const activeProjectId =
@@ -33,6 +41,30 @@ export const useProjectWorkspace = () => {
api.projectIssues.list,
activeProjectId ? { projectId: activeProjectId } : "skip"
);
const events = useQuery(
api.projectIssues.events,
activeProjectId ? { projectId: activeProjectId } : "skip"
);
const pullRequest = events
?.map((event) => event.data)
.find(
(
data
): data is {
pullRequest: {
baseBranch: string;
branch: string;
number: number;
status: "open" | "closed" | "merged";
url: string;
};
} =>
typeof data === "object" &&
data !== null &&
"pullRequest" in data &&
typeof data.pullRequest === "object" &&
data.pullRequest !== null
)?.pullRequest;
const connectRepository = async () => {
setPendingAction("connect");
@@ -48,18 +80,24 @@ export const useProjectWorkspace = () => {
}
};
const raiseIssue = async () => {
const raiseIssue = async (input?: {
readonly body?: string;
readonly title?: string;
}) => {
if (!activeProjectId) {
return;
}
const nextTitle = input?.title ?? issueTitle;
const nextBody = input?.body ?? issueBody;
setPendingAction("issue");
setError(null);
try {
await createIssue({
body: issueBody,
const issueId = await createIssue({
body: nextBody,
projectId: activeProjectId,
title: issueTitle,
title: nextTitle,
});
setSelectedIssueId(issueId);
setIssueTitle("");
setIssueBody("");
} catch (caughtError) {
@@ -69,6 +107,19 @@ export const useProjectWorkspace = () => {
}
};
const raiseIssueFromSignal = async (signalId: Id<"signals">) => {
setPendingAction(`signal:${signalId}`);
setError(null);
try {
const outcome = await createIssueFromSignal({ signalId });
setSelectedIssueId(outcome.issueId);
} catch (caughtError) {
setError(errorMessage(caughtError));
} finally {
setPendingAction(null);
}
};
const startIssue = async (
issueId: Id<"projectIssues">,
issueNumber: number,
@@ -90,28 +141,54 @@ export const useProjectWorkspace = () => {
setPendingAction(null);
}
};
const startWorkUnit = async (issueId: Id<"projectIssues">) => {
const issue = issues?.find((candidate) => candidate._id === issueId);
if (!issue) {
setError("That work unit is no longer available");
return;
}
await startIssue(issue._id, issue.number, issue.title);
};
const selectedProject =
projects?.find(
(project) => project.id === (activeProjectId as unknown as string)
) ?? null;
const selectedIssue =
issues?.find((issue) => issue._id === selectedIssueId) ?? issues?.[0];
const projectLoop = buildProjectLoopView({
artifacts,
issue: selectedIssue,
source: selectedProject?.sources[0],
});
const issueSummary = summarizeProjectIssues(issues);
return {
artifacts,
connectRepository,
error,
events,
issueBody,
issueSummary,
issueTitle,
issues,
pendingAction,
projectLoop,
projects,
pullRequest,
raiseIssue,
raiseIssueFromSignal,
repository,
selectedIssue,
selectedIssueId,
selectedProject,
selectedProjectId: activeProjectId,
setIssueBody,
setIssueTitle,
setRepository,
setSelectedIssueId,
setSelectedProjectId,
startIssue,
startWorkUnit,
} as const;
};

View File

@@ -0,0 +1,203 @@
import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { useFlueAgent } from "@flue/react";
import { useQuery } from "convex/react";
import { useMemo, useState } from "react";
import { useChatAgent } from "@/hooks/chat/use-chat-agent";
import { usePersonalOrganization } from "@/hooks/use-personal-organization";
import { useProjectWorkspace } from "@/hooks/use-project-workspace";
import type { ChatAgentState } from "@/lib/chat/types";
import {
buildWorkUnitCard,
buildWorkUnitDetail,
eventsForIssue,
} from "@/lib/work-os/work-unit-projection";
import type {
LinkedSignal,
WorkUnitCard,
WorkUnitDetail,
} from "@/lib/work-os/work-unit-projection";
export interface SignalItem {
readonly title: string;
readonly summary: string;
readonly sourceCount: number;
readonly createdAt: number;
}
export type ComposerMode = "project" | "work-unit";
export interface WorkOsState {
readonly projects: ReturnType<typeof useProjectWorkspace>["projects"];
readonly selectedProject: ReturnType<
typeof useProjectWorkspace
>["selectedProject"];
readonly selectedProjectId: ReturnType<
typeof useProjectWorkspace
>["selectedProjectId"];
readonly repository: string;
readonly setRepository: (value: string) => void;
readonly connectRepository: () => Promise<void>;
readonly workUnitCards: readonly WorkUnitCard[];
readonly selectedIssue: ReturnType<
typeof useProjectWorkspace
>["selectedIssue"];
readonly selectedIssueId: Id<"projectIssues"> | null;
readonly selectedDetail: WorkUnitDetail | null;
readonly selectIssue: (issueId: Id<"projectIssues"> | null) => void;
readonly startIssue: ReturnType<typeof useProjectWorkspace>["startIssue"];
readonly raiseIssue: ReturnType<typeof useProjectWorkspace>["raiseIssue"];
readonly issueTitle: string;
readonly setIssueTitle: (value: string) => void;
readonly issueBody: string;
readonly setIssueBody: (value: string) => void;
readonly signals: readonly SignalItem[];
readonly composerMode: ComposerMode;
readonly setComposerMode: (mode: ComposerMode) => void;
readonly projectAgent: ChatAgentState;
readonly workUnitAgent: ChatAgentState;
readonly pendingAction: string | null;
readonly error: string | null;
}
const toLinkedSignals = (
entries:
| readonly {
readonly signalId: Id<"signals">;
readonly title: string;
readonly summary: string;
readonly sourceCount: number;
readonly createdAt: number;
}[]
| undefined
): readonly LinkedSignal[] =>
(entries ?? []).map((entry) => ({
createdAt: entry.createdAt,
signalId: entry.signalId,
sourceCount: entry.sourceCount,
summary: entry.summary,
title: entry.title,
}));
export const useWorkOs = (): WorkOsState => {
const workspace = useProjectWorkspace();
const orgState = usePersonalOrganization();
const projectAgent = useChatAgent();
const activeProjectId = workspace.selectedProjectId;
const workUnitFlue = useFlueAgent({
id: workspace.selectedIssueId
? String(workspace.selectedIssueId)
: undefined,
live: "sse",
name: "project-manager",
});
const workUnitAgent: ChatAgentState = {
error: workUnitFlue.error,
historyReady: workUnitFlue.historyReady,
messages: workUnitFlue.messages,
sendMessage: workUnitFlue.sendMessage,
status: workspace.selectedIssueId ? workUnitFlue.status : "idle",
};
// Project-level signals for the global sidebar panel.
const allSignals = useQuery(
api.signals.list,
orgState.organizationId
? { organizationId: orgState.organizationId }
: "skip"
);
const signals = useMemo<readonly SignalItem[]>(() => {
if (!allSignals || !activeProjectId) {
return [];
}
return allSignals
.filter((entry) => entry.signal.projectId === activeProjectId)
.map((entry) => ({
createdAt: entry.signal.createdAt,
sourceCount: entry.sources.length,
summary: entry.signal.problemStatement.summary,
title: entry.signal.problemStatement.title,
}));
}, [allSignals, activeProjectId]);
// All linked signals for the active project, grouped by issue id.
// Used for card counts and detail display.
const linkedByIssue = useQuery(
api.signals.listLinkedSignalsForProject,
activeProjectId ? { projectId: activeProjectId } : "skip"
);
const [composerMode, setComposerMode] = useState<ComposerMode>("project");
const selectIssue = (issueId: Id<"projectIssues"> | null) => {
workspace.setSelectedIssueId(issueId);
setComposerMode(issueId ? "work-unit" : "project");
};
const workUnitCards = useMemo<readonly WorkUnitCard[]>(() => {
if (!workspace.issues) {
return [];
}
return workspace.issues.map((issue) => {
const issueEvents = eventsForIssue(workspace.events, issue._id);
const linked = toLinkedSignals(linkedByIssue?.[String(issue._id)]);
return buildWorkUnitCard({
issue,
issueEvents,
linkedSignals: linked,
});
});
}, [workspace.issues, workspace.events, linkedByIssue]);
const selectedDetail = useMemo<WorkUnitDetail | null>(() => {
if (!workspace.selectedIssue) {
return null;
}
const issueEvents = eventsForIssue(
workspace.events,
workspace.selectedIssue._id
);
const linked = toLinkedSignals(
linkedByIssue?.[String(workspace.selectedIssue._id)]
);
return buildWorkUnitDetail({
issue: workspace.selectedIssue,
issueEvents,
linkedSignals: linked,
});
}, [workspace.selectedIssue, workspace.events, linkedByIssue]);
return {
composerMode,
connectRepository: workspace.connectRepository,
error: workspace.error,
issueBody: workspace.issueBody,
issueTitle: workspace.issueTitle,
pendingAction: workspace.pendingAction,
projectAgent,
projects: workspace.projects,
raiseIssue: workspace.raiseIssue,
repository: workspace.repository,
selectIssue,
selectedDetail,
selectedIssue: workspace.selectedIssue,
selectedIssueId: workspace.selectedIssueId,
selectedProject: workspace.selectedProject,
selectedProjectId: activeProjectId,
setComposerMode,
setIssueBody: workspace.setIssueBody,
setIssueTitle: workspace.setIssueTitle,
setRepository: workspace.setRepository,
signals,
startIssue: workspace.startIssue,
workUnitAgent,
workUnitCards,
};
};

View File

@@ -4,49 +4,10 @@
html,
body {
min-height: 100%;
overflow: hidden;
}
.chat-surface {
position: relative;
isolation: isolate;
background:
radial-gradient(
circle at 50% -12%,
oklch(0.72 0.045 255 / 9%),
transparent 32rem
),
var(--background);
}
.chat-surface::before {
position: absolute;
z-index: -1;
inset: 0;
background-image: radial-gradient(
oklch(0.5 0 0 / 9%) 0.55px,
transparent 0.55px
);
background-size: 18px 18px;
mask-image: linear-gradient(to bottom, black, transparent 42%);
content: "";
pointer-events: none;
}
.chat-header {
background: color-mix(in oklch, var(--background) 88%, transparent);
backdrop-filter: blur(18px) saturate(1.25);
}
.zopu-mark {
border: 1px solid color-mix(in oklch, var(--foreground) 10%, transparent);
background:
linear-gradient(145deg, oklch(1 0 0 / 65%), transparent 55%),
color-mix(in oklch, var(--muted) 88%, var(--background));
color: var(--foreground);
box-shadow:
inset 0 1px 0 oklch(1 0 0 / 48%),
0 6px 18px -12px oklch(0 0 0 / 55%);
.ai-conversation-mobile > div {
scrollbar-gutter: auto !important;
}
.chat-message {
@@ -120,15 +81,6 @@ body {
}
}
.composer-dock {
position: relative;
background: linear-gradient(to top, var(--background) 68%, transparent);
}
.composer-shell {
transform: translateZ(0);
}
@keyframes chat-message-enter {
from {
opacity: 0;

View File

@@ -0,0 +1,186 @@
import type { api } from "@code/backend/convex/_generated/api";
import type { Doc } from "@code/backend/convex/_generated/dataModel";
import {
getProjectWorkNextAction,
getProjectWorkProgress,
getProjectWorkStatus,
} from "@code/primitives/project-work";
import type {
MobileAssistantView,
MobileProjectView,
MobileSignalView,
MobileWorkspaceView,
MobileWorkUnitTone,
MobileWorkUnitView,
} from "@code/ui/components/mobile-product";
import type { FunctionReturnType } from "convex/server";
type SignalList = FunctionReturnType<typeof api.signals.list>;
type ProjectEventList = FunctionReturnType<typeof api.projectIssues.events>;
type ProjectView = FunctionReturnType<typeof api.projects.list>[number];
interface BuildMobileWorkspaceViewInput {
readonly artifacts: readonly Doc<"projectArtifacts">[] | undefined;
readonly assistant: MobileAssistantView;
readonly events: ProjectEventList | undefined;
readonly issues: readonly Doc<"projectIssues">[] | undefined;
readonly organizationLabel?: string;
readonly projects: readonly ProjectView[] | undefined;
readonly selectedProject: ProjectView | null;
readonly selectedWorkUnitId?: string;
readonly signals: SignalList | undefined;
}
const updatedDateFormatter = new Intl.DateTimeFormat("en", {
day: "numeric",
month: "short",
});
const statusTone = (
status: ReturnType<typeof getProjectWorkStatus>
): MobileWorkUnitTone => {
if (status.phase === "completed") {
return "green";
}
if (status.phase === "failed" || status.phase === "waiting") {
return "orange";
}
if (status.phase === "captured") {
return "purple";
}
return "blue";
};
const isPullRequestData = (
value: unknown
): value is {
pullRequest: {
number: number;
status: "open" | "closed" | "merged";
url: string;
};
} =>
typeof value === "object" &&
value !== null &&
"pullRequest" in value &&
typeof value.pullRequest === "object" &&
value.pullRequest !== null &&
"number" in value.pullRequest &&
typeof value.pullRequest.number === "number" &&
"url" in value.pullRequest &&
typeof value.pullRequest.url === "string";
const toWorkUnit = (
issue: Doc<"projectIssues">,
artifactCount: number,
events: ProjectEventList | undefined
): MobileWorkUnitView => {
const status = getProjectWorkStatus(issue.status);
const pullRequest = events
?.filter((event) => event.issueId === issue._id)
.map((event) => event.data)
.find(isPullRequestData)?.pullRequest;
return {
artifactCount,
canRetry: status.phase === "failed" || status.phase === "waiting",
canStart: status.phase === "captured",
code: `#${issue.number}`,
id: issue._id,
nextAction: getProjectWorkNextAction(issue.status),
progress: getProjectWorkProgress(issue.status),
pullRequestNumber: pullRequest?.number,
reviewUrl: pullRequest?.url,
statusLabel: status.label,
summary: issue.body,
title: issue.title,
tone: statusTone(status),
updatedLabel: updatedDateFormatter.format(issue.updatedAt),
};
};
const toLatestSignalView = (
signal: SignalList[number]["signal"] | undefined
): MobileSignalView | undefined => {
if (!signal) {
return;
}
return {
desiredOutcome: signal.problemStatement.desiredOutcome,
id: signal._id,
projectId: signal.projectId ?? undefined,
summary: signal.problemStatement.summary,
title: signal.problemStatement.title,
};
};
const relevantSignalsForProject = (
signals: SignalList | undefined,
projectId: ProjectView["id"] | undefined
) =>
signals?.filter(
({ signal }) =>
!signal.projectId || String(signal.projectId) === String(projectId)
) ?? [];
const makeProjectViews = (
projects: readonly ProjectView[] | undefined
): readonly MobileProjectView[] =>
projects?.map((project) => ({
connected: project.sources.length > 0,
id: project.id,
name: project.name,
})) ?? [];
export const buildMobileWorkspaceView = ({
artifacts,
assistant,
events,
issues,
organizationLabel = "Personal workspace",
projects,
selectedProject,
selectedWorkUnitId,
signals,
}: BuildMobileWorkspaceViewInput): MobileWorkspaceView => {
const artifactCount = artifacts?.length ?? 0;
const workUnits =
issues?.map((issue) => toWorkUnit(issue, artifactCount, events)) ?? [];
const selectedWorkUnit =
workUnits.find((workUnit) => workUnit.id === selectedWorkUnitId) ??
workUnits.find((workUnit) => workUnit.tone === "blue") ??
workUnits[0];
const selectedProjectId = selectedProject?.id;
const relevantSignals = relevantSignalsForProject(signals, selectedProjectId);
const latestSignal = relevantSignals[0]?.signal;
const activeCount = workUnits.filter(
(workUnit) => workUnit.progress < 100
).length;
const needsAttentionCount = issues?.filter(
(issue) => issue.status === "failed" || issue.status === "needs-input"
).length;
const shippedCount = issues?.filter(
(issue) => issue.status === "completed"
).length;
const hasSelectedProject = Boolean(selectedProject);
const projectViews = makeProjectViews(projects);
return {
activeCount,
activityCount: workUnits.length + relevantSignals.length,
artifactCount,
assistant,
isLoading:
projects === undefined ||
(hasSelectedProject && (artifacts === undefined || issues === undefined)),
latestSignal: toLatestSignalView(latestSignal),
needsAttentionCount: needsAttentionCount ?? 0,
organizationLabel,
projectName: selectedProject?.name,
projects: projectViews,
selectedProjectId: selectedProject?.id,
selectedWorkUnit,
shippedCount: shippedCount ?? 0,
totalCount: workUnits.length,
workUnits,
};
};

View File

@@ -0,0 +1,91 @@
import type { Doc, Id } from "@code/backend/convex/_generated/dataModel";
import { describe, expect, test } from "vitest";
import {
buildProjectLoopView,
summarizeProjectIssues,
} from "./project-evidence";
const makeIssue = (
status: Doc<"projectIssues">["status"],
issueId: Id<"projectIssues"> = "issue-1" as Id<"projectIssues">
): Doc<"projectIssues"> =>
({
_creationTime: 1,
_id: issueId,
body: "Make the project loop visible in the web app.",
createdAt: 1000,
number: 8,
projectId: "project-1",
status,
title: "Build project loop UI",
updatedAt: 2000,
}) as Doc<"projectIssues">;
const makeArtifact = (content: string): Doc<"projectArtifacts"> =>
({
_creationTime: 1,
_id: "artifact-1",
content,
createdAt: 1000,
path: "artifacts.md",
projectId: "project-1",
revision: 2,
updatedAt: 2000,
}) as Doc<"projectArtifacts">;
describe("project evidence", () => {
test("turns published PR evidence into a review action", () => {
const view = buildProjectLoopView({
artifacts: [
makeArtifact(
"Verification: 14 unit tests passed.\nPull request: PR #42"
),
],
issue: makeIssue("completed"),
source: {
host: "git.example.com",
repositoryPath: "puter/zopu",
url: "https://git.example.com/puter/zopu",
},
});
expect(view?.progress).toBe(100);
expect(view?.verification).toBe("passed");
expect(view?.pullRequest.reviewUrl).toBe(
"https://git.example.com/puter/zopu/pulls/42"
);
expect(view?.verificationNote).toContain(
"Verification: 14 unit tests passed."
);
});
test("keeps the review slot pending when no PR is published", () => {
const view = buildProjectLoopView({
artifacts: [
makeArtifact("The run is recording implementation evidence."),
],
issue: makeIssue("working"),
source: undefined,
});
expect(view?.verification).toBe("running");
expect(view?.pullRequest.state).toBe("pending");
expect(view?.pullRequest.reviewUrl).toBeUndefined();
});
test("summarizes the project queue without inventing work", () => {
const summary = summarizeProjectIssues([
makeIssue("working"),
makeIssue("needs-input", "issue-2" as Id<"projectIssues">),
makeIssue("completed", "issue-3" as Id<"projectIssues">),
]);
expect(summary).toEqual({
active: 2,
completed: 1,
needsInput: 1,
total: 3,
});
});
});

View File

@@ -0,0 +1,315 @@
import type { Doc } from "@code/backend/convex/_generated/dataModel";
import type { ProjectIssueStatus } from "@code/primitives/project-issue";
import {
getProjectWorkNextAction,
getProjectWorkProgress,
getProjectWorkStatus,
} from "@code/primitives/project-work";
import type { ProjectVerificationStatus } from "@code/primitives/project-work";
type ProjectIssue = Doc<"projectIssues">;
type ProjectArtifact = Doc<"projectArtifacts">;
export interface ProjectSourceSummary {
readonly host: string;
readonly repositoryPath: string;
readonly url: string;
}
export interface ProjectIssueSummary {
readonly active: number;
readonly completed: number;
readonly needsInput: number;
readonly total: number;
}
export interface ProjectVerificationCheck {
readonly detail: string;
readonly label: string;
readonly state: "attention" | "complete" | "current" | "upcoming";
}
export interface ProjectActivityItem {
readonly detail: string;
readonly label: string;
readonly time: string;
}
export interface ProjectPullRequest {
readonly number: number | undefined;
readonly reviewUrl: string | undefined;
readonly state: "available" | "pending";
}
export interface ProjectLoopView {
readonly activity: readonly ProjectActivityItem[];
readonly currentState: string;
readonly nextAction: string;
readonly progress: number;
readonly pullRequest: ProjectPullRequest;
readonly status: ReturnType<typeof getProjectWorkStatus>;
readonly verification: ProjectVerificationStatus;
readonly verificationChecks: readonly ProjectVerificationCheck[];
readonly verificationNote: string;
}
const STATUS_TEXT: Readonly<Record<ProjectIssueStatus, string>> = {
completed:
"Implementation and verification are complete. Review the resulting change.",
failed:
"The run stopped with its workspace preserved. Review the failure and retry when ready.",
"needs-input":
"The run is paused until a project decision or missing piece is resolved.",
open: "The issue is ready to start. Zopu will keep the project context attached to the run.",
queued: "The issue is queued for the project manager to pick up.",
working:
"The project manager is working through the issue and recording durable evidence.",
};
const formatTime = (timestamp: number): string =>
new Intl.DateTimeFormat(undefined, {
hour: "numeric",
minute: "2-digit",
}).format(timestamp);
const relativeTime = (timestamp: number): string => {
const minutes = Math.max(0, Math.round((Date.now() - timestamp) / 60_000));
if (minutes < 1) {
return "just now";
}
if (minutes < 60) {
return `${minutes}m ago`;
}
const hours = Math.round(minutes / 60);
if (hours < 24) {
return `${hours}h ago`;
}
return `${Math.round(hours / 24)}d ago`;
};
const allArtifactText = (artifacts: readonly ProjectArtifact[]): string =>
artifacts.map((artifact) => artifact.content).join("\n");
const extractPullRequest = (
artifacts: readonly ProjectArtifact[],
source: ProjectSourceSummary | undefined
): ProjectPullRequest => {
const text = allArtifactText(artifacts);
const urlMatch = text.match(/https?:\/\/[^\s)]+\/pulls\/\d+/iu)?.[0];
const numberMatch = text.match(/\bPR\s*#(?<number>\d+)\b/iu);
const numberValue = numberMatch?.groups?.number;
const number = numberValue ? Number(numberValue) : undefined;
let reviewUrl = urlMatch;
if (!reviewUrl && number && source) {
reviewUrl = `${source.url.replace(/\/$/u, "")}/pulls/${number}`;
}
return {
number,
reviewUrl,
state: reviewUrl ? "available" : "pending",
};
};
const extractVerificationNote = (
artifacts: readonly ProjectArtifact[],
status: ProjectIssueStatus
): string => {
const evidenceLine = allArtifactText(artifacts)
.split("\n")
.map((line) => line.trim())
.find((line) => /verification|tests?|browser|passed|failed/iu.test(line));
if (evidenceLine) {
return evidenceLine.replace(/^[-*#\s]+/u, "").slice(0, 140);
}
if (status === "completed") {
return "The agent reported completion after its verification step.";
}
if (status === "failed") {
return "Verification did not complete successfully.";
}
if (status === "working") {
return "Verification will appear here as the run records evidence.";
}
return "No verification evidence has been recorded yet.";
};
const makeRunCheck = (status: ProjectIssueStatus): ProjectVerificationCheck => {
if (status === "queued") {
return {
detail: "Agent admission is queued",
label: "Agent run",
state: "current",
};
}
if (status === "open") {
return {
detail: "Start the issue to create a run",
label: "Agent run",
state: "upcoming",
};
}
return {
detail: "Project manager run is attached",
label: "Agent run",
state: "complete",
};
};
const makeVerificationCheck = (
verification: ProjectVerificationStatus
): ProjectVerificationCheck => {
if (verification === "passed") {
return {
detail: "Verification passed",
label: "Verification",
state: "complete",
};
}
if (verification === "failed") {
return {
detail: "Review the preserved failure",
label: "Verification",
state: "attention",
};
}
if (verification === "blocked") {
return {
detail: "Waiting on a project decision",
label: "Verification",
state: "attention",
};
}
if (verification === "running") {
return {
detail: "Evidence is being recorded",
label: "Verification",
state: "current",
};
}
return {
detail: "No verification result yet",
label: "Verification",
state: "upcoming",
};
};
const makeChecks = (
status: ProjectIssueStatus,
verification: ProjectVerificationStatus,
pullRequest: ProjectPullRequest
): readonly ProjectVerificationCheck[] => {
const issueCheck: ProjectVerificationCheck =
status === "open"
? {
detail: "Waiting to be started",
label: "Issue accepted",
state: "upcoming",
}
: {
detail: "Issue context is attached",
label: "Issue accepted",
state: "complete",
};
const pullRequestCheck: ProjectVerificationCheck =
pullRequest.state === "available"
? {
detail: "Ready for human review",
label: "Gitea pull request",
state: "complete",
}
: {
detail: "Appears after a verified run publishes one",
label: "Gitea pull request",
state: "upcoming",
};
return [
issueCheck,
makeRunCheck(status),
makeVerificationCheck(verification),
pullRequestCheck,
];
};
const buildActivity = (
issue: ProjectIssue,
artifacts: readonly ProjectArtifact[],
statusLabel: string
): readonly ProjectActivityItem[] => {
const artifact = [...artifacts]
.toSorted((left, right) => right.updatedAt - left.updatedAt)
.find((candidate) => candidate.path !== "work.md");
const items: ProjectActivityItem[] = [
{
detail: `Issue #${issue.number} entered the project loop`,
label: "Issue created",
time: formatTime(issue.createdAt),
},
];
if (issue.updatedAt !== issue.createdAt) {
items.unshift({
detail: `Status is now ${statusLabel.toLowerCase()}`,
label: "Work state updated",
time: relativeTime(issue.updatedAt),
});
}
if (artifact) {
items.unshift({
detail: `Durable project evidence changed in ${artifact.path}`,
label: "Evidence recorded",
time: relativeTime(artifact.updatedAt),
});
}
return items.slice(0, 3);
};
export const summarizeProjectIssues = (
issues: readonly ProjectIssue[] | undefined
): ProjectIssueSummary => {
if (!issues) {
return { active: 0, completed: 0, needsInput: 0, total: 0 };
}
return {
active: issues.filter(
(issue) => !["completed", "failed"].includes(issue.status)
).length,
completed: issues.filter((issue) => issue.status === "completed").length,
needsInput: issues.filter((issue) => issue.status === "needs-input").length,
total: issues.length,
};
};
export const buildProjectLoopView = ({
artifacts,
issue,
source,
}: {
readonly artifacts: readonly ProjectArtifact[] | undefined;
readonly issue: ProjectIssue | undefined;
readonly source: ProjectSourceSummary | undefined;
}): ProjectLoopView | null => {
if (!issue) {
return null;
}
const status = getProjectWorkStatus(issue.status);
const { verification } = status;
const resolvedArtifacts = artifacts ?? [];
const pullRequest = extractPullRequest(resolvedArtifacts, source);
return {
activity: buildActivity(issue, resolvedArtifacts, status.label),
currentState: STATUS_TEXT[issue.status],
nextAction: getProjectWorkNextAction(issue.status),
progress: getProjectWorkProgress(issue.status),
pullRequest,
status,
verification,
verificationChecks: makeChecks(issue.status, verification, pullRequest),
verificationNote: extractVerificationNote(resolvedArtifacts, issue.status),
};
};

View File

@@ -0,0 +1,484 @@
import type { Doc, Id } from "@code/backend/convex/_generated/dataModel";
import { describe, expect, test } from "vitest";
import {
buildActivityTimeline,
buildWorkUnitCard,
buildWorkUnitDetail,
eventsForIssue,
extractArtifactPaths,
extractLatestSummary,
extractPullRequestFromEvents,
} from "./work-unit-projection";
import type { LinkedSignal } from "./work-unit-projection";
type Issue = Doc<"projectIssues">;
type Event = Doc<"projectEvents">;
const ISSUE_A = "issue-a" as Id<"projectIssues">;
const ISSUE_B = "issue-b" as Id<"projectIssues">;
const makeLinkedSignal = (
overrides: Partial<LinkedSignal> = {}
): LinkedSignal => ({
createdAt: 5000,
signalId: "sig-1" as Id<"signals">,
sourceCount: 2,
summary: "Three users hit the same error.",
title: "OAuth crash on Safari",
...overrides,
});
const makeIssue = (overrides: Partial<Issue> = {}): Issue =>
({
_creationTime: 1,
_id: ISSUE_A,
body: "Fix the Safari OAuth callback so users can complete sign-in.",
createdAt: 1000,
number: 1,
projectId: "project-1" as Id<"projects">,
status: "open",
title: "Fix Safari OAuth callback",
updatedAt: 2000,
...overrides,
}) as Issue;
const makeEvent = (
kind: string,
data: Record<string, unknown>,
overrides: Partial<Event> = {}
): Event =>
({
_creationTime: 1,
_id: `evt-${kind}-${overrides.createdAt ?? 1000}` as Id<"projectEvents">,
createdAt: overrides.createdAt ?? 1000,
data,
issueId: ISSUE_A,
kind,
projectId: "project-1" as Id<"projects">,
...overrides,
}) as Event;
describe("eventsForIssue", () => {
test("filters to a specific issue and ignores undefined", () => {
const events: Event[] = [
makeEvent("issue.created", { number: 1 }, { createdAt: 1000 }),
makeEvent(
"issue.created",
{ number: 2 },
{ createdAt: 2000, issueId: ISSUE_B }
),
];
expect(eventsForIssue(events, ISSUE_A)).toHaveLength(1);
expect(eventsForIssue(undefined, ISSUE_A)).toEqual([]);
});
});
describe("extractArtifactPaths", () => {
test("collects distinct paths from artifact.updated events", () => {
const events: Event[] = [
makeEvent(
"artifact.updated",
{ path: "work.md", revision: 1 },
{ createdAt: 1000 }
),
makeEvent(
"artifact.updated",
{ path: "work.md", revision: 2 },
{ createdAt: 2000 }
),
makeEvent(
"artifact.updated",
{ path: "artifacts.md", revision: 1 },
{ createdAt: 3000 }
),
makeEvent("issue.created", { number: 1 }, { createdAt: 4000 }),
];
expect(extractArtifactPaths(events)).toEqual(["artifacts.md", "work.md"]);
});
test("returns empty for no artifact events", () => {
expect(extractArtifactPaths([])).toEqual([]);
});
});
describe("buildWorkUnitCard", () => {
test("projects a fresh issue with no events and no linked signals", () => {
const card = buildWorkUnitCard({
issue: makeIssue(),
issueEvents: [],
linkedSignals: [],
});
expect(card.title).toBe("Fix Safari OAuth callback");
expect(card.status).toBe("open");
expect(card.stepCount).toBe(0);
expect(card.currentActivity).toBeNull();
expect(card.needsInput).toBe(false);
expect(card.hasPullRequest).toBe(false);
expect(card.artifactCount).toBe(0);
expect(card.signalCount).toBe(0);
expect(card.summary).toContain("Fix the Safari OAuth");
});
test("counts per-issue artifacts from events, not project-wide", () => {
const events: Event[] = [
makeEvent(
"artifact.updated",
{ path: "work.md", revision: 1 },
{ createdAt: 1000 }
),
makeEvent(
"artifact.updated",
{ path: "artifacts.md", revision: 1 },
{ createdAt: 2000 }
),
];
const card = buildWorkUnitCard({
issue: makeIssue({ status: "working" }),
issueEvents: events,
linkedSignals: [],
});
expect(card.artifactCount).toBe(2);
expect(card.stepCount).toBe(2);
});
test("reflects needs-input status and activity from events", () => {
const events = [
makeEvent("issue.created", { number: 1 }, { createdAt: 1000 }),
makeEvent("issue.queued", { number: 1 }, { createdAt: 2000 }),
makeEvent("agent.needs-input", {}, { createdAt: 3000 }),
];
const card = buildWorkUnitCard({
issue: makeIssue({ status: "needs-input" }),
issueEvents: events,
linkedSignals: [],
});
expect(card.needsInput).toBe(true);
expect(card.stepCount).toBe(3);
expect(card.currentActivity).toBe("Needs your input");
});
test("detects PR from gitea event", () => {
const events = [
makeEvent("issue.created", { number: 1 }, { createdAt: 1000 }),
makeEvent(
"gitea.pull_request.created",
{
pullRequest: { number: 42, url: "https://git.example.com/pulls/42" },
},
{ createdAt: 5000 }
),
];
const card = buildWorkUnitCard({
issue: makeIssue({ status: "completed" }),
issueEvents: events,
linkedSignals: [],
});
expect(card.hasPullRequest).toBe(true);
});
});
describe("linked signal projection", () => {
test("card signalCount reflects real linked signals", () => {
const card = buildWorkUnitCard({
issue: makeIssue(),
issueEvents: [],
linkedSignals: [
makeLinkedSignal(),
makeLinkedSignal({
signalId: "sig-2" as Id<"signals">,
title: "Build error",
}),
],
});
expect(card.signalCount).toBe(2);
});
test("card signalCount is 0 when no signals are linked", () => {
const card = buildWorkUnitCard({
issue: makeIssue(),
issueEvents: [],
linkedSignals: [],
});
expect(card.signalCount).toBe(0);
});
test("detail exposes linked signals with title, summary, and source count", () => {
const detail = buildWorkUnitDetail({
issue: makeIssue(),
issueEvents: [],
linkedSignals: [
makeLinkedSignal(),
makeLinkedSignal({
signalId: "sig-2" as Id<"signals">,
sourceCount: 5,
summary: "CI broke",
}),
],
});
expect(detail.signalCount).toBe(2);
expect(detail.linkedSignals).toHaveLength(2);
expect(detail.linkedSignals[0].title).toBe("OAuth crash on Safari");
expect(detail.linkedSignals[0].sourceCount).toBe(2);
expect(detail.linkedSignals[1].sourceCount).toBe(5);
});
test("detail linked signals empty when no attachment exists", () => {
const detail = buildWorkUnitDetail({
issue: makeIssue(),
issueEvents: [],
linkedSignals: [],
});
expect(detail.signalCount).toBe(0);
expect(detail.linkedSignals).toEqual([]);
});
});
describe("extractLatestSummary", () => {
test("returns the most recent agent summary", () => {
const events = [
makeEvent("agent.working", {}, { createdAt: 1000 }),
makeEvent(
"agent.completed",
{ summary: "All tests passed." },
{ createdAt: 5000 }
),
];
expect(extractLatestSummary(events)).toBe("All tests passed.");
});
test("returns null when no terminal agent event exists", () => {
const events = [
makeEvent("issue.created", { number: 1 }, { createdAt: 1000 }),
];
expect(extractLatestSummary(events)).toBeNull();
});
test("prefers failure error over earlier completion", () => {
const events = [
makeEvent("agent.completed", { summary: "Done." }, { createdAt: 1000 }),
makeEvent(
"agent.failed",
{ error: "Tests failed." },
{ createdAt: 5000 }
),
];
expect(extractLatestSummary(events)).toBe("Tests failed.");
});
});
describe("extractPullRequestFromEvents", () => {
test("extracts URL and number from gitea event", () => {
const events = [
makeEvent(
"gitea.pull_request.created",
{ pullRequest: { number: 7, url: "https://git.example.com/pulls/7" } },
{ createdAt: 1000 }
),
];
const info = extractPullRequestFromEvents(events);
expect(info.hasPR).toBe(true);
expect(info.url).toBe("https://git.example.com/pulls/7");
expect(info.number).toBe(7);
});
test("returns false when no PR event exists", () => {
expect(extractPullRequestFromEvents([]).hasPR).toBe(false);
});
});
describe("buildActivityTimeline", () => {
test("sorts newest first and maps labels", () => {
const events = [
makeEvent("issue.created", { number: 1 }, { createdAt: 1000 }),
makeEvent("agent.completed", { summary: "Done." }, { createdAt: 5000 }),
];
const timeline = buildActivityTimeline(events);
expect(timeline).toHaveLength(2);
expect(timeline[0].kind).toBe("agent.completed");
expect(timeline[0].label).toBe("Work completed");
expect(timeline[1].kind).toBe("issue.created");
expect(timeline[1].label).toBe("Work created");
});
test("maps signal.attached event to activity", () => {
const events = [
makeEvent(
"signal.attached",
{ signalId: "sig-1", signalProblemTitle: "OAuth crash" },
{ createdAt: 3000 }
),
];
const timeline = buildActivityTimeline(events);
expect(timeline[0].label).toBe("Signal linked");
expect(timeline[0].detail).toContain("OAuth crash");
});
});
describe("buildWorkUnitDetail", () => {
test("aggregates all detail fields from per-issue events", () => {
const events: Event[] = [
makeEvent("issue.created", { number: 1 }, { createdAt: 1000 }),
makeEvent(
"artifact.updated",
{ path: "work.md", revision: 2 },
{ createdAt: 2000 }
),
makeEvent(
"artifact.updated",
{ path: "artifacts.md", revision: 1 },
{ createdAt: 2500 }
),
makeEvent(
"gitea.pull_request.created",
{ pullRequest: { number: 3, url: "https://git.example.com/pulls/3" } },
{ createdAt: 3000 }
),
];
const detail = buildWorkUnitDetail({
issue: makeIssue({ status: "completed" }),
issueEvents: events,
linkedSignals: [makeLinkedSignal()],
});
expect(detail.stepCount).toBe(4);
expect(detail.artifactCount).toBe(2);
expect(detail.artifactPaths).toEqual(["artifacts.md", "work.md"]);
expect(detail.signalCount).toBe(1);
expect(detail.hasPullRequest).toBe(true);
expect(detail.pullRequestUrl).toBe("https://git.example.com/pulls/3");
expect(detail.pullRequestNumber).toBe(3);
expect(detail.activity).toHaveLength(4);
expect(detail.activity[0].kind).toBe("gitea.pull_request.created");
});
});
// ---------------------------------------------------------------------------
// Cross-issue isolation: one issue must never inherit another issue's
// artifacts, PR, signals, or activity.
// ---------------------------------------------------------------------------
describe("cross-issue isolation", () => {
const SIGNAL_A = makeLinkedSignal({ signalId: "sig-a" as Id<"signals"> });
const SIGNAL_B = makeLinkedSignal({
signalId: "sig-b" as Id<"signals">,
title: "Different signal",
});
const sharedEvents: Event[] = [
makeEvent(
"issue.created",
{ number: 1 },
{ createdAt: 1000, issueId: ISSUE_A }
),
makeEvent(
"artifact.updated",
{ path: "work.md", revision: 1 },
{ createdAt: 2000, issueId: ISSUE_A }
),
makeEvent(
"gitea.pull_request.created",
{ pullRequest: { number: 5, url: "https://git.example.com/pulls/5" } },
{ createdAt: 3000, issueId: ISSUE_A }
),
makeEvent(
"agent.completed",
{ summary: "Done for A." },
{ createdAt: 4000, issueId: ISSUE_A }
),
// Issue B events — completely separate
makeEvent(
"issue.created",
{ number: 2 },
{ createdAt: 1500, issueId: ISSUE_B }
),
makeEvent(
"artifact.updated",
{ path: "artifacts.md", revision: 1 },
{ createdAt: 2500, issueId: ISSUE_B }
),
makeEvent(
"agent.failed",
{ error: "Failed for B." },
{ createdAt: 3500, issueId: ISSUE_B }
),
];
test("issue A does not inherit issue B's artifacts", () => {
const eventsA = eventsForIssue(sharedEvents, ISSUE_A);
const cardA = buildWorkUnitCard({
issue: makeIssue({ _id: ISSUE_A, number: 1 }),
issueEvents: eventsA,
linkedSignals: [SIGNAL_A],
});
expect(cardA.artifactCount).toBe(1);
expect(cardA.stepCount).toBe(4);
const eventsB = eventsForIssue(sharedEvents, ISSUE_B);
const cardB = buildWorkUnitCard({
issue: makeIssue({ _id: ISSUE_B, number: 2, title: "Issue B" }),
issueEvents: eventsB,
linkedSignals: [SIGNAL_B],
});
expect(cardB.artifactCount).toBe(1);
expect(cardB.stepCount).toBe(3);
});
test("issue A does not inherit issue B's PR", () => {
const eventsB = eventsForIssue(sharedEvents, ISSUE_B);
const cardB = buildWorkUnitCard({
issue: makeIssue({ _id: ISSUE_B, number: 2, title: "Issue B" }),
issueEvents: eventsB,
linkedSignals: [],
});
expect(cardB.hasPullRequest).toBe(false);
});
test("issue A does not inherit issue B's summary", () => {
const eventsA = eventsForIssue(sharedEvents, ISSUE_A);
expect(extractLatestSummary(eventsA)).toBe("Done for A.");
const eventsB = eventsForIssue(sharedEvents, ISSUE_B);
expect(extractLatestSummary(eventsB)).toBe("Failed for B.");
});
test("detail for issue A lists only issue A's artifact paths", () => {
const eventsA = eventsForIssue(sharedEvents, ISSUE_A);
const detailA = buildWorkUnitDetail({
issue: makeIssue({ _id: ISSUE_A, number: 1 }),
issueEvents: eventsA,
linkedSignals: [SIGNAL_A],
});
expect(detailA.artifactPaths).toEqual(["work.md"]);
expect(detailA.pullRequestNumber).toBe(5);
});
test("detail for issue B lists only issue B's artifact paths and no PR", () => {
const eventsB = eventsForIssue(sharedEvents, ISSUE_B);
const detailB = buildWorkUnitDetail({
issue: makeIssue({ _id: ISSUE_B, number: 2, title: "Issue B" }),
issueEvents: eventsB,
linkedSignals: [],
});
expect(detailB.artifactPaths).toEqual(["artifacts.md"]);
expect(detailB.hasPullRequest).toBe(false);
expect(detailB.pullRequestUrl).toBeNull();
});
test("issue A does not inherit issue B's linked signals", () => {
const detailA = buildWorkUnitDetail({
issue: makeIssue({ _id: ISSUE_A, number: 1 }),
issueEvents: eventsForIssue(sharedEvents, ISSUE_A),
linkedSignals: [SIGNAL_A],
});
expect(detailA.signalCount).toBe(1);
expect(detailA.linkedSignals[0].title).toBe("OAuth crash on Safari");
const detailB = buildWorkUnitDetail({
issue: makeIssue({ _id: ISSUE_B, number: 2, title: "Issue B" }),
issueEvents: eventsForIssue(sharedEvents, ISSUE_B),
linkedSignals: [SIGNAL_B],
});
expect(detailB.signalCount).toBe(1);
expect(detailB.linkedSignals[0].title).toBe("Different signal");
});
});

View File

@@ -0,0 +1,329 @@
import type { Doc, Id } from "@code/backend/convex/_generated/dataModel";
// ---------------------------------------------------------------------------
// Work Unit card projection — pure functions that turn Convex documents into
// the collapsed/expanded Work Unit views used by the Work OS surface.
//
// Counts are derived from issue-scoped evidence: events for artifacts/steps/
// PRs, and authenticated signalIssueAttachments for linked Signals. We never
// use project-wide artifact lists or signal lists.
// ---------------------------------------------------------------------------
export type ProjectEvent = Doc<"projectEvents">;
export type ProjectIssue = Doc<"projectIssues">;
/** A Signal linked to a Work Unit via signalIssueAttachments. */
export interface LinkedSignal {
readonly signalId: Id<"signals">;
readonly title: string;
readonly summary: string;
readonly sourceCount: number;
readonly createdAt: number;
}
export interface WorkUnitCard {
readonly issueId: Id<"projectIssues">;
readonly number: number;
readonly title: string;
readonly status: ProjectIssue["status"];
readonly summary: string;
readonly signalCount: number;
readonly currentActivity: string | null;
readonly stepCount: number;
readonly artifactCount: number;
readonly hasPullRequest: boolean;
readonly needsInput: boolean;
readonly updatedAt: number;
}
export interface WorkUnitActivityItem {
readonly label: string;
readonly detail: string;
readonly time: string;
readonly kind: string;
readonly createdAt: number;
}
export interface WorkUnitDetail {
readonly issue: ProjectIssue;
readonly activity: readonly WorkUnitActivityItem[];
readonly stepCount: number;
readonly artifactCount: number;
readonly artifactPaths: readonly string[];
readonly signalCount: number;
readonly linkedSignals: readonly LinkedSignal[];
readonly hasPullRequest: boolean;
readonly pullRequestUrl: string | null;
readonly pullRequestNumber: number | null;
readonly needsInput: boolean;
readonly latestSummary: string | null;
}
// ---------------------------------------------------------------------------
// Time formatting
// ---------------------------------------------------------------------------
export const formatRelativeTime = (timestamp: number): string => {
const minutes = Math.max(0, Math.round((Date.now() - timestamp) / 60_000));
if (minutes < 1) {
return "just now";
}
if (minutes < 60) {
return `${minutes}m ago`;
}
const hours = Math.round(minutes / 60);
if (hours < 24) {
return `${hours}h ago`;
}
return `${Math.round(hours / 24)}d ago`;
};
export const formatClockTime = (timestamp: number): string =>
new Intl.DateTimeFormat(undefined, {
hour: "numeric",
minute: "2-digit",
}).format(timestamp);
// ---------------------------------------------------------------------------
// Event filtering
// ---------------------------------------------------------------------------
export const eventsForIssue = (
events: readonly ProjectEvent[] | undefined,
issueId: Id<"projectIssues">
): readonly ProjectEvent[] =>
(events ?? []).filter((e) => e.issueId === issueId);
// ---------------------------------------------------------------------------
// Event-to-activity projection
// ---------------------------------------------------------------------------
interface ActivityProjection {
readonly label: string;
readonly detail: string;
}
const getString = (
data: Record<string, unknown>,
key: string,
fallback: string
): string => (typeof data[key] === "string" ? (data[key] as string) : fallback);
const ACTIVITY_HANDLERS: Record<
string,
(data: Record<string, unknown>, event: ProjectEvent) => ActivityProjection
> = {
"agent.completed": (data) => ({
detail: getString(data, "summary", "Run completed successfully"),
label: "Work completed",
}),
"agent.failed": (data) => ({
detail: getString(data, "error", "The run stopped unexpectedly"),
label: "Run failed",
}),
"agent.needs-input": () => ({
detail: "Waiting for a decision or missing information",
label: "Needs your input",
}),
"agent.working": () => ({
detail: "The project manager is progressing the issue",
label: "Agent working",
}),
"agent.workspace.created": (data) => ({
detail: `Workspace ready on ${getString(data, "branchName", "work branch")}`,
label: "Workspace prepared",
}),
"artifact.updated": (data) => ({
detail: `Evidence revised in ${getString(data, "path", "artifact")} (r${data.revision ?? "?"})`,
label: "Evidence updated",
}),
"gitea.lifecycle.updated": (data) => ({
detail: `${getString(data, "status", "updated")} on ${getString(data, "branch", "branch")}`,
label: "Git lifecycle",
}),
"gitea.pull_request.created": (data) => {
const pr = data.pullRequest as { number?: number } | undefined;
return {
detail: `Pull request #${pr?.number ?? "?"} opened for review`,
label: "Pull request opened",
};
},
"issue.created": (data) => ({
detail: `Issue #${data.number ?? "?"} entered the loop`,
label: "Work created",
}),
"issue.queued": (data) => ({
detail: `Queued #${data.number ?? "?"} for the project manager`,
label: "Work queued",
}),
"signal.attached": (data) => ({
detail: `Signal "${getString(data, "signalProblemTitle", "Unknown")}" linked to this Work Unit`,
label: "Signal linked",
}),
};
const projectEventToActivity = (event: ProjectEvent): ActivityProjection => {
const data = (event.data ?? {}) as Record<string, unknown>;
const handler = ACTIVITY_HANDLERS[event.kind];
if (handler) {
return handler(data, event);
}
return { detail: event.kind, label: "Activity" };
};
export const buildActivityTimeline = (
issueEvents: readonly ProjectEvent[]
): readonly WorkUnitActivityItem[] =>
[...issueEvents]
.toSorted((a, b) => b.createdAt - a.createdAt)
.map((event) => {
const projection = projectEventToActivity(event);
return {
createdAt: event.createdAt,
detail: projection.detail,
kind: event.kind,
label: projection.label,
time: formatRelativeTime(event.createdAt),
};
});
// ---------------------------------------------------------------------------
// PR extraction from issue-scoped events
// ---------------------------------------------------------------------------
export interface PullRequestInfo {
readonly hasPR: boolean;
readonly url: string | null;
readonly number: number | null;
}
export const extractPullRequestFromEvents = (
issueEvents: readonly ProjectEvent[]
): PullRequestInfo => {
const prEvent = issueEvents.find(
(e) => e.kind === "gitea.pull_request.created"
);
if (!prEvent) {
return { hasPR: false, number: null, url: null };
}
const data = prEvent.data as {
pullRequest?: { url?: string; number?: number };
};
const pr = data.pullRequest;
return {
hasPR: true,
number: pr?.number ?? null,
url: pr?.url ?? null,
};
};
// ---------------------------------------------------------------------------
// Latest agent summary
// ---------------------------------------------------------------------------
export const extractLatestSummary = (
issueEvents: readonly ProjectEvent[]
): string | null => {
const summaryEvent = [...issueEvents]
.toSorted((a, b) => b.createdAt - a.createdAt)
.find((e) => e.kind === "agent.completed" || e.kind === "agent.failed");
if (!summaryEvent) {
return null;
}
const data = summaryEvent.data as { summary?: string; error?: string };
if (summaryEvent.kind === "agent.completed") {
return data.summary ?? null;
}
return data.error ?? null;
};
// ---------------------------------------------------------------------------
// Artifact path extraction from issue-scoped artifact.updated events
// ---------------------------------------------------------------------------
export const extractArtifactPaths = (
issueEvents: readonly ProjectEvent[]
): readonly string[] => {
const paths = new Set<string>();
for (const event of issueEvents) {
if (event.kind !== "artifact.updated") {
continue;
}
const data = event.data as { path?: string };
if (typeof data.path === "string") {
paths.add(data.path);
}
}
return [...paths].toSorted();
};
// ---------------------------------------------------------------------------
// Card builder — derives counts from issue-scoped events + linked signals
// ---------------------------------------------------------------------------
export const buildWorkUnitCard = ({
issue,
issueEvents,
linkedSignals,
}: {
readonly issue: ProjectIssue;
readonly issueEvents: readonly ProjectEvent[];
readonly linkedSignals: readonly LinkedSignal[];
}): WorkUnitCard => {
const sorted = [...issueEvents].toSorted((a, b) => b.createdAt - a.createdAt);
const [latestEvent] = sorted;
const currentActivity = latestEvent
? projectEventToActivity(latestEvent).label
: null;
const prInfo = extractPullRequestFromEvents(issueEvents);
const summary = extractLatestSummary(issueEvents) ?? issue.body.slice(0, 140);
const artifactPaths = extractArtifactPaths(issueEvents);
return {
artifactCount: artifactPaths.length,
currentActivity,
hasPullRequest: prInfo.hasPR,
issueId: issue._id,
needsInput: issue.status === "needs-input",
number: issue.number,
signalCount: linkedSignals.length,
status: issue.status,
stepCount: issueEvents.length,
summary,
title: issue.title,
updatedAt: issue.updatedAt,
};
};
// ---------------------------------------------------------------------------
// Detail builder — same per-issue derivation, plus linked signals
// ---------------------------------------------------------------------------
export const buildWorkUnitDetail = ({
issue,
issueEvents,
linkedSignals,
}: {
readonly issue: ProjectIssue;
readonly issueEvents: readonly ProjectEvent[];
readonly linkedSignals: readonly LinkedSignal[];
}): WorkUnitDetail => {
const activity = buildActivityTimeline(issueEvents);
const prInfo = extractPullRequestFromEvents(issueEvents);
const artifactPaths = extractArtifactPaths(issueEvents);
return {
activity,
artifactCount: artifactPaths.length,
artifactPaths,
hasPullRequest: prInfo.hasPR,
issue,
latestSummary: extractLatestSummary(issueEvents),
linkedSignals,
needsInput: issue.status === "needs-input",
pullRequestNumber: prInfo.number,
pullRequestUrl: prInfo.url,
signalCount: linkedSignals.length,
stepCount: issueEvents.length,
};
};

View File

@@ -68,7 +68,7 @@ export const Layout = ({ children }: { children: React.ReactNode }) => (
<Meta />
<Links />
</head>
<body>
<body className="bg-[#0e0e0d]">
{children}
<ScrollRestoration />
<Scripts />
@@ -82,12 +82,11 @@ const App = () => (
<ThemeProvider
attribute="class"
defaultTheme="dark"
forcedTheme="dark"
disableTransitionOnChange
storageKey="vite-ui-theme"
>
<div className="h-svh min-h-0 overflow-hidden">
<Outlet />
</div>
<Outlet />
<Toaster richColors />
</ThemeProvider>
</AuthenticatedFlueProvider>

View File

@@ -1,4 +1,19 @@
import { index, layout, route } from "@react-router/dev/routes";
import type { RouteConfig } from "@react-router/dev/routes";
import { flatRoutes } from "@react-router/fs-routes";
export default flatRoutes() satisfies RouteConfig;
export default [
layout("./routes/auth/layout.tsx", [
route("login", "./routes/auth/login/page.tsx"),
route("signup", "./routes/auth/signup/page.tsx"),
]),
layout("./routes/app/layout.tsx", [
layout("./routes/app/mobile/layout.tsx", [
index("./routes/app/mobile/page.tsx"),
route("chat", "./routes/app/mobile/chat/page.tsx"),
route("work", "./routes/app/mobile/work-list/page.tsx"),
route("work/:workUnitId", "./routes/app/mobile/work/page.tsx"),
]),
route("dashboard", "./routes/app/dashboard/page.tsx"),
route("todos", "./routes/app/todos/page.tsx"),
]),
] satisfies RouteConfig;

View File

@@ -1,12 +0,0 @@
import { ChatPage } from "@/components/chat/chat-page";
import type { Route } from "./+types/_app._index";
export const meta = (_args: Route.MetaArgs) => [
{ title: "Zopu" },
{ content: "Chat with Zopu", name: "description" },
];
const Home = () => <ChatPage />;
export default Home;

View File

@@ -1,5 +0,0 @@
import { ProjectWorkspacePage } from "@/components/projects/project-workspace-page";
export default function Dashboard() {
return <ProjectWorkspacePage />;
}

View File

@@ -1,102 +0,0 @@
import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { Button } from "@code/ui/components/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@code/ui/components/card";
import { Checkbox } from "@code/ui/components/checkbox";
import { Input } from "@code/ui/components/input";
import { useMutation, useQuery } from "convex/react";
import { Loader2, Trash2 } from "lucide-react";
import { useState, type FormEvent } from "react";
export default function Todos() {
const [newTodoText, setNewTodoText] = useState("");
const todos = useQuery(api.todos.getAll);
const createTodo = useMutation(api.todos.create);
const toggleTodo = useMutation(api.todos.toggle);
const deleteTodo = useMutation(api.todos.deleteTodo);
const handleAddTodo = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const text = newTodoText.trim();
if (!text) return;
await createTodo({ text });
setNewTodoText("");
};
const handleToggleTodo = (id: Id<"todos">, currentCompleted: boolean) => {
toggleTodo({ id, completed: !currentCompleted });
};
const handleDeleteTodo = (id: Id<"todos">) => {
deleteTodo({ id });
};
return (
<div className="w-full mx-auto max-w-md py-10">
<Card>
<CardHeader>
<CardTitle>Todo List</CardTitle>
<CardDescription>Manage your tasks efficiently</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleAddTodo} className="mb-6 flex items-center space-x-2">
<Input
value={newTodoText}
onChange={(e) => setNewTodoText(e.target.value)}
placeholder="Add a new task..."
/>
<Button type="submit" disabled={!newTodoText.trim()}>
Add
</Button>
</form>
{todos === undefined ? (
<div className="flex justify-center py-4">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : todos.length === 0 ? (
<p className="py-4 text-center">No todos yet. Add one above!</p>
) : (
<ul className="space-y-2">
{todos.map((todo) => (
<li
key={todo._id}
className="flex items-center justify-between rounded-md border p-2"
>
<div className="flex items-center space-x-2">
<Checkbox
checked={todo.completed}
onCheckedChange={() => handleToggleTodo(todo._id, todo.completed)}
id={`todo-${todo._id}`}
/>
<label
htmlFor={`todo-${todo._id}`}
className={`${todo.completed ? "line-through text-muted-foreground" : ""}`}
>
{todo.text}
</label>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteTodo(todo._id)}
aria-label="Delete todo"
>
<Trash2 className="h-4 w-4" />
</Button>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import { WorkOsPage } from "@/components/work-os/work-os-page";
export default function Dashboard() {
return <WorkOsPage />;
}

View File

@@ -0,0 +1,12 @@
import { MobileFlowPage } from "@/components/mobile-workspace/mobile-flow-page";
import type { Route } from "./+types/page";
export const meta = (_args: Route.MetaArgs) => [
{ title: "Zopu" },
{ content: "Chat with Zopu", name: "description" },
];
export default function MobileChatPage() {
return <MobileFlowPage screen="assistant-chat" />;
}

View File

@@ -0,0 +1,9 @@
import { Outlet } from "react-router";
export default function MobileProductLayout() {
return (
<div className="min-h-svh bg-[#11110f]">
<Outlet />
</div>
);
}

View File

@@ -0,0 +1,15 @@
import { MobileFlowPage } from "@/components/mobile-workspace/mobile-flow-page";
import type { Route } from "./+types/page";
export const meta = (_args: Route.MetaArgs) => [
{ title: "Daily focus | Zopu" },
{
content: "Supervise active work and open the next action",
name: "description",
},
];
export default function MobileHomePage() {
return <MobileFlowPage screen="home" />;
}

View File

@@ -0,0 +1,15 @@
import { MobileFlowPage } from "@/components/mobile-workspace/mobile-flow-page";
import type { Route } from "./+types/page";
export const meta = (_args: Route.MetaArgs) => [
{ title: "Active work | Zopu" },
{
content: "Browse active work and expand a work unit in place",
name: "description",
},
];
export default function MobileWorkListPage() {
return <MobileFlowPage screen="work-list" />;
}

View File

@@ -0,0 +1,15 @@
import { MobileFlowPage } from "@/components/mobile-workspace/mobile-flow-page";
import type { Route } from "./+types/page";
export const meta = (_args: Route.MetaArgs) => [
{ title: "Work unit | Zopu" },
{
content: "Review the state, next milestone, and activity for a work unit",
name: "description",
},
];
export default function MobileWorkUnitPage() {
return <MobileFlowPage screen="work-unit-detail" />;
}

View File

@@ -0,0 +1,123 @@
import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { Button } from "@code/ui/components/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@code/ui/components/card";
import { Checkbox } from "@code/ui/components/checkbox";
import { Input } from "@code/ui/components/input";
import { useMutation, useQuery } from "convex/react";
import { Loader2, Trash2 } from "lucide-react";
import { useState } from "react";
import type { FormEvent, ReactNode } from "react";
export default function Todos() {
const [newTodoText, setNewTodoText] = useState("");
const todos = useQuery(api.todos.getAll);
const createTodo = useMutation(api.todos.create);
const toggleTodo = useMutation(api.todos.toggle);
const deleteTodo = useMutation(api.todos.deleteTodo);
const handleAddTodo = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const text = newTodoText.trim();
if (!text) {
return;
}
await createTodo({ text });
setNewTodoText("");
};
const handleToggleTodo = (id: Id<"todos">, currentCompleted: boolean) => {
void toggleTodo({ completed: !currentCompleted, id });
};
const handleDeleteTodo = (id: Id<"todos">) => {
void deleteTodo({ id });
};
let todoContent: ReactNode;
if (todos === undefined) {
todoContent = (
<div className="flex justify-center py-4">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
);
} else if (todos.length === 0) {
todoContent = (
<p className="py-4 text-center">No todos yet. Add one above!</p>
);
} else {
todoContent = (
<ul className="space-y-2">
{todos.map((todo) => (
<li
className="flex items-center justify-between rounded-md border p-2"
key={todo._id}
>
<div className="flex items-center space-x-2">
<Checkbox
checked={todo.completed}
id={`todo-${todo._id}`}
onCheckedChange={() =>
handleToggleTodo(todo._id, todo.completed)
}
/>
<label
className={
todo.completed
? "text-muted-foreground line-through"
: undefined
}
htmlFor={`todo-${todo._id}`}
>
{todo.text}
</label>
</div>
<Button
aria-label="Delete todo"
onClick={() => handleDeleteTodo(todo._id)}
size="icon"
variant="ghost"
>
<Trash2 className="h-4 w-4" />
</Button>
</li>
))}
</ul>
);
}
return (
<div className="mx-auto w-full max-w-md py-10">
<Card>
<CardHeader>
<CardTitle>Todo List</CardTitle>
<CardDescription>Manage your tasks efficiently</CardDescription>
</CardHeader>
<CardContent>
<form
className="mb-6 flex items-center space-x-2"
onSubmit={handleAddTodo}
>
<Input
onChange={(event) => setNewTodoText(event.target.value)}
placeholder="Add a new task..."
value={newTodoText}
/>
<Button disabled={!newTodoText.trim()} type="submit">
Add
</Button>
</form>
{todoContent}
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,7 +1,7 @@
import { LoginForm } from "@code/auth/web";
import { useNavigate } from "react-router";
import type { Route } from "./+types/_auth.login";
import type { Route } from "./+types/page";
export const meta = (_args: Route.MetaArgs) => [
{ title: "Sign in | Zopu" },

View File

@@ -1,7 +1,7 @@
import { SignupForm } from "@code/auth/web";
import { useNavigate } from "react-router";
import type { Route } from "./+types/_auth.signup";
import type { Route } from "./+types/page";
export const meta = (_args: Route.MetaArgs) => [
{ title: "Create account | Zopu" },

View File

@@ -1,5 +1,10 @@
{
"include": ["**/*", "**/.server/**/*", "**/.client/**/*", ".react-router/types/**/*"],
"include": [
"**/*",
"**/.server/**/*",
"**/.client/**/*",
".react-router/types/**/*"
],
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2024"],
"types": ["node", "vite/client"],
@@ -10,6 +15,8 @@
"rootDirs": [".", "./.react-router/types"],
"paths": {
"@/*": ["./src/*"],
"@code/primitives": ["../../packages/primitives/src/index.ts"],
"@code/primitives/*": ["../../packages/primitives/src/*"],
"@code/ui/*": ["../../packages/ui/src/*"]
},
"esModuleInterop": true,

View File

@@ -3,12 +3,12 @@ import path from "node:path";
import { reactRouter } from "@react-router/dev/vite";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite-plus";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
envDir: path.resolve(import.meta.dirname, "../.."),
plugins: [tailwindcss(), reactRouter(), tsconfigPaths()],
plugins: [tailwindcss(), reactRouter()],
resolve: {
dedupe: ["convex", "react", "react-dom"],
tsconfigPaths: true,
},
});

237
bun.lock
View File

@@ -5,10 +5,16 @@
"": {
"name": "code",
"devDependencies": {
"@code/backend": "workspace:*",
"@code/config": "workspace:*",
"@code/env": "workspace:*",
"@code/primitives": "workspace:*",
"@flue/sdk": "1.0.0-beta.9",
"@types/bun": "catalog:",
"@types/node": "^22.13.14",
"convex": "catalog:",
"convex-test": "catalog:",
"effect": "catalog:",
"oxfmt": "latest",
"oxlint": "latest",
"rolldown": "1.1.4",
@@ -112,6 +118,7 @@
"@code/auth": "workspace:*",
"@code/backend": "workspace:*",
"@code/env": "workspace:*",
"@code/primitives": "workspace:*",
"@code/ui": "workspace:*",
"@flue/react": "1.0.0-beta.9",
"@flue/sdk": "1.0.0-beta.9",
@@ -147,18 +154,25 @@
"name": "@code/agents",
"version": "0.0.0",
"dependencies": {
"@agentos-software/opencode": "0.2.7",
"@code/backend": "workspace:*",
"@code/env": "workspace:*",
"@code/primitives": "workspace:*",
"@flue/runtime": "latest",
"@rivet-dev/agentos-core": "catalog:",
"convex": "catalog:",
"hono": "4.12.30",
"dockerode": "^5.0.1",
"effect": "catalog:",
"get-port": "^7.2.0",
"hono": "4.12.31",
"sandbox-agent": "0.4.2",
"valibot": "^1.4.2",
},
"devDependencies": {
"@code/config": "workspace:*",
"@flue/cli": "latest",
"@types/bun": "catalog:",
"@types/dockerode": "^4.0.1",
"typescript": "catalog:",
},
},
@@ -232,6 +246,7 @@
"name": "@code/primitives",
"version": "0.0.0",
"dependencies": {
"@agentos-software/git": "0.3.3",
"@rivet-dev/agentos": "catalog:",
"effect": "catalog:",
},
@@ -248,6 +263,11 @@
"dependencies": {
"@base-ui/react": "^1.6.0",
"@shadcn/react": "^0.2.0",
"@streamdown/cjk": "^1.0.3",
"@streamdown/code": "^1.1.1",
"@streamdown/math": "^1.0.2",
"@streamdown/mermaid": "^1.0.2",
"ai": "^7.0.35",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "catalog:",
@@ -255,9 +275,12 @@
"react": "^19.2.7",
"react-dom": "^19.2.7",
"shadcn": "^4.12.0",
"shiki": "^4.3.1",
"sonner": "catalog:",
"streamdown": "^2.5.0",
"tailwind-merge": "catalog:",
"tw-animate-css": "^1.4.0",
"use-stick-to-bottom": "^1.1.6",
},
"devDependencies": {
"@code/config": "workspace:*",
@@ -316,6 +339,8 @@
"@agentos-software/gawk": ["@agentos-software/gawk@0.3.4", "", {}, "sha512-NlU6nGxoqIUc1I2zdBHFwH04gE0tBygP2FcBO12VBptty7lbgq1CNLk909jIcSNFEwlm3VRPHeZNkbdVKZ2Ujg=="],
"@agentos-software/git": ["@agentos-software/git@0.3.3", "", {}, "sha512-6hCVv4P9eZ5JiPUcLv7y2I5rgxgKvXfdNRvyXCHecMyz6oxE5T0VW8WkSMuwmGTQFUhIIvPn7DpyNDbCaGMpgw=="],
"@agentos-software/grep": ["@agentos-software/grep@0.3.4", "", {}, "sha512-Bta2Ljl+kCX/3Bjg06Q9N9LPRcf13S92lHRaYG+CeGVDXabIIgkHLUGIQlI1OKuIr7IRAktjgelUAuJnxGFvvw=="],
"@agentos-software/gzip": ["@agentos-software/gzip@0.3.4", "", {}, "sha512-l7Y/Vwiwsqgna68yYwdNCnUBPGBg5zdmtjEFeG3hnDDFLe/02h17q0gUIqJmDBIAy1q9GoizqcFi7zXO2xygKg=="],
@@ -330,6 +355,12 @@
"@agentos-software/tar": ["@agentos-software/tar@0.3.4", "", {}, "sha512-/SSqfgS5xOufvulsz5kVDTCFxpDy+5s7Og1iJe9krjU7Jvtgnp9TpPaJnSr721h6uY6tsWHcs3u8E457EwSkNw=="],
"@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "4.0.3", "@ai-sdk/provider-utils": "5.0.12", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-gqTMvV0N8/JirIZ3OzwjSZRYxzwZu/PeOFCKb8NB9fstWH39tI+L6CkeMNVou5/HCKEYAw6RCOHW59Vhquv8vA=="],
"@ai-sdk/provider": ["@ai-sdk/provider@4.0.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-e0CpNWJUY7OxAFAnCZkw+ri9QOHWwTs1tXP42782KFGCU07qt8NiXCrCVowyCB5dP2r5/Uls+g2oPd8kOJn9dw=="],
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.12", "", { "dependencies": { "@ai-sdk/provider": "4.0.3", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bbhlOgHeYwrIGheLkM6fhS8hVger8uFPmcOLg+kxc9EFh7y30XYorWhthlYAgpadO3SJhFZrIcEknN7qEqEVvA=="],
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
"@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="],
@@ -540,6 +571,8 @@
"@babylonjs/core": ["@babylonjs/core@7.54.3", "", {}, "sha512-P5ncXVd8GEUJLhwloP9V0oVwQYIrvZztguVeLlvd5Rx+9aQnenKjpV8auJ6SRsUlAmNZU4pFTKzwF6o2EUfhAw=="],
"@balena/dockerignore": ["@balena/dockerignore@1.0.2", "", {}, "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q=="],
"@base-ui/react": ["@base-ui/react@1.6.0", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@base-ui/utils": "0.3.1", "@floating-ui/react-dom": "^2.1.8", "@floating-ui/utils": "^0.2.11", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@date-fns/tz": "^1.2.0", "@types/react": "^17 || ^18 || ^19", "date-fns": "^4.0.0", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@date-fns/tz", "@types/react", "date-fns"] }, "sha512-/jzjTWJYXhRFO45Bev9lc3cHbmjzCMpUqbMZ2AgKy/z25mY9B6shGSNcXcjQar9n5doM0KYW1W8fcFv2jZBuMw=="],
"@base-ui/utils": ["@base-ui/utils@0.3.1", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@floating-ui/utils": "^0.2.11", "reselect": "^5.2.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-gFFiltORVmW/N6IILTGxizP3PBpVpysqML1ALY5Vk0mH+7faVkCknOU31goYHN5Aoek2dkjxva1XOD2Ce9WuIg=="],
@@ -806,6 +839,10 @@
"@gorhom/portal": ["@gorhom/portal@1.0.14", "", { "dependencies": { "nanoid": "^3.3.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A=="],
"@grpc/grpc-js": ["@grpc/grpc-js@1.14.4", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ=="],
"@grpc/proto-loader": ["@grpc/proto-loader@0.7.15", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.2.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ=="],
"@hono/node-server": ["@hono/node-server@2.0.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-bjD221KPLoJTWUwso1J6fGKiTXEUFedG/s0visavY4zakFPkeGURMRNly+FhBHs7T8Dz4qHaZIMX9ZoJHSJtKA=="],
"@hono/standard-validator": ["@hono/standard-validator@0.2.3", "", { "peerDependencies": { "@standard-schema/spec": "^1.0.0", "hono": ">=3.9.0" } }, "sha512-bp9vHu6Va6SfMHC3D4ZLBbT/woi+AZ9CRdTXQu3kLJuLh2W/Gb9UO4hijS+BQAGFXi4EGpXdetxpzwTAawSVeg=="],
@@ -906,6 +943,8 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="],
"@malept/cross-spawn-promise": ["@malept/cross-spawn-promise@1.1.1", "", { "dependencies": { "cross-spawn": "^7.0.1" } }, "sha512-RTBGWL5FWQcg9orDOCcp4LvItNzUPcyEU9bwaeJX0rJ1IQxzucC48Y0/sQLp/g6t99IQgAlGIaesJS+gTn7tVQ=="],
"@mariozechner/clipboard": ["@mariozechner/clipboard@0.3.9", "", { "optionalDependencies": { "@mariozechner/clipboard-darwin-arm64": "0.3.9", "@mariozechner/clipboard-darwin-universal": "0.3.9", "@mariozechner/clipboard-darwin-x64": "0.3.9", "@mariozechner/clipboard-linux-arm64-gnu": "0.3.9", "@mariozechner/clipboard-linux-arm64-musl": "0.3.9", "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9", "@mariozechner/clipboard-linux-x64-gnu": "0.3.9", "@mariozechner/clipboard-linux-x64-musl": "0.3.9", "@mariozechner/clipboard-win32-arm64-msvc": "0.3.9", "@mariozechner/clipboard-win32-x64-msvc": "0.3.9" } }, "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA=="],
@@ -1328,6 +1367,20 @@
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A=="],
"@sandbox-agent/cli": ["@sandbox-agent/cli@0.4.2", "", { "dependencies": { "@sandbox-agent/cli-shared": "0.4.2" }, "optionalDependencies": { "@sandbox-agent/cli-darwin-arm64": "0.4.2", "@sandbox-agent/cli-darwin-x64": "0.4.2", "@sandbox-agent/cli-linux-arm64": "0.4.2", "@sandbox-agent/cli-linux-x64": "0.4.2", "@sandbox-agent/cli-win32-x64": "0.4.2" }, "bin": { "sandbox-agent": "bin/sandbox-agent" } }, "sha512-trO//ypJBSt5xkewuol9LOykvDgHwUXq8R+yQVS+0CmpN3lYUtewHkb+At9RVGRhDMmJZY2oasaXDnhfurQ33w=="],
"@sandbox-agent/cli-darwin-arm64": ["@sandbox-agent/cli-darwin-arm64@0.4.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+L1O8SI7k/LLhyB4dG0ghmz1cJHa0WtVjuRTrEE2gw/5EbGLWopPBsCVCmQ7snrQ4fPwtaiZDhfExcEj1VI7aw=="],
"@sandbox-agent/cli-darwin-x64": ["@sandbox-agent/cli-darwin-x64@0.4.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-dDg/EwWsdgVVbJiiCX1scSNRRA48u77SsC7Tuqrfzx4fIJMLuLiIcmEtXQyCBWysSyQNV2Cr+PYXXQfCb3xg8g=="],
"@sandbox-agent/cli-linux-arm64": ["@sandbox-agent/cli-linux-arm64@0.4.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-TGmTUexMoubmWQyTeaOJu0rDVl2h0Ifh1pZ0ceZy7u/6Eoqs2n46CbfQtasUxZJf10uxPgRyzEDhcdDrTYVQUA=="],
"@sandbox-agent/cli-linux-x64": ["@sandbox-agent/cli-linux-x64@0.4.2", "", { "os": "linux", "cpu": "x64" }, "sha512-H9Rbqq0DRkCHvakzefJUDrDa2y+vJjlYd5/tefzKbQ34locE13TGNygRLxdEVXpBECjK9wVdBwTVEphQNsOcjw=="],
"@sandbox-agent/cli-shared": ["@sandbox-agent/cli-shared@0.4.2", "", {}, "sha512-sjZXRkKeFXCSKR6hHzF2Af8CCRO3F3WFwVQJ22+sLTXJ2xskV8lkUE4egknQU9B5BC1Zumts/YiNCFQWG85awQ=="],
"@sandbox-agent/cli-win32-x64": ["@sandbox-agent/cli-win32-x64@0.4.2", "", { "os": "win32", "cpu": "x64" }, "sha512-lZNfHWPwQe/VH51Yvrl/ATCUvBZ3a+c8mwovojhQcmZlv4QuUQPkuvxhPqHRh9AyBx78L5J/ha46es2doa34nQ=="],
"@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="],
"@secure-exec/core": ["@secure-exec/core@0.2.1", "", { "dependencies": { "better-sqlite3": "^12.8.0" } }, "sha512-HsnUv6gClpMA1BBRmX86j30TKTZtgJC/fO1tVavr7IpM2zNKbHU8LgSlBd7mv2SNy02ImTmU/GnQ3aYB4NSbEg=="],
@@ -1346,6 +1399,22 @@
"@shadcn/react": ["@shadcn/react@0.2.1", "", { "peerDependencies": { "@types/react": ">=19", "react": ">=19" }, "optionalPeers": ["@types/react", "react"] }, "sha512-5krgi3dRMKb5jH6a+qPzVJUy/54s0kKE4Rw4LjDfLqOdVQTWKUgxWf1kW8r912I0jX/Lzxqc+pgjkjWxUIK5BQ=="],
"@shikijs/core": ["@shikijs/core@4.3.1", "", { "dependencies": { "@shikijs/primitive": "4.3.1", "@shikijs/types": "4.3.1", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-ANMDxuaPsNMdDC1m4vfvhlDmJweMwkE5XitTwrq2rWHx5jM+dlm4MmHt2PP6t0uejfR77SuhrhJ0zEijIF/uhA=="],
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.3.1", "", { "dependencies": { "@shikijs/types": "4.3.1", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-JBItcnPuYq7jVJdZo/vMj94r+szT7XEjHFX+mvFDGSEIbVAXAGyHAHzhbWzpGOwYidCZrErJLLgn2PVeiokHnQ=="],
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.3.1", "", { "dependencies": { "@shikijs/types": "4.3.1", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-OXyNMzg0pews+msMj4cHeqT4xiYKKvbnn6VbdAXxfoFl3SSx4fJTc8FadECuc5/H9p3BzhNAoAUXKwAu9rWYhg=="],
"@shikijs/langs": ["@shikijs/langs@4.3.1", "", { "dependencies": { "@shikijs/types": "4.3.1" } }, "sha512-m0l9nsDqgBHvbZbk7A0/kXz/impK3uB/c6rAn6Gpg/uPtdZRQ+alsN/17MU5thb68XTj/4DxkZAotrM0GGSpDQ=="],
"@shikijs/primitive": ["@shikijs/primitive@4.3.1", "", { "dependencies": { "@shikijs/types": "4.3.1", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-CXQRQOYy1leqQ8ceTeJdmXv/bsUY++6QyLpXJ94LZAAYj5X2SKRdc5ipguv4NPyGVKItB2PPwUpRNe0Sjh5S1A=="],
"@shikijs/themes": ["@shikijs/themes@4.3.1", "", { "dependencies": { "@shikijs/types": "4.3.1" } }, "sha512-dgpoJ4WqNi2yTmizQHBJ5zcX6j2lE6icN/0yt4l1kkf16jrY/pwPLoTb1ETsWMz0OBLf9ZNvwmxft+cH+N9qSA=="],
"@shikijs/types": ["@shikijs/types@4.3.1", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-CHFxE0jztBIZRHH6gxXE7DXUCFXjReEGxZ/j0rfSLGKZuwp2xBYycEP14875DSa9KLL/6700oxIq6oO6ef9K2g=="],
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
"@silvia-odwyer/photon-node": ["@silvia-odwyer/photon-node@0.3.4", "", {}, "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA=="],
"@sinclair/typebox": ["@sinclair/typebox@0.34.52", "", {}, "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw=="],
@@ -1392,6 +1461,14 @@
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@streamdown/cjk": ["@streamdown/cjk@1.0.3", "", { "dependencies": { "remark-cjk-friendly": "^2.0.1", "remark-cjk-friendly-gfm-strikethrough": "^2.0.1", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-WRg8HR/gHbBoTgsMd91OKFUClIoDcEFVofJvluvEAyjx3KpU0aGgD9tGDqHkHj14ShoMSkX0IYetWGegTcwIJw=="],
"@streamdown/code": ["@streamdown/code@1.1.1", "", { "dependencies": { "shiki": "^3.19.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-i7HTNuDgZWb+VdrNVOam9gQhIc5MSSDXKWXgbUrn/4vSRaSMM+Rtl10MQj4wLWPNpF7p80waJsAqFP8HZfb0Jg=="],
"@streamdown/math": ["@streamdown/math@1.0.2", "", { "dependencies": { "katex": "^0.16.27", "rehype-katex": "^7.0.1", "remark-math": "^6.0.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-r8Ur9/lBuFnzZAFdEWrLUF2s/gRwRRRwruqltdZibyjbCBnuW7SJbFm26nXqvpJPW/gzpBUMrBVBzd88z05D5g=="],
"@streamdown/mermaid": ["@streamdown/mermaid@1.0.2", "", { "dependencies": { "mermaid": "^11.12.2" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-Fr/4sBWnAeSnxM3PcrV/+DiZe5oPMq9gOkUIAH7ZauJeuwrZ/DVzD4g0zlav6AH0axh2m/sOfrfLtY5aLT7niw=="],
"@t3-oss/env-core": ["@t3-oss/env-core@0.13.11", "", { "peerDependencies": { "arktype": "^2.1.0", "typescript": ">=5.0.0", "valibot": "^1.0.0-beta.7 || ^1.0.0", "zod": "^3.24.0 || ^4.0.0" }, "optionalPeers": ["arktype", "typescript", "valibot", "zod"] }, "sha512-sM7GYY+KL7H/Hl0BE0inWfk3nRHZOLhmVn7sHGxaZt9FAR6KqREXAE+6TqKfiavfXmpRxO/OZ2QgKRd+oiBYRQ=="],
"@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="],
@@ -1540,6 +1617,10 @@
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
"@types/docker-modem": ["@types/docker-modem@3.0.6", "", { "dependencies": { "@types/node": "*", "@types/ssh2": "*" } }, "sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg=="],
"@types/dockerode": ["@types/dockerode@4.0.1", "", { "dependencies": { "@types/docker-modem": "*", "@types/node": "*", "@types/ssh2": "*" } }, "sha512-cmUpB+dPN955PxBEuXE3f6lKO1hHiIGYJA46IVF3BJpNsZGvtBDcRnlrHYHtOH/B6vtDOyl2kZ2ShAu3mgc27Q=="],
"@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
@@ -1560,6 +1641,8 @@
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/katex": ["@types/katex@0.16.8", "", {}, "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg=="],
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
"@types/mime-types": ["@types/mime-types@2.1.4", "", {}, "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w=="],
@@ -1576,6 +1659,8 @@
"@types/retry": ["@types/retry@0.12.2", "", {}, "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow=="],
"@types/ssh2": ["@types/ssh2@1.15.5", "", { "dependencies": { "@types/node": "^18.11.18" } }, "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ=="],
"@types/stats.js": ["@types/stats.js@0.17.4", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="],
"@types/three": ["@types/three@0.165.0", "", { "dependencies": { "@tweenjs/tween.js": "~23.1.1", "@types/stats.js": "*", "@types/webxr": "*", "fflate": "~0.8.2", "meshoptimizer": "~0.18.1" } }, "sha512-AJK8JZAFNBF0kBXiAIl5pggYlzAGGA8geVYQXAcPCEDRbyA+oEjkpUBcJJrtNz6IiALwzGexFJGZG2yV3WsYBw=="],
@@ -1618,6 +1703,8 @@
"@vercel/detect-agent": ["@vercel/detect-agent@1.2.3", "", {}, "sha512-VYNCgUc0nOmC4WJmWw9GkrKdfr8Zl4/rxhC5SvgacBgxiW9W/9NRttUoHHXV8xdII3MaRgkZZVX8Ikzc/Jmjag=="],
"@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="],
"@vitest/browser": ["@vitest/browser@4.1.9", "", { "dependencies": { "@blazediff/core": "1.9.1", "@vitest/mocker": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pngjs": "^7.0.0", "sirv": "^3.0.2", "tinyrainbow": "^3.1.0", "ws": "^8.19.0" }, "peerDependencies": { "vitest": "4.1.9" } }, "sha512-j1BKtWmPcqpMhmx/L9EPLgAJpCb0zKfwoWLmqBbxaogCXHjOwHFSEoHCBfnGtx93xKQwilZ26m+UOsHqHMkRNg=="],
"@vitest/browser-preview": ["@vitest/browser-preview@4.1.9", "", { "dependencies": { "@testing-library/dom": "^10.4.1", "@testing-library/user-event": "^14.6.1", "@vitest/browser": "4.1.9" }, "peerDependencies": { "vitest": "4.1.9" } }, "sha512-a4/OrkMDb/WUnE4OOB/4FJbK3rYVO7YykqtUgcTKG4p2a0R3XcjPVu7SLRHFBs2+NIYhv5yxp1Lz3dbdGBjIow=="],
@@ -1654,6 +1741,8 @@
"@voidzero-dev/vite-plus-win32-x64-msvc": ["@voidzero-dev/vite-plus-win32-x64-msvc@0.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-+VUui1OIaFX0tqdUAXjmoKVlujEtWVdcsFDw2Jff+D6b4LUTQaOMAaaic8nNdfZL6wEjweRREQLZi/icZAXtNQ=="],
"@workflow/serde": ["@workflow/serde@4.1.0", "", {}, "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ=="],
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="],
"@xterm/headless": ["@xterm/headless@6.0.0", "", {}, "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw=="],
@@ -1666,10 +1755,14 @@
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"acp-http-client": ["acp-http-client@0.4.2", "", { "dependencies": { "@agentclientprotocol/sdk": "^0.16.1" } }, "sha512-3wtPieF08YIU4vNXaoL5up/1D0if4i9IX3Ye5q/bwbcwg1BKsazIK/VNNfvN4ldbPjWul69IqIOpGRS3I0qo3Q=="],
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"agent-cli-detector": ["agent-cli-detector@0.1.4", "", { "bin": { "agent-cli-detector": "dist/cli.js" } }, "sha512-qPgevFvpaQoBaRJVKzr8R7h1WPvV3DtbgRIQlne4le66KBzXx5hNBwo/+NTw67LgkKBlhCzksrdautpUdlls0Q=="],
"ai": ["ai@7.0.36", "", { "dependencies": { "@ai-sdk/gateway": "4.0.27", "@ai-sdk/provider": "4.0.3", "@ai-sdk/provider-utils": "5.0.12" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-1XJjua58GVQ0CyO2Xbioyladt85x71Joup2U8qKrjHUl8tHYwrDw8iFRtav6e94AxSVCn9FVgTS17oX1OCquKA=="],
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
@@ -1698,6 +1791,8 @@
"asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="],
"asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="],
"asn1.js": ["asn1.js@4.10.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0" } }, "sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw=="],
"assert": ["assert@2.1.0", "", { "dependencies": { "call-bind": "^1.0.2", "is-nan": "^1.3.2", "object-is": "^1.1.5", "object.assign": "^4.1.4", "util": "^0.12.5" } }, "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw=="],
@@ -1742,6 +1837,8 @@
"basic-ftp": ["basic-ftp@5.3.1", "", {}, "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw=="],
"bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="],
"better-auth": ["better-auth@1.6.15", "", { "dependencies": { "@better-auth/core": "1.6.15", "@better-auth/drizzle-adapter": "1.6.15", "@better-auth/kysely-adapter": "1.6.15", "@better-auth/memory-adapter": "1.6.15", "@better-auth/mongo-adapter": "1.6.15", "@better-auth/prisma-adapter": "1.6.15", "@better-auth/telemetry": "1.6.15", "@better-auth/utils": "0.4.1", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "better-call": "1.3.5", "defu": "^6.1.4", "jose": "^6.1.3", "kysely": "^0.28.17 || ^0.29.0", "nanostores": "^1.1.1", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": "^0.45.2", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-0nuQuEru3ZrLF+9xFUuN3llAmR+6gHLtLunoXaZxB9lXGjSmfBcc6SZUgYq4DfzugPnLvdnzYazsyprZFSFC4Q=="],
"better-call": ["better-call@1.3.5", "", { "dependencies": { "@better-auth/utils": "^0.4.0", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-kOFJkBP7utAQLEYrobZm3vkTH8mXq5GNgvjc5/XEST1ilVHaxXUXfeDeFlqoETMtyqS4+3/h4ONX2i++ebZrvA=="],
@@ -1804,6 +1901,8 @@
"buffer-xor": ["buffer-xor@1.0.3", "", {}, "sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ=="],
"buildcheck": ["buildcheck@0.0.7", "", {}, "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA=="],
"builtin-status-codes": ["builtin-status-codes@3.0.0", "", {}, "sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ=="],
"bun-ffi-structs": ["bun-ffi-structs@0.2.4", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-AJzsqoVFs1KBbJbWHIYrVZLDC3NhTqqh25awRXqzoLzmBAKr5oqk6+CwuYHAekKx+VBCYVohBoKuRq40dV+TYg=="],
@@ -1940,6 +2039,8 @@
"cosmiconfig": ["cosmiconfig@9.0.2", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg=="],
"cpu-features": ["cpu-features@0.0.10", "", { "dependencies": { "buildcheck": "~0.0.6", "nan": "^2.19.0" } }, "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA=="],
"create-ecdh": ["create-ecdh@4.0.4", "", { "dependencies": { "bn.js": "^4.1.0", "elliptic": "^6.5.3" } }, "sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A=="],
"create-hash": ["create-hash@1.2.0", "", { "dependencies": { "cipher-base": "^1.0.1", "inherits": "^2.0.1", "md5.js": "^1.3.4", "ripemd160": "^2.0.1", "sha.js": "^2.4.0" } }, "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg=="],
@@ -2016,7 +2117,7 @@
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
"d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="],
"d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
"d3-polygon": ["d3-polygon@3.0.1", "", {}, "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg=="],
@@ -2110,6 +2211,10 @@
"dnssd-advertise": ["dnssd-advertise@1.1.6", "", {}, "sha512-Ndrrf6BMPalkQPd/zubL+4YghH2J9NspapQ09uDXwYbvOPkP0oaqf5CkcwJ0b50kS2O3ul6yVu+jz+RY62Cejg=="],
"docker-modem": ["docker-modem@5.0.7", "", { "dependencies": { "debug": "^4.1.1", "readable-stream": "^3.5.0", "split-ca": "^1.0.1", "ssh2": "^1.15.0" } }, "sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA=="],
"dockerode": ["dockerode@5.0.1", "", { "dependencies": { "@balena/dockerignore": "^1.0.2", "@grpc/grpc-js": "^1.11.1", "@grpc/proto-loader": "^0.7.13", "docker-modem": "^5.0.7", "protobufjs": "^7.3.2", "tar-fs": "^2.1.4" } }, "sha512-avsq/xk4YPIrn0CgleX5bjT9Y8IT1p9PxrNQ++RBQ2WEyFfHCTDsT9kmyxz+H/axnjAwg8wJWEIuPGOUuNupiA=="],
"dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
"dom-helpers": ["dom-helpers@3.4.0", "", { "dependencies": { "@babel/runtime": "^7.1.2" } }, "sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA=="],
@@ -2156,7 +2261,7 @@
"enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="],
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
@@ -2388,6 +2493,8 @@
"get-own-enumerable-keys": ["get-own-enumerable-keys@1.0.0", "", {}, "sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA=="],
"get-port": ["get-port@7.2.0", "", {}, "sha512-afP4W205ONCuMoPBqcR6PSXnzX35KTcJygfJfcp+QY+uwm3p20p1YczWXhlICIzGMCxYBQcySEcOgsJcrkyobg=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="],
@@ -2436,18 +2543,30 @@
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
"hast-util-from-dom": ["hast-util-from-dom@5.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hastscript": "^9.0.0", "web-namespaces": "^2.0.0" } }, "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q=="],
"hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="],
"hast-util-from-html-isomorphic": ["hast-util-from-html-isomorphic@2.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-from-dom": "^5.0.0", "hast-util-from-html": "^2.0.0", "unist-util-remove-position": "^5.0.0" } }, "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw=="],
"hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="],
"hast-util-is-element": ["hast-util-is-element@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g=="],
"hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="],
"hast-util-raw": ["hast-util-raw@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-from-parse5": "^8.0.0", "hast-util-to-parse5": "^8.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "parse5": "^7.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw=="],
"hast-util-sanitize": ["hast-util-sanitize@5.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "unist-util-position": "^5.0.0" } }, "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg=="],
"hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
"hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
"hast-util-to-parse5": ["hast-util-to-parse5@8.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA=="],
"hast-util-to-text": ["hast-util-to-text@4.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "hast-util-is-element": "^3.0.0", "unist-util-find-after": "^5.0.0" } }, "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A=="],
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
"hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="],
@@ -2466,7 +2585,7 @@
"hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="],
"hono": ["hono@4.12.30", "", {}, "sha512-emn+JoJjrN9YTpRDS5it/UI2SO9BAE37T6I3d963RxcZ81G9A4pr2SZTEiiaiKbzx+NKRg5BZ89fCL7gCJCUog=="],
"hono": ["hono@4.12.31", "", {}, "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg=="],
"hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="],
@@ -2616,6 +2735,8 @@
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
"json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
@@ -2696,6 +2817,8 @@
"lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
"lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="],
"lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="],
"lodash.throttle": ["lodash.throttle@4.1.1", "", {}, "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ=="],
@@ -2722,7 +2845,7 @@
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
"marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
"marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="],
"marky": ["marky@1.3.0", "", {}, "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ=="],
@@ -2746,6 +2869,8 @@
"mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="],
"mdast-util-math": ["mdast-util-math@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "longest-streak": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.1.0", "unist-util-remove-position": "^5.0.0" } }, "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w=="],
"mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="],
"mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="],
@@ -2758,6 +2883,10 @@
"mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="],
"mdast-util-to-markdown-cjk-friendly": ["mdast-util-to-markdown-cjk-friendly@1.0.0", "", { "dependencies": { "mdast-util-to-markdown": "^2.1.2", "micromark-extension-cjk-friendly-util": "3.0.1", "micromark-util-symbol": "^2.0.1" }, "peerDependencies": { "@types/mdast": "*" }, "optionalPeers": ["@types/mdast"] }, "sha512-BoaAm8mlJ+LAYz0Qs532Y3ciTuQYgBUPZcSFbvC/ZKmEMAKgulw84YvQK1gI34t/vL2euSfuaWlqczkTBgamkw=="],
"mdast-util-to-markdown-cjk-friendly-gfm-strikethrough": ["mdast-util-to-markdown-cjk-friendly-gfm-strikethrough@1.0.0", "", { "dependencies": { "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-to-markdown": "^2.1.2", "micromark-extension-cjk-friendly-util": "3.0.1", "micromark-util-symbol": "^2.0.1" }, "peerDependencies": { "@types/mdast": "*" }, "optionalPeers": ["@types/mdast"] }, "sha512-1ePVfB4P/vz3xSsm6H3D32r6VYGErxclnuLLFK02/2ReF+UdEKm7caulK6Vm0LBIp5gPRtB2Z1OYDznCkX3k2w=="],
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
"mdn-data": ["mdn-data@2.0.14", "", {}, "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow=="],
@@ -2808,6 +2937,12 @@
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
"micromark-extension-cjk-friendly": ["micromark-extension-cjk-friendly@2.0.1", "", { "dependencies": { "devlop": "^1.1.0", "micromark-extension-cjk-friendly-util": "3.0.1", "micromark-util-chunked": "^2.0.1", "micromark-util-resolve-all": "^2.0.1", "micromark-util-symbol": "^2.0.1" }, "peerDependencies": { "micromark": "^4.0.0", "micromark-util-types": "^2.0.0" }, "optionalPeers": ["micromark-util-types"] }, "sha512-OkzoYVTL1ChbvQ8Cc1ayTIz7paFQz8iS9oIYmewncweUSwmWR+hkJF9spJ1lxB90XldJl26A1F4IkPOKS3bDXw=="],
"micromark-extension-cjk-friendly-gfm-strikethrough": ["micromark-extension-cjk-friendly-gfm-strikethrough@2.0.1", "", { "dependencies": { "devlop": "^1.1.0", "get-east-asian-width": "^1.4.0", "micromark-extension-cjk-friendly-util": "3.0.1", "micromark-util-character": "^2.1.1", "micromark-util-chunked": "^2.0.1", "micromark-util-resolve-all": "^2.0.1", "micromark-util-symbol": "^2.0.1" }, "peerDependencies": { "micromark": "^4.0.0", "micromark-util-types": "^2.0.0" }, "optionalPeers": ["micromark-util-types"] }, "sha512-wVC0zwjJNqQeX+bb07YTPu/CvSAyCTafyYb7sMhX1r62/Lw5M/df3JyYaANyp8g15c1ypJRFSsookTqA1IDsUg=="],
"micromark-extension-cjk-friendly-util": ["micromark-extension-cjk-friendly-util@3.0.1", "", { "dependencies": { "get-east-asian-width": "^1.4.0", "micromark-util-character": "^2.1.1", "micromark-util-symbol": "^2.0.1" }, "peerDependencies": { "micromark-util-types": "*" }, "optionalPeers": ["micromark-util-types"] }, "sha512-GcbXqTTHOsiZHyF753oIddP/J2eH8j9zpyQPhkof6B2JNxfEJabnQqxbCgzJNuNes0Y2jTNJ3LiYPSXr6eJA8w=="],
"micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="],
"micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="],
@@ -2822,6 +2957,8 @@
"micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="],
"micromark-extension-math": ["micromark-extension-math@3.1.0", "", { "dependencies": { "@types/katex": "^0.16.0", "devlop": "^1.0.0", "katex": "^0.16.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg=="],
"micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
"micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
@@ -2918,6 +3055,8 @@
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
"nan": ["nan@2.28.0", "", {}, "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ=="],
"nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="],
"nanostores": ["nanostores@1.4.1", "", {}, "sha512-PGd3uPojJB9Z07d5NX3Db/SOSBbyy3wLMUGq0GpnEEJfVzY9mq7daPMAZ3jObV5D3Jn+YKND636eI5ULg7F80Q=="],
@@ -2992,6 +3131,10 @@
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
"oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="],
"oniguruma-to-es": ["oniguruma-to-es@4.3.6", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="],
"open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="],
"openai": ["openai@6.26.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA=="],
@@ -3266,6 +3409,12 @@
"regenerator-runtime": ["regenerator-runtime@0.13.11", "", {}, "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="],
"regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
"regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="],
"regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
"regexpu-core": ["regexpu-core@6.4.0", "", { "dependencies": { "regenerate": "^1.4.2", "regenerate-unicode-properties": "^10.2.2", "regjsgen": "^0.8.0", "regjsparser": "^0.13.0", "unicode-match-property-ecmascript": "^2.0.0", "unicode-match-property-value-ecmascript": "^2.2.1" } }, "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA=="],
"regjsgen": ["regjsgen@0.8.0", "", {}, "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q=="],
@@ -3274,12 +3423,20 @@
"rehype-harden": ["rehype-harden@1.1.8", "", { "dependencies": { "unist-util-visit": "^5.0.0" } }, "sha512-Qn7vR1xrf6fZCrkm9TDWi/AB4ylrHy+jqsNm1EHOAmbARYA6gsnVJBq/sdBh6kmT4NEZxH5vgIjrscefJAOXcw=="],
"rehype-katex": ["rehype-katex@7.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/katex": "^0.16.0", "hast-util-from-html-isomorphic": "^2.0.0", "hast-util-to-text": "^4.0.0", "katex": "^0.16.0", "unist-util-visit-parents": "^6.0.0", "vfile": "^6.0.0" } }, "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA=="],
"rehype-raw": ["rehype-raw@7.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-raw": "^9.0.0", "vfile": "^6.0.0" } }, "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww=="],
"rehype-sanitize": ["rehype-sanitize@6.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-sanitize": "^5.0.0" } }, "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg=="],
"remark-cjk-friendly": ["remark-cjk-friendly@2.3.1", "", { "dependencies": { "mdast-util-to-markdown-cjk-friendly": "1.0.0", "micromark-extension-cjk-friendly": "2.0.1" }, "peerDependencies": { "@types/mdast": "^4.0.0", "unified": "^11.0.0" }, "optionalPeers": ["@types/mdast"] }, "sha512-f+pKZRxCRwNEGFBKNRAZAqU91GIK1SAo3ZyFHWRUgC9zcxRR0BXKd6YwqgSsxtW0rNpUDtONj7H5nje2WL3fcA=="],
"remark-cjk-friendly-gfm-strikethrough": ["remark-cjk-friendly-gfm-strikethrough@2.3.1", "", { "dependencies": { "mdast-util-to-markdown-cjk-friendly-gfm-strikethrough": "1.0.0", "micromark-extension-cjk-friendly-gfm-strikethrough": "2.0.1" }, "peerDependencies": { "@types/mdast": "^4.0.0", "unified": "^11.0.0" }, "optionalPeers": ["@types/mdast"] }, "sha512-JE3TGgouk/sy92SemNMEUhO5mNP4on04cmzOV3s3R5Dbk160ewmpM4tgPiinKKvoJ5UW2fTu7FOYsjVbusSA9w=="],
"remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
"remark-math": ["remark-math@6.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-math": "^3.0.0", "micromark-extension-math": "^3.0.0", "unified": "^11.0.0" } }, "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA=="],
"remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
"remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="],
@@ -3338,6 +3495,8 @@
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"sandbox-agent": ["sandbox-agent@0.4.2", "", { "dependencies": { "@sandbox-agent/cli-shared": "0.4.2", "acp-http-client": "0.4.2" }, "optionalDependencies": { "@sandbox-agent/cli": "0.4.2" }, "peerDependencies": { "@cloudflare/sandbox": ">=0.1.0", "@daytonaio/sdk": ">=0.12.0", "@e2b/code-interpreter": ">=1.0.0", "@fly/sprites": ">=0.0.1", "@vercel/sandbox": ">=0.1.0", "computesdk": ">=0.1.0", "dockerode": ">=4.0.0", "get-port": ">=7.0.0", "modal": ">=0.1.0" }, "optionalPeers": ["@cloudflare/sandbox", "@daytonaio/sdk", "@e2b/code-interpreter", "@fly/sprites", "@vercel/sandbox", "computesdk", "dockerode", "get-port", "modal"] }, "sha512-fH6WDQEaIrgiu93LxZcy+4Dx+t+/cslu+hzXImDyUlsaL6jV2jIv4fdxELkALlo7uzyEDVK9lmqs9qy65RHwBQ=="],
"sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
@@ -3384,6 +3543,8 @@
"shell-quote": ["shell-quote@1.8.4", "", {}, "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ=="],
"shiki": ["shiki@4.3.1", "", { "dependencies": { "@shikijs/core": "4.3.1", "@shikijs/engine-javascript": "4.3.1", "@shikijs/engine-oniguruma": "4.3.1", "@shikijs/langs": "4.3.1", "@shikijs/themes": "4.3.1", "@shikijs/types": "4.3.1", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-oR+qDVi2OjX1tmDpyv+3KviX01KzO6Af+0NNnKnsp9491UEGz2YpxTuJboS/6VhYpTdqzmuJBuiTlrAWWJAssw=="],
"side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="],
"side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
@@ -3432,6 +3593,8 @@
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
"split-ca": ["split-ca@1.0.1", "", {}, "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ=="],
"split-on-first": ["split-on-first@1.1.0", "", {}, "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw=="],
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
@@ -3440,6 +3603,8 @@
"sql.js": ["sql.js@1.14.1", "", {}, "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A=="],
"ssh2": ["ssh2@1.17.0", "", { "dependencies": { "asn1": "^0.2.6", "bcrypt-pbkdf": "^1.0.2" }, "optionalDependencies": { "cpu-features": "~0.0.10", "nan": "^2.23.0" } }, "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ=="],
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
"stackframe": ["stackframe@1.3.4", "", {}, "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw=="],
@@ -3594,6 +3759,8 @@
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
"tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="],
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"type-fest": ["type-fest@5.8.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA=="],
@@ -3632,10 +3799,14 @@
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
"unist-util-find-after": ["unist-util-find-after@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ=="],
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
"unist-util-remove-position": ["unist-util-remove-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q=="],
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
"unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
@@ -3662,6 +3833,8 @@
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
"use-stick-to-bottom": ["use-stick-to-bottom@1.1.6", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-z3Up8jYQGTkUCsGBnwg6/wj70KgXoW5Kz1AAc1j8MtQuYMBo6ZsdhrIXoegxa7gaMMilgQYyTohTrt3p94jHog=="],
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"util": ["util@0.12.5", "", { "dependencies": { "inherits": "^2.0.3", "is-arguments": "^1.0.4", "is-generator-function": "^1.0.7", "is-typed-array": "^1.1.3", "which-typed-array": "^1.1.2" } }, "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA=="],
@@ -3854,14 +4027,14 @@
"@expo/xcpretty/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"@flue/runtime/hono": ["hono@4.12.31", "", {}, "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg=="],
"@google/genai/google-auth-library": ["google-auth-library@10.9.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-xtvUqvINPhTaBm7nXqlYPcrMHJPm1lCNdSovxnKKhTm+4JsvQ+KGVYJViLoH9Yxu8w+T0Qv5HubzYT9BLrppJg=="],
"@google/genai/p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="],
"@google/genai/ws": ["ws@8.21.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw=="],
"@grpc/grpc-js/@grpc/proto-loader": ["@grpc/proto-loader@0.8.1", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg=="],
"@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.12", "", {}, "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g=="],
"@jest/types/@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="],
@@ -3888,10 +4061,10 @@
"@modelcontextprotocol/sdk/@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
"@modelcontextprotocol/sdk/hono": ["hono@4.12.31", "", {}, "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg=="],
"@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
"@opentui/core/marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
"@react-native/dev-middleware/open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="],
"@react-native/dev-middleware/serve-static": ["serve-static@1.16.3", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "~0.19.1" } }, "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA=="],
@@ -3914,6 +4087,8 @@
"@secure-exec/nodejs/esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="],
"@streamdown/code/shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="],
"@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="],
@@ -3938,6 +4113,12 @@
"@testing-library/dom/pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
"@types/docker-modem/@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="],
"@types/dockerode/@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="],
"@types/ssh2/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"@types/ws/@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="],
"@types/yauzl/@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="],
@@ -4000,24 +4181,24 @@
"d3/d3-hierarchy": ["d3-hierarchy@3.1.2", "", {}, "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA=="],
"d3/d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
"d3/d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
"d3-chord/d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
"d3-dsv/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="],
"d3-dsv/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"d3-sankey/d3-array": ["d3-array@2.12.1", "", { "dependencies": { "internmap": "^1.0.0" } }, "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ=="],
"d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="],
"defaults/clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="],
"degenerator/ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="],
"diffie-hellman/bn.js": ["bn.js@4.12.5", "", {}, "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ=="],
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"dot-prop/is-obj": ["is-obj@2.0.0", "", {}, "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w=="],
"elliptic/bn.js": ["bn.js@4.12.5", "", {}, "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ=="],
@@ -4132,8 +4313,6 @@
"parse-png/pngjs": ["pngjs@3.4.0", "", {}, "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w=="],
"parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"parse5-htmlparser2-tree-adapter/parse5": ["parse5@6.0.1", "", {}, "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="],
"path-scurry/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="],
@@ -4182,8 +4361,6 @@
"ripemd160/hash-base": ["hash-base@3.1.2", "", { "dependencies": { "inherits": "^2.0.4", "readable-stream": "^2.3.8", "safe-buffer": "^5.2.1", "to-buffer": "^1.2.1" } }, "sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg=="],
"rivetkit/hono": ["hono@4.12.31", "", {}, "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg=="],
"rivetkit/uuid": ["uuid@12.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-9obBF8sMIHJWNQaO6IGOG8giGa/jUpKX34bz6o4whVs8M0WAvhID2tNxYp6A2XEBJPuZSX8wsS/6TEKfIDc+nw=="],
"router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
@@ -4208,8 +4385,6 @@
"stacktrace-parser/type-fest": ["type-fest@0.7.1", "", {}, "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg=="],
"streamdown/marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="],
"string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
"strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
@@ -4376,8 +4551,6 @@
"@rivet-dev/agentos/rivetkit/@rivetkit/workflow-engine": ["@rivetkit/workflow-engine@2.3.7", "", { "dependencies": { "@rivetkit/bare-ts": "^0.6.2", "cbor-x": "^1.6.0", "fdb-tuple": "^1.0.0", "pino": "^9.6.0", "vbare": "^0.0.4" } }, "sha512-+C4kGuSNysrw2zs/LQoYxouOxufKHHMxiIXKF8Ab9GU2BCJ32RUST9dYU/gaQE/w9uM+hqAHUz0DTlOBYU6p6g=="],
"@rivet-dev/agentos/rivetkit/hono": ["hono@4.12.31", "", {}, "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg=="],
"@rivet-dev/agentos/rivetkit/uuid": ["uuid@12.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-9obBF8sMIHJWNQaO6IGOG8giGa/jUpKX34bz6o4whVs8M0WAvhID2tNxYp6A2XEBJPuZSX8wsS/6TEKfIDc+nw=="],
"@rivetkit/framework-base/rivetkit/@rivetkit/engine-cli": ["@rivetkit/engine-cli@2.3.7", "", { "optionalDependencies": { "@rivetkit/engine-cli-darwin-arm64": "2.3.7", "@rivetkit/engine-cli-darwin-x64": "2.3.7", "@rivetkit/engine-cli-linux-arm64-musl": "2.3.7", "@rivetkit/engine-cli-linux-x64-musl": "2.3.7", "@rivetkit/engine-cli-win32-x64": "2.3.7" } }, "sha512-CezLwJ0B7dWDbA7qM6Aq04mwnrJAdrDActRrrcb4NBa20h7wO9KPAYBwyJz/dRnKm9EUUNcFZ6hrSDpp6T3+Rg=="],
@@ -4394,8 +4567,6 @@
"@rivetkit/framework-base/rivetkit/@rivetkit/workflow-engine": ["@rivetkit/workflow-engine@2.3.7", "", { "dependencies": { "@rivetkit/bare-ts": "^0.6.2", "cbor-x": "^1.6.0", "fdb-tuple": "^1.0.0", "pino": "^9.6.0", "vbare": "^0.0.4" } }, "sha512-+C4kGuSNysrw2zs/LQoYxouOxufKHHMxiIXKF8Ab9GU2BCJ32RUST9dYU/gaQE/w9uM+hqAHUz0DTlOBYU6p6g=="],
"@rivetkit/framework-base/rivetkit/hono": ["hono@4.12.31", "", {}, "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg=="],
"@rivetkit/framework-base/rivetkit/uuid": ["uuid@12.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-9obBF8sMIHJWNQaO6IGOG8giGa/jUpKX34bz6o4whVs8M0WAvhID2tNxYp6A2XEBJPuZSX8wsS/6TEKfIDc+nw=="],
"@rivetkit/react/@tanstack/react-store/@tanstack/store": ["@tanstack/store@0.7.7", "", {}, "sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ=="],
@@ -4414,8 +4585,6 @@
"@rivetkit/react/rivetkit/@rivetkit/workflow-engine": ["@rivetkit/workflow-engine@2.3.7", "", { "dependencies": { "@rivetkit/bare-ts": "^0.6.2", "cbor-x": "^1.6.0", "fdb-tuple": "^1.0.0", "pino": "^9.6.0", "vbare": "^0.0.4" } }, "sha512-+C4kGuSNysrw2zs/LQoYxouOxufKHHMxiIXKF8Ab9GU2BCJ32RUST9dYU/gaQE/w9uM+hqAHUz0DTlOBYU6p6g=="],
"@rivetkit/react/rivetkit/hono": ["hono@4.12.31", "", {}, "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg=="],
"@rivetkit/react/rivetkit/uuid": ["uuid@12.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-9obBF8sMIHJWNQaO6IGOG8giGa/jUpKX34bz6o4whVs8M0WAvhID2tNxYp6A2XEBJPuZSX8wsS/6TEKfIDc+nw=="],
"@secure-exec/nodejs/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="],
@@ -4470,6 +4639,18 @@
"@secure-exec/nodejs/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="],
"@streamdown/code/shiki/@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="],
"@streamdown/code/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA=="],
"@streamdown/code/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g=="],
"@streamdown/code/shiki/@shikijs/langs": ["@shikijs/langs@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg=="],
"@streamdown/code/shiki/@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="],
"@streamdown/code/shiki/@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="],
"@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
"@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
@@ -4496,6 +4677,12 @@
"@testing-library/dom/pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
"@types/docker-modem/@types/node/undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
"@types/dockerode/@types/node/undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
"@types/ssh2/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"@types/ws/@types/node/undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
"@types/yauzl/@types/node/undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],

View File

@@ -0,0 +1,83 @@
###############################################################################
# Zopu Runtime Deployment — Environment Template
#
# Copy to .env and fill in real values. Never commit .env to the repository.
# This file documents every environment group required by the single-node
# execution plane. Lines marked REQUIRED must be set before first start.
###############################################################################
# ---------------------------------------------------------------------------
# 1. Convex (control plane) — REQUIRED
# Convex is already deployed; provide the production deployment URL.
# ---------------------------------------------------------------------------
CONVEX_URL=https://your-deployment.convex.cloud
CONVEX_SITE_URL=https://your-deployment.convex.site
SITE_URL=http://localhost:5173
# ---------------------------------------------------------------------------
# 2. Self-hosted Git / Gitea — REQUIRED for issue lifecycle
# The agent daemon clones repos and creates PRs through Gitea.
# ---------------------------------------------------------------------------
GITEA_URL=https://git.openputer.com
GITEA_TOKEN=replace-with-gitea-api-token
# ---------------------------------------------------------------------------
# 3. Model gateway — REQUIRED
# All model calls route through this OpenAI-compatible endpoint.
# ---------------------------------------------------------------------------
AGENT_MODEL_PROVIDER=cheaptricks
AGENT_MODEL_NAME=glm-5.2
AGENT_MODEL_API=openai-completions
AGENT_MODEL_BASE_URL=https://ai.example.invalid/v1
AGENT_MODEL_API_KEY=replace-with-model-gateway-key
AGENT_MODEL_CONTEXT_WINDOW=262000
AGENT_MODEL_MAX_TOKENS=131072
# ---------------------------------------------------------------------------
# 4. AgentOS / RivetKit — OPTIONAL (defaults shown)
# registry.start() in the daemon boots an in-process RivetKit engine
# (envoy mode) backed by a native Rust sidecar. createClient() connects
# back to RIVET_ENDPOINT. No separate Rivet Engine process is required
# for single-node operation. Leave RIVET_ENDPOINT unset to use the
# library default (http://localhost:6420).
# ---------------------------------------------------------------------------
#RIVET_ENDPOINT=http://localhost:6420
# ---------------------------------------------------------------------------
# 5. Zopu agent service (Flue)
# FLUE_DB_TOKEN authenticates the Flue persistence adapter.
# PORT is the Flue Node HTTP server listen port.
# ---------------------------------------------------------------------------
FLUE_DB_TOKEN=replace-with-long-random-token
PORT=3583
# ---------------------------------------------------------------------------
# 6. Daemon identity
# ---------------------------------------------------------------------------
DAEMON_ID=zopu-dedicated
DAEMON_NAME=Zopu Dedicated Server
DAEMON_VERSION=0.0.0
DAEMON_HEARTBEAT_MS=15000
DAEMON_COMMAND_LEASE_MS=60000
# ---------------------------------------------------------------------------
# 7. Docker sandbox
# The zopu service user is added to the docker group during bootstrap.
# Orb sandboxes will use Docker for full-system isolation, but the Orb
# lane contract has not landed yet. Docker access is provisioned now so
# the boundary is ready; no Docker-backed sandbox code is wired today.
# ---------------------------------------------------------------------------
# No env vars required; Docker socket access is via group membership.
# ---------------------------------------------------------------------------
# 8. Service authentication / secrets
# These tokens authenticate inter-service calls. Generate strong randoms.
# ---------------------------------------------------------------------------
# Better Auth / Convex JWT secret (if the agent service needs to mint tokens):
#AUTH_SECRET=replace-with-64-char-hex
# ---------------------------------------------------------------------------
# 9. Private networking (Tailscale) — OPTIONAL
# When Tailscale is available, set the hostname for private DNS.
# ---------------------------------------------------------------------------
#TAILSCALE_HOSTNAME=zopu-runtime

View File

@@ -0,0 +1,331 @@
# Zopu Single-Node Runtime Deployment
Deployment artifacts for the Zopu execution plane on a single Debian dedicated
server. Convex is already deployed as the control plane; this lane deploys only
the execution plane: the daemon (Bun/Effect + AgentOS/RivetKit), the Flue agent
service, Docker Engine for future Orb sandboxes, and supporting infrastructure.
## Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ Debian Dedicated Server │
│ ~12 CPU cores · ~40 GB RAM · single-node │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ zopu-daemon │ │ zopu-agent │ │ Docker │ │
│ │ (systemd) │ │ (systemd) │ │ Engine │ │
│ │ │ │ │ │ │ │
│ │ Effect daemon│ │ Flue Node │ │ Orb sandboxes│ │
│ │ + RivetKit │ │ server.mjs │ │ (future) │ │
│ │ in-process │ │ :3583 │ │ │ │
│ │ engine │ │ │ │ │ │
│ │ + native │ │ │ │ │ │
│ │ sidecar │ │ │ │ │ │
│ │ :6420 │ │ │ │ │ │
│ └──────┬───────┘ └──────┬───────┘ └──────────────┘ │
│ │ │ │
│ └────────┬───────────┘ │
│ │ │
│ ┌────────▼─────────┐ │
│ │ Convex (cloud) │ ← control plane (pre-deployed)│
│ └──────────────────┘ │
│ │
│ systemd timers: health-check (60s), docker-cleanup (daily) │
│ cron: disk-monitor (daily 06:00) │
│ ufw: deny-incoming, SSH + tailscale0 │
│ Tailscale: optional private overlay │
└─────────────────────────────────────────────────────────────┘
```
### Single-node RivetKit topology
The daemon calls `registry.start()` from `rivetkit`, which boots an
**in-process RivetKit engine** (envoy mode) backed by a **native Rust
sidecar** binary (`@rivet-dev/agentos-sidecar`, platform-resolved). The engine
listens on `RIVET_ENDPOINT` (default `http://localhost:6420`). The daemon then
calls `createClient(RIVET_ENDPOINT)` to connect back to its own in-process
engine for actor dispatch.
Evidence: RivetKit source `chunk-YDUQHING.js` line 4751 —
`DEFAULT_ENDPOINT = "http://localhost:6420"`. The `Registry.start()` method
calls `#startEnvoy()``runtime.serveRegistry()` for serverful mode (Mode A).
The `createClient()` function reads `RIVET_ENDPOINT` env or defaults to the
same `http://localhost:6420`.
**No separate Rivet Engine process is required.** The engine, actor envoy, and
sidecar all run inside the daemon process. A future multi-node deployment
would externalize the engine, but that is out of scope.
### Docker / Orb boundary
Docker Engine is installed and the `zopu` service user is in the `docker`
group. The daemon's systemd unit includes `SupplementaryGroups=docker`.
However, **no Docker-backed sandbox code is currently wired**. The Orb
sandbox lane contract has not landed; Docker access is provisioned now so the
boundary is ready. The current agent uses the in-process AgentOS VM (Wasm/V8)
sandbox, not Docker.
## Files
```
deploy/zopu-runtime/
├── bootstrap.sh # One-shot Debian installer
├── .env.template # Environment template (all groups documented)
├── README.md # This file (runbook)
├── systemd/
│ ├── zopu-daemon.service # Daemon systemd unit
│ ├── zopu-agent.service # Agent systemd unit
│ ├── zopu-health.service # Health check oneshot
│ ├── zopu-health.timer # Health check every 60s
│ ├── zopu-docker-cleanup.service
│ └── zopu-docker-cleanup.timer
├── scripts/
│ ├── health-check.sh # TCP/process health probes
│ ├── update.sh # Update to branch or commit
│ ├── rollback.sh # Roll back to previous commit
│ ├── docker-cleanup.sh # Prune stopped containers/images/networks
│ └── disk-monitor.sh # Disk usage alerting
└── caddy/
└── Caddyfile # Optional reverse proxy config (documentation)
```
## Fresh install
```bash
# 1. SSH into the fresh Debian 12 server as root.
# 2. Set environment overrides (optional):
export ZOPU_REPO_URL="ssh://git@git.openputer.com:2222/puter/zopu-code.git"
export ZOPU_REPO_BRANCH="dogfood/v0"
# export TAILSCALE_AUTHKEY="tskey-..."
# export TAILSCALE_HOSTNAME="zopu-runtime"
# 3. Run the bootstrap script:
bash bootstrap.sh
# 4. Edit .env with real values:
nano /opt/zopu/.env
# 5. Start services:
systemctl start zopu-daemon
sleep 3
systemctl start zopu-agent
# 6. Enable timers:
systemctl enable --now zopu-health.timer zopu-docker-cleanup.timer
# 7. Verify:
/opt/zopu/deploy/zopu-runtime/scripts/health-check.sh
```
## Start / stop / restart
```bash
# Start both services
systemctl start zopu-daemon zopu-agent
# Stop both services
systemctl stop zopu-agent zopu-daemon
# Restart (daemon first — it owns the RivetKit engine)
systemctl restart zopu-daemon && sleep 3 && systemctl restart zopu-agent
# Enable on boot
systemctl enable zopu-daemon zopu-agent
# Disable on boot
systemctl disable zopu-daemon zopu-agent
```
## Log inspection
All service logs go to journald with `SyslogIdentifier` tags.
```bash
# Daemon logs (live follow)
journalctl -u zopu-daemon -f
# Agent logs (live follow)
journalctl -u zopu-agent -f
# Last 100 lines of daemon
journalctl -u zopu-daemon -n 100
# Logs since boot
journalctl -u zopu-daemon -b
# Health check timer logs
journalctl -u zopu-health.service -n 50
# Docker cleanup logs
journalctl -u zopu-docker-cleanup.service -n 50
# Disk monitor logs (cron → file)
tail -100 /var/log/zopu/disk-monitor.log
# All Zopu syslog identifiers
journalctl -t zopu-daemon -t zopu-agent --since "1 hour ago"
```
## Health checks
```bash
# Manual health check (prints all probes)
/opt/zopu/deploy/zopu-runtime/scripts/health-check.sh
# Quiet mode (exit code only)
/opt/zopu/deploy/zopu-runtime/scripts/health-check.sh --quiet
# Check systemd timer is running
systemctl status zopu-health.timer
systemctl list-timers zopu-health.timer
```
The health check probes:
1. `zopu-daemon` systemd unit is active
2. `zopu-agent` systemd unit is active
3. RivetKit engine port (default 6420) accepts TCP connections
4. Flue agent port (default 3583) accepts TCP connections
5. Docker daemon responds to `docker info`
No HTTP health endpoints are assumed. Flue does not expose one by design
(per Flue docs: "Flue does not add a health endpoint"). RivetKit's health
route is internal to the registry runtime and not documented as
publicly addressable on the engine endpoint.
## Update to commit
```bash
# Update to latest of dogfood/v0 (default)
/opt/zopu/deploy/zopu-runtime/scripts/update.sh
# Update to a specific branch
/opt/zopu/deploy/zopu-runtime/scripts/update.sh dogfood/runtime-deploy
# Update to a specific commit
/opt/zopu/deploy/zopu-runtime/scripts/update.sh abc123def456
```
The update script:
1. Records current HEAD to `.last-deployed-sha`
2. Fetches, resolves branch-or-commit, checks out
3. `bun install`, builds daemon and agent
4. Restarts daemon, waits, restarts agent
5. Runs health check; reports failure and rollback instructions
## Rollback
```bash
# Roll back to the previously deployed commit
/opt/zopu/deploy/zopu-runtime/scripts/rollback.sh
# Roll back to a specific commit
/opt/zopu/deploy/zopu-runtime/scripts/rollback.sh abc123def456
```
Rollback reads `.last-deployed-sha` (written by `update.sh`), checks out that
commit, rebuilds, and restarts services. The pre-rollback SHA is saved to
`.pre-rollback-sha` for re-rollback if needed.
## Docker cleanup
```bash
# Manual cleanup
/opt/zopu/deploy/zopu-runtime/scripts/docker-cleanup.sh
# Check Docker disk usage
docker system df
# Timer runs daily; check its schedule
systemctl list-timers zopu-docker-cleanup.timer
```
Cleanup prunes:
- Stopped containers older than 24 hours
- Dangling (untagged) images
- Unused networks
Named volumes and running containers are never removed.
## Disk-space monitoring
```bash
# Manual check
/opt/zopu/deploy/zopu-runtime/scripts/disk-monitor.sh
# Custom threshold (90%)
/opt/zopu/deploy/zopu-runtime/scripts/disk-monitor.sh --warn-percent 90
```
A cron job runs at 06:00 daily and writes to `/var/log/zopu/disk-monitor.log`.
Default alert threshold is 80%.
## Firewall and private networking
The firewall (`ufw`) is deny-by-default:
- SSH (port 22) is allowed on all interfaces
- All traffic on `tailscale0` is allowed (Tailscale private overlay)
- All other incoming traffic is denied
The RivetKit engine (`:6420`) and Flue agent (`:3583`) ports are **not**
exposed on public interfaces. Reachability options:
1. **Tailscale** (recommended): bootstrap runs `ufw allow in on tailscale0`
so all ports are reachable over the private overlay. Set
`TAILSCALE_AUTHKEY` before running bootstrap to configure automatically.
Other Tailscale-connected machines can reach the agent at
`http://zopu-runtime:3583` and the engine at `http://zopu-runtime:6420`.
2. **Custom private interface**: if you have a non-Tailscale private network
(e.g. a VLAN or wireguard interface), add an explicit rule:
```bash
ufw allow in on eth1 # or your private interface name
```
Do NOT assume direct private IP access works by default — the deny-incoming
policy blocks it until an interface-specific rule is added.
3. **Caddy** (optional): install Caddy and use the annotated Caddyfile in
`caddy/` if you need TLS termination or a public ingress point.
## Environment groups
See [`.env.template`](./.env.template) for the full annotated template. The
eight required groups:
| Group | Variables |
|-------|-----------|
| Convex | `CONVEX_URL`, `CONVEX_SITE_URL`, `SITE_URL` |
| Gitea | `GITEA_URL`, `GITEA_TOKEN` |
| Model gateway | `AGENT_MODEL_*` |
| AgentOS/RivetKit | `RIVET_ENDPOINT` (optional) |
| Zopu agent | `FLUE_DB_TOKEN`, `PORT` |
| Daemon | `DAEMON_ID`, `DAEMON_NAME`, `DAEMON_VERSION`, `DAEMON_HEARTBEAT_MS`, `DAEMON_COMMAND_LEASE_MS` |
| Docker sandbox | group membership (no env vars) |
| Service auth | `AUTH_SECRET` (if needed) |
## What is NOT deployed
- **Web app**: the web frontend is not deployed in this lane.
- **Kubernetes**: no container orchestration.
- **PostgreSQL for Rivet**: the in-process RivetKit engine uses its own
storage; no external PostgreSQL is required.
- **Multi-node coordination**: single-node only.
- **Public administration endpoints**: no admin HTTP surface.
- **Secrets in source**: `.env` is never committed; `.env.template` contains
only placeholder values.
- **Production generated-app hosting**: only the execution plane runs here.
- **Docker-backed Orb sandboxes**: Docker is installed and access is
provisioned, but no Orb sandbox code is wired. This is a boundary
prepared for the Orb lane, not a working feature.
## Reproducibility
The deployment does not require the developer's MacBook to remain online.
Once bootstrap completes and `.env` is filled in:
1. Services run under systemd with `Restart=always`.
2. Logs persist in journald.
3. Health checks run every 60 seconds via systemd timer.
4. Docker cleanup runs daily.
5. Disk usage is monitored daily.
6. Unattended-upgrades handles Debian security patches.

274
deploy/zopu-runtime/bootstrap.sh Executable file
View File

@@ -0,0 +1,274 @@
#!/usr/bin/env bash
#
# bootstrap.sh — One-shot installer for the Zopu single-node execution plane.
#
# Run as root on a fresh Debian 12 host:
#
# bash bootstrap.sh
#
# Environment overrides (set before running):
# ZOPU_REPO_URL — SSH clone URL (default: ssh://git@git.openputer.com:2222/puter/zopu-code.git)
# ZOPU_REPO_BRANCH — branch to deploy (default: dogfood/v0)
# ZOPU_INSTALL_DIR — install path (default: /opt/zopu)
# ZOPU_SERVICE_USER — system user (default: zopu)
# TAILSCALE_AUTHKEY — if set, configure Tailscale
# TAILSCALE_HOSTNAME — Tailscale hostname (default: zopu-runtime)
#
# Installs: Docker Engine, Bun, clones the repo, runs bun install, builds the
# daemon and agent, creates a non-root service user, installs systemd units,
# and configures firewall/Tailscale defaults.
set -euo pipefail
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
REPO_URL="${ZOPU_REPO_URL:-ssh://git@git.openputer.com:2222/puter/zopu-code.git}"
REPO_BRANCH="${ZOPU_REPO_BRANCH:-dogfood/v0}"
INSTALL_DIR="${ZOPU_INSTALL_DIR:-/opt/zopu}"
SERVICE_USER="${ZOPU_SERVICE_USER:-zopu}"
DEPLOY_DIR="${INSTALL_DIR}/deploy/zopu-runtime"
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
log() { echo -e "${GREEN}[bootstrap]${NC} $*"; }
warn() { echo -e "${YELLOW}[bootstrap]${NC} $*"; }
err() { echo -e "${RED}[bootstrap]${NC} $*" >&2; }
# runuser is part of util-linux (essential on Debian) and always available.
# sudo is NOT assumed on minimal Debian installs.
run_as_service() {
runuser -u "$SERVICE_USER" -- "$@"
}
# ---------------------------------------------------------------------------
# Pre-flight
# ---------------------------------------------------------------------------
if [[ "$EUID" -ne 0 ]]; then
err "This script must be run as root."
exit 1
fi
if [[ -f /etc/debian_version ]]; then
log "Detected Debian $(cat /etc/debian_version)"
else
warn "This script targets Debian 12. Other distributions may need manual adjustments."
fi
# ---------------------------------------------------------------------------
# 1. System packages
# ---------------------------------------------------------------------------
log "Updating apt and installing base packages..."
apt-get update -y
apt-get install -y \
ca-certificates \
curl \
gnupg \
ufw \
git \
jq \
netcat-openbsd \
openssh-client \
unattended-upgrades \
rsyslog
# ---------------------------------------------------------------------------
# 2. Docker Engine
# ---------------------------------------------------------------------------
if ! command -v docker &>/dev/null; then
log "Installing Docker Engine..."
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg \
-o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
https://download.docker.com/linux/debian \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
> /etc/apt/sources.list.d/docker.list
apt-get update -y
apt-get install -y \
docker-ce \
docker-ce-cli \
containerd.io \
docker-buildx-plugin \
docker-compose-plugin
else
log "Docker Engine already installed: $(docker --version)"
fi
systemctl enable --now docker
# ---------------------------------------------------------------------------
# 3. Bun
# ---------------------------------------------------------------------------
if ! command -v bun &>/dev/null; then
log "Installing Bun..."
curl -fsSL https://bun.sh/install | bash
ln -sf /root/.bun/bin/bun /usr/local/bin/bun
else
log "Bun already installed: $(bun --version)"
fi
# ---------------------------------------------------------------------------
# 4. Service user
# ---------------------------------------------------------------------------
if ! id "$SERVICE_USER" &>/dev/null; then
log "Creating service user: $SERVICE_USER"
useradd -r -m -d "/home/$SERVICE_USER" -s /bin/bash "$SERVICE_USER"
fi
if ! id -nG "$SERVICE_USER" | grep -qw docker; then
usermod -aG docker "$SERVICE_USER"
log "Added $SERVICE_USER to docker group"
fi
# ---------------------------------------------------------------------------
# 5. Clone or update repository
# ---------------------------------------------------------------------------
if [[ -d "$INSTALL_DIR/.git" ]]; then
log "Repository exists at $INSTALL_DIR, fetching latest..."
cd "$INSTALL_DIR"
git fetch origin
git checkout "$REPO_BRANCH"
git reset --hard "origin/$REPO_BRANCH"
else
log "Cloning $REPO_URL (branch $REPO_BRANCH) into $INSTALL_DIR..."
git clone --branch "$REPO_BRANCH" "$REPO_URL" "$INSTALL_DIR"
cd "$INSTALL_DIR"
fi
# ---------------------------------------------------------------------------
# 5b. Hand ownership of the checkout to the service user
# ---------------------------------------------------------------------------
log "Setting ownership of $INSTALL_DIR to $SERVICE_USER..."
chown -R "$SERVICE_USER":"$SERVICE_USER" "$INSTALL_DIR"
# ---------------------------------------------------------------------------
# 6. Install dependencies and build
# ---------------------------------------------------------------------------
log "Running bun install..."
run_as_service bun install
log "Building daemon binary..."
run_as_service bun run build:daemon
log "Building agent service..."
run_as_service bun run build:agents
# ---------------------------------------------------------------------------
# 7. Environment file
# ---------------------------------------------------------------------------
ENV_FILE="$INSTALL_DIR/.env"
if [[ ! -f "$ENV_FILE" ]]; then
log "Copying .env.template to .env — EDIT BEFORE STARTING SERVICES"
cp "$DEPLOY_DIR/.env.template" "$ENV_FILE"
chown "$SERVICE_USER":"$SERVICE_USER" "$ENV_FILE"
chmod 600 "$ENV_FILE"
warn "Edit $ENV_FILE with real values before starting services."
else
log ".env already exists at $ENV_FILE"
# Ensure correct ownership and permissions on existing .env
chown "$SERVICE_USER":"$SERVICE_USER" "$ENV_FILE"
chmod 600 "$ENV_FILE"
fi
# ---------------------------------------------------------------------------
# 8. Persistent log directory
# ---------------------------------------------------------------------------
LOG_DIR="/var/log/zopu"
mkdir -p "$LOG_DIR"
chown "$SERVICE_USER":"$SERVICE_USER" "$LOG_DIR"
# ---------------------------------------------------------------------------
# 9. Install systemd units (substitute placeholders)
# ---------------------------------------------------------------------------
log "Installing systemd units..."
for unit in zopu-daemon.service zopu-agent.service \
zopu-health.timer zopu-health.service \
zopu-docker-cleanup.timer zopu-docker-cleanup.service; do
SRC="$DEPLOY_DIR/systemd/$unit"
DST="/etc/systemd/system/$unit"
if [[ -f "$SRC" ]]; then
sed \
-e "s|__INSTALL_DIR__|$INSTALL_DIR|g" \
-e "s|__SERVICE_USER__|$SERVICE_USER|g" \
"$SRC" > "$DST"
log " installed $unit"
fi
done
systemctl daemon-reload
# ---------------------------------------------------------------------------
# 10. Firewall (deny-by-default, explicit allow for Tailscale)
# ---------------------------------------------------------------------------
log "Configuring firewall..."
if ! ufw status 2>/dev/null | grep -q "Status: active"; then
ufw allow 22/tcp
ufw default deny incoming
ufw default allow outgoing
# Allow all traffic on the Tailscale interface (if present)
# This lets the agent and engine ports be reached over the private overlay.
ufw allow in on tailscale0 || warn "tailscale0 not present yet; rule will activate when interface appears"
ufw --force enable
log "Firewall enabled: SSH (22) allowed, tailscale0 allowed."
warn "Agent and RivetKit ports are NOT exposed on public interfaces."
warn "Reachability is via Tailscale (tailscale0) only."
else
log "Firewall already active. Ensuring tailscale0 rule..."
ufw allow in on tailscale0 2>/dev/null || true
fi
# ---------------------------------------------------------------------------
# 11. Tailscale (optional)
# ---------------------------------------------------------------------------
if [[ -n "${TAILSCALE_AUTHKEY:-}" ]]; then
log "Installing and configuring Tailscale..."
if ! command -v tailscaled &>/dev/null; then
curl -fsSL https://tailscale.com/install.sh | sh
fi
tailscale up --authkey "$TAILSCALE_AUTHKEY" \
--hostname "${TAILSCALE_HOSTNAME:-zopu-runtime}" \
--accept-routes
log "Tailscale configured: $(tailscale ip -4 2>/dev/null || echo 'waiting for IP')"
# Re-apply the tailscale0 firewall rule now that the interface exists
ufw allow in on tailscale0 2>/dev/null || true
else
warn "TAILSCALE_AUTHKEY not set — skipping Tailscale setup."
warn "Without Tailscale, services are reachable only via localhost."
warn "To use a private network interface, add an explicit UFW rule:"
warn " ufw allow in on <interface>"
fi
# ---------------------------------------------------------------------------
# 12. Disk-space monitoring cron
# /etc/cron.d format REQUIRES a username field.
# ---------------------------------------------------------------------------
log "Installing disk-space monitor (daily at 06:00)..."
CRON_LINE="0 6 * * * ${SERVICE_USER} ${DEPLOY_DIR}/scripts/disk-monitor.sh --warn-percent 80 >> /var/log/zopu/disk-monitor.log 2>&1"
echo "$CRON_LINE" > /etc/cron.d/zopu-disk-monitor
chmod 644 /etc/cron.d/zopu-disk-monitor
# ---------------------------------------------------------------------------
# Done
# ---------------------------------------------------------------------------
log "Bootstrap complete."
echo ""
echo "Next steps:"
echo " 1. Edit $ENV_FILE with real values"
echo " 2. Start services:"
echo " systemctl start zopu-daemon zopu-agent"
echo " 3. Enable health monitoring:"
echo " systemctl enable --now zopu-health.timer zopu-docker-cleanup.timer"
echo " 4. Verify health:"
echo " $DEPLOY_DIR/scripts/health-check.sh"
echo ""
warn "Services are NOT started automatically. Edit .env first."

View File

@@ -0,0 +1,26 @@
# Caddyfile — Minimal reverse proxy for the Zopu agent service.
#
# This is OPTIONAL. The agent service does not need to be publicly exposed
# for the execution plane to function. Use Caddy only when an HTTP endpoint
# is genuinely needed (e.g., a webhook ingress from Convex site functions).
#
# In the default single-node deployment, the agent listens on localhost:3583
# and is reachable over Tailscale. Install Caddy only if you need TLS
# termination or a public ingress point.
#
# --- Private/Tailscale deployment (recommended) ---
#
# Listen only on the Tailscale interface. Replace the hostname with your
# Tailscale machine name or IP.
#
# http://zopu-runtime.tail-xxxx.ts.net {
# reverse_proxy localhost:3583
# }
#
# --- Public deployment with automatic TLS (only if needed) ---
#
# agent.example.invalid {
# reverse_proxy localhost:3583
# }
# Default: do not ship an active Caddyfile. The file is documentation.

View File

@@ -0,0 +1,63 @@
#!/usr/bin/env bash
#
# disk-monitor.sh — Check disk usage and alert when above threshold.
#
# Usage:
# disk-monitor.sh # print usage, warn at 80%
# disk-monitor.sh --warn-percent 90 # custom threshold
#
# Requires GNU coreutils df (standard on Debian). Uses --output for
# deterministic column ordering regardless of locale.
#
# Exit code 0 if under threshold, 1 if at or above.
set -euo pipefail
WARN_PERCENT=80
while [[ $# -gt 0 ]]; do
case "$1" in
--warn-percent)
WARN_PERCENT="$2"
shift 2
;;
*)
echo "Unknown argument: $1" >&2
exit 2
;;
esac
done
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
EXIT_CODE=0
# Partitions to check: install root and /var (Docker data-root is often here)
PARTITIONS="${PARTITIONS:-/ /var}"
for partition in $PARTITIONS; do
if [[ ! -d "$partition" ]]; then
continue
fi
# GNU df --output columns: pcent (Use%), size, avail, target (Mounted on)
read -r USAGE_PCT SIZE AVAIL MOUNT <<< "$(df -h --output=pcent,size,avail,target "$partition" | awk 'NR==2 {gsub(/%/,"",$1); print $1, $2, $3, $4}')"
if [[ -z "${USAGE_PCT:-}" || ! "${USAGE_PCT:-}" =~ ^[0-9]+$ ]]; then
echo " [skip] Could not read usage for ${partition}"
continue
fi
if [[ "$USAGE_PCT" -ge "$WARN_PERCENT" ]]; then
echo -e " ${RED}WARN${NC} ${MOUNT}: ${USAGE_PCT}% used (${AVAIL} avail of ${SIZE}) — threshold ${WARN_PERCENT}%"
EXIT_CODE=1
elif [[ "$USAGE_PCT" -ge $((WARN_PERCENT - 10)) ]]; then
echo -e " ${YELLOW}NOTE${NC} ${MOUNT}: ${USAGE_PCT}% used (${AVAIL} avail of ${SIZE}) — approaching threshold"
else
echo -e " ${GREEN}OK${NC} ${MOUNT}: ${USAGE_PCT}% used (${AVAIL} avail of ${SIZE})"
fi
done
exit "$EXIT_CODE"

View File

@@ -0,0 +1,36 @@
#!/usr/bin/env bash
#
# docker-cleanup.sh — Remove stopped containers, dangling images, and unused networks.
#
# Safe to run via systemd timer (daily). Uses Docker's built-in pruning
# commands with conservative scope.
#
# Does NOT prune volumes. Named volumes may hold durable data and cannot be
# safely auto-pruned in a deployment that mixes stateful workloads. When the
# Orb lane lands with labeled resources, volume pruning can be scoped to
# Orb-managed labels (e.g. --filter label=org.openputer.orb). Until then,
# manage volumes manually.
set -euo pipefail
echo "[docker-cleanup] $(date -u +%Y-%m-%dT%H:%M:%SZ)"
# Remove stopped containers older than 24 hours
echo "[docker-cleanup] Pruning stopped containers (>24h old)..."
docker container prune -f --filter "until=24h"
# Remove dangling images (untagged intermediate layers only)
echo "[docker-cleanup] Pruning dangling images..."
docker image prune -f
# Remove unused networks
echo "[docker-cleanup] Pruning unused networks..."
docker network prune -f
# Volumes are intentionally NOT pruned. See header comment.
# Show remaining disk usage
echo "[docker-cleanup] Docker disk usage:"
docker system df
echo "[docker-cleanup] Done."

View File

@@ -0,0 +1,122 @@
#!/usr/bin/env bash
#
# health-check.sh — Probe the Zopu execution plane.
#
# Checks (TCP/process-level; no assumed HTTP health routes):
# 1. zopu-daemon systemd unit is active
# 2. zopu-agent systemd unit is active
# 3. RivetKit engine TCP port (RIVET_ENDPOINT, default 6420) accepts connections
# 4. Flue agent TCP port (PORT, default 3583) accepts connections
# 5. Docker daemon is reachable
#
# Flue does not expose a health endpoint by design. RivetKit's health route
# is internal to the registry runtime. We use TCP connection checks only.
#
# Usage:
# health-check.sh # print results
# health-check.sh --quiet # suppress output, exit 0 only if all healthy
#
# Exit codes: 0 = all healthy, 1 = one or more unhealthy
set -euo pipefail
QUIET=false
[[ "${1:-}" == "--quiet" ]] && QUIET=true
GREEN='\033[0;32m'
RED='\033[0;31m'
NC='\033[0m'
pass() { $QUIET || echo -e " ${GREEN}PASS${NC} $*"; }
fail() { echo -e " ${RED}FAIL${NC} $*" >&2; FAILURES=$((FAILURES + 1)); }
FAILURES=0
# ---------------------------------------------------------------------------
# Load environment
# ---------------------------------------------------------------------------
ENV_FILE="${ENV_FILE:-/opt/zopu/.env}"
if [[ -f "$ENV_FILE" ]]; then
# shellcheck source=/dev/null
set -a
. "$ENV_FILE"
set +a
fi
RIVET_PORT="6420"
if [[ -n "${RIVET_ENDPOINT:-}" ]]; then
RIVET_PORT=$(echo "$RIVET_ENDPOINT" | sed -n 's|.*://[^:]*:\([0-9]*\).*|\1|p')
[[ -z "$RIVET_PORT" ]] && RIVET_PORT="6420"
fi
AGENT_PORT="${PORT:-3583}"
# ---------------------------------------------------------------------------
# TCP probe helper: works on Debian (nc from netcat-openbsd) and macOS.
# Falls back to bash /dev/tcp if nc is unavailable.
# ---------------------------------------------------------------------------
tcp_probe() {
local host="$1" port="$2"
if command -v nc &>/dev/null; then
nc -z -w 5 "$host" "$port" 2>/dev/null
else
timeout 5 bash -c "echo > /dev/tcp/${host}/${port}" 2>/dev/null
fi
}
# ---------------------------------------------------------------------------
# 1. Daemon systemd unit
# ---------------------------------------------------------------------------
if systemctl is-active --quiet zopu-daemon 2>/dev/null; then
pass "zopu-daemon service is active"
else
fail "zopu-daemon service is not active"
fi
# ---------------------------------------------------------------------------
# 2. Agent systemd unit
# ---------------------------------------------------------------------------
if systemctl is-active --quiet zopu-agent 2>/dev/null; then
pass "zopu-agent service is active"
else
fail "zopu-agent service is not active"
fi
# ---------------------------------------------------------------------------
# 3. RivetKit engine TCP port
# ---------------------------------------------------------------------------
if tcp_probe localhost "$RIVET_PORT"; then
pass "RivetKit engine port ${RIVET_PORT} is accepting connections"
else
fail "RivetKit engine port ${RIVET_PORT} is not accepting connections"
fi
# ---------------------------------------------------------------------------
# 4. Flue agent TCP port
# ---------------------------------------------------------------------------
if tcp_probe localhost "$AGENT_PORT"; then
pass "Flue agent port ${AGENT_PORT} is accepting connections"
else
fail "Flue agent port ${AGENT_PORT} is not accepting connections"
fi
# ---------------------------------------------------------------------------
# 5. Docker daemon
# ---------------------------------------------------------------------------
if docker info &>/dev/null; then
pass "Docker daemon is reachable"
else
fail "Docker daemon is not reachable"
fi
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
$QUIET || echo ""
if [[ "$FAILURES" -eq 0 ]]; then
$QUIET || echo -e "${GREEN}All checks passed.${NC}"
exit 0
else
echo -e "${RED}${FAILURES} check(s) failed.${NC}" >&2
exit 1
fi

View File

@@ -0,0 +1,99 @@
#!/usr/bin/env bash
#
# rollback.sh — Roll back to the previously deployed commit.
#
# Usage:
# rollback.sh # roll back to .last-deployed-sha
# rollback.sh <sha> # roll back to a specific commit
#
# .env is gitignored and is never touched by git operations. It survives
# updates and rollbacks unchanged.
#
# Must be run as root (uses runuser to build as the service user).
set -euo pipefail
INSTALL_DIR="${ZOPU_INSTALL_DIR:-/opt/zopu}"
SERVICE_USER="${ZOPU_SERVICE_USER:-zopu}"
GREEN='\033[0;32m'
RED='\033[0;31m'
NC='\033[0m'
log() { echo -e "${GREEN}[rollback]${NC} $*"; }
err() { echo -e "${RED}[rollback]${NC} $*" >&2; }
run_as_service() {
runuser -u "$SERVICE_USER" -- "$@"
}
cd "$INSTALL_DIR"
# Determine target
ROLLBACK_SHA="${1:-}"
if [[ -z "$ROLLBACK_SHA" ]]; then
LAST_SHA_FILE="${INSTALL_DIR}/.last-deployed-sha"
if [[ ! -f "$LAST_SHA_FILE" ]]; then
err "No previous deployment recorded in ${LAST_SHA_FILE}."
err "Pass a commit SHA explicitly: rollback.sh <sha>"
exit 1
fi
ROLLBACK_SHA=$(cat "$LAST_SHA_FILE")
fi
# Validate commit exists
if ! git rev-parse --verify "${ROLLBACK_SHA}^{commit}" &>/dev/null; then
err "Commit ${ROLLBACK_SHA} does not exist in the local repository."
exit 1
fi
CURRENT_SHA=$(git rev-parse HEAD)
log "Current: ${CURRENT_SHA:0:12}"
log "Rolling back to: ${ROLLBACK_SHA:0:12}"
# Save current state before rolling back (enables re-rollback)
echo "$CURRENT_SHA" > "${INSTALL_DIR}/.pre-rollback-sha"
# Checkout target
git checkout "$ROLLBACK_SHA"
# Restore ownership of the checkout to the service user after git operations
log "Setting ownership of checkout to $SERVICE_USER..."
chown -R "$SERVICE_USER":"$SERVICE_USER" "$INSTALL_DIR"
# Confirm .env is intact and has correct permissions
if [[ -f "${INSTALL_DIR}/.env" ]]; then
log ".env preserved."
chmod 600 "${INSTALL_DIR}/.env"
else
err ".env is missing! Restore it from backup before starting services."
fi
log "Running bun install..."
run_as_service bun install
log "Building daemon..."
run_as_service bun run build:daemon
log "Building agent service..."
run_as_service bun run build:agents
log "Restarting services..."
systemctl restart zopu-daemon
sleep 3
systemctl restart zopu-agent
sleep 5
# Health check
HEALTH_SCRIPT="${INSTALL_DIR}/deploy/zopu-runtime/scripts/health-check.sh"
if [[ -x "$HEALTH_SCRIPT" ]]; then
log "Running health check..."
if "$HEALTH_SCRIPT"; then
log "Rollback complete and healthy."
else
err "Health check failed after rollback!"
err " journalctl -u zopu-daemon -n 50"
err " journalctl -u zopu-agent -n 50"
exit 1
fi
fi

View File

@@ -0,0 +1,108 @@
#!/usr/bin/env bash
#
# update.sh — Update the Zopu runtime to a specific branch or commit.
#
# Usage:
# update.sh # update to latest dogfood/v0
# update.sh dogfood/v0 # update to latest of branch dogfood/v0
# update.sh <commit-sha> # checkout and build a specific commit
#
# The argument is treated as a branch name first; if no matching remote
# tracking branch exists it is treated as a commit SHA.
#
# .env is gitignored and is never touched by git operations. It survives
# updates and rollbacks unchanged.
#
# Must be run as root (uses runuser to build as the service user).
set -euo pipefail
INSTALL_DIR="${ZOPU_INSTALL_DIR:-/opt/zopu}"
SERVICE_USER="${ZOPU_SERVICE_USER:-zopu}"
TARGET="${1:-dogfood/v0}"
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
log() { echo -e "${GREEN}[update]${NC} $*"; }
warn() { echo -e "${YELLOW}[update]${NC} $*"; }
err() { echo -e "${RED}[update]${NC} $*" >&2; }
run_as_service() {
runuser -u "$SERVICE_USER" -- "$@"
}
cd "$INSTALL_DIR"
# Record current commit for rollback
CURRENT_SHA=$(git rev-parse HEAD)
log "Current HEAD: ${CURRENT_SHA:0:12}"
echo "$CURRENT_SHA" > "${INSTALL_DIR}/.last-deployed-sha"
# Fetch all remotes
log "Fetching from origin..."
git fetch origin
# Resolve target: branch first, then commit
if git show-ref --verify --quiet "refs/remotes/origin/${TARGET}"; then
log "Target is a branch: ${TARGET}"
git checkout -B "$TARGET" "origin/${TARGET}"
elif git rev-parse --verify "${TARGET}^{commit}" &>/dev/null; then
log "Target is a commit: ${TARGET:0:12}"
git checkout "$TARGET"
else
err "'${TARGET}' is not a known branch or valid commit."
err "Available branches: $(git branch -r | tr '\n' ' ')"
exit 1
fi
NEW_SHA=$(git rev-parse HEAD)
log "Now at: ${NEW_SHA:0:12}"
# Restore ownership of the checkout to the service user after git operations
log "Setting ownership of checkout to $SERVICE_USER..."
chown -R "$SERVICE_USER":"$SERVICE_USER" "$INSTALL_DIR"
# Confirm .env is intact and has correct permissions
if [[ -f "${INSTALL_DIR}/.env" ]]; then
log ".env preserved."
chmod 600 "${INSTALL_DIR}/.env"
else
err ".env is missing! Restore it from backup before starting services."
fi
# Install and build
log "Running bun install..."
run_as_service bun install
log "Building daemon binary..."
run_as_service bun run build:daemon
log "Building agent service..."
run_as_service bun run build:agents
# Graceful restart: daemon first (owns RivetKit engine), then agent
log "Restarting services..."
systemctl restart zopu-daemon
sleep 3
systemctl restart zopu-agent
sleep 5
# Health check
HEALTH_SCRIPT="${INSTALL_DIR}/deploy/zopu-runtime/scripts/health-check.sh"
if [[ -x "$HEALTH_SCRIPT" ]]; then
log "Running health check..."
if "$HEALTH_SCRIPT"; then
log "Update complete and healthy."
else
err "Health check failed after update!"
err " journalctl -u zopu-daemon -n 50"
err " journalctl -u zopu-agent -n 50"
err "To rollback: ${INSTALL_DIR}/deploy/zopu-runtime/scripts/rollback.sh"
exit 1
fi
else
log "Update complete. Verify manually."
fi

View File

@@ -0,0 +1,39 @@
[Unit]
Description=Zopu Agent Service (Flue Node server)
After=network-online.target zopu-daemon.service
Wants=network-online.target
[Service]
Type=simple
User=__SERVICE_USER__
Group=__SERVICE_USER__
WorkingDirectory=__INSTALL_DIR__/packages/agents
EnvironmentFile=__INSTALL_DIR__/.env
# Flue build output: packages/agents/dist/server.mjs
# Listens on PORT (default 3583 per .env.template).
ExecStart=/usr/local/bin/bun dist/server.mjs
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
SyslogIdentifier=zopu-agent
# Resource limits
MemoryMax=8G
CPUWeight=80
# Security hardening
NoNewPrivileges=true
ProtectSystem=full
ProtectHome=true
PrivateTmp=true
# Docker socket access for Orb sandbox runtime (lives in agent process)
SupplementaryGroups=docker
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,40 @@
[Unit]
Description=Zopu Daemon (Bun/Effect + AgentOS/RivetKit)
After=network-online.target docker.service
Wants=network-online.target
[Service]
Type=simple
User=__SERVICE_USER__
Group=__SERVICE_USER__
WorkingDirectory=__INSTALL_DIR__
EnvironmentFile=__INSTALL_DIR__/.env
# RivetKit in-process engine: envoy mode (serverful).
# registry.start() boots the native Rust sidecar and actor envoy.
# RIVET_ENDPOINT defaults to http://localhost:6420 inside the daemon process.
ExecStart=__INSTALL_DIR__/apps/daemon/dist/code-daemon
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
SyslogIdentifier=zopu-daemon
# Resource limits (12-core, 40 GB host)
MemoryMax=8G
CPUWeight=100
# Security hardening
NoNewPrivileges=true
ProtectSystem=full
ProtectHome=true
PrivateTmp=true
# Docker socket access (for future Orb sandboxes; group membership required)
SupplementaryGroups=docker
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,7 @@
[Unit]
Description=Zopu Docker Cleanup
After=docker.service
[Service]
Type=oneshot
ExecStart=__INSTALL_DIR__/deploy/zopu-runtime/scripts/docker-cleanup.sh

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Zopu Docker Cleanup (daily)
[Timer]
OnCalendar=daily
Persistent=true
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,11 @@
[Unit]
Description=Zopu Health Check
[Service]
Type=oneshot
User=__SERVICE_USER__
EnvironmentFile=__INSTALL_DIR__/.env
# health-check.sh reads ENV_FILE to find the .env to source. Set it to the
# install path so custom install dirs work correctly.
Environment=ENV_FILE=__INSTALL_DIR__/.env
ExecStart=__INSTALL_DIR__/deploy/zopu-runtime/scripts/health-check.sh --quiet

View File

@@ -0,0 +1,10 @@
[Unit]
Description=Zopu Health Check (every 60s)
[Timer]
OnBootSec=30
OnUnitActiveSec=60
AccuracySec=5
[Install]
WantedBy=timers.target

46
docs/SMOKE.md Normal file
View File

@@ -0,0 +1,46 @@
# Zopu Integration Smoke
The smoke harness is a repeatable integration lane for the thin Zopu MVP. It probes the local web app, authenticated Convex control plane, Flue `project-manager` route, AgentOS daemon/workspace contract, and the CPA OpenAI-compatible gateway before it creates any project issue.
The harness does not edit CPA configuration, restart services, print bearer tokens/API keys, or run parallel worker calls. `minimax-m3` is the serial worker-loop model; `glm-5.2` performs the plan and the independent review.
## Setup
Use a disposable/test project that already has a connected repository source and the issue-scoped artifact set. Export a Better Auth/Convex access token for the signed-in user, the local daemon id, and the CPA key/base URL. The CPA URL must include its OpenAI-compatible `/v1` path.
```bash
export ZOPU_SMOKE_ACCESS_TOKEN='...'
export ZOPU_SMOKE_PROJECT_ID='...'
export ZOPU_SMOKE_DAEMON_ID='local-macbook'
export ZOPU_SMOKE_CPA_BASE_URL='https://ai.example.invalid/v1'
export ZOPU_SMOKE_CPA_API_KEY='...'
```
The harness reuses `AGENT_MODEL_*`, `CONVEX_URL`, `DAEMON_ID`, `SITE_URL`, and `VITE_FLUE_URL` when their `ZOPU_SMOKE_*` equivalents are absent. It never rewrites those values. Override the tiny feature request with `ZOPU_SMOKE_FEATURE_REQUEST` when the disposable project needs a more specific target.
## Commands
Run capability probes only:
```bash
bun run smoke:zopu
```
Drive one issue through the project loop and review it:
```bash
bun run smoke:zopu --run --report /tmp/zopu-smoke.json
```
The command prints one of these stable markers:
- `ZOPU_SMOKE_PREFLIGHT_PASSED` means all probes passed and no mutation was requested.
- `ZOPU_SMOKE_COMPLETED` means the issue was created, the worker reported completion, the durable issue reached `completed`, and GLM approved the result.
- `ZOPU_SMOKE_CONTRACT_BLOCKED` means a required capability or configuration is missing; the JSON report names the exact check and next action.
- `ZOPU_SMOKE_FAILED` means runtime execution started but did not satisfy the worker/reviewer contract.
Exit codes are `0` for a passing preflight or completed run, `2` for a contract block, and `1` for a runtime failure. Reports contain only endpoint labels, statuses, counts, ids, and sanitized failure text; model transcripts and credentials are intentionally omitted.
## Current lane boundary
The smoke intentionally stops before mutation when the project source, issue-scoped AgentOS artifacts, or online daemon contract is absent. Those checks are the integration boundary for the project-loop and daemon lanes; do not paper over them in this harness or make a live gateway change to satisfy a failed probe.

View File

@@ -40,6 +40,7 @@
"check": "ultracite check",
"lint": "vp lint",
"format": "vp fmt",
"smoke:zopu": "bun scripts/zopu-smoke.ts",
"staged": "vp staged",
"hooks:setup": "vp config",
"dev:native": "vp run --filter native dev",
@@ -58,20 +59,27 @@
"build:agents": "vp run --filter @code/agents build",
"docs:update": "bun run scripts/update-docs.ts",
"subtree": "bun run scripts/subtree.ts",
"fix": "ultracite fix"
"fix": "ultracite fix",
"orb:proof": "bun run scripts/orb-proof.ts"
},
"dependencies": {},
"devDependencies": {
"@code/backend": "workspace:*",
"@code/config": "workspace:*",
"@code/env": "workspace:*",
"@code/primitives": "workspace:*",
"@flue/sdk": "1.0.0-beta.9",
"@types/bun": "catalog:",
"@types/node": "^22.13.14",
"convex": "catalog:",
"convex-test": "catalog:",
"effect": "catalog:",
"oxfmt": "latest",
"oxlint": "latest",
"rolldown": "1.1.4",
"typescript": "catalog:",
"ultracite": "7.9.3",
"vite-plus": "0.2.2",
"convex-test": "catalog:",
"vitest": "catalog:"
},
"overrides": {

View File

@@ -12,18 +12,25 @@
"run:zopu": "bun --env-file=../../.env flue run zopu"
},
"dependencies": {
"@agentos-software/opencode": "0.2.7",
"@code/backend": "workspace:*",
"@code/env": "workspace:*",
"@code/primitives": "workspace:*",
"@flue/runtime": "latest",
"@rivet-dev/agentos-core": "catalog:",
"convex": "catalog:",
"hono": "4.12.30",
"dockerode": "^5.0.1",
"effect": "catalog:",
"get-port": "^7.2.0",
"hono": "4.12.31",
"sandbox-agent": "0.4.2",
"valibot": "^1.4.2"
},
"devDependencies": {
"@code/config": "workspace:*",
"@flue/cli": "latest",
"@types/bun": "catalog:",
"@types/dockerode": "^4.0.1",
"typescript": "catalog:"
}
}

View File

@@ -0,0 +1,132 @@
import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { parseAgentEnv } from "@code/env/agent";
import { defineAction } from "@flue/runtime";
import { ConvexHttpClient } from "convex/browser";
import * as v from "valibot";
import {
createGiteaHttpTransport,
runPostRunGiteaLifecycle,
} from "../git/gitea";
const pullRequestOutput = v.object({
baseBranch: v.string(),
branch: v.string(),
number: v.number(),
status: v.picklist(["open", "closed", "merged"]),
url: v.string(),
});
const output = v.object({
baseBranch: v.string(),
branch: v.string(),
commitSha: v.optional(v.string()),
pullRequest: v.optional(pullRequestOutput),
status: v.picklist([
"no_changes",
"committed",
"pushed",
"pull_request_open",
"failed",
]),
});
export const createFinalizeGiteaLifecycle = (
issueAgentId: string,
runtimeEnv: Record<string, string | undefined>
) => {
const env = parseAgentEnv(runtimeEnv);
const client = new ConvexHttpClient(env.CONVEX_URL);
const issueId = issueAgentId as Id<"projectIssues">;
return defineAction({
description:
"After verification, inspect the issue workspace, commit and push its work branch, and create an open Gitea pull request. Never merge.",
input: v.object({
commitMessage: v.optional(v.pipe(v.string(), v.maxLength(120))),
verified: v.boolean(),
}),
name: "finalize_gitea_lifecycle",
output,
async run({ harness, input, log }) {
const context = await client.query(api.agentWorkspace.get, {
issueId,
token: env.FLUE_DB_TOKEN,
});
if (!context.source) {
throw new Error("Project has no Git source configured");
}
if (!context.source.defaultBranch) {
throw new Error("Project Git source has no default branch");
}
if (!env.GITEA_TOKEN) {
throw new Error(
"GITEA_TOKEN is required for Gitea pull request creation"
);
}
const runner = {
async run(
command: string,
options?: { cwd?: string; env?: Record<string, string> }
) {
return await harness.shell(command, options);
},
};
const transport = createGiteaHttpTransport({
baseUrl: env.GITEA_URL,
token: env.GITEA_TOKEN,
});
try {
const result = await runPostRunGiteaLifecycle({
baseBranch: context.source.defaultBranch,
body: `Verified changes for project issue #${context.issue.number}. Merge remains a manual review action.`,
commitMessage: input.commitMessage,
issueNumber: context.issue.number,
issueTitle: context.issue.title,
repositoryPath: context.source.repositoryPath,
runner,
title: `Issue #${context.issue.number}: ${context.issue.title}`,
transport,
verification: input.verified ? "passed" : "failed",
workspace: "/workspace/repository",
});
await client.mutation(api.agentWorkspace.recordGiteaLifecycle, {
baseBranch: result.baseBranch,
branch: result.branch,
...(result.commitSha === undefined
? {}
: { commitSha: result.commitSha }),
issueId,
...(result.pullRequest === undefined
? {}
: { pullRequest: result.pullRequest }),
status: result.status,
token: env.FLUE_DB_TOKEN,
});
log.info("Gitea lifecycle completed", {
branch: result.branch,
status: result.status,
});
return result;
} catch (error) {
const branchResult = await runner.run("git branch --show-current", {
cwd: "/workspace/repository",
});
const branch = branchResult.stdout.trim() || "unknown";
const message = error instanceof Error ? error.message : String(error);
await client.mutation(api.agentWorkspace.recordGiteaLifecycle, {
baseBranch: context.source.defaultBranch,
branch,
error: message,
issueId,
status: "failed",
token: env.FLUE_DB_TOKEN,
});
throw error;
}
},
});
};

View File

@@ -2,6 +2,7 @@ import { parseAgentEnv } from "@code/env/agent";
import { defineAgent } from "@flue/runtime";
import type { AgentRouteHandler } from "@flue/runtime";
import { createFinalizeGiteaLifecycle } from "../actions/finalize-gitea-lifecycle";
import { agentOs } from "../sandboxes/agent-os";
import { createProjectTools } from "../tools/project";
@@ -14,15 +15,16 @@ export default defineAgent(({ env, id }) => {
const { AGENT_MODEL_NAME, AGENT_MODEL_PROVIDER } = parseAgentEnv(env);
return {
cwd: "/workspace",
actions: [createFinalizeGiteaLifecycle(id, env)],
cwd: "/workspace/repository",
description,
instructions: `You are the issue-scoped project manager and coding agent.
Start every run by calling lookup_issue_context, reading issue.md, project.md, business.md, design.md, agent.md, work.md, steps.md, artifacts.md, signals.md, agent-manager.md, context.md, and card.md from the AgentOS workspace. Call report_work_status with working before editing.
Start every run by calling lookup_issue_context, reading /workspace/control/issue.md, the canonical context files under /workspace/control/context, and the operational artifacts under /workspace/control/artifacts. Call report_work_status with working before editing.
Work only on the bound issue. Inspect existing files before changing them. If a missing product decision makes safe progress impossible, publish the current work.md and steps.md, report needs-input, then ask one focused question. Otherwise implement the complete issue, run the relevant command or scenario in AgentOS, and preserve command evidence.
Work only on the bound issue. The repository checkout is the current working directory; inspect existing source files before changing them. If a missing product decision makes safe progress impossible, publish the current work.md and steps.md, report needs-input, then ask one focused question. Otherwise implement the complete issue, run the relevant install, edit, test, or scenario command in AgentOS, and preserve command evidence.
Before finishing, update the local work.md, steps.md, artifacts.md, and context.md files and publish each changed canonical artifact with publish_project_artifact. Report completed only after verification. On an unrecoverable error, report failed with the exact blocker. Never claim a repository change or command result you did not observe.`,
Before finishing, update the operational files under /workspace/control/artifacts and publish each changed canonical artifact with publish_project_artifact. After verification, call finalize_gitea_lifecycle with verified=true; it owns the post-run Git/Gitea lifecycle and never merges. Report completed only after that action succeeds or reports no_changes. On an unrecoverable error, report failed with the exact blocker. Never claim a repository change or command result you did not observe.`,
model: `${AGENT_MODEL_PROVIDER}/${AGENT_MODEL_NAME}`,
sandbox: agentOs(env),
tools: createProjectTools(id, env),

View File

@@ -8,24 +8,70 @@ import { local } from "@flue/runtime/node";
import paseo from "../skills/paseo/SKILL.md" with { type: "skill" };
import { paseoCli } from "../tools/paseo";
import { createSignalRoutingTools } from "../tools/signals";
const repositoryRoot = path.resolve(process.cwd(), "../..");
const INSTRUCTIONS = `You are Zopu, the global planning and work-routing agent for the Zopu Work OS.
## Your role
You listen to user messages in the persistent global conversation and decide whether they contain actionable work. The control plane stores every user message as exact evidence before you process it; you never supply or rewrite raw message text.
## Work routing loop
When a user sends a message, follow this decision flow:
1. **Assess actionability.** Does the message contain a concrete problem, request, blocker, opportunity, or decision that warrants a work unit? Greetings, questions about the system, casual conversation, and exploration do NOT create work. If the message is casual conversation, respond naturally and do nothing else.
2. **Identify project context.** If the message references a project, call list_projects to find it. If the user has one project, use it. If the project is ambiguous, ask one focused clarification.
3. **Create a Signal (only when actionable).** When the message is actionable:
a. Call list_signal_evidence to see the exact admitted user messages.
b. Select the message IDs that compose the problem statement.
c. Call create_signal with a structured problem statement (title, summary, desiredOutcome, constraints). The problem statement must faithfully represent the user's own intent. Do not invent scope they did not mention.
d. Include the projectId when the project is known.
4. **Route the Signal.** After creating the Signal (or if one already exists):
a. Call list_active_issues for the relevant project.
b. Compare the Signal's problem to existing issues.
c. If the Signal clearly relates to an existing active issue, call attach_signal_to_issue.
d. If the Signal is new work that does not match any existing issue, call create_issue_from_signal.
e. If genuinely uncertain whether to attach or create, ask one focused question.
5. **Explain the outcome.** Tell the user clearly what happened:
- "Created a Signal: [title] and attached it to issue #[number]: [issue title]."
- "Created a Signal: [title] and opened new issue #[number]: [issue title]."
- Include the problem statement title so the user can verify accuracy.
6. **Optionally begin work.** Only when the user explicitly asks to start or work on the issue, call begin_issue. Do not begin work automatically.
## Rules
- Never supply or rewrite the raw source message text. The control plane copies it server-side.
- Never create work from casual chat.
- Ask at most one focused clarification when genuinely ambiguous.
- Preserve project and organization scope at all times.
- Repeated delivery of the same message must not create duplicate Signals or attachments. The backend is idempotent.
- Never claim you created a Signal until the tool call returns successfully.
- Do not create Candidate Work, Work Units, or Runs directly. Those are downstream concerns.
- When the user asks about existing work, use list_active_issues and list_recent_signals to answer.`;
export { authenticatedAgentRoute as route } from "../auth";
export default defineAgent(({ env }) => {
export default defineAgent(({ env, id }) => {
const { AGENT_MODEL_NAME, AGENT_MODEL_PROVIDER } = parseAgentEnv(env);
return {
cwd: repositoryRoot,
description: "A simple conversational agent with persistent history.",
instructions:
"You are Zopu, the global planning agent. Help the user clarify and plan work. User messages are durably captured as Signal evidence by the control plane; do not claim that you created a Signal until secure tool execution is wired.",
description:
"Project-scoped work-routing agent that turns conversation into Signals and routes them to issues.",
instructions: INSTRUCTIONS,
model: `${AGENT_MODEL_PROVIDER}/${AGENT_MODEL_NAME}`,
// sandbox: agentos(),
sandbox: local({ cwd: repositoryRoot }),
skills: [paseo],
tools: [paseoCli],
tools: [paseoCli, ...createSignalRoutingTools(id, env)],
};
});

View File

@@ -3,6 +3,8 @@ import { registerProvider } from "@flue/runtime";
import { flue } from "@flue/runtime/routing";
import { Hono } from "hono";
import { projectRequestRoute } from "./project-request";
const agentEnv = parseAgentEnv(process.env);
registerProvider(agentEnv.AGENT_MODEL_PROVIDER, {
@@ -18,6 +20,7 @@ registerProvider(agentEnv.AGENT_MODEL_PROVIDER, {
});
const app = new Hono();
app.post("/project-requests", projectRequestRoute);
app.route("/", flue());
export default app;

View File

@@ -98,7 +98,9 @@ const { CONVEX_URL } = parseAgentEnv(process.env);
* caller's JWT is set on this instance only and discarded when the request
* ends. We never mutate auth on a shared/global Convex client.
*/
const createAuthenticatedClient = (accessToken: string): ConvexHttpClient => {
export const createAuthenticatedClient = (
accessToken: string
): ConvexHttpClient => {
const client = new ConvexHttpClient(CONVEX_URL);
client.setAuth(accessToken);
return client;
@@ -120,7 +122,7 @@ interface HeaderSource {
* Extract and validate the Bearer access token from the request. Returns null
* when absent or malformed so the caller can produce a clean 401.
*/
const extractBearerToken = (request: HeaderSource): string | null => {
export const extractBearerToken = (request: HeaderSource): string | null => {
const header = request.headers.get("authorization");
if (!header) {
return null;

View File

@@ -0,0 +1,155 @@
import { describe, expect, test } from "vitest";
import { createGiteaHttpTransport, runPostRunGiteaLifecycle } from "./gitea";
import type { GitCommandRunner, GiteaTransport } from "./gitea";
const repository = {
cloneUrl: "https://git.openputer.com/puter/zopu-code.git",
defaultBranch: "main",
htmlUrl: "https://git.openputer.com/puter/zopu-code",
name: "zopu-code",
sshUrl: "ssh://git@git.openputer.com:2222/puter/zopu-code.git",
};
const makeRunner = (): GitCommandRunner & { readonly commands: string[] } => {
const commands: string[] = [];
let statusCalls = 0;
return {
commands,
run(command) {
commands.push(command);
if (command === "git branch --show-current") {
return Promise.resolve({
exitCode: 0,
stderr: "",
stdout: "work/issue-5\n",
});
}
if (command === "git status --porcelain=v1") {
statusCalls += 1;
return Promise.resolve({
exitCode: 0,
stderr: "",
stdout: statusCalls === 1 ? " M src/gitea.ts\n" : "",
});
}
if (command === "git diff --no-ext-diff --binary") {
return Promise.resolve({
exitCode: 0,
stderr: "",
stdout: "diff --git ...",
});
}
if (command === "git rev-parse HEAD") {
return Promise.resolve({
exitCode: 0,
stderr: "",
stdout: "abc123\n",
});
}
if (command.includes("git status --porcelain=v1")) {
return Promise.resolve({ exitCode: 0, stderr: "", stdout: "" });
}
return Promise.resolve({ exitCode: 0, stderr: "", stdout: "" });
},
};
};
describe("Gitea lifecycle adapter", () => {
test("inspects, commits, pushes, and creates an open PR through mocked boundaries", async () => {
const runner = makeRunner();
const pullRequests: unknown[] = [];
const transport: GiteaTransport = {
createPullRequest(input) {
pullRequests.push(input);
return Promise.resolve({
base: { ref: input.base },
head: { ref: input.head },
htmlUrl: "https://git.openputer.com/puter/zopu-code/pulls/5",
number: 5,
state: "open",
});
},
getRepository() {
return Promise.resolve(repository);
},
};
const result = await runPostRunGiteaLifecycle({
baseBranch: "main",
body: "Generated by the verified project run.",
issueNumber: 5,
issueTitle: "Publish verified changes",
repositoryPath: "puter/zopu-code",
runner,
transport,
verification: "passed",
workspace: "/workspace",
});
expect(result).toMatchObject({
branch: "work/issue-5",
commitSha: "abc123",
pullRequest: {
baseBranch: "main",
branch: "work/issue-5",
number: 5,
status: "open",
},
status: "pull_request_open",
});
expect(runner.commands).toEqual([
"git branch --show-current",
"git status --porcelain=v1",
"git diff --no-ext-diff --binary",
"git add --all && git commit -m 'feat(issue-5): Publish verified changes'",
"git rev-parse HEAD",
"git status --porcelain=v1",
"git push 'ssh://git@git.openputer.com:2222/puter/zopu-code.git' HEAD:'work/issue-5'",
]);
expect(pullRequests).toHaveLength(1);
});
test("mocks the HTTP Gitea boundary without calling the network", async () => {
const requests: Request[] = [];
const transport = createGiteaHttpTransport({
baseUrl: "https://git.openputer.com",
fetch: (input, init) => {
const request = new Request(String(input), init);
requests.push(request);
return Promise.resolve(
new Response(
request.method === "GET"
? JSON.stringify(repository)
: JSON.stringify({
base: { ref: "main" },
head: { ref: "work/issue-5" },
html_url: "https://git.openputer.com/puter/zopu-code/pulls/5",
number: 5,
state: "open",
}),
{ headers: { "content-type": "application/json" } }
)
);
},
token: "scoped-test-token",
});
await transport.getRepository("puter/zopu-code");
await transport.createPullRequest({
base: "main",
body: "body",
head: "work/issue-5",
repositoryPath: "puter/zopu-code",
title: "title",
});
expect(requests).toHaveLength(2);
expect(requests[0]?.url).toBe(
"https://git.openputer.com/api/v1/repos/puter/zopu-code"
);
expect(requests[1]?.headers.get("authorization")).toBe(
"token scoped-test-token"
);
});
});

View File

@@ -0,0 +1,307 @@
import {
decideGitLifecycle,
GitLifecycleError,
inspectGitWorkspace,
makeCommitMessage,
validatePullRequestMetadata,
} from "@code/primitives/git";
import type {
GitLifecycleDecisionResult,
GitLifecycleResult,
GitWorkspaceInspection,
} from "@code/primitives/git";
import { Effect } from "effect";
export interface GitCommandResult {
readonly exitCode: number;
readonly stderr: string;
readonly stdout: string;
}
export interface GitCommandRunner {
readonly run: (
command: string,
options?: {
readonly cwd?: string;
readonly env?: Record<string, string>;
}
) => Promise<GitCommandResult>;
}
export interface GiteaRepository {
readonly cloneUrl: string;
readonly defaultBranch: string;
readonly htmlUrl: string;
readonly name: string;
readonly sshUrl: string;
}
export interface GiteaPullRequest {
readonly base: { readonly ref: string };
readonly head: { readonly ref: string };
readonly htmlUrl: string;
readonly number: number;
readonly state: "open" | "closed" | "merged";
}
export interface GiteaTransport {
readonly createPullRequest: (input: {
readonly base: string;
readonly body: string;
readonly head: string;
readonly repositoryPath: string;
readonly title: string;
}) => Promise<GiteaPullRequest>;
readonly getRepository: (repositoryPath: string) => Promise<GiteaRepository>;
}
export interface GiteaHttpTransportOptions {
readonly baseUrl: string;
readonly fetch?: (
input: string | URL | Request,
init?: RequestInit
) => Promise<Response>;
readonly token: string;
}
const shellQuote = (value: string): string =>
`'${value.replaceAll("'", `'"'"'`)}'`;
const providerFailure = (
message: string,
reason: "CommandFailed" | "RemoteRejected"
) => new GitLifecycleError({ message, reason });
const runChecked = async (
runner: GitCommandRunner,
command: string,
options?: Parameters<GitCommandRunner["run"]>[1]
): Promise<string> => {
let result: GitCommandResult;
try {
result = await runner.run(command, options);
} catch (error) {
throw new GitLifecycleError({
message: `Git command could not run: ${
error instanceof Error ? error.message : String(error)
}`,
reason: "CommandFailed",
});
}
if (result.exitCode !== 0) {
throw providerFailure(
result.stderr.trim() || `Git command failed: ${command}`,
command.startsWith("git push") ? "RemoteRejected" : "CommandFailed"
);
}
return result.stdout;
};
const toHttpPath = (repositoryPath: string): string =>
repositoryPath
.split("/")
.map((segment) => encodeURIComponent(segment))
.join("/");
const requestJson = async <T>(
options: GiteaHttpTransportOptions,
path: string,
init?: RequestInit
): Promise<T> => {
let response: Response;
try {
response = await (options.fetch ?? globalThis.fetch)(
`${options.baseUrl.replace(/\/$/u, "")}${path}`,
{
...init,
headers: {
Accept: "application/json",
Authorization: `token ${options.token}`,
"Content-Type": "application/json",
...init?.headers,
},
}
);
} catch (error) {
throw new GitLifecycleError({
message: `Gitea request could not run: ${
error instanceof Error ? error.message : String(error)
}`,
reason: "RequestFailed",
});
}
if (!response.ok) {
const detail = await response.text();
throw new GitLifecycleError({
message: `Gitea request failed (${response.status}): ${detail}`,
reason:
response.status === 401 || response.status === 403
? "Authentication"
: "RequestFailed",
});
}
return (await response.json()) as T;
};
export const createGiteaHttpTransport = (
options: GiteaHttpTransportOptions
): GiteaTransport => ({
async createPullRequest(input) {
const response = await requestJson<{
base: { ref: string };
head: { ref: string };
html_url: string;
number: number;
state: "open" | "closed" | "merged";
}>(options, `/api/v1/repos/${toHttpPath(input.repositoryPath)}/pulls`, {
body: JSON.stringify({
base: input.base,
body: input.body,
head: input.head,
title: input.title,
}),
method: "POST",
});
return {
base: response.base,
head: response.head,
htmlUrl: response.html_url,
number: response.number,
state: response.state,
};
},
async getRepository(repositoryPath) {
const response = await requestJson<{
clone_url: string;
default_branch: string;
html_url: string;
name: string;
ssh_url: string;
}>(options, `/api/v1/repos/${toHttpPath(repositoryPath)}`);
return {
cloneUrl: response.clone_url,
defaultBranch: response.default_branch,
htmlUrl: response.html_url,
name: response.name,
sshUrl: response.ssh_url,
};
},
});
export interface RunPostRunGiteaLifecycleInput {
readonly baseBranch: string;
readonly body: string;
readonly commitMessage?: string;
readonly issueNumber: number;
readonly issueTitle: string;
readonly repositoryPath: string;
readonly runner: GitCommandRunner;
readonly title?: string;
readonly transport: GiteaTransport;
readonly verification: "passed" | "failed" | "not-run";
readonly workspace: string;
}
const inspect = async (
input: RunPostRunGiteaLifecycleInput
): Promise<GitWorkspaceInspection> => {
const [branch, status, diff] = await Promise.all([
runChecked(input.runner, "git branch --show-current", {
cwd: input.workspace,
}),
runChecked(input.runner, "git status --porcelain=v1", {
cwd: input.workspace,
}),
runChecked(input.runner, "git diff --no-ext-diff --binary", {
cwd: input.workspace,
}),
]);
return await Effect.runPromise(
inspectGitWorkspace({
branch: branch.trim(),
diff,
status,
})
);
};
export const runPostRunGiteaLifecycle = async (
input: RunPostRunGiteaLifecycleInput
): Promise<GitLifecycleResult> => {
const inspection = await inspect(input);
const initialDecision = (await Effect.runPromise(
decideGitLifecycle({
baseBranch: input.baseBranch,
inspection,
pushed: false,
verification: input.verification,
})
)) as GitLifecycleDecisionResult;
if (initialDecision.decision === "finish") {
return {
baseBranch: input.baseBranch as GitLifecycleResult["baseBranch"],
branch: inspection.branch,
commitSha: undefined,
pullRequest: undefined,
status: "no_changes",
};
}
const repository = await input.transport.getRepository(input.repositoryPath);
await runChecked(
input.runner,
`git add --all && git commit -m ${shellQuote(
input.commitMessage ??
makeCommitMessage(input.issueNumber, input.issueTitle)
)}`,
{ cwd: input.workspace }
);
const commitOutput = await runChecked(input.runner, "git rev-parse HEAD", {
cwd: input.workspace,
});
const commitSha = commitOutput.trim();
const cleanStatus = await runChecked(
input.runner,
"git status --porcelain=v1",
{ cwd: input.workspace }
);
if (cleanStatus.trim().length > 0) {
throw providerFailure(
"Git workspace remained dirty after commit",
"CommandFailed"
);
}
await runChecked(
input.runner,
`git push ${shellQuote(repository.sshUrl)} HEAD:${shellQuote(inspection.branch)}`,
{ cwd: input.workspace }
);
const pullRequest = await input.transport.createPullRequest({
base: input.baseBranch,
body: input.body,
head: inspection.branch,
repositoryPath: input.repositoryPath,
title: input.title ?? `Issue #${input.issueNumber}: ${input.issueTitle}`,
});
const metadata = await Effect.runPromise(
validatePullRequestMetadata({
baseBranch: pullRequest.base.ref,
branch: pullRequest.head.ref,
number: pullRequest.number,
status: pullRequest.state,
url: pullRequest.htmlUrl,
})
);
return {
baseBranch: input.baseBranch as GitLifecycleResult["baseBranch"],
branch: inspection.branch,
commitSha,
pullRequest: metadata,
status: "pull_request_open",
};
};

View File

@@ -0,0 +1,105 @@
# Orb Runtime
One logical execution workspace for one ProjectIssue run. An Orb bundles an AgentOS actor, an OpenCode harness session, a Docker sandbox, a mounted repository workspace, project context, model-gateway configuration, process/log handling, normalized execution events, and a cleanup lifecycle.
## Architecture
```
┌──────────────────────────────────────────────────┐
│ OrbRuntime │
│ creates OrbHandle instances │
├──────────────────────────────────────────────────┤
│ OrbHandle │
│ ┌─────────────┐ ┌────────────────────────┐ │
│ │ AgentOS VM │ │ SandboxAgent + Docker │ │
│ │ (OpenCode │◄──►│ (sandbox-agent server │ │
│ │ ACP agent) │ │ in a Docker container)│ │
│ └─────────────┘ └────────────────────────┘ │
│ │ │ │
│ session events runProcess / │
│ → normalized createProcess │
│ OrbEvent │
└──────────────────────────────────────────────────┘
```
The AgentOS VM runs the OpenCode ACP adapter (lightweight agent loop, session management, durable identity). Heavy execution — package installs, test suites, builds — runs inside a Docker container hosting a sandbox-agent server. The `DockerSandboxProvider` calls `SandboxAgent.start({ sandbox: docker(...) })`, which starts the server in a Docker container with a dynamically-mapped host port and returns a `SandboxAgent` client whose `baseUrl` both the main process and the AgentOS sidecar subprocess reach over `127.0.0.1`. The sandbox filesystem is mounted into the VM at `/mnt/sandbox`.
## Domain model
| Type | Description |
| --- | --- |
| `OrbId` | Branded identifier for one Orb |
| `OrbRunId` | Branded identifier for one run attempt |
| `OrbSessionId` | Branded identifier for one OpenCode session |
| `OrbIdentity` | `{ projectId, runId, workUnitId }` — derives the actor key |
| `OrbState` | `creating → prepared → running → needs-input → completed/failed/cancelled → disposed` |
| `RunState` | `queued → provisioning → preparing → running → verifying → succeeded/failed/cancelled` |
| `OrbEvent` | Normalized, secret-free execution event |
Orb state and run state are separate state machines. The VM/sandbox lease state is never conflated with the product work-unit state.
## Tagged errors
| Error | Reasons |
| --- | --- |
| `OrbStateError` | `InvalidTransition`, `AlreadyDisposed`, `NotRunning` |
| `OrbSandboxError` | `DockerUnavailable`, `ContainerStart`, `CommandFailed`, `ContainerCleanup` |
| `OrbSessionError` | `OpenSession`, `PromptFailed`, `AgentNotInstalled`, `SessionNotFound` |
| `OrbConfigurationError` | `MissingGateway`, `MissingIdentity`, `InvalidModelConfig` |
## Environment variables
| Variable | Required | Description |
| --- | --- | --- |
| `ORB_PROOF` | Proof only | Set to `1` to run the proof fixture |
| `ORB_GATEWAY_API_KEY` | Proof only | Model gateway API key |
| `ORB_GATEWAY_BASE_URL` | Proof only | Model gateway base URL (OpenAI-compatible `/v1`) |
| `ORB_GATEWAY_MODEL` | Proof only | Model name |
| `ORB_GATEWAY_PROVIDER` | Proof only | Provider identifier |
| `ORB_DOCKER_IMAGE` | Optional | Docker image (default: `oven/bun:1.3-debian`) |
| `ORB_DOCKER_WORKSPACE` | Optional | Host workspace root (default: `/tmp/orb-workspaces`) |
| `RIVET_ENDPOINT` | Optional | AgentOS/RivetKit sidecar endpoint |
No permanent provider credentials are stored in project files. API keys are injected at runtime via the OpenCode config written to the VM filesystem, and all event output is passed through `redactSecrets`.
## Docker requirements
- Docker daemon running and accessible via the Docker socket (`/var/run/docker.sock`)
- The sandbox uses `rivetdev/sandbox-agent` as its Docker image by default
- The `sandbox-agent/docker` provider creates containers with `AutoRemove` and a dynamically allocated host port
- Writable bind mount is the project workspace only (mounted at `/home/sandbox` inside the container)
- The AgentOS sidecar subprocess reaches the sandbox-agent server over `127.0.0.1:<hostPort>`
## Local startup
```bash
# Run the proof fixture
ORB_PROOF=1 \
ORB_GATEWAY_API_KEY=your-key \
ORB_GATEWAY_BASE_URL=https://ai.example.com/v1 \
ORB_GATEWAY_MODEL=glm-5.2 \
ORB_GATEWAY_PROVIDER=cheaptricks \
bun run scripts/orb-proof.ts
```
Stable markers: `ORB_PROOF_PASSED` (exit 0), `ORB_PROOF_BLOCKED` (exit 2), `ORB_PROOF_FAILED` (exit 1).
## Filesystem layout
```
SandboxAgent container (/home/sandbox = host bind mount)
/home/sandbox/repository/ — project checkout
/home/sandbox/control/ — issue + context files
AgentOS VM (host process)
/mnt/sandbox/ — sandbox mount (via SandboxAgent baseUrl)
/mnt/sandbox/repository/ — repo (via sandbox)
/root/.config/opencode/ — OpenCode config (chmod 600)
```
## Current limitations
- No automatic merge or production deployment capability.
- No multi-region support.
- Interactive PTY sessions are not wired through the sandbox agent.
- The model turn stage depends on a reachable OpenAI-compatible gateway; if the gateway is unreachable the proof reports BLOCKED at that stage but still passes Docker and AgentOS/OpenCode.

View File

@@ -0,0 +1,109 @@
import { Schema } from "effect";
// ---------------------------------------------------------------------------
// Context pack — assembled from ProjectIssue, evidence, project docs, artifacts
// ---------------------------------------------------------------------------
export const ContextFile = Schema.Struct({
content: Schema.String,
label: Schema.String,
});
export type ContextFile = typeof ContextFile.Type;
export const ContextPackInput = Schema.Struct({
artifacts: Schema.Array(
Schema.Struct({
content: Schema.String,
path: Schema.String,
})
),
contextFiles: Schema.Array(ContextFile),
evidence: Schema.Array(
Schema.Struct({
content: Schema.String,
source: Schema.String,
})
),
issueBody: Schema.String,
issueNumber: Schema.Int,
issueTitle: Schema.String,
repositoryMetadata: Schema.Struct({
baseBranch: Schema.String,
repositoryName: Schema.String,
repositoryUrl: Schema.String,
}),
});
export type ContextPackInput = typeof ContextPackInput.Type;
const section = (heading: string, lines: readonly string[]): string => {
if (lines.length === 0) {
return "";
}
return `## ${heading}\n\n${lines.join("\n")}`;
};
const OPERATIONAL_INSTRUCTIONS = `## Operational Instructions
You are working inside an isolated sandbox on a dedicated work branch. The repository checkout is your working directory.
1. Inspect existing source before changing anything.
2. Implement the complete issue scope. Run the project's test or verification command.
3. If you need human input to proceed safely, emit exactly one line starting with \`NEEDS_INPUT:\` followed by your question, then stop.
4. When implementation and verification are complete, emit exactly one line starting with \`WORK_COMPLETE:\` followed by a one-sentence summary, then stop.
5. Do not merge, deploy, or push. The orchestrator handles the Git lifecycle after you signal completion.
6. Never claim a change you did not observe. Preserve command evidence.`;
/**
* Build a concise context pack prompt for the OpenCode session. The pack is
* sent as the first task and includes: issue details, project docs, evidence,
* prior artifacts, repository metadata, and operational instructions with the
* needs-input / work-complete marker protocol.
*/
export const buildContextPack = (input: ContextPackInput): string => {
const parts: string[] = [
`# Issue #${input.issueNumber}: ${input.issueTitle}\n\n${input.issueBody}`,
];
if (input.contextFiles.length > 0) {
parts.push(
section(
"Project Context",
input.contextFiles.map(
(file) => `### ${file.label}\n\n\`\`\`\n${file.content}\n\`\`\``
)
)
);
}
if (input.evidence.length > 0) {
parts.push(
section(
"Supporting Evidence",
input.evidence.map((item) => `- **${item.source}**: ${item.content}`)
)
);
}
if (input.artifacts.length > 0) {
parts.push(
section(
"Previous Work Artifacts",
input.artifacts.map(
(artifact) =>
`### ${artifact.path}\n\n\`\`\`\n${artifact.content}\n\`\`\``
)
)
);
}
parts.push(
section("Repository", [
`- Name: ${input.repositoryMetadata.repositoryName}`,
`- URL: ${input.repositoryMetadata.repositoryUrl}`,
`- Base branch: ${input.repositoryMetadata.baseBranch}`,
]),
OPERATIONAL_INSTRUCTIONS
);
return parts.filter((part) => part.length > 0).join("\n\n---\n\n");
};

View File

@@ -0,0 +1,170 @@
/* eslint-disable no-console -- diagnostic skip messages in a Docker-guarded suite */
import { spawn } from "node:child_process";
import { setTimeout as sleepTimer } from "node:timers/promises";
import { Effect } from "effect";
import { describe, expect, it as vitestIt } from "vitest";
import { DockerSandboxProvider } from "./docker-sandbox";
import { OrbSandboxError } from "./domain";
// Real containers need more than the 5s default; bind a generous timeout here.
const it = (name: string, fn: () => Promise<void>): void => {
vitestIt(name, fn, 60_000);
};
// Node-compatible Docker availability check.
const runCliExit = (args: readonly string[]): Promise<number | null> =>
// eslint-disable-next-line promise/avoid-new -- wrapping one-shot child close in a single promise
new Promise((resolve) => {
const [command = "docker", ...rest] = args;
const proc = spawn(command, rest, {
stdio: ["ignore", "pipe", "pipe"],
});
proc.on("error", () => resolve(null));
proc.on("close", (code) => resolve(code));
});
const dockerAvailable = async (): Promise<boolean> => {
try {
const code = await runCliExit([
"docker",
"version",
"--format",
"{{.Server.Version}}",
]);
return code === 0;
} catch {
return false;
}
};
// Top-level await so describe.skipIf evaluates Docker availability at registration.
const HAVE_DOCKER = await dockerAvailable();
const tmpDir = (): string =>
`/tmp/orb-test-${Date.now()}-${Math.trunc(Math.random() * 1e6)}`;
describe.skipIf(!HAVE_DOCKER)(
"DockerSandboxProvider (SandboxAgent + Docker)",
() => {
it("starts a SandboxAgent server and exposes a reachable baseUrl", async () => {
const provider = await Effect.runPromise(
DockerSandboxProvider.create({
hostWorkspacePath: tmpDir(),
})
);
try {
const client = await provider.start();
expect(provider.isStarted).toBe(true);
// The SandboxAgent client must have a baseUrl property — this is what
// AgentOS serializes so the sidecar can reach the sandbox.
const { baseUrl } = client as unknown as { baseUrl: string };
expect(baseUrl).toBeTruthy();
expect(baseUrl).toMatch(/^https?:\/\//u);
// Run a command to verify the sandbox-agent server actually works.
const result = await client.runProcess({
args: ["-c", "echo hello-orb"],
command: "sh",
});
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("hello-orb");
} finally {
await provider.dispose();
}
});
it("is idempotent: start returns the same client on repeated calls", async () => {
const provider = await Effect.runPromise(
DockerSandboxProvider.create({
hostWorkspacePath: tmpDir(),
})
);
try {
const c1 = await provider.start();
const c2 = await provider.start();
expect(c1).toBe(c2);
} finally {
await provider.dispose();
}
});
it("disposes cleanly and frees the Docker container", async () => {
const provider = await Effect.runPromise(
DockerSandboxProvider.create({
hostWorkspacePath: tmpDir(),
})
);
await provider.start();
expect(provider.isStarted).toBe(true);
await provider.dispose();
expect(provider.isStarted).toBe(false);
// Give AutoRemove a moment.
// eslint-disable-next-line no-await-in-loop -- single settling wait
await sleepTimer(2000);
// Second dispose is a safe no-op.
await expect(provider.dispose()).resolves.toBeUndefined();
});
it("binds the host workspace into the sandbox container", async () => {
const workspace = tmpDir();
const { spawn: nodeSpawn } = await import("node:child_process");
// eslint-disable-next-line promise/avoid-new -- one-shot shell write
await new Promise<void>((resolve) => {
const p = nodeSpawn(
"sh",
[
"-c",
`mkdir -p ${workspace} && echo proof-file > ${workspace}/marker.txt`,
],
{
stdio: ["ignore", "pipe", "pipe"],
}
);
p.on("close", () => resolve());
});
const provider = await Effect.runPromise(
DockerSandboxProvider.create({
hostWorkspacePath: workspace,
})
);
try {
const client = await provider.start();
const result = await client.runProcess({
args: ["-c", "cat /home/sandbox/marker.txt"],
command: "sh",
});
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("proof-file");
} finally {
await provider.dispose();
}
});
}
);
describe.skipIf(HAVE_DOCKER)("DockerSandboxProvider without Docker", () => {
it("create fails with DockerUnavailable", async () => {
const error = await Effect.runPromise(
Effect.flip(
DockerSandboxProvider.create({
hostWorkspacePath: "/tmp/orb-test-nodocker",
})
)
);
expect(error).toBeInstanceOf(OrbSandboxError);
expect(error.reason).toBe("DockerUnavailable");
});
});
if (!HAVE_DOCKER) {
console.warn(
"[docker-sandbox.test.ts] Docker daemon unavailable; SandboxAgent tests skipped."
);
}

View File

@@ -0,0 +1,118 @@
import type {
AgentOsSandboxClient,
AgentOsSandboxProvider,
} from "@rivet-dev/agentos-core";
import { Effect } from "effect";
import { OrbSandboxError } from "./domain";
// ---------------------------------------------------------------------------
// Docker sandbox — AgentOsSandboxProvider backed by sandbox-agent + Docker
// ---------------------------------------------------------------------------
//
// AgentOS serializes sandbox mounts through getSerializableClientConfig,
// which reads client.baseUrl and passes it to the sidecar. An in-process
// client object can never satisfy that contract because it has no network
// endpoint. The supported boundary is a standard SandboxAgent client:
//
// SandboxAgent.start({ sandbox: docker({ image, binds }) })
//
// starts a sandbox-agent server inside a Docker container, dynamically maps
// a host port, and returns a SandboxAgent client whose baseUrl the sidecar
// can reach over HTTP on 127.0.0.1:<hostPort>. Both the main process and
// the sidecar subprocess run on the host, so localhost connectivity works.
const SANDBOX_AGENT_IMAGE = "rivetdev/sandbox-agent:0.5.0-rc.2-full";
const DEFAULT_WORKSPACE = "/home/sandbox";
// ---------------------------------------------------------------------------
// DockerSandboxProvider — wraps SandboxAgent.start with the docker provider
// ---------------------------------------------------------------------------
export interface DockerSandboxOptions {
readonly containerName?: string;
readonly hostWorkspacePath: string;
readonly image?: string;
readonly workDir?: string;
}
export class DockerSandboxProvider implements AgentOsSandboxProvider {
private readonly image: string;
private readonly binds: string[];
private client: AgentOsSandboxClient | null = null;
private disposeFn: (() => Promise<void>) | null = null;
constructor(options: DockerSandboxOptions) {
this.image = options.image ?? SANDBOX_AGENT_IMAGE;
// Bind the host workspace read-write into the sandbox container so the
// restricted writable area is the project workspace only.
this.binds = [
`${options.hostWorkspacePath}:${options.workDir ?? DEFAULT_WORKSPACE}`,
];
}
static readonly create = Effect.fn("DockerSandboxProvider.create")(
function* createProvider(options: DockerSandboxOptions) {
// eslint-disable-next-line no-use-before-define -- defined at module bottom
yield* checkDockerAvailable();
return new DockerSandboxProvider(options);
}
);
async start(): Promise<AgentOsSandboxClient> {
if (this.client) {
return this.client;
}
const { SandboxAgent } = await import("sandbox-agent");
const { docker } = await import("sandbox-agent/docker");
const sandboxAgent = await SandboxAgent.start({
sandbox: docker({
binds: this.binds,
image: this.image,
}),
});
this.client = sandboxAgent as unknown as AgentOsSandboxClient;
this.disposeFn = async () => {
// destroySandbox permanently tears down the backing Docker container;
// dispose() alone only closes the HTTP connection.
await sandboxAgent.destroySandbox();
};
return this.client;
}
async dispose(): Promise<void> {
const dispose = this.disposeFn;
this.client = null;
this.disposeFn = null;
if (dispose) {
await dispose();
}
}
get isStarted(): boolean {
return this.client !== null;
}
}
// ---------------------------------------------------------------------------
// Docker availability check
// ---------------------------------------------------------------------------
const checkDockerAvailable = Effect.fn("Docker.checkAvailable")(
function* checkDockerAvailable() {
yield* Effect.tryPromise({
catch: (cause) =>
new OrbSandboxError({
message: `Docker is not available: ${cause instanceof Error ? cause.message : String(cause)}`,
reason: "DockerUnavailable",
}),
try: async () => {
const { default: Docker } = await import("dockerode");
const docker = new Docker();
await docker.ping();
},
});
}
);

View File

@@ -0,0 +1,132 @@
import { Effect } from "effect";
import { describe, expect, it } from "vitest";
import {
orbActorKey,
OrbStateError,
transitionOrbState,
transitionRunState,
} from "./domain";
const runTransition = (from: string, to: string) =>
Effect.runSync(
Effect.flip(transitionOrbState({ from: from as never, to: to as never }))
);
const runTransitionOk = (from: string, to: string) =>
Effect.runSync(transitionOrbState({ from: from as never, to: to as never }));
const runRunState = (from: string, to: string) =>
Effect.runSync(
Effect.flip(transitionRunState({ from: from as never, to: to as never }))
);
const runRunStateOk = (from: string, to: string) =>
Effect.runSync(transitionRunState({ from: from as never, to: to as never }));
describe("orbActorKey", () => {
it("produces a stable key from the identity triple", () => {
const key = orbActorKey({
projectId: "prj-1",
runId: "run-1",
workUnitId: "wrk-1",
});
expect(key).toBe("project:prj-1:work:wrk-1:run:run-1");
});
it("preserves order across different identities", () => {
expect(orbActorKey({ projectId: "a", runId: "b", workUnitId: "c" })).toBe(
"project:a:work:c:run:b"
);
});
});
describe("transitionOrbState", () => {
it("allows creating -> prepared", () => {
expect(runTransitionOk("creating", "prepared")).toBe("prepared");
});
it("allows prepared -> running", () => {
expect(runTransitionOk("prepared", "running")).toBe("running");
});
it("allows running -> needs-input", () => {
expect(runTransitionOk("running", "needs-input")).toBe("needs-input");
});
it("allows running -> completed", () => {
expect(runTransitionOk("running", "completed")).toBe("completed");
});
it("allows completed -> disposed", () => {
expect(runTransitionOk("completed", "disposed")).toBe("disposed");
});
it("allows needs-input -> running (resume)", () => {
expect(runTransitionOk("needs-input", "running")).toBe("running");
});
it("rejects disposed -> running", () => {
const error = runTransition("disposed", "running");
expect(error).toBeInstanceOf(OrbStateError);
expect(error.reason).toBe("AlreadyDisposed");
});
it("rejects completed -> creating", () => {
const error = runTransition("completed", "creating");
expect(error).toBeInstanceOf(OrbStateError);
expect(error.reason).toBe("InvalidTransition");
});
it("rejects prepared -> needs-input (skipping running)", () => {
const error = runTransition("prepared", "needs-input");
expect(error).toBeInstanceOf(OrbStateError);
expect(error.reason).toBe("InvalidTransition");
});
it("rejects cancelled -> running", () => {
const error = runTransition("cancelled", "running");
expect(error).toBeInstanceOf(OrbStateError);
expect(error.reason).toBe("InvalidTransition");
});
});
describe("transitionRunState", () => {
it("allows queued -> provisioning", () => {
expect(runRunStateOk("queued", "provisioning")).toBe("provisioning");
});
it("allows provisioning -> preparing", () => {
expect(runRunStateOk("provisioning", "preparing")).toBe("preparing");
});
it("allows running -> verifying", () => {
expect(runRunStateOk("running", "verifying")).toBe("verifying");
});
it("allows verifying -> succeeded", () => {
expect(runRunStateOk("verifying", "succeeded")).toBe("succeeded");
});
it("allows running -> cancelled", () => {
expect(runRunStateOk("running", "cancelled")).toBe("cancelled");
});
it("rejects succeeded -> running (terminal)", () => {
const error = runRunState("succeeded", "running");
expect(error).toBeInstanceOf(OrbStateError);
expect(error.reason).toBe("InvalidTransition");
});
it("rejects failed -> running (terminal)", () => {
const error = runRunState("failed", "running");
expect(error).toBeInstanceOf(OrbStateError);
expect(error.reason).toBe("InvalidTransition");
});
it("rejects queued -> succeeded (skipping steps)", () => {
const error = runRunState("queued", "succeeded");
expect(error).toBeInstanceOf(OrbStateError);
expect(error.reason).toBe("InvalidTransition");
});
});

View File

@@ -0,0 +1,259 @@
/* eslint-disable max-classes-per-file -- each domain failure has a distinct tagged reason. */
import { Effect, Schema } from "effect";
const MeaningfulString = Schema.String.check(
Schema.makeFilter((value) => value.trim().length > 0, {
expected: "a non-empty string",
})
);
// ---------------------------------------------------------------------------
// Branded identifiers
// ---------------------------------------------------------------------------
export const OrbId = MeaningfulString.pipe(Schema.brand("OrbId"));
export type OrbId = typeof OrbId.Type;
export const OrbRunId = MeaningfulString.pipe(Schema.brand("OrbRunId"));
export type OrbRunId = typeof OrbRunId.Type;
export const OrbSessionId = MeaningfulString.pipe(Schema.brand("OrbSessionId"));
export type OrbSessionId = typeof OrbSessionId.Type;
// ---------------------------------------------------------------------------
// Actor identity — one Orb per project/issue/run triple
// ---------------------------------------------------------------------------
export const OrbIdentity = Schema.Struct({
projectId: MeaningfulString,
runId: MeaningfulString,
workUnitId: MeaningfulString,
});
export type OrbIdentity = typeof OrbIdentity.Type;
/** Stable RivetKit actor key derived from the identity triple. */
export const orbActorKey = (identity: OrbIdentity): string =>
`project:${identity.projectId}:work:${identity.workUnitId}:run:${identity.runId}`;
// ---------------------------------------------------------------------------
// State machines — product state stays separate from run state
// ---------------------------------------------------------------------------
export const OrbState = Schema.Literals([
"creating",
"prepared",
"running",
"needs-input",
"completed",
"failed",
"cancelled",
"disposed",
]);
export type OrbState = typeof OrbState.Type;
export const RunState = Schema.Literals([
"queued",
"provisioning",
"preparing",
"running",
"verifying",
"succeeded",
"failed",
"cancelled",
]);
export type RunState = typeof RunState.Type;
const ORB_TRANSITIONS: Readonly<Record<OrbState, readonly OrbState[]>> = {
cancelled: ["disposed"],
completed: ["disposed"],
creating: ["prepared", "running", "failed", "cancelled", "disposed"],
disposed: [],
failed: ["disposed"],
"needs-input": ["running", "completed", "failed", "cancelled", "disposed"],
prepared: ["running", "failed", "cancelled", "disposed"],
running: ["needs-input", "completed", "failed", "cancelled", "disposed"],
};
const RUN_TRANSITIONS: Readonly<Record<RunState, readonly RunState[]>> = {
cancelled: [],
failed: [],
preparing: ["running", "verifying", "failed", "cancelled"],
provisioning: ["preparing", "running", "failed", "cancelled"],
queued: ["provisioning", "preparing", "running", "failed", "cancelled"],
running: ["verifying", "succeeded", "failed", "cancelled"],
succeeded: [],
verifying: ["succeeded", "failed", "cancelled"],
};
// ---------------------------------------------------------------------------
// Tagged errors
// ---------------------------------------------------------------------------
export const OrbStateErrorReason = Schema.Literals([
"InvalidTransition",
"AlreadyDisposed",
"NotRunning",
]);
export type OrbStateErrorReason = typeof OrbStateErrorReason.Type;
export class OrbStateError extends Schema.TaggedErrorClass<OrbStateError>()(
"OrbStateError",
{
from: Schema.String,
message: Schema.String,
reason: OrbStateErrorReason,
to: Schema.String,
}
) {}
export const OrbSandboxErrorReason = Schema.Literals([
"DockerUnavailable",
"ContainerStart",
"CommandFailed",
"ContainerCleanup",
]);
export type OrbSandboxErrorReason = typeof OrbSandboxErrorReason.Type;
export class OrbSandboxError extends Schema.TaggedErrorClass<OrbSandboxError>()(
"OrbSandboxError",
{
message: Schema.String,
reason: OrbSandboxErrorReason,
}
) {}
export const OrbSessionErrorReason = Schema.Literals([
"OpenSession",
"PromptFailed",
"AgentNotInstalled",
"SessionNotFound",
"PermissionDenied",
]);
export type OrbSessionErrorReason = typeof OrbSessionErrorReason.Type;
export class OrbSessionError extends Schema.TaggedErrorClass<OrbSessionError>()(
"OrbSessionError",
{
message: Schema.String,
reason: OrbSessionErrorReason,
}
) {}
export const OrbConfigurationErrorReason = Schema.Literals([
"MissingGateway",
"MissingIdentity",
"InvalidModelConfig",
]);
export type OrbConfigurationErrorReason =
typeof OrbConfigurationErrorReason.Type;
export class OrbConfigurationError extends Schema.TaggedErrorClass<OrbConfigurationError>()(
"OrbConfigurationError",
{
message: Schema.String,
reason: OrbConfigurationErrorReason,
}
) {}
// ---------------------------------------------------------------------------
// Configuration types
// ---------------------------------------------------------------------------
export const OrbModelGatewayConfig = Schema.Struct({
apiKey: MeaningfulString,
baseUrl: MeaningfulString,
model: MeaningfulString,
provider: MeaningfulString,
});
export type OrbModelGatewayConfig = typeof OrbModelGatewayConfig.Type;
export const OrbProjectContext = Schema.Struct({
artifacts: Schema.Array(
Schema.Struct({
content: Schema.String,
path: MeaningfulString,
})
),
contextFiles: Schema.Array(
Schema.Struct({
content: Schema.String,
path: MeaningfulString,
})
),
issueBody: MeaningfulString,
issueTitle: MeaningfulString,
repositoryUrl: Schema.UndefinedOr(MeaningfulString),
});
export type OrbProjectContext = typeof OrbProjectContext.Type;
// ---------------------------------------------------------------------------
// Transition helpers
// ---------------------------------------------------------------------------
export const transitionOrbState = Effect.fn("Orb.transitionState")(
function* transitionOrbState(input: {
readonly from: OrbState;
readonly to: OrbState;
}) {
if (input.from === input.to) {
return input.to;
}
if (input.from === "disposed") {
return yield* Effect.fail(
new OrbStateError({
from: input.from,
message: "Orb is already disposed",
reason: "AlreadyDisposed",
to: input.to,
})
);
}
if (!ORB_TRANSITIONS[input.from].includes(input.to)) {
return yield* Effect.fail(
new OrbStateError({
from: input.from,
message: `Cannot transition orb from ${input.from} to ${input.to}`,
reason: "InvalidTransition",
to: input.to,
})
);
}
return input.to;
}
);
export const transitionRunState = Effect.fn("Orb.transitionRunState")(
function* transitionRunState(input: {
readonly from: RunState;
readonly to: RunState;
}) {
if (input.from === input.to) {
return input.to;
}
if (
input.from === "succeeded" ||
input.from === "failed" ||
input.from === "cancelled"
) {
return yield* Effect.fail(
new OrbStateError({
from: input.from,
message: `Run is already in terminal state ${input.from}`,
reason: "InvalidTransition",
to: input.to,
})
);
}
if (!RUN_TRANSITIONS[input.from].includes(input.to)) {
return yield* Effect.fail(
new OrbStateError({
from: input.from,
message: `Cannot transition run from ${input.from} to ${input.to}`,
reason: "InvalidTransition",
to: input.to,
})
);
}
return input.to;
}
);

View File

@@ -0,0 +1,134 @@
/* eslint-disable no-non-null-assertion -- test assertions on defined events */
import { describe, expect, it } from "vitest";
import { makeOrbEvent, normalizeSessionEvent, redactSecrets } from "./events";
describe("redactSecrets", () => {
it("redacts api_key patterns", () => {
const input = "Using api_key=sk-abc123def456ghi789jkl012mno345";
const result = redactSecrets(input);
expect(result).not.toContain("sk-abc123");
expect(result).toContain("[REDACTED]");
});
it("redacts Bearer tokens", () => {
const input = "Authorization: Bearer eyJhbGciOiJIUzI1";
const result = redactSecrets(input);
expect(result).toContain("Bearer [REDACTED]");
expect(result).not.toContain("eyJhbGciOiJIUzI1");
});
it("redacts token= patterns", () => {
const input = 'token: "my-secret-token-value"';
const result = redactSecrets(input);
expect(result).not.toContain("my-secret-token-value");
});
it("preserves non-secret text", () => {
expect(redactSecrets("just regular text")).toBe("just regular text");
});
});
describe("makeOrbEvent", () => {
it("creates an event with sequence and timestamp", () => {
const event = makeOrbEvent(5, "session_opened", { text: "opened" });
expect(event.sequence).toBe(5);
expect(event.type).toBe("session_opened");
expect(event.text).toBe("opened");
expect(event.timestamp).toBeDefined();
});
it("creates an event with command and exitCode", () => {
const event = makeOrbEvent(3, "command_executed", {
command: "bun test",
exitCode: 0,
});
expect(event.command).toBe("bun test");
expect(event.exitCode).toBe(0);
});
});
describe("normalizeSessionEvent", () => {
it("normalizes agent_message_chunk", () => {
const event = normalizeSessionEvent({
rawText: "Working on the fix",
sequence: 5,
sessionId: "sess-1",
timestamp: "2026-07-24T12:00:00Z",
type: "agent_message_chunk",
});
expect(event).toBeDefined();
expect(event!.type).toBe("agent_message_chunk");
expect(event!.text).toBe("Working on the fix");
expect(event!.sequence).toBe(5);
});
it("normalizes agent_message to agent_message_completed", () => {
const event = normalizeSessionEvent({
content: [{ text: "Done", type: "text" }],
sequence: 10,
sessionId: "sess-1",
timestamp: "2026-07-24T12:00:05Z",
type: "agent_message",
});
expect(event).toBeDefined();
expect(event!.type).toBe("agent_message_completed");
expect(event!.text).toBe("Done");
});
it("normalizes tool_call", () => {
const event = normalizeSessionEvent({
sequence: 3,
sessionId: "sess-1",
timestamp: "2026-07-24T12:00:01Z",
title: "bash",
toolCallId: "tc-1",
type: "tool_call",
});
expect(event).toBeDefined();
expect(event!.type).toBe("tool_call_started");
expect(event!.toolName).toBe("bash");
expect(event!.toolCallId).toBe("tc-1");
});
it("normalizes permission_request", () => {
const event = normalizeSessionEvent({
sequence: 7,
sessionId: "sess-1",
timestamp: "2026-07-24T12:00:03Z",
type: "permission_request",
});
expect(event).toBeDefined();
expect(event!.type).toBe("permission_requested");
});
it("returns undefined for unmapped types", () => {
const event = normalizeSessionEvent({
sequence: 1,
sessionId: "sess-1",
timestamp: "2026-07-24T12:00:00Z",
type: "session_config",
});
expect(event).toBeUndefined();
});
it("returns undefined when type is missing", () => {
const event = normalizeSessionEvent({
sequence: 1,
sessionId: "sess-1",
});
expect(event).toBeUndefined();
});
it("redacts secrets in persisted event copies", () => {
const event = normalizeSessionEvent({
rawText: "Using api_key=sk-secret123456789012345678",
sequence: 2,
sessionId: "sess-1",
timestamp: "2026-07-24T12:00:00Z",
type: "agent_message_chunk",
});
expect(event).toBeDefined();
expect(event!.text).not.toContain("sk-secret");
});
});

View File

@@ -0,0 +1,216 @@
import { Schema } from "effect";
// ---------------------------------------------------------------------------
// Normalized Orb events — domain-meaningful, secret-free
// ---------------------------------------------------------------------------
export const OrbEventVariant = Schema.Literals([
"session_opened",
"agent_message_chunk",
"agent_message_completed",
"agent_thought_chunk",
"tool_call_started",
"tool_call_completed",
"permission_requested",
"permission_denied",
"command_executed",
"session_failed",
"session_closed",
"vm_booted",
"vm_shutdown",
]);
export type OrbEventVariant = typeof OrbEventVariant.Type;
export const OrbEvent = Schema.Struct({
command: Schema.UndefinedOr(Schema.String),
exitCode: Schema.UndefinedOr(Schema.Int),
sequence: Schema.Number,
text: Schema.UndefinedOr(Schema.String),
timestamp: Schema.String,
toolCallId: Schema.UndefinedOr(Schema.String),
toolName: Schema.UndefinedOr(Schema.String),
type: OrbEventVariant,
});
export type OrbEvent = typeof OrbEvent.Type;
/** Construct an OrbEvent with auto-incrementing sequence and timestamp. */
export const makeOrbEvent = (
sequence: number,
type: OrbEventVariant,
fields?: Partial<Omit<OrbEvent, "sequence" | "timestamp" | "type">>
): OrbEvent =>
({
command: fields?.command ?? undefined,
exitCode: fields?.exitCode ?? undefined,
sequence,
text: fields?.text ?? undefined,
timestamp: new Date().toISOString(),
toolCallId: fields?.toolCallId ?? undefined,
toolName: fields?.toolName ?? undefined,
type,
}) as unknown as OrbEvent;
// ---------------------------------------------------------------------------
// Secret redaction — applied only to persisted/logged event copies,
// never to prompts sent to the model.
// ---------------------------------------------------------------------------
const REDACT_PATTERNS = [
/(?:api[_-]?key|token|secret|password|credential)["'\s:=]+(?<value>[^\s"'},]+)/giu,
/sk-(?<key>[a-zA-Z0-9]{20,})/gu,
/Bearer\s+[a-zA-Z0-9._-]+/gu,
];
export const redactSecrets = (text: string): string => {
let result = text;
for (const pattern of REDACT_PATTERNS) {
result = result.replaceAll(pattern, (match) =>
match.toLowerCase().includes("bearer")
? "Bearer [REDACTED]"
: "[REDACTED]"
);
}
return result;
};
// ---------------------------------------------------------------------------
// Translation from AgentOS session stream entries to normalized OrbEvent
// ---------------------------------------------------------------------------
interface AcpSessionUpdateLike {
readonly type?: string;
readonly sessionUpdate?: string;
readonly rawText?: string;
readonly text?: string;
readonly content?: unknown;
readonly toolCallId?: string | null;
readonly toolCallStatus?: string;
readonly title?: string | null;
readonly sessionId?: string;
}
interface SessionStreamEntryLike {
readonly afterSequence?: number;
readonly content?: unknown;
readonly durability?: string;
readonly rawText?: string;
readonly sequence?: number;
readonly sessionId?: string;
readonly sessionUpdate?: string;
readonly text?: string;
readonly timestamp?: string;
readonly title?: string | null;
readonly toolCallId?: string | null;
readonly type?: string;
}
const IGNORED_TYPES = new Set([
"session_config",
"agent_description",
"agent_capability",
]);
const MESSAGE_TYPES = new Set([
"agent_message_chunk",
"agent_message",
"agent_thought_chunk",
]);
const TOOL_CALL_TYPES = new Set([
"tool_call",
"tool_call_status",
"tool_call_update",
]);
const extractText = (entry: AcpSessionUpdateLike): string | undefined => {
if (entry.rawText !== undefined) {
return entry.rawText;
}
if (entry.text !== undefined) {
return entry.text;
}
if (typeof entry.content === "string") {
return entry.content;
}
if (Array.isArray(entry.content)) {
const texts = entry.content
.filter(
(block): block is { readonly type: string; readonly text?: unknown } =>
typeof block === "object" && block !== null && "type" in block
)
.map((block) => (typeof block.text === "string" ? block.text : undefined))
.filter((text): text is string => text !== undefined);
return texts.length > 0 ? texts.join("") : undefined;
}
return undefined;
};
const optionalToolFields = (
raw: SessionStreamEntryLike
): { toolCallId?: string; toolName?: string } => ({
...(raw.toolCallId === null || raw.toolCallId === undefined
? {}
: { toolCallId: raw.toolCallId }),
...(raw.title === null || raw.title === undefined
? {}
: { toolName: raw.title }),
});
const fromMessage = (
type: string,
sequence: number,
raw: SessionStreamEntryLike
): OrbEvent | undefined => {
const text = extractText(raw);
if (text === undefined) {
// agent_message completes even without text; chunk/thought do not.
return type === "agent_message"
? makeOrbEvent(sequence, "agent_message_completed", {})
: undefined;
}
const variant = (
type === "agent_message" ? "agent_message_completed" : type
) as OrbEventVariant;
return makeOrbEvent(sequence, variant, { text: redactSecrets(text) });
};
const fromToolCall = (
type: string,
sequence: number,
raw: SessionStreamEntryLike
): OrbEvent =>
makeOrbEvent(
sequence,
type === "tool_call" ? "tool_call_started" : "tool_call_completed",
optionalToolFields(raw)
);
/**
* Translate one AgentOS SessionStreamEntry into zero or one normalized OrbEvent.
* Returns undefined for event types that have no domain-meaningful mapping yet.
* Secret redaction is applied so persisted event copies never leak credentials.
*/
export const normalizeSessionEvent = (
raw: SessionStreamEntryLike
): OrbEvent | undefined => {
const type = raw.type ?? raw.sessionUpdate;
if (type === undefined) {
return undefined;
}
const sequence = raw.sequence ?? 0;
if (IGNORED_TYPES.has(type)) {
return undefined;
}
if (type === "permission_request") {
return makeOrbEvent(sequence, "permission_requested", {
text: "Permission requested",
});
}
if (MESSAGE_TYPES.has(type)) {
return fromMessage(type, sequence, raw);
}
if (TOOL_CALL_TYPES.has(type)) {
return fromToolCall(type, sequence, raw);
}
return undefined;
};

View File

@@ -0,0 +1,81 @@
import { runPostRunGiteaLifecycle } from "../git/gitea";
import type { GitCommandRunner, GiteaTransport } from "../git/gitea";
import type {
GitLifecyclePort,
GitLifecycleResult,
GitPublishInput,
OrbRunPort,
} from "./ports";
// ---------------------------------------------------------------------------
// OrbGitCommandRunner — runs git commands inside the Orb sandbox
//
// Adapts the OrbRunPort.executeCommand interface to the GitCommandRunner
// shape expected by runPostRunGiteaLifecycle. Commands execute inside the
// Docker sandbox where OpenCode made its changes.
// ---------------------------------------------------------------------------
const createOrbGitRunner = (orb: OrbRunPort): GitCommandRunner => ({
run(
command: string,
options?: { readonly cwd?: string; readonly env?: Record<string, string> }
) {
const cwd = options?.cwd ?? "/mnt/sandbox/repository";
return orb.executeCommand(command, cwd);
},
});
// ---------------------------------------------------------------------------
// Git lifecycle adapter factory
//
// Creates a GitLifecyclePort bound to one Orb run. The command runner executes
// git inside that Orb's sandbox; the Gitea transport creates PRs via HTTP.
// This is the application-layer entry point — no interactive Flue shell needed.
// ---------------------------------------------------------------------------
export const createGitLifecyclePort = (
orb: OrbRunPort,
transport: GiteaTransport
): GitLifecyclePort => {
const runner = createOrbGitRunner(orb);
return {
async publish(input: GitPublishInput): Promise<GitLifecycleResult> {
const result = await runPostRunGiteaLifecycle({
baseBranch: input.baseBranch,
body: `Verified changes for project issue #${input.issueNumber}. Merge remains a manual review action.`,
...(input.commitMessage === undefined
? {}
: { commitMessage: input.commitMessage }),
issueNumber: input.issueNumber,
issueTitle: input.issueTitle,
repositoryPath: input.repositoryPath,
runner,
title: `Issue #${input.issueNumber}: ${input.issueTitle}`,
transport,
verification: input.verification,
workspace: input.workspace,
});
return {
baseBranch: result.baseBranch,
branch: result.branch,
...(result.commitSha === undefined
? {}
: { commitSha: result.commitSha }),
...(result.pullRequest === undefined
? {}
: {
pullRequest: {
baseBranch: result.pullRequest.baseBranch,
branch: result.pullRequest.branch,
number: result.pullRequest.number,
status: result.pullRequest.status,
url: result.pullRequest.url,
},
}),
status: result.status,
};
},
};
};

View File

@@ -0,0 +1,13 @@
// oxlint-disable-next-line no-barrel-file -- The Orb module exposes its public surface here.
export * from "./domain";
export * from "./events";
export * from "./docker-sandbox";
export * from "./opencode-config";
export * from "./permission-policy";
export * from "./runtime";
export * from "./ports";
export * from "./project-events";
export * from "./context-pack";
export * from "./orb-project-manager";
export * from "./orb-adapter";
export * from "./git-adapter";

View File

@@ -0,0 +1,82 @@
/* eslint-disable no-non-null-assertion -- test assertions on defined objects */
import { Effect } from "effect";
import { describe, expect, it } from "vitest";
import { OrbConfigurationError } from "./domain";
import {
opencodeSetupCommands,
prepareOpenCodeConfig,
} from "./opencode-config";
const validGateway = {
apiKey: "test-key-123",
baseUrl: "https://gateway.example.com/v1",
model: "test-model",
provider: "test-provider",
};
const validContext = {
artifacts: [],
contextFiles: [],
issueBody: "Fix the bug",
issueTitle: "Bug fix",
repositoryUrl: undefined,
};
describe("prepareOpenCodeConfig", () => {
it("produces valid config JSON with provider and model", () => {
const result = Effect.runSync(
prepareOpenCodeConfig({
context: validContext,
gateway: validGateway,
})
);
const parsed = JSON.parse(result.configJson);
expect(parsed.model).toBe("test-provider/test-model");
expect(parsed.provider["test-provider"].baseUrl).toBe(
"https://gateway.example.com/v1"
);
expect(parsed.provider["test-provider"].apiKey).toBe("test-key-123");
});
it("sets config path under opencode config directory", () => {
const result = Effect.runSync(
prepareOpenCodeConfig({
context: validContext,
gateway: validGateway,
})
);
expect(result.configPath).toContain("opencode");
expect(result.configPath).toContain("config.json");
expect(result.instructionsPath).toBe("/workspace/control/issue.md");
});
it("rejects empty base URL", () => {
const error = Effect.runSync(
Effect.flip(
prepareOpenCodeConfig({
context: validContext,
gateway: { ...validGateway, baseUrl: " " },
})
)
);
expect(error).toBeInstanceOf(OrbConfigurationError);
expect(error.reason).toBe("InvalidModelConfig");
});
});
describe("opencodeSetupCommands", () => {
it("produces mkdir, write, and chmod commands", () => {
const config = Effect.runSync(
prepareOpenCodeConfig({
context: validContext,
gateway: validGateway,
})
);
const commands = opencodeSetupCommands(config);
expect(commands.length).toBe(3);
expect(commands[0]).toContain("mkdir");
expect(commands[1]).toContain("cat >");
expect(commands[2]).toContain("chmod 600");
});
});

View File

@@ -0,0 +1,76 @@
import { Effect } from "effect";
import { OrbConfigurationError } from "./domain";
import type { OrbModelGatewayConfig, OrbProjectContext } from "./domain";
// ---------------------------------------------------------------------------
// OpenCode configuration — prepared inside the AgentOS VM filesystem
// ---------------------------------------------------------------------------
const OPENCODE_CONFIG_DIR = "/root/.config/opencode";
const OPENCODE_CONFIG_PATH = `${OPENCODE_CONFIG_DIR}/config.json`;
const AGENT_INSTRUCTIONS_PATH = "/workspace/control/issue.md";
export interface PreparedOpenCodeConfig {
readonly configJson: string;
readonly configPath: string;
readonly instructionsPath: string;
}
const validateGateway = (
gateway: OrbModelGatewayConfig
): Effect.Effect<void, OrbConfigurationError> =>
gateway.baseUrl.trim().length === 0
? Effect.fail(
new OrbConfigurationError({
message: "Model gateway base URL must not be empty",
reason: "InvalidModelConfig",
})
)
: Effect.void;
/**
* Build the OpenCode configuration JSON and file layout for one Orb run.
* The configuration points OpenCode at the model gateway with run-scoped
* credentials injected at runtime — never committed to project files.
*/
export const prepareOpenCodeConfig = Effect.fn("Orb.prepareOpenCodeConfig")(
function* prepareOpenCodeConfig(input: {
readonly context: OrbProjectContext;
readonly gateway: OrbModelGatewayConfig;
}) {
yield* validateGateway(input.gateway);
const config = {
$schema: "https://opencode.ai/config.json",
model: `${input.gateway.provider}/${input.gateway.model}`,
provider: {
[input.gateway.provider]: {
apiKey: input.gateway.apiKey,
baseUrl: input.gateway.baseUrl,
models: {
[input.gateway.model]: {
name: input.gateway.model,
},
},
},
},
};
const configJson = JSON.stringify(config, null, 2);
return {
configJson,
configPath: OPENCODE_CONFIG_PATH,
instructionsPath: AGENT_INSTRUCTIONS_PATH,
} satisfies PreparedOpenCodeConfig;
}
);
/** Shell commands that stage the OpenCode config directory inside a VM. */
export const opencodeSetupCommands = (config: PreparedOpenCodeConfig) =>
[
`mkdir -p ${OPENCODE_CONFIG_DIR}`,
`cat > ${config.configPath} << 'ORB_EOF'\n${config.configJson}\nORB_EOF`,
`chmod 600 ${config.configPath}`,
] as const;

View File

@@ -0,0 +1,132 @@
import { Effect } from "effect";
import type { OrbEvent } from "./events";
import type {
CommandResult,
OrbAdapter,
OrbCreatePortInput,
OrbRunPort,
PrepareRepoInput,
} from "./ports";
import { OrbRuntime } from "./runtime";
import type { OrbEnv, OrbHandle } from "./runtime";
// ---------------------------------------------------------------------------
// RealOrbRun — wraps an OrbHandle behind the OrbRunPort interface
//
// Effect-based Orb methods are run to Promise here so the orchestrator and
// tests work with plain async/await. Effect failures surface as rejections.
// ---------------------------------------------------------------------------
class RealOrbRun implements OrbRunPort {
private readonly listeners = new Set<(event: OrbEvent) => void>();
private unsubscribe: (() => void) | null = null;
private readonly handle: OrbHandle;
constructor(handle: OrbHandle) {
this.handle = handle;
}
/** Called once after the handle-level listener is wired. */
_setUnsubscribe(fn: () => void): void {
this.unsubscribe = fn;
}
get orbId(): string {
return this.handle.id;
}
get runId(): string {
return this.handle.runId;
}
get sessionId(): string | undefined {
return this.handle.currentSessionId;
}
get state(): string {
return this.handle.state;
}
onEvent = (listener: (event: OrbEvent) => void): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
/** Forward an OrbHandle event to all port-level listeners. */
forwardEvent = (event: OrbEvent): void => {
for (const listener of this.listeners) {
listener(event);
}
};
prepareRepository = async (input: PrepareRepoInput): Promise<void> => {
await Effect.runPromise(this.handle.prepareRepository(input));
};
openSession = async (): Promise<string> =>
(await Effect.runPromise(this.handle.openSession())) as string;
sendTask = async (prompt: string): Promise<unknown> =>
await Effect.runPromise(this.handle.sendTask(prompt));
executeCommand = async (
command: string,
cwd?: string
): Promise<CommandResult> => {
const result = (await Effect.runPromise(
this.handle.executeCommand({
command,
...(cwd === undefined ? {} : { cwd }),
})
)) as { exitCode: number | null; stderr: string; stdout: string };
return {
exitCode: result.exitCode ?? -1,
stderr: result.stderr,
stdout: result.stdout,
};
};
cancel = async (): Promise<void> => {
await Effect.runPromise(this.handle.cancel().pipe(Effect.ignore));
};
dispose = async (): Promise<void> => {
this.unsubscribe?.();
this.listeners.clear();
await Effect.runPromise(this.handle.dispose().pipe(Effect.ignore));
};
}
// ---------------------------------------------------------------------------
// Real Orb adapter — wraps OrbRuntime.createOrb behind the OrbAdapter port
// ---------------------------------------------------------------------------
export const createOrbAdapter = (env?: OrbEnv): OrbAdapter => {
const runtime = new OrbRuntime(env);
return {
createOrb: async (input: OrbCreatePortInput): Promise<OrbRunPort> => {
const handle = await Effect.runPromise(
runtime.createOrb({
context: input.context,
docker: input.docker,
gateway: input.gateway,
identity: input.identity,
})
);
const run = new RealOrbRun(handle);
// Bridge OrbHandle events to port listeners via a single forwarding point.
const unsubscribe = handle.onEvent((event) => {
run.forwardEvent(event);
});
run._setUnsubscribe(unsubscribe);
return run;
},
};
};

View File

@@ -0,0 +1,132 @@
/* eslint-disable no-console -- integration test is a CLI-style probe */
/**
* Opt-in live integration test for the Orb-wired project-manager.
*
* Only runs when ORB_PM_INTEGRATION=1 is set. Requires:
* - Docker daemon
* - Model gateway credentials (ORB_GATEWAY_* or AGENT_MODEL_*)
* - A local Gitea instance with a test repo (optional — set GITEA_*)
*
* This test exercises the full flow: Orb creation, repo preparation, session
* open, model turn, Git lifecycle (or skip if Gitea is not configured).
*
* Usage:
* ORB_PM_INTEGRATION=1 \
* ORB_GATEWAY_API_KEY=... \
* ORB_GATEWAY_BASE_URL=https://ai.example.com/v1 \
* ORB_GATEWAY_MODEL=glm-5.2 \
* ORB_GATEWAY_PROVIDER=cheaptricks \
* bun test packages/agents/src/orb/orb-project-manager.live.test.ts
*/
import { describe, expect, it } from "vitest";
import { createGiteaHttpTransport } from "../git/gitea";
import { createGitLifecyclePort } from "./git-adapter";
import { createOrbAdapter } from "./orb-adapter";
import { OrbProjectManager } from "./orb-project-manager";
const env = (key: string): string | undefined => process.env[key];
const isLive = env("ORB_PM_INTEGRATION") === "1";
const resolveGateway = () => {
const apiKey = env("ORB_GATEWAY_API_KEY") ?? env("AGENT_MODEL_API_KEY");
const baseUrl = env("ORB_GATEWAY_BASE_URL") ?? env("AGENT_MODEL_BASE_URL");
const model = env("ORB_GATEWAY_MODEL") ?? env("AGENT_MODEL_NAME");
const provider = env("ORB_GATEWAY_PROVIDER") ?? env("AGENT_MODEL_PROVIDER");
if (!apiKey || !baseUrl || !model || !provider) {
return null;
}
return { apiKey, baseUrl, model, provider };
};
const hasGitea = () =>
env("GITEA_URL") !== undefined && env("GITEA_TOKEN") !== undefined;
describe.skipIf(!isLive)("OrbProjectManager live integration", () => {
it("creates an Orb, prepares repo, sends a model turn, and projects events", async () => {
const gateway = resolveGateway();
expect(gateway, "Gateway credentials required").not.toBeNull();
const adapter = createOrbAdapter();
const events: { type: string; text?: string }[] = [];
const pm = new OrbProjectManager({
createGitLifecycle: (orb) =>
createGitLifecyclePort(
orb,
createGiteaHttpTransport({
baseUrl: env("GITEA_URL") ?? "http://localhost:3000",
token: env("GITEA_TOKEN") ?? "",
})
),
onProjectEvent: (e) => events.push({ text: e.text, type: e.type }),
orbAdapter: adapter,
});
const result = await pm.startIssue({
baseBranch: "main",
branchName: `work/orb-pm-test-${Date.now()}`,
context: {
artifacts: [],
contextFiles: [],
issueBody: "Reply with WORK_COMPLETE: hello world test passed",
issueTitle: "Integration: model echo",
repositoryUrl: undefined,
},
contextPack: {
artifacts: [],
contextFiles: [],
evidence: [],
issueBody: "Reply with WORK_COMPLETE: hello world test passed",
issueNumber: 1,
issueTitle: "Integration: model echo",
repositoryMetadata: {
baseBranch: "main",
repositoryName: "orb-pm-test",
repositoryUrl: "local",
},
},
docker: {
hostWorkspacePath: `/tmp/orb-pm-test-${Date.now()}`,
},
gateway: gateway ?? {
apiKey: "",
baseUrl: "",
model: "",
provider: "",
},
issueId: `orb-pm-integration-${Date.now()}`,
issueNumber: 1,
issueTitle: "Integration: model echo",
projectId: "orb-pm-test",
runId: `run-${Date.now()}`,
});
expect(result.orbId).toBeDefined();
expect(result.sessionId).toBeDefined();
const eventTypes = events.map((e) => e.type);
expect(eventTypes).toContain("run.started");
expect(eventTypes).toContain("run.repository_prepared");
expect(eventTypes).toContain("run.session_opened");
// If Gitea is configured, attempt the full Git lifecycle.
if (hasGitea()) {
console.log("[orb-pm] Gitea configured — attempting Git lifecycle...");
try {
const gitResult = await pm.complete({
commitMessage: "test: orb project-manager integration",
issueId: result.issueId,
});
console.log(`[orb-pm] Git lifecycle result: ${gitResult.status}`);
} catch (error) {
console.log(
`[orb-pm] Git lifecycle failed (expected in CI): ${error instanceof Error ? error.message : String(error)}`
);
}
}
await pm.cancel(result.issueId);
console.log(`[orb-pm] Events: ${eventTypes.join(", ")}`);
}, 120_000);
});

View File

@@ -0,0 +1,709 @@
/* eslint-disable no-non-null-assertion -- test assertions on controlled fakes */
import { describe, expect, it } from "vitest";
import { makeOrbEvent } from "./events";
import type { OrbEvent } from "./events";
import { OrbProjectManager, ProjectManagerError } from "./orb-project-manager";
import type {
GitLifecyclePort,
GitLifecycleResult,
GitPublishInput,
OrbAdapter,
OrbCreatePortInput,
OrbRunPort,
PrepareRepoInput,
ProjectArtifact,
} from "./ports";
import type { ProjectRunEvent } from "./project-events";
// ---------------------------------------------------------------------------
// Fake Orb run — records calls and emits configurable events on sendTask
// ---------------------------------------------------------------------------
interface FakeOrbConfig {
/** Events to emit when sendTask resolves, mapped by call index. */
readonly eventsByTurn?: readonly (readonly OrbEvent[])[];
/** Default events to emit on every sendTask if no per-turn mapping. */
readonly defaultEvents?: readonly OrbEvent[];
/** If true, sendTask rejects on the first call. */
readonly failOnFirstSend?: boolean;
/** If true, prepareRepository rejects. */
readonly failOnPrepare?: boolean;
}
class FakeOrbRun implements OrbRunPort {
readonly orbId: string;
readonly runId: string;
sessionId: string | undefined;
state = "running";
private readonly config: FakeOrbConfig;
readonly sentTasks: string[] = [];
readonly prepareCalls: PrepareRepoInput[] = [];
cancelCalled = false;
disposeCalled = false;
private listeners = new Set<(event: OrbEvent) => void>();
private sendCallCount = 0;
constructor(orbId: string, runId: string, config: FakeOrbConfig) {
this.orbId = orbId;
this.runId = runId;
this.config = config;
}
onEvent(listener: (event: OrbEvent) => void): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
private emit(events: readonly OrbEvent[]): void {
for (const event of events) {
for (const listener of this.listeners) {
listener(event);
}
}
}
prepareRepository(input: PrepareRepoInput): Promise<void> {
this.prepareCalls.push(input);
if (this.config.failOnPrepare) {
return Promise.reject(new Error("Fake: prepareRepository failed"));
}
this.state = "prepared";
return Promise.resolve();
}
openSession(): Promise<string> {
this.sessionId = `session-${this.orbId}`;
this.state = "running";
return Promise.resolve(this.sessionId);
}
sendTask(prompt: string): Promise<unknown> {
this.sentTasks.push(prompt);
const callIndex = this.sendCallCount;
this.sendCallCount += 1;
if (callIndex === 0 && this.config.failOnFirstSend) {
return Promise.reject(new Error("Fake: sendTask failed on first call"));
}
const events =
this.config.eventsByTurn?.[callIndex] ?? this.config.defaultEvents ?? [];
this.emit(events);
return Promise.resolve({ ok: true });
}
executeCommand(
command: string,
_cwd?: string
): Promise<{ exitCode: number; stderr: string; stdout: string }> {
void this.config;
return Promise.resolve({
exitCode: 0,
stderr: "",
stdout: `fake: ${command}`,
});
}
cancel(): Promise<void> {
this.cancelCalled = true;
this.state = "cancelled";
return Promise.resolve();
}
dispose(): Promise<void> {
this.disposeCalled = true;
this.state = "disposed";
return Promise.resolve();
}
}
// ---------------------------------------------------------------------------
// Fake Orb adapter
// ---------------------------------------------------------------------------
const createFakeOrbAdapter = (
config?: FakeOrbConfig,
counter?: { value: number }
): { adapter: OrbAdapter; runs: FakeOrbRun[] } => {
const runs: FakeOrbRun[] = [];
const cfg = config ?? {};
const cnt = counter ?? { value: 0 };
const adapter: OrbAdapter = {
createOrb(_input: OrbCreatePortInput): Promise<OrbRunPort> {
cnt.value += 1;
const orbId = `orb-fake-${cnt.value}`;
const runId = `run-fake-${cnt.value}`;
const run = new FakeOrbRun(orbId, runId, cfg);
runs.push(run);
return Promise.resolve(run);
},
};
return { adapter, runs };
};
// ---------------------------------------------------------------------------
// Fake Git lifecycle adapter
// ---------------------------------------------------------------------------
interface FakeGitConfig {
readonly result?: GitLifecycleResult;
readonly failWith?: Error;
readonly failOnFirstAttempt?: boolean;
}
const createFakeGitLifecycle = (
config?: FakeGitConfig
): { port: GitLifecyclePort; publishCalls: GitPublishInput[] } => {
const cfg = config ?? {};
const publishCalls: GitPublishInput[] = [];
let attemptCount = 0;
const defaultResult: GitLifecycleResult = {
baseBranch: "main",
branch: "work/issue-42",
commitSha: "abc123def",
pullRequest: {
baseBranch: "main",
branch: "work/issue-42",
number: 7,
status: "open",
url: "https://git.example.com/repo/pulls/7",
},
status: "pull_request_open",
};
const port: GitLifecyclePort = {
publish(input: GitPublishInput): Promise<GitLifecycleResult> {
publishCalls.push(input);
attemptCount += 1;
if (cfg.failWith && attemptCount === 1) {
return Promise.reject(cfg.failWith);
}
if (cfg.failOnFirstAttempt && attemptCount === 1) {
return Promise.reject(
new Error("Fake: PR creation failed on first attempt")
);
}
return Promise.resolve(cfg.result ?? defaultResult);
},
};
return { port, publishCalls };
};
// ---------------------------------------------------------------------------
// Shared fixtures
// ---------------------------------------------------------------------------
const baseStartInput = (
overrides?: Partial<StartIssueInputTest>
): StartIssueInputTest => ({
baseBranch: "main",
branchName: "work/issue-42",
context: {
artifacts: [],
contextFiles: [],
issueBody: "Add a hello world endpoint",
issueTitle: "Add hello endpoint",
repositoryUrl: undefined,
},
contextPack: {
artifacts: [],
contextFiles: [],
evidence: [],
issueBody: "Add a hello world endpoint",
issueNumber: 42,
issueTitle: "Add hello endpoint",
repositoryMetadata: {
baseBranch: "main",
repositoryName: "test-repo",
repositoryUrl: "https://git.example.com/repo",
},
},
docker: { hostWorkspacePath: "/tmp/test" },
gateway: {
apiKey: "test-key",
baseUrl: "https://gw.example.com/v1",
model: "m1",
provider: "p1",
},
issueId: "issue-42",
issueNumber: 42,
issueTitle: "Add hello endpoint",
projectId: "prj-1",
repositoryPath: "org/repo",
runId: "run-42",
...overrides,
});
type StartIssueInputTest = Parameters<OrbProjectManager["startIssue"]>[0];
const makeMessageEvent = (text: string, sequence: number): OrbEvent =>
makeOrbEvent(sequence, "agent_message_completed", { text });
const makeCommandEvent = (
command: string,
exitCode: number,
sequence: number
): OrbEvent =>
makeOrbEvent(sequence, "command_executed", { command, exitCode });
// ===========================================================================
// TESTS
// ===========================================================================
describe("OrbProjectManager", () => {
// -----------------------------------------------------------------------
// 1. First-message start
// -----------------------------------------------------------------------
describe("first-message start", () => {
it("creates an Orb run, prepares the repo, opens a session, and sends the context pack", async () => {
const { adapter, runs } = createFakeOrbAdapter({
defaultEvents: [makeMessageEvent("WORK_COMPLETE: done", 1)],
});
const events: ProjectRunEvent[] = [];
const pm = new OrbProjectManager({
createGitLifecycle: () => createFakeGitLifecycle().port,
onProjectEvent: (e) => events.push(e),
orbAdapter: adapter,
});
const result = await pm.startIssue(baseStartInput());
expect(result.issueId).toBe("issue-42");
expect(result.status).toBe("completing");
expect(result.sessionId).toBeDefined();
expect(runs).toHaveLength(1);
expect(runs[0]!.prepareCalls).toHaveLength(1);
expect(runs[0]!.prepareCalls[0]?.branchName).toBe("work/issue-42");
expect(runs[0]!.sentTasks).toHaveLength(1);
expect(runs[0]!.sentTasks[0]).toContain("Add hello endpoint");
const eventTypes = events.map((e) => e.type);
expect(eventTypes).toContain("run.started");
expect(eventTypes).toContain("run.repository_prepared");
expect(eventTypes).toContain("run.session_opened");
});
it("maps OrbEvents into durable project events and forwards them to the sink", async () => {
const { adapter } = createFakeOrbAdapter({
defaultEvents: [
makeOrbEvent(1, "tool_call_started", { toolName: "edit_file" }),
makeCommandEvent("npm test", 0, 2),
makeMessageEvent("WORK_COMPLETE: all tests pass", 3),
],
});
const events: ProjectRunEvent[] = [];
const pm = new OrbProjectManager({
createGitLifecycle: () => createFakeGitLifecycle().port,
onProjectEvent: (e) => events.push(e),
orbAdapter: adapter,
});
await pm.startIssue(baseStartInput());
const types = events.map((e) => e.type);
expect(types).toContain("run.agent_progress");
expect(types).toContain("run.command_executed");
expect(types).toContain("run.agent_message");
const cmdEvent = events.find((e) => e.type === "run.command_executed");
expect(cmdEvent?.text).toBe("npm test");
expect(cmdEvent?.exitCode).toBe(0);
});
});
// -----------------------------------------------------------------------
// 2. Follow-up forwarding
// -----------------------------------------------------------------------
describe("follow-up forwarding", () => {
it("forwards a contextual message to the same OpenCode session", async () => {
const { adapter, runs } = createFakeOrbAdapter({
eventsByTurn: [
[makeMessageEvent("NEEDS_INPUT: what name?", 1)],
[makeMessageEvent("WORK_COMPLETE: done", 2)],
],
});
const pm = new OrbProjectManager({
createGitLifecycle: () => createFakeGitLifecycle().port,
orbAdapter: adapter,
});
const start = await pm.startIssue(baseStartInput());
expect(start.status).toBe("needs-input");
expect(start.needsInputQuestion).toBe("what name?");
const followUp = await pm.sendMessage("issue-42", "Name it /hello");
expect(followUp.status).toBe("completing");
expect(runs[0]!.sentTasks).toHaveLength(2);
expect(runs[0]!.sentTasks[1]).toBe("Name it /hello");
});
it("rejects a follow-up for a non-existent run", async () => {
const { adapter } = createFakeOrbAdapter();
const pm = new OrbProjectManager({
createGitLifecycle: () => createFakeGitLifecycle().port,
orbAdapter: adapter,
});
await expect(pm.sendMessage("nope", "hello")).rejects.toThrow();
try {
await pm.sendMessage("nope", "hello");
} catch (error) {
expect(error).toBeInstanceOf(ProjectManagerError);
expect((error as ProjectManagerError).reason).toBe("RunNotFound");
}
});
it("rejects a follow-up after cancellation", async () => {
const { adapter } = createFakeOrbAdapter({
defaultEvents: [makeMessageEvent("NEEDS_INPUT: hmm", 1)],
});
const pm = new OrbProjectManager({
createGitLifecycle: () => createFakeGitLifecycle().port,
orbAdapter: adapter,
});
await pm.startIssue(baseStartInput());
await pm.cancel("issue-42");
await expect(pm.sendMessage("issue-42", "hello")).rejects.toThrow();
try {
await pm.sendMessage("issue-42", "hello");
} catch (error) {
expect(error).toBeInstanceOf(ProjectManagerError);
expect((error as ProjectManagerError).reason).toBe("RunTerminal");
}
});
});
// -----------------------------------------------------------------------
// 3. Needs-input handling
// -----------------------------------------------------------------------
describe("needs-input handling", () => {
it("detects the needs-input marker and surfaces the question", async () => {
const { adapter } = createFakeOrbAdapter({
defaultEvents: [
makeMessageEvent("NEEDS_INPUT: Should I use GET or POST?", 1),
],
});
const pm = new OrbProjectManager({
createGitLifecycle: () => createFakeGitLifecycle().port,
orbAdapter: adapter,
});
const result = await pm.startIssue(baseStartInput());
expect(result.status).toBe("needs-input");
expect(result.needsInputQuestion).toBe("Should I use GET or POST?");
const events = pm.getRunEvents("issue-42");
expect(events.some((e) => e.type === "run.needs_input")).toBe(true);
});
it("clears the needs-input condition when a follow-up is sent", async () => {
const { adapter } = createFakeOrbAdapter({
eventsByTurn: [
[makeMessageEvent("NEEDS_INPUT: clarify?", 1)],
[makeMessageEvent("WORK_COMPLETE: done", 2)],
],
});
const pm = new OrbProjectManager({
createGitLifecycle: () => createFakeGitLifecycle().port,
orbAdapter: adapter,
});
await pm.startIssue(baseStartInput());
expect(pm.getRunStatus("issue-42")).toBe("needs-input");
const result = await pm.sendMessage("issue-42", "Use POST");
expect(result.status).toBe("completing");
expect(result.needsInputQuestion).toBeUndefined();
});
});
// -----------------------------------------------------------------------
// 4. Cancellation
// -----------------------------------------------------------------------
describe("cancellation", () => {
it("cancels and disposes the Orb run", async () => {
const { adapter, runs } = createFakeOrbAdapter({
defaultEvents: [makeMessageEvent("NEEDS_INPUT: wait", 1)],
});
const pm = new OrbProjectManager({
createGitLifecycle: () => createFakeGitLifecycle().port,
orbAdapter: adapter,
});
await pm.startIssue(baseStartInput());
await pm.cancel("issue-42");
expect(runs[0]!.cancelCalled).toBe(true);
expect(runs[0]!.disposeCalled).toBe(true);
expect(pm.getRunStatus("issue-42")).toBe("cancelled");
});
it("is idempotent — cancelling a non-existent run is a no-op", async () => {
const { adapter } = createFakeOrbAdapter();
const pm = new OrbProjectManager({
createGitLifecycle: () => createFakeGitLifecycle().port,
orbAdapter: adapter,
});
await expect(pm.cancel("nonexistent")).resolves.toBeUndefined();
});
it("is idempotent — cancelling twice does not error", async () => {
const { adapter } = createFakeOrbAdapter({
defaultEvents: [makeMessageEvent("working...", 1)],
});
const pm = new OrbProjectManager({
createGitLifecycle: () => createFakeGitLifecycle().port,
orbAdapter: adapter,
});
await pm.startIssue(baseStartInput());
await pm.cancel("issue-42");
await pm.cancel("issue-42");
expect(pm.getRunStatus("issue-42")).toBe("cancelled");
});
});
// -----------------------------------------------------------------------
// 5. Duplicate-start prevention
// -----------------------------------------------------------------------
describe("duplicate-start prevention", () => {
it("does not create a second Orb run for an active issue", async () => {
const { adapter, runs } = createFakeOrbAdapter({
defaultEvents: [makeMessageEvent("NEEDS_INPUT: hmm", 1)],
});
const pm = new OrbProjectManager({
createGitLifecycle: () => createFakeGitLifecycle().port,
orbAdapter: adapter,
});
const first = await pm.startIssue(baseStartInput());
const second = await pm.startIssue(baseStartInput());
expect(runs).toHaveLength(1);
expect(second.orbId).toBe(first.orbId);
expect(second.status).toBe("needs-input");
});
it("allows starting a new run after the previous one completed", async () => {
const { adapter, runs } = createFakeOrbAdapter({
defaultEvents: [makeMessageEvent("WORK_COMPLETE: done", 1)],
});
const { port: gitPort } = createFakeGitLifecycle();
const pm = new OrbProjectManager({
createGitLifecycle: () => gitPort,
orbAdapter: adapter,
});
await pm.startIssue(baseStartInput());
await pm.complete({ issueId: "issue-42" });
expect(pm.getRunStatus("issue-42")).toBe("completed");
// A terminal run allows re-creating via startIssue.
const second = await pm.startIssue(baseStartInput());
expect(runs.length).toBeGreaterThanOrEqual(2);
expect(second.orbId).not.toBe(runs[0]!.orbId);
});
});
// -----------------------------------------------------------------------
// 6. Successful PR completion
// -----------------------------------------------------------------------
describe("successful PR completion", () => {
it("runs the Git lifecycle, creates a PR, stores artifacts, and marks completed", async () => {
const { adapter } = createFakeOrbAdapter({
defaultEvents: [makeMessageEvent("WORK_COMPLETE: implemented", 1)],
});
const artifacts: ProjectArtifact[] = [];
const { port: gitPort, publishCalls } = createFakeGitLifecycle();
const pm = new OrbProjectManager({
createGitLifecycle: () => gitPort,
onArtifact: (a) => artifacts.push(a),
orbAdapter: adapter,
});
await pm.startIssue(baseStartInput());
const result = await pm.complete({ issueId: "issue-42" });
expect(result.status).toBe("pull_request_open");
expect(result.pullRequest?.number).toBe(7);
expect(pm.getRunStatus("issue-42")).toBe("completed");
expect(publishCalls).toHaveLength(1);
expect(publishCalls[0]?.branchName).toBe("work/issue-42");
const artifactTypes = artifacts.map((a) => a.type);
expect(artifactTypes).toContain("branch");
expect(artifactTypes).toContain("commit");
expect(artifactTypes).toContain("pull_request");
expect(artifactTypes).toContain("agent_summary");
});
it("marks completed with no_changes when the Git lifecycle reports no changes", async () => {
const { adapter } = createFakeOrbAdapter({
defaultEvents: [makeMessageEvent("WORK_COMPLETE: nothing to do", 1)],
});
const { port: gitPort } = createFakeGitLifecycle({
result: {
baseBranch: "main",
branch: "work/issue-42",
status: "no_changes",
},
});
const pm = new OrbProjectManager({
createGitLifecycle: () => gitPort,
orbAdapter: adapter,
});
await pm.startIssue(baseStartInput());
const result = await pm.complete({ issueId: "issue-42" });
expect(result.status).toBe("no_changes");
expect(pm.getRunStatus("issue-42")).toBe("completed");
});
});
// -----------------------------------------------------------------------
// 7. Failed PR creation recovery
// -----------------------------------------------------------------------
describe("failed PR creation recovery", () => {
it("marks the run as failed and raises a tagged error when PR creation fails", async () => {
const { adapter } = createFakeOrbAdapter({
defaultEvents: [makeMessageEvent("WORK_COMPLETE: done", 1)],
});
const { port: gitPort } = createFakeGitLifecycle({
failWith: new Error("Remote rejected push"),
});
const pm = new OrbProjectManager({
createGitLifecycle: () => gitPort,
orbAdapter: adapter,
});
await pm.startIssue(baseStartInput());
try {
await pm.complete({ issueId: "issue-42" });
expect.fail("Should have thrown");
} catch (error) {
expect(error).toBeInstanceOf(ProjectManagerError);
expect((error as ProjectManagerError).reason).toBe("GitRejection");
}
expect(pm.getRunStatus("issue-42")).toBe("failed");
});
it("allows retrying completion after a failed attempt (idempotent commit+push)", async () => {
const { adapter } = createFakeOrbAdapter({
defaultEvents: [makeMessageEvent("WORK_COMPLETE: done", 1)],
});
const { port: gitPort, publishCalls } = createFakeGitLifecycle({
failOnFirstAttempt: true,
});
const pm = new OrbProjectManager({
createGitLifecycle: () => gitPort,
orbAdapter: adapter,
});
await pm.startIssue(baseStartInput());
try {
await pm.complete({ issueId: "issue-42" });
} catch {
// expected first-attempt failure
}
expect(pm.getRunStatus("issue-42")).toBe("failed");
// Retry: the Orb run still exists, the branch is preserved.
const result = await pm.complete({ issueId: "issue-42" });
expect(result.status).toBe("pull_request_open");
expect(pm.getRunStatus("issue-42")).toBe("completed");
expect(publishCalls).toHaveLength(2);
});
});
// -----------------------------------------------------------------------
// 8. Infrastructure failure mapping
// -----------------------------------------------------------------------
describe("infrastructure failure mapping", () => {
it("maps a failed Orb creation to InfrastructureFailure", async () => {
const failingAdapter: OrbAdapter = {
createOrb: () => Promise.reject(new Error("Docker daemon unavailable")),
};
const pm = new OrbProjectManager({
createGitLifecycle: () => createFakeGitLifecycle().port,
orbAdapter: failingAdapter,
});
try {
await pm.startIssue(baseStartInput());
expect.fail("Should have thrown");
} catch (error) {
expect(error).toBeInstanceOf(ProjectManagerError);
expect((error as ProjectManagerError).reason).toBe(
"InfrastructureFailure"
);
}
});
it("maps a failed prepareRepository to InfrastructureFailure", async () => {
const { adapter } = createFakeOrbAdapter({
failOnPrepare: true,
});
const pm = new OrbProjectManager({
createGitLifecycle: () => createFakeGitLifecycle().port,
orbAdapter: adapter,
});
try {
await pm.startIssue(baseStartInput());
expect.fail("Should have thrown");
} catch (error) {
expect(error).toBeInstanceOf(ProjectManagerError);
expect((error as ProjectManagerError).reason).toBe(
"InfrastructureFailure"
);
}
});
it("does not create duplicate events from idempotent completion", async () => {
const { adapter } = createFakeOrbAdapter({
defaultEvents: [makeMessageEvent("WORK_COMPLETE: done", 1)],
});
const { port: gitPort } = createFakeGitLifecycle();
const pm = new OrbProjectManager({
createGitLifecycle: () => gitPort,
orbAdapter: adapter,
});
await pm.startIssue(baseStartInput());
await pm.complete({ issueId: "issue-42" });
const eventsBefore = pm.getRunEvents("issue-42").length;
const result = await pm.complete({ issueId: "issue-42" });
const eventsAfter = pm.getRunEvents("issue-42").length;
// Idempotent: returns cached result without duplicating events.
expect(result.status).toBe("pull_request_open");
expect(eventsAfter).toBe(eventsBefore);
});
});
});

View File

@@ -0,0 +1,636 @@
/* eslint-disable max-classes-per-file -- domain errors are grouped by concern. */
import { Schema } from "effect";
import { buildContextPack } from "./context-pack";
import type { ContextPackInput } from "./context-pack";
import type { OrbEvent } from "./events";
import type {
GitLifecyclePort,
GitLifecycleResult,
OrbAdapter,
OrbCreatePortInput,
OrbRunPort,
ProjectArtifact,
RunStatus,
} from "./ports";
import { isWorkComplete, mapOrbEvent } from "./project-events";
import type { ProjectRunEvent } from "./project-events";
// ---------------------------------------------------------------------------
// Tagged errors — the failure-mapping surface
// ---------------------------------------------------------------------------
export const ProjectManagerErrorReason = Schema.Literals([
"RunNotFound",
"SessionNotReady",
"RunTerminal",
"DuplicateActiveRun",
"InfrastructureFailure",
"NeedsInput",
"GitRejection",
"PullRequestFailure",
"Cancelled",
"UnrecoverableFailure",
]);
export type ProjectManagerErrorReason = typeof ProjectManagerErrorReason.Type;
export class ProjectManagerError extends Schema.TaggedErrorClass<ProjectManagerError>()(
"ProjectManagerError",
{
issueId: Schema.String,
message: Schema.String,
reason: ProjectManagerErrorReason,
}
) {}
// ---------------------------------------------------------------------------
// Active run record — one per managed issue
// ---------------------------------------------------------------------------
interface ActiveRun {
readonly issueId: string;
readonly orbId: string;
readonly runId: string;
readonly orb: OrbRunPort;
sessionId: string | undefined;
status: RunStatus;
readonly projectEvents: ProjectRunEvent[];
result: GitLifecycleResult | undefined;
needsInputQuestion: string | undefined;
readonly contextPack: string;
lastTurnEventIndex: number;
readonly baseBranch: string;
readonly branchName: string;
readonly issueNumber: number;
readonly issueTitle: string;
readonly repositoryPath: string;
readonly workspacePath: string;
}
// ---------------------------------------------------------------------------
// Dependencies injected into the orchestrator
// ---------------------------------------------------------------------------
export interface OrbProjectManagerDeps {
readonly orbAdapter: OrbAdapter;
readonly createGitLifecycle: (orb: OrbRunPort) => GitLifecyclePort;
readonly onProjectEvent?: (event: ProjectRunEvent) => void;
readonly onArtifact?: (artifact: ProjectArtifact) => void;
}
// ---------------------------------------------------------------------------
// Input types for the orchestration API
// ---------------------------------------------------------------------------
export interface StartIssueInput {
readonly issueId: string;
readonly projectId: string;
readonly runId: string;
readonly context: OrbCreatePortInput["context"];
readonly gateway: OrbCreatePortInput["gateway"];
readonly docker: OrbCreatePortInput["docker"];
readonly baseBranch: string;
readonly branchName: string;
readonly contextPack: ContextPackInput;
readonly workspacePath?: string;
readonly repositoryPath?: string;
readonly issueNumber?: number;
readonly issueTitle?: string;
}
export interface StartIssueResult {
readonly issueId: string;
readonly orbId: string;
readonly runId: string;
readonly sessionId: string | undefined;
readonly status: RunStatus;
readonly needsInputQuestion?: string;
}
export interface SendMessageResult {
readonly issueId: string;
readonly status: RunStatus;
readonly needsInputQuestion?: string;
}
export interface CompleteInput {
readonly issueId: string;
readonly verification?: "passed" | "failed" | "not-run";
readonly commitMessage?: string;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const isTerminal = (status: RunStatus): boolean =>
status === "completed" || status === "failed" || status === "cancelled";
const isSessionValid = (run: ActiveRun): boolean =>
run.sessionId !== undefined && !isTerminal(run.status);
const timestamp = () => new Date().toISOString();
const wrapError = (
error: unknown,
issueId: string,
reason: ProjectManagerErrorReason,
fallback: string
): ProjectManagerError => {
const message = error instanceof Error ? error.message : String(error);
return new ProjectManagerError({
issueId,
message: message.length > 0 ? message : fallback,
reason,
});
};
const isGitRejection = (error: unknown): boolean => {
if (!(error instanceof Error)) {
return false;
}
const message = error.message.toLowerCase();
return (
message.includes("rejected") ||
message.includes("authentication") ||
message.includes("permission denied") ||
message.includes("remote")
);
};
// ---------------------------------------------------------------------------
// OrbProjectManager — thin orchestration agent over the merged Orb runtime
//
// Responsibilities:
// - Create or resume an Orb run per issue (idempotent)
// - Assemble a context pack and send it as the implementation objective
// - Project OrbEvents into durable ProjectRunEvents (no parallel event system)
// - Detect needs-input and work-complete conditions
// - Forward follow-up messages to the same OpenCode session
// - Drive the Git publish lifecycle on completion (never auto-merge)
// - Store branch/commit/diff/PR/summary as project artifacts
// ---------------------------------------------------------------------------
export class OrbProjectManager {
private readonly activeRuns = new Map<string, ActiveRun>();
private readonly deps: OrbProjectManagerDeps;
constructor(deps: OrbProjectManagerDeps) {
this.deps = deps;
}
// -----------------------------------------------------------------------
// Public state queries
// -----------------------------------------------------------------------
getRunStatus(issueId: string): RunStatus | undefined {
return this.activeRuns.get(issueId)?.status;
}
getRunEvents(issueId: string): readonly ProjectRunEvent[] {
return this.activeRuns.get(issueId)?.projectEvents ?? [];
}
getRunResult(issueId: string): GitLifecycleResult | undefined {
return this.activeRuns.get(issueId)?.result;
}
// -----------------------------------------------------------------------
// Start or resume an Orb run for an issue
// -----------------------------------------------------------------------
async startIssue(input: StartIssueInput): Promise<StartIssueResult> {
const existing = this.activeRuns.get(input.issueId);
// Idempotency: a still-active run is reused, never duplicated.
if (existing && !isTerminal(existing.status)) {
return {
issueId: input.issueId,
needsInputQuestion: existing.needsInputQuestion,
orbId: existing.orbId,
runId: existing.runId,
sessionId: existing.sessionId,
status: existing.status,
};
}
let orb: OrbRunPort;
try {
orb = await this.deps.orbAdapter.createOrb({
context: input.context,
docker: input.docker,
gateway: input.gateway,
identity: {
projectId: input.projectId,
runId: input.runId,
workUnitId: input.issueId,
},
});
} catch (error) {
throw wrapError(
error,
input.issueId,
"InfrastructureFailure",
"Failed to create Orb run"
);
}
const contextPack = buildContextPack(input.contextPack);
const run: ActiveRun = {
baseBranch: input.baseBranch,
branchName: input.branchName,
contextPack,
issueId: input.issueId,
issueNumber: input.issueNumber ?? 0,
issueTitle: input.issueTitle ?? input.issueId,
lastTurnEventIndex: 0,
needsInputQuestion: undefined,
orb,
orbId: orb.orbId,
projectEvents: [],
repositoryPath: input.repositoryPath ?? "",
result: undefined,
runId: orb.runId,
sessionId: undefined,
status: "starting",
workspacePath: input.workspacePath ?? "/mnt/sandbox/repository",
};
this.activeRuns.set(input.issueId, run);
// Subscribe to OrbEvents and project them into durable ProjectRunEvents.
orb.onEvent((orbEvent: OrbEvent) => {
this.processOrbEvent(orbEvent, run);
});
this.emitProjectEvent(run, "run.started", {
text: `Started Orb run for issue ${input.issueId}`,
});
try {
await orb.prepareRepository({
baseBranch: input.baseBranch,
branchName: input.branchName,
});
this.emitProjectEvent(run, "run.repository_prepared", {
text: `Repository prepared on branch ${input.branchName}`,
});
const sessionId = await orb.openSession();
run.sessionId = sessionId;
run.status = "working";
this.emitProjectEvent(run, "run.session_opened", {
text: `Session ${sessionId} opened`,
});
// Record turn boundary before sending the implementation objective.
run.lastTurnEventIndex = run.projectEvents.length;
// Send the implementation objective (the assembled context pack).
await orb.sendTask(contextPack);
// After the turn, check for needs-input or work-complete signals.
OrbProjectManager.evaluateTurnOutcome(run);
} catch (error) {
if (error instanceof ProjectManagerError) {
throw error;
}
throw wrapError(
error,
input.issueId,
"InfrastructureFailure",
"Orb run failed during startup"
);
}
return {
issueId: input.issueId,
needsInputQuestion: run.needsInputQuestion,
orbId: run.orbId,
runId: run.runId,
sessionId: run.sessionId,
status: run.status,
};
}
// -----------------------------------------------------------------------
// Forward a follow-up message to the same OpenCode session
// -----------------------------------------------------------------------
async sendMessage(
issueId: string,
message: string
): Promise<SendMessageResult> {
const run = this.activeRuns.get(issueId);
if (!run) {
throw new ProjectManagerError({
issueId,
message: `No active run for issue ${issueId}`,
reason: "RunNotFound",
});
}
if (isTerminal(run.status)) {
throw new ProjectManagerError({
issueId,
message: `Run for issue ${issueId} is in terminal state ${run.status}`,
reason: "RunTerminal",
});
}
if (!isSessionValid(run)) {
throw new ProjectManagerError({
issueId,
message: `Session is not open for issue ${issueId}`,
reason: "SessionNotReady",
});
}
// Clear any prior needs-input condition and record turn boundary.
run.needsInputQuestion = undefined;
run.status = "working";
run.lastTurnEventIndex = run.projectEvents.length;
try {
await run.orb.sendTask(message);
OrbProjectManager.evaluateTurnOutcome(run);
} catch (error) {
throw wrapError(
error,
issueId,
"InfrastructureFailure",
"Failed to forward message to OpenCode session"
);
}
return {
issueId,
needsInputQuestion: run.needsInputQuestion,
status: run.status,
};
}
// -----------------------------------------------------------------------
// Cancel — terminate OpenCode and sandbox, then dispose
// -----------------------------------------------------------------------
async cancel(issueId: string): Promise<void> {
const run = this.activeRuns.get(issueId);
if (!run) {
// Idempotent cancel: a non-existent run is already "cancelled".
return;
}
if (run.status === "cancelled") {
return;
}
try {
await run.orb.cancel();
} catch {
// best-effort: proceed to dispose even if cancel failed
}
try {
await run.orb.dispose();
} catch {
// best-effort cleanup
}
run.status = "cancelled";
this.emitProjectEvent(run, "run.cancelled", {
text: "Run cancelled by user",
});
}
// -----------------------------------------------------------------------
// Complete — run Git publish lifecycle, store artifacts, mark completed
// -----------------------------------------------------------------------
async complete(input: CompleteInput): Promise<GitLifecycleResult> {
const run = this.activeRuns.get(input.issueId);
if (!run) {
throw new ProjectManagerError({
issueId: input.issueId,
message: `No active run for issue ${input.issueId}`,
reason: "RunNotFound",
});
}
// Idempotency: a completed run with an existing result is returned as-is.
if (run.status === "completed" && run.result !== undefined) {
return run.result;
}
if (run.status === "cancelled") {
throw new ProjectManagerError({
issueId: input.issueId,
message: "Cannot complete a cancelled run",
reason: "Cancelled",
});
}
const git = this.deps.createGitLifecycle(run.orb);
const verification = input.verification ?? "passed";
run.status = "completing";
let gitResult: GitLifecycleResult;
try {
gitResult = await git.publish({
baseBranch: run.baseBranch,
branchName: run.branchName,
commitMessage: input.commitMessage,
issueNumber: run.issueNumber,
issueTitle: run.issueTitle,
repositoryPath: run.repositoryPath,
verification,
workspace: run.workspacePath,
});
} catch (error) {
run.status = "failed";
const reason = isGitRejection(error)
? "GitRejection"
: "PullRequestFailure";
this.emitProjectEvent(run, "run.failed", {
text: error instanceof Error ? error.message : String(error),
});
throw wrapError(
error,
input.issueId,
reason,
"Git publish lifecycle failed"
);
}
run.result = gitResult;
this.storeArtifacts(run, gitResult);
// Mark completed only when a PR exists or a verified no-change result.
if (
(gitResult.status === "pull_request_open" && gitResult.pullRequest) ||
gitResult.status === "no_changes"
) {
run.status = "completed";
this.emitProjectEvent(run, "run.completed", {
text: gitResult.pullRequest
? `PR #${gitResult.pullRequest.number} created: ${gitResult.pullRequest.url}`
: "No changes to publish",
});
} else {
run.status = "failed";
this.emitProjectEvent(run, "run.failed", {
text: `Git lifecycle stopped at ${gitResult.status} without a pull request`,
});
throw new ProjectManagerError({
issueId: input.issueId,
message: `Git lifecycle did not produce a pull request (status: ${gitResult.status})`,
reason: "PullRequestFailure",
});
}
return gitResult;
}
// -----------------------------------------------------------------------
// Dispose all runs (for graceful shutdown)
// -----------------------------------------------------------------------
async disposeAll(): Promise<void> {
const issues = [...this.activeRuns.keys()];
await Promise.allSettled(
issues.map(async (issueId) => {
const run = this.activeRuns.get(issueId);
if (run && !isTerminal(run.status)) {
try {
await run.orb.dispose();
} catch {
// best-effort
}
}
})
);
}
// -----------------------------------------------------------------------
// Internal: OrbEvent processing
// -----------------------------------------------------------------------
private processOrbEvent(orbEvent: OrbEvent, run: ActiveRun): void {
const projectEvent = mapOrbEvent(orbEvent, run.issueId, run.runId);
if (projectEvent !== undefined) {
run.projectEvents.push(projectEvent);
this.deps.onProjectEvent?.(projectEvent);
if (
projectEvent.type === "run.needs_input" &&
run.needsInputQuestion === undefined
) {
run.needsInputQuestion = projectEvent.text;
}
}
}
// -----------------------------------------------------------------------
// Internal: evaluate the outcome of a completed model turn
// -----------------------------------------------------------------------
private static evaluateTurnOutcome(run: ActiveRun): void {
// Only scan events from the current turn (after the last turn boundary).
const turnEvents = run.projectEvents.slice(run.lastTurnEventIndex);
// Check for needs-input: the mapOrbEvent step already extracted the marker
// into a run.needs_input event with the question text. A run.needs_input
// event IS the signal — no need to re-extract the marker from its text.
const needsInputEvent = [...turnEvents]
.toReversed()
.find((event) => event.type === "run.needs_input");
if (needsInputEvent !== undefined) {
run.status = "needs-input";
run.needsInputQuestion = needsInputEvent.text ?? "Agent requires input";
return;
}
// Check for work-complete marker in agent messages from this turn.
const turnMessages = turnEvents.filter(
(event) => event.type === "run.agent_message"
);
const hasWorkComplete = turnMessages.some(
(event) => event.text !== undefined && isWorkComplete(event.text)
);
if (hasWorkComplete && run.status === "working") {
run.status = "completing";
}
}
// -----------------------------------------------------------------------
// Internal: emit a synthetic project event (not derived from an OrbEvent)
// -----------------------------------------------------------------------
private emitProjectEvent(
run: ActiveRun,
type: ProjectRunEvent["type"],
fields: { text?: string; exitCode?: number; toolName?: string }
): void {
const event: ProjectRunEvent = {
exitCode: fields.exitCode,
issueId: run.issueId,
runId: run.runId,
sequence: run.projectEvents.length + 1,
text: fields.text,
timestamp: timestamp(),
toolName: fields.toolName,
type,
};
run.projectEvents.push(event);
this.deps.onProjectEvent?.(event);
}
// -----------------------------------------------------------------------
// Internal: store artifacts from the Git lifecycle result
// -----------------------------------------------------------------------
private storeArtifacts(run: ActiveRun, result: GitLifecycleResult): void {
const ts = timestamp();
const base = { issueId: run.issueId, runId: run.runId };
const emitArtifact = (
type: ProjectArtifact["type"],
path: string,
content: string
): void => {
const artifact: ProjectArtifact = {
...base,
content,
path,
timestamp: ts,
type,
};
this.deps.onArtifact?.(artifact);
};
emitArtifact("branch", "branch.txt", result.branch);
if (result.commitSha) {
emitArtifact("commit", "commit.txt", result.commitSha);
}
if (result.pullRequest) {
emitArtifact(
"pull_request",
"pull_request.json",
JSON.stringify(result.pullRequest, null, 2)
);
}
const lastMessage = [...run.projectEvents]
.toReversed()
.find(
(event) =>
event.type === "run.agent_message" || event.type === "run.needs_input"
);
if (lastMessage?.text) {
emitArtifact("agent_summary", "summary.md", lastMessage.text);
}
}
}

View File

@@ -0,0 +1,107 @@
import { describe, expect, it } from "vitest";
import { evaluatePermission, isDangerousPermission } from "./permission-policy";
const allowOption = { id: "allow", title: "Allow" };
const denyOption = { id: "deny", title: "Deny" };
describe("isDangerousPermission", () => {
it("flags merge operations", () => {
expect(
isDangerousPermission({
requestId: "r1",
toolCall: { title: "git merge main" },
})
).toBe(true);
});
it("flags production deployment", () => {
expect(
isDangerousPermission({
requestId: "r2",
toolCall: { title: "deploy to production" },
})
).toBe(true);
});
it("flags secret access", () => {
expect(
isDangerousPermission({
requestId: "r3",
toolCall: { title: "read secrets" },
})
).toBe(true);
});
it("flags credential access", () => {
expect(
isDangerousPermission({
requestId: "r4",
toolCall: { title: "access credentials" },
})
).toBe(true);
});
it("does not flag safe operations", () => {
expect(
isDangerousPermission({
requestId: "r5",
toolCall: { title: "run tests" },
})
).toBe(false);
});
it("does not flag file edits", () => {
expect(
isDangerousPermission({
requestId: "r6",
toolCall: { title: "edit src/index.ts" },
})
).toBe(false);
});
});
describe("evaluatePermission", () => {
it("allows safe operations and picks allow option", () => {
const decision = evaluatePermission({
options: [allowOption, denyOption],
requestId: "r1",
toolCall: { title: "run bun test" },
});
expect(decision.allow).toBe(true);
expect(decision.optionId).toBe("allow");
});
it("denies dangerous operations and picks deny option", () => {
const decision = evaluatePermission({
options: [allowOption, denyOption],
requestId: "r2",
toolCall: { title: "git merge main" },
});
expect(decision.allow).toBe(false);
expect(decision.optionId).toBe("deny");
});
it("falls back to last option when no explicit deny exists", () => {
const decision = evaluatePermission({
options: [
{ id: "ok", title: "OK" },
{ id: "cancel", title: "Cancel" },
],
requestId: "r3",
toolCall: { title: "deploy to production" },
});
expect(decision.allow).toBe(false);
expect(decision.optionId).toBe("cancel");
});
it("handles empty options", () => {
const decision = evaluatePermission({
options: [],
requestId: "r4",
toolCall: { title: "run tests" },
});
expect(decision.allow).toBe(true);
expect(decision.optionId).toBeUndefined();
});
});

View File

@@ -0,0 +1,90 @@
/**
* Narrow permission policy for Orb sessions.
*
* Denies merge, production deployment, secret access, and external
* communications. Allows all other operations. Used as the callback for
* ACP permission_request events with permissionPolicy: "ask".
*/
interface PermissionOption {
readonly id: string;
readonly title?: string;
readonly description?: string;
}
interface PermissionRequestLike {
readonly requestId: string;
readonly options?: readonly PermissionOption[];
readonly toolCall?: {
readonly title?: string;
readonly kind?: string;
readonly name?: string;
};
}
const DENY_PATTERNS = [
/\bmerge\b/iu,
/\bdeploy\b.*\bprod/iu,
/\bproduction\b/iu,
/\bsecret/iu,
/\bcredential/iu,
/\bpassword\b/iu,
/\bapi[_-]?key\b/iu,
/\bpush\s+to\s+(?<branch>main|master)\b/iu,
];
/** Evaluate whether a permission request is dangerous. */
export const isDangerousPermission = (
request: PermissionRequestLike
): boolean => {
const text = [
request.toolCall?.title,
request.toolCall?.kind,
request.toolCall?.name,
]
.filter((s): s is string => typeof s === "string")
.join(" ");
return DENY_PATTERNS.some((pattern) => pattern.test(text));
};
/**
* Pick the option ID that matches the desired decision. Falls back to the
* last option (typically deny) for safety when no explicit deny option exists,
* or the first option (typically allow) when no explicit allow option exists.
*/
const pickOption = (
options: readonly PermissionOption[],
allow: boolean
): string | undefined => {
if (options.length === 0) {
return undefined;
}
if (allow) {
const match = options.find(
(o) =>
/allow|accept|yes|permit/iu.test(o.title ?? "") ||
/allow|accept|yes|permit/iu.test(o.description ?? "")
);
return match?.id ?? options[0]?.id;
}
const match = options.find(
(o) =>
/deny|reject|no|cancel/iu.test(o.title ?? "") ||
/deny|reject|no|cancel/iu.test(o.description ?? "")
);
return match?.id ?? options.at(-1)?.id;
};
export interface PermissionDecision {
readonly allow: boolean;
readonly optionId: string | undefined;
}
/** Evaluate a permission request and return the decision. */
export const evaluatePermission = (
request: PermissionRequestLike
): PermissionDecision => {
const dangerous = isDangerousPermission(request);
const optionId = pickOption(request.options ?? [], !dangerous);
return { allow: !dangerous, optionId };
};

View File

@@ -0,0 +1,136 @@
/* eslint-disable max-classes-per-file -- domain errors are grouped by concern. */
import { Schema } from "effect";
import type {
OrbIdentity,
OrbModelGatewayConfig,
OrbProjectContext,
} from "./domain";
import type { OrbEvent } from "./events";
// ---------------------------------------------------------------------------
// Command result — shared shape for sandbox command execution
// ---------------------------------------------------------------------------
export interface CommandResult {
readonly exitCode: number;
readonly stderr: string;
readonly stdout: string;
}
// ---------------------------------------------------------------------------
// Orb port — abstracts OrbRuntime/OrbHandle for testability
// ---------------------------------------------------------------------------
export interface PrepareRepoInput {
readonly baseBranch?: string;
readonly branchName?: string;
}
export interface OrbRunPort {
readonly orbId: string;
readonly runId: string;
readonly sessionId: string | undefined;
readonly state: string;
readonly onEvent: (listener: (event: OrbEvent) => void) => () => void;
readonly prepareRepository: (input: PrepareRepoInput) => Promise<void>;
readonly openSession: () => Promise<string>;
readonly sendTask: (prompt: string) => Promise<unknown>;
readonly executeCommand: (
command: string,
cwd?: string
) => Promise<CommandResult>;
readonly cancel: () => Promise<void>;
readonly dispose: () => Promise<void>;
}
export interface OrbCreatePortInput {
readonly context: OrbProjectContext;
readonly gateway: OrbModelGatewayConfig;
readonly identity: OrbIdentity;
readonly docker: {
readonly hostWorkspacePath: string;
readonly containerName?: string;
readonly image?: string;
};
}
export interface OrbAdapter {
readonly createOrb: (input: OrbCreatePortInput) => Promise<OrbRunPort>;
}
// ---------------------------------------------------------------------------
// Git lifecycle port — abstracts the Gitea lifecycle for testability
// ---------------------------------------------------------------------------
export interface GitPublishInput {
readonly workspace: string;
readonly baseBranch: string;
readonly branchName: string;
readonly issueNumber: number;
readonly issueTitle: string;
readonly repositoryPath: string;
readonly commitMessage?: string;
readonly verification: "passed" | "failed" | "not-run";
}
export interface GitPullRequestMeta {
readonly baseBranch: string;
readonly branch: string;
readonly number: number;
readonly status: "open" | "closed" | "merged";
readonly url: string;
}
export interface GitLifecycleResult {
readonly baseBranch: string;
readonly branch: string;
readonly commitSha?: string;
readonly pullRequest?: GitPullRequestMeta;
readonly status:
| "no_changes"
| "committed"
| "pushed"
| "pull_request_open"
| "failed";
}
export interface GitLifecyclePort {
readonly publish: (input: GitPublishInput) => Promise<GitLifecycleResult>;
}
// ---------------------------------------------------------------------------
// Project artifact — durable output stored after a run
// ---------------------------------------------------------------------------
export const ProjectArtifactSchema = Schema.Struct({
content: Schema.String,
issueId: Schema.String,
path: Schema.String,
runId: Schema.String,
timestamp: Schema.String,
type: Schema.Literals([
"branch",
"commit",
"diff",
"verification_report",
"pull_request",
"agent_summary",
]),
});
export type ProjectArtifact = typeof ProjectArtifactSchema.Type;
// ---------------------------------------------------------------------------
// Orchestration status for a managed issue run
// ---------------------------------------------------------------------------
export const RunStatus = Schema.Literals([
"starting",
"working",
"needs-input",
"completing",
"completed",
"failed",
"cancelled",
]);
export type RunStatus = typeof RunStatus.Type;

View File

@@ -0,0 +1,123 @@
import { describe, expect, it } from "vitest";
import { makeOrbEvent } from "./events";
import {
extractNeedsInputQuestion,
isWorkComplete,
mapOrbEvent,
NEEDS_INPUT_MARKER,
WORK_COMPLETE_MARKER,
} from "./project-events";
describe("marker detection", () => {
it("extracts the needs-input question from a marker message", () => {
const text = `Some preamble\n${NEEDS_INPUT_MARKER} Which port should I use?`;
expect(extractNeedsInputQuestion(text)).toBe("Which port should I use?");
});
it("returns undefined for a message without the marker", () => {
expect(extractNeedsInputQuestion("just working")).toBeUndefined();
});
it("returns a default question when marker has no text after it", () => {
expect(extractNeedsInputQuestion(NEEDS_INPUT_MARKER)).toBe(
"Agent requires input"
);
});
it("detects the work-complete marker", () => {
expect(isWorkComplete(`${WORK_COMPLETE_MARKER} all good`)).toBe(true);
expect(isWorkComplete("still working")).toBe(false);
});
});
describe("mapOrbEvent", () => {
const issueId = "issue-1";
const runId = "run-1";
it("maps session_opened to run.session_opened", () => {
const event = mapOrbEvent(
makeOrbEvent(1, "session_opened", { text: "Session abc opened" }),
issueId,
runId
);
expect(event?.type).toBe("run.session_opened");
expect(event?.text).toBe("Session abc opened");
expect(event?.issueId).toBe(issueId);
expect(event?.runId).toBe(runId);
});
it("maps agent_message_completed with needs-input marker to run.needs_input", () => {
const event = mapOrbEvent(
makeOrbEvent(2, "agent_message_completed", {
text: `${NEEDS_INPUT_MARKER} What name?`,
}),
issueId,
runId
);
expect(event?.type).toBe("run.needs_input");
expect(event?.text).toBe("What name?");
});
it("maps agent_message_completed without marker to run.agent_message", () => {
const event = mapOrbEvent(
makeOrbEvent(3, "agent_message_completed", { text: "I fixed the bug" }),
issueId,
runId
);
expect(event?.type).toBe("run.agent_message");
expect(event?.text).toBe("I fixed the bug");
});
it("maps command_executed to run.command_executed with exit code", () => {
const event = mapOrbEvent(
makeOrbEvent(4, "command_executed", { command: "npm test", exitCode: 0 }),
issueId,
runId
);
expect(event?.type).toBe("run.command_executed");
expect(event?.exitCode).toBe(0);
expect(event?.text).toBe("npm test");
});
it("maps tool_call events to run.agent_progress", () => {
const started = mapOrbEvent(
makeOrbEvent(5, "tool_call_started", { toolName: "edit_file" }),
issueId,
runId
);
expect(started?.type).toBe("run.agent_progress");
expect(started?.toolName).toBe("edit_file");
});
it("maps session_failed to run.failed", () => {
const event = mapOrbEvent(
makeOrbEvent(6, "session_failed", { text: "crashed" }),
issueId,
runId
);
expect(event?.type).toBe("run.failed");
});
it("returns undefined for chunked events and vm_booted", () => {
expect(
mapOrbEvent(
makeOrbEvent(7, "agent_message_chunk", { text: "partial" }),
issueId,
runId
)
).toBeUndefined();
expect(
mapOrbEvent(makeOrbEvent(8, "vm_booted"), issueId, runId)
).toBeUndefined();
});
it("maps permission_requested to run.permission_requested", () => {
const event = mapOrbEvent(
makeOrbEvent(9, "permission_requested", { text: "needs approval" }),
issueId,
runId
);
expect(event?.type).toBe("run.permission_requested");
});
});

View File

@@ -0,0 +1,171 @@
import { Schema } from "effect";
import type { OrbEvent } from "./events";
// ---------------------------------------------------------------------------
// Durable project events — human-meaningful projections of Orb execution
//
// These are NOT raw OpenCode events. Each variant maps to a product-level
// concept the UI and work-graph understand. Raw OrbEvents are preserved for
// audit; this is the durable projection layer.
// ---------------------------------------------------------------------------
export const ProjectRunEventVariant = Schema.Literals([
"run.started",
"run.repository_prepared",
"run.session_opened",
"run.agent_message",
"run.agent_progress",
"run.command_executed",
"run.needs_input",
"run.permission_requested",
"run.completed",
"run.failed",
"run.cancelled",
"run.session_closed",
]);
export type ProjectRunEventVariant = typeof ProjectRunEventVariant.Type;
export const ProjectRunEvent = Schema.Struct({
exitCode: Schema.UndefinedOr(Schema.Int),
issueId: Schema.String,
runId: Schema.String,
sequence: Schema.Number,
text: Schema.UndefinedOr(Schema.String),
timestamp: Schema.String,
toolName: Schema.UndefinedOr(Schema.String),
type: ProjectRunEventVariant,
});
export type ProjectRunEvent = typeof ProjectRunEvent.Type;
// ---------------------------------------------------------------------------
// Marker detection — OpenCode signals product-level conditions via markers
// ---------------------------------------------------------------------------
/** Prefix the agent emits when it cannot proceed without human input. */
export const NEEDS_INPUT_MARKER = "NEEDS_INPUT:";
/**
* Prefix the agent emits when implementation and verification are complete
* and the orchestrator should proceed to the Git publish lifecycle.
*/
export const WORK_COMPLETE_MARKER = "WORK_COMPLETE:";
export const extractNeedsInputQuestion = (text: string): string | undefined => {
const index = text.indexOf(NEEDS_INPUT_MARKER);
if (index === -1) {
return undefined;
}
const after = text.slice(index + NEEDS_INPUT_MARKER.length).trim();
return after.length > 0 ? after.slice(0, 4000) : "Agent requires input";
};
export const isWorkComplete = (text: string): boolean =>
text.includes(WORK_COMPLETE_MARKER);
// ---------------------------------------------------------------------------
// OrbEvent → ProjectRunEvent translation
// ---------------------------------------------------------------------------
/**
* Translate one normalized OrbEvent into zero or one durable ProjectRunEvent.
* Returns undefined for OrbEvents that have no product-meaningful projection
* (e.g. chunked intermediate output that is only useful in the raw audit log).
*/
export const mapOrbEvent = (
orbEvent: OrbEvent,
issueId: string,
runId: string
): ProjectRunEvent | undefined => {
const base: {
exitCode: number | undefined;
issueId: string;
runId: string;
sequence: number;
text: string | undefined;
timestamp: string;
toolName: string | undefined;
} = {
exitCode: undefined,
issueId,
runId,
sequence: orbEvent.sequence,
text: undefined,
timestamp: orbEvent.timestamp,
toolName: undefined,
};
switch (orbEvent.type) {
case "session_opened": {
return {
...base,
text: orbEvent.text ?? "Session opened",
type: "run.session_opened",
};
}
case "vm_booted": {
return undefined;
}
case "agent_message_completed": {
const text = orbEvent.text ?? "";
if (extractNeedsInputQuestion(text) !== undefined) {
return {
...base,
text: extractNeedsInputQuestion(text),
type: "run.needs_input",
};
}
return {
...base,
text,
type: "run.agent_message",
};
}
case "agent_message_chunk":
case "agent_thought_chunk": {
return undefined;
}
case "tool_call_started":
case "tool_call_completed": {
return {
...base,
text: undefined,
toolName: orbEvent.toolName,
type: "run.agent_progress",
};
}
case "command_executed": {
return {
...base,
exitCode: orbEvent.exitCode,
text: orbEvent.command,
type: "run.command_executed",
};
}
case "permission_requested":
case "permission_denied": {
return {
...base,
text: orbEvent.text ?? "Permission requested",
type: "run.permission_requested",
};
}
case "session_failed": {
return {
...base,
text: orbEvent.text ?? "Session failed",
type: "run.failed",
};
}
case "session_closed": {
return {
...base,
text: orbEvent.text ?? "Session closed",
type: "run.session_closed",
};
}
default: {
return undefined;
}
}
};

View File

@@ -0,0 +1,125 @@
/* eslint-disable no-non-null-assertion -- test assertions on defined objects */
import { Effect } from "effect";
import { describe, expect, it } from "vitest";
import { OrbConfigurationError } from "./domain";
import { makeOrbEvent } from "./events";
import type { OrbEvent } from "./events";
import { OrbHandle, OrbRuntime } from "./runtime";
const validGateway = {
apiKey: "test-key",
baseUrl: "https://gw.example.com/v1",
model: "m1",
provider: "p1",
};
const validContext = {
artifacts: [],
contextFiles: [],
issueBody: "Do the thing",
issueTitle: "Thing",
repositoryUrl: undefined,
};
const validIdentity = {
projectId: "prj-1",
runId: "run-1",
workUnitId: "wrk-1",
};
describe("OrbRuntime.createOrb validation", () => {
it("rejects missing gateway API key before touching Docker", () => {
const runtime = new OrbRuntime();
// This must fail at validation, not at Docker — if Docker is the failure,
// that indicates the validation ordering is wrong.
const error = Effect.runSync(
Effect.flip(
runtime.createOrb({
context: validContext,
docker: {
containerName: "orb-test",
hostWorkspacePath: "/tmp/orb-test",
},
gateway: { ...validGateway, apiKey: " " },
identity: validIdentity,
})
)
);
expect(error).toBeInstanceOf(OrbConfigurationError);
expect(error.reason).toBe("MissingGateway");
});
});
describe("OrbHandle event lifecycle", () => {
it("emits events to listeners", () => {
const handle = new OrbHandle(
"orb-x" as never,
"run-x" as never,
validIdentity
);
const events: OrbEvent[] = [];
handle.onEvent((e) => events.push(e));
handle.emitEvent(makeOrbEvent(1, "vm_booted", { text: "booted" }));
handle.emitEvent(
makeOrbEvent(2, "command_executed", { command: "ls", exitCode: 0 })
);
expect(events.length).toBe(2);
expect(events[0]!.type).toBe("vm_booted");
expect(events[1]!.type).toBe("command_executed");
expect(events[1]!.command).toBe("ls");
});
it("unsubscribes listeners correctly", () => {
const handle = new OrbHandle(
"orb-y" as never,
"run-y" as never,
validIdentity
);
const events: OrbEvent[] = [];
const unsub = handle.onEvent((e) => events.push(e));
handle.emitEvent(makeOrbEvent(1, "session_opened"));
unsub();
handle.emitEvent(makeOrbEvent(2, "session_closed"));
expect(events.length).toBe(1);
});
});
describe("OrbHandle state transitions", () => {
it("starts in creating state", () => {
const handle = new OrbHandle(
"orb-z" as never,
"run-z" as never,
validIdentity
);
expect(handle.state).toBe("creating");
});
it("transitions to prepared then running", () => {
const handle = new OrbHandle(
"orb-a" as never,
"run-a" as never,
validIdentity
);
Effect.runSync(handle.setOrbState("prepared"));
expect(handle.state).toBe("prepared");
Effect.runSync(handle.setOrbState("running"));
expect(handle.state).toBe("running");
});
it("rejects invalid transition", () => {
const handle = new OrbHandle(
"orb-b" as never,
"run-b" as never,
validIdentity
);
const error = Effect.runSync(
Effect.flip(handle.setOrbState("needs-input"))
);
expect(error.reason).toBe("InvalidTransition");
});
});

View File

@@ -0,0 +1,763 @@
/* eslint-disable prefer-destructuring -- field captures before mutation are intentional */
/* eslint-disable max-classes-per-file -- runtime and handle form one service. */
import opencodePkg from "@agentos-software/opencode";
import { AgentOs } from "@rivet-dev/agentos-core";
import type { SessionStreamEntry } from "@rivet-dev/agentos-core";
import { Effect } from "effect";
import type { DockerSandboxOptions } from "./docker-sandbox";
import { DockerSandboxProvider } from "./docker-sandbox";
import {
OrbConfigurationError,
OrbSandboxError,
OrbSessionError,
orbActorKey,
transitionOrbState,
transitionRunState,
} from "./domain";
import type {
OrbIdentity,
OrbId,
OrbModelGatewayConfig,
OrbProjectContext,
OrbRunId,
OrbSessionId,
OrbState,
OrbStateError,
RunState,
} from "./domain";
import type { OrbEvent } from "./events";
import { makeOrbEvent, normalizeSessionEvent, redactSecrets } from "./events";
import { prepareOpenCodeConfig } from "./opencode-config";
import { evaluatePermission } from "./permission-policy";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface OrbEnv {
readonly dockerImage?: string;
readonly dockerWorkspace?: string;
readonly rivetEndpoint?: string;
}
export interface OrbCreateInput {
readonly context: OrbProjectContext;
readonly docker: Omit<DockerSandboxOptions, "image">;
readonly gateway: OrbModelGatewayConfig;
readonly identity: OrbIdentity;
}
// ---------------------------------------------------------------------------
// OrbHandle
// ---------------------------------------------------------------------------
export class OrbHandle {
readonly id: OrbId;
readonly runId: OrbRunId;
readonly identity: OrbIdentity;
readonly actorKey: string;
private vm: AgentOs | null = null;
private dockerProvider: DockerSandboxProvider | null = null;
private sessionId: string | undefined;
private orbState: OrbState = "creating";
private runState: RunState = "queued";
private eventSequence = 0;
private readonly eventListeners = new Set<(event: OrbEvent) => void>();
private unsubscribeSession: (() => void) | null = null;
private context: OrbProjectContext | undefined;
private gateway: OrbModelGatewayConfig | undefined;
constructor(id: OrbId, runId: OrbRunId, identity: OrbIdentity) {
this.id = id;
this.runId = runId;
this.identity = identity;
this.actorKey = orbActorKey(identity);
}
get state(): OrbState {
return this.orbState;
}
get currentSessionId(): string | undefined {
return this.sessionId;
}
get docker(): DockerSandboxProvider | null {
return this.dockerProvider;
}
get vmInstance(): AgentOs | null {
return this.vm;
}
// -----------------------------------------------------------------------
// Event API
// -----------------------------------------------------------------------
onEvent(listener: (event: OrbEvent) => void): () => void {
this.eventListeners.add(listener);
return () => {
this.eventListeners.delete(listener);
};
}
emitEvent(event: OrbEvent): void {
for (const listener of this.eventListeners) {
listener(event);
}
}
private nextSequence(): number {
this.eventSequence += 1;
return this.eventSequence;
}
// -----------------------------------------------------------------------
// State transitions
// -----------------------------------------------------------------------
readonly setOrbState = (to: OrbState): Effect.Effect<void, OrbStateError> =>
transitionOrbState({ from: this.orbState, to }).pipe(
Effect.tap((next) =>
Effect.sync(() => {
this.orbState = next;
})
),
Effect.asVoid
);
readonly setRunState = (to: RunState): Effect.Effect<void, OrbStateError> =>
transitionRunState({ from: this.runState, to }).pipe(
Effect.tap((next) =>
Effect.sync(() => {
this.runState = next;
})
),
Effect.asVoid
);
// -----------------------------------------------------------------------
// Internal attachment
// -----------------------------------------------------------------------
_attachVm(vm: AgentOs): void {
this.vm = vm;
this.unsubscribeSession = vm.onSessionEvent((entry: SessionStreamEntry) => {
const normalized = normalizeSessionEvent(entry);
if (normalized) {
this.emitEvent({ ...normalized, sequence: this.nextSequence() });
}
if (
typeof entry === "object" &&
entry !== null &&
"type" in entry &&
entry.type === "permission_request"
) {
void this.handlePermissionRequest(
entry as unknown as {
requestId: string;
options: {
description?: string;
id: string;
title?: string;
}[];
toolCall?: { kind?: string; name?: string; title?: string };
}
);
}
});
this.emitEvent(
makeOrbEvent(this.nextSequence(), "vm_booted", { text: "VM booted" })
);
}
_attachDocker(provider: DockerSandboxProvider): void {
this.dockerProvider = provider;
}
_configure(input: {
readonly context: OrbProjectContext;
readonly gateway: OrbModelGatewayConfig;
}): void {
this.context = input.context;
this.gateway = input.gateway;
}
// -----------------------------------------------------------------------
// Permission handler
// -----------------------------------------------------------------------
private async handlePermissionRequest(request: {
readonly requestId: string;
readonly options: {
description?: string;
id: string;
title?: string;
}[];
readonly toolCall?: { kind?: string; name?: string; title?: string };
}): Promise<void> {
const vm = this.vm;
const sessionId = this.sessionId;
if (!vm || !sessionId) {
return;
}
const decision = evaluatePermission(request);
if (!decision.allow) {
this.emitEvent(
makeOrbEvent(this.nextSequence(), "permission_denied", {
text: `Permission denied for: ${request.toolCall?.title ?? "unknown"}`,
})
);
}
if (decision.optionId) {
await vm
.respondPermission({
optionId: decision.optionId,
requestId: request.requestId,
sessionId,
})
.catch(
// eslint-disable-next-line no-empty-function -- best-effort permission response
() => {}
);
}
}
// -----------------------------------------------------------------------
// Repository preparation
// -----------------------------------------------------------------------
readonly prepareRepository = Effect.fn("Orb.prepareRepository")(
function* prepareRepository(
this: OrbHandle,
input: { readonly baseBranch?: string; readonly branchName?: string }
) {
if (!this.dockerProvider) {
return yield* Effect.fail(
new OrbSandboxError({
message: "Docker sandbox is not attached",
reason: "ContainerStart",
})
);
}
yield* this.setOrbState("prepared");
yield* this.setRunState("provisioning");
const client = yield* Effect.tryPromise({
catch: (cause) =>
new OrbSandboxError({
message: `Failed to start Docker sandbox: ${cause instanceof Error ? cause.message : String(cause)}`,
reason: "ContainerStart",
}),
try: () => {
const dp = this.dockerProvider;
if (!dp) {
throw new Error("Docker provider detached");
}
return dp.start();
},
});
const repoUrl = this.context?.repositoryUrl;
if (repoUrl) {
const branch = input.branchName ?? "main";
const base = input.baseBranch ?? "main";
// eslint-disable-next-line no-use-before-define -- module-level helper
const baseRef = `origin/${base}`;
// eslint-disable-next-line no-use-before-define -- module-level helper
const cloneCmd = `git clone --branch ${shellQuote(base)} --single-branch ${shellQuote(repoUrl)} /home/sandbox/repository || git clone ${shellQuote(repoUrl)} /home/sandbox/repository`;
// eslint-disable-next-line no-use-before-define -- module-level helper
const checkoutCmd = `cd /home/sandbox/repository && git checkout -b ${shellQuote(branch)} ${shellQuote(baseRef)} 2>/dev/null || git checkout ${shellQuote(branch)} 2>/dev/null || true`;
const result = yield* Effect.tryPromise({
catch: (cause) =>
new OrbSandboxError({
message: `Repository checkout failed: ${cause instanceof Error ? cause.message : String(cause)}`,
reason: "CommandFailed",
}),
try: () =>
client.runProcess({
args: ["-c", `${cloneCmd} && ${checkoutCmd}`],
command: "sh",
cwd: "/home/sandbox",
timeoutMs: 300_000,
}),
});
if (result.exitCode !== 0) {
return yield* Effect.fail(
new OrbSandboxError({
message: `Repository clone failed: ${result.stderr}`,
reason: "CommandFailed",
})
);
}
} else {
yield* Effect.tryPromise({
catch: (cause) =>
new OrbSandboxError({
message: `Failed to create workspace: ${cause instanceof Error ? cause.message : String(cause)}`,
reason: "CommandFailed",
}),
try: () =>
client.runProcess({
args: ["-c", "mkdir -p /home/sandbox/repository"],
command: "sh",
cwd: "/home/sandbox",
}),
});
}
const ctx = this.context;
const vm = this.vm;
if (ctx && vm) {
yield* Effect.tryPromise({
catch: () => null, // eslint-disable-next-line no-empty-function -- best-effort context staging
try: () =>
vm
.mkdir("/mnt/sandbox/control", { recursive: true })
.then(() =>
vm.writeFile(
"/mnt/sandbox/control/issue.md",
`# ${ctx.issueTitle}\n\n${ctx.issueBody}\n`
)
),
});
for (const file of ctx.contextFiles) {
yield* Effect.tryPromise({
catch: () => null,
try: () =>
vm.writeFile(`/mnt/sandbox/control/${file.path}`, file.content),
});
}
}
yield* this.setRunState("preparing");
}
);
// -----------------------------------------------------------------------
// OpenCode session
// -----------------------------------------------------------------------
readonly openSession = Effect.fn("Orb.openSession")(
function* openSession(this: OrbHandle) {
if (!this.vm) {
return yield* Effect.fail(
new OrbSessionError({
message: "AgentOS VM is not attached",
reason: "OpenSession",
})
);
}
if (!this.gateway || !this.context) {
return yield* Effect.fail(
new OrbConfigurationError({
message: "Orb is not configured with gateway and context",
reason: "MissingGateway",
})
);
}
const config = yield* prepareOpenCodeConfig({
context: this.context,
gateway: this.gateway,
});
const vm = this.vm;
yield* Effect.tryPromise({
catch: (cause) =>
new OrbSessionError({
message: `Failed to create config directory: ${cause instanceof Error ? cause.message : String(cause)}`,
reason: "OpenSession",
}),
try: () => vm.mkdir("/root/.config/opencode", { recursive: true }),
});
yield* Effect.tryPromise({
catch: (cause) =>
new OrbSessionError({
message: `Failed to write OpenCode config: ${cause instanceof Error ? cause.message : String(cause)}`,
reason: "OpenSession",
}),
try: () => vm.writeFile(config.configPath, config.configJson),
});
// Restrict the config file containing the run-scoped gateway key.
yield* Effect.tryPromise({
catch: () => null, // eslint-disable-next-line no-empty-function -- best-effort hardening
try: () => vm.exec(`chmod 600 ${config.configPath}`),
}).pipe(Effect.ignore);
const agents = yield* Effect.tryPromise({
catch: (cause) =>
new OrbSessionError({
message: `Failed to list agents: ${cause instanceof Error ? cause.message : String(cause)}`,
reason: "AgentNotInstalled",
}),
try: () => vm.listAgents(),
});
const hasOpencode = agents.some(
(a) => a.id === "opencode" && a.installed
);
if (!hasOpencode) {
return yield* Effect.fail(
new OrbSessionError({
message: "OpenCode agent is not installed in the VM",
reason: "AgentNotInstalled",
})
);
}
yield* Effect.tryPromise({
catch: (cause) =>
new OrbSessionError({
message: `Failed to open OpenCode session: ${cause instanceof Error ? cause.message : String(cause)}`,
reason: "OpenSession",
}),
try: () =>
vm.openSession({
agent: "opencode",
cwd: "/mnt/sandbox/repository",
permissionPolicy: "ask",
skipOsInstructions: false,
}),
});
const sessionInfo = yield* Effect.tryPromise({
catch: (cause) =>
new OrbSessionError({
message: `Failed to read session info: ${cause instanceof Error ? cause.message : String(cause)}`,
reason: "OpenSession",
}),
try: () => vm.getSession(),
});
this.sessionId = sessionInfo.sessionId;
this.emitEvent(
makeOrbEvent(this.nextSequence(), "session_opened", {
text: `Session ${sessionInfo.sessionId} opened`,
})
);
yield* this.setOrbState("running");
yield* this.setRunState("running");
return sessionInfo.sessionId as OrbSessionId;
}
);
// -----------------------------------------------------------------------
// Send task — raw prompt to model, redacted copy in events only
// -----------------------------------------------------------------------
readonly sendTask = Effect.fn("Orb.sendTask")(function* sendTask(
this: OrbHandle,
prompt: string
) {
if (!this.vm || !this.sessionId) {
return yield* Effect.fail(
new OrbSessionError({
message: "No active OpenCode session",
reason: "SessionNotFound",
})
);
}
// eslint-disable-next-line prefer-destructuring -- captured before mutation
const vm = this.vm;
// eslint-disable-next-line prefer-destructuring -- captured before mutation
const sessionId = this.sessionId;
// Send raw prompt — no redaction of outgoing content.
const result = yield* Effect.tryPromise({
catch: (cause) =>
new OrbSessionError({
message: `Prompt failed: ${cause instanceof Error ? cause.message : String(cause)}`,
reason: "PromptFailed",
}),
try: () =>
vm.prompt({
content: [{ text: prompt, type: "text" }],
sessionId,
}),
});
return result;
});
// -----------------------------------------------------------------------
// Execute command via Docker
// -----------------------------------------------------------------------
readonly executeCommand = Effect.fn("Orb.executeCommand")(
function* executeCommand(
this: OrbHandle,
input: {
readonly command: string;
readonly cwd?: string;
readonly env?: Readonly<Record<string, string>>;
readonly timeoutMs?: number;
}
) {
if (!this.dockerProvider) {
return yield* Effect.fail(
new OrbSandboxError({
message: "Docker sandbox is not attached",
reason: "ContainerStart",
})
);
}
const client = yield* Effect.tryPromise({
catch: (cause) =>
new OrbSandboxError({
message: `Failed to get sandbox client: ${cause instanceof Error ? cause.message : String(cause)}`,
reason: "ContainerStart",
}),
try: () => {
const dp = this.dockerProvider;
if (!dp) {
throw new Error("Docker provider detached");
}
return dp.start();
},
});
const result = yield* Effect.tryPromise({
catch: (cause) =>
new OrbSandboxError({
message: `Command failed: ${cause instanceof Error ? cause.message : String(cause)}`,
reason: "CommandFailed",
}),
try: () =>
client.runProcess({
args: ["-c", input.command],
command: "sh",
...(input.cwd === undefined ? {} : { cwd: input.cwd }),
...(input.env === undefined ? {} : { env: input.env }),
...(input.timeoutMs === undefined
? {}
: { timeoutMs: input.timeoutMs }),
}),
});
// Emit redacted copy for logs/UI.
this.emitEvent(
makeOrbEvent(this.nextSequence(), "command_executed", {
command: redactSecrets(input.command),
exitCode: result.exitCode ?? undefined,
})
);
return result;
}
);
// -----------------------------------------------------------------------
// Cancel / dispose
// -----------------------------------------------------------------------
readonly cancel = Effect.fn("Orb.cancel")(function* cancel(this: OrbHandle) {
// eslint-disable-next-line prefer-destructuring -- captured before mutation
const vm = this.vm;
// eslint-disable-next-line prefer-destructuring -- captured before mutation
const sessionId = this.sessionId;
if (vm && sessionId) {
yield* Effect.tryPromise({
catch: () => null,
try: () => vm.cancelPrompt({ sessionId }),
}).pipe(Effect.ignore);
}
yield* this.setOrbState("cancelled");
yield* this.setRunState("cancelled");
this.emitEvent(
makeOrbEvent(this.nextSequence(), "session_closed", {
text: "Orb cancelled",
})
);
});
readonly dispose = Effect.fn("Orb.dispose")(
function* dispose(this: OrbHandle) {
// eslint-disable-next-line prefer-destructuring -- captured before mutation
const vm = this.vm;
// eslint-disable-next-line prefer-destructuring -- captured before mutation
const sessionId = this.sessionId;
// eslint-disable-next-line prefer-destructuring -- captured before mutation
const docker = this.dockerProvider;
if (vm && sessionId) {
yield* Effect.tryPromise({
catch: () => null,
try: () => vm.cancelPrompt({ sessionId }),
}).pipe(Effect.ignore);
}
// Dispose the VM before its sandbox so the mount unbinds cleanly.
if (vm) {
if (this.unsubscribeSession) {
this.unsubscribeSession();
this.unsubscribeSession = null;
}
yield* Effect.tryPromise({
catch: () => null,
try: () => vm.dispose(),
}).pipe(Effect.ignore);
}
if (docker) {
yield* Effect.tryPromise({
catch: () => null,
try: () => docker.dispose(),
}).pipe(Effect.ignore);
}
yield* this.setOrbState("disposed");
this.eventListeners.clear();
this.vm = null;
this.dockerProvider = null;
this.sessionId = undefined;
}
);
}
// ---------------------------------------------------------------------------
// OrbRuntime
// ---------------------------------------------------------------------------
export class OrbRuntime {
private readonly orbs = new Map<string, OrbHandle>();
private readonly env: OrbEnv;
constructor(env: OrbEnv = {}) {
this.env = env;
}
readonly createOrb = Effect.fn("OrbRuntime.createOrb")(function* createOrb(
this: OrbRuntime,
input: OrbCreateInput
) {
if (!input.gateway.apiKey.trim()) {
return yield* Effect.fail(
new OrbConfigurationError({
message: "Model gateway API key is required",
reason: "MissingGateway",
})
);
}
const orbId =
`orb-${input.identity.projectId}-${input.identity.runId}` as OrbId;
const runId = `run-${input.identity.runId}` as OrbRunId;
const handle = new OrbHandle(orbId, runId, input.identity);
handle._configure({
context: input.context,
gateway: input.gateway,
});
// 1. Create and start Docker sandbox (the provider owns its container).
const dockerOptions: DockerSandboxOptions = {
...input.docker,
...(this.env.dockerImage === undefined
? {}
: { image: this.env.dockerImage }),
};
const provider = yield* DockerSandboxProvider.create(dockerOptions);
handle._attachDocker(provider);
// 2-3. Start the sandbox, create the AgentOS VM, and link OpenCode. Any
// failure here disposes the VM before its sandbox so neither leaks.
let createdVm: AgentOs | null = null;
const vm = yield* Effect.gen(function* vm() {
const sandboxClient = yield* Effect.tryPromise({
catch: (cause) =>
new OrbSandboxError({
message: `Failed to start Docker sandbox: ${cause instanceof Error ? cause.message : String(cause)}`,
reason: "ContainerStart",
}),
try: () => provider.start(),
});
const created = yield* Effect.tryPromise({
catch: (cause) =>
new OrbSessionError({
message: `Failed to create AgentOS VM: ${cause instanceof Error ? cause.message : String(cause)}`,
reason: "OpenSession",
}),
try: () =>
AgentOs.create({
database: {
path: `/tmp/${orbId}.db`,
type: "sqlite_file",
},
sandbox: {
client: sandboxClient,
dispose: false,
mountPath: "/mnt/sandbox",
readOnly: false,
sandboxRoot: "/home/sandbox",
},
software: [opencodePkg],
}),
});
createdVm = created;
yield* Effect.tryPromise({
catch: (cause) =>
new OrbSessionError({
message: `Failed to link OpenCode: ${cause instanceof Error ? cause.message : String(cause)}`,
reason: "AgentNotInstalled",
}),
try: () => created.linkSoftware({ path: opencodePkg.packagePath }),
});
return created;
}).pipe(
Effect.tapError(() =>
Effect.tryPromise({
catch: () => null,
// eslint-disable-next-line no-use-before-define -- module-level cleanup helper
try: () => disposeVmBeforeSandbox(createdVm, provider),
}).pipe(Effect.ignore)
)
);
// 4. Attach VM to handle.
handle._attachVm(vm);
this.orbs.set(orbId, handle);
return handle;
});
getOrb(id: string): OrbHandle | undefined {
return this.orbs.get(id);
}
listOrbs(): readonly OrbHandle[] {
return [...this.orbs.values()];
}
}
// ---------------------------------------------------------------------------
// Partial-failure cleanup — dispose the VM before its sandbox
// ---------------------------------------------------------------------------
const disposeVmBeforeSandbox = async (
vm: AgentOs | null,
docker: DockerSandboxProvider
): Promise<void> => {
if (vm) {
try {
await vm.dispose();
} catch {
// best-effort; container removal below is the hard guarantee
}
}
await docker.dispose();
};
// ---------------------------------------------------------------------------
// Shell quoting
// ---------------------------------------------------------------------------
// eslint-disable-next-line no-use-before-define -- module-level helper
const shellQuote = (value: string): string =>
`'${value.replaceAll("'", `'"'"'`)}'`;

View File

@@ -0,0 +1,162 @@
import {
decodeProjectIssueRequest,
ProjectIssueDispatchInput,
ProjectIssueRequestError,
ProjectIssueRequestResult,
ProjectIssueValidationError,
} from "@code/primitives/project-issue";
import type { ProjectIssueRequest } from "@code/primitives/project-issue";
import { dispatch } from "@flue/runtime";
import type { ConvexHttpClient } from "convex/browser";
import { makeFunctionReference } from "convex/server";
import { Effect, Schema } from "effect";
import type { Context } from "hono";
import { createAuthenticatedClient, extractBearerToken } from "./auth";
interface ProjectIssueCreateArgs extends Record<string, unknown> {
readonly body: string;
readonly projectId: string;
readonly title: string;
}
const createIssue = makeFunctionReference<
"mutation",
ProjectIssueCreateArgs,
string
>("projectIssues:create");
const createIssueFromSignal = makeFunctionReference<
"mutation",
{ readonly signalId: string },
{ readonly issueId: string; readonly projectId: string }
>("projectIssues:createFromSignal");
const beginIssue = makeFunctionReference<
"mutation",
{ readonly issueId: string },
"queued" | "working"
>("projectIssues:begin");
const markDispatchFailed = makeFunctionReference<
"mutation",
{ readonly error: string; readonly issueId: string },
null
>("projectIssues:markDispatchFailed");
const invalidRequest = (message: string) =>
Response.json({ error: message }, { status: 400 });
const knownAuthorizationFailure = (message: string): boolean =>
/authentication required|membership required|project not found|signal not found|not project-scoped/iu.test(
message
);
const errorMessage = (error: unknown): string =>
error instanceof Error ? error.message : String(error);
const decodeRequest = async (input: unknown): Promise<ProjectIssueRequest> => {
try {
return await Effect.runPromise(decodeProjectIssueRequest(input));
} catch (error) {
if (
error instanceof ProjectIssueRequestError ||
error instanceof ProjectIssueValidationError
) {
throw invalidRequest(
error instanceof Error ? error.message : "Invalid project request"
);
}
throw error;
}
};
const createIssueForRequest = async (
client: ConvexHttpClient,
request: Awaited<ReturnType<typeof decodeRequest>>
): Promise<{ readonly issueId: string; readonly projectId: string }> => {
if (request.kind === "signal") {
return client.mutation(createIssueFromSignal, {
signalId: request.signalId,
});
}
const issueId = await client.mutation(createIssue, {
body: request.body,
projectId: request.projectId,
title: request.title,
});
return { issueId, projectId: request.projectId };
};
export const projectRequestRoute = async (c: Context): Promise<Response> => {
const accessToken = extractBearerToken(c.req.raw);
if (!accessToken) {
return c.json({ error: "Unauthorized" }, 401);
}
let input: unknown;
try {
input = await c.req.json();
} catch {
return invalidRequest("Request body must be valid JSON");
}
let request: Awaited<ReturnType<typeof decodeRequest>>;
try {
request = await decodeRequest(input);
} catch (error) {
if (error instanceof Response) {
return error;
}
return c.json({ error: "Invalid project request" }, 400);
}
const client = createAuthenticatedClient(accessToken);
let issue:
| { readonly issueId: string; readonly projectId: string }
| undefined;
try {
issue = await createIssueForRequest(client, request);
const status = await client.mutation(beginIssue, {
issueId: issue.issueId,
});
const dispatchInput = Schema.decodeUnknownSync(ProjectIssueDispatchInput)({
issueId: issue.issueId,
kind: "project.issue.started",
projectId: issue.projectId,
});
const receipt = await dispatch({
agent: "project-manager",
id: issue.issueId,
input: dispatchInput,
});
const result = Schema.decodeUnknownSync(ProjectIssueRequestResult)({
acceptedAt: receipt.acceptedAt,
dispatchId: receipt.dispatchId,
issueId: issue.issueId,
projectId: issue.projectId,
status,
});
return c.json(result, 202);
} catch (error) {
const message = errorMessage(error);
if (issue) {
try {
await client.mutation(markDispatchFailed, {
error: message,
issueId: issue.issueId,
});
} catch {
// Preserve the original request failure; the issue remains inspectable.
}
}
return c.json(
{
error: knownAuthorizationFailure(message)
? "Project request is not authorized"
: "Project request could not be dispatched",
},
knownAuthorizationFailure(message) ? 403 : 502
);
}
};

Some files were not shown because too many files have changed in this diff Show More