Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
9b18a37
feat(server): move session families with worktrees
pascalandr Aug 12, 2026
da63783
feat(ui): organize sessions by worktree
pascalandr Aug 12, 2026
1cceb94
feat(desktop): open worktrees in file manager
pascalandr Aug 12, 2026
cee018d
merge(DEV-v2): refresh worktree session families
pascalandr Aug 18, 2026
bd84b22
Merge branch 'DEV-v2' into refresh/pr649-20260818
pascalandr Aug 18, 2026
0d3bdd1
merge(DEV-v2): refresh PR649 session migration
pascalandr Aug 18, 2026
6581ed4
Merge remote-tracking branch 'upstream/DEV-v2' into refresh/pr649-202…
pascalandr Aug 19, 2026
9124823
Merge remote-tracking branch 'upstream/DEV-v2' into refresh/pr649-202…
pascalandr Aug 19, 2026
f7573d1
Merge remote-tracking branch 'upstream/DEV-v2' into refresh/pr649-202…
pascalandr Aug 19, 2026
ca41460
Merge branch 'DEV-v2' into refresh/pr649-20260818
pascalandr Aug 19, 2026
cf5b35e
fix(worktrees): preserve service path and move invariants
pascalandr Aug 19, 2026
0238009
fix(worktrees): close nested and WSL rollback gaps
pascalandr Aug 19, 2026
eeccf02
Merge branch 'DEV-v2' into refresh/pr649-20260818
pascalandr Aug 19, 2026
faaa2b3
Merge branch 'DEV-v2' into refresh/pr649-20260818
pascalandr Aug 19, 2026
a2ad7e4
Merge branch 'DEV-v2' into refresh/pr649-20260818
pascalandr Aug 19, 2026
e4699a3
Merge branch 'DEV-v2' into refresh/pr649-20260818
pascalandr Aug 19, 2026
286612a
feat(worktrees): finalize native V2 session families
pascalandr Aug 30, 2026
ab9f5d8
fix(worktrees): keep selector actions accessible
pascalandr Aug 30, 2026
c9d3e25
merge(DEV-v2): refresh worktree session families
pascalandr Aug 30, 2026
9c077dc
fix(worktrees): finalize native V2 session safety
pascalandr Aug 31, 2026
7bbbdbb
merge(DEV-v2): adopt persistent developer automation
pascalandr Aug 31, 2026
196bb66
docs(developer-mode): align V2 validation workflow
pascalandr Aug 31, 2026
d0b8383
merge(DEV-v2): adopt developer automation reconnect fixes
pascalandr Aug 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/pr-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ jobs:
packages/ui/src/stores/session-generation-recovery.test.ts
packages/ui/src/stores/session-pagination.test.ts
packages/ui/src/stores/session-pending-state.test.ts
packages/ui/src/stores/session-tree.test.ts
packages/ui/src/stores/session-previews.test.ts
packages/ui/src/stores/workspace-load-readiness.test.ts
packages/ui/src/types/session.test.ts
Expand All @@ -171,6 +172,8 @@ jobs:
packages/ui/src/stores/session-request-authority.test.ts
packages/ui/src/stores/session-send-lifecycle.test.ts
packages/ui/src/stores/session-status.test.ts
packages/ui/src/stores/worktree-ready.test.ts
packages/ui/src/stores/worktrees-delete.test.ts

- name: Test server
run: node --import tsx --test "packages/server/src/**/*.test.ts"
Expand Down
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ Behavior for agents:
- Use the `write` tool only when creating new files from scratch.

## V2 Runtime Launch
- Launch the release executable from PowerShell with the dedicated WebView2 profile, CDP port, Rust backtraces, and Node source maps described in `MIGRATION_V2.md`.
- Stop the running CodeNomad instance before rebuilding the same release path, then relaunch it from the independent OpenCode TUI.
- Enable **Developer Mode** from the session tab bar and fully restart CodeNomad once; do not configure a fixed CDP port or a manual WebView2 profile.
- Rebuild Electron before calling `codenomad.act({ action: "restart" })`. For Windows Tauri, stop and relaunch the release executable only when the linker cannot replace it; never stop the shared OpenCode daemon.

## Commit Message Guidelines
- When creating commits, use detailed commit messages: a concise conventional-style subject followed by body paragraphs that explain the user-visible behavior change, the implementation approach, important edge cases or platform considerations, and the validation or test coverage added.
Expand Down
14 changes: 3 additions & 11 deletions MIGRATION_V2.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,19 +117,11 @@ Git status, diff, stage, unstage, commit, worktree creation, and worktree remova
- Fence late workspace creation and cleanup so cancelled restore requests cannot leak or delete the wrong logical instance.
- Build Electron and Tauri server resources reproducibly from the integrity-pinned root workspace lock for the requested OS/CPU target; no independent server lockfile or prebuild dependency repair remains.

### Native Release Debug Launch
### Developer Mode Validation

For interactive validation of the native V2 application, launch the existing release binary from the `DEV-v2` worktree with an isolated WebView2 profile, CDP inspection, Rust backtraces, and Node source maps. Run this from PowerShell; do not substitute `tauri dev`, because that compiles and runs a different debug environment.
Enable **Developer Mode** from the session tab bar and fully restart CodeNomad once. The native host owns its isolated browser profile, dynamically assigned loopback CDP endpoint, backtraces, and source maps; do not set manual WebView2 or remote-debugging environment variables and do not substitute `tauri dev` for a release build.

```powershell
$env:WEBVIEW2_USER_DATA_FOLDER="$env:TEMP\opencode\codenomad-v2-debug"
$env:WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS="--remote-debugging-port=9223"
$env:RUST_BACKTRACE="1"
$env:NODE_OPTIONS="--enable-source-maps"
& "D:\CodeNomad-worktrees\opencode-v2-foundation\packages\tauri-app\target\release\codenomad-tauri.exe"
```

The native WebView is then inspectable through CDP at `http://127.0.0.1:9223`. Stop the running instance before rebuilding this release path, then relaunch it from the independent OpenCode TUI.
After rebuilding Electron, use `codenomad.act({ action: "restart" })` so the persistent OpenCode adapter can reconnect this session to the new native process generation. For Windows Tauri, stop and relaunch the release executable manually only when the linker cannot replace the running binary. The shared OpenCode daemon remains alive in both cases. See `dev-docs/DEVELOPER_MODE.md` for the complete contract and trust boundaries.

## Removed Legacy Architecture

Expand Down
37 changes: 18 additions & 19 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 16 additions & 0 deletions packages/server/src/api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,15 @@ export interface WorktreeDescriptor {
slug: string
/** Absolute directory path on the server host. */
directory: string
/** Equivalent path in the OpenCode service namespace (notably WSL). */
serviceDirectory?: string
/** Exact path registered in Git's worktree inventory. */
registeredDirectory?: string
kind: WorktreeKind
/** Optional VCS branch name when available. */
branch?: string
/** Commit recorded by the Git worktree inventory. */
head?: string
}

export interface WorktreeListResponse {
Expand All @@ -110,6 +116,16 @@ export interface WorktreeCreateRequest {
branch?: string
}

export interface WorktreeSessionMoveRequest {
worktreeSlug: string
}

export interface WorktreeSessionMoveResponse {
rootSessionId: string
sessionIds: string[]
worktreeSlug: string
}

export type GitChangeKind = "added" | "modified" | "deleted" | "renamed" | "copied" | "untracked" | "unmerged"

export interface WorktreeGitStatusEntry {
Expand Down
52 changes: 20 additions & 32 deletions packages/server/src/server/__tests__/instance-proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,14 @@ import { afterEach, describe, it } from "node:test"
import { Readable } from "node:stream"
import Fastify, { type FastifyInstance } from "fastify"
import replyFrom from "@fastify/reply-from"
import type { OpenCodeClient, SessionInfo } from "@opencode-ai/client"
import type { LocationRef, OpenCodeClient, SessionInfo } from "@opencode-ai/client"
import type { Logger } from "../../logger"
import { redactSecrets, registerInstanceProxyRoutes, type InstanceProxyWorkspaceManager } from "../http-server"
import { WorktreeDeletionFence } from "../../workspaces/worktree-session-evacuation"

const apps: FastifyInstance[] = []
afterEach(async () => Promise.all(apps.splice(0).map((app) => app.close())))

const cursor = (value: object) => Buffer.from(JSON.stringify(value)).toString("base64url")

function logger(): Logger {
const value = { debug() {}, trace() {}, error() {}, isLevelEnabled() { return false } }
return value as unknown as Logger
Expand All @@ -21,13 +19,14 @@ function logger(): Logger {
async function harness(
sessionDirectory = "/repo/worktree",
activeSessions: Record<string, { type: "running" }> = {},
sessionLocations: Record<string, string | Error> = {},
sessionLocations: Record<string, string | LocationRef | Error> = {},
workspacePath = "/repo",
serviceDirectory = workspacePath,
pathMappings: Record<string, string> = {},
ptyDirectories: Record<string, string | Error> = {},
shellDirectories: Record<string, string | Error> = {},
directoryMappings: Record<string, string> = {},
sessionListLocation: LocationRef = { directory: sessionDirectory, workspaceID: "worktree-location" },
) {
const upstream = Fastify()
apps.push(upstream)
Expand Down Expand Up @@ -84,9 +83,13 @@ async function harness(
sessionGets.push(sessionID)
const location = sessionLocations[sessionID] ?? sessionDirectory
if (location instanceof Error) throw location
return { id: sessionID, location: { directory: location } } as SessionInfo
return { id: sessionID, location: typeof location === "string" ? { directory: location } : location } as SessionInfo
},
active: async () => activeSessions,
list: async () => ({
data: [{ id: "listed", projectID: "owned-project", location: sessionListLocation, time: { created: 1, updated: 1 } } as SessionInfo],
cursor: { next: null },
}),
},
pty: {
list: async () => ({
Expand Down Expand Up @@ -121,6 +124,7 @@ async function harness(
invalidateSharedServiceConnection: () => { invalidations += 1 },
getInstanceAuthorizationHeader: () => "Basic internal-secret",
getServiceDirectory: () => serviceDirectory,
getServiceLocation: () => ({ directory: serviceDirectory, workspaceID: "owned-location" }),
getServiceDirectoryForPath: async (_id, directory) => directory === workspacePath
? serviceDirectory
: owned.has(directory) ? directoryMappings[directory] ?? directory : undefined,
Expand Down Expand Up @@ -390,7 +394,7 @@ describe("instance proxy location enforcement", () => {
url: "/workspaces/workspace/instance/api/session?project=owned-project",
})
assert.equal(response.statusCode, 200)
assert.equal(JSON.parse(response.body).url, "/api/session?project=owned-project")
assert.equal(JSON.parse(response.body).url, "/api/session?project=owned-project&workspace=owned-location")

const foreign = await app.inject({
method: "GET",
Expand All @@ -399,41 +403,25 @@ describe("instance proxy location enforcement", () => {
assert.equal(foreign.statusCode, 403)
})

it("uses the cursor scope and rejects malformed or forged session-list cursors", async () => {
it("treats session-list cursors as opaque and validates the returned locations", async () => {
const { app, requestCount } = await harness()
const ownedCursor = cursor({ directory: "/repo/worktree", anchor: { id: "session-1", time: 1, direction: "next" } })
const response = await app.inject({
method: "GET",
url: `/workspaces/workspace/instance/api/session?cursor=${ownedCursor}&directory=%2Fother&workspace=foreign-location`,
url: "/workspaces/workspace/instance/api/session?cursor=opaque.native-cursor",
})
assert.equal(response.statusCode, 200)
const upstreamUrl = JSON.parse(response.body).url as string
assert.match(upstreamUrl, new RegExp(`cursor=${ownedCursor}`))
assert.doesNotMatch(upstreamUrl, /directory=/)
assert.doesNotMatch(upstreamUrl, /workspace=/)

const forgedCursor = cursor({ directory: "/other", anchor: { id: "session-1", time: 1, direction: "next" } })
assert.deepEqual(JSON.parse(response.body).data.map((session: SessionInfo) => session.id), ["listed"])
assert.equal((await app.inject({
method: "GET",
url: `/workspaces/workspace/instance/api/session?cursor=${forgedCursor}&directory=%2Frepo`,
})).statusCode, 403)
assert.equal((await app.inject({
method: "GET",
url: "/workspaces/workspace/instance/api/session?cursor=not-json",
url: "/workspaces/workspace/instance/api/session?cursor=opaque&directory=%2Frepo",
})).statusCode, 400)
assert.equal((await app.inject({
method: "GET",
url: `/workspaces/workspace/instance/api/session?cursor=${cursor({ directory: "/repo" })}`,
})).statusCode, 400)
assert.equal((await app.inject({
assert.equal(requestCount(), 0)

const foreign = await harness("/repo/worktree", {}, {}, "/repo", "/repo", {}, {}, {}, {}, { directory: "/other" })
assert.equal((await foreign.app.inject({
method: "GET",
url: `/workspaces/workspace/instance/api/session?cursor=${cursor({ workspace: "foreign-location", directory: "/repo", anchor: { id: "session-1", time: 1, direction: "next" } })}`,
url: "/workspaces/workspace/instance/api/session?cursor=opaque",
})).statusCode, 403)
assert.equal((await app.inject({
method: "GET",
url: `/workspaces/workspace/instance/api/session?cursor=${cursor({ workspace: "owned-location", anchor: { id: "session-1", time: 1, direction: "next" } })}`,
})).statusCode, 200)
assert.equal(requestCount(), 2)
})

it("filters PTYs and rejects foreign PTY access", async () => {
Expand Down Expand Up @@ -659,7 +647,7 @@ describe("instance proxy location enforcement", () => {
it("filters active sessions to the workspace without failing on stale ids", async () => {
const active = { owned: { type: "running" as const }, foreign: { type: "running" as const }, stale: { type: "running" as const } }
const { app, sessionGets, requestCount } = await harness("/repo/worktree", active, {
foreign: "/other",
foreign: { directory: "/repo/worktree", workspaceID: "foreign-location" },
stale: new Error("missing"),
})
const response = await app.inject({ method: "GET", url: "/workspaces/workspace/instance/api/session/active" })
Expand Down
Loading
Loading