@mahekara28/context-relay 0.0.0-stage → 0.3.0-beta.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.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +128 -2
  3. package/README.python.md +246 -0
  4. package/dist/adapters/capture.d.ts +4 -0
  5. package/dist/adapters/capture.js +286 -0
  6. package/dist/adapters/capture.js.map +1 -0
  7. package/dist/adapters/config.d.ts +11 -0
  8. package/dist/adapters/config.js +139 -0
  9. package/dist/adapters/config.js.map +1 -0
  10. package/dist/adapters/manager.d.ts +57 -0
  11. package/dist/adapters/manager.js +220 -0
  12. package/dist/adapters/manager.js.map +1 -0
  13. package/dist/adapters/normalize.d.ts +9 -0
  14. package/dist/adapters/normalize.js +166 -0
  15. package/dist/adapters/normalize.js.map +1 -0
  16. package/dist/adapters/rpc.d.ts +12 -0
  17. package/dist/adapters/rpc.js +100 -0
  18. package/dist/adapters/rpc.js.map +1 -0
  19. package/dist/adapters/types.d.ts +59 -0
  20. package/dist/adapters/types.js +51 -0
  21. package/dist/adapters/types.js.map +1 -0
  22. package/dist/cli.d.ts +2 -0
  23. package/dist/cli.js +427 -0
  24. package/dist/cli.js.map +1 -0
  25. package/dist/client.d.ts +11 -0
  26. package/dist/client.js +136 -0
  27. package/dist/client.js.map +1 -0
  28. package/dist/delivery.d.ts +9 -0
  29. package/dist/delivery.js +35 -0
  30. package/dist/delivery.js.map +1 -0
  31. package/dist/index.d.ts +5 -0
  32. package/dist/index.js +5 -0
  33. package/dist/index.js.map +1 -0
  34. package/dist/mcp.d.ts +3 -0
  35. package/dist/mcp.js +109 -0
  36. package/dist/mcp.js.map +1 -0
  37. package/dist/model.d.ts +169 -0
  38. package/dist/model.js +79 -0
  39. package/dist/model.js.map +1 -0
  40. package/dist/proposals.d.ts +65 -0
  41. package/dist/proposals.js +134 -0
  42. package/dist/proposals.js.map +1 -0
  43. package/dist/server.d.ts +21 -0
  44. package/dist/server.js +375 -0
  45. package/dist/server.js.map +1 -0
  46. package/dist/store.d.ts +33 -0
  47. package/dist/store.js +393 -0
  48. package/dist/store.js.map +1 -0
  49. package/dist/ui/app.js +845 -0
  50. package/dist/ui/assets/THIRD_PARTY_LICENSE.txt +25 -0
  51. package/dist/ui/assets/claude.svg +1 -0
  52. package/dist/ui/assets/codex.svg +1 -0
  53. package/dist/ui/assets/goose.svg +1 -0
  54. package/dist/ui/assets/opencode.svg +1 -0
  55. package/dist/ui/assets/relay.svg +1 -0
  56. package/dist/ui/index.html +23 -0
  57. package/dist/ui/style.css +989 -0
  58. package/dist/util.d.ts +16 -0
  59. package/dist/util.js +86 -0
  60. package/dist/util.js.map +1 -0
  61. package/docs/HARNESS_CONNECTIONS.md +53 -0
  62. package/docs/NPM_QUICKSTART.md +150 -0
  63. package/docs/WORKSPACE.md +61 -0
  64. package/docs/releases/NPM_PUBLISHING.md +60 -0
  65. package/package.json +78 -4
package/dist/ui/app.js ADDED
@@ -0,0 +1,845 @@
1
+ const refKey = (r) => r.sessionId + ":" + r.eventId;
2
+ const $ = (s) => document.querySelector(s);
3
+ const escape = (value) =>
4
+ String(value ?? "").replace(
5
+ /[&<>"']/g,
6
+ (c) =>
7
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[
8
+ c
9
+ ],
10
+ );
11
+ const names = {
12
+ claude: "Claude Code",
13
+ codex: "Codex",
14
+ opencode: "OpenCode",
15
+ goose: "Goose",
16
+ manual: "Manual",
17
+ };
18
+ const icon = (h) =>
19
+ h === "manual"
20
+ ? "◈"
21
+ : `<img class="logo" src="/assets/${escape(h)}.svg" alt="">`;
22
+ const badge = (text, type = "") =>
23
+ `<span class="pill ${type}">${escape(text)}</span>`;
24
+ const num = (n) => new Intl.NumberFormat().format(n);
25
+ const lines = (s) =>
26
+ String(s ?? "")
27
+ .split("\n")
28
+ .map((x) => x.trim())
29
+ .filter(Boolean);
30
+ const state = {
31
+ view: "threads",
32
+ projects: [],
33
+ projectId: "",
34
+ sessions: [],
35
+ checkpoints: [],
36
+ handoffs: [],
37
+ connections: [],
38
+ jobs: [],
39
+ provider: {},
40
+ sessionId: "",
41
+ events: [],
42
+ selected: new Set(),
43
+ search: "",
44
+ draft: null,
45
+ merge: new Set(),
46
+ busy: false,
47
+ };
48
+ let token =
49
+ new URLSearchParams(location.hash.slice(1)).get("token") ||
50
+ sessionStorage.getItem("relay-token") ||
51
+ "";
52
+ if (location.hash) {
53
+ history.replaceState(null, "", location.pathname);
54
+ if (token) sessionStorage.setItem("relay-token", token);
55
+ }
56
+ document.documentElement.dataset.theme =
57
+ localStorage.getItem("relay-theme") ||
58
+ (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
59
+ async function api(path, body) {
60
+ const response = await fetch("/api" + path, {
61
+ method: body === undefined ? "GET" : "POST",
62
+ headers: {
63
+ Authorization: "Bearer " + token,
64
+ ...(body === undefined ? {} : { "Content-Type": "application/json" }),
65
+ },
66
+ body: body === undefined ? undefined : JSON.stringify(body),
67
+ signal: AbortSignal.timeout(45000),
68
+ });
69
+ const data = await response.json();
70
+ if (!response.ok) {
71
+ if (response.status === 401) {
72
+ token = "";
73
+ sessionStorage.removeItem("relay-token");
74
+ login();
75
+ }
76
+ throw Error(data.error?.message || "The request failed.");
77
+ }
78
+ return data;
79
+ }
80
+ let noticeTimer;
81
+ function notice(message) {
82
+ $("#notice").textContent = message;
83
+ $("#notice").classList.add("visible");
84
+ clearTimeout(noticeTimer);
85
+ noticeTimer = setTimeout(
86
+ () => $("#notice").classList.remove("visible"),
87
+ 7000,
88
+ );
89
+ }
90
+ function modal(content) {
91
+ $("#modal").innerHTML = content;
92
+ $("#modal").showModal();
93
+ }
94
+ function closeModal() {
95
+ $("#modal").close();
96
+ }
97
+ function login() {
98
+ $("#app").innerHTML =
99
+ `<main class="welcome"><div class="brand"><img src="/assets/relay.svg" alt="">Context Relay</div><h1>Your context stays with you.</h1><p class="muted">Open this workspace with <code>relay ui</code>, or enter your local workspace token.</p><form id="login-form"><label>Local access token<input name="token" type="password" required autocomplete="off"></label><p class="help">Stored in this browser tab for this local service only.</p><button class="primary" type="submit">Open workspace</button></form></main>`;
100
+ }
101
+ function currentProject() {
102
+ return state.projects.find((p) => p.id === state.projectId);
103
+ }
104
+ function currentSession() {
105
+ return state.sessions.find((s) => s.id === state.sessionId);
106
+ }
107
+ async function refresh() {
108
+ state.projects = await api("/projects");
109
+ if (!state.projects.some((p) => p.id === state.projectId))
110
+ state.projectId = state.projects[0]?.id || "";
111
+ const q = "?projectId=" + encodeURIComponent(state.projectId);
112
+ if (state.projectId) {
113
+ [state.sessions, state.checkpoints, state.connections] = await Promise.all([
114
+ api("/sessions" + q),
115
+ api("/checkpoints" + q),
116
+ api("/connections" + q),
117
+ ]);
118
+ } else {
119
+ state.sessions = [];
120
+ state.checkpoints = [];
121
+ state.connections = [];
122
+ }
123
+ [state.handoffs, state.jobs, state.provider] = await Promise.all([
124
+ api("/handoffs"),
125
+ api("/jobs"),
126
+ api("/provider"),
127
+ ]);
128
+ state.handoffs = state.handoffs.filter(
129
+ (h) => h.packet.project.id === state.projectId,
130
+ );
131
+ state.jobs = state.jobs.filter((j) =>
132
+ state.connections.some((c) => c.id === j.connectionId),
133
+ );
134
+ if (!state.sessions.some((s) => s.id === state.sessionId)) {
135
+ state.sessionId = state.sessions[0]?.id || "";
136
+ state.selected.clear();
137
+ }
138
+ state.events = state.sessionId
139
+ ? await api("/sessions/" + state.sessionId + "/events?limit=200")
140
+ : [];
141
+ render();
142
+ }
143
+ const views = [
144
+ ["threads", "≋", "Threads"],
145
+ ["review", "◈", "Review handoff"],
146
+ ["checkpoints", "▣", "Checkpoints"],
147
+ ["handoffs", "↗", "Handoffs"],
148
+ ["connections", "⌘", "Connections"],
149
+ ["architecture", "⌑", "Architecture"],
150
+ ];
151
+ function render() {
152
+ if (!token) return login();
153
+ const project = currentProject();
154
+ $("#app").innerHTML =
155
+ `<div class="shell"><aside class="sidebar"><div class="brand"><img src="/assets/relay.svg" alt="">Context Relay</div><div class="project"><label>Workspace<select id="project-select" aria-label="Project">${state.projects.map((p) => `<option value="${escape(p.id)}" ${p.id === state.projectId ? "selected" : ""}>${escape(p.name)}</option>`).join("") || "<option>No projects yet</option>"}</select></label><button class="small quiet" data-action="add-project" aria-label="Add project">+</button></div><nav class="nav" aria-label="Workspace">${views.map(([id, symbol, label]) => `<button data-view="${id}" ${state.view === id ? 'aria-current="page"' : ""}><span class="symbol" aria-hidden="true">${symbol}</span>${label}</button>`).join("")}</nav><div class="sidefooter"><div class="row"><span class="localdot"></span><span class="tagline">Local workspace</span></div><p class="tagline">Capture → review → continue.<br>Your next thread starts informed.</p><div class="row"><button class="small quiet" data-action="theme">Toggle theme</button><button class="small quiet" data-action="lock">Lock</button></div><span class="tagline mono">v0.3.0-beta.1</span></div></aside><main class="main"><header class="topbar"><div class="breadcrumb">Workspace <span>/ ${escape(project?.name || "Get started")}</span></div><div class="row">${badge("● Local only", "good")}<button class="small" data-action="refresh">Refresh</button><button class="small quiet" data-action="theme" aria-label="Toggle color theme">◐</button></div></header><div id="content" class="content" tabindex="-1">${!project ? onboarding() : { threads: threadsView, review: reviewView, checkpoints: checkpointsView, handoffs: handoffsView, connections: connectionsView, architecture: architectureView }[state.view]()}</div></main></div>`;
156
+ }
157
+ function head(kicker, title, description, actions = "") {
158
+ return `<div class="pagehead"><div><div class="eyebrow">${kicker}</div><h1>${title}</h1><p>${description}</p></div>${actions}</div>`;
159
+ }
160
+ function empty(title, text, action = "") {
161
+ return `<div class="panel empty"><span class="mark" aria-hidden="true">↗</span><h2>${title}</h2><p>${text}</p>${action}</div>`;
162
+ }
163
+ function onboarding() {
164
+ return (
165
+ head(
166
+ "A fresh start",
167
+ "Keep the work. Change the thread.",
168
+ "Connect a local project to find its coding sessions and prepare a reviewed continuation.",
169
+ ) +
170
+ empty(
171
+ "Start with a project",
172
+ "Choose a project directory. Relay captures sessions only when you request it.",
173
+ '<button class="primary" data-action="add-project">Add local project</button><p class="help">Want a guided sample? Run <code>relay demo</code> and refresh.</p>',
174
+ )
175
+ );
176
+ }
177
+ function growth() {
178
+ if (!state.events.length) return "";
179
+ let sum = 0;
180
+ const values = state.events.map((e) => (sum += e.text.length)),
181
+ max = values.at(-1) || 1;
182
+ const points = values
183
+ .map(
184
+ (v, i) =>
185
+ `${((i / Math.max(values.length - 1, 1)) * 600).toFixed(1)},${(65 - (v / max) * 55).toFixed(1)}`,
186
+ )
187
+ .join(" ");
188
+ return `<div class="growth"><div class="row between"><span class="eyebrow">Captured history growth</span><span class="help">${num(sum)} characters shown</span></div><svg viewBox="0 0 600 80" role="img" aria-label="Cumulative characters across the displayed source events" preserveAspectRatio="none"><polygon class="area" points="0,75 ${points} 600,75"></polygon><polyline class="line" points="${points}"></polyline></svg><div class="row between"><span class="help">First captured event</span><span class="help">${state.events.length} events shown · not model context usage</span></div></div>`;
189
+ }
190
+ function threadsView() {
191
+ const session = currentSession();
192
+ const visible = state.sessions.filter((s) =>
193
+ (s.title + " " + names[s.harness])
194
+ .toLowerCase()
195
+ .includes(state.search.toLowerCase()),
196
+ );
197
+ return (
198
+ head(
199
+ "01 / Your working history",
200
+ "Pick up where you left off.",
201
+ "A long thread should not become a dead end. Select the evidence your next session needs.",
202
+ '<button class="primary" data-view="connections">Connect a harness</button>',
203
+ ) +
204
+ `<div class="stats"><div class="stat"><span class="eyebrow">Captured threads</span><strong>${num(state.sessions.length)}</strong><small>Across ${new Set(state.sessions.map((s) => s.harness)).size} harnesses</small></div><div class="stat"><span class="eyebrow">Source events</span><strong>${num(state.sessions.reduce((n, s) => n + s.eventCount, 0))}</strong><small>Visible messages & tool results</small></div><div class="stat"><span class="eyebrow">Reviewed checkpoints</span><strong>${state.checkpoints.length}</strong><small>Immutable, with source references</small></div></div>` +
205
+ (state.sessions.length
206
+ ? `<div class="threadlayout"><aside class="panel threadlist" aria-label="Captured sessions"><div class="search"><label class="sr-only" for="session-search">Search threads</label><input id="session-search" type="search" placeholder="Search threads…" value="${escape(state.search)}"></div>${visible.map((s) => `<button class="thread" data-session="${s.id}" aria-pressed="${s.id === state.sessionId}"><div class="row">${icon(s.harness)}${names[s.harness]}${badge("r" + s.revision)}</div><strong>${escape(s.title)}</strong><div class="meta"><span>${s.eventCount} events</span><span>${num(s.characters)} chars</span></div></button>`).join("") || '<div class="empty">No matching threads.</div>'}</aside><section class="panel"><div class="panelhead row between"><div><div class="row">${icon(session.harness)}<h2>${escape(session.title)}</h2></div><p class="help">Captured source · revision ${session.revision}</p></div>${badge(names[session.harness])}</div>${growth()}<div class="timeline">${state.events.map((e, i) => `<article class="event"><div class="row">${badge(e.kind === "compaction" ? "compaction" : e.role, e.role === "user" ? "warn" : "")}<span class="muted mono">${String(i + 1).padStart(2, "0")}</span><label class="check"><input type="checkbox" data-evidence="${escape(e.id)}" ${state.selected.has(e.id) ? "checked" : ""}>Use as evidence</label></div>${e.text.length > 1200 ? `<details><summary>${escape(e.text.slice(0, 180))}…</summary><p>${escape(e.text)}</p></details>` : `<p>${escape(e.text)}</p>`}<div class="sourceid">source / ${escape(e.id)}</div></article>`).join("")}</div><div class="toolbar"><span class="help" id="selected-count">${state.selected.size} selected · source text stays inspectable</span><div class="row">${state.events.length < session.eventCount ? '<button data-action="more-events">Load more</button>' : ""}<button class="primary" data-action="new-draft">Review handoff →</button></div></div></section></div>`
207
+ : empty(
208
+ "No captured threads yet",
209
+ "Connect a harness, discover matching sessions, then capture the ones you want to carry forward.",
210
+ '<button class="primary" data-view="connections">Set up a connection</button>',
211
+ ))
212
+ );
213
+ }
214
+ function reviewView() {
215
+ const d = state.draft;
216
+ if (!d)
217
+ return (
218
+ head(
219
+ "02 / Make the context explicit",
220
+ "Review before you relay.",
221
+ "A checkpoint records what matters now: the goal, current instructions, evidence and remaining work.",
222
+ ) +
223
+ empty(
224
+ "Start with source evidence",
225
+ "Select events in a captured thread, or merge existing checkpoints.",
226
+ '<button class="primary" data-view="threads">Choose a thread</button><button data-view="checkpoints">Browse checkpoints</button>',
227
+ )
228
+ );
229
+ return (
230
+ head(
231
+ "02 / Review handoff",
232
+ "Carry the decisions forward.",
233
+ "Check the current instructions against their sources. Saving creates an immutable checkpoint.",
234
+ ) +
235
+ `<div class="reviewlayout"><div class="panel"><div class="section stack"><h3><span class="number">01</span>What are we continuing?</h3><label>Checkpoint title<input data-draft="title" value="${escape(d.title)}" maxlength="200"></label><label>Goal<textarea data-draft="goal">${escape(d.goal)}</textarea></label>${d.parents.length ? `<div class="banner">Combining ${d.parents.length} checkpoints. Original checkpoints and native threads remain available.</div>` : ""}</div><div class="section"><div class="row between"><h3><span class="number">02</span>Current instructions</h3><button class="small" data-action="add-instruction">+ Add instruction</button></div><p class="help">Each instruction cites its evidence. Use the latest explicit correction when sources disagree.</p>${Object.entries(
236
+ d.conflicts,
237
+ )
238
+ .map(
239
+ ([key, items]) =>
240
+ `<div class="conflict"><strong>Resolve: ${escape(key)}</strong>${items.map((item, i) => `<label class="check"><input type="radio" name="conflict-${escape(key)}" data-conflict="${escape(key)}" value="${i}" ${d.resolutions[key] === item.text ? "checked" : ""}>${escape(item.text)}</label>`).join("")}</div>`,
241
+ )
242
+ .join(
243
+ "",
244
+ )}${d.instructions.map((item, i) => `<div class="instruction"><div class="row between"><label>Instruction key<input data-instruction="${i}" data-field="key" value="${escape(item.key)}" pattern="[a-z0-9_.-]+"></label><button class="small quiet" data-remove-instruction="${i}">Remove</button></div><label>Instruction<textarea data-instruction="${i}" data-field="text">${escape(item.text)}</textarea></label><label>Source evidence<select data-instruction-source="${i}">${d.events.map((e, n) => `<option value="${n}" ${refKey(item.sources[0]) === refKey(e.ref) ? "selected" : ""}>${escape(e.role + " · " + e.text.slice(0, 90))}</option>`).join("")}</select></label><div class="cite">${item.sources.map((r) => escape(r.eventId)).join(" · ")}</div></div>`).join("") || '<p class="help">No instructions yet. Add only the constraints the next session must retain.</p>'}</div><div class="section stack"><h3><span class="number">03</span>What does done mean?</h3><label>Acceptance criteria · one per line<textarea data-draft="criteria">${escape(d.criteria.join("\n"))}</textarea></label><label>Open work · one item per line<textarea data-draft="openWork">${escape(d.openWork.join("\n"))}</textarea></label><label>Files to verify · project-relative paths, one per line<textarea data-draft="files" placeholder="src/webhooks.ts">${escape(d.files.join("\n"))}</textarea></label><p class="help">Relay records file hashes and Git HEAD. Files are referenced, not copied.</p></div><div class="section stack"><h3><span class="number">04</span>Where should this continue?</h3><label>Destination<select data-draft="recipient">${Object.entries(
245
+ names,
246
+ )
247
+ .map(
248
+ ([key, name]) =>
249
+ `<option value="${key}" ${d.recipient === key ? "selected" : ""}>${name}</option>`,
250
+ )
251
+ .join(
252
+ "",
253
+ )}</select></label><label class="check"><input type="checkbox" id="review-confirm" ${d.reviewed ? "checked" : ""}>I reviewed the instructions, source evidence and open work.</label><div class="row"><button class="primary" data-action="save-handoff">Save checkpoint & offer handoff</button><button data-action="discard-draft" class="quiet">Discard draft</button></div></div></div><aside class="stack"><div class="panel pad"><div class="row between"><h2>Evidence tray</h2>${badge(d.events.length + " sources")}</div><p class="help">Selected quotations are data, not instructions to follow automatically.</p>${d.events.map((e) => `<div class="evidencecard"><div class="row">${badge(e.role)}</div><p>${escape(e.text.slice(0, 1000))}${e.text.length > 1000 ? "…" : ""}</p><div class="mono">${escape(e.ref.eventId)}</div>${e.text.length > 1000 ? `<details><summary>Full source</summary><p>${escape(e.text)}</p></details>` : ""}</div>`).join("")}</div><div class="panel pad stack"><div><h3>Draft with Nebius</h3><p class="help">Optional. Preview the selected evidence before it leaves this computer. The response fills this editor for review.</p></div>${badge(state.provider.configured ? "Provider configured" : "Provider not configured", state.provider.configured ? "good" : "")}<button data-action="proposal-preview" ${state.provider.configured ? "" : "disabled"}>Preview evidence to send</button>${!state.provider.configured ? '<p class="help">Set NEBIUS_API_KEY and NEBIUS_MODEL in the service environment, then restart Relay. Manual review works without them.</p>' : ""}</div></aside></div>`
254
+ );
255
+ }
256
+ function checkpointsView() {
257
+ return (
258
+ head(
259
+ "03 / Durable working state",
260
+ "Checkpoints, not chat dumps.",
261
+ "Combine reviewed context without modifying the original sessions. Conflicting instructions require an explicit decision.",
262
+ `<button class="primary" data-action="merge">Merge selected (${state.merge.size})</button>`,
263
+ ) +
264
+ (state.checkpoints.length
265
+ ? `<div class="cards">${state.checkpoints.map((c) => `<article class="panel checkpoint"><div class="row between"><label class="check"><input type="checkbox" data-merge="${c.id}" ${state.merge.has(c.id) ? "checked" : ""}><strong>${escape(c.title)}</strong></label>${badge(c.sources.length + " sources")}</div><p class="summary">${escape(c.goal)}</p><div class="row between"><span class="help">${c.instructions.length} instructions · ${c.openWork.length} open items · ${c.artifacts.length} file references</span><div class="row"><button class="small" data-inspect="${c.id}">Inspect</button><button class="small" data-revise="${c.id}">Review a new revision</button></div></div></article>`).join("")}</div>`
266
+ : empty(
267
+ "No checkpoints yet",
268
+ "Build the first reviewed handoff from a captured thread.",
269
+ '<button class="primary" data-view="threads">Browse threads</button>',
270
+ ))
271
+ );
272
+ }
273
+ function handoffsView() {
274
+ return (
275
+ head(
276
+ "04 / Continue elsewhere",
277
+ "A clean start, with the context.",
278
+ "Open a new chat in the destination harness and paste its continuation prompt. MCP records acceptance when that agent claims it.",
279
+ ) +
280
+ `<div class="banner warning">Acceptance confirms delivery. It does not mean the task or its tests have passed. A Markdown export is a portable fallback and does not record acceptance.</div><div class="cards spaced">${state.handoffs.map((h) => `<article class="panel handoff"><div class="row between"><div class="route row">${icon(h.recipient)}To ${names[h.recipient]}</div>${badge(h.status, h.status === "accepted" ? "good" : h.status === "offered" ? "warn" : "")}</div><h3>${escape(h.packet.goal)}</h3><p class="help">${["claude", "codex"].includes(h.recipient) ? "Fresh terminal session: relay launch --id " + escape(h.id) + " (use the same --data-dir as this workspace)." : "Open a fresh session and paste the continuation prompt."}</p><p class="id">${escape(h.id)} · ${h.packet.instructions.length} instructions · ${h.packet.omitted.evidence} evidence omitted</p><p class="prompt">Ask ${names[h.recipient]} to inspect Context Relay handoff <strong>${escape(h.id)}</strong>, verify its sources and local state, then accept it before continuing.</p><div class="row"><button class="primary" data-copy="${h.id}">Copy continuation prompt</button><button data-export="${h.id}">Export Markdown</button><button data-freshness="${h.checkpointId}">Check freshness</button>${h.status === "offered" ? `<button class="quiet danger" data-cancel="${h.id}">Cancel offer</button>` : ""}</div></article>`).join("") || '<div class="empty"><h2>No handoffs yet</h2><p>Create a reviewed checkpoint to offer it to another session.</p></div>'}</div>`
281
+ );
282
+ }
283
+ function connectionsView() {
284
+ return (
285
+ head(
286
+ "05 / Plug in your harnesses",
287
+ "Different tools. One working context.",
288
+ "Capture reads native history. MCP gives the destination agent a project-scoped way to retrieve and accept context.",
289
+ '<button class="primary" data-action="connect">Add connection</button>',
290
+ ) +
291
+ `<div class="grid2">${state.connections.map((c) => `<article class="panel connection"><div class="row between"><div class="row">${icon(c.harness)}<strong>${names[c.harness]}</strong></div>${badge(c.capture ? "Capture enabled" : "MCP only", c.capture ? "good" : "")}</div><p class="detail">${escape(c.configPath || "Capture only · no harness settings changed")}<br>${c.lastSync ? "Last capture: " + escape(new Date(c.lastSync).toLocaleString()) : "Not captured yet"}</p>${c.lastError ? `<p class="help error">${escape(c.lastError)}</p>` : ""}<div class="row"><button class="primary" data-discover="${c.id}">Discover sessions</button><button data-command="${c.id}" class="small">MCP command</button><button class="quiet small" data-disconnect="${c.id}">Disconnect</button></div></article>`).join("") || '<div class="panel empty"><h2>No connections yet</h2><p>Choose a harness and review the project settings it will use.</p></div>'}<div class="panel pad stack"><h3>Manual import</h3><p class="help">Import a normalized JSON session from another tool. The selected project must match the file’s projectId.</p><label>Session JSON<input id="import-file" type="file" accept="application/json,.json"></label><p class="help mono">Project ID: ${escape(state.projectId)}</p></div></div><section class="panel pad stack jobs-panel"><h2>Capture jobs</h2>${
292
+ state.jobs
293
+ .slice(0, 10)
294
+ .map(
295
+ (j) =>
296
+ `<div class="job"><div class="row between"><span>${escape(names[state.connections.find((c) => c.id === j.connectionId)?.harness] || "Capture")} · ${j.completed}/${j.total} sessions</span>${badge(j.state, j.state === "completed" && !j.errors?.length ? "good" : "warn")}${j.state === "running" ? `<button class="small" data-cancel-job="${j.id}">Cancel</button>` : ""}</div><progress max="${j.total || 1}" value="${j.completed}"></progress>${j.error ? `<p class="help">${escape(j.error)}</p>` : ""}${(j.errors || []).map((e) => `<p class="help">${escape(e.externalId)}: ${escape(e.message)}</p>`).join("")}</div>`,
297
+ )
298
+ .join("") || '<p class="help">No capture jobs in this project.</p>'
299
+ }</section>`
300
+ );
301
+ }
302
+ function architectureView() {
303
+ return (
304
+ head(
305
+ "06 / How it fits together",
306
+ "One local service. Three ways in.",
307
+ "The CLI, workspace and MCP server share the same project state and handoff rules.",
308
+ ) +
309
+ `<section class="panel architecture"><div class="harnesses">${Object.entries(
310
+ names,
311
+ )
312
+ .filter(([h]) => h !== "manual")
313
+ .map(
314
+ ([h, name]) =>
315
+ `<div class="harness">${icon(h)}<strong>${name}</strong><span class="help">Native sessions</span></div>`,
316
+ )
317
+ .join(
318
+ "",
319
+ )}</div><div class="grid2"><div class="lane"><h3>↓ Capture adapters</h3><p class="help">Explicit discovery and sync<br>Completed messages · source IDs · project roots</p></div><div class="lane"><h3>↑ MCP tools & resources</h3><p class="help">Inspect evidence and reviewed packets<br>Recipient-bound acceptance</p></div></div><div class="core"><div class="eyebrow">Context Relay</div><h2>Capture → Review → Checkpoint → Handoff</h2><p class="help">Immutable snapshots · explicit conflict resolution · file freshness · bounded packets</p></div><div class="grid2"><div class="lane"><h3>Private SQLite workspace</h3><p class="help">Projects, events, checkpoints, receipts<br>Loopback service · authenticated API</p></div><div class="lane"><h3>Optional Nebius Token Factory</h3><p class="help">Selected evidence → validated proposal<br>Explicit preview and consent · human review</p></div></div><div class="banner">MCP standardizes access to Relay's context tools. Each capture adapter still handles its harness's native history format. Relay does not modify native chat databases or transfer hidden model state.</div></section>`
320
+ );
321
+ }
322
+ async function setSession(id) {
323
+ state.sessionId = id;
324
+ state.selected.clear();
325
+ state.events = await api("/sessions/" + id + "/events?limit=200");
326
+ render();
327
+ }
328
+ function replaceDraft() {
329
+ return (
330
+ !state.draft ||
331
+ window.confirm(
332
+ "Replace the unsaved review draft? Its changes will be discarded.",
333
+ )
334
+ );
335
+ }
336
+ function newDraft() {
337
+ if (!replaceDraft()) return;
338
+ const s = currentSession();
339
+ const chosen = state.events.filter((e) => state.selected.has(e.id));
340
+ if (!chosen.length) throw Error("Select at least one source event first.");
341
+ state.draft = {
342
+ projectId: state.projectId,
343
+ title: "Continue " + s.title,
344
+ goal: "",
345
+ sources: [{ sessionId: s.id, revision: s.revision }],
346
+ instructions: [],
347
+ criteria: [],
348
+ openWork: [],
349
+ files: [],
350
+ parents: [],
351
+ resolutions: {},
352
+ conflicts: {},
353
+ evidence: chosen.map((e) => ({ sessionId: s.id, eventId: e.id })),
354
+ events: chosen.map((e) => ({
355
+ ...e,
356
+ ref: { sessionId: s.id, eventId: e.id },
357
+ })),
358
+ recipient: "codex",
359
+ reviewed: false,
360
+ };
361
+ state.view = "review";
362
+ render();
363
+ }
364
+ async function draftFrom(ids) {
365
+ if (!replaceDraft()) return;
366
+ const checkpoints = ids.map((id) =>
367
+ state.checkpoints.find((c) => c.id === id),
368
+ );
369
+ const refs = new Map(),
370
+ sources = new Map(),
371
+ instructions = new Map(),
372
+ conflicts = {};
373
+ for (const c of checkpoints) {
374
+ for (const source of c.sources)
375
+ sources.set(source.sessionId, {
376
+ sessionId: source.sessionId,
377
+ revision:
378
+ state.sessions.find((s) => s.id === source.sessionId)?.revision ??
379
+ source.revision,
380
+ });
381
+ for (const ref of [
382
+ ...c.evidence,
383
+ ...c.instructions.flatMap((i) => i.sources),
384
+ ])
385
+ refs.set(refKey(ref), ref);
386
+ for (const item of c.instructions) {
387
+ if (!instructions.has(item.key)) instructions.set(item.key, item);
388
+ else if (instructions.get(item.key).text !== item.text) {
389
+ conflicts[item.key] ??= [instructions.get(item.key)];
390
+ conflicts[item.key].push(item);
391
+ }
392
+ }
393
+ }
394
+ const all = new Map();
395
+ for (const sessionId of sources.keys()) {
396
+ const events = await api("/sessions/" + sessionId + "/events?limit=20000");
397
+ for (const e of events)
398
+ all.set(refKey({ sessionId, eventId: e.id }), {
399
+ ...e,
400
+ ref: { sessionId, eventId: e.id },
401
+ });
402
+ }
403
+ state.draft = {
404
+ projectId: state.projectId,
405
+ title:
406
+ checkpoints.length > 1
407
+ ? "Merged working context"
408
+ : "Revise " + checkpoints[0].title,
409
+ goal: checkpoints[0].goal,
410
+ sources: [...sources.values()],
411
+ instructions: structuredClone([...instructions.values()]),
412
+ criteria: [...new Set(checkpoints.flatMap((c) => c.criteria))],
413
+ openWork: [...new Set(checkpoints.flatMap((c) => c.openWork))],
414
+ files: [
415
+ ...new Set(checkpoints.flatMap((c) => c.artifacts.map((a) => a.path))),
416
+ ],
417
+ parents: ids,
418
+ resolutions: {},
419
+ conflicts,
420
+ evidence: [...refs.values()],
421
+ events: [...refs.keys()].map((k) => all.get(k)).filter(Boolean),
422
+ recipient: "codex",
423
+ reviewed: false,
424
+ };
425
+ if (!state.draft.events.length) {
426
+ state.draft = null;
427
+ throw Error(
428
+ "These checkpoints contain no source quotations. Select fresh source evidence in Threads.",
429
+ );
430
+ }
431
+ state.view = "review";
432
+ render();
433
+ notice(
434
+ "Sources use their latest captured revisions. Review changed work and files before saving.",
435
+ );
436
+ }
437
+ function connectionDialog() {
438
+ modal(
439
+ `<h2>Connect a coding harness</h2><p class="help">Scoped to ${escape(currentProject().name)}. Discovery and capture run only when requested.</p><form id="connect-form"><label>Harness<select name="harness">${Object.entries(
440
+ names,
441
+ )
442
+ .filter(([k]) => k !== "manual")
443
+ .map(([k, n]) => `<option value="${k}">${n}</option>`)
444
+ .join(
445
+ "",
446
+ )}</select></label><label>Source path or OpenCode loopback URL · optional<input name="source" placeholder="Use the harness default"></label><label>Codex or Goose executable path · optional<input name="executable" placeholder="Use executable from PATH"></label><label class="check"><input name="configure" type="checkbox" checked>Configure project MCP access. Goose receives a command manifest; enable it in Goose yourself.</label><p class="help">Existing unrelated settings are preserved. Disconnect removes only Relay’s recorded entry. Reconnecting updates this project's connection.</p><div class="actions"><button type="button" data-action="close-modal">Cancel</button><button type="submit" class="primary">Connect harness</button></div></form>`,
447
+ );
448
+ }
449
+ function download(name, text) {
450
+ const url = URL.createObjectURL(
451
+ new Blob([text], { type: "text/markdown;charset=utf-8" }),
452
+ );
453
+ const a = document.createElement("a");
454
+ a.href = url;
455
+ a.download = name;
456
+ a.click();
457
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
458
+ }
459
+ async function copy(text) {
460
+ try {
461
+ await navigator.clipboard.writeText(text);
462
+ notice("Continuation prompt copied.");
463
+ } catch {
464
+ modal(
465
+ '<h2>Copy continuation prompt</h2><textarea readonly aria-label="Continuation prompt">' +
466
+ escape(text) +
467
+ '</textarea><div class="actions"><button data-action="close-modal">Done</button></div>',
468
+ );
469
+ }
470
+ }
471
+ async function action(button) {
472
+ const d = button.dataset;
473
+ if (d.view) {
474
+ state.view = d.view;
475
+ render();
476
+ return;
477
+ }
478
+ if (d.session) return setSession(d.session);
479
+ if (d.removeInstruction !== undefined) {
480
+ state.draft.instructions.splice(Number(d.removeInstruction), 1);
481
+ render();
482
+ return;
483
+ }
484
+ if (d.inspect) {
485
+ const c = state.checkpoints.find((c) => c.id === d.inspect);
486
+ const fresh = await api("/checkpoints/" + c.id + "/freshness");
487
+ modal(
488
+ `<h2>${escape(c.title)}</h2>${badge(fresh.current ? "Current" : "Needs review", fresh.current ? "good" : "warn")}<p class="help">${escape(fresh.reasons.join(" "))}</p><pre>${escape(JSON.stringify(c, null, 2))}</pre><div class="actions"><button data-action="close-modal">Close</button></div>`,
489
+ );
490
+ return;
491
+ }
492
+ if (d.revise) return draftFrom([d.revise]);
493
+ if (d.copy || d.export) {
494
+ const value = await api("/handoffs/" + (d.copy || d.export) + "/export");
495
+ if (d.copy) return copy(value.prompt);
496
+ download("relay-handoff-" + d.export.slice(0, 8) + ".md", value.markdown);
497
+ return;
498
+ }
499
+ if (d.freshness) {
500
+ const fresh = await api("/checkpoints/" + d.freshness + "/freshness");
501
+ modal(
502
+ `<h2>${fresh.current ? "Captured state is current" : "This handoff needs review"}</h2><p>${escape(fresh.reasons.join(" ") || "Captured revisions, recorded files and Git HEAD still match. Capture the native source again to check for uncaptured changes.")}</p><div class="actions"><button data-action="close-modal">Close</button></div>`,
503
+ );
504
+ return;
505
+ }
506
+ if (d.cancel) {
507
+ await api("/handoffs/" + d.cancel + "/cancel", {});
508
+ await refresh();
509
+ return;
510
+ }
511
+ if (d.disconnect) {
512
+ await api("/connections/" + d.disconnect + "/disconnect", {});
513
+ notice("Disconnected. Captured history remains available.");
514
+ await refresh();
515
+ return;
516
+ }
517
+ if (d.command) {
518
+ const command = await api("/connections/" + d.command + "/command");
519
+ modal(
520
+ `<h2>MCP stdio command</h2><p class="help">Command and argument array. For Goose, configure this as a stdio extension; its project manifest is also available on disk.</p><pre>${escape(JSON.stringify(command, null, 2))}</pre><div class="actions"><button data-action="close-modal">Close</button></div>`,
521
+ );
522
+ return;
523
+ }
524
+ if (d.discover) {
525
+ const sessions = await api("/connections/" + d.discover + "/discover");
526
+ modal(
527
+ `<h2>Choose sessions to capture</h2><p class="help">${sessions.length} sessions match this project. Source data stays in its native location.</p><form id="capture-form" data-connection="${d.discover}">${sessions.map((s) => `<label class="check"><input type="checkbox" name="sessions" value="${escape(s.externalId)}">${escape(s.title)}</label>`).join("") || "<p>No matching sessions found. Check your source and project path.</p>"}<div class="actions"><button type="button" data-action="close-modal">Cancel</button><button type="submit" class="primary" ${sessions.length ? "" : "disabled"}>Capture selected</button></div></form>`,
528
+ );
529
+ return;
530
+ }
531
+ if (d.cancelJob) {
532
+ await api("/jobs/" + d.cancelJob + "/cancel", {});
533
+ notice("Cancellation requested; the active source read will finish first.");
534
+ return;
535
+ }
536
+ switch (d.action) {
537
+ case "close-modal":
538
+ closeModal();
539
+ break;
540
+ case "refresh":
541
+ await refresh();
542
+ notice("Workspace refreshed.");
543
+ break;
544
+ case "theme":
545
+ document.documentElement.dataset.theme =
546
+ document.documentElement.dataset.theme === "dark" ? "light" : "dark";
547
+ localStorage.setItem(
548
+ "relay-theme",
549
+ document.documentElement.dataset.theme,
550
+ );
551
+ break;
552
+ case "lock":
553
+ token = "";
554
+ sessionStorage.removeItem("relay-token");
555
+ state.draft = null;
556
+ login();
557
+ break;
558
+ case "add-project":
559
+ if (state.draft)
560
+ throw Error(
561
+ "Save or discard the review draft before adding another project.",
562
+ );
563
+ modal(
564
+ '<h2>Add a local project</h2><form id="project-form"><label>Absolute project directory<input name="root" placeholder="/path/to/project" required></label><label>Display name · optional<input name="name"></label><div class="actions"><button type="button" data-action="close-modal">Cancel</button><button type="submit" class="primary">Add project</button></div></form>',
565
+ );
566
+ break;
567
+ case "connect":
568
+ connectionDialog();
569
+ break;
570
+ case "new-draft":
571
+ newDraft();
572
+ break;
573
+ case "more-events":
574
+ state.events.push(
575
+ ...(await api(
576
+ "/sessions/" +
577
+ state.sessionId +
578
+ "/events?offset=" +
579
+ state.events.length +
580
+ "&limit=200",
581
+ )),
582
+ );
583
+ render();
584
+ break;
585
+ case "discard-draft":
586
+ state.draft = null;
587
+ render();
588
+ break;
589
+ case "add-instruction": {
590
+ const draft = state.draft;
591
+ draft.instructions.push({
592
+ key: "instruction-" + (draft.instructions.length + 1),
593
+ text: "",
594
+ sources: [draft.events[0].ref],
595
+ });
596
+ draft.reviewed = false;
597
+ render();
598
+ break;
599
+ }
600
+ case "merge":
601
+ if (state.merge.size < 2)
602
+ throw Error("Select at least two checkpoints to merge.");
603
+ await draftFrom([...state.merge]);
604
+ break;
605
+ case "save-handoff": {
606
+ const draft = state.draft;
607
+ if (!draft.reviewed)
608
+ throw Error("Confirm that you reviewed the handoff first.");
609
+ for (const key of Object.keys(draft.conflicts))
610
+ if (!draft.resolutions[key])
611
+ throw Error("Resolve the conflicting instruction: " + key);
612
+ const { events, conflicts, recipient, reviewed, ...input } = draft;
613
+ const checkpoint = await api("/checkpoints", input);
614
+ try {
615
+ await api("/handoffs", { checkpointId: checkpoint.id, recipient });
616
+ state.draft = null;
617
+ state.view = "handoffs";
618
+ await refresh();
619
+ notice(
620
+ "Checkpoint saved. The destination agent can now inspect and accept the offer.",
621
+ );
622
+ } catch (error) {
623
+ state.draft = null;
624
+ state.view = "checkpoints";
625
+ await refresh();
626
+ throw Error("Checkpoint saved, but the offer failed: " + error.message);
627
+ }
628
+ break;
629
+ }
630
+ case "proposal-preview": {
631
+ const draft = state.draft;
632
+ const preview = await api("/proposals/preview", {
633
+ projectId: state.projectId,
634
+ evidence: draft.evidence,
635
+ });
636
+ modal(
637
+ `<h2>Review what goes to Nebius</h2><p class="help">${escape(preview.warning)}</p><p class="help">${num(preview.bytes)} UTF-8 bytes · model ${escape(preview.model)}</p><pre>${escape(JSON.stringify(preview.events, null, 2))}</pre><label class="check"><input type="checkbox" id="provider-consent">I want to send this selected evidence to Nebius to generate a draft.</label><div class="actions"><button data-action="close-modal">Cancel</button><button class="primary" data-action="generate-proposal">Send & generate draft</button></div>`,
638
+ );
639
+ break;
640
+ }
641
+ case "generate-proposal": {
642
+ if (!$("#provider-consent").checked)
643
+ throw Error("Confirm the evidence transfer before generating.");
644
+ const draft = state.draft;
645
+ const result = await api("/proposals", {
646
+ projectId: state.projectId,
647
+ evidence: draft.evidence,
648
+ consent: true,
649
+ });
650
+ Object.assign(draft, result.proposal, {
651
+ reviewed: false,
652
+ resolutions: {},
653
+ });
654
+ closeModal();
655
+ render();
656
+ notice("Proposal loaded. Review every instruction before saving.");
657
+ break;
658
+ }
659
+ }
660
+ }
661
+ document.addEventListener("click", (event) => {
662
+ const button = event.target.closest("button");
663
+ if (!button || (button.type === "submit" && button.closest("form"))) return;
664
+ if (button.disabled) return;
665
+ button.disabled = true;
666
+ Promise.resolve(action(button))
667
+ .catch((error) => notice(error.message))
668
+ .finally(() => {
669
+ button.disabled = false;
670
+ });
671
+ });
672
+ document.addEventListener("input", (event) => {
673
+ const el = event.target;
674
+ if (el.id === "session-search") {
675
+ const start = el.selectionStart;
676
+ state.search = el.value;
677
+ render();
678
+ $("#session-search").focus();
679
+ $("#session-search").setSelectionRange(start, start);
680
+ return;
681
+ }
682
+ const d = state.draft;
683
+ if (!d) return;
684
+ if (el.dataset.draft) {
685
+ d[el.dataset.draft] = ["criteria", "openWork", "files"].includes(
686
+ el.dataset.draft,
687
+ )
688
+ ? lines(el.value)
689
+ : el.value;
690
+ d.reviewed = false;
691
+ }
692
+ if (el.dataset.instruction !== undefined) {
693
+ const item = d.instructions[Number(el.dataset.instruction)];
694
+ item[el.dataset.field] = el.value;
695
+ if (el.dataset.field === "text" && d.resolutions[item.key])
696
+ d.resolutions[item.key] = el.value;
697
+ d.reviewed = false;
698
+ }
699
+ if ($("#review-confirm") && el.id !== "review-confirm")
700
+ $("#review-confirm").checked = d.reviewed;
701
+ });
702
+ document.addEventListener("change", (event) => {
703
+ const el = event.target;
704
+ Promise.resolve()
705
+ .then(async () => {
706
+ if (el.id === "project-select") {
707
+ if (!replaceDraft()) {
708
+ el.value = state.projectId;
709
+ return;
710
+ }
711
+ state.projectId = el.value;
712
+ state.sessionId = "";
713
+ state.draft = null;
714
+ state.merge.clear();
715
+ await refresh();
716
+ }
717
+ if (el.dataset.evidence) {
718
+ if (el.checked) state.selected.add(el.dataset.evidence);
719
+ else state.selected.delete(el.dataset.evidence);
720
+ $("#selected-count").textContent =
721
+ state.selected.size + " selected · source text stays inspectable";
722
+ }
723
+ if (el.dataset.merge) {
724
+ if (el.checked) state.merge.add(el.dataset.merge);
725
+ else state.merge.delete(el.dataset.merge);
726
+ render();
727
+ }
728
+ if (el.dataset.instructionSource !== undefined) {
729
+ state.draft.instructions[Number(el.dataset.instructionSource)].sources =
730
+ [state.draft.events[Number(el.value)].ref];
731
+ state.draft.reviewed = false;
732
+ render();
733
+ }
734
+ if (el.dataset.conflict) {
735
+ const d = state.draft,
736
+ item = d.conflicts[el.dataset.conflict][Number(el.value)];
737
+ d.resolutions[el.dataset.conflict] = item.text;
738
+ d.instructions = d.instructions.filter(
739
+ (i) => i.key !== el.dataset.conflict,
740
+ );
741
+ d.instructions.push(structuredClone(item));
742
+ d.reviewed = false;
743
+ render();
744
+ }
745
+ if (el.id === "review-confirm") state.draft.reviewed = el.checked;
746
+ if (el.id === "import-file") {
747
+ const file = el.files[0];
748
+ if (!file) return;
749
+ if (file.size > 4 * 1024 * 1024)
750
+ throw Error("Import file exceeds 4 MiB.");
751
+ const data = JSON.parse(await file.text());
752
+ if (data.projectId !== state.projectId)
753
+ throw Error("The file projectId must match the selected project.");
754
+ await api("/sessions/import", data);
755
+ await refresh();
756
+ notice("Session imported.");
757
+ }
758
+ })
759
+ .catch((error) => notice(error.message));
760
+ });
761
+ document.addEventListener("submit", (event) => {
762
+ event.preventDefault();
763
+ const form = event.target,
764
+ data = new FormData(form),
765
+ button = form.querySelector("[type=submit]");
766
+ if (button) button.disabled = true;
767
+ Promise.resolve()
768
+ .then(async () => {
769
+ if (form.id === "login-form") {
770
+ token = String(data.get("token")).trim();
771
+ sessionStorage.setItem("relay-token", token);
772
+ await refresh();
773
+ }
774
+ if (form.id === "project-form") {
775
+ const project = await api("/projects", {
776
+ root: data.get("root"),
777
+ ...(data.get("name") ? { name: data.get("name") } : {}),
778
+ });
779
+ state.projectId = project.id;
780
+ closeModal();
781
+ await refresh();
782
+ }
783
+ if (form.id === "connect-form") {
784
+ await api("/connections", {
785
+ projectId: state.projectId,
786
+ harness: data.get("harness"),
787
+ configureMcp: data.has("configure"),
788
+ ...(data.get("source") ? { source: data.get("source") } : {}),
789
+ ...(data.get("executable")
790
+ ? { executable: data.get("executable") }
791
+ : {}),
792
+ });
793
+ closeModal();
794
+ await refresh();
795
+ notice("Harness connected. Discover sessions when ready.");
796
+ }
797
+ if (form.id === "capture-form") {
798
+ const selected = data.getAll("sessions");
799
+ if (!selected.length) throw Error("Choose at least one session.");
800
+ await api("/connections/" + form.dataset.connection + "/sync", {
801
+ externalIds: selected,
802
+ });
803
+ closeModal();
804
+ await refresh();
805
+ notice("Capture started. Progress appears below.");
806
+ }
807
+ })
808
+ .catch((error) => notice(error.message))
809
+ .finally(() => {
810
+ if (button) button.disabled = false;
811
+ });
812
+ });
813
+ setInterval(async () => {
814
+ if (!token || !state.jobs.some((j) => j.state === "running")) return;
815
+ try {
816
+ const jobs = await api("/jobs");
817
+ const wasRunning = state.jobs.some((j) => j.state === "running");
818
+ state.jobs = jobs.filter((j) =>
819
+ state.connections.some((c) => c.id === j.connectionId),
820
+ );
821
+ if (wasRunning && !state.jobs.some((j) => j.state === "running")) {
822
+ await refresh();
823
+ notice("Capture finished. Review the job results.");
824
+ } else if (state.view === "connections" && !$("#modal").open) render();
825
+ } catch (error) {
826
+ notice(error.message);
827
+ }
828
+ }, 1500);
829
+ if (token)
830
+ refresh().catch((error) => {
831
+ notice(error.message);
832
+ if (token)
833
+ $("#app").innerHTML =
834
+ '<main class="welcome"><h1>Workspace unavailable</h1><p>' +
835
+ escape(error.message) +
836
+ '</p><button data-action="refresh">Retry connection</button></main>';
837
+ });
838
+ else login();
839
+
840
+ window.addEventListener("beforeunload", (event) => {
841
+ if (state.draft) {
842
+ event.preventDefault();
843
+ event.returnValue = "";
844
+ }
845
+ });