ocuclaw 1.3.0 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +3 -1
  2. package/dist/config/runtime-config-session-title-model.test.js +22 -0
  3. package/dist/config/runtime-config.js +24 -15
  4. package/dist/domain/debug-store.js +18 -0
  5. package/dist/domain/glasses-display-system-prompt.js +52 -0
  6. package/dist/domain/glasses-display-system-prompt.test.js +44 -0
  7. package/dist/domain/glasses-ui-system-prompt.js +6 -22
  8. package/dist/domain/glasses-ui-system-prompt.test.js +13 -0
  9. package/dist/domain/prompt-channel-fragments.js +32 -0
  10. package/dist/domain/prompt-channel-fragments.test.js +70 -0
  11. package/dist/gateway/gateway-timing-ledger.js +15 -3
  12. package/dist/gateway/openclaw-client.js +80 -3
  13. package/dist/index.js +22 -0
  14. package/dist/runtime/channel-two-hook.js +36 -0
  15. package/dist/runtime/container-env.js +41 -0
  16. package/dist/runtime/display-toggle-states.js +98 -0
  17. package/dist/runtime/plugin-version-service.js +23 -0
  18. package/dist/runtime/register-session-title-distiller.js +100 -0
  19. package/dist/runtime/relay-core.js +307 -68
  20. package/dist/runtime/relay-service.js +120 -13
  21. package/dist/runtime/relay-worker-entry.js +26 -0
  22. package/dist/runtime/relay-worker-protocol.js +0 -4
  23. package/dist/runtime/relay-worker-supervisor.js +43 -79
  24. package/dist/runtime/relay-worker-transport.js +41 -0
  25. package/dist/runtime/session-service.js +159 -15
  26. package/dist/runtime/session-title-distiller-budget.js +36 -0
  27. package/dist/runtime/session-title-distiller-helpers.js +130 -0
  28. package/dist/runtime/session-title-distiller.js +354 -0
  29. package/dist/runtime/session-title-record.js +21 -0
  30. package/dist/runtime/stable-prompt-snapshot.js +119 -0
  31. package/dist/tools/glasses-ui-cron.js +9 -3
  32. package/dist/tools/glasses-ui-paint-floor.js +10 -3
  33. package/dist/tools/glasses-ui-recipes.js +13 -178
  34. package/dist/tools/glasses-ui-surfaces.js +8 -1
  35. package/dist/tools/glasses-ui-tool-description.test.js +16 -0
  36. package/dist/tools/glasses-ui-tool.js +98 -60
  37. package/dist/tools/session-title-tool.js +14 -76
  38. package/dist/tools/session-title-tool.test.js +53 -0
  39. package/dist/version.js +2 -2
  40. package/openclaw.plugin.json +9 -0
  41. package/package.json +6 -4
  42. package/skills/glasses-ui/SKILL.md +163 -0
  43. package/dist/runtime/downstream-server.js +0 -2057
  44. package/dist/runtime/plugin-update-service.js +0 -216
@@ -1,53 +1,17 @@
1
- // Recipe executors for glasses_ui_refresh. Three kinds: shell (spawn bash),
2
- // http (in-process fetch), and llm (added in Task 3 — four backends behind
3
- // one dispatcher). All return either { output } or { error: <string> }.
1
+ // Recipe executors for glasses_ui_refresh. Kinds: http (in-process fetch),
2
+ // llm (two HTTP API backends behind one dispatcher), and system-stats
3
+ // (in-process node:os reads). All return either { output } or
4
+ // { error: <string> }. The shell (spawn bash) and llm claude-cli
5
+ // (spawn claude) backends were removed to drop the plugin's last
6
+ // child_process spawn — see the backends comment in config/runtime-config.ts.
4
7
  //
5
8
  // output is `string` for plain text and `object` for JSON. The cron engine
6
9
  // hands `output` to the template engine which handles both shapes.
7
10
 
8
- import { spawn } from "node:child_process";
9
- import { tmpdir, totalmem, freemem, loadavg, cpus } from "node:os";
11
+ import { totalmem, freemem, loadavg, cpus } from "node:os";
10
12
  import * as dns from "node:dns";
11
13
  import { Agent } from "undici";
12
14
 
13
- // Env vars allowed to cross into the spawned Claude CLI subprocess.
14
- // Everything else is stripped — Claude reads its own auth from ~/.claude/
15
- // (reachable via HOME), so cloud-provider keys (AWS_*, GOOGLE_*,
16
- // ANTHROPIC_API_KEY, OPENAI_API_KEY, *_SECRET, *_TOKEN) and database URLs
17
- // must never reach the agent the CLI is running. Operators who want an
18
- // API key in the LLM-tick path use the *-api backends, which read the key
19
- // via the host's modelAuth resolver — not from spawn env.
20
- const CLI_SPAWN_ENV_ALLOWLIST = [
21
- "PATH",
22
- "HOME",
23
- "USER",
24
- "LOGNAME",
25
- "SHELL",
26
- "TERM",
27
- "LANG",
28
- "TZ",
29
- "XDG_CONFIG_HOME",
30
- "XDG_CACHE_HOME",
31
- "XDG_DATA_HOME",
32
- "XDG_RUNTIME_DIR",
33
- "NODE_OPTIONS",
34
- ];
35
- const CLI_SPAWN_ENV_ALLOWLIST_PREFIXES = ["LC_"];
36
-
37
- function buildScopedSpawnEnv() {
38
- const sourceEnv = process.env || {};
39
- const out = {};
40
- for (const k of CLI_SPAWN_ENV_ALLOWLIST) {
41
- if (typeof sourceEnv[k] === "string") out[k] = sourceEnv[k];
42
- }
43
- for (const k of Object.keys(sourceEnv)) {
44
- if (CLI_SPAWN_ENV_ALLOWLIST_PREFIXES.some((p) => k.startsWith(p))) {
45
- out[k] = sourceEnv[k];
46
- }
47
- }
48
- return out;
49
- }
50
-
51
15
  const DEFAULT_TIMEOUT_MS = 10_000;
52
16
  const DEFAULT_OUTPUT_CAP_BYTES = 64 * 1024;
53
17
 
@@ -71,77 +35,6 @@ function parseJsonIfPossible(text) {
71
35
  }
72
36
  }
73
37
 
74
- export async function executeShellRecipe(params) {
75
- const command = params && typeof params.command === "string" ? params.command : "";
76
- if (!command) {
77
- return { error: "shell recipe missing command" };
78
- }
79
- const timeoutMs = Number.isFinite(params && params.timeoutMs)
80
- ? params.timeoutMs
81
- : DEFAULT_TIMEOUT_MS;
82
- const outputCapBytes = Number.isFinite(params && params.outputCapBytes)
83
- ? params.outputCapBytes
84
- : DEFAULT_OUTPUT_CAP_BYTES;
85
-
86
- return new Promise((resolve) => {
87
- const child = spawn("bash", ["-c", command], {
88
- stdio: ["ignore", "pipe", "pipe"],
89
- });
90
- const chunks = [];
91
- const errChunks = [];
92
- let bytes = 0;
93
- let truncated = false;
94
- let done = false;
95
-
96
- const finish = (result) => {
97
- if (done) return;
98
- done = true;
99
- clearTimeout(timer);
100
- try { child.kill("SIGKILL"); } catch (_) { /* already exited */ }
101
- resolve(result);
102
- };
103
-
104
- const timer = setTimeout(() => {
105
- finish({ error: `shell recipe timeout after ${timeoutMs}ms` });
106
- }, timeoutMs);
107
-
108
- child.stdout.on("data", (chunk) => {
109
- if (bytes + chunk.length > outputCapBytes) {
110
- const remaining = outputCapBytes - bytes;
111
- if (remaining > 0) chunks.push(chunk.slice(0, remaining));
112
- bytes = outputCapBytes;
113
- truncated = true;
114
- try { child.kill("SIGTERM"); } catch (_) {}
115
- } else {
116
- chunks.push(chunk);
117
- bytes += chunk.length;
118
- }
119
- });
120
-
121
- child.stderr.on("data", (chunk) => {
122
- if (errChunks.length < 32) errChunks.push(chunk);
123
- });
124
-
125
- child.on("error", (err) => {
126
- finish({ error: `shell recipe spawn error: ${err && err.message ? err.message : err}` });
127
- });
128
-
129
- child.on("close", (code, signal) => {
130
- const stdout = Buffer.concat(chunks).toString("utf8");
131
- if (code !== 0 && !truncated) {
132
- const stderr = Buffer.concat(errChunks).toString("utf8").trim();
133
- finish({
134
- error: signal
135
- ? `shell recipe killed by ${signal}`
136
- : `shell recipe exit code ${code}${stderr ? ": " + stderr.slice(0, 200) : ""}`,
137
- });
138
- return;
139
- }
140
- finish({ output: parseJsonIfPossible(stdout) });
141
- });
142
- });
143
- }
144
-
145
38
  function checkIpv4Tuple(a, b) {
146
39
  if (a === 127) return "loopback IPv4 blocked";
147
40
  if (a === 10) return "RFC1918 IPv4 blocked";
@@ -504,67 +397,11 @@ function stripModelProviderPrefix(modelRef) {
504
397
  return idx === -1 ? modelRef : modelRef.slice(idx + 1);
505
398
  }
506
399
 
507
- async function runClaudeCli(params, deps) {
508
- const spawnFn = deps && deps.spawn ? deps.spawn : spawn;
509
- const promptText =
510
- (params.systemPrompt ? params.systemPrompt + "\n\n" : "") + params.prompt;
511
- const model = stripModelProviderPrefix(params.model);
512
- // Lock the spawned Claude CLI to plan-mode with an empty toolset so the
513
- // tick prompt can't drive file/shell/web tools to exfil. --bare disables
514
- // hooks, plugin sync, CLAUDE.md auto-discovery, and keychain reads — the
515
- // CLI runs as a pure text-in/text-out worker.
516
- const args = [
517
- "-p", promptText,
518
- "--output-format", "text",
519
- "--permission-mode", "plan",
520
- "--tools", "",
521
- "--bare",
522
- ];
523
- if (model) args.push("--model", model);
524
- const timeoutMs = Number.isFinite(params.timeoutMs) ? params.timeoutMs : 60_000;
525
-
526
- return new Promise((resolve) => {
527
- const child = spawnFn("claude", args, {
528
- stdio: ["ignore", "pipe", "pipe"],
529
- cwd: tmpdir(),
530
- env: buildScopedSpawnEnv(),
531
- });
532
- const chunks = [];
533
- const errChunks = [];
534
- let done = false;
535
- const finish = (result) => {
536
- if (done) return;
537
- done = true;
538
- clearTimeout(timer);
539
- try { child.kill && child.kill("SIGKILL"); } catch (_) {}
540
- resolve(result);
541
- };
542
- const timer = setTimeout(
543
- () => finish({ error: `claude-cli timeout after ${timeoutMs}ms` }),
544
- timeoutMs,
545
- );
546
- child.stdout.on("data", (c) => chunks.push(c));
547
- child.stderr.on("data", (c) => errChunks.push(c));
548
- child.on("error", (err) => finish({ error: `claude-cli spawn error: ${err.message}` }));
549
- child.on("close", (code) => {
550
- if (code !== 0) {
551
- const stderr = Buffer.concat(errChunks).toString("utf8").trim();
552
- finish({ error: `claude-cli exit code ${code}${stderr ? ": " + stderr.slice(0, 200) : ""}` });
553
- return;
554
- }
555
- finish({ output: Buffer.concat(chunks).toString("utf8") });
556
- });
557
- });
558
- }
559
-
560
- // codex-cli backend removed (round-5 autoreview): Codex's `read-only`
561
- // sandbox blocks writes and exec but still permits filesystem reads, so
562
- // an agent prompt could drive the spawned process to read ~/.aws/credentials,
563
- // ~/.ssh/*, etc. and emit them through stdout → glasses body. Claude CLI
564
- // is structurally safe because --tools "" disables every tool including
565
- // Read; Codex has no equivalent flag. Operators who want Codex point an
566
- // openai-compat backend at https://api.openai.com (or wherever Codex is
567
- // served) — that path has no tool surface at all.
400
+ // Both CLI-spawn backends (codex-cli, claude-cli) were removed to eliminate
401
+ // the plugin's last child_process spawn see the backends comment in
402
+ // config/runtime-config.ts for the rationale and the deferred native-delegation
403
+ // track. Operators who want those providers point an *-api backend at the
404
+ // provider endpoint (key resolved via the host modelAuth, tool-less).
568
405
 
569
406
  async function runAnthropicApi(params, deps) {
570
407
  const fetchFn = deps && deps.fetch ? deps.fetch : fetch;
@@ -666,8 +503,6 @@ export async function executeLlmRecipeWithDeps(recipe, ctx, deps) {
666
503
  };
667
504
  if (!baseParams.prompt) return { error: "llm recipe missing prompt" };
668
505
  switch (backend) {
669
- case "claude-cli":
670
- return runClaudeCli(baseParams, deps || {});
671
506
  case "anthropic-api":
672
507
  return runAnthropicApi(baseParams, deps || {});
673
508
  case "openai-compat":
@@ -743,4 +578,4 @@ export async function executeSystemStatsRecipe(params, opts) {
743
578
  }
744
579
  }
745
580
 
746
- export default { executeShellRecipe, executeHttpRecipe, executeLlmRecipe, executeLlmRecipeWithDeps, executeSystemStatsRecipe, computeCpuPct };
581
+ export default { executeHttpRecipe, executeLlmRecipe, executeLlmRecipeWithDeps, executeSystemStatsRecipe, computeCpuPct };
@@ -236,7 +236,14 @@ export function createSurfaceStore(deps = {}) {
236
236
  // be move-independent (replace, the schema DEFAULT, must behave like
237
237
  // patch here, not silently drop a latched exit).
238
238
  const priorTop = bySurface.get(top);
239
- stopCron(top);
239
+ // SILENT stop: this is slot recycling for the incoming replace render, not
240
+ // a real outcome. A non-silent stop fires the cron's onResolve with a
241
+ // synthesized `preempted`, which (with no pending call at this instant)
242
+ // latches a bogus exit onto the prior entry — carried into makeEntry below,
243
+ // it makes the very render we are applying discard-for-exit on re-attach
244
+ // ("fresh render instantly dismissed", B7 — the real B3 contamination
245
+ // mechanism, found 2026-06-11).
246
+ stopCron(top, { silent: true });
240
247
  bySurface.set(top, makeEntry(sessionKey, params && params.kind, priorTop));
241
248
  return { mode: "replace", surfaceId: top };
242
249
  }
@@ -0,0 +1,16 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { GLASSES_UI_TOOL_DESCRIPTION } from "./glasses-ui-tool.ts";
4
+
5
+ test("description now carries the follow-up + back/selected usage rules", () => {
6
+ const d = GLASSES_UI_TOOL_DESCRIPTION;
7
+ assert.match(d, /text_surface/);
8
+ assert.match(d, /list_surface/);
9
+ assert.match(d, /list_with_details_surface/);
10
+ // Channel-3 additions (moved from the old nudge):
11
+ assert.match(d, /NEXT output|next output/);
12
+ assert.match(d, /back/i);
13
+ assert.match(d, /selected/i);
14
+ // Skill pointer retained:
15
+ assert.match(d, /glasses-ui/);
16
+ });
@@ -7,7 +7,6 @@
7
7
  import { validateTemplate } from "./glasses-ui-template.js";
8
8
  import { createGlassesUiCronEngine } from "./glasses-ui-cron.js";
9
9
  import {
10
- executeShellRecipe,
11
10
  executeHttpRecipe,
12
11
  executeLlmRecipe,
13
12
  executeSystemStatsRecipe,
@@ -32,7 +31,7 @@ import {
32
31
  export { createPendingRenderMap, createSurfaceStore, GLASSES_UI_LIMITS };
33
32
 
34
33
  export const GLASSES_UI_REFRESH_LIMITS = {
35
- intervalMsMin: { shell: 1000, http: 1000, "system-stats": 1000, "llm-cli": 60_000, "llm-api": 30_000 },
34
+ intervalMsMin: { http: 1000, "system-stats": 1000, "llm-api": 30_000 },
36
35
  intervalMsMax: 3_600_000,
37
36
  maxDurationMsMin: 10_000,
38
37
  maxDurationMsMax: 7_200_000,
@@ -63,13 +62,6 @@ export const GLASSES_UI_REFRESH_LIMITS = {
63
62
 
64
63
  const ON_ERROR_VALUES = new Set(["keep_last", "show_error", "stop"]);
65
64
 
66
- function llmIntervalMinForBackend(backend) {
67
- if (backend === "claude-cli") {
68
- return GLASSES_UI_REFRESH_LIMITS.intervalMsMin["llm-cli"];
69
- }
70
- return GLASSES_UI_REFRESH_LIMITS.intervalMsMin["llm-api"];
71
- }
72
-
73
65
  // The effective per-tick interval floor is the larger of the tier minimum and
74
66
  // the paint-floor coalescer's cadence (Spike D, 150ms) — no tick may schedule
75
67
  // faster than the glass can paint. Today every tier min already exceeds 150ms,
@@ -92,8 +84,8 @@ export function validateRefreshSpec(refresh, glassesUiLiveCfg) {
92
84
  return { ok: false, code: "refresh_invalid_recipe", message: "refresh.recipe is required" };
93
85
  }
94
86
  const kind = recipe.kind;
95
- if (kind !== "shell" && kind !== "http" && kind !== "llm" && kind !== "system-stats") {
96
- return { ok: false, code: "refresh_invalid_recipe", message: `recipe.kind must be shell/http/llm/system-stats, got ${JSON.stringify(kind)}` };
87
+ if (kind !== "http" && kind !== "llm" && kind !== "system-stats") {
88
+ return { ok: false, code: "refresh_invalid_recipe", message: `recipe.kind must be http/llm/system-stats, got ${JSON.stringify(kind)}` };
97
89
  }
98
90
  // Sanitize the recipe — clamp/reject agent-supplied timeoutMs / outputCapBytes
99
91
  // / maxOutputTokens to declared bounds, copy known fields only. The returned
@@ -105,23 +97,7 @@ export function validateRefreshSpec(refresh, glassesUiLiveCfg) {
105
97
  if (raw < min || raw > max) return undefined; // signal out-of-range
106
98
  return Math.floor(raw);
107
99
  };
108
- if (kind === "shell") {
109
- if (cfg.shellEnabled === false) return { ok: false, code: "refresh_disabled", message: "shell recipes disabled" };
110
- if (typeof recipe.command !== "string" || !recipe.command.trim()) {
111
- return { ok: false, code: "refresh_invalid_recipe", message: "shell recipe requires command (non-empty string)" };
112
- }
113
- sanitizedRecipe.command = recipe.command;
114
- if (recipe.timeoutMs !== undefined) {
115
- const v = bounded(recipe.timeoutMs, GLASSES_UI_REFRESH_LIMITS.shellHttpTimeoutMsMin, GLASSES_UI_REFRESH_LIMITS.shellHttpTimeoutMsMax);
116
- if (v === undefined) return { ok: false, code: "refresh_invalid_recipe", message: `shell.timeoutMs ${recipe.timeoutMs} out of bounds [${GLASSES_UI_REFRESH_LIMITS.shellHttpTimeoutMsMin}..${GLASSES_UI_REFRESH_LIMITS.shellHttpTimeoutMsMax}]` };
117
- if (v !== null) sanitizedRecipe.timeoutMs = v;
118
- }
119
- if (recipe.outputCapBytes !== undefined) {
120
- const v = bounded(recipe.outputCapBytes, GLASSES_UI_REFRESH_LIMITS.outputCapBytesMin, GLASSES_UI_REFRESH_LIMITS.outputCapBytesMax);
121
- if (v === undefined) return { ok: false, code: "refresh_invalid_recipe", message: `shell.outputCapBytes ${recipe.outputCapBytes} out of bounds` };
122
- if (v !== null) sanitizedRecipe.outputCapBytes = v;
123
- }
124
- } else if (kind === "http") {
100
+ if (kind === "http") {
125
101
  if (cfg.httpEnabled === false) return { ok: false, code: "refresh_disabled", message: "http recipes disabled" };
126
102
  if (typeof recipe.url !== "string" || !recipe.url.trim()) {
127
103
  return { ok: false, code: "refresh_invalid_recipe", message: "http recipe requires url (non-empty string)" };
@@ -159,7 +135,7 @@ export function validateRefreshSpec(refresh, glassesUiLiveCfg) {
159
135
  }
160
136
  } else if (kind === "system-stats") {
161
137
  // Built-in tier: host RAM/CPU via the in-process structured reader. NOT gated
162
- // by httpEnabled/shellEnabled/llmEnabled — it touches no network, no shell, no
138
+ // by httpEnabled/llmEnabled — it touches no network, no shell, no
163
139
  // model. Only the master `enabled` switch (checked above) governs it. Do NOT
164
140
  // add a cfg.*Enabled gate here (intentional — Phase 3 design).
165
141
  if (recipe.sampleWindowMs !== undefined) {
@@ -176,7 +152,7 @@ export function validateRefreshSpec(refresh, glassesUiLiveCfg) {
176
152
  }
177
153
  const minForKind =
178
154
  kind === "llm"
179
- ? llmIntervalMinForBackend(cfg.tickBackend)
155
+ ? GLASSES_UI_REFRESH_LIMITS.intervalMsMin["llm-api"]
180
156
  : GLASSES_UI_REFRESH_LIMITS.intervalMsMin[kind];
181
157
  const minEffective = effectiveIntervalFloorMs(minForKind);
182
158
  if (intervalMs < minEffective) {
@@ -307,16 +283,6 @@ const refreshSchemaForToolParams = {
307
283
  },
308
284
  recipe: {
309
285
  oneOf: [
310
- {
311
- type: "object",
312
- required: ["kind", "command"],
313
- properties: {
314
- kind: { const: "shell" },
315
- command: { type: "string" },
316
- timeoutMs: { type: "integer" },
317
- outputCapBytes: { type: "integer" },
318
- },
319
- },
320
286
  {
321
287
  type: "object",
322
288
  required: ["kind", "url"],
@@ -536,7 +502,6 @@ export function createGlassesUiToolHandler(deps) {
536
502
  emitLifecycle,
537
503
  monotonicNowMs: () => performance.now(),
538
504
  executeRecipe: async (recipe, ctx) => {
539
- if (recipe.kind === "shell") return executeShellRecipe(recipe);
540
505
  if (recipe.kind === "http") return executeHttpRecipe(recipe);
541
506
  if (recipe.kind === "system-stats") return executeSystemStatsRecipe(recipe);
542
507
  if (recipe.kind === "llm") return executeLlmRecipe(recipe, ctx);
@@ -555,7 +520,7 @@ export function createGlassesUiToolHandler(deps) {
555
520
  ? Math.min(state.recipe.maxOutputTokens, cfg.tickMaxOutputTokens || 200)
556
521
  : (cfg.tickMaxOutputTokens || 200);
557
522
  return {
558
- backend: cfg.tickBackend || "claude-cli",
523
+ backend: cfg.tickBackend || "anthropic-api",
559
524
  model,
560
525
  baseUrl: cfg.tickApiBaseUrl || "",
561
526
  apiKey: deps.resolveLlmApiKey ? deps.resolveLlmApiKey(model) : "",
@@ -577,8 +542,8 @@ export function createGlassesUiToolHandler(deps) {
577
542
  // parent/chat after a back/pop, and the per-surface coalescer state doesn't
578
543
  // leak across a long push/replace session. (pauseCron on push does NOT
579
544
  // dispose — the parent resumes.)
580
- stopCron: (id) => {
581
- cronEngine.stop(id, { result: "preempted" });
545
+ stopCron: (id, opts) => {
546
+ cronEngine.stop(id, { result: "preempted" }, opts);
582
547
  paintFloor.dispose(id);
583
548
  },
584
549
  mintSurfaceId: newSurfaceId,
@@ -645,11 +610,33 @@ export function createGlassesUiToolHandler(deps) {
645
610
  refreshValidated = v.refresh;
646
611
  }
647
612
 
613
+ // params.depth is the execute-level RUN-CALL ordinal (resets at agent_end).
614
+ // It is used ONLY as the "first render of this run" signal for stale-stack
615
+ // reaping below — it must NEVER reach the wire. The wire depth is derived
616
+ // from the store's true stack depth after applyRender (B6: ordinals never
617
+ // decrement on Back, so they drift past entry counts and break both the
618
+ // plugin pop reconciliation and the client's clear-vs-append decision).
648
619
  const depth = Number.isFinite(params.depth) ? Math.max(1, Math.floor(params.depth)) : 1;
649
620
  const update =
650
621
  params.spec && (params.spec.update === "patch" || params.spec.update === "push")
651
622
  ? params.spec.update
652
623
  : "replace";
624
+ // Stale-stack reaping (B3 safety net): a depth-1 render means NEW ROOT — a
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.
631
+ if (depth <= 1 && surfaceStore.stackDepth(sessionKey) > 1) {
632
+ const stackDepthBefore = surfaceStore.stackDepth(sessionKey);
633
+ const reapedPending = reapSession(sessionKey, { result: "preempted" });
634
+ emitLifecycle("stale_stack_reaped", "warn", {
635
+ sessionKey,
636
+ stackDepthBefore,
637
+ reapedPending,
638
+ });
639
+ }
653
640
  // The plugin owns surfaceIds (spec §Core model). applyRender derives the
654
641
  // target from the session's current top: patch/replace reuse the top id
655
642
  // (re-attach in place), push mints a child + pauses the parent cron, the
@@ -681,6 +668,12 @@ export function createGlassesUiToolHandler(deps) {
681
668
  }
682
669
  }
683
670
 
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
+ const wireDepth = Math.max(1, surfaceStore.stackDepth(sessionKey));
676
+
684
677
  // Initial render uses the agent's seed (instant). Routed through the
685
678
  // paint-floor coalescer as a leading-edge render sentinel so it shares the
686
679
  // single send chokepoint (a render supersedes any queued field patch for
@@ -688,7 +681,7 @@ export function createGlassesUiToolHandler(deps) {
688
681
  paintFloor.enqueue({
689
682
  surfaceId,
690
683
  sessionKey,
691
- patch: { __render: true, __depth: depth, __spec: validation.spec },
684
+ patch: { __render: true, __depth: wireDepth, __spec: validation.spec },
692
685
  });
693
686
 
694
687
  // Live-refresh path: kick off the cron in parallel. A `patch` onto an
@@ -697,10 +690,9 @@ export function createGlassesUiToolHandler(deps) {
697
690
  // content when this render carries a refresh.
698
691
  if (refreshValidated && !(update === "patch" && cronEngine.isActive(surfaceId))) {
699
692
  // Pre-warm the LLM API key cache so tick 1 doesn't see an empty key
700
- // and fail the smoke test. For non-LLM recipes this is a no-op.
701
- // For the claude-cli backend the resolved key is "" and not consumed
702
- // (the CLI auths via its own login), so the prewarm is a harmless
703
- // no-op there.
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.
704
696
  if (refreshValidated.recipe.kind === "llm" && typeof deps.prewarmLlmApiKey === "function") {
705
697
  const cfg = deps.getGlassesUiLiveConfig ? deps.getGlassesUiLiveConfig() : {};
706
698
  const agentModel =
@@ -812,6 +804,21 @@ export function createGlassesUiToolHandler(deps) {
812
804
  guard += 1;
813
805
  }
814
806
  }
807
+ if (
808
+ popCount === 0 &&
809
+ storeDepthBefore > 1 &&
810
+ surfaceStore.topSurfaceId(sessionKey) === ev.surfaceId
811
+ ) {
812
+ // Surface-match fallback (B6): a Back event reports the surfaceId being
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).
819
+ resumedParent = surfaceStore.popBack(sessionKey);
820
+ popCount += 1;
821
+ }
815
822
  // Push (newDepth > lastDepth) is already reflected in the store by the
816
823
  // agent's push render (applyRender), so it is intentionally a no-op here.
817
824
  emitLifecycle("nav_reconcile", "debug", {
@@ -827,19 +834,25 @@ export function createGlassesUiToolHandler(deps) {
827
834
  navDepthBySession.set(sessionKey, newDepth);
828
835
  }
829
836
 
837
+ // Stop crons, resolve pending calls with `outcome`, clear the session stack.
838
+ // Resolve pending + delete entries FIRST (so pending calls settle with
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) {
844
+ cronEngine.stopAllForSession(sessionKey, outcome);
845
+ const reaped = surfaceStore.drainSession(sessionKey, outcome);
846
+ surfaceStore.exit(sessionKey); // clear the stack (crons already stopped)
847
+ navDepthBySession.delete(sessionKey); // drop stale nav depth (stack is now 0)
848
+ return reaped;
849
+ }
850
+
830
851
  return {
831
852
  runDynamicUi,
832
853
  handleNavEvent,
833
854
  drainSession(sessionKey, outcome) {
834
- cronEngine.stopAllForSession(sessionKey, outcome);
835
- // Resolve pending + delete entries FIRST (so pending calls settle with
836
- // `outcome`), THEN clear the per-session stack. exit() deletes entries
837
- // without resolving, so it must NOT run before the drain or the pending
838
- // promises would hang.
839
- const reaped = surfaceStore.drainSession(sessionKey, outcome);
840
- surfaceStore.exit(sessionKey); // clear the stack (crons already stopped)
841
- navDepthBySession.delete(sessionKey); // drop stale nav depth (stack is now 0)
842
- return reaped;
855
+ return reapSession(sessionKey, outcome);
843
856
  },
844
857
  drainAll(outcome) {
845
858
  cronEngine.stopAll(outcome);
@@ -894,6 +907,13 @@ export const GLASSES_UI_TOOL_DESCRIPTION = [
894
907
  "exit-to-chat policy, the {{path|filter}} template + per-item {label,body}",
895
908
  "reference, and worked examples (including a live system-stats",
896
909
  "list_with_details surface). Keep this description lean; depth lives in the skill.",
910
+ "",
911
+ "After the call resolves, your NEXT output decides the glasses: another",
912
+ "render_glasses_ui replaces the surface (drill-down / next step); a short text",
913
+ "reply hands the screen back to chat (the surface disappears); a silent run-end",
914
+ "leaves the surface up until the user dismisses it. A \"back\" result means the",
915
+ "user wants to revise their previous answer — re-render it or pivot; after a",
916
+ "\"selected\" result, follow up with another render or a brief one-line ack.",
897
917
  ].join("\n");
898
918
 
899
919
  // Shared per-session depth counter. OpenClaw loads the plugin's register(api)
@@ -1067,10 +1087,28 @@ export function registerGlassesUiTool(api, service) {
1067
1087
  // Reconcile client nav-events with the SINGLE store stack (Task 16). On pop
1068
1088
  // the client reports the surfaceId now back on top + the post-pop depth; the
1069
1089
  // store knows it. The relay frame carries no sessionKey, so resolve it from
1070
- // the surface's store entry (sessionForSurface), falling back to "main".
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.)
1071
1096
  if (typeof service.onGlassesUiNavEvent === "function") {
1072
1097
  service.onGlassesUiNavEvent((ev) => {
1073
- const sessionKey = handler.sessionForSurface(ev.surfaceId) || "main";
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
+ }
1107
+ } catch (_) {
1108
+ // observability must never break the nav path
1109
+ }
1110
+ return;
1111
+ }
1074
1112
  handler.handleNavEvent(sessionKey, ev);
1075
1113
  });
1076
1114
  }