slidemind 0.3.19 → 0.4.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 (186) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/AGENTS.md +591 -15
  4. package/README.md +202 -7
  5. package/bin/slidemind.mjs +50 -2
  6. package/package.json +2 -2
  7. package/skills/create-slides/SKILL.md +484 -886
  8. package/skills/search/SKILL.md +46 -23
  9. package/src/bridge/build-and-push.mjs +548 -73
  10. package/src/bridge/bundle.mjs +100 -31
  11. package/src/bridge/capture.mjs +14 -17
  12. package/src/bridge/client/brands.mjs +157 -18
  13. package/src/bridge/client/engine-routing.mjs +48 -13
  14. package/src/bridge/client/engine-search.mjs +24 -1
  15. package/src/bridge/client/engine-slides.mjs +1 -1
  16. package/src/bridge/client/render.mjs +27 -1
  17. package/src/bridge/client/transport.mjs +499 -23
  18. package/src/bridge/client.mjs +10 -0
  19. package/src/bridge/config.mjs +67 -6
  20. package/src/bridge/engine-download.mjs +0 -10
  21. package/src/bridge/graph-auth.mjs +55 -3
  22. package/src/bridge/image-intent-envelope.mjs +239 -0
  23. package/src/bridge/package-preview.mjs +67 -0
  24. package/src/bridge/sharepoint.mjs +914 -0
  25. package/src/builders/asset-fetch.mjs +203 -0
  26. package/src/builders/block-dividers.mjs +83 -0
  27. package/src/builders/build.mjs +171 -87
  28. package/src/builders/card-height.mjs +294 -0
  29. package/src/builders/chart-hole.mjs +183 -0
  30. package/src/builders/chart-jsx.mjs +410 -0
  31. package/src/builders/chart-labels.mjs +326 -0
  32. package/src/builders/concept-geometry.mjs +370 -0
  33. package/src/builders/concept-intent.mjs +200 -0
  34. package/src/builders/concept-layout.mjs +79 -0
  35. package/src/builders/concept-lines.mjs +509 -0
  36. package/src/builders/concept-lists.mjs +270 -0
  37. package/src/builders/concept-marks.mjs +84 -0
  38. package/src/builders/concept-relationships.mjs +12 -0
  39. package/src/builders/concept-spec.mjs +645 -0
  40. package/src/builders/concept-text.mjs +140 -0
  41. package/src/builders/concept-waves.mjs +57 -0
  42. package/src/builders/copy-fit.mjs +139 -0
  43. package/src/builders/design-tokens.mjs +347 -0
  44. package/src/builders/draft-check.mjs +552 -340
  45. package/src/builders/draft-lines.mjs +369 -0
  46. package/src/builders/edit-route.mjs +29 -0
  47. package/src/builders/figure-sketch.mjs +375 -0
  48. package/src/builders/fold-sets.mjs +639 -0
  49. package/src/builders/gate-server.mjs +9 -4
  50. package/src/builders/gate-session.js +273 -12
  51. package/src/builders/gate-shell.js +1494 -0
  52. package/src/builders/image-intent-check.mjs +308 -0
  53. package/src/builders/image-use-reconcile.mjs +107 -0
  54. package/src/builders/kit-helpers.mjs +121 -0
  55. package/src/builders/layout-names.mjs +32 -0
  56. package/src/builders/legacy-reference-uses.mjs +179 -0
  57. package/src/builders/line-fit.mjs +224 -0
  58. package/src/builders/line-native.mjs +895 -0
  59. package/src/builders/line-relations.mjs +230 -0
  60. package/src/builders/lint-fix.mjs +117 -9
  61. package/src/builders/lint.mjs +25 -20
  62. package/src/builders/list-jsx.mjs +88 -0
  63. package/src/builders/placement.mjs +133 -0
  64. package/src/builders/plan-checks.mjs +135 -0
  65. package/src/builders/plan-comments.mjs +19 -0
  66. package/src/builders/plan-handlers.mjs +95 -0
  67. package/src/builders/plan-markdown-edit.mjs +3 -3
  68. package/src/builders/plan-overlay.js +1530 -1608
  69. package/src/builders/plan.mjs +689 -186
  70. package/src/builders/preset-definitions.json +1 -0
  71. package/src/builders/preset-eval.mjs +244 -0
  72. package/src/builders/preview.html +51 -120
  73. package/src/builders/red-thread-backbone.mjs +135 -36
  74. package/src/builders/repair.mjs +205 -0
  75. package/src/builders/repeat-groups.mjs +540 -0
  76. package/src/builders/resolve-skill.mjs +5 -4
  77. package/src/builders/review-overlay.js +296 -293
  78. package/src/builders/review.mjs +305 -84
  79. package/src/builders/rule-shift.mjs +96 -0
  80. package/src/builders/seat.mjs +354 -0
  81. package/src/builders/settle-driver.mjs +236 -0
  82. package/src/builders/settle.mjs +1448 -0
  83. package/src/builders/slide-prep.mjs +67 -0
  84. package/src/builders/spec-assets.mjs +236 -0
  85. package/src/builders/spec-match.mjs +56 -0
  86. package/src/builders/stack.mjs +413 -0
  87. package/src/builders/storyboard-markdown.mjs +70 -27
  88. package/src/builders/storyboard-schema.mjs +54 -114
  89. package/src/builders/text-blocks.mjs +212 -0
  90. package/src/builders/text-card-fit.mjs +552 -0
  91. package/src/builders/text-stack.mjs +276 -0
  92. package/src/builders/transcribe.mjs +1069 -0
  93. package/src/cli/assets.mjs +164 -0
  94. package/src/cli/common.mjs +173 -12
  95. package/src/cli/concept-spec-support.mjs +76 -0
  96. package/src/cli/concepts.mjs +1002 -103
  97. package/src/cli/deck.mjs +401 -65
  98. package/src/cli/diagnose.mjs +4 -2
  99. package/src/cli/engine-helpers.mjs +17 -10
  100. package/src/cli/engine-results.mjs +76 -3
  101. package/src/cli/help.mjs +232 -28
  102. package/src/cli/install.mjs +9 -0
  103. package/src/cli/maintenance.mjs +104 -0
  104. package/src/cli/read.mjs +2 -1
  105. package/src/cli/ready.mjs +6 -3
  106. package/src/cli/record.mjs +522 -0
  107. package/src/cli/remote.mjs +818 -0
  108. package/src/cli/revision.mjs +686 -0
  109. package/src/cli/search.mjs +30 -11
  110. package/src/cli/session.mjs +56 -13
  111. package/src/cli/setup.mjs +46 -2
  112. package/src/cli/status.mjs +142 -33
  113. package/src/cli/tasks.mjs +246 -0
  114. package/src/cli/transcribe.mjs +854 -0
  115. package/src/cli/workspace-ui.mjs +260 -0
  116. package/src/cli/workspace.mjs +212 -34
  117. package/src/install-channel.mjs +64 -7
  118. package/src/lib/adopt.mjs +346 -25
  119. package/src/lib/concept-spec-binding.mjs +96 -0
  120. package/src/lib/conversions.mjs +70 -6
  121. package/src/lib/convert-folded.mjs +107 -0
  122. package/src/lib/deck-paths.mjs +418 -0
  123. package/src/lib/durable-ids.mjs +47 -0
  124. package/src/lib/edit-operations.mjs +316 -0
  125. package/src/lib/edit-state.mjs +1244 -0
  126. package/src/lib/edit-transitions.mjs +120 -0
  127. package/src/lib/extensions.mjs +501 -0
  128. package/src/lib/image-assets.mjs +465 -0
  129. package/src/lib/image-derivatives.mjs +234 -0
  130. package/src/lib/image-intent-fingerprint.mjs +70 -0
  131. package/src/lib/image-intent-tags.mjs +176 -0
  132. package/src/lib/image-intent.mjs +802 -0
  133. package/src/lib/image-use-overrides.mjs +279 -0
  134. package/src/lib/inline-components.mjs +613 -0
  135. package/src/lib/lint-converted.mjs +68 -4
  136. package/src/lib/merge-json.mjs +163 -0
  137. package/src/lib/overwrite-guard.mjs +3 -3
  138. package/src/lib/presentation-context.mjs +531 -0
  139. package/src/lib/preservation-tags.mjs +157 -0
  140. package/src/lib/preservation-verdict.mjs +1342 -0
  141. package/src/lib/publish.mjs +1557 -0
  142. package/src/lib/rebase.mjs +945 -0
  143. package/src/lib/reconcile-ack.mjs +4 -6
  144. package/src/lib/reconcile.mjs +62 -24
  145. package/src/lib/record-client.mjs +175 -0
  146. package/src/lib/record-state.mjs +151 -0
  147. package/src/lib/remote.mjs +661 -0
  148. package/src/lib/revision.mjs +1210 -0
  149. package/src/lib/slide-guids.mjs +4 -6
  150. package/src/lib/slide-preservation.mjs +423 -0
  151. package/src/lib/slide-selection.mjs +9 -0
  152. package/src/lib/workspace-transaction.mjs +117 -0
  153. package/src/pptx/content-digest.mjs +334 -0
  154. package/src/pptx/deck-assemble.mjs +1278 -0
  155. package/src/pptx/fill/engine.mjs +2 -3
  156. package/src/pptx/fill/freeform.mjs +6 -3
  157. package/src/pptx/ooxml.mjs +20 -0
  158. package/src/pptx/package-evidence.mjs +383 -0
  159. package/src/pptx/tags.mjs +163 -11
  160. package/src/update-check.mjs +22 -4
  161. package/src/workspace/adapters.mjs +222 -0
  162. package/src/workspace/auth.mjs +256 -0
  163. package/src/workspace/autostart.mjs +204 -0
  164. package/src/workspace/daemon.mjs +360 -0
  165. package/src/workspace/discovery.mjs +180 -0
  166. package/src/workspace/edit.mjs +167 -0
  167. package/src/workspace/events.mjs +52 -0
  168. package/src/workspace/gate-client.mjs +290 -0
  169. package/src/workspace/handback.mjs +73 -0
  170. package/src/workspace/lifecycle.mjs +635 -0
  171. package/src/workspace/operations.mjs +200 -0
  172. package/src/workspace/pick-folder.mjs +152 -0
  173. package/src/workspace/registry.mjs +359 -0
  174. package/src/workspace/remote-links.mjs +397 -0
  175. package/src/workspace/remote-status.mjs +359 -0
  176. package/src/workspace/routes.mjs +1331 -0
  177. package/src/workspace/server.mjs +159 -0
  178. package/src/workspace/sync.mjs +1119 -0
  179. package/src/workspace/task-actions.mjs +215 -0
  180. package/src/workspace/tasks.mjs +507 -0
  181. package/src/workspace/ui/app.js +1886 -0
  182. package/src/workspace/ui/index.html +141 -0
  183. package/src/workspace/ui/plan-bridge.js +120 -0
  184. package/src/workspace/ui/sharepoint.svg +59 -0
  185. package/src/workspace/ui/styles.css +423 -0
  186. package/src/builders/visual-families.mjs +0 -46
@@ -68,6 +68,208 @@ const WIRE = {
68
68
  // is tiny, but a debounce still keeps typing off the storage path entirely.
69
69
  const DRAFT_DEBOUNCE_MS = 300;
70
70
 
71
+ // ---------- decisions on the wire ----------
72
+ // A decision POST answers with one of a small set of outcomes, and the page
73
+ // reacts to each differently: a busy deck keeps the answer and re-sends it, a
74
+ // stopped workspace waits for the heartbeat, a newer version reloads, a 401 is a
75
+ // sign-out. These used to collapse into "any 409 = round superseded" and "any
76
+ // failure = alert()", which locked the composer for good and lost answers.
77
+ // Both gates (standalone and embedded) read the same table.
78
+ const DECISION_CODES = new Set([
79
+ "stale_subject",
80
+ "local_conflict", // the record moved under the page: a newer version, like stale_subject
81
+ "deck_busy",
82
+ "approval_in_progress", // another approval is being written: retried like deck_busy
83
+ "already_answered",
84
+ "validation_failed",
85
+ "unauthorized",
86
+ ]);
87
+ // Refusals the runner re-sends by itself (same request, same operationId).
88
+ const RETRY_CODES = new Set(["deck_busy", "approval_in_progress"]);
89
+ const BUSY_RETRY_MS = 3000;
90
+ const OFFLINE_RETRY_MS = 5000;
91
+
92
+ // One id per decision ATTEMPT, reused on every retry of it: the server answers a
93
+ // repeated id idempotently, so a retry after a lost response can never answer
94
+ // twice.
95
+ export function newOperationId() {
96
+ try {
97
+ if (globalThis.crypto && typeof globalThis.crypto.randomUUID === "function") return globalThis.crypto.randomUUID();
98
+ } catch {
99
+ /* fall through to the hand-rolled v4 */
100
+ }
101
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (ch) {
102
+ const r = (Math.random() * 16) | 0;
103
+ return (ch === "x" ? r : (r & 0x3) | 0x8).toString(16);
104
+ });
105
+ }
106
+
107
+ // `status` + parsed body → { ok: true, ack } | { ok: false, code, message, retryable, details, status }.
108
+ // `body` may be null (no JSON); a network failure is classified by the caller as
109
+ // { ok:false, code:"offline" }. The standalone gate answers a settled run with
110
+ // `{ error: "round_settled" }` (a plain string): another window already decided.
111
+ export function classifyDecision(status, body) {
112
+ if (status >= 200 && status < 300) return { ok: true, ack: body && typeof body === "object" ? body : {} };
113
+ const err = body && body.error;
114
+ const raw = err && typeof err === "object" ? err.code : typeof err === "string" ? err : null;
115
+ const out = {
116
+ ok: false,
117
+ status,
118
+ code: raw || null,
119
+ message: err && typeof err === "object" && typeof err.message === "string" ? err.message : "",
120
+ retryable: !!(err && typeof err === "object" && err.retryable),
121
+ details: err && typeof err === "object" && err.details ? err.details : null,
122
+ };
123
+ // Only an EXPLICIT already_answered (or the standalone gate's round_settled)
124
+ // means "answered elsewhere": any other 409 (operation_mismatch,
125
+ // outcome_unknown, unsupported_format, …) is a failure that keeps the drafts
126
+ // and shows the server's words — never a silent switch to "answered".
127
+ if (status === 401) out.code = "unauthorized";
128
+ else if (raw === "round_settled") out.code = "already_answered";
129
+ else if (DECISION_CODES.has(raw)) out.code = raw;
130
+ else if (status >= 500 || raw === "workspace_stopping" || raw === "stopping") out.code = "offline";
131
+ else if (status === 422) out.code = "validation_failed";
132
+ else out.code = raw || "failed";
133
+ return out;
134
+ }
135
+
136
+ // One heartbeat answer → the connection state + the beat. A foreign service on
137
+ // the port (or a non-JSON body) is NOT the workspace answering: it reads as
138
+ // offline, never as "the agent stopped listening".
139
+ export function readBeat(status, body, service) {
140
+ if (status === 401) return { conn: "signedout", beat: null };
141
+ if (!(status >= 200 && status < 300) || !body || typeof body !== "object") return { conn: "offline", beat: null };
142
+ if (service && body.service && body.service !== service) return { conn: "offline", beat: null };
143
+ return { conn: "online", beat: body };
144
+ }
145
+
146
+ // When the agent received the answer to `taskId`, or null.
147
+ export function pickedUpAt(beat, taskId) {
148
+ const a = beat && beat.answered;
149
+ if (!a || !taskId || a.taskId !== taskId) return null;
150
+ return a.pickedUpAt || null;
151
+ }
152
+
153
+ // The decision state machine: one attempt at a time; a busy deck re-sends the
154
+ // SAME request every 3 s, a stopped workspace re-sends it on `retryNow()` (the
155
+ // heartbeat came back) or every 5 s, everything else ends the attempt.
156
+ // `handlers`: onSuccess(ack) · onBusy({ since, elapsedMs, details }) ·
157
+ // onOffline(outcome) · onFailed(outcome). `retryOffline: false` (the standalone
158
+ // gate — its server is the command, and it does not come back) ends an offline
159
+ // attempt as a failure instead of waiting.
160
+ export function createDecisionRunner(opts) {
161
+ const doFetch = opts.fetch;
162
+ const endpoint = opts.endpoint;
163
+ // Wrapped, never stored bare: a browser's setTimeout called as a method of
164
+ // another object throws "Illegal invocation".
165
+ const timers = opts.timers || {
166
+ setTimeout: (fn, ms) => setTimeout(fn, ms),
167
+ clearTimeout: (id) => clearTimeout(id),
168
+ };
169
+ const now = opts.now || Date.now;
170
+ const busyMs = opts.busyRetryMs || BUSY_RETRY_MS;
171
+ const offlineMs = opts.offlineRetryMs || OFFLINE_RETRY_MS;
172
+ const retryOffline = opts.retryOffline !== false;
173
+ let current = null;
174
+
175
+ function finish(c) {
176
+ if (c.timer) timers.clearTimeout(c.timer);
177
+ c.timer = null;
178
+ if (current === c) current = null;
179
+ }
180
+ function schedule(c, ms) {
181
+ if (c.timer) timers.clearTimeout(c.timer);
182
+ c.timer = timers.setTimeout(function () {
183
+ c.timer = null;
184
+ send(c);
185
+ }, ms);
186
+ }
187
+ function send(c) {
188
+ if (current !== c || c.inFlight) return;
189
+ c.inFlight = true;
190
+ c.state = "sending";
191
+ let p;
192
+ try {
193
+ p = doFetch(endpoint, {
194
+ method: "POST",
195
+ headers: { "Content-Type": "application/json" },
196
+ credentials: "same-origin",
197
+ body: JSON.stringify(c.attempt.body),
198
+ });
199
+ } catch (e) {
200
+ p = Promise.reject(e);
201
+ }
202
+ Promise.resolve(p)
203
+ .then(
204
+ function (res) {
205
+ return Promise.resolve(res.json ? res.json() : null)
206
+ .catch(function () {
207
+ return null;
208
+ })
209
+ .then(function (body) {
210
+ return classifyDecision(res.status, body);
211
+ });
212
+ },
213
+ function () {
214
+ return { ok: false, code: "offline", status: 0, message: "", retryable: true, details: null, network: true };
215
+ },
216
+ )
217
+ .then(function (o) {
218
+ c.inFlight = false;
219
+ handle(c, o);
220
+ });
221
+ }
222
+ function handle(c, o) {
223
+ if (current !== c) return; // cancelled meanwhile
224
+ if (o.ok) {
225
+ finish(c);
226
+ c.handlers.onSuccess(o.ack);
227
+ return;
228
+ }
229
+ if (RETRY_CODES.has(o.code)) {
230
+ if (!c.busySince) c.busySince = now();
231
+ c.state = "busy";
232
+ c.handlers.onBusy({ since: c.busySince, elapsedMs: now() - c.busySince, details: o.details });
233
+ schedule(c, busyMs);
234
+ return;
235
+ }
236
+ if (o.code === "offline" && retryOffline) {
237
+ c.state = "offline";
238
+ c.handlers.onOffline(o);
239
+ schedule(c, offlineMs);
240
+ return;
241
+ }
242
+ finish(c);
243
+ c.handlers.onFailed(o);
244
+ }
245
+ return {
246
+ start(attempt, handlers) {
247
+ if (current) return false;
248
+ current = { attempt: attempt, handlers: handlers, timer: null, busySince: null, state: "sending", inFlight: false };
249
+ send(current);
250
+ return true;
251
+ },
252
+ // The heartbeat answered again: re-send a waiting attempt right away.
253
+ retryNow() {
254
+ const c = current;
255
+ if (!c || c.inFlight || (c.state !== "offline" && c.state !== "busy")) return false;
256
+ if (c.timer) timers.clearTimeout(c.timer);
257
+ c.timer = null;
258
+ send(c);
259
+ return true;
260
+ },
261
+ cancel() {
262
+ if (current) finish(current);
263
+ },
264
+ pending() {
265
+ return current ? current.attempt : null;
266
+ },
267
+ state() {
268
+ return current ? current.state : null;
269
+ },
270
+ };
271
+ }
272
+
71
273
  export function createGateSession(cfg) {
72
274
  const name = cfg.name; // "review" | "plan" — storage prefix + wire shape
73
275
  const deck = cfg.deck || "";
@@ -79,9 +281,24 @@ export function createGateSession(cfg) {
79
281
  // would differ by definition).
80
282
  const LEDGER_KEY = "slidemind-" + name + "-ledger:" + deck;
81
283
  const CTX_KEY = "slidemind-" + name + "-ctx:" + deck;
82
- const ROUND_READY_FLAG = "slidemind-" + name + "-round-ready";
284
+ // Per deck as well: the workspace page frames several decks' gates in ONE tab,
285
+ // and a tab's sessionStorage is shared by all of them.
286
+ const ROUND_READY_FLAG = "slidemind-" + name + "-round-ready:" + deck;
287
+ // A one-line message the NEXT load shows ("here is the new version, your
288
+ // comments are kept") — the reload that makes it true wipes the JS context.
289
+ const NOTICE_KEY = "slidemind-" + name + "-notice:" + deck;
83
290
  let draftTimer = null;
84
291
  let pollTimer = null;
292
+ // The decision wire. Embedded, a stopped workspace comes back (the daemon
293
+ // restarts), so an offline attempt waits for it; the standalone gate's server
294
+ // IS the command, so an offline attempt there ends.
295
+ const runner = createDecisionRunner({
296
+ fetch: doFetch,
297
+ endpoint: cfg.endpoint,
298
+ timers: cfg.timers,
299
+ now: cfg.now,
300
+ retryOffline: !!cfg.embedded,
301
+ });
85
302
 
86
303
  function read(key, fallback) {
87
304
  try {
@@ -110,21 +327,25 @@ export function createGateSession(cfg) {
110
327
  clearTimeout(draftTimer);
111
328
  draftTimer = null;
112
329
  }
113
- write(CTX_KEY, Object.assign({ nonce: cfg.round || "" }, extra || {}));
330
+ write(CTX_KEY, Object.assign({ nonce: cfg.round || "", subject: cfg.subject || null }, extra || {}));
114
331
  }
115
332
  // Same write, coalesced — for the per-keystroke callers.
116
333
  function saveCtxSoon(extra) {
117
334
  if (draftTimer) clearTimeout(draftTimer);
118
335
  draftTimer = setTimeout(function () {
119
336
  draftTimer = null;
120
- write(CTX_KEY, Object.assign({ nonce: cfg.round || "" }, extra || {}));
337
+ write(CTX_KEY, Object.assign({ nonce: cfg.round || "", subject: cfg.subject || null }, extra || {}));
121
338
  }, DRAFT_DEBOUNCE_MS);
122
339
  }
123
340
 
124
341
  // Restore this tab's session and work out whether THIS load is a new round.
125
342
  // Returns the ledger plus the round state; the caller applies its own ctx
126
343
  // fields from the returned `ctx` and its own round-arrival cosmetics.
127
- function restore(seed) {
344
+ // `opts.hold(ctx)` may keep a changed nonce from counting as a new round: in
345
+ // the workspace the agent's edits change the content (and so the nonce) while
346
+ // it is still revising, and that is not the next round — the agent has not
347
+ // asked yet, so nothing it was handed is "addressed".
348
+ function restore(seed, opts) {
128
349
  let ledger = read(LEDGER_KEY, []) || [];
129
350
  if (!Array.isArray(ledger)) ledger = [];
130
351
  // A tab left open across the upgrade from the queueing release still holds
@@ -138,15 +359,26 @@ export function createGateSession(cfg) {
138
359
  let roundNo = ctx && typeof ctx.round === "number" ? ctx.round : 1;
139
360
  // A different SERVER nonce = a genuinely newer run served this page, i.e. a
140
361
  // new round. Same nonce = a plain refresh of the round already on screen.
141
- const newRound = !!(ctx && ctx.nonce && cfg.round && ctx.nonce !== cfg.round);
362
+ const nonceMoved = !!(ctx && ctx.nonce && cfg.round && ctx.nonce !== cfg.round);
363
+ const held = nonceMoved && !!(opts && typeof opts.hold === "function" && opts.hold(ctx));
364
+ const newRound = nonceMoved && !held;
365
+ // The agent asked again on BYTE-IDENTICAL content (it read the comments and
366
+ // changed nothing): a new round, but nothing was worked in — the comments it
367
+ // was handed stay "sent", and the page must not say "updated".
368
+ const unchanged = newRound && !!(ctx && ctx.subject && cfg.subject && ctx.subject === cfg.subject);
369
+ const hadSent = ledger.some(function (c) {
370
+ return c.status === "sent";
371
+ });
142
372
  if (newRound) {
143
373
  roundNo += 1;
144
- ledger.forEach(function (c) {
145
- if (c.status === "sent") {
146
- c.status = "addressed";
147
- c.addressedInRound = roundNo;
148
- }
149
- });
374
+ if (!unchanged) {
375
+ ledger.forEach(function (c) {
376
+ if (c.status === "sent") {
377
+ c.status = "addressed";
378
+ c.addressedInRound = roundNo;
379
+ }
380
+ });
381
+ }
150
382
  }
151
383
  // Seed a tab with no session of its own (a fresh window) from the deck's
152
384
  // persisted round. These are the rows the agent has JUST been handed — they
@@ -165,7 +397,22 @@ export function createGateSession(cfg) {
165
397
  // sent this round; approving the same render again would only 409), and
166
398
  // ends the moment a newer run serves the page.
167
399
  const working = !newRound && !!(ctx && ctx.working);
168
- return { ledger, roundNo, working, newRound, ctx };
400
+ return { ledger, roundNo, working, newRound, held, unchanged, hadSent, ctx };
401
+ }
402
+
403
+ // The one-line message for the next load (see NOTICE_KEY): written right
404
+ // before a reload, consumed once at boot.
405
+ function noteForNextLoad(note) {
406
+ write(NOTICE_KEY, note);
407
+ }
408
+ function consumeNote() {
409
+ const note = read(NOTICE_KEY, null);
410
+ try {
411
+ store.removeItem(NOTICE_KEY);
412
+ } catch {
413
+ /* storage blocked — nothing was stored either */
414
+ }
415
+ return note && typeof note === "object" ? note : null;
169
416
  }
170
417
 
171
418
  // Where a row stands, for the ledger rail / sidebar chip.
@@ -265,6 +512,9 @@ export function createGateSession(cfg) {
265
512
  // a tab buried in a background window must not swallow the round invisibly,
266
513
  // so the server opens a fresh browser tab and answers `action: "close"` here,
267
514
  // which retires this tab rather than reloading it into a hidden duplicate.
515
+ // STANDALONE gates only: inside the workspace page the shell's heartbeat
516
+ // monitor watches the round for the page's whole life (it never times out —
517
+ // the workspace page has no "session ended").
268
518
  function startNextRoundPolling(handlers) {
269
519
  if (pollTimer) return; // one poll per page life (working mode can re-enter)
270
520
  const startedAt = Date.now();
@@ -331,5 +581,16 @@ export function createGateSession(cfg) {
331
581
  consumeRoundFlag,
332
582
  announceNewRound,
333
583
  startNextRoundPolling,
584
+ noteForNextLoad,
585
+ consumeNote,
586
+ // the decision wire (see createDecisionRunner)
587
+ decide: runner.start,
588
+ retryDecision: runner.retryNow,
589
+ cancelDecision: runner.cancel,
590
+ decisionPending: runner.pending,
591
+ decisionState: runner.state,
592
+ newOperationId,
593
+ readBeat,
594
+ pickedUpAt,
334
595
  };
335
596
  }