19 Commits

Author SHA1 Message Date
-Puter
a5414d83bd Convex-only Slice 1: clients talk to Convex, Flue is a private worker
Normalize the topology so web/desktop/mobile clients communicate only with
Convex. Convex admits durable commands, dispatches private Flue turns through
FLUE_URL, and stores the product-facing result before clients observe it.

- Normalized relational schema: organizations, projects, conversations/turns/
  messages/attachments, signals/sources/constraints, works/events/attachments.
- Conversation turns queued in Convex; the agent runAction dispatches to Flue
  and persists assistant response, signals, and proposed work back into Convex.
- Browser chat moved off the Flue transport: use-chat-agent now reads reactive
  Convex rows and sends through conversationMessages.send.
- Fixed signed-in blank screen: gate all product queries on useConvexAuth
  (isAuthenticated && !isRefreshing) plus personal-org bootstrap.
- Pinned react/react-dom to 19.2.8 via catalog to fix SSR dual-React crash.
- Removed ~16.6k net lines: daemon, AgentOS/Orb, project issues/runs/artifacts,
  todos, browser Flue transport, mobile execution views, smoke scripts.

Verified end-to-end on cheaptricks: connect repo, send actionable message,
Flue turn completes, Signal + proposed Work created with exact provenance,
persists across refresh.
2026-07-27 21:32:17 +05:30
-Puter
4c741fbe06 Fix type errors, lint, and complexity from VPS architecture cleanup
- Fix brand-to-Convex-Id casts in use-project-workspace (as unknown as)
- Extract ProjectsLoading and ConnectProject components to drop SliceOnePage
  complexity below the lint threshold of 20
- Fix formatting in project-requests.ts
- Archive orphaned standalone chat files (route removed, referenced dead env)
- All 8 packages typecheck clean; 31 web tests pass; root check clean
2026-07-27 18:57:16 +05:30
Miniputer
1855735245 Auth flow refactor, project requests, and routing updates
- Add server-side auth token loader (auth.server.ts) with SSR cookie forwarding
- Add project-requests client for explicit/signal/existing issue dispatch
- Refactor auth layout and app layout for SSR auth gating
- Update auth-client and auth-provider for convex token flow
- Extend project-issue primitives with request result schema
- Wire projectIssues backend mutation for request handling
- Update slice-one page and project-workspace hook
- Adjust routes (remove unused), Caddy config, and env templates
2026-07-27 18:47:43 +05:30
-Puter
cc47007fa9 Slice 1 polish: archive dormant code, merge work-os into primitives, add futures
- Archive dormant apps (daemon, desktop, native, tui) and superseded
  packages/server into repos/ for reference
- Archive 21 dead web components (chat page shell, work-os cluster, strays)
  into repos/web/; keep reused message-rendering primitives in components/chat
- Merge @code/work-os into @code/primitives; work.ts absorbed via ./work
  subpath and barrel export; update all four importers
- Add docs/futures.md tracking audio/video (blocked at Flue + provider),
  web layout cleanup, and message rendering quality
- Repoint dev:zopu script to @code/agents (the live Flue server)
- Note repos/ reference-code convention in AGENTS.md
2026-07-27 16:34:17 +05:30
-Puter
302fd159df docs: Slice 1 handoff notes 2026-07-27 15:22:16 +05:30
cfdb2efc70 Slice 1: Conversation to Proposed Work with MiMo V2.5 (#19) 2026-07-27 09:32:29 +00:00
-Puter
6da82f0ed8 merge: resolve conflicts with origin/master (routes, Caddy ports, scripts) 2026-07-27 15:01:38 +05:30
-Puter
789d26bb8d docs: deployment notes for local Mac dev and Cheaptricks staging 2026-07-27 12:59:04 +05:30
-Puter
4605dd7c92 fix: allow Caddy domain host in Vite dev server 2026-07-27 12:38:09 +05:30
-Puter
d8383a788e feat: Slice 1 polish - MiMo V2.5 model, visible reasoning, image attachments, mobile keyboard fix
- Switch conversation agent to xiaomi/mimo-v2.5 (multimodal: text + image)
- Render native reasoning parts as live 'Thinking trace' (streaming open,
  collapsed after completion); inline <think> extraction for streaming models
- Image attachments: picker (up to 4, 10MB each), base64 to Flue
  AgentPromptImage, authenticated blob-URL replay for historical images
- Mobile keyboard viewport fix: visual-viewport hook, fixed shell,
  interactive-widget=resizes-content, header pinned, composer follows keyboard
- Conversation to Signal to proposed Work: Convex persistence, Effect
  validation in @code/work-os, Work cards with exact source provenance
- Streamdown markdown + Mermaid chart rendering in chat messages
- Flue tool turns hidden, reasoning-containing turns remain visible
- Frontend regression tests: keyboard viewport, responsive shell,
  attachment overflow, authenticated images, reasoning traces, transforms
- .env.example updated to xiaomi/mimo-v2.5 config
2026-07-27 12:22:54 +05:30
sai karthik
58ff7942bd feat: add project connection to chat 2026-07-26 21:00:08 +05:30
sai karthik
39b27a229f feat: deploy zopu single-node production 2026-07-26 11:39:21 +05:30
-Puter
3bfa9ac65e deploy(caddy): reverse-proxy zopu chat + API behind zopu.cheaptricks.puter.wtf 2026-07-26 01:41:50 +05:30
sai karthik
2a0487aa6e feat: integrate mobile work chat and Gitea delivery 2026-07-26 00:50:11 +05:30
sai karthik
48200a11df Integrate mobile chat workspace and OpenRouter agent flow 2026-07-25 17:49:11 +05:30
-Puter
d4d5620c18 Merge origin/master into zopu chat setup 2026-07-25 16:12:43 +05:30
-Puter
d4745591a9 feat(zopu): chat server, standalone chat route, and CORS 2026-07-25 16:12:04 +05:30
a17aa5c283 Zopu dev bootstrap: git runtimes, Codex agent-os, dev agent
Merge t3code/explore-primitives-package onto master.
2026-07-24 21:50:45 +00:00
-Puter
19f771ea71 fix(backend): explicit types on linked-signals toSorted comparator
Resolves implicit-any type errors blocking the Convex dev server from
starting on master after the dogfood merge.
2026-07-25 02:40:48 +05:30
281 changed files with 12916 additions and 18381 deletions

View File

@@ -6,10 +6,8 @@ SITE_URL=http://localhost:5173
NATIVE_APP_URL=code://
# Browser and native public endpoints
VITE_AUTH_URL=http://localhost:5173
VITE_CONVEX_URL=https://example.convex.cloud
VITE_CONVEX_SITE_URL=https://example.convex.site
# For phone testing, replace localhost with this machine's Tailscale IPv4 address.
VITE_FLUE_URL=http://localhost:3583
EXPO_PUBLIC_CONVEX_URL=https://example.convex.cloud
EXPO_PUBLIC_CONVEX_SITE_URL=https://example.convex.site
@@ -23,16 +21,20 @@ DAEMON_COMMAND_LEASE_MS=60000
# Flue persistence adapter
FLUE_DB_TOKEN=replace-with-a-long-random-token
FLUE_URL=http://localhost:3583
# Agent model provider
AGENT_MODEL_PROVIDER=cheaptricks
AGENT_MODEL_NAME=glm-5.2
AGENT_MODEL_PROVIDER=xiaomi
AGENT_MODEL_NAME=mimo-v2.5
AGENT_MODEL_API=openai-completions
AGENT_MODEL_BASE_URL=https://ai.example.com/v1
AGENT_MODEL_API_KEY=replace-with-provider-api-key
AGENT_MODEL_CONTEXT_WINDOW=262000
AGENT_MODEL_CONTEXT_WINDOW=1048576
AGENT_MODEL_MAX_TOKENS=131072
# Self-hosted git (Gitea) — canonical repository host and API token
GITEA_URL=https://git.openputer.com
GITEA_TOKEN=replace-with-a-gitea-personal-access-token
# Zopu lean server (standalone chat + work actor)
VITE_ZOPU_SERVER_URL=http://localhost:3590

View File

@@ -1 +1 @@
This Bun/TypeScript monorepo uses Bun/Vite+, Convex, Effect v4, and Flue; use `@code/*` exports, validate env in `packages/env`, never edit generated files or `repos/`, and keep UI presentational by moving UI state to reusable hooks and business/services/pure logic to dedicated modules. Treat `docs/PRODUCT.md`, `docs/DESIGN.md`, and `docs/TECH.md` as the canonical product, experience, and architecture specifications; read the relevant files before decisions or changes in those domains. Before Flue work, MUST read `.agents/skills/flue/SKILL.md` and use installed-version `flue docs`; after every change, MUST run `bunx ultracite check` on every changed source, script, and agent file (apply `bunx ultracite fix` first when needed), run the affected package's `bun run check-types`, exercise the changed behavior with its targeted runtime or smoke test, then run root `bun run check` and report unrelated pre-existing failures separately. Keep each individual instruction in any `AGENTS.md` to 12 dense sentences, replacing stale guidance rather than appending; this limit applies per instruction, not to the whole file.
This Bun/TypeScript monorepo uses Bun/Vite+, Convex, Effect v4, and Flue; use `@code/*` exports, validate env in `packages/env`, never edit generated files or `repos/` (archived reference code — dormant apps, superseded web components, and prior implementations kept for reference), and keep UI presentational by moving UI state to reusable hooks and business/services/pure logic to dedicated modules. Treat `docs/PRODUCT.md`, `docs/DESIGN.md`, and `docs/TECH.md` as the canonical product, experience, and architecture specifications; read the relevant files before decisions or changes in those domains. Before Flue work, MUST read `.agents/skills/flue/SKILL.md` and use installed-version `flue docs`; after every change, MUST run `bunx ultracite check` on every changed source, script, and agent file (apply `bunx ultracite fix` first when needed), run the affected package's `bun run check-types`, exercise the changed behavior with its targeted runtime or smoke test, then run root `bun run check` and report unrelated pre-existing failures separately. Keep each individual instruction in any `AGENTS.md` to 12 dense sentences, replacing stale guidance rather than appending; this limit applies per instruction, not to the whole file.

View File

@@ -15,8 +15,6 @@
"@code/env": "workspace:*",
"@code/primitives": "workspace:*",
"@code/ui": "workspace:*",
"@flue/react": "1.0.0-beta.9",
"@flue/sdk": "1.0.0-beta.9",
"@react-router/fs-routes": "^8.1.0",
"@react-router/node": "^8.1.0",
"@react-router/serve": "^8.1.0",
@@ -24,8 +22,8 @@
"isbot": "^5.1.44",
"lucide-react": "catalog:",
"next-themes": "catalog:",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react": "catalog:",
"react-dom": "catalog:",
"react-router": "^8.1.0",
"sonner": "catalog:",
"streamdown": "2.5.0"

View File

@@ -1,7 +1,7 @@
import type { Config } from "@react-router/dev/config";
export default {
// Desktop addons package static web assets; SSR output cannot be bundled
ssr: false,
appDirectory: "src",
ssr: true,
} satisfies Config;

View File

@@ -0,0 +1,162 @@
import { useConvexAccessToken } from "@code/auth/web";
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentGroup,
AttachmentMedia,
AttachmentTitle,
} from "@code/ui/components/attachment";
import { ImageIcon, X } from "lucide-react";
import { useEffect, useState } from "react";
import type { PendingChatImage } from "@/lib/chat/attachments";
import type { ConversationPart } from "@/lib/chat/types";
type FilePart = Extract<ConversationPart, { type: "file" }>;
const isDirectlyRenderableUrl = (url: string): boolean =>
url.startsWith("blob:") || url.startsWith("data:");
const useAuthenticatedImageSource = (url?: string): string | undefined => {
const resolveAccessToken = useConvexAccessToken();
const [loaded, setLoaded] = useState<{
readonly source: string;
readonly url: string;
}>();
useEffect(() => {
if (!url || isDirectlyRenderableUrl(url)) {
return;
}
let active = true;
let objectUrl: string | undefined;
const load = async (): Promise<void> => {
try {
const accessToken = await resolveAccessToken();
const response = await fetch(url, {
headers: accessToken
? { authorization: `Bearer ${accessToken}` }
: undefined,
});
if (!response.ok) {
return;
}
objectUrl = URL.createObjectURL(await response.blob());
if (active) {
setLoaded({ source: objectUrl, url });
}
} catch {
// The attachment remains represented by its filename and media type.
}
};
void load();
return () => {
active = false;
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
};
}, [resolveAccessToken, url]);
if (url && isDirectlyRenderableUrl(url)) {
return url;
}
return loaded && loaded.url === url ? loaded.source : undefined;
};
const ImagePreview = ({
alt,
mediaType,
url,
}: {
readonly alt: string;
readonly mediaType: string;
readonly url?: string;
}) => {
const source = useAuthenticatedImageSource(url);
return (
<AttachmentMedia
variant={mediaType.startsWith("image/") ? "image" : "icon"}
>
{source && mediaType.startsWith("image/") ? (
<img alt={alt} className="size-full object-cover" src={source} />
) : (
<ImageIcon className="size-4" />
)}
</AttachmentMedia>
);
};
export const PendingChatAttachments = ({
images,
onRemove,
}: {
readonly images: readonly PendingChatImage[];
readonly onRemove: (id: string) => void;
}) => (
<AttachmentGroup className="max-w-full gap-2 pb-2">
{images.map((image) => (
<Attachment
className="w-24 border-[#d7d3c7] bg-[#fffefa] text-[#20201d]"
key={image.id}
orientation="vertical"
size="sm"
>
<ImagePreview
alt={image.file.name}
mediaType={image.file.type}
url={image.previewUrl}
/>
<AttachmentContent>
<AttachmentTitle>{image.file.name}</AttachmentTitle>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction
aria-label={`Remove ${image.file.name}`}
className="bg-white/90 hover:bg-white"
onClick={() => onRemove(image.id)}
>
<X className="size-3.5" />
</AttachmentAction>
</AttachmentActions>
</Attachment>
))}
</AttachmentGroup>
);
export const MessageAttachments = ({
parts,
}: {
readonly parts: readonly FilePart[];
}) => {
if (parts.length === 0) {
return null;
}
return (
<AttachmentGroup className="mb-2 max-w-full gap-2">
{parts.map((part, index) => {
const title = part.filename ?? `Image ${index + 1}`;
return (
<Attachment
className="min-w-0 max-w-48 border-[#d7d3c7] bg-[#fffefa] text-[#20201d]"
key={part.id ?? `${part.mediaType}:${index}`}
size="sm"
>
<ImagePreview
alt={title}
mediaType={part.mediaType}
url={part.url}
/>
<AttachmentContent>
<AttachmentTitle>{title}</AttachmentTitle>
<AttachmentDescription>{part.mediaType}</AttachmentDescription>
</AttachmentContent>
</Attachment>
);
})}
</AttachmentGroup>
);
};

View File

@@ -7,26 +7,60 @@ import {
MobileChatBubble,
MobileChatMessage,
} from "@code/ui/components/mobile-chat";
import type { ReactNode } from "react";
import { getMessageText, isMessageStreaming } from "@/lib/chat/transforms";
import type { ChatMessageProps } from "@/lib/chat/types";
import {
getMessageText,
getReasoningText,
isReasoningStreaming,
isMessageStreaming,
} from "@/lib/chat/transforms";
import type {
AssistantResponseState,
ChatMessageProps,
} from "@/lib/chat/types";
import { AssistantIdentity } from "./assistant-identity";
import { ChatToolCall } from "./chat-tool-call";
import { MessageAttachments } from "./chat-attachments";
export const ChatMessage = ({ message }: ChatMessageProps) => {
const isUser = message.role === "user";
const isStreaming = isMessageStreaming(message);
const text = getMessageText(message);
if (message.parts.length === 0 && !isStreaming) {
const ReasoningTrace = ({
isStreaming,
text,
}: {
readonly isStreaming: boolean;
readonly text: string;
}) => {
if (!text) {
return null;
}
return (
<details
className="mb-3 border-l-2 border-[#b9b5aa] pl-3 text-[#5f5d55]"
open={isStreaming ? true : undefined}
>
<summary className="cursor-pointer select-none text-xs font-medium text-[#69675e]">
{isStreaming ? "Thinking live" : "Thinking trace"}
</summary>
<MessageResponse
className="chat-reasoning mt-2 min-w-0 text-xs leading-5"
isAnimating={isStreaming}
>
{text}
</MessageResponse>
</details>
);
};
let content: ReactNode = text;
if (!isUser) {
content = text ? (
const AssistantText = ({
hasReasoning,
isStreaming,
text,
}: {
readonly hasReasoning: boolean;
readonly isStreaming: boolean;
readonly text: string;
}) => {
if (text) {
return (
<MessageResponse
caret={isStreaming ? "block" : undefined}
className="chat-markdown min-w-0"
@@ -34,33 +68,71 @@ export const ChatMessage = ({ message }: ChatMessageProps) => {
>
{text}
</MessageResponse>
) : (
<div className="thinking-line" aria-label="Zopu is preparing a response">
<span />
<span />
<span />
</div>
);
}
if (hasReasoning) {
return null;
}
return (
<div className="thinking-line" aria-label="Zopu is preparing a response">
<span />
<span />
<span />
</div>
);
};
const assistantState = (
isStreaming: boolean,
reasoningStreaming: boolean,
text: string
): AssistantResponseState | undefined => {
if (reasoningStreaming && !text) {
return "thinking";
}
return isStreaming ? "writing" : undefined;
};
export const ChatMessage = ({ message }: ChatMessageProps) => {
const isUser = message.role === "user";
const isStreaming = isMessageStreaming(message);
const text = getMessageText(message);
const reasoning = getReasoningText(message);
const reasoningStreaming = isReasoningStreaming(message);
const fileParts = message.parts.filter((part) => part.type === "file");
if (message.parts.length === 0 && !isStreaming) {
return null;
}
const sender = isUser ? "user" : "assistant";
const content = isUser ? (
text
) : (
<AssistantText
hasReasoning={Boolean(reasoning)}
isStreaming={isStreaming}
text={text}
/>
);
const identityState = assistantState(isStreaming, reasoningStreaming, text);
return (
<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} />
)}
{isUser ? null : <AssistantIdentity state={identityState} />}
<MobileChatBubble
className={isUser ? "whitespace-pre-wrap" : undefined}
sender={sender}
>
{message.parts.map((part) =>
part.type === "dynamic-tool" ? (
<ChatToolCall key={part.toolCallId} part={part} />
) : null
<MessageAttachments parts={fileParts} />
{isUser ? null : (
<ReasoningTrace
isStreaming={reasoningStreaming}
text={reasoning}
/>
)}
{content}
</MobileChatBubble>

View File

@@ -1,60 +0,0 @@
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";
export const ChatToolCall = ({ part }: ChatToolCallProps) => {
let detail =
typeof part.input === "string"
? part.input
: JSON.stringify(part.input, null, 2);
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);
status = "done";
tone = "success";
} else if (part.state === "output-error") {
detail = part.errorText;
status = "failed";
tone = "error";
}
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 (
<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

@@ -1,102 +0,0 @@
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} />;
};

View File

@@ -0,0 +1,434 @@
import { projectWorkNotices } from "@code/primitives/work";
import {
Conversation,
ConversationContent,
} from "@code/ui/components/ai-elements/conversation";
import { Button } from "@code/ui/components/button";
import {
ChevronRight,
FolderGit2,
LoaderCircle,
Menu,
MessageSquareText,
ImagePlus,
Send,
Sparkles,
X,
} from "lucide-react";
import { useMemo, useRef, useState } from "react";
import { PendingChatAttachments } from "@/components/chat/chat-attachments";
import { ChatMessage } from "@/components/chat/chat-message";
import { ChatThinkingResponse } from "@/components/chat/chat-thinking-response";
import { useChatImages } from "@/hooks/chat/use-chat-images";
import { useSliceOne } from "@/hooks/slice-one/use-slice-one";
import { useVisualViewportStyle } from "@/hooks/slice-one/use-visual-viewport";
import {
buildSliceOneTimeline,
findSourceMessageTarget,
} from "@/lib/slice-one/presentation";
type SliceWork = NonNullable<ReturnType<typeof useSliceOne>["works"]>[number];
const EMPTY_WORKS: readonly SliceWork[] = [];
interface WorkCardProps {
readonly onSourceSelect: (rawText: string) => void;
readonly work: SliceWork;
}
const WorkCard = ({ onSourceSelect, work }: WorkCardProps) => {
const [sourcesOpen, setSourcesOpen] = useState(false);
const sources = work.signals.flatMap((signal) => signal.sources);
return (
<article className="border border-[#d7d3c7] bg-[#fffefa] p-4 text-[#20201d] shadow-[0_10px_30px_rgba(30,30,20,0.06)]">
<div className="flex items-start gap-3">
<span className="grid size-8 shrink-0 place-items-center bg-[#dcff68]">
<Sparkles className="size-4" />
</span>
<div className="min-w-0 flex-1">
<p className="text-[10px] font-semibold uppercase text-[#65713a]">
Proposed Work
</p>
<h2 className="mt-1 text-[15px] font-semibold leading-5">
{work.title}
</h2>
<p className="mt-1.5 text-[13px] leading-5 text-[#626057]">
{work.objective}
</p>
</div>
</div>
<button
className="mt-3 flex w-full items-center justify-between border-t border-[#e7e3d9] pt-3 text-left text-xs text-[#69675e]"
onClick={() => setSourcesOpen((open) => !open)}
type="button"
>
<span>
{sources.length} exact source{" "}
{sources.length === 1 ? "message" : "messages"}
</span>
<ChevronRight
className={`size-4 transition-transform ${sourcesOpen ? "rotate-90" : ""}`}
/>
</button>
{sourcesOpen ? (
<div className="mt-3 space-y-2">
{sources.map((source) => (
<button
className="flex w-full items-start gap-2 border-l-2 border-[#a8b750] bg-[#f4f2e9] px-3 py-2 text-left text-xs leading-5 hover:bg-[#ece9dd]"
key={source.messageId}
onClick={() => onSourceSelect(source.rawText)}
type="button"
>
<MessageSquareText className="mt-1 size-3.5 shrink-0 text-[#65713a]" />
<span className="min-w-0 flex-1">{source.rawText}</span>
</button>
))}
</div>
) : null}
</article>
);
};
const ConversationLoading = () => (
<output
aria-label="Loading conversation"
className="mx-auto flex min-h-[55vh] w-full max-w-md flex-col justify-center gap-4"
>
<span className="h-16 w-4/5 animate-pulse rounded-sm bg-[#e3e0d5]" />
<span className="ml-auto h-12 w-3/5 animate-pulse rounded-sm bg-[#dedbd0]" />
<span className="h-24 w-full animate-pulse rounded-sm bg-[#e3e0d5]" />
<span className="sr-only">Loading conversation</span>
</output>
);
const ConversationEmptyState = () => (
<div className="grid min-h-[55vh] place-items-center text-center">
<div>
<MessageSquareText className="mx-auto size-7 text-[#8a887f]" />
<h1 className="mt-4 text-xl font-semibold">What should move forward?</h1>
<p className="mx-auto mt-2 max-w-sm text-sm leading-6 text-[#69675f]">
Describe an outcome or problem. Casual conversation stays conversation.
</p>
</div>
</div>
);
type SliceOneState = ReturnType<typeof useSliceOne>;
const ProjectsLoading = () => (
<div className="grid min-h-svh place-items-center bg-[#f2f0e7]">
<LoaderCircle className="size-5 animate-spin" />
</div>
);
const ConnectProject = ({ slice }: { slice: SliceOneState }) => (
<main className="grid min-h-svh place-items-center bg-[#f2f0e7] px-5 text-[#20201d]">
<form
className="w-full max-w-sm"
onSubmit={(event) => {
event.preventDefault();
void slice.connectRepository();
}}
>
<span className="grid size-11 place-items-center bg-[#20201d] text-white">
<FolderGit2 className="size-5" />
</span>
<h1 className="mt-6 text-2xl font-semibold">Connect one project</h1>
<p className="mt-2 text-sm leading-6 text-[#68665e]">
Slice 1 turns actionable conversation into proposed Work with exact
provenance.
</p>
<input
aria-label="Public Git repository URL"
className="mt-6 h-12 w-full border border-[#c9c5b9] bg-[#fffefa] px-3 text-sm outline-none focus:border-[#55564e]"
onChange={(event) => slice.setRepository(event.target.value)}
placeholder="https://github.com/owner/repository"
required
value={slice.repository}
/>
{slice.error ? (
<p className="mt-2 text-xs text-red-700">{slice.error.message}</p>
) : null}
<Button
className="mt-3 h-12 w-full"
disabled={slice.pending}
type="submit"
>
{slice.pending ? (
<LoaderCircle className="size-4 animate-spin" />
) : null}
{slice.pending ? "Connecting" : "Connect project"}
</Button>
</form>
</main>
);
export const SliceOnePage = () => {
const slice = useSliceOne();
const viewportStyle = useVisualViewportStyle();
const [draft, setDraft] = useState("");
const attachments = useChatImages();
const imageInput = useRef<HTMLInputElement>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [highlightedMessageId, setHighlightedMessageId] = useState<string>();
const highlightTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const works = slice.works ?? EMPTY_WORKS;
const workById = useMemo(
() => new Map(works.map((work) => [String(work._id), work])),
[works]
);
const notices = useMemo(() => projectWorkNotices(works), [works]);
const timeline = useMemo(
() => buildSliceOneTimeline(slice.agent.messages, notices),
[notices, slice.agent.messages]
);
const busy =
slice.agent.status === "submitted" || slice.agent.status === "streaming";
const revealSourceMessage = (rawText: string) => {
const messageId = findSourceMessageTarget(slice.agent.messages, rawText);
if (!messageId) {
return;
}
setDrawerOpen(false);
setHighlightedMessageId(messageId);
if (highlightTimer.current) {
clearTimeout(highlightTimer.current);
}
requestAnimationFrame(() => {
document
.querySelector(`#${CSS.escape(`slice-message-${messageId}`)}`)
?.scrollIntoView({ behavior: "smooth", block: "center" });
});
highlightTimer.current = setTimeout(
() => setHighlightedMessageId(undefined),
1800
);
};
if (slice.projects === undefined) {
return <ProjectsLoading />;
}
if (!slice.selectedProject) {
return <ConnectProject slice={slice} />;
}
const send = async () => {
const message = draft.trim();
if (!message || busy) {
return;
}
await slice.agent.sendMessage(message, {
images: attachments.images.map((image) => image.file),
});
setDraft("");
attachments.clear();
};
return (
<main
className="slice-one-surface fixed inset-x-0 top-0 flex min-h-0 overflow-hidden bg-[#f2f0e7] text-[#20201d]"
style={viewportStyle}
>
<section className="flex min-w-0 flex-1 flex-col">
<header className="flex h-14 shrink-0 items-center border-b border-[#d7d3c7] bg-[#faf9f4] px-4">
<div className="min-w-0 flex-1 pr-2">
<select
aria-label="Current project"
className="block h-7 max-w-full border-0 bg-transparent pr-7 text-sm font-semibold text-[#20201d] outline-none"
onChange={(event) => slice.selectProject(event.target.value)}
value={slice.selectedProject.id}
>
{slice.projects.map((project) => (
<option key={project.id} value={project.id}>
{project.name}
</option>
))}
</select>
<p className="text-[10px] uppercase text-[#858277]">
Conversation to proposed Work
</p>
</div>
<button
className="flex h-9 items-center gap-2 border border-[#c9c5b9] bg-white px-3 text-xs lg:hidden"
onClick={() => setDrawerOpen(true)}
type="button"
>
<Menu className="size-4" /> Work{" "}
{slice.works === undefined ? "…" : works.length}
</button>
</header>
<Conversation className="min-h-0 flex-1">
<ConversationContent className="mx-auto min-h-full w-full max-w-2xl gap-4 px-4 py-5 sm:px-6">
{!slice.agent.historyReady && timeline.length === 0 ? (
<ConversationLoading />
) : null}
{slice.agent.historyReady && timeline.length === 0 ? (
<ConversationEmptyState />
) : null}
{timeline.map((item) => {
if (item.kind === "work") {
const work = workById.get(item.notice.workId);
return work ? (
<div
className="chat-message ml-7"
key={`notice-${item.notice.eventId}`}
>
<p className="mb-2 text-[10px] font-semibold uppercase text-[#65713a]">
Work proposed from this conversation
</p>
<WorkCard
onSourceSelect={revealSourceMessage}
work={work}
/>
</div>
) : null;
}
return (
<div
className={`rounded-sm transition-colors duration-300 ${
highlightedMessageId === item.message.id
? "bg-[#dcff68]/70 ring-2 ring-[#7f9130] ring-offset-4 ring-offset-[#f2f0e7]"
: ""
}`}
id={`slice-message-${item.message.id}`}
key={item.message.id}
>
<ChatMessage message={item.message} />
</div>
);
})}
{slice.agent.status === "submitted" ? (
<ChatThinkingResponse />
) : null}
</ConversationContent>
</Conversation>
<div className="shrink-0 border-t border-[#d7d3c7] bg-[#faf9f4] p-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]">
{attachments.images.length > 0 ? (
<div className="mx-auto max-w-2xl">
<PendingChatAttachments
images={attachments.images}
onRemove={attachments.handleRemove}
/>
</div>
) : null}
{slice.agent.error ? (
<p className="mx-auto mb-2 max-w-2xl text-xs text-red-700">
{slice.agent.error.message}
</p>
) : null}
{attachments.error ? (
<p className="mx-auto mb-2 max-w-2xl text-xs text-red-700">
{attachments.error}
</p>
) : null}
<div className="mx-auto flex max-w-2xl items-end gap-2">
<input
accept="image/*"
aria-label="Attach images"
className="sr-only"
multiple
onChange={(event) => {
attachments.addFiles(event.target.files);
event.target.value = "";
}}
ref={imageInput}
type="file"
/>
<Button
aria-label="Attach images"
className="size-11 shrink-0"
disabled={busy}
onClick={() => imageInput.current?.click()}
size="icon"
type="button"
variant="outline"
>
<ImagePlus className="size-4" />
</Button>
<textarea
aria-label="Message Zopu"
className="max-h-32 min-h-11 flex-1 resize-none border border-[#c9c5b9] bg-white px-3 py-2.5 text-sm outline-none focus:border-[#55564e]"
disabled={busy}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
void send();
}
}}
placeholder="Describe an outcome or problem…"
rows={1}
value={draft}
/>
<Button
aria-label="Send message"
className="size-11 shrink-0"
disabled={!draft.trim() || busy}
onClick={() => void send()}
size="icon"
type="button"
>
{busy ? (
<LoaderCircle className="size-4 animate-spin" />
) : (
<Send className="size-4" />
)}
</Button>
</div>
</div>
</section>
<aside className="hidden w-[380px] shrink-0 overflow-y-auto border-l border-[#d7d3c7] bg-[#e9e7de] p-4 lg:block">
<h2 className="text-sm font-semibold">Proposed Work</h2>
<p className="mb-4 text-xs text-[#747168]">
{works.length} durable outcomes
</p>
<div className="space-y-3">
{works.map((work) => (
<WorkCard
key={work._id}
onSourceSelect={revealSourceMessage}
work={work}
/>
))}
</div>
</aside>
{drawerOpen ? (
<div className="fixed inset-0 z-50 bg-black/30 lg:hidden">
<button
aria-label="Close Work drawer"
className="absolute inset-0"
onClick={() => setDrawerOpen(false)}
type="button"
/>
<section className="absolute inset-y-0 right-0 flex w-[min(92vw,380px)] flex-col bg-[#e9e7de] shadow-2xl">
<header className="flex h-14 items-center border-b border-[#cfcbc0] px-4">
<h2 className="flex-1 text-sm font-semibold">Proposed Work</h2>
<button
aria-label="Close Work drawer"
className="grid size-9 place-items-center"
onClick={() => setDrawerOpen(false)}
type="button"
>
<X className="size-4" />
</button>
</header>
<div className="flex-1 space-y-3 overflow-y-auto p-4">
{works.length === 0 ? (
<p className="py-12 text-center text-sm text-[#747168]">
Actionable messages will appear here.
</p>
) : null}
{works.map((work) => (
<WorkCard
key={work._id}
onSourceSelect={revealSourceMessage}
work={work}
/>
))}
</div>
</section>
</div>
) : null}
</main>
);
};

View File

@@ -1,60 +0,0 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { useChatAgent } from "./use-chat-agent";
const mocks = vi.hoisted(() => ({
agent: {
error: undefined,
historyReady: true,
messages: [],
sendMessage: vi.fn(() => Promise.resolve()),
status: "idle" as const,
},
organization: {} as { error?: Error; organizationId?: string },
useFlueAgent: vi.fn(),
}));
vi.mock("@flue/react", () => ({
useFlueAgent: mocks.useFlueAgent,
}));
vi.mock("@/hooks/use-personal-organization", () => ({
usePersonalOrganization: () => mocks.organization,
}));
describe("useChatAgent", () => {
beforeEach(() => {
mocks.organization = {};
mocks.agent.sendMessage.mockClear();
mocks.useFlueAgent.mockReset();
mocks.useFlueAgent.mockReturnValue(mocks.agent);
});
test("does not use the agent before organization bootstrap completes", async () => {
const chat = useChatAgent();
expect(mocks.useFlueAgent).toHaveBeenCalledWith({
id: undefined,
live: "sse",
name: "zopu",
});
expect(chat.status).toBe("connecting");
await expect(chat.sendMessage("too early")).rejects.toThrow(
"Personal organization is still being prepared"
);
expect(mocks.agent.sendMessage).not.toHaveBeenCalled();
});
test("uses the ensured organization id for the agent session", async () => {
mocks.organization = { organizationId: "org-a" };
const chat = useChatAgent();
expect(mocks.useFlueAgent).toHaveBeenCalledWith({
id: "org-a",
live: "sse",
name: "zopu",
});
await chat.sendMessage("ready");
expect(mocks.agent.sendMessage).toHaveBeenCalledWith("ready");
});
});

View File

@@ -1,41 +1,106 @@
import { useFlueAgent } from "@flue/react";
import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { useMutation, useQuery } from "convex/react";
import { useState } from "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";
import { projectConversation } from "@/lib/chat/conversation";
import type { AgentStatus, ChatAgentState } from "@/lib/chat/types";
const requestId = (): string =>
globalThis.crypto?.randomUUID?.() ??
`${Date.now()}-${Math.random().toString(16).slice(2)}`;
const uploadImage = async (
file: File,
generateUploadUrl: () => Promise<string>
) => {
const response = await fetch(await generateUploadUrl(), {
body: file,
headers: { "content-type": file.type },
method: "POST",
});
if (!response.ok) {
throw new Error(`Image upload failed (${response.status})`);
}
const payload: unknown = await response.json();
if (
typeof payload !== "object" ||
payload === null ||
!("storageId" in payload) ||
typeof payload.storageId !== "string"
) {
throw new Error("Image upload returned an invalid response");
}
return {
filename: file.name || undefined,
mimeType: file.type,
storageId: payload.storageId as Id<"_storage">,
};
};
export const useOrganizationChatAgent = (
organization: PersonalOrganizationState
): ChatAgentState => {
const agent = useFlueAgent({
...CHAT_AGENT,
id: organization.organizationId,
});
const { organizationId } = organization;
const rows = useQuery(
api.conversationMessages.listForCurrentOrganization,
organizationId ? { organizationId } : "skip"
);
const send = useMutation(api.conversationMessages.send);
const generateUploadUrl = useMutation(
api.conversationMessages.generateUploadUrl
);
const [sendError, setSendError] = useState<Error>();
const sendMessage = async (message: string): Promise<void> => {
if (!organization.organizationId) {
const projected = projectConversation(rows ?? []);
let status: AgentStatus = projected.pending ? "submitted" : "idle";
if (!organizationId || rows === undefined) {
status = organization.error ? "error" : "connecting";
} else if (projected.failedError || sendError) {
status = "error";
}
const sendMessage = async (
message: string,
options?: { readonly images?: readonly File[] }
): Promise<void> => {
if (!organizationId) {
throw (
organization.error ??
new Error("Personal organization is still being prepared")
);
}
await agent.sendMessage(message);
setSendError(undefined);
try {
const images = await Promise.all(
(options?.images ?? []).map((file) =>
uploadImage(file, generateUploadUrl)
)
);
await send({
clientRequestId: requestId(),
images,
organizationId,
rawText: message,
});
} catch (error) {
const normalized =
error instanceof Error ? error : new Error(String(error));
setSendError(normalized);
throw normalized;
}
};
let { status } = agent;
if (!organization.organizationId) {
status = organization.error ? "error" : "connecting";
}
return {
error: organization.error ?? agent.error,
historyReady: agent.historyReady,
messages: agent.messages,
error:
organization.error ??
sendError ??
(projected.failedError ? new Error(projected.failedError) : undefined),
historyReady: rows !== undefined,
messages: projected.messages,
sendMessage,
status,
};
};
export const useChatAgent = (): ChatAgentState =>
useOrganizationChatAgent(usePersonalOrganization());

View File

@@ -0,0 +1,79 @@
import { useEffect, useRef, useState } from "react";
import { MAX_CHAT_IMAGES, validateChatImage } from "@/lib/chat/attachments";
import type { PendingChatImage } from "@/lib/chat/attachments";
const generateImageId = (): string =>
globalThis.crypto?.randomUUID?.() ??
`${Date.now()}-${Math.random().toString(16).slice(2)}`;
export const useChatImages = () => {
const [images, setImages] = useState<PendingChatImage[]>([]);
const [error, setError] = useState<string>();
const previewUrls = useRef(new Set<string>());
useEffect(
() => () => {
for (const previewUrl of previewUrls.current) {
URL.revokeObjectURL(previewUrl);
}
},
[]
);
const addFiles = (files: FileList | null) => {
if (!files) {
return;
}
setError(undefined);
setImages((current) => {
const remaining = MAX_CHAT_IMAGES - current.length;
if (remaining <= 0) {
setError(`Attach up to ${MAX_CHAT_IMAGES} images`);
return current;
}
const next = [...current];
for (const file of [...files].slice(0, remaining)) {
const validation = validateChatImage(file);
if (!validation.accepted) {
setError(validation.message);
continue;
}
const previewUrl = URL.createObjectURL(file);
previewUrls.current.add(previewUrl);
next.push({
file,
id: generateImageId(),
previewUrl,
});
}
if (files.length > remaining) {
setError(`Attach up to ${MAX_CHAT_IMAGES} images`);
}
return next;
});
};
const remove = (id: string) => {
setImages((current) => {
const removed = current.find((image) => image.id === id);
if (removed) {
URL.revokeObjectURL(removed.previewUrl);
previewUrls.current.delete(removed.previewUrl);
}
return current.filter((image) => image.id !== id);
});
setError(undefined);
};
const clear = () => {
for (const previewUrl of previewUrls.current) {
URL.revokeObjectURL(previewUrl);
}
previewUrls.current.clear();
setImages([]);
setError(undefined);
};
return { addFiles, clear, error, handleRemove: remove, images } as const;
};

View File

@@ -0,0 +1,77 @@
import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { useAction, useQuery } from "convex/react";
import { useMemo, useState } from "react";
import { useOrganizationChatAgent } from "@/hooks/chat/use-chat-agent";
import { usePersonalOrganization } from "@/hooks/use-personal-organization";
const toError = (error: unknown) =>
error instanceof Error ? error : new Error(String(error));
export const useSliceOne = () => {
const organization = usePersonalOrganization();
const projects = useQuery(
api.projects.list,
organization.organizationId ? {} : "skip"
);
const importPublicGit = useAction(api.projects.importPublicGit);
const agent = useOrganizationChatAgent(organization);
const [selectedProjectId, setSelectedProjectId] =
useState<Id<"projects"> | null>(null);
const [repository, setRepository] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState<Error>();
const selectedProjectStillExists = projects?.some(
(project) => project.id === (selectedProjectId as unknown as string)
);
const activeProjectId = selectedProjectStillExists
? selectedProjectId
: ((projects?.[0]?.id as unknown as Id<"projects"> | undefined) ?? null);
const works = useQuery(
api.works.listForProject,
activeProjectId ? { projectId: activeProjectId } : "skip"
);
const selectedProject = useMemo(
() =>
projects?.find(
(project) => project.id === (activeProjectId as unknown as string)
) ?? null,
[activeProjectId, projects]
);
const connectRepository = async () => {
const repositoryUrl = repository.trim();
if (!repositoryUrl || pending) {
return;
}
setPending(true);
setError(undefined);
try {
const project = await importPublicGit({ repositoryUrl });
setSelectedProjectId(project.id as unknown as Id<"projects">);
setRepository("");
} catch (caughtError) {
setError(toError(caughtError));
} finally {
setPending(false);
}
};
const selectProject = (projectId: string) => {
setSelectedProjectId(projectId as unknown as Id<"projects">);
};
return {
agent,
connectRepository,
error,
pending,
projects,
repository,
selectProject,
selectedProject,
setRepository,
works,
} as const;
};

View File

@@ -0,0 +1,26 @@
import { describe, expect, test } from "vitest";
import { visualViewportStyle } from "./use-visual-viewport";
describe("Slice 1 visual viewport", () => {
test("shrinks the application surface to the keyboard-visible height", () => {
expect(visualViewportStyle({ height: 500, offsetTop: 0 })).toEqual({
height: "500px",
transform: undefined,
});
});
test("tracks a panned visual viewport without scrolling the document", () => {
expect(visualViewportStyle({ height: 500, offsetTop: 44 })).toEqual({
height: "500px",
transform: "translateY(44px)",
});
});
test("uses a dynamic viewport fallback before browser measurement", () => {
expect(visualViewportStyle()).toEqual({
height: "100dvh",
transform: undefined,
});
});
});

View File

@@ -0,0 +1,52 @@
import type { CSSProperties } from "react";
import { useEffect, useState } from "react";
export interface VisualViewportFrame {
readonly height: number;
readonly offsetTop: number;
}
const readVisualViewport = (): VisualViewportFrame => ({
height: Math.round(window.visualViewport?.height ?? window.innerHeight),
offsetTop: Math.round(window.visualViewport?.offsetTop ?? 0),
});
export const visualViewportStyle = (
frame?: VisualViewportFrame
): CSSProperties => ({
height: frame ? `${frame.height}px` : "100dvh",
transform: frame?.offsetTop ? `translateY(${frame.offsetTop}px)` : undefined,
});
export const useVisualViewportStyle = (): CSSProperties => {
const [frame, setFrame] = useState<VisualViewportFrame>();
useEffect(() => {
const root = document.documentElement;
const viewport = window.visualViewport;
let animationFrame = 0;
const update = () => {
cancelAnimationFrame(animationFrame);
animationFrame = requestAnimationFrame(() => {
setFrame(readVisualViewport());
});
};
root.classList.add("slice-one-viewport-lock");
update();
window.addEventListener("resize", update);
viewport?.addEventListener("resize", update);
viewport?.addEventListener("scroll", update);
return () => {
cancelAnimationFrame(animationFrame);
root.classList.remove("slice-one-viewport-lock");
window.removeEventListener("resize", update);
viewport?.removeEventListener("resize", update);
viewport?.removeEventListener("scroll", update);
};
}, []);
return visualViewportStyle(frame);
};

View File

@@ -1,91 +0,0 @@
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

@@ -1,82 +0,0 @@
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

@@ -1,7 +1,7 @@
import { useWebAuth } from "@code/auth/web";
import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { useMutation } from "convex/react";
import { useConvexAuth, useMutation } from "convex/react";
import { useEffect, useState } from "react";
export interface PersonalOrganizationState {
@@ -13,6 +13,8 @@ export interface PersonalOrganizationState {
/** Ensure the authenticated user has its personal tenancy boundary. */
export const usePersonalOrganization = (): PersonalOrganizationState => {
const auth = useWebAuth();
const { isAuthenticated, isRefreshing } = useConvexAuth();
const convexReady = isAuthenticated && !isRefreshing;
const userId = auth.status === "authenticated" ? auth.user.id : null;
const ensurePersonalOrganization = useMutation(
api.organizations.ensurePersonalOrganization
@@ -25,7 +27,7 @@ export const usePersonalOrganization = (): PersonalOrganizationState => {
}>();
useEffect(() => {
if (!userId) {
if (!convexReady || !userId) {
return;
}
@@ -57,9 +59,9 @@ export const usePersonalOrganization = (): PersonalOrganizationState => {
return () => {
active = false;
};
}, [ensurePersonalOrganization, userId]);
}, [convexReady, ensurePersonalOrganization, userId]);
if (!userId || state?.userId !== userId) {
if (!convexReady || !userId || state?.userId !== userId) {
return {};
}

View File

@@ -1,194 +0,0 @@
import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel";
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);
export const useProjectWorkspace = () => {
const flueClient = useFlueClient();
const projects = useQuery(api.projects.list);
const [selectedProjectId, setSelectedProjectId] =
useState<Id<"projects"> | null>(null);
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 =
selectedProjectId ??
(projects?.[0]?.id as unknown as Id<"projects">) ??
null;
const artifacts = useQuery(
api.projectArtifacts.list,
activeProjectId ? { projectId: activeProjectId } : "skip"
);
const issues = useQuery(
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");
setError(null);
try {
const outcome = await importPublicGit({ repositoryUrl: repository });
setSelectedProjectId(outcome.id as unknown as Id<"projects">);
setRepository("");
} catch (caughtError) {
setError(errorMessage(caughtError));
} finally {
setPendingAction(null);
}
};
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 {
const issueId = await createIssue({
body: nextBody,
projectId: activeProjectId,
title: nextTitle,
});
setSelectedIssueId(issueId);
setIssueTitle("");
setIssueBody("");
} catch (caughtError) {
setError(errorMessage(caughtError));
} finally {
setPendingAction(null);
}
};
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,
title: string
) => {
const actionKey = `issue:${issueId}`;
setPendingAction(actionKey);
setError(null);
try {
await beginIssue({ issueId });
await flueClient.agents.send("project-manager", String(issueId), {
message: `Start project issue ${issueNumber}: ${title}. Read the bound context and complete the workflow.`,
});
} catch (caughtError) {
const message = errorMessage(caughtError);
await markDispatchFailed({ error: message, issueId });
setError(message);
} finally {
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

@@ -6,6 +6,13 @@ body {
min-height: 100%;
}
html.slice-one-viewport-lock,
html.slice-one-viewport-lock body {
height: 100%;
overflow: hidden;
overscroll-behavior: none;
}
.ai-conversation-mobile > div {
scrollbar-gutter: auto !important;
}
@@ -68,6 +75,19 @@ body {
min-height: 1.75rem;
}
.route-progress-bar {
animation: route-progress 900ms ease-in-out infinite;
}
@keyframes route-progress {
from {
transform: translateX(-100%);
}
to {
transform: translateX(400%);
}
}
@keyframes response-dot {
0%,
60%,
@@ -96,6 +116,12 @@ body {
color: var(--foreground);
}
.slice-one-surface .chat-markdown,
.slice-one-surface .chat-reasoning,
.slice-one-surface .thinking-line {
color: #232321;
}
.chat-markdown > :first-child {
margin-top: 0;
}

View File

@@ -0,0 +1,64 @@
import { env } from "@code/env/web";
import { redirect } from "react-router";
interface AuthLoaderData {
readonly token: string | null;
}
const tokenUrl = new URL("/api/auth/convex/token", env.VITE_AUTH_URL);
export const loadAuthToken = async (
request: Request
): Promise<AuthLoaderData> => {
const cookie = request.headers.get("cookie");
if (!cookie) {
return { token: null };
}
const headers = new Headers({ cookie });
headers.set("host", tokenUrl.host);
const response = await fetch(tokenUrl, { headers });
if (response.status === 401) {
return { token: null };
}
if (!response.ok) {
throw new Response("Authentication service unavailable", { status: 503 });
}
const payload: unknown = await response.json();
if (
typeof payload !== "object" ||
payload === null ||
!("token" in payload) ||
typeof payload.token !== "string"
) {
throw new Response("Authentication service returned an invalid response", {
status: 502,
});
}
return { token: payload.token };
};
export const requireAuthToken = async (
request: Request
): Promise<AuthLoaderData> => {
const auth = await loadAuthToken(request);
if (!auth.token) {
const requestUrl = new URL(request.url);
const returnTo = `${requestUrl.pathname}${requestUrl.search}`;
throw redirect(`/login?returnTo=${encodeURIComponent(returnTo)}`);
}
return auth;
};
export const redirectAuthenticated = async (
request: Request
): Promise<AuthLoaderData> => {
const auth = await loadAuthToken(request);
if (auth.token) {
const requestUrl = new URL(request.url);
const returnTo = requestUrl.searchParams.get("returnTo");
throw redirect(returnTo?.startsWith("/") ? returnTo : "/");
}
return auth;
};

View File

@@ -0,0 +1,23 @@
import { describe, expect, test } from "vitest";
import { MAX_CHAT_IMAGE_BYTES, validateChatImage } from "./attachments";
describe("chat image attachments", () => {
test("accepts supported image payloads within the upload limit", () => {
expect(validateChatImage({ size: 1024, type: "image/png" })).toEqual({
accepted: true,
});
});
test("rejects non-images and oversized images before transport", () => {
expect(validateChatImage({ size: 1024, type: "text/plain" }).accepted).toBe(
false
);
expect(
validateChatImage({
size: MAX_CHAT_IMAGE_BYTES + 1,
type: "image/jpeg",
}).accepted
).toBe(false);
});
});

View File

@@ -0,0 +1,28 @@
export const MAX_CHAT_IMAGES = 4;
export const MAX_CHAT_IMAGE_BYTES = 10 * 1024 * 1024;
export interface PendingChatImage {
readonly file: File;
readonly id: string;
readonly previewUrl: string;
}
export interface ImageValidationResult {
readonly accepted: boolean;
readonly message?: string;
}
export const validateChatImage = (
file: Pick<File, "size" | "type">
): ImageValidationResult => {
if (!file.type.startsWith("image/")) {
return { accepted: false, message: "Only image files can be attached" };
}
if (file.size > MAX_CHAT_IMAGE_BYTES) {
return {
accepted: false,
message: "Images must be 10 MB or smaller",
};
}
return { accepted: true };
};

View File

@@ -1,22 +1,12 @@
import type { AgentStatus } from "@flue/react";
import type { AgentStatus } from "./types";
/**
* The global Zopu agent. The instance `id` is the current organization id
* (resolved at runtime by the chat hook), not the legacy `main` shared
* instance. Organization scoping is the hard tenancy boundary.
*/
export const CHAT_AGENT = {
live: "sse",
name: "zopu",
} as const;
export const MODEL_LABEL = "GLM-5.2";
export const SUGGESTIONS = [
["Think it through", "Help me make a difficult decision"],
["Make a plan", "Turn an idea into clear next steps"],
["Explain simply", "Break down something complex"],
] as const;
export const MODEL_ID = "openrouter/openai/gpt-oss-20b:free";
export const MODEL_LABEL = "GPT-OSS 20B Free";
export const STATUS_COPY: Record<AgentStatus, string> = {
connecting: "Connecting",

View File

@@ -0,0 +1,44 @@
import { describe, expect, test } from "vitest";
import type { ConversationRow } from "./conversation";
import { projectConversation } from "./conversation";
const row = (
overrides: Partial<ConversationRow> &
Pick<ConversationRow, "messageId" | "role" | "status">
): ConversationRow => ({
attachments: [],
error: null,
rawText: "",
...overrides,
});
describe("projectConversation", () => {
test("keeps queued assistant rows as status instead of duplicate messages", () => {
const state = projectConversation([
row({
messageId: "user-1",
rawText: "Build it",
role: "user",
status: "processing",
}),
row({ messageId: "assistant-1", role: "assistant", status: "queued" }),
]);
expect(state.pending).toBe(true);
expect(state.messages).toHaveLength(1);
});
test("projects completed Convex rows into renderable messages", () => {
const state = projectConversation([
row({
messageId: "assistant-1",
rawText: "Captured the request.",
role: "assistant",
status: "completed",
}),
]);
expect(state.messages[0]?.parts).toEqual([
{ state: "done", text: "Captured the request.", type: "text" },
]);
});
});

View File

@@ -0,0 +1,50 @@
import type { ConversationMessage } from "./types";
export interface ConversationRow {
readonly attachments: readonly {
readonly filename: string | null;
readonly id: string;
readonly mediaType: string;
readonly url: string | null;
}[];
readonly error: string | null;
readonly messageId: string;
readonly rawText: string;
readonly role: "assistant" | "user";
readonly status: "completed" | "failed" | "processing" | "queued";
}
export const projectConversation = (rows: readonly ConversationRow[]) => ({
failedError: rows.findLast(
(row) => row.role === "assistant" && row.status === "failed"
)?.error,
messages: rows
.filter((row) => row.role === "user" || row.status === "completed")
.map<ConversationMessage>((row) => ({
id: row.messageId,
parts: [
...row.attachments.map((attachment) => ({
filename: attachment.filename ?? undefined,
id: attachment.id,
mediaType: attachment.mediaType,
type: "file" as const,
url: attachment.url ?? undefined,
})),
...(row.rawText
? [
{
state: "done" as const,
text: row.rawText,
type: "text" as const,
},
]
: []),
],
role: row.role,
})),
pending: rows.some(
(row) =>
row.role === "assistant" &&
(row.status === "queued" || row.status === "processing")
),
});

View File

@@ -0,0 +1,43 @@
import { describe, expect, test } from "vitest";
import {
extractThinkingMarkup,
getReasoningText,
isReasoningStreaming,
} from "./transforms";
import type { ConversationMessage } from "./types";
const message = (parts: ConversationMessage["parts"]): ConversationMessage => ({
id: "message-1",
parts,
role: "assistant",
});
describe("reasoning traces", () => {
test("extracts completed and streaming MiniMax think blocks", () => {
expect(
extractThinkingMarkup(
"<think>First thought</think><think>Current thought"
)
).toBe("First thought\n\nCurrent thought");
});
test("combines native reasoning with inline thinking", () => {
expect(
getReasoningText(
message([
{ state: "streaming", text: "Native thought", type: "reasoning" },
{ state: "streaming", text: "<think>Inline thought", type: "text" },
])
)
).toBe("Native thought\n\nInline thought");
});
test("recognizes an open streamed think block as live reasoning", () => {
expect(
isReasoningStreaming(
message([{ state: "streaming", text: "<think>Working", type: "text" }])
)
).toBe(true);
});
});

View File

@@ -1,4 +1,4 @@
import type { AgentStatus, FlueConversationMessage } from "@flue/react";
import type { AgentStatus, ConversationMessage } from "./types";
export const stripThinkingMarkup = (text: string): string => {
const withoutCompletedBlocks = text.replaceAll(
@@ -15,24 +15,61 @@ export const stripThinkingMarkup = (text: string): string => {
return visibleText.replaceAll("</think>", "").trimStart();
};
export const getMessageText = (message: FlueConversationMessage): string =>
stripThinkingMarkup(
message.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n")
);
export const isMessageStreaming = (message: FlueConversationMessage): boolean =>
message.parts.some((part) => {
if (part.type === "dynamic-tool") {
return part.state === "input-available";
export const extractThinkingMarkup = (text: string): string => {
const completed = [
...text.matchAll(/<think>(?<content>[\s\S]*?)<\/think>/giu),
]
.map((match) => match.groups?.content?.trim())
.filter((value): value is string => Boolean(value));
const lowerText = text.toLowerCase();
const openTagIndex = lowerText.lastIndexOf("<think>");
const closeTagIndex = lowerText.lastIndexOf("</think>");
if (openTagIndex > closeTagIndex) {
const active = text.slice(openTagIndex + "<think>".length).trim();
if (active) {
completed.push(active);
}
return (
}
return completed.join("\n\n");
};
export const getRawMessageText = (message: ConversationMessage): string =>
message.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n");
export const getMessageText = (message: ConversationMessage): string =>
stripThinkingMarkup(getRawMessageText(message));
export const getReasoningText = (message: ConversationMessage): string => {
const nativeReasoning = message.parts
.filter((part) => part.type === "reasoning")
.map((part) => part.text)
.join("\n");
const inlineReasoning = extractThinkingMarkup(getRawMessageText(message));
return [nativeReasoning, inlineReasoning].filter(Boolean).join("\n\n");
};
export const isReasoningStreaming = (message: ConversationMessage): boolean => {
const nativeReasoningStreaming = message.parts.some(
(part) => part.type === "reasoning" && part.state === "streaming"
);
const rawText = getRawMessageText(message).toLowerCase();
const inlineReasoningStreaming =
rawText.lastIndexOf("<think>") > rawText.lastIndexOf("</think>") &&
message.parts.some(
(part) => part.type === "text" && part.state === "streaming"
);
return nativeReasoningStreaming || inlineReasoningStreaming;
};
export const isMessageStreaming = (message: ConversationMessage): boolean =>
message.parts.some(
(part) =>
(part.type === "text" || part.type === "reasoning") &&
part.state === "streaming"
);
});
);
export const getStatusDotClass = (status: AgentStatus): string => {
if (status === "error") {

View File

@@ -1,14 +1,43 @@
import type {
AgentStatus,
FlueConversationMessage,
FlueConversationPart,
} from "@flue/react";
export type AgentStatus =
| "connecting"
| "error"
| "idle"
| "streaming"
| "submitted";
export type ConversationPart =
| {
readonly state: "done" | "streaming";
readonly text: string;
readonly type: "text";
}
| {
readonly state: "done" | "streaming";
readonly text: string;
readonly type: "reasoning";
}
| {
readonly filename?: string;
readonly id?: string;
readonly mediaType: string;
readonly type: "file";
readonly url?: string;
};
export interface ConversationMessage {
readonly id: string;
readonly parts: ConversationPart[];
readonly role: "assistant" | "user";
}
export interface ChatAgentState {
error?: Error;
historyReady: boolean;
messages: FlueConversationMessage[];
sendMessage: (message: string) => Promise<void>;
messages: ConversationMessage[];
sendMessage: (
message: string,
options?: { readonly images?: readonly File[] }
) => Promise<void>;
status: AgentStatus;
}
@@ -20,7 +49,7 @@ export interface ChatComposerProps {
export interface ChatConversationProps {
historyReady: boolean;
messages: FlueConversationMessage[];
messages: ConversationMessage[];
onSuggestion: (suggestion: string) => void;
status: AgentStatus;
}
@@ -30,11 +59,7 @@ export interface ChatHeaderProps {
}
export interface ChatMessageProps {
message: FlueConversationMessage;
}
export interface ChatToolCallProps {
part: Extract<FlueConversationPart, { type: "dynamic-tool" }>;
message: ConversationMessage;
}
export type AssistantResponseState = "thinking" | "writing";

View File

@@ -1,115 +0,0 @@
import { createFlueClient } from "@flue/sdk";
import { describe, expect, test } from "vitest";
import { createFlueFetch } from "./flue-transport";
const BASE_URL = new URL("https://flue.example/api/");
describe("createFlueFetch", () => {
test("overlapping SDK agent sends keep distinct request IDs", async () => {
const firstResponse = Promise.withResolvers<Response>();
const secondResponse = Promise.withResolvers<Response>();
const bothStarted = Promise.withResolvers<boolean>();
const capturedHeaders: Headers[] = [];
let requestCount = 0;
let requestId = 0;
const fetchImpl: typeof fetch = (input, init) => {
capturedHeaders.push(new Headers(init?.headers));
requestCount += 1;
if (requestCount === 2) {
bothStarted.resolve(true);
}
if (requestCount === 1) {
return firstResponse.promise;
}
if (requestCount === 2) {
return secondResponse.promise;
}
throw new Error(`Unexpected request: ${String(input)}`);
};
const client = createFlueClient({
baseUrl: BASE_URL.toString(),
fetch: createFlueFetch({
baseUrl: BASE_URL,
fetchImpl,
generateRequestId: () => `request-${(requestId += 1)}`,
}),
headers: { authorization: "Bearer current-jwt" },
});
const firstSend = client.agents.send("zopu", "org-a", {
message: "first",
});
const secondSend = client.agents.send("zopu", "org-a", {
message: "second",
});
await bothStarted.promise;
expect(capturedHeaders).toHaveLength(2);
expect(capturedHeaders[0]?.get("x-zopu-request-id")).toBe("request-1");
expect(capturedHeaders[1]?.get("x-zopu-request-id")).toBe("request-2");
expect(capturedHeaders[0]?.get("authorization")).toBe("Bearer current-jwt");
expect(capturedHeaders[1]?.get("authorization")).toBe("Bearer current-jwt");
firstResponse.resolve(
Response.json(
{
offset: "1",
streamUrl: "http://internal/streams/first",
submissionId: "submission-1",
},
{ status: 202 }
)
);
secondResponse.resolve(
Response.json(
{
offset: "2",
streamUrl: "http://internal/streams/second",
submissionId: "submission-2",
},
{ status: 202 }
)
);
await expect(Promise.all([firstSend, secondSend])).resolves.toEqual([
{
offset: "1",
streamUrl: "https://flue.example/streams/first",
submissionId: "submission-1",
},
{
offset: "2",
streamUrl: "https://flue.example/streams/second",
submissionId: "submission-2",
},
]);
});
test("history and stream observation requests remain untagged", async () => {
const capturedHeaders: Headers[] = [];
const fetchImpl: typeof fetch = (_input, init) => {
capturedHeaders.push(new Headers(init?.headers));
return Promise.resolve(new Response(null, { status: 204 }));
};
const flueFetch = createFlueFetch({
baseUrl: BASE_URL,
fetchImpl,
generateRequestId: () => "must-not-be-used",
});
await flueFetch("https://flue.example/api/agents/zopu/org-a?view=history", {
headers: { authorization: "Bearer current-jwt" },
method: "GET",
});
await flueFetch("https://flue.example/api/agents/zopu/org-a?view=updates", {
headers: { authorization: "Bearer current-jwt" },
method: "GET",
});
expect(capturedHeaders).toHaveLength(2);
expect(capturedHeaders[0]?.has("x-zopu-request-id")).toBe(false);
expect(capturedHeaders[1]?.has("x-zopu-request-id")).toBe(false);
});
});

View File

@@ -1,82 +0,0 @@
interface FlueFetchOptions {
readonly baseUrl: URL;
readonly fetchImpl?: typeof fetch;
readonly generateRequestId?: () => string;
}
/**
* Build the Flue transport used by the browser client.
*
* The installed Flue SDK does not expose per-call headers on `agents.send`.
* Its custom `fetch` seam does receive the resolved URL and method, so request
* IDs are attached here to each agent admission POST. History and Durable
* Streams observation requests are GETs and intentionally remain untagged.
*/
export const createFlueFetch = ({
baseUrl,
fetchImpl = fetch,
generateRequestId = crypto.randomUUID,
}: FlueFetchOptions): typeof fetch => {
const basePath = baseUrl.pathname.replace(/\/+$/u, "");
return async (input, init) => {
const inputUrl =
typeof input === "string" || input instanceof URL ? input : input.url;
const requestUrl = new URL(inputUrl, baseUrl);
const method = (
init?.method ?? (input instanceof Request ? input.method : "GET")
).toUpperCase();
const relativePath = requestUrl.pathname.startsWith(`${basePath}/`)
? requestUrl.pathname.slice(basePath.length)
: requestUrl.pathname;
const pathSegments = relativePath.split("/").filter(Boolean);
const isAgentAdmission =
method === "POST" &&
pathSegments.length === 3 &&
pathSegments[0] === "agents";
let requestInit = init;
if (isAgentAdmission) {
const headers = new Headers(
input instanceof Request ? input.headers : undefined
);
for (const [key, value] of new Headers(init?.headers).entries()) {
headers.set(key, value);
}
headers.set("x-zopu-request-id", generateRequestId());
requestInit = { ...init, headers };
}
const response = await fetchImpl(input, requestInit);
if (
!response.ok ||
!response.headers.get("content-type")?.includes("application/json")
) {
return response;
}
const body = (await response.clone().json()) as unknown;
if (
typeof body !== "object" ||
body === null ||
!("streamUrl" in body) ||
typeof body.streamUrl !== "string"
) {
return response;
}
const streamUrl = new URL(body.streamUrl);
streamUrl.protocol = baseUrl.protocol;
streamUrl.host = baseUrl.host;
const headers = new Headers(response.headers);
headers.set("location", streamUrl.toString());
return Response.json(
{ ...body, streamUrl: streamUrl.toString() },
{
headers,
status: response.status,
statusText: response.statusText,
}
);
};
};

View File

@@ -1,186 +0,0 @@
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

@@ -1,91 +0,0 @@
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

@@ -1,315 +0,0 @@
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,53 @@
import { readFileSync } from "node:fs";
import { describe, expect, test } from "vitest";
const source = (relativePath: string) =>
readFileSync(new URL(relativePath, import.meta.url), "utf-8");
describe("Slice 1 frontend regression contracts", () => {
test("keeps keyboard resizing on the visual viewport instead of page scroll", () => {
const page = source("../../components/slice-one/slice-one-page.tsx");
const root = source("../../root.tsx");
const styles = source("../../index.css");
expect(root).toContain("interactive-widget=resizes-content");
expect(page).toContain("style={viewportStyle}");
expect(page).toContain("fixed inset-x-0 top-0");
expect(page).not.toContain("slice-one-surface flex h-svh");
expect(styles).toContain("html.slice-one-viewport-lock body");
expect(styles).toContain("overflow: hidden");
});
test("keeps the responsive shell shrinkable with a pinned composer", () => {
const page = source("../../components/slice-one/slice-one-page.tsx");
expect(page).toContain('className="flex min-w-0 flex-1 flex-col"');
expect(page).toContain('<Conversation className="min-h-0 flex-1">');
expect(page).toContain('className="shrink-0 border-t');
expect(page).toContain(
'className="mx-auto flex max-w-2xl items-end gap-2"'
);
});
test("keeps attachment strips contained and markdown capabilities enabled", () => {
const attachment = source("../../components/chat/chat-attachments.tsx");
const attachmentPrimitive = source(
"../../../../../packages/ui/src/components/attachment.tsx"
);
const chatMessage = source("../../components/chat/chat-message.tsx");
const messageRenderer = source(
"../../../../../packages/ui/src/components/ai-elements/message.tsx"
);
expect(attachment).toContain('className="max-w-full');
expect(attachment).toContain("useAuthenticatedImageSource");
expect(attachment).toContain(`authorization: \`Bearer \${accessToken}\``);
expect(attachmentPrimitive).toContain("overflow-x-auto");
expect(chatMessage).toContain("<MessageAttachments parts={fileParts} />");
expect(chatMessage).toContain("<MessageResponse");
expect(messageRenderer).toContain("const streamdownPlugins = {");
expect(messageRenderer).toContain("mermaid");
expect(messageRenderer).toContain("plugins={streamdownPlugins}");
});
});

View File

@@ -0,0 +1,50 @@
import type { WorkNotice } from "@code/primitives/work";
import { describe, expect, test } from "vitest";
import type { ConversationMessage } from "@/lib/chat/types";
import { buildSliceOneTimeline, findSourceMessageTarget } from "./presentation";
const textMessage = (
id: string,
role: "assistant" | "user",
text: string
): ConversationMessage => ({
id,
parts: [{ state: "done", text, type: "text" }],
role,
});
const notice: WorkNotice = {
createdAt: 10,
eventId: "event-1",
sourceMessageIds: ["source-1"],
sourceTexts: ["Build the phone flow."],
workId: "work-1",
};
describe("Slice 1 presentation", () => {
test("places proposed Work after the assistant response to its source", () => {
const timeline = buildSliceOneTimeline(
[
textMessage("user-1", "user", "Build the phone flow."),
textMessage("assistant-1", "assistant", "Captured and proposed Work."),
],
[notice]
);
expect(
timeline.map((item) =>
item.kind === "message" ? item.message.id : item.notice.workId
)
).toEqual(["user-1", "assistant-1", "work-1"]);
});
test("finds the exact source message without trimming it", () => {
const messages = [textMessage("user-1", "user", " Exact source ")];
expect(findSourceMessageTarget(messages, " Exact source ")).toBe(
"user-1"
);
expect(findSourceMessageTarget(messages, "Exact source")).toBeUndefined();
});
});

View File

@@ -0,0 +1,80 @@
import type { WorkNotice } from "@code/primitives/work";
import {
getMessageText,
getRawMessageText,
getReasoningText,
} from "@/lib/chat/transforms";
import type { ConversationMessage } from "@/lib/chat/types";
export type SliceTimelineItem =
| {
readonly kind: "message";
readonly message: ConversationMessage;
}
| { readonly kind: "work"; readonly notice: WorkNotice };
export const isSliceOneVisibleMessage = (
message: ConversationMessage
): boolean =>
message.role === "user" ||
getMessageText(message).length > 0 ||
getReasoningText(message).length > 0;
const targetIndexForNotice = (
messages: readonly ConversationMessage[],
notice: WorkNotice
): number => {
const sourceIndexes = notice.sourceTexts.flatMap((sourceText) => {
const index = messages.findIndex(
(message) =>
message.role === "user" && getRawMessageText(message) === sourceText
);
return index === -1 ? [] : [index];
});
if (sourceIndexes.length === 0) {
return messages.length - 1;
}
const sourceIndex = Math.max(...sourceIndexes);
const responseOffset = messages
.slice(sourceIndex + 1)
.findIndex((message) => message.role === "assistant");
return responseOffset === -1 ? sourceIndex : sourceIndex + responseOffset + 1;
};
export const buildSliceOneTimeline = (
allMessages: readonly ConversationMessage[],
notices: readonly WorkNotice[]
): readonly SliceTimelineItem[] => {
const messages = allMessages.filter(isSliceOneVisibleMessage);
const noticesByMessageIndex = new Map<number, WorkNotice[]>();
for (const notice of notices) {
const targetIndex = targetIndexForNotice(messages, notice);
const atTarget = noticesByMessageIndex.get(targetIndex) ?? [];
atTarget.push(notice);
noticesByMessageIndex.set(targetIndex, atTarget);
}
const timeline: SliceTimelineItem[] = [];
for (const [index, message] of messages.entries()) {
timeline.push({ kind: "message", message });
for (const notice of noticesByMessageIndex.get(index) ?? []) {
timeline.push({ kind: "work", notice });
}
}
if (messages.length === 0) {
for (const notice of noticesByMessageIndex.get(-1) ?? []) {
timeline.push({ kind: "work", notice });
}
}
return timeline;
};
export const findSourceMessageTarget = (
messages: readonly ConversationMessage[],
sourceText: string
): string | undefined =>
messages.find(
(message) =>
message.role === "user" && getRawMessageText(message) === sourceText
)?.id;

View File

@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { getUserInitials } from "./get-user-initials";
describe("getUserInitials", () => {
it("uses the first two name parts", () => {
expect(getUserInitials("Sai Karthik")).toBe("SK");
});
it("uses a neutral fallback while auth loads", () => {
expect(getUserInitials()).toBe("U");
});
});

View File

@@ -0,0 +1,10 @@
export const getUserInitials = (name?: string): string => {
const parts = name?.trim().split(/\s+/u).filter(Boolean) ?? [];
if (parts.length === 0) {
return "U";
}
return parts
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? "")
.join("");
};

View File

@@ -1,11 +1,8 @@
import { useConvexAccessToken, WebAuthProvider } from "@code/auth/web";
import { env } from "@code/env/web";
import { WebAuthProvider } from "@code/auth/web";
import { Toaster } from "@code/ui/components/sonner";
import { FlueProvider } from "@flue/react";
import "./index.css";
import { createFlueClient } from "@flue/sdk";
import { useMemo } from "react";
import { LoaderCircle } from "lucide-react";
import {
isRouteErrorResponse,
Links,
@@ -13,11 +10,14 @@ import {
Outlet,
Scripts,
ScrollRestoration,
useNavigation,
} from "react-router";
import type { Route } from "./+types/root";
import { ThemeProvider } from "./components/theme-provider";
import { createFlueFetch } from "./lib/flue-transport";
import { loadAuthToken } from "./lib/auth.server";
export const loader = ({ request }: Route.LoaderArgs) => loadAuthToken(request);
export const links: Route.LinksFunction = () => [
{ href: "https://fonts.googleapis.com", rel: "preconnect" },
@@ -32,39 +32,14 @@ export const links: Route.LinksFunction = () => [
},
];
const flueBaseUrl = new URL(env.VITE_FLUE_URL);
const flueFetch = createFlueFetch({ baseUrl: flueBaseUrl });
const AuthenticatedFlueProvider = ({
children,
}: {
children: React.ReactNode;
}) => {
const resolveAccessToken = useConvexAccessToken();
const client = useMemo(
() =>
createFlueClient({
baseUrl: flueBaseUrl.toString(),
fetch: flueFetch,
headers: async () => {
const accessToken = await resolveAccessToken();
const headers: Record<string, string> = {};
if (accessToken) {
headers.authorization = `Bearer ${accessToken}`;
}
return headers;
},
}),
[resolveAccessToken]
);
return <FlueProvider client={client}>{children}</FlueProvider>;
};
export const Layout = ({ children }: { children: React.ReactNode }) => (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, interactive-widget=resizes-content"
/>
<Meta />
<Links />
</head>
@@ -76,20 +51,42 @@ export const Layout = ({ children }: { children: React.ReactNode }) => (
</html>
);
const App = () => (
<WebAuthProvider>
<AuthenticatedFlueProvider>
<ThemeProvider
attribute="class"
defaultTheme="dark"
forcedTheme="dark"
disableTransitionOnChange
storageKey="vite-ui-theme"
>
<Outlet />
<Toaster richColors />
</ThemeProvider>
</AuthenticatedFlueProvider>
const RouteProgress = () => {
const navigation = useNavigation();
if (!navigation.location) {
return null;
}
return (
<output
aria-label="Loading page"
className="fixed inset-x-0 top-0 z-50 h-0.5 overflow-hidden bg-[#d7d3c7]"
>
<div className="route-progress-bar h-full w-1/3 bg-[#7f9130]" />
</output>
);
};
export const HydrateFallback = () => (
<main className="grid min-h-svh place-items-center bg-[#f2f0e7] text-[#69675f]">
<div className="flex items-center gap-2 text-sm">
<LoaderCircle className="size-4 animate-spin" /> Loading Zopu
</div>
</main>
);
const App = ({ loaderData }: Route.ComponentProps) => (
<WebAuthProvider initialToken={loaderData.token}>
<ThemeProvider
attribute="class"
defaultTheme="dark"
forcedTheme="dark"
disableTransitionOnChange
storageKey="vite-ui-theme"
>
<RouteProgress />
<Outlet />
<Toaster richColors />
</ThemeProvider>
</WebAuthProvider>
);

View File

@@ -7,13 +7,7 @@ export default [
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"),
]),
index("./routes/app/mobile/page.tsx"),
route("dashboard", "./routes/app/dashboard/page.tsx"),
route("todos", "./routes/app/todos/page.tsx"),
]),
] satisfies RouteConfig;

View File

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

View File

@@ -1,29 +1,12 @@
import { useWebAuth } from "@code/auth/web";
import { Navigate, Outlet, useLocation } from "react-router";
import { Outlet } from "react-router";
import { requireAuthToken } from "@/lib/auth.server";
import type { Route } from "./+types/layout";
export const loader = ({ request }: Route.LoaderArgs) =>
requireAuthToken(request);
export default function AppLayout() {
const auth = useWebAuth();
const location = useLocation();
if (auth.status === "loading") {
return (
<div className="grid min-h-svh place-items-center text-sm text-muted-foreground">
Loading
</div>
);
}
if (auth.status === "inconsistent") {
return (
<div className="grid min-h-svh place-items-center text-sm text-muted-foreground">
Your session could not be verified
</div>
);
}
if (auth.status === "unauthenticated") {
return <Navigate replace state={{ from: location.pathname }} to="/login" />;
}
return <Outlet />;
}

View File

@@ -1,12 +0,0 @@
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

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

View File

@@ -1,15 +1,5 @@
import { MobileFlowPage } from "@/components/mobile-workspace/mobile-flow-page";
import { SliceOnePage } from "@/components/slice-one/slice-one-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" />;
export default function MobileLandingRedirect() {
return <SliceOnePage />;
}

View File

@@ -1,15 +0,0 @@
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

@@ -1,15 +0,0 @@
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

@@ -1,123 +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 } 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,21 +1,13 @@
import { useWebAuth } from "@code/auth/web";
import { Navigate, Outlet } from "react-router";
import { Outlet } from "react-router";
import { redirectAuthenticated } from "@/lib/auth.server";
import type { Route } from "./+types/layout";
export const loader = ({ request }: Route.LoaderArgs) =>
redirectAuthenticated(request);
export default function AuthLayout() {
const auth = useWebAuth();
if (auth.status === "loading") {
return (
<div className="grid min-h-svh place-items-center text-sm text-muted-foreground">
Loading
</div>
);
}
if (auth.status === "authenticated") {
return <Navigate replace to="/" />;
}
return (
<main className="grid min-h-svh place-items-center bg-muted/30 p-6 md:p-10">
<div className="w-full max-w-sm">

View File

@@ -1,5 +1,5 @@
import { LoginForm } from "@code/auth/web";
import { useNavigate } from "react-router";
import { useNavigate, useSearchParams } from "react-router";
import type { Route } from "./+types/page";
@@ -10,6 +10,14 @@ export const meta = (_args: Route.MetaArgs) => [
export default function LoginRoute() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const returnTo = searchParams.get("returnTo");
return <LoginForm onSuccess={() => navigate("/", { replace: true })} />;
return (
<LoginForm
onSuccess={() =>
navigate(returnTo?.startsWith("/") ? returnTo : "/", { replace: true })
}
/>
);
}

View File

@@ -11,4 +11,7 @@ export default defineConfig({
dedupe: ["convex", "react", "react-dom"],
tsconfigPaths: true,
},
server: {
allowedHosts: true,
},
});

732
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -8,11 +8,21 @@
# ---------------------------------------------------------------------------
# 1. Convex (control plane) — REQUIRED
# Convex is already deployed; provide the production deployment URL.
# Public URLs for the self-hosted Convex deployment.
# ---------------------------------------------------------------------------
CONVEX_URL=https://your-deployment.convex.cloud
CONVEX_SITE_URL=https://your-deployment.convex.site
SITE_URL=http://localhost:5173
SITE_URL=http://localhost:13100
VITE_AUTH_URL=http://localhost:13100
VITE_CONVEX_URL=https://your-deployment.convex.cloud
VITE_FLUE_URL=http://localhost:3583
VITE_ZOPU_SERVER_URL=http://localhost:3590
# Self-hosted Convex origins used by convex/docker-compose.yml
CONVEX_CLOUD_ORIGIN=https://your-deployment.convex.cloud
CONVEX_SITE_ORIGIN=https://your-deployment.convex.site
CONVEX_INSTANCE_NAME=zopu-production
CONVEX_INSTANCE_SECRET=
# ---------------------------------------------------------------------------
# 2. Self-hosted Git / Gitea — REQUIRED for issue lifecycle
@@ -46,16 +56,15 @@ AGENT_MODEL_MAX_TOKENS=131072
# ---------------------------------------------------------------------------
# 5. Zopu agent service (Flue)
# FLUE_DB_TOKEN authenticates the Flue persistence adapter.
# PORT is the Flue Node HTTP server listen port.
# zopu-agent.service pins the Flue Node server to port 3583.
# ---------------------------------------------------------------------------
FLUE_DB_TOKEN=replace-with-long-random-token
PORT=3583
# ---------------------------------------------------------------------------
# 6. Daemon identity
# ---------------------------------------------------------------------------
DAEMON_ID=zopu-dedicated
DAEMON_NAME=Zopu Dedicated Server
DAEMON_NAME=Zopu-Dedicated-Server
DAEMON_VERSION=0.0.0
DAEMON_HEARTBEAT_MS=15000
DAEMON_COMMAND_LEASE_MS=60000

View File

@@ -1,9 +1,6 @@
# 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.
Deployment artifacts for the complete Zopu stack on a single Debian dedicated server: the React Router web app, a persistent self-hosted Convex control plane, the daemon (Bun/Effect + AgentOS/RivetKit), the Flue agent service, Docker Engine, and supporting infrastructure.
## Architecture
@@ -13,23 +10,17 @@ service, Docker Engine for future Orb sandboxes, and supporting infrastructure.
│ ~12 CPU cores · ~40 GB RAM · single-node │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ zopu-daemon │ │ zopu-agent │ │ Docker │ │
│ │ zopu-web │ │ zopu-agent │ │ Docker │ │
│ │ (systemd) │ │ (systemd) │ │ Engine │ │
│ │ │ │ │ │ │ │
│ │ Effect daemon│ │ Flue Node │ │ Orb sandboxes│ │
│ │ + RivetKit │ │ server.mjs │ │ (future) │ │
│ │ in-process │ │ :3583 │ │ │ │
│ │ engine │ │ │ │ │ │
│ │ + native │ │ │ │ │ │
│ │ sidecar │ │ │ │ │ │
│ │ :6420 │ │ │ │ │ │
│ │ React Router │ │ Flue Node 22 │ │ Convex │ │
│ │ :13100 │ │ server.mjs │ │ backend │ │
│ │ │ │ :3583 │ │ :3210/:3211 │ │
│ └──────┬───────┘ └──────┬───────┘ └──────────────┘ │
│ │ │ │
└───────────────────
────────▼─────────
│ │ Convex (cloud) │ ← control plane (pre-deployed)│
│ └──────────────────┘ │
─────────────
│ zopu-daemon │ Bun + Effect + RivetKit :6420
──────────────
│ │
│ systemd timers: health-check (60s), docker-cleanup (daily) │
│ cron: disk-monitor (daily 06:00) │
@@ -40,31 +31,15 @@ service, Docker Engine for future Orb sandboxes, and supporting infrastructure.
### 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.
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`.
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.
**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.
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
@@ -76,6 +51,7 @@ deploy/zopu-runtime/
├── systemd/
│ ├── zopu-daemon.service # Daemon systemd unit
│ ├── zopu-agent.service # Agent systemd unit
│ ├── zopu-web.service # React Router web systemd unit
│ ├── zopu-health.service # Health check oneshot
│ ├── zopu-health.timer # Health check every 60s
│ ├── zopu-docker-cleanup.service
@@ -108,7 +84,7 @@ bash bootstrap.sh
nano /opt/zopu/.env
# 5. Start services:
systemctl start zopu-daemon
systemctl start zopu-web zopu-daemon
sleep 3
systemctl start zopu-agent
@@ -122,20 +98,20 @@ systemctl enable --now zopu-health.timer zopu-docker-cleanup.timer
## Start / stop / restart
```bash
# Start both services
systemctl start zopu-daemon zopu-agent
# Start all services
systemctl start zopu-web zopu-daemon zopu-agent
# Stop both services
systemctl stop zopu-agent zopu-daemon
# Stop all services
systemctl stop zopu-agent zopu-daemon zopu-web
# Restart (daemon first — it owns the RivetKit engine)
systemctl restart zopu-daemon && sleep 3 && systemctl restart zopu-agent
systemctl restart zopu-web zopu-daemon && sleep 3 && systemctl restart zopu-agent
# Enable on boot
systemctl enable zopu-daemon zopu-agent
systemctl enable zopu-web zopu-daemon zopu-agent
# Disable on boot
systemctl disable zopu-daemon zopu-agent
systemctl disable zopu-web zopu-daemon zopu-agent
```
## Log inspection
@@ -183,16 +159,14 @@ 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.
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
@@ -208,6 +182,7 @@ publicly addressable on the engine endpoint.
```
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
@@ -224,9 +199,7 @@ The update script:
/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.
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
@@ -242,11 +215,11 @@ 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
@@ -259,69 +232,71 @@ Named volumes and running containers are never removed.
/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%.
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:
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:
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.
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:
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` |
| Zopu agent | `FLUE_DB_TOKEN` (`zopu-agent.service` sets port 3583) |
| 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) |
## Public single-node routes
The checked-in Caddy example assumes Cloudflare Tunnel terminates TLS and sends the four Zopu hosts to Caddy on loopback:
- `zopu.sai-onchain.me` → React Router web app on `127.0.0.1:13100`
- `zopu-api.sai-onchain.me` → Convex API on `127.0.0.1:3210`
- `zopu-site.sai-onchain.me` → Convex HTTP actions on `127.0.0.1:3211`
- `zopu-agent.sai-onchain.me` → Flue on `127.0.0.1:3583`
Self-hosted Convex state lives in the `zopu-convex-data` Docker volume. Generate the CLI admin key after the backend is healthy:
```bash
docker compose \
--env-file /opt/zopu/.env \
-f /opt/zopu/deploy/zopu-runtime/convex/docker-compose.yml \
exec backend ./generate_admin_key.sh
```
## 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.
- **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.
- **Secrets in source**: `.env` is never committed; `.env.template` contains only placeholder values.
- **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:
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.

View File

@@ -14,9 +14,9 @@
# 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.
# Installs: Docker Engine, Node.js 22, Bun, clones the repo, runs bun install, builds the
# web app, daemon, and agent, creates a non-root service user, installs systemd
# units, and configures firewall/Tailscale defaults.
set -euo pipefail
@@ -105,18 +105,31 @@ fi
systemctl enable --now docker
# ---------------------------------------------------------------------------
# 3. Bun
# 3. Node.js 22 (required by the Flue Node target)
# ---------------------------------------------------------------------------
NODE_MAJOR=$(node --version 2>/dev/null | sed -n 's/^v\([0-9][0-9]*\).*/\1/p')
if [[ -z "$NODE_MAJOR" || "$NODE_MAJOR" -lt 22 ]]; then
log "Installing Node.js 22..."
curl -fsSL https://deb.nodesource.com/setup_22.x -o /tmp/nodesource_setup.sh
bash /tmp/nodesource_setup.sh
apt-get install -y nodejs
else
log "Node.js already installed: $(node --version)"
fi
# ---------------------------------------------------------------------------
# 4. 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
install -m 0755 /root/.bun/bin/bun /usr/local/bin/bun
else
log "Bun already installed: $(bun --version)"
fi
# ---------------------------------------------------------------------------
# 4. Service user
# 5. Service user
# ---------------------------------------------------------------------------
if ! id "$SERVICE_USER" &>/dev/null; then
log "Creating service user: $SERVICE_USER"
@@ -129,7 +142,7 @@ if ! id -nG "$SERVICE_USER" | grep -qw docker; then
fi
# ---------------------------------------------------------------------------
# 5. Clone or update repository
# 6. Clone or update repository
# ---------------------------------------------------------------------------
if [[ -d "$INSTALL_DIR/.git" ]]; then
log "Repository exists at $INSTALL_DIR, fetching latest..."
@@ -144,25 +157,28 @@ else
fi
# ---------------------------------------------------------------------------
# 5b. Hand ownership of the checkout to the service user
# 6b. 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
# 7. Install dependencies and build
# ---------------------------------------------------------------------------
log "Running bun install..."
run_as_service bun install
log "Building daemon binary..."
log "Building web app..."
run_as_service bun run --cwd apps/web build
log "Validating daemon production build..."
run_as_service bun run build:daemon
log "Building agent service..."
run_as_service bun run build:agents
# ---------------------------------------------------------------------------
# 7. Environment file
# 8. Environment file
# ---------------------------------------------------------------------------
ENV_FILE="$INSTALL_DIR/.env"
if [[ ! -f "$ENV_FILE" ]]; then
@@ -179,17 +195,17 @@ else
fi
# ---------------------------------------------------------------------------
# 8. Persistent log directory
# 9. 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)
# 10. Install systemd units (substitute placeholders)
# ---------------------------------------------------------------------------
log "Installing systemd units..."
for unit in zopu-daemon.service zopu-agent.service \
for unit in zopu-web.service 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"
@@ -205,7 +221,7 @@ done
systemctl daemon-reload
# ---------------------------------------------------------------------------
# 10. Firewall (deny-by-default, explicit allow for Tailscale)
# 11. Firewall (deny-by-default, explicit allow for Tailscale)
# ---------------------------------------------------------------------------
log "Configuring firewall..."
if ! ufw status 2>/dev/null | grep -q "Status: active"; then
@@ -227,7 +243,7 @@ else
fi
# ---------------------------------------------------------------------------
# 11. Tailscale (optional)
# 12. Tailscale (optional)
# ---------------------------------------------------------------------------
if [[ -n "${TAILSCALE_AUTHKEY:-}" ]]; then
log "Installing and configuring Tailscale..."
@@ -249,7 +265,7 @@ else
fi
# ---------------------------------------------------------------------------
# 12. Disk-space monitoring cron
# 13. Disk-space monitoring cron
# /etc/cron.d format REQUIRES a username field.
# ---------------------------------------------------------------------------
log "Installing disk-space monitor (daily at 06:00)..."
@@ -265,7 +281,7 @@ echo ""
echo "Next steps:"
echo " 1. Edit $ENV_FILE with real values"
echo " 2. Start services:"
echo " systemctl start zopu-daemon zopu-agent"
echo " systemctl start zopu-web zopu-daemon zopu-agent"
echo " 3. Enable health monitoring:"
echo " systemctl enable --now zopu-health.timer zopu-docker-cleanup.timer"
echo " 4. Verify health:"

View File

@@ -1,26 +1,23 @@
# Caddyfile — Minimal reverse proxy for the Zopu agent service.
# Caddyfile — Public reverse proxy for the Zopu web + API.
#
# 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
# }
# The web frontend (port 5173) is served at the root, Better Auth is proxied
# first-party under /api/auth, and Flue is mounted under /api/flue.
# Default: do not ship an active Caddyfile. The file is documentation.
zopu.cheaptricks.puter.wtf {
bind 135.181.82.179 2a01:4f9:c013:4a64::1
encode zstd gzip
handle /api/auth/* {
reverse_proxy https://befitting-dalmatian-161.convex.site {
header_up Host befitting-dalmatian-161.convex.site
header_up X-Forwarded-Host {host}
header_up X-Forwarded-Proto {scheme}
}
}
handle_path /api/flue/* {
reverse_proxy 127.0.0.1:3585
}
reverse_proxy 127.0.0.1:5173
}

View File

@@ -0,0 +1,25 @@
{
admin 127.0.0.1:2019
auto_https off
http_port 8080
}
http://zopu.sai-onchain.me {
bind 127.0.0.1
reverse_proxy 127.0.0.1:13100
}
http://zopu-api.sai-onchain.me {
bind 127.0.0.1
reverse_proxy 127.0.0.1:3210
}
http://zopu-site.sai-onchain.me {
bind 127.0.0.1
reverse_proxy 127.0.0.1:3211
}
http://zopu-agent.sai-onchain.me {
bind 127.0.0.1
reverse_proxy 127.0.0.1:3583
}

View File

@@ -0,0 +1,13 @@
tunnel: 9e54ac9c-c1a1-4583-be08-3b5f1acc7b65
credentials-file: /root/.cloudflared/9e54ac9c-c1a1-4583-be08-3b5f1acc7b65.json
ingress:
- hostname: zopu.sai-onchain.me
service: http://127.0.0.1:8080
- hostname: zopu-api.sai-onchain.me
service: http://127.0.0.1:8080
- hostname: zopu-site.sai-onchain.me
service: http://127.0.0.1:8080
- hostname: zopu-agent.sai-onchain.me
service: http://127.0.0.1:8080
- service: http_status:404

View File

@@ -0,0 +1,32 @@
services:
backend:
image: ghcr.io/get-convex/convex-backend:latest
container_name: zopu-convex
restart: unless-stopped
stop_grace_period: 10s
stop_signal: SIGINT
ports:
- "127.0.0.1:3210:3210"
- "127.0.0.1:3211:3211"
volumes:
- zopu-convex-data:/convex/data
environment:
CONVEX_CLOUD_ORIGIN: ${CONVEX_CLOUD_ORIGIN}
CONVEX_SITE_ORIGIN: ${CONVEX_SITE_ORIGIN}
DISABLE_BEACON: "true"
DISABLE_METRICS_ENDPOINT: "true"
DOCUMENT_RETENTION_DELAY: "172800"
INSTANCE_NAME: ${CONVEX_INSTANCE_NAME:-zopu-production}
INSTANCE_SECRET: ${CONVEX_INSTANCE_SECRET:-}
REDACT_LOGS_TO_CLIENT: "true"
RUST_LOG: ${CONVEX_RUST_LOG:-info}
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3210/version"]
interval: 5s
timeout: 3s
retries: 12
start_period: 10s
volumes:
zopu-convex-data:
name: zopu-convex-data

View File

@@ -72,7 +72,10 @@ fi
log "Running bun install..."
run_as_service bun install
log "Building daemon..."
log "Building web app..."
run_as_service bun run --cwd apps/web build
log "Validating daemon production build..."
run_as_service bun run build:daemon
log "Building agent service..."
@@ -82,6 +85,7 @@ log "Restarting services..."
systemctl restart zopu-daemon
sleep 3
systemctl restart zopu-agent
systemctl restart zopu-web
sleep 5
# Health check
@@ -94,6 +98,7 @@ if [[ -x "$HEALTH_SCRIPT" ]]; then
err "Health check failed after rollback!"
err " journalctl -u zopu-daemon -n 50"
err " journalctl -u zopu-agent -n 50"
err " journalctl -u zopu-web -n 50"
exit 1
fi
fi

View File

@@ -77,17 +77,21 @@ fi
log "Running bun install..."
run_as_service bun install
log "Building daemon binary..."
log "Building web app..."
run_as_service bun run --cwd apps/web build
log "Validating daemon production build..."
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
# Graceful restart: daemon first (owns RivetKit engine), then agent and web
log "Restarting services..."
systemctl restart zopu-daemon
sleep 3
systemctl restart zopu-agent
systemctl restart zopu-web
sleep 5
# Health check
@@ -100,6 +104,7 @@ if [[ -x "$HEALTH_SCRIPT" ]]; then
err "Health check failed after update!"
err " journalctl -u zopu-daemon -n 50"
err " journalctl -u zopu-agent -n 50"
err " journalctl -u zopu-web -n 50"
err "To rollback: ${INSTALL_DIR}/deploy/zopu-runtime/scripts/rollback.sh"
exit 1
fi

View File

@@ -10,10 +10,11 @@ Group=__SERVICE_USER__
WorkingDirectory=__INSTALL_DIR__/packages/agents
EnvironmentFile=__INSTALL_DIR__/.env
Environment=PORT=3583
# Flue build output: packages/agents/dist/server.mjs
# Listens on PORT (default 3583 per .env.template).
ExecStart=/usr/local/bin/bun dist/server.mjs
# Flue's Node target requires Node built-ins such as node:sqlite.
# Listens on the service-pinned port 3583.
ExecStart=/usr/bin/node dist/server.mjs
Restart=always
RestartSec=10

View File

@@ -10,11 +10,13 @@ Group=__SERVICE_USER__
WorkingDirectory=__INSTALL_DIR__
EnvironmentFile=__INSTALL_DIR__/.env
Environment=HOME=/var/lib/zopu
StateDirectory=zopu
# 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
# Run the Bun source entrypoint so AgentOS software assets retain their real
# node_modules paths; Bun compiled executables do not embed .aospkg files.
ExecStart=/usr/local/bin/bun --env-file=__INSTALL_DIR__/.env apps/daemon/src/index.ts
Restart=always
RestartSec=10

View File

@@ -0,0 +1,26 @@
[Unit]
Description=Zopu Web
After=network-online.target docker.service
Wants=network-online.target
[Service]
Type=simple
User=__SERVICE_USER__
Group=__SERVICE_USER__
WorkingDirectory=__INSTALL_DIR__/apps/web
EnvironmentFile=__INSTALL_DIR__/.env
Environment=HOST=127.0.0.1
Environment=PORT=13100
ExecStart=/usr/local/bin/bun run start
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=zopu-web
NoNewPrivileges=true
ProtectSystem=full
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target

View File

@@ -1,420 +0,0 @@
# Signals Backend Implementation Plan
> **For OMP:** Create one isolated task worktree per implementation task. Run dependent tasks serially, merge each completed task branch into `feat/signals`, resolve integration conflicts on that branch, and verify the merged result before dispatching the next dependent task.
**Goal:** Build the first product-grade Signal slice: the authenticated global agent turns one or more exact user messages into one durable, Effect-modeled Signal containing immutable raw evidence and a separate processed problem statement.
**Architecture:** Keep the Signal domain pure in `@code/primitives`; keep authorization, idempotency, storage, and source resolution in Convex; keep conversation transport and the agent decision in Flue. The global Zopu agent becomes organization-scoped and receives two narrow tools: list eligible user-message sources, then create a Signal from selected message IDs and a processed problem statement. AgentOS remains downstream execution infrastructure and is not involved in Signal capture.
**Tech Stack:** Bun workspaces/catalogs, TypeScript, Effect v4, Convex, Flue, Hono, Better Auth, Vitest, convex-test.
---
## Current findings and decisions
1. `PRODUCT.md` separates **Signal**, **Candidate Work**, and **Work Unit**. This slice must not create candidate, plan, priority, run, or execution fields.
2. `TECH.md` requires explicit organization scope, exact user-message persistence, raw-evidence/agent-interpretation separation, and source links.
3. The current global chat is `zopu/main`, so every user shares one Flue instance. Flue routes are pass-through and the browser sends no identity. This must be corrected before Signals can be safe.
4. Canonical Flue `user_message` records already contain exact text plus `messageId`, `conversationId`, timestamp, and `submissionId`, but Convex stores them inside opaque JSON batches. Product commands should not parse those batches ad hoc.
5. Existing `projectSignals` is an execution event feed, not the product Signal entity. Rename it to `projectEvents` before introducing `signals`.
6. The Signal primitive will use Effect schemas and a pure Effect constructor. It will not contain a repository service or depend on Convex, Flue, Hono, AgentOS, or generated IDs.
7. A Signal is created only when the global agent decides the conversation contains a coherent problem statement. Individual user messages remain conversation evidence until then.
8. One Signal may compose multiple ordered user messages. Raw text remains per-message and exact; `problemStatement` is the agent-produced interpretation.
9. Signal creation is idempotent by organization + conversation + ordered source-message IDs so Flue retries cannot create duplicates.
10. Use a minimal organization foundation because `ownerId` is a user identifier, not the hard tenancy boundary required by the product. Do not build full organization administration in this slice.
## Domain model
```ts
interface Signal {
readonly id: SignalId;
readonly scope:
| { readonly _tag: "Organization"; readonly organizationId: OrganizationId }
| {
readonly _tag: "Project";
readonly organizationId: OrganizationId;
readonly projectId: ProjectId;
};
readonly sourceMessages: readonly [SignalSourceMessage, ...SignalSourceMessage[]];
readonly problemStatement: ProblemStatement;
readonly processedBy: {
readonly agentName: string;
readonly agentInstanceId: string;
};
readonly createdAt: TimestampMs;
}
interface SignalSourceMessage {
readonly conversationId: ConversationId;
readonly messageId: ConversationMessageId;
readonly rawText: string; // exact; never trim or rewrite
readonly createdAt: TimestampMs;
}
interface ProblemStatement {
readonly title: string;
readonly summary: string;
readonly desiredOutcome: string;
readonly constraints: readonly string[];
}
```
**Invariants:**
- Every Signal has exactly one organization scope and zero or one project scope.
- `sourceMessages` is non-empty, ordered, and unique by `(conversationId, messageId)`.
- Every source is a user message from the same organization-scoped global conversation.
- `rawText` is copied server-side from the stored source message; the agent never supplies it.
- `problemStatement.title`, `summary`, and `desiredOutcome` are non-empty; constraints are structured as individual non-empty items and remain distinct from raw evidence.
- Signal does not contain candidate/work-unit/run fields.
- IDs are branded non-empty strings in primitives; adapters translate Convex/Flue IDs.
- Timestamps are non-negative integer epoch milliseconds.
## Persistence model
Add these application tables in `packages/backend/convex/schema.ts`:
- `organizations`: minimal durable tenant record with `name`, `createdBy`, `createdAt`.
- `organizationMembers`: `organizationId`, `userId` (`identity.tokenIdentifier`), `role`, `createdAt`; indexes by user and organization/user.
- `conversationMessages`: normalized user-message evidence with `organizationId`, `conversationId`, `messageId`, `submissionId?`, `clientRequestId`, `role: "user"`, exact `rawText`, `status: "admitting" | "admitted" | "failed"`, and `createdAt`; unique lookup indexes by organization/message and organization/client request.
- `signals`: `organizationId`, optional `projectId`, `sourceKey`, structured `problemStatement` (`title`, `summary`, `desiredOutcome`, `constraints`), `processedByAgentName`, `processedByAgentInstanceId`, `createdAt`; indexes by organization/createdAt, project/createdAt, and organization/sourceKey.
- `signalSources`: `signalId`, `organizationId`, `conversationId`, `messageId`, `ordinal`, `rawTextSnapshot`, `sourceCreatedAt`, optional `submissionId`; indexes by signal/ordinal and organization/message.
- `projectEvents`: the renamed current execution event feed.
Do not add lifecycle/candidate status to `signals` yet. A later Candidate component will own promotion and relationships.
---
### Task 1: Normalize the workspace dependency catalog
**Objective:** Centralize every dependency repeated across workspace manifests without forcing intentionally different platform versions into one range.
**Files:**
- Modify: `package.json`
- Modify: `apps/daemon/package.json`
- Modify: other `apps/*/package.json` and `packages/*/package.json` entries identified by the catalog audit
- Modify: `bun.lock` through `bun install`
**Steps:**
1. Add default catalog entries for shared versions that should converge: `effect`, AgentOS packages, `typescript`, `@types/bun`, `@types/react`, `heroui-native`, `vite`, `vitest`, and `convex-test`.
2. Add named catalogs for intentional platform splits, especially Node type majors and React/React Native compatibility ranges. Use `catalog:<name>` rather than silently upgrading Expo/native consumers.
3. Replace direct Effect and AgentOS pins in `apps/daemon/package.json` with `catalog:`.
4. Replace repeated direct versions in workspace manifests with their default or named catalog reference. Leave peer dependency compatibility ranges as peer ranges when they intentionally describe supported versions rather than installation versions.
5. Run `bun install` and verify the lockfile resolves the same intended platform families.
**Acceptance:**
- No repeated installed dependency version remains scattered across manifests without an explicit reason.
- Effect v4 and AgentOS resolve from the root catalog everywhere.
- Native React remains on its Expo-compatible exact family; web/TUI ranges are not accidentally collapsed.
**Verification:**
```bash
bun install
bun run check-types
```
---
### Task 2: Add the pure Effect Signal primitive
**Objective:** Establish the portable Signal vocabulary and validation rules before storage or agents depend on it.
**Files:**
- Create: `packages/primitives/src/signal.ts`
- Create: `packages/primitives/src/signal.test.ts`
- Modify: `packages/primitives/src/index.ts`
- Modify: `packages/primitives/package.json`
**Implementation:**
- Define branded schemas for `SignalId`, `OrganizationId`, `ProjectId`, `ConversationId`, `ConversationMessageId`, and `TimestampMs`.
- Define `SignalScope`, `SignalSourceMessage`, `ProblemStatement`, `ProcessedByAgent`, `SignalInput`, and `Signal` with `Schema.Struct` / discriminated unions.
- Add `SignalValidationError` with `Schema.TaggedErrorClass`.
- Add `composeSignal = Effect.fn("Signal.compose")` that decodes input, rejects duplicate/out-of-order sources, preserves raw whitespace exactly, and returns a validated Signal.
- Export the module from the root and an explicit `./signal` package subpath.
- Add a package `test` script using cataloged Vitest.
**Tests:**
- Accept organization and project scopes.
- Reject empty IDs, empty source list, empty problem-statement title/summary/desired outcome, empty constraint items, negative/fractional timestamps, duplicate source IDs, mixed conversations, and out-of-order timestamps.
- Prove raw leading/trailing whitespace and source ordering survive unchanged.
- Prove candidate/work-unit/run fields are absent from the decoded result.
**Verification:**
```bash
bun run --cwd packages/primitives test
bun run --cwd packages/primitives check-types
bunx ultracite check packages/primitives/src/signal.ts packages/primitives/src/signal.test.ts packages/primitives/src/index.ts
```
---
### Task 3: Establish minimal organization tenancy
**Objective:** Give every durable Signal and global conversation a real tenant boundary instead of reusing a user ID or `main`.
**Files:**
- Modify: `packages/backend/convex/schema.ts`
- Modify: `packages/backend/convex/authz.ts`
- Create: `packages/backend/convex/organizations.ts`
- Create: `packages/backend/convex/organizations.test.ts`
**Implementation:**
- Add `organizations` and `organizationMembers` tables.
- Add `ensurePersonalOrganization`, idempotently creating one personal organization and owner membership for the authenticated user.
- Add `getCurrent` and `requireOrganizationMember` helpers.
- Keep full organization invitations, multiple active organizations, roles beyond owner/member, and organization settings out of scope.
- Ensure every query/mutation denies access without both authenticated identity and membership.
**Tests:**
- First ensure creates one organization and owner membership.
- Repeated ensure returns the same organization.
- A second identity cannot read or mutate the first identity's organization.
**Verification:**
```bash
bun run --cwd packages/backend test -- organizations
bun run --cwd packages/backend check-types
```
---
### Task 4: Rename execution signals to project events
**Objective:** Remove the naming collision before creating the product `signals` table.
**Files:**
- Modify: `packages/backend/convex/schema.ts`
- Modify: `packages/backend/convex/projects.ts`
- Modify: `packages/backend/convex/projectIssues.ts`
- Modify: `packages/backend/convex/agentWorkspace.ts`
- Modify: `packages/backend/convex/artifactModel.ts`
- Modify: `packages/backend/convex/README.md`
- Optional migration file only if existing rows are present: `packages/backend/convex/migrations.ts`
**Steps:**
1. Inspect the active Convex deployment count for `projectSignals` before changing schema.
2. If empty, cleanly rename the table and call sites to `projectEvents`.
3. If non-empty, add a one-off internal migration that copies rows with preserved relations/timestamps, verify source/destination counts, then remove the old schema/table references. Do not leave a compatibility alias.
4. Update `signals.md` seed copy to say “project events,” not product Signals.
**Acceptance:**
- `projectSignals` no longer exists in source or schema.
- Existing project event behavior remains unchanged.
- Product `signals` can be introduced without semantic ambiguity.
**Verification:**
```bash
bun run --cwd packages/backend check-types
```
Exercise project connect, issue create/start, artifact update, and agent status once and confirm `projectEvents` receives the same events.
---
### Task 5: Normalize authenticated global user messages
**Objective:** Persist exact tenant-scoped user evidence at the conversation ingress before the agent can create Signals.
**Files:**
- Modify: `packages/backend/convex/schema.ts`
- Create: `packages/backend/convex/conversationMessages.ts`
- Create: `packages/backend/convex/conversationMessages.test.ts`
- Modify: `packages/agents/src/agents/zopu.ts`
- Create: `packages/agents/src/auth.ts`
- Modify: `packages/agents/package.json`
- Modify: `apps/web/src/root.tsx`
- Modify: `apps/web/src/lib/chat/constants.ts`
- Modify: `apps/web/src/hooks/chat/use-chat-agent.ts`
- Modify: `packages/auth/src/web/index.ts` only if the access-token helper is not already exported
**Ingress sequence:**
1. Web obtains the Better Auth/Convex access token with the installed `fetchAccessToken` API.
2. The custom Flue fetch adds `Authorization: Bearer <token>` and a stable `x-zopu-request-id` to agent POSTs.
3. Zopu route middleware requires the bearer token, sets it on a short-lived `ConvexHttpClient`, resolves current organization membership, and rejects an agent instance ID that is not that organization ID.
4. Before `next()`, middleware clones and validates the direct payload and calls `conversationMessages.beginUserMessage` with exact text and request ID.
5. After Flue returns its admission receipt, middleware patches `submissionId` and status `admitted`; on failure it marks the message `failed` and rethrows/returns the original failure.
6. The web global agent ID becomes the current organization ID rather than `main`.
**Important constraints:**
- Do not trust organization ID, raw text, timestamp, or source role from an agent tool.
- Do not parse `flueConversationBatches.recordsJson` for product commands.
- Do not persist auth tokens.
- Keep failed admissions as evidence with explicit status; do not silently delete them.
- Ensure GET/SSE observation routes use the same organization authorization.
**Tests:**
- Unauthorized Flue route is rejected.
- Organization A cannot use or observe organization B's agent ID.
- Duplicate request ID is idempotent.
- Exact whitespace/casing is stored.
- Success records submission ID; failure records failed status.
**Verification:**
- Send one message containing leading/trailing whitespace through the running web app.
- Confirm the agent replies.
- Query Convex and confirm the normalized user message has exact raw text, organization scope, conversation/message identity, request ID, and admitted submission ID.
- Sign in as another identity and confirm it cannot observe the first conversation.
---
### Task 6: Persist Signals atomically in Convex
**Objective:** Resolve selected message IDs server-side, run the Effect constructor, and atomically insert one idempotent Signal plus ordered source snapshots.
**Files:**
- Modify: `packages/backend/convex/schema.ts`
- Create: `packages/backend/convex/signals.ts`
- Create: `packages/backend/convex/signals.test.ts`
- Modify: `packages/backend/package.json`
**Interface:**
```ts
createFromMessages({
organizationId,
projectId?,
conversationId,
messageIds,
problemStatement: { title, summary, desiredOutcome, constraints },
processedBy: { agentName, agentInstanceId },
}) -> signalId
```
**Implementation:**
- Expose an agent-token-gated mutation for Zopu and authenticated read queries for product clients.
- Resolve every message from `conversationMessages` under the same organization and conversation.
- Require role `user`, status `admitted`, non-empty ordered selection, and optional project ownership by the same organization.
- Copy raw text/timestamps/submission IDs into `signalSources`; never accept snapshots from the agent.
- Build `sourceKey` deterministically from organization, conversation, and ordered message IDs.
- Run `composeSignal` from `@code/primitives/signal` before writes.
- In one Convex mutation, return an existing row for the same source key or insert `signals` and all `signalSources` in ordinal order.
- Add bounded `list` and `get` queries that enforce membership and return the composed source records.
**Tests:**
- Creates one global Signal from one user message.
- Composes several ordered user messages into one Signal.
- Preserves exact raw snapshots while storing a separate structured problem statement.
- Rejects cross-organization, cross-conversation, assistant, failed, missing, duplicated, and unauthorized project messages.
- Repeating the same source set returns the existing Signal without duplicate source rows.
**Verification:**
```bash
bun run --cwd packages/backend test -- signals
bun run --cwd packages/backend check-types
bunx ultracite check packages/backend/convex/schema.ts packages/backend/convex/signals.ts packages/backend/convex/signals.test.ts
```
---
### Task 7: Let Zopu decide and create Signals
**Objective:** Give the global agent a narrow, validated mechanism to inspect eligible user evidence and create a Signal when the problem statement is coherent.
**Files:**
- Create: `packages/agents/src/tools/signals.ts`
- Modify: `packages/agents/src/agents/zopu.ts`
- Modify: `packages/agents/package.json`
- Create: `packages/agents/scripts/signal-smoke.ts`
**Tools:**
1. `list_signal_source_messages`
- No free tenant input; organization comes from the authenticated agent instance ID.
- Returns a bounded list of admitted user messages not already used by a Signal, including message ID, exact text, and timestamp.
2. `create_signal`
- Input: ordered message IDs, structured problem statement (`title`, `summary`, `desiredOutcome`, `constraints`), optional project ID.
- Calls the Convex `createFromMessages` mutation with `agentName: "zopu"` and the current organization-scoped instance ID.
- Returns only the Signal ID and source count.
**Agent instruction:**
- Continue normal planning conversation until the user's instructions form a coherent problem statement.
- Do not create a Signal for greetings, exploration without a concrete problem, or every individual message.
- When ready, call `list_signal_source_messages`, select only the messages composing the problem, then call `create_signal` once.
- Tell the user the captured problem-statement title, summary, desired outcome, and constraints.
- Do not create Candidate Work or start AgentOS in this slice.
**Retry safety:**
- Flue may replay tool calls after interruption. Convex source-key idempotency is authoritative.
- The tool must surface domain errors; it must not swallow or convert them into fake success.
**Verification:**
Run the focused smoke script and a live browser scenario:
1. Send three messages: an initial problem, a clarification, and an explicit final constraint.
2. Observe Zopu create exactly one Signal after the statement is coherent.
3. Confirm the Signal contains all selected raw messages in order and one structured problem statement with title, summary, desired outcome, and constraints.
4. Restart the agent service/retry the final turn and confirm no duplicate Signal is inserted.
5. Confirm no AgentOS actor, candidate, work unit, issue, or run is created.
```bash
bun run --cwd packages/agents signal:smoke
bun run --cwd packages/agents check-types
bun run --cwd apps/web check-types
bunx ultracite check packages/agents/src/tools/signals.ts packages/agents/src/agents/zopu.ts packages/agents/scripts/signal-smoke.ts apps/web/src/root.tsx apps/web/src/lib/chat/constants.ts apps/web/src/hooks/chat/use-chat-agent.ts
```
---
### Task 8: Final verification and cleanup
**Objective:** Prove the complete Signal slice works and remove temporary migration/scaffolding.
**Steps:**
1. Run all focused tests and smoke scenarios from Tasks 27.
2. Run Ultracite fix if necessary, then check every changed source/script/agent file.
3. Run affected package type checks: primitives, backend, agents, auth if touched, and web.
4. Run root `bun run check` and report unrelated pre-existing failures separately.
5. Remove a one-off `projectSignals` migration only after row-count verification and successful cutover, unless the repository convention keeps completed migrations.
6. Update existing backend/agent documentation only where names or commands became stale; do not add speculative docs.
**Final acceptance criteria:**
- Global conversations are organization-scoped and authenticated; `zopu/main` is gone.
- Exact user text is stored before/with Flue admission and cannot cross organizations.
- Zopu can intentionally compose one or more user messages into exactly one durable Signal.
- Signal raw evidence and the structured processed problem statement are separate and queryable.
- Source provenance is immutable and ordered.
- Retry produces no duplicate Signal.
- Existing project execution events still work under `projectEvents`.
- Signal creation does not create Candidate Work, Work Unit, run, or AgentOS resources.
- Effect v4 and AgentOS dependency versions come from workspace catalogs.
- Focused tests, package type checks, runtime smoke, and root check are reported with evidence.
## Risks and deliberate tradeoffs
- **Minimal organization bootstrap:** This adds only the tenancy needed for Signals. Multi-org switching, invites, billing, and policy are separate components.
- **Normalized user evidence plus Flue transcript:** There are temporarily two durable representations. Flue remains the canonical conversational transcript; `conversationMessages` is the application-domain evidence index required for authorization and provenance. Idempotent request IDs and submission links keep them correlated.
- **No automatic Signal for every message:** This follows the product thesis and avoids task noise. The agent explicitly decides when the instructions are coherent.
- **No separate Flow agent yet:** Zopu performs the first extraction using narrow tools. A future Flow Agent can adopt the same Convex command without changing the Signal primitive.
- **No AgentOS here:** AgentOS begins only after future Candidate/Work Unit readiness and scheduling.
- **No Signal status model yet:** Candidate attachment, knowledge capture, dismissal, and work promotion belong to later components. Adding them now would pre-design unimplemented workflows.

View File

@@ -1,367 +1,469 @@
# Zopu Work OS — Product Design
> **Status:** canonical working interaction/interface specification · **Audience:** product design, frontend/product engineering, agent experience · **Scope:** information architecture, screens, cards, interaction patterns, states, actions, principles · **Excludes:** backend and low-level infrastructure
> **Related:** `product.md` defines the domain; `dev-loop.md` defines phases/gates; `slices.md` defines UI rollout.
## 1. Objective and governing model
> **Status:** Working interaction specification
> **Audience:** frontend, product, agents generating UI
> **Constraint:** simple interface; complexity is revealed only when useful.
Design one calm, persistent workspace for supervising many active work units across organizations, products, projects, teams, and contexts—without making users manage chat threads, model settings, or agent sessions. It must make an autonomous system understandable and controllable by showing the minimum needed to answer: **what matters; what is happening; what is blocked; what needs human action; what happens next; why the system believes it.**
## 1. Experience principle
> **The conversation is the command surface; the work unit is the persistent product object.**
## 2. Experience principles
| Principle | Requirement |
| --- | --- |
| One continuous workspace | Do not organize around chat history, sessions, or thread lists. Keep one workspace and a composer that works globally or within a selected unit. |
| Work first, machinery second | Show outcomes, blockers, decisions, artifacts, results. Hide agent names, model choices, runtime and infrastructure unless they explain progress/debug failure. |
| Progressive disclosure | Collapsed cards scan quickly; expanded cards become working surfaces; evidence/raw events/technical detail are on demand. |
| Calm density | Support dense operational information without an enterprise-dashboard feel; use hierarchy, spacing, typography, grouping—not excessive borders, color, or permanent panels. |
| Human attention is scarce | Centralize approvals, blockers, unanswered questions, and other requests; users must not hunt through cards. |
| Visible context | Always show organization; project/portfolio scope; global vs work-specific composer; selected unit; whether an action affects external/production systems. |
| Nearby provenance | Important claims, decisions, and recommendations expose source indicators; evidence is one interaction away, not buried in an audit app. |
## 3. Product language
| Term | Meaning |
| --- | --- |
| **Organization** | Company, client, or hard context boundary. |
| **Project** | Product, application, repository-backed system, or durable operating context. |
| **Goal** | Measurable business/product objective. |
| **Signal** | New information that may matter. |
| **Candidate** | Proposed work unit awaiting confirmation. |
| **Work unit** | Persistent outcome the system is responsible for progressing. |
| **Run** | One execution attempt inside a work unit. |
| **Artifact** | Produced/collected output: PR, preview, report, design, document, etc. |
| **Attention item** | Decision, approval, question, blocker, review, or risk requiring human action. |
Do not expose **agent thread, model context window, harness session, VM, sandbox lifecycle, actor wake state** except in technical/debug views.
## 4. Global information architecture
Desktop uses three regions plus a persistent composer:
The user should experience:
```text
┌─────────────────────────────────────────────────────────────────────┐
│ Header │
├───────────────┬───────────────────────────────────┬─────────────────┤
│ Work index │ Primary workspace │ Context panel │
│ │ cards / expanded work / chat │ evidence, │
│ │ │ artifacts, │
│ │ │ preview, tools │
├───────────────┴───────────────────────────────────┴─────────────────┤
│ Persistent composer │
└─────────────────────────────────────────────────────────────────────┘
Talk naturally
→ see durable Work emerge
→ approve what matters
→ watch meaningful progress
→ answer precise questions
→ inspect evidence
→ receive a reviewable result
```
The layout collapses gracefully on smaller screens.
The UI MUST optimize **confidence per minute of human attention**, not display every agent action.
### Header
Global orientation/light controls: current organization switcher; current project or portfolio scope; search/command trigger; attention count; activity indicator; user menu. Do **not** fill it with model controls, agent selectors, or environment settings.
### Left work index
This is operational navigation, not chat history: **Now, Attention, Discovery, Active work, Waiting, Review, Monitoring, Recently completed, Goals, Projects, Knowledge, Activity**. Show compact counts/status indicators. A project section may show name, health, active-unit count, attention count, current goal/release.
### Primary workspace
Show portfolio/project overview, project overview, work-card feed, expanded work unit, global conversation moments, candidate review, attention workflow, results/learning.
### Context panel
Change with the selected object; show only when relevant (otherwise hide/collapse). Possible content: evidence, sources, files, PR, code changes, preview, test results, research, agent activity, decisions, dependencies, analytics, customer feedback, version history.
### Persistent composer
Available everywhere; scope is visibly lightweight:
## 2. Information architecture
```text
Global workspace
Working in: Fix Safari OAuth callback failures
Project
├── Conversation
├── Work cards
├── Attention
├── Artifacts/search
└── Project knowledge/settings
```
Accept text, files, images, voice, links, source/work-unit/project mentions, and natural-language commands. Users never select a model, agent, or thinking mode.
v0 may render Conversation and Work in one workspace with a side/detail panel.
## 5. Primary screens
## 3. Project conversation
### 5.1 Command Center (default)
One continuous project conversation, not session/thread-centric navigation.
An operational briefing—not a wall of metrics—answering portfolio-level orientation, autonomous progress, meaningful changes, and required actions:
Message types:
- **Today:** highest-value active work; upcoming deadlines; work likely to block; recently unblocked work.
- **Needs attention:** approvals, decisions, questions, reviews, risks.
- **Autonomous progress:** work advanced since last visit; new artifacts; completed verification; follow-up candidates.
- **Project health:** current goal, active count, blocker count, recent result, risk/health signal per relevant project.
- **Recent outcomes:** completed work, measured effects, captured learnings.
- user;
- Zopu response;
- Signal notice;
- Work created/updated;
- human decision;
- important artifact/result.
### 5.2 Discovery Inbox
Conversation actions:
Separate raw information from proposed interpretation. Views: new signals; candidate work; possible duplicates; suggested links to existing work; low-confidence observations. Actions: **Accept, Edit, Attach to existing work, Merge, Defer, Dismiss, Save as knowledge, Ask why**. Batch obvious duplicates/low-value signals when useful; important candidate creation remains inspectable.
- send message;
- attach context;
- reference Work;
- create/modify priority;
- answer a question;
- ask for status/explanation.
### 5.3 Project Overview
When a message creates Work, render a compact inline link/card. Preserve a link from Work back to exact source message.
One product/application context: project identity/health; current goals; active work; waiting/blocked work; recent releases/outcomes; connected sources; important context documents; team/responsibility; autonomous activity. It answers: what is the project trying to achieve, what is moving/blocked, what changed, and what requires the user?
## 4. Work card
### 5.4 Work Unit Workspace
### Collapsed
Expanded selected card; structure:
| Section | Contents |
| --- | --- |
| Work header | Outcome title; organization/project; type; phase; status; priority; owner/responsible party; last meaningful change; autonomy state. |
| Objective | Desired outcome; why now; linked goal; success criteria; scope/non-goals. |
| Current understanding | Concise generated summary split into **Known, Inferred, Uncertain, Conflicting**. |
| Plan | Current approach; steps; completed actions; next action; alternative path when relevant. |
| Blockers/dependencies | For each: what is blocked, why, who/what resolves it, impact of waiting, requested human action. |
| Activity | Compressed chronology of user instructions, system/agent progress, state changes, decisions, artifacts, external actions, verification. |
| Artifacts | Previews/links for PRs, commits, documents, screenshots, previews, test reports, designs, research. |
| Results | After publication/completion: expected and actual result, measurement window, evidence, unexpected effects, follow-up recommendation. |
| Contextual composer | Fixed at bottom and clearly scoped to this unit. |
### 5.5 Attention Queue
Central human-in-the-loop surface; categories: **Decisions, Approvals, Questions, Blockers, Reviews, Access requests, Risk alerts**. Every item answers what/why is needed; recommendation and supporting evidence; what follows approval; consequence of no action; urgency; reversibility. Enable quick review without opening the full workspace unless deeper context is needed.
### 5.6 Activity Stream
Show meaningful autonomous events: work discovered/updated; run started; plan prepared; code changed; verification completed; artifact created; blocker detected; approval requested; work completed; result measured. Do not fill it with every tool call; events can expand for technical detail/provenance.
### 5.7 Results and Learning
A feedback system, not an archive. Views: recently completed; monitoring; successful outcomes; unexpected regressions; follow-up opportunities; reusable learnings. Each result links to original objective and evidence.
### 5.8 Knowledge
Expose durable organization/project understanding across **Product, Business, Customers, Design, Technical context, Decisions, Known constraints, Reusable learnings**. Mark each item **Canonical, Inferred, Pending review, Outdated, or Conflicting**.
## 6. Card system
A card is a visual projection of a durable product object. The same work unit may appear as a compact/expanded card, attention item, embedded conversation object, timeline event, or result card; never create separate task systems per surface.
| Type | Purpose and collapsed anatomy | Actions |
| --- | --- | --- |
| **Signal** | Newly detected information: source icon/name; observation; project/organization; time; suggested relationship; confidence. | Create work; attach to existing work; save as knowledge; dismiss. |
| **Candidate** | Proposed durable unit: proposed outcome; why it matters; project; linked goal; supporting-source count; confidence; duplicate warning when relevant. | Accept; edit; merge; defer; dismiss; ask why. |
| **Active work** | Operational state. Must answer what it is, why it matters, current state, blocker, and next step. Anatomy below. | Use status-appropriate actions. |
| **Waiting/blocked** | Visually distinct without alarmism: exact blocker; responsible party; time waiting; impact; next resolution action. | Review/resolve requested action. |
| **Approval** | Safe bounded decision: exact action; why; recommendation; alternatives; risk; impact; evidence; reversibility. | Approve; modify; reject; ask for more evidence; delegate. |
| **Review** | Evaluate artifact/completed run: what changed; acceptance criteria; verification summary; open concerns; preview/diff access; recommended decision. | Accept; request changes; continue discussion; compare alternative; stop work. |
| **Result** | Connect completed work to outcome: intended outcome; shipped work; expected impact; actual result; measurement period; unexpected effects; follow-up. | Accept result; continue monitoring; create follow-up; reopen; roll back where applicable. |
| **Parent initiative** | Larger outcome composed of child units: initiative outcome; child progress; critical path; blockers; next milestone; active/completed child counts. Do not duplicate every child detail. | Use child/initiative actions as applicable. |
### Card examples/anatomy
Display only decision-relevant information:
```text
Support · Acme Corp · 18m ago
Three users could not finish OAuth setup in Safari.
Possible match: Fix Safari callback failures · Confidence: High
[Attach] [Create separate work] [Dismiss]
Title
phase/status
latest meaningful update
blocker/question
next action
slice progress
artifact count
```
Example:
```text
[Type] [Organization / Project] [Status]
Fix Safari OAuth callback failures
Supports: Improve onboarding reliability · 42% complete
Blocked: Need production Safari logs
Latest: Agent reproduced the issue · 18m ago
Next: Approve temporary logging deployment
3 artifacts · 5 sources · 2 runs
Add build-aware health endpoint
Verifying slice 2 of 3
HTTP behavior passed; design check pending
Next: complete verifier review
2 signals · 4 artifacts
```
Avoid raw tokens, wakeups, grep calls, and internal actor noise.
### Expanded
Use six sections:
```text
Waiting · Approval required
Fix Safari OAuth callback failures
Need: Approve 30-minute diagnostic logging
Impact: Investigation cannot continue without production evidence
[Review request]
1. Outcome
2. Design
3. Build
4. Evidence
5. Delivery
6. Result
```
#### Outcome
- objective/user impact;
- source Signals;
- scope/non-goals;
- acceptance criteria;
- risk;
- assumptions/questions;
- definition approval.
#### Design
- impact map;
- architecture/sequence;
- file-tree delta;
- call-flow delta;
- key interfaces/invariants;
- vertical slices;
- design approval/revisions.
#### Build
- slice graph/progress;
- current attempt;
- meaningful activity;
- questions/decisions;
- retry/replan history;
- contextual composer.
#### Evidence
- verification checklist;
- command/test results;
- browser/API results;
- screenshots/video;
- design-conformance report;
- maintainability/security evidence;
- exact candidate SHA/environment.
#### Delivery
- commit/branch/PR;
- review package;
- preview/staging;
- merge/release approvals;
- rollout/rollback state.
#### Result
- expected vs actual outcome;
- post-release health;
- final classification;
- follow-up Signals;
- learning proposals.
## 5. Phase language
Use user-meaningful phases:
```text
Approval required — Merge PR #184
Why: All automated verification passed; no blocking review issues remain.
Impact: Changes OAuth callback validation; no database migration.
Evidence: 14 unit tests; 6 browser tests; preview reviewed; automated code review attached.
[Review changes] [Request changes] [Merge]
Defining outcome
Awaiting definition approval
Designing
Awaiting design approval
Ready
Building slice N of M
Verifying slice N
Slice review required
Integrating
Ready for merge
Releasing
Observing result
Completed
Blocked
Needs input
Failed
Cancelled
```
## 7. Card expansion
Do not expose internal state-machine names unless in diagnostics.
Selecting a work card expands it in place in the primary workspace. On desktop, only one primary card is expanded; surrounding feed remains visible where practical; right panel follows selected section/artifact; composer scope becomes that unit; collapse retains card position; deep links open directly to a unit. Expansion must feel like entering a workspace, not a disconnected detail page. Mobile may use a full-screen sheet/route while preserving the model.
## 6. Attention objects
## 8. Global/contextual chat
**Global mode** coordinates work across active organization/portfolio and may embed work cards/attention items. Example prompts: “What needs my attention today?”; “Create work for the repeated OAuth complaints.”; “Compare onboarding issues across my projects.”; “What is blocked for the release?”; “Move the pricing experiment behind the reliability work.”; “Summarize what progressed overnight.”
**Context mode** focuses one work unit: “Add Safari 17 to the acceptance criteria.”; “Explain the current hypothesis.”; “Show me the failing test.”; “Try a solution without changing the data model.”; “Prepare a smaller pull request.”; “Ask the customer for the missing information.” Header shows scope and easy return to global.
When work is described, the system may update an existing unit, create a candidate, create accepted work when intent is explicit and policy permits, ask a clarifying question, or save knowledge. Briefly state the outcome:
A question/approval is a structured card:
```text
Created candidate work: Improve OAuth diagnostics
Linked to: Restore OAuth reliability · Sources attached: 3
Decision title
exact question
why it matters
recommendation
alternatives
consequences
affected Work/slice
primary action
reply action
```
Mentions insert structured references (without copying IDs/links) to a work unit, project, goal, artifact, source, or person.
## 9. Status design
Use these user-facing statuses and explicit primary actions:
| Status | Meaning | Primary actions |
| --- | --- | --- |
| **Proposed** | Suggested, not started. | Accept; edit; merge; dismiss. |
| **Clarifying** | Outcome/requirements incomplete. | Answer question; add context; accept assumption. |
| **Ready** | Defined enough to begin. | Start; review plan; change priority. |
| **Active** | Progressing. | Inspect; steer; pause; add context. |
| **Waiting** | Depends on person, dependency, approval, or external event. | Resolve blocker; grant access; make decision; continue waiting. |
| **Review** | Artifact/result needs evaluation. | Approve; request changes; continue discussion. |
| **Monitoring** | Shipped; outcome is being watched. | Inspect result; extend monitoring; create follow-up; roll back. |
| **Done** | Completed or intentionally stopped. | View result; reopen; create follow-up; archive. |
Modifiers: **Blocked, Approval required, Waiting on user, Waiting on dependency, Running autonomously, Scheduled, At risk**. Always pair color with text and iconography.
## 10. Activity language
Activity is meaningful product language, not raw agent noise.
- **Prefer:** Investigated authentication flow; identified likely state-validation bug; added regression tests; verification passed; PR created; waiting for merge approval.
- **Hide from primary UI (technical drawer only):** called grep; ran shell command; read 34 files; model generated 2,600 tokens; actor woke.
Group related events into sessions:
Example:
```text
Implementation run · 24 minutes
• Inspected callback handler • Added state validation
• Added regression tests • 14 tests passed
• Created PR #184
Should /health be public?
Recommendation
Expose basic status publicly; protect detailed diagnostics.
Why
Public uptime checks need access, but build metadata may reveal deployment data.
[Use recommendation] [Reply]
```
## 11. Provenance
Answering MUST resume the same Work. Never create a disconnected chat thread.
Four progressively disclosed levels:
Attention inbox ordering:
1. **Source chips (on cards):** Git, Support, Analytics, Conversation, System inference, Human decision.
2. **Evidence panel:** sources supporting current understanding/recommendation.
3. **Raw source:** original message, issue, log, file, or event.
4. **Derivation detail (high-impact claims):** conclusion; evidence; assumptions; confidence; conflicting evidence; last updated; producing agent/process.
1. production/security blockers;
2. decisions blocking active Work;
3. review/approval requests;
4. informational updates.
Labels: **Verified, Reported, Inferred, Assumed, Conflicting, Outdated**. Do not force derivation detail during normal operation.
## 7. Definition and design review
## 12. Organization/project/portfolio scope
Use diffable structured documents, not long prose blobs.
Users may manage several organizations/projects. The active organization is always visible; switching organization changes the full scope; never blend private context across organizations. Portfolio views may aggregate counts/summaries only with explicit labels. Composer indicates portfolio, organization, project, or work-unit scope.
### Work Definition editor
Editable fields:
- problem;
- outcome;
- scope/non-goals;
- acceptance criteria;
- assumptions;
- questions;
- risk.
Actions:
```text
Portfolio
├── Company A
│ ├── Product Alpha
│ └── Internal Platform
└── Client B
└── Customer Portal
Approve | Request revision | Edit directly
```
Do not require deep-tree navigation for every action: provide search, recent projects, and scope-aware commands.
### Design Packet review
## 13. Project onboarding
Show:
Onboarding prepares a capable working environment, not a long settings form.
- one architecture diagram;
- expected file-tree changes;
- expected call flow;
- key types/contracts;
- slices;
- risks/trade-offs.
- **Entry:** start from template; connect existing repository; import repository; create empty project.
- **Initial templates:** React, Hono, Express, full-stack web application, API service, Expo application, monorepo. Each card states inclusions, fit, expected setup time, and included verification.
- **Context setup:** concise sections for Product, Business, Design, Technical conventions, Agent instructions. System may draft from repository/conversation; user reviews/confirms. Allow progressive completion; clearly mark missing context; do not require complete documents before starting.
- **Readiness:** Repository ready; Dependencies prepared; Context confirmed; Verification passed; Preview available; Agent ready. Describe infrastructure in plain operational language, not raw logs.
Highlight changes between versions. Approval records actor/user, version, timestamp, and optional comment.
## 14. Artifacts
## 8. Slice presentation
Artifacts are first-class in the work unit. Present as PR summary, code diff, preview frame, test report, screenshot gallery, research document, design proposal, generated file, or release note. Each shows **name, type, producer, time, related run, verification state, review state, source/destination**. Open in the right panel or focused viewer without breaking work-unit context.
## 15. Human actions
Use explicit actions, never vague “continue”: **Approve plan; answer question; grant temporary access; review preview; review changes; request revision; approve branch push; approve PR; approve merge; approve deployment; send message; pause autonomous work; cancel run; reopen work.** Every sensitive action shows scope and consequence:
Each slice shows:
```text
Deploy preview
Project: Zopu Web · Branch: work/W-103/team-invitations
Environment: Temporary preview · External impact: None
Expected duration: 20 minutes
[Deploy preview]
objective
observable behavior
planned code changes
required checks
dependencies
status
attempt count
artifacts
review requirement
```
## 16. Autonomy controls
A timeline is useful, but the primary representation is a dependency-aware list.
Do not expose a complex agent-control panel by default. States: **Manual; Ask before actions; Autonomous within policy; Paused**. Project/organization policy can set defaults. Example unit indicators:
Slice statuses:
```text
Autonomous within policy · No action required
Paused after repeated verification failure · Review required
Pending | Ready | Running | Verifying | Review | Passed | Failed | Blocked
```
Advanced settings may expose policy detail; everyday use stays simple.
## 9. Activity design
## 17. Empty, loading, failure, conflict
Normalize events into concise statements:
| State | Required behavior |
| --- | --- |
| Empty workspace | Never show a blank dashboard; offer Create project, Connect repository, Describe what you are working on, Import existing issues, Start from a template. |
| No active work | Show recent outcomes and suggested candidates; never invent busywork. |
| Run provisioning | Use “Preparing workspace”, “Loading project context”, “Starting implementation”; expose infrastructure terms only when delayed/failed. |
| Failure | Explain what failed; preserved work; retry safety; recommendation; whether human action is required. Example: “Verification failed. The implementation is saved, but 2 browser tests are failing. The system recommends another repair attempt.” Actions: Review failures / Retry repair / Stop work. |
| Conflicting evidence | Show conflict, never silently choose: “Support reports OAuth failures increased; Analytics currently shows no significant change.” Actions: Inspect sources / Continue monitoring / Start investigation. |
Good:
## 18. Responsive behavior
```text
Implemented build metadata provider
Focused tests passed
Verifier found response schema mismatch
Repair attempt started with failure evidence
```
| View | Layout/behavior |
| --- | --- |
| Desktop | Three columns; one expanded unit; right contextual panel; persistent bottom composer. |
| Tablet | Collapsible left index; primary workspace; right panel as overlay or resizable drawer. |
| Mobile | Bottom navigation **Now, Attention, Work, Projects**; vertical card feed; expanded work full-screen route/sheet; composer fixed above navigation; context content in tabs/sheets. Prioritize supervision, review, approval over full code inspection. |
Bad:
## 19. Visual language
```text
Tool call: bash
Read 42 files
Generated 7,932 tokens
Actor wake event
```
Direction: **calm, precise, premium, spacious, highly legible, modern without looking experimental**. Use warm white or restrained dark backgrounds; soft neutral surfaces; thin separators; large rounded corners; subtle depth; strong type hierarchy; restrained accent; selective pastel category/phase states; smooth expansion/scope transitions.
Provide an expandable diagnostics/log panel for experts.
Avoid dense enterprise dashboards; excessive gradients; permanent borders around every item; bright status colors everywhere; model/agent-control clutter; chat bubbles dominating; Kanban as default mental model; gamification. It should feel like a focused operating environment, not AI widgets.
## 10. Verification UX
## 20. Motion
Checklist rows contain:
Motion reinforces state/continuity: card→workspace expansion; composer-scope change; candidate→accepted work; approval resolving/work resuming; new artifact; blocker clearing. Avoid decorative distraction; the system feels alive because work meaningfully changes, not because it constantly animates.
```text
check name
status
scope
candidate revision
evidence link
duration
failure summary
```
## 21. Accessibility
Statuses:
Require full keyboard navigation; clear focus states; screen-reader labels for statuses/actions; status never color-only; sufficient light/dark contrast; reduced-motion support; predictable card expansion; confirmations for destructive/external effects; human-readable relative timestamps with exact-time access.
```text
Queued | Running | Passed | Failed | Skipped | Inconclusive
```
## 22. MVP
Failures should include:
### Screens
- exact assertion/exit code;
- relevant output;
- expected vs actual;
- repair action;
- retry count.
1. **Active Work:** work-card feed; project/status filters; expanded work unit.
2. **Discovery Inbox:** signals; candidate work; merge/attach.
3. **Attention Queue:** decisions; approvals; questions; reviews.
4. **Project Overview:** goals; active work; project context; artifacts/recent outcomes.
Design-conformance findings are labeled:
All four persist: header; work index; global/contextual composer; realtime activity.
```text
Conforms | Intentional deviation | Concern | Unknown
```
### Cards
Metrics never masquerade as truth; human review may accept justified deviations.
Signal; Candidate; Active work; Waiting/blocker; Approval; Review; Result. Do not create one card type per technical event; use sections/modifiers in the core system.
## 11. Review package
## 23. Success criteria
The delivery screen should narrate the change in review order:
Within seconds, users can answer: **What should I focus on? What progressed without me? What is blocked? What requires my decision? Why is this happening? What evidence supports current understanding? What happens next? What changed in the project? What result did completed work produce?** They can start, steer, review, or stop work without understanding internal agent/infrastructure architecture.
1. original intent;
2. accepted behavior;
3. architecture/program-design summary;
4. slice-by-slice implementation;
5. screenshots/video/API examples;
6. important diffs;
7. verification evidence;
8. deviations and risks;
9. exact commit and PR actions.
## 24. Shipping checklist
Primary actions:
- Primary object is a work unit, not a chat session.
- Organization/project scope is clear.
- Next human action is obvious.
- Machine actions and human decisions are separated.
- Provenance is available.
- Uncertainty is represented honestly.
- Detail is progressively disclosed.
- Technical internals are hidden unless useful.
- Composer preserves continuity.
- Experience reduces coordination rather than creating more.
```text
Open PR | Approve | Request changes | Answer blocker
```
## 25. Core experience statement
Merge remains external/manual initially.
Zopu should feel like supervising a capable operating system, not operating a collection of agents: one workspace shows meaningful work; the user resolves the few things requiring judgment and continues conversation where needed; the system handles context, execution, progress tracking, and knowledge retention beneath a calm surface organized around persistent outcomes.
## 12. Result and learning UX
After delivery:
```text
Expected
Actual
Evidence
Classification
Follow-ups
```
Learning proposals are reviewable cards:
```text
Proposed update
reason/evidence
target document/Kit
diff
[Accept] [Edit] [Reject]
```
Never silently rewrite canonical knowledge.
## 13. Empty/loading/error states
### Empty project
```text
Describe what you want to accomplish.
Zopu will convert actionable intent into reviewable Work.
```
### Processing
Use phase-specific state:
```text
Extracting actionable intent…
Drafting Work Definition…
Preparing Design Packet…
```
### Failure
Always show:
- failed phase;
- durable status;
- retained evidence;
- recommended next action;
- retry/replan/cancel controls.
Never show an endless generic spinner.
## 14. Responsive behavior
Mobile:
- Conversation is primary.
- Work cards appear inline and in a drawer.
- Attention cards pin above composer.
- Expanded Work uses stacked sections.
- Diagnostics remain secondary.
Desktop:
- Conversation left/main.
- Work list/detail right or split panel.
- Attention queue accessible globally.
## 15. Accessibility and interaction rules
- All status meaning has text, not color alone.
- Streaming updates preserve focus.
- Approvals require explicit controls.
- Destructive actions show scope and consequence.
- Keyboard navigation covers cards, sections, actions.
- Long logs are virtualized/collapsed.
- Timestamps and actor identity are available in audit detail.
## 16. v0 screens
Only build:
```text
Project workspace
Work detail
Attention state inside Work
Verification/review package inside Work
```
Do not build a separate dashboard for every backend module.
## 17. Design acceptance
The experience is successful when a technical founder can:
1. state intent in chat;
2. see exact intent become a Work card;
3. approve a testable definition/design;
4. understand current progress in under a minute;
5. answer a blocker without finding an agent session;
6. inspect verification evidence;
7. reach the exact verified PR;
8. understand why Zopu considers the Work ready.

View File

@@ -1,379 +0,0 @@
# Zopu Dogfood v0 — Complete Loop
> Integration and operations document for the first end-to-end dogfood loop. Grounded in the merged code on `dogfood/v0`. Read alongside [docs/PRODUCT.md](./PRODUCT.md), [docs/DESIGN.md](./DESIGN.md), [docs/TECH.md](./TECH.md), and [docs/SMOKE.md](./SMOKE.md).
## 1. What this loop proves
A user sends a message in the web Work OS. Zopu (the global planning agent) extracts a Signal, routes it to a ProjectIssue, and the user starts work. The issue runs inside an isolated execution environment (Orb: Docker + AgentOS + OpenCode), produces a verified change on a work branch, opens a Gitea pull request, and reports the outcome back into durable state. A GLM planner/reviewer independently approves the result.
The loop touches every layer: control plane (Convex), agent service (Flue), execution plane (Orb runtime), model gateway, Git/Gitea, and the web UI.
## 2. Architecture summary
```
┌──────────────────────────────────────────────────────────────────┐
│ Web Work OS (apps/web) │
│ Conversation composer → Zopu agent → Signals → Work Units │
│ Start button → projectIssues.begin + Flue project-manager send │
└──────────────┬───────────────────────────────────────────────────┘
┌──────────▼──────────┐
│ Convex (control plane) │
│ projects, projectIssues, │
│ signals, events, artifacts│
└──────────┬──────────┘
┌──────────▼──────────────────────┐
│ Flue agent service (:3583) │
│ ┌─────────────────────────────┐ │
│ │ zopu agent │ │
│ │ (global conversation → │ │
│ │ Signal → issue routing) │ │
│ └─────────────────────────────┘ │
│ ┌─────────────────────────────┐ │
│ │ project-manager agent │ │
│ │ (issue-scoped work, │ │
│ │ AgentOS sandbox, │ │
│ │ finalize_gitea_lifecycle) │ │
│ └─────────────────────────────┘ │
└──────────┬──────────────────────┘
┌──────────▼──────────────────────────────────────┐
│ Orb Runtime (Docker + AgentOS + OpenCode) │
│ OrbProjectManager orchestrates: │
│ createOrb → prepareRepository → openSession │
│ → sendTask (context pack) → model turn │
│ → WORK_COMPLETE / NEEDS_INPUT detection │
│ → Git lifecycle (commit, push, Gitea PR) │
│ Scripts: scripts/orb-proof.ts (3-stage proof) │
│ scripts/orb-project-run.ts (full issue) │
└──────────┬──────────────────────────────────────┘
┌──────────▼──────────┐ ┌───────────────────┐
│ Model gateway (CPA) │ │ Gitea (self-hosted)│
│ OpenAI-compatible │ │ PRs, branches │
│ minimax-m3 (worker) │ │ │
│ glm-5.2 (planner) │ │ │
└──────────────────────┘ └───────────────────┘
```
### Two execution paths
The codebase has two ways to run issue-scoped work, both merged and functional:
1. **Flue project-manager path** (the web start button). The web UI calls `projectIssues.begin` (Convex mutation) then sends a message to the `project-manager` Flue agent (`apps/web/src/hooks/use-project-workspace.ts`). The agent works inside an in-process AgentOS sandbox and calls the `finalize_gitea_lifecycle` action (`packages/agents/src/actions/finalize-gitea-lifecycle.ts`) to open a Gitea PR.
2. **Orb project-manager path** (Docker-isolated). The `OrbProjectManager` (`packages/agents/src/orb/orb-project-manager.ts`) orchestrates a Docker sandbox + AgentOS VM + OpenCode session per issue. It projects raw OrbEvents into durable `ProjectRunEvent`s, detects `WORK_COMPLETE:` / `NEEDS_INPUT:` markers, and drives the Git lifecycle via `git-adapter.ts`. The entry point is `scripts/orb-project-run.ts`.
The Orb path provides stronger isolation (full Docker container vs. in-process VM) and is the forward path for the dedicated server. The Flue path is the current local MacBook flow.
### Key files
| Component | File |
| --- | --- |
| Zopu agent (global routing) | `packages/agents/src/agents/zopu.ts` |
| Project-manager agent (Flue) | `packages/agents/src/agents/project-manager.ts` |
| Signal routing tools | `packages/agents/src/tools/signals.ts` |
| Orb runtime | `packages/agents/src/orb/runtime.ts` |
| Orb project-manager | `packages/agents/src/orb/orb-project-manager.ts` |
| Orb adapter | `packages/agents/src/orb/orb-adapter.ts` |
| Git adapter (Gitea lifecycle) | `packages/agents/src/orb/git-adapter.ts` |
| Docker sandbox | `packages/agents/src/orb/docker-sandbox.ts` |
| OpenCode config injection | `packages/agents/src/orb/opencode-config.ts` |
| Context pack assembly | `packages/agents/src/orb/context-pack.ts` |
| Event projection | `packages/agents/src/orb/project-events.ts` |
| finalize_gitea_lifecycle action | `packages/agents/src/actions/finalize-gitea-lifecycle.ts` |
| Smoke harness | `scripts/zopu-smoke.ts` |
| Orb proof fixture | `scripts/orb-proof.ts` |
| Orb issue driver | `scripts/orb-project-run.ts` |
| Web start hook | `apps/web/src/hooks/use-project-workspace.ts` |
| Convex projectIssues | `packages/backend/convex/projectIssues.ts` |
| Convex signals | `packages/backend/convex/signals.ts` |
| Daemon runtime | `apps/daemon/src/runtime.ts` |
## 3. Service startup order
Services must start in dependency order. The daemon owns the in-process RivetKit engine that the agent service depends on.
### Local MacBook development
```bash
# 1. Convex dev server (control plane + generated API)
bun run dev:server
# 2. Agent daemon (Effect daemon + RivetKit engine + AgentOS)
bun run dev:daemon
# 3. Flue agent service (loads zopu + project-manager agents on :3583)
bun run dev:agents
# 4. Web app (Vite on :5173)
bun run dev:web
# 5. (Optional) Docker Desktop — required for the Orb path
open -a Docker
```
### Dedicated server
See [deploy/zopu-runtime/README.md](../deploy/zopu-runtime/README.md). The systemd order is:
```bash
systemctl start zopu-daemon # starts RivetKit engine on :6420
sleep 3
systemctl start zopu-agent # starts Flue on :3583
```
The daemon must be active before the agent service because `registry.start()` boots the in-process RivetKit engine that the agent's `createClient(RIVET_ENDPOINT)` connects back to.
## 4. Required environment variables
All groups are documented in [deploy/zopu-runtime/.env.template](../deploy/zopu-runtime/.env.template). Parsed by `packages/env/src/agent.ts` (`parseAgentEnv`) and `packages/env/src/server.ts`.
### Convex (control plane)
| Variable | Required | Description |
| ----------------- | -------- | --------------------------------- |
| `CONVEX_URL` | Yes | Convex deployment URL |
| `CONVEX_SITE_URL` | Yes | Convex site URL (for actions) |
| `SITE_URL` | Yes | Web app origin (CORS / redirects) |
### Self-hosted Git / Gitea
| Variable | Required | Description |
| --- | --- | --- |
| `GITEA_URL` | For PR lifecycle | Gitea base URL (default: `https://git.openputer.com`) |
| `GITEA_TOKEN` | For PR lifecycle | Gitea API token for branch push + PR creation |
### Model gateway (CPA)
| Variable | Required | Description |
| --- | --- | --- |
| `AGENT_MODEL_PROVIDER` | Yes | Provider identifier (e.g. `cheaptricks`) |
| `AGENT_MODEL_NAME` | Yes | Model name (e.g. `glm-5.2`) |
| `AGENT_MODEL_API` | Yes | Must be `openai-completions` |
| `AGENT_MODEL_BASE_URL` | Yes | OpenAI-compatible `/v1` endpoint |
| `AGENT_MODEL_API_KEY` | Yes | Gateway API key |
| `AGENT_MODEL_CONTEXT_WINDOW` | Yes | Context window in tokens |
| `AGENT_MODEL_MAX_TOKENS` | Yes | Max output tokens |
The smoke worker uses `minimax-m3`; the planner/reviewer uses `glm-5.2`. Both must be in the CPA model catalog.
### AgentOS / RivetKit
| Variable | Required | Description |
| --- | --- | --- |
| `RIVET_ENDPOINT` | No | RivetKit engine endpoint (default: `http://localhost:6420`) |
`registry.start()` boots an in-process engine (envoy mode) backed by a native Rust sidecar. No separate Rivet Engine process is required for single-node operation.
### Zopu agent service (Flue)
| Variable | Required | Description |
| --------------- | -------- | --------------------------------------- |
| `FLUE_DB_TOKEN` | Yes | Flue persistence adapter token |
| `PORT` | Yes | Flue HTTP listen port (default: `3583`) |
### Daemon identity
| Variable | Required | Description |
| --- | --- | --- |
| `DAEMON_ID` | Yes | Unique daemon identifier |
| `DAEMON_NAME` | Yes | Human-readable name |
| `DAEMON_VERSION` | Yes | Daemon version string |
| `DAEMON_HEARTBEAT_MS` | Yes | Heartbeat interval (default: `15000`) |
| `DAEMON_COMMAND_LEASE_MS` | Yes | Command lease duration (default: `60000`) |
### Docker sandbox (Orb path)
| Variable | Required | Description |
| --- | --- | --- |
| `ORB_DOCKER_IMAGE` | No | Docker image override (default: `rivetdev/sandbox-agent:0.5.0-rc.2-full`) |
| `ORB_DOCKER_WORKSPACE` | No | Host workspace root (default: `/tmp/orb-workspaces`) |
Docker socket access is via group membership on the dedicated server.
### Service authentication
| Variable | Required | Description |
| ------------- | --------- | ------------------------------- |
| `AUTH_SECRET` | If needed | Better Auth / Convex JWT secret |
## 5. Local MacBook development flow
```bash
# Clone and install
git clone <repo> zopu && cd zopu
git checkout dogfood/v0
bun install
# Start services in order (see startup order above)
bun run dev:server # Convex
bun run dev:daemon # daemon + RivetKit engine
bun run dev:agents # Flue agents
bun run dev:web # web UI
# Verify the loop is wired
bun run smoke:zopu
# Run the Orb proof (requires Docker + gateway)
ORB_PROOF=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 run orb:proof
# Drive one issue through the Orb project-manager (requires Docker + gateway)
ORB_RUN=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 run orb:run
```
## 6. Dedicated-server runtime flow
The dedicated server runs only the execution plane. Convex is already deployed as the control plane.
```bash
# SSH into the fresh Debian 12 server as root
export ZOPU_REPO_URL="ssh://git@git.openputer.com:2222/puter/zopu-code.git"
export ZOPU_REPO_BRANCH="dogfood/v0"
bash deploy/zopu-runtime/bootstrap.sh
# Edit .env with real values
nano /opt/zopu/.env
# Start services
systemctl start zopu-daemon
sleep 3
systemctl start zopu-agent
# Enable timers
systemctl enable --now zopu-health.timer zopu-docker-cleanup.timer
# Verify
/opt/zopu/deploy/zopu-runtime/scripts/health-check.sh
```
See [deploy/zopu-runtime/README.md](../deploy/zopu-runtime/README.md) for update, rollback, Docker cleanup, disk monitoring, firewall, and Tailscale details.
## 7. Demo procedure
Prerequisites: all services running, a disposable test project with a connected repository source, and the issue-scoped artifact set.
1. Open the web app (`http://localhost:5173` or the Tailscale hostname).
2. Select the test project in the Work OS.
3. Type an actionable message in the composer (e.g. "Add a tiny status indicator to the dashboard"). Zopu creates a Signal and routes it to a ProjectIssue.
4. The Work Unit card appears. Click **Start work**.
5. The web UI calls `projectIssues.begin` then sends the issue to the `project-manager` Flue agent. The agent works inside its sandbox.
6. Watch the Work Unit timeline for `run.session_opened`, `run.agent_message`, and `run.command_executed` events.
7. When the agent emits `WORK_COMPLETE:`, the Git lifecycle runs (commit, push, Gitea PR).
8. The Work Unit card shows the PR link. Click **Review changes** to open the Gitea PR.
9. Merge remains a manual action.
For the Orb Docker path, replace step 4-5 with:
```bash
ORB_RUN=1 \
ORB_GATEWAY_API_KEY=... \
ORB_GATEWAY_BASE_URL=... \
ORB_GATEWAY_MODEL=glm-5.2 \
ORB_GATEWAY_PROVIDER=cheaptricks \
GITEA_URL=... GITEA_TOKEN=... \
ORB_RUN_REPOSITORY_URL=https://git.example.com/owner/repo.git \
ORB_RUN_REPOSITORY_PATH=owner/repo \
bun run orb:run
```
## 8. Health checks
### Local
```bash
bun run smoke:zopu # full preflight (all layers)
curl -s http://localhost:5173/ # web
curl -s http://localhost:3583/ # Flue (no health endpoint by design)
```
### Dedicated server
```bash
/opt/zopu/deploy/zopu-runtime/scripts/health-check.sh
# Probes: systemd units active, RivetKit :6420 TCP, Flue :3583 TCP, docker info
```
## 9. Logs
### Local
Flue agent logs go to stdout in the `bun run dev:agents` terminal. Daemon logs go to the `bun run dev:daemon` terminal.
### Dedicated server
```bash
journalctl -u zopu-daemon -f # daemon (live)
journalctl -u zopu-agent -f # agent (live)
journalctl -u zopu-daemon -n 100 # last 100 lines
journalctl -u zopu-health.service -n 50
journalctl -t zopu-daemon -t zopu-agent --since "1 hour ago"
```
## 10. Failure recovery
| Symptom | Diagnosis | Recovery |
| --- | --- | --- |
| `ZOPU_SMOKE_CONTRACT_BLOCKED` with `convex` failed | Convex dev server not running | `bun run dev:server` |
| `ZOPU_SMOKE_CONTRACT_BLOCKED` with `flue` failed | Flue agent service not running | `bun run dev:agents` |
| `ZOPU_SMOKE_CONTRACT_BLOCKED` with `docker-daemon` failed | Docker not running | Start Docker Desktop / `systemctl start docker` |
| `ZOPU_SMOKE_CONTRACT_BLOCKED` with `gitea-creds-valid` failed | Missing or invalid `GITEA_TOKEN` | Set `GITEA_URL` + `GITEA_TOKEN` in env |
| `ORB_PROOF_BLOCKED` at stage 2 | AgentOS VM creation failed | Check Docker daemon + `rivetdev/sandbox-agent` image |
| `ORB_PROOF_BLOCKED` at stage 3 | Model gateway unreachable | Check `ORB_GATEWAY_*` env and gateway connectivity |
| Work Unit stuck in `needs-input` | Agent emitted `NEEDS_INPUT:` | Resolve the question and send a follow-up message |
| PR not created after `WORK_COMPLETE` | Gitea creds or repo path missing | Verify `GITEA_URL`, `GITEA_TOKEN`, repository path |
| Daemon offline in Convex | Daemon process crashed | `systemctl restart zopu-daemon` |
## 11. Cleanup
### Local Orb artifacts
```bash
docker container prune -f --filter "label=orb"
rm -rf /tmp/orb-* /tmp/orb-proof-*
```
### Dedicated server
```bash
/opt/zopu/deploy/zopu-runtime/scripts/docker-cleanup.sh # prune stopped containers/images
```
Named volumes and running containers are never removed.
## 12. Known limitations
1. **Two parallel execution paths.** The Flue `project-manager` agent (in-process AgentOS sandbox) and the `OrbProjectManager` (Docker + AgentOS + OpenCode) both work but are not yet unified behind a single dispatcher. The web start button uses the Flue path; the Orb path is driven by `scripts/orb-project-run.ts`. Unifying these behind the Work Unit start button is a forward milestone.
2. **No auto-merge.** The Git lifecycle opens a pull request and stops. Merge is always a manual human action, by design.
3. **Docker not wired on the dedicated server.** Docker Engine is installed and the `zopu` user is in the `docker` group, but the daemon currently uses the in-process AgentOS VM sandbox. The Orb Docker path is proven locally via `scripts/orb-proof.ts` but not yet the default on the server.
4. **OpenCode ACP gateway wiring.** The model gateway works via `OPENCODE_CONFIG_CONTENT` injected through the package manifest env + OpenAI seed model remapping (see `packages/agents/src/orb/opencode-config.ts` and `runtime.ts`). The three-stage orb proof passes. This is a known working boundary, not a general-purpose provider configuration.
5. **Pre-existing check failures.** The root `bun run check` has a pre-existing unrelated failure: formatting under `repos/effect` and dual Hono patch versions (4.12.30 vs 4.12.31). These are not related to the dogfood loop and should not be fixed in this lane.
6. **Single-node only.** The dedicated server runs one node with an in-process RivetKit engine. Multi-node coordination is out of scope.
7. **Web app not deployed on the server.** The server runs the execution plane only; the web frontend runs locally or is deployed separately.
8. **No production generated-app hosting.** Only the execution plane runs on the dedicated server.
## 13. Next three incremental milestones
1. **Unify the execution dispatcher.** Route the web Work Unit start button through a single dispatcher that selects the Orb Docker path (when Docker is available) or the Flue in-process path (fallback). This eliminates the two-parallel-paths limitation and makes the Orb path the default on the dedicated server. Touch point: `apps/web/src/hooks/use-project-workspace.ts` start action + a new Convex action that invokes `OrbProjectManager`.
2. **Stream Orb events into Convex ProjectEvents.** The `OrbProjectManager.onProjectEvent` callback currently writes to an in-memory array. Wire it to persist `ProjectRunEvent`s into the Convex `projectEvents` table so the web Work Unit timeline shows live Orb execution progress. Touch point: `packages/agents/src/orb/orb-project-manager.ts` + a new Convex mutation mirroring `agentWorkspace.recordGiteaLifecycle`.
3. **Durable run resume after daemon restart.** `OrbProjectManager` holds active runs in memory. Add a Convex-backed run ledger so an active Orb run can resume after a daemon restart instead of being lost. This moves the run-state machine from in-memory to durable, matching the `OrbState` / `RunState` transitions in `packages/agents/src/orb/domain.ts`.

View File

@@ -1,228 +1,422 @@
# Zopu Work OS — Product
# Zopu Work OS — Product Specification
> Working spec · product/design/ops/research/agent teams · concepts, behavior, boundaries, value. Excludes implementation, infra, tech choices, low-level UI.
> **Related:** `dev-loop.md` defines delivery; `slices.md` defines product increments; `evaluation.md` defines quality measurement.
## 1. Summary
> **Status:** Working source of truth
> **Audience:** product, design, engineering, agents
> **Normative words:** **MUST** = required, **SHOULD** = default, **MAY** = optional
> **Product boundary:** this file defines *what and why*, not implementation details.
Zopu Work OS is a persistent OS for technical founders, product engineers, and small teams managing the full lifecycle of building and growing software across products, companies, repos, customers, and functions. It replaces fragmented chats, trackers, docs, and manually coordinated AI agents with one coherent system organized around **units of work**.
## 1. Thesis
One continuous workspace discovers work, builds understanding, coordinates execution, requests human judgment when needed, tracks outcomes, and retains knowledge. Not a better chat app or just another PM tool — it continuously converts company signals into structured, persistent, executable work.
Zopu is a **completion system for durable units of work**, not a chat wrapper and not a coding-agent launcher.
**Promise:** One workspace that understands the company, turns signals into work, progresses work autonomously, and brings the human in only when direction, judgment, permission, or taste is required.
The product converts conversation and external events into verified outcomes:
## 2. Customer
```text
Talk/events → Signals → Work → Definition → Design → Slices
→ Resolution → Verification → Delivery → Observation → Learning
```
**Ideal:** technical founder/small team that operates 1+ software products across several companies/clients/repos; moves between engineering, product, support, sales, marketing, ops, strategy; uses many tools without a unified model; uses AI coding tools but finds session/thread fragmentation; needs more execution capacity without hiring per function; needs context continuity, reliable delegation, visibility into autonomous work.
The core promise:
**Secondary:** product engineers (several initiatives), fractional CTOs, agencies/studios, solo founders, small product/eng teams, multi-internal-product operators.
> Given an accepted unit of work, Zopu keeps advancing it until a verified outcome exists or a precise terminal blocker is recorded.
Technical-first because code is a clear execution domain (measurable artifacts, established workflows, strong familiarity). Broader direction: research, content, marketing, support, product ops, sales enablement, other knowledge/creative work.
Agents may propose, plan, implement, review, and test. **The system decides completion from evidence.**
## 3. Problem — work is fragmented three ways
## 2. Initial customer and scope
| Fragmentation | Detail |
| --- | --- |
| **Across tools** | One problem spans support→analytics→tracker→browser→repo→PR→release notes→customer comms. Each tool stores a partial view; none owns the lifecycle. |
| **Across AI sessions** | Chat/thread-organized work forces repeated decisions: which conversation, what context to restate, which model/agent, where to store output, how it relates to existing work, what changed after shipping. Conversation becomes structure though conversations are temporary and work is persistent. |
| **Founders as coordination** | Small-team founders bridge every system/function: remember decisions, connect feedback to work, check blockers, move work between tools, review AI output, decide next steps. The founder becomes the human integration bus. |
### ICP v0
Zopu removes this burden by maintaining a persistent model of work, context, state, evidence, execution, and outcomes.
Technical founders and small engineering teams working in one Git repository.
## 4. Thesis
### Supported v0 workflow
- **Conversation is the interface, not the structure.** The durable structure is the **work unit**. Changing tasks must not require a new chat; the persistent conversation's scope moves between whole workspace and a specific work unit.
- **Work = outcomes, not activity.** Represent: _fix Safari OAuth failures; reduce onboarding abandonment; launch team invitations; resolve a billing escalation; rewrite the pricing page; validate a direction._ Do not promote every message/tool-call/agent action to a top-level task.
- **The product participates in work** — not just store/display: discover, understand, plan, execute, verify, publish, measure.
- **Autonomy reduces attention, not adds noise.** Successful when it makes correct progress, informs at the right level, asks only when necessary.
- **Knowledge compounds.** Every decision, result, failure, signal, artifact improves understanding and accelerates/future-proofs future work.
```text
Project chat
→ actionable Signal
→ approved Work Definition
→ lightweight Design Packet
→ vertical slices
→ isolated coding run
→ independent verification
→ branch + pull request
→ human merge
→ result assessment
```
## 5. Core objects
### v0 constraints
Use a small set of durable concepts that stays stable across domains.
- One user/organization/project/repository path first.
- Web interface only.
- One implementation harness first.
- One sandbox provider first.
- Manual merge.
- No autonomous production mutation.
- No arbitrary generated tools receiving immediate execution authority.
- No generic support for every founder workflow yet.
### Organization
The domain must remain broader than Git, but the implementation should prove quality on software delivery first.
Hard business/permission/knowledge boundary. Represents a company, client, startup, internal BU, or personal portfolio. Owns its users, products, policies, integrations, knowledge, work, history. **Information never moves between organizations implicitly.**
## 3. Product surfaces
### Project / product
The primary interface remains intentionally small:
Durable operating context within an org: software product, repo-backed app, client engagement, internal platform, website/service, focused initiative. Contains goals, product knowledge, conventions, connected sources, relevant people, active work. Reduces context ambiguity without forcing manual folder structures.
```text
1. Continuous project conversation
2. Work cards
3. Human attention queue
```
### Goal
### Continuous conversation
What the org/project is trying to achieve (e.g. reduce onboarding failure, reach a reliability target, launch enterprise support, improve trial→paid, close first ten enterprise customers). Distinguishes valuable work from merely possible. A work unit may support 1+ goals but every important unit has a clear reason.
One project-level conversation is used to:
- discuss intent;
- create Signals;
- ask about Work;
- add evidence or constraints;
- change priorities;
- respond to agent questions.
Conversation is not the system of record for execution state.
### Work cards
A Work card is the durable, inspectable representation of one desired outcome. It exposes definition, design, execution, evidence, delivery, and result without forcing the user to inspect raw agent transcripts.
### Attention queue
All decisions requiring a human are first-class objects:
- clarify intent;
- approve definition/design;
- grant permission;
- select an alternative;
- review a slice;
- approve merge/release;
- accept risk.
## 4. Canonical domain
### Signal
Raw info that may matter: support ticket, customer comment, error spike, deploy failure, sales objection, team-channel message, analytics change, founder thought. **Not automatically a task.** Fates: attach to existing work, convert to candidate, save as knowledge, dismiss, mark irrelevant/duplicate.
Evidence that may require attention.
```text
Signal
├── exact source/provenance
├── extracted meaning
├── project context
├── confidence
├── related signals
└── candidate Work links
```
A Signal MUST preserve the original source. A model summary never replaces evidence.
### Work
A durable desired outcome, not a message, issue, session, branch, or PR.
```text
Work
├── objective
├── reason/user impact
├── source Signals
├── Work Definition
├── Design Packet
├── slices/steps
├── runs/attempts
├── questions/decisions
├── artifacts/evidence
├── delivery
└── result
```
### Candidate work
### Work Definition
Proposed work unit not yet accepted as durable active work. Created when a signal/cluster seems actionable. Explains: proposed outcome, why it matters, supporting evidence, project/goal, system confidence, whether similar work exists. User can accept/edit/merge/defer/dismiss.
A testable contract before implementation:
### Work unit
- problem;
- desired outcome;
- affected users;
- in/out of scope;
- acceptance criteria;
- constraints;
- assumptions;
- unresolved questions;
- risk class;
- required artifacts;
- rollout/rollback requirements when applicable.
Central object. Persistent, stateful representation of one meaningful outcome. Owns: objective, desired result, scope/non-goals, current understanding, requirements, success criteria, status, priority, dependencies, blockers, decisions, evidence, plan, human actions, agent activity, artifacts, results, learning.
### Design Packet
Persists across conversations, agent runs, people, tools, time. Not identical to (though may contain): a single prompt, chat thread, agent session, PR, checklist item, branch.
A reviewable implementation intent:
### Step
- impact map;
- architecture summary/sequence;
- file-tree diff;
- call-flow diff;
- key types/interfaces;
- invariants;
- migration/security/operational concerns;
- vertical slice plan;
- explicit trade-offs and deviations.
Bounded action within a work unit: inspect logs, reproduce failure, draft plan, add tests, review preview, contact customers. Organizes execution but must not become another noisy task layer — visible when useful, hidden when only the outcome matters.
### Vertical Slice
### Run
A bounded unit of implementation that produces observable, independently verifiable progress.
One agent/workflow attempt to progress a work unit: research root cause, implement, review, verify, prep release. A unit can have parallel/repeated runs. Subordinate to the work unit — never replaces it as the primary object.
A valid slice answers:
- what behavior becomes observable;
- what code boundaries change;
- what artifacts must exist;
- what evidence proves completion;
- what review is required before continuation.
### Artifact
### Kit
Durable output/evidence: PR, commit, patch, design, spec, test report, screenshot, preview, research memo, customer-response draft, launch plan. Attached to the work unit, accessible from history and current state.
A versioned executable configuration for resolving a class of Work:
### Decision
- roles/agent definitions;
- tools and grants;
- skills/instructions;
- context policy;
- runtime requirements;
- harness policy;
- budgets;
- output contracts;
- verification recipes;
- escalation policy.
Records an important human/system choice + reasoning: approve plan, choose design direction, reject a migration, delay a feature, accept a limitation. Stays visible after the conversation so future agents don't repeat settled debates.
A Kit is not an agent and not a run. v0 uses one static Coding Kit; dynamic Kit Builder comes after the loop is reliable.
### Approval
### Run and Attempt
Specific human permission before a sensitive/consequential action: merge code, deploy prod, send customer message, access sensitive data, spend money, change business policy. Precise, inspectable, bounded — user knows exactly what happens after approval.
A Run coordinates execution for Work or a slice. An Attempt is one bounded execution by one harness in one environment.
### Result & learning
Attempt terminal classifications:
Result records post-completion reality, comparing: intended outcome, expected impact, actual outcome, measurement period, unexpected effects, recommended follow-up. Learning = reusable knowledge extracted to improve future planning/execution across the relevant org/project.
```text
Succeeded | RetryableFailure | NeedsInput | Blocked
| VerificationFailed | BudgetExhausted | Cancelled | PermanentFailure
```
## 6. Work lifecycle
“Agent working” is not a durable outcome.
Four conceptually consistent phases across all domains.
### Artifact and Evidence
| Phase | The system… | Goal/output |
| --- | --- | --- |
| **Discover** | Extracts, groups, dedupes, connects signals; proposes candidate work; relates to goals/projects. | Identify meaningful work with sufficient evidence/relevance — _not_ the largest backlog. |
| **Understand** | Gathers context — research, code/system inspection, customer evidence, analytics, prior decisions, constraints, open questions, risks, success criteria. | Actionable understanding, not an endless report. |
| **Create & iterate** | Coordinates production — code, specs, designs, docs, tests, customer comms, launch assets, research, business recommendations. Produces intermediate artifacts, verifies, incorporates feedback without losing continuity. | Working solution. |
| **Publish & learn** | Releases/delivers/shares, then follows the outcome — deploy status, error rates, adoption, customer reactions, conversion, support volume, revenue impact, qualitative feedback. | Measured result. |
Examples:
Lifecycle ends only when work produces a result, is intentionally stopped, or is superseded.
- plan/design;
- diff;
- test report;
- screenshot/video;
- commit;
- PR;
- preview;
- deployment;
- runtime logs;
- performance/security reports.
## 7. Work-unit boundaries
Every artifact SHOULD include producer, Work/slice/run, source revision, environment, provenance, and verification status.
Quality depends on how work is divided.
### Result
**Good unit** = one independently meaningful outcome, summarizable in one clear sentence, reaches a recognizable result. Good: _fix Safari OAuth callback failures; decide SAML support this quarter; reduce onboarding time-to-first-value; prepare & launch team invitations._ Poor: _improve the product; marketing; work on onboarding; open the repo; read the logs_ (too broad or too granular).
Delivery is not necessarily outcome. Result compares intended and actual impact:
**Split when:** multiple independent outcomes; different owners/approval paths; different orgs/permission boundaries; different deadlines/success criteria; one part finishes while another stays active; hard to summarize/review; a child deserves its own lifecycle/result.
```text
Achieved | PartiallyAchieved | NotAchieved | Regressed | Inconclusive
```
**Merge when:** two candidates describe the same outcome; different channels reported the same problem; one candidate is just evidence for an existing unit; neither can be completed independently. System may propose merges; uncertain merges stay reversible/user-reviewable.
### Learning
**Parent/child:** large initiatives contain child units; parent = larger result, each child still independently meaningful. _Parent: Restore OAuth reliability → children: fix Safari callbacks, improve setup diagnostics, update docs, notify customers, monitor post-release completion._ Parent summarizes progress/dependencies without replacing child detail.
Post-work observations that may propose updates to:
## 8. Conversation model
- project knowledge;
- architecture decisions;
- test recipes;
- skills;
- tools;
- Kit definitions;
- planning heuristics.
One persistent conversational surface, two scopes.
Canonical knowledge updates require review.
| Scope | Use |
| --- | --- |
| **Global** | Think/act across workspace: introduce ideas/problems, ask what needs attention, compare work across projects, reprioritize, create/modify work, request summaries, discuss dependencies, request research/execution. System auto-connects messages to relevant projects/units while preserving source. |
| **Contextual** | Same composer focused on one work unit: add requirements, answer questions, review progress, change direction, approve a plan, request another attempt, ask for explanation, continue from an artifact. Entering does **not** create a new thread — it changes the active scope. |
## 5. Quality doctrine
Conversation is not the source of truth. Important facts/decisions/requirements/approvals/artifacts/results are extracted into structured objects while preserving links to source conversation.
High-quality software is evaluated across:
## 9. Autonomous behavior
| Dimension | Required question |
|---|---|
| Correctness | Does behavior satisfy the accepted criteria? |
| Regression safety | Did existing behavior remain valid? |
| Maintainability | Does the change preserve understandable boundaries? |
| Security | Are permissions, secrets, data, and dependencies safe? |
| Reliability | Are failures and edge cases handled? |
| Operability | Can it be deployed, observed, debugged, and rolled back? |
| Performance | Are expected resource limits preserved? |
| UX | Does the actual user path work? |
| Traceability | Can claims be tied to evidence and revisions? |
| Reversibility | Can risk be disabled or rolled back? |
The system continuously observes, understands, organizes, executes, escalates, learns.
### Completion rule
**May autonomously:** attach new evidence; update summaries/understanding; detect stale/blocked work; propose new work; run safe research; prepare plans/drafts; produce code/artifacts inside approved boundaries; run verification; suggest priorities; monitor completed work; create follow-up candidates; summarize activity.
Work is complete only when:
**Request human for:** strategic direction; ambiguous product decisions; business tradeoffs; sensitive permissions; high-risk actions; external communication; production changes; priority conflicts; conflicting evidence; actions outside granted policy.
1. accepted behavior is implemented on a known revision;
2. required checks pass in a known environment;
3. design deviations are visible;
4. required human gates are satisfied;
5. delivery artifacts exist;
6. post-release observation is completed when required.
**Must not:** create speculative work without evidence; treat confidence as certainty; hide failures/uncertainty; silently cross org boundaries; reopen settled decisions without new evidence; perform irreversible actions without authority; optimize for activity over outcomes.
A passing test suite is necessary but not sufficient for medium/high-risk work.
## 10. Human-in-the-loop
### Builder-verifier separation
User must not inspect every unit to find what needs attention. Human requests centralize into an **Attention Queue**. Each request states: what's required, why, the recommendation, supporting evidence, what happens after approval, what happens if the user does nothing, urgency, reversibility. **Categories:** decision · approval · clarification · access request · review · blocker resolution · risk alert.
The implementation worker MUST NOT be the sole authority on completion.
Minimize unnecessary approvals via explicit standing policies while preserving clear control over sensitive actions.
```text
Builder → candidate output
Verifier → independent evidence
Resolver → completion decision
Human → policy-defined approvals
```
## 11. Provenance & trust
### Risk lanes
User must understand why the system believes something and what caused an action. **Every important claim carries provenance.** Sources: message, ticket, repo change, error report, analytics event, document, agent observation, human decision. Claim status distinguished as: verified · reported · inferred · assumed · conflicting · outdated. Primary interface stays readable; deeper evidence/derivation available on demand. **Never present an agent summary as equivalent to primary evidence.**
#### Fast
## 12. Knowledge model
Copy, docs, isolated styling, deterministic mechanical changes.
Accumulates several kinds; each retains its scope (personal prefs, project facts, org-wide truths never mixed blindly).
```text
short contract → implement → verify → PR
```
| Kind | Content |
| --- | --- |
| **Canonical** | Reviewed descriptions: business, product, customers, design principles, architecture concepts, conventions, policies, goals. |
| **Operational** | Current structured state: active work, priorities, dependencies, ownership, blockers, approvals, results. |
| **Episodic** | History: conversations, runs, errors, decisions, reviews, releases, customer reactions. |
| **Learned patterns** | Reusable conclusions: common failure causes, customer prefs, repeated objections, effective launch patterns, known limitations, preferred technical/product approaches. |
#### Standard
## 13. Product boundaries
Normal features, APIs, workflows, changes spanning a few modules.
| Boundary | Rule |
| --- | --- |
| **Control layer, not tool replacement** | Specialist systems (source control, support, comms, analytics, billing, deployment, docs) keep native records/actions. Zopu provides the unified model: what matters, its state, next step, whether outcome worked. |
| **Own the work graph** | Connects organizations→projects→goals→signals→work units→dependencies→decisions→runs→artifacts→results→learnings. Durable source of coordination/context. |
| **Execution is replaceable** | Execution is a capability beneath the work unit. Users shouldn't care which agent/model/harness ran unless inspection is useful. |
| **No unnecessary AI config** | Users shouldn't normally choose model, thinking level, agent framework, context-window strategy, execution runtime, tool routing. System chooses by task/risk/cost/policy. |
```text
definition → combined design → 13 slices → verify → review → PR
```
## 14. Optimization
#### Critical
**Optimize for: maximum useful progress with minimum required human attention.**
Auth, billing, permissions, migrations, shared infrastructure, destructive operations.
Measures: correct extraction of meaningful work; low false-positive candidates; signal→understanding time; time to first useful action; accepted units reaching meaningful outcomes; reduced repeated context gathering; human attention per outcome; decision/output quality; trust/inspectability/reversibility; knowledge reuse; improvement from past results.
```text
definition approval → architecture approval → program-design approval
→ one slice at a time → staged release → observation/rollback gate
```
**Don't primarily optimize:** chat count, time in interface, task count, agent-run count, notification count, raw model usage.
## 6. Resolver contract
A successful day: user opens briefly, resolves a few high-value decisions, sees many units progressed correctly without them.
The Work Resolver is the central product engine.
## 15. Business value
It MUST continuously answer:
| Form | Value |
| --- | --- |
| **Execution capacity** | Small team progresses more work without proportional headcount. |
| **Coordination reduction** | Handles repetitive context-gathering, state updates, linking, follow-ups, output prep. |
| **Continuity** | Work keeps context, history, decisions, artifacts across tools/time. |
| **Better opportunity capture** | Notices recurring problems, customer opportunities, operational risks, key relationships teams otherwise miss. |
- What remains incomplete?
- What is executable now?
- What capability can perform it?
- What evidence is missing?
- Should it retry, repair, replan, escalate, or stop?
- Has the Work reached a valid terminal state?
## 16. Initial scope — first product loop
Resolver outcomes:
1. User starts/connects a software project.
2. Product establishes project context.
3. User describes desired work via global composer.
4. System creates/updates a work unit.
5. Unit is clarified and accepted.
6. System progresses work autonomously.
7. User reviews only necessary decisions/outputs.
8. System produces verified artifacts.
9. User approves publication/integration where required.
10. Product updates the unit and retains knowledge.
```text
VerifiedOutcomeDelivered
VerifiedPRReady
HumanDecisionRequired
ExternalDependencyBlocked
BudgetExhaustedWithEvidence
PermanentlyFailedWithDiagnosis
Cancelled
```
First version must prove persistent work units beat disposable agent chats.
The Resolver MUST:
## 17. Long-term direction
- operate from durable state;
- execute bounded attempts;
- enforce budgets and permissions;
- preserve provenance;
- recover after process failure;
- avoid duplicate side effects;
- require evidence before transitions;
- surface precise blockers;
- never silently stall.
Technical product is the starting point. The same work-unit model later supports: product research, marketing campaigns, content production, sales enablement, support operations, pricing work, business analysis, hiring, internal ops. Long-term: a general OS for creative/knowledge work.
## 7. Human operating model
**Enduring principles:** one persistent workspace · work units not chat sessions · outcome-oriented execution · invisible orchestration · explicit human authority · strong provenance · compounding organizational knowledge.
Humans should spend attention on high-leverage decisions, not supervise tool calls.
## 18. Initial non-goals
Preferred gates:
Not intended to be: a full external-tool replacement; a general social collaboration platform; a traditional kanban with AI decoration; a model playground; a prompt-management product; a marketplace of visible agents; a system that creates work faster than users can evaluate; a fully autonomous company with no human authority.
- Work Definition approval;
- Design Packet approval for standard/critical work;
- slice review when risk requires it;
- merge/release approval;
- resolution of ambiguity or policy exceptions.
## 19. Principles
A human request must include:
1. Organize around work, not conversations.
2. Represent outcomes, not activity.
3. Keep the system calm while the machinery stays powerful.
4. Bring humans in for judgment, not clerical coordination.
5. Expose evidence and uncertainty.
6. Make important actions understandable and reversible.
7. Preserve organization and project boundaries.
8. Prefer one coherent model over many disconnected features.
9. Let knowledge compound across completed work.
10. Measure success by outcomes and reduced attention.
```text
decision needed
why it is needed
recommended action
alternatives
consequences
urgency
affected Work/slice
```
## 20. Working statement
## 8. External collaboration
Zopu Work OS is an autonomous OS for technical founders and small product teams. It observes work across the company, turns fragmented signals into persistent units of work, progresses them via agents and tools, and brings the human in only when judgment, permission, or direction is required. One continuous workspace moves from idea/problem to verified, shipped, measurable outcome while preserving context and building durable understanding over time.
Buzz, Slack-like systems, Git forges, monitoring, support, and analytics are interfaces and Signal sources.
They MAY:
- create Signals;
- carry status updates;
- present questions;
- receive review/preview links;
- report incidents and results.
They MUST NOT own canonical Work, Resolver, verification, or completion state.
## 9. Product success metrics
Primary:
- verified outcomes per unit of human attention;
- median time from accepted Work to verified PR/outcome;
- percentage of Work reaching an explicit terminal state;
- verification escape rate;
- post-merge rework rate;
- design-review-to-implementation deviation rate.
Secondary:
- retry count;
- human interruption count;
- stale Work count;
- duplicate Signal/Work rate;
- average review-package comprehension time;
- Kit/version performance by work type.
Avoid optimizing token volume, agent count, or code generated.
## 10. Non-goals for early versions
- Fully autonomous company operation.
- Lights-off merging/deployment.
- Universal dynamic tool creation.
- Large unbounded fleets.
- Replacing Git or CI.
- Encoding all product truth in chat.
- Making one model/harness part of the product domain.
- Automating maintainability judgment without human policy.

57
docs/README.md Normal file
View File

@@ -0,0 +1,57 @@
# Zopu Work OS — Documentation Map
Dense context set for product development and coding agents.
## Read order
```text
1. agent-context.md — bootstrap and non-negotiables
2. product.md — product model and quality doctrine
3. glossary.md — canonical terminology
4. dev-loop.md — normative software delivery process
5. tech.md — architecture, actors, ports, runtimes
6. design.md — user experience
7. slices.md — sequential vertical product increments
8. dev-plan.md — repository execution plan/backlog
9. evaluation.md — quality measurement and improvement
```
## Use by role
| Role | Minimum context |
|---|---|
| Product/definition agent | agent-context, product, glossary, dev-loop |
| Architecture/design agent | agent-context, tech, dev-loop, current Work Definition |
| Coding agent | agent-context, current Design Packet/Slice, tech sections, repository rules |
| Verifier | agent-context, dev-loop, evaluation, Verification Plan |
| Frontend agent | agent-context, design, product, current slice |
| Lead/orchestrator | all documents, current repository state, active decisions |
## Document boundaries
```text
product.md what/why
tech.md system architecture
design.md interaction behavior
dev-loop.md how software Work is delivered
slices.md how Zopu product is incrementally built
dev-plan.md engineering order and release gates
evaluation.md how quality and improvements are measured
glossary.md exact term definitions
agent-context compact instructions for execution agents
```
## First build target
```text
message
→ Signal
→ approved Work Definition
→ approved Design Packet
→ one isolated implementation slice
→ independent verification
→ exact verified PR
→ human question/resume
```
Start at `dev-plan.md` backlog item 01. Do not begin dynamic Kit Builder or fleets before Slices 17 work reliably.

View File

@@ -1,88 +0,0 @@
# 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, the CPA OpenAI-compatible gateway, and the Orb execution-plane dependencies (Docker, OpenCode, Gitea, repository writability) 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.
## Preflight checks
| Check ID | Layer | What it probes |
| --- | --- | --- |
| `web` | Control plane | Web app HTTP reachability |
| `convex` | Control plane | Convex health query returns OK |
| `organization` | Control plane | Authenticated personal organization |
| `project-contract` | Control plane | Disposable project exists with a connected source |
| `agentos-workspace-contract` | Control plane | Issue-scoped artifacts present |
| `agentos-daemon` | Control plane | Online local daemon |
| `flue` | Agent service | project-manager Flue route reachable |
| `cpa-model-catalog` | Model gateway | minimax-m3 and glm-5.2 in catalog |
| `cpa-completion-minimax-m3` | Model gateway | minimax-m3 accepts a no-tools completion |
| `cpa-completion-glm-5.2` | Model gateway | glm-5.2 accepts a no-tools completion |
| `model-roles` | Model gateway | Role selection contract (worker/planner-reviewer) |
| `worker-gateway-config` | Model gateway | AGENT_MODEL_BASE_URL matches the CPA base URL |
| `docker-daemon` | Orb execution plane | Docker daemon responds to `docker version` |
| `opencode-package` | Orb execution plane | `@agentos-software/opencode` is resolvable |
| `gitea-reachable` | Orb execution plane | Gitea `/api/v1/version` endpoint reachable |
| `gitea-creds-valid` | Orb execution plane | Gitea token authenticates against `/api/v1/user` |
| `gitea-pr-creation` | Orb execution plane | Target repository accessible for PR creation |
| `repository-writable` | Orb execution plane | Orb workspace root is writable |
A missing or unreachable dependency reports the check as failed with the exact reason. The harness never fakes success.
## 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='...'
```
For the Orb execution-plane checks (Docker, Gitea PR creation), also export:
```bash
export GITEA_URL='https://git.example.com'
export GITEA_TOKEN='...'
export ZOPU_SMOKE_REPOSITORY_PATH='owner/repo'
```
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.
## Cleanup
The harness does not create Docker containers or Orb workspaces in preflight mode. In run mode, the Flue `project-manager` agent owns its sandbox lifecycle. To clean up Orb proof fixtures or run artifacts:
```bash
docker container prune -f --filter "label=orb"
rm -rf /tmp/orb-* /tmp/orb-proof-*
```
## Current lane boundary
The smoke intentionally stops before mutation when the project source, issue-scoped AgentOS artifacts, or online daemon contract is absent. The Orb execution-plane checks (Docker, OpenCode, Gitea) report BLOCKED when those external dependencies are not available on the local MacBook; this is expected and does not indicate an implementation failure. See [docs/DOGFOOD_V0.md](./DOGFOOD_V0.md) for the full lifecycle.

View File

@@ -1,251 +1,665 @@
# Zopu Work OS — Technical Architecture
> Working spec · engineering/platform/infra/security/agent teams · starts from an existing Better-T-Stack monorepo (web + Expo + shared packages).
> **Related:** `agent-context.md` defines agent rules; `dev-loop.md` defines orchestration; `glossary.md` defines terms.
**Objective.** Multi-tenant Work OS: durable orgs/projects/goals/work units/runs/artifacts/outcomes; one global conversation + scoped work-unit conversations; continuous candidate-work extraction from conversation and connected systems; coding agents in isolated reproducible environments; autonomous execution with explicit human-approval boundaries; agent progress streamed into durable state; inspectable artifacts (commits, PRs, test reports, previews, docs, reviews); scales across orgs/repos/concurrent units/environments; harnesses and model providers replaceable.
> **Status:** Working technical source of truth
> **Audience:** engineers and implementation agents
> **Read after:** `product.md`
> **Principle:** durable intent/state is separate from disposable execution.
**Central ownership rule.** Work OS owns intent + durable state · Flow agents own orchestration · AgentOS owns agent runtime coordination · Harnesses own individual agent loops · Sandboxes own full-system execution · Git owns source history · Artifact storage owns generated outputs.
## 1. System topology
## 1. High-level architecture
Control plane + execution plane.
```mermaid
flowchart TB
User --> Web[Web] & Mobile[Expo]
Web & Mobile --> API[App API] & Realtime
API --> Auth[Identity/tenancy] & WorkGraph[Work graph] & Conversation & Policy
Conversation & WorkGraph --> Flow[Flow Agent]
Flow --> Context & Scheduler & Policy & Gateway[Model gateway] & Integrations & Secrets[Secrets/workload identity broker]
Scheduler --> Actor[AgentOS run actor]
Actor <--> Harness[OpenCode/ACP] & Sandbox[Full Linux sandbox]
Harness --> Gateway & Integrations
Sandbox --> Git & Runtime[Services/browsers/tests/builds]
Actor & Sandbox --> Secrets
Runtime --> Preview
Actor & Sandbox & Git --> Events[Event/provenance log]
Events --> WorkGraph & Realtime & Observability
Artifact[Object storage] --> WorkGraph
Context & WorkGraph --> Search
Policy --> Notifications
```text
Web / desktop / mobile
│ Convex queries, mutations, storage
Convex application backend
├── authentication and tenancy
├── normalized product data
├── conversation turn queue
└── reactive client projections
│ service-authenticated dispatch
FLUE orchestration service
├── model calls
├── typed tools
└── canonical Flue persistence in Convex
│ later execution commands
Rivet Engine + AgentOS (post-Slice 1)
└── sandboxes, harnesses, and durable execution
```
## 2. Components & ownership
Clients MUST communicate only with Convex. FLUE and future Rivet/AgentOS
services are private workers: Convex admits durable commands, invokes the
worker, and stores the product-facing result before clients observe it.
**Client apps (web, Expo)** — product surfaces only: render org/project/work-unit/run/artifact/attention state; send domain commands via app API; subscribe realtime; upload via signed/brokered flows. Never reach model providers, Git internals, harnesses, or sandboxes directly. Commands: create work unit, continue work, change composer scope, approve plan, resolve blocker, review artifact, request changes, pause/cancel run, approve merge, approve deployment.
### Ownership
**Application API** — sole public backend: authz; org/project scoping; CRUD stable entities; command validation; conversation ingestion; attachment metadata; card/workspace/activity/attention queries; realtime-subscription authz; idempotency. Never expose internal runtime APIs.
| Layer | Owns |
|---|---|
| Convex | authentication, normalized product records, command admission, reactive reads |
| FLUE | private programmable orchestration and domain-specific agents |
| Rivet actors (later) | serialized execution ownership, recovery, timers, leases |
| Harness | bounded coding/tool loop |
| Sandbox/runtime | filesystem, processes, network, isolation |
| Git | source revision history |
| Artifact store | durable outputs/evidence |
| External systems | collaboration, delivery, monitoring |
**Identity & tenancy** — one Work OS identity per user; organizations are hard tenant boundaries. Every durable/transient entity carries `organization_id` (+ `project_id`/`work_unit_id`/`run_id` where applicable). Deny-by-default authz on every command/query. Cross-org queries are explicit portfolio ops; never blend underlying knowledge/execution context.
No client, harness, sandbox, or FLUE process owns product state.
**Secrets & workload-identity broker** — shared service owning short-lived run identities and scoped credential minting/injection for Git, model gateway, integrations, artifact storage, preview routing, and optional cloud access; secrets remain outside repositories/actor state, and broker decisions are policy-bound and auditable.
### Slice 1 relational core
**Work graph (canonical product DB)** — relational DB (authoritative) + event table (append-only provenance/replay) + materialized projections (cards, attention, portfolio, activity) + object storage (large artifacts) + search index. Entities: User, Organization, Membership, Project, Project source, Goal, Signal, Candidate, Work unit, Work-unit relation, Step, Run, Run attempt, Agent session, Blocker, Decision, Approval, Artifact, Source reference, Result, Learning, Conversation message, Composer scope, Integration connection, Policy, Workload identity. Work-unit record = structured fields + projections, **not** a big agent-authored JSON blob.
```text
organizations ──< organizationMembers
organizations ──< projects ──< projectContextDocuments
organizations ──1 conversations ──< conversationTurns
conversationTurns ──< conversationMessages ──< conversationAttachments
projects ──< signals ──< signalConstraints
signals ──< signalSources >── conversationMessages
projects ──< works
signals ──< signalWorkAttachments >── works
works ──< workEvents
```
**Event & provenance log** — all meaningful changes are events. Envelope:
Convex mutations provide atomic transactions and optimistic serializability.
Foreign-key integrity and uniqueness are enforced in the owning mutations;
composite indexes back every identity and relation lookup. Flue's adapter
tables remain isolated infrastructure persistence and are not product-domain
relations.
```json
{
"event_id": "evt_01...",
"type": "run.verification_completed",
"organization_id": "org_01...",
"project_id": "prj_01...",
"work_unit_id": "wrk_01...",
"run_id": "run_01...",
"actor_type": "agent",
"actor_id": "opencode",
"source_type": "agentos_session",
"source_id": "session_01...",
"sequence": 184,
"payload": {
"summary": "All unit and browser tests passed",
"artifact_ids": ["art_01..."]
},
"created_at": "2026-07-23T12:00:00Z"
## 2. Domain boundaries
Recommended packages:
```text
packages/
├── signals/
├── work/
├── design/
├── planning/
├── kits/
├── resolver/
├── verification/
├── artifacts/
├── results/
├── knowledge/
├── runtimes/
├── harnesses/
└── integrations/
```
Each domain SHOULD separate:
```text
domain/ pure state, schemas, invariants
application/ use cases and orchestration
ports/ Effect services
adapters/ infrastructure implementations
```
Avoid package proliferation in v0; logical boundaries may begin as folders.
## 3. Core records
Minimal durable model:
```ts
type Id = string
interface Message {
id: Id
projectId: Id
content: string
createdAt: number
}
interface Signal {
id: Id
projectId: Id
sourceType: string
sourceId: string
sourcePayloadRef?: string
summary: string
fingerprint: string
status: "candidate" | "accepted" | "dismissed"
}
interface Work {
id: Id
projectId: Id
title: string
objective: string
risk: "low" | "medium" | "high"
status: WorkStatus
definitionVersion?: number
designVersion?: number
createdAt: number
updatedAt: number
}
interface Step {
id: Id
workId: Id
sliceId?: Id
kind: "design" | "implement" | "verify" | "integrate" | "publish" | "observe"
objective: string
dependsOn: readonly Id[]
status: StepStatus
}
interface Run {
id: Id
workId: Id
stepId: Id
kitVersion: string
status: RunStatus
}
interface Attempt {
id: Id
runId: Id
number: number
harness: string
runtime: string
sourceRevision: string
status: AttemptStatus
startedAt?: number
endedAt?: number
}
interface Artifact {
id: Id
workId: Id
stepId?: Id
runId?: Id
attemptId?: Id
type: string
uri?: string
contentHash?: string
sourceRevision?: string
environmentId?: string
metadata: unknown
}
interface Question {
id: Id
workId: Id
stepId?: Id
attemptId?: Id
prompt: string
recommendation?: string
alternatives: readonly string[]
status: "open" | "answered" | "withdrawn"
answer?: string
}
```
Requirements: monotonic sequence per run/actor stream; idempotent ingestion; immutable original payload; normalized derived state; trace card→events; distinguish raw evidence vs agent interpretation vs human decision.
All external/model payloads MUST be decoded with schemas at boundaries.
**Conversation service** — persistent conversation per user workspace, scoped. Message fields: org scope (+optional project/work-unit scope), author, content blocks, attachments, source references, extraction status, domain-event links. Persist exact user message; publish for extraction/orchestration; global + work-unit scope without new sessions; one message may reference several work units; preserve source links when structured state is extracted.
## 4. Work state machine
**Flow Agent (orchestration above specialist agents)** — interpret intent; extract signals/candidates; match new info to existing work; detect duplicates/relationships; build/update work briefs; decide whether a human question is required; select execution strategy; create runs; monitor run events; retry/fallback/escalate; summarize into work-unit state; create approval requests; propose follow-ups. Not the sole durable truth — outputs validated into commands/events. Does not directly mutate repos; coding is delegated to a harness in an isolated environment.
**Memory classes (four)**
- _Canonical project knowledge_ (versioned/reviewable, prefer in Git): `product.md`, `design.md`, `tech.md`, `business.md`, `AGENTS.md`, READMEs, decision records, repo-local conventions.
- _Structured operational memory_ (DB): goals, work-unit state, requirements, decisions, approvals, blockers, dependencies, results.
- _Episodic memory_ (events/transcripts): user messages, agent runs, tool calls, errors, reviews, deployments, human interventions.
- _Retrieval index_ (derived, non-authoritative → returns source refs): canonical docs, repo content, conversation, work-unit events, artifacts, connected external sources.
**Context assembly** — never inject all knowledge. Run context pack by precedence: (1) org policy (2) project goals + canonical context (3) repo conventions/instructions (4) work-unit objective/requirements/decisions (5) relevant evidence/artifacts (6) current run instructions (7) runtime + tool constraints. Each item retains source, scope, timestamp, confidence/verification status, revision id. Deterministic enough to audit/reproduce.
**Policy & approval engine** (above harness permissions + runtime capabilities) — per action: auto-allowed / allowed under standing policy / requires human approval / denied. Examples: create branch, push work branch, create PR → automatic; merge → approval; deploy preview → automatic; deploy production → approval; never send customer comms without review; never access prod data from a default coding run. Approval record = exact action, params, requested capability, evidence/rationale, risk level, expiration, user decision, resulting action event.
**Run scheduler / workflow engine** — converts accepted work → bounded runs. Owns queue, tenant quotas, priority, fairness, concurrency limits, run leasing, retry policy, budget allocation, cancellation, workflow checkpoints, waiting-for/resuming-after approvals. Run = idempotent steps; persist progress before waiting on human input.
**AgentOS runtime** — durable supervisor/harness runtime per active run. One actor per active run or isolated workbox (never one giant actor per user/org); durable run-environment identity; harness session lifecycle; event streaming; permission requests; lightweight fs/process coordination; sleep/wake when safe. Actor key: `org:{orgId}:project:{projectId}:work:{workUnitId}:run:{runId}`. Durable work unit survives actor destruction/replacement.
**Agent harness** — OpenCode is the initial coding harness (architecture supports other ACP-compatible/adapted harnesses later). Harness owns: model interaction loop, tool selection, repo inspection, file editing, shell requests, local reasoning state, session progress. Harness does **not** own: product work-unit state, org policy, billing, global memory, project permissions, long-term artifact retention. Drive OpenCode through AgentOS; never expose its standalone server to product clients.
**Full Linux sandbox** — compatibility/heavy-execution environment: repo checkout, writable branch/worktree, native binaries, package install, language runtimes, DBs, browser automation, dev servers, tests/builds, file watching, preview processes. AgentOS supervises via explicit adapters/bindings. Division:
```
AgentOS: durable run identity, harness session, approvals, lightweight coordination
Sandbox: full repo fs, native commands, services, browser, tests, builds
```text
Proposed
→ Defining
→ AwaitingDefinitionApproval
→ Designing
→ AwaitingDesignApproval
→ Ready
→ ExecutingSlice
→ VerifyingSlice
→ AwaitingSliceReview
→ IntegratedVerification
→ Review
→ Releasing
→ Observing
→ Completed
```
Known first-party templates may run in lighter env; imported arbitrary repos default to full sandbox.
Side/terminal states:
**Workbox** (product-neutral term for execution capsule beneath a run) — contains repo checkout, isolated branch, runtime recipe, harness config, project skills, temp credentials, running services, preview endpoints, test outputs. Ephemeral/replaceable; work unit is durable. Default isolation: **one writable checkout per active work unit / mutating run**. Parallel read-only runs may share immutable snapshots; parallel mutating runs use separate branches + separate Workboxes.
**Git (managed/connected)** — source history. Creation modes: managed template, connect external, import+mirror, managed-first then add external remote. Identity: one Work OS user identity, internal service identities for automation, repo-scoped short-lived credentials, no second visible Git account for users. Branching: `main` + `work/{W-id}/{slug}`. Git may be hidden in product but user retains portability (export, clone, patch download, external remotes).
**Project templates** — React web, Hono, Express, full-stack, API-only, Expo, monorepo starter. Template defines: source files, runtime versions, install/test/verify/dev commands, service ports, preview behavior, project context prompts, default policies, agent skills. Generates a reusable base snapshot after successful setup+verification.
**Project config files** — repo structure:
```
/ README.md, AGENTS.md, product.md, design.md, tech.md
/.zopu/ project.yaml, services.yaml, policies.yaml
lifecycle/{setup,resume,verify,preview} skills/{testing,reviewing,deploying}/SKILL.md
/apps/
```text
NeedsInput | Blocked | Replanning | Failed | Cancelled
```
`project.yaml`:
Transitions require explicit commands plus evidence. State MUST NOT be inferred from the latest text message.
```yaml
name: example-project
runtime: { template: react-hono, node: "24", packageManager: pnpm }
commands:
{
install: pnpm install --frozen-lockfile,
test: pnpm test,
verify: pnpm run verify,
dev: pnpm dev,
}
services: { web: { port: 3000 }, api: { port: 4000 } }
changes: { workflow: pull_request, baseBranch: main }
approvals:
{
pushBranch: automatic,
createPullRequest: automatic,
mergePullRequest: required,
deployPreview: automatic,
deployProduction: required,
}
## 5. Actor model
Start small.
### ProjectActor
Owns:
- project configuration;
- repository/runtime policies;
- active Work index;
- integration endpoints;
- project-level Signal routing.
### WorkActor
Initial central aggregate:
- Work Definition and versions;
- Design Packet and versions;
- slice/step graph;
- Resolver state;
- approvals/questions;
- artifact references;
- result.
It serializes lifecycle transitions and commands.
### AttemptActor
Owns one execution attempt:
- runtime lease/sandbox ID;
- harness session;
- scoped credentials;
- event stream/checkpoints;
- cancellation;
- attempt timeout;
- normalized outcome.
### VerificationActor
Split from WorkActor after v0 verification is stable. Owns plan, checks, evidence, verdict.
### IntegrationActor
Added when multiple slices/branches exist. Owns branch composition, conflicts, integrated revision, integrated checks.
### ResultActor
Added after delivery observation exists. Owns rollout observation, original success signals, final outcome, learning proposals.
Do not create an actor per document or tool call. Create actors for durable identity, serialized ownership, independent failure/recovery, or timers.
## 6. Commands, events, and idempotency
Use command/event vocabulary rather than mutable agent prose.
Example commands:
```text
ProcessMessage
AcceptSignal
CreateWork
ApproveDefinition
ApproveDesign
StartNextSlice
RecordHarnessEvent
CompleteAttempt
StartVerification
RecordVerificationCheck
CreateRepairAttempt
PublishPullRequest
RecordHumanDecision
CompleteWork
```
**Executor / integration gateway** — shared integration+tool gateway: GitHub, Slack, Linear, Intercom, analytics, CRM, cloud services, internal APIs, custom functions. Owns connection mgmt, tool catalog, scoped auth, tool-level policies, approval requirements, auditable invocation. Does **not** own: shell exec, repo fs ops, work-unit state, agent session state, sandbox lifecycle. Flow Agent may get broader org-level tools; OpenCode gets only project/run-scoped tools for the current task.
Example events:
**Model gateway** — all model calls route here. Provider credentials, auto routing, tenant attribution, cost accounting, budget enforcement, rate limits, retries/fallback, prompt/response logging under policy, redaction, run-scoped credentials. Don't expose model selection by default. Run token (short-lived, scoped): `{organization_id, project_id, work_unit_id, run_id, model_classes:["coding-standard","coding-fast"], budget_usd:8, expires_in_seconds:3600}`.
**Artifact storage** (object storage, not actor state/DB rows) — types: patch, commit bundle, test report, build log, screenshot, video, preview snapshot, research doc, review report, generated file. Metadata: tenant/work-unit/run scope, type, content hash, producer, source event, retention policy, access policy.
**Preview gateway** — controlled exposure (not raw sandbox ports): authenticated access, project/run-scoped routing, short-lived URLs, service health, automatic shutdown, event on ready/fail, screenshot/browser verification integration.
**Observability & billing** — every path emits logs, metrics, traces, cost, token use, runtime duration, tool calls, network use, artifact size, retry count, user-intervention count. Correlation dims: `organization_id, project_id, work_unit_id, run_id, actor_id, session_id, sandbox_id, sequence, timestamp`. Cost per successful outcome, not per model request.
## 3. Core execution flows
**Project onboarding.** User creates project → Project service creates/imports/connects repo → apply template (commit initial files) → Context service drafts context docs → user reviews/commits canonical docs → template runs setup+verify → Snapshot service creates sanitized base snapshot → project active.
**Prompt → candidate work.** User describes request → Conversation persists scoped message → publishes extraction job → Flow searches related work/sources → creates/updates signal → if existing work matches: attach signal + update understanding; else if new outcome: create candidate → Work graph publishes materialized update → realtime shows updated/new card.
**Accepted work → verified PR.** User approves candidate → accept work unit → Flow persists objective + acceptance criteria → Scheduler creates implementation run → AgentOS provisions run actor → Workbox creates isolated sandbox → clone repo + create work branch → start OpenCode session → send run context pack. **Execution loop:** harness inspects/edits/executes, streams events/requests to AgentOS, which persists meaningful events → work card updates. On completion: run verification → upload reports/screenshots → commit/push branch → Flow creates PR + attaches artifacts + moves to Review → UI shows PR/preview/evidence/human action.
**Approval continuation.** Harness requests sensitive action → AgentOS → Policy evaluates action/scope: auto-allow · or → creates approval request to attention queue → user sees action/evidence/risk/impact → approve/modify/reject → policy persists decision → AgentOS resumes with bounded permission · or → deny with reason.
## 4. State machines
Keep product, run, and environment state separate. Frontend primarily exposes work-unit state; run state = operational detail; Workbox state hidden except debugging/delayed startup.
```
Work unit: proposed → clarifying → ready → active → waiting|review|monitoring → done
modifiers: blocked, waiting_on_user, waiting_on_dependency, approval_required, autonomous, at_risk, scheduled
Run: queued → provisioning → preparing → running → waiting_for_input|verifying → succeeded|failed|cancelled
Workbox: creating → preparing → ready → busy → sleeping|waking → destroyed
```text
SignalCreated
SignalAttached
WorkProposed
DefinitionGenerated
DefinitionApproved
DesignGenerated
DesignApproved
SliceStarted
AttemptStarted
AttemptCompleted
VerificationPassed
VerificationFailed
QuestionOpened
QuestionAnswered
PullRequestCreated
WorkCompleted
```
## 5. Concurrency model
Every side-effecting command MUST include an idempotency key. Suggested key:
- One writable checkout per active mutating run; one branch per independently shippable work unit.
- Serialize mutating runs against same branch; permit parallel read-only analysis on immutable snapshots; permit parallel child work on separate branches; merge/integration = explicit controlled action.
- A work unit may use multiple runs: research → implementation → automated review → verification → human review.
- Never let agents share one writable working dir without deliberate coordination + conflict handling.
## 6. Persistence & recovery
Durable truth lives outside transient agent processes. Persist after every meaningful step: work-unit state changes, decisions, approval requests, agent summaries, audit-relevant tool results, commit/patch checkpoints, artifact metadata, verification results. Don't rely on running shells, in-memory harness state, open sockets, or uncommitted working dirs surviving. Before waiting for human input: persist findings, commit relevant files, upload artifacts, record exact requested decision, store resumable workflow state. All workflow steps idempotent or safely repeatable.
## 7. Security model
**Trust boundaries** — untrusted: imported repos, repo instructions, external docs, customer messages, MCP tools, tool responses, generated code, agent-authored summaries. A model recommendation is **not** an authorization decision.
**Layered authorization** — sensitive action requires ALL: `product policy AND harness permission AND runtime capability AND scoped credential`. Path: agent requests → harness permission check → Work OS policy eval → human approval when required → runtime capability check → credential minting → execution → provenance event.
**Workload identity** — each run gets short-lived identity: org_id, project_id, work_unit_id, run_id, initiating principal, permitted capabilities, expiration. Workbox exchanges it for scoped Git/model-gateway/integration/artifact/preview/cloud access. Never inject long-lived org credentials into sandboxes.
**Network policy** — deny-by-default or allowlisted by run type (package registry during setup, repo host, model gateway, approved external APIs; no unrestricted prod network by default).
**Secrets** — stored outside repos/actor state; org/project scoped; minted/injected only when required; short-lived; redacted from logs/transcripts; rotatable without rebuilding projects; audited on access.
**Data isolation** — every query/event/artifact/context lookup/runtime token is tenant-scoped. Cross-org portfolio views may aggregate summaries but never give one org's raw context to another org's agent run.
**Human approvals** required for: prod deployment, merging protected branches, accessing sensitive prod data, external communications, billing/financial changes, data deletion, org-level policy changes. Action-specific; expire after use or time.
**Auditability** — retain: who initiated, context provided, agent+model class, tools called, files changed, approvals requested/granted, artifacts produced, what was published/merged, outcome observed.
## 8. Scaling model
Scale via many small isolated actors + queued runs (not long-lived per-user machines).
**Tenant scheduler** enforces: max concurrent runs per org/project, priority classes, fair scheduling, subscription limits, model/sandbox budget, retry/runtime-duration limits. **Work queue** states: `accepted → queued → leased → running → completed`. Leases idempotent + recoverable (prevent duplicate expensive execution). **Resource limits** (hard + soft): tokens/run, cost/run, runtime duration, tool calls, file count + repo size, network bytes, process count, memory/CPU, artifact size, subagent depth, repeated-failure count. **Artifact offloading** — large logs/binaries → object storage; keep refs in work graph. **Snapshots** — sanitized reusable snapshots accelerate setup. Layers: base OS → language/toolchain → template → project deps. Never snapshot: long-lived creds, user secrets, active tokens, unreviewed prod data. **Multi-region** (not MVP unless demanded) — keep control-plane relational consistency explicit: org home region, region-local execution actors + artifact/cache placement, global routing to org's control plane, explicit data-residency restrictions.
## 9. Better-T-Stack monorepo mapping
```
apps/ web, expo, api, worker, agent-runtime, preview-gateway
packages/ db, auth, domain, api-contract, events, realtime, work-graph, conversation,
flow-agent, context, policy, scheduler, integrations, model-gateway, git,
artifacts, sandbox, agentos, opencode, observability, billing, ui, config
```text
<workId>:<commandType>:<logicalVersion>
```
Boundaries: **domain**=pure types + state transitions · **events**=envelopes/schemas/reducers/provenance · **work-graph**=work-entity queries + materialized views · **conversation**=message persistence/scopes/extraction jobs/source links · **flow-agent**=orchestration/planning/run coordination · **context**=canonical loading/retrieval/context-pack construction · **policy**=evaluation + approval workflows · **scheduler**=queues/leases/quotas/workflows/retries/cancellation · **agentos**=client/actor naming/session lifecycle/event translation · **opencode**=harness config/session prompts/tool policy/result normalization · **sandbox**=provider adapters/workspace lifecycle/commands/logs/previews · **git**=repo provisioning/branches/commits/pushes/PRs/mirrors · **integrations**=executor connection mgmt + tool scopes · **model-gateway**=provider routing/budgets/credentials/cost attribution.
External artifact creation stores provider ID before transition completion to prevent duplicate PRs/deployments.
## 10. API & command design
## 7. Effect service boundaries
Prefer domain commands over generic entity mutation. Examples: `CreateProject, ImportRepository, CreateWorkUnit, AcceptCandidate, AttachSignal, MergeCandidates, StartWork, UpdateRequirements, ApprovePlan, ResolveBlocker, RequestChanges, PauseRun, CancelRun, ApproveAction, RejectAction, ApproveMerge, ApproveDeployment, ReopenWorkUnit`. Each command: validate tenant scope + current state; idempotent where practical; emit ≥1 domain event; no direct client control of infra details.
Keep domain/application code provider-neutral.
## 11. Event translation (harness → product)
```ts
interface HarnessRuntime {
open(input: OpenHarnessInput): Effect.Effect<HarnessSession, HarnessError>
prompt(id: string, content: string): Effect.Effect<void, HarnessError>
events(id: string): Stream.Stream<HarnessEvent, HarnessError>
approve(input: PermissionDecision): Effect.Effect<void, HarnessError>
abort(id: string): Effect.Effect<void, HarnessError>
close(id: string): Effect.Effect<void, HarnessError>
}
Preserve raw harness events for audit; translate to human-meaningful product events:
interface SandboxRuntime {
create(spec: SandboxSpec): Effect.Effect<SandboxLease, SandboxError>
exec(lease: SandboxLease, cmd: Command): Effect.Effect<CommandResult, SandboxError>
readFile(lease: SandboxLease, path: string): Effect.Effect<Uint8Array, SandboxError>
writeFile(lease: SandboxLease, path: string, body: Uint8Array): Effect.Effect<void, SandboxError>
pause(lease: SandboxLease): Effect.Effect<void, SandboxError>
resume(id: string): Effect.Effect<SandboxLease, SandboxError>
terminate(lease: SandboxLease): Effect.Effect<void, SandboxError>
}
```
tool_call bash "pnpm test" → run.verification_started
tool_result exit_code=0 → run.test_suite_passed
permission_request git_push → approval.requested
file_edit src/auth/callback.ts → artifact.code_change_updated
interface SourceControl {
prepareWorktree(input: WorktreeInput): Effect.Effect<Worktree, GitError>
diff(worktree: Worktree): Effect.Effect<DiffArtifact, GitError>
commit(input: CommitInput): Effect.Effect<CommitArtifact, GitError>
push(input: PushInput): Effect.Effect<BranchArtifact, GitError>
createPullRequest(input: PullRequestInput): Effect.Effect<PullRequestArtifact, GitError>
}
interface VerificationRuntime {
execute(plan: VerificationPlan, env: EnvironmentRef):
Effect.Effect<VerificationResult, VerificationError>
}
```
Primary UI hides raw transcripts unless user opens technical details.
Also define:
## 12. Implementation sequence
```text
ArtifactStore | SecretBroker | EventJournal | PreviewRuntime | RuntimePolicy
```
- **Phase 1 — Durable product model:** orgs, projects, work units, candidates, runs, artifacts, conversation scopes, event log, card projections.
- **Phase 2 — One complete coding loop:** one template project, managed Git, one sandbox provider, one AgentOS actor/run, OpenCode harness, model gateway, branch creation, tests, artifact upload, PR output, human approval.
- **Phase 3 — Context & memory:** canonical Markdown files, context-pack construction, retrieval index, decision memory, source provenance.
- **Phase 4 — Autonomous discovery:** continuous signal ingestion, candidate extraction, duplicate detection, existing-work matching, attention queue.
- **Phase 5 — Additional integrations/workflows:** external Git, support, team comms, issue trackers, analytics, deployment + monitoring.
Use `Layer` for adapters, `Scope` for leases/processes, `Stream` for events, `Schedule` for bounded retries, and supervised fibers for long-running consumers. Pin Effect version and isolate beta API churn.
## 13. MVP success criteria
## 8. FLUE responsibilities
Reliably demonstrate: `Prompt → candidate/work unit → clarified objective → isolated coding run → tested change → preview/evidence → pull request → human review → durable completed work card`. The work unit must remain understandable + resumable even if the harness session or Workbox is destroyed.
Use FLUE for domain-specific agents/workflows:
## 14. Non-goals (first release)
- conversation response and Signal proposal;
- Work Definition compiler;
- impact analysis;
- architecture/program design;
- vertical-slice planning;
- Resolver decision support;
- verification-plan generation;
- maintainability review;
- result/learning synthesis.
Every harness; every sandbox provider; arbitrary multi-agent swarms; full replacement of external Git providers; multi-region active-active control plane; unlimited imported-repo compatibility; automatic prod deployment without approval; generic low-code workflow builder; exposing model selection + agent internals to users.
FLUE output MUST be typed proposals/commands, validated by application policy.
## 15. Architectural principles
FLUE MUST NOT directly:
1. Durable work state outlives execution environments. 2. Every execution path is tenant-scoped. 3. One writable checkout per mutating unit of work. 4. Persist events before projecting summaries. 5. Treat agent outputs as claims with provenance, not unquestioned truth. 6. Separate product policy, harness permission, and runtime capability. 7. Short-lived credentials + explicit workload identity. 8. Keep harnesses, models, sandboxes, and Git providers replaceable. 9. Hide infrastructure state from the product surface unless actionable. 10. Measure successful outcomes and human attention, not merely agent activity.
- mutate arbitrary database tables;
- bypass actor lifecycle;
- grant itself tools;
- merge/deploy outside policy;
- mark Work complete without evidence.
## 9. Harness strategy
Harness is replaceable:
```text
HarnessRuntime
├── OmpHarnessLive
├── OpenCodeHarnessLive
├── CodexHarnessLive
├── PiHarnessLive
└── FlueHarnessLive
```
v0 selects one harness. Prefer mature coding harnesses for repository exploration/edit/test loops; use custom FLUE agents for product-specific reasoning.
Zopu owns Work, branches, worktrees, budgets, approvals, artifacts, and completion. Harness owns one bounded implementation loop.
## 10. Runtime strategy
### CubeSandbox
Best for full Linux execution:
- native binaries;
- Bun/Node/Python;
- browsers;
- databases/services;
- project builds/tests;
- pause/resume;
- strong isolation.
Use E2B-compatible SDK behind `SandboxRuntime`. OMP runs as a process inside the Cube microVM.
### AgentOS
Best for:
- lightweight durable agent environments;
- ACP-integrated software;
- actor-adjacent orchestration;
- context/files/networking that fit runtime limits.
Use an attached full sandbox when native/heavy tooling is needed.
### Persistent project machine
Later optimization for long-lived caches, large repositories, and developer-customized environments. Use Git worktrees per active Work. Do not make it the only isolation boundary.
### Runtime selection
The Resolver requests capabilities:
```text
writable repo, Bun, browser, PostgreSQL, 8 GB RAM, network policy
```
`RuntimePolicy` chooses provider. Product logic never branches on Cube/AgentOS directly.
## 11. Repository isolation
Initial safe model:
```text
one project repository mirror
one Git worktree per active Work/slice
one mutating attempt per worktree
```
Rules:
- attempts receive scoped worktrees;
- parallel mutating attempts use separate branches;
- credentials are short-lived and repository-scoped;
- host home directories are never mounted;
- model credentials are run-scoped;
- untrusted code runs in sandbox;
- output commits record base and candidate SHA.
## 12. Planning and design artifacts
Required for standard work:
```text
WorkDefinition
ImpactMap
DesignPacket
VerticalSlicePlan
VerificationPlan
```
Program design SHOULD include:
- expected file-tree delta;
- expected call-flow delta;
- key types/signatures;
- dependency direction;
- invariants;
- security/data boundaries;
- known deviations.
The verifier compares candidate code against this design, but metrics are evidence, not absolute truth.
## 13. Verification architecture
Verification layers:
```text
Static
├── format/lint/typecheck
├── dependency policy
├── secret scan
└── static security
Behavior
├── unit
├── integration
├── contract
└── property tests where useful
Product
├── browser/user flow
├── screenshots/video
├── accessibility
└── visual checks
Operational
├── build/start/health
├── migration/rollback
├── logs
└── resource limits
Design
├── expected vs actual files/interfaces
├── dependency graph delta
├── design deviations
└── maintainability review
```
A `VerificationResult` MUST bind checks to candidate commit and environment.
Repair loop:
```text
failure evidence → bounded repair attempt → clean verification rerun
```
Tests added by the implementation SHOULD fail on the base revision and pass on the candidate when practical.
## 14. Delivery and integration
Publishing order:
```text
slice checks pass
→ integrated candidate created
→ impacted checks on integrated SHA
→ commit/push
→ PR
→ review package
```
Never create a “verified PR” from a different SHA than the verified candidate.
Review package:
- original intent;
- approved definition/design;
- slice narrative;
- meaningful diffs;
- screenshots/video;
- verification evidence;
- deviations/risks;
- exact commit/PR.
Manual merge remains policy in v0.
## 15. Preview and release
Preview is an artifact, not an open random port. `PreviewRuntime` may use:
- sandbox-exposed app;
- agentOS Apps for compatible generated HTTP apps;
- external staging/deployment.
Production release requires explicit policy, rollout plan, health signals, and rollback trigger.
## 16. Security baseline
- private control-plane endpoints;
- authenticated Cube/Rivet APIs;
- scoped runtime tokens;
- no long-lived model/Git secrets in workspace files;
- network egress policy;
- artifact access authorization;
- immutable audit trail for approvals;
- tool allowlist per Kit;
- destructive tools denied by default;
- merge/deploy human-gated initially;
- cleanup of terminated sandboxes and credentials.
## 17. Observability
Record product-level events, not only infrastructure logs.
Required dimensions:
```text
projectId workId sliceId runId attemptId actorId
kitVersion harness runtime model baseSha candidateSha
```
Track:
- state-transition latency;
- attempt duration/outcome;
- retries/replans;
- verification checks;
- human wait time;
- token/compute cost;
- duplicate side effects;
- abandoned/stale Work;
- post-release failures.
Raw harness logs are retained as artifacts; UI consumes normalized events.
## 18. Initial deployment shape
```text
Web + API + Convex
Bun/Effect daemon
Rivet cluster/actors
├── FLUE agents/workflows
└── SandboxRuntime
└── CubeSandbox on dedicated/VDS host
└── OMP + repo + tests
```
Keep Cube control APIs private; expose only authorized preview paths. Rivet and execution daemon may share the VPS initially but remain separate deployable processes.
## 19. Technical acceptance for v0
The architecture is proven when one real repository supports:
```text
message → Signal → approved Work → approved Design Packet
→ one slice → isolated harness run → independent verification
→ verified commit → real PR → human response/resume
```
With:
- durable recovery;
- no duplicate Work/PR;
- bounded retries;
- exact evidence;
- cancellation;
- explicit terminal states.

328
docs/agent-context.md Normal file
View File

@@ -0,0 +1,328 @@
# Zopu Work OS — Agent Context
> **Purpose:** compact bootstrap context for Codex/OMP/OpenCode/FLUE workers contributing to Zopu.
> **Usage:** read this first, then load only task-relevant documents and repository files.
## 1. Mission
Build Zopu: a Work OS that turns conversation and external Signals into durable, verified outcomes.
```text
Signal → Work → Definition → Design → Vertical Slices
→ Resolver → Attempts → Verification → Delivery → Result → Learning
```
The product is not chat threads, an issue tracker, a harness UI, or an autonomous PR factory.
## 2. Source-of-truth order
```text
1. current accepted Work/Question/Decision
2. approved Work Definition
3. approved Design Packet + current Slice
4. repository tests/types/code
5. product.md
6. tech.md
7. design.md
8. dev-loop.md
9. slices.md
10. dev-plan.md
11. glossary.md
12. evaluation.md
```
When sources conflict, report the conflict. Do not silently choose a convenient interpretation.
## 3. Current product target
Initial user:
```text
technical founder / small engineering team
one project + one Git repository
web chat + Work cards
manual merge
```
Initial complete loop:
```text
message
→ Signal
→ approved Work
→ approved Design Packet
→ one bounded coding slice
→ independent verification
→ exact verified commit
→ real PR
→ human intervention/resume
```
## 4. Non-negotiable invariants
1. Work is a durable outcome, not a chat/session/issue/PR.
2. Exact Signal provenance is preserved.
3. State transitions are explicit commands/events.
4. FLUE/model output is validated proposal data, not authority.
5. Rivet actors own serialized lifecycle/recovery.
6. Harnesses and runtimes are replaceable adapters.
7. One mutating Attempt owns one isolated worktree.
8. Every Attempt is bounded and terminally classified.
9. Builder output remains candidate output until independent verification.
10. Evidence binds to exact revision and environment.
11. Published PR head equals verified integrated SHA.
12. Human questions/approvals are durable objects.
13. Retry/restart must not duplicate Work, commits, PRs, or deployments.
14. Merge/deploy remain human-gated initially.
15. Completion requires outcome evidence, not an agents “done.”
## 5. System ownership
```text
Work OS durable intent/state/evidence/policy
Rivet actors identity, serialization, timers, recovery
FLUE domain agents/workflows and typed proposals
Harness bounded coding/tool loop
Sandbox filesystem/process/network isolation
Git source history
ArtifactStore durable output/evidence
UI/Buzz interaction surfaces, not workflow truth
```
## 6. Initial actor shape
```text
ProjectActor
├── project config + active Work index
WorkActor
├── definition/design/slices
├── resolver state
├── questions/approvals
├── artifacts/result
AttemptActor
├── runtime lease
├── harness session
├── normalized events
├── timeout/cancellation/outcome
```
Add Verification/Integration/Result actors only when their lifecycles justify separation.
## 7. Initial adapters
Preferred first implementation:
```text
FLUE definition/design/resolver support
Rivet actors
CubeSandbox full Linux runtime
OMP first coding harness, replaceable
Git forge branch/commit/PR
Convex durable application data/projections where used
Effect application ports/layers/errors/scopes/streams
```
Do not import provider-specific types into domain modules.
## 8. Working protocol for every code task
### Before editing
1. Identify current vertical slice and acceptance criteria.
2. Read relevant docs and repository rules.
3. Inspect current architecture, tests, and existing abstractions.
4. State impacted modules and risks.
5. Produce/confirm a small implementation plan.
6. Ask a Question only when ambiguity changes behavior, security, data, or architecture.
### During implementation
1. Stay within the current slice.
2. Preserve dependency direction.
3. Prefer existing patterns over parallel frameworks.
4. Add typed boundaries and tagged errors.
5. Make external effects idempotent.
6. Add cancellation/timeouts for long-running operations.
7. Record exact revisions/provider identifiers.
8. Add tests with the implementation.
9. Do not weaken unrelated tests or hide failures.
10. Explain intentional Design Packet deviations.
### Before declaring candidate ready
Run task-relevant checks:
```text
format/lint/typecheck
focused unit/integration tests
behavioral/live check
security/secret check where relevant
design-conformance review
```
Report:
```text
files changed
behavior implemented
checks run + results
known risks/deviations
artifacts/revision
remaining blockers
```
Never claim verified unless the independent verification path ran.
## 9. Code organization rule
Use logical separation:
```text
domain/ pure types/state/invariants
application/ use cases/commands
ports/ Effect services
adapters/ FLUE/Rivet/Cube/OMP/Git implementations
```
Keep the initial package count practical. A folder boundary is enough until independent reuse/lifecycle exists.
## 10. State modeling rule
Prefer explicit state machines over booleans.
Bad:
```ts
isRunning: boolean
isDone: boolean
hasError: boolean
```
Good:
```ts
type AttemptStatus =
| "queued"
| "running"
| "succeeded"
| "retryable_failure"
| "needs_input"
| "blocked"
| "verification_failed"
| "budget_exhausted"
| "cancelled"
| "permanent_failure"
```
Transitions must validate current version/state.
## 11. Model/agent output rule
Agents return schemas such as:
```text
SignalProposal
WorkDefinitionProposal
DesignPacketProposal
ResolverDecisionProposal
VerificationAssessment
LearningProposal
```
Application code decodes, authorizes, and executes commands. Agents do not directly mutate arbitrary state.
## 12. Quality rule
For every behavior, identify:
```text
acceptance criterion
implementation evidence
independent check
exact candidate revision
review requirement
```
Tests prove immediate behavior. Design review protects maintainability and future change cost. Both matter.
## 13. UI rule
Expose:
```text
outcome
phase
latest meaningful update
next action/blocker
slice progress
evidence
delivery/result
```
Hide low-level harness noise by default. Preserve raw logs in diagnostics.
## 14. Scope control
Do not implement unless required by the current slice:
- dynamic Kit Builder;
- multiple harness providers;
- large fleets;
- autonomous merge/deploy;
- universal workflows;
- automatic canonical-memory mutation;
- sophisticated multi-tenancy.
Design replaceable boundaries, then ship one path.
## 15. Task completion template
```md
## Implemented
- ...
## Behavior
- ...
## Verification run
- `command` — pass/fail
- ...
## Evidence
- candidate SHA/artifacts
- ...
## Design deviations
- none / ...
## Remaining risks or blockers
- none / ...
```
## 16. Required reading by task type
| Task | Read |
|---|---|
| product/domain | product.md, glossary.md |
| UI | design.md, product.md |
| actors/state | tech.md, glossary.md |
| orchestration | dev-loop.md, tech.md |
| current sequencing | slices.md, dev-plan.md |
| verification/evals | evaluation.md, dev-loop.md |
| provider adapter | tech.md plus provider docs |
| broad feature | agent-context.md + all relevant sections |
## 17. Stop and escalate when
- accepted definition/design is missing or contradictory;
- required permission is unavailable;
- worktree/revision identity is uncertain;
- a destructive action is outside policy;
- verification cannot bind to exact candidate;
- implementation requires expanding scope materially;
- a retry would repeat a non-transient failure;
- repository state suggests another active mutating owner.
Return a precise Question or blocker, not a guess.

233
docs/deployment.md Normal file
View File

@@ -0,0 +1,233 @@
# Deployment Notes
Two active environments share one Convex deployment (`dev:befitting-dalmatian-161`).
Each runs its own web server, Flue agents process, and `.env` file.
## Environments
| | Local Mac Dev | Cheaptricks Staging |
| --- | --- | --- |
| SSH alias | (local) | `cheaptricks` |
| Repo path | `/Users/puter/Workspace/zopu/code` | `/workspace/code` |
| Tailscale IPv4 | `100.101.157.28` | `100.122.185.111` |
| Web URL | `http://100.101.157.28:5173` | `https://zopu.cheaptricks.puter.wtf` |
| Flue URL (internal) | `http://100.101.157.28:3585` | `http://127.0.0.1:3585` |
| Flue URL (browser-facing) | `http://100.101.157.28:3585` | `https://zopu.cheaptricks.puter.wtf/api` |
| Caddy | none (direct Tailscale) | `zopu.cheaptricks.puter.wtf` HTTPS termination |
| Bun | installed directly | symlink at `/workspace/.bun` |
| Process manager | manual `bun run dev:tailscale:*` | `nohup` into `/tmp/zopu-*.log` |
## Shared Convex deployment
Deployment name: `dev:befitting-dalmatian-161`
Convex URL: `https://befitting-dalmatian-161.convex.cloud`
Convex Site URL: `https://befitting-dalmatian-161.convex.site`
Convex env vars are deployment-scoped, not per-machine. Authenticate from any
machine with `npx convex dev` inside `packages/backend`.
Key Convex env var:
```
SITE_URL = <the origin Better Auth should trust>
```
This controls `trustedOrigins` in the Better Auth config
(`packages/backend/convex/auth.ts`). It must match the URL the browser
actually visits, or sign-in fails silently with a CORS rejection.
When switching between environments:
```bash
cd packages/backend
# For local Mac testing:
npx convex env set SITE_URL 'http://100.101.157.28:5173'
# For Cheaptricks staging:
npx convex env set SITE_URL 'https://zopu.cheaptricks.puter.wtf'
```
## Local Mac Dev `.env`
```env
CONVEX_DEPLOYMENT=dev:befitting-dalmatian-161
CONVEX_URL=https://befitting-dalmatian-161.convex.cloud
CONVEX_SITE_URL=https://befitting-dalmatian-161.convex.site
SITE_URL=http://100.101.157.28:5173
NATIVE_APP_URL=code://
VITE_CONVEX_URL=https://befitting-dalmatian-161.convex.cloud
VITE_CONVEX_SITE_URL=https://befitting-dalmatian-161.convex.site
VITE_FLUE_URL=http://100.101.157.28:3585
DAEMON_ID=local-macbook
DAEMON_NAME=Local MacBook
DAEMON_VERSION=0.0.0
DAEMON_HEARTBEAT_MS=15000
DAEMON_COMMAND_LEASE_MS=60000
FLUE_DB_TOKEN=<from secrets manager>
AGENT_MODEL_PROVIDER=xiaomi
AGENT_MODEL_NAME=mimo-v2.5
AGENT_MODEL_API=openai-completions
AGENT_MODEL_BASE_URL=<cheaptricks gateway base url>
AGENT_MODEL_API_KEY=<cheaptricks api key>
AGENT_MODEL_CONTEXT_WINDOW=1048576
AGENT_MODEL_MAX_TOKENS=131072
GITEA_URL=https://git.openputer.com
GITEA_TOKEN=<gitea personal access token>
```
### Start local dev
```bash
bun run dev:tailscale:agents -- --port 3585 &
bun run dev:tailscale:web &
```
Both bind `0.0.0.0` so they are reachable over Tailscale from a phone.
Before testing locally, flip the Convex `SITE_URL`:
```bash
cd packages/backend && npx convex env set SITE_URL 'http://100.101.157.28:5173'
```
## Cheaptricks Staging `.env`
Located at `/workspace/code/.env` on the `cheaptricks` host.
```env
CONVEX_DEPLOYMENT=dev:befitting-dalmatian-161
CONVEX_URL=https://befitting-dalmatian-161.convex.cloud
CONVEX_SITE_URL=https://befitting-dalmatian-161.convex.site
SITE_URL=https://zopu.cheaptricks.puter.wtf
NATIVE_APP_URL=code://
VITE_CONVEX_URL=https://befitting-dalmatian-161.convex.cloud
VITE_CONVEX_SITE_URL=https://befitting-dalmatian-161.convex.site
VITE_FLUE_URL=https://zopu.cheaptricks.puter.wtf/api
VITE_ZOPU_SERVER_URL=https://zopu.cheaptricks.puter.wtf/api
PORT=3590
DAEMON_ID=local-macbook
DAEMON_NAME=Local MacBook
DAEMON_VERSION=0.0.0
DAEMON_HEARTBEAT_MS=15000
DAEMON_COMMAND_LEASE_MS=60000
FLUE_DB_TOKEN=<from secrets manager>
AGENT_MODEL_PROVIDER=xiaomi
AGENT_MODEL_NAME=mimo-v2.5
AGENT_MODEL_API=openai-completions
AGENT_MODEL_BASE_URL=https://ai.cheaptricks.puter.wtf/v1
AGENT_MODEL_API_KEY=<cheaptricks api key>
AGENT_MODEL_CONTEXT_WINDOW=1048576
AGENT_MODEL_MAX_TOKENS=131072
GITEA_URL=https://git.openputer.com
GITEA_TOKEN=<gitea personal access token>
```
### Caddy config
File: `/etc/caddy/Caddyfile` on `cheaptricks`
```
zopu.cheaptricks.puter.wtf {
bind 135.181.82.179 2a01:4f9:c013:4a64::1
encode zstd gzip
handle_path /api/* {
reverse_proxy 127.0.0.1:3585
}
reverse_proxy 127.0.0.1:5173
}
```
`/api/*` routes to the Flue agents process (port 3585).
Everything else routes to the web dev server (port 5173).
Reload after changes:
```bash
sudo systemctl reload caddy
```
### Vite allowedHosts
`apps/web/vite.config.ts` must include `server: { allowedHosts: true }` or
Vite rejects requests arriving through the Caddy domain.
### Start staging dev
```bash
ssh cheaptricks
export PATH=$PATH:/workspace/.bun/bin
cd /workspace/code
# Pull latest
git pull origin feat/web-integrarion
bun install
# Start both processes with nohup so they survive SSH disconnect
nohup bun run dev:tailscale:agents -- --port 3585 > /tmp/zopu-agents.log 2>&1 &
nohup bun run dev:tailscale:web > /tmp/zopu-web.log 2>&1 &
```
Before testing on staging, flip the Convex `SITE_URL`:
```bash
cd packages/backend && npx convex env set SITE_URL 'https://zopu.cheaptricks.puter.wtf'
```
### Verify staging
```bash
curl -sS -o /dev/null -w '%{http_code}\n' https://zopu.cheaptricks.puter.wtf/
# expect: 200
curl -sS -D - -o /dev/null \
'https://befitting-dalmatian-161.convex.site/api/auth/get-session' \
-H 'Origin: https://zopu.cheaptricks.puter.wtf' | grep access-control-allow-origin
# expect: access-control-allow-origin: https://zopu.cheaptricks.puter.wtf
```
## Model configuration
Both environments use the same model via the Cheaptricks AI gateway:
- Provider identity: `xiaomi` (Flue catalog maps this to MiMo multimodal metadata)
- Model: `mimo-v2.5`
- API protocol: `openai-completions`
- Context window: `1048576`
- Max output tokens: `131072`
- Multimodal: text + image input
The `AGENT_MODEL_BASE_URL` differs:
- Local Mac: uses the external gateway URL
- Cheaptricks: uses `https://ai.cheaptricks.puter.wtf/v1` (local to the box)
## Known gotchas
1. **Convex SITE_URL is single-valued.** Only one origin can be trusted at a
time. Switch it when moving between local and staging, or sign-in breaks
silently with a CORS error in the browser console.
2. **Vite blocks unknown hosts by default.** Caddy domain must be allowed
via `server.allowedHosts` in `apps/web/vite.config.ts`.
3. **Flue port changed from 3583 to 3585.** The old Caddy config pointed at
3583/13100. Current ports are 3585 (Flue) and 5173 (web).
4. **`.env` is gitignored.** Each machine maintains its own copy. The repo
ships `.env.example` as the template.
5. **Convex CLI auth is per-machine.** Run `npx convex dev` once inside
`packages/backend` on each new machine to authenticate the CLI.

705
docs/dev-loop.md Normal file
View File

@@ -0,0 +1,705 @@
# Zopu Work OS — Software Development Loop
> **Purpose:** canonical operating process for producing high-quality software with humans and agents.
> **Applies to:** Zopu product development and software Work resolved by Zopu.
> **Core principle:** accelerate implementation without removing design judgment, independent verification, or release learning.
## 1. The model
Software delivery contains two nested loops.
### Outer product loop
```text
Observe problem
→ define outcome
→ design solution
→ deliver slices
→ release
→ observe result
→ learn/revise
```
### Inner slice loop
```text
Select approved slice
→ prepare isolated candidate
→ implement
→ verify behavior
→ verify design conformance
→ review/resteer
→ integrate
```
The outer loop proves that the right problem was solved. The inner loop proves that each implementation increment is correct and preserves the intended system shape.
The system optimizes:
```text
verified progress / human-attention minute
```
It does not optimize commits, tokens, agents, PR count, or code volume.
## 2. Why this process exists
Agent harnesses compress implementation time but do not remove these failure modes:
- ambiguous requirements implemented literally;
- locally correct code that damages system boundaries;
- horizontal plans whose behavior is visible only at the end;
- tests that pass while the user path is wrong;
- builder self-certification;
- parallel changes that fail when integrated;
- merged code that does not solve the original problem;
- lessons lost after delivery.
Therefore decisions move earlier and implementation is divided into reviewable vertical slices.
## 3. Delivery routes
Risk determines ceremony.
### Fast lane
Use for isolated, reversible, deterministic changes.
```text
Work Definition
→ compact design note
→ implement
→ automated verification
→ PR
```
Required human gate: merge or policy exception.
### Standard lane
Use for normal product behavior, APIs, UI flows, and multi-module changes.
```text
Work Definition
→ impact analysis
→ program design
→ 14 vertical slices
→ slice verification/review
→ integrated verification
→ PR
```
Required gates: definition/design approval and merge.
### Critical lane
Use for authentication, authorization, billing, migrations, destructive operations, secrets, shared infrastructure, or high blast radius.
```text
Work Definition approval
→ architecture approval
→ program-design approval
→ one slice at a time
→ security/rollback verification
→ staged release approval
→ observation
```
A risk classifier may recommend a lane; policy makes the final decision.
## 4. Phase A — Problem and product definition
### Inputs
- user conversation;
- Signals and provenance;
- incidents/analytics/support;
- project strategy and constraints;
- existing related Work.
### Required artifact: Work Definition
```text
problem
affected users
desired observable outcome
success signals
in scope / out of scope
acceptance criteria
constraints
assumptions
unresolved questions
risk lane
required artifacts
release/rollback requirements
```
### Rules
- Acceptance criteria describe behavior, not implementation.
- Assumptions are explicit and labeled.
- Important ambiguity becomes a Question, not hidden prompt context.
- The original Signal remains attached.
- A changed definition creates a new version and invalidates dependent approvals.
### Gate
Proceed when the definition is testable, important questions are resolved, and required approval exists.
## 5. Phase B — System impact and architecture
### Goal
Determine how the change interacts with the existing system before code is generated.
### Required artifact: Impact Map
```text
directly changed components
potentially affected components
data/security boundaries
public contracts
migrations and compatibility
operational effects
required verification categories
```
### Architecture artifact
Use only the detail needed for the Work:
- component/sequence diagram;
- API/event contracts;
- data ownership;
- trust and permission boundaries;
- migration/rollout shape;
- failure and recovery paths.
Architecture describes component interaction. It does not replace program design.
### Gate
Proceed when affected boundaries and externally visible contracts are understood.
## 6. Phase C — Program design
### Goal
Make implementation intent cheap to review before code volume exists.
### Required artifact: Design Packet
```text
architecture summary
expected file-tree delta
expected call-flow delta
key types/interfaces/signatures
dependency direction
invariants
error model
state transitions
security/operational concerns
trade-offs
known deviations
```
Example file-tree intent:
```diff
packages/resolver/
+ domain/resolution-decision.ts
+ application/resolve-next-slice.ts
+ ports/harness-runtime.ts
+ adapters/omp-harness-live.ts
```
Example call-flow intent:
```text
WorkActor.start
→ resolveNextSlice
→ AttemptActor.start
→ SandboxRuntime.create
→ HarnessRuntime.open/prompt
→ normalize outcome
→ VerificationRuntime.execute
→ WorkActor.transition
```
### Review questions
- Is the dependency direction correct?
- Does one module own each invariant?
- Is the next likely change still local?
- Is complexity introduced where it belongs?
- Are persistence and external side effects idempotent?
- Are failure, cancellation, and recovery explicit?
- Can the behavior be verified independently?
### Gate
Standard/critical Work requires an approved Design Packet. Small fast-lane Work may use a compact design note.
## 7. Phase D — Vertical slice planning
### Goal
Turn design into increments that are observable, executable, and reviewable.
### Slice contract
```text
objective
user/system behavior exposed
planned code boundaries
inputs/dependencies
expected outputs/artifacts
verification checks
review requirement
budget/retry policy
```
### Valid slice
A slice:
- crosses the layers needed to expose behavior;
- produces evidence before later slices;
- can be reverted or revised without discarding the full implementation;
- is small enough for one focused review;
- does not postpone all integration/testing until the end.
### Invalid horizontal plan
```text
build all database code
→ build all backend code
→ build all frontend code
→ test at end
```
### Preferred plan
```text
Slice 1: one durable Work transition visible in UI
Slice 2: one isolated execution attempt visible in UI
Slice 3: independent verification visible in UI
Slice 4: verified PR artifact visible in UI
```
Agents should receive only the current approved slice plus necessary surrounding context, not an invitation to redesign the whole system.
## 8. Phase E — Kit compilation
### Goal
Compile approved intent into an executable, policy-bound package.
### Inputs
```text
Work Definition
Design Packet
current Slice
project/repository policy
risk lane
tool/runtime/harness registry
previous evidence and decisions
```
### Output: immutable Execution Kit version
```text
roles
context selection
skills/instructions
tool grants
runtime capabilities
harness selection
source-control policy
output contracts
verification recipe
budgets/timeouts
human gates
escalation policy
```
v0 uses a static Coding Kit. Dynamic selection/composition follows only after repeated runs establish useful patterns.
### Kit invariants
- least-privilege tools;
- no self-granted capabilities;
- explicit model/runtime/harness;
- bounded compute and retries;
- exact expected artifacts;
- verifiers separated from builders;
- version recorded on every Run.
## 9. Phase F — Slice execution
### Preparation
The Resolver:
1. confirms the slice and design versions are current;
2. resolves dependencies;
3. creates Run and Attempt records;
4. prepares an isolated worktree/base SHA;
5. provisions runtime;
6. injects scoped context and credentials;
7. starts the selected harness.
### Implementer contract
The implementer receives:
```text
current slice only
accepted Work Definition
relevant Design Packet excerpt
repository rules
completion/evidence contract
known previous failures/decisions
explicit forbidden actions
```
It may inspect surrounding code as needed but must not expand scope silently.
### Normalized execution events
```text
MeaningfulProgress
ToolStarted/Completed
QuestionRaised
ArtifactProduced
CandidateReady
AttemptFailed
AttemptCancelled
```
Raw provider events remain diagnostics, not product state.
### Attempt terminal outcomes
```text
Succeeded
RetryableFailure
NeedsInput
Blocked
VerificationFailed
BudgetExhausted
Cancelled
PermanentFailure
```
No attempt remains indefinitely active. Timeouts and process death are classified outcomes.
## 10. Phase G — Independent verification
### Principle
Candidate output is not complete output.
### Verification layers
#### Static
- format/lint/typecheck;
- dependency policy;
- secret/security scan;
- generated-file consistency.
#### Behavioral
- focused unit/integration/contract tests;
- new tests fail on base and pass on candidate where practical;
- service startup and live API assertions;
- database/migration behavior.
#### Product
- real browser/user path;
- screenshots/video;
- accessibility/visual checks;
- frontend/backend interaction.
#### Operational
- build/start/health;
- logs and failure handling;
- resource limits;
- rollout/rollback checks.
#### Design
- expected vs actual files/interfaces/call paths;
- dependency graph changes;
- duplicated logic;
- unjustified casts/exceptions;
- explicit deviations from approved design.
### Verification record
Every check binds to:
```text
Work/slice/run/attempt
candidate SHA
environment/runtime
command or assertion
exit/status
evidence artifact
timestamp
verifier identity/version
```
### Repair loop
```text
failed evidence
→ Resolver classifies
→ bounded repair attempt
→ implementer receives exact failure
→ full required verification reruns
```
Passing only the failed command is insufficient when the candidate changed.
## 11. Phase H — Slice review and resteering
Policy decides whether human review is required after each slice.
### Review package
```text
slice objective
approved design intent
actual behavior
meaningful code narrative
verification evidence
design deviations
risks/questions
recommended decision
```
### Decisions
```text
Accept slice
Request repair
Revise design
Split/reorder slices
Stop/cancel Work
```
Resteering updates durable definition/design/slice versions. It is not an informal chat instruction lost inside a harness transcript.
## 12. Phase I — Integration
Individually valid slices may be invalid together.
### Integration flow
```text
compose candidate commits
→ resolve conflict/overlap
→ produce integrated SHA
→ run impacted + required checks
→ generate integrated review package
```
### Invariants
- the PR head equals the verified integrated SHA;
- checks identify exact revision/environment;
- overlapping assumptions are surfaced;
- no slice bypasses integrated verification;
- failures return to repair/replan, not silent merge.
## 13. Phase J — Delivery
### Delivery artifacts
```text
commit
branch
pull request
preview/staging URL
release notes
migration/rollback plan
```
Creating a PR is a delivery transition, not completion of the original outcome.
### Human review
The review experience should explain:
1. original intent;
2. accepted behavior;
3. architecture/program design;
4. slice narrative;
5. important diffs;
6. evidence;
7. deviations/risks;
8. exact revision and next action.
Manual merge remains the initial policy.
## 14. Phase K — Release and observation
For releasable Work:
```text
verified candidate
→ preview/staging
→ approval
→ rollout
→ health observation
→ accept or rollback
```
Observation compares:
```text
expected success signal
vs
actual production/user result
```
Final classifications:
```text
Achieved
PartiallyAchieved
NotAchieved
Regressed
Inconclusive
```
Failure or unexpected behavior creates new Signals linked to the original Work.
## 15. Phase L — Learning
After terminal Work, evaluate both output and process.
### Questions
- Was intent captured correctly?
- Did design predict implementation?
- Which slice boundaries worked poorly?
- Which attempts/retries occurred and why?
- Which context/tools were missing?
- Which verification found defects?
- Which defects escaped?
- Did human review request major changes?
- Did release achieve the result?
- Should project knowledge, a Kit, skill, tool, or policy change?
### Outputs
Reviewable proposals:
```text
knowledge update
architecture decision
test recipe
Kit/tool/skill revision
planning heuristic
follow-up Work
```
No automatic mutation of canonical knowledge.
## 16. Human attention model
Humans intervene at policy-defined gates, not every tool action.
### High-value gates
```text
definition approval
design approval
risk/permission exception
slice review
merge/release approval
result acceptance
knowledge update approval
```
### Question format
```text
decision required
why it blocks progress
recommended answer
alternatives
consequences
affected Work/slice/version
```
Answers are durable Decisions and resume the same Work.
## 17. Resolver decision table
| Condition | Resolver action |
|---|---|
| executable slice exists | start bounded Attempt |
| transient infrastructure/model failure | retry by policy |
| candidate fails checks | create repair Attempt with evidence |
| design appears invalid | enter Replanning |
| required information missing | open Question; enter NeedsInput |
| dependency unavailable | enter Blocked with recheck/escalation |
| budget exhausted | terminal outcome with evidence |
| all slices pass | integrated verification |
| integrated candidate passes | publish/review |
| release requires observation | enter Observing |
| result established | complete and synthesize learning |
## 18. Failure and recovery rules
- Durable state is written before external effects.
- External provider IDs are persisted before acknowledging completion.
- Commands are idempotent.
- Actor/process restart resumes from checkpoints.
- Leases expire and are reclaimed.
- Sandbox/harness termination is best-effort plus reconciliation.
- Stale callbacks are rejected by version/attempt identity.
- Every terminal failure includes diagnosis, evidence, and recommended next action.
## 19. Anti-patterns
Do not:
- send a vague issue directly to a fleet;
- let the harness own Work state;
- equate tests passing with maintainability;
- generate all layers before exposing behavior;
- let the builder be sole verifier;
- hide design changes inside implementation;
- merge independently verified branches without integrated checks;
- create duplicate PRs/deployments on retry;
- mark Work complete at PR creation;
- auto-update canonical knowledge from one run;
- add agents because parallelism appears impressive.
## 20. Definition of a high-quality delivered Work
```text
✓ original evidence preserved
✓ outcome and acceptance criteria approved
✓ risk lane correct
✓ impact/design reviewed as required
✓ vertical slices independently demonstrated
✓ every Attempt terminal and auditable
✓ candidate verified independently
✓ design deviations explicit
✓ integrated SHA verified
✓ review package understandable
✓ delivery/release policy satisfied
✓ actual result assessed
✓ learning captured as proposals
```
This document is the normative delivery process. `slices.md` defines how Zopu itself is built incrementally; this file defines how Zopu and its agents should build software.

445
docs/dev-plan.md Normal file
View File

@@ -0,0 +1,445 @@
# Zopu Work OS — Development Plan
> **Related:** `slices.md` defines product increments; `dev-loop.md` defines the normative delivery loop; `evaluation.md` defines promotion gates.
> **Purpose:** concrete implementation order for the repository.
> **Strategy:** one vertical product loop, contracts first, provider adapters second, generalization last.
## 1. Target demonstration
Use one real repository and one deterministic Work:
> Add `GET /health` returning `{ status: "ok", commit: "<sha>" }`, with automated and live HTTP verification, then create a verified PR.
The first release is successful only when the full path works from chat to exact PR evidence.
## 2. Engineering rules
1. Land domain contracts before parallel adapter/UI work.
2. Keep FLUE, Rivet, Cube, AgentOS, and harness types behind ports.
3. One side effect has one idempotency key.
4. One mutating attempt owns one worktree.
5. Every state transition has a command, event, actor, timestamp, and evidence reference.
6. Every async process has timeout, cancellation, retry policy, and terminal classification.
7. Builder output is candidate output until independent verification passes.
8. Do not generalize until two real use cases require it.
9. Do not add an agent role without an evaluation showing quality gain.
10. Keep manual merge/deploy gates initially.
## 3. Repository workstreams
Recommended logical ownership:
```text
A. Domain/actors
B. FLUE definition/design agents
C. Resolver/runtime/harness
D. Verification/Git
E. Web experience
F. Deployment/operations
```
Only parallelize after shared schemas and event contracts are merged.
## 4. Phase 0 — Baseline and contracts
### Deliverables
- repository architecture inventory;
- one canonical glossary;
- schemas for Message, Signal, Work, WorkDefinition, DesignPacket, VerticalSlice, Run, Attempt, Artifact, Question;
- Work/Attempt state machines;
- command/event list;
- port interfaces;
- static `CodingKitV0`;
- deterministic fixture project/task.
### Tests
- schema round trips;
- state transition property/table tests;
- idempotency tests;
- fake adapters;
- actor restart/replay tests.
### Exit gate
A fake end-to-end test can create Work, approve definition/design, run fake slices, verify, and produce a fake PR artifact.
## 5. Milestone A — Work exists (Slices 12)
### Issue order
1. Persist project messages.
2. Implement `ProcessMessage`.
3. FLUE structured Signal/Work proposal.
4. Signal fingerprinting and attach/create rules.
5. WorkActor/projection.
6. Reactive Work card.
7. Work Definition schema/versioning.
8. Definition compiler agent.
9. definition edit/approval/questions UI.
10. approval invalidation tests.
### Release gate
```text
message → exact Signal → proposed card → approved Work Definition
```
No runtime integration.
## 6. Milestone B — Work is executable (Slices 35)
### Issue order
1. ImpactMap/DesignPacket schemas.
2. design compiler and slice planner.
3. design review/version UI.
4. Resolver decision function as pure domain logic.
5. WorkActor resolver commands/events.
6. FakeHarness/FakeSandbox adapters.
7. AttemptActor lifecycle.
8. CubeSandbox adapter.
9. selected harness adapter (OMP first unless spike rejects it).
10. Git worktree preparation.
11. normalized activity stream.
12. cancellation/timeout/cleanup.
### Contract freeze before adapter work
```text
HarnessEvent
SandboxLease
AttemptInput/Outcome
Artifact metadata
```
### Release gate
```text
approved design → one real slice → isolated repository changes
```
No PR claim yet.
## 7. Milestone C — Work is trustworthy (Slices 67)
### Issue order
1. VerificationPlan schema.
2. command-check runner.
3. HTTP-check runner.
4. revision/environment binding.
5. verifier role/process.
6. repair-attempt loop.
7. design-conformance evidence.
8. Git commit/push adapter.
9. PR creation adapter with idempotency.
10. review-package generator/UI.
11. exact-SHA invariant tests.
### Required quality fixture
For the health task, prove:
```text
new test fails on base
candidate passes focused tests
service starts
GET /health returns expected body
candidate SHA equals PR head SHA
```
### Release gate
A human can review and merge a real verified PR without reading raw agent logs.
## 8. Milestone D — Work is steerable (Slice 8)
### Issue order
1. Question/Decision lifecycle.
2. harness permission/question normalization.
3. attention UI.
4. contextual answer routing.
5. same-session resume and restarted-session recovery.
6. stale-question/version protections.
### Release gate
A deliberate ambiguity blocks Work, receives an answer, and resumes without duplicate execution.
## 9. Milestone E — Work survives complexity (Slices 910)
### Issue order
1. multi-slice commit model.
2. IntegrationActor/use case.
3. conflict/overlap analysis.
4. integrated verification.
5. preview adapter.
6. release/rollback policy.
7. observation and WorkResult.
8. incident/result-to-Signal loop.
### Release gate
Two slices pass independently, integrate on one SHA, publish preview, and produce an observed result.
## 10. Milestone F — System improvement (Slices 1112)
### Issue order
1. post-run evaluation schema.
2. learning synthesis.
3. knowledge proposal/diff workflow.
4. Kit registry/versioning.
5. Kit compiler.
6. tool/skill registry.
7. role evaluation harness.
8. controlled parallel fleet.
9. policy fallback to static Kit.
### Release gate
A completed run proposes a reviewable Kit/knowledge improvement, and a second run demonstrably benefits.
## 11. Actor rollout
### Initial
```text
ProjectActor
WorkActor
AttemptActor
```
WorkActor contains Resolver state and verification orchestration initially.
### Split only when justified
```text
VerificationActor — independent check lifecycle becomes complex
IntegrationActor — multi-branch/slice composition exists
ResultActor — deployment observation exists
ResolverActor — scheduling lifecycle needs independent ownership
```
Avoid actor-per-record designs.
## 12. Test architecture
### Domain
- transition tables;
- invariants;
- retry/budget logic;
- dependency graph readiness;
- approval/version invalidation;
- completion rules.
### Application
- command → event → projection;
- idempotent side effects;
- crash/restart recovery;
- timeout/cancellation;
- stale command rejection.
### Adapter contract tests
Run same suite against fake and live adapters:
```text
HarnessRuntime
SandboxRuntime
SourceControl
ArtifactStore
```
### End-to-end
Maintain fixtures:
```text
happy path
casual message/no Work
duplicate message
definition revision
design rejection
harness transient failure
human question/resume
verification repair
retry exhaustion
PR duplicate callback
actor restart mid-attempt
cancellation
```
### Quality evaluation
Before adding a new model/role/Kit, compare:
- success rate;
- escaped defect rate;
- review changes requested;
- retries;
- human attention;
- cost/time;
- design deviation.
## 13. Operational rollout
### Local
- fake adapters;
- local Rivet/AgentOS;
- one local repository fixture.
### Internal VPS
- deployed Rivet actors;
- Bun/Effect daemon;
- private Cube API;
- one OMP template;
- test forge repository.
### Dogfood repository
- real branch/PR;
- manual review;
- no production deploy;
- collect every failure.
### Controlled release
- one project/user;
- quotas;
- kill switch;
- audit trail;
- explicit external side-effect gates.
## 14. Security checklist before real repositories
```text
private control-plane networking
Cube/Rivet authentication
short-lived Git/model credentials
sandbox egress policy
no host HOME mounts
tool allowlist
artifact authorization
secret redaction
attempt timeout/cleanup
manual merge/deploy
audit approvals
```
## 15. Branch/PR sequencing
Suggested integration branch:
```text
dogfood/v0
```
Per-slice branches:
```text
dogfood/s01-work
dogfood/s02-definition
...
```
Within a slice, parallel branches may cover:
```text
contracts/domain
backend/actor
frontend
adapter
tests
```
Merge order:
```text
contracts → domain/actor → adapters/UI → integration tests
```
Every branch must target the current slice integration branch, not independently invent shared schemas.
## 16. First backlog
Execute exactly in this order:
```text
01 glossary + schemas
02 Work/Attempt state machines
03 commands/events/idempotency
04 fake E2E harness
05 message persistence
06 Signal extraction
07 Work card
08 Work Definition
09 definition approval
10 Design Packet
11 vertical-slice planner
12 design approval
13 Resolver with fake harness
14 AttemptActor
15 CubeSandbox adapter
16 OMP adapter/spike
17 real repository mutation
18 VerificationRuntime
19 repair loop
20 Git commit/push/PR
21 review package
22 contextual question/resume
23 integration verification
24 preview/release/result
25 learning proposals
26 dynamic Kit Builder
```
## 17. First production-quality acceptance test
```text
Given:
- one configured project/repository;
- one actionable user message;
- no pre-existing Work.
When:
- Zopu processes the message;
- the user approves definition/design;
- the Resolver executes all slices;
- verification passes;
- publication is requested.
Then:
- one Signal and one Work exist;
- exact provenance is preserved;
- every attempt has a terminal outcome;
- one verified candidate SHA exists;
- all required checks bind to that SHA;
- one PR exists at that SHA;
- the Work card explains intent, design, changes, evidence, and next action;
- actor/process restarts produce no duplicate Work, attempts, commits, or PRs.
```
## 18. Stop conditions
Pause feature expansion when any is true:
- stale “running” Work exists;
- duplicate external side effects occur;
- verification cannot identify exact SHA/environment;
- human cannot understand why a Work is “done”;
- retries are unbounded;
- agents bypass actor/application commands;
- provider-specific assumptions leak into domain;
- new roles add cost without measured quality improvement.
Fix the completion system before adding more autonomy.

1281
docs/dogfood-plan.md Normal file

File diff suppressed because it is too large Load Diff

405
docs/evaluation.md Normal file
View File

@@ -0,0 +1,405 @@
# Zopu Work OS — Evaluation and Improvement
> **Purpose:** measure whether Zopu produces better software and improve agents, Kits, tools, prompts, and workflow without regressions.
> **Principle:** no autonomy increase without evidence.
## 1. Evaluation targets
Evaluate five layers separately:
```text
A. Intent quality
B. Design quality
C. Execution reliability
D. Software quality
E. Product outcome
```
A fast agent that generates more code but increases review/rework is a regression.
## 2. Core metrics
### Intent
- Signal precision/recall on labeled messages;
- duplicate/incorrect Work rate;
- acceptance-criteria correction rate;
- unresolved ambiguity discovered after implementation.
### Design
- human design revision rate;
- implementation deviation rate;
- missed impacted components;
- slice replanning rate;
- architecture-review issues discovered after coding.
### Execution
- terminal Work percentage;
- stale-running count;
- attempt success/retry/failure distribution;
- restart recovery success;
- duplicate side effects;
- cancellation cleanup;
- time/cost per verified slice.
### Software quality
- verification pass rate on first candidate;
- escaped defect/regression rate;
- review changes requested;
- test weakening incidents;
- security/maintainability findings;
- integration-only failures;
- post-merge rollback/hotfix rate.
### Human experience
- attention minutes per verified outcome;
- blocking questions per Work;
- time to understand review package;
- percentage of recommendations accepted;
- user override/correction rate.
### Outcome
- achieved/partial/failed/inconclusive distribution;
- time from merge to result;
- original success signal movement;
- follow-up incident rate.
## 3. Evaluation corpus
Maintain a versioned fixture corpus.
### Message/Signal fixtures
```text
casual conversation
clear actionable request
multiple intents
duplicate request
new evidence for existing Work
conflicting requirement
incident/error Signal
```
### Software Work fixtures
```text
small isolated bug
API behavior addition
frontend/backend vertical flow
cross-package refactor
auth/permission change
database migration
parallel-slice conflict
verification repair
human question/resume
release regression
```
Each fixture includes:
```text
repository/base SHA
input Signals
expected Work Definition properties
risk lane
expected impacted areas
required behavior/evidence
forbidden outcomes
human gold review notes
```
## 4. Evaluation modes
### Deterministic unit/property evaluation
For:
- schemas;
- state transitions;
- dependency readiness;
- idempotency;
- budgets/retries;
- approval invalidation;
- actor recovery.
Must run on every change.
### Adapter contract evaluation
Run the same suite against fake and live:
```text
HarnessRuntime
SandboxRuntime
SourceControl
ArtifactStore
PreviewRuntime
```
### Agent offline evaluation
Replay fixture inputs against candidate agent/Kit versions. Store all outputs and compare with current production baseline.
### End-to-end sandbox evaluation
Run real repository tasks in isolated environments, verify exact candidate, and score review quality.
### Dogfood/online evaluation
Observe real Work with human decisions, merge outcomes, incidents, and post-release results.
## 5. Scoring rubric
Score each candidate run 03 per dimension.
| Score | Meaning |
|---|---|
| 0 | unsafe/incorrect/missing |
| 1 | substantial human repair required |
| 2 | acceptable with minor correction |
| 3 | correct, complete, reviewable |
Dimensions:
```text
intent fidelity
scope control
design correctness
slice quality
implementation correctness
test quality
maintainability
security/reliability
evidence completeness
review clarity
result achievement
```
Critical safety/invariant violation is automatic failure regardless of aggregate score.
## 6. Golden invariants
Automated assertions:
```text
no duplicate Signal/Work/PR/deployment
no Work stuck active beyond lease/timeout
no verification without SHA/environment
no PR head different from verified SHA
no stale approval accepted
no cross-worktree mutation
no unscoped persistent credential
no builder-only completion verdict
no retry beyond policy
no silent canonical-knowledge mutation
```
## 7. Failure taxonomy
Classify failures consistently:
```text
Intent
├── missed Signal
├── wrong Work
├── scope drift
└── unresolved ambiguity
Design
├── missed impact
├── wrong boundary
├── invalid slice
└── hidden trade-off
Execution
├── model failure
├── harness failure
├── sandbox failure
├── tool failure
├── timeout/cancellation
└── recovery/idempotency failure
Verification
├── missing check
├── flaky check
├── false pass
├── false failure
└── wrong revision/environment
Delivery
├── integration conflict
├── wrong PR revision
├── release failure
└── rollback failure
Outcome
├── user problem remains
├── regression
└── inconclusive observation
```
Every failed Work records one primary and optional contributing categories.
## 8. Experiment protocol
Any change to agent, prompt, model, Kit, tool, runtime policy, or Resolver heuristic requires:
```text
hypothesis
target metric
candidate version
baseline version
fixture set
budget
results
regressions
decision
```
Recommended sequence:
```text
offline corpus
→ shadow/dry run
→ small dogfood cohort
→ policy-controlled rollout
```
Do not promote based on one impressive example.
## 9. Agent/Kit promotion gate
A candidate may replace baseline only when:
- golden invariants pass;
- no critical regression;
- task success does not decrease materially;
- review/rework improves or remains acceptable;
- compute/time increase is justified;
- failure mode and rollback are known;
- version can be selected/reverted independently.
Record promotion as a durable decision.
## 10. Verifier evaluation
Verifiers themselves need evaluation.
Test:
- whether injected defects are caught;
- false-positive rate;
- whether checks bind exact candidate;
- whether design deviations are identified;
- whether evidence is concise and actionable;
- whether repair instructions reproduce the failure.
Use mutation/injected-defect suites where practical.
## 11. Human review calibration
Periodically compare:
```text
agent/verifier verdict
human reviewer verdict
post-merge outcome
```
Track disagreement categories. A reviewer model agreeing with another model is not ground truth.
## 12. Post-release feedback
For completed Work, schedule/perform observation using defined success signals.
Convert:
- incidents;
- support complaints;
- failed health checks;
- rollback;
- unexpected metrics;
- manual rework
into linked Signals and evaluation records.
## 13. Improvement outputs
Evaluation may produce proposals for:
```text
Work Definition rule
Design Packet template
slice heuristic
verification recipe
Kit/role/tool
runtime policy
repository knowledge
UI review package
```
Each proposal states evidence, expected benefit, risk, and rollback.
## 14. Dashboard minimum
Internal dogfood dashboard:
```text
active/stale Work
terminal outcome distribution
first-pass verification
retry/replan causes
human wait/attention
duplicate side effects
review changes requested
post-merge failures
cost/time per verified Work
agent/Kit version comparison
```
Do not collapse quality into one vanity score.
## 15. Initial benchmark gate
Before v0 dogfood, pass:
```text
✓ actionable vs casual Signal fixtures
✓ duplicate processing
✓ definition/design version invalidation
✓ fake Resolver crash recovery
✓ sandbox cancellation
✓ verification repair
✓ exact-SHA PR invariant
✓ human question/resume
✓ duplicate callback/PR prevention
```
Before increasing autonomy, add:
```text
✓ integration conflict
✓ auth/permission fixture
✓ migration/rollback fixture
✓ post-release regression fixture
```
## 16. Continuous improvement loop
```text
Run Work
→ capture structured outcome
→ classify failures
→ aggregate patterns
→ propose one bounded change
→ evaluate against corpus
→ dogfood
→ promote or revert
```
Improve the weakest stage, not the most fashionable model.

22
docs/futures.md Normal file
View File

@@ -0,0 +1,22 @@
# Futures
Work that is intentionally deferred from the current slice. Each item records enough context that a future thread can pick it up without re-investigating.
## Audio and video input
Status: blocked at two layers; not viable through the current transport.
MiMo V2.5 lists audio and video in its model spec, but the live stack cannot carry either modality today:
- Flue agent transport exposes only `images: AgentPromptImage[]` on `AgentPromptOptions`, with `AgentPromptImage.type` pinned to `'image'`. There is no `audio` or `video` field, so the client cannot attach either.
- The Cheaptricks gateway rejects non-image content parts upstream. Probed directly with OpenAI-style `input_audio` and `video_url` parts, both returned `"Error from provider (Console Go): Upstream request failed"` while a plain text probe on the same endpoint returned a normal completion.
Cheapest real path, if this is revisited: video via client-side frame extraction sent as image attachments (Flue already supports image input). True audio/video would need a Flue provider adapter (Provider API) plus an upstream that actually accepts the modality, which the current probe suggests it does not.
## Web layout cleanup
Polish pass on the mobile-first layout: spacing, alignment, responsive edge cases, and removing dead or vestigial UI introduced during the slice-one build. Goal is a tightened, consistent layout before slice two adds more surfaces.
## Message rendering and streaming quality
Improve the read experience for streamed assistant messages: markdown fidelity, reasoning-trace presentation, code block and Mermaid rendering stability, and the perceived smoothness of token streaming. Covers both correctness of the rendered output and the feel of the live update.

44
docs/glossary.md Normal file
View File

@@ -0,0 +1,44 @@
# Zopu Work OS — Canonical Glossary
> **Purpose:** prevent agents and humans from using overlapping terms inconsistently.
| Term | Definition | Not the same as |
|---|---|---|
| Project | Durable product/repository/context boundary. | Work or chat thread |
| Message | Exact conversational input/output. | Signal |
| Signal | Provenanced evidence that may require attention. | Work |
| Work | Durable desired outcome linking all evidence and lifecycle. | Issue, task, session, PR |
| Work Definition | Versioned product-level contract: problem, outcome, criteria, scope, risk. | Implementation plan |
| Impact Map | Systems/contracts/data/operations potentially affected. | File list only |
| Architecture | Component interaction, ownership, contracts, trust/data flow. | Program design |
| Design Packet | Versioned implementation intent: file/call-flow deltas, types, invariants, slices. | Generated code |
| Vertical Slice | Bounded increment exposing observable behavior across necessary layers. | Horizontal backend/frontend phase |
| Step | Executable graph node; a slice may contain implementation/verify/publish steps. | Work |
| Kit | Versioned executable policy/configuration for resolving Work. | Agent or runtime |
| Kit Builder | Compiler selecting/composing a Kit from approved intent and policy. | Unbounded prompt generator |
| Resolver | Durable engine deciding what must happen next until terminal outcome. | Coding harness |
| Run | Coordination record for resolving a Work/step using one Kit version. | Attempt |
| Attempt | One bounded execution in one runtime/harness context. | Whole Work |
| Actor | Rivet durable identity owning serialized state/lifecycle. | Agent/model |
| Agent | Program/model-directed reasoning role producing proposals or actions. | Actor |
| Harness | Coding/tool-loop implementation such as OMP/OpenCode/Codex/Pi. | Sandbox |
| Runtime | Capability provider executing processes/files/network. | Harness |
| Sandbox | Isolated runtime instance for untrusted/project execution. | Worktree |
| Orb | Product-level execution envelope: runtime, workspace, harness, context, credentials, artifacts. | Agent |
| Worktree | Git checkout isolated for a candidate branch/Work. | Sandbox |
| Candidate | Unverified implementation revision/output. | Verified result |
| Verification Plan | Predeclared checks and evidence required for completion. | Test command only |
| Verification Result | Evidence-bound verdict for an exact revision/environment. | Agent statement |
| Artifact | Durable output/reference: diff, report, screenshot, commit, PR, preview. | Evidence automatically |
| Evidence | Artifact/data supporting a specific claim/check. | Summary prose |
| Question | Structured human input request blocking or affecting Work. | Chat thread |
| Decision | Durable answer/approval bound to exact Work/design/version. | Informal message |
| Review Package | Human-oriented narrative of intent, design, changes, evidence, risks. | Raw Git diff |
| Integration | Composition and verification of multiple slice/candidate changes. | Merge without checks |
| Delivery | Publication/release artifacts and transitions. | Outcome |
| Result | Actual effect compared with desired outcome. | PR merged |
| Learning | Post-work observation proposing future knowledge/process improvement. | Automatic memory mutation |
| Knowledge | Reviewed durable project/product facts, decisions, and conventions. | Harness session memory |
| Policy | Deterministic constraints for risk, tools, permissions, gates, budgets. | Model preference |
| Provenance | Exact origin and chain linking Signal, decision, code, evidence, result. | Generated summary |
| Terminal state | Explicit successful/failed/blocked/cancelled outcome requiring no hidden work. | “Agent stopped” |

93
docs/handoff-slice1.md Normal file
View File

@@ -0,0 +1,93 @@
# Slice 1 Handoff Notes
Date: 2026-07-27
Branch: `master` at `cfdb2efc7`
PR: #19 (merged)
## What was built
### Core product loop
- Conversation to Signal to proposed Work with exact source provenance.
- Convex owns durable data: `conversationMessages`, `signals`, `works`, `workEvents`.
- Effect validation in `@code/work-os` package.
- Flue agent (`packages/agents/src/agents/zopu.ts`) routes actionable messages through Signal/Work tools.
- Slice-one tools at `packages/agents/src/tools/slice-one.ts`.
- Mobile-first UI at `apps/web/src/components/slice-one/slice-one-page.tsx`.
### Model
- MiMo V2.5 (`xiaomi/mimo-v2.5`) via Cheaptricks AI gateway.
- Provider identity `xiaomi` gives Flue correct multimodal metadata (text + image).
- `AGENT_MODEL_BASE_URL` points at Cheaptricks; API key in `.env`.
- Context window 1,048,576; max output tokens 131,076.
- `thinkingLevel: "medium"`.
### Reasoning traces
- Native Flue `reasoning` parts render as live "Thinking trace" (open while streaming, collapsed after).
- Inline `<think>...</think>` extraction for models that embed thinking in text.
- No fallback that hides model output.
### Image attachments
- Picker button + hidden file input, up to 4 images at 10 MB each.
- Base64 conversion into Flue `AgentPromptImage`.
- Authenticated blob-URL rendering for historical image replay.
- Verified live: MiMo read exact text and background color from generated test images.
### Mobile keyboard fix
- Visual viewport hook (`use-visual-viewport.ts`).
- Fixed full-screen shell; `interactive-widget=resizes-content` in viewport meta.
- Header stays pinned; chat shrinks; composer follows keyboard.
- Regression tests in `frontend-regressions.test.ts` and `use-visual-viewport.test.ts`.
### Rendering
- Streamdown markdown streaming with Mermaid chart support.
- Tool turns hidden; reasoning-containing tool turns remain visible.
- Work cards render inline in the conversation timeline with source navigation.
### Deployment
- Cheaptricks staging live at `https://zopu.cheaptricks.puter.wtf`.
- Caddy reverse proxy: `/api/*` to Flue (port 3585), root to web (port 5173).
- `server.allowedHosts: true` in `apps/web/vite.config.ts` for Caddy domain.
- Convex `SITE_URL` set to `https://zopu.cheaptricks.puter.wtf`.
- Both processes running via `nohup` on Cheaptricks (PIDs 416955 and 418726).
- Deployment notes at `docs/deployment.md`.
### Repository cleanup
- 17 stale worktrees removed (Codex, Paseo, T3).
- 23 stale local branches deleted.
- 18 stale remote branches deleted.
- 6 stale PRs closed (#1, #10, #11, #12, #13, #15).
- 8 stale issues closed (#4, #5, #6, #7, #8, #9, #14, #17).
- 0 open PRs, 0 open issues.
## Current state
### Running
- Cheaptricks: web on `:5173`, Flue on `:3585`, both persistent via `nohup`.
- Local Mac: servers stopped. Run `bun run dev:tailscale:agents -- --port 3585` and `bun run dev:tailscale:web` to start.
### Git
- `master` at `cfdb2efc7` on local, Cheaptricks, and remote.
- Only `master` branch locally. Two remote branches remain: `origin/sai/changes`, `origin/t3code/explore-primitives-package` (not cleaned because they may be owned by other contributors/tools).
## Known issues and next steps
1. **Convex SITE_URL is single-valued.** Switch it when moving between local and staging:
```bash
cd packages/backend
npx convex env set SITE_URL 'http://100.101.157.28:5173' # local
npx convex env set SITE_URL 'https://zopu.cheaptricks.puter.wtf' # staging
```
2. **MiMo latency is ~14-20 seconds per turn.** This is model-side thinking time, not transport. Streaming works correctly; first reasoning token arrives live. Consider a faster model or lower `thinkingLevel` if latency becomes a product problem.
3. **Audio/video input not wired.** MiMo supports audio and video per its spec, but the composer only accepts images. Adding audio/video would require extending `use-chat-images.ts`, the file input `accept` attribute, and the Flue `AgentPromptImage` type if it does not already cover those media types.
4. **Two remote branches remain.** `origin/sai/changes` and `origin/t3code/explore-primitives-package` were left because they may be owned by other tools. Delete with `git push origin --delete sai/changes t3code/explore-primitives-package` if safe.
5. **Convex schema and functions are deployed** to the dev deployment `befitting-dalmatian-161`. No pending migrations.
6. **`.env` is gitignored** on both machines. See `docs/deployment.md` for the exact templates.
## Slice 2 preview
Per `docs/slices.md`, Slice 2 is "Work Definition and approval" - turning proposed Work into approved Work with structured definitions, acceptance criteria, and the approval flow. The Convex `works.ts` schema and `@code/work-os` primitives are already in place to build on.

65
docs/manifest.json Normal file
View File

@@ -0,0 +1,65 @@
{
"read_order": [
"agent-context.md",
"product.md",
"glossary.md",
"dev-loop.md",
"tech.md",
"design.md",
"slices.md",
"dev-plan.md",
"evaluation.md"
],
"files": {
"README.md": {
"bytes": 1957,
"lines": 57,
"sha256": "9f732b8c40e1714767bbf31ef8ea6263c64b572775555f00527d775c6204f9c2"
},
"agent-context.md": {
"bytes": 7930,
"lines": 328,
"sha256": "6f2e97bc7e2606e84792391beb2d5bb8f4c23a720c277e4b22976cab2cfaf730"
},
"design.md": {
"bytes": 8470,
"lines": 469,
"sha256": "4d42edbcf6f723b2e845350b5b99925b78fc2fa09783d726f02fec5389bf0a9c"
},
"dev-loop.md": {
"bytes": 15737,
"lines": 705,
"sha256": "3fe99dcbf05cecfd2403b289fd9d0545372309e41c02973875739ace5822eb04"
},
"dev-plan.md": {
"bytes": 10138,
"lines": 445,
"sha256": "4e1e68fe91a9f442c1f662d133a27efb9c74223ed92f2f2634cff2aa36d8e429"
},
"evaluation.md": {
"bytes": 8310,
"lines": 405,
"sha256": "22037e62ae0b7abd1b639c3bd0b6e1a19328b919dd6a7c3fc4a1b7a62c9cac7e"
},
"glossary.md": {
"bytes": 3800,
"lines": 44,
"sha256": "79abd47b212d3b3c160238f703360a841090da6c9787b9c9f8871e14588aef74"
},
"product.md": {
"bytes": 10423,
"lines": 422,
"sha256": "020e1b7dd00be90aa1061f1ab2cd1a0416f48735c281a079b1e995dc333c5b75"
},
"slices.md": {
"bytes": 10132,
"lines": 493,
"sha256": "7ee7500685e5789f2aebf29d6a3b7d14866edc7c0f85b2f2c727edb669e66b00"
},
"tech.md": {
"bytes": 15349,
"lines": 663,
"sha256": "bcef9fdd0166ead4ff9f1c898016098871089680956de79e990e66d5d472e4d8"
}
}
}

493
docs/slices.md Normal file
View File

@@ -0,0 +1,493 @@
# Zopu Work OS — Vertical Slices
> **Related:** `dev-loop.md` defines the software-building process; `dev-plan.md` defines implementation sequencing.
> **Purpose:** ordered product increments. Complete each slice end-to-end before starting the next.
> **Reference task:** “Add `GET /health` returning status and current build commit.”
> **Rule:** every slice must produce visible product behavior, durable state, and automated acceptance coverage.
## Global definition of done
For every slice:
- schema/state migrations applied;
- commands/events are idempotent;
- actor restart does not corrupt state;
- frontend handles loading/error/empty states;
- important actions are audited;
- unit/integration tests cover invariants;
- no provider-specific types leak into domain;
- docs updated when contracts change.
## Slice 1 — Conversation → Signal → Work card
### User outcome
A user message creates one actionable Signal and one proposed Work card with exact provenance.
### Backend
```text
Message
Signal
Work
WorkEvent
ProcessMessage → structured FLUE proposal → validated command
```
Implement:
- persist exact message;
- Signal fingerprint/idempotency;
- create vs attach decision;
- proposed Work state;
- Work event feed.
### Frontend
- continuous chat;
- inline Work creation notice;
- collapsed reactive Work card;
- source-message link.
### Acceptance
- duplicate processing creates no duplicate Signal/Work;
- casual message creates no Work;
- actionable message creates one Work;
- card survives refresh/restart;
- exact source text is recoverable.
### Explicitly exclude
Planning, sandboxes, Git, verification.
---
## Slice 2 — Work Definition and approval
### User outcome
The proposed Work becomes a testable outcome contract that can be edited and approved.
### Backend
Add:
```text
WorkDefinition(versioned)
DefinitionApproval
Question
RiskClass
```
FLUE compiles:
- problem;
- desired outcome;
- scope/non-goals;
- acceptance criteria;
- assumptions/questions;
- risk.
Work state:
```text
Proposed → Defining → AwaitingDefinitionApproval → Designing
```
### Frontend
Expanded Outcome section; edit/approve/request revision; unresolved-question cards.
### Acceptance
- high-impact unresolved question blocks approval;
- approval binds exact version;
- revision invalidates stale approval;
- risk lane is visible;
- definition can be reconstructed from event history.
---
## Slice 3 — Design Packet and vertical slices
### User outcome
The user can review expected architecture/code shape and approve a bounded slice plan before coding.
### Backend
Add versioned:
```text
ImpactMap
DesignPacket
VerticalSlice
VerificationPlan
DesignApproval
```
Required Design Packet fields:
- affected systems/files;
- architecture summary;
- expected file-tree/call-flow changes;
- key types/invariants;
- risks/trade-offs;
- 14 vertical slices;
- evidence requirements per slice.
### Frontend
Design section with diagram, file tree, call flow, slices, version diff, approval.
### Acceptance
- horizontal “backend/frontend/test later” plan rejected;
- each slice is independently observable/verifiable;
- approval binds definition+design versions;
- changed definition invalidates dependent design;
- Work reaches `Ready`.
---
## Slice 4 — Resolver state machine with fake harness
### User outcome
Starting Work visibly advances slices, retries bounded failures, and surfaces blockers without real code execution.
### Backend
Add:
```text
Run
Attempt
ResolverDecision
KitVersion
HarnessRuntime port
FakeHarnessLive
```
Static `CodingKitV0`.
Implement durable loop:
```text
Ready → ExecutingSlice → VerifyingSlice → NextSlice
```
Attempt outcomes:
```text
Succeeded | RetryableFailure | NeedsInput | Blocked
| VerificationFailed | BudgetExhausted | Cancelled | PermanentFailure
```
### Frontend
Slice progress, meaningful activity, retry count, stop/retry controls, question state.
### Acceptance
- injected transient failure retries up to policy;
- restart resumes from durable state;
- no executable step produces explicit failure;
- cancellation terminates attempt;
- no Work remains permanently “running.”
---
## Slice 5 — Real sandbox + implementation harness
### User outcome
Zopu implements one approved slice in an isolated repository environment and streams useful progress.
### Backend
Initial adapter choice:
```text
SandboxRuntime = CubeSandboxLive
HarnessRuntime = OmpHarnessLive (or one chosen harness)
```
Flow:
```text
prepare worktree
→ create sandbox
→ clone/mount repo
→ inject context
→ run one slice
→ normalize events
→ collect diff/artifacts
→ pause/terminate
```
Security:
- scoped Git/model tokens;
- isolated HOME/worktree;
- one mutating attempt per worktree;
- timeout/cancel cleanup.
### Frontend
Current activity, changed files, artifact links, expandable raw logs.
### Acceptance
- real repository file changes occur;
- another Work cannot see/modify checkout;
- cancellation stops process;
- provider failure becomes classified attempt outcome;
- exact base/candidate revision recorded.
---
## Slice 6 — Independent verification and repair
### User outcome
The card shows objective evidence; failed checks trigger a bounded repair loop.
### Backend
Implement `VerificationRuntime` and verifier role.
Initial checks:
```text
format/lint/typecheck
focused tests
service start
HTTP behavior
secret scan
expected vs actual files/interfaces
test weakening/deletion detection
```
Bind result to candidate SHA/environment.
Repair:
```text
failure evidence → repair attempt → clean rerun
```
### Frontend
Evidence checklist, exact failures, candidate SHA, repair status.
### Acceptance
- implementer cannot self-mark passed;
- failed check stores output/exit code;
- repair receives exact evidence;
- max attempts enforced;
- final verdict is Passed/Failed/Inconclusive.
---
## Slice 7 — Git publication and review package
### User outcome
A verified candidate becomes a real branch/commit/PR with an understandable review package.
### Backend
`SourceControl` adapter:
```text
commit → push → create PR
```
Idempotent artifact creation; verify PR head SHA equals verified SHA.
Generate review package:
- intent/definition/design;
- slice narrative;
- important diffs;
- screenshots/API evidence;
- checks;
- deviations/risks.
### Frontend
Delivery section, PR action, narrative review package.
### Acceptance
- no duplicate PR after retry;
- exact verified SHA published;
- PR link survives restart;
- merge remains human-controlled;
- request-changes action returns Work to appropriate state.
**Milestone:** first useful product.
---
## Slice 8 — Contextual human intervention
### User outcome
A blocked agent asks one precise question; the user answers from the Work card; the same Work resumes.
### Backend
Durable `Question`/`Decision`; map response to Work/slice/attempt/harness session.
### Frontend
Attention card with recommendation, alternatives, consequences; contextual composer.
### Acceptance
- answer persists before resume;
- harness restart does not lose decision;
- stale questions cannot mutate newer plan;
- response never creates unrelated thread;
- attention queue orders blockers correctly.
---
## Slice 9 — Multi-slice integration verification
### User outcome
Several passing slices are combined and tested as one exact candidate before PR readiness.
### Backend
Add `IntegrationActor`/use case:
```text
compose commits
detect overlap/conflict
rebase/resolve policy
run impacted checks on integrated SHA
```
### Frontend
Integration state, conflict blocker, combined evidence.
### Acceptance
- individually passing slices cannot skip combined checks;
- conflicts become explicit blockers;
- integrated SHA is the published SHA;
- failed integration can replan/repair.
---
## Slice 10 — Preview, release, and observation
### User outcome
The user can inspect a preview, approve delivery, and see whether the released behavior works.
### Backend
Provider-neutral `PreviewRuntime`/release adapter. Add:
```text
RolloutPlan
RollbackPlan
HealthSignal
ObservationWindow
WorkResult
```
### Frontend
Preview link, release gate, health state, expected-vs-actual result.
### Acceptance
- preview binds candidate SHA;
- release requires policy-defined approval;
- observation records actual behavior;
- rollback trigger is explicit for critical work;
- merged PR alone does not mark outcome achieved.
---
## Slice 11 — Learning and knowledge proposals
### User outcome
Completed Work produces reviewable improvements to project knowledge and execution policy.
### Backend
Synthesize:
- planning errors;
- missing context;
- useful/failing tools;
- escaped defects;
- Kit/test recommendations.
Create proposal artifacts, never direct canonical mutation.
### Frontend
Diffable learning cards: accept/edit/reject.
### Acceptance
- every proposal cites run/evidence;
- rejection is retained;
- accepted update is versioned;
- no autonomous rewrite of canonical docs.
---
## Slice 12 — Dynamic Kit Builder and controlled fleet
### User outcome
Zopu selects/composes the right roles, tools, runtime, and checks for different software Work while preserving policy.
### Backend
Kit compiler inputs:
```text
Work Definition + risk + Design Packet + project policy
+ runtime/tool registry + previous results
```
Outputs immutable/versioned `ExecutionKit`.
Add roles only with measurable value:
```text
investigator implementer verifier security-reviewer
browser-tester integration-coordinator
```
### Acceptance
- Kit is explainable/versioned;
- tool grants are least privilege;
- budgets enforced;
- role addition has evaluation evidence;
- dynamic tools are proposed/reviewed before trust;
- fallback static Kit remains available.
## Dependency graph
```text
1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → 10 → 11 → 12
```
Parallel engineering is allowed *within* a slice after contracts land, but product release order remains sequential.

View File

@@ -3,8 +3,14 @@
"private": true,
"workspaces": {
"packages": [
"apps/*",
"packages/*"
"apps/web",
"packages/agents",
"packages/auth",
"packages/backend",
"packages/config",
"packages/env",
"packages/primitives",
"packages/ui"
],
"catalog": {
"@rivet-dev/agentos": "^0.2.7",
@@ -14,6 +20,8 @@
"zod": "^4.4.3",
"lucide-react": "^1.23.0",
"next-themes": "^0.4.6",
"react": "19.2.8",
"react-dom": "19.2.8",
"sonner": "^2.0.7",
"convex": "^1.42.1",
"better-auth": "1.6.15",
@@ -35,33 +43,26 @@
"type": "module",
"scripts": {
"dev": "vp run -r dev",
"build": "vp run -r build --filter '!desktop' && vp run --filter desktop build",
"build": "vp run -r build",
"check-types": "vp run -r check-types",
"check": "ultracite check",
"check": "ultracite check package.json vite.config.ts apps/web/package.json apps/web/src/root.tsx apps/web/src/index.css apps/web/src/components/chat apps/web/src/components/slice-one apps/web/src/hooks/chat apps/web/src/hooks/slice-one apps/web/src/lib/chat apps/web/src/lib/slice-one apps/web/src/routes.ts apps/web/src/routes/app/dashboard/page.tsx apps/web/src/routes/app/mobile/page.tsx packages/agents/package.json packages/agents/src/agents/zopu.ts packages/agents/src/app.ts packages/agents/src/auth.ts packages/agents/src/tools/slice-one.ts packages/backend/package.json packages/backend/convex/conversationMessages.ts packages/backend/convex/conversationMessages.test.ts packages/backend/convex/projects.ts packages/backend/convex/schema.ts packages/backend/convex/signalRouting.ts packages/backend/convex/signalRouting.test.ts packages/backend/convex/works.ts packages/backend/convex/works.test.ts packages/primitives/src/work.ts packages/primitives/src/work.test.ts",
"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",
"dev:web": "vp run --filter web dev",
"dev:agents": "vp run --filter @code/agents dev",
"dev:tailscale:web": "vp run --filter web dev:tailscale",
"dev:tailscale:agents": "vp run --filter @code/agents dev:tailscale",
"dev:desktop": "vp run --filter desktop dev:hmr",
"build:desktop": "vp run --filter desktop build:stable",
"build:desktop:canary": "vp run --filter desktop build:canary",
"dev:tui": "vp run --filter tui dev",
"dev:server": "vp run --filter @code/backend dev",
"dev:setup": "vp run --filter @code/backend dev:setup",
"dev:daemon": "vp run --filter daemon dev",
"build:daemon": "vp run --filter daemon build",
"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",
"orb:proof": "bun run scripts/orb-proof.ts",
"orb:run": "bun run scripts/orb-project-run.ts"
"slice1": "vp run -r dev",
"dev:zopu": "vp run --filter @code/agents dev",
"dev:zopu:web": "vp run --filter web dev"
},
"dependencies": {},
"devDependencies": {

View File

@@ -9,29 +9,20 @@
"dev": "bun --env-file=../../.env flue dev",
"dev:tailscale": "bun --env-file=../../.env flue dev",
"run": "bun --env-file=../../.env flue run",
"run:zopu": "bun --env-file=../../.env flue run zopu",
"run:zopu-dev": "bun --env-file=../../.env flue run zopu-dev"
"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:",
"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

@@ -1,91 +0,0 @@
/**
* Smoke test: exercise the agentos sandbox adapter through Flue's
* createSandboxSessionEnv wrapper — the same path the zopu agent uses.
*
* Run: bun --env-file=../../.env packages/agents/scripts/agentos-adapter-test.ts
*/
import { agentos } from "../src/sandboxes/agentos";
const main = async () => {
const factory = agentos();
// Flue calls this once per harness initialization.
const env = await factory.createSessionEnv({ id: "smoke-test" });
let pass = 0;
let fail = 0;
const assert = (label: string, condition: boolean) => {
if (condition) {
pass += 1;
console.log(`${label}`);
} else {
fail += 1;
console.log(`${label}`);
}
};
console.log("\n=== File operations ===");
await env.writeFile("test.txt", "hello from Flue adapter");
assert("writeFile", true);
const content = await env.readFile("test.txt");
assert(
"readFile returns written content",
content === "hello from Flue adapter"
);
const stat = await env.stat("test.txt");
assert("stat isFile", stat.isFile === true);
assert("stat size", stat.size === "hello from Flue adapter".length);
assert("exists true", (await env.exists("test.txt")) === true);
assert("exists false for missing", (await env.exists("nope.txt")) === false);
console.log("\n=== Directory operations ===");
await env.mkdir("subdir", { recursive: true });
await env.writeFile("subdir/a.ts", "export const a = 1;");
await env.writeFile("subdir/b.ts", "export const b = 2;");
const entries = await env.readdir("subdir");
assert(
"readdir returns names",
entries.length === 2 && entries.includes("a.ts")
);
await env.rm("subdir/b.ts");
const after = await env.readdir("subdir");
assert("rm removes file", after.length === 1 && after[0] === "a.ts");
await env.rm("subdir", { recursive: true });
assert("rm recursive removes dir", (await env.exists("subdir")) === false);
console.log("\n=== Shell exec ===");
const echoRes = await env.exec("echo 'adapter shell works'");
assert("exec echo exit code", echoRes.exitCode === 0);
assert("exec echo stdout", echoRes.stdout.trim() === "adapter shell works");
const pipeRes = await env.exec("echo 'hello' | tr a-z A-Z");
assert("exec pipe", pipeRes.stdout.trim() === "HELLO");
const lsRes = await env.exec("ls", { cwd: "/workspace" });
assert("exec with cwd", lsRes.stdout.includes("test.txt"));
const envRes = await env.exec("echo $TEST_VAR", {
env: { TEST_VAR: "injected" },
});
assert("exec with env", envRes.stdout.trim() === "injected");
console.log(`\n=== ${pass} passed, ${fail} failed ===`);
if (fail > 0) {
process.exit(1);
}
};
try {
await main();
} catch (error) {
console.error(error);
process.exit(1);
}

View File

@@ -1,142 +0,0 @@
/**
* Reference: agentOS VM as a sandbox backend for Flue.
*
* This script demonstrates the core idea behind a Flue sandbox adapter that
* delegates file/shell operations to an agentOS VM. It does NOT wire into the
* Flue agent yet — it proves the primitives work end-to-end so you can see
* exactly how the adapter will map calls.
*
* Run: bun --env-file=../../.env packages/agents/scripts/agentos-sandbox-ref.ts
*
* Architecture:
*
* Flue agent (host) agentOS VM (isolated Wasm+V8)
* ┌─────────────────────┐ ┌──────────────────────┐
* │ defineAgent(...) │ │ Linux-like sandbox │
* │ sandbox: agentOs()│── readFile ──► │ persistent FS │
* │ │── writeFile ─► │ shell (sh, coreutils)│
* │ │── exec ──────► │ ~6ms cold start │
* └─────────────────────┘ └──────────────────────┘
*
* The Flue SandboxApi maps 1:1 to AgentOs methods:
*
* Flue SandboxApi AgentOs (core SDK)
* ───────────────────────── ──────────────────────────
* readFile(path) vm.readFile(path)
* readFileBuffer(path) vm.readFile(path)
* writeFile(path, content) vm.writeFile(path, content)
* stat(path) vm.stat(path)
* readdir(path) vm.readdir(path)
* exists(path) vm.exists(path)
* mkdir(path) vm.mkdir(path)
* rm(path) vm.remove(path)
* exec(command, opts) vm.exec(command, opts)
*/
import { AgentOs } from "@rivet-dev/agentos-core";
import type { VirtualStat } from "@rivet-dev/agentos-core";
const formatStat = (stat: VirtualStat): string =>
JSON.stringify({
isDirectory: stat.isDirectory,
isSymbolicLink: stat.isSymbolicLink,
mtime: new Date(stat.mtimeMs).toISOString(),
size: stat.size,
});
const main = async () => {
console.log("=== Booting agentOS VM ===\n");
// AgentOs.create() boots a local VM with the default software bundle
// (sh + coreutils). The VM sleeps when idle and wakes on the next action.
const vm = await AgentOs.create();
console.log("VM booted.\n");
// ── File operations ──────────────────────────────────────────────
console.log("=== writeFile ===");
await vm.writeFile("/workspace/hello.txt", "Hello from agentOS VM!");
console.log("wrote /workspace/hello.txt\n");
console.log("=== readFile ===");
const content = await vm.readFile("/workspace/hello.txt");
console.log("content:", new TextDecoder().decode(content), "\n");
console.log("=== stat ===");
const stat = await vm.stat("/workspace/hello.txt");
console.log("stat:", formatStat(stat), "\n");
console.log("=== exists ===");
console.log(
"exists /workspace/hello.txt:",
await vm.exists("/workspace/hello.txt")
);
console.log(
"exists /workspace/nope.txt:",
await vm.exists("/workspace/nope.txt"),
"\n"
);
console.log("=== mkdir + readdir ===");
await vm.mkdir("/workspace/src", { recursive: true });
await vm.writeFile("/workspace/src/a.ts", "export const a = 1;");
await vm.writeFile("/workspace/src/b.ts", "export const b = 2;");
console.log(
"readdir /workspace/src:",
await vm.readdir("/workspace/src"),
"\n"
);
// ── Shell exec ───────────────────────────────────────────────────
console.log("=== exec: echo ===");
const echoResult = await vm.exec("echo 'shell works!'");
console.log(
"stdout:",
echoResult.stdout.trim(),
`(exit ${echoResult.exitCode})\n`
);
console.log("=== exec: pipe + coreutils ===");
const pipeResult = await vm.exec("echo 'hello world' | tr a-z A-Z | wc -w");
console.log(
"stdout:",
pipeResult.stdout.trim(),
`(exit ${pipeResult.exitCode})\n`
);
console.log("=== exec: with cwd + env ===");
await vm.mkdir("/workspace/project", { recursive: true });
await vm.writeFile("/workspace/project/file.txt", "data");
const cwdResult = await vm.exec("pwd && ls", {
cwd: "/workspace/project",
env: { CUSTOM_VAR: "from-env" },
});
console.log(
"stdout:",
cwdResult.stdout.trim(),
`(exit ${cwdResult.exitCode})\n`
);
// ── Remove ───────────────────────────────────────────────────────
console.log("=== remove ===");
await vm.remove("/workspace/src/b.ts");
console.log(
"after remove, readdir:",
await vm.readdir("/workspace/src"),
"\n"
);
// ── Cleanup ──────────────────────────────────────────────────────
await vm.dispose();
console.log("=== VM disposed. All primitives work. ===");
};
try {
await main();
} catch (error) {
console.error(error);
process.exit(1);
}

View File

@@ -1,132 +0,0 @@
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

@@ -1,32 +0,0 @@
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";
export const description =
"Works one repository issue inside an isolated AgentOS workspace.";
export const route: AgentRouteHandler = (_context, next) => next();
export default defineAgent(({ env, id }) => {
const { AGENT_MODEL_NAME, AGENT_MODEL_PROVIDER } = parseAgentEnv(env);
return {
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 /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. 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 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

@@ -1,51 +0,0 @@
import { parseAgentEnv } from "@code/env/agent";
import { defineAgent } from "@flue/runtime";
import { local } from "@flue/runtime/node";
import { createGitRemoteTools, makeGitRemoteConfig } from "../tools/git-remote";
import { createWorkflowTools } from "../tools/workflow";
const INSTRUCTIONS = `You are Zopu Dev, the development agent for the canonical zopu-code repository (self-hosted Gitea: puter/zopu-code).
## Your job
You talk with the user and turn agreed-upon work into tracked issues, then kick off autonomous implementation. You operate on exactly one repository.
## Loop
1. Understand the request. Ask one focused clarifying question only when genuinely ambiguous. Do not create work from casual chat.
2. Check for duplicates. Call list_issues to see what already exists.
3. Create the issue. Call create_issue with a clear title and a body that captures the acceptance criteria. Report the issue number and URL back to the user.
4. Start work only on explicit confirmation. When the user says to start (e.g. "go", "start", "work on it"), call start_workflow with the issue number. This spins up a Codex agent in an isolated worktree that implements, verifies, commits, and opens a pull request.
5. Report outcomes plainly: "Created issue #N: <title> — <url>" and "Started run <id> (status <status>)".
## Rules
- Never invent issue numbers. Always create or list first.
- Never start work without explicit user confirmation.
- Titles are concise; bodies carry the acceptance criteria.
- If a tool returns an error with a reason, surface it and suggest next steps instead of retrying blindly.`;
export default defineAgent(({ env }) => {
const agentEnv = parseAgentEnv(env);
const gitConfig = makeGitRemoteConfig(agentEnv);
return {
description:
"Development agent that creates issues on the canonical zopu-code repo and starts autonomous Codex work runs.",
instructions: INSTRUCTIONS,
model: `${agentEnv.AGENT_MODEL_PROVIDER}/${agentEnv.AGENT_MODEL_NAME}`,
sandbox: local({ cwd: process.cwd() }),
tools: [
...createGitRemoteTools(gitConfig),
...createWorkflowTools({
convexUrl: agentEnv.CONVEX_URL,
token: agentEnv.FLUE_DB_TOKEN,
}),
],
};
});

View File

@@ -1,22 +1,13 @@
import path from "node:path";
import { parseAgentEnv } from "@code/env/agent";
import { defineAgent } from "@flue/runtime";
// import { agentos } from "../sandboxes/agentos";
import { local } from "@flue/runtime/node";
import { createSliceOneTools } from "../tools/slice-one";
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.
const INSTRUCTIONS = `You are Zopu for product Slice 1: Conversation to Signal to proposed Work.
## 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.
The application stores each user message as exact evidence before you process it. You may interpret that evidence, but never supply, rewrite, or invent source text.
## Work routing loop
@@ -24,7 +15,9 @@ 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.
Direct questions, casual conversation, and image-reading requests must be answered immediately without calling any tools.
2. **Identify project context.** Call list_projects. If there is one project, use it. If there are several and the request is ambiguous, ask one focused question.
3. **Create a Signal (only when actionable).** When the message is actionable:
a. Call list_signal_evidence to see the exact admitted user messages.
@@ -32,46 +25,43 @@ When a user sends a message, follow this decision flow:
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.
4. **Route the Signal.** After creating the Signal:
a. Call list_proposed_work for the project.
b. If the Signal clearly describes the same desired outcome as existing Work, call attach_signal_to_work.
c. Otherwise call create_work_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.
- "Captured [Signal title] and linked it to [Work title]."
- "Captured [Signal title] and proposed [Work title]."
- Keep the response brief; the product renders the durable Work card separately.
## Rules
- Never supply or rewrite the raw source message text. The control plane copies it server-side.
- Never create work from casual chat.
- You receive and can see images attached to the current user message. This model supports image input. Never claim images are unavailable, omitted, or unsupported. If a message has attached images, inspect them before responding.
- 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.`;
- Do not start implementation, planning, sandboxes, Git, verification, or delivery. Those are explicitly outside Slice 1.
- Proposed Work is the only Work status in this slice.`;
export { authenticatedAgentRoute as route } from "../auth";
export {
convexAgentRoute as attachments,
convexAgentRoute as route,
} from "../auth";
export default defineAgent(({ env, id }) => {
const { AGENT_MODEL_NAME, AGENT_MODEL_PROVIDER } = parseAgentEnv(env);
return {
cwd: repositoryRoot,
description:
"Project-scoped work-routing agent that turns conversation into Signals and routes them to issues.",
"Turns actionable conversation into provenanced Signals and proposed Work.",
instructions: INSTRUCTIONS,
model: `${AGENT_MODEL_PROVIDER}/${AGENT_MODEL_NAME}`,
// sandbox: agentos(),
sandbox: local({ cwd: repositoryRoot }),
skills: [paseo],
tools: [paseoCli, ...createSignalRoutingTools(id, env)],
thinkingLevel: "medium",
tools: createSliceOneTools(id, env),
};
});

View File

@@ -3,24 +3,28 @@ 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, {
api: agentEnv.AGENT_MODEL_API,
apiKey: agentEnv.AGENT_MODEL_API_KEY,
apiKey:
agentEnv.AGENT_MODEL_PROVIDER === "openrouter"
? (agentEnv.OPENROUTER_API_KEY ?? agentEnv.AGENT_MODEL_API_KEY)
: agentEnv.AGENT_MODEL_API_KEY,
baseUrl: agentEnv.AGENT_MODEL_BASE_URL,
models: {
[agentEnv.AGENT_MODEL_NAME]: {
contextWindow: agentEnv.AGENT_MODEL_CONTEXT_WINDOW,
maxTokens: agentEnv.AGENT_MODEL_MAX_TOKENS,
},
"openai/gpt-oss-20b:free": {
contextWindow: 131_072,
maxTokens: agentEnv.AGENT_MODEL_MAX_TOKENS,
},
},
});
const app = new Hono();
app.post("/project-requests", projectRequestRoute);
app.route("/", flue());
app.route("/", flue() as unknown as Hono);
export default app;

View File

@@ -1,320 +1,18 @@
/**
* Authenticated Flue route middleware for the organization-scoped global agent.
*
* Every request to the Zopu agent routes through here. The middleware:
*
* 1. Requires a Bearer access token (the Better Auth/Convex JWT).
* 2. Creates a fresh, request-scoped {@link ConvexHttpClient} and sets the
* token on it — never on a shared/global client. Auth is per-request.
* 3. Resolves (ensuring) the caller's current personal organization.
* 4. Requires the agent instance id (`:id`) to equal that organization id, so
* one organization cannot use or observe another's agent instance.
* 5. Authorizes GET/SSE observation routes as strictly as POST.
* 6. For POST only: captures exact user-message evidence *before* Flue
* admission (`beginUserMessage`, status `admitting`), then patches it to
* `admitted` with the Flue receipt submission id, or `failed` on error.
* Observation requests (GET/HEAD) never create evidence.
*
* The organization id, conversation id, message id, role, and timestamp are
* never trusted from the client or an agent tool — they are resolved
* server-side. Only the raw message text travels from the user, and it is
* stored verbatim (never trimmed or rewritten).
*/
import { parseAgentEnv } from "@code/env/agent";
import type { AgentRouteHandler } from "@flue/runtime";
import { ConvexHttpClient } from "convex/browser";
import { makeFunctionReference } from "convex/server";
// ---------------------------------------------------------------------------
// Typed function references (no generated backend bindings imported).
// ---------------------------------------------------------------------------
/** Only Convex may invoke or observe the organization-scoped Flue agent. */
export const convexAgentRoute: AgentRouteHandler = async (context, next) => {
const env = parseAgentEnv(process.env);
const authorization = context.req.header("authorization");
if (authorization !== `Bearer ${env.FLUE_DB_TOKEN}`) {
return context.json({ error: "Unauthorized" }, 401);
}
interface OrganizationView {
readonly _id: string;
readonly _creationTime: number;
readonly name: string;
readonly kind: "personal" | "team";
readonly createdBy: string;
readonly createdAt: number;
}
const organizationId = context.req.header("x-zopu-organization-id");
if (!organizationId || organizationId !== context.req.param("id")) {
return context.json({ error: "Forbidden" }, 403);
}
interface ConversationMessageView {
readonly _id: string;
readonly _creationTime: number;
readonly organizationId: string;
readonly conversationId: string;
readonly messageId: string;
readonly submissionId: string | null;
readonly clientRequestId: string;
readonly role: "user";
readonly rawText: string;
readonly status: "admitting" | "admitted" | "failed";
readonly createdAt: number;
}
const ensurePersonalOrganization = makeFunctionReference<
"mutation",
Record<string, never>,
OrganizationView
>("organizations:ensurePersonalOrganization");
const beginUserMessage = makeFunctionReference<
"mutation",
{
readonly organizationId: string;
readonly clientRequestId: string;
readonly rawText: string;
},
ConversationMessageView
>("conversationMessages:beginUserMessage");
const markAdmitted = makeFunctionReference<
"mutation",
{
readonly organizationId: string;
readonly clientRequestId: string;
readonly submissionId: string;
},
ConversationMessageView
>("conversationMessages:markAdmitted");
const markFailed = makeFunctionReference<
"mutation",
{
readonly organizationId: string;
readonly clientRequestId: string;
},
ConversationMessageView
>("conversationMessages:markFailed");
// ---------------------------------------------------------------------------
// Request-scoped authenticated Convex client.
// ---------------------------------------------------------------------------
const { CONVEX_URL } = parseAgentEnv(process.env);
/**
* A short-lived authenticated Convex client bound to one HTTP request. The
* 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.
*/
export const createAuthenticatedClient = (
accessToken: string
): ConvexHttpClient => {
const client = new ConvexHttpClient(CONVEX_URL);
client.setAuth(accessToken);
return client;
};
/**
* Minimal structural shape the header helpers read from an incoming request.
* Using this avoids a structural clash between the global (Bun) `Request`/
* `Headers` and the undici types that Hono's context exposes, while staying
* type-safe.
*/
interface HeaderSource {
readonly headers: {
readonly get: (name: string) => string | null;
};
}
/**
* Extract and validate the Bearer access token from the request. Returns null
* when absent or malformed so the caller can produce a clean 401.
*/
export const extractBearerToken = (request: HeaderSource): string | null => {
const header = request.headers.get("authorization");
if (!header) {
return null;
}
const match = /^Bearer\s+(?<token>\S+)$/u.exec(header);
return match?.groups?.token ?? null;
};
/**
* Read the stable per-send request id supplied by the web client. This is
* generated once when the user sends a message and reused across fetch/stream
* retries, so it is a reliable idempotency key for evidence capture.
*/
const extractClientRequestId = (request: HeaderSource): string | null => {
const header = request.headers.get("x-zopu-request-id");
if (!header || header.length === 0) {
return null;
}
return header;
};
/**
* Safely parse the direct agent payload to extract the exact user message
* text. Mirrors Flue's `DirectAgentPayload` shape `{ message, images? }`.
* Returns null for non-JSON or missing `message` so the request is rejected
* with 400 rather than crashing the middleware.
*/
const extractMessageText = async (
request: HeaderSource & { readonly json: () => Promise<unknown> }
): Promise<string | null> => {
const contentType = request.headers.get("content-type") ?? "";
if (!contentType.includes("application/json")) {
return null;
}
try {
const body = (await request.json()) as unknown;
if (
typeof body !== "object" ||
body === null ||
!("message" in body) ||
typeof (body as { message?: unknown }).message !== "string"
) {
return null;
}
return (body as { message: string }).message;
} catch {
return null;
}
};
/**
* Read the Flue admission receipt submission id from the response Flue
* produced after `next()`. The response body is `202 { streamUrl, offset,
* submissionId }`. Returns null when the body has no submission id (e.g. an
* error response).
*/
const extractSubmissionId = async (
response: HeaderSource & {
readonly clone: () => { readonly json: () => Promise<unknown> };
}
): Promise<string | null> => {
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.includes("application/json")) {
return null;
}
try {
const body = (await response.clone().json()) as unknown;
if (
typeof body === "object" &&
body !== null &&
"submissionId" in body &&
typeof (body as { submissionId?: unknown }).submissionId === "string"
) {
return (body as { submissionId: string }).submissionId;
}
} catch {
// Non-JSON or unreadable body: no submission id to record.
}
return null;
};
/**
* Admit a direct submission through Flue and record the admission outcome on
* the normalized evidence row. On admission failure, the evidence is marked
* failed and the original error is rethrown — never swallowed. On success,
* the Flue receipt submission id (if present) patches the evidence to
* admitted.
*/
const admitAndRecordEvidence = async (
continueAdmission: () => Promise<void>,
client: ConvexHttpClient,
getResponse: () => Response | undefined,
evidence: {
readonly organizationId: string;
readonly clientRequestId: string;
}
): Promise<void> => {
try {
await continueAdmission();
} catch (error) {
try {
await client.mutation(markFailed, evidence);
} catch {
// Best-effort: the original error is the primary concern.
}
throw error;
}
// Patch the evidence with the Flue receipt submission id. If the response
// carries no submission id (unexpected), leave the evidence in its begun
// state rather than guessing.
const response = getResponse();
if (!response) {
return;
}
const submissionId = await extractSubmissionId(response);
if (!submissionId) {
return;
}
try {
await client.mutation(markAdmitted, {
...evidence,
submissionId,
});
} catch {
// Best-effort: the admission already succeeded and the response is
// finalized. A failure to patch metadata must not break the request.
}
};
/**
* The authenticated Flue route middleware. Authorizes every method, and for
* POST captures normalized user-message evidence around Flue admission.
*/
export const authenticatedAgentRoute: AgentRouteHandler = async (c, next) => {
const accessToken = extractBearerToken(c.req.raw);
if (!accessToken) {
return c.json({ error: "Unauthorized" }, 401);
}
const client = createAuthenticatedClient(accessToken);
// Resolve (ensuring) the caller's current personal organization.
let organization: OrganizationView;
try {
organization = await client.mutation(ensurePersonalOrganization, {});
} catch {
return c.json({ error: "Unauthorized" }, 401);
}
// The agent instance id must be the caller's organization id. This is the
// hard tenancy boundary: one organization cannot use or observe another's
// agent instance.
const instanceId = c.req.param("id") ?? "";
if (instanceId !== organization._id) {
return c.json({ error: "Forbidden" }, 403);
}
// Observation routes (GET/HEAD) are authorized but never create evidence.
if (c.req.method !== "POST") {
return next();
}
// --- Evidence capture (POST only) -------------------------------------
const clientRequestId = extractClientRequestId(c.req.raw);
const messageText = await extractMessageText(c.req.raw.clone());
if (!clientRequestId) {
return c.json({ error: "Missing x-zopu-request-id" }, 400);
}
if (messageText === null) {
return c.json({ error: "Invalid message payload" }, 400);
}
// Begin evidence before Flue admission so the message is durable even if
// admission fails or the process is interrupted.
try {
await client.mutation(beginUserMessage, {
clientRequestId,
organizationId: organization._id,
rawText: messageText,
});
} catch {
return c.json({ error: "Evidence capture failed" }, 500);
}
// Admit through Flue. After next(), c.res holds the admission response.
// Evidence is patched from the admission receipt, or marked failed without
// swallowing the original error.
await admitAndRecordEvidence(next, client, () => c.res, {
clientRequestId,
organizationId: organization._id,
});
return await next().then(() => context.res);
};

View File

@@ -1,155 +0,0 @@
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

@@ -1,307 +0,0 @@
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

@@ -1,105 +0,0 @@
# 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

@@ -1,109 +0,0 @@
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

@@ -1,170 +0,0 @@
/* 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

@@ -1,118 +0,0 @@
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

@@ -1,132 +0,0 @@
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");
});
});

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