From 07cd76f2bb4a3ececb6307bbdbfd244553c68f02 Mon Sep 17 00:00:00 2001 From: chenhg5 Date: Fri, 13 Mar 2026 09:13:49 +0800 Subject: [PATCH] feat(workspace): add multi-workspace support with bind command and i18n messages Added support for managing multiple workspaces including: - New workspace bind command to link existing directories - Enhanced session management scoped to workspaces - Improved error handling with localized messages - Fixed interactive state lookup for multi-workspace contexts generated by llmgit Co-Authored-By: Claude --- core/engine.go | 337 ++++++++++++++++++++++++++++++++------------ core/engine_test.go | 115 +++++++++++++++ core/i18n.go | 124 ++++++++++++++++ go.sum | 4 +- 4 files changed, 488 insertions(+), 92 deletions(-) diff --git a/core/engine.go b/core/engine.go index 6129324..99072a3 100644 --- a/core/engine.go +++ b/core/engine.go @@ -724,7 +724,7 @@ func (e *Engine) handleMessage(p Platform, msg *Message) { workspace, channelName, err := e.resolveWorkspace(p, channelID) if err != nil { slog.Error("workspace resolution failed", "err", err) - e.reply(p, msg.ReplyCtx, fmt.Sprintf("Workspace resolution error: %v", err)) + e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgWsResolutionError, err)) return } if workspace == "" { @@ -845,6 +845,16 @@ func (e *Engine) handleVoiceMessage(p Platform, msg *Message) { func (e *Engine) handlePendingPermission(p Platform, msg *Message, content string) bool { e.interactiveMu.Lock() state, ok := e.interactiveStates[msg.SessionKey] + if (!ok || state == nil) && e.multiWorkspace { + suffix := ":" + msg.SessionKey + for key, s := range e.interactiveStates { + if strings.HasSuffix(key, suffix) { + state = s + ok = true + break + } + } + } e.interactiveMu.Unlock() if !ok || state == nil { return false @@ -1649,7 +1659,7 @@ func (e *Engine) handleCommand(p Platform, msg *Message, raw string) bool { e.cmdTTS(p, msg, args) case "workspace": if !e.multiWorkspace { - e.reply(p, msg.ReplyCtx, "Workspace commands are only available in multi-workspace mode.") + e.reply(p, msg.ReplyCtx, e.i18n.T(MsgWsNotEnabled)) return true } e.handleWorkspaceCommand(p, msg, args) @@ -1676,22 +1686,42 @@ func (e *Engine) handleWorkspaceCommand(p Platform, msg *Message, args []string) subCmd := "" if len(args) > 0 { - subCmd = matchSubCommand(args[0], []string{"init", "unbind", "list"}) + subCmd = matchSubCommand(args[0], []string{"init", "bind", "unbind", "list"}) } switch subCmd { case "": b := e.workspaceBindings.Lookup(projectKey, channelID) if b == nil { - e.reply(p, msg.ReplyCtx, "No workspace bound to this channel.") + e.reply(p, msg.ReplyCtx, e.i18n.T(MsgWsNoBinding)) } else { - e.reply(p, msg.ReplyCtx, fmt.Sprintf("Workspace: `%s`\nBound: %s", - b.Workspace, b.BoundAt.Format(time.RFC3339))) + e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgWsInfo, b.Workspace, b.BoundAt.Format(time.RFC3339))) } + case "bind": + if len(args) < 2 { + e.reply(p, msg.ReplyCtx, e.i18n.T(MsgWsBindUsage)) + return + } + wsName := args[1] + wsPath := filepath.Join(e.baseDir, wsName) + + // Check if workspace directory exists + if _, err := os.Stat(wsPath); os.IsNotExist(err) { + e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgWsBindNotFound, wsName)) + return + } + + channelName := "" + if resolver, ok := p.(ChannelNameResolver); ok { + channelName, _ = resolver.ResolveChannelName(channelID) + } + e.workspaceBindings.Bind(projectKey, channelID, channelName, wsPath) + e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgWsBindSuccess, wsName)) + case "init": if len(args) < 2 { - e.reply(p, msg.ReplyCtx, "Usage: `workspace init `") + e.reply(p, msg.ReplyCtx, e.i18n.T(MsgWsInitUsage)) return } repoURL := args[1] @@ -1709,15 +1739,14 @@ func (e *Engine) handleWorkspaceCommand(p Platform, msg *Message, args []string) channelName, _ = resolver.ResolveChannelName(channelID) } e.workspaceBindings.Bind(projectKey, channelID, channelName, cloneTo) - e.reply(p, msg.ReplyCtx, fmt.Sprintf( - "Directory `%s` already exists. Bound workspace to this channel. Ready.", cloneTo)) + e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgWsCloneSuccess, cloneTo)) return } - e.reply(p, msg.ReplyCtx, fmt.Sprintf("Cloning `%s` to `%s`...", repoURL, cloneTo)) + e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgWsCloneProgress, repoURL)) if err := gitClone(repoURL, cloneTo); err != nil { - e.reply(p, msg.ReplyCtx, fmt.Sprintf("Clone failed: %v", err)) + e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgWsCloneFailed, err)) return } @@ -1726,21 +1755,20 @@ func (e *Engine) handleWorkspaceCommand(p Platform, msg *Message, args []string) channelName, _ = resolver.ResolveChannelName(channelID) } e.workspaceBindings.Bind(projectKey, channelID, channelName, cloneTo) - e.reply(p, msg.ReplyCtx, fmt.Sprintf( - "Clone complete. Bound workspace `%s` to this channel. Ready.", cloneTo)) + e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgWsCloneSuccess, cloneTo)) case "unbind": e.workspaceBindings.Unbind(projectKey, channelID) - e.reply(p, msg.ReplyCtx, "Workspace unbound from this channel.") + e.reply(p, msg.ReplyCtx, e.i18n.T(MsgWsUnbindSuccess)) case "list": bindings := e.workspaceBindings.ListByProject(projectKey) if len(bindings) == 0 { - e.reply(p, msg.ReplyCtx, "No workspaces bound.") + e.reply(p, msg.ReplyCtx, e.i18n.T(MsgWsListEmpty)) return } var sb strings.Builder - sb.WriteString("Bound workspaces:\n") + sb.WriteString(e.i18n.T(MsgWsListTitle) + "\n") for chID, b := range bindings { name := b.ChannelName if name == "" { @@ -1752,19 +1780,25 @@ func (e *Engine) handleWorkspaceCommand(p Platform, msg *Message, args []string) default: e.reply(p, msg.ReplyCtx, - "Usage: `workspace [init | unbind | list]`") + "Usage: `/workspace [bind | init | unbind | list]`") } } func (e *Engine) cmdNew(p Platform, msg *Message, args []string) { + _, sessions, interactiveKey, err := e.commandContext(p, msg) + if err != nil { + e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgWsResolutionError, err)) + return + } + slog.Info("cmdNew: cleaning up old session", "session_key", msg.SessionKey) - e.cleanupInteractiveState(msg.SessionKey) + e.cleanupInteractiveState(interactiveKey) slog.Info("cmdNew: cleanup done, creating new session", "session_key", msg.SessionKey) name := "" if len(args) > 0 { name = strings.Join(args, " ") } - s := e.sessions.NewSession(msg.SessionKey, name) + s := sessions.NewSession(msg.SessionKey, name) if name != "" { e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgNewSessionCreatedName), name)) } else { @@ -1776,8 +1810,14 @@ func (e *Engine) cmdNew(p Platform, msg *Message, args []string) { const listPageSize = 20 func (e *Engine) cmdList(p Platform, msg *Message, args []string) { + agent, sessions, _, err := e.commandContext(p, msg) + if err != nil { + e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgWsResolutionError, err)) + return + } + if !supportsCards(p) { - agentSessions, err := e.agent.ListSessions(e.ctx) + agentSessions, err := agent.ListSessions(e.ctx) if err != nil { e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgListError), err)) return @@ -1806,8 +1846,8 @@ func (e *Engine) cmdList(p Platform, msg *Message, args []string) { end = total } - agentName := e.agent.Name() - activeSession := e.sessions.GetOrCreateActive(msg.SessionKey) + agentName := agent.Name() + activeSession := sessions.GetOrCreateActive(msg.SessionKey) activeAgentID := activeSession.AgentSessionID var sb strings.Builder @@ -1822,7 +1862,7 @@ func (e *Engine) cmdList(p Platform, msg *Message, args []string) { if s.ID == activeAgentID { marker = "▶" } - displayName := e.sessions.GetSessionName(s.ID) + displayName := sessions.GetSessionName(s.ID) if displayName != "" { displayName = "📌 " + displayName } else { @@ -1868,33 +1908,38 @@ func (e *Engine) cmdSwitch(p Platform, msg *Message, args []string) { query := strings.TrimSpace(strings.Join(args, " ")) slog.Info("cmdSwitch: listing agent sessions", "session_key", msg.SessionKey) - agentSessions, err := e.agent.ListSessions(e.ctx) + agent, sessions, interactiveKey, err := e.commandContext(p, msg) + if err != nil { + e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgWsResolutionError, err)) + return + } + agentSessions, err := agent.ListSessions(e.ctx) if err != nil { e.reply(p, msg.ReplyCtx, fmt.Sprintf("❌ %v", err)) return } - matched := e.matchSession(agentSessions, query) + matched := e.matchSession(agentSessions, sessions, query) if matched == nil { e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgSwitchNoMatch), query)) return } slog.Info("cmdSwitch: cleaning up old session", "session_key", msg.SessionKey) - e.cleanupInteractiveState(msg.SessionKey) + e.cleanupInteractiveState(interactiveKey) slog.Info("cmdSwitch: cleanup done", "session_key", msg.SessionKey) - session := e.sessions.GetOrCreateActive(msg.SessionKey) + session := sessions.GetOrCreateActive(msg.SessionKey) session.AgentSessionID = matched.ID session.Name = matched.Summary session.ClearHistory() - e.sessions.Save() + sessions.Save() shortID := matched.ID if len(shortID) > 12 { shortID = shortID[:12] } - displayName := e.sessions.GetSessionName(matched.ID) + displayName := sessions.GetSessionName(matched.ID) if displayName == "" { displayName = matched.Summary } @@ -1908,7 +1953,7 @@ func (e *Engine) cmdSwitch(p Platform, msg *Message, args []string) { // 3. Session ID prefix match // 4. Custom name prefix match (case-insensitive) // 5. Summary substring match (case-insensitive) -func (e *Engine) matchSession(sessions []AgentSessionInfo, query string) *AgentSessionInfo { +func (e *Engine) matchSession(sessions []AgentSessionInfo, manager *SessionManager, query string) *AgentSessionInfo { if len(sessions) == 0 { return nil } @@ -1922,7 +1967,7 @@ func (e *Engine) matchSession(sessions []AgentSessionInfo, query string) *AgentS // 2. Exact custom name match for i := range sessions { - name := e.sessions.GetSessionName(sessions[i].ID) + name := manager.GetSessionName(sessions[i].ID) if name != "" && strings.ToLower(name) == queryLower { return &sessions[i] } @@ -1937,7 +1982,7 @@ func (e *Engine) matchSession(sessions []AgentSessionInfo, query string) *AgentS // 4. Custom name prefix match for i := range sessions { - name := e.sessions.GetSessionName(sessions[i].ID) + name := manager.GetSessionName(sessions[i].ID) if name != "" && strings.HasPrefix(strings.ToLower(name), queryLower) { return &sessions[i] } @@ -1969,15 +2014,25 @@ func (e *Engine) cmdShell(p Platform, msg *Message, raw string) { return } - go func() { - workDir := "" + // In multi-workspace mode, resolve workspace directory for this channel + var workDir string + if e.multiWorkspace { + channelID := extractChannelID(msg.SessionKey) + projectKey := "project:" + e.name + if b := e.workspaceBindings.Lookup(projectKey, channelID); b != nil { + workDir = b.Workspace + } + } + if workDir == "" { if wd, ok := e.agent.(interface{ GetWorkDir() string }); ok { workDir = wd.GetWorkDir() } - if workDir == "" { - workDir, _ = os.Getwd() - } + } + if workDir == "" { + workDir, _ = os.Getwd() + } + go func() { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() @@ -2016,7 +2071,12 @@ func (e *Engine) cmdSearch(p Platform, msg *Message, args []string) { keyword := strings.ToLower(strings.Join(args, " ")) // Get all agent sessions - agentSessions, err := e.agent.ListSessions(e.ctx) + agent, sessions, _, err := e.commandContext(p, msg) + if err != nil { + e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgWsResolutionError, err)) + return + } + agentSessions, err := agent.ListSessions(e.ctx) if err != nil { e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgSearchError), err)) return @@ -2034,7 +2094,7 @@ func (e *Engine) cmdSearch(p Platform, msg *Message, args []string) { for _, s := range agentSessions { // Check session name (custom name or summary) - customName := e.sessions.GetSessionName(s.ID) + customName := sessions.GetSessionName(s.ID) displayName := customName if displayName == "" { displayName = s.Summary @@ -2093,6 +2153,12 @@ func (e *Engine) cmdName(p Platform, msg *Message, args []string) { return } + agent, sessions, _, err := e.commandContext(p, msg) + if err != nil { + e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgWsResolutionError, err)) + return + } + // Check if first arg is a number → naming a specific session by list index var targetID string var name string @@ -2103,7 +2169,7 @@ func (e *Engine) cmdName(p Platform, msg *Message, args []string) { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgNameUsage)) return } - agentSessions, err := e.agent.ListSessions(e.ctx) + agentSessions, err := agent.ListSessions(e.ctx) if err != nil { e.reply(p, msg.ReplyCtx, fmt.Sprintf("❌ %v", err)) return @@ -2116,7 +2182,7 @@ func (e *Engine) cmdName(p Platform, msg *Message, args []string) { name = strings.Join(args[1:], " ") } else { // /name → current session - session := e.sessions.GetOrCreateActive(msg.SessionKey) + session := sessions.GetOrCreateActive(msg.SessionKey) targetID = session.AgentSessionID if targetID == "" { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgNameNoSession)) @@ -2131,7 +2197,7 @@ func (e *Engine) cmdName(p Platform, msg *Message, args []string) { return } - e.sessions.SetSessionName(targetID, name) + sessions.SetSessionName(targetID, name) shortID := targetID if len(shortID) > 12 { @@ -2142,7 +2208,12 @@ func (e *Engine) cmdName(p Platform, msg *Message, args []string) { func (e *Engine) cmdCurrent(p Platform, msg *Message) { if !supportsCards(p) { - s := e.sessions.GetOrCreateActive(msg.SessionKey) + _, sessions, _, err := e.commandContext(p, msg) + if err != nil { + e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgWsResolutionError, err)) + return + } + s := sessions.GetOrCreateActive(msg.SessionKey) agentID := s.AgentSessionID if agentID == "" { agentID = e.i18n.T(MsgSessionNotStarted) @@ -2156,6 +2227,11 @@ func (e *Engine) cmdCurrent(p Platform, msg *Message) { func (e *Engine) cmdStatus(p Platform, msg *Message) { if !supportsCards(p) { + agent, sessions, _, err := e.commandContext(p, msg) + if err != nil { + e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgWsResolutionError, err)) + return + } platNames := make([]string, len(e.platforms)) for i, pl := range e.platforms { platNames[i] = pl.Name() @@ -2171,7 +2247,7 @@ func (e *Engine) cmdStatus(p Platform, msg *Message) { langStr := fmt.Sprintf("%s (%s)", string(cur), langDisplayName(cur)) var modeStr string - if ms, ok := e.agent.(ModeSwitcher); ok { + if ms, ok := agent.(ModeSwitcher); ok { mode := ms.GetMode() if mode != "" { modeStr = e.i18n.Tf(MsgStatusMode, mode) @@ -2199,8 +2275,8 @@ func (e *Engine) cmdStatus(p Platform, msg *Message) { } modeStr += e.i18n.Tf(MsgStatusQuiet, quietStr) - s := e.sessions.GetOrCreateActive(msg.SessionKey) - sessionDisplayName := e.sessions.GetSessionName(s.AgentSessionID) + s := sessions.GetOrCreateActive(msg.SessionKey) + sessionDisplayName := sessions.GetSessionName(s.AgentSessionID) if sessionDisplayName == "" { sessionDisplayName = s.Name } @@ -2222,7 +2298,7 @@ func (e *Engine) cmdStatus(p Platform, msg *Message) { e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgStatusTitle, e.name, - e.agent.Name(), + agent.Name(), platformStr, uptimeStr, langStr, @@ -2536,12 +2612,14 @@ func (e *Engine) simpleCard(title, color, content string) *Card { func (e *Engine) renderListCardSafe(sessionKey string, page int) *Card { card, err := e.renderListCard(sessionKey, page) if err != nil { - return e.simpleCard(e.i18n.Tf(MsgCardTitleSessions, e.agent.Name(), 0), "red", err.Error()) + agent, _ := e.sessionContextForKey(sessionKey) + return e.simpleCard(e.i18n.Tf(MsgCardTitleSessions, agent.Name(), 0), "red", err.Error()) } return card } func (e *Engine) renderStatusCard(sessionKey string) *Card { + agent, sessions := e.sessionContextForKey(sessionKey) platNames := make([]string, len(e.platforms)) for i, pl := range e.platforms { platNames[i] = pl.Name() @@ -2557,7 +2635,7 @@ func (e *Engine) renderStatusCard(sessionKey string) *Card { langStr := fmt.Sprintf("%s (%s)", string(cur), langDisplayName(cur)) var modeStr string - if ms, ok := e.agent.(ModeSwitcher); ok { + if ms, ok := agent.(ModeSwitcher); ok { mode := ms.GetMode() if mode != "" { modeStr = e.i18n.Tf(MsgStatusMode, mode) @@ -2585,8 +2663,8 @@ func (e *Engine) renderStatusCard(sessionKey string) *Card { } modeStr += e.i18n.Tf(MsgStatusQuiet, quietStr) - s := e.sessions.GetOrCreateActive(sessionKey) - sessionDisplayName := e.sessions.GetSessionName(s.AgentSessionID) + s := sessions.GetOrCreateActive(sessionKey) + sessionDisplayName := sessions.GetSessionName(s.AgentSessionID) if sessionDisplayName == "" { sessionDisplayName = s.Name } @@ -2608,7 +2686,7 @@ func (e *Engine) renderStatusCard(sessionKey string) *Card { statusText := e.i18n.Tf(MsgStatusTitle, e.name, - e.agent.Name(), + agent.Name(), platformStr, uptimeStr, langStr, @@ -2683,7 +2761,12 @@ func (e *Engine) cmdHistory(p Platform, msg *Message, args []string) { args = []string{"10"} } - s := e.sessions.GetOrCreateActive(msg.SessionKey) + agent, sessions, _, err := e.commandContext(p, msg) + if err != nil { + e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgWsResolutionError, err)) + return + } + s := sessions.GetOrCreateActive(msg.SessionKey) n := 10 if v, err := strconv.Atoi(args[0]); err == nil && v > 0 { n = v @@ -2691,7 +2774,7 @@ func (e *Engine) cmdHistory(p Platform, msg *Message, args []string) { entries := s.GetHistory(n) if len(entries) == 0 && s.AgentSessionID != "" { - if hp, ok := e.agent.(HistoryProvider); ok { + if hp, ok := agent.(HistoryProvider); ok { if agentEntries, err := hp.GetSessionHistory(e.ctx, s.AgentSessionID, n); err == nil { entries = agentEntries } @@ -4118,8 +4201,10 @@ func (e *Engine) executeCardAction(cmd, args, sessionKey string) { } case "/new": - e.cleanupInteractiveState(sessionKey) - e.sessions.NewSession(sessionKey, "") + interactiveKey := e.interactiveKeyForSessionKey(sessionKey) + _, sessions := e.sessionContextForKey(sessionKey) + e.cleanupInteractiveState(interactiveKey) + sessions.NewSession(sessionKey, "") case "/delete-mode": e.executeDeleteModeAction(sessionKey, args) @@ -4142,22 +4227,25 @@ func (e *Engine) executeCardAction(cmd, args, sessionKey string) { if args == "" { return } - agentSessions, err := e.agent.ListSessions(e.ctx) + agent, sessions := e.sessionContextForKey(sessionKey) + agentSessions, err := agent.ListSessions(e.ctx) if err != nil || len(agentSessions) == 0 { return } - matched := e.matchSession(agentSessions, args) + matched := e.matchSession(agentSessions, sessions, args) if matched == nil { return } - e.cleanupInteractiveState(sessionKey) - session := e.sessions.GetOrCreateActive(sessionKey) + interactiveKey := e.interactiveKeyForSessionKey(sessionKey) + e.cleanupInteractiveState(interactiveKey) + session := sessions.GetOrCreateActive(sessionKey) session.AgentSessionID = matched.ID session.Name = matched.Summary session.ClearHistory() - e.sessions.Save() + sessions.Save() case "/stop": + sessionKey = e.interactiveKeyForSessionKey(sessionKey) e.interactiveMu.Lock() state, ok := e.interactiveStates[sessionKey] if !ok || state == nil { @@ -4253,7 +4341,8 @@ func (e *Engine) clearDeleteModeState(sessionKey string) { } func (e *Engine) renderDeleteModeCard(sessionKey string) *Card { - agentSessions, err := e.agent.ListSessions(e.ctx) + agent, sessions := e.sessionContextForKey(sessionKey) + agentSessions, err := agent.ListSessions(e.ctx) if err != nil { return e.simpleCard(e.i18n.T(MsgDeleteModeTitle), "red", err.Error()) } @@ -4263,15 +4352,15 @@ func (e *Engine) renderDeleteModeCard(sessionKey string) *Card { } switch dm.phase { case "confirm": - return e.renderDeleteModeConfirmCard(dm, agentSessions) + return e.renderDeleteModeConfirmCard(sessions, dm, agentSessions) case "result": return e.renderDeleteModeResultCard(dm) default: - return e.renderDeleteModeSelectCard(sessionKey, dm, agentSessions) + return e.renderDeleteModeSelectCard(sessionKey, sessions, dm, agentSessions) } } -func (e *Engine) renderDeleteModeSelectCard(sessionKey string, dm *deleteModeState, agentSessions []AgentSessionInfo) *Card { +func (e *Engine) renderDeleteModeSelectCard(sessionKey string, sessions *SessionManager, dm *deleteModeState, agentSessions []AgentSessionInfo) *Card { if len(agentSessions) == 0 { return e.simpleCard(e.i18n.T(MsgDeleteModeTitle), "red", e.i18n.T(MsgListEmpty)) } @@ -4297,7 +4386,7 @@ func (e *Engine) renderDeleteModeSelectCard(sessionKey string, dm *deleteModeSta if _, ok := dm.selectedIDs[s.ID]; ok { marker = "☑" } - if active := e.sessions.GetOrCreateActive(sessionKey).AgentSessionID; active == s.ID { + if active := sessions.GetOrCreateActive(sessionKey).AgentSessionID; active == s.ID { marker += " ▶" } btnText := e.i18n.T(MsgDeleteModeSelect) @@ -4307,7 +4396,7 @@ func (e *Engine) renderDeleteModeSelectCard(sessionKey string, dm *deleteModeSta btnType = "primary" } cb.ListItemBtn( - e.i18n.Tf(MsgListItem, marker, i+1, e.deleteSessionDisplayName(&s), s.MessageCount, s.ModifiedAt.Format("01-02 15:04")), + e.i18n.Tf(MsgListItem, marker, i+1, e.deleteSessionDisplayName(sessions, &s), s.MessageCount, s.ModifiedAt.Format("01-02 15:04")), btnText, btnType, fmt.Sprintf("act:/delete-mode toggle %s", s.ID), @@ -4335,8 +4424,8 @@ func (e *Engine) renderDeleteModeSelectCard(sessionKey string, dm *deleteModeSta return cb.Build() } -func (e *Engine) renderDeleteModeConfirmCard(dm *deleteModeState, agentSessions []AgentSessionInfo) *Card { - selectedNames := e.deleteModeSelectionNames(dm, agentSessions) +func (e *Engine) renderDeleteModeConfirmCard(sessions *SessionManager, dm *deleteModeState, agentSessions []AgentSessionInfo) *Card { + selectedNames := e.deleteModeSelectionNames(sessions, dm, agentSessions) body := strings.Join(selectedNames, "\n") if body == "" { body = e.i18n.T(MsgDeleteModeEmptySelection) @@ -4359,11 +4448,11 @@ func (e *Engine) renderDeleteModeResultCard(dm *deleteModeState) *Card { Build() } -func (e *Engine) deleteModeSelectionNames(dm *deleteModeState, agentSessions []AgentSessionInfo) []string { +func (e *Engine) deleteModeSelectionNames(sessions *SessionManager, dm *deleteModeState, agentSessions []AgentSessionInfo) []string { names := make([]string, 0, len(dm.selectedIDs)) for i := range agentSessions { if _, ok := dm.selectedIDs[agentSessions[i].ID]; ok { - names = append(names, "- "+e.deleteSessionDisplayName(&agentSessions[i])) + names = append(names, "- "+e.deleteSessionDisplayName(sessions, &agentSessions[i])) } } return names @@ -4432,11 +4521,12 @@ func (e *Engine) executeDeleteModeAction(sessionKey, args string) { } func (e *Engine) submitDeleteModeSelection(sessionKey string, dm *deleteModeState) []string { - deleter, ok := e.agent.(SessionDeleter) + agent, _ := e.sessionContextForKey(sessionKey) + deleter, ok := agent.(SessionDeleter) if !ok { return []string{e.i18n.T(MsgDeleteNotSupported)} } - agentSessions, err := e.agent.ListSessions(e.ctx) + agentSessions, err := agent.ListSessions(e.ctx) if err != nil { return []string{fmt.Sprintf("❌ %v", err)} } @@ -4613,12 +4703,13 @@ func (e *Engine) renderModeCard() *Card { } func (e *Engine) renderListCard(sessionKey string, page int) (*Card, error) { - agentSessions, err := e.agent.ListSessions(e.ctx) + agent, sessions := e.sessionContextForKey(sessionKey) + agentSessions, err := agent.ListSessions(e.ctx) if err != nil { return nil, fmt.Errorf(e.i18n.T(MsgListError), err) } if len(agentSessions) == 0 { - return e.simpleCard(e.i18n.Tf(MsgCardTitleSessions, e.agent.Name(), 0), "turquoise", e.i18n.T(MsgListEmpty)), nil + return e.simpleCard(e.i18n.Tf(MsgCardTitleSessions, agent.Name(), 0), "turquoise", e.i18n.T(MsgListEmpty)), nil } total := len(agentSessions) @@ -4633,8 +4724,8 @@ func (e *Engine) renderListCard(sessionKey string, page int) (*Card, error) { end = total } - agentName := e.agent.Name() - activeSession := e.sessions.GetOrCreateActive(sessionKey) + agentName := agent.Name() + activeSession := sessions.GetOrCreateActive(sessionKey) activeAgentID := activeSession.AgentSessionID var titleStr string @@ -4651,7 +4742,7 @@ func (e *Engine) renderListCard(sessionKey string, page int) (*Card, error) { if s.ID == activeAgentID { marker = "▶" } - displayName := e.sessions.GetSessionName(s.ID) + displayName := sessions.GetSessionName(s.ID) if displayName != "" { displayName = "📌 " + displayName } else { @@ -4698,7 +4789,8 @@ func (e *Engine) renderListCard(sessionKey string, page int) (*Card, error) { // ────────────────────────────────────────────────────────────── func (e *Engine) renderCurrentCard(sessionKey string) *Card { - s := e.sessions.GetOrCreateActive(sessionKey) + _, sessions := e.sessionContextForKey(sessionKey) + s := sessions.GetOrCreateActive(sessionKey) agentID := s.AgentSessionID if agentID == "" { agentID = e.i18n.T(MsgSessionNotStarted) @@ -4712,11 +4804,12 @@ func (e *Engine) renderCurrentCard(sessionKey string) *Card { } func (e *Engine) renderHistoryCard(sessionKey string) *Card { - s := e.sessions.GetOrCreateActive(sessionKey) + agent, sessions := e.sessionContextForKey(sessionKey) + s := sessions.GetOrCreateActive(sessionKey) entries := s.GetHistory(10) if len(entries) == 0 && s.AgentSessionID != "" { - if hp, ok := e.agent.(HistoryProvider); ok { + if hp, ok := agent.(HistoryProvider); ok { if agentEntries, err := hp.GetSessionHistory(e.ctx, s.AgentSessionID, 10); err == nil { entries = agentEntries } @@ -5916,7 +6009,12 @@ func (e *Engine) cmdAliasDel(p Platform, msg *Message, args []string) { } func (e *Engine) cmdDelete(p Platform, msg *Message, args []string) { - deleter, ok := e.agent.(SessionDeleter) + agent, _, _, err := e.commandContext(p, msg) + if err != nil { + e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgWsResolutionError, err)) + return + } + deleter, ok := agent.(SessionDeleter) if !ok { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgDeleteNotSupported)) return @@ -5936,7 +6034,7 @@ func (e *Engine) cmdDelete(p Platform, msg *Message, args []string) { return } - agentSessions, err := e.agent.ListSessions(e.ctx) + agentSessions, err := agent.ListSessions(e.ctx) if err != nil { e.reply(p, msg.ReplyCtx, fmt.Sprintf("❌ %v", err)) return @@ -6070,23 +6168,24 @@ func (e *Engine) deleteSingleSessionReply(msg *Message, deleter SessionDeleter, } // Prevent deleting the currently active session - activeSession := e.sessions.GetOrCreateActive(msg.SessionKey) + _, sessions := e.sessionContextForKey(msg.SessionKey) + activeSession := sessions.GetOrCreateActive(msg.SessionKey) if activeSession.AgentSessionID == matched.ID { return e.i18n.T(MsgDeleteActiveDenied) } - displayName := e.deleteSessionDisplayName(matched) + displayName := e.deleteSessionDisplayName(sessions, matched) if err := deleter.DeleteSession(e.ctx, matched.ID); err != nil { return fmt.Sprintf("❌ %s: %v", displayName, err) } - e.sessions.SetSessionName(matched.ID, "") + sessions.SetSessionName(matched.ID, "") return fmt.Sprintf(e.i18n.T(MsgDeleteSuccess), displayName) } -func (e *Engine) deleteSessionDisplayName(matched *AgentSessionInfo) string { - displayName := e.sessions.GetSessionName(matched.ID) +func (e *Engine) deleteSessionDisplayName(sessions *SessionManager, matched *AgentSessionInfo) string { + displayName := sessions.GetSessionName(matched.ID) if displayName == "" { displayName = matched.Summary } @@ -6417,6 +6516,66 @@ func extractChannelID(sessionKey string) string { return "" } +// commandContext resolves the appropriate agent, session manager, and interactive key +// for a command. In multi-workspace mode, it routes to the bound workspace if present. +func (e *Engine) commandContext(p Platform, msg *Message) (Agent, *SessionManager, string, error) { + if !e.multiWorkspace { + return e.agent, e.sessions, msg.SessionKey, nil + } + channelID := extractChannelID(msg.SessionKey) + if channelID == "" { + return e.agent, e.sessions, msg.SessionKey, nil + } + workspace, _, err := e.resolveWorkspace(p, channelID) + if err != nil { + return nil, nil, "", err + } + if workspace == "" { + return e.agent, e.sessions, msg.SessionKey, nil + } + wsAgent, wsSessions, err := e.getOrCreateWorkspaceAgent(workspace) + if err != nil { + return nil, nil, "", err + } + return wsAgent, wsSessions, workspace + ":" + msg.SessionKey, nil +} + +// sessionContextForKey resolves the agent and session manager for a sessionKey. +// It uses existing workspace bindings and falls back to global context if unresolved. +func (e *Engine) sessionContextForKey(sessionKey string) (Agent, *SessionManager) { + if !e.multiWorkspace || e.workspaceBindings == nil { + return e.agent, e.sessions + } + channelID := extractChannelID(sessionKey) + if channelID == "" { + return e.agent, e.sessions + } + projectKey := "project:" + e.name + if b := e.workspaceBindings.Lookup(projectKey, channelID); b != nil { + if wsAgent, wsSessions, err := e.getOrCreateWorkspaceAgent(b.Workspace); err == nil { + return wsAgent, wsSessions + } + } + return e.agent, e.sessions +} + +// interactiveKeyForSessionKey returns the interactive state key for a sessionKey. +// In multi-workspace mode, it prefixes with the bound workspace path when available. +func (e *Engine) interactiveKeyForSessionKey(sessionKey string) string { + if !e.multiWorkspace || e.workspaceBindings == nil { + return sessionKey + } + channelID := extractChannelID(sessionKey) + if channelID == "" { + return sessionKey + } + projectKey := "project:" + e.name + if b := e.workspaceBindings.Lookup(projectKey, channelID); b != nil { + return b.Workspace + ":" + sessionKey + } + return sessionKey +} + // resolveWorkspace resolves a channel to a workspace directory. // Returns (workspacePath, channelName, error). // If workspacePath is empty, the init flow should be triggered. @@ -6482,7 +6641,7 @@ func (e *Engine) handleWorkspaceInitFlow(p Platform, msg *Message, channelID, ch channelName: channelName, } e.initFlowsMu.Unlock() - e.reply(p, msg.ReplyCtx, "No workspace found for this channel. Send me a git repo URL to clone, or use `workspace init `.") + e.reply(p, msg.ReplyCtx, e.i18n.T(MsgWsNotFoundHint)) return true } diff --git a/core/engine_test.go b/core/engine_test.go index 8a2bd39..0240e99 100644 --- a/core/engine_test.go +++ b/core/engine_test.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "path/filepath" "strings" "testing" "time" @@ -29,6 +30,20 @@ func (s *stubAgentSession) CurrentSessionID() string func (s *stubAgentSession) Alive() bool { return true } func (s *stubAgentSession) Close() error { return nil } +type recordingAgentSession struct { + stubAgentSession + lastID string + lastResult PermissionResult + calls int +} + +func (s *recordingAgentSession) RespondPermission(id string, res PermissionResult) error { + s.lastID = id + s.lastResult = res + s.calls++ + return nil +} + type stubPlatformEngine struct { n string sent []string @@ -565,6 +580,106 @@ func TestSendPermissionPrompt_PlainPlatform(t *testing.T) { } } +func TestCmdList_MultiWorkspaceUsesWorkspaceSessions(t *testing.T) { + p := &stubPlatformEngine{n: "test"} + globalAgent := &stubListAgent{ + sessions: []AgentSessionInfo{ + {ID: "g1", Summary: "Global One", MessageCount: 1}, + }, + } + e := NewEngine("test", globalAgent, []Platform{p}, "", LangEnglish) + + baseDir := t.TempDir() + bindingPath := filepath.Join(t.TempDir(), "bindings.json") + e.SetMultiWorkspace(baseDir, bindingPath) + + wsDir := filepath.Join(baseDir, "ws1") + if err := os.MkdirAll(wsDir, 0o755); err != nil { + t.Fatal(err) + } + channelID := "C123" + e.workspaceBindings.Bind("project:test", channelID, "chan", wsDir) + + ws := e.workspacePool.GetOrCreate(wsDir) + ws.agent = &stubListAgent{ + sessions: []AgentSessionInfo{ + {ID: "w1", Summary: "Workspace One", MessageCount: 2}, + }, + } + ws.sessions = NewSessionManager("") + + msg := &Message{SessionKey: "slack:" + channelID + ":U1", ReplyCtx: "ctx"} + e.cmdList(p, msg, nil) + + if len(p.sent) == 0 { + t.Fatal("expected /list to send a response") + } + if strings.Contains(p.sent[0], "Global One") { + t.Fatalf("expected workspace sessions, got global list: %q", p.sent[0]) + } + if !strings.Contains(p.sent[0], "Workspace One") { + t.Fatalf("expected workspace list to contain session summary, got %q", p.sent[0]) + } +} + +func TestHandlePendingPermission_MultiWorkspaceLookup(t *testing.T) { + e := newTestEngine() + e.multiWorkspace = true + + sessionKey := "slack:C123:U1" + interactiveKey := "/tmp/ws:" + sessionKey + + pending := &pendingPermission{ + RequestID: "req-1", + ToolInput: map[string]any{"path": "/tmp/x"}, + Resolved: make(chan struct{}), + } + session := &recordingAgentSession{} + + e.interactiveMu.Lock() + e.interactiveStates[interactiveKey] = &interactiveState{ + agentSession: session, + pending: pending, + } + e.interactiveMu.Unlock() + + p := &stubPlatformEngine{n: "test"} + msg := &Message{SessionKey: sessionKey, ReplyCtx: "ctx"} + + if !e.handlePendingPermission(p, msg, "allow") { + t.Fatal("expected pending permission to be handled") + } + + e.interactiveMu.Lock() + state := e.interactiveStates[interactiveKey] + e.interactiveMu.Unlock() + if state == nil { + t.Fatal("expected interactive state to remain") + } + state.mu.Lock() + hasPending := state.pending != nil + state.mu.Unlock() + if hasPending { + t.Fatal("expected pending permission to be cleared") + } + + select { + case <-pending.Resolved: + default: + t.Fatal("expected pending permission to be resolved") + } + + if session.calls != 1 { + t.Fatalf("RespondPermission calls = %d, want 1", session.calls) + } + if session.lastID != "req-1" { + t.Fatalf("RespondPermission id = %q, want %q", session.lastID, "req-1") + } + if session.lastResult.Behavior != "allow" { + t.Fatalf("RespondPermission behavior = %q, want %q", session.lastResult.Behavior, "allow") + } +} + // --- quiet tests --- func TestQuietSessionToggle(t *testing.T) { diff --git a/core/i18n.go b/core/i18n.go index fe7439a..7a5a68e 100644 --- a/core/i18n.go +++ b/core/i18n.go @@ -429,6 +429,23 @@ const ( MsgBuiltinCmdHelp MsgKey = "help" MsgBuiltinCmdBind MsgKey = "bind" MsgBuiltinCmdShell MsgKey = "shell" + + // Multi-workspace messages + MsgWsNotEnabled MsgKey = "ws_not_enabled" + MsgWsNoBinding MsgKey = "ws_no_binding" + MsgWsInfo MsgKey = "ws_info" + MsgWsInitUsage MsgKey = "ws_init_usage" + MsgWsBindUsage MsgKey = "ws_bind_usage" + MsgWsBindSuccess MsgKey = "ws_bind_success" + MsgWsBindNotFound MsgKey = "ws_bind_not_found" + MsgWsUnbindSuccess MsgKey = "ws_unbind_success" + MsgWsListEmpty MsgKey = "ws_list_empty" + MsgWsListTitle MsgKey = "ws_list_title" + MsgWsNotFoundHint MsgKey = "ws_not_found_hint" + MsgWsResolutionError MsgKey = "ws_resolution_error" + MsgWsCloneProgress MsgKey = "ws_clone_progress" + MsgWsCloneSuccess MsgKey = "ws_clone_success" + MsgWsCloneFailed MsgKey = "ws_clone_failed" ) var messages = map[MsgKey]map[Language]string{ @@ -2705,6 +2722,113 @@ var messages = map[MsgKey]map[Language]string{ LangJapanese: "シェルコマンドを実行、引数: <コマンド>", LangSpanish: "Ejecutar un comando shell, arg: ", }, + + // Multi-workspace messages + MsgWsNotEnabled: { + LangEnglish: "Workspace commands are only available in multi-workspace mode.", + LangChinese: "工作区命令仅在多工作区模式下可用。", + LangTraditionalChinese: "工作區命令僅在多工作區模式下可用。", + LangJapanese: "ワークスペースコマンドはマルチワークスペースモードでのみ使用できます。", + LangSpanish: "Los comandos de workspace solo están disponibles en modo multi-workspace.", + }, + MsgWsNoBinding: { + LangEnglish: "No workspace bound to this channel.", + LangChinese: "此频道未绑定工作区。", + LangTraditionalChinese: "此頻道未綁定工作區。", + LangJapanese: "このチャンネルにワークスペースがバインドされていません。", + LangSpanish: "No hay workspace vinculado a este canal.", + }, + MsgWsInfo: { + LangEnglish: "Workspace: `%s`\nBound: %s", + LangChinese: "工作区: `%s`\n绑定时间: %s", + LangTraditionalChinese: "工作區: `%s`\n綁定時間: %s", + LangJapanese: "ワークスペース: `%s`\nバインド: %s", + LangSpanish: "Workspace: `%s`\nVinculado: %s", + }, + MsgWsInitUsage: { + LangEnglish: "Usage: `/workspace init `", + LangChinese: "用法: `/workspace init `", + LangTraditionalChinese: "用法: `/workspace init `", + LangJapanese: "使い方: `/workspace init `", + LangSpanish: "Uso: `/workspace init `", + }, + MsgWsBindUsage: { + LangEnglish: "Usage: `/workspace bind `", + LangChinese: "用法: `/workspace bind <工作区名称>`", + LangTraditionalChinese: "用法: `/workspace bind <工作區名稱>`", + LangJapanese: "使い方: `/workspace bind <ワークスペース名>`", + LangSpanish: "Uso: `/workspace bind `", + }, + MsgWsBindSuccess: { + LangEnglish: "✅ Workspace bound: `%s`", + LangChinese: "✅ 工作区绑定成功: `%s`", + LangTraditionalChinese: "✅ 工作區綁定成功: `%s`", + LangJapanese: "✅ ワークスペースをバインドしました: `%s`", + LangSpanish: "✅ Workspace vinculado: `%s`", + }, + MsgWsBindNotFound: { + LangEnglish: "Workspace not found: `%s`", + LangChinese: "工作区不存在: `%s`", + LangTraditionalChinese: "工作區不存在: `%s`", + LangJapanese: "ワークスペースが見つかりません: `%s`", + LangSpanish: "Workspace no encontrado: `%s`", + }, + MsgWsUnbindSuccess: { + LangEnglish: "✅ Workspace unbound.", + LangChinese: "✅ 已解除工作区绑定。", + LangTraditionalChinese: "✅ 已解除工作區綁定。", + LangJapanese: "✅ ワークスペースのバインドを解除しました。", + LangSpanish: "✅ Workspace desvinculado.", + }, + MsgWsListEmpty: { + LangEnglish: "No workspaces bound.", + LangChinese: "没有绑定的工作区。", + LangTraditionalChinese: "沒有綁定的工作區。", + LangJapanese: "バインドされたワークスペースがありません。", + LangSpanish: "No hay workspaces vinculados.", + }, + MsgWsListTitle: { + LangEnglish: "Bound workspaces:", + LangChinese: "已绑定的工作区:", + LangTraditionalChinese: "已綁定的工作區:", + LangJapanese: "バインドされたワークスペース:", + LangSpanish: "Workspaces vinculados:", + }, + MsgWsNotFoundHint: { + LangEnglish: "No workspace found for this channel. Send me a git repo URL to clone, or use `/workspace init `.", + LangChinese: "此频道未找到工作区。请发送 git 仓库地址进行克隆,或使用 `/workspace init <仓库地址>`。", + LangTraditionalChinese: "此頻道未找到工作區。請發送 git 倉庫地址進行克隆,或使用 `/workspace init <倉庫地址>`。", + LangJapanese: "このチャンネルにワークスペースが見つかりません。gitリポジトリURLを送信するか、`/workspace init ` を使用してください。", + LangSpanish: "No se encontró workspace para este canal. Envía una URL de repo git para clonar, o usa `/workspace init `.", + }, + MsgWsResolutionError: { + LangEnglish: "Workspace resolution error: %v", + LangChinese: "工作区解析错误: %v", + LangTraditionalChinese: "工作區解析錯誤: %v", + LangJapanese: "ワークスペース解決エラー: %v", + LangSpanish: "Error de resolución de workspace: %v", + }, + MsgWsCloneProgress: { + LangEnglish: "🔄 Cloning repository: %s", + LangChinese: "🔄 正在克隆仓库: %s", + LangTraditionalChinese: "🔄 正在克隆倉庫: %s", + LangJapanese: "🔄 リポジトリをクローン中: %s", + LangSpanish: "🔄 Clonando repositorio: %s", + }, + MsgWsCloneSuccess: { + LangEnglish: "✅ Repository cloned successfully: `%s`", + LangChinese: "✅ 仓库克隆成功: `%s`", + LangTraditionalChinese: "✅ 倉庫克隆成功: `%s`", + LangJapanese: "✅ リポジトリのクローンに成功しました: `%s`", + LangSpanish: "✅ Repositorio clonado exitosamente: `%s`", + }, + MsgWsCloneFailed: { + LangEnglish: "❌ Failed to clone repository: %v", + LangChinese: "❌ 克隆仓库失败: %v", + LangTraditionalChinese: "❌ 克隆倉庫失敗: %v", + LangJapanese: "❌ リポジトリのクローンに失敗しました: %v", + LangSpanish: "❌ Error al clonar repositorio: %v", + }, } func (i *I18n) T(key MsgKey) string { diff --git a/go.sum b/go.sum index b70d691..b00c8ed 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,4 @@ -github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0 h1:MEaUJLQJKFxTNo0xg+dKyOJA2Nu4O8kPVKuJ/gBiyjc= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno= github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= @@ -41,7 +41,6 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= @@ -58,7 +57,6 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=