ocuclaw 1.3.2 → 1.3.3

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.
@@ -0,0 +1,115 @@
1
+ // Main-side latch over the worker's send-buffer high-water count (roadmap
2
+ // step 4a). The worker transport counts app clients whose ws.bufferedAmount
3
+ // exceeds the high-water mark every health heartbeat and posts the count to
4
+ // main; this latch converts those reports into the boolean the glasses-ui
5
+ // paint-floor shed consumes (relay-service.isGlassesSendBufferOverHighWater).
6
+ //
7
+ // LEAF MODULE: must not import other relay runtime modules (the CJS emitter
8
+ // resolves bidirectional imports to {} mid-cycle — see
9
+ // memory/ocuclaw-cjs-emitter-import-cycle).
10
+ //
11
+ // Semantics:
12
+ // - latch on any report with sendBufferHighWaterClients >= 1
13
+ // - hysteresis: a 0-report clears only after recoveredHoldMs has elapsed
14
+ // since the last >=1 report (pressure flaps at paint cadence otherwise)
15
+ // - decay: with no fresh report inside staleMs the latch fails OPEN (false)
16
+ // — a dead or wedged worker must never freeze glasses paints forever
17
+ // - a report from a new workerEpoch discards prior state (worker restart)
18
+ // - false->true / true->false transitions emit debug events so the 4b
19
+ // hardware validation can observe the shed firing
20
+
21
+ const DEFAULT_RECOVERED_HOLD_MS = 3_000;
22
+ const DEFAULT_STALE_MS = 5_000;
23
+
24
+ export function createGlassesBackpressureLatch(options = {}) {
25
+ const now = typeof options.now === "function" ? options.now : Date.now;
26
+ const recoveredHoldMs = Number.isFinite(options.recoveredHoldMs)
27
+ ? options.recoveredHoldMs
28
+ : DEFAULT_RECOVERED_HOLD_MS;
29
+ const staleMs = Number.isFinite(options.staleMs) ? options.staleMs : DEFAULT_STALE_MS;
30
+ const emitDebug = typeof options.emitDebug === "function" ? options.emitDebug : () => {};
31
+
32
+ let latched = false;
33
+ let latchedAtMs = null;
34
+ let lastOverAtMs = null;
35
+ let lastReportAtMs = null;
36
+ let workerEpoch = null;
37
+
38
+ function clearState() {
39
+ latched = false;
40
+ latchedAtMs = null;
41
+ lastOverAtMs = null;
42
+ lastReportAtMs = null;
43
+ }
44
+
45
+ function emitTransition(nextLatched, reason, atMs) {
46
+ if (nextLatched === latched) return;
47
+ if (nextLatched) {
48
+ latchedAtMs = atMs;
49
+ emitDebug("glasses_backpressure_latched", "warn", { reason });
50
+ } else {
51
+ emitDebug("glasses_backpressure_cleared", "info", {
52
+ reason,
53
+ latchedForMs: latchedAtMs === null ? null : Math.max(0, atMs - latchedAtMs),
54
+ });
55
+ latchedAtMs = null;
56
+ }
57
+ latched = nextLatched;
58
+ }
59
+
60
+ /** Re-evaluate decay/hysteresis clears against the clock. */
61
+ function evaluate(atMs) {
62
+ if (!latched) return;
63
+ if (lastReportAtMs !== null && atMs - lastReportAtMs > staleMs) {
64
+ emitTransition(false, "stale_reports", atMs);
65
+ return;
66
+ }
67
+ if (
68
+ lastOverAtMs !== null &&
69
+ atMs - lastOverAtMs >= recoveredHoldMs &&
70
+ lastReportAtMs !== null &&
71
+ lastReportAtMs > lastOverAtMs
72
+ ) {
73
+ // The newest report said 0 and the hold window since the last >=1
74
+ // report has fully elapsed.
75
+ emitTransition(false, "recovered", atMs);
76
+ }
77
+ }
78
+
79
+ function report(params) {
80
+ const atMs = now();
81
+ const count =
82
+ params && Number.isFinite(params.sendBufferHighWaterClients)
83
+ ? params.sendBufferHighWaterClients
84
+ : null;
85
+ if (count === null) return;
86
+ const epoch = params && Number.isFinite(params.workerEpoch) ? params.workerEpoch : null;
87
+ if (epoch !== null && workerEpoch !== null && epoch !== workerEpoch) {
88
+ // Worker restarted: prior pressure belongs to dead sockets.
89
+ clearState();
90
+ }
91
+ if (epoch !== null) workerEpoch = epoch;
92
+ lastReportAtMs = atMs;
93
+ if (count >= 1) {
94
+ lastOverAtMs = atMs;
95
+ emitTransition(true, "over_high_water", atMs);
96
+ return;
97
+ }
98
+ evaluate(atMs);
99
+ }
100
+
101
+ function isOverHighWater() {
102
+ evaluate(now());
103
+ return latched;
104
+ }
105
+
106
+ function reset(reason) {
107
+ const atMs = now();
108
+ emitTransition(false, typeof reason === "string" ? reason : "reset", atMs);
109
+ clearState();
110
+ }
111
+
112
+ return { report, isOverHighWater, reset };
113
+ }
114
+
115
+ export default { createGlassesBackpressureLatch };
@@ -18,9 +18,11 @@ import { createEvenAiRunWaiter } from "../even-ai/even-ai-run-waiter.js";
18
18
  import { createEvenAiSettingsStore } from "../even-ai/even-ai-settings-store.js";
19
19
  import { createPluginOpenclawClient } from "../gateway/openclaw-client.js";
20
20
  import { createPluginRpcGatewayBridge } from "../gateway/gateway-bridge.js";
21
+ import { createAgentTurnTracker } from "../tools/glasses-ui-wake.js";
21
22
  import { createDownstreamHandler } from "./downstream-handler.js";
22
23
  import { createOcuClawSettingsStore } from "./ocuclaw-settings-store.js";
23
24
  import { createRelayHealthMonitor } from "./relay-health-monitor.js";
25
+ import { createGlassesBackpressureLatch } from "./glasses-backpressure-latch.js";
24
26
  import { createRelayOperationRegistry } from "./relay-operation-registry.js";
25
27
  import { createRelayWorkerSupervisor } from "./relay-worker-supervisor.js";
26
28
  import {
@@ -29,6 +31,9 @@ import {
29
31
  } from "./session-service.js";
30
32
  import { createUpstreamRuntime } from "./upstream-runtime.js";
31
33
 
34
+ const GLASSES_UI_MARKERS = new Set(["listening", "parked", "inflight"]);
35
+ export function sanitizeGlassesMarker(v) { return GLASSES_UI_MARKERS.has(v) ? v : undefined; }
36
+
32
37
  const SONIOX_TEMP_KEY_URL = "https://api.soniox.com/v1/auth/temporary-api-key";
33
38
  const SONIOX_MODELS_URL = "https://api.soniox.com/v1/models";
34
39
  const DEFAULT_SONIOX_TEMP_KEY_EXPIRES_IN_SECONDS = 3600;
@@ -342,6 +347,12 @@ function createRelay(opts) {
342
347
  const activityStatusAdapter = createActivityStatusAdapter(
343
348
  opts.activityStatusAdapter,
344
349
  );
350
+ // Per-session "agent turn in flight or imminent" signal (roadmap 6f):
351
+ // marked busy on every dispatched send (voice/user/wake), refreshed by the
352
+ // gateway activity stream, idled on end-phase activity, decay-bounded
353
+ // (fail open). The glasses-ui wake controller consults it so a wake never
354
+ // races a genuine turn (voice absorbs wake, §2.6c).
355
+ const agentTurnTracker = createAgentTurnTracker();
345
356
  const sharedHttpServer = opts.httpServer || null;
346
357
 
347
358
  // --- Cached state ---
@@ -1262,6 +1273,10 @@ function createRelay(opts) {
1262
1273
  const runId = activity && activity.runId ? activity.runId : null;
1263
1274
  const origin = activity && activity.origin ? activity.origin : null;
1264
1275
  const phase = activity && activity.phase ? activity.phase : null;
1276
+ agentTurnTracker.onActivity(
1277
+ (activity && activity.sessionKey) || sessionService.ensureSessionKey(),
1278
+ phase,
1279
+ );
1265
1280
 
1266
1281
  emitDebug(
1267
1282
  "app.timeline",
@@ -1348,6 +1363,7 @@ function createRelay(opts) {
1348
1363
  surfaceId: params && typeof params.surfaceId === "string" ? params.surfaceId : "",
1349
1364
  depth: Number.isFinite(params && params.depth) ? Math.floor(params.depth) : 1,
1350
1365
  spec: params && params.spec ? params.spec : null,
1366
+ marker: sanitizeGlassesMarker(params && params.marker),
1351
1367
  };
1352
1368
  server.broadcast(JSON.stringify(payload));
1353
1369
  emitDebug(
@@ -1382,6 +1398,7 @@ function createRelay(opts) {
1382
1398
  })
1383
1399
  .filter((i) => i !== null);
1384
1400
  }
1401
+ const m = sanitizeGlassesMarker(patch.marker); if (m) cleanPatch.marker = m;
1385
1402
  const payload = {
1386
1403
  type: "glasses_ui_surface_update",
1387
1404
  sessionKey: params && typeof params.sessionKey === "string" ? params.sessionKey : null,
@@ -1542,6 +1559,9 @@ function createRelay(opts) {
1542
1559
  );
1543
1560
 
1544
1561
  return maybeSeedOcuClawSessionConfig(resolvedSessionKey).then(() => {
1562
+ // A genuine user/voice send is starting an agent turn — mark the
1563
+ // session busy so a racing glasses wake is absorbed (§2.6c).
1564
+ agentTurnTracker.markBusy(resolvedSessionKey);
1545
1565
  // Dispatch upstream first so local transcript work cannot delay first
1546
1566
  // model tokens on large histories.
1547
1567
  const upstreamPromise = gatewayBridge.sendMessage(
@@ -2676,6 +2696,14 @@ function createRelay(opts) {
2676
2696
 
2677
2697
  const pluginVersionService = createPluginVersionService();
2678
2698
 
2699
+ // Roadmap 4a: latch the worker's per-heartbeat send-buffer pressure counts
2700
+ // into the boolean the glasses-ui paint-floor shed queries
2701
+ // (isGlassesSendBufferOverHighWater on the relay API / relay-service facade).
2702
+ const glassesBackpressureLatch = createGlassesBackpressureLatch({
2703
+ emitDebug: (event, severity, data) =>
2704
+ emitDebug("relay.health", event, severity, null, () => data || {}),
2705
+ });
2706
+
2679
2707
  server = createRelayWorkerSupervisor({
2680
2708
  pluginId: "ocuclaw",
2681
2709
  getPluginVersion: () => pluginVersionService.getPluginVersion(),
@@ -2686,6 +2714,7 @@ function createRelay(opts) {
2686
2714
  host: opts.host,
2687
2715
  port: opts.port,
2688
2716
  token: opts.token,
2717
+ onWorkerBackpressure: (message) => glassesBackpressureLatch.report(message),
2689
2718
  externalDebugToolsEnabled,
2690
2719
  evenAiRequestTimeoutMs: opts.evenAiRequestTimeoutMs,
2691
2720
  evenAiMaxBodyBytes: opts.evenAiMaxBodyBytes,
@@ -3331,6 +3360,48 @@ function createRelay(opts) {
3331
3360
  sendGlassesUiSurfaceUpdate(params);
3332
3361
  },
3333
3362
 
3363
+ /**
3364
+ * Tap-to-wake lane (roadmap 6f): ONE agent turn for a parked glasses
3365
+ * gesture, dispatched through the same gateway client the voice send
3366
+ * uses. The MESSAGE is built (and sanitized) by the glasses-ui wake
3367
+ * controller — refs-only with non-wearer provenance framing; this method
3368
+ * is a dumb transport and deliberately does NOT touch the local
3369
+ * conversation state (a wake is not a wearer utterance — no synthetic
3370
+ * user message without provenance, §2.6).
3371
+ */
3372
+ dispatchGlassesWake(params) {
3373
+ const sessionKey =
3374
+ params && typeof params.sessionKey === "string" && params.sessionKey
3375
+ ? params.sessionKey
3376
+ : sessionService.ensureSessionKey();
3377
+ const message = params && typeof params.message === "string" ? params.message : "";
3378
+ if (!message) {
3379
+ return Promise.reject(new Error("dispatchGlassesWake requires a message"));
3380
+ }
3381
+ const idempotencyKey =
3382
+ params && typeof params.idempotencyKey === "string" && params.idempotencyKey
3383
+ ? params.idempotencyKey
3384
+ : null;
3385
+ agentTurnTracker.markBusy(sessionKey);
3386
+ emitDebug(
3387
+ "relay.protocol",
3388
+ "glasses_wake_dispatch",
3389
+ "info",
3390
+ { sessionKey },
3391
+ () => ({
3392
+ idempotencyKey,
3393
+ messageChars: message.length,
3394
+ }),
3395
+ );
3396
+ const requestParams = { message, sessionKey };
3397
+ if (idempotencyKey) requestParams.idempotencyKey = idempotencyKey;
3398
+ return gatewayBridge.request("agent", requestParams, { expectFinal: false });
3399
+ },
3400
+
3401
+ isAgentTurnBusy(sessionKey) {
3402
+ return agentTurnTracker.isBusy(sessionKey);
3403
+ },
3404
+
3334
3405
  onGlassesUiResult(handler) {
3335
3406
  return onGlassesUiResult(handler);
3336
3407
  },
@@ -3351,6 +3422,10 @@ function createRelay(opts) {
3351
3422
  return server ? server.getConnectedAppCount() > 0 : false;
3352
3423
  },
3353
3424
 
3425
+ isGlassesSendBufferOverHighWater() {
3426
+ return glassesBackpressureLatch.isOverHighWater();
3427
+ },
3428
+
3354
3429
  onAppClientDisconnect(handler) {
3355
3430
  return onAppClientDisconnect(handler);
3356
3431
  },
@@ -447,6 +447,38 @@ export function createOcuClawRelayService(opts = {}) {
447
447
  }
448
448
  return false;
449
449
  },
450
+ isGlassesSendBufferOverHighWater() {
451
+ // Roadmap 4a: glasses-ui paint-floor shed signal (glasses-ui-tool.ts
452
+ // already consults this; false = no shedding, the safe default).
453
+ const liveRelay = resolveLiveRelay();
454
+ if (liveRelay && typeof liveRelay.isGlassesSendBufferOverHighWater === "function") {
455
+ return liveRelay.isGlassesSendBufferOverHighWater();
456
+ }
457
+ return false;
458
+ },
459
+ // Tap-to-wake lane + busy signal (roadmap 6f). registerGlassesUiTool
460
+ // probes these at plugin register() time — BEFORE start() — and wires
461
+ // dispatchWake to null when the probe fails, so they must exist on the
462
+ // facade unconditionally and resolve the live (possibly shared) relay at
463
+ // call time, like every other passthrough above.
464
+ dispatchGlassesWake(params) {
465
+ const liveRelay = resolveLiveRelay();
466
+ if (!liveRelay || typeof liveRelay.dispatchGlassesWake !== "function") {
467
+ // The wake controller retries once, then parks the wake in its
468
+ // durable outbox with a warn lifecycle — never silent after the ✓-ack.
469
+ return Promise.reject(new Error("ocuclaw relay not started"));
470
+ }
471
+ return liveRelay.dispatchGlassesWake(params);
472
+ },
473
+ isAgentTurnBusy(sessionKey) {
474
+ const liveRelay = resolveLiveRelay();
475
+ if (liveRelay && typeof liveRelay.isAgentTurnBusy === "function") {
476
+ return liveRelay.isAgentTurnBusy(sessionKey);
477
+ }
478
+ // Idle, not busy: a missing relay must surface through the dispatch
479
+ // path's failure handling, never suppress wakes as phantom-busy.
480
+ return false;
481
+ },
450
482
  start,
451
483
  stop,
452
484
  };
@@ -777,6 +777,14 @@ export function createRelayWorkerSupervisor(options = {}) {
777
777
  postMainFrame("unicast", frame, pending.requesterClientId);
778
778
  return;
779
779
  }
780
+ if (message.kind === "worker.backpressure") {
781
+ // Roadmap 4a: heartbeat-cadence send-buffer pressure counts from the
782
+ // worker transport; relay-core latches them for the glasses paint shed.
783
+ if (typeof options.onWorkerBackpressure === "function") {
784
+ options.onWorkerBackpressure(message);
785
+ }
786
+ return;
787
+ }
780
788
  if (message.kind === "debug" && typeof options.emitDebug === "function") {
781
789
  options.emitDebug(
782
790
  message.category || "relay.worker.health",
@@ -997,8 +997,17 @@ export function createRelayWorkerTransport(options = {}) {
997
997
  }, Math.max(10, Math.min(1000, manifest.queue.messageSendTtlMs)));
998
998
  healthTimer = setInterval(() => {
999
999
  health.updateLoopLagP95Ms(sampleLoopLagP95Ms());
1000
- health.updateSendBufferHighWaterClients(countSendBufferHighWaterClients());
1000
+ const sendBufferHighWaterClients = countSendBufferHighWaterClients();
1001
+ health.updateSendBufferHighWaterClients(sendBufferHighWaterClients);
1001
1002
  health.sample();
1003
+ // Roadmap 4a: main holds the glasses paint-floor shed latch; the ws
1004
+ // sockets (and their bufferedAmount) live here in the worker, so the
1005
+ // count crosses on every heartbeat (the latch fail-opens on staleness).
1006
+ postToMain({
1007
+ kind: "worker.backpressure",
1008
+ workerEpoch: manifest.workerEpoch,
1009
+ sendBufferHighWaterClients,
1010
+ });
1002
1011
  }, manifest.health.heartbeatIntervalMs);
1003
1012
  postToMain({ kind: "worker.ready", workerEpoch: manifest.workerEpoch, address: address() });
1004
1013
  }
@@ -311,6 +311,12 @@ export function createGlassesUiCronEngine(deps) {
311
311
  resolved: false,
312
312
  nextTickTimer: null,
313
313
  maxDurationTimer: null,
314
+ // Pause-aware duration accounting (roadmap 6e): the cap measures
315
+ // ACTIVE time only. remainingMs is banked on pause and re-armed on
316
+ // resume; armedAtMs is monotonic (never wall-clock — the 2026-06-01
317
+ // freeze came from mixing clocks).
318
+ maxDurationRemainingMs: params.refresh.maxDurationMs,
319
+ maxDurationArmedAtMs: null,
314
320
  isSmokeTest: false,
315
321
  lastTickAt: null,
316
322
  generationToken: 0,
@@ -319,7 +325,14 @@ export function createGlassesUiCronEngine(deps) {
319
325
  };
320
326
  active.set(state.surfaceId, state);
321
327
  // Arm the duration cap.
328
+ state.maxDurationArmedAtMs = monotonicNowMs();
322
329
  state.maxDurationTimer = setTimeoutFn(() => {
330
+ // Cron-death observability (Wave-1 e2e leg-4 gap): the cap firing was
331
+ // invisible — bounded tick-silence watches were the only evidence.
332
+ emitLifecycle("cron_max_duration_reached", "debug", {
333
+ surfaceId: state.surfaceId,
334
+ sessionKey: state.sessionKey,
335
+ });
323
336
  resolveAndClean(state, { result: "timeout" });
324
337
  }, params.refresh.maxDurationMs);
325
338
  // start() is sync; runSmokeTest is async. Catch any rejection so it
@@ -380,6 +393,20 @@ export function createGlassesUiCronEngine(deps) {
380
393
  clearTimeoutFn(state.nextTickTimer);
381
394
  state.nextTickTimer = null;
382
395
  }
396
+ // Pause-aware duration cap (roadmap 6e): stop the wall-clock burn and
397
+ // bank the remaining ACTIVE budget. Pre-6e the cap kept running while
398
+ // the parent hid under a pushed child — hardware-observed 2026-06-11
399
+ // as cron_resume {found:false} → Back landed on a frozen list. Guarded
400
+ // on the timer handle so a double-pause cannot double-deduct.
401
+ if (state.maxDurationTimer) {
402
+ clearTimeoutFn(state.maxDurationTimer);
403
+ state.maxDurationTimer = null;
404
+ state.maxDurationRemainingMs = Math.max(
405
+ 0,
406
+ state.maxDurationRemainingMs - (monotonicNowMs() - state.maxDurationArmedAtMs),
407
+ );
408
+ state.maxDurationArmedAtMs = null;
409
+ }
383
410
  state.paused = true;
384
411
  // Bump so an in-flight tick (started before pause) is discarded when it
385
412
  // resolves — it must not patch the now-hidden parent screen.
@@ -398,6 +425,29 @@ export function createGlassesUiCronEngine(deps) {
398
425
  });
399
426
  return false;
400
427
  }
428
+ // Re-arm the duration cap with the banked ACTIVE remainder (roadmap
429
+ // 6e). An exhausted budget resolves terminal timeout here instead of
430
+ // re-arming a dead cron — honest death beats a zombie surface.
431
+ if (!state.maxDurationTimer) {
432
+ if (state.maxDurationRemainingMs <= 0) {
433
+ emitLifecycle("cron_resume", "debug", {
434
+ surfaceId,
435
+ found: true,
436
+ resolved: false,
437
+ branch: "max_duration_exhausted",
438
+ });
439
+ resolveAndClean(state, { result: "timeout" });
440
+ return false;
441
+ }
442
+ state.maxDurationArmedAtMs = monotonicNowMs();
443
+ state.maxDurationTimer = setTimeoutFn(() => {
444
+ emitLifecycle("cron_max_duration_reached", "debug", {
445
+ surfaceId: state.surfaceId,
446
+ sessionKey: state.sessionKey,
447
+ });
448
+ resolveAndClean(state, { result: "timeout" });
449
+ }, state.maxDurationRemainingMs);
450
+ }
401
451
  state.paused = false;
402
452
  if (state.nextTickTimer) {
403
453
  clearTimeoutFn(state.nextTickTimer);
@@ -35,6 +35,14 @@ export function createPaintFloorCoalescer(deps) {
35
35
  return !!(p && p.__render === true);
36
36
  }
37
37
 
38
+ function markerOf(p) {
39
+ if (!p) return undefined;
40
+ return p.__marker !== undefined ? p.__marker : p.marker;
41
+ }
42
+ function isMarkerOnly(p) {
43
+ return !!p && !isRenderSentinel(p) && Object.keys(p).length === 1 && p.marker !== undefined;
44
+ }
45
+
38
46
  function mergePatch(base, incoming) {
39
47
  // A render sentinel ({ __render, __depth, __spec }, Task 13) and a field
40
48
  // patch ({ body }/{ items }/{ title }) must NEVER shallow-merge — a merged
@@ -43,8 +51,22 @@ export function createPaintFloorCoalescer(deps) {
43
51
  // earlier one wholesale (a render replaces a queued field patch; a field
44
52
  // patch after a queued render replaces that render). Same-kind writes
45
53
  // merge last-write-wins per field as before.
54
+ //
55
+ // Marker-only field patch must NEVER drop a queued render (7a): merge the
56
+ // marker onto the render sentinel instead of superseding it wholesale.
57
+ if (isMarkerOnly(incoming) && isRenderSentinel(base)) {
58
+ return { ...base, __marker: incoming.marker };
59
+ }
46
60
  if (isRenderSentinel(base) !== isRenderSentinel(incoming)) {
47
- return incoming && typeof incoming === "object" ? { ...incoming } : {};
61
+ // Different write kinds: later supersedes wholesale (a render replaces a
62
+ // queued field patch; a content field patch replaces a queued render).
63
+ // Marker is sticky — carry it forward if the survivor didn't set one.
64
+ const next = incoming && typeof incoming === "object" ? { ...incoming } : {};
65
+ if (markerOf(next) === undefined) {
66
+ const m = markerOf(base);
67
+ if (m !== undefined) { if (isRenderSentinel(next)) next.__marker = m; else next.marker = m; }
68
+ }
69
+ return next;
48
70
  }
49
71
  const merged = base ? { ...base } : {};
50
72
  if (incoming && typeof incoming === "object") {