From a1a8fd35466c9fd8e8b4ff063974312c170b1388 Mon Sep 17 00:00:00 2001 From: Petrus Pennanen Date: Fri, 31 Jul 2026 19:48:44 +0200 Subject: [PATCH] uik-daemon: userId/handle swap warning + agent gate cmd + launchd docs The Jul 31 dashboard outage taught three lessons in one afternoon: INTENT_USER_ID set to the agent handle ran silently for 27 days on the Mini (the API creates any user document without complaint) - the daemon now warns at startup on that exact swap. INTENT_AGENT_GATE_CMD lets an agent's beat follow its real supervisor process instead of a bare timer, so a dead agent stops reading as active. launchd installation documented with the TTL rationale. 204/204 tests pass. Co-Authored-By: Claude Opus 5 --- packages/user-intent-kit/README.md | 50 ++++++++++++++++++++++ packages/user-intent-kit/bin/uik-daemon.js | 30 ++++++++++++- 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/packages/user-intent-kit/README.md b/packages/user-intent-kit/README.md index 71e47a1..09ad3ff 100644 --- a/packages/user-intent-kit/README.md +++ b/packages/user-intent-kit/README.md @@ -289,8 +289,58 @@ Current: **v0.2.2** (2026-04-08). See [CHANGELOG.md](CHANGELOG.md) for the per-r Highlights since v0.1.0: - **v0.2.0**: added `bin/uik-daemon.js`, the persistent background daemon that publishes device and agent heartbeats. Exposed as the `uik-daemon` npm bin and runnable via `npx uik-daemon`. - **v0.2.1**: daemon docs in README (`Running as a daemon`). Fixed silent-exit in `examples/iak-integration.js` so the demo no longer terminates when its `setInterval` is unref'd. +- **v0.2.3**: `uik-daemon` warns at startup when `INTENT_USER_ID` equals the agent handle (the most common misconfiguration — heartbeats silently feed a nonexistent user's dashboard), and gains `INTENT_AGENT_GATE_CMD` so an agent's beat can be gated on its real supervisor process. launchd install documented. - **v0.2.2**: `uik-daemon` now re-publishes agent status on the same `POLL_INTERVAL_MS` cadence as the desktop adapter heartbeat. Before this fix the agent slot would expire after its TTL even though the device slot stayed fresh. Caught while dogfooding on the Mac mini. +## Running uik-daemon under launchd (macOS) + +The daemon must run continuously: presence entries expire after their TTL +(90 s for devices), so a stopped daemon means a stale dashboard within two +minutes. Install it as a LaunchAgent: + +```xml + + + + + + Labelcom.example.uik-daemon + ProgramArguments + + /usr/local/bin/node + /path/to/ide-agent-kit/packages/user-intent-kit/bin/uik-daemon.js + + EnvironmentVariables + + INTENT_API_KEYxfb_… + INTENT_USER_IDyour-user-id + INTENT_AGENT_HANDLE@your-agent + INTENT_DEVICE_IDmacbook + + RunAtLoad + KeepAlive + StandardOutPath/tmp/uik-daemon.log + StandardErrorPath/tmp/uik-daemon.log + + +``` + +```sh +launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.uik-daemon.plist +launchctl list | grep uik # verify +``` + +**INTENT_USER_ID is the human user whose dashboard the heartbeats feed — not +the agent's handle.** The API silently creates a document for any user string, +so the swapped form appears to work while the real dashboard stays stale (this +exact mistake ran silently for 27 days on one machine). The daemon now warns at +startup when the two match. + +**INTENT_AGENT_GATE_CMD** (optional): a shell command; when set, the agent beat +publishes only while the command exits 0. Use it to tie an agent's presence to +its actual supervisor process instead of a bare timer, e.g. +`INTENT_AGENT_GATE_CMD="launchctl list com.example.agent-supervisor"`. + ## License AGPL-3.0 diff --git a/packages/user-intent-kit/bin/uik-daemon.js b/packages/user-intent-kit/bin/uik-daemon.js index 72a8bf8..616bbc2 100755 --- a/packages/user-intent-kit/bin/uik-daemon.js +++ b/packages/user-intent-kit/bin/uik-daemon.js @@ -18,6 +18,7 @@ */ import { hostname } from 'node:os'; +import { execSync } from 'node:child_process'; import { IntentClient, IAKAdapter, DesktopAdapter } from '../src/index.js'; const baseUrl = process.env.INTENT_API_BASE || 'https://groupmind.one/api/v1'; @@ -32,17 +33,44 @@ if (!apiKey || !userId) { process.exit(1); } +// The single most common misconfiguration (hit twice on 2026-07-31 alone, +// including 27 silent days on one machine): the AGENT handle placed in +// INTENT_USER_ID. The API accepts any string and silently creates a new +// user document, so the daemon "works" while the real dashboard stays +// stale. Warn loudly; do not exit, in case someone genuinely named their +// user after an agent. +if (userId.toLowerCase() === agentHandle.replace(/^@/, '').toLowerCase()) { + console.error( + `uik-daemon: WARNING - INTENT_USER_ID (${userId}) equals the agent handle. ` + + 'INTENT_USER_ID must be the human user whose dashboard these heartbeats feed; ' + + 'heartbeats are likely going to the wrong document.' + ); +} + const client = new IntentClient({ baseUrl, apiKey, userId, deviceId }); const iak = new IAKAdapter(client, { agentHandle }); const desktop = new DesktopAdapter(client, { pollIntervalMs }); desktop.start(); -await iak.publishStatus({ status: 'active', currentTask: null }); // Re-publish agent status on the same interval as the desktop heartbeat, // otherwise the agent slot expires after its TTL while the device stays // fresh — caught dogfooding on 2026-04-08. +// Optional honesty gate: when INTENT_AGENT_GATE_CMD is set, the agent +// beat only publishes while that command exits 0 (e.g. `launchctl list +// com.example.agent-supervisor`). Without it, a daemon on a timer reports +// an agent as active forever, even when the agent process is long dead. +const gateCmd = process.env.INTENT_AGENT_GATE_CMD || null; +function gateOpen() { + if (!gateCmd) return true; + try { execSync(gateCmd, { stdio: 'ignore' }); return true; } + catch { return false; } +} + +if (gateOpen()) await iak.publishStatus({ status: 'active', currentTask: null }); + const agentTimer = setInterval(() => { + if (!gateOpen()) return; iak.publishStatus({ status: 'active', currentTask: null }).catch(() => {}); }, pollIntervalMs);