fix: keep node presence fresh

This commit is contained in:
Peter Steinberger
2026-01-04 14:30:43 +01:00
parent 672700f2b3
commit 529cf91ac3
9 changed files with 6377 additions and 240 deletions

View File

@@ -9,29 +9,24 @@
- Sessions: primary session key is fixed to `main` (or `global` for global scope); `session.mainKey` is ignored. - Sessions: primary session key is fixed to `main` (or `global` for global scope); `session.mainKey` is ignored.
### Features ### Features
- Highlight: agent-to-agent ping-pong (reply-back loop) with `REPLY_SKIP` plus target announce step with `ANNOUNCE_SKIP` (max turns configurable, 05).
- Gateway: support `gateway.port` + `CLAWDIS_GATEWAY_PORT` across CLI, TUI, and macOS app. - Gateway: support `gateway.port` + `CLAWDIS_GATEWAY_PORT` across CLI, TUI, and macOS app.
- Gateway: add config hot reload with hybrid restart strategy (`gateway.reload`) and per-section reload handling. - Gateway: add config hot reload with hybrid restart strategy (`gateway.reload`) and per-section reload handling.
- UI: centralize tool display metadata and show action/detail summaries across Web Chat, SwiftUI, Android, and the TUI. - UI: centralize tool display metadata and show action/detail summaries across Web Chat, SwiftUI, Android, and the TUI.
- Control UI: support configurable base paths (`gateway.controlUi.basePath`, default unchanged) for hosting under URL prefixes. - Control UI: support configurable base paths (`gateway.controlUi.basePath`, default unchanged) for hosting under URL prefixes.
- Onboarding: shared wizard engine powering CLI + macOS via gateway wizard RPC. - Onboarding: shared wizard engine powering CLI + macOS via gateway wizard RPC.
- Config: expose schema + UI hints for generic config forms (Web UI + future clients). - Config: expose schema + UI hints for generic config forms (Web UI + future clients).
- Browser: add multi-profile browser control with per-profile remote CDP URLs — thanks @jamesgroat.
- Skills: add blogwatcher skill for RSS/Atom monitoring — thanks @Hyaxia. - Skills: add blogwatcher skill for RSS/Atom monitoring — thanks @Hyaxia.
- Skills: add Notion API skill — thanks @scald.
- Discord: emit system events for reaction add/remove with per-guild reaction notifications (off|own|all|allowlist) (#140) — thanks @thewilloftheshadow. - Discord: emit system events for reaction add/remove with per-guild reaction notifications (off|own|all|allowlist) (#140) — thanks @thewilloftheshadow.
- Slack: add socket-mode connector, tools, and UI/docs updates (#170) — thanks @thewilloftheshadow.
- Agent: add optional per-session Docker sandbox for tool execution (`agent.sandbox`) with allow/deny policy and auto-pruning. - Agent: add optional per-session Docker sandbox for tool execution (`agent.sandbox`) with allow/deny policy and auto-pruning.
- Agent: add sandboxed Chromium browser (CDP + optional noVNC observer) for sandboxed sessions. - Agent: add sandboxed Chromium browser (CDP + optional noVNC observer) for sandboxed sessions.
- Nodes: add `location.get` with Always/Precise settings on macOS/iOS/Android plus CLI/tool support. - Nodes: add `location.get` with Always/Precise settings on macOS/iOS/Android plus CLI/tool support.
- Android nodes: add `sms.send` with permission-gated capability refresh (#172) — thanks @vsabavat. - Sessions: add agenttoagent post step with `ANNOUNCE_SKIP` to suppress channel announcements.
### Fixes ### Fixes
- macOS: improve Swift 6 strict concurrency compatibility (#166) — thanks @Nachx639. - Gateway/macOS: keep node presence fresh with periodic beacons + show presence status in Instances (#168) — thanks @mbelinky.
- CI: fix lint ordering after merge cleanup (#156) — thanks @steipete. - CI: fix lint ordering after merge cleanup (#156) — thanks @steipete.
- CI: consolidate checks to avoid redundant installs (#144) — thanks @thewilloftheshadow. - CI: consolidate checks to avoid redundant installs (#144) — thanks @thewilloftheshadow.
- WhatsApp: support `gifPlayback` for MP4 GIF sends via CLI/gateway. - WhatsApp: support `gifPlayback` for MP4 GIF sends via CLI/gateway.
- Gateway: log config hot reloads for dynamic-read changes without restarts.
- Sessions: prevent `sessions_send` timeouts by running nested agent turns on a separate lane. - Sessions: prevent `sessions_send` timeouts by running nested agent turns on a separate lane.
- Sessions: use per-send run IDs for gateway agent calls to avoid wait collisions. - Sessions: use per-send run IDs for gateway agent calls to avoid wait collisions.
- Auto-reply: drop final payloads when block streaming to avoid duplicate Discord sends. - Auto-reply: drop final payloads when block streaming to avoid duplicate Discord sends.
@@ -64,7 +59,6 @@
- Build: drop stale ClawdisCLI product from macOS build-and-run script. - Build: drop stale ClawdisCLI product from macOS build-and-run script.
- Auto-reply: add run-level telemetry + typing TTL guardrails to diagnose stuck replies. - Auto-reply: add run-level telemetry + typing TTL guardrails to diagnose stuck replies.
- WhatsApp: honor per-group mention gating overrides when group ids are stored as session keys. - WhatsApp: honor per-group mention gating overrides when group ids are stored as session keys.
- Slack: add missing deps and wire Slack into cron/heartbeat/hook delivery.
- Dependencies: bump pi-mono packages to 0.32.3. - Dependencies: bump pi-mono packages to 0.32.3.
### Docs ### Docs
@@ -79,8 +73,7 @@
- Queue: clarify steer-backlog behavior with inline commands and update examples for streaming surfaces. - Queue: clarify steer-backlog behavior with inline commands and update examples for streaming surfaces.
- Sandbox: document per-session agent sandbox setup, browser image, and Docker build. - Sandbox: document per-session agent sandbox setup, browser image, and Docker build.
- macOS: clarify menu bar uses sessionKey from agent events. - macOS: clarify menu bar uses sessionKey from agent events.
- Sessions: document agent-to-agent reply loop (`REPLY_SKIP`) and announce step (`ANNOUNCE_SKIP`). - Sessions: document agent-to-agent post step and `ANNOUNCE_SKIP`.
- Skills: clarify wacli third-party messaging scope and JID format examples.
## 2.0.0-beta5 — 2026-01-03 ## 2.0.0-beta5 — 2026-01-03

View File

@@ -73,6 +73,7 @@ struct InstancesSettings: View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) { HStack(spacing: 8) {
Text(inst.host ?? "unknown host").font(.subheadline.bold()) Text(inst.host ?? "unknown host").font(.subheadline.bold())
self.presenceIndicator(inst)
if let ip = inst.ip { Text("(") + Text(ip).monospaced() + Text(")") } if let ip = inst.ip { Text("(") + Text(ip).monospaced() + Text(")") }
} }
@@ -146,6 +147,29 @@ struct InstancesSettings: View {
.font(.footnote) .font(.footnote)
} }
private func presenceIndicator(_ inst: InstanceInfo) -> some View {
let status = self.presenceStatus(for: inst)
return HStack(spacing: 4) {
Circle()
.fill(status.color)
.frame(width: 6, height: 6)
.accessibilityHidden(true)
Text(status.label)
.foregroundStyle(.secondary)
}
.font(.caption)
.help("Presence updated \(inst.ageDescription).")
.accessibilityLabel("\(status.label) presence")
}
private func presenceStatus(for inst: InstanceInfo) -> (label: String, color: Color) {
let nowMs = Date().timeIntervalSince1970 * 1000
let ageSeconds = max(0, Int((nowMs - inst.ts) / 1000))
if ageSeconds <= 120 { return ("Active", .green) }
if ageSeconds <= 300 { return ("Idle", .yellow) }
return ("Stale", .gray)
}
@ViewBuilder @ViewBuilder
private func leadingDeviceIcon(_ inst: InstanceInfo, device: DevicePresentation?) -> some View { private func leadingDeviceIcon(_ inst: InstanceInfo, device: DevicePresentation?) -> some View {
let symbol = self.leadingDeviceSymbol(inst, device: device) let symbol = self.leadingDeviceSymbol(inst, device: device)
@@ -307,6 +331,10 @@ struct InstancesSettings: View {
return "Connect" return "Connect"
case "disconnect": case "disconnect":
return "Disconnect" return "Disconnect"
case "node-connected":
return "Node connect"
case "node-disconnected":
return "Node disconnect"
case "launch": case "launch":
return "Launch" return "Launch"
case "periodic": case "periodic":

View File

@@ -100,7 +100,7 @@ Pairing details: `docs/gateway/pairing.md`.
## 5) Verify the node is connected ## 5) Verify the node is connected
- In the macOS app: **Instances** tab should show something like `iOS Node (...)`. - In the macOS app: **Instances** tab should show something like `iOS Node (...)` with a green “Active” presence dot shortly after connect.
- Via nodes status (paired + connected): - Via nodes status (paired + connected):
```bash ```bash
clawdis nodes status clawdis nodes status

View File

@@ -24,7 +24,7 @@ Presence entries are structured objects with (some) fields:
- `modelIdentifier` (optional): hardware model identifier like `iPad16,6` or `Mac16,6` - `modelIdentifier` (optional): hardware model identifier like `iPad16,6` or `Mac16,6`
- `mode`: e.g. `gateway`, `app`, `webchat`, `cli` - `mode`: e.g. `gateway`, `app`, `webchat`, `cli`
- `lastInputSeconds` (optional): “seconds since last user input” for that client machine - `lastInputSeconds` (optional): “seconds since last user input” for that client machine
- `reason`: a short marker like `self`, `connect`, `periodic`, `instances-refresh` - `reason`: a short marker like `self`, `connect`, `node-connected`, `node-disconnected`, `periodic`, `instances-refresh`
- `text`: legacy/debug summary string (kept for backwards compatibility and UI display) - `text`: legacy/debug summary string (kept for backwards compatibility and UI display)
- `ts`: last update timestamp (ms since epoch) - `ts`: last update timestamp (ms since epoch)
@@ -61,6 +61,16 @@ Implementation:
- Gateway: `src/gateway/server.ts` handles method `system-event` by calling `updateSystemPresence(...)`. - Gateway: `src/gateway/server.ts` handles method `system-event` by calling `updateSystemPresence(...)`.
- mac app beaconing: `apps/macos/Sources/Clawdis/PresenceReporter.swift`. - mac app beaconing: `apps/macos/Sources/Clawdis/PresenceReporter.swift`.
### 4) Node bridge beacons (gateway-owned presence)
When a node bridge connection authenticates, the Gateway emits a presence entry
for that node and starts periodic refresh beacons so it does not expire.
- Connect/disconnect markers: `node-connected`, `node-disconnected`
- Periodic heartbeat: every 3 minutes (`reason: periodic`)
Implementation: `src/gateway/server.ts` (node bridge handlers + timer beacons).
## Merge + dedupe rules (why `instanceId` matters) ## Merge + dedupe rules (why `instanceId` matters)
All producers write into a single in-memory presence map. All producers write into a single in-memory presence map.
@@ -109,6 +119,9 @@ Implementation:
- View: `apps/macos/Sources/Clawdis/InstancesSettings.swift` - View: `apps/macos/Sources/Clawdis/InstancesSettings.swift`
- Store: `apps/macos/Sources/Clawdis/InstancesStore.swift` - Store: `apps/macos/Sources/Clawdis/InstancesStore.swift`
The Instances rows show a small presence indicator (Active/Idle/Stale) based on
the last beacon age. The label is derived from the entry timestamp (`ts`).
The store refreshes periodically and also applies `presence` WS events. The store refreshes periodically and also applies `presence` WS events.
## Debugging tips ## Debugging tips

View File

@@ -212,7 +212,7 @@ git worktree remove /tmp/issue-99
When submitting PRs to external repos, use this format for quality & maintainer-friendliness: When submitting PRs to external repos, use this format for quality & maintainer-friendliness:
```markdown ````markdown
## Original Prompt ## Original Prompt
[Exact request/problem statement] [Exact request/problem statement]
@@ -228,7 +228,7 @@ When submitting PRs to external repos, use this format for quality & maintainer-
# Example # Example
command example command example
``` ```
``` ````
## Feature intent (maintainer-friendly) ## Feature intent (maintainer-friendly)
[Why useful, how it fits, workflows it enables] [Why useful, how it fits, workflows it enables]

View File

@@ -7,11 +7,7 @@ vi.mock("../gateway/call.js", () => ({
vi.mock("../config/config.js", () => ({ vi.mock("../config/config.js", () => ({
loadConfig: () => ({ loadConfig: () => ({
session: { session: { mainKey: "main", scope: "per-sender" },
mainKey: "main",
scope: "per-sender",
agentToAgent: { maxPingPongTurns: 2 },
},
}), }),
resolveGatewayPort: () => 18789, resolveGatewayPort: () => 18789,
})); }));
@@ -131,28 +127,18 @@ describe("sessions tools", () => {
let agentCallCount = 0; let agentCallCount = 0;
let _historyCallCount = 0; let _historyCallCount = 0;
let sendCallCount = 0; let sendCallCount = 0;
let lastWaitedRunId: string | undefined; let waitRunId: string | undefined;
const replyByRunId = new Map<string, string>(); let nextHistoryIsWaitReply = false;
const requesterKey = "discord:group:req";
callGatewayMock.mockImplementation(async (opts: unknown) => { callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown }; const request = opts as { method?: string; params?: unknown };
calls.push(request); calls.push(request);
if (request.method === "agent") { if (request.method === "agent") {
agentCallCount += 1; agentCallCount += 1;
const runId = `run-${agentCallCount}`; const runId = `run-${agentCallCount}`;
const params = request.params as const params = request.params as { message?: string } | undefined;
| { message?: string; sessionKey?: string } if (params?.message === "wait") {
| undefined; waitRunId = runId;
const message = params?.message ?? "";
let reply = "REPLY_SKIP";
if (message === "ping" || message === "wait") {
reply = "done";
} else if (message === "Agent-to-agent announce step.") {
reply = "ANNOUNCE_SKIP";
} else if (params?.sessionKey === requesterKey) {
reply = "pong";
} }
replyByRunId.set(runId, reply);
return { return {
runId, runId,
status: "accepted", status: "accepted",
@@ -161,13 +147,15 @@ describe("sessions tools", () => {
} }
if (request.method === "agent.wait") { if (request.method === "agent.wait") {
const params = request.params as { runId?: string } | undefined; const params = request.params as { runId?: string } | undefined;
lastWaitedRunId = params?.runId; if (params?.runId && params.runId === waitRunId) {
nextHistoryIsWaitReply = true;
}
return { runId: params?.runId ?? "run-1", status: "ok" }; return { runId: params?.runId ?? "run-1", status: "ok" };
} }
if (request.method === "chat.history") { if (request.method === "chat.history") {
_historyCallCount += 1; _historyCallCount += 1;
const text = const text = nextHistoryIsWaitReply ? "done" : "ANNOUNCE_SKIP";
(lastWaitedRunId && replyByRunId.get(lastWaitedRunId)) ?? ""; nextHistoryIsWaitReply = false;
return { return {
messages: [ messages: [
{ {
@@ -190,10 +178,9 @@ describe("sessions tools", () => {
return {}; return {};
}); });
const tool = createClawdisTools({ const tool = createClawdisTools().find(
agentSessionKey: requesterKey, (candidate) => candidate.name === "sessions_send",
agentSurface: "discord", );
}).find((candidate) => candidate.name === "sessions_send");
expect(tool).toBeDefined(); expect(tool).toBeDefined();
if (!tool) throw new Error("missing sessions_send tool"); if (!tool) throw new Error("missing sessions_send tool");
@@ -204,7 +191,6 @@ describe("sessions tools", () => {
}); });
expect(fire.details).toMatchObject({ status: "accepted", runId: "run-1" }); expect(fire.details).toMatchObject({ status: "accepted", runId: "run-1" });
await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
const waitPromise = tool.execute("call6", { const waitPromise = tool.execute("call6", {
sessionKey: "main", sessionKey: "main",
@@ -218,14 +204,13 @@ describe("sessions tools", () => {
}); });
expect(typeof (waited.details as { runId?: string }).runId).toBe("string"); expect(typeof (waited.details as { runId?: string }).runId).toBe("string");
await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
const agentCalls = calls.filter((call) => call.method === "agent"); const agentCalls = calls.filter((call) => call.method === "agent");
const waitCalls = calls.filter((call) => call.method === "agent.wait"); const waitCalls = calls.filter((call) => call.method === "agent.wait");
const historyOnlyCalls = calls.filter( const historyOnlyCalls = calls.filter(
(call) => call.method === "chat.history", (call) => call.method === "chat.history",
); );
expect(agentCalls).toHaveLength(8); expect(agentCalls).toHaveLength(4);
for (const call of agentCalls) { for (const call of agentCalls) {
expect(call.params).toMatchObject({ lane: "nested" }); expect(call.params).toMatchObject({ lane: "nested" });
} }
@@ -246,21 +231,11 @@ describe("sessions tools", () => {
?.extraSystemPrompt === "string" && ?.extraSystemPrompt === "string" &&
( (
call.params as { extraSystemPrompt?: string } call.params as { extraSystemPrompt?: string }
)?.extraSystemPrompt?.includes("Agent-to-agent reply step"), )?.extraSystemPrompt?.includes("Agent-to-agent post step"),
), ),
).toBe(true); ).toBe(true);
expect( expect(waitCalls).toHaveLength(3);
agentCalls.some( expect(historyOnlyCalls).toHaveLength(3);
(call) =>
typeof (call.params as { extraSystemPrompt?: string })
?.extraSystemPrompt === "string" &&
(
call.params as { extraSystemPrompt?: string }
)?.extraSystemPrompt?.includes("Agent-to-agent announce step"),
),
).toBe(true);
expect(waitCalls).toHaveLength(8);
expect(historyOnlyCalls).toHaveLength(8);
expect( expect(
waitCalls.some( waitCalls.some(
(call) => (call) =>
@@ -269,110 +244,4 @@ describe("sessions tools", () => {
).toBe(true); ).toBe(true);
expect(sendCallCount).toBe(0); expect(sendCallCount).toBe(0);
}); });
it("sessions_send runs ping-pong then announces", async () => {
callGatewayMock.mockReset();
const calls: Array<{ method?: string; params?: unknown }> = [];
let agentCallCount = 0;
let lastWaitedRunId: string | undefined;
const replyByRunId = new Map<string, string>();
const requesterKey = "discord:group:req";
const targetKey = "discord:group:target";
let sendParams: { to?: string; provider?: string; message?: string } = {};
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown };
calls.push(request);
if (request.method === "agent") {
agentCallCount += 1;
const runId = `run-${agentCallCount}`;
const params = request.params as
| {
message?: string;
sessionKey?: string;
extraSystemPrompt?: string;
}
| undefined;
let reply = "initial";
if (params?.extraSystemPrompt?.includes("Agent-to-agent reply step")) {
reply = params.sessionKey === requesterKey ? "pong-1" : "pong-2";
}
if (
params?.extraSystemPrompt?.includes("Agent-to-agent announce step")
) {
reply = "announce now";
}
replyByRunId.set(runId, reply);
return {
runId,
status: "accepted",
acceptedAt: 2000 + agentCallCount,
};
}
if (request.method === "agent.wait") {
const params = request.params as { runId?: string } | undefined;
lastWaitedRunId = params?.runId;
return { runId: params?.runId ?? "run-1", status: "ok" };
}
if (request.method === "chat.history") {
const text =
(lastWaitedRunId && replyByRunId.get(lastWaitedRunId)) ?? "";
return {
messages: [
{
role: "assistant",
content: [{ type: "text", text }],
timestamp: 20,
},
],
};
}
if (request.method === "send") {
const params = request.params as
| { to?: string; provider?: string; message?: string }
| undefined;
sendParams = {
to: params?.to,
provider: params?.provider,
message: params?.message,
};
return { messageId: "m-announce" };
}
return {};
});
const tool = createClawdisTools({
agentSessionKey: requesterKey,
agentSurface: "discord",
}).find((candidate) => candidate.name === "sessions_send");
expect(tool).toBeDefined();
if (!tool) throw new Error("missing sessions_send tool");
const waited = await tool.execute("call7", {
sessionKey: targetKey,
message: "ping",
timeoutSeconds: 1,
});
expect(waited.details).toMatchObject({
status: "ok",
reply: "initial",
});
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
const replySteps = calls.filter(
(call) =>
call.method === "agent" &&
typeof (call.params as { extraSystemPrompt?: string })
?.extraSystemPrompt === "string" &&
(
call.params as { extraSystemPrompt?: string }
)?.extraSystemPrompt?.includes("Agent-to-agent reply step"),
);
expect(replySteps).toHaveLength(2);
expect(sendParams).toMatchObject({
to: "channel:target",
provider: "discord",
message: "announce now",
});
});
}); });

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -946,15 +946,18 @@ export async function startGatewayServer(
nodePresenceTimers.delete(nodeId); nodePresenceTimers.delete(nodeId);
}; };
const beaconNodePresence = (node: { const beaconNodePresence = (
nodeId: string; node: {
displayName?: string; nodeId: string;
remoteIp?: string; displayName?: string;
version?: string; remoteIp?: string;
platform?: string; version?: string;
deviceFamily?: string; platform?: string;
modelIdentifier?: string; deviceFamily?: string;
}, reason: string) => { modelIdentifier?: string;
},
reason: string,
) => {
const host = node.displayName?.trim() || node.nodeId; const host = node.displayName?.trim() || node.nodeId;
const rawIp = node.remoteIp?.trim(); const rawIp = node.remoteIp?.trim();
const ip = rawIp && !isLoopbackAddress(rawIp) ? rawIp : undefined; const ip = rawIp && !isLoopbackAddress(rawIp) ? rawIp : undefined;
@@ -1826,6 +1829,10 @@ export async function startGatewayServer(
await stopGmailWatcher(); await stopGmailWatcher();
cron.stop(); cron.stop();
heartbeatRunner.stop(); heartbeatRunner.stop();
for (const timer of nodePresenceTimers.values()) {
clearInterval(timer);
}
nodePresenceTimers.clear();
broadcast("shutdown", { broadcast("shutdown", {
reason, reason,
restartExpectedMs, restartExpectedMs,