Skip to content

Commit 3dcc72b

Browse files
abizerclaudecursoragent
authored
nssh: add status subcommand with --tail (#5)
## Summary - `nssh status` — list active outbound sessions (local) and/or the current pinned session (remote). Same command, same output shape, works on either side. - `nssh status --tail` — pretty-print jsonl events from all active sessions, multiplexed with `[target] HH:MM:SS event key=value …` prefixes. Ctrl+C to stop. - Introduces a lightweight session registry at `~/.local/state/nssh/sessions/<pid>.json` — written on connect, removed on exit, GC'd on stale-PID detection. ## Example ``` $ nssh status active local sessions: PID TARGET TOPIC UPTIME LOG 83421 devbox nssh_xyz 2h14m ~/.local/state/nssh/nssh.nssh_xyz.jsonl $ nssh status --tail following 1 session(s) — Ctrl+C to stop [devbox] 16:42:01 message-in kind=clip-read-request id=abc [devbox] 16:42:02 message-in kind=open url=https://… ``` On remote hosts the command projects `~/.local/state/nssh/session` (the TOML written by the local session-init) into the same shape, so `nssh status` on the remote shows topic/server/log. The two views aren't mutually exclusive — a machine that's both a local driver and a remote target gets both sections. ## Test plan - [x] `go build ./...`, `go vet ./...`, `go test ./...` all pass - [x] `nssh status` prints `no active nssh sessions` on a clean machine - [x] Fake registry entry renders in the table - [x] Impossible PID (999999) is GC'd on the next `status` call - [ ] Open a real `nssh <host>` session; confirm it appears in `nssh status`, disappears on exit - [ ] `nssh status --tail` prints live events during clipboard/xdg-open activity 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Medium risk because it adds new session lifecycle bookkeeping (pidfile registry + cleanup) and changes logging/event emission paths used during active sessions, which could affect observability and session exit behavior. > > **Overview** > Adds `nssh status [--tail]` to display active local sessions (via a new `~/.local/state/nssh/sessions/<pid>.json` registry) and the current remote pinned session (projected from `~/.local/state/nssh/session`), with `--tail` multiplexing and pretty-printing new JSONL log events. > > Unifies wire-message logging across the session and shim by introducing `logMessage` (`msg-send`/`msg-recv` with `kind`/`mime`/`id`/`url`/`size`), updates clipboard/open paths to emit these events, and ensures sessions unregister even when exiting via `os.Exit`. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 275c972. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent 8a4c4cf commit 3dcc72b

5 files changed

Lines changed: 417 additions & 27 deletions

File tree

cmd/nssh/clipboard.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,10 @@ func handleClipReadRequest(env wire.Envelope, topicURL string) {
7070
resp := wire.Envelope{Kind: "clip-read-response", ID: env.ID}
7171
resp.Body = base64.StdEncoding.EncodeToString([]byte("ERROR: " + err.Error()))
7272
body, _ := json.Marshal(resp)
73-
ntfy.PublishMessage(topicURL, string(body))
73+
if perr := ntfy.PublishMessage(topicURL, string(body)); perr != nil {
74+
fmt.Fprintf(os.Stderr, "nssh: clip-read error response: %v\n", perr)
75+
}
76+
logMessage("out", resp, 0)
7477
return
7578
}
7679

@@ -92,4 +95,5 @@ func handleClipReadRequest(env wire.Envelope, topicURL string) {
9295
fmt.Fprintf(os.Stderr, "nssh: clip-read response: %v\n", err)
9396
}
9497
}
98+
logMessage("out", resp, len(data))
9599
}

cmd/nssh/log.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import (
77
"path/filepath"
88
"sync"
99
"time"
10+
11+
"github.com/abizer/nssh/v2/internal/wire"
1012
)
1113

1214
var (
@@ -64,3 +66,29 @@ func logErr(where string, err error) {
6466
fmt.Fprintf(os.Stderr, "nssh: %s: %v\n", where, err)
6567
logEvent("error", map[string]any{"where": where, "err": err.Error()})
6668
}
69+
70+
// logMessage emits a msg-send or msg-recv event with a consistent schema so
71+
// both sides of the tunnel produce the same wire-event shape. "dir" is "in"
72+
// when the envelope arrived from the topic, "out" when we're publishing.
73+
// size is the payload size in bytes — attachment size for images, decoded
74+
// body length for inline text, 0 if unknown.
75+
func logMessage(dir string, env wire.Envelope, size int) {
76+
event := "msg-recv"
77+
if dir == "out" {
78+
event = "msg-send"
79+
}
80+
fields := map[string]any{"kind": env.Kind}
81+
if env.Mime != "" {
82+
fields["mime"] = env.Mime
83+
}
84+
if env.ID != "" {
85+
fields["id"] = env.ID
86+
}
87+
if env.URL != "" {
88+
fields["url"] = env.URL
89+
}
90+
if size > 0 {
91+
fields["size"] = size
92+
}
93+
logEvent(event, fields)
94+
}

cmd/nssh/main.go

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package main
33
import (
44
"bufio"
55
"context"
6+
"encoding/base64"
67
"encoding/json"
78
"fmt"
89
"net"
@@ -153,23 +154,18 @@ func handleMessage(msg ntfy.Msg, topicURL, sshTarget string) {
153154
env, ok := wire.Parse(msg.Message)
154155
if !ok {
155156
fmt.Fprintf(os.Stderr, "nssh: ignoring unrecognized message (%d bytes)\n", len(msg.Message))
156-
logEvent("message-ignored", map[string]any{"size": len(msg.Message)})
157+
logEvent("msg-unknown", map[string]any{"size": len(msg.Message)})
157158
return
158159
}
159-
fields := map[string]any{"kind": env.Kind}
160-
if env.Mime != "" {
161-
fields["mime"] = env.Mime
162-
}
163-
if env.ID != "" {
164-
fields["id"] = env.ID
165-
}
166-
if env.URL != "" {
167-
fields["url"] = env.URL
168-
}
160+
size := 0
169161
if msg.Attachment != nil {
170-
fields["attachment_size"] = msg.Attachment.Size
162+
size = int(msg.Attachment.Size)
163+
} else if env.Body != "" {
164+
if decoded, err := base64.StdEncoding.DecodeString(env.Body); err == nil {
165+
size = len(decoded)
166+
}
171167
}
172-
logEvent("message-in", fields)
168+
logMessage("in", env, size)
173169

174170
switch env.Kind {
175171
case "open":
@@ -302,6 +298,7 @@ func usage() {
302298
fmt.Fprintln(os.Stderr, " nssh [--ssh|--mosh] <host> [ssh args...] open a session")
303299
fmt.Fprintln(os.Stderr, " nssh infect [--force] <host> install on a remote host")
304300
fmt.Fprintln(os.Stderr, " nssh infect [--force] self symlink personas on this machine")
301+
fmt.Fprintln(os.Stderr, " nssh status [--tail] show active sessions")
305302
fmt.Fprintln(os.Stderr, " nssh --version print version info")
306303
os.Exit(1)
307304
}
@@ -357,6 +354,9 @@ func main() {
357354
case "infect":
358355
infectCmd(os.Args[2:])
359356
return
357+
case "status":
358+
statusCmd(os.Args[2:])
359+
return
360360
case "-v", "--version":
361361
printVersion()
362362
return
@@ -448,6 +448,12 @@ func nsshMain() {
448448
"server": cfg.Server,
449449
})
450450

451+
sessionFile, err := registerSession(cfg, sshTarget)
452+
if err != nil {
453+
fmt.Fprintf(os.Stderr, "nssh: register session: %v\n", err)
454+
}
455+
defer unregisterSession(sessionFile)
456+
451457
// One SSH login-shell to probe version, write the session file, and seed
452458
// the remote JSONL log before the interactive session starts.
453459
remoteVer := prepareRemote(sshTarget, cfg)
@@ -493,13 +499,14 @@ func nsshMain() {
493499
session = exec.Command("ssh", sshArgs...)
494500
}
495501

496-
err := runSession(session, sigs)
502+
sessErr := runSession(session, sigs)
497503
resetTerminal()
498504
exitCode := 0
499-
if exitErr, ok := err.(*exec.ExitError); ok {
505+
if exitErr, ok := sessErr.(*exec.ExitError); ok {
500506
exitCode = exitErr.ExitCode()
501507
}
502508
logEvent("session-end", map[string]any{"exit": exitCode, "mosh": useMosh})
509+
unregisterSession(sessionFile) // defers don't fire under os.Exit
503510
if exitCode != 0 {
504511
os.Exit(exitCode)
505512
}

cmd/nssh/shim.go

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -34,24 +34,19 @@ func shimClipWrite(topicURL, mime string) {
3434
os.Exit(1)
3535
}
3636
if len(data) == 0 {
37-
logEvent("clip-write-empty", nil)
37+
logEvent("clip-write-empty", map[string]any{"mime": mime})
3838
return
3939
}
40-
logEvent("clip-write", map[string]any{"mime": mime, "size": len(data)})
4140

41+
env := wire.Envelope{Kind: "clip-write", Mime: mime}
4242
if len(data) <= inlineThreshold && !strings.HasPrefix(mime, "image/") {
43-
env := wire.Envelope{
44-
Kind: "clip-write",
45-
Mime: mime,
46-
Body: base64.StdEncoding.EncodeToString(data),
47-
}
43+
env.Body = base64.StdEncoding.EncodeToString(data)
4844
body, _ := json.Marshal(env)
4945
if err := ntfy.PublishMessage(topicURL, string(body)); err != nil {
5046
fmt.Fprintf(os.Stderr, "nssh: %v\n", err)
5147
os.Exit(1)
5248
}
5349
} else {
54-
env := wire.Envelope{Kind: "clip-write", Mime: mime}
5550
msg, _ := json.Marshal(env)
5651
filename := "clip.dat"
5752
if strings.HasPrefix(mime, "image/png") {
@@ -62,19 +57,20 @@ func shimClipWrite(topicURL, mime string) {
6257
os.Exit(1)
6358
}
6459
}
60+
logMessage("out", env, len(data))
6561
}
6662

6763
func shimClipRead(topicURL, mime string) {
6864
id := strconv.FormatInt(time.Now().UnixNano(), 36)
6965
since := strconv.FormatInt(time.Now().Unix(), 10)
70-
logEvent("clip-read-request", map[string]any{"mime": mime, "id": id})
7166

7267
req := wire.Envelope{Kind: "clip-read-request", ID: id, Mime: mime}
7368
body, _ := json.Marshal(req)
7469
if err := ntfy.PublishMessage(topicURL, string(body)); err != nil {
7570
fmt.Fprintf(os.Stderr, "nssh: publish read request: %v\n", err)
7671
os.Exit(1)
7772
}
73+
logMessage("out", req, 0)
7874

7975
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
8076
defer cancel()
@@ -112,7 +108,7 @@ func shimClipRead(topicURL, mime string) {
112108
logEvent("clip-read-error", map[string]any{"id": id, "err": string(data)})
113109
os.Exit(1)
114110
}
115-
logEvent("clip-read-resolved", map[string]any{"id": id, "size": len(data), "inline": true})
111+
logMessage("in", env, len(data))
116112
os.Stdout.Write(data)
117113
return
118114
}
@@ -122,7 +118,7 @@ func shimClipRead(topicURL, mime string) {
122118
fmt.Fprintf(os.Stderr, "nssh: fetch attachment: %v\n", err)
123119
os.Exit(1)
124120
}
125-
logEvent("clip-read-resolved", map[string]any{"id": id, "size": len(data), "inline": false})
121+
logMessage("in", env, len(data))
126122
os.Stdout.Write(data)
127123
return
128124
}
@@ -166,6 +162,7 @@ func doXdgOpen(args []string) {
166162
logEvent("publish-failed", map[string]any{"kind": "open", "err": err.Error()})
167163
runFallback("xdg-open", args)
168164
}
165+
logMessage("out", env, 0)
169166
}
170167

171168
func doXclip(args []string) {

0 commit comments

Comments
 (0)