61 Commits

Author SHA1 Message Date
-Puter
7668fa69cc fix: route web auth through same origin 2026-07-29 17:01:22 +05:30
-Puter
3ffa1cfc7c feat: project durable agent conversations 2026-07-29 08:19:17 +05:30
-Puter
18eb150d7d fix: serve web assets from app directory 2026-07-29 08:13:52 +05:30
-Puter
e1b0b731e0 deploy: attach backend to shared network 2026-07-29 08:09:30 +05:30
-Puter
d428a2492b fix: make primitives Node ESM compatible 2026-07-29 07:51:07 +05:30
-Puter
fe0fd9b16c deploy: enable Node TypeScript export resolution 2026-07-29 07:40:44 +05:30
-Puter
fc1fcf5d44 deploy: run agent backend with Bun 2026-07-29 07:31:36 +05:30
-Puter
3ae72864bd deploy: target agent package during Flue build 2026-07-29 07:21:02 +05:30
-Puter
0d5d54caa8 deploy: build agent without local env file 2026-07-29 07:15:19 +05:30
-Puter
830bcc4756 deploy: build applications on Node base 2026-07-29 07:09:07 +05:30
-Puter
0e56a462cd deploy: prefer Node for native install scripts 2026-07-29 07:03:32 +05:30
-Puter
9fb293a539 deploy: build native modules with Node 2026-07-29 07:00:47 +05:30
-Puter
062c00f53c deploy: install frontend build toolchain 2026-07-29 06:58:04 +05:30
-Puter
a7e70c9b2a deploy: install backend build toolchain 2026-07-29 06:56:36 +05:30
-Puter
526ed59776 deploy: fix compose build contexts 2026-07-29 06:46:07 +05:30
-Puter
fd3980c6bf deploy: split Dokploy services 2026-07-29 06:42:06 +05:30
-Puter
d8a4bbe804 chore cleanp 2026-07-29 01:24:37 +05:30
-Puter
9eb6bcd25f refactor canonical components and remove slice 1 2026-07-29 01:24:16 +05:30
-Puter
a907539810 flue refactor and migrations 2026-07-29 00:45:46 +05:30
-Puter
601aca73c2 pnpm migrations 2026-07-29 00:45:33 +05:30
-Puter
ffecff3857 Run fixed Zopu worktrees with Pi 2026-07-28 22:20:42 +05:30
-Puter
0d7162544b fix: run AgentOS workspaces on remote runner 2026-07-28 20:08:46 +05:30
-Puter
092a9793ea fix: resolve repository hosts outside AgentOS 2026-07-28 18:30:08 +05:30
-Puter
5ee0a8d50e fix: allow AgentOS process environment 2026-07-28 18:27:16 +05:30
-Puter
420676f2d7 fix: allow AgentOS command execution 2026-07-28 18:21:31 +05:30
-Puter
24d82e2a06 fix: allow AgentOS workspace filesystem 2026-07-28 18:18:24 +05:30
-Puter
d47fa0e96a fix: allow slow AgentOS workspace boot 2026-07-28 18:14:32 +05:30
-Puter
1e7c893985 fix: apply AgentOS runtime permissions 2026-07-28 17:49:41 +05:30
-Puter
f9ebcb4a01 fix: align AgentOS runtime packages 2026-07-28 17:40:40 +05:30
-Puter
dceaa2b417 fix: allow AgentOS outbound networking 2026-07-28 17:29:47 +05:30
-Puter
a4f121e190 chore: preserve work attempt schema order 2026-07-28 17:12:54 +05:30
-Puter
4edd456b5b feat: wire real AgentOS execution 2026-07-28 16:52:49 +05:30
-Puter
4d9f6da41b fix: install AgentOS Codex session adapter 2026-07-28 16:09:34 +05:30
-Puter
a53029cf7a fix: await Rivet envoy registration 2026-07-28 15:34:08 +05:30
-Puter
eede4c10ba fix: build runner native modules with Node 2026-07-28 15:20:03 +05:30
-Puter
35169672e1 fix: add runner native build toolchain 2026-07-28 15:12:57 +05:30
-Puter
359d9e2285 feat: add durable AgentOS slice execution 2026-07-28 14:50:45 +05:30
-Puter
05a3baaac3 fix: bundle all SSR deps to eliminate React instance duplication 2026-07-28 03:27:41 +05:30
-Puter
4cc40cb2e4 fix: broaden SSR noExternal to cover all React-dependent workspace packages 2026-07-28 03:25:27 +05:30
-Puter
814df02be9 fix: bundle @convex-dev/better-auth in SSR to prevent React instance duplication 2026-07-28 03:21:41 +05:30
-Puter
d7f6cbdcdc fix: pin react/react-dom to 19.2.8 to prevent SSR duplicate instance crash 2026-07-28 03:16:34 +05:30
-Puter
a8d946b6a9 Slice 4 control-plane repairs, lint/catalog cleanup, Flue adapter fixes
Convex control plane (Slices 1-4 prerequisites for Slice 5):
- Versioned Definition/Design persistence: revisions preserve history
  instead of deleting prior slices/approvals
- Fenced conversation turn queue: attempt-numbered leases prevent stale
  worker overwrites; expired turns reconciled by cron
- Fenced Run/Attempt claiming: only queued attempts from running Runs can
  be claimed; lease expiry checks on every finish/checkpoint
- Multi-slice progression: successful slice marks next slice ready instead
  of completing the whole Work; completed Runs blocked from retry
- Typed AttemptClassification in schema and resolver decisions table
- Durable resolver decisions for normal outcomes, cancellation, and
  lease-expiry recovery
- Persistent artifacts and delivery metadata with provenance, source
  revision, verification status, and controlled delivery transitions
- High-impact open questions block definition approval
- Question submission gated to defining/awaiting-approval states only
- Delivery status updates validate JSON metadata before persisting
- Planner failures recorded as durable blocked events
- Approval identity uses canonical tokenIdentifier

Primitives:
- decodeDefinition separates decode from validate
- Design validation enforces 1-4 slices with unique IDs and valid deps
- Artifact and delivery draft schemas with verified-revision binding
- Delivery status transition table

Package management:
- Consolidated shared deps into single catalog (hono, valibot, streamdown,
  @types/react, @types/node, @tailwindcss/*, react-native)
- Removed cross-version Hono boundary: flue() exported as Fetchable
- Deleted bun.lock and regenerated from clean catalog resolution

Lint/format:
- Removed .eslintignore; oxc uses native ignorePatterns in oxlint.config.ts
- Deleted redundant packages/backend/.oxlintrc.json
- Added repos/** and scripts/** to ignore patterns
- Convex-specific rule overrides for ES2022 target constraints
- Fixed all oxc errors in fluePersistence.ts and agents/src/db.ts
- Fixed all formatting across changed files
2026-07-28 02:53:05 +05:30
0e32c35515 Slice 4 hardening: bounded resolver, terminal states, reconciliation (#21)
Fixes review findings before Slice 5. Resolver: resolveOutcome + WORK_STATUS_FOR_OUTCOME + lifecycle fixes. Execution: bounded finishAttempt retry, terminal Work status preservation, slice validation, bounded reconciliation with 30s cron. Planning: revision guards, durable submitQuestion, design-approval invalidation. Primitives 69 (+4), backend 22 (+10) tests; root typecheck/build/Ultracite pass.
2026-07-27 18:39:09 +00:00
-Puter
005b26fa32 Slices 2-4: Work becomes executable
Domain primitives: versioned WorkDefinition/DesignPacket with risk, questions,
and approval gates; lifecycle transition table with invalidation rules;
Resolver (Run/Attempt/outcomes/CodingKitV0); provider-neutral HarnessRuntime
with normalized events and a deterministic FakeHarnessLive that always reaches
a terminal classification and never claims implementation.

Convex durable model: workDefinitions, workQuestions, workApprovals,
designPackets, workSlices, workRuns, workAttempts, workAttemptEvents tables;
broadened works.status union; generalized workEvents with optional signalId,
referenceId, and payloadJson. Authenticated commands for definition request/
save/revise/approve, question answer/withdraw, design save/revise/approve, and
simulated execution start/cancel/retry with leased attempts, checkpointed
events, and expired-lease reconciliation.

Private work-planner FLUE agent with proposal-only tools (definition, design,
question). Convex validates and stores every proposal; FLUE never approves or
advances Work.

Expanded Work card with Outcome, Design, and Build sections wired to the new
mutations. Slice 1 conversation and provenance preserved.
2026-07-27 22:52:14 +05:30
cb7484912c Convex-only Slice 1: clients talk to Convex, Flue is a private worker (#20) 2026-07-27 16:03:36 +00:00
-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
347 changed files with 43037 additions and 23695 deletions

View File

@@ -6,10 +6,8 @@ SITE_URL=http://localhost:5173
NATIVE_APP_URL=code:// NATIVE_APP_URL=code://
# Browser and native public endpoints # Browser and native public endpoints
VITE_AUTH_URL=http://localhost:5173
VITE_CONVEX_URL=https://example.convex.cloud 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_URL=https://example.convex.cloud
EXPO_PUBLIC_CONVEX_SITE_URL=https://example.convex.site EXPO_PUBLIC_CONVEX_SITE_URL=https://example.convex.site
@@ -19,20 +17,26 @@ DAEMON_NAME=Local MacBook
DAEMON_VERSION=0.0.0 DAEMON_VERSION=0.0.0
DAEMON_HEARTBEAT_MS=15000 DAEMON_HEARTBEAT_MS=15000
DAEMON_COMMAND_LEASE_MS=60000 DAEMON_COMMAND_LEASE_MS=60000
# RIVET_ENDPOINT=http://localhost:6420 RIVET_ENDPOINT=http://localhost:6420
RIVET_PUBLIC_ENDPOINT=http://localhost:6420
RIVET_WORKSPACE_TOKEN=replace-with-a-long-random-workspace-token
# Flue persistence adapter # Flue persistence adapter
FLUE_DB_TOKEN=replace-with-a-long-random-token FLUE_DB_TOKEN=replace-with-a-long-random-token
FLUE_URL=http://localhost:3583
# Agent model provider # Agent model provider
AGENT_MODEL_PROVIDER=cheaptricks AGENT_MODEL_PROVIDER=xiaomi
AGENT_MODEL_NAME=glm-5.2 AGENT_MODEL_NAME=mimo-v2.5
AGENT_MODEL_API=openai-completions AGENT_MODEL_API=openai-completions
AGENT_MODEL_BASE_URL=https://ai.example.com/v1 AGENT_MODEL_BASE_URL=https://ai.example.com/v1
AGENT_MODEL_API_KEY=replace-with-provider-api-key AGENT_MODEL_API_KEY=replace-with-provider-api-key
AGENT_MODEL_CONTEXT_WINDOW=262000 AGENT_MODEL_CONTEXT_WINDOW=1048576
AGENT_MODEL_MAX_TOKENS=131072 AGENT_MODEL_MAX_TOKENS=131072
# Self-hosted git (Gitea) — canonical repository host and API token # Self-hosted git (Gitea) — canonical repository host and API token
GITEA_URL=https://git.openputer.com GITEA_URL=https://git.openputer.com
GITEA_TOKEN=replace-with-a-gitea-personal-access-token GITEA_TOKEN=replace-with-a-gitea-personal-access-token
# Zopu lean server (standalone chat + work actor)
VITE_ZOPU_SERVER_URL=http://localhost:3590

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
verify-deps-before-run=false

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.

36
apps/web/Dockerfile Normal file
View File

@@ -0,0 +1,36 @@
FROM oven/bun:1.3.14 AS bun
FROM node:24-bookworm-slim AS build
COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
g++ \
make \
python3 \
&& rm -rf /var/lib/apt/lists/*
COPY . .
ARG VITE_AUTH_URL
ARG VITE_CONVEX_URL
ENV VITE_AUTH_URL=$VITE_AUTH_URL
ENV VITE_CONVEX_URL=$VITE_CONVEX_URL
RUN bun install --frozen-lockfile
RUN bun run --filter web build
FROM node:24-bookworm-slim
ENV HOST=0.0.0.0
ENV NODE_ENV=production
ENV PORT=3000
WORKDIR /app/apps/web
COPY --from=build /app /app
EXPOSE 3000
CMD ["node", "node_modules/.bin/react-router-serve", "build/server/index.js"]

View File

@@ -15,8 +15,6 @@
"@code/env": "workspace:*", "@code/env": "workspace:*",
"@code/primitives": "workspace:*", "@code/primitives": "workspace:*",
"@code/ui": "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/fs-routes": "^8.1.0",
"@react-router/node": "^8.1.0", "@react-router/node": "^8.1.0",
"@react-router/serve": "^8.1.0", "@react-router/serve": "^8.1.0",
@@ -24,18 +22,18 @@
"isbot": "^5.1.44", "isbot": "^5.1.44",
"lucide-react": "catalog:", "lucide-react": "catalog:",
"next-themes": "catalog:", "next-themes": "catalog:",
"react": "^19.2.7", "react": "catalog:",
"react-dom": "^19.2.7", "react-dom": "catalog:",
"react-router": "^8.1.0", "react-router": "^8.1.0",
"sonner": "catalog:", "sonner": "catalog:",
"streamdown": "2.5.0" "streamdown": "catalog:"
}, },
"devDependencies": { "devDependencies": {
"@code/config": "workspace:*", "@code/config": "workspace:*",
"@react-router/dev": "^8.1.0", "@react-router/dev": "^8.1.0",
"@tailwindcss/vite": "^4.3.2", "@tailwindcss/vite": "catalog:",
"@types/node": "^22.13.14", "@types/node": "catalog:",
"@types/react": "^19.2.17", "@types/react": "catalog:",
"@types/react-dom": "catalog:", "@types/react-dom": "catalog:",
"react-router-devtools": "^6.2.1", "react-router-devtools": "^6.2.1",
"tailwindcss": "catalog:", "tailwindcss": "catalog:",

View File

@@ -1,7 +1,7 @@
import type { Config } from "@react-router/dev/config"; import type { Config } from "@react-router/dev/config";
export default { export default {
// Desktop addons package static web assets; SSR output cannot be bundled
ssr: false,
appDirectory: "src", appDirectory: "src",
ssr: true,
} satisfies Config; } 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, MobileChatBubble,
MobileChatMessage, MobileChatMessage,
} from "@code/ui/components/mobile-chat"; } from "@code/ui/components/mobile-chat";
import type { ReactNode } from "react";
import { getMessageText, isMessageStreaming } from "@/lib/chat/transforms"; import {
import type { ChatMessageProps } from "@/lib/chat/types"; getMessageText,
getReasoningText,
isReasoningStreaming,
isMessageStreaming,
} from "@/lib/chat/transforms";
import type {
AssistantResponseState,
ChatMessageProps,
} from "@/lib/chat/types";
import { AssistantIdentity } from "./assistant-identity"; import { AssistantIdentity } from "./assistant-identity";
import { ChatToolCall } from "./chat-tool-call"; import { MessageAttachments } from "./chat-attachments";
export const ChatMessage = ({ message }: ChatMessageProps) => { const ReasoningTrace = ({
const isUser = message.role === "user"; isStreaming,
const isStreaming = isMessageStreaming(message); text,
const text = getMessageText(message); }: {
readonly isStreaming: boolean;
if (message.parts.length === 0 && !isStreaming) { readonly text: string;
}) => {
if (!text) {
return null; 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; const AssistantText = ({
if (!isUser) { hasReasoning,
content = text ? ( isStreaming,
text,
}: {
readonly hasReasoning: boolean;
readonly isStreaming: boolean;
readonly text: string;
}) => {
if (text) {
return (
<MessageResponse <MessageResponse
caret={isStreaming ? "block" : undefined} caret={isStreaming ? "block" : undefined}
className="chat-markdown min-w-0" className="chat-markdown min-w-0"
@@ -34,33 +68,71 @@ export const ChatMessage = ({ message }: ChatMessageProps) => {
> >
{text} {text}
</MessageResponse> </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 sender = isUser ? "user" : "assistant";
const content = isUser ? (
text
) : (
<AssistantText
hasReasoning={Boolean(reasoning)}
isStreaming={isStreaming}
text={text}
/>
);
const identityState = assistantState(isStreaming, reasoningStreaming, text);
return ( return (
<Message className="max-w-full gap-0" from={message.role}> <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"> <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}> <MobileChatMessage className="chat-message" sender={sender}>
<div className={isUser ? "max-w-full" : "w-full min-w-0"}> <div className={isUser ? "max-w-full" : "w-full min-w-0"}>
{!isUser && ( {isUser ? null : <AssistantIdentity state={identityState} />}
<AssistantIdentity state={isStreaming ? "writing" : undefined} />
)}
<MobileChatBubble <MobileChatBubble
className={isUser ? "whitespace-pre-wrap" : undefined} className={isUser ? "whitespace-pre-wrap" : undefined}
sender={sender} sender={sender}
> >
{message.parts.map((part) => <MessageAttachments parts={fileParts} />
part.type === "dynamic-tool" ? ( {isUser ? null : (
<ChatToolCall key={part.toolCallId} part={part} /> <ReasoningTrace
) : null isStreaming={reasoningStreaming}
text={reasoning}
/>
)} )}
{content} {content}
</MobileChatBubble> </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,112 @@
import { Button } from "@code/ui/components/button";
import { ImagePlus, LoaderCircle, Send } from "lucide-react";
import { useRef } from "react";
import { PendingChatAttachments } from "@/components/chat/chat-attachments";
import { useChatImages } from "@/hooks/chat/use-chat-images";
import type { WorkspaceState } from "@/lib/workspace/types";
export const ConversationComposer = ({
draft,
onDraftChange,
workspace,
}: {
readonly draft: string;
readonly onDraftChange: (draft: string) => void;
readonly workspace: WorkspaceState;
}) => {
const imageInput = useRef<HTMLInputElement>(null);
const attachments = useChatImages();
const busy =
workspace.agent.status === "submitted" ||
workspace.agent.status === "streaming";
const send = async () => {
const message = draft.trim();
if (!message || busy) {
return;
}
await workspace.agent.sendMessage(message, {
images: attachments.images.map((image) => image.file),
});
onDraftChange("");
attachments.clear();
};
return (
<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}
{workspace.agent.error ? (
<p className="mx-auto mb-2 max-w-2xl text-xs text-red-700">
{workspace.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) => onDraftChange(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>
);
};

View File

@@ -0,0 +1,108 @@
import { projectWorkNotices } from "@code/primitives/work";
import {
Conversation,
ConversationContent,
} from "@code/ui/components/ai-elements/conversation";
import { MessageSquareText } from "lucide-react";
import { useMemo } from "react";
import { ChatMessage } from "@/components/chat/chat-message";
import { ChatThinkingResponse } from "@/components/chat/chat-thinking-response";
import { buildWorkspaceTimeline } from "@/lib/workspace/presentation";
import type { WorkRecord, WorkspaceState } from "@/lib/workspace/types";
import { WorkCard } from "./work-card";
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>
);
export const ConversationFeed = ({
highlightedMessageId,
onSourceSelect,
workspace,
}: {
readonly highlightedMessageId?: string;
readonly onSourceSelect: (rawText: string) => void;
readonly workspace: WorkspaceState;
}) => {
const works = workspace.works ?? [];
const workById = new Map(
works.map((work) => [String(work._id), work] as const)
);
const notices = projectWorkNotices(works);
const timeline = useMemo(
() => buildWorkspaceTimeline(workspace.agent.messages, notices),
[notices, workspace.agent.messages]
);
return (
<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">
{!workspace.agent.historyReady && timeline.length === 0 ? (
<ConversationLoading />
) : null}
{workspace.agent.historyReady && timeline.length === 0 ? (
<ConversationEmptyState />
) : null}
{timeline.map((item) => {
if (item.kind === "work") {
const work = workById.get(item.notice.workId) as
| WorkRecord
| undefined;
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>
<div className="rounded-sm">
<span className="sr-only">Proposed Work</span>
<WorkCard
onSourceSelect={onSourceSelect}
work={work}
workspace={workspace}
/>
</div>
</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={`workspace-message-${item.message.id}`}
key={item.message.id}
>
<ChatMessage message={item.message} />
</div>
);
})}
{workspace.agent.status === "submitted" ? (
<ChatThinkingResponse />
) : null}
</ConversationContent>
</Conversation>
);
};

View File

@@ -0,0 +1,50 @@
import { Button } from "@code/ui/components/button";
import { FolderGit2, LoaderCircle } from "lucide-react";
import type { WorkspaceState } from "@/lib/workspace/types";
export const ProjectConnectForm = ({
workspace,
}: {
readonly workspace: WorkspaceState;
}) => (
<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 workspace.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 a project</h1>
<p className="mt-2 text-sm leading-6 text-[#68665e]">
Turn actionable project 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) => workspace.setRepository(event.target.value)}
placeholder="https://github.com/owner/repository"
required
value={workspace.repository}
/>
{workspace.error ? (
<p className="mt-2 text-xs text-red-700">{workspace.error.message}</p>
) : null}
<Button
className="mt-3 h-12 w-full"
disabled={workspace.pending}
type="submit"
>
{workspace.pending ? (
<LoaderCircle className="size-4 animate-spin" />
) : null}
{workspace.pending ? "Connecting" : "Connect project"}
</Button>
</form>
</main>
);

View File

@@ -0,0 +1,63 @@
import { Button } from "@code/ui/components/button";
import { Menu, Settings } from "lucide-react";
import { useState } from "react";
import type { WorkspaceState } from "@/lib/workspace/types";
import { ProjectSettingsPanel } from "./project-settings-panel";
export const ProjectHeader = ({
onOpenDrawer,
workspace,
}: {
readonly onOpenDrawer: () => void;
readonly workspace: WorkspaceState;
}) => {
const [settingsOpen, setSettingsOpen] = useState(false);
const works = workspace.works ?? [];
return (
<header className="relative 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) => workspace.selectProject(event.target.value)}
value={workspace.selectedProject?.id ?? ""}
>
{(workspace.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
aria-label="Project settings"
className="mr-2 size-9"
onClick={() => setSettingsOpen((open) => !open)}
size="icon"
variant="outline"
>
<Settings className="size-4" />
</Button>
<button
className="flex h-9 items-center gap-2 border border-[#c9c5b9] bg-white px-3 text-xs lg:hidden"
onClick={onOpenDrawer}
type="button"
>
<Menu className="size-4" /> Work{" "}
{workspace.works === undefined ? "…" : works.length}
</button>
{settingsOpen ? (
<ProjectSettingsPanel
onClose={() => setSettingsOpen(false)}
workspace={workspace}
/>
) : null}
</header>
);
};

View File

@@ -0,0 +1,100 @@
import { Button } from "@code/ui/components/button";
import { AlertTriangle, X } from "lucide-react";
import { useState } from "react";
import type { WorkspaceState } from "@/lib/workspace/types";
export const ProjectSettingsPanel = ({
onClose,
workspace,
}: {
readonly onClose: () => void;
readonly workspace: WorkspaceState;
}) => {
const [serverUrl, setServerUrl] = useState("https://git.openputer.com");
const [username, setUsername] = useState("");
const [token, setToken] = useState("");
const handleClearOperationError = () => workspace.clearOperationError();
return (
<section className="absolute right-4 top-12 z-40 w-[min(92vw,360px)] border border-[#c9c5b9] bg-[#fffefa] p-4 shadow-xl">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold">Project Git</h2>
<button aria-label="Close settings" onClick={onClose} type="button">
<X className="size-4" />
</button>
</div>
<p className="mt-1 text-xs text-[#747168]">
{workspace.projectGitConnection
? `${workspace.projectGitConnection.provider} · ${workspace.projectGitConnection.serverUrl}`
: "No Git credentials attached"}
</p>
{workspace.operationError ? (
<p className="mt-2 flex items-start gap-1.5 border border-red-300 bg-red-50 px-2 py-1.5 text-xs text-red-800">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span className="min-w-0 flex-1 leading-5">
{workspace.operationError.message}
</span>
<button
aria-label="Dismiss error"
className="shrink-0"
onClick={handleClearOperationError}
type="button"
>
<X className="size-3.5" />
</button>
</p>
) : null}
<div className="mt-4 flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => void workspace.authorizeGithub()}
>
Authorize GitHub
</Button>
<Button size="sm" onClick={() => void workspace.connectLinkedGithub()}>
Use GitHub
</Button>
</div>
<div className="mt-4 space-y-2 border-t border-[#e7e3d9] pt-4">
<input
aria-label="Gitea server URL"
className="h-9 w-full border px-2 text-xs"
onChange={(event) => setServerUrl(event.target.value)}
value={serverUrl}
/>
<input
aria-label="Gitea username"
className="h-9 w-full border px-2 text-xs"
onChange={(event) => setUsername(event.target.value)}
placeholder="Username (optional)"
value={username}
/>
<input
aria-label="Gitea access token"
className="h-9 w-full border px-2 text-xs"
onChange={(event) => setToken(event.target.value)}
placeholder="Personal access token"
type="password"
value={token}
/>
<Button
className="w-full"
disabled={!token.trim()}
size="sm"
onClick={() => {
void workspace.connectGitea({
serverUrl,
token,
username: username || undefined,
});
setToken("");
}}
>
Connect Gitea
</Button>
</div>
</section>
);
};

View File

@@ -0,0 +1,123 @@
import { useRef, useState } from "react";
import { useProjectWorkspace } from "@/hooks/workspace/use-project-workspace";
import { useVisualViewportStyle } from "@/hooks/workspace/use-visual-viewport";
import { findSourceMessageTarget } from "@/lib/workspace/presentation";
import { ConversationComposer } from "./conversation-composer";
import { ConversationFeed } from "./conversation-feed";
import { ProjectConnectForm } from "./project-connect-form";
import { ProjectHeader } from "./project-header";
import { WorkFeed } from "./work-feed";
const ProjectLoading = () => (
<main className="grid min-h-svh place-items-center bg-[#f2f0e7]">
<span className="size-5 animate-spin rounded-full border-2 border-[#20201d] border-t-transparent" />
</main>
);
export const ProjectWorkspacePage = () => {
const workspace = useProjectWorkspace();
const viewportStyle = useVisualViewportStyle();
const [draft, setDraft] = useState("");
const [drawerOpen, setDrawerOpen] = useState(false);
const [highlightedMessageId, setHighlightedMessageId] = useState<string>();
const highlightTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
if (workspace.projects === undefined) {
return <ProjectLoading />;
}
if (!workspace.selectedProject) {
return <ProjectConnectForm workspace={workspace} />;
}
const works = workspace.works ?? [];
const revealSourceMessage = (rawText: string) => {
const messageId = findSourceMessageTarget(
workspace.agent.messages,
rawText
);
if (!messageId) {
return;
}
setDrawerOpen(false);
setHighlightedMessageId(messageId);
if (highlightTimer.current) {
clearTimeout(highlightTimer.current);
}
requestAnimationFrame(() =>
document
.querySelector(`#workspace-message-${CSS.escape(messageId)}`)
?.scrollIntoView({ behavior: "smooth", block: "center" })
);
highlightTimer.current = setTimeout(
() => setHighlightedMessageId(undefined),
1800
);
};
return (
<main
className="workspace-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">
<ProjectHeader
onOpenDrawer={() => setDrawerOpen(true)}
workspace={workspace}
/>
<ConversationFeed
highlightedMessageId={highlightedMessageId}
onSourceSelect={revealSourceMessage}
workspace={workspace}
/>
<ConversationComposer
draft={draft}
onDraftChange={setDraft}
workspace={workspace}
/>
</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>
<WorkFeed
onSourceSelect={revealSourceMessage}
works={works}
workspace={workspace}
/>
</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"
>
×
</button>
</header>
<div className="flex-1 overflow-y-auto p-4">
<WorkFeed
onSourceSelect={revealSourceMessage}
works={works}
workspace={workspace}
/>
</div>
</section>
</div>
) : null}
</main>
);
};

View File

@@ -0,0 +1,395 @@
import { Button } from "@code/ui/components/button";
import {
AlertTriangle,
Check,
ChevronRight,
FileCode2,
Hammer,
Play,
RotateCcw,
ScrollText,
Sparkles,
X,
} from "lucide-react";
import { useState } from "react";
import { changedFilesFor } from "@/lib/workspace/types";
import type { WorkRecord, WorkspaceState } from "@/lib/workspace/types";
interface WorkCardProps {
readonly onSourceSelect: (rawText: string) => void;
readonly work: WorkRecord;
readonly workspace: WorkspaceState;
}
const starterDefinition = (work: WorkRecord) => ({
acceptanceCriteria: ["The requested outcome is observable and documented"],
affectedUsers: ["Project users"],
assumptions: [],
constraints: [],
desiredOutcome: work.objective,
inScope: [work.objective],
outOfScope: ["Unrelated product changes"],
problem: work.objective,
questions: [],
requiredArtifacts: ["Simulation activity and terminal outcome"],
risk: "medium",
});
const starterDesign = (work: WorkRecord) => ({
architectureSummary:
"Validate the approved Definition, then exercise one deterministic fake slice.",
callFlowDelta: [
"Work -> Run -> Attempt -> normalized events -> terminal outcome",
],
concerns: [],
evidenceRequirements: ["Terminal Run classification"],
fileTreeDelta: [],
impactMap: {
files: [],
modules: [],
risks: [],
summary: "Compact vertical-slice simulation",
},
invariants: [
"Simulation never claims implementation",
"Every Attempt reaches a terminal classification",
],
keyInterfaces: ["HarnessRuntime", "AttemptOutcome"],
slices: [
{
codeBoundaries: ["workExecution"],
dependsOn: [],
evidenceRequirements: ["Normalized activity events"],
id: "deterministic-simulation",
objective: work.objective,
observableBehavior: "A terminal fake Run is visible",
reviewRequired: false,
title: "Deterministic simulation",
verification: ["Run completes with a terminal classification"],
},
],
tradeoffs: ["Fake runtime proves contract before sandbox integration"],
});
// oxlint-disable-next-line complexity -- this card intentionally coordinates review actions and run evidence.
export const WorkCard = ({
onSourceSelect,
work,
workspace,
}: WorkCardProps) => {
const [sourcesOpen, setSourcesOpen] = useState(false);
const [expanded, setExpanded] = useState(false);
const [logsOpen, setLogsOpen] = useState(false);
const sources = work.signals.flatMap((signal) => signal.sources);
const [latestRun] = work.runs;
const openQuestions =
work.definition?.questions?.filter((question) => question.status === "open")
.length ?? 0;
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>
<button
className="mt-2 flex w-full items-center justify-between border-t border-[#e7e3d9] pt-3 text-left text-xs font-medium text-[#20201d]"
onClick={() => setExpanded((open) => !open)}
type="button"
>
<span>{expanded ? "Hide Work details" : "Open Work details"}</span>
<ChevronRight
className={`size-4 transition-transform ${expanded ? "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"
>
<span className="min-w-0 flex-1">{source.rawText}</span>
</button>
))}
</div>
) : null}
{expanded ? (
<div className="mt-4 space-y-4 border-t border-[#e7e3d9] pt-4 text-xs">
<section>
<p className="font-semibold uppercase tracking-wide text-[#65713a]">
Outcome
</p>
<p className="mt-1 leading-5 text-[#626057]">{work.objective}</p>
<p className="mt-2 text-[#747168]">
Risk: {work.definition?.risk ?? "not defined"}
</p>
{openQuestions > 0 ? (
<p className="mt-1 text-amber-800">
{openQuestions} open question(s)
</p>
) : null}
<div className="mt-2 flex flex-wrap gap-2">
{work.status === "proposed" ? (
<Button
size="sm"
onClick={() => void workspace.requestDefinition(work._id)}
>
<Sparkles className="size-3.5" /> Define
</Button>
) : null}
{work.status === "defining" ? (
<Button
size="sm"
variant="outline"
onClick={() =>
void workspace.saveDefinition(
work._id,
starterDefinition(work)
)
}
>
<Hammer className="size-3.5" /> Save definition
</Button>
) : null}
{work.status === "awaiting-definition-approval" &&
work.definitionVersion ? (
<Button
size="sm"
onClick={() =>
void workspace.approveDefinition(
work._id,
work.definitionVersion as number
)
}
>
<Check className="size-3.5" /> Approve
</Button>
) : null}
</div>
</section>
<section>
<p className="font-semibold uppercase tracking-wide text-[#65713a]">
Design
</p>
<p className="mt-1 leading-5 text-[#626057]">
{work.design?.architectureSummary ?? "No Design Packet yet."}
</p>
{work.design?.slices?.map((item) => (
<p className="mt-1 text-[#747168]" key={item.id}>
{item.title}: {item.observableBehavior}
</p>
))}
<div className="mt-2 flex flex-wrap gap-2">
{work.status === "designing" ? (
<Button
size="sm"
variant="outline"
onClick={() =>
void workspace.saveDesign(work._id, starterDesign(work))
}
>
<Hammer className="size-3.5" /> Save design
</Button>
) : null}
{work.status === "awaiting-design-approval" &&
work.definitionApprovalVersion &&
work.designVersion ? (
<Button
size="sm"
onClick={() =>
void workspace.approveDesign(
work._id,
work.definitionApprovalVersion as number,
work.designVersion as number
)
}
>
<Check className="size-3.5" /> Approve design
</Button>
) : null}
</div>
</section>
<section>
<p className="font-semibold uppercase tracking-wide text-[#65713a]">
Build
</p>
{workspace.operationError ? (
<p className="mt-2 flex items-start gap-1.5 border border-red-300 bg-red-50 px-2 py-1.5 text-red-800">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span className="min-w-0 flex-1 leading-5">
{workspace.operationError.message}
</span>
<button
aria-label="Dismiss error"
className="shrink-0"
onClick={() => workspace.clearOperationError()}
type="button"
>
<X className="size-3.5" />
</button>
</p>
) : null}
{latestRun ? (
<div className="mt-1 space-y-2 text-[#626057]">
<p className="leading-5">
{latestRun.executionKind === "real"
? "AgentOS"
: "Simulation"}{" "}
run {latestRun.status}:{" "}
{latestRun.terminalSummary ??
latestRun.terminalClassification ??
"activity is still arriving"}
</p>
{latestRun.baseRevision ? (
<p className="font-mono text-[10px] text-[#747168]">
{latestRun.baseRevision.slice(0, 8)} {" "}
{latestRun.candidateRevision?.slice(0, 8) ?? "working"}
</p>
) : null}
{latestRun.artifacts?.map((artifact) => (
<div key={artifact._id} className="space-y-1">
<p className="font-medium">
{artifact.uri ? (
<a
className="underline"
href={artifact.uri}
rel="noreferrer"
target="_blank"
>
{artifact.title}
</a>
) : (
artifact.title
)}
</p>
{changedFilesFor(artifact).length > 0 ? (
<ul className="space-y-0.5">
{changedFilesFor(artifact).map((file) => (
<li
className="flex items-start gap-1.5 font-mono text-[11px] leading-5 text-[#747168]"
key={file}
>
<FileCode2 className="mt-0.5 size-3 shrink-0 text-[#9a985f]" />
<span className="min-w-0 break-all">{file}</span>
</li>
))}
</ul>
) : null}
</div>
))}
{latestRun.attemptEvents &&
latestRun.attemptEvents.length > 0 ? (
<div className="space-y-1">
{(logsOpen
? latestRun.attemptEvents
: latestRun.attemptEvents.slice(-3)
).map((item) => (
<p
className="border-l-2 border-[#b8c760] pl-2 leading-5"
key={item._id}
>
{item.message}
</p>
))}
{latestRun.attemptEvents.length > 3 ? (
<button
className="flex items-center gap-1 font-medium text-[#65713a] hover:underline"
onClick={() => setLogsOpen((open) => !open)}
type="button"
>
<ScrollText className="size-3.5" />
{logsOpen
? "Show recent activity"
: `Show full activity log (${latestRun.attemptEvents.length})`}
<ChevronRight
className={`size-3.5 transition-transform ${logsOpen ? "rotate-90" : ""}`}
/>
</button>
) : null}
</div>
) : null}
</div>
) : (
<p className="mt-1 text-[#747168]">No implementation Run yet.</p>
)}
<div className="mt-2 flex flex-wrap gap-2">
{work.status === "ready" ? (
<>
<Button
size="sm"
disabled={!workspace.projectGitConnection}
onClick={() => void workspace.startExecution(work._id)}
>
<Play className="size-3.5" /> Run
</Button>
<Button
size="sm"
variant="outline"
onClick={() =>
void workspace.startSimulation(work._id, "success")
}
>
<Play className="size-3.5" /> Simulate
</Button>
</>
) : null}
{latestRun?.status === "running" ? (
<Button
size="sm"
variant="outline"
onClick={() =>
void (latestRun.executionKind === "real"
? workspace.cancelExecution(latestRun._id)
: workspace.cancelSimulation(latestRun._id))
}
>
<X className="size-3.5" /> Cancel
</Button>
) : null}
{latestRun?.executionKind !== "real" &&
latestRun?.status === "terminal" &&
latestRun.terminalClassification === "RetryableFailure" ? (
<Button
size="sm"
variant="outline"
onClick={() => void workspace.retrySimulation(latestRun._id)}
>
<RotateCcw className="size-3.5" /> Retry
</Button>
) : null}
</div>
</section>
</div>
) : null}
</article>
);
};

View File

@@ -0,0 +1,29 @@
import type { WorkRecord, WorkspaceState } from "@/lib/workspace/types";
import { WorkCard } from "./work-card";
export const WorkFeed = ({
onSourceSelect,
works,
workspace,
}: {
readonly onSourceSelect: (rawText: string) => void;
readonly works: readonly WorkRecord[];
readonly workspace: WorkspaceState;
}) => (
<div className="space-y-3">
{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={onSourceSelect}
work={work}
workspace={workspace}
/>
))}
</div>
);

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,111 @@
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 type { PersonalOrganizationState } from "@/hooks/use-personal-organization";
import { CHAT_AGENT } from "@/lib/chat/constants"; import { projectConversation } from "@/lib/chat/conversation";
import type { ChatAgentState } from "@/lib/chat/types"; 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 = ( export const useOrganizationChatAgent = (
organization: PersonalOrganizationState organization: PersonalOrganizationState
): ChatAgentState => { ): ChatAgentState => {
const agent = useFlueAgent({ const { organizationId } = organization;
...CHAT_AGENT, const rows = useQuery(
id: organization.organizationId, 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> => { const projected = projectConversation(rows ?? []);
if (!organization.organizationId) {
let status: AgentStatus = "idle";
if (projected.streaming) {
status = "streaming";
} else if (projected.pending) {
status = "submitted";
}
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 ( throw (
organization.error ?? organization.error ??
new Error("Personal organization is still being prepared") 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 { return {
error: organization.error ?? agent.error, error:
historyReady: agent.historyReady, organization.error ??
messages: agent.messages, sendError ??
(projected.failedError ? new Error(projected.failedError) : undefined),
historyReady: rows !== undefined,
messages: projected.messages,
sendMessage, sendMessage,
status, 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

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

@@ -0,0 +1,276 @@
import { authClient } from "@code/auth/web";
import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { useAction, useMutation, useQuery } from "convex/react";
import { makeFunctionReference } from "convex/server";
import { useMemo, useState } from "react";
import { useOrganizationChatAgent } from "@/hooks/chat/use-chat-agent";
import { usePersonalOrganization } from "@/hooks/use-personal-organization";
import type { WorkRecord, WorkspaceState } from "@/lib/workspace/types";
const toError = (error: unknown) =>
error instanceof Error ? error : new Error(String(error));
type ExecutionScenario =
| "success"
| "transient-failure-then-success"
| "needs-input"
| "permanent-failure"
| "cancelled";
const workListRef = makeFunctionReference<
"query",
{ projectId: Id<"projects"> },
WorkRecord[]
>("workPlanning:listForProject");
const requestDefinitionRef = makeFunctionReference<
"mutation",
{ workId: Id<"works"> },
unknown
>("workPlanning:requestDefinition");
const saveDefinitionRef = makeFunctionReference<
"mutation",
{ workId: Id<"works">; payloadJson: string },
unknown
>("workPlanning:saveDefinitionProposal");
const approveDefinitionRef = makeFunctionReference<
"mutation",
{ workId: Id<"works">; version: number },
unknown
>("workPlanning:approveDefinition");
const saveDesignRef = makeFunctionReference<
"mutation",
{ workId: Id<"works">; payloadJson: string },
unknown
>("workPlanning:saveDesignProposal");
const approveDesignRef = makeFunctionReference<
"mutation",
{ workId: Id<"works">; definitionVersion: number; designVersion: number },
unknown
>("workPlanning:approveDesign");
const startSimulationRef = makeFunctionReference<
"mutation",
{ workId: Id<"works">; scenario: ExecutionScenario; sliceId?: string },
unknown
>("workExecution:startSimulatedExecution");
const cancelSimulationRef = makeFunctionReference<
"mutation",
{ runId: Id<"workRuns"> },
unknown
>("workExecution:cancelSimulatedExecution");
const retrySimulationRef = makeFunctionReference<
"mutation",
{ runId: Id<"workRuns"> },
unknown
>("workExecution:retrySimulatedExecution");
const startExecutionRef = makeFunctionReference<
"mutation",
{ workId: Id<"works">; sliceId?: string },
unknown
>("workExecutionWorkflow:startExecution");
const cancelExecutionRef = makeFunctionReference<
"mutation",
{ runId: Id<"workRuns"> },
unknown
>("workExecutionWorkflow:cancelExecution");
const listGitConnectionsRef = makeFunctionReference<
"query",
Record<string, never>,
readonly {
id: string;
provider: "github" | "gitea";
serverUrl: string;
username?: string;
}[]
>("gitConnectionData:list");
const projectGitConnectionRef = makeFunctionReference<
"query",
{ projectId: Id<"projects"> },
{
id: string;
provider: "github" | "gitea";
serverUrl: string;
username?: string;
} | null
>("gitConnectionData:getForProject");
const connectGiteaRef = makeFunctionReference<
"action",
{ serverUrl: string; token: string; username?: string },
{ connectionId: Id<"gitConnections"> }
>("gitConnections:connectGitea");
const connectGithubRef = makeFunctionReference<
"action",
Record<string, never>,
{ connectionId: Id<"gitConnections"> }
>("gitConnections:connectGithub");
const attachGitConnectionRef = makeFunctionReference<
"mutation",
{ connectionId: Id<"gitConnections">; projectId: Id<"projects"> },
unknown
>("gitConnectionData:attachToProject");
export const useProjectWorkspace = (): WorkspaceState => {
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 [operationError, setOperationError] = 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(
workListRef,
activeProjectId ? { projectId: activeProjectId } : "skip"
);
const gitConnections = useQuery(
listGitConnectionsRef,
organization.organizationId ? {} : "skip"
);
const projectGitConnection = useQuery(
projectGitConnectionRef,
activeProjectId ? { projectId: activeProjectId } : "skip"
);
const requestDefinitionMutation = useMutation(requestDefinitionRef);
const saveDefinitionMutation = useMutation(saveDefinitionRef);
const approveDefinitionMutation = useMutation(approveDefinitionRef);
const saveDesignMutation = useMutation(saveDesignRef);
const approveDesignMutation = useMutation(approveDesignRef);
const startSimulationMutation = useMutation(startSimulationRef);
const cancelSimulationMutation = useMutation(cancelSimulationRef);
const retrySimulationMutation = useMutation(retrySimulationRef);
const startExecutionMutation = useMutation(startExecutionRef);
const cancelExecutionMutation = useMutation(cancelExecutionRef);
const attachGitConnectionMutation = useMutation(attachGitConnectionRef);
const connectGiteaAction = useAction(connectGiteaRef);
const connectGithubAction = useAction(connectGithubRef);
const selectedProject = useMemo(
() =>
projects?.find(
(project) => project.id === (activeProjectId as unknown as string)
) ?? null,
[activeProjectId, projects]
);
const runOperation = async <T>(operation: () => Promise<T>): Promise<T> => {
setOperationError(undefined);
try {
return await operation();
} catch (caughtError) {
setOperationError(toError(caughtError));
throw caughtError;
}
};
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 attachConnection = async (connectionId: Id<"gitConnections">) => {
if (!activeProjectId) {
throw new Error("Select a project first");
}
await attachGitConnectionMutation({
connectionId,
projectId: activeProjectId,
});
};
const connectGitea = (input: {
serverUrl: string;
token: string;
username?: string;
}) =>
runOperation(async () => {
const result = await connectGiteaAction(input);
await attachConnection(result.connectionId);
});
const connectLinkedGithub = () =>
runOperation(async () => {
const result = await connectGithubAction({});
await attachConnection(result.connectionId);
});
return {
agent,
approveDefinition: (workId: Id<"works">, version: number) =>
approveDefinitionMutation({ version, workId }),
approveDesign: (
workId: Id<"works">,
definitionVersion: number,
designVersion: number
) => approveDesignMutation({ definitionVersion, designVersion, workId }),
authorizeGithub: () =>
authClient.signIn.social({
callbackURL: window.location.href,
provider: "github",
}),
cancelExecution: (runId: Id<"workRuns">) =>
runOperation(() => cancelExecutionMutation({ runId })),
cancelSimulation: (runId: Id<"workRuns">) =>
runOperation(() => cancelSimulationMutation({ runId })),
clearOperationError: () => setOperationError(undefined),
connectGitea,
connectLinkedGithub,
connectRepository,
error,
gitConnections,
operationError,
pending,
projectGitConnection,
projects,
repository,
requestDefinition: (workId: Id<"works">) =>
requestDefinitionMutation({ workId }),
retrySimulation: (runId: Id<"workRuns">) =>
runOperation(() => retrySimulationMutation({ runId })),
saveDefinition: (workId: Id<"works">, payload: unknown) =>
saveDefinitionMutation({ payloadJson: JSON.stringify(payload), workId }),
saveDesign: (workId: Id<"works">, payload: unknown) =>
saveDesignMutation({ payloadJson: JSON.stringify(payload), workId }),
selectProject: (projectId: string) =>
setSelectedProjectId(projectId as unknown as Id<"projects">),
selectedProject: selectedProject
? { id: selectedProject.id, name: selectedProject.name }
: null,
setRepository,
startExecution: (workId: Id<"works">, sliceId?: string) =>
runOperation(() => startExecutionMutation({ sliceId, workId })),
startSimulation: (
workId: Id<"works">,
scenario: ExecutionScenario,
sliceId?: string
) =>
runOperation(() =>
startSimulationMutation({ scenario, sliceId, workId })
),
works,
};
};

View File

@@ -0,0 +1,26 @@
import { describe, expect, test } from "vitest";
import { visualViewportStyle } from "./use-visual-viewport";
describe("Workspace 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("workspace-viewport-lock");
update();
window.addEventListener("resize", update);
viewport?.addEventListener("resize", update);
viewport?.addEventListener("scroll", update);
return () => {
cancelAnimationFrame(animationFrame);
root.classList.remove("workspace-viewport-lock");
window.removeEventListener("resize", update);
viewport?.removeEventListener("resize", update);
viewport?.removeEventListener("scroll", update);
};
}, []);
return visualViewportStyle(frame);
};

View File

@@ -6,6 +6,13 @@ body {
min-height: 100%; min-height: 100%;
} }
html.workspace-viewport-lock,
html.workspace-viewport-lock body {
height: 100%;
overflow: hidden;
overscroll-behavior: none;
}
.ai-conversation-mobile > div { .ai-conversation-mobile > div {
scrollbar-gutter: auto !important; scrollbar-gutter: auto !important;
} }
@@ -68,6 +75,19 @@ body {
min-height: 1.75rem; 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 { @keyframes response-dot {
0%, 0%,
60%, 60%,
@@ -96,6 +116,12 @@ body {
color: var(--foreground); color: var(--foreground);
} }
.workspace-surface .chat-markdown,
.workspace-surface .chat-reasoning,
.workspace-surface .thinking-line {
color: #232321;
}
.chat-markdown > :first-child { .chat-markdown > :first-child {
margin-top: 0; margin-top: 0;
} }

View File

@@ -0,0 +1,63 @@
import { redirect } from "react-router";
interface AuthLoaderData {
readonly token: string | null;
}
export const loadAuthToken = async (
request: Request
): Promise<AuthLoaderData> => {
const cookie = request.headers.get("cookie");
if (!cookie) {
return { token: null };
}
const tokenUrl = new URL(
"/api/auth/convex/token",
new URL(request.url).origin
);
const headers = new Headers({ cookie });
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 * The global Zopu agent. The instance `id` is the current organization id
* (resolved at runtime by the chat hook), not the legacy `main` shared * (resolved at runtime by the chat hook), not the legacy `main` shared
* instance. Organization scoping is the hard tenancy boundary. * instance. Organization scoping is the hard tenancy boundary.
*/ */
export const CHAT_AGENT = { export const MODEL_ID = "openrouter/openai/gpt-oss-20b:free";
live: "sse", export const MODEL_LABEL = "GPT-OSS 20B Free";
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 STATUS_COPY: Record<AgentStatus, string> = { export const STATUS_COPY: Record<AgentStatus, string> = {
connecting: "Connecting", connecting: "Connecting",

View File

@@ -0,0 +1,59 @@
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: "dispatching",
}),
row({ messageId: "assistant-1", role: "assistant", status: "queued" }),
]);
expect(state.pending).toBe(true);
expect(state.messages).toHaveLength(1);
});
test("projects partial assistant text as streaming", () => {
const state = projectConversation([
row({
messageId: "assistant-streaming",
rawText: "Working",
role: "assistant",
status: "running",
}),
]);
expect(state.streaming).toBe(true);
expect(state.messages[0]?.parts).toEqual([
{ state: "streaming", text: "Working", type: "text" },
]);
});
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,77 @@
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:
| "aborted"
| "completed"
| "dispatching"
| "failed"
| "queued"
| "running";
}
export const projectConversation = (rows: readonly ConversationRow[]) => {
const streaming = rows.some(
(row) =>
row.role === "assistant" &&
row.status === "running" &&
row.rawText.length > 0
);
return {
failedError: rows.findLast(
(row) => row.role === "assistant" && row.status === "failed"
)?.error,
messages: rows
.filter(
(row) =>
row.role === "user" ||
row.status === "completed" ||
(row.role === "assistant" &&
row.status === "running" &&
row.rawText.length > 0)
)
.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:
row.status === "running"
? ("streaming" as const)
: ("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 === "dispatching" ||
row.status === "running")
),
streaming,
};
};

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 => { export const stripThinkingMarkup = (text: string): string => {
const withoutCompletedBlocks = text.replaceAll( const withoutCompletedBlocks = text.replaceAll(
@@ -15,24 +15,61 @@ export const stripThinkingMarkup = (text: string): string => {
return visibleText.replaceAll("</think>", "").trimStart(); return visibleText.replaceAll("</think>", "").trimStart();
}; };
export const getMessageText = (message: FlueConversationMessage): string => export const extractThinkingMarkup = (text: string): string => {
stripThinkingMarkup( const completed = [
message.parts ...text.matchAll(/<think>(?<content>[\s\S]*?)<\/think>/giu),
.filter((part) => part.type === "text") ]
.map((part) => part.text) .map((match) => match.groups?.content?.trim())
.join("\n") .filter((value): value is string => Boolean(value));
); const lowerText = text.toLowerCase();
const openTagIndex = lowerText.lastIndexOf("<think>");
export const isMessageStreaming = (message: FlueConversationMessage): boolean => const closeTagIndex = lowerText.lastIndexOf("</think>");
message.parts.some((part) => { if (openTagIndex > closeTagIndex) {
if (part.type === "dynamic-tool") { const active = text.slice(openTagIndex + "<think>".length).trim();
return part.state === "input-available"; 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.type === "text" || part.type === "reasoning") &&
part.state === "streaming" part.state === "streaming"
); );
});
export const getStatusDotClass = (status: AgentStatus): string => { export const getStatusDotClass = (status: AgentStatus): string => {
if (status === "error") { if (status === "error") {

View File

@@ -1,14 +1,43 @@
import type { export type AgentStatus =
AgentStatus, | "connecting"
FlueConversationMessage, | "error"
FlueConversationPart, | "idle"
} from "@flue/react"; | "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 { export interface ChatAgentState {
error?: Error; error?: Error;
historyReady: boolean; historyReady: boolean;
messages: FlueConversationMessage[]; messages: ConversationMessage[];
sendMessage: (message: string) => Promise<void>; sendMessage: (
message: string,
options?: { readonly images?: readonly File[] }
) => Promise<void>;
status: AgentStatus; status: AgentStatus;
} }
@@ -20,7 +49,7 @@ export interface ChatComposerProps {
export interface ChatConversationProps { export interface ChatConversationProps {
historyReady: boolean; historyReady: boolean;
messages: FlueConversationMessage[]; messages: ConversationMessage[];
onSuggestion: (suggestion: string) => void; onSuggestion: (suggestion: string) => void;
status: AgentStatus; status: AgentStatus;
} }
@@ -30,11 +59,7 @@ export interface ChatHeaderProps {
} }
export interface ChatMessageProps { export interface ChatMessageProps {
message: FlueConversationMessage; message: ConversationMessage;
}
export interface ChatToolCallProps {
part: Extract<FlueConversationPart, { type: "dynamic-tool" }>;
} }
export type AssistantResponseState = "thinking" | "writing"; 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,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

@@ -0,0 +1,62 @@
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("Workspace frontend regression contracts", () => {
test("keeps keyboard resizing on the visual viewport instead of page scroll", () => {
const page = source(
"../../components/workspace/project-workspace-page.tsx"
);
const viewport = source("../../hooks/workspace/use-visual-viewport.ts");
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(viewport).toContain("workspace-viewport-lock");
expect(styles).toContain("html.workspace-viewport-lock body");
expect(styles).toContain("overflow: hidden");
});
test("keeps the responsive shell shrinkable with a pinned composer", () => {
const page = source(
"../../components/workspace/project-workspace-page.tsx"
);
const feed = source("../../components/workspace/conversation-feed.tsx");
const composer = source(
"../../components/workspace/conversation-composer.tsx"
);
expect(page).toContain('className="flex min-w-0 flex-1 flex-col"');
expect(feed).toContain('<Conversation className="min-h-0 flex-1">');
expect(composer).toContain('className="shrink-0 border-t');
expect(composer).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,53 @@
import type { WorkNotice } from "@code/primitives/work";
import { describe, expect, test } from "vitest";
import type { ConversationMessage } from "@/lib/chat/types";
import {
buildWorkspaceTimeline,
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("Workspace presentation", () => {
test("places proposed Work after the assistant response to its source", () => {
const timeline = buildWorkspaceTimeline(
[
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,75 @@
import type { WorkNotice } from "@code/primitives/work";
import {
getMessageText,
getRawMessageText,
getReasoningText,
} from "@/lib/chat/transforms";
import type { ConversationMessage } from "@/lib/chat/types";
import type { WorkspaceTimelineItem } from "./types";
export const isVisibleConversationMessage = (
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 buildWorkspaceTimeline = (
allMessages: readonly ConversationMessage[],
notices: readonly WorkNotice[]
): readonly WorkspaceTimelineItem[] => {
const messages = allMessages.filter(isVisibleConversationMessage);
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: WorkspaceTimelineItem[] = [];
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,163 @@
import type { Id } from "@code/backend/convex/_generated/dataModel";
import type { WorkNotice } from "@code/primitives/work";
import type { ConversationMessage } from "@/lib/chat/types";
export interface WorkRecord {
readonly _id: Id<"works">;
readonly title: string;
readonly objective: string;
readonly status: string;
readonly definitionVersion?: number;
readonly definitionApprovalVersion?: number;
readonly designVersion?: number;
readonly signals: readonly {
readonly sources: readonly { messageId: string; rawText: string }[];
}[];
readonly runs: readonly WorkRun[];
readonly definition: {
readonly risk?: string;
readonly questions?: readonly { status: string }[];
} | null;
readonly design: {
readonly architectureSummary?: string;
readonly slices?: readonly {
readonly id: string;
readonly title: string;
readonly observableBehavior: string;
}[];
} | null;
}
export interface WorkRun {
readonly artifacts?: readonly WorkArtifact[];
readonly attemptEvents?: readonly WorkAttemptEvent[];
readonly baseRevision?: string;
readonly candidateRevision?: string;
readonly executionKind?: string;
readonly _id: Id<"workRuns">;
readonly status: string;
readonly terminalClassification?: string;
readonly terminalSummary?: string;
}
export interface WorkArtifact {
readonly _id: string;
readonly metadataJson: string;
readonly title: string;
readonly uri?: string;
}
export interface WorkAttemptEvent {
readonly _id: string;
readonly message: string;
}
export interface GitConnection {
readonly id: string;
readonly provider: "github" | "gitea";
readonly serverUrl: string;
readonly username?: string;
}
export interface ProjectListItem {
readonly id: string;
readonly name: string;
}
export interface WorkspaceState {
readonly agent: {
readonly error?: Error;
readonly historyReady: boolean;
readonly messages: readonly ConversationMessage[];
readonly sendMessage: (
message: string,
options?: { readonly images?: readonly File[] }
) => Promise<void>;
readonly status:
| "connecting"
| "error"
| "idle"
| "streaming"
| "submitted";
};
readonly approveDefinition: (
workId: Id<"works">,
version: number
) => Promise<unknown>;
readonly approveDesign: (
workId: Id<"works">,
definitionVersion: number,
designVersion: number
) => Promise<unknown>;
readonly authorizeGithub: () => Promise<unknown>;
readonly cancelExecution: (runId: Id<"workRuns">) => Promise<unknown>;
readonly cancelSimulation: (runId: Id<"workRuns">) => Promise<unknown>;
readonly clearOperationError: () => void;
readonly connectGitea: (input: {
readonly serverUrl: string;
readonly token: string;
readonly username?: string;
}) => Promise<void>;
readonly connectLinkedGithub: () => Promise<void>;
readonly connectRepository: () => Promise<void>;
readonly error?: Error;
readonly gitConnections: readonly GitConnection[] | undefined;
readonly operationError?: Error;
readonly pending: boolean;
readonly projectGitConnection: GitConnection | null | undefined;
readonly projects: readonly ProjectListItem[] | undefined;
readonly repository: string;
readonly requestDefinition: (workId: Id<"works">) => Promise<unknown>;
readonly retrySimulation: (runId: Id<"workRuns">) => Promise<unknown>;
readonly saveDefinition: (
workId: Id<"works">,
payload: unknown
) => Promise<unknown>;
readonly saveDesign: (
workId: Id<"works">,
payload: unknown
) => Promise<unknown>;
readonly selectProject: (projectId: string) => void;
readonly selectedProject: ProjectListItem | null;
readonly setRepository: (value: string) => void;
readonly startExecution: (
workId: Id<"works">,
sliceId?: string
) => Promise<unknown>;
readonly startSimulation: (
workId: Id<"works">,
scenario:
| "success"
| "transient-failure-then-success"
| "needs-input"
| "permanent-failure"
| "cancelled",
sliceId?: string
) => Promise<unknown>;
readonly works: readonly WorkRecord[] | undefined;
}
export type WorkspaceTimelineItem =
| { readonly kind: "message"; readonly message: ConversationMessage }
| { readonly kind: "work"; readonly notice: WorkNotice };
export interface ArtifactMetadata {
readonly changedFiles?: readonly string[];
}
export const parseArtifactMetadata = (
artifact: WorkArtifact
): ArtifactMetadata => {
if (!artifact.metadataJson) {
return {};
}
try {
return JSON.parse(artifact.metadataJson) as ArtifactMetadata;
} catch {
return {};
}
};
export const changedFilesFor = (artifact: WorkArtifact): readonly string[] =>
parseArtifactMetadata(artifact).changedFiles ?? [];

View File

@@ -1,11 +1,8 @@
import { useConvexAccessToken, WebAuthProvider } from "@code/auth/web"; import { WebAuthProvider } from "@code/auth/web";
import { env } from "@code/env/web";
import { Toaster } from "@code/ui/components/sonner"; import { Toaster } from "@code/ui/components/sonner";
import { FlueProvider } from "@flue/react";
import "./index.css"; import "./index.css";
import { createFlueClient } from "@flue/sdk"; import { LoaderCircle } from "lucide-react";
import { useMemo } from "react";
import { import {
isRouteErrorResponse, isRouteErrorResponse,
Links, Links,
@@ -13,11 +10,14 @@ import {
Outlet, Outlet,
Scripts, Scripts,
ScrollRestoration, ScrollRestoration,
useNavigation,
} from "react-router"; } from "react-router";
import type { Route } from "./+types/root"; import type { Route } from "./+types/root";
import { ThemeProvider } from "./components/theme-provider"; 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 = () => [ export const links: Route.LinksFunction = () => [
{ href: "https://fonts.googleapis.com", rel: "preconnect" }, { 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 }) => ( export const Layout = ({ children }: { children: React.ReactNode }) => (
<html lang="en"> <html lang="en">
<head> <head>
<meta charSet="utf-8" /> <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 /> <Meta />
<Links /> <Links />
</head> </head>
@@ -76,20 +51,42 @@ export const Layout = ({ children }: { children: React.ReactNode }) => (
</html> </html>
); );
const App = () => ( const RouteProgress = () => {
<WebAuthProvider> const navigation = useNavigation();
<AuthenticatedFlueProvider> if (!navigation.location) {
<ThemeProvider return null;
attribute="class" }
defaultTheme="dark" return (
forcedTheme="dark" <output
disableTransitionOnChange aria-label="Loading page"
storageKey="vite-ui-theme" className="fixed inset-x-0 top-0 z-50 h-0.5 overflow-hidden bg-[#d7d3c7]"
> >
<Outlet /> <div className="route-progress-bar h-full w-1/3 bg-[#7f9130]" />
<Toaster richColors /> </output>
</ThemeProvider> );
</AuthenticatedFlueProvider> };
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> </WebAuthProvider>
); );

View File

@@ -6,14 +6,5 @@ export default [
route("login", "./routes/auth/login/page.tsx"), route("login", "./routes/auth/login/page.tsx"),
route("signup", "./routes/auth/signup/page.tsx"), route("signup", "./routes/auth/signup/page.tsx"),
]), ]),
layout("./routes/app/layout.tsx", [ layout("./routes/app/layout.tsx", [index("./routes/app/workspace/page.tsx")]),
layout("./routes/app/mobile/layout.tsx", [
index("./routes/app/mobile/page.tsx"),
route("chat", "./routes/app/mobile/chat/page.tsx"),
route("work", "./routes/app/mobile/work-list/page.tsx"),
route("work/:workUnitId", "./routes/app/mobile/work/page.tsx"),
]),
route("dashboard", "./routes/app/dashboard/page.tsx"),
route("todos", "./routes/app/todos/page.tsx"),
]),
] satisfies RouteConfig; ] satisfies RouteConfig;

View File

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

View File

@@ -1,29 +1,12 @@
import { useWebAuth } from "@code/auth/web"; import { Outlet } from "react-router";
import { Navigate, Outlet, useLocation } 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() { 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 />; 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 +0,0 @@
import { MobileFlowPage } from "@/components/mobile-workspace/mobile-flow-page";
import type { Route } from "./+types/page";
export const meta = (_args: Route.MetaArgs) => [
{ title: "Daily focus | Zopu" },
{
content: "Supervise active work and open the next action",
name: "description",
},
];
export default function MobileHomePage() {
return <MobileFlowPage screen="home" />;
}

View File

@@ -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

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

View File

@@ -1,21 +1,13 @@
import { useWebAuth } from "@code/auth/web"; import { Outlet } from "react-router";
import { Navigate, 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() { 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 ( return (
<main className="grid min-h-svh place-items-center bg-muted/30 p-6 md:p-10"> <main className="grid min-h-svh place-items-center bg-muted/30 p-6 md:p-10">
<div className="w-full max-w-sm"> <div className="w-full max-w-sm">

View File

@@ -1,5 +1,5 @@
import { LoginForm } from "@code/auth/web"; import { LoginForm } from "@code/auth/web";
import { useNavigate } from "react-router"; import { useNavigate, useSearchParams } from "react-router";
import type { Route } from "./+types/page"; import type { Route } from "./+types/page";
@@ -10,6 +10,14 @@ export const meta = (_args: Route.MetaArgs) => [
export default function LoginRoute() { export default function LoginRoute() {
const navigate = useNavigate(); 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

@@ -7,8 +7,14 @@ import { defineConfig } from "vite-plus";
export default defineConfig({ export default defineConfig({
envDir: path.resolve(import.meta.dirname, "../.."), envDir: path.resolve(import.meta.dirname, "../.."),
plugins: [tailwindcss(), reactRouter()], plugins: [tailwindcss(), reactRouter()],
ssr: {
noExternal: true,
},
resolve: { resolve: {
dedupe: ["convex", "react", "react-dom"], dedupe: ["convex", "react", "react-dom"],
tsconfigPaths: true, tsconfigPaths: true,
}, },
server: {
allowedHosts: true,
},
}); });

5133
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
FROM oven/bun:1.3.14 AS build
WORKDIR /app
COPY . .
RUN bun install --frozen-lockfile
RUN bun run --filter @code/agents build
FROM node:24-bookworm-slim
ENV NODE_ENV=production
ENV PORT=3000
WORKDIR /app
COPY --from=build /app /app
EXPOSE 3000
CMD ["node", "packages/agents/dist/server.mjs"]

View File

@@ -0,0 +1,31 @@
services:
backend:
build:
context: ../..
dockerfile: packages/agents/Dockerfile
environment:
AGENT_BACKEND_URL: ${AGENT_BACKEND_URL}
AGENT_MODEL_API: ${AGENT_MODEL_API}
AGENT_MODEL_API_KEY: ${AGENT_MODEL_API_KEY}
AGENT_MODEL_BASE_URL: ${AGENT_MODEL_BASE_URL}
AGENT_MODEL_CONTEXT_WINDOW: ${AGENT_MODEL_CONTEXT_WINDOW}
AGENT_MODEL_MAX_TOKENS: ${AGENT_MODEL_MAX_TOKENS}
AGENT_MODEL_NAME: ${AGENT_MODEL_NAME}
AGENT_MODEL_PROVIDER: ${AGENT_MODEL_PROVIDER}
CONVEX_URL: ${CONVEX_URL}
DAEMON_ID: zopu-agent-backend
FLUE_DB_TOKEN: ${FLUE_DB_TOKEN}
GITEA_TOKEN: ${GITEA_TOKEN:-}
GITEA_URL: ${GITEA_URL:-https://git.openputer.com}
PORT: "3000"
RIVET_ENDPOINT: ${RIVET_ENDPOINT}
RIVET_PUBLIC_ENDPOINT: ${RIVET_PUBLIC_ENDPOINT}
RIVET_WORKSPACE_TOKEN: ${RIVET_WORKSPACE_TOKEN}
networks:
- default
- dokploy-network
restart: unless-stopped
networks:
dokploy-network:
external: true

View File

@@ -0,0 +1,15 @@
services:
frontend:
build:
context: ../..
dockerfile: apps/web/Dockerfile
args:
VITE_AUTH_URL: ${VITE_AUTH_URL}
VITE_CONVEX_URL: ${VITE_CONVEX_URL}
environment:
HOST: 0.0.0.0
NODE_ENV: production
PORT: "3000"
VITE_AUTH_URL: ${VITE_AUTH_URL}
VITE_CONVEX_URL: ${VITE_CONVEX_URL}
restart: unless-stopped

View File

@@ -0,0 +1,23 @@
FROM oven/bun:1.3.14 AS bun
FROM node:24-bookworm-slim
COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates g++ git make python3 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /opt/zopu-source
COPY . .
RUN bun install --frozen-lockfile
ENV AGENT_WORKSPACE_ROOT=/var/lib/zopu/workspaces
ENV BUN_EXECUTABLE=/usr/local/bin/bun
ENV DAEMON_ID=zopu-agentos-runner
ENV ZOPU_SOURCE_REPOSITORY=/opt/zopu-source
VOLUME ["/var/lib/zopu/workspaces"]
CMD ["bun", "packages/agents/src/runner.ts"]

View File

@@ -0,0 +1,26 @@
services:
runner:
build:
context: .
dockerfile: deploy/dokploy/runner.Dockerfile
environment:
AGENT_MODEL_API: ${AGENT_MODEL_API}
AGENT_MODEL_API_KEY: ${AGENT_MODEL_API_KEY}
AGENT_MODEL_BASE_URL: ${AGENT_MODEL_BASE_URL}
AGENT_MODEL_CONTEXT_WINDOW: ${AGENT_MODEL_CONTEXT_WINDOW}
AGENT_MODEL_MAX_TOKENS: ${AGENT_MODEL_MAX_TOKENS}
AGENT_MODEL_NAME: ${AGENT_MODEL_NAME}
AGENT_MODEL_PROVIDER: ${AGENT_MODEL_PROVIDER}
CONVEX_URL: ${CONVEX_URL}
DAEMON_ID: zopu-agentos-runner
FLUE_DB_TOKEN: ${FLUE_DB_TOKEN}
RIVET_ENDPOINT: ${RIVET_ENDPOINT}
RIVET_PUBLIC_ENDPOINT: ${RIVET_PUBLIC_ENDPOINT}
RIVET_WORKSPACE_TOKEN: ${RIVET_WORKSPACE_TOKEN}
ZOPU_SOURCE_REPOSITORY: /opt/zopu-source
volumes:
- runner-workspaces:/var/lib/zopu/workspaces
restart: unless-stopped
volumes:
runner-workspaces:

View File

@@ -0,0 +1,21 @@
FROM oven/bun:1.3.14 AS build
WORKDIR /app
COPY . .
RUN bun install --frozen-lockfile
RUN bun run --filter web build
FROM node:24-bookworm-slim
ENV HOST=0.0.0.0
ENV NODE_ENV=production
ENV PORT=3000
WORKDIR /app
COPY --from=build /app /app
EXPOSE 3000
CMD ["node", "apps/web/node_modules/.bin/react-router-serve", "apps/web/build/server/index.js"]

View File

@@ -8,21 +8,24 @@
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 1. Convex (control plane) — REQUIRED # 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_URL=https://your-deployment.convex.cloud
CONVEX_SITE_URL=https://your-deployment.convex.site 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 # 2. Model gateway — REQUIRED
# The agent daemon clones repos and creates PRs through Gitea.
# ---------------------------------------------------------------------------
GITEA_URL=https://git.openputer.com
GITEA_TOKEN=replace-with-gitea-api-token
# ---------------------------------------------------------------------------
# 3. Model gateway — REQUIRED
# All model calls route through this OpenAI-compatible endpoint. # All model calls route through this OpenAI-compatible endpoint.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
AGENT_MODEL_PROVIDER=cheaptricks AGENT_MODEL_PROVIDER=cheaptricks
@@ -34,28 +37,28 @@ AGENT_MODEL_CONTEXT_WINDOW=262000
AGENT_MODEL_MAX_TOKENS=131072 AGENT_MODEL_MAX_TOKENS=131072
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 4. AgentOS / RivetKit — OPTIONAL (defaults shown) # 3. AgentOS / Rivet Engine — REQUIRED for the execution runner
# registry.start() in the daemon boots an in-process RivetKit engine # The runner creates isolated worktrees from ZOPU_SOURCE_REPOSITORY and
# (envoy mode) backed by a native Rust sidecar. createClient() connects # connects to AgentOS through the public engine endpoint.
# back to RIVET_ENDPOINT. No separate Rivet Engine process is required
# for single-node operation. Leave RIVET_ENDPOINT unset to use the
# library default (http://localhost:6420).
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
#RIVET_ENDPOINT=http://localhost:6420 RIVET_ENDPOINT=https://default:@rivet.example.com
RIVET_PUBLIC_ENDPOINT=https://default@rivet.example.com
RIVET_RUNNER_VERSION=1
RIVET_WORKSPACE_TOKEN=replace-with-a-long-random-workspace-token
ZOPU_SOURCE_REPOSITORY=/opt/zopu-source
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 5. Zopu agent service (Flue) # 4. Zopu agent service (Flue)
# FLUE_DB_TOKEN authenticates the Flue persistence adapter. # 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 FLUE_DB_TOKEN=replace-with-long-random-token
PORT=3583 AGENT_BACKEND_URL=https://zopu-agent.example.com
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 6. Daemon identity # 5. Daemon identity
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
DAEMON_ID=zopu-dedicated DAEMON_ID=zopu-dedicated
DAEMON_NAME=Zopu Dedicated Server DAEMON_NAME=Zopu-Dedicated-Server
DAEMON_VERSION=0.0.0 DAEMON_VERSION=0.0.0
DAEMON_HEARTBEAT_MS=15000 DAEMON_HEARTBEAT_MS=15000
DAEMON_COMMAND_LEASE_MS=60000 DAEMON_COMMAND_LEASE_MS=60000

View File

@@ -1,9 +1,6 @@
# Zopu Single-Node Runtime Deployment # Zopu Single-Node Runtime Deployment
Deployment artifacts for the Zopu execution plane on a single Debian dedicated 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.
server. Convex is already deployed as the control plane; this lane deploys only
the execution plane: the daemon (Bun/Effect + AgentOS/RivetKit), the Flue agent
service, Docker Engine for future Orb sandboxes, and supporting infrastructure.
## Architecture ## Architecture
@@ -13,23 +10,17 @@ service, Docker Engine for future Orb sandboxes, and supporting infrastructure.
│ ~12 CPU cores · ~40 GB RAM · single-node │ │ ~12 CPU cores · ~40 GB RAM · single-node │
│ │ │ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ zopu-daemon │ │ zopu-agent │ │ Docker │ │ │ │ zopu-web │ │ zopu-agent │ │ Docker │ │
│ │ (systemd) │ │ (systemd) │ │ Engine │ │ │ │ (systemd) │ │ (systemd) │ │ Engine │ │
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │
│ │ Effect daemon│ │ Flue Node │ │ Orb sandboxes│ │ │ │ React Router │ │ Flue Node 22 │ │ Convex │ │
│ │ + RivetKit │ │ server.mjs │ │ (future) │ │ │ │ :13100 │ │ server.mjs │ │ backend │ │
│ │ in-process │ │ :3583 │ │ │ │ │ │ │ │ :3583 │ │ :3210/:3211 │ │
│ │ engine │ │ │ │ │ │
│ │ + native │ │ │ │ │ │
│ │ sidecar │ │ │ │ │ │
│ │ :6420 │ │ │ │ │ │
│ └──────┬───────┘ └──────┬───────┘ └──────────────┘ │ │ └──────┬───────┘ └──────┬───────┘ └──────────────┘ │
│ │ │ │ │ │ │ │
└─────────────────── ─────────────
│ zopu-daemon │ Bun + Effect + RivetKit :6420
────────▼───────── ──────────────
│ │ Convex (cloud) │ ← control plane (pre-deployed)│
│ └──────────────────┘ │
│ │ │ │
│ systemd timers: health-check (60s), docker-cleanup (daily) │ │ systemd timers: health-check (60s), docker-cleanup (daily) │
│ cron: disk-monitor (daily 06:00) │ │ cron: disk-monitor (daily 06:00) │
@@ -40,31 +31,15 @@ service, Docker Engine for future Orb sandboxes, and supporting infrastructure.
### Single-node RivetKit topology ### Single-node RivetKit topology
The daemon calls `registry.start()` from `rivetkit`, which boots an 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.
**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 — 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`.
`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 **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.
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 / Orb boundary
Docker Engine is installed and the `zopu` service user is in the `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.
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 ## Files
@@ -76,6 +51,7 @@ deploy/zopu-runtime/
├── systemd/ ├── systemd/
│ ├── zopu-daemon.service # Daemon systemd unit │ ├── zopu-daemon.service # Daemon systemd unit
│ ├── zopu-agent.service # Agent systemd unit │ ├── zopu-agent.service # Agent systemd unit
│ ├── zopu-web.service # React Router web systemd unit
│ ├── zopu-health.service # Health check oneshot │ ├── zopu-health.service # Health check oneshot
│ ├── zopu-health.timer # Health check every 60s │ ├── zopu-health.timer # Health check every 60s
│ ├── zopu-docker-cleanup.service │ ├── zopu-docker-cleanup.service
@@ -108,7 +84,7 @@ bash bootstrap.sh
nano /opt/zopu/.env nano /opt/zopu/.env
# 5. Start services: # 5. Start services:
systemctl start zopu-daemon systemctl start zopu-web zopu-daemon
sleep 3 sleep 3
systemctl start zopu-agent systemctl start zopu-agent
@@ -122,20 +98,20 @@ systemctl enable --now zopu-health.timer zopu-docker-cleanup.timer
## Start / stop / restart ## Start / stop / restart
```bash ```bash
# Start both services # Start all services
systemctl start zopu-daemon zopu-agent systemctl start zopu-web zopu-daemon zopu-agent
# Stop both services # Stop all services
systemctl stop zopu-agent zopu-daemon systemctl stop zopu-agent zopu-daemon zopu-web
# Restart (daemon first — it owns the RivetKit engine) # 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 # Enable on boot
systemctl enable zopu-daemon zopu-agent systemctl enable zopu-web zopu-daemon zopu-agent
# Disable on boot # Disable on boot
systemctl disable zopu-daemon zopu-agent systemctl disable zopu-web zopu-daemon zopu-agent
``` ```
## Log inspection ## Log inspection
@@ -183,16 +159,14 @@ systemctl list-timers zopu-health.timer
``` ```
The health check probes: The health check probes:
1. `zopu-daemon` systemd unit is active 1. `zopu-daemon` systemd unit is active
2. `zopu-agent` systemd unit is active 2. `zopu-agent` systemd unit is active
3. RivetKit engine port (default 6420) accepts TCP connections 3. RivetKit engine port (default 6420) accepts TCP connections
4. Flue agent port (default 3583) accepts TCP connections 4. Flue agent port (default 3583) accepts TCP connections
5. Docker daemon responds to `docker info` 5. Docker daemon responds to `docker info`
No HTTP health endpoints are assumed. Flue does not expose one by design 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.
(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 ## Update to commit
@@ -208,6 +182,7 @@ publicly addressable on the engine endpoint.
``` ```
The update script: The update script:
1. Records current HEAD to `.last-deployed-sha` 1. Records current HEAD to `.last-deployed-sha`
2. Fetches, resolves branch-or-commit, checks out 2. Fetches, resolves branch-or-commit, checks out
3. `bun install`, builds daemon and agent 3. `bun install`, builds daemon and agent
@@ -224,9 +199,7 @@ The update script:
/opt/zopu/deploy/zopu-runtime/scripts/rollback.sh abc123def456 /opt/zopu/deploy/zopu-runtime/scripts/rollback.sh abc123def456
``` ```
Rollback reads `.last-deployed-sha` (written by `update.sh`), checks out that 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.
commit, rebuilds, and restarts services. The pre-rollback SHA is saved to
`.pre-rollback-sha` for re-rollback if needed.
## Docker cleanup ## Docker cleanup
@@ -242,11 +215,11 @@ systemctl list-timers zopu-docker-cleanup.timer
``` ```
Cleanup prunes: Cleanup prunes:
- Stopped containers older than 24 hours - Stopped containers older than 24 hours
- Dangling (untagged) images - Dangling (untagged) images
- Unused networks - Unused networks
Named volumes and running containers are never removed. Named volumes and running containers are never removed.
## Disk-space monitoring ## 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 /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`. A cron job runs at 06:00 daily and writes to `/var/log/zopu/disk-monitor.log`. Default alert threshold is 80%.
Default alert threshold is 80%.
## Firewall and private networking ## Firewall and private networking
The firewall (`ufw`) is deny-by-default: The firewall (`ufw`) is deny-by-default:
- SSH (port 22) is allowed on all interfaces - SSH (port 22) is allowed on all interfaces
- All traffic on `tailscale0` is allowed (Tailscale private overlay) - All traffic on `tailscale0` is allowed (Tailscale private overlay)
- All other incoming traffic is denied - All other incoming traffic is denied
The RivetKit engine (`:6420`) and Flue agent (`:3583`) ports are **not** The RivetKit engine (`:6420`) and Flue agent (`:3583`) ports are **not** exposed on public interfaces. Reachability options:
exposed on public interfaces. Reachability options:
1. **Tailscale** (recommended): bootstrap runs `ufw allow in on tailscale0` 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`.
so all ports are reachable over the private overlay. Set 2. **Custom private interface**: if you have a non-Tailscale private network (e.g. a VLAN or wireguard interface), add an explicit rule:
`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 ```bash
ufw allow in on eth1 # or your private interface name ufw allow in on eth1 # or your private interface name
``` ```
Do NOT assume direct private IP access works by default — the deny-incoming Do NOT assume direct private IP access works by default — the deny-incoming policy blocks it until an interface-specific rule is added.
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.
3. **Caddy** (optional): install Caddy and use the annotated Caddyfile in
`caddy/` if you need TLS termination or a public ingress point.
## Environment groups ## Environment groups
See [`.env.template`](./.env.template) for the full annotated template. The See [`.env.template`](./.env.template) for the full annotated template. The eight required groups:
eight required groups:
| Group | Variables | | Group | Variables |
|-------|-----------| | --- | --- |
| Convex | `CONVEX_URL`, `CONVEX_SITE_URL`, `SITE_URL` | | Convex | `CONVEX_URL`, `CONVEX_SITE_URL`, `SITE_URL` |
| Gitea | `GITEA_URL`, `GITEA_TOKEN` | | Gitea | `GITEA_URL`, `GITEA_TOKEN` |
| Model gateway | `AGENT_MODEL_*` | | Model gateway | `AGENT_MODEL_*` |
| AgentOS/RivetKit | `RIVET_ENDPOINT` (optional) | | 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` | | Daemon | `DAEMON_ID`, `DAEMON_NAME`, `DAEMON_VERSION`, `DAEMON_HEARTBEAT_MS`, `DAEMON_COMMAND_LEASE_MS` |
| Docker sandbox | group membership (no env vars) | | Docker sandbox | group membership (no env vars) |
| Service auth | `AUTH_SECRET` (if needed) | | 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 ## What is NOT deployed
- **Web app**: the web frontend is not deployed in this lane.
- **Kubernetes**: no container orchestration. - **Kubernetes**: no container orchestration.
- **PostgreSQL for Rivet**: the in-process RivetKit engine uses its own - **PostgreSQL for Rivet**: the in-process RivetKit engine uses its own storage; no external PostgreSQL is required.
storage; no external PostgreSQL is required.
- **Multi-node coordination**: single-node only. - **Multi-node coordination**: single-node only.
- **Public administration endpoints**: no admin HTTP surface. - **Public administration endpoints**: no admin HTTP surface.
- **Secrets in source**: `.env` is never committed; `.env.template` contains - **Secrets in source**: `.env` is never committed; `.env.template` contains only placeholder values.
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.
- **Production generated-app hosting**: only the execution plane runs here.
- **Docker-backed Orb sandboxes**: Docker is installed and access is
provisioned, but no Orb sandbox code is wired. This is a boundary
prepared for the Orb lane, not a working feature.
## Reproducibility ## Reproducibility
The deployment does not require the developer's MacBook to remain online. The deployment does not require the developer's MacBook to remain online. Once bootstrap completes and `.env` is filled in:
Once bootstrap completes and `.env` is filled in:
1. Services run under systemd with `Restart=always`. 1. Services run under systemd with `Restart=always`.
2. Logs persist in journald. 2. Logs persist in journald.

View File

@@ -14,9 +14,9 @@
# TAILSCALE_AUTHKEY — if set, configure Tailscale # TAILSCALE_AUTHKEY — if set, configure Tailscale
# TAILSCALE_HOSTNAME — Tailscale hostname (default: zopu-runtime) # TAILSCALE_HOSTNAME — Tailscale hostname (default: zopu-runtime)
# #
# Installs: Docker Engine, Bun, clones the repo, runs bun install, builds the # Installs: Docker Engine, Node.js 22, Bun, clones the repo, runs bun install, builds the
# daemon and agent, creates a non-root service user, installs systemd units, # web app, daemon, and agent, creates a non-root service user, installs systemd
# and configures firewall/Tailscale defaults. # units, and configures firewall/Tailscale defaults.
set -euo pipefail set -euo pipefail
@@ -105,18 +105,31 @@ fi
systemctl enable --now docker 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 if ! command -v bun &>/dev/null; then
log "Installing Bun..." log "Installing Bun..."
curl -fsSL https://bun.sh/install | bash 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 else
log "Bun already installed: $(bun --version)" log "Bun already installed: $(bun --version)"
fi fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 4. Service user # 5. Service user
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
if ! id "$SERVICE_USER" &>/dev/null; then if ! id "$SERVICE_USER" &>/dev/null; then
log "Creating service user: $SERVICE_USER" log "Creating service user: $SERVICE_USER"
@@ -129,7 +142,7 @@ if ! id -nG "$SERVICE_USER" | grep -qw docker; then
fi fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 5. Clone or update repository # 6. Clone or update repository
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
if [[ -d "$INSTALL_DIR/.git" ]]; then if [[ -d "$INSTALL_DIR/.git" ]]; then
log "Repository exists at $INSTALL_DIR, fetching latest..." log "Repository exists at $INSTALL_DIR, fetching latest..."
@@ -144,25 +157,28 @@ else
fi 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..." log "Setting ownership of $INSTALL_DIR to $SERVICE_USER..."
chown -R "$SERVICE_USER":"$SERVICE_USER" "$INSTALL_DIR" chown -R "$SERVICE_USER":"$SERVICE_USER" "$INSTALL_DIR"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 6. Install dependencies and build # 7. Install dependencies and build
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
log "Running bun install..." log "Running bun install..."
run_as_service 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 run_as_service bun run build:daemon
log "Building agent service..." log "Building agent service..."
run_as_service bun run build:agents run_as_service bun run build:agents
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 7. Environment file # 8. Environment file
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
ENV_FILE="$INSTALL_DIR/.env" ENV_FILE="$INSTALL_DIR/.env"
if [[ ! -f "$ENV_FILE" ]]; then if [[ ! -f "$ENV_FILE" ]]; then
@@ -179,17 +195,17 @@ else
fi fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 8. Persistent log directory # 9. Persistent log directory
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
LOG_DIR="/var/log/zopu" LOG_DIR="/var/log/zopu"
mkdir -p "$LOG_DIR" mkdir -p "$LOG_DIR"
chown "$SERVICE_USER":"$SERVICE_USER" "$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..." 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-health.timer zopu-health.service \
zopu-docker-cleanup.timer zopu-docker-cleanup.service; do zopu-docker-cleanup.timer zopu-docker-cleanup.service; do
SRC="$DEPLOY_DIR/systemd/$unit" SRC="$DEPLOY_DIR/systemd/$unit"
@@ -205,7 +221,7 @@ done
systemctl daemon-reload systemctl daemon-reload
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 10. Firewall (deny-by-default, explicit allow for Tailscale) # 11. Firewall (deny-by-default, explicit allow for Tailscale)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
log "Configuring firewall..." log "Configuring firewall..."
if ! ufw status 2>/dev/null | grep -q "Status: active"; then if ! ufw status 2>/dev/null | grep -q "Status: active"; then
@@ -227,7 +243,7 @@ else
fi fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 11. Tailscale (optional) # 12. Tailscale (optional)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
if [[ -n "${TAILSCALE_AUTHKEY:-}" ]]; then if [[ -n "${TAILSCALE_AUTHKEY:-}" ]]; then
log "Installing and configuring Tailscale..." log "Installing and configuring Tailscale..."
@@ -249,7 +265,7 @@ else
fi fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 12. Disk-space monitoring cron # 13. Disk-space monitoring cron
# /etc/cron.d format REQUIRES a username field. # /etc/cron.d format REQUIRES a username field.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
log "Installing disk-space monitor (daily at 06:00)..." log "Installing disk-space monitor (daily at 06:00)..."
@@ -265,7 +281,7 @@ echo ""
echo "Next steps:" echo "Next steps:"
echo " 1. Edit $ENV_FILE with real values" echo " 1. Edit $ENV_FILE with real values"
echo " 2. Start services:" 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 " 3. Enable health monitoring:"
echo " systemctl enable --now zopu-health.timer zopu-docker-cleanup.timer" echo " systemctl enable --now zopu-health.timer zopu-docker-cleanup.timer"
echo " 4. Verify health:" 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 # The web frontend (port 5173) is served at the root, Better Auth is proxied
# for the execution plane to function. Use Caddy only when an HTTP endpoint # first-party under /api/auth, and Flue is mounted under /api/flue.
# is genuinely needed (e.g., a webhook ingress from Convex site functions).
#
# In the default single-node deployment, the agent listens on localhost:3583
# and is reachable over Tailscale. Install Caddy only if you need TLS
# termination or a public ingress point.
#
# --- Private/Tailscale deployment (recommended) ---
#
# Listen only on the Tailscale interface. Replace the hostname with your
# Tailscale machine name or IP.
#
# http://zopu-runtime.tail-xxxx.ts.net {
# reverse_proxy localhost:3583
# }
#
# --- Public deployment with automatic TLS (only if needed) ---
#
# agent.example.invalid {
# reverse_proxy localhost:3583
# }
# Default: do not ship an active Caddyfile. The file is documentation. 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

@@ -0,0 +1,23 @@
FROM oven/bun:1.3.14 AS bun
FROM node:24-bookworm-slim
COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates g++ git make python3 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /opt/zopu-source
COPY . .
RUN bun install --frozen-lockfile
ENV AGENT_WORKSPACE_ROOT=/var/lib/zopu/workspaces
ENV BUN_EXECUTABLE=/usr/local/bin/bun
ENV DAEMON_ID=zopu-agentos-runner
ENV ZOPU_SOURCE_REPOSITORY=/opt/zopu-source
VOLUME ["/var/lib/zopu/workspaces"]
CMD ["bun", "packages/agents/src/runner.ts"]

View File

@@ -0,0 +1,26 @@
services:
zopu-agentos-runner:
build:
context: ../../..
dockerfile: deploy/zopu-runtime/runner/Dockerfile
environment:
AGENT_MODEL_API: ${AGENT_MODEL_API}
AGENT_MODEL_API_KEY: ${AGENT_MODEL_API_KEY}
AGENT_MODEL_BASE_URL: ${AGENT_MODEL_BASE_URL}
AGENT_MODEL_CONTEXT_WINDOW: ${AGENT_MODEL_CONTEXT_WINDOW}
AGENT_MODEL_MAX_TOKENS: ${AGENT_MODEL_MAX_TOKENS}
AGENT_MODEL_NAME: ${AGENT_MODEL_NAME}
AGENT_MODEL_PROVIDER: ${AGENT_MODEL_PROVIDER}
CONVEX_URL: ${CONVEX_URL}
DAEMON_ID: zopu-agentos-runner
FLUE_DB_TOKEN: ${FLUE_DB_TOKEN}
RIVET_ENDPOINT: ${RIVET_ENDPOINT}
RIVET_PUBLIC_ENDPOINT: ${RIVET_PUBLIC_ENDPOINT}
RIVET_WORKSPACE_TOKEN: ${RIVET_WORKSPACE_TOKEN}
ZOPU_SOURCE_REPOSITORY: /opt/zopu-source
volumes:
- zopu-agentos-workspaces:/var/lib/zopu/workspaces
restart: unless-stopped
volumes:
zopu-agentos-workspaces:

View File

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

View File

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

View File

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

View File

@@ -10,11 +10,13 @@ Group=__SERVICE_USER__
WorkingDirectory=__INSTALL_DIR__ WorkingDirectory=__INSTALL_DIR__
EnvironmentFile=__INSTALL_DIR__/.env EnvironmentFile=__INSTALL_DIR__/.env
Environment=HOME=/var/lib/zopu
StateDirectory=zopu
# RivetKit in-process engine: envoy mode (serverful). # RivetKit in-process engine: envoy mode (serverful).
# registry.start() boots the native Rust sidecar and actor envoy. # Run the Bun source entrypoint so AgentOS software assets retain their real
# RIVET_ENDPOINT defaults to http://localhost:6420 inside the daemon process. # node_modules paths; Bun compiled executables do not embed .aospkg files.
ExecStart=__INSTALL_DIR__/apps/daemon/dist/code-daemon ExecStart=/usr/local/bin/bun --env-file=__INSTALL_DIR__/.env apps/daemon/src/index.ts
Restart=always Restart=always
RestartSec=10 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 # 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.** The user should experience:
## 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:
```text ```text
┌─────────────────────────────────────────────────────────────────────┐ Talk naturally
│ Header │ → see durable Work emerge
├───────────────┬───────────────────────────────────┬─────────────────┤ → approve what matters
│ Work index │ Primary workspace │ Context panel │ → watch meaningful progress
│ │ cards / expanded work / chat │ evidence, │ → answer precise questions
│ │ │ artifacts, │ → inspect evidence
│ │ │ preview, tools │ → receive a reviewable result
├───────────────┴───────────────────────────────────┴─────────────────┤
│ Persistent composer │
└─────────────────────────────────────────────────────────────────────┘
``` ```
The layout collapses gracefully on smaller screens. The UI MUST optimize **confidence per minute of human attention**, not display every agent action.
### Header ## 2. Information architecture
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:
```text ```text
Global workspace Project
Working in: Fix Safari OAuth callback failures ├── 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. - user;
- **Needs attention:** approvals, decisions, questions, reviews, risks. - Zopu response;
- **Autonomous progress:** work advanced since last visit; new artifacts; completed verification; follow-up candidates. - Signal notice;
- **Project health:** current goal, active count, blocker count, recent result, risk/health signal per relevant project. - Work created/updated;
- **Recent outcomes:** completed work, measured effects, captured learnings. - 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: Display only decision-relevant information:
| 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
```text ```text
Support · Acme Corp · 18m ago Title
Three users could not finish OAuth setup in Safari. phase/status
Possible match: Fix Safari callback failures · Confidence: High latest meaningful update
[Attach] [Create separate work] [Dismiss] blocker/question
next action
slice progress
artifact count
``` ```
Example:
```text ```text
[Type] [Organization / Project] [Status] Add build-aware health endpoint
Fix Safari OAuth callback failures Verifying slice 2 of 3
Supports: Improve onboarding reliability · 42% complete HTTP behavior passed; design check pending
Blocked: Need production Safari logs Next: complete verifier review
Latest: Agent reproduced the issue · 18m ago 2 signals · 4 artifacts
Next: Approve temporary logging deployment
3 artifacts · 5 sources · 2 runs
``` ```
Avoid raw tokens, wakeups, grep calls, and internal actor noise.
### Expanded
Use six sections:
```text ```text
Waiting · Approval required 1. Outcome
Fix Safari OAuth callback failures 2. Design
Need: Approve 30-minute diagnostic logging 3. Build
Impact: Investigation cannot continue without production evidence 4. Evidence
[Review request] 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 ```text
Approval required — Merge PR #184 Defining outcome
Why: All automated verification passed; no blocking review issues remain. Awaiting definition approval
Impact: Changes OAuth callback validation; no database migration. Designing
Evidence: 14 unit tests; 6 browser tests; preview reviewed; automated code review attached. Awaiting design approval
[Review changes] [Request changes] [Merge] 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 A question/approval is a structured card:
**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:
```text ```text
Created candidate work: Improve OAuth diagnostics Decision title
Linked to: Restore OAuth reliability · Sources attached: 3 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. Example:
## 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:
```text ```text
Implementation run · 24 minutes Should /health be public?
• Inspected callback handler • Added state validation
• Added regression tests • 14 tests passed Recommendation
• Created PR #184 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. 1. production/security blockers;
2. **Evidence panel:** sources supporting current understanding/recommendation. 2. decisions blocking active Work;
3. **Raw source:** original message, issue, log, file, or event. 3. review/approval requests;
4. **Derivation detail (high-impact claims):** conclusion; evidence; assumptions; confidence; conflicting evidence; last updated; producing agent/process. 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 ```text
Portfolio Approve | Request revision | Edit directly
├── Company A
│ ├── Product Alpha
│ └── Internal Platform
└── Client B
└── Customer Portal
``` ```
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. Highlight changes between versions. Approval records actor/user, version, timestamp, and optional comment.
- **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.
## 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. Each slice shows:
## 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:
```text ```text
Deploy preview objective
Project: Zopu Web · Branch: work/W-103/team-invitations observable behavior
Environment: Temporary preview · External impact: None planned code changes
Expected duration: 20 minutes required checks
[Deploy preview] 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 ```text
Autonomous within policy · No action required Pending | Ready | Running | Verifying | Review | Passed | Failed | Blocked
Paused after repeated verification failure · Review required
``` ```
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 | Good:
| --- | --- |
| 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. |
## 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 | Bad:
| --- | --- |
| 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. |
## 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. Design-conformance findings are labeled:
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.
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. ```text
- Organization/project scope is clear. Open PR | Approve | Request changes | Answer blocker
- 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.
## 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 | ### ICP v0
| --- | --- |
| **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. |
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. ```text
- **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. Project chat
- **The product participates in work** — not just store/display: discover, understand, plan, execute, verify, publish, measure. → actionable Signal
- **Autonomy reduces attention, not adds noise.** Successful when it makes correct progress, informs at the right level, asks only when necessary. → approved Work Definition
- **Knowledge compounds.** Every decision, result, failure, signal, artifact improves understanding and accelerates/future-proofs future work. → 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 ### 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 | Examples:
| --- | --- | --- |
| **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. |
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 | ## 5. Quality doctrine
| --- | --- |
| **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. |
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 | #### Standard
| --- | --- |
| **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. |
## 13. Product boundaries Normal features, APIs, workflows, changes spanning a few modules.
| Boundary | Rule | ```text
| --- | --- | definition → combined design → 13 slices → verify → review → PR
| **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. |
## 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 | - What remains incomplete?
| --- | --- | - What is executable now?
| **Execution capacity** | Small team progresses more work without proportional headcount. | - What capability can perform it?
| **Coordination reduction** | Handles repetitive context-gathering, state updates, linking, follow-ups, output prep. | - What evidence is missing?
| **Continuity** | Work keeps context, history, decisions, artifacts across tools/time. | - Should it retry, repair, replan, escalate, or stop?
| **Better opportunity capture** | Notices recurring problems, customer opportunities, operational risks, key relationships teams otherwise miss. | - Has the Work reached a valid terminal state?
## 16. Initial scope — first product loop Resolver outcomes:
1. User starts/connects a software project. ```text
2. Product establishes project context. VerifiedOutcomeDelivered
3. User describes desired work via global composer. VerifiedPRReady
4. System creates/updates a work unit. HumanDecisionRequired
5. Unit is clarified and accepted. ExternalDependencyBlocked
6. System progresses work autonomously. BudgetExhaustedWithEvidence
7. User reviews only necessary decisions/outputs. PermanentlyFailedWithDiagnosis
8. System produces verified artifacts. Cancelled
9. User approves publication/integration where required. ```
10. Product updates the unit and retains knowledge.
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. ```text
2. Represent outcomes, not activity. decision needed
3. Keep the system calm while the machinery stays powerful. why it is needed
4. Bring humans in for judgment, not clerical coordination. recommended action
5. Expose evidence and uncertainty. alternatives
6. Make important actions understandable and reversible. consequences
7. Preserve organization and project boundaries. urgency
8. Prefer one coherent model over many disconnected features. affected Work/slice
9. Let knowledge compound across completed work. ```
10. Measure success by outcomes and reduced attention.
## 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,678 @@
# Zopu Work OS — Technical Architecture # 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 ```text
Web / desktop / mobile
Control plane + execution plane. │ Convex queries, mutations, storage
```mermaid Convex application backend
flowchart TB ├── authentication and tenancy
User --> Web[Web] & Mobile[Expo] ├── normalized product data
Web & Mobile --> API[App API] & Realtime ├── conversation turn queue
API --> Auth[Identity/tenancy] & WorkGraph[Work graph] & Conversation & Policy └── reactive client projections
Conversation & WorkGraph --> Flow[Flow Agent] │ durable Workflow steps + service-authenticated dispatch
Flow --> Context & Scheduler & Policy & Gateway[Model gateway] & Integrations & Secrets[Secrets/workload identity broker]
Scheduler --> Actor[AgentOS run actor] Private agent backend
Actor <--> Harness[OpenCode/ACP] & Sandbox[Full Linux sandbox] ├── FLUE product agents and typed tools
Harness --> Gateway & Integrations ├── AgentOS execution environments
Sandbox --> Git & Runtime[Services/browsers/tests/builds] ├── Codex implementation harness
Actor & Sandbox --> Secrets └── canonical events/results returned to Convex
Runtime --> Preview │ optional attached full sandbox
Actor & Sandbox & Git --> Events[Event/provenance log]
Events --> WorkGraph & Realtime & Observability Cube/E2B-compatible runtime (later)
Artifact[Object storage] --> WorkGraph
Context & WorkGraph --> Search
Policy --> Notifications
``` ```
## 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 |
| Convex Workflow | durable sequencing, retries, cancellation, scheduling, and completion callbacks |
| Agent backend | private programmable agents and execution adapters |
| Rivet Engine + runners | placement, routing, sleep/wake, and execution ownership for AgentOS workspaces |
| 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 ## 2. Domain boundaries
{
"event_id": "evt_01...", Recommended packages:
"type": "run.verification_completed",
"organization_id": "org_01...", ```text
"project_id": "prj_01...", packages/
"work_unit_id": "wrk_01...", ├── signals/
"run_id": "run_01...", ├── work/
"actor_type": "agent", ├── design/
"actor_id": "opencode", ├── planning/
"source_type": "agentos_session", ├── kits/
"source_id": "session_01...", ├── resolver/
"sequence": 184, ├── verification/
"payload": { ├── artifacts/
"summary": "All unit and browser tests passed", ├── results/
"artifact_ids": ["art_01..."] ├── knowledge/
}, ├── runtimes/
"created_at": "2026-07-23T12:00:00Z" ├── 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. ```text
Proposed
**Memory classes (four)** → Defining
→ AwaitingDefinitionApproval
- _Canonical project knowledge_ (versioned/reviewable, prefer in Git): `product.md`, `design.md`, `tech.md`, `business.md`, `AGENTS.md`, READMEs, decision records, repo-local conventions. → Designing
- _Structured operational memory_ (DB): goals, work-unit state, requirements, decisions, approvals, blockers, dependencies, results. → AwaitingDesignApproval
- _Episodic memory_ (events/transcripts): user messages, agent runs, tool calls, errors, reviews, deployments, human interventions. → Ready
- _Retrieval index_ (derived, non-authoritative → returns source refs): canonical docs, repo content, conversation, work-unit events, artifacts, connected external sources. → ExecutingSlice
→ VerifyingSlice
**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. → AwaitingSliceReview
→ IntegratedVerification
**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. → Review
→ Releasing
**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. → Observing
→ Completed
**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
``` ```
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. ```text
NeedsInput | Blocked | Replanning | Failed | Cancelled
**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/
``` ```
`project.yaml`: Transitions require explicit commands plus evidence. State MUST NOT be inferred from the latest text message.
```yaml ## 5. Actor model
name: example-project
runtime: { template: react-hono, node: "24", packageManager: pnpm } Start small.
commands:
{ ### ProjectActor
install: pnpm install --frozen-lockfile,
test: pnpm test, Owns:
verify: pnpm run verify,
dev: pnpm dev, - project configuration;
} - repository/runtime policies;
services: { web: { port: 3000 }, api: { port: 4000 } } - active Work index;
changes: { workflow: pull_request, baseBranch: main } - integration endpoints;
approvals: - project-level Signal routing.
{
pushBranch: automatic, ### WorkActor
createPullRequest: automatic,
mergePullRequest: required, Initial central aggregate:
deployPreview: automatic,
deployProduction: required, - 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}`. ```text
SignalCreated
**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. SignalAttached
WorkProposed
**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. DefinitionGenerated
DefinitionApproved
**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. DesignGenerated
DesignApproved
## 3. Core execution flows SliceStarted
AttemptStarted
**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. AttemptCompleted
VerificationPassed
**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. VerificationFailed
QuestionOpened
**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. QuestionAnswered
PullRequestCreated
**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. WorkCompleted
## 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
``` ```
## 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. ```text
- 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. <workId>:<commandType>:<logicalVersion>
- 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
``` ```
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>;
}
``` interface SourceControl {
tool_call bash "pnpm test" → run.verification_started prepareWorktree(input: WorktreeInput): Effect.Effect<Worktree, GitError>;
tool_result exit_code=0 → run.test_suite_passed diff(worktree: Worktree): Effect.Effect<DiffArtifact, GitError>;
permission_request git_push → approval.requested commit(input: CommitInput): Effect.Effect<CommitArtifact, GitError>;
file_edit src/auth/callback.ts → artifact.code_change_updated 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. 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.
- **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.
## 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
### Convex orchestration
Slice 5 uses the Convex Workflow component as the durable product orchestration layer. Workflow steps call private agent-backend actions, while all user-visible state, events, artifacts, idempotency keys, and cancellation remain canonical in Convex.
### 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.
Slice 5 starts here with one Codex-backed AgentOS actor and one authenticated repository checkout per project. Convex Workflow owns product orchestration; Rivet Engine coordinates placement while a normal runner executes the actor. Use an attached full sandbox through the E2B-compatible boundary 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.

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