ocuclaw 1.3.2 → 1.3.4

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 (84) hide show
  1. package/README.md +29 -1
  2. package/dist/config/runtime-config-session-title-model.test.js +0 -3
  3. package/dist/config/runtime-config.js +22 -33
  4. package/dist/domain/activity-status-adapter.js +0 -7
  5. package/dist/domain/activity-status-arbiter.js +3 -27
  6. package/dist/domain/activity-status-labels.js +8 -38
  7. package/dist/domain/code-span-regions.js +4 -24
  8. package/dist/domain/constant-time-equal.js +9 -0
  9. package/dist/domain/constant-time-equal.test.js +28 -0
  10. package/dist/domain/conversation-state.js +27 -138
  11. package/dist/domain/debug-bundle-cache.js +52 -0
  12. package/dist/domain/debug-bundle-format.js +60 -0
  13. package/dist/domain/debug-bundle-preview.js +123 -0
  14. package/dist/domain/debug-bundle-redaction.js +182 -0
  15. package/dist/domain/debug-bundle-save.js +11 -0
  16. package/dist/domain/debug-bundle-zip.js +15 -0
  17. package/dist/domain/debug-bundle.js +97 -0
  18. package/dist/domain/debug-store.js +6 -17
  19. package/dist/domain/debug-upload-preset.js +27 -0
  20. package/dist/domain/glasses-display-system-prompt.js +0 -5
  21. package/dist/domain/glasses-display-system-prompt.test.js +1 -1
  22. package/dist/domain/glasses-ui-content-summary.js +0 -6
  23. package/dist/domain/glasses-ui-system-prompt.test.js +1 -2
  24. package/dist/domain/message-emoji-allowlist.js +0 -7
  25. package/dist/domain/message-emoji-filter.js +3 -9
  26. package/dist/domain/neural-emoji-reactor-tag-config.js +3 -3
  27. package/dist/domain/prompt-channel-fragments.js +1 -10
  28. package/dist/domain/tagged-span-parser.js +3 -26
  29. package/dist/domain/tagged-span-strip.js +0 -7
  30. package/dist/even-ai/even-ai-endpoint.js +77 -24
  31. package/dist/even-ai/even-ai-run-waiter.js +0 -1
  32. package/dist/even-ai/even-ai-settings-store.js +11 -0
  33. package/dist/gateway/gateway-bridge.js +8 -9
  34. package/dist/gateway/gateway-timing-ledger.js +8 -6
  35. package/dist/gateway/openclaw-client.js +97 -297
  36. package/dist/gateway/sanitize-connect-reason.js +10 -0
  37. package/dist/gateway/sanitize-connect-reason.test.js +34 -0
  38. package/dist/index.js +3 -3
  39. package/dist/runtime/channel-two-hook.js +1 -6
  40. package/dist/runtime/container-env.js +1 -5
  41. package/dist/runtime/debug-bundle-handler.js +159 -0
  42. package/dist/runtime/display-toggle-states.js +6 -17
  43. package/dist/runtime/downstream-handler.js +682 -508
  44. package/dist/runtime/glasses-backpressure-latch.js +93 -0
  45. package/dist/runtime/ocuclaw-settings-store.js +10 -1
  46. package/dist/runtime/openclaw-host-version.js +5 -0
  47. package/dist/runtime/plugin-version-service.js +13 -6
  48. package/dist/runtime/provider-usage-select.js +0 -6
  49. package/dist/runtime/register-session-title-distiller.js +14 -16
  50. package/dist/runtime/relay-core.js +657 -271
  51. package/dist/runtime/relay-service.js +40 -36
  52. package/dist/runtime/relay-worker-approval-replay-cache.js +1 -1
  53. package/dist/runtime/relay-worker-entry.js +1 -2
  54. package/dist/runtime/relay-worker-health.js +2 -10
  55. package/dist/runtime/relay-worker-protocol.js +6 -1
  56. package/dist/runtime/relay-worker-supervisor.js +109 -39
  57. package/dist/runtime/relay-worker-transport.js +157 -15
  58. package/dist/runtime/session-context-service.js +5 -45
  59. package/dist/runtime/session-service.js +157 -175
  60. package/dist/runtime/session-title-distiller-budget.js +1 -5
  61. package/dist/runtime/session-title-distiller-helpers.js +14 -24
  62. package/dist/runtime/session-title-distiller.js +109 -122
  63. package/dist/runtime/session-title-record.js +0 -6
  64. package/dist/runtime/stable-prompt-snapshot.js +3 -14
  65. package/dist/runtime/upstream-runtime.js +600 -103
  66. package/dist/tools/device-info-tool.js +4 -21
  67. package/dist/tools/glasses-ui-cron.js +58 -63
  68. package/dist/tools/glasses-ui-descriptors.js +4 -33
  69. package/dist/tools/glasses-ui-limits.js +0 -13
  70. package/dist/tools/glasses-ui-paint-floor.js +22 -34
  71. package/dist/tools/glasses-ui-recipes.js +92 -101
  72. package/dist/tools/glasses-ui-surfaces.js +295 -100
  73. package/dist/tools/glasses-ui-template.js +7 -22
  74. package/dist/tools/glasses-ui-tool-description.test.js +2 -2
  75. package/dist/tools/glasses-ui-tool.js +475 -331
  76. package/dist/tools/glasses-ui-voicemail.js +242 -0
  77. package/dist/tools/glasses-ui-wake.js +195 -0
  78. package/dist/tools/session-title-tool.js +2 -7
  79. package/dist/tools/session-title-tool.test.js +1 -1
  80. package/dist/version.js +3 -2
  81. package/openclaw.plugin.json +60 -13
  82. package/package.json +3 -2
  83. package/skills/glasses-ui/SKILL.md +19 -3
  84. package/dist/runtime/protocol-adapter.js +0 -387
@@ -1,36 +1,87 @@
1
- // Per-session surface store for the glasses-UI tool. Extracted from
2
- // glasses-ui-tool.ts (Phase 1) so this stateful logic can be unit-tested in
3
- // isolation and so the tool file thins toward a schema+wiring layer (spec
4
- // §Lifecycle detail — Surface/stack lifecycle store).
5
- //
6
- // Phase 2 evolves the Phase-1 createPendingRenderMap IN PLACE into
7
- // createSurfaceStore (SINGLE store — there is never a second store alongside
8
- // it). The decoupled lifecycle means a surface's lifetime is independent of
9
- // any one tool call: resolve() settles the in-flight pending call but the
10
- // surface entry PERSISTS (keyed by surfaceId), and a new register() does NOT
11
- // preempt the previous surface. The legacy createPendingRenderMap name survives
12
- // only as an alias (bottom of this file) for the Phase 1 import path.
13
-
14
- // A terminal outcome ends the surface (teardown / exit). A nonterminal outcome
15
- // (selected/back) keeps the surface alive and hands the turn to the agent.
16
1
  const TERMINAL_RESULTS = new Set(["dismissed", "timeout", "glasses_disconnected", "preempted", "recipe_failed"]);
17
2
 
18
3
  export function isTerminalOutcome(outcome) {
19
4
  return !!(outcome && typeof outcome.result === "string" && TERMINAL_RESULTS.has(outcome.result));
20
5
  }
21
6
 
7
+ export const GLASS_EVENT_ORIGINS = ["gesture", "schedule", "threshold", "system"];
8
+
9
+ export function normalizeGlassesSessionKey(key) {
10
+ return typeof key === "string" ? key.replace(/^agent:[^:]+:/, "") : key;
11
+ }
12
+
22
13
  export function createSurfaceStore(deps = {}) {
14
+
15
+ const storeId =
16
+ typeof deps.storeId === "string" && deps.storeId
17
+ ? deps.storeId
18
+ : `st-${Math.random().toString(36).slice(2, 8)}`;
19
+ const emitLifecycle =
20
+ typeof deps.emitLifecycle === "function" ? deps.emitLifecycle : () => {};
23
21
  const pauseCron = typeof deps.pauseCron === "function" ? deps.pauseCron : () => {};
24
22
  const resumeCron = typeof deps.resumeCron === "function" ? deps.resumeCron : () => {};
25
23
  const stopCron = typeof deps.stopCron === "function" ? deps.stopCron : () => {};
24
+ const now = typeof deps.now === "function" ? deps.now : Date.now;
26
25
  const mintSurfaceId =
27
26
  typeof deps.mintSurfaceId === "function"
28
27
  ? deps.mintSurfaceId
29
28
  : () => `ui-${Math.random().toString(36).slice(2, 10)}`;
30
- // surfaceId -> { sessionKey, kind, pending: (resolver|null), lastContent,
31
- // state, queuedEvent, exitLatched }
29
+ const mintUuid =
30
+ typeof deps.mintUuid === "function"
31
+ ? deps.mintUuid
32
+ : () => `su-${Math.random().toString(36).slice(2, 10)}${Math.random().toString(36).slice(2, 6)}`;
33
+
32
34
  const bySurface = new Map();
33
- const stackBySession = new Map(); // sessionKey -> [surfaceId, ...] (bottom→top)
35
+ const stackBySession = new Map();
36
+
37
+ const DEAD_LETTER_EVENT_CAP = 32;
38
+ const SURFACE_EVENT_LOG_CAP = 32;
39
+ const deadLetterBySession = new Map();
40
+ let eventSeq = 0;
41
+
42
+ function deadLetterFor(sessionKey) {
43
+ let list = deadLetterBySession.get(sessionKey);
44
+ if (!list) { list = []; deadLetterBySession.set(sessionKey, list); }
45
+ return list;
46
+ }
47
+
48
+ function deadLetterEntryEvents(sessionKey, surfaceId, entry, reason) {
49
+
50
+ if (!entry || entry.exitLatched || !entry.events || entry.events.length === 0) return;
51
+ const eventIds = entry.events.map((e) => e.eventId);
52
+ const list = deadLetterFor(sessionKey);
53
+ list.push({
54
+ surfaceUuid: entry.uuid,
55
+ surfaceId,
56
+ events: entry.events,
57
+ reason,
58
+ reapedAtMs: now(),
59
+
60
+ staleAfterMs: Number.isFinite(entry.staleAfterMs) ? entry.staleAfterMs : null,
61
+ });
62
+ entry.events = [];
63
+
64
+ emitLifecycle("dead_letter_appended", "debug", {
65
+ sessionKey,
66
+ surfaceId,
67
+ surfaceUuid: entry.uuid,
68
+ reason,
69
+ eventIds,
70
+ count: eventIds.length,
71
+ });
72
+ let total = list.reduce((n, r) => n + r.events.length, 0);
73
+ while (total > DEAD_LETTER_EVENT_CAP && list.length) {
74
+ const oldest = list[0];
75
+ const overflow = total - DEAD_LETTER_EVENT_CAP;
76
+ if (oldest.events.length <= overflow) {
77
+ total -= oldest.events.length;
78
+ list.shift();
79
+ } else {
80
+ oldest.events.splice(0, overflow);
81
+ total -= overflow;
82
+ }
83
+ }
84
+ }
34
85
 
35
86
  function stackFor(sessionKey) {
36
87
  let s = stackBySession.get(sessionKey);
@@ -38,55 +89,73 @@ export function createSurfaceStore(deps = {}) {
38
89
  return s;
39
90
  }
40
91
 
41
- // A fresh entry starts un-latched with an empty queue. A `prior` entry (an
42
- // in-place content swap of the SAME surfaceId — i.e. `replace`) carries its
43
- // latched exit / last-wins queued event forward so reattach semantics are
44
- // MOVE-INDEPENDENT: a terminal latched (or a nonterminal queued) during
45
- // `visible_awaiting_agent` is honored on the agent's NEXT render whether that
46
- // render is patch, replace (the schema DEFAULT) or push — never silently
47
- // dropped by the rebuild. (Without this carry-forward, a `replace` rebuild
48
- // would reset exitLatched=false / queuedEvent=null and onReattached would
49
- // return "reattached" instead of "discarded_for_exit", failing to tear down.)
50
92
  function makeEntry(sessionKey, kind, prior) {
51
93
  return {
52
94
  sessionKey, kind: kind || null, pending: null, lastContent: null,
53
95
  state: "visible_pending",
54
96
  queuedEvent: prior ? prior.queuedEvent : null,
55
97
  exitLatched: prior ? !!prior.exitLatched : false,
98
+
99
+ uuid: prior ? prior.uuid : mintUuid(),
100
+ events: prior ? prior.events : [],
101
+ queueMode: prior && prior.queueMode === "log" ? "log" : "latest",
102
+
103
+ staleAfterMs: null,
104
+
105
+ title: prior ? prior.title : null,
106
+ awaitingAgentResponse: false,
56
107
  };
57
108
  }
58
109
 
59
- function register(sessionKey, surfaceId, meta) {
110
+ function register(rawSessionKey, surfaceId, meta) {
111
+ const sessionKey = normalizeGlassesSessionKey(rawSessionKey);
60
112
  return new Promise((resolve) => {
61
113
  const existing = bySurface.get(surfaceId);
62
114
  if (existing) {
63
- // Re-attach to an existing surface: replace its pending resolver,
64
- // keep the entry. No preempt of a different surface. Restore the
65
- // pending state so a re-render returns to visible_pending before
66
- // onReattached flushes any queued event / latched exit.
115
+
67
116
  existing.pending = resolve;
68
117
  if (meta && meta.kind) existing.kind = meta.kind;
118
+ if (meta && (meta.queueMode === "log" || meta.queueMode === "latest")) {
119
+ existing.queueMode = meta.queueMode;
120
+ }
121
+ existing.staleAfterMs = meta && Number.isFinite(meta.staleAfterMs) ? meta.staleAfterMs : null;
122
+ if (meta && typeof meta.title === "string") existing.title = meta.title;
123
+ existing.awaitingAgentResponse = false;
69
124
  existing.sessionKey = sessionKey;
70
125
  existing.state = "visible_pending";
71
126
  return;
72
127
  }
73
128
  const entry = makeEntry(sessionKey, meta && meta.kind ? meta.kind : null);
129
+ if (meta && (meta.queueMode === "log" || meta.queueMode === "latest")) {
130
+ entry.queueMode = meta.queueMode;
131
+ }
132
+ entry.staleAfterMs = meta && Number.isFinite(meta.staleAfterMs) ? meta.staleAfterMs : null;
133
+ if (meta && typeof meta.title === "string") entry.title = meta.title;
74
134
  entry.pending = resolve;
75
135
  bySurface.set(surfaceId, entry);
76
136
  });
77
137
  }
78
138
 
139
+ function decorateDelivery(entry, outcome) {
140
+ if (!outcome || typeof outcome !== "object" || Array.isArray(outcome)) return outcome;
141
+ if (outcome.surfaceUuid !== undefined) return outcome;
142
+ return { ...outcome, surfaceUuid: entry.uuid };
143
+ }
144
+
79
145
  function resolve(surfaceId, outcome) {
80
146
  const entry = bySurface.get(surfaceId);
81
147
  if (!entry || !entry.pending) return false;
82
148
  const pending = entry.pending;
83
- entry.pending = null; // settle the call; surface entry persists
149
+ entry.pending = null;
84
150
  if (isTerminalOutcome(outcome)) {
85
151
  entry.state = "exiting";
152
+ entry.awaitingAgentResponse = false;
86
153
  } else {
87
154
  entry.state = "visible_awaiting_agent";
155
+
156
+ entry.awaitingAgentResponse = !!(outcome && outcome.result !== "window_expired");
88
157
  }
89
- pending(outcome);
158
+ pending(decorateDelivery(entry, outcome));
90
159
  return true;
91
160
  }
92
161
 
@@ -99,18 +168,39 @@ export function createSurfaceStore(deps = {}) {
99
168
  return !!(entry && entry.pending);
100
169
  }
101
170
 
102
- // Carried over from createPendingRenderMap (Phase 1). drainSession/drainAll
103
- // resolve any in-flight pending call; Task 11 extends them to also stop
104
- // crons and clear the per-session stack. They are the only resolve path that
105
- // also DELETES surfaces (terminal teardown), unlike resolve() above.
106
- function drainSession(sessionKey, outcome) {
171
+ function decorateDrainOutcome(entry, outcome) {
172
+ if (!outcome || typeof outcome !== "object" || Array.isArray(outcome)) return outcome;
173
+ return decorateDelivery(entry, {
174
+ ...outcome,
175
+ origin: typeof outcome.origin === "string" ? outcome.origin : "system",
176
+ });
177
+ }
178
+
179
+ function drainSession(rawSessionKey, outcome) {
180
+ const sessionKey = normalizeGlassesSessionKey(rawSessionKey);
107
181
  let n = 0;
108
182
  for (const [surfaceId, entry] of [...bySurface]) {
109
183
  if (entry.sessionKey !== sessionKey) continue;
110
184
  const pending = entry.pending;
111
185
  entry.pending = null;
186
+ deadLetterEntryEvents(sessionKey, surfaceId, entry, "drain_session");
112
187
  bySurface.delete(surfaceId);
113
- if (pending) { pending(outcome); n += 1; }
188
+ if (pending) { pending(decorateDrainOutcome(entry, outcome)); n += 1; }
189
+ }
190
+ return n;
191
+ }
192
+
193
+ function settlePending(rawSessionKey, outcome) {
194
+ const sessionKey = normalizeGlassesSessionKey(rawSessionKey);
195
+ let n = 0;
196
+ for (const [, entry] of bySurface) {
197
+ if (entry.sessionKey !== sessionKey || !entry.pending) continue;
198
+ const pending = entry.pending;
199
+ entry.pending = null;
200
+
201
+ entry.state = "visible_awaiting_agent";
202
+ pending(decorateDrainOutcome(entry, outcome));
203
+ n += 1;
114
204
  }
115
205
  return n;
116
206
  }
@@ -120,8 +210,9 @@ export function createSurfaceStore(deps = {}) {
120
210
  for (const [surfaceId, entry] of [...bySurface]) {
121
211
  const pending = entry.pending;
122
212
  entry.pending = null;
213
+ deadLetterEntryEvents(entry.sessionKey, surfaceId, entry, "drain_all");
123
214
  bySurface.delete(surfaceId);
124
- if (pending) { pending(outcome); n += 1; }
215
+ if (pending) { pending(decorateDrainOutcome(entry, outcome)); n += 1; }
125
216
  }
126
217
  return n;
127
218
  }
@@ -131,16 +222,102 @@ export function createSurfaceStore(deps = {}) {
131
222
  return entry ? entry.state : null;
132
223
  }
133
224
 
134
- function queueEvent(surfaceId, event) {
225
+ function queueEvent(surfaceId, event, opts) {
135
226
  const entry = bySurface.get(surfaceId);
136
227
  if (!entry) return false;
137
228
  if (isTerminalOutcome(event)) {
138
- entry.exitLatched = true; // latched exit beats any queued nonterminal
229
+ entry.exitLatched = true;
139
230
  entry.queuedEvent = event;
140
- } else if (!entry.exitLatched) {
141
- entry.queuedEvent = event; // last-wins among nonterminals
231
+ return { ok: true, eventId: ++eventSeq, surfaceUuid: entry.uuid, kind: "terminal_latch" };
142
232
  }
143
- return true;
233
+ if (entry.exitLatched) {
234
+
235
+ const latched = entry.queuedEvent;
236
+ const latchedOrigin = latched && typeof latched.origin === "string" ? latched.origin : "gesture";
237
+ if (latchedOrigin === "gesture") {
238
+
239
+ return false;
240
+ }
241
+ entry.exitLatched = false;
242
+ entry.queuedEvent = null;
243
+
244
+ }
245
+ const record = {
246
+ eventId: ++eventSeq,
247
+ surfaceUuid: entry.uuid,
248
+ origin: opts && typeof opts.origin === "string" ? opts.origin : "gesture",
249
+ actor: opts && typeof opts.actor === "string" ? opts.actor : "wearer",
250
+ queuedAtMs: now(),
251
+ deliveredVia: null,
252
+ outcome: event,
253
+ };
254
+ entry.events.push(record);
255
+ if (entry.events.length > SURFACE_EVENT_LOG_CAP) {
256
+ entry.events.splice(0, entry.events.length - SURFACE_EVENT_LOG_CAP);
257
+ }
258
+ entry.queuedEvent = event;
259
+ return { ok: true, eventId: record.eventId, surfaceUuid: entry.uuid };
260
+ }
261
+
262
+ function titleOf(surfaceId) {
263
+ const entry = bySurface.get(surfaceId);
264
+ return entry ? entry.title : null;
265
+ }
266
+
267
+ function markerFor(surfaceId) {
268
+ const entry = bySurface.get(surfaceId);
269
+ if (!entry) return null;
270
+ if (entry.pending) return "listening";
271
+ if ((entry.events && entry.events.length > 0) || entry.awaitingAgentResponse) return "inflight";
272
+ return "parked";
273
+ }
274
+
275
+ function clearAwaitingResponse(rawSessionKey) {
276
+ const sessionKey = normalizeGlassesSessionKey(rawSessionKey);
277
+ for (const [, entry] of bySurface) {
278
+ if (entry.sessionKey === sessionKey) entry.awaitingAgentResponse = false;
279
+ }
280
+ }
281
+
282
+ function breadcrumbFor(rawSessionKey) {
283
+ const s = stackBySession.get(normalizeGlassesSessionKey(rawSessionKey));
284
+ if (!s || s.length === 0) return null;
285
+ const titles = s
286
+ .map((id) => { const e = bySurface.get(id); return e && typeof e.title === "string" ? e.title : null; })
287
+ .filter((t) => typeof t === "string" && t.length > 0);
288
+ return titles.length ? titles.join(" › ") : null;
289
+ }
290
+
291
+ function uuidOf(surfaceId) {
292
+ const entry = bySurface.get(surfaceId);
293
+ return entry ? entry.uuid : null;
294
+ }
295
+
296
+ function peekEvents(surfaceId) {
297
+ const entry = bySurface.get(surfaceId);
298
+ return entry ? [...entry.events] : [];
299
+ }
300
+
301
+ function reduceForDelivery(surfaceId) {
302
+ const entry = bySurface.get(surfaceId);
303
+ if (!entry) return null;
304
+ if (entry.queueMode === "log") {
305
+ return { mode: "log", events: [...entry.events] };
306
+ }
307
+ const newest = entry.events.length ? entry.events[entry.events.length - 1] : null;
308
+ return { mode: "latest", outcome: newest ? newest.outcome : null };
309
+ }
310
+
311
+ function peekDeadLetter(sessionKey) {
312
+ const list = deadLetterBySession.get(normalizeGlassesSessionKey(sessionKey));
313
+ return list ? list.map((r) => ({ ...r, events: [...r.events] })) : [];
314
+ }
315
+
316
+ function drainDeadLetter(rawSessionKey) {
317
+ const sessionKey = normalizeGlassesSessionKey(rawSessionKey);
318
+ const list = deadLetterBySession.get(sessionKey) || [];
319
+ deadLetterBySession.set(sessionKey, []);
320
+ return list;
144
321
  }
145
322
 
146
323
  function isExitLatched(surfaceId) {
@@ -151,40 +328,70 @@ export function createSurfaceStore(deps = {}) {
151
328
  function onReattached(surfaceId) {
152
329
  const entry = bySurface.get(surfaceId);
153
330
  if (!entry) return "no_surface";
331
+ let staleLatchDropped = false;
154
332
  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);
333
+
334
+ const latched = entry.queuedEvent;
335
+ const latchedOrigin =
336
+ latched && typeof latched.origin === "string" ? latched.origin : "gesture";
337
+ if (latchedOrigin !== "gesture") {
338
+ entry.exitLatched = false;
339
+ entry.queuedEvent = null;
340
+ staleLatchDropped = true;
341
+ } else {
342
+
343
+ entry.state = "exiting";
344
+ const terminal = entry.queuedEvent || { result: "dismissed" };
345
+ entry.queuedEvent = null;
346
+ entry.events = [];
347
+ if (entry.pending) {
348
+ const pending = entry.pending;
349
+ entry.pending = null;
350
+ pending(decorateDelivery(entry, terminal));
351
+ }
352
+ return "discarded_for_exit";
166
353
  }
167
- return "discarded_for_exit";
168
354
  }
169
355
  entry.state = "reattached";
170
- const queued = entry.queuedEvent;
356
+
357
+ const newest = entry.events.length ? entry.events[entry.events.length - 1] : null;
358
+ let delivered = null;
359
+ if (newest) {
360
+ const parkedForMs = Math.max(0, now() - newest.queuedAtMs);
361
+ delivered = {
362
+ ...newest.outcome,
363
+ surfaceUuid: entry.uuid,
364
+ eventId: newest.eventId,
365
+ origin: newest.origin,
366
+ actor: newest.actor || "wearer",
367
+ queuedAtMs: newest.queuedAtMs,
368
+ parkedForMs,
369
+ };
370
+ if (Number.isFinite(entry.staleAfterMs) && parkedForMs > entry.staleAfterMs) {
371
+ delivered.stale = true;
372
+ }
373
+ } else if (entry.queuedEvent) {
374
+ delivered = decorateDelivery(entry, entry.queuedEvent);
375
+ }
171
376
  entry.queuedEvent = null;
172
- if (queued && entry.pending) {
377
+ entry.events = [];
378
+ if (delivered && entry.pending) {
173
379
  const pending = entry.pending;
174
380
  entry.pending = null;
175
381
  entry.state = "visible_awaiting_agent";
176
- pending(queued);
382
+ entry.awaitingAgentResponse = true;
383
+ pending(delivered);
177
384
  }
178
- return "reattached";
385
+ return staleLatchDropped ? "reattached_stale_latch_dropped" : "reattached";
179
386
  }
180
387
 
181
388
  function topSurfaceId(sessionKey) {
182
- const s = stackBySession.get(sessionKey);
389
+ const s = stackBySession.get(normalizeGlassesSessionKey(sessionKey));
183
390
  return s && s.length ? s[s.length - 1] : null;
184
391
  }
185
392
 
186
393
  function stackDepth(sessionKey) {
187
- const s = stackBySession.get(sessionKey);
394
+ const s = stackBySession.get(normalizeGlassesSessionKey(sessionKey));
188
395
  return s ? s.length : 0;
189
396
  }
190
397
 
@@ -193,15 +400,11 @@ export function createSurfaceStore(deps = {}) {
193
400
  return entry ? entry.sessionKey : null;
194
401
  }
195
402
 
196
- // Derive the target surfaceId from the session's current top. The agent
197
- // never supplies a surfaceId (spec §Core model — the plugin owns them,
198
- // keyed by session + stack). Returns { mode, surfaceId } so the caller can
199
- // render against the bound id.
200
- function applyRender(sessionKey, params) {
403
+ function applyRender(rawSessionKey, params) {
404
+ const sessionKey = normalizeGlassesSessionKey(rawSessionKey);
201
405
  const stack = stackFor(sessionKey);
202
406
  const top = stack[stack.length - 1] || null;
203
- // First render in a session (empty stack): create a root at depth 1,
204
- // regardless of the requested update (there is nothing to patch/push onto).
407
+
205
408
  if (!top) {
206
409
  const id = mintSurfaceId();
207
410
  stack.push(id);
@@ -212,9 +415,7 @@ export function createSurfaceStore(deps = {}) {
212
415
  : params && params.update === "push" ? "push"
213
416
  : "replace";
214
417
  if (update === "patch") {
215
- // Re-attach in place: reuse the top id, keep its entry + cron. The
216
- // caller sends a field patch; the body content slot is updated by the
217
- // render send, not here.
418
+
218
419
  const entry = bySurface.get(top);
219
420
  if (entry && params && params.kind) entry.kind = params.kind;
220
421
  if (entry) entry.state = "visible_pending";
@@ -227,39 +428,36 @@ export function createSurfaceStore(deps = {}) {
227
428
  bySurface.set(id, makeEntry(sessionKey, params && params.kind));
228
429
  return { mode: "push", surfaceId: id };
229
430
  }
230
- // replace: swap the current top's CONTENT in place, reusing its id (no
231
- // new back-target, depth unchanged). The entry is reset to a fresh
232
- // pending state; the cron for this slot is reset for new content. CARRY
233
- // FORWARD the prior entry's latched exit / queued event (same surfaceId)
234
- // so a terminal/nonterminal recorded during visible_awaiting_agent is
235
- // still honored by the subsequent onReattached — reattach semantics must
236
- // be move-independent (replace, the schema DEFAULT, must behave like
237
- // patch here, not silently drop a latched exit).
431
+
238
432
  const priorTop = bySurface.get(top);
239
- // SILENT stop: this is slot recycling for the incoming replace render, not
240
- // a real outcome. A non-silent stop fires the cron's onResolve with a
241
- // synthesized `preempted`, which (with no pending call at this instant)
242
- // latches a bogus exit onto the prior entry — carried into makeEntry below,
243
- // it makes the very render we are applying discard-for-exit on re-attach
244
- // ("fresh render instantly dismissed", B7 — the real B3 contamination
245
- // mechanism, found 2026-06-11).
433
+
246
434
  stopCron(top, { silent: true });
247
435
  bySurface.set(top, makeEntry(sessionKey, params && params.kind, priorTop));
248
436
  return { mode: "replace", surfaceId: top };
249
437
  }
250
438
 
251
- function popBack(sessionKey) {
439
+ function popBack(rawSessionKey) {
440
+ const sessionKey = normalizeGlassesSessionKey(rawSessionKey);
252
441
  const stack = stackFor(sessionKey);
253
442
  const child = stack.pop();
254
- if (child) { stopCron(child); bySurface.delete(child); }
443
+ if (child) {
444
+ stopCron(child);
445
+ deadLetterEntryEvents(sessionKey, child, bySurface.get(child), "pop_back");
446
+ bySurface.delete(child);
447
+ }
255
448
  const parent = stack[stack.length - 1] || null;
256
449
  if (parent) resumeCron(parent);
257
450
  return parent;
258
451
  }
259
452
 
260
- function exit(sessionKey) {
453
+ function exit(rawSessionKey) {
454
+ const sessionKey = normalizeGlassesSessionKey(rawSessionKey);
261
455
  const stack = stackFor(sessionKey);
262
- for (const id of stack) { stopCron(id); bySurface.delete(id); }
456
+ for (const id of stack) {
457
+ stopCron(id);
458
+ deadLetterEntryEvents(sessionKey, id, bySurface.get(id), "exit");
459
+ bySurface.delete(id);
460
+ }
263
461
  stackBySession.set(sessionKey, []);
264
462
  return true;
265
463
  }
@@ -269,17 +467,14 @@ export function createSurfaceStore(deps = {}) {
269
467
  }
270
468
 
271
469
  return {
272
- register, resolve, hasSurface, isPending, drainSession, drainAll,
470
+ storeId,
471
+ register, resolve, hasSurface, isPending, drainSession, drainAll, settlePending,
273
472
  stateOf, queueEvent, isExitLatched, onReattached,
274
473
  applyRender, popBack, exit, topSurfaceId, stackDepth, sessionKeys, sessionForSurface,
474
+ uuidOf, titleOf, markerFor, clearAwaitingResponse, breadcrumbFor,
475
+ peekEvents, reduceForDelivery, peekDeadLetter, drainDeadLetter,
275
476
  _bySurface: bySurface,
276
477
  };
277
478
  }
278
479
 
279
- // Backwards-compatible alias. Phase 1 extracted createPendingRenderMap; Phase 2
280
- // evolved it into createSurfaceStore (single store — see the SINGLE-STORE
281
- // EVOLUTION note in the plan). The old name survives only as an alias so the
282
- // Phase 1 import path (glasses-ui-tool re-export) keeps resolving. There is
283
- // never a separate createPendingRenderMap *instance* — both names construct the
284
- // one evolved store.
285
480
  export const createPendingRenderMap = createSurfaceStore;
@@ -1,9 +1,3 @@
1
- // Template engine for glasses UI refresh recipes.
2
- // Pure functions. No I/O. Parses {{path | filter:arg | filter:arg}} expressions,
3
- // resolves paths against a data object (plus an optional previous-tick object
4
- // exposed under {{previous.*}}), and applies a small filter set sized exactly
5
- // to the spec's stated use cases.
6
-
7
1
  const KNOWN_FILTERS = new Set([
8
2
  "trim",
9
3
  "lower",
@@ -18,11 +12,10 @@ const KNOWN_FILTERS = new Set([
18
12
  "plus",
19
13
  ]);
20
14
 
21
- // Filters that require a numeric arg.
22
15
  const NUMERIC_ARG_FILTERS = new Set(["round", "truncate"]);
23
- // Filters that require a string arg (a quoted literal in the template).
16
+
24
17
  const STRING_ARG_FILTERS = new Set(["default", "prefix"]);
25
- // Filters that require a path arg (resolves against the data object).
18
+
26
19
  const PATH_ARG_FILTERS = new Set(["minus", "plus"]);
27
20
 
28
21
  function resolvePath(path, data, previous) {
@@ -51,10 +44,7 @@ function resolvePath(path, data, previous) {
51
44
  }
52
45
 
53
46
  function parseFilter(filterSrc) {
54
- // Format: `name` or `name:arg` where arg can be:
55
- // - a quoted string: `default:"x"` or `prefix:"+"`
56
- // - a number: `round:2` or `truncate:18`
57
- // - a path: `minus:previous.value`
47
+
58
48
  const colonIdx = filterSrc.indexOf(":");
59
49
  const name = (colonIdx === -1 ? filterSrc : filterSrc.slice(0, colonIdx)).trim();
60
50
  const rawArg = colonIdx === -1 ? "" : filterSrc.slice(colonIdx + 1).trim();
@@ -81,7 +71,7 @@ function parseFilter(filterSrc) {
81
71
  }
82
72
  return { ok: true, name, arg: rawArg };
83
73
  }
84
- // No-arg filters.
74
+
85
75
  return { ok: true, name, arg: null };
86
76
  }
87
77
 
@@ -113,10 +103,7 @@ function applyFilter(value, filter, data, previous) {
113
103
  case "default":
114
104
  return value === undefined || value === null || value === "" ? filter.arg : value;
115
105
  case "prefix": {
116
- // Truthy = non-zero, non-empty. Numbers >= 0 with prefix:"+" should
117
- // still prepend on positive values; here we treat "truthy" as the
118
- // value's actual truthiness — agent uses prefix when they want the
119
- // literal prepended on a non-empty value.
106
+
120
107
  if (value === undefined || value === null || value === "") return value;
121
108
  if (typeof value === "number" && value === 0) return value;
122
109
  return `${filter.arg}${value}`;
@@ -139,8 +126,6 @@ function stringify(value) {
139
126
  return String(value);
140
127
  }
141
128
 
142
- // PUBLIC ----
143
-
144
129
  export function substituteTemplate(template, data, opts) {
145
130
  if (typeof template !== "string") return "";
146
131
  const previous = opts && opts.previous ? opts.previous : null;
@@ -150,7 +135,7 @@ export function substituteTemplate(template, data, opts) {
150
135
  let value = resolvePath(path, data, previous);
151
136
  for (let i = 1; i < parts.length; i += 1) {
152
137
  const f = parseFilter(parts[i]);
153
- if (!f.ok) return stringify(value); // shouldn't happen if validateTemplate was called
138
+ if (!f.ok) return stringify(value);
154
139
  value = applyFilter(value, f, data, previous);
155
140
  }
156
141
  return stringify(value);
@@ -161,7 +146,7 @@ export function validateTemplate(template) {
161
146
  if (typeof template !== "string") {
162
147
  return { ok: false, code: "refresh_template_invalid", message: "template must be a string" };
163
148
  }
164
- // Detect unclosed braces.
149
+
165
150
  const openCount = (template.match(/\{\{/g) || []).length;
166
151
  const closeCount = (template.match(/\}\}/g) || []).length;
167
152
  if (openCount !== closeCount) {
@@ -7,10 +7,10 @@ test("description now carries the follow-up + back/selected usage rules", () =>
7
7
  assert.match(d, /text_surface/);
8
8
  assert.match(d, /list_surface/);
9
9
  assert.match(d, /list_with_details_surface/);
10
- // Channel-3 additions (moved from the old nudge):
10
+
11
11
  assert.match(d, /NEXT output|next output/);
12
12
  assert.match(d, /back/i);
13
13
  assert.match(d, /selected/i);
14
- // Skill pointer retained:
14
+
15
15
  assert.match(d, /glasses-ui/);
16
16
  });