opencode-gitbutler 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,8 +16,9 @@ This plugin bridges the gap by bringing GitButler's virtual branch power directl
16
16
  - Zero-config setup. Just add it to your plugins and go.
17
17
  - Works with GitButler virtual branches to avoid worktree overhead.
18
18
  - Impersonates Cursor for full GitButler CLI compatibility.
19
- - Unique multi-agent session mapping.
20
- - Hunk-level rub guard to skip multi-stack files.
19
+ - Session-first routing: every edit/write is assigned via `but cursor after-edit`.
20
+ - Unique multi-agent session mapping so subagents stay on the parent branch.
21
+ - Hunk-level rub guard in post-stop recovery to avoid unsafe auto-moves.
21
22
 
22
23
  ## Installation
23
24
 
@@ -46,11 +47,29 @@ See [GitButler installation docs](https://docs.gitbutler.com/installation) for o
46
47
  ### 3. Restart OpenCode
47
48
 
48
49
  The plugin automatically:
50
+ - Routes every `edit`/`write` through GitButler's `after-edit`
49
51
  - Creates and renames branches based on your prompts
50
- - Generates commit messages using Claude Haiku
51
- - Injects workspace state into agent context via `SKILL.md`
52
+ - Rewords commit messages using Claude Haiku (with deterministic fallback)
53
+ - Injects workspace state notifications into agent context
52
54
  - Checks for updates on session creation
53
55
 
56
+ ## How Branch Assignment Works
57
+
58
+ This plugin uses a session-first flow. In practice:
59
+
60
+ 1. On every `edit`/`write`, the plugin resolves your **root session** (parent session for subagents).
61
+ 2. It derives a deterministic `conversation_id` from that root session (or from `branch_target`, when configured).
62
+ 3. It always calls `but cursor after-edit` with that `conversation_id` and file path.
63
+ 4. On idle/stop, it calls `but cursor stop` for that same `conversation_id`.
64
+ 5. In post-stop processing, it may:
65
+ - sweep edited files and `but rub` unassigned changes when attribution is safe,
66
+ - reword commit message,
67
+ - rename default `ge-branch-*` names,
68
+ - sync OpenCode session title,
69
+ - clean empty default branches.
70
+
71
+ This avoids cross-session branch pollution and keeps subagent edits attached to the parent session branch.
72
+
54
73
  ## Configuration
55
74
 
56
75
  Create `.opencode/gitbutler.json` in your workspace root to override defaults:
@@ -77,7 +96,23 @@ Create `.opencode/gitbutler.json` in your workspace root to override defaults:
77
96
  "auto_update": true,
78
97
 
79
98
  // Regex pattern for default branch detection
80
- "default_branch_pattern": "^ge-branch-\\d+$"
99
+ "default_branch_pattern": "^ge-branch-\\d+$",
100
+
101
+ // Milliseconds before file lock is considered stale
102
+ "stale_lock_ms": 300000,
103
+
104
+ // Max age of pending notifications before expiry
105
+ "notification_max_age_ms": 300000,
106
+
107
+ // Enable branch inference heuristics in post-stop sweep
108
+ "inference_enabled": true,
109
+
110
+ // Optional: force all sessions onto one branch seed
111
+ "branch_target": "",
112
+
113
+ // Reserved (currently no-op)
114
+ "edit_debounce_ms": 200,
115
+ "gc_on_session_start": false
81
116
  }
82
117
  ```
83
118
 
@@ -93,6 +128,12 @@ Create `.opencode/gitbutler.json` in your workspace root to override defaults:
93
128
  | `branch_slug_max_length` | number | `50` | Max auto-generated branch name length |
94
129
  | `auto_update` | boolean | `true` | Check npm for newer versions |
95
130
  | `default_branch_pattern` | string | `"^ge-branch-\\d+$"` | Regex for default branch detection |
131
+ | `stale_lock_ms` | number | `300000` | Lock age threshold before stale cleanup |
132
+ | `notification_max_age_ms` | number | `300000` | Expiry window for queued state notifications |
133
+ | `inference_enabled` | boolean | `true` | Enable branch inference in post-stop sweep |
134
+ | `branch_target` | string | unset | Force all sessions to one branch seed (disables per-session isolation) |
135
+ | `edit_debounce_ms` | number | `200` | Reserved, currently no-op |
136
+ | `gc_on_session_start` | boolean | `false` | Reserved, currently no-op |
96
137
 
97
138
  All fields are optional. Missing fields use defaults.
98
139
 
@@ -105,7 +146,7 @@ How this plugin compares to GitButler's built-in Cursor and Claude Code integrat
105
146
  | Post-edit hook | `after-edit` | PostToolUse | `tool.execute.after` | Equal |
106
147
  | Stop/idle hook | `stop` | Stop | `session.idle` | Equal |
107
148
  | Branch creation | `get_or_create_session` | `get_or_create_session` | via `conversation_id` | Equal |
108
- | Auto-assign to existing branch | | | `but rub` via `findFileBranch()` | **Better** |
149
+ | Auto-assign to existing branch | Internal | Internal | Session-first `after-edit` + safe post-stop `but rub` sweep | **Better** |
109
150
  | Branch auto-rename (LLM) | From Cursor DB | From transcript | `but reword` + user prompt | Equal |
110
151
  | Auto-commit on stop | `handle_changes()` | `handle_changes()` | via `but cursor stop` | Equal |
111
152
  | Commit message (LLM) | OpenAI gpt-4-mini | OpenAI gpt-4-mini | Claude Haiku via OpenCode SDK | Equal |
@@ -118,6 +159,13 @@ How this plugin compares to GitButler's built-in Cursor and Claude Code integrat
118
159
 
119
160
  For the full architecture breakdown, gap analysis, and known issues, see [`docs/gitbutler-integration.md`](docs/gitbutler-integration.md).
120
161
 
162
+ ## Known Operational Limits
163
+
164
+ - The plugin only performs GitButler actions in workspace mode (`gitbutler/workspace` branch).
165
+ - If `branch_target` is set, all sessions intentionally share one branch seed.
166
+ - `edit_debounce_ms` and `gc_on_session_start` are reserved config fields and are currently no-op.
167
+ - GitButler CLI still has upstream edge cases around unapply/pull after squash-merge with deleted remote branches (see linked issues in `docs/gitbutler-integration.md`).
168
+
121
169
  ## Troubleshooting
122
170
 
123
171
  ### GitButler CLI not found
@@ -139,6 +187,15 @@ If `.opencode/gitbutler.json` is missing, the plugin uses all defaults. No error
139
187
 
140
188
  Enable `log_enabled: true` in config to write detailed logs to `.opencode/plugin/debug.log`. Useful for diagnosing branch creation, commit message generation, and state injection issues.
141
189
 
190
+ ### Wrong branch assignment
191
+
192
+ If changes still appear on an unexpected branch:
193
+
194
+ 1. Ensure `branch_target` is not set in `.opencode/gitbutler.json`.
195
+ 2. Check `.opencode/plugin/session-map.json` and confirm subagent sessions resolve to the expected parent.
196
+ 3. Inspect `.opencode/plugin/debug.log` for `after-edit`, `session-stop`, and `branch-collision` events.
197
+ 4. Run `but status --json -f` and verify where the file is currently assigned.
198
+
142
199
  ### LLM timeout
143
200
 
144
201
  If commit message generation times out, increase `llm_timeout_ms` in config:
package/dist/index.js CHANGED
@@ -101,9 +101,89 @@ async function loadConfig(cwd) {
101
101
  }
102
102
 
103
103
  // src/logger.ts
104
- import { appendFile } from "fs/promises";
104
+ import {
105
+ appendFile,
106
+ mkdir,
107
+ rename,
108
+ rm,
109
+ stat
110
+ } from "fs/promises";
111
+ import { dirname } from "path";
105
112
  var LOG_PATH_SUFFIX = ".opencode/plugin/debug.log";
106
- function createLogger(logEnabled, cwd) {
113
+ var DEFAULT_MAX_FILE_BYTES = 5 * 1024 * 1024;
114
+ function createLogger(logEnabled, cwd, options = {}) {
115
+ const maxFileBytes = options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES;
116
+ const logPath = `${cwd}/${LOG_PATH_SUFFIX}`;
117
+ const logDir = dirname(logPath);
118
+ const rotatedPath = `${logPath}.1`;
119
+ const warned = new Set;
120
+ let writeQueue = Promise.resolve();
121
+ function toErrorMessage(err) {
122
+ if (err instanceof Error)
123
+ return err.message;
124
+ return String(err);
125
+ }
126
+ function warnOnce(key, message, err) {
127
+ if (warned.has(key))
128
+ return;
129
+ warned.add(key);
130
+ const suffix = err ? `: ${toErrorMessage(err)}` : "";
131
+ console.warn(`[opencode-gitbutler] ${message}${suffix}`);
132
+ }
133
+ function getErrCode(err) {
134
+ if (typeof err === "object" && err !== null && "code" in err && typeof err.code === "string") {
135
+ return err.code;
136
+ }
137
+ return;
138
+ }
139
+ async function ensureLogDirectory() {
140
+ try {
141
+ await mkdir(logDir, { recursive: true });
142
+ return true;
143
+ } catch (err) {
144
+ warnOnce("mkdir", `Logger failed to create log directory at ${logDir}`, err);
145
+ return false;
146
+ }
147
+ }
148
+ async function rotateIfNeeded() {
149
+ if (maxFileBytes <= 0)
150
+ return;
151
+ let fileSize = 0;
152
+ try {
153
+ const fileInfo = await stat(logPath);
154
+ fileSize = fileInfo.size;
155
+ } catch (err) {
156
+ if (getErrCode(err) === "ENOENT")
157
+ return;
158
+ warnOnce("rotate-stat", `Logger failed to read log file size at ${logPath}`, err);
159
+ return;
160
+ }
161
+ if (fileSize < maxFileBytes)
162
+ return;
163
+ try {
164
+ await rm(rotatedPath, { force: true });
165
+ await rename(logPath, rotatedPath);
166
+ } catch (err) {
167
+ if (getErrCode(err) === "ENOENT")
168
+ return;
169
+ warnOnce("rotate", `Logger failed to rotate log file at ${logPath}`, err);
170
+ }
171
+ }
172
+ function enqueue(line) {
173
+ writeQueue = writeQueue.then(async () => {
174
+ const ready = await ensureLogDirectory();
175
+ if (!ready)
176
+ return;
177
+ await rotateIfNeeded();
178
+ try {
179
+ await appendFile(logPath, line);
180
+ } catch (err) {
181
+ warnOnce("write", `Logger failed to append to ${logPath}`, err);
182
+ }
183
+ }).catch((err) => {
184
+ warnOnce("pipeline", "Logger pipeline failure", err);
185
+ });
186
+ }
107
187
  function write(level, cat, data = {}) {
108
188
  if (!logEnabled)
109
189
  return;
@@ -113,13 +193,14 @@ function createLogger(logEnabled, cwd) {
113
193
  cat,
114
194
  ...data
115
195
  };
116
- appendFile(`${cwd}/${LOG_PATH_SUFFIX}`, JSON.stringify(entry) + `
117
- `).catch(() => {});
196
+ enqueue(JSON.stringify(entry) + `
197
+ `);
118
198
  }
119
199
  return {
120
200
  info: (cat, data) => write("info", cat, data ?? {}),
121
201
  warn: (cat, data) => write("warn", cat, data ?? {}),
122
- error: (cat, data) => write("error", cat, data ?? {})
202
+ error: (cat, data) => write("error", cat, data ?? {}),
203
+ flush: () => writeQueue
123
204
  };
124
205
  }
125
206
 
@@ -823,6 +904,9 @@ function classifyRewordFailure(stderr) {
823
904
  return "not-workspace";
824
905
  return "unknown";
825
906
  }
907
+ function rewordTrackingKey(branchCliId, commitId) {
908
+ return `${branchCliId}\x00${commitId}`;
909
+ }
826
910
  function createRewordManager(deps) {
827
911
  const {
828
912
  cwd,
@@ -1136,11 +1220,12 @@ function createRewordManager(deps) {
1136
1220
  continue;
1137
1221
  if (branch.commits.length === 0)
1138
1222
  continue;
1139
- if (rewordedBranches.has(branch.cliId))
1140
- continue;
1141
1223
  const commit = branch.commits[0];
1142
1224
  if (!commit)
1143
1225
  continue;
1226
+ const trackingKey = rewordTrackingKey(branch.cliId, commit.commitId);
1227
+ if (rewordedBranches.has(trackingKey))
1228
+ continue;
1144
1229
  try {
1145
1230
  const VALID_CONVENTIONAL = /^(feat|fix|refactor|test|docs|style|perf|chore|ci|build)(\(.+?\))?:\s/;
1146
1231
  const DEFAULT_PLACEHOLDERS = [
@@ -1156,7 +1241,7 @@ function createRewordManager(deps) {
1156
1241
  commit: commit.cliId,
1157
1242
  existingMessage: existingMsg
1158
1243
  });
1159
- rewordedBranches.add(branch.cliId);
1244
+ rewordedBranches.add(trackingKey);
1160
1245
  rewordCount++;
1161
1246
  } else {
1162
1247
  const llmMessage = await generateLLMCommitMessage(commit.commitId, prompt);
@@ -1181,7 +1266,7 @@ function createRewordManager(deps) {
1181
1266
  failCount++;
1182
1267
  continue;
1183
1268
  }
1184
- rewordedBranches.add(branch.cliId);
1269
+ rewordedBranches.add(trackingKey);
1185
1270
  savePluginState(conversationsWithEdits, rewordedBranches, branchOwnership).catch(() => {});
1186
1271
  addNotification(sessionID, `Commit on branch \`${branch.name}\` reworded to: "${commitMsg}"`);
1187
1272
  log.info("reword", {
@@ -1242,6 +1327,25 @@ function createRewordManager(deps) {
1242
1327
  }))
1243
1328
  });
1244
1329
  }
1330
+ if (candidateBranches.length === 1) {
1331
+ const branch = candidateBranches[0];
1332
+ const syncedBranchName = latestBranchName ?? branch.name;
1333
+ const current = branchOwnership.get(conversationId);
1334
+ const branchChanged = !current || current.rootSessionID !== rootSessionID || current.branchName !== syncedBranchName;
1335
+ if (branchChanged) {
1336
+ branchOwnership.set(conversationId, {
1337
+ rootSessionID,
1338
+ branchName: syncedBranchName,
1339
+ firstSeen: current?.firstSeen ?? Date.now()
1340
+ });
1341
+ savePluginState(conversationsWithEdits, rewordedBranches, branchOwnership).catch(() => {});
1342
+ log.info("branch-ownership-updated", {
1343
+ conversationId,
1344
+ rootSessionID,
1345
+ branchName: syncedBranchName
1346
+ });
1347
+ }
1348
+ }
1245
1349
  if (shouldSetTitle) {
1246
1350
  const singleBranch = candidateBranches[0];
1247
1351
  const titleToSet = latestBranchName ?? singleBranch.name;
@@ -1282,6 +1386,21 @@ function createRewordManager(deps) {
1282
1386
  }
1283
1387
 
1284
1388
  // src/plugin.ts
1389
+ async function toUUID(input) {
1390
+ const data = new TextEncoder().encode(input);
1391
+ const hash = await crypto.subtle.digest("SHA-256", data);
1392
+ const hex = [...new Uint8Array(hash)].map((b) => b.toString(16).padStart(2, "0")).join("");
1393
+ return [
1394
+ hex.slice(0, 8),
1395
+ hex.slice(8, 12),
1396
+ `4${hex.slice(12, 15)}`,
1397
+ `8${hex.slice(15, 18)}`,
1398
+ hex.slice(18, 30)
1399
+ ].join("-");
1400
+ }
1401
+ function sessionCacheKey(rootSessionID, filePath) {
1402
+ return `${rootSessionID}\x00${filePath}`;
1403
+ }
1285
1404
  function createGitButlerPlugin(config = { ...DEFAULT_CONFIG }) {
1286
1405
  return async ({ client, directory, worktree }) => {
1287
1406
  const cwd = worktree ?? directory;
@@ -1298,9 +1417,25 @@ function createGitButlerPlugin(config = { ...DEFAULT_CONFIG }) {
1298
1417
  const persistedState = await state.loadPluginState();
1299
1418
  const conversationsWithEdits = new Set(persistedState.conversationsWithEdits);
1300
1419
  const rewordedBranches = new Set(persistedState.rewordedBranches);
1420
+ const MAX_TRACKED_REWORD_KEYS = 2000;
1421
+ while (rewordedBranches.size > MAX_TRACKED_REWORD_KEYS) {
1422
+ const oldest = rewordedBranches.values().next().value;
1423
+ if (!oldest)
1424
+ break;
1425
+ rewordedBranches.delete(oldest);
1426
+ }
1301
1427
  for (const [convId, ownership] of Object.entries(persistedState.branchOwnership ?? {})) {
1302
1428
  branchOwnership.set(convId, ownership);
1303
1429
  }
1430
+ async function persistPluginState() {
1431
+ while (rewordedBranches.size > MAX_TRACKED_REWORD_KEYS) {
1432
+ const oldest = rewordedBranches.values().next().value;
1433
+ if (!oldest)
1434
+ break;
1435
+ rewordedBranches.delete(oldest);
1436
+ }
1437
+ await state.savePluginState(conversationsWithEdits, rewordedBranches, branchOwnership);
1438
+ }
1304
1439
  log.info("state-loaded", {
1305
1440
  conversations: conversationsWithEdits.size,
1306
1441
  reworded: rewordedBranches.size,
@@ -1354,7 +1489,9 @@ function createGitButlerPlugin(config = { ...DEFAULT_CONFIG }) {
1354
1489
  rewordedBranches,
1355
1490
  branchOwnership,
1356
1491
  editedFilesPerConversation,
1357
- savePluginState: state.savePluginState,
1492
+ savePluginState: async () => {
1493
+ await persistPluginState();
1494
+ },
1358
1495
  internalSessionIds,
1359
1496
  reapStaleLocks,
1360
1497
  client
@@ -1371,18 +1508,6 @@ function createGitButlerPlugin(config = { ...DEFAULT_CONFIG }) {
1371
1508
  cachedStatus = { data: fresh, timestamp: Date.now() };
1372
1509
  return fresh;
1373
1510
  }
1374
- async function toUUID(input) {
1375
- const data = new TextEncoder().encode(input);
1376
- const hash = await crypto.subtle.digest("SHA-256", data);
1377
- const hex = [...new Uint8Array(hash)].map((b) => b.toString(16).padStart(2, "0")).join("");
1378
- return [
1379
- hex.slice(0, 8),
1380
- hex.slice(8, 12),
1381
- `4${hex.slice(12, 15)}`,
1382
- `8${hex.slice(15, 18)}`,
1383
- hex.slice(18, 30)
1384
- ].join("-");
1385
- }
1386
1511
  function extractFilePathFromArgs(args) {
1387
1512
  const raw = args.filePath ?? args.file_path ?? args.path;
1388
1513
  return raw ? cli.toRelativePath(raw) : undefined;
@@ -1482,80 +1607,22 @@ function createGitButlerPlugin(config = { ...DEFAULT_CONFIG }) {
1482
1607
  const relativePath = cli.toRelativePath(filePath);
1483
1608
  try {
1484
1609
  const rootSessionID = state.resolveSessionRoot(input.sessionID);
1485
- const cached = assignmentCache.get(relativePath);
1486
- const cacheHit = cached && Date.now() - cached.timestamp < ASSIGNMENT_CACHE_TTL_MS && cached.rootSessionID === rootSessionID;
1487
- if (!cacheHit) {
1488
- const statusSnapshot = cli.getFullStatus();
1489
- if (!statusSnapshot) {
1490
- log.warn("after-edit-skip-no-status", {
1491
- file: relativePath,
1492
- sessionID: input.sessionID
1493
- });
1494
- return;
1495
- }
1496
- const branchInfo = cli.findFileBranch(relativePath, statusSnapshot);
1497
- if (branchInfo.inBranch) {
1498
- if (branchInfo.unassignedCliId && branchInfo.branchCliId) {
1499
- if (cli.hasMultiBranchHunks(relativePath, statusSnapshot)) {
1500
- log.warn("rub-skip-multi-branch", {
1501
- file: relativePath,
1502
- confidence: branchInfo.confidence
1503
- });
1504
- } else {
1505
- log.info("rub-check", {
1506
- file: relativePath,
1507
- multiBranch: false,
1508
- source: branchInfo.unassignedCliId,
1509
- dest: branchInfo.branchCliId,
1510
- confidence: branchInfo.confidence
1511
- });
1512
- const rubOk = cli.butRub(branchInfo.unassignedCliId, branchInfo.branchCliId);
1513
- if (rubOk) {
1514
- log.info("rub-ok", {
1515
- source: branchInfo.unassignedCliId,
1516
- dest: branchInfo.branchCliId,
1517
- file: relativePath,
1518
- confidence: branchInfo.confidence
1519
- });
1520
- } else {
1521
- log.error("rub-failed", {
1522
- source: branchInfo.unassignedCliId,
1523
- dest: branchInfo.branchCliId,
1524
- file: relativePath,
1525
- confidence: branchInfo.confidence
1526
- });
1527
- }
1528
- }
1529
- } else if (!branchInfo.branchCliId) {
1530
- log.warn("after-edit-ambiguous-assignment", {
1531
- file: relativePath,
1532
- sessionID: input.sessionID,
1533
- confidence: branchInfo.confidence
1534
- });
1535
- } else {
1536
- log.info("after-edit-already-assigned", {
1537
- file: relativePath,
1538
- sessionID: input.sessionID,
1539
- branch: branchInfo.branchName ?? branchInfo.branchCliId,
1540
- branchCliId: branchInfo.branchCliId,
1541
- confidence: branchInfo.confidence ?? "high"
1542
- });
1543
- }
1544
- return;
1545
- }
1546
- } else {
1610
+ const branchSeed = config.branch_target ?? rootSessionID;
1611
+ const cacheKey = sessionCacheKey(rootSessionID, relativePath);
1612
+ const cached = assignmentCache.get(cacheKey);
1613
+ const cacheHit = cached && Date.now() - cached.timestamp < ASSIGNMENT_CACHE_TTL_MS;
1614
+ const conversationId = cacheHit ? cached.conversationId : await toUUID(branchSeed);
1615
+ if (cacheHit) {
1547
1616
  log.info("assignment-cache-hit", {
1548
1617
  file: relativePath,
1549
- conversationId: cached.conversationId,
1550
- rootSessionID: cached.rootSessionID,
1618
+ conversationId,
1551
1619
  ageMs: Date.now() - cached.timestamp
1552
1620
  });
1553
1621
  }
1554
- const branchSeed = config.branch_target ?? rootSessionID;
1555
- const conversationId = cacheHit ? cached.conversationId : await toUUID(branchSeed);
1556
1622
  log.info("after-edit", {
1557
1623
  file: relativePath,
1558
1624
  sessionID: input.sessionID,
1625
+ rootSessionID,
1559
1626
  conversationId
1560
1627
  });
1561
1628
  try {
@@ -1567,7 +1634,7 @@ function createGitButlerPlugin(config = { ...DEFAULT_CONFIG }) {
1567
1634
  hook_event_name: "afterFileEdit",
1568
1635
  workspace_roots: [cwd]
1569
1636
  });
1570
- assignmentCache.set(relativePath, {
1637
+ assignmentCache.set(cacheKey, {
1571
1638
  conversationId,
1572
1639
  rootSessionID,
1573
1640
  timestamp: Date.now()
@@ -1575,6 +1642,7 @@ function createGitButlerPlugin(config = { ...DEFAULT_CONFIG }) {
1575
1642
  } catch (err) {
1576
1643
  log.error("cursor-after-edit-error", {
1577
1644
  file: relativePath,
1645
+ conversationId,
1578
1646
  error: err instanceof Error ? err.message : String(err)
1579
1647
  });
1580
1648
  }
@@ -1598,7 +1666,7 @@ function createGitButlerPlugin(config = { ...DEFAULT_CONFIG }) {
1598
1666
  firstSeen: Date.now()
1599
1667
  });
1600
1668
  }
1601
- state.savePluginState(conversationsWithEdits, rewordedBranches, branchOwnership).catch(() => {});
1669
+ persistPluginState().catch(() => {});
1602
1670
  } finally {
1603
1671
  const releasedLock = fileLocks.get(relativePath);
1604
1672
  fileLocks.delete(relativePath);
@@ -1658,7 +1726,21 @@ function createGitButlerPlugin(config = { ...DEFAULT_CONFIG }) {
1658
1726
  error: err instanceof Error ? err.message : String(err)
1659
1727
  });
1660
1728
  }
1661
- await reword.postStopProcessing(props?.sessionID, conversationId, stopFailed);
1729
+ let postStopSucceeded = false;
1730
+ try {
1731
+ await reword.postStopProcessing(props?.sessionID, conversationId, stopFailed);
1732
+ postStopSucceeded = true;
1733
+ } catch (err) {
1734
+ log.error("post-stop-error", {
1735
+ conversationId,
1736
+ error: err instanceof Error ? err.message : String(err)
1737
+ });
1738
+ }
1739
+ if (!stopFailed && postStopSucceeded) {
1740
+ conversationsWithEdits.delete(conversationId);
1741
+ editedFilesPerConversation.delete(conversationId);
1742
+ persistPluginState().catch(() => {});
1743
+ }
1662
1744
  assignmentCache.clear();
1663
1745
  cachedStatus = null;
1664
1746
  } finally {
@@ -1720,7 +1802,7 @@ ${branchList}`);
1720
1802
  }
1721
1803
  }
1722
1804
  if (rewordedBranches.size > 0) {
1723
- contextParts.push(`Reworded branches (commit messages updated): ${rewordedBranches.size} branches`);
1805
+ contextParts.push(`Tracked reworded commits: ${rewordedBranches.size}`);
1724
1806
  }
1725
1807
  if (conversationsWithEdits.has(conversationId)) {
1726
1808
  contextParts.push(`This session has active edits tracked in GitButler (conversation: ${conversationId.slice(0, 8)})`);
package/dist/logger.d.ts CHANGED
@@ -1,8 +1,12 @@
1
1
  export type LogLevel = "info" | "warn" | "error";
2
+ export type LoggerOptions = {
3
+ maxFileBytes?: number;
4
+ };
2
5
  export type Logger = {
3
6
  info: (cat: string, data?: Record<string, unknown>) => void;
4
7
  warn: (cat: string, data?: Record<string, unknown>) => void;
5
8
  error: (cat: string, data?: Record<string, unknown>) => void;
9
+ flush?: () => Promise<void>;
6
10
  };
7
11
  /**
8
12
  * Structured NDJSON logger with explicit levels.
@@ -17,5 +21,5 @@ export type Logger = {
17
21
  * jq 'select(.cat == "cursor-ok")'
18
22
  * grep '"cat":"llm-' debug.log | jq .
19
23
  */
20
- export declare function createLogger(logEnabled: boolean, cwd: string): Logger;
24
+ export declare function createLogger(logEnabled: boolean, cwd: string, options?: LoggerOptions): Logger;
21
25
  //# sourceMappingURL=logger.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"logger.d.ts","sourceRoot":"","sources":["../src/logger.ts"],"names":[],"mappings":"AAIA,MAAM,MAAM,QAAQ,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC;AAEjD,MAAM,MAAM,MAAM,GAAG;IACnB,IAAI,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;IAC5D,IAAI,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;IAC5D,KAAK,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;CAC9D,CAAC;AAEF;;;;;;;;;;;;GAYG;AACH,wBAAgB,YAAY,CAAC,UAAU,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAwBrE"}
1
+ {"version":3,"file":"logger.d.ts","sourceRoot":"","sources":["../src/logger.ts"],"names":[],"mappings":"AAYA,MAAM,MAAM,QAAQ,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC;AAEjD,MAAM,MAAM,aAAa,GAAG;IAC1B,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,MAAM,GAAG;IACnB,IAAI,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;IAC5D,IAAI,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;IAC5D,KAAK,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;IAC7D,KAAK,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC7B,CAAC;AAEF;;;;;;;;;;;;GAYG;AACH,wBAAgB,YAAY,CAC1B,UAAU,EAAE,OAAO,EACnB,GAAG,EAAE,MAAM,EACX,OAAO,GAAE,aAAkB,GAC1B,MAAM,CA0HR"}
package/dist/plugin.d.ts CHANGED
@@ -17,5 +17,7 @@
17
17
  */
18
18
  import type { Plugin } from "@opencode-ai/plugin";
19
19
  import type { GitButlerPluginConfig } from "./config.js";
20
+ export declare function toUUID(input: string): Promise<string>;
21
+ export declare function sessionCacheKey(rootSessionID: string, filePath: string): string;
20
22
  export declare function createGitButlerPlugin(config?: GitButlerPluginConfig): Plugin;
21
23
  //# sourceMappingURL=plugin.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAClD,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,aAAa,CAAC;AAUzD,wBAAgB,qBAAqB,CACnC,MAAM,GAAE,qBAA6C,GACpD,MAAM,CA2pBR"}
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAClD,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,aAAa,CAAC;AAUzD,wBAAsB,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAa3D;AAED,wBAAgB,eAAe,CAAC,aAAa,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAE/E;AAED,wBAAgB,qBAAqB,CACnC,MAAM,GAAE,qBAA6C,GACpD,MAAM,CAumBR"}
package/dist/reword.d.ts CHANGED
@@ -100,5 +100,6 @@ export type RewordManager = {
100
100
  postStopProcessing: (sessionID: string | undefined, conversationId: string, stopFailed?: boolean) => Promise<void>;
101
101
  };
102
102
  export declare function classifyRewordFailure(stderr: string): string;
103
+ export declare function rewordTrackingKey(branchCliId: string, commitId: string): string;
103
104
  export declare function createRewordManager(deps: RewordDeps): RewordManager;
104
105
  //# sourceMappingURL=reword.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"reword.d.ts","sourceRoot":"","sources":["../src/reword.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAC1C,OAAO,KAAK,EAAE,GAAG,EAAiB,MAAM,UAAU,CAAC;AACnD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAClD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AACvD,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,aAAa,CAAC;AAEzD,MAAM,MAAM,UAAU,GAAG;IACvB,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,GAAG,CAAC;IACT,MAAM,EAAE,qBAAqB,CAAC;IAC9B,oBAAoB,EAAE,MAAM,CAAC;IAC7B,eAAe,EAAE,mBAAmB,CAAC,iBAAiB,CAAC,CAAC;IACxD,kBAAkB,EAAE,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,KAAK,MAAM,CAAC;IAC9D,sBAAsB,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACpC,gBAAgB,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC9B,eAAe,EAAE,GAAG,CAAC,MAAM,EAAE,eAAe,CAAC,CAAC;IAC9C,0BAA0B,EAAE,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC;IACrD,eAAe,EAAE,CACf,aAAa,EAAE,GAAG,CAAC,MAAM,CAAC,EAC1B,QAAQ,EAAE,GAAG,CAAC,MAAM,CAAC,EACrB,SAAS,EAAE,GAAG,CAAC,MAAM,EAAE,eAAe,CAAC,KACpC,OAAO,CAAC,IAAI,CAAC,CAAC;IACnB,kBAAkB,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAChC,cAAc,EAAE,MAAM,IAAI,CAAC;IAC3B,MAAM,EAAE;QACN,OAAO,EAAE;YACP,QAAQ,EAAE,CAAC,IAAI,EAAE;gBACf,IAAI,EAAE;oBAAE,EAAE,EAAE,MAAM,CAAA;iBAAE,CAAC;gBACrB,KAAK,EAAE;oBAAE,KAAK,EAAE,MAAM,CAAA;iBAAE,CAAC;aAC1B,KAAK,OAAO,CAAC;gBACZ,IAAI,CAAC,EAAE,KAAK,CAAC;oBACX,IAAI,EAAE;wBAAE,IAAI,EAAE,MAAM,CAAA;qBAAE,CAAC;oBACvB,KAAK,EAAE,KAAK,CAAC;wBAAE,IAAI,EAAE,MAAM,CAAC;wBAAC,IAAI,CAAC,EAAE,MAAM,CAAA;qBAAE,CAAC,CAAC;iBAC/C,CAAC,CAAC;aACJ,CAAC,CAAC;YACH,MAAM,EAAE,CAAC,IAAI,EAAE;gBACb,IAAI,EAAE;oBAAE,KAAK,EAAE,MAAM,CAAA;iBAAE,CAAC;aACzB,KAAK,OAAO,CAAC;gBAAE,IAAI,CAAC,EAAE;oBAAE,EAAE,EAAE,MAAM,CAAA;iBAAE,CAAA;aAAE,CAAC,CAAC;YACzC,MAAM,EAAE,CAAC,IAAI,EAAE;gBACb,IAAI,EAAE;oBAAE,EAAE,EAAE,MAAM,CAAA;iBAAE,CAAC;gBACrB,IAAI,EAAE;oBACJ,KAAK,EAAE;wBAAE,UAAU,EAAE,MAAM,CAAC;wBAAC,OAAO,EAAE,MAAM,CAAA;qBAAE,CAAC;oBAC/C,MAAM,EAAE,MAAM,CAAC;oBACf,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;oBAC7B,KAAK,EAAE,KAAK,CAAC;wBAAE,IAAI,EAAE,MAAM,CAAC;wBAAC,IAAI,EAAE,MAAM,CAAA;qBAAE,CAAC,CAAC;iBAC9C,CAAC;aACH,KAAK,OAAO,CAAC;gBACZ,IAAI,CAAC,EAAE;oBACL,KAAK,EAAE,KAAK,CAAC;wBAAE,IAAI,EAAE,MAAM,CAAC;wBAAC,IAAI,CAAC,EAAE,MAAM,CAAA;qBAAE,CAAC,CAAC;iBAC/C,CAAC;aACH,CAAC,CAAC;YACH,MAAM,EAAE,CAAC,IAAI,EAAE;gBACb,IAAI,EAAE;oBAAE,EAAE,EAAE,MAAM,CAAA;iBAAE,CAAC;aACtB,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;YACvB,MAAM,EAAE,CAAC,IAAI,EAAE;gBACb,IAAI,EAAE;oBAAE,EAAE,EAAE,MAAM,CAAA;iBAAE,CAAC;gBACrB,IAAI,EAAE;oBAAE,KAAK,EAAE,MAAM,CAAA;iBAAE,CAAC;aACzB,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;SACxB,CAAC;KACH,CAAC;CACH,CAAC;AAEF,eAAO,MAAM,sBAAsB,EAAE,KAAK,CAAC;IACzC,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;CAChB,CA8BA,CAAC;AAEF,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAQvD;AAED,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAiBtD;AAED,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAStE;AAED,MAAM,MAAM,aAAa,GAAG;IAC1B,eAAe,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC/D,wBAAwB,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC3F,kBAAkB,EAAE,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,EAAE,cAAc,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,OAAO,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACpH,CAAC;AAEF,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAU5D;AAED,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,UAAU,GAAG,aAAa,CA0kBnE"}
1
+ {"version":3,"file":"reword.d.ts","sourceRoot":"","sources":["../src/reword.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-gitbutler",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "type": "module",
5
5
  "description": "OpenCode plugin for GitButler integration",
6
6
  "main": "dist/index.js",