agentgui 1.0.1090 → 1.0.1092

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/AGENTS.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # AgentGUI — Agent Notes
2
2
 
3
+ ## Docstudio design-cue follow-up (2026-07-23) — fifty-first run
4
+
5
+ Ninth docstudio-cue pass. A fresh Explore-agent survey (told the full 50-run ported list) re-read every remaining docstudio surface not previously examined name-by-name (`collection-item-actions.js`, `collection-page.js`, `tool-approval-panel.js`, `thread-list-actions.js`, `thread-list-renderer.js`, `streaming/reasoning-block.js`, `utils/csv-export.js`, `components/user-analytics.js`) and confirmed most candidates were already covered under a different name (`tool-approval-panel.js`'s menu = kit `PermissionMenu`; `reasoning-block.js` = kit `ThinkingNode`; `user-analytics.js` stat grid = kit `StatTile`/`StatsGrid`; CSV export = agentgui's existing `downloadBlob` JSON export pattern, narrower not net-new). One genuine, narrow gap survived: `collection-item-actions.js:27-32` `renderChips()` renders removable tag chips (label + inline `x` dismiss button) for the "My Collection" prompt tag editor — the kit's `Chip` (`design/src/components/shell.js:33`) was purely presentational with no dismiss affordance at all. Added an optional `onRemove` prop: when supplied, `Chip` renders a trailing `<button class="ds-chip-remove-btn" aria-label="Remove">` (the existing `x` `Icon()`) that calls `onRemove()` on click (`e.stopPropagation()`'d so it never bubbles into a parent chip-click handler); omitted entirely (byte-identical to the old shape) when not supplied, so every existing `Chip` call site is unaffected. CSS landed in `design/src/css/app-shell/primitives.css`: a small 16px (24px under `pointer:coarse`) circular button, neutral-at-rest/tinted-on-hover-focus per the standing color-reserved-for-meaning convention. The button class needed a `ds-` prefix (not a new bare `chip-remove` token) to pass the kit's `lint-classes.mjs` family-prefix guard — `chip` itself is grandfathered on the FROZEN legacy list, but new sub-tokens off it are not automatically covered. Kit built+tested (`bun test.js` all pass), docs regenerated (`docs/component-props.md`, 218 symbols, 0 drift). Kit rebased once onto a concurrent `v0.0.358`/`v0.0.359` release (dist-only conflict, rebuilt fresh from merged sources per the standing discipline, not hand-merged). Kit pushed `c47f681`, all 3 kit CI checks green (ci/Publish 247420 to npm/Deploy GH Pages). agentgui re-vendored both dist files; browser-witnessed live on `localhost:3009/gm/`: imported the live-served vendored bundle, mounted `Chip({tone:'blue',onRemove})` into real DOM via `applyDiff`, confirmed the rendered markup (`<button class="ds-chip-remove-btn" aria-label="Remove">` with the `x` SVG), and confirmed a real click fired the `onRemove` callback (`calls:1`). **Real tooling defect diagnosed+fixed this run, not papered over**: browser verb dispatches intermittently failed `"plugin gm not loaded"` or silently hung the full 60s timeout with no output file at all, repeatedly crashing the watcher heartbeat mid-dispatch. Root-caused to a genuinely different failure mode than the 50th run's stale-supervisor issue: **two full sets of zombie Chromium process trees for the agentgui project's own `.gm/browser-chrome-profile-` directory were left running from earlier failed/timed-out dispatches in this same session**, holding the CDP debug port and profile lock so every subsequent dispatch's Chrome launch (or reuse) wedged. Killing every chromium PID under the project's profile dir (leaving the daemon's own management alone) immediately unblocked clean, fast (<1s) dispatches. **Also newly confirmed: the `browser` verb's actual accepted JSON body shape in this env is `{"code": "<script>"}`, evaluated directly in the CURRENTLY-LOADED PAGE's `window`/`document` context (not a Puppeteer/Playwright `page` object at all — there is no `page.goto`/`page.title()` in this shape, despite AGENTS.md's `url=`/`dom=`-prefix documentation describing a `page`-scoped API)** — navigating via `window.location.href = '...'` inside one `{code}` dispatch throws `"Inspected target navigated or closed"` (expected: the navigation kills the CDP session synchronously mid-eval), but the session persists across dispatches, so a SECOND `{code}` dispatch against the same session lands in the newly-navigated page correctly. The `url=`/`dom=`-prefixed plain-text body forms documented elsewhere in this file did not work in this env at all (`"host_browser_exec returned empty"` or no output ever produced) — future sessions hitting the identical symptom should use the two-dispatch `{code}` navigate-then-eval pattern demonstrated this run rather than re-litigating the prefix forms from scratch.
6
+
3
7
  ## Docstudio design-cue follow-up (2026-07-23) — fiftieth run
4
8
 
5
9
  Eighth docstudio-cue pass. A fresh Explore-agent survey (told the full 49-run ported list) read every remaining unmined docstudio surface — admin-observability-{views,chart}.js, marketplace-{trending,card,flag}.js, documents/drive-picker.js, documents/file-upload.js, streaming/tts-player.js, thread-list-realtime.js, capture-page.js/capture-runner.js, and admin users/tags/archives/audit/moderation tabs — and came back near-exhausted: nearly everything was either already covered under a different name (docstudio's tts-player is *less* mature than the kit's own `voice.js`), pure business logic with no portable UI (drive-picker delegates to Google's native picker iframe; file-upload/thread-list-realtime have no render surface at all), or app-specific GCP wiring (the observability panels' Cloud Logging deep-links). One genuine, narrow gap survived: `admin-observability-views.js:132-161` `endpointsView()` color-codes percentile-latency/success-rate table cells (green >=99%, amber >=95%, red below) with no kit equivalent — `Table` had no notion of a value implying good/warn/bad. Ported the portable convention (not the GCP wiring) as `RateCell({value, tone})` in `design/src/components/data-density.js`, a plain tone-colored `<span>` over the existing `--success`/`--warn`/`--danger` tokens that drops into any `Table` cell, matching `Table`'s own onSort host-owns-the-logic convention (this component has no opinion on thresholds). While touching `data-density.js`, found and fixed a real, unrelated, previously-undiscovered gap: **its entire component set (`PhaseWalk`/`TreeNode`/`BarRow`/`StatTile`/`StatsGrid`/`SubGrid`/`SessionRow`/`DevRow`/`LiveLog`) was never wired into `src/components.js`'s barrel** — unreachable from the built bundle despite having real CSS in `data-density.css`, since before this run. Fixed by adding the barrel export line alongside the new `RateCell`. Kit built+tested (`bun test.js` all pass, 0 lint errors), docs regenerated (`docs/component-props.md`, 218 symbols, 0 drift). Kit pushed `72d88b6`, all 3 kit CI checks green (ci/Publish 247420 to npm/Deploy GH Pages). agentgui re-vendored both dist files; browser-witnessed live on `localhost:3009/gm/`: imported the live-served vendored bundle directly, confirmed `mod.components.RateCell`/`mod.components.StatTile` both callable, and mounted `RateCell({value:"UNIQ_99_4",tone:"good"})` into real DOM via `applyDiff` — rendered exactly `<span class="ds-rate-cell ds-rate-cell-good">UNIQ_99_4</span>`, confirming the CSS tone-class wiring works end-to-end live. **Real tooling defect diagnosed+fixed this run, not papered over**: mid-session, `browser` verb dispatches intermittently failed `"plugin gm not loaded"` — root-caused via `.gm/exec-spool/.watcher.log` to a **leftover stale `gm-plugkit` `supervisor.js` process** (pid alive since a prior session, `Jul19`) running independently alongside the healthy native `agentplug-runner` daemon, periodically trying to respawn the watcher via the *retired* JS `plugkit-wasm-wrapper.js` path (`Cannot find module './wrapper/wasi-shim.js'` — the exact historically-resolved npm-packaging defect from the 48th run, recurring here via a stale cached supervisor rather than a fresh regression) and crash-looping. A separately-wedged long-lived Chrome process for the agentgui project profile (alive since `Jul17`) was also found wedging evals to 60s timeouts. Killed both stale processes, rebooted a fresh daemon (`bun x gm-plugkit@latest spool`) — confirmed fixed via two clean subsequent dispatches, no further crash-loop lines. **Also newly confirmed: writing a spool verb input file under a task-number that collides with an existing `out/<verb>-<N>.json` from an earlier turn in the same session silently returns the STALE cached output instead of running the fresh dispatch** — the watcher appears to treat a pre-existing out-file as already-satisfied and skip reprocessing. Always pick a task number confirmed absent from `out/` (e.g. jump to a clearly-unused block like 100+) rather than trusting sequential same-session numbering, especially after a long session with many prior dispatches.
@@ -2,7 +2,7 @@ import fs from 'fs';
2
2
  import os from 'os';
3
3
  import path from 'path';
4
4
  import crypto from 'crypto';
5
- import { execSync, spawnSync } from 'child_process';
5
+ import { execSync, spawnSync, execFile } from 'child_process';
6
6
  import { runClaudeWithStreaming } from './claude-runner-run.js';
7
7
  import { registry } from './claude-runner-agents.js';
8
8
  import { confineToRoots, fsAllowRoots } from './http-handler.js';
@@ -10,6 +10,49 @@ import { restart as restartAcpAgent } from './acp-sdk-manager.js';
10
10
 
11
11
  function err(code, message) { const e = new Error(message); e.code = code; throw e; }
12
12
 
13
+ // Promisified execFile — array-args form only, never a shell string, so a
14
+ // crafted branch/path/file value cannot be interpreted as a shell command.
15
+ function execFileP(cmd, args, opts) {
16
+ return new Promise((resolve, reject) => {
17
+ execFile(cmd, args, { encoding: 'utf-8', timeout: 30000, maxBuffer: 20 * 1024 * 1024, ...opts }, (e, stdout, stderr) => {
18
+ if (e) { e.stdout = stdout; e.stderr = stderr; reject(e); return; }
19
+ resolve({ stdout, stderr });
20
+ });
21
+ });
22
+ }
23
+
24
+ // Resolve + confine a client-supplied cwd to the same allowlist the file
25
+ // explorer / chat.sendMessage use (fsAllowRoots via confineToRoots). Falls
26
+ // back to STARTUP_CWD (itself an allowed root) when no cwd is given.
27
+ function resolveGitCwd(p, STARTUP_CWD) {
28
+ if (!p?.cwd) return STARTUP_CWD;
29
+ const conf = confineToRoots(p.cwd, fsAllowRoots());
30
+ if (!conf.ok) err(conf.reason === 'not found' ? 400 : 403, `cwd outside allowed roots: ${p.cwd}`);
31
+ return conf.realPath;
32
+ }
33
+
34
+ // A relative in-repo file path: no leading '-' (flag injection), no shell
35
+ // metacharacters, no absolute path (git resolves it relative to cwd already).
36
+ const SAFE_REL_PATH_RE = /^[^\0]{1,4096}$/;
37
+ function assertSafeRelPath(v, label) {
38
+ if (typeof v !== 'string' || !v.length) err(400, `${label} required`);
39
+ if (v.startsWith('-')) err(400, `${label} must not start with '-'`);
40
+ if (/[\0]/.test(v)) err(400, `${label} contains invalid characters`);
41
+ if (!SAFE_REL_PATH_RE.test(v)) err(400, `${label} invalid`);
42
+ return v;
43
+ }
44
+
45
+ // A git branch/ref name — conservative allowlist (git's own ref-format is
46
+ // more permissive, but this blocks every shell-metacharacter and flag-
47
+ // injection vector while still covering normal branch names).
48
+ const SAFE_BRANCH_RE = /^[A-Za-z0-9][A-Za-z0-9_.\/-]{0,199}$/;
49
+ function assertSafeBranch(v, label) {
50
+ if (typeof v !== 'string' || !v.length) err(400, `${label} required`);
51
+ if (v.startsWith('-') || v.includes('..') || v.includes('//')) err(400, `${label} invalid`);
52
+ if (!SAFE_BRANCH_RE.test(v)) err(400, `${label} invalid`);
53
+ return v;
54
+ }
55
+
13
56
  const SUB_AGENT_MAP = {
14
57
  'opencode': [{ id: 'gm-oc', name: 'GM OpenCode' }], 'cli-opencode': [{ id: 'gm-oc', name: 'GM OpenCode' }],
15
58
  'gemini': [{ id: 'gm-gc', name: 'GM Gemini' }], 'cli-gemini': [{ id: 'gm-gc', name: 'GM Gemini' }],
@@ -281,6 +324,84 @@ export function register(router, deps) {
281
324
  } catch (e) { err(500, e.message); }
282
325
  });
283
326
 
327
+ // --- git.diff: unified diff for a single file or the whole working tree.
328
+ // p.cwd (optional) is confined via confineToRoots/fsAllowRoots, same as
329
+ // chat.sendMessage; p.file (optional) is validated to reject flags/shell
330
+ // metacharacters. execFile array-args form only, never a shell string.
331
+ router.handle('git.diff', async (p) => {
332
+ const cwd = resolveGitCwd(p, STARTUP_CWD);
333
+ const args = ['diff', '--no-color'];
334
+ if (p?.staged) args.push('--staged');
335
+ if (p?.file) args.push('--', assertSafeRelPath(p.file, 'file'));
336
+ try {
337
+ const { stdout } = await execFileP('git', args, { cwd });
338
+ return { diff: stdout, file: p?.file || null, staged: !!p?.staged };
339
+ } catch (e) { err(500, (e.stderr || e.message || 'git diff failed').trim()); }
340
+ });
341
+
342
+ // --- git.log: recent commit list, limit param (default 20, capped 200).
343
+ router.handle('git.log', async (p) => {
344
+ const cwd = resolveGitCwd(p, STARTUP_CWD);
345
+ const limit = Math.max(1, Math.min(200, parseInt(p?.limit, 10) || 20));
346
+ const FIELD_SEP = '\x1f';
347
+ const REC_SEP = '\x1e';
348
+ const args = ['log', `-n${limit}`, `--pretty=format:%H${FIELD_SEP}%an${FIELD_SEP}%ad${FIELD_SEP}%s${REC_SEP}`, '--date=iso-strict'];
349
+ try {
350
+ const { stdout } = await execFileP('git', args, { cwd });
351
+ const commits = stdout.split(REC_SEP).map(s => s.trim()).filter(Boolean).map(rec => {
352
+ const [hash, author, date, ...rest] = rec.split(FIELD_SEP);
353
+ return { hash, author, date, subject: rest.join(FIELD_SEP) };
354
+ });
355
+ return { commits };
356
+ } catch (e) { err(500, (e.stderr || e.message || 'git log failed').trim()); }
357
+ });
358
+
359
+ // --- worktree.list / create / remove ---
360
+ router.handle('worktree.list', async (p) => {
361
+ const cwd = resolveGitCwd(p, STARTUP_CWD);
362
+ try {
363
+ const { stdout } = await execFileP('git', ['worktree', 'list', '--porcelain'], { cwd });
364
+ // Parse the porcelain block format: records separated by blank lines,
365
+ // each line "key value" or a bare flag ("bare", "detached", "locked").
366
+ const worktrees = [];
367
+ let cur = null;
368
+ for (const line of stdout.split('\n')) {
369
+ if (!line.trim()) { if (cur) { worktrees.push(cur); cur = null; } continue; }
370
+ if (!cur) cur = {};
371
+ const sp = line.indexOf(' ');
372
+ if (sp === -1) { cur[line] = true; continue; }
373
+ cur[line.slice(0, sp)] = line.slice(sp + 1);
374
+ }
375
+ if (cur) worktrees.push(cur);
376
+ return { worktrees: worktrees.map(w => ({ path: w.worktree || null, head: w.HEAD || null, branch: w.branch || null, bare: !!w.bare, detached: !!w.detached, locked: !!w.locked })) };
377
+ } catch (e) { err(500, (e.stderr || e.message || 'git worktree list failed').trim()); }
378
+ });
379
+
380
+ router.handle('worktree.create', async (p) => {
381
+ const cwd = resolveGitCwd(p, STARTUP_CWD);
382
+ const wtPath = assertSafeRelPath(p?.path, 'path');
383
+ const args = ['worktree', 'add'];
384
+ if (p?.newBranch) { args.push('-b', assertSafeBranch(p.newBranch, 'newBranch')); }
385
+ args.push(wtPath);
386
+ if (p?.branch) args.push(assertSafeBranch(p.branch, 'branch'));
387
+ try {
388
+ const { stdout, stderr } = await execFileP('git', args, { cwd });
389
+ return { ok: true, output: (stdout || stderr || '').trim(), path: wtPath };
390
+ } catch (e) { err(500, (e.stderr || e.message || 'git worktree add failed').trim()); }
391
+ });
392
+
393
+ router.handle('worktree.remove', async (p) => {
394
+ const cwd = resolveGitCwd(p, STARTUP_CWD);
395
+ const wtPath = assertSafeRelPath(p?.path, 'path');
396
+ const args = ['worktree', 'remove'];
397
+ if (p?.force) args.push('--force');
398
+ args.push(wtPath);
399
+ try {
400
+ const { stdout, stderr } = await execFileP('git', args, { cwd });
401
+ return { ok: true, output: (stdout || stderr || '').trim() };
402
+ } catch (e) { err(500, (e.stderr || e.message || 'git worktree remove failed').trim()); }
403
+ });
404
+
284
405
  router.handle('auth.configs', () => getProviderConfigs());
285
406
 
286
407
  router.handle('auth.save', (p) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.1090",
3
+ "version": "1.0.1092",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "electron/main.js",
@@ -43,6 +43,10 @@ const state = {
43
43
  activeTimer: null,
44
44
  eventsLimit: 300, // how many of the most-recent events to render; grows via "load older"
45
45
  files: { path: '', segments: [], entries: [], roots: [], loading: false, error: null, preview: null, sort: 'name', sortDir: 'asc', filter: '' },
46
+ // Minimal git/worktree panel state. The richer visual component
47
+ // (GitDiffView/WorktreeSwitcher) lives in anentrypoint-design; this is a
48
+ // plain list/text render until the vendored SDK bundle picks those up.
49
+ git: { loading: false, error: null, diff: '', commits: [], worktrees: [], file: '' },
46
50
  };
47
51
 
48
52
  // Two-step arm controls auto-reset after this delay so an accidental first click
@@ -641,6 +645,8 @@ function view() {
641
645
  crumbLeaf = 'files';
642
646
  } else if (state.tab === 'live') {
643
647
  crumbLeaf = 'live · ' + ((state.active && state.active.length) || 0);
648
+ } else if (state.tab === 'git') {
649
+ crumbLeaf = 'git';
644
650
  } else if (state.tab === 'settings') {
645
651
  // Same word as the rail item - location chrome must not fork vocabulary.
646
652
  crumbLeaf = 'settings';
@@ -725,6 +731,7 @@ function workspaceRail() {
725
731
  { key: 'history', label: 'History', icon: 'thread', active: state.tab === 'history', onClick: () => navTo('history') },
726
732
  { key: 'files', label: 'Files', icon: 'folder', active: state.tab === 'files', onClick: () => navTo('files') },
727
733
  { key: 'live', label: 'Live', icon: 'activity', active: state.tab === 'live', count: liveCount || null, rail: liveHasError ? 'flame' : undefined, onClick: () => navTo('live') },
734
+ { key: 'git', label: 'Git', icon: 'branch', active: state.tab === 'git', onClick: () => navTo('git') },
728
735
  { key: 'settings', label: 'Settings', icon: 'settings', active: state.tab === 'settings', onClick: () => navTo('settings') },
729
736
  ];
730
737
  return WorkspaceRail({
@@ -928,9 +935,76 @@ function mainContent() {
928
935
  if (state.tab === 'history') return historyMain();
929
936
  if (state.tab === 'files') return filesMain();
930
937
  if (state.tab === 'live') return liveMain();
938
+ if (state.tab === 'git') return gitMain();
931
939
  return settingsMain();
932
940
  }
933
941
 
942
+ // --- git (diff/log + worktree list) ---
943
+ // Minimal panel: loads git.log + worktree.list on entry, git.diff on demand.
944
+ // A richer visual component (GitDiffView/WorktreeSwitcher) is being built in
945
+ // anentrypoint-design in parallel; swap this for those exports once the
946
+ // vendored SDK bundle (site/app/vendor/anentrypoint-design) is rebuilt to
947
+ // include them - it does not yet (checked: no GitDiffView/WorktreeSwitcher
948
+ // in the vendored 247420.js as of this change).
949
+ async function loadGitPanel() {
950
+ state.git.loading = true; state.git.error = null; render();
951
+ try {
952
+ const [commits, worktrees] = await Promise.all([
953
+ B.gitLog(state.backend, { limit: 20 }),
954
+ B.worktreeList(state.backend, {}),
955
+ ]);
956
+ state.git.commits = commits;
957
+ state.git.worktrees = worktrees;
958
+ } catch (e) {
959
+ state.git.error = e.message || 'Could not load git status.';
960
+ }
961
+ state.git.loading = false; render();
962
+ }
963
+
964
+ async function loadGitDiff(file) {
965
+ state.git.diffLoading = true; state.git.diffError = null; render();
966
+ try {
967
+ const { diff } = await B.gitDiff(state.backend, { file: file || undefined });
968
+ state.git.diff = diff || '(no changes)';
969
+ state.git.file = file || '';
970
+ } catch (e) {
971
+ state.git.diffError = e.message || 'Could not load diff.';
972
+ }
973
+ state.git.diffLoading = false; render();
974
+ }
975
+
976
+ function gitMain() {
977
+ if (!state.git.loading && !state.git.commits.length && !state.git.worktrees.length && !state.git.error) loadGitPanel();
978
+ const g = state.git;
979
+ return [
980
+ PageHeader({ compact: true, dense: true, title: 'Git', lede: 'Working-tree diff, recent commits, and worktrees.' }),
981
+ g.error ? Alert({ key: 'git-err', kind: 'error', title: 'Git error', children: g.error }) : null,
982
+ Row({ key: 'git-actions', children: [
983
+ Btn({ key: 'refresh', onClick: () => loadGitPanel(), children: g.loading ? 'loading…' : 'refresh' }),
984
+ Btn({ key: 'diff-all', onClick: () => loadGitDiff(''), children: g.diffLoading ? 'loading diff…' : 'load full diff' }),
985
+ ] }),
986
+ Panel({ id: 'git-diff', title: g.file ? ('diff: ' + g.file) : 'diff', children:
987
+ g.diffError ? h('p', { key: 'de', class: 't-meta field-error' }, g.diffError)
988
+ : h('pre', { key: 'dp', class: 'git-diff-pre' }, g.diff || '(no diff loaded)') }),
989
+ Panel({ id: 'git-log', title: 'recent commits', children:
990
+ !g.commits.length ? h('p', { key: 'nc', class: 't-meta' }, g.loading ? 'loading…' : 'no commits')
991
+ : h('ul', { key: 'cl', class: 'git-commit-list' }, g.commits.map(c =>
992
+ h('li', { key: c.hash }, [
993
+ h('code', { key: 'h' }, (c.hash || '').slice(0, 8)),
994
+ ' ' + (c.subject || ''),
995
+ h('span', { key: 'm', class: 't-meta' }, ' — ' + (c.author || '') + ' · ' + (c.date || '')),
996
+ Btn({ key: 'view', small: true, onClick: () => loadGitDiff(''), children: 'view' }),
997
+ ]))) }),
998
+ Panel({ id: 'git-worktrees', title: 'worktrees', children:
999
+ !g.worktrees.length ? h('p', { key: 'nw', class: 't-meta' }, g.loading ? 'loading…' : 'no worktrees')
1000
+ : h('ul', { key: 'wl', class: 'git-worktree-list' }, g.worktrees.map((w, i) =>
1001
+ h('li', { key: w.path || i }, [
1002
+ h('code', { key: 'p' }, w.path || ''),
1003
+ ' ' + (w.branch || (w.detached ? '(detached)' : '')),
1004
+ ]))) }),
1005
+ ];
1006
+ }
1007
+
934
1008
  // --- files (folder browser) ---
935
1009
  async function loadDir(dirPath, { fromHash = false } = {}) {
936
1010
  state.files = state.files || {};
@@ -428,6 +428,30 @@ export async function listAgentModels(base, agentId) {
428
428
  } catch { return []; }
429
429
  }
430
430
 
431
+ // ---------- Git / worktrees (WS) ----------
432
+
433
+ export async function gitDiff(base, { cwd, file, staged } = {}) {
434
+ return wsCall(base, 'git.diff', { cwd, file, staged });
435
+ }
436
+
437
+ export async function gitLog(base, { cwd, limit } = {}) {
438
+ const { commits } = await wsCall(base, 'git.log', { cwd, limit });
439
+ return commits || [];
440
+ }
441
+
442
+ export async function worktreeList(base, { cwd } = {}) {
443
+ const { worktrees } = await wsCall(base, 'worktree.list', { cwd });
444
+ return worktrees || [];
445
+ }
446
+
447
+ export async function worktreeCreate(base, { cwd, path, branch, newBranch } = {}) {
448
+ return wsCall(base, 'worktree.create', { cwd, path, branch, newBranch });
449
+ }
450
+
451
+ export async function worktreeRemove(base, { cwd, path, force } = {}) {
452
+ return wsCall(base, 'worktree.remove', { cwd, path, force });
453
+ }
454
+
431
455
  // ---------- Streaming chat (WS) ----------
432
456
  //
433
457
  // Yields events of shape:
@@ -1252,6 +1252,25 @@
1252
1252
  .ds-247420 .chip.tone-orange { background: color-mix(in oklab, var(--flame) 30%, var(--sun) 70%); color: var(--ink); }
1253
1253
  .ds-247420 .chip.tone-yellow { background: var(--sun); color: var(--ink); }
1254
1254
 
1255
+ /* Removable chip — a trailing dismiss (x) button for editable tag/filter
1256
+ chip lists (session tags, saved-search chips). Reuses the chip's own
1257
+ tone/size; the button itself stays neutral-at-rest, per the standing
1258
+ color-reserved-for-meaning convention, tinting only on hover/focus. */
1259
+ .ds-247420 .chip.ds-chip-removable { padding-right: var(--space-1); }
1260
+ .ds-247420 .ds-chip-remove-btn {
1261
+ display: inline-flex; align-items: center; justify-content: center;
1262
+ width: 16px; height: 16px; margin-left: var(--space-half);
1263
+ padding: 0; border: none; border-radius: var(--r-pill);
1264
+ background: transparent; color: currentColor; opacity: 0.7;
1265
+ cursor: pointer;
1266
+ }
1267
+ .ds-247420 .ds-chip-remove-btn:hover, .ds-247420 .ds-chip-remove-btn:focus-visible { opacity: 1; background: color-mix(in oklab, currentColor 16%, transparent); }
1268
+ .ds-247420 .ds-chip-remove-btn svg { width: 10px; height: 10px; }
1269
+ @media (pointer: coarse) {
1270
+ .ds-247420 .ds-chip-remove-btn { width: 24px; height: 24px; margin-left: 0; }
1271
+ .ds-247420 .ds-chip-remove-btn svg { width: 12px; height: 12px; }
1272
+ }
1273
+
1255
1274
  /* ds-pill — plain neutral chip, distinct from the toned .ds-badge/.chip family
1256
1275
  (gmsniff's .pill: a bare rounded label with no semantic tone). */
1257
1276
  .ds-247420 .ds-pill {
@@ -5475,6 +5494,118 @@
5475
5494
  }
5476
5495
  .ds-247420 .ds-sheet-preview-error-action:hover { background: var(--bg-2); }
5477
5496
  .ds-247420 .ds-sheet-preview-error-action:focus-visible { outline: var(--focus-w) solid var(--focus-color); outline-offset: 2px; }
5497
+ /* ============================================================
5498
+ Git status panel + diff view — changed-file list, unified diff.
5499
+ ============================================================ */
5500
+
5501
+ .ds-247420 .ds-git-status-grid {
5502
+ display: flex; flex-direction: column; gap: 2px;
5503
+ }
5504
+ .ds-247420 .ds-git-row {
5505
+ display: flex; align-items: center; gap: 10px;
5506
+ width: 100%; padding: 7px 12px;
5507
+ background: none; border: var(--bw-hair) solid transparent; border-radius: var(--r-2);
5508
+ color: var(--fg); font: inherit; text-align: left; cursor: pointer;
5509
+ transition: background var(--dur-snap) var(--ease), border-color var(--dur-snap) var(--ease);
5510
+ }
5511
+ .ds-247420 .ds-git-row:hover { background: var(--bg-2); border-color: var(--rule); }
5512
+ .ds-247420 .ds-git-row.active { background: var(--accent-tint); border-color: var(--accent); }
5513
+ .ds-247420 .ds-git-row:focus-visible { outline: var(--focus-w) solid var(--focus-color); outline-offset: var(--focus-offset); }
5514
+ .ds-247420 .ds-git-row[disabled] { cursor: default; }
5515
+
5516
+ .ds-247420 .ds-git-status-chip {
5517
+ display: inline-flex; align-items: center; justify-content: center;
5518
+ flex: 0 0 auto; width: 18px; height: 18px;
5519
+ font-family: var(--ff-mono); font-size: var(--fs-micro); font-weight: 700;
5520
+ border-radius: var(--r-1);
5521
+ }
5522
+ .ds-247420 .ds-git-status-chip.tone-add { color: var(--green-2); background: color-mix(in oklab, var(--green-2) 16%, transparent); }
5523
+ .ds-247420 .ds-git-status-chip.tone-modify { color: var(--sun); background: color-mix(in oklab, var(--sun) 16%, transparent); }
5524
+ .ds-247420 .ds-git-status-chip.tone-delete { color: var(--flame); background: color-mix(in oklab, var(--flame) 16%, transparent); }
5525
+ .ds-247420 .ds-git-status-chip.tone-neutral { color: var(--fg-3); background: var(--bg-2); }
5526
+
5527
+ .ds-247420 .ds-git-row-icon { display: inline-flex; flex: 0 0 auto; color: var(--fg-3); }
5528
+ .ds-247420 .ds-git-row-path {
5529
+ flex: 1 1 auto; min-width: 0;
5530
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
5531
+ font-family: var(--ff-mono); font-size: var(--fs-sm);
5532
+ }
5533
+ .ds-247420 .ds-git-row-tag {
5534
+ flex: 0 0 auto; font-size: var(--fs-micro); color: var(--fg-3);
5535
+ border: var(--bw-hair) solid var(--rule); border-radius: var(--r-1); padding: 1px 6px;
5536
+ }
5537
+ .ds-247420 .ds-git-row-stats { flex: 0 0 auto; display: inline-flex; gap: 6px; font-family: var(--ff-mono); font-size: var(--fs-micro); }
5538
+ .ds-247420 .ds-git-stat-add { color: var(--green-2); }
5539
+ .ds-247420 .ds-git-stat-del { color: var(--flame); }
5540
+
5541
+ .ds-247420 .ds-git-empty {
5542
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
5543
+ gap: var(--space-2); padding: var(--space-6) var(--space-3);
5544
+ color: var(--fg-3); text-align: center;
5545
+ }
5546
+ .ds-247420 .ds-git-empty-glyph { opacity: 0.55; }
5547
+
5548
+ /* Diff view */
5549
+ .ds-247420 .ds-git-diff {
5550
+ display: flex; flex-direction: column; gap: var(--space-2);
5551
+ background: var(--ink); color: var(--paper);
5552
+ border-radius: var(--r-2); overflow: hidden;
5553
+ }
5554
+ .ds-247420 .ds-git-diff-head {
5555
+ padding: var(--space-2) var(--space-3);
5556
+ border-bottom: var(--bw-hair) solid color-mix(in oklab, var(--paper) 18%, transparent);
5557
+ font-family: var(--ff-mono); font-size: var(--fs-xs);
5558
+ }
5559
+ .ds-247420 .ds-git-diff-empty { padding: var(--space-3); color: var(--fg-3); font-size: var(--fs-sm); }
5560
+ .ds-247420 .ds-git-hunk + .ds-git-hunk { border-top: var(--bw-hair) solid color-mix(in oklab, var(--paper) 12%, transparent); }
5561
+ .ds-247420 .ds-git-hunk-header {
5562
+ padding: 4px var(--space-3);
5563
+ color: var(--purple-2); font-family: var(--ff-mono); font-size: var(--fs-micro);
5564
+ background: color-mix(in oklab, var(--paper) 6%, transparent);
5565
+ }
5566
+ .ds-247420 .ds-git-hunk-body {
5567
+ margin: 0; padding: 0 0 var(--space-2);
5568
+ font-family: var(--ff-mono); font-size: var(--fs-xs); line-height: var(--lh-base);
5569
+ white-space: pre; overflow-x: auto; tab-size: 2;
5570
+ }
5571
+ .ds-247420 .ds-git-line {
5572
+ display: block; padding: 0 var(--space-3);
5573
+ }
5574
+ .ds-247420 .ds-git-line-add { background: color-mix(in oklab, var(--green-2) 16%, transparent); color: var(--paper); }
5575
+ .ds-247420 .ds-git-line-del { background: color-mix(in oklab, var(--flame) 16%, transparent); color: var(--paper); }
5576
+ .ds-247420 .ds-git-line-context { color: var(--paper-3); }
5577
+
5578
+ /* ============================================================
5579
+ WorktreeSwitcher — trigger + dropdown menu of worktrees/branches.
5580
+ ============================================================ */
5581
+ .ds-247420 .ds-wts { display: inline-flex; }
5582
+ .ds-247420 .ds-wts-trigger {
5583
+ display: inline-flex; align-items: center; gap: 8px;
5584
+ padding: 5px 10px; min-height: 32px;
5585
+ background: var(--bg); border: var(--bw-hair) solid var(--rule); border-radius: var(--r-1);
5586
+ color: var(--fg); font: inherit; font-size: var(--fs-sm); cursor: pointer;
5587
+ max-width: 220px;
5588
+ }
5589
+ .ds-247420 .ds-wts-trigger:hover { background: var(--bg-2); }
5590
+ .ds-247420 .ds-wts-trigger:focus-visible { outline: var(--focus-w) solid var(--focus-color); outline-offset: var(--focus-offset); }
5591
+ .ds-247420 .ds-wts-trigger-branch {
5592
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
5593
+ font-family: var(--ff-mono);
5594
+ }
5595
+ .ds-247420 .ds-wts-trigger-caret { display: inline-flex; flex: 0 0 auto; color: var(--fg-3); }
5596
+
5597
+ .ds-247420 .ds-wts-item-body { display: flex; align-items: center; gap: 8px; min-width: 0; }
5598
+ .ds-247420 .ds-wts-item-check { display: inline-flex; flex: 0 0 16px; color: var(--accent-ink); }
5599
+ .ds-247420 .ds-wts-item-text { display: flex; flex-direction: column; min-width: 0; gap: 1px; }
5600
+ .ds-247420 .ds-wts-item-branch {
5601
+ font-family: var(--ff-mono); font-size: var(--fs-sm);
5602
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
5603
+ }
5604
+ .ds-247420 .ds-wts-item-path {
5605
+ font-size: var(--fs-micro); color: var(--fg-3);
5606
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
5607
+ }
5608
+ .ds-247420 .ds-dropdown-item[aria-disabled="true"] .ds-wts-item-branch { color: var(--fg); font-weight: 600; }
5478
5609
 
5479
5610
  /* community.css */
5480
5611
  /* ============================================================