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.
- package/dist/runtime/glasses-backpressure-latch.js +115 -0
- package/dist/runtime/relay-core.js +75 -0
- package/dist/runtime/relay-service.js +32 -0
- package/dist/runtime/relay-worker-supervisor.js +8 -0
- package/dist/runtime/relay-worker-transport.js +10 -1
- package/dist/tools/glasses-ui-cron.js +50 -0
- package/dist/tools/glasses-ui-paint-floor.js +23 -1
- package/dist/tools/glasses-ui-surfaces.js +361 -34
- package/dist/tools/glasses-ui-tool.js +594 -86
- package/dist/tools/glasses-ui-voicemail.js +299 -0
- package/dist/tools/glasses-ui-wake.js +262 -0
- package/dist/version.js +2 -2
- package/package.json +2 -2
- package/skills/glasses-ui/SKILL.md +19 -3
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
141
|
-
entry.queuedEvent = event; // last-wins among nonterminals
|
|
302
|
+
return { ok: true, eventId: ++eventSeq, surfaceUuid: entry.uuid, kind: "terminal_latch" };
|
|
142
303
|
}
|
|
143
|
-
|
|
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
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
565
|
+
function popBack(rawSessionKey) {
|
|
566
|
+
const sessionKey = normalizeGlassesSessionKey(rawSessionKey);
|
|
252
567
|
const stack = stackFor(sessionKey);
|
|
253
568
|
const child = stack.pop();
|
|
254
|
-
if (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(
|
|
579
|
+
function exit(rawSessionKey) {
|
|
580
|
+
const sessionKey = normalizeGlassesSessionKey(rawSessionKey);
|
|
261
581
|
const stack = stackFor(sessionKey);
|
|
262
|
-
for (const id of stack) {
|
|
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
|
-
|
|
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
|
}
|