@gaia-ai/gaia 0.1.4

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.
@@ -0,0 +1,1557 @@
1
+ // src/plugins/auth/basic.ts
2
+ function basicAuthProvider(tokenBase64) {
3
+ const authProvider = {
4
+ id: "basic_auth",
5
+ displayName: "HTTP Basic (inline)",
6
+ capabilities: { login: false, logout: false, status: true },
7
+ async login() {
8
+ throw new Error("GAIA uses static inline HTTP Basic auth.");
9
+ },
10
+ async logout() {
11
+ },
12
+ async status() {
13
+ return { loggedIn: true, provider: "basic_auth" };
14
+ },
15
+ createAdapter() {
16
+ return {
17
+ async apply(req) {
18
+ return {
19
+ ...req,
20
+ headers: {
21
+ ...req.headers ?? {},
22
+ Authorization: `Basic ${tokenBase64}`
23
+ }
24
+ };
25
+ }
26
+ };
27
+ }
28
+ };
29
+ return { id: "gaia-basic-auth", authProvider };
30
+ }
31
+
32
+ // src/plugins/plugins.ts
33
+ async function selectRemote(config) {
34
+ return config.remote.createRemote(config);
35
+ }
36
+ async function selectExecutor(config) {
37
+ return config.executor.createExecutor(config);
38
+ }
39
+ async function selectWorkspace(config) {
40
+ return config.workspace.createWorkspace(config);
41
+ }
42
+ async function selectAgent(config) {
43
+ return config.agent.createAgent(config);
44
+ }
45
+
46
+ // src/plugins/remote/drupal.ts
47
+ import { resolveAuth } from "dropsh";
48
+ import { createHttpClient, createJsonApiClient } from "dropsh/plugin";
49
+ var ACTIVE = ["claimed", "running"];
50
+ function parseTicketComments(included) {
51
+ if (!Array.isArray(included)) {
52
+ return [];
53
+ }
54
+ const comments = [];
55
+ for (const res of included) {
56
+ if (res?.type !== "gaia_comment--gaia_comment") {
57
+ continue;
58
+ }
59
+ const attrs = res.attributes ?? {};
60
+ const body = attrs.body;
61
+ const value = typeof body === "string" ? body : typeof body?.value === "string" ? body.value : "";
62
+ comments.push({
63
+ type: typeof attrs.gaia_comment_type === "string" ? attrs.gaia_comment_type : "comment",
64
+ body: value,
65
+ created: typeof attrs.created === "string" ? attrs.created : ""
66
+ });
67
+ }
68
+ return comments;
69
+ }
70
+ var DrupalGaiaRemote = class {
71
+ constructor(api) {
72
+ this.api = api;
73
+ }
74
+ api;
75
+ async fetchActiveRuns(id) {
76
+ const rows = await this.api.collection("gaia_run").where("conductor_id.machine_id", "=", id).whereIn("state", ACTIVE).fields(["state", "state_at_start", "worktree_path"]).page(100).list();
77
+ return Promise.all(
78
+ rows.map(async (r) => ({
79
+ runUuid: r.id,
80
+ ticketUuid: r.rel("ticket_id") ?? "",
81
+ ticketIdentifier: await this.getRunTicketIdentifier(r.id),
82
+ branchName: await this.getRunTicketBranchName(r.id),
83
+ state: r.attr("state") ?? "",
84
+ stateAtStart: r.attr("state_at_start") ?? "",
85
+ worktreePath: r.attr("worktree_path") ?? ""
86
+ }))
87
+ );
88
+ }
89
+ async activeRunCount(id) {
90
+ return this.api.collection("gaia_run").where("conductor_id.machine_id", "=", id).whereIn("state", ACTIVE).fields(["drupal_internal__id"]).page(100).count();
91
+ }
92
+ async claimNext(c) {
93
+ const res = await this.api.post("gaia/claim-next", {
94
+ data: {
95
+ attributes: {
96
+ lease_seconds: c.leaseSeconds,
97
+ // Server (`ConductorResolverTrait`) narrows to the caller's conductor
98
+ // by the `machine_id` attribute; `c.conductorId` IS the machine_id.
99
+ machine_id: c.conductorId
100
+ }
101
+ }
102
+ });
103
+ if (!res?.data) {
104
+ return null;
105
+ }
106
+ const d = res.data;
107
+ const rawId = d.attributes?.drupal_internal__id;
108
+ if (rawId === void 0 || rawId === null) {
109
+ throw new Error(`claimNext: missing drupal_internal__id on run ${d.id}`);
110
+ }
111
+ return {
112
+ runUuid: d.id,
113
+ runId: Number(rawId),
114
+ ticketUuid: d.relationships?.ticket_id?.data?.id ?? "",
115
+ stateAtStart: String(d.attributes?.state_at_start ?? ""),
116
+ // handler_id was dropped; the handler is the work the run does, i.e. the
117
+ // ticket state the run started in.
118
+ handler: String(d.attributes?.state_at_start ?? "")
119
+ };
120
+ }
121
+ async getTicket(uuid) {
122
+ const doc = await this.api.get(
123
+ `gaia_ticket/gaia_ticket/${uuid}?include=comments`
124
+ );
125
+ const attrs = doc.data?.attributes ?? {};
126
+ const issueUrl = typeof attrs.origin === "string" ? attrs.origin : void 0;
127
+ return {
128
+ uuid: doc.data?.id ?? uuid,
129
+ identifier: typeof attrs.identifier === "string" ? attrs.identifier : uuid,
130
+ title: typeof attrs.title === "string" ? attrs.title : "",
131
+ state: typeof attrs.state === "string" ? attrs.state : "",
132
+ branchName: typeof attrs.branch_name === "string" ? attrs.branch_name : "",
133
+ ...typeof attrs.base_branch === "string" && attrs.base_branch !== "" ? { baseBranch: attrs.base_branch } : {},
134
+ comments: parseTicketComments(doc.included),
135
+ ...issueUrl ? { issueUrl } : {}
136
+ };
137
+ }
138
+ async getRunWorktree(uuid) {
139
+ const r = await this.api.resource("gaia_run", uuid);
140
+ return r.attr("worktree_path") ?? "";
141
+ }
142
+ async getRunTicketIdentifier(uuid) {
143
+ const run = await this.api.resource("gaia_run", uuid);
144
+ const ticketUuid = run.rel("ticket_id");
145
+ if (!ticketUuid) {
146
+ return "";
147
+ }
148
+ const ticket = await this.api.resource("gaia_ticket", ticketUuid);
149
+ return ticket.attr("identifier") ?? "";
150
+ }
151
+ async getRunTicketBranchName(uuid) {
152
+ const run = await this.api.resource("gaia_run", uuid);
153
+ const ticketUuid = run.rel("ticket_id");
154
+ if (!ticketUuid) {
155
+ return "";
156
+ }
157
+ const ticket = await this.api.resource("gaia_ticket", ticketUuid);
158
+ return ticket.attr("branch_name") ?? "";
159
+ }
160
+ async registerConductor(reg) {
161
+ const r = await this.api.upsert(
162
+ "gaia_conductor",
163
+ { path: "machine_id", value: reg.id },
164
+ {
165
+ attributes: {
166
+ machine_id: reg.id,
167
+ status: "online",
168
+ workspace_root: reg.workspace,
169
+ label: reg.label,
170
+ states: reg.states,
171
+ max_parallel: reg.max_parallel
172
+ },
173
+ relationships: {
174
+ owner_user_id: {
175
+ data: { type: "user--user", id: await this.api.me() }
176
+ },
177
+ project_id: {
178
+ data: {
179
+ type: "gaia_project--gaia_project",
180
+ id: await this.projectUuid(reg.project)
181
+ }
182
+ }
183
+ }
184
+ }
185
+ );
186
+ return r.id;
187
+ }
188
+ async heartbeat(reg, load, lease = 300) {
189
+ const res = await this.api.post("gaia/heartbeat", {
190
+ data: {
191
+ attributes: {
192
+ machine_id: reg.id,
193
+ project: reg.project,
194
+ states: reg.states,
195
+ workspace_root: reg.workspace,
196
+ label: reg.label,
197
+ max_parallel: reg.max_parallel,
198
+ current_load: load,
199
+ lease_seconds: lease
200
+ }
201
+ }
202
+ });
203
+ const status = res?.data?.attributes?.status;
204
+ return typeof status === "string" ? status : "online";
205
+ }
206
+ async getConductorStatus(id) {
207
+ const c = await this.api.collection("gaia_conductor").where("machine_id", "=", id).fields(["status"]).first();
208
+ return c ? c.attr("status") ?? null : null;
209
+ }
210
+ async setConductorStatus(id, status) {
211
+ const c = await this.api.collection("gaia_conductor").where("machine_id", "=", id).first();
212
+ if (c) {
213
+ await this.api.update("gaia_conductor", c.id, { attributes: { status } });
214
+ }
215
+ }
216
+ async listConductors(owner) {
217
+ let col = this.api.collection("gaia_conductor");
218
+ if (owner === "me") {
219
+ col = col.where("owner_user_id.id", "=", await this.api.me());
220
+ }
221
+ const rows = await col.page(200).list();
222
+ return rows.map((r) => ({
223
+ id: r.attr("machine_id") ?? r.id,
224
+ project: "",
225
+ label: r.attr("label") ?? "",
226
+ status: r.attr("status") ?? "",
227
+ lastSeen: r.attr("last_seen") ?? 0,
228
+ load: r.attr("current_load") ?? 0
229
+ }));
230
+ }
231
+ async markRunning(uuid, attrs) {
232
+ const t = Math.floor(Date.now() / 1e3);
233
+ await this.api.update("gaia_run", uuid, {
234
+ attributes: {
235
+ state: "running",
236
+ heartbeat: t,
237
+ ...attrs?.worktree_path ? { worktree_path: attrs.worktree_path } : {}
238
+ }
239
+ });
240
+ }
241
+ async fetchFinalizableRuns(id) {
242
+ const rows = await this.api.collection("gaia_run").where("conductor_id.machine_id", "=", id).where("state", "=", "done").where("closed", "=", "0").fields(["worktree_path"]).page(100).list();
243
+ return rows.map((r) => ({
244
+ runUuid: r.id,
245
+ worktreePath: r.attr("worktree_path") ?? ""
246
+ }));
247
+ }
248
+ async finalizeRun(uuid, log) {
249
+ const t = Math.floor(Date.now() / 1e3);
250
+ await this.api.update("gaia_run", uuid, {
251
+ attributes: {
252
+ closed: true,
253
+ closed_date: t,
254
+ ...log ? { log } : {}
255
+ }
256
+ });
257
+ }
258
+ async fetchFinalizableTickets(id) {
259
+ const rows = await this.api.collection("gaia_ticket").where("conductor_id.machine_id", "=", id).where("state", "=", "done").where("closed", "=", "0").fields(["branch_name"]).page(100).list();
260
+ return Promise.all(
261
+ rows.map(async (r) => ({
262
+ ticketUuid: r.id,
263
+ branchName: r.attr("branch_name") ?? "",
264
+ worktreePath: await this.latestRunWorktree(r.id)
265
+ }))
266
+ );
267
+ }
268
+ async closeTicket(uuid) {
269
+ const t = Math.floor(Date.now() / 1e3);
270
+ await this.api.update("gaia_ticket", uuid, {
271
+ attributes: { closed: true, closed_date: t }
272
+ });
273
+ }
274
+ /**
275
+ * Absolute worktree path of the ticket's latest run (highest run id with a
276
+ * non-empty worktree_path), or '' when none — the cwd the cleanup command
277
+ * runs in. The conductor persists worktree_path on markRunning, so a done
278
+ * ticket's run carries the path even after the run closed.
279
+ */
280
+ async latestRunWorktree(ticketUuid) {
281
+ const rows = await this.api.collection("gaia_run").where("ticket_id.id", "=", ticketUuid).fields(["worktree_path", "drupal_internal__id"]).sort("-drupal_internal__id").page(100).list();
282
+ for (const r of rows) {
283
+ const path = r.attr("worktree_path");
284
+ if (path) {
285
+ return path;
286
+ }
287
+ }
288
+ return "";
289
+ }
290
+ async projectUuid(name) {
291
+ const p = await this.api.collection("gaia_project").where("name", "=", name).first();
292
+ if (!p) {
293
+ throw new Error(`gaia project "${name}" not found`);
294
+ }
295
+ return p.id;
296
+ }
297
+ };
298
+ function drupalRemote() {
299
+ return {
300
+ kind: "remote",
301
+ id: "drupal",
302
+ requiredModules: [],
303
+ async createRemote(config) {
304
+ const http = createHttpClient();
305
+ const auth = await resolveAuth({
306
+ baseUrl: config.site.base_url,
307
+ plugins: config.plugins ?? [],
308
+ http,
309
+ now: Date.now
310
+ // stateDir omitted → dropsh defaultStateDir() (~/.config/dropsh)
311
+ });
312
+ return new DrupalGaiaRemote(
313
+ createJsonApiClient({
314
+ baseUrl: config.site.base_url,
315
+ prefix: config.site.jsonapi_prefix,
316
+ http,
317
+ auth
318
+ })
319
+ );
320
+ }
321
+ };
322
+ }
323
+
324
+ // src/plugins/remote/fake.ts
325
+ var ACTIVE2 = ["claimed", "running"];
326
+ var FakeGaiaRemote = class {
327
+ calls = {
328
+ markRunning: [],
329
+ finalizeRun: [],
330
+ closeTicket: []
331
+ };
332
+ /**
333
+ * Override for reconcile tests: when set, `fetchActiveRuns` returns this
334
+ * list verbatim instead of deriving it from the internal runs map.
335
+ */
336
+ activeRuns = null;
337
+ runs = /* @__PURE__ */ new Map();
338
+ queue = [];
339
+ tickets;
340
+ conductorStatus = "online";
341
+ /** Stable counter for assigning numeric ids to unseeded runs. */
342
+ runIdCounter = 0;
343
+ constructor(seed = {}) {
344
+ this.tickets = seed.tickets ?? {};
345
+ for (const r of seed.runs ?? []) {
346
+ const handler = r.handler ?? "code";
347
+ const runId = r.id ?? ++this.runIdCounter;
348
+ this.runs.set(r.runUuid, {
349
+ runUuid: r.runUuid,
350
+ runId,
351
+ ticketUuid: r.ticketUuid,
352
+ handler,
353
+ state: r.state ?? r.stateAtStart,
354
+ stateAtStart: r.stateAtStart,
355
+ ...r.worktreePath !== void 0 ? { worktreePath: r.worktreePath } : {},
356
+ ...r.closed !== void 0 ? { closed: r.closed } : {}
357
+ });
358
+ this.queue.push({
359
+ runUuid: r.runUuid,
360
+ runId,
361
+ ticketUuid: r.ticketUuid,
362
+ stateAtStart: r.stateAtStart,
363
+ handler
364
+ });
365
+ }
366
+ }
367
+ async registerConductor(_reg) {
368
+ this.conductorStatus = "online";
369
+ return "fake-conductor-uuid";
370
+ }
371
+ async heartbeat() {
372
+ this.conductorStatus = "online";
373
+ return this.conductorStatus;
374
+ }
375
+ async getConductorStatus(_conductorId) {
376
+ return this.conductorStatus;
377
+ }
378
+ async setConductorStatus(_conductorId, status) {
379
+ this.conductorStatus = status;
380
+ }
381
+ async listConductors(_owner) {
382
+ return [];
383
+ }
384
+ async activeRunCount(_conductorId) {
385
+ return this.internalActiveRuns().length;
386
+ }
387
+ async fetchActiveRuns(_conductorId) {
388
+ if (this.activeRuns !== null) {
389
+ return this.activeRuns;
390
+ }
391
+ return this.internalActiveRuns().map((r) => {
392
+ const ticket = this.tickets[r.ticketUuid];
393
+ const identifier = ticket?.identifier ?? "";
394
+ const branchName = ticket?.branchName ?? (identifier ? `gaia/${identifier.toLowerCase()}` : "");
395
+ return {
396
+ runUuid: r.runUuid,
397
+ ticketUuid: r.ticketUuid,
398
+ ticketIdentifier: identifier,
399
+ branchName,
400
+ state: r.state,
401
+ stateAtStart: r.stateAtStart,
402
+ worktreePath: r.worktreePath ?? ""
403
+ };
404
+ });
405
+ }
406
+ async claimNext(_claim) {
407
+ return this.queue.shift() ?? null;
408
+ }
409
+ async getTicket(ticketUuid) {
410
+ const t = this.tickets[ticketUuid];
411
+ if (!t) {
412
+ throw new Error(`fake remote: ticket ${ticketUuid} not seeded`);
413
+ }
414
+ const branchName = t.branchName ?? `gaia/${t.identifier.toLowerCase()}`;
415
+ return {
416
+ uuid: ticketUuid,
417
+ identifier: t.identifier,
418
+ title: t.title,
419
+ state: t.state,
420
+ branchName,
421
+ ...t.baseBranch ? { baseBranch: t.baseBranch } : {},
422
+ comments: t.comments ?? [],
423
+ ...t.url ? { issueUrl: t.url } : {}
424
+ };
425
+ }
426
+ async getRunWorktree(runUuid) {
427
+ return this.runs.get(runUuid)?.worktreePath ?? "";
428
+ }
429
+ async getRunTicketIdentifier(runUuid) {
430
+ const ticketUuid = this.runs.get(runUuid)?.ticketUuid;
431
+ if (!ticketUuid) {
432
+ return "";
433
+ }
434
+ return this.tickets[ticketUuid]?.identifier ?? "";
435
+ }
436
+ async getRunTicketBranchName(runUuid) {
437
+ const ticketUuid = this.runs.get(runUuid)?.ticketUuid;
438
+ if (!ticketUuid) {
439
+ return "";
440
+ }
441
+ const ticket = this.tickets[ticketUuid];
442
+ if (!ticket) {
443
+ return "";
444
+ }
445
+ return ticket.branchName ?? `gaia/${ticket.identifier.toLowerCase()}`;
446
+ }
447
+ async markRunning(runUuid, attrs) {
448
+ this.calls.markRunning.push({
449
+ runUuid,
450
+ ...attrs ? {
451
+ attrs: {
452
+ ...attrs.worktree_path ? { worktree_path: attrs.worktree_path } : {}
453
+ }
454
+ } : {}
455
+ });
456
+ const run = this.runs.get(runUuid);
457
+ if (run) {
458
+ run.state = "running";
459
+ if (attrs?.worktree_path) {
460
+ run.worktreePath = attrs.worktree_path;
461
+ }
462
+ }
463
+ }
464
+ async fetchFinalizableRuns(_conductorId) {
465
+ return [...this.runs.values()].filter((r) => r.state === "done" && !r.closed).map((r) => ({ runUuid: r.runUuid, worktreePath: r.worktreePath ?? "" }));
466
+ }
467
+ async finalizeRun(runUuid, log) {
468
+ this.calls.finalizeRun.push({ runUuid, log });
469
+ const run = this.runs.get(runUuid);
470
+ if (run) {
471
+ run.closed = true;
472
+ if (log) run.log = log;
473
+ }
474
+ }
475
+ async fetchFinalizableTickets(_conductorId) {
476
+ return Object.entries(this.tickets).filter(([, t]) => t.state === "done" && !t.closed).map(([ticketUuid, t]) => ({
477
+ ticketUuid,
478
+ branchName: t.branchName ?? `gaia/${t.identifier.toLowerCase()}`,
479
+ worktreePath: this.latestRunWorktree(ticketUuid)
480
+ }));
481
+ }
482
+ async closeTicket(uuid) {
483
+ this.calls.closeTicket.push(uuid);
484
+ const t = this.tickets[uuid];
485
+ if (t) {
486
+ t.closed = true;
487
+ }
488
+ }
489
+ /** Worktree path of the ticket's most recently seeded run, or '' when none. */
490
+ latestRunWorktree(ticketUuid) {
491
+ let worktree = "";
492
+ for (const r of this.runs.values()) {
493
+ if (r.ticketUuid === ticketUuid && r.worktreePath) {
494
+ worktree = r.worktreePath;
495
+ }
496
+ }
497
+ return worktree;
498
+ }
499
+ internalActiveRuns() {
500
+ return [...this.runs.values()].filter((r) => ACTIVE2.includes(r.state));
501
+ }
502
+ };
503
+ function fakeRemote(seed = {}) {
504
+ const remote = new FakeGaiaRemote(seed);
505
+ return {
506
+ kind: "remote",
507
+ id: "fake",
508
+ requiredModules: [],
509
+ async createRemote(_config) {
510
+ return remote;
511
+ }
512
+ };
513
+ }
514
+
515
+ // src/plugins/workspace/fake.ts
516
+ var FakeWorkspace = class {
517
+ async ensure(identifier, _title) {
518
+ return { path: `/fake/${identifier}`, instructions: null };
519
+ }
520
+ async beforeRun(_path) {
521
+ }
522
+ async afterRun(_path) {
523
+ }
524
+ async afterDone(_path) {
525
+ }
526
+ };
527
+ function fakeWorkspace() {
528
+ const workspace = new FakeWorkspace();
529
+ return {
530
+ kind: "workspace",
531
+ id: "fake",
532
+ requiredModules: [],
533
+ async createWorkspace(_config) {
534
+ return workspace;
535
+ }
536
+ };
537
+ }
538
+
539
+ // src/plugins/workspace/git.ts
540
+ import { existsSync as existsSync2 } from "node:fs";
541
+ import { dirname, join, resolve as resolve2 } from "node:path";
542
+
543
+ // src/core/exec.ts
544
+ import { execFile } from "node:child_process";
545
+ import { promisify } from "node:util";
546
+ var execFileAsync = promisify(execFile);
547
+ var ExecError = class extends Error {
548
+ constructor(file, args, exitCode, stderr, options) {
549
+ super(`exec failed: ${file}`, options);
550
+ this.file = file;
551
+ this.args = args;
552
+ this.exitCode = exitCode;
553
+ this.stderr = stderr;
554
+ this.name = "ExecError";
555
+ }
556
+ file;
557
+ args;
558
+ exitCode;
559
+ stderr;
560
+ };
561
+ var CommandRunner = class {
562
+ constructor(logger) {
563
+ this.logger = logger;
564
+ }
565
+ logger;
566
+ async run(file, args, opts = {}) {
567
+ try {
568
+ const { stdout } = await execFileAsync(file, args, {
569
+ encoding: "utf8",
570
+ ...opts
571
+ });
572
+ this.logger.debug({ file, args, cwd: opts.cwd }, "exec ok");
573
+ return stdout;
574
+ } catch (err) {
575
+ const e = err;
576
+ const exitCode = typeof e.code === "number" ? e.code : null;
577
+ const stderr = e.stderr ?? "";
578
+ this.logger.error(
579
+ { file, args, cwd: opts.cwd, exitCode, stderr },
580
+ "exec failed"
581
+ );
582
+ throw new ExecError(file, args, exitCode, stderr, { cause: err });
583
+ }
584
+ }
585
+ };
586
+ var noopLogger = {
587
+ debug() {
588
+ },
589
+ info() {
590
+ },
591
+ warn() {
592
+ },
593
+ error() {
594
+ }
595
+ };
596
+ var defaultRunner = new CommandRunner(noopLogger);
597
+ function exec(file, args, opts = {}) {
598
+ return defaultRunner.run(file, args, opts);
599
+ }
600
+
601
+ // src/core/slug.ts
602
+ var DEFAULT_SLUG_MAX_LENGTH = 40;
603
+ function slugify(text, maxLength = DEFAULT_SLUG_MAX_LENGTH) {
604
+ const slug = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
605
+ if (slug.length <= maxLength) {
606
+ return slug;
607
+ }
608
+ return slug.slice(0, maxLength).replace(/-+$/g, "");
609
+ }
610
+
611
+ // src/plugins/workspace/instructions.ts
612
+ import { createHash } from "node:crypto";
613
+ import { existsSync, readFileSync } from "node:fs";
614
+ import { resolve } from "node:path";
615
+ function loadInstructions(workspacePath) {
616
+ const path = resolve(workspacePath, "WORKFLOW.md");
617
+ if (!existsSync(path)) {
618
+ return null;
619
+ }
620
+ const text = readFileSync(path, "utf8");
621
+ return {
622
+ path,
623
+ sha256: createHash("sha256").update(text).digest("hex"),
624
+ text
625
+ };
626
+ }
627
+
628
+ // src/plugins/workspace/git.ts
629
+ var DEFAULT_BRANCH_TEMPLATE = "{branchPrefix}{key}-{titleSlug}";
630
+ function renderBranchTemplate(template, vars) {
631
+ const rendered = template.replace(/\{(\w+)\}/g, (whole, key) => {
632
+ const value = vars[key];
633
+ return value === void 0 ? whole : value;
634
+ });
635
+ return rendered.replace(/[-_./]+$/g, "");
636
+ }
637
+ var defaultHookRunner = async (command, cwd) => {
638
+ await exec("sh", ["-c", command], { cwd });
639
+ };
640
+ var defaultGitRunner = async (args, cwd) => {
641
+ await exec("git", args, { cwd });
642
+ };
643
+ var GitWorkspace = class {
644
+ constructor(options) {
645
+ this.options = options;
646
+ this.runHook = options.runHook ?? defaultHookRunner;
647
+ this.runGit = options.runGit ?? defaultGitRunner;
648
+ this.root = resolve2(options.root);
649
+ this.worktreesRoot = options.worktreesRoot ? resolve2(options.worktreesRoot) : `${this.root}-worktrees`;
650
+ this.branchPrefix = options.branchPrefix ?? "gaia/";
651
+ this.branchTemplate = options.branchTemplate ?? DEFAULT_BRANCH_TEMPLATE;
652
+ }
653
+ options;
654
+ runHook;
655
+ runGit;
656
+ root;
657
+ worktreesRoot;
658
+ branchPrefix;
659
+ branchTemplate;
660
+ /** Render the branch name for a ticket from the configured template. */
661
+ branchFor(identifier, title) {
662
+ return renderBranchTemplate(this.branchTemplate, {
663
+ branchPrefix: this.branchPrefix,
664
+ key: this.keyFor(identifier),
665
+ identifier,
666
+ titleSlug: slugify(title ?? "")
667
+ });
668
+ }
669
+ keyFor(identifier) {
670
+ return identifier.replace(/[^A-Za-z0-9._-]/g, "_");
671
+ }
672
+ pathFor(key) {
673
+ return resolve2(join(this.worktreesRoot, key));
674
+ }
675
+ async ensure(identifier, title, _baseRef) {
676
+ const key = this.keyFor(identifier);
677
+ if (key === "" || key.startsWith(".")) {
678
+ throw new Error(`invalid workspace identifier: ${identifier}`);
679
+ }
680
+ const path = this.pathFor(key);
681
+ if (existsSync2(path)) {
682
+ return { path, instructions: loadInstructions(path) };
683
+ }
684
+ const branch = this.branchFor(identifier, title);
685
+ await this.runGit(
686
+ ["-C", this.root, "worktree", "add", "--force", "-B", branch, path],
687
+ this.root
688
+ );
689
+ if (this.options.hooks?.after_create) {
690
+ await this.runHook(this.options.hooks.after_create, path);
691
+ }
692
+ return { path, instructions: loadInstructions(path) };
693
+ }
694
+ async remove(identifier) {
695
+ const key = this.keyFor(identifier);
696
+ const path = this.pathFor(key);
697
+ await this.runGit(
698
+ ["-C", this.root, "worktree", "remove", "--force", path],
699
+ this.root
700
+ );
701
+ }
702
+ async beforeRun(path) {
703
+ if (this.options.hooks?.before_run) {
704
+ await this.runHook(this.options.hooks.before_run, path);
705
+ }
706
+ }
707
+ async afterRun(path) {
708
+ if (this.options.hooks?.after_run) {
709
+ await this.runHook(this.options.hooks.after_run, path);
710
+ }
711
+ }
712
+ async afterDone(path) {
713
+ if (this.options.hooks?.after_done) {
714
+ await this.runHook(this.options.hooks.after_done, path);
715
+ }
716
+ }
717
+ };
718
+ function gitWorkspace(opts = {}) {
719
+ return {
720
+ kind: "workspace",
721
+ id: "git",
722
+ requiredModules: [],
723
+ async createWorkspace(config) {
724
+ const root = config.config_path ? dirname(config.config_path) : process.cwd();
725
+ return new GitWorkspace({
726
+ root,
727
+ ...opts.hooks ? { hooks: opts.hooks } : {},
728
+ ...opts.branchPrefix ? { branchPrefix: opts.branchPrefix } : {},
729
+ ...opts.branchTemplate ? { branchTemplate: opts.branchTemplate } : {}
730
+ });
731
+ }
732
+ };
733
+ }
734
+
735
+ // src/plugins/workspace/herdr.ts
736
+ import { dirname as dirname2, join as join2, resolve as resolve3 } from "node:path";
737
+ var defaultExec = (args) => exec("herdr", args);
738
+ var defaultGitExec = (args) => exec("git", args);
739
+ var defaultHookRunner2 = async (command, cwd) => {
740
+ await exec("sh", ["-c", command], { cwd });
741
+ };
742
+ function isRecord(value) {
743
+ return typeof value === "object" && value !== null && !Array.isArray(value);
744
+ }
745
+ function parseJson(output, command) {
746
+ try {
747
+ return JSON.parse(output);
748
+ } catch {
749
+ throw new Error(`herdr ${command} returned invalid JSON`);
750
+ }
751
+ }
752
+ function parseWorktreeList(output) {
753
+ const parsed = parseJson(output, "worktree list");
754
+ const worktrees = isRecord(parsed) ? parsed.result?.worktrees : void 0;
755
+ if (!Array.isArray(worktrees)) {
756
+ throw new Error("herdr worktree list returned invalid schema");
757
+ }
758
+ return worktrees.filter(isRecord).filter((w) => typeof w.branch === "string" && typeof w.path === "string").map((w) => ({
759
+ branch: w.branch,
760
+ path: w.path,
761
+ ...typeof w.open_workspace_id === "string" ? { open_workspace_id: w.open_workspace_id } : {}
762
+ }));
763
+ }
764
+ function parseWorktreePath(output, command) {
765
+ const parsed = parseJson(output, command);
766
+ const path = isRecord(parsed) ? parsed.result?.worktree?.path : void 0;
767
+ if (typeof path !== "string") {
768
+ throw new Error(`herdr ${command} returned invalid schema`);
769
+ }
770
+ return path;
771
+ }
772
+ function validateBranch(branch) {
773
+ if (!/^[A-Za-z0-9._/-]+$/.test(branch) || branch.startsWith(".") || branch.includes("..")) {
774
+ throw new Error(`invalid workspace branch: ${branch}`);
775
+ }
776
+ }
777
+ var HerdrWorkspace = class {
778
+ constructor(options) {
779
+ this.options = options;
780
+ this.execHerdr = options.execHerdr ?? defaultExec;
781
+ this.execGit = options.execGit ?? defaultGitExec;
782
+ this.runHook = options.runHook ?? defaultHookRunner2;
783
+ this.root = resolve3(options.root);
784
+ this.worktreeDir = options.worktreeDir ?? ".gaia-worktrees";
785
+ }
786
+ options;
787
+ execHerdr;
788
+ execGit;
789
+ runHook;
790
+ root;
791
+ worktreeDir;
792
+ /**
793
+ * Resolve the base ref for a new worktree branch. Prefers the configured
794
+ * `baseBranch`; otherwise the remote tracking branch of the repo's HEAD
795
+ * (e.g. `origin/develop`). Best-effort: a repo with no upstream returns
796
+ * `undefined`, leaving herdr to fall back to the parent workspace HEAD.
797
+ */
798
+ async resolveBaseRef() {
799
+ if (this.options.baseBranch) {
800
+ return this.options.baseBranch;
801
+ }
802
+ try {
803
+ const ref = (await this.execGit([
804
+ "-C",
805
+ this.root,
806
+ "rev-parse",
807
+ "--abbrev-ref",
808
+ "--symbolic-full-name",
809
+ "@{u}"
810
+ ])).trim();
811
+ return ref || void 0;
812
+ } catch {
813
+ return void 0;
814
+ }
815
+ }
816
+ /**
817
+ * Fetch the base ref's remote branch so the new worktree starts from the
818
+ * latest pushed state. Best-effort: offline / unknown remote is non-fatal.
819
+ */
820
+ async fetchBaseRef(baseRef) {
821
+ const slash = baseRef.indexOf("/");
822
+ if (slash <= 0) {
823
+ return;
824
+ }
825
+ const remote = baseRef.slice(0, slash);
826
+ const branch = baseRef.slice(slash + 1);
827
+ try {
828
+ await this.execGit(["-C", this.root, "fetch", remote, branch]);
829
+ } catch {
830
+ }
831
+ }
832
+ /**
833
+ * Verify a base-ref candidate resolves on the remote (after fetching it).
834
+ * Returns the ref when it exists, else undefined so the caller falls back.
835
+ */
836
+ async verifyRemoteRef(ref) {
837
+ await this.fetchBaseRef(ref);
838
+ try {
839
+ await this.execGit([
840
+ "-C",
841
+ this.root,
842
+ "rev-parse",
843
+ "--verify",
844
+ "--quiet",
845
+ `${ref}^{commit}`
846
+ ]);
847
+ return ref;
848
+ } catch {
849
+ return void 0;
850
+ }
851
+ }
852
+ async ensure(_identifier, branch, baseRefOverride) {
853
+ if (branch === void 0) {
854
+ throw new Error("herdr workspace requires a branch name");
855
+ }
856
+ validateBranch(branch);
857
+ const worktrees = parseWorktreeList(
858
+ await this.execHerdr(["worktree", "list", "--cwd", this.root, "--json"])
859
+ );
860
+ const existing = worktrees.find((w) => w.branch === branch);
861
+ if (existing) {
862
+ let path2 = existing.path;
863
+ if (!existing.open_workspace_id) {
864
+ path2 = parseWorktreePath(
865
+ await this.execHerdr([
866
+ "worktree",
867
+ "open",
868
+ "--cwd",
869
+ this.root,
870
+ "--branch",
871
+ branch,
872
+ "--no-focus",
873
+ "--json"
874
+ ]),
875
+ "worktree open"
876
+ );
877
+ }
878
+ return { path: path2, instructions: loadInstructions(path2) };
879
+ }
880
+ let baseRef;
881
+ if (baseRefOverride !== void 0) {
882
+ baseRef = await this.verifyRemoteRef(baseRefOverride);
883
+ }
884
+ if (baseRef === void 0) {
885
+ baseRef = await this.resolveBaseRef();
886
+ if (baseRef !== void 0) {
887
+ await this.fetchBaseRef(baseRef);
888
+ }
889
+ }
890
+ const path = parseWorktreePath(
891
+ await this.execHerdr([
892
+ "worktree",
893
+ "create",
894
+ "--cwd",
895
+ this.root,
896
+ "--branch",
897
+ branch,
898
+ ...baseRef !== void 0 ? ["--base", baseRef] : [],
899
+ "--label",
900
+ branch,
901
+ "--path",
902
+ join2(this.worktreeDir, branch),
903
+ "--no-focus",
904
+ "--json"
905
+ ]),
906
+ "worktree create"
907
+ );
908
+ if (this.options.hooks?.after_create) {
909
+ await this.runHook(this.options.hooks.after_create, path);
910
+ }
911
+ return { path, instructions: loadInstructions(path) };
912
+ }
913
+ async beforeRun(path) {
914
+ if (this.options.hooks?.before_run) {
915
+ await this.runHook(this.options.hooks.before_run, path);
916
+ }
917
+ }
918
+ async afterRun(path) {
919
+ if (this.options.hooks?.after_run) {
920
+ await this.runHook(this.options.hooks.after_run, path);
921
+ }
922
+ }
923
+ async afterDone(path) {
924
+ if (this.options.hooks?.after_done) {
925
+ await this.runHook(this.options.hooks.after_done, path);
926
+ }
927
+ }
928
+ };
929
+ function herdrWorkspace(opts = {}) {
930
+ return {
931
+ kind: "workspace",
932
+ id: "herdr",
933
+ requiredModules: [],
934
+ async createWorkspace(config) {
935
+ const root = config.config_path ? dirname2(config.config_path) : process.cwd();
936
+ return new HerdrWorkspace({
937
+ root,
938
+ ...opts.hooks ? { hooks: opts.hooks } : {},
939
+ ...opts.worktreeDir ? { worktreeDir: opts.worktreeDir } : {},
940
+ ...opts.baseBranch ? { baseBranch: opts.baseBranch } : {}
941
+ });
942
+ }
943
+ };
944
+ }
945
+
946
+ // plugins/claude/src/index.ts
947
+ import { readdir, readFile, stat } from "node:fs/promises";
948
+ import { homedir } from "node:os";
949
+ import { join as join3 } from "node:path";
950
+ function shellSingleQuote(value) {
951
+ return `'${value.replaceAll("'", "'\\''")}'`;
952
+ }
953
+ function encodeClaudeProjectDir(cwd) {
954
+ return cwd.replace(/[^a-zA-Z0-9]/g, "-");
955
+ }
956
+ var ClaudeAgent = class {
957
+ constructor(options, home = homedir()) {
958
+ this.options = options;
959
+ this.home = home;
960
+ }
961
+ options;
962
+ home;
963
+ id = "claude";
964
+ launchCommand(prompt) {
965
+ if (prompt === "") {
966
+ return "claude";
967
+ }
968
+ const parts = ["claude", shellSingleQuote(prompt)];
969
+ if (this.options.model) {
970
+ parts.push("--model", this.options.model);
971
+ }
972
+ parts.push("--dangerously-skip-permissions");
973
+ return parts.join(" ");
974
+ }
975
+ async getRunLog(worktreePath) {
976
+ const dir = join3(
977
+ this.home,
978
+ ".claude",
979
+ "projects",
980
+ encodeClaudeProjectDir(worktreePath)
981
+ );
982
+ let entries;
983
+ try {
984
+ entries = await readdir(dir);
985
+ } catch {
986
+ return "";
987
+ }
988
+ let latest = null;
989
+ for (const name of entries) {
990
+ if (!name.endsWith(".jsonl")) {
991
+ continue;
992
+ }
993
+ const full = join3(dir, name);
994
+ try {
995
+ const s = await stat(full);
996
+ if (!latest || s.mtimeMs > latest.mtimeMs) {
997
+ latest = { path: full, mtimeMs: s.mtimeMs };
998
+ }
999
+ } catch {
1000
+ }
1001
+ }
1002
+ if (!latest) {
1003
+ return "";
1004
+ }
1005
+ try {
1006
+ return await readFile(latest.path, "utf8");
1007
+ } catch {
1008
+ return "";
1009
+ }
1010
+ }
1011
+ };
1012
+ function claudeAgent(options = {}) {
1013
+ const agent = new ClaudeAgent(options);
1014
+ return {
1015
+ kind: "agent",
1016
+ id: "claude",
1017
+ requiredModules: [],
1018
+ async createAgent() {
1019
+ return agent;
1020
+ }
1021
+ };
1022
+ }
1023
+
1024
+ // plugins/codex/src/index.ts
1025
+ function shellSingleQuote2(value) {
1026
+ return `'${value.replaceAll("'", "'\\''")}'`;
1027
+ }
1028
+ var CodexAgent = class {
1029
+ constructor(options) {
1030
+ this.options = options;
1031
+ }
1032
+ options;
1033
+ id = "codex";
1034
+ launchCommand(prompt) {
1035
+ const parts = [
1036
+ "codex",
1037
+ "--sandbox danger-full-access",
1038
+ "--dangerously-bypass-approvals-and-sandbox",
1039
+ "--dangerously-bypass-hook-trust"
1040
+ ];
1041
+ if (this.options.model) {
1042
+ parts.push("--model", this.options.model);
1043
+ }
1044
+ if (prompt !== "") {
1045
+ parts.push(shellSingleQuote2(prompt));
1046
+ }
1047
+ return parts.join(" ");
1048
+ }
1049
+ async getRunLog() {
1050
+ return "";
1051
+ }
1052
+ };
1053
+ function codexAgent(options = {}) {
1054
+ const agent = new CodexAgent(options);
1055
+ return {
1056
+ kind: "agent",
1057
+ id: "codex",
1058
+ requiredModules: [],
1059
+ async createAgent() {
1060
+ return agent;
1061
+ }
1062
+ };
1063
+ }
1064
+
1065
+ // plugins/fake/src/index.ts
1066
+ import { writeFileSync } from "node:fs";
1067
+ import { isAbsolute, join as join4, relative, resolve as resolve4, sep } from "node:path";
1068
+ var FakeExecutor = class {
1069
+ constructor(options) {
1070
+ this.options = options;
1071
+ }
1072
+ options;
1073
+ id = "fake";
1074
+ capabilities() {
1075
+ return { persistent: false };
1076
+ }
1077
+ async startRun(input) {
1078
+ const branch = input.ticket.branchName;
1079
+ if (this.options.markerFile) {
1080
+ writeFileSync(
1081
+ this.markerPath(input.workspacePath, this.options.markerFile),
1082
+ JSON.stringify({
1083
+ run_uuid: input.run.uuid,
1084
+ ticket_uuid: input.ticket.uuid,
1085
+ handler: input.run.handler,
1086
+ instructions_sha256: input.instructions?.sha256 ?? null
1087
+ })
1088
+ );
1089
+ }
1090
+ return { sessionRef: branch };
1091
+ }
1092
+ markerPath(workspacePath, markerFile) {
1093
+ const workspace = resolve4(workspacePath);
1094
+ const marker = resolve4(join4(workspace, markerFile));
1095
+ const markerRelativePath = relative(workspace, marker);
1096
+ if (isAbsolute(markerFile) || markerRelativePath === ".." || markerRelativePath.startsWith(`..${sep}`)) {
1097
+ throw new Error(
1098
+ `markerFile must stay inside the workspace: ${markerFile}`
1099
+ );
1100
+ }
1101
+ return marker;
1102
+ }
1103
+ async retire(_branch) {
1104
+ }
1105
+ async stopAgent(_branch) {
1106
+ }
1107
+ async removeWorktree(_branch, _worktreePath) {
1108
+ }
1109
+ };
1110
+ function fakeExecutor(options = {}) {
1111
+ const executor = new FakeExecutor({
1112
+ outcome: options.outcome ?? "exited",
1113
+ ...options.markerFile !== void 0 ? { markerFile: options.markerFile } : {}
1114
+ });
1115
+ return {
1116
+ kind: "executor",
1117
+ id: "fake",
1118
+ requiredModules: [],
1119
+ async createExecutor() {
1120
+ return executor;
1121
+ }
1122
+ };
1123
+ }
1124
+
1125
+ // plugins/herdr/src/index.ts
1126
+ import { existsSync as existsSync3 } from "node:fs";
1127
+ import { dirname as dirname3 } from "node:path";
1128
+
1129
+ // plugins/herdr/src/config.ts
1130
+ var BUILTIN_DEFAULTS = {
1131
+ tabLabel: "{identifier} \xB7 {titleSlug}",
1132
+ workspaceLabel: "{repoName}",
1133
+ panes: []
1134
+ };
1135
+ function resolveStateConfig(options, state) {
1136
+ const base = options.default ?? {};
1137
+ const stateCfg = options.states?.[state] ?? {};
1138
+ return {
1139
+ tabLabel: stateCfg.tabLabel ?? base.tabLabel ?? BUILTIN_DEFAULTS.tabLabel,
1140
+ workspaceLabel: stateCfg.workspaceLabel ?? base.workspaceLabel ?? BUILTIN_DEFAULTS.workspaceLabel,
1141
+ panes: stateCfg.panes ?? base.panes ?? BUILTIN_DEFAULTS.panes
1142
+ };
1143
+ }
1144
+ function renderTemplate(tpl, vars) {
1145
+ return tpl.replace(/\{(\w+)\}/g, (whole, key) => {
1146
+ const value = vars[key];
1147
+ return value === void 0 ? whole : value;
1148
+ });
1149
+ }
1150
+
1151
+ // plugins/herdr/src/fs.ts
1152
+ import { chmodSync, lstatSync, readdirSync, rmSync } from "node:fs";
1153
+ import { join as join5 } from "node:path";
1154
+ function restoreWritable(path) {
1155
+ let stat2;
1156
+ try {
1157
+ stat2 = lstatSync(path);
1158
+ } catch {
1159
+ return;
1160
+ }
1161
+ if (stat2.isSymbolicLink()) {
1162
+ return;
1163
+ }
1164
+ try {
1165
+ chmodSync(path, stat2.mode | 128);
1166
+ } catch {
1167
+ }
1168
+ if (stat2.isDirectory()) {
1169
+ let entries;
1170
+ try {
1171
+ entries = readdirSync(path);
1172
+ } catch {
1173
+ return;
1174
+ }
1175
+ for (const entry of entries) {
1176
+ restoreWritable(join5(path, entry));
1177
+ }
1178
+ }
1179
+ }
1180
+ function forceRemoveDir(path) {
1181
+ restoreWritable(path);
1182
+ rmSync(path, { recursive: true, force: true });
1183
+ }
1184
+
1185
+ // plugins/herdr/src/index.ts
1186
+ async function defaultExec2(args) {
1187
+ return exec("herdr", args);
1188
+ }
1189
+ function isRecord2(value) {
1190
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1191
+ }
1192
+ function parseJson2(output, command) {
1193
+ try {
1194
+ return JSON.parse(output);
1195
+ } catch {
1196
+ throw new Error(`herdr ${command} returned invalid JSON`);
1197
+ }
1198
+ }
1199
+ function parseWorktreeList2(output) {
1200
+ const parsed = parseJson2(output, "worktree list");
1201
+ const worktrees = isRecord2(parsed) ? parsed.result?.worktrees : void 0;
1202
+ if (!Array.isArray(worktrees)) {
1203
+ return [];
1204
+ }
1205
+ return worktrees.filter(isRecord2).filter((w) => typeof w.branch === "string" && typeof w.path === "string").map((w) => ({
1206
+ branch: w.branch,
1207
+ path: w.path,
1208
+ ...typeof w.open_workspace_id === "string" && w.open_workspace_id ? { open_workspace_id: w.open_workspace_id } : {}
1209
+ }));
1210
+ }
1211
+ function parseTabList(output) {
1212
+ const parsed = parseJson2(output, "tab list");
1213
+ const tabs = isRecord2(parsed) ? parsed.result?.tabs : void 0;
1214
+ if (!Array.isArray(tabs)) {
1215
+ return [];
1216
+ }
1217
+ return tabs.filter(isRecord2).filter((t) => typeof t.tab_id === "string" && typeof t.label === "string").map((t) => ({
1218
+ tabId: t.tab_id,
1219
+ label: t.label
1220
+ }));
1221
+ }
1222
+ function parsePaneList(output) {
1223
+ const parsed = parseJson2(output, "pane list");
1224
+ const panes = isRecord2(parsed) ? parsed.result?.panes : void 0;
1225
+ if (!Array.isArray(panes)) {
1226
+ return [];
1227
+ }
1228
+ return panes.filter(isRecord2).filter(
1229
+ (p) => typeof p.pane_id === "string" && typeof p.tab_id === "string"
1230
+ ).map((p) => ({
1231
+ paneId: p.pane_id,
1232
+ tabId: p.tab_id
1233
+ }));
1234
+ }
1235
+ function parseTabCreate(output) {
1236
+ const parsed = parseJson2(output, "tab create");
1237
+ const result = isRecord2(parsed) ? parsed.result : void 0;
1238
+ const paneId = result?.root_pane?.pane_id;
1239
+ const tabId = result?.tab?.tab_id;
1240
+ if (typeof paneId !== "string" || typeof tabId !== "string") {
1241
+ throw new Error("herdr tab create returned invalid schema");
1242
+ }
1243
+ return { paneId, tabId };
1244
+ }
1245
+ function parsePaneSplit(output) {
1246
+ const parsed = parseJson2(output, "pane split");
1247
+ const paneId = isRecord2(parsed) ? parsed.result?.pane?.pane_id : void 0;
1248
+ if (typeof paneId !== "string") {
1249
+ throw new Error("herdr pane split returned invalid schema");
1250
+ }
1251
+ return paneId;
1252
+ }
1253
+ function shellSingleQuote3(value) {
1254
+ return `'${value.replaceAll("'", "'\\''")}'`;
1255
+ }
1256
+ function normPath(path) {
1257
+ return path.replace(/\/+$/, "");
1258
+ }
1259
+ function resolveWorktreeEntry(entries, branch, worktreePath) {
1260
+ if (worktreePath) {
1261
+ const want = normPath(worktreePath);
1262
+ const byPath = entries.find((e) => normPath(e.path) === want);
1263
+ if (byPath) {
1264
+ return byPath;
1265
+ }
1266
+ }
1267
+ return entries.find((e) => e.branch === branch) ?? null;
1268
+ }
1269
+ var HerdrExecutor = class {
1270
+ constructor(options, execHerdr = defaultExec2) {
1271
+ this.options = options;
1272
+ this.execHerdr = execHerdr;
1273
+ }
1274
+ options;
1275
+ execHerdr;
1276
+ id = "herdr";
1277
+ capabilities() {
1278
+ return { persistent: true };
1279
+ }
1280
+ /**
1281
+ * Find the worktree entry (path + open_workspace_id) for a branch via
1282
+ * worktree list. Returns null if the branch has no worktree.
1283
+ */
1284
+ async findWorktreeByBranch(branch) {
1285
+ const output = await this.execHerdr(["worktree", "list", "--json"]);
1286
+ const entries = parseWorktreeList2(output);
1287
+ return entries.find((e) => e.branch === branch) ?? null;
1288
+ }
1289
+ /**
1290
+ * Find the open_workspace_id for a branch via worktree list.
1291
+ * Returns null if no workspace is open for that branch.
1292
+ */
1293
+ async findWorkspaceByBranch(branch) {
1294
+ const entry = await this.findWorktreeByBranch(branch);
1295
+ return entry?.open_workspace_id ?? null;
1296
+ }
1297
+ /**
1298
+ * Tear down a ticket's entire worktree (git worktree + hosted workspace).
1299
+ * `herdr worktree remove` is keyed by an open workspace id, so resolve the
1300
+ * worktree first — by its stable `worktreePath`, falling back to `branch`
1301
+ * (see {@link resolveWorktreeEntry}): if its workspace is already open use
1302
+ * that id; otherwise open the on-disk worktree to obtain one (mirrors
1303
+ * startRun's open-if-needed).
1304
+ *
1305
+ * `herdr worktree list` is machine-global — it enumerates every repo's
1306
+ * worktrees on the host, most of which are not ours. So a no-match does NOT
1307
+ * mean "error"; it means herdr no longer tracks this ticket's worktree.
1308
+ * Teardown is idempotent, keyed off the stable on-disk `worktreePath`:
1309
+ * - path gone from disk → already torn down; quiet success.
1310
+ * - path still on disk → herdr lost track but the dir survives (an
1311
+ * orphan we own); reclaim it directly with {@link forceRemoveDir} so it
1312
+ * can't pile up — the concern the old throw was meant to surface.
1313
+ * Either way we never touch, list-dump, or fail over foreign worktrees.
1314
+ */
1315
+ async removeWorktree(branch, worktreePath) {
1316
+ const entries = parseWorktreeList2(
1317
+ await this.execHerdr(["worktree", "list", "--json"])
1318
+ );
1319
+ const entry = resolveWorktreeEntry(entries, branch, worktreePath);
1320
+ if (!entry) {
1321
+ if (worktreePath && existsSync3(worktreePath)) {
1322
+ forceRemoveDir(worktreePath);
1323
+ }
1324
+ return;
1325
+ }
1326
+ let workspaceId = entry.open_workspace_id ?? null;
1327
+ if (!workspaceId) {
1328
+ await this.execHerdr([
1329
+ "worktree",
1330
+ "open",
1331
+ "--cwd",
1332
+ this.options.root ?? entry.path,
1333
+ "--branch",
1334
+ entry.branch,
1335
+ "--no-focus",
1336
+ "--json"
1337
+ ]);
1338
+ const reopened = resolveWorktreeEntry(
1339
+ parseWorktreeList2(await this.execHerdr(["worktree", "list", "--json"])),
1340
+ branch,
1341
+ worktreePath
1342
+ );
1343
+ workspaceId = reopened?.open_workspace_id ?? null;
1344
+ if (!workspaceId) {
1345
+ throw new Error(
1346
+ `herdr worktree open did not yield a workspace id for path ${entry.path} (branch ${entry.branch})`
1347
+ );
1348
+ }
1349
+ }
1350
+ restoreWritable(entry.path);
1351
+ await this.execHerdr([
1352
+ "worktree",
1353
+ "remove",
1354
+ "--workspace",
1355
+ workspaceId,
1356
+ "--force",
1357
+ "--json"
1358
+ ]);
1359
+ }
1360
+ async startRun(input) {
1361
+ const branchName = input.ticket.branchName;
1362
+ const cfg = resolveStateConfig(
1363
+ this.options,
1364
+ input.ticket.state
1365
+ );
1366
+ let workspaceId = await this.findWorkspaceByBranch(branchName);
1367
+ if (!workspaceId) {
1368
+ await this.execHerdr([
1369
+ "worktree",
1370
+ "open",
1371
+ "--cwd",
1372
+ this.options.root ?? input.workspacePath,
1373
+ "--branch",
1374
+ branchName,
1375
+ "--no-focus",
1376
+ "--json"
1377
+ ]);
1378
+ workspaceId = await this.findWorkspaceByBranch(branchName);
1379
+ if (!workspaceId) {
1380
+ throw new Error(
1381
+ `herdr worktree open did not produce an open_workspace_id for branch ${branchName}`
1382
+ );
1383
+ }
1384
+ }
1385
+ const vars = {
1386
+ identifier: input.ticket.identifier,
1387
+ title: input.ticket.title,
1388
+ titleSlug: slugify(input.ticket.title),
1389
+ runUuid: input.run.uuid,
1390
+ workspacePath: input.workspacePath
1391
+ };
1392
+ const { paneId: rootPaneId, tabId } = parseTabCreate(
1393
+ await this.execHerdr([
1394
+ "tab",
1395
+ "create",
1396
+ "--workspace",
1397
+ workspaceId,
1398
+ "--label",
1399
+ `${input.ticket.identifier} \xB7 ${input.ticket.state} #${input.run.id}`,
1400
+ "--no-focus"
1401
+ ])
1402
+ );
1403
+ try {
1404
+ await this.execHerdr(["pane", "run", rootPaneId, this.commandFor(input)]);
1405
+ for (const spec of cfg.panes) {
1406
+ const splitPaneId = parsePaneSplit(
1407
+ await this.execHerdr([
1408
+ "pane",
1409
+ "split",
1410
+ rootPaneId,
1411
+ "--direction",
1412
+ spec.direction,
1413
+ "--cwd",
1414
+ input.workspacePath,
1415
+ spec.focus ? "--focus" : "--no-focus"
1416
+ ])
1417
+ );
1418
+ await this.execHerdr([
1419
+ "pane",
1420
+ "run",
1421
+ splitPaneId,
1422
+ renderTemplate(spec.command, vars)
1423
+ ]);
1424
+ }
1425
+ return { sessionRef: branchName };
1426
+ } catch (err) {
1427
+ await this.rollback(tabId);
1428
+ throw err;
1429
+ }
1430
+ }
1431
+ /** Best-effort cleanup: close only the just-created tab. Swallows errors. */
1432
+ async rollback(tabId) {
1433
+ try {
1434
+ await this.execHerdr(["tab", "close", tabId]);
1435
+ } catch {
1436
+ }
1437
+ }
1438
+ async retire(branch) {
1439
+ const workspaceId = await this.findWorkspaceByBranch(branch);
1440
+ if (!workspaceId) {
1441
+ return;
1442
+ }
1443
+ const tabs = parseTabList(
1444
+ await this.execHerdr(["tab", "list", "--workspace", workspaceId])
1445
+ );
1446
+ const panes = parsePaneList(
1447
+ await this.execHerdr(["pane", "list", "--workspace", workspaceId])
1448
+ );
1449
+ for (const tab of tabs) {
1450
+ try {
1451
+ if (tab.label.endsWith(" (done)")) {
1452
+ continue;
1453
+ }
1454
+ const pane = panes.find((p) => p.tabId === tab.tabId);
1455
+ if (pane) {
1456
+ await this.execHerdr(["pane", "send-keys", pane.paneId, "C-c"]);
1457
+ }
1458
+ await this.execHerdr([
1459
+ "tab",
1460
+ "rename",
1461
+ tab.tabId,
1462
+ `${tab.label} (done)`
1463
+ ]);
1464
+ } catch {
1465
+ }
1466
+ }
1467
+ }
1468
+ /**
1469
+ * Ask the agent in each open tab of the branch workspace to exit gracefully:
1470
+ * type `/exit` then send Enter into its pane. Unlike {@link retire} (C-c
1471
+ * SIGINT), this is the clean shutdown claude expects. Best-effort: no open
1472
+ * workspace, a `(done)` tab, or a tab with no matching pane are skipped, and
1473
+ * one failing tab never aborts the others.
1474
+ */
1475
+ async stopAgent(branch) {
1476
+ const workspaceId = await this.findWorkspaceByBranch(branch);
1477
+ if (!workspaceId) {
1478
+ return;
1479
+ }
1480
+ const tabs = parseTabList(
1481
+ await this.execHerdr(["tab", "list", "--workspace", workspaceId])
1482
+ );
1483
+ const panes = parsePaneList(
1484
+ await this.execHerdr(["pane", "list", "--workspace", workspaceId])
1485
+ );
1486
+ for (const tab of tabs) {
1487
+ if (tab.label.endsWith(" (done)")) {
1488
+ continue;
1489
+ }
1490
+ const pane = panes.find((p) => p.tabId === tab.tabId);
1491
+ if (!pane) {
1492
+ continue;
1493
+ }
1494
+ try {
1495
+ await this.execHerdr(["pane", "send-text", pane.paneId, "/exit"]);
1496
+ await this.execHerdr(["pane", "send-keys", pane.paneId, "Enter"]);
1497
+ } catch {
1498
+ }
1499
+ }
1500
+ }
1501
+ commandFor(input) {
1502
+ const env = Object.entries(input.env ?? {}).map(([key, value]) => {
1503
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
1504
+ throw new Error(`invalid environment variable name for herdr: ${key}`);
1505
+ }
1506
+ return `${key}=${shellSingleQuote3(value)}`;
1507
+ });
1508
+ const command = input.command ?? this.options.command ?? "claude";
1509
+ return [...env, command].join(" ");
1510
+ }
1511
+ };
1512
+ function herdrExecutor(options = {}) {
1513
+ return {
1514
+ kind: "executor",
1515
+ id: "herdr",
1516
+ requiredModules: [],
1517
+ async createExecutor(config) {
1518
+ const root = options.root ?? dirname3(config.config_path);
1519
+ return new HerdrExecutor({
1520
+ command: options.command ?? "claude",
1521
+ root,
1522
+ ...options.default ? { default: options.default } : {},
1523
+ ...options.states ? { states: options.states } : {}
1524
+ });
1525
+ }
1526
+ };
1527
+ }
1528
+
1529
+ // scripts/plugins-entry.ts
1530
+ import { oauth2Plugin } from "@dropsh/plugin-oauth2";
1531
+ export {
1532
+ ClaudeAgent,
1533
+ CodexAgent,
1534
+ DrupalGaiaRemote,
1535
+ FakeExecutor,
1536
+ FakeGaiaRemote,
1537
+ FakeWorkspace,
1538
+ GitWorkspace,
1539
+ HerdrExecutor,
1540
+ HerdrWorkspace,
1541
+ basicAuthProvider,
1542
+ claudeAgent,
1543
+ codexAgent,
1544
+ drupalRemote,
1545
+ encodeClaudeProjectDir,
1546
+ fakeExecutor,
1547
+ fakeRemote,
1548
+ fakeWorkspace,
1549
+ gitWorkspace,
1550
+ herdrExecutor,
1551
+ herdrWorkspace,
1552
+ oauth2Plugin,
1553
+ selectAgent,
1554
+ selectExecutor,
1555
+ selectRemote,
1556
+ selectWorkspace
1557
+ };