@desplega.ai/agent-swarm 1.79.0 → 1.79.2

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.
Files changed (46) hide show
  1. package/README.md +2 -0
  2. package/openapi.json +559 -1
  3. package/package.json +4 -4
  4. package/plugin/skills/kv-storage/SKILL.md +168 -0
  5. package/plugin/skills/pages/SKILL.md +149 -0
  6. package/src/artifact-sdk/browser-sdk.ts +292 -0
  7. package/src/be/db.ts +309 -0
  8. package/src/be/migrations/061_kv_store.sql +34 -0
  9. package/src/be/migrations/062_pages_view_count.sql +9 -0
  10. package/src/commands/provider-credentials.ts +1 -1
  11. package/src/http/index.ts +2 -0
  12. package/src/http/kv.ts +658 -0
  13. package/src/http/page-proxy.ts +5 -0
  14. package/src/http/pages-public.ts +50 -6
  15. package/src/http/status.ts +1 -1
  16. package/src/providers/claude-adapter.ts +138 -7
  17. package/src/providers/pi-mono-adapter.ts +3 -3
  18. package/src/providers/pi-mono-extension.ts +1 -1
  19. package/src/server.ts +20 -1
  20. package/src/tasks/context-key.ts +28 -0
  21. package/src/telemetry.ts +65 -1
  22. package/src/tests/claude-adapter-binary.test.ts +628 -0
  23. package/src/tests/context-key.test.ts +17 -0
  24. package/src/tests/kv-http.test.ts +331 -0
  25. package/src/tests/kv-namespace-resolution.test.ts +172 -0
  26. package/src/tests/kv-page-proxy.test.ts +212 -0
  27. package/src/tests/kv-storage.test.ts +227 -0
  28. package/src/tests/kv-tool.test.ts +217 -0
  29. package/src/tests/page-proxy.test.ts +5 -1
  30. package/src/tests/page-session.test.ts +10 -5
  31. package/src/tests/pages-authed-mode.test.ts +5 -1
  32. package/src/tests/pages-public-html.test.ts +10 -1
  33. package/src/tests/pages-view-count.test.ts +220 -0
  34. package/src/tests/swarm-diff.test.ts +303 -0
  35. package/src/tests/telemetry-init.test.ts +149 -0
  36. package/src/tools/kv/index.ts +5 -0
  37. package/src/tools/kv/kv-delete.ts +89 -0
  38. package/src/tools/kv/kv-get.ts +64 -0
  39. package/src/tools/kv/kv-incr.ts +116 -0
  40. package/src/tools/kv/kv-list.ts +81 -0
  41. package/src/tools/kv/kv-set.ts +194 -0
  42. package/src/tools/kv/resolve-namespace.ts +58 -0
  43. package/src/tools/tool-config.ts +7 -0
  44. package/src/types.ts +53 -0
  45. package/src/utils/internal-ai/complete-structured.ts +7 -10
  46. package/src/utils/internal-ai/credentials.ts +3 -3
@@ -0,0 +1,168 @@
1
+ ---
2
+ name: kv-storage
3
+ description: Use the swarm KV store (Redis-like, namespaced) for cross-task / cross-session / per-page state. Auto-scoped to your context (Slack thread / PR / Linear issue / agent / page). Use for counters, cursors, page state. Do NOT use for secrets (`swarm_config`), embedded knowledge (`memory`), or files (`agent-fs`).
4
+ ---
5
+
6
+ # KV Storage
7
+
8
+ Namespaced key/value store inside the swarm SQLite DB. Auto-scoped to your
9
+ calling context — same string used by `agent_tasks.contextKey`.
10
+
11
+ > **Capability gate**: the `kv-*` MCP tools are only available when your
12
+ > `CAPABILITIES` includes `kv` (default-on; check `my-agent-info`). The REST
13
+ > endpoints under `/api/kv/*` are always present on the API server.
14
+
15
+ ## When to use KV
16
+
17
+ | You need… | Use this | Not this |
18
+ |---|---|---|
19
+ | Count something in this Slack thread / PR / Linear issue | **KV** (auto-scoped) | memory / agent-fs |
20
+ | Save a cursor / last-seen state for a recurring schedule | **KV** | swarm_config |
21
+ | Page-internal counter / vote / state across reloads | **KV** via `swarmSdk.kv` | memory |
22
+ | Cross-task state in the same conversation | **KV** (auto-scoped to `task:slack:...`) | parentTaskId only |
23
+ | Secrets, API tokens, OAuth creds | `swarm_config` (encrypted + masked) | **NOT KV** |
24
+ | Cross-session knowledge for this agent ("how do I…") | `memory_search` / `memory-get` | **NOT KV** |
25
+ | Files, binaries, long documents | `agent-fs` | **NOT KV** |
26
+ | Workflow run state | workflow vars (own KV) | **NOT KV** |
27
+
28
+ Rule of thumb:
29
+ - If a future invocation should *find this without knowing the key* → memory.
30
+ - If a future invocation will *know exactly which key to read* → KV.
31
+ - If it has secrets in it → `swarm_config`.
32
+ - If it's bytes (image, pdf, large doc) → agent-fs.
33
+
34
+ ## Namespacing
35
+
36
+ Namespace is just a string. It mirrors the `contextKey` schema
37
+ (`src/tasks/context-key.ts`). When you don't pass one, the server resolves it
38
+ from request headers in this order:
39
+
40
+ 1. `X-Page-Id` (only the page-proxy sets this) → `task:page:<id>`
41
+ 2. `X-Source-Task-Id` → that task's `contextKey` (e.g. `task:slack:C123:1776...`)
42
+ 3. `X-Agent-ID` → `task:agent:<id>` (per-agent scratchpad)
43
+
44
+ So **inside a session triggered by a Slack thread, KV is automatically scoped
45
+ to that thread** — your sibling tasks (re-runs, retries, follow-ups in the same
46
+ thread) read the same store with no setup. Same for PRs (`task:trackers:github:owner:repo:pr:N`),
47
+ Linear issues (`task:trackers:linear:DES-42`), schedules, workflows.
48
+
49
+ You can override the namespace explicitly when you need to — see "Explicit
50
+ override" below.
51
+
52
+ ## Quick recipes
53
+
54
+ ### MCP — inside any agent session
55
+
56
+ ```
57
+ kv-set key="vote-count" value=0 valueType="integer" # → namespace = task:slack:...
58
+ kv-incr key="vote-count" # → 1
59
+ kv-incr key="vote-count" by=5 # → 6
60
+ kv-get key="vote-count" # → entry with value=6
61
+ kv-list prefix="vote-" # → all matching entries
62
+ kv-delete key="vote-count" # → done
63
+ ```
64
+
65
+ `kv-set` defaults to `valueType: 'json'` and JSON-encodes whatever you pass.
66
+ Use `'string'` to skip encoding (good for short tokens, URLs) and
67
+ `'integer'` for counters (required by `kv-incr`).
68
+
69
+ ### REST — humans, scripts, external clients
70
+
71
+ ```bash
72
+ # Header-resolved namespace (recommended for in-session calls)
73
+ curl -H "Authorization: Bearer $API_KEY" \
74
+ -H "X-Agent-ID: $AGENT_ID" \
75
+ "$MCP_BASE_URL/api/kv/last-cursor"
76
+
77
+ # Explicit namespace
78
+ curl -H "Authorization: Bearer $API_KEY" \
79
+ "$MCP_BASE_URL/api/kv/_/task:trackers:linear:DES-42/last-comment-id"
80
+
81
+ # PUT a JSON value with a 10-minute TTL
82
+ curl -X PUT -H "Authorization: Bearer $API_KEY" -H "X-Agent-ID: $AGENT_ID" \
83
+ -H "Content-Type: application/json" \
84
+ -d '{"value":{"n":42},"valueType":"json","expiresInSec":600}' \
85
+ "$MCP_BASE_URL/api/kv/snapshot"
86
+
87
+ # List with a prefix
88
+ curl -H "Authorization: Bearer $API_KEY" -H "X-Agent-ID: $AGENT_ID" \
89
+ "$MCP_BASE_URL/api/kv?prefix=daily-&limit=50"
90
+ ```
91
+
92
+ ### Pages browser SDK — inside an authed page
93
+
94
+ Page proxy forces the namespace to `task:page:<id>` — no namespace argument is
95
+ exposed. Use it for page-local counters, vote tallies, multi-step form state,
96
+ "remember this number from last refresh" UX:
97
+
98
+ ```js
99
+ // Inside a page's <script> tag
100
+ const count = await swarmSdk.kv.incr('clicks'); // → number-valued entry
101
+ await swarmSdk.kv.set('lastSeen', Date.now()); // → 'json' by default
102
+ const entry = await swarmSdk.kv.get('clicks'); // → { value, valueType, ... } or null
103
+ await swarmSdk.kv.del('clicks');
104
+ const all = await swarmSdk.kv.list({ prefix: 'click', limit: 50 });
105
+ ```
106
+
107
+ Public pages (`authMode: 'public'`) cannot reach `/@swarm/api/*` and so cannot
108
+ use KV. Promote to `authed` or `password` mode if the page needs state.
109
+
110
+ ## Explicit override
111
+
112
+ Pass `namespace` to read/write somewhere other than your auto-context:
113
+
114
+ ```
115
+ kv-get key="seed" namespace="swarm:experiments" # ad-hoc namespace
116
+ kv-set key="note" value="hi" namespace="task:agent:OTHER-AGENT-ID"
117
+ # → 403 unless caller is lead
118
+ ```
119
+
120
+ Rules:
121
+ - **Reads:** any authenticated caller can read any namespace.
122
+ - **Writes to `task:agent:<X>`** where X ≠ caller agentId: **403** unless lead.
123
+ - **Writes to `task:page:<X>`** from anywhere except a page-proxy request: **403**.
124
+ - Everything else: writable by any authenticated caller.
125
+
126
+ ## TTL & expiry
127
+
128
+ Default = **no expiry**. Opt in by passing `expiresInSec`:
129
+
130
+ ```
131
+ kv-set key="lock-token" value="xyz" valueType="string" expiresInSec=60
132
+ ```
133
+
134
+ Expiry is *lazy*: reads on an expired key return null and delete the row;
135
+ `kv-list` filters expired rows out of the SELECT but doesn't delete them
136
+ (keeps cursor pagination stable). No background sweeper — expired rows that
137
+ never get touched stay on disk harmlessly.
138
+
139
+ ## Body cap
140
+
141
+ 2 MiB per value. Over the cap returns 413. If you want to store something
142
+ larger, write it to `agent-fs` and stash the path in KV.
143
+
144
+ ## Gotchas
145
+
146
+ - **Namespaces ARE contextKey strings.** The same string that lets the swarm
147
+ find sibling tasks for a PR also indexes KV for that PR.
148
+ - **Reads return `null` for missing AND expired keys** — you can't tell the
149
+ difference from one call. (If you need to know, list the key.)
150
+ - **INCR collides** if the existing row has `valueType` `'json'` or `'string'`
151
+ (409 / `KvTypeCollisionError`). Delete and re-create as `'integer'` first,
152
+ or use a different key.
153
+ - **JSON values round-trip** through `JSON.parse` on read. If you wrote
154
+ `{a:1}`, you'll get back the object — not the raw string. Use
155
+ `valueType: 'string'` if you want byte-exact storage.
156
+ - **No CAS / SETNX yet.** Use `kv-incr` for atomic counters; for
157
+ "claim a token" patterns, set with a short TTL and re-check.
158
+ - **Page SDK has no `namespace` argument.** Pages are always scoped to
159
+ `task:page:<id>`. Don't try to encode another namespace in the key path —
160
+ the URL gets rewritten anyway.
161
+
162
+ ## See also
163
+
164
+ - `src/be/migrations/061_kv_store.sql` — schema (`kv_entries`)
165
+ - `src/http/kv.ts` — REST handler + namespace resolution
166
+ - `src/tools/kv/*` — MCP tool registrars
167
+ - `src/artifact-sdk/browser-sdk.ts` — `swarmSdk.kv` for pages
168
+ - `plugin/skills/pages/SKILL.md` — companion skill for authed pages
@@ -210,6 +210,155 @@ For the full list of fields each endpoint accepts/returns, see
210
210
  [**docs.agent-swarm.dev/docs/api-reference**](https://docs.agent-swarm.dev/docs/api-reference).
211
211
  The SDK is a thin domain wrapper — anything documented there is reachable.
212
212
 
213
+ ## Built-in primitives
214
+
215
+ Every HTML page automatically gets a small set of zero-dep web components
216
+ auto-injected alongside the Browser SDK. Drop them into your page body —
217
+ no `<script>` import, no bundling, no Tailwind required (though Tailwind
218
+ Play CDN is loaded, so utility classes work too).
219
+
220
+ ### `<swarm-diff>` — unified diff renderer
221
+
222
+ Render a unified diff with a two-column gutter, severity annotations, and a
223
+ deterministic anchor id per hunk (so deep-linking + jump lists work). The
224
+ element reads its payload from its `textContent` as JSON of shape
225
+ `{ hunks: [{ old_start, old_lines, new_start, new_lines, lines, annotations? }] }`.
226
+
227
+ ```html
228
+ <swarm-diff
229
+ file="src/foo.ts"
230
+ base-sha="abc123"
231
+ head-sha="def456">
232
+ { "hunks": [
233
+ { "old_start": 10, "old_lines": 3, "new_start": 10, "new_lines": 4,
234
+ "lines": [
235
+ { "type": "context", "text": " const x = 1;" },
236
+ { "type": "del", "text": "- console.log(x);" },
237
+ { "type": "add", "text": "+ logger.info({ x });" },
238
+ { "type": "add", "text": "+ return x;" }
239
+ ],
240
+ "annotations": [
241
+ { "line": 12, "severity": "warn", "text": "Avoid raw console.log" }
242
+ ]
243
+ }
244
+ ] }
245
+ </swarm-diff>
246
+ ```
247
+
248
+ **Inputs**
249
+
250
+ | Attribute | Required | Notes |
251
+ |---|---|---|
252
+ | `file` | yes | Path label rendered in the hunk header. Used for the anchor id slug. |
253
+ | `base-sha` | no | Pre-change SHA. Rendered in the header next to `head-sha`. |
254
+ | `head-sha` | no | Post-change SHA. |
255
+
256
+ **Line shape**
257
+
258
+ | Field | Values | Notes |
259
+ |---|---|---|
260
+ | `type` | `context` \| `add` \| `del` | Drives row tint (green / red / neutral) and gutter line numbering. |
261
+ | `text` | string | Rendered verbatim (HTML-escaped). |
262
+
263
+ **Annotation shape** — attaches to a NEW-side line by line number; rendered
264
+ as a margin badge on that row.
265
+
266
+ | Field | Values | Notes |
267
+ |---|---|---|
268
+ | `line` | integer | New-side line number (falls back to old-side if no add for that line). |
269
+ | `severity` | `error` \| `warn` \| `info` | Drives badge color. |
270
+ | `text` | string | Badge body. |
271
+
272
+ **Anchor id**
273
+
274
+ Each hunk gets `id="swarm-diff-<file-slug>-<old_start>"` for deep-linking.
275
+ Use `<swarm-diff-jumps></swarm-diff-jumps>` anywhere in the page body to
276
+ render a tiny "Jump to" navigation of every diff hunk on the page —
277
+ handy when an agent ships a multi-file annotated PR.
278
+
279
+ **Programmatic form**
280
+
281
+ If you need to render a diff from a fetch response (rather than inline JSON),
282
+ use `window.swarmUi.renderDiff(rootEl, diffData)`:
283
+
284
+ ```html
285
+ <div id="diff-target"></div>
286
+ <script>
287
+ const data = await fetch('/some/diff.json').then(r => r.json());
288
+ window.swarmUi.renderDiff(document.getElementById('diff-target'), data);
289
+ </script>
290
+ ```
291
+
292
+ **Annotated-PR example**
293
+
294
+ ```html
295
+ <!doctype html>
296
+ <html><head><title>PR #1234 — `console.log` cleanup</title></head>
297
+ <body>
298
+ <h1>PR #1234 — cleanup raw <code>console.log</code> calls</h1>
299
+ <p>Replaces ad-hoc logging with the project logger.</p>
300
+
301
+ <swarm-diff-jumps></swarm-diff-jumps>
302
+
303
+ <swarm-diff file="src/foo.ts" base-sha="abc123" head-sha="def456">
304
+ { "hunks": [
305
+ { "old_start": 10, "old_lines": 3, "new_start": 10, "new_lines": 4,
306
+ "lines": [
307
+ { "type": "context", "text": " const x = 1;" },
308
+ { "type": "del", "text": "- console.log(x);" },
309
+ { "type": "add", "text": "+ logger.info({ x });" },
310
+ { "type": "add", "text": "+ return x;" }
311
+ ],
312
+ "annotations": [
313
+ { "line": 12, "severity": "warn", "text": "Avoid raw console.log" }
314
+ ]
315
+ }
316
+ ] }
317
+ </swarm-diff>
318
+
319
+ <swarm-diff file="src/bar.ts" base-sha="abc123" head-sha="def456">
320
+ { "hunks": [
321
+ { "old_start": 5, "old_lines": 1, "new_start": 5, "new_lines": 1,
322
+ "lines": [
323
+ { "type": "del", "text": "- console.error('boom');" },
324
+ { "type": "add", "text": "+ logger.error('boom');" }
325
+ ]
326
+ }
327
+ ] }
328
+ </swarm-diff>
329
+ </body></html>
330
+ ```
331
+
332
+ ## Print / PDF export
333
+
334
+ Every HTML page also gets a `@media print` rule baked into the head defaults:
335
+ - Light theme on print (white background, black text, underlined black links).
336
+ - Anything with the `.no-print` class is hidden (annotation badges and the
337
+ jump list already carry this class — use it on agent-emitted chrome you
338
+ want suppressed in PDF exports).
339
+ - `.swarm-card` and `<swarm-diff>` get `break-inside: avoid` so they don't
340
+ split mid-element across pages.
341
+
342
+ Trigger the export from the SPA's "Export PDF" button on `/pages/:id` — it
343
+ opens the iframe's native print dialog (HTML pages) or the SPA's print
344
+ dialog (JSON pages). The browser's "Print → Save as PDF" handles the actual
345
+ file. No headless Chromium, no server-side rendering — zero infra weight.
346
+
347
+ > Want a custom print layout? Override the print styles in your page's own
348
+ > `<style>` block — agent CSS always wins over the head defaults.
349
+
350
+ ## View counter
351
+
352
+ Every successful `200` from `GET /p/:id` (HTML inline) and `GET /p/:id.json`
353
+ (JSON metadata) bumps a `view_count` field on the page. `302` (JSON pages
354
+ redirecting to the SPA), `401`/`403` (auth gate), and `404` do NOT bump.
355
+ The count is exposed on `GET /api/pages` listing (`viewCount` field) and the
356
+ SPA `/pages` index renders it as a small eye-count badge per row.
357
+
358
+ No per-viewer dedup — this is a coarse popularity signal, not analytics.
359
+ Bumps are best-effort (wrapped in try/catch so a counter write never fails
360
+ the response).
361
+
213
362
  ## JSON Renderer
214
363
 
215
364
  JSON pages are rendered via [`@json-render/react`](https://json-render.dev)
@@ -14,6 +14,9 @@
14
14
  // - repos list, get, create, update, delete
15
15
  // - schedules list, get, create, update, delete, run
16
16
  // - approvalRequests list, get, create, respond
17
+ // - kv get, set, del, incr, list (namespace is forced server-
18
+ // side to the page's own `task:page:<id>` — no namespace
19
+ // argument is exposed)
17
20
  //
18
21
  // Full HTTP API reference: https://docs.agent-swarm.dev/docs/api-reference
19
22
  export const BROWSER_SDK_JS = `
@@ -103,6 +106,22 @@ class SwarmSDK {
103
106
  create: (body) => call('POST', '/approval-requests', body),
104
107
  respond: (id, body) => call('POST', '/approval-requests/' + enc(id) + '/respond', body),
105
108
  };
109
+
110
+ // KV store. The namespace is FORCED by the page-proxy to \`task:page:<id>\`
111
+ // (it injects X-Page-Id which the kv handler treats as highest priority).
112
+ // No namespace argument is exposed — pages cannot read/write any other
113
+ // namespace via this SDK.
114
+ this.kv = {
115
+ get: (key) => call('GET', '/kv/' + enc(key)),
116
+ set: (key, value, opts) => call('PUT', '/kv/' + enc(key), {
117
+ value,
118
+ valueType: opts && opts.valueType,
119
+ expiresInSec: opts && opts.expiresInSec,
120
+ }),
121
+ del: (key) => call('DELETE', '/kv/' + enc(key)),
122
+ incr: (key, by) => call('POST', '/kv/' + enc(key) + '/incr', { by: by == null ? 1 : by }),
123
+ list: (opts) => call('GET', '/kv' + qs(opts)),
124
+ };
106
125
  }
107
126
  }
108
127
 
@@ -112,3 +131,276 @@ class SwarmSDK {
112
131
  window.SwarmSDK = SwarmSDK;
113
132
  window.swarmSdk = new SwarmSDK();
114
133
  `;
134
+
135
+ // ─── UI primitives ──────────────────────────────────────────────────────────
136
+ //
137
+ // Auto-injected alongside the SDK. Exposes a tiny set of declarative web
138
+ // components agents can drop into HTML pages without bundling anything. v1:
139
+ // only \`<swarm-diff>\` (unified-diff renderer) + \`<swarm-diff-jumps>\` (a
140
+ // sibling-anchor jump list). All zero-dep, pure DOM — Tailwind utility
141
+ // classes are used freely since the Play CDN is already loaded by
142
+ // PAGE_HEAD_DEFAULTS, but every visual aspect has inline-style fallbacks so
143
+ // the component is still legible if Tailwind fails to load.
144
+
145
+ /**
146
+ * Renders a unified diff as a two-column-gutter HTML table inside a
147
+ * `<swarm-diff>` custom element. Reads `file`, `base-sha`, `head-sha`
148
+ * attributes and parses the element's text content as JSON of shape
149
+ * `{ hunks: [{ old_start, old_lines, new_start, new_lines, lines:
150
+ * [{ type: 'context' | 'add' | 'del', text }], annotations?: [{ line,
151
+ * severity, text }] }] }`. Severity ∈ `error|warn|info`. Each hunk gets a
152
+ * deterministic anchor id so deep-linking + the sibling `<swarm-diff-jumps>`
153
+ * component works.
154
+ *
155
+ * Pure JS, no deps. Tailwind utility classes are sprinkled in but every
156
+ * critical visual property has an inline-style fallback.
157
+ */
158
+ export const SWARM_UI_JS = `
159
+ (function() {
160
+ if (typeof window === 'undefined' || !window.customElements) return;
161
+ if (window.customElements.get('swarm-diff')) return;
162
+
163
+ var SEV_COLOR = {
164
+ error: '#ef4444',
165
+ warn: '#f59e0b',
166
+ info: '#3b82f6',
167
+ };
168
+
169
+ function esc(s) {
170
+ return String(s == null ? '' : s)
171
+ .replace(/&/g, '&amp;')
172
+ .replace(/</g, '&lt;')
173
+ .replace(/>/g, '&gt;')
174
+ .replace(/"/g, '&quot;')
175
+ .replace(/'/g, '&#39;');
176
+ }
177
+
178
+ function slugifyAttr(s) {
179
+ return String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
180
+ }
181
+
182
+ function parseHunks(jsonText) {
183
+ var trimmed = (jsonText || '').trim();
184
+ if (!trimmed) return [];
185
+ try {
186
+ var parsed = JSON.parse(trimmed);
187
+ if (parsed && Array.isArray(parsed.hunks)) return parsed.hunks;
188
+ if (Array.isArray(parsed)) return parsed;
189
+ return [];
190
+ } catch (e) {
191
+ console.warn('[swarm-diff] failed to parse JSON body:', e);
192
+ return [];
193
+ }
194
+ }
195
+
196
+ function renderAnnotation(ann) {
197
+ var color = SEV_COLOR[ann && ann.severity] || SEV_COLOR.info;
198
+ return (
199
+ '<span class="swarm-diff-annot no-print" '
200
+ + 'style="display:inline-block;margin-left:8px;padding:1px 6px;'
201
+ + 'border-radius:4px;font-size:11px;font-weight:600;'
202
+ + 'background:' + color + '22;color:' + color + ';border:1px solid ' + color + '55;">'
203
+ + esc((ann && ann.severity ? ann.severity.toUpperCase() : 'INFO')) + ' · ' + esc(ann && ann.text || '')
204
+ + '</span>'
205
+ );
206
+ }
207
+
208
+ function renderHunk(hunk, hunkIdx, file) {
209
+ var oldLines = hunk.old_lines || 0;
210
+ var newLines = hunk.new_lines || 0;
211
+ var oldStart = hunk.old_start || 0;
212
+ var newStart = hunk.new_start || 0;
213
+ var lines = Array.isArray(hunk.lines) ? hunk.lines : [];
214
+ var annotations = Array.isArray(hunk.annotations) ? hunk.annotations : [];
215
+ // Index annotations by new-side line number for fast lookup per row.
216
+ var annByLine = {};
217
+ for (var i = 0; i < annotations.length; i++) {
218
+ var a = annotations[i];
219
+ if (a && typeof a.line === 'number') {
220
+ if (!annByLine[a.line]) annByLine[a.line] = [];
221
+ annByLine[a.line].push(a);
222
+ }
223
+ }
224
+
225
+ var rowsHtml = '';
226
+ var oldN = oldStart;
227
+ var newN = newStart;
228
+ for (var j = 0; j < lines.length; j++) {
229
+ var line = lines[j] || {};
230
+ var type = line.type || 'context';
231
+ var text = line.text == null ? '' : line.text;
232
+ var bg, oldCell, newCell, sign;
233
+ if (type === 'add') {
234
+ bg = 'rgba(34,197,94,0.10)';
235
+ oldCell = '';
236
+ newCell = String(newN++);
237
+ sign = '+';
238
+ } else if (type === 'del') {
239
+ bg = 'rgba(239,68,68,0.10)';
240
+ oldCell = String(oldN++);
241
+ newCell = '';
242
+ sign = '-';
243
+ } else {
244
+ bg = 'transparent';
245
+ oldCell = String(oldN++);
246
+ newCell = String(newN++);
247
+ sign = ' ';
248
+ }
249
+
250
+ var annHtml = '';
251
+ var anns = annByLine[Number(newCell)] || annByLine[Number(oldCell)] || [];
252
+ for (var k = 0; k < anns.length; k++) annHtml += renderAnnotation(anns[k]);
253
+
254
+ rowsHtml += (
255
+ '<tr style="background:' + bg + ';">'
256
+ + '<td class="swarm-diff-gutter" style="user-select:none;text-align:right;padding:0 8px;color:#7c8aa6;font-size:12px;width:48px;">' + esc(oldCell) + '</td>'
257
+ + '<td class="swarm-diff-gutter" style="user-select:none;text-align:right;padding:0 8px;color:#7c8aa6;font-size:12px;width:48px;">' + esc(newCell) + '</td>'
258
+ + '<td class="swarm-diff-sign" style="user-select:none;text-align:center;padding:0 4px;color:#7c8aa6;font-size:12px;width:18px;">' + esc(sign) + '</td>'
259
+ + '<td class="swarm-diff-code" style="padding:0 8px;white-space:pre-wrap;word-break:break-word;font-family:\\'Space Mono\\',ui-monospace,monospace;font-size:12px;">' + esc(text) + annHtml + '</td>'
260
+ + '</tr>'
261
+ );
262
+ }
263
+
264
+ var anchorSlug = slugifyAttr((file || 'hunk') + '-' + (oldStart || hunkIdx + 1));
265
+ var anchorId = 'swarm-diff-' + anchorSlug;
266
+ var header = (
267
+ '@@ -' + oldStart + ',' + oldLines + ' +' + newStart + ',' + newLines + ' @@'
268
+ );
269
+
270
+ return (
271
+ '<a id="' + esc(anchorId) + '" class="swarm-diff-anchor" data-hunk="' + esc(anchorSlug) + '"></a>'
272
+ + '<div class="swarm-diff-hunk-header" style="padding:6px 12px;background:rgba(124,138,166,0.10);color:#7c8aa6;font-family:\\'Space Mono\\',ui-monospace,monospace;font-size:11px;border-top:1px solid var(--swarm-border,#22304a);">'
273
+ + esc(header)
274
+ + '</div>'
275
+ + '<table class="swarm-diff-table" style="width:100%;border-collapse:collapse;table-layout:fixed;">'
276
+ + '<tbody>' + rowsHtml + '</tbody>'
277
+ + '</table>'
278
+ );
279
+ }
280
+
281
+ function renderDiff(rootEl, diffData) {
282
+ var hunks = (diffData && Array.isArray(diffData.hunks)) ? diffData.hunks : (Array.isArray(diffData) ? diffData : []);
283
+ var file = rootEl.getAttribute('file') || '';
284
+ var baseSha = rootEl.getAttribute('base-sha') || '';
285
+ var headSha = rootEl.getAttribute('head-sha') || '';
286
+
287
+ var shaLine = '';
288
+ if (baseSha || headSha) {
289
+ shaLine = '<span class="swarm-diff-sha" style="font-family:\\'Space Mono\\',ui-monospace,monospace;font-size:11px;color:#7c8aa6;">'
290
+ + esc(baseSha) + ' → ' + esc(headSha)
291
+ + '</span>';
292
+ }
293
+
294
+ var hunksHtml = '';
295
+ for (var i = 0; i < hunks.length; i++) {
296
+ hunksHtml += renderHunk(hunks[i] || {}, i, file);
297
+ }
298
+
299
+ rootEl.innerHTML = (
300
+ '<div class="swarm-diff-root" style="border:1px solid var(--swarm-border,#22304a);border-radius:8px;background:var(--swarm-card,#121826);overflow:hidden;margin:12px 0;break-inside:avoid;">'
301
+ + '<div class="swarm-diff-header" style="display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:rgba(59,130,246,0.10);border-bottom:1px solid var(--swarm-border,#22304a);">'
302
+ + '<span class="swarm-diff-file" style="font-family:\\'Space Mono\\',ui-monospace,monospace;font-size:13px;font-weight:700;color:var(--swarm-text,#e6eaf2);">' + esc(file || '(untitled)') + '</span>'
303
+ + shaLine
304
+ + '</div>'
305
+ + hunksHtml
306
+ + '</div>'
307
+ );
308
+ }
309
+
310
+ // Public function form lives on window.swarmUi so callers can render an
311
+ // arbitrary root element programmatically. The custom element below is just
312
+ // a declarative wrapper around the same render function.
313
+ window.swarmUi = window.swarmUi || {};
314
+ window.swarmUi.renderDiff = renderDiff;
315
+
316
+ // Defer the parse-and-render so the HTML parser has time to finish
317
+ // appending JSON text children. \`connectedCallback\` fires on the opening
318
+ // tag — \`this.textContent\` is empty until children parse. Without a
319
+ // defer, every declarative <swarm-diff> renders an empty header and the
320
+ // JSON text remains visible as orphan children.
321
+ //
322
+ // queueMicrotask alone is NOT enough — Chrome's streaming parser drains
323
+ // microtasks between chunks, so the microtask can run BEFORE the JSON
324
+ // child is appended. We need to wait for the parser to finish the current
325
+ // document load, then read textContent.
326
+ //
327
+ // * \`document.readyState === 'loading'\` ⇒ parser still streaming →
328
+ // wait for DOMContentLoaded (fires after all children are parsed).
329
+ // * otherwise (element was created/inserted dynamically post-load) ⇒
330
+ // queueMicrotask is fine — DOM is stable, just give the caller a tick.
331
+ //
332
+ // Re-entrancy (element moved/reconnected) re-fires connectedCallback so
333
+ // we re-render against current textContent.
334
+ class SwarmDiffElement extends HTMLElement {
335
+ connectedCallback() {
336
+ var self = this;
337
+ var doRender = function() {
338
+ if (!self.isConnected) return;
339
+ var raw = self.textContent || '';
340
+ renderDiff(self, { hunks: parseHunks(raw) });
341
+ // Notify <swarm-diff-jumps> instances so they can pick up new anchors.
342
+ self.dispatchEvent(new CustomEvent('swarm-diff:rendered', { bubbles: true }));
343
+ };
344
+ if (typeof document !== 'undefined' && document.readyState === 'loading') {
345
+ document.addEventListener('DOMContentLoaded', doRender, { once: true });
346
+ } else {
347
+ queueMicrotask(doRender);
348
+ }
349
+ }
350
+ }
351
+ window.customElements.define('swarm-diff', SwarmDiffElement);
352
+
353
+ // Sibling-anchor jump list. Walks subsequent siblings, finds every
354
+ // <swarm-diff data-hunk=...> anchor, and renders a small list of links.
355
+ //
356
+ // Same parse-order hazard as <swarm-diff>: <swarm-diff-jumps> usually
357
+ // appears in the document BEFORE the <swarm-diff> elements it indexes, so
358
+ // we also defer to a microtask AND re-render whenever a <swarm-diff> in the
359
+ // document finishes rendering its anchors.
360
+ class SwarmDiffJumpsElement extends HTMLElement {
361
+ connectedCallback() {
362
+ var self = this;
363
+ var renderJumps = function() {
364
+ var anchors = document.querySelectorAll('.swarm-diff-anchor[data-hunk]');
365
+ if (!anchors.length) {
366
+ self.innerHTML = '<span class="no-print" style="color:#7c8aa6;font-size:12px;">No hunks yet.</span>';
367
+ return;
368
+ }
369
+ var items = '';
370
+ for (var i = 0; i < anchors.length; i++) {
371
+ var a = anchors[i];
372
+ var slug = a.getAttribute('data-hunk') || ('hunk-' + i);
373
+ // Hunk title = nearest preceding diff's file attribute if available.
374
+ var diff = a.closest && a.closest('swarm-diff');
375
+ var file = (diff && diff.getAttribute('file')) || slug;
376
+ items += '<li style="margin:0;padding:2px 0;"><a href="#' + esc(a.id) + '" style="color:#3b82f6;text-decoration:none;font-family:\\'Space Mono\\',ui-monospace,monospace;font-size:12px;">' + esc(file) + '</a></li>';
377
+ }
378
+ self.innerHTML = (
379
+ '<nav class="swarm-diff-jumps no-print" style="padding:8px 12px;border:1px dashed var(--swarm-border,#22304a);border-radius:8px;background:rgba(124,138,166,0.05);">'
380
+ + '<div style="font-size:10px;text-transform:uppercase;letter-spacing:0.08em;color:#7c8aa6;margin-bottom:4px;">Jump to</div>'
381
+ + '<ul style="list-style:none;padding:0;margin:0;">' + items + '</ul>'
382
+ + '</nav>'
383
+ );
384
+ };
385
+ // Wait for the parser to finish initial load before first query —
386
+ // <swarm-diff> elements also defer to DOMContentLoaded so we must
387
+ // run AFTER they finish rendering their anchors. The event listener
388
+ // below handles the live-update case.
389
+ if (typeof document !== 'undefined' && document.readyState === 'loading') {
390
+ document.addEventListener('DOMContentLoaded', function() { queueMicrotask(renderJumps); }, { once: true });
391
+ } else {
392
+ queueMicrotask(renderJumps);
393
+ }
394
+ // Re-render whenever a sibling <swarm-diff> finishes its async render.
395
+ self._onDiffRendered = function() { renderJumps(); };
396
+ document.addEventListener('swarm-diff:rendered', self._onDiffRendered);
397
+ }
398
+ disconnectedCallback() {
399
+ if (this._onDiffRendered) {
400
+ document.removeEventListener('swarm-diff:rendered', this._onDiffRendered);
401
+ }
402
+ }
403
+ }
404
+ window.customElements.define('swarm-diff-jumps', SwarmDiffJumpsElement);
405
+ })();
406
+ `;