opencode-pilot 0.24.11 → 0.24.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,8 @@
1
1
  class OpencodePilot < Formula
2
2
  desc "Automation daemon for OpenCode - polls GitHub/Linear issues and spawns sessions"
3
3
  homepage "https://github.com/athal7/opencode-pilot"
4
- url "https://github.com/athal7/opencode-pilot/archive/refs/tags/v0.24.10.tar.gz"
5
- sha256 "09c76da7756c11bfd192fb74c3b9d7e0335678258cfaebf8e7ef3473f7f74e55"
4
+ url "https://github.com/athal7/opencode-pilot/archive/refs/tags/v0.24.11.tar.gz"
5
+ sha256 "c0c9e3cdb3b34275d7a1d63b128430dd7e6fdd00dc8fe9e3baa5f3124b955422"
6
6
  license "MIT"
7
7
 
8
8
  depends_on "node"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-pilot",
3
- "version": "0.24.11",
3
+ "version": "0.24.12",
4
4
  "type": "module",
5
5
  "main": "plugin/index.js",
6
6
  "description": "Automation daemon for OpenCode - polls for work and spawns sessions",
@@ -4,19 +4,19 @@
4
4
  * Starts OpenCode sessions with configurable prompts.
5
5
  * Supports prompt_template for custom prompts (e.g., to invoke /devcontainer).
6
6
  *
7
- * ## Session creation invariants
7
+ * ## Session directory handling
8
8
  *
9
- * Every session created by this module MUST satisfy all three invariants.
10
- * See service/session-context.js for full documentation and rationale.
9
+ * POST /session?directory=X sets both session.directory (working dir) and
10
+ * session.projectID (derived from the git root of X). Sandbox directories
11
+ * are git worktrees of the parent repo — they share the same root commit,
12
+ * so OpenCode resolves the correct projectID automatically.
11
13
  *
12
- * A. Project scoping – session's projectID 'global' visible in UI
13
- * B. Working directory agent operates in the worktree, not the main repo
14
- * C. Session isolation reuse never crosses PR/issue boundaries
14
+ * This means we always POST with the workingDirectory. No PATCH-based
15
+ * "project re-scoping" is needed. See test/integration/real-server.test.js
16
+ * for verification against a real OpenCode server.
15
17
  *
16
- * The SessionContext value object carries both directories together so no
17
- * call site can accidentally pass the wrong one. When you change session
18
- * creation logic, verify all three invariants in the integration tests under
19
- * "session creation invariants".
18
+ * Session isolation: worktree sessions skip findReusableSession entirely
19
+ * to prevent cross-PR contamination (each PR gets its own session).
20
20
  */
21
21
 
22
22
  import { execSync } from "child_process";
@@ -688,11 +688,12 @@ export async function findReusableSession(serverUrl, directory, options = {}) {
688
688
  /**
689
689
  * Create a session via the OpenCode HTTP API
690
690
  *
691
- * Satisfies session creation invariants A and B (see module header):
692
- * A. Creates the session scoped to sessionCtx.projectDirectory so OpenCode
693
- * assigns the correct projectID (session visible in desktop UI).
694
- * B. Sends the first message with sessionCtx.workingDirectory so the agent
695
- * operates in the worktree, not the main repo.
691
+ * Creates the session with sessionCtx.workingDirectory. OpenCode resolves
692
+ * the correct projectID from the git root of that directory — sandbox dirs
693
+ * (git worktrees) resolve to the same project as the parent repo, so no
694
+ * separate "project scoping" step is needed.
695
+ *
696
+ * Verified against real OpenCode server in test/integration/real-server.test.js.
696
697
  *
697
698
  * @param {string} serverUrl - Server URL (e.g., "http://localhost:4096")
698
699
  * @param {SessionContext} sessionCtx - Carries both directories (see session-context.js)
@@ -707,17 +708,24 @@ export async function findReusableSession(serverUrl, directory, options = {}) {
707
708
  export async function createSessionViaApi(serverUrl, sessionCtx, prompt, options = {}) {
708
709
  const fetchFn = options.fetch || fetch;
709
710
  const headerTimeout = options.headerTimeout || HEADER_TIMEOUT_MS;
710
- // Invariant A: use projectDirectory so OpenCode assigns the correct projectID.
711
- // Invariant B: use workingDirectory for messages so the agent operates in the worktree.
712
- const projectDir = sessionCtx.projectDirectory;
711
+ // POST /session?directory=X sets both session.directory and projectID.
712
+ // OpenCode resolves projectID from the git root of X sandbox directories
713
+ // (git worktrees) share the same root commit as the parent repo, so they
714
+ // get the correct projectID automatically. No PATCH re-scoping needed.
715
+ //
716
+ // PATCH /session/:id only updates title/archived — the ?directory param
717
+ // is a routing parameter (determines which project to look in), NOT a
718
+ // mutation of session.directory.
713
719
  const directory = sessionCtx.workingDirectory;
714
720
 
715
721
  let session = null;
716
722
 
717
723
  try {
718
- // Step 1: Create session scoped to the project directory
724
+ // Step 1: Create session with the working directory.
725
+ // This is what determines where the agent actually operates (file reads,
726
+ // writes, tool execution). For worktree sessions this is the sandbox path.
719
727
  const sessionUrl = new URL('/session', serverUrl);
720
- sessionUrl.searchParams.set('directory', projectDir);
728
+ sessionUrl.searchParams.set('directory', directory);
721
729
 
722
730
  const createResponse = await fetchFn(sessionUrl.toString(), {
723
731
  method: 'POST',
@@ -733,10 +741,12 @@ export async function createSessionViaApi(serverUrl, sessionCtx, prompt, options
733
741
  session = await createResponse.json();
734
742
  debug(`createSessionViaApi: created session ${session.id} in ${directory}`);
735
743
 
736
- // Step 2: Update session title if provided
744
+ // Step 2: Set session title if provided.
745
+ // PATCH ?directory must match the session's directory so the server can
746
+ // find it (it's a routing param, not a mutation).
737
747
  if (options.title) {
738
748
  const updateUrl = new URL(`/session/${session.id}`, serverUrl);
739
- updateUrl.searchParams.set('directory', projectDir);
749
+ updateUrl.searchParams.set('directory', directory);
740
750
  await fetchFn(updateUrl.toString(), {
741
751
  method: 'PATCH',
742
752
  headers: { 'Content-Type': 'application/json' },
@@ -1,49 +1,36 @@
1
1
  /**
2
2
  * session-context.js - SessionContext value object
3
3
  *
4
- * Encapsulates the two-directory problem that repeatedly caused session
5
- * creation regressions (v0.24.7 through v0.24.10).
4
+ * Tracks both the project directory (main git repo) and the working directory
5
+ * (which may be a sandbox/worktree) for session creation.
6
6
  *
7
- * ## The Problem
7
+ * ## How OpenCode's API actually works
8
8
  *
9
- * OpenCode's POST /session API accepts a single `directory` parameter that
10
- * conflates two distinct concerns:
9
+ * Verified against a real OpenCode server (see test/integration/real-server.test.js):
11
10
  *
12
- * 1. **Project scoping** (projectID) which project the session belongs to
13
- * in the desktop UI. Requires the *project directory* (the main git repo).
11
+ * - POST /session?directory=X sets `session.directory = X` and derives
12
+ * `session.projectID` from the git root of X. Sandbox directories are
13
+ * git worktrees that share the same root commit as the parent repo, so
14
+ * they get the correct projectID automatically. There is NO need to
15
+ * create with the project directory for "project scoping".
14
16
  *
15
- * 2. **Working directory** where the agent executes file operations.
16
- * Requires the *worktree directory* when using git worktrees.
17
+ * - PATCH /session/:id only updates title/archived. The ?directory param
18
+ * is a routing parameter (determines which project to look in), NOT a
19
+ * mutation of session.directory.
17
20
  *
18
- * When a worktree is active, these directories differ. Passing the wrong one
19
- * satisfies one requirement but silently breaks the other.
21
+ * - GET /session?directory=X uses ?directory for both project routing
22
+ * (middleware) and as an exact filter on session.directory (route handler).
23
+ * This means sessions created with a sandbox dir are only visible when
24
+ * querying with that sandbox dir — natural isolation per worktree.
20
25
  *
21
- * ## The Three Invariants
26
+ * ## Why SessionContext still carries both directories
22
27
  *
23
- * Any correct session creation MUST satisfy all three:
24
- *
25
- * A. **Project scoping**: Session's projectID matches the project (not
26
- * 'global'). The session is visible in the desktop app.
27
- * Requires POST /session?directory=<projectDirectory>
28
- *
29
- * B. **Working directory**: Agent operates in the correct location.
30
- * In worktree mode the session's working dir must be the worktree path,
31
- * so file reads/writes go to the right branch.
32
- * → Requires per-message directory=<workingDirectory>
33
- * (or PATCH /session/:id to update the session working dir)
34
- *
35
- * C. **Session isolation**: Session reuse only finds sessions for the
36
- * *same work item* (same PR/issue), not other PRs sharing the project.
37
- * → Worktree sessions must NOT be reused across items; each PR gets
38
- * its own session.
39
- *
40
- * ## The Solution
41
- *
42
- * - Create sessions with `projectDirectory` (satisfies A).
43
- * - Send messages with `workingDirectory` (satisfies B).
44
- * - Skip `findReusableSession` entirely when in a worktree (satisfies C),
45
- * because neither the worktree path nor the project path can safely scope
46
- * reuse to a single PR.
28
+ * Even though createSessionViaApi only needs workingDirectory, the project
29
+ * directory is still needed for:
30
+ * - Worktree detection (isWorktree) to skip session reuse for sandbox
31
+ * sessions and prevent cross-PR contamination
32
+ * - resolveWorktreeDirectory needs the base project dir to create/list
33
+ * worktrees via GET/POST /experimental/worktree?directory=<projectDir>
47
34
  *
48
35
  * ## Worktree Detection
49
36
  *
@@ -54,13 +41,13 @@
54
41
  export class SessionContext {
55
42
  /**
56
43
  * @param {string} projectDirectory - Base git repo path. Used for:
57
- * - POST /session?directory=... (sets projectID for UI visibility)
58
- * - PATCH /session/:id?directory=... (if post-creation re-scoping needed)
59
- * - listSessions query (for session reuse lookup in non-worktree mode)
44
+ * - Worktree detection (isWorktree check for session isolation)
45
+ * - Worktree API calls (GET/POST /experimental/worktree?directory=...)
60
46
  *
61
47
  * @param {string} workingDirectory - Directory where the agent does work. Used for:
48
+ * - POST /session?directory=... (sets session.directory AND projectID)
62
49
  * - POST /session/:id/message?directory=... (file operations)
63
- * - POST /session/:id/command?directory=... (slash commands)
50
+ * - PATCH /session/:id?directory=... (routing for title updates)
64
51
  * Equals projectDirectory when not using worktrees.
65
52
  */
66
53
  constructor(projectDirectory, workingDirectory) {
@@ -73,9 +60,8 @@ export class SessionContext {
73
60
 
74
61
  /**
75
62
  * True when the session runs in a worktree separate from the main repo.
76
- * Worktree sessions must NOT participate in findReusableSession (invariant C):
77
- * - Querying by workingDirectory finds old sessions scoped to 'global' (wrong projectID)
78
- * - Querying by projectDirectory finds sessions for other PRs in the same project
63
+ * Worktree sessions skip findReusableSession to prevent cross-PR
64
+ * contamination each PR/issue in its own sandbox gets its own session.
79
65
  */
80
66
  get isWorktree() {
81
67
  return this.projectDirectory !== this.workingDirectory;
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Integration tests against a REAL OpenCode server.
3
+ *
4
+ * These tests verify actual API behavior — not mocked assumptions.
5
+ * They require a running OpenCode instance (the desktop app) with access
6
+ * to this repo's project. Tests are skipped when no server is available.
7
+ *
8
+ * What these tests prove:
9
+ *
10
+ * 1. Creating a session with a sandbox directory sets `session.directory`
11
+ * to the sandbox path AND resolves the correct `projectID` (same as
12
+ * the parent repo). This disproves the assumption that sandbox
13
+ * directories produce `projectID = 'global'`.
14
+ *
15
+ * 2. PATCH /session/:id does NOT change `session.directory`. The
16
+ * `?directory` query param on PATCH is a routing parameter only.
17
+ *
18
+ * These facts mean createSessionViaApi only needs to POST with the
19
+ * working directory — no PATCH-based "re-scoping" is needed.
20
+ */
21
+ import { describe, it, before, after } from "node:test";
22
+ import assert from "node:assert";
23
+ import path from "node:path";
24
+
25
+ // ─── Server discovery ───────────────────────────────────────────────────────
26
+
27
+ const PROJECT_DIR = path.resolve(import.meta.dirname, "../..");
28
+ const SERVER_URL = "http://localhost:4096";
29
+ const SANDBOX_NAME = "test-real-server";
30
+
31
+ let serverAvailable = false;
32
+ let projectID = null;
33
+ let sandboxDir = null;
34
+ const createdSessionIds = [];
35
+
36
+ async function checkServer() {
37
+ try {
38
+ const encoded = encodeURIComponent(PROJECT_DIR);
39
+ const res = await fetch(`${SERVER_URL}/session?directory=${encoded}`);
40
+ if (!res.ok) return false;
41
+
42
+ // Also verify this project is known
43
+ const projRes = await fetch(`${SERVER_URL}/project`);
44
+ if (!projRes.ok) return false;
45
+ const projects = await projRes.json();
46
+ const match = projects.find((p) => p.worktree === PROJECT_DIR);
47
+ if (!match) return false;
48
+ projectID = match.id;
49
+ return true;
50
+ } catch {
51
+ return false;
52
+ }
53
+ }
54
+
55
+ async function createSandbox() {
56
+ const encoded = encodeURIComponent(PROJECT_DIR);
57
+ const res = await fetch(
58
+ `${SERVER_URL}/experimental/worktree?directory=${encoded}`,
59
+ {
60
+ method: "POST",
61
+ headers: { "Content-Type": "application/json" },
62
+ body: JSON.stringify({ name: SANDBOX_NAME }),
63
+ }
64
+ );
65
+ if (!res.ok) return null;
66
+ const wt = await res.json();
67
+ return wt.directory;
68
+ }
69
+
70
+ async function findOrCreateSandbox() {
71
+ const encoded = encodeURIComponent(PROJECT_DIR);
72
+ const res = await fetch(
73
+ `${SERVER_URL}/experimental/worktree?directory=${encoded}`
74
+ );
75
+ if (res.ok) {
76
+ const worktrees = await res.json();
77
+ const existing = worktrees.find((w) => w.endsWith(`/${SANDBOX_NAME}`));
78
+ if (existing) return existing;
79
+ }
80
+ return createSandbox();
81
+ }
82
+
83
+ async function archiveSession(id, directory) {
84
+ const encoded = encodeURIComponent(directory);
85
+ await fetch(`${SERVER_URL}/session/${id}?directory=${encoded}`, {
86
+ method: "PATCH",
87
+ headers: { "Content-Type": "application/json" },
88
+ body: JSON.stringify({ time: { archived: Date.now() } }),
89
+ }).catch(() => {});
90
+ }
91
+
92
+ // ─── Test suite ─────────────────────────────────────────────────────────────
93
+
94
+ describe("real server: session directory behavior", { skip: false }, () => {
95
+ before(async () => {
96
+ serverAvailable = await checkServer();
97
+ if (!serverAvailable) return;
98
+ sandboxDir = await findOrCreateSandbox();
99
+ });
100
+
101
+ after(async () => {
102
+ if (!serverAvailable) return;
103
+ // Archive test sessions so they don't clutter the UI
104
+ for (const { id, directory } of createdSessionIds) {
105
+ await archiveSession(id, directory);
106
+ }
107
+ });
108
+
109
+ it("skip: no OpenCode server running", { skip: !false }, function () {
110
+ // This is a sentinel — replaced dynamically in before()
111
+ });
112
+
113
+ it("POST /session with sandbox dir → correct directory AND projectID", async (t) => {
114
+ if (!serverAvailable) return t.skip("no OpenCode server");
115
+ if (!sandboxDir) return t.skip("could not create sandbox");
116
+
117
+ const encoded = encodeURIComponent(sandboxDir);
118
+ const res = await fetch(`${SERVER_URL}/session?directory=${encoded}`, {
119
+ method: "POST",
120
+ headers: { "Content-Type": "application/json" },
121
+ body: JSON.stringify({}),
122
+ });
123
+
124
+ assert.ok(res.ok, `POST /session should succeed (got ${res.status})`);
125
+ const session = await res.json();
126
+ createdSessionIds.push({ id: session.id, directory: sandboxDir });
127
+
128
+ // The session's directory must be the sandbox path — this is where the
129
+ // agent will operate. This was the bug: prior code created with the
130
+ // project dir, so the agent worked in the wrong directory.
131
+ assert.strictEqual(
132
+ session.directory,
133
+ sandboxDir,
134
+ "session.directory must be the sandbox path (where agent operates)"
135
+ );
136
+
137
+ // The projectID must match the parent repo's project — NOT 'global'.
138
+ // This disproves the assumption that led to 4 regression-fix cycles.
139
+ assert.strictEqual(
140
+ session.projectID,
141
+ projectID,
142
+ "session.projectID must match the parent repo (sandbox is a git worktree of the same repo)"
143
+ );
144
+ });
145
+
146
+ it("POST /session with project dir → project directory and same projectID", async (t) => {
147
+ if (!serverAvailable) return t.skip("no OpenCode server");
148
+
149
+ const encoded = encodeURIComponent(PROJECT_DIR);
150
+ const res = await fetch(`${SERVER_URL}/session?directory=${encoded}`, {
151
+ method: "POST",
152
+ headers: { "Content-Type": "application/json" },
153
+ body: JSON.stringify({}),
154
+ });
155
+
156
+ assert.ok(res.ok, `POST /session should succeed (got ${res.status})`);
157
+ const session = await res.json();
158
+ createdSessionIds.push({ id: session.id, directory: PROJECT_DIR });
159
+
160
+ assert.strictEqual(
161
+ session.directory,
162
+ PROJECT_DIR,
163
+ "session.directory must be the project path"
164
+ );
165
+
166
+ assert.strictEqual(
167
+ session.projectID,
168
+ projectID,
169
+ "session.projectID must be the same whether created from sandbox or project dir"
170
+ );
171
+ });
172
+
173
+ it("PATCH /session/:id does NOT change session.directory", async (t) => {
174
+ if (!serverAvailable) return t.skip("no OpenCode server");
175
+ if (!sandboxDir) return t.skip("could not create sandbox");
176
+
177
+ // Create session with sandbox dir
178
+ const encoded = encodeURIComponent(sandboxDir);
179
+ const createRes = await fetch(
180
+ `${SERVER_URL}/session?directory=${encoded}`,
181
+ {
182
+ method: "POST",
183
+ headers: { "Content-Type": "application/json" },
184
+ body: JSON.stringify({}),
185
+ }
186
+ );
187
+ const session = await createRes.json();
188
+ createdSessionIds.push({ id: session.id, directory: sandboxDir });
189
+
190
+ // PATCH with project dir (this is what the "re-scoping" code tried to do)
191
+ const projectEncoded = encodeURIComponent(PROJECT_DIR);
192
+ const patchRes = await fetch(
193
+ `${SERVER_URL}/session/${session.id}?directory=${projectEncoded}`,
194
+ {
195
+ method: "PATCH",
196
+ headers: { "Content-Type": "application/json" },
197
+ body: JSON.stringify({ title: "patched-test" }),
198
+ }
199
+ );
200
+ assert.ok(patchRes.ok, `PATCH should succeed (got ${patchRes.status})`);
201
+ const patched = await patchRes.json();
202
+
203
+ // The directory must NOT have changed — PATCH only updates title/archived
204
+ assert.strictEqual(
205
+ patched.directory,
206
+ sandboxDir,
207
+ "PATCH must NOT change session.directory (it only updates title/archived)"
208
+ );
209
+
210
+ // Title should have been updated
211
+ assert.strictEqual(patched.title, "patched-test", "title should be updated");
212
+
213
+ // Read it back to be sure
214
+ const readRes = await fetch(
215
+ `${SERVER_URL}/session?directory=${encoded}`
216
+ );
217
+ const sessions = await readRes.json();
218
+ const readBack = sessions.find((s) => s.id === session.id);
219
+ assert.ok(readBack, "session should be readable from sandbox dir");
220
+ assert.strictEqual(
221
+ readBack.directory,
222
+ sandboxDir,
223
+ "read-back confirms directory unchanged after PATCH"
224
+ );
225
+ });
226
+
227
+ it("GET /session?directory filters by exact session.directory match", async (t) => {
228
+ if (!serverAvailable) return t.skip("no OpenCode server");
229
+ if (!sandboxDir) return t.skip("could not create sandbox");
230
+
231
+ // Create session with sandbox dir
232
+ const encoded = encodeURIComponent(sandboxDir);
233
+ const createRes = await fetch(
234
+ `${SERVER_URL}/session?directory=${encoded}`,
235
+ {
236
+ method: "POST",
237
+ headers: { "Content-Type": "application/json" },
238
+ body: JSON.stringify({}),
239
+ }
240
+ );
241
+ const session = await createRes.json();
242
+ createdSessionIds.push({ id: session.id, directory: sandboxDir });
243
+
244
+ // Query with sandbox dir — should find it (exact match on session.directory)
245
+ const fromSandbox = await fetch(
246
+ `${SERVER_URL}/session?directory=${encoded}`
247
+ );
248
+ const sandboxSessions = await fromSandbox.json();
249
+ const foundFromSandbox = sandboxSessions.find((s) => s.id === session.id);
250
+ assert.ok(
251
+ foundFromSandbox,
252
+ "session should be found when querying with sandbox dir (exact match)"
253
+ );
254
+
255
+ // Query with project dir — should NOT find it because session.directory
256
+ // is the sandbox path, not the project path. The ?directory param on
257
+ // GET /session is both a project-routing param (middleware) AND an exact
258
+ // filter on session.directory (route handler). Since session.directory
259
+ // is the sandbox path, it won't match the project path filter.
260
+ // This is actually correct behavior: it means session reuse via
261
+ // findReusableSession naturally isolates sandbox sessions from project
262
+ // sessions — each sandbox only sees its own sessions.
263
+ const projectEncoded = encodeURIComponent(PROJECT_DIR);
264
+ const fromProject = await fetch(
265
+ `${SERVER_URL}/session?directory=${projectEncoded}`
266
+ );
267
+ const projectSessions = await fromProject.json();
268
+ const foundFromProject = projectSessions.find((s) => s.id === session.id);
269
+ assert.ok(
270
+ !foundFromProject,
271
+ "session should NOT appear in project dir listing (directory filter is an exact match on session.directory)"
272
+ );
273
+ });
274
+ });
@@ -564,9 +564,9 @@ describe("integration: worktree creation with worktree_name", () => {
564
564
  assert.ok(worktreeCreateCalled, "Should create worktree when worktree_name is configured");
565
565
  assert.strictEqual(createdWorktreeName, "pr-42", "Should expand worktree_name template");
566
566
  assert.ok(sessionCreated, "Should create session");
567
- // Session creation uses the project directory (for correct projectID scoping)
568
- // The worktree path is used for messages/commands (file operations)
569
- assert.strictEqual(sessionDirectory, "/proj", "Session should be scoped to project directory");
567
+ // Session creation uses the worktree directory (sets actual working dir)
568
+ // PATCH with project dir handles UI scoping (best-effort)
569
+ assert.strictEqual(sessionDirectory, "/worktree/pr-42", "Session should be created with worktree as working directory");
570
570
  });
571
571
 
572
572
  it("reuses stored directory when reprocessing same item", async () => {
@@ -618,8 +618,8 @@ describe("integration: worktree creation with worktree_name", () => {
618
618
  assert.ok(result.success, "Action should succeed");
619
619
  // Should NOT create a new worktree since we have existing_directory
620
620
  assert.strictEqual(worktreeCreateCalled, false, "Should NOT create new worktree when existing_directory provided");
621
- // Session creation uses the project directory (for correct projectID scoping)
622
- assert.strictEqual(sessionDirectory, "/proj", "Session should be scoped to project directory");
621
+ // Session creation uses the worktree directory (sets actual working dir)
622
+ assert.strictEqual(sessionDirectory, existingWorktreeDir, "Session should be created with worktree as working directory");
623
623
  });
624
624
 
625
625
  it("skips session reuse when working in a worktree", async () => {
@@ -674,10 +674,10 @@ describe("integration: worktree creation with worktree_name", () => {
674
674
  // Should NOT query for existing sessions when in a worktree
675
675
  assert.strictEqual(sessionListQueried, false,
676
676
  "Should skip session reuse entirely when in a worktree");
677
- // Should create a new session scoped to the project directory
677
+ // Should create a new session with the worktree as working directory
678
678
  assert.ok(sessionCreated, "Should create a new session");
679
- assert.strictEqual(sessionCreateDirectory, "/proj",
680
- "New session should be scoped to project directory");
679
+ assert.strictEqual(sessionCreateDirectory, existingWorktreeDir,
680
+ "New session should be created with worktree as working directory");
681
681
  });
682
682
  });
683
683
 
@@ -1032,6 +1032,10 @@ describe("session creation invariants", () => {
1032
1032
  calls.sessionCreateDirectory = u.searchParams.get("directory");
1033
1033
  calls.sessionCreated = true;
1034
1034
  }
1035
+ if (method === "PATCH" && /^\/session\/[^/]+$/.test(u.pathname)) {
1036
+ calls.patchDirectory = u.searchParams.get("directory");
1037
+ calls.patchSessionId = u.pathname.split("/")[2];
1038
+ }
1035
1039
  if (method === "GET" && u.pathname === "/session") {
1036
1040
  calls.sessionListQueried = true;
1037
1041
  }
@@ -1039,11 +1043,11 @@ describe("session creation invariants", () => {
1039
1043
  };
1040
1044
  }
1041
1045
 
1042
- it("invariant A+B: session scoped to project, message sent to worktree", async () => {
1043
- // This is THE test that would have caught every regression in v0.24.7–v0.24.10.
1044
- // It asserts both invariants simultaneously:
1045
- // A. POST /session directory = project path (not worktree) correct projectID
1046
- // B. POST /session/:id/message directory = worktree path → correct working dir
1046
+ it("session created with worktree directory, message sent to worktree", async () => {
1047
+ // POST /session must use the worktree directory this sets session.directory
1048
+ // and determines where the agent operates. OpenCode derives the correct
1049
+ // projectID from the git root (sandbox worktrees share the same root commit).
1050
+ // Verified against a real server in test/integration/real-server.test.js.
1047
1051
 
1048
1052
  const calls = {};
1049
1053
 
@@ -1069,22 +1073,18 @@ describe("session creation invariants", () => {
1069
1073
 
1070
1074
  assert.ok(result.success, `Action should succeed (warning: ${result.warning || "none"})`);
1071
1075
 
1072
- // Invariant A: session creation uses the project directory
1073
- assert.strictEqual(calls.sessionCreateDirectory, "/proj",
1074
- "INVARIANT A VIOLATED: POST /session must use projectDirectory for correct projectID");
1076
+ // Session creation uses the worktree directory
1077
+ assert.strictEqual(calls.sessionCreateDirectory, "/worktree/pr-99",
1078
+ "POST /session must use workingDirectory so agent operates in worktree");
1075
1079
 
1076
- // Invariant B: message uses the worktree directory
1080
+ // Message also uses the worktree directory
1077
1081
  assert.strictEqual(calls.messageDirectory, "/worktree/pr-99",
1078
- "INVARIANT B VIOLATED: POST /message must use workingDirectory for correct file operations");
1079
-
1080
- // Sanity: the two directories must be different in the worktree case
1081
- assert.notStrictEqual(calls.sessionCreateDirectory, calls.messageDirectory,
1082
- "In worktree mode, session creation dir and message dir must differ");
1082
+ "POST /message must use workingDirectory for correct file operations");
1083
1083
  });
1084
1084
 
1085
- it("invariant A+B hold when reprocessing with existing_directory", async () => {
1085
+ it("reprocessing uses existing worktree directory for session and message", async () => {
1086
1086
  // When reprocessing an item (e.g., new feedback on a PR), the existing worktree
1087
- // directory is passed. The same invariants must still hold.
1087
+ // directory is passed. Session creation and messages must both use it.
1088
1088
 
1089
1089
  const calls = {};
1090
1090
 
@@ -1111,13 +1111,13 @@ describe("session creation invariants", () => {
1111
1111
 
1112
1112
  assert.ok(result.success, `Action should succeed (warning: ${result.warning || "none"})`);
1113
1113
 
1114
- // Invariant A: session creation uses the project directory, not the worktree
1115
- assert.strictEqual(calls.sessionCreateDirectory, "/proj",
1116
- "INVARIANT A VIOLATED: reprocessing must still use projectDirectory for POST /session");
1114
+ // Session creation uses the existing worktree directory
1115
+ assert.strictEqual(calls.sessionCreateDirectory, "/worktree/calm-wizard",
1116
+ "POST /session must use workingDirectory for correct session directory");
1117
1117
 
1118
- // Invariant B: message uses the existing worktree directory
1118
+ // Message uses the existing worktree directory
1119
1119
  assert.strictEqual(calls.messageDirectory, "/worktree/calm-wizard",
1120
- "INVARIANT B VIOLATED: reprocessing must send messages to the existing worktree path");
1120
+ "POST /message must use workingDirectory for correct file operations");
1121
1121
  });
1122
1122
 
1123
1123
  it("invariant C: second PR in same project gets its own session", async () => {
@@ -982,9 +982,9 @@ Check for bugs and security issues.`;
982
982
  // Should NOT call worktree endpoints when existing_directory is provided
983
983
  assert.strictEqual(worktreeListCalled, false, 'Should NOT list worktrees');
984
984
  assert.strictEqual(worktreeCreateCalled, false, 'Should NOT create worktree');
985
- // Session creation uses project directory (for correct projectID scoping)
986
- assert.strictEqual(sessionDirectory, '/data/proj',
987
- 'Session creation should use project directory, not worktree');
985
+ // Session creation uses worktree directory (sets actual working dir)
986
+ assert.strictEqual(sessionDirectory, '/data/worktree/calm-wizard',
987
+ 'Session creation should use worktree as working directory');
988
988
  // Result directory is the worktree (where file operations happen)
989
989
  assert.strictEqual(result.directory, '/data/worktree/calm-wizard',
990
990
  'Result should include worktree directory');
@@ -1043,11 +1043,12 @@ Check for bugs and security issues.`;
1043
1043
  assert.ok(messageUrl.includes('%2Fpath%2Fto%2Fproject'), 'Message URL should include encoded directory path');
1044
1044
  });
1045
1045
 
1046
- test('uses projectDirectory for session creation, working directory for messages', async () => {
1046
+ test('uses workingDirectory for session creation, no PATCH when no title', async () => {
1047
1047
  const { createSessionViaApi } = await import('../../service/actions.js');
1048
1048
 
1049
1049
  const mockSessionId = 'ses_test_proj';
1050
1050
  let createUrl = null;
1051
+ let patchCalled = false;
1051
1052
  let messageUrl = null;
1052
1053
 
1053
1054
  const mockFetch = async (url, opts) => {
@@ -1058,6 +1059,11 @@ Check for bugs and security issues.`;
1058
1059
  return { ok: true, json: async () => ({ id: mockSessionId }) };
1059
1060
  }
1060
1061
 
1062
+ if (opts?.method === 'PATCH') {
1063
+ patchCalled = true;
1064
+ return { ok: true, json: async () => ({}) };
1065
+ }
1066
+
1061
1067
  if (urlObj.pathname.includes('/message') && opts?.method === 'POST') {
1062
1068
  messageUrl = url;
1063
1069
  return { ok: true, json: async () => ({ success: true }) };
@@ -1076,13 +1082,17 @@ Check for bugs and security issues.`;
1076
1082
  { fetch: mockFetch }
1077
1083
  );
1078
1084
 
1079
- // Session creation should use the project directory (for correct projectID scoping)
1080
- assert.ok(createUrl.includes('%2Fhome%2Fuser%2Fcode%2Fodin'),
1081
- 'Session creation should use projectDirectory');
1082
- assert.ok(!createUrl.includes('worktree'),
1083
- 'Session creation should NOT use the worktree path');
1085
+ // Session creation should use the worktree directory
1086
+ assert.ok(createUrl.includes('worktree'),
1087
+ 'Session creation should use workingDirectory (worktree path)');
1088
+ assert.ok(createUrl.includes('pr-415'),
1089
+ 'Session creation should use workingDirectory (worktree path)');
1090
+
1091
+ // No PATCH when no title is provided — no "project re-scoping" needed
1092
+ assert.strictEqual(patchCalled, false,
1093
+ 'PATCH should NOT be called when no title (no project re-scoping needed)');
1084
1094
 
1085
- // Message should use the working directory (for file operations in the worktree)
1095
+ // Message should use the working directory
1086
1096
  assert.ok(messageUrl.includes('worktree'),
1087
1097
  'Message should use the worktree working directory');
1088
1098
  assert.ok(messageUrl.includes('pr-415'),