sealkeep 0.9.0 → 0.11.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.
Files changed (274) hide show
  1. package/ARCHITECTURE.md +163 -14
  2. package/CHANGELOG.md +223 -1
  3. package/CONTROL_PLANE.md +2 -2
  4. package/LICENSE +1 -1
  5. package/README.md +108 -22
  6. package/THIRD_PARTY.md +2 -2
  7. package/THREAT_MODEL.md +23 -4
  8. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/chunk-access.d.ts +26 -4
  9. package/dist/packages/sealkeep-crypto/src/chunk-access.js +219 -0
  10. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.d.ts +11 -1
  11. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.js +58 -21
  12. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.d.ts +2 -2
  13. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.d.ts +1 -0
  14. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.js +1 -0
  15. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.js +4 -4
  16. package/dist/packages/sealkeep-crypto/src/squeeze.d.ts +6 -0
  17. package/dist/packages/sealkeep-crypto/src/squeeze.js +39 -0
  18. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.d.ts +36 -3
  19. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.js +196 -37
  20. package/dist/site/index.html +1808 -1904
  21. package/dist/site/llms.txt +67 -0
  22. package/dist/site/trust/architecture-data-flow.html +53 -0
  23. package/dist/site/trust/audit-roadmap.html +37 -0
  24. package/dist/site/trust/deployment-responsibility.html +11 -0
  25. package/dist/site/trust/dpa-sample.html +30 -0
  26. package/dist/site/trust/release-provenance.html +21 -0
  27. package/dist/site/trust/subprocessors.html +15 -0
  28. package/dist/site/trust/threat-model.html +34 -0
  29. package/dist/site/trust/toms.html +41 -0
  30. package/dist/site/trust-document.css +32 -0
  31. package/dist/site/trust.html +73 -0
  32. package/dist/site/visual/assets/index-8Nxnkc7o.js +207 -0
  33. package/dist/site/visual/assets/index-oxLy1bpy.css +1 -0
  34. package/dist/site/visual/index.html +18 -0
  35. package/dist/site.zip +0 -0
  36. package/dist/src/activity.d.ts +9 -0
  37. package/dist/src/activity.js +90 -1
  38. package/dist/src/adapters.d.ts +175 -5
  39. package/dist/src/adapters.js +961 -73
  40. package/dist/src/agent-context.d.ts +135 -0
  41. package/dist/src/agent-context.js +1059 -0
  42. package/dist/src/archive-copies.d.ts +47 -0
  43. package/dist/src/archive-copies.js +179 -0
  44. package/dist/src/audit.d.ts +1 -1
  45. package/dist/src/audit.js +29 -4
  46. package/dist/src/autopilot.d.ts +52 -7
  47. package/dist/src/autopilot.js +143 -25
  48. package/dist/src/background-bandwidth.d.ts +46 -0
  49. package/dist/src/background-bandwidth.js +301 -0
  50. package/dist/src/background-cpu.d.ts +82 -0
  51. package/dist/src/background-cpu.js +212 -0
  52. package/dist/src/background-worker-error.d.ts +12 -0
  53. package/dist/src/background-worker-error.js +18 -0
  54. package/dist/src/branding.d.ts +24 -1
  55. package/dist/src/branding.js +31 -1
  56. package/dist/src/bridge.d.ts +233 -0
  57. package/dist/src/bridge.js +604 -0
  58. package/dist/src/byte-stream.d.ts +91 -0
  59. package/dist/src/byte-stream.js +385 -0
  60. package/dist/src/chunk-store.d.ts +41 -8
  61. package/dist/src/chunk-store.js +161 -65
  62. package/dist/src/cli.js +1698 -163
  63. package/dist/src/cloud.d.ts +841 -31
  64. package/dist/src/cloud.js +3196 -277
  65. package/dist/src/context-background.d.ts +37 -0
  66. package/dist/src/context-background.js +309 -0
  67. package/dist/src/context-drain-child.d.ts +1 -0
  68. package/dist/src/context-drain-child.js +98 -0
  69. package/dist/src/context-reader.d.ts +118 -0
  70. package/dist/src/context-reader.js +447 -0
  71. package/dist/src/control-plane/auth.d.ts +32 -4
  72. package/dist/src/control-plane/auth.js +85 -24
  73. package/dist/src/control-plane/server.js +19 -6
  74. package/dist/src/control-plane.d.ts +17 -1
  75. package/dist/src/control-plane.js +32 -6
  76. package/dist/src/crypto.d.ts +1 -1
  77. package/dist/src/crypto.js +5 -5
  78. package/dist/src/daemon-lease.d.ts +70 -0
  79. package/dist/src/daemon-lease.js +420 -0
  80. package/dist/src/daemon.d.ts +94 -1
  81. package/dist/src/daemon.js +1082 -105
  82. package/dist/src/darwin-service-policy.d.ts +41 -0
  83. package/dist/src/darwin-service-policy.js +60 -0
  84. package/dist/src/dashboard-cli.js +15 -15
  85. package/dist/src/device-authorization.d.ts +37 -0
  86. package/dist/src/device-authorization.js +199 -0
  87. package/dist/src/device-enrollment.d.ts +91 -0
  88. package/dist/src/device-enrollment.js +349 -0
  89. package/dist/src/disk.d.ts +17 -12
  90. package/dist/src/disk.js +43 -17
  91. package/dist/src/doctor.d.ts +35 -1
  92. package/dist/src/doctor.js +316 -41
  93. package/dist/src/durable-ticket-lock.d.ts +24 -0
  94. package/dist/src/durable-ticket-lock.js +232 -0
  95. package/dist/src/enroll.d.ts +1 -1
  96. package/dist/src/enroll.js +13 -7
  97. package/dist/src/env.d.ts +10 -1
  98. package/dist/src/env.js +11 -3
  99. package/dist/src/errors.d.ts +8 -8
  100. package/dist/src/errors.js +6 -6
  101. package/dist/src/flush.d.ts +12 -0
  102. package/dist/src/flush.js +37 -0
  103. package/dist/src/heartbeat.d.ts +86 -12
  104. package/dist/src/heartbeat.js +415 -29
  105. package/dist/src/index-background-watchdog.d.ts +1 -0
  106. package/dist/src/index-background-watchdog.js +94 -0
  107. package/dist/src/index-background-work.d.ts +21 -0
  108. package/dist/src/index-background-work.js +25 -0
  109. package/dist/src/index-background.d.ts +64 -0
  110. package/dist/src/index-background.js +394 -0
  111. package/dist/src/index-build-child.d.ts +1 -0
  112. package/dist/src/index-build-child.js +109 -0
  113. package/dist/src/index-manifest.d.ts +52 -0
  114. package/dist/src/index-manifest.js +444 -0
  115. package/dist/src/index-publication-proof.d.ts +84 -0
  116. package/dist/src/index-publication-proof.js +380 -0
  117. package/dist/src/index-publication-state.d.ts +149 -0
  118. package/dist/src/index-publication-state.js +696 -0
  119. package/dist/src/index-publication-verifier.d.ts +89 -0
  120. package/dist/src/index-publication-verifier.js +341 -0
  121. package/dist/src/index-publish.d.ts +62 -0
  122. package/dist/src/index-publish.js +540 -0
  123. package/dist/src/index-scratch-cleanup.d.ts +19 -0
  124. package/dist/src/index-scratch-cleanup.js +166 -0
  125. package/dist/src/index-segment-types.d.ts +132 -0
  126. package/dist/src/index-segment-types.js +21 -0
  127. package/dist/src/index-segments.d.ts +9 -0
  128. package/dist/src/index-segments.js +516 -0
  129. package/dist/src/index-store.d.ts +123 -0
  130. package/dist/src/index-store.js +495 -0
  131. package/dist/src/index-sync.d.ts +91 -15
  132. package/dist/src/index-sync.js +286 -53
  133. package/dist/src/index-upgrade-publication.d.ts +30 -0
  134. package/dist/src/index-upgrade-publication.js +179 -0
  135. package/dist/src/integration-manager.d.ts +32 -0
  136. package/dist/src/integration-manager.js +394 -0
  137. package/dist/src/leakscan.js +1 -1
  138. package/dist/src/local-api.d.ts +56 -15
  139. package/dist/src/local-api.js +4979 -541
  140. package/dist/src/machine-settings.d.ts +51 -0
  141. package/dist/src/machine-settings.js +166 -0
  142. package/dist/src/managed-chunks.d.ts +5 -2
  143. package/dist/src/managed-chunks.js +14 -14
  144. package/dist/src/mcp-install.d.ts +11 -9
  145. package/dist/src/mcp-install.js +73 -28
  146. package/dist/src/mcp-workspace.d.ts +18 -0
  147. package/dist/src/mcp-workspace.js +50 -0
  148. package/dist/src/mcp.js +294 -25
  149. package/dist/src/migrate.js +27 -21
  150. package/dist/src/notify.d.ts +1 -1
  151. package/dist/src/notify.js +5 -5
  152. package/dist/src/offload.d.ts +201 -14
  153. package/dist/src/offload.js +1848 -140
  154. package/dist/src/onboarding.d.ts +8 -1
  155. package/dist/src/onboarding.js +4 -4
  156. package/dist/src/packages.d.ts +2 -2
  157. package/dist/src/packages.js +10 -2
  158. package/dist/src/passkey.d.ts +0 -1
  159. package/dist/src/passkey.js +2 -7
  160. package/dist/src/password-lock.d.ts +2 -2
  161. package/dist/src/password-lock.js +6 -6
  162. package/dist/src/paths.d.ts +2 -0
  163. package/dist/src/paths.js +2 -0
  164. package/dist/src/presence.d.ts +86 -0
  165. package/dist/src/presence.js +240 -0
  166. package/dist/src/progress-deadline.d.ts +21 -0
  167. package/dist/src/progress-deadline.js +91 -0
  168. package/dist/src/project-repair.d.ts +55 -0
  169. package/dist/src/project-repair.js +131 -0
  170. package/dist/src/providers/gcs.d.ts +28 -7
  171. package/dist/src/providers/gcs.js +35 -24
  172. package/dist/src/providers/gdrive.d.ts +71 -8
  173. package/dist/src/providers/gdrive.js +223 -50
  174. package/dist/src/providers/index.d.ts +11 -3
  175. package/dist/src/providers/index.js +30 -10
  176. package/dist/src/providers/s3.d.ts +30 -8
  177. package/dist/src/providers/s3.js +41 -30
  178. package/dist/src/providers/safe-storage-fetch.d.ts +12 -0
  179. package/dist/src/providers/safe-storage-fetch.js +72 -0
  180. package/dist/src/queue.d.ts +136 -19
  181. package/dist/src/queue.js +862 -96
  182. package/dist/src/reclaim-transaction.d.ts +156 -0
  183. package/dist/src/reclaim-transaction.js +1027 -0
  184. package/dist/src/recovery-codes.d.ts +32 -0
  185. package/dist/src/recovery-codes.js +338 -0
  186. package/dist/src/recovery.js +12 -9
  187. package/dist/src/rehydrate.d.ts +25 -22
  188. package/dist/src/rehydrate.js +319 -23
  189. package/dist/src/restore.d.ts +57 -4
  190. package/dist/src/restore.js +272 -36
  191. package/dist/src/resume-stub.d.ts +92 -0
  192. package/dist/src/resume-stub.js +417 -0
  193. package/dist/src/retention.d.ts +98 -7
  194. package/dist/src/retention.js +1066 -62
  195. package/dist/src/rotate.js +3 -3
  196. package/dist/src/search.d.ts +566 -8
  197. package/dist/src/search.js +5897 -290
  198. package/dist/src/secrets.d.ts +51 -7
  199. package/dist/src/secrets.js +316 -24
  200. package/dist/src/service.d.ts +49 -11
  201. package/dist/src/service.js +776 -35
  202. package/dist/src/share.js +3 -3
  203. package/dist/src/shared-spaces.d.ts +98 -0
  204. package/dist/src/shared-spaces.js +214 -0
  205. package/dist/src/source-reader.d.ts +73 -0
  206. package/dist/src/source-reader.js +715 -0
  207. package/dist/src/spool.d.ts +1 -1
  208. package/dist/src/spool.js +1 -1
  209. package/dist/src/start-tui.js +2 -1
  210. package/dist/src/start.js +2 -2
  211. package/dist/src/storage-endpoint.d.ts +21 -0
  212. package/dist/src/storage-endpoint.js +122 -0
  213. package/dist/src/storage-setup.js +12 -12
  214. package/dist/src/storage-targets.d.ts +109 -6
  215. package/dist/src/storage-targets.js +975 -67
  216. package/dist/src/stream-to-cloud.d.ts +5 -1
  217. package/dist/src/stream-to-cloud.js +34 -14
  218. package/dist/src/sync-rules.d.ts +31 -6
  219. package/dist/src/sync-rules.js +153 -14
  220. package/dist/src/team-backfill-scheduling.d.ts +8 -0
  221. package/dist/src/team-backfill-scheduling.js +33 -0
  222. package/dist/src/team-backfill.d.ts +116 -0
  223. package/dist/src/team-backfill.js +1429 -0
  224. package/dist/src/team-index-cache.d.ts +16 -0
  225. package/dist/src/team-index-cache.js +152 -0
  226. package/dist/src/team-offboarding.d.ts +38 -0
  227. package/dist/src/team-offboarding.js +1043 -0
  228. package/dist/src/team-presence.d.ts +127 -0
  229. package/dist/src/team-presence.js +904 -0
  230. package/dist/src/team-publication-policy.d.ts +20 -0
  231. package/dist/src/team-publication-policy.js +140 -0
  232. package/dist/src/team-realtime.d.ts +68 -0
  233. package/dist/src/team-realtime.js +816 -0
  234. package/dist/src/team-source-facts-cache.d.ts +23 -0
  235. package/dist/src/team-source-facts-cache.js +255 -0
  236. package/dist/src/trash.d.ts +1 -1
  237. package/dist/src/trash.js +2 -2
  238. package/dist/src/tui.js +11 -12
  239. package/dist/src/types.d.ts +173 -7
  240. package/dist/src/types.js +20 -0
  241. package/dist/src/ui-server.d.ts +163 -35
  242. package/dist/src/ui-server.js +712 -72
  243. package/dist/src/ui.d.ts +1 -2
  244. package/dist/src/ui.js +1 -2
  245. package/dist/src/upload.d.ts +27 -0
  246. package/dist/src/upload.js +383 -43
  247. package/dist/src/vault.d.ts +226 -30
  248. package/dist/src/vault.js +1776 -192
  249. package/dist/src/watcher.d.ts +7 -1
  250. package/dist/src/watcher.js +198 -55
  251. package/dist/src/worker.d.ts +27 -3
  252. package/dist/src/worker.js +274 -55
  253. package/package.json +33 -12
  254. package/scripts/native-reboot-rehearsal.mjs +90 -0
  255. package/web/app.js +6032 -343
  256. package/web/bootstrap.js +17 -0
  257. package/web/index.html +255 -57
  258. package/web/rail.js +317 -40
  259. package/web/retention.html +2 -2
  260. package/web/rules-view.js +188 -16
  261. package/web/sessions-view.js +485 -62
  262. package/web/sessions.html +2 -2
  263. package/web/setup-api.js +152 -29
  264. package/web/setup-logic.js +68 -9
  265. package/web/setup.html +113 -44
  266. package/web/setup.js +604 -71
  267. package/web/style.css +513 -98
  268. package/dist/packages/vaultline-crypto/src/chunk-access.js +0 -93
  269. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.d.ts +0 -0
  270. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.js +0 -0
  271. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.js +0 -0
  272. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.d.ts +0 -0
  273. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.d.ts +0 -0
  274. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.js +0 -0
@@ -34,11 +34,108 @@
34
34
  return `${Math.floor(days / 30)}mo ago`;
35
35
  };
36
36
 
37
+ const SESSION_CACHE_KEY = "sealkeep.sessionsView.v1";
38
+ const NO_PROJECT = "(none)";
37
39
  let sessions = [];
40
+ let lastSessionData = null;
41
+ let lastLoadedAt = null;
42
+ let currentVaultId = null;
43
+ let hasStorageTarget = false;
44
+ let hasWritableStorageTarget = false;
45
+ let sessionEligibilityLive = false;
38
46
  const chosen = new Set();
39
47
 
40
48
  const DAY = 86400000;
41
49
 
50
+ /**
51
+ * Keep the last successful disk walk in this browser, just as My vault keeps
52
+ * its last catalog. A /v1/sessions walk can cover thousands of transcripts;
53
+ * a transient failure must not turn that useful table into an empty one.
54
+ * The vault id travels with the snapshot so a different vault on this same
55
+ * local port never inherits another vault's paths.
56
+ */
57
+ function readSessionCache(vaultId) {
58
+ try {
59
+ const cached = JSON.parse(localStorage.getItem(SESSION_CACHE_KEY) ?? "null");
60
+ if (cached?.version !== 1 || cached.vaultId !== vaultId || !cached.loadedAt
61
+ || !cached.data || !Array.isArray(cached.data.sessions)) return null;
62
+ return cached;
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ function writeSessionCache(vaultId, data, loadedAt) {
69
+ if (!vaultId) return;
70
+ try {
71
+ localStorage.setItem(SESSION_CACHE_KEY, JSON.stringify({ version: 1, vaultId, data, loadedAt }));
72
+ } catch {
73
+ // A disabled/full browser store costs only the warm reload. The live
74
+ // session view and its in-memory fallback continue to work.
75
+ }
76
+ }
77
+
78
+ const loadedAgo = (iso) => {
79
+ const delta = Date.now() - Date.parse(iso);
80
+ if (!Number.isFinite(delta)) return "earlier";
81
+ const minutes = Math.max(0, Math.round(delta / 60000));
82
+ if (minutes < 1) return "just now";
83
+ if (minutes < 60) return `${minutes}m ago`;
84
+ const hours = Math.round(minutes / 60);
85
+ if (hours < 24) return `${hours}h ago`;
86
+ const days = Math.round(hours / 24);
87
+ return days < 30 ? `${days}d ago` : `${Math.round(days / 30)}mo ago`;
88
+ };
89
+
90
+ function summaryText(data) {
91
+ const rows = Array.isArray(data?.sessions) ? data.sessions : [];
92
+ const ready = rows.filter((item) => !item.archived && !item.queued);
93
+ const queued = rows.filter((item) => !item.archived && item.queued);
94
+ const readyBytes = ready.reduce((total, item) => total + (Number(item.bytes) || 0), 0);
95
+ const queuedBytes = queued.reduce((total, item) => total + (Number(item.bytes) || 0), 0);
96
+ const readyText = ready.length
97
+ ? `${bytes(readyBytes)} in ${ready.length} ready to archive`
98
+ : "none waiting for you to archive";
99
+ const queuedText = queued.length
100
+ ? ` ${bytes(queuedBytes)} in ${queued.length} already queued to seal.`
101
+ : "";
102
+ return `${bytes(Number(data?.totalBytes) || 0)} across ${rows.length} sessions on disk. ${readyText}.${queuedText}`;
103
+ }
104
+
105
+ function setSessionSummary(data, state = "live", loadedAt = null) {
106
+ const summary = $("summary");
107
+ summary.className = state === "failed" ? "note error" : "note";
108
+ if (state === "updating") {
109
+ summary.textContent = `${summaryText(data)} Updating · showing data loaded ${loadedAgo(loadedAt)}.`;
110
+ } else if (state === "failed") {
111
+ summary.textContent = `${summaryText(data)} Could not update · showing data loaded ${loadedAgo(loadedAt)}.`;
112
+ } else {
113
+ summary.textContent = summaryText(data);
114
+ }
115
+ }
116
+
117
+ function acceptSessionData(data, loadedAt = new Date().toISOString(), eligibilityLive = true) {
118
+ if (!data || !Array.isArray(data.sessions)) throw new Error("Sessions response did not include a session list");
119
+ sessions = data.sessions;
120
+ sessionEligibilityLive = eligibilityLive;
121
+ lastSessionData = {
122
+ sessions: data.sessions,
123
+ totalBytes: Number(data.totalBytes) || 0,
124
+ unarchivedBytes: Number(data.unarchivedBytes) || 0
125
+ };
126
+ lastLoadedAt = loadedAt;
127
+ // Project chips are a projection of THIS answer, not permanent controls.
128
+ // A background refresh can discover a new project (or lose an old one),
129
+ // so reconcile them every time while preserving a still-valid selection.
130
+ buildProjectChips();
131
+ // A selection can go stale across a refresh: anything now archived or
132
+ // already queued must not be re-submitted.
133
+ for (const row of sessions) if (row.archived || row.queued) chosen.delete(row.path);
134
+ setSessionSummary(lastSessionData);
135
+ draw();
136
+ refreshChoice();
137
+ }
138
+
42
139
  /**
43
140
  * The rows that survive the current filters, in the chosen order.
44
141
  *
@@ -50,6 +147,27 @@
50
147
  /** The active chip in a group, or "" for none. */
51
148
  const chip = (group) => $("chips").querySelector(`.chip.on[data-group="${group}"]`)?.dataset.value ?? "";
52
149
 
150
+ /**
151
+ * A project label is presentation, not identity. Two unrelated repositories
152
+ * can both be called `api`; filtering by that word mixed their sessions and
153
+ * made the chip's apparent folder disagree with the rows it selected. Prefer
154
+ * the detector's stable key, with path/name fallbacks for older cached rows.
155
+ */
156
+ function projectIdentity(row) {
157
+ if (!row?.project) return NO_PROJECT;
158
+ if (typeof row.projectKey === "string" && row.projectKey) return `key:${row.projectKey}`;
159
+ if (typeof row.projectPath === "string" && row.projectPath) return `path:${row.projectPath}`;
160
+ return `name:${row.project}`;
161
+ }
162
+
163
+ const compactProjectPath = (path) => String(path ?? "").replace(/^\/(?:Users|home)\/[^/]+/, "~");
164
+
165
+ function projectChipFor(row) {
166
+ const identity = projectIdentity(row);
167
+ return [...$("chips").querySelectorAll('.chip[data-group="project"]')]
168
+ .find((button) => button.dataset.value === identity) ?? null;
169
+ }
170
+
53
171
  function visible() {
54
172
  const needle = $("f-search").value.trim().toLowerCase();
55
173
  const agent = chip("agent");
@@ -62,10 +180,14 @@
62
180
  const rows = sessions.filter((row) => {
63
181
  if (needle && !row.path.toLowerCase().includes(needle)) return false;
64
182
  if (agent && row.agent !== agent) return false;
65
- // "(none)" collects the sessions whose transcripts never said where they
66
- // ran; any other project chip is a name to match exactly.
183
+ // The stable identity, not the display name, is the filter. This keeps
184
+ // same-named repositories separate and also survives a folder rename
185
+ // when the detector still reports the same projectKey.
186
+ // Keep accepting the original name-valued chips as well as the stable
187
+ // identities produced below. The explicit sentinel cannot collide with
188
+ // a real project because real names are prefixed by projectIdentity().
67
189
  if (project === "(none)") { if (row.project) return false; }
68
- else if (project && row.project !== project) return false;
190
+ else if (project && projectIdentity(row) !== project && row.project !== project) return false;
69
191
  if (state === "archived" && !row.archived) return false;
70
192
  // A queued row is on its way into the vault: no decision is pending on
71
193
  // it, so it is not "not archived yet" — it has its own chip. The three
@@ -94,6 +216,14 @@
94
216
  const total = sessions.filter((row) => chosen.has(row.path)).reduce((sum, row) => sum + row.bytes, 0);
95
217
  $("chosen").textContent = chosen.size === 0 ? "Nothing selected." : `${chosen.size} selected · ${bytes(total)}`;
96
218
  $("archive").disabled = chosen.size === 0;
219
+ const rows = selectable();
220
+ const selected = rows.filter((row) => chosen.has(row.path)).length;
221
+ // The table header and the written "Select all shown" control are two
222
+ // entrances to one selection. Keep the native checkbox honest after either
223
+ // entrance, including the useful mixed state after ticking individual rows.
224
+ $("all").checked = rows.length > 0 && selected === rows.length;
225
+ $("all").indeterminate = selected > 0 && selected < rows.length;
226
+ $("all").disabled = rows.length === 0;
97
227
  }
98
228
 
99
229
  let draw = function () {
@@ -104,9 +234,13 @@
104
234
  // What the filters are currently hiding, said out loud. A list that silently
105
235
  // shows 12 of 697 looks like a list of 12.
106
236
  const shownBytes = rows.reduce((sum, row) => sum + row.bytes, 0);
237
+ const state = chip("state");
238
+ const queuedCount = sessions.filter((item) => !item.archived && item.queued).length;
107
239
  $("filter-count").textContent = sessions.length === 0
108
240
  ? ""
109
- : `Showing ${rows.length} of ${sessions.length} sessions · ${bytes(shownBytes)}`;
241
+ : state === "unarchived" && rows.length === 0
242
+ ? `0 ready to archive of ${sessions.length} sessions${queuedCount ? ` · ${queuedCount} already queued` : ""}`
243
+ : `Showing ${rows.length} of ${sessions.length} sessions · ${bytes(shownBytes)}`;
110
244
 
111
245
  if (rows.length === 0) {
112
246
  const row = body.insertRow();
@@ -115,7 +249,9 @@
115
249
  cell.className = "note";
116
250
  cell.textContent = sessions.length === 0
117
251
  ? "No sessions found for the agents this build can archive."
118
- : "No session matches these filters. Widen them, or clear them to see everything.";
252
+ : state === "unarchived" && queuedCount > 0
253
+ ? `No session is waiting for you to archive. ${queuedCount} ${queuedCount === 1 ? "is" : "are"} already queued; choose “Queued to seal” above to see ${queuedCount === 1 ? "it" : "them"}.`
254
+ : "No session matches these filters. Widen them, or clear them to see everything.";
119
255
  return;
120
256
  }
121
257
  for (const item of rows) {
@@ -125,6 +261,7 @@
125
261
  const tick = row.insertCell();
126
262
  const box = document.createElement("input");
127
263
  box.type = "checkbox";
264
+ box.setAttribute("aria-label", `Select ${item.path.split("/").pop()} to archive`);
128
265
  box.checked = chosen.has(item.path);
129
266
  // An archive already covers this file at exactly this length, so sealing
130
267
  // it again would write a duplicate of something we hold. A queued row is
@@ -145,7 +282,7 @@
145
282
  const name = document.createElement("span");
146
283
  name.className = "name";
147
284
  name.textContent = item.project;
148
- const matching = $("chips").querySelector(`.chip[data-group="project"][data-value="${CSS.escape(item.project)}"]`);
285
+ const matching = projectChipFor(item);
149
286
  if (matching) {
150
287
  name.classList.add("filterable");
151
288
  name.title = "Show only this project";
@@ -155,7 +292,7 @@
155
292
  if (item.projectPath) {
156
293
  const where = document.createElement("span");
157
294
  where.className = "where";
158
- where.textContent = item.projectPath.replace(/^\/(?:Users|home)\/[^/]+/, "~");
295
+ where.textContent = compactProjectPath(item.projectPath);
159
296
  where.title = item.projectPath;
160
297
  proj.append(where);
161
298
  }
@@ -187,17 +324,22 @@
187
324
  if (item.stored) state.innerHTML = '<span class="ok">in your cloud</span>';
188
325
  else if (item.archived) state.innerHTML = '<span class="ok">archived</span>';
189
326
  else if (item.queued && item.held) {
190
- // Queued but refused on its last attempt — today that means the disk
191
- // guard stopped it. "sealing…" here was a lie the queue knew better
192
- // than; the guard's own sentence rides on the tooltip.
327
+ // A durable queued intent is not necessarily active work. In
328
+ // particular, a later Settings exclusion pauses automatic work without
329
+ // deleting it. Say which kind of hold this is instead of showing an
330
+ // endless "sealing…" state.
193
331
  const span = document.createElement("span");
194
332
  span.className = "note";
195
- span.textContent = "waiting for room";
333
+ span.textContent = item.heldKind === "policy"
334
+ ? "paused in Settings"
335
+ : item.heldKind === "space"
336
+ ? "waiting for room"
337
+ : "waiting to retry";
196
338
  span.title = item.held;
197
339
  state.append(span);
198
340
  }
199
341
  else if (item.queued) state.innerHTML = `<span class="queued">sealing…${item.sealingPct !== null && item.sealingPct !== undefined ? ` ${item.sealingPct}%` : ""}</span>`;
200
- else state.innerHTML = '<span class="note">not archived</span>';
342
+ else state.innerHTML = '<span class="note">ready to archive</span>';
201
343
  }
202
344
  };
203
345
 
@@ -207,6 +349,13 @@
207
349
  // six hundred.
208
350
  // Only rows that can actually be sealed: not archived, not already queued.
209
351
  const selectable = () => visible().filter((item) => !item.archived && !item.queued);
352
+ const MAX_ROUTED_SEND = 5_000;
353
+ // This is deliberately not `chosen`: row checkboxes mean "seal this
354
+ // original", while this direct action routes the complete sealed heads left
355
+ // by the current filters. One control must never silently borrow the other's
356
+ // selection language.
357
+ const sendableShown = () => visible().filter((item) => item.archived && !item.stored
358
+ && typeof item.sendableArchiveId === "string" && item.sendableArchiveId);
210
359
 
211
360
  // The button says how many it would take, so it reads as a sentence: "Select
212
361
  // all 13 shown (21 GB)". When some visible rows are already archived or in
@@ -222,21 +371,46 @@
222
371
  const total = rows.reduce((sum, row) => sum + row.bytes, 0);
223
372
  $("select-shown").textContent = !rows.length ? "Nothing to select"
224
373
  : rows.length === shown ? `Select all ${rows.length} shown (${bytes(total)})`
225
- : `Select the ${rows.length} not yet archived (${bytes(total)})`;
374
+ : `Select the ${rows.length} ready to archive (${bytes(total)})`;
226
375
  $("select-shown").disabled = rows.length === 0;
376
+ const send = $("send-archived");
377
+ const sendable = sendableShown();
378
+ send.hidden = !hasStorageTarget;
379
+ send.disabled = !sessionEligibilityLive || !hasWritableStorageTarget || sendable.length === 0 || sendable.length > MAX_ROUTED_SEND;
380
+ send.textContent = sendable.length === 0
381
+ ? "No complete sealed chains shown"
382
+ : sendable.length > MAX_ROUTED_SEND
383
+ ? `Narrow filters to send (maximum ${MAX_ROUTED_SEND})`
384
+ : `Send ${sendable.length} complete sealed chain${sendable.length === 1 ? "" : "s"} shown to storage`;
385
+ send.title = !sessionEligibilityLive
386
+ ? "Refresh Sessions successfully before sending; cached eligibility is display-only"
387
+ : !hasWritableStorageTarget
388
+ ? "Connect and resume a configured storage target before sending"
389
+ : sendable.length === 0
390
+ ? "No filtered archived-but-not-stored session has a complete local sealed chain"
391
+ : sendable.length > MAX_ROUTED_SEND
392
+ ? `Normal routing accepts at most ${MAX_ROUTED_SEND} archive heads at once; narrow the filters first`
393
+ : "Sends only the filtered archived-but-not-stored rows with proven complete local chains, using normal project pins, target order, caps, and fallback";
227
394
  };
228
395
 
229
396
  // Shared by the static chips and the project chips built after load — a
230
397
  // dynamic chip with different click behavior would be two filter languages
231
398
  // on one row.
232
399
  function wireChip(button) {
400
+ button.setAttribute("aria-pressed", button.classList.contains("on") ? "true" : "false");
233
401
  button.onclick = () => {
234
402
  const on = button.classList.contains("on");
235
403
  // One chip per group: choosing "older than 30 days" replaces "older than
236
404
  // 7 days" rather than stacking two ages. Clicking the active chip clears
237
405
  // the group.
238
- for (const other of $("chips").querySelectorAll(`.chip[data-group="${button.dataset.group}"]`)) other.classList.remove("on");
239
- if (!on) button.classList.add("on");
406
+ for (const other of $("chips").querySelectorAll(`.chip[data-group="${button.dataset.group}"]`)) {
407
+ other.classList.remove("on");
408
+ other.setAttribute("aria-pressed", "false");
409
+ }
410
+ if (!on) {
411
+ button.classList.add("on");
412
+ button.setAttribute("aria-pressed", "true");
413
+ }
240
414
  draw();
241
415
  refreshChoice();
242
416
  };
@@ -249,18 +423,40 @@
249
423
  * one for sessions whose transcripts never said where they ran. They sit
250
424
  * before the age group so the row reads as what, then when, then how big.
251
425
  */
252
- function buildProjectChips() {
253
- if ($("chips").querySelector('.chip[data-group="project"]')) return;
426
+ function clearProjectChips() {
427
+ for (const node of $("chips").querySelectorAll('.chip[data-group="project"]')) node.remove();
428
+ for (const node of $("chips").querySelectorAll(".project-chip-sep")) node.remove();
429
+ }
430
+
431
+ function buildProjectChips(resetSelection = false) {
432
+ const selected = resetSelection ? "" : chip("project");
433
+ clearProjectChips();
254
434
  const totals = new Map();
255
435
  let unplaced = 0;
256
436
  for (const row of sessions) {
257
- if (row.project) totals.set(row.project, (totals.get(row.project) ?? 0) + row.bytes);
258
- else unplaced += 1;
437
+ if (!row.project) {
438
+ unplaced += 1;
439
+ continue;
440
+ }
441
+ const identity = projectIdentity(row);
442
+ const project = totals.get(identity) ?? {
443
+ identity,
444
+ name: row.project,
445
+ bytes: 0,
446
+ paths: new Set(),
447
+ projectKey: row.projectKey ?? null
448
+ };
449
+ project.bytes += Number(row.bytes) || 0;
450
+ if (row.projectPath) project.paths.add(row.projectPath);
451
+ totals.set(identity, project);
259
452
  }
260
453
  // A machine where no transcript names a project gets no dead chip row.
261
454
  if (totals.size === 0) return;
262
455
  const anchor = $("chips").querySelector('.chip[data-group="age"]');
263
456
  const add = (node) => anchor.parentElement.insertBefore(node, anchor);
457
+ const lead = document.createElement("span");
458
+ lead.className = "chip-sep project-chip-sep";
459
+ add(lead);
264
460
  const make = (value, label, title) => {
265
461
  const button = document.createElement("button");
266
462
  button.type = "button";
@@ -269,14 +465,42 @@
269
465
  button.dataset.value = value;
270
466
  button.textContent = label;
271
467
  if (title) button.title = title;
468
+ if (value === selected) button.classList.add("on");
272
469
  wireChip(button);
273
470
  add(button);
274
471
  };
275
- for (const [name] of [...totals.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8)) make(name, name);
276
- if (unplaced > 0) make("(none)", "No project", "Sessions whose transcripts do not say which project they belong to");
277
- const sep = document.createElement("span");
278
- sep.className = "chip-sep";
279
- add(sep);
472
+ const projects = [...totals.values()].sort((a, b) => b.bytes - a.bytes || a.identity.localeCompare(b.identity));
473
+ const nameCounts = new Map();
474
+ for (const project of projects) nameCounts.set(project.name, (nameCounts.get(project.name) ?? 0) + 1);
475
+ let shownProjects = projects.slice(0, 8);
476
+ const selectedProject = projects.find((project) => project.identity === selected);
477
+ if (selectedProject && !shownProjects.some((project) => project.identity === selected)) {
478
+ // A refresh can change the size ranking while someone is looking at one
479
+ // project. Keep that exact active identity in the controls instead of
480
+ // silently clearing the filter merely because it fell from 8th to 9th.
481
+ shownProjects = [...shownProjects.slice(0, 7), selectedProject];
482
+ }
483
+ for (const project of shownProjects) {
484
+ const paths = [...project.paths].sort();
485
+ const duplicateName = nameCounts.get(project.name) > 1;
486
+ const location = paths[0] ? compactProjectPath(paths[0]) : "";
487
+ // Same-named projects need visible locations, not two identical buttons.
488
+ // A truncated stable key is only the last-resort distinction for legacy
489
+ // rows whose metadata has no working directory at all.
490
+ const suffix = duplicateName
491
+ ? location || (project.projectKey ? `project …${project.projectKey.slice(-8)}` : "location unavailable")
492
+ : "";
493
+ const label = suffix ? `${project.name} · ${suffix}` : project.name;
494
+ const title = paths.length
495
+ ? `Sessions for ${project.name} in ${paths.join(", ")}`
496
+ : `Sessions for this ${project.name} project`;
497
+ make(project.identity, label, title);
498
+ }
499
+ if (unplaced > 0) make(NO_PROJECT, "No project", "Sessions whose transcripts do not say which project they belong to");
500
+ // No trailing separator: the static one at the age anchor already sits
501
+ // there, and adding another doubled it while the group's LEADING edge ran
502
+ // straight on from the state chips.
503
+
280
504
  }
281
505
 
282
506
  /**
@@ -286,12 +510,37 @@
286
510
  * other than a card statement. Free vaults get nothing here rather than an
287
511
  * upsell: a bar reading "0 of 0" would be worse than silence.
288
512
  */
289
- async function drawPlan() {
513
+ async function drawPlan(statusReady = api("/v1/status"), targetsReady = api("/v1/storage/targets")) {
514
+ // Storage configuration must not wait behind a slow account-plan read.
515
+ // Free/BYOB storage is local machine state and can answer first.
516
+ hasWritableStorageTarget = false;
517
+ draw();
518
+ refreshChoice();
519
+ void targetsReady.then((payload) => {
520
+ const targets = Array.isArray(payload?.targets) ? payload.targets : [];
521
+ hasStorageTarget = targets.length > 0;
522
+ hasWritableStorageTarget = targets.some((target) => target?.connected === true && target?.enabled !== false);
523
+ for (const storageChip of $("chips").querySelectorAll(".storage-chip")) {
524
+ storageChip.hidden = !hasStorageTarget;
525
+ if (!hasStorageTarget) {
526
+ storageChip.classList.remove("on");
527
+ storageChip.setAttribute("aria-pressed", "false");
528
+ }
529
+ }
530
+ if (!hasStorageTarget) {
531
+ const activeState = $("chips").querySelector('.chip.on[data-group="state"]');
532
+ if (!activeState) {
533
+ const ready = $("chips").querySelector('.chip[data-group="state"][data-value="unarchived"]');
534
+ ready?.classList.add("on");
535
+ ready?.setAttribute("aria-pressed", "true");
536
+ }
537
+ }
538
+ draw();
539
+ refreshChoice();
540
+ }).catch(() => undefined);
290
541
  try {
291
- const status = await api("/v1/status");
542
+ const status = await statusReady;
292
543
  if (!status.plan || !status.quota) return;
293
- // The cloud-state chips only mean something once the account has storage.
294
- for (const proChip of $("chips").querySelectorAll(".pro-chip")) proChip.hidden = false;
295
544
  const used = Number(status.quota.usedBytes) || 0;
296
545
  const limit = Number(status.quota.quotaBytes) || 0;
297
546
  const room = limit - used;
@@ -317,24 +566,51 @@
317
566
  pollTimer = setInterval(async () => {
318
567
  try {
319
568
  const data = await api("/v1/sessions");
320
- sessions = data.sessions;
321
- draw();
322
- refreshChoice();
569
+ const loadedAt = new Date().toISOString();
570
+ acceptSessionData(data, loadedAt);
571
+ writeSessionCache(currentVaultId, lastSessionData, loadedAt);
323
572
  reportProgress();
324
- } catch { /* one missed poll is fine; the next tick retries */ }
573
+ } catch {
574
+ // The next tick retries, but until then say that the visible rows are
575
+ // the last successful answer instead of silently presenting them as live.
576
+ if (lastSessionData) setSessionSummary(lastSessionData, "failed", lastLoadedAt);
577
+ }
325
578
  }, 3000);
326
579
  }
327
580
  function reportProgress() {
328
581
  const queued = sessions.filter((row) => row.queued);
329
- const waiting = queued.filter((row) => row.held).length;
330
- const sealing = queued.length;
331
- if (sealing > 0) {
332
- $("result").textContent = waiting === sealing
582
+ const paused = queued.filter((row) => row.heldKind === "policy");
583
+ const heldActive = queued.filter((row) => row.held && row.heldKind !== "policy");
584
+ const waiting = heldActive.length;
585
+ const active = queued.length - paused.length;
586
+ if (queued.length > 0 && typeof state !== "undefined" && state?.daemon?.phraseRemembered === false) {
587
+ // No amount of three-second polling can make a locked vault progress.
588
+ // Stop the loop and put the exact local remedy beside the waiting rows;
589
+ // returning to Sessions after storing the phrase kicks the drain again.
590
+ stopPolling();
591
+ $("result").textContent = `${queued.length} session${queued.length === 1 ? " is" : "s are"} waiting. This machine cannot encrypt them until it remembers your recovery phrase. `;
592
+ const fix = document.createElement("a");
593
+ fix.href = "#recovery-phrase";
594
+ fix.textContent = "Store recovery phrase";
595
+ $("result").append(fix);
596
+ return;
597
+ }
598
+ if (active > 0) {
599
+ $("result").textContent = waiting === active && heldActive.every((row) => row.heldKind === "space")
333
600
  // The guard's own sentence ends without punctuation, so this used to run
334
601
  // straight into the instruction: "...rather than filling the disk Free
335
602
  // some space and they seal on their own."
336
- ? `${waiting} waiting for disk room — ${String(queued.find((row) => row.held).held).replace(/[.\s]+$/, "")}. Free some space and they seal on their own.`
337
- : `Sealing in the background — ${sealing - waiting} working${waiting ? `, ${waiting} waiting for room` : ""}. You can close or refresh this page; it carries on without it.`;
603
+ ? `${waiting} waiting for disk room — ${String(heldActive[0].held).replace(/[.\s]+$/, "")}. Free some space and they seal on their own.`
604
+ : waiting === active
605
+ ? `${waiting} waiting to retry — ${String(heldActive[0].held).replace(/[.\s]+$/, "")}. Sealkeep will retry on its own.`
606
+ : `Sealing in the background — ${active - waiting} working${waiting ? `, ${waiting} waiting to retry` : ""}${paused.length ? `, ${paused.length} paused in Settings` : ""}. You can close or refresh this page; it carries on without it.`;
607
+ } else if (paused.length > 0) {
608
+ // A policy pause changes only when the person changes Settings. Polling
609
+ // every three seconds cannot make progress and used to keep this browser
610
+ // and the local API busy forever. Re-entering Sessions or changing the
611
+ // setting performs a normal refresh.
612
+ stopPolling();
613
+ $("result").textContent = `${paused.length} queued session${paused.length === 1 ? " is" : "s are"} paused by this machine's Settings. ${paused.length === 1 ? "It remains" : "They remain"} untouched and will continue automatically when that agent or folder is enabled.`;
338
614
  } else {
339
615
  stopPolling();
340
616
  $("result").textContent = "Everything queued is sealed. The originals are untouched — reclaim them from the retention page when you are ready.";
@@ -344,34 +620,136 @@
344
620
  const active = $("chips").querySelector('.chip[data-group="state"].on');
345
621
  if (active?.dataset.value === "sealing") {
346
622
  active.classList.remove("on");
347
- $("chips").querySelector('.chip[data-group="state"][data-value="archived"]')?.classList.add("on");
623
+ active.setAttribute("aria-pressed", "false");
624
+ const archived = $("chips").querySelector('.chip[data-group="state"][data-value="archived"]');
625
+ archived?.classList.add("on");
626
+ archived?.setAttribute("aria-pressed", "true");
348
627
  draw();
349
628
  refreshChoice();
350
629
  }
351
630
  }
352
631
  }
353
632
 
354
- async function load() {
355
- try {
356
- const data = await api("/v1/sessions");
357
- sessions = data.sessions;
358
- buildProjectChips();
359
- // A selection can go stale across a refresh: anything now archived or
360
- // already queued must not be re-submitted.
361
- for (const row of sessions) if (row.archived || row.queued) chosen.delete(row.path);
362
- const done = sessions.filter((item) => item.archived).length;
363
- $("summary").textContent = `${bytes(data.totalBytes)} across ${sessions.length} sessions. `
364
- + `${bytes(data.unarchivedBytes)} in ${sessions.length - done} not yet archived.`;
633
+ async function load(statusReady = null) {
634
+ // Start the slow disk walk immediately, but attach its rejection handler
635
+ // before waiting for the fast identity used to validate the saved view.
636
+ const sessionsReady = api("/v1/sessions").then(
637
+ (value) => ({ value }),
638
+ (error) => ({ error })
639
+ );
640
+ const identityReady = (statusReady ?? api("/v1/status")).then(
641
+ (value) => ({ value }),
642
+ (error) => ({ error })
643
+ );
644
+
645
+ // On an ordinary revisit the already-rendered rows are the immediate
646
+ // fallback. Label them now; neither a slow identity response nor a slow
647
+ // disk walk should postpone the fact that a refresh is in progress.
648
+ const immediateFallback = lastSessionData
649
+ ? { data: lastSessionData, loadedAt: lastLoadedAt }
650
+ : null;
651
+ if (immediateFallback) {
652
+ sessionEligibilityLive = false;
365
653
  draw();
366
654
  refreshChoice();
367
- // A background seal keeps going whether or not this view is open. If one
368
- // is running, a fresh visit picks the progress line straight back up.
369
- if (sessions.some((row) => row.queued)) { reportProgress(); startPolling(); }
370
- } catch (error) {
655
+ setSessionSummary(immediateFallback.data, "updating", immediateFallback.loadedAt);
656
+ }
657
+
658
+ const first = await Promise.race([
659
+ sessionsReady.then((result) => ({ kind: "sessions", result })),
660
+ identityReady.then((identity) => ({ kind: "identity", identity }))
661
+ ]);
662
+ if (first.kind === "sessions" && !first.result.error) {
663
+ // Live rows are safe to paint without waiting for account identity: they
664
+ // came from this local agent just now. Identity is needed only before
665
+ // persisting them for a future reload.
666
+ const loadedAt = new Date().toISOString();
667
+ acceptSessionData(first.result.value, loadedAt);
668
+ const accepted = lastSessionData;
669
+ // Start first, then let reportProgress immediately cancel it when every
670
+ // queued row is policy-paused. Doing these in the opposite order created
671
+ // a fresh timer after the pause handler had correctly stopped nothing.
672
+ if (sessions.some((row) => row.queued)) { startPolling(); reportProgress(); }
673
+ void identityReady.then((identity) => {
674
+ const vaultId = identity.value?.vaultId ?? null;
675
+ if (!vaultId) return;
676
+ if (currentVaultId && currentVaultId !== vaultId) {
677
+ chosen.clear();
678
+ buildProjectChips(true);
679
+ draw();
680
+ refreshChoice();
681
+ }
682
+ currentVaultId = vaultId;
683
+ writeSessionCache(vaultId, accepted, loadedAt);
684
+ });
685
+ return;
686
+ }
687
+ if (first.kind === "sessions" && first.result.error && immediateFallback) {
688
+ // The identity request may itself be slow or unavailable. The user still
689
+ // gets an immediate, honest warning on the rows already in front of them.
690
+ setSessionSummary(immediateFallback.data, "failed", immediateFallback.loadedAt);
691
+ }
692
+
693
+ const identity = first.kind === "identity" ? first.identity : await identityReady;
694
+ const vaultId = identity.value?.vaultId ?? null;
695
+ if (vaultId && lastSessionData && currentVaultId !== vaultId) {
696
+ // This port now fronts a different vault. Do not retain a prior vault's
697
+ // (or identity-less) paths in memory while the new disk walk completes.
698
+ sessions = [];
699
+ sessionEligibilityLive = false;
700
+ lastSessionData = null;
701
+ lastLoadedAt = null;
702
+ chosen.clear();
703
+ stopPolling();
704
+ clearProjectChips();
705
+ draw();
706
+ refreshChoice();
707
+ }
708
+ if (vaultId) currentVaultId = vaultId;
709
+
710
+ let fallback = lastSessionData && (!vaultId || currentVaultId === vaultId)
711
+ ? { data: lastSessionData, loadedAt: lastLoadedAt }
712
+ : null;
713
+ if (!fallback && vaultId) {
714
+ const cached = readSessionCache(vaultId);
715
+ if (cached) {
716
+ acceptSessionData(cached.data, cached.loadedAt, false);
717
+ fallback = { data: lastSessionData, loadedAt: lastLoadedAt };
718
+ }
719
+ }
720
+ if (fallback) setSessionSummary(fallback.data, "updating", fallback.loadedAt);
721
+
722
+ const result = first.kind === "sessions" ? first.result : await sessionsReady;
723
+ if (result.error) {
724
+ if (fallback) {
725
+ // Keep the rows, filters and selection exactly where they were. The
726
+ // warning explicitly names their age so cached never masquerades as live.
727
+ setSessionSummary(fallback.data, "failed", fallback.loadedAt);
728
+ return;
729
+ }
730
+ $("summary").className = "note error";
371
731
  $("summary").textContent = token
372
- ? `Could not read sessions: ${error.message}`
732
+ ? `Could not read sessions: ${result.error.message}`
373
733
  : "Connect the dashboard first so it can read this machine's sessions.";
374
734
  $("rows").textContent = "";
735
+ return;
736
+ }
737
+
738
+ try {
739
+ const loadedAt = new Date().toISOString();
740
+ acceptSessionData(result.value, loadedAt);
741
+ writeSessionCache(vaultId, lastSessionData, loadedAt);
742
+ // A background seal keeps going whether or not this view is open. If one
743
+ // is running, a fresh visit picks the progress line straight back up.
744
+ if (sessions.some((row) => row.queued)) { startPolling(); reportProgress(); }
745
+ } catch (error) {
746
+ if (fallback) {
747
+ setSessionSummary(fallback.data, "failed", fallback.loadedAt);
748
+ } else {
749
+ $("summary").className = "note error";
750
+ $("summary").textContent = `Could not read sessions: ${error.message}`;
751
+ $("rows").textContent = "";
752
+ }
375
753
  }
376
754
  }
377
755
 
@@ -380,8 +758,13 @@
380
758
  $("f-search").addEventListener("input", () => { draw(); refreshChoice(); });
381
759
  $("f-sort").addEventListener("change", () => { draw(); refreshChoice(); });
382
760
  $("f-clear").onclick = () => {
383
- for (const other of $("chips").querySelectorAll(".chip")) other.classList.remove("on");
384
- $("chips").querySelector('.chip[data-value="unarchived"]').classList.add("on");
761
+ for (const other of $("chips").querySelectorAll(".chip")) {
762
+ other.classList.remove("on");
763
+ other.setAttribute("aria-pressed", "false");
764
+ }
765
+ const ready = $("chips").querySelector('.chip[data-value="unarchived"]');
766
+ ready.classList.add("on");
767
+ ready.setAttribute("aria-pressed", "true");
385
768
  $("f-search").value = "";
386
769
  $("f-sort").value = "size";
387
770
  draw();
@@ -415,14 +798,20 @@
415
798
  const failures = outcome.results.filter((item) => !item.ok);
416
799
  chosen.clear();
417
800
  const data = await api("/v1/sessions");
418
- sessions = data.sessions;
419
- // The rows just queued belong to "Sealing now", not "Not archived yet" —
801
+ const loadedAt = new Date().toISOString();
802
+ acceptSessionData(data, loadedAt);
803
+ writeSessionCache(currentVaultId, lastSessionData, loadedAt);
804
+ // The rows just queued belong to "Queued to seal", not "Ready to archive" —
420
805
  // staying on the old chip would show an empty table the moment the
421
806
  // person acted. Follow the work: switch to the chip that watches it.
422
807
  if (outcome.queued > 0) {
423
808
  const sealingChip = $("chips").querySelector('.chip[data-group="state"][data-value="sealing"]');
424
- for (const other of $("chips").querySelectorAll('.chip[data-group="state"]')) other.classList.remove("on");
809
+ for (const other of $("chips").querySelectorAll('.chip[data-group="state"]')) {
810
+ other.classList.remove("on");
811
+ other.setAttribute("aria-pressed", "false");
812
+ }
425
813
  sealingChip?.classList.add("on");
814
+ sealingChip?.setAttribute("aria-pressed", "true");
426
815
  }
427
816
  draw();
428
817
  refreshChoice();
@@ -434,6 +823,36 @@
434
823
  $("archive").disabled = chosen.size === 0;
435
824
  }
436
825
  };
826
+
827
+ $("send-archived").onclick = async () => {
828
+ // Recompute from the filters at click time. Stale cached rows without the
829
+ // explicit API eligibility field are excluded rather than guessed in.
830
+ const ids = [...new Set(sendableShown().map((item) => item.sendableArchiveId))];
831
+ if (!sessionEligibilityLive || !hasWritableStorageTarget || ids.length === 0 || ids.length > MAX_ROUTED_SEND) return;
832
+ const send = $("send-archived");
833
+ send.disabled = true;
834
+ send.textContent = "Starting normal storage routing…";
835
+ $("result").textContent = `Starting ${ids.length} complete sealed chain${ids.length === 1 ? "" : "s"}…`;
836
+ try {
837
+ const outcome = await api("/v1/upload", { method: "POST", body: JSON.stringify({ ids }) });
838
+ if (outcome.upload && typeof drawUpload === "function") {
839
+ drawUpload(outcome.upload);
840
+ if ((!outcome.upload.finishedAt || outcome.upload.cleanupRetryAt) && typeof watchUpload === "function") watchUpload(outcome.upload);
841
+ }
842
+ $("result").textContent = outcome.alreadyRunning
843
+ ? "Another storage transfer is already running. These rows were not added; follow Queue, then try again."
844
+ : `Queued ${ids.length} complete sealed chain${ids.length === 1 ? "" : "s"} for normal storage routing. Follow progress in Queue.`;
845
+ // The successful request consumed this live eligibility snapshot.
846
+ // Queue completion refreshes Sessions; until then, do not offer the
847
+ // same heads again from stale rows.
848
+ if (!outcome.alreadyRunning) sessionEligibilityLive = false;
849
+ } catch (error) {
850
+ $("result").textContent = `Nothing was sent to storage: ${error.message}`;
851
+ } finally {
852
+ draw();
853
+ refreshChoice();
854
+ }
855
+ };
437
856
  }
438
857
 
439
858
  let initialised = false;
@@ -441,11 +860,15 @@
441
860
  // every later entry shows the already-rendered table instantly and refreshes
442
861
  // the data underneath it — no placeholder, no flash, no lost scroll.
443
862
  window.initSessionsView = function () {
863
+ const statusReady = api("/v1/status");
444
864
  if (!initialised) {
445
865
  initialised = true;
446
866
  wire();
447
- void drawPlan();
448
867
  }
449
- void load();
868
+ // Account identity and plan share one read with cache validation. Storage
869
+ // targets are refreshed on every entry because connect/pause changes are
870
+ // machine state, not a property of the cached session walk.
871
+ void drawPlan(statusReady);
872
+ void load(statusReady);
450
873
  };
451
874
  })();