dsh-git-badge 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kevin McIsaac
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,22 @@
1
+ # dsh-git-badge
2
+
3
+ Git status badges for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness):
4
+
5
+ - **Input-row chip** β€” `🟒 main` next to the access picker, showing the git state of the workspace the current conversation is attached to. Works on every install.
6
+ - **Sidebar row badges** β€” full Claude-Code-statusline-style badges (`| 🟑 main ↑0 ↓2 ✎3`) on workspace rows. Requires the `sidebar.workspaces.row` seam; without it the plugin degrades to chip-only and logs what's active to the browser console.
7
+
8
+ Event-driven freshness: working trees are watched and updates push over SSE β€” badges flip within ~1s of any commit, stage, or edit. One `git status --porcelain=v2` call per sample; the API only answers registered workspace paths.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ dsh plugin --profile web add dsh-git-badge
14
+ ```
15
+
16
+ Restart the web process, refresh the browser. Requires Node β‰₯ 20 and `git` on PATH.
17
+
18
+ Seam details: [github.com/Kevin-McIsaac/dsh-workspace-git-badge](https://github.com/Kevin-McIsaac/dsh-workspace-git-badge) (SEAM.md / PR.md).
19
+
20
+ ## License
21
+
22
+ MIT
@@ -0,0 +1,4 @@
1
+ # dsh-git-badge bundle patch: mount the node half into the host plugin tree.
2
+ - insert:
3
+ - id: git-badge
4
+ name: 'dsh-git-badge'
package/lib/client.js ADDED
@@ -0,0 +1,253 @@
1
+ /**
2
+ * dsh-git-badge β€” client half.
3
+ *
4
+ * Surfaces:
5
+ * - sidebar.workspaces.row (seam): full row badge β€” emoji, branch, sync, ✎files
6
+ * - conversation.input.left (upstream): compact chip β€” emoji + branch only
7
+ * The hover card is intentionally untouched.
8
+ */
9
+ window.__ModuleLoader__.load({
10
+ id: "dsh-git-badge",
11
+ factory: (require) => {
12
+ var module = { exports: {} };
13
+ var exports = module.exports;
14
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
15
+ let react_jsx_runtime = require("react/jsx-runtime");
16
+ let react = require("react");
17
+
18
+ /** Module-level git-status cache. Entries are invalidated by SSE events, never by age. */
19
+ const GIT_CACHE = new Map();
20
+ /** In-flight fetches per cacheKey: bursts collapse into one request. */
21
+ const GIT_INFLIGHT = new Map();
22
+ /** Slow safety-net poll: refreshes even if the SSE stream is silently dead. */
23
+ const FALLBACK_POLL_MS = 60000;
24
+
25
+ //#region SSE change feed (one EventSource per page, ref-counted)
26
+ const eventListeners = new Set();
27
+ let eventSource = null;
28
+ let eventRefs = 0;
29
+
30
+ function subscribeGitEvents(onChange) {
31
+ eventListeners.add(onChange);
32
+ eventRefs += 1;
33
+ if (eventSource === null) {
34
+ eventSource = new EventSource("/api/git-badge/events");
35
+ eventSource.onmessage = (message) => {
36
+ let path;
37
+ try {
38
+ path = JSON.parse(message.data).path;
39
+ } catch {
40
+ return;
41
+ }
42
+ // invalidate every cache entry for this workspace, then refetch
43
+ for (const key of [...GIT_CACHE.keys()]) {
44
+ if (key === path) GIT_CACHE.delete(key);
45
+ }
46
+ for (const fn of [...eventListeners]) {
47
+ try {
48
+ fn(path);
49
+ } catch {
50
+ /* one bad subscriber must not starve the rest */
51
+ }
52
+ }
53
+ };
54
+ }
55
+ return () => {
56
+ eventListeners.delete(onChange);
57
+ eventRefs -= 1;
58
+ if (eventRefs === 0 && eventSource !== null) {
59
+ eventSource.close();
60
+ eventSource = null;
61
+ }
62
+ };
63
+ }
64
+ //#endregion
65
+
66
+ /**
67
+ * Shared git-status hook. Fetches once on mount and then only when the
68
+ * node half's watcher reports a change for this workspace (SSE), plus a
69
+ * slow safety-net poll in case the stream dies silently. Returns
70
+ * undefined while loading and for non-git paths.
71
+ */
72
+ function useGitStatus(cwd) {
73
+ const cacheKey = cwd;
74
+ const hit = cacheKey === void 0 ? void 0 : GIT_CACHE.get(cacheKey);
75
+ const [info, setInfo] = react.useState(hit !== void 0 ? hit.data : void 0);
76
+ react.useEffect(() => {
77
+ if (cacheKey === void 0) return;
78
+ let alive = true;
79
+ const apply = (data) => {
80
+ // A degraded response (git timeout/failure) must never clobber a
81
+ // good cached badge β€” keep the last-known state until a real
82
+ // event or the fallback poll succeeds.
83
+ if (data !== null && data.error !== void 0 && GIT_CACHE.has(cacheKey)) return;
84
+ GIT_CACHE.set(cacheKey, { at: Date.now(), data });
85
+ if (alive) setInfo(data);
86
+ };
87
+ const load = () => {
88
+ // dedupe: an in-flight fetch for this key serves all callers
89
+ let pending = GIT_INFLIGHT.get(cacheKey);
90
+ if (pending === void 0) {
91
+ pending = fetch("/api/git-badge?path=" + encodeURIComponent(cwd))
92
+ .then((r) => r.json())
93
+ .finally(() => GIT_INFLIGHT.delete(cacheKey));
94
+ GIT_INFLIGHT.set(cacheKey, pending);
95
+ }
96
+ pending.then(apply).catch(() => {});
97
+ };
98
+ load();
99
+ // this workspace's watcher events β†’ refetch
100
+ const unsubscribe = subscribeGitEvents((path) => {
101
+ if (path === cwd) load();
102
+ });
103
+ const fallback = setInterval(load, FALLBACK_POLL_MS);
104
+ return () => {
105
+ alive = false;
106
+ unsubscribe();
107
+ clearInterval(fallback);
108
+ };
109
+ }, [cacheKey]);
110
+ return info;
111
+ }
112
+
113
+ /**
114
+ * Shared sync/dirty suffix for all surfaces. Rule set:
115
+ * - dirty files render as ✎n (pencil = worktree files, distinct from
116
+ * the ↑/↓ commit-sync axis)
117
+ * - while dirty, BOTH sync counts render including zeros (↑0 ↓2 ✎3),
118
+ * so every number is positionally attributable
119
+ * - clean workspaces stay quiet: arrows only when nonzero, no ✎ at 0
120
+ * - no upstream: arrows omitted entirely
121
+ */
122
+ function formatGitSuffix(info) {
123
+ const parts = [];
124
+ const hasUpstream = info.ahead !== void 0 || info.behind !== void 0;
125
+ const files = (info.changedFiles || 0) + (info.untrackedFiles || 0);
126
+ if (hasUpstream && (files > 0 || info.ahead > 0 || info.behind > 0)) {
127
+ parts.push("\u2191" + (info.ahead || 0));
128
+ parts.push("\u2193" + (info.behind || 0));
129
+ }
130
+ if (files > 0) parts.push("\u270E" + files);
131
+ return parts.length > 0 ? " " + parts.join(" ") : "";
132
+ }
133
+
134
+ const META_STYLE = {
135
+ color: "var(--dsw-alias-label-tertiary, #9ea7ad)",
136
+ fontSize: "12px",
137
+ lineHeight: "20px",
138
+ flex: "none",
139
+ whiteSpace: "nowrap"
140
+ };
141
+
142
+ /**
143
+ * Row badge, Claude Code statusline style:
144
+ * the_paragliding_app | 🟒 main ↑1 ↓2
145
+ * Always renders the workspace name (so the row keeps its identity);
146
+ * appends the muted `| emoji branch sync` part only for git workspaces.
147
+ */
148
+ function WorkspaceGitBadge({ label, cwd }) {
149
+ const info = useGitStatus(cwd, false);
150
+ const children = [react_jsx_runtime.jsx("span", { style: { minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: label })];
151
+ if (cwd !== void 0 && info !== void 0 && info.git === true) {
152
+ children.push(
153
+ react_jsx_runtime.jsx("span", { style: { ...META_STYLE, margin: "0 7px" }, children: "|" }),
154
+ react_jsx_runtime.jsx("span", { style: { ...META_STYLE, marginRight: "4px" }, children: info.dirty ? "🟑" : "🟒" }),
155
+ react_jsx_runtime.jsx("span", { style: META_STYLE, children: info.branch + formatGitSuffix(info) })
156
+ );
157
+ }
158
+ return react_jsx_runtime.jsx("span", { style: { display: "flex", alignItems: "center", minWidth: 0 }, children });
159
+ }
160
+
161
+ //#region composer chip (fallback + companion surface: conversation.composer.dock)
162
+ /** Client root ctx, captured at apply() for the chip's service lookups. */
163
+ let clientCtx = null;
164
+
165
+ /**
166
+ * Resolve the workspace cwd attached to a session: projects the
167
+ * workspaces service's snapshot store (items carry path + sessionIds)
168
+ * and re-resolves on change. Returns undefined while unknown.
169
+ */
170
+ function useSessionWorkspaceCwd(sessionId) {
171
+ const workspaces = clientCtx === null ? null : clientCtx.get("workspaces");
172
+ const resolve = () => {
173
+ if (sessionId === void 0 || workspaces === void 0) return void 0;
174
+ const item = workspaces.list.getSnapshot().items.find((w) => w.sessionIds !== void 0 && w.sessionIds.includes(sessionId));
175
+ return item === void 0 ? void 0 : item.path;
176
+ };
177
+ const [cwd, setCwd] = react.useState(resolve);
178
+ react.useEffect(() => {
179
+ if (sessionId === void 0 || workspaces === void 0) return;
180
+ setCwd(resolve());
181
+ return workspaces.list.subscribe(() => {
182
+ setCwd(resolve());
183
+ });
184
+ }, [sessionId, workspaces]);
185
+ return cwd;
186
+ }
187
+
188
+ /**
189
+ * Chip line docked at the composer: git state of the workspace the
190
+ * CURRENT conversation is attached to. Works on unpatched installs
191
+ * (conversation.composer.dock is an upstream additive slot), and stays
192
+ * useful next to the sidebar rows on patched/upstream-seam installs
193
+ * because it is context-anchored ("where am I") rather than surveying.
194
+ */
195
+ function ComposerGitChip({ sessionId }) {
196
+ const cwd = useSessionWorkspaceCwd(sessionId);
197
+ const info = useGitStatus(cwd, true);
198
+ if (cwd === void 0 || info === void 0 || info.git !== true) return null;
199
+ const text = (info.dirty ? "\uD83D\uDFE1 " : "\uD83D\uDFE2 ") + info.branch + formatGitSuffix(info);
200
+ return react_jsx_runtime.jsx("span", {
201
+ style: {
202
+ display: "inline-flex",
203
+ alignItems: "center",
204
+ flex: "none",
205
+ gap: "4px",
206
+ color: "var(--dsw-alias-label-secondary, #5b6570)",
207
+ fontSize: "12px",
208
+ lineHeight: "24px",
209
+ whiteSpace: "nowrap",
210
+ cursor: "default"
211
+ },
212
+ children: text
213
+ });
214
+ }
215
+ //#endregion
216
+
217
+ const inject = ["slots", "workspaces"];
218
+
219
+ /**
220
+ * Register the badge into both seams. The seam owner hands each entry the
221
+ * row owner share as props; the badge destructures { workspaceId, cwd,
222
+ * label } from it.
223
+ */
224
+ function apply(ctx) {
225
+ clientCtx = ctx;
226
+ // inject() re-evaluates when a seam's declaration appears, so boot
227
+ // order relative to the workspace browser does not matter. On an
228
+ // unpatched install the row seams never get declared, so those two
229
+ // registrations simply never render anything.
230
+ ctx.slots.inject("sidebar.workspaces.row", () => ctx.slots.register({
231
+ name: "sidebar.workspaces.row",
232
+ id: "git-badge"
233
+ }, WorkspaceGitBadge));
234
+ // Input-row chip: upstream additive slot rendered in the input bar's
235
+ // leading cluster, right after the access picker β€” the git state sits
236
+ // with the controls that govern the conversation. Present on every
237
+ // install. (Alternative surface if ever needed:
238
+ // conversation.input.dock = the row below the input, shared with the
239
+ // usage-stats entry.)
240
+ ctx.slots.inject("conversation.input.left", () => ctx.slots.register({
241
+ name: "conversation.input.left",
242
+ id: "git-badge-chip",
243
+ inject: (sessionId) => ({ sessionId })
244
+ }, ComposerGitChip));
245
+ const seamDeclared = ctx.slots.spec("sidebar.workspaces.row") !== void 0;
246
+ console.info("[dsh-git-badge] surfaces: input chip = on; sidebar rows = " + (seamDeclared ? "on (seam present)" : "off (seam absent β€” sidebar badges need the sidebar.workspaces.row seam)") + ".");
247
+ }
248
+
249
+ exports.apply = apply;
250
+ exports.inject = inject;
251
+ return module.exports;
252
+ }
253
+ });
package/lib/index.js ADDED
@@ -0,0 +1,295 @@
1
+ /**
2
+ * dsh-git-badge β€” node half.
3
+ *
4
+ * Hardened git-status service behind two routes:
5
+ *
6
+ * GET /api/git-badge?path=<workspace path>[&detail=1]
7
+ * - path allowlist: only directories registered as DSH workspaces
8
+ * (WorkspaceRegistry records) are ever queried β€” no filesystem probing
9
+ * of arbitrary paths.
10
+ * - one git invocation per sample: `git --no-optional-locks status
11
+ * --porcelain=v2 --branch` yields branch, ahead/behind, and the
12
+ * changed / untracked counts in a single call (`--no-optional-locks`
13
+ * guarantees the read never contends with the user's own git
14
+ * operations on the index).
15
+ * - `detail=1` adds one `log -1` for the hover card; row refresh never
16
+ * pays for it.
17
+ *
18
+ * GET /api/git-badge/events (text/event-stream)
19
+ * - pushes `{ path }` dirty notifications the moment a workspace's git
20
+ * state can have changed. Freshness is EVENT-DRIVEN: each registered
21
+ * workspace root is watched recursively (.git included), so a commit /
22
+ * checkout / stage / worktree edit pushes one SSE message and every
23
+ * mounted row refetches immediately. There is no fixed polling
24
+ * interval anywhere in the pipeline.
25
+ *
26
+ * When upstream lands a workspace-metadata service, this half is deleted and
27
+ * the client half re-points at it β€” the seam contract does not change.
28
+ */
29
+ import { execFile } from "node:child_process";
30
+ import { realpath } from "node:fs/promises";
31
+ import { watch, existsSync } from "node:fs";
32
+ import { join } from "node:path";
33
+
34
+ const inject = ["webServer", "workspaceRegistry"];
35
+ const name = "dsh-git-badge";
36
+
37
+ /**
38
+ * Run git in dir. Resolves { stdout } on success; on failure { stdout: null }
39
+ * plus exactly one of: timeout (execFile timeout kill), missing (no git
40
+ * binary), or exitCode (git ran and rejected β€” e.g. "not a repository").
41
+ * The exitCode/timeout distinction separates a definitive answer (non-repo)
42
+ * from a transient one (degraded).
43
+ */
44
+ function runGit(dir, args) {
45
+ return new Promise((resolve) => {
46
+ execFile("git", args, { cwd: dir, timeout: 3000 }, (error, stdout) => {
47
+ if (error === void 0 || error === null) resolve({ stdout: String(stdout) });
48
+ else if (error.killed) resolve({ stdout: null, timeout: true });
49
+ else if (error.code === "ENOENT") resolve({ stdout: null, missing: true });
50
+ else resolve({ stdout: null, exitCode: error.code });
51
+ });
52
+ });
53
+ }
54
+
55
+ /** Marker response for a git invocation that failed or timed out (transient). */
56
+ const GIT_DEGRADED = { git: false, error: "git unavailable (timeout or failure)" };
57
+
58
+ /**
59
+ * Parse `git status --porcelain=v2 --branch` output.
60
+ * Headers: `# branch.head <name>` (or `(detached)`), `# branch.upstream
61
+ * <name>`, `# branch.ab +ahead -behind`. Records per path: `1`/`2` carry the
62
+ * two-column XY status (X = index/staged, Y = worktree), `u` unmerged,
63
+ * `?` untracked.
64
+ */
65
+ function parseStatusV2(out) {
66
+ let branch = "HEAD (detached)";
67
+ let upstream;
68
+ let ahead;
69
+ let behind;
70
+ let staged = 0;
71
+ let unstaged = 0;
72
+ let unmerged = 0;
73
+ let untracked = 0;
74
+ for (const line of out.split("\n")) {
75
+ if (line.startsWith("# branch.head ")) branch = line.slice(14).trim();
76
+ else if (line.startsWith("# branch.upstream ")) upstream = line.slice(18).trim();
77
+ else if (line.startsWith("# branch.ab ")) {
78
+ const [, plus, minus] = line.slice(12).trim().split(" ");
79
+ const a = Number.parseInt((plus ?? "").slice(1), 10);
80
+ const b = Number.parseInt((minus ?? "").slice(1), 10);
81
+ if (Number.isFinite(a)) ahead = a;
82
+ if (Number.isFinite(b)) behind = b;
83
+ } else if (line.startsWith("1 ") || line.startsWith("2 ")) {
84
+ // XY columns: X = index (staged), Y = worktree (unstaged)
85
+ if (line[2] !== ".") staged += 1;
86
+ if (line[3] !== ".") unstaged += 1;
87
+ } else if (line.startsWith("u ")) unmerged += 1;
88
+ else if (line.startsWith("? ")) untracked += 1;
89
+ }
90
+ return { branch, upstream, ahead, behind, staged, unstaged, unmerged, untracked };
91
+ }
92
+
93
+ /**
94
+ * Git status for dir; { git: false } when dir is not a repository,
95
+ * GIT_DEGRADED on transient git failure.
96
+ *
97
+ * Subdirectory workspaces: status is computed from `--show-toplevel`, so a
98
+ * workspace pointing INSIDE a larger repository reports that repository's
99
+ * branch, dirty state, and ahead/behind counts (what git itself considers
100
+ * dirty), not the subdirectory in isolation. Intentional.
101
+ */
102
+ async function gitStatus(dir, wantDetail) {
103
+ const top = await runGit(dir, ["rev-parse", "--show-toplevel"]);
104
+ if (top.stdout === null) {
105
+ // git ran and rejected ("not a repository") is a definitive answer;
106
+ // timeout / missing binary is transient
107
+ return top.exitCode !== void 0 ? { git: false } : GIT_DEGRADED;
108
+ }
109
+ const toplevel = top.stdout.trim();
110
+ if (toplevel === "") return { git: false };
111
+ const statusOut = await runGit(toplevel, ["--no-optional-locks", "status", "--porcelain=v2", "--branch"]);
112
+ if (statusOut.stdout === null) return GIT_DEGRADED;
113
+ const parsed = parseStatusV2(statusOut.stdout);
114
+ const dirtyFiles = parsed.staged + parsed.unstaged + parsed.unmerged + parsed.untracked;
115
+ const info = {
116
+ git: true,
117
+ branch: parsed.branch,
118
+ upstream: parsed.upstream,
119
+ dirty: dirtyFiles > 0,
120
+ changedFiles: parsed.staged + parsed.unstaged,
121
+ stagedFiles: parsed.staged,
122
+ unstagedFiles: parsed.unstaged,
123
+ unmergedFiles: parsed.unmerged,
124
+ untrackedFiles: parsed.untracked,
125
+ ahead: parsed.ahead,
126
+ behind: parsed.behind
127
+ };
128
+ if (wantDetail) {
129
+ // last 3 commits (subject + relative age) for the hover card
130
+ const logOut = await runGit(toplevel, ["log", "-3", "--format=%h%x09%s%x09%cr"]);
131
+ if (logOut.stdout !== null && logOut.stdout.trim() !== "") {
132
+ info.lastCommits = logOut.stdout.trim().split("\n").map((line) => {
133
+ const [hash, subject, when] = line.split("\t");
134
+ return { hash, subject: subject ?? "", when: when ?? "" };
135
+ }).filter((c) => c.hash !== void 0);
136
+ }
137
+ // stash count, only surfaced when nonzero
138
+ const stashOut = await runGit(toplevel, ["stash", "list"]);
139
+ if (stashOut.stdout !== null) {
140
+ const count = stashOut.stdout.split("\n").filter((l) => l.trim() !== "").length;
141
+ if (count > 0) info.stashCount = count;
142
+ }
143
+ }
144
+ return info;
145
+ }
146
+
147
+ //#region git-state watcher
148
+ /**
149
+ * One recursive watcher per workspace `.git` directory. Any event under it
150
+ * (HEAD swap, index write, ref update) marks the workspace dirty; a 200ms
151
+ * debounce collapses burst events (a single `git commit` touches index,
152
+ * refs, COMMIT_EDITMSG, objects…) into one notification.
153
+ */
154
+ const watchers = new Map();
155
+ const changeListeners = new Set();
156
+ const WATCH_RETRY_MS = 60000;
157
+ const DEBOUNCE_MS = 200;
158
+
159
+ function notifyChange(key) {
160
+ for (const fn of changeListeners) {
161
+ try {
162
+ fn(key);
163
+ } catch {
164
+ /* a dead SSE subscriber must never break the others */
165
+ }
166
+ }
167
+ }
168
+
169
+ function unwatchWorkspace(key) {
170
+ const record = watchers.get(key);
171
+ if (record === void 0) return;
172
+ watchers.delete(key);
173
+ if (record.watcher !== null) record.watcher.close();
174
+ if (record.timer !== void 0) clearTimeout(record.timer);
175
+ }
176
+
177
+ function watchWorkspace(root, key) {
178
+ const existing = watchers.get(key);
179
+ if (existing !== void 0) {
180
+ // live watcher, or a failed attempt still inside its retry backoff
181
+ if (existing.watcher !== null || Date.now() - existing.failedAt < WATCH_RETRY_MS) return;
182
+ watchers.delete(key);
183
+ }
184
+ // only git workspaces need a badge; a missing .git skips the watcher but
185
+ // retries on the normal backoff (cheap existsSync) so a later `git init`
186
+ // in the workspace is picked up
187
+ if (!existsSync(join(root, ".git"))) {
188
+ watchers.set(key, { watcher: null, failedAt: Date.now(), timer: void 0 });
189
+ return;
190
+ }
191
+ try {
192
+ // Watch the whole worktree, .git included: worktree edits (the most
193
+ // common dirty signal) and metadata ops (commit / checkout / stage)
194
+ // all surface here. The 200ms debounce collapses save bursts; if the
195
+ // tree is too large for the inotify budget the error handler drops
196
+ // back to the 60s client poll.
197
+ const watcher = watch(root, { recursive: true }, () => {
198
+ const record = watchers.get(key);
199
+ if (record === void 0) return;
200
+ clearTimeout(record.timer);
201
+ record.timer = setTimeout(() => notifyChange(key), DEBOUNCE_MS);
202
+ });
203
+ watcher.on("error", () => unwatchWorkspace(key));
204
+ watchers.set(key, { watcher, timer: void 0 });
205
+ } catch {
206
+ // watch refused (permissions, watch budget) β€” remember and back off
207
+ watchers.set(key, { watcher: null, failedAt: Date.now(), timer: void 0 });
208
+ }
209
+ }
210
+
211
+ /** Reconcile the watcher set with the current workspace registry. */
212
+ function syncWatchers(ctx) {
213
+ const wanted = new Set();
214
+ for (const entity of ctx.workspaceRegistry.list()) {
215
+ const p = entity?.record?.path ?? entity?.path;
216
+ if (typeof p !== "string") continue;
217
+ wanted.add(p);
218
+ watchWorkspace(p, p);
219
+ }
220
+ for (const key of [...watchers.keys()]) if (!wanted.has(key)) unwatchWorkspace(key);
221
+ }
222
+ //#endregion
223
+
224
+ /** Host plugin body β€” register the status route and the SSE change feed. */
225
+ function apply(ctx) {
226
+ ctx.effect(() => ctx.webServer.register({
227
+ kind: "exact",
228
+ path: "/api/git-badge",
229
+ handler: async (req, res) => {
230
+ try {
231
+ const url = new URL(req.url, "http://localhost");
232
+ const raw = url.searchParams.get("path") ?? "";
233
+ if (raw === "") throw new Error("missing path");
234
+ // Allowlist: the requested path must be (or resolve to) a registered
235
+ // workspace directory. Anything else is refused without inspection.
236
+ const registered = new Set();
237
+ for (const entity of ctx.workspaceRegistry.list()) {
238
+ const p = entity?.record?.path ?? entity?.path;
239
+ if (typeof p === "string") registered.add(p);
240
+ }
241
+ const resolved = await realpath(raw).catch(() => null);
242
+ if (resolved === null || !registered.has(raw) && !registered.has(resolved)) {
243
+ res.writeHead(403, { "content-type": "application/json" });
244
+ res.end(JSON.stringify({ git: false, error: "path is not a registered workspace" }));
245
+ return;
246
+ }
247
+ const detail = url.searchParams.get("detail") === "1";
248
+ const body = JSON.stringify(await gitStatus(resolved, detail));
249
+ res.writeHead(200, { "content-type": "application/json" });
250
+ res.end(body);
251
+ } catch (error) {
252
+ res.writeHead(400, { "content-type": "application/json" });
253
+ res.end(JSON.stringify({ git: false, error: String(error?.message ?? error) }));
254
+ }
255
+ }
256
+ }));
257
+ ctx.effect(() => ctx.webServer.register({
258
+ kind: "exact",
259
+ path: "/api/git-badge/events",
260
+ handler: async (req, res) => {
261
+ // keep the watcher set aligned with the live registry on every connect
262
+ syncWatchers(ctx);
263
+ res.writeHead(200, {
264
+ "content-type": "text/event-stream",
265
+ "cache-control": "no-cache",
266
+ connection: "keep-alive"
267
+ });
268
+ // flush immediately: staged headers only hit the wire on first write,
269
+ // and the first real event may be minutes away
270
+ res.write(": connected\n\n");
271
+ const send = (key) => {
272
+ try {
273
+ res.write(`data: ${JSON.stringify({ path: key })}\n\n`);
274
+ } catch {
275
+ /* socket gone; the close handler cleans up */
276
+ }
277
+ };
278
+ changeListeners.add(send);
279
+ // comment-only heartbeat keeps proxies from idling the stream out
280
+ const heartbeat = setInterval(() => {
281
+ try {
282
+ res.write(": hb\n\n");
283
+ } catch {
284
+ /* ignore */
285
+ }
286
+ }, 25000);
287
+ req.on("close", () => {
288
+ changeListeners.delete(send);
289
+ clearInterval(heartbeat);
290
+ });
291
+ }
292
+ }));
293
+ }
294
+
295
+ export { apply, inject, name };
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "dsh-git-badge",
3
+ "version": "0.5.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.",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./client": "./lib/client.js",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "dsh": {
13
+ "bundle": {
14
+ "patch": "./cordis.patch.yml"
15
+ },
16
+ "client": {
17
+ "inject": [
18
+ "@deepseek-ai/dsh-client-runtime"
19
+ ],
20
+ "platform": "web"
21
+ }
22
+ },
23
+ "keywords": [
24
+ "dsh",
25
+ "dsh-plugin",
26
+ "dsh-bundle",
27
+ "git",
28
+ "status",
29
+ "badge",
30
+ "sidebar"
31
+ ],
32
+ "author": "Kevin McIsaac",
33
+ "license": "MIT",
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/Kevin-McIsaac/dsh-workspace-git-badge.git",
37
+ "directory": "plugin"
38
+ },
39
+ "engines": {
40
+ "node": ">=20"
41
+ },
42
+ "files": [
43
+ "lib/index.js",
44
+ "lib/client.js",
45
+ "cordis.patch.yml",
46
+ "package.json",
47
+ "README.md",
48
+ "LICENSE"
49
+ ],
50
+ "peerDependencies": {
51
+ "@deepseek-ai/cordis": ">=4",
52
+ "@deepseek-ai/dsh-client-runtime": ">=0.1",
53
+ "react": ">=18"
54
+ }
55
+ }