feat: serve yoink over MCP with yoink mcp - #12
Merged
Conversation
monitorProcess writes info.State from its own goroutine the moment a process exits, but SendInput, Kill and GetPTY read it after releasing the read lock — a data race whenever a client acts on a process that is exiting at the same time. Route all three through a lookupRunning helper that does the map lookup and the state check under one read lock, matching what CleanAll already does. Adds a regression test that trips the race detector before the fix.
Agents drive yoink by shelling out and parsing `yoink ls` output, which
means string surgery on a display format built for humans. The two
behaviors that make yoink useful to an agent — `log --new` for
incremental polling and `snapshot` for TUIs — are also invisible unless
someone loads a skill file that mentions them.
`yoink mcp` serves the daemon over the Model Context Protocol on stdio,
exposing eight tools that return structured state. It is a client of the
daemon rather than a second process manager, so the CLI and an agent
share one process table: an agent's `yoink_ls` lists the dev server you
started by hand, and you can `yoink attach` to what an agent spawned.
Nothing new to install — it compiles into the existing binary:
claude mcp add --scope user yoink -- yoink mcp
`yoink_wait` is bounded and resumable, since MCP clients cap tool-call
duration: hitting timeoutSeconds returns done=false/timedOut=true with
the drained output rather than an error, and the agent calls again.
`attach` is not exposed (it needs a controlling TTY); send-redacted and
--type are flags on `yoink_send` rather than separate tools.
golangci-lint reported 33 unchecked error returns across the repo. Nearly all are deliberate — writes to stdout, Close/Remove on cleanup paths, fire-and-forget calls in tests — so they are now marked as such rather than left ambiguous. One was a latent bug: `yoink ls` discarded the tabwriter Flush error, so a failed flush would silently print truncated output. That error is now returned. golangci-lint run is clean.
✅ PR title follows the required formatCurrent title: |
dupehound flagged the eight near-identical AddTool blocks in registerTools. The SDK's AddTool is generic over each tool's input and output types, so the handlers cannot go in a table; a thin addTool wrapper gets each registration down to one call instead. Also extracts connectMCPTo so the shared-daemon test reuses the standard connection setup rather than repeating it.
The rationale that mattered already lives where readers look for it: wait semantics and the not-exposed commands in docs/COMMANDS.md, the daemon-client design in docs/README.md.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Serves yoink over the Model Context Protocol so agents manage background processes as native tools instead of shelling out and parsing terminal output.
Why
Agents drive yoink today by running the CLI and reading the text back. Three problems:
log --new(incremental polling) andsnapshot(rendered TUI screen) are what make yoink worth using from an agent, and both only get discovered if a skill file mentions them.wait. Blocking on N processes with a timeout is the one thing an agent can't express as a shell call.What
yoink mcpserves the daemon over MCP on stdio. It is a client of the daemon, not a second process manager — each tool call becomes the sameDaemonRequestthe CLI sends.That means the CLI and an agent share one process table:
An agent's
yoink_lslists the dev server you started by hand, and you canyoink attachto something an agent spawned.Eight tools:
yoink_run,yoink_ls,yoink_log,yoink_send,yoink_snapshot,yoink_wait,yoink_kill,yoink_clean.Not exposed:
attach(needs a controlling TTY),daemon(implementation detail),version(in the handshake).send-redactedand--typeare flags onyoink_sendrather than separate tools.Install
Nothing new to download — it compiles into the existing binary:
yoink_waitis bounded and resumableMCP clients cap tool-call duration (commonly ~60s), so a naive
wait --timeout 300would be killed by the client and surface as a transport error.Instead
timeoutSecondsdefaults to 30, clamps to 55, and hitting it is not an error:{ "done": false, "timedOut": true, "processes": [ { "process": "build", "state": "running", "output": "compiling...\n" } ] }The agent calls again to keep waiting. Draining uses the same cursor as
log --new, so resuming never re-reads output it already saw. A failed process is likewise a normal result (state: "failed"withexitCode), not a tool error — errors are reserved for malformed requests and an unreachable daemon.Also in here
A data race fix in
ProcessManager(4f4b4de), found by the new tests under-race.monitorProcesswritesinfo.Statefrom its own goroutine the instant a process exits, butSendInput,KillandGetPTYread it after releasing the read lock. Any client acting on a process that is exiting concurrently raced. All three now go through alookupRunninghelper that does the lookup and state check under one lock.This is in the shared core, so it fixes
yoink send/kill/attachfor CLI users too, not just MCP.Lint cleanup (
8d657f8): 33 unchecked error returns made explicit. Nearly all were deliberate (stdout writes, cleanup-pathClose, fire-and-forget test calls). One was a latent bug —yoink lsdiscarded the tabwriterFlusherror, silently printing truncated output on failure.golangci-lint runis now clean.Tests
Everything runs real — real daemon, real PTYs, real processes, no mocks.
services/mcp_service_test.go— MCP server over an in-memory transport against a real daemon: tool surface, run/log/ls round-trip,log --newreturning each line exactly once,sendreaching a process blocked onread,send --key ctrl+c, snapshot rendering, wait timeout/failure/multi-process/clamping, kill + clean, and 12 negative cases (unknown alias, mutually exclusive args, unknown key name).integration/mcp_test.go— builds the binary and speaks MCP over stdio toyoink mcp: handshake, tool list, run/wait round-trip, CLI and MCP sharing one daemon in both directions, and a stdout-cleanliness regression check (stdout is the protocol stream).services/process_manager_test.go— race regression test that trips-racebefore the fix.go test -race ./...— 215 passing, run 4x for flakiness.gofmt,go vet,golangci-lintall clean.