Conversation
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Implements the DaemonState struct and all required methods (new, apply_event, find_config, find_opmode, to_json) with comprehensive unit tests covering: - Initial disconnected state with empty caches - Connection and disconnection state management - OpMode list population and lookup - Configuration list parsing and lookup - Telemetry caching by tag with replacement - JSON serialization of all state All six tests pass cleanly. Verified Telemetry struct has the expected fields (tag: String, strings: Vec, numbers: BTreeMap, Default impl).
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Implements the Request enum (serde-tagged on `cmd`) and handle_request, which runs each command against a live RobocolClient + DaemonState + WaiterRegistry and produces the JSON reply. Watch/Shutdown are left unreachable here since Task 5's connection layer intercepts them before calling handle_request. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replaces the main.rs stub with argument parsing (--peer/--peer-port/ --bind-port), a RobocolClient event-pump thread that feeds DaemonState, WaiterRegistry, and Subscribers in order, and a Unix-socket accept loop that spawns one thread per connection. bind_socket handles the stale- socket (crashed prior run) and already-running (live daemon) cases. Also removes the #[allow(dead_code)] annotations Tasks 2-5 left on state/waiters/request/connection now that main.rs calls everything; clippy --all-targets -D warnings is clean with none of them restored. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…stderr Spawned ds_agentd's stdout/stderr now go to ds_agentd.log next to the socket instead of /dev/null, and the timeout/spawn-failure messages name that log path, so a startup crash (e.g. port already bound) is diagnosable instead of just "did not become ready in time". The daemon_unreachable JSON error is now written to both stdout and stderr in main() and in `daemon status`, per spec. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
WaiterRegistry::register now takes a predicate so an unsolicited or stale same-kind event (the RC's NOTIFY_INIT_OP_MODE $Stop$Robot$ after a stop) can't resolve an unrelated request. Init/Run match on the opmode name; everything else accepts any event of its kind (LynxModules can't be payload-matched — its response doesn't carry the serial). handle_request now fails fast with not_connected for every command except ping/status, via one ensure_connected gate ahead of dispatch, instead of letting stop/restart/config writes/gamepad go out with no peer. Also drops the stale 'Task N' comments and the dead_code allow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…eep $Stop$Robot$ out of last_inited bind_socket() now runs first so the 'already running -> exit 0' path is reachable with default ports, and a UDP bind failure (Deck Station or ds_cli holding 20884) prints a diagnosable message, removes the socket file so it isn't left stale, and exits 1 instead of panicking. The event pump exits the process when the robocol worker's event stream ends, so a daemon can't linger answering connected:true from a frozen cache. DaemonState no longer records $Stop$Robot$ as last_inited, so a bare 'run' after 'stop' still targets the user's last real opmode. Also drops connection.rs's stale 'Task 6' comment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…NTD_ARGS through
The auto-spawned ds_agentd now gets its own process group so it outlives
the one-shot CLI and any shell job control around it, and DS_AGENTD_ARGS
(split on whitespace) is forwarded to it so a caller can pick the peer or
bind port without running the daemon by hand.
'daemon stop' now waits for the daemon's {"ok":true} instead of
fire-and-forget; 'daemon status' always carries a 'running' field. All
unreachable-daemon exits go through one print_unreachable helper that
emits the same JSON shape on stdout and stderr, including the post-connect
write failure that used to print bare text.
The spawn log lives at socket_path().with_extension("log") so the /tmp
fallback is per-uid like the socket, and '-1s'/'nans' durations are
rejected instead of wrapping.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…auto-spawn The fake_rc test now polls try_wait for up to 2 s after 'daemon stop' and fails if the daemon is still alive, and reads the first watch line on a helper thread with a 5 s recv_timeout so a silent daemon can't hang the suite (the kill-on-drop EOFs the helper). A second test drives the auto-spawn path end to end: DS_AGENTD_ARGS points the spawned daemon at a dead port, 'status' answers ok/connected:false, the daemon's log file exists beside the socket, 'daemon status' reports running:true, and after 'daemon stop' the socket stops accepting. A Drop guard re-issues 'daemon stop' and removes the runtime dir so a panic mid-test can't orphan the detached daemon. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A payload-matched waiter (init Foo) is only removed by a later
OpModeInited("Foo"); if that never comes, each timed-out attempt
left a permanent entry in WaiterRegistry. register now returns a
WaiterId and wait_for_event unregisters it on timeout.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Points external/robocol at whitehml/robocol#4 (headless-agent, 74f26fc) so the ds_agent commands documented in the README exist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Requesting changes:
The daemon is a good idea: a stateless caller cannot hold a stateful protocol
connection, and dropping the DS link very likely stops a running OpMode on the
RC.
What I want changed is the framing. As written this is an agent-only side tool
that leaves ds_cli untouched. I want to be able to monitor RC state while the
agent works, so ds_cli and ds_agent should have feature parity. Update
ds_cli to match.
Minimal version (this PR)
ds_cli and ds_agent are two front ends over one ds_daemon. Deck Station is
untouched and keeps using robocol as a plain library. Source/dev only, Unix
only, robocol repo only.
No work here affects the GUI: it reads the protocol directly and does not use
the daemon.
Possible stretch goal
Deck Station's RobocolBridge becomes a daemon client too, so the GUI, CLI and
agent share one connection and one centrally managed gamepad model, with each
front end seeing the others' state. That version has to solve JPEG frames over
IPC, work on Windows, ship as a product without source, and leave no stale
daemons. It touches both repos and is a re-scoping of the project, so it gets a
much stricter review bar and I will require many more cycles of review before I
let it anywhere near a dev branch.
This would in theory let the agent control the robot while we monitor the live
camera feeds, graphed telemetry values, and field graphics. We could also drive
one gamepad while letting the agent drive the other live.
Do not build for it now. Just do not paint it out: keep handle_request free of
socket assumptions, and keep the wire protocol transport-agnostic.
Requested changes
1. Rename both crates for their new scope. ds_agentd becomes ds_daemon;
it is no longer agent-specific. ds_agent_ipc becomes ds_proto: it is about
to gain a third consumer and most of the shared surface, so it is neither
agent-specific nor only about IPC any more.
2. One command surface, not three. Today the verb list exists three times:
ds_cli/src/main.rs:110-168, ds_agent/src/main.rs:20-62, and the Request
enum in ds_agentd/src/request.rs:17-95. They have already diverged: ds_cli
has w and quit, ds_agent has gamepad, status, watch and
daemon status|stop.
ds_proto becomes the single shared surface, holding:
- socket path and line framing, as today
Request, itsverb()and the const parse table (see 7)- one
parse(&[&str]) -> Result<Request, String> - one
render(&Value) -> Stringfor human output - the client-side watch filter, which both front ends need (see 6)
It gains a serde dep, moved over from ds_agentd with Request's
Deserialize derive. Keep the transport bits in their own module so the seam is
drawn if the stretch goal ever forces a split, but do not split now.
ds_cli becomes a daemon client rather than a second RobocolClient owner, and
gets everything this PR added for free. Drop the hardcoded w pulse; gamepad
with flags supersedes it. Keep a --json on ds_cli and a --pretty on
ds_agent so they are genuinely the same tool in two modes.
2a. Only ds_daemon may depend on robocol. Request is plain data
(String, f32, bool, Option, Vec) with no robocol types in it, and
build_gamepad stays behind in the daemon's dispatch, so moving Request out
does not drag robocol into ds_proto. That leaves ds_cli and ds_agent
depending on ds_proto alone, with ds_cli dropping its current direct
robocol dep. Please hold this line in the Cargo manifests: it is what stops a
front end from quietly reaching around the daemon.
3. Persistent connections. connection.rs:80-96 reads exactly one request
per connection and returns. A REPL needs many over one connection. Loop until
EOF. This is small, but it is a protocol-shape decision and much cheaper now.
4. Per-connection session state. last_inited lives in the global
DaemonState, so with two clients connected a bare run can pick up the other
client's init. Move it onto the connection.
5. Gamepad ownership, arbitrated by the daemon. Operator preempts agent.
This is a dev and debugging surface, not robot control, so the goal is a clear
seam rather than a safety interlock.
Gamepad::default()setsuser: 1(robocol/src/packets/gamepad.rs:53) and
build_gamepadinrequest.rsends with..Gamepad::default(), so the
Gamepadrequest has nouserfield and the agent is permanently gamepad1.
Add it. The agent may hold either pad or both.claim gamepad1|gamepad2|bothandrelease, with the daemon holding a simple
user -> ownertable. No TTL lease needed: ads_cliclaim always succeeds
and preempts whatever the agent held, so stale claims are self-correcting.- On transfer the daemon sends a neutral packet for that user before the new
owner's first input. This is the load-bearing part. It guarantees the
operator never inherits whatever stick value the agent left behind, and it
doubles as the recovery path for a stuck stick. - The neutral must target the user being transferred.
Gamepad::default()is
hardcodeduser: 1, so releasing or preempting gamepad2 would currently
neutralize the wrong pad. - An agent claim on a pad the operator holds is rejected with a distinct error
code, so the agent is told it was preempted instead of silently fighting. - Emit a takeover event on
watch, so an agent streaming events can see it lost
the pad.
5a. Pulses must repeat. Gamepad packets are not in the ack/retransmit path,
so the current shape (one packet, sleep, one neutral) is two unacked datagrams.
A dropped first packet means the command silently did not happen; a dropped
neutral leaves the pad deflected. Deck Station is immune to this by accident
because it re-sends at 50 Hz. Send the pulse as a repeated stream for its
duration with a short neutral tail. For a debugging tool the silent no-op is the
worse of the two failures: you cannot trust what you are observing.
5b. fake_rc ignores user 2. fake_rc/src/main.rs:552,574 drops any packet
with gp.user != 1, so a gamepad2 claim cannot be exercised against the mock.
Teach it user 2, and more generally make fake_rc a more robust mock surface.
6. Telemetry filtering. watch --types filters by event type. Add filtering
by telemetry tag and by key, so an operator or an agent can limit feedback to
specific streams and manage context. Apply it client-side: this is end-user
presentation, not passthrough or control behavior, which is the daemon's domain.
7. A parity test, backed by an exhaustive match. The test is the smaller
half of this. Give Request a verb(&self) -> &'static str whose match is
exhaustive, so a new variant does not compile until someone gives it a verb.
That is the real guard, and it fires at build time rather than in CI.
Then build the parser from one const table of (verb, parse_fn) pairs, so the
verb list and the parser cannot drift apart, and have the test walk that table
asserting that each verb parses and that req.verb() returns the verb it came
from.
That catches a variant with no verb (compiler), a verb wired to the wrong
variant, and a verb the parser no longer accepts. It does not catch a variant
that has a verb() arm but no table entry, because stock Rust cannot enumerate
instances of a data-carrying enum. Left alone, such a variant is dead code
reachable from nothing, which is a much smaller problem than a command only one
front end can reach.
8. Detect an RC that already has a driver station. The protocol has a
dedicated signal for this and we are ignoring it. PeerType includes
NotConnectedDueToPreexistingConnection = 3
(robocol/src/packets/peer_discovery.rs:13), which is what the RC answers when
another DS already owns it.
Nothing reads it. handle_datagram (robocol/src/client.rs:401-431) latches
self.peer and emits Event::Connected on any parseable datagram before it
looks at the packet, then discards the discovery reply via
Packet::PeerDiscovery(_). So against a busy RC the client reports connected,
heartbeats a peer that will never answer, ensure_connected passes, and every
command dies with a bare timeout and no hint why.
- Read
peer_typeon the discovery reply. On
NotConnectedDueToPreexistingConnection, do not latch the peer and do not
emitConnected. Surface a distinct event instead. - Fix the ordering while you are in there: inspect the packet before latching
self.peer. ds_daemonshould stop attempting to connect, report the issue back to its clients,
and wait for further instruction before attempting to connect again.
Two consequences to handle. ds_agent auto-spawns the daemon, so if the daemon
exits, the next invocation respawns it and it exits again; the reason has to
reach the user through ds_agent's own output, not only the daemon log, or it
reads as a silent daemon_unreachable loop. And fake_rc needs a mode that
answers peer_type 3 so this is testable, which folds into the mock work in 5b.
9. Docs. Update the repo README with minimal, human-readable instructions
for using the CLI and the agent. Show terminal command examples, use plain
language, and keep to a terse instruction-manual style.
Summary
Headless, scriptable access to the driver station for a desktop AI agent (or any script), with full command parity with
ds_cli. Three new workspace crates next tods_cli:ds_agent_ipc— socket path ($XDG_RUNTIME_DIR/ds_agentd.sock, else/tmp/ds_agentd-<uid>.sock) and newline-delimited JSON framing shared by both sides.ds_agentd— daemon owning one persistentRobocolClient. Keeps aDaemonStatesnapshot (connection, opmode list, configs, telemetry per tag, scan/lynx results), resolves each request against the event stream with payload-matched one-shot waiters, and fans events out towatchsubscribers over bounded queues. Binds the Unix socket before UDP so a second instance exits cleanly and a held port 20884 (Deck Station GUI /ds_cli) is diagnosed instead of panicking.ds_agent— one-shot CLI: argv → one JSON request → JSON reply on stdout, exit 0/1 onok. Auto-spawnsds_agentddetached (own process group, stderr →ds_agentd.logbeside the socket) when no daemon answers.daemon status|stop,watch [--types a,b],gamepad --left-stick-y -1 --duration 300ms, etc.Error codes:
daemon_unreachable,not_connected,unknown_opmode,unknown_config,timeout,bad_args.DS_AGENT_TIMEOUT_MSoverrides the 5 s wait;DS_AGENTD_ARGSpasses--peer/--peer-port/--bind-portthrough an auto-spawn.No changes to
robocol,fake_rc, ords_cli. Unix-only (Unix domain sockets). No MCP server and no webcam frames in this phase — the design leaves both as thin follow-ons.Design spec and plan live in deck-station: whitehml/deck-station#14, which bumps the submodule to this branch.
Test plan
scripts/lint.shclean (fmt, clippy-D warnings,cargo test --workspace)ds_agentdunit tests (state, waiters, request dispatch, connection handling), 9ds_agent, 4ds_agent_ipcfake_rc→ds_agentd→ds_agentthroughlist → init → run → stop → watch(telemetry) → daemon stop; and the auto-spawn path (log file created, daemon exits ondaemon stop)cargo run -p ds_agent -- list,init <name>,run,stop🤖 Generated with Claude Code