From f3490918935a09d99ca795c66caf8cf294a99331 Mon Sep 17 00:00:00 2001 From: Liangsheng Yin Date: Wed, 8 Jul 2026 18:15:28 -0700 Subject: [PATCH] claude session auto-resume via agenttracker --- frontend/types/gotypes.d.ts | 3 + hooks/wave-agents-hook.sh | 3 + pkg/agenttracker/agenttracker.go | 54 +++++++++ pkg/agenttracker/agenttracker_test.go | 109 ++++++++++++++++++ pkg/blockcontroller/blockcontroller.go | 8 ++ .../shellutil/shellintegration/bash_bashrc.sh | 16 ++- .../shellutil/shellintegration/zsh_zshrc.sh | 16 ++- pkg/wshrpc/wshrpctypes.go | 2 + 8 files changed, 209 insertions(+), 2 deletions(-) create mode 100644 pkg/agenttracker/agenttracker_test.go diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 6af5dc779a..20cb66d917 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -94,6 +94,8 @@ declare global { lastnotification?: string; startts?: number; updatedts?: number; + interactive?: boolean; + endreason?: string; }; // wshrpc.AiMessageData @@ -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; diff --git a/hooks/wave-agents-hook.sh b/hooks/wave-agents-hook.sh index 1d9cbf3593..6040041082 100755 --- a/hooks/wave-agents-hook.sh +++ b/hooks/wave-agents-hook.sh @@ -33,6 +33,9 @@ jq -c \ cwd: (.cwd // ""), prompt: (.prompt // ""), message: (.message // ""), + source: (.source // ""), + reason: (.reason // ""), + model: (.model // ""), blockid: $blockid, tabid: $tabid, workspaceid: $workspaceid, diff --git a/pkg/agenttracker/agenttracker.go b/pkg/agenttracker/agenttracker.go index aa7f3743fc..c649892e45 100644 --- a/pkg/agenttracker/agenttracker.go +++ b/pkg/agenttracker/agenttracker.go @@ -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" @@ -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 { @@ -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}) } @@ -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 { @@ -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 diff --git a/pkg/agenttracker/agenttracker_test.go b/pkg/agenttracker/agenttracker_test.go new file mode 100644 index 0000000000..a51b0012d7 --- /dev/null +++ b/pkg/agenttracker/agenttracker_test.go @@ -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) + } +} diff --git a/pkg/blockcontroller/blockcontroller.go b/pkg/blockcontroller/blockcontroller.go index 75f1938e12..43322ae550 100644 --- a/pkg/blockcontroller/blockcontroller.go +++ b/pkg/blockcontroller/blockcontroller.go @@ -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" @@ -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) diff --git a/pkg/util/shellutil/shellintegration/bash_bashrc.sh b/pkg/util/shellutil/shellintegration/bash_bashrc.sh index 8ec2dc49fb..f80d86eec2 100644 --- a/pkg/util/shellutil/shellintegration/bash_bashrc.sh +++ b/pkg/util/shellutil/shellintegration/bash_bashrc.sh @@ -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) \ No newline at end of file +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 \ No newline at end of file diff --git a/pkg/util/shellutil/shellintegration/zsh_zshrc.sh b/pkg/util/shellutil/shellintegration/zsh_zshrc.sh index 07d2df9a40..7f15af293a 100644 --- a/pkg/util/shellutil/shellintegration/zsh_zshrc.sh +++ b/pkg/util/shellutil/shellintegration/zsh_zshrc.sh @@ -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 \ No newline at end of file +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 \ No newline at end of file diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index 4dee402571..fd1515abb6 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -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 {