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 <noreply@anthropic.com>
This commit is contained in:
chenhg5andClaude committed 2026-03-13 09:13:49 +08:00
1 parent 35cdb4daf1
commit 07cd76f2bb
4 files changed
+488 -92

No files matched your search

+248 -89
View File
@@ -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 <git-url>`")
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 <url> | unbind | list]`")
"Usage: `/workspace [bind <name> | init <url> | 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 <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 <url>`.")
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgWsNotFoundHint))
return true
}
+115
View File
@@ -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) {
+124
View File
@@ -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: <comando>",
},
// 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 <git-url>`",
LangChinese: "用法: `/workspace init <git仓库地址>`",
LangTraditionalChinese: "用法: `/workspace init <git倉庫地址>`",
LangJapanese: "使い方: `/workspace init <git-url>`",
LangSpanish: "Uso: `/workspace init <git-url>`",
},
MsgWsBindUsage: {
LangEnglish: "Usage: `/workspace bind <workspace-name>`",
LangChinese: "用法: `/workspace bind <工作区名称>`",
LangTraditionalChinese: "用法: `/workspace bind <工作區名稱>`",
LangJapanese: "使い方: `/workspace bind <ワークスペース名>`",
LangSpanish: "Uso: `/workspace bind <nombre-workspace>`",
},
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 <url>`.",
LangChinese: "此频道未找到工作区。请发送 git 仓库地址进行克隆,或使用 `/workspace init <仓库地址>`。",
LangTraditionalChinese: "此頻道未找到工作區。請發送 git 倉庫地址進行克隆,或使用 `/workspace init <倉庫地址>`。",
LangJapanese: "このチャンネルにワークスペースが見つかりません。gitリポジトリURLを送信するか、`/workspace init <url>` を使用してください。",
LangSpanish: "No se encontró workspace para este canal. Envía una URL de repo git para clonar, o usa `/workspace init <url>`.",
},
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 {
+1 -3
View File
@@ -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=