diff --git a/Assets.xcassets/AgentIcons/Grok.imageset/Contents.json b/Assets.xcassets/AgentIcons/Grok.imageset/Contents.json new file mode 100644 index 000000000000..82616cc3aa1d --- /dev/null +++ b/Assets.xcassets/AgentIcons/Grok.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Grok.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Assets.xcassets/AgentIcons/Grok.imageset/Grok.svg b/Assets.xcassets/AgentIcons/Grok.imageset/Grok.svg new file mode 100644 index 000000000000..7287b077673c --- /dev/null +++ b/Assets.xcassets/AgentIcons/Grok.imageset/Grok.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/CLI/cmux.swift b/CLI/cmux.swift index c8703ef8b301..7189ac4f7a20 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -19524,49 +19524,78 @@ struct CMUXCLI { !cwd.isEmpty else { return nil } - let projectURL = grokSessionsRoot(env: env) - .appendingPathComponent(grokEncodedSessionCWD(cwd), isDirectory: true) + let sessionsRoot = grokSessionsRoot(env: env) let fileManager = FileManager.default - var isDirectory = ObjCBool(false) - guard fileManager.fileExists(atPath: projectURL.path, isDirectory: &isDirectory), - isDirectory.boolValue else { + let projectURLs = grokEncodedSessionCWDs(cwd).compactMap { encodedCWD -> URL? in + let projectURL = sessionsRoot.appendingPathComponent(encodedCWD, isDirectory: true) + var isDirectory = ObjCBool(false) + guard fileManager.fileExists(atPath: projectURL.path, isDirectory: &isDirectory), + isDirectory.boolValue else { + return nil + } + return projectURL + } + guard !projectURLs.isEmpty else { return nil } if let sessionId = sessionId?.trimmingCharacters(in: .whitespacesAndNewlines), !sessionId.isEmpty { - let sessionURL = projectURL.appendingPathComponent(sessionId, isDirectory: true) - if fileManager.fileExists(atPath: sessionURL.path, isDirectory: &isDirectory), - isDirectory.boolValue { - return sessionURL + for projectURL in projectURLs { + let sessionURL = projectURL.appendingPathComponent(sessionId, isDirectory: true) + var isDirectory = ObjCBool(false) + if fileManager.fileExists(atPath: sessionURL.path, isDirectory: &isDirectory), + isDirectory.boolValue { + return sessionURL + } } return nil } - guard let sessionURLs = try? fileManager.contentsOfDirectory( - at: projectURL, - includingPropertiesForKeys: [.contentModificationDateKey, .isDirectoryKey], - options: [.skipsHiddenFiles] - ) else { - return nil + return projectURLs.compactMap { projectURL -> URL? in + guard let sessionURLs = try? fileManager.contentsOfDirectory( + at: projectURL, + includingPropertiesForKeys: [.contentModificationDateKey, .isDirectoryKey], + options: [.skipsHiddenFiles] + ) else { + return nil + } + return sessionURLs + .filter { url in + ((try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false) + && fileManager.fileExists(atPath: url.appendingPathComponent("chat_history.jsonl").path) + } + .max { lhs, rhs in + grokHistoryModifiedDate(lhs) < grokHistoryModifiedDate(rhs) + } + }.max { lhs, rhs in + grokHistoryModifiedDate(lhs) < grokHistoryModifiedDate(rhs) } + } - return sessionURLs - .filter { url in - ((try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false) - && fileManager.fileExists(atPath: url.appendingPathComponent("chat_history.jsonl").path) - } - .max { lhs, rhs in - let leftDate = (try? lhs - .appendingPathComponent("chat_history.jsonl") - .resourceValues(forKeys: [.contentModificationDateKey]) - .contentModificationDate) ?? .distantPast - let rightDate = (try? rhs - .appendingPathComponent("chat_history.jsonl") - .resourceValues(forKeys: [.contentModificationDateKey]) - .contentModificationDate) ?? .distantPast - return leftDate < rightDate - } + private func grokHistoryModifiedDate(_ sessionURL: URL) -> Date { + (try? sessionURL + .appendingPathComponent("chat_history.jsonl") + .resourceValues(forKeys: [.contentModificationDateKey]) + .contentModificationDate) ?? .distantPast + } + + private func grokEncodedSessionCWDs(_ cwd: String) -> [String] { + guard let rawCwd = grokNormalizedHookPath(cwd) else { + return [] + } + var seen = Set() + return [rawCwd, (rawCwd as NSString).standardizingPath] + .map(grokEncodedSessionCWD) + .filter { seen.insert($0).inserted } + } + + private func grokNormalizedHookPath(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) + guard let trimmed, !trimmed.isEmpty else { + return nil + } + return trimmed } private func grokSessionsRoot(env: [String: String]) -> URL { @@ -20134,6 +20163,8 @@ struct CMUXCLI { } case "pi": return agentSurfaceResumeWithOption(kind: kind, launchCommand: launchCommand, fallbackExecutable: "pi", option: "--session", sessionId: sessionId) + case "grok": + return agentSurfaceResumeWithOption(kind: kind, launchCommand: launchCommand, fallbackExecutable: "grok", option: "-r", sessionId: sessionId) case "amp": let original = agentSurfaceResumeCommandParts(launchCommand: launchCommand, fallbackExecutable: "amp") return AgentLaunchSanitizer.preservedArguments(kind: kind, args: original.tail).map { @@ -25695,7 +25726,11 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { for def in Self.agentDefs { if let agentFilterDef, agentFilterDef.name != def.name { continue } let configDir = def.resolvedConfigDir() - let canUseMissingConfigDir = def.name == "opencode" || def.name == "pi" || def.name == "amp" || (!isUninstall && def.name == "rovodev") + let canUseMissingConfigDir = def.createConfigDirIfMissing + || def.name == "opencode" + || def.name == "pi" + || def.name == "amp" + || (!isUninstall && def.name == "rovodev") if !canUseMissingConfigDir, !fm.fileExists(atPath: configDir) { print(" \(def.name): skipped (config dir not found)") skipped += 1 diff --git a/Resources/bin/grok b/Resources/bin/grok new file mode 100755 index 000000000000..9856cd8e57e5 --- /dev/null +++ b/Resources/bin/grok @@ -0,0 +1,169 @@ +#!/usr/bin/env bash +# cmux grok wrapper - installs cmux hooks and captures launch metadata. + +find_real_grok() { + local self_dir + self_dir="$(cd "$(dirname "$0")" && pwd)" + local names=(grok grok-macos-aarch64 grok-macos-aarch) + local IFS=: + for d in $PATH; do + [[ "$d" == "$self_dir" ]] && continue + local name candidate + for name in "${names[@]}"; do + candidate="$d/$name" + [[ -x "$candidate" ]] || continue + if [[ "$candidate" -ef "$0" ]]; then + continue + fi + printf '%s' "$candidate" + return 0 + done + done + return 1 +} + +resolve_cmux_bin() { + local bundled_cli self_dir + bundled_cli="${CMUX_BUNDLED_CLI_PATH:-}" + if [[ -n "$bundled_cli" && -x "$bundled_cli" ]]; then + printf '%s' "$bundled_cli" + return 0 + fi + + self_dir="$(cd "$(dirname "$0")" && pwd)" + bundled_cli="$self_dir/cmux" + if [[ -x "$bundled_cli" ]]; then + printf '%s' "$bundled_cli" + return 0 + fi + + bundled_cli="$(command -v cmux || true)" + if [[ -n "$bundled_cli" ]]; then + printf '%s' "$bundled_cli" + return 0 + fi + + return 1 +} + +cmux_socket_available() { + local socket="${CMUX_SOCKET_PATH:-}" + [[ -n "$socket" && -S "$socket" ]] || return 1 + + local cmux_bin + cmux_bin="$(resolve_cmux_bin)" || return 1 + CMUXTERM_CLI_RESPONSE_TIMEOUT_SEC=0.75 \ + "$cmux_bin" --socket "$socket" ping >/dev/null 2>&1 +} + +encode_launch_argv() { + { + printf '%s\0' "$REAL_GROK" + if (( $# > 0 )); then + printf '%s\0' "$@" + fi + } | base64 | tr -d '\n' +} + +grok_option_consumes_value() { + case "$1" in + --cwd|--model|-m|--permission-mode|--prompt-file|--prompt-json|\ + --sandbox|--settings|--config|--profile|--resume|-r) + return 0 + ;; + esac + return 1 +} + +grok_passthrough_option_flag() { + case "$1" in + --help|-h|--version|-v|--single|--single=*|-p|--prompt-file|\ + --prompt-file=*|--prompt-json|--prompt-json=*|--output-format|\ + --output-format=*|--best-of-n|--best-of-n=*) + return 0 + ;; + esac + return 1 +} + +grok_builtin_command_name() { + case "$1" in + auth|completion|completions|config|help|login|logout|models|sessions|\ + setup|update|upgrade|version) + return 0 + ;; + esac + return 1 +} + +grok_command_like_invocation() { + local candidate="$1" + [[ -n "$candidate" ]] || return 1 + case "$candidate" in + *[![:alnum:]_-]*) + return 1 + ;; + esac + grok_builtin_command_name "$candidate" +} + +should_install_grok_hooks() { + (( $# == 0 )) && return 0 + + local arg + local skip_next=false + for arg in "$@"; do + if [[ "$skip_next" == true ]]; then + skip_next=false + continue + fi + case "$arg" in + --) + return 0 + ;; + --resume=*|-r=*) + return 0 + ;; + -*) + if grok_passthrough_option_flag "$arg"; then + return 1 + fi + if [[ "$arg" != *=* ]] && grok_option_consumes_value "$arg"; then + skip_next=true + fi + continue + ;; + *) + if grok_command_like_invocation "$arg"; then + return 1 + fi + return 0 + ;; + esac + done + + return 0 +} + +REAL_GROK="$(find_real_grok)" || { echo "Error: grok not found in PATH" >&2; exit 127; } + +IN_CMUX=0 +if [[ -n "${CMUX_SURFACE_ID:-}" ]]; then + IN_CMUX=1 +fi + +if [[ "$IN_CMUX" == "1" && "${CMUX_GROK_HOOKS_DISABLED:-}" != "1" ]] \ + && cmux_socket_available \ + && should_install_grok_hooks "$@"; then + CMUX_BIN="$(resolve_cmux_bin || true)" + if [[ -n "$CMUX_BIN" ]]; then + CMUXTERM_CLI_RESPONSE_TIMEOUT_SEC=2 \ + "$CMUX_BIN" --socket "$CMUX_SOCKET_PATH" hooks grok install --yes >/dev/null 2>&1 || true + fi + export CMUX_AGENT_LAUNCH_KIND="grok" + export CMUX_AGENT_LAUNCH_EXECUTABLE="$REAL_GROK" + export CMUX_AGENT_LAUNCH_ARGV_B64="$(encode_launch_argv "$@")" + export CMUX_AGENT_LAUNCH_CWD="$PWD" +fi + +exec "$REAL_GROK" "$@" diff --git a/Resources/shell-integration/cmux-bash-integration.bash b/Resources/shell-integration/cmux-bash-integration.bash index 6d5c0f65c542..cb65affbd164 100644 --- a/Resources/shell-integration/cmux-bash-integration.bash +++ b/Resources/shell-integration/cmux-bash-integration.bash @@ -145,30 +145,34 @@ _cmux_restore_scrollback_once() { } _cmux_restore_scrollback_once _CMUX_CLAUDE_WRAPPER="${_CMUX_CLAUDE_WRAPPER:-}" -_cmux_install_claude_wrapper() { +_CMUX_GROK_WRAPPER="${_CMUX_GROK_WRAPPER:-}" +_cmux_install_cli_wrapper() { + local command_name="$1" + local wrapper_variable="$2" local integration_dir="${CMUX_SHELL_INTEGRATION_DIR:-}" local existing_type="" [[ -n "$integration_dir" ]] || return 0 integration_dir="${integration_dir%/}" local bundle_dir="${integration_dir%/shell-integration}" - local wrapper_path="$bundle_dir/bin/claude" + local wrapper_path="$bundle_dir/bin/$command_name" [[ -x "$wrapper_path" ]] || return 0 - existing_type="$(type -t claude 2>/dev/null || true)" + existing_type="$(type -t "$command_name" 2>/dev/null || true)" case "$existing_type" in alias|function) return 0 ;; esac - # Keep the bundled claude wrapper ahead of later PATH mutations. Install it - # via eval so an existing `alias claude=...` cannot break parsing. - _CMUX_CLAUDE_WRAPPER="$wrapper_path" - unalias claude >/dev/null 2>&1 || true - eval 'claude() { "$_CMUX_CLAUDE_WRAPPER" "$@"; }' + # Keep the bundled wrapper ahead of later PATH mutations. Install it + # via eval so an existing alias cannot break parsing. + printf -v "$wrapper_variable" '%s' "$wrapper_path" + unalias "$command_name" >/dev/null 2>&1 || true + eval "$command_name() { \"\${$wrapper_variable}\" \"\$@\"; }" } -_cmux_install_claude_wrapper +_cmux_install_cli_wrapper claude _CMUX_CLAUDE_WRAPPER +_cmux_install_cli_wrapper grok _CMUX_GROK_WRAPPER _cmux_now() { printf '%s\n' "${EPOCHSECONDS:-$SECONDS}" } diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh index d7c0f7e59958..fc2a4d50db98 100644 --- a/Resources/shell-integration/cmux-zsh-integration.zsh +++ b/Resources/shell-integration/cmux-zsh-integration.zsh @@ -157,22 +157,26 @@ _cmux_now() { } typeset -g _CMUX_CLAUDE_WRAPPER="" -_cmux_install_claude_wrapper() { +typeset -g _CMUX_GROK_WRAPPER="" +_cmux_install_cli_wrapper() { + local command_name="$1" + local wrapper_variable="$2" local integration_dir="${CMUX_SHELL_INTEGRATION_DIR:-}" [[ -n "$integration_dir" ]] || return 0 integration_dir="${integration_dir%/}" local bundle_dir="${integration_dir%/shell-integration}" - local wrapper_path="$bundle_dir/bin/claude" + local wrapper_path="$bundle_dir/bin/$command_name" [[ -x "$wrapper_path" ]] || return 0 - # Keep the bundled claude wrapper ahead of later PATH mutations. Install it - # via eval so an existing `alias claude=...` cannot break parsing. - _CMUX_CLAUDE_WRAPPER="$wrapper_path" - builtin unalias claude >/dev/null 2>&1 || true - eval 'claude() { "$_CMUX_CLAUDE_WRAPPER" "$@"; }' + # Keep the bundled wrapper ahead of later PATH mutations. Install it + # via eval so an existing alias cannot break parsing. + typeset -g "$wrapper_variable=$wrapper_path" + builtin unalias "$command_name" >/dev/null 2>&1 || true + eval "$command_name() { \"\${$wrapper_variable}\" \"\$@\"; }" } -_cmux_install_claude_wrapper +_cmux_install_cli_wrapper claude _CMUX_CLAUDE_WRAPPER +_cmux_install_cli_wrapper grok _CMUX_GROK_WRAPPER _cmux_normalize_claude_config_dir() { [[ -n "${CLAUDE_CONFIG_DIR:-}" && -n "${HOME:-}" ]] || return 0 diff --git a/Sources/RestorableAgentTypes.swift b/Sources/RestorableAgentTypes.swift index d45dc561eb12..6e1feae2df69 100644 --- a/Sources/RestorableAgentTypes.swift +++ b/Sources/RestorableAgentTypes.swift @@ -20,9 +20,8 @@ enum RestorableAgentKind: Codable, Hashable, Sendable { static let allCases: [RestorableAgentKind] = [ .claude, .codex, - .grok, - // Pi is registry-owned so the built-in Vault registration can be - // overridden by project config while direct .pi values still encode. + // Pi and Grok are registry-owned so the built-in Vault registrations can be + // overridden by project config while direct native values still encode. .amp, .cursor, .gemini, diff --git a/Sources/SessionAgentPresentation.swift b/Sources/SessionAgentPresentation.swift index 51d65f4ecbb2..9a5747d2d9af 100644 --- a/Sources/SessionAgentPresentation.swift +++ b/Sources/SessionAgentPresentation.swift @@ -19,7 +19,7 @@ extension SessionAgent { switch self { case .claude: return "AgentIcons/Claude" case .codex: return "AgentIcons/Codex" - case .grok: return nil + case .grok: return "AgentIcons/Grok" case .opencode: return "AgentIcons/OpenCode" case .rovodev: return "AgentIcons/RovoDev" case .registered(let agent): diff --git a/Sources/SessionIndexModels.swift b/Sources/SessionIndexModels.swift index 0372cb4bfc88..baaddd5a897d 100644 --- a/Sources/SessionIndexModels.swift +++ b/Sources/SessionIndexModels.swift @@ -342,21 +342,21 @@ struct SessionEntry: Identifiable, Hashable { } return parts.joined(separator: " ") case let .grok(model, permissionMode, sandboxMode, grokHome): - var parts = ["grok -r \(Self.shellQuote(sessionId))"] + var argv = ["grok", "-r", sessionId] if let model, !model.isEmpty { - parts.append("-m \(Self.shellQuote(model))") + argv.append(contentsOf: ["-m", model]) } if let permissionMode, !permissionMode.isEmpty { - parts.append("--permission-mode \(Self.shellQuote(permissionMode))") + argv.append(contentsOf: ["--permission-mode", permissionMode]) } if let sandboxMode, !sandboxMode.isEmpty { - parts.append("--sandbox \(Self.shellQuote(sandboxMode))") + argv.append(contentsOf: ["--sandbox", sandboxMode]) } let environment = grokHome.flatMap { value -> [String: String]? in let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : ["GROK_HOME": trimmed] } ?? [:] - return Self.withShellEnvironment(environment, command: parts.joined(separator: " ")) + return Self.singleQuotedShellCommand(environment: environment, argv: argv) case let .opencode(providerModel, agentName): var parts = ["opencode --session \(sessionId)"] if let providerModel, !providerModel.isEmpty { @@ -412,6 +412,29 @@ struct SessionEntry: Identifiable, Hashable { return "env \(assignments.joined(separator: " ")) \(command)" } + private static func singleQuotedShellCommand( + environment: [String: String], + argv: [String] + ) -> String { + var parts: [String] = [] + let assignments = environment + .filter { key, _ in + key.range(of: #"^[A-Za-z_][A-Za-z0-9_]*$"#, options: .regularExpression) != nil + } + .sorted { $0.key < $1.key } + .map { key, value in "\(key)=\(value)" } + if !assignments.isEmpty { + parts.append("env") + parts.append(contentsOf: assignments) + } + parts.append(contentsOf: argv) + return parts.map(Self.shellSingleQuote).joined(separator: " ") + } + + private static func shellSingleQuote(_ value: String) -> String { + "'\(value.replacingOccurrences(of: "'", with: #"'\''"#))'" + } + /// Single-quote a value for safe shell injection. Escapes embedded single quotes. static func shellQuote(_ value: String) -> String { if value.range(of: "[^A-Za-z0-9_./:=+-]", options: .regularExpression) == nil { diff --git a/Sources/SessionIndexRegisteredAgents.swift b/Sources/SessionIndexRegisteredAgents.swift index 08e42c634a23..c519d0b99e45 100644 --- a/Sources/SessionIndexRegisteredAgents.swift +++ b/Sources/SessionIndexRegisteredAgents.swift @@ -1,5 +1,228 @@ import Foundation +struct GrokSessionRoot: Sendable, Hashable { + let sessionsRoot: String + let grokHomeForResume: String? +} + +private struct GrokHookObservedSessionStoreFile: Decodable { + var sessions: [String: GrokHookObservedSessionRecord] + + private enum CodingKeys: String, CodingKey { + case sessions + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + sessions = try container.decodeIfPresent( + [String: GrokHookObservedSessionRecord].self, + forKey: .sessions + ) ?? [:] + } +} + +private struct GrokHookObservedSessionRecord: Decodable { + var launchCommand: GrokHookObservedLaunchCommand? +} + +private struct GrokHookObservedLaunchCommand: Decodable { + var environment: [String: String]? +} + +enum GrokSessionLocator { + static func defaultSessionsRoot(homeDirectory: String = NSHomeDirectory()) -> String { + let standardizedHome = expandTilde(homeDirectory, homeDirectory: homeDirectory) + return ((standardizedHome as NSString).appendingPathComponent(".grok") as NSString) + .appendingPathComponent("sessions") + } + + static func encodedSessionCWD(_ cwd: String) -> String { + var encoded = "" + for byte in cwd.utf8 { + let isUnreserved = (byte >= 0x41 && byte <= 0x5A) + || (byte >= 0x61 && byte <= 0x7A) + || (byte >= 0x30 && byte <= 0x39) + || byte == 0x2D + || byte == 0x2E + || byte == 0x5F + || byte == 0x7E + if isUnreserved { + encoded.append(Character(UnicodeScalar(byte))) + } else { + encoded.append(String(format: "%%%02X", byte)) + } + } + return encoded + } + + static func workingDirectory(fromProjectDirectoryName name: String) -> String? { + let decoded = name.removingPercentEncoding ?? name + return normalizedWorkingDirectory(decoded) + } + + static func normalizedWorkingDirectory(_ value: String?) -> String? { + let trimmed = normalized(value) + return trimmed.map { ($0 as NSString).standardizingPath } + } + + static func encodedSessionCWDs(for cwd: String) -> [String] { + guard let rawCwd = normalized(cwd) else { + return [] + } + var seen = Set() + return [rawCwd, (rawCwd as NSString).standardizingPath] + .map(encodedSessionCWD) + .filter { seen.insert($0).inserted } + } + + static func sessionRoot( + registration: CmuxVaultAgentRegistration, + environment: [String: String] = ProcessInfo.processInfo.environment, + homeDirectory: String = NSHomeDirectory() + ) -> GrokSessionRoot { + let rawRoot: String + let configuredRoot = normalized(registration.sessionDirectory) + let configuredIsDefault = configuredRoot.map { + expandTilde($0, homeDirectory: homeDirectory) + == (defaultSessionsRoot(homeDirectory: homeDirectory) as NSString).standardizingPath + } ?? false + if let grokHome = normalized(environment["GROK_HOME"]), + configuredRoot == nil || configuredIsDefault { + rawRoot = (grokHome as NSString).appendingPathComponent("sessions") + } else if let configured = configuredRoot { + rawRoot = configured + } else { + rawRoot = defaultSessionsRoot(homeDirectory: homeDirectory) + } + let sessionsRoot = expandTilde(rawRoot, homeDirectory: homeDirectory) + let grokHome = grokHomeForResume( + sessionsRoot: sessionsRoot, + defaultSessionsRoot: defaultSessionsRoot(homeDirectory: homeDirectory) + ) + return GrokSessionRoot(sessionsRoot: sessionsRoot, grokHomeForResume: grokHome) + } + + static func sessionRoots( + registration: CmuxVaultAgentRegistration, + cwdFilter: String?, + environment: [String: String] = ProcessInfo.processInfo.environment, + homeDirectory: String = NSHomeDirectory(), + observedGrokHomes: [String] = [] + ) -> [GrokSessionRoot] { + let root = sessionRoot( + registration: registration, + environment: environment, + homeDirectory: homeDirectory + ) + var roots = [root] + if registrationUsesDefaultGrokRoot(registration: registration, homeDirectory: homeDirectory) { + for grokHome in observedGrokHomes { + guard let candidate = sessionRoot( + grokHome: grokHome, + homeDirectory: homeDirectory + ) else { + continue + } + roots.append(candidate) + } + roots = deduplicatedSessionRoots(roots) + } + guard let cwdFilter = normalized(cwdFilter) else { + return roots + } + let scopedRoots = roots.flatMap { root in + encodedSessionCWDs(for: cwdFilter).map { encodedCwd in + let scopedRoot = (root.sessionsRoot as NSString).appendingPathComponent(encodedCwd) + return GrokSessionRoot(sessionsRoot: scopedRoot, grokHomeForResume: root.grokHomeForResume) + } + } + return deduplicatedSessionRoots(scopedRoots) + } + + static func observedGrokHomes( + homeDirectory: String = NSHomeDirectory(), + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default + ) -> [String] { + let storeURL = RestorableAgentKind.grok.hookStoreFileURL( + homeDirectory: homeDirectory, + environment: environment + ) + guard fileManager.fileExists(atPath: storeURL.path), + let data = try? Data(contentsOf: storeURL), + let state = try? JSONDecoder().decode(GrokHookObservedSessionStoreFile.self, from: data) else { + return [] + } + + var seen = Set() + var homes: [String] = [] + for record in state.sessions.values { + guard let rawHome = normalized(record.launchCommand?.environment?["GROK_HOME"]) else { + continue + } + let home = expandTilde(rawHome, homeDirectory: homeDirectory) + guard seen.insert(home).inserted else { continue } + homes.append(home) + } + return homes + } + + private static func grokHomeForResume(sessionsRoot: String, defaultSessionsRoot: String) -> String? { + let standardizedRoot = (sessionsRoot as NSString).standardizingPath + let standardizedDefault = (defaultSessionsRoot as NSString).standardizingPath + guard standardizedRoot != standardizedDefault else { return nil } + guard (standardizedRoot as NSString).lastPathComponent == "sessions" else { return nil } + return (standardizedRoot as NSString).deletingLastPathComponent + } + + private static func sessionRoot(grokHome: String, homeDirectory: String) -> GrokSessionRoot? { + guard let normalizedHome = normalized(grokHome) else { return nil } + let expandedHome = expandTilde(normalizedHome, homeDirectory: homeDirectory) + let sessionsRoot = (expandedHome as NSString).appendingPathComponent("sessions") + let grokHome = grokHomeForResume( + sessionsRoot: sessionsRoot, + defaultSessionsRoot: defaultSessionsRoot(homeDirectory: homeDirectory) + ) + return GrokSessionRoot(sessionsRoot: sessionsRoot, grokHomeForResume: grokHome) + } + + private static func registrationUsesDefaultGrokRoot( + registration: CmuxVaultAgentRegistration, + homeDirectory: String + ) -> Bool { + guard let configuredRoot = normalized(registration.sessionDirectory) else { + return true + } + let expandedConfigured = expandTilde(configuredRoot, homeDirectory: homeDirectory) + let expandedDefault = (defaultSessionsRoot(homeDirectory: homeDirectory) as NSString).standardizingPath + return expandedConfigured == expandedDefault + } + + private static func deduplicatedSessionRoots(_ roots: [GrokSessionRoot]) -> [GrokSessionRoot] { + var seen = Set() + return roots.filter { root in + seen.insert((root.sessionsRoot as NSString).standardizingPath).inserted + } + } + + private static func expandTilde(_ path: String, homeDirectory: String) -> String { + let home = ((homeDirectory as NSString).expandingTildeInPath as NSString).standardizingPath + if path == "~" { + return home + } + if path.hasPrefix("~/") { + let suffix = String(path.dropFirst(2)) + return ((home as NSString).appendingPathComponent(suffix) as NSString).standardizingPath + } + return ((path as NSString).expandingTildeInPath as NSString).standardizingPath + } + + private static func normalized(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed?.isEmpty == false ? trimmed : nil + } +} + extension SessionIndexStore { private struct RegisteredAgentJSONLMetadata { var title: String = "" @@ -8,6 +231,133 @@ extension SessionIndexStore { var sessionId: String? } + private struct GrokSessionMetadata { + var title: String = "" + var model: String? + var permissionMode: String? + var sandboxMode: String? + var branch: String? + } + + nonisolated static func loadGrokEntries( + registration: CmuxVaultAgentRegistration, + needle: String, + cwdFilter: String?, + offset: Int, + limit: Int, + agent: SessionAgent = .grok, + environment: [String: String] = ProcessInfo.processInfo.environment, + homeDirectory: String = NSHomeDirectory(), + fileManager: FileManager = .default + ) async -> [SessionEntry] { + let observedGrokHomes = GrokSessionLocator.observedGrokHomes( + homeDirectory: homeDirectory, + environment: environment, + fileManager: fileManager + ) + let roots = GrokSessionLocator.sessionRoots( + registration: registration, + cwdFilter: cwdFilter, + environment: environment, + homeDirectory: homeDirectory, + observedGrokHomes: observedGrokHomes + ) + guard !roots.isEmpty else { return [] } + let fm = fileManager + + var candidates: [(url: URL, modified: Date, prefilteredByRipgrep: Bool, root: GrokSessionRoot)] = [] + if !needle.isEmpty { + for root in roots { + guard let rgPaths = await ripgrepMatchingPaths( + needle: needle, + root: root.sessionsRoot, + fileGlob: "chat_history.jsonl" + ) else { + candidates.append( + contentsOf: enumerateGrokHistoryCandidates(root: root, fileManager: fileManager).map { + (url: $0.0, modified: $0.1, prefilteredByRipgrep: false, root: root) + } + ) + continue + } + for url in rgPaths where url.lastPathComponent == "chat_history.jsonl" { + guard let attrs = try? fm.attributesOfItem(atPath: url.path), + let modified = attrs[.modificationDate] as? Date else { + continue + } + candidates.append((url, modified, true, root)) + } + } + } else { + for root in roots { + candidates.append( + contentsOf: enumerateGrokHistoryCandidates(root: root, fileManager: fileManager).map { + (url: $0.0, modified: $0.1, prefilteredByRipgrep: false, root: root) + } + ) + } + } + + candidates.sort { $0.modified > $1.modified } + let target = offset + limit + var matches: [SessionEntry] = [] + var seenSessionIds = Set() + var scanned = 0 + for candidate in candidates { + if Task.isCancelled { break } + if matches.count >= target { break } + if scanned >= searchMaxFiles { break } + scanned += 1 + + if !needle.isEmpty && !candidate.prefilteredByRipgrep { + guard fileContains(candidate.url, needle: needle) else { continue } + } + + let sessionDirectory = candidate.url.deletingLastPathComponent() + let projectDirectory = sessionDirectory.deletingLastPathComponent().lastPathComponent + let cwd = GrokSessionLocator.workingDirectory(fromProjectDirectoryName: projectDirectory) + if let cwdFilter, + GrokSessionLocator.normalizedWorkingDirectory(cwd) + != GrokSessionLocator.normalizedWorkingDirectory(cwdFilter) { + continue + } + + let metadata = extractGrokSessionMetadata(url: candidate.url) + let sessionId = sessionDirectory.lastPathComponent + guard seenSessionIds.insert(sessionId).inserted else { continue } + let specifics: AgentSpecifics + switch agent { + case .grok: + specifics = .grok( + model: metadata.model, + permissionMode: metadata.permissionMode, + sandboxMode: metadata.sandboxMode, + grokHome: candidate.root.grokHomeForResume + ) + default: + specifics = .registered( + registrationWithGrokHomePrefix( + registration, + grokHome: candidate.root.grokHomeForResume + ) + ) + } + matches.append(SessionEntry( + id: "\(registration.id):\(sessionId)", + agent: agent, + sessionId: sessionId, + title: metadata.title, + cwd: cwd, + gitBranch: metadata.branch, + pullRequest: nil, + modified: candidate.modified, + fileURL: candidate.url, + specifics: specifics + )) + } + return Array(matches.dropFirst(offset).prefix(limit)) + } + nonisolated static func loadRegisteredAgentEntries( registration: CmuxVaultAgentRegistration, needle: String, @@ -15,6 +365,16 @@ extension SessionIndexStore { offset: Int, limit: Int ) async -> [SessionEntry] { + if case .grokSessionDirectory = registration.sessionIdSource { + return await loadGrokEntries( + registration: registration, + needle: needle, + cwdFilter: cwdFilter, + offset: offset, + limit: limit, + agent: .registered(RegisteredSessionAgent(registration: registration)) + ) + } let roots = registeredSessionRoots(registration: registration, cwdFilter: cwdFilter) guard !roots.isEmpty else { return [] } let fm = FileManager.default @@ -89,6 +449,10 @@ extension SessionIndexStore { registration: CmuxVaultAgentRegistration, cwdFilter: String? ) -> [String] { + if case .grokSessionDirectory = registration.sessionIdSource { + return GrokSessionLocator.sessionRoots(registration: registration, cwdFilter: cwdFilter) + .map(\.sessionsRoot) + } guard let root = registration.sessionDirectory.map({ ($0 as NSString).expandingTildeInPath }) else { return [] } @@ -100,6 +464,44 @@ extension SessionIndexStore { return [root] } + nonisolated private static func registrationWithGrokHomePrefix( + _ registration: CmuxVaultAgentRegistration, + grokHome: String? + ) -> CmuxVaultAgentRegistration { + guard let grokHome = grokHome?.trimmingCharacters(in: .whitespacesAndNewlines), + !grokHome.isEmpty, + !registration.resumeCommand.contains("GROK_HOME") else { + return registration + } + var copy = registration + copy.resumeCommand = "env GROK_HOME=\(SessionEntry.shellQuote(grokHome)) \(registration.resumeCommand)" + return copy + } + + nonisolated private static func enumerateGrokHistoryCandidates( + root: GrokSessionRoot, + fileManager: FileManager + ) -> [(URL, Date)] { + let fm = fileManager + var isDirectory: ObjCBool = false + guard fm.fileExists(atPath: root.sessionsRoot, isDirectory: &isDirectory), + isDirectory.boolValue, + let enumerator = fm.enumerator( + at: URL(fileURLWithPath: root.sessionsRoot, isDirectory: true), + includingPropertiesForKeys: [.contentModificationDateKey, .isRegularFileKey], + options: [.skipsHiddenFiles] + ) else { + return [] + } + var candidates: [(URL, Date)] = [] + for case let url as URL in enumerator where url.lastPathComponent == "chat_history.jsonl" { + let values = try? url.resourceValues(forKeys: [.contentModificationDateKey, .isRegularFileKey]) + guard values?.isRegularFile == true, let modified = values?.contentModificationDate else { continue } + candidates.append((url, modified)) + } + return candidates + } + nonisolated private static func enumerateRegisteredJSONLCandidates(root: String) -> [(URL, Date)] { let fm = FileManager.default var isDirectory: ObjCBool = false @@ -121,6 +523,50 @@ extension SessionIndexStore { return candidates } + nonisolated private static func extractGrokSessionMetadata(url: URL) -> GrokSessionMetadata { + var metadata = GrokSessionMetadata() + var remainingBranchProbeLines: Int? + forEachJSONLine(url: url, maxBytes: 512 * 1024) { object in + if metadata.title.isEmpty { + metadata.title = grokTitle(in: object) ?? "" + } + if metadata.model == nil { + metadata.model = firstString(in: object, keys: ["model", "modelId", "modelID", "model_id"]) + ?? firstString( + in: object["message"] as? [String: Any] ?? [:], + keys: ["model", "modelId", "modelID", "model_id"] + ) + } + if metadata.permissionMode == nil { + metadata.permissionMode = firstString( + in: object, + keys: ["permissionMode", "permission_mode", "approvalPolicy", "approval_policy"] + ) + } + if metadata.sandboxMode == nil { + metadata.sandboxMode = firstString( + in: object, + keys: ["sandboxMode", "sandbox_mode", "sandbox"] + ) + } + if metadata.branch == nil, let git = object["git"] as? [String: Any] { + metadata.branch = firstString(in: git, keys: ["branch", "gitBranch"]) + } + if metadata.branch == nil { + metadata.branch = firstString(in: object, keys: ["gitBranch", "branch"]) + } + let hasStableMetadata = !metadata.title.isEmpty + && metadata.model != nil + && metadata.permissionMode != nil + && metadata.sandboxMode != nil + guard hasStableMetadata else { return false } + guard metadata.branch == nil else { return true } + remainingBranchProbeLines = (remainingBranchProbeLines ?? 32) - 1 + return (remainingBranchProbeLines ?? 0) <= 0 + } + return metadata + } + nonisolated private static func extractRegisteredJSONLMetadata( url: URL, registration: CmuxVaultAgentRegistration, @@ -131,7 +577,7 @@ extension SessionIndexStore { switch registration.sessionIdSource { case .argvOption: needsNativeSessionID = true - case .piSessionFile: + case .piSessionFile, .grokSessionDirectory: needsNativeSessionID = false } forEachJSONLine(url: url, maxBytes: 512 * 1024) { object in @@ -226,6 +672,66 @@ extension SessionIndexStore { return firstText(in: object, keys: ["text", "content"]) } + nonisolated private static func grokTitle(in object: [String: Any]) -> String? { + if shouldUseGrokObjectAsTitle(object) { + if let title = grokTitleText(firstText(in: object, keys: ["content", "text"])) { + return title + } + if let message = grokTitleText(firstString(in: object, keys: ["message"])) { + return message + } + } + if let message = object["message"] as? [String: Any], + shouldUseGrokObjectAsTitle(message) { + return grokTitleText(firstText(in: message, keys: ["content", "text"])) + } + if let messages = object["messages"] as? [[String: Any]] { + return messages.compactMap { message in + shouldUseGrokObjectAsTitle(message) + ? grokTitleText(firstText(in: message, keys: ["content", "text"])) + : nil + }.first + } + return nil + } + + nonisolated private static func grokTitleText(_ value: String?) -> String? { + guard let value else { return nil } + if let userQuery = grokTaggedContent(named: "user_query", in: value) { + return userQuery + } + let withoutMetadata = ["user_info", "git_status", "system-reminder"].reduce(value) { partial, tag in + removingGrokTaggedContent(named: tag, from: partial) + } + return trimmedNonEmpty(withoutMetadata) + } + + nonisolated private static func grokTaggedContent(named tag: String, in text: String) -> String? { + let openTag = "<\(tag)>" + let closeTag = "" + guard let openRange = text.range(of: openTag) else { return nil } + let bodyStart = openRange.upperBound + guard let closeRange = text[bodyStart...].range(of: closeTag) else { return nil } + return trimmedNonEmpty(String(text[bodyStart.. String { + let openTag = "<\(tag)>" + let closeTag = "" + var result = text + while let openRange = result.range(of: openTag) { + let bodyStart = openRange.upperBound + guard let closeRange = result[bodyStart...].range(of: closeTag) else { break } + result.removeSubrange(openRange.lowerBound.. Bool { + let role = firstString(in: object, keys: ["role", "type"]) + return role == nil || isUserRole(role) + } + nonisolated private static func firstTextValue(_ value: Any?) -> String? { if let string = value as? String { return trimmedNonEmpty(string) diff --git a/Sources/SessionIndexStore.swift b/Sources/SessionIndexStore.swift index 1f2ba649d36d..50b7f01529b2 100644 --- a/Sources/SessionIndexStore.swift +++ b/Sources/SessionIndexStore.swift @@ -1093,6 +1093,12 @@ final class SessionIndexStore: ObservableObject { let scopedCwd = currentDirectory?.trimmingCharacters(in: .whitespacesAndNewlines) cwdFilter = scopedCwd?.isEmpty == false ? scopedCwd : nil registry = await Self.vaultAgentRegistry(workingDirectory: cwdFilter) + } else if a == .grok { + let scopedCwd = currentDirectory?.trimmingCharacters(in: .whitespacesAndNewlines) + cwdFilter = scopedCwd?.isEmpty == false ? scopedCwd : nil + registry = await Self.vaultAgentRegistry( + workingDirectory: cwdFilter + ) } else { cwdFilter = nil registry = CmuxVaultAgentRegistry(registrations: []) @@ -1197,8 +1203,14 @@ final class SessionIndexStore: ObservableObject { switch agent { case .claude: return await loadClaudeEntries(needle: needle, cwdFilter: cwdFilter, offset: offset, limit: limit) case .codex: return await loadCodexEntries(needle: needle, cwdFilter: cwdFilter, offset: offset, limit: limit, errorBag: errorBag) - // Grok sessions are restored from hook persistence, so searchAgent has no filesystem loader for this case. - case .grok: return [] + case .grok: + return await loadGrokEntries( + registration: registry.registration(id: "grok") ?? .builtInGrok, + needle: needle, + cwdFilter: cwdFilter, + offset: offset, + limit: limit + ) case .opencode: return loadOpenCodeEntries(needle: needle, cwdFilter: cwdFilter, offset: offset, limit: limit, errorBag: errorBag) case .rovodev: return loadRovoDevEntries(needle: needle, cwdFilter: cwdFilter, offset: offset, limit: limit, errorBag: errorBag) case .hermesAgent: return loadHermesAgentEntries(needle: needle, cwdFilter: cwdFilter, offset: offset, limit: limit, errorBag: errorBag) diff --git a/Sources/SessionIndexView.swift b/Sources/SessionIndexView.swift index 61cb04db01e7..aee4b1422a71 100644 --- a/Sources/SessionIndexView.swift +++ b/Sources/SessionIndexView.swift @@ -1033,6 +1033,21 @@ private enum SessionTranscriptPreviewState: Equatable { case loaded([SessionTranscriptDisplayRow]) } +private extension SessionEntry { + var usesGrokTranscriptLayout: Bool { + if agent == .grok { + return true + } + guard case .registered(let registration) = specifics else { + return false + } + if case .grokSessionDirectory = registration.sessionIdSource { + return true + } + return false + } +} + private enum SessionTranscriptLoader { private static let streamChunkSize = 256 * 1024 private static let maxPreviewRecordBytes = 2 * 1024 * 1024 @@ -1064,6 +1079,58 @@ private enum SessionTranscriptLoader { Data(#""role":"#.utf8), Data(#""role": "#.utf8) ] + private static let grokAssistantRoleNeedles = [ + Data(#""role":"assistant""#.utf8), + Data(#""role": "assistant""#.utf8), + Data(#""type":"assistant""#.utf8), + Data(#""type": "assistant""#.utf8) + ] + private static let grokUserRoleNeedles = [ + Data(#""role":"user""#.utf8), + Data(#""role": "user""#.utf8), + Data(#""type":"user""#.utf8), + Data(#""type": "user""#.utf8) + ] + private static let grokSystemRoleNeedles = [ + Data(#""role":"system""#.utf8), + Data(#""role": "system""#.utf8), + Data(#""role":"developer""#.utf8), + Data(#""role": "developer""#.utf8), + Data(#""type":"system""#.utf8), + Data(#""type": "system""#.utf8), + Data(#""type":"developer""#.utf8), + Data(#""type": "developer""#.utf8) + ] + private static let grokToolRoleNeedles = [ + Data(#""role":"tool""#.utf8), + Data(#""role": "tool""#.utf8), + Data(#""role":"tool_use""#.utf8), + Data(#""role": "tool_use""#.utf8), + Data(#""role":"tool_result""#.utf8), + Data(#""role": "tool_result""#.utf8), + Data(#""role":"function_call""#.utf8), + Data(#""role": "function_call""#.utf8), + Data(#""role":"function_call_output""#.utf8), + Data(#""role": "function_call_output""#.utf8), + Data(#""type":"tool""#.utf8), + Data(#""type": "tool""#.utf8), + Data(#""type":"tool_use""#.utf8), + Data(#""type": "tool_use""#.utf8), + Data(#""type":"tool_result""#.utf8), + Data(#""type": "tool_result""#.utf8), + Data(#""type":"function_call""#.utf8), + Data(#""type": "function_call""#.utf8), + Data(#""type":"function_call_output""#.utf8), + Data(#""type": "function_call_output""#.utf8) + ] + private static let grokRoleNeedles = [ + Data(#""role":"#.utf8), + Data(#""role": "#.utf8) + ] + + grokAssistantRoleNeedles + + grokUserRoleNeedles + + grokSystemRoleNeedles + + grokToolRoleNeedles static func load(entry: SessionEntry) async throws -> [SessionTranscriptTurn] { if entry.agent == .opencode { @@ -1084,12 +1151,21 @@ private enum SessionTranscriptLoader { throw SessionTranscriptLoadError.missingFile } let agent = entry.agent + let usesGrokTranscriptLayout = entry.usesGrokTranscriptLayout return try await Task.detached(priority: .userInitiated) { - try loadSynchronously(from: url, agent: agent) + try loadSynchronously( + from: url, + agent: agent, + usesGrokTranscriptLayout: usesGrokTranscriptLayout + ) }.value } - private static func loadSynchronously(from url: URL, agent: SessionAgent) throws -> [SessionTranscriptTurn] { + private static func loadSynchronously( + from url: URL, + agent: SessionAgent, + usesGrokTranscriptLayout: Bool + ) throws -> [SessionTranscriptTurn] { guard FileManager.default.fileExists(atPath: url.path) else { throw SessionTranscriptLoadError.missingFile } @@ -1130,7 +1206,12 @@ private enum SessionTranscriptLoader { didHitTurnLimit = turns.count >= maxPreviewTurns return } - guard let parsed = parseLineData(lineData, agent: agent, id: lineIndex) else { + guard let parsed = parseLineData( + lineData, + agent: agent, + usesGrokTranscriptLayout: usesGrokTranscriptLayout, + id: lineIndex + ) else { return } turns.append(parsed) @@ -1145,8 +1226,16 @@ private enum SessionTranscriptLoader { if remainingCapacity > 0 { lineData.append(contentsOf: segment.prefix(remainingCapacity)) } - if shouldParseRawLine(lineData, agent: agent) { - oversizedPreviewRole = inferredRole(from: lineData, agent: agent) ?? .event + if shouldParseRawLine( + lineData, + agent: agent, + usesGrokTranscriptLayout: usesGrokTranscriptLayout + ) { + oversizedPreviewRole = inferredRole( + from: lineData, + agent: agent, + usesGrokTranscriptLayout: usesGrokTranscriptLayout + ) ?? .event } lineData.removeAll(keepingCapacity: true) isSkippingOversizedLine = true @@ -1308,19 +1397,26 @@ private enum SessionTranscriptLoader { private static func parseLineData( _ lineData: Data, agent: SessionAgent, + usesGrokTranscriptLayout: Bool, id: Int ) -> SessionTranscriptTurn? { guard !lineData.isEmpty, - shouldParseRawLine(lineData, agent: agent), + shouldParseRawLine(lineData, agent: agent, usesGrokTranscriptLayout: usesGrokTranscriptLayout), let object = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any] else { return nil } - return parseLine(object, agent: agent, id: id) + return parseLine( + object, + agent: agent, + usesGrokTranscriptLayout: usesGrokTranscriptLayout, + id: id + ) } private static func parseLine( _ object: [String: Any], agent: SessionAgent, + usesGrokTranscriptLayout: Bool, id: Int ) -> SessionTranscriptTurn? { switch agent { @@ -1329,7 +1425,12 @@ private enum SessionTranscriptLoader { case .codex: return parseCodexLine(object, id: id) case .grok, .opencode, .rovodev, .registered: - return parseGenericLine(object, agent: agent, id: id) + return parseGenericLine( + object, + agent: agent, + usesGrokTranscriptLayout: usesGrokTranscriptLayout, + id: id + ) case .hermesAgent: return nil } @@ -1378,17 +1479,33 @@ private enum SessionTranscriptLoader { private static func parseGenericLine( _ object: [String: Any], agent: SessionAgent, + usesGrokTranscriptLayout: Bool, id: Int ) -> SessionTranscriptTurn? { - if let parsed = parseGenericMessage(object, agent: agent, id: id) { + if let parsed = parseGenericMessage( + object, + agent: agent, + usesGrokTranscriptLayout: usesGrokTranscriptLayout, + id: id + ) { return parsed } if let payload = object["payload"] as? [String: Any], - let parsed = parseGenericMessage(payload, agent: agent, id: id) { + let parsed = parseGenericMessage( + payload, + agent: agent, + usesGrokTranscriptLayout: usesGrokTranscriptLayout, + id: id + ) { return parsed } if let message = object["message"] as? [String: Any], - let parsed = parseGenericMessage(message, agent: agent, id: id) { + let parsed = parseGenericMessage( + message, + agent: agent, + usesGrokTranscriptLayout: usesGrokTranscriptLayout, + id: id + ) { return parsed } return nil @@ -1397,10 +1514,31 @@ private enum SessionTranscriptLoader { private static func parseGenericMessage( _ object: [String: Any], agent: SessionAgent, + usesGrokTranscriptLayout: Bool, id: Int ) -> SessionTranscriptTurn? { let fallbackRole: SessionTranscriptRole? = { if case .registered = agent { return .event }; return nil }() - guard let role = transcriptRole(from: object["role"] as? String) ?? fallbackRole else { + let rawRole = object["role"] as? String + let parsedRole = transcriptRole(from: rawRole) + let roleFromRole = usesGrokTranscriptLayout + && parsedRole == .event + && rawRole?.caseInsensitiveCompare("event") != .orderedSame + ? nil + : parsedRole + let shouldUseGrokTypeRole = usesGrokTranscriptLayout + && roleFromRole == nil + let roleFromType: SessionTranscriptRole? = { + guard shouldUseGrokTypeRole else { return nil } + let rawType = object["type"] as? String + let parsedTypeRole = transcriptRole(from: rawType) + if parsedTypeRole == .event, + rawType?.caseInsensitiveCompare("event") != .orderedSame { + return nil + } + return parsedTypeRole + }() + let shouldUseFallbackRole = !usesGrokTranscriptLayout + guard let role = roleFromType ?? roleFromRole ?? (shouldUseFallbackRole ? fallbackRole : nil) else { return nil } let content = object["content"] ?? object["text"] ?? object["message"] @@ -1604,14 +1742,23 @@ private enum SessionTranscriptLoader { } } - private static func shouldParseRawLine(_ data: Data, agent: SessionAgent) -> Bool { + private static func shouldParseRawLine( + _ data: Data, + agent: SessionAgent, + usesGrokTranscriptLayout: Bool + ) -> Bool { + if usesGrokTranscriptLayout { + return containsAny(data, needles: grokRoleNeedles) + } switch agent { case .claude: return containsAny(data, needles: claudeUserNeedles) case .codex: return containsAny(data, needles: codexResponseItemNeedles) && containsAny(data, needles: codexPreviewNeedles) - case .grok, .opencode, .rovodev: + case .grok: + return containsAny(data, needles: grokRoleNeedles) + case .opencode, .rovodev: return containsAny(data, needles: genericRoleNeedles) case .registered: return true @@ -1620,7 +1767,14 @@ private enum SessionTranscriptLoader { } } - private static func inferredRole(from data: Data, agent: SessionAgent) -> SessionTranscriptRole? { + private static func inferredRole( + from data: Data, + agent: SessionAgent, + usesGrokTranscriptLayout: Bool + ) -> SessionTranscriptRole? { + if usesGrokTranscriptLayout { + return inferredGrokRole(from: data) + } switch agent { case .claude: if containsAny(data, needles: [Data(#""type":"assistant""#.utf8), Data(#""type": "assistant""#.utf8)]) { @@ -1629,7 +1783,7 @@ private enum SessionTranscriptLoader { if containsAny(data, needles: [Data(#""type":"user""#.utf8), Data(#""type": "user""#.utf8)]) { return .user } - case .codex, .grok, .opencode, .rovodev, .registered: + case .codex, .opencode, .rovodev, .registered: if containsAny(data, needles: [Data(#""role":"assistant""#.utf8), Data(#""role": "assistant""#.utf8)]) { return .assistant } @@ -1639,12 +1793,30 @@ private enum SessionTranscriptLoader { if containsAny(data, needles: [Data(#""type":"function_call""#.utf8), Data(#""type": "function_call""#.utf8)]) { return .tool } + case .grok: + return inferredGrokRole(from: data) case .hermesAgent: return nil } return nil } + private static func inferredGrokRole(from data: Data) -> SessionTranscriptRole? { + if containsAny(data, needles: grokAssistantRoleNeedles) { + return .assistant + } + if containsAny(data, needles: grokUserRoleNeedles) { + return .user + } + if containsAny(data, needles: grokSystemRoleNeedles) { + return .system + } + if containsAny(data, needles: grokToolRoleNeedles) { + return .tool + } + return nil + } + private static func containsAny(_ data: Data, needles: [Data]) -> Bool { needles.contains { data.range(of: $0) != nil } } diff --git a/Sources/VaultAgentProcessScanner.swift b/Sources/VaultAgentProcessScanner.swift index d4411d8d85e7..a92161506801 100644 --- a/Sources/VaultAgentProcessScanner.swift +++ b/Sources/VaultAgentProcessScanner.swift @@ -743,12 +743,16 @@ private struct VaultObservedAgentProcess: Sendable { private extension CmuxVaultAgentDetectRule { func matches(_ process: VaultObservedAgentProcess) -> Bool { - guard processName != nil || !argvContains.isEmpty else { return false } - let processNameMatch = processName.map { expected in + var expectedNames = processNames + if let processName { + expectedNames.append(processName) + } + guard !expectedNames.isEmpty || !argvContains.isEmpty else { return false } + let processNameMatch = expectedNames.isEmpty || expectedNames.contains { expected in process.executableBasenames.contains { candidate in candidate.compare(expected, options: [.caseInsensitive, .literal]) == .orderedSame } - } ?? true + } let argvContainsMatch = argvContains.isEmpty || argvContains.allSatisfy { needle in if needle.contains(" ") { let joinedArguments = process.arguments.joined(separator: " ") @@ -789,6 +793,11 @@ private extension CmuxVaultAgentSessionIDSource { ) ?? session } return PiSessionLocator.latestSessionPath(for: process, registration: registration, fileManager: fileManager) + case .grokSessionDirectory: + if let session = process.arguments.grokResumeSessionID { + return session + } + return nil } } } @@ -825,6 +834,31 @@ private extension Array where Element == String { } return nil } + + var grokResumeSessionID: String? { + let options = ["-r", "--resume"] + for index in indices { + let argument = self[index] + if options.contains(argument) { + let nextIndex = self.index(after: index) + guard nextIndex < endIndex else { continue } + let value = self[nextIndex].trimmingCharacters(in: .whitespacesAndNewlines) + if !value.isEmpty, !value.hasPrefix("-") { + return value + } + continue + } + for option in options { + let prefix = option + "=" + guard argument.hasPrefix(prefix) else { continue } + let value = String(argument.dropFirst(prefix.count)).trimmingCharacters(in: .whitespacesAndNewlines) + if !value.isEmpty, !value.hasPrefix("-") { + return value + } + } + } + return nil + } } enum PiSessionLocator { diff --git a/Sources/VaultAgentRegistry.swift b/Sources/VaultAgentRegistry.swift index 45ad53a2be92..983aab67958c 100644 --- a/Sources/VaultAgentRegistry.swift +++ b/Sources/VaultAgentRegistry.swift @@ -106,6 +106,10 @@ struct CmuxVaultAgentRegistration: Codable, Hashable, Sendable { !processName.isEmpty { return processName } + if let processName = detect.processNames.first?.trimmingCharacters(in: .whitespacesAndNewlines), + !processName.isEmpty { + return processName + } return id } @@ -121,18 +125,35 @@ struct CmuxVaultAgentRegistration: Codable, Hashable, Sendable { sessionDirectory: "~/.pi/agent/sessions" ) } + + static var builtInGrok: CmuxVaultAgentRegistration { + CmuxVaultAgentRegistration( + id: "grok", + name: "Grok", + detect: CmuxVaultAgentDetectRule(processNames: ["grok", "grok-macos-aarch64", "grok-macos-aarch"]), + sessionIdSource: .grokSessionDirectory, + resumeCommand: "{{executable}} -r {{sessionId}}", + cwd: .preserve, + sessionDirectory: "~/.grok/sessions" + ) + } } struct CmuxVaultAgentDetectRule: Codable, Hashable, Sendable { var processName: String? + var processNames: [String] var argvContains: [String] private enum CodingKeys: String, CodingKey { - case processName, argvContains + case processName, processNames, argvContains } - init(processName: String? = nil, argvContains: [String] = []) { - self.processName = processName?.trimmingCharacters(in: .whitespacesAndNewlines) + init(processName: String? = nil, processNames: [String] = [], argvContains: [String] = []) { + let name = processName?.trimmingCharacters(in: .whitespacesAndNewlines) + self.processName = name?.isEmpty == true ? nil : name + self.processNames = processNames + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } self.argvContains = argvContains .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } @@ -143,6 +164,7 @@ struct CmuxVaultAgentDetectRule: Codable, Hashable, Sendable { let name = try container.decodeIfPresent(String.self, forKey: .processName)? .trimmingCharacters(in: .whitespacesAndNewlines) processName = name?.isEmpty == true ? nil : name + processNames = try Self.decodeOneOrManyStrings(forKey: .processNames, in: container) argvContains = try Self.decodeOneOrManyStrings(forKey: .argvContains, in: container) } @@ -165,6 +187,7 @@ struct CmuxVaultAgentDetectRule: Codable, Hashable, Sendable { enum CmuxVaultAgentSessionIDSource: Codable, Hashable, Sendable { case argvOption(String) case piSessionFile + case grokSessionDirectory private enum CodingKeys: String, CodingKey { case type, argvOption @@ -177,6 +200,8 @@ enum CmuxVaultAgentSessionIDSource: Codable, Hashable, Sendable { switch trimmed { case "piSessionFile", "pi-session-file": self = .piSessionFile + case "grokSessionDirectory", "grok-session-directory": + self = .grokSessionDirectory default: guard !trimmed.isEmpty else { throw DecodingError.dataCorrupted( @@ -202,6 +227,17 @@ enum CmuxVaultAgentSessionIDSource: Codable, Hashable, Sendable { ) } self = .piSessionFile + case "grokSessionDirectory", "grok-session-directory": + if let option = try container.decodeIfPresent(String.self, forKey: .argvOption)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !option.isEmpty { + throw DecodingError.dataCorruptedError( + forKey: .argvOption, + in: container, + debugDescription: "grokSessionDirectory must not include argvOption" + ) + } + self = .grokSessionDirectory case "argvOption", "argv-option": let option = try container.decodeIfPresent(String.self, forKey: .argvOption)? .trimmingCharacters(in: .whitespacesAndNewlines) @@ -230,6 +266,8 @@ enum CmuxVaultAgentSessionIDSource: Codable, Hashable, Sendable { try container.encode(option, forKey: .argvOption) case .piSessionFile: try container.encode("piSessionFile", forKey: .type) + case .grokSessionDirectory: + try container.encode("grokSessionDirectory", forKey: .type) } } } @@ -301,7 +339,10 @@ struct CmuxVaultAgentRegistry: Sendable { environment: [String: String] = ProcessInfo.processInfo.environment, fileManager: FileManager = .default ) -> CmuxVaultAgentRegistry { - var registrations = [CmuxVaultAgentRegistration.builtInPi] + var registrations = [ + CmuxVaultAgentRegistration.builtInPi, + CmuxVaultAgentRegistration.builtInGrok, + ] for path in configPaths(homeDirectory: homeDirectory, workingDirectory: workingDirectory, environment: environment, fileManager: fileManager) { guard let config = decodeConfig(at: path, fileManager: fileManager) else { continue } registrations.append(contentsOf: config.vault?.agents ?? []) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 4c6c9e734fa5..aeb83bb4f7fd 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -411,6 +411,7 @@ D3622000A1B2C3D4E5F60718 /* BrowserArrowKeyForwardingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3622001A1B2C3D4E5F60718 /* BrowserArrowKeyForwardingTests.swift */; }; E30750000000000000000006 /* CmuxConfigNamedColorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E30750000000000000000005 /* CmuxConfigNamedColorTests.swift */; }; C1ADE00002A1B2C3D4E5F719 /* claude in Copy CLI */ = {isa = PBXBuildFile; fileRef = C1ADE00001A1B2C3D4E5F719 /* claude */; }; + C1ADE10002A1B2C3D4E5F719 /* grok in Copy CLI */ = {isa = PBXBuildFile; fileRef = C1ADE10001A1B2C3D4E5F719 /* grok */; }; C2577000A1B2C3D4E5F60718 /* TerminalCmdClickUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2577001A1B2C3D4E5F60718 /* TerminalCmdClickUITests.swift */; }; CA39C0304FE351A21C372429 /* SidebarWidthPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE0171AF1F49F7547191CEE5 /* SidebarWidthPolicyTests.swift */; }; CB23911D7E131E8FBC9B82B6 /* SidebarOrderingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC39DE4B96D1931C52AF7D68 /* SidebarOrderingTests.swift */; }; @@ -541,6 +542,7 @@ files = ( B900000BA1B2C3D4E5F60719 /* cmux in Copy CLI */, C1ADE00002A1B2C3D4E5F719 /* claude in Copy CLI */, + C1ADE10002A1B2C3D4E5F719 /* grok in Copy CLI */, D1BEF00002A1B2C3D4E5F719 /* open in Copy CLI */, ); name = "Copy CLI"; @@ -959,6 +961,7 @@ C1A2B3C4D5E6F70800000002 /* CmuxConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxConfigTests.swift; sourceTree = ""; }; E30750000000000000000005 /* CmuxConfigNamedColorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxConfigNamedColorTests.swift; sourceTree = ""; }; C1ADE00001A1B2C3D4E5F719 /* claude */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = Resources/bin/claude; sourceTree = SOURCE_ROOT; }; + C1ADE10001A1B2C3D4E5F719 /* grok */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = Resources/bin/grok; sourceTree = SOURCE_ROOT; }; C2577001A1B2C3D4E5F60718 /* TerminalCmdClickUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalCmdClickUITests.swift; sourceTree = ""; }; D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPaneNavigationKeybindUITests.swift; sourceTree = ""; }; D0E0F0B5A1B2C3D4E5F60718 /* FindSelectionShortcutUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FindSelectionShortcutUITests.swift; sourceTree = ""; }; @@ -1092,6 +1095,7 @@ B2E7294509CC42FE9191870E /* xterm-ghostty */, A5002001 /* THIRD_PARTY_LICENSES.md */, C1ADE00001A1B2C3D4E5F719 /* claude */, + C1ADE10001A1B2C3D4E5F719 /* grok */, DA7A10CA710E000000000001 /* Localizable.xcstrings */, DA7A10CA710E000000000002 /* InfoPlist.xcstrings */, FEED0000000000000000F006 /* opencode-plugin.js */, diff --git a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift index 76396508bd9a..20cf720ca052 100644 --- a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift +++ b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift @@ -1521,6 +1521,98 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + func testGrokHookPublishesResumeBindingWithNativeResumeFlag() throws { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("grok-resume") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-grok-resume-\(UUID().uuidString)", isDirectory: true) + let workspaceId = "11111111-1111-1111-1111-111111111111" + let surfaceId = "22222222-2222-2222-2222-222222222222" + let sessionId = "grok-session-123" + let grokHome = root.appendingPathComponent("grok-home", isDirectory: true) + + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { + Darwin.close(listenerFD) + unlink(socketPath) + try? FileManager.default.removeItem(at: root) + } + + let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in + guard let payload = self.jsonObject(line) else { + return "OK" + } + guard let id = payload["id"] as? String, let method = payload["method"] as? String else { + return self.malformedRequestResponse(id: payload["id"] as? String, raw: line) + } + switch method { + case "surface.list": + return self.surfaceListResponse(id: id, surfaceId: surfaceId) + case "feed.push": + return self.v2Response(id: id, ok: true, result: [:]) + case "surface.resume.set": + return self.v2Response(id: id, ok: true, result: ["ok": true]) + default: + return self.v2Response(id: id, ok: false, error: ["code": "unrecognized_method", "message": "unexpected method: \(method)"]) + } + } + + var environment = ProcessInfo.processInfo.environment + environment["CMUX_SOCKET_PATH"] = socketPath + environment["CMUX_WORKSPACE_ID"] = workspaceId + environment["CMUX_SURFACE_ID"] = surfaceId + environment["CMUX_AGENT_HOOK_STATE_DIR"] = root.path + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + environment["CMUX_AGENT_LAUNCH_KIND"] = "grok" + environment["CMUX_AGENT_LAUNCH_EXECUTABLE"] = "/Users/example/.grok/bin/grok" + environment["CMUX_AGENT_LAUNCH_CWD"] = root.path + environment["CMUX_AGENT_LAUNCH_ARGV_B64"] = base64NULSeparated([ + "/Users/example/.grok/bin/grok", + "--model", + "grok-4", + "--resume", + "old-session", + "--permission-mode", + "auto", + "--cwd", + root.path, + "initial prompt should not replay" + ]) + environment["GROK_HOME"] = grokHome.path + + let result = runProcess( + executablePath: cliPath, + arguments: ["hooks", "grok", "prompt-submit"], + environment: environment, + standardInput: #"{"sessionId":"\#(sessionId)","cwd":"\#(root.path)","hookEventName":"UserPromptSubmit","prompt":"continue"}"#, + timeout: 5 + ) + + wait(for: [serverHandled], timeout: 5) + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + + let resumeBindingRequests = state.commands.compactMap { command -> [String: Any]? in + guard let payload = jsonObject(command), + payload["method"] as? String == "surface.resume.set" else { + return nil + } + return payload["params"] as? [String: Any] + } + XCTAssertEqual(resumeBindingRequests.count, 1, state.commands.joined(separator: "\n")) + let request = try XCTUnwrap(resumeBindingRequests.first) + XCTAssertEqual(request["checkpoint_id"] as? String, sessionId) + XCTAssertEqual(request["auto_resume"] as? Bool, true) + XCTAssertEqual(request["kind"] as? String, "grok") + XCTAssertEqual(request["environment"] as? [String: String], ["GROK_HOME": grokHome.path]) + XCTAssertEqual( + request["command"] as? String, + "cd '\(root.path)' && '/Users/example/.grok/bin/grok' '-r' '\(sessionId)' '--model' 'grok-4' '--permission-mode' 'auto' '--cwd' '\(root.path)'" + ) + } + func testAgentPromptClearsSurfaceResumeBindingWhenResumeCommandUnavailable() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("agent-resume-unavailable") diff --git a/cmuxTests/PiVaultAgentPersistenceTests.swift b/cmuxTests/PiVaultAgentPersistenceTests.swift index d0468f525a5f..bc491727a003 100644 --- a/cmuxTests/PiVaultAgentPersistenceTests.swift +++ b/cmuxTests/PiVaultAgentPersistenceTests.swift @@ -68,6 +68,17 @@ final class PiVaultAgentPersistenceTests: XCTestCase { XCTAssertEqual(SessionAgent.registered(agent).assetName, "AgentIcons/Pi") } + func testBuiltInGrokRegistrationUsesNativeSessionDirectory() { + let registration = CmuxVaultAgentRegistration.builtInGrok + + XCTAssertEqual(registration.id, "grok") + XCTAssertEqual(registration.sessionIdSource, .grokSessionDirectory) + XCTAssertEqual(registration.sessionDirectory, "~/.grok/sessions") + XCTAssertEqual(registration.detect.processNames, ["grok", "grok-macos-aarch64", "grok-macos-aarch"]) + XCTAssertTrue(registration.detect.argvContains.isEmpty) + XCTAssertEqual(SessionAgent.grok.assetName, "AgentIcons/Grok") + } + func testRegisteredAgentTemplateFailsClosedWhenPlaceholderIsUnavailable() { let registration = CmuxVaultAgentRegistration( id: "acme-agent", @@ -406,6 +417,403 @@ final class PiVaultAgentPersistenceTests: XCTestCase { XCTAssertEqual(entry.cwd, cwd) } + func testGrokVaultLoadsNativeChatHistoryFromEncodedDirectory() async throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-grok-vault-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let cwd = "/tmp/grok repo" + let sessionId = "grok-session-123" + let grokHome = tempDir.appendingPathComponent("grok-home", isDirectory: true) + let sessionsRoot = grokHome.appendingPathComponent("sessions", isDirectory: true) + let historyURL = sessionsRoot + .appendingPathComponent(GrokSessionLocator.encodedSessionCWD(cwd), isDirectory: true) + .appendingPathComponent(sessionId, isDirectory: true) + .appendingPathComponent("chat_history.jsonl", isDirectory: false) + try FileManager.default.createDirectory( + at: historyURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try """ + {"type":"assistant","content":"assistant preface"} + {"type":"user","content":"Implement Grok Vault","model":"grok-4","permissionMode":"auto","sandboxMode":"danger-full-access","git":{"branch":"issue-4394-grok-vault-resume"}} + {"type":"assistant","content":"done"} + """.write(to: historyURL, atomically: true, encoding: .utf8) + + var registration = CmuxVaultAgentRegistration.builtInGrok + registration.sessionDirectory = sessionsRoot.path + let entries = await SessionIndexStore.loadGrokEntries( + registration: registration, + needle: "", + cwdFilter: nil, + offset: 0, + limit: 10 + ) + + let entry = try XCTUnwrap(entries.first) + XCTAssertEqual(entry.agent, .grok) + XCTAssertEqual(entry.sessionId, sessionId) + XCTAssertEqual(entry.title, "Implement Grok Vault") + XCTAssertEqual(entry.cwd, cwd) + XCTAssertEqual(entry.gitBranch, "issue-4394-grok-vault-resume") + XCTAssertEqual(entry.fileURL, historyURL) + XCTAssertEqual( + entry.resumeCommand, + "cd '/tmp/grok repo' && 'env' 'GROK_HOME=\(grokHome.path)' 'grok' '-r' 'grok-session-123' '-m' 'grok-4' '--permission-mode' 'auto' '--sandbox' 'danger-full-access'" + ) + } + + func testGrokVaultTitlePrefersUserQueryOverInjectedMetadata() async throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-grok-vault-metadata-title-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let cwd = "/tmp/grok metadata repo" + let sessionId = "grok-metadata-session" + let sessionsRoot = tempDir.appendingPathComponent("sessions", isDirectory: true) + let historyURL = sessionsRoot + .appendingPathComponent(GrokSessionLocator.encodedSessionCWD(cwd), isDirectory: true) + .appendingPathComponent(sessionId, isDirectory: true) + .appendingPathComponent("chat_history.jsonl", isDirectory: false) + try FileManager.default.createDirectory( + at: historyURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + + let userContent = """ + + OS Version: macos 26.4 + + + Current branch: issue-4394-grok-vault-resume + + + Implement native Vault metadata + + """ + let records: [[String: Any]] = [ + ["type": "system", "content": "You are Grok"], + ["type": "user", "content": userContent, "model": "grok-4"], + ] + let jsonLines = try records.map { record in + let data = try JSONSerialization.data(withJSONObject: record, options: [.sortedKeys]) + return String(decoding: data, as: UTF8.self) + }.joined(separator: "\n") + try (jsonLines + "\n").write(to: historyURL, atomically: true, encoding: .utf8) + + var registration = CmuxVaultAgentRegistration.builtInGrok + registration.sessionDirectory = sessionsRoot.path + let entries = await SessionIndexStore.loadGrokEntries( + registration: registration, + needle: "", + cwdFilter: nil, + offset: 0, + limit: 10 + ) + + let entry = try XCTUnwrap(entries.first) + XCTAssertEqual(entry.title, "Implement native Vault metadata") + } + + func testGrokVaultFindsBranchAfterStableMetadata() async throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-grok-vault-late-branch-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let cwd = "/tmp/grok late branch" + let sessionId = "grok-late-branch-session" + let sessionsRoot = tempDir.appendingPathComponent("sessions", isDirectory: true) + let historyURL = sessionsRoot + .appendingPathComponent(GrokSessionLocator.encodedSessionCWD(cwd), isDirectory: true) + .appendingPathComponent(sessionId, isDirectory: true) + .appendingPathComponent("chat_history.jsonl", isDirectory: false) + try FileManager.default.createDirectory( + at: historyURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try """ + {"type":"user","content":"Find late branch","model":"grok-4","permissionMode":"auto","sandboxMode":"danger-full-access"} + {"type":"assistant","content":"Working","git":{"branch":"late-branch"}} + """.write(to: historyURL, atomically: true, encoding: .utf8) + + var registration = CmuxVaultAgentRegistration.builtInGrok + registration.sessionDirectory = sessionsRoot.path + let entries = await SessionIndexStore.loadGrokEntries( + registration: registration, + needle: "", + cwdFilter: nil, + offset: 0, + limit: 10 + ) + + let entry = try XCTUnwrap(entries.first) + XCTAssertEqual(entry.gitBranch, "late-branch") + } + + func testGrokVaultLoadsHookObservedShellGrokHome() async throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-grok-vault-observed-home-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let homeDirectory = tempDir.appendingPathComponent("home", isDirectory: true) + let hookStore = homeDirectory + .appendingPathComponent(".cmuxterm", isDirectory: true) + .appendingPathComponent("grok-hook-sessions.json", isDirectory: false) + try FileManager.default.createDirectory(at: hookStore.deletingLastPathComponent(), withIntermediateDirectories: true) + + let cwd = "/tmp/grok observed home" + let sessionId = "grok-observed-home-session" + let grokHome = tempDir.appendingPathComponent("shell-grok-home", isDirectory: true) + let historyURL = grokHome + .appendingPathComponent("sessions", isDirectory: true) + .appendingPathComponent(GrokSessionLocator.encodedSessionCWD(cwd), isDirectory: true) + .appendingPathComponent(sessionId, isDirectory: true) + .appendingPathComponent("chat_history.jsonl", isDirectory: false) + try FileManager.default.createDirectory( + at: historyURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try """ + {"type":"user","content":"Find sessions under shell GROK_HOME","model":"grok-4","permissionMode":"auto","sandboxMode":"danger-full-access"} + """.write(to: historyURL, atomically: true, encoding: .utf8) + + try """ + { + "version": 1, + "sessions": { + "\(sessionId)": { + "launchCommand": { + "environment": { + "GROK_HOME": "\(grokHome.path)" + } + } + } + } + } + """.write(to: hookStore, atomically: true, encoding: .utf8) + + let entries = await SessionIndexStore.loadGrokEntries( + registration: .builtInGrok, + needle: "", + cwdFilter: nil, + offset: 0, + limit: 10, + environment: [:], + homeDirectory: homeDirectory.path + ) + + let entry = try XCTUnwrap(entries.first) + XCTAssertEqual(entry.sessionId, sessionId) + XCTAssertEqual(entry.title, "Find sessions under shell GROK_HOME") + XCTAssertEqual(entry.cwd, cwd) + XCTAssertEqual( + entry.resumeCommand, + "cd '/tmp/grok observed home' && 'env' 'GROK_HOME=\(grokHome.path)' 'grok' '-r' '\(sessionId)' '-m' 'grok-4' '--permission-mode' 'auto' '--sandbox' 'danger-full-access'" + ) + } + + func testGrokVaultLoadsHookObservedShellGrokHomeFromCustomStateDir() async throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-grok-vault-custom-state-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let homeDirectory = tempDir.appendingPathComponent("home", isDirectory: true) + let hookStateDir = tempDir.appendingPathComponent("hook-state", isDirectory: true) + let hookStore = hookStateDir.appendingPathComponent("grok-hook-sessions.json", isDirectory: false) + try FileManager.default.createDirectory(at: hookStateDir, withIntermediateDirectories: true) + + let cwd = "/tmp/grok custom state" + let sessionId = "grok-custom-state-session" + let grokHome = tempDir.appendingPathComponent("custom-state-grok-home", isDirectory: true) + let historyURL = grokHome + .appendingPathComponent("sessions", isDirectory: true) + .appendingPathComponent(GrokSessionLocator.encodedSessionCWD(cwd), isDirectory: true) + .appendingPathComponent(sessionId, isDirectory: true) + .appendingPathComponent("chat_history.jsonl", isDirectory: false) + try FileManager.default.createDirectory( + at: historyURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try """ + {"type":"user","content":"Find sessions under custom hook state","model":"grok-4"} + """.write(to: historyURL, atomically: true, encoding: .utf8) + + try """ + { + "version": 1, + "sessions": { + "\(sessionId)": { + "launchCommand": { + "environment": { + "GROK_HOME": "\(grokHome.path)" + } + } + } + } + } + """.write(to: hookStore, atomically: true, encoding: .utf8) + + let entries = await SessionIndexStore.loadGrokEntries( + registration: .builtInGrok, + needle: "", + cwdFilter: nil, + offset: 0, + limit: 10, + environment: ["CMUX_AGENT_HOOK_STATE_DIR": hookStateDir.path], + homeDirectory: homeDirectory.path + ) + + let entry = try XCTUnwrap(entries.first) + XCTAssertEqual(entry.sessionId, sessionId) + XCTAssertEqual(entry.title, "Find sessions under custom hook state") + XCTAssertEqual( + entry.resumeCommand, + "cd '/tmp/grok custom state' && 'env' 'GROK_HOME=\(grokHome.path)' 'grok' '-r' '\(sessionId)' '-m' 'grok-4'" + ) + } + + func testRegisteredGrokSessionDirectoryUsesNativeDirectoryLayout() async throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-registered-grok-vault-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let cwd = "/tmp/custom grok repo" + let sessionId = "custom-grok-session-123" + let sessionsRoot = tempDir.appendingPathComponent("sessions", isDirectory: true) + let historyURL = sessionsRoot + .appendingPathComponent(GrokSessionLocator.encodedSessionCWD(cwd), isDirectory: true) + .appendingPathComponent(sessionId, isDirectory: true) + .appendingPathComponent("chat_history.jsonl", isDirectory: false) + try FileManager.default.createDirectory( + at: historyURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try """ + {"type":"user","content":"Resume a custom Grok-compatible agent","git":{"branch":"issue-4394-grok-vault-resume"}} + """.write(to: historyURL, atomically: true, encoding: .utf8) + + let registration = CmuxVaultAgentRegistration( + id: "custom-grok", + name: "Custom Grok", + detect: CmuxVaultAgentDetectRule(processName: "custom-grok"), + sessionIdSource: .grokSessionDirectory, + resumeCommand: "custom-grok -r {{sessionId}}", + cwd: .preserve, + sessionDirectory: sessionsRoot.path + ) + let entries = await SessionIndexStore.loadRegisteredAgentEntries( + registration: registration, + needle: "", + cwdFilter: nil, + offset: 0, + limit: 10 + ) + + let entry = try XCTUnwrap(entries.first) + XCTAssertEqual(entry.id, "custom-grok:\(sessionId)") + XCTAssertEqual(entry.agent, .registered(RegisteredSessionAgent(registration: registration))) + XCTAssertEqual(entry.sessionId, sessionId) + XCTAssertEqual(entry.title, "Resume a custom Grok-compatible agent") + XCTAssertEqual(entry.cwd, cwd) + XCTAssertEqual(entry.gitBranch, "issue-4394-grok-vault-resume") + XCTAssertEqual( + entry.resumeCommand, + "cd '/tmp/custom grok repo' && 'env' 'GROK_HOME=\(tempDir.path)' 'custom-grok' '-r' '\(sessionId)'" + ) + } + + func testGrokVaultCWDFilterUsesEncodedProjectDirectory() async throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-grok-vault-filter-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let sessionsRoot = tempDir.appendingPathComponent("sessions", isDirectory: true) + func writeHistory(cwd: String, sessionId: String, prompt: String) throws { + let historyURL = sessionsRoot + .appendingPathComponent(GrokSessionLocator.encodedSessionCWD(cwd), isDirectory: true) + .appendingPathComponent(sessionId, isDirectory: true) + .appendingPathComponent("chat_history.jsonl", isDirectory: false) + try FileManager.default.createDirectory( + at: historyURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try #"{"type":"user","content":"\#(prompt)"}"# + .write(to: historyURL, atomically: true, encoding: .utf8) + } + + try writeHistory(cwd: "/tmp/current grok repo", sessionId: "current-session", prompt: "current") + try writeHistory(cwd: "/tmp/current grok repo/../current grok repo", sessionId: "current-session", prompt: "duplicate") + try writeHistory(cwd: "/tmp/other grok repo", sessionId: "other-session", prompt: "other") + + var registration = CmuxVaultAgentRegistration.builtInGrok + registration.sessionDirectory = sessionsRoot.path + let entries = await SessionIndexStore.loadGrokEntries( + registration: registration, + needle: "", + cwdFilter: "/tmp/current grok repo/../current grok repo", + offset: 0, + limit: 10 + ) + + XCTAssertEqual(entries.map(\.sessionId), ["current-session"]) + XCTAssertEqual(entries.first?.cwd, "/tmp/current grok repo") + } + + @MainActor + func testGrokAgentSearchScopeUsesCurrentDirectoryCWDFilter() async throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-grok-agent-scope-filter-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let previousGrokHome = getenv("GROK_HOME").map { String(cString: $0) } + let grokHome = tempDir.appendingPathComponent("grok-home", isDirectory: true) + setenv("GROK_HOME", grokHome.path, 1) + defer { + if let previousGrokHome { + setenv("GROK_HOME", previousGrokHome, 1) + } else { + unsetenv("GROK_HOME") + } + } + + let sessionsRoot = grokHome.appendingPathComponent("sessions", isDirectory: true) + func writeHistory(cwd: String, sessionId: String, prompt: String) throws { + let historyURL = sessionsRoot + .appendingPathComponent(GrokSessionLocator.encodedSessionCWD(cwd), isDirectory: true) + .appendingPathComponent(sessionId, isDirectory: true) + .appendingPathComponent("chat_history.jsonl", isDirectory: false) + try FileManager.default.createDirectory( + at: historyURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try #"{"type":"user","content":"\#(prompt)","model":"grok-4"}"# + .write(to: historyURL, atomically: true, encoding: .utf8) + } + + try writeHistory(cwd: "/tmp/current grok search", sessionId: "current-session", prompt: "current") + try writeHistory(cwd: "/tmp/other grok search", sessionId: "other-session", prompt: "other") + + let store = SessionIndexStore() + store.setCurrentDirectoryIfChanged("/tmp/current grok search") + let outcome = await store.searchSessions( + query: "", + scope: .agent(.grok), + offset: 0, + limit: 10 + ) + + XCTAssertEqual(outcome.entries.map(\.sessionId), ["current-session"]) + XCTAssertEqual(outcome.entries.first?.cwd, "/tmp/current grok search") + } + func testPiVaultAgentSnapshotRoundTripBuildsTargetedSessionCommand() throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-pi-vault-agent-\(UUID().uuidString)", isDirectory: true) diff --git a/cmuxTests/SessionIndexViewTests.swift b/cmuxTests/SessionIndexViewTests.swift index c1eae7c7a87f..91b15e273ff3 100644 --- a/cmuxTests/SessionIndexViewTests.swift +++ b/cmuxTests/SessionIndexViewTests.swift @@ -133,7 +133,7 @@ final class SessionIndexViewTests: XCTestCase { XCTAssertEqual( entry.resumeCommand, - "env GROK_HOME='/tmp/grok home' grok -r grok-session-123 -m grok-4 --permission-mode auto --sandbox danger-full-access" + "'env' 'GROK_HOME=/tmp/grok home' 'grok' '-r' 'grok-session-123' '-m' 'grok-4' '--permission-mode' 'auto' '--sandbox' 'danger-full-access'" ) }