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.
- package/README.md +3 -1
- package/dist/config/runtime-config-session-title-model.test.js +22 -0
- package/dist/config/runtime-config.js +24 -15
- package/dist/domain/debug-store.js +18 -0
- package/dist/domain/glasses-display-system-prompt.js +52 -0
- package/dist/domain/glasses-display-system-prompt.test.js +44 -0
- package/dist/domain/glasses-ui-system-prompt.js +6 -22
- package/dist/domain/glasses-ui-system-prompt.test.js +13 -0
- package/dist/domain/prompt-channel-fragments.js +32 -0
- package/dist/domain/prompt-channel-fragments.test.js +70 -0
- package/dist/gateway/gateway-timing-ledger.js +15 -3
- package/dist/gateway/openclaw-client.js +80 -3
- package/dist/index.js +22 -0
- package/dist/runtime/channel-two-hook.js +36 -0
- package/dist/runtime/container-env.js +41 -0
- package/dist/runtime/display-toggle-states.js +98 -0
- package/dist/runtime/plugin-version-service.js +23 -0
- package/dist/runtime/register-session-title-distiller.js +100 -0
- package/dist/runtime/relay-core.js +307 -68
- package/dist/runtime/relay-service.js +120 -13
- package/dist/runtime/relay-worker-entry.js +26 -0
- package/dist/runtime/relay-worker-protocol.js +0 -4
- package/dist/runtime/relay-worker-supervisor.js +43 -79
- package/dist/runtime/relay-worker-transport.js +41 -0
- package/dist/runtime/session-service.js +159 -15
- package/dist/runtime/session-title-distiller-budget.js +36 -0
- package/dist/runtime/session-title-distiller-helpers.js +130 -0
- package/dist/runtime/session-title-distiller.js +354 -0
- package/dist/runtime/session-title-record.js +21 -0
- package/dist/runtime/stable-prompt-snapshot.js +119 -0
- package/dist/tools/glasses-ui-cron.js +9 -3
- package/dist/tools/glasses-ui-paint-floor.js +10 -3
- package/dist/tools/glasses-ui-recipes.js +13 -178
- package/dist/tools/glasses-ui-surfaces.js +8 -1
- package/dist/tools/glasses-ui-tool-description.test.js +16 -0
- package/dist/tools/glasses-ui-tool.js +98 -60
- package/dist/tools/session-title-tool.js +14 -76
- package/dist/tools/session-title-tool.test.js +53 -0
- package/dist/version.js +2 -2
- package/openclaw.plugin.json +9 -0
- package/package.json +6 -4
- package/skills/glasses-ui/SKILL.md +163 -0
- package/dist/runtime/downstream-server.js +0 -2057
- package/dist/runtime/plugin-update-service.js +0 -216
|
@@ -1,53 +1,17 @@
|
|
|
1
|
-
// Recipe executors for glasses_ui_refresh.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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 {
|
|
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
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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 {
|
|
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
|
-
|
|
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: {
|
|
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 !== "
|
|
96
|
-
return { ok: false, code: "refresh_invalid_recipe", message: `recipe.kind must be
|
|
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 === "
|
|
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/
|
|
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
|
-
?
|
|
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 || "
|
|
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:
|
|
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
|
-
//
|
|
702
|
-
//
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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
|
}
|