ocuclaw 1.3.3 → 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.
- package/README.md +29 -1
- package/dist/config/runtime-config-session-title-model.test.js +0 -3
- package/dist/config/runtime-config.js +22 -33
- package/dist/domain/activity-status-adapter.js +0 -7
- package/dist/domain/activity-status-arbiter.js +3 -27
- package/dist/domain/activity-status-labels.js +8 -38
- package/dist/domain/code-span-regions.js +4 -24
- package/dist/domain/constant-time-equal.js +9 -0
- package/dist/domain/constant-time-equal.test.js +28 -0
- package/dist/domain/conversation-state.js +27 -138
- package/dist/domain/debug-bundle-cache.js +52 -0
- package/dist/domain/debug-bundle-format.js +60 -0
- package/dist/domain/debug-bundle-preview.js +123 -0
- package/dist/domain/debug-bundle-redaction.js +182 -0
- package/dist/domain/debug-bundle-save.js +11 -0
- package/dist/domain/debug-bundle-zip.js +15 -0
- package/dist/domain/debug-bundle.js +97 -0
- package/dist/domain/debug-store.js +6 -17
- package/dist/domain/debug-upload-preset.js +27 -0
- package/dist/domain/glasses-display-system-prompt.js +0 -5
- package/dist/domain/glasses-display-system-prompt.test.js +1 -1
- package/dist/domain/glasses-ui-content-summary.js +0 -6
- package/dist/domain/glasses-ui-system-prompt.test.js +1 -2
- package/dist/domain/message-emoji-allowlist.js +0 -7
- package/dist/domain/message-emoji-filter.js +3 -9
- package/dist/domain/neural-emoji-reactor-tag-config.js +3 -3
- package/dist/domain/prompt-channel-fragments.js +1 -10
- package/dist/domain/tagged-span-parser.js +3 -26
- package/dist/domain/tagged-span-strip.js +0 -7
- package/dist/even-ai/even-ai-endpoint.js +77 -24
- package/dist/even-ai/even-ai-run-waiter.js +0 -1
- package/dist/even-ai/even-ai-settings-store.js +11 -0
- package/dist/gateway/gateway-bridge.js +8 -9
- package/dist/gateway/gateway-timing-ledger.js +8 -6
- package/dist/gateway/openclaw-client.js +97 -297
- package/dist/gateway/sanitize-connect-reason.js +10 -0
- package/dist/gateway/sanitize-connect-reason.test.js +34 -0
- package/dist/index.js +3 -3
- package/dist/runtime/channel-two-hook.js +1 -6
- package/dist/runtime/container-env.js +1 -5
- package/dist/runtime/debug-bundle-handler.js +159 -0
- package/dist/runtime/display-toggle-states.js +6 -17
- package/dist/runtime/downstream-handler.js +682 -508
- package/dist/runtime/glasses-backpressure-latch.js +2 -24
- package/dist/runtime/ocuclaw-settings-store.js +10 -1
- package/dist/runtime/openclaw-host-version.js +5 -0
- package/dist/runtime/plugin-version-service.js +13 -6
- package/dist/runtime/provider-usage-select.js +0 -6
- package/dist/runtime/register-session-title-distiller.js +14 -16
- package/dist/runtime/relay-core.js +601 -290
- package/dist/runtime/relay-service.js +19 -47
- package/dist/runtime/relay-worker-approval-replay-cache.js +1 -1
- package/dist/runtime/relay-worker-entry.js +1 -2
- package/dist/runtime/relay-worker-health.js +2 -10
- package/dist/runtime/relay-worker-protocol.js +6 -1
- package/dist/runtime/relay-worker-supervisor.js +103 -41
- package/dist/runtime/relay-worker-transport.js +150 -17
- package/dist/runtime/session-context-service.js +5 -45
- package/dist/runtime/session-service.js +157 -175
- package/dist/runtime/session-title-distiller-budget.js +1 -5
- package/dist/runtime/session-title-distiller-helpers.js +14 -24
- package/dist/runtime/session-title-distiller.js +109 -122
- package/dist/runtime/session-title-record.js +0 -6
- package/dist/runtime/stable-prompt-snapshot.js +3 -14
- package/dist/runtime/upstream-runtime.js +600 -103
- package/dist/tools/device-info-tool.js +4 -21
- package/dist/tools/glasses-ui-cron.js +22 -77
- package/dist/tools/glasses-ui-descriptors.js +4 -33
- package/dist/tools/glasses-ui-limits.js +0 -13
- package/dist/tools/glasses-ui-paint-floor.js +5 -39
- package/dist/tools/glasses-ui-recipes.js +92 -101
- package/dist/tools/glasses-ui-surfaces.js +31 -163
- package/dist/tools/glasses-ui-template.js +7 -22
- package/dist/tools/glasses-ui-tool-description.test.js +2 -2
- package/dist/tools/glasses-ui-tool.js +87 -451
- package/dist/tools/glasses-ui-voicemail.js +6 -63
- package/dist/tools/glasses-ui-wake.js +9 -76
- package/dist/tools/session-title-tool.js +2 -7
- package/dist/tools/session-title-tool.test.js +1 -1
- package/dist/version.js +3 -2
- package/openclaw.plugin.json +60 -13
- package/package.json +3 -2
- package/dist/runtime/protocol-adapter.js +0 -387
|
@@ -1,15 +1,11 @@
|
|
|
1
|
-
// Glasses UI tool: lets the agent paint an interactive surface on the user's
|
|
2
|
-
// Even G2 glasses HUD instead of replying with text. Plain JSON Schema is used
|
|
3
|
-
// for the tool parameters because the plugin build is a regex-based emitter
|
|
4
|
-
// that has no access to typebox at runtime; OpenClaw consumes JSON Schema
|
|
5
|
-
// directly anyway.
|
|
6
|
-
|
|
7
1
|
import { validateTemplate } from "./glasses-ui-template.js";
|
|
8
2
|
import { createGlassesUiCronEngine } from "./glasses-ui-cron.js";
|
|
9
3
|
import {
|
|
10
4
|
executeHttpRecipe,
|
|
11
5
|
executeLlmRecipe,
|
|
12
6
|
executeSystemStatsRecipe,
|
|
7
|
+
normalizeHttpAllowHosts,
|
|
8
|
+
isHttpHostAllowed,
|
|
13
9
|
} from "./glasses-ui-recipes.js";
|
|
14
10
|
import { createPendingRenderMap, createSurfaceStore, isTerminalOutcome, normalizeGlassesSessionKey } from "./glasses-ui-surfaces.js";
|
|
15
11
|
import { createGlassesWakeController } from "./glasses-ui-wake.js";
|
|
@@ -22,14 +18,6 @@ import {
|
|
|
22
18
|
buildOneOfBranches,
|
|
23
19
|
} from "./glasses-ui-descriptors.js";
|
|
24
20
|
|
|
25
|
-
// Re-exported so existing consumers/tests that import these from this module
|
|
26
|
-
// keep working after the extractions (spec §Changes A — extraction is behavior-
|
|
27
|
-
// preserving). Canonical homes: createPendingRenderMap/createSurfaceStore ->
|
|
28
|
-
// ./glasses-ui-surfaces.js, GLASSES_UI_LIMITS -> ./glasses-ui-limits.js. Kept as
|
|
29
|
-
// ONE bare `export {}` statement because the CJS emitter (scripts/build.mjs)
|
|
30
|
-
// strips only the first such statement — a second would survive into the .cjs
|
|
31
|
-
// as invalid syntax. createPendingRenderMap is the Phase-1 alias of the single
|
|
32
|
-
// createSurfaceStore (see glasses-ui-surfaces.ts).
|
|
33
21
|
export { createPendingRenderMap, createSurfaceStore, GLASSES_UI_LIMITS };
|
|
34
22
|
|
|
35
23
|
export const GLASSES_UI_REFRESH_LIMITS = {
|
|
@@ -53,21 +41,15 @@ export const GLASSES_UI_REFRESH_LIMITS = {
|
|
|
53
41
|
maxOutputTokensMin: 16,
|
|
54
42
|
maxOutputTokensMax: 1000,
|
|
55
43
|
maxOutputTokensDefault: 200,
|
|
56
|
-
|
|
57
|
-
// substituted OUTPUT is clamped to bodyMax/itemMax at runtime, but a huge
|
|
58
|
-
// template itself is wasted work — 4KB is generous for any HUD line.
|
|
44
|
+
|
|
59
45
|
templateMaxChars: 4096,
|
|
60
|
-
|
|
46
|
+
|
|
61
47
|
systemStatsWindowMsMin: 50,
|
|
62
48
|
systemStatsWindowMsMax: 1000,
|
|
63
49
|
};
|
|
64
50
|
|
|
65
51
|
const ON_ERROR_VALUES = new Set(["keep_last", "show_error", "stop"]);
|
|
66
52
|
|
|
67
|
-
// The effective per-tick interval floor is the larger of the tier minimum and
|
|
68
|
-
// the paint-floor coalescer's cadence (Spike D, 150ms) — no tick may schedule
|
|
69
|
-
// faster than the glass can paint. Today every tier min already exceeds 150ms,
|
|
70
|
-
// so this only guards the floor from ever relaxing below the coalescer cadence.
|
|
71
53
|
function effectiveIntervalFloorMs(tierMinMs) {
|
|
72
54
|
return Math.max(tierMinMs, DEFAULT_PAINT_FLOOR_MS);
|
|
73
55
|
}
|
|
@@ -89,14 +71,11 @@ export function validateRefreshSpec(refresh, glassesUiLiveCfg) {
|
|
|
89
71
|
if (kind !== "http" && kind !== "llm" && kind !== "system-stats") {
|
|
90
72
|
return { ok: false, code: "refresh_invalid_recipe", message: `recipe.kind must be http/llm/system-stats, got ${JSON.stringify(kind)}` };
|
|
91
73
|
}
|
|
92
|
-
|
|
93
|
-
// / maxOutputTokens to declared bounds, copy known fields only. The returned
|
|
94
|
-
// `refresh.recipe` is this sanitized version, never the raw input — so the
|
|
95
|
-
// executors at run-time see vetted values.
|
|
74
|
+
|
|
96
75
|
const sanitizedRecipe = { kind };
|
|
97
76
|
const bounded = (raw, min, max) => {
|
|
98
77
|
if (!Number.isFinite(raw)) return null;
|
|
99
|
-
if (raw < min || raw > max) return undefined;
|
|
78
|
+
if (raw < min || raw > max) return undefined;
|
|
100
79
|
return Math.floor(raw);
|
|
101
80
|
};
|
|
102
81
|
if (kind === "http") {
|
|
@@ -104,6 +83,13 @@ export function validateRefreshSpec(refresh, glassesUiLiveCfg) {
|
|
|
104
83
|
if (typeof recipe.url !== "string" || !recipe.url.trim()) {
|
|
105
84
|
return { ok: false, code: "refresh_invalid_recipe", message: "http recipe requires url (non-empty string)" };
|
|
106
85
|
}
|
|
86
|
+
|
|
87
|
+
const allowHosts = normalizeHttpAllowHosts(cfg.httpAllowHosts);
|
|
88
|
+
let recipeHost = "";
|
|
89
|
+
try { recipeHost = new URL(recipe.url).hostname; } catch (_) {}
|
|
90
|
+
if (!isHttpHostAllowed(recipeHost, allowHosts)) {
|
|
91
|
+
return { ok: false, code: "refresh_host_not_allowed", message: `http recipe host not in allowlist: ${recipeHost || recipe.url.trim()}` };
|
|
92
|
+
}
|
|
107
93
|
sanitizedRecipe.url = recipe.url;
|
|
108
94
|
if (typeof recipe.method === "string") sanitizedRecipe.method = recipe.method;
|
|
109
95
|
if (recipe.headers && typeof recipe.headers === "object") sanitizedRecipe.headers = recipe.headers;
|
|
@@ -136,10 +122,7 @@ export function validateRefreshSpec(refresh, glassesUiLiveCfg) {
|
|
|
136
122
|
if (v !== null) sanitizedRecipe.maxOutputTokens = v;
|
|
137
123
|
}
|
|
138
124
|
} else if (kind === "system-stats") {
|
|
139
|
-
|
|
140
|
-
// by httpEnabled/llmEnabled — it touches no network, no shell, no
|
|
141
|
-
// model. Only the master `enabled` switch (checked above) governs it. Do NOT
|
|
142
|
-
// add a cfg.*Enabled gate here (intentional — Phase 3 design).
|
|
125
|
+
|
|
143
126
|
if (recipe.sampleWindowMs !== undefined) {
|
|
144
127
|
const v = bounded(recipe.sampleWindowMs, GLASSES_UI_REFRESH_LIMITS.systemStatsWindowMsMin, GLASSES_UI_REFRESH_LIMITS.systemStatsWindowMsMax);
|
|
145
128
|
if (v === undefined) return { ok: false, code: "refresh_invalid_recipe", message: `system-stats.sampleWindowMs ${recipe.sampleWindowMs} out of bounds [${GLASSES_UI_REFRESH_LIMITS.systemStatsWindowMsMin}..${GLASSES_UI_REFRESH_LIMITS.systemStatsWindowMsMax}]` };
|
|
@@ -147,7 +130,6 @@ export function validateRefreshSpec(refresh, glassesUiLiveCfg) {
|
|
|
147
130
|
}
|
|
148
131
|
}
|
|
149
132
|
|
|
150
|
-
// Interval bounds.
|
|
151
133
|
const intervalMs = refresh.intervalMs;
|
|
152
134
|
if (!Number.isFinite(intervalMs)) {
|
|
153
135
|
return { ok: false, code: "refresh_invalid_recipe", message: "refresh.intervalMs is required" };
|
|
@@ -168,7 +150,6 @@ export function validateRefreshSpec(refresh, glassesUiLiveCfg) {
|
|
|
168
150
|
return { ok: false, code: "refresh_interval_too_high", message: `intervalMs ${intervalMs} above max ${GLASSES_UI_REFRESH_LIMITS.intervalMsMax}` };
|
|
169
151
|
}
|
|
170
152
|
|
|
171
|
-
// Duration.
|
|
172
153
|
const maxDurationMs = Number.isFinite(refresh.maxDurationMs)
|
|
173
154
|
? refresh.maxDurationMs
|
|
174
155
|
: GLASSES_UI_REFRESH_LIMITS.maxDurationMsDefault;
|
|
@@ -176,13 +157,11 @@ export function validateRefreshSpec(refresh, glassesUiLiveCfg) {
|
|
|
176
157
|
return { ok: false, code: "refresh_duration_too_high", message: `maxDurationMs ${maxDurationMs} out of bounds` };
|
|
177
158
|
}
|
|
178
159
|
|
|
179
|
-
// onError.
|
|
180
160
|
const onError = typeof refresh.onError === "string" ? refresh.onError : "keep_last";
|
|
181
161
|
if (!ON_ERROR_VALUES.has(onError)) {
|
|
182
162
|
return { ok: false, code: "refresh_invalid_recipe", message: `onError must be keep_last/show_error/stop` };
|
|
183
163
|
}
|
|
184
164
|
|
|
185
|
-
// Templates.
|
|
186
165
|
const targets = refresh.targets && typeof refresh.targets === "object" ? refresh.targets : {};
|
|
187
166
|
if (typeof targets.body === "string") {
|
|
188
167
|
if (targets.body.length > GLASSES_UI_REFRESH_LIMITS.templateMaxChars) {
|
|
@@ -192,10 +171,7 @@ export function validateRefreshSpec(refresh, glassesUiLiveCfg) {
|
|
|
192
171
|
if (!v.ok) return v;
|
|
193
172
|
}
|
|
194
173
|
if (Array.isArray(targets.items)) {
|
|
195
|
-
|
|
196
|
-
// so a 100k-entry array would burn CPU substituting templates that are
|
|
197
|
-
// immediately discarded. Reject (rather than truncate) so the agent gets
|
|
198
|
-
// clear feedback that it over-supplied.
|
|
174
|
+
|
|
199
175
|
if (targets.items.length > GLASSES_UI_LIMITS.maxItems) {
|
|
200
176
|
return {
|
|
201
177
|
ok: false,
|
|
@@ -212,7 +188,7 @@ export function validateRefreshSpec(refresh, glassesUiLiveCfg) {
|
|
|
212
188
|
const v = validateTemplate(item);
|
|
213
189
|
if (!v.ok) return v;
|
|
214
190
|
} else if (item && typeof item === "object" && !Array.isArray(item)) {
|
|
215
|
-
|
|
191
|
+
|
|
216
192
|
if (typeof item.label !== "string") {
|
|
217
193
|
return { ok: false, code: "refresh_template_invalid", message: `targets.items[${i}].label must be a string template` };
|
|
218
194
|
}
|
|
@@ -259,10 +235,6 @@ const updateSchemaForToolParams = {
|
|
|
259
235
|
"\"push\": stack a new screen; the parent is retained and its cron pauses.",
|
|
260
236
|
};
|
|
261
237
|
|
|
262
|
-
// Roadmap 6b (§2.6 interaction-window contract). The gateway's dynamic-tool
|
|
263
|
-
// watchdog reads call.arguments.timeoutMs (clamp ceiling 600000ms); this
|
|
264
|
-
// schema entry legitimizes that knob and steers its use. One-shot by design:
|
|
265
|
-
// the listen window is never renewed — the agent re-renders to listen again.
|
|
266
238
|
const timeoutMsSchemaForToolParams = {
|
|
267
239
|
type: "integer",
|
|
268
240
|
minimum: 1000,
|
|
@@ -273,7 +245,6 @@ const timeoutMsSchemaForToolParams = {
|
|
|
273
245
|
"omit for fire-and-forget. Never renewed automatically — re-render to listen again.",
|
|
274
246
|
};
|
|
275
247
|
|
|
276
|
-
// staleAfterMs reservation (roadmap 6b; annotate-only through the soak).
|
|
277
248
|
const staleAfterMsSchemaForToolParams = {
|
|
278
249
|
type: "integer",
|
|
279
250
|
minimum: 1000,
|
|
@@ -291,10 +262,6 @@ export const GLASSES_UI_WINDOW_LIMITS = {
|
|
|
291
262
|
staleAfterMsMax: staleAfterMsSchemaForToolParams.maximum,
|
|
292
263
|
};
|
|
293
264
|
|
|
294
|
-
// Cross-kind window fields (like `update`/`refresh`, these live at the tool
|
|
295
|
-
// layer — the kind descriptors rebuild a whitelisted canonical spec, which is
|
|
296
|
-
// also what keeps both fields OFF the wire). Reject (never clamp) so the agent
|
|
297
|
-
// gets explicit feedback instead of a silently different window.
|
|
298
265
|
function validateWindowFields(spec) {
|
|
299
266
|
const out = { ok: true, timeoutMs: undefined, staleAfterMs: undefined };
|
|
300
267
|
if (spec && spec.timeoutMs !== undefined) {
|
|
@@ -399,21 +366,13 @@ const refreshSchemaForToolParams = {
|
|
|
399
366
|
},
|
|
400
367
|
};
|
|
401
368
|
|
|
402
|
-
// Top-level `properties` lists every field a valid spec may carry across all
|
|
403
|
-
// `kind`s. OpenClaw's Anthropic provider strips `oneOf` when building
|
|
404
|
-
// `input_schema` (it keeps only top-level `properties` + `required`), so
|
|
405
|
-
// without this flat union the model would see `properties: {}` and have to
|
|
406
|
-
// guess the shape from the tool description alone. Per-kind shape constraints
|
|
407
|
-
// are still enforced by `validateGlassesUiSpec` and the JSON Schema `oneOf`
|
|
408
|
-
// below for clients that honor it.
|
|
409
369
|
export const glassesUiParametersSchema = {
|
|
410
370
|
type: "object",
|
|
411
371
|
required: ["kind"],
|
|
412
372
|
properties: {
|
|
413
373
|
kind: {
|
|
414
374
|
type: "string",
|
|
415
|
-
|
|
416
|
-
// is one descriptor with no edit here (spec §Modularity).
|
|
375
|
+
|
|
417
376
|
enum: listKindStrings(),
|
|
418
377
|
description:
|
|
419
378
|
"Surface kind. Each kind expects a different items/body shape — see " +
|
|
@@ -442,26 +401,15 @@ export const glassesUiParametersSchema = {
|
|
|
442
401
|
"[{\"label\": \"Monday\", \"body\": \"Cloudy 14C, light rain pm\"}, " +
|
|
443
402
|
"{\"label\": \"Tuesday\", \"body\": \"Sunny 19C\"}]. Up to 20 items.",
|
|
444
403
|
},
|
|
445
|
-
|
|
446
|
-
// (see the block comment above), so a refresh entry that lives only in
|
|
447
|
-
// the oneOf branches is invisible on that path and the live-refresh
|
|
448
|
-
// feature becomes unreachable. The per-branch copies below stay for
|
|
449
|
-
// clients that honor oneOf.
|
|
404
|
+
|
|
450
405
|
refresh: refreshSchemaForToolParams,
|
|
451
|
-
|
|
452
|
-
// default replace). Top-level for the same Anthropic-strips-oneOf reason as
|
|
453
|
-
// refresh; mirrored into every oneOf branch below.
|
|
406
|
+
|
|
454
407
|
update: updateSchemaForToolParams,
|
|
455
|
-
|
|
408
|
+
|
|
456
409
|
timeoutMs: timeoutMsSchemaForToolParams,
|
|
457
410
|
staleAfterMs: staleAfterMsSchemaForToolParams,
|
|
458
411
|
},
|
|
459
|
-
|
|
460
|
-
// enum order). Each branch's `refresh` slot — declared `undefined` in the
|
|
461
|
-
// descriptor's schemaBranch — is filled here with the shared refresh schema
|
|
462
|
-
// so the per-branch shape matches today's hand-written one (the tool owns
|
|
463
|
-
// refreshSchemaForToolParams; the descriptor only declares the slot). `update`
|
|
464
|
-
// is mirrored into every branch the same way.
|
|
412
|
+
|
|
465
413
|
oneOf: buildOneOfBranches().map((branch) => ({
|
|
466
414
|
...branch,
|
|
467
415
|
properties: {
|
|
@@ -479,10 +427,7 @@ export function validateGlassesUiSpec(input) {
|
|
|
479
427
|
return { ok: false, code: "invalid_kind", message: "spec must be an object" };
|
|
480
428
|
}
|
|
481
429
|
const obj = input;
|
|
482
|
-
|
|
483
|
-
// kind" reproduces today's invalid_kind. Per-kind validation (incl. the
|
|
484
|
-
// shared title check, which each descriptor runs first) lives in the
|
|
485
|
-
// descriptor's validateSpec, so behavior is identical to the old switch.
|
|
430
|
+
|
|
486
431
|
const descriptor = getKindDescriptor(obj.kind);
|
|
487
432
|
if (!descriptor) {
|
|
488
433
|
return {
|
|
@@ -498,9 +443,6 @@ export function validateGlassesUiSpec(input) {
|
|
|
498
443
|
|
|
499
444
|
import { randomUUID } from "node:crypto";
|
|
500
445
|
|
|
501
|
-
// Mirror of even-ai-model-hook's session classifier. Inlined to avoid a
|
|
502
|
-
// cross-file CJS dependency (even-ai-model-hook is ESM-only in dist).
|
|
503
|
-
// Keep these constants in sync with even-ai-model-hook.ts.
|
|
504
446
|
const EVEN_AI_THROWAWAY_SESSION_PREFIX = "ocuclaw:even-ai:";
|
|
505
447
|
const EVEN_AI_DEFAULT_DEDICATED_SESSION_KEY = "ocuclaw:even-ai";
|
|
506
448
|
|
|
@@ -522,80 +464,40 @@ export function isEvenAiAgentSession(sessionKey, dedicatedSessionKey) {
|
|
|
522
464
|
return !!normalizedDedicated && normalized === normalizedDedicated;
|
|
523
465
|
}
|
|
524
466
|
|
|
525
|
-
// Default timeout when no per-call or per-handler timeout is provided.
|
|
526
|
-
// Bounds the orphan-tool_use corruption window (see Family 2 in the OpenClaw
|
|
527
|
-
// task-runs zombies / orphan tool_use memory): if a render_glasses_ui call
|
|
528
|
-
// stays unresolved this long, the plugin returns { result: "timeout" } so
|
|
529
|
-
// the agent's runtime persists a matching tool_result and the next turn's
|
|
530
|
-
// session replay won't 400 on an unmatched tool_use block.
|
|
531
467
|
export const DEFAULT_RENDER_GLASSES_UI_TIMEOUT_MS = 30 * 60 * 1000;
|
|
532
468
|
|
|
533
|
-
// The gateway's dynamic-tool watchdog default (resolveDynamicToolCallTimeoutMs
|
|
534
|
-
// in the installed 2026.6.1 dist; agent-suppliable via the 6b timeoutMs field,
|
|
535
|
-
// clamped there to <=600000). UNDOCUMENTED upstream — re-audit on any host
|
|
536
|
-
// upgrade (§5c upstream ask (v) tracks getting it documented).
|
|
537
469
|
export const GATEWAY_DYNAMIC_TOOL_DEFAULT_TIMEOUT_MS = 90_000;
|
|
538
470
|
|
|
539
|
-
// Self-teaching wrap-up payload (roadmap 6c): window_expired is a listen
|
|
540
|
-
// timeout, never an error and never a paint event — the surface stays live.
|
|
541
471
|
const WINDOW_EXPIRED_HINT =
|
|
542
472
|
"The listen window closed; the surface is still live on glass and keeps " +
|
|
543
473
|
"updating. New taps park - re-render this surface (e.g. update:\"patch\") " +
|
|
544
474
|
"to collect them in this run, or end your turn and they ride the next one.";
|
|
545
475
|
|
|
546
476
|
export function createGlassesUiToolHandler(deps) {
|
|
547
|
-
|
|
548
|
-
// which it delegates pause/resume/stop to). There is never a separate pending
|
|
549
|
-
// map alongside it (spec §Core model — one store).
|
|
550
|
-
// Short-lived per-surface capture used to read the cron's merged outcome
|
|
551
|
-
// (ticks{}, lastBody, lastItems, ...extra) when runDynamicUi stops the
|
|
552
|
-
// cron after a user dismissal (pending already resolved with a user-only
|
|
553
|
-
// outcome that lacks ticks). Cleared immediately after stop returns.
|
|
477
|
+
|
|
554
478
|
const capturedCronOutcome = new Map();
|
|
555
479
|
|
|
556
|
-
// Breadcrumb title clip (roadmap 7a). The DynamicUiScreen title band is
|
|
557
|
-
// BODY_W = MARKER_X = 548px on the client and the client does NOT clip
|
|
558
|
-
// DynamicUiScreen titles — so the plugin must pre-clip the composed
|
|
559
|
-
// breadcrumb or it overflows the band. Conservative pixel-safe char clip
|
|
560
|
-
// (NOT font_measure: the plugin runtime has no require of
|
|
561
|
-
// vendor/pretext-patched/dist/font_measure.js, and a cross-package require
|
|
562
|
-
// from the installed plugin is fragile). Assume a worst-case 20px per char
|
|
563
|
-
// (the probed uniform full-cell ceiling) so floor(budget/20) chars never
|
|
564
|
-
// overflow — it over-clips slightly, never under. 540 = 548 minus an 8px
|
|
565
|
-
// safety margin.
|
|
566
480
|
const TITLE_BUDGET_PX = 540;
|
|
567
|
-
|
|
568
|
-
// oldest-ancestor-first until the rejoined "A › B › C" fits the char budget;
|
|
569
|
-
// if a single segment still overflows, it hard-truncates that last segment.
|
|
570
|
-
// reserveText is kept defaulted-empty for future suffix callers (7a has
|
|
571
|
-
// none — the (stale) suffix was dropped per Path D).
|
|
481
|
+
|
|
572
482
|
function clipBreadcrumb(s, reserveText = "") {
|
|
573
483
|
if (typeof s !== "string" || s.length === 0) return s;
|
|
574
484
|
const charBudget = Math.floor((TITLE_BUDGET_PX - reserveText.length * 20) / 20);
|
|
575
485
|
if (charBudget <= 0) return "";
|
|
576
486
|
if (s.length <= charBudget) return s;
|
|
577
487
|
const segments = s.split(" › ");
|
|
578
|
-
|
|
579
|
-
// keeping at least the rightmost segment.
|
|
488
|
+
|
|
580
489
|
while (segments.length > 1 && segments.join(" › ").length > charBudget) {
|
|
581
490
|
segments.shift();
|
|
582
491
|
}
|
|
583
492
|
const joined = segments.join(" › ");
|
|
584
493
|
if (joined.length <= charBudget) return joined;
|
|
585
|
-
|
|
494
|
+
|
|
586
495
|
return joined.slice(0, charBudget);
|
|
587
496
|
}
|
|
588
497
|
|
|
589
|
-
// Marker-only emit on the content-less presence transitions (roadmap 7a). A
|
|
590
|
-
// live resolve, a parked tap, a window_expired wrap-up, and a popBack resume
|
|
591
|
-
// change the surface's derived presence marker without changing its content —
|
|
592
|
-
// so ship a marker-only surface_update (no title/body/items) through the same
|
|
593
|
-
// paint-floor chokepoint. Gated to the session's TOP surface so a transition
|
|
594
|
-
// on a backgrounded surface stays quiet; an off-enum/absent marker is a no-op.
|
|
595
|
-
// (surfaceStore/paintFloor are defined further down but only read at call time.)
|
|
596
498
|
function emitMarker(sessionKey, surfaceId) {
|
|
597
499
|
if (!surfaceId) return;
|
|
598
|
-
if (surfaceStore.topSurfaceId(sessionKey) !== surfaceId) return;
|
|
500
|
+
if (surfaceStore.topSurfaceId(sessionKey) !== surfaceId) return;
|
|
599
501
|
const marker = surfaceStore.markerFor(surfaceId);
|
|
600
502
|
if (!marker) return;
|
|
601
503
|
paintFloor.enqueue({ surfaceId, sessionKey, patch: { marker } });
|
|
@@ -606,14 +508,6 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
606
508
|
? deps.newSurfaceId
|
|
607
509
|
: () => `ui-${randomUUID().slice(0, 8)}`;
|
|
608
510
|
|
|
609
|
-
// Permanent glasses.lifecycle observability (nav reconcile + cron pause/
|
|
610
|
-
// resume/tick). No-op when the dep is absent (tests) or the debug category is
|
|
611
|
-
// disabled. See docs/superpowers/findings/2026-05-30-glasses-ui-phase4-hardware.md.
|
|
612
|
-
// Every event is stamped with the owning store's id: OpenClaw loads the
|
|
613
|
-
// plugin register() in multiple isolated contexts, each with its own handler
|
|
614
|
-
// + store, and only the storeId makes a cross-context divergence visible in
|
|
615
|
-
// traces (drift #3, 2026-06-12: a queued wake run's collect rendered against
|
|
616
|
-
// a sibling context's empty store and minted a phantom root).
|
|
617
511
|
const storeId =
|
|
618
512
|
typeof deps.storeId === "string" && deps.storeId
|
|
619
513
|
? deps.storeId
|
|
@@ -623,8 +517,6 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
623
517
|
const emitLifecycle = (event, severity, data) =>
|
|
624
518
|
baseEmitLifecycle(event, severity, { storeId, ...(data || {}) });
|
|
625
519
|
|
|
626
|
-
// Resolve the handler-wide default timeout. Per-call timeouts may still
|
|
627
|
-
// override this via params.timeoutMs.
|
|
628
520
|
function resolveHandlerTimeoutMs() {
|
|
629
521
|
if (!deps || deps.timeoutMs === undefined) return DEFAULT_RENDER_GLASSES_UI_TIMEOUT_MS;
|
|
630
522
|
if (typeof deps.timeoutMs === "function") {
|
|
@@ -634,15 +526,8 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
634
526
|
return Number.isFinite(deps.timeoutMs) ? deps.timeoutMs : DEFAULT_RENDER_GLASSES_UI_TIMEOUT_MS;
|
|
635
527
|
}
|
|
636
528
|
|
|
637
|
-
// The single plugin->glass send chokepoint (Spike D). EVERY send — the
|
|
638
|
-
// initial render frame and every cron surface_update — routes through this
|
|
639
|
-
// trailing-edge coalescer so bursts collapse to ≤1 frame per 150ms and shed
|
|
640
|
-
// under BLE backpressure. A { __render } sentinel is a full container
|
|
641
|
-
// rebuild; a plain field patch is a surface_update.
|
|
642
529
|
const paintFloor = createPaintFloorCoalescer({
|
|
643
|
-
|
|
644
|
-
// a leading-edge send) so synchronous send-ordering assertions hold;
|
|
645
|
-
// production uses the 150ms Spike-D cadence.
|
|
530
|
+
|
|
646
531
|
paintFloorMs: Number.isFinite(deps.paintFloorMs) ? deps.paintFloorMs : DEFAULT_PAINT_FLOOR_MS,
|
|
647
532
|
send: ({ surfaceId, sessionKey, patch }) => {
|
|
648
533
|
if (patch && patch.__render) {
|
|
@@ -658,7 +543,11 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
658
543
|
emitLifecycle,
|
|
659
544
|
monotonicNowMs: () => performance.now(),
|
|
660
545
|
executeRecipe: async (recipe, ctx) => {
|
|
661
|
-
if (recipe.kind === "http")
|
|
546
|
+
if (recipe.kind === "http") {
|
|
547
|
+
|
|
548
|
+
const cfg = deps.getGlassesUiLiveConfig ? deps.getGlassesUiLiveConfig() : {};
|
|
549
|
+
return executeHttpRecipe(recipe, { allowHosts: normalizeHttpAllowHosts(cfg.httpAllowHosts) });
|
|
550
|
+
}
|
|
662
551
|
if (recipe.kind === "system-stats") return executeSystemStatsRecipe(recipe);
|
|
663
552
|
if (recipe.kind === "llm") return executeLlmRecipe(recipe, ctx);
|
|
664
553
|
return { error: `unknown recipe kind: ${recipe.kind}` };
|
|
@@ -686,22 +575,14 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
686
575
|
},
|
|
687
576
|
});
|
|
688
577
|
|
|
689
|
-
// The SINGLE live surface store (spec §Core model). Constructed after the
|
|
690
|
-
// cron engine so it can delegate pause/resume/stop; id minting reuses the
|
|
691
|
-
// handler's minter. Replaces the Phase-1 pending map — never a second store.
|
|
692
578
|
const surfaceStore = createSurfaceStore({
|
|
693
579
|
storeId,
|
|
694
580
|
emitLifecycle,
|
|
695
|
-
|
|
581
|
+
|
|
696
582
|
now: typeof deps.now === "function" ? deps.now : undefined,
|
|
697
583
|
pauseCron: (id) => cronEngine.pause(id),
|
|
698
584
|
resumeCron: (id) => cronEngine.resume(id),
|
|
699
|
-
|
|
700
|
-
// exit, drain). Dispose the paint-floor coalescer entry too so an armed
|
|
701
|
-
// trailing flush can't paint a stale surface_update onto the now-visible
|
|
702
|
-
// parent/chat after a back/pop, and the per-surface coalescer state doesn't
|
|
703
|
-
// leak across a long push/replace session. (pauseCron on push does NOT
|
|
704
|
-
// dispose — the parent resumes.)
|
|
585
|
+
|
|
705
586
|
stopCron: (id, opts) => {
|
|
706
587
|
cronEngine.stop(id, { result: "preempted" }, opts);
|
|
707
588
|
paintFloor.dispose(id);
|
|
@@ -709,12 +590,6 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
709
590
|
mintSurfaceId: newSurfaceId,
|
|
710
591
|
});
|
|
711
592
|
|
|
712
|
-
// Tap-to-wake (roadmap 6f): a parked GESTURE nonterminal buys one agent
|
|
713
|
-
// turn via the relay's gateway client. The controller owns the arbitration
|
|
714
|
-
// (origin gate, voice-absorbs-wake, in-flight coalescing, cooldown, retry/
|
|
715
|
-
// outbox); the lane and busy signal are injected from the relay facade by
|
|
716
|
-
// registerGlassesUiTool. Without a lane (legacy host) the controller
|
|
717
|
-
// no-ops and parked taps keep their collect-on-next-render semantics.
|
|
718
593
|
const wakeController = createGlassesWakeController({
|
|
719
594
|
dispatchWake: typeof deps.dispatchWake === "function" ? deps.dispatchWake : null,
|
|
720
595
|
isAgentTurnBusy: typeof deps.isAgentTurnBusy === "function" ? deps.isAgentTurnBusy : () => false,
|
|
@@ -723,12 +598,6 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
723
598
|
wakeCooldownMs: deps.wakeCooldownMs,
|
|
724
599
|
});
|
|
725
600
|
|
|
726
|
-
// Voicemail (roadmap 7b): parked events whose wake was unavailable/failed
|
|
727
|
-
// (wake outbox) or whose surface was destructively reaped (dead-letter)
|
|
728
|
-
// are delivered to the session's NEXT genuine turn as a refs-only
|
|
729
|
-
// system-context fragment. Lives on the handler so the shared-handler
|
|
730
|
-
// hoist gives every load context's before_prompt_build hook the same
|
|
731
|
-
// pending state (first hook to fire drains; duplicates see nothing).
|
|
732
601
|
const voicemail = createGlassesVoicemail({
|
|
733
602
|
now: typeof deps.now === "function" ? deps.now : Date.now,
|
|
734
603
|
ttlMs: deps.voicemailTtlMs,
|
|
@@ -739,9 +608,7 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
739
608
|
|
|
740
609
|
deps.relay.onGlassesUiResult((msg) => {
|
|
741
610
|
if (!msg || typeof msg.surfaceId !== "string" || !msg.outcome) return;
|
|
742
|
-
|
|
743
|
-
// on this channel is a wearer gesture. The actor slot is present-and-
|
|
744
|
-
// ignorable today (policy stays single-wearer at launch).
|
|
611
|
+
|
|
745
612
|
const outcome = {
|
|
746
613
|
...msg.outcome,
|
|
747
614
|
origin: typeof msg.outcome.origin === "string" ? msg.outcome.origin : "gesture",
|
|
@@ -749,9 +616,7 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
749
616
|
};
|
|
750
617
|
const terminal = isTerminalOutcome(outcome);
|
|
751
618
|
if (terminal && cronEngine.isActive(msg.surfaceId)) {
|
|
752
|
-
|
|
753
|
-
// the outcome via capturedCronOutcome, then settle the in-flight call (or
|
|
754
|
-
// queue the terminal so the next render discards-for-exit).
|
|
619
|
+
|
|
755
620
|
let merged = outcome;
|
|
756
621
|
capturedCronOutcome.set(msg.surfaceId, (cronOutcome) => { merged = cronOutcome; });
|
|
757
622
|
cronEngine.stop(msg.surfaceId, outcome);
|
|
@@ -761,25 +626,17 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
761
626
|
}
|
|
762
627
|
return;
|
|
763
628
|
}
|
|
764
|
-
|
|
765
|
-
// the surface is in visible_awaiting_agent — queue last-wins so the agent's
|
|
766
|
-
// next render delivers the latest event (spec §Tool-call accounting). A
|
|
767
|
-
// terminal with no live cron also lands here (queue → next render tears down).
|
|
768
|
-
// Capture the session up front: a live resolve moves the surface to
|
|
769
|
-
// visible_awaiting_agent (●→◌); a parked tap queues (◌). Both are
|
|
770
|
-
// content-less marker transitions on the active surface (roadmap 7a).
|
|
629
|
+
|
|
771
630
|
const sessionKey = surfaceStore.sessionForSurface(msg.surfaceId);
|
|
772
631
|
if (surfaceStore.resolve(msg.surfaceId, outcome)) {
|
|
773
|
-
|
|
632
|
+
|
|
774
633
|
emitMarker(sessionKey, msg.surfaceId);
|
|
775
634
|
} else {
|
|
776
635
|
const receipt = surfaceStore.queueEvent(msg.surfaceId, outcome, {
|
|
777
636
|
origin: outcome.origin,
|
|
778
637
|
actor: outcome.actor,
|
|
779
638
|
});
|
|
780
|
-
|
|
781
|
-
// (terminal latches carry no actionable intent — the next render tears
|
|
782
|
-
// down anyway; a latched-exit drop returns falsy and must never wake).
|
|
639
|
+
|
|
783
640
|
if (receipt && !receipt.kind) {
|
|
784
641
|
wakeController.onParkedGesture({
|
|
785
642
|
sessionKey: surfaceStore.sessionForSurface(msg.surfaceId),
|
|
@@ -790,7 +647,7 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
790
647
|
origin: outcome.origin,
|
|
791
648
|
});
|
|
792
649
|
}
|
|
793
|
-
|
|
650
|
+
|
|
794
651
|
emitMarker(sessionKey, msg.surfaceId);
|
|
795
652
|
}
|
|
796
653
|
});
|
|
@@ -807,9 +664,7 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
807
664
|
err.code = validation.code;
|
|
808
665
|
throw err;
|
|
809
666
|
}
|
|
810
|
-
|
|
811
|
-
// the stripped relay form address the same session everywhere downstream
|
|
812
|
-
// (store, nav-depth map, cron sessionKey, wake refs, lifecycle events).
|
|
667
|
+
|
|
813
668
|
const sessionKey = normalizeGlassesSessionKey(
|
|
814
669
|
typeof params.sessionKey === "string" && params.sessionKey.trim()
|
|
815
670
|
? params.sessionKey.trim()
|
|
@@ -835,9 +690,6 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
835
690
|
refreshValidated = v.refresh;
|
|
836
691
|
}
|
|
837
692
|
|
|
838
|
-
// Cross-kind window fields (roadmap 6b). Validated here — the kind
|
|
839
|
-
// descriptors rebuild a whitelisted canonical spec, so neither field can
|
|
840
|
-
// reach the wire; staleAfterMs is handed to the store per render.
|
|
841
693
|
const windowFields = validateWindowFields(params.spec);
|
|
842
694
|
if (!windowFields.ok) {
|
|
843
695
|
emitLifecycle("render_rejected", "warn", {
|
|
@@ -850,24 +702,12 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
850
702
|
throw err;
|
|
851
703
|
}
|
|
852
704
|
|
|
853
|
-
// params.depth is the execute-level RUN-CALL ordinal (resets at agent_end).
|
|
854
|
-
// It is used ONLY as the "first render of this run" signal for stale-stack
|
|
855
|
-
// reaping below — it must NEVER reach the wire. The wire depth is derived
|
|
856
|
-
// from the store's true stack depth after applyRender (B6: ordinals never
|
|
857
|
-
// decrement on Back, so they drift past entry counts and break both the
|
|
858
|
-
// plugin pop reconciliation and the client's clear-vs-append decision).
|
|
859
705
|
const depth = Number.isFinite(params.depth) ? Math.max(1, Math.floor(params.depth)) : 1;
|
|
860
706
|
const update =
|
|
861
707
|
params.spec && (params.spec.update === "patch" || params.spec.update === "push")
|
|
862
708
|
? params.spec.update
|
|
863
709
|
: "replace";
|
|
864
|
-
|
|
865
|
-
// session stack still holding PUSHED children at that moment is orphan
|
|
866
|
-
// residue from an earlier run (e.g. a client that bailed to chat without
|
|
867
|
-
// popping). Reap it before registering so a stale child can't swallow this
|
|
868
|
-
// render's events or forward a stale latched exit. A SINGLE root entry is
|
|
869
|
-
// NOT stale — that's the designed patch/replace re-attach path
|
|
870
|
-
// (visible_awaiting_agent), which must keep its latch/queue semantics.
|
|
710
|
+
|
|
871
711
|
if (depth <= 1 && surfaceStore.stackDepth(sessionKey) > 1) {
|
|
872
712
|
const stackDepthBefore = surfaceStore.stackDepth(sessionKey);
|
|
873
713
|
const reapedPending = reapSession(sessionKey, { result: "preempted" });
|
|
@@ -877,19 +717,14 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
877
717
|
reapedPending,
|
|
878
718
|
});
|
|
879
719
|
}
|
|
880
|
-
|
|
881
|
-
// target from the session's current top: patch/replace reuse the top id
|
|
882
|
-
// (re-attach in place), push mints a child + pauses the parent cron, the
|
|
883
|
-
// first render mints a root. This is the single place a surfaceId is bound.
|
|
720
|
+
|
|
884
721
|
const stackDepthBeforeAttach = surfaceStore.stackDepth(sessionKey);
|
|
885
722
|
const applied = surfaceStore.applyRender(sessionKey, {
|
|
886
723
|
update,
|
|
887
724
|
kind: validation.spec.kind,
|
|
888
725
|
});
|
|
889
726
|
const surfaceId = applied.surfaceId;
|
|
890
|
-
|
|
891
|
-
// "root" means a collect render did NOT find the surface it references —
|
|
892
|
-
// the drift-#3 phantom-root signature. Permanent tripwire.
|
|
727
|
+
|
|
893
728
|
emitLifecycle("surface_attach", "debug", {
|
|
894
729
|
surfaceId,
|
|
895
730
|
sessionKey,
|
|
@@ -902,68 +737,34 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
902
737
|
staleAfterMs: windowFields.staleAfterMs,
|
|
903
738
|
title: typeof validation.spec.title === "string" ? validation.spec.title : undefined,
|
|
904
739
|
});
|
|
905
|
-
|
|
906
|
-
// onto an already-attached surface (the visible_awaiting_agent window can
|
|
907
|
-
// only exist on a surface that previously resolved a call). A fresh
|
|
908
|
-
// root/push has an empty queue and stays visible_pending. onReattached
|
|
909
|
-
// delivers any queued nonterminal against the call we just established, or —
|
|
910
|
-
// if an exit was latched — returns "discarded_for_exit" so this render is
|
|
911
|
-
// dropped and the surface tears down (spec §Tool-call accounting). This is
|
|
912
|
-
// move-independent: replace (the schema default) carries the prior entry's
|
|
913
|
-
// latched exit / queued event forward (Task 8 makeEntry).
|
|
740
|
+
|
|
914
741
|
if (applied.mode === "patch" || applied.mode === "replace") {
|
|
915
742
|
const reattach = surfaceStore.onReattached(surfaceId);
|
|
916
743
|
if (reattach === "discarded_for_exit") {
|
|
917
|
-
|
|
918
|
-
// latched terminal outcome (so `promise` is settled). Tear down instead
|
|
919
|
-
// of painting the discarded render.
|
|
744
|
+
|
|
920
745
|
if (cronEngine.isActive(surfaceId)) cronEngine.stop(surfaceId, { result: "dismissed" });
|
|
921
746
|
surfaceStore.exit(sessionKey);
|
|
922
747
|
return promise;
|
|
923
748
|
}
|
|
924
749
|
if (reattach === "reattached_stale_latch_dropped") {
|
|
925
|
-
|
|
926
|
-
// summary) queued under a prior call was dropped instead of consuming
|
|
927
|
-
// this fresh call's window. Observable for ride forensics.
|
|
750
|
+
|
|
928
751
|
emitLifecycle("stale_cron_summary_dropped", "debug", { surfaceId, sessionKey });
|
|
929
752
|
}
|
|
930
753
|
}
|
|
931
754
|
|
|
932
|
-
// The wire depth is the TRUE stack depth (entry count) after applyRender:
|
|
933
|
-
// root=1, push=parent+1, replace/patch=unchanged. The client keys its
|
|
934
|
-
// clear-vs-append-vs-swap decision and Back classification on this value,
|
|
935
|
-
// and handleNavEvent's pop loop compares it against the same entry counts.
|
|
936
755
|
const wireDepth = Math.max(1, surfaceStore.stackDepth(sessionKey));
|
|
937
756
|
|
|
938
|
-
// Initial render uses the agent's seed (instant). Routed through the
|
|
939
|
-
// paint-floor coalescer as a leading-edge render sentinel so it shares the
|
|
940
|
-
// single send chokepoint (a render supersedes any queued field patch for
|
|
941
|
-
// this surface; see glasses-ui-paint-floor mergePatch).
|
|
942
|
-
// Compose the depth breadcrumb into the spec title BEFORE the enqueue
|
|
943
|
-
// (roadmap 7a). The store retains each surface's own title at register, so
|
|
944
|
-
// breadcrumbFor joins the live stack's titles ("System stats › CPU"); the
|
|
945
|
-
// conservative pixel-safe clip keeps it inside the client's title band.
|
|
946
757
|
const breadcrumb = surfaceStore.breadcrumbFor(sessionKey);
|
|
947
758
|
if (breadcrumb) validation.spec.title = clipBreadcrumb(breadcrumb);
|
|
948
759
|
paintFloor.enqueue({
|
|
949
760
|
surfaceId,
|
|
950
761
|
sessionKey,
|
|
951
|
-
|
|
952
|
-
// open window, "inflight" for a collect that just delivered a parked tap
|
|
953
|
-
// via onReattached (the enqueue runs AFTER onReattached, so markerFor
|
|
954
|
-
// reads true post-collect state).
|
|
762
|
+
|
|
955
763
|
patch: { __render: true, __depth: wireDepth, __spec: validation.spec, __marker: surfaceStore.markerFor(surfaceId) },
|
|
956
764
|
});
|
|
957
765
|
|
|
958
|
-
// Live-refresh path: kick off the cron in parallel. A `patch` onto an
|
|
959
|
-
// already-ticking surface leaves its cron alone (spec: "cron keeps
|
|
960
|
-
// ticking"); every other move (replace/push/root) starts a cron for the new
|
|
961
|
-
// content when this render carries a refresh.
|
|
962
766
|
if (refreshValidated && !(update === "patch" && cronEngine.isActive(surfaceId))) {
|
|
963
|
-
|
|
964
|
-
// and fail the smoke test. For non-LLM recipes this is a no-op. All llm
|
|
965
|
-
// backends are HTTP API backends that resolve a key via host modelAuth;
|
|
966
|
-
// a missing key degrades to a graceful recipe_failed on tick 1.
|
|
767
|
+
|
|
967
768
|
if (refreshValidated.recipe.kind === "llm" && typeof deps.prewarmLlmApiKey === "function") {
|
|
968
769
|
const cfg = deps.getGlassesUiLiveConfig ? deps.getGlassesUiLiveConfig() : {};
|
|
969
770
|
const agentModel =
|
|
@@ -977,8 +778,7 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
977
778
|
try {
|
|
978
779
|
await deps.prewarmLlmApiKey(prewarmModel);
|
|
979
780
|
} catch (_) {
|
|
980
|
-
|
|
981
|
-
// recipe_failed with a useful error from the backend.
|
|
781
|
+
|
|
982
782
|
}
|
|
983
783
|
}
|
|
984
784
|
}
|
|
@@ -997,29 +797,16 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
997
797
|
)
|
|
998
798
|
: undefined,
|
|
999
799
|
onResolve: (cronOutcome) => {
|
|
1000
|
-
|
|
1001
|
-
// result. capturedCronOutcome lets the terminal-via-user path read
|
|
1002
|
-
// the merged outcome. Only the cron's OWN terminal outcomes resolve
|
|
1003
|
-
// the pending call here (recipe_failed / timeout / external stop);
|
|
1004
|
-
// user-action results resolve via onGlassesUiResult above.
|
|
800
|
+
|
|
1005
801
|
const capture = capturedCronOutcome.get(surfaceId);
|
|
1006
802
|
if (capture) capture(cronOutcome);
|
|
1007
803
|
if (isTerminalOutcome(cronOutcome)) {
|
|
1008
|
-
|
|
1009
|
-
// gesture — origin "system" (roadmap 6b). A user-action terminal
|
|
1010
|
-
// routed through cron stop carries its gesture stamp in `extra`
|
|
1011
|
-
// and wins the merge, so this fallback never overwrites it.
|
|
804
|
+
|
|
1012
805
|
const stamped = {
|
|
1013
806
|
...cronOutcome,
|
|
1014
807
|
origin: typeof cronOutcome.origin === "string" ? cronOutcome.origin : "system",
|
|
1015
808
|
};
|
|
1016
|
-
|
|
1017
|
-
// visible_awaiting_agent after a nonterminal user action and the
|
|
1018
|
-
// cron then hit its own terminal — recipe_failed / maxDuration
|
|
1019
|
-
// timeout), QUEUE the terminal so the agent's next render's
|
|
1020
|
-
// onReattached returns discarded_for_exit and tears the surface
|
|
1021
|
-
// down. Without this fallback the dead-cron surface would persist.
|
|
1022
|
-
// Symmetric with the onGlassesUiResult terminal path.
|
|
809
|
+
|
|
1023
810
|
if (!surfaceStore.resolve(surfaceId, stamped)) {
|
|
1024
811
|
surfaceStore.queueEvent(surfaceId, stamped);
|
|
1025
812
|
}
|
|
@@ -1034,21 +821,11 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
1034
821
|
deps && typeof deps.clearTimeout === "function" ? deps.clearTimeout : clearTimeout;
|
|
1035
822
|
const cleanups = [];
|
|
1036
823
|
|
|
1037
|
-
// 6c wrap-up: a NON-terminal pre-deadline resolve beats the gateway
|
|
1038
|
-
// watchdog (default 90s; agent-suppliable via the 6b timeoutMs schema
|
|
1039
|
-
// field) so the agent gets a useful, self-teaching result instead of a
|
|
1040
|
-
// bare timeout string — and the pending resolver is FREED, so subsequent
|
|
1041
|
-
// taps PARK in the 6a event log. Armed on EVERY render, including cron/
|
|
1042
|
-
// refresh surfaces: pre-6c the only timer here was the terminal janitor
|
|
1043
|
-
// below, gated !refreshValidated, so cron surfaces had no plugin-side
|
|
1044
|
-
// deadline at all and their calls died only at the watchdog cliff.
|
|
1045
824
|
const effectiveWindowMs =
|
|
1046
825
|
windowFields.timeoutMs !== undefined
|
|
1047
826
|
? windowFields.timeoutMs
|
|
1048
827
|
: GATEWAY_DYNAMIC_TOOL_DEFAULT_TIMEOUT_MS;
|
|
1049
|
-
|
|
1050
|
-
// margin doesn't fit, fall back to half the window — the wrap-up must
|
|
1051
|
-
// ALWAYS beat the gateway or it delivers into an abandoned call.
|
|
828
|
+
|
|
1052
829
|
const wrapUpMarginMs = Math.min(5000, Math.max(2000, Math.floor(effectiveWindowMs * 0.05)));
|
|
1053
830
|
const wrapUpDelayMs = Math.max(effectiveWindowMs - wrapUpMarginMs, Math.floor(effectiveWindowMs / 2));
|
|
1054
831
|
const windowExpiredOutcome = (extra) =>
|
|
@@ -1070,18 +847,12 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
1070
847
|
windowMs: effectiveWindowMs,
|
|
1071
848
|
via: "wrap_up_timer",
|
|
1072
849
|
});
|
|
1073
|
-
|
|
1074
|
-
// awaitingAgentResponse stays false and markerFor derives parked (7a).
|
|
850
|
+
|
|
1075
851
|
emitMarker(sessionKey, surfaceId);
|
|
1076
852
|
}
|
|
1077
853
|
}, wrapUpDelayMs);
|
|
1078
854
|
cleanups.push(() => clearTimeoutFn(wrapUpHandle));
|
|
1079
855
|
|
|
1080
|
-
// 6c abort: the gateway delivers its watchdog/run-abort AbortSignal as
|
|
1081
|
-
// execute()'s third argument — and ALSO aborts it after NORMAL completion,
|
|
1082
|
-
// so releasing here must tolerate abort-after-resolve (surfaceStore.resolve
|
|
1083
|
-
// no-ops once settled). Releasing the orphaned resolver is what turns the
|
|
1084
|
-
// first post-cliff tap from resolved-into-the-void into a parked event.
|
|
1085
856
|
const signal = params.signal;
|
|
1086
857
|
if (signal && typeof signal.addEventListener === "function") {
|
|
1087
858
|
const onAbort = () => {
|
|
@@ -1106,43 +877,26 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
1106
877
|
}
|
|
1107
878
|
}
|
|
1108
879
|
|
|
1109
|
-
// Terminal janitor (pre-6c behavior, unchanged semantics): bounds the
|
|
1110
|
-
// orphan-tool_use corruption window on non-refresh surfaces via the
|
|
1111
|
-
// 30-min default knob. Cron surfaces keep relying on maxDurationMs.
|
|
1112
880
|
const timeoutMs = Number.isFinite(params.timeoutMs)
|
|
1113
881
|
? params.timeoutMs
|
|
1114
882
|
: resolveHandlerTimeoutMs();
|
|
1115
883
|
if (!refreshValidated && Number.isFinite(timeoutMs) && timeoutMs > 0) {
|
|
1116
884
|
const handle = setTimeoutFn(() => {
|
|
1117
|
-
|
|
1118
|
-
// no-op when the client already produced a real outcome. timeout is
|
|
1119
|
-
// terminal, so it also moves the surface to `exiting`.
|
|
885
|
+
|
|
1120
886
|
surfaceStore.resolve(surfaceId, { result: "timeout", timeout_ms: timeoutMs, origin: "system" });
|
|
1121
887
|
}, timeoutMs);
|
|
1122
888
|
cleanups.push(() => clearTimeoutFn(handle));
|
|
1123
889
|
}
|
|
1124
890
|
|
|
1125
|
-
// Decoupled lifecycle: the call resolves on the user's action (via
|
|
1126
|
-
// onGlassesUiResult), the cron's terminal outcome (via onResolve), the
|
|
1127
|
-
// wrap-up timer, or the abort release. A nonterminal resolve (selected/
|
|
1128
|
-
// back/window_expired) does NOT stop the cron — the surface persists
|
|
1129
|
-
// until a terminal or a drain. Per-call timers/listeners die with the call.
|
|
1130
891
|
return promise.then((outcome) => {
|
|
1131
892
|
for (const fn of cleanups) {
|
|
1132
|
-
try { fn(); } catch (_) {
|
|
893
|
+
try { fn(); } catch (_) { }
|
|
1133
894
|
}
|
|
1134
895
|
return outcome;
|
|
1135
896
|
});
|
|
1136
897
|
}
|
|
1137
898
|
|
|
1138
|
-
|
|
1139
|
-
// pop (depth down) on the client nav-event. The surfaceIds + pause/resume
|
|
1140
|
-
// live in surfaceStore (the single source of truth) — there is NO second
|
|
1141
|
-
// stack here. On push the parent cron was already paused by applyRender
|
|
1142
|
-
// during the agent's push render, so push is idempotent here; on pop we drive
|
|
1143
|
-
// surfaceStore.popBack, which stops the child cron and staleness-resumes the
|
|
1144
|
-
// parent (Spike B: the plugin owns the resume target).
|
|
1145
|
-
const navDepthBySession = new Map(); // sessionKey -> lastSeenDepth
|
|
899
|
+
const navDepthBySession = new Map();
|
|
1146
900
|
|
|
1147
901
|
function handleNavEvent(rawSessionKey, ev) {
|
|
1148
902
|
const sessionKey = normalizeGlassesSessionKey(rawSessionKey);
|
|
@@ -1152,8 +906,7 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
1152
906
|
let popCount = 0;
|
|
1153
907
|
let resumedParent = null;
|
|
1154
908
|
if (newDepth < lastDepth) {
|
|
1155
|
-
|
|
1156
|
-
// depth — each popBack stops the child cron + resumes the new parent.
|
|
909
|
+
|
|
1157
910
|
let guard = 0;
|
|
1158
911
|
while (surfaceStore.stackDepth(sessionKey) > newDepth && guard < 64) {
|
|
1159
912
|
resumedParent = surfaceStore.popBack(sessionKey);
|
|
@@ -1166,21 +919,11 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
1166
919
|
storeDepthBefore > 1 &&
|
|
1167
920
|
surfaceStore.topSurfaceId(sessionKey) === ev.surfaceId
|
|
1168
921
|
) {
|
|
1169
|
-
|
|
1170
|
-
// backed OUT OF — the store top. If the depth comparison said no-op
|
|
1171
|
-
// (drifted ordinals from an older client, or any depth desync) but the
|
|
1172
|
-
// reported surface IS the top with a parent beneath, pop exactly one
|
|
1173
|
-
// level. Push events carry the PARENT surfaceId — never the top after a
|
|
1174
|
-
// push — so this cannot misfire on a push report; and a duplicate Back
|
|
1175
|
-
// delivery is idempotent (after the pop the top no longer matches).
|
|
922
|
+
|
|
1176
923
|
resumedParent = surfaceStore.popBack(sessionKey);
|
|
1177
924
|
popCount += 1;
|
|
1178
925
|
}
|
|
1179
|
-
|
|
1180
|
-
// agent's push render (applyRender), so it is intentionally a no-op here.
|
|
1181
|
-
// Resume re-asserts the restored parent's presence marker (7a): a pop
|
|
1182
|
-
// brings the parent back as the top surface, so emit its current marker
|
|
1183
|
-
// (emitMarker self-gates to the session top — resumedParent is now it).
|
|
926
|
+
|
|
1184
927
|
if (popCount > 0 && resumedParent) {
|
|
1185
928
|
emitMarker(sessionKey, resumedParent);
|
|
1186
929
|
}
|
|
@@ -1197,18 +940,12 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
1197
940
|
navDepthBySession.set(sessionKey, newDepth);
|
|
1198
941
|
}
|
|
1199
942
|
|
|
1200
|
-
// Stop crons, resolve pending calls with `outcome`, clear the session stack.
|
|
1201
|
-
// Resolve pending + delete entries FIRST (so pending calls settle with
|
|
1202
|
-
// `outcome`), THEN clear the per-session stack. exit() deletes entries
|
|
1203
|
-
// without resolving, so it must NOT run before the drain or the pending
|
|
1204
|
-
// promises would hang. Shared by the public drainSession (agent_end /
|
|
1205
|
-
// disconnect) and the stale-stack reap in runDynamicUi (B3).
|
|
1206
943
|
function reapSession(rawSessionKey, outcome) {
|
|
1207
944
|
const sessionKey = normalizeGlassesSessionKey(rawSessionKey);
|
|
1208
945
|
cronEngine.stopAllForSession(sessionKey, outcome);
|
|
1209
946
|
const reaped = surfaceStore.drainSession(sessionKey, outcome);
|
|
1210
|
-
surfaceStore.exit(sessionKey);
|
|
1211
|
-
navDepthBySession.delete(sessionKey);
|
|
947
|
+
surfaceStore.exit(sessionKey);
|
|
948
|
+
navDepthBySession.delete(sessionKey);
|
|
1212
949
|
return reaped;
|
|
1213
950
|
}
|
|
1214
951
|
|
|
@@ -1219,10 +956,7 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
1219
956
|
drainSession(sessionKey, outcome) {
|
|
1220
957
|
return reapSession(sessionKey, outcome);
|
|
1221
958
|
},
|
|
1222
|
-
|
|
1223
|
-
// PRESERVE surfaces, parked events, crons and the stack — surfaces are
|
|
1224
|
-
// designed to outlive runs (drift #3: the old unconditional reap here
|
|
1225
|
-
// destroyed parked wearer intent whenever it fired in the owning context).
|
|
959
|
+
|
|
1226
960
|
settleSession(sessionKey, outcome) {
|
|
1227
961
|
return surfaceStore.settlePending(sessionKey, outcome);
|
|
1228
962
|
},
|
|
@@ -1235,18 +969,14 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
1235
969
|
navDepthBySession.clear();
|
|
1236
970
|
return reaped;
|
|
1237
971
|
},
|
|
1238
|
-
|
|
1239
|
-
// below) is the production consumer — an external drain steals owed
|
|
1240
|
-
// voicemail, so these passthroughs are debug/ops surfaces only.
|
|
972
|
+
|
|
1241
973
|
peekWakeOutbox() {
|
|
1242
974
|
return wakeController.peekWakeOutbox();
|
|
1243
975
|
},
|
|
1244
976
|
drainWakeOutbox() {
|
|
1245
977
|
return wakeController.drainWakeOutbox();
|
|
1246
978
|
},
|
|
1247
|
-
|
|
1248
|
-
// next genuine turn, or null when nothing is owed. Consumed by the
|
|
1249
|
-
// before_prompt_build hook in registerGlassesUiTool.
|
|
979
|
+
|
|
1250
980
|
buildVoicemailInjection(sessionKey) {
|
|
1251
981
|
return voicemail.buildInjection(sessionKey);
|
|
1252
982
|
},
|
|
@@ -1260,16 +990,11 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
1260
990
|
surfaceStackDepth(sessionKey) {
|
|
1261
991
|
return surfaceStore.stackDepth(sessionKey);
|
|
1262
992
|
},
|
|
1263
|
-
|
|
1264
|
-
// after a wearer tap) must flip the surface inflight→parked. settleSession
|
|
1265
|
-
// already cleared any leaked pending call; clearing awaitingResponse drops
|
|
1266
|
-
// the last "agent is responding" fact so markerFor derives parked. Routed
|
|
1267
|
-
// through the handler closure because surfaceStore/emitMarker are NOT in
|
|
1268
|
-
// scope at the agent_end hook site (Codex finding).
|
|
993
|
+
|
|
1269
994
|
parkMarkerOnAgentEnd(sessionKey) {
|
|
1270
|
-
surfaceStore.clearAwaitingResponse(sessionKey);
|
|
995
|
+
surfaceStore.clearAwaitingResponse(sessionKey);
|
|
1271
996
|
const top = surfaceStore.topSurfaceId(sessionKey);
|
|
1272
|
-
if (top) emitMarker(sessionKey, top);
|
|
997
|
+
if (top) emitMarker(sessionKey, top);
|
|
1273
998
|
},
|
|
1274
999
|
sessionForSurface(surfaceId) {
|
|
1275
1000
|
return surfaceStore.sessionForSurface(surfaceId);
|
|
@@ -1318,13 +1043,6 @@ export const GLASSES_UI_TOOL_DESCRIPTION = [
|
|
|
1318
1043
|
"\"selected\" result, follow up with another render or a brief one-line ack.",
|
|
1319
1044
|
].join("\n");
|
|
1320
1045
|
|
|
1321
|
-
// Shared per-session depth counter. OpenClaw loads the plugin's register(api)
|
|
1322
|
-
// in multiple isolated contexts (gateway startup, per-agent-run tool discovery)
|
|
1323
|
-
// and each call to registerGlassesUiTool would otherwise close over its own
|
|
1324
|
-
// Map. execute() runs in the per-run context's closure while api.on("agent_end",
|
|
1325
|
-
// ...) fires from an earlier global-context closure, so reset-on-end would miss
|
|
1326
|
-
// the live counter. Stashing the map on globalThis under a stable Symbol gives
|
|
1327
|
-
// every load context the same Map to read and mutate.
|
|
1328
1046
|
const DEPTH_MAP_SYMBOL = Symbol.for("ocuclaw.glasses-ui.depthBySession");
|
|
1329
1047
|
function getSharedDepthMap() {
|
|
1330
1048
|
let m = globalThis[DEPTH_MAP_SYMBOL];
|
|
@@ -1335,16 +1053,6 @@ function getSharedDepthMap() {
|
|
|
1335
1053
|
return m;
|
|
1336
1054
|
}
|
|
1337
1055
|
|
|
1338
|
-
// Shared handler record, same multi-context reasoning as the depth map but for
|
|
1339
|
-
// ALL of the tool's mutable state: surface store + dead-letter, cron engine,
|
|
1340
|
-
// paint-floor coalescer and the 6f wake controller/outbox. The 2026-06-12
|
|
1341
|
-
// live census (drift #3) proved tool execution and agent_end hooks land in
|
|
1342
|
-
// DIFFERENT load contexts, and a QUEUED run's execution resolves to the
|
|
1343
|
-
// non-owning context ~3/4 of the time — with per-context stores that minted a
|
|
1344
|
-
// phantom root and stranded the parked tap. One shared handler makes every
|
|
1345
|
-
// context's execute/hook/relay-callback address the same state, so which
|
|
1346
|
-
// registry the gateway picks per run stops mattering. Tests inject an
|
|
1347
|
-
// isolated `opts.scopeHost` ({}); production omits it and shares globalThis.
|
|
1348
1056
|
const HANDLER_SCOPE_SYMBOL = Symbol.for("ocuclaw.glasses-ui.sharedHandler");
|
|
1349
1057
|
|
|
1350
1058
|
export function registerGlassesUiTool(api, service, opts = {}) {
|
|
@@ -1389,18 +1097,11 @@ export function registerGlassesUiTool(api, service, opts = {}) {
|
|
|
1389
1097
|
return typeof key === "string" ? key : "";
|
|
1390
1098
|
}
|
|
1391
1099
|
} catch (_) {
|
|
1392
|
-
|
|
1100
|
+
|
|
1393
1101
|
}
|
|
1394
1102
|
return "";
|
|
1395
1103
|
}
|
|
1396
1104
|
|
|
1397
|
-
// Inline resolution: the cron engine asks for the API key once per tick
|
|
1398
|
-
// for the current model. We cache the last-resolved model→key pair across
|
|
1399
|
-
// ticks since model rarely changes within a cron. runDynamicUi awaits
|
|
1400
|
-
// prewarmLlmApiKey before starting the cron, so tick 1 sees the resolved
|
|
1401
|
-
// key. resolveLlmApiKeySync is only called after the prewarm has populated
|
|
1402
|
-
// the cache; if it ever runs uncached it returns "" and the backend
|
|
1403
|
-
// reports a useful error (cron resolves recipe_failed via the breaker).
|
|
1404
1105
|
let lastModel = null;
|
|
1405
1106
|
let lastKey = "";
|
|
1406
1107
|
async function prewarmLlmApiKey(modelRef) {
|
|
@@ -1411,8 +1112,7 @@ export function registerGlassesUiTool(api, service, opts = {}) {
|
|
|
1411
1112
|
}
|
|
1412
1113
|
function resolveLlmApiKeySync(modelRef) {
|
|
1413
1114
|
if (modelRef === lastModel) return lastKey;
|
|
1414
|
-
|
|
1415
|
-
// off async resolution but return empty for this tick.
|
|
1115
|
+
|
|
1416
1116
|
resolveLlmApiKey(modelRef).then((key) => {
|
|
1417
1117
|
lastModel = modelRef;
|
|
1418
1118
|
lastKey = key;
|
|
@@ -1420,12 +1120,6 @@ export function registerGlassesUiTool(api, service, opts = {}) {
|
|
|
1420
1120
|
return "";
|
|
1421
1121
|
}
|
|
1422
1122
|
|
|
1423
|
-
// Get-or-create the shared handler. Only the CREATING context builds the
|
|
1424
|
-
// handler and wires the relay-singleton callbacks (disconnect drain, nav
|
|
1425
|
-
// reconcile) — a second wiring against the same shared relay would
|
|
1426
|
-
// double-handle every nav event / disconnect on the now-shared store.
|
|
1427
|
-
// Boot order makes the creator the gateway-startup full registry, whose
|
|
1428
|
-
// api/service carry the complete runtime surface.
|
|
1429
1123
|
let scopeRecord = scopeHost[HANDLER_SCOPE_SYMBOL];
|
|
1430
1124
|
const createsHandler = !scopeRecord || !scopeRecord.handler;
|
|
1431
1125
|
|
|
@@ -1441,7 +1135,7 @@ export function registerGlassesUiTool(api, service, opts = {}) {
|
|
|
1441
1135
|
service.emitGlassesUiLifecycle(event, severity, data);
|
|
1442
1136
|
}
|
|
1443
1137
|
} catch (_) {
|
|
1444
|
-
|
|
1138
|
+
|
|
1445
1139
|
}
|
|
1446
1140
|
},
|
|
1447
1141
|
getGlassesUiLiveConfig: () => {
|
|
@@ -1455,10 +1149,7 @@ export function registerGlassesUiTool(api, service, opts = {}) {
|
|
|
1455
1149
|
resolveLlmApiKey: resolveLlmApiKeySync,
|
|
1456
1150
|
prewarmLlmApiKey,
|
|
1457
1151
|
timeoutMs: () => {
|
|
1458
|
-
|
|
1459
|
-
// effect on the next render without a gateway restart. A non-finite
|
|
1460
|
-
// or <=0 value disables the timeout (infinite wait — the pre-2026-05-23
|
|
1461
|
-
// behaviour, kept available as an escape hatch).
|
|
1152
|
+
|
|
1462
1153
|
try {
|
|
1463
1154
|
const cfg = service.getRuntimeConfig && service.getRuntimeConfig();
|
|
1464
1155
|
const v = cfg && cfg.renderGlassesUiTimeoutMs;
|
|
@@ -1468,25 +1159,14 @@ export function registerGlassesUiTool(api, service, opts = {}) {
|
|
|
1468
1159
|
}
|
|
1469
1160
|
},
|
|
1470
1161
|
isSessionConnected: () => {
|
|
1471
|
-
|
|
1472
|
-
// contexts) for any connected downstream client. Per-session
|
|
1473
|
-
// connection tracking would need deeper plumbing; the global check
|
|
1474
|
-
// catches the common failure mode where the tool is invoked with no
|
|
1475
|
-
// glasses client at all, which is what produced the indefinite hangs
|
|
1476
|
-
// before this gate existed.
|
|
1162
|
+
|
|
1477
1163
|
if (typeof service.hasConnectedAppClient === "function") {
|
|
1478
1164
|
return service.hasConnectedAppClient();
|
|
1479
1165
|
}
|
|
1480
1166
|
return false;
|
|
1481
1167
|
},
|
|
1482
1168
|
isUnderBackpressure: () => {
|
|
1483
|
-
|
|
1484
|
-
// send buffer is over its high-water mark (Spike D — there is no
|
|
1485
|
-
// glass-side paint-ack, so transport pressure is the only signal). The
|
|
1486
|
-
// signal source is relay-health-monitor's send-buffer high-water; until
|
|
1487
|
-
// relay-service surfaces it as isGlassesSendBufferOverHighWater this
|
|
1488
|
-
// returns false (safe default — no shedding). Completing this query is
|
|
1489
|
-
// part of the BLE-backpressure hardening validated on hardware (Task 20).
|
|
1169
|
+
|
|
1490
1170
|
try {
|
|
1491
1171
|
return typeof service.isGlassesSendBufferOverHighWater === "function"
|
|
1492
1172
|
? service.isGlassesSendBufferOverHighWater()
|
|
@@ -1495,9 +1175,7 @@ export function registerGlassesUiTool(api, service, opts = {}) {
|
|
|
1495
1175
|
return false;
|
|
1496
1176
|
}
|
|
1497
1177
|
},
|
|
1498
|
-
|
|
1499
|
-
// predates them — the wake controller then no-ops (parked taps keep
|
|
1500
|
-
// their collect-on-next-render semantics).
|
|
1178
|
+
|
|
1501
1179
|
dispatchWake:
|
|
1502
1180
|
typeof service.dispatchGlassesWake === "function"
|
|
1503
1181
|
? (params) => service.dispatchGlassesWake(params)
|
|
@@ -1517,10 +1195,6 @@ export function registerGlassesUiTool(api, service, opts = {}) {
|
|
|
1517
1195
|
scopeRecord = { handler, refs: 0 };
|
|
1518
1196
|
scopeHost[HANDLER_SCOPE_SYMBOL] = scopeRecord;
|
|
1519
1197
|
|
|
1520
|
-
// Wire glasses-disconnect to stop any active crons for the affected
|
|
1521
|
-
// session. The agent_end hook below only SETTLES pending calls; this path
|
|
1522
|
-
// is the real teardown — the glasses are gone, so surfaces, parked events
|
|
1523
|
-
// and crons all drain (dead-lettering undelivered nonterminals).
|
|
1524
1198
|
if (typeof service.onAppClientDisconnect === "function") {
|
|
1525
1199
|
service.onAppClientDisconnect(({ sessionKey }) => {
|
|
1526
1200
|
const target = sessionKey || null;
|
|
@@ -1532,12 +1206,6 @@ export function registerGlassesUiTool(api, service, opts = {}) {
|
|
|
1532
1206
|
});
|
|
1533
1207
|
}
|
|
1534
1208
|
|
|
1535
|
-
// Reconcile client nav-events with the SINGLE store stack (Task 16). On pop
|
|
1536
|
-
// the client reports the surfaceId now back on top + the post-pop depth; the
|
|
1537
|
-
// store knows it. The relay frame carries no sessionKey, so resolve it from
|
|
1538
|
-
// the surface's store entry (sessionForSurface). With the shared handler
|
|
1539
|
-
// this wiring exists ONCE per process; the foreign-surface no-op guard
|
|
1540
|
-
// stays for surfaces the store genuinely does not know (B2).
|
|
1541
1209
|
if (typeof service.onGlassesUiNavEvent === "function") {
|
|
1542
1210
|
service.onGlassesUiNavEvent((ev) => {
|
|
1543
1211
|
const sessionKey = handler.sessionForSurface(ev.surfaceId);
|
|
@@ -1550,7 +1218,7 @@ export function registerGlassesUiTool(api, service, opts = {}) {
|
|
|
1550
1218
|
});
|
|
1551
1219
|
}
|
|
1552
1220
|
} catch (_) {
|
|
1553
|
-
|
|
1221
|
+
|
|
1554
1222
|
}
|
|
1555
1223
|
return;
|
|
1556
1224
|
}
|
|
@@ -1569,14 +1237,7 @@ export function registerGlassesUiTool(api, service, opts = {}) {
|
|
|
1569
1237
|
|
|
1570
1238
|
api.registerTool(
|
|
1571
1239
|
(ctx) => {
|
|
1572
|
-
|
|
1573
|
-
// sessions). Those runs' responses go back to the Even Realities native
|
|
1574
|
-
// app — not the OcuClaw chat surface — so a glasses popup wouldn't be
|
|
1575
|
-
// reachable for the user. The tool stays visible to:
|
|
1576
|
-
// • the normal OcuClaw chat path (ocuclaw:<timestamp> sessions)
|
|
1577
|
-
// • the Even AI listen-mode path (which intercepts the HTTP
|
|
1578
|
-
// request and routes it through the active OcuClaw session, so
|
|
1579
|
-
// the sessionKey is an ocuclaw:<timestamp>, not ocuclaw:even-ai*).
|
|
1240
|
+
|
|
1580
1241
|
const sessionKey = ctx && typeof ctx.sessionKey === "string" ? ctx.sessionKey : "";
|
|
1581
1242
|
if (isEvenAiAgentSession(sessionKey, resolveDedicatedEvenAiSessionKey())) {
|
|
1582
1243
|
return null;
|
|
@@ -1590,10 +1251,7 @@ export function registerGlassesUiTool(api, service, opts = {}) {
|
|
|
1590
1251
|
const resolvedSessionKey = normalizeGlassesSessionKey(factorySessionKey || "main");
|
|
1591
1252
|
const depth = nextDepth(resolvedSessionKey);
|
|
1592
1253
|
try {
|
|
1593
|
-
|
|
1594
|
-
// argument (execute(toolCallId, args, signal, onUpdate) — verified
|
|
1595
|
-
// against the installed 2026.6.1 dist). runDynamicUi releases the
|
|
1596
|
-
// pending resolver on abort so post-cliff taps park (roadmap 6c).
|
|
1254
|
+
|
|
1597
1255
|
const outcome = await handler.runDynamicUi({
|
|
1598
1256
|
sessionKey: resolvedSessionKey,
|
|
1599
1257
|
depth,
|
|
@@ -1615,12 +1273,7 @@ export function registerGlassesUiTool(api, service, opts = {}) {
|
|
|
1615
1273
|
);
|
|
1616
1274
|
|
|
1617
1275
|
if (typeof api.on === "function") {
|
|
1618
|
-
|
|
1619
|
-
// genuine turn's prompt as appended system context (Channel-2 class —
|
|
1620
|
-
// gateway hooks.ts concatenates appendSystemContext across handlers, so
|
|
1621
|
-
// this composes with the channel-two fragment). Registered per load
|
|
1622
|
-
// context; the shared handler's pending state makes duplicate firings
|
|
1623
|
-
// drain-once.
|
|
1276
|
+
|
|
1624
1277
|
api.on("before_prompt_build", (_event, ctx) => {
|
|
1625
1278
|
const sessionKey = ctx && typeof ctx.sessionKey === "string" ? ctx.sessionKey : null;
|
|
1626
1279
|
if (!sessionKey) return undefined;
|
|
@@ -1628,27 +1281,18 @@ export function registerGlassesUiTool(api, service, opts = {}) {
|
|
|
1628
1281
|
const fragment = handler.buildVoicemailInjection(sessionKey);
|
|
1629
1282
|
return fragment ? { appendSystemContext: fragment } : undefined;
|
|
1630
1283
|
} catch (_) {
|
|
1631
|
-
|
|
1284
|
+
|
|
1632
1285
|
return undefined;
|
|
1633
1286
|
}
|
|
1634
1287
|
});
|
|
1635
1288
|
|
|
1636
1289
|
api.on("agent_end", (_event, ctx) => {
|
|
1637
1290
|
const sessionKey = ctx && typeof ctx.sessionKey === "string" ? ctx.sessionKey : null;
|
|
1638
|
-
|
|
1639
|
-
// depth counter — the safety net for runs torn down externally (timeout,
|
|
1640
|
-
// abort) so no pending call leaks across runs / corrupts tool_use
|
|
1641
|
-
// bookkeeping. SETTLE ONLY: surfaces, parked events, crons and the
|
|
1642
|
-
// stack persist (surfaces are designed to outlive runs; the old
|
|
1643
|
-
// unconditional drain here destroyed parked wearer intent whenever it
|
|
1644
|
-
// fired in the store-owning context — drift #3). With the shared
|
|
1645
|
-
// handler this hook fires from every load context against ONE store;
|
|
1646
|
-
// settlePending is idempotent so the duplicate firings are harmless.
|
|
1291
|
+
|
|
1647
1292
|
if (sessionKey) {
|
|
1648
1293
|
const stackDepth = handler.surfaceStackDepth(sessionKey);
|
|
1649
1294
|
const settledPending = handler.settleSession(sessionKey, { result: "preempted" });
|
|
1650
|
-
|
|
1651
|
-
// fired (which context's store, against what stack) was invisible.
|
|
1295
|
+
|
|
1652
1296
|
try {
|
|
1653
1297
|
if (typeof service.emitGlassesUiLifecycle === "function") {
|
|
1654
1298
|
service.emitGlassesUiLifecycle("agent_end_settle", "debug", {
|
|
@@ -1659,22 +1303,15 @@ export function registerGlassesUiTool(api, service, opts = {}) {
|
|
|
1659
1303
|
});
|
|
1660
1304
|
}
|
|
1661
1305
|
} catch (_) {
|
|
1662
|
-
|
|
1306
|
+
|
|
1663
1307
|
}
|
|
1664
|
-
|
|
1665
|
-
// surface marker — clears awaiting-response so markerFor derives parked.
|
|
1308
|
+
|
|
1666
1309
|
handler.parkMarkerOnAgentEnd(sessionKey);
|
|
1667
1310
|
}
|
|
1668
1311
|
resetDepth(sessionKey);
|
|
1669
1312
|
});
|
|
1670
1313
|
}
|
|
1671
1314
|
|
|
1672
|
-
// Refcounted teardown (review P1): every register context holds a reference
|
|
1673
|
-
// to the shared handler. Only the LAST live context's dispose drains it —
|
|
1674
|
-
// a secondary context disposing mid-flight (e.g. an ephemeral registry
|
|
1675
|
-
// being torn down) must not preempt live surfaces, clear the shared depth
|
|
1676
|
-
// map, or delete the record (which would let a later register mint a second
|
|
1677
|
-
// store and reintroduce the drift-#3 context split).
|
|
1678
1315
|
scopeRecord.refs += 1;
|
|
1679
1316
|
let disposedThisContext = false;
|
|
1680
1317
|
return function dispose() {
|
|
@@ -1684,8 +1321,7 @@ export function registerGlassesUiTool(api, service, opts = {}) {
|
|
|
1684
1321
|
if (scopeRecord.refs > 0) return;
|
|
1685
1322
|
handler.drainAll({ result: "preempted" });
|
|
1686
1323
|
depthBySession.clear();
|
|
1687
|
-
|
|
1688
|
-
// (real teardown happens once, at gateway service stop).
|
|
1324
|
+
|
|
1689
1325
|
if (scopeHost[HANDLER_SCOPE_SYMBOL] === scopeRecord) {
|
|
1690
1326
|
delete scopeHost[HANDLER_SCOPE_SYMBOL];
|
|
1691
1327
|
}
|