feat: integrate mobile work chat and Gitea delivery

This commit is contained in:
sai karthik
2026-07-26 00:50:11 +05:30
parent 48200a11df
commit 2a0487aa6e
31 changed files with 2477 additions and 220 deletions

View File

@@ -18,7 +18,7 @@
"@rivet-dev/agentos": "catalog:",
"convex": "catalog:",
"effect": "catalog:",
"rivetkit": "0.0.0-stack-feat-rivetkit-forward-inspector-tabs-to-native-plugin-actors-qkwmksyy.d2859b0"
"rivetkit": "2.3.7"
},
"devDependencies": {
"@code/config": "workspace:*",

View File

@@ -2,26 +2,27 @@ import { env } from "@code/env/server";
import { Console, Effect, FiberSet, Result, Stream } from "effect";
import { makeAgentOsRuntime } from "./agent-os";
import { ConvexControlPlane, type DaemonCommand } from "./convex";
import { ConvexControlPlane } from "./convex";
import type { DaemonCommand } from "./convex";
const errorMessage = (cause: unknown) =>
cause instanceof Error ? cause.message : String(cause);
export const runDaemon = Effect.scoped(
Effect.gen(function* () {
Effect.gen(function* runDaemon() {
const controlPlane = yield* ConvexControlPlane;
const agentOs = yield* makeAgentOsRuntime;
const sessionId = crypto.randomUUID();
const hostname = yield* Effect.promise(() => import("node:os")).pipe(
Effect.map((os) => os.hostname())
);
const fibers = yield* FiberSet.make<void>();
const fibers = yield* FiberSet.make();
yield* controlPlane.connect({
sessionId,
architecture: process.arch,
hostname,
platform: process.platform,
architecture: process.arch,
sessionId,
});
yield* Console.log(`daemon ${env.DAEMON_ID} connected as ${sessionId}`);
@@ -40,33 +41,37 @@ export const runDaemon = Effect.scoped(
Effect.forkScoped
);
const processCommand = Effect.fn("Daemon.processCommand")(function* (
candidate: DaemonCommand
) {
const command = yield* controlPlane.claim(candidate._id, sessionId);
if (!command) {
return;
const processCommand = Effect.fn("Daemon.processCommand")(
function* processCommand(candidate: DaemonCommand) {
const command = yield* controlPlane.claim(candidate._id, sessionId);
if (!command) {
return;
}
const started = yield* controlPlane.start(command._id, sessionId);
if (!started) {
return;
}
yield* controlPlane.recordEvent({
commandId: command._id,
data: { actorKey: command.actorKey, method: command.method },
kind: "command.started",
});
const result = yield* agentOs.execute(command).pipe(Effect.result);
if (Result.isSuccess(result)) {
yield* controlPlane.succeed(
command._id,
sessionId,
result.success ?? null
);
return;
}
yield* controlPlane.fail(
command._id,
sessionId,
errorMessage(result.failure)
);
}
const started = yield* controlPlane.start(command._id, sessionId);
if (!started) {
return;
}
yield* controlPlane.recordEvent({
commandId: command._id,
kind: "command.started",
data: { method: command.method, actorKey: command.actorKey },
});
const result = yield* agentOs.execute(command).pipe(Effect.result);
if (Result.isSuccess(result)) {
yield* controlPlane.succeed(command._id, sessionId, result.success);
return;
}
yield* controlPlane.fail(
command._id,
sessionId,
errorMessage(result.failure)
);
});
);
yield* controlPlane.commands.pipe(
Stream.runForEach((commands) =>

View File

@@ -1,21 +1,29 @@
import { signOutWeb, useWebAuth } from "@code/auth/web";
import type { Id } from "@code/backend/convex/_generated/dataModel";
import {
MobileAssistantChatScreen,
MobileExpandedWorkScreen,
MobileHomeScreen,
MobileWorkChatScreen,
MobileWorkListScreen,
MobileWorkStackScreen,
MobileWorkUnitDetailScreen,
} from "@code/ui/components/mobile-product";
import type { FormEvent } from "react";
import { useNavigate, useParams } from "react-router";
import { useMobileProjectWorkspace } from "@/hooks/use-mobile-project-workspace";
import { useMobileWorkStack } from "@/hooks/use-mobile-work-stack";
import { useMobileWorkspace } from "@/hooks/use-mobile-workspace";
import { MODEL_ID, MODEL_LABEL } from "@/lib/chat/constants";
import { getMobileStatusMessage } from "@/lib/mobile-workspace/mobile-status-message";
import { getUserInitials } from "@/lib/users/get-user-initials";
export type MobileFlowScreen =
| "assistant-chat"
| "home"
| "stack-home"
| "work-chat"
| "work-list"
| "work-unit-detail";
@@ -26,6 +34,7 @@ interface MobileFlowPageProps {
export const MobileFlowPage = ({ screen }: MobileFlowPageProps) => {
const navigate = useNavigate();
const { workUnitId } = useParams();
const auth = useWebAuth();
const projectWorkspace = useMobileProjectWorkspace({
selectedWorkUnitId: workUnitId,
});
@@ -34,8 +43,21 @@ export const MobileFlowPage = ({ screen }: MobileFlowPageProps) => {
projectWorkspace.raiseIssue({ body, title }),
onSend: projectWorkspace.sendMessage,
});
const workStack = useMobileWorkStack(projectWorkspace.data.workUnits);
const workPath = "/work";
const chatPath = "/chat";
const handleRestoreChecked = workStack.restoreChecked;
const handleWorkUnitChecked = workStack.markChecked;
const handleWorkUnitSentBack = workStack.sendToBack;
const user = auth.status === "authenticated" ? auth.user : undefined;
const handleSignOut = async () => {
await signOutWeb();
navigate("/login");
};
const statusMessage = getMobileStatusMessage({
assistantError: projectWorkspace.error?.message,
localStatus: workspace.statusMessage,
});
const handleOpenUnit = (selectedWorkUnitId?: string) => {
const targetId =
@@ -48,7 +70,7 @@ export const MobileFlowPage = ({ screen }: MobileFlowPageProps) => {
workspace.setExpanded(true);
return;
}
navigate(`/work/${targetId}`);
navigate(`/chat/${targetId}`);
};
const screenProps = {
@@ -58,7 +80,7 @@ export const MobileFlowPage = ({ screen }: MobileFlowPageProps) => {
data: projectWorkspace.data,
modelId: MODEL_ID,
modelLabel: MODEL_LABEL,
onBack: () => navigate(workPath),
onBack: () => navigate(chatPath),
onComposerChange: workspace.handleComposerChange,
onComposerMessageSubmit: workspace.handleComposerMessageSubmit,
onComposerSubmit: workspace.handleComposerSubmit,
@@ -99,12 +121,55 @@ export const MobileFlowPage = ({ screen }: MobileFlowPageProps) => {
projectManagerOpen: workspace.projectManagerOpen,
repositoryValue: projectWorkspace.projectWorkspace.repository,
settingsOpen: workspace.settingsOpen,
statusMessage: workspace.statusMessage ?? projectWorkspace.error?.message,
statusMessage,
};
if (screen === "assistant-chat") {
return <MobileAssistantChatScreen {...screenProps} />;
}
if (screen === "stack-home") {
return (
<MobileWorkStackScreen
checkedCount={workStack.checkedCount}
composerValue={workspace.composerValue}
data={projectWorkspace.data}
modelId={MODEL_ID}
modelLabel={MODEL_LABEL}
onComposerChange={workspace.handleComposerChange}
onComposerSubmit={workspace.handleComposerSubmit}
onOpenUnit={(selectedWorkUnitId) =>
navigate(`/chat/${selectedWorkUnitId}`)
}
onRestoreChecked={handleRestoreChecked}
onSettingsClose={() => workspace.setSettingsOpen(false)}
onSettingsOpen={() => workspace.setSettingsOpen(true)}
onSignOut={() => {
void handleSignOut();
}}
onWorkUnitChecked={handleWorkUnitChecked}
onWorkUnitSentBack={handleWorkUnitSentBack}
settingsOpen={workspace.settingsOpen}
statusMessage={statusMessage}
userEmail={user?.email}
userInitials={getUserInitials(user?.name)}
userName={user?.name}
workUnits={workStack.visibleWorkUnits}
/>
);
}
if (screen === "work-chat") {
return (
<MobileWorkChatScreen
composerValue={workspace.composerValue}
data={projectWorkspace.data}
onBack={() => navigate(chatPath)}
onComposerChange={workspace.handleComposerChange}
onComposerMessageSubmit={workspace.handleComposerMessageSubmit}
onComposerSubmit={workspace.handleComposerSubmit}
statusMessage={statusMessage}
/>
);
}
if (screen === "home") {
return <MobileHomeScreen {...screenProps} />;
}

View File

@@ -13,24 +13,45 @@ import { STATUS_COPY } from "@/lib/chat/constants";
import { getMessageText } from "@/lib/chat/transforms";
import { buildMobileWorkspaceView } from "@/lib/mobile-workspace/build-mobile-workspace-view";
const WORK_UNIT_SCOPE_PATTERN =
/^<zopu-work-unit id="(?<workUnitId>[^"]+)">[\s\S]*?<message>(?<message>[\s\S]*?)<\/message>\s*<\/zopu-work-unit>$/u;
const toAssistantMessages = (
messages: ReturnType<typeof useOrganizationChatAgent>["messages"]
): MobileAssistantMessageView[] =>
messages.flatMap((message) => {
messages: ReturnType<typeof useOrganizationChatAgent>["messages"],
selectedWorkUnitId?: string
): MobileAssistantMessageView[] => {
const result: MobileAssistantMessageView[] = [];
for (const message of messages) {
if (message.role !== "assistant" && message.role !== "user") {
return [];
continue;
}
const text = getMessageText(message);
return text
? [
{
id: message.id,
role: message.role,
text,
},
]
: [];
});
const rawText = getMessageText(message);
if (!rawText) {
continue;
}
if (!selectedWorkUnitId) {
result.push({ id: message.id, role: message.role, text: rawText });
continue;
}
if (message.role === "user") {
const match = WORK_UNIT_SCOPE_PATTERN.exec(rawText);
if (match?.groups?.workUnitId !== selectedWorkUnitId) {
result.length = 0;
continue;
}
result.push({
id: message.id,
role: message.role,
text: match.groups.message?.trim() ?? rawText,
});
continue;
}
if (result.length > 0) {
result.push({ id: message.id, role: message.role, text: rawText });
}
}
return result;
};
const getStatusTone = (
status: ReturnType<typeof useOrganizationChatAgent>["status"]
@@ -57,12 +78,23 @@ export const useMobileProjectWorkspace = ({
? { organizationId: organization.organizationId }
: "skip"
);
const selectedIssue = projectWorkspace.issues?.find(
(issue) => String(issue._id) === selectedWorkUnitId
);
const sendMessage = (message: string) => {
if (!selectedIssue) {
return chatAgent.sendMessage(message);
}
return chatAgent.sendMessage(
`<zopu-work-unit id="${selectedIssue._id}">\n<context>Issue #${selectedIssue.number}: ${selectedIssue.title}</context>\n<message>${message}</message>\n</zopu-work-unit>`
);
};
const assistant: MobileAssistantView = {
isBusy:
chatAgent.status === "connecting" ||
chatAgent.status === "streaming" ||
chatAgent.status === "submitted",
messages: toAssistantMessages(chatAgent.messages),
messages: toAssistantMessages(chatAgent.messages, selectedWorkUnitId),
statusLabel: STATUS_COPY[chatAgent.status],
statusTone: getStatusTone(chatAgent.status),
};
@@ -88,7 +120,7 @@ export const useMobileProjectWorkspace = ({
raiseIssue: projectWorkspace.raiseIssue,
raiseIssueFromSignal: (signalId: string) =>
projectWorkspace.raiseIssueFromSignal(signalId as Id<"signals">),
sendMessage: chatAgent.sendMessage,
sendMessage,
startWorkUnit: projectWorkspace.startWorkUnit,
} as const;
};

View File

@@ -0,0 +1,66 @@
import type { MobileWorkUnitView } from "@code/ui/components/mobile-product";
import { useCallback, useMemo, useState } from "react";
export const useMobileWorkStack = (
workUnits: readonly MobileWorkUnitView[]
) => {
const [order, setOrder] = useState<readonly string[]>([]);
const [checkedIds, setCheckedIds] = useState<ReadonlySet<string>>(
() => new Set()
);
const workUnitsById = useMemo(
() => new Map(workUnits.map((workUnit) => [workUnit.id, workUnit])),
[workUnits]
);
const availableIds = useMemo(
() => new Set(workUnitsById.keys()),
[workUnitsById]
);
const visibleWorkUnits = useMemo(() => {
const retainedOrder = order.filter(
(workUnitId) =>
availableIds.has(workUnitId) && !checkedIds.has(workUnitId)
);
const retainedIds = new Set(retainedOrder);
const addedIds = workUnits
.map((workUnit) => workUnit.id)
.filter(
(workUnitId) =>
!retainedIds.has(workUnitId) && !checkedIds.has(workUnitId)
);
return [...retainedOrder, ...addedIds].flatMap((workUnitId) => {
const workUnit = workUnitsById.get(workUnitId);
return workUnit ? [workUnit] : [];
});
}, [availableIds, checkedIds, order, workUnits, workUnitsById]);
const sendToBack = useCallback(
(workUnitId: string) => {
if (visibleWorkUnits[0]?.id !== workUnitId) {
return;
}
setOrder([...visibleWorkUnits.slice(1).map(({ id }) => id), workUnitId]);
},
[visibleWorkUnits]
);
const markChecked = useCallback((workUnitId: string) => {
setCheckedIds((current) => new Set(current).add(workUnitId));
}, []);
const restoreChecked = useCallback(() => {
setCheckedIds(new Set());
setOrder([]);
}, []);
return {
checkedCount: [...checkedIds].filter((workUnitId) =>
availableIds.has(workUnitId)
).length,
markChecked,
restoreChecked,
sendToBack,
visibleWorkUnits,
} as const;
};

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { getMobileStatusMessage } from "./mobile-status-message";
describe("getMobileStatusMessage", () => {
it("keeps local composer feedback", () => {
expect(
getMobileStatusMessage({
assistantError: "Flue API error 401",
localStatus: "Sent to Zopu",
})
).toBe("Sent to Zopu");
});
it("does not expose Flue authorization internals", () => {
expect(
getMobileStatusMessage({
assistantError:
'Flue API error 401: GET /agents/zopu/example: {"error":"Unauthorized"}',
})
).toBe("Zopu is reconnecting…");
});
});

View File

@@ -0,0 +1,25 @@
interface MobileStatusMessageInput {
readonly assistantError?: string;
readonly localStatus?: string;
}
const isAgentAuthorizationError = (message: string) =>
message.includes("Flue API error 401") ||
message.includes('"Unauthorized"') ||
message.includes("GET /agents/");
export const getMobileStatusMessage = ({
assistantError,
localStatus,
}: MobileStatusMessageInput): string | undefined => {
if (localStatus) {
return localStatus;
}
if (!assistantError) {
return undefined;
}
if (isAgentAuthorizationError(assistantError)) {
return "Zopu is reconnecting…";
}
return "Zopu needs attention";
};

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

@@ -10,6 +10,7 @@ export default [
layout("./routes/app/mobile/layout.tsx", [
index("./routes/app/mobile/page.tsx"),
route("chat", "./routes/app/mobile/chat/page.tsx"),
route("chat/:workUnitId", "./routes/app/mobile/chat/work/page.tsx"),
route("work", "./routes/app/mobile/work-list/page.tsx"),
route("work/:workUnitId", "./routes/app/mobile/work/page.tsx"),
]),

View File

@@ -8,5 +8,5 @@ export const meta = (_args: Route.MetaArgs) => [
];
export default function MobileChatPage() {
return <MobileFlowPage screen="assistant-chat" />;
return <MobileFlowPage screen="stack-home" />;
}

View File

@@ -0,0 +1,15 @@
import { MobileFlowPage } from "@/components/mobile-workspace/mobile-flow-page";
import type { Route } from "./+types/page";
export const meta = (_args: Route.MetaArgs) => [
{ title: "Work chat | Zopu" },
{
content: "Chat with Zopu in the context of a selected work unit",
name: "description",
},
];
export default function MobileWorkChatPage() {
return <MobileFlowPage screen="work-chat" />;
}

159
bun.lock
View File

@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "code",
@@ -34,7 +35,7 @@
"@rivet-dev/agentos": "catalog:",
"convex": "catalog:",
"effect": "catalog:",
"rivetkit": "0.0.0-stack-feat-rivetkit-forward-inspector-tabs-to-native-plugin-actors-qkwmksyy.d2859b0",
"rivetkit": "2.3.7",
},
"devDependencies": {
"@code/config": "workspace:*",
@@ -1287,19 +1288,19 @@
"@rivetkit/bare-ts": ["@rivetkit/bare-ts@0.6.2", "", {}, "sha512-3qndQUQXLdwafMEqfhz24hUtDPcsf1Bu3q52Kb8MqeH8JUh3h6R4HYW3ZJXiQsLcyYyFM68PuIwlLRlg1xDEpg=="],
"@rivetkit/engine-cli": ["@rivetkit/engine-cli@0.0.0-stack-feat-rivetkit-forward-inspector-tabs-to-native-plugin-actors-qkwmksyy.d2859b0", "", { "optionalDependencies": { "@rivetkit/engine-cli-darwin-arm64": "0.0.0-stack-feat-rivetkit-forward-inspector-tabs-to-native-plugin-actors-qkwmksyy.d2859b0", "@rivetkit/engine-cli-darwin-x64": "0.0.0-stack-feat-rivetkit-forward-inspector-tabs-to-native-plugin-actors-qkwmksyy.d2859b0", "@rivetkit/engine-cli-linux-arm64-musl": "0.0.0-stack-feat-rivetkit-forward-inspector-tabs-to-native-plugin-actors-qkwmksyy.d2859b0", "@rivetkit/engine-cli-linux-x64-musl": "0.0.0-stack-feat-rivetkit-forward-inspector-tabs-to-native-plugin-actors-qkwmksyy.d2859b0" } }, "sha512-7fq+rJt5+bujGV+Q0esDY3OJrzRUhEpYd2EV0oVUI+pcIRyYJMx2y7KrMUucqTFOr77O/NDlmSGTfD4cStRfrg=="],
"@rivetkit/engine-cli": ["@rivetkit/engine-cli@2.3.7", "", { "optionalDependencies": { "@rivetkit/engine-cli-darwin-arm64": "2.3.7", "@rivetkit/engine-cli-darwin-x64": "2.3.7", "@rivetkit/engine-cli-linux-arm64-musl": "2.3.7", "@rivetkit/engine-cli-linux-x64-musl": "2.3.7", "@rivetkit/engine-cli-win32-x64": "2.3.7" } }, "sha512-CezLwJ0B7dWDbA7qM6Aq04mwnrJAdrDActRrrcb4NBa20h7wO9KPAYBwyJz/dRnKm9EUUNcFZ6hrSDpp6T3+Rg=="],
"@rivetkit/engine-cli-darwin-arm64": ["@rivetkit/engine-cli-darwin-arm64@0.0.0-stack-feat-rivetkit-forward-inspector-tabs-to-native-plugin-actors-qkwmksyy.d2859b0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kPzGTfdIIR5qAZpZDDRXC3OtGd8Jh/dZb/Pbmd03k/2DT12ILUHmI2DZg829icylExSSmhfRVjUSYsjRw8rmug=="],
"@rivetkit/engine-cli-darwin-arm64": ["@rivetkit/engine-cli-darwin-arm64@2.3.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-F3yGSDIaW94BZzuAm3erQRT/Ki55wHJ/b6tOaE7d9tRmTAZw/XqHmjNdUYjOvFUQKLb+SSsOittM4dINrAANQQ=="],
"@rivetkit/engine-cli-darwin-x64": ["@rivetkit/engine-cli-darwin-x64@0.0.0-stack-feat-rivetkit-forward-inspector-tabs-to-native-plugin-actors-qkwmksyy.d2859b0", "", { "os": "darwin", "cpu": "x64" }, "sha512-0rJcsIttFzycpR4J/ciECjPk+KzlVFeJwTPobhG9anWHycq8GpCuQx9ipgBA++01tZYo/x7kEfSU+dNdrxqsEA=="],
"@rivetkit/engine-cli-darwin-x64": ["@rivetkit/engine-cli-darwin-x64@2.3.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-P8BbQsamIvv8U/cEoJUFr+uAzwjjl9cG+ZZxjDX9OJxbuD11ESpHvDLgge54ijynO3IjoP8cIzjIXat6mr2Gkw=="],
"@rivetkit/engine-cli-linux-arm64-musl": ["@rivetkit/engine-cli-linux-arm64-musl@0.0.0-stack-feat-rivetkit-forward-inspector-tabs-to-native-plugin-actors-qkwmksyy.d2859b0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jXh2SgMHm/JPwVGOlFSO1NGMy7/2t4lNwU/iSwX4CN1px9Nrr4Ud8n0FY58VHcU4Ppv1tWAdWEWsOlF5EZlr8Q=="],
"@rivetkit/engine-cli-linux-arm64-musl": ["@rivetkit/engine-cli-linux-arm64-musl@2.3.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-G3s76BHfIs6R9rm6JYI13sOjPLy08+C8WvVoFwSdMqsibEaqlfo36wVkp/x1g0sEZUek/dBxVrKqMMR7c4/gug=="],
"@rivetkit/engine-cli-linux-x64-musl": ["@rivetkit/engine-cli-linux-x64-musl@0.0.0-stack-feat-rivetkit-forward-inspector-tabs-to-native-plugin-actors-qkwmksyy.d2859b0", "", { "os": "linux", "cpu": "x64" }, "sha512-pDBkdi5la//sp7Uq1KXDR4eXbwO0l/HmQCnosbgWHWz8OBL62RcueeYWQt181Gl7VIsINm1QB7mihUcbcECVxw=="],
"@rivetkit/engine-cli-linux-x64-musl": ["@rivetkit/engine-cli-linux-x64-musl@2.3.7", "", { "os": "linux", "cpu": "x64" }, "sha512-LWhrUVzULcsZ4ufCLsx23YfrdMW2INd9g0ANBac9+PzGwL8qYfVIXnDpR3587lapu68EbSEPZcconf1tBlvJxQ=="],
"@rivetkit/engine-cli-win32-x64": ["@rivetkit/engine-cli-win32-x64@2.3.7", "", { "os": "win32", "cpu": "x64" }, "sha512-jyA5PlHivHXyyq2ehnuXwxd5TUSbB4emomnph9EovsJo7qIdmSNaaCO13NYBT483fd0uXiNI0MK/lkJa9ePY5A=="],
"@rivetkit/engine-envoy-protocol": ["@rivetkit/engine-envoy-protocol@0.0.0-stack-feat-rivetkit-forward-inspector-tabs-to-native-plugin-actors-qkwmksyy.d2859b0", "", { "dependencies": { "@rivetkit/bare-ts": "^0.6.2" } }, "sha512-2u0MPsYLMSuwaX0amz52kZX4wsh3PmbczjPb4AoSk1hCbycf1IN3Vkj3RR87ZwMTlLYKkcpgq8fHPlxoPSkftQ=="],
"@rivetkit/engine-envoy-protocol": ["@rivetkit/engine-envoy-protocol@2.3.7", "", { "dependencies": { "@rivetkit/bare-ts": "^0.6.2" } }, "sha512-lsJM3ERwozQCebaOMKJzqIQlcLbbN3LXurRB0+7LsM6FUUL2l6hMoq5I2De/G35DJ6OWDva9AmzfvzI+5f1GPg=="],
"@rivetkit/framework-base": ["@rivetkit/framework-base@2.3.7", "", { "dependencies": { "@tanstack/store": "^0.7.1", "fast-deep-equal": "^3.1.3", "rivetkit": "2.3.7" } }, "sha512-zxmdAzFogA3PeT3ZgFIoYlEAJVi3iCwbPBXczZKf2x2QMCl2cTZ2jswFCAgHUUMQWj+AWiX65usxq73a9Ls/qQ=="],
@@ -1307,29 +1308,29 @@
"@rivetkit/react": ["@rivetkit/react@2.3.7", "", { "dependencies": { "@rivetkit/framework-base": "2.3.7", "@tanstack/react-store": "^0.7.1", "rivetkit": "2.3.7" }, "peerDependencies": { "react": "^18 || ^19", "react-dom": "^18 || ^19" } }, "sha512-Y3UoUYhkLKDQrJdloCmYK+fhJMlPw5BPHbRb5VutFh5L8csIRZLoCoMitzV7mjNUxuDE1+k10iGuUHYPZUP4aA=="],
"@rivetkit/rivetkit-napi": ["@rivetkit/rivetkit-napi@0.0.0-stack-feat-rivetkit-forward-inspector-tabs-to-native-plugin-actors-qkwmksyy.d2859b0", "", { "dependencies": { "@napi-rs/cli": "^2.18.4", "@rivetkit/engine-envoy-protocol": "0.0.0-stack-feat-rivetkit-forward-inspector-tabs-to-native-plugin-actors-qkwmksyy.d2859b0" }, "optionalDependencies": { "@rivetkit/rivetkit-napi-darwin-arm64": "0.0.0-stack-feat-rivetkit-forward-inspector-tabs-to-native-plugin-actors-qkwmksyy.d2859b0", "@rivetkit/rivetkit-napi-darwin-x64": "0.0.0-stack-feat-rivetkit-forward-inspector-tabs-to-native-plugin-actors-qkwmksyy.d2859b0", "@rivetkit/rivetkit-napi-linux-arm64-gnu": "0.0.0-stack-feat-rivetkit-forward-inspector-tabs-to-native-plugin-actors-qkwmksyy.d2859b0", "@rivetkit/rivetkit-napi-linux-arm64-musl": "0.0.0-stack-feat-rivetkit-forward-inspector-tabs-to-native-plugin-actors-qkwmksyy.d2859b0", "@rivetkit/rivetkit-napi-linux-x64-gnu": "0.0.0-stack-feat-rivetkit-forward-inspector-tabs-to-native-plugin-actors-qkwmksyy.d2859b0", "@rivetkit/rivetkit-napi-linux-x64-musl": "0.0.0-stack-feat-rivetkit-forward-inspector-tabs-to-native-plugin-actors-qkwmksyy.d2859b0" } }, "sha512-uSBQFnTSuh+YMjdRR34KAS7QJQpkVXNleNdzLoCic1XdRyIh13KvuX3+JR3bbk3S32euM7ZeScy3kve8c3nuyA=="],
"@rivetkit/rivetkit-napi": ["@rivetkit/rivetkit-napi@2.3.7", "", { "dependencies": { "@napi-rs/cli": "^2.18.4", "@rivetkit/engine-envoy-protocol": "2.3.7" }, "optionalDependencies": { "@rivetkit/rivetkit-napi-darwin-arm64": "2.3.7", "@rivetkit/rivetkit-napi-darwin-x64": "2.3.7", "@rivetkit/rivetkit-napi-linux-arm64-gnu": "2.3.7", "@rivetkit/rivetkit-napi-linux-arm64-musl": "2.3.7", "@rivetkit/rivetkit-napi-linux-x64-gnu": "2.3.7", "@rivetkit/rivetkit-napi-linux-x64-musl": "2.3.7", "@rivetkit/rivetkit-napi-win32-x64-msvc": "2.3.7" } }, "sha512-1d3HtNKzJwdkznWDWSpCwg1rBn+nVwUfcKrApINTfiEhfDyVeznwbVFkfHWqOBHsjU17vvWFfjjTtf8QsjLwUQ=="],
"@rivetkit/rivetkit-napi-darwin-arm64": ["@rivetkit/rivetkit-napi-darwin-arm64@0.0.0-stack-feat-rivetkit-forward-inspector-tabs-to-native-plugin-actors-qkwmksyy.d2859b0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TQPCk1U2Z7otH9ehV7NEEksRT/hzEsLyMaA7ZpkMKxOr8bBNWkES7U+YY5fc7mLFdWR/N15iN7SU0koxJE7PjA=="],
"@rivetkit/rivetkit-napi-darwin-arm64": ["@rivetkit/rivetkit-napi-darwin-arm64@2.3.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gI1wMe6z85VDeR4K/wnzevVakXTg5UCfvjlDOp1+23nyU3NlInhN156dEWO8EsfRMrs+1bYoGdc4rcoN+4PUrw=="],
"@rivetkit/rivetkit-napi-darwin-x64": ["@rivetkit/rivetkit-napi-darwin-x64@0.0.0-stack-feat-rivetkit-forward-inspector-tabs-to-native-plugin-actors-qkwmksyy.d2859b0", "", { "os": "darwin", "cpu": "x64" }, "sha512-wAuJIR0efIcpaQQvWgniZ0qOy6x5+/V+/agACJhQVSjj7P29kY0piEBKLKDtP0M4gTd6GGzBAe5oNY0t785/0w=="],
"@rivetkit/rivetkit-napi-darwin-x64": ["@rivetkit/rivetkit-napi-darwin-x64@2.3.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-H5tUwvCbKwTIBiyQG01ikK5mdc9ISg+bv8gPUw41mYOKq8rb9nBLkcl2TKcreFHac8KaGxtnBZzYcjbUV3NcwQ=="],
"@rivetkit/rivetkit-napi-linux-arm64-gnu": ["@rivetkit/rivetkit-napi-linux-arm64-gnu@0.0.0-stack-feat-rivetkit-forward-inspector-tabs-to-native-plugin-actors-qkwmksyy.d2859b0", "", { "os": "linux", "cpu": "arm64" }, "sha512-A+kuGvc9RJjUflPVHbHPkQSH4xyjasbVMNRfq2ISdaJ/CnNcmrexJ4F+5Mm2VPz61gxNjkSoyk8tPupz07CZnw=="],
"@rivetkit/rivetkit-napi-linux-arm64-gnu": ["@rivetkit/rivetkit-napi-linux-arm64-gnu@2.3.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-MpOeiO5M737e93hM/FjcH+zv8xBEX1tgBd/59K/Uw7qlRbWW7JGz8WGjavdxv211LRqxsn8EUlGRxcADDsXnBA=="],
"@rivetkit/rivetkit-napi-linux-arm64-musl": ["@rivetkit/rivetkit-napi-linux-arm64-musl@0.0.0-stack-feat-rivetkit-forward-inspector-tabs-to-native-plugin-actors-qkwmksyy.d2859b0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jo7WcmJ7hjWpi/8DUlWu1x2JtHfEAiY21QdNy5VZ23Dtg/pzKzDAn4m6myPB/s8MS+nAOiJcruWTH16qMHAeWw=="],
"@rivetkit/rivetkit-napi-linux-arm64-musl": ["@rivetkit/rivetkit-napi-linux-arm64-musl@2.3.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-0GsUgd/HHvIq6v92kp0H175mGm2Exvs4krBLih0MIpVAkqJk8EIcKI6wJhCwY04Fr0MS8sHkUhjuHXa7QcmlYA=="],
"@rivetkit/rivetkit-napi-linux-x64-gnu": ["@rivetkit/rivetkit-napi-linux-x64-gnu@0.0.0-stack-feat-rivetkit-forward-inspector-tabs-to-native-plugin-actors-qkwmksyy.d2859b0", "", { "os": "linux", "cpu": "x64" }, "sha512-hcHO0FkMQITQ3taSJ0084DVDRysOep3eBzv8cwgA0qJhIzzd5EF+4qo4QO4woEdnDYCGMBJZLByYyEPFY3WSOQ=="],
"@rivetkit/rivetkit-napi-linux-x64-gnu": ["@rivetkit/rivetkit-napi-linux-x64-gnu@2.3.7", "", { "os": "linux", "cpu": "x64" }, "sha512-8wCD1FuDpM+yiKRG8ro1K8ILVpJXzCNKViJ1axq1b4Nyqgod/nyEvvKDaT4qhA5+Om+W5i6YRO1uddjC6QjJSQ=="],
"@rivetkit/rivetkit-napi-linux-x64-musl": ["@rivetkit/rivetkit-napi-linux-x64-musl@0.0.0-stack-feat-rivetkit-forward-inspector-tabs-to-native-plugin-actors-qkwmksyy.d2859b0", "", { "os": "linux", "cpu": "x64" }, "sha512-Xx8DWZwdWBZdkqSbR7UdPR1nrZH53tK4r0kSM9Y670GGwgBHg3mesaYshPs/B0XzEFVybF5vS/0ToIi97T/LIw=="],
"@rivetkit/rivetkit-napi-linux-x64-musl": ["@rivetkit/rivetkit-napi-linux-x64-musl@2.3.7", "", { "os": "linux", "cpu": "x64" }, "sha512-8RGzXD33GH1/DWufFzCZRI+npF5IL/9z7Ja3pthsuYgrL+axGG0iqfAjbwj5HHnB/ceUGQVT+uCKzoLnG+odtw=="],
"@rivetkit/rivetkit-napi-win32-x64-msvc": ["@rivetkit/rivetkit-napi-win32-x64-msvc@2.3.7", "", { "os": "win32", "cpu": "x64" }, "sha512-n1by/coKxWOb2FZDGLM2qmULxcLSqx8hxJuk7Hsb1gl4uNHGviUzYr+r7WAiOzonK/Dgy477u3OKf5yBie743A=="],
"@rivetkit/rivetkit-wasm": ["@rivetkit/rivetkit-wasm@0.0.0-stack-feat-rivetkit-forward-inspector-tabs-to-native-plugin-actors-qkwmksyy.d2859b0", "", {}, "sha512-8bTqxP4WihKbKifZHZT1umnINvlv1NU+GfciQ+pciiIxnaPVT7dX++IczTB2g3njEibYL44wrEzicNNXNsf6lg=="],
"@rivetkit/rivetkit-wasm": ["@rivetkit/rivetkit-wasm@2.3.7", "", {}, "sha512-o7QBtFOJrajyPVBIDQ32k8sDNFsUc8v2ETfNpr+Ch374ydmZMzCv3XKJU9vubc9bZa7WpCHSzf5ztc8b8i/Xpg=="],
"@rivetkit/traces": ["@rivetkit/traces@0.0.0-stack-feat-rivetkit-forward-inspector-tabs-to-native-plugin-actors-qkwmksyy.d2859b0", "", { "dependencies": { "@rivetkit/bare-ts": "^0.6.2", "cbor-x": "^1.6.0", "fdb-tuple": "^1.0.0", "vbare": "^0.0.4" } }, "sha512-4yXOCC2rPncF/aNBhW4KU6CF56Bo9Sslidj311eY0iGqCxRBpQhI57Y5eX8Ho5DNXmVTobjP6USxeWp6Oymmvg=="],
"@rivetkit/traces": ["@rivetkit/traces@2.3.7", "", { "dependencies": { "@rivetkit/bare-ts": "^0.6.2", "cbor-x": "^1.6.0", "fdb-tuple": "^1.0.0", "vbare": "^0.0.4" } }, "sha512-B7QYjFP2HPxyfPsVjfcNIV7FPLWgGWGypmlT/dZM1i1Cf1WRdeuBumPLffgs3YceiY7Pm4D/gZZVt5p0m4Cx+A=="],
"@rivetkit/virtual-websocket": ["@rivetkit/virtual-websocket@0.0.0-stack-feat-rivetkit-forward-inspector-tabs-to-native-plugin-actors-qkwmksyy.d2859b0", "", {}, "sha512-Rd3MdziVAS4Ut/dqcLfN9pMCRLOSqK7n/AunWtI6Xr72y+0jh7gwFVYmZRJGyGamnfJFTKTQnbnhg+KGkyu+wg=="],
"@rivetkit/virtual-websocket": ["@rivetkit/virtual-websocket@2.3.7", "", {}, "sha512-jwxDvYbr3YB6vFuxwRdB/AqUUMwdtC1rIAc4tNN6VQR3weHbE+PrDoHKbe85zrtlBtcnG89/p8tlovrj8qyQWA=="],
"@rivetkit/workflow-engine": ["@rivetkit/workflow-engine@0.0.0-stack-feat-rivetkit-forward-inspector-tabs-to-native-plugin-actors-qkwmksyy.d2859b0", "", { "dependencies": { "@rivetkit/bare-ts": "^0.6.2", "cbor-x": "^1.6.0", "fdb-tuple": "^1.0.0", "pino": "^9.6.0", "vbare": "^0.0.4" } }, "sha512-Nh+tlQT9FoBsznHfRujeT/hlgj/4k4d3M7+0ogZpCmPhuwim0cjiog6fL18bfehr7BPobvSH0SInqLvF3kPPWA=="],
"@rivetkit/workflow-engine": ["@rivetkit/workflow-engine@2.3.7", "", { "dependencies": { "@rivetkit/bare-ts": "^0.6.2", "cbor-x": "^1.6.0", "fdb-tuple": "^1.0.0", "pino": "^9.6.0", "vbare": "^0.0.4" } }, "sha512-+C4kGuSNysrw2zs/LQoYxouOxufKHHMxiIXKF8Ab9GU2BCJ32RUST9dYU/gaQE/w9uM+hqAHUz0DTlOBYU6p6g=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.4", "", { "os": "android", "cpu": "arm64" }, "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw=="],
@@ -3299,7 +3300,7 @@
"pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"punycode": ["punycode@1.4.1", "", {}, "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ=="],
"pure-rand": ["pure-rand@8.4.2", "", {}, "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng=="],
@@ -3467,7 +3468,7 @@
"ripemd160": ["ripemd160@2.0.3", "", { "dependencies": { "hash-base": "^3.1.2", "inherits": "^2.0.4" } }, "sha512-5Di9UC0+8h1L6ZD2d7awM7E/T4uA1fJRlx6zk/NvdCCVEoAnFqvHmCuNeIKoCeIixBX/q8uM+6ycDvF8woqosA=="],
"rivetkit": ["rivetkit@0.0.0-stack-feat-rivetkit-forward-inspector-tabs-to-native-plugin-actors-qkwmksyy.d2859b0", "", { "dependencies": { "@hono/zod-openapi": "^1.1.5", "@rivetkit/bare-ts": "^0.6.2", "@rivetkit/engine-cli": "0.0.0-stack-feat-rivetkit-forward-inspector-tabs-to-native-plugin-actors-qkwmksyy.d2859b0", "@rivetkit/engine-envoy-protocol": "0.0.0-stack-feat-rivetkit-forward-inspector-tabs-to-native-plugin-actors-qkwmksyy.d2859b0", "@rivetkit/on-change": "6.0.1", "@rivetkit/rivetkit-napi": "0.0.0-stack-feat-rivetkit-forward-inspector-tabs-to-native-plugin-actors-qkwmksyy.d2859b0", "@rivetkit/rivetkit-wasm": "0.0.0-stack-feat-rivetkit-forward-inspector-tabs-to-native-plugin-actors-qkwmksyy.d2859b0", "@rivetkit/traces": "0.0.0-stack-feat-rivetkit-forward-inspector-tabs-to-native-plugin-actors-qkwmksyy.d2859b0", "@rivetkit/virtual-websocket": "0.0.0-stack-feat-rivetkit-forward-inspector-tabs-to-native-plugin-actors-qkwmksyy.d2859b0", "@rivetkit/workflow-engine": "0.0.0-stack-feat-rivetkit-forward-inspector-tabs-to-native-plugin-actors-qkwmksyy.d2859b0", "cbor-x": "^1.6.0", "drizzle-orm": "^0.44.2", "hono": "^4.7.0", "invariant": "^2.2.4", "p-retry": "^6.2.1", "pino": "^9.5.0", "uuid": "^12.0.0", "vbare": "^0.0.4", "zod": "^4.1.0" }, "peerDependencies": { "drizzle-kit": "^0.31.2", "eventsource": "^4.0.0", "ws": "^8.0.0" }, "optionalPeers": ["drizzle-kit", "eventsource", "ws"] }, "sha512-3kXHTXMv6u/P29xul+8YnqCmkhyj3TYJxl1f40yyjWqbrxhAPR7qoW+mDY3NTIMX6xpXuiqnR9gp9pSPiM4s0A=="],
"rivetkit": ["rivetkit@2.3.7", "", { "dependencies": { "@hono/zod-openapi": "^1.1.5", "@rivet-dev/agent-os-core": "^0.1.1", "@rivetkit/bare-ts": "^0.6.2", "@rivetkit/engine-cli": "2.3.7", "@rivetkit/engine-envoy-protocol": "2.3.7", "@rivetkit/on-change": "6.0.1", "@rivetkit/rivetkit-napi": "2.3.7", "@rivetkit/rivetkit-wasm": "2.3.7", "@rivetkit/traces": "2.3.7", "@rivetkit/virtual-websocket": "2.3.7", "@rivetkit/workflow-engine": "2.3.7", "cbor-x": "^1.6.0", "drizzle-orm": "^0.44.2", "hono": "^4.7.0", "invariant": "^2.2.4", "p-retry": "^6.2.1", "pino": "^9.5.0", "uuid": "^12.0.0", "vbare": "^0.0.4", "zod": "^4.1.0" }, "peerDependencies": { "drizzle-kit": "^0.31.2", "eventsource": "^4.0.0", "ws": "^8.0.0" }, "optionalPeers": ["drizzle-kit", "eventsource", "ws"] }, "sha512-V2IWlXq9SRLJWrcYVa8WUftELCpbdsBVWZIqz2WQjhhzRK9Aan65RfMoZVx62A9k8HuIO6MH1nAPLhOuk1xePQ=="],
"robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="],
@@ -4073,16 +4074,10 @@
"@react-router/dev/react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
"@rivet-dev/agentos/rivetkit": ["rivetkit@2.3.7", "", { "dependencies": { "@hono/zod-openapi": "^1.1.5", "@rivet-dev/agent-os-core": "^0.1.1", "@rivetkit/bare-ts": "^0.6.2", "@rivetkit/engine-cli": "2.3.7", "@rivetkit/engine-envoy-protocol": "2.3.7", "@rivetkit/on-change": "6.0.1", "@rivetkit/rivetkit-napi": "2.3.7", "@rivetkit/rivetkit-wasm": "2.3.7", "@rivetkit/traces": "2.3.7", "@rivetkit/virtual-websocket": "2.3.7", "@rivetkit/workflow-engine": "2.3.7", "cbor-x": "^1.6.0", "drizzle-orm": "^0.44.2", "hono": "^4.7.0", "invariant": "^2.2.4", "p-retry": "^6.2.1", "pino": "^9.5.0", "uuid": "^12.0.0", "vbare": "^0.0.4", "zod": "^4.1.0" }, "peerDependencies": { "drizzle-kit": "^0.31.2", "eventsource": "^4.0.0", "ws": "^8.0.0" }, "optionalPeers": ["drizzle-kit", "eventsource", "ws"] }, "sha512-V2IWlXq9SRLJWrcYVa8WUftELCpbdsBVWZIqz2WQjhhzRK9Aan65RfMoZVx62A9k8HuIO6MH1nAPLhOuk1xePQ=="],
"@rivetkit/framework-base/@tanstack/store": ["@tanstack/store@0.7.7", "", {}, "sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ=="],
"@rivetkit/framework-base/rivetkit": ["rivetkit@2.3.7", "", { "dependencies": { "@hono/zod-openapi": "^1.1.5", "@rivet-dev/agent-os-core": "^0.1.1", "@rivetkit/bare-ts": "^0.6.2", "@rivetkit/engine-cli": "2.3.7", "@rivetkit/engine-envoy-protocol": "2.3.7", "@rivetkit/on-change": "6.0.1", "@rivetkit/rivetkit-napi": "2.3.7", "@rivetkit/rivetkit-wasm": "2.3.7", "@rivetkit/traces": "2.3.7", "@rivetkit/virtual-websocket": "2.3.7", "@rivetkit/workflow-engine": "2.3.7", "cbor-x": "^1.6.0", "drizzle-orm": "^0.44.2", "hono": "^4.7.0", "invariant": "^2.2.4", "p-retry": "^6.2.1", "pino": "^9.5.0", "uuid": "^12.0.0", "vbare": "^0.0.4", "zod": "^4.1.0" }, "peerDependencies": { "drizzle-kit": "^0.31.2", "eventsource": "^4.0.0", "ws": "^8.0.0" }, "optionalPeers": ["drizzle-kit", "eventsource", "ws"] }, "sha512-V2IWlXq9SRLJWrcYVa8WUftELCpbdsBVWZIqz2WQjhhzRK9Aan65RfMoZVx62A9k8HuIO6MH1nAPLhOuk1xePQ=="],
"@rivetkit/react/@tanstack/react-store": ["@tanstack/react-store@0.7.7", "", { "dependencies": { "@tanstack/store": "0.7.7", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-qqT0ufegFRDGSof9D/VqaZgjNgp4tRPHZIJq2+QIHkMUtHjaJ0lYrrXjeIUJvjnTbgPfSD1XgOMEt0lmANn6Zg=="],
"@rivetkit/react/rivetkit": ["rivetkit@2.3.7", "", { "dependencies": { "@hono/zod-openapi": "^1.1.5", "@rivet-dev/agent-os-core": "^0.1.1", "@rivetkit/bare-ts": "^0.6.2", "@rivetkit/engine-cli": "2.3.7", "@rivetkit/engine-envoy-protocol": "2.3.7", "@rivetkit/on-change": "6.0.1", "@rivetkit/rivetkit-napi": "2.3.7", "@rivetkit/rivetkit-wasm": "2.3.7", "@rivetkit/traces": "2.3.7", "@rivetkit/virtual-websocket": "2.3.7", "@rivetkit/workflow-engine": "2.3.7", "cbor-x": "^1.6.0", "drizzle-orm": "^0.44.2", "hono": "^4.7.0", "invariant": "^2.2.4", "p-retry": "^6.2.1", "pino": "^9.5.0", "uuid": "^12.0.0", "vbare": "^0.0.4", "zod": "^4.1.0" }, "peerDependencies": { "drizzle-kit": "^0.31.2", "eventsource": "^4.0.0", "ws": "^8.0.0" }, "optionalPeers": ["drizzle-kit", "eventsource", "ws"] }, "sha512-V2IWlXq9SRLJWrcYVa8WUftELCpbdsBVWZIqz2WQjhhzRK9Aan65RfMoZVx62A9k8HuIO6MH1nAPLhOuk1xePQ=="],
"@secure-exec/nodejs/es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
"@secure-exec/nodejs/esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="],
@@ -4297,8 +4292,6 @@
"native/react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="],
"node-stdlib-browser/punycode": ["punycode@1.4.1", "", {}, "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ=="],
"npm-package-arg/hosted-git-info": ["hosted-git-info@7.0.2", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w=="],
"npm-package-arg/validate-npm-package-name": ["validate-npm-package-name@5.0.1", "", {}, "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ=="],
@@ -4403,7 +4396,7 @@
"uniwind/lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="],
"url/punycode": ["punycode@1.4.1", "", {}, "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ=="],
"uri-js/punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"vite-plus/oxfmt": ["oxfmt@0.57.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.57.0", "@oxfmt/binding-android-arm64": "0.57.0", "@oxfmt/binding-darwin-arm64": "0.57.0", "@oxfmt/binding-darwin-x64": "0.57.0", "@oxfmt/binding-freebsd-x64": "0.57.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.57.0", "@oxfmt/binding-linux-arm-musleabihf": "0.57.0", "@oxfmt/binding-linux-arm64-gnu": "0.57.0", "@oxfmt/binding-linux-arm64-musl": "0.57.0", "@oxfmt/binding-linux-ppc64-gnu": "0.57.0", "@oxfmt/binding-linux-riscv64-gnu": "0.57.0", "@oxfmt/binding-linux-riscv64-musl": "0.57.0", "@oxfmt/binding-linux-s390x-gnu": "0.57.0", "@oxfmt/binding-linux-x64-gnu": "0.57.0", "@oxfmt/binding-linux-x64-musl": "0.57.0", "@oxfmt/binding-openharmony-arm64": "0.57.0", "@oxfmt/binding-win32-arm64-msvc": "0.57.0", "@oxfmt/binding-win32-ia32-msvc": "0.57.0", "@oxfmt/binding-win32-x64-msvc": "0.57.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-ZB7Bi+rGDSqmVIo9jwcLyFgjxXvQhDdU+jx+ZrVy6VRiVXK2+CHc4hO3J4dUQjHe7V0ymHB+MDuv5z+NhK07HA=="],
@@ -4537,56 +4530,8 @@
"@mariozechner/pi-coding-agent/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"@rivet-dev/agentos/rivetkit/@rivetkit/engine-cli": ["@rivetkit/engine-cli@2.3.7", "", { "optionalDependencies": { "@rivetkit/engine-cli-darwin-arm64": "2.3.7", "@rivetkit/engine-cli-darwin-x64": "2.3.7", "@rivetkit/engine-cli-linux-arm64-musl": "2.3.7", "@rivetkit/engine-cli-linux-x64-musl": "2.3.7", "@rivetkit/engine-cli-win32-x64": "2.3.7" } }, "sha512-CezLwJ0B7dWDbA7qM6Aq04mwnrJAdrDActRrrcb4NBa20h7wO9KPAYBwyJz/dRnKm9EUUNcFZ6hrSDpp6T3+Rg=="],
"@rivet-dev/agentos/rivetkit/@rivetkit/engine-envoy-protocol": ["@rivetkit/engine-envoy-protocol@2.3.7", "", { "dependencies": { "@rivetkit/bare-ts": "^0.6.2" } }, "sha512-lsJM3ERwozQCebaOMKJzqIQlcLbbN3LXurRB0+7LsM6FUUL2l6hMoq5I2De/G35DJ6OWDva9AmzfvzI+5f1GPg=="],
"@rivet-dev/agentos/rivetkit/@rivetkit/rivetkit-napi": ["@rivetkit/rivetkit-napi@2.3.7", "", { "dependencies": { "@napi-rs/cli": "^2.18.4", "@rivetkit/engine-envoy-protocol": "2.3.7" }, "optionalDependencies": { "@rivetkit/rivetkit-napi-darwin-arm64": "2.3.7", "@rivetkit/rivetkit-napi-darwin-x64": "2.3.7", "@rivetkit/rivetkit-napi-linux-arm64-gnu": "2.3.7", "@rivetkit/rivetkit-napi-linux-arm64-musl": "2.3.7", "@rivetkit/rivetkit-napi-linux-x64-gnu": "2.3.7", "@rivetkit/rivetkit-napi-linux-x64-musl": "2.3.7", "@rivetkit/rivetkit-napi-win32-x64-msvc": "2.3.7" } }, "sha512-1d3HtNKzJwdkznWDWSpCwg1rBn+nVwUfcKrApINTfiEhfDyVeznwbVFkfHWqOBHsjU17vvWFfjjTtf8QsjLwUQ=="],
"@rivet-dev/agentos/rivetkit/@rivetkit/rivetkit-wasm": ["@rivetkit/rivetkit-wasm@2.3.7", "", {}, "sha512-o7QBtFOJrajyPVBIDQ32k8sDNFsUc8v2ETfNpr+Ch374ydmZMzCv3XKJU9vubc9bZa7WpCHSzf5ztc8b8i/Xpg=="],
"@rivet-dev/agentos/rivetkit/@rivetkit/traces": ["@rivetkit/traces@2.3.7", "", { "dependencies": { "@rivetkit/bare-ts": "^0.6.2", "cbor-x": "^1.6.0", "fdb-tuple": "^1.0.0", "vbare": "^0.0.4" } }, "sha512-B7QYjFP2HPxyfPsVjfcNIV7FPLWgGWGypmlT/dZM1i1Cf1WRdeuBumPLffgs3YceiY7Pm4D/gZZVt5p0m4Cx+A=="],
"@rivet-dev/agentos/rivetkit/@rivetkit/virtual-websocket": ["@rivetkit/virtual-websocket@2.3.7", "", {}, "sha512-jwxDvYbr3YB6vFuxwRdB/AqUUMwdtC1rIAc4tNN6VQR3weHbE+PrDoHKbe85zrtlBtcnG89/p8tlovrj8qyQWA=="],
"@rivet-dev/agentos/rivetkit/@rivetkit/workflow-engine": ["@rivetkit/workflow-engine@2.3.7", "", { "dependencies": { "@rivetkit/bare-ts": "^0.6.2", "cbor-x": "^1.6.0", "fdb-tuple": "^1.0.0", "pino": "^9.6.0", "vbare": "^0.0.4" } }, "sha512-+C4kGuSNysrw2zs/LQoYxouOxufKHHMxiIXKF8Ab9GU2BCJ32RUST9dYU/gaQE/w9uM+hqAHUz0DTlOBYU6p6g=="],
"@rivet-dev/agentos/rivetkit/uuid": ["uuid@12.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-9obBF8sMIHJWNQaO6IGOG8giGa/jUpKX34bz6o4whVs8M0WAvhID2tNxYp6A2XEBJPuZSX8wsS/6TEKfIDc+nw=="],
"@rivetkit/framework-base/rivetkit/@rivetkit/engine-cli": ["@rivetkit/engine-cli@2.3.7", "", { "optionalDependencies": { "@rivetkit/engine-cli-darwin-arm64": "2.3.7", "@rivetkit/engine-cli-darwin-x64": "2.3.7", "@rivetkit/engine-cli-linux-arm64-musl": "2.3.7", "@rivetkit/engine-cli-linux-x64-musl": "2.3.7", "@rivetkit/engine-cli-win32-x64": "2.3.7" } }, "sha512-CezLwJ0B7dWDbA7qM6Aq04mwnrJAdrDActRrrcb4NBa20h7wO9KPAYBwyJz/dRnKm9EUUNcFZ6hrSDpp6T3+Rg=="],
"@rivetkit/framework-base/rivetkit/@rivetkit/engine-envoy-protocol": ["@rivetkit/engine-envoy-protocol@2.3.7", "", { "dependencies": { "@rivetkit/bare-ts": "^0.6.2" } }, "sha512-lsJM3ERwozQCebaOMKJzqIQlcLbbN3LXurRB0+7LsM6FUUL2l6hMoq5I2De/G35DJ6OWDva9AmzfvzI+5f1GPg=="],
"@rivetkit/framework-base/rivetkit/@rivetkit/rivetkit-napi": ["@rivetkit/rivetkit-napi@2.3.7", "", { "dependencies": { "@napi-rs/cli": "^2.18.4", "@rivetkit/engine-envoy-protocol": "2.3.7" }, "optionalDependencies": { "@rivetkit/rivetkit-napi-darwin-arm64": "2.3.7", "@rivetkit/rivetkit-napi-darwin-x64": "2.3.7", "@rivetkit/rivetkit-napi-linux-arm64-gnu": "2.3.7", "@rivetkit/rivetkit-napi-linux-arm64-musl": "2.3.7", "@rivetkit/rivetkit-napi-linux-x64-gnu": "2.3.7", "@rivetkit/rivetkit-napi-linux-x64-musl": "2.3.7", "@rivetkit/rivetkit-napi-win32-x64-msvc": "2.3.7" } }, "sha512-1d3HtNKzJwdkznWDWSpCwg1rBn+nVwUfcKrApINTfiEhfDyVeznwbVFkfHWqOBHsjU17vvWFfjjTtf8QsjLwUQ=="],
"@rivetkit/framework-base/rivetkit/@rivetkit/rivetkit-wasm": ["@rivetkit/rivetkit-wasm@2.3.7", "", {}, "sha512-o7QBtFOJrajyPVBIDQ32k8sDNFsUc8v2ETfNpr+Ch374ydmZMzCv3XKJU9vubc9bZa7WpCHSzf5ztc8b8i/Xpg=="],
"@rivetkit/framework-base/rivetkit/@rivetkit/traces": ["@rivetkit/traces@2.3.7", "", { "dependencies": { "@rivetkit/bare-ts": "^0.6.2", "cbor-x": "^1.6.0", "fdb-tuple": "^1.0.0", "vbare": "^0.0.4" } }, "sha512-B7QYjFP2HPxyfPsVjfcNIV7FPLWgGWGypmlT/dZM1i1Cf1WRdeuBumPLffgs3YceiY7Pm4D/gZZVt5p0m4Cx+A=="],
"@rivetkit/framework-base/rivetkit/@rivetkit/virtual-websocket": ["@rivetkit/virtual-websocket@2.3.7", "", {}, "sha512-jwxDvYbr3YB6vFuxwRdB/AqUUMwdtC1rIAc4tNN6VQR3weHbE+PrDoHKbe85zrtlBtcnG89/p8tlovrj8qyQWA=="],
"@rivetkit/framework-base/rivetkit/@rivetkit/workflow-engine": ["@rivetkit/workflow-engine@2.3.7", "", { "dependencies": { "@rivetkit/bare-ts": "^0.6.2", "cbor-x": "^1.6.0", "fdb-tuple": "^1.0.0", "pino": "^9.6.0", "vbare": "^0.0.4" } }, "sha512-+C4kGuSNysrw2zs/LQoYxouOxufKHHMxiIXKF8Ab9GU2BCJ32RUST9dYU/gaQE/w9uM+hqAHUz0DTlOBYU6p6g=="],
"@rivetkit/framework-base/rivetkit/uuid": ["uuid@12.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-9obBF8sMIHJWNQaO6IGOG8giGa/jUpKX34bz6o4whVs8M0WAvhID2tNxYp6A2XEBJPuZSX8wsS/6TEKfIDc+nw=="],
"@rivetkit/react/@tanstack/react-store/@tanstack/store": ["@tanstack/store@0.7.7", "", {}, "sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ=="],
"@rivetkit/react/rivetkit/@rivetkit/engine-cli": ["@rivetkit/engine-cli@2.3.7", "", { "optionalDependencies": { "@rivetkit/engine-cli-darwin-arm64": "2.3.7", "@rivetkit/engine-cli-darwin-x64": "2.3.7", "@rivetkit/engine-cli-linux-arm64-musl": "2.3.7", "@rivetkit/engine-cli-linux-x64-musl": "2.3.7", "@rivetkit/engine-cli-win32-x64": "2.3.7" } }, "sha512-CezLwJ0B7dWDbA7qM6Aq04mwnrJAdrDActRrrcb4NBa20h7wO9KPAYBwyJz/dRnKm9EUUNcFZ6hrSDpp6T3+Rg=="],
"@rivetkit/react/rivetkit/@rivetkit/engine-envoy-protocol": ["@rivetkit/engine-envoy-protocol@2.3.7", "", { "dependencies": { "@rivetkit/bare-ts": "^0.6.2" } }, "sha512-lsJM3ERwozQCebaOMKJzqIQlcLbbN3LXurRB0+7LsM6FUUL2l6hMoq5I2De/G35DJ6OWDva9AmzfvzI+5f1GPg=="],
"@rivetkit/react/rivetkit/@rivetkit/rivetkit-napi": ["@rivetkit/rivetkit-napi@2.3.7", "", { "dependencies": { "@napi-rs/cli": "^2.18.4", "@rivetkit/engine-envoy-protocol": "2.3.7" }, "optionalDependencies": { "@rivetkit/rivetkit-napi-darwin-arm64": "2.3.7", "@rivetkit/rivetkit-napi-darwin-x64": "2.3.7", "@rivetkit/rivetkit-napi-linux-arm64-gnu": "2.3.7", "@rivetkit/rivetkit-napi-linux-arm64-musl": "2.3.7", "@rivetkit/rivetkit-napi-linux-x64-gnu": "2.3.7", "@rivetkit/rivetkit-napi-linux-x64-musl": "2.3.7", "@rivetkit/rivetkit-napi-win32-x64-msvc": "2.3.7" } }, "sha512-1d3HtNKzJwdkznWDWSpCwg1rBn+nVwUfcKrApINTfiEhfDyVeznwbVFkfHWqOBHsjU17vvWFfjjTtf8QsjLwUQ=="],
"@rivetkit/react/rivetkit/@rivetkit/rivetkit-wasm": ["@rivetkit/rivetkit-wasm@2.3.7", "", {}, "sha512-o7QBtFOJrajyPVBIDQ32k8sDNFsUc8v2ETfNpr+Ch374ydmZMzCv3XKJU9vubc9bZa7WpCHSzf5ztc8b8i/Xpg=="],
"@rivetkit/react/rivetkit/@rivetkit/traces": ["@rivetkit/traces@2.3.7", "", { "dependencies": { "@rivetkit/bare-ts": "^0.6.2", "cbor-x": "^1.6.0", "fdb-tuple": "^1.0.0", "vbare": "^0.0.4" } }, "sha512-B7QYjFP2HPxyfPsVjfcNIV7FPLWgGWGypmlT/dZM1i1Cf1WRdeuBumPLffgs3YceiY7Pm4D/gZZVt5p0m4Cx+A=="],
"@rivetkit/react/rivetkit/@rivetkit/virtual-websocket": ["@rivetkit/virtual-websocket@2.3.7", "", {}, "sha512-jwxDvYbr3YB6vFuxwRdB/AqUUMwdtC1rIAc4tNN6VQR3weHbE+PrDoHKbe85zrtlBtcnG89/p8tlovrj8qyQWA=="],
"@rivetkit/react/rivetkit/@rivetkit/workflow-engine": ["@rivetkit/workflow-engine@2.3.7", "", { "dependencies": { "@rivetkit/bare-ts": "^0.6.2", "cbor-x": "^1.6.0", "fdb-tuple": "^1.0.0", "pino": "^9.6.0", "vbare": "^0.0.4" } }, "sha512-+C4kGuSNysrw2zs/LQoYxouOxufKHHMxiIXKF8Ab9GU2BCJ32RUST9dYU/gaQE/w9uM+hqAHUz0DTlOBYU6p6g=="],
"@rivetkit/react/rivetkit/uuid": ["uuid@12.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-9obBF8sMIHJWNQaO6IGOG8giGa/jUpKX34bz6o4whVs8M0WAvhID2tNxYp6A2XEBJPuZSX8wsS/6TEKfIDc+nw=="],
"@secure-exec/nodejs/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="],
"@secure-exec/nodejs/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="],
@@ -4981,66 +4926,6 @@
"@google/genai/google-auth-library/gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
"@rivet-dev/agentos/rivetkit/@rivetkit/engine-cli/@rivetkit/engine-cli-darwin-arm64": ["@rivetkit/engine-cli-darwin-arm64@2.3.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-F3yGSDIaW94BZzuAm3erQRT/Ki55wHJ/b6tOaE7d9tRmTAZw/XqHmjNdUYjOvFUQKLb+SSsOittM4dINrAANQQ=="],
"@rivet-dev/agentos/rivetkit/@rivetkit/engine-cli/@rivetkit/engine-cli-darwin-x64": ["@rivetkit/engine-cli-darwin-x64@2.3.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-P8BbQsamIvv8U/cEoJUFr+uAzwjjl9cG+ZZxjDX9OJxbuD11ESpHvDLgge54ijynO3IjoP8cIzjIXat6mr2Gkw=="],
"@rivet-dev/agentos/rivetkit/@rivetkit/engine-cli/@rivetkit/engine-cli-linux-arm64-musl": ["@rivetkit/engine-cli-linux-arm64-musl@2.3.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-G3s76BHfIs6R9rm6JYI13sOjPLy08+C8WvVoFwSdMqsibEaqlfo36wVkp/x1g0sEZUek/dBxVrKqMMR7c4/gug=="],
"@rivet-dev/agentos/rivetkit/@rivetkit/engine-cli/@rivetkit/engine-cli-linux-x64-musl": ["@rivetkit/engine-cli-linux-x64-musl@2.3.7", "", { "os": "linux", "cpu": "x64" }, "sha512-LWhrUVzULcsZ4ufCLsx23YfrdMW2INd9g0ANBac9+PzGwL8qYfVIXnDpR3587lapu68EbSEPZcconf1tBlvJxQ=="],
"@rivet-dev/agentos/rivetkit/@rivetkit/rivetkit-napi/@rivetkit/rivetkit-napi-darwin-arm64": ["@rivetkit/rivetkit-napi-darwin-arm64@2.3.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gI1wMe6z85VDeR4K/wnzevVakXTg5UCfvjlDOp1+23nyU3NlInhN156dEWO8EsfRMrs+1bYoGdc4rcoN+4PUrw=="],
"@rivet-dev/agentos/rivetkit/@rivetkit/rivetkit-napi/@rivetkit/rivetkit-napi-darwin-x64": ["@rivetkit/rivetkit-napi-darwin-x64@2.3.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-H5tUwvCbKwTIBiyQG01ikK5mdc9ISg+bv8gPUw41mYOKq8rb9nBLkcl2TKcreFHac8KaGxtnBZzYcjbUV3NcwQ=="],
"@rivet-dev/agentos/rivetkit/@rivetkit/rivetkit-napi/@rivetkit/rivetkit-napi-linux-arm64-gnu": ["@rivetkit/rivetkit-napi-linux-arm64-gnu@2.3.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-MpOeiO5M737e93hM/FjcH+zv8xBEX1tgBd/59K/Uw7qlRbWW7JGz8WGjavdxv211LRqxsn8EUlGRxcADDsXnBA=="],
"@rivet-dev/agentos/rivetkit/@rivetkit/rivetkit-napi/@rivetkit/rivetkit-napi-linux-arm64-musl": ["@rivetkit/rivetkit-napi-linux-arm64-musl@2.3.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-0GsUgd/HHvIq6v92kp0H175mGm2Exvs4krBLih0MIpVAkqJk8EIcKI6wJhCwY04Fr0MS8sHkUhjuHXa7QcmlYA=="],
"@rivet-dev/agentos/rivetkit/@rivetkit/rivetkit-napi/@rivetkit/rivetkit-napi-linux-x64-gnu": ["@rivetkit/rivetkit-napi-linux-x64-gnu@2.3.7", "", { "os": "linux", "cpu": "x64" }, "sha512-8wCD1FuDpM+yiKRG8ro1K8ILVpJXzCNKViJ1axq1b4Nyqgod/nyEvvKDaT4qhA5+Om+W5i6YRO1uddjC6QjJSQ=="],
"@rivet-dev/agentos/rivetkit/@rivetkit/rivetkit-napi/@rivetkit/rivetkit-napi-linux-x64-musl": ["@rivetkit/rivetkit-napi-linux-x64-musl@2.3.7", "", { "os": "linux", "cpu": "x64" }, "sha512-8RGzXD33GH1/DWufFzCZRI+npF5IL/9z7Ja3pthsuYgrL+axGG0iqfAjbwj5HHnB/ceUGQVT+uCKzoLnG+odtw=="],
"@rivetkit/framework-base/rivetkit/@rivetkit/engine-cli/@rivetkit/engine-cli-darwin-arm64": ["@rivetkit/engine-cli-darwin-arm64@2.3.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-F3yGSDIaW94BZzuAm3erQRT/Ki55wHJ/b6tOaE7d9tRmTAZw/XqHmjNdUYjOvFUQKLb+SSsOittM4dINrAANQQ=="],
"@rivetkit/framework-base/rivetkit/@rivetkit/engine-cli/@rivetkit/engine-cli-darwin-x64": ["@rivetkit/engine-cli-darwin-x64@2.3.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-P8BbQsamIvv8U/cEoJUFr+uAzwjjl9cG+ZZxjDX9OJxbuD11ESpHvDLgge54ijynO3IjoP8cIzjIXat6mr2Gkw=="],
"@rivetkit/framework-base/rivetkit/@rivetkit/engine-cli/@rivetkit/engine-cli-linux-arm64-musl": ["@rivetkit/engine-cli-linux-arm64-musl@2.3.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-G3s76BHfIs6R9rm6JYI13sOjPLy08+C8WvVoFwSdMqsibEaqlfo36wVkp/x1g0sEZUek/dBxVrKqMMR7c4/gug=="],
"@rivetkit/framework-base/rivetkit/@rivetkit/engine-cli/@rivetkit/engine-cli-linux-x64-musl": ["@rivetkit/engine-cli-linux-x64-musl@2.3.7", "", { "os": "linux", "cpu": "x64" }, "sha512-LWhrUVzULcsZ4ufCLsx23YfrdMW2INd9g0ANBac9+PzGwL8qYfVIXnDpR3587lapu68EbSEPZcconf1tBlvJxQ=="],
"@rivetkit/framework-base/rivetkit/@rivetkit/rivetkit-napi/@rivetkit/rivetkit-napi-darwin-arm64": ["@rivetkit/rivetkit-napi-darwin-arm64@2.3.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gI1wMe6z85VDeR4K/wnzevVakXTg5UCfvjlDOp1+23nyU3NlInhN156dEWO8EsfRMrs+1bYoGdc4rcoN+4PUrw=="],
"@rivetkit/framework-base/rivetkit/@rivetkit/rivetkit-napi/@rivetkit/rivetkit-napi-darwin-x64": ["@rivetkit/rivetkit-napi-darwin-x64@2.3.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-H5tUwvCbKwTIBiyQG01ikK5mdc9ISg+bv8gPUw41mYOKq8rb9nBLkcl2TKcreFHac8KaGxtnBZzYcjbUV3NcwQ=="],
"@rivetkit/framework-base/rivetkit/@rivetkit/rivetkit-napi/@rivetkit/rivetkit-napi-linux-arm64-gnu": ["@rivetkit/rivetkit-napi-linux-arm64-gnu@2.3.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-MpOeiO5M737e93hM/FjcH+zv8xBEX1tgBd/59K/Uw7qlRbWW7JGz8WGjavdxv211LRqxsn8EUlGRxcADDsXnBA=="],
"@rivetkit/framework-base/rivetkit/@rivetkit/rivetkit-napi/@rivetkit/rivetkit-napi-linux-arm64-musl": ["@rivetkit/rivetkit-napi-linux-arm64-musl@2.3.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-0GsUgd/HHvIq6v92kp0H175mGm2Exvs4krBLih0MIpVAkqJk8EIcKI6wJhCwY04Fr0MS8sHkUhjuHXa7QcmlYA=="],
"@rivetkit/framework-base/rivetkit/@rivetkit/rivetkit-napi/@rivetkit/rivetkit-napi-linux-x64-gnu": ["@rivetkit/rivetkit-napi-linux-x64-gnu@2.3.7", "", { "os": "linux", "cpu": "x64" }, "sha512-8wCD1FuDpM+yiKRG8ro1K8ILVpJXzCNKViJ1axq1b4Nyqgod/nyEvvKDaT4qhA5+Om+W5i6YRO1uddjC6QjJSQ=="],
"@rivetkit/framework-base/rivetkit/@rivetkit/rivetkit-napi/@rivetkit/rivetkit-napi-linux-x64-musl": ["@rivetkit/rivetkit-napi-linux-x64-musl@2.3.7", "", { "os": "linux", "cpu": "x64" }, "sha512-8RGzXD33GH1/DWufFzCZRI+npF5IL/9z7Ja3pthsuYgrL+axGG0iqfAjbwj5HHnB/ceUGQVT+uCKzoLnG+odtw=="],
"@rivetkit/react/rivetkit/@rivetkit/engine-cli/@rivetkit/engine-cli-darwin-arm64": ["@rivetkit/engine-cli-darwin-arm64@2.3.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-F3yGSDIaW94BZzuAm3erQRT/Ki55wHJ/b6tOaE7d9tRmTAZw/XqHmjNdUYjOvFUQKLb+SSsOittM4dINrAANQQ=="],
"@rivetkit/react/rivetkit/@rivetkit/engine-cli/@rivetkit/engine-cli-darwin-x64": ["@rivetkit/engine-cli-darwin-x64@2.3.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-P8BbQsamIvv8U/cEoJUFr+uAzwjjl9cG+ZZxjDX9OJxbuD11ESpHvDLgge54ijynO3IjoP8cIzjIXat6mr2Gkw=="],
"@rivetkit/react/rivetkit/@rivetkit/engine-cli/@rivetkit/engine-cli-linux-arm64-musl": ["@rivetkit/engine-cli-linux-arm64-musl@2.3.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-G3s76BHfIs6R9rm6JYI13sOjPLy08+C8WvVoFwSdMqsibEaqlfo36wVkp/x1g0sEZUek/dBxVrKqMMR7c4/gug=="],
"@rivetkit/react/rivetkit/@rivetkit/engine-cli/@rivetkit/engine-cli-linux-x64-musl": ["@rivetkit/engine-cli-linux-x64-musl@2.3.7", "", { "os": "linux", "cpu": "x64" }, "sha512-LWhrUVzULcsZ4ufCLsx23YfrdMW2INd9g0ANBac9+PzGwL8qYfVIXnDpR3587lapu68EbSEPZcconf1tBlvJxQ=="],
"@rivetkit/react/rivetkit/@rivetkit/rivetkit-napi/@rivetkit/rivetkit-napi-darwin-arm64": ["@rivetkit/rivetkit-napi-darwin-arm64@2.3.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gI1wMe6z85VDeR4K/wnzevVakXTg5UCfvjlDOp1+23nyU3NlInhN156dEWO8EsfRMrs+1bYoGdc4rcoN+4PUrw=="],
"@rivetkit/react/rivetkit/@rivetkit/rivetkit-napi/@rivetkit/rivetkit-napi-darwin-x64": ["@rivetkit/rivetkit-napi-darwin-x64@2.3.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-H5tUwvCbKwTIBiyQG01ikK5mdc9ISg+bv8gPUw41mYOKq8rb9nBLkcl2TKcreFHac8KaGxtnBZzYcjbUV3NcwQ=="],
"@rivetkit/react/rivetkit/@rivetkit/rivetkit-napi/@rivetkit/rivetkit-napi-linux-arm64-gnu": ["@rivetkit/rivetkit-napi-linux-arm64-gnu@2.3.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-MpOeiO5M737e93hM/FjcH+zv8xBEX1tgBd/59K/Uw7qlRbWW7JGz8WGjavdxv211LRqxsn8EUlGRxcADDsXnBA=="],
"@rivetkit/react/rivetkit/@rivetkit/rivetkit-napi/@rivetkit/rivetkit-napi-linux-arm64-musl": ["@rivetkit/rivetkit-napi-linux-arm64-musl@2.3.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-0GsUgd/HHvIq6v92kp0H175mGm2Exvs4krBLih0MIpVAkqJk8EIcKI6wJhCwY04Fr0MS8sHkUhjuHXa7QcmlYA=="],
"@rivetkit/react/rivetkit/@rivetkit/rivetkit-napi/@rivetkit/rivetkit-napi-linux-x64-gnu": ["@rivetkit/rivetkit-napi-linux-x64-gnu@2.3.7", "", { "os": "linux", "cpu": "x64" }, "sha512-8wCD1FuDpM+yiKRG8ro1K8ILVpJXzCNKViJ1axq1b4Nyqgod/nyEvvKDaT4qhA5+Om+W5i6YRO1uddjC6QjJSQ=="],
"@rivetkit/react/rivetkit/@rivetkit/rivetkit-napi/@rivetkit/rivetkit-napi-linux-x64-musl": ["@rivetkit/rivetkit-napi-linux-x64-musl@2.3.7", "", { "os": "linux", "cpu": "x64" }, "sha512-8RGzXD33GH1/DWufFzCZRI+npF5IL/9z7Ja3pthsuYgrL+axGG0iqfAjbwj5HHnB/ceUGQVT+uCKzoLnG+odtw=="],
"cli-highlight/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"cli-highlight/yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],

View File

@@ -1,3 +1,6 @@
import { exec, execFile } from "node:child_process";
import { promisify } from "node:util";
import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { parseAgentEnv } from "@code/env/agent";
@@ -9,6 +12,64 @@ import {
createGiteaHttpTransport,
runPostRunGiteaLifecycle,
} from "../git/gitea";
import { AgentOsSandboxApi } from "../sandboxes/agent-os";
import {
clonePublicRepository,
mirrorSandboxToHostCheckout,
} from "../sandboxes/host-repository-bridge";
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
const GIT_OUTPUT_LIMIT_BYTES = 8 * 1024 * 1024;
const createHostGitRunner = () => ({
async run(
command: string,
options?: { cwd?: string; env?: Record<string, string> }
) {
try {
const result = await execAsync(command, {
cwd: options?.cwd,
env: options?.env,
maxBuffer: GIT_OUTPUT_LIMIT_BYTES,
});
return {
exitCode: 0,
stderr: String(result.stderr),
stdout: String(result.stdout),
};
} catch (error) {
const failure = error as Error & {
readonly code?: number;
readonly stderr?: string;
readonly stdout?: string;
};
return {
exitCode:
typeof failure.code === "number" && failure.code > 0
? failure.code
: 1,
stderr: failure.stderr ?? failure.message,
stdout: failure.stdout ?? "",
};
}
},
});
const makeAuthenticatedRepositoryUrl = (
repositoryUrl: string,
repositoryPath: string,
token: string
): string => {
const [owner] = repositoryPath.split("/");
if (!owner) {
throw new Error("Gitea repository path has no owner");
}
const url = new URL(repositoryUrl);
url.username = owner;
url.password = token;
return url.toString();
};
const pullRequestOutput = v.object({
baseBranch: v.string(),
@@ -49,7 +110,7 @@ export const createFinalizeGiteaLifecycle = (
}),
name: "finalize_gitea_lifecycle",
output,
async run({ harness, input, log }) {
async run({ input, log }) {
const context = await client.query(api.agentWorkspace.get, {
issueId,
token: env.FLUE_DB_TOKEN,
@@ -65,21 +126,49 @@ export const createFinalizeGiteaLifecycle = (
"GITEA_TOKEN is required for Gitea pull request creation"
);
}
if (!context.run) {
throw new Error("Project work run is not initialized");
}
const runner = {
async run(
command: string,
options?: { cwd?: string; env?: Record<string, string> }
) {
return await harness.shell(command, options);
},
};
const sandbox = new AgentOsSandboxApi(
client,
env.DAEMON_ID,
context.run.actorKey
);
const checkout = await clonePublicRepository({
branchName:
context.run.branchName ?? `work/issue-${context.issue.number}`,
repositoryUrl: context.source.url,
});
const runner = createHostGitRunner();
const transport = createGiteaHttpTransport({
baseUrl: env.GITEA_URL,
token: env.GITEA_TOKEN,
});
try {
await mirrorSandboxToHostCheckout({
checkoutDirectory: checkout.checkoutDirectory,
sandbox,
sandboxDirectory: context.run.checkoutPath ?? "/workspace/repository",
});
await execFileAsync(
"git",
[
"remote",
"set-url",
"origin",
makeAuthenticatedRepositoryUrl(
context.source.url,
context.source.repositoryPath,
env.GITEA_TOKEN
),
],
{
cwd: checkout.checkoutDirectory,
maxBuffer: GIT_OUTPUT_LIMIT_BYTES,
}
);
const result = await runPostRunGiteaLifecycle({
baseBranch: context.source.defaultBranch,
body: `Verified changes for project issue #${context.issue.number}. Merge remains a manual review action.`,
@@ -91,7 +180,7 @@ export const createFinalizeGiteaLifecycle = (
title: `Issue #${context.issue.number}: ${context.issue.title}`,
transport,
verification: input.verified ? "passed" : "failed",
workspace: "/workspace/repository",
workspace: checkout.checkoutDirectory,
});
await client.mutation(api.agentWorkspace.recordGiteaLifecycle, {
baseBranch: result.baseBranch,
@@ -113,7 +202,7 @@ export const createFinalizeGiteaLifecycle = (
return result;
} catch (error) {
const branchResult = await runner.run("git branch --show-current", {
cwd: "/workspace/repository",
cwd: checkout.checkoutDirectory,
});
const branch = branchResult.stdout.trim() || "unknown";
const message = error instanceof Error ? error.message : String(error);
@@ -126,6 +215,8 @@ export const createFinalizeGiteaLifecycle = (
token: env.FLUE_DB_TOKEN,
});
throw error;
} finally {
await checkout.cleanup();
}
},
});

View File

@@ -22,7 +22,7 @@ export default defineAgent(({ env, id }) => {
Start every run by calling lookup_issue_context, reading /workspace/control/issue.md, the canonical context files under /workspace/control/context, and the operational artifacts under /workspace/control/artifacts. Call report_work_status with working before editing.
Work only on the bound issue. The repository checkout is the current working directory; inspect existing source files before changing them. If a missing product decision makes safe progress impossible, publish the current work.md and steps.md, report needs-input, then ask one focused question. Otherwise implement the complete issue, run the relevant install, edit, test, or scenario command in AgentOS, and preserve command evidence.
Work only on the bound issue. The repository checkout is the current working directory; inspect existing source files before changing them. Git metadata stays on the trusted host bridge, so do not run git commands inside AgentOS—the final lifecycle action mirrors verified files into the host checkout before committing and pushing. If a missing product decision makes safe progress impossible, publish the current work.md and steps.md, report needs-input, then ask one focused question. Otherwise implement the complete issue, run the relevant install, edit, test, or scenario command in AgentOS, and preserve command evidence.
Before finishing, update the operational files under /workspace/control/artifacts and publish each changed canonical artifact with publish_project_artifact. After verification, call finalize_gitea_lifecycle with verified=true; it owns the post-run Git/Gitea lifecycle and never merges. Report completed only after that action succeeds or reports no_changes. On an unrecoverable error, report failed with the exact blocker. Never claim a repository change or command result you did not observe.`,
model: `${AGENT_MODEL_PROVIDER}/${AGENT_MODEL_NAME}`,

View File

@@ -105,7 +105,7 @@ describe("Gitea lifecycle adapter", () => {
"git add --all && git commit -m 'feat(issue-5): Publish verified changes'",
"git rev-parse HEAD",
"git status --porcelain=v1",
"git push 'ssh://git@git.openputer.com:2222/puter/zopu-code.git' HEAD:'work/issue-5'",
"git push origin HEAD:'work/issue-5'",
]);
expect(pullRequests).toHaveLength(1);
});

View File

@@ -248,7 +248,7 @@ export const runPostRunGiteaLifecycle = async (
};
}
const repository = await input.transport.getRepository(input.repositoryPath);
await input.transport.getRepository(input.repositoryPath);
await runChecked(
input.runner,
@@ -276,7 +276,7 @@ export const runPostRunGiteaLifecycle = async (
await runChecked(
input.runner,
`git push ${shellQuote(repository.sshUrl)} HEAD:${shellQuote(inspection.branch)}`,
`git push origin HEAD:${shellQuote(inspection.branch)}`,
{ cwd: input.workspace }
);

View File

@@ -11,6 +11,11 @@ import { ConvexHttpClient } from "convex/browser";
import { Effect } from "effect";
import * as v from "valibot";
import {
clonePublicRepository,
mirrorHostCheckoutToSandbox,
} from "./host-repository-bridge";
const execResultSchema = v.object({
exitCode: v.number(),
stderr: v.string(),
@@ -29,7 +34,7 @@ interface ExecuteOptions {
readonly timeoutMs?: number;
}
class AgentOsSandboxApi implements SandboxApi {
export class AgentOsSandboxApi implements SandboxApi {
readonly #actorKey: string[];
readonly #client: ConvexHttpClient;
readonly #daemonId: string;
@@ -258,13 +263,27 @@ export const agentOs = (
if (!checkoutOperation || checkoutOperation._tag !== "Exec") {
throw new Error("Issue workspace plan must include a checkout command");
}
const checkoutCommandResult = await sandbox.exec(
checkoutOperation.command,
{
cwd: checkoutOperation.cwd,
timeoutMs: checkoutOperation.timeoutMs,
const hostCheckout = await clonePublicRepository({
branchName: plan.branchName,
repositoryUrl: plan.sourceUrl,
timeoutMs: checkoutOperation.timeoutMs,
});
try {
if (!(await sandbox.exists(plan.checkoutPath))) {
await mirrorHostCheckoutToSandbox({
checkoutDirectory: hostCheckout.checkoutDirectory,
sandbox,
sandboxDirectory: plan.checkoutPath,
});
}
);
} finally {
await hostCheckout.cleanup();
}
const checkoutCommandResult = {
exitCode: 0,
stderr: "",
stdout: `${plan.branchName} ${hostCheckout.headSha}\n`,
};
await Promise.all(
stagingOperations

View File

@@ -0,0 +1,297 @@
import {
lstat,
mkdir,
mkdtemp,
readFile,
rm,
stat,
symlink,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import nodePath from "node:path";
import type { FileStat, SandboxApi } from "@flue/runtime";
import { afterEach, describe, expect, it } from "vitest";
import {
mirrorHostCheckoutToSandbox,
mirrorSandboxToHostCheckout,
parseGitCommitSha,
parsePublicGitUrl,
} from "./host-repository-bridge";
class FakeSandbox implements SandboxApi {
readonly files = new Map<string, Uint8Array>();
readonly directories = new Set<string>(["/"]);
async readFile(path: string): Promise<string> {
return new TextDecoder().decode(await this.readFileBuffer(path));
}
readFileBuffer(path: string): Promise<Uint8Array> {
const content = this.files.get(path);
if (!content) {
throw new Error(`Missing fake sandbox file: ${path}`);
}
return Promise.resolve(Uint8Array.from(content));
}
writeFile(path: string, content: string | Uint8Array): Promise<void> {
this.files.set(
path,
typeof content === "string"
? new TextEncoder().encode(content)
: Uint8Array.from(content)
);
return Promise.resolve();
}
stat(path: string): Promise<FileStat> {
const content = this.files.get(path);
if (content) {
return Promise.resolve({
isDirectory: false,
isFile: true,
size: content.byteLength,
});
}
if (this.directories.has(path)) {
return Promise.resolve({ isDirectory: true, isFile: false });
}
throw new Error(`Missing fake sandbox path: ${path}`);
}
readdir(path: string): Promise<string[]> {
const prefix = path === "/" ? "/" : `${path}/`;
const entries = new Set<string>();
for (const directory of this.directories) {
if (directory.startsWith(prefix)) {
const [entry] = directory.slice(prefix.length).split("/");
if (entry) {
entries.add(entry);
}
}
}
for (const file of this.files.keys()) {
if (file.startsWith(prefix)) {
const [entry] = file.slice(prefix.length).split("/");
if (entry) {
entries.add(entry);
}
}
}
return Promise.resolve([...entries]);
}
exists(path: string): Promise<boolean> {
return Promise.resolve(this.files.has(path) || this.directories.has(path));
}
mkdir(
path: string,
options?: { readonly recursive?: boolean }
): Promise<void> {
if (!options?.recursive) {
this.directories.add(path);
return Promise.resolve();
}
const parts = path.split("/").filter(Boolean);
let current = "";
for (const part of parts) {
current += `/${part}`;
this.directories.add(current);
}
return Promise.resolve();
}
rm(
path: string,
options?: { readonly force?: boolean; readonly recursive?: boolean }
): Promise<void> {
const prefix = `${path}/`;
if (options?.recursive) {
for (const file of this.files.keys()) {
if (file === path || file.startsWith(prefix)) {
this.files.delete(file);
}
}
for (const directory of this.directories) {
if (directory === path || directory.startsWith(prefix)) {
this.directories.delete(directory);
}
}
return Promise.resolve();
}
this.files.delete(path);
this.directories.delete(path);
return Promise.resolve();
}
exec(): Promise<{
exitCode: number;
stderr: string;
stdout: string;
}> {
void this.directories;
return Promise.resolve({ exitCode: 0, stderr: "", stdout: "" });
}
}
const temporaryDirectories: string[] = [];
const makeCheckout = async (): Promise<string> => {
const directory = await mkdtemp(nodePath.join(tmpdir(), "host-bridge-test-"));
temporaryDirectories.push(directory);
await mkdir(nodePath.join(directory, ".git"), { recursive: true });
await writeFile(nodePath.join(directory, ".git", "keep"), "git metadata");
return directory;
};
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { force: true, recursive: true }))
);
});
describe("parsePublicGitUrl", () => {
it("accepts public HTTP(S) repositories", () => {
expect(
parsePublicGitUrl("https://git.openputer.com/team/repository.git")
.hostname
).toBe("git.openputer.com");
});
it.each([
"file:///tmp/repository",
"ssh://git@example.com/team/repository.git",
"https://token@example.com/team/repository.git",
"http://127.0.0.1/repository.git",
"http://192.168.1.10/repository.git",
])("rejects non-public repository URL %s", (repositoryUrl) => {
expect(() => parsePublicGitUrl(repositoryUrl)).toThrow();
});
});
describe("parseGitCommitSha", () => {
it("normalizes a full SHA returned by git", () => {
const sha = "a".repeat(40);
expect(parseGitCommitSha(` ${sha}\n`)).toBe(sha);
});
it.each(["", "abc123", "g".repeat(40), "a".repeat(41)])(
"rejects invalid commit SHA %s",
(sha) => {
expect(() => parseGitCommitSha(sha)).toThrow("invalid HEAD commit SHA");
}
);
});
describe("repository mirroring", () => {
it("copies host files into a sandbox without copying .git", async () => {
const checkoutDirectory = await makeCheckout();
await mkdir(nodePath.join(checkoutDirectory, "src"), {
recursive: true,
});
await writeFile(nodePath.join(checkoutDirectory, "README.md"), "hello");
await writeFile(
nodePath.join(checkoutDirectory, "src", "binary.bin"),
Uint8Array.from([0, 255, 1])
);
const sandbox = new FakeSandbox();
await mirrorHostCheckoutToSandbox({
checkoutDirectory,
sandbox,
sandboxDirectory: "/workspace/repository",
});
expect(
new TextDecoder().decode(
sandbox.files.get("/workspace/repository/README.md")
)
).toBe("hello");
expect(sandbox.files.get("/workspace/repository/src/binary.bin")).toEqual(
Uint8Array.from([0, 255, 1])
);
expect(
[...sandbox.files.keys()].some((filePath) => filePath.includes("/.git/"))
).toBe(false);
});
it("stages sandbox files, removes stale files, and preserves .git", async () => {
const checkoutDirectory = await makeCheckout();
await writeFile(nodePath.join(checkoutDirectory, "stale.txt"), "delete me");
const executablePath = nodePath.join(checkoutDirectory, "run.sh");
await writeFile(executablePath, "#!/bin/sh\nexit 0\n", { mode: 0o755 });
const sandbox = new FakeSandbox();
await sandbox.mkdir("/workspace/repository/src", { recursive: true });
await sandbox.writeFile(
"/workspace/repository/run.sh",
"#!/bin/sh\necho changed\n"
);
await sandbox.writeFile(
"/workspace/repository/src/index.ts",
"export const value = 1;\n"
);
await mirrorSandboxToHostCheckout({
checkoutDirectory,
sandbox,
sandboxDirectory: "/workspace/repository",
});
await expect(
readFile(nodePath.join(checkoutDirectory, "stale.txt"))
).rejects.toThrow();
await expect(
readFile(nodePath.join(checkoutDirectory, ".git", "keep"), "utf-8")
).resolves.toBe("git metadata");
await expect(
readFile(nodePath.join(checkoutDirectory, "src", "index.ts"), "utf-8")
).resolves.toBe("export const value = 1;\n");
const executableMetadata = await stat(executablePath);
expect(executableMetadata.mode % 0o1000).toBe(0o755);
});
it("enforces transfer limits before modifying the sandbox", async () => {
const checkoutDirectory = await makeCheckout();
await writeFile(nodePath.join(checkoutDirectory, "one.txt"), "1");
await writeFile(nodePath.join(checkoutDirectory, "two.txt"), "2");
const sandbox = new FakeSandbox();
await expect(
mirrorHostCheckoutToSandbox({
checkoutDirectory,
limits: { maxFiles: 1 },
sandbox,
sandboxDirectory: "/workspace/repository",
})
).rejects.toThrow("limit is 1");
expect(sandbox.files.size).toBe(0);
});
it("rejects host symbolic links", async () => {
const checkoutDirectory = await makeCheckout();
await writeFile(nodePath.join(checkoutDirectory, "outside.txt"), "outside");
await symlink(
"outside.txt",
nodePath.join(checkoutDirectory, "linked.txt")
);
const sandbox = new FakeSandbox();
await expect(
mirrorHostCheckoutToSandbox({
checkoutDirectory,
sandbox,
sandboxDirectory: "/workspace/repository",
})
).rejects.toThrow("Symbolic links are not supported");
const linkedMetadata = await lstat(
nodePath.join(checkoutDirectory, "linked.txt")
);
expect(linkedMetadata.isSymbolicLink()).toBe(true);
});
});

View File

@@ -0,0 +1,506 @@
import { execFile } from "node:child_process";
import {
chmod,
lstat,
mkdir,
mkdtemp,
readdir,
readFile,
rename,
rm,
writeFile,
} from "node:fs/promises";
import { isIP } from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import type { SandboxApi } from "@flue/runtime";
const DEFAULT_GIT_TIMEOUT_MS = 60_000;
const GIT_OUTPUT_LIMIT_BYTES = 1_048_576;
export interface RepositoryMirrorLimits {
readonly maxDepth: number;
readonly maxFileBytes: number;
readonly maxFiles: number;
readonly maxTotalBytes: number;
}
export const DEFAULT_REPOSITORY_MIRROR_LIMITS: RepositoryMirrorLimits = {
maxDepth: 64,
maxFileBytes: 10 * 1024 * 1024,
maxFiles: 10_000,
maxTotalBytes: 100 * 1024 * 1024,
};
export interface HostRepositoryCheckout {
readonly branchName: string;
readonly checkoutDirectory: string;
readonly headSha: string;
readonly temporaryDirectory: string;
readonly cleanup: () => Promise<void>;
}
interface MirrorFile {
readonly mode?: number;
readonly path: string;
readonly size: number;
}
interface GitResult {
readonly stderr: string;
readonly stdout: string;
}
const gitError = (
args: readonly string[],
error: Error & { readonly stderr?: string }
): Error => {
const detail = error.stderr?.trim() || error.message;
return new Error(`git ${args.join(" ")} failed: ${detail}`, {
cause: error,
});
};
const execFileAsync = promisify(execFile);
const runGit = async (
args: readonly string[],
options: { readonly cwd?: string; readonly timeoutMs: number }
): Promise<GitResult> => {
try {
const result = await execFileAsync("git", [...args], {
cwd: options.cwd,
maxBuffer: GIT_OUTPUT_LIMIT_BYTES,
timeout: options.timeoutMs,
});
return {
stderr: String(result.stderr),
stdout: String(result.stdout),
};
} catch (error) {
if (!(error instanceof Error)) {
throw error;
}
throw gitError(
args,
error as Error & {
readonly stderr?: string;
}
);
}
};
const isPrivateIpv4 = (hostname: string): boolean => {
const octets = hostname.split(".").map(Number);
const [first = -1, second = -1] = octets;
return (
first === 0 ||
first === 10 ||
first === 127 ||
(first === 169 && second === 254) ||
(first === 172 && second >= 16 && second <= 31) ||
(first === 192 && second === 168)
);
};
const isPrivateIpv6 = (hostname: string): boolean => {
const normalized = hostname
.toLowerCase()
.replaceAll("[", "")
.replaceAll("]", "");
return (
normalized === "::" ||
normalized === "::1" ||
normalized.startsWith("fc") ||
normalized.startsWith("fd") ||
normalized.startsWith("fe8") ||
normalized.startsWith("fe9") ||
normalized.startsWith("fea") ||
normalized.startsWith("feb")
);
};
export const parsePublicGitUrl = (repositoryUrl: string): URL => {
const url = new URL(repositoryUrl);
if (url.protocol !== "https:" && url.protocol !== "http:") {
throw new Error("Public Git repositories must use an HTTP(S) URL");
}
if (url.username || url.password) {
throw new Error("Public Git repository URLs must not contain credentials");
}
const hostname = url.hostname.toLowerCase();
const addressKind = isIP(hostname);
const isPrivateAddress =
(addressKind === 4 && isPrivateIpv4(hostname)) ||
(addressKind === 6 && isPrivateIpv6(hostname));
if (
hostname === "localhost" ||
hostname.endsWith(".localhost") ||
hostname.endsWith(".local") ||
isPrivateAddress
) {
throw new Error("Public Git repository URLs must use a public host");
}
return url;
};
export const parseGitCommitSha = (value: string): string => {
const sha = value.trim();
if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u.test(sha)) {
throw new Error("Git returned an invalid HEAD commit SHA");
}
return sha;
};
export const clonePublicRepository = async (options: {
readonly branchName: string;
readonly repositoryUrl: string;
readonly timeoutMs?: number;
}): Promise<HostRepositoryCheckout> => {
const timeoutMs = options.timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS;
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
throw new Error("Git timeout must be a positive integer");
}
const repositoryUrl = parsePublicGitUrl(options.repositoryUrl).toString();
await runGit(["check-ref-format", "--branch", options.branchName], {
timeoutMs,
});
const temporaryDirectory = await mkdtemp(
path.join(tmpdir(), "zopu-host-repository-")
);
const checkoutDirectory = path.join(temporaryDirectory, "repository");
let headSha = "";
try {
await runGit(
[
"clone",
"--depth",
"1",
"--single-branch",
"--no-tags",
"--",
repositoryUrl,
checkoutDirectory,
],
{ timeoutMs }
);
await runGit(["switch", "-c", options.branchName], {
cwd: checkoutDirectory,
timeoutMs,
});
const headResult = await runGit(["rev-parse", "HEAD"], {
cwd: checkoutDirectory,
timeoutMs,
});
headSha = parseGitCommitSha(headResult.stdout);
} catch (error) {
await rm(temporaryDirectory, { force: true, recursive: true });
throw error;
}
return {
branchName: options.branchName,
checkoutDirectory,
cleanup: () =>
rm(temporaryDirectory, {
force: true,
recursive: true,
}),
headSha,
temporaryDirectory,
};
};
const resolveLimits = (
limits: Partial<RepositoryMirrorLimits> | undefined
): RepositoryMirrorLimits => {
const resolvedLimits = {
...DEFAULT_REPOSITORY_MIRROR_LIMITS,
...limits,
};
for (const [name, value] of Object.entries(resolvedLimits)) {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer`);
}
}
return resolvedLimits;
};
const assertSafeEntryName = (name: string): void => {
if (
!name ||
name === "." ||
name === ".." ||
name.includes("/") ||
name.includes("\\")
) {
throw new Error(`Sandbox returned an unsafe directory entry: ${name}`);
}
};
const assertWithinCheckout = (checkoutDirectory: string): string => {
const absoluteCheckout = path.resolve(checkoutDirectory);
if (absoluteCheckout === path.resolve(path.sep)) {
throw new Error("Repository checkout cannot be the filesystem root");
}
return absoluteCheckout;
};
const assertAbsoluteSandboxDirectory = (sandboxDirectory: string): string => {
if (!path.posix.isAbsolute(sandboxDirectory)) {
throw new Error("Sandbox directory must be absolute");
}
return path.posix.normalize(sandboxDirectory);
};
const enforceFileBounds = (
files: readonly MirrorFile[],
limits: RepositoryMirrorLimits
): void => {
if (files.length > limits.maxFiles) {
throw new Error(
`Repository contains ${files.length} files; limit is ${limits.maxFiles}`
);
}
let totalBytes = 0;
for (const file of files) {
if (file.size > limits.maxFileBytes) {
throw new Error(
`${file.path} is ${file.size} bytes; per-file limit is ${limits.maxFileBytes}`
);
}
totalBytes += file.size;
if (totalBytes > limits.maxTotalBytes) {
throw new Error(
`Repository files exceed the ${limits.maxTotalBytes}-byte total limit`
);
}
}
};
const listHostFiles = async (
checkoutDirectory: string,
limits: RepositoryMirrorLimits
): Promise<MirrorFile[]> => {
const files: MirrorFile[] = [];
const visit = async (
directory: string,
relativeDirectory: string
): Promise<void> => {
const depth = relativeDirectory
? relativeDirectory.split(path.sep).length
: 0;
if (depth > limits.maxDepth) {
throw new Error(`Repository directory depth exceeds ${limits.maxDepth}`);
}
const entries = await readdir(directory, { withFileTypes: true });
entries.sort((left, right) => left.name.localeCompare(right.name));
for (const entry of entries) {
if (!relativeDirectory && entry.name === ".git") {
continue;
}
const relativePath = path.join(relativeDirectory, entry.name);
const absolutePath = path.join(directory, entry.name);
if (entry.isSymbolicLink()) {
throw new Error(`Symbolic links are not supported: ${relativePath}`);
}
if (entry.isDirectory()) {
// oxlint-disable-next-line no-await-in-loop -- deterministic bounded traversal.
await visit(absolutePath, relativePath);
continue;
}
if (!entry.isFile()) {
throw new Error(`Unsupported repository entry: ${relativePath}`);
}
// oxlint-disable-next-line no-await-in-loop -- metadata is bounded before file transfer.
const metadata = await lstat(absolutePath);
files.push({
mode: metadata.mode % 0o1000,
path: relativePath.split(path.sep).join(path.posix.sep),
size: metadata.size,
});
enforceFileBounds(files, limits);
}
};
await visit(checkoutDirectory, "");
return files;
};
const listSandboxFiles = async (
sandbox: SandboxApi,
sandboxDirectory: string,
limits: RepositoryMirrorLimits
): Promise<MirrorFile[]> => {
const files: MirrorFile[] = [];
const visit = async (
directory: string,
relativeDirectory: string
): Promise<void> => {
const depth = relativeDirectory
? relativeDirectory.split(path.posix.sep).length
: 0;
if (depth > limits.maxDepth) {
throw new Error(`Sandbox directory depth exceeds ${limits.maxDepth}`);
}
const entries = [...(await sandbox.readdir(directory))].toSorted(
(left, right) => left.localeCompare(right)
);
for (const entry of entries) {
assertSafeEntryName(entry);
if (!relativeDirectory && entry === ".git") {
continue;
}
const relativePath = path.posix.join(relativeDirectory, entry);
const absolutePath = path.posix.join(directory, entry);
// oxlint-disable-next-line no-await-in-loop -- sandbox metadata is read sequentially and bounded.
const metadata = await sandbox.stat(absolutePath);
if (metadata.isSymbolicLink) {
throw new Error(`Symbolic links are not supported: ${relativePath}`);
}
if (metadata.isDirectory) {
// oxlint-disable-next-line no-await-in-loop -- deterministic bounded traversal.
await visit(absolutePath, relativePath);
continue;
}
if (!metadata.isFile) {
throw new Error(`Unsupported sandbox entry: ${relativePath}`);
}
if (metadata.size === undefined) {
throw new Error(`Sandbox did not report a size for ${relativePath}`);
}
files.push({ path: relativePath, size: metadata.size });
enforceFileBounds(files, limits);
}
};
await visit(sandboxDirectory, "");
return files;
};
export const mirrorHostCheckoutToSandbox = async (options: {
readonly checkoutDirectory: string;
readonly limits?: Partial<RepositoryMirrorLimits>;
readonly sandbox: SandboxApi;
readonly sandboxDirectory: string;
}): Promise<void> => {
const checkoutDirectory = assertWithinCheckout(options.checkoutDirectory);
const sandboxDirectory = assertAbsoluteSandboxDirectory(
options.sandboxDirectory
);
const limits = resolveLimits(options.limits);
const files = await listHostFiles(checkoutDirectory, limits);
await options.sandbox.mkdir(sandboxDirectory, { recursive: true });
for (const file of files) {
const hostPath = path.join(
checkoutDirectory,
...file.path.split(path.posix.sep)
);
const sandboxPath = path.posix.join(sandboxDirectory, file.path);
// oxlint-disable-next-line no-await-in-loop -- bounded sequential writes avoid overwhelming remote sandboxes.
await options.sandbox.mkdir(path.posix.dirname(sandboxPath), {
recursive: true,
});
// oxlint-disable-next-line no-await-in-loop -- bounded sequential transfer limits peak memory.
const content = await readFile(hostPath);
if (content.byteLength !== file.size) {
throw new Error(`${file.path} changed while it was being mirrored`);
}
// oxlint-disable-next-line no-await-in-loop -- bounded sequential writes avoid overwhelming remote sandboxes.
await options.sandbox.writeFile(sandboxPath, content);
}
};
const clearCheckoutFiles = async (checkoutDirectory: string): Promise<void> => {
const entries = await readdir(checkoutDirectory);
for (const entry of entries) {
if (entry === ".git") {
continue;
}
// oxlint-disable-next-line no-await-in-loop -- deletion is deliberately scoped to checkout children.
await rm(path.join(checkoutDirectory, entry), {
force: true,
recursive: true,
});
}
};
export const mirrorSandboxToHostCheckout = async (options: {
readonly checkoutDirectory: string;
readonly limits?: Partial<RepositoryMirrorLimits>;
readonly sandbox: SandboxApi;
readonly sandboxDirectory: string;
}): Promise<void> => {
const checkoutDirectory = assertWithinCheckout(options.checkoutDirectory);
const sandboxDirectory = assertAbsoluteSandboxDirectory(
options.sandboxDirectory
);
const limits = resolveLimits(options.limits);
const files = await listSandboxFiles(
options.sandbox,
sandboxDirectory,
limits
);
const originalFiles = await listHostFiles(checkoutDirectory, limits);
const originalModes = new Map(
originalFiles.map((file) => [file.path, file.mode])
);
const stagingDirectory = await mkdtemp(
path.join(path.dirname(checkoutDirectory), ".zopu-sandbox-mirror-")
);
try {
let transferredBytes = 0;
for (const file of files) {
const sandboxPath = path.posix.join(sandboxDirectory, file.path);
// oxlint-disable-next-line no-await-in-loop -- bounded sequential reads limit peak memory.
const content = await options.sandbox.readFileBuffer(sandboxPath);
if (content.byteLength !== file.size) {
throw new Error(`${file.path} changed while it was being mirrored`);
}
transferredBytes += content.byteLength;
if (transferredBytes > limits.maxTotalBytes) {
throw new Error(
`Sandbox files exceed the ${limits.maxTotalBytes}-byte total limit`
);
}
const stagedPath = path.join(
stagingDirectory,
...file.path.split(path.posix.sep)
);
// oxlint-disable-next-line no-await-in-loop -- staged writes keep the checkout intact until transfer succeeds.
await mkdir(path.dirname(stagedPath), { recursive: true });
// oxlint-disable-next-line no-await-in-loop -- bounded sequential writes limit peak memory.
await writeFile(stagedPath, content);
const originalMode = originalModes.get(file.path);
if (originalMode !== undefined) {
// oxlint-disable-next-line no-await-in-loop -- preserve executable bits for existing files.
await chmod(stagedPath, originalMode);
}
}
await clearCheckoutFiles(checkoutDirectory);
const stagedEntries = await readdir(stagingDirectory);
for (const entry of stagedEntries) {
// oxlint-disable-next-line no-await-in-loop -- each top-level entry is moved exactly once.
await rename(
path.join(stagingDirectory, entry),
path.join(checkoutDirectory, entry)
);
}
} finally {
await rm(stagingDirectory, { force: true, recursive: true });
}
};

View File

@@ -0,0 +1,85 @@
import { readFileSync } from "node:fs";
import git from "@agentos-software/git";
import { describe, expect, it } from "vitest";
import {
getExecutableGitSoftware,
makeGitExecutablesRunnable,
} from "./agent-os-git-software";
const TAR_BLOCK_SIZE = 512;
const READ_ONLY_EXECUTABLE_MODE = Buffer.from([0xa4, 0x81, 0x00, 0x00]);
const RUNNABLE_EXECUTABLE_MODE = Buffer.from([0xed, 0x81, 0x00, 0x00]);
const readTarModes = (packageBytes: Buffer): Map<string, number> => {
const tarOffset =
16 + packageBytes.readUInt32LE(8) + packageBytes.readUInt32LE(12);
const modes = new Map<string, number>();
let offset = tarOffset;
while (
offset + TAR_BLOCK_SIZE <= packageBytes.length &&
packageBytes
.subarray(offset, offset + TAR_BLOCK_SIZE)
.some((byte) => byte !== 0)
) {
const [name = ""] = packageBytes
.subarray(offset, offset + 100)
.toString("utf-8")
.split("\0");
const mode = Number.parseInt(
packageBytes
.subarray(offset + 100, offset + 108)
.toString("ascii")
.replaceAll("\0", "")
.trim(),
8
);
const size = Number.parseInt(
packageBytes
.subarray(offset + 124, offset + 136)
.toString("ascii")
.replaceAll("\0", "")
.trim() || "0",
8
);
modes.set(name, mode);
offset +=
TAR_BLOCK_SIZE + Math.ceil(size / TAR_BLOCK_SIZE) * TAR_BLOCK_SIZE;
}
return modes;
};
describe("AgentOS Git software", () => {
it("marks the packaged Git commands executable", () => {
const repaired = makeGitExecutablesRunnable(readFileSync(git.packagePath));
const modes = readTarModes(repaired);
expect(modes.get("./bin/git")).toBe(0o755);
expect(modes.get("./bin/git-remote-http")).toBe(0o755);
expect(modes.get("./bin/git-remote-https")).toBe(0o755);
expect(repaired.includes(Buffer.from("0.3.0-zp.1", "ascii"))).toBe(true);
const mountIndexOffset = 16 + repaired.readUInt32LE(8);
const tarOffset = mountIndexOffset + repaired.readUInt32LE(12);
const mountIndex = repaired.subarray(mountIndexOffset, tarOffset);
expect(mountIndex.includes(READ_ONLY_EXECUTABLE_MODE)).toBe(false);
expect(
mountIndex.toString("hex").split(RUNNABLE_EXECUTABLE_MODE.toString("hex"))
.length - 1
).toBe(3);
});
it("writes a stable repaired package for the AgentOS registry", () => {
const first = getExecutableGitSoftware();
const second = getExecutableGitSoftware();
expect(second).toEqual(first);
expect(readTarModes(readFileSync(first.packagePath)).get("./bin/git")).toBe(
0o755
);
});
});

View File

@@ -0,0 +1,194 @@
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import git from "@agentos-software/git";
const AOS_PACKAGE_MAGIC = Buffer.from([0x89, 0x41, 0x4f, 0x53]);
const TAR_BLOCK_SIZE = 512;
const TAR_CHECKSUM_OFFSET = 148;
const TAR_CHECKSUM_SIZE = 8;
const TAR_MODE_OFFSET = 100;
const TAR_MODE_SIZE = 8;
const TAR_NAME_SIZE = 100;
const TAR_SIZE_OFFSET = 124;
const TAR_SIZE_SIZE = 12;
const GIT_EXECUTABLES = new Set([
"./bin/git",
"./bin/git-remote-http",
"./bin/git-remote-https",
]);
const UPSTREAM_GIT_PACKAGE_VERSION = Buffer.from("0.3.0-rc.2", "ascii");
const REPAIRED_GIT_PACKAGE_VERSION = Buffer.from("0.3.0-zp.1", "ascii");
const READ_ONLY_EXECUTABLE_MODE = Buffer.from([0xa4, 0x81, 0x00, 0x00]);
const RUNNABLE_EXECUTABLE_MODE = Buffer.from([0xed, 0x81, 0x00, 0x00]);
const parseTarOctal = (
packageBytes: Buffer,
offset: number,
length: number
): number => {
const value = packageBytes
.subarray(offset, offset + length)
.toString("ascii")
.replaceAll("\0", "")
.trim();
return value.length === 0 ? 0 : Number.parseInt(value, 8);
};
const readTarName = (packageBytes: Buffer, headerOffset: number): string => {
const [name = ""] = packageBytes
.subarray(headerOffset, headerOffset + TAR_NAME_SIZE)
.toString("utf-8")
.split("\0");
return name;
};
const isEmptyTarHeader = (
packageBytes: Buffer,
headerOffset: number
): boolean =>
packageBytes
.subarray(headerOffset, headerOffset + TAR_BLOCK_SIZE)
.every((byte) => byte === 0);
const writeTarChecksum = (packageBytes: Buffer, headerOffset: number): void => {
packageBytes.fill(
0x20,
headerOffset + TAR_CHECKSUM_OFFSET,
headerOffset + TAR_CHECKSUM_OFFSET + TAR_CHECKSUM_SIZE
);
let checksum = 0;
for (
let index = headerOffset;
index < headerOffset + TAR_BLOCK_SIZE;
index += 1
) {
checksum += packageBytes[index] ?? 0;
}
const encodedChecksum = `${checksum.toString(8).padStart(6, "0")}\0 `;
packageBytes.write(
encodedChecksum,
headerOffset + TAR_CHECKSUM_OFFSET,
TAR_CHECKSUM_SIZE,
"ascii"
);
};
const getTarOffset = (packageBytes: Buffer): number => {
if (
packageBytes.length < 16 ||
!packageBytes
.subarray(0, AOS_PACKAGE_MAGIC.length)
.equals(AOS_PACKAGE_MAGIC)
) {
throw new Error("Invalid AgentOS package header");
}
return 16 + packageBytes.readUInt32LE(8) + packageBytes.readUInt32LE(12);
};
export const makeGitExecutablesRunnable = (source: Uint8Array): Buffer => {
const packageBytes = Buffer.from(source);
const mountIndexOffset = 16 + packageBytes.readUInt32LE(8);
const tarOffset = getTarOffset(packageBytes);
const versionOffset = packageBytes.indexOf(
UPSTREAM_GIT_PACKAGE_VERSION,
0,
"ascii"
);
if (versionOffset === -1 || versionOffset >= tarOffset) {
throw new Error("Expected AgentOS Git package version was not found");
}
REPAIRED_GIT_PACKAGE_VERSION.copy(packageBytes, versionOffset);
let mountModeOffset = mountIndexOffset;
let repairedMountEntries = 0;
while (mountModeOffset < tarOffset) {
mountModeOffset = packageBytes.indexOf(
READ_ONLY_EXECUTABLE_MODE,
mountModeOffset
);
if (mountModeOffset < 0 || mountModeOffset >= tarOffset) {
break;
}
RUNNABLE_EXECUTABLE_MODE.copy(packageBytes, mountModeOffset);
repairedMountEntries += 1;
mountModeOffset += RUNNABLE_EXECUTABLE_MODE.length;
}
if (repairedMountEntries !== GIT_EXECUTABLES.size) {
throw new Error(
`Expected ${GIT_EXECUTABLES.size} Git mount entries, repaired ${repairedMountEntries}`
);
}
let headerOffset = tarOffset;
let repairedExecutables = 0;
while (
headerOffset + TAR_BLOCK_SIZE <= packageBytes.length &&
!isEmptyTarHeader(packageBytes, headerOffset)
) {
const name = readTarName(packageBytes, headerOffset);
const size = parseTarOctal(
packageBytes,
headerOffset + TAR_SIZE_OFFSET,
TAR_SIZE_SIZE
);
if (GIT_EXECUTABLES.has(name)) {
packageBytes.write(
"0000755\0",
headerOffset + TAR_MODE_OFFSET,
TAR_MODE_SIZE,
"ascii"
);
writeTarChecksum(packageBytes, headerOffset);
repairedExecutables += 1;
}
headerOffset +=
TAR_BLOCK_SIZE + Math.ceil(size / TAR_BLOCK_SIZE) * TAR_BLOCK_SIZE;
}
if (repairedExecutables !== GIT_EXECUTABLES.size) {
throw new Error(
`Expected ${GIT_EXECUTABLES.size} Git executables, repaired ${repairedExecutables}`
);
}
return packageBytes;
};
let cachedGitSoftware: { readonly packagePath: string } | undefined;
export const getExecutableGitSoftware = (): {
readonly packagePath: string;
} => {
if (cachedGitSoftware) {
return cachedGitSoftware;
}
const source = readFileSync(git.packagePath);
const repaired = makeGitExecutablesRunnable(source);
const digest = createHash("sha256")
.update(repaired)
.digest("hex")
.slice(0, 16);
const cacheDirectory = path.join(tmpdir(), "zopu-agentos-software");
const packagePath = path.join(cacheDirectory, `git-${digest}.aospkg`);
mkdirSync(cacheDirectory, { recursive: true });
if (!existsSync(packagePath)) {
writeFileSync(packagePath, repaired);
}
cachedGitSoftware = { packagePath };
return cachedGitSoftware;
};

View File

@@ -1,15 +1,16 @@
/* eslint-disable max-classes-per-file -- the AgentOS service and its tagged error form one adapter contract. */
import codex from "@agentos-software/codex-cli";
import git from "@agentos-software/git";
import { agentOS as createAgentOsActor } from "@rivet-dev/agentos";
import type { AgentOSConfigInput } from "@rivet-dev/agentos";
import { Context, Effect, Layer, Schema } from "effect";
import { getExecutableGitSoftware } from "./agent-os-git-software";
const withGitSoftware = (
config: AgentOSConfigInput<undefined>
): AgentOSConfigInput<undefined> => ({
...config,
software: [git, ...(config.software ?? [])],
software: [getExecutableGitSoftware(), ...(config.software ?? [])],
});
export class AgentOsError extends Schema.TaggedErrorClass<AgentOsError>()(
@@ -83,7 +84,7 @@ export const createAgentOsActorEffect = Effect.fn("createAgentOsActorEffect")(
// ---------------------------------------------------------------------------
/** Software bundle for a Codex-capable VM: the Codex harness plus git. */
export const codexSoftware = [codex, git] as const;
export const codexSoftware = [codex, getExecutableGitSoftware()] as const;
/** Model-provider env that Codex reads inside the VM. Pass this to the session's
* `env` when opening a Codex session (`agent: "codex"`). */

View File

@@ -73,6 +73,16 @@ describe("project workspace primitives", () => {
operation._tag === "Exec" && operation.command.includes("git clone")
)
).toBe(true);
const checkoutOperation = plan.operations.find(
(operation) => operation._tag === "Exec"
);
expect(checkoutOperation?._tag).toBe("Exec");
if (checkoutOperation?._tag === "Exec") {
expect(checkoutOperation.command).not.toContain("--single-branch");
expect(checkoutOperation.command).toContain(
"checkout -b 'work/issue-7-abc123'"
);
}
});
it("rejects unsafe sources and control-file paths", () => {

View File

@@ -260,17 +260,18 @@ const makeCheckoutCommand = (input: {
readonly sourceUrl: string;
}): string => {
const checkout = shellQuote(input.checkoutPath);
const baseBranch = shellQuote(input.baseBranch);
const branch = shellQuote(input.branchName);
const originBaseBranch = shellQuote(`origin/${input.baseBranch}`);
const branchRef = shellQuote(
`${input.checkoutPath}/.git/refs/heads/${input.branchName}`
);
const source = shellQuote(input.sourceUrl);
return [
"set -eu",
`mkdir -p ${shellQuote(WORKSPACE_PATH)}`,
`if [ ! -d ${checkout}/.git ]; then git clone --branch ${baseBranch} --single-branch ${source} ${checkout}; fi`,
`if [ "$(git -C ${checkout} branch --show-current)" != ${branch} ]; then git -C ${checkout} show-ref --verify --quiet refs/heads/${branch} && git -C ${checkout} checkout ${branch} || git -C ${checkout} checkout -b ${branch} ${originBaseBranch}; fi`,
`printf '%s\\n' "$(git -C ${checkout} branch --show-current)" "$(git -C ${checkout} rev-parse HEAD)"`,
`if [ ! -d ${checkout}/.git ]; then git clone ${source} ${checkout}; fi`,
`if [ -f ${branchRef} ]; then git -C ${checkout} checkout ${branch}; else git -C ${checkout} checkout -b ${branch}; fi`,
`printf '%s\\n' ${branch} "$(git -C ${checkout} rev-parse HEAD)"`,
].join(" && ");
};

View File

@@ -2,7 +2,9 @@ export {
MobileAssistantChatScreen,
MobileExpandedWorkScreen,
MobileHomeScreen,
MobileWorkChatScreen,
MobileWorkListScreen,
MobileWorkStackScreen,
MobileWorkUnitDetailScreen,
} from "./mobile-workspace/index";
export type {
@@ -12,6 +14,8 @@ export type {
MobileSignalView,
MobileWorkspaceScreenProps,
MobileWorkspaceView,
MobileWorkChatScreenProps,
MobileWorkStackScreenProps,
MobileWorkUnitTone,
MobileWorkUnitView,
} from "./mobile-workspace/index";

View File

@@ -1,7 +1,13 @@
export { MobileAssistantChatScreen } from "./mobile-assistant-chat-screen";
export { MobileExpandedWorkScreen } from "./mobile-expanded-work-screen";
export { MobileHomeScreen } from "./mobile-home-screen";
export { MobileSettingsPanel } from "./mobile-settings-panel";
export type { MobileSettingsPanelProps } from "./mobile-settings-panel";
export { MobileWorkChatScreen } from "./mobile-work-chat-screen";
export type { MobileWorkChatScreenProps } from "./mobile-work-chat-screen";
export { MobileWorkListScreen } from "./mobile-work-list-screen";
export { MobileWorkStackScreen } from "./mobile-work-stack-screen";
export type { MobileWorkStackScreenProps } from "./mobile-work-stack-screen";
export { MobileWorkUnitDetailScreen } from "./mobile-work-unit-detail-screen";
export type {
MobileAssistantMessageView,

View File

@@ -0,0 +1,83 @@
import { X } from "lucide-react";
export interface MobileSettingsPanelProps {
readonly modelId: string;
readonly modelLabel: string;
readonly onClose: () => void;
readonly onSignOut?: () => void;
readonly userEmail?: string;
readonly userName?: string;
}
export const MobileSettingsPanel = ({
modelId,
modelLabel,
onClose,
onSignOut,
userEmail,
userName,
}: MobileSettingsPanelProps) => (
<section
aria-label="Settings"
className="absolute inset-0 z-50 flex flex-col bg-[#f7f8f5] text-[#11110f]"
>
<header className="flex h-20 shrink-0 items-center border-b border-[#e9eae6] bg-white px-4">
<div>
<h2 className="text-[18px] font-semibold">Settings</h2>
<p className="text-[11px] text-[#807b76]">
Your account and Zopu runtime.
</p>
</div>
<button
aria-label="Close settings"
className="ml-auto flex size-10 items-center justify-center rounded-full bg-[#f2f3ef]"
onClick={onClose}
type="button"
>
<X className="size-5" />
</button>
</header>
<div className="space-y-3 p-4">
<div className="rounded-[22px] bg-white p-4">
<p className="text-[10px] font-semibold tracking-[0.08em] text-[#807b76] uppercase">
Account
</p>
<p className="mt-2 text-[15px] font-semibold">
{userName ?? "Signed-in user"}
</p>
{userEmail ? (
<p className="mt-0.5 text-[12px] text-[#807b76]">{userEmail}</p>
) : null}
</div>
<div className="rounded-[22px] bg-white p-4">
<label
className="text-[10px] font-semibold tracking-[0.08em] text-[#807b76] uppercase"
htmlFor="mobile-stack-model"
>
Chat model
</label>
<select
className="mt-2 h-11 w-full rounded-[14px] bg-[#f2f3ef] px-3 text-[13px] font-semibold outline-none"
disabled
id="mobile-stack-model"
value={modelId}
>
<option value={modelId}>{modelLabel}</option>
</select>
<p className="mt-2 text-[11px] leading-4 text-[#807b76]">
The configured OpenRouter model is used for Zopu conversations and
project work.
</p>
</div>
{onSignOut ? (
<button
className="h-12 w-full rounded-[16px] bg-black text-[13px] font-semibold text-white"
onClick={onSignOut}
type="button"
>
Sign out
</button>
) : null}
</div>
</section>
);

View File

@@ -0,0 +1,206 @@
import { cn } from "@code/ui/lib/utils";
import { ArrowLeft, ArrowUp, ExternalLink, Sparkles } from "lucide-react";
import type { FormEvent } from "react";
import {
Conversation,
ConversationContent,
ConversationScrollButton,
} from "../ai-elements/conversation";
import {
Message,
MessageContent,
MessageResponse,
} from "../ai-elements/message";
import {
PromptInput,
PromptInputBody,
PromptInputSubmit,
PromptInputTextarea,
} from "../ai-elements/prompt-input";
import type { MobileWorkspaceView } from "./models";
export interface MobileWorkChatScreenProps {
readonly composerValue: string;
readonly data: MobileWorkspaceView;
readonly onBack: () => void;
readonly onComposerChange: (value: string) => void;
readonly onComposerMessageSubmit?: (message: string) => Promise<void>;
readonly onComposerSubmit: (event: FormEvent<HTMLFormElement>) => void;
readonly statusMessage?: string;
}
export const MobileWorkChatScreen = ({
composerValue,
data,
onBack,
onComposerChange,
onComposerMessageSubmit,
onComposerSubmit,
statusMessage,
}: MobileWorkChatScreenProps) => {
const workUnit = data.selectedWorkUnit;
return (
<div className="mx-auto flex h-[100dvh] max-h-[858px] min-h-[700px] w-full max-w-[390px] flex-col overflow-hidden bg-white text-[#0b0b0a]">
<header className="flex h-20 shrink-0 items-center border-b border-[#eff0ec] bg-white px-4">
<button
aria-label="Back to daily focus"
className="flex size-10 items-center justify-center rounded-full bg-[#f2f3ef]"
onClick={onBack}
type="button"
>
<ArrowLeft className="size-5" />
</button>
<div className="min-w-0 flex-1 px-3 text-center">
<p className="truncate text-[15px] font-semibold">
{workUnit?.title ?? "Work chat"}
</p>
<p className="text-[10px] text-[#807b76]">
{workUnit
? `${workUnit.code} · ${workUnit.statusLabel}`
: "Loading work unit"}
</p>
</div>
{workUnit?.reviewUrl ? (
<a
aria-label={`Open pull request ${workUnit.pullRequestNumber ?? ""}`}
className="flex size-10 items-center justify-center rounded-full bg-[#c8ff00]"
href={workUnit.reviewUrl}
rel="noreferrer"
target="_blank"
>
<ExternalLink className="size-5" />
</a>
) : (
<span className="size-10" />
)}
</header>
<main className="min-h-0 flex-1 bg-[#fefefe]">
<Conversation className="h-full">
<ConversationContent className="min-h-full gap-5 px-4 pt-5 pb-6">
{workUnit ? (
<section className="rounded-[22px] bg-[#e8efff] p-4 text-[#14265f]">
<div className="flex items-center text-[10px] font-semibold text-[#315dc0]">
{workUnit.statusLabel.toUpperCase()}
<span className="ml-auto">{workUnit.progress}%</span>
</div>
<p className="mt-2 text-[17px] leading-5 font-semibold">
{workUnit.nextAction}
</p>
<p className="mt-1 line-clamp-2 text-[12px] leading-4 opacity-70">
{workUnit.summary}
</p>
{workUnit.reviewUrl ? (
<a
className="mt-4 flex h-10 items-center justify-center gap-2 rounded-[14px] bg-black text-[12px] font-semibold text-white"
href={workUnit.reviewUrl}
rel="noreferrer"
target="_blank"
>
Review PR #{workUnit.pullRequestNumber}
<ExternalLink className="size-4" />
</a>
) : null}
</section>
) : null}
<Message className="max-w-full gap-0" from="assistant">
<MessageContent className="w-full bg-transparent p-0 text-[#11110f] dark:text-[#11110f]">
<div className="flex items-center gap-2">
<span className="flex size-7 items-center justify-center rounded-[9px] bg-[#c8ff00]">
<Sparkles className="size-4" />
</span>
<strong className="text-[14px]">Zopu</strong>
<span className="rounded-full bg-[#f1f1ef] px-2 py-1 text-[9px] text-[#aaa7a3]">
WORK PARTNER
</span>
</div>
<p className="mt-3 text-[15px] leading-5">
This chat is scoped to {workUnit?.title ?? "this work unit"}.
Add context, change direction, or ask what happens next.
</p>
</MessageContent>
</Message>
{data.assistant.messages.map((message) => (
<Message
className="max-w-full gap-0"
from={message.role}
key={message.id}
>
<MessageContent
className={cn(
"max-w-full gap-0 overflow-visible p-0",
message.role === "user"
? "ml-auto w-fit max-w-[86%] rounded-[22px] bg-[#0c0c0b] px-4 py-3 text-[15px] leading-5 text-white group-[.is-user]:rounded-[22px] group-[.is-user]:bg-[#0c0c0b] group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-white"
: "w-full bg-transparent text-[#11110f] dark:text-[#11110f]"
)}
>
{message.role === "assistant" ? (
<>
<div className="flex items-center gap-2">
<span className="flex size-7 items-center justify-center rounded-[9px] bg-[#c8ff00]">
<Sparkles className="size-4" />
</span>
<strong className="text-[14px]">Zopu</strong>
</div>
<MessageResponse
className="mt-3 text-[15px] leading-5 text-[#11110f] dark:text-[#11110f]"
isAnimating={data.assistant.isBusy}
>
{message.text}
</MessageResponse>
</>
) : (
<span className="whitespace-pre-wrap">{message.text}</span>
)}
</MessageContent>
</Message>
))}
{data.assistant.isBusy ? (
<p className="text-[12px] text-[#807b76]">Zopu is working</p>
) : null}
</ConversationContent>
<ConversationScrollButton
aria-label="Scroll to latest message"
className="bottom-2 size-9 rounded-full border border-[#dededb] bg-white text-black shadow-sm"
/>
</Conversation>
</main>
<footer className="relative h-[106px] shrink-0 border-t border-[#eff0ec] bg-white px-3 pt-3">
<PromptInput
className="w-full [&_[data-slot=input-group]]:min-h-[58px] [&_[data-slot=input-group]]:rounded-full [&_[data-slot=input-group]]:border-0 [&_[data-slot=input-group]]:bg-[#f2f3ef] [&_[data-slot=input-group]]:px-3 [&_[data-slot=input-group]]:py-1.5 [&_[data-slot=input-group]]:shadow-none [&_[data-slot=input-group]]:dark:bg-[#f2f3ef]"
onSubmit={async (message) => {
if (onComposerMessageSubmit) {
await onComposerMessageSubmit(message.text);
return;
}
onComposerChange(message.text);
}}
>
<PromptInputBody>
<PromptInputTextarea
aria-label="Ask about this work unit"
className="max-h-20 min-h-10 flex-1 resize-none bg-transparent px-2 py-2.5 text-[14px] leading-5 shadow-none outline-none placeholder:text-[#aaa7a3] dark:bg-transparent"
onChange={(event) => onComposerChange(event.currentTarget.value)}
placeholder="Ask about this work unit..."
value={composerValue}
/>
<PromptInputSubmit
aria-label="Send"
className="size-11 shrink-0 rounded-full bg-black text-white hover:bg-black/85 disabled:bg-black disabled:text-white disabled:opacity-100"
disabled={!composerValue.trim() || data.assistant.isBusy}
size="icon-sm"
status={data.assistant.isBusy ? "streaming" : "ready"}
>
<ArrowUp className="size-5" />
</PromptInputSubmit>
</PromptInputBody>
</PromptInput>
<p className="mt-1 text-center text-[9px] text-[#b5b2ae]">
{statusMessage ?? "Messages stay with this work unit"}
</p>
<form className="sr-only" onSubmit={onComposerSubmit} />
</footer>
</div>
);
};

View File

@@ -0,0 +1,458 @@
import { useSwipeCard } from "@code/ui/hooks/use-swipe-card";
import { cn } from "@code/ui/lib/utils";
import {
ArrowUp,
Check,
ChevronRight,
MoreHorizontal,
Plus,
RotateCcw,
} from "lucide-react";
import type { FormEvent, MouseEvent } from "react";
import { MobileSettingsPanel } from "./mobile-settings-panel";
import type {
MobileWorkspaceView,
MobileWorkUnitTone,
MobileWorkUnitView,
} from "./models";
const toneClass: Readonly<Record<MobileWorkUnitTone, string>> = {
blue: "bg-[#e8efff] text-[#14265f]",
green: "bg-[#e8f7ef] text-[#105c35]",
orange: "bg-[#fff0e8] text-[#8a2e13]",
purple: "bg-[#f1e8ff] text-[#3e0b74]",
};
const statusClass: Readonly<Record<MobileWorkUnitTone, string>> = {
blue: "bg-[#d5e1ff] text-[#315dc0]",
green: "bg-[#d8f1e4] text-[#238050]",
orange: "bg-[#ffe0d0] text-[#c53c08]",
purple: "bg-[#e6d6ff] text-[#7137ef]",
};
const dateLabel = new Intl.DateTimeFormat("en", {
day: "numeric",
weekday: "short",
})
.format(new Date())
.toUpperCase();
interface WorkStackHeaderProps {
readonly activeCount: number;
readonly onOpenSettings?: () => void;
readonly userInitials: string;
}
const WorkStackHeader = ({
activeCount,
onOpenSettings,
userInitials,
}: WorkStackHeaderProps) => (
<header className="flex h-[96px] shrink-0 items-center bg-white px-4">
<div className="flex size-10 items-center justify-center rounded-[14px] bg-[#c8ff00] text-[20px] font-bold text-black">
Z
</div>
<div className="ml-3">
<h1 className="text-[20px] leading-6 font-semibold tracking-[-0.03em] text-[#0b0b0a]">
Zopu
</h1>
<p className="mt-0.5 flex items-center gap-2 text-[13px] leading-4 text-[#7c7772]">
<span className="size-1.5 rounded-full bg-[#64ad1f]" />
{activeCount} active work units
</p>
</div>
<button
aria-label="Open settings"
className="ml-auto flex size-10 items-center justify-center rounded-full bg-[#f2f3ef] text-black"
onClick={onOpenSettings}
type="button"
>
<MoreHorizontal className="size-5" />
</button>
<button
aria-label="Open profile"
className="ml-2 flex size-10 items-center justify-center rounded-full bg-[#0b0b0a] text-[13px] font-semibold text-white"
onClick={onOpenSettings}
type="button"
>
{userInitials}
</button>
</header>
);
interface SwipeWorkCardProps {
readonly data: MobileWorkspaceView;
readonly onChecked: (workUnitId: string) => void;
readonly onOpen: (workUnitId: string) => void;
readonly onSendBack: (workUnitId: string) => void;
readonly workUnit: MobileWorkUnitView;
}
const SwipeWorkCard = ({
data,
onChecked,
onOpen,
onSendBack,
workUnit,
}: SwipeWorkCardProps) => {
const swipeCard = useSwipeCard({
itemKey: workUnit.id,
onSwipe: (direction) => {
if (direction === "right") {
onChecked(workUnit.id);
return;
}
onSendBack(workUnit.id);
},
});
const {
backLabelRef,
cardRef,
checkedLabelRef,
dragged,
pointerHandlers,
swipe,
transitionHandlers,
} = swipeCard;
const handleOpen = (event: MouseEvent<HTMLButtonElement>) => {
if (dragged()) {
event.preventDefault();
return;
}
onOpen(workUnit.id);
};
return (
<div className="relative h-full touch-pan-y select-none">
<span
className="pointer-events-none absolute top-5 left-5 z-10 rounded-full bg-black px-3 py-1.5 text-[11px] font-semibold text-white opacity-0"
ref={backLabelRef}
>
SEND TO BACK
</span>
<span
className="pointer-events-none absolute top-5 right-5 z-10 rounded-full bg-[#c8ff00] px-3 py-1.5 text-[11px] font-semibold text-black opacity-0"
ref={checkedLabelRef}
>
CHECKED
</span>
<article
aria-label={`${workUnit.title}. Swipe right to check, left to send to back.`}
className={cn(
"relative h-full overflow-hidden rounded-[30px] p-5 shadow-[0_12px_38px_rgba(43,61,107,0.08)] will-change-transform",
toneClass[workUnit.tone]
)}
ref={cardRef}
{...transitionHandlers}
>
<div className="flex items-center">
<span
className={cn(
"rounded-full px-2.5 py-1.5 text-[10px] font-semibold",
statusClass[workUnit.tone]
)}
>
{workUnit.statusLabel.toUpperCase()}
</span>
<span className="ml-2 text-[10px] opacity-55">{workUnit.code}</span>
<MoreHorizontal className="ml-auto size-5 opacity-75" />
</div>
<button
className="mt-5 block w-full text-left focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#315dc0]"
onClick={handleOpen}
onKeyDown={(event) => {
if (event.key === "ArrowRight") {
swipe("right");
}
if (event.key === "ArrowLeft") {
swipe("left");
}
}}
type="button"
{...pointerHandlers}
>
<h3 className="line-clamp-2 text-[27px] leading-[30px] font-semibold tracking-[-0.045em]">
{workUnit.title}
</h3>
<p className="mt-2 truncate text-[13px] opacity-60">
Updated {workUnit.updatedLabel}
</p>
<div className="mt-3 border-t border-current/10 pt-3">
<p className="text-[10px] font-semibold opacity-60">
CURRENT STATE
</p>
<p className="mt-1 line-clamp-2 text-[14px] leading-5 opacity-80">
{workUnit.summary}
</p>
</div>
<div className="mt-5 h-[7px] rounded-full bg-current/10">
<div
className="h-full rounded-full bg-current"
style={{ width: `${workUnit.progress}%` }}
/>
</div>
<div className="mt-2 flex text-[10px] font-semibold">
<span>{workUnit.progress}% COMPLETE</span>
<span className="ml-auto font-normal opacity-60">
{workUnit.canRetry ? "Needs attention" : "On track"}
</span>
</div>
</button>
<div className="absolute inset-x-5 bottom-5 flex items-center border-t border-current/10 pt-4">
<button
aria-label={`Mark ${workUnit.title} checked`}
className="flex size-10 shrink-0 items-center justify-center rounded-[14px] bg-white text-[#315dc0] active:scale-[0.98]"
onClick={() => swipe("right")}
type="button"
>
<Check className="size-5" />
</button>
<div className="ml-3 min-w-0 flex-1">
<p className="text-[10px] font-semibold opacity-55">NEXT ACTION</p>
<p className="truncate text-[14px] font-semibold">
{workUnit.nextAction}
</p>
<p className="text-[10px] opacity-55">
{data.artifactCount} artifacts available
</p>
</div>
<button
className="ml-3 flex h-12 shrink-0 items-center gap-1 rounded-[17px] bg-[#c8ff00] px-4 text-[12px] font-semibold text-black active:scale-[0.98]"
onClick={() => onOpen(workUnit.id)}
type="button"
>
Open unit
<ChevronRight className="size-4" />
</button>
</div>
</article>
</div>
);
};
interface WorkStackComposerProps {
readonly onChange: (value: string) => void;
readonly onSubmit: (event: FormEvent<HTMLFormElement>) => void;
readonly statusMessage?: string;
readonly value: string;
}
const WorkStackComposer = ({
onChange,
onSubmit,
statusMessage,
value,
}: WorkStackComposerProps) => (
<footer className="relative h-[112px] shrink-0 border-t border-[#eff0ec] bg-white px-[14px] pt-[14px]">
<form
className="flex h-[60px] items-center rounded-[30px] bg-[#f2f3ef] p-2"
onSubmit={onSubmit}
>
<button
aria-label="Add attachment"
className="flex size-11 shrink-0 items-center justify-center rounded-full bg-white text-[#5e5a56]"
type="button"
>
<Plus className="size-5" strokeWidth={1.8} />
</button>
<label className="ml-2 min-w-0 flex-1">
<span className="sr-only">Ask Zopu or create new work</span>
<textarea
className="block h-5 w-full resize-none overflow-hidden bg-transparent text-[14px] leading-5 text-[#282622] outline-none placeholder:text-[#b3afab]"
onChange={(event) => onChange(event.target.value)}
placeholder="Ask Zopu or create new work..."
rows={1}
value={value}
/>
<span className="block truncate text-[10px] leading-4 text-[#c0bcb7]">
{statusMessage ?? "Use @ to reference a work unit"}
</span>
</label>
<button
aria-label="Send"
className="ml-2 flex size-11 shrink-0 items-center justify-center rounded-full bg-black text-white active:scale-[0.98]"
type="submit"
>
<ArrowUp className="size-5" strokeWidth={2.2} />
</button>
</form>
<span className="absolute bottom-[10px] left-1/2 h-[5px] w-[120px] -translate-x-1/2 rounded-full bg-black" />
</footer>
);
export interface MobileWorkStackScreenProps {
readonly checkedCount: number;
readonly composerValue: string;
readonly data: MobileWorkspaceView;
readonly modelId: string;
readonly modelLabel: string;
readonly onComposerChange: (value: string) => void;
readonly onComposerSubmit: (event: FormEvent<HTMLFormElement>) => void;
readonly onOpenUnit: (workUnitId: string) => void;
readonly onRestoreChecked: () => void;
readonly onSettingsClose: () => void;
readonly onSettingsOpen: () => void;
readonly onSignOut?: () => void;
readonly onWorkUnitChecked: (workUnitId: string) => void;
readonly onWorkUnitSentBack: (workUnitId: string) => void;
readonly settingsOpen: boolean;
readonly statusMessage?: string;
readonly userEmail?: string;
readonly userInitials: string;
readonly userName?: string;
readonly workUnits: readonly MobileWorkUnitView[];
}
export const MobileWorkStackScreen = ({
checkedCount,
composerValue,
data,
modelId,
modelLabel,
onComposerChange,
onComposerSubmit,
onOpenUnit,
onRestoreChecked,
onSettingsClose,
onSettingsOpen,
onSignOut,
onWorkUnitChecked,
onWorkUnitSentBack,
settingsOpen,
statusMessage,
userEmail,
userInitials,
userName,
workUnits,
}: MobileWorkStackScreenProps) => {
const [currentWorkUnit, ...queuedWorkUnits] = workUnits;
const previewWorkUnits = queuedWorkUnits.slice(0, 2).toReversed();
const attentionLabel =
data.needsAttentionCount === 1
? "1 item needs attention."
: `${data.needsAttentionCount} items need attention.`;
return (
<div className="relative mx-auto flex h-[100dvh] max-h-[858px] min-h-[700px] w-full max-w-[390px] flex-col overflow-hidden bg-white text-[#0b0b0a]">
<WorkStackHeader
activeCount={data.activeCount}
onOpenSettings={onSettingsOpen}
userInitials={userInitials}
/>
<main className="relative min-h-0 flex-1 overflow-hidden bg-[#f7f8f5] px-5 pt-[26px]">
<div className="flex items-center">
<h2 className="text-[31px] leading-9 font-semibold tracking-[-0.055em]">
Daily focus
</h2>
<span className="ml-auto rounded-full bg-white px-3 py-1.5 text-[11px] font-semibold text-[#5f5b57]">
{dateLabel}
</span>
</div>
<p className="mt-1 text-[14px] leading-5 text-[#807b76]">
{data.needsAttentionCount > 0
? `One priority is moving. ${attentionLabel}`
: "Your active work is moving."}
</p>
<section
aria-label="Active work stack"
className="absolute inset-x-5 top-[154px] bottom-11"
>
{previewWorkUnits.map((workUnit, reverseIndex) => {
const depth = previewWorkUnits.length - reverseIndex;
return (
<button
aria-label={`Open ${workUnit.title}`}
className={cn(
"absolute inset-x-5 h-[112px] rounded-[28px] px-4 pt-4 text-left focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-black",
toneClass[workUnit.tone]
)}
key={workUnit.id}
onClick={() => onOpenUnit(workUnit.id)}
style={{
top: `${(depth - 1) * 36}px`,
transform: `scale(${1 - depth * 0.025})`,
}}
type="button"
>
<span
className={cn(
"rounded-full px-2.5 py-1.5 text-[10px] font-semibold",
statusClass[workUnit.tone]
)}
>
{workUnit.statusLabel.toUpperCase()}
</span>
<span className="ml-2 text-[10px] opacity-55">
{workUnit.code}
</span>
</button>
);
})}
{currentWorkUnit ? (
<div
className="absolute inset-x-0 bottom-0 h-[calc(100%-58px)]"
key={currentWorkUnit.id}
>
<SwipeWorkCard
data={data}
onChecked={onWorkUnitChecked}
onOpen={onOpenUnit}
onSendBack={onWorkUnitSentBack}
workUnit={currentWorkUnit}
/>
</div>
) : (
<div className="absolute inset-x-0 top-16 rounded-[30px] bg-white px-6 py-12 text-center">
<Check className="mx-auto size-8 text-[#64ad1f]" />
<h3 className="mt-3 text-[20px] font-semibold">
Daily focus cleared
</h3>
<p className="mt-1 text-[13px] text-[#807b76]">
{checkedCount} work units checked for now.
</p>
<button
className="mt-5 inline-flex h-10 items-center gap-2 rounded-[14px] bg-black px-4 text-[12px] font-semibold text-white"
onClick={onRestoreChecked}
type="button"
>
<RotateCcw className="size-4" />
Restore stack
</button>
</div>
)}
</section>
{workUnits.length > 0 ? (
<div className="absolute inset-x-0 bottom-4 flex justify-center gap-2">
{workUnits.slice(0, 4).map((workUnit, index) => (
<span
className={cn(
"h-1.5 rounded-full bg-[#d7d6d4]",
index === 0 ? "w-6 bg-black" : "w-1.5"
)}
key={workUnit.id}
/>
))}
</div>
) : null}
</main>
<WorkStackComposer
onChange={onComposerChange}
onSubmit={onComposerSubmit}
statusMessage={statusMessage}
value={composerValue}
/>
{settingsOpen ? (
<MobileSettingsPanel
modelId={modelId}
modelLabel={modelLabel}
onClose={onSettingsClose}
onSignOut={onSignOut}
userEmail={userEmail}
userName={userName}
/>
) : null}
</div>
);
};

View File

@@ -0,0 +1,156 @@
import type {
PointerEvent as ReactPointerEvent,
TransitionEvent as ReactTransitionEvent,
} from "react";
import { useCallback, useEffect, useRef } from "react";
export type SwipeDirection = "left" | "right";
interface UseSwipeCardOptions {
readonly itemKey: string;
readonly onSwipe: (direction: SwipeDirection) => void;
}
const SWIPE_THRESHOLD = 82;
const EXIT_DISTANCE = 460;
const EXIT_DURATION_MS = 180;
export const useSwipeCard = ({ itemKey, onSwipe }: UseSwipeCardOptions) => {
const cardRef = useRef<HTMLElement>(null);
const checkedLabelRef = useRef<HTMLSpanElement>(null);
const backLabelRef = useRef<HTMLSpanElement>(null);
const startXRef = useRef(0);
const distanceRef = useRef(0);
const draggingRef = useRef(false);
const swipingRef = useRef(false);
const pendingDirectionRef = useRef<SwipeDirection | null>(null);
const exitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const updateVisuals = useCallback((distance: number, animate = false) => {
const card = cardRef.current;
if (!card) {
return;
}
card.style.transition = animate
? `transform ${EXIT_DURATION_MS}ms cubic-bezier(0.16, 1, 0.3, 1)`
: "none";
card.style.transform = `translate3d(${distance}px, 0, 0) rotate(${distance / 32}deg)`;
const actionOpacity = Math.min(Math.abs(distance) / SWIPE_THRESHOLD, 1);
if (checkedLabelRef.current) {
checkedLabelRef.current.style.opacity =
distance > 0 ? String(actionOpacity) : "0";
}
if (backLabelRef.current) {
backLabelRef.current.style.opacity =
distance < 0 ? String(actionOpacity) : "0";
}
}, []);
const reset = useCallback(() => {
if (exitTimerRef.current) {
clearTimeout(exitTimerRef.current);
exitTimerRef.current = null;
}
distanceRef.current = 0;
draggingRef.current = false;
swipingRef.current = false;
pendingDirectionRef.current = null;
updateVisuals(0, true);
}, [updateVisuals]);
const finishSwipe = useCallback(() => {
const direction = pendingDirectionRef.current;
if (!direction) {
return;
}
pendingDirectionRef.current = null;
if (exitTimerRef.current) {
clearTimeout(exitTimerRef.current);
exitTimerRef.current = null;
}
onSwipe(direction);
}, [onSwipe]);
const swipe = useCallback(
(direction: SwipeDirection) => {
if (swipingRef.current) {
return;
}
swipingRef.current = true;
pendingDirectionRef.current = direction;
const distance = direction === "right" ? EXIT_DISTANCE : -EXIT_DISTANCE;
distanceRef.current = distance;
updateVisuals(distance, true);
exitTimerRef.current = setTimeout(finishSwipe, EXIT_DURATION_MS + 80);
},
[finishSwipe, updateVisuals]
);
useEffect(() => {
reset();
return reset;
}, [itemKey, reset]);
const onPointerDown = (event: ReactPointerEvent<HTMLElement>) => {
if (
swipingRef.current ||
(event.pointerType === "mouse" && event.button !== 0)
) {
return;
}
draggingRef.current = true;
startXRef.current = event.clientX;
distanceRef.current = 0;
event.currentTarget.setPointerCapture(event.pointerId);
};
const onPointerMove = (event: ReactPointerEvent<HTMLElement>) => {
if (!draggingRef.current) {
return;
}
distanceRef.current = event.clientX - startXRef.current;
updateVisuals(distanceRef.current);
};
const onPointerUp = (event: ReactPointerEvent<HTMLElement>) => {
if (!draggingRef.current) {
return;
}
draggingRef.current = false;
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
if (Math.abs(distanceRef.current) >= SWIPE_THRESHOLD) {
swipe(distanceRef.current > 0 ? "right" : "left");
return;
}
reset();
};
const onTransitionEnd = (event: ReactTransitionEvent<HTMLElement>) => {
if (
event.currentTarget === event.target &&
event.propertyName === "transform" &&
swipingRef.current
) {
finishSwipe();
}
};
return {
backLabelRef,
cardRef,
checkedLabelRef,
dragged: () => Math.abs(distanceRef.current) > 8,
pointerHandlers: {
onPointerCancel: reset,
onPointerDown,
onPointerMove,
onPointerUp,
},
swipe,
transitionHandlers: {
onTransitionEnd,
},
} as const;
};