ocuclaw 1.3.0 → 1.3.1

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.
@@ -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 };
@@ -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) : "",
@@ -697,10 +662,9 @@ export function createGlassesUiToolHandler(deps) {
697
662
  // content when this render carries a refresh.
698
663
  if (refreshValidated && !(update === "patch" && cronEngine.isActive(surfaceId))) {
699
664
  // 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.
665
+ // and fail the smoke test. For non-LLM recipes this is a no-op. All llm
666
+ // backends are HTTP API backends that resolve a key via host modelAuth;
667
+ // a missing key degrades to a graceful recipe_failed on tick 1.
704
668
  if (refreshValidated.recipe.kind === "llm" && typeof deps.prewarmLlmApiKey === "function") {
705
669
  const cfg = deps.getGlassesUiLiveConfig ? deps.getGlassesUiLiveConfig() : {};
706
670
  const agentModel =
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
- export const PLUGIN_VERSION = "1.3.0";
2
- export const REQUIRES_CLIENT_VERSION = "1.3.0";
1
+ export const PLUGIN_VERSION = "1.3.1";
2
+ export const REQUIRES_CLIENT_VERSION = "1.3.1";
package/package.json CHANGED
@@ -1,14 +1,15 @@
1
1
  {
2
2
  "name": "ocuclaw",
3
- "version": "1.3.0",
4
- "requiresClientVersion": "1.3.0",
3
+ "version": "1.3.1",
4
+ "requiresClientVersion": "1.3.1",
5
5
  "description": "OcuClaw for Even Realities G2 smart glasses.",
6
6
  "type": "module",
7
7
  "main": "./dist/index.js",
8
8
  "files": [
9
9
  "README.md",
10
10
  "dist/",
11
- "openclaw.plugin.json"
11
+ "openclaw.plugin.json",
12
+ "skills/"
12
13
  ],
13
14
  "keywords": [
14
15
  "even",
@@ -0,0 +1,156 @@
1
+ ---
2
+ name: glasses-ui
3
+ description: Authoring guide for render_glasses_ui surfaces on Even G2 glasses. Load BEFORE building any live/refreshing surface, per-item detail list, or multi-screen flow — covers the capability-tier ladder (system-stats host metrics, http data), the patch/replace/push moves and exit-to-chat policy, recipe recon, per-item {label,body} templates, and worked examples.
4
+ user-invocable: false
5
+ ---
6
+
7
+ # Authoring glasses surfaces with `render_glasses_ui`
8
+
9
+ `render_glasses_ui` paints an interactive surface on the user's Even G2 HUD instead of a text reply. The call blocks until the user selects, dismisses, or backs out. This skill is the source of truth for **authoring** surfaces; the tool description is deliberately lean.
10
+
11
+ ## Surface kinds
12
+
13
+ | kind | use it for | caps |
14
+ |---|---|---|
15
+ | `text_surface` | one formatted read-only block | body ≤ 1000 chars; optional `title` ≤ 64 |
16
+ | `list_surface` | a short pickable list, label-only | ≤ 20 items × ≤ 64 chars |
17
+ | `list_with_details_surface` | a pickable list where each item carries a detail body shown as the user scrolls | label ≤ 64, body ≤ 200; total of all bodies ≤ 6144 |
18
+
19
+ `list_with_details` items are `{ label, body }` objects (`label` required, `body` optional). A bare string is treated as a label-only item. Use it when each option needs a 1–2 sentence compare-before-choosing detail — all bodies ship in one call, so the user browses with zero round-trips.
20
+
21
+ ## Capability tiers — pick the lowest tier that answers the need
22
+
23
+ The refresh recipe runs at a capability tier. **Always pick the lowest tier that answers the need.**
24
+
25
+ - **L0 — `http` (data APIs).** In-process, SSRF-guarded fetch → template → surface. For pure data: scores, status APIs, quotes, weather. **Not currently enableable on this install:** `http` is gated by `glassesUiLive.httpEnabled`, and that opt-in isn't reachable yet (the plugin's config-schema omits the `glassesUiLive` block — tracked as a separate fix). Until that lands, an `http` refresh is **rejected (refresh disabled)** — do not author one expecting it to run. It is documented here so you know the tier exists and is the right pick once enabled.
26
+ - **L0′ — `system-stats` (host metrics).** Built-in, in-process `node:os` reader (RAM/CPU/load). **No operator gate** — governed only by the master `glassesUiLive.enabled` (on by default). **This is the only refresh tier that runs without operator config today.** Reach for it for anything host-metric.
27
+ - **Reasoning / judgment tier (`agent`, L1/L2) — designed, not yet available.** A future tier where a sandboxed subagent interprets a page or local files on a timer. It is design-gated (credential-isolation + containment spike) and **not built** — there is no `agent` recipe kind. For a surface that needs interpretation *right now*, render it **once from your own turn** (compose the content yourself, render a static `text_surface`/list). A *self-refreshing* reasoning surface needs an agent in the loop, which isn't wired yet; a live reasoning tier is coming.
28
+
29
+ ### `system-stats` output fields (the `{{path}}` sources)
30
+
31
+ `memTotalMb`, `memUsedMb`, `memFreeMb`, `memUsedPct`, `cpuPct`, `loadAvg1`. Optional recipe param `sampleWindowMs` (50–1000, default 200) sizes the CPU-sample window.
32
+
33
+ ## The four moves
34
+
35
+ `render_glasses_ui` takes an optional `update` telling it how this render relates to the current surface. **Default is `replace`.**
36
+
37
+ | move | what it does | when |
38
+ |---|---|---|
39
+ | `patch` | edit *some fields* of the current screen; the refresh cron **keeps ticking** | a partial in-place edit |
40
+ | `replace` *(default)* | swap the *whole content* of the current screen in place; **no back-target** | new content, no going back |
41
+ | `push` | stack a *new child screen*; the parent is retained and **its cron pauses** (resumes on Back) | new content, the user can go back |
42
+ | exit to chat | *not a param* — just end your turn with a short text reply; the chat screen takes over and the surface disappears | you're done; respond in chat |
43
+
44
+ **One-line rule:** partial edit → `patch`; new content, no going back → `replace`; new content, can go back → `push`; done → exit to chat.
45
+
46
+ **Exit-to-chat policy:** on a *deliberate* exit (the user backs past the root, or you're finished), the default is to **respond in chat and not reflexively re-surface**. You retain the capability to surface again later in the conversation — just don't bounce a new surface up reflexively. On an *in-stack* Back (depth ≥ 2), the client transparently restores the parent and its cron resumes; you don't need to re-render it.
47
+
48
+ > **Don't preempt yourself.** Omitting `update` means `replace` — it swaps your current surface in place. To drill into a detail *without losing the list*, use `push`. (Back restores the parent **surface** and re-fires its cron; it does not restore list scroll position.)
49
+
50
+ ## Live refresh: recipe recon (validate-then-commit)
51
+
52
+ Add a `refresh` block to make a surface self-update: `{ recipe, intervalMs, targets, onError?, maxDurationMs?, maxConsecutiveFailures? }`. `intervalMs` ≥ 1000.
53
+
54
+ When you submit a render carrying `refresh`, the plugin runs an **initial smoke-test tick before the surface commits**. If that tick fails, the call resolves with `result: "recipe_failed"` and a `failureReason` string — **read it, fix the recipe, and retry**; the surface and cron only start on success. This is your recon loop: don't guess twice, read the failure.
55
+
56
+ `targets` maps recipe output → display:
57
+ - `targets.body` — a string template for `text_surface`.
58
+ - `targets.items` — an array of templates for list surfaces; each entry is either a string (label-only) or `{ label, body }`. The **whole array** is emitted each tick. **Labels are templated too**, so keep static labels as plain literals (no `{{…}}`) — only put `{{…}}` in the parts that should change.
59
+
60
+ ### Template filters
61
+
62
+ `{{path}}` reads a value (`{{output}}` for the raw recipe output). Chain filters left-to-right:
63
+
64
+ `trim` · `upper` · `lower` · `int` · `round:N` · `percent` (×100, append %) · `truncate:N` · `default:"--"` · `prefix:"+"` · `minus:previous.value` · `plus:previous.value`. Use `previous.*` paths for deltas vs the prior tick.
65
+
66
+ ## Worked examples
67
+
68
+ ### 1. Live host stats — `system-stats` text_surface (runs today)
69
+
70
+ ```js
71
+ render_glasses_ui({
72
+ kind: "text_surface",
73
+ title: "Host",
74
+ body: "RAM — CPU —",
75
+ refresh: {
76
+ recipe: { kind: "system-stats" },
77
+ intervalMs: 2000,
78
+ targets: { body: "RAM {{memUsedPct | round:0}}% CPU {{cpuPct | round:0}}% Load {{loadAvg1 | round:2}}" }
79
+ }
80
+ })
81
+ ```
82
+
83
+ ### 2. Live host stats — `system-stats` list_with_details (the canonical interactive example, runs today)
84
+
85
+ ```js
86
+ render_glasses_ui({
87
+ kind: "list_with_details_surface",
88
+ items: [
89
+ { label: "Memory", body: "—" },
90
+ { label: "CPU", body: "—" },
91
+ { label: "Load", body: "—" }
92
+ ],
93
+ refresh: {
94
+ recipe: { kind: "system-stats" },
95
+ intervalMs: 2000,
96
+ targets: {
97
+ items: [
98
+ { label: "Memory", body: "{{memUsedMb}} / {{memTotalMb}} MB ({{memUsedPct | round:0}}%)" },
99
+ { label: "CPU", body: "{{cpuPct | round:1}}% busy" },
100
+ { label: "Load", body: "{{loadAvg1 | round:2}} (1-min avg)" }
101
+ ]
102
+ }
103
+ }
104
+ })
105
+ // Labels are static literals (don't re-render); bodies tick every 2s.
106
+ ```
107
+
108
+ ### 3. `push` drill-down (don't preempt the list)
109
+
110
+ ```js
111
+ // User highlights "Memory" and asks for detail → stack a child, keep the live list underneath.
112
+ render_glasses_ui({
113
+ kind: "text_surface",
114
+ title: "Memory detail",
115
+ body: "Used … of … MB …", // compose from what you know, or give the child its own system-stats refresh
116
+ update: "push"
117
+ })
118
+ // The parent list's cron pauses while the child is up; on Back it staleness-resumes.
119
+ ```
120
+
121
+ ### 4. `http` data (designed; NOT enableable on this install — shown for completeness)
122
+
123
+ ```js
124
+ // L0 http — DATA APIs. Requires operator opt-in glassesUiLive.httpEnabled, which is
125
+ // NOT currently reachable (config-schema gap). This will be REJECTED today — prefer system-stats.
126
+ render_glasses_ui({
127
+ kind: "text_surface",
128
+ title: "AAPL",
129
+ body: "—",
130
+ refresh: {
131
+ recipe: { kind: "http", url: "https://api.example.com/quote/AAPL", jsonPath: "$.data" },
132
+ intervalMs: 30000,
133
+ targets: { body: "AAPL {{price}} ({{change | round:1 | prefix:\"+\"}})" }
134
+ }
135
+ })
136
+ ```
137
+
138
+ ## Outcomes (the `result` you get back)
139
+
140
+ | result | meaning |
141
+ |---|---|
142
+ | `selected` | user picked a list item; `selected_index` + `selected_text` returned |
143
+ | `back` | user double-tapped above the root; they want to revise — re-render the previous step or pivot |
144
+ | `dismissed` | dismissed at root, or no selection made |
145
+ | `timeout` | no interaction within the window (non-refresh default 30 min; refresh ends at `maxDurationMs`) |
146
+ | `recipe_failed` | refresh only — initial smoke tick failed, the consecutive-failure breaker fired, or `onError:stop`; `failureReason` carries the last error |
147
+ | `glasses_disconnected` | refresh only — the glasses client dropped mid-cron |
148
+
149
+ Refresh results also carry: `ticks: { count, succeeded, failed, lastSuccessAt, lastFailureAt? }`, `lastBody`, `lastItems`, and `failureReason` (on `recipe_failed`).
150
+
151
+ ## Quick reference
152
+
153
+ - Pick the **lowest tier**: host metrics → `system-stats`; pure data API → `http` (not enabled yet). Needs interpretation → render once from your turn.
154
+ - `update` default is `replace` (in-place). Use `push` to drill in without losing the parent; `patch` to edit fields while the cron keeps ticking.
155
+ - `intervalMs` ≥ 1000. Read `failureReason` on `recipe_failed` and fix the recipe.
156
+ - After a surface resolves, a short text reply exits to chat; another render replaces; silence lets it linger.