opencode-gitbutler 0.1.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/dist/index.js ADDED
@@ -0,0 +1,1731 @@
1
+ // @bun
2
+ // src/config.ts
3
+ import { resolve } from "path";
4
+ var DEFAULT_CONFIG = {
5
+ log_enabled: true,
6
+ commit_message_model: "claude-haiku-4-5",
7
+ commit_message_provider: "anthropic",
8
+ llm_timeout_ms: 15000,
9
+ max_diff_chars: 4000,
10
+ branch_slug_max_length: 50,
11
+ auto_update: true,
12
+ default_branch_pattern: "^ge-branch-\\d+$",
13
+ stale_lock_ms: 300000,
14
+ edit_debounce_ms: 200,
15
+ gc_on_session_start: false,
16
+ notification_max_age_ms: 300000
17
+ };
18
+ var CONFIG_FILE_NAME = ".opencode/gitbutler.json";
19
+ function stripJsonComments(input) {
20
+ let result = "";
21
+ let i = 0;
22
+ const len = input.length;
23
+ while (i < len) {
24
+ const ch = input[i];
25
+ if (ch === '"') {
26
+ let j = i + 1;
27
+ while (j < len) {
28
+ if (input[j] === "\\") {
29
+ j += 2;
30
+ continue;
31
+ }
32
+ if (input[j] === '"') {
33
+ j++;
34
+ break;
35
+ }
36
+ j++;
37
+ }
38
+ result += input.slice(i, j);
39
+ i = j;
40
+ continue;
41
+ }
42
+ if (ch === "/" && i + 1 < len && input[i + 1] === "/") {
43
+ i += 2;
44
+ while (i < len && input[i] !== `
45
+ `)
46
+ i++;
47
+ continue;
48
+ }
49
+ if (ch === "/" && i + 1 < len && input[i + 1] === "*") {
50
+ const closeIdx = input.indexOf("*/", i + 2);
51
+ i = closeIdx !== -1 ? closeIdx + 2 : len;
52
+ continue;
53
+ }
54
+ result += ch;
55
+ i++;
56
+ }
57
+ result = result.replace(/,\s*([\]}])/g, "$1");
58
+ return result;
59
+ }
60
+ function isValidRegex(pattern) {
61
+ if (typeof pattern !== "string")
62
+ return false;
63
+ try {
64
+ new RegExp(pattern);
65
+ return true;
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
70
+ async function loadConfig(cwd) {
71
+ const configPath = resolve(cwd, CONFIG_FILE_NAME);
72
+ try {
73
+ const file = Bun.file(configPath);
74
+ if (!await file.exists()) {
75
+ return { ...DEFAULT_CONFIG };
76
+ }
77
+ const raw = await file.text();
78
+ const cleaned = stripJsonComments(raw);
79
+ const parsed = JSON.parse(cleaned);
80
+ return {
81
+ log_enabled: typeof parsed.log_enabled === "boolean" ? parsed.log_enabled : DEFAULT_CONFIG.log_enabled,
82
+ commit_message_model: typeof parsed.commit_message_model === "string" ? parsed.commit_message_model : DEFAULT_CONFIG.commit_message_model,
83
+ commit_message_provider: typeof parsed.commit_message_provider === "string" ? parsed.commit_message_provider : DEFAULT_CONFIG.commit_message_provider,
84
+ llm_timeout_ms: typeof parsed.llm_timeout_ms === "number" && parsed.llm_timeout_ms > 0 ? parsed.llm_timeout_ms : DEFAULT_CONFIG.llm_timeout_ms,
85
+ max_diff_chars: typeof parsed.max_diff_chars === "number" && parsed.max_diff_chars > 0 ? parsed.max_diff_chars : DEFAULT_CONFIG.max_diff_chars,
86
+ branch_slug_max_length: typeof parsed.branch_slug_max_length === "number" && parsed.branch_slug_max_length > 0 ? parsed.branch_slug_max_length : DEFAULT_CONFIG.branch_slug_max_length,
87
+ auto_update: typeof parsed.auto_update === "boolean" ? parsed.auto_update : DEFAULT_CONFIG.auto_update,
88
+ default_branch_pattern: isValidRegex(parsed.default_branch_pattern) ? parsed.default_branch_pattern : DEFAULT_CONFIG.default_branch_pattern,
89
+ stale_lock_ms: typeof parsed.stale_lock_ms === "number" && parsed.stale_lock_ms > 0 ? parsed.stale_lock_ms : DEFAULT_CONFIG.stale_lock_ms,
90
+ edit_debounce_ms: typeof parsed.edit_debounce_ms === "number" && parsed.edit_debounce_ms >= 0 ? parsed.edit_debounce_ms : DEFAULT_CONFIG.edit_debounce_ms,
91
+ gc_on_session_start: typeof parsed.gc_on_session_start === "boolean" ? parsed.gc_on_session_start : DEFAULT_CONFIG.gc_on_session_start,
92
+ notification_max_age_ms: typeof parsed.notification_max_age_ms === "number" && parsed.notification_max_age_ms >= 0 ? parsed.notification_max_age_ms : DEFAULT_CONFIG.notification_max_age_ms,
93
+ ...typeof parsed.branch_target === "string" && parsed.branch_target.length > 0 ? { branch_target: parsed.branch_target } : {}
94
+ };
95
+ } catch (err) {
96
+ console.warn(`[opencode-gitbutler] Failed to parse config at ${configPath}: ${err instanceof Error ? err.message : String(err)}. Using defaults.`);
97
+ return { ...DEFAULT_CONFIG };
98
+ }
99
+ }
100
+
101
+ // src/logger.ts
102
+ import { appendFile } from "fs/promises";
103
+ var LOG_PATH_SUFFIX = ".opencode/plugin/debug.log";
104
+ function createLogger(logEnabled, cwd) {
105
+ function write(level, cat, data = {}) {
106
+ if (!logEnabled)
107
+ return;
108
+ const entry = {
109
+ ts: new Date().toISOString(),
110
+ level,
111
+ cat,
112
+ ...data
113
+ };
114
+ appendFile(`${cwd}/${LOG_PATH_SUFFIX}`, JSON.stringify(entry) + `
115
+ `).catch(() => {});
116
+ }
117
+ return {
118
+ info: (cat, data) => write("info", cat, data ?? {}),
119
+ warn: (cat, data) => write("warn", cat, data ?? {}),
120
+ error: (cat, data) => write("error", cat, data ?? {})
121
+ };
122
+ }
123
+
124
+ // src/cli.ts
125
+ import { resolve as resolve2, relative } from "path";
126
+ var CURSOR_RETRY_PARAMS = {
127
+ stop: { maxRetries: 5, baseMs: 500 },
128
+ default: { maxRetries: 3, baseMs: 200 }
129
+ };
130
+ function createCli(cwd, log) {
131
+ const resolvedCwd = resolve2(cwd);
132
+ function toRelativePath(absPath) {
133
+ const resolved = resolve2(absPath);
134
+ const rel = relative(resolvedCwd, resolved);
135
+ if (rel.startsWith(".."))
136
+ return absPath;
137
+ return rel;
138
+ }
139
+ function isWorkspaceMode() {
140
+ try {
141
+ const proc = Bun.spawnSync(["git", "symbolic-ref", "--short", "HEAD"], { cwd, stdout: "pipe", stderr: "pipe" });
142
+ if (proc.exitCode !== 0)
143
+ return false;
144
+ return proc.stdout.toString().trim() === "gitbutler/workspace";
145
+ } catch {
146
+ return false;
147
+ }
148
+ }
149
+ function findFileBranch(filePath, statusData) {
150
+ try {
151
+ let data;
152
+ if (statusData) {
153
+ data = statusData;
154
+ } else {
155
+ const proc = Bun.spawnSync(["but", "status", "--json", "-f"], { cwd, stdout: "pipe", stderr: "pipe" });
156
+ if (proc.exitCode !== 0)
157
+ return { inBranch: false };
158
+ data = JSON.parse(proc.stdout.toString());
159
+ }
160
+ const normalized = toRelativePath(filePath);
161
+ const unassigned = data.unassignedChanges?.find((ch) => ch.filePath === normalized);
162
+ for (const stack of data.stacks ?? []) {
163
+ if (stack.assignedChanges?.some((ch) => ch.filePath === normalized)) {
164
+ return { inBranch: true };
165
+ }
166
+ for (const branch of stack.branches ?? []) {
167
+ for (const commit of branch.commits ?? []) {
168
+ if (commit.changes?.some((ch) => ch.filePath === normalized)) {
169
+ return {
170
+ inBranch: true,
171
+ branchCliId: branch.cliId,
172
+ branchName: branch.name,
173
+ unassignedCliId: unassigned?.cliId
174
+ };
175
+ }
176
+ }
177
+ }
178
+ }
179
+ return { inBranch: false };
180
+ } catch {
181
+ return { inBranch: false };
182
+ }
183
+ }
184
+ function butRub(source, dest) {
185
+ try {
186
+ const proc = Bun.spawnSync(["but", "rub", source, dest], { cwd, stdout: "pipe", stderr: "pipe" });
187
+ return proc.exitCode === 0;
188
+ } catch {
189
+ return false;
190
+ }
191
+ }
192
+ function butUnapply(branchCliId) {
193
+ try {
194
+ const proc = Bun.spawnSync(["but", "unapply", branchCliId], { cwd, stdout: "pipe", stderr: "pipe" });
195
+ return {
196
+ ok: proc.exitCode === 0,
197
+ stderr: proc.stderr?.toString().trim() ?? ""
198
+ };
199
+ } catch (err) {
200
+ return {
201
+ ok: false,
202
+ stderr: err instanceof Error ? err.message : String(err)
203
+ };
204
+ }
205
+ }
206
+ async function butUnapplyWithRetry(branchCliId, branchName, maxRetries = 4) {
207
+ let lastStderr = "";
208
+ for (let attempt = 0;attempt <= maxRetries; attempt++) {
209
+ if (attempt > 0) {
210
+ const status = getFullStatus();
211
+ if (status?.stacks) {
212
+ const branch = status.stacks.flatMap((s) => s.branches ?? []).find((b) => b.cliId === branchCliId);
213
+ if (!branch) {
214
+ log.info("cleanup-ok", {
215
+ branch: branchName,
216
+ retries: attempt,
217
+ reason: "branch-gone"
218
+ });
219
+ return true;
220
+ }
221
+ if (branch.commits.length > 0) {
222
+ log.info("cleanup-skipped", {
223
+ branch: branchName,
224
+ retries: attempt,
225
+ reason: "branch-has-commits",
226
+ commitCount: branch.commits.length
227
+ });
228
+ return true;
229
+ }
230
+ }
231
+ }
232
+ const result = butUnapply(branchCliId);
233
+ if (result.ok) {
234
+ log.info("cleanup-ok", {
235
+ branch: branchName,
236
+ ...attempt > 0 ? { retries: attempt } : {}
237
+ });
238
+ return true;
239
+ }
240
+ lastStderr = result.stderr;
241
+ const isLocked = lastStderr.includes("locked") || lastStderr.includes("SQLITE_BUSY") || lastStderr.includes("database is locked");
242
+ const isNotFound = lastStderr.includes("not found") || lastStderr.includes("Branch not found");
243
+ if (isNotFound) {
244
+ log.info("cleanup-ok", {
245
+ branch: branchName,
246
+ retries: attempt,
247
+ reason: "not-found"
248
+ });
249
+ return true;
250
+ }
251
+ if (attempt < maxRetries) {
252
+ const delay = 500 * 2 ** attempt;
253
+ log.info("cleanup-retry", {
254
+ branch: branchName,
255
+ attempt: attempt + 1,
256
+ delayMs: delay,
257
+ reason: isLocked ? "locked" : "unknown",
258
+ stderr: lastStderr.slice(0, 200)
259
+ });
260
+ await Bun.sleep(delay);
261
+ }
262
+ }
263
+ log.error("cleanup-failed", {
264
+ branch: branchName,
265
+ attempts: maxRetries + 1,
266
+ stderr: lastStderr.slice(0, 500),
267
+ reason: lastStderr.includes("locked") ? "locked" : "unknown"
268
+ });
269
+ return false;
270
+ }
271
+ function getFullStatus() {
272
+ try {
273
+ const proc = Bun.spawnSync(["but", "status", "--json", "-f"], { cwd, stdout: "pipe", stderr: "pipe" });
274
+ if (proc.exitCode !== 0)
275
+ return null;
276
+ return JSON.parse(proc.stdout.toString());
277
+ } catch {
278
+ return null;
279
+ }
280
+ }
281
+ function butReword(target, message) {
282
+ try {
283
+ const proc = Bun.spawnSync(["but", "reword", target, "-m", message], { cwd, stdout: "pipe", stderr: "pipe" });
284
+ return {
285
+ ok: proc.exitCode === 0,
286
+ stderr: proc.stderr?.toString().trim() ?? ""
287
+ };
288
+ } catch (err) {
289
+ return {
290
+ ok: false,
291
+ stderr: err instanceof Error ? err.message : String(err)
292
+ };
293
+ }
294
+ }
295
+ async function butCursor(subcommand, payload) {
296
+ const json = JSON.stringify(payload);
297
+ const retryParams = CURSOR_RETRY_PARAMS[subcommand] ?? CURSOR_RETRY_PARAMS.default;
298
+ for (let attempt = 0;attempt <= retryParams.maxRetries; attempt++) {
299
+ const proc = Bun.spawn(["but", "cursor", subcommand], {
300
+ cwd,
301
+ stdout: "ignore",
302
+ stderr: "pipe",
303
+ stdin: new Blob([json])
304
+ });
305
+ const exitCode = await proc.exited;
306
+ if (exitCode === 0) {
307
+ log.info("cursor-ok", {
308
+ subcommand,
309
+ conversationId: payload.conversation_id,
310
+ ...attempt > 0 ? { retries: attempt } : {}
311
+ });
312
+ return;
313
+ }
314
+ const stderr = await new Response(proc.stderr).text();
315
+ const isExpectedError = stderr.includes("not in workspace mode") || stderr.includes("not initialized") || stderr.includes("No such file or directory") || stderr.includes("No hunk headers") || stderr.includes("no changes") || stderr.includes("checkout gitbutler/workspace");
316
+ if (isExpectedError)
317
+ return;
318
+ const isRecoverableRace = stderr.includes("Stack not found") || stderr.includes("reference mismatch") || stderr.includes("Branch not found") || stderr.includes("workspace reference");
319
+ if (isRecoverableRace) {
320
+ log.warn("cursor-race", {
321
+ subcommand,
322
+ exitCode,
323
+ stderr: stderr.trim(),
324
+ attempt,
325
+ conversationId: payload.conversation_id
326
+ });
327
+ return;
328
+ }
329
+ const isRetryable = stderr.includes("database is locked") || stderr.includes("SQLITE_BUSY") || stderr.includes("failed to lock file");
330
+ if (isRetryable && attempt < retryParams.maxRetries) {
331
+ const delay = retryParams.baseMs * 2 ** attempt;
332
+ await Bun.sleep(delay);
333
+ continue;
334
+ }
335
+ log.error("cursor-error", {
336
+ subcommand,
337
+ exitCode,
338
+ stderr: stderr.trim(),
339
+ attempt
340
+ });
341
+ throw new Error(`but cursor ${subcommand} failed (exit ${exitCode}): ${stderr.trim()}`);
342
+ }
343
+ }
344
+ function extractFilePath(output) {
345
+ return output.metadata?.filediff?.file ?? output.metadata?.filepath ?? undefined;
346
+ }
347
+ function extractEdits(output) {
348
+ const fd = output.metadata?.filediff;
349
+ if (fd?.before != null && fd?.after != null) {
350
+ return [
351
+ { old_string: fd.before, new_string: fd.after }
352
+ ];
353
+ }
354
+ return [];
355
+ }
356
+ function hasMultiBranchHunks(filePath, statusData) {
357
+ try {
358
+ let data;
359
+ if (statusData) {
360
+ data = statusData;
361
+ } else {
362
+ const proc = Bun.spawnSync(["but", "status", "--json", "-f"], { cwd, stdout: "pipe", stderr: "pipe" });
363
+ if (proc.exitCode !== 0)
364
+ return false;
365
+ data = JSON.parse(proc.stdout.toString());
366
+ }
367
+ let branchCount = 0;
368
+ for (const stack of data.stacks ?? []) {
369
+ for (const branch of stack.branches ?? []) {
370
+ const hasInBranch = branch.commits?.some((c) => c.changes?.some((ch) => ch.filePath === filePath));
371
+ if (hasInBranch)
372
+ branchCount++;
373
+ if (branchCount > 1)
374
+ return true;
375
+ }
376
+ }
377
+ return false;
378
+ } catch {
379
+ return false;
380
+ }
381
+ }
382
+ return {
383
+ isWorkspaceMode,
384
+ findFileBranch,
385
+ butRub,
386
+ butUnapply,
387
+ butUnapplyWithRetry,
388
+ getFullStatus,
389
+ butReword,
390
+ butCursor,
391
+ extractFilePath,
392
+ extractEdits,
393
+ hasMultiBranchHunks,
394
+ toRelativePath
395
+ };
396
+ }
397
+
398
+ // src/state.ts
399
+ var SUBAGENT_TOOLS = new Set([
400
+ "agent",
401
+ "task",
402
+ "delegate_task"
403
+ ]);
404
+ function createStateManager(cwd, log) {
405
+ const SESSION_MAP_PATH = `${cwd}/.opencode/plugin/session-map.json`;
406
+ const PLUGIN_STATE_PATH = `${cwd}/.opencode/plugin/plugin-state.json`;
407
+ const parentSessionByTaskSession = new Map;
408
+ async function loadPluginState() {
409
+ try {
410
+ const file = Bun.file(PLUGIN_STATE_PATH);
411
+ if (!await file.exists())
412
+ return {
413
+ conversationsWithEdits: [],
414
+ rewordedBranches: [],
415
+ branchOwnership: {}
416
+ };
417
+ const state = await file.json();
418
+ return {
419
+ ...state,
420
+ branchOwnership: state.branchOwnership ?? {}
421
+ };
422
+ } catch {
423
+ return {
424
+ conversationsWithEdits: [],
425
+ rewordedBranches: [],
426
+ branchOwnership: {}
427
+ };
428
+ }
429
+ }
430
+ async function savePluginState(conversations, reworded, ownership) {
431
+ const state = {
432
+ conversationsWithEdits: [...conversations],
433
+ rewordedBranches: [...reworded],
434
+ branchOwnership: Object.fromEntries(ownership)
435
+ };
436
+ await Bun.write(PLUGIN_STATE_PATH, JSON.stringify(state, null, 2) + `
437
+ `);
438
+ }
439
+ async function loadSessionMap() {
440
+ try {
441
+ const file = Bun.file(SESSION_MAP_PATH);
442
+ if (!await file.exists())
443
+ return new Map;
444
+ const data = await file.json();
445
+ return new Map(Object.entries(data));
446
+ } catch {
447
+ return new Map;
448
+ }
449
+ }
450
+ async function saveSessionMap(map) {
451
+ await Bun.write(SESSION_MAP_PATH, JSON.stringify(Object.fromEntries(map), null, 2) + `
452
+ `);
453
+ }
454
+ function resolveSessionRoot(sessionID) {
455
+ if (!sessionID)
456
+ return "opencode-default";
457
+ const seen = new Set;
458
+ let current = sessionID;
459
+ while (true) {
460
+ if (seen.has(current))
461
+ return current;
462
+ seen.add(current);
463
+ const parent = parentSessionByTaskSession.get(current);
464
+ if (!parent)
465
+ return current;
466
+ current = parent;
467
+ }
468
+ }
469
+ async function trackSubagentMapping(input, output) {
470
+ const tool = input.tool;
471
+ const parentSessionID = input.sessionID;
472
+ const taskCallID = input.callID;
473
+ if (!tool || !SUBAGENT_TOOLS.has(tool))
474
+ return;
475
+ if (!parentSessionID)
476
+ return;
477
+ if (taskCallID) {
478
+ parentSessionByTaskSession.set(taskCallID, parentSessionID);
479
+ }
480
+ const metadata = output?.metadata;
481
+ const executionSessionId = typeof metadata?.sessionId === "string" ? metadata.sessionId : typeof metadata?.session_id === "string" ? metadata.session_id : undefined;
482
+ if (executionSessionId && executionSessionId !== parentSessionID) {
483
+ parentSessionByTaskSession.set(executionSessionId, parentSessionID);
484
+ log.info("session-map-execution", {
485
+ executionSession: executionSessionId,
486
+ parent: parentSessionID,
487
+ callID: taskCallID
488
+ });
489
+ }
490
+ try {
491
+ await saveSessionMap(parentSessionByTaskSession);
492
+ } catch (err) {
493
+ log.warn("session-map-save-failed", {
494
+ task: taskCallID,
495
+ parent: parentSessionID,
496
+ error: err instanceof Error ? err.message : String(err)
497
+ });
498
+ }
499
+ log.info("session-map-subagent", {
500
+ task: taskCallID,
501
+ parent: parentSessionID
502
+ });
503
+ }
504
+ async function trackSessionCreatedMapping(event) {
505
+ if (event.type !== "session.created")
506
+ return;
507
+ const properties = event.properties;
508
+ if (!properties)
509
+ return;
510
+ const info = properties.info;
511
+ const sessionID = (typeof info?.id === "string" ? info.id : undefined) ?? (typeof properties.id === "string" ? properties.id : undefined);
512
+ const parentSessionID = (typeof info?.parentID === "string" ? info.parentID : undefined) ?? (typeof properties.parentSessionID === "string" ? properties.parentSessionID : typeof properties.parent_session_id === "string" ? properties.parent_session_id : undefined);
513
+ if (!sessionID || !parentSessionID)
514
+ return;
515
+ parentSessionByTaskSession.set(sessionID, parentSessionID);
516
+ try {
517
+ await saveSessionMap(parentSessionByTaskSession);
518
+ } catch (err) {
519
+ log.warn("session-map-save-failed", {
520
+ session: sessionID,
521
+ parent: parentSessionID,
522
+ error: err instanceof Error ? err.message : String(err)
523
+ });
524
+ }
525
+ log.info("session-map-created", {
526
+ session: sessionID,
527
+ parent: parentSessionID
528
+ });
529
+ }
530
+ return {
531
+ loadPluginState,
532
+ savePluginState,
533
+ loadSessionMap,
534
+ saveSessionMap,
535
+ resolveSessionRoot,
536
+ trackSubagentMapping,
537
+ trackSessionCreatedMapping,
538
+ parentSessionByTaskSession
539
+ };
540
+ }
541
+
542
+ // src/notify.ts
543
+ function createNotificationManager(log, resolveSessionRoot, maxAgeMs = 300000) {
544
+ const pendingNotifications = new Map;
545
+ function reapExpired() {
546
+ if (maxAgeMs <= 0)
547
+ return;
548
+ const now = Date.now();
549
+ for (const [rootID, notifications] of pendingNotifications) {
550
+ const expired = notifications.filter((n) => now - n.timestamp > maxAgeMs);
551
+ if (expired.length > 0) {
552
+ for (const n of expired) {
553
+ log.warn("notification-expired", {
554
+ rootID,
555
+ message: n.message,
556
+ ageMs: now - n.timestamp
557
+ });
558
+ }
559
+ const remaining = notifications.filter((n) => now - n.timestamp <= maxAgeMs);
560
+ if (remaining.length === 0) {
561
+ pendingNotifications.delete(rootID);
562
+ } else {
563
+ pendingNotifications.set(rootID, remaining);
564
+ }
565
+ }
566
+ }
567
+ }
568
+ function addNotification(sessionID, message) {
569
+ reapExpired();
570
+ const rootID = resolveSessionRoot(sessionID);
571
+ const existing = pendingNotifications.get(rootID) ?? [];
572
+ existing.push({
573
+ message,
574
+ timestamp: Date.now()
575
+ });
576
+ pendingNotifications.set(rootID, existing);
577
+ log.info("notification-queued", {
578
+ rootID,
579
+ message
580
+ });
581
+ }
582
+ function consumeNotifications(sessionID) {
583
+ const rootID = resolveSessionRoot(sessionID);
584
+ const notifications = pendingNotifications.get(rootID);
585
+ if (!notifications || notifications.length === 0)
586
+ return null;
587
+ const now = Date.now();
588
+ const live = maxAgeMs > 0 ? notifications.filter((n) => {
589
+ const expired = now - n.timestamp > maxAgeMs;
590
+ if (expired) {
591
+ log.warn("notification-expired", {
592
+ rootID,
593
+ message: n.message,
594
+ ageMs: now - n.timestamp
595
+ });
596
+ }
597
+ return !expired;
598
+ }) : notifications;
599
+ pendingNotifications.delete(rootID);
600
+ if (live.length === 0)
601
+ return null;
602
+ const lines = live.map((n) => `- ${n.message}`).join(`
603
+ `);
604
+ return [
605
+ "<system-reminder>",
606
+ "[GITBUTLER STATE UPDATE]",
607
+ "The following happened automatically since your last response:",
608
+ "",
609
+ lines,
610
+ "",
611
+ "This is informational \u2014 no action needed unless relevant to your current task.",
612
+ "</system-reminder>"
613
+ ].join(`
614
+ `);
615
+ }
616
+ return { addNotification, consumeNotifications };
617
+ }
618
+
619
+ // src/reword.ts
620
+ var COMMIT_PREFIX_PATTERNS = [
621
+ {
622
+ pattern: /\b(fix|bug|broken|repair|patch)\b/i,
623
+ prefix: "fix"
624
+ },
625
+ {
626
+ pattern: /\b(add|create|implement|new|feature)\b/i,
627
+ prefix: "feat"
628
+ },
629
+ {
630
+ pattern: /\b(refactor|clean|restructure|reorganize)\b/i,
631
+ prefix: "refactor"
632
+ },
633
+ {
634
+ pattern: /\b(test|spec|coverage)\b/i,
635
+ prefix: "test"
636
+ },
637
+ {
638
+ pattern: /\b(doc|readme|documentation)\b/i,
639
+ prefix: "docs"
640
+ },
641
+ {
642
+ pattern: /\b(style|css|design|ui|layout)\b/i,
643
+ prefix: "style"
644
+ },
645
+ {
646
+ pattern: /\b(perf|performance|optimize|speed)\b/i,
647
+ prefix: "perf"
648
+ }
649
+ ];
650
+ function detectCommitPrefix(text) {
651
+ for (const {
652
+ pattern,
653
+ prefix
654
+ } of COMMIT_PREFIX_PATTERNS) {
655
+ if (pattern.test(text))
656
+ return prefix;
657
+ }
658
+ return "chore";
659
+ }
660
+ function toCommitMessage(prompt) {
661
+ const firstLine = prompt.split(`
662
+ `)[0]?.trim() ?? "";
663
+ if (!firstLine)
664
+ return "chore: OpenCode session changes";
665
+ const prefix = detectCommitPrefix(firstLine);
666
+ const description = firstLine.replace(/^(fix|feat|refactor|test|docs|style|perf|chore)(\(.+?\))?:\s*/i, "").trim();
667
+ const maxLen = 72 - prefix.length - 2;
668
+ const truncated = description.length > maxLen ? description.slice(0, maxLen - 3) + "..." : description;
669
+ return `${prefix}: ${truncated || "OpenCode session changes"}`;
670
+ }
671
+ function toBranchSlug(prompt, maxLength) {
672
+ const cleaned = prompt.replace(/[^a-zA-Z0-9\s-]/g, "").trim().toLowerCase().split(/\s+/).slice(0, 6).join("-");
673
+ return cleaned.slice(0, maxLength) || "opencode-session";
674
+ }
675
+ function classifyRewordFailure(stderr) {
676
+ if (stderr.includes("locked") || stderr.includes("SQLITE_BUSY"))
677
+ return "locked";
678
+ if (stderr.includes("not found") || stderr.includes("Branch not found"))
679
+ return "not-found";
680
+ if (stderr.includes("reference mismatch") || stderr.includes("workspace reference"))
681
+ return "reference-mismatch";
682
+ if (stderr.includes("not in workspace mode") || stderr.includes("not initialized"))
683
+ return "not-workspace";
684
+ return "unknown";
685
+ }
686
+ function createRewordManager(deps) {
687
+ const {
688
+ cwd,
689
+ log,
690
+ cli,
691
+ config,
692
+ defaultBranchPattern,
693
+ addNotification,
694
+ resolveSessionRoot,
695
+ conversationsWithEdits,
696
+ rewordedBranches,
697
+ branchOwnership,
698
+ editedFilesPerConversation,
699
+ savePluginState,
700
+ internalSessionIds,
701
+ reapStaleLocks,
702
+ client
703
+ } = deps;
704
+ const LLM_TIMEOUT_MS = config.llm_timeout_ms;
705
+ const MAX_DIFF_CHARS = config.max_diff_chars;
706
+ async function fetchUserPrompt(sessionID) {
707
+ try {
708
+ const res = await client.session.messages({
709
+ path: { id: sessionID },
710
+ query: { limit: 5 }
711
+ });
712
+ if (!res.data)
713
+ return null;
714
+ for (const msg of res.data) {
715
+ if (msg.info.role !== "user")
716
+ continue;
717
+ const textPart = msg.parts.find((p) => p.type === "text");
718
+ if (textPart?.text)
719
+ return textPart.text;
720
+ }
721
+ return null;
722
+ } catch {
723
+ return null;
724
+ }
725
+ }
726
+ async function generateLLMCommitMessage(commitId, userPrompt) {
727
+ try {
728
+ log.info("llm-start", {
729
+ commitId,
730
+ promptLength: userPrompt.length
731
+ });
732
+ const diffProc = Bun.spawnSync([
733
+ "git",
734
+ "show",
735
+ commitId,
736
+ "--format=",
737
+ "--no-color"
738
+ ], { cwd, stdout: "pipe", stderr: "pipe" });
739
+ if (diffProc.exitCode !== 0)
740
+ return null;
741
+ const diff = diffProc.stdout.toString().trim();
742
+ if (!diff)
743
+ return null;
744
+ const truncatedDiff = diff.length > MAX_DIFF_CHARS ? diff.slice(0, MAX_DIFF_CHARS) + `
745
+ ... (truncated)` : diff;
746
+ const sessionRes = await client.session.create({
747
+ body: { title: "commit-msg-gen" }
748
+ });
749
+ if (!sessionRes.data)
750
+ return null;
751
+ const tempSessionId = sessionRes.data.id;
752
+ internalSessionIds.add(tempSessionId);
753
+ try {
754
+ const promptText = [
755
+ "Generate a one-line conventional commit message for this diff.",
756
+ "Format: type: description (max 72 chars total).",
757
+ "Types: feat, fix, refactor, test, docs, style, perf, chore.",
758
+ `User intent: "${userPrompt.split(`
759
+ `)[0]?.trim().slice(0, 200) ?? ""}"`,
760
+ "",
761
+ "Diff:",
762
+ truncatedDiff,
763
+ "",
764
+ "Reply with ONLY the commit message, nothing else."
765
+ ].join(`
766
+ `);
767
+ const timeoutPromise = new Promise((resolve3) => setTimeout(() => resolve3(null), LLM_TIMEOUT_MS));
768
+ const llmPromise = client.session.prompt({
769
+ path: { id: tempSessionId },
770
+ body: {
771
+ model: {
772
+ providerID: config.commit_message_provider,
773
+ modelID: config.commit_message_model
774
+ },
775
+ system: "You are a commit message generator. Output ONLY a single-line conventional commit message. No explanation, no markdown, no quotes, no code fences.",
776
+ tools: {},
777
+ parts: [
778
+ { type: "text", text: promptText }
779
+ ]
780
+ }
781
+ });
782
+ const response = await Promise.race([
783
+ llmPromise,
784
+ timeoutPromise
785
+ ]);
786
+ if (!response || !("data" in response) || !response.data) {
787
+ log.warn("llm-timeout-or-empty", {
788
+ commitId
789
+ });
790
+ return null;
791
+ }
792
+ const textPart = response.data.parts.find((p) => p.type === "text");
793
+ if (!textPart?.text)
794
+ return null;
795
+ const message = textPart.text.trim().replace(/^["'`]+|["'`]+$/g, "").split(`
796
+ `)[0]?.trim();
797
+ if (!message)
798
+ return null;
799
+ const validPrefix = /^(feat|fix|refactor|test|docs|style|perf|chore|ci|build)(\(.+?\))?:\s/;
800
+ if (!validPrefix.test(message)) {
801
+ log.warn("llm-invalid-format", {
802
+ commitId,
803
+ message
804
+ });
805
+ return null;
806
+ }
807
+ if (message.length > 72)
808
+ return message.slice(0, 69) + "...";
809
+ log.info("llm-success", {
810
+ commitId,
811
+ message
812
+ });
813
+ return message;
814
+ } finally {
815
+ internalSessionIds.delete(tempSessionId);
816
+ client.session.delete({ path: { id: tempSessionId } }).catch(() => {});
817
+ }
818
+ } catch {
819
+ return null;
820
+ }
821
+ }
822
+ async function postStopProcessing(sessionID, conversationId, stopFailed = false) {
823
+ if (!sessionID)
824
+ return;
825
+ const rootSessionID = resolveSessionRoot(sessionID);
826
+ log.info("post-stop-start", {
827
+ sessionID,
828
+ rootSessionID
829
+ });
830
+ if (stopFailed) {
831
+ log.warn("post-stop-degraded", {
832
+ sessionID,
833
+ rootSessionID,
834
+ reason: "stop command failed, attempting recovery"
835
+ });
836
+ }
837
+ reapStaleLocks();
838
+ const editedFiles = editedFilesPerConversation.get(conversationId);
839
+ let sweepRubCount = 0;
840
+ if (editedFiles && editedFiles.size > 0) {
841
+ for (const filePath of editedFiles) {
842
+ try {
843
+ const branchInfo = cli.findFileBranch(filePath);
844
+ if (branchInfo.unassignedCliId && branchInfo.branchCliId) {
845
+ if (!cli.hasMultiBranchHunks(filePath)) {
846
+ const rubOk = cli.butRub(branchInfo.unassignedCliId, branchInfo.branchCliId);
847
+ if (rubOk) {
848
+ sweepRubCount++;
849
+ log.info("post-stop-sweep-rub", {
850
+ file: filePath,
851
+ source: branchInfo.unassignedCliId,
852
+ dest: branchInfo.branchCliId
853
+ });
854
+ }
855
+ }
856
+ }
857
+ } catch {}
858
+ }
859
+ if (sweepRubCount > 0) {
860
+ log.info("post-stop-sweep-summary", {
861
+ conversationId,
862
+ filesChecked: editedFiles.size,
863
+ rubbed: sweepRubCount
864
+ });
865
+ }
866
+ }
867
+ const prompt = await fetchUserPrompt(rootSessionID);
868
+ if (!prompt)
869
+ return;
870
+ const status = cli.getFullStatus();
871
+ if (!status?.stacks)
872
+ return;
873
+ const ownershipSnapshot = [];
874
+ for (const stack of status.stacks) {
875
+ for (const branch of stack.branches ?? []) {
876
+ if (branch.commits.length > 0 || (stack.assignedChanges?.length ?? 0) > 0) {
877
+ ownershipSnapshot.push({
878
+ branchName: branch.name,
879
+ branchCliId: branch.cliId,
880
+ commitCount: branch.commits.length
881
+ });
882
+ }
883
+ }
884
+ }
885
+ log.info("branch-ownership-snapshot", {
886
+ sessionID,
887
+ rootSessionID,
888
+ branches: ownershipSnapshot
889
+ });
890
+ let rewordCount = 0;
891
+ let renameCount = 0;
892
+ let cleanupCount = 0;
893
+ let failCount = 0;
894
+ let latestBranchName = null;
895
+ for (const stack of status.stacks) {
896
+ for (const branch of stack.branches ?? []) {
897
+ if (branch.branchStatus !== "completelyUnpushed")
898
+ continue;
899
+ if (branch.commits.length === 0)
900
+ continue;
901
+ if (rewordedBranches.has(branch.cliId))
902
+ continue;
903
+ const commit = branch.commits[0];
904
+ if (!commit)
905
+ continue;
906
+ try {
907
+ const VALID_CONVENTIONAL = /^(feat|fix|refactor|test|docs|style|perf|chore|ci|build)(\(.+?\))?:\s/;
908
+ const DEFAULT_PLACEHOLDERS = [
909
+ "session changes",
910
+ "opencode session changes",
911
+ "cursor session changes"
912
+ ];
913
+ const existingMsg = commit.message?.trim() ?? "";
914
+ const isAlreadyReworded = VALID_CONVENTIONAL.test(existingMsg) && !DEFAULT_PLACEHOLDERS.some((p) => existingMsg.toLowerCase().includes(p));
915
+ if (isAlreadyReworded) {
916
+ log.info("reword-skipped-existing", {
917
+ branch: branch.name,
918
+ commit: commit.cliId,
919
+ existingMessage: existingMsg
920
+ });
921
+ rewordedBranches.add(branch.cliId);
922
+ rewordCount++;
923
+ } else {
924
+ const llmMessage = await generateLLMCommitMessage(commit.commitId, prompt);
925
+ const commitMsg = llmMessage ?? toCommitMessage(prompt);
926
+ let rewordResult = cli.butReword(commit.cliId, commitMsg);
927
+ if (!rewordResult.ok) {
928
+ const isLocked = rewordResult.stderr.includes("locked") || rewordResult.stderr.includes("SQLITE_BUSY");
929
+ if (isLocked) {
930
+ await Bun.sleep(1000);
931
+ rewordResult = cli.butReword(commit.cliId, commitMsg);
932
+ }
933
+ }
934
+ if (!rewordResult.ok) {
935
+ const reason = classifyRewordFailure(rewordResult.stderr);
936
+ log.warn("reword-failed", {
937
+ branch: branch.name,
938
+ commit: commit.cliId,
939
+ message: commitMsg,
940
+ reason,
941
+ stderr: rewordResult.stderr.slice(0, 300)
942
+ });
943
+ failCount++;
944
+ continue;
945
+ }
946
+ rewordedBranches.add(branch.cliId);
947
+ savePluginState(conversationsWithEdits, rewordedBranches, branchOwnership).catch(() => {});
948
+ addNotification(sessionID, `Commit on branch \`${branch.name}\` reworded to: "${commitMsg}"`);
949
+ log.info("reword", {
950
+ branch: branch.name,
951
+ commit: commit.cliId,
952
+ message: commitMsg,
953
+ source: llmMessage ? "llm" : "deterministic",
954
+ multi: branch.commits.length > 1
955
+ });
956
+ rewordCount++;
957
+ }
958
+ if (defaultBranchPattern.test(branch.name)) {
959
+ latestBranchName = toBranchSlug(prompt, config.branch_slug_max_length);
960
+ const renameResult = cli.butReword(branch.cliId, latestBranchName);
961
+ if (renameResult.ok) {
962
+ log.info("branch-rename", {
963
+ status: "ok",
964
+ from: branch.name,
965
+ to: latestBranchName
966
+ });
967
+ addNotification(sessionID, `Branch renamed from \`${branch.name}\` to \`${latestBranchName}\``);
968
+ renameCount++;
969
+ } else {
970
+ log.warn("branch-rename", {
971
+ status: "failed",
972
+ from: branch.name,
973
+ to: latestBranchName,
974
+ reason: classifyRewordFailure(renameResult.stderr),
975
+ stderr: renameResult.stderr.slice(0, 300)
976
+ });
977
+ latestBranchName = branch.name;
978
+ failCount++;
979
+ }
980
+ } else {
981
+ log.info("branch-rename", {
982
+ status: "skipped",
983
+ branch: branch.name,
984
+ reason: "user-named"
985
+ });
986
+ latestBranchName = branch.name;
987
+ }
988
+ } catch (err) {
989
+ log.error("reword-error", {
990
+ branch: branch.name,
991
+ error: err instanceof Error ? err.message : String(err)
992
+ });
993
+ }
994
+ }
995
+ }
996
+ if (!latestBranchName) {
997
+ const existing = status.stacks.flatMap((s) => s.branches ?? []).filter((b) => b.commits.length > 0 && !defaultBranchPattern.test(b.name));
998
+ if (existing.length > 0) {
999
+ latestBranchName = existing[existing.length - 1].name;
1000
+ }
1001
+ }
1002
+ if (latestBranchName) {
1003
+ client.session.update({
1004
+ path: { id: rootSessionID },
1005
+ body: { title: latestBranchName }
1006
+ }).catch(() => {});
1007
+ addNotification(sessionID, `Session title updated to \`${latestBranchName}\``);
1008
+ }
1009
+ for (const stack of status.stacks) {
1010
+ for (const branch of stack.branches ?? []) {
1011
+ if (branch.commits.length === 0 && (stack.assignedChanges?.length ?? 0) === 0 && defaultBranchPattern.test(branch.name)) {
1012
+ const ok = await cli.butUnapplyWithRetry(branch.cliId, branch.name);
1013
+ if (ok) {
1014
+ addNotification(sessionID, `Empty branch \`${branch.name}\` cleaned up`);
1015
+ cleanupCount++;
1016
+ }
1017
+ }
1018
+ }
1019
+ }
1020
+ log.info("post-stop-summary", {
1021
+ sessionID,
1022
+ rootSessionID,
1023
+ reworded: rewordCount,
1024
+ renamed: renameCount,
1025
+ cleanedUp: cleanupCount,
1026
+ failed: failCount,
1027
+ stopFailed
1028
+ });
1029
+ }
1030
+ return {
1031
+ fetchUserPrompt,
1032
+ generateLLMCommitMessage,
1033
+ postStopProcessing
1034
+ };
1035
+ }
1036
+
1037
+ // src/plugin.ts
1038
+ function createGitButlerPlugin(config = { ...DEFAULT_CONFIG }) {
1039
+ return async ({ client, directory, worktree }) => {
1040
+ const cwd = worktree ?? directory;
1041
+ const log = createLogger(config.log_enabled, cwd);
1042
+ const cli = createCli(cwd, log);
1043
+ const state = createStateManager(cwd, log);
1044
+ const diskSessionMap = await state.loadSessionMap();
1045
+ for (const [k, v] of diskSessionMap) {
1046
+ state.parentSessionByTaskSession.set(k, v);
1047
+ }
1048
+ const branchOwnership = new Map;
1049
+ const persistedState = await state.loadPluginState();
1050
+ const conversationsWithEdits = new Set(persistedState.conversationsWithEdits);
1051
+ const rewordedBranches = new Set(persistedState.rewordedBranches);
1052
+ for (const [convId, ownership] of Object.entries(persistedState.branchOwnership ?? {})) {
1053
+ branchOwnership.set(convId, ownership);
1054
+ }
1055
+ log.info("state-loaded", {
1056
+ conversations: conversationsWithEdits.size,
1057
+ reworded: rewordedBranches.size,
1058
+ logEnabled: config.log_enabled,
1059
+ autoUpdate: config.auto_update,
1060
+ commitModel: config.commit_message_model
1061
+ });
1062
+ log.info("plugin-init", {
1063
+ workspaceMode: cli.isWorkspaceMode(),
1064
+ sessionMapSize: state.parentSessionByTaskSession.size
1065
+ });
1066
+ const internalSessionIds = new Set;
1067
+ const activeStopProcessing = new Set;
1068
+ let mainSessionID;
1069
+ const notify = createNotificationManager(log, state.resolveSessionRoot, config.notification_max_age_ms);
1070
+ let DEFAULT_BRANCH_PATTERN;
1071
+ try {
1072
+ DEFAULT_BRANCH_PATTERN = new RegExp(config.default_branch_pattern);
1073
+ } catch {
1074
+ DEFAULT_BRANCH_PATTERN = new RegExp(DEFAULT_CONFIG.default_branch_pattern);
1075
+ }
1076
+ const fileLocks = new Map;
1077
+ const LOCK_TIMEOUT_MS = 60000;
1078
+ const LOCK_POLL_MS = 1000;
1079
+ const STALE_LOCK_MS = 5 * 60000;
1080
+ function reapStaleLocks() {
1081
+ const now = Date.now();
1082
+ const staleCutoff = config.stale_lock_ms ?? STALE_LOCK_MS;
1083
+ for (const [filePath, lock] of fileLocks.entries()) {
1084
+ if (now - lock.timestamp > staleCutoff) {
1085
+ fileLocks.delete(filePath);
1086
+ log.info("lock-reaped", {
1087
+ file: filePath,
1088
+ owner: lock.sessionID,
1089
+ ageMs: now - lock.timestamp,
1090
+ operation: lock.operation
1091
+ });
1092
+ }
1093
+ }
1094
+ }
1095
+ const editedFilesPerConversation = new Map;
1096
+ const reword = createRewordManager({
1097
+ cwd,
1098
+ log,
1099
+ cli,
1100
+ config,
1101
+ defaultBranchPattern: DEFAULT_BRANCH_PATTERN,
1102
+ addNotification: notify.addNotification,
1103
+ resolveSessionRoot: state.resolveSessionRoot,
1104
+ conversationsWithEdits,
1105
+ rewordedBranches,
1106
+ branchOwnership,
1107
+ editedFilesPerConversation,
1108
+ savePluginState: state.savePluginState,
1109
+ internalSessionIds,
1110
+ reapStaleLocks,
1111
+ client
1112
+ });
1113
+ const assignmentCache = new Map;
1114
+ const ASSIGNMENT_CACHE_TTL_MS = 30000;
1115
+ let cachedStatus = null;
1116
+ const STATUS_CACHE_TTL_MS = 1e4;
1117
+ function getCachedStatus() {
1118
+ if (cachedStatus && Date.now() - cachedStatus.timestamp < STATUS_CACHE_TTL_MS) {
1119
+ return cachedStatus.data;
1120
+ }
1121
+ const fresh = cli.getFullStatus();
1122
+ cachedStatus = { data: fresh, timestamp: Date.now() };
1123
+ return fresh;
1124
+ }
1125
+ async function toUUID(input) {
1126
+ const data = new TextEncoder().encode(input);
1127
+ const hash = await crypto.subtle.digest("SHA-256", data);
1128
+ const hex = [...new Uint8Array(hash)].map((b) => b.toString(16).padStart(2, "0")).join("");
1129
+ return [
1130
+ hex.slice(0, 8),
1131
+ hex.slice(8, 12),
1132
+ `4${hex.slice(12, 15)}`,
1133
+ `8${hex.slice(15, 18)}`,
1134
+ hex.slice(18, 30)
1135
+ ].join("-");
1136
+ }
1137
+ function extractFilePathFromArgs(args) {
1138
+ const raw = args.filePath ?? args.file_path ?? args.path;
1139
+ return raw ? cli.toRelativePath(raw) : undefined;
1140
+ }
1141
+ return {
1142
+ "tool.execute.before": async (input, output) => {
1143
+ if (internalSessionIds.has(input.sessionID ?? ""))
1144
+ return;
1145
+ if (input.tool !== "edit" && input.tool !== "write")
1146
+ return;
1147
+ if (!output.args)
1148
+ return;
1149
+ const filePath = extractFilePathFromArgs(output.args);
1150
+ if (!filePath)
1151
+ return;
1152
+ const sessionID = input.sessionID ?? "unknown";
1153
+ const existing = fileLocks.get(filePath);
1154
+ if (existing) {
1155
+ const isStale = Date.now() - existing.timestamp > STALE_LOCK_MS;
1156
+ if (isStale) {
1157
+ log.warn("lock-stale", {
1158
+ file: filePath,
1159
+ owner: existing.sessionID,
1160
+ ageMs: Date.now() - existing.timestamp,
1161
+ ownerOperation: existing.operation
1162
+ });
1163
+ } else if (existing.sessionID !== sessionID) {
1164
+ log.info("lock-contention", {
1165
+ file: filePath,
1166
+ owner: existing.sessionID,
1167
+ ownerOperation: existing.operation,
1168
+ ownerAgeMs: Date.now() - existing.timestamp,
1169
+ waiter: sessionID,
1170
+ waiterOperation: input.tool
1171
+ });
1172
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
1173
+ while (Date.now() < deadline) {
1174
+ await Bun.sleep(LOCK_POLL_MS);
1175
+ const current = fileLocks.get(filePath);
1176
+ if (!current || current.sessionID === sessionID || Date.now() - current.timestamp > STALE_LOCK_MS)
1177
+ break;
1178
+ }
1179
+ const stillLocked = fileLocks.get(filePath);
1180
+ if (stillLocked && stillLocked.sessionID !== sessionID && Date.now() - stillLocked.timestamp <= STALE_LOCK_MS) {
1181
+ log.error("lock-timeout", {
1182
+ file: filePath,
1183
+ owner: stillLocked.sessionID,
1184
+ ownerOperation: stillLocked.operation,
1185
+ ownerAgeMs: Date.now() - stillLocked.timestamp,
1186
+ waiter: sessionID,
1187
+ waiterOperation: input.tool
1188
+ });
1189
+ }
1190
+ }
1191
+ }
1192
+ const previousLock = fileLocks.get(filePath);
1193
+ fileLocks.set(filePath, {
1194
+ sessionID,
1195
+ timestamp: Date.now(),
1196
+ operation: input.tool ?? "unknown"
1197
+ });
1198
+ log.info("lock-acquired", {
1199
+ file: filePath,
1200
+ session: sessionID,
1201
+ operation: input.tool,
1202
+ ...previousLock ? { previousAgeMs: Date.now() - previousLock.timestamp } : {}
1203
+ });
1204
+ },
1205
+ "tool.execute.after": async (input, output) => {
1206
+ if (internalSessionIds.has(input.sessionID ?? ""))
1207
+ return;
1208
+ await state.trackSubagentMapping(input, output);
1209
+ if (input.tool !== "edit" && input.tool !== "write")
1210
+ return;
1211
+ if (!cli.isWorkspaceMode())
1212
+ return;
1213
+ const filePath = cli.extractFilePath(output);
1214
+ if (!filePath) {
1215
+ const sessionID = input.sessionID ?? "unknown";
1216
+ let releasedCount = 0;
1217
+ for (const [
1218
+ lockedPath,
1219
+ lock
1220
+ ] of fileLocks.entries()) {
1221
+ if (lock.sessionID === sessionID) {
1222
+ fileLocks.delete(lockedPath);
1223
+ releasedCount++;
1224
+ }
1225
+ }
1226
+ log.info("after-edit-no-filepath", {
1227
+ sessionID,
1228
+ tool: input.tool,
1229
+ locksReleased: releasedCount
1230
+ });
1231
+ return;
1232
+ }
1233
+ const relativePath = cli.toRelativePath(filePath);
1234
+ try {
1235
+ const cached = assignmentCache.get(relativePath);
1236
+ const cacheHit = cached && Date.now() - cached.timestamp < ASSIGNMENT_CACHE_TTL_MS;
1237
+ if (!cacheHit) {
1238
+ const statusSnapshot = cli.getFullStatus();
1239
+ const branchInfo = cli.findFileBranch(relativePath, statusSnapshot);
1240
+ if (branchInfo.inBranch) {
1241
+ if (branchInfo.unassignedCliId && branchInfo.branchCliId) {
1242
+ if (cli.hasMultiBranchHunks(relativePath, statusSnapshot)) {
1243
+ log.warn("rub-skip-multi-branch", {
1244
+ file: relativePath
1245
+ });
1246
+ } else {
1247
+ log.info("rub-check", {
1248
+ file: relativePath,
1249
+ multiBranch: false,
1250
+ source: branchInfo.unassignedCliId,
1251
+ dest: branchInfo.branchCliId
1252
+ });
1253
+ const rubOk = cli.butRub(branchInfo.unassignedCliId, branchInfo.branchCliId);
1254
+ if (rubOk) {
1255
+ log.info("rub-ok", {
1256
+ source: branchInfo.unassignedCliId,
1257
+ dest: branchInfo.branchCliId,
1258
+ file: relativePath
1259
+ });
1260
+ } else {
1261
+ log.error("rub-failed", {
1262
+ source: branchInfo.unassignedCliId,
1263
+ dest: branchInfo.branchCliId,
1264
+ file: relativePath
1265
+ });
1266
+ }
1267
+ }
1268
+ } else {
1269
+ log.info("after-edit-already-assigned", {
1270
+ file: relativePath,
1271
+ sessionID: input.sessionID,
1272
+ branch: branchInfo.branchName
1273
+ });
1274
+ }
1275
+ return;
1276
+ }
1277
+ } else {
1278
+ log.info("assignment-cache-hit", {
1279
+ file: relativePath,
1280
+ branchCliId: cached.branchCliId,
1281
+ ageMs: Date.now() - cached.timestamp
1282
+ });
1283
+ }
1284
+ const branchSeed = config.branch_target ?? state.resolveSessionRoot(input.sessionID);
1285
+ const conversationId = cacheHit ? cached.conversationId : await toUUID(branchSeed);
1286
+ log.info("after-edit", {
1287
+ file: relativePath,
1288
+ sessionID: input.sessionID,
1289
+ conversationId
1290
+ });
1291
+ try {
1292
+ await cli.butCursor("after-edit", {
1293
+ conversation_id: conversationId,
1294
+ generation_id: crypto.randomUUID(),
1295
+ file_path: relativePath,
1296
+ edits: cli.extractEdits(output),
1297
+ hook_event_name: "afterFileEdit",
1298
+ workspace_roots: [cwd]
1299
+ });
1300
+ assignmentCache.set(relativePath, {
1301
+ branchCliId: conversationId,
1302
+ conversationId,
1303
+ timestamp: Date.now()
1304
+ });
1305
+ } catch (err) {
1306
+ log.error("cursor-after-edit-error", {
1307
+ file: relativePath,
1308
+ error: err instanceof Error ? err.message : String(err)
1309
+ });
1310
+ }
1311
+ conversationsWithEdits.add(conversationId);
1312
+ if (!editedFilesPerConversation.has(conversationId)) {
1313
+ editedFilesPerConversation.set(conversationId, new Set);
1314
+ }
1315
+ editedFilesPerConversation.get(conversationId).add(relativePath);
1316
+ const rootSessionID = state.resolveSessionRoot(input.sessionID);
1317
+ const existingOwner = branchOwnership.get(conversationId);
1318
+ if (existingOwner && existingOwner.rootSessionID !== rootSessionID) {
1319
+ log.error("branch-collision", {
1320
+ conversationId,
1321
+ existingOwner: existingOwner.rootSessionID,
1322
+ newOwner: rootSessionID,
1323
+ existingBranch: existingOwner.branchName
1324
+ });
1325
+ } else if (!existingOwner) {
1326
+ branchOwnership.set(conversationId, {
1327
+ rootSessionID,
1328
+ branchName: `conversation-${conversationId.slice(0, 8)}`,
1329
+ firstSeen: Date.now()
1330
+ });
1331
+ }
1332
+ state.savePluginState(conversationsWithEdits, rewordedBranches, branchOwnership).catch(() => {});
1333
+ } finally {
1334
+ const releasedLock = fileLocks.get(relativePath);
1335
+ fileLocks.delete(relativePath);
1336
+ log.info("lock-released", {
1337
+ file: relativePath,
1338
+ session: input.sessionID,
1339
+ ...releasedLock ? { heldMs: Date.now() - releasedLock.timestamp } : {}
1340
+ });
1341
+ }
1342
+ },
1343
+ event: async ({ event }) => {
1344
+ if (!event?.type)
1345
+ return;
1346
+ const eventProps = event.properties;
1347
+ if (internalSessionIds.has(eventProps?.sessionID ?? ""))
1348
+ return;
1349
+ await state.trackSessionCreatedMapping(event);
1350
+ if (event.type === "session.created") {
1351
+ const crProps = event.properties;
1352
+ const sessId = typeof crProps?.id === "string" ? crProps.id : undefined;
1353
+ const parentId = typeof crProps?.parentSessionID === "string" ? crProps.parentSessionID : typeof crProps?.parent_session_id === "string" ? crProps.parent_session_id : undefined;
1354
+ if (sessId && !parentId) {
1355
+ mainSessionID = sessId;
1356
+ }
1357
+ }
1358
+ const props = event.properties;
1359
+ const isIdle = event.type === "session.idle" || event.type === "session.status" && props?.status?.type === "idle";
1360
+ if (!isIdle)
1361
+ return;
1362
+ if (!cli.isWorkspaceMode())
1363
+ return;
1364
+ const branchSeed = config.branch_target ?? state.resolveSessionRoot(props?.sessionID);
1365
+ const conversationId = await toUUID(branchSeed);
1366
+ if (!conversationsWithEdits.has(conversationId))
1367
+ return;
1368
+ if (activeStopProcessing.has(conversationId))
1369
+ return;
1370
+ activeStopProcessing.add(conversationId);
1371
+ try {
1372
+ log.info("session-stop", {
1373
+ sessionID: props?.sessionID,
1374
+ conversationId
1375
+ });
1376
+ let stopFailed = false;
1377
+ try {
1378
+ await cli.butCursor("stop", {
1379
+ conversation_id: conversationId,
1380
+ generation_id: crypto.randomUUID(),
1381
+ status: "completed",
1382
+ hook_event_name: "stop",
1383
+ workspace_roots: [cwd]
1384
+ });
1385
+ } catch (err) {
1386
+ stopFailed = true;
1387
+ log.error("cursor-stop-error", {
1388
+ conversationId,
1389
+ error: err instanceof Error ? err.message : String(err)
1390
+ });
1391
+ }
1392
+ await reword.postStopProcessing(props?.sessionID, conversationId, stopFailed);
1393
+ assignmentCache.clear();
1394
+ cachedStatus = null;
1395
+ } finally {
1396
+ activeStopProcessing.delete(conversationId);
1397
+ }
1398
+ },
1399
+ "experimental.chat.messages.transform": async (_input, output) => {
1400
+ const { messages } = output;
1401
+ if (messages.length === 0)
1402
+ return;
1403
+ let lastUserMsgIdx = -1;
1404
+ for (let i = messages.length - 1;i >= 0; i--) {
1405
+ if (messages[i].info.role === "user") {
1406
+ lastUserMsgIdx = i;
1407
+ break;
1408
+ }
1409
+ }
1410
+ if (lastUserMsgIdx === -1)
1411
+ return;
1412
+ const lastUserMessage = messages[lastUserMsgIdx];
1413
+ const messageSessionID = lastUserMessage.info.sessionID;
1414
+ const sessionID = messageSessionID ?? mainSessionID;
1415
+ if (!sessionID)
1416
+ return;
1417
+ const notification = notify.consumeNotifications(sessionID);
1418
+ if (!notification)
1419
+ return;
1420
+ const textPartIndex = lastUserMessage.parts.findIndex((p) => p.type === "text" && p.text);
1421
+ if (textPartIndex === -1)
1422
+ return;
1423
+ const syntheticPart = {
1424
+ id: `gitbutler_ctx_${Date.now()}`,
1425
+ messageID: lastUserMessage.info.id,
1426
+ sessionID,
1427
+ type: "text",
1428
+ text: notification,
1429
+ synthetic: true
1430
+ };
1431
+ lastUserMessage.parts.splice(textPartIndex, 0, syntheticPart);
1432
+ log.info("context-injected", {
1433
+ sessionID,
1434
+ contentLength: notification.length
1435
+ });
1436
+ },
1437
+ "experimental.session.compacting": async (input, output) => {
1438
+ try {
1439
+ const rootSessionID = state.resolveSessionRoot(input.sessionID);
1440
+ const conversationId = await toUUID(rootSessionID);
1441
+ const contextParts = [];
1442
+ const status = getCachedStatus();
1443
+ if (status?.stacks) {
1444
+ const stacks = status.stacks;
1445
+ const activeBranches = stacks.flatMap((s) => s.branches ?? []).filter((b) => b.commits.length > 0 || (stacks.find((s) => (s.branches ?? []).includes(b))?.assignedChanges?.length ?? 0) > 0);
1446
+ if (activeBranches.length > 0) {
1447
+ const branchList = activeBranches.map((b) => `- \`${b.name}\` (${b.commits.length} commits)`).join(`
1448
+ `);
1449
+ contextParts.push(`Active GitButler branches:
1450
+ ${branchList}`);
1451
+ }
1452
+ }
1453
+ if (rewordedBranches.size > 0) {
1454
+ contextParts.push(`Reworded branches (commit messages updated): ${rewordedBranches.size} branches`);
1455
+ }
1456
+ if (conversationsWithEdits.has(conversationId)) {
1457
+ contextParts.push(`This session has active edits tracked in GitButler (conversation: ${conversationId.slice(0, 8)})`);
1458
+ }
1459
+ const ownership = branchOwnership.get(conversationId);
1460
+ if (ownership) {
1461
+ contextParts.push(`Session branch ownership: root=${ownership.rootSessionID.slice(0, 8)}, branch=${ownership.branchName}`);
1462
+ }
1463
+ if (contextParts.length > 0) {
1464
+ output.context.push(`<gitbutler-state>
1465
+ ` + contextParts.join(`
1466
+
1467
+ `) + `
1468
+ </gitbutler-state>`);
1469
+ log.info("compacting-context-injected", {
1470
+ sessionID: input.sessionID,
1471
+ contextItems: contextParts.length
1472
+ });
1473
+ }
1474
+ } catch {}
1475
+ },
1476
+ "experimental.chat.system.transform": async (_input, output) => {
1477
+ if (!cli.isWorkspaceMode())
1478
+ return;
1479
+ try {
1480
+ const status = getCachedStatus();
1481
+ if (!status?.stacks)
1482
+ return;
1483
+ const activeBranches = status.stacks.flatMap((s) => s.branches ?? []).filter((b) => b.commits.length > 0);
1484
+ const unassignedCount = status.unassignedChanges?.length ?? 0;
1485
+ if (activeBranches.length === 0 && unassignedCount === 0)
1486
+ return;
1487
+ const branchNames = activeBranches.map((b) => b.name).join(", ");
1488
+ output.system.push(`[GitButler] Workspace mode active. ` + `${activeBranches.length} branch(es): ${branchNames}. ` + `${unassignedCount} unassigned change(s).`);
1489
+ } catch {}
1490
+ }
1491
+ };
1492
+ };
1493
+ }
1494
+
1495
+ // src/auto-update.ts
1496
+ var NPM_DIST_TAGS_URL = "https://registry.npmjs.org/-/package/opencode-gitbutler/dist-tags";
1497
+ var FETCH_TIMEOUT_MS = 5000;
1498
+ function parseVersion(version) {
1499
+ const match = version.match(/^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/);
1500
+ if (!match)
1501
+ return null;
1502
+ return {
1503
+ major: Number(match[1]),
1504
+ minor: Number(match[2]),
1505
+ patch: Number(match[3]),
1506
+ prerelease: match[4] ?? ""
1507
+ };
1508
+ }
1509
+ function compareVersions(a, b) {
1510
+ const pa = parseVersion(a);
1511
+ const pb = parseVersion(b);
1512
+ if (!pa || !pb)
1513
+ return 0;
1514
+ for (const field of ["major", "minor", "patch"]) {
1515
+ if (pa[field] > pb[field])
1516
+ return 1;
1517
+ if (pa[field] < pb[field])
1518
+ return -1;
1519
+ }
1520
+ if (!pa.prerelease && pb.prerelease)
1521
+ return 1;
1522
+ if (pa.prerelease && !pb.prerelease)
1523
+ return -1;
1524
+ if (pa.prerelease < pb.prerelease)
1525
+ return -1;
1526
+ if (pa.prerelease > pb.prerelease)
1527
+ return 1;
1528
+ return 0;
1529
+ }
1530
+ async function checkForUpdate(currentVersion) {
1531
+ const controller = new AbortController;
1532
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
1533
+ try {
1534
+ const response = await fetch(NPM_DIST_TAGS_URL, {
1535
+ signal: controller.signal,
1536
+ headers: { Accept: "application/json" }
1537
+ });
1538
+ if (!response.ok)
1539
+ return null;
1540
+ const data = await response.json();
1541
+ const latest = data.latest;
1542
+ if (!latest || typeof latest !== "string")
1543
+ return null;
1544
+ return {
1545
+ current: currentVersion,
1546
+ latest,
1547
+ updateAvailable: compareVersions(latest, currentVersion) > 0
1548
+ };
1549
+ } catch {
1550
+ return null;
1551
+ } finally {
1552
+ clearTimeout(timer);
1553
+ }
1554
+ }
1555
+ function formatUpdateMessage(info) {
1556
+ return `opencode-gitbutler update available: ${info.current} \u2192 ${info.latest}. ` + `Run \`bun add opencode-gitbutler@latest\` to update.`;
1557
+ }
1558
+ function createAutoUpdateHook(config) {
1559
+ if (config.auto_update === false) {
1560
+ return { onSessionCreated: async () => null };
1561
+ }
1562
+ let checked = false;
1563
+ let pendingMessage = null;
1564
+ let checkPromise = null;
1565
+ checkPromise = checkForUpdate(config.currentVersion).then((info) => {
1566
+ if (info?.updateAvailable) {
1567
+ pendingMessage = formatUpdateMessage(info);
1568
+ }
1569
+ }).catch(() => {}).finally(() => {
1570
+ checkPromise = null;
1571
+ });
1572
+ return {
1573
+ onSessionCreated: async () => {
1574
+ if (checked)
1575
+ return null;
1576
+ checked = true;
1577
+ if (checkPromise) {
1578
+ await checkPromise;
1579
+ }
1580
+ const msg = pendingMessage;
1581
+ pendingMessage = null;
1582
+ return msg;
1583
+ }
1584
+ };
1585
+ }
1586
+
1587
+ // src/index.ts
1588
+ var DUPLICATE_GUARD_KEY = "__opencode_gitbutler_loaded__";
1589
+ var pkg = await Bun.file(new URL("../package.json", import.meta.url)).json();
1590
+ var PACKAGE_VERSION = pkg.version;
1591
+ var COMMAND_FILES = ["b-branch", "b-branch-commit", "b-branch-pr", "b-branch-gc"];
1592
+ function parseFrontmatter(content) {
1593
+ const lines = content.split(/\r?\n/);
1594
+ if (lines[0]?.trim() !== "---") {
1595
+ return { fields: {}, template: content };
1596
+ }
1597
+ let frontmatterEnd = -1;
1598
+ for (let i = 1;i < lines.length; i += 1) {
1599
+ if (lines[i]?.trim() === "---") {
1600
+ frontmatterEnd = i;
1601
+ break;
1602
+ }
1603
+ }
1604
+ if (frontmatterEnd === -1) {
1605
+ return { fields: {}, template: content };
1606
+ }
1607
+ const fields = {};
1608
+ for (const line of lines.slice(1, frontmatterEnd)) {
1609
+ const trimmed = line.trim();
1610
+ if (!trimmed || trimmed.startsWith("#"))
1611
+ continue;
1612
+ const separatorIndex = trimmed.indexOf(":");
1613
+ if (separatorIndex === -1)
1614
+ continue;
1615
+ const key = trimmed.slice(0, separatorIndex).trim();
1616
+ const rawValue = trimmed.slice(separatorIndex + 1).trim();
1617
+ if (!key)
1618
+ continue;
1619
+ if (!key)
1620
+ continue;
1621
+ const isQuoted = rawValue.startsWith('"') && rawValue.endsWith('"') || rawValue.startsWith("'") && rawValue.endsWith("'");
1622
+ let parsedValue;
1623
+ if (isQuoted) {
1624
+ parsedValue = rawValue.slice(1, -1);
1625
+ } else if (rawValue === "true" || rawValue === "false") {
1626
+ parsedValue = rawValue === "true";
1627
+ } else if (/^-?\d+(?:\.\d+)?$/.test(rawValue)) {
1628
+ parsedValue = Number(rawValue);
1629
+ } else {
1630
+ parsedValue = rawValue;
1631
+ }
1632
+ fields[key] = parsedValue;
1633
+ }
1634
+ return {
1635
+ fields,
1636
+ template: lines.slice(frontmatterEnd + 1).join(`
1637
+ `)
1638
+ };
1639
+ }
1640
+ async function loadCommands() {
1641
+ const commands = {};
1642
+ for (const commandName of COMMAND_FILES) {
1643
+ try {
1644
+ const commandPath = new URL(`../command/${commandName}.md`, import.meta.url);
1645
+ const file = Bun.file(commandPath);
1646
+ if (!await file.exists()) {
1647
+ continue;
1648
+ }
1649
+ const source = await file.text();
1650
+ const { fields, template } = parseFrontmatter(source);
1651
+ const command = {
1652
+ template
1653
+ };
1654
+ if (typeof fields.description === "string") {
1655
+ command.description = fields.description;
1656
+ }
1657
+ if (typeof fields.agent === "string") {
1658
+ command.agent = fields.agent;
1659
+ }
1660
+ if (typeof fields.model === "string") {
1661
+ command.model = fields.model;
1662
+ }
1663
+ if (typeof fields.subtask === "boolean") {
1664
+ command.subtask = fields.subtask;
1665
+ }
1666
+ commands[commandName] = command;
1667
+ } catch {
1668
+ continue;
1669
+ }
1670
+ }
1671
+ return commands;
1672
+ }
1673
+ var GitButlerPlugin = async (input) => {
1674
+ const g = globalThis;
1675
+ if (g[DUPLICATE_GUARD_KEY]) {
1676
+ console.warn("[opencode-gitbutler] Plugin already loaded \u2014 skipping duplicate registration.");
1677
+ return {};
1678
+ }
1679
+ g[DUPLICATE_GUARD_KEY] = true;
1680
+ const cwd = input.worktree ?? input.directory;
1681
+ const config = await loadConfig(cwd);
1682
+ const hooks = await createGitButlerPlugin(config)(input);
1683
+ const autoUpdate = createAutoUpdateHook({
1684
+ currentVersion: PACKAGE_VERSION,
1685
+ auto_update: config.auto_update
1686
+ });
1687
+ const skillDir = new URL("../skill", import.meta.url).pathname;
1688
+ const commandDefinitions = await loadCommands();
1689
+ const originalEvent = hooks.event;
1690
+ hooks.event = async (payload) => {
1691
+ if (originalEvent) {
1692
+ await originalEvent(payload);
1693
+ }
1694
+ if (payload.event?.type === "session.created") {
1695
+ const props = payload.event.properties;
1696
+ const hasParent = typeof props?.parentSessionID === "string" || typeof props?.parent_session_id === "string";
1697
+ if (!hasParent) {
1698
+ const msg = await autoUpdate.onSessionCreated();
1699
+ if (msg) {
1700
+ console.warn(`[opencode-gitbutler] ${msg}`);
1701
+ }
1702
+ }
1703
+ }
1704
+ };
1705
+ const originalConfig = hooks.config;
1706
+ hooks.config = async (config2) => {
1707
+ if (originalConfig) {
1708
+ await originalConfig(config2);
1709
+ }
1710
+ const extendedConfig = config2;
1711
+ if (!extendedConfig.skills)
1712
+ extendedConfig.skills = {};
1713
+ if (!extendedConfig.skills.paths)
1714
+ extendedConfig.skills.paths = [];
1715
+ if (!extendedConfig.skills.paths.includes(skillDir)) {
1716
+ extendedConfig.skills.paths.push(skillDir);
1717
+ }
1718
+ if (!extendedConfig.command) {
1719
+ extendedConfig.command = {};
1720
+ }
1721
+ for (const [name, definition] of Object.entries(commandDefinitions)) {
1722
+ extendedConfig.command[name] = definition;
1723
+ }
1724
+ };
1725
+ return hooks;
1726
+ };
1727
+ var src_default = GitButlerPlugin;
1728
+ export {
1729
+ src_default as default,
1730
+ GitButlerPlugin
1731
+ };