dsh-git-badge 0.8.0 → 0.9.1

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/lib/index.js CHANGED
@@ -89,6 +89,15 @@ const config = {
89
89
  fetchTtlMs: 60000,
90
90
  /** fs-event burst collapse window */
91
91
  debounceMs: 200,
92
+ /**
93
+ * Coalescing window for identical status reads. 0 (the default) collapses only
94
+ * CONCURRENT identical reads — which is exactly what a burst of sidebar
95
+ * session rows mounting together produces — and leaves sequential reads
96
+ * honest, so a caller that mutates a repository and asks again always sees the
97
+ * new state. Raise it to also absorb serial bursts, at the cost of serving a
98
+ * change up to that many milliseconds late.
99
+ */
100
+ statusCacheMs: 0,
92
101
  /** backoff before re-attempting a failed watcher (or a missing .git) */
93
102
  watchRetryMs: 60000,
94
103
  /**
@@ -126,7 +135,22 @@ const config = {
126
135
  * one so every degradation path (no gh, logged out, no PR, garbage output,
127
136
  * timeout) is covered with no gh, no network and no forge.
128
137
  */
129
- prRunner: null
138
+ prRunner: null,
139
+ /**
140
+ * Bound on the repository-wide open-PR read (`gh pr list`). Only one page is
141
+ * read, so a repository with more open PRs than this simply has the excess
142
+ * invisible to worktree selection; the checkout's own PR read is unaffected.
143
+ */
144
+ prListLimit: 50,
145
+ /**
146
+ * Worktree-aware status: "auto" lets a SESSION-targeted badge follow the one
147
+ * linked worktree whose branch has an open PR, so a conversation doing its
148
+ * work in a worktree reports that tree instead of the main checkout its
149
+ * directory names; "off" always reports the session's own directory. A
150
+ * workspace-targeted row never follows a worktree whatever this is set to — a
151
+ * row surveys the checkout the registry owns. See effectiveTarget.
152
+ */
153
+ worktreeStatus: "auto"
130
154
  };
131
155
 
132
156
  /**
@@ -305,6 +329,13 @@ function summarizePr(json) {
305
329
  // omitted, not present-and-undefined: absence is the contract for "nothing to
306
330
  // say" throughout this half, so a consumer tests the key, never the value
307
331
  if (typeof json.reviewDecision === "string" && json.reviewDecision !== "") pr.review = json.reviewDecision;
332
+ // GitHub's OWN merge verdict (CLEAN / BLOCKED / BEHIND / DIRTY / DRAFT /
333
+ // UNSTABLE / HAS_HOOKS / UNKNOWN), passed through rather than re-derived here:
334
+ // whether a PR is mergeable depends on branch protection and required reviews,
335
+ // which is the forge's business and not this plugin's to interpret. `gh pr list`
336
+ // does not ask for it, so a worktree-candidate entry simply has no `mergeState`
337
+ // — absence is the contract, as everywhere else in this half.
338
+ if (typeof json.mergeStateStatus === "string" && json.mergeStateStatus !== "") pr.mergeState = json.mergeStateStatus.toUpperCase();
308
339
  // The chip links the token to the PR, so `url` is the one field that becomes an
309
340
  // `href`: only http(s) is emitted, and anything else is omitted like every
310
341
  // other "nothing to say" field, so a payload value cannot reach an anchor as a
@@ -329,7 +360,7 @@ async function readPrStatus(toplevel, branch) {
329
360
  // a detached HEAD is not a branch `gh` can resolve a PR for
330
361
  if (typeof branch !== "string" || branch === "" || branch.startsWith("HEAD")) return void 0;
331
362
  if (!(await originIsGitHub(toplevel))) return void 0;
332
- const out = await runPrCli("gh", ["pr", "view", branch, "--json", "number,state,isDraft,reviewDecision,statusCheckRollup,url"], {
363
+ const out = await runPrCli("gh", ["pr", "view", branch, "--json", "number,state,isDraft,reviewDecision,statusCheckRollup,url,mergeStateStatus"], {
333
364
  cwd: toplevel,
334
365
  timeout: config.prTimeoutMs,
335
366
  // GH_PROMPT_DISABLED: an auth prompt must never hang the refresh (the gh
@@ -348,16 +379,121 @@ async function readPrStatus(toplevel, branch) {
348
379
  return summarizePr(json);
349
380
  }
350
381
 
382
+ /** toplevel -> { lastAttemptAt, inFlight, value: Map<branch, pr> }. */
383
+ const prListState = new Map();
384
+
385
+ /**
386
+ * Every OPEN pull request in `toplevel`'s repository, keyed by its head branch.
387
+ * ONE `gh pr list` answers the whole repository, which is what makes worktree
388
+ * selection affordable: N worktrees cost one forge call, not N.
389
+ *
390
+ * Degrades exactly like readPrStatus, and for the same reason: a non-array body,
391
+ * unparseable output, a logged-out `gh`, a non-GitHub `origin` and a timeout all
392
+ * resolve an empty map. "No PRs" and "no forge" are the same answer here — both
393
+ * mean no worktree can be shown to be the one the session is working in.
394
+ */
395
+ async function readOpenPrs(toplevel) {
396
+ if (!(await originIsGitHub(toplevel))) return new Map();
397
+ const out = await runPrCli("gh", ["pr", "list", "--state", "open", "--limit", String(config.prListLimit), "--json", "number,headRefName,state,isDraft,reviewDecision,statusCheckRollup,url"], {
398
+ cwd: toplevel,
399
+ timeout: config.prTimeoutMs,
400
+ env: { GH_PROMPT_DISABLED: "1", GH_NO_UPDATE_NOTIFIER: "1", GH_PAGER: "cat", NO_COLOR: "1" }
401
+ });
402
+ if (out.stdout === null) return new Map();
403
+ let json;
404
+ try {
405
+ json = JSON.parse(out.stdout);
406
+ } catch {
407
+ return new Map();
408
+ }
409
+ if (!Array.isArray(json)) return new Map();
410
+ const byBranch = new Map();
411
+ for (const row of json) {
412
+ const branch = row?.headRefName;
413
+ if (typeof branch !== "string" || branch === "") continue;
414
+ const pr = summarizePr(row);
415
+ if (pr !== void 0) byBranch.set(branch, pr);
416
+ }
417
+ return byBranch;
418
+ }
419
+
420
+ /** Order-insensitive identity for an open-PR set, so "did it change?" is exact. */
421
+ function prListKey(byBranch) {
422
+ if (byBranch === void 0) return "";
423
+ return JSON.stringify([...byBranch].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
424
+ }
425
+
426
+ /**
427
+ * Open PRs for toplevel: the value to serve NOW (possibly undefined), spawning an
428
+ * out-of-band refresh when the TTL has lapsed — the same shape and the same
429
+ * reasoning as prStatusFor, because the forge is never route latency.
430
+ *
431
+ * `notify` fires only when the set CHANGES: a changed set can change which
432
+ * worktree a session badge follows, so the client has to be told to refetch
433
+ * rather than waiting for its 60s safety-net poll.
434
+ */
435
+ function openPrsFor(toplevel, notify) {
436
+ if (config.prStatus === "off") return void 0;
437
+ const now = Date.now();
438
+ const state = prListState.get(toplevel);
439
+ if (state !== void 0) {
440
+ // Fresh, or a refresh is already running: serve what we have either way.
441
+ if (now - state.lastAttemptAt < config.prTtlMs || state.inFlight !== null) return state.value;
442
+ }
443
+ const record = { lastAttemptAt: now, inFlight: null, value: state?.value };
444
+ const inFlight = (async () => {
445
+ const value = await readOpenPrs(toplevel).catch(() => void 0);
446
+ if (value !== void 0) {
447
+ const changed = prListKey(record.value) !== prListKey(value);
448
+ record.value = value;
449
+ if (changed) notify(toplevel);
450
+ }
451
+ record.lastAttemptAt = Date.now();
452
+ record.inFlight = null;
453
+ return value;
454
+ })();
455
+ record.inFlight = inFlight;
456
+ prListState.set(toplevel, record);
457
+ return record.value;
458
+ }
459
+
460
+ /**
461
+ * The one linked worktree a session badge may follow: exactly one entry other
462
+ * than the checkout itself that is checked out on a branch with an OPEN PR.
463
+ *
464
+ * Exactly one, because the plugin cannot know which tree a session uses — DSH
465
+ * records no session→worktree link at all — so an ambiguous repository stays on
466
+ * its own checkout rather than guessing. A second open PR is a reason to say
467
+ * nothing, not a reason to pick one. Detached, bare and prunable entries are
468
+ * never candidates: they have no branch a PR could belong to.
469
+ */
470
+ function selectSessionWorktree(worktrees, openPrs, checkoutPath) {
471
+ if (openPrs === void 0 || openPrs.size === 0) return void 0;
472
+ const candidates = worktrees.filter((wt) =>
473
+ wt.path !== checkoutPath &&
474
+ wt.detached !== true &&
475
+ wt.bare !== true &&
476
+ wt.prunable !== true &&
477
+ typeof wt.branch === "string" &&
478
+ openPrs.has(wt.branch)
479
+ );
480
+ return candidates.length === 1 ? candidates[0] : void 0;
481
+ }
482
+
351
483
  /**
352
484
  * PR state for toplevel: the value to serve NOW (possibly undefined, possibly
353
485
  * stale), spawning an out-of-band refresh when the TTL has lapsed. `notify` is
354
486
  * called only when the refreshed value CHANGES, so an unchanged answer costs one
355
487
  * forge call per TTL and zero SSE traffic.
488
+ *
489
+ * Keyed by BRANCH as well as toplevel: a checkout inside the TTL window would
490
+ * otherwise be served the PR of the branch it was on when the window opened.
356
491
  */
357
492
  function prStatusFor(toplevel, branch, notify) {
358
493
  if (config.prStatus === "off") return void 0;
359
494
  const now = Date.now();
360
- const state = prState.get(toplevel);
495
+ const key = toplevel + "\u0000" + branch;
496
+ const state = prState.get(key);
361
497
  if (state !== void 0) {
362
498
  // Fresh, or a refresh is already running: serve what we have either way.
363
499
  if (now - state.lastAttemptAt < config.prTtlMs || state.inFlight !== null) return state.value;
@@ -373,7 +509,7 @@ function prStatusFor(toplevel, branch, notify) {
373
509
  return value;
374
510
  })();
375
511
  record.inFlight = inFlight;
376
- prState.set(toplevel, record);
512
+ prState.set(key, record);
377
513
  return record.value;
378
514
  }
379
515
  //#endregion
@@ -414,6 +550,86 @@ function parseStatusV2(out) {
414
550
  return { branch, upstream, ahead, behind, staged, unstaged, unmerged, untracked };
415
551
  }
416
552
 
553
+ /**
554
+ * Parse `git worktree list --porcelain`. Blocks of `key value` lines separated by
555
+ * a blank line: `worktree <path>`, `HEAD <sha>`, `branch refs/heads/<name>` (the
556
+ * line is ABSENT when the entry is detached), and the flag lines `bare`, `locked`
557
+ * and `prunable`, which may carry a reason after the key.
558
+ *
559
+ * The last field is the checkout's directory NAME, which is all the badge ever
560
+ * reports — never the path (AGENTS.md rule 7). Git prints the main worktree first,
561
+ * but the caller identifies the checkout it is standing in by comparing `path`
562
+ * against its own toplevel rather than by list position.
563
+ */
564
+ function parseWorktreeList(stdout) {
565
+ const entries = [];
566
+ let entry = null;
567
+ const flush = () => {
568
+ if (entry !== null) entries.push({ ...entry, name: basename(entry.path) });
569
+ entry = null;
570
+ };
571
+ for (const raw of String(stdout ?? "").split("\n")) {
572
+ const line = raw.trim();
573
+ if (line === "") {
574
+ flush();
575
+ continue;
576
+ }
577
+ const at = line.indexOf(" ");
578
+ const key = at === -1 ? line : line.slice(0, at);
579
+ const value = at === -1 ? "" : line.slice(at + 1);
580
+ if (key === "worktree") {
581
+ flush();
582
+ entry = { path: value, head: "", branch: void 0, detached: false, bare: false, locked: false, prunable: false };
583
+ continue;
584
+ }
585
+ if (entry === null) continue;
586
+ if (key === "HEAD") entry.head = value;
587
+ else if (key === "branch") entry.branch = value.replace(/^refs\/heads\//, "");
588
+ else if (key === "detached") entry.detached = true;
589
+ else if (key === "bare") entry.bare = true;
590
+ else if (key === "locked") entry.locked = true;
591
+ else if (key === "prunable") entry.prunable = true;
592
+ }
593
+ flush();
594
+ return entries;
595
+ }
596
+
597
+ /**
598
+ * The worktree landscape of the repository `dir` sits in: its toplevel in GIT's
599
+ * own spelling (so it compares exactly against the listed paths, with no
600
+ * spelling guesswork) and every worktree of that repository, main one included.
601
+ *
602
+ * Two cheap local invocations. A failure of either degrades to "no linked
603
+ * worktrees", which leaves the badge on the directory the session actually names
604
+ * rather than substituting a guess.
605
+ */
606
+ async function readWorktrees(dir) {
607
+ const top = await runGit(dir, ["rev-parse", "--show-toplevel"]);
608
+ const toplevel = top.stdout === null ? "" : top.stdout.trim();
609
+ if (toplevel === "") return { toplevel: "", worktrees: [] };
610
+ const list = await runGit(dir, ["worktree", "list", "--porcelain"]);
611
+ return { toplevel, worktrees: list.stdout === null ? [] : parseWorktreeList(list.stdout) };
612
+ }
613
+
614
+ /** In-flight worktree probes, so a burst of callers shares one pair of calls. */
615
+ const worktreeInFlight = new Map();
616
+
617
+ /**
618
+ * `readWorktrees` with identical CONCURRENT calls collapsed, the same way
619
+ * `gitStatus` collapses status reads: every session row of one workspace probes
620
+ * the same directory at the same moment. Deliberately no TTL — the probe is two
621
+ * local git calls, and a worktree created a moment ago must be visible at once.
622
+ */
623
+ function worktreesFor(dir) {
624
+ const pending = worktreeInFlight.get(dir);
625
+ if (pending !== void 0) return pending;
626
+ const run = readWorktrees(dir).finally(() => {
627
+ worktreeInFlight.delete(dir);
628
+ });
629
+ worktreeInFlight.set(dir, run);
630
+ return run;
631
+ }
632
+
417
633
  /**
418
634
  * In-progress operation markers, in precedence order. Git drops one of these in
419
635
  * the per-worktree git dir while an operation is paused; the first match names
@@ -464,7 +680,7 @@ function operationMarker(gitDir) {
464
680
  * so neither is paid for by a sidebar row refresh: a row needs status and
465
681
  * identity, nothing more.
466
682
  */
467
- async function gitStatus(dir, wantDetail, wantPr) {
683
+ async function gitStatusUncached(dir, wantDetail, wantPr) {
468
684
  // one invocation answers both questions: the repository root (so a
469
685
  // subdirectory workspace reports the whole repo) and the per-worktree git
470
686
  // dir (where the operation markers above live)
@@ -573,6 +789,42 @@ async function gitStatus(dir, wantDetail, wantPr) {
573
789
  return info;
574
790
  }
575
791
 
792
+ /** In-flight status reads, so N callers asking at once share one `git status`. */
793
+ const statusInFlight = new Map();
794
+ /** Completed reads inside `config.statusCacheMs`; unused at the 0 default. */
795
+ const statusCache = new Map();
796
+
797
+ /**
798
+ * Status for dir, with identical CONCURRENT reads collapsed into one. That is the
799
+ * shape a burst of sidebar session rows produces when they mount together: N rows
800
+ * of one workspace resolve to the same directory, and without this they would run
801
+ * N `git status` walks of the same tree at the same instant.
802
+ *
803
+ * The optional `statusCacheMs` window extends the collapse to serial bursts. It
804
+ * defaults to 0 on purpose: a cache that outlives the call would let a caller
805
+ * that mutates a repository and asks again read a stale answer, and correctness
806
+ * of a *status* badge outranks saving a walk.
807
+ */
808
+ function gitStatus(dir, wantDetail, wantPr) {
809
+ const key = dir + "\u0000" + (wantDetail === true) + "\u0000" + (wantPr === true);
810
+ if (config.statusCacheMs > 0) {
811
+ const hit = statusCache.get(key);
812
+ if (hit !== void 0 && Date.now() - hit.at < config.statusCacheMs) return Promise.resolve(hit.value);
813
+ }
814
+ const pending = statusInFlight.get(key);
815
+ if (pending !== void 0) return pending;
816
+ const run = gitStatusUncached(dir, wantDetail, wantPr)
817
+ .then((value) => {
818
+ if (config.statusCacheMs > 0) statusCache.set(key, { at: Date.now(), value });
819
+ return value;
820
+ })
821
+ .finally(() => {
822
+ statusInFlight.delete(key);
823
+ });
824
+ statusInFlight.set(key, run);
825
+ return run;
826
+ }
827
+
576
828
  //#region git-state watcher
577
829
  /**
578
830
  * One recursive watcher per workspace, plus a second one when the git dir lives
@@ -806,6 +1058,49 @@ function watchWorkspace(root, key, workspaceId) {
806
1058
  }
807
1059
  }
808
1060
 
1061
+ /**
1062
+ * Workspace path -> { worktreePath, workspaceId, owned } for the inferred
1063
+ * worktree a session badge is currently following. One per workspace: selection
1064
+ * is a single choice by construction (selectSessionWorktree), so following a
1065
+ * different tree retires the previous one here.
1066
+ */
1067
+ const selectedWorktrees = new Map();
1068
+
1069
+ /**
1070
+ * Keep the watcher set aligned with the worktree a session badge has been told to
1071
+ * follow.
1072
+ *
1073
+ * A selected worktree is usually NOT a registered workspace, so nothing else
1074
+ * watches it — and freshness would then depend on the owning checkout's recursive
1075
+ * watch happening to cover it (true for a worktree nested inside the repository,
1076
+ * false for a sibling directory). Its events must also carry the OWNING workspace
1077
+ * id, because that is what a session-targeted client matches its events against:
1078
+ * `notifyChange` reads the id off the watcher record for the path it watched.
1079
+ *
1080
+ * `owned` records whether this call created the watcher. A worktree can itself be
1081
+ * a registered workspace (someone may have opened one as a workspace), and
1082
+ * retiring the selection must never tear down a watcher the registry owns.
1083
+ *
1084
+ * Idempotent and cheap: called on every session request, and a no-op while the
1085
+ * selection is unchanged. `chosenPath === undefined` retires whatever was
1086
+ * selected — the "the worktree is gone, or its PR closed" path.
1087
+ */
1088
+ function selectWorktreeWatch(ownerPath, workspaceId, chosenPath) {
1089
+ const previous = selectedWorktrees.get(ownerPath);
1090
+ if (previous !== void 0 && previous.worktreePath === chosenPath) {
1091
+ previous.workspaceId = workspaceId;
1092
+ return;
1093
+ }
1094
+ if (previous !== void 0) {
1095
+ if (previous.owned) unwatchWorkspace(previous.worktreePath);
1096
+ selectedWorktrees.delete(ownerPath);
1097
+ }
1098
+ if (chosenPath === undefined) return;
1099
+ const owned = !watchers.has(chosenPath);
1100
+ watchWorkspace(chosenPath, chosenPath, workspaceId);
1101
+ selectedWorktrees.set(ownerPath, { worktreePath: chosenPath, workspaceId, owned });
1102
+ }
1103
+
809
1104
  /** Reconcile the watcher set with the current workspace registry. */
810
1105
  function syncWatchers(ctx) {
811
1106
  const wanted = new Set();
@@ -815,6 +1110,17 @@ function syncWatchers(ctx) {
815
1110
  wanted.add(entity.path);
816
1111
  watchWorkspace(entity.path, entity.path, entity.id === void 0 ? void 0 : String(entity.id));
817
1112
  }
1113
+ // An inferred worktree is watched because a session request selected it, not
1114
+ // because the registry owns it. Keep the ones whose owner is still registered
1115
+ // and retire the rest, so this reconcile never tears down a live selection.
1116
+ for (const [ownerPath, entry] of selectedWorktrees) {
1117
+ if (!wanted.has(ownerPath)) {
1118
+ if (entry.owned) unwatchWorkspace(entry.worktreePath);
1119
+ selectedWorktrees.delete(ownerPath);
1120
+ continue;
1121
+ }
1122
+ wanted.add(entry.worktreePath);
1123
+ }
818
1124
  for (const key of [...watchers.keys()]) if (!wanted.has(key)) unwatchWorkspace(key);
819
1125
  }
820
1126
  //#endregion
@@ -870,6 +1176,46 @@ async function resolveWorkspace(ctx, params) {
870
1176
  return { status: 404, error: { code: "session-not-found", message: "no registered workspace owns that session" } };
871
1177
  }
872
1178
 
1179
+ /**
1180
+ * The directory a request is ABOUT.
1181
+ *
1182
+ * A SESSION-targeted chip may follow the one linked worktree its repository can be
1183
+ * shown to be working in (selectSessionWorktree): DSH records no session→worktree
1184
+ * link at all — session cwd is immutable creation metadata and `attachSession`
1185
+ * requires it to equal the workspace path — so a conversation started in the main
1186
+ * checkout would otherwise report `main` while its work, and its pull request,
1187
+ * live in a tree. The tree's watcher is reconciled HERE, because this is the only
1188
+ * place that knows which tree was chosen.
1189
+ *
1190
+ * A WORKSPACE-targeted row never follows a worktree: a row surveys the checkout
1191
+ * the registry owns, and substituting a tree would make the row lie about the
1192
+ * branch it is on.
1193
+ *
1194
+ * Gated three ways so the common case costs nothing: the caller must name a
1195
+ * session, must be asking about PR state at all (selection is defined by an open
1196
+ * PR, so a caller not asking cannot benefit), and the repository must actually
1197
+ * have a linked worktree. Only then is the forge consulted.
1198
+ */
1199
+ async function effectiveTarget(target, bySession, wantPr, notify) {
1200
+ if (bySession !== true || wantPr !== true || config.worktreeStatus === "off") return { id: target.id, path: target.path };
1201
+ const { toplevel, worktrees } = await worktreesFor(target.path);
1202
+ // Git lists the main worktree first, so `main.path === toplevel` is the exact
1203
+ // test for "the session's own directory is the main checkout". A session whose
1204
+ // directory IS a linked worktree is its own answer and is never swapped
1205
+ // sideways onto a sibling.
1206
+ const main = worktrees[0];
1207
+ if (toplevel === "" || main === void 0 || main.path !== toplevel || worktrees.length <= 1) {
1208
+ selectWorktreeWatch(target.path, target.id, void 0);
1209
+ return { id: target.id, path: target.path };
1210
+ }
1211
+ const openPrs = openPrsFor(toplevel, notify);
1212
+ const chosen = selectSessionWorktree(worktrees, openPrs, toplevel);
1213
+ selectWorktreeWatch(target.path, target.id, chosen === void 0 ? void 0 : chosen.path);
1214
+ if (chosen === void 0) return { id: target.id, path: target.path };
1215
+ // The name and branch travel; the PATH never does (AGENTS.md rule 7).
1216
+ return { id: target.id, path: chosen.path, worktree: { name: chosen.name, branch: chosen.branch } };
1217
+ }
1218
+
873
1219
  /** Host plugin body — register the status route and the SSE change feed. */
874
1220
  function apply(ctx) {
875
1221
  ctx.effect(() => ctx.webServer.register({
@@ -886,13 +1232,43 @@ function apply(ctx) {
886
1232
  }
887
1233
  const detail = url.searchParams.get("detail") === "1";
888
1234
  const pr = url.searchParams.get("pr") === "1";
889
- const info = await gitStatus(target.path, detail, pr);
1235
+ // WHO asked decides whether a linked worktree may stand in for the
1236
+ // session's own directory — see effectiveTarget. A workspace-targeted
1237
+ // row never leaves the checkout the registry owns.
1238
+ const session = url.searchParams.get("session");
1239
+ const effective = await effectiveTarget(target, session !== null && session !== "", pr, notifyChange);
1240
+ const info = await gitStatus(effective.path, detail, pr);
1241
+ const body = { ...info, workspace: target.id };
1242
+ if (effective.worktree !== void 0 && info.git === true) {
1243
+ // The status describes a worktree the SESSION's directory does not
1244
+ // name. Saying so is the whole honesty of the feature: the chip's
1245
+ // mark says "worktree", its name says which, and this flag tells the
1246
+ // card why the checkout below differs from the branch beside it.
1247
+ body.worktreeInferred = true;
1248
+ if (detail) {
1249
+ // The session's OWN directory, for the card's `checkout` row, so the
1250
+ // main checkout stays visible once the badge follows a tree. Gated
1251
+ // by detail=1 (a pointer resting on the chip), so nothing at rest
1252
+ // pays for the second read.
1253
+ const own = await gitStatus(target.path, false, false);
1254
+ if (own.git === true) {
1255
+ body.checkout = {
1256
+ branch: own.branch,
1257
+ dirty: own.dirty,
1258
+ changedFiles: own.changedFiles,
1259
+ untrackedFiles: own.untrackedFiles,
1260
+ ahead: own.ahead,
1261
+ behind: own.behind
1262
+ };
1263
+ }
1264
+ }
1265
+ }
890
1266
  // Echo the resolved workspace id. SSE events identify the workspace,
891
1267
  // not the session, so a session-targeted client has no other way to
892
1268
  // tell whether an event belongs to it — without this the input chip
893
1269
  // only ever refreshed on remount or the 60s poll.
894
1270
  res.writeHead(200, { "content-type": "application/json" });
895
- res.end(JSON.stringify({ ...info, workspace: target.id }));
1271
+ res.end(JSON.stringify(body));
896
1272
  } catch (error) {
897
1273
  res.writeHead(400, { "content-type": "application/json" });
898
1274
  res.end(JSON.stringify({ git: false, error: { code: "bad-request", message: String(error?.message ?? error) } }));
@@ -968,15 +1344,27 @@ export {
968
1344
  operationMarker,
969
1345
  outerGitDir,
970
1346
  parseStatusV2,
1347
+ parseWorktreeList,
1348
+ readWorktrees,
1349
+ worktreesFor,
971
1350
  runCli,
972
1351
  runGit,
973
1352
  gitStatus,
1353
+ gitStatusUncached,
974
1354
  resolveWorkspace,
1355
+ effectiveTarget,
975
1356
  readPrStatus,
976
1357
  summarizePr,
977
1358
  summarizeChecks,
978
1359
  prStatusFor,
979
1360
  prState,
1361
+ readOpenPrs,
1362
+ openPrsFor,
1363
+ prListState,
1364
+ selectSessionWorktree,
1365
+ selectWorktreeWatch,
1366
+ selectedWorktrees,
1367
+ statusInFlight,
980
1368
  watchWorkspace,
981
1369
  unwatchWorkspace,
982
1370
  syncWatchers,
package/package.json CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "dsh-git-badge",
3
- "version": "0.8.0",
4
- "description": "Git status badges for DeepSeek Harness: sidebar workspace rows (via the sidebar.workspaces.row seam) and an input-row chip showing the current conversation's workspace git state. Event-driven freshness over SSE.",
3
+ "version": "0.9.1",
4
+ "description": "Git status badges for DeepSeek Harness: sidebar session rows (via the sidebar.workspaces.sessionRow seam) and an input-row chip showing the current conversation's workspace git state. Event-driven freshness over SSE.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
+ "bin": {
8
+ "dsh-git-badge-seam": "seam/apply.js"
9
+ },
7
10
  "exports": {
8
11
  ".": "./lib/index.js",
9
12
  "./client": "./lib/client.js",
@@ -45,15 +48,13 @@
45
48
  "files": [
46
49
  "lib/index.js",
47
50
  "lib/client.js",
51
+ "seam/apply.js",
52
+ "seam/anchors.js",
53
+ "seam/stub-index.js",
48
54
  "cordis.patch.yml",
49
55
  "package.json",
50
56
  "README.md",
51
57
  "CHANGELOG.md",
52
58
  "LICENSE"
53
- ],
54
- "peerDependencies": {
55
- "@deepseek-ai/cordis": ">=4",
56
- "@deepseek-ai/dsh-client-runtime": ">=0.1",
57
- "react": ">=18"
58
- }
59
+ ]
59
60
  }