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 = "\(tag)>"
+ 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 = "\(tag)>"
+ 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'"
)
}