@kontourai/flow-agents 4.1.0 → 4.2.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.
Files changed (108) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/build/src/builder-flow-runtime.js +10 -5
  3. package/context/contracts/verification-contract.md +6 -0
  4. package/context/scripts/telemetry/lib/config.sh +29 -0
  5. package/dist/base/build/package.json +1 -1
  6. package/dist/base/build/src/builder-flow-runtime.js +10 -5
  7. package/dist/base/context/contracts/verification-contract.md +6 -0
  8. package/dist/base/context/scripts/telemetry/lib/config.sh +29 -0
  9. package/dist/base/evals/acceptance/prove-capture-teeth.sh +24 -0
  10. package/dist/base/evals/integration/test_effective_backlog_settings.sh +118 -15
  11. package/dist/base/evals/integration/test_liveness_console_relay.sh +91 -1
  12. package/dist/base/evals/integration/test_liveness_heartbeat.sh +66 -0
  13. package/dist/base/install.sh +1 -1
  14. package/dist/base/scripts/hooks/claude-telemetry-hook.js +6 -0
  15. package/dist/base/scripts/hooks/lib/codex-exit-code.js +75 -23
  16. package/dist/base/scripts/hooks/lib/liveness-heartbeat.js +42 -1
  17. package/dist/base/scripts/hooks/lib/liveness-write.js +109 -9
  18. package/dist/base/scripts/liveness/relay.sh +23 -11
  19. package/dist/base/scripts/telemetry/lib/config.sh +29 -0
  20. package/dist/claude-code/build/package.json +1 -1
  21. package/dist/claude-code/build/src/builder-flow-runtime.js +10 -5
  22. package/dist/claude-code/context/contracts/verification-contract.md +6 -0
  23. package/dist/claude-code/context/scripts/telemetry/lib/config.sh +29 -0
  24. package/dist/claude-code/evals/acceptance/prove-capture-teeth.sh +24 -0
  25. package/dist/claude-code/evals/integration/test_effective_backlog_settings.sh +118 -15
  26. package/dist/claude-code/evals/integration/test_liveness_console_relay.sh +91 -1
  27. package/dist/claude-code/evals/integration/test_liveness_heartbeat.sh +66 -0
  28. package/dist/claude-code/install.sh +1 -1
  29. package/dist/claude-code/scripts/hooks/claude-telemetry-hook.js +6 -0
  30. package/dist/claude-code/scripts/hooks/lib/codex-exit-code.js +75 -23
  31. package/dist/claude-code/scripts/hooks/lib/liveness-heartbeat.js +42 -1
  32. package/dist/claude-code/scripts/hooks/lib/liveness-write.js +109 -9
  33. package/dist/claude-code/scripts/liveness/relay.sh +23 -11
  34. package/dist/claude-code/scripts/telemetry/lib/config.sh +29 -0
  35. package/dist/codex/build/package.json +1 -1
  36. package/dist/codex/build/src/builder-flow-runtime.js +10 -5
  37. package/dist/codex/context/contracts/verification-contract.md +6 -0
  38. package/dist/codex/context/scripts/telemetry/lib/config.sh +29 -0
  39. package/dist/codex/evals/acceptance/prove-capture-teeth.sh +24 -0
  40. package/dist/codex/evals/integration/test_effective_backlog_settings.sh +118 -15
  41. package/dist/codex/evals/integration/test_liveness_console_relay.sh +91 -1
  42. package/dist/codex/evals/integration/test_liveness_heartbeat.sh +66 -0
  43. package/dist/codex/install.sh +1 -1
  44. package/dist/codex/scripts/hooks/claude-telemetry-hook.js +6 -0
  45. package/dist/codex/scripts/hooks/lib/codex-exit-code.js +75 -23
  46. package/dist/codex/scripts/hooks/lib/liveness-heartbeat.js +42 -1
  47. package/dist/codex/scripts/hooks/lib/liveness-write.js +109 -9
  48. package/dist/codex/scripts/liveness/relay.sh +23 -11
  49. package/dist/codex/scripts/telemetry/lib/config.sh +29 -0
  50. package/dist/kiro/build/package.json +1 -1
  51. package/dist/kiro/build/src/builder-flow-runtime.js +10 -5
  52. package/dist/kiro/context/contracts/verification-contract.md +6 -0
  53. package/dist/kiro/context/scripts/telemetry/lib/config.sh +29 -0
  54. package/dist/kiro/evals/acceptance/prove-capture-teeth.sh +24 -0
  55. package/dist/kiro/evals/integration/test_effective_backlog_settings.sh +118 -15
  56. package/dist/kiro/evals/integration/test_liveness_console_relay.sh +91 -1
  57. package/dist/kiro/evals/integration/test_liveness_heartbeat.sh +66 -0
  58. package/dist/kiro/install.sh +1 -1
  59. package/dist/kiro/scripts/hooks/claude-telemetry-hook.js +6 -0
  60. package/dist/kiro/scripts/hooks/lib/codex-exit-code.js +75 -23
  61. package/dist/kiro/scripts/hooks/lib/liveness-heartbeat.js +42 -1
  62. package/dist/kiro/scripts/hooks/lib/liveness-write.js +109 -9
  63. package/dist/kiro/scripts/liveness/relay.sh +23 -11
  64. package/dist/kiro/scripts/telemetry/lib/config.sh +29 -0
  65. package/dist/opencode/build/package.json +1 -1
  66. package/dist/opencode/build/src/builder-flow-runtime.js +10 -5
  67. package/dist/opencode/context/contracts/verification-contract.md +6 -0
  68. package/dist/opencode/context/scripts/telemetry/lib/config.sh +29 -0
  69. package/dist/opencode/evals/acceptance/prove-capture-teeth.sh +24 -0
  70. package/dist/opencode/evals/integration/test_effective_backlog_settings.sh +118 -15
  71. package/dist/opencode/evals/integration/test_liveness_console_relay.sh +91 -1
  72. package/dist/opencode/evals/integration/test_liveness_heartbeat.sh +66 -0
  73. package/dist/opencode/install.sh +1 -1
  74. package/dist/opencode/scripts/hooks/claude-telemetry-hook.js +6 -0
  75. package/dist/opencode/scripts/hooks/lib/codex-exit-code.js +75 -23
  76. package/dist/opencode/scripts/hooks/lib/liveness-heartbeat.js +42 -1
  77. package/dist/opencode/scripts/hooks/lib/liveness-write.js +109 -9
  78. package/dist/opencode/scripts/liveness/relay.sh +23 -11
  79. package/dist/opencode/scripts/telemetry/lib/config.sh +29 -0
  80. package/dist/pi/build/package.json +1 -1
  81. package/dist/pi/build/src/builder-flow-runtime.js +10 -5
  82. package/dist/pi/context/contracts/verification-contract.md +6 -0
  83. package/dist/pi/context/scripts/telemetry/lib/config.sh +29 -0
  84. package/dist/pi/evals/acceptance/prove-capture-teeth.sh +24 -0
  85. package/dist/pi/evals/integration/test_effective_backlog_settings.sh +118 -15
  86. package/dist/pi/evals/integration/test_liveness_console_relay.sh +91 -1
  87. package/dist/pi/evals/integration/test_liveness_heartbeat.sh +66 -0
  88. package/dist/pi/install.sh +1 -1
  89. package/dist/pi/scripts/hooks/claude-telemetry-hook.js +6 -0
  90. package/dist/pi/scripts/hooks/lib/codex-exit-code.js +75 -23
  91. package/dist/pi/scripts/hooks/lib/liveness-heartbeat.js +42 -1
  92. package/dist/pi/scripts/hooks/lib/liveness-write.js +109 -9
  93. package/dist/pi/scripts/liveness/relay.sh +23 -11
  94. package/dist/pi/scripts/telemetry/lib/config.sh +29 -0
  95. package/evals/acceptance/prove-capture-teeth.sh +24 -0
  96. package/evals/integration/test_effective_backlog_settings.sh +118 -15
  97. package/evals/integration/test_liveness_console_relay.sh +91 -1
  98. package/evals/integration/test_liveness_heartbeat.sh +66 -0
  99. package/package.json +2 -1
  100. package/scripts/hooks/claude-telemetry-hook.js +6 -0
  101. package/scripts/hooks/lib/codex-exit-code.js +75 -23
  102. package/scripts/hooks/lib/liveness-heartbeat.js +42 -1
  103. package/scripts/hooks/lib/liveness-write.js +109 -9
  104. package/scripts/liveness/relay.sh +23 -11
  105. package/scripts/telemetry/lib/config.sh +29 -0
  106. package/src/builder-flow-runtime.ts +10 -5
  107. package/src/cli/builder-flow-runtime.test.mjs +55 -2
  108. package/src/cli/codex-exit-code.test.mjs +217 -2
@@ -18,6 +18,13 @@ source "$ROOT/evals/lib/node.sh"
18
18
 
19
19
  TMPDIR_EVAL="$(mktemp -d)"
20
20
  trap 'rm -rf "$TMPDIR_EVAL"; [[ -n "${STUB_PID:-}" ]] && kill "$STUB_PID" 2>/dev/null' EXIT
21
+
22
+ # Hermetic HOME: the conf-driven relay resolver (#567) reads __KIRO_PACKAGE_ROOT__/telemetry-console.conf
23
+ # as a resolution slot. Point HOME at an empty dir so these tests never read the running machine's
24
+ # real console conf — enablement here is controlled only by the env flag / an explicit
25
+ # TELEMETRY_CONFIG_FILE, exactly as each case sets up.
26
+ export HOME="$TMPDIR_EVAL/clean-home"
27
+ mkdir -p "$HOME"
21
28
  PORT=38795
22
29
  RECV="$TMPDIR_EVAL/recv.jsonl"
23
30
 
@@ -29,6 +36,12 @@ fail() { echo " [FAIL] $1"; errors=$((errors + 1)); }
29
36
  # controls the response — "ok" (200) or "fail" (500) — to exercise the relay-failure path.
30
37
  start_stub() {
31
38
  local mode="$1"
39
+ # Fresh port per stub: the relay POST is detached/async, so a late POST from a PRIOR "on" case
40
+ # could otherwise race into the next case's freshly-cleared RECV on a reused port and cause a
41
+ # spurious "a POST happened" failure in a "no POST" assertion. A new port each time sends any
42
+ # stray prior-case POST to a now-dead port (connection refused), never into this case's RECV.
43
+ PORT=$((PORT + 1))
44
+ ENDPOINT="http://127.0.0.1:${PORT}/records"
32
45
  STUB_MODE="$mode" node -e '
33
46
  const http=require("http"),fs=require("fs");
34
47
  const out=process.argv[1], mode=process.env.STUB_MODE||"ok";
@@ -67,7 +80,7 @@ echo "=== console liveness relay (#295) ==="
67
80
  echo "--- 1. relay on+configured: liveness emit mirrors to the Console with Bearer + tenant ---"
68
81
  : > "$RECV"; start_stub ok || { fail "stub server did not start"; }
69
82
  ROOT_A="$TMPDIR_EVAL/a"
70
- EVT1='{"type":"claim","subjectId":"relay-subj-1","actor":"claude-code:sessA:host","actor_key":"claude-code:sessA:host","at":"2026-07-05T00:00:00Z","ttlSeconds":1800,"host":"host","branch":"agent/a/relay-subj-1","artifact_dir":"relay-subj-1"}'
83
+ EVT1='{"type":"claim","subjectId":"relay-subj-1","actor":"claude-code:sessA:host","actor_key":"claude-code:sessA:host","at":"2026-07-05T00:00:00Z","ttlSeconds":1800,"host":"host","branch":"agent/a/relay-subj-1","artifact_dir":"relay-subj-1","source":"tool-activity","activity":{"tool":"Bash"}}'
71
84
  (
72
85
  export FLOW_AGENTS_CONSOLE_LIVENESS_RELAY=1
73
86
  export FLOW_AGENTS_CONSOLE_LIVENESS_ENDPOINT_URL="$ENDPOINT"
@@ -92,6 +105,8 @@ const checks=[
92
105
  ["subjectId", b.subjectId==="relay-subj-1"],
93
106
  ["actor", b.actor==="claude-code:sessA:host"],
94
107
  ["branch", b.branch==="agent/a/relay-subj-1"],
108
+ ["source tool-activity", b.source==="tool-activity"],
109
+ ["activity.tool Bash (richer real-liveness rides to the Console)", !!b.activity && b.activity.tool==="Bash"],
95
110
  ];
96
111
  for(const [name,ok] of checks) console.log((ok?"OK ":"BAD ")+name);
97
112
  ' "$RECV" > "$TMPDIR_EVAL/checks.txt" 2>&1
@@ -156,6 +171,81 @@ console.log(hasRawCtl?"RAW":"ESCAPED");
156
171
  if grep -q ESCAPED "$TMPDIR_EVAL/inj.txt"; then pass "hostile actor/branch control bytes are JSON-escaped, never raw in the POST body (injection discipline)"; else fail "raw control bytes reached the POST body: $(cat "$TMPDIR_EVAL/inj.txt")"; fi
157
172
  stop_stub
158
173
 
174
+ # ─── 6. CONF-DRIVEN enablement (#567): NO env flag, `console_liveness_relay=1` in the conf → POST ─
175
+ echo "--- 6. conf-driven (#567): console_liveness_relay=1 in the conf relays with NO env var ---"
176
+ : > "$RECV"; start_stub ok
177
+ ROOT_F="$TMPDIR_EVAL/f"
178
+ CONF6="$TMPDIR_EVAL/conf6.conf"
179
+ printf 'console_telemetry_url=https://example.invalid\nconsole_liveness_relay=1\n' > "$CONF6"
180
+ (
181
+ unset FLOW_AGENTS_CONSOLE_LIVENESS_RELAY # the whole point: no env var
182
+ export TELEMETRY_CONFIG_FILE="$CONF6"
183
+ export FLOW_AGENTS_CONSOLE_LIVENESS_ENDPOINT_URL="$ENDPOINT" # POST target (stub)
184
+ emit_event "$ROOT_F" '{"type":"claim","subjectId":"conf-on-1","actor":"a","at":"t"}'
185
+ )
186
+ [[ -s "$ROOT_F/liveness/events.jsonl" ]] && pass "local write happened (conf-driven case)" || fail "local write missing (conf-driven)"
187
+ if wait_for_post; then pass "conf-driven relay POSTed with NO env var (console_liveness_relay=1)"; else fail "conf-driven relay did NOT POST (env-var-free enablement broken)"; fi
188
+ stop_stub
189
+
190
+ # ─── 7. CONF-DRIVEN auto-on parity (#469): a console url with no explicit key defaults ON ────────
191
+ echo "--- 7. conf-driven auto-on: a console url with no explicit key relays; explicit =0 suppresses ---"
192
+ : > "$RECV"; start_stub ok
193
+ ROOT_G="$TMPDIR_EVAL/g"
194
+ CONF7="$TMPDIR_EVAL/conf7.conf"
195
+ printf 'console_telemetry_url=https://example.invalid\n' > "$CONF7" # url only, no explicit key
196
+ (
197
+ unset FLOW_AGENTS_CONSOLE_LIVENESS_RELAY
198
+ export TELEMETRY_CONFIG_FILE="$CONF7"
199
+ export FLOW_AGENTS_CONSOLE_LIVENESS_ENDPOINT_URL="$ENDPOINT"
200
+ emit_event "$ROOT_G" '{"type":"claim","subjectId":"auto-on-1","actor":"a","at":"t"}'
201
+ )
202
+ if wait_for_post; then pass "auto-on: a configured console url defaults the relay ON (#469 parity)"; else fail "auto-on did NOT POST (console-url default-on broken)"; fi
203
+ stop_stub
204
+
205
+ : > "$RECV"; start_stub ok
206
+ ROOT_H="$TMPDIR_EVAL/h"
207
+ CONF8="$TMPDIR_EVAL/conf8.conf"
208
+ printf 'console_telemetry_url=https://example.invalid\nconsole_liveness_relay=0\n' > "$CONF8" # explicit off wins
209
+ (
210
+ unset FLOW_AGENTS_CONSOLE_LIVENESS_RELAY
211
+ export TELEMETRY_CONFIG_FILE="$CONF8"
212
+ export FLOW_AGENTS_CONSOLE_LIVENESS_ENDPOINT_URL="$ENDPOINT"
213
+ emit_event "$ROOT_H" '{"type":"claim","subjectId":"conf-off-1","actor":"a","at":"t"}'
214
+ )
215
+ sleep 1
216
+ [[ -s "$ROOT_H/liveness/events.jsonl" ]] && pass "local write happened (explicit conf off)" || fail "local write missing (explicit conf off)"
217
+ [[ ! -s "$RECV" ]] && pass "explicit console_liveness_relay=0 suppresses the relay even with a url (opt-out wins)" || fail "a POST happened despite console_liveness_relay=0"
218
+ stop_stub
219
+
220
+ # ─── 8. EXFIL DEFENSE: an UNTRUSTED (mode 644) default-path conf must NOT relay (trust gate) ─────
221
+ # The cases above drive enablement through TELEMETRY_CONFIG_FILE, which is trusted BY DESIGN (an
222
+ # explicit env override bypasses the mode-600 gate). This case exercises the actual supply-chain
223
+ # defense end-to-end: a conf planted at the user-global default slot (__KIRO_PACKAGE_ROOT__/…, reached via
224
+ # the hermetic HOME set at the top) pointing at an attacker stub with console_liveness_relay=1, but
225
+ # mode 644 — exactly what a non-operator drop (clone/tarball/local tool) can produce, since git can
226
+ # only ship 644/755, never 600. relay.sh re-sources config.sh, whose telemetry_conf_trusted gate
227
+ # (mode 600 + owner + not-a-symlink) rejects it, so no URL resolves and nothing is POSTed — while
228
+ # the durable local write still happens. NO env flag, NO TELEMETRY_CONFIG_FILE, NO endpoint env:
229
+ # the untrusted conf is the ONLY possible signal, so a POST here would be a real exfil regression.
230
+ echo "--- 8. exfil defense: an untrusted (mode 644) __KIRO_PACKAGE_ROOT__ conf with an attacker url never relays ---"
231
+ : > "$RECV"; start_stub ok
232
+ ROOT_X="$TMPDIR_EVAL/x"
233
+ mkdir -p "__KIRO_PACKAGE_ROOT__"
234
+ UNTRUSTED_CONF="__KIRO_PACKAGE_ROOT__/telemetry-console.conf"
235
+ printf 'console_telemetry_url=http://127.0.0.1:%s\nconsole_liveness_relay=1\n' "$PORT" > "$UNTRUSTED_CONF"
236
+ chmod 644 "$UNTRUSTED_CONF"
237
+ (
238
+ unset FLOW_AGENTS_CONSOLE_LIVENESS_RELAY
239
+ unset TELEMETRY_CONFIG_FILE
240
+ unset FLOW_AGENTS_CONSOLE_LIVENESS_ENDPOINT_URL
241
+ emit_event "$ROOT_X" '{"type":"claim","subjectId":"exfil-x","actor":"a","at":"t"}'
242
+ )
243
+ sleep 1
244
+ [[ -s "$ROOT_X/liveness/events.jsonl" ]] && pass "local write happened (untrusted-conf case, local-first intact)" || fail "local write missing (untrusted conf)"
245
+ [[ ! -s "$RECV" ]] && pass "an untrusted (644) default-path conf naming an attacker url NEVER relays (trust gate holds, no exfil)" || fail "EXFIL: a POST reached the attacker stub from an untrusted 644 conf"
246
+ rm -f "$UNTRUSTED_CONF"
247
+ stop_stub
248
+
159
249
  echo ""
160
250
  if [[ "$errors" -eq 0 ]]; then
161
251
  echo "test_liveness_console_relay: all checks passed."
@@ -704,6 +704,72 @@ else
704
704
  _fail "maybeEmitHeartbeat did not neutralize an orphan heartbeat as expected: $J1_OUT"
705
705
  fi
706
706
 
707
+ # ─── K. Richer "real liveness": activity descriptor on the emitted heartbeat ────────────────
708
+ echo "--- K. Activity enrichment (tool name on the pulse) ---"
709
+
710
+ # call_heartbeat_activity <scratch_root> <env_json> <now_iso> <activity_json>
711
+ # Like call_heartbeat, but passes an `activity` descriptor through opts so we can prove the emitted
712
+ # heartbeat carries a sanitized activity field (the "what is this agent doing right now" signal).
713
+ call_heartbeat_activity() {
714
+ local root="$1" env_json="$2" now="$3" activity_json="$4"
715
+ MODULE_ARG="$HEARTBEAT_MODULE" ROOT_ARG="$root" ENV_JSON_ARG="$env_json" NOW_ARG="$now" ACTIVITY_ARG="$activity_json" \
716
+ node - <<'NODE'
717
+ const { maybeEmitHeartbeat } = require(process.env.MODULE_ARG);
718
+ const env = JSON.parse(process.env.ENV_JSON_ARG);
719
+ const opts = { cwd: process.env.ROOT_ARG, env, now: process.env.NOW_ARG, activity: JSON.parse(process.env.ACTIVITY_ARG) };
720
+ process.stdout.write(JSON.stringify(maybeEmitHeartbeat(opts)));
721
+ NODE
722
+ }
723
+
724
+ # K1: a clean tool name rides through onto the emitted heartbeat as activity.tool.
725
+ K1_ROOT="$(new_scratch)"
726
+ seed_claim "$K1_ROOT" "k1-subj" "agent-k1" "2026-06-25T12:00:00.000Z" 1800
727
+ K1_RESULT="$(call_heartbeat_activity "$K1_ROOT" '{"FLOW_AGENTS_ACTOR":"agent-k1"}' "2026-06-25T12:01:00.000Z" '{"tool":"Bash"}')"
728
+ K1_LINE="$(tail -n 1 "$(stream_file "$K1_ROOT")" 2>/dev/null)"
729
+ if [[ "$K1_RESULT" == '{"emitted":true}' ]] \
730
+ && grep -q '"type":"heartbeat"' <<<"$K1_LINE" \
731
+ && grep -q '"activity":{"tool":"Bash"}' <<<"$K1_LINE"; then
732
+ _pass "the emitted heartbeat carries a sanitized activity.tool (what the agent is doing right now)"
733
+ else
734
+ _fail "the heartbeat did not carry the expected activity.tool: result=$K1_RESULT line=$K1_LINE"
735
+ fi
736
+
737
+ # K2: an MCP-style tool name (double underscores + hyphens) survives sanitizeSegment intact.
738
+ K2_ROOT="$(new_scratch)"
739
+ seed_claim "$K2_ROOT" "k2-subj" "agent-k2" "2026-06-25T12:00:00.000Z" 1800
740
+ call_heartbeat_activity "$K2_ROOT" '{"FLOW_AGENTS_ACTOR":"agent-k2"}' "2026-06-25T12:01:00.000Z" '{"tool":"mcp__claude-in-chrome__navigate"}' >/dev/null
741
+ K2_LINE="$(tail -n 1 "$(stream_file "$K2_ROOT")" 2>/dev/null)"
742
+ if grep -q '"activity":{"tool":"mcp__claude-in-chrome__navigate"}' <<<"$K2_LINE"; then
743
+ _pass "an MCP tool name (double underscores + hyphens) rides through the allowlist sanitizer intact"
744
+ else
745
+ _fail "the MCP tool name was not preserved on the activity descriptor: line=$K2_LINE"
746
+ fi
747
+
748
+ # K3: a hostile tool name (control bytes / injection payload) is stripped to the allowlist charset —
749
+ # never emitted raw into the append-only stream.
750
+ K3_ROOT="$(new_scratch)"
751
+ seed_claim "$K3_ROOT" "k3-subj" "agent-k3" "2026-06-25T12:00:00.000Z" 1800
752
+ call_heartbeat_activity "$K3_ROOT" '{"FLOW_AGENTS_ACTOR":"agent-k3"}' "2026-06-25T12:01:00.000Z" '{"tool":"Ba\"sh\n{\"x\":1}"}' >/dev/null
753
+ K3_LINE="$(tail -n 1 "$(stream_file "$K3_ROOT")" 2>/dev/null)"
754
+ # The allowlist keeps only [A-Za-z0-9_.-], so the payload collapses to "Bashx1".
755
+ if grep -q '"activity":{"tool":"Bashx1"}' <<<"$K3_LINE" && ! grep -q 'Ba"sh' <<<"$K3_LINE"; then
756
+ _pass "a hostile tool name is stripped to the allowlist charset, never emitted raw (injection discipline)"
757
+ else
758
+ _fail "the hostile tool name was not sanitized as expected: line=$K3_LINE"
759
+ fi
760
+
761
+ # K4: no activity supplied → the heartbeat is a plain aliveness pulse with NO activity key (the field
762
+ # is omitted, not emitted empty).
763
+ K4_ROOT="$(new_scratch)"
764
+ seed_claim "$K4_ROOT" "k4-subj" "agent-k4" "2026-06-25T12:00:00.000Z" 1800
765
+ call_heartbeat "$K4_ROOT" '{"FLOW_AGENTS_ACTOR":"agent-k4"}' "2026-06-25T12:01:00.000Z" >/dev/null
766
+ K4_LINE="$(tail -n 1 "$(stream_file "$K4_ROOT")" 2>/dev/null)"
767
+ if grep -q '"type":"heartbeat"' <<<"$K4_LINE" && ! grep -q '"activity"' <<<"$K4_LINE"; then
768
+ _pass "with no activity supplied the heartbeat omits the activity field entirely (no empty-object noise)"
769
+ else
770
+ _fail "the no-activity heartbeat unexpectedly carried an activity field: line=$K4_LINE"
771
+ fi
772
+
707
773
  echo ""
708
774
  if [[ "$errors" -eq 0 ]]; then
709
775
  echo "Liveness heartbeat integration passed."
@@ -73,7 +73,7 @@ fi
73
73
  export DEST
74
74
  find "$DEST" \( -path "$DEST/AGENTS.md" -o -path "$DEST/CLAUDE.md" \) -prune -o -type f \( -name '*.json' -o -name '*.md' -o -name '*.sh' -o -name '*.js' -o -name '*.ts' -o -name '*.yaml' -o -name '*.yml' \) -print0 | xargs -0 perl -0pi -e 's#__KIRO_PACKAGE_ROOT__#$ENV{DEST}#g'
75
75
  if command -v node >/dev/null 2>&1; then
76
- node "$DEST/scripts/install-merge.js" --stamp-only --version "4.1.0" --install-record "$DEST/.flow-agents/install.json" --runtime "kiro" || true
76
+ node "$DEST/scripts/install-merge.js" --stamp-only --version "4.2.1" --install-record "$DEST/.flow-agents/install.json" --runtime "kiro" || true
77
77
  fi
78
78
  if [[ ${#CONSOLE_CONFIG_ARGS[@]} -gt 0 || -n "${FLOW_AGENTS_TELEMETRY_SINK:-}" || -n "${FLOW_AGENTS_TELEMETRY_SINKS:-}" || -n "${FLOW_AGENTS_CONSOLE_URL:-}" || -n "${CONSOLE_TELEMETRY_URL:-}" || -n "${CONSOLE_URL:-}" || -n "${FLOW_AGENTS_CONSOLE_TOKEN_FILE:-}" || -n "${CONSOLE_TELEMETRY_TOKEN_FILE:-}" ]]; then
79
79
  bash "$DEST/scripts/telemetry/install-console-config.sh" "$DEST/scripts/telemetry/telemetry.conf" "${CONSOLE_CONFIG_ARGS[@]}"
@@ -105,9 +105,15 @@ async function main() {
105
105
  let conflict;
106
106
  if (canonical === 'postToolUse') {
107
107
  try {
108
+ // Richer "real liveness": name the tool this pulse rode in on so the heartbeat says WHAT the
109
+ // agent is doing, not just that it is alive. Claude Code's PostToolUse payload carries the
110
+ // tool name as `tool_name` (fall back to `toolName`); the heartbeat sanitizes it, so a
111
+ // missing/odd value degrades to a plain aliveness pulse rather than an error.
112
+ const toolName = payload.tool_name || payload.toolName;
108
113
  const heartbeatResult = require('./lib/liveness-heartbeat').maybeEmitHeartbeat({
109
114
  cwd: process.cwd(),
110
115
  env: process.env,
116
+ activity: toolName ? { tool: toolName } : undefined,
111
117
  });
112
118
  conflict = heartbeatResult && heartbeatResult.conflict;
113
119
  } catch (err) {
@@ -113,10 +113,13 @@ function normalizeCallArguments(argumentsField) {
113
113
  }
114
114
  }
115
115
  if (!parsed || typeof parsed !== 'object') return null;
116
- return normalizeCommandText(parsed.command);
116
+ // Codex records exec_command arguments under `cmd`; older shell fixtures
117
+ // and some adapters use `command`.
118
+ return normalizeCommandText(parsed.cmd) || normalizeCommandText(parsed.command);
117
119
  }
118
120
 
119
121
  const OUTPUT_FIELD_NEEDLE = '"output":"';
122
+ const COMMAND_FUNCTION_NAMES = new Set(['exec_command', 'shell']);
120
123
 
121
124
  /**
122
125
  * parseCandidateLine(line, maxLineHeadBytes) → candidate | null
@@ -149,7 +152,15 @@ function parseCandidateLine(line, maxLineHeadBytes) {
149
152
  return { type: 'function_call_output', callId, output: payload.output };
150
153
  }
151
154
  if (payload.type === 'function_call') {
152
- return { type: 'function_call', callId, command: normalizeCallArguments(payload.arguments) };
155
+ const name = typeof payload.name === 'string' && payload.name.trim() ? payload.name.trim() : null;
156
+ const command = normalizeCallArguments(payload.arguments);
157
+ const commandCapable = name === null || COMMAND_FUNCTION_NAMES.has(name) || command !== null;
158
+ return {
159
+ type: 'function_call',
160
+ callId,
161
+ commandCapable,
162
+ command,
163
+ };
153
164
  }
154
165
  return null;
155
166
  }
@@ -214,13 +225,12 @@ function resolveContainedRealPath(transcriptPath) {
214
225
  * Correlation policy (Decision B, #470 iteration 2, HIGH finding #4), in
215
226
  * priority order:
216
227
  * 1. call_id match wins — authoritative.
217
- * 2. Absent a call_id match: take the newest `function_call_output` in the
218
- * scanned window, resolve its paired `function_call` (same call_id), and
219
- * compare that call's `arguments` command (normalized) to `command`. A
220
- * resolved MATCH uses the newest output; a resolved MISMATCH DECLINES
221
- * (`null`) rather than knowingly attribute a different call's exit code.
222
- * 3. If no pairing is resolvable at all (the common single-call case): fall
223
- * back to the newest `function_call_output` banner.
228
+ * 2. Absent a call_id match: pair outputs with calls by rollout call_id and
229
+ * select the only pair whose normalized arguments match `command`. Zero
230
+ * matches or multiple matches DECLINE (`null`) rather than attribute a
231
+ * neighboring or repeated command's exit code.
232
+ * 3. If no pairing is resolvable at all and the rollout contains exactly one
233
+ * output with no function call, use that genuinely unpaired legacy output.
224
234
  *
225
235
  * Any failure (missing/unreadable/non-regular file, containment violation,
226
236
  * malformed JSON lines, no candidate found) yields `null` — never throws.
@@ -271,41 +281,83 @@ function readExitCodeFromRollout(transcriptPath, options) {
271
281
  }
272
282
  if (truncated && lines.length > 1) lines.shift();
273
283
 
274
- let matchedByCallId = null;
284
+ const outputsByRequestedCallId = [];
275
285
  let newestOutputEntry = null;
276
- let pairedCommand = null;
286
+ const outputEntries = [];
287
+ const commandByCallId = new Map();
288
+ const seenFunctionCallIds = new Set();
289
+ const ambiguousCallIds = new Set();
290
+ let functionCallCount = 0;
291
+ let sawUnclassifiableLine = false;
292
+ let sawUnresolvableFunctionCall = false;
277
293
 
278
294
  for (let i = lines.length - 1; i >= 0; i--) {
279
295
  const line = lines[i].trim();
280
296
  if (!line) continue;
281
297
  const candidate = parseCandidateLine(line, maxLineHeadBytes);
282
- if (!candidate) continue; // malformed/partial/unrecognized line — skip, keep scanning
298
+ if (!candidate) {
299
+ if (Buffer.byteLength(line, 'utf8') > maxLineHeadBytes) {
300
+ sawUnclassifiableLine = true;
301
+ } else {
302
+ try {
303
+ JSON.parse(line); // valid non-candidate events do not poison correlation
304
+ } catch {
305
+ sawUnclassifiableLine = true;
306
+ }
307
+ }
308
+ continue; // malformed/partial/unrecognized line — skip, keep scanning
309
+ }
283
310
 
284
311
  if (candidate.type === 'function_call_output') {
312
+ outputEntries.push(candidate); // scan order is newest to oldest
285
313
  if (newestOutputEntry === null) newestOutputEntry = candidate; // first seen scanning backward = newest
286
314
  if (callId && candidate.callId === callId) {
287
- matchedByCallId = candidate;
288
- break; // call_id match is authoritative — stop scanning
315
+ outputsByRequestedCallId.push(candidate);
289
316
  }
290
317
  } else if (candidate.type === 'function_call') {
291
- if (newestOutputEntry && pairedCommand === null && candidate.callId === newestOutputEntry.callId) {
292
- pairedCommand = candidate.command;
318
+ functionCallCount += 1;
319
+ if (candidate.callId) {
320
+ if (seenFunctionCallIds.has(candidate.callId)) ambiguousCallIds.add(candidate.callId);
321
+ else seenFunctionCallIds.add(candidate.callId);
322
+ }
323
+ if (candidate.commandCapable && candidate.callId && candidate.command !== null) {
324
+ if (!commandByCallId.has(candidate.callId)) commandByCallId.set(candidate.callId, candidate.command);
325
+ } else if (candidate.commandCapable) {
326
+ sawUnresolvableFunctionCall = true;
293
327
  }
294
328
  }
295
329
  }
296
330
 
297
331
  let chosenOutput = null;
298
- if (matchedByCallId) {
299
- chosenOutput = matchedByCallId.output;
332
+ if ((callId || command) && sawUnclassifiableLine) return null;
333
+ if (callId) {
334
+ if (outputsByRequestedCallId.length === 1 && !ambiguousCallIds.has(callId)) {
335
+ chosenOutput = outputsByRequestedCallId[0].output;
336
+ } else {
337
+ return null; // explicit call ID is unresolved, reused, or has duplicate outputs
338
+ }
300
339
  } else if (newestOutputEntry) {
301
- if (pairedCommand !== null && command) {
302
- if (pairedCommand === command) {
303
- chosenOutput = newestOutputEntry.output;
340
+ if (command) {
341
+ if (sawUnresolvableFunctionCall) return null;
342
+ const matchingCallIds = [...commandByCallId.entries()]
343
+ .filter(([, candidateCommand]) => candidateCommand === command)
344
+ .map(([candidateCallId]) => candidateCallId);
345
+ const matches = outputEntries.filter((entry) => commandByCallId.get(entry.callId) === command);
346
+ if (matchingCallIds.length === 1
347
+ && !ambiguousCallIds.has(matchingCallIds[0])
348
+ && matches.length === 1) {
349
+ chosenOutput = matches[0].output;
350
+ } else if (matchingCallIds.length > 0 || matches.length > 0 || commandByCallId.size > 0) {
351
+ return null; // no unique command correlation
352
+ } else if (!truncated && outputEntries.length === 1 && functionCallCount === 0) {
353
+ chosenOutput = newestOutputEntry.output; // genuinely unpaired legacy fallback
304
354
  } else {
305
- return null; // positive command mismatch decline rather than mis-attribute
355
+ return null; // multi-call or partially paired tail
306
356
  }
357
+ } else if (!truncated && outputEntries.length === 1 && functionCallCount === 0) {
358
+ chosenOutput = newestOutputEntry.output; // genuinely unpaired legacy fallback
307
359
  } else {
308
- chosenOutput = newestOutputEntry.output; // single-call fallback no pairing resolvable
360
+ return null; // no correlation signal for a paired or multi-call tail
309
361
  }
310
362
  }
311
363
 
@@ -164,6 +164,39 @@ function readActiveSlug(root, actorKey) {
164
164
  return sanitized === 'unknown' ? '' : sanitized;
165
165
  }
166
166
 
167
+ /**
168
+ * Sanitize an optional caller-supplied `activity` descriptor into a clean, bounded shape suitable
169
+ * for embedding in the emitted heartbeat event (issue: richer "real liveness" signals). The point
170
+ * is to make each pulse say WHAT the agent is doing right now — the tool it just ran — not merely
171
+ * that it is alive. Every field flows both into the local append-only JSONL and (via relay.sh) into
172
+ * the hosted Console record, so each value is passed through the same allowlist+64-cap
173
+ * `sanitizeSegment` the write side already applies to actors/slugs: a hostile or oversized tool
174
+ * name (a custom MCP tool string, a hand-edited hook payload) can never inject control bytes or
175
+ * unbounded text downstream. Returns `undefined` when there is no usable activity, so the caller
176
+ * omits the field entirely rather than emitting `activity: {}` noise.
177
+ *
178
+ * @param {{tool?: string}|undefined} activity
179
+ * @returns {{tool: string}|undefined}
180
+ */
181
+ function sanitizeActivity(activity) {
182
+ // Fully self-contained fail-safe: this runs on the hot path just BEFORE the durable local append
183
+ // in maybeEmitHeartbeat, and local-first is sacred (ADR 0012 §5) — enriching the pulse must never
184
+ // be able to prevent the write. `String(activity.tool)` could in principle throw for an exotic
185
+ // caller value (a throwing toString/valueOf); swallow it here and degrade to a plain aliveness
186
+ // pulse rather than letting the durable write be skipped.
187
+ try {
188
+ if (!activity || typeof activity !== 'object') return undefined;
189
+ const out = {};
190
+ if (activity.tool != null && String(activity.tool).trim()) {
191
+ const tool = sanitizeSegment(activity.tool);
192
+ if (tool && tool !== 'unknown') out.tool = tool;
193
+ }
194
+ return Object.keys(out).length ? out : undefined;
195
+ } catch {
196
+ return undefined;
197
+ }
198
+ }
199
+
167
200
  /**
168
201
  * Filter a list of parsed liveness events down to the ones matching a given
169
202
  * (subjectId, actor) pair. Shared by the bounded tail read and the full-read
@@ -270,7 +303,11 @@ function attachConflictField(result, conflict) {
270
303
  * wall-clock timer), so it fires once per genuinely new conflicting event, independent of
271
304
  * whether our own heartbeat emits or throttles this call.
272
305
  *
273
- * @param {{cwd?: string, env?: NodeJS.ProcessEnv, now?: Date|string}} [opts]
306
+ * @param {{cwd?: string, env?: NodeJS.ProcessEnv, now?: Date|string, activity?: {tool?: string}}} [opts]
307
+ * `activity` is an optional descriptor of what the agent is doing right now (e.g. the tool this
308
+ * postToolUse pulse rode in on); when present + usable it is sanitized and embedded in the emitted
309
+ * heartbeat so the fleet view shows WHAT, not just aliveness. Purely additive — never affects the
310
+ * throttle/emit/conflict decisions.
274
311
  * @returns {{emitted: boolean, reason?: string, conflict?: {actor: string, lastAt: string, ttlSeconds: number}}}
275
312
  */
276
313
  function maybeEmitHeartbeat(opts = {}) {
@@ -383,12 +420,16 @@ function maybeEmitHeartbeat(opts = {}) {
383
420
  const conflict = computeConflict(holderEvents, slug, actor, nowMs, last.at);
384
421
 
385
422
  const nowIso = new Date(nowMs).toISOString();
423
+ const activity = sanitizeActivity(opts.activity);
386
424
  appendLivenessEvent(root, {
387
425
  type: 'heartbeat',
388
426
  subjectId: slug,
389
427
  actor,
390
428
  at: nowIso,
391
429
  source: 'tool-activity',
430
+ // Richer "real liveness": name the tool this pulse rode in on, so the fleet view shows WHAT
431
+ // each agent is doing, not just that it is alive. Omitted entirely when no usable activity.
432
+ ...(activity ? { activity } : {}),
392
433
  });
393
434
  return attachConflictField({ emitted: true }, conflict);
394
435
  } catch (err) {
@@ -18,27 +18,127 @@
18
18
  */
19
19
 
20
20
  const fs = require('fs');
21
+ const os = require('os');
21
22
  const path = require('path');
22
23
  const { spawn } = require('child_process');
23
24
 
25
+ const TRUTHY = new Set(['1', 'true', 'yes', 'on']);
26
+ const FALSY = new Set(['0', 'false', 'no', 'off']);
27
+ // Cheap per-process memo keyed by the exact env inputs, so a throttled hot path does at most one
28
+ // conf read per (env flag, conf path) signature. Keyed inputs make unit tests that vary
29
+ // TELEMETRY_CONFIG_FILE independent.
30
+ const relayEnabledCache = new Map();
31
+
32
+ /**
33
+ * The console-conf resolution chain, mirroring scripts/telemetry/lib/config.sh's slot order:
34
+ * 1. TELEMETRY_CONFIG_FILE (env) if set + readable
35
+ * 2. per-workspace override: <workspace-root>/.kontourai/telemetry-console.conf
36
+ * 3. user-global durable override: __KIRO_PACKAGE_ROOT__/telemetry-console.conf
37
+ * 4. the shipped default: scripts/telemetry/telemetry.conf (relative to this file)
38
+ * Slot 4's path is <this file>/../../telemetry/telemetry.conf; the workspace root for slot 2 is
39
+ * three levels up from scripts/hooks/lib. This resolver is only the SPAWN pre-gate — it is
40
+ * intentionally lenient (no mode-600 trust gate). relay.sh re-sources config.sh, which applies the
41
+ * trust gate and is the authoritative decision for both enablement and the POST endpoint, so a
42
+ * lenient "enabled" here at worst spawns a relay.sh that then trust-gates itself to a no-op.
43
+ *
44
+ * ACCEPTED, BY DESIGN (security review LOW, A04): because this pre-gate does NOT re-apply
45
+ * config.sh's mode-600/owner gate, it can read an explicit key from an UNtrusted default-path conf
46
+ * (a mode-644 .kontourai/telemetry-console.conf a local tool dropped) that config.sh would skip.
47
+ * This is fail-toward-no-op, never fail-toward-exfil: the only divergence is (a) we spawn a
48
+ * relay.sh that config.sh then no-ops (harmless), or (b) an untrusted `=0` here suppresses a spawn
49
+ * that a trusted conf would have enabled (a missed pulse — availability only, requiring local FS
50
+ * write access). The pre-gate deliberately does not guarantee relay AVAILABILITY — only that we
51
+ * avoid spawning when nothing plausibly enables it. It NEVER weakens the exfil defense, which lives
52
+ * entirely in relay.sh's authoritative, trust-gated re-resolution.
53
+ * @returns {string|null} first readable conf path, or null
54
+ */
55
+ function resolveConsoleConfPath(env) {
56
+ const candidates = [
57
+ env.TELEMETRY_CONFIG_FILE,
58
+ path.join(__dirname, '..', '..', '..', '.kontourai', 'telemetry-console.conf'),
59
+ path.join(os.homedir(), '.flow-agents', 'telemetry-console.conf'),
60
+ path.join(__dirname, '..', '..', 'telemetry', 'telemetry.conf'),
61
+ ];
62
+ for (const c of candidates) {
63
+ try {
64
+ if (c && fs.existsSync(c) && fs.statSync(c).isFile()) return c;
65
+ } catch {
66
+ /* keep scanning */
67
+ }
68
+ }
69
+ return null;
70
+ }
71
+
72
+ /**
73
+ * Conf-driven liveness relay enablement (#567 — parity with the economics relay #469, so an
74
+ * operator enables via `console_liveness_relay=1` in the console conf, NOT an env var/.profile).
75
+ * Precedence mirrors config.sh's economics rule EXACTLY (config.sh:182-204): an explicit
76
+ * `console_liveness_relay` conf key wins over the env var; absent a key, a pre-set env var is
77
+ * honored; absent both, default ON once a console sink is configured (any console_*url key present)
78
+ * — opt-out, not silent-off. Best-effort and cheap: any FS/parse failure falls back to the env-only
79
+ * decision; never throws. This is the spawn pre-gate; relay.sh+config.sh is authoritative.
80
+ */
81
+ function resolveLivenessRelayEnabled(env) {
82
+ const envFlag = String(env.FLOW_AGENTS_CONSOLE_LIVENESS_RELAY || '').toLowerCase();
83
+ const envDecision = TRUTHY.has(envFlag) ? true : FALSY.has(envFlag) ? false : null;
84
+ const cacheKey = `${envFlag}|${env.TELEMETRY_CONFIG_FILE || ''}`;
85
+ if (relayEnabledCache.has(cacheKey)) return relayEnabledCache.get(cacheKey);
86
+ let enabled;
87
+ try {
88
+ let explicit = null;
89
+ let hasConsoleUrl = false;
90
+ const confPath = resolveConsoleConfPath(env);
91
+ if (confPath) {
92
+ for (const raw of fs.readFileSync(confPath, 'utf8').split('\n')) {
93
+ const line = raw.trim();
94
+ if (!line || line.startsWith('#')) continue;
95
+ const eq = line.indexOf('=');
96
+ if (eq < 0) continue;
97
+ const key = line.slice(0, eq).trim();
98
+ const value = line.slice(eq + 1).trim().toLowerCase();
99
+ if (key === 'console_liveness_relay') {
100
+ if (TRUTHY.has(value)) explicit = true;
101
+ else if (FALSY.has(value)) explicit = false;
102
+ } else if (key === 'console_telemetry_url' || key === 'console_url' || key === 'console_telemetry_endpoint_url') {
103
+ if (value) hasConsoleUrl = true;
104
+ }
105
+ }
106
+ }
107
+ // config.sh precedence: explicit conf key > pre-set env var > default-on when a console url resolves.
108
+ enabled = explicit !== null ? explicit : envDecision !== null ? envDecision : hasConsoleUrl;
109
+ } catch {
110
+ enabled = envDecision === true; // best-effort: honor only an explicit env-on if the conf read failed
111
+ }
112
+ relayEnabledCache.set(cacheKey, enabled);
113
+ return enabled;
114
+ }
115
+
24
116
  /**
25
- * OPTIONAL console liveness relay (#295, ADR 0021 §4/§7). Best-effort, FULLY detached mirror of a
26
- * liveness event to the hosted Console via `scripts/liveness/relay.sh`. Local-first is sacred: this
27
- * runs AFTER the durable local append and can never block, throw, or affect it — gated on
28
- * `FLOW_AGENTS_CONSOLE_LIVENESS_RELAY`, the whole thing wrapped so any failure (missing script,
29
- * spawn error) is swallowed. No flag ⇒ a single cheap env read and return (true no-op).
117
+ * OPTIONAL console liveness relay (#295, ADR 0021 §4/§7; conf-driven per #567). Best-effort, FULLY
118
+ * detached mirror of a liveness event to the hosted Console via `scripts/liveness/relay.sh`.
119
+ * Local-first is sacred: this runs AFTER the durable local append and can never block, throw, or
120
+ * affect it — gated on `resolveLivenessRelayEnabled`, the whole thing wrapped so any failure
121
+ * (missing script, spawn error) is swallowed. Not enabled ⇒ a cheap resolve and return (true no-op).
30
122
  *
31
123
  * @param {object} evt The liveness event just written locally.
32
124
  * @returns {void}
33
125
  */
34
126
  function relayLivenessEvent(evt) {
35
127
  try {
36
- const flag = String(process.env.FLOW_AGENTS_CONSOLE_LIVENESS_RELAY || '').toLowerCase();
37
- if (flag !== '1' && flag !== 'true' && flag !== 'yes' && flag !== 'on') return;
128
+ if (!resolveLivenessRelayEnabled(process.env)) return;
38
129
  // scripts/hooks/lib/ -> scripts/liveness/relay.sh (same relative layout in dist/* bundles).
39
130
  const relay = path.join(__dirname, '..', '..', 'liveness', 'relay.sh');
40
131
  if (!fs.existsSync(relay)) return;
41
- const child = spawn('bash', [relay, JSON.stringify(evt)], { detached: true, stdio: 'ignore' });
132
+ // Pass the environment through UNCHANGED do not force the enable flag. relay.sh re-sources
133
+ // config.sh, which is the authoritative, trust-gated decision for both enablement (conf key >
134
+ // env > default-on) and the POST endpoint. Our resolve above is only a lenient spawn pre-gate:
135
+ // if we enabled from an untrusted default-path conf, config.sh trust-gates relay.sh to a no-op
136
+ // rather than us bypassing it by forcing the flag on.
137
+ const child = spawn('bash', [relay, JSON.stringify(evt)], {
138
+ detached: true,
139
+ stdio: 'ignore',
140
+ env: process.env,
141
+ });
42
142
  child.on('error', () => {}); // never surface a spawn failure
43
143
  child.unref(); // fully detach — the parent never waits on the relay
44
144
  } catch {
@@ -70,4 +170,4 @@ function appendLivenessEvent(root, evt) {
70
170
  relayLivenessEvent(evt); // then optionally mirror to the Console — best-effort, detached, off by default
71
171
  }
72
172
 
73
- module.exports = { livenessStreamFile, appendLivenessEvent };
173
+ module.exports = { livenessStreamFile, appendLivenessEvent, resolveLivenessRelayEnabled };