Skip to content
Open
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
3 changes: 3 additions & 0 deletions frontend/types/gotypes.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ declare global {
lastnotification?: string;
startts?: number;
updatedts?: number;
interactive?: boolean;
endreason?: string;
};

// wshrpc.AiMessageData
Expand Down Expand Up @@ -1606,6 +1608,7 @@ declare global {
"debug:panictype"?: string;
"block:view"?: string;
"block:controller"?: string;
"block:subblock"?: boolean;
"ai:backendtype"?: string;
"ai:local"?: boolean;
"wsh:cmd"?: string;
Expand Down
3 changes: 3 additions & 0 deletions hooks/wave-agents-hook.sh
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ jq -c \
cwd: (.cwd // ""),
prompt: (.prompt // ""),
message: (.message // ""),
source: (.source // ""),
reason: (.reason // ""),
model: (.model // ""),
blockid: $blockid,
tabid: $tabid,
workspaceid: $workspaceid,
Expand Down
54 changes: 54 additions & 0 deletions pkg/agenttracker/agenttracker.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@ const (
Status_Ended = "ended"
)

// SessionEnd reasons that mean the user deliberately exited claude. Anything
// else -- notably "other", which covers both SIGHUP on Wave shutdown and -p
// mode completion -- keeps the session eligible for auto-resume.
var deliberateEndReasons = map[string]bool{
"prompt_input_exit": true,
"logout": true,
"clear": true,
}

const (
HookEvent_SessionStart = "SessionStart"
HookEvent_UserPromptSubmit = "UserPromptSubmit"
Expand Down Expand Up @@ -64,6 +73,8 @@ type hookEvent struct {
Pid int `json:"pid,omitempty"`
Prompt string `json:"prompt,omitempty"`
Message string `json:"message,omitempty"`
Reason string `json:"reason,omitempty"`
Model string `json:"model,omitempty"`
}

type AgentTracker struct {
Expand Down Expand Up @@ -103,6 +114,44 @@ func ListSessions() []wshrpc.AgentSessionInfo {
return rtn
}

// GetResumeCandidate returns the sessionid that a fresh shell in the given
// block should `claude --resume`, or "" if there is none. The candidate is the
// most recently active interactive session in the block, and it loses
// candidacy if the user deliberately exited it or if its process is still
// alive (e.g. running inside tmux -- attaching a second client would corrupt
// the session).
func GetResumeCandidate(blockId string) string {
best := findResumeCandidate(blockId)
if best == nil {
return ""
}
if best.Pid > 0 {
if exists, err := process.PidExists(int32(best.Pid)); err == nil && exists {
return ""
}
}
return best.SessionId
}

func findResumeCandidate(blockId string) *wshrpc.AgentSessionInfo {
globalTracker.lock.Lock()
defer globalTracker.lock.Unlock()
var best *wshrpc.AgentSessionInfo
for _, session := range globalTracker.sessions {
if session.BlockId != blockId || !session.Interactive {
continue
}
if best == nil || session.UpdatedTs > best.UpdatedTs {
best = session
}
}
if best == nil || deliberateEndReasons[best.EndReason] {
return nil
}
rtn := *best
return &rtn
}

func publishUpdate() {
wps.Broker.Publish(wps.WaveEvent{Event: wps.Event_AgentTrackerUpdate})
}
Expand Down Expand Up @@ -253,6 +302,10 @@ func (t *AgentTracker) applyEvent_withlock(event *hookEvent) bool {
case HookEvent_SessionStart:
// resume of an ended session revives it in place (StartTs preserved)
session.Status = Status_Idle
// -p (print mode) SessionStart payloads carry no "model" field; those
// one-shot sessions must never become resume candidates
session.Interactive = event.Model != ""
session.EndReason = ""
case HookEvent_UserPromptSubmit:
session.Status = Status_Working
if event.Prompt != "" && event.Prompt != session.LastPrompt {
Expand All @@ -276,6 +329,7 @@ func (t *AgentTracker) applyEvent_withlock(event *hookEvent) bool {
}
case HookEvent_SessionEnd:
session.Status = Status_Ended
session.EndReason = event.Reason
}
if session.Status != oldStatus {
visibleChange = true
Expand Down
109 changes: 109 additions & 0 deletions pkg/agenttracker/agenttracker_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

package agenttracker

import (
"os"
"testing"

"github.com/wavetermdev/waveterm/pkg/wshrpc"
)

func resetTracker() {
globalTracker.lock.Lock()
defer globalTracker.lock.Unlock()
globalTracker.sessions = make(map[string]*wshrpc.AgentSessionInfo)
}

func applyEvents(t *testing.T, events []hookEvent) {
t.Helper()
globalTracker.lock.Lock()
defer globalTracker.lock.Unlock()
for i := range events {
globalTracker.applyEvent_withlock(&events[i])
}
}

func TestResumeCandidateKilledSession(t *testing.T) {
resetTracker()
// interactive session killed with the terminal: SIGHUP produces
// SessionEnd reason "other", which must keep candidacy
applyEvents(t, []hookEvent{
{Ts: 100, Event: HookEvent_SessionStart, SessionId: "s1", BlockId: "b1", Model: "m"},
{Ts: 200, Event: HookEvent_SessionEnd, SessionId: "s1", BlockId: "b1", Reason: "other"},
})
if got := GetResumeCandidate("b1"); got != "s1" {
t.Errorf("killed session should be resume candidate, got %q", got)
}
if got := GetResumeCandidate("b2"); got != "" {
t.Errorf("other block should have no candidate, got %q", got)
}
}

func TestResumeCandidateDeliberateExit(t *testing.T) {
resetTracker()
applyEvents(t, []hookEvent{
{Ts: 100, Event: HookEvent_SessionStart, SessionId: "s1", BlockId: "b1", Model: "m"},
{Ts: 200, Event: HookEvent_SessionEnd, SessionId: "s1", BlockId: "b1", Reason: "prompt_input_exit"},
})
if got := GetResumeCandidate("b1"); got != "" {
t.Errorf("deliberately exited session must not resume, got %q", got)
}
}

func TestResumeCandidateIgnoresPrintMode(t *testing.T) {
resetTracker()
// a claude -p run (no model in SessionStart) inside the block, more recent
// than the interactive session, must not steal or clear candidacy
applyEvents(t, []hookEvent{
{Ts: 100, Event: HookEvent_SessionStart, SessionId: "s1", BlockId: "b1", Model: "m"},
{Ts: 200, Event: HookEvent_SessionStart, SessionId: "s2", BlockId: "b1"},
{Ts: 300, Event: HookEvent_SessionEnd, SessionId: "s2", BlockId: "b1", Reason: "other"},
{Ts: 400, Event: HookEvent_SessionEnd, SessionId: "s1", BlockId: "b1", Reason: "other"},
})
if got := GetResumeCandidate("b1"); got != "s1" {
t.Errorf("print-mode session must be ignored, want s1, got %q", got)
}
}

func TestResumeCandidateReviveClearsEndReason(t *testing.T) {
resetTracker()
// deliberate exit followed by a resume of the same session id (resume
// keeps the id) re-arms candidacy; the next kill keeps it armed
applyEvents(t, []hookEvent{
{Ts: 100, Event: HookEvent_SessionStart, SessionId: "s1", BlockId: "b1", Model: "m"},
{Ts: 200, Event: HookEvent_SessionEnd, SessionId: "s1", BlockId: "b1", Reason: "prompt_input_exit"},
{Ts: 300, Event: HookEvent_SessionStart, SessionId: "s1", BlockId: "b1", Model: "m"},
{Ts: 400, Event: HookEvent_SessionEnd, SessionId: "s1", BlockId: "b1", Reason: "other"},
})
if got := GetResumeCandidate("b1"); got != "s1" {
t.Errorf("revived session should be candidate again, got %q", got)
}
}

func TestResumeCandidateLatestWins(t *testing.T) {
resetTracker()
// two interactive sessions in one block: the most recently active one
// decides; if it was deliberately exited, do not fall back to the older one
applyEvents(t, []hookEvent{
{Ts: 100, Event: HookEvent_SessionStart, SessionId: "s1", BlockId: "b1", Model: "m"},
{Ts: 200, Event: HookEvent_SessionStart, SessionId: "s2", BlockId: "b1", Model: "m"},
{Ts: 300, Event: HookEvent_SessionEnd, SessionId: "s2", BlockId: "b1", Reason: "prompt_input_exit"},
})
if got := GetResumeCandidate("b1"); got != "" {
t.Errorf("latest session deliberately exited, no fallback expected, got %q", got)
}
}

func TestResumeCandidateSkipsLiveProcess(t *testing.T) {
resetTracker()
// session whose pid is still alive (e.g. claude inside tmux survived the
// Wave restart) must not get a second client attached
applyEvents(t, []hookEvent{
{Ts: 100, Event: HookEvent_SessionStart, SessionId: "s1", BlockId: "b1", Model: "m", Pid: os.Getpid()},
})
if got := GetResumeCandidate("b1"); got != "" {
t.Errorf("live-pid session must not be resumed, got %q", got)
}
}
8 changes: 8 additions & 0 deletions pkg/blockcontroller/blockcontroller.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"time"

"github.com/google/uuid"
"github.com/wavetermdev/waveterm/pkg/agenttracker"
"github.com/wavetermdev/waveterm/pkg/blocklogger"
"github.com/wavetermdev/waveterm/pkg/filestore"
"github.com/wavetermdev/waveterm/pkg/jobcontroller"
Expand Down Expand Up @@ -482,6 +483,13 @@ func makeSwapToken(ctx context.Context, logCtx context.Context, blockId string,
}
token.Env["WAVETERM_CLIENTID"] = wstore.GetClientId()
token.Env["WAVETERM_CONN"] = remoteName
// agenttracker only sees hooks from local claude processes, so resume
// injection is local-shell only (remote blocks use durable jobs instead)
if conncontroller.IsLocalConnName(remoteName) && blockMeta.GetString(waveobj.MetaKey_Controller, "") == BlockController_Shell {
if resumeSid := agenttracker.GetResumeCandidate(blockId); resumeSid != "" {
token.Env["WAVETERM_CLAUDE_RESUME"] = resumeSid
}
}
envMap, err := resolveEnvMap(blockId, blockMeta, remoteName)
if err != nil {
log.Printf("error resolving env map: %v\n", err)
Expand Down
16 changes: 15 additions & 1 deletion pkg/util/shellutil/shellintegration/bash_bashrc.sh
Original file line number Diff line number Diff line change
Expand Up @@ -114,4 +114,18 @@ _waveterm_si_preexec() {

# Add our functions to the bash-preexec arrays
precmd_functions+=(_waveterm_si_precmd)
preexec_functions+=(_waveterm_si_preexec)
preexec_functions+=(_waveterm_si_preexec)

# auto-resume the claude session that was live in this block when Wave shut
# down (set by the block controller from agenttracker state). unset happens
# BEFORE launch so nested/exec'd shells and claude's own children never
# re-trigger; running claude as a plain command (not exec) means a failed or
# finished resume falls back to a normal prompt.
if [ -n "$WAVETERM_CLAUDE_RESUME" ]; then
_waveterm_claude_resume_sid="$WAVETERM_CLAUDE_RESUME"
unset WAVETERM_CLAUDE_RESUME
if command -v claude >/dev/null 2>&1; then
claude --resume "$_waveterm_claude_resume_sid"
fi
unset _waveterm_claude_resume_sid
fi
16 changes: 15 additions & 1 deletion pkg/util/shellutil/shellintegration/zsh_zshrc.sh
Original file line number Diff line number Diff line change
Expand Up @@ -139,4 +139,18 @@ fi
autoload -U add-zsh-hook
add-zsh-hook precmd _waveterm_si_precmd
add-zsh-hook preexec _waveterm_si_preexec
add-zsh-hook chpwd _waveterm_si_osc7
add-zsh-hook chpwd _waveterm_si_osc7

# auto-resume the claude session that was live in this block when Wave shut
# down (set by the block controller from agenttracker state). unset happens
# BEFORE launch so nested/exec'd shells and claude's own children never
# re-trigger; running claude as a plain command (not exec) means a failed or
# finished resume falls back to a normal prompt.
if [[ -n "$WAVETERM_CLAUDE_RESUME" ]]; then
_waveterm_claude_resume_sid="$WAVETERM_CLAUDE_RESUME"
unset WAVETERM_CLAUDE_RESUME
if (( $+commands[claude] )); then
claude --resume "$_waveterm_claude_resume_sid"
fi
unset _waveterm_claude_resume_sid
fi
2 changes: 2 additions & 0 deletions pkg/wshrpc/wshrpctypes.go
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,8 @@ type AgentSessionInfo struct {
LastNotification string `json:"lastnotification,omitempty"`
StartTs int64 `json:"startts,omitempty"`
UpdatedTs int64 `json:"updatedts,omitempty"`
Interactive bool `json:"interactive,omitempty"`
EndReason string `json:"endreason,omitempty"`
}

type BlocksListRequest struct {
Expand Down