ocuclaw 1.3.2 → 1.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +93 -0
- 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 +657 -271
- package/dist/runtime/relay-service.js +40 -36
- 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 +109 -39
- package/dist/runtime/relay-worker-transport.js +157 -15
- 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 +58 -63
- 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 +22 -34
- package/dist/tools/glasses-ui-recipes.js +92 -101
- package/dist/tools/glasses-ui-surfaces.js +295 -100
- 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 +475 -331
- package/dist/tools/glasses-ui-voicemail.js +242 -0
- package/dist/tools/glasses-ui-wake.js +195 -0
- 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/skills/glasses-ui/SKILL.md +19 -3
- package/dist/runtime/protocol-adapter.js +0 -387
|
@@ -1,17 +1,15 @@
|
|
|
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
|
-
import { createPendingRenderMap, createSurfaceStore, isTerminalOutcome } from "./glasses-ui-surfaces.js";
|
|
10
|
+
import { createPendingRenderMap, createSurfaceStore, isTerminalOutcome, normalizeGlassesSessionKey } from "./glasses-ui-surfaces.js";
|
|
11
|
+
import { createGlassesWakeController } from "./glasses-ui-wake.js";
|
|
12
|
+
import { createGlassesVoicemail } from "./glasses-ui-voicemail.js";
|
|
15
13
|
import { createPaintFloorCoalescer, DEFAULT_PAINT_FLOOR_MS } from "./glasses-ui-paint-floor.js";
|
|
16
14
|
import { GLASSES_UI_LIMITS } from "./glasses-ui-limits.js";
|
|
17
15
|
import {
|
|
@@ -20,14 +18,6 @@ import {
|
|
|
20
18
|
buildOneOfBranches,
|
|
21
19
|
} from "./glasses-ui-descriptors.js";
|
|
22
20
|
|
|
23
|
-
// Re-exported so existing consumers/tests that import these from this module
|
|
24
|
-
// keep working after the extractions (spec §Changes A — extraction is behavior-
|
|
25
|
-
// preserving). Canonical homes: createPendingRenderMap/createSurfaceStore ->
|
|
26
|
-
// ./glasses-ui-surfaces.js, GLASSES_UI_LIMITS -> ./glasses-ui-limits.js. Kept as
|
|
27
|
-
// ONE bare `export {}` statement because the CJS emitter (scripts/build.mjs)
|
|
28
|
-
// strips only the first such statement — a second would survive into the .cjs
|
|
29
|
-
// as invalid syntax. createPendingRenderMap is the Phase-1 alias of the single
|
|
30
|
-
// createSurfaceStore (see glasses-ui-surfaces.ts).
|
|
31
21
|
export { createPendingRenderMap, createSurfaceStore, GLASSES_UI_LIMITS };
|
|
32
22
|
|
|
33
23
|
export const GLASSES_UI_REFRESH_LIMITS = {
|
|
@@ -51,21 +41,15 @@ export const GLASSES_UI_REFRESH_LIMITS = {
|
|
|
51
41
|
maxOutputTokensMin: 16,
|
|
52
42
|
maxOutputTokensMax: 1000,
|
|
53
43
|
maxOutputTokensDefault: 200,
|
|
54
|
-
|
|
55
|
-
// substituted OUTPUT is clamped to bodyMax/itemMax at runtime, but a huge
|
|
56
|
-
// template itself is wasted work — 4KB is generous for any HUD line.
|
|
44
|
+
|
|
57
45
|
templateMaxChars: 4096,
|
|
58
|
-
|
|
46
|
+
|
|
59
47
|
systemStatsWindowMsMin: 50,
|
|
60
48
|
systemStatsWindowMsMax: 1000,
|
|
61
49
|
};
|
|
62
50
|
|
|
63
51
|
const ON_ERROR_VALUES = new Set(["keep_last", "show_error", "stop"]);
|
|
64
52
|
|
|
65
|
-
// The effective per-tick interval floor is the larger of the tier minimum and
|
|
66
|
-
// the paint-floor coalescer's cadence (Spike D, 150ms) — no tick may schedule
|
|
67
|
-
// faster than the glass can paint. Today every tier min already exceeds 150ms,
|
|
68
|
-
// so this only guards the floor from ever relaxing below the coalescer cadence.
|
|
69
53
|
function effectiveIntervalFloorMs(tierMinMs) {
|
|
70
54
|
return Math.max(tierMinMs, DEFAULT_PAINT_FLOOR_MS);
|
|
71
55
|
}
|
|
@@ -87,14 +71,11 @@ export function validateRefreshSpec(refresh, glassesUiLiveCfg) {
|
|
|
87
71
|
if (kind !== "http" && kind !== "llm" && kind !== "system-stats") {
|
|
88
72
|
return { ok: false, code: "refresh_invalid_recipe", message: `recipe.kind must be http/llm/system-stats, got ${JSON.stringify(kind)}` };
|
|
89
73
|
}
|
|
90
|
-
|
|
91
|
-
// / maxOutputTokens to declared bounds, copy known fields only. The returned
|
|
92
|
-
// `refresh.recipe` is this sanitized version, never the raw input — so the
|
|
93
|
-
// executors at run-time see vetted values.
|
|
74
|
+
|
|
94
75
|
const sanitizedRecipe = { kind };
|
|
95
76
|
const bounded = (raw, min, max) => {
|
|
96
77
|
if (!Number.isFinite(raw)) return null;
|
|
97
|
-
if (raw < min || raw > max) return undefined;
|
|
78
|
+
if (raw < min || raw > max) return undefined;
|
|
98
79
|
return Math.floor(raw);
|
|
99
80
|
};
|
|
100
81
|
if (kind === "http") {
|
|
@@ -102,6 +83,13 @@ export function validateRefreshSpec(refresh, glassesUiLiveCfg) {
|
|
|
102
83
|
if (typeof recipe.url !== "string" || !recipe.url.trim()) {
|
|
103
84
|
return { ok: false, code: "refresh_invalid_recipe", message: "http recipe requires url (non-empty string)" };
|
|
104
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
|
+
}
|
|
105
93
|
sanitizedRecipe.url = recipe.url;
|
|
106
94
|
if (typeof recipe.method === "string") sanitizedRecipe.method = recipe.method;
|
|
107
95
|
if (recipe.headers && typeof recipe.headers === "object") sanitizedRecipe.headers = recipe.headers;
|
|
@@ -134,10 +122,7 @@ export function validateRefreshSpec(refresh, glassesUiLiveCfg) {
|
|
|
134
122
|
if (v !== null) sanitizedRecipe.maxOutputTokens = v;
|
|
135
123
|
}
|
|
136
124
|
} else if (kind === "system-stats") {
|
|
137
|
-
|
|
138
|
-
// by httpEnabled/llmEnabled — it touches no network, no shell, no
|
|
139
|
-
// model. Only the master `enabled` switch (checked above) governs it. Do NOT
|
|
140
|
-
// add a cfg.*Enabled gate here (intentional — Phase 3 design).
|
|
125
|
+
|
|
141
126
|
if (recipe.sampleWindowMs !== undefined) {
|
|
142
127
|
const v = bounded(recipe.sampleWindowMs, GLASSES_UI_REFRESH_LIMITS.systemStatsWindowMsMin, GLASSES_UI_REFRESH_LIMITS.systemStatsWindowMsMax);
|
|
143
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}]` };
|
|
@@ -145,7 +130,6 @@ export function validateRefreshSpec(refresh, glassesUiLiveCfg) {
|
|
|
145
130
|
}
|
|
146
131
|
}
|
|
147
132
|
|
|
148
|
-
// Interval bounds.
|
|
149
133
|
const intervalMs = refresh.intervalMs;
|
|
150
134
|
if (!Number.isFinite(intervalMs)) {
|
|
151
135
|
return { ok: false, code: "refresh_invalid_recipe", message: "refresh.intervalMs is required" };
|
|
@@ -166,7 +150,6 @@ export function validateRefreshSpec(refresh, glassesUiLiveCfg) {
|
|
|
166
150
|
return { ok: false, code: "refresh_interval_too_high", message: `intervalMs ${intervalMs} above max ${GLASSES_UI_REFRESH_LIMITS.intervalMsMax}` };
|
|
167
151
|
}
|
|
168
152
|
|
|
169
|
-
// Duration.
|
|
170
153
|
const maxDurationMs = Number.isFinite(refresh.maxDurationMs)
|
|
171
154
|
? refresh.maxDurationMs
|
|
172
155
|
: GLASSES_UI_REFRESH_LIMITS.maxDurationMsDefault;
|
|
@@ -174,13 +157,11 @@ export function validateRefreshSpec(refresh, glassesUiLiveCfg) {
|
|
|
174
157
|
return { ok: false, code: "refresh_duration_too_high", message: `maxDurationMs ${maxDurationMs} out of bounds` };
|
|
175
158
|
}
|
|
176
159
|
|
|
177
|
-
// onError.
|
|
178
160
|
const onError = typeof refresh.onError === "string" ? refresh.onError : "keep_last";
|
|
179
161
|
if (!ON_ERROR_VALUES.has(onError)) {
|
|
180
162
|
return { ok: false, code: "refresh_invalid_recipe", message: `onError must be keep_last/show_error/stop` };
|
|
181
163
|
}
|
|
182
164
|
|
|
183
|
-
// Templates.
|
|
184
165
|
const targets = refresh.targets && typeof refresh.targets === "object" ? refresh.targets : {};
|
|
185
166
|
if (typeof targets.body === "string") {
|
|
186
167
|
if (targets.body.length > GLASSES_UI_REFRESH_LIMITS.templateMaxChars) {
|
|
@@ -190,10 +171,7 @@ export function validateRefreshSpec(refresh, glassesUiLiveCfg) {
|
|
|
190
171
|
if (!v.ok) return v;
|
|
191
172
|
}
|
|
192
173
|
if (Array.isArray(targets.items)) {
|
|
193
|
-
|
|
194
|
-
// so a 100k-entry array would burn CPU substituting templates that are
|
|
195
|
-
// immediately discarded. Reject (rather than truncate) so the agent gets
|
|
196
|
-
// clear feedback that it over-supplied.
|
|
174
|
+
|
|
197
175
|
if (targets.items.length > GLASSES_UI_LIMITS.maxItems) {
|
|
198
176
|
return {
|
|
199
177
|
ok: false,
|
|
@@ -210,7 +188,7 @@ export function validateRefreshSpec(refresh, glassesUiLiveCfg) {
|
|
|
210
188
|
const v = validateTemplate(item);
|
|
211
189
|
if (!v.ok) return v;
|
|
212
190
|
} else if (item && typeof item === "object" && !Array.isArray(item)) {
|
|
213
|
-
|
|
191
|
+
|
|
214
192
|
if (typeof item.label !== "string") {
|
|
215
193
|
return { ok: false, code: "refresh_template_invalid", message: `targets.items[${i}].label must be a string template` };
|
|
216
194
|
}
|
|
@@ -257,6 +235,73 @@ const updateSchemaForToolParams = {
|
|
|
257
235
|
"\"push\": stack a new screen; the parent is retained and its cron pauses.",
|
|
258
236
|
};
|
|
259
237
|
|
|
238
|
+
const timeoutMsSchemaForToolParams = {
|
|
239
|
+
type: "integer",
|
|
240
|
+
minimum: 1000,
|
|
241
|
+
maximum: 600_000,
|
|
242
|
+
description:
|
|
243
|
+
"Optional one-shot interaction window for THIS call, in ms (default 90000, " +
|
|
244
|
+
"max 600000). Pass 300000-600000 when expecting the user to read or decide; " +
|
|
245
|
+
"omit for fire-and-forget. Never renewed automatically — re-render to listen again.",
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const staleAfterMsSchemaForToolParams = {
|
|
249
|
+
type: "integer",
|
|
250
|
+
minimum: 1000,
|
|
251
|
+
maximum: 86_400_000,
|
|
252
|
+
description:
|
|
253
|
+
"Optional per-render staleness window, in ms. A tap parked longer than this " +
|
|
254
|
+
"is still delivered but annotated stale:true — treat a stale actuating tap " +
|
|
255
|
+
"as a re-confirm prompt, never an action. Default absent (no annotation).",
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
export const GLASSES_UI_WINDOW_LIMITS = {
|
|
259
|
+
timeoutMsMin: timeoutMsSchemaForToolParams.minimum,
|
|
260
|
+
timeoutMsMax: timeoutMsSchemaForToolParams.maximum,
|
|
261
|
+
staleAfterMsMin: staleAfterMsSchemaForToolParams.minimum,
|
|
262
|
+
staleAfterMsMax: staleAfterMsSchemaForToolParams.maximum,
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
function validateWindowFields(spec) {
|
|
266
|
+
const out = { ok: true, timeoutMs: undefined, staleAfterMs: undefined };
|
|
267
|
+
if (spec && spec.timeoutMs !== undefined) {
|
|
268
|
+
const v = spec.timeoutMs;
|
|
269
|
+
if (
|
|
270
|
+
!Number.isFinite(v) ||
|
|
271
|
+
v < GLASSES_UI_WINDOW_LIMITS.timeoutMsMin ||
|
|
272
|
+
v > GLASSES_UI_WINDOW_LIMITS.timeoutMsMax
|
|
273
|
+
) {
|
|
274
|
+
return {
|
|
275
|
+
ok: false,
|
|
276
|
+
code: "timeout_ms_out_of_bounds",
|
|
277
|
+
message:
|
|
278
|
+
`timeoutMs ${JSON.stringify(v)} out of bounds ` +
|
|
279
|
+
`[${GLASSES_UI_WINDOW_LIMITS.timeoutMsMin}..${GLASSES_UI_WINDOW_LIMITS.timeoutMsMax}]; ` +
|
|
280
|
+
"pass 300000-600000 when expecting the user to read or decide, omit for fire-and-forget",
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
out.timeoutMs = Math.floor(v);
|
|
284
|
+
}
|
|
285
|
+
if (spec && spec.staleAfterMs !== undefined) {
|
|
286
|
+
const v = spec.staleAfterMs;
|
|
287
|
+
if (
|
|
288
|
+
!Number.isFinite(v) ||
|
|
289
|
+
v < GLASSES_UI_WINDOW_LIMITS.staleAfterMsMin ||
|
|
290
|
+
v > GLASSES_UI_WINDOW_LIMITS.staleAfterMsMax
|
|
291
|
+
) {
|
|
292
|
+
return {
|
|
293
|
+
ok: false,
|
|
294
|
+
code: "stale_after_ms_out_of_bounds",
|
|
295
|
+
message:
|
|
296
|
+
`staleAfterMs ${JSON.stringify(v)} out of bounds ` +
|
|
297
|
+
`[${GLASSES_UI_WINDOW_LIMITS.staleAfterMsMin}..${GLASSES_UI_WINDOW_LIMITS.staleAfterMsMax}]`,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
out.staleAfterMs = Math.floor(v);
|
|
301
|
+
}
|
|
302
|
+
return out;
|
|
303
|
+
}
|
|
304
|
+
|
|
260
305
|
const refreshSchemaForToolParams = {
|
|
261
306
|
type: "object",
|
|
262
307
|
description: "Optional periodic refresh policy; turns this surface into a live-updating one.",
|
|
@@ -321,21 +366,13 @@ const refreshSchemaForToolParams = {
|
|
|
321
366
|
},
|
|
322
367
|
};
|
|
323
368
|
|
|
324
|
-
// Top-level `properties` lists every field a valid spec may carry across all
|
|
325
|
-
// `kind`s. OpenClaw's Anthropic provider strips `oneOf` when building
|
|
326
|
-
// `input_schema` (it keeps only top-level `properties` + `required`), so
|
|
327
|
-
// without this flat union the model would see `properties: {}` and have to
|
|
328
|
-
// guess the shape from the tool description alone. Per-kind shape constraints
|
|
329
|
-
// are still enforced by `validateGlassesUiSpec` and the JSON Schema `oneOf`
|
|
330
|
-
// below for clients that honor it.
|
|
331
369
|
export const glassesUiParametersSchema = {
|
|
332
370
|
type: "object",
|
|
333
371
|
required: ["kind"],
|
|
334
372
|
properties: {
|
|
335
373
|
kind: {
|
|
336
374
|
type: "string",
|
|
337
|
-
|
|
338
|
-
// is one descriptor with no edit here (spec §Modularity).
|
|
375
|
+
|
|
339
376
|
enum: listKindStrings(),
|
|
340
377
|
description:
|
|
341
378
|
"Surface kind. Each kind expects a different items/body shape — see " +
|
|
@@ -364,29 +401,23 @@ export const glassesUiParametersSchema = {
|
|
|
364
401
|
"[{\"label\": \"Monday\", \"body\": \"Cloudy 14C, light rain pm\"}, " +
|
|
365
402
|
"{\"label\": \"Tuesday\", \"body\": \"Sunny 19C\"}]. Up to 20 items.",
|
|
366
403
|
},
|
|
367
|
-
|
|
368
|
-
// (see the block comment above), so a refresh entry that lives only in
|
|
369
|
-
// the oneOf branches is invisible on that path and the live-refresh
|
|
370
|
-
// feature becomes unreachable. The per-branch copies below stay for
|
|
371
|
-
// clients that honor oneOf.
|
|
404
|
+
|
|
372
405
|
refresh: refreshSchemaForToolParams,
|
|
373
|
-
|
|
374
|
-
// default replace). Top-level for the same Anthropic-strips-oneOf reason as
|
|
375
|
-
// refresh; mirrored into every oneOf branch below.
|
|
406
|
+
|
|
376
407
|
update: updateSchemaForToolParams,
|
|
408
|
+
|
|
409
|
+
timeoutMs: timeoutMsSchemaForToolParams,
|
|
410
|
+
staleAfterMs: staleAfterMsSchemaForToolParams,
|
|
377
411
|
},
|
|
378
|
-
|
|
379
|
-
// enum order). Each branch's `refresh` slot — declared `undefined` in the
|
|
380
|
-
// descriptor's schemaBranch — is filled here with the shared refresh schema
|
|
381
|
-
// so the per-branch shape matches today's hand-written one (the tool owns
|
|
382
|
-
// refreshSchemaForToolParams; the descriptor only declares the slot). `update`
|
|
383
|
-
// is mirrored into every branch the same way.
|
|
412
|
+
|
|
384
413
|
oneOf: buildOneOfBranches().map((branch) => ({
|
|
385
414
|
...branch,
|
|
386
415
|
properties: {
|
|
387
416
|
...branch.properties,
|
|
388
417
|
refresh: refreshSchemaForToolParams,
|
|
389
418
|
update: updateSchemaForToolParams,
|
|
419
|
+
timeoutMs: timeoutMsSchemaForToolParams,
|
|
420
|
+
staleAfterMs: staleAfterMsSchemaForToolParams,
|
|
390
421
|
},
|
|
391
422
|
})),
|
|
392
423
|
};
|
|
@@ -396,10 +427,7 @@ export function validateGlassesUiSpec(input) {
|
|
|
396
427
|
return { ok: false, code: "invalid_kind", message: "spec must be an object" };
|
|
397
428
|
}
|
|
398
429
|
const obj = input;
|
|
399
|
-
|
|
400
|
-
// kind" reproduces today's invalid_kind. Per-kind validation (incl. the
|
|
401
|
-
// shared title check, which each descriptor runs first) lives in the
|
|
402
|
-
// descriptor's validateSpec, so behavior is identical to the old switch.
|
|
430
|
+
|
|
403
431
|
const descriptor = getKindDescriptor(obj.kind);
|
|
404
432
|
if (!descriptor) {
|
|
405
433
|
return {
|
|
@@ -415,9 +443,6 @@ export function validateGlassesUiSpec(input) {
|
|
|
415
443
|
|
|
416
444
|
import { randomUUID } from "node:crypto";
|
|
417
445
|
|
|
418
|
-
// Mirror of even-ai-model-hook's session classifier. Inlined to avoid a
|
|
419
|
-
// cross-file CJS dependency (even-ai-model-hook is ESM-only in dist).
|
|
420
|
-
// Keep these constants in sync with even-ai-model-hook.ts.
|
|
421
446
|
const EVEN_AI_THROWAWAY_SESSION_PREFIX = "ocuclaw:even-ai:";
|
|
422
447
|
const EVEN_AI_DEFAULT_DEDICATED_SESSION_KEY = "ocuclaw:even-ai";
|
|
423
448
|
|
|
@@ -439,36 +464,59 @@ export function isEvenAiAgentSession(sessionKey, dedicatedSessionKey) {
|
|
|
439
464
|
return !!normalizedDedicated && normalized === normalizedDedicated;
|
|
440
465
|
}
|
|
441
466
|
|
|
442
|
-
// Default timeout when no per-call or per-handler timeout is provided.
|
|
443
|
-
// Bounds the orphan-tool_use corruption window (see Family 2 in the OpenClaw
|
|
444
|
-
// task-runs zombies / orphan tool_use memory): if a render_glasses_ui call
|
|
445
|
-
// stays unresolved this long, the plugin returns { result: "timeout" } so
|
|
446
|
-
// the agent's runtime persists a matching tool_result and the next turn's
|
|
447
|
-
// session replay won't 400 on an unmatched tool_use block.
|
|
448
467
|
export const DEFAULT_RENDER_GLASSES_UI_TIMEOUT_MS = 30 * 60 * 1000;
|
|
449
468
|
|
|
469
|
+
export const GATEWAY_DYNAMIC_TOOL_DEFAULT_TIMEOUT_MS = 90_000;
|
|
470
|
+
|
|
471
|
+
const WINDOW_EXPIRED_HINT =
|
|
472
|
+
"The listen window closed; the surface is still live on glass and keeps " +
|
|
473
|
+
"updating. New taps park - re-render this surface (e.g. update:\"patch\") " +
|
|
474
|
+
"to collect them in this run, or end your turn and they ride the next one.";
|
|
475
|
+
|
|
450
476
|
export function createGlassesUiToolHandler(deps) {
|
|
451
|
-
|
|
452
|
-
// which it delegates pause/resume/stop to). There is never a separate pending
|
|
453
|
-
// map alongside it (spec §Core model — one store).
|
|
454
|
-
// Short-lived per-surface capture used to read the cron's merged outcome
|
|
455
|
-
// (ticks{}, lastBody, lastItems, ...extra) when runDynamicUi stops the
|
|
456
|
-
// cron after a user dismissal (pending already resolved with a user-only
|
|
457
|
-
// outcome that lacks ticks). Cleared immediately after stop returns.
|
|
477
|
+
|
|
458
478
|
const capturedCronOutcome = new Map();
|
|
479
|
+
|
|
480
|
+
const TITLE_BUDGET_PX = 540;
|
|
481
|
+
|
|
482
|
+
function clipBreadcrumb(s, reserveText = "") {
|
|
483
|
+
if (typeof s !== "string" || s.length === 0) return s;
|
|
484
|
+
const charBudget = Math.floor((TITLE_BUDGET_PX - reserveText.length * 20) / 20);
|
|
485
|
+
if (charBudget <= 0) return "";
|
|
486
|
+
if (s.length <= charBudget) return s;
|
|
487
|
+
const segments = s.split(" › ");
|
|
488
|
+
|
|
489
|
+
while (segments.length > 1 && segments.join(" › ").length > charBudget) {
|
|
490
|
+
segments.shift();
|
|
491
|
+
}
|
|
492
|
+
const joined = segments.join(" › ");
|
|
493
|
+
if (joined.length <= charBudget) return joined;
|
|
494
|
+
|
|
495
|
+
return joined.slice(0, charBudget);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function emitMarker(sessionKey, surfaceId) {
|
|
499
|
+
if (!surfaceId) return;
|
|
500
|
+
if (surfaceStore.topSurfaceId(sessionKey) !== surfaceId) return;
|
|
501
|
+
const marker = surfaceStore.markerFor(surfaceId);
|
|
502
|
+
if (!marker) return;
|
|
503
|
+
paintFloor.enqueue({ surfaceId, sessionKey, patch: { marker } });
|
|
504
|
+
}
|
|
505
|
+
|
|
459
506
|
const newSurfaceId =
|
|
460
507
|
deps && typeof deps.newSurfaceId === "function"
|
|
461
508
|
? deps.newSurfaceId
|
|
462
509
|
: () => `ui-${randomUUID().slice(0, 8)}`;
|
|
463
510
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
511
|
+
const storeId =
|
|
512
|
+
typeof deps.storeId === "string" && deps.storeId
|
|
513
|
+
? deps.storeId
|
|
514
|
+
: `st-${Math.random().toString(36).slice(2, 8)}`;
|
|
515
|
+
const baseEmitLifecycle =
|
|
468
516
|
typeof deps.emitLifecycle === "function" ? deps.emitLifecycle : () => {};
|
|
517
|
+
const emitLifecycle = (event, severity, data) =>
|
|
518
|
+
baseEmitLifecycle(event, severity, { storeId, ...(data || {}) });
|
|
469
519
|
|
|
470
|
-
// Resolve the handler-wide default timeout. Per-call timeouts may still
|
|
471
|
-
// override this via params.timeoutMs.
|
|
472
520
|
function resolveHandlerTimeoutMs() {
|
|
473
521
|
if (!deps || deps.timeoutMs === undefined) return DEFAULT_RENDER_GLASSES_UI_TIMEOUT_MS;
|
|
474
522
|
if (typeof deps.timeoutMs === "function") {
|
|
@@ -478,19 +526,12 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
478
526
|
return Number.isFinite(deps.timeoutMs) ? deps.timeoutMs : DEFAULT_RENDER_GLASSES_UI_TIMEOUT_MS;
|
|
479
527
|
}
|
|
480
528
|
|
|
481
|
-
// The single plugin->glass send chokepoint (Spike D). EVERY send — the
|
|
482
|
-
// initial render frame and every cron surface_update — routes through this
|
|
483
|
-
// trailing-edge coalescer so bursts collapse to ≤1 frame per 150ms and shed
|
|
484
|
-
// under BLE backpressure. A { __render } sentinel is a full container
|
|
485
|
-
// rebuild; a plain field patch is a surface_update.
|
|
486
529
|
const paintFloor = createPaintFloorCoalescer({
|
|
487
|
-
|
|
488
|
-
// a leading-edge send) so synchronous send-ordering assertions hold;
|
|
489
|
-
// production uses the 150ms Spike-D cadence.
|
|
530
|
+
|
|
490
531
|
paintFloorMs: Number.isFinite(deps.paintFloorMs) ? deps.paintFloorMs : DEFAULT_PAINT_FLOOR_MS,
|
|
491
532
|
send: ({ surfaceId, sessionKey, patch }) => {
|
|
492
533
|
if (patch && patch.__render) {
|
|
493
|
-
deps.relay.sendGlassesUiRender({ sessionKey, surfaceId, depth: patch.__depth, spec: patch.__spec });
|
|
534
|
+
deps.relay.sendGlassesUiRender({ sessionKey, surfaceId, depth: patch.__depth, spec: patch.__spec, marker: patch.__marker });
|
|
494
535
|
} else {
|
|
495
536
|
deps.relay.sendGlassesUiSurfaceUpdate({ sessionKey, surfaceId, patch });
|
|
496
537
|
}
|
|
@@ -502,7 +543,11 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
502
543
|
emitLifecycle,
|
|
503
544
|
monotonicNowMs: () => performance.now(),
|
|
504
545
|
executeRecipe: async (recipe, ctx) => {
|
|
505
|
-
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
|
+
}
|
|
506
551
|
if (recipe.kind === "system-stats") return executeSystemStatsRecipe(recipe);
|
|
507
552
|
if (recipe.kind === "llm") return executeLlmRecipe(recipe, ctx);
|
|
508
553
|
return { error: `unknown recipe kind: ${recipe.kind}` };
|
|
@@ -530,18 +575,14 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
530
575
|
},
|
|
531
576
|
});
|
|
532
577
|
|
|
533
|
-
// The SINGLE live surface store (spec §Core model). Constructed after the
|
|
534
|
-
// cron engine so it can delegate pause/resume/stop; id minting reuses the
|
|
535
|
-
// handler's minter. Replaces the Phase-1 pending map — never a second store.
|
|
536
578
|
const surfaceStore = createSurfaceStore({
|
|
579
|
+
storeId,
|
|
580
|
+
emitLifecycle,
|
|
581
|
+
|
|
582
|
+
now: typeof deps.now === "function" ? deps.now : undefined,
|
|
537
583
|
pauseCron: (id) => cronEngine.pause(id),
|
|
538
584
|
resumeCron: (id) => cronEngine.resume(id),
|
|
539
|
-
|
|
540
|
-
// exit, drain). Dispose the paint-floor coalescer entry too so an armed
|
|
541
|
-
// trailing flush can't paint a stale surface_update onto the now-visible
|
|
542
|
-
// parent/chat after a back/pop, and the per-surface coalescer state doesn't
|
|
543
|
-
// leak across a long push/replace session. (pauseCron on push does NOT
|
|
544
|
-
// dispose — the parent resumes.)
|
|
585
|
+
|
|
545
586
|
stopCron: (id, opts) => {
|
|
546
587
|
cronEngine.stop(id, { result: "preempted" }, opts);
|
|
547
588
|
paintFloor.dispose(id);
|
|
@@ -549,28 +590,65 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
549
590
|
mintSurfaceId: newSurfaceId,
|
|
550
591
|
});
|
|
551
592
|
|
|
593
|
+
const wakeController = createGlassesWakeController({
|
|
594
|
+
dispatchWake: typeof deps.dispatchWake === "function" ? deps.dispatchWake : null,
|
|
595
|
+
isAgentTurnBusy: typeof deps.isAgentTurnBusy === "function" ? deps.isAgentTurnBusy : () => false,
|
|
596
|
+
emitLifecycle,
|
|
597
|
+
now: typeof deps.now === "function" ? deps.now : Date.now,
|
|
598
|
+
wakeCooldownMs: deps.wakeCooldownMs,
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
const voicemail = createGlassesVoicemail({
|
|
602
|
+
now: typeof deps.now === "function" ? deps.now : Date.now,
|
|
603
|
+
ttlMs: deps.voicemailTtlMs,
|
|
604
|
+
drainWakeOutbox: () => wakeController.drainWakeOutbox(),
|
|
605
|
+
drainDeadLetter: (sessionKey) => surfaceStore.drainDeadLetter(sessionKey),
|
|
606
|
+
emitLifecycle,
|
|
607
|
+
});
|
|
608
|
+
|
|
552
609
|
deps.relay.onGlassesUiResult((msg) => {
|
|
553
610
|
if (!msg || typeof msg.surfaceId !== "string" || !msg.outcome) return;
|
|
554
|
-
|
|
611
|
+
|
|
612
|
+
const outcome = {
|
|
613
|
+
...msg.outcome,
|
|
614
|
+
origin: typeof msg.outcome.origin === "string" ? msg.outcome.origin : "gesture",
|
|
615
|
+
actor: typeof msg.outcome.actor === "string" ? msg.outcome.actor : "wearer",
|
|
616
|
+
};
|
|
617
|
+
const terminal = isTerminalOutcome(outcome);
|
|
555
618
|
if (terminal && cronEngine.isActive(msg.surfaceId)) {
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
// queue the terminal so the next render discards-for-exit).
|
|
559
|
-
let merged = msg.outcome;
|
|
619
|
+
|
|
620
|
+
let merged = outcome;
|
|
560
621
|
capturedCronOutcome.set(msg.surfaceId, (cronOutcome) => { merged = cronOutcome; });
|
|
561
|
-
cronEngine.stop(msg.surfaceId,
|
|
622
|
+
cronEngine.stop(msg.surfaceId, outcome);
|
|
562
623
|
capturedCronOutcome.delete(msg.surfaceId);
|
|
563
624
|
if (!surfaceStore.resolve(msg.surfaceId, merged)) {
|
|
564
625
|
surfaceStore.queueEvent(msg.surfaceId, merged);
|
|
565
626
|
}
|
|
566
627
|
return;
|
|
567
628
|
}
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
629
|
+
|
|
630
|
+
const sessionKey = surfaceStore.sessionForSurface(msg.surfaceId);
|
|
631
|
+
if (surfaceStore.resolve(msg.surfaceId, outcome)) {
|
|
632
|
+
|
|
633
|
+
emitMarker(sessionKey, msg.surfaceId);
|
|
634
|
+
} else {
|
|
635
|
+
const receipt = surfaceStore.queueEvent(msg.surfaceId, outcome, {
|
|
636
|
+
origin: outcome.origin,
|
|
637
|
+
actor: outcome.actor,
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
if (receipt && !receipt.kind) {
|
|
641
|
+
wakeController.onParkedGesture({
|
|
642
|
+
sessionKey: surfaceStore.sessionForSurface(msg.surfaceId),
|
|
643
|
+
surfaceUuid: receipt.surfaceUuid,
|
|
644
|
+
eventId: receipt.eventId,
|
|
645
|
+
result: outcome.result,
|
|
646
|
+
itemIndex: outcome.selected_index,
|
|
647
|
+
origin: outcome.origin,
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
emitMarker(sessionKey, msg.surfaceId);
|
|
574
652
|
}
|
|
575
653
|
});
|
|
576
654
|
|
|
@@ -586,10 +664,12 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
586
664
|
err.code = validation.code;
|
|
587
665
|
throw err;
|
|
588
666
|
}
|
|
589
|
-
|
|
667
|
+
|
|
668
|
+
const sessionKey = normalizeGlassesSessionKey(
|
|
590
669
|
typeof params.sessionKey === "string" && params.sessionKey.trim()
|
|
591
670
|
? params.sessionKey.trim()
|
|
592
|
-
: "main"
|
|
671
|
+
: "main",
|
|
672
|
+
);
|
|
593
673
|
if (typeof deps.isSessionConnected === "function" && !deps.isSessionConnected(sessionKey)) {
|
|
594
674
|
const err = new Error(
|
|
595
675
|
"glasses_not_connected: no Even glasses client connected for this session",
|
|
@@ -610,24 +690,24 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
610
690
|
refreshValidated = v.refresh;
|
|
611
691
|
}
|
|
612
692
|
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
693
|
+
const windowFields = validateWindowFields(params.spec);
|
|
694
|
+
if (!windowFields.ok) {
|
|
695
|
+
emitLifecycle("render_rejected", "warn", {
|
|
696
|
+
surfaceId: null,
|
|
697
|
+
code: windowFields.code,
|
|
698
|
+
reason: windowFields.message,
|
|
699
|
+
});
|
|
700
|
+
const err = new Error(`${windowFields.code}: ${windowFields.message}`);
|
|
701
|
+
err.code = windowFields.code;
|
|
702
|
+
throw err;
|
|
703
|
+
}
|
|
704
|
+
|
|
619
705
|
const depth = Number.isFinite(params.depth) ? Math.max(1, Math.floor(params.depth)) : 1;
|
|
620
706
|
const update =
|
|
621
707
|
params.spec && (params.spec.update === "patch" || params.spec.update === "push")
|
|
622
708
|
? params.spec.update
|
|
623
709
|
: "replace";
|
|
624
|
-
|
|
625
|
-
// session stack still holding PUSHED children at that moment is orphan
|
|
626
|
-
// residue from an earlier run (e.g. a client that bailed to chat without
|
|
627
|
-
// popping). Reap it before registering so a stale child can't swallow this
|
|
628
|
-
// render's events or forward a stale latched exit. A SINGLE root entry is
|
|
629
|
-
// NOT stale — that's the designed patch/replace re-attach path
|
|
630
|
-
// (visible_awaiting_agent), which must keep its latch/queue semantics.
|
|
710
|
+
|
|
631
711
|
if (depth <= 1 && surfaceStore.stackDepth(sessionKey) > 1) {
|
|
632
712
|
const stackDepthBefore = surfaceStore.stackDepth(sessionKey);
|
|
633
713
|
const reapedPending = reapSession(sessionKey, { result: "preempted" });
|
|
@@ -637,62 +717,54 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
637
717
|
reapedPending,
|
|
638
718
|
});
|
|
639
719
|
}
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
// (re-attach in place), push mints a child + pauses the parent cron, the
|
|
643
|
-
// first render mints a root. This is the single place a surfaceId is bound.
|
|
720
|
+
|
|
721
|
+
const stackDepthBeforeAttach = surfaceStore.stackDepth(sessionKey);
|
|
644
722
|
const applied = surfaceStore.applyRender(sessionKey, {
|
|
645
723
|
update,
|
|
646
724
|
kind: validation.spec.kind,
|
|
647
725
|
});
|
|
648
726
|
const surfaceId = applied.surfaceId;
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
727
|
+
|
|
728
|
+
emitLifecycle("surface_attach", "debug", {
|
|
729
|
+
surfaceId,
|
|
730
|
+
sessionKey,
|
|
731
|
+
mode: applied.mode,
|
|
732
|
+
requestedUpdate: update,
|
|
733
|
+
stackDepthBefore: stackDepthBeforeAttach,
|
|
734
|
+
});
|
|
735
|
+
const promise = surfaceStore.register(sessionKey, surfaceId, {
|
|
736
|
+
kind: validation.spec.kind,
|
|
737
|
+
staleAfterMs: windowFields.staleAfterMs,
|
|
738
|
+
title: typeof validation.spec.title === "string" ? validation.spec.title : undefined,
|
|
739
|
+
});
|
|
740
|
+
|
|
659
741
|
if (applied.mode === "patch" || applied.mode === "replace") {
|
|
660
742
|
const reattach = surfaceStore.onReattached(surfaceId);
|
|
661
743
|
if (reattach === "discarded_for_exit") {
|
|
662
|
-
|
|
663
|
-
// latched terminal outcome (so `promise` is settled). Tear down instead
|
|
664
|
-
// of painting the discarded render.
|
|
744
|
+
|
|
665
745
|
if (cronEngine.isActive(surfaceId)) cronEngine.stop(surfaceId, { result: "dismissed" });
|
|
666
746
|
surfaceStore.exit(sessionKey);
|
|
667
747
|
return promise;
|
|
668
748
|
}
|
|
749
|
+
if (reattach === "reattached_stale_latch_dropped") {
|
|
750
|
+
|
|
751
|
+
emitLifecycle("stale_cron_summary_dropped", "debug", { surfaceId, sessionKey });
|
|
752
|
+
}
|
|
669
753
|
}
|
|
670
754
|
|
|
671
|
-
// The wire depth is the TRUE stack depth (entry count) after applyRender:
|
|
672
|
-
// root=1, push=parent+1, replace/patch=unchanged. The client keys its
|
|
673
|
-
// clear-vs-append-vs-swap decision and Back classification on this value,
|
|
674
|
-
// and handleNavEvent's pop loop compares it against the same entry counts.
|
|
675
755
|
const wireDepth = Math.max(1, surfaceStore.stackDepth(sessionKey));
|
|
676
756
|
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
// single send chokepoint (a render supersedes any queued field patch for
|
|
680
|
-
// this surface; see glasses-ui-paint-floor mergePatch).
|
|
757
|
+
const breadcrumb = surfaceStore.breadcrumbFor(sessionKey);
|
|
758
|
+
if (breadcrumb) validation.spec.title = clipBreadcrumb(breadcrumb);
|
|
681
759
|
paintFloor.enqueue({
|
|
682
760
|
surfaceId,
|
|
683
761
|
sessionKey,
|
|
684
|
-
|
|
762
|
+
|
|
763
|
+
patch: { __render: true, __depth: wireDepth, __spec: validation.spec, __marker: surfaceStore.markerFor(surfaceId) },
|
|
685
764
|
});
|
|
686
765
|
|
|
687
|
-
// Live-refresh path: kick off the cron in parallel. A `patch` onto an
|
|
688
|
-
// already-ticking surface leaves its cron alone (spec: "cron keeps
|
|
689
|
-
// ticking"); every other move (replace/push/root) starts a cron for the new
|
|
690
|
-
// content when this render carries a refresh.
|
|
691
766
|
if (refreshValidated && !(update === "patch" && cronEngine.isActive(surfaceId))) {
|
|
692
|
-
|
|
693
|
-
// and fail the smoke test. For non-LLM recipes this is a no-op. All llm
|
|
694
|
-
// backends are HTTP API backends that resolve a key via host modelAuth;
|
|
695
|
-
// a missing key degrades to a graceful recipe_failed on tick 1.
|
|
767
|
+
|
|
696
768
|
if (refreshValidated.recipe.kind === "llm" && typeof deps.prewarmLlmApiKey === "function") {
|
|
697
769
|
const cfg = deps.getGlassesUiLiveConfig ? deps.getGlassesUiLiveConfig() : {};
|
|
698
770
|
const agentModel =
|
|
@@ -706,8 +778,7 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
706
778
|
try {
|
|
707
779
|
await deps.prewarmLlmApiKey(prewarmModel);
|
|
708
780
|
} catch (_) {
|
|
709
|
-
|
|
710
|
-
// recipe_failed with a useful error from the backend.
|
|
781
|
+
|
|
711
782
|
}
|
|
712
783
|
}
|
|
713
784
|
}
|
|
@@ -726,77 +797,116 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
726
797
|
)
|
|
727
798
|
: undefined,
|
|
728
799
|
onResolve: (cronOutcome) => {
|
|
729
|
-
|
|
730
|
-
// result. capturedCronOutcome lets the terminal-via-user path read
|
|
731
|
-
// the merged outcome. Only the cron's OWN terminal outcomes resolve
|
|
732
|
-
// the pending call here (recipe_failed / timeout / external stop);
|
|
733
|
-
// user-action results resolve via onGlassesUiResult above.
|
|
800
|
+
|
|
734
801
|
const capture = capturedCronOutcome.get(surfaceId);
|
|
735
802
|
if (capture) capture(cronOutcome);
|
|
736
803
|
if (isTerminalOutcome(cronOutcome)) {
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
surfaceStore.queueEvent(surfaceId, cronOutcome);
|
|
804
|
+
|
|
805
|
+
const stamped = {
|
|
806
|
+
...cronOutcome,
|
|
807
|
+
origin: typeof cronOutcome.origin === "string" ? cronOutcome.origin : "system",
|
|
808
|
+
};
|
|
809
|
+
|
|
810
|
+
if (!surfaceStore.resolve(surfaceId, stamped)) {
|
|
811
|
+
surfaceStore.queueEvent(surfaceId, stamped);
|
|
746
812
|
}
|
|
747
813
|
}
|
|
748
814
|
},
|
|
749
815
|
});
|
|
750
816
|
}
|
|
751
817
|
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
818
|
+
const setTimeoutFn =
|
|
819
|
+
deps && typeof deps.setTimeout === "function" ? deps.setTimeout : setTimeout;
|
|
820
|
+
const clearTimeoutFn =
|
|
821
|
+
deps && typeof deps.clearTimeout === "function" ? deps.clearTimeout : clearTimeout;
|
|
822
|
+
const cleanups = [];
|
|
823
|
+
|
|
824
|
+
const effectiveWindowMs =
|
|
825
|
+
windowFields.timeoutMs !== undefined
|
|
826
|
+
? windowFields.timeoutMs
|
|
827
|
+
: GATEWAY_DYNAMIC_TOOL_DEFAULT_TIMEOUT_MS;
|
|
828
|
+
|
|
829
|
+
const wrapUpMarginMs = Math.min(5000, Math.max(2000, Math.floor(effectiveWindowMs * 0.05)));
|
|
830
|
+
const wrapUpDelayMs = Math.max(effectiveWindowMs - wrapUpMarginMs, Math.floor(effectiveWindowMs / 2));
|
|
831
|
+
const windowExpiredOutcome = (extra) =>
|
|
832
|
+
Object.assign(
|
|
833
|
+
{
|
|
834
|
+
result: "window_expired",
|
|
835
|
+
surface_still_live: true,
|
|
836
|
+
window_ms: effectiveWindowMs,
|
|
837
|
+
origin: "system",
|
|
838
|
+
hint: WINDOW_EXPIRED_HINT,
|
|
839
|
+
},
|
|
840
|
+
extra,
|
|
841
|
+
);
|
|
842
|
+
const wrapUpHandle = setTimeoutFn(() => {
|
|
843
|
+
if (surfaceStore.resolve(surfaceId, windowExpiredOutcome())) {
|
|
844
|
+
emitLifecycle("window_expired", "debug", {
|
|
845
|
+
surfaceId,
|
|
846
|
+
sessionKey,
|
|
847
|
+
windowMs: effectiveWindowMs,
|
|
848
|
+
via: "wrap_up_timer",
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
emitMarker(sessionKey, surfaceId);
|
|
852
|
+
}
|
|
853
|
+
}, wrapUpDelayMs);
|
|
854
|
+
cleanups.push(() => clearTimeoutFn(wrapUpHandle));
|
|
855
|
+
|
|
856
|
+
const signal = params.signal;
|
|
857
|
+
if (signal && typeof signal.addEventListener === "function") {
|
|
858
|
+
const onAbort = () => {
|
|
859
|
+
if (surfaceStore.resolve(surfaceId, windowExpiredOutcome({ aborted: true }))) {
|
|
860
|
+
emitLifecycle("window_expired", "debug", {
|
|
861
|
+
surfaceId,
|
|
862
|
+
sessionKey,
|
|
863
|
+
windowMs: effectiveWindowMs,
|
|
864
|
+
via: "abort_signal",
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
};
|
|
868
|
+
if (signal.aborted) {
|
|
869
|
+
onAbort();
|
|
870
|
+
} else {
|
|
871
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
872
|
+
cleanups.push(() => {
|
|
873
|
+
if (typeof signal.removeEventListener === "function") {
|
|
874
|
+
signal.removeEventListener("abort", onAbort);
|
|
875
|
+
}
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
755
880
|
const timeoutMs = Number.isFinite(params.timeoutMs)
|
|
756
881
|
? params.timeoutMs
|
|
757
882
|
: resolveHandlerTimeoutMs();
|
|
758
883
|
if (!refreshValidated && Number.isFinite(timeoutMs) && timeoutMs > 0) {
|
|
759
|
-
const setTimeoutFn =
|
|
760
|
-
deps && typeof deps.setTimeout === "function" ? deps.setTimeout : setTimeout;
|
|
761
|
-
const clearTimeoutFn =
|
|
762
|
-
deps && typeof deps.clearTimeout === "function" ? deps.clearTimeout : clearTimeout;
|
|
763
884
|
const handle = setTimeoutFn(() => {
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
// terminal, so it also moves the surface to `exiting`.
|
|
767
|
-
surfaceStore.resolve(surfaceId, { result: "timeout", timeout_ms: timeoutMs });
|
|
885
|
+
|
|
886
|
+
surfaceStore.resolve(surfaceId, { result: "timeout", timeout_ms: timeoutMs, origin: "system" });
|
|
768
887
|
}, timeoutMs);
|
|
769
|
-
|
|
770
|
-
clearTimeoutFn(handle);
|
|
771
|
-
return outcome;
|
|
772
|
-
});
|
|
888
|
+
cleanups.push(() => clearTimeoutFn(handle));
|
|
773
889
|
}
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
890
|
+
|
|
891
|
+
return promise.then((outcome) => {
|
|
892
|
+
for (const fn of cleanups) {
|
|
893
|
+
try { fn(); } catch (_) { }
|
|
894
|
+
}
|
|
895
|
+
return outcome;
|
|
896
|
+
});
|
|
780
897
|
}
|
|
781
898
|
|
|
782
|
-
|
|
783
|
-
// pop (depth down) on the client nav-event. The surfaceIds + pause/resume
|
|
784
|
-
// live in surfaceStore (the single source of truth) — there is NO second
|
|
785
|
-
// stack here. On push the parent cron was already paused by applyRender
|
|
786
|
-
// during the agent's push render, so push is idempotent here; on pop we drive
|
|
787
|
-
// surfaceStore.popBack, which stops the child cron and staleness-resumes the
|
|
788
|
-
// parent (Spike B: the plugin owns the resume target).
|
|
789
|
-
const navDepthBySession = new Map(); // sessionKey -> lastSeenDepth
|
|
899
|
+
const navDepthBySession = new Map();
|
|
790
900
|
|
|
791
|
-
function handleNavEvent(
|
|
901
|
+
function handleNavEvent(rawSessionKey, ev) {
|
|
902
|
+
const sessionKey = normalizeGlassesSessionKey(rawSessionKey);
|
|
792
903
|
const newDepth = Number.isFinite(ev.depth) ? Math.max(1, Math.floor(ev.depth)) : 1;
|
|
793
904
|
const lastDepth = navDepthBySession.get(sessionKey) || surfaceStore.stackDepth(sessionKey) || 1;
|
|
794
905
|
const storeDepthBefore = surfaceStore.stackDepth(sessionKey);
|
|
795
906
|
let popCount = 0;
|
|
796
907
|
let resumedParent = null;
|
|
797
908
|
if (newDepth < lastDepth) {
|
|
798
|
-
|
|
799
|
-
// depth — each popBack stops the child cron + resumes the new parent.
|
|
909
|
+
|
|
800
910
|
let guard = 0;
|
|
801
911
|
while (surfaceStore.stackDepth(sessionKey) > newDepth && guard < 64) {
|
|
802
912
|
resumedParent = surfaceStore.popBack(sessionKey);
|
|
@@ -809,18 +919,14 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
809
919
|
storeDepthBefore > 1 &&
|
|
810
920
|
surfaceStore.topSurfaceId(sessionKey) === ev.surfaceId
|
|
811
921
|
) {
|
|
812
|
-
|
|
813
|
-
// backed OUT OF — the store top. If the depth comparison said no-op
|
|
814
|
-
// (drifted ordinals from an older client, or any depth desync) but the
|
|
815
|
-
// reported surface IS the top with a parent beneath, pop exactly one
|
|
816
|
-
// level. Push events carry the PARENT surfaceId — never the top after a
|
|
817
|
-
// push — so this cannot misfire on a push report; and a duplicate Back
|
|
818
|
-
// delivery is idempotent (after the pop the top no longer matches).
|
|
922
|
+
|
|
819
923
|
resumedParent = surfaceStore.popBack(sessionKey);
|
|
820
924
|
popCount += 1;
|
|
821
925
|
}
|
|
822
|
-
|
|
823
|
-
|
|
926
|
+
|
|
927
|
+
if (popCount > 0 && resumedParent) {
|
|
928
|
+
emitMarker(sessionKey, resumedParent);
|
|
929
|
+
}
|
|
824
930
|
emitLifecycle("nav_reconcile", "debug", {
|
|
825
931
|
sessionKey,
|
|
826
932
|
evSurfaceId: ev.surfaceId,
|
|
@@ -834,26 +940,26 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
834
940
|
navDepthBySession.set(sessionKey, newDepth);
|
|
835
941
|
}
|
|
836
942
|
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
// `outcome`), THEN clear the per-session stack. exit() deletes entries
|
|
840
|
-
// without resolving, so it must NOT run before the drain or the pending
|
|
841
|
-
// promises would hang. Shared by the public drainSession (agent_end /
|
|
842
|
-
// disconnect) and the stale-stack reap in runDynamicUi (B3).
|
|
843
|
-
function reapSession(sessionKey, outcome) {
|
|
943
|
+
function reapSession(rawSessionKey, outcome) {
|
|
944
|
+
const sessionKey = normalizeGlassesSessionKey(rawSessionKey);
|
|
844
945
|
cronEngine.stopAllForSession(sessionKey, outcome);
|
|
845
946
|
const reaped = surfaceStore.drainSession(sessionKey, outcome);
|
|
846
|
-
surfaceStore.exit(sessionKey);
|
|
847
|
-
navDepthBySession.delete(sessionKey);
|
|
947
|
+
surfaceStore.exit(sessionKey);
|
|
948
|
+
navDepthBySession.delete(sessionKey);
|
|
848
949
|
return reaped;
|
|
849
950
|
}
|
|
850
951
|
|
|
851
952
|
return {
|
|
953
|
+
storeId,
|
|
852
954
|
runDynamicUi,
|
|
853
955
|
handleNavEvent,
|
|
854
956
|
drainSession(sessionKey, outcome) {
|
|
855
957
|
return reapSession(sessionKey, outcome);
|
|
856
958
|
},
|
|
959
|
+
|
|
960
|
+
settleSession(sessionKey, outcome) {
|
|
961
|
+
return surfaceStore.settlePending(sessionKey, outcome);
|
|
962
|
+
},
|
|
857
963
|
drainAll(outcome) {
|
|
858
964
|
cronEngine.stopAll(outcome);
|
|
859
965
|
const reaped = surfaceStore.drainAll(outcome);
|
|
@@ -863,6 +969,17 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
863
969
|
navDepthBySession.clear();
|
|
864
970
|
return reaped;
|
|
865
971
|
},
|
|
972
|
+
|
|
973
|
+
peekWakeOutbox() {
|
|
974
|
+
return wakeController.peekWakeOutbox();
|
|
975
|
+
},
|
|
976
|
+
drainWakeOutbox() {
|
|
977
|
+
return wakeController.drainWakeOutbox();
|
|
978
|
+
},
|
|
979
|
+
|
|
980
|
+
buildVoicemailInjection(sessionKey) {
|
|
981
|
+
return voicemail.buildInjection(sessionKey);
|
|
982
|
+
},
|
|
866
983
|
isCronActive(surfaceId) {
|
|
867
984
|
return cronEngine.isActive(surfaceId);
|
|
868
985
|
},
|
|
@@ -873,6 +990,12 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
873
990
|
surfaceStackDepth(sessionKey) {
|
|
874
991
|
return surfaceStore.stackDepth(sessionKey);
|
|
875
992
|
},
|
|
993
|
+
|
|
994
|
+
parkMarkerOnAgentEnd(sessionKey) {
|
|
995
|
+
surfaceStore.clearAwaitingResponse(sessionKey);
|
|
996
|
+
const top = surfaceStore.topSurfaceId(sessionKey);
|
|
997
|
+
if (top) emitMarker(sessionKey, top);
|
|
998
|
+
},
|
|
876
999
|
sessionForSurface(surfaceId) {
|
|
877
1000
|
return surfaceStore.sessionForSurface(surfaceId);
|
|
878
1001
|
},
|
|
@@ -888,8 +1011,8 @@ export const GLASSES_UI_TOOL_DESCRIPTION = [
|
|
|
888
1011
|
" short detail body (≤200 chars) shown as the user",
|
|
889
1012
|
" scrolls; use when options need a 1-2 sentence",
|
|
890
1013
|
" compare-before-choosing detail.",
|
|
891
|
-
"The call
|
|
892
|
-
"
|
|
1014
|
+
"The call carries one one-shot listen window. result is one of: selected,",
|
|
1015
|
+
"back, dismissed, window_expired, timeout, recipe_failed, glasses_disconnected.",
|
|
893
1016
|
"",
|
|
894
1017
|
"Optional params:",
|
|
895
1018
|
" refresh — make the surface self-update on a timer (e.g. live host stats via",
|
|
@@ -899,6 +1022,10 @@ export const GLASSES_UI_TOOL_DESCRIPTION = [
|
|
|
899
1022
|
" fields; cron keeps ticking), \"replace\" (default; swap content in",
|
|
900
1023
|
" place, no back-target), \"push\" (stack a child screen; the parent",
|
|
901
1024
|
" is retained and its cron pauses, resuming on back).",
|
|
1025
|
+
" timeoutMs — listen ms (default 90000, max 600000); 300000-600000 when the",
|
|
1026
|
+
" user must read or decide; omit for fire-and-forget.",
|
|
1027
|
+
"window_expired is NOT an error: the surface stays live; taps park — re-render",
|
|
1028
|
+
"(update:\"patch\") to collect, or end your turn (parked taps wake you).",
|
|
902
1029
|
"",
|
|
903
1030
|
"Before authoring any refreshing/live surface, per-item detail list, or",
|
|
904
1031
|
"multi-screen flow, load the \"glasses-ui\" skill — it is the authoring source",
|
|
@@ -916,13 +1043,6 @@ export const GLASSES_UI_TOOL_DESCRIPTION = [
|
|
|
916
1043
|
"\"selected\" result, follow up with another render or a brief one-line ack.",
|
|
917
1044
|
].join("\n");
|
|
918
1045
|
|
|
919
|
-
// Shared per-session depth counter. OpenClaw loads the plugin's register(api)
|
|
920
|
-
// in multiple isolated contexts (gateway startup, per-agent-run tool discovery)
|
|
921
|
-
// and each call to registerGlassesUiTool would otherwise close over its own
|
|
922
|
-
// Map. execute() runs in the per-run context's closure while api.on("agent_end",
|
|
923
|
-
// ...) fires from an earlier global-context closure, so reset-on-end would miss
|
|
924
|
-
// the live counter. Stashing the map on globalThis under a stable Symbol gives
|
|
925
|
-
// every load context the same Map to read and mutate.
|
|
926
1046
|
const DEPTH_MAP_SYMBOL = Symbol.for("ocuclaw.glasses-ui.depthBySession");
|
|
927
1047
|
function getSharedDepthMap() {
|
|
928
1048
|
let m = globalThis[DEPTH_MAP_SYMBOL];
|
|
@@ -933,24 +1053,30 @@ function getSharedDepthMap() {
|
|
|
933
1053
|
return m;
|
|
934
1054
|
}
|
|
935
1055
|
|
|
936
|
-
|
|
1056
|
+
const HANDLER_SCOPE_SYMBOL = Symbol.for("ocuclaw.glasses-ui.sharedHandler");
|
|
1057
|
+
|
|
1058
|
+
export function registerGlassesUiTool(api, service, opts = {}) {
|
|
937
1059
|
if (!api || typeof api.registerTool !== "function") {
|
|
938
1060
|
throw new Error("registerGlassesUiTool requires api.registerTool");
|
|
939
1061
|
}
|
|
940
1062
|
if (!service) {
|
|
941
1063
|
throw new Error("registerGlassesUiTool requires the OcuClaw relay service");
|
|
942
1064
|
}
|
|
1065
|
+
const scopeHost =
|
|
1066
|
+
opts && opts.scopeHost && typeof opts.scopeHost === "object" ? opts.scopeHost : globalThis;
|
|
943
1067
|
|
|
944
1068
|
const depthBySession = getSharedDepthMap();
|
|
945
1069
|
|
|
946
|
-
function nextDepth(
|
|
1070
|
+
function nextDepth(rawSessionKey) {
|
|
1071
|
+
const sessionKey = normalizeGlassesSessionKey(rawSessionKey);
|
|
947
1072
|
const prev = depthBySession.get(sessionKey) || 0;
|
|
948
1073
|
const next = prev + 1;
|
|
949
1074
|
depthBySession.set(sessionKey, next);
|
|
950
1075
|
return next;
|
|
951
1076
|
}
|
|
952
1077
|
|
|
953
|
-
function resetDepth(
|
|
1078
|
+
function resetDepth(rawSessionKey) {
|
|
1079
|
+
const sessionKey = normalizeGlassesSessionKey(rawSessionKey);
|
|
954
1080
|
if (sessionKey) {
|
|
955
1081
|
depthBySession.delete(sessionKey);
|
|
956
1082
|
} else {
|
|
@@ -971,18 +1097,11 @@ export function registerGlassesUiTool(api, service) {
|
|
|
971
1097
|
return typeof key === "string" ? key : "";
|
|
972
1098
|
}
|
|
973
1099
|
} catch (_) {
|
|
974
|
-
|
|
1100
|
+
|
|
975
1101
|
}
|
|
976
1102
|
return "";
|
|
977
1103
|
}
|
|
978
1104
|
|
|
979
|
-
// Inline resolution: the cron engine asks for the API key once per tick
|
|
980
|
-
// for the current model. We cache the last-resolved model→key pair across
|
|
981
|
-
// ticks since model rarely changes within a cron. runDynamicUi awaits
|
|
982
|
-
// prewarmLlmApiKey before starting the cron, so tick 1 sees the resolved
|
|
983
|
-
// key. resolveLlmApiKeySync is only called after the prewarm has populated
|
|
984
|
-
// the cache; if it ever runs uncached it returns "" and the backend
|
|
985
|
-
// reports a useful error (cron resolves recipe_failed via the breaker).
|
|
986
1105
|
let lastModel = null;
|
|
987
1106
|
let lastKey = "";
|
|
988
1107
|
async function prewarmLlmApiKey(modelRef) {
|
|
@@ -993,8 +1112,7 @@ export function registerGlassesUiTool(api, service) {
|
|
|
993
1112
|
}
|
|
994
1113
|
function resolveLlmApiKeySync(modelRef) {
|
|
995
1114
|
if (modelRef === lastModel) return lastKey;
|
|
996
|
-
|
|
997
|
-
// off async resolution but return empty for this tick.
|
|
1115
|
+
|
|
998
1116
|
resolveLlmApiKey(modelRef).then((key) => {
|
|
999
1117
|
lastModel = modelRef;
|
|
1000
1118
|
lastKey = key;
|
|
@@ -1002,7 +1120,10 @@ export function registerGlassesUiTool(api, service) {
|
|
|
1002
1120
|
return "";
|
|
1003
1121
|
}
|
|
1004
1122
|
|
|
1005
|
-
|
|
1123
|
+
let scopeRecord = scopeHost[HANDLER_SCOPE_SYMBOL];
|
|
1124
|
+
const createsHandler = !scopeRecord || !scopeRecord.handler;
|
|
1125
|
+
|
|
1126
|
+
const handler = createsHandler ? createGlassesUiToolHandler({
|
|
1006
1127
|
relay: {
|
|
1007
1128
|
sendGlassesUiRender: (msg) => service.sendGlassesUiRender(msg),
|
|
1008
1129
|
sendGlassesUiSurfaceUpdate: (msg) => service.sendGlassesUiSurfaceUpdate(msg),
|
|
@@ -1014,7 +1135,7 @@ export function registerGlassesUiTool(api, service) {
|
|
|
1014
1135
|
service.emitGlassesUiLifecycle(event, severity, data);
|
|
1015
1136
|
}
|
|
1016
1137
|
} catch (_) {
|
|
1017
|
-
|
|
1138
|
+
|
|
1018
1139
|
}
|
|
1019
1140
|
},
|
|
1020
1141
|
getGlassesUiLiveConfig: () => {
|
|
@@ -1028,10 +1149,7 @@ export function registerGlassesUiTool(api, service) {
|
|
|
1028
1149
|
resolveLlmApiKey: resolveLlmApiKeySync,
|
|
1029
1150
|
prewarmLlmApiKey,
|
|
1030
1151
|
timeoutMs: () => {
|
|
1031
|
-
|
|
1032
|
-
// effect on the next render without a gateway restart. A non-finite
|
|
1033
|
-
// or <=0 value disables the timeout (infinite wait — the pre-2026-05-23
|
|
1034
|
-
// behaviour, kept available as an escape hatch).
|
|
1152
|
+
|
|
1035
1153
|
try {
|
|
1036
1154
|
const cfg = service.getRuntimeConfig && service.getRuntimeConfig();
|
|
1037
1155
|
const v = cfg && cfg.renderGlassesUiTimeoutMs;
|
|
@@ -1041,25 +1159,14 @@ export function registerGlassesUiTool(api, service) {
|
|
|
1041
1159
|
}
|
|
1042
1160
|
},
|
|
1043
1161
|
isSessionConnected: () => {
|
|
1044
|
-
|
|
1045
|
-
// contexts) for any connected downstream client. Per-session
|
|
1046
|
-
// connection tracking would need deeper plumbing; the global check
|
|
1047
|
-
// catches the common failure mode where the tool is invoked with no
|
|
1048
|
-
// glasses client at all, which is what produced the indefinite hangs
|
|
1049
|
-
// before this gate existed.
|
|
1162
|
+
|
|
1050
1163
|
if (typeof service.hasConnectedAppClient === "function") {
|
|
1051
1164
|
return service.hasConnectedAppClient();
|
|
1052
1165
|
}
|
|
1053
1166
|
return false;
|
|
1054
1167
|
},
|
|
1055
1168
|
isUnderBackpressure: () => {
|
|
1056
|
-
|
|
1057
|
-
// send buffer is over its high-water mark (Spike D — there is no
|
|
1058
|
-
// glass-side paint-ack, so transport pressure is the only signal). The
|
|
1059
|
-
// signal source is relay-health-monitor's send-buffer high-water; until
|
|
1060
|
-
// relay-service surfaces it as isGlassesSendBufferOverHighWater this
|
|
1061
|
-
// returns false (safe default — no shedding). Completing this query is
|
|
1062
|
-
// part of the BLE-backpressure hardening validated on hardware (Task 20).
|
|
1169
|
+
|
|
1063
1170
|
try {
|
|
1064
1171
|
return typeof service.isGlassesSendBufferOverHighWater === "function"
|
|
1065
1172
|
? service.isGlassesSendBufferOverHighWater()
|
|
@@ -1068,49 +1175,56 @@ export function registerGlassesUiTool(api, service) {
|
|
|
1068
1175
|
return false;
|
|
1069
1176
|
}
|
|
1070
1177
|
},
|
|
1071
|
-
});
|
|
1072
1178
|
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1179
|
+
dispatchWake:
|
|
1180
|
+
typeof service.dispatchGlassesWake === "function"
|
|
1181
|
+
? (params) => service.dispatchGlassesWake(params)
|
|
1182
|
+
: null,
|
|
1183
|
+
isAgentTurnBusy: (sessionKey) => {
|
|
1184
|
+
try {
|
|
1185
|
+
return typeof service.isAgentTurnBusy === "function"
|
|
1186
|
+
? !!service.isAgentTurnBusy(sessionKey)
|
|
1187
|
+
: false;
|
|
1188
|
+
} catch (_) {
|
|
1189
|
+
return false;
|
|
1083
1190
|
}
|
|
1084
|
-
}
|
|
1085
|
-
}
|
|
1191
|
+
},
|
|
1192
|
+
}) : scopeRecord.handler;
|
|
1193
|
+
|
|
1194
|
+
if (createsHandler) {
|
|
1195
|
+
scopeRecord = { handler, refs: 0 };
|
|
1196
|
+
scopeHost[HANDLER_SCOPE_SYMBOL] = scopeRecord;
|
|
1197
|
+
|
|
1198
|
+
if (typeof service.onAppClientDisconnect === "function") {
|
|
1199
|
+
service.onAppClientDisconnect(({ sessionKey }) => {
|
|
1200
|
+
const target = sessionKey || null;
|
|
1201
|
+
if (target) {
|
|
1202
|
+
handler.drainSession(target, { result: "glasses_disconnected" });
|
|
1203
|
+
} else {
|
|
1204
|
+
handler.drainAll({ result: "glasses_disconnected" });
|
|
1205
|
+
}
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
if (typeof service.onGlassesUiNavEvent === "function") {
|
|
1210
|
+
service.onGlassesUiNavEvent((ev) => {
|
|
1211
|
+
const sessionKey = handler.sessionForSurface(ev.surfaceId);
|
|
1212
|
+
if (!sessionKey) {
|
|
1213
|
+
try {
|
|
1214
|
+
if (typeof service.emitGlassesUiLifecycle === "function") {
|
|
1215
|
+
service.emitGlassesUiLifecycle("nav_event_skipped_foreign_surface", "debug", {
|
|
1216
|
+
evSurfaceId: ev.surfaceId,
|
|
1217
|
+
evDepth: ev.depth,
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
} catch (_) {
|
|
1086
1221
|
|
|
1087
|
-
// Reconcile client nav-events with the SINGLE store stack (Task 16). On pop
|
|
1088
|
-
// the client reports the surfaceId now back on top + the post-pop depth; the
|
|
1089
|
-
// store knows it. The relay frame carries no sessionKey, so resolve it from
|
|
1090
|
-
// the surface's store entry (sessionForSurface). Every plugin-load context
|
|
1091
|
-
// registers one of these handlers on the SHARED relay, so each nav-event
|
|
1092
|
-
// fans out to N contexts but at most one context's store knows the surface —
|
|
1093
|
-
// a context that cannot resolve it must NO-OP. (The old "main" fallback made
|
|
1094
|
-
// the sibling contexts reconcile an empty store carrying stale cross-session
|
|
1095
|
-
// lastDepth: the 3-4x duplicate nav_reconcile on hardware, bug B2.)
|
|
1096
|
-
if (typeof service.onGlassesUiNavEvent === "function") {
|
|
1097
|
-
service.onGlassesUiNavEvent((ev) => {
|
|
1098
|
-
const sessionKey = handler.sessionForSurface(ev.surfaceId);
|
|
1099
|
-
if (!sessionKey) {
|
|
1100
|
-
try {
|
|
1101
|
-
if (typeof service.emitGlassesUiLifecycle === "function") {
|
|
1102
|
-
service.emitGlassesUiLifecycle("nav_event_skipped_foreign_surface", "debug", {
|
|
1103
|
-
evSurfaceId: ev.surfaceId,
|
|
1104
|
-
evDepth: ev.depth,
|
|
1105
|
-
});
|
|
1106
1222
|
}
|
|
1107
|
-
|
|
1108
|
-
// observability must never break the nav path
|
|
1223
|
+
return;
|
|
1109
1224
|
}
|
|
1110
|
-
|
|
1111
|
-
}
|
|
1112
|
-
|
|
1113
|
-
});
|
|
1225
|
+
handler.handleNavEvent(sessionKey, ev);
|
|
1226
|
+
});
|
|
1227
|
+
}
|
|
1114
1228
|
}
|
|
1115
1229
|
|
|
1116
1230
|
function resolveDedicatedEvenAiSessionKey() {
|
|
@@ -1123,14 +1237,7 @@ export function registerGlassesUiTool(api, service) {
|
|
|
1123
1237
|
|
|
1124
1238
|
api.registerTool(
|
|
1125
1239
|
(ctx) => {
|
|
1126
|
-
|
|
1127
|
-
// sessions). Those runs' responses go back to the Even Realities native
|
|
1128
|
-
// app — not the OcuClaw chat surface — so a glasses popup wouldn't be
|
|
1129
|
-
// reachable for the user. The tool stays visible to:
|
|
1130
|
-
// • the normal OcuClaw chat path (ocuclaw:<timestamp> sessions)
|
|
1131
|
-
// • the Even AI listen-mode path (which intercepts the HTTP
|
|
1132
|
-
// request and routes it through the active OcuClaw session, so
|
|
1133
|
-
// the sessionKey is an ocuclaw:<timestamp>, not ocuclaw:even-ai*).
|
|
1240
|
+
|
|
1134
1241
|
const sessionKey = ctx && typeof ctx.sessionKey === "string" ? ctx.sessionKey : "";
|
|
1135
1242
|
if (isEvenAiAgentSession(sessionKey, resolveDedicatedEvenAiSessionKey())) {
|
|
1136
1243
|
return null;
|
|
@@ -1140,14 +1247,16 @@ export function registerGlassesUiTool(api, service) {
|
|
|
1140
1247
|
name: "render_glasses_ui",
|
|
1141
1248
|
description: GLASSES_UI_TOOL_DESCRIPTION,
|
|
1142
1249
|
parameters: glassesUiParametersSchema,
|
|
1143
|
-
async execute(_toolCallId, params) {
|
|
1144
|
-
const resolvedSessionKey = factorySessionKey || "main";
|
|
1250
|
+
async execute(_toolCallId, params, signal) {
|
|
1251
|
+
const resolvedSessionKey = normalizeGlassesSessionKey(factorySessionKey || "main");
|
|
1145
1252
|
const depth = nextDepth(resolvedSessionKey);
|
|
1146
1253
|
try {
|
|
1254
|
+
|
|
1147
1255
|
const outcome = await handler.runDynamicUi({
|
|
1148
1256
|
sessionKey: resolvedSessionKey,
|
|
1149
1257
|
depth,
|
|
1150
1258
|
spec: params,
|
|
1259
|
+
signal,
|
|
1151
1260
|
});
|
|
1152
1261
|
return {
|
|
1153
1262
|
content: [{ type: "text", text: JSON.stringify(outcome) }],
|
|
@@ -1164,22 +1273,57 @@ export function registerGlassesUiTool(api, service) {
|
|
|
1164
1273
|
);
|
|
1165
1274
|
|
|
1166
1275
|
if (typeof api.on === "function") {
|
|
1276
|
+
|
|
1277
|
+
api.on("before_prompt_build", (_event, ctx) => {
|
|
1278
|
+
const sessionKey = ctx && typeof ctx.sessionKey === "string" ? ctx.sessionKey : null;
|
|
1279
|
+
if (!sessionKey) return undefined;
|
|
1280
|
+
try {
|
|
1281
|
+
const fragment = handler.buildVoicemailInjection(sessionKey);
|
|
1282
|
+
return fragment ? { appendSystemContext: fragment } : undefined;
|
|
1283
|
+
} catch (_) {
|
|
1284
|
+
|
|
1285
|
+
return undefined;
|
|
1286
|
+
}
|
|
1287
|
+
});
|
|
1288
|
+
|
|
1167
1289
|
api.on("agent_end", (_event, ctx) => {
|
|
1168
1290
|
const sessionKey = ctx && typeof ctx.sessionKey === "string" ? ctx.sessionKey : null;
|
|
1169
|
-
|
|
1170
|
-
// depth counter. Normal-completion runs will have already resolved
|
|
1171
|
-
// their tool call; this branch is the safety net for runs torn down
|
|
1172
|
-
// externally (timeout, abort) so the in-memory map doesn't leak
|
|
1173
|
-
// across runs.
|
|
1291
|
+
|
|
1174
1292
|
if (sessionKey) {
|
|
1175
|
-
handler.
|
|
1293
|
+
const stackDepth = handler.surfaceStackDepth(sessionKey);
|
|
1294
|
+
const settledPending = handler.settleSession(sessionKey, { result: "preempted" });
|
|
1295
|
+
|
|
1296
|
+
try {
|
|
1297
|
+
if (typeof service.emitGlassesUiLifecycle === "function") {
|
|
1298
|
+
service.emitGlassesUiLifecycle("agent_end_settle", "debug", {
|
|
1299
|
+
sessionKey: normalizeGlassesSessionKey(sessionKey),
|
|
1300
|
+
stackDepth,
|
|
1301
|
+
settledPending,
|
|
1302
|
+
storeId: handler.storeId,
|
|
1303
|
+
});
|
|
1304
|
+
}
|
|
1305
|
+
} catch (_) {
|
|
1306
|
+
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
handler.parkMarkerOnAgentEnd(sessionKey);
|
|
1176
1310
|
}
|
|
1177
1311
|
resetDepth(sessionKey);
|
|
1178
1312
|
});
|
|
1179
1313
|
}
|
|
1180
1314
|
|
|
1315
|
+
scopeRecord.refs += 1;
|
|
1316
|
+
let disposedThisContext = false;
|
|
1181
1317
|
return function dispose() {
|
|
1318
|
+
if (disposedThisContext) return;
|
|
1319
|
+
disposedThisContext = true;
|
|
1320
|
+
scopeRecord.refs -= 1;
|
|
1321
|
+
if (scopeRecord.refs > 0) return;
|
|
1182
1322
|
handler.drainAll({ result: "preempted" });
|
|
1183
1323
|
depthBySession.clear();
|
|
1324
|
+
|
|
1325
|
+
if (scopeHost[HANDLER_SCOPE_SYMBOL] === scopeRecord) {
|
|
1326
|
+
delete scopeHost[HANDLER_SCOPE_SYMBOL];
|
|
1327
|
+
}
|
|
1184
1328
|
};
|
|
1185
1329
|
}
|