@clipboard-health/groundcrew 2.1.1 → 2.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.
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { runCommandAsync } from "./commandRunner.js";
8
8
  import { detectHostCapabilities } from "./host.js";
9
+ import { shellSingleQuote } from "./shell.js";
9
10
  import { errorMessage, log, readEnvironmentVariable } from "./util.js";
10
11
  async function runWorkspaceCommand(command, arguments_, signal) {
11
12
  return signal === undefined
@@ -24,23 +25,30 @@ function parseCmuxList(output) {
24
25
  if (typeof ws.title !== "string" || ws.title.length === 0) {
25
26
  continue;
26
27
  }
27
- items.push({ title: ws.title, ref: pickCmuxRef({ ...ws, title: ws.title }) });
28
+ const id = pickCmuxId(ws);
29
+ if (id === undefined) {
30
+ log(`cmux list-workspaces returned workspace "${ws.title}" without a usable id or ref; skipping`);
31
+ continue;
32
+ }
33
+ items.push({ title: ws.title, id });
28
34
  }
29
35
  return items;
30
36
  }
31
37
  /**
32
- * Pick the most-specific identifier cmux returned for this workspace.
33
- * Caller has already verified `title` is non-empty, so the title fallback
34
- * is always defined.
38
+ * The stable workspace handle cmux v2 expects in JSON-RPC params. Prefer
39
+ * the UUID; fall back to the legacy `workspace:N` short ref when older
40
+ * cmux builds don't surface it. Returns `undefined` when neither is
41
+ * available — cmux v2 `workspace.close` rejects titles, so we must never
42
+ * forward `title` as a workspace handle.
35
43
  */
36
- function pickCmuxRef(ws) {
37
- if (typeof ws.ref === "string" && ws.ref.length > 0) {
38
- return ws.ref;
39
- }
44
+ function pickCmuxId(ws) {
40
45
  if (typeof ws.id === "string" && ws.id.length > 0) {
41
46
  return ws.id;
42
47
  }
43
- return ws.title;
48
+ if (typeof ws.ref === "string" && ws.ref.length > 0) {
49
+ return ws.ref;
50
+ }
51
+ return undefined;
44
52
  }
45
53
  async function listCmuxRaw(signal) {
46
54
  try {
@@ -54,13 +62,17 @@ async function listCmuxRaw(signal) {
54
62
  return undefined;
55
63
  }
56
64
  }
57
- function extractCmuxOpenRef(output) {
65
+ function extractCmuxOpenId(output) {
58
66
  try {
59
- // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- cmux --json prints a workspace ref/id object
67
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- cmux --json prints a workspace_id/ref object
60
68
  const parsed = JSON.parse(output);
61
- const candidate = parsed.ref ?? parsed.id ?? "";
62
- if (candidate.length > 0) {
63
- return candidate;
69
+ const uuid = parsed.workspace_id ?? parsed.id ?? "";
70
+ if (uuid.length > 0) {
71
+ return uuid;
72
+ }
73
+ const ref = parsed.workspace_ref ?? parsed.ref ?? "";
74
+ if (ref.length > 0) {
75
+ return ref;
64
76
  }
65
77
  }
66
78
  catch {
@@ -69,7 +81,91 @@ function extractCmuxOpenRef(output) {
69
81
  const match = /workspace:\d+/.exec(output);
70
82
  return match ? match[0] : undefined;
71
83
  }
72
- async function applyCmuxStatus(ref, status, signal) {
84
+ /**
85
+ * Inspect `cmux current-workspace`. When groundcrew is itself launched
86
+ * inside a cmux SSH workspace, `workspace.create` lands the new workspace
87
+ * on the local (macOS) cmux app rather than the remote where the agent's
88
+ * worktree lives. We can't replicate cmux's full SSH bootstrap
89
+ * (relay_port, daemon, etc.) from the remote side, so we instead wrap the
90
+ * agent launch command in a plain `ssh` to the same destination. Returns
91
+ * `undefined` when there is nothing to inherit, leaving callers free to
92
+ * launch locally as usual.
93
+ */
94
+ async function probeCurrentCmuxRemote(signal) {
95
+ if (readEnvironmentVariable("CMUX_WORKSPACE_ID") === undefined) {
96
+ return undefined;
97
+ }
98
+ let output;
99
+ try {
100
+ output = await runWorkspaceCommand("cmux", ["--json", "current-workspace"], signal);
101
+ }
102
+ catch (error) {
103
+ if (isSignalAborted(signal)) {
104
+ throw error;
105
+ }
106
+ // CMUX_WORKSPACE_ID is set, so we are inside a cmux workspace and a
107
+ // probe failure means we cannot tell whether this is an SSH context.
108
+ // Silently degrading to the local path would point cmux at a working
109
+ // directory that lives on a remote host; surface the failure instead
110
+ // so the caller can roll the worktree back rather than launch into
111
+ // the void.
112
+ throw new Error(`cmux current-workspace probe failed while CMUX_WORKSPACE_ID is set: ${errorMessage(error)}`, { cause: error });
113
+ }
114
+ try {
115
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- cmux --json current-workspace shape per v2 API
116
+ const parsed = JSON.parse(output);
117
+ const remote = parsed.workspace?.remote;
118
+ if (remote === undefined ||
119
+ remote.connected !== true ||
120
+ remote.transport !== "ssh" ||
121
+ typeof remote.destination !== "string" ||
122
+ remote.destination.length === 0) {
123
+ return undefined;
124
+ }
125
+ const inherited = { destination: remote.destination };
126
+ if (typeof remote.port === "number") {
127
+ inherited.port = remote.port;
128
+ }
129
+ if (typeof remote.identity_file === "string" && remote.identity_file.length > 0) {
130
+ inherited.identity_file = remote.identity_file;
131
+ }
132
+ if (Array.isArray(remote.ssh_options) && remote.ssh_options.length > 0) {
133
+ inherited.ssh_options = remote.ssh_options;
134
+ }
135
+ return inherited;
136
+ }
137
+ catch (error) {
138
+ // Same reasoning as the command-failure branch above: with
139
+ // CMUX_WORKSPACE_ID set, malformed JSON means we cannot decide
140
+ // between local and SSH context, so refuse rather than silently
141
+ // launching at the wrong working directory.
142
+ throw new Error(`cmux current-workspace returned malformed output while CMUX_WORKSPACE_ID is set: ${errorMessage(error)}`, { cause: error });
143
+ }
144
+ }
145
+ /**
146
+ * Compose an `ssh -t <destination> -- <cd && cmd>` invocation that lands
147
+ * a new cmux workspace's terminal on the same SSH remote where
148
+ * groundcrew is running. Path-bearing fields (`cwd`, the launch script
149
+ * inside `command`) stay valid because the remote shell evaluates them.
150
+ * The outermost return value is a single shell string suitable for
151
+ * `cmux new-workspace --command`.
152
+ */
153
+ function buildSshWrappedCommand(spec, remote) {
154
+ const remoteShell = `cd ${shellSingleQuote(spec.cwd)} && ${spec.command}`;
155
+ const sshTokens = ["ssh", "-t"];
156
+ if (remote.port !== undefined) {
157
+ sshTokens.push("-p", String(remote.port));
158
+ }
159
+ if (remote.identity_file !== undefined) {
160
+ sshTokens.push("-i", shellSingleQuote(remote.identity_file));
161
+ }
162
+ for (const option of remote.ssh_options ?? []) {
163
+ sshTokens.push("-o", shellSingleQuote(option));
164
+ }
165
+ sshTokens.push(shellSingleQuote(remote.destination), "--", shellSingleQuote(remoteShell));
166
+ return sshTokens.join(" ");
167
+ }
168
+ async function applyCmuxStatus(workspaceId, status, signal) {
73
169
  const arguments_ = ["set-status", "model", status.text];
74
170
  if (status.icon !== undefined) {
75
171
  arguments_.push("--icon", status.icon);
@@ -77,41 +173,40 @@ async function applyCmuxStatus(ref, status, signal) {
77
173
  if (status.color !== undefined) {
78
174
  arguments_.push("--color", status.color);
79
175
  }
80
- arguments_.push("--workspace", ref);
176
+ arguments_.push("--workspace", workspaceId);
81
177
  await runWorkspaceCommand("cmux", arguments_, signal);
82
178
  }
83
- async function closeCmuxWorkspace(refOrName, signal) {
84
- await runWorkspaceCommand("cmux", ["close-workspace", "--workspace", refOrName], signal);
179
+ async function closeCmuxWorkspace(workspaceId, signal) {
180
+ await runWorkspaceCommand("cmux", ["close-workspace", "--workspace", workspaceId], signal);
85
181
  }
86
182
  const cmuxAdapter = {
87
183
  async open(spec, signal) {
88
- const output = await runWorkspaceCommand("cmux", [
89
- "--json",
90
- "new-workspace",
91
- "--name",
92
- spec.name,
93
- "--cwd",
94
- spec.cwd,
95
- "--command",
96
- spec.command,
97
- ], signal);
98
- const ref = extractCmuxOpenRef(output);
99
- if (ref === undefined) {
184
+ const inheritedRemote = await probeCurrentCmuxRemote(signal);
185
+ const newWorkspaceArguments = ["--json", "new-workspace", "--name", spec.name];
186
+ if (inheritedRemote === undefined) {
187
+ newWorkspaceArguments.push("--working-directory", spec.cwd, "--command", spec.command);
188
+ }
189
+ else {
190
+ // Skip --working-directory: the path is on the SSH remote and would
191
+ // fall back to $HOME (macOS) when cmux tries to chdir locally. The
192
+ // wrapped ssh command does its own `cd` on the remote side.
193
+ newWorkspaceArguments.push("--command", buildSshWrappedCommand(spec, inheritedRemote));
194
+ }
195
+ const output = await runWorkspaceCommand("cmux", newWorkspaceArguments, signal);
196
+ const workspaceId = extractCmuxOpenId(output);
197
+ if (workspaceId === undefined) {
100
198
  log(`cmux new-workspace returned unrecognized output for ${spec.name}; if a workspace was created, run \`cmux close-workspace\` manually.`);
101
199
  throw new Error(`Unexpected cmux output: ${output}`);
102
200
  }
103
201
  if (spec.status !== undefined) {
104
202
  try {
105
- await applyCmuxStatus(ref, spec.status, signal);
203
+ await applyCmuxStatus(workspaceId, spec.status, signal);
106
204
  }
107
205
  catch (error) {
108
- try {
109
- await closeCmuxWorkspace(ref, signal);
110
- }
111
- catch (closeError) {
112
- log(`cmux close-workspace failed for ${spec.name}: ${errorMessage(closeError)}`);
113
- }
114
- throw error;
206
+ // v2 cmux builds may not implement `set-status`; status pills are
207
+ // a nice-to-have, not load-bearing. Log and keep the workspace
208
+ // rather than tearing down a successful launch.
209
+ log(`cmux set-status failed for ${spec.name} (continuing): ${errorMessage(error)}`);
115
210
  }
116
211
  }
117
212
  },
@@ -122,7 +217,10 @@ const cmuxAdapter = {
122
217
  async close(name, signal) {
123
218
  const raw = await listCmuxRaw(signal);
124
219
  if (raw === undefined) {
125
- await closeCmuxWorkspace(name, signal);
220
+ // cmux v2 `workspace.close` rejects titles, so forwarding `name`
221
+ // would always fail. The list failure has already been logged by
222
+ // `listCmuxRaw`; bail rather than guarantee a downstream error.
223
+ log(`cmux close-workspace skipped for ${name}: list-workspaces failed, no usable id`);
126
224
  return;
127
225
  }
128
226
  const match = raw.find((ws) => ws.title === name);
@@ -130,7 +228,7 @@ const cmuxAdapter = {
130
228
  return;
131
229
  }
132
230
  try {
133
- await closeCmuxWorkspace(match.ref, signal);
231
+ await closeCmuxWorkspace(match.id, signal);
134
232
  }
135
233
  catch (error) {
136
234
  if (isSignalAborted(signal)) {
@@ -146,6 +244,12 @@ const cmuxAdapter = {
146
244
  throw error;
147
245
  }
148
246
  },
247
+ accessHint(_name) {
248
+ // cmux is a TUI; users surface workspaces by launching the cmux app,
249
+ // not a shell command. No useful hint to emit.
250
+ // oxlint-disable-next-line unicorn/no-useless-undefined -- explicit signal that the backend has no hint
251
+ return undefined;
252
+ },
149
253
  };
150
254
  export function resolveWorkspaceKind(arguments_) {
151
255
  const { config, host } = arguments_;
@@ -186,6 +290,9 @@ const TMUX_SESSION = "groundcrew";
186
290
  // sentinel and filter it out — it stays around as a placeholder so the
187
291
  // session doesn't collapse when the last ticket window closes.
188
292
  const TMUX_IDLE_WINDOW = "_groundcrew_idle";
293
+ function tmuxTarget(name) {
294
+ return `${TMUX_SESSION}:${name}`;
295
+ }
189
296
  function isTmuxNotFoundError(error) {
190
297
  // runCommand surfaces the child's stderr in error.message, so the "no
191
298
  // server" / "missing session" / "can't find window" signatures are visible
@@ -265,7 +372,7 @@ function parseTmuxWindows(output) {
265
372
  const tmuxAdapter = {
266
373
  async open(spec, signal) {
267
374
  await ensureTmuxSession(signal);
268
- const target = `${TMUX_SESSION}:${spec.name}`;
375
+ const target = tmuxTarget(spec.name);
269
376
  const keepDeadWindowsEnv = readEnvironmentVariable("GROUNDCREW_KEEP_DEAD_WINDOWS");
270
377
  const keepDeadWindows = keepDeadWindowsEnv !== undefined && keepDeadWindowsEnv.length > 0;
271
378
  await runWorkspaceCommand("tmux", [
@@ -307,7 +414,7 @@ const tmuxAdapter = {
307
414
  },
308
415
  async close(name, signal) {
309
416
  try {
310
- await runWorkspaceCommand("tmux", ["kill-window", "-t", `${TMUX_SESSION}:${name}`], signal);
417
+ await runWorkspaceCommand("tmux", ["kill-window", "-t", tmuxTarget(name)], signal);
311
418
  }
312
419
  catch (error) {
313
420
  if (isSignalAborted(signal)) {
@@ -319,6 +426,9 @@ const tmuxAdapter = {
319
426
  throw error;
320
427
  }
321
428
  },
429
+ accessHint(name) {
430
+ return { kind: "attachCommand", command: `tmux attach -t ${tmuxTarget(name)}` };
431
+ },
322
432
  };
323
433
  // Per-config cache: production resolves the adapter once at first use
324
434
  // (loadConfig returns a frozen, cached instance); each test uses a fresh
@@ -364,4 +474,8 @@ export const workspaces = {
364
474
  const adapter = await adapterFor(config, signal);
365
475
  await adapter.close(name, signal);
366
476
  },
477
+ async accessHint(config, name, signal) {
478
+ const adapter = await adapterFor(config, signal);
479
+ return adapter.accessHint(name);
480
+ },
367
481
  };
@@ -10,6 +10,11 @@
10
10
  import type { ResolvedConfig } from "./config.ts";
11
11
  import { type WorkspaceProbe } from "./workspaces.ts";
12
12
  export type WorktreeKind = "host";
13
+ export declare class WorktreeAlreadyExistsError extends Error {
14
+ readonly dir: string;
15
+ constructor(dir: string);
16
+ }
17
+ export declare function isWorktreeAlreadyExistsError(error: unknown): error is WorktreeAlreadyExistsError;
13
18
  export interface WorktreeEntry {
14
19
  repository: string;
15
20
  /** Linear ticket id, lowercased — e.g. "team-220". */
@@ -1 +1 @@
1
- {"version":3,"file":"worktrees.d.ts","sourceRoot":"","sources":["../../src/lib/worktrees.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAOH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAElD,OAAO,EAAE,KAAK,cAAc,EAAc,MAAM,iBAAiB,CAAC;AAIlE,MAAM,MAAM,YAAY,GAAG,MAAM,CAAC;AAElC,MAAM,WAAW,aAAa;IAC5B,UAAU,EAAE,MAAM,CAAC;IACnB,sDAAsD;IACtD,MAAM,EAAE,MAAM,CAAC;IACf,+CAA+C;IAC/C,UAAU,EAAE,MAAM,CAAC;IACnB,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,YAAY,CAAC;CACpB;AAED,MAAM,WAAW,YAAY;IAC3B,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;CAChB;AAoSD,iBAAS,IAAI,CAAC,MAAM,EAAE,cAAc,GAAG,aAAa,EAAE,CAErD;AAED,iBAAS,YAAY,CAAC,MAAM,EAAE,cAAc,EAAE,MAAM,EAAE,MAAM,GAAG,aAAa,EAAE,CAE7E;AAED,iBAAe,MAAM,CACnB,MAAM,EAAE,cAAc,EACtB,IAAI,EAAE,YAAY,EAClB,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,aAAa,CAAC,CAUxB;AAED,iBAAe,MAAM,CACnB,MAAM,EAAE,cAAc,EACtB,KAAK,EAAE,aAAa,EACpB,OAAO,CAAC,EAAE;IAAE,KAAK,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,WAAW,CAAA;CAAE,GAClD,OAAO,CAAC,IAAI,CAAC,CAKf;AAED,MAAM,MAAM,YAAY,GAAG,iBAAiB,GAAG,iBAAiB,CAAC;AAEjE,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,aAAa,CAAC;IACrB,IAAI,EAAE,YAAY,CAAC;IACnB,KAAK,EAAE,OAAO,CAAC;CAChB;AAED,MAAM,WAAW,cAAc;IAC7B,+DAA+D;IAC/D,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,sCAAsC;IACtC,OAAO,EAAE,aAAa,EAAE,CAAC;IACzB,wDAAwD;IACxD,QAAQ,EAAE,eAAe,EAAE,CAAC;IAC5B,cAAc,EAAE,cAAc,CAAC;CAChC;AAKD,iBAAe,QAAQ,CACrB,MAAM,EAAE,cAAc,EACtB,OAAO,EAAE,SAAS,aAAa,EAAE,EACjC,OAAO,CAAC,EAAE;IAAE,KAAK,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,WAAW,CAAA;CAAE,GAClD,OAAO,CAAC,cAAc,CAAC,CAkDzB;AAED,eAAO,MAAM,SAAS;;;;;;CAMrB,CAAC"}
1
+ {"version":3,"file":"worktrees.d.ts","sourceRoot":"","sources":["../../src/lib/worktrees.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAOH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAElD,OAAO,EAAE,KAAK,cAAc,EAAc,MAAM,iBAAiB,CAAC;AAIlE,MAAM,MAAM,YAAY,GAAG,MAAM,CAAC;AAElC,qBAAa,0BAA2B,SAAQ,KAAK;IACnD,SAAgB,GAAG,EAAE,MAAM,CAAC;IAE5B,YAAmB,GAAG,EAAE,MAAM,EAI7B;CACF;AAED,wBAAgB,4BAA4B,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,0BAA0B,CAEhG;AAED,MAAM,WAAW,aAAa;IAC5B,UAAU,EAAE,MAAM,CAAC;IACnB,sDAAsD;IACtD,MAAM,EAAE,MAAM,CAAC;IACf,+CAA+C;IAC/C,UAAU,EAAE,MAAM,CAAC;IACnB,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,YAAY,CAAC;CACpB;AAED,MAAM,WAAW,YAAY;IAC3B,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;CAChB;AAoSD,iBAAS,IAAI,CAAC,MAAM,EAAE,cAAc,GAAG,aAAa,EAAE,CAErD;AAED,iBAAS,YAAY,CAAC,MAAM,EAAE,cAAc,EAAE,MAAM,EAAE,MAAM,GAAG,aAAa,EAAE,CAE7E;AAED,iBAAe,MAAM,CACnB,MAAM,EAAE,cAAc,EACtB,IAAI,EAAE,YAAY,EAClB,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,aAAa,CAAC,CAQxB;AAED,iBAAe,MAAM,CACnB,MAAM,EAAE,cAAc,EACtB,KAAK,EAAE,aAAa,EACpB,OAAO,CAAC,EAAE;IAAE,KAAK,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,WAAW,CAAA;CAAE,GAClD,OAAO,CAAC,IAAI,CAAC,CAKf;AAED,MAAM,MAAM,YAAY,GAAG,iBAAiB,GAAG,iBAAiB,CAAC;AAEjE,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,aAAa,CAAC;IACrB,IAAI,EAAE,YAAY,CAAC;IACnB,KAAK,EAAE,OAAO,CAAC;CAChB;AAED,MAAM,WAAW,cAAc;IAC7B,+DAA+D;IAC/D,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,sCAAsC;IACtC,OAAO,EAAE,aAAa,EAAE,CAAC;IACzB,wDAAwD;IACxD,QAAQ,EAAE,eAAe,EAAE,CAAC;IAC5B,cAAc,EAAE,cAAc,CAAC;CAChC;AAKD,iBAAe,QAAQ,CACrB,MAAM,EAAE,cAAc,EACtB,OAAO,EAAE,SAAS,aAAa,EAAE,EACjC,OAAO,CAAC,EAAE;IAAE,KAAK,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,WAAW,CAAA;CAAE,GAClD,OAAO,CAAC,cAAc,CAAC,CAkDzB;AAED,eAAO,MAAM,SAAS;;;;;;CAMrB,CAAC"}
@@ -14,6 +14,17 @@ import { runCommandAsync } from "./commandRunner.js";
14
14
  import { errorMessage, log } from "./util.js";
15
15
  import { workspaces } from "./workspaces.js";
16
16
  const LONG_RUNNING_COMMAND_OPTIONS = { stdio: "inherit", timeoutMs: 0 };
17
+ export class WorktreeAlreadyExistsError extends Error {
18
+ dir;
19
+ constructor(dir) {
20
+ super(`Worktree already exists: ${dir}`);
21
+ this.dir = dir;
22
+ this.name = "WorktreeAlreadyExistsError";
23
+ }
24
+ }
25
+ export function isWorktreeAlreadyExistsError(error) {
26
+ return error instanceof WorktreeAlreadyExistsError;
27
+ }
17
28
  const TICKET_RE = /^[a-z][\da-z]*-\d+$/;
18
29
  const TICKET_DIR_RE = /^(.+)-([a-z][\da-z]*-\d+)$/;
19
30
  function branchPrefix() {
@@ -239,11 +250,9 @@ function findByTicket(config, ticket) {
239
250
  return list(config).filter((entry) => entry.ticket === ticket);
240
251
  }
241
252
  async function create(config, spec, signal) {
242
- const existing = findByTicket(config, spec.ticket).filter((entry) => entry.repository === spec.repository);
243
- if (existing.length > 0) {
244
- const [first] = existing;
245
- /* v8 ignore next @preserve -- length>0 guarantees [0] is defined */
246
- throw new Error(`Worktree already exists: ${first?.dir}`);
253
+ const existing = findByTicket(config, spec.ticket).find((entry) => entry.repository === spec.repository);
254
+ if (existing !== undefined) {
255
+ throw new WorktreeAlreadyExistsError(existing.dir);
247
256
  }
248
257
  return await createWorktree(config, spec, signal);
249
258
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clipboard-health/groundcrew",
3
- "version": "2.1.1",
3
+ "version": "2.3.0",
4
4
  "description": "Linear-driven orchestrator that launches AI coding agents in git worktrees, with workspace lifecycle and usage tracking.",
5
5
  "keywords": [
6
6
  "agent",