Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
4fcb16a
docs: add waveattach design spec
dfbb Apr 27, 2026
c82d492
docs: revise waveattach design after technical review
dfbb Apr 27, 2026
185e0b4
docs: add waveattach implementation plan
dfbb Apr 27, 2026
b87ad6e
feat(waveattach): add auth package (data dir, db jwt key read, socket…
dfbb Apr 27, 2026
71763ad
fix(waveattach): use server-returned routeId from AuthenticateCommand
dfbb Apr 27, 2026
b72a9a6
feat(waveattach): add output streaming (snapshot + wps event subscrip…
dfbb Apr 27, 2026
6a62758
fix(waveattach): fix event data unmarshal, rename to makeEventBuffer,…
dfbb Apr 27, 2026
ace4618
feat(waveattach): add interactive block selector
dfbb Apr 27, 2026
99cd9c9
fix(waveattach): rename blockEntry fields to match spec (Workspace, Tab)
dfbb Apr 27, 2026
15a95e0
fix(waveattach): selector safety and style fixes
dfbb Apr 27, 2026
b956f75
feat(waveattach): add main attach loop with raw mode and ctrl+a d sta…
dfbb Apr 27, 2026
bc6e0cc
fix(waveattach): use Status_Done constant, return nil for normal exits
dfbb Apr 27, 2026
15c5e25
feat(waveattach): add cli entrypoint
dfbb Apr 27, 2026
04b3695
fix(waveattach): support platform-specific data dir paths (macOS Libr…
dfbb Apr 27, 2026
b0ef081
fix(waveattach): ignore transient input RPC errors to prevent attach …
dfbb Apr 27, 2026
b3d0259
fix(waveattach): add trailing newline to exit messages to prevent zsh…
dfbb Apr 27, 2026
8409e5b
feat(waveattach): restore wave block term size on detach
dfbb Apr 27, 2026
3cef289
feat(waveattach): integrate attach command into wsh
dfbb Apr 28, 2026
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
41 changes: 41 additions & 0 deletions cmd/wsh/cmd/wshcmd-attach.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

package cmd

import (
"github.com/spf13/cobra"
"github.com/wavetermdev/waveterm/pkg/waveattach"
)

var attachCmd = &cobra.Command{
Use: "attach [blockid]",
Short: "attach to a Wave Terminal block from an external terminal",
Long: "Attach to a running term block in Wave Terminal. Press Ctrl+A D to detach.",
Args: cobra.MaximumNArgs(1),
RunE: attachRun,
DisableFlagsInUseLine: true,
}

func init() {
rootCmd.AddCommand(attachCmd)
}

func attachRun(cmd *cobra.Command, args []string) error {
rpcClient, _, err := waveattach.Connect()
if err != nil {
return err
}

var blockId string
if len(args) == 1 {
blockId = args[0]
} else {
blockId, err = waveattach.SelectBlock(rpcClient)
if err != nil {
return err
}
}

return waveattach.Attach(rpcClient, blockId)
}
209 changes: 209 additions & 0 deletions pkg/waveattach/attach.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

package waveattach

import (
"bytes"
"context"
"encoding/base64"
"errors"
"fmt"
"io"
"os"
"os/signal"
"syscall"

"github.com/wavetermdev/waveterm/pkg/blockcontroller"
"github.com/wavetermdev/waveterm/pkg/util/utilfn"
"github.com/wavetermdev/waveterm/pkg/waveobj"
"github.com/wavetermdev/waveterm/pkg/wps"
"github.com/wavetermdev/waveterm/pkg/wshrpc"
"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient"
"github.com/wavetermdev/waveterm/pkg/wshutil"
"golang.org/x/term"
)

const ctrlA = 0x01

type prefixKey struct {
gotPrefix bool
}

func newPrefixKey() *prefixKey { return &prefixKey{} }

func (p *prefixKey) feed(b []byte, w io.Writer) (detach bool, err error) {
for _, c := range b {
if !p.gotPrefix {
if c == ctrlA {
p.gotPrefix = true
continue
}
if _, err := w.Write([]byte{c}); err != nil {
return false, err
}
continue
}
switch c {
case 'd', 'D':
return true, nil
case ctrlA:
if _, err := w.Write([]byte{ctrlA}); err != nil {
return false, err
}
default:
p.gotPrefix = false
if _, err := w.Write([]byte{ctrlA, c}); err != nil {
return false, err
}
Comment on lines +47 to +58
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reset the prefix state after Ctrl+A Ctrl+A.

The doubled-Ctrl+A branch writes a literal Ctrl+A but leaves gotPrefix set. The next byte is still interpreted as a detach suffix, so sequences like Ctrl+A Ctrl+A d detach instead of forwarding ^Ad, and Ctrl+A Ctrl+A x emits an extra Ctrl+A.

💡 Suggested fix
 		switch c {
 		case 'd', 'D':
 			return true, nil
 		case ctrlA:
+			p.gotPrefix = false
 			if _, err := w.Write([]byte{ctrlA}); err != nil {
 				return false, err
 			}
 		default:
 			p.gotPrefix = false
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/waveattach/attach.go` around lines 47 - 58, The Ctrl+A handling branch
leaves p.gotPrefix true after writing a literal Ctrl+A, causing the next byte to
be misinterpreted as a detach suffix; modify the case for ctrlA in the switch
inside the attach logic so that after successfully writing the literal ctrlA
(the write in the ctrlA branch) you also set p.gotPrefix = false (just like the
default branch does) so the prefix state is cleared and subsequent bytes are
forwarded correctly.

}
}
return false, nil
}

var ErrDetached = errors.New("detached")
var ErrBlockClosed = errors.New("block closed")

func Attach(rpcClient *wshutil.WshRpc, blockId string) error {
fd := int(os.Stdin.Fd())
if !term.IsTerminal(fd) {
return fmt.Errorf("stdin is not a terminal")
}
oldState, err := term.MakeRaw(fd)
if err != nil {
return fmt.Errorf("entering raw mode: %w", err)
}
defer term.Restore(fd, oldState)

origTermSize := getBlockTermSize(rpcClient, blockId)

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

exitCh := make(chan error, 3)

winchCh := make(chan os.Signal, 1)
signal.Notify(winchCh, syscall.SIGWINCH)
defer signal.Stop(winchCh)

sendTermSize := func() {
w, h, err := term.GetSize(fd)
if err != nil {
return
}
data := wshrpc.CommandBlockInputData{
BlockId: blockId,
TermSize: &waveobj.TermSize{Rows: h, Cols: w},
}
_ = wshclient.ControllerInputCommand(rpcClient, data, nil)
}
sendTermSize()

go func() {
for {
select {
case <-winchCh:
sendTermSize()
case <-ctx.Done():
return
}
}
}()

blockRef := waveobj.MakeORef(waveobj.OType_Block, blockId).String()
rpcClient.EventListener.On(wps.Event_ControllerStatus, func(ev *wps.WaveEvent) {
if !ev.HasScope(blockRef) {
return
}
var status blockcontroller.BlockControllerRuntimeStatus
if err := utilfn.ReUnmarshal(&status, ev.Data); err != nil {
return
}
if status.ShellProcStatus == blockcontroller.Status_Done {
exitCh <- ErrBlockClosed
}
})
subReq := wps.SubscriptionRequest{
Event: wps.Event_ControllerStatus,
Scopes: []string{blockRef},
}
if err := wshclient.EventSubCommand(rpcClient, subReq, nil); err != nil {
return fmt.Errorf("subscribing to controllerstatus events: %w", err)
}

go func() {
exitCh <- StreamOutput(ctx, rpcClient, blockId, os.Stdout)
}()

go func() {
exitCh <- inputLoop(ctx, rpcClient, blockId)
}()

exitErr := <-exitCh
cancel()
if origTermSize != nil {
restoreData := wshrpc.CommandBlockInputData{
BlockId: blockId,
TermSize: origTermSize,
}
_ = wshclient.ControllerInputCommand(rpcClient, restoreData, &wshrpc.RpcOpts{Timeout: 3000})
}
// ensure cursor is at column 0 before printing exit message
fmt.Fprintf(os.Stdout, "\r\n")
switch {
case errors.Is(exitErr, ErrDetached):
fmt.Fprintf(os.Stderr, "\r\n[detached]\r\n")
return nil
case errors.Is(exitErr, ErrBlockClosed):
fmt.Fprintf(os.Stderr, "\r\n[block closed]\r\n")
return nil
case exitErr != nil:
fmt.Fprintf(os.Stderr, "\r\n[error] %v\r\n", exitErr)
return exitErr
}
return nil
}

func inputLoop(ctx context.Context, rpcClient *wshutil.WshRpc, blockId string) error {
pk := newPrefixKey()
buf := make([]byte, 4096)
for {
n, err := os.Stdin.Read(buf)
if err != nil {
return err
}
var forward bytes.Buffer
detach, err := pk.feed(buf[:n], &forward)
if err != nil {
return err
}
if forward.Len() > 0 {
data := wshrpc.CommandBlockInputData{
BlockId: blockId,
InputData64: base64.StdEncoding.EncodeToString(forward.Bytes()),
}
// ignore transient RPC errors (e.g. timeout under rapid input) to keep the attach alive
wshclient.ControllerInputCommand(rpcClient, data, &wshrpc.RpcOpts{Timeout: 2000})
}
if detach {
return ErrDetached
}
select {
case <-ctx.Done():
return ctx.Err()
default:
}
}
}

func getBlockTermSize(rpcClient *wshutil.WshRpc, blockId string) *waveobj.TermSize {
info, err := wshclient.BlockInfoCommand(rpcClient, blockId, &wshrpc.RpcOpts{Timeout: 3000})
if err != nil || info == nil || info.Block == nil {
return nil
}
rtOpts := info.Block.RuntimeOpts
if rtOpts == nil || (rtOpts.TermSize.Rows == 0 && rtOpts.TermSize.Cols == 0) {
return nil
}
return &waveobj.TermSize{Rows: rtOpts.TermSize.Rows, Cols: rtOpts.TermSize.Cols}
}
81 changes: 81 additions & 0 deletions pkg/waveattach/attach_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

package waveattach

import (
"bytes"
"testing"
)

func TestPrefixKeyMachine_PlainBytesPassThrough(t *testing.T) {
m := newPrefixKey()
var out bytes.Buffer
det, err := m.feed([]byte("hello"), &out)
if err != nil || det {
t.Fatalf("unexpected: detach=%v err=%v", det, err)
}
if out.String() != "hello" {
t.Errorf("want 'hello', got %q", out.String())
}
}

func TestPrefixKeyMachine_DetachOnCtrlAD(t *testing.T) {
m := newPrefixKey()
var out bytes.Buffer
det, _ := m.feed([]byte{0x01, 'd'}, &out)
if !det {
t.Fatal("expected detach")
}
if out.Len() != 0 {
t.Errorf("expected nothing forwarded, got %q", out.String())
}
}

func TestPrefixKeyMachine_DetachOnCtrlACapitalD(t *testing.T) {
m := newPrefixKey()
var out bytes.Buffer
det, _ := m.feed([]byte{0x01, 'D'}, &out)
if !det {
t.Fatal("expected detach")
}
}

func TestPrefixKeyMachine_LiteralCtrlAByDoubling(t *testing.T) {
m := newPrefixKey()
var out bytes.Buffer
det, _ := m.feed([]byte{0x01, 0x01}, &out)
if det {
t.Fatal("did not expect detach")
}
if !bytes.Equal(out.Bytes(), []byte{0x01}) {
t.Errorf("want 0x01, got %v", out.Bytes())
}
}

func TestPrefixKeyMachine_PrefixThenOtherKey(t *testing.T) {
m := newPrefixKey()
var out bytes.Buffer
det, _ := m.feed([]byte{0x01, 'x'}, &out)
if det {
t.Fatal("did not expect detach")
}
if !bytes.Equal(out.Bytes(), []byte{0x01, 'x'}) {
t.Errorf("want [0x01 'x'], got %v", out.Bytes())
}
}

func TestPrefixKeyMachine_PrefixSplitAcrossReads(t *testing.T) {
m := newPrefixKey()
var out bytes.Buffer
if det, _ := m.feed([]byte{0x01}, &out); det {
t.Fatal("did not expect detach yet")
}
if out.Len() != 0 {
t.Errorf("expected buffered, got %q", out.String())
}
det, _ := m.feed([]byte{'d'}, &out)
if !det {
t.Fatal("expected detach on second feed")
}
}
Loading
Loading