Context
Session commands are queued hub-side (queueCommand() in agent-hub/server.js) and only delivered on the next heartbeat reply — worst case HUB_INTERVAL = 20s after the click, plus the beat the agent then fires immediately after executing (the extra-beat logic in run_forever()). The UI papers over the gap with optimistic pending spinners.
Meanwhile, tunnel-agent.js already holds a persistent WebSocket control channel to the hub that today carries exactly one message type ({open, port}) and exists on every host.
Problem
Every session action (spawn/kill/start/restart/delete — and more with the input/push/PR features) feels sluggish: click → up to 20s of spinner → execution → next beat before state reflects. Raising the heartbeat rate is the wrong fix (usage parsing, git calls, and payload size scale with it).
Proposal
Use the control channel as a wake-up signal, keeping the heartbeat as the single source of truth for state and command delivery:
- Hub: when a command is queued for host X and
controlChannels[X] is live, send {poke: true} on it.
- Agent:
tunnel-agent.js writes a byte to a tiny local notification mechanism shared with the manager — simplest is a named pipe or a loopback UDP datagram that hub-agent.py selects on with a INTERVAL-second timeout instead of time.sleep(INTERVAL). On poke: beat immediately, receive the queued command in the reply, execute, beat again (existing logic).
- Result: command latency drops from ~0–20s to sub-second on hosts with a live tunnel, with zero change to the delivery semantics (still at-least-once via the heartbeat queue + cmdId de-dup; hosts with a broken tunnel degrade to today's behavior).
- The
select-loop change also fixes a small existing wart: the manager currently can't react to anything mid-sleep.
Alternatives considered
- Commands in the control channel: faster still, but creates a second delivery path with its own ordering/de-dup/auth concerns — the poke design keeps one authoritative path.
- Hub-side long-poll on
/api/heartbeat: holds sockets open through Cloudflare; the tunnel WS already solved that problem once.
Touchpoints
agent-hub/server.js — poke on queue; controlChannels[name].sendOpen gains a sibling
agent/tunnel-agent.js — handle {poke}, local relay
agent/hub-agent.py — interruptible wait in run_forever()
Context
Session commands are queued hub-side (
queueCommand()inagent-hub/server.js) and only delivered on the next heartbeat reply — worst caseHUB_INTERVAL= 20s after the click, plus the beat the agent then fires immediately after executing (the extra-beat logic inrun_forever()). The UI papers over the gap with optimistic pending spinners.Meanwhile, tunnel-agent.js already holds a persistent WebSocket control channel to the hub that today carries exactly one message type (
{open, port}) and exists on every host.Problem
Every session action (spawn/kill/start/restart/delete — and more with the input/push/PR features) feels sluggish: click → up to 20s of spinner → execution → next beat before state reflects. Raising the heartbeat rate is the wrong fix (usage parsing, git calls, and payload size scale with it).
Proposal
Use the control channel as a wake-up signal, keeping the heartbeat as the single source of truth for state and command delivery:
controlChannels[X]is live, send{poke: true}on it.tunnel-agent.jswrites a byte to a tiny local notification mechanism shared with the manager — simplest is a named pipe or a loopback UDP datagram thathub-agent.pyselects on with aINTERVAL-second timeout instead oftime.sleep(INTERVAL). On poke: beat immediately, receive the queued command in the reply, execute, beat again (existing logic).select-loop change also fixes a small existing wart: the manager currently can't react to anything mid-sleep.Alternatives considered
/api/heartbeat: holds sockets open through Cloudflare; the tunnel WS already solved that problem once.Touchpoints
agent-hub/server.js— poke on queue;controlChannels[name].sendOpengains a siblingagent/tunnel-agent.js— handle{poke}, local relayagent/hub-agent.py— interruptible wait inrun_forever()