chore: checkpoint current worktree state

This commit is contained in:
Mohamed Boudra
2026-04-11 13:09:51 +07:00
parent 3b8cfcbec6
commit aa1d33f0a0
7 changed files with 246 additions and 301 deletions
@@ -0,0 +1,158 @@
# Attachment-Based Review Context Plan
## Goal
Use structured attachments as the source of truth for review context during agent creation.
This covers two related behaviors:
1. Provider-facing context for the agent prompt
2. Worktree checkout behavior when creating a worktree from a review item
The design should stay compatible with future forge support such as GitLab.
## Core Direction
- Do not parse the prompt to discover issue/PR intent.
- Do not introduce deeply nested transport objects for this flow.
- Make the attachment itself the discriminated union.
- Keep forge-specific checkout logic below the attachment layer.
- Let each agent provider translate attachments into its own prompt/input format.
## Initial Attachment Types
Two initial structured attachment types:
- `github_pr`
- `github_issue`
With separate MIME types:
- `application/github-pr`
- `application/github-issue`
Proposed wire shape:
```ts
type AgentAttachment =
| {
type: "github_pr";
mimeType: "application/github-pr";
number: number;
title: string;
url: string;
body?: string | null;
baseRefName?: string | null;
headRefName?: string | null;
}
| {
type: "github_issue";
mimeType: "application/github-issue";
number: number;
title: string;
url: string;
body?: string | null;
};
```
## Backward Compatibility
- Add new optional `attachments` fields; keep existing `images` fields.
- Unknown attachment discriminators must be dropped during schema normalization instead of failing the full request.
- Malformed attachment entries should also be ignored safely.
- Old clients continue working because `attachments` is optional.
- New clients can send `attachments` without breaking older flows that still rely on `images`.
## Request-Level Changes
Add optional `attachments` to:
- `create_agent_request`
- `send_agent_message_request`
Existing `initialPrompt` remains plain user text.
The selected GitHub PR/issue becomes a structured attachment instead of being injected into prompt text as the primary source of truth.
## App Responsibilities
When the user selects a GitHub item:
- If it is a PR, create a `github_pr` attachment
- If it is an issue, create a `github_issue` attachment
- Include the attachment in the create-agent request
The UI can still render friendly labels and previews from the same data.
The app should stop treating PR/issue context as prompt-only metadata.
## Worktree Creation Behavior
During agent creation, if worktree creation is requested:
- Inspect normalized attachments
- If a `github_pr` attachment is present, use it to drive checkout for the worktree
The important rule:
- attachment type identifies the review object
- server-side git logic decides how to check it out
This keeps the attachment contract simple while allowing fork-safe implementation details.
## Checkout Resolution
The attachment itself should not encode the full checkout strategy.
Instead, the server should resolve checkout from the `github_pr` attachment using forge-aware logic.
Examples of possible implementation strategies:
- `gh pr checkout`
- `git fetch origin refs/pull/<number>/head:<local-branch>`
- another GitHub-aware resolver if needed later
This is intentionally a server implementation detail, not part of the attachment schema.
## Provider Responsibilities
Each provider adapter should receive normalized attachments and decide how to represent them for the model.
Examples:
- Claude: render attachment into text blocks
- Codex: inject as blocks or text
- OpenCode: send as file/resource-like input where appropriate
- ACP: convert to resource/text forms as supported
The session layer should not hardcode one translation strategy for all providers.
## Suggested Implementation Order
1. Add tolerant attachment schema and normalization in shared/server message handling.
2. Thread `attachments` through app -> daemon client -> session layer.
3. Update create-agent UI flows to emit `github_pr` / `github_issue` attachments from GitHub selection.
4. Teach worktree creation to inspect attachments and special-case `github_pr`.
5. Update provider adapters to translate attachments into provider-specific prompt/input forms.
6. Optionally migrate image transport into the same attachment mechanism later.
## Files Likely Involved
- `packages/server/src/shared/messages.ts`
- `packages/server/src/client/daemon-client.ts`
- `packages/server/src/server/session.ts`
- `packages/server/src/server/worktree-session.ts`
- `packages/server/src/server/agent/agent-sdk-types.ts`
- `packages/server/src/server/agent/providers/claude-agent.ts`
- `packages/server/src/server/agent/providers/codex-app-server-agent.ts`
- `packages/server/src/server/agent/providers/opencode-agent.ts`
- `packages/server/src/server/agent/providers/acp-agent.ts`
- `packages/app/src/screens/new-workspace-screen.tsx`
- `packages/app/src/screens/agent/draft-agent-screen.tsx`
- `packages/app/src/contexts/session-context.tsx`
## Notes
- The current image path can remain in place initially.
- The plan intentionally keeps checkout concerns separate from provider prompt translation.
- Future forge support should add new attachment discriminators rather than expanding GitHub-only nested fields.
@@ -6,11 +6,10 @@ import {
type PropsWithChildren,
type ReactElement,
} from "react";
import { useMutation } from "@tanstack/react-query";
import { Dimensions, Platform, Text, View } from "react-native";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ExternalLink, Globe, LoaderCircle, Play, SquareTerminal } from "lucide-react-native";
import { ExternalLink } from "lucide-react-native";
import { GitHubIcon } from "@/components/icons/github-icon";
import { DiffStat } from "@/components/diff-stat";
import { Pressable } from "react-native";
@@ -18,10 +17,7 @@ import { Portal } from "@gorhom/portal";
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import type { PrHint } from "@/hooks/use-checkout-pr-status-query";
import { useToast } from "@/contexts/toast-context";
import { useSessionStore } from "@/stores/session-store";
import { openExternalUrl } from "@/utils/open-external-url";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import { PrBadge } from "@/components/sidebar-workspace-list";
interface Rect {
@@ -108,8 +104,7 @@ function WorkspaceHoverCardDesktop({
const triggerHoveredRef = useRef(false);
const contentHoveredRef = useRef(false);
const hasScripts = workspace.scripts.length > 0;
const hasContent = hasScripts || prHint !== null;
const hasContent = prHint !== null || !!workspace.diffStat;
const clearGraceTimer = useCallback(() => {
if (graceTimerRef.current) {
@@ -195,32 +190,6 @@ function WorkspaceHoverCardDesktop({
);
}
function getScriptHealthColor(input: {
health: SidebarWorkspaceEntry["scripts"][number]["health"];
theme: ReturnType<typeof useUnistyles>["theme"];
}): string {
if (input.health === "healthy") {
return input.theme.colors.palette.blue[500];
}
if (input.health === "unhealthy") {
return input.theme.colors.palette.red[500];
}
return input.theme.colors.foregroundMuted;
}
function getScriptHealthLabel(
health: SidebarWorkspaceEntry["scripts"][number]["health"],
): "Healthy" | "Unhealthy" | "Unknown" {
if (health === "healthy") {
return "Healthy";
}
if (health === "unhealthy") {
return "Unhealthy";
}
return "Unknown";
}
function WorkspaceHoverCardContent({
workspace,
prHint,
@@ -235,39 +204,10 @@ function WorkspaceHoverCardContent({
onContentLeave: () => void;
}): ReactElement | null {
const { theme } = useUnistyles();
const toast = useToast();
const client = useSessionStore((state) => state.sessions[workspace.serverId]?.client ?? null);
const bottomSheetInternal = useBottomSheetModalInternal(true);
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
const startScriptMutation = useMutation({
mutationFn: async (scriptName: string) => {
if (!client) {
throw new Error("Daemon client not available");
}
const result = await client.startWorkspaceScript(workspace.workspaceId, scriptName);
if (result.error) {
throw new Error(result.error);
}
return result;
},
onSuccess: (data) => {
if (data.terminalId) {
navigateToPreparedWorkspaceTab({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
target: { kind: "terminal", terminalId: data.terminalId },
});
}
},
onError: (error, scriptName) => {
toast.show(
error instanceof Error ? error.message : `Failed to start ${scriptName}`,
{ variant: "error" },
);
},
});
// Measure trigger — same pattern as tooltip.tsx
useEffect(() => {
@@ -345,167 +285,6 @@ function WorkspaceHoverCardContent({
{prHint ? <PrBadge hint={prHint} /> : null}
</View>
) : null}
{workspace.scripts.length > 0 ? (
<>
<View style={styles.separator} />
<View style={styles.sectionLabelRow}>
<Play size={12} color={theme.colors.foregroundMuted} fill="transparent" />
<Text style={styles.sectionLabel}>Scripts</Text>
</View>
<View style={styles.sectionList} testID="hover-card-script-list">
{workspace.scripts.map((script) => {
const isRunning = script.lifecycle === "running";
const isService = (script.type ?? "service") === "service";
const isLinkable = isService && isRunning && !!script.url;
const exitCode = script.exitCode ?? null;
let dotColor: string;
if (isService) {
dotColor = isRunning
? getScriptHealthColor({ health: script.health, theme })
: theme.colors.foregroundMuted;
} else if (isRunning) {
dotColor = theme.colors.palette.blue[500];
} else if (exitCode === 0) {
dotColor = theme.colors.palette.green[500];
} else if (exitCode !== null) {
dotColor = theme.colors.palette.red[500];
} else {
dotColor = theme.colors.foregroundMuted;
}
let accessibilityStatus: string;
if (isService) {
accessibilityStatus = isRunning ? getScriptHealthLabel(script.health) : "Stopped";
} else if (isRunning) {
accessibilityStatus = "Running";
} else if (exitCode === 0) {
accessibilityStatus = "Completed";
} else if (exitCode !== null) {
accessibilityStatus = `Failed (exit ${exitCode})`;
} else {
accessibilityStatus = "Stopped";
}
return (
<Pressable
key={script.hostname}
accessibilityRole={isLinkable ? "link" : undefined}
accessibilityLabel={`${script.scriptName} script — ${accessibilityStatus}`}
testID={`hover-card-script-${script.scriptName}`}
style={({ hovered }) => [
styles.listRow,
hovered && isLinkable && styles.listRowHovered,
]}
onPress={isLinkable ? () => void openExternalUrl(script.url!) : undefined}
disabled={!isLinkable}
>
{({ hovered }) => (
<>
{isService ? (
<Globe
size={14}
color={dotColor}
testID={`hover-card-script-health-${script.scriptName}`}
style={styles.scriptIcon}
/>
) : (
<SquareTerminal
size={14}
color={dotColor}
testID={`hover-card-script-health-${script.scriptName}`}
style={styles.scriptIcon}
/>
)}
<Text
style={[
styles.listRowLabel,
{
color: isRunning
? theme.colors.foreground
: theme.colors.foregroundMuted,
},
]}
numberOfLines={1}
>
{script.scriptName}
</Text>
{isService && isRunning && script.url ? (
<Text style={styles.listRowSecondary} numberOfLines={1}>
{script.url.replace(/^https?:\/\//, "")}
</Text>
) : !isService && !isRunning && exitCode !== null && exitCode !== 0 ? (
<Text style={styles.listRowSecondary} numberOfLines={1}>
exit {exitCode}
</Text>
) : (
<View style={styles.listRowSpacer} />
)}
{isRunning ? (
isLinkable && hovered ? (
<View
style={[
styles.externalLinkOverlay,
{
backgroundImage: `linear-gradient(to right, transparent, ${theme.colors.surface2} 40%)`,
},
]}
>
<ExternalLink
size={12}
color={theme.colors.foreground}
/>
</View>
) : null
) : (
<Pressable
accessibilityRole="button"
accessibilityLabel={`Run ${script.scriptName} script`}
testID={`hover-card-script-start-${script.scriptName}`}
hitSlop={4}
disabled={startScriptMutation.isPending}
onPress={(event) => {
event.stopPropagation();
startScriptMutation.mutate(script.scriptName);
}}
style={styles.startButton}
>
{({ hovered: actionHovered }) =>
startScriptMutation.isPending &&
startScriptMutation.variables === script.scriptName ? (
<LoaderCircle size={12} color={theme.colors.foregroundMuted} />
) : (
<>
<Play
size={10}
color={actionHovered ? theme.colors.foreground : theme.colors.foregroundMuted}
fill="transparent"
/>
<Text
style={[
styles.startButtonLabel,
{
color: actionHovered
? theme.colors.foreground
: theme.colors.foregroundMuted,
},
]}
>
Run
</Text>
</>
)
}
</Pressable>
)}
</>
)}
</Pressable>
);
})}
</View>
</>
) : null}
{prHint?.checks && prHint.checks.length > 0 ? (
<>
<View style={styles.separator} />
@@ -619,30 +398,6 @@ const styles = StyleSheet.create((theme) => ({
height: 1,
backgroundColor: theme.colors.border,
},
sectionLabelRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1.5],
paddingHorizontal: theme.spacing[3],
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[1],
},
sectionLabel: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
},
sectionList: {
paddingBottom: theme.spacing[1],
},
listRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: 6,
minHeight: 28,
},
listRowHovered: {
backgroundColor: theme.colors.surface2,
},
@@ -656,27 +411,6 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
justifyContent: "center",
},
listRowLabel: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
lineHeight: 18,
flexShrink: 0,
},
listRowSecondary: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
lineHeight: 18,
flex: 1,
minWidth: 0,
textAlign: "right",
},
listRowSpacer: {
flex: 1,
minWidth: 0,
},
scriptIcon: {
flexShrink: 0,
},
checksSummaryRow: {
flexDirection: "row",
alignItems: "center",
@@ -706,13 +440,4 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
},
startButton: {
flexDirection: "row",
alignItems: "center",
gap: 3,
},
startButtonLabel: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
},
}));
@@ -49,6 +49,7 @@ const styles = StyleSheet.create((theme) => ({
? {
overflowX: "auto",
overflowY: "auto",
WebkitAppRegion: "no-drag",
}
: null),
},
+1 -1
View File
@@ -20,7 +20,7 @@ echo " Metro: http://localhost:${EXPO_PORT}"
echo "══════════════════════════════════════════════════════"
# Launch Metro + Electron together, kill both on exit
"$ROOT_DIR/node_modules/.bin/concurrently" \
exec "$ROOT_DIR/node_modules/.bin/concurrently" \
--kill-others \
--names "metro,electron" \
--prefix-colors "magenta,cyan" \
+51 -19
View File
@@ -209,6 +209,8 @@ async function resolveStatus(): Promise<DesktopDaemonStatus> {
error: null,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logDesktopDaemonLifecycle("resolveStatus CLI command failed", { error: errorMessage });
return {
serverId: "",
status: "stopped",
@@ -218,7 +220,7 @@ async function resolveStatus(): Promise<DesktopDaemonStatus> {
home,
version: null,
desktopManaged: false,
error: error instanceof Error ? error.message : String(error),
error: errorMessage,
};
}
}
@@ -231,6 +233,14 @@ function normalizeVersion(version: string | null): string | null {
async function startDaemon(): Promise<DesktopDaemonStatus> {
const current = await resolveStatus();
logDesktopDaemonLifecycle("initial status check before start", {
status: current.status,
pid: current.pid,
listen: current.listen,
serverId: current.serverId || null,
error: current.error,
desktopManaged: current.desktopManaged,
});
if (current.status === "running") {
const appVersion = normalizeVersion(resolveDesktopAppVersion());
const daemonVersion = normalizeVersion(current.version);
@@ -269,7 +279,16 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
const child: ChildProcess = spawnProcess(invocation.command, invocation.args, {
detached: true,
env: { ...invocation.env, PASEO_DESKTOP_MANAGED: "1" },
stdio: ["ignore", "ignore", "ignore"],
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout!.on("data", (data: Buffer) => {
stdout += data.toString();
});
child.stderr!.on("data", (data: Buffer) => {
stderr += data.toString();
});
logDesktopDaemonLifecycle("detached spawn returned", {
@@ -280,41 +299,54 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
child.unref();
// Wait for process to survive the grace period
const exitedEarly = await new Promise<boolean>((resolve) => {
type GraceResult =
| { exitedEarly: false }
| { exitedEarly: true; code: number | null; signal: string | null; error?: Error };
const result = await new Promise<GraceResult>((resolve) => {
let settled = false;
const finish = (value: boolean) => {
const finish = (value: GraceResult) => {
if (settled) return;
settled = true;
resolve(value);
};
const timer = setTimeout(() => finish(false), DETACHED_STARTUP_GRACE_MS);
const timer = setTimeout(() => finish({ exitedEarly: false }), DETACHED_STARTUP_GRACE_MS);
child.once("error", () => {
logDesktopDaemonLifecycle("detached child emitted error during grace period", {
childPid: child.pid ?? null,
});
child.once("error", (error) => {
clearTimeout(timer);
finish(true);
finish({ exitedEarly: true, code: null, signal: null, error });
});
child.once("exit", () => {
logDesktopDaemonLifecycle("detached child emitted exit during grace period", {
childPid: child.pid ?? null,
});
child.once("exit", (code, signal) => {
clearTimeout(timer);
finish(true);
finish({ exitedEarly: true, code, signal });
});
});
logDesktopDaemonLifecycle("detached startup grace period completed", {
childPid: child.pid ?? null,
exitedEarly,
exitedEarly: result.exitedEarly,
...(result.exitedEarly
? {
exitCode: result.code,
signal: result.signal,
error: result.error?.message ?? null,
stdout: stdout.slice(0, 2000),
stderr: stderr.slice(0, 2000),
}
: {}),
});
if (exitedEarly) {
if (result.exitedEarly) {
const reason = result.error
? result.error.message
: `exit code ${result.code ?? "unknown"}${result.signal ? ` (${result.signal})` : ""}`;
const parts = [`Daemon failed to start: ${reason}`];
if (stderr.trim()) parts.push(`stderr:\n${stderr.trim()}`);
if (stdout.trim()) parts.push(`stdout:\n${stdout.trim()}`);
const logs = tailFile(logFilePath(), 15);
throw new Error(`Daemon failed to start.${logs ? `\n\nRecent logs:\n${logs}` : ""}`);
if (logs) parts.push(`Recent logs (${logFilePath()}):\n${logs}`);
throw new Error(parts.join("\n\n"));
}
// Poll for PID file with server ID
+32 -3
View File
@@ -4,6 +4,7 @@ import { spawnProcess } from "@getpaseo/server";
import { createRequire } from "node:module";
import path from "node:path";
import { app } from "electron";
import log from "electron-log/main";
import {
DESKTOP_CLI_ENV,
createNodeEntrypointInvocation as createSharedNodeEntrypointInvocation,
@@ -284,7 +285,18 @@ export async function runCliTextCommand(args: string[]): Promise<string> {
if (result.exitCode !== 0) {
const stderr = result.stderr.trim();
throw new Error(stderr.length > 0 ? stderr : `CLI command failed with exit code ${result.exitCode}`);
const stdout = result.stdout.trim();
log.warn("[desktop cli]", `CLI text command failed`, {
args,
exitCode: result.exitCode,
stdout: stdout.slice(0, 500),
stderr: stderr.slice(0, 500),
});
throw new Error(
stderr.length > 0
? stderr
: `CLI command failed with exit code ${result.exitCode}${stdout.length > 0 ? `\nstdout: ${stdout.slice(0, 200)}` : ""}`,
);
}
return result.stdout.trimEnd();
@@ -298,11 +310,24 @@ export async function runCliJsonCommand(args: string[]): Promise<unknown> {
if (result.exitCode !== 0) {
const stderr = result.stderr.trim();
throw new Error(stderr.length > 0 ? stderr : `CLI command failed with exit code ${result.exitCode}`);
const stdout = result.stdout.trim();
log.warn("[desktop cli]", `CLI command failed`, {
args,
exitCode: result.exitCode,
stdout: stdout.slice(0, 500),
stderr: stderr.slice(0, 500),
command: invocation.command,
});
throw new Error(
stderr.length > 0
? stderr
: `CLI command failed with exit code ${result.exitCode}${stdout.length > 0 ? `\nstdout: ${stdout.slice(0, 200)}` : ""}`,
);
}
const stdout = result.stdout.trim();
if (stdout.length === 0) {
log.warn("[desktop cli]", `CLI command produced no output`, { args });
throw new Error("CLI command did not produce JSON output.");
}
@@ -310,7 +335,11 @@ export async function runCliJsonCommand(args: string[]): Promise<unknown> {
// Extract the first valid JSON object or array from the output.
const jsonStart = stdout.search(/[{[]/);
if (jsonStart < 0) {
throw new Error("CLI command output contained no JSON.");
log.warn("[desktop cli]", `CLI command output contained no JSON`, {
args,
stdout: stdout.slice(0, 500),
});
throw new Error(`CLI command output contained no JSON. Output: ${stdout.slice(0, 200)}`);
}
const jsonText = stdout.slice(jsonStart);
+1 -1
View File
@@ -31,7 +31,7 @@
"dev": "cross-env NODE_ENV=development tsx scripts/dev-runner.ts",
"dev:tsx": "cross-env NODE_ENV=development tsx watch --ignore '**/*.timestamp-*' src/server/index.ts",
"build": "node -e \"require('node:fs').rmSync('dist',{ recursive: true, force: true })\" && npm run build:lib && npm run build:scripts",
"build:lib": "tsc -p tsconfig.server.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/server/server/speech/providers/local/sherpa/assets',{recursive:true}); fs.copyFileSync('src/server/speech/providers/local/sherpa/assets/silero_vad.onnx','dist/server/server/speech/providers/local/sherpa/assets/silero_vad.onnx'); fs.cpSync('src/terminal/shell-integration','dist/server/terminal/shell-integration',{recursive:true}); fs.cpSync('src/terminal/shell-integration','dist/src/terminal/shell-integration',{recursive:true});\"",
"build:lib": "tsc -p tsconfig.server.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/server/server/speech/providers/local/sherpa/assets',{recursive:true}); fs.copyFileSync('src/server/speech/providers/local/sherpa/assets/silero_vad.onnx','dist/server/server/speech/providers/local/sherpa/assets/silero_vad.onnx'); fs.cpSync('src/terminal/shell-integration','dist/server/terminal/shell-integration',{recursive:true}); fs.cpSync('src/terminal/shell-integration','dist/src/terminal/shell-integration',{recursive:true}); fs.cpSync('src/server/db/migrations','dist/server/server/db/migrations',{recursive:true});\"",
"build:scripts": "tsc -p tsconfig.scripts.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/scripts',{recursive:true}); fs.copyFileSync('scripts/mcp-stdio-socket-bridge-cli.mjs','dist/scripts/mcp-stdio-socket-bridge-cli.mjs');\"",
"prepack": "npm run build",
"start": "cross-env NODE_ENV=production node dist/server/server/index.js",