opencode2-cow-worktree 0.1.1 → 0.3.0

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.
package/README.md CHANGED
@@ -148,7 +148,11 @@ clone (default `"none"`):
148
148
  - `"none"`: a request for `cow` produces a Deep clone or fails. Never a
149
149
  shallow worktree.
150
150
  - `"git"`: on a non-CoW filesystem the tool may build a regular `git`
151
- worktree instead and report `mechanism: "git"`.
151
+ worktree instead and report `mechanism: "git"`. This works on opencode2
152
+ builds whose worktree create still accepts a `strategy` request
153
+ (2.0.2-era). From the projectID-era API onward (20260915 nightlies,
154
+ v2.0.3+) the create always runs the selected strategy and ignores the
155
+ field, so the non-CoW refusal surfaces and the fallback cannot engage.
152
156
 
153
157
  **`targetRoot`** — where `spawn_workspace` places the worktree. Unset (the
154
158
  default) means a sibling of the source, on the source's filesystem by
@@ -206,7 +210,9 @@ If the requested name already belongs to a cow worktree, the call attaches: a
206
210
  new session binds to the existing directory and `attached: true` comes back —
207
211
  nothing is cloned. Attach only happens for worktrees this strategy
208
212
  materialized; anything else already at that path (a `git` worktree, an unknown
209
- directory) is refused before anything changes.
213
+ directory) is refused before anything changes. If the worktree's recorded
214
+ session still shows activity, the attach is refused and the error names the
215
+ occupying session and the ways to recover.
210
216
 
211
217
  A create whose target path already exists is refused before the first write:
212
218
  `cow` never merges into, or deletes, a directory it did not create. Resolve
@@ -235,8 +241,10 @@ regardless of `fallback`. Only `spawn_workspace` consults the policy.
235
241
  - **"the target is on a different filesystem"** — set `worktree.directory` as
236
242
  shown above, or point `targetRoot` at the source's filesystem.
237
243
  - **`cow` fails on an ext4 or tmpfs project** — expected: that filesystem
238
- cannot clone. Use the `git` fallback for tool calls, or let the project use
239
- the built-in strategy.
244
+ cannot clone. On opencode2 builds from the projectID era (20260915
245
+ nightlies, v2.0.3+) the `git` fallback cannot be requested through the
246
+ create API, so the refusal is final; on 2.0.2-era builds the `fallback:
247
+ "git"` option produces a regular git worktree for tool calls.
240
248
  - **The plugin is stuck on an old version** — opencode2 caches the package
241
249
  under `~/.cache/opencode/node_modules/`. Remove the plugin's cache
242
250
  directory and restart: `rm -rf ~/.cache/opencode/node_modules/opencode2-cow-worktree`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode2-cow-worktree",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "Copy-on-write worktree strategy for opencode2: a Deep clone of the whole working directory, ignored files included, so parallel agents start ready to run",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,251 @@
1
+ /**
2
+ * The occupancy guard for `spawn_workspace` (issue #15): a cow worktree carries
3
+ * a marker naming the session that holds it, and an attach refuses while that
4
+ * session is still alive.
5
+ *
6
+ * The decision half is pure and table-driven, in the style of `dirty.ts`, so
7
+ * the refusal table is testable without a filesystem or opencode2. The probe
8
+ * half classifies one session lookup; the file half owns the marker file and
9
+ * the git exclude entry that keeps it out of `git status`.
10
+ *
11
+ * opencode2 sessions persist after completion and carry no status field — the
12
+ * record's `time.updated` (epoch milliseconds) is the only liveness signal a
13
+ * probe gets (verified against 0.0.0-next-20260912.3). Dormancy is therefore a
14
+ * time judgement, not a state read, and the guard fails closed wherever that
15
+ * judgement cannot be made: a probe that errors, and a record whose
16
+ * `time.updated` is missing or unusable, both refuse.
17
+ */
18
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
19
+ import { dirname, join } from "node:path";
20
+
21
+ /** The worktree-root file naming the session that holds the worktree. */
22
+ export const MARKER_NAME = ".cow-session.json";
23
+
24
+ /**
25
+ * How long a session may stay silent before it no longer counts as holding a
26
+ * worktree. `time.updated` advances at message writes only (probe 2026-09-14:
27
+ * exactly 2 distinct values across a completed sim turn), so a turn silent
28
+ * longer than this window false-clears; false clearance is the worse
29
+ * direction, so the window is set to the generous end.
30
+ */
31
+ export const OCCUPIED_AFTER_MS = 60 * 60 * 1000;
32
+
33
+ /**
34
+ * The marker payload. `startedAt` is informational, written for a human
35
+ * reading the file: decisions never compare it against the server's
36
+ * `time.updated` — the two come from different clocks.
37
+ */
38
+ export interface OccupancyMarker {
39
+ readonly sessionID: string;
40
+ readonly startedAt?: string;
41
+ }
42
+
43
+ /** A marker file read's three answers: a marker, no marker, or an unusable one. */
44
+ export type ParsedMarker = OccupancyMarker | undefined | "malformed";
45
+
46
+ /** What one probe of the marker's session reported. */
47
+ export type ProbeResult =
48
+ | { readonly kind: "absent" }
49
+ | { readonly kind: "live"; readonly updated: number }
50
+ | { readonly kind: "error" };
51
+
52
+ /** Whether the attach may proceed, and why not when it may not. */
53
+ export type OccupancyDecision =
54
+ | { readonly action: "refuse"; readonly reason: string }
55
+ | { readonly action: "proceed"; readonly rewrite: boolean };
56
+
57
+ /**
58
+ * The three ways out of an occupancy refusal, spelled out in full because the
59
+ * refusal is the only place the guard explains itself. Exactly three, in the
60
+ * order a blocked caller reaches for them.
61
+ */
62
+ export const RECOVERIES =
63
+ "Pick another worktree name; delete the occupying session out of band " +
64
+ "(DELETE /api/session/<sessionID> against this server); or remove the " +
65
+ `occupancy marker (${MARKER_NAME}) from the worktree root if you know it is stale.`;
66
+
67
+ /**
68
+ * Parses a marker file's raw content. An absent file is `undefined`; anything
69
+ * that does not parse as JSON, or that names no session, is `"malformed"` —
70
+ * the guard cannot tell who holds the worktree, so it will not guess.
71
+ */
72
+ export function parseMarker(raw: string | undefined): ParsedMarker {
73
+ if (raw === undefined) return undefined;
74
+ let parsed: unknown;
75
+ try {
76
+ parsed = JSON.parse(raw);
77
+ } catch {
78
+ return "malformed";
79
+ }
80
+ if (typeof parsed !== "object" || parsed === null) return "malformed";
81
+ const sessionID = (parsed as { sessionID?: unknown }).sessionID;
82
+ if (typeof sessionID !== "string" || sessionID.length === 0) return "malformed";
83
+ const startedAt = (parsed as { startedAt?: unknown }).startedAt;
84
+ return typeof startedAt === "string" ? { sessionID, startedAt } : { sessionID };
85
+ }
86
+
87
+ /**
88
+ * Classifies one `sessionGet` probe of the session a marker names.
89
+ *
90
+ * The verified server contract: an absent id makes the lookup throw with
91
+ * `_tag: "SessionNotFoundError"` (HTTP 404), which is a *positive* absence —
92
+ * the one answer that proves the worktree free. A resolved `null` or
93
+ * `undefined` counts as absent too. Anything else is an unanswerable probe: a
94
+ * throw with any other tag, a non-object body, or a body without a string id
95
+ * all refuse rather than guess, and so does a body whose `time.updated` is not
96
+ * a usable epoch-milliseconds number — a malformed 200 must never become
97
+ * dormancy, because naive `now - updated` arithmetic over a missing or
98
+ * non-numeric value would read as endlessly idle.
99
+ */
100
+ export async function probeOccupyingSession(
101
+ sessionGet: (id: string) => Promise<unknown>,
102
+ sessionID: string,
103
+ ): Promise<ProbeResult> {
104
+ let answer: unknown;
105
+ try {
106
+ answer = await sessionGet(sessionID);
107
+ } catch (cause) {
108
+ return isSessionNotFound(cause) ? { kind: "absent" } : { kind: "error" };
109
+ }
110
+ if (answer === null || answer === undefined) return { kind: "absent" };
111
+ if (typeof answer !== "object") return { kind: "error" };
112
+ const record = answer as { id?: unknown; time?: { updated?: unknown } };
113
+ if (typeof record.id !== "string") return { kind: "error" };
114
+ const updated = record.time?.updated;
115
+ return typeof updated === "number" && Number.isFinite(updated) && updated >= 0
116
+ ? { kind: "live", updated }
117
+ : { kind: "error" };
118
+ }
119
+
120
+ function isSessionNotFound(cause: unknown): boolean {
121
+ // The only positive-absence signals from a throw, both verified: the HTTP
122
+ // payload tags the 404 `SessionNotFoundError`, while the error the
123
+ // plugin-side lookup throws is `Session.NotFoundError` with an empty
124
+ // message. No message matching: a substring is not a tag, and an unrelated
125
+ // gateway error that happens to phrase itself like a 404 must refuse, not
126
+ // clear the worktree.
127
+ return (
128
+ typeof cause === "object" &&
129
+ cause !== null &&
130
+ ((cause as { _tag?: unknown })._tag === "SessionNotFoundError" ||
131
+ (cause as { _tag?: unknown })._tag === "Session.NotFoundError")
132
+ );
133
+ }
134
+
135
+ /**
136
+ * The occupancy decision table.
137
+ *
138
+ * Refusals, in the order checked: a malformed marker (the holder is unknown),
139
+ * a probe error (a failed lookup is not a positive absence), a live record
140
+ * whose `time.updated` is unusable (fail closed — the naive arithmetic would
141
+ * call it dormant), and a live record still inside the occupancy window.
142
+ * Otherwise the attach proceeds; `rewrite` says whether an old marker is being
143
+ * replaced — a stale one the probe proved dead, or a dormant one past the
144
+ * window — as opposed to a first marker for a worktree that never had one.
145
+ */
146
+ export function occupancyDecision(input: {
147
+ readonly marker: ParsedMarker;
148
+ readonly probe: ProbeResult;
149
+ readonly now: number;
150
+ }): OccupancyDecision {
151
+ if (input.marker === "malformed") {
152
+ return {
153
+ action: "refuse",
154
+ reason: `the occupancy marker is unparsable or names no session, so the holder is unknown. ${RECOVERIES}`,
155
+ };
156
+ }
157
+ if (input.probe.kind === "error") {
158
+ return {
159
+ action: "refuse",
160
+ reason: `the session holding this worktree could not be checked, and a failed lookup is not proof it is gone. ${RECOVERIES}`,
161
+ };
162
+ }
163
+ if (input.probe.kind === "live") {
164
+ const updated = input.probe.updated;
165
+ // Deliberate defense-in-depth, not dead code: the classifier already maps
166
+ // an unusable `updated` to a probe error, so this row only fires if that
167
+ // classification ever drifts.
168
+ if (!Number.isFinite(updated) || updated < 0) {
169
+ return {
170
+ action: "refuse",
171
+ reason: `the session holding this worktree reports no usable last-activity time. ${RECOVERIES}`,
172
+ };
173
+ }
174
+ const idle = input.now - updated;
175
+ if (!Number.isFinite(idle) || idle <= OCCUPIED_AFTER_MS) {
176
+ const holder = input.marker === undefined ? "A session" : `Session ${input.marker.sessionID}`;
177
+ return { action: "refuse", reason: occupiedMessage(holder, idle) };
178
+ }
179
+ }
180
+ return { action: "proceed", rewrite: input.marker !== undefined };
181
+ }
182
+
183
+ function occupiedMessage(holder: string, idleMs: number): string {
184
+ return `${holder} appears to still be using it — its last activity was ${humanAge(idleMs)} ago. ${RECOVERIES}`;
185
+ }
186
+
187
+ /** A last-active age in human form; an unanswerable age never reads as fresh. */
188
+ function humanAge(ms: number): string {
189
+ if (!Number.isFinite(ms) || ms < 0) return "an unknown amount of time";
190
+ if (ms < 60_000) return "less than a minute";
191
+ const minutes = Math.floor(ms / 60_000);
192
+ if (minutes < 60) return `${minutes} minute(s)`;
193
+ const hours = Math.floor(minutes / 60);
194
+ if (hours < 24) return `${hours} hour(s)`;
195
+ return `${Math.floor(hours / 24)} day(s)`;
196
+ }
197
+
198
+ // The file half: the marker file and its git exclude entry. Read failures
199
+ // other than "not there" propagate — an unreadable marker is not evidence of a
200
+ // free worktree — and write failures propagate to the caller's warning path.
201
+
202
+ const EXCLUDE_APPEND = `\n# opencode2-cow-worktree occupancy marker\n${MARKER_NAME}\n`;
203
+
204
+ /**
205
+ * The raw marker file's content, or `undefined` when there is none. Any other
206
+ * read failure (permissions, an obstacle in the path) propagates.
207
+ */
208
+ export async function readMarkerFile(directory: string): Promise<string | undefined> {
209
+ try {
210
+ return await readFile(join(directory, MARKER_NAME), "utf8");
211
+ } catch (cause) {
212
+ if (isNotFound(cause)) return undefined;
213
+ throw cause;
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Writes the marker naming `sessionID`, first ensuring the worktree's
219
+ * `.git/info/exclude` ignores the marker file — the exclude line goes in
220
+ * before the marker exists, so `git status` never sees it, not even in the
221
+ * moment between the two writes. Both steps are idempotent, and both
222
+ * propagate their failures: the caller demotes them to a warning, never a
223
+ * failed spawn.
224
+ */
225
+ export async function writeMarkerFile(directory: string, sessionID: string): Promise<void> {
226
+ await ensureGitExclude(directory);
227
+ const marker: OccupancyMarker = { sessionID, startedAt: new Date().toISOString() };
228
+ await writeFile(join(directory, MARKER_NAME), `${JSON.stringify(marker, null, 2)}\n`, "utf8");
229
+ }
230
+
231
+ async function ensureGitExclude(directory: string): Promise<void> {
232
+ const excludePath = join(directory, ".git", "info", "exclude");
233
+ const current = await readFile(excludePath, "utf8").catch((cause) => {
234
+ if (isNotFound(cause)) return undefined;
235
+ throw cause;
236
+ });
237
+ if (current !== undefined && hasExcludeEntry(current)) return;
238
+ await mkdir(dirname(excludePath), { recursive: true });
239
+ await writeFile(excludePath, (current ?? "") + EXCLUDE_APPEND, "utf8");
240
+ }
241
+
242
+ /** Whether the exclude already carries the marker entry as its own line. */
243
+ function hasExcludeEntry(content: string): boolean {
244
+ return content.split("\n").some((line) => line.trim() === MARKER_NAME);
245
+ }
246
+
247
+ function isNotFound(cause: unknown): boolean {
248
+ return (
249
+ typeof cause === "object" && cause !== null && (cause as { code?: unknown }).code === "ENOENT"
250
+ );
251
+ }
package/src/plugin.ts CHANGED
@@ -3,6 +3,7 @@ import { stat } from "node:fs/promises";
3
3
  import { probeCowCapability } from "./capability";
4
4
  import { fallbackPolicy, postCreateHooks, targetRoot } from "./config";
5
5
  import { deviceOf, isDirectory } from "./device";
6
+ import { readMarkerFile, writeMarkerFile } from "./occupancy";
6
7
  import { listCowWorktrees, spawnWorkspace } from "./tool";
7
8
  import type {
8
9
  FallbackPolicy,
@@ -60,6 +61,11 @@ const spawnWorkspaceOutput = {
60
61
  description:
61
62
  "True when the session was attached to an existing worktree instead of a new clone.",
62
63
  },
64
+ markerWarning: {
65
+ type: "string",
66
+ description:
67
+ "Set when the occupancy marker could not be written after a successful session start.",
68
+ },
63
69
  },
64
70
  required: ["sessionID", "directory", "mechanism"],
65
71
  additionalProperties: false,
@@ -140,21 +146,35 @@ function liveDeps(
140
146
  return {
141
147
  probe: probeCowCapability,
142
148
  probeDevice: deviceOf,
143
- listWorktrees: () => ctx.worktree.list(),
149
+ // The projectID-era API (20260915 nightlies onward) requires the project
150
+ // id on every worktree call and no longer accepts a `location` query, so
151
+ // all three seams derive it from the plugin's own location context.
152
+ listWorktrees: () => ctx.worktree.list({ projectID: ctx.location.project.id }),
144
153
  isDirectory,
145
154
  createWorktree: (input) =>
146
155
  ctx.worktree.create({
147
- strategy: input.strategy,
148
- name: input.name,
149
- location: { directory: input.sourceDirectory },
156
+ projectID: ctx.location.project.id,
157
+ from: input.sourceDirectory,
150
158
  directory: input.parentDirectory,
159
+ name: input.name,
160
+ // Ignored by projectID-era binaries (the selected strategy wins);
161
+ // honored by the 2.0.2-era API, where the git fallback needs it.
162
+ strategy: input.strategy,
151
163
  }),
152
164
  createSession: async (directory, name) => {
153
165
  const session = await ctx.session.create({ title: name, location: { directory } });
154
166
  return session.id;
155
167
  },
156
168
  removeWorktree: (directory) =>
157
- ctx.worktree.remove({ directory, force: true }),
169
+ ctx.worktree.remove({ projectID: ctx.location.project.id, directory, force: true }),
170
+ // `ctx.session.get` exists on the v2 runtime but is untyped in the
171
+ // installed beta; types/opencode2-worktree.d.ts declares the verified
172
+ // `{ sessionID }` shape and the structural record slice the occupancy
173
+ // guard reads.
174
+ sessionGet: (id) => ctx.session.get({ sessionID: id }),
175
+ readMarker: readMarkerFile,
176
+ writeMarker: writeMarkerFile,
177
+ now: Date.now,
158
178
  fallback,
159
179
  targetRoot: worktreeRoot,
160
180
  };
@@ -165,9 +185,11 @@ function liveDeps(
165
185
  *
166
186
  * `setup` registers the `cow` Strategy through the worktree seam, and the
167
187
  * `spawn_workspace` and `list_worktrees` tools through the tool seam.
168
- * Registering the Strategy also selects it as the Location default; opencode2's
169
- * registry still lets a caller name the built-in `git` strategy explicitly, so
170
- * this module does not touch that behavior.
188
+ * Registering the Strategy also selects it as the default; the projectID-era
189
+ * create API has no per-request strategy field, so the selected strategy is
190
+ * what every create uses. The create call still carries `strategy` for
191
+ * 2.0.2-era binaries, where it is what lets the tool's opt-in `git` fallback
192
+ * name the built-in git strategy.
171
193
  */
172
194
  export default {
173
195
  id: "opencode2-cow-worktree",
@@ -192,7 +214,9 @@ export default {
192
214
  description:
193
215
  "Create a worktree and start a session in it, reporting the mechanism that produced the directory. " +
194
216
  "When a worktree with the requested name already exists and was produced by the cow strategy, the " +
195
- "session is attached to it instead of creating a new directory; anything else at that name is refused.",
217
+ "session is attached to it instead of creating a new directory; anything else at that name is refused. " +
218
+ "If the worktree's recorded session still shows activity, the attach is refused and the error names " +
219
+ "the occupying session and the ways to recover.",
196
220
  input: spawnWorkspaceInput,
197
221
  output: spawnWorkspaceOutput,
198
222
  // A tool defaults into CodeMode, which advertises it to the model only
@@ -203,10 +227,13 @@ export default {
203
227
  const result = await spawnWorkspace(input, liveDeps(ctx, fallback, worktreeRoot));
204
228
  // The text is what tells attach from create: on attach `mechanism`
205
229
  // reports the found directory's mechanism, and the caller must never
206
- // read that as a fresh clone having happened.
207
- const content = result.attached
208
- ? `Attached to existing cow worktree at ${result.directory} (session ${result.sessionID}); no new worktree was created.`
209
- : `Created ${result.mechanism} worktree at ${result.directory} (session ${result.sessionID}).`;
230
+ // read that as a fresh clone having happened. A marker-write failure
231
+ // never fails the call; it rides along as a warning line.
232
+ const content =
233
+ (result.attached
234
+ ? `Attached to existing cow worktree at ${result.directory} (session ${result.sessionID}); no new worktree was created.`
235
+ : `Created ${result.mechanism} worktree at ${result.directory} (session ${result.sessionID}).`) +
236
+ (result.markerWarning === undefined ? "" : `\nwarning: ${result.markerWarning}`);
210
237
  return {
211
238
  output: result,
212
239
  content,
@@ -233,7 +260,7 @@ export default {
233
260
  // the inventory and one stat per row, none of spawn_workspace's
234
261
  // other seams. (Option validation happens once in setup, so there
235
262
  // is no validation side effect to dodge either way.)
236
- listWorktrees: () => ctx.worktree.list(),
263
+ listWorktrees: () => ctx.worktree.list({ projectID: ctx.location.project.id }),
237
264
  statEntry: stat,
238
265
  });
239
266
  const summary =
package/src/tool.ts CHANGED
@@ -3,7 +3,8 @@ import { basename, join, resolve } from "node:path";
3
3
  import { assertSameDevice } from "./device";
4
4
  import type { CowCapability } from "./capability";
5
5
  import type { Mechanism } from "./mechanism";
6
- import type { WorktreeInventoryEntry } from "../types/opencode2-worktree";
6
+ import { MARKER_NAME, RECOVERIES, occupancyDecision, parseMarker, probeOccupyingSession } from "./occupancy";
7
+ import type { SessionGetResult, WorktreeInventoryEntry } from "../types/opencode2-worktree";
7
8
 
8
9
  /** What a successful `spawnWorkspace` produced. */
9
10
  export interface SpawnWorkspaceResult {
@@ -17,6 +18,13 @@ export interface SpawnWorkspaceResult {
17
18
  * directory that was found, not of a clone this call performed.
18
19
  */
19
20
  readonly attached?: boolean;
21
+ /**
22
+ * Set when the occupancy marker could not be written after a successful
23
+ * session start. The session is live either way — the marker only arms the
24
+ * occupancy guard for the next spawn — so the failure rides along as a
25
+ * warning instead of failing the call.
26
+ */
27
+ readonly markerWarning?: string;
20
28
  }
21
29
 
22
30
  /** Whether a caller who asked for a CoW clone may be given a Shallow worktree. */
@@ -69,6 +77,23 @@ export interface SpawnWorkspaceDeps {
69
77
  * without a filesystem.
70
78
  */
71
79
  readonly isDirectory: (path: string) => Promise<boolean>;
80
+ /**
81
+ * Reads one session by id — the occupancy guard probes the session a marker
82
+ * names. Declared total because the classifier treats every throw and every
83
+ * odd shape as an answer (see `probeOccupyingSession`); the live binding's
84
+ * 404 arrives as a thrown `SessionNotFoundError`.
85
+ */
86
+ readonly sessionGet: (id: string) => Promise<SessionGetResult>;
87
+ /** The raw occupancy marker file's content, or `undefined` when there is none. */
88
+ readonly readMarker: (directory: string) => Promise<string | undefined>;
89
+ /**
90
+ * Writes the occupancy marker for a fresh session, git-excluding it first.
91
+ * A failure here is demoted to the result's `markerWarning`, never a failed
92
+ * spawn: an unmarked worktree is the pre-guard status quo, not an error.
93
+ */
94
+ readonly writeMarker: (directory: string, sessionID: string) => Promise<void>;
95
+ /** Wall-clock milliseconds. Injected so dormancy is testable. */
96
+ readonly now: () => number;
72
97
  /** Defaults to `"none"`: a request for `cow` is a statement about what you get. */
73
98
  readonly fallback?: FallbackPolicy;
74
99
  /** Worktree parent. Defaults to a same-filesystem sibling of the source. */
@@ -155,7 +180,17 @@ export async function spawnWorkspace(
155
180
 
156
181
  try {
157
182
  const sessionID = await deps.createSession(worktree.directory, input.name);
158
- return { sessionID, directory: worktree.directory, mechanism };
183
+ // Cow only: a git worktree's `.git` is a file, so the exclude the marker
184
+ // needs has no place to live — no marker, no exclude; the guard is a
185
+ // cow-worktree contract.
186
+ const markerWarning =
187
+ mechanism === "cow" ? await markerWarningOf(worktree.directory, sessionID, deps) : undefined;
188
+ return {
189
+ sessionID,
190
+ directory: worktree.directory,
191
+ mechanism,
192
+ ...(markerWarning === undefined ? {} : { markerWarning }),
193
+ };
159
194
  } catch (cause) {
160
195
  // The cleanup is best-effort: it must never replace the failure that
161
196
  // triggered it. `removeWorktree` goes through opencode2's DELETE route,
@@ -277,7 +312,9 @@ async function tryAttach(
277
312
  * The attach decision for a path that already exists. The inventory is the
278
313
  * source of truth for "ours" (ADR 0003): an entry recorded with `cow` plus the
279
314
  * Deep-clone signature (`.git` is a directory) means the `cow` strategy
280
- * materialized this directory, so a new session may bind to it. Every other
315
+ * materialized this directory, so a new session may bind to it — and the
316
+ * occupancy marker then decides whether it may, while the session the marker
317
+ * names is still alive (`assertOccupancyFree`). Every other
281
318
  * answer is refused loudly — before any session is created and with no
282
319
  * filesystem change — because a Foreign worktree, another strategy's Worktree,
283
320
  * or a `cow` row that lost its Deep-clone shape is not this tool's to attach
@@ -296,9 +333,19 @@ async function attachToExisting(
296
333
  if (!(await deps.isDirectory(join(target, ".git")))) {
297
334
  throw notDeepCloneError(target);
298
335
  }
336
+ await assertOccupancyFree(target, deps);
299
337
  try {
300
338
  const sessionID = await deps.createSession(target, name);
301
- return { sessionID, directory: target, mechanism: "cow", attached: true };
339
+ // Written even when no marker existed (a legacy worktree): from the next
340
+ // spawn onward, this guard is what protects the directory.
341
+ const markerWarning = await markerWarningOf(target, sessionID, deps);
342
+ return {
343
+ sessionID,
344
+ directory: target,
345
+ mechanism: "cow",
346
+ attached: true,
347
+ ...(markerWarning === undefined ? {} : { markerWarning }),
348
+ };
302
349
  } catch (cause) {
303
350
  // No removeWorktree here, unlike the create flow: the directory existed
304
351
  // before this call, so a failed session start must leave it untouched.
@@ -306,6 +353,78 @@ async function attachToExisting(
306
353
  }
307
354
  }
308
355
 
356
+ /**
357
+ * The occupancy half of attach (issue #15), run after the Deep-clone signature
358
+ * check: the marker decides whether the worktree is free to attach. No marker
359
+ * is a legacy worktree — it attaches, and the marker is written on the way
360
+ * out. A marker naming a live session refuses, naming the holder and the way
361
+ * out; a marker whose session is gone (a 404) or past the occupancy window is
362
+ * stale, and the attach proceeds to rewrite it.
363
+ */
364
+ async function assertOccupancyFree(target: string, deps: SpawnWorkspaceDeps): Promise<void> {
365
+ let raw: string | undefined;
366
+ try {
367
+ raw = await deps.readMarker(target);
368
+ } catch (cause) {
369
+ // readMarker's live binding answers `undefined` for a marker that is not
370
+ // there, so a throw here is EACCES-class: an unreadable marker is not
371
+ // evidence of a free worktree.
372
+ const reason = cause instanceof Error ? cause.message : String(cause);
373
+ throw markerReadError(target, reason);
374
+ }
375
+ const marker = parseMarker(raw);
376
+ if (marker === "malformed") throw malformedMarkerError(target);
377
+ const probe =
378
+ marker === undefined
379
+ ? { kind: "absent" as const }
380
+ : await probeOccupyingSession(deps.sessionGet, marker.sessionID);
381
+ const decision = occupancyDecision({ marker, probe, now: deps.now() });
382
+ if (decision.action === "refuse") {
383
+ throw new Error(`refusing to attach to ${target}: ${decision.reason}`);
384
+ }
385
+ }
386
+
387
+ /**
388
+ * The refusal for a marker that cannot be read: the guard cannot tell who
389
+ * holds the worktree, so it will not hand it out.
390
+ */
391
+ function markerReadError(target: string, reason: string): Error {
392
+ return new Error(
393
+ `refusing to attach to ${target}: the occupancy marker at ` +
394
+ `${join(target, MARKER_NAME)} could not be read (${reason}). ${RECOVERIES}`,
395
+ );
396
+ }
397
+
398
+ /**
399
+ * The refusal for a marker that cannot be parsed: the guard cannot tell who
400
+ * holds the worktree, so it will not hand it out.
401
+ */
402
+ function malformedMarkerError(target: string): Error {
403
+ return new Error(
404
+ `refusing to attach to ${target}: ${join(target, MARKER_NAME)} is malformed — ` +
405
+ `it does not parse as JSON or names no session, so the holder is unknown. ${RECOVERIES}`,
406
+ );
407
+ }
408
+
409
+ /**
410
+ * Writes the occupancy marker through the injected seam, demoting a failure to
411
+ * the warning string the result carries. The session is live either way; the
412
+ * marker only arms the guard for the next spawn.
413
+ */
414
+ async function markerWarningOf(
415
+ directory: string,
416
+ sessionID: string,
417
+ deps: Pick<SpawnWorkspaceDeps, "writeMarker">,
418
+ ): Promise<string | undefined> {
419
+ try {
420
+ await deps.writeMarker(directory, sessionID);
421
+ return undefined;
422
+ } catch (cause) {
423
+ const reason = cause instanceof Error ? cause.message : String(cause);
424
+ return `the occupancy marker could not be written to ${directory}: ${reason}`;
425
+ }
426
+ }
427
+
309
428
  /**
310
429
  * The inventory entry recorded for a directory, when the inventory knows it.
311
430
  *