synabun 2026.4.20-beta.9 → 2026.4.21

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/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  Raw internal changelogs are archived in [changelog/](changelog/).
4
4
 
5
+ ## 2026-04-21
6
+
7
+ ### Added — Workspace Trust Flow For Windows Git "Dubious Ownership" Repos
8
+ - **New `POST /api/terminal/trust-workspace` endpoint** — In `neural-interface/server.js`, added `detectDubiousOwnership(dir)` helper that probes `git rev-parse --is-inside-work-tree` and matches stderr against `/dubious ownership|safe\.directory/i` (CVE-2022-24765 protection; fires on Windows when the repo is owned by a different SID — external drives, cross-user installs, copied repos). New `normalizeGitPath(dir)` converts backslashes to forward slashes and strips trailing slashes to match the format `git config --add safe.directory` expects. The endpoint validates `path` (non-empty string, no control chars, not `*`/`/`/`\\`, `existsSync` + `isDirectory`), runs `spawnGit(['config', '--global', '--add', 'safe.directory', normalizeGitPath(abs)])`, re-probes to confirm, logs `[git] trusted workspace: <path>`, and returns `{ ok, path, stillDubious }`.
9
+ - **`POST /api/claude-code/integrations` flags dubious workspaces on registration** — The `target === 'project'` branch now calls `detectDubiousOwnership(normalized)` after saving the project to the hook-projects list; when the error fires it adds `dubiousOwnership: true, trustPath: normalizeGitPath(normalized)` to the response JSON. Mac/Linux and healthy Windows installs see no change to response shape.
10
+ - **`promptTrustWorkspace(trustPath)` modal in the file-explorer add-project flow** — In `neural-interface/public/shared/ui-file-explorer.js`, `addProjectFlow()` now reads the integrations response and `await`s a new `promptTrustWorkspace()` modal when `dubiousOwnership` is true. The modal reuses the existing `.fe-modal-overlay` system with a `.fe-trust-modal` amber variant, shows the exact path in a `.fe-trust-path` monospace chip, and offers **Skip** (close, no git change) or **Trust workspace** (POSTs to `/api/terminal/trust-workspace`, toasts the result, closes). The promise resolves either way so the add flow never blocks.
11
+ - **Inline trust row in the onboarding wizard** — In `neural-interface/public/onboarding.html`, the project-add step on `$addBtn` now persists `dubiousOwnership` + `trustPath` fields onto each `wizardState.projects[i]` entry, and `renderProjectList()` appends an amber inline `⚠ Git ownership mismatch — [Trust workspace]` row under the card when flagged. Click POSTs to `/api/terminal/trust-workspace`; on success the row swaps to `✓ Workspace trusted`. Separate from the file-explorer modal because onboarding already has its own card-based flow and shouldn't launch overlay dialogs.
12
+ - **Amber modal styling** — In `neural-interface/public/shared/styles.css`, added `.fe-trust-modal` overrides of `.fe-modal-title` (amber `#ffb74d`) and `.fe-modal-confirm` (warm `rgba(255,183,77,…)`) so the trust dialog is visually distinct from the destructive-red delete modal. New `.fe-trust-path` class styles the monospace path chip with dark background and `word-break: break-all`.
13
+
14
+ ### Fixed — Git UI Silently Disabled On Windows Across Shim / Version / PATH Variants
15
+ - **`git` invocation rewritten to resolve absolute path, then fall back to shell only when needed** — In `neural-interface/server.js`, added `resolveGitPath()` that runs `where git` on Windows / `command -v git` on Unix via `execSync` at first use, caches the result, and prefers `.exe` entries over `.cmd`/`.bat` shims (Scoop, winget) so `spawnSync` can execute git directly without cmd.exe involvement. New `spawnGit(args, opts)` uses the resolved absolute path with no shell on all platforms; only falls back to `shell: true` with per-arg metacharacter quoting when path resolution fails OR the resolved target is a `.cmd`/`.bat` shim. Logs `[git] resolved: <path>` at first use so Windows users can confirm which binary Node is running.
16
+ - **Chained branch-name fallback for old git installs** — `getGitInfo()` and `GET /api/terminal/branches` used `git branch --show-current` which requires git 2.22+ — older Git for Windows builds silently exited non-zero and the branch label disappeared. Added chained fallback: `branch --show-current` → `rev-parse --abbrev-ref HEAD` → `rev-parse --short HEAD`. The `--abbrev-ref` form works on every git version and handles both named branches and detached HEAD (returns literal `"HEAD"`, which the parser treats as "no branch" and escalates to the short SHA).
17
+ - **Plain `git branch` parse replacing `--format=%(refname:short)`** — The branch-list endpoint's `--format=%(refname:short)` arg was prone to cmd.exe variable-expansion mangling on Windows even with double-quoting. Replaced with bare `git branch` and a `line.replace(/^[\s*]+/, '').trim()` parser that strips the current-branch marker and filters out `(HEAD detached …)` lines and rename arrows. Removes the cmd.exe `%(…)` hazard entirely.
18
+ - **All git calls now route through `spawnGit` with unified error logging** — The call sites (`getGitInfo`, `/api/terminal/branches`, `/api/terminal/checkout`, the project-add integration probe) had inconsistent handling — some used raw `execSync`, some `spawnSync` directly. Every git call now goes through `spawnGit(['…'])`, and every failure routes through a new `logGitFailure(where, err)` helper that suppresses the expected "not a git repository" case, deduplicates the "git not found on PATH" warning via `_gitMissingLogged`, and logs everything else with one-line stderr.
19
+ - **Startup `git --version` probe** — Added a `setImmediate(() => spawnGit(['--version']))` at module init that always logs `[git] git version X.Y.Z — OK` or `[git] startup probe FAILED: <reason>. File explorer git UI will be hidden.` — the first diagnostic Windows users should check, since every prior git failure was silently swallowed by the catch blocks.
20
+ - **`windowsHide: true` on every git spawn** — Prevents a momentary cmd.exe console window flash on Windows whenever the file explorer refreshes git status.
21
+
5
22
  ## 2026-04-20
6
23
 
7
24
  ### Added — Install Toggles For Claude / Gemini / Codex / OpenCode In Onboarding Step 5
@@ -2154,15 +2154,58 @@ document.getElementById('buildBtn').addEventListener('click', async () => {
2154
2154
  wizardState.projects.forEach((p, i) => {
2155
2155
  const card = document.createElement('div');
2156
2156
  card.className = 'onboard-project-card';
2157
- card.style.cssText = 'display:flex;align-items:center;gap:10px;padding:10px 12px;background:rgba(109,213,140,0.06);border:1px solid rgba(109,213,140,0.2);border-radius:8px;font-size:12px';
2157
+ card.style.cssText = 'display:flex;flex-direction:column;gap:8px;padding:10px 12px;background:rgba(109,213,140,0.06);border:1px solid rgba(109,213,140,0.2);border-radius:8px;font-size:12px';
2158
+ const safePath = p.path.replace(/\\/g, '/');
2158
2159
  card.innerHTML = `
2159
- <svg viewBox="0 0 24 24" style="width:18px;height:18px;fill:none;stroke:rgba(109,213,140,0.8);stroke-width:2;flex-shrink:0"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
2160
- <div style="flex:1;min-width:0">
2161
- <div style="color:var(--t-bright);font-weight:500">${p.label}</div>
2162
- <div style="color:var(--t-muted);font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${p.path.replace(/\\/g, '/')}">${p.path.replace(/\\/g, '/')}</div>
2160
+ <div style="display:flex;align-items:center;gap:10px">
2161
+ <svg viewBox="0 0 24 24" style="width:18px;height:18px;fill:none;stroke:rgba(109,213,140,0.8);stroke-width:2;flex-shrink:0"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
2162
+ <div style="flex:1;min-width:0">
2163
+ <div style="color:var(--t-bright);font-weight:500">${p.label}</div>
2164
+ <div style="color:var(--t-muted);font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${safePath}">${safePath}</div>
2165
+ </div>
2166
+ <button class="onboard-project-remove" style="background:none;border:none;color:var(--t-muted);cursor:pointer;font-size:16px;padding:2px 6px;line-height:1;opacity:0.5" title="Remove">&times;</button>
2163
2167
  </div>
2164
- <button class="onboard-project-remove" style="background:none;border:none;color:var(--t-muted);cursor:pointer;font-size:16px;padding:2px 6px;line-height:1;opacity:0.5" title="Remove">&times;</button>
2165
2168
  `;
2169
+ if (p.dubiousOwnership && !p.trusted) {
2170
+ const trustRow = document.createElement('div');
2171
+ trustRow.className = 'onboard-trust-row';
2172
+ trustRow.style.cssText = 'display:flex;align-items:center;gap:8px;padding:6px 8px;background:rgba(255,183,77,0.08);border:1px solid rgba(255,183,77,0.25);border-radius:6px;font-size:11px;color:rgba(255,204,128,0.9)';
2173
+ trustRow.innerHTML = `
2174
+ <span>&#9888; Git ownership mismatch &mdash; workspace needs to be trusted</span>
2175
+ <button class="onboard-trust-btn" style="margin-left:auto;background:rgba(255,183,77,0.2);border:1px solid rgba(255,183,77,0.35);color:#ffe0b2;padding:4px 10px;border-radius:5px;font-size:11px;cursor:pointer">Trust workspace</button>
2176
+ `;
2177
+ const $btn = trustRow.querySelector('.onboard-trust-btn');
2178
+ $btn.addEventListener('click', async () => {
2179
+ $btn.disabled = true;
2180
+ $btn.textContent = 'Trusting...';
2181
+ try {
2182
+ const r = await fetch('/api/terminal/trust-workspace', {
2183
+ method: 'POST',
2184
+ headers: { 'Content-Type': 'application/json' },
2185
+ body: JSON.stringify({ path: p.trustPath || safePath }),
2186
+ });
2187
+ const d = await r.json();
2188
+ if (d.ok) {
2189
+ p.trusted = true;
2190
+ renderProjectList();
2191
+ } else {
2192
+ $btn.disabled = false;
2193
+ $btn.textContent = 'Trust workspace';
2194
+ $error.textContent = d.error || 'Trust failed';
2195
+ }
2196
+ } catch {
2197
+ $btn.disabled = false;
2198
+ $btn.textContent = 'Trust workspace';
2199
+ $error.textContent = 'Trust failed';
2200
+ }
2201
+ });
2202
+ card.appendChild(trustRow);
2203
+ } else if (p.trusted) {
2204
+ const okRow = document.createElement('div');
2205
+ okRow.style.cssText = 'font-size:11px;color:rgba(109,213,140,0.8)';
2206
+ okRow.textContent = '✓ Workspace trusted';
2207
+ card.appendChild(okRow);
2208
+ }
2166
2209
  card.querySelector('.onboard-project-remove').addEventListener('click', () => {
2167
2210
  wizardState.projects.splice(i, 1);
2168
2211
  renderProjectList();
@@ -2212,15 +2255,16 @@ document.getElementById('buildBtn').addEventListener('click', async () => {
2212
2255
 
2213
2256
  // Register via API
2214
2257
  $addBtn.innerHTML = '<div class="spinner" style="width:14px;height:14px"></div> Registering...';
2258
+ let integData = null;
2215
2259
  try {
2216
2260
  const res = await fetch('/api/claude-code/integrations', {
2217
2261
  method: 'POST',
2218
2262
  headers: { 'Content-Type': 'application/json' },
2219
2263
  body: JSON.stringify({ target: 'project', projectPath: folderPath, label }),
2220
2264
  });
2221
- const data = await res.json();
2222
- if (!data.ok) {
2223
- $error.textContent = data.error || 'Failed to register project.';
2265
+ integData = await res.json();
2266
+ if (!integData.ok) {
2267
+ $error.textContent = integData.error || 'Failed to register project.';
2224
2268
  $addBtn.disabled = false;
2225
2269
  $addBtn.innerHTML = origHTML;
2226
2270
  return;
@@ -2232,7 +2276,12 @@ document.getElementById('buildBtn').addEventListener('click', async () => {
2232
2276
  return;
2233
2277
  }
2234
2278
 
2235
- wizardState.projects.push({ path: folderPath, label });
2279
+ const projectEntry = { path: folderPath, label };
2280
+ if (integData && integData.dubiousOwnership && integData.trustPath) {
2281
+ projectEntry.dubiousOwnership = true;
2282
+ projectEntry.trustPath = integData.trustPath;
2283
+ }
2284
+ wizardState.projects.push(projectEntry);
2236
2285
  $addBtn.disabled = false;
2237
2286
  $addBtn.innerHTML = origHTML;
2238
2287
  renderProjectList();
@@ -1620,21 +1620,25 @@ export function renderToolActivityDock(tab, panelRoot, callbacks = {}) {
1620
1620
  Agents
1621
1621
  </span>
1622
1622
  <span class="ocp-activity-header-count">${runningCount ? `${runningCount}/` : ''}${totalCount}</span>
1623
+ <button class="ocp-activity-header-hide" type="button" title="Collapse (Ctrl+Shift+A)" aria-label="Close agents drawer">×</button>
1623
1624
  </div>
1624
1625
  <div class="ocp-activity-list">
1625
1626
  ${entries.map(e => activityRowHtml(e, { expanded: !!expandedMap[e.key] })).join('')}
1626
1627
  </div>
1627
1628
  </div>
1628
1629
  <button class="${tagClass}" title="${escAttr(tagTitle)}" type="button">
1629
- <span class="ocp-activity-tag-dot"></span>
1630
- <span class="ocp-activity-tag-count">${tagCount}</span>
1631
- <span class="ocp-activity-tag-label">${runningCount > 0 ? 'Running' : 'Agents'}</span>
1630
+ ${runningCount > 0 ? '<span class="ocp-activity-tag-dot"></span>' : ''}
1631
+ <span class="ocp-activity-tag-count">${tagCount} ${runningCount > 0 ? 'RUN' : 'AGT'}</span>
1632
1632
  </button>
1633
1633
  `;
1634
1634
 
1635
1635
  dock.querySelector('.ocp-activity-tag')?.addEventListener('click', () => {
1636
1636
  if (typeof onToggle === 'function') onToggle(!isOpen);
1637
1637
  });
1638
+ dock.querySelector('.ocp-activity-header-hide')?.addEventListener('click', (ev) => {
1639
+ ev.stopPropagation();
1640
+ if (typeof onToggle === 'function') onToggle(false);
1641
+ });
1638
1642
 
1639
1643
  dock.querySelectorAll('.ocp-activity-row').forEach((row) => {
1640
1644
  const agentKey = row.dataset.agentKey;
@@ -2024,72 +2024,64 @@ export function injectStyles() {
2024
2024
  border-color: rgba(232,224,220,0.35);
2025
2025
  }
2026
2026
 
2027
- /* ── Tool activity dock (left edge, bottom-anchored) ── */
2027
+ /* ── Tool activity dock (sibling surface, matches Claude todo dock aesthetic) ── */
2028
2028
  .ocp-activity-dock {
2029
2029
  position: absolute;
2030
- bottom: 72px;
2030
+ bottom: 0;
2031
2031
  right: calc(100% + 6px);
2032
2032
  display: flex;
2033
+ flex-direction: row-reverse;
2033
2034
  align-items: flex-end;
2034
- z-index: 3;
2035
+ z-index: 20;
2035
2036
  pointer-events: none;
2037
+ max-height: 100%;
2036
2038
  }
2037
2039
  .ocp-activity-tag {
2038
2040
  pointer-events: auto;
2039
2041
  display: flex;
2040
2042
  flex-direction: column;
2041
2043
  align-items: center;
2042
- justify-content: center;
2043
- gap: 4px;
2044
- padding: 10px 8px;
2045
- min-height: 64px;
2046
- border: none;
2047
- border-radius: 10px 0 0 10px;
2048
- background: linear-gradient(180deg, rgba(26, 26, 32, 0.88), rgba(16, 16, 20, 0.92));
2049
- backdrop-filter: blur(14px);
2050
- -webkit-backdrop-filter: blur(14px);
2051
- box-shadow: inset 1px 0 0 rgba(255, 255, 255, 0.06), -2px 0 10px rgba(0, 0, 0, 0.18);
2044
+ gap: 5px;
2045
+ padding: 9px 6px;
2046
+ background: rgba(18, 18, 20, 0.92);
2047
+ backdrop-filter: blur(60px) saturate(1.5);
2048
+ -webkit-backdrop-filter: blur(60px) saturate(1.5);
2049
+ border: 0.5px solid rgba(255, 255, 255, 0.06);
2050
+ border-radius: 10px;
2052
2051
  color: rgba(232, 224, 220, 0.78);
2053
- font-size: 11px;
2054
- font-weight: 600;
2055
- font-variant-numeric: tabular-nums;
2056
2052
  cursor: pointer;
2057
- transition: background 0.2s ease, color 0.2s ease;
2053
+ font-family: 'JetBrains Mono', monospace;
2054
+ font-variant-numeric: tabular-nums;
2055
+ box-shadow:
2056
+ 0 0 0 0.5px rgba(0, 0, 0, 0.3),
2057
+ 0 1px 2px rgba(0, 0, 0, 0.15),
2058
+ 0 4px 8px rgba(0, 0, 0, 0.12),
2059
+ 0 12px 24px rgba(0, 0, 0, 0.14);
2060
+ transition: background 0.15s, border-color 0.15s, color 0.15s;
2058
2061
  }
2059
2062
  .ocp-activity-tag:hover {
2060
- background: linear-gradient(180deg, rgba(32, 32, 40, 0.92), rgba(22, 22, 28, 0.94));
2061
- color: rgba(232, 224, 220, 1);
2062
- }
2063
- .ocp-activity-tag.running {
2064
- color: #b5edc1;
2063
+ background: rgba(28, 26, 22, 0.94);
2064
+ border-color: rgba(255, 255, 255, 0.12);
2065
+ color: rgba(232, 224, 220, 0.98);
2065
2066
  }
2067
+ .ocp-activity-tag.running { color: #b5edc1; }
2066
2068
  .ocp-activity-tag-dot {
2067
- width: 7px;
2068
- height: 7px;
2069
+ width: 5px;
2070
+ height: 5px;
2069
2071
  border-radius: 50%;
2070
2072
  background: rgba(232, 224, 220, 0.45);
2071
2073
  }
2072
2074
  .ocp-activity-tag.running .ocp-activity-tag-dot {
2073
2075
  background: #6fd18a;
2074
- box-shadow: 0 0 8px rgba(111, 209, 138, 0.7);
2076
+ box-shadow: 0 0 6px rgba(111, 209, 138, 0.7);
2075
2077
  animation: ocp-activity-pulse 1.4s ease-in-out infinite;
2076
2078
  }
2077
2079
  .ocp-activity-tag-count {
2078
- font-size: 12px;
2079
- font-weight: 700;
2080
- }
2081
- .ocp-activity-tag-label {
2082
- font-size: 9px;
2083
- font-weight: 600;
2084
- letter-spacing: 0.06em;
2085
- text-transform: uppercase;
2080
+ font-size: 9.5px;
2086
2081
  writing-mode: vertical-rl;
2087
2082
  transform: rotate(180deg);
2088
- color: rgba(232, 224, 220, 0.5);
2089
- margin-top: 2px;
2090
- }
2091
- .ocp-activity-tag.running .ocp-activity-tag-label {
2092
- color: rgba(181, 237, 193, 0.8);
2083
+ letter-spacing: 0.04em;
2084
+ opacity: 0.85;
2093
2085
  }
2094
2086
 
2095
2087
  .ocp-activity-drawer {
@@ -2098,19 +2090,45 @@ export function injectStyles() {
2098
2090
  max-width: 0;
2099
2091
  overflow: hidden;
2100
2092
  opacity: 0;
2101
- background: linear-gradient(180deg, rgba(22, 22, 28, 0.92), rgba(14, 14, 18, 0.94));
2102
- backdrop-filter: blur(18px);
2103
- -webkit-backdrop-filter: blur(18px);
2104
- border-radius: 12px 0 0 12px;
2105
- box-shadow: inset 1px 0 0 rgba(255, 255, 255, 0.06), -6px 6px 22px rgba(0, 0, 0, 0.3);
2106
- margin-right: -1px;
2107
- transition: width 0.24s cubic-bezier(0.22, 1, 0.36, 1), max-width 0.24s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.18s ease;
2093
+ background: rgba(18, 18, 20, 0.92);
2094
+ backdrop-filter: blur(60px) saturate(1.5);
2095
+ -webkit-backdrop-filter: blur(60px) saturate(1.5);
2096
+ border: 0.5px solid rgba(255, 255, 255, 0.06);
2097
+ border-radius: 14px;
2098
+ box-shadow:
2099
+ 0 0 0 0.5px rgba(0, 0, 0, 0.3),
2100
+ 0 1px 2px rgba(0, 0, 0, 0.15),
2101
+ 0 4px 8px rgba(0, 0, 0, 0.12),
2102
+ 0 12px 24px rgba(0, 0, 0, 0.14),
2103
+ 0 32px 64px rgba(0, 0, 0, 0.18);
2104
+ display: flex;
2105
+ flex-direction: column;
2106
+ margin-right: 6px;
2107
+ max-height: 100%;
2108
+ transition: width 0.22s cubic-bezier(0.2, 0, 0.2, 1), max-width 0.22s cubic-bezier(0.2, 0, 0.2, 1), opacity 0.18s ease;
2108
2109
  }
2109
2110
  .ocp-activity-dock.open .ocp-activity-drawer {
2110
2111
  width: 320px;
2111
2112
  max-width: 320px;
2112
2113
  opacity: 1;
2113
2114
  }
2115
+ .ocp-activity-dock.open .ocp-activity-tag { display: none; }
2116
+ .ocp-activity-header-hide {
2117
+ margin-left: 6px;
2118
+ background: none;
2119
+ border: none;
2120
+ color: rgba(232, 224, 220, 0.55);
2121
+ cursor: pointer;
2122
+ font-size: 15px;
2123
+ line-height: 1;
2124
+ padding: 2px 5px;
2125
+ border-radius: 4px;
2126
+ transition: background 0.12s, color 0.12s;
2127
+ }
2128
+ .ocp-activity-header-hide:hover {
2129
+ color: rgba(232, 224, 220, 1);
2130
+ background: rgba(255, 255, 255, 0.06);
2131
+ }
2114
2132
  .ocp-activity-header {
2115
2133
  padding: 12px 14px 8px;
2116
2134
  font-size: 10px;
@@ -2119,14 +2137,15 @@ export function injectStyles() {
2119
2137
  text-transform: uppercase;
2120
2138
  color: rgba(232, 224, 220, 0.55);
2121
2139
  display: flex;
2122
- justify-content: space-between;
2123
2140
  align-items: center;
2141
+ gap: 8px;
2124
2142
  border-bottom: 1px solid rgba(255, 255, 255, 0.05);
2125
2143
  }
2126
2144
  .ocp-activity-header-title {
2127
2145
  display: inline-flex;
2128
2146
  align-items: center;
2129
2147
  gap: 7px;
2148
+ margin-right: auto;
2130
2149
  }
2131
2150
  .ocp-activity-header-dot {
2132
2151
  width: 6px;
@@ -761,6 +761,10 @@ export async function sendMessage(content, options = {}) {
761
761
  removeThinking(container);
762
762
  finalizeStreamingMessage(container);
763
763
  }
764
+ // Mark this assistant message as rendered so late terminal SSE
765
+ // message.updated events (which arrive after POST resolves on some
766
+ // Windows/model combos) don't re-stream the full payload into a new bubble.
767
+ if (info.id) _renderedAssistantMsgIds.add(info.id);
764
768
  tab.running = false;
765
769
  tab.turnStartedAt = 0;
766
770
  if (info.tokens || info.usage) {
@@ -1186,6 +1190,10 @@ export function handleSSEEvent(eventType, event) {
1186
1190
  removeThinking(container);
1187
1191
  finalizeStreamingMessage(container);
1188
1192
  _partTypes.clear();
1193
+ // Only mark the ID as rendered at the terminal signal (tokens/usage
1194
+ // arrived or status is explicitly terminal). Mid-turn message.updated
1195
+ // events may fire before streaming starts and shouldn't lock out later
1196
+ // part.delta / part.updated events.
1189
1197
  if (assistantMid && (explicitTerminal || info.tokens || info.usage)) {
1190
1198
  _renderedAssistantMsgIds.add(assistantMid);
1191
1199
  }
@@ -1215,6 +1223,8 @@ export function handleSSEEvent(eventType, event) {
1215
1223
  // Streaming text delta — the primary streaming event from OpenCode
1216
1224
  case 'message.part.delta': {
1217
1225
  if (_userMsgIds.has(event.messageID)) break; // skip user message echoes
1226
+ // Skip deltas for assistant messages already finalized (late echoes).
1227
+ if (event.messageID && _renderedAssistantMsgIds.has(event.messageID)) break;
1218
1228
  if (event.delta) {
1219
1229
  const partType = String(event.part?.type || event.partType || _partTypes.get(event.partID) || '').trim().toLowerCase();
1220
1230
  const isReasoning = partType === 'reasoning' || partType === 'thinking' || partType === 'thought';
@@ -1249,7 +1259,11 @@ export function handleSSEEvent(eventType, event) {
1249
1259
  update();
1250
1260
  } else if (updatedType === 'text') {
1251
1261
  const hasStreaming = container.querySelector('.ocp-msg-assistant.streaming');
1252
- if (!hasStreaming) {
1262
+ // Skip the fallback render if this assistant message has already been
1263
+ // finalized by POST response or a prior message.updated — otherwise we
1264
+ // create a duplicate bubble when late text part.updated events arrive.
1265
+ const partMsgId = part.messageID || part.messageId;
1266
+ if (!hasStreaming && !(partMsgId && _renderedAssistantMsgIds.has(partMsgId))) {
1253
1267
  renderAssistantPayload(container, part, { textMode: 'stream' });
1254
1268
  }
1255
1269
  setTurnStatus(tab, 'Writing response…', tabStatusDetail(tab));
@@ -13868,6 +13868,23 @@
13868
13868
  }
13869
13869
  .fe-modal-confirm:hover { background: rgba(229,115,115,0.35); color: #ffab91; }
13870
13870
 
13871
+ /* ── Trust-workspace modal (amber accent, different from destructive red) ── */
13872
+ .fe-trust-modal .fe-modal-title { color: #ffb74d; }
13873
+ .fe-trust-modal .fe-modal-confirm { background: rgba(255,183,77,0.18); color: #ffcc80; }
13874
+ .fe-trust-modal .fe-modal-confirm:hover { background: rgba(255,183,77,0.3); color: #ffe0b2; }
13875
+ .fe-trust-modal .fe-modal-confirm:disabled { opacity: 0.5; cursor: default; }
13876
+ .fe-trust-path {
13877
+ margin-top: 10px;
13878
+ padding: 8px 10px;
13879
+ background: rgba(0,0,0,0.3);
13880
+ border: 1px solid rgba(255,255,255,0.08);
13881
+ border-radius: 6px;
13882
+ font-family: var(--font-mono, ui-monospace, monospace);
13883
+ font-size: 11px;
13884
+ color: rgba(255,255,255,0.6);
13885
+ word-break: break-all;
13886
+ }
13887
+
13871
13888
  /* ── Color palette popup ── */
13872
13889
  .fe-color-palette {
13873
13890
  position: absolute;
@@ -648,7 +648,13 @@ async function addProjectFlow() {
648
648
 
649
649
  // Register as a project
650
650
  const label = data.path.split(/[\\/]/).pop() || data.path;
651
- await fetch('/api/claude-code/integrations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ target: 'project', projectPath: data.path, label }) });
651
+ const integRes = await fetch('/api/claude-code/integrations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ target: 'project', projectPath: data.path, label }) });
652
+ const integData = await integRes.json().catch(() => ({}));
653
+
654
+ // Windows git CVE-2022-24765 safe.directory prompt — only fires when server detected the specific error.
655
+ if (integData && integData.dubiousOwnership && integData.trustPath) {
656
+ await promptTrustWorkspace(integData.trustPath);
657
+ }
652
658
 
653
659
  // Refresh projects list and select the new one
654
660
  await fetchProjects();
@@ -665,6 +671,67 @@ async function addProjectFlow() {
665
671
  }
666
672
  }
667
673
 
674
+ function promptTrustWorkspace(trustPath) {
675
+ return new Promise((resolve) => {
676
+ const existing = document.getElementById('fe-trust-modal');
677
+ if (existing) existing.remove();
678
+
679
+ const overlay = document.createElement('div');
680
+ overlay.id = 'fe-trust-modal';
681
+ overlay.className = 'fe-modal-overlay';
682
+
683
+ const modal = document.createElement('div');
684
+ modal.className = 'fe-modal fe-trust-modal';
685
+ modal.innerHTML = `
686
+ <div class="fe-modal-title">Git ownership mismatch</div>
687
+ <div class="fe-modal-body">
688
+ Git is refusing to read this workspace because it's owned by a different user account. Trusting it adds the directory to your global <code>safe.directory</code> config so Synabun's git UI can show the branch and changes.
689
+ <div class="fe-trust-path" id="fe-trust-path"></div>
690
+ </div>
691
+ <div class="fe-modal-actions">
692
+ <button class="fe-modal-btn fe-modal-cancel" id="fe-trust-skip">Skip</button>
693
+ <button class="fe-modal-btn fe-modal-confirm" id="fe-trust-confirm">Trust workspace</button>
694
+ </div>
695
+ `;
696
+ overlay.appendChild(modal);
697
+ document.body.appendChild(overlay);
698
+ modal.querySelector('#fe-trust-path').textContent = trustPath;
699
+
700
+ requestAnimationFrame(() => overlay.classList.add('fe-modal-overlay--in'));
701
+
702
+ const done = () => {
703
+ overlay.classList.remove('fe-modal-overlay--in');
704
+ setTimeout(() => overlay.remove(), 150);
705
+ document.removeEventListener('keydown', onKey);
706
+ resolve();
707
+ };
708
+ const onKey = (ev) => { if (ev.key === 'Escape') done(); };
709
+ document.addEventListener('keydown', onKey);
710
+
711
+ modal.querySelector('#fe-trust-skip').addEventListener('click', done);
712
+ overlay.addEventListener('click', (ev) => { if (ev.target === overlay) done(); });
713
+
714
+ modal.querySelector('#fe-trust-confirm').addEventListener('click', async () => {
715
+ const btn = modal.querySelector('#fe-trust-confirm');
716
+ btn.disabled = true;
717
+ btn.textContent = 'Trusting...';
718
+ try {
719
+ const res = await fetch('/api/terminal/trust-workspace', {
720
+ method: 'POST',
721
+ headers: { 'Content-Type': 'application/json' },
722
+ body: JSON.stringify({ path: trustPath }),
723
+ });
724
+ const data = await res.json();
725
+ if (data.ok) showToast('Workspace trusted');
726
+ else showToast(data.error || 'Trust failed');
727
+ } catch {
728
+ showToast('Trust failed');
729
+ }
730
+ done();
731
+ });
732
+ });
733
+ }
734
+
668
735
  function initProjectSelector() {
669
736
  const btn = $('fe-project-btn');
670
737
  const wrap = $('fe-project-selector');
@@ -1704,21 +1771,21 @@ function focusEditorLocation(lineNum, columnNum = 1) {
1704
1771
  syncGutterScroll();
1705
1772
  }
1706
1773
 
1707
- async function openFileEditor(filePath, opts = {}) {
1708
- try {
1709
- const normalizedPath = String(filePath || '').replace(/\\/g, '/');
1710
- const res = await fetch(`/api/file-content?path=${encodeURIComponent(filePath)}`);
1711
- const data = await res.json();
1712
- if (!res.ok) {
1713
- const isOpencodeConfig = normalizedPath.endsWith('/.config/opencode/config.json');
1714
- const isAllowlistMiss = res.status === 403 && data?.error === 'Path outside registered project roots';
1715
- if (isOpencodeConfig && isAllowlistMiss) {
1716
- showToast('Restart Synabun to apply OpenCode config editor support');
1717
- return;
1718
- }
1719
- showToast(data.error || 'Cannot open file');
1720
- return;
1721
- }
1774
+ async function openFileEditor(filePath, opts = {}) {
1775
+ try {
1776
+ const normalizedPath = String(filePath || '').replace(/\\/g, '/');
1777
+ const res = await fetch(`/api/file-content?path=${encodeURIComponent(filePath)}`);
1778
+ const data = await res.json();
1779
+ if (!res.ok) {
1780
+ const isOpencodeConfig = normalizedPath.endsWith('/.config/opencode/config.json');
1781
+ const isAllowlistMiss = res.status === 403 && data?.error === 'Path outside registered project roots';
1782
+ if (isOpencodeConfig && isAllowlistMiss) {
1783
+ showToast('Restart Synabun to apply OpenCode config editor support');
1784
+ return;
1785
+ }
1786
+ showToast(data.error || 'Cannot open file');
1787
+ return;
1788
+ }
1722
1789
 
1723
1790
  _editorOpen = true;
1724
1791
  _editorFilePath = data.path;
@@ -2269,6 +2269,7 @@ function buildProjectsTab(ccIntegrations) {
2269
2269
  </div>
2270
2270
  <div class="cc-panel-actions">
2271
2271
  <button class="cc-explore-btn" data-cc-explore="${i}" style="background:var(--accent-blue-bg);border:1px solid var(--accent-blue-border);color:var(--accent-blue);padding:5px 12px;border-radius:4px;cursor:pointer;font-size:12px">Learn it</button>
2272
+ <button class="cc-trust-btn" data-cc-trust="${i}" title="Add this workspace to git safe.directory (fixes 'dubious ownership' errors)" style="background:rgba(255,183,77,0.14);border:1px solid rgba(255,183,77,0.3);color:#ffcc80;padding:5px 12px;border-radius:4px;cursor:pointer;font-size:12px">Trust workspace</button>
2272
2273
  <button class="cc-enable-btn${p.installed ? ' on' : ''}" data-cc-project-toggle="${i}">${p.installed ? 'Enabled' : 'Enable'}</button>
2273
2274
  <button class="cc-remove-panel-btn" data-cc-remove="${i}">Remove</button>
2274
2275
  </div>
@@ -6147,6 +6148,43 @@ export async function openSettingsModal() {
6147
6148
  });
6148
6149
  });
6149
6150
 
6151
+ // Trust workspace (adds to git safe.directory global config)
6152
+ overlay.querySelectorAll('.cc-trust-btn[data-cc-trust]').forEach(btn => {
6153
+ btn.addEventListener('click', async (e) => {
6154
+ e.stopPropagation();
6155
+ const idx = btn.dataset.ccTrust;
6156
+ const panel = overlay.querySelector(`.cc-panel[data-cc-idx="${idx}"]`);
6157
+ const projectPath = panel?.dataset.ccPath;
6158
+ if (!projectPath) return;
6159
+ const origText = btn.textContent;
6160
+ btn.disabled = true;
6161
+ btn.textContent = 'Trusting...';
6162
+ try {
6163
+ const res = await fetch('/api/terminal/trust-workspace', {
6164
+ method: 'POST',
6165
+ headers: { 'Content-Type': 'application/json' },
6166
+ body: JSON.stringify({ path: projectPath }),
6167
+ });
6168
+ const data = await res.json();
6169
+ if (data.ok) {
6170
+ btn.textContent = '✓ Trusted';
6171
+ btn.style.background = 'rgba(109,213,140,0.14)';
6172
+ btn.style.borderColor = 'rgba(109,213,140,0.3)';
6173
+ btn.style.color = '#a5d6a7';
6174
+ setTimeout(() => { btn.disabled = false; btn.textContent = 'Trust workspace'; btn.style.background = 'rgba(255,183,77,0.14)'; btn.style.borderColor = 'rgba(255,183,77,0.3)'; btn.style.color = '#ffcc80'; }, 2500);
6175
+ } else {
6176
+ btn.disabled = false;
6177
+ btn.textContent = origText;
6178
+ alert('Trust failed: ' + (data.error || 'unknown error'));
6179
+ }
6180
+ } catch (err) {
6181
+ btn.disabled = false;
6182
+ btn.textContent = origText;
6183
+ alert('Trust failed: ' + err.message);
6184
+ }
6185
+ });
6186
+ });
6187
+
6150
6188
  // Remove project
6151
6189
  overlay.querySelectorAll('.cc-remove-panel-btn[data-cc-remove]').forEach(btn => {
6152
6190
  btn.addEventListener('click', async (e) => {
@@ -95,7 +95,20 @@ dotenvConfig({ path: resolve(process.env.SYNABUN_DATA_HOME || resolve(__dirname,
95
95
 
96
96
  const app = express();
97
97
  const SERVER_BOOT_ID = randomUUID();
98
- app.use(express.json({ limit: '5mb' }));
98
+ app.use(express.json({ limit: '25mb' }));
99
+
100
+ // Clean body-parser error handler: ui-state / whiteboard persist base64 images and can
101
+ // exceed the JSON limit. Default Express handler dumps a 10-line raw-body stack with no
102
+ // URL — this logs one line with context and responds 413 instead.
103
+ app.use((err, req, res, next) => {
104
+ if (err && (err.type === 'entity.too.large' || err.type === 'entity.parse.failed')) {
105
+ const len = req.headers['content-length'] || '?';
106
+ console.warn(`[body-parser] ${err.type} ${req.method} ${req.originalUrl || req.url} content-length=${len} limit=${err.limit || '?'}`);
107
+ if (!res.headersSent) res.status(err.status || 413).json({ error: err.type, limit: err.limit });
108
+ return;
109
+ }
110
+ next(err);
111
+ });
99
112
 
100
113
  // Block tunnel traffic from accessing anything except /mcp and /invite
101
114
  // Cloudflared adds CF-Connecting-IP header on proxied requests
@@ -3814,8 +3827,8 @@ let _ocpBootReconcileTried = false;
3814
3827
  let _ocpLastBootAt = 0; // Date.now() when serve process last became ready
3815
3828
  let _ocpAuthReconciling = false; // prevent concurrent reconciliation
3816
3829
 
3817
- // ── Verbose trace logging (toggle with OCP_VERBOSE=0 to disable) ──
3818
- const OCP_VERBOSE = process.env.OCP_VERBOSE !== '0';
3830
+ // ── Verbose trace logging (opt-in: OCP_VERBOSE=1 to enable) ──
3831
+ const OCP_VERBOSE = process.env.OCP_VERBOSE === '1';
3819
3832
  let _ocpTraceSeq = 0;
3820
3833
  function _ocpNow() { return Number(process.hrtime.bigint() / 1_000_000n); }
3821
3834
  function _ocpMs(start) { return _ocpNow() - start; }
@@ -12742,6 +12755,18 @@ app.post('/api/claude-code/integrations', (req, res) => {
12742
12755
  // Create default category tree for the project (parent + children)
12743
12756
  try { ensureProjectCategories(); } catch {}
12744
12757
 
12758
+ // Probe for git "dubious ownership" — signals the UI to show an explicit trust prompt.
12759
+ // Only relevant on Windows when the project dir is owned by a different SID than the
12760
+ // account running Node (external drives, copied repos, etc.). No-op on Mac/Linux.
12761
+ let dubiousOwnership = false;
12762
+ let trustPath = null;
12763
+ try {
12764
+ if (detectDubiousOwnership(normalized)) {
12765
+ dubiousOwnership = true;
12766
+ trustPath = normalizeGitPath(normalized);
12767
+ }
12768
+ } catch { /* non-critical */ }
12769
+
12745
12770
  // Auto-create greeting config entry for the new project
12746
12771
  try {
12747
12772
  const greetingKey = projectLabel.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
@@ -12761,7 +12786,7 @@ app.post('/api/claude-code/integrations', (req, res) => {
12761
12786
  }
12762
12787
  } catch { /* non-critical */ }
12763
12788
 
12764
- return res.json({ ok: true, message: `Hook enabled for ${basename(normalized)}.` });
12789
+ return res.json({ ok: true, message: `Hook enabled for ${basename(normalized)}.`, dubiousOwnership, trustPath });
12765
12790
  }
12766
12791
 
12767
12792
  res.status(400).json({ error: 'Invalid target. Use "global" or "project".' });
@@ -17072,27 +17097,132 @@ app.get('/api/terminal/sessions/:id/claude-session', (req, res) => {
17072
17097
 
17073
17098
  // ── Terminal file tree ──
17074
17099
 
17100
+ const IS_WINDOWS = process.platform === 'win32';
17101
+ let _gitMissingLogged = false;
17102
+ let _gitExePath = null; // Cached absolute path — preferring .exe on Windows
17103
+ let _gitResolved = false;
17104
+
17105
+ // Resolve git's absolute path at first use. On Windows, `where git` lists all matches;
17106
+ // prefer .exe so spawnSync works without cmd.exe quoting issues. On Unix, use `command -v`.
17107
+ function resolveGitPath() {
17108
+ if (_gitResolved) return _gitExePath;
17109
+ _gitResolved = true;
17110
+ try {
17111
+ const cmd = IS_WINDOWS ? 'where git' : 'command -v git';
17112
+ const out = execSync(cmd, { encoding: 'utf8', timeout: 3000, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
17113
+ const lines = out.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
17114
+ if (IS_WINDOWS) {
17115
+ _gitExePath = lines.find(l => /\.exe$/i.test(l)) || lines[0] || null;
17116
+ } else {
17117
+ _gitExePath = lines[0] || null;
17118
+ }
17119
+ if (_gitExePath) console.log(`[git] resolved: ${_gitExePath}`);
17120
+ else console.warn('[git] resolver returned no paths');
17121
+ } catch (err) {
17122
+ console.warn('[git] resolution failed:', err?.message || String(err));
17123
+ _gitExePath = null;
17124
+ }
17125
+ return _gitExePath;
17126
+ }
17127
+
17128
+ // Cross-platform git invocation. Uses resolved absolute path when possible (spawnable
17129
+ // directly on all OSes with no shell/quoting pitfalls). Falls back to `shell: true` on
17130
+ // Windows only when path resolution fails or when the resolved target is a .cmd/.bat shim.
17131
+ function spawnGit(args, opts = {}) {
17132
+ const baseOpts = { stdio: 'pipe', timeout: 5000, windowsHide: true, ...opts };
17133
+ const gitPath = resolveGitPath();
17134
+ if (gitPath) {
17135
+ if (IS_WINDOWS && /\.(cmd|bat)$/i.test(gitPath)) {
17136
+ const quoted = args.map(a => /[\s()%&|<>^"']/.test(a) ? `"${String(a).replace(/"/g, '\\"')}"` : a);
17137
+ return spawnSync(gitPath, quoted, { ...baseOpts, shell: true });
17138
+ }
17139
+ return spawnSync(gitPath, args, baseOpts);
17140
+ }
17141
+ if (IS_WINDOWS) {
17142
+ const quoted = args.map(a => /[\s()%&|<>^"']/.test(a) ? `"${String(a).replace(/"/g, '\\"')}"` : a);
17143
+ return spawnSync('git', quoted, { ...baseOpts, shell: true });
17144
+ }
17145
+ return spawnSync('git', args, baseOpts);
17146
+ }
17147
+
17148
+ function logGitFailure(where, err) {
17149
+ const msg = err?.stderr?.toString?.() || err?.message || String(err);
17150
+ if (!msg || /not a git repository/i.test(msg)) return;
17151
+ if (/ENOENT/i.test(msg) || /is not recognized/i.test(msg) || /command not found/i.test(msg)) {
17152
+ if (_gitMissingLogged) return;
17153
+ _gitMissingLogged = true;
17154
+ console.warn(`[git] '${where}' failed: git executable not found on PATH. Install Git and ensure it's on the Node process PATH.`);
17155
+ return;
17156
+ }
17157
+ console.warn(`[git] '${where}' failed:`, msg.trim().split('\n')[0]);
17158
+ }
17159
+
17160
+ // Startup probe — always logs git status so Windows users can see whether git works at all.
17161
+ setImmediate(() => {
17162
+ const probe = spawnGit(['--version'], { timeout: 3000 });
17163
+ if (probe.status === 0) {
17164
+ console.log(`[git] ${probe.stdout.toString().trim()} — OK`);
17165
+ } else {
17166
+ const msg = probe.stderr?.toString?.()?.trim() || probe.error?.message || 'unknown error';
17167
+ console.warn(`[git] startup probe FAILED: ${msg}. File explorer git UI will be hidden.`);
17168
+ }
17169
+ });
17170
+
17171
+ // Git "dubious ownership" detection — CVE-2022-24765 safety check refuses to operate on
17172
+ // repos owned by a different OS user/SID. Common on Windows external drives or repos
17173
+ // copied from another machine. Returns true only when git emits the specific error.
17174
+ function detectDubiousOwnership(dir) {
17175
+ const r = spawnGit(['rev-parse', '--is-inside-work-tree'], { cwd: dir, timeout: 3000 });
17176
+ if (r.status === 0) return false;
17177
+ const stderr = r.stderr?.toString?.() || '';
17178
+ return /dubious ownership|safe\.directory/i.test(stderr);
17179
+ }
17180
+
17181
+ // Normalize a filesystem path for `git config --add safe.directory` — forward-slashed
17182
+ // absolute path, no trailing slash.
17183
+ function normalizeGitPath(dir) {
17184
+ return String(dir).replace(/\\/g, '/').replace(/\/+$/, '');
17185
+ }
17186
+
17075
17187
  /** Try to get git status for a directory. Returns { branch, statuses } or null. */
17076
17188
  function getGitInfo(dir) {
17077
- try {
17078
- // Check if inside a git work tree
17079
- execSync('git rev-parse --is-inside-work-tree', { cwd: dir, stdio: 'pipe', timeout: 3000 });
17189
+ // Check if inside a git work tree (spawnGit for consistent path resolution)
17190
+ const inside = spawnGit(['rev-parse', '--is-inside-work-tree'], { cwd: dir, timeout: 3000 });
17191
+ if (inside.status !== 0) {
17192
+ logGitFailure('rev-parse --is-inside-work-tree', inside.error || { stderr: inside.stderr });
17193
+ return null;
17194
+ }
17080
17195
 
17081
- // Get branch name
17082
- let branch = '';
17083
- try {
17084
- branch = execSync('git branch --show-current', { cwd: dir, stdio: 'pipe', timeout: 3000 }).toString().trim();
17085
- if (!branch) {
17086
- // Detached HEAD — get short SHA
17087
- branch = execSync('git rev-parse --short HEAD', { cwd: dir, stdio: 'pipe', timeout: 3000 }).toString().trim();
17088
- }
17089
- } catch {}
17196
+ // Get branch name — try --show-current first (git 2.22+), then fall back to
17197
+ // --abbrev-ref HEAD (works on older git; detached HEAD returns the literal "HEAD"),
17198
+ // then short SHA for detached HEAD. The chained fallback is essential for Windows users
17199
+ // with older Git for Windows installs where --show-current exits non-zero.
17200
+ let branch = '';
17201
+ const showCurrent = spawnGit(['branch', '--show-current'], { cwd: dir, timeout: 3000 });
17202
+ if (showCurrent.status === 0) {
17203
+ branch = showCurrent.stdout.toString().trim();
17204
+ } else {
17205
+ logGitFailure('branch --show-current', showCurrent.error || { stderr: showCurrent.stderr });
17206
+ }
17207
+ if (!branch) {
17208
+ const abbrev = spawnGit(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: dir, timeout: 3000 });
17209
+ if (abbrev.status === 0) {
17210
+ const b = abbrev.stdout.toString().trim();
17211
+ if (b && b !== 'HEAD') branch = b;
17212
+ }
17213
+ }
17214
+ if (!branch) {
17215
+ const shortSha = spawnGit(['rev-parse', '--short', 'HEAD'], { cwd: dir, timeout: 3000 });
17216
+ if (shortSha.status === 0) branch = shortSha.stdout.toString().trim();
17217
+ }
17090
17218
 
17219
+ try {
17091
17220
  // Get status for this directory (porcelain v1: XY <path>)
17092
- // spawnSync over execSync — execSync's 1MB default maxBuffer overflows on repos with very large
17093
- // status output (e.g. tracked browser-profile caches), throwing ENOBUFS and silently disabling git UI.
17094
- const statusResult = spawnSync('git', ['status', '--porcelain', '-u', '.'], { cwd: dir, stdio: 'pipe', timeout: 5000, maxBuffer: 256 * 1024 * 1024 });
17095
- if (statusResult.status !== 0) return { branch, statuses: new Map() };
17221
+ const statusResult = spawnGit(['status', '--porcelain', '-u', '.'], { cwd: dir, maxBuffer: 256 * 1024 * 1024 });
17222
+ if (statusResult.status !== 0) {
17223
+ logGitFailure('status --porcelain', statusResult.error || { stderr: statusResult.stderr });
17224
+ return { branch, statuses: new Map() };
17225
+ }
17096
17226
  const raw = statusResult.stdout.toString();
17097
17227
  const statuses = new Map(); // name → status code
17098
17228
 
@@ -17130,8 +17260,9 @@ function getGitInfo(dir) {
17130
17260
  }
17131
17261
 
17132
17262
  return { branch, statuses };
17133
- } catch {
17134
- return null;
17263
+ } catch (err) {
17264
+ logGitFailure('getGitInfo status-parse', err);
17265
+ return { branch, statuses: new Map() };
17135
17266
  }
17136
17267
  }
17137
17268
 
@@ -17213,23 +17344,36 @@ app.get('/api/terminal/branches', (req, res) => {
17213
17344
  const dir = req.query.path;
17214
17345
  if (!dir || typeof dir !== 'string') return res.status(400).json({ error: 'path required' });
17215
17346
 
17216
- try {
17217
- execSync('git rev-parse --is-inside-work-tree', { cwd: dir, stdio: 'pipe', timeout: 3000 });
17218
- } catch {
17347
+ const inside = spawnGit(['rev-parse', '--is-inside-work-tree'], { cwd: dir, timeout: 3000 });
17348
+ if (inside.status !== 0) {
17349
+ logGitFailure('branches: rev-parse', inside.error || { stderr: inside.stderr });
17219
17350
  return res.json({ branches: [], current: null });
17220
17351
  }
17221
17352
 
17222
17353
  try {
17223
- // Current branch
17354
+ // Current branch — chained fallback (older git may not support --show-current)
17224
17355
  let current = '';
17225
- try {
17226
- current = execSync('git branch --show-current', { cwd: dir, stdio: 'pipe', timeout: 3000 }).toString().trim();
17227
- } catch {}
17228
-
17229
- // All local branches — spawnSync with argv so the %(...) format isn't mangled by cmd.exe variable expansion on Windows
17230
- const branchResult = spawnSync('git', ['branch', '--format=%(refname:short)'], { cwd: dir, stdio: 'pipe', timeout: 5000 });
17231
- const raw = branchResult.status === 0 ? branchResult.stdout.toString().trim() : '';
17232
- const branches = raw ? raw.split('\n').map(b => b.trim()).filter(Boolean) : [];
17356
+ const showCurrent = spawnGit(['branch', '--show-current'], { cwd: dir, timeout: 3000 });
17357
+ if (showCurrent.status === 0) current = showCurrent.stdout.toString().trim();
17358
+ else logGitFailure('branches: show-current', showCurrent.error || { stderr: showCurrent.stderr });
17359
+ if (!current) {
17360
+ const abbrev = spawnGit(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: dir, timeout: 3000 });
17361
+ if (abbrev.status === 0) {
17362
+ const b = abbrev.stdout.toString().trim();
17363
+ if (b && b !== 'HEAD') current = b;
17364
+ }
17365
+ }
17366
+
17367
+ // All local branches — plain `git branch` parsed manually; avoids --format=%(refname:short)
17368
+ // which can get mangled by cmd.exe variable expansion on Windows even with proper quoting.
17369
+ const branchResult = spawnGit(['branch'], { cwd: dir });
17370
+ if (branchResult.status !== 0) logGitFailure('branch list', branchResult.error || { stderr: branchResult.stderr });
17371
+ const raw = branchResult.status === 0 ? branchResult.stdout.toString() : '';
17372
+ // Plain `git branch` output: each line is " branch" or "* branch" (current). Strip
17373
+ // marker + whitespace. Skip detached-HEAD lines like "* (HEAD detached at abc1234)".
17374
+ const branches = raw.split('\n')
17375
+ .map(l => l.replace(/^[\s*]+/, '').trim())
17376
+ .filter(b => b && !b.startsWith('(') && !b.includes(' -> '));
17233
17377
 
17234
17378
  res.json({ branches, current });
17235
17379
  } catch (err) {
@@ -17237,6 +17381,38 @@ app.get('/api/terminal/branches', (req, res) => {
17237
17381
  }
17238
17382
  });
17239
17383
 
17384
+ // Trust a workspace path with git — runs `git config --global --add safe.directory <path>`
17385
+ // so git stops refusing to operate on it (CVE-2022-24765 dubious-ownership protection).
17386
+ // Triggered by the add-project UI flows when the server detects the dubious-ownership error.
17387
+ app.post('/api/terminal/trust-workspace', (req, res) => {
17388
+ const { path: dir } = req.body || {};
17389
+ if (!dir || typeof dir !== 'string') return res.status(400).json({ ok: false, error: 'path required' });
17390
+ if (/[\x00-\x1f]/.test(dir) || dir === '*' || dir === '/' || dir === '\\') {
17391
+ return res.status(400).json({ ok: false, error: 'Invalid path' });
17392
+ }
17393
+ let abs;
17394
+ try {
17395
+ abs = resolve(dir);
17396
+ if (!existsSync(abs)) return res.status(400).json({ ok: false, error: `Directory not found: ${abs}` });
17397
+ if (!statSync(abs).isDirectory()) return res.status(400).json({ ok: false, error: 'Path is not a directory' });
17398
+ } catch (err) {
17399
+ return res.status(400).json({ ok: false, error: err.message });
17400
+ }
17401
+
17402
+ const gitPath = normalizeGitPath(abs);
17403
+ const r = spawnGit(['config', '--global', '--add', 'safe.directory', gitPath]);
17404
+ if (r.status !== 0) {
17405
+ const stderr = r.stderr?.toString?.()?.trim() || r.error?.message || 'git config failed';
17406
+ logGitFailure('config --add safe.directory', r.error || { stderr: r.stderr });
17407
+ return res.status(500).json({ ok: false, error: stderr });
17408
+ }
17409
+
17410
+ // Confirm by re-probing
17411
+ const stillDubious = detectDubiousOwnership(abs);
17412
+ console.log(`[git] trusted workspace: ${gitPath}${stillDubious ? ' (but still flagged — check permissions)' : ''}`);
17413
+ res.json({ ok: true, path: gitPath, stillDubious });
17414
+ });
17415
+
17240
17416
  app.post('/api/terminal/checkout', (req, res) => {
17241
17417
  const { path: dir, branch } = req.body;
17242
17418
  if (!dir || !branch) return res.status(400).json({ error: 'path and branch required' });
@@ -17246,26 +17422,25 @@ app.post('/api/terminal/checkout', (req, res) => {
17246
17422
  return res.status(400).json({ error: 'Invalid branch name' });
17247
17423
  }
17248
17424
 
17249
- try {
17250
- execSync('git rev-parse --is-inside-work-tree', { cwd: dir, stdio: 'pipe', timeout: 3000 });
17251
- } catch {
17425
+ const inside = spawnGit(['rev-parse', '--is-inside-work-tree'], { cwd: dir, timeout: 3000 });
17426
+ if (inside.status !== 0) {
17427
+ logGitFailure('checkout: rev-parse', inside.error || { stderr: inside.stderr });
17252
17428
  return res.status(400).json({ error: 'Not a git repository' });
17253
17429
  }
17254
17430
 
17255
- // Use spawnSync to avoid shell injection — branch passed as argument, not interpolated
17256
- // No '--' separator — that would make git treat the branch name as a file pathspec
17257
- const result = spawnSync('git', ['checkout', branch], { cwd: dir, stdio: 'pipe', timeout: 10000 });
17258
-
17431
+ // Branch is validated against /^[\w.\-/]+$/ above, so no shell metacharacters are possible.
17432
+ // No '--' separator — that would make git treat the branch name as a file pathspec.
17433
+ const result = spawnGit(['checkout', branch], { cwd: dir, timeout: 10000 });
17259
17434
  if (result.status !== 0) {
17260
- const stderr = result.stderr?.toString?.()?.trim() || 'Checkout failed';
17435
+ const stderr = result.stderr?.toString?.()?.trim() || result.error?.message || 'Checkout failed';
17261
17436
  return res.status(500).json({ error: stderr });
17262
17437
  }
17263
17438
 
17264
17439
  // Return confirmed branch name + git's own output
17265
17440
  let current = '';
17266
- try {
17267
- current = execSync('git branch --show-current', { cwd: dir, stdio: 'pipe', timeout: 3000 }).toString().trim();
17268
- } catch {}
17441
+ const showCurrent = spawnGit(['branch', '--show-current'], { cwd: dir, timeout: 3000 });
17442
+ if (showCurrent.status === 0) current = showCurrent.stdout.toString().trim();
17443
+ else logGitFailure('checkout: show-current', showCurrent.error || { stderr: showCurrent.stderr });
17269
17444
  const output = result.stderr?.toString?.()?.trim() || `Switched to branch '${current}'`;
17270
17445
  res.json({ ok: true, branch: current, output });
17271
17446
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synabun",
3
- "version": "2026.4.20-beta.9",
3
+ "version": "2026.4.21",
4
4
  "description": "Persistent vector memory for AI assistants",
5
5
  "license": "Apache-2.0",
6
6
  "author": "The SynaBun Authors",