ocuclaw 1.3.3 → 1.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +29 -1
  2. package/dist/config/runtime-config-session-title-model.test.js +0 -3
  3. package/dist/config/runtime-config.js +22 -33
  4. package/dist/domain/activity-status-adapter.js +0 -7
  5. package/dist/domain/activity-status-arbiter.js +3 -27
  6. package/dist/domain/activity-status-labels.js +8 -38
  7. package/dist/domain/code-span-regions.js +4 -24
  8. package/dist/domain/constant-time-equal.js +9 -0
  9. package/dist/domain/constant-time-equal.test.js +28 -0
  10. package/dist/domain/conversation-state.js +27 -138
  11. package/dist/domain/debug-bundle-cache.js +52 -0
  12. package/dist/domain/debug-bundle-format.js +60 -0
  13. package/dist/domain/debug-bundle-preview.js +123 -0
  14. package/dist/domain/debug-bundle-redaction.js +182 -0
  15. package/dist/domain/debug-bundle-save.js +11 -0
  16. package/dist/domain/debug-bundle-zip.js +15 -0
  17. package/dist/domain/debug-bundle.js +97 -0
  18. package/dist/domain/debug-store.js +6 -17
  19. package/dist/domain/debug-upload-preset.js +27 -0
  20. package/dist/domain/glasses-display-system-prompt.js +0 -5
  21. package/dist/domain/glasses-display-system-prompt.test.js +1 -1
  22. package/dist/domain/glasses-ui-content-summary.js +0 -6
  23. package/dist/domain/glasses-ui-system-prompt.test.js +1 -2
  24. package/dist/domain/message-emoji-allowlist.js +0 -7
  25. package/dist/domain/message-emoji-filter.js +3 -9
  26. package/dist/domain/neural-emoji-reactor-tag-config.js +3 -3
  27. package/dist/domain/prompt-channel-fragments.js +1 -10
  28. package/dist/domain/tagged-span-parser.js +3 -26
  29. package/dist/domain/tagged-span-strip.js +0 -7
  30. package/dist/even-ai/even-ai-endpoint.js +77 -24
  31. package/dist/even-ai/even-ai-run-waiter.js +0 -1
  32. package/dist/even-ai/even-ai-settings-store.js +11 -0
  33. package/dist/gateway/gateway-bridge.js +8 -9
  34. package/dist/gateway/gateway-timing-ledger.js +8 -6
  35. package/dist/gateway/openclaw-client.js +97 -297
  36. package/dist/gateway/sanitize-connect-reason.js +10 -0
  37. package/dist/gateway/sanitize-connect-reason.test.js +34 -0
  38. package/dist/index.js +3 -3
  39. package/dist/runtime/channel-two-hook.js +1 -6
  40. package/dist/runtime/container-env.js +1 -5
  41. package/dist/runtime/debug-bundle-handler.js +159 -0
  42. package/dist/runtime/display-toggle-states.js +6 -17
  43. package/dist/runtime/downstream-handler.js +682 -508
  44. package/dist/runtime/glasses-backpressure-latch.js +2 -24
  45. package/dist/runtime/ocuclaw-settings-store.js +10 -1
  46. package/dist/runtime/openclaw-host-version.js +5 -0
  47. package/dist/runtime/plugin-version-service.js +13 -6
  48. package/dist/runtime/provider-usage-select.js +0 -6
  49. package/dist/runtime/register-session-title-distiller.js +14 -16
  50. package/dist/runtime/relay-core.js +601 -290
  51. package/dist/runtime/relay-service.js +19 -47
  52. package/dist/runtime/relay-worker-approval-replay-cache.js +1 -1
  53. package/dist/runtime/relay-worker-entry.js +1 -2
  54. package/dist/runtime/relay-worker-health.js +2 -10
  55. package/dist/runtime/relay-worker-protocol.js +6 -1
  56. package/dist/runtime/relay-worker-supervisor.js +103 -41
  57. package/dist/runtime/relay-worker-transport.js +150 -17
  58. package/dist/runtime/session-context-service.js +5 -45
  59. package/dist/runtime/session-service.js +157 -175
  60. package/dist/runtime/session-title-distiller-budget.js +1 -5
  61. package/dist/runtime/session-title-distiller-helpers.js +14 -24
  62. package/dist/runtime/session-title-distiller.js +109 -122
  63. package/dist/runtime/session-title-record.js +0 -6
  64. package/dist/runtime/stable-prompt-snapshot.js +3 -14
  65. package/dist/runtime/upstream-runtime.js +600 -103
  66. package/dist/tools/device-info-tool.js +4 -21
  67. package/dist/tools/glasses-ui-cron.js +22 -77
  68. package/dist/tools/glasses-ui-descriptors.js +4 -33
  69. package/dist/tools/glasses-ui-limits.js +0 -13
  70. package/dist/tools/glasses-ui-paint-floor.js +5 -39
  71. package/dist/tools/glasses-ui-recipes.js +92 -101
  72. package/dist/tools/glasses-ui-surfaces.js +31 -163
  73. package/dist/tools/glasses-ui-template.js +7 -22
  74. package/dist/tools/glasses-ui-tool-description.test.js +2 -2
  75. package/dist/tools/glasses-ui-tool.js +87 -451
  76. package/dist/tools/glasses-ui-voicemail.js +6 -63
  77. package/dist/tools/glasses-ui-wake.js +9 -76
  78. package/dist/tools/session-title-tool.js +2 -7
  79. package/dist/tools/session-title-tool.test.js +1 -1
  80. package/dist/version.js +3 -2
  81. package/openclaw.plugin.json +60 -13
  82. package/package.json +3 -2
  83. package/dist/runtime/protocol-adapter.js +0 -387
package/README.md CHANGED
@@ -46,6 +46,18 @@ openclaw config set plugins.entries.ocuclaw.config.evenAiToken "your-even-ai-tok
46
46
 
47
47
  > **Note:** When `evenAiEnabled` is `true`, `evenAiToken` is required. Config validation will reject the change if you enable Even AI without setting the token.
48
48
 
49
+ Optional Even AI tuning (only used when `evenAiEnabled` is `true`):
50
+
51
+ - `evenAiRoutingMode`: `active` routes through the current session (default), `background` reuses a dedicated background session, `background_new` starts a fresh background session per request.
52
+ - `evenAiSystemPrompt`: Extra system prompt appended to Even AI runs only.
53
+
54
+ ```bash
55
+ openclaw config set plugins.entries.ocuclaw.config.evenAiRoutingMode "active"
56
+ openclaw config set plugins.entries.ocuclaw.config.evenAiSystemPrompt "your-extra-prompt"
57
+ ```
58
+
59
+ > **Note:** These two seed the Even AI settings on first boot. If you use the OcuClaw glasses client or phone WebUI, the in-app Even AI settings editor takes over afterward, and later changes to these config keys won't affect live behaviour unless the stored settings are reset. For deployments that use **only** the direct Even Realities Even AI pathway — never launching the OcuClaw client — these keys are the only way to configure routing mode and system prompt. `evenAiSystemPrompt` has no glasses-side editor, so set it via the phone WebUI or config.
60
+
49
61
  Advanced optional settings:
50
62
 
51
63
  ```bash
@@ -53,8 +65,24 @@ openclaw config set plugins.entries.ocuclaw.config.wsBind "127.0.0.1"
53
65
  # wsPort default is 9000; on Windows that port is often reserved by WinNAT, so the
54
66
  # setup assistant uses 47800. Pick any free port in 30000-49151 if you override it.
55
67
  openclaw config set plugins.entries.ocuclaw.config.wsPort 47800 --strict-json
56
- openclaw config set plugins.entries.ocuclaw.config.sessionLimit 10 --strict-json
68
+ # Recent sessions fetched for the WebUI switcher/search list (default 80). Glasses
69
+ # clamp to their own item-count cap, so this only widens the WebUI list.
70
+ openclaw config set plugins.entries.ocuclaw.config.sessionLimit 80 --strict-json
71
+ # Optional model override ("provider/model") for the background session-title
72
+ # distiller. This is a lightweight background task, so a small, fast, inexpensive
73
+ # model is a good choice (e.g. Anthropic's Haiku) — it keeps title generation off
74
+ # your main model's tokens and latency. Leave unset to use your normal model.
75
+ openclaw config set plugins.entries.ocuclaw.config.sessionTitleModel "anthropic/claude-haiku-4-5"
76
+ # How long render_glasses_ui waits for a user pick before resolving { result: "timeout" }.
77
+ # Default 1800000 (30 minutes); 0 disables the timeout (infinite wait).
78
+ openclaw config set plugins.entries.ocuclaw.config.renderGlassesUiTimeoutMs 1800000 --strict-json
79
+ # How long (ms) a fresh agent summary outranks a tool label in the glasses activity
80
+ # status. Default 5000, clamped to 3000-8000.
81
+ openclaw config set plugins.entries.ocuclaw.config.freshnessWindowMs 5000 --strict-json
82
+ # Debug tooling (only relevant when externalDebugToolsEnabled is true):
57
83
  openclaw config set plugins.entries.ocuclaw.config.externalDebugToolsEnabled true --strict-json
84
+ # Per-channel filters that suppress or sample noisy debug events.
85
+ openclaw config set plugins.entries.ocuclaw.config.debugNoisyPolicies '{}' --strict-json
58
86
  ```
59
87
 
60
88
  Run `openclaw plugins inspect ocuclaw` to see all settings with their descriptions and defaults.
@@ -2,9 +2,6 @@ import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import { createRuntimeConfig } from "./runtime-config.ts";
4
4
 
5
- // Minimal valid config: relayToken (pluginConfig) + a gateway auth token
6
- // (openclawConfig) — createRuntimeConfig throws without a resolvable
7
- // gatewayUrl/gatewayToken (gatewayUrl defaults to ws://127.0.0.1:18789).
8
5
  const base = { relayToken: "tok" };
9
6
  const openclawConfig = { gateway: { auth: { token: "gw" } } };
10
7
 
@@ -18,6 +18,15 @@ function parseIntOrDefault(value, defaultValue) {
18
18
  return parsed;
19
19
  }
20
20
 
21
+ function clampInt(value, min, max, fallback) {
22
+ const n = Number(value);
23
+ if (!Number.isFinite(n)) return fallback;
24
+ const rounded = Math.floor(n);
25
+ if (rounded < min) return min;
26
+ if (rounded > max) return max;
27
+ return rounded;
28
+ }
29
+
21
30
  function parseEvenAiRoutingMode(value) {
22
31
  return normalizeEvenAiRoutingMode(value);
23
32
  }
@@ -97,19 +106,6 @@ function resolveDebugNoisyPolicies(pluginValue, envValue) {
97
106
  return parseJsonOrUndefined(envValue, "debugNoisyPolicies");
98
107
  }
99
108
 
100
- // Supported live-refresh LLM backends — both are HTTP API backends. The two
101
- // CLI-spawn backends were removed: codex-cli because Codex's read-only sandbox
102
- // still permits filesystem reads (an agent prompt could exfil ~/.aws/credentials,
103
- // ~/.ssh/*, etc. via stdout into the glasses surface), and claude-cli to remove
104
- // the plugin's last child_process spawn so the OpenClaw installer's static
105
- // dangerous-code scanner passes without --dangerously-force-unsafe-install. (The
106
- // scanner can't see that --tools "" made the CLI tool-less; it just sees a spawn.
107
- // This is not an exploit claim about claude-cli — it removes spawn surface and
108
- // clears the static block.) The proper agentic tier — delegating ticks to the
109
- // native OpenClaw runtime instead of spawning a CLI — is tracked separately (the
110
- // glasses-ui L1/L2 delegation redesign) and blocked on request-scoped
111
- // api.runtime.subagent.run. Operators who want Claude/Codex point an *-api
112
- // backend at the provider endpoint (key resolved via the host modelAuth).
113
109
  const GLASSES_UI_LIVE_BACKENDS = new Set([
114
110
  "anthropic-api",
115
111
  "openai-compat",
@@ -122,8 +118,7 @@ const GLASSES_UI_LIVE_DEFAULT_MODEL = {
122
118
 
123
119
  function resolveGlassesUiLive(value) {
124
120
  const raw = isObject(value) ? value : {};
125
- // Unknown/removed backends (incl. a stale tickBackend: "claude-cli" or
126
- // "codex-cli" from an operator's pre-removal config) coerce to this default.
121
+
127
122
  const tickBackend = GLASSES_UI_LIVE_BACKENDS.has(raw.tickBackend)
128
123
  ? raw.tickBackend
129
124
  : "anthropic-api";
@@ -136,20 +131,13 @@ function resolveGlassesUiLive(value) {
136
131
  tickApiBaseUrl,
137
132
  allowAgentModelOverride: parseBool(raw.allowAgentModelOverride, false),
138
133
  tickMaxOutputTokens: parseIntOrDefault(raw.tickMaxOutputTokens, 200),
139
- // http recipes issue agent-influenced outbound network requests on a
140
- // schedule. The recipe executor blocks loopback/RFC1918/link-local
141
- // destinations and resolves hostnames through an SSRF-safe dispatcher,
142
- // but the capability — "the plugin's gateway host can fetch arbitrary
143
- // public URLs the agent chooses" — is itself worth an operator opt-in.
144
- // Default to disabled; set
145
- // plugins.entries.ocuclaw.config.glassesUiLive.httpEnabled = true to
146
- // enable. The dispatcher protection still applies once enabled.
134
+
147
135
  httpEnabled: parseBool(raw.httpEnabled, false),
148
- // llm ticks are agent-influenced model calls on a schedule (token spend +
149
- // hallucinated-display risk); the tier is deliberately untaught in the
150
- // skill, and an enabled-but-undocumented capability is the worst of both.
151
- // Operator opt-in like httpEnabled (user decision 2026-06-10); the L1/L2
152
- // agent tier is the sanctioned path to reasoning surfaces when it lands.
136
+
137
+ httpAllowHosts: Array.isArray(raw.httpAllowHosts)
138
+ ? raw.httpAllowHosts.filter((h) => typeof h === "string")
139
+ : [],
140
+
153
141
  llmEnabled: parseBool(raw.llmEnabled, false),
154
142
  maxConcurrentSurfacesPerHost: parseIntOrDefault(raw.maxConcurrentSurfacesPerHost, 4),
155
143
  };
@@ -206,12 +194,9 @@ export function createRuntimeConfig(opts = {}) {
206
194
  relayToken,
207
195
  wsBind: pickString(pluginConfig.wsBind, "127.0.0.1"),
208
196
  wsPort: parseIntOrDefault(pickValue(pluginConfig.wsPort), 9000),
209
- sessionLimit: parseIntOrDefault(pickValue(pluginConfig.sessionLimit), 10),
197
+ sessionLimit: parseIntOrDefault(pickValue(pluginConfig.sessionLimit), 80),
210
198
  sonioxApiKey: pickString(pluginConfig.sonioxApiKey),
211
- debugPayloadMaxBytes: parseIntOrDefault(
212
- pickValue(pluginConfig.debugPayloadMaxBytes),
213
- 2048,
214
- ),
199
+ cartesiaApiKey: pickString(pluginConfig.cartesiaApiKey),
215
200
  debugNoisyPolicies: resolveDebugNoisyPolicies(
216
201
  pluginConfig.debugNoisyPolicies,
217
202
  undefined,
@@ -220,6 +205,10 @@ export function createRuntimeConfig(opts = {}) {
220
205
  pluginConfig.externalDebugToolsEnabled,
221
206
  false,
222
207
  ),
208
+ allowDebugUpload: parseBool(pluginConfig.allowDebugUpload, false),
209
+ debugUploadMaxZipBytes: clampInt(pluginConfig.debugUploadMaxZipBytes, 100_000, 4_300_000, 4_000_000),
210
+ debugUploadCapturePreset: Array.isArray(pluginConfig.debugUploadCapturePreset) ? pluginConfig.debugUploadCapturePreset : undefined,
211
+ debugBundleSaveDir: pluginConfig.debugBundleSaveDir || "",
223
212
  evenAiEnabled,
224
213
  evenAiToken,
225
214
  evenAiSystemPrompt: pickString(pluginConfig.evenAiSystemPrompt),
@@ -251,7 +251,6 @@ function createActivityStatusAdapter(opts) {
251
251
  const now =
252
252
  typeof options.now === "function" ? options.now : () => Date.now();
253
253
 
254
- /** @type {Map<string, {seq: number, toolStartCount: number, currentActivityId: string|null, toolContextByActivityId: Map<string, {label: string, detail: string|null, category: string|null, intent: string|null}>}>} */
255
254
  const runStates = new Map();
256
255
 
257
256
  function getRunState(runKey) {
@@ -381,8 +380,6 @@ function createActivityStatusAdapter(opts) {
381
380
  detail = resolvedThinking.detail;
382
381
  }
383
382
 
384
- // Agent-authored summaries get a clamp-only shortLabel when the
385
- // 64-char header budget needs it; generic "Thinking..." never does.
386
383
  if (label && isExplanatoryThinkingLabel(label) && label.length > SHORT_LABEL_MAX_CHARS) {
387
384
  shortLabel = label;
388
385
  }
@@ -505,10 +502,6 @@ function createActivityStatusAdapter(opts) {
505
502
  result.candidateAtMs = now();
506
503
  result.freshnessWindowMs = freshnessWindowMs;
507
504
 
508
- // Association ids (optional, normalized): trim, or drop when empty. The
509
- // {...activity} spread already copies them, but normalize at the contract
510
- // boundary so untrusted callers can't leak whitespace/empty ids. See
511
- // transport spec docs/superpowers/specs/2026-06-01-...-redesign-design.md §4/§11.
512
505
  if (typeof activity.toolCallId === "string" && activity.toolCallId.trim()) {
513
506
  result.toolCallId = activity.toolCallId.trim();
514
507
  } else {
@@ -1,18 +1,9 @@
1
- // Pure ladder-ranking logic for the activity-status arbiter (Plan 2 core).
2
- //
3
- // LEAF MODULE: it may import small shared leaf helpers, but nothing it imports
4
- // may import it back. The CJS build (scripts/build.mjs#emitCjsFile) appends
5
- // `module.exports = {...}` at EOF and converts imports to eager in-place
6
- // requires, so a bidirectional import would resolve to `{}` mid-cycle and
7
- // crash at call time. Keep this module a strict leaf.
8
-
9
1
  const RANK_INTERVENTION = "intervention";
10
2
  const RANK_GENERATED_SUMMARY = "generated_summary";
11
3
  const RANK_TOOL = "tool";
12
4
  const RANK_GENERIC_THINKING = "generic_thinking";
13
5
  const RANK_QUIET = "quiet";
14
6
 
15
- // Ladder order, highest first. Matches spec §5.
16
7
  const RANKS = [
17
8
  RANK_INTERVENTION,
18
9
  RANK_GENERATED_SUMMARY,
@@ -30,16 +21,6 @@ function isInterventionSignal(s) {
30
21
  );
31
22
  }
32
23
 
33
- // Classify an already-resolved activity into its ladder rank.
34
- // The branch order IS the ladder order. `intervention` and `tool` are
35
- // rankable regardless of includeThinking; the two thinking-derived ranks
36
- // (`generated_summary` and `generic_thinking`) are both gated by
37
- // `includeThinking === true` (spec §7.4: `includeThinking = false` →
38
- // no `generated_summary` override and no `generic_thinking` fallback).
39
- //
40
- // NOTE: the guard here enforces spec §7.4 directly — callers may pass
41
- // any value for `thinkingSummarySource` regardless of `includeThinking`
42
- // and will always get the correct answer.
43
24
  function classifyRank(signals) {
44
25
  const s = signals || {};
45
26
  if (isInterventionSignal(s)) return RANK_INTERVENTION;
@@ -57,8 +38,6 @@ function classifyRank(signals) {
57
38
  return RANK_QUIET;
58
39
  }
59
40
 
60
- // Exact generic stems that must never outrank a tool, even from a
61
- // summary/bold source. Compared against the normalized whole label.
62
41
  const GENERIC_SUMMARY_DENYLIST = new Set([
63
42
  "thinking",
64
43
  "working",
@@ -77,14 +56,11 @@ function normalizeSummaryLabel(label) {
77
56
  if (typeof label !== "string") return "";
78
57
  return label
79
58
  .replace(/\*\*/g, "")
80
- .replace(/[.…]+$/g, "") // trailing dots / ellipsis
81
- .trim() // leading/trailing whitespace
59
+ .replace(/[.…]+$/g, "")
60
+ .trim()
82
61
  .toLowerCase();
83
62
  }
84
63
 
85
- // A summary may outrank a tool only if it is an authored, concise, specific
86
- // signal: source ∈ {summary, bold}, non-empty, not a generic stem, and not a
87
- // bare verb with no object.
88
64
  function evaluateSummaryEligibility(thinkingSummarySource, label) {
89
65
  if (thinkingSummarySource !== "summary" && thinkingSummarySource !== "bold") {
90
66
  return false;
@@ -92,7 +68,7 @@ function evaluateSummaryEligibility(thinkingSummarySource, label) {
92
68
  const normalized = normalizeSummaryLabel(label);
93
69
  if (!normalized) return false;
94
70
  if (GENERIC_SUMMARY_DENYLIST.has(normalized)) return false;
95
- // bare verb / single token = no object
71
+
96
72
  if (!/\s/.test(normalized)) return false;
97
73
  return true;
98
74
  }
@@ -1,11 +1,6 @@
1
1
  import path from "node:path";
2
2
 
3
3
  const DEFAULT_MAX_LABEL_CHARS = 120;
4
- // No plugin-side preview clipping below the overall label budget: the client's
5
- // display-width clip (pretext pixel truncation on glasses, Compose ellipsis on
6
- // phone) is the final physical backstop — see the activity-status redesign spec
7
- // §8.2. The old 30-char TOOL_PREVIEW_CHARS cap pre-truncated previews well under
8
- // the glasses line's real pixel capacity, leaving ~90-170px of dead space.
9
4
 
10
5
  const REDACT_QUERY_KEYS = "(token|access_token|api_key|key|password|secret)";
11
6
 
@@ -126,7 +121,7 @@ function filenameFromPath(pathValue) {
126
121
  const normalized = cleaned.replace(/[;,)]+$/g, "");
127
122
  if (!normalized) return null;
128
123
  if (isNullishToken(normalized)) return null;
129
- // Ignore shell variable/subshell paths like "$f", "${file}", or "$(mktemp)".
124
+
130
125
  if (/\$[({]?[A-Za-z_][A-Za-z0-9_]*[)}]?/.test(normalized) || /\$\(.+\)/.test(normalized)) {
131
126
  return null;
132
127
  }
@@ -277,14 +272,14 @@ function unwrapShellCommand(raw) {
277
272
  let payload = match[2].trim();
278
273
  const quote = payload.charAt(0);
279
274
  if (quote === '"' || quote === "'") {
280
- if (payload.length < 2 || !payload.endsWith(quote)) break; // unbalanced -- keep original
275
+ if (payload.length < 2 || !payload.endsWith(quote)) break;
281
276
  payload = payload.slice(1, -1);
282
277
  if (quote === '"') payload = payload.replace(/\\(["\\$`])/g, "$1");
283
278
  }
284
279
  if (!payload.trim()) break;
285
280
  cmd = payload.trim();
286
281
  }
287
- // Strip ONE leading `cd <dir> &&` prefix so the real command classifies.
282
+
288
283
  const cdMatch = cmd.match(/^cd\s+[^;&|]+&&\s*([\s\S]+)$/);
289
284
  if (cdMatch) cmd = cdMatch[1].trim();
290
285
  return cmd;
@@ -295,11 +290,6 @@ const SHELL_KEYWORDS = new Set([
295
290
  "while", "for", "until", "case", "esac", "{", "}", "(", ")", "!", "time",
296
291
  ]);
297
292
 
298
- // Split into command-position token lists: one list per &&/||/;/|/
299
- // newline segment, with shell keywords, [ ... ] tests, and leading
300
- // VAR=value assignments stripped. Quoted separators are not shell-parsed
301
- // (deterministic v1); the head token of the first segment -- which drives
302
- // classification -- is unaffected by that limitation.
303
293
  function commandSegments(command) {
304
294
  const out = [];
305
295
  for (const rawSeg of String(command || "").split(/&&|\|\||[;|\n]/)) {
@@ -347,7 +337,7 @@ function classifyExecSegment(tokens) {
347
337
  if (fileName) return execResult(`Reading ${fileName}...`, "filesystem", "fs.read", fileName, "file");
348
338
  }
349
339
  if (bin === "sed") {
350
- // In-place sed is a WRITE — label it honestly before the read arm.
340
+
351
341
  const inPlace = rest.some(
352
342
  (t) => t === "-i" || t.startsWith("-i.") || t === "--in-place" || t.startsWith("--in-place="),
353
343
  );
@@ -376,8 +366,7 @@ function classifyExecSegment(tokens) {
376
366
  return execResult("Searching files...", "search", "search.files", null, "phrase");
377
367
  }
378
368
  if (bin === "git") {
379
- // Skip value-taking global flags (`git -C <path> status`, `git -c k=v
380
- // commit`) so the flag's ARGUMENT is never mislabeled as the subcommand.
369
+
381
370
  let sub = null;
382
371
  for (let index = 0; index < rest.length; index += 1) {
383
372
  const token = rest[index];
@@ -423,7 +412,6 @@ function labelFromExecCommand(command) {
423
412
  }
424
413
  const raw = unwrapShellCommand(original);
425
414
 
426
- // 1. agent-browser (substring, unchanged behavior)
427
415
  if (raw.includes("agent-browser")) {
428
416
  const query = extractBrowserQueryFromCommand(raw);
429
417
  if (query) {
@@ -442,9 +430,6 @@ function labelFromExecCommand(command) {
442
430
 
443
431
  const segments = commandSegments(raw);
444
432
 
445
- // 2. curl/wget LEADING the command (first segment only — a trailing
446
- // `&& curl …` must not outrank the leading command's first-token arm;
447
- // pipelines ending in curl still resolve via the URL fallback below)
448
433
  if (segments.length > 0) {
449
434
  const leadBin = path.basename(segments[0][0]);
450
435
  if (leadBin === "curl" || leadBin === "wget") {
@@ -456,8 +441,6 @@ function labelFromExecCommand(command) {
456
441
  }
457
442
  }
458
443
 
459
- // 3. redirects + mktemp (unchanged relative order, BEFORE the read arms —
460
- // preserves the pinned mktemp test)
461
444
  const appendMatch = raw.match(/(?:^|\s)>>\s*([^\s]+)/);
462
445
  if (appendMatch) {
463
446
  const fileName = filenameFromPath(appendMatch[1]);
@@ -485,7 +468,6 @@ function labelFromExecCommand(command) {
485
468
  }
486
469
  }
487
470
 
488
- // 4. find/grep/rg piped into wc → counting
489
471
  if (
490
472
  segments.length >= 2 &&
491
473
  ["find", "grep", "rg"].includes(path.basename(segments[0][0])) &&
@@ -494,13 +476,11 @@ function labelFromExecCommand(command) {
494
476
  return execResult("Counting matches...", "search", "search.files");
495
477
  }
496
478
 
497
- // 5. first-token arms, first matching segment wins
498
479
  for (const tokens of segments) {
499
480
  const hit = classifyExecSegment(tokens);
500
481
  if (hit) return hit;
501
482
  }
502
483
 
503
- // 6. bare-URL substring fallback (deliberately demoted below first-token arms)
504
484
  if (/https?:\/\//i.test(raw)) {
505
485
  const url = extractFirstUrl(raw);
506
486
  const host = hostFromUrl(url);
@@ -508,7 +488,6 @@ function labelFromExecCommand(command) {
508
488
  return execResult("Fetching data...", "network", "network.fetch");
509
489
  }
510
490
 
511
- // 7. raw fallback on the UNWRAPPED inner command
512
491
  return execResult(`Running: ${sanitizeText(raw, DEFAULT_MAX_LABEL_CHARS)}`, "terminal", "terminal.exec");
513
492
  }
514
493
 
@@ -819,8 +798,6 @@ function mapToolLabel(toolName, activityPath, args, options) {
819
798
  const SHORT_LABEL_MAX_CHARS = 64;
820
799
  const SHORT_LABEL_TARGET_CHARS = 42;
821
800
 
822
- // Per-intent verb palettes (spec section 6.3, user-approved 2026-06-06). Applied
823
- // ONLY to deterministic fallback labels — never to agent-authored summaries.
824
801
  const VERB_PALETTES = {
825
802
  "search.web": ["researching", "looking up", "searching for"],
826
803
  "fs.read": ["reading", "checking", "opening"],
@@ -852,9 +829,6 @@ function fnv1aHash(text) {
852
829
  return hash >>> 0;
853
830
  }
854
831
 
855
- // Deterministic header-safe short label, or null when the intent has no
856
- // palette (fixed-phrase arms ARE their own short form; emit-when-differs
857
- // in the adapter keeps them off the wire).
858
832
  function buildShortLabel(input) {
859
833
  const obj = isObject(input) ? input : null;
860
834
  const intent = obj ? asString(obj.intent) : null;
@@ -869,17 +843,13 @@ function buildShortLabel(input) {
869
843
  if (!verbs) return null;
870
844
  let subject = obj && obj.subject ? String(obj.subject).trim() : "";
871
845
  if (!subject || subjectKind === "fixed") return null;
872
- // Redact BEFORE trimming/truncation: slicing a raw secret below the
873
- // redaction patterns' length floors (e.g. sk- + 16 chars) would let a
874
- // partial token escape the adapter's emission-time sanitizeText.
846
+
875
847
  subject = redactSecrets(subject).trim();
876
848
  if (!subject) return null;
877
849
  if (subjectKind === "query") subject = trimQueryForShortLabel(subject);
878
850
  const verb = verbs[fnv1aHash(stabilityKey) % verbs.length];
879
- // Informativeness floor: keep the distinguishing token; over budget the
880
- // SUBJECT truncates and the trailing "..." doubles as the ellipsis
881
- // (no mixed "…..." glyph run).
882
- const budgetForSubject = SHORT_LABEL_TARGET_CHARS - verb.length - 4; // " " + "..."
851
+
852
+ const budgetForSubject = SHORT_LABEL_TARGET_CHARS - verb.length - 4;
883
853
  if (subject.length > budgetForSubject) {
884
854
  subject = subject.slice(0, Math.max(budgetForSubject, 8)).trimEnd();
885
855
  }
@@ -1,28 +1,11 @@
1
- // Markdown code-region scanner for the tagged-span grammar passes.
2
- // Leaf module by design: no imports (CJS emitter import-cycle hazard).
3
- //
4
- // Computes [start, end) regions of text covered by markdown code so that
5
- // tagged-span grammar (<emoji:…>, <dwell>, <skim>) quoted inside backticks
6
- // or fenced blocks is treated as literal text instead of live tags.
7
- //
8
- // Streaming-partial semantics: an unclosed fence runs to end-of-text
9
- // (CommonMark), while an unclosed inline backtick stays literal until its
10
- // closer arrives — the cumulative re-parse on the next flush re-interprets,
11
- // matching how the markdown pass already behaves on partial text.
12
-
13
- /**
14
- * @param {string} text
15
- * @returns {Array<[number, number]>} sorted, non-overlapping [start, end) regions
16
- */
17
1
  export function computeCodeSpanRegions(text) {
18
2
  if (typeof text !== "string" || !text) return [];
19
3
  const n = text.length;
20
4
  const regions = [];
21
5
 
22
- // --- Pass 1: fenced code blocks (line-oriented, ``` or ~~~) ---
23
6
  const FENCE_OPEN_RE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
24
7
  const FENCE_CLOSE_RE = /^ {0,3}(`{3,}|~{3,})[ \t]*$/;
25
- let fence = null; // { char, len, start }
8
+ let fence = null;
26
9
  let lineStart = 0;
27
10
  while (lineStart < n) {
28
11
  const nl = text.indexOf("\n", lineStart);
@@ -30,7 +13,7 @@ export function computeCodeSpanRegions(text) {
30
13
  const line = text.slice(lineStart, lineEnd);
31
14
  if (!fence) {
32
15
  const open = FENCE_OPEN_RE.exec(line);
33
- // Backtick fence info strings must not contain backticks (CommonMark).
16
+
34
17
  if (open && !(open[1][0] === "`" && open[2].includes("`"))) {
35
18
  fence = { char: open[1][0], len: open[1].length, start: lineStart };
36
19
  }
@@ -53,9 +36,6 @@ export function computeCodeSpanRegions(text) {
53
36
  return false;
54
37
  };
55
38
 
56
- // --- Pass 2: inline backtick code spans outside fences ---
57
- // CommonMark: a run of N backticks closes only on the next run of exactly
58
- // N backticks, and a code span cannot cross a blank line.
59
39
  let i = 0;
60
40
  while (i < n) {
61
41
  if (text[i] !== "`" || inFence(i)) {
@@ -81,7 +61,7 @@ export function computeCodeSpanRegions(text) {
81
61
  continue;
82
62
  }
83
63
  if (ch === "\n") {
84
- // Blank line ends the paragraph — no closer for this opener.
64
+
85
65
  let p = k + 1;
86
66
  while (p < n && (text[p] === " " || text[p] === "\t")) p += 1;
87
67
  if (p < n && text[p] === "\n") break scan;
@@ -90,7 +70,7 @@ export function computeCodeSpanRegions(text) {
90
70
  }
91
71
 
92
72
  if (close === -1) {
93
- // Unmatched run: literal backticks, keep scanning after the run.
73
+
94
74
  i = runEnd;
95
75
  continue;
96
76
  }
@@ -0,0 +1,9 @@
1
+ import { createHash, timingSafeEqual } from "node:crypto";
2
+
3
+ export function constantTimeEqual(a, b) {
4
+ if (typeof a !== "string" || typeof b !== "string") return false;
5
+ if (a.length === 0 || b.length === 0) return false;
6
+ const da = createHash("sha256").update(a, "utf8").digest();
7
+ const db = createHash("sha256").update(b, "utf8").digest();
8
+ return timingSafeEqual(da, db);
9
+ }
@@ -0,0 +1,28 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { constantTimeEqual } from "./constant-time-equal.ts";
4
+
5
+ test("constantTimeEqual: equal non-empty strings compare equal", () => {
6
+ assert.equal(constantTimeEqual("s3cr3t-token", "s3cr3t-token"), true);
7
+ assert.equal(constantTimeEqual("a", "a"), true);
8
+ });
9
+
10
+ test("constantTimeEqual: any difference compares unequal", () => {
11
+ assert.equal(constantTimeEqual("s3cr3t-token", "s3cr3t-toke"), false);
12
+ assert.equal(constantTimeEqual("s3cr3t-token", "s3cr3t-tokeN"), false);
13
+ assert.equal(constantTimeEqual("abc", "xbc"), false);
14
+ });
15
+
16
+ test("constantTimeEqual: length mismatch never throws (hashed to fixed width)", () => {
17
+ assert.doesNotThrow(() => constantTimeEqual("short", "a much much longer candidate value"));
18
+ assert.equal(constantTimeEqual("short", "a much much longer candidate value"), false);
19
+ });
20
+
21
+ test("constantTimeEqual: non-string or empty inputs are always false", () => {
22
+ assert.equal(constantTimeEqual("", ""), false);
23
+ assert.equal(constantTimeEqual("x", ""), false);
24
+ assert.equal(constantTimeEqual(null, "x"), false);
25
+ assert.equal(constantTimeEqual("x", undefined), false);
26
+ assert.equal(constantTimeEqual(undefined, undefined), false);
27
+ assert.equal(constantTimeEqual(123, 123), false);
28
+ });