Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .structlint.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ dir_structure:
- "helpers/**"
- "integration/**"
- "docs/**"
- "scripts/**"
- ".github/**"
- ".claude/**"
disallowedPaths:
Expand All @@ -27,6 +28,7 @@ file_naming_pattern:
- "*.go"
- "*.mod"
- "*.sum"
- "*.sh"
- "*.yaml"
- "*.yml"
- "*.json"
Expand Down
24 changes: 22 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,29 @@ yoink log deploy --lines 100
# Show only new lines since last --new call
yoink log deploy --new

# Render the current screen of any process — works correctly for TUI apps
# (yoink log gives raw escape garbage for TUIs; snapshot renders the actual frame)
yoink snapshot htop
yoink snap htop # short alias

# Send input to a process waiting for confirmation
yoink send deploy "y"

# Send input character by character (simulates typing — great for asciinema recordings)
yoink send deploy "hello world" --type

# Control typing speed with --delay <ms> (default 50ms)
yoink send deploy "hello world" --type --delay 100

# Send without pressing Enter (useful for prompts that read a single key)
yoink send deploy "y" --no-enter

# Send sensitive input (passwords, tokens) — hidden from logs
yoink send-redacted deploy "my-secret-token"

# Flags work on send-redacted too
yoink send-redacted deploy "my-secret" --type --delay 80

# Full interactive reattach (Ctrl+] to detach)
yoink attach deploy

Expand Down Expand Up @@ -66,8 +83,9 @@ yoink runs a lightweight daemon that holds PTY (pseudo-terminal) sessions open f
- **ls**: Lists all processes with state (running/done/failed), PID, exit code
- **log**: Shows buffered output (last 1000 lines kept per process). Use `--new` to see only lines since the last `--new` call
- **wait**: Polls a process until it finishes, streaming new output to stdout on each cycle. Exits 0 on success, propagates the process exit code on failure, or exits 124 on timeout (matches GNU `timeout` convention). Flags: `--timeout` (seconds, 0=forever), `--poll` (seconds, default 2), `--message` (custom timeout error)
- **send**: Writes text to the process stdin (fire-and-forget)
- **send-redacted**: Same as send, but input is masked in logs
- **snapshot** (`snap`): Renders the current VT100 screen of a process as plain text. Unlike `log`, this correctly handles TUI applications (htop, vim, Claude, etc.) by replaying their escape sequences through a VT100 emulator and returning only the rendered content. Works on both running and finished processes
- **send**: Writes text to the process stdin (fire-and-forget). Flags: `--type` (character by character), `--delay` (ms between chars, default 50), `--no-enter` (omit trailing newline)
- **send-redacted**: Same as send, but input is masked in logs. Supports the same `--type`/`--delay`/`--no-enter` flags
- **attach**: Bridges your terminal to the process PTY for full interactive control
- **kill**: Sends SIGTERM (or SIGKILL with --force)
- **clean**: Removes finished processes from the list
Expand All @@ -79,6 +97,8 @@ The daemon auto-starts when needed and stores its socket at `~/.yoink/yoink.sock
| Feature | Linux | macOS | Windows |
|---------|-------|-------|---------|
| run/ls/log/wait/send/send-redacted/kill/clean | Yes | Yes | Planned |
| send --type / --delay / --no-enter | Yes | Yes | Planned |
| snapshot (VT100 screen render) | Yes | Yes | Planned |
| attach (PTY reattach) | Yes | Yes | Planned |

## Development
Expand Down
13 changes: 8 additions & 5 deletions actions/send.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,14 @@ func (a *SendAction) Execute(c *cli.Context) error {
input := strings.Join(c.Args()[1:], " ")

resp, err := a.client.Do(domain.DaemonRequest{
Type: domain.ReqSend,
ID: id,
Alias: alias,
Input: input,
Redacted: a.redacted,
Type: domain.ReqSend,
ID: id,
Alias: alias,
Input: input,
Redacted: a.redacted,
TypeMode: c.Bool("type"),
TypeDelay: c.Int("delay"),
NoEnter: c.Bool("no-enter"),
})
if err != nil {
return err
Expand Down
46 changes: 46 additions & 0 deletions actions/snapshot.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package actions

import (
"fmt"

"github.com/AxeForging/yoink/domain"
"github.com/AxeForging/yoink/services"
"github.com/urfave/cli"
)

// SnapshotAction renders the current VT100 screen of a process as plain text.
type SnapshotAction struct {
client *services.ClientService
}

// NewSnapshotAction creates a SnapshotAction.
func NewSnapshotAction(client *services.ClientService) *SnapshotAction {
return &SnapshotAction{client: client}
}

// Execute prints the rendered screen of the process to stdout.
func (a *SnapshotAction) Execute(c *cli.Context) error {
if c.NArg() < 1 {
return fmt.Errorf("usage: yoink snapshot <id|alias>")
}

id, alias := parseProcessRef(c.Args().First())

resp, err := a.client.Do(domain.DaemonRequest{
Type: domain.ReqSnapshot,
ID: id,
Alias: alias,
})
if err != nil {
return err
}
if !resp.Success {
return fmt.Errorf("%s", resp.Error)
}

fmt.Print(resp.Output)
if len(resp.Output) > 0 && resp.Output[len(resp.Output)-1] != '\n' {
fmt.Println()
}
return nil
}
20 changes: 15 additions & 5 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,31 @@
yoink uses a daemon/client architecture:

```
yoink run "cmd" --> yoinkd (daemon) --> PTY --> child process
yoink ls --> yoinkd --> process table
yoink log 1 --> yoinkd --> ring buffer (last N lines)
yoink send 1 "y" --> yoinkd --> write to PTY fd
yoink attach 1 --> yoinkd --> bridge terminal <-> PTY
yoink run "cmd" --> yoinkd (daemon) --> PTY --> child process
yoink ls --> yoinkd --> process table
yoink log 1 --> yoinkd --> ring buffer (last N lines)
yoink snapshot 1 --> yoinkd --> VT100 emulator framebuffer (rendered screen)
yoink send 1 "y" --> yoinkd --> write to PTY fd (whole string + newline)
yoink send 1 "y" --type --> yoinkd --> write to PTY fd char by char with delay
yoink send 1 "y" --no-enter --> yoinkd --> write to PTY fd without trailing newline
yoink attach 1 --> yoinkd --> bridge terminal <-> PTY
```

### Daemon

The daemon (`yoink daemon`) listens on `~/.yoink/yoink.sock`. It manages:
- Process spawning with PTY allocation
- Output ring buffers (1000 lines per process)
- VT100 emulator per process (for `snapshot` support)
- Process lifecycle monitoring
- Attach/detach multiplexing

### Snapshot vs Log

`yoink log` reads raw lines from the ring buffer. For plain processes this is clean text, but TUI applications (htop, vim, any ncurses app) write cursor-movement and erase sequences that make `log` output unreadable.

`yoink snapshot` feeds all PTY bytes through a `vt10x` VT100 emulator maintained per process. At any point you can call `snapshot` to get the current rendered screen as plain text — exactly what you'd see if you were attached to the terminal. It works on running and finished processes alike.

### Protocol

Client-daemon communication uses JSON over Unix socket. Each connection handles one request-response pair, except for `attach` which switches to raw byte forwarding after the handshake.
Expand Down
22 changes: 13 additions & 9 deletions domain/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,19 +50,23 @@ const (
ReqKill RequestType = "kill"
ReqClean RequestType = "clean"
ReqShutdown RequestType = "shutdown"
ReqSnapshot RequestType = "snapshot"
)

// DaemonRequest is sent from client to daemon over the Unix socket.
type DaemonRequest struct {
Type RequestType `json:"type"`
ID int `json:"id,omitempty"`
Alias string `json:"alias,omitempty"`
Command string `json:"command,omitempty"`
Input string `json:"input,omitempty"`
Lines int `json:"lines,omitempty"`
New bool `json:"new,omitempty"`
Force bool `json:"force,omitempty"`
Redacted bool `json:"redacted,omitempty"`
Type RequestType `json:"type"`
ID int `json:"id,omitempty"`
Alias string `json:"alias,omitempty"`
Command string `json:"command,omitempty"`
Input string `json:"input,omitempty"`
Lines int `json:"lines,omitempty"`
New bool `json:"new,omitempty"`
Force bool `json:"force,omitempty"`
Redacted bool `json:"redacted,omitempty"`
TypeMode bool `json:"type_mode,omitempty"`
TypeDelay int `json:"type_delay,omitempty"` // milliseconds between characters
NoEnter bool `json:"no_enter,omitempty"`
}

// DaemonResponse is sent from daemon to client over the Unix socket.
Expand Down
16 changes: 16 additions & 0 deletions flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,19 @@ var messageFlag = cli.StringFlag{
Name: "message, m",
Usage: "Custom error message on timeout",
}

var typeModeFlag = cli.BoolFlag{
Name: "type",
Usage: "Send input character by character (simulates typing)",
}

var typeDelayFlag = cli.IntFlag{
Name: "delay, d",
Value: 50,
Usage: "Milliseconds between characters when using --type",
}

var noEnterFlag = cli.BoolFlag{
Name: "no-enter",
Usage: "Do not send a newline after the input",
}
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ go 1.25.0

require (
github.com/creack/pty v1.1.24
github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02
github.com/rs/zerolog v1.34.0
github.com/urfave/cli v1.22.17
golang.org/x/term v0.41.0
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02 h1:AgcIVYPa6XJnU3phs104wLj8l5GEththEw6+F79YsIY=
github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
Expand Down
Loading
Loading