ocuclaw 1.3.2 → 1.3.3

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.
@@ -19,19 +19,109 @@ export function isTerminalOutcome(outcome) {
19
19
  return !!(outcome && typeof outcome.result === "string" && TERMINAL_RESULTS.has(outcome.result));
20
20
  }
21
21
 
22
+ // Provenance enum for GlassEvents and wake envelopes (roadmap 6b, §2.6).
23
+ // Only "gesture" is an enabled wake origin at launch; the others reserve the
24
+ // event-to-wake categories (schedule/threshold/system) so enabling them later
25
+ // is a policy change, not a schema migration.
26
+ export const GLASS_EVENT_ORIGINS = ["gesture", "schedule", "threshold", "system"];
27
+
28
+ // Session keys reach the store in two forms: the canonical agent ctx form
29
+ // ("agent:<id>:<key>", what registerTool factories and agent_end hooks see)
30
+ // and the stripped relay form ("<key>", what relay-side callbacks like the
31
+ // glasses-disconnect drain carry). The suffix is the identity; the prefix is
32
+ // routing. Normalizing at the store boundary makes every caller agnostic —
33
+ // the same lesson as the 6f busy-tracker key fix (drift #2, `0cc639b9`).
34
+ export function normalizeGlassesSessionKey(key) {
35
+ return typeof key === "string" ? key.replace(/^agent:[^:]+:/, "") : key;
36
+ }
37
+
22
38
  export function createSurfaceStore(deps = {}) {
39
+ // Census identity: every store instance carries a storeId so lifecycle
40
+ // traces can tell WHICH plugin-load context's store handled a render/reap
41
+ // (drift #3, 2026-06-12: a queued wake run's collect landed in a sibling
42
+ // context's empty store and minted a phantom root — invisible without this).
43
+ const storeId =
44
+ typeof deps.storeId === "string" && deps.storeId
45
+ ? deps.storeId
46
+ : `st-${Math.random().toString(36).slice(2, 8)}`;
47
+ const emitLifecycle =
48
+ typeof deps.emitLifecycle === "function" ? deps.emitLifecycle : () => {};
23
49
  const pauseCron = typeof deps.pauseCron === "function" ? deps.pauseCron : () => {};
24
50
  const resumeCron = typeof deps.resumeCron === "function" ? deps.resumeCron : () => {};
25
51
  const stopCron = typeof deps.stopCron === "function" ? deps.stopCron : () => {};
52
+ const now = typeof deps.now === "function" ? deps.now : Date.now;
26
53
  const mintSurfaceId =
27
54
  typeof deps.mintSurfaceId === "function"
28
55
  ? deps.mintSurfaceId
29
56
  : () => `ui-${Math.random().toString(36).slice(2, 10)}`;
57
+ const mintUuid =
58
+ typeof deps.mintUuid === "function"
59
+ ? deps.mintUuid
60
+ : () => `su-${Math.random().toString(36).slice(2, 10)}${Math.random().toString(36).slice(2, 6)}`;
30
61
  // surfaceId -> { sessionKey, kind, pending: (resolver|null), lastContent,
31
- // state, queuedEvent, exitLatched }
62
+ // state, queuedEvent, exitLatched, uuid, events, queueMode }
32
63
  const bySurface = new Map();
33
64
  const stackBySession = new Map(); // sessionKey -> [surfaceId, ...] (bottom→top)
34
65
 
66
+ // --- Parked-outcome event log + dead-letter (roadmap 6a, §2.6) ---
67
+ // Nonterminal outcomes append to a per-surface GlassEvent log; "latest" is
68
+ // the default read-time delivery reducer ("log" declarable per surface).
69
+ // Reap paths (drain/exit/popBack) hand undelivered events to a per-session
70
+ // dead-letter instead of destroying them: a ✓-acked tap must NEVER silently
71
+ // disappear (the 2026-06-12 panel's fatal). Wake/voicemail legs (6f/7b)
72
+ // drain the dead-letter; this wave only guarantees survival.
73
+ const DEAD_LETTER_EVENT_CAP = 32; // per session, FIFO eviction
74
+ const SURFACE_EVENT_LOG_CAP = 32; // per surface, FIFO eviction
75
+ const deadLetterBySession = new Map(); // sessionKey -> [{surfaceUuid, surfaceId, events, reason, reapedAtMs}]
76
+ let eventSeq = 0;
77
+
78
+ function deadLetterFor(sessionKey) {
79
+ let list = deadLetterBySession.get(sessionKey);
80
+ if (!list) { list = []; deadLetterBySession.set(sessionKey, list); }
81
+ return list;
82
+ }
83
+
84
+ function deadLetterEntryEvents(sessionKey, surfaceId, entry, reason) {
85
+ // A latched terminal means the wearer ended the surface — parked
86
+ // nonterminals beneath it are no longer intent to deliver.
87
+ if (!entry || entry.exitLatched || !entry.events || entry.events.length === 0) return;
88
+ const eventIds = entry.events.map((e) => e.eventId);
89
+ const list = deadLetterFor(sessionKey);
90
+ list.push({
91
+ surfaceUuid: entry.uuid,
92
+ surfaceId,
93
+ events: entry.events,
94
+ reason,
95
+ reapedAtMs: now(),
96
+ // The surface's declared staleness window travels with the reaped
97
+ // events so the 6f/7b consumers (wake/voicemail) can frame age honestly.
98
+ staleAfterMs: Number.isFinite(entry.staleAfterMs) ? entry.staleAfterMs : null,
99
+ });
100
+ entry.events = [];
101
+ // Reap-path observability: a ✓-acked event leaving the live queue for the
102
+ // dead-letter must be traceable (the contract is "never silently lost").
103
+ emitLifecycle("dead_letter_appended", "debug", {
104
+ sessionKey,
105
+ surfaceId,
106
+ surfaceUuid: entry.uuid,
107
+ reason,
108
+ eventIds,
109
+ count: eventIds.length,
110
+ });
111
+ let total = list.reduce((n, r) => n + r.events.length, 0);
112
+ while (total > DEAD_LETTER_EVENT_CAP && list.length) {
113
+ const oldest = list[0];
114
+ const overflow = total - DEAD_LETTER_EVENT_CAP;
115
+ if (oldest.events.length <= overflow) {
116
+ total -= oldest.events.length;
117
+ list.shift();
118
+ } else {
119
+ oldest.events.splice(0, overflow);
120
+ total -= overflow;
121
+ }
122
+ }
123
+ }
124
+
35
125
  function stackFor(sessionKey) {
36
126
  let s = stackBySession.get(sessionKey);
37
127
  if (!s) { s = []; stackBySession.set(sessionKey, s); }
@@ -53,10 +143,24 @@ export function createSurfaceStore(deps = {}) {
53
143
  state: "visible_pending",
54
144
  queuedEvent: prior ? prior.queuedEvent : null,
55
145
  exitLatched: prior ? !!prior.exitLatched : false,
146
+ // Durable identity + parked-event log carry forward through replace
147
+ // (same surfaceId, same surface as far as the wearer is concerned).
148
+ uuid: prior ? prior.uuid : mintUuid(),
149
+ events: prior ? prior.events : [],
150
+ queueMode: prior && prior.queueMode === "log" ? "log" : "latest",
151
+ // Per-render declaration (NOT carried through replace): register() sets
152
+ // it from each render's meta, so the latest render's spec governs.
153
+ staleAfterMs: null,
154
+ // 7a: title carries forward through replace (same surface, wearer continuity).
155
+ // awaitingAgentResponse resets on a fresh entry — a new render opens a new
156
+ // window so markerFor returns "listening" anyway.
157
+ title: prior ? prior.title : null,
158
+ awaitingAgentResponse: false,
56
159
  };
57
160
  }
58
161
 
59
- function register(sessionKey, surfaceId, meta) {
162
+ function register(rawSessionKey, surfaceId, meta) {
163
+ const sessionKey = normalizeGlassesSessionKey(rawSessionKey);
60
164
  return new Promise((resolve) => {
61
165
  const existing = bySurface.get(surfaceId);
62
166
  if (existing) {
@@ -66,16 +170,36 @@ export function createSurfaceStore(deps = {}) {
66
170
  // onReattached flushes any queued event / latched exit.
67
171
  existing.pending = resolve;
68
172
  if (meta && meta.kind) existing.kind = meta.kind;
173
+ if (meta && (meta.queueMode === "log" || meta.queueMode === "latest")) {
174
+ existing.queueMode = meta.queueMode;
175
+ }
176
+ existing.staleAfterMs = meta && Number.isFinite(meta.staleAfterMs) ? meta.staleAfterMs : null;
177
+ if (meta && typeof meta.title === "string") existing.title = meta.title;
178
+ existing.awaitingAgentResponse = false; // a fresh render opens a new window
69
179
  existing.sessionKey = sessionKey;
70
180
  existing.state = "visible_pending";
71
181
  return;
72
182
  }
73
183
  const entry = makeEntry(sessionKey, meta && meta.kind ? meta.kind : null);
184
+ if (meta && (meta.queueMode === "log" || meta.queueMode === "latest")) {
185
+ entry.queueMode = meta.queueMode;
186
+ }
187
+ entry.staleAfterMs = meta && Number.isFinite(meta.staleAfterMs) ? meta.staleAfterMs : null;
188
+ if (meta && typeof meta.title === "string") entry.title = meta.title;
74
189
  entry.pending = resolve;
75
190
  bySurface.set(surfaceId, entry);
76
191
  });
77
192
  }
78
193
 
194
+ // Every delivered outcome carries the surface's durable identity (roadmap
195
+ // 6b): agents and the 6f wake/voicemail consumers key on surfaceUuid, never
196
+ // on the plugin-internal surfaceId. Additive only — caller fields win.
197
+ function decorateDelivery(entry, outcome) {
198
+ if (!outcome || typeof outcome !== "object" || Array.isArray(outcome)) return outcome;
199
+ if (outcome.surfaceUuid !== undefined) return outcome;
200
+ return { ...outcome, surfaceUuid: entry.uuid };
201
+ }
202
+
79
203
  function resolve(surfaceId, outcome) {
80
204
  const entry = bySurface.get(surfaceId);
81
205
  if (!entry || !entry.pending) return false;
@@ -83,10 +207,14 @@ export function createSurfaceStore(deps = {}) {
83
207
  entry.pending = null; // settle the call; surface entry persists
84
208
  if (isTerminalOutcome(outcome)) {
85
209
  entry.state = "exiting";
210
+ entry.awaitingAgentResponse = false;
86
211
  } else {
87
212
  entry.state = "visible_awaiting_agent";
213
+ // A wearer gesture (selected/back) means the agent is now responding;
214
+ // a window_expired resolve is the timer, not the wearer → parked.
215
+ entry.awaitingAgentResponse = !!(outcome && outcome.result !== "window_expired");
88
216
  }
89
- pending(outcome);
217
+ pending(decorateDelivery(entry, outcome));
90
218
  return true;
91
219
  }
92
220
 
@@ -103,14 +231,47 @@ export function createSurfaceStore(deps = {}) {
103
231
  // resolve any in-flight pending call; Task 11 extends them to also stop
104
232
  // crons and clear the per-session stack. They are the only resolve path that
105
233
  // also DELETES surfaces (terminal teardown), unlike resolve() above.
106
- function drainSession(sessionKey, outcome) {
234
+ // Drains are plugin/host-initiated by definition — stamp origin "system"
235
+ // (never a wearer gesture) unless the caller already typed it.
236
+ function decorateDrainOutcome(entry, outcome) {
237
+ if (!outcome || typeof outcome !== "object" || Array.isArray(outcome)) return outcome;
238
+ return decorateDelivery(entry, {
239
+ ...outcome,
240
+ origin: typeof outcome.origin === "string" ? outcome.origin : "system",
241
+ });
242
+ }
243
+
244
+ function drainSession(rawSessionKey, outcome) {
245
+ const sessionKey = normalizeGlassesSessionKey(rawSessionKey);
107
246
  let n = 0;
108
247
  for (const [surfaceId, entry] of [...bySurface]) {
109
248
  if (entry.sessionKey !== sessionKey) continue;
110
249
  const pending = entry.pending;
111
250
  entry.pending = null;
251
+ deadLetterEntryEvents(sessionKey, surfaceId, entry, "drain_session");
112
252
  bySurface.delete(surfaceId);
113
- if (pending) { pending(outcome); n += 1; }
253
+ if (pending) { pending(decorateDrainOutcome(entry, outcome)); n += 1; }
254
+ }
255
+ return n;
256
+ }
257
+
258
+ // Settle still-pending calls for a session WITHOUT tearing anything down:
259
+ // entries, parked events, crons and the stack all persist (surfaces are
260
+ // DESIGNED to outlive runs — resolve ≠ teardown). This is the run-teardown
261
+ // safety net's correct scope: no leaked pending call / orphan tool_use,
262
+ // and no destruction of parked wearer intent (drift #3, 2026-06-12).
263
+ function settlePending(rawSessionKey, outcome) {
264
+ const sessionKey = normalizeGlassesSessionKey(rawSessionKey);
265
+ let n = 0;
266
+ for (const [, entry] of bySurface) {
267
+ if (entry.sessionKey !== sessionKey || !entry.pending) continue;
268
+ const pending = entry.pending;
269
+ entry.pending = null;
270
+ // The call leaked past its run; the surface stays live and collectable,
271
+ // exactly like the post-window_expired parked state.
272
+ entry.state = "visible_awaiting_agent";
273
+ pending(decorateDrainOutcome(entry, outcome));
274
+ n += 1;
114
275
  }
115
276
  return n;
116
277
  }
@@ -120,8 +281,9 @@ export function createSurfaceStore(deps = {}) {
120
281
  for (const [surfaceId, entry] of [...bySurface]) {
121
282
  const pending = entry.pending;
122
283
  entry.pending = null;
284
+ deadLetterEntryEvents(entry.sessionKey, surfaceId, entry, "drain_all");
123
285
  bySurface.delete(surfaceId);
124
- if (pending) { pending(outcome); n += 1; }
286
+ if (pending) { pending(decorateDrainOutcome(entry, outcome)); n += 1; }
125
287
  }
126
288
  return n;
127
289
  }
@@ -131,16 +293,118 @@ export function createSurfaceStore(deps = {}) {
131
293
  return entry ? entry.state : null;
132
294
  }
133
295
 
134
- function queueEvent(surfaceId, event) {
296
+ function queueEvent(surfaceId, event, opts) {
135
297
  const entry = bySurface.get(surfaceId);
136
298
  if (!entry) return false;
137
299
  if (isTerminalOutcome(event)) {
138
300
  entry.exitLatched = true; // latched exit beats any queued nonterminal
139
301
  entry.queuedEvent = event;
140
- } else if (!entry.exitLatched) {
141
- entry.queuedEvent = event; // last-wins among nonterminals
302
+ return { ok: true, eventId: ++eventSeq, surfaceUuid: entry.uuid, kind: "terminal_latch" };
142
303
  }
143
- return true;
304
+ if (entry.exitLatched) {
305
+ // Path D: a wearer gesture on a SYSTEM-origin latch (cron maxDuration death,
306
+ // stamped origin:"system" at tool.ts:945) is NOT the wearer ending the
307
+ // surface — revive it. Clear the latch and fall through to the normal append
308
+ // (truthy receipt → ✓-ack → wake; the next render takes onReattached's deliver
309
+ // path, not discarded_for_exit). A GESTURE-origin latch keeps dropping (the
310
+ // wearer really ended it). Mirrors onReattached's forgiveness (surfaces.ts:373-377).
311
+ const latched = entry.queuedEvent;
312
+ const latchedOrigin = latched && typeof latched.origin === "string" ? latched.origin : "gesture";
313
+ if (latchedOrigin === "gesture") {
314
+ // Wearer-ended surface — drop; never ✓-ack a dead tap.
315
+ return false;
316
+ }
317
+ entry.exitLatched = false;
318
+ entry.queuedEvent = null;
319
+ // fall through to append the reviving gesture below
320
+ }
321
+ const record = {
322
+ eventId: ++eventSeq,
323
+ surfaceUuid: entry.uuid,
324
+ origin: opts && typeof opts.origin === "string" ? opts.origin : "gesture",
325
+ actor: opts && typeof opts.actor === "string" ? opts.actor : "wearer",
326
+ queuedAtMs: now(),
327
+ deliveredVia: null,
328
+ outcome: event,
329
+ };
330
+ entry.events.push(record);
331
+ if (entry.events.length > SURFACE_EVENT_LOG_CAP) {
332
+ entry.events.splice(0, entry.events.length - SURFACE_EVENT_LOG_CAP);
333
+ }
334
+ entry.queuedEvent = event; // legacy newest-nonterminal mirror (carry-forward path)
335
+ return { ok: true, eventId: record.eventId, surfaceUuid: entry.uuid };
336
+ }
337
+
338
+ function titleOf(surfaceId) {
339
+ const entry = bySurface.get(surfaceId);
340
+ return entry ? entry.title : null;
341
+ }
342
+
343
+ // Plugin-authoritative presence marker (roadmap 7a). Pure function of two
344
+ // store facts: is a listen window open, and is anything in flight (a queued
345
+ // tap OR the agent responding to a just-delivered gesture). Glyphs are
346
+ // mapped client-side; the plugin only emits these enum strings.
347
+ function markerFor(surfaceId) {
348
+ const entry = bySurface.get(surfaceId);
349
+ if (!entry) return null;
350
+ if (entry.pending) return "listening";
351
+ if ((entry.events && entry.events.length > 0) || entry.awaitingAgentResponse) return "inflight";
352
+ return "parked";
353
+ }
354
+
355
+ // 7a: called by the agent_end hook to flip inflight→parked for the session's
356
+ // surfaces when the agent turn ends without a new render (silent end).
357
+ function clearAwaitingResponse(rawSessionKey) {
358
+ const sessionKey = normalizeGlassesSessionKey(rawSessionKey);
359
+ for (const [, entry] of bySurface) {
360
+ if (entry.sessionKey === sessionKey) entry.awaitingAgentResponse = false;
361
+ }
362
+ }
363
+
364
+ // 7a: compose the breadcrumb from the session's stack of titles, separator " › ".
365
+ // Returns null when the session has no stack, is empty, or has no titled surfaces.
366
+ function breadcrumbFor(rawSessionKey) {
367
+ const s = stackBySession.get(normalizeGlassesSessionKey(rawSessionKey));
368
+ if (!s || s.length === 0) return null;
369
+ const titles = s
370
+ .map((id) => { const e = bySurface.get(id); return e && typeof e.title === "string" ? e.title : null; })
371
+ .filter((t) => typeof t === "string" && t.length > 0);
372
+ return titles.length ? titles.join(" › ") : null;
373
+ }
374
+
375
+ function uuidOf(surfaceId) {
376
+ const entry = bySurface.get(surfaceId);
377
+ return entry ? entry.uuid : null;
378
+ }
379
+
380
+ function peekEvents(surfaceId) {
381
+ const entry = bySurface.get(surfaceId);
382
+ return entry ? [...entry.events] : [];
383
+ }
384
+
385
+ // Read-time delivery reducer (6a): "latest" collapses to the newest
386
+ // nonterminal (the pre-6a delivery semantics); "log" exposes the ordered
387
+ // sequence for consumers that drain it whole (wake/voicemail, v2 collect).
388
+ function reduceForDelivery(surfaceId) {
389
+ const entry = bySurface.get(surfaceId);
390
+ if (!entry) return null;
391
+ if (entry.queueMode === "log") {
392
+ return { mode: "log", events: [...entry.events] };
393
+ }
394
+ const newest = entry.events.length ? entry.events[entry.events.length - 1] : null;
395
+ return { mode: "latest", outcome: newest ? newest.outcome : null };
396
+ }
397
+
398
+ function peekDeadLetter(sessionKey) {
399
+ const list = deadLetterBySession.get(normalizeGlassesSessionKey(sessionKey));
400
+ return list ? list.map((r) => ({ ...r, events: [...r.events] })) : [];
401
+ }
402
+
403
+ function drainDeadLetter(rawSessionKey) {
404
+ const sessionKey = normalizeGlassesSessionKey(rawSessionKey);
405
+ const list = deadLetterBySession.get(sessionKey) || [];
406
+ deadLetterBySession.set(sessionKey, []);
407
+ return list;
144
408
  }
145
409
 
146
410
  function isExitLatched(surfaceId) {
@@ -151,40 +415,89 @@ export function createSurfaceStore(deps = {}) {
151
415
  function onReattached(surfaceId) {
152
416
  const entry = bySurface.get(surfaceId);
153
417
  if (!entry) return "no_surface";
418
+ let staleLatchDropped = false;
154
419
  if (entry.exitLatched) {
155
- // A terminal latched during the agent's turn. Discard this render and
156
- // resolve the freshly-established pending call (the re-attach render's
157
- // promise) with the latched terminal outcome so the tool call settles
158
- // instead of hanging, then tear down.
159
- entry.state = "exiting";
160
- const terminal = entry.queuedEvent || { result: "dismissed" };
161
- entry.queuedEvent = null;
162
- if (entry.pending) {
163
- const pending = entry.pending;
164
- entry.pending = null;
165
- pending(terminal);
420
+ // Generation gate (roadmap 6d, panel amendment 5): any latch read here
421
+ // was by construction queued under a PRIOR call (queueEvent only
422
+ // latches while no call is pending). A SYSTEM-origin terminal a
423
+ // cron's own maxDuration timeout / recipe_failed summary — is plugin
424
+ // noise relative to the agent's fresh render and must NOT consume the
425
+ // fresh call's window (HW 2026-06-11: a 7-min-stale cron `timeout`
426
+ // ate a fresh 10-min call in 2.2s). Drop it and proceed; the fresh
427
+ // render recycles the slot, which is the teardown the latch wanted.
428
+ // GESTURE terminals (and legacy un-stamped ones — honoring a teardown
429
+ // is the safe default) keep the discard-for-exit contract: the wearer
430
+ // ended the surface and the fresh render must not resurrect it.
431
+ const latched = entry.queuedEvent;
432
+ const latchedOrigin =
433
+ latched && typeof latched.origin === "string" ? latched.origin : "gesture";
434
+ if (latchedOrigin !== "gesture") {
435
+ entry.exitLatched = false;
436
+ entry.queuedEvent = null;
437
+ staleLatchDropped = true;
438
+ } else {
439
+ // A terminal latched during the agent's turn. Discard this render and
440
+ // resolve the freshly-established pending call (the re-attach render's
441
+ // promise) with the latched terminal outcome so the tool call settles
442
+ // instead of hanging, then tear down. Parked nonterminals beneath the
443
+ // latch are dropped (the wearer ended the surface), not dead-lettered.
444
+ entry.state = "exiting";
445
+ const terminal = entry.queuedEvent || { result: "dismissed" };
446
+ entry.queuedEvent = null;
447
+ entry.events = [];
448
+ if (entry.pending) {
449
+ const pending = entry.pending;
450
+ entry.pending = null;
451
+ pending(decorateDelivery(entry, terminal));
452
+ }
453
+ return "discarded_for_exit";
166
454
  }
167
- return "discarded_for_exit";
168
455
  }
169
456
  entry.state = "reattached";
170
- const queued = entry.queuedEvent;
457
+ // "latest"-reducer delivery (unchanged since 6a): the newest nonterminal
458
+ // resolves the re-attached call and the log drains. 6b: the delivery is
459
+ // decorated with the log record's identity + provenance + age, and — when
460
+ // the render declared staleAfterMs — an annotate-only stale flag (the tap
461
+ // is still delivered; degrading a stale actuating tap to a re-confirm is
462
+ // the agent's job, taught in the skill).
463
+ const newest = entry.events.length ? entry.events[entry.events.length - 1] : null;
464
+ let delivered = null;
465
+ if (newest) {
466
+ const parkedForMs = Math.max(0, now() - newest.queuedAtMs);
467
+ delivered = {
468
+ ...newest.outcome,
469
+ surfaceUuid: entry.uuid,
470
+ eventId: newest.eventId,
471
+ origin: newest.origin,
472
+ actor: newest.actor || "wearer",
473
+ queuedAtMs: newest.queuedAtMs,
474
+ parkedForMs,
475
+ };
476
+ if (Number.isFinite(entry.staleAfterMs) && parkedForMs > entry.staleAfterMs) {
477
+ delivered.stale = true;
478
+ }
479
+ } else if (entry.queuedEvent) {
480
+ delivered = decorateDelivery(entry, entry.queuedEvent);
481
+ }
171
482
  entry.queuedEvent = null;
172
- if (queued && entry.pending) {
483
+ entry.events = [];
484
+ if (delivered && entry.pending) {
173
485
  const pending = entry.pending;
174
486
  entry.pending = null;
175
487
  entry.state = "visible_awaiting_agent";
176
- pending(queued);
488
+ entry.awaitingAgentResponse = true; // 7a: a delivered queued gesture = agent now responding
489
+ pending(delivered);
177
490
  }
178
- return "reattached";
491
+ return staleLatchDropped ? "reattached_stale_latch_dropped" : "reattached";
179
492
  }
180
493
 
181
494
  function topSurfaceId(sessionKey) {
182
- const s = stackBySession.get(sessionKey);
495
+ const s = stackBySession.get(normalizeGlassesSessionKey(sessionKey));
183
496
  return s && s.length ? s[s.length - 1] : null;
184
497
  }
185
498
 
186
499
  function stackDepth(sessionKey) {
187
- const s = stackBySession.get(sessionKey);
500
+ const s = stackBySession.get(normalizeGlassesSessionKey(sessionKey));
188
501
  return s ? s.length : 0;
189
502
  }
190
503
 
@@ -197,7 +510,8 @@ export function createSurfaceStore(deps = {}) {
197
510
  // never supplies a surfaceId (spec §Core model — the plugin owns them,
198
511
  // keyed by session + stack). Returns { mode, surfaceId } so the caller can
199
512
  // render against the bound id.
200
- function applyRender(sessionKey, params) {
513
+ function applyRender(rawSessionKey, params) {
514
+ const sessionKey = normalizeGlassesSessionKey(rawSessionKey);
201
515
  const stack = stackFor(sessionKey);
202
516
  const top = stack[stack.length - 1] || null;
203
517
  // First render in a session (empty stack): create a root at depth 1,
@@ -248,18 +562,28 @@ export function createSurfaceStore(deps = {}) {
248
562
  return { mode: "replace", surfaceId: top };
249
563
  }
250
564
 
251
- function popBack(sessionKey) {
565
+ function popBack(rawSessionKey) {
566
+ const sessionKey = normalizeGlassesSessionKey(rawSessionKey);
252
567
  const stack = stackFor(sessionKey);
253
568
  const child = stack.pop();
254
- if (child) { stopCron(child); bySurface.delete(child); }
569
+ if (child) {
570
+ stopCron(child);
571
+ deadLetterEntryEvents(sessionKey, child, bySurface.get(child), "pop_back");
572
+ bySurface.delete(child);
573
+ }
255
574
  const parent = stack[stack.length - 1] || null;
256
575
  if (parent) resumeCron(parent);
257
576
  return parent;
258
577
  }
259
578
 
260
- function exit(sessionKey) {
579
+ function exit(rawSessionKey) {
580
+ const sessionKey = normalizeGlassesSessionKey(rawSessionKey);
261
581
  const stack = stackFor(sessionKey);
262
- for (const id of stack) { stopCron(id); bySurface.delete(id); }
582
+ for (const id of stack) {
583
+ stopCron(id);
584
+ deadLetterEntryEvents(sessionKey, id, bySurface.get(id), "exit");
585
+ bySurface.delete(id);
586
+ }
263
587
  stackBySession.set(sessionKey, []);
264
588
  return true;
265
589
  }
@@ -269,9 +593,12 @@ export function createSurfaceStore(deps = {}) {
269
593
  }
270
594
 
271
595
  return {
272
- register, resolve, hasSurface, isPending, drainSession, drainAll,
596
+ storeId,
597
+ register, resolve, hasSurface, isPending, drainSession, drainAll, settlePending,
273
598
  stateOf, queueEvent, isExitLatched, onReattached,
274
599
  applyRender, popBack, exit, topSurfaceId, stackDepth, sessionKeys, sessionForSurface,
600
+ uuidOf, titleOf, markerFor, clearAwaitingResponse, breadcrumbFor,
601
+ peekEvents, reduceForDelivery, peekDeadLetter, drainDeadLetter,
275
602
  _bySurface: bySurface,
276
603
  };
277
604
  }