diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 2544b3d8d1a5..a140c4fee83f 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1919,14 +1919,14 @@ struct CMUXCLI { "--attr", "--before-workspace", "--body", "--color", "--command", "--config", "--cwd", "--description", "--direction", "--domain", "--dx", "--dy", "--email", "--event", "--expires", "--focus", - "--function", "--id", "--image", "--index", "--key", "--layout", - "--lines", "--load-state", "--max-depth", "--name", "--os", + "--function", "--id", "--image", "--index", "--key", "--kind", + "--layout", "--lines", "--load-state", "--max-depth", "--name", "--os", "--out", "--pane", "--panel", "--path", "--profile", "--property", "--provider", "--relay-port", "--script", "--selector", "--session", - "--source", "--subtitle", "--surface", "--tab", "--target-pane", + "--shell", "--source", "--subtitle", "--surface", "--tab", "--target-pane", "--text", "--timeout", "--timeout-ms", "--title", "--transcript", "--turn", "--type", "--url", "--url-contains", "--value", "--window", - "--workspace", + "--workspace", "--checkpoint", "--checkpoint-id", ] private func parsePresentationOptions( @@ -2036,6 +2036,17 @@ struct CMUXCLI { } } + private static func shouldFocusWindowBeforeDispatch(command: String, commandArgs: [String]) -> Bool { + let normalizedCommand = command.lowercased() + if normalizedCommand == "surface-resume" { + return false + } + if normalizedCommand == "surface", commandArgs.first?.lowercased() == "resume" { + return false + } + return true + } + func run() throws { let processEnv = ProcessInfo.processInfo.environment let cliBundleIdentifier = CLISocketPathResolver.currentAppBundleIdentifier() @@ -2379,6 +2390,11 @@ struct CMUXCLI { return } + try validateSurfaceResumeCommandValueOptionsBeforeSocket( + command: command, + commandArgs: commandArgs + ) + let client = SocketClient(path: resolvedSocketPath) if resolvedSocketPath != socketPath { cliTelemetry.breadcrumb( @@ -2413,9 +2429,9 @@ struct CMUXCLI { ) let idFormat = try resolvedIDFormat(jsonOutput: jsonOutput, raw: idFormatArg) - // Existing CLI --window routing focuses first so commands without an + // Most CLI --window routing focuses first so commands without an // explicit window_id still target the selected window. - if let windowId { + if let windowId, Self.shouldFocusWindowBeforeDispatch(command: command, commandArgs: commandArgs) { do { let normalizedWindow = try normalizeWindowHandle(windowId, client: client) ?? windowId _ = try client.sendV2(method: "window.focus", params: ["window_id": normalizedWindow]) @@ -3066,6 +3082,24 @@ struct CMUXCLI { let payload = try client.sendV2(method: "surface.create", params: params) printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["surface", "pane", "workspace"])) + case "surface": + try runSurfaceCommand( + commandArgs: commandArgs, + client: client, + jsonOutput: jsonOutput, + idFormat: idFormat, + windowOverride: windowId + ) + + case "surface-resume": + try runSurfaceResumeCommand( + commandArgs: commandArgs, + client: client, + jsonOutput: jsonOutput, + idFormat: idFormat, + windowOverride: windowId + ) + case "close-surface": let csWsFlag = optionValue(commandArgs, name: "--workspace") let workspaceArg = csWsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) @@ -4273,6 +4307,7 @@ struct CMUXCLI { _ raw: String?, client: SocketClient, workspaceHandle: String? = nil, + windowHandle: String? = nil, allowFocused: Bool = false ) throws -> String? { guard let raw else { @@ -4292,6 +4327,9 @@ struct CMUXCLI { } var params: [String: Any] = [:] + if let windowHandle { + params["window_id"] = windowHandle + } if let workspaceHandle { params["workspace_id"] = workspaceHandle } @@ -4413,6 +4451,276 @@ struct CMUXCLI { } } + private func runSurfaceCommand( + commandArgs: [String], + client: SocketClient, + jsonOutput: Bool, + idFormat: CLIIDFormat, + windowOverride: String? + ) throws { + guard let subcommand = commandArgs.first?.lowercased() else { + throw CLIError(message: "surface requires a subcommand. Try: cmux surface resume show --json") + } + switch subcommand { + case "resume": + try runSurfaceResumeCommand( + commandArgs: Array(commandArgs.dropFirst()), + client: client, + jsonOutput: jsonOutput, + idFormat: idFormat, + windowOverride: windowOverride + ) + default: + throw CLIError(message: "Unsupported surface subcommand: \(subcommand)") + } + } + + private func runSurfaceResumeCommand( + commandArgs: [String], + client: SocketClient, + jsonOutput: Bool, + idFormat: CLIIDFormat, + windowOverride: String? + ) throws { + let subcommand = commandArgs.first?.lowercased() ?? "show" + let rest = commandArgs.first == nil ? [] : Array(commandArgs.dropFirst()) + switch subcommand { + case "set": + try validateSurfaceResumeValueOptions( + rest, + optionNames: Self.surfaceResumeSetValueOptions, + context: "surface resume set" + ) + let target = try surfaceResumeTarget(rest, client: client, windowOverride: windowOverride) + var params = target.params + let splitRemaining = splitAtArgumentTerminator(target.remaining) + let (name, rem1) = parseOption(splitRemaining.options, name: "--name") + let (kind, rem2) = parseOption(rem1, name: "--kind") + let (checkpoint, rem3) = parseOption(rem2, name: "--checkpoint") + let (checkpointID, rem4) = parseOption(rem3, name: "--checkpoint-id") + let (source, rem5) = parseOption(rem4, name: "--source") + let (cwd, rem6) = parseOption(rem5, name: "--cwd") + let (shellCommand, rem7) = parseOption(rem6, name: "--shell") + + if let name { params["name"] = name } + if let kind { params["kind"] = kind } + if let checkpoint = checkpointID ?? checkpoint { params["checkpoint_id"] = checkpoint } + params["source"] = source ?? "cli" + params["cwd"] = cwd ?? ProcessInfo.processInfo.environment["PWD"] ?? FileManager.default.currentDirectoryPath + + let commandText: String + if let shellCommand { + if let unexpected = (rem7 + (splitRemaining.argv ?? [])).first { + throw CLIError(message: "surface resume set: unexpected argument '\(unexpected)' after --shell. Quote the full shell command or use -- ") + } + commandText = shellCommand.trimmingCharacters(in: .whitespacesAndNewlines) + } else { + if splitRemaining.argv != nil, let unexpected = rem7.first { + throw CLIError(message: "surface resume set: unexpected argument '\(unexpected)' before --") + } + let argv = splitRemaining.argv ?? rem7 + guard !argv.isEmpty else { + throw CLIError(message: "surface resume set requires --shell or -- ") + } + commandText = argv.map(cliShellQuote).joined(separator: " ") + } + guard !commandText.isEmpty else { + throw CLIError(message: "surface resume set requires a non-empty command") + } + params["command"] = commandText + + let payload = try client.sendV2(method: "surface.resume.set", params: params) + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: "OK") + + case "show", "get": + try validateSurfaceResumeValueOptions( + rest, + optionNames: Self.surfaceResumeTargetValueOptions, + context: "surface resume \(subcommand)" + ) + let params = try surfaceResumeTarget(rest, client: client, windowOverride: windowOverride).params + let payload = try client.sendV2(method: "surface.resume.get", params: params) + if jsonOutput { + print(jsonString(formatIDs(payload, mode: idFormat))) + } else if let binding = payload["resume_binding"] as? [String: Any], + let command = binding["command"] as? String, + !command.isEmpty { + print(command) + } else { + print("No resume binding") + } + + case "clear": + try validateSurfaceResumeValueOptions( + rest, + optionNames: Self.surfaceResumeClearValueOptions, + context: "surface resume clear" + ) + let target = try surfaceResumeTarget(rest, client: client, windowOverride: windowOverride) + var params = target.params + let (checkpoint, rem1) = parseOption(target.remaining, name: "--checkpoint") + let (checkpointID, rem2) = parseOption(rem1, name: "--checkpoint-id") + let (source, remaining) = parseOption(rem2, name: "--source") + if let unexpected = remaining.first { + throw CLIError(message: "surface resume clear: unexpected argument '\(unexpected)'") + } + if let checkpoint = checkpointID ?? checkpoint { params["checkpoint_id"] = checkpoint } + if let source { params["source"] = source } + let payload = try client.sendV2(method: "surface.resume.clear", params: params) + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: "OK") + + default: + throw CLIError(message: "Unsupported surface resume subcommand: \(subcommand)") + } + } + + private func validateSurfaceResumeCommandValueOptionsBeforeSocket( + command: String, + commandArgs: [String] + ) throws { + if command == "surface" { + guard commandArgs.first?.lowercased() == "resume" else { return } + try validateSurfaceResumeCommandValueOptions(Array(commandArgs.dropFirst())) + return + } + if command == "surface-resume" { + try validateSurfaceResumeCommandValueOptions(commandArgs) + } + } + + private func validateSurfaceResumeCommandValueOptions(_ commandArgs: [String]) throws { + let subcommand = commandArgs.first?.lowercased() ?? "show" + let rest = commandArgs.first == nil ? [] : Array(commandArgs.dropFirst()) + switch subcommand { + case "set": + try validateSurfaceResumeValueOptions( + rest, + optionNames: Self.surfaceResumeSetValueOptions, + context: "surface resume set" + ) + try validateSurfaceResumeSetCommandTokensBeforeSocket(rest) + case "show", "get": + try validateSurfaceResumeValueOptions( + rest, + optionNames: Self.surfaceResumeTargetValueOptions, + context: "surface resume \(subcommand)" + ) + case "clear": + try validateSurfaceResumeValueOptions( + rest, + optionNames: Self.surfaceResumeClearValueOptions, + context: "surface resume clear" + ) + default: + return + } + } + + private func validateSurfaceResumeSetCommandTokensBeforeSocket(_ args: [String]) throws { + let splitArgs = splitAtArgumentTerminator(args) + let (_, rem1) = parseOption(splitArgs.options, name: "--workspace") + let (_, rem2) = parseOption(rem1, name: "--surface") + let (_, rem3) = parseOption(rem2, name: "--name") + let (_, rem4) = parseOption(rem3, name: "--kind") + let (_, rem5) = parseOption(rem4, name: "--checkpoint") + let (_, rem6) = parseOption(rem5, name: "--checkpoint-id") + let (_, rem7) = parseOption(rem6, name: "--source") + let (_, rem8) = parseOption(rem7, name: "--cwd") + let (shellCommand, remaining) = parseOption(rem8, name: "--shell") + + if shellCommand != nil, let unexpected = (remaining + (splitArgs.argv ?? [])).first { + throw CLIError(message: "surface resume set: unexpected argument '\(unexpected)' after --shell. Quote the full shell command or use -- ") + } + if splitArgs.argv != nil, let unexpected = remaining.first { + throw CLIError(message: "surface resume set: unexpected argument '\(unexpected)' before --") + } + } + + private static let surfaceResumeTargetValueOptions: Set = ["--workspace", "--surface"] + private static let surfaceResumeSetValueOptions: Set = surfaceResumeTargetValueOptions.union([ + "--name", "--kind", "--checkpoint", "--checkpoint-id", "--source", "--cwd", "--shell", + ]) + private static let surfaceResumeClearValueOptions: Set = surfaceResumeTargetValueOptions.union([ + "--checkpoint", "--checkpoint-id", "--source", + ]) + + private func validateSurfaceResumeValueOptions( + _ args: [String], + optionNames: Set, + context: String + ) throws { + var pastTerminator = false + for (index, arg) in args.enumerated() { + if pastTerminator { + continue + } + if arg == "--" { + pastTerminator = true + continue + } + guard optionNames.contains(arg) else { continue } + guard index + 1 < args.count else { + throw CLIError(message: "\(context): \(arg) requires a value") + } + let value = args[index + 1] + guard value != "--", + !optionNames.contains(value), + !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw CLIError(message: "\(context): \(arg) requires a value") + } + } + } + + private struct SurfaceResumeTarget { + var params: [String: Any] + var remaining: [String] + } + + private func splitAtArgumentTerminator(_ args: [String]) -> (options: [String], argv: [String]?) { + guard let delimiterIndex = args.firstIndex(of: "--") else { + return (args, nil) + } + let argvStart = args.index(after: delimiterIndex) + return (Array(args[.. SurfaceResumeTarget { + let splitArgs = splitAtArgumentTerminator(args) + let (workspaceOpt, rem1) = parseOption(splitArgs.options, name: "--workspace") + let (surfaceOpt, remaining) = parseOption(rem1, name: "--surface") + let env = ProcessInfo.processInfo.environment + let usesImplicitSurface = surfaceOpt == nil + && windowOverride == nil + && env["CMUX_SURFACE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + let shouldUseEnvWorkspace = surfaceOpt == nil + && !usesImplicitSurface + && windowOverride == nil + let workspaceRaw = workspaceOpt ?? (shouldUseEnvWorkspace ? env["CMUX_WORKSPACE_ID"] : nil) + let surfaceRaw = surfaceOpt ?? (workspaceOpt == nil && windowOverride == nil ? env["CMUX_SURFACE_ID"] : nil) + var params: [String: Any] = [:] + let windowHandle = try normalizeWindowHandle(windowOverride, client: client) + if let windowHandle { params["window_id"] = windowHandle } + let workspaceId = try normalizeWorkspaceHandle(workspaceRaw, client: client, windowHandle: windowHandle) + if let workspaceId { params["workspace_id"] = workspaceId } + let surfaceId = try normalizeSurfaceHandle( + surfaceRaw, + client: client, + workspaceHandle: workspaceId, + windowHandle: windowHandle + ) + if let surfaceId { params["surface_id"] = surfaceId } + let remainingWithArgv = remaining + (splitArgs.argv.map { ["--"] + $0 } ?? []) + return SurfaceResumeTarget(params: params, remaining: remainingWithArgv) + } + + private func cliShellQuote(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + private func debugString(_ value: Any?) -> String? { guard let value, !(value is NSNull) else { return nil } if let string = value as? String { @@ -9875,6 +10183,32 @@ struct CMUXCLI { cmux surface-health cmux surface-health --workspace workspace:2 """ + case "surface", "surface-resume": + return """ + Usage: cmux surface resume set [flags] -- + cmux surface resume set [flags] --shell + cmux surface resume show [--json] [flags] + cmux surface resume get [--json] [flags] + cmux surface resume clear [flags] + + Attach restart command metadata to a terminal surface. + Public CLI bindings are stored for inspection and manual restore. + + Flags: + --workspace Workspace context (default: $CMUX_WORKSPACE_ID) + --surface Surface context (default: $CMUX_SURFACE_ID) + --cwd Working directory for restore (default: $PWD) + --name Display name for the binding + --kind Binding kind, for example agent or tmux + --checkpoint Provider checkpoint or session id + --checkpoint-id Same as --checkpoint and takes precedence + --source Binding source label + + Examples: + cmux surface resume set --kind tmux --shell "tmux attach -t work" + cmux surface resume set --kind opencode --checkpoint ses_123 -- opencode --session ses_123 + cmux surface resume show --json + """ case "debug-terminals": return """ Usage: cmux debug-terminals @@ -16529,6 +16863,18 @@ struct CMUXCLI { markActive: shouldPromoteActiveSession, turnId: parsedInput.turnId ) + if shouldPromoteActiveSession { + publishAgentSurfaceResumeBinding( + client: client, + workspaceId: workspaceId, + surfaceId: surfaceId, + kind: "claude", + displayName: String(localized: "cli.claude-hook.notification.title", defaultValue: "Claude Code"), + sessionId: sessionId, + cwd: parsedInput.cwd, + launchCommand: launchCommand + ) + } } // Register PID for stale-session detection and OSC suppression. // Startup/resume SessionStart remains non-visible; /clear is a @@ -16610,6 +16956,16 @@ struct CMUXCLI { markActive: true, allowsNewSessionReplacement: true ) + publishAgentSurfaceResumeBinding( + client: client, + workspaceId: workspaceId, + surfaceId: surfaceId, + kind: "claude", + displayName: String(localized: "cli.claude-hook.notification.title", defaultValue: "Claude Code"), + sessionId: sessionId, + cwd: parsedInput.cwd ?? mappedSession?.cwd, + launchCommand: mappedSession?.launchCommand + ) } try? setClaudeStatus( @@ -16682,6 +17038,16 @@ struct CMUXCLI { markActive: true, turnId: parsedInput.turnId ) + publishAgentSurfaceResumeBinding( + client: client, + workspaceId: workspaceId, + surfaceId: surfaceId, + kind: "claude", + displayName: String(localized: "cli.claude-hook.notification.title", defaultValue: "Claude Code"), + sessionId: sessionId, + cwd: parsedInput.cwd ?? mappedSession?.cwd, + launchCommand: mappedSession?.launchCommand + ) } _ = try sendV1Command("clear_notifications --tab=\(workspaceId)", client: client) try setClaudeStatus( @@ -16789,6 +17155,12 @@ struct CMUXCLI { // as current only when the consumed session was the active one. if let consumedSession { let workspaceId = consumedSession.workspaceId + clearAgentSurfaceResumeBinding( + client: client, + workspaceId: workspaceId, + surfaceId: consumedSession.surfaceId, + sessionId: consumedSession.sessionId + ) sendClaudeFeedTelemetry(workspaceId: workspaceId) let shouldClearVisibleState = shouldApplyClaudeHookVisibleMutation( sessionStore: sessionStore, @@ -18980,6 +19352,258 @@ struct CMUXCLI { ) } + private func publishAgentSurfaceResumeBinding( + client: SocketClient, + workspaceId: String, + surfaceId: String, + kind: String, + displayName: String, + sessionId: String, + cwd: String?, + launchCommand: AgentHookLaunchCommandRecord? + ) { + guard let command = agentSurfaceResumeCommand( + kind: kind, + sessionId: sessionId, + launchCommand: launchCommand, + workingDirectory: cwd + ) else { + clearAgentSurfaceResumeBinding( + client: client, + workspaceId: workspaceId, + surfaceId: surfaceId, + sessionId: sessionId + ) + return + } + var params: [String: Any] = [ + "surface_id": surfaceId, + "name": displayName, + "kind": kind, + "checkpoint_id": sessionId, + "source": "agent-hook", + "command": command, + "auto_resume": true + ] + if let cwd = normalizedHookValue(cwd) ?? normalizedHookValue(launchCommand?.workingDirectory) { + params["cwd"] = cwd + } + if let environment = agentSurfaceResumeEnvironment(kind: kind, environment: launchCommand?.environment), + !environment.isEmpty { + params["environment"] = environment + } + _ = try? client.sendV2(method: "surface.resume.set", params: params) + } + + private func clearAgentSurfaceResumeBinding( + client: SocketClient, + workspaceId: String, + surfaceId: String, + sessionId: String? + ) { + let normalizedSessionId = normalizedHookValue(sessionId) + var params: [String: Any] = [ + "surface_id": surfaceId, + "source": "agent-hook" + ] + if let normalizedSessionId { + params["checkpoint_id"] = normalizedSessionId + } + _ = try? client.sendV2(method: "surface.resume.clear", params: params) + } + + private func agentSurfaceResumeCommand( + kind: String, + sessionId: String, + launchCommand: AgentHookLaunchCommandRecord?, + workingDirectory: String? + ) -> String? { + let normalizedSessionId = normalizedHookValue(sessionId) + guard let normalizedSessionId else { return nil } + + let argv: [String]? + switch launchCommand?.launcher { + case "claudeTeams": + let original = agentSurfaceResumeCommandParts(launchCommand: launchCommand, fallbackExecutable: "cmux") + var tail = original.tail + let removedToken = tail.first == "claude-teams" + if removedToken { tail.removeFirst() } + argv = AgentLaunchSanitizer.preservedArguments(kind: "claude", args: tail).map { + agentSurfaceResumePrefixedArguments( + executable: original.executable, + token: "claude-teams", + option: "--resume", + sessionId: normalizedSessionId, + preserved: $0 + ) + } + case "omo": + let original = agentSurfaceResumeCommandParts(launchCommand: launchCommand, fallbackExecutable: "cmux") + var tail = original.tail + let removedToken = tail.first == "omo" + if removedToken { tail.removeFirst() } + argv = AgentLaunchSanitizer.preservedArguments(kind: "opencode", args: tail).map { + agentSurfaceResumePrefixedArguments( + executable: original.executable, + token: "omo", + option: "--session", + sessionId: normalizedSessionId, + preserved: $0 + ) + } + case "codexTeams": + let original = agentSurfaceResumeCommandParts(launchCommand: launchCommand, fallbackExecutable: "cmux") + var tail = original.tail + if tail.first == "codex-teams" { tail.removeFirst() } + argv = AgentLaunchSanitizer.preservedCodexForkArguments(args: tail).map { + [original.executable, "codex-teams", "resume", normalizedSessionId] + $0 + } + case "omx", "omc": + argv = nil + default: + argv = agentSurfaceResumeArguments(kind: kind, sessionId: normalizedSessionId, launchCommand: launchCommand) + } + + guard let argv, !argv.isEmpty else { return nil } + return agentSurfaceResumeShellCommand( + argv: argv, + workingDirectory: workingDirectory ?? launchCommand?.workingDirectory + ) + } + + private func agentSurfaceResumeArguments( + kind: String, + sessionId: String, + launchCommand: AgentHookLaunchCommandRecord? + ) -> [String]? { + switch kind { + case "claude": + return agentSurfaceResumeWithOption(kind: kind, launchCommand: launchCommand, fallbackExecutable: "claude", option: "--resume", sessionId: sessionId) + case "codex": + let original = agentSurfaceResumeCommandParts(launchCommand: launchCommand, fallbackExecutable: "codex") + return AgentLaunchSanitizer.preservedCodexForkArguments(args: original.tail).map { + [original.executable, "resume", sessionId] + $0 + } + case "pi": + return agentSurfaceResumeWithOption(kind: kind, launchCommand: launchCommand, fallbackExecutable: "pi", option: "--session", sessionId: sessionId) + case "amp": + let original = agentSurfaceResumeCommandParts(launchCommand: launchCommand, fallbackExecutable: "amp") + return AgentLaunchSanitizer.preservedArguments(kind: kind, args: original.tail).map { + [original.executable, "threads", "continue"] + $0 + [sessionId] + } + case "cursor": + return agentSurfaceResumeWithOption(kind: kind, launchCommand: launchCommand, fallbackExecutable: "cursor-agent", option: "--resume", sessionId: sessionId) + case "gemini": + return agentSurfaceResumeWithOption(kind: kind, launchCommand: launchCommand, fallbackExecutable: "gemini", option: "--resume", sessionId: sessionId) + case "opencode": + let original = agentSurfaceResumeCommandParts(launchCommand: launchCommand, fallbackExecutable: "opencode") + return AgentLaunchSanitizer.preservedArguments(kind: kind, args: original.tail).map { + [original.executable, "--session", sessionId] + $0 + } + case "rovodev": + let original = agentSurfaceResumeCommandParts(launchCommand: launchCommand, fallbackExecutable: "acli") + return AgentLaunchSanitizer.preservedArguments(kind: kind, args: original.tail).map { + [original.executable, "rovodev", "run", "--restore", sessionId] + $0 + } + case "hermes-agent": + let original = agentSurfaceResumeCommandParts(launchCommand: launchCommand, fallbackExecutable: "hermes") + return AgentLaunchSanitizer.preservedArguments(kind: kind, args: original.tail).map { + [original.executable] + $0 + ["--resume", sessionId] + } + case "copilot": + return agentSurfaceResumeWithOption(kind: kind, launchCommand: launchCommand, fallbackExecutable: "copilot", option: "--resume", sessionId: sessionId) + case "codebuddy": + return agentSurfaceResumeWithOption(kind: kind, launchCommand: launchCommand, fallbackExecutable: "codebuddy", option: "--resume", sessionId: sessionId) + case "factory": + return agentSurfaceResumeWithOption(kind: kind, launchCommand: launchCommand, fallbackExecutable: "droid", option: "--resume", sessionId: sessionId) + case "qoder": + return agentSurfaceResumeWithOption(kind: kind, launchCommand: launchCommand, fallbackExecutable: "qodercli", option: "--resume", sessionId: sessionId) + default: + return nil + } + } + + private func agentSurfaceResumeWithOption( + kind: String, + launchCommand: AgentHookLaunchCommandRecord?, + fallbackExecutable: String, + option: String, + sessionId: String + ) -> [String]? { + let original = agentSurfaceResumeCommandParts(launchCommand: launchCommand, fallbackExecutable: fallbackExecutable) + return AgentLaunchSanitizer.preservedArguments(kind: kind, args: original.tail).map { + [original.executable, option, sessionId] + $0 + } + } + + private func agentSurfaceResumePrefixedArguments( + executable: String, + token: String, + option: String, + sessionId: String, + preserved: [String] + ) -> [String] { + [executable, token, option, sessionId] + preserved + } + + private func agentSurfaceResumeCommandParts( + launchCommand: AgentHookLaunchCommandRecord?, + fallbackExecutable: String + ) -> (executable: String, tail: [String]) { + let arguments = launchCommand?.arguments ?? [] + let executable = normalizedHookValue(launchCommand?.executablePath) + ?? arguments.first + ?? fallbackExecutable + let tail = arguments.isEmpty ? [] : Array(arguments.dropFirst()) + return (executable: executable, tail: tail) + } + + private func agentSurfaceResumeShellCommand( + argv: [String], + workingDirectory: String? + ) -> String { + var commandParts: [String] = [] + commandParts.append(contentsOf: argv) + + var command = commandParts.map(cliShellQuote).joined(separator: " ") + if let cwd = normalizedHookValue(workingDirectory) { + command = "cd \(cliShellQuote(cwd)) && \(command)" + } + return command + } + + private func agentSurfaceResumeEnvironment( + kind: String, + environment: [String: String]? + ) -> [String: String]? { + guard let environment else { return nil } + let selected = selectedAgentLaunchEnvironment(from: environment) + guard !selected.isEmpty else { return nil } + + let claudeAuthKeys: Set = [ + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_BASE_URL", + "ANTHROPIC_MODEL", + "ANTHROPIC_SMALL_FAST_MODEL", + "CLAUDE_CODE_USE_BEDROCK", + "CLAUDE_CODE_USE_VERTEX", + "CLAUDE_CONFIG_DIR" + ] + var resolved = selected + if kind == "claude" { + let preservedClaudeKeys = selected.keys.sorted().filter { claudeAuthKeys.contains($0) } + if !preservedClaudeKeys.isEmpty { + for key in preservedClaudeKeys { + resolved.removeValue(forKey: key) + } + resolved["CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV"] = "1" + resolved["CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV_KEYS"] = preservedClaudeKeys.joined(separator: ",") + } + } + return resolved + } + private func decodeNULSeparatedBase64(_ rawValue: String?) -> [String]? { guard let rawValue = normalizedHookValue(rawValue), let data = Data(base64Encoded: rawValue) else { @@ -20872,6 +21496,16 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { pid: pid, launchCommand: launchCommand ) + publishAgentSurfaceResumeBinding( + client: client, + workspaceId: workspaceId, + surfaceId: surfaceId, + kind: def.name, + displayName: def.displayName, + sessionId: sessionId, + cwd: hookCwd ?? mapped?.cwd, + launchCommand: launchCommand + ) } if let pid { _ = try? sendV1Command( @@ -20906,6 +21540,16 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { pid: pid, launchCommand: launchCommand ) + publishAgentSurfaceResumeBinding( + client: client, + workspaceId: workspaceId, + surfaceId: surfaceId, + kind: def.name, + displayName: def.displayName, + sessionId: sessionId, + cwd: hookCwd ?? mapped?.cwd, + launchCommand: launchCommand ?? mapped?.launchCommand + ) } if let pid { _ = try? sendV1Command( @@ -21013,6 +21657,16 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { launchCommand: launchCommand, lastSubtitle: subtitle, lastBody: body) + publishAgentSurfaceResumeBinding( + client: client, + workspaceId: workspaceId, + surfaceId: surfaceId, + kind: def.name, + displayName: def.displayName, + sessionId: sessionId, + cwd: cwd, + launchCommand: launchCommand ?? mapped?.launchCommand + ) } if let pid { _ = try? sendV1Command( @@ -21049,6 +21703,12 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { } if let mapped = try? store.consume(sessionId: sessionId, workspaceId: nil, surfaceId: nil) { sendAgentFeedTelemetry(workspaceId: mapped.workspaceId) + clearAgentSurfaceResumeBinding( + client: client, + workspaceId: mapped.workspaceId, + surfaceId: mapped.surfaceId, + sessionId: mapped.sessionId + ) _ = try? sendV1Command( "clear_agent_pid \(pidKey) --tab=\(mapped.workspaceId)\(socketPanelOption(mapped.surfaceId)) --clear-status", client: client @@ -24219,6 +24879,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { split-off --surface [--workspace ] [--focus ] reorder-surface --surface (--index | --before | --after ) [--focus ] tab-action --action [--tab ] [--surface ] [--workspace ] [--title ] [--url ] [--focus ] + surface resume [--workspace ] [--surface ] rename-tab [--workspace ] [--tab ] [--surface ] drag-surface-to-split --surface <id|ref|index> <left|right|up|down> [--workspace <id|ref|index>] [--focus <true|false>] refresh-surfaces diff --git a/README.ar.md b/README.ar.md index fded5765ba72..58fa9e2d5bf1 100644 --- a/README.ar.md +++ b/README.ar.md @@ -236,15 +236,33 @@ cmux هو لبنة أساسية وليس حلًا جاهزًا. يمنحك طر cmux NIGHTLY هو تطبيق منفصل بمعرّف حزمة خاص به، لذا يعمل بجانب الإصدار المستقر. يُبنى تلقائيًا من أحدث commit على فرع `main` ويتم تحديثه تلقائيًا عبر Sparkle الخاص به. -## استعادة الجلسة (السلوك الحالي) +## استعادة الجلسة -عند إعادة التشغيل، يستعيد cmux حاليًا تخطيط التطبيق والبيانات الوصفية فقط: +عند إغلاق cmux، يتم حفظ الجلسة الحالية. عند إعادة التشغيل، يستعيد cmux الحالة التي يملكها التطبيق: - تخطيط النوافذ/مساحات العمل/الأجزاء - مجلدات العمل - سجل تمرير الطرفية (أفضل جهد) - عنوان URL للمتصفح وسجل التنقل -cmux **لا** يستعيد حالة العمليات الحية داخل تطبيقات الطرفية. على سبيل المثال، جلسات Claude Code/tmux/vim النشطة لا يتم استئنافها بعد إعادة التشغيل بعد. +لا ينشئ cmux نقاط تحقق لأي حالة عملية حية عشوائية. يعاد فتح tmux وvim وshell وتطبيقات الطرفية غير المدعومة كطرفيات عادية. + +يمكن استئناف جلسات الوكلاء المدعومة عندما تحفظ hooks معرف جلسة أصليًا: + +```bash +cmux hooks setup +cmux hooks setup codex +cmux hooks setup --agent opencode +``` + +يمكن للمستخدمين المتقدمين والتكاملات ربط أمر استئناف مخصص بسطح الطرفية الحالي. هذا مفيد للأدوات التي تملك حالة دائمة خاصة بها، مثل جلسات tmux أو واجهات agent CLI مخصصة: + +```bash +cmux surface resume set --kind tmux --checkpoint work --shell "tmux attach -t work" +cmux surface resume show --json +cmux surface resume clear --checkpoint work +``` + +يبقى هذا الربط مرتبطًا بسطح cmux. تُحفظ الارتباطات التي ينشئها CLI العام أو socket للفحص والاستئناف اليدوي. لا يشغّل cmux تلقائيًا إلا ارتباطات الاستئناف التي يضع عليها علامة موثوقة، مثل ارتباطات tmux المكتشفة من العمليات الحية. تُسقط مفاتيح البيئة الحساسة مثل الرموز وكلمات المرور والأسرار ومفاتيح API قبل حفظ ربط الاستئناف. ## تاريخ النجوم diff --git a/README.bs.md b/README.bs.md index 6608507fa0f9..aade8b815dfa 100644 --- a/README.bs.md +++ b/README.bs.md @@ -236,15 +236,33 @@ Prečice razvojnih alata preglednika prate Safari zadane postavke i mogu se pril cmux NIGHTLY je zasebna aplikacija sa vlastitim bundle ID-om, tako da radi uporedo sa stabilnom verzijom. Automatski se gradi iz najnovijeg `main` commita i ažurira se putem vlastitog Sparkle feeda. -## Vraćanje sesije (trenutno ponašanje) +## Vraćanje sesije -Prilikom ponovnog pokretanja, cmux trenutno vraća samo raspored aplikacije i metapodatke: +Kada zatvorite cmux, trenutna sesija se sprema. Pri ponovnom pokretanju cmux vraća stanje kojim upravlja aplikacija: - Raspored prozora/radnih prostora/panela - Radne direktorije - Scrollback terminala (po mogućnosti) - URL preglednika i historija navigacije -cmux **ne** vraća stanje živih procesa unutar terminalnih aplikacija. Na primjer, aktivne sesije Claude Code/tmux/vim se još ne nastavljaju nakon restarta. +cmux ne pravi checkpoint proizvoljnog stanja živih procesa. tmux, vim, shellovi i nepodržane terminalne aplikacije ponovo se otvaraju kao obični terminali. + +Podržane agent sesije mogu se nastaviti kada hooks spreme izvorni ID sesije: + +```bash +cmux hooks setup +cmux hooks setup codex +cmux hooks setup --agent opencode +``` + +Napredni korisnici i integracije mogu vezati prilagođenu komandu za nastavak na trenutni terminal surface. To je korisno za alate s vlastitim trajnim stanjem, poput tmux sesija ili prilagođenih agent CLI alata: + +```bash +cmux surface resume set --kind tmux --checkpoint work --shell "tmux attach -t work" +cmux surface resume show --json +cmux surface resume clear --checkpoint work +``` + +Binding ostaje vezan za cmux surface. Bindingi napravljeni javnim CLI-jem ili socketom čuvaju se za pregled i ručni nastavak. cmux automatski pokreće samo resume bindinge koje označi pouzdanim, poput tmux bindinga otkrivenih iz živih procesa. Osjetljivi ključevi okruženja, poput tokena, lozinki, tajni i API ključeva, odbacuju se prije spremanja resume bindinga. ## Historija zvjezdica diff --git a/README.da.md b/README.da.md index 761b33ea89c9..e2718f62791b 100644 --- a/README.da.md +++ b/README.da.md @@ -236,15 +236,33 @@ Browserens udviklerværktøjsgenveje følger Safaris standarder og kan tilpasses cmux NIGHTLY er en separat app med sit eget bundle-ID, så den kører side om side med den stabile version. Bygges automatisk fra det seneste `main`-commit og opdaterer sig selv automatisk via sit eget Sparkle-feed. -## Sessionsgenoprettelse (nuværende adfærd) +## Sessionsgenoprettelse -Ved genstart genopretter cmux i øjeblikket kun app-layout og metadata: +Når du afslutter cmux, gemmes den aktuelle session. Ved genstart genopretter cmux app-ejet tilstand: - Vindue/workspace/panel-layout - Arbejdsmapper - Terminal-scrollback (best effort) - Browser-URL og navigationshistorik -cmux genopretter **ikke** aktive procestilstande i terminalapps. For eksempel genoptages aktive Claude Code/tmux/vim-sessioner endnu ikke efter genstart. +cmux tager ikke checkpoints af vilkårlig aktiv procestilstand. tmux, vim, shells og ikke-understøttede terminalapps åbnes igen som normale terminaler. + +Understøttede agent-sessioner kan genoptages, når hooks har gemt et native sessions-ID: + +```bash +cmux hooks setup +cmux hooks setup codex +cmux hooks setup --agent opencode +``` + +Avancerede brugere og integrationer kan knytte en brugerdefineret genoptagelseskommando til den aktuelle terminal-surface. Det er nyttigt for værktøjer med egen varig tilstand, som tmux-sessioner eller brugerdefinerede agent-CLI'er: + +```bash +cmux surface resume set --kind tmux --checkpoint work --shell "tmux attach -t work" +cmux surface resume show --json +cmux surface resume clear --checkpoint work +``` + +Bindingen forbliver knyttet til cmux-surfacen. Bindinger oprettet via offentlig CLI eller socket gemmes til inspektion og manuel genoptagelse. cmux auto-kører kun resume-bindinger, som den markerer som betroede, for eksempel tmux-bindinger fundet fra live processer. Følsomme miljønøgler som tokens, adgangskoder, hemmeligheder og API-nøgler fjernes, før en resume-binding gemmes. ## Stjernehistorik diff --git a/README.de.md b/README.de.md index 1740f82734af..f011bd165969 100644 --- a/README.de.md +++ b/README.de.md @@ -236,15 +236,33 @@ Tastenkürzel für Browser-Entwicklertools folgen den Safari-Standardeinstellung cmux NIGHTLY ist eine separate App mit eigener Bundle-ID, die neben der stabilen Version läuft. Wird automatisch vom neuesten `main`-Commit gebaut und aktualisiert sich über einen eigenen Sparkle-Feed. -## Sitzungswiederherstellung (aktuelles Verhalten) +## Sitzungswiederherstellung -Beim Neustart stellt cmux derzeit nur App-Layout und Metadaten wieder her: +Beim Beenden speichert cmux die aktuelle Sitzung. Beim Neustart stellt cmux den von der App verwalteten Zustand wieder her: - Fenster-/Arbeitsbereich-/Bereichs-Layout - Arbeitsverzeichnisse - Terminal-Scrollback (bestmöglich) - Browser-URL und Navigationsverlauf -cmux stellt **keine** laufenden Prozesse in Terminal-Apps wieder her. Zum Beispiel werden aktive Claude Code-/tmux-/vim-Sitzungen nach einem Neustart noch nicht fortgesetzt. +cmux erstellt keine Prüfpunkte für beliebige laufende Prozesse. tmux, vim, Shells und nicht unterstützte Terminal-Apps werden als normale Terminals erneut geöffnet. + +Unterstützte Agent-Sitzungen können fortgesetzt werden, wenn Hooks eine native Sitzungs-ID gespeichert haben: + +```bash +cmux hooks setup +cmux hooks setup codex +cmux hooks setup --agent opencode +``` + +Fortgeschrittene Nutzer und Integrationen können einen eigenen Wiederaufnahmebefehl an die aktuelle Terminal-Surface binden. Das ist nützlich für Werkzeuge mit eigenem dauerhaftem Zustand, etwa tmux-Sitzungen oder eigene Agent-CLIs: + +```bash +cmux surface resume set --kind tmux --checkpoint work --shell "tmux attach -t work" +cmux surface resume show --json +cmux surface resume clear --checkpoint work +``` + +Die Bindung bleibt mit der cmux-Surface verknüpft. Über öffentliche CLI oder Socket erstellte Bindungen werden zur Prüfung und manuellen Wiederaufnahme gespeichert. cmux startet nur Wiederaufnahme-Bindungen automatisch, die als vertrauenswürdig markiert sind, zum Beispiel aus laufenden Prozessen erkannte tmux-Bindungen. Sensible Umgebungsvariablen wie Tokens, Passwörter, Secrets und API-Keys werden verworfen, bevor eine Wiederaufnahme-Bindung gespeichert wird. ## Star-Verlauf diff --git a/README.es.md b/README.es.md index 0a3e3eb63b16..f4a889a44d43 100644 --- a/README.es.md +++ b/README.es.md @@ -236,15 +236,33 @@ Los atajos de herramientas de desarrollo del navegador siguen los valores predet cmux NIGHTLY es una app separada con su propio bundle ID, por lo que se ejecuta junto a la versión estable. Se compila automáticamente desde el último commit de `main` y se actualiza automáticamente a través de su propio feed de Sparkle. -## Restauración de sesión (comportamiento actual) +## Restauración de sesión -Al relanzar, cmux actualmente restaura solo el diseño y los metadatos de la aplicación: +Al salir, cmux guarda la sesión actual. Al relanzar, cmux restaura el estado que pertenece a la app: - Diseño de ventanas/espacios de trabajo/paneles - Directorios de trabajo - Historial de desplazamiento del terminal (mejor esfuerzo) - URL del navegador e historial de navegación -cmux **no** restaura el estado de los procesos activos dentro de las aplicaciones de terminal. Por ejemplo, las sesiones activas de Claude Code/tmux/vim no se reanudan después de reiniciar todavía. +cmux no guarda puntos de control de procesos activos arbitrarios. tmux, vim, shells y apps de terminal no compatibles se vuelven a abrir como terminales normales. + +Las sesiones de agentes compatibles pueden reanudarse cuando los hooks han guardado un ID de sesión nativo: + +```bash +cmux hooks setup +cmux hooks setup codex +cmux hooks setup --agent opencode +``` + +Los usuarios avanzados y las integraciones pueden asociar un comando de reanudación personalizado a la superficie de terminal actual. Esto es útil para herramientas con estado duradero propio, como sesiones tmux o CLIs de agentes personalizados: + +```bash +cmux surface resume set --kind tmux --checkpoint work --shell "tmux attach -t work" +cmux surface resume show --json +cmux surface resume clear --checkpoint work +``` + +La asociación queda ligada a la superficie de cmux. Las asociaciones creadas por el CLI público o el socket se guardan para inspección y reanudación manual. cmux solo ejecuta automáticamente las asociaciones de reanudación que marca como confiables, como las de tmux detectadas desde procesos activos. Las claves de entorno sensibles, como tokens, contraseñas, secretos y claves de API, se descartan antes de guardar una asociación de reanudación. ## Historial de estrellas diff --git a/README.fr.md b/README.fr.md index 64f3c4303687..d802386aab29 100644 --- a/README.fr.md +++ b/README.fr.md @@ -236,15 +236,33 @@ Les raccourcis des outils de développement du navigateur suivent les valeurs pa cmux NIGHTLY est une application séparée avec son propre identifiant de bundle, elle fonctionne donc en parallèle de la version stable. Construite automatiquement à partir du dernier commit `main` et mise à jour automatiquement via son propre flux Sparkle. -## Restauration de session (comportement actuel) +## Restauration de session -Au relancement, cmux restaure actuellement uniquement la disposition et les métadonnées de l'application : +À la fermeture, cmux enregistre la session en cours. Au relancement, cmux restaure l'état géré par l'application : - Disposition des fenêtres/espaces de travail/panneaux - Répertoires de travail - Historique de défilement du terminal (au mieux) - URL du navigateur et historique de navigation -cmux ne restaure **pas** l'état des processus actifs dans les applications du terminal. Par exemple, les sessions actives de Claude Code/tmux/vim ne sont pas encore reprises après un redémarrage. +cmux ne crée pas de point de contrôle pour n'importe quel processus actif. tmux, vim, les shells et les applications de terminal non prises en charge se rouvrent comme des terminaux normaux. + +Les sessions d'agents prises en charge peuvent reprendre lorsque les hooks ont enregistré un ID de session natif : + +```bash +cmux hooks setup +cmux hooks setup codex +cmux hooks setup --agent opencode +``` + +Les utilisateurs avancés et les intégrations peuvent associer une commande de reprise personnalisée à la surface de terminal active. C'est utile pour les outils qui ont leur propre état durable, comme les sessions tmux ou les CLI d'agents personnalisés : + +```bash +cmux surface resume set --kind tmux --checkpoint work --shell "tmux attach -t work" +cmux surface resume show --json +cmux surface resume clear --checkpoint work +``` + +Cette association reste liée à la surface cmux. Les associations créées par le CLI public ou le socket sont conservées pour inspection et reprise manuelle. cmux exécute automatiquement seulement les associations de reprise qu'il marque comme fiables, comme les associations tmux détectées depuis des processus actifs. Les clés d’environnement sensibles, comme les jetons, mots de passe, secrets et clés API, sont supprimées avant l’enregistrement d’une association de reprise. ## Historique des étoiles diff --git a/README.it.md b/README.it.md index 76b6dea5b71f..8ccd2578e488 100644 --- a/README.it.md +++ b/README.it.md @@ -236,15 +236,33 @@ Le scorciatoie degli strumenti di sviluppo del browser seguono i valori predefin cmux NIGHTLY è un'app separata con il proprio bundle ID, quindi funziona in parallelo alla versione stabile. Compilata automaticamente dall'ultimo commit `main` e aggiornata automaticamente tramite il proprio feed Sparkle. -## Ripristino sessione (comportamento attuale) +## Ripristino sessione -Al riavvio, cmux attualmente ripristina solo il layout e i metadati dell'applicazione: +Alla chiusura, cmux salva la sessione corrente. Al riavvio, cmux ripristina lo stato gestito dall'app: - Layout di finestre/workspace/pannelli - Directory di lavoro - Scrollback del terminale (best effort) - URL del browser e cronologia di navigazione -cmux **non** ripristina lo stato dei processi attivi nelle applicazioni del terminale. Per esempio, le sessioni attive di Claude Code/tmux/vim non vengono ancora riprese dopo un riavvio. +cmux non crea checkpoint per processi attivi arbitrari. tmux, vim, shell e app terminale non supportate si riaprono come terminali normali. + +Le sessioni degli agent supportati possono riprendere quando gli hook hanno salvato un ID sessione nativo: + +```bash +cmux hooks setup +cmux hooks setup codex +cmux hooks setup --agent opencode +``` + +Utenti avanzati e integrazioni possono associare un comando di ripristino personalizzato alla surface del terminale corrente. È utile per strumenti con stato persistente proprio, come sessioni tmux o CLI agent personalizzate: + +```bash +cmux surface resume set --kind tmux --checkpoint work --shell "tmux attach -t work" +cmux surface resume show --json +cmux surface resume clear --checkpoint work +``` + +L'associazione resta legata alla surface di cmux. Le associazioni create dal CLI pubblico o dal socket vengono salvate per ispezione e ripristino manuale. cmux esegue automaticamente solo le associazioni di resume che marca come attendibili, per esempio quelle tmux rilevate dai processi attivi. Le chiavi di ambiente sensibili, come token, password, segreti e chiavi API, vengono scartate prima di salvare un’associazione di resume. ## Cronologia Stelle diff --git a/README.ja.md b/README.ja.md index b27e86965bbf..18ecb233e55a 100644 --- a/README.ja.md +++ b/README.ja.md @@ -236,15 +236,33 @@ cmuxの設定方法の詳細は、[ドキュメントをご覧ください](http cmux NIGHTLYは独自のバンドルIDを持つ別のアプリなので、安定版と並行して実行できます。最新の`main`コミットから自動的にビルドされ、独自のSparkleフィード経由で自動更新されます。 -## セッション復元(現在の動作) +## セッション復元 -再起動時、cmuxは現在アプリのレイアウトとメタデータのみを復元します: +終了すると、cmuxは現在のセッションを保存します。再起動時にcmuxはアプリが管理する状態を復元します: - ウィンドウ/ワークスペース/ペインのレイアウト - 作業ディレクトリ - ターミナルのスクロールバック(ベストエフォート) - ブラウザのURLとナビゲーション履歴 -cmuxはターミナルアプリ内のライブプロセスの状態を復元**しません**。例えば、アクティブなClaude Code/tmux/vimセッションは再起動後にまだ再開されません。 +cmuxは任意のライブプロセス状態をチェックポイントしません。tmux、vim、シェル、未対応のターミナルアプリは通常のターミナルとして再度開きます。 + +対応エージェントは、フックがネイティブセッションIDを保存している場合に復元できます: + +```bash +cmux hooks setup +cmux hooks setup codex +cmux hooks setup --agent opencode +``` + +上級ユーザーや連携機能は、現在のターミナルサーフェスにカスタム復元コマンドを紐づけられます。tmuxセッションやカスタムエージェントCLIのように、独自の永続状態を持つツールに使います: + +```bash +cmux surface resume set --kind tmux --checkpoint work --shell "tmux attach -t work" +cmux surface resume show --json +cmux surface resume clear --checkpoint work +``` + +この紐づけはcmuxサーフェスに保存されます。公開CLIやsocketで作成された紐づけは確認と手動復元用に保存されます。cmuxが自動実行するのは、実行中プロセスから検出したtmux紐づけなど、信頼済みとして扱う復元紐づけだけです。 トークン、パスワード、シークレット、APIキーなどの機密環境変数キーは、復元紐づけを保存する前に破棄されます。 ## Star History diff --git a/README.km.md b/README.km.md index 9767f68731a9..8ef3bc2a9ca7 100644 --- a/README.km.md +++ b/README.km.md @@ -236,16 +236,34 @@ cmux គឺជាមូលដ្ឋានគ្រឹះ (primitive) មិន cmux NIGHTLY គឺជាកម្មវិធីដាច់ដោយឡែកមួយដែលមាន bundle ID ផ្ទាល់ខ្លួន ដូច្នេះវាអាចដំណើរការទន្ទឹមគ្នាជាមួយនឹងកំណែធម្មតា (stable version)។ វាត្រូវបានបង្កើតឡើងដោយស្វ័យប្រវត្តិពី commit `main` ចុងក្រោយបង្អស់ និងធ្វើបច្ចុប្បន្នភាពដោយស្វ័យប្រវត្តិតាមរយៈ Sparkle feed របស់វាផ្ទាល់។ -## ការស្ដារ Session ឡើងវិញ (អាកប្បកិរិយាបច្ចុប្បន្ន) +## ការស្ដារ Session ឡើងវិញ -នៅពេលបើកឡើងវិញ បច្ចុប្បន្ន cmux នឹងស្ដារតែប្លង់កម្មវិធី និងទិន្នន័យមេតា (metadata) ប៉ុណ្ណោះ៖ +នៅពេលអ្នកបិទ cmux វានឹងរក្សាទុក session បច្ចុប្បន្ន។ ពេលបើកឡើងវិញ cmux នឹងស្ដារស្ថានភាពដែលកម្មវិធីគ្រប់គ្រង៖ * ប្លង់ Window/workspace/pane * ថតការងារ (Working directories) * Terminal scrollback (ប្រឹងប្រែងឱ្យអស់លទ្ធភាព) * ប្រវត្តិរុករក និង URL របស់កម្មវិធីរុករក -cmux **មិន** ស្ដារស្ថានភាពដំណើរការផ្ទាល់ (live process state) នៅក្នុងកម្មវិធី terminal ឡើយ។ ឧទាហរណ៍ session របស់ Claude Code/tmux/vim ដែលកំពុងដំណើរការ មិនទាន់អាចបន្តឡើងវិញបានទេបន្ទាប់ពីចាប់ផ្ដើមឡើងវិញ។ +cmux មិន checkpoint ស្ថានភាព live process ណាមួយដោយស្វ័យប្រវត្តិទេ។ tmux, vim, shell និងកម្មវិធី terminal ដែលមិនទាន់គាំទ្រ នឹងបើកឡើងវិញជាទម្រង់ terminal ធម្មតា។ + +session របស់ agent ដែលគាំទ្រ អាច resume បាននៅពេល hooks បានរក្សាទុក native session ID៖ + +```bash +cmux hooks setup +cmux hooks setup codex +cmux hooks setup --agent opencode +``` + +អ្នកប្រើប្រាស់កម្រិតខ្ពស់ និង integration អាចភ្ជាប់ command resume ផ្ទាល់ខ្លួនទៅ terminal surface បច្ចុប្បន្ន។ វាមានប្រយោជន៍សម្រាប់ឧបករណ៍ដែលមានស្ថានភាពរក្សាទុករបស់ខ្លួន ដូចជា tmux session ឬ custom agent CLI៖ + +```bash +cmux surface resume set --kind tmux --checkpoint work --shell "tmux attach -t work" +cmux surface resume show --json +cmux surface resume clear --checkpoint work +``` + +binding នេះនៅតែភ្ជាប់ទៅ cmux surface។ binding ដែលបង្កើតដោយ public CLI ឬ socket ត្រូវបានរក្សាទុកសម្រាប់ពិនិត្យ និង resume ដោយដៃ។ cmux ដំណើរការ resume binding ដោយស្វ័យប្រវត្តិ តែ binding ដែលវាសម្គាល់ថា trusted ប៉ុណ្ណោះ ដូចជា tmux binding ដែលរកឃើញពី live process។ environment key ដែលមានភាពរសើប ដូចជា token, password, secret និង API key នឹងត្រូវបានដកចេញ មុនពេលរក្សាទុក resume binding។ ## Star History diff --git a/README.ko.md b/README.ko.md index 94eba21e96de..5ccc1125e496 100644 --- a/README.ko.md +++ b/README.ko.md @@ -236,15 +236,33 @@ cmux 설정 방법에 대한 자세한 내용은 [문서를 확인해주세요]( cmux NIGHTLY는 자체 번들 ID를 가진 별도의 앱이라 안정 버전과 함께 실행할 수 있어요. 최신 `main` 커밋에서 자동으로 빌드되고, 자체 Sparkle 피드를 통해 자동 업데이트돼요. -## 세션 복원 (현재 동작) +## 세션 복원 -재실행 시 cmux는 현재 앱 레이아웃과 메타데이터만 복원해요: +cmux를 종료하면 현재 세션을 저장합니다. 다시 실행하면 cmux가 앱이 관리하는 상태를 복원합니다: - 창/워크스페이스/패널 레이아웃 - 작업 디렉토리 - 터미널 스크롤백 (최선 노력) - 브라우저 URL 및 탐색 기록 -cmux는 터미널 앱 내부의 라이브 프로세스 상태를 복원하지 **않아요**. 예를 들어 활성 Claude Code/tmux/vim 세션은 재시작 후 아직 복원되지 않아요. +cmux는 임의의 라이브 프로세스 상태를 체크포인트하지 않습니다. tmux, vim, shell, 지원되지 않는 터미널 앱은 일반 터미널로 다시 열립니다. + +지원되는 agent 세션은 hooks가 네이티브 세션 ID를 저장한 경우 다시 시작할 수 있습니다: + +```bash +cmux hooks setup +cmux hooks setup codex +cmux hooks setup --agent opencode +``` + +고급 사용자와 통합은 현재 터미널 surface에 사용자 지정 resume 명령을 연결할 수 있습니다. tmux 세션이나 사용자 지정 agent CLI처럼 자체 영구 상태가 있는 도구에 유용합니다: + +```bash +cmux surface resume set --kind tmux --checkpoint work --shell "tmux attach -t work" +cmux surface resume show --json +cmux surface resume clear --checkpoint work +``` + +이 binding은 cmux surface에 계속 연결됩니다. 공개 CLI나 socket으로 만든 binding은 확인과 수동 resume용으로 저장됩니다. cmux는 실행 중인 프로세스에서 감지한 tmux binding처럼 신뢰됨으로 표시한 resume binding만 자동 실행합니다. 토큰, 비밀번호, 시크릿, API 키 같은 민감한 환경 변수 키는 resume binding을 저장하기 전에 제거됩니다. ## Star History diff --git a/README.md b/README.md index 57cdc0a51c35..56b95435e180 100644 --- a/README.md +++ b/README.md @@ -271,6 +271,25 @@ OpenCode, Pi, Amp, Cursor CLI, Gemini, Rovo Dev, Copilot, CodeBuddy, Factory, and Qoder. Claude Code is handled by the cmux Claude wrapper when Claude integration is enabled in Settings. +Advanced users and integrations can attach a custom resume command to the +current terminal surface. This is useful for tools with their own durable state, +such as tmux sessions or custom agent CLIs: + +```bash +cmux surface resume set --kind tmux --checkpoint work --shell "tmux attach -t work" +cmux surface resume show --json +cmux surface resume clear --checkpoint work +``` + +The binding stays attached to the cmux surface. Public CLI or socket-created +bindings are stored for inspection and manual restore unless you approve a +signed command prefix for automatic restore. Approved prefixes are also bound to +the working directory and exact environment values, when present. Review or edit +approvals in **Settings > Terminal > Resume Commands**. cmux only auto-runs +resume bindings it marks trusted, such as live process-detected tmux bindings or +user-approved prefixes. Sensitive environment keys such as tokens, passwords, +secrets, and API keys are dropped before a resume binding is stored. + To keep restored agent terminals idle instead of automatically running their resume commands, turn off **Settings > Terminal > Resume Agent Sessions on Reopen** or set this in `~/.config/cmux/cmux.json`: diff --git a/README.no.md b/README.no.md index 850427433c11..1f9477a3e49b 100644 --- a/README.no.md +++ b/README.no.md @@ -236,15 +236,33 @@ Nettleserens utviklerverktøysnarveier følger Safari-standarder og kan tilpasse cmux NIGHTLY er en separat app med sin egen bundle-ID, så den kjører ved siden av den stabile versjonen. Bygges automatisk fra den siste `main`-commiten og oppdateres automatisk via sin egen Sparkle-feed. -## Sesjonssgjenoppretting (nåværende oppførsel) +## Sesjonsgjenoppretting -Ved omstart gjenoppretter cmux for øyeblikket kun applayouten og metadata: +Når du avslutter cmux, lagres den nåværende sesjonen. Ved omstart gjenoppretter cmux tilstand som eies av appen: - Vindu-/arbeidsområde-/panellayout - Arbeidsmapper - Terminal-rullingshistorikk (best effort) - Nettleser-URL og navigasjonshistorikk -cmux gjenoppretter **ikke** aktive prosesstilstander inne i terminalapper. For eksempel blir aktive Claude Code/tmux/vim-sesjoner ikke gjenopptatt etter omstart ennå. +cmux tar ikke checkpoint av vilkårlig aktiv prosesstilstand. tmux, vim, shell og terminalapper uten støtte åpnes igjen som vanlige terminaler. + +Støttede agentøkter kan gjenopptas når hooks har lagret en native sesjons-ID: + +```bash +cmux hooks setup +cmux hooks setup codex +cmux hooks setup --agent opencode +``` + +Avanserte brukere og integrasjoner kan knytte en egendefinert gjenopptakskommando til gjeldende terminal-surface. Dette er nyttig for verktøy med egen varig tilstand, som tmux-sesjoner eller egendefinerte agent-CLI-er: + +```bash +cmux surface resume set --kind tmux --checkpoint work --shell "tmux attach -t work" +cmux surface resume show --json +cmux surface resume clear --checkpoint work +``` + +Bindingen forblir knyttet til cmux-surfacen. Bindinger opprettet via offentlig CLI eller socket lagres for inspeksjon og manuell gjenopptakelse. cmux auto-kjører bare resume-bindinger den markerer som klarerte, for eksempel tmux-bindinger oppdaget fra levende prosesser. Sensitive miljønøkler som tokens, passord, hemmeligheter og API-nøkler fjernes før en resume-binding lagres. ## Stjernehistorikk diff --git a/README.pl.md b/README.pl.md index 72f7c91d2641..316df529fe62 100644 --- a/README.pl.md +++ b/README.pl.md @@ -236,15 +236,33 @@ Skróty narzędzi deweloperskich przeglądarki odpowiadają domyślnym ustawieni cmux NIGHTLY to osobna aplikacja z własnym identyfikatorem pakietu, więc działa obok wersji stabilnej. Budowana automatycznie z najnowszego commitu `main` i aktualizuje się automatycznie przez własny kanał Sparkle. -## Przywracanie sesji (obecne zachowanie) +## Przywracanie sesji -Przy ponownym uruchomieniu cmux obecnie przywraca tylko układ aplikacji i metadane: +Po zamknięciu cmux zapisuje bieżącą sesję. Po ponownym uruchomieniu cmux przywraca stan należący do aplikacji: - Układ okien/przestrzeni roboczych/paneli - Katalogi robocze - Scrollback terminala (najlepsza próba) - URL przeglądarki i historia nawigacji -cmux **nie** przywraca stanu żywych procesów wewnątrz aplikacji terminalowych. Na przykład aktywne sesje Claude Code/tmux/vim nie są jeszcze wznawiane po restarcie. +cmux nie tworzy checkpointów dowolnego stanu działających procesów. tmux, vim, powłoki i nieobsługiwane aplikacje terminalowe otwierają się ponownie jako zwykłe terminale. + +Obsługiwane sesje agentów mogą zostać wznowione, gdy hooks zapiszą natywny ID sesji: + +```bash +cmux hooks setup +cmux hooks setup codex +cmux hooks setup --agent opencode +``` + +Zaawansowani użytkownicy i integracje mogą przypiąć własne polecenie wznowienia do bieżącej terminal surface. Przydaje się to narzędziom z własnym trwałym stanem, takim jak sesje tmux lub niestandardowe agent CLI: + +```bash +cmux surface resume set --kind tmux --checkpoint work --shell "tmux attach -t work" +cmux surface resume show --json +cmux surface resume clear --checkpoint work +``` + +Binding pozostaje powiązany z cmux surface. Bindingi utworzone przez publiczny CLI lub socket są zapisywane do inspekcji i ręcznego wznowienia. cmux automatycznie uruchamia tylko bindingi wznowienia oznaczone jako zaufane, na przykład bindingi tmux wykryte z działających procesów. Wrażliwe klucze środowiska, takie jak tokeny, hasła, sekrety i klucze API, są odrzucane przed zapisaniem bindingu wznowienia. ## Historia Gwiazdek diff --git a/README.pt-BR.md b/README.pt-BR.md index adf432ab6074..d1111f055da1 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -236,15 +236,33 @@ Os atalhos de ferramentas do desenvolvedor do navegador seguem os padrões do Sa O cmux NIGHTLY é um app separado com seu próprio bundle ID, então roda ao lado da versão estável. Construído automaticamente a partir do último commit em `main` e se atualiza automaticamente via seu próprio feed Sparkle. -## Restauração de sessão (comportamento atual) +## Restauração de sessão -Ao reiniciar, o cmux atualmente restaura apenas o layout do app e metadados: +Ao sair, o cmux salva a sessão atual. Ao abrir novamente, o cmux restaura o estado pertencente ao app: - Layout de janelas/workspaces/painéis - Diretórios de trabalho - Histórico de rolagem do terminal (melhor esforço) - URL do navegador e histórico de navegação -O cmux **não** restaura o estado de processos ativos dentro de apps de terminal. Por exemplo, sessões ativas de Claude Code/tmux/vim não são retomadas após reiniciar ainda. +O cmux não cria checkpoints de processos ativos arbitrários. tmux, vim, shells e apps de terminal sem suporte reabrem como terminais normais. + +Sessões de agentes compatíveis podem ser retomadas quando os hooks salvam um ID de sessão nativo: + +```bash +cmux hooks setup +cmux hooks setup codex +cmux hooks setup --agent opencode +``` + +Usuários avançados e integrações podem associar um comando personalizado de retomada à surface de terminal atual. Isso é útil para ferramentas com estado durável próprio, como sessões tmux ou CLIs de agentes customizados: + +```bash +cmux surface resume set --kind tmux --checkpoint work --shell "tmux attach -t work" +cmux surface resume show --json +cmux surface resume clear --checkpoint work +``` + +A associação fica ligada à surface do cmux. Associações criadas pela CLI pública ou pelo socket são salvas para inspeção e retomada manual. O cmux só executa automaticamente associações de retomada que marca como confiáveis, como associações tmux detectadas a partir de processos ativos. Chaves de ambiente sensíveis, como tokens, senhas, segredos e chaves de API, são descartadas antes de salvar uma associação de retomada. ## Histórico de Estrelas diff --git a/README.ru.md b/README.ru.md index 98b13b6f44e5..300301113ec9 100644 --- a/README.ru.md +++ b/README.ru.md @@ -236,15 +236,33 @@ cmux — это примитив, а не решение. Он даёт вам cmux NIGHTLY — это отдельное приложение с собственным идентификатором пакета, поэтому оно работает параллельно со стабильной версией. Собирается автоматически из последнего коммита `main` и обновляется через собственный канал Sparkle. -## Восстановление сессии (текущее поведение) +## Восстановление сессии -При перезапуске cmux в настоящее время восстанавливает только макет приложения и метаданные: +При выходе cmux сохраняет текущую сессию. При повторном запуске cmux восстанавливает состояние, которым управляет приложение: - Макет окон/рабочих пространств/панелей - Рабочие каталоги - Scrollback терминала (по возможности) - URL браузера и история навигации -cmux **не** восстанавливает состояние живых процессов внутри терминальных приложений. Например, активные сессии Claude Code/tmux/vim пока не возобновляются после перезапуска. +cmux не создает checkpoint для произвольного состояния живых процессов. tmux, vim, shell и неподдерживаемые терминальные приложения открываются заново как обычные терминалы. + +Поддерживаемые сессии агентов могут возобновляться, если hooks сохранили нативный ID сессии: + +```bash +cmux hooks setup +cmux hooks setup codex +cmux hooks setup --agent opencode +``` + +Опытные пользователи и интеграции могут привязать пользовательскую команду возобновления к текущей terminal surface. Это полезно для инструментов с собственным долговременным состоянием, например tmux-сессий или пользовательских agent CLI: + +```bash +cmux surface resume set --kind tmux --checkpoint work --shell "tmux attach -t work" +cmux surface resume show --json +cmux surface resume clear --checkpoint work +``` + +Привязка остается связанной с surface в cmux. Привязки, созданные публичным CLI или socket, сохраняются для проверки и ручного возобновления. cmux автоматически запускает только привязки возобновления, которые помечает как доверенные, например tmux-привязки, обнаруженные из живых процессов. Чувствительные ключи окружения, такие как токены, пароли, секреты и API-ключи, отбрасываются перед сохранением привязки возобновления. ## История звёзд diff --git a/README.th.md b/README.th.md index 690a0cbb65ea..7ed4592b8559 100644 --- a/README.th.md +++ b/README.th.md @@ -236,15 +236,33 @@ cmux เป็นส่วนประกอบพื้นฐาน ไม่ cmux NIGHTLY เป็นแอปแยกต่างหากที่มี bundle ID เป็นของตัวเอง จึงสามารถรันควบคู่กับเวอร์ชันเสถียรได้ สร้างอัตโนมัติจากคอมมิต `main` ล่าสุดและอัปเดตอัตโนมัติผ่านฟีด Sparkle ของตัวเอง -## การกู้คืนเซสชัน (พฤติกรรมปัจจุบัน) +## การกู้คืนเซสชัน -เมื่อเปิดใหม่ cmux จะกู้คืนเลย์เอาต์และข้อมูลเมตาของแอปเท่านั้น: +เมื่อออกจาก cmux ระบบจะบันทึกเซสชันปัจจุบันไว้ เมื่อเปิดใหม่ cmux จะกู้คืนสถานะที่แอปเป็นเจ้าของ: - เลย์เอาต์หน้าต่าง/เวิร์กสเปซ/แผง - ไดเรกทอรีทำงาน - ประวัติการเลื่อนของเทอร์มินัล (พยายามอย่างดีที่สุด) - URL ของเบราว์เซอร์และประวัติการนำทาง -cmux **ไม่**กู้คืนสถานะกระบวนการที่กำลังทำงานภายในแอปเทอร์มินัล ตัวอย่างเช่น เซสชัน Claude Code/tmux/vim ที่กำลังทำงานอยู่จะยังไม่ถูกกู้คืนหลังจากรีสตาร์ท +cmux ไม่ checkpoint สถานะ live process ใด ๆ โดยพลการ tmux, vim, shell และแอปเทอร์มินัลที่ยังไม่รองรับจะเปิดกลับมาเป็นเทอร์มินัลปกติ + +เซสชัน agent ที่รองรับสามารถ resume ได้เมื่อ hooks บันทึก native session ID ไว้แล้ว: + +```bash +cmux hooks setup +cmux hooks setup codex +cmux hooks setup --agent opencode +``` + +ผู้ใช้ขั้นสูงและ integration สามารถผูกคำสั่ง resume แบบกำหนดเองกับ terminal surface ปัจจุบันได้ เหมาะกับเครื่องมือที่มีสถานะถาวรของตัวเอง เช่น tmux session หรือ agent CLI แบบกำหนดเอง: + +```bash +cmux surface resume set --kind tmux --checkpoint work --shell "tmux attach -t work" +cmux surface resume show --json +cmux surface resume clear --checkpoint work +``` + +binding นี้ยังเชื่อมกับ cmux surface อยู่ binding ที่สร้างผ่าน public CLI หรือ socket จะถูกเก็บไว้สำหรับตรวจสอบและ resume ด้วยตนเอง cmux จะ auto-run เฉพาะ resume binding ที่ถูกทำเครื่องหมายว่า trusted เช่น tmux binding ที่ตรวจพบจาก process ที่กำลังทำงาน คีย์ environment ที่ละเอียดอ่อน เช่น token, password, secret และ API key จะถูกตัดทิ้งก่อนบันทึก resume binding ## ประวัติดาว diff --git a/README.tr.md b/README.tr.md index 13102ee7729e..8f9480ea2da2 100644 --- a/README.tr.md +++ b/README.tr.md @@ -236,15 +236,33 @@ Tarayıcı geliştirici araçları kısayolları Safari varsayılanlarını taki cmux NIGHTLY, kendi bundle ID'sine sahip ayrı bir uygulamadır, bu yüzden kararlı sürümle yan yana çalışır. En son `main` commit'inden otomatik olarak derlenir ve kendi Sparkle akışı aracılığıyla otomatik güncellenir. -## Oturum geri yükleme (mevcut davranış) +## Oturum geri yükleme -Yeniden başlatıldığında, cmux şu anda yalnızca uygulama düzenini ve meta verileri geri yükler: +cmux'tan çıktığınızda mevcut oturum kaydedilir. Yeniden başlatıldığında cmux uygulamaya ait durumu geri yükler: - Pencere/çalışma alanı/panel düzeni - Çalışma dizinleri - Terminal kaydırma geçmişi (en iyi çaba) - Tarayıcı URL'si ve gezinme geçmişi -cmux, terminal uygulamaları içindeki canlı işlem durumunu geri **yüklemez**. Örneğin, aktif Claude Code/tmux/vim oturumları yeniden başlatma sonrasında henüz devam ettirilmez. +cmux rastgele canlı işlem durumunu checkpoint etmez. tmux, vim, shell'ler ve desteklenmeyen terminal uygulamaları normal terminaller olarak yeniden açılır. + +Desteklenen agent oturumları, hooks yerel bir oturum ID'si kaydettiğinde sürdürülebilir: + +```bash +cmux hooks setup +cmux hooks setup codex +cmux hooks setup --agent opencode +``` + +Gelişmiş kullanıcılar ve entegrasyonlar mevcut terminal surface'ine özel bir resume komutu bağlayabilir. Bu, tmux oturumları veya özel agent CLI'ları gibi kendi kalıcı durumuna sahip araçlar için kullanışlıdır: + +```bash +cmux surface resume set --kind tmux --checkpoint work --shell "tmux attach -t work" +cmux surface resume show --json +cmux surface resume clear --checkpoint work +``` + +Bu binding cmux surface'ine bağlı kalır. Genel CLI veya socket ile oluşturulan bindingler inceleme ve manuel resume için saklanır. cmux yalnızca güvenilir olarak işaretlediği resume bindinglerini otomatik çalıştırır, örneğin canlı processlerden algılanan tmux bindingleri. Token, parola, gizli değer ve API anahtarı gibi hassas ortam anahtarları resume binding kaydedilmeden önce atılır. ## Yıldız Geçmişi diff --git a/README.uk.md b/README.uk.md index 22bb64900619..43961225a589 100644 --- a/README.uk.md +++ b/README.uk.md @@ -238,15 +238,33 @@ cmux NIGHTLY — це окремий додаток з власним bundle ID, Повідомляйте про помилки нічних збірок на [GitHub Issues](https://github.com/manaflow-ai/cmux/issues) або в [#nightly-bugs у Discord](https://discord.gg/xsgFEVrWCZ). -## Відновлення сесії (поточна поведінка) +## Відновлення сесії -При перезапуску cmux наразі відновлює лише макет та метадані додатку: +Під час виходу cmux зберігає поточну сесію. Після повторного запуску cmux відновлює стан, яким керує застосунок: - Макет вікон/робочих просторів/панелей - Робочі директорії - Буфер прокрутки терміналу (наскільки можливо) - URL браузера та історію навігації -cmux **не** відновлює стан активних процесів усередині термінальних додатків. Наприклад, активні сесії Claude Code/tmux/vim поки що не відновлюються після перезапуску. +cmux не створює checkpoint для довільного стану активних процесів. tmux, vim, shell і непідтримувані термінальні застосунки відкриваються заново як звичайні термінали. + +Підтримувані сесії агентів можуть відновлюватися, якщо hooks зберегли нативний ID сесії: + +```bash +cmux hooks setup +cmux hooks setup codex +cmux hooks setup --agent opencode +``` + +Досвідчені користувачі та інтеграції можуть прив'язати власну команду відновлення до поточної terminal surface. Це корисно для інструментів із власним тривалим станом, наприклад сесій tmux або власних agent CLI: + +```bash +cmux surface resume set --kind tmux --checkpoint work --shell "tmux attach -t work" +cmux surface resume show --json +cmux surface resume clear --checkpoint work +``` + +Прив'язка залишається пов'язаною з surface у cmux. Прив'язки, створені публічним CLI або socket, зберігаються для перевірки й ручного відновлення. cmux автоматично запускає лише прив'язки відновлення, які позначає як довірені, наприклад tmux-прив'язки, виявлені з живих процесів. Чутливі ключі середовища, як-от токени, паролі, секрети та API-ключі, відкидаються перед збереженням прив’язки відновлення. ## Історія зірок diff --git a/README.vi.md b/README.vi.md index 030f50ac6da1..686c3fe116d8 100644 --- a/README.vi.md +++ b/README.vi.md @@ -236,15 +236,33 @@ cmux NIGHTLY là một app riêng với bundle ID riêng, nên có thể chạy Báo lỗi nightly trên [GitHub Issues](https://github.com/manaflow-ai/cmux/issues) hoặc trong [#nightly-bugs trên Discord](https://discord.gg/xsgFEVrWCZ). -## Khôi phục phiên (hành vi hiện tại) +## Khôi phục phiên -Khi mở lại, cmux hiện chỉ khôi phục bố cục app và metadata: +Khi thoát, cmux lưu phiên hiện tại. Khi mở lại, cmux khôi phục trạng thái thuộc về app: - Bố cục cửa sổ/workspace/pane - Thư mục làm việc - Scrollback của terminal (cố gắng hết mức) - URL và lịch sử điều hướng của trình duyệt -cmux **không** khôi phục trạng thái tiến trình đang chạy bên trong terminal. Ví dụ, các phiên Claude Code/tmux/vim đang hoạt động chưa được khôi phục sau khi restart. +cmux không checkpoint trạng thái tiến trình đang chạy bất kỳ. tmux, vim, shell và app terminal chưa hỗ trợ sẽ mở lại như terminal bình thường. + +Các phiên agent được hỗ trợ có thể tiếp tục khi hooks đã lưu ID phiên gốc: + +```bash +cmux hooks setup +cmux hooks setup codex +cmux hooks setup --agent opencode +``` + +Người dùng nâng cao và tích hợp có thể gắn lệnh khôi phục tùy chỉnh vào surface terminal hiện tại. Điều này hữu ích cho công cụ có trạng thái bền riêng, như phiên tmux hoặc CLI agent tùy chỉnh: + +```bash +cmux surface resume set --kind tmux --checkpoint work --shell "tmux attach -t work" +cmux surface resume show --json +cmux surface resume clear --checkpoint work +``` + +Binding này vẫn gắn với surface của cmux. Binding tạo qua CLI công khai hoặc socket được lưu để kiểm tra và resume thủ công. cmux chỉ tự động chạy những resume binding được đánh dấu là đáng tin cậy, ví dụ binding tmux phát hiện từ process đang chạy. Các khóa môi trường nhạy cảm như token, mật khẩu, secret và API key sẽ bị loại bỏ trước khi lưu resume binding. ## Lịch sử sao diff --git a/README.zh-CN.md b/README.zh-CN.md index 71114c930094..cd17d292e064 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -236,15 +236,33 @@ cmux 是原语,而非解决方案。它提供终端、浏览器、通知、工 cmux NIGHTLY 是一个拥有独立 Bundle ID 的单独应用,因此可以与稳定版并行运行。它从最新的 `main` 提交自动构建,并通过独立的 Sparkle 更新源自动更新。 -## 会话恢复(当前行为) +## 会话恢复 -重新启动时,cmux 目前仅恢复应用布局和元数据: +退出 cmux 会保存当前会话。重新启动时,cmux 会恢复应用管理的状态: - 窗口/工作区/窗格布局 - 工作目录 - 终端回滚缓冲区(尽力恢复) - 浏览器 URL 和导航历史 -cmux **不会**恢复终端应用内部的实时进程状态。例如,活动的 Claude Code/tmux/vim 会话在重启后尚无法恢复。 +cmux 不会为任意实时进程状态做检查点。tmux、vim、shell 和不支持的终端应用会作为普通终端重新打开。 + +当 hooks 保存了原生会话 ID 时,受支持的 agent 会话可以恢复: + +```bash +cmux hooks setup +cmux hooks setup codex +cmux hooks setup --agent opencode +``` + +高级用户和集成可以把自定义恢复命令绑定到当前终端 surface。这适用于 tmux 会话或自定义 agent CLI 等拥有持久状态的工具: + +```bash +cmux surface resume set --kind tmux --checkpoint work --shell "tmux attach -t work" +cmux surface resume show --json +cmux surface resume clear --checkpoint work +``` + +这个绑定会继续关联到 cmux surface。通过公开 CLI 或 socket 创建的绑定会保存用于检查和手动恢复。cmux 只会自动运行被标记为可信的恢复绑定,例如从运行中进程检测到的 tmux 绑定。 令牌、密码、密钥和 API key 等敏感环境变量键会在保存恢复绑定前被丢弃。 ## Star History diff --git a/README.zh-TW.md b/README.zh-TW.md index 33d26c9c0bde..d904a23d80bb 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -236,15 +236,33 @@ cmux 是一個基礎元件,而非完整方案。它提供終端機、瀏覽器 cmux NIGHTLY 是一個獨立的應用程式,擁有自己的 bundle ID,因此可以與穩定版並行執行。每次從最新的 `main` 提交自動建構,並透過自己的 Sparkle 來源自動更新。 -## 工作階段還原(目前行為) +## 工作階段還原 -重新啟動時,cmux 目前僅還原應用程式佈局和中繼資料: +退出 cmux 會保存目前工作階段。重新啟動時,cmux 會還原應用程式管理的狀態: - 視窗/工作區/窗格佈局 - 工作目錄 - 終端機捲動緩衝區(盡力而為) - 瀏覽器 URL 和瀏覽歷程 -cmux **不會**還原終端機應用程式內的即時程序狀態。例如,活躍的 Claude Code/tmux/vim 工作階段在重新啟動後尚無法恢復。 +cmux 不會為任意即時程序狀態做檢查點。tmux、vim、shell 和未支援的終端機應用程式會以一般終端機重新開啟。 + +當 hooks 已保存原生工作階段 ID 時,支援的 agent 工作階段可以還原: + +```bash +cmux hooks setup +cmux hooks setup codex +cmux hooks setup --agent opencode +``` + +進階使用者和整合可以把自訂還原命令綁定到目前終端機 surface。這適合 tmux 工作階段或自訂 agent CLI 這類有持久狀態的工具: + +```bash +cmux surface resume set --kind tmux --checkpoint work --shell "tmux attach -t work" +cmux surface resume show --json +cmux surface resume clear --checkpoint work +``` + +這個綁定會繼續關聯到 cmux surface。透過公開 CLI 或 socket 建立的綁定會儲存供檢查與手動還原。cmux 只會自動執行標記為可信任的還原綁定,例如從執行中程序偵測到的 tmux 綁定。 權杖、密碼、祕密和 API key 等敏感環境變數鍵,會在儲存還原綁定前被丟棄。 ## Star 歷史 diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index a797e3f0dfa3..6d13a83e0c0c 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -106624,6 +106624,533 @@ } } }, + "surfaceResumeApproval.cwd.none": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "None" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "なし" + } + } + } + }, + "surfaceResumeApproval.proposal.ask": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ask Each Time" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "毎回確認" + } + } + } + }, + "surfaceResumeApproval.proposal.auto": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Auto-Restore" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動復元" + } + } + } + }, + "surfaceResumeApproval.proposal.manual": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Keep Manual" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "手動で保持" + } + } + } + }, + "surfaceResumeApproval.proposal.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A process wants cmux to keep this resume command for the current terminal:\n\n%@\n\nWorking directory: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プロセスが現在のターミナルにこの復元コマンドを保持しようとしています:\n\n%@\n\n作業ディレクトリ: %@" + } + } + } + }, + "surfaceResumeApproval.proposal.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Allow Resume Command?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "復元コマンドを許可しますか?" + } + } + } + }, + "surfaceResumeApproval.runPrompt.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux is restoring a terminal with this resume command:\n\n%@\n\nWorking directory: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux はこの復元コマンドでターミナルを復元しようとしています:\n\n%@\n\n作業ディレクトリ: %@" + } + } + } + }, + "surfaceResumeApproval.runPrompt.run": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Run" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "実行" + } + } + } + }, + "surfaceResumeApproval.runPrompt.skip": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Skip" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スキップ" + } + } + } + }, + "surfaceResumeApproval.runPrompt.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Run Resume Command?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "復元コマンドを実行しますか?" + } + } + } + }, + "settings.terminal.resumeCommands": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Resume Commands" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "復元コマンド" + } + } + } + }, + "settings.terminal.resumeCommands.cwdSummary": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Prefix: %@, cwd: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プレフィックス: %@、cwd: %@" + } + } + } + }, + "settings.terminal.resumeCommands.cwdOnlySummary": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cwd: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cwd: %@" + } + } + } + }, + "settings.terminal.resumeCommands.delete": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "削除" + } + } + } + }, + "settings.terminal.resumeCommands.deleteFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not delete the resume command approval." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "復元コマンドの承認を削除できませんでした。" + } + } + } + }, + "settings.terminal.resumeCommands.deleted": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Resume command approval deleted." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "復元コマンドの承認を削除しました。" + } + } + } + }, + "settings.terminal.resumeCommands.empty": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No custom resume commands have been approved." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "承認済みのカスタム復元コマンドはありません。" + } + } + } + }, + "settings.terminal.resumeCommands.envSummary": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "env: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "環境変数: %@" + } + } + } + }, + "settings.terminal.resumeCommands.invalidPrefix": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter a valid shell-style command prefix." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "有効なシェル形式のコマンドプレフィックスを入力してください。" + } + } + } + }, + "settings.terminal.resumeCommands.invalidSignature": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Signature does not match. Delete this approval and approve the command again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "署名が一致しません。この承認を削除してから、コマンドを再度承認してください。" + } + } + } + }, + "settings.terminal.resumeCommands.policy.auto": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Auto" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動" + } + } + } + }, + "settings.terminal.resumeCommands.policy.manual": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Manual" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "手動" + } + } + } + }, + "settings.terminal.resumeCommands.policy.prompt": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ask" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "確認" + } + } + } + }, + "settings.terminal.resumeCommands.prefixPlaceholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Command prefix" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コマンドプレフィックス" + } + } + } + }, + "settings.terminal.resumeCommands.prefixSummary": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Prefix: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プレフィックス: %@" + } + } + } + }, + "settings.terminal.resumeCommands.save": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Save" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "保存" + } + } + } + }, + "settings.terminal.resumeCommands.sourceUnknown": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unknown source" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不明なソース" + } + } + } + }, + "settings.terminal.resumeCommands.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Review signed command prefixes that can restore non-agent terminal surfaces." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "非エージェントのターミナルサーフェスを復元できる署名済みコマンドプレフィックスを確認します。" + } + } + } + }, + "settings.terminal.resumeCommands.updateFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not update the resume command approval." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "復元コマンドの承認を更新できませんでした。" + } + } + } + }, + "settings.terminal.resumeCommands.updated": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Resume command approval updated." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "復元コマンドの承認を更新しました。" + } + } + } + }, + "settings.search.alias.setting.terminal.resume-commands": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "surface resume commands approvals command prefixes auto restore ask manual tmux hibernation sticky process" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "surface resume commands approvals command prefixes auto restore ask manual tmux hibernation sticky process" + } + } + } + }, "settings.app.preferredEditor.subtitle": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index fc20f54bebac..c0ecc5ad8ff1 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -865,6 +865,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var sessionAutosaveTimer: DispatchSourceTimer? private var sessionAutosaveTickInFlight = false private var sessionAutosaveDeferredRetryPending = false + private var processDetectedSessionSaveGeneration: UInt64 = 0 private let sessionPersistenceQueue = DispatchQueue( label: "com.cmuxterm.app.sessionPersistence", qos: .utility @@ -1535,7 +1536,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ] ) isTerminatingApp = true - _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) + _ = saveSessionSnapshotIncludingProcessDetectedIndexes(includeScrollback: true, removeWhenEmpty: false) // Tagged DEV builds are ephemeral, skip quit confirmation entirely. if SocketControlSettings.isTaggedDevBuild() { @@ -1602,7 +1603,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent StartupBreadcrumbLog.append("appDelegate.willTerminate.begin") isTerminatingApp = true closeAllWebInspectorsBeforeAppTeardown() - _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) + _ = saveSessionSnapshotIncludingProcessDetectedIndexes(includeScrollback: true, removeWhenEmpty: false) stopSessionAutosaveTimer() CloudVMActionLauncher.shared.terminateAll() CmuxSSHURLProcessLauncher.shared.terminateAll() @@ -1624,12 +1625,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent func applicationWillResignActive(_ notification: Notification) { guard !isTerminatingApp else { return } clearConfiguredShortcutChordState() - _ = saveSessionSnapshot(includeScrollback: false) + saveSessionSnapshotAfterLoadingProcessDetectedIndexes(includeScrollback: false) } func persistSessionForUpdateRelaunch() { isTerminatingApp = true - _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) + _ = saveSessionSnapshotIncludingProcessDetectedIndexes(includeScrollback: true, removeWhenEmpty: false) } func configure(tabManager: TabManager, notificationStore: TerminalNotificationStore, sidebarState: SidebarState) { @@ -2785,11 +2786,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) } - private func attemptStartupSessionRestoreIfNeeded(primaryWindow: NSWindow) { - guard !didAttemptStartupSessionRestore else { return } + @discardableResult + private func attemptStartupSessionRestoreIfNeeded(primaryWindow: NSWindow) -> Bool { + guard !didAttemptStartupSessionRestore else { return false } didAttemptStartupSessionRestore = true - guard !didHandleExplicitOpenIntentAtStartup else { return } - guard let primaryContext = contextForMainTerminalWindow(primaryWindow) else { return } + guard !didHandleExplicitOpenIntentAtStartup else { return false } + guard let primaryContext = contextForMainTerminalWindow(primaryWindow) else { return false } let startupSnapshot = startupSessionSnapshot let primaryWindowSnapshot = startupSnapshot?.windows.first @@ -2821,38 +2823,41 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } - if let startupSnapshot { - let additionalWindows = Array(startupSnapshot - .windows - .dropFirst() - .prefix(max(0, SessionPersistencePolicy.maxWindowsPerSnapshot - 1))) + guard let startupSnapshot else { return false } + + let additionalWindows = Array(startupSnapshot + .windows + .dropFirst() + .prefix(max(0, SessionPersistencePolicy.maxWindowsPerSnapshot - 1))) #if DEBUG - for (index, windowSnapshot) in additionalWindows.enumerated() { - cmuxDebugLog( - "session.restore.enqueueAdditional idx=\(index + 1) " + - "frame={\(debugSessionRectDescription(windowSnapshot.frame))} " + - "display={\(debugSessionDisplayDescription(windowSnapshot.display))}" - ) - } + for (index, windowSnapshot) in additionalWindows.enumerated() { + cmuxDebugLog( + "session.restore.enqueueAdditional idx=\(index + 1) " + + "frame={\(debugSessionRectDescription(windowSnapshot.frame))} " + + "display={\(debugSessionDisplayDescription(windowSnapshot.display))}" + ) + } #endif - if !additionalWindows.isEmpty { - DispatchQueue.main.async { [weak self] in - guard let self else { return } - for windowSnapshot in additionalWindows { - _ = self.createMainWindow(sessionWindowSnapshot: windowSnapshot) - } - self.completeSessionRestoreOperation(isManualReopen: false) + if !additionalWindows.isEmpty { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + for windowSnapshot in additionalWindows { + _ = self.createMainWindow(sessionWindowSnapshot: windowSnapshot) } - } else { - completeSessionRestoreOperation(isManualReopen: false) + self.completeSessionRestoreOperation(isManualReopen: false) } + } else { + completeSessionRestoreOperation(isManualReopen: false) } + return true } private func completeSessionRestoreOperation(isManualReopen: Bool) { startupSessionSnapshot = nil isApplyingSessionRestore = false if Self.shouldSaveSessionSnapshotOnRestoreCompletion(isManualReopen: isManualReopen) { + // Auto-resume input can be queued before tmux has spawned; preserve + // restored process-detected bindings until a later live scan. _ = saveSessionSnapshot(includeScrollback: false) } } @@ -3345,7 +3350,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent Task { @MainActor [weak self] in guard let self else { return } self.isTerminatingApp = true - _ = self.saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) + _ = self.saveSessionSnapshotIncludingProcessDetectedIndexes(includeScrollback: true, removeWhenEmpty: false) } } lifecycleSnapshotObservers.append(powerOffObserver) @@ -3358,9 +3363,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent Task { @MainActor [weak self] in guard let self else { return } if self.isTerminatingApp { - _ = self.saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) + _ = self.saveSessionSnapshotIncludingProcessDetectedIndexes(includeScrollback: true, removeWhenEmpty: false) } else { - _ = self.saveSessionSnapshot(includeScrollback: false) + self.saveSessionSnapshotAfterLoadingProcessDetectedIndexes(includeScrollback: false) } } } @@ -3428,7 +3433,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func sessionAutosaveFingerprint( includeScrollback: Bool, - restorableAgentIndex: RestorableAgentSessionIndex + restorableAgentIndex: RestorableAgentSessionIndex, + surfaceResumeBindingIndex: SurfaceResumeBindingIndex ) -> Int? { guard !includeScrollback else { return nil } @@ -3442,7 +3448,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent hasher.combine(context.windowId) hasher.combine( context.tabManager.sessionAutosaveFingerprint( - restorableAgentIndex: restorableAgentIndex + restorableAgentIndex: restorableAgentIndex, + surfaceResumeBindingIndex: surfaceResumeBindingIndex ) ) hasher.combine(context.sidebarState.isVisible) @@ -3471,7 +3478,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func saveSessionSnapshot( includeScrollback: Bool, removeWhenEmpty: Bool = false, - restorableAgentIndex: RestorableAgentSessionIndex? = nil + restorableAgentIndex: RestorableAgentSessionIndex? = nil, + surfaceResumeBindingIndex: SurfaceResumeBindingIndex? = nil ) -> Bool { if Self.shouldSkipSessionSaveDuringRestore( isApplyingSessionRestore: isApplyingSessionRestore, @@ -3482,7 +3490,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #endif return false } - let writeSynchronously = Self.shouldWriteSessionSnapshotSynchronously( isTerminatingApp: isTerminatingApp, includeScrollback: includeScrollback @@ -3500,7 +3507,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard let snapshot = buildSessionSnapshot( includeScrollback: includeScrollback, - restorableAgentIndex: restorableAgentIndex + restorableAgentIndex: restorableAgentIndex, + surfaceResumeBindingIndex: surfaceResumeBindingIndex ) else { persistSessionSnapshot( nil, @@ -3560,6 +3568,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) } + func debugBuildSessionSnapshotForTesting( + includeScrollback: Bool, + surfaceResumeBindingIndex: SurfaceResumeBindingIndex? = nil + ) -> AppSessionSnapshot? { + buildSessionSnapshot( + includeScrollback: includeScrollback, + surfaceResumeBindingIndex: surfaceResumeBindingIndex + ) + } + func debugSeedSessionSnapshotScrollback(charactersPerTerminal: Int) -> [String: Any] { let workspaces = sortedMainWindowContextsForSessionSnapshot().flatMap { context in context.tabManager.tabs.filter { !$0.isRemoteWorkspace } @@ -3575,6 +3593,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent !isTerminatingApp } + nonisolated static func shouldSaveSessionSnapshotAfterMainWindowRegistration( + isTerminatingApp: Bool, + didApplyStartupSessionRestore: Bool, + isApplyingSessionRestore: Bool + ) -> Bool { + !isTerminatingApp && !didApplyStartupSessionRestore && !isApplyingSessionRestore + } + nonisolated static func shouldSkipSessionSaveDuringRestore( isApplyingSessionRestore: Bool, includeScrollback: Bool @@ -3623,10 +3649,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } sessionAutosaveTickInFlight = true - Task { @MainActor in await self.finishSessionAutosaveTick(source: source) } + let generation = nextProcessDetectedSessionSaveGeneration() + Task { @MainActor in await self.finishSessionAutosaveTick(source: source, generation: generation) } } - private func finishSessionAutosaveTick(source: String) async { + private func finishSessionAutosaveTick(source: String, generation: UInt64) async { #if DEBUG let timingStart = CmuxTypingTiming.start() let phaseStart = ProcessInfo.processInfo.systemUptime @@ -3659,10 +3686,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #if DEBUG let fingerprintStart = ProcessInfo.processInfo.systemUptime #endif - let restorableAgentIndex = await RestorableAgentSessionIndex.loadIncludingProcessDetectedSnapshots() + let resumeIndexes = await ProcessDetectedResumeIndexes.load() + guard !isTerminatingApp, + isCurrentProcessDetectedSessionSaveGeneration(generation) else { +#if DEBUG + cmuxDebugLog( + "session.save.skipped reason=stale_process_detected_scan includeScrollback=0 source=\(source)" + ) +#endif + return + } let autosaveFingerprint = sessionAutosaveFingerprint( includeScrollback: false, - restorableAgentIndex: restorableAgentIndex + restorableAgentIndex: resumeIndexes.restorableAgentIndex, + surfaceResumeBindingIndex: resumeIndexes.surfaceResumeBindingIndex ) #if DEBUG fingerprintMs = (ProcessInfo.processInfo.systemUptime - fingerprintStart) * 1000.0 @@ -3688,7 +3725,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #endif _ = saveSessionSnapshot( includeScrollback: false, - restorableAgentIndex: restorableAgentIndex + restorableAgentIndex: resumeIndexes.restorableAgentIndex, + surfaceResumeBindingIndex: resumeIndexes.surfaceResumeBindingIndex ) #if DEBUG saveMs = (ProcessInfo.processInfo.systemUptime - saveStart) * 1000.0 @@ -3700,6 +3738,49 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) } + @discardableResult + private func saveSessionSnapshotIncludingProcessDetectedIndexes( + includeScrollback: Bool, + removeWhenEmpty: Bool = false + ) -> Bool { + let resumeIndexes = ProcessDetectedResumeIndexes.loadSynchronously() + return saveSessionSnapshot( + includeScrollback: includeScrollback, + removeWhenEmpty: removeWhenEmpty, + restorableAgentIndex: resumeIndexes.restorableAgentIndex, + surfaceResumeBindingIndex: resumeIndexes.surfaceResumeBindingIndex + ) + } + + private func saveSessionSnapshotAfterLoadingProcessDetectedIndexes( + includeScrollback: Bool, + removeWhenEmpty: Bool = false + ) { + let generation = nextProcessDetectedSessionSaveGeneration() + Task { @MainActor [weak self] in + let resumeIndexes = await ProcessDetectedResumeIndexes.load() + guard let self, + !self.isTerminatingApp, + self.isCurrentProcessDetectedSessionSaveGeneration(generation) else { return } + _ = self.saveSessionSnapshot( + includeScrollback: includeScrollback, + removeWhenEmpty: removeWhenEmpty, + restorableAgentIndex: resumeIndexes.restorableAgentIndex, + surfaceResumeBindingIndex: resumeIndexes.surfaceResumeBindingIndex + ) + } + } + + @discardableResult + private func nextProcessDetectedSessionSaveGeneration() -> UInt64 { + processDetectedSessionSaveGeneration &+= 1 + return processDetectedSessionSaveGeneration + } + + private func isCurrentProcessDetectedSessionSaveGeneration(_ generation: UInt64) -> Bool { + generation == processDetectedSessionSaveGeneration + } + fileprivate func recordTypingActivity() { lastTypingActivityAt = ProcessInfo.processInfo.systemUptime } @@ -3797,7 +3878,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func buildSessionSnapshot( includeScrollback: Bool, - restorableAgentIndex suppliedRestorableAgentIndex: RestorableAgentSessionIndex? = nil + restorableAgentIndex suppliedRestorableAgentIndex: RestorableAgentSessionIndex? = nil, + surfaceResumeBindingIndex suppliedSurfaceResumeBindingIndex: SurfaceResumeBindingIndex? = nil ) -> AppSessionSnapshot? { let contexts = sortedMainWindowContextsForSessionSnapshot() @@ -3813,7 +3895,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent display: displaySnapshot(for: window), tabManager: context.tabManager.sessionSnapshot( includeScrollback: includeScrollback, - restorableAgentIndex: restorableAgentIndex + restorableAgentIndex: restorableAgentIndex, + surfaceResumeBindingIndex: suppliedSurfaceResumeBindingIndex ), sidebar: SessionSidebarSnapshot( isVisible: context.sidebarState.isVisible, @@ -3983,9 +4066,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent setActiveMainWindow(window) } - attemptStartupSessionRestoreIfNeeded(primaryWindow: window) - if !isTerminatingApp { - _ = saveSessionSnapshot(includeScrollback: false) + let didApplyStartupSessionRestore = attemptStartupSessionRestoreIfNeeded(primaryWindow: window) + if Self.shouldSaveSessionSnapshotAfterMainWindowRegistration( + isTerminatingApp: isTerminatingApp, + didApplyStartupSessionRestore: didApplyStartupSessionRestore, + isApplyingSessionRestore: isApplyingSessionRestore + ) { + saveSessionSnapshotAfterLoadingProcessDetectedIndexes(includeScrollback: false) } } @@ -14224,7 +14311,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // in applicationShouldTerminate/applicationWillTerminate. Saving again here would // overwrite it as windows tear down one-by-one, dropping closed windows and replay. if Self.shouldPersistSnapshotOnWindowUnregister(isTerminatingApp: isTerminatingApp) { - _ = saveSessionSnapshot(includeScrollback: false, removeWhenEmpty: false) + saveSessionSnapshotAfterLoadingProcessDetectedIndexes(includeScrollback: false, removeWhenEmpty: false) } } diff --git a/Sources/CmuxTopSnapshot.swift b/Sources/CmuxTopSnapshot.swift index c6f7514d6c71..0e48118d0fd0 100644 --- a/Sources/CmuxTopSnapshot.swift +++ b/Sources/CmuxTopSnapshot.swift @@ -112,6 +112,11 @@ nonisolated struct CmuxTopProcessInfo: Sendable { self.virtualBytes = virtualBytes self.threadCount = threadCount } + + var isTerminalForegroundProcessGroup: Bool { + guard let processGroupID, let terminalProcessGroupID else { return false } + return processGroupID == terminalProcessGroupID + } } nonisolated struct CmuxTopProcessScope: Sendable { @@ -344,12 +349,10 @@ nonisolated final class CmuxTopProcessSnapshot: @unchecked Sendable { Set( pids.compactMap { pid in guard let process = processesByPID[pid], - let processGroupID = process.processGroupID, - let foregroundGroupID = process.terminalProcessGroupID, - processGroupID == foregroundGroupID else { + process.isTerminalForegroundProcessGroup else { return nil } - return foregroundGroupID + return process.terminalProcessGroupID } ) } diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 669f7203b82b..1779e9183666 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -5246,6 +5246,11 @@ final class TerminalSurface: Identifiable, ObservableObject { desiredFocusState } + @MainActor + func debugAdditionalEnvironmentForTesting() -> [String: String] { + additionalEnvironment + } + func debugForceRefreshCount() -> Int { debugForceRefreshCountLock.lock() defer { debugForceRefreshCountLock.unlock() } diff --git a/Sources/GhosttyTerminalViewSupport.swift b/Sources/GhosttyTerminalViewSupport.swift index b7e6a0b0aacf..085d34d1d24a 100644 --- a/Sources/GhosttyTerminalViewSupport.swift +++ b/Sources/GhosttyTerminalViewSupport.swift @@ -43,4 +43,8 @@ extension TerminalSurface { let byteCount = initialInput?.utf8.count ?? 0 return (byteCount > 0, byteCount) } + + func debugInitialInputForTesting() -> String? { + initialInput + } } diff --git a/Sources/RestorableAgentSession.swift b/Sources/RestorableAgentSession.swift index 6847f3c927cf..e24cb6b469d5 100644 --- a/Sources/RestorableAgentSession.swift +++ b/Sources/RestorableAgentSession.swift @@ -163,6 +163,17 @@ enum AgentResumeCommandBuilder { } guard let preserved = AgentLaunchSanitizer.preservedArguments(kind: "claude", args: args) else { return nil } return [original.executable, "claude-teams", "--resume", sessionId] + preserved + case "codexTeams": + let original = commandParts( + launchCommand: launchCommand, + fallbackExecutable: "cmux" + ) + var args = original.tail + if args.first == "codex-teams" { + args.removeFirst() + } + guard let preserved = AgentLaunchSanitizer.preservedCodexForkArguments(args: args) else { return nil } + return [original.executable, "codex-teams", "resume", sessionId] + preserved case "omo": let original = commandParts( launchCommand: launchCommand, @@ -202,7 +213,7 @@ enum AgentResumeCommandBuilder { ) case .codex: let original = commandParts(launchCommand: launchCommand, fallbackExecutable: "codex") - guard let preserved = AgentLaunchSanitizer.preservedArguments(kind: "codex", args: original.tail) else { return nil } + guard let preserved = AgentLaunchSanitizer.preservedCodexForkArguments(args: original.tail) else { return nil } return [original.executable, "resume", sessionId] + preserved case .pi: return resumeWithOption( @@ -649,9 +660,10 @@ struct RestorableAgentSessionIndex: Sendable { } private let snapshotsByPanel: [PanelKey: SessionRestorableAgentSnapshot] + private let snapshotsByPanelId: [UUID: SessionRestorableAgentSnapshot] func snapshot(workspaceId: UUID, panelId: UUID) -> SessionRestorableAgentSnapshot? { - snapshotsByPanel[PanelKey(workspaceId: workspaceId, panelId: panelId)] + snapshotsByPanel[PanelKey(workspaceId: workspaceId, panelId: panelId)] ?? snapshotsByPanelId[panelId] } static func load( @@ -672,20 +684,30 @@ struct RestorableAgentSessionIndex: Sendable { fileManager: FileManager = .default ) async -> RestorableAgentSessionIndex { await Task.detached(priority: .utility) { - let registry = CmuxVaultAgentRegistry.load(homeDirectory: homeDirectory, fileManager: fileManager) - let detectedSnapshots = processDetectedSnapshots( - registry: registry, - fileManager: fileManager - ) - return load( + loadIncludingProcessDetectedSnapshotsSynchronously( homeDirectory: homeDirectory, - fileManager: fileManager, - registry: registry, - detectedSnapshots: detectedSnapshots + fileManager: fileManager ) }.value } + static func loadIncludingProcessDetectedSnapshotsSynchronously( + homeDirectory: String = NSHomeDirectory(), + fileManager: FileManager = .default + ) -> RestorableAgentSessionIndex { + let registry = CmuxVaultAgentRegistry.load(homeDirectory: homeDirectory, fileManager: fileManager) + let detectedSnapshots = processDetectedSnapshots( + registry: registry, + fileManager: fileManager + ) + return load( + homeDirectory: homeDirectory, + fileManager: fileManager, + registry: registry, + detectedSnapshots: detectedSnapshots + ) + } + static func load( homeDirectory: String, fileManager: FileManager, @@ -758,7 +780,7 @@ struct RestorableAgentSessionIndex: Sendable { resolved[key] = detected } - return RestorableAgentSessionIndex(snapshotsByPanel: resolved.mapValues(\.snapshot)) + return RestorableAgentSessionIndex(snapshotsByPanel: resolved) } private static func normalizedWorkingDirectory(_ rawValue: String?) -> String? { @@ -1001,8 +1023,100 @@ struct RestorableAgentSessionIndex: Sendable { return rawValue } - private init(snapshotsByPanel: [PanelKey: SessionRestorableAgentSnapshot]) { - self.snapshotsByPanel = snapshotsByPanel + private init(snapshotsByPanel: [PanelKey: (snapshot: SessionRestorableAgentSnapshot, updatedAt: TimeInterval)]) { + self.snapshotsByPanel = snapshotsByPanel.mapValues(\.snapshot) + var snapshotsByPanelId: [UUID: (snapshot: SessionRestorableAgentSnapshot, updatedAt: TimeInterval)] = [:] + for (key, value) in snapshotsByPanel { + let existing = snapshotsByPanelId[key.panelId] + if existing == nil || value.updatedAt >= (existing?.updatedAt ?? 0) { + snapshotsByPanelId[key.panelId] = value + } + } + self.snapshotsByPanelId = snapshotsByPanelId.mapValues(\.snapshot) + } +} + +nonisolated struct SurfaceResumeBindingIndex: Sendable { + static let empty = SurfaceResumeBindingIndex(bindingsByPanel: [:]) + + typealias PanelKey = RestorableAgentSessionIndex.PanelKey + + private let bindingsByPanel: [PanelKey: SurfaceResumeBindingSnapshot] + private let bindingsByPanelId: [UUID: SurfaceResumeBindingSnapshot] + + init(bindingsByPanel: [PanelKey: SurfaceResumeBindingSnapshot]) { + self.bindingsByPanel = bindingsByPanel + var bindingsByPanelId: [UUID: SurfaceResumeBindingSnapshot] = [:] + for (key, binding) in bindingsByPanel { + let existing = bindingsByPanelId[key.panelId] + if existing == nil || binding.updatedAt >= (existing?.updatedAt ?? 0) { + bindingsByPanelId[key.panelId] = binding + } + } + self.bindingsByPanelId = bindingsByPanelId + } + + func binding(workspaceId: UUID, panelId: UUID) -> SurfaceResumeBindingSnapshot? { + bindingsByPanel[PanelKey(workspaceId: workspaceId, panelId: panelId)] ?? bindingsByPanelId[panelId] + } + + static func loadProcessDetectedBindingsSynchronously( + fileManager: FileManager = .default + ) -> SurfaceResumeBindingIndex { + let detectedBindings = processDetectedTmuxBindings(fileManager: fileManager) + return SurfaceResumeBindingIndex(bindingsByPanel: detectedBindings.mapValues(\.binding)) + } + + static func loadIncludingProcessDetectedBindings( + fileManager: FileManager = .default + ) async -> SurfaceResumeBindingIndex { + await Task.detached(priority: .utility) { + loadProcessDetectedBindingsSynchronously(fileManager: fileManager) + }.value + } +} + +struct ProcessDetectedResumeIndexes: Sendable { + let restorableAgentIndex: RestorableAgentSessionIndex + let surfaceResumeBindingIndex: SurfaceResumeBindingIndex + + static func load( + homeDirectory: String = NSHomeDirectory(), + fileManager: FileManager = .default + ) async -> ProcessDetectedResumeIndexes { + await Task.detached(priority: .utility) { + loadSynchronously(homeDirectory: homeDirectory, fileManager: fileManager) + }.value + } + + static func loadSynchronously( + homeDirectory: String = NSHomeDirectory(), + fileManager: FileManager = .default + ) -> ProcessDetectedResumeIndexes { + let capturedAt = Date().timeIntervalSince1970 + let processSnapshot = CmuxTopProcessSnapshot.capture(includeProcessDetails: true) + let registry = CmuxVaultAgentRegistry.load(homeDirectory: homeDirectory, fileManager: fileManager) + let detectedSnapshots = RestorableAgentSessionIndex.processDetectedSnapshots( + registry: registry, + fileManager: fileManager, + processSnapshot: processSnapshot, + capturedAt: capturedAt + ) + let restorableAgentIndex = RestorableAgentSessionIndex.load( + homeDirectory: homeDirectory, + fileManager: fileManager, + registry: registry, + detectedSnapshots: detectedSnapshots + ) + let detectedBindings = SurfaceResumeBindingIndex.processDetectedTmuxBindings( + fileManager: fileManager, + processSnapshot: processSnapshot, + capturedAt: capturedAt + ) + return ProcessDetectedResumeIndexes( + restorableAgentIndex: restorableAgentIndex, + surfaceResumeBindingIndex: SurfaceResumeBindingIndex(bindingsByPanel: detectedBindings.mapValues(\.binding)) + ) } } diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index 5f28ea798ace..82a033677987 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -1,6 +1,12 @@ import CoreGraphics import Foundation import Bonsplit +#if canImport(CryptoKit) +import CryptoKit +#endif +#if canImport(Security) +import Security +#endif enum SessionSnapshotSchema { static let currentVersion = 1 @@ -221,11 +227,775 @@ struct SessionGitBranchSnapshot: Codable, Sendable { var isDirty: Bool } +enum SurfaceResumeApprovalPolicy: String, Codable, CaseIterable, Sendable { + case manual + case prompt + case auto +} + +nonisolated struct SurfaceResumeBindingSnapshot: Codable, Equatable, Sendable { + var name: String? + var kind: String? + var command: String + var cwd: String? + var checkpointId: String? + var source: String? + var environment: [String: String]? + var autoResume: Bool? + var approvalPolicy: SurfaceResumeApprovalPolicy? + var approvalRecordId: String? + var updatedAt: TimeInterval + + init( + name: String? = nil, + kind: String? = nil, + command: String, + cwd: String? = nil, + checkpointId: String? = nil, + source: String? = nil, + environment: [String: String]? = nil, + autoResume: Bool? = nil, + approvalPolicy: SurfaceResumeApprovalPolicy? = nil, + approvalRecordId: String? = nil, + updatedAt: TimeInterval = Date().timeIntervalSince1970 + ) { + self.name = Self.normalized(name) + self.kind = Self.normalized(kind) + self.command = command.trimmingCharacters(in: .whitespacesAndNewlines) + self.cwd = Self.normalized(cwd) + self.checkpointId = Self.normalized(checkpointId) + self.source = Self.normalized(source) + self.environment = Self.normalizedEnvironment(environment) + self.autoResume = autoResume + self.approvalPolicy = approvalPolicy + self.approvalRecordId = Self.normalized(approvalRecordId) + self.updatedAt = updatedAt + } + + var isProcessDetected: Bool { + source == "process-detected" + } + + var isAgentHookBinding: Bool { + source == "agent-hook" + } + + var allowsAutomaticResume: Bool { + autoResume == true + } + + func shouldYieldToDetectedSurfaceResumeBinding(_ detectedBinding: SurfaceResumeBindingSnapshot) -> Bool { + detectedBinding.isProcessDetected && (isProcessDetected || isAgentHookBinding) + } + + static let maxInlineStartupInputBytes = SessionRestorableAgentSnapshot.maxInlineStartupInputBytes + + var startupInput: String? { + inlineStartupInput + } + + var inlineStartupInput: String? { + let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + guard let environment, !environment.isEmpty else { + return trimmed + "\n" + } + let assignments = environment.keys.sorted().compactMap { key -> String? in + guard let value = environment[key] else { return nil } + return "\(key)=\(value)" + } + let argv = ["/usr/bin/env"] + assignments + ["/bin/zsh", "-lc", trimmed] + return argv.map(Self.shellSingleQuoted).joined(separator: " ") + "\n" + } + + func startupInputWithLauncherScript( + fileManager: FileManager = .default, + temporaryDirectory: URL = FileManager.default.temporaryDirectory, + allowLauncherScript: Bool = true + ) -> String? { + guard let inlineInput = inlineStartupInput else { return nil } + guard inlineInput.utf8.count > Self.maxInlineStartupInputBytes else { + return inlineInput + } + guard allowLauncherScript else { return inlineInput } + guard let scriptURL = SurfaceResumeBindingScriptStore.writeLauncherScript( + inlineInput: inlineInput, + binding: self, + fileManager: fileManager, + temporaryDirectory: temporaryDirectory + ) else { + return nil + } + + let scriptInput = "/bin/zsh \(Self.shellSingleQuoted(scriptURL.path))\n" + return scriptInput.utf8.count <= Self.maxInlineStartupInputBytes ? scriptInput : nil + } + + private static func normalized(_ rawValue: String?) -> String? { + guard let rawValue = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines), + !rawValue.isEmpty else { + return nil + } + return rawValue + } + + private static func normalizedEnvironment(_ environment: [String: String]?) -> [String: String]? { + guard let environment else { return nil } + let normalized = environment.reduce(into: [String: String]()) { result, item in + let key = item.key.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty, !isSensitiveEnvironmentKey(key) else { return } + guard isSafeEnvironmentValue(item.value) else { return } + result[key] = item.value + } + return normalized.isEmpty ? nil : normalized + } + + private static func isSafeEnvironmentValue(_ value: String) -> Bool { + !value.unicodeScalars.contains { $0.value < 0x20 || $0.value == 0x7F } + } + + private static func isSensitiveEnvironmentKey(_ key: String) -> Bool { + let uppercasedKey = key.uppercased() + let sensitiveFragments = [ + "API_KEY", + "ACCESS_KEY", + "AUTH_TOKEN", + "BEARER_TOKEN", + "PRIVATE_KEY", + "PASSWORD", + "PASSWD", + "SECRET", + "TOKEN", + "CREDENTIAL", + "COOKIE", + ] + return sensitiveFragments.contains { uppercasedKey.contains($0) } + } + + private static func shellSingleQuoted(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } +} + +nonisolated struct SurfaceResumeApprovalRecord: Codable, Equatable, Identifiable, Sendable { + var version: Int + var id: String + var name: String? + var commandPrefix: [String] + var cwd: String? + var environment: [String: String]? + var environmentKeys: [String] + var source: String? + var policy: SurfaceResumeApprovalPolicy + var createdAt: TimeInterval + var updatedAt: TimeInterval + var lastUsedAt: TimeInterval? + var signature: String? + + init( + id: String = UUID().uuidString.lowercased(), + name: String? = nil, + commandPrefix: [String], + cwd: String? = nil, + environment: [String: String]? = nil, + environmentKeys: [String] = [], + source: String? = nil, + policy: SurfaceResumeApprovalPolicy, + createdAt: TimeInterval = Date().timeIntervalSince1970, + updatedAt: TimeInterval = Date().timeIntervalSince1970, + lastUsedAt: TimeInterval? = nil, + signature: String? = nil + ) { + self.version = 1 + self.id = id + self.name = Self.normalized(name) + self.commandPrefix = commandPrefix.filter { !$0.isEmpty } + self.cwd = SurfaceResumeCommandCanonicalizer.normalizedCWD(cwd) + self.environment = Self.normalizedEnvironment(environment) + self.environmentKeys = Self.normalizedEnvironmentKeys(environmentKeys, environment: self.environment) + self.source = Self.normalized(source) + self.policy = policy + self.createdAt = createdAt + self.updatedAt = updatedAt + self.lastUsedAt = lastUsedAt + self.signature = Self.normalized(signature) + } + + var commandPrefixText: String { + commandPrefix.map(SurfaceResumeCommandCanonicalizer.shellQuoted).joined(separator: " ") + } + + func matches(_ binding: SurfaceResumeBindingSnapshot) -> Bool { + guard !commandPrefix.isEmpty, + let tokens = SurfaceResumeCommandCanonicalizer.tokens(from: binding.command), + tokens.count >= commandPrefix.count, + Array(tokens.prefix(commandPrefix.count)) == commandPrefix else { + return false + } + if let cwd { + guard SurfaceResumeCommandCanonicalizer.normalizedCWD(binding.cwd) == cwd else { + return false + } + } + let bindingEnvironment = binding.environment ?? [:] + guard let environment, !environment.isEmpty else { + return bindingEnvironment.isEmpty + } + return bindingEnvironment == environment + } + + func signingPayloadData() -> Data { + let encodedPrefix = commandPrefix + .map { Data($0.utf8).base64EncodedString() } + .joined(separator: ",") + let encodedEnvironmentKeys = environmentKeys + .map { Data($0.utf8).base64EncodedString() } + .joined(separator: ",") + let encodedEnvironment = (environment ?? [:]) + .keys + .sorted() + .map { key in + let value = environment?[key] ?? "" + return "\(Data(key.utf8).base64EncodedString())=\(Data(value.utf8).base64EncodedString())" + } + .joined(separator: ",") + let fields = [ + "version=\(version)", + "id=\(id)", + "name=\(name.map { Data($0.utf8).base64EncodedString() } ?? "")", + "commandPrefix=\(encodedPrefix)", + "cwd=\(cwd.map { Data($0.utf8).base64EncodedString() } ?? "")", + "environment=\(encodedEnvironment)", + "environmentKeys=\(encodedEnvironmentKeys)", + "source=\(source.map { Data($0.utf8).base64EncodedString() } ?? "")", + "policy=\(policy.rawValue)", + "createdAt=\(createdAt)", + "updatedAt=\(updatedAt)", + "lastUsedAt=\(lastUsedAt.map { String($0) } ?? "")", + ] + return fields.joined(separator: "\n").data(using: .utf8) ?? Data() + } + + func signed(secret: Data) -> SurfaceResumeApprovalRecord { + var copy = self + copy.signature = SurfaceResumeApprovalSignature.sign(copy.signingPayloadData(), secret: secret) + return copy + } + + func hasValidSignature(secret: Data) -> Bool { + guard let signature else { return false } + return SurfaceResumeApprovalSignature.sign(signingPayloadData(), secret: secret) == signature + } + + private static func normalized(_ rawValue: String?) -> String? { + guard let rawValue = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines), + !rawValue.isEmpty else { + return nil + } + return rawValue + } + + private static func normalizedEnvironment(_ environment: [String: String]?) -> [String: String]? { + guard let environment else { return nil } + let normalized = environment.reduce(into: [String: String]()) { result, item in + let key = item.key.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty else { return } + guard isSafeEnvironmentValue(item.value) else { return } + result[key] = item.value + } + return normalized.isEmpty ? nil : normalized + } + + private static func isSafeEnvironmentValue(_ value: String) -> Bool { + !value.unicodeScalars.contains { $0.value < 0x20 || $0.value == 0x7F } + } + + private static func normalizedEnvironmentKeys( + _ environmentKeys: [String], + environment: [String: String]? + ) -> [String] { + let explicitKeys = environmentKeys + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + let environmentDerivedKeys: [String] = environment.map { Array($0.keys) } ?? [] + return Array(Set(explicitKeys + environmentDerivedKeys)).sorted() + } +} + +enum SurfaceResumeCommandCanonicalizer { + static func tokens(from command: String) -> [String]? { + let scalars = Array(command.unicodeScalars) + var tokens: [String] = [] + var token = String.UnicodeScalarView() + var index = 0 + var quote: UnicodeScalar? + + func flushToken() { + guard !token.isEmpty else { return } + tokens.append(String(token)) + token.removeAll(keepingCapacity: true) + } + + while index < scalars.count { + let scalar = scalars[index] + if let activeQuote = quote { + if scalar == activeQuote { + quote = nil + } else if activeQuote == "\"", scalar == "\\", index + 1 < scalars.count { + index += 1 + token.append(scalars[index]) + } else { + token.append(scalar) + } + } else if scalar == "'" || scalar == "\"" { + quote = scalar + } else if CharacterSet.whitespacesAndNewlines.contains(scalar) { + flushToken() + } else if scalar == "\\", index + 1 < scalars.count { + index += 1 + token.append(scalars[index]) + } else { + token.append(scalar) + } + index += 1 + } + + guard quote == nil else { return nil } + flushToken() + return tokens.isEmpty ? nil : tokens + } + + static func normalizedCWD(_ rawValue: String?) -> String? { + guard let rawValue = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines), + !rawValue.isEmpty else { + return nil + } + return ((rawValue as NSString).expandingTildeInPath as NSString).standardizingPath + } + + static func shellQuoted(_ value: String) -> String { + guard !value.isEmpty else { return "''" } + let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_+-=./:@%") + if value.unicodeScalars.allSatisfy({ allowed.contains($0) }) { + return value + } + return "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } +} + +enum SurfaceResumeApprovalSignature { + static func sign(_ payload: Data, secret: Data) -> String { +#if canImport(CryptoKit) + let key = SymmetricKey(data: secret) + let code = HMAC<SHA256>.authenticationCode(for: payload, using: key) + return Data(code).base64EncodedString() +#else + return "" +#endif + } +} + +enum SurfaceResumeApprovalStore { + static let didChangeNotification = Notification.Name("cmux.surfaceResumeApprovalsDidChange") + private static let defaultFileName = "resume-commands.json" + private static let secretFileName = ".surface-resume-approval-secret" + private static let keychainService = "com.cmuxterm.app.surface-resume-approvals" + private static let keychainAccount = "hmac-secret-v1" + + struct StoredFile: Codable { + var version: Int + var records: [SurfaceResumeApprovalRecord] + } + + static func defaultURL(environment: [String: String] = ProcessInfo.processInfo.environment) -> URL { + if let override = environment["CMUX_SURFACE_RESUME_APPROVAL_STORE_PATH"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty { + return URL(fileURLWithPath: (override as NSString).expandingTildeInPath, isDirectory: false) + } + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".config/cmux", isDirectory: true) + .appendingPathComponent(defaultFileName, isDirectory: false) + } + + static func loadRecords( + fileURL: URL = defaultURL(), + fileManager: FileManager = .default + ) -> [SurfaceResumeApprovalRecord] { + guard let data = try? Data(contentsOf: fileURL) else { return [] } + if let file = try? JSONDecoder().decode(StoredFile.self, from: data) { + return file.records + } + return (try? JSONDecoder().decode([SurfaceResumeApprovalRecord].self, from: data)) ?? [] + } + + static func validRecords( + fileURL: URL = defaultURL(), + fileManager: FileManager = .default, + signingSecret: Data? = nil + ) -> [SurfaceResumeApprovalRecord] { + let signingSecret = signingSecret ?? defaultSigningSecret(fileManager: fileManager) + guard let signingSecret else { return [] } + return loadRecords(fileURL: fileURL, fileManager: fileManager) + .filter { $0.hasValidSignature(secret: signingSecret) } + } + + static func matchingRecord( + for binding: SurfaceResumeBindingSnapshot, + fileURL: URL = defaultURL(), + fileManager: FileManager = .default, + signingSecret: Data? = nil + ) -> SurfaceResumeApprovalRecord? { + validRecords(fileURL: fileURL, fileManager: fileManager, signingSecret: signingSecret) + .filter { $0.matches(binding) } + .sorted { lhs, rhs in + if lhs.commandPrefix.count != rhs.commandPrefix.count { + return lhs.commandPrefix.count > rhs.commandPrefix.count + } + return lhs.updatedAt > rhs.updatedAt + } + .first + } + + static func applyingStoredApproval( + to binding: SurfaceResumeBindingSnapshot, + fileURL: URL = defaultURL(), + fileManager: FileManager = .default, + signingSecret: Data? = nil + ) -> SurfaceResumeBindingSnapshot { + if binding.isProcessDetected { + var trustedBinding = binding + trustedBinding.autoResume = true + trustedBinding.approvalPolicy = .auto + trustedBinding.approvalRecordId = nil + return trustedBinding + } + + if binding.isAgentHookBinding { + var trustedBinding = binding + trustedBinding.autoResume = binding.autoResume == true + trustedBinding.approvalPolicy = trustedBinding.autoResume == true ? .auto : .manual + trustedBinding.approvalRecordId = nil + return trustedBinding + } + + var effective = binding + guard let record = matchingRecord( + for: binding, + fileURL: fileURL, + fileManager: fileManager, + signingSecret: signingSecret + ) else { + effective.autoResume = false + effective.approvalPolicy = .manual + effective.approvalRecordId = nil + return effective + } + + effective.approvalPolicy = record.policy + effective.approvalRecordId = record.id + effective.autoResume = record.policy == .auto + return effective + } + + @discardableResult + static func approve( + binding: SurfaceResumeBindingSnapshot, + policy: SurfaceResumeApprovalPolicy, + commandPrefix: [String]? = nil, + fileURL: URL = defaultURL(), + fileManager: FileManager = .default, + signingSecret: Data? = nil + ) -> SurfaceResumeApprovalRecord? { + let signingSecret = signingSecret ?? defaultSigningSecret(fileManager: fileManager) + guard let signingSecret, + let tokens = SurfaceResumeCommandCanonicalizer.tokens(from: binding.command) else { + return nil + } + let prefix = commandPrefix ?? tokens + guard !prefix.isEmpty, tokens.count >= prefix.count, Array(tokens.prefix(prefix.count)) == prefix else { + return nil + } + let now = Date().timeIntervalSince1970 + let existing = matchingRecord( + for: binding, + fileURL: fileURL, + fileManager: fileManager, + signingSecret: signingSecret + ) + let record = SurfaceResumeApprovalRecord( + id: existing?.id ?? UUID().uuidString.lowercased(), + name: binding.name, + commandPrefix: prefix, + cwd: binding.cwd, + environment: binding.environment, + environmentKeys: Array((binding.environment ?? [:]).keys), + source: binding.source, + policy: policy, + createdAt: existing?.createdAt ?? now, + updatedAt: now, + lastUsedAt: existing?.lastUsedAt, + signature: nil + ).signed(secret: signingSecret) + writeReplacing(record: record, fileURL: fileURL, fileManager: fileManager) + return record + } + + @discardableResult + static func update( + recordId: String, + policy: SurfaceResumeApprovalPolicy? = nil, + commandPrefix: [String]? = nil, + fileURL: URL = defaultURL(), + fileManager: FileManager = .default, + signingSecret: Data? = nil + ) -> Bool { + let signingSecret = signingSecret ?? defaultSigningSecret(fileManager: fileManager) + guard let signingSecret else { return false } + var records = loadRecords(fileURL: fileURL, fileManager: fileManager) + guard let index = records.firstIndex(where: { $0.id == recordId }) else { return false } + var record = records[index] + guard record.hasValidSignature(secret: signingSecret) else { return false } + if let policy { + record.policy = policy + } + if let commandPrefix { + guard !commandPrefix.isEmpty else { return false } + record.commandPrefix = commandPrefix + } + record.updatedAt = Date().timeIntervalSince1970 + records[index] = record.signed(secret: signingSecret) + return write(records: records, fileURL: fileURL, fileManager: fileManager) + } + + @discardableResult + static func delete( + recordId: String, + fileURL: URL = defaultURL(), + fileManager: FileManager = .default + ) -> Bool { + let records = loadRecords(fileURL: fileURL, fileManager: fileManager) + .filter { $0.id != recordId } + return write(records: records, fileURL: fileURL, fileManager: fileManager) + } + + @discardableResult + static func removeAll( + fileURL: URL = defaultURL(), + fileManager: FileManager = .default + ) -> Bool { + try? fileManager.removeItem(at: fileURL) + NotificationCenter.default.post(name: didChangeNotification, object: nil) + return true + } + + static func isValid(_ record: SurfaceResumeApprovalRecord, signingSecret: Data? = defaultSigningSecret()) -> Bool { + guard let signingSecret else { return false } + return record.hasValidSignature(secret: signingSecret) + } + + static func defaultSigningSecret(fileManager: FileManager = .default) -> Data? { + let env = ProcessInfo.processInfo.environment + if let encoded = env["CMUX_SURFACE_RESUME_APPROVAL_SECRET_B64"], + let data = Data(base64Encoded: encoded), + !data.isEmpty { + return data + } + if let data = keychainSecret(), !data.isEmpty { + return data + } + let generated = randomSecret() + if storeKeychainSecret(generated) { + return generated + } + return fileBackedSecret(fileManager: fileManager, generated: generated) + } + + private static func writeReplacing( + record: SurfaceResumeApprovalRecord, + fileURL: URL, + fileManager: FileManager + ) { + var records = loadRecords(fileURL: fileURL, fileManager: fileManager) + if let index = records.firstIndex(where: { $0.id == record.id }) { + records[index] = record + } else { + records.append(record) + } + _ = write(records: records, fileURL: fileURL, fileManager: fileManager) + } + + @discardableResult + private static func write( + records: [SurfaceResumeApprovalRecord], + fileURL: URL, + fileManager: FileManager + ) -> Bool { + do { + try fileManager.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try? fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: fileURL.deletingLastPathComponent().path) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(StoredFile(version: 1, records: records)) + try data.write(to: fileURL, options: [.atomic]) + try? fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: fileURL.path) + NotificationCenter.default.post(name: didChangeNotification, object: nil) + return true + } catch { + return false + } + } + + private static func randomSecret() -> Data { + var bytes = [UInt8](repeating: 0, count: 32) +#if canImport(Security) + if SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) == errSecSuccess { + return Data(bytes) + } +#endif + for index in bytes.indices { + bytes[index] = UInt8.random(in: UInt8.min...UInt8.max) + } + return Data(bytes) + } + + private static func fileBackedSecret(fileManager: FileManager, generated: Data) -> Data? { + let url = defaultURL().deletingLastPathComponent().appendingPathComponent(secretFileName, isDirectory: false) + if let existing = try? Data(contentsOf: url), !existing.isEmpty { + return existing + } + do { + try fileManager.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try generated.write(to: url, options: [.atomic]) + try? fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + return generated + } catch { + return nil + } + } + +#if canImport(Security) + private static func keychainSecret() -> Data? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainService, + kSecAttrAccount as String: keychainAccount, + kSecUseDataProtectionKeychain as String: true, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess else { return nil } + return result as? Data + } + + private static func storeKeychainSecret(_ secret: Data) -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainService, + kSecAttrAccount as String: keychainAccount, + kSecUseDataProtectionKeychain as String: true, + ] + let updateStatus = SecItemUpdate( + query as CFDictionary, + [kSecValueData as String: secret] as CFDictionary + ) + if updateStatus == errSecSuccess { return true } + if updateStatus != errSecItemNotFound { return false } + var insert = query + insert[kSecValueData as String] = secret + insert[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock + return SecItemAdd(insert as CFDictionary, nil) == errSecSuccess + } +#else + private static func keychainSecret() -> Data? { nil } + private static func storeKeychainSecret(_ secret: Data) -> Bool { false } +#endif +} + +private enum SurfaceResumeBindingScriptStore { + private static let directoryName = "cmux-surface-resume" + private static let scriptTTL: TimeInterval = 24 * 60 * 60 + + static func writeLauncherScript( + inlineInput: String, + binding: SurfaceResumeBindingSnapshot, + fileManager: FileManager, + temporaryDirectory: URL + ) -> URL? { + let directoryURL = temporaryDirectory.appendingPathComponent(directoryName, isDirectory: true) + do { + try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true) + try? fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directoryURL.path) + pruneOldScripts(in: directoryURL, fileManager: fileManager) + + let prefix = safeFilenamePrefix(binding: binding) + let scriptURL = directoryURL.appendingPathComponent( + "\(prefix)-\(UUID().uuidString).zsh", + isDirectory: false + ) + let contents = "#!/bin/zsh\nrm -f -- \"$0\" 2>/dev/null || true\n\(inlineInput)" + try contents.write(to: scriptURL, atomically: true, encoding: .utf8) + try? fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: scriptURL.path) + return scriptURL + } catch { + return nil + } + } + + private static func safeFilenamePrefix(binding: SurfaceResumeBindingSnapshot) -> String { + let rawPrefix = binding.kind ?? binding.source ?? "surface-resume" + let safePrefix = rawPrefix + .prefix(24) + .map { character -> Character in + character.isLetter || character.isNumber || character == "-" ? character : "_" + } + return safePrefix.isEmpty ? "surface-resume" : String(safePrefix) + } + + private static func pruneOldScripts(in directoryURL: URL, fileManager: FileManager) { + guard let scriptURLs = try? fileManager.contentsOfDirectory( + at: directoryURL, + includingPropertiesForKeys: [.contentModificationDateKey], + options: [.skipsHiddenFiles] + ) else { + return + } + let cutoff = Date().addingTimeInterval(-scriptTTL) + for scriptURL in scriptURLs where scriptURL.pathExtension == "zsh" { + guard let values = try? scriptURL.resourceValues(forKeys: [.contentModificationDateKey]), + let modifiedAt = values.contentModificationDate, + modifiedAt < cutoff else { + continue + } + try? fileManager.removeItem(at: scriptURL) + } + } +} + struct SessionTerminalPanelSnapshot: Codable, Sendable { var workingDirectory: String? var scrollback: String? var agent: SessionRestorableAgentSnapshot? var tmuxStartCommand: String? + var resumeBinding: SurfaceResumeBindingSnapshot? + + init( + workingDirectory: String? = nil, + scrollback: String? = nil, + agent: SessionRestorableAgentSnapshot? = nil, + tmuxStartCommand: String? = nil, + resumeBinding: SurfaceResumeBindingSnapshot? = nil + ) { + self.workingDirectory = workingDirectory + self.scrollback = scrollback + self.agent = agent + self.tmuxStartCommand = tmuxStartCommand + self.resumeBinding = resumeBinding + } } struct SessionBrowserPanelSnapshot: Codable, Sendable { var urlString: String? diff --git a/Sources/SettingsNavigation.swift b/Sources/SettingsNavigation.swift index cb316936debb..a32da8154345 100644 --- a/Sources/SettingsNavigation.swift +++ b/Sources/SettingsNavigation.swift @@ -86,7 +86,7 @@ enum SettingsNavigationTarget: String, CaseIterable, Identifiable { case .app: return "\(title) appearance language workspace notifications menu bar telemetry" case .terminal: - return "\(title) scrollbar auto resume restore reopen relaunch quit sessions agents claude codex opencode rovodev toggle" + return "\(title) scrollbar auto resume restore reopen relaunch quit sessions agents claude codex opencode rovodev commands approvals prefixes toggle" case .workspaceColors: return "\(title) palette tabs" case .sidebarAppearance: @@ -319,6 +319,7 @@ enum SettingsSearchIndex { setting(.app, "palette-search-all", String(localized: "settings.app.commandPaletteSearchAllSurfaces", defaultValue: "Command Palette Searches All Surfaces"), "cmd p search terminal browser markdown"), setting(.terminal, "scrollbar", String(localized: "settings.terminal.scrollBar", defaultValue: "Show Terminal Scroll Bar"), "terminal shell scrollback"), setting(.terminal, "agent-auto-resume", String(localized: "settings.terminal.agentAutoResume", defaultValue: "Resume Agent Sessions on Reopen"), "terminal.autoResumeAgentSessions auto resume restore reopen relaunch quit sessions agents claude code codex opencode rovo dev rovodev toggle"), + setting(.terminal, "resume-commands", String(localized: "settings.terminal.resumeCommands", defaultValue: "Resume Commands"), "surface resume command approvals prefixes auto restore prompt manual tmux hibernation"), setting(.sidebarAppearance, "match-terminal", String(localized: "settings.sidebarAppearance.matchTerminalBackground", defaultValue: "Match Terminal Background"), "sidebar material transparency"), setting(.sidebarAppearance, "hide-sidebar-details", String(localized: "settings.app.hideAllSidebarDetails", defaultValue: "Hide All Sidebar Details"), "workspace sidebar compact"), setting(.sidebarAppearance, "show-workspace-description", String(localized: "settings.app.showWorkspaceDescription", defaultValue: "Show Workspace Description in Sidebar"), "workspace description notes markdown"), diff --git a/Sources/SettingsSearchAliases.swift b/Sources/SettingsSearchAliases.swift index 2ed7d4fc2138..f8883ec4c1c8 100644 --- a/Sources/SettingsSearchAliases.swift +++ b/Sources/SettingsSearchAliases.swift @@ -68,6 +68,7 @@ enum SettingsSearchAliasIndex { "app:rename-selects-name": localized("settings.search.alias.setting.app.rename-selects-name", defaultValue: "app.renameSelectsExistingName rename select all existing title command palette workspace name"), "app:palette-search-all": localized("settings.search.alias.setting.app.palette-search-all", defaultValue: "app.commandPaletteSearchesAllSurfaces command palette search all surfaces cmd-p terminal browser markdown"), "terminal:scrollbar": localized("settings.search.alias.setting.terminal.scrollbar", defaultValue: "terminal.showScrollBar scrollback scrollbar scroll bar right edge alternate screen tui"), + "terminal:resume-commands": localized("settings.search.alias.setting.terminal.resume-commands", defaultValue: "surface resume commands approvals command prefixes auto restore ask manual tmux hibernation sticky process"), "sidebarAppearance:match-terminal": localized("settings.search.alias.setting.sidebarAppearance.match-terminal", defaultValue: "sidebarAppearance.matchTerminalBackground transparent background material terminal background sync"), "sidebarAppearance:hide-sidebar-details": localized("settings.search.alias.setting.app.hide-sidebar-details", defaultValue: "sidebar.hideAllDetails compact sidebar hide details only title minimal left rail"), "sidebarAppearance:show-workspace-description": localized("settings.search.alias.setting.app.show-workspace-description", defaultValue: "sidebar.showWorkspaceDescription workspace description notes markdown sidebar"), diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 7ad39123fd8d..5f751491abd4 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -7315,7 +7315,8 @@ class TabManager: ObservableObject { extension TabManager { func sessionAutosaveFingerprint( - restorableAgentIndex: RestorableAgentSessionIndex = .empty + restorableAgentIndex: RestorableAgentSessionIndex = .empty, + surfaceResumeBindingIndex: SurfaceResumeBindingIndex = .empty ) -> Int { var hasher = Hasher() hasher.combine(selectedTabId) @@ -7362,6 +7363,13 @@ extension TabManager { ), into: &hasher ) + Self.hashSurfaceResumeBindingSnapshot( + workspace.effectiveSurfaceResumeBinding( + panelId: panelId, + surfaceResumeBindingIndex: surfaceResumeBindingIndex + ), + into: &hasher + ) } if let progress = workspace.progress { @@ -7435,6 +7443,31 @@ extension TabManager { hashOptionalString(launchCommand.source, into: &hasher) } + nonisolated private static func hashSurfaceResumeBindingSnapshot( + _ snapshot: SurfaceResumeBindingSnapshot?, + into hasher: inout Hasher + ) { + guard let snapshot else { + hasher.combine(false) + return + } + + hasher.combine(true) + hashOptionalString(snapshot.name, into: &hasher) + hashOptionalString(snapshot.kind, into: &hasher) + hasher.combine(snapshot.command) + hashOptionalString(snapshot.cwd, into: &hasher) + hashOptionalString(snapshot.checkpointId, into: &hasher) + hashOptionalString(snapshot.source, into: &hasher) + hashStringMap(snapshot.environment, into: &hasher) + hasher.combine(snapshot.allowsAutomaticResume) + if snapshot.isProcessDetected { + hasher.combine(false) + } else { + hashOptionalDouble(snapshot.updatedAt, into: &hasher) + } + } + nonisolated private static func hashOptionalString(_ value: String?, into hasher: inout Hasher) { if let value { hasher.combine(true) @@ -7453,9 +7486,24 @@ extension TabManager { } } + nonisolated private static func hashStringMap(_ value: [String: String]?, into hasher: inout Hasher) { + guard let value, !value.isEmpty else { + hasher.combine(false) + return + } + hasher.combine(true) + let keys = value.keys.sorted() + hasher.combine(keys.count) + for key in keys { + hasher.combine(key) + hasher.combine(value[key] ?? "") + } + } + func sessionSnapshot( includeScrollback: Bool, - restorableAgentIndex: RestorableAgentSessionIndex = .empty + restorableAgentIndex: RestorableAgentSessionIndex = .empty, + surfaceResumeBindingIndex: SurfaceResumeBindingIndex? = nil ) -> SessionTabManagerSnapshot { let restorableTabs = tabs .filter(\.isRestorableInSessionSnapshot) @@ -7464,7 +7512,8 @@ extension TabManager { .map { $0.sessionSnapshot( includeScrollback: includeScrollback, - restorableAgentIndex: restorableAgentIndex + restorableAgentIndex: restorableAgentIndex, + surfaceResumeBindingIndex: surfaceResumeBindingIndex ) } let selectedWorkspaceIndex = selectedTabId.flatMap { selectedTabId in diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 9895ea84ae74..c61e6d09a653 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -2609,6 +2609,12 @@ class TerminalController { return v2Result(id: id, self.v2SurfaceRefresh(params: params)) case "surface.health": return v2Result(id: id, self.v2SurfaceHealth(params: params)) + case "surface.resume.set": + return v2Result(id: id, self.v2SurfaceResumeSet(params: params)) + case "surface.resume.get": + return v2Result(id: id, self.v2SurfaceResumeGet(params: params)) + case "surface.resume.clear": + return v2Result(id: id, self.v2SurfaceResumeClear(params: params)) case "debug.terminals": return v2Result(id: id, self.v2DebugTerminals(params: params)) case "surface.send_text": @@ -3001,6 +3007,9 @@ class TerminalController { "tab.action", "surface.refresh", "surface.health", + "surface.resume.set", + "surface.resume.get", + "surface.resume.clear", "debug.terminals", "surface.send_text", "surface.send_key", @@ -5996,6 +6005,7 @@ class TerminalController { item["requested_working_directory"] = v2OrNull(v2NonEmptyString(terminalPanel.requestedWorkingDirectory)) item["initial_command"] = v2OrNull(v2NonEmptyString(terminalPanel.surface.debugInitialCommand())) item["tmux_start_command"] = v2OrNull(v2NonEmptyString(terminalPanel.surface.debugTmuxStartCommand())) + item["resume_binding"] = v2SurfaceResumeBindingPayload(ws.surfaceResumeBinding(panelId: panel.id)) } return item } @@ -6051,6 +6061,301 @@ class TerminalController { return .ok(payload) } + private func v2SurfaceResumeSet(params: [String: Any]) -> V2CallResult { + if let error = v2SurfaceResumeTargetValidationError(params: params) { + return error + } + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: Self.v2WindowUnavailableMessage, data: nil) + } + guard let command = v2RawString(params, "command")?.trimmingCharacters(in: .whitespacesAndNewlines), + !command.isEmpty else { + return .err(code: "invalid_params", message: "Missing command", data: nil) + } + + let source = v2PublicSurfaceResumeSource(params) + let binding = SurfaceResumeBindingSnapshot( + name: v2OptionalTrimmedRawString(params, "name"), + kind: v2OptionalTrimmedRawString(params, "kind"), + command: command, + cwd: v2OptionalTrimmedRawString(params, "cwd"), + checkpointId: v2OptionalTrimmedRawString(params, "checkpoint_id") ?? v2OptionalTrimmedRawString(params, "checkpointId"), + source: source, + environment: v2StringMap(params, "environment"), + autoResume: source == "agent-hook" ? (v2Bool(params, "auto_resume") ?? false) : false, + updatedAt: Date().timeIntervalSince1970 + ) + + var result: V2CallResult = .err(code: "internal_error", message: "Failed to set resume binding", data: nil) + v2MainSync { + guard let target = v2ResolveSurfaceResumeTarget(params: params, fallbackTabManager: tabManager) else { + result = .err(code: "not_found", message: "Surface not found", data: nil) + return + } + let effectiveBinding = v2SurfaceResumeBindingWithApproval(binding) + guard target.workspace.setSurfaceResumeBinding(effectiveBinding, panelId: target.surfaceId) else { + result = .err(code: "invalid_params", message: "Resume command is empty", data: nil) + return + } + result = .ok(v2SurfaceResumeResult( + tabManager: target.tabManager, + workspace: target.workspace, + surfaceId: target.surfaceId, + binding: effectiveBinding, + cleared: false + )) + } + return result + } + + private func v2SurfaceResumeBindingWithApproval(_ binding: SurfaceResumeBindingSnapshot) -> SurfaceResumeBindingSnapshot { + let existingRecord = SurfaceResumeApprovalStore.matchingRecord(for: binding) + var effectiveBinding = SurfaceResumeApprovalStore.applyingStoredApproval(to: binding) + guard v2ShouldPromptForSurfaceResumeApproval(binding: binding, existingRecord: existingRecord) else { + return effectiveBinding + } + let policy = v2PromptForSurfaceResumeApproval(binding: effectiveBinding) + guard let record = SurfaceResumeApprovalStore.approve(binding: binding, policy: policy) else { + return effectiveBinding + } + effectiveBinding = SurfaceResumeApprovalStore.applyingStoredApproval(to: binding) + effectiveBinding.approvalPolicy = record.policy + effectiveBinding.approvalRecordId = record.id + effectiveBinding.autoResume = record.policy == .auto + return effectiveBinding + } + + private func v2ShouldPromptForSurfaceResumeApproval( + binding: SurfaceResumeBindingSnapshot, + existingRecord: SurfaceResumeApprovalRecord? + ) -> Bool { + guard Thread.isMainThread else { + return false + } + guard ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil else { + return false + } + guard !binding.isProcessDetected, !binding.isAgentHookBinding else { + return false + } + guard SurfaceResumeCommandCanonicalizer.tokens(from: binding.command) != nil else { + return false + } + guard let existingRecord else { return true } + return existingRecord.policy == .prompt + } + + private func v2PromptForSurfaceResumeApproval( + binding: SurfaceResumeBindingSnapshot + ) -> SurfaceResumeApprovalPolicy { + let alert = NSAlert() + alert.alertStyle = .informational + alert.messageText = String( + localized: "surfaceResumeApproval.proposal.title", + defaultValue: "Allow Resume Command?" + ) + let cwd = binding.cwd ?? String(localized: "surfaceResumeApproval.cwd.none", defaultValue: "None") + alert.informativeText = String( + format: String( + localized: "surfaceResumeApproval.proposal.message", + defaultValue: "A process wants cmux to keep this resume command for the current terminal:\n\n%@\n\nWorking directory: %@" + ), + binding.command, + cwd + ) + alert.addButton(withTitle: String(localized: "surfaceResumeApproval.proposal.auto", defaultValue: "Auto-Restore")) + alert.addButton(withTitle: String(localized: "surfaceResumeApproval.proposal.ask", defaultValue: "Ask Each Time")) + alert.addButton(withTitle: String(localized: "surfaceResumeApproval.proposal.manual", defaultValue: "Keep Manual")) + + switch alert.runModal() { + case .alertFirstButtonReturn: + return .auto + case .alertSecondButtonReturn: + return .prompt + default: + return .manual + } + } + + private func v2SurfaceResumeGet(params: [String: Any]) -> V2CallResult { + if let error = v2SurfaceResumeTargetValidationError(params: params) { + return error + } + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: Self.v2WindowUnavailableMessage, data: nil) + } + + var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: nil) + v2MainSync { + guard let target = v2ResolveSurfaceResumeTarget(params: params, fallbackTabManager: tabManager) else { + result = .err(code: "not_found", message: "Surface not found", data: nil) + return + } + result = .ok(v2SurfaceResumeResult( + tabManager: target.tabManager, + workspace: target.workspace, + surfaceId: target.surfaceId, + binding: target.workspace.surfaceResumeBinding(panelId: target.surfaceId), + cleared: false + )) + } + return result + } + + private func v2SurfaceResumeClear(params: [String: Any]) -> V2CallResult { + if let error = v2SurfaceResumeTargetValidationError(params: params) { + return error + } + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: Self.v2WindowUnavailableMessage, data: nil) + } + + let expectedCheckpointId = v2OptionalTrimmedRawString(params, "checkpoint_id") + ?? v2OptionalTrimmedRawString(params, "checkpointId") + let expectedSource = v2OptionalTrimmedRawString(params, "source") + var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: nil) + v2MainSync { + guard let target = v2ResolveSurfaceResumeTarget(params: params, fallbackTabManager: tabManager) else { + result = .err(code: "not_found", message: "Surface not found", data: nil) + return + } + let currentBinding = target.workspace.surfaceResumeBinding(panelId: target.surfaceId) + if let expectedCheckpointId, currentBinding?.checkpointId != expectedCheckpointId { + result = .ok(v2SurfaceResumeResult( + tabManager: target.tabManager, + workspace: target.workspace, + surfaceId: target.surfaceId, + binding: currentBinding, + cleared: false + )) + return + } + if let expectedSource, currentBinding?.source != expectedSource { + result = .ok(v2SurfaceResumeResult( + tabManager: target.tabManager, + workspace: target.workspace, + surfaceId: target.surfaceId, + binding: currentBinding, + cleared: false + )) + return + } + _ = target.workspace.clearSurfaceResumeBinding(panelId: target.surfaceId) + result = .ok(v2SurfaceResumeResult( + tabManager: target.tabManager, + workspace: target.workspace, + surfaceId: target.surfaceId, + binding: nil, + cleared: true + )) + } + return result + } + + private func v2PublicSurfaceResumeSource(_ params: [String: Any]) -> String? { + let source = v2OptionalTrimmedRawString(params, "source") + return source == "process-detected" ? "manual" : source + } + + private static let v2WindowUnavailableMessage = "cmux window is not available. Reopen the window and try again." + + private func v2SurfaceResumeTargetValidationError(params: [String: Any]) -> V2CallResult? { + for key in ["window_id", "workspace_id", "surface_id", "tab_id"] { + if v2HasNonNullParam(params, key), v2UUID(params, key) == nil { + return .err(code: "invalid_params", message: "Missing or invalid \(key)", data: nil) + } + } + return nil + } + + @MainActor + private func v2ResolveSurfaceResumeTarget( + params: [String: Any], + fallbackTabManager: TabManager + ) -> (tabManager: TabManager, workspace: Workspace, surfaceId: UUID)? { + if let explicitSurfaceId = v2UUID(params, "surface_id") ?? v2UUID(params, "tab_id") { + if let explicitWorkspaceId = v2UUID(params, "workspace_id") { + guard let workspace = fallbackTabManager.tabs.first(where: { $0.id == explicitWorkspaceId }), + workspace.terminalPanel(for: explicitSurfaceId) != nil else { + return nil + } + return (fallbackTabManager, workspace, explicitSurfaceId) + } + + if v2UUID(params, "window_id") != nil { + guard let workspace = fallbackTabManager.tabs.first(where: { + $0.terminalPanel(for: explicitSurfaceId) != nil + }) else { + return nil + } + return (fallbackTabManager, workspace, explicitSurfaceId) + } + + if let located = AppDelegate.shared?.locateSurface(surfaceId: explicitSurfaceId), + let workspace = located.tabManager.tabs.first(where: { $0.id == located.workspaceId }), + workspace.terminalPanel(for: explicitSurfaceId) != nil { + return (located.tabManager, workspace, explicitSurfaceId) + } + if let workspace = fallbackTabManager.tabs.first(where: { + $0.terminalPanel(for: explicitSurfaceId) != nil + }) { + return (fallbackTabManager, workspace, explicitSurfaceId) + } + if let workspace = v2ResolveWorkspace(params: params, tabManager: fallbackTabManager), + workspace.terminalPanel(for: explicitSurfaceId) != nil { + return (fallbackTabManager, workspace, explicitSurfaceId) + } + return nil + } + guard let workspace = v2ResolveWorkspace(params: params, tabManager: fallbackTabManager), + let surfaceId = workspace.focusedPanelId, + workspace.terminalPanel(for: surfaceId) != nil else { + return nil + } + return (fallbackTabManager, workspace, surfaceId) + } + + private func v2SurfaceResumeResult( + tabManager: TabManager, + workspace: Workspace, + surfaceId: UUID, + binding: SurfaceResumeBindingSnapshot?, + cleared: Bool + ) -> [String: Any] { + let paneId = workspace.paneId(forPanelId: surfaceId)?.id + let windowId = v2ResolveWindowId(tabManager: tabManager) + return [ + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId), + "workspace_id": workspace.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspace.id), + "pane_id": v2OrNull(paneId?.uuidString), + "pane_ref": v2Ref(kind: .pane, uuid: paneId), + "surface_id": surfaceId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), + "cleared": cleared, + "resume_binding": v2SurfaceResumeBindingPayload(binding) + ] + } + + private func v2SurfaceResumeBindingPayload(_ binding: SurfaceResumeBindingSnapshot?) -> Any { + guard let binding else { return NSNull() } + let effectiveBinding = SurfaceResumeApprovalStore.applyingStoredApproval(to: binding) + return [ + "name": v2OrNull(effectiveBinding.name), + "kind": v2OrNull(effectiveBinding.kind), + "command": effectiveBinding.command, + "cwd": v2OrNull(effectiveBinding.cwd), + "checkpoint_id": v2OrNull(effectiveBinding.checkpointId), + "source": v2OrNull(effectiveBinding.source), + "environment": v2OrNull(effectiveBinding.environment), + "auto_resume": effectiveBinding.allowsAutomaticResume, + "approval_policy": v2OrNull(effectiveBinding.approvalPolicy?.rawValue), + "approval_record_id": v2OrNull(effectiveBinding.approvalRecordId), + "updated_at": effectiveBinding.updatedAt + ] + } + private func v2SurfaceFocus(params: [String: Any]) -> V2CallResult { guard let tabManager = v2ResolveTabManager(params: params) else { return .err(code: "unavailable", message: "TabManager not available", data: nil) diff --git a/Sources/TmuxResumeParser.swift b/Sources/TmuxResumeParser.swift new file mode 100644 index 000000000000..0a7f2e3a94eb --- /dev/null +++ b/Sources/TmuxResumeParser.swift @@ -0,0 +1,337 @@ +import Foundation + +enum TmuxResumeParser { + static func binding( + processName: String, + processPath: String?, + arguments: [String], + environment: [String: String], + capturedAt: TimeInterval + ) -> SurfaceResumeBindingSnapshot? { + let observed = ObservedTmuxProcess( + processName: processName, + processPath: processPath, + arguments: arguments + ) + guard let invocation = resumeInvocation(observed: observed) else { return nil } + + let command = invocation.argv.map(shellSingleQuoted).joined(separator: " ") + let cwd = normalized(environment["CMUX_AGENT_LAUNCH_CWD"] ?? environment["PWD"]) + let resumeEnvironment = tmuxResumeEnvironment(from: environment) + return SurfaceResumeBindingSnapshot( + name: invocation.sessionName.map { "tmux \($0)" } ?? "tmux", + kind: "tmux", + command: command, + cwd: cwd, + checkpointId: invocation.sessionName, + source: "process-detected", + environment: resumeEnvironment, + autoResume: true, + updatedAt: capturedAt + ) + } + + static func argumentLooksLikeTmux(_ argument: String) -> Bool { + let normalized = argument.lowercased() + if argumentLooksLikeTmuxClientProcessTitle(normalized) { + return true + } + let pathComponents = (normalized as NSString).pathComponents + let basename = pathComponents.last ?? normalized + return basename == "tmux" || argumentLooksLikeTmuxClientProcessTitle(basename) + } + + static func argumentLooksLikeTmuxProcessTitle(_ argument: String) -> Bool { + let normalized = argument.lowercased() + if normalized.hasPrefix("tmux:") { + return true + } + let pathComponents = (normalized as NSString).pathComponents + let basename = pathComponents.last ?? normalized + return basename.hasPrefix("tmux:") + } + + static func argumentLooksLikeTmuxServerProcessTitle(_ argument: String) -> Bool { + let normalized = argument.lowercased() + if normalized.hasPrefix("tmux: server") { + return true + } + let pathComponents = (normalized as NSString).pathComponents + let basename = pathComponents.last ?? normalized + return basename.hasPrefix("tmux: server") + } + + private struct ObservedTmuxProcess { + let processName: String + let processPath: String? + let arguments: [String] + + var executableBasenames: [String] { + var names: [String] = [] + if !processName.isEmpty { names.append(processName) } + if let processPath, !processPath.isEmpty { names.append((processPath as NSString).lastPathComponent) } + if let first = arguments.first, !first.isEmpty { names.append((first as NSString).lastPathComponent) } + var seen = Set<String>() + return names.filter { seen.insert($0).inserted } + } + + var isTmuxProcess: Bool { + executableBasenames.contains(where: TmuxResumeParser.argumentLooksLikeTmux) + } + + var hasTmuxServerProcessTitle: Bool { + executableBasenames.contains(where: TmuxResumeParser.argumentLooksLikeTmuxServerProcessTitle) + } + } + + private struct TmuxResumeInvocation { + let argv: [String] + let sessionName: String? + } + + private static func resumeInvocation(observed: ObservedTmuxProcess) -> TmuxResumeInvocation? { + guard observed.isTmuxProcess else { return nil } + guard !observed.hasTmuxServerProcessTitle else { return nil } + + let executable = tmuxExecutable(observed: observed) + let tail = tmuxTailArguments(observed: observed) + let parsed = parseTopLevelArguments(tail) + guard parsed.isSafe else { return nil } + + var argv = [executable] + argv.append(contentsOf: parsed.socketFlags) + argv.append("attach") + if let sessionName = parsed.sessionName { + argv.append(contentsOf: ["-t", sessionName]) + } + return TmuxResumeInvocation(argv: argv, sessionName: parsed.sessionName) + } + + private static func tmuxExecutable(observed: ObservedTmuxProcess) -> String { + if let first = normalized(observed.arguments.first), + argumentLooksLikeTmux(first), + !argumentLooksLikeTmuxProcessTitle(first) { + return first + } + if let path = normalized(observed.processPath), + argumentLooksLikeTmux(path), + !argumentLooksLikeTmuxProcessTitle(path) { + return path + } + return "tmux" + } + + private static func tmuxTailArguments(observed: ObservedTmuxProcess) -> [String] { + guard let first = observed.arguments.first else { return [] } + return argumentLooksLikeTmux(first) + ? Array(observed.arguments.dropFirst()) + : observed.arguments + } + + private struct ParsedTmuxTopLevelArguments { + let socketFlags: [String] + let sessionName: String? + let isSafe: Bool + } + + private static func parseTopLevelArguments(_ arguments: [String]) -> ParsedTmuxTopLevelArguments { + var index = 0 + var socketFlags: [String] = [] + + while index < arguments.count { + let argument = arguments[index] + if argument == "--" { + index += 1 + break + } + guard argument.hasPrefix("-") else { break } + if appendSocketFlag(argument, arguments: arguments, index: &index, into: &socketFlags) { + continue + } + index += topLevelOptionWidth(argument, arguments: arguments, index: index) + } + + guard index < arguments.count else { + return ParsedTmuxTopLevelArguments(socketFlags: socketFlags, sessionName: nil, isSafe: false) + } + + let command = arguments[index] + let commandArgs = Array(arguments.dropFirst(index + 1)) + switch command { + case "attach-session", "attach", "a": + let sessionName = optionValue(commandArgs, short: "t", long: "target-session") + guard sessionName != nil else { + return ParsedTmuxTopLevelArguments(socketFlags: socketFlags, sessionName: nil, isSafe: false) + } + return ParsedTmuxTopLevelArguments( + socketFlags: socketFlags, + sessionName: sessionName, + isSafe: true + ) + case "new-session", "new": + guard hasFlag(commandArgs, short: "A") else { + return ParsedTmuxTopLevelArguments(socketFlags: socketFlags, sessionName: nil, isSafe: false) + } + let sessionName = optionValue(commandArgs, short: "s", long: "session-name") + guard sessionName != nil else { + return ParsedTmuxTopLevelArguments(socketFlags: socketFlags, sessionName: nil, isSafe: false) + } + return ParsedTmuxTopLevelArguments( + socketFlags: socketFlags, + sessionName: sessionName, + isSafe: true + ) + default: + return ParsedTmuxTopLevelArguments(socketFlags: socketFlags, sessionName: nil, isSafe: false) + } + } + + private static func appendSocketFlag( + _ argument: String, + arguments: [String], + index: inout Int, + into socketFlags: inout [String] + ) -> Bool { + for option in ["L", "S"] { + let short = "-\(option)" + if argument == short { + let valueIndex = index + 1 + guard valueIndex < arguments.count, + let value = normalized(arguments[valueIndex]) else { + index = valueIndex + return true + } + socketFlags.append(contentsOf: [short, value]) + index += 2 + return true + } + if argument.hasPrefix(short), argument.count > short.count { + let value = String(argument.dropFirst(short.count)) + if let normalizedValue = normalized(value) { + socketFlags.append(contentsOf: [short, normalizedValue]) + } + index += 1 + return true + } + } + return false + } + + private static func topLevelOptionWidth(_ argument: String, arguments: [String], index: Int) -> Int { + if argument.contains("=") { return 1 } + let valueOptions: Set<String> = ["-c", "-f"] + guard valueOptions.contains(argument), index + 1 < arguments.count else { return 1 } + return 2 + } + + private static func hasFlag(_ arguments: [String], short: Character, long: String? = nil) -> Bool { + for argument in arguments { + if argument == "--" { break } + if let long, argument == "--\(long)" { return true } + if argument == "-\(short)" { return true } + if shortFlagCluster(argument, contains: short) { + return true + } + } + return false + } + + private static func shortFlagCluster(_ argument: String, contains short: Character) -> Bool { + guard argument.hasPrefix("-"), !argument.hasPrefix("--") else { return false } + for option in argument.dropFirst() { + if option == short { return true } + if valueOptionCharacters.contains(option) { return false } + } + return false + } + + private static let valueOptionCharacters: Set<Character> = { + ["c", "e", "F", "f", "n", "s", "t", "x", "y"] + }() + + private enum ClusterValueMatch { + case inline(String) + case nextArgument + } + + private static func clusterValue(_ argument: String, short: Character) -> ClusterValueMatch? { + guard argument.hasPrefix("-"), !argument.hasPrefix("--") else { return nil } + var index = argument.index(after: argument.startIndex) + while index < argument.endIndex { + let option = argument[index] + let nextIndex = argument.index(after: index) + if option == short { + if nextIndex < argument.endIndex { + return .inline(String(argument[nextIndex...])) + } + return .nextArgument + } + if valueOptionCharacters.contains(option) { + return nil + } + index = nextIndex + } + return nil + } + + private static func optionValue(_ arguments: [String], short: Character, long: String) -> String? { + var index = 0 + while index < arguments.count { + let argument = arguments[index] + if argument == "--" { break } + if argument == "--\(long)" || argument == "-\(short)" { + return valueAfter(arguments, index: index) + } + let longPrefix = "--\(long)=" + if argument.hasPrefix(longPrefix) { + return normalized(String(argument.dropFirst(longPrefix.count))) + } + let shortPrefix = "-\(short)" + if argument.hasPrefix(shortPrefix), argument.count > shortPrefix.count { + return normalized(String(argument.dropFirst(shortPrefix.count))) + } + if let clusterValue = clusterValue(argument, short: short) { + if case .inline(let value) = clusterValue { + return normalized(value) + } + return valueAfter(arguments, index: index) + } + index += 1 + } + return nil + } + + private static func valueAfter(_ arguments: [String], index: Int) -> String? { + let nextIndex = index + 1 + guard nextIndex < arguments.count else { return nil } + guard arguments[nextIndex] != "--" else { return nil } + return normalized(arguments[nextIndex]) + } + + private static func argumentLooksLikeTmuxClientProcessTitle(_ argument: String) -> Bool { + let normalized = argument.lowercased() + if normalized.hasPrefix("tmux: client") { + return true + } + let pathComponents = (normalized as NSString).pathComponents + let basename = pathComponents.last ?? normalized + return basename.hasPrefix("tmux: client") + } + + private static func shellSingleQuoted(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + + private static func tmuxResumeEnvironment(from environment: [String: String]) -> [String: String]? { + guard let tmuxTmpDir = normalized(environment["TMUX_TMPDIR"]) else { return nil } + return ["TMUX_TMPDIR": tmuxTmpDir] + } + + private static func normalized(_ rawValue: String?) -> String? { + guard let rawValue = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines), + !rawValue.isEmpty else { + return nil + } + return rawValue + } +} diff --git a/Sources/VaultAgentProcessScanner.swift b/Sources/VaultAgentProcessScanner.swift index 3f25d8578772..d4411d8d85e7 100644 --- a/Sources/VaultAgentProcessScanner.swift +++ b/Sources/VaultAgentProcessScanner.swift @@ -34,7 +34,21 @@ extension RestorableAgentSessionIndex { fileManager: FileManager ) -> [PanelKey: (snapshot: SessionRestorableAgentSnapshot, updatedAt: TimeInterval)] { let capturedAt = Date().timeIntervalSince1970 - let processSnapshot = CmuxTopProcessSnapshot.capture(includeProcessDetails: false) + let processSnapshot = CmuxTopProcessSnapshot.capture(includeProcessDetails: true) + return processDetectedSnapshots( + registry: registry, + fileManager: fileManager, + processSnapshot: processSnapshot, + capturedAt: capturedAt + ) + } + + static func processDetectedSnapshots( + registry: CmuxVaultAgentRegistry, + fileManager: FileManager, + processSnapshot: CmuxTopProcessSnapshot, + capturedAt: TimeInterval + ) -> [PanelKey: (snapshot: SessionRestorableAgentSnapshot, updatedAt: TimeInterval)] { var resolved = processDetectedOpenCodeSnapshots( processSnapshot: processSnapshot, capturedAt: capturedAt, @@ -535,6 +549,67 @@ extension RestorableAgentSessionIndex { } } +extension SurfaceResumeBindingIndex { + static func processDetectedTmuxBindings( + fileManager: FileManager + ) -> [PanelKey: (binding: SurfaceResumeBindingSnapshot, updatedAt: TimeInterval)] { + _ = fileManager + let capturedAt = Date().timeIntervalSince1970 + let processSnapshot = CmuxTopProcessSnapshot.capture(includeProcessDetails: true) + return processDetectedTmuxBindings( + fileManager: fileManager, + processSnapshot: processSnapshot, + capturedAt: capturedAt + ) + } + + static func processDetectedTmuxBindings( + fileManager: FileManager, + processSnapshot: CmuxTopProcessSnapshot, + capturedAt: TimeInterval + ) -> [PanelKey: (binding: SurfaceResumeBindingSnapshot, updatedAt: TimeInterval)] { + _ = fileManager + var resolved: [PanelKey: (binding: SurfaceResumeBindingSnapshot, updatedAt: TimeInterval)] = [:] + + for process in processSnapshot.cmuxScopedProcesses() { + guard let workspaceId = process.cmuxWorkspaceID, + let panelId = process.cmuxSurfaceID, + process.isTerminalForegroundProcessGroup, + let processArguments = CmuxTopProcessSnapshot.processArgumentsAndEnvironment(for: process.pid) else { + continue + } + guard let binding = TmuxResumeParser.binding( + processName: process.name, + processPath: process.path, + arguments: processArguments.arguments, + environment: processArguments.environment, + capturedAt: capturedAt + ) else { + continue + } + resolved[PanelKey(workspaceId: workspaceId, panelId: panelId)] = (binding: binding, updatedAt: capturedAt) + } + + return resolved + } + + static func tmuxResumeBindingForTesting( + processName: String, + processPath: String?, + arguments: [String], + environment: [String: String], + capturedAt: TimeInterval = 1_777_777_777 + ) -> SurfaceResumeBindingSnapshot? { + TmuxResumeParser.binding( + processName: processName, + processPath: processPath, + arguments: arguments, + environment: environment, + capturedAt: capturedAt + ) + } +} + private struct VaultObservedAgentProcess: Sendable { let processName: String let processPath: String? @@ -596,6 +671,18 @@ private struct VaultObservedAgentProcess: Sendable { basename == "open-code" } + static func argumentLooksLikeTmux(_ argument: String) -> Bool { + TmuxResumeParser.argumentLooksLikeTmux(argument) + } + + static func argumentLooksLikeTmuxProcessTitle(_ argument: String) -> Bool { + TmuxResumeParser.argumentLooksLikeTmuxProcessTitle(argument) + } + + static func argumentLooksLikeTmuxServerProcessTitle(_ argument: String) -> Bool { + TmuxResumeParser.argumentLooksLikeTmuxServerProcessTitle(argument) + } + private static func wrapperLooksLikeNodeRuntime(_ basename: String) -> Bool { switch basename.lowercased() { case "node": diff --git a/Sources/Workspace+DetachedSurfaceTransfer.swift b/Sources/Workspace+DetachedSurfaceTransfer.swift index bd44d7672c6f..409aa729c952 100644 --- a/Sources/Workspace+DetachedSurfaceTransfer.swift +++ b/Sources/Workspace+DetachedSurfaceTransfer.swift @@ -27,6 +27,7 @@ extension Workspace { let restoredUnread: Bool let restorableAgent: SessionRestorableAgentSnapshot? let restorableAgentResumeState: RestoredAgentResumeState? + let resumeBinding: SurfaceResumeBindingSnapshot? let agentRuntime: DetachedAgentRuntimeState? let isRemoteTerminal: Bool let remoteRelayPort: Int? @@ -51,6 +52,7 @@ extension Workspace { restoredUnread: restoredUnread, restorableAgent: restorableAgent, restorableAgentResumeState: restorableAgentResumeState, + resumeBinding: resumeBinding, agentRuntime: agentRuntime, isRemoteTerminal: isRemoteTerminal, remoteRelayPort: remoteRelayPort, diff --git a/Sources/Workspace+PanelLifecycle.swift b/Sources/Workspace+PanelLifecycle.swift index 316c766e65d7..ff7d6e2ba2b2 100644 --- a/Sources/Workspace+PanelLifecycle.swift +++ b/Sources/Workspace+PanelLifecycle.swift @@ -198,6 +198,7 @@ extension Workspace { manualUnreadMarkedAt.removeValue(forKey: panelId) panelShellActivityStates.removeValue(forKey: panelId) surfaceTTYNames.removeValue(forKey: panelId) + surfaceResumeBindingsByPanelId.removeValue(forKey: panelId) surfaceListeningPorts.removeValue(forKey: panelId) restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId) #if DEBUG diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 27371b3a53f6..a071c3ddb16b 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -162,10 +162,14 @@ extension Workspace { func sessionSnapshot( includeScrollback: Bool, - restorableAgentIndex: RestorableAgentSessionIndex? = nil + restorableAgentIndex: RestorableAgentSessionIndex? = nil, + surfaceResumeBindingIndex: SurfaceResumeBindingIndex? = nil ) -> SessionWorkspaceSnapshot { let tree = bonsplitController.treeSnapshot() let layout = sessionLayoutSnapshot(from: tree) + if let surfaceResumeBindingIndex { + reconcileSurfaceResumeBindings(using: surfaceResumeBindingIndex) + } let orderedPanelIds = sidebarOrderedPanelIds() var seen: Set<UUID> = [] @@ -183,7 +187,11 @@ extension Workspace { sessionPanelSnapshot( panelId: panelId, includeScrollback: includeScrollback, - restorableAgent: restorableAgentIndex?.snapshot(workspaceId: id, panelId: panelId) + restorableAgent: restorableAgentIndex?.snapshot(workspaceId: id, panelId: panelId), + resumeBinding: effectiveSurfaceResumeBinding( + panelId: panelId, + surfaceResumeBindingIndex: surfaceResumeBindingIndex + ) ) } @@ -249,6 +257,7 @@ extension Workspace { restoredAgentSnapshotsByPanelId.removeAll(keepingCapacity: false) restoredAgentResumeStatesByPanelId.removeAll(keepingCapacity: false) invalidatedRestoredAgentFingerprintsByPanelId.removeAll(keepingCapacity: false) + surfaceResumeBindingsByPanelId.removeAll(keepingCapacity: false) let restoredRemoteConfiguration = snapshot.remote?.workspaceConfiguration() if let restoredRemoteConfiguration { @@ -388,7 +397,8 @@ extension Workspace { private func sessionPanelSnapshot( panelId: UUID, includeScrollback: Bool, - restorableAgent: SessionRestorableAgentSnapshot? + restorableAgent: SessionRestorableAgentSnapshot?, + resumeBinding: SurfaceResumeBindingSnapshot? ) -> SessionPanelSnapshot? { guard let panel = panels[panelId] else { return nil } @@ -453,12 +463,18 @@ extension Workspace { let restorableTmuxStartCommand = effectiveRestorableAgent == nil ? Self.restorableTmuxStartCommand(terminalPanel.surface.debugTmuxStartCommand()) : nil + let resumeStartupInput = Self.surfaceResumeStartupInput( + resumeBinding, + autoResumeAgentSessions: AgentSessionAutoResumeSettings.isEnabled(), + promptForApproval: false + ) let shouldPersistScrollback = Self.shouldPersistSessionScrollback( shellActivityState: panelShellActivityStates[panelId], fallbackNeedsConfirmClose: terminalPanel.needsConfirmClose() ) && Self.shouldReplaySessionScrollback( restorableAgent: effectiveRestorableAgent, - tmuxStartCommand: restorableTmuxStartCommand + tmuxStartCommand: restorableTmuxStartCommand, + resumeStartupInput: resumeStartupInput ) #if DEBUG let allowDebugFallbackScrollback = debugSessionSnapshotScrollbackFallbackPanelIds.contains(panelId) @@ -482,7 +498,8 @@ extension Workspace { workingDirectory: directory, scrollback: resolvedScrollback, agent: effectiveRestorableAgent, - tmuxStartCommand: restorableTmuxStartCommand + tmuxStartCommand: restorableTmuxStartCommand, + resumeBinding: resumeBinding ) browserSnapshot = nil markdownSnapshot = nil @@ -561,12 +578,69 @@ extension Workspace { nonisolated static func shouldReplaySessionScrollback( restorableAgent: SessionRestorableAgentSnapshot?, - tmuxStartCommand: String? = nil + tmuxStartCommand: String? = nil, + resumeStartupInput: String? = nil ) -> Bool { // Agent restores relaunch from the provider's session ID. Replaying the // old TUI scrollback can print stale launch commands and race the resume input. // OMX HUD panes restore from their tmux start command for the same reason. - restorableAgent == nil && restorableTmuxStartCommand(tmuxStartCommand) == nil + restorableAgent == nil && restorableTmuxStartCommand(tmuxStartCommand) == nil && resumeStartupInput == nil + } + + nonisolated static func surfaceResumeStartupInput( + _ resumeBinding: SurfaceResumeBindingSnapshot?, + autoResumeAgentSessions: Bool, + allowLauncherScript: Bool = false, + promptForApproval: Bool = true, + approvalStoreURL: URL = SurfaceResumeApprovalStore.defaultURL(), + approvalSigningSecret: Data? = nil + ) -> String? { + guard let resumeBinding else { return nil } + let effectiveBinding = SurfaceResumeApprovalStore.applyingStoredApproval( + to: resumeBinding, + fileURL: approvalStoreURL, + signingSecret: approvalSigningSecret + ) + if effectiveBinding.source == "agent-hook", !autoResumeAgentSessions { + return nil + } + if effectiveBinding.approvalPolicy == .prompt { + guard promptForApproval else { return nil } + guard shouldRunPromptedSurfaceResume(effectiveBinding) else { return nil } + return effectiveBinding.startupInputWithLauncherScript(allowLauncherScript: allowLauncherScript) + } + guard effectiveBinding.allowsAutomaticResume else { return nil } + return effectiveBinding.startupInputWithLauncherScript(allowLauncherScript: allowLauncherScript) + } + + nonisolated private static func shouldRunPromptedSurfaceResume(_ binding: SurfaceResumeBindingSnapshot) -> Bool { + guard Thread.isMainThread, ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil else { + return false + } + return MainActor.assumeIsolated { + shouldRunPromptedSurfaceResumeOnMain(binding) + } + } + + @MainActor + private static func shouldRunPromptedSurfaceResumeOnMain(_ binding: SurfaceResumeBindingSnapshot) -> Bool { + let alert = NSAlert() + alert.alertStyle = .informational + alert.messageText = String( + localized: "surfaceResumeApproval.runPrompt.title", + defaultValue: "Run Resume Command?" + ) + alert.informativeText = String( + format: String( + localized: "surfaceResumeApproval.runPrompt.message", + defaultValue: "cmux is restoring a terminal with this resume command:\n\n%@\n\nWorking directory: %@" + ), + binding.command, + binding.cwd ?? String(localized: "surfaceResumeApproval.cwd.none", defaultValue: "None") + ) + alert.addButton(withTitle: String(localized: "surfaceResumeApproval.runPrompt.run", defaultValue: "Run")) + alert.addButton(withTitle: String(localized: "surfaceResumeApproval.runPrompt.skip", defaultValue: "Skip")) + return alert.runModal() == .alertFirstButtonReturn } nonisolated static func restorableTmuxStartCommand(_ rawCommand: String?) -> String? { @@ -762,17 +836,68 @@ extension Workspace { } } + func reconcileSurfaceResumeBindings(using surfaceResumeBindingIndex: SurfaceResumeBindingIndex) { + for panelId in panels.keys { + let storedBinding = surfaceResumeBindingsByPanelId[panelId] + let detectedBinding = surfaceResumeBindingIndex.binding(workspaceId: id, panelId: panelId) + + guard let storedBinding else { + if let detectedBinding, detectedBinding.isProcessDetected { + surfaceResumeBindingsByPanelId[panelId] = detectedBinding + } + continue + } + guard let detectedBinding else { + if storedBinding.isProcessDetected { + surfaceResumeBindingsByPanelId.removeValue(forKey: panelId) + } + continue + } + if storedBinding.shouldYieldToDetectedSurfaceResumeBinding(detectedBinding) { + surfaceResumeBindingsByPanelId[panelId] = detectedBinding + } else if storedBinding.isProcessDetected { + surfaceResumeBindingsByPanelId.removeValue(forKey: panelId) + } + } + } + + func effectiveSurfaceResumeBinding( + panelId: UUID, + surfaceResumeBindingIndex: SurfaceResumeBindingIndex? + ) -> SurfaceResumeBindingSnapshot? { + let storedBinding = surfaceResumeBindingsByPanelId[panelId] + guard let surfaceResumeBindingIndex else { + return storedBinding + } + + let detectedBinding = surfaceResumeBindingIndex.binding(workspaceId: id, panelId: panelId) + guard let storedBinding else { return detectedBinding } + guard let detectedBinding else { return storedBinding.isProcessDetected ? nil : storedBinding } + if storedBinding.shouldYieldToDetectedSurfaceResumeBinding(detectedBinding) { return detectedBinding } + if storedBinding.isProcessDetected { return nil } + return storedBinding + } + private func createPanel(from snapshot: SessionPanelSnapshot, inPane paneId: PaneID) -> UUID? { switch snapshot.type { case .terminal: + let resumeBinding = snapshot.terminal?.resumeBinding + let restorableAgent = snapshot.terminal?.agent + let autoResumeAgentSessions = AgentSessionAutoResumeSettings.isEnabled() + let restoredBindingInput = Self.surfaceResumeStartupInput( + resumeBinding, + autoResumeAgentSessions: autoResumeAgentSessions, + allowLauncherScript: true + ) + let effectiveResumeBinding = restoredBindingInput == nil ? nil : resumeBinding let workingDirectory = - snapshot.terminal?.workingDirectory - ?? snapshot.terminal?.agent?.workingDirectory + effectiveResumeBinding?.cwd + ?? snapshot.terminal?.workingDirectory + ?? restorableAgent?.workingDirectory ?? snapshot.directory ?? currentDirectory let localWorkingDirectory = remoteTerminalStartupCommand() == nil ? workingDirectory : nil - let restorableAgent = snapshot.terminal?.agent - let restorableTmuxStartCommand = restorableAgent == nil + let restorableTmuxStartCommand = restorableAgent == nil && restoredBindingInput == nil ? Self.restorableTmuxStartCommand(snapshot.terminal?.tmuxStartCommand) : nil let restoredTmuxStartupScript = restorableTmuxStartCommand.flatMap { @@ -784,12 +909,17 @@ extension Workspace { let restoredTmuxStartCommand = restoredTmuxStartupScript == nil ? nil : restorableTmuxStartCommand let shouldReplayScrollback = Self.shouldReplaySessionScrollback( restorableAgent: restorableAgent, - tmuxStartCommand: restoredTmuxStartCommand + tmuxStartCommand: restoredTmuxStartCommand, + resumeStartupInput: restoredBindingInput ) - let autoResumeAgentSessions = AgentSessionAutoResumeSettings.isEnabled() let restoredAgentResumeInput = autoResumeAgentSessions - ? restorableAgent?.resumeStartupInput() + ? (restoredBindingInput == nil ? restorableAgent?.resumeStartupInput() : nil) : nil + let restoredStartupInput = restoredBindingInput ?? restoredAgentResumeInput + let restoredAgentWillRunStartupInput = restorableAgent != nil && ( + restoredAgentResumeInput != nil || + (restoredBindingInput != nil && resumeBinding?.isAgentHookBinding == true) + ) #if DEBUG if let restorableAgent { let sessionPreview = String(restorableAgent.sessionId.prefix(8)) @@ -803,6 +933,14 @@ extension Workspace { "replayScrollback=\(shouldReplayScrollback ? 1 : 0)" ) } + if let resumeBinding { + cmuxDebugLog( + "session.restore.surfaceResume panel=\(snapshot.id.uuidString.prefix(5)) " + + "kind=\(resumeBinding.kind ?? "unknown") source=\(resumeBinding.source ?? "unknown") " + + "hasInput=\(restoredBindingInput == nil ? 0 : 1) " + + "replayScrollback=\(shouldReplayScrollback ? 1 : 0)" + ) + } #endif let replayEnvironment = SessionScrollbackReplayStore.replayEnvironment( for: shouldReplayScrollback ? snapshot.terminal?.scrollback : nil @@ -813,11 +951,16 @@ extension Workspace { workingDirectory: localWorkingDirectory, initialCommand: restoredTmuxStartupScript?.path, tmuxStartCommand: restoredTmuxStartCommand, - initialInput: restoredAgentResumeInput, + initialInput: restoredStartupInput, startupEnvironment: replayEnvironment ) else { return nil } + if let resumeBinding { + surfaceResumeBindingsByPanelId[terminalPanel.id] = resumeBinding + } else { + surfaceResumeBindingsByPanelId.removeValue(forKey: terminalPanel.id) + } let fallbackScrollback = shouldReplayScrollback ? SessionPersistencePolicy.truncatedScrollback(snapshot.terminal?.scrollback) : nil @@ -828,7 +971,7 @@ extension Workspace { } if let restorableAgent { restoredAgentSnapshotsByPanelId[terminalPanel.id] = restorableAgent - if restoredAgentResumeInput != nil { + if restoredAgentWillRunStartupInput { restoredAgentResumeStatesByPanelId[terminalPanel.id] = .awaitingAutoResumeCommand } else { restoredAgentResumeStatesByPanelId[terminalPanel.id] = .manualResumeAvailable @@ -7349,6 +7492,7 @@ final class Workspace: Identifiable, ObservableObject { var debugSessionSnapshotSyntheticScrollbackByPanelId: [UUID: String] = [:] #endif var restoredAgentSnapshotsByPanelId: [UUID: SessionRestorableAgentSnapshot] = [:] + var surfaceResumeBindingsByPanelId: [UUID: SurfaceResumeBindingSnapshot] = [:] enum RestoredAgentResumeState: Equatable { case manualResumeAvailable case awaitingAutoResumeCommand @@ -8807,6 +8951,7 @@ final class Workspace: Identifiable, ObservableObject { ) { let fingerprint = TabManager.restorableAgentSnapshotFingerprint(restoredAgent) invalidatedRestoredAgentFingerprintsByPanelId[panelId] = fingerprint + clearRestoredAgentResumeBinding(panelId: panelId, restoredAgent: restoredAgent) clearRestoredAgentSnapshot(panelId: panelId) #if DEBUG cmuxDebugLog( @@ -8821,6 +8966,41 @@ final class Workspace: Identifiable, ObservableObject { restoredAgentResumeStatesByPanelId.removeValue(forKey: panelId) } + private func clearRestoredAgentResumeBinding( + panelId: UUID, + restoredAgent: SessionRestorableAgentSnapshot + ) { + guard let binding = surfaceResumeBindingsByPanelId[panelId], + binding.source == "agent-hook" else { + return + } + let checkpointId = binding.checkpointId?.trimmingCharacters(in: .whitespacesAndNewlines) + guard checkpointId == nil || checkpointId == restoredAgent.sessionId else { + return + } + surfaceResumeBindingsByPanelId.removeValue(forKey: panelId) + } + + @discardableResult + func setSurfaceResumeBinding(_ binding: SurfaceResumeBindingSnapshot, panelId: UUID) -> Bool { + guard terminalPanel(for: panelId) != nil, + let startupInput = binding.startupInput, + !startupInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return false + } + surfaceResumeBindingsByPanelId[panelId] = binding + return true + } + + @discardableResult + func clearSurfaceResumeBinding(panelId: UUID) -> Bool { + surfaceResumeBindingsByPanelId.removeValue(forKey: panelId) != nil + } + + func surfaceResumeBinding(panelId: UUID) -> SurfaceResumeBindingSnapshot? { + surfaceResumeBindingsByPanelId[panelId] + } + func panelNeedsConfirmClose(panelId: UUID, fallbackNeedsConfirmClose: Bool) -> Bool { Self.resolveCloseConfirmation( shellActivityState: panelShellActivityStates[panelId], @@ -9056,6 +9236,9 @@ final class Workspace: Identifiable, ObservableObject { restoredAgentSnapshotsByPanelId = restoredAgentSnapshotsByPanelId.filter { validSurfaceIds.contains($0.key) } + surfaceResumeBindingsByPanelId = surfaceResumeBindingsByPanelId.filter { + validSurfaceIds.contains($0.key) + } restoredAgentResumeStatesByPanelId = restoredAgentResumeStatesByPanelId.filter { validSurfaceIds.contains($0.key) } @@ -11584,6 +11767,7 @@ final class Workspace: Identifiable, ObservableObject { panels.removeValue(forKey: detached.panelId) panelDirectories.removeValue(forKey: detached.panelId) surfaceTTYNames.removeValue(forKey: detached.panelId) + surfaceResumeBindingsByPanelId.removeValue(forKey: detached.panelId) syncRemotePortScanTTYs() panelTitles.removeValue(forKey: detached.panelId) panelCustomTitles.removeValue(forKey: detached.panelId) @@ -11634,6 +11818,11 @@ final class Workspace: Identifiable, ObservableObject { } else { restoredAgentResumeStatesByPanelId.removeValue(forKey: detached.panelId) } + if let resumeBinding = detached.resumeBinding, !resumeBinding.isProcessDetected { + surfaceResumeBindingsByPanelId[detached.panelId] = resumeBinding + } else { + surfaceResumeBindingsByPanelId.removeValue(forKey: detached.panelId) + } adoptDetachedAgentRuntimeState(detached.agentRuntime) if let markdownPanel = detached.panel as? MarkdownPanel, panelSubscriptions[markdownPanel.id] == nil { @@ -14059,6 +14248,10 @@ extension Workspace: BonsplitDelegate { let transferFallbackTitle = cachedTitle ?? panel.displayTitle let restorableAgent = restoredAgentSnapshotsByPanelId[panelId] let restorableAgentResumeState = restoredAgentResumeStatesByPanelId[panelId] + let resumeBinding = effectiveSurfaceResumeBinding( + panelId: panelId, + surfaceResumeBindingIndex: nil + ) let agentRuntime = agentRuntimeState(forPanelId: panelId) pendingDetachedSurfaces[tabId] = DetachedSurfaceTransfer( sourceWorkspaceId: id, @@ -14078,6 +14271,7 @@ extension Workspace: BonsplitDelegate { restoredUnread: restoredUnreadPanelIds.contains(panelId), restorableAgent: restorableAgent, restorableAgentResumeState: restorableAgentResumeState, + resumeBinding: resumeBinding, agentRuntime: agentRuntime, isRemoteTerminal: activeRemoteTerminalSurfaceIds.contains(panelId), remoteRelayPort: activeRemoteTerminalSurfaceIds.contains(panelId) diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 6844f3ac82e3..aacfe3883975 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -6302,6 +6302,8 @@ struct SettingsView: View { } } + SurfaceResumeApprovalSettingsCard() + SettingsSectionHeader(title: String(localized: "settings.section.sidebarAppearance", defaultValue: "Sidebar")) .settingsSearchAnchor(SettingsSearchIndex.sectionID(for: .sidebarAppearance)) SettingsCard { @@ -7551,6 +7553,241 @@ struct SettingsView: View { } } +private struct SurfaceResumeApprovalSettingsCard: View { + @State private var records: [SurfaceResumeApprovalRecord] = [] + @State private var prefixDrafts: [String: String] = [:] + @State private var statusMessage: String? + @State private var statusIsError = false + + var body: some View { + SettingsCard { + SettingsCardRow( + configurationReview: .settingsOnly, + String(localized: "settings.terminal.resumeCommands", defaultValue: "Resume Commands"), + subtitle: String( + localized: "settings.terminal.resumeCommands.subtitle", + defaultValue: "Review signed command prefixes that can restore non-agent terminal surfaces." + ), + controlWidth: 80, + searchAnchorID: SettingsSearchIndex.settingID(for: .terminal, idSuffix: "resume-commands") + ) { + Text(String(format: "%d", records.count)) + .font(.caption.monospacedDigit()) + .foregroundColor(.secondary) + } + + if records.isEmpty { + SettingsCardDivider() + Text(String(localized: "settings.terminal.resumeCommands.empty", defaultValue: "No custom resume commands have been approved.")) + .font(.caption) + .foregroundColor(.secondary) + .padding(.horizontal, 14) + .padding(.vertical, 10) + } else { + ForEach(records) { record in + SettingsCardDivider() + SurfaceResumeApprovalRecordRow( + record: record, + prefixDraft: Binding( + get: { prefixDrafts[record.id] ?? record.commandPrefixText }, + set: { prefixDrafts[record.id] = $0 } + ), + isValid: SurfaceResumeApprovalStore.isValid(record), + onPolicyChange: { policy in updatePolicy(record, policy: policy) }, + onSavePrefix: { savePrefix(record) }, + onDelete: { delete(record) } + ) + } + } + + if let statusMessage { + SettingsCardDivider() + Text(statusMessage) + .font(.caption) + .foregroundColor(statusIsError ? .red : .secondary) + .padding(.horizontal, 14) + .padding(.vertical, 9) + } + } + .onAppear(perform: reload) + .onReceive(NotificationCenter.default.publisher(for: SurfaceResumeApprovalStore.didChangeNotification)) { _ in + reload() + } + } + + private func reload() { + let loadedRecords = SurfaceResumeApprovalStore.loadRecords() + .sorted { lhs, rhs in lhs.updatedAt > rhs.updatedAt } + records = loadedRecords + let validIds = Set(loadedRecords.map(\.id)) + prefixDrafts = prefixDrafts.filter { validIds.contains($0.key) } + for record in loadedRecords where prefixDrafts[record.id] == nil { + prefixDrafts[record.id] = record.commandPrefixText + } + } + + private func updatePolicy(_ record: SurfaceResumeApprovalRecord, policy: SurfaceResumeApprovalPolicy) { + guard SurfaceResumeApprovalStore.update(recordId: record.id, policy: policy) else { + setStatus( + String(localized: "settings.terminal.resumeCommands.updateFailed", defaultValue: "Could not update the resume command approval."), + isError: true + ) + return + } + setStatus(String(localized: "settings.terminal.resumeCommands.updated", defaultValue: "Resume command approval updated.")) + reload() + } + + private func savePrefix(_ record: SurfaceResumeApprovalRecord) { + let draft = prefixDrafts[record.id] ?? record.commandPrefixText + guard let tokens = SurfaceResumeCommandCanonicalizer.tokens(from: draft), !tokens.isEmpty else { + setStatus( + String(localized: "settings.terminal.resumeCommands.invalidPrefix", defaultValue: "Enter a valid shell-style command prefix."), + isError: true + ) + return + } + guard SurfaceResumeApprovalStore.update(recordId: record.id, commandPrefix: tokens) else { + setStatus( + String(localized: "settings.terminal.resumeCommands.updateFailed", defaultValue: "Could not update the resume command approval."), + isError: true + ) + return + } + setStatus(String(localized: "settings.terminal.resumeCommands.updated", defaultValue: "Resume command approval updated.")) + reload() + } + + private func delete(_ record: SurfaceResumeApprovalRecord) { + guard SurfaceResumeApprovalStore.delete(recordId: record.id) else { + setStatus( + String(localized: "settings.terminal.resumeCommands.deleteFailed", defaultValue: "Could not delete the resume command approval."), + isError: true + ) + return + } + setStatus(String(localized: "settings.terminal.resumeCommands.deleted", defaultValue: "Resume command approval deleted.")) + reload() + } + + private func setStatus(_ message: String, isError: Bool = false) { + statusMessage = message + statusIsError = isError + } +} + +private struct SurfaceResumeApprovalRecordRow: View { + let record: SurfaceResumeApprovalRecord + @Binding var prefixDraft: String + let isValid: Bool + let onPolicyChange: (SurfaceResumeApprovalPolicy) -> Void + let onSavePrefix: () -> Void + let onDelete: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 3) { + Text(record.name ?? record.commandPrefixText) + .font(.system(size: 13, weight: .medium)) + .lineLimit(1) + Text(summaryText) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(2) + } + Spacer(minLength: 12) + Picker("", selection: policyBinding) { + ForEach(SurfaceResumeApprovalPolicy.allCases, id: \.self) { policy in + Text(policy.localizedSettingsTitle).tag(policy) + } + } + .labelsHidden() + .pickerStyle(.menu) + .disabled(!isValid) + .frame(width: 136) + } + + TextField( + String(localized: "settings.terminal.resumeCommands.prefixPlaceholder", defaultValue: "Command prefix"), + text: $prefixDraft + ) + .textFieldStyle(.roundedBorder) + .font(.system(.caption, design: .monospaced)) + .disabled(!isValid) + .onSubmit(onSavePrefix) + + HStack(spacing: 8) { + if !isValid { + Text(String(localized: "settings.terminal.resumeCommands.invalidSignature", defaultValue: "Signature does not match. Delete this approval and approve the command again.")) + .font(.caption) + .foregroundColor(.red) + .lineLimit(2) + } else { + Text(record.source ?? String(localized: "settings.terminal.resumeCommands.sourceUnknown", defaultValue: "Unknown source")) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + } + Spacer() + Button(String(localized: "settings.terminal.resumeCommands.save", defaultValue: "Save")) { + onSavePrefix() + } + .controlSize(.small) + .disabled(!isValid) + Button(String(localized: "settings.terminal.resumeCommands.delete", defaultValue: "Delete"), role: .destructive) { + onDelete() + } + .controlSize(.small) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + } + + private var policyBinding: Binding<SurfaceResumeApprovalPolicy> { + Binding( + get: { record.policy }, + set: { onPolicyChange($0) } + ) + } + + private var summaryText: String { + var parts = [ + String( + format: String(localized: "settings.terminal.resumeCommands.prefixSummary", defaultValue: "Prefix: %@"), + record.commandPrefixText + ) + ] + if let cwd = record.cwd { + parts.append(String( + format: String(localized: "settings.terminal.resumeCommands.cwdOnlySummary", defaultValue: "cwd: %@"), + cwd + )) + } + if !record.environmentKeys.isEmpty { + parts.append(String( + format: String(localized: "settings.terminal.resumeCommands.envSummary", defaultValue: "env: %@"), + record.environmentKeys.joined(separator: ", ") + )) + } + return parts.joined(separator: ", ") + } +} + +private extension SurfaceResumeApprovalPolicy { + var localizedSettingsTitle: String { + switch self { + case .manual: + return String(localized: "settings.terminal.resumeCommands.policy.manual", defaultValue: "Manual") + case .prompt: + return String(localized: "settings.terminal.resumeCommands.policy.prompt", defaultValue: "Ask") + case .auto: + return String(localized: "settings.terminal.resumeCommands.policy.auto", defaultValue: "Auto") + } + } +} + struct SettingsSectionHeader: View { let title: String diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index fe94658271db..8a99db12fd1d 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -326,6 +326,7 @@ A5001601 /* SentryHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001600 /* SentryHelper.swift */; }; A5001610 /* SessionPersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001611 /* SessionPersistence.swift */; }; A5001670 /* SessionRestoredTerminalCommandStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001671 /* SessionRestoredTerminalCommandStore.swift */; }; + B35751000000000000000002 /* TmuxResumeParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = B35751000000000000000001 /* TmuxResumeParser.swift */; }; A5001621 /* AppleScriptSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001620 /* AppleScriptSupport.swift */; }; C3873001C3873001C3873001 /* GhosttyCrashBreadcrumb.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3873002C3873002C3873002 /* GhosttyCrashBreadcrumb.swift */; }; C3873003C3873003C3873003 /* GhosttyCrashReportMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3873004C3873004C3873004 /* GhosttyCrashReportMetadata.swift */; }; @@ -865,6 +866,7 @@ A5001600 /* SentryHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryHelper.swift; sourceTree = "<group>"; }; A5001611 /* SessionPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistence.swift; sourceTree = "<group>"; }; A5001671 /* SessionRestoredTerminalCommandStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionRestoredTerminalCommandStore.swift; sourceTree = "<group>"; }; + B35751000000000000000001 /* TmuxResumeParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxResumeParser.swift; sourceTree = "<group>"; }; A5001620 /* AppleScriptSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleScriptSupport.swift; sourceTree = "<group>"; }; A5001622 /* cmux.sdef */ = {isa = PBXFileReference; lastKnownFileType = text.sdef; path = cmux.sdef; sourceTree = "<group>"; }; A5001641 /* RemoteRelayZshBootstrap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteRelayZshBootstrap.swift; sourceTree = "<group>"; }; @@ -1410,6 +1412,7 @@ A5001222 /* WindowAccessor.swift */, A5001611 /* SessionPersistence.swift */, A5001671 /* SessionRestoredTerminalCommandStore.swift */, + B35751000000000000000001 /* TmuxResumeParser.swift */, A5001661 /* RestorableAgentSession.swift */, C0DEF0C10000000000000002 /* AgentForkSupport.swift */, C0DEF0C20000000000000002 /* SemanticVersion.swift */, @@ -2143,6 +2146,7 @@ A500120C /* WindowAccessor.swift in Sources */, A5001610 /* SessionPersistence.swift in Sources */, A5001670 /* SessionRestoredTerminalCommandStore.swift in Sources */, + B35751000000000000000002 /* TmuxResumeParser.swift in Sources */, A5001660 /* RestorableAgentSession.swift in Sources */, C0DEF0C10000000000000001 /* AgentForkSupport.swift in Sources */, C0DEF0C20000000000000001 /* SemanticVersion.swift in Sources */, diff --git a/cmuxTests/AgentSessionAutoResumeSettingsTests.swift b/cmuxTests/AgentSessionAutoResumeSettingsTests.swift index 1a19de57de9b..ec96aed098e4 100644 --- a/cmuxTests/AgentSessionAutoResumeSettingsTests.swift +++ b/cmuxTests/AgentSessionAutoResumeSettingsTests.swift @@ -109,6 +109,224 @@ final class AgentSessionAutoResumeSettingsTests: XCTestCase { XCTAssertNil(restoredWithoutAutoResume.sessionSnapshot(includeScrollback: false).panels.first?.terminal?.agent) } + @MainActor + func testDisabledAutoResumeDoesNotRunAgentHookResumeBinding() throws { + let defaults = UserDefaults.standard + let key = AgentSessionAutoResumeSettings.autoResumeAgentSessionsKey + let previous = defaults.object(forKey: key) + defer { + if let previous { + defaults.set(previous, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + + let source = Workspace() + let sourcePanelId = try XCTUnwrap(source.focusedPanelId) + let sourceIndex = try makeRestorableAgentIndex( + workspaceId: source.id, + panelId: sourcePanelId, + sessionId: "codex-binding-auto-resume-disabled-session" + ) + let bindingIndex = SurfaceResumeBindingIndex(bindingsByPanel: [ + SurfaceResumeBindingIndex.PanelKey(workspaceId: source.id, panelId: sourcePanelId): SurfaceResumeBindingSnapshot( + name: "Codex", + kind: "codex", + command: "codex resume codex-binding-auto-resume-disabled-session", + cwd: "/tmp/repo", + checkpointId: "codex-binding-auto-resume-disabled-session", + source: "agent-hook", + updatedAt: 1_777_777_777 + ), + ]) + let snapshot = source.sessionSnapshot( + includeScrollback: false, + restorableAgentIndex: sourceIndex, + surfaceResumeBindingIndex: bindingIndex + ) + + defaults.set(false, forKey: key) + let restored = Workspace() + restored.restoreSessionSnapshot(snapshot) + let restoredPanelId = try XCTUnwrap(restored.focusedPanelId) + let restoredPanel = try XCTUnwrap(restored.terminalPanel(for: restoredPanelId)) + let input = restoredPanel.surface.debugInitialInputMetadata() + + XCTAssertFalse(input.hasInitialInput) + XCTAssertEqual(input.byteCount, 0) + XCTAssertEqual( + restored.sessionSnapshot(includeScrollback: false).panels.first?.terminal?.resumeBinding?.source, + "agent-hook" + ) + XCTAssertEqual( + restored.sessionSnapshot(includeScrollback: false).panels.first?.terminal?.agent?.sessionId, + "codex-binding-auto-resume-disabled-session" + ) + + restored.updatePanelShellActivityState(panelId: restoredPanelId, state: .commandRunning) + let userCommandSnapshot = restored.sessionSnapshot(includeScrollback: false) + XCTAssertNil(userCommandSnapshot.panels.first?.terminal?.agent) + XCTAssertNil(userCommandSnapshot.panels.first?.terminal?.resumeBinding) + } + + @MainActor + func testDisabledAutoResumeKeepsScrollbackForSuppressedAgentHookBinding() throws { + let defaults = UserDefaults.standard + let key = AgentSessionAutoResumeSettings.autoResumeAgentSessionsKey + let previous = defaults.object(forKey: key) + defer { + if let previous { + defaults.set(previous, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + + let source = Workspace() + let sourcePanelId = try XCTUnwrap(source.focusedPanelId) + let bindingIndex = SurfaceResumeBindingIndex(bindingsByPanel: [ + SurfaceResumeBindingIndex.PanelKey(workspaceId: source.id, panelId: sourcePanelId): SurfaceResumeBindingSnapshot( + name: "OpenCode", + kind: "opencode", + command: "opencode --session suppressed-binding-session", + cwd: "/tmp/repo", + checkpointId: "suppressed-binding-session", + source: "agent-hook", + updatedAt: 1_777_777_777 + ), + ]) + var snapshot = source.sessionSnapshot( + includeScrollback: false, + surfaceResumeBindingIndex: bindingIndex + ) + let panelIndex = try XCTUnwrap(snapshot.panels.indices.first) + let savedScrollback = "previous output\n" + snapshot.panels[panelIndex].terminal?.scrollback = savedScrollback + + defaults.set(false, forKey: key) + let restored = Workspace() + restored.restoreSessionSnapshot(snapshot) + let restoredPanelId = try XCTUnwrap(restored.focusedPanelId) + let restoredPanel = try XCTUnwrap(restored.terminalPanel(for: restoredPanelId)) + let input = restoredPanel.surface.debugInitialInputMetadata() + + XCTAssertFalse(input.hasInitialInput) + XCTAssertEqual(input.byteCount, 0) + XCTAssertEqual( + restored.sessionSnapshot(includeScrollback: true).panels.first?.terminal?.scrollback, + savedScrollback + ) + } + + @MainActor + func testAgentHookResumeBindingKeepsRestoredAgentPendingDuringStartupCommand() throws { + let defaults = UserDefaults.standard + let key = AgentSessionAutoResumeSettings.autoResumeAgentSessionsKey + let previous = defaults.object(forKey: key) + defer { + if let previous { + defaults.set(previous, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + defaults.set(true, forKey: key) + + let source = Workspace() + let sourcePanelId = try XCTUnwrap(source.focusedPanelId) + let sourceIndex = try makeRestorableAgentIndex( + workspaceId: source.id, + panelId: sourcePanelId, + sessionId: "codex-binding-auto-resume-session" + ) + let bindingIndex = SurfaceResumeBindingIndex(bindingsByPanel: [ + SurfaceResumeBindingIndex.PanelKey(workspaceId: source.id, panelId: sourcePanelId): SurfaceResumeBindingSnapshot( + name: "Codex", + kind: "codex", + command: "codex resume codex-binding-auto-resume-session", + cwd: "/tmp/repo", + checkpointId: "codex-binding-auto-resume-session", + source: "agent-hook", + autoResume: true, + updatedAt: 1_777_777_777 + ), + ]) + let snapshot = source.sessionSnapshot( + includeScrollback: false, + restorableAgentIndex: sourceIndex, + surfaceResumeBindingIndex: bindingIndex + ) + + let restored = Workspace() + restored.restoreSessionSnapshot(snapshot) + let restoredPanelId = try XCTUnwrap(restored.focusedPanelId) + let restoredPanel = try XCTUnwrap(restored.terminalPanel(for: restoredPanelId)) + XCTAssertTrue(restoredPanel.surface.debugInitialInputMetadata().hasInitialInput) + + restored.updatePanelShellActivityState(panelId: restoredPanelId, state: .commandRunning) + XCTAssertEqual( + restored.sessionSnapshot(includeScrollback: false).panels.first?.terminal?.agent?.sessionId, + "codex-binding-auto-resume-session" + ) + + restored.updatePanelShellActivityState(panelId: restoredPanelId, state: .promptIdle) + let completedSnapshot = restored.sessionSnapshot(includeScrollback: false) + XCTAssertNil(completedSnapshot.panels.first?.terminal?.agent) + XCTAssertNil(completedSnapshot.panels.first?.terminal?.resumeBinding) + } + + @MainActor + func testNonAgentResumeBindingDoesNotMarkRestoredAgentAwaitingAutoResume() throws { + let defaults = UserDefaults.standard + let key = AgentSessionAutoResumeSettings.autoResumeAgentSessionsKey + let previous = defaults.object(forKey: key) + defer { + if let previous { + defaults.set(previous, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + defaults.set(true, forKey: key) + + let source = Workspace() + let sourcePanelId = try XCTUnwrap(source.focusedPanelId) + let sourceIndex = try makeRestorableAgentIndex( + workspaceId: source.id, + panelId: sourcePanelId, + sessionId: "codex-agent-inside-tmux-session" + ) + let bindingIndex = SurfaceResumeBindingIndex(bindingsByPanel: [ + SurfaceResumeBindingIndex.PanelKey(workspaceId: source.id, panelId: sourcePanelId): SurfaceResumeBindingSnapshot( + name: "tmux work", + kind: "tmux", + command: "tmux attach -t work", + cwd: "/tmp/repo", + checkpointId: "work", + source: "process-detected", + autoResume: true, + updatedAt: 1_777_777_777 + ), + ]) + let snapshot = source.sessionSnapshot( + includeScrollback: false, + restorableAgentIndex: sourceIndex, + surfaceResumeBindingIndex: bindingIndex + ) + + let restored = Workspace() + restored.restoreSessionSnapshot(snapshot) + let restoredPanelId = try XCTUnwrap(restored.focusedPanelId) + let restoredPanel = try XCTUnwrap(restored.terminalPanel(for: restoredPanelId)) + XCTAssertTrue(restoredPanel.surface.debugInitialInputMetadata().hasInitialInput) + + restored.updatePanelShellActivityState(panelId: restoredPanelId, state: .commandRunning) + let runningSnapshot = restored.sessionSnapshot(includeScrollback: false) + XCTAssertNil(runningSnapshot.panels.first?.terminal?.agent) + XCTAssertEqual(runningSnapshot.panels.first?.terminal?.resumeBinding?.kind, "tmux") + } + private func makeRestorableAgentIndex( workspaceId: UUID, panelId: UUID, @@ -116,6 +334,15 @@ final class AgentSessionAutoResumeSettingsTests: XCTestCase { ) throws -> RestorableAgentSessionIndex { let home = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-agent-auto-resume-\(UUID().uuidString)", isDirectory: true) + let previousHookStateDir = getenv("CMUX_AGENT_HOOK_STATE_DIR").map { String(cString: $0) } + setenv("CMUX_AGENT_HOOK_STATE_DIR", home.appendingPathComponent("hook-state", isDirectory: true).path, 1) + defer { + if let previousHookStateDir { + setenv("CMUX_AGENT_HOOK_STATE_DIR", previousHookStateDir, 1) + } else { + unsetenv("CMUX_AGENT_HOOK_STATE_DIR") + } + } let storeURL = RestorableAgentKind.codex.hookStoreFileURL(homeDirectory: home.path) try FileManager.default.createDirectory(at: storeURL.deletingLastPathComponent(), withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: home) } diff --git a/cmuxTests/AppDelegateIssue2907RoutingTests.swift b/cmuxTests/AppDelegateIssue2907RoutingTests.swift index c9143f763e91..5aeff940bbce 100644 --- a/cmuxTests/AppDelegateIssue2907RoutingTests.swift +++ b/cmuxTests/AppDelegateIssue2907RoutingTests.swift @@ -121,6 +121,408 @@ final class AppDelegateIssue2907RoutingTests: XCTestCase { try assertWorkspaceListContains(try workspaceListPayload(surfaceId: surfaceId), workspaceId: workspace.id) } + func testSurfaceResumeSetRejectsSurfaceOutsideExplicitWindow() throws { + _ = NSApplication.shared + let previousAppDelegate = AppDelegate.shared + let app = AppDelegate() + defer { + AppDelegate.shared = previousAppDelegate + } + + let firstWindowId = UUID() + let secondWindowId = UUID() + let firstWindow = makeMainWindow(id: firstWindowId) + let secondWindow = makeMainWindow(id: secondWindowId) + defer { + TerminalController.shared.setActiveTabManager(nil) + app.unregisterMainWindowContextForTesting(windowId: firstWindowId) + app.unregisterMainWindowContextForTesting(windowId: secondWindowId) + firstWindow.orderOut(nil) + secondWindow.orderOut(nil) + } + + let firstManager = TabManager(autoWelcomeIfNeeded: false) + let secondManager = TabManager(autoWelcomeIfNeeded: false) + app.registerMainWindow( + firstWindow, + windowId: firstWindowId, + tabManager: firstManager, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState(), + fileExplorerState: FileExplorerState() + ) + app.registerMainWindow( + secondWindow, + windowId: secondWindowId, + tabManager: secondManager, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState(), + fileExplorerState: FileExplorerState() + ) + TerminalController.shared.setActiveTabManager(firstManager) + + let secondWorkspace = try XCTUnwrap(secondManager.selectedWorkspace) + let secondPanelId = try XCTUnwrap(secondWorkspace.focusedPanelId) + let (raw, envelope) = try v2Envelope( + method: "surface.resume.set", + params: [ + "window_id": firstWindowId.uuidString, + "surface_id": secondPanelId.uuidString, + "command": "echo wrong-window" + ] + ) + + XCTAssertEqual(envelope["ok"] as? Bool, false, raw) + XCTAssertNil(secondWorkspace.surfaceResumeBinding(panelId: secondPanelId)) + } + + func testSurfaceResumeRejectsMalformedSurfaceOrTabIdWithoutFocusedFallback() throws { + _ = NSApplication.shared + let previousAppDelegate = AppDelegate.shared + let app = AppDelegate() + defer { + AppDelegate.shared = previousAppDelegate + } + + let windowId = UUID() + let window = makeMainWindow(id: windowId) + defer { + TerminalController.shared.setActiveTabManager(nil) + app.unregisterMainWindowContextForTesting(windowId: windowId) + window.orderOut(nil) + } + + let manager = TabManager(autoWelcomeIfNeeded: false) + app.registerMainWindow( + window, + windowId: windowId, + tabManager: manager, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState(), + fileExplorerState: FileExplorerState() + ) + TerminalController.shared.setActiveTabManager(manager) + + let workspace = try XCTUnwrap(manager.selectedWorkspace) + let panelId = try XCTUnwrap(workspace.focusedPanelId) + XCTAssertTrue(workspace.setSurfaceResumeBinding( + SurfaceResumeBindingSnapshot(command: "echo keep", source: "test"), + panelId: panelId + )) + + for key in ["surface_id", "tab_id"] { + for method in ["surface.resume.set", "surface.resume.get", "surface.resume.clear"] { + var params: [String: Any] = [ + "window_id": windowId.uuidString, + key: "not-a-surface" + ] + if method == "surface.resume.set" { + params["command"] = "echo bad" + } + + let (raw, envelope) = try v2Envelope(method: method, params: params) + + XCTAssertEqual(envelope["ok"] as? Bool, false, raw) + let error = try XCTUnwrap(envelope["error"] as? [String: Any], raw) + XCTAssertEqual(error["code"] as? String, "invalid_params", raw) + XCTAssertEqual(workspace.surfaceResumeBinding(panelId: panelId)?.command, "echo keep") + } + } + } + + func testSurfaceResumeUsesTabIdAliasForTargetSurface() throws { + _ = NSApplication.shared + let previousAppDelegate = AppDelegate.shared + let app = AppDelegate() + defer { + AppDelegate.shared = previousAppDelegate + } + + let windowId = UUID() + let window = makeMainWindow(id: windowId) + defer { + TerminalController.shared.setActiveTabManager(nil) + app.unregisterMainWindowContextForTesting(windowId: windowId) + window.orderOut(nil) + } + + let manager = TabManager(autoWelcomeIfNeeded: false) + app.registerMainWindow( + window, + windowId: windowId, + tabManager: manager, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState(), + fileExplorerState: FileExplorerState() + ) + TerminalController.shared.setActiveTabManager(manager) + + let workspace = try XCTUnwrap(manager.selectedWorkspace) + let focusedPanelId = try XCTUnwrap(workspace.focusedPanelId) + let focusedPanel = try XCTUnwrap(workspace.terminalPanel(for: focusedPanelId)) + let splitPanel = try XCTUnwrap(workspace.newTerminalSplit( + from: focusedPanel.id, + orientation: .horizontal, + focus: false + )) + + let setResult = try v2Result( + method: "surface.resume.set", + params: [ + "window_id": windowId.uuidString, + "tab_id": splitPanel.id.uuidString, + "command": "tmux attach -t alias-target", + "checkpoint_id": "alias-target", + ] + ) + XCTAssertEqual(setResult["surface_id"] as? String, splitPanel.id.uuidString) + XCTAssertNil(workspace.surfaceResumeBinding(panelId: focusedPanel.id)) + XCTAssertEqual( + workspace.surfaceResumeBinding(panelId: splitPanel.id)?.command, + "tmux attach -t alias-target" + ) + + let getResult = try v2Result( + method: "surface.resume.get", + params: [ + "window_id": windowId.uuidString, + "tab_id": splitPanel.id.uuidString, + ] + ) + XCTAssertEqual(getResult["surface_id"] as? String, splitPanel.id.uuidString) + let getBinding = try XCTUnwrap(getResult["resume_binding"] as? [String: Any]) + XCTAssertEqual(getBinding["checkpoint_id"] as? String, "alias-target") + + let clearResult = try v2Result( + method: "surface.resume.clear", + params: [ + "window_id": windowId.uuidString, + "tab_id": splitPanel.id.uuidString, + "checkpoint_id": "alias-target", + ] + ) + XCTAssertEqual(clearResult["surface_id"] as? String, splitPanel.id.uuidString) + XCTAssertEqual(clearResult["cleared"] as? Bool, true) + XCTAssertNil(workspace.surfaceResumeBinding(panelId: splitPanel.id)) + } + + func testSurfaceResumePayloadIncludesEnvironment() throws { + _ = NSApplication.shared + let previousAppDelegate = AppDelegate.shared + let app = AppDelegate() + defer { + AppDelegate.shared = previousAppDelegate + } + + let windowId = UUID() + let window = makeMainWindow(id: windowId) + defer { + TerminalController.shared.setActiveTabManager(nil) + app.unregisterMainWindowContextForTesting(windowId: windowId) + window.orderOut(nil) + } + + let manager = TabManager(autoWelcomeIfNeeded: false) + app.registerMainWindow( + window, + windowId: windowId, + tabManager: manager, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState(), + fileExplorerState: FileExplorerState() + ) + TerminalController.shared.setActiveTabManager(manager) + + let workspace = try XCTUnwrap(manager.selectedWorkspace) + let panelId = try XCTUnwrap(workspace.focusedPanelId) + let environment = [ + "EMPTY": "", + "SPACED": " keep exact ", + "ANTHROPIC_API_KEY": "should-not-persist", + ] + let setResult = try v2Result( + method: "surface.resume.set", + params: [ + "window_id": windowId.uuidString, + "workspace_id": workspace.id.uuidString, + "surface_id": panelId.uuidString, + "command": "tmux attach -t dogfood", + "environment": environment, + ] + ) + let setBinding = try XCTUnwrap(setResult["resume_binding"] as? [String: Any]) + let setEnvironment = try XCTUnwrap(setBinding["environment"] as? [String: Any]) + XCTAssertEqual(setEnvironment["EMPTY"] as? String, "") + XCTAssertEqual(setEnvironment["SPACED"] as? String, " keep exact ") + XCTAssertNil(setEnvironment["ANTHROPIC_API_KEY"]) + XCTAssertEqual(setBinding["auto_resume"] as? Bool, false) + + let getResult = try v2Result( + method: "surface.resume.get", + params: [ + "window_id": windowId.uuidString, + "workspace_id": workspace.id.uuidString, + "surface_id": panelId.uuidString, + ] + ) + let getBinding = try XCTUnwrap(getResult["resume_binding"] as? [String: Any]) + let getEnvironment = try XCTUnwrap(getBinding["environment"] as? [String: Any]) + XCTAssertEqual(getEnvironment["EMPTY"] as? String, "") + XCTAssertEqual(getEnvironment["SPACED"] as? String, " keep exact ") + XCTAssertNil(getEnvironment["ANTHROPIC_API_KEY"]) + XCTAssertEqual(getBinding["auto_resume"] as? Bool, false) + } + + func testSurfaceResumeSetCannotEnableAutoResumeFromSocket() throws { + _ = NSApplication.shared + let previousAppDelegate = AppDelegate.shared + let app = AppDelegate() + defer { + AppDelegate.shared = previousAppDelegate + } + + let windowId = UUID() + let window = makeMainWindow(id: windowId) + defer { + TerminalController.shared.setActiveTabManager(nil) + app.unregisterMainWindowContextForTesting(windowId: windowId) + window.orderOut(nil) + } + + let manager = TabManager(autoWelcomeIfNeeded: false) + app.registerMainWindow( + window, + windowId: windowId, + tabManager: manager, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState(), + fileExplorerState: FileExplorerState() + ) + TerminalController.shared.setActiveTabManager(manager) + + let workspace = try XCTUnwrap(manager.selectedWorkspace) + let panelId = try XCTUnwrap(workspace.focusedPanelId) + let result = try v2Result( + method: "surface.resume.set", + params: [ + "window_id": windowId.uuidString, + "workspace_id": workspace.id.uuidString, + "surface_id": panelId.uuidString, + "command": "tmux attach -t sticky", + "source": "process-detected", + "auto_resume": true, + ] + ) + + let binding = try XCTUnwrap(result["resume_binding"] as? [String: Any]) + XCTAssertEqual(binding["auto_resume"] as? Bool, false) + XCTAssertEqual(binding["source"] as? String, "manual") + XCTAssertEqual(workspace.surfaceResumeBinding(panelId: panelId)?.allowsAutomaticResume, false) + XCTAssertEqual(workspace.surfaceResumeBinding(panelId: panelId)?.source, "manual") + } + + func testSurfaceResumeSetAllowsAgentHookAutoResume() throws { + _ = NSApplication.shared + let previousAppDelegate = AppDelegate.shared + let app = AppDelegate() + defer { + AppDelegate.shared = previousAppDelegate + } + + let windowId = UUID() + let window = makeMainWindow(id: windowId) + defer { + TerminalController.shared.setActiveTabManager(nil) + app.unregisterMainWindowContextForTesting(windowId: windowId) + window.orderOut(nil) + } + + let manager = TabManager(autoWelcomeIfNeeded: false) + app.registerMainWindow( + window, + windowId: windowId, + tabManager: manager, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState(), + fileExplorerState: FileExplorerState() + ) + TerminalController.shared.setActiveTabManager(manager) + + let workspace = try XCTUnwrap(manager.selectedWorkspace) + let panelId = try XCTUnwrap(workspace.focusedPanelId) + let result = try v2Result( + method: "surface.resume.set", + params: [ + "window_id": windowId.uuidString, + "workspace_id": workspace.id.uuidString, + "surface_id": panelId.uuidString, + "command": "codex resume session", + "source": "agent-hook", + "auto_resume": true, + ] + ) + + let binding = try XCTUnwrap(result["resume_binding"] as? [String: Any]) + XCTAssertEqual(binding["auto_resume"] as? Bool, true) + XCTAssertEqual(binding["source"] as? String, "agent-hook") + XCTAssertEqual(workspace.surfaceResumeBinding(panelId: panelId)?.allowsAutomaticResume, true) + XCTAssertEqual(workspace.surfaceResumeBinding(panelId: panelId)?.source, "agent-hook") + } + + func testSurfaceResumeClearCheckpointGuardKeepsDifferentBinding() throws { + _ = NSApplication.shared + let previousAppDelegate = AppDelegate.shared + let app = AppDelegate() + defer { + AppDelegate.shared = previousAppDelegate + } + + let windowId = UUID() + let window = makeMainWindow(id: windowId) + defer { + TerminalController.shared.setActiveTabManager(nil) + app.unregisterMainWindowContextForTesting(windowId: windowId) + window.orderOut(nil) + } + + let manager = TabManager(autoWelcomeIfNeeded: false) + app.registerMainWindow( + window, + windowId: windowId, + tabManager: manager, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState(), + fileExplorerState: FileExplorerState() + ) + TerminalController.shared.setActiveTabManager(manager) + + let workspace = try XCTUnwrap(manager.selectedWorkspace) + let panelId = try XCTUnwrap(workspace.focusedPanelId) + _ = try v2Result( + method: "surface.resume.set", + params: [ + "window_id": windowId.uuidString, + "surface_id": panelId.uuidString, + "command": "codex resume new-session", + "checkpoint_id": "new-session", + "source": "agent-hook", + ] + ) + + let clearResult = try v2Result( + method: "surface.resume.clear", + params: [ + "window_id": windowId.uuidString, + "surface_id": panelId.uuidString, + "checkpoint_id": "old-session", + "source": "agent-hook", + ] + ) + + XCTAssertEqual(clearResult["cleared"] as? Bool, false) + XCTAssertEqual(workspace.surfaceResumeBinding(panelId: panelId)?.checkpointId, "new-session") + } + func testIssue2907TabManagerDependentSocketCommandsRecoverLiveSurfaceContext() throws { _ = NSApplication.shared let previousAppDelegate = AppDelegate.shared @@ -482,4 +884,129 @@ final class AppDelegateIssue2907RoutingTests: XCTestCase { destinationPaneIdString ) } + + func testSurfaceResumeSetUsesLiveSurfaceWhenWorkspaceIdIsOmittedAfterMove() throws { + _ = NSApplication.shared + let previousAppDelegate = AppDelegate.shared + let app = AppDelegate() + defer { + AppDelegate.shared = previousAppDelegate + } + + let windowId = UUID() + let window = makeMainWindow(id: windowId) + defer { + TerminalController.shared.setActiveTabManager(nil) + app.unregisterMainWindowContextForTesting(windowId: windowId) + window.orderOut(nil) + } + + let manager = TabManager(autoWelcomeIfNeeded: false) + app.registerMainWindow( + window, + windowId: windowId, + tabManager: manager, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState(), + fileExplorerState: FileExplorerState() + ) + TerminalController.shared.setActiveTabManager(manager) + + let sourceWorkspace = try XCTUnwrap(manager.selectedWorkspace) + let sourcePanel = try XCTUnwrap(sourceWorkspace.focusedTerminalPanel) + let splitPanel = try XCTUnwrap(sourceWorkspace.newTerminalSplit( + from: sourcePanel.id, + orientation: .horizontal, + focus: false + )) + let moved = try v2Result( + method: "pane.break", + params: [ + "surface_id": splitPanel.id.uuidString, + "focus": false, + ] + ) + let destinationWorkspaceId = try XCTUnwrap(moved["workspace_id"] as? String) + let destinationWorkspace = try XCTUnwrap( + manager.tabs.first { $0.id.uuidString == destinationWorkspaceId } + ) + + _ = try v2Result( + method: "surface.resume.set", + params: [ + "window_id": windowId.uuidString, + "surface_id": splitPanel.id.uuidString, + "command": "tmux attach -t moved", + "source": "agent-hook", + ] + ) + + XCTAssertNil(sourceWorkspace.surfaceResumeBinding(panelId: splitPanel.id)) + XCTAssertEqual( + destinationWorkspace.surfaceResumeBinding(panelId: splitPanel.id)?.command, + "tmux attach -t moved" + ) + } + + func testSurfaceResumeSetRejectsMismatchedWorkspaceScopeAfterMove() throws { + _ = NSApplication.shared + let previousAppDelegate = AppDelegate.shared + let app = AppDelegate() + defer { + AppDelegate.shared = previousAppDelegate + } + + let windowId = UUID() + let window = makeMainWindow(id: windowId) + defer { + TerminalController.shared.setActiveTabManager(nil) + app.unregisterMainWindowContextForTesting(windowId: windowId) + window.orderOut(nil) + } + + let manager = TabManager(autoWelcomeIfNeeded: false) + app.registerMainWindow( + window, + windowId: windowId, + tabManager: manager, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState(), + fileExplorerState: FileExplorerState() + ) + TerminalController.shared.setActiveTabManager(manager) + + let sourceWorkspace = try XCTUnwrap(manager.selectedWorkspace) + let sourcePanel = try XCTUnwrap(sourceWorkspace.focusedTerminalPanel) + let splitPanel = try XCTUnwrap(sourceWorkspace.newTerminalSplit( + from: sourcePanel.id, + orientation: .horizontal, + focus: false + )) + let moved = try v2Result( + method: "pane.break", + params: [ + "surface_id": splitPanel.id.uuidString, + "focus": false, + ] + ) + let destinationWorkspaceId = try XCTUnwrap(moved["workspace_id"] as? String) + let destinationWorkspace = try XCTUnwrap( + manager.tabs.first { $0.id.uuidString == destinationWorkspaceId } + ) + + let (raw, envelope) = try v2Envelope( + method: "surface.resume.set", + params: [ + "window_id": windowId.uuidString, + "workspace_id": sourceWorkspace.id.uuidString, + "surface_id": splitPanel.id.uuidString, + "command": "tmux attach -t moved", + "source": "agent-hook", + ] + ) + + XCTAssertEqual(envelope["ok"] as? Bool, false, raw) + XCTAssertNil(sourceWorkspace.surfaceResumeBinding(panelId: splitPanel.id)) + XCTAssertNil(destinationWorkspace.surfaceResumeBinding(panelId: splitPanel.id)) + } } diff --git a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift index 7e74e8ebc14f..ee0529fcacfc 100644 --- a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift +++ b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift @@ -122,6 +122,16 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { }, "Expected stale Stop from old session not to clobber the clear session, saw \(context.state.commands)" ) + let resumeBindingRequests = context.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, context.state.commands.joined(separator: "\n")) + XCTAssertEqual(resumeBindingRequests.first?["checkpoint_id"] as? String, "clear-session") + XCTAssertEqual(resumeBindingRequests.first?["auto_resume"] as? Bool, true) } func testClaudePromptSubmitFromNewSessionCanReplaceStoppedSession() throws { @@ -178,6 +188,67 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + func testClaudePromptSubmitResumeBindingPersistsAuthSelectionMarkersWithoutValues() throws { + let context = try makeClaudeHookContext(name: "claude-resume-env-redaction") + defer { context.cleanup() } + + let sessionId = "claude-redacted-env-session" + let launchEnvironment = [ + "CMUX_AGENT_LAUNCH_KIND": "claude", + "CMUX_AGENT_LAUNCH_EXECUTABLE": "/usr/local/bin/claude", + "CMUX_AGENT_LAUNCH_CWD": context.root.path, + "CMUX_AGENT_LAUNCH_ARGV_B64": base64NULSeparated([ + "/usr/local/bin/claude", + "--model", + "sonnet", + ]), + "ANTHROPIC_API_KEY": "should-not-persist", + "ANTHROPIC_BASE_URL": "https://api.example.test", + "ANTHROPIC_MODEL": "claude-sonnet-test", + "CLAUDE_CONFIG_DIR": context.root.appendingPathComponent("claude-config", isDirectory: true).path, + ] + let start = runClaudeHook( + context: context, + arguments: ["hooks", "claude", "session-start"], + standardInput: #"{"session_id":"\#(sessionId)","source":"startup","cwd":"\#(context.root.path)","hook_event_name":"SessionStart"}"#, + extraEnvironment: launchEnvironment + ) + XCTAssertFalse(start.timedOut, start.stderr) + XCTAssertEqual(start.status, 0, start.stderr) + + let commandStart = context.state.commands.count + let prompt = runClaudeHook( + context: context, + arguments: ["hooks", "claude", "prompt-submit"], + standardInput: #"{"session_id":"\#(sessionId)","turn_id":"turn-1","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit"}"#, + extraEnvironment: launchEnvironment + ) + XCTAssertFalse(prompt.timedOut, prompt.stderr) + XCTAssertEqual(prompt.status, 0, prompt.stderr) + + let promptCommands = Array(context.state.commands.dropFirst(commandStart)) + let resumeBindingRequests = promptCommands.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, promptCommands.joined(separator: "\n")) + let request = try XCTUnwrap(resumeBindingRequests.first) + XCTAssertEqual(request["auto_resume"] as? Bool, true) + let environment = try XCTUnwrap(request["environment"] as? [String: Any]) + XCTAssertEqual(environment["CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV"] as? String, "1") + XCTAssertEqual( + environment["CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV_KEYS"] as? String, + "ANTHROPIC_BASE_URL,ANTHROPIC_MODEL,CLAUDE_CONFIG_DIR" + ) + XCTAssertNil(environment["ANTHROPIC_API_KEY"]) + XCTAssertNil(environment["ANTHROPIC_BASE_URL"]) + XCTAssertNil(environment["ANTHROPIC_MODEL"]) + XCTAssertNil(environment["CLAUDE_CONFIG_DIR"]) + } + func testClaudeSessionEndChecksConsumedWorkspaceBeforeClearingVisibleState() throws { let context = try makeClaudeHookContext(name: "claude-stale-session-end-workspace") defer { context.cleanup() } @@ -305,6 +376,51 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(active["turnId"] as? String, "turn-2") } + func testClaudeSessionEndClearsMatchingSurfaceResumeBinding() throws { + let context = try makeClaudeHookContext(name: "claude-session-end-resume-clear") + defer { context.cleanup() } + + let sessionId = "ending-session" + let now = Date().timeIntervalSince1970 + let store: [String: Any] = [ + "version": 1, + "sessions": [ + sessionId: [ + "sessionId": sessionId, + "workspaceId": context.workspaceId, + "surfaceId": context.surfaceId, + "cwd": context.root.path, + "startedAt": now, + "updatedAt": now, + ], + ], + ] + let stateURL = context.root.appendingPathComponent("claude-hook-sessions.json") + try JSONSerialization.data(withJSONObject: store, options: [.prettyPrinted]) + .write(to: stateURL, options: .atomic) + + let result = runClaudeHook( + context: context, + arguments: ["hooks", "claude", "session-end"], + standardInput: #"{"session_id":"\#(sessionId)","cwd":"\#(context.root.path)","hook_event_name":"SessionEnd"}"# + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + let clearRequests = context.state.commands.compactMap { command -> [String: Any]? in + guard let payload = jsonObject(command), + payload["method"] as? String == "surface.resume.clear" else { + return nil + } + return payload["params"] as? [String: Any] + } + let request = try XCTUnwrap(clearRequests.first) + XCTAssertNil(request["workspace_id"]) + XCTAssertEqual(request["surface_id"] as? String, context.surfaceId) + XCTAssertEqual(request["checkpoint_id"] as? String, sessionId) + XCTAssertEqual(request["source"] as? String, "agent-hook") + } + func testRightSidebarCLIForwardsV1SocketCommandsQuietly() throws { let cliPath = try bundledCLIPath() let cases: [(name: String, arguments: [String], expectedCommand: String, response: String, stdout: String)] = [ @@ -962,44 +1078,191 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } - private struct ClaudeHookContext { - let cliPath: String - let socketPath: String - let listenerFD: Int32 - let state: MockSocketServerState - let root: URL - let workspaceId: String - let surfaceId: String + func testCodexTeamsForkPromptPublishesResumeBinding() throws { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("codex-team-resume") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-codex-teams-resume-\(UUID().uuidString)", isDirectory: true) + let workspaceId = "33333333-3333-3333-3333-333333333333" + let surfaceId = "44444444-4444-4444-4444-444444444444" + let sessionId = "019dad34-d218-7943-b81a-eddac5c87951" + let parentSessionId = "019dad34-d218-7943-b81a-parent-session" + let ttyName = "ttys-test-codex-teams-resume" - func cleanup() { + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { Darwin.close(listenerFD) unlink(socketPath) try? FileManager.default.removeItem(at: root) } + + let storeURL = root.appendingPathComponent("codex-hook-sessions.json", isDirectory: false) + let now = Date().timeIntervalSince1970 + let store: [String: Any] = [ + "version": 1, + "sessions": [ + sessionId: [ + "sessionId": sessionId, + "workspaceId": workspaceId, + "surfaceId": surfaceId, + "cwd": root.path, + "startedAt": now, + "updatedAt": now, + "launchCommand": [ + "launcher": "codexTeams", + "executablePath": "/usr/local/bin/cmux", + "arguments": [ + "/usr/local/bin/cmux", + "codex-teams", + "fork", + parentSessionId, + "--model", + "gpt-5.4", + "stale fork prompt", + "--sandbox", + "danger-full-access", + "initial prompt should not replay" + ], + "workingDirectory": root.path, + "environment": ["CODEX_HOME": root.appendingPathComponent("codex-home", isDirectory: true).path], + "capturedAt": now, + "source": "test", + ], + ], + ], + ] + try JSONSerialization.data(withJSONObject: store, options: [.prettyPrinted]).write(to: storeURL, options: .atomic) + + let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in + guard let payload = self.jsonObject(line) else { + return line.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{") + ? self.malformedRequestResponse(raw: line) + : "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": + let params = payload["params"] as? [String: Any] ?? [:] + if params["workspace_id"] as? String == workspaceId { + return self.surfaceListResponse(id: id, surfaceId: surfaceId) + } + return self.v2Response(id: id, ok: false, error: ["code": "not_found", "message": "workspace not found"]) + case "debug.terminals": + return self.v2Response( + id: id, + ok: true, + result: ["terminals": [["tty": ttyName, "workspace_id": workspaceId, "surface_id": surfaceId]]] + ) + case "workspace.current": + return self.v2Response(id: id, ok: true, result: ["workspace_id": workspaceId]) + 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_CLI_TTY_NAME"] = ttyName + environment["CMUX_AGENT_HOOK_STATE_DIR"] = root.path + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + environment["CMUX_AGENT_LAUNCH_KIND"] = "codexTeams" + environment["CMUX_AGENT_LAUNCH_EXECUTABLE"] = "/usr/local/bin/cmux" + environment["CMUX_AGENT_LAUNCH_CWD"] = root.path + environment["CMUX_AGENT_LAUNCH_ARGV_B64"] = base64NULSeparated([ + "/usr/local/bin/cmux", + "codex-teams", + "fork", + parentSessionId, + "--model", + "gpt-5.4", + "stale fork prompt", + "--sandbox", + "danger-full-access", + "initial prompt should not replay" + ]) + environment["CODEX_HOME"] = root.appendingPathComponent("codex-home", isDirectory: true).path + + let result = runProcess( + executablePath: cliPath, + arguments: ["hooks", "codex", "prompt-submit"], + environment: environment, + standardInput: #"{"session_id":"\#(sessionId)","cwd":"\#(root.path)","hook_event_name":"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["command"] as? String, + "cd '\(root.path)' && '/usr/local/bin/cmux' 'codex-teams' 'resume' '\(sessionId)' '--model' 'gpt-5.4' '--sandbox' 'danger-full-access'" + ) } - private func makeClaudeHookContext(name: String) throws -> ClaudeHookContext { + func testAgentPromptClearsSurfaceResumeBindingWhenResumeCommandUnavailable() throws { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("agent-resume-unavailable") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() let root = FileManager.default.temporaryDirectory - .appendingPathComponent("cmux-\(name)-\(UUID().uuidString)", isDirectory: true) - let socketPath = makeSocketPath(String(name.prefix(6))) + .appendingPathComponent("cmux-agent-resume-unavailable-\(UUID().uuidString)", isDirectory: true) + let workspaceId = "11111111-1111-1111-1111-111111111111" + let surfaceId = "22222222-2222-2222-2222-222222222222" + let sessionId = "nonresumable-agent-session" + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - return ClaudeHookContext( - cliPath: try bundledCLIPath(), - socketPath: socketPath, - listenerFD: try bindUnixSocket(at: socketPath), - state: MockSocketServerState(), - root: root, - workspaceId: "11111111-1111-1111-1111-111111111111", - surfaceId: "22222222-2222-2222-2222-222222222222" - ) - } + defer { + Darwin.close(listenerFD) + unlink(socketPath) + try? FileManager.default.removeItem(at: root) + } - private func runClaudeHook( - context: ClaudeHookContext, - arguments: [String], - standardInput: String - ) -> ProcessRunResult { - let serverHandled = startMockServer(listenerFD: context.listenerFD, state: context.state) { line in + let now = Date().timeIntervalSince1970 + let store: [String: Any] = [ + "version": 1, + "sessions": [ + sessionId: [ + "sessionId": sessionId, + "workspaceId": workspaceId, + "surfaceId": surfaceId, + "cwd": root.path, + "startedAt": now, + "updatedAt": now, + "launchCommand": [ + "launcher": "omx", + "executablePath": "/usr/local/bin/cmux", + "arguments": ["/usr/local/bin/cmux", "omx", "hud"], + "workingDirectory": root.path, + "capturedAt": now, + "source": "test", + ], + ], + ], + ] + try JSONSerialization.data(withJSONObject: store, options: [.prettyPrinted]) + .write(to: root.appendingPathComponent("claude-hook-sessions.json"), options: .atomic) + + let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in guard let payload = self.jsonObject(line) else { return "OK" } @@ -1008,7 +1271,16 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { } switch method { case "surface.list": - return self.surfaceListResponse(id: id, surfaceId: context.surfaceId) + let params = payload["params"] as? [String: Any] ?? [:] + if params["workspace_id"] as? String == workspaceId { + return self.surfaceListResponse(id: id, surfaceId: surfaceId) + } + return self.v2Response(id: id, ok: false, error: ["code": "not_found", "message": "workspace not found"]) + case "surface.resume.clear": + return self.v2Response(id: id, ok: true, result: ["cleared": true]) + case "surface.resume.set": + XCTFail("Non-resumable launcher should not publish a resume binding") + return self.v2Response(id: id, ok: true, result: ["ok": true]) case "feed.push": return self.v2Response(id: id, ok: true, result: [:]) default: @@ -1016,19 +1288,653 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { } } + let environment = [ + "HOME": root.path, + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "CMUX_SOCKET_PATH": socketPath, + "CMUX_WORKSPACE_ID": workspaceId, + "CMUX_SURFACE_ID": surfaceId, + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_CLI_SENTRY_DISABLED": "1", + "CMUX_CLAUDE_HOOK_SENTRY_DISABLED": "1", + ] + let result = runProcess( - executablePath: context.cliPath, - arguments: arguments, - environment: [ - "HOME": context.root.path, - "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", - "CMUX_SOCKET_PATH": context.socketPath, - "CMUX_WORKSPACE_ID": context.workspaceId, - "CMUX_SURFACE_ID": context.surfaceId, - "CMUX_CLAUDE_HOOK_STATE_PATH": context.root.appendingPathComponent("claude-hook-sessions.json").path, - "CMUX_CLI_SENTRY_DISABLED": "1", - "CMUX_CLAUDE_HOOK_SENTRY_DISABLED": "1", + executablePath: cliPath, + arguments: ["hooks", "claude", "prompt-submit"], + environment: environment, + standardInput: #"{"session_id":"\#(sessionId)","cwd":"\#(root.path)","hook_event_name":"UserPromptSubmit"}"#, + timeout: 5 + ) + + wait(for: [serverHandled], timeout: 5) + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + + let clearRequests = state.commands.compactMap { command -> [String: Any]? in + guard let payload = jsonObject(command), + payload["method"] as? String == "surface.resume.clear" else { + return nil + } + return payload["params"] as? [String: Any] + } + let request = try XCTUnwrap(clearRequests.first) + XCTAssertNil(request["workspace_id"]) + XCTAssertEqual(request["surface_id"] as? String, surfaceId) + XCTAssertEqual(request["source"] as? String, "agent-hook") + XCTAssertEqual(request["checkpoint_id"] as? String, sessionId) + } + + func testGenericAgentSessionEndClearsMatchingSurfaceResumeBinding() throws { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("agent-resume-clear") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-resume-clear-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { + Darwin.close(listenerFD) + unlink(socketPath) + try? FileManager.default.removeItem(at: root) + } + + let workspaceId = "11111111-1111-1111-1111-111111111111" + let surfaceId = "22222222-2222-2222-2222-222222222222" + let sessionId = "codex-ending-session" + let now = Date().timeIntervalSince1970 + let store: [String: Any] = [ + "version": 1, + "sessions": [ + sessionId: [ + "sessionId": sessionId, + "workspaceId": workspaceId, + "surfaceId": surfaceId, + "cwd": root.path, + "startedAt": now, + "updatedAt": now, + ], ], + ] + try JSONSerialization.data(withJSONObject: store, options: [.prettyPrinted]) + .write(to: root.appendingPathComponent("codex-hook-sessions.json"), options: .atomic) + + 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.resume.clear": + return self.v2Response(id: id, ok: true, result: ["cleared": true]) + case "feed.push": + return self.v2Response(id: id, ok: true, result: [:]) + 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" + + let result = runProcess( + executablePath: cliPath, + arguments: ["hooks", "codex", "session-end"], + environment: environment, + standardInput: #"{"session_id":"\#(sessionId)","cwd":"\#(root.path)","hook_event_name":"SessionEnd"}"#, + timeout: 5 + ) + + wait(for: [serverHandled], timeout: 5) + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + let clearRequests = state.commands.compactMap { command -> [String: Any]? in + guard let payload = jsonObject(command), + payload["method"] as? String == "surface.resume.clear" else { + return nil + } + return payload["params"] as? [String: Any] + } + let request = try XCTUnwrap(clearRequests.first) + XCTAssertNil(request["workspace_id"]) + XCTAssertEqual(request["surface_id"] as? String, surfaceId) + XCTAssertEqual(request["checkpoint_id"] as? String, sessionId) + XCTAssertEqual(request["source"] as? String, "agent-hook") + } + + func testSurfaceResumeClearCLIForwardsCheckpointAndSourceGuards() throws { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("resume-clear-guards") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + defer { + Darwin.close(listenerFD) + unlink(socketPath) + } + + let workspaceId = "11111111-1111-1111-1111-111111111111" + let surfaceId = "22222222-2222-2222-2222-222222222222" + let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in + guard let payload = self.jsonObject(line), + let id = payload["id"] as? String, + let method = payload["method"] as? String else { + return self.malformedRequestResponse(raw: line) + } + XCTAssertEqual(method, "surface.resume.clear") + return self.v2Response(id: id, ok: true, result: ["cleared": false]) + } + + var environment = ProcessInfo.processInfo.environment + environment["CMUX_SOCKET_PATH"] = socketPath + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + + let result = runProcess( + executablePath: cliPath, + arguments: [ + "surface", "resume", "clear", + "--workspace", workspaceId, + "--surface", surfaceId, + "--checkpoint", "old-session", + "--checkpoint-id", "new-session", + "--source", "agent-hook", + ], + environment: environment, + timeout: 5 + ) + + wait(for: [serverHandled], timeout: 5) + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertEqual(result.stdout, "OK\n") + + let clearRequests = state.commands.compactMap { command -> [String: Any]? in + guard let payload = jsonObject(command), + payload["method"] as? String == "surface.resume.clear" else { + return nil + } + return payload["params"] as? [String: Any] + } + let request = try XCTUnwrap(clearRequests.first) + XCTAssertEqual(request["workspace_id"] as? String, workspaceId) + XCTAssertEqual(request["surface_id"] as? String, surfaceId) + XCTAssertEqual(request["checkpoint_id"] as? String, "new-session") + XCTAssertEqual(request["source"] as? String, "agent-hook") + } + + func testSurfaceResumeSetCLIPreservesQuotedShellCommand() throws { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("resume-set-shell") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + defer { + Darwin.close(listenerFD) + unlink(socketPath) + } + + let workspaceId = "11111111-1111-1111-1111-111111111111" + let surfaceId = "22222222-2222-2222-2222-222222222222" + let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in + guard let payload = self.jsonObject(line), + let id = payload["id"] as? String, + let method = payload["method"] as? String else { + return self.malformedRequestResponse(raw: line) + } + XCTAssertEqual(method, "surface.resume.set") + return self.v2Response(id: id, ok: true, result: ["resume_binding": [:]]) + } + + var environment = ProcessInfo.processInfo.environment + environment["CMUX_SOCKET_PATH"] = socketPath + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + + let result = runProcess( + executablePath: cliPath, + arguments: [ + "surface", "resume", "set", + "--workspace", workspaceId, + "--surface", surfaceId, + "--kind", "tmux", + "--shell", "tmux attach -t work", + ], + environment: environment, + timeout: 5 + ) + + wait(for: [serverHandled], timeout: 5) + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertEqual(result.stdout, "OK\n") + + let setRequests = 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(setRequests.count, 1) + let request = try XCTUnwrap(setRequests.first) + XCTAssertEqual(request["workspace_id"] as? String, workspaceId) + XCTAssertEqual(request["surface_id"] as? String, surfaceId) + XCTAssertEqual(request["kind"] as? String, "tmux") + XCTAssertEqual(request["command"] as? String, "tmux attach -t work") + } + + func testSurfaceResumeSetCLIStopsParsingOptionsAfterTerminator() throws { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("resume-set-terminator") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + defer { + Darwin.close(listenerFD) + unlink(socketPath) + } + + let workspaceId = "11111111-1111-1111-1111-111111111111" + let surfaceId = "22222222-2222-2222-2222-222222222222" + let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in + guard let payload = self.jsonObject(line), + let id = payload["id"] as? String, + let method = payload["method"] as? String else { + return self.malformedRequestResponse(raw: line) + } + XCTAssertEqual(method, "surface.resume.set") + return self.v2Response(id: id, ok: true, result: ["resume_binding": [:]]) + } + + var environment = ProcessInfo.processInfo.environment + environment["CMUX_SOCKET_PATH"] = socketPath + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + + let result = runProcess( + executablePath: cliPath, + arguments: [ + "surface", "resume", "set", + "--workspace", workspaceId, + "--surface", surfaceId, + "--", + "myapp", + "--name", "foo", + "--kind", "bar", + "--cwd", "/tmp/ignored", + "--surface", "not-a-target", + ], + environment: environment, + timeout: 5 + ) + + wait(for: [serverHandled], timeout: 5) + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertEqual(result.stdout, "OK\n") + + let setRequests = 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] + } + let request = try XCTUnwrap(setRequests.first) + XCTAssertEqual(request["workspace_id"] as? String, workspaceId) + XCTAssertEqual(request["surface_id"] as? String, surfaceId) + XCTAssertNil(request["name"]) + XCTAssertNil(request["kind"]) + XCTAssertEqual( + request["command"] as? String, + "'myapp' '--name' 'foo' '--kind' 'bar' '--cwd' '/tmp/ignored' '--surface' 'not-a-target'" + ) + } + + func testSurfaceResumeSetCLIDoesNotScopeExplicitSurfaceToEnvWorkspace() throws { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("resume-set-surface") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + defer { + Darwin.close(listenerFD) + unlink(socketPath) + } + + let staleWorkspaceId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + let movedSurfaceId = "22222222-2222-2222-2222-222222222222" + let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in + guard let payload = self.jsonObject(line), + let id = payload["id"] as? String, + let method = payload["method"] as? String else { + return self.malformedRequestResponse(raw: line) + } + XCTAssertEqual(method, "surface.resume.set") + return self.v2Response(id: id, ok: true, result: ["resume_binding": [:]]) + } + + var environment = ProcessInfo.processInfo.environment + environment["CMUX_SOCKET_PATH"] = socketPath + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + environment["CMUX_WORKSPACE_ID"] = staleWorkspaceId + environment["CMUX_SURFACE_ID"] = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" + + let result = runProcess( + executablePath: cliPath, + arguments: [ + "surface", "resume", "set", + "--surface", movedSurfaceId, + "--shell", "tmux attach -t moved", + ], + environment: environment, + timeout: 5 + ) + + wait(for: [serverHandled], timeout: 5) + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + + let setRequests = 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] + } + let request = try XCTUnwrap(setRequests.first) + XCTAssertNil(request["workspace_id"]) + XCTAssertEqual(request["surface_id"] as? String, movedSurfaceId) + } + + func testSurfaceResumeSetCLIRejectsTrailingShellTokens() throws { + let cliPath = try bundledCLIPath() + let missingSocketPath = "/tmp/cmux-test-missing-\(UUID().uuidString).sock" + + var environment = ProcessInfo.processInfo.environment + environment["CMUX_SOCKET_PATH"] = missingSocketPath + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + + let result = runProcess( + executablePath: cliPath, + arguments: [ + "surface", "resume", "set", + "--workspace", "11111111-1111-1111-1111-111111111111", + "--surface", "22222222-2222-2222-2222-222222222222", + "--shell", "tmux", + "attach", + "-t", + "work", + ], + environment: environment, + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertNotEqual(result.status, 0, result.stderr) + XCTAssertTrue(result.stderr.contains("surface resume set: unexpected argument 'attach' after --shell")) + XCTAssertFalse(result.stderr.contains("Socket"), result.stderr) + } + + func testSurfaceResumeSetCLIRejectsPreTerminatorCommandTokens() throws { + let cliPath = try bundledCLIPath() + let missingSocketPath = "/tmp/cmux-test-missing-\(UUID().uuidString).sock" + var environment = ProcessInfo.processInfo.environment + environment["CMUX_SOCKET_PATH"] = missingSocketPath + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + + let result = runProcess( + executablePath: cliPath, + arguments: [ + "surface", "resume", "set", + "--workspace", "11111111-1111-1111-1111-111111111111", + "--surface", "22222222-2222-2222-2222-222222222222", + "myapp", + "--", + "--flag", + ], + environment: environment, + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 1, result.stderr) + XCTAssertTrue(result.stderr.contains("surface resume set: unexpected argument 'myapp' before --")) + XCTAssertFalse(result.stderr.contains("Socket"), result.stderr) + } + + func testSurfaceResumeSetCLIRejectsDanglingValueOptionsBeforeSocketRequest() throws { + let cliPath = try bundledCLIPath() + let workspaceId = "11111111-1111-1111-1111-111111111111" + let surfaceId = "22222222-2222-2222-2222-222222222222" + let missingSocketPath = "/tmp/cmux-test-missing-\(UUID().uuidString).sock" + var environment = ProcessInfo.processInfo.environment + environment["CMUX_SOCKET_PATH"] = missingSocketPath + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + + let cases: [(arguments: [String], expected: String)] = [ + ( + [ + "surface", "resume", "set", + "--workspace", workspaceId, + "--surface", + ], + "surface resume set: --surface requires a value" + ), + ( + [ + "surface", "resume", "set", + "--workspace", workspaceId, + "--surface", surfaceId, + "--shell", + ], + "surface resume set: --shell requires a value" + ), + ( + [ + "surface", "resume", "set", + "--workspace", workspaceId, + "--surface", surfaceId, + "--shell", "--", + ], + "surface resume set: --shell requires a value" + ), + ] + + for item in cases { + let result = runProcess( + executablePath: cliPath, + arguments: item.arguments, + environment: environment, + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 1, result.stderr) + XCTAssertTrue(result.stdout.isEmpty, result.stdout) + XCTAssertTrue(result.stderr.contains(item.expected), result.stderr) + XCTAssertFalse(result.stderr.contains("Socket"), result.stderr) + } + } + + func testSurfaceResumeClearCLIRejectsMalformedGuardsBeforeClearing() throws { + let cliPath = try bundledCLIPath() + let missingSocketPath = "/tmp/cmux-test-missing-\(UUID().uuidString).sock" + + var environment = ProcessInfo.processInfo.environment + environment["CMUX_SOCKET_PATH"] = missingSocketPath + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + + let result = runProcess( + executablePath: cliPath, + arguments: [ + "surface", "resume", "clear", + "--workspace", "11111111-1111-1111-1111-111111111111", + "--surface", "22222222-2222-2222-2222-222222222222", + "--checkpoint", + ], + environment: environment, + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertNotEqual(result.status, 0, result.stderr) + XCTAssertTrue(result.stderr.contains("surface resume clear: --checkpoint requires a value")) + XCTAssertFalse(result.stderr.contains("Socket"), result.stderr) + } + + func testSurfaceResumeClearCLINormalizesWindowIndex() throws { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("resume-clear-window") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + defer { + Darwin.close(listenerFD) + unlink(socketPath) + } + + let windowId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + let surfaceRef = "surface:7" + let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in + guard let payload = self.jsonObject(line), + let id = payload["id"] as? String, + let method = payload["method"] as? String else { + return self.malformedRequestResponse(raw: line) + } + switch method { + case "window.list": + return self.v2Response( + id: id, + ok: true, + result: ["windows": [["id": windowId, "ref": "window:1", "index": 0]]] + ) + case "window.focus": + return self.v2Response(id: id, ok: true, result: ["window_id": windowId]) + case "surface.list": + let params = payload["params"] as? [String: Any] ?? [:] + XCTAssertEqual(params["window_id"] as? String, "window:1") + return self.v2Response( + id: id, + ok: true, + result: ["surfaces": [["id": "ignored-id", "ref": surfaceRef, "index": 0]]] + ) + case "surface.resume.clear": + return self.v2Response(id: id, ok: true, result: ["cleared": 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_CLI_SENTRY_DISABLED"] = "1" + + let result = runProcess( + executablePath: cliPath, + arguments: ["--window", "0", "surface", "resume", "clear", "--surface", "0"], + environment: environment, + timeout: 5 + ) + + wait(for: [serverHandled], timeout: 5) + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertEqual(result.stdout, "OK\n") + + let clearRequests = state.commands.compactMap { command -> [String: Any]? in + guard let payload = jsonObject(command), + payload["method"] as? String == "surface.resume.clear" else { + return nil + } + return payload["params"] as? [String: Any] + } + XCTAssertFalse( + state.commands.contains { command in + jsonObject(command)?["method"] as? String == "window.focus" + }, + "surface resume metadata commands should route by window_id without focusing the window" + ) + let request = try XCTUnwrap(clearRequests.first) + XCTAssertEqual(request["window_id"] as? String, "window:1") + XCTAssertNotEqual(request["window_id"] as? String, "0") + XCTAssertEqual(request["surface_id"] as? String, surfaceRef) + } + + private struct ClaudeHookContext { + let cliPath: String + let socketPath: String + let listenerFD: Int32 + let state: MockSocketServerState + let root: URL + let workspaceId: String + let surfaceId: String + + func cleanup() { + Darwin.close(listenerFD) + unlink(socketPath) + try? FileManager.default.removeItem(at: root) + } + } + + private func makeClaudeHookContext(name: String) throws -> ClaudeHookContext { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-\(name)-\(UUID().uuidString)", isDirectory: true) + let socketPath = makeSocketPath(String(name.prefix(6))) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return ClaudeHookContext( + cliPath: try bundledCLIPath(), + socketPath: socketPath, + listenerFD: try bindUnixSocket(at: socketPath), + state: MockSocketServerState(), + root: root, + workspaceId: "11111111-1111-1111-1111-111111111111", + surfaceId: "22222222-2222-2222-2222-222222222222" + ) + } + + private func runClaudeHook( + context: ClaudeHookContext, + arguments: [String], + standardInput: String, + extraEnvironment: [String: String] = [:] + ) -> ProcessRunResult { + let serverHandled = startMockServer(listenerFD: context.listenerFD, state: context.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: context.surfaceId) + case "feed.push": + return self.v2Response(id: id, ok: true, result: [:]) + case "surface.resume.clear": + return self.v2Response(id: id, ok: true, result: ["cleared": true]) + default: + return self.v2Response(id: id, ok: false, error: ["code": "unrecognized_method", "message": "unexpected method: \(method)"]) + } + } + + var environment = [ + "HOME": context.root.path, + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "CMUX_SOCKET_PATH": context.socketPath, + "CMUX_WORKSPACE_ID": context.workspaceId, + "CMUX_SURFACE_ID": context.surfaceId, + "CMUX_CLAUDE_HOOK_STATE_PATH": context.root.appendingPathComponent("claude-hook-sessions.json").path, + "CMUX_CLI_SENTRY_DISABLED": "1", + "CMUX_CLAUDE_HOOK_SENTRY_DISABLED": "1", + ] + for (key, value) in extraEnvironment { + environment[key] = value + } + + let result = runProcess( + executablePath: context.cliPath, + arguments: arguments, + environment: environment, standardInput: standardInput, timeout: 5 ) diff --git a/cmuxTests/CmuxTopSnapshotScopeTests.swift b/cmuxTests/CmuxTopSnapshotScopeTests.swift index d26e56e6151f..c3d545771567 100644 --- a/cmuxTests/CmuxTopSnapshotScopeTests.swift +++ b/cmuxTests/CmuxTopSnapshotScopeTests.swift @@ -9,6 +9,16 @@ import Darwin #endif final class CmuxTopSnapshotScopeTests: XCTestCase { + func testProcessForegroundGroupRequiresTerminalForegroundMatch() { + let foreground = makeProcessInfo(processGroupID: 10, terminalProcessGroupID: 10) + let background = makeProcessInfo(processGroupID: 11, terminalProcessGroupID: 10) + let detached = makeProcessInfo(processGroupID: nil, terminalProcessGroupID: nil) + + XCTAssertTrue(foreground.isTerminalForegroundProcessGroup) + XCTAssertFalse(background.isTerminalForegroundProcessGroup) + XCTAssertFalse(detached.isTerminalForegroundProcessGroup) + } + @MainActor func testWindowRollupMatchesPSForApplicationProcessTree() throws { let fixture = try SpawnedProcessTree.start() @@ -590,6 +600,28 @@ final class CmuxTopSnapshotScopeTests: XCTestCase { XCTAssertTrue(totalPIDs.contains(monitorPID)) } + private func makeProcessInfo( + processGroupID: Int?, + terminalProcessGroupID: Int? + ) -> CmuxTopProcessInfo { + CmuxTopProcessInfo( + pid: 123, + parentPID: 1, + name: "tmux", + path: "/opt/homebrew/bin/tmux", + ttyDevice: nil, + cmuxWorkspaceID: nil, + cmuxSurfaceID: nil, + cmuxAttributionReason: nil, + processGroupID: processGroupID, + terminalProcessGroupID: terminalProcessGroupID, + cpuPercent: 0, + residentBytes: 0, + virtualBytes: 0, + threadCount: 1 + ) + } + @MainActor func testLaunchdParentedWebKitRootProcessStaysUnderBrowserWebView() throws { let workspaceID = UUID(uuidString: "77777777-7777-7777-7777-777777777777")! diff --git a/cmuxTests/RestorableAgentSessionIndexTests.swift b/cmuxTests/RestorableAgentSessionIndexTests.swift index 9f52d4df2160..dcb6a92ac8b2 100644 --- a/cmuxTests/RestorableAgentSessionIndexTests.swift +++ b/cmuxTests/RestorableAgentSessionIndexTests.swift @@ -135,6 +135,70 @@ final class RestorableAgentSessionIndexTests: XCTestCase { ) } + func testPanelFallbackUsesLatestHookRecord() throws { + let fm = FileManager.default + let root = fm.temporaryDirectory + .appendingPathComponent("cmux-claude-panel-fallback-\(UUID().uuidString)", isDirectory: true) + defer { try? fm.removeItem(at: root) } + + let configDir = root.appendingPathComponent("claude-config", isDirectory: true) + let projectsDir = configDir.appendingPathComponent("projects", isDirectory: true) + let cwd = root.appendingPathComponent("repo", isDirectory: true) + try fm.createDirectory(at: cwd, withIntermediateDirectories: true) + try fm.createDirectory( + at: projectsDir.appendingPathComponent( + RestorableAgentSessionIndex.encodeClaudeProjectDir(cwd.path), + isDirectory: true + ), + withIntermediateDirectories: true + ) + + let panelId = UUID() + let oldWorkspaceId = UUID() + let latestWorkspaceId = UUID() + let movedWorkspaceId = UUID() + let oldSessionId = "11111111-1111-1111-1111-111111111111" + let latestSessionId = "22222222-2222-2222-2222-222222222222" + try writeClaudeTranscript(sessionId: oldSessionId, cwd: cwd, projectsDir: projectsDir) + try writeClaudeTranscript(sessionId: latestSessionId, cwd: cwd, projectsDir: projectsDir) + + try writeClaudeHookStore( + root: root, + sessions: [ + oldSessionId: hookRecord( + sessionId: oldSessionId, + workspaceId: oldWorkspaceId, + panelId: panelId, + cwd: cwd.path, + configDir: configDir.path, + updatedAt: 10 + ), + latestSessionId: hookRecord( + sessionId: latestSessionId, + workspaceId: latestWorkspaceId, + panelId: panelId, + cwd: cwd.path, + configDir: configDir.path, + updatedAt: 20 + ), + ] + ) + + let index = RestorableAgentSessionIndex.load( + homeDirectory: root.path, + fileManager: fm + ) + + XCTAssertEqual( + index.snapshot(workspaceId: oldWorkspaceId, panelId: panelId)?.sessionId, + oldSessionId + ) + XCTAssertEqual( + index.snapshot(workspaceId: movedWorkspaceId, panelId: panelId)?.sessionId, + latestSessionId + ) + } + private func hookRecord( sessionId: String, workspaceId: UUID, diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift index 2bffa1de5773..6c39dd512c6e 100644 --- a/cmuxTests/SessionPersistenceTests.swift +++ b/cmuxTests/SessionPersistenceTests.swift @@ -496,6 +496,37 @@ final class SessionPersistenceTests: XCTestCase { XCTAssertFalse(AppDelegate.shouldPersistSnapshotOnWindowUnregister(isTerminatingApp: true)) } + func testMainWindowRegistrationSnapshotSavePolicySkipsStartupRestore() { + XCTAssertTrue( + AppDelegate.shouldSaveSessionSnapshotAfterMainWindowRegistration( + isTerminatingApp: false, + didApplyStartupSessionRestore: false, + isApplyingSessionRestore: false + ) + ) + XCTAssertFalse( + AppDelegate.shouldSaveSessionSnapshotAfterMainWindowRegistration( + isTerminatingApp: true, + didApplyStartupSessionRestore: false, + isApplyingSessionRestore: false + ) + ) + XCTAssertFalse( + AppDelegate.shouldSaveSessionSnapshotAfterMainWindowRegistration( + isTerminatingApp: false, + didApplyStartupSessionRestore: true, + isApplyingSessionRestore: false + ) + ) + XCTAssertFalse( + AppDelegate.shouldSaveSessionSnapshotAfterMainWindowRegistration( + isTerminatingApp: false, + didApplyStartupSessionRestore: false, + isApplyingSessionRestore: true + ) + ) + } + func testShouldSkipSessionSaveDuringRestorePolicy() { XCTAssertTrue( AppDelegate.shouldSkipSessionSaveDuringRestore( @@ -1273,7 +1304,8 @@ final class SessionPersistenceTests: XCTestCase { includeScrollback: false, restorableAgentIndex: staleIndex ) - XCTAssertEqual(initialSnapshot.panels.first?.terminal?.agent?.kind, scenario.kind) + let expectedKind: RestorableAgentKind = scenario.kind == .pi ? .custom("pi") : scenario.kind + XCTAssertEqual(initialSnapshot.panels.first?.terminal?.agent?.kind, expectedKind) workspace.updatePanelShellActivityState(panelId: panelId, state: .promptIdle) workspace.updatePanelShellActivityState(panelId: panelId, state: .commandRunning) @@ -1282,7 +1314,7 @@ final class SessionPersistenceTests: XCTestCase { includeScrollback: false, restorableAgentIndex: staleIndex ) - XCTAssertNil(staleSnapshot.panels.first?.terminal?.agent, scenario.kind.rawValue) + XCTAssertNil(staleSnapshot.panels.first?.terminal?.agent, expectedKind.rawValue) } } @@ -1443,6 +1475,12 @@ final class SessionPersistenceTests: XCTestCase { "source": "process", ], ] + if kind == .claude { + let transcriptURL = home.appendingPathComponent("\(sessionId).jsonl", isDirectory: false) + try #"{"type":"assistant","message":{"content":[{"type":"text","text":"ok"}]}}"# + .write(to: transcriptURL, atomically: true, encoding: .utf8) + sessionRecord["transcriptPath"] = transcriptURL.path + } if let pid { sessionRecord["pid"] = pid } @@ -1946,6 +1984,70 @@ final class SocketListenerAcceptPolicyTests: XCTestCase { ) } + func testCodexTeamsResumeCommandUsesWrapperSubcommand() { + let snapshot = SessionRestorableAgentSnapshot( + kind: .codex, + sessionId: "019dad34-d218-7943-b81a-eddac5c87951", + workingDirectory: "/Users/example/repo", + launchCommand: AgentLaunchCommandSnapshot( + launcher: "codexTeams", + executablePath: "/usr/local/bin/cmux", + arguments: [ + "/usr/local/bin/cmux", + "codex-teams", + "--model", + "gpt-5.4", + "--image", + "/tmp/team screenshot.png", + "--sandbox", + "danger-full-access", + "initial prompt should not replay" + ], + workingDirectory: "/Users/example/repo", + environment: ["CODEX_HOME": "/tmp/codex home"], + capturedAt: 123, + source: "environment" + ) + ) + + XCTAssertEqual( + snapshot.resumeCommand, + "cd '/Users/example/repo' && 'env' 'CODEX_HOME=/tmp/codex home' '/usr/local/bin/cmux' 'codex-teams' 'resume' '019dad34-d218-7943-b81a-eddac5c87951' '--model' 'gpt-5.4' '--sandbox' 'danger-full-access'" + ) + } + + func testCodexTeamsResumeCommandDropsOriginalForkTarget() { + let snapshot = SessionRestorableAgentSnapshot( + kind: .codex, + sessionId: "019dad34-d218-7943-b81a-eddac5c87952", + workingDirectory: "/Users/example/repo", + launchCommand: AgentLaunchCommandSnapshot( + launcher: "codexTeams", + executablePath: "/usr/local/bin/cmux", + arguments: [ + "/usr/local/bin/cmux", + "codex-teams", + "fork", + "019dad34-d218-7943-b81a-eddac5c87951", + "--model", + "gpt-5.4", + "stale fork prompt", + "--sandbox", + "danger-full-access" + ], + workingDirectory: "/Users/example/repo", + environment: ["CODEX_HOME": "/tmp/codex home"], + capturedAt: 123, + source: "environment" + ) + ) + + XCTAssertEqual( + snapshot.resumeCommand, + "cd '/Users/example/repo' && 'env' 'CODEX_HOME=/tmp/codex home' '/usr/local/bin/cmux' 'codex-teams' 'resume' '019dad34-d218-7943-b81a-eddac5c87952' '--model' 'gpt-5.4' '--sandbox' 'danger-full-access'" + ) + } + func testForkCommandsUseVerifiedAgentForkSyntaxAndPreserveContext() { let claude = SessionRestorableAgentSnapshot( kind: .claude, @@ -3287,6 +3389,1151 @@ final class SidebarDragFailsafePolicyTests: XCTestCase { } extension SessionPersistenceTests { + func testSurfaceResumeBindingStartupInputUsesExactCommand() { + let binding = SurfaceResumeBindingSnapshot( + name: "OpenCode", + kind: "opencode", + command: "opencode --session ses_123", + cwd: "/tmp/project", + checkpointId: "ses_123", + source: "cli", + updatedAt: 1_777_777_777 + ) + + XCTAssertEqual(binding.startupInput, "opencode --session ses_123\n") + } + + func testSurfaceResumeBindingStartupInputScopesEnvironmentToCommand() { + let binding = SurfaceResumeBindingSnapshot( + command: "cd '/tmp/project' && codex resume session", + environment: [ + "SPACED": " keep exact ", + "CODEX_HOME": "/tmp/codex home", + "EMPTY": "", + "ANTHROPIC_API_KEY": "should-not-persist", + ] + ) + + XCTAssertEqual( + binding.startupInput, + "'/usr/bin/env' 'CODEX_HOME=/tmp/codex home' 'EMPTY=' 'SPACED= keep exact ' '/bin/zsh' '-lc' 'cd '\\''/tmp/project'\\'' && codex resume session'\n" + ) + } + + func testSurfaceResumeBindingStartupInputUsesLauncherScriptWhenLong() throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-surface-resume-test-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let longPath = "/tmp/" + String(repeating: "nested-path-", count: 120) + let binding = SurfaceResumeBindingSnapshot( + kind: "codex", + command: "codex resume session --add-dir \(longPath)", + environment: [ + "CODEX_HOME": "/tmp/codex home", + ] + ) + + let inlineInput = try XCTUnwrap(binding.inlineStartupInput) + XCTAssertGreaterThan(inlineInput.utf8.count, SurfaceResumeBindingSnapshot.maxInlineStartupInputBytes) + + let input = try XCTUnwrap(binding.startupInputWithLauncherScript(temporaryDirectory: tempDir)) + XCTAssertLessThanOrEqual(input.utf8.count, SurfaceResumeBindingSnapshot.maxInlineStartupInputBytes) + XCTAssertTrue(input.hasPrefix("/bin/zsh '")) + XCTAssertFalse(input.contains(longPath)) + + let trimmedInput = input.trimmingCharacters(in: .whitespacesAndNewlines) + let prefix = "/bin/zsh '" + let scriptPath = String(trimmedInput.dropFirst(prefix.count).dropLast()) + let scriptContents = try String(contentsOfFile: scriptPath, encoding: .utf8) + XCTAssertTrue(scriptContents.contains(longPath)) + XCTAssertTrue(scriptContents.contains("'CODEX_HOME=/tmp/codex home'")) + XCTAssertTrue(scriptContents.contains("codex resume session")) + } + + @MainActor + func testSnapshotPrefersFreshProcessDetectedSurfaceResumeBinding() throws { + let workspace = Workspace() + let panelId = try XCTUnwrap(workspace.focusedPanelId) + XCTAssertTrue( + workspace.setSurfaceResumeBinding( + SurfaceResumeBindingSnapshot( + name: "tmux", + kind: "tmux", + command: "tmux attach -t stale", + cwd: "/tmp/old", + checkpointId: "stale", + source: "process-detected", + updatedAt: 10 + ), + panelId: panelId + ) + ) + + let bindingIndex = SurfaceResumeBindingIndex(bindingsByPanel: [ + SurfaceResumeBindingIndex.PanelKey(workspaceId: workspace.id, panelId: panelId): SurfaceResumeBindingSnapshot( + name: "tmux", + kind: "tmux", + command: "tmux attach -t fresh", + cwd: "/tmp/new", + checkpointId: "fresh", + source: "process-detected", + updatedAt: 20 + ), + ]) + let snapshot = workspace.sessionSnapshot( + includeScrollback: false, + surfaceResumeBindingIndex: bindingIndex + ) + + XCTAssertEqual(snapshot.panels.first?.terminal?.resumeBinding?.checkpointId, "fresh") + XCTAssertEqual(snapshot.panels.first?.terminal?.resumeBinding?.command, "tmux attach -t fresh") + XCTAssertEqual( + workspace.sessionSnapshot(includeScrollback: false).panels.first?.terminal?.resumeBinding?.checkpointId, + "fresh" + ) + } + + @MainActor + func testSnapshotUsesProcessDetectedSurfaceResumeBindingAfterWorkspaceMove() throws { + let originalWorkspaceId = UUID() + let workspace = Workspace() + let panelId = try XCTUnwrap(workspace.focusedPanelId) + let bindingIndex = SurfaceResumeBindingIndex(bindingsByPanel: [ + SurfaceResumeBindingIndex.PanelKey(workspaceId: originalWorkspaceId, panelId: panelId): SurfaceResumeBindingSnapshot( + name: "tmux", + kind: "tmux", + command: "tmux attach -t moved", + cwd: "/tmp/moved", + checkpointId: "moved", + source: "process-detected", + updatedAt: 20 + ), + ]) + let snapshot = workspace.sessionSnapshot( + includeScrollback: false, + surfaceResumeBindingIndex: bindingIndex + ) + + XCTAssertEqual(snapshot.panels.first?.terminal?.resumeBinding?.checkpointId, "moved") + XCTAssertEqual(snapshot.panels.first?.terminal?.resumeBinding?.command, "tmux attach -t moved") + XCTAssertEqual( + workspace.sessionSnapshot(includeScrollback: false).panels.first?.terminal?.resumeBinding?.checkpointId, + "moved" + ) + } + + @MainActor + func testSnapshotKeepsExplicitSurfaceResumeBindingOverDetectedBinding() throws { + let workspace = Workspace() + let panelId = try XCTUnwrap(workspace.focusedPanelId) + XCTAssertTrue( + workspace.setSurfaceResumeBinding( + SurfaceResumeBindingSnapshot( + name: "Codex", + kind: "codex", + command: "codex resume explicit", + cwd: "/tmp/explicit", + checkpointId: "explicit", + source: "cli", + updatedAt: 10 + ), + panelId: panelId + ) + ) + + let bindingIndex = SurfaceResumeBindingIndex(bindingsByPanel: [ + SurfaceResumeBindingIndex.PanelKey(workspaceId: workspace.id, panelId: panelId): SurfaceResumeBindingSnapshot( + name: "tmux", + kind: "tmux", + command: "tmux attach -t detected", + cwd: "/tmp/detected", + checkpointId: "detected", + source: "process-detected", + updatedAt: 20 + ), + ]) + let snapshot = workspace.sessionSnapshot( + includeScrollback: false, + surfaceResumeBindingIndex: bindingIndex + ) + + XCTAssertEqual(snapshot.panels.first?.terminal?.resumeBinding?.checkpointId, "explicit") + XCTAssertEqual(snapshot.panels.first?.terminal?.resumeBinding?.command, "codex resume explicit") + } + + @MainActor + func testSnapshotPrefersProcessDetectedTmuxOverAgentHookBinding() throws { + let workspace = Workspace() + let panelId = try XCTUnwrap(workspace.focusedPanelId) + XCTAssertTrue( + workspace.setSurfaceResumeBinding( + SurfaceResumeBindingSnapshot( + name: "Codex", + kind: "codex", + command: "codex resume session", + cwd: "/tmp/agent", + checkpointId: "session", + source: "agent-hook", + updatedAt: 10 + ), + panelId: panelId + ) + ) + + let bindingIndex = SurfaceResumeBindingIndex(bindingsByPanel: [ + SurfaceResumeBindingIndex.PanelKey(workspaceId: workspace.id, panelId: panelId): SurfaceResumeBindingSnapshot( + name: "tmux", + kind: "tmux", + command: "tmux attach -t detected", + cwd: "/tmp/detected", + checkpointId: "detected", + source: "process-detected", + autoResume: true, + updatedAt: 20 + ), + ]) + let snapshot = workspace.sessionSnapshot( + includeScrollback: false, + surfaceResumeBindingIndex: bindingIndex + ) + + XCTAssertEqual(snapshot.panels.first?.terminal?.resumeBinding?.checkpointId, "detected") + XCTAssertEqual(snapshot.panels.first?.terminal?.resumeBinding?.command, "tmux attach -t detected") + XCTAssertEqual( + workspace.sessionSnapshot(includeScrollback: false).panels.first?.terminal?.resumeBinding?.checkpointId, + "detected" + ) + } + + @MainActor + func testAutosaveFingerprintIgnoresSurfaceResumeBindingUpdatedAt() throws { + let manager = TabManager() + let workspace = try XCTUnwrap(manager.selectedWorkspace) + let panelId = try XCTUnwrap(workspace.focusedPanelId) + let key = SurfaceResumeBindingIndex.PanelKey(workspaceId: workspace.id, panelId: panelId) + let firstIndex = SurfaceResumeBindingIndex(bindingsByPanel: [ + key: SurfaceResumeBindingSnapshot( + name: "tmux", + kind: "tmux", + command: "tmux attach -t work", + cwd: "/tmp/project", + checkpointId: "work", + source: "process-detected", + updatedAt: 10 + ), + ]) + let secondIndex = SurfaceResumeBindingIndex(bindingsByPanel: [ + key: SurfaceResumeBindingSnapshot( + name: "tmux", + kind: "tmux", + command: "tmux attach -t work", + cwd: "/tmp/project", + checkpointId: "work", + source: "process-detected", + updatedAt: 20 + ), + ]) + + XCTAssertEqual( + manager.sessionAutosaveFingerprint(surfaceResumeBindingIndex: firstIndex), + manager.sessionAutosaveFingerprint(surfaceResumeBindingIndex: secondIndex) + ) + } + + @MainActor + func testAutosaveFingerprintIncludesManualSurfaceResumeBindingUpdatedAt() throws { + let manager = TabManager() + let workspace = try XCTUnwrap(manager.selectedWorkspace) + let panelId = try XCTUnwrap(workspace.focusedPanelId) + let key = SurfaceResumeBindingIndex.PanelKey(workspaceId: workspace.id, panelId: panelId) + let firstIndex = SurfaceResumeBindingIndex(bindingsByPanel: [ + key: SurfaceResumeBindingSnapshot( + name: "custom", + kind: "custom", + command: "echo one", + cwd: "/tmp/project", + checkpointId: "custom", + source: "cli", + updatedAt: 10 + ), + ]) + let secondIndex = SurfaceResumeBindingIndex(bindingsByPanel: [ + key: SurfaceResumeBindingSnapshot( + name: "custom", + kind: "custom", + command: "echo one", + cwd: "/tmp/project", + checkpointId: "custom", + source: "cli", + updatedAt: 20 + ), + ]) + + XCTAssertNotEqual( + manager.sessionAutosaveFingerprint(surfaceResumeBindingIndex: firstIndex), + manager.sessionAutosaveFingerprint(surfaceResumeBindingIndex: secondIndex) + ) + } + + @MainActor + func testAutosaveFingerprintUsesEffectiveSurfaceResumeBinding() throws { + let manager = TabManager() + let workspace = try XCTUnwrap(manager.selectedWorkspace) + let panelId = try XCTUnwrap(workspace.focusedPanelId) + XCTAssertTrue( + workspace.setSurfaceResumeBinding( + SurfaceResumeBindingSnapshot( + name: "tmux", + kind: "tmux", + command: "tmux attach -t stale", + cwd: "/tmp/stale", + checkpointId: "stale", + source: "process-detected", + updatedAt: 10 + ), + panelId: panelId + ) + ) + + let key = SurfaceResumeBindingIndex.PanelKey(workspaceId: workspace.id, panelId: panelId) + let firstIndex = SurfaceResumeBindingIndex(bindingsByPanel: [ + key: SurfaceResumeBindingSnapshot( + name: "tmux", + kind: "tmux", + command: "tmux attach -t first", + cwd: "/tmp/first", + checkpointId: "first", + source: "process-detected", + updatedAt: 20 + ), + ]) + let secondIndex = SurfaceResumeBindingIndex(bindingsByPanel: [ + key: SurfaceResumeBindingSnapshot( + name: "tmux", + kind: "tmux", + command: "tmux attach -t second", + cwd: "/tmp/second", + checkpointId: "second", + source: "process-detected", + updatedAt: 30 + ), + ]) + + XCTAssertNotEqual( + manager.sessionAutosaveFingerprint(surfaceResumeBindingIndex: firstIndex), + manager.sessionAutosaveFingerprint(surfaceResumeBindingIndex: secondIndex) + ) + } + + @MainActor + func testAutosaveFingerprintIncludesSurfaceResumeBindingEnvironment() throws { + let manager = TabManager() + let workspace = try XCTUnwrap(manager.selectedWorkspace) + let panelId = try XCTUnwrap(workspace.focusedPanelId) + let key = SurfaceResumeBindingIndex.PanelKey(workspaceId: workspace.id, panelId: panelId) + let firstIndex = SurfaceResumeBindingIndex(bindingsByPanel: [ + key: SurfaceResumeBindingSnapshot( + name: "Codex", + kind: "codex", + command: "codex resume session", + cwd: "/tmp/project", + checkpointId: "session", + source: "agent-hook", + environment: ["CODEX_HOME": "/tmp/codex-a"], + updatedAt: 10 + ), + ]) + let secondIndex = SurfaceResumeBindingIndex(bindingsByPanel: [ + key: SurfaceResumeBindingSnapshot( + name: "Codex", + kind: "codex", + command: "codex resume session", + cwd: "/tmp/project", + checkpointId: "session", + source: "agent-hook", + environment: ["CODEX_HOME": "/tmp/codex-b"], + updatedAt: 10 + ), + ]) + + XCTAssertNotEqual( + manager.sessionAutosaveFingerprint(surfaceResumeBindingIndex: firstIndex), + manager.sessionAutosaveFingerprint(surfaceResumeBindingIndex: secondIndex) + ) + } + + @MainActor + func testAutosaveFingerprintIncludesSurfaceResumeBindingAutoResumeTrust() throws { + let manager = TabManager() + let workspace = try XCTUnwrap(manager.selectedWorkspace) + let panelId = try XCTUnwrap(workspace.focusedPanelId) + let key = SurfaceResumeBindingIndex.PanelKey(workspaceId: workspace.id, panelId: panelId) + let untrustedIndex = SurfaceResumeBindingIndex(bindingsByPanel: [ + key: SurfaceResumeBindingSnapshot( + name: "tmux", + kind: "tmux", + command: "tmux attach -t work", + cwd: "/tmp/project", + checkpointId: "work", + source: "process-detected", + updatedAt: 10 + ), + ]) + let trustedIndex = SurfaceResumeBindingIndex(bindingsByPanel: [ + key: SurfaceResumeBindingSnapshot( + name: "tmux", + kind: "tmux", + command: "tmux attach -t work", + cwd: "/tmp/project", + checkpointId: "work", + source: "process-detected", + autoResume: true, + updatedAt: 10 + ), + ]) + + XCTAssertNotEqual( + manager.sessionAutosaveFingerprint(surfaceResumeBindingIndex: untrustedIndex), + manager.sessionAutosaveFingerprint(surfaceResumeBindingIndex: trustedIndex) + ) + } + + func testSurfaceResumeBindingPreservesExactNonSensitiveEnvironmentValues() { + let binding = SurfaceResumeBindingSnapshot( + command: "codex resume session", + environment: [ + " EMPTY ": "", + "SPACED": " keep exact ", + "PLAIN": "value", + "MULTILINE": "line\nbreak", + "NULL_BYTE": "bad\u{0}value", + "ANTHROPIC_API_KEY": "should-not-persist", + "SERVICE_TOKEN": "should-not-persist", + ] + ) + + XCTAssertEqual(binding.environment?["EMPTY"], "") + XCTAssertEqual(binding.environment?["SPACED"], " keep exact ") + XCTAssertEqual(binding.environment?["PLAIN"], "value") + XCTAssertNil(binding.environment?["MULTILINE"]) + XCTAssertNil(binding.environment?["NULL_BYTE"]) + XCTAssertNil(binding.environment?["ANTHROPIC_API_KEY"]) + XCTAssertNil(binding.environment?["SERVICE_TOKEN"]) + } + + func testSurfaceResumeApprovalAutoPolicyAppliesSignedPrefix() throws { + let storeURL = try makeSurfaceResumeApprovalStoreURL() + let secret = Data("approval-secret".utf8) + let binding = SurfaceResumeBindingSnapshot( + name: "tmux work", + kind: "tmux", + command: "tmux attach -t 'work session'", + cwd: "/tmp/project", + source: "cli", + environment: ["PATH": "/usr/bin:/bin"] + ) + + let record = try XCTUnwrap(SurfaceResumeApprovalStore.approve( + binding: binding, + policy: .auto, + commandPrefix: ["tmux", "attach"], + fileURL: storeURL, + signingSecret: secret + )) + XCTAssertTrue(record.hasValidSignature(secret: secret)) + + let effectiveBinding = SurfaceResumeApprovalStore.applyingStoredApproval( + to: binding, + fileURL: storeURL, + signingSecret: secret + ) + XCTAssertEqual(effectiveBinding.approvalPolicy, .auto) + XCTAssertEqual(effectiveBinding.approvalRecordId, record.id) + XCTAssertTrue(effectiveBinding.allowsAutomaticResume) + + let changedEnvironmentBinding = SurfaceResumeBindingSnapshot( + name: "tmux work", + kind: "tmux", + command: "tmux attach -t 'work session'", + cwd: "/tmp/project", + source: "cli", + environment: ["PATH": "/tmp/bin"] + ) + let changedEnvironmentEffectiveBinding = SurfaceResumeApprovalStore.applyingStoredApproval( + to: changedEnvironmentBinding, + fileURL: storeURL, + signingSecret: secret + ) + XCTAssertEqual(changedEnvironmentEffectiveBinding.approvalPolicy, .manual) + XCTAssertFalse(changedEnvironmentEffectiveBinding.allowsAutomaticResume) + } + + func testSurfaceResumeApprovalRejectsTamperedRecord() throws { + let storeURL = try makeSurfaceResumeApprovalStoreURL() + let secret = Data("approval-secret".utf8) + let binding = SurfaceResumeBindingSnapshot( + command: "tmux attach -t work", + cwd: "/tmp/project", + source: "cli" + ) + + var record = try XCTUnwrap(SurfaceResumeApprovalStore.approve( + binding: binding, + policy: .manual, + fileURL: storeURL, + signingSecret: secret + )) + record.policy = .auto + let encoder = JSONEncoder() + let data = try encoder.encode(SurfaceResumeApprovalStore.StoredFile(version: 1, records: [record])) + try data.write(to: storeURL, options: [.atomic]) + + let effectiveBinding = SurfaceResumeApprovalStore.applyingStoredApproval( + to: binding, + fileURL: storeURL, + signingSecret: secret + ) + XCTAssertEqual(effectiveBinding.approvalPolicy, .manual) + XCTAssertNil(effectiveBinding.approvalRecordId) + XCTAssertFalse(effectiveBinding.allowsAutomaticResume) + } + + func testSurfaceResumeApprovalMissingRecordResetsStalePromptPolicy() throws { + let binding = SurfaceResumeBindingSnapshot( + command: "tmux attach -t work", + cwd: "/tmp/project", + source: "cli", + autoResume: false, + approvalPolicy: .prompt, + approvalRecordId: "deleted-record" + ) + + let effectiveBinding = SurfaceResumeApprovalStore.applyingStoredApproval( + to: binding, + fileURL: URL(fileURLWithPath: "/tmp/cmux-missing-\(UUID().uuidString).json"), + signingSecret: Data("approval-secret".utf8) + ) + XCTAssertEqual(effectiveBinding.approvalPolicy, .manual) + XCTAssertNil(effectiveBinding.approvalRecordId) + XCTAssertFalse(effectiveBinding.allowsAutomaticResume) + } + + func testSurfaceResumePromptPolicyDoesNotRunAutomaticallyUnderTest() throws { + let storeURL = try makeSurfaceResumeApprovalStoreURL() + let secret = Data("approval-secret".utf8) + let binding = SurfaceResumeBindingSnapshot( + command: "tmux attach -t work", + cwd: "/tmp/project", + source: "cli" + ) + + XCTAssertNotNil(SurfaceResumeApprovalStore.approve( + binding: binding, + policy: .prompt, + fileURL: storeURL, + signingSecret: secret + )) + + let input = Workspace.surfaceResumeStartupInput( + binding, + autoResumeAgentSessions: true, + approvalStoreURL: storeURL, + approvalSigningSecret: secret + ) + XCTAssertNil(input) + } + + func testSurfaceResumePromptPolicyDoesNotPromptDuringSnapshot() throws { + let storeURL = try makeSurfaceResumeApprovalStoreURL() + let secret = Data("approval-secret".utf8) + let binding = SurfaceResumeBindingSnapshot( + command: "tmux attach -t work", + cwd: "/tmp/project", + source: "cli" + ) + + XCTAssertNotNil(SurfaceResumeApprovalStore.approve( + binding: binding, + policy: .prompt, + fileURL: storeURL, + signingSecret: secret + )) + + let input = Workspace.surfaceResumeStartupInput( + binding, + autoResumeAgentSessions: true, + promptForApproval: false, + approvalStoreURL: storeURL, + approvalSigningSecret: secret + ) + XCTAssertNil(input) + } + + func testProcessDetectedSurfaceResumeRemainsTrustedWithoutApprovalRecord() { + let binding = SurfaceResumeBindingSnapshot( + command: "tmux attach -t work", + cwd: "/tmp/project", + source: "process-detected" + ) + + let effectiveBinding = SurfaceResumeApprovalStore.applyingStoredApproval( + to: binding, + fileURL: URL(fileURLWithPath: "/tmp/cmux-missing-\(UUID().uuidString).json"), + signingSecret: Data("approval-secret".utf8) + ) + XCTAssertEqual(effectiveBinding.approvalPolicy, .auto) + XCTAssertTrue(effectiveBinding.allowsAutomaticResume) + } + + func testAgentHookSurfaceResumeAutoResumeRemainsTrustedWithoutApprovalRecord() { + let binding = SurfaceResumeBindingSnapshot( + command: "codex resume session", + cwd: "/tmp/project", + source: "agent-hook", + autoResume: true + ) + + let effectiveBinding = SurfaceResumeApprovalStore.applyingStoredApproval( + to: binding, + fileURL: URL(fileURLWithPath: "/tmp/cmux-missing-\(UUID().uuidString).json"), + signingSecret: Data("approval-secret".utf8) + ) + XCTAssertEqual(effectiveBinding.approvalPolicy, .auto) + XCTAssertTrue(effectiveBinding.allowsAutomaticResume) + } + + private func makeSurfaceResumeApprovalStoreURL() throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-surface-resume-approvals-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + addTeardownBlock { + try? FileManager.default.removeItem(at: root) + } + return root.appendingPathComponent("resume-commands.json", isDirectory: false) + } + + @MainActor + func testRestoreRunsSurfaceResumeBindingFromBindingCwd() throws { + let source = Workspace() + let sourcePanelId = try XCTUnwrap(source.focusedPanelId) + source.panelDirectories[sourcePanelId] = "/tmp/old" + let bindingIndex = SurfaceResumeBindingIndex(bindingsByPanel: [ + SurfaceResumeBindingIndex.PanelKey(workspaceId: source.id, panelId: sourcePanelId): SurfaceResumeBindingSnapshot( + name: "script", + kind: "custom", + command: "./resume.sh", + cwd: "/tmp/new", + checkpointId: "script", + source: "process-detected", + autoResume: true, + updatedAt: 10 + ), + ]) + let snapshot = source.sessionSnapshot( + includeScrollback: false, + surfaceResumeBindingIndex: bindingIndex + ) + + let restored = Workspace() + restored.restoreSessionSnapshot(snapshot) + let restoredPanelId = try XCTUnwrap(restored.focusedPanelId) + let restoredPanel = try XCTUnwrap(restored.terminalPanel(for: restoredPanelId)) + + XCTAssertEqual(restoredPanel.requestedWorkingDirectory, "/tmp/new") + XCTAssertTrue(restoredPanel.surface.debugInitialInputMetadata().hasInitialInput) + } + + @MainActor + func testRestoreDoesNotRunUntrustedSurfaceResumeBindingByDefault() throws { + let source = Workspace() + let sourcePanelId = try XCTUnwrap(source.focusedPanelId) + let bindingIndex = SurfaceResumeBindingIndex(bindingsByPanel: [ + SurfaceResumeBindingIndex.PanelKey(workspaceId: source.id, panelId: sourcePanelId): SurfaceResumeBindingSnapshot( + name: "script", + kind: "custom", + command: "./resume.sh", + cwd: "/tmp/sticky", + checkpointId: "script", + source: "process-detected", + updatedAt: 10 + ), + ]) + let snapshot = source.sessionSnapshot( + includeScrollback: false, + surfaceResumeBindingIndex: bindingIndex + ) + + let restored = Workspace() + restored.restoreSessionSnapshot(snapshot) + let restoredPanelId = try XCTUnwrap(restored.focusedPanelId) + let restoredPanel = try XCTUnwrap(restored.terminalPanel(for: restoredPanelId)) + + XCTAssertFalse(restoredPanel.surface.debugInitialInputMetadata().hasInitialInput) + XCTAssertEqual( + restored.sessionSnapshot(includeScrollback: false).panels.first?.terminal?.resumeBinding?.command, + "./resume.sh" + ) + } + + @MainActor + func testRestoreScopesSurfaceResumeBindingEnvironmentToInitialInput() throws { + let source = Workspace() + let sourcePanelId = try XCTUnwrap(source.focusedPanelId) + let bindingIndex = SurfaceResumeBindingIndex(bindingsByPanel: [ + SurfaceResumeBindingIndex.PanelKey(workspaceId: source.id, panelId: sourcePanelId): SurfaceResumeBindingSnapshot( + name: "Codex", + kind: "codex", + command: "codex resume session", + cwd: "/tmp/project", + checkpointId: "session", + source: "process-detected", + environment: [ + "CODEX_HOME": "/tmp/codex home", + "EMPTY": "", + ], + autoResume: true, + updatedAt: 10 + ), + ]) + let snapshot = source.sessionSnapshot( + includeScrollback: false, + surfaceResumeBindingIndex: bindingIndex + ) + + let restored = Workspace() + restored.restoreSessionSnapshot(snapshot) + let restoredPanelId = try XCTUnwrap(restored.focusedPanelId) + let restoredPanel = try XCTUnwrap(restored.terminalPanel(for: restoredPanelId)) + + XCTAssertNil(restoredPanel.surface.debugAdditionalEnvironmentForTesting()["CODEX_HOME"]) + XCTAssertNil(restoredPanel.surface.debugAdditionalEnvironmentForTesting()["EMPTY"]) + XCTAssertEqual( + restoredPanel.surface.debugInitialInputForTesting(), + "'/usr/bin/env' 'CODEX_HOME=/tmp/codex home' 'EMPTY=' '/bin/zsh' '-lc' 'codex resume session'\n" + ) + } + + @MainActor + func testRestoreUsesLauncherScriptForLongSurfaceResumeBinding() throws { + let source = Workspace() + let sourcePanelId = try XCTUnwrap(source.focusedPanelId) + let longPath = "/tmp/" + String(repeating: "nested-project-", count: 120) + let bindingIndex = SurfaceResumeBindingIndex(bindingsByPanel: [ + SurfaceResumeBindingIndex.PanelKey(workspaceId: source.id, panelId: sourcePanelId): SurfaceResumeBindingSnapshot( + name: "Codex", + kind: "codex", + command: "codex resume session --add-dir \(longPath)", + cwd: "/tmp/project", + checkpointId: "session", + source: "process-detected", + environment: [ + "CODEX_HOME": "/tmp/codex home", + ], + autoResume: true, + updatedAt: 10 + ), + ]) + let snapshot = source.sessionSnapshot( + includeScrollback: false, + surfaceResumeBindingIndex: bindingIndex + ) + + let restored = Workspace() + restored.restoreSessionSnapshot(snapshot) + let restoredPanelId = try XCTUnwrap(restored.focusedPanelId) + let restoredPanel = try XCTUnwrap(restored.terminalPanel(for: restoredPanelId)) + + XCTAssertNil(restoredPanel.surface.debugAdditionalEnvironmentForTesting()["CODEX_HOME"]) + let input = try XCTUnwrap(restoredPanel.surface.debugInitialInputForTesting()) + XCTAssertTrue(input.hasPrefix("/bin/zsh '")) + XCTAssertFalse(input.contains(longPath)) + + let trimmedInput = input.trimmingCharacters(in: .whitespacesAndNewlines) + let prefix = "/bin/zsh '" + let scriptPath = String(trimmedInput.dropFirst(prefix.count).dropLast()) + defer { try? FileManager.default.removeItem(atPath: scriptPath) } + let scriptContents = try String(contentsOfFile: scriptPath, encoding: .utf8) + XCTAssertTrue(scriptContents.contains(longPath)) + XCTAssertTrue(scriptContents.contains("'CODEX_HOME=/tmp/codex home'")) + XCTAssertTrue(scriptContents.contains("codex resume session")) + } + + @MainActor + func testRestoreRetainsProcessDetectedSurfaceResumeBindingBeforeRedetection() throws { + let source = Workspace() + let sourcePanelId = try XCTUnwrap(source.focusedPanelId) + let bindingIndex = SurfaceResumeBindingIndex(bindingsByPanel: [ + SurfaceResumeBindingIndex.PanelKey(workspaceId: source.id, panelId: sourcePanelId): SurfaceResumeBindingSnapshot( + name: "tmux", + kind: "tmux", + command: "tmux attach -t restored", + cwd: "/tmp/project", + checkpointId: "restored", + source: "process-detected", + updatedAt: 10 + ), + ]) + let snapshot = source.sessionSnapshot( + includeScrollback: false, + surfaceResumeBindingIndex: bindingIndex + ) + + let restored = Workspace() + restored.restoreSessionSnapshot(snapshot) + let immediateSnapshot = restored.sessionSnapshot(includeScrollback: false) + + XCTAssertEqual(immediateSnapshot.panels.first?.terminal?.resumeBinding?.checkpointId, "restored") + XCTAssertEqual(immediateSnapshot.panels.first?.terminal?.resumeBinding?.command, "tmux attach -t restored") + } + + @MainActor + func testSnapshotDropsStaleProcessDetectedSurfaceResumeBindingAfterCleanRedetection() throws { + let workspace = Workspace() + let panelId = try XCTUnwrap(workspace.focusedPanelId) + XCTAssertTrue( + workspace.setSurfaceResumeBinding( + SurfaceResumeBindingSnapshot( + name: "tmux", + kind: "tmux", + command: "tmux attach -t stale", + cwd: "/tmp/stale", + checkpointId: "stale", + source: "process-detected", + updatedAt: 10 + ), + panelId: panelId + ) + ) + + let snapshot = workspace.sessionSnapshot( + includeScrollback: false, + surfaceResumeBindingIndex: .empty + ) + + XCTAssertNil(snapshot.panels.first?.terminal?.resumeBinding) + XCTAssertNil(workspace.sessionSnapshot(includeScrollback: false).panels.first?.terminal?.resumeBinding) + } + + @MainActor + func testSnapshotCachesNewProcessDetectedSurfaceResumeBindingForLaterNoScanSave() throws { + let workspace = Workspace() + let panelId = try XCTUnwrap(workspace.focusedPanelId) + let bindingIndex = SurfaceResumeBindingIndex(bindingsByPanel: [ + SurfaceResumeBindingIndex.PanelKey(workspaceId: workspace.id, panelId: panelId): SurfaceResumeBindingSnapshot( + name: "tmux", + kind: "tmux", + command: "tmux attach -t cached", + cwd: "/tmp/project", + checkpointId: "cached", + source: "process-detected", + updatedAt: 10 + ), + ]) + + let scannedSnapshot = workspace.sessionSnapshot( + includeScrollback: false, + surfaceResumeBindingIndex: bindingIndex + ) + let laterSnapshot = workspace.sessionSnapshot(includeScrollback: false) + + XCTAssertEqual(scannedSnapshot.panels.first?.terminal?.resumeBinding?.checkpointId, "cached") + XCTAssertEqual(laterSnapshot.panels.first?.terminal?.resumeBinding?.checkpointId, "cached") + } + + @MainActor + func testAppDelegateSnapshotPreservesRestoredProcessDetectedSurfaceResumeBindingBeforeScan() throws { + let previousAppDelegate = AppDelegate.shared + let app = AppDelegate() + AppDelegate.shared = app + defer { + AppDelegate.shared = previousAppDelegate + } + + let manager = TabManager(autoWelcomeIfNeeded: false) + let windowId = app.registerMainWindowContextForTesting(tabManager: manager) + defer { + app.unregisterMainWindowContextForTesting(windowId: windowId) + } + + let workspace = try XCTUnwrap(manager.selectedWorkspace) + let panelId = try XCTUnwrap(workspace.focusedPanelId) + XCTAssertTrue( + workspace.setSurfaceResumeBinding( + SurfaceResumeBindingSnapshot( + name: "tmux", + kind: "tmux", + command: "tmux attach -t restored", + cwd: "/tmp/project", + checkpointId: "restored", + source: "process-detected", + updatedAt: 10 + ), + panelId: panelId + ) + ) + + let noScanSnapshot = try XCTUnwrap(app.debugBuildSessionSnapshotForTesting(includeScrollback: false)) + let noScanBinding = noScanSnapshot.windows.first?.tabManager.workspaces.first?.panels + .first(where: { $0.id == panelId })? + .terminal? + .resumeBinding + XCTAssertEqual(noScanBinding?.checkpointId, "restored") + + let cleanScanSnapshot = try XCTUnwrap( + app.debugBuildSessionSnapshotForTesting( + includeScrollback: false, + surfaceResumeBindingIndex: .empty + ) + ) + let cleanScanBinding = cleanScanSnapshot.windows.first?.tabManager.workspaces.first?.panels + .first(where: { $0.id == panelId })? + .terminal? + .resumeBinding + XCTAssertNil(cleanScanBinding) + } + + func testTmuxProcessDetectedResumeBindingPreservesSocketFlags() throws { + let binding = try XCTUnwrap( + SurfaceResumeBindingIndex.tmuxResumeBindingForTesting( + processName: "tmux: client", + processPath: "/opt/homebrew/bin/tmux", + arguments: ["/opt/homebrew/bin/tmux", "-L", "dev", "attach-session", "-t", "work"], + environment: ["PWD": "/tmp/project"] + ) + ) + + XCTAssertEqual(binding.kind, "tmux") + XCTAssertEqual(binding.source, "process-detected") + XCTAssertEqual(binding.allowsAutomaticResume, true) + XCTAssertEqual(binding.checkpointId, "work") + XCTAssertEqual(binding.cwd, "/tmp/project") + XCTAssertEqual(binding.command, "'/opt/homebrew/bin/tmux' '-L' 'dev' 'attach' '-t' 'work'") + } + + func testTmuxProcessDetectedResumeBindingPreservesTmuxTmpdir() throws { + let binding = try XCTUnwrap( + SurfaceResumeBindingIndex.tmuxResumeBindingForTesting( + processName: "tmux: client", + processPath: "/opt/homebrew/bin/tmux", + arguments: ["/opt/homebrew/bin/tmux", "-L", "dev", "attach-session", "-t", "work"], + environment: [ + "PWD": "/tmp/project", + "TMUX": "/tmp/tmux-current,123,0", + "TMUX_TMPDIR": "/var/folders/custom-tmux", + ] + ) + ) + + XCTAssertEqual(binding.command, "'/opt/homebrew/bin/tmux' '-L' 'dev' 'attach' '-t' 'work'") + XCTAssertEqual(binding.environment, ["TMUX_TMPDIR": "/var/folders/custom-tmux"]) + let startupInput = try XCTUnwrap(binding.startupInput) + XCTAssertTrue(startupInput.contains("'TMUX_TMPDIR=/var/folders/custom-tmux'"), startupInput) + XCTAssertFalse(startupInput.contains("TMUX="), startupInput) + } + + func testTmuxProcessDetectedResumeBindingParsesAttachAlias() throws { + let binding = try XCTUnwrap( + SurfaceResumeBindingIndex.tmuxResumeBindingForTesting( + processName: "tmux: client", + processPath: "/opt/homebrew/bin/tmux", + arguments: ["/opt/homebrew/bin/tmux", "a", "-t", "work"], + environment: [:] + ) + ) + + XCTAssertEqual(binding.checkpointId, "work") + XCTAssertEqual(binding.command, "'/opt/homebrew/bin/tmux' 'attach' '-t' 'work'") + } + + func testTmuxProcessDetectedResumeBindingDoesNotUseProcessTitleAsExecutable() throws { + let binding = try XCTUnwrap( + SurfaceResumeBindingIndex.tmuxResumeBindingForTesting( + processName: "tmux: client", + processPath: "/opt/homebrew/bin/tmux", + arguments: ["tmux: client", "attach-session", "-t", "work"], + environment: [:] + ) + ) + + XCTAssertEqual(binding.command, "'/opt/homebrew/bin/tmux' 'attach' '-t' 'work'") + } + + func testTmuxProcessDetectedResumeBindingDropsFullClientProcessTitle() throws { + let binding = try XCTUnwrap( + SurfaceResumeBindingIndex.tmuxResumeBindingForTesting( + processName: "tmux: client (/dev/ttys001)", + processPath: nil, + arguments: ["tmux: client (/dev/ttys001)", "attach-session", "-t", "work"], + environment: ["PWD": "/tmp/project"] + ) + ) + + XCTAssertEqual(binding.checkpointId, "work") + XCTAssertEqual(binding.cwd, "/tmp/project") + XCTAssertEqual(binding.command, "'tmux' 'attach' '-t' 'work'") + } + + func testTmuxProcessDetectedResumeBindingRejectsFullServerProcessTitle() { + let binding = SurfaceResumeBindingIndex.tmuxResumeBindingForTesting( + processName: "tmux: server (/private/tmp/tmux-501/default)", + processPath: nil, + arguments: ["tmux: server (/private/tmp/tmux-501/default)"], + environment: [:] + ) + + XCTAssertNil(binding) + } + + func testTmuxProcessDetectedResumeBindingRejectsServerProcessTitle() { + let binding = SurfaceResumeBindingIndex.tmuxResumeBindingForTesting( + processName: "tmux: server", + processPath: "/opt/homebrew/bin/tmux", + arguments: ["tmux: server"], + environment: [:] + ) + + XCTAssertNil(binding) + } + + func testTmuxAttachFlagParserTreatsConfigFlagAsValueTaking() { + let binding = SurfaceResumeBindingIndex.tmuxResumeBindingForTesting( + processName: "tmux", + processPath: nil, + arguments: ["tmux", "new", "-fA"], + environment: [:] + ) + + XCTAssertNil(binding) + } + + func testTmuxAttachFlagParserTreatsShellCommandFlagAsValueTaking() throws { + let binding = try XCTUnwrap( + SurfaceResumeBindingIndex.tmuxResumeBindingForTesting( + processName: "tmux", + processPath: nil, + arguments: ["tmux", "-c", "/bin/zsh", "attach", "-t", "work"], + environment: [:] + ) + ) + + XCTAssertEqual(binding.checkpointId, "work") + XCTAssertEqual(binding.command, "'tmux' 'attach' '-t' 'work'") + } + + func testTmuxProcessDetectedResumeBindingRejectsUnnamedAttach() { + let attachBinding = SurfaceResumeBindingIndex.tmuxResumeBindingForTesting( + processName: "tmux", + processPath: nil, + arguments: ["tmux", "attach"], + environment: [:] + ) + let aliasBinding = SurfaceResumeBindingIndex.tmuxResumeBindingForTesting( + processName: "tmux", + processPath: nil, + arguments: ["tmux", "a"], + environment: [:] + ) + + XCTAssertNil(attachBinding) + XCTAssertNil(aliasBinding) + } + + func testTmuxProcessDetectedResumeBindingRejectsCommandlessClient() { + let executableOnlyBinding = SurfaceResumeBindingIndex.tmuxResumeBindingForTesting( + processName: "tmux", + processPath: nil, + arguments: ["tmux"], + environment: [:] + ) + let processTitleBinding = SurfaceResumeBindingIndex.tmuxResumeBindingForTesting( + processName: "tmux: client", + processPath: nil, + arguments: ["tmux: client"], + environment: [:] + ) + + XCTAssertNil(executableOnlyBinding) + XCTAssertNil(processTitleBinding) + } + + func testTmuxOptionValueDoesNotReadTargetFromConfigValue() { + let binding = SurfaceResumeBindingIndex.tmuxResumeBindingForTesting( + processName: "tmux", + processPath: nil, + arguments: ["tmux", "attach", "-factive-pane"], + environment: [:] + ) + + XCTAssertNil(binding) + } + + func testTmuxOptionValueStopsAtValueTakingClusterOption() { + let binding = SurfaceResumeBindingIndex.tmuxResumeBindingForTesting( + processName: "tmux", + processPath: nil, + arguments: ["tmux", "new", "-Ans"], + environment: [:] + ) + + XCTAssertNil(binding) + } + + func testTmuxOptionValueStopsAtCommandTerminator() { + let attachBinding = SurfaceResumeBindingIndex.tmuxResumeBindingForTesting( + processName: "tmux", + processPath: nil, + arguments: ["tmux", "attach", "--", "-t", "work"], + environment: [:] + ) + let newBinding = SurfaceResumeBindingIndex.tmuxResumeBindingForTesting( + processName: "tmux", + processPath: nil, + arguments: ["tmux", "new", "-A", "--", "-s", "work"], + environment: [:] + ) + + XCTAssertNil(attachBinding) + XCTAssertNil(newBinding) + } + + func testTmuxProcessDetectedResumeBindingRejectsUnnamedNewAttachSession() { + let binding = SurfaceResumeBindingIndex.tmuxResumeBindingForTesting( + processName: "tmux", + processPath: nil, + arguments: ["tmux", "new-session", "-A"], + environment: [:] + ) + + XCTAssertNil(binding) + } + + func testTmuxProcessDetectedResumeBindingParsesNewAttachSession() throws { + let binding = try XCTUnwrap( + SurfaceResumeBindingIndex.tmuxResumeBindingForTesting( + processName: "tmux", + processPath: nil, + arguments: ["tmux", "new", "-As", "work"], + environment: [:] + ) + ) + + XCTAssertEqual(binding.checkpointId, "work") + XCTAssertEqual(binding.command, "'tmux' 'attach' '-t' 'work'") + } + + func testTmuxProcessDetectedResumeBindingRejectsSessionNameThatLooksLikeAttachFlag() { + let binding = SurfaceResumeBindingIndex.tmuxResumeBindingForTesting( + processName: "tmux", + processPath: nil, + arguments: ["tmux", "new", "-sA"], + environment: [:] + ) + + XCTAssertNil(binding) + } + func testMarkdownFileLinkResolverRecognizesMarkdownPathLikeStrings() { XCTAssertTrue(MarkdownPanelFileLinkResolver.isMarkdownPathLike("other-markdown.md")) XCTAssertTrue(MarkdownPanelFileLinkResolver.isMarkdownPathLike("test/markdown.md")) diff --git a/cmuxTests/SettingsSearchIndexTests.swift b/cmuxTests/SettingsSearchIndexTests.swift index b9537458b5d9..0f7898a4451f 100644 --- a/cmuxTests/SettingsSearchIndexTests.swift +++ b/cmuxTests/SettingsSearchIndexTests.swift @@ -20,6 +20,7 @@ final class SettingsSearchIndexTests: XCTestCase { assertSearch("workspace cwd", contains: SettingsSearchIndex.settingID(for: .app, idSuffix: "workspace-inherit-working-directory")) assertSearch("claude sessions", contains: SettingsSearchIndex.settingID(for: .terminal, idSuffix: "agent-auto-resume")) assertSearch("opencode resume", contains: SettingsSearchIndex.settingID(for: .terminal, idSuffix: "agent-auto-resume")) + assertSearch("tmux resume command approval", contains: SettingsSearchIndex.settingID(for: .terminal, idSuffix: "resume-commands")) assertSearch("ctrl b", contains: SettingsSearchIndex.settingID(for: .keyboardShortcuts, idSuffix: "shortcut-chords")) assertSearch("split right", contains: SettingsSearchIndex.settingID(for: .keyboardShortcuts, idSuffix: "shortcuts")) assertSearch("factory defaults", contains: SettingsSearchIndex.settingID(for: .reset, idSuffix: "reset-all")) diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index 1b43746d6a8e..5b1d0c2af7c9 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -2556,6 +2556,34 @@ final class WorkspaceCreationWorkingDirectoryInheritanceTests: XCTestCase { } } + func testDetachedWorkspaceDoesNotPersistProcessDetectedResumeBinding() throws { + let manager = TabManager( + initialWorkingDirectory: "/tmp/cmux-source-\(UUID().uuidString)", + autoWelcomeIfNeeded: false + ) + let source = try XCTUnwrap(manager.selectedWorkspace) + let binding = SurfaceResumeBindingSnapshot( + name: "tmux work", + kind: "tmux", + command: "tmux attach -t work", + cwd: "/tmp/cmux-source", + checkpointId: "work", + source: "process-detected", + updatedAt: 1_777_777_777 + ) + let detached = makeDetachedWorkspaceTestTransfer( + sourceWorkspaceId: source.id, + resumeBinding: binding + ) + + let inserted = try XCTUnwrap(manager.addWorkspace( + fromDetachedSurface: detached, + select: false + )) + + XCTAssertNil(inserted.surfaceResumeBinding(panelId: detached.panelId)) + } + private func withWorkspaceWorkingDirectoryInheritanceSetting( _ value: Bool?, _ body: () throws -> Void @@ -2582,7 +2610,8 @@ final class WorkspaceCreationWorkingDirectoryInheritanceTests: XCTestCase { private func makeDetachedWorkspaceTestTransfer( sourceWorkspaceId: UUID, - directory: String? = nil + directory: String? = nil, + resumeBinding: SurfaceResumeBindingSnapshot? = nil ) -> Workspace.DetachedSurfaceTransfer { let panel = DetachedWorkspaceTestPanel() return Workspace.DetachedSurfaceTransfer( @@ -2603,6 +2632,7 @@ final class WorkspaceCreationWorkingDirectoryInheritanceTests: XCTestCase { restoredUnread: false, restorableAgent: nil, restorableAgentResumeState: nil, + resumeBinding: resumeBinding, agentRuntime: nil, isRemoteTerminal: false, remoteRelayPort: nil, diff --git a/docs/agent-hooks.md b/docs/agent-hooks.md index 578d2ce84885..aa885a646cd8 100644 --- a/docs/agent-hooks.md +++ b/docs/agent-hooks.md @@ -44,6 +44,12 @@ Session hooks write `~/.cmuxterm/<agent>-hook-sessions.json`. Each entry stores The sanitizer preserves model, sandbox, config, and cwd-related flags. It drops prompts, credentials, old session selectors, and noninteractive commands so relaunch resumes the session instead of starting a new task or leaking secrets. +## Custom surface resume commands + +Use `cmux surface resume set --shell <command>` to attach a resume command to the current terminal surface. Public CLI and socket-created commands are kept for inspection and manual restore by default. To auto-run one on restore, approve the prompt or change its signed command prefix in **Settings > Terminal > Resume Commands**. + +Approvals are prefix-based and signed by cmux. They also bind the working directory and exact environment values when present. A process can propose a command, but it cannot make that command sticky without the user choosing Auto-Restore or Ask Each Time in cmux. + ## Disable automatic resume To restore panes without automatically restarting saved agent sessions, turn off diff --git a/web/app/[locale]/docs/session-restore/page.tsx b/web/app/[locale]/docs/session-restore/page.tsx index 0b7e287e68d2..b251657d3cca 100644 --- a/web/app/[locale]/docs/session-restore/page.tsx +++ b/web/app/[locale]/docs/session-restore/page.tsx @@ -45,6 +45,13 @@ cmux hooks setup codex cmux hooks setup --agent opencode`}</CodeBlock> <p>{t("setupBehavior")}</p> + <h2>{t("surfaceBindingsTitle")}</h2> + <p>{t("surfaceBindingsDesc")}</p> + <CodeBlock lang="bash">{`cmux surface resume set --kind tmux --checkpoint work --shell "tmux attach -t work" +cmux surface resume show --json +cmux surface resume clear --checkpoint work`}</CodeBlock> + <p>{t("surfaceBindingsNote")}</p> + <h2>{t("supportedTitle")}</h2> <table> <thead> diff --git a/web/messages/ar.json b/web/messages/ar.json index 017f82ce4c08..75a7669d032e 100644 --- a/web/messages/ar.json +++ b/web/messages/ar.json @@ -701,6 +701,7 @@ "gettingStarted": "البدء", "concepts": "المفاهيم", "configuration": "الإعدادات", + "sessionRestore": "استعادة الجلسة", "customCommands": "أوامر مخصصة", "keyboardShortcuts": "اختصارات لوحة المفاتيح", "apiReference": "مرجع الواجهة البرمجية", @@ -746,6 +747,49 @@ "agentPromptIntro": "استخدم هذا الموجه عندما تريد من coding agent إنشاء عناصر تحكم Dock. يطلب من الوكيل تشغيل `cmux docs dock`، وفحص المشروع، والسؤال قبل التخمين.", "agentPromptCodeTitle": "موجه الوكيل", "agentPrompt": "Set up cmux Dock controls for the current context.\n\nFirst, learn the feature before editing:\n1. Run `cmux docs dock` if the cmux CLI is available. If it is not, read https://cmux.com/docs/dock.\n2. Inspect the repository or current directory to understand the project type, scripts, package manager, dev servers, logs, task runners, test commands, and any existing TUI tools.\n3. If the desired Dock is ambiguous, ask the user what they want monitored or controlled before writing files.\n\nDock is cmux's right-sidebar terminal control area. A Dock config is JSON with a top-level `controls` array. Each control runs a command in its own Ghostty-backed terminal section using the user's login shell. Controls are useful for project dashboards, git/status views, dev server or build status, test watchers, log tails, queues, local services, or a custom TUI such as `cmux feed tui --opentui` when that feed is useful.\n\nChoose where to write the config:\n- In a repository or project directory, create or edit `.cmux/dock.json` so teammates can share it.\n- For a personal default outside a repo, create or edit `~/.config/cmux/dock.json`.\n- If both exist, project `.cmux/dock.json` is more specific for that project. Nested project configs apply to that directory tree; use the nearest relevant project config instead of writing unrelated controls globally.\n- If there is no repo and no clear project root, use the global config only after confirming the user wants a personal Dock.\n\nSchema:\n{\n \"controls\": [\n {\n \"id\": \"short-stable-id\",\n \"title\": \"Human label\",\n \"command\": \"safe command to run\",\n \"cwd\": \"optional/path\",\n \"height\": 220,\n \"env\": { \"NAME\": \"value\" }\n }\n ]\n}\n\nRules:\n- Keep ids stable, lowercase, and unique.\n- Use `cwd` for subdirectories; relative paths resolve from the config base.\n- Use `height` only when a control needs a fixed amount of vertical space.\n- Use `env` only for non-secret values needed by one control.\n- Do not put secrets, tokens, or machine-specific private paths in a shared project config.\n- Prefer commands that are safe to start repeatedly and make sense in a terminal.\n- Do not invent unavailable scripts. Read package files, Makefiles, Procfiles, README docs, config files, and existing tooling first.\n- Keep shared project Docks portable for teammates. Put personal or machine-specific controls in the global Dock.\n\nDeliverable:\n- Create or update the appropriate dock.json.\n- Preserve existing useful controls unless the user asked to replace them.\n- Validate that the JSON parses.\n- Summarize what each control does and any commands the user should review before trusting the Dock config." + }, + "sessionRestore": { + "title": "استعادة الجلسة", + "metaTitle": "استعادة الجلسة", + "metaDescription": "كيف يستعيد cmux النوافذ ومساحات العمل واللوحات ومجلدات العمل والتمرير الخلفي وسجل المتصفح وجلسات الوكلاء المدعومة بعد إعادة التشغيل.", + "intro": "يحفظ cmux شكل عملك حتى تعيد إعادة تشغيل التطبيق النوافذ ومساحات العمل واللوحات وسياق الطرفية وحالة المتصفح نفسها.", + "restoredTitle": "ما الذي يستعيده cmux", + "restoredDesc": "بعد إعادة التشغيل، يستعيد cmux التخطيط والبيانات الوصفية التي يملكها التطبيق:", + "restoredItemLayout": "تخطيط النوافذ ومساحات العمل واللوحات", + "restoredItemCwd": "مجلدات العمل", + "restoredItemScrollback": "تمرير الطرفية الخلفي، بأفضل جهد", + "restoredItemBrowser": "رابط المتصفح وسجل التنقل", + "liveProcessCallout": "لا يحفظ cmux حالة العمليات الحية العامة. يعاد فتح tmux وvim والصدفات وتطبيقات الطرفية غير المدعومة كطرفيات عادية ما لم يكن لديها تكامل استئناف خاص في cmux.", + "agentResumeTitle": "استئناف جلسات الوكلاء", + "agentResumeDesc": "يمكن استئناف وكلاء البرمجة المدعومين عندما يلتقط cmux معرف الجلسة الأصلي للوكيل. ثبّت hooks بعد تثبيت CLI الخاص بالوكيل حتى يكون الملف التنفيذي على PATH:", + "setupBehavior": "يشغّل cmux hooks setup كل تكامل مدعوم يعثر عليه ويطبع ملخصًا للوكلاء الذين تم تخطيهم. استخدم اسم وكيل عندما تريد تكاملًا واحدًا فقط.", + "surfaceBindingsTitle": "أوامر استئناف surface مخصصة", + "surfaceBindingsDesc": "يمكن للمستخدمين المتقدمين والتكاملات ربط أي surface طرفية بأمر إعادة تشغيل. يحفظ cmux الارتباطات التي ينشئها CLI العام أو socket للفحص والاستئناف اليدوي.", + "surfaceBindingsNote": "يمكن مراجعة البادئات المعتمدة أو تعديلها من Settings > Terminal > Resume Commands. يشغّل cmux تلقائيًا فقط ارتباطات الاستئناف التي يضع عليها علامة موثوقة، مثل ارتباطات tmux المكتشفة من العمليات الحية أو البادئات التي اعتمدها المستخدم. لا يحفظ cmux ذاكرة العمليات العامة. تُسقط مفاتيح البيئة الحساسة مثل الرموز وكلمات المرور والأسرار ومفاتيح API قبل حفظ ربط الاستئناف. ترتبط الموافقات أيضاً بدليل العمل وقيم البيئة الدقيقة عند وجودها.", + "supportedTitle": "الوكلاء المدعومون", + "agentHeader": "الوكيل", + "binaryHeader": "الملف التنفيذي", + "resumeHeader": "أمر الاستئناف", + "feedHeader": "جسر التغذية", + "feedPermissionRequest": "PermissionRequest", + "feedPreToolUsePermissionRequest": "PreToolUse، PermissionRequest", + "feedBeforeShellExecution": "beforeShellExecution", + "feedPreToolUse": "PreToolUse", + "pluginEventBus": "حافلة أحداث plugin", + "none": "لا يوجد", + "supportedNote": "يتعامل cmux Claude wrapper مع Claude Code عندما يكون تكامل Claude مفعّلًا في الإعدادات. يقبل Rovo Dev أيضًا rovo كاسم إعداد مستعار.", + "manualTitle": "استعادة يدوية", + "manualDesc": "يستعيد cmux آخر snapshot محفوظ عند التشغيل العادي. يمكنك أيضًا إعادة تطبيق snapshot السابقة يدويًا:", + "manualItemMenu": "File > Reopen Previous Session", + "disableTitle": "تعطيل الاستئناف التلقائي للوكلاء", + "disableDesc": "لاستعادة اللوحات من دون تشغيل أوامر استئناف الوكلاء المحفوظة، أوقف Settings > Terminal > Resume Agent Sessions on Reopen أو اضبط:", + "disableNote": "يعطّل هذا أوامر استئناف الوكلاء فقط. يستمر cmux في استعادة التخطيط ومجلدات العمل والتمرير الخلفي وسجل المتصفح.", + "underHoodTitle": "كيف يعمل", + "underHoodItemSnapshot": "يكتب cmux snapshot JSON بإصدار إلى ~/Library/Application Support/cmux/session-<bundle-id>.json، إضافة إلى cache للجلسة السابقة لإعادة الفتح اليدوي.", + "underHoodItemScrollback": "يُحفظ تمرير الطرفية الخلفي كنص محدود ويعاد تشغيله عبر ملف مؤقت عند الاستعادة. هذا بأفضل جهد لأن تطبيقات الطرفية يمكنها إعادة رسم الشاشة أو مسحها.", + "underHoodItemHooks": "تكتب agent hooks الملف ~/.cmuxterm/<agent>-hook-sessions.json وفيه معرف جلسة الوكيل، ومعرف مساحة عمل cmux، ومعرف surface، وcwd، ومعرف العملية عند توفره، وأمر تشغيل منظف.", + "underHoodItemResume": "عند الاستعادة، يعيد cmux بناء النوافذ واللوحات أولًا. إذا كان استئناف الوكلاء التلقائي مفعّلًا، يشغّل أمر shell لمرة واحدة ينفذ أمر الاستئناف الأصلي للوكيل مع معرف الجلسة المحفوظ.", + "underHoodMore": "تغطي <configLink>وثائق الإعداد</configLink> العادية cmux.json. تبقي استعادة الجلسة تخطيط التطبيق منفصلًا عن إعدادات عرض طرفية Ghostty." } }, "legal": { diff --git a/web/messages/bs.json b/web/messages/bs.json index fe4da2a2a5cd..d651461f5f91 100644 --- a/web/messages/bs.json +++ b/web/messages/bs.json @@ -701,6 +701,7 @@ "gettingStarted": "Početak rada", "concepts": "Koncepti", "configuration": "Konfiguracija", + "sessionRestore": "Obnova sesije", "customCommands": "Prilagođene komande", "keyboardShortcuts": "Prečice na tastaturi", "apiReference": "API Referenca", @@ -746,6 +747,49 @@ "agentPromptIntro": "Koristi ovaj prompt kada želiš da coding agent napravi Dock kontrole. On kaže agentu da pokrene `cmux docs dock`, pregleda projekat i pita prije nego što pogađa.", "agentPromptCodeTitle": "Agent prompt", "agentPrompt": "Set up cmux Dock controls for the current context.\n\nFirst, learn the feature before editing:\n1. Run `cmux docs dock` if the cmux CLI is available. If it is not, read https://cmux.com/docs/dock.\n2. Inspect the repository or current directory to understand the project type, scripts, package manager, dev servers, logs, task runners, test commands, and any existing TUI tools.\n3. If the desired Dock is ambiguous, ask the user what they want monitored or controlled before writing files.\n\nDock is cmux's right-sidebar terminal control area. A Dock config is JSON with a top-level `controls` array. Each control runs a command in its own Ghostty-backed terminal section using the user's login shell. Controls are useful for project dashboards, git/status views, dev server or build status, test watchers, log tails, queues, local services, or a custom TUI such as `cmux feed tui --opentui` when that feed is useful.\n\nChoose where to write the config:\n- In a repository or project directory, create or edit `.cmux/dock.json` so teammates can share it.\n- For a personal default outside a repo, create or edit `~/.config/cmux/dock.json`.\n- If both exist, project `.cmux/dock.json` is more specific for that project. Nested project configs apply to that directory tree; use the nearest relevant project config instead of writing unrelated controls globally.\n- If there is no repo and no clear project root, use the global config only after confirming the user wants a personal Dock.\n\nSchema:\n{\n \"controls\": [\n {\n \"id\": \"short-stable-id\",\n \"title\": \"Human label\",\n \"command\": \"safe command to run\",\n \"cwd\": \"optional/path\",\n \"height\": 220,\n \"env\": { \"NAME\": \"value\" }\n }\n ]\n}\n\nRules:\n- Keep ids stable, lowercase, and unique.\n- Use `cwd` for subdirectories; relative paths resolve from the config base.\n- Use `height` only when a control needs a fixed amount of vertical space.\n- Use `env` only for non-secret values needed by one control.\n- Do not put secrets, tokens, or machine-specific private paths in a shared project config.\n- Prefer commands that are safe to start repeatedly and make sense in a terminal.\n- Do not invent unavailable scripts. Read package files, Makefiles, Procfiles, README docs, config files, and existing tooling first.\n- Keep shared project Docks portable for teammates. Put personal or machine-specific controls in the global Dock.\n\nDeliverable:\n- Create or update the appropriate dock.json.\n- Preserve existing useful controls unless the user asked to replace them.\n- Validate that the JSON parses.\n- Summarize what each control does and any commands the user should review before trusting the Dock config." + }, + "sessionRestore": { + "title": "Obnova sesije", + "metaTitle": "Obnova sesije", + "metaDescription": "Kako cmux nakon ponovnog pokretanja obnavlja prozore, workspaceove, paneove, radne direktorije, scrollback, historiju browsera i podržane agent sesije.", + "intro": "cmux čuva oblik tvog rada kako bi ponovno pokretanje aplikacije vratilo iste prozore, workspaceove, paneove, terminalski kontekst i stanje browsera.", + "restoredTitle": "Šta cmux obnavlja", + "restoredDesc": "Nakon ponovnog pokretanja cmux obnavlja layout i metapodatke kojima upravlja aplikacija:", + "restoredItemLayout": "Layout prozora, workspaceova i paneova", + "restoredItemCwd": "Radne direktorije", + "restoredItemScrollback": "Terminalski scrollback, best effort", + "restoredItemBrowser": "Browser URL i historiju navigacije", + "liveProcessCallout": "cmux ne checkpointuje proizvoljno stanje živih procesa. tmux, vim, shellovi i nepodržane terminalske aplikacije ponovno se otvaraju kao obični terminali osim ako imaju vlastitu cmux resume integraciju.", + "agentResumeTitle": "Nastavak agent sesije", + "agentResumeDesc": "Podržani AI coding agenti mogu nastaviti kada cmux uhvati nativni session ID agenta. Instaliraj hooks nakon agent CLI-ja kako bi binarij bio na PATH:", + "setupBehavior": "cmux hooks setup instalira sve podržane integracije koje pronađe i ispisuje sažetak preskočenih agenata. Navedi ime agenta kada želiš samo jednu integraciju.", + "surfaceBindingsTitle": "Prilagođene surface resume komande", + "surfaceBindingsDesc": "Napredni korisnici i integracije mogu vezati bilo koju terminalsku surface za restart komandu. cmux čuva bindinge napravljene javnim CLI-jem ili socketom za pregled i ručni nastavak.", + "surfaceBindingsNote": "Odobreni prefiksi mogu se pregledati ili urediti u Settings > Terminal > Resume Commands. cmux automatski pokreće samo resume bindinge koje označi pouzdanim, poput tmux bindinga otkrivenih iz živih procesa ili prefiksa koje je odobrio korisnik. cmux i dalje ne checkpointuje proizvoljnu memoriju procesa. Osjetljivi ključevi okruženja, poput tokena, lozinki, tajni i API ključeva, odbacuju se prije spremanja resume bindinga. Odobrenja se također vežu za radni direktorij i tačne vrijednosti okruženja kada postoje.", + "supportedTitle": "Podržani agenti", + "agentHeader": "Agent", + "binaryHeader": "Binarij", + "resumeHeader": "Resume komanda", + "feedHeader": "Feed bridge", + "feedPermissionRequest": "PermissionRequest", + "feedPreToolUsePermissionRequest": "PreToolUse, PermissionRequest", + "feedBeforeShellExecution": "beforeShellExecution", + "feedPreToolUse": "PreToolUse", + "pluginEventBus": "plugin event bus", + "none": "nema", + "supportedNote": "Claude Code obrađuje cmux Claude wrapper kada je Claude integracija uključena u Settings. Rovo Dev prihvata i rovo kao setup alias.", + "manualTitle": "Ručna obnova", + "manualDesc": "cmux pri normalnom pokretanju obnavlja zadnji sačuvani snapshot. Prethodni snapshot možeš ponovno primijeniti i ručno:", + "manualItemMenu": "File > Reopen Previous Session", + "disableTitle": "Isključi automatski nastavak agenata", + "disableDesc": "Za obnovu paneova bez pokretanja sačuvanih agent resume komandi, isključi Settings > Terminal > Resume Agent Sessions on Reopen ili postavi:", + "disableNote": "Ovo isključuje samo agent resume komande. cmux i dalje obnavlja layout, radne direktorije, scrollback i historiju browsera.", + "underHoodTitle": "Kako radi", + "underHoodItemSnapshot": "cmux piše verzionisani JSON snapshot u ~/Library/Application Support/cmux/session-<bundle-id>.json, plus cache prethodne sesije za ručno ponovno otvaranje.", + "underHoodItemScrollback": "Terminalski scrollback čuva se kao ograničen tekst i pri obnovi se replaya kroz privremenu datoteku. Ovo je best effort jer terminalske aplikacije mogu ponovo nacrtati ili obrisati ekran.", + "underHoodItemHooks": "Agent hooks pišu ~/.cmuxterm/<agent>-hook-sessions.json s agent session ID-jem, cmux workspace ID-jem, surface ID-jem, cwd, PID-om kada je dostupan i sanitizovanom launch komandom.", + "underHoodItemResume": "Pri obnovi cmux prvo rekonstruira prozore i paneove. Ako je automatski nastavak agenata uključen, pokreće jednokratnu shell komandu koja izvršava nativnu resume komandu agenta sa sačuvanim session ID-jem.", + "underHoodMore": "Redovna <configLink>dokumentacija konfiguracije</configLink> pokriva cmux.json. Obnova sesije drži layout aplikacije odvojen od Ghostty terminal rendering postavki." } }, "legal": { diff --git a/web/messages/da.json b/web/messages/da.json index f5a3f5bacbd1..dae091d20330 100644 --- a/web/messages/da.json +++ b/web/messages/da.json @@ -701,6 +701,7 @@ "gettingStarted": "Kom i gang", "concepts": "Koncepter", "configuration": "Konfiguration", + "sessionRestore": "Sessionsgenoprettelse", "customCommands": "Brugerdefinerede kommandoer", "keyboardShortcuts": "Tastaturgenveje", "apiReference": "API-reference", @@ -746,6 +747,49 @@ "agentPromptIntro": "Brug denne prompt, når du vil have en coding agent til at oprette Dock-kontroller. Den beder agenten om at køre `cmux docs dock`, undersøge projektet og spørge før den gætter.", "agentPromptCodeTitle": "Agent-prompt", "agentPrompt": "Set up cmux Dock controls for the current context.\n\nFirst, learn the feature before editing:\n1. Run `cmux docs dock` if the cmux CLI is available. If it is not, read https://cmux.com/docs/dock.\n2. Inspect the repository or current directory to understand the project type, scripts, package manager, dev servers, logs, task runners, test commands, and any existing TUI tools.\n3. If the desired Dock is ambiguous, ask the user what they want monitored or controlled before writing files.\n\nDock is cmux's right-sidebar terminal control area. A Dock config is JSON with a top-level `controls` array. Each control runs a command in its own Ghostty-backed terminal section using the user's login shell. Controls are useful for project dashboards, git/status views, dev server or build status, test watchers, log tails, queues, local services, or a custom TUI such as `cmux feed tui --opentui` when that feed is useful.\n\nChoose where to write the config:\n- In a repository or project directory, create or edit `.cmux/dock.json` so teammates can share it.\n- For a personal default outside a repo, create or edit `~/.config/cmux/dock.json`.\n- If both exist, project `.cmux/dock.json` is more specific for that project. Nested project configs apply to that directory tree; use the nearest relevant project config instead of writing unrelated controls globally.\n- If there is no repo and no clear project root, use the global config only after confirming the user wants a personal Dock.\n\nSchema:\n{\n \"controls\": [\n {\n \"id\": \"short-stable-id\",\n \"title\": \"Human label\",\n \"command\": \"safe command to run\",\n \"cwd\": \"optional/path\",\n \"height\": 220,\n \"env\": { \"NAME\": \"value\" }\n }\n ]\n}\n\nRules:\n- Keep ids stable, lowercase, and unique.\n- Use `cwd` for subdirectories; relative paths resolve from the config base.\n- Use `height` only when a control needs a fixed amount of vertical space.\n- Use `env` only for non-secret values needed by one control.\n- Do not put secrets, tokens, or machine-specific private paths in a shared project config.\n- Prefer commands that are safe to start repeatedly and make sense in a terminal.\n- Do not invent unavailable scripts. Read package files, Makefiles, Procfiles, README docs, config files, and existing tooling first.\n- Keep shared project Docks portable for teammates. Put personal or machine-specific controls in the global Dock.\n\nDeliverable:\n- Create or update the appropriate dock.json.\n- Preserve existing useful controls unless the user asked to replace them.\n- Validate that the JSON parses.\n- Summarize what each control does and any commands the user should review before trusting the Dock config." + }, + "sessionRestore": { + "title": "Sessionsgenoprettelse", + "metaTitle": "Sessionsgenoprettelse", + "metaDescription": "Hvordan cmux gendanner vinduer, workspaces, paneler, arbejdsmapper, scrollback, browserhistorik og understøttede agent-sessioner efter genstart.", + "intro": "cmux gemmer formen på dit arbejde, så appen efter genstart kan hente de samme vinduer, workspaces, paneler, terminalkontekst og browserstatus tilbage.", + "restoredTitle": "Hvad cmux gendanner", + "restoredDesc": "Efter genstart gendanner cmux layout og metadata ejet af appen:", + "restoredItemLayout": "Vindues-, workspace- og panel-layout", + "restoredItemCwd": "Arbejdsmapper", + "restoredItemScrollback": "Terminal-scrollback, best effort", + "restoredItemBrowser": "Browser-URL og navigationshistorik", + "liveProcessCallout": "cmux checkpoint'er ikke vilkårlig live-processtatus. tmux, vim, shells og ikke-understøttede terminalapps åbner som normale terminaler, medmindre de har deres egen cmux resume-integration.", + "agentResumeTitle": "Genoptag agent-session", + "agentResumeDesc": "Understøttede AI-kodeagenter kan genoptages, når cmux har fanget agentens native session-ID. Installer hooks efter agent-CLI'en, så binæren findes på PATH:", + "setupBehavior": "cmux hooks setup installerer alle understøttede integrationer, den kan finde, og viser en oversigt over agenter, der blev sprunget over. Brug et agentnavn, når du kun vil have én integration.", + "surfaceBindingsTitle": "Brugerdefinerede surface resume-kommandoer", + "surfaceBindingsDesc": "Avancerede brugere og integrationer kan binde enhver terminal-surface til en restart-kommando. cmux gemmer bindinger oprettet via offentlig CLI eller socket til inspektion og manuel genoptagelse.", + "surfaceBindingsNote": "Godkendte præfikser kan gennemgås eller redigeres i Settings > Terminal > Resume Commands. cmux auto-kører kun resume-bindinger, som den markerer som betroede, for eksempel tmux-bindinger fundet fra live processer eller bruger-godkendte præfikser. cmux checkpoint'er stadig ikke vilkårlig proceshukommelse. Følsomme miljønøgler som tokens, adgangskoder, hemmeligheder og API-nøgler fjernes, før en resume-binding gemmes. Godkendelser bindes også til arbejdsmappen og præcise miljøværdier, når de findes.", + "supportedTitle": "Understøttede agenter", + "agentHeader": "Agent", + "binaryHeader": "Binær", + "resumeHeader": "Resume-kommando", + "feedHeader": "Feed-bro", + "feedPermissionRequest": "PermissionRequest", + "feedPreToolUsePermissionRequest": "PreToolUse, PermissionRequest", + "feedBeforeShellExecution": "beforeShellExecution", + "feedPreToolUse": "PreToolUse", + "pluginEventBus": "plugin event bus", + "none": "ingen", + "supportedNote": "Claude Code håndteres af cmux Claude-wrapperen, når Claude-integration er aktiveret i Settings. Rovo Dev accepterer også rovo som setup-alias.", + "manualTitle": "Manuel gendannelse", + "manualDesc": "cmux gendanner det senest gemte snapshot ved normal start. Du kan også genanvende det forrige snapshot manuelt:", + "manualItemMenu": "File > Reopen Previous Session", + "disableTitle": "Slå automatisk agent-genoptagelse fra", + "disableDesc": "For at gendanne paneler uden at starte gemte agent resume-kommandoer skal du slå Settings > Terminal > Resume Agent Sessions on Reopen fra eller sætte:", + "disableNote": "Dette slår kun agent resume-kommandoer fra. cmux gendanner stadig layout, arbejdsmapper, scrollback og browserhistorik.", + "underHoodTitle": "Sådan virker det", + "underHoodItemSnapshot": "cmux skriver et versioneret JSON-snapshot til ~/Library/Application Support/cmux/session-<bundle-id>.json samt en previous-session cache til manuel genåbning.", + "underHoodItemScrollback": "Terminal-scrollback gemmes som begrænset tekst og replayes via en midlertidig fil ved gendannelse. Det er best effort, fordi terminalapps kan gentegne eller rydde skærmen.", + "underHoodItemHooks": "Agent hooks skriver ~/.cmuxterm/<agent>-hook-sessions.json med agent-session-ID, cmux workspace-ID, surface-ID, cwd, proces-ID når det findes, og en saniteret startkommando.", + "underHoodItemResume": "Ved gendannelse genopbygger cmux først vinduer og paneler. Hvis automatisk agent-genoptagelse er aktiveret, starter den en engangs-shellkommando, der kører agentens native resume-kommando med det gemte session-ID.", + "underHoodMore": "Den almindelige <configLink>konfigurationsdokumentation</configLink> dækker cmux.json. Sessionsgenoprettelse holder app-layout adskilt fra Ghostty-terminalens renderingsindstillinger." } }, "legal": { diff --git a/web/messages/de.json b/web/messages/de.json index c7cbb0ecd082..1aeb6575e403 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -701,6 +701,7 @@ "gettingStarted": "Erste Schritte", "concepts": "Konzepte", "configuration": "Konfiguration", + "sessionRestore": "Sitzungswiederherstellung", "customCommands": "Benutzerdefinierte Befehle", "keyboardShortcuts": "Tastaturkürzel", "apiReference": "API-Referenz", @@ -746,6 +747,49 @@ "agentPromptIntro": "Verwende diesen Prompt, wenn ein Coding-Agent Dock-Kontrollen erstellen soll. Er weist den Agenten an, `cmux docs dock` auszuführen, das Projekt zu prüfen und vor dem Raten nachzufragen.", "agentPromptCodeTitle": "Agenten-Prompt", "agentPrompt": "Set up cmux Dock controls for the current context.\n\nFirst, learn the feature before editing:\n1. Run `cmux docs dock` if the cmux CLI is available. If it is not, read https://cmux.com/docs/dock.\n2. Inspect the repository or current directory to understand the project type, scripts, package manager, dev servers, logs, task runners, test commands, and any existing TUI tools.\n3. If the desired Dock is ambiguous, ask the user what they want monitored or controlled before writing files.\n\nDock is cmux's right-sidebar terminal control area. A Dock config is JSON with a top-level `controls` array. Each control runs a command in its own Ghostty-backed terminal section using the user's login shell. Controls are useful for project dashboards, git/status views, dev server or build status, test watchers, log tails, queues, local services, or a custom TUI such as `cmux feed tui --opentui` when that feed is useful.\n\nChoose where to write the config:\n- In a repository or project directory, create or edit `.cmux/dock.json` so teammates can share it.\n- For a personal default outside a repo, create or edit `~/.config/cmux/dock.json`.\n- If both exist, project `.cmux/dock.json` is more specific for that project. Nested project configs apply to that directory tree; use the nearest relevant project config instead of writing unrelated controls globally.\n- If there is no repo and no clear project root, use the global config only after confirming the user wants a personal Dock.\n\nSchema:\n{\n \"controls\": [\n {\n \"id\": \"short-stable-id\",\n \"title\": \"Human label\",\n \"command\": \"safe command to run\",\n \"cwd\": \"optional/path\",\n \"height\": 220,\n \"env\": { \"NAME\": \"value\" }\n }\n ]\n}\n\nRules:\n- Keep ids stable, lowercase, and unique.\n- Use `cwd` for subdirectories; relative paths resolve from the config base.\n- Use `height` only when a control needs a fixed amount of vertical space.\n- Use `env` only for non-secret values needed by one control.\n- Do not put secrets, tokens, or machine-specific private paths in a shared project config.\n- Prefer commands that are safe to start repeatedly and make sense in a terminal.\n- Do not invent unavailable scripts. Read package files, Makefiles, Procfiles, README docs, config files, and existing tooling first.\n- Keep shared project Docks portable for teammates. Put personal or machine-specific controls in the global Dock.\n\nDeliverable:\n- Create or update the appropriate dock.json.\n- Preserve existing useful controls unless the user asked to replace them.\n- Validate that the JSON parses.\n- Summarize what each control does and any commands the user should review before trusting the Dock config." + }, + "sessionRestore": { + "title": "Sitzungswiederherstellung", + "metaTitle": "Sitzungswiederherstellung", + "metaDescription": "Wie cmux nach einem Neustart Fenster, Workspaces, Paneele, Arbeitsverzeichnisse, Scrollback, Browserverlauf und unterstützte Agent-Sitzungen wiederherstellt.", + "intro": "cmux speichert die Struktur Ihrer Arbeit, damit die App nach einem Neustart dieselben Fenster, Workspaces, Paneele, den Terminalkontext und den Browserzustand zurückbringen kann.", + "restoredTitle": "Was cmux wiederherstellt", + "restoredDesc": "Nach dem Neustart stellt cmux app-eigenes Layout und Metadaten wieder her:", + "restoredItemLayout": "Fenster-, Workspace- und Paneel-Layout", + "restoredItemCwd": "Arbeitsverzeichnisse", + "restoredItemScrollback": "Terminal-Scrollback, best effort", + "restoredItemBrowser": "Browser-URL und Navigationsverlauf", + "liveProcessCallout": "cmux checkpointet keinen beliebigen Live-Prozesszustand. tmux, vim, Shells und nicht unterstützte Terminal-Apps öffnen sich als normale Terminals, sofern sie keine eigene cmux-Wiederaufnahmeintegration haben.", + "agentResumeTitle": "Agent-Sitzungen wiederaufnehmen", + "agentResumeDesc": "Unterstützte KI-Coding-Agenten können wiederaufgenommen werden, wenn cmux die native Sitzungs-ID des Agents erfasst hat. Installieren Sie Hooks nach der Agent-CLI, damit die Binärdatei auf PATH liegt:", + "setupBehavior": "cmux hooks setup installiert jede unterstützte Integration, die es findet, und gibt eine Zusammenfassung übersprungener Agents aus. Verwenden Sie einen Agent-Namen, wenn Sie nur eine Integration einrichten wollen.", + "surfaceBindingsTitle": "Benutzerdefinierte Surface-Wiederaufnahmebefehle", + "surfaceBindingsDesc": "Fortgeschrittene Nutzer und Integrationen können jede Terminal-Surface an einen Neustartbefehl binden. cmux speichert über öffentliche CLI oder Socket erstellte Bindungen zur Prüfung und manuellen Wiederaufnahme.", + "surfaceBindingsNote": "Genehmigte Präfixe können unter Settings > Terminal > Resume Commands geprüft oder bearbeitet werden. cmux startet nur Wiederaufnahme-Bindungen automatisch, die als vertrauenswürdig markiert sind, zum Beispiel aus laufenden Prozessen erkannte tmux-Bindungen oder vom Benutzer genehmigte Präfixe. cmux checkpointet weiterhin keinen beliebigen Prozessspeicher. Sensible Umgebungsvariablen wie Tokens, Passwörter, Secrets und API-Keys werden verworfen, bevor eine Wiederaufnahme-Bindung gespeichert wird. Genehmigungen sind außerdem an Arbeitsverzeichnis und exakte Umgebungswerte gebunden, wenn sie vorhanden sind.", + "supportedTitle": "Unterstützte Agents", + "agentHeader": "Agent", + "binaryHeader": "Binärdatei", + "resumeHeader": "Wiederaufnahmebefehl", + "feedHeader": "Feed-Brücke", + "feedPermissionRequest": "PermissionRequest", + "feedPreToolUsePermissionRequest": "PreToolUse, PermissionRequest", + "feedBeforeShellExecution": "beforeShellExecution", + "feedPreToolUse": "PreToolUse", + "pluginEventBus": "Plugin-Event-Bus", + "none": "keine", + "supportedNote": "Claude Code wird vom cmux Claude-Wrapper verarbeitet, wenn die Claude-Integration in Settings aktiviert ist. Rovo Dev akzeptiert auch rovo als Setup-Alias.", + "manualTitle": "Manuelle Wiederherstellung", + "manualDesc": "cmux stellt beim normalen Start den zuletzt gespeicherten Snapshot wieder her. Sie können den vorherigen Snapshot auch manuell erneut anwenden:", + "manualItemMenu": "File > Reopen Previous Session", + "disableTitle": "Automatische Agent-Wiederaufnahme deaktivieren", + "disableDesc": "Um Paneele wiederherzustellen, ohne gespeicherte Agent-Wiederaufnahmebefehle zu starten, deaktivieren Sie Settings > Terminal > Resume Agent Sessions on Reopen oder setzen Sie:", + "disableNote": "Dies deaktiviert nur Agent-Wiederaufnahmebefehle. cmux stellt Layout, Arbeitsverzeichnisse, Scrollback und Browserverlauf weiterhin wieder her.", + "underHoodTitle": "So funktioniert es", + "underHoodItemSnapshot": "cmux schreibt einen versionierten JSON-Snapshot nach ~/Library/Application Support/cmux/session-<bundle-id>.json und zusätzlich einen Previous-Session-Cache für manuelles Wiederöffnen.", + "underHoodItemScrollback": "Terminal-Scrollback wird als begrenzter Text gespeichert und bei der Wiederherstellung über eine temporäre Datei wiedergegeben. Das ist best effort, weil Terminal-Apps ihren Bildschirm neu zeichnen oder löschen können.", + "underHoodItemHooks": "Agent-Hooks schreiben ~/.cmuxterm/<agent>-hook-sessions.json mit Agent-Sitzungs-ID, cmux-Workspace-ID, Surface-ID, cwd, Prozess-ID falls verfügbar und einem bereinigten Startbefehl.", + "underHoodItemResume": "Bei der Wiederherstellung baut cmux zuerst Fenster und Paneele neu auf. Wenn automatische Agent-Wiederaufnahme aktiviert ist, startet es einen einmaligen Shell-Befehl, der den nativen Wiederaufnahmebefehl des Agents mit der gespeicherten Sitzungs-ID ausführt.", + "underHoodMore": "Die reguläre <configLink>Konfigurationsdokumentation</configLink> behandelt cmux.json. Sitzungswiederherstellung hält App-Layout getrennt von Ghostty-Terminal-Rendering-Einstellungen." } }, "legal": { diff --git a/web/messages/en.json b/web/messages/en.json index bebf8b6b5afe..e1516354f285 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -310,6 +310,9 @@ "agentResumeTitle": "Agent session resume", "agentResumeDesc": "Supported AI coding agents can resume when cmux has captured the agent's native session ID. Install hooks after installing the agent CLI so the agent binary is on PATH:", "setupBehavior": "Running cmux hooks setup installs every supported integration it can find and prints a summary for skipped agents. Use an agent name when you only want one integration.", + "surfaceBindingsTitle": "Custom surface resume commands", + "surfaceBindingsDesc": "Advanced users and integrations can bind any terminal surface to a restart command. cmux stores public CLI and socket-created bindings for inspection and manual restore unless you approve a signed command prefix.", + "surfaceBindingsNote": "Review or edit approved prefixes in Settings > Terminal > Resume Commands. cmux only auto-runs resume bindings it marks trusted, such as live process-detected tmux bindings or user-approved prefixes. cmux still does not checkpoint arbitrary process memory. Sensitive environment keys such as tokens, passwords, secrets, and API keys are dropped before a resume binding is stored. Approved prefixes are also bound to the working directory and exact environment values when present.", "supportedTitle": "Supported agents", "agentHeader": "Agent", "binaryHeader": "Binary", diff --git a/web/messages/es.json b/web/messages/es.json index 0eb6f7561744..c3910d711b0c 100644 --- a/web/messages/es.json +++ b/web/messages/es.json @@ -701,6 +701,7 @@ "gettingStarted": "Primeros pasos", "concepts": "Conceptos", "configuration": "Configuración", + "sessionRestore": "Restauración de sesión", "customCommands": "Comandos personalizados", "keyboardShortcuts": "Atajos de teclado", "apiReference": "Referencia de API", @@ -746,6 +747,49 @@ "agentPromptIntro": "Usa este prompt cuando quieras que un agente de código cree controles Dock. Le indica al agente que ejecute `cmux docs dock`, inspeccione el proyecto y pregunte antes de adivinar.", "agentPromptCodeTitle": "Prompt del agente", "agentPrompt": "Set up cmux Dock controls for the current context.\n\nFirst, learn the feature before editing:\n1. Run `cmux docs dock` if the cmux CLI is available. If it is not, read https://cmux.com/docs/dock.\n2. Inspect the repository or current directory to understand the project type, scripts, package manager, dev servers, logs, task runners, test commands, and any existing TUI tools.\n3. If the desired Dock is ambiguous, ask the user what they want monitored or controlled before writing files.\n\nDock is cmux's right-sidebar terminal control area. A Dock config is JSON with a top-level `controls` array. Each control runs a command in its own Ghostty-backed terminal section using the user's login shell. Controls are useful for project dashboards, git/status views, dev server or build status, test watchers, log tails, queues, local services, or a custom TUI such as `cmux feed tui --opentui` when that feed is useful.\n\nChoose where to write the config:\n- In a repository or project directory, create or edit `.cmux/dock.json` so teammates can share it.\n- For a personal default outside a repo, create or edit `~/.config/cmux/dock.json`.\n- If both exist, project `.cmux/dock.json` is more specific for that project. Nested project configs apply to that directory tree; use the nearest relevant project config instead of writing unrelated controls globally.\n- If there is no repo and no clear project root, use the global config only after confirming the user wants a personal Dock.\n\nSchema:\n{\n \"controls\": [\n {\n \"id\": \"short-stable-id\",\n \"title\": \"Human label\",\n \"command\": \"safe command to run\",\n \"cwd\": \"optional/path\",\n \"height\": 220,\n \"env\": { \"NAME\": \"value\" }\n }\n ]\n}\n\nRules:\n- Keep ids stable, lowercase, and unique.\n- Use `cwd` for subdirectories; relative paths resolve from the config base.\n- Use `height` only when a control needs a fixed amount of vertical space.\n- Use `env` only for non-secret values needed by one control.\n- Do not put secrets, tokens, or machine-specific private paths in a shared project config.\n- Prefer commands that are safe to start repeatedly and make sense in a terminal.\n- Do not invent unavailable scripts. Read package files, Makefiles, Procfiles, README docs, config files, and existing tooling first.\n- Keep shared project Docks portable for teammates. Put personal or machine-specific controls in the global Dock.\n\nDeliverable:\n- Create or update the appropriate dock.json.\n- Preserve existing useful controls unless the user asked to replace them.\n- Validate that the JSON parses.\n- Summarize what each control does and any commands the user should review before trusting the Dock config." + }, + "sessionRestore": { + "title": "Restauración de sesión", + "metaTitle": "Restauración de sesión", + "metaDescription": "Cómo cmux restaura ventanas, workspaces, paneles, directorios de trabajo, scrollback, historial del navegador y sesiones de agentes compatibles después de reiniciar.", + "intro": "cmux guarda la forma de tu trabajo para que al relanzar la app vuelvan las mismas ventanas, workspaces, paneles, contexto de terminal y estado del navegador.", + "restoredTitle": "Qué restaura cmux", + "restoredDesc": "Después de relanzar, cmux restaura layout y metadatos propios de la app:", + "restoredItemLayout": "Layout de ventanas, workspaces y paneles", + "restoredItemCwd": "Directorios de trabajo", + "restoredItemScrollback": "Scrollback de terminal, best effort", + "restoredItemBrowser": "URL del navegador e historial de navegación", + "liveProcessCallout": "cmux no checkpointa estado arbitrario de procesos vivos. tmux, vim, shells y apps de terminal no compatibles se reabren como terminales normales salvo que tengan su propia integración de resume de cmux.", + "agentResumeTitle": "Reanudación de sesiones de agentes", + "agentResumeDesc": "Los agentes de coding con IA compatibles pueden reanudarse cuando cmux ha capturado el ID de sesión nativo del agente. Instala hooks después de instalar el CLI del agente para que su binario esté en PATH:", + "setupBehavior": "cmux hooks setup instala todas las integraciones compatibles que encuentra e imprime un resumen de los agentes omitidos. Usa el nombre de un agente cuando solo quieras una integración.", + "surfaceBindingsTitle": "Comandos personalizados de resume de surface", + "surfaceBindingsDesc": "Los usuarios avanzados e integraciones pueden vincular cualquier surface de terminal a un comando de reinicio. cmux guarda las asociaciones creadas por el CLI público o el socket para inspección y reanudación manual.", + "surfaceBindingsNote": "Los prefijos aprobados se pueden revisar o editar en Settings > Terminal > Resume Commands. cmux solo ejecuta automáticamente las asociaciones de resume que marca como confiables, como las de tmux detectadas desde procesos activos o prefijos aprobados por el usuario. cmux sigue sin checkpointar memoria arbitraria de procesos. Las claves de entorno sensibles, como tokens, contraseñas, secretos y claves de API, se descartan antes de guardar una asociación de reanudación. Las aprobaciones también se vinculan al directorio de trabajo y a los valores exactos del entorno cuando existen.", + "supportedTitle": "Agentes compatibles", + "agentHeader": "Agente", + "binaryHeader": "Binario", + "resumeHeader": "Comando de resume", + "feedHeader": "Puente de feed", + "feedPermissionRequest": "PermissionRequest", + "feedPreToolUsePermissionRequest": "PreToolUse, PermissionRequest", + "feedBeforeShellExecution": "beforeShellExecution", + "feedPreToolUse": "PreToolUse", + "pluginEventBus": "bus de eventos de plugin", + "none": "ninguno", + "supportedNote": "Claude Code lo maneja el wrapper de Claude de cmux cuando la integración de Claude está activada en Settings. Rovo Dev también acepta rovo como alias de setup.", + "manualTitle": "Restauración manual", + "manualDesc": "cmux restaura el último snapshot guardado en un arranque normal. También puedes reaplicar manualmente el snapshot anterior:", + "manualItemMenu": "File > Reopen Previous Session", + "disableTitle": "Desactivar la reanudación automática de agentes", + "disableDesc": "Para restaurar paneles sin lanzar comandos de resume de agentes guardados, desactiva Settings > Terminal > Resume Agent Sessions on Reopen o configura:", + "disableNote": "Esto solo desactiva los comandos de resume de agentes. cmux sigue restaurando layout, directorios de trabajo, scrollback e historial del navegador.", + "underHoodTitle": "Cómo funciona", + "underHoodItemSnapshot": "cmux escribe un snapshot JSON versionado en ~/Library/Application Support/cmux/session-<bundle-id>.json, además de una caché de sesión anterior para reapertura manual.", + "underHoodItemScrollback": "El scrollback de terminal se guarda como texto acotado y se reproduce mediante un archivo temporal al restaurar. Es best effort porque las apps de terminal pueden redibujar o limpiar su pantalla.", + "underHoodItemHooks": "Los agent hooks escriben ~/.cmuxterm/<agent>-hook-sessions.json con el ID de sesión del agente, ID de workspace de cmux, ID de surface, cwd, ID de proceso cuando está disponible y un comando de lanzamiento sanitizado.", + "underHoodItemResume": "Al restaurar, cmux reconstruye primero ventanas y paneles. Si la reanudación automática de agentes está activada, lanza un comando shell de una sola ejecución que ejecuta el comando nativo de resume del agente con el ID de sesión guardado.", + "underHoodMore": "La <configLink>documentación de configuración</configLink> normal cubre cmux.json. La restauración de sesión mantiene el layout de la app separado de la configuración de renderizado de terminal de Ghostty." } }, "legal": { diff --git a/web/messages/fr.json b/web/messages/fr.json index 3aea36b212c0..6ec2d9e168d7 100644 --- a/web/messages/fr.json +++ b/web/messages/fr.json @@ -701,6 +701,7 @@ "gettingStarted": "Premiers pas", "concepts": "Concepts", "configuration": "Configuration", + "sessionRestore": "Restauration de session", "customCommands": "Commandes personnalisées", "keyboardShortcuts": "Raccourcis clavier", "apiReference": "Référence API", @@ -746,6 +747,49 @@ "agentPromptIntro": "Utilisez ce prompt quand vous voulez qu'un agent de code crée des contrôles Dock. Il demande à l'agent d'exécuter `cmux docs dock`, d'inspecter le projet et de poser une question avant de deviner.", "agentPromptCodeTitle": "Prompt d'agent", "agentPrompt": "Set up cmux Dock controls for the current context.\n\nFirst, learn the feature before editing:\n1. Run `cmux docs dock` if the cmux CLI is available. If it is not, read https://cmux.com/docs/dock.\n2. Inspect the repository or current directory to understand the project type, scripts, package manager, dev servers, logs, task runners, test commands, and any existing TUI tools.\n3. If the desired Dock is ambiguous, ask the user what they want monitored or controlled before writing files.\n\nDock is cmux's right-sidebar terminal control area. A Dock config is JSON with a top-level `controls` array. Each control runs a command in its own Ghostty-backed terminal section using the user's login shell. Controls are useful for project dashboards, git/status views, dev server or build status, test watchers, log tails, queues, local services, or a custom TUI such as `cmux feed tui --opentui` when that feed is useful.\n\nChoose where to write the config:\n- In a repository or project directory, create or edit `.cmux/dock.json` so teammates can share it.\n- For a personal default outside a repo, create or edit `~/.config/cmux/dock.json`.\n- If both exist, project `.cmux/dock.json` is more specific for that project. Nested project configs apply to that directory tree; use the nearest relevant project config instead of writing unrelated controls globally.\n- If there is no repo and no clear project root, use the global config only after confirming the user wants a personal Dock.\n\nSchema:\n{\n \"controls\": [\n {\n \"id\": \"short-stable-id\",\n \"title\": \"Human label\",\n \"command\": \"safe command to run\",\n \"cwd\": \"optional/path\",\n \"height\": 220,\n \"env\": { \"NAME\": \"value\" }\n }\n ]\n}\n\nRules:\n- Keep ids stable, lowercase, and unique.\n- Use `cwd` for subdirectories; relative paths resolve from the config base.\n- Use `height` only when a control needs a fixed amount of vertical space.\n- Use `env` only for non-secret values needed by one control.\n- Do not put secrets, tokens, or machine-specific private paths in a shared project config.\n- Prefer commands that are safe to start repeatedly and make sense in a terminal.\n- Do not invent unavailable scripts. Read package files, Makefiles, Procfiles, README docs, config files, and existing tooling first.\n- Keep shared project Docks portable for teammates. Put personal or machine-specific controls in the global Dock.\n\nDeliverable:\n- Create or update the appropriate dock.json.\n- Preserve existing useful controls unless the user asked to replace them.\n- Validate that the JSON parses.\n- Summarize what each control does and any commands the user should review before trusting the Dock config." + }, + "sessionRestore": { + "title": "Restauration de session", + "metaTitle": "Restauration de session", + "metaDescription": "Comment cmux restaure fenêtres, workspaces, panneaux, répertoires de travail, scrollback, historique du navigateur et sessions d'agents prises en charge après relance.", + "intro": "cmux enregistre la forme de votre travail pour que la relance de l'app retrouve les mêmes fenêtres, workspaces, panneaux, contexte de terminal et état du navigateur.", + "restoredTitle": "Ce que cmux restaure", + "restoredDesc": "Après relance, cmux restaure la disposition et les métadonnées appartenant à l'app :", + "restoredItemLayout": "Disposition des fenêtres, workspaces et panneaux", + "restoredItemCwd": "Répertoires de travail", + "restoredItemScrollback": "Scrollback du terminal, best effort", + "restoredItemBrowser": "URL du navigateur et historique de navigation", + "liveProcessCallout": "cmux ne checkpoint pas l'état arbitraire des processus actifs. tmux, vim, les shells et les apps de terminal non prises en charge rouvrent comme des terminaux normaux, sauf s'ils ont leur propre intégration de reprise cmux.", + "agentResumeTitle": "Reprise des sessions d'agents", + "agentResumeDesc": "Les agents de coding IA pris en charge peuvent reprendre lorsque cmux a capturé l'ID de session natif de l'agent. Installez les hooks après le CLI de l'agent pour que son binaire soit sur PATH :", + "setupBehavior": "cmux hooks setup installe toutes les intégrations prises en charge qu'il trouve et affiche un résumé des agents ignorés. Utilisez le nom d'un agent si vous ne voulez qu'une intégration.", + "surfaceBindingsTitle": "Commandes personnalisées de reprise de surface", + "surfaceBindingsDesc": "Les utilisateurs avancés et les intégrations peuvent lier toute surface de terminal à une commande de redémarrage. cmux conserve les associations créées par le CLI public ou le socket pour inspection et reprise manuelle.", + "surfaceBindingsNote": "Les préfixes approuvés peuvent être vérifiés ou modifiés dans Settings > Terminal > Resume Commands. cmux exécute automatiquement seulement les associations de reprise qu'il marque comme fiables, comme les associations tmux détectées depuis des processus actifs ou les préfixes approuvés par l’utilisateur. cmux ne checkpoint toujours pas la mémoire arbitraire des processus. Les clés d’environnement sensibles, comme les jetons, mots de passe, secrets et clés API, sont supprimées avant l’enregistrement d’une association de reprise. Les approbations sont aussi liées au répertoire de travail et aux valeurs exactes de l’environnement quand elles existent.", + "supportedTitle": "Agents pris en charge", + "agentHeader": "Agent", + "binaryHeader": "Binaire", + "resumeHeader": "Commande de reprise", + "feedHeader": "Pont de feed", + "feedPermissionRequest": "PermissionRequest", + "feedPreToolUsePermissionRequest": "PreToolUse, PermissionRequest", + "feedBeforeShellExecution": "beforeShellExecution", + "feedPreToolUse": "PreToolUse", + "pluginEventBus": "bus d'événements plugin", + "none": "aucun", + "supportedNote": "Claude Code est géré par le wrapper Claude de cmux lorsque l'intégration Claude est activée dans Settings. Rovo Dev accepte aussi rovo comme alias de setup.", + "manualTitle": "Restauration manuelle", + "manualDesc": "cmux restaure le dernier snapshot enregistré au lancement normal. Vous pouvez aussi réappliquer manuellement le snapshot précédent :", + "manualItemMenu": "File > Reopen Previous Session", + "disableTitle": "Désactiver la reprise automatique des agents", + "disableDesc": "Pour restaurer les panneaux sans lancer les commandes de reprise d'agents enregistrées, désactivez Settings > Terminal > Resume Agent Sessions on Reopen ou définissez :", + "disableNote": "Cela désactive seulement les commandes de reprise des agents. cmux restaure toujours la disposition, les répertoires de travail, le scrollback et l'historique du navigateur.", + "underHoodTitle": "Fonctionnement", + "underHoodItemSnapshot": "cmux écrit un snapshot JSON versionné dans ~/Library/Application Support/cmux/session-<bundle-id>.json, ainsi qu'un cache de session précédente pour la réouverture manuelle.", + "underHoodItemScrollback": "Le scrollback du terminal est stocké comme texte borné et rejoué via un fichier temporaire à la restauration. C'est best effort, car les apps de terminal peuvent redessiner ou effacer leur écran.", + "underHoodItemHooks": "Les hooks d'agents écrivent ~/.cmuxterm/<agent>-hook-sessions.json avec l'ID de session de l'agent, l'ID de workspace cmux, l'ID de surface, le cwd, l'ID de processus si disponible et une commande de lancement assainie.", + "underHoodItemResume": "À la restauration, cmux reconstruit d'abord les fenêtres et les panneaux. Si la reprise automatique des agents est activée, il lance une commande shell ponctuelle qui exécute la commande de reprise native de l'agent avec l'ID de session enregistré.", + "underHoodMore": "La <configLink>documentation de configuration</configLink> habituelle couvre cmux.json. La restauration de session garde la disposition de l'app séparée des réglages de rendu du terminal Ghostty." } }, "legal": { diff --git a/web/messages/it.json b/web/messages/it.json index e8230417f71c..910757b25d17 100644 --- a/web/messages/it.json +++ b/web/messages/it.json @@ -701,6 +701,7 @@ "gettingStarted": "Per iniziare", "concepts": "Concetti", "configuration": "Configurazione", + "sessionRestore": "Ripristino sessione", "customCommands": "Comandi personalizzati", "keyboardShortcuts": "Scorciatoie da tastiera", "apiReference": "Riferimento API", @@ -746,6 +747,49 @@ "agentPromptIntro": "Usa questo prompt quando vuoi che un agente di coding crei controlli Dock. Indica all’agente di eseguire `cmux docs dock`, ispezionare il progetto e chiedere prima di tirare a indovinare.", "agentPromptCodeTitle": "Prompt per l'agente", "agentPrompt": "Set up cmux Dock controls for the current context.\n\nFirst, learn the feature before editing:\n1. Run `cmux docs dock` if the cmux CLI is available. If it is not, read https://cmux.com/docs/dock.\n2. Inspect the repository or current directory to understand the project type, scripts, package manager, dev servers, logs, task runners, test commands, and any existing TUI tools.\n3. If the desired Dock is ambiguous, ask the user what they want monitored or controlled before writing files.\n\nDock is cmux's right-sidebar terminal control area. A Dock config is JSON with a top-level `controls` array. Each control runs a command in its own Ghostty-backed terminal section using the user's login shell. Controls are useful for project dashboards, git/status views, dev server or build status, test watchers, log tails, queues, local services, or a custom TUI such as `cmux feed tui --opentui` when that feed is useful.\n\nChoose where to write the config:\n- In a repository or project directory, create or edit `.cmux/dock.json` so teammates can share it.\n- For a personal default outside a repo, create or edit `~/.config/cmux/dock.json`.\n- If both exist, project `.cmux/dock.json` is more specific for that project. Nested project configs apply to that directory tree; use the nearest relevant project config instead of writing unrelated controls globally.\n- If there is no repo and no clear project root, use the global config only after confirming the user wants a personal Dock.\n\nSchema:\n{\n \"controls\": [\n {\n \"id\": \"short-stable-id\",\n \"title\": \"Human label\",\n \"command\": \"safe command to run\",\n \"cwd\": \"optional/path\",\n \"height\": 220,\n \"env\": { \"NAME\": \"value\" }\n }\n ]\n}\n\nRules:\n- Keep ids stable, lowercase, and unique.\n- Use `cwd` for subdirectories; relative paths resolve from the config base.\n- Use `height` only when a control needs a fixed amount of vertical space.\n- Use `env` only for non-secret values needed by one control.\n- Do not put secrets, tokens, or machine-specific private paths in a shared project config.\n- Prefer commands that are safe to start repeatedly and make sense in a terminal.\n- Do not invent unavailable scripts. Read package files, Makefiles, Procfiles, README docs, config files, and existing tooling first.\n- Keep shared project Docks portable for teammates. Put personal or machine-specific controls in the global Dock.\n\nDeliverable:\n- Create or update the appropriate dock.json.\n- Preserve existing useful controls unless the user asked to replace them.\n- Validate that the JSON parses.\n- Summarize what each control does and any commands the user should review before trusting the Dock config." + }, + "sessionRestore": { + "title": "Ripristino sessione", + "metaTitle": "Ripristino sessione", + "metaDescription": "Come cmux ripristina finestre, workspace, pannelli, directory di lavoro, scrollback, cronologia browser e sessioni agent supportate dopo il riavvio.", + "intro": "cmux salva la forma del tuo lavoro così, al riavvio dell'app, può riportare le stesse finestre, workspace, pannelli, contesto del terminale e stato del browser.", + "restoredTitle": "Cosa ripristina cmux", + "restoredDesc": "Dopo il riavvio, cmux ripristina layout e metadati gestiti dall'app:", + "restoredItemLayout": "Layout di finestre, workspace e pannelli", + "restoredItemCwd": "Directory di lavoro", + "restoredItemScrollback": "Scrollback del terminale, best effort", + "restoredItemBrowser": "URL del browser e cronologia di navigazione", + "liveProcessCallout": "cmux non checkpointa lo stato arbitrario dei processi live. tmux, vim, shell e app terminale non supportate si riaprono come terminali normali, a meno che abbiano una propria integrazione di resume cmux.", + "agentResumeTitle": "Resume delle sessioni agent", + "agentResumeDesc": "Gli agent di coding IA supportati possono riprendere quando cmux ha catturato l'ID sessione nativo dell'agent. Installa gli hook dopo il CLI dell'agent così il binario è su PATH:", + "setupBehavior": "cmux hooks setup installa ogni integrazione supportata che trova e stampa un riepilogo degli agent saltati. Usa il nome di un agent quando vuoi una sola integrazione.", + "surfaceBindingsTitle": "Comandi surface resume personalizzati", + "surfaceBindingsDesc": "Utenti avanzati e integrazioni possono collegare qualsiasi surface di terminale a un comando di riavvio. cmux salva le associazioni create da CLI pubblico o socket per ispezione e resume manuale.", + "surfaceBindingsNote": "I prefissi approvati possono essere controllati o modificati in Settings > Terminal > Resume Commands. cmux esegue automaticamente solo le associazioni di resume che marca come attendibili, per esempio quelle tmux rilevate dai processi attivi o i prefissi approvati dall’utente. cmux continua a non checkpointare memoria arbitraria dei processi. Le chiavi di ambiente sensibili, come token, password, segreti e chiavi API, vengono scartate prima di salvare un’associazione di resume. Le approvazioni sono associate anche alla directory di lavoro e ai valori esatti dell’ambiente quando presenti.", + "supportedTitle": "Agent supportati", + "agentHeader": "Agent", + "binaryHeader": "Binario", + "resumeHeader": "Comando resume", + "feedHeader": "Feed bridge", + "feedPermissionRequest": "PermissionRequest", + "feedPreToolUsePermissionRequest": "PreToolUse, PermissionRequest", + "feedBeforeShellExecution": "beforeShellExecution", + "feedPreToolUse": "PreToolUse", + "pluginEventBus": "bus eventi plugin", + "none": "nessuno", + "supportedNote": "Claude Code è gestito dal wrapper Claude di cmux quando l'integrazione Claude è abilitata in Settings. Rovo Dev accetta anche rovo come alias di setup.", + "manualTitle": "Ripristino manuale", + "manualDesc": "cmux ripristina l'ultimo snapshot salvato all'avvio normale. Puoi anche riapplicare manualmente lo snapshot precedente:", + "manualItemMenu": "File > Reopen Previous Session", + "disableTitle": "Disattiva il resume automatico degli agent", + "disableDesc": "Per ripristinare pannelli senza lanciare i comandi di resume agent salvati, disattiva Settings > Terminal > Resume Agent Sessions on Reopen oppure imposta:", + "disableNote": "Questo disattiva solo i comandi di resume degli agent. cmux ripristina comunque layout, directory di lavoro, scrollback e cronologia browser.", + "underHoodTitle": "Come funziona", + "underHoodItemSnapshot": "cmux scrive uno snapshot JSON versionato in ~/Library/Application Support/cmux/session-<bundle-id>.json, più una cache della sessione precedente per la riapertura manuale.", + "underHoodItemScrollback": "Lo scrollback del terminale viene salvato come testo limitato e riprodotto tramite un file temporaneo al ripristino. È best effort perché le app terminale possono ridisegnare o pulire lo schermo.", + "underHoodItemHooks": "Gli agent hook scrivono ~/.cmuxterm/<agent>-hook-sessions.json con ID sessione agent, ID workspace cmux, ID surface, cwd, ID processo quando disponibile e comando di lancio sanificato.", + "underHoodItemResume": "Al ripristino, cmux ricostruisce prima finestre e pannelli. Se il resume automatico degli agent è abilitato, lancia un comando shell one-shot che esegue il comando resume nativo dell'agent con l'ID sessione salvato.", + "underHoodMore": "La normale <configLink>documentazione di configurazione</configLink> copre cmux.json. Il ripristino sessione mantiene il layout dell'app separato dalle impostazioni di rendering del terminale Ghostty." } }, "legal": { diff --git a/web/messages/ja.json b/web/messages/ja.json index 5ee2b8a4a5f9..b28010ba57af 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -228,6 +228,49 @@ "reqItem2": "Apple SiliconまたはIntel Mac", "metaTitle": "はじめに" }, + "sessionRestore": { + "title": "セッション復元", + "metaTitle": "セッション復元", + "metaDescription": "再起動後にcmuxがウィンドウ、ワークスペース、ペイン、作業ディレクトリ、スクロールバック、ブラウザ履歴、対応エージェントセッションを復元する仕組み。", + "intro": "cmuxは作業状態の形を保存し、アプリを再起動したときに同じウィンドウ、ワークスペース、ペイン、ターミナルコンテキスト、ブラウザ状態へ戻せるようにします。", + "restoredTitle": "cmuxが復元するもの", + "restoredDesc": "再起動後、cmuxはアプリが管理するレイアウトとメタデータを復元します:", + "restoredItemLayout": "ウィンドウ、ワークスペース、ペインのレイアウト", + "restoredItemCwd": "作業ディレクトリ", + "restoredItemScrollback": "ターミナルのスクロールバック(ベストエフォート)", + "restoredItemBrowser": "ブラウザURLとナビゲーション履歴", + "liveProcessCallout": "cmuxは任意のライブプロセス状態をチェックポイントしません。tmux、vim、シェル、未対応のターミナルアプリは、独自のcmux復元連携がない限り通常のターミナルとして再度開きます。", + "agentResumeTitle": "エージェントセッションの復元", + "agentResumeDesc": "対応するAIコーディングエージェントは、cmuxがエージェント固有のセッションIDを取得している場合に復元できます。エージェントCLIをインストールし、そのバイナリがPATH上にある状態でフックをインストールしてください:", + "setupBehavior": "cmux hooks setupは見つかった対応連携をすべてインストールし、スキップしたエージェントの概要を表示します。1つの連携だけを設定したい場合はエージェント名を指定します。", + "surfaceBindingsTitle": "カスタムサーフェス復元コマンド", + "surfaceBindingsDesc": "上級ユーザーや連携機能は、任意のターミナルサーフェスに再起動用コマンドを紐づけられます。cmuxは公開CLIやsocketで作成された紐づけを、署名済みコマンドプレフィックスを承認するまでは確認と手動復元用に保存します。", + "surfaceBindingsNote": "承認済みプレフィックスは 設定 > ターミナル > 復元コマンド で確認または編集できます。cmuxが自動実行するのは、実行中プロセスから検出したtmux紐づけやユーザー承認済みプレフィックスなど、信頼済みとして扱う復元紐づけだけです。cmuxは任意のプロセスメモリをチェックポイントするわけではありません。 トークン、パスワード、シークレット、APIキーなどの機密環境変数キーは、復元紐づけを保存する前に破棄されます。 承認済みプレフィックスは、存在する場合は作業ディレクトリと正確な環境変数値にも紐づきます。", + "supportedTitle": "対応エージェント", + "agentHeader": "エージェント", + "binaryHeader": "バイナリ", + "resumeHeader": "復元コマンド", + "feedHeader": "フィード連携", + "feedPermissionRequest": "PermissionRequest", + "feedPreToolUsePermissionRequest": "PreToolUse、PermissionRequest", + "feedBeforeShellExecution": "beforeShellExecution", + "feedPreToolUse": "PreToolUse", + "pluginEventBus": "プラグインイベントバス", + "none": "なし", + "supportedNote": "Claude Codeは、設定でClaude連携が有効な場合にcmux Claudeラッパーで処理されます。Rovo Devはセットアップエイリアスとしてrovoも受け付けます。", + "manualTitle": "手動復元", + "manualDesc": "cmuxは通常起動時に最後に保存したスナップショットを復元します。以前のスナップショットを手動で再適用することもできます:", + "manualItemMenu": "ファイル > 前回のセッションを再度開く", + "disableTitle": "エージェント自動復元を無効にする", + "disableDesc": "保存されたエージェント復元コマンドを起動せずにペインだけを復元するには、設定 > ターミナル > 再オープン時にエージェントセッションを復元 をオフにするか、次を設定します:", + "disableNote": "これはエージェント復元コマンドだけを無効にします。cmuxはレイアウト、作業ディレクトリ、スクロールバック、ブラウザ履歴を引き続き復元します。", + "underHoodTitle": "仕組み", + "underHoodItemSnapshot": "cmuxはバージョン付きJSONスナップショットを ~/Library/Application Support/cmux/session-<bundle-id>.json に書き込み、手動再オープン用の前回セッションキャッシュも保存します。", + "underHoodItemScrollback": "ターミナルのスクロールバックは上限付きテキストとして保存され、復元時に一時ファイル経由で再生されます。ターミナルアプリが画面を再描画またはクリアできるため、これはベストエフォートです。", + "underHoodItemHooks": "エージェントフックは ~/.cmuxterm/<agent>-hook-sessions.json に、エージェントセッションID、cmuxワークスペースID、サーフェスID、cwd、利用可能な場合はプロセスID、サニタイズ済み起動コマンドを書き込みます。", + "underHoodItemResume": "復元時、cmuxはまずウィンドウとペインを再構築します。エージェント自動復元が有効な場合、保存されたセッションIDでエージェント固有の復元コマンドを実行する一度限りのシェルコマンドを起動します。", + "underHoodMore": "通常の<configLink>設定ドキュメント</configLink>ではcmux.jsonを扱います。セッション復元は、アプリのレイアウトをGhosttyのターミナル描画設定とは別に保存します。" + }, "concepts": { "title": "コンセプト", "metaDescription": "cmuxのターミナル構成:ウィンドウ、ワークスペース、ペイン、サーフェス。サイドバー、分割、socket APIの背後にある階層構造。", @@ -937,6 +980,7 @@ "gettingStarted": "はじめに", "concepts": "コンセプト", "configuration": "設定", + "sessionRestore": "セッション復元", "customCommands": "カスタムコマンド", "keyboardShortcuts": "キーボードショートカット", "apiReference": "APIリファレンス", diff --git a/web/messages/km.json b/web/messages/km.json index 5f3d7112b673..5fc55a33cc48 100644 --- a/web/messages/km.json +++ b/web/messages/km.json @@ -701,6 +701,7 @@ "gettingStarted": "ចាប់ផ្ដើម", "concepts": "គោលគំនិត", "configuration": "កំណត់រចនាសម្ព័ន្ធ", + "sessionRestore": "ស្ដារវគ្គ", "customCommands": "ពាក្យបញ្ជាផ្ទាល់ខ្លួន", "keyboardShortcuts": "ផ្លូវកាត់ក្ដារចុច", "apiReference": "ឯកសារយោង API", @@ -746,6 +747,49 @@ "agentPromptIntro": "ប្រើ prompt នេះនៅពេលអ្នកចង់ឱ្យ coding agent បង្កើត Dock controls។ វាប្រាប់ agent ឱ្យរត់ `cmux docs dock`, ពិនិត្យ project និងសួរមុននឹងទស្សន៍ទាយ។", "agentPromptCodeTitle": "Agent prompt", "agentPrompt": "Set up cmux Dock controls for the current context.\n\nFirst, learn the feature before editing:\n1. Run `cmux docs dock` if the cmux CLI is available. If it is not, read https://cmux.com/docs/dock.\n2. Inspect the repository or current directory to understand the project type, scripts, package manager, dev servers, logs, task runners, test commands, and any existing TUI tools.\n3. If the desired Dock is ambiguous, ask the user what they want monitored or controlled before writing files.\n\nDock is cmux's right-sidebar terminal control area. A Dock config is JSON with a top-level `controls` array. Each control runs a command in its own Ghostty-backed terminal section using the user's login shell. Controls are useful for project dashboards, git/status views, dev server or build status, test watchers, log tails, queues, local services, or a custom TUI such as `cmux feed tui --opentui` when that feed is useful.\n\nChoose where to write the config:\n- In a repository or project directory, create or edit `.cmux/dock.json` so teammates can share it.\n- For a personal default outside a repo, create or edit `~/.config/cmux/dock.json`.\n- If both exist, project `.cmux/dock.json` is more specific for that project. Nested project configs apply to that directory tree; use the nearest relevant project config instead of writing unrelated controls globally.\n- If there is no repo and no clear project root, use the global config only after confirming the user wants a personal Dock.\n\nSchema:\n{\n \"controls\": [\n {\n \"id\": \"short-stable-id\",\n \"title\": \"Human label\",\n \"command\": \"safe command to run\",\n \"cwd\": \"optional/path\",\n \"height\": 220,\n \"env\": { \"NAME\": \"value\" }\n }\n ]\n}\n\nRules:\n- Keep ids stable, lowercase, and unique.\n- Use `cwd` for subdirectories; relative paths resolve from the config base.\n- Use `height` only when a control needs a fixed amount of vertical space.\n- Use `env` only for non-secret values needed by one control.\n- Do not put secrets, tokens, or machine-specific private paths in a shared project config.\n- Prefer commands that are safe to start repeatedly and make sense in a terminal.\n- Do not invent unavailable scripts. Read package files, Makefiles, Procfiles, README docs, config files, and existing tooling first.\n- Keep shared project Docks portable for teammates. Put personal or machine-specific controls in the global Dock.\n\nDeliverable:\n- Create or update the appropriate dock.json.\n- Preserve existing useful controls unless the user asked to replace them.\n- Validate that the JSON parses.\n- Summarize what each control does and any commands the user should review before trusting the Dock config." + }, + "sessionRestore": { + "title": "ស្ដារវគ្គ", + "metaTitle": "ស្ដារវគ្គ", + "metaDescription": "របៀបដែល cmux ស្ដារ window, workspace, pane, working directory, scrollback, browser history និង agent session ដែលគាំទ្រ បន្ទាប់ពីបើកឡើងវិញ។", + "intro": "cmux រក្សាទម្រង់ការងាររបស់អ្នក ដើម្បីឲ្យការបើក app ឡើងវិញនាំ window, workspace, pane, terminal context និង browser state ដដែលត្រឡប់មកវិញ។", + "restoredTitle": "អ្វីដែល cmux ស្ដារ", + "restoredDesc": "បន្ទាប់ពីបើកឡើងវិញ cmux ស្ដារ layout និង metadata ដែល app គ្រប់គ្រង៖", + "restoredItemLayout": "layout របស់ window, workspace និង pane", + "restoredItemCwd": "working directory", + "restoredItemScrollback": "terminal scrollback តាម best effort", + "restoredItemBrowser": "Browser URL និង navigation history", + "liveProcessCallout": "cmux មិន checkpoint state របស់ live process ទូទៅទេ។ tmux, vim, shell និង terminal app ដែលមិនគាំទ្រ បើកឡើងវិញជាទម្រង់ terminal ធម្មតា លុះត្រាតែវាមាន cmux resume integration ផ្ទាល់ខ្លួន។", + "agentResumeTitle": "បន្ត agent session", + "agentResumeDesc": "AI coding agent ដែលគាំទ្រ អាច resume បានពេល cmux បានចាប់យក native session ID របស់ agent។ ដំឡើង hooks បន្ទាប់ពីដំឡើង agent CLI ដើម្បីឲ្យ binary ស្ថិតនៅលើ PATH៖", + "setupBehavior": "cmux hooks setup ដំឡើង integration ដែលគាំទ្រទាំងអស់ដែលរកឃើញ ហើយបង្ហាញសង្ខេប agent ដែលបានរំលង។ ប្រើឈ្មោះ agent បើចង់បាន integration តែមួយ។", + "surfaceBindingsTitle": "command surface resume ផ្ទាល់ខ្លួន", + "surfaceBindingsDesc": "អ្នកប្រើប្រាស់កម្រិតខ្ពស់ និង integration អាច bind terminal surface ណាមួយទៅ restart command។ cmux រក្សាទុក binding ដែលបង្កើតដោយ public CLI ឬ socket សម្រាប់ពិនិត្យ និង resume ដោយដៃ។", + "surfaceBindingsNote": "prefix ដែលបាន approve អាចពិនិត្យ ឬកែបាននៅ Settings > Terminal > Resume Commands។ cmux auto-run តែ resume binding ដែលវាសម្គាល់ថា trusted ប៉ុណ្ណោះ ដូចជា tmux binding ដែលរកឃើញពី live process ឬ prefix ដែល user បាន approve។ cmux នៅតែមិន checkpoint process memory ទូទៅទេ។ environment key ដែលមានភាពរសើប ដូចជា token, password, secret និង API key នឹងត្រូវបានដកចេញ មុនពេលរក្សាទុក resume binding។ Approval ក៏ភ្ជាប់ជាមួយ working directory និងតម្លៃ environment ពិតប្រាកដ ប្រសិនបើមាន។", + "supportedTitle": "agent ដែលគាំទ្រ", + "agentHeader": "Agent", + "binaryHeader": "Binary", + "resumeHeader": "Resume command", + "feedHeader": "Feed bridge", + "feedPermissionRequest": "PermissionRequest", + "feedPreToolUsePermissionRequest": "PreToolUse, PermissionRequest", + "feedBeforeShellExecution": "beforeShellExecution", + "feedPreToolUse": "PreToolUse", + "pluginEventBus": "plugin event bus", + "none": "គ្មាន", + "supportedNote": "Claude Code ត្រូវបានគ្រប់គ្រងដោយ cmux Claude wrapper នៅពេលបើក Claude integration ក្នុង Settings។ Rovo Dev ក៏ទទួល rovo ជា setup alias ផងដែរ។", + "manualTitle": "ស្ដារដោយដៃ", + "manualDesc": "cmux ស្ដារ snapshot ចុងក្រោយដែលបានរក្សាទុក ពេលបើកធម្មតា។ អ្នកក៏អាច apply snapshot មុនដោយដៃបាន៖", + "manualItemMenu": "File > Reopen Previous Session", + "disableTitle": "បិទ automatic agent resume", + "disableDesc": "ដើម្បីស្ដារ pane ដោយមិនបើក command resume របស់ agent ដែលបានរក្សាទុក សូមបិទ Settings > Terminal > Resume Agent Sessions on Reopen ឬកំណត់៖", + "disableNote": "វាបិទតែ command resume របស់ agent ប៉ុណ្ណោះ។ cmux នៅតែស្ដារ layout, working directory, scrollback និង browser history។", + "underHoodTitle": "របៀបដំណើរការ", + "underHoodItemSnapshot": "cmux សរសេរ JSON snapshot មាន version ទៅ ~/Library/Application Support/cmux/session-<bundle-id>.json និង previous-session cache សម្រាប់ reopen ដោយដៃ។", + "underHoodItemScrollback": "Terminal scrollback ត្រូវបានរក្សាទុកជាអត្ថបទមានដែនកំណត់ ហើយ replay តាម temporary file ពេលស្ដារ។ វាជា best effort ព្រោះ terminal app អាច redraw ឬ clear អេក្រង់បាន។", + "underHoodItemHooks": "Agent hooks សរសេរ ~/.cmuxterm/<agent>-hook-sessions.json ជាមួយ agent session ID, cmux workspace ID, surface ID, cwd, process ID បើមាន និង sanitized launch command។", + "underHoodItemResume": "ពេលស្ដារ cmux សាងសង់ window និង pane មុន។ បើ automatic agent resume បើក វាបើក one-shot shell command ដែលរត់ native resume command របស់ agent ជាមួយ session ID ដែលបានរក្សាទុក។", + "underHoodMore": "<configLink>ឯកសារ configuration</configLink> ធម្មតាគ្របដណ្តប់ cmux.json។ Session restore រក្សា app layout ផ្សេងពី Ghostty terminal rendering settings។" } }, "legal": { diff --git a/web/messages/ko.json b/web/messages/ko.json index 9e8d94170a12..3975f0914ac6 100644 --- a/web/messages/ko.json +++ b/web/messages/ko.json @@ -701,6 +701,7 @@ "gettingStarted": "시작하기", "concepts": "개념", "configuration": "설정", + "sessionRestore": "세션 복원", "customCommands": "사용자 정의 명령어", "keyboardShortcuts": "키보드 단축키", "apiReference": "API 레퍼런스", @@ -746,6 +747,49 @@ "agentPromptIntro": "코딩 에이전트가 Dock 컨트롤을 만들게 하려면 이 프롬프트를 사용하세요. 에이전트에게 `cmux docs dock`을 실행하고 프로젝트를 살펴본 뒤 추측하기 전에 질문하라고 지시합니다.", "agentPromptCodeTitle": "에이전트 프롬프트", "agentPrompt": "Set up cmux Dock controls for the current context.\n\nFirst, learn the feature before editing:\n1. Run `cmux docs dock` if the cmux CLI is available. If it is not, read https://cmux.com/docs/dock.\n2. Inspect the repository or current directory to understand the project type, scripts, package manager, dev servers, logs, task runners, test commands, and any existing TUI tools.\n3. If the desired Dock is ambiguous, ask the user what they want monitored or controlled before writing files.\n\nDock is cmux's right-sidebar terminal control area. A Dock config is JSON with a top-level `controls` array. Each control runs a command in its own Ghostty-backed terminal section using the user's login shell. Controls are useful for project dashboards, git/status views, dev server or build status, test watchers, log tails, queues, local services, or a custom TUI such as `cmux feed tui --opentui` when that feed is useful.\n\nChoose where to write the config:\n- In a repository or project directory, create or edit `.cmux/dock.json` so teammates can share it.\n- For a personal default outside a repo, create or edit `~/.config/cmux/dock.json`.\n- If both exist, project `.cmux/dock.json` is more specific for that project. Nested project configs apply to that directory tree; use the nearest relevant project config instead of writing unrelated controls globally.\n- If there is no repo and no clear project root, use the global config only after confirming the user wants a personal Dock.\n\nSchema:\n{\n \"controls\": [\n {\n \"id\": \"short-stable-id\",\n \"title\": \"Human label\",\n \"command\": \"safe command to run\",\n \"cwd\": \"optional/path\",\n \"height\": 220,\n \"env\": { \"NAME\": \"value\" }\n }\n ]\n}\n\nRules:\n- Keep ids stable, lowercase, and unique.\n- Use `cwd` for subdirectories; relative paths resolve from the config base.\n- Use `height` only when a control needs a fixed amount of vertical space.\n- Use `env` only for non-secret values needed by one control.\n- Do not put secrets, tokens, or machine-specific private paths in a shared project config.\n- Prefer commands that are safe to start repeatedly and make sense in a terminal.\n- Do not invent unavailable scripts. Read package files, Makefiles, Procfiles, README docs, config files, and existing tooling first.\n- Keep shared project Docks portable for teammates. Put personal or machine-specific controls in the global Dock.\n\nDeliverable:\n- Create or update the appropriate dock.json.\n- Preserve existing useful controls unless the user asked to replace them.\n- Validate that the JSON parses.\n- Summarize what each control does and any commands the user should review before trusting the Dock config." + }, + "sessionRestore": { + "title": "세션 복원", + "metaTitle": "세션 복원", + "metaDescription": "재실행 후 cmux가 창, workspace, pane, 작업 디렉터리, scrollback, 브라우저 기록, 지원되는 agent 세션을 복원하는 방식입니다.", + "intro": "cmux는 작업의 형태를 저장해 앱을 다시 실행했을 때 같은 창, workspace, pane, 터미널 컨텍스트, 브라우저 상태를 되돌릴 수 있게 합니다.", + "restoredTitle": "cmux가 복원하는 항목", + "restoredDesc": "재실행 후 cmux는 앱이 소유한 layout과 metadata를 복원합니다:", + "restoredItemLayout": "창, workspace, pane layout", + "restoredItemCwd": "작업 디렉터리", + "restoredItemScrollback": "터미널 scrollback, best effort", + "restoredItemBrowser": "브라우저 URL과 탐색 기록", + "liveProcessCallout": "cmux는 임의의 live process 상태를 checkpoint하지 않습니다. tmux, vim, shell, 미지원 터미널 앱은 자체 cmux resume integration이 없는 한 일반 터미널로 다시 열립니다.", + "agentResumeTitle": "Agent 세션 resume", + "agentResumeDesc": "지원되는 AI coding agent는 cmux가 agent의 native session ID를 캡처했을 때 resume할 수 있습니다. agent binary가 PATH에 있도록 agent CLI 설치 후 hook을 설치하세요:", + "setupBehavior": "cmux hooks setup은 찾을 수 있는 모든 지원 integration을 설치하고 건너뛴 agent 요약을 출력합니다. 하나의 integration만 원하면 agent 이름을 사용하세요.", + "surfaceBindingsTitle": "사용자 지정 surface resume 명령", + "surfaceBindingsDesc": "고급 사용자와 integration은 어떤 터미널 surface든 restart 명령에 연결할 수 있습니다. cmux는 공개 CLI나 socket으로 만든 binding을 확인과 수동 resume용으로 저장합니다.", + "surfaceBindingsNote": "승인된 prefix는 Settings > Terminal > Resume Commands에서 확인하거나 수정할 수 있습니다. cmux는 실행 중인 프로세스에서 감지한 tmux binding이나 사용자가 승인한 prefix처럼 신뢰됨으로 표시한 resume binding만 자동 실행합니다. cmux는 여전히 임의의 process memory를 checkpoint하지 않습니다. 토큰, 비밀번호, 시크릿, API 키 같은 민감한 환경 변수 키는 resume binding을 저장하기 전에 제거됩니다. 승인은 작업 디렉터리와 정확한 환경 값이 있을 때에도 함께 묶입니다.", + "supportedTitle": "지원되는 agent", + "agentHeader": "Agent", + "binaryHeader": "Binary", + "resumeHeader": "Resume 명령", + "feedHeader": "Feed bridge", + "feedPermissionRequest": "PermissionRequest", + "feedPreToolUsePermissionRequest": "PreToolUse, PermissionRequest", + "feedBeforeShellExecution": "beforeShellExecution", + "feedPreToolUse": "PreToolUse", + "pluginEventBus": "plugin event bus", + "none": "없음", + "supportedNote": "Claude integration이 Settings에서 켜져 있으면 Claude Code는 cmux Claude wrapper가 처리합니다. Rovo Dev는 setup alias로 rovo도 받습니다.", + "manualTitle": "수동 복원", + "manualDesc": "cmux는 일반 실행 시 마지막으로 저장된 snapshot을 복원합니다. 이전 snapshot을 수동으로 다시 적용할 수도 있습니다:", + "manualItemMenu": "File > Reopen Previous Session", + "disableTitle": "Agent 자동 resume 끄기", + "disableDesc": "저장된 agent resume 명령을 실행하지 않고 pane만 복원하려면 Settings > Terminal > Resume Agent Sessions on Reopen을 끄거나 다음을 설정하세요:", + "disableNote": "이 설정은 agent resume 명령만 끕니다. cmux는 layout, 작업 디렉터리, scrollback, 브라우저 기록을 계속 복원합니다.", + "underHoodTitle": "작동 방식", + "underHoodItemSnapshot": "cmux는 versioned JSON snapshot을 ~/Library/Application Support/cmux/session-<bundle-id>.json에 쓰고, 수동 reopen을 위한 previous-session cache도 저장합니다.", + "underHoodItemScrollback": "터미널 scrollback은 제한된 텍스트로 저장되며 복원 시 임시 파일을 통해 replay됩니다. 터미널 앱이 화면을 다시 그리거나 지울 수 있으므로 best effort입니다.", + "underHoodItemHooks": "Agent hook은 ~/.cmuxterm/<agent>-hook-sessions.json에 agent session ID, cmux workspace ID, surface ID, cwd, 가능한 경우 process ID, sanitize된 launch 명령을 씁니다.", + "underHoodItemResume": "복원 시 cmux는 먼저 창과 pane을 다시 만듭니다. agent 자동 resume이 켜져 있으면 저장된 session ID로 agent의 native resume 명령을 실행하는 one-shot shell 명령을 시작합니다.", + "underHoodMore": "일반 <configLink>configuration docs</configLink>는 cmux.json을 다룹니다. 세션 복원은 app layout을 Ghostty 터미널 렌더링 설정과 분리해 저장합니다." } }, "legal": { diff --git a/web/messages/no.json b/web/messages/no.json index 2c1f853cc6c1..e8807e30ab87 100644 --- a/web/messages/no.json +++ b/web/messages/no.json @@ -701,6 +701,7 @@ "gettingStarted": "Kom i gang", "concepts": "Konsepter", "configuration": "Konfigurasjon", + "sessionRestore": "Sesjongjenoppretting", "customCommands": "Egendefinerte kommandoer", "keyboardShortcuts": "Tastatursnarveier", "apiReference": "API-referanse", @@ -746,6 +747,49 @@ "agentPromptIntro": "Bruk denne prompten når du vil at en coding-agent skal lage Dock-kontroller. Den ber agenten kjøre `cmux docs dock`, inspisere prosjektet og spørre før den gjetter.", "agentPromptCodeTitle": "Agent-prompt", "agentPrompt": "Set up cmux Dock controls for the current context.\n\nFirst, learn the feature before editing:\n1. Run `cmux docs dock` if the cmux CLI is available. If it is not, read https://cmux.com/docs/dock.\n2. Inspect the repository or current directory to understand the project type, scripts, package manager, dev servers, logs, task runners, test commands, and any existing TUI tools.\n3. If the desired Dock is ambiguous, ask the user what they want monitored or controlled before writing files.\n\nDock is cmux's right-sidebar terminal control area. A Dock config is JSON with a top-level `controls` array. Each control runs a command in its own Ghostty-backed terminal section using the user's login shell. Controls are useful for project dashboards, git/status views, dev server or build status, test watchers, log tails, queues, local services, or a custom TUI such as `cmux feed tui --opentui` when that feed is useful.\n\nChoose where to write the config:\n- In a repository or project directory, create or edit `.cmux/dock.json` so teammates can share it.\n- For a personal default outside a repo, create or edit `~/.config/cmux/dock.json`.\n- If both exist, project `.cmux/dock.json` is more specific for that project. Nested project configs apply to that directory tree; use the nearest relevant project config instead of writing unrelated controls globally.\n- If there is no repo and no clear project root, use the global config only after confirming the user wants a personal Dock.\n\nSchema:\n{\n \"controls\": [\n {\n \"id\": \"short-stable-id\",\n \"title\": \"Human label\",\n \"command\": \"safe command to run\",\n \"cwd\": \"optional/path\",\n \"height\": 220,\n \"env\": { \"NAME\": \"value\" }\n }\n ]\n}\n\nRules:\n- Keep ids stable, lowercase, and unique.\n- Use `cwd` for subdirectories; relative paths resolve from the config base.\n- Use `height` only when a control needs a fixed amount of vertical space.\n- Use `env` only for non-secret values needed by one control.\n- Do not put secrets, tokens, or machine-specific private paths in a shared project config.\n- Prefer commands that are safe to start repeatedly and make sense in a terminal.\n- Do not invent unavailable scripts. Read package files, Makefiles, Procfiles, README docs, config files, and existing tooling first.\n- Keep shared project Docks portable for teammates. Put personal or machine-specific controls in the global Dock.\n\nDeliverable:\n- Create or update the appropriate dock.json.\n- Preserve existing useful controls unless the user asked to replace them.\n- Validate that the JSON parses.\n- Summarize what each control does and any commands the user should review before trusting the Dock config." + }, + "sessionRestore": { + "title": "Sesjongjenoppretting", + "metaTitle": "Sesjongjenoppretting", + "metaDescription": "Hvordan cmux gjenoppretter vinduer, workspaces, paneler, arbeidsmapper, scrollback, nettleserhistorikk og støttede agentøkter etter omstart.", + "intro": "cmux lagrer formen på arbeidet ditt slik at appen etter omstart kan hente tilbake de samme vinduene, workspaces, panelene, terminalkonteksten og nettlesertilstanden.", + "restoredTitle": "Hva cmux gjenoppretter", + "restoredDesc": "Etter omstart gjenoppretter cmux layout og metadata som appen eier:", + "restoredItemLayout": "Layout for vinduer, workspaces og paneler", + "restoredItemCwd": "Arbeidsmapper", + "restoredItemScrollback": "Terminal-scrollback, best effort", + "restoredItemBrowser": "Nettleser-URL og navigasjonshistorikk", + "liveProcessCallout": "cmux checkpoint-er ikke vilkårlig live prosesstilstand. tmux, vim, shell og terminalapper uten støtte åpnes som vanlige terminaler med mindre de har egen cmux resume-integrasjon.", + "agentResumeTitle": "Gjenoppta agentøkt", + "agentResumeDesc": "Støttede AI-kodeagenter kan gjenopptas når cmux har fanget agentens native session-ID. Installer hooks etter agent-CLI-en slik at binæren finnes på PATH:", + "setupBehavior": "cmux hooks setup installerer alle støttede integrasjoner den finner og skriver ut et sammendrag for agenter som ble hoppet over. Bruk et agentnavn når du bare vil ha én integrasjon.", + "surfaceBindingsTitle": "Egendefinerte surface resume-kommandoer", + "surfaceBindingsDesc": "Avanserte brukere og integrasjoner kan binde hvilken som helst terminal-surface til en restart-kommando. cmux lagrer bindinger laget via offentlig CLI eller socket for inspeksjon og manuell gjenopptakelse.", + "surfaceBindingsNote": "Godkjente prefikser kan gjennomgås eller redigeres i Settings > Terminal > Resume Commands. cmux auto-kjører bare resume-bindinger den markerer som klarerte, for eksempel tmux-bindinger oppdaget fra levende prosesser eller bruker-godkjente prefikser. cmux checkpoint-er fortsatt ikke vilkårlig prosessminne. Sensitive miljønøkler som tokens, passord, hemmeligheter og API-nøkler fjernes før en resume-binding lagres. Godkjenninger bindes også til arbeidsmappen og eksakte miljøverdier når de finnes.", + "supportedTitle": "Støttede agenter", + "agentHeader": "Agent", + "binaryHeader": "Binær", + "resumeHeader": "Resume-kommando", + "feedHeader": "Feed-bro", + "feedPermissionRequest": "PermissionRequest", + "feedPreToolUsePermissionRequest": "PreToolUse, PermissionRequest", + "feedBeforeShellExecution": "beforeShellExecution", + "feedPreToolUse": "PreToolUse", + "pluginEventBus": "plugin event bus", + "none": "ingen", + "supportedNote": "Claude Code håndteres av cmux Claude-wrapperen når Claude-integrasjonen er aktivert i Settings. Rovo Dev godtar også rovo som setup-alias.", + "manualTitle": "Manuell gjenoppretting", + "manualDesc": "cmux gjenoppretter siste lagrede snapshot ved normal oppstart. Du kan også bruke forrige snapshot manuelt igjen:", + "manualItemMenu": "File > Reopen Previous Session", + "disableTitle": "Slå av automatisk agent-gjenopptakelse", + "disableDesc": "For å gjenopprette paneler uten å starte lagrede agent resume-kommandoer, slå av Settings > Terminal > Resume Agent Sessions on Reopen eller sett:", + "disableNote": "Dette slår bare av agent resume-kommandoer. cmux gjenoppretter fortsatt layout, arbeidsmapper, scrollback og nettleserhistorikk.", + "underHoodTitle": "Slik fungerer det", + "underHoodItemSnapshot": "cmux skriver et versjonert JSON-snapshot til ~/Library/Application Support/cmux/session-<bundle-id>.json, pluss en previous-session cache for manuell gjenåpning.", + "underHoodItemScrollback": "Terminal-scrollback lagres som begrenset tekst og spilles av via en midlertidig fil ved gjenoppretting. Dette er best effort fordi terminalapper kan tegne om eller tømme skjermen.", + "underHoodItemHooks": "Agent hooks skriver ~/.cmuxterm/<agent>-hook-sessions.json med agent session-ID, cmux workspace-ID, surface-ID, cwd, prosess-ID når tilgjengelig og en sanert startkommando.", + "underHoodItemResume": "Ved gjenoppretting bygger cmux først opp vinduer og paneler. Hvis automatisk agent-gjenopptakelse er aktivert, starter den en engangs shell-kommando som kjører agentens native resume-kommando med lagret session-ID.", + "underHoodMore": "Vanlige <configLink>konfigurasjonsdokumenter</configLink> dekker cmux.json. Sesjongjenoppretting holder app-layout separat fra Ghostty-terminalens renderingsinnstillinger." } }, "legal": { diff --git a/web/messages/pl.json b/web/messages/pl.json index f82b7a128cda..1a9cf948043f 100644 --- a/web/messages/pl.json +++ b/web/messages/pl.json @@ -701,6 +701,7 @@ "gettingStarted": "Szybki start", "concepts": "Koncepty", "configuration": "Konfiguracja", + "sessionRestore": "Przywracanie sesji", "customCommands": "Niestandardowe polecenia", "keyboardShortcuts": "Skróty klawiszowe", "apiReference": "Dokumentacja API", @@ -746,6 +747,49 @@ "agentPromptIntro": "Użyj tego promptu, gdy chcesz, aby agent kodujący utworzył kontrolki Dock. Poleca agentowi uruchomić `cmux docs dock`, sprawdzić projekt i zapytać przed zgadywaniem.", "agentPromptCodeTitle": "Prompt dla agenta", "agentPrompt": "Set up cmux Dock controls for the current context.\n\nFirst, learn the feature before editing:\n1. Run `cmux docs dock` if the cmux CLI is available. If it is not, read https://cmux.com/docs/dock.\n2. Inspect the repository or current directory to understand the project type, scripts, package manager, dev servers, logs, task runners, test commands, and any existing TUI tools.\n3. If the desired Dock is ambiguous, ask the user what they want monitored or controlled before writing files.\n\nDock is cmux's right-sidebar terminal control area. A Dock config is JSON with a top-level `controls` array. Each control runs a command in its own Ghostty-backed terminal section using the user's login shell. Controls are useful for project dashboards, git/status views, dev server or build status, test watchers, log tails, queues, local services, or a custom TUI such as `cmux feed tui --opentui` when that feed is useful.\n\nChoose where to write the config:\n- In a repository or project directory, create or edit `.cmux/dock.json` so teammates can share it.\n- For a personal default outside a repo, create or edit `~/.config/cmux/dock.json`.\n- If both exist, project `.cmux/dock.json` is more specific for that project. Nested project configs apply to that directory tree; use the nearest relevant project config instead of writing unrelated controls globally.\n- If there is no repo and no clear project root, use the global config only after confirming the user wants a personal Dock.\n\nSchema:\n{\n \"controls\": [\n {\n \"id\": \"short-stable-id\",\n \"title\": \"Human label\",\n \"command\": \"safe command to run\",\n \"cwd\": \"optional/path\",\n \"height\": 220,\n \"env\": { \"NAME\": \"value\" }\n }\n ]\n}\n\nRules:\n- Keep ids stable, lowercase, and unique.\n- Use `cwd` for subdirectories; relative paths resolve from the config base.\n- Use `height` only when a control needs a fixed amount of vertical space.\n- Use `env` only for non-secret values needed by one control.\n- Do not put secrets, tokens, or machine-specific private paths in a shared project config.\n- Prefer commands that are safe to start repeatedly and make sense in a terminal.\n- Do not invent unavailable scripts. Read package files, Makefiles, Procfiles, README docs, config files, and existing tooling first.\n- Keep shared project Docks portable for teammates. Put personal or machine-specific controls in the global Dock.\n\nDeliverable:\n- Create or update the appropriate dock.json.\n- Preserve existing useful controls unless the user asked to replace them.\n- Validate that the JSON parses.\n- Summarize what each control does and any commands the user should review before trusting the Dock config." + }, + "sessionRestore": { + "title": "Przywracanie sesji", + "metaTitle": "Przywracanie sesji", + "metaDescription": "Jak cmux po ponownym uruchomieniu przywraca okna, workspaces, panele, katalogi robocze, scrollback, historię przeglądarki i obsługiwane sesje agentów.", + "intro": "cmux zapisuje kształt pracy, aby po ponownym uruchomieniu aplikacji wróciły te same okna, workspaces, panele, kontekst terminala i stan przeglądarki.", + "restoredTitle": "Co przywraca cmux", + "restoredDesc": "Po ponownym uruchomieniu cmux przywraca layout i metadane należące do aplikacji:", + "restoredItemLayout": "Layout okien, workspaces i paneli", + "restoredItemCwd": "Katalogi robocze", + "restoredItemScrollback": "Scrollback terminala, best effort", + "restoredItemBrowser": "URL przeglądarki i historia nawigacji", + "liveProcessCallout": "cmux nie checkpointuje dowolnego stanu żywych procesów. tmux, vim, shelle i nieobsługiwane aplikacje terminalowe otwierają się jako zwykłe terminale, chyba że mają własną integrację resume cmux.", + "agentResumeTitle": "Wznawianie sesji agentów", + "agentResumeDesc": "Obsługiwani agenci AI coding mogą wznowić pracę, gdy cmux przechwyci natywny ID sesji agenta. Zainstaluj hooks po instalacji CLI agenta, aby binarka była na PATH:", + "setupBehavior": "cmux hooks setup instaluje każdą znalezioną obsługiwaną integrację i wypisuje podsumowanie pominiętych agentów. Użyj nazwy agenta, gdy chcesz tylko jedną integrację.", + "surfaceBindingsTitle": "Własne polecenia surface resume", + "surfaceBindingsDesc": "Zaawansowani użytkownicy i integracje mogą powiązać dowolną terminal surface z poleceniem restartu. cmux zapisuje bindingi utworzone przez publiczny CLI lub socket do inspekcji i ręcznego wznowienia.", + "surfaceBindingsNote": "Zatwierdzone prefiksy można sprawdzić lub edytować w Settings > Terminal > Resume Commands. cmux automatycznie uruchamia tylko bindingi resume oznaczone jako zaufane, na przykład bindingi tmux wykryte z działających procesów albo prefiksy zatwierdzone przez użytkownika. cmux nadal nie checkpointuje dowolnej pamięci procesów. Wrażliwe klucze środowiska, takie jak tokeny, hasła, sekrety i klucze API, są odrzucane przed zapisaniem bindingu wznowienia. Zatwierdzenia są też powiązane z katalogiem roboczym i dokładnymi wartościami środowiska, gdy są obecne.", + "supportedTitle": "Obsługiwani agenci", + "agentHeader": "Agent", + "binaryHeader": "Binarka", + "resumeHeader": "Polecenie resume", + "feedHeader": "Most feed", + "feedPermissionRequest": "PermissionRequest", + "feedPreToolUsePermissionRequest": "PreToolUse, PermissionRequest", + "feedBeforeShellExecution": "beforeShellExecution", + "feedPreToolUse": "PreToolUse", + "pluginEventBus": "plugin event bus", + "none": "brak", + "supportedNote": "Claude Code obsługuje wrapper cmux Claude, gdy integracja Claude jest włączona w Settings. Rovo Dev akceptuje też rovo jako alias setup.", + "manualTitle": "Ręczne przywracanie", + "manualDesc": "cmux przywraca ostatni zapisany snapshot przy normalnym starcie. Możesz też ręcznie zastosować poprzedni snapshot:", + "manualItemMenu": "File > Reopen Previous Session", + "disableTitle": "Wyłącz automatyczne wznawianie agentów", + "disableDesc": "Aby przywrócić panele bez uruchamiania zapisanych poleceń resume agentów, wyłącz Settings > Terminal > Resume Agent Sessions on Reopen albo ustaw:", + "disableNote": "To wyłącza tylko polecenia resume agentów. cmux nadal przywraca layout, katalogi robocze, scrollback i historię przeglądarki.", + "underHoodTitle": "Jak to działa", + "underHoodItemSnapshot": "cmux zapisuje wersjonowany snapshot JSON w ~/Library/Application Support/cmux/session-<bundle-id>.json oraz cache poprzedniej sesji do ręcznego ponownego otwarcia.", + "underHoodItemScrollback": "Scrollback terminala jest zapisywany jako ograniczony tekst i odtwarzany przez plik tymczasowy podczas przywracania. To best effort, bo aplikacje terminalowe mogą przerysować lub wyczyścić ekran.", + "underHoodItemHooks": "Agent hooks zapisują ~/.cmuxterm/<agent>-hook-sessions.json z ID sesji agenta, ID workspace cmux, ID surface, cwd, ID procesu gdy dostępny i oczyszczonym poleceniem startowym.", + "underHoodItemResume": "Podczas przywracania cmux najpierw odbudowuje okna i panele. Jeśli automatyczne wznawianie agentów jest włączone, uruchamia jednorazowe polecenie shell, które wykonuje natywne polecenie resume agenta z zapisanym ID sesji.", + "underHoodMore": "Zwykła <configLink>dokumentacja konfiguracji</configLink> opisuje cmux.json. Przywracanie sesji trzyma layout aplikacji oddzielnie od ustawień renderowania terminala Ghostty." } }, "legal": { diff --git a/web/messages/pt-BR.json b/web/messages/pt-BR.json index 62eaef3aad94..1c269873b80c 100644 --- a/web/messages/pt-BR.json +++ b/web/messages/pt-BR.json @@ -701,6 +701,7 @@ "gettingStarted": "Primeiros Passos", "concepts": "Conceitos", "configuration": "Configuração", + "sessionRestore": "Restauração de sessão", "customCommands": "Comandos personalizados", "keyboardShortcuts": "Atalhos de Teclado", "apiReference": "Referência da API", @@ -746,6 +747,49 @@ "agentPromptIntro": "Use este prompt quando quiser que um agente de código crie controles Dock. Ele instrui o agente a executar `cmux docs dock`, inspecionar o projeto e perguntar antes de adivinhar.", "agentPromptCodeTitle": "Prompt do agente", "agentPrompt": "Set up cmux Dock controls for the current context.\n\nFirst, learn the feature before editing:\n1. Run `cmux docs dock` if the cmux CLI is available. If it is not, read https://cmux.com/docs/dock.\n2. Inspect the repository or current directory to understand the project type, scripts, package manager, dev servers, logs, task runners, test commands, and any existing TUI tools.\n3. If the desired Dock is ambiguous, ask the user what they want monitored or controlled before writing files.\n\nDock is cmux's right-sidebar terminal control area. A Dock config is JSON with a top-level `controls` array. Each control runs a command in its own Ghostty-backed terminal section using the user's login shell. Controls are useful for project dashboards, git/status views, dev server or build status, test watchers, log tails, queues, local services, or a custom TUI such as `cmux feed tui --opentui` when that feed is useful.\n\nChoose where to write the config:\n- In a repository or project directory, create or edit `.cmux/dock.json` so teammates can share it.\n- For a personal default outside a repo, create or edit `~/.config/cmux/dock.json`.\n- If both exist, project `.cmux/dock.json` is more specific for that project. Nested project configs apply to that directory tree; use the nearest relevant project config instead of writing unrelated controls globally.\n- If there is no repo and no clear project root, use the global config only after confirming the user wants a personal Dock.\n\nSchema:\n{\n \"controls\": [\n {\n \"id\": \"short-stable-id\",\n \"title\": \"Human label\",\n \"command\": \"safe command to run\",\n \"cwd\": \"optional/path\",\n \"height\": 220,\n \"env\": { \"NAME\": \"value\" }\n }\n ]\n}\n\nRules:\n- Keep ids stable, lowercase, and unique.\n- Use `cwd` for subdirectories; relative paths resolve from the config base.\n- Use `height` only when a control needs a fixed amount of vertical space.\n- Use `env` only for non-secret values needed by one control.\n- Do not put secrets, tokens, or machine-specific private paths in a shared project config.\n- Prefer commands that are safe to start repeatedly and make sense in a terminal.\n- Do not invent unavailable scripts. Read package files, Makefiles, Procfiles, README docs, config files, and existing tooling first.\n- Keep shared project Docks portable for teammates. Put personal or machine-specific controls in the global Dock.\n\nDeliverable:\n- Create or update the appropriate dock.json.\n- Preserve existing useful controls unless the user asked to replace them.\n- Validate that the JSON parses.\n- Summarize what each control does and any commands the user should review before trusting the Dock config." + }, + "sessionRestore": { + "title": "Restauração de sessão", + "metaTitle": "Restauração de sessão", + "metaDescription": "Como o cmux restaura janelas, workspaces, painéis, diretórios de trabalho, scrollback, histórico do navegador e sessões de agentes compatíveis após reabrir.", + "intro": "O cmux salva a forma do seu trabalho para que reabrir o app traga de volta as mesmas janelas, workspaces, painéis, contexto do terminal e estado do navegador.", + "restoredTitle": "O que o cmux restaura", + "restoredDesc": "Após reabrir, o cmux restaura layout e metadados pertencentes ao app:", + "restoredItemLayout": "Layout de janelas, workspaces e painéis", + "restoredItemCwd": "Diretórios de trabalho", + "restoredItemScrollback": "Scrollback do terminal, best effort", + "restoredItemBrowser": "URL do navegador e histórico de navegação", + "liveProcessCallout": "O cmux não faz checkpoint de estado arbitrário de processos vivos. tmux, vim, shells e apps de terminal não compatíveis reabrem como terminais normais, a menos que tenham sua própria integração de resume do cmux.", + "agentResumeTitle": "Retomada de sessões de agentes", + "agentResumeDesc": "Agentes de coding com IA compatíveis podem retomar quando o cmux captura o ID de sessão nativo do agente. Instale hooks depois do CLI do agente para que o binário esteja no PATH:", + "setupBehavior": "cmux hooks setup instala toda integração compatível que encontrar e imprime um resumo dos agentes ignorados. Use um nome de agente quando quiser só uma integração.", + "surfaceBindingsTitle": "Comandos personalizados de surface resume", + "surfaceBindingsDesc": "Usuários avançados e integrações podem associar qualquer terminal surface a um comando de reinício. O cmux salva associações criadas pela CLI pública ou pelo socket para inspeção e retomada manual.", + "surfaceBindingsNote": "Prefixos aprovados podem ser revisados ou editados em Settings > Terminal > Resume Commands. O cmux só executa automaticamente associações de resume marcadas como confiáveis, como associações tmux detectadas a partir de processos ativos ou prefixos aprovados pelo usuário. O cmux ainda não faz checkpoint de memória arbitrária de processos. Chaves de ambiente sensíveis, como tokens, senhas, segredos e chaves de API, são descartadas antes de salvar uma associação de retomada. As aprovações também ficam vinculadas ao diretório de trabalho e aos valores exatos de ambiente quando presentes.", + "supportedTitle": "Agentes compatíveis", + "agentHeader": "Agente", + "binaryHeader": "Binário", + "resumeHeader": "Comando resume", + "feedHeader": "Ponte de feed", + "feedPermissionRequest": "PermissionRequest", + "feedPreToolUsePermissionRequest": "PreToolUse, PermissionRequest", + "feedBeforeShellExecution": "beforeShellExecution", + "feedPreToolUse": "PreToolUse", + "pluginEventBus": "plugin event bus", + "none": "nenhum", + "supportedNote": "Claude Code é tratado pelo wrapper Claude do cmux quando a integração Claude está ativada em Settings. Rovo Dev também aceita rovo como alias de setup.", + "manualTitle": "Restauração manual", + "manualDesc": "O cmux restaura o último snapshot salvo na inicialização normal. Você também pode reaplicar manualmente o snapshot anterior:", + "manualItemMenu": "File > Reopen Previous Session", + "disableTitle": "Desativar retomada automática de agentes", + "disableDesc": "Para restaurar painéis sem iniciar comandos de resume de agentes salvos, desative Settings > Terminal > Resume Agent Sessions on Reopen ou defina:", + "disableNote": "Isso desativa apenas comandos de resume de agentes. O cmux ainda restaura layout, diretórios de trabalho, scrollback e histórico do navegador.", + "underHoodTitle": "Como funciona", + "underHoodItemSnapshot": "O cmux grava um snapshot JSON versionado em ~/Library/Application Support/cmux/session-<bundle-id>.json, além de um cache da sessão anterior para reabertura manual.", + "underHoodItemScrollback": "O scrollback do terminal é salvo como texto limitado e reproduzido por um arquivo temporário na restauração. É best effort porque apps de terminal podem redesenhar ou limpar a tela.", + "underHoodItemHooks": "Agent hooks gravam ~/.cmuxterm/<agent>-hook-sessions.json com ID de sessão do agente, ID de workspace do cmux, ID de surface, cwd, ID do processo quando disponível e um comando de lançamento sanitizado.", + "underHoodItemResume": "Na restauração, o cmux reconstrói janelas e painéis primeiro. Se a retomada automática de agentes estiver ativada, ele inicia um comando shell único que executa o comando nativo de resume do agente com o ID de sessão salvo.", + "underHoodMore": "A <configLink>documentação de configuração</configLink> normal cobre cmux.json. A restauração de sessão mantém o layout do app separado das configurações de renderização do terminal Ghostty." } }, "legal": { diff --git a/web/messages/ru.json b/web/messages/ru.json index ce7aaa0dbbb1..74674f0b239f 100644 --- a/web/messages/ru.json +++ b/web/messages/ru.json @@ -701,6 +701,7 @@ "gettingStarted": "Начало работы", "concepts": "Концепции", "configuration": "Конфигурация", + "sessionRestore": "Восстановление сессии", "customCommands": "Пользовательские команды", "keyboardShortcuts": "Горячие клавиши", "apiReference": "Справочник API", @@ -746,6 +747,49 @@ "agentPromptIntro": "Используйте этот промпт, когда хотите, чтобы coding agent создал элементы Dock. Он просит агента выполнить `cmux docs dock`, изучить проект и спросить перед тем, как что-либо предполагать.", "agentPromptCodeTitle": "Промпт агента", "agentPrompt": "Set up cmux Dock controls for the current context.\n\nFirst, learn the feature before editing:\n1. Run `cmux docs dock` if the cmux CLI is available. If it is not, read https://cmux.com/docs/dock.\n2. Inspect the repository or current directory to understand the project type, scripts, package manager, dev servers, logs, task runners, test commands, and any existing TUI tools.\n3. If the desired Dock is ambiguous, ask the user what they want monitored or controlled before writing files.\n\nDock is cmux's right-sidebar terminal control area. A Dock config is JSON with a top-level `controls` array. Each control runs a command in its own Ghostty-backed terminal section using the user's login shell. Controls are useful for project dashboards, git/status views, dev server or build status, test watchers, log tails, queues, local services, or a custom TUI such as `cmux feed tui --opentui` when that feed is useful.\n\nChoose where to write the config:\n- In a repository or project directory, create or edit `.cmux/dock.json` so teammates can share it.\n- For a personal default outside a repo, create or edit `~/.config/cmux/dock.json`.\n- If both exist, project `.cmux/dock.json` is more specific for that project. Nested project configs apply to that directory tree; use the nearest relevant project config instead of writing unrelated controls globally.\n- If there is no repo and no clear project root, use the global config only after confirming the user wants a personal Dock.\n\nSchema:\n{\n \"controls\": [\n {\n \"id\": \"short-stable-id\",\n \"title\": \"Human label\",\n \"command\": \"safe command to run\",\n \"cwd\": \"optional/path\",\n \"height\": 220,\n \"env\": { \"NAME\": \"value\" }\n }\n ]\n}\n\nRules:\n- Keep ids stable, lowercase, and unique.\n- Use `cwd` for subdirectories; relative paths resolve from the config base.\n- Use `height` only when a control needs a fixed amount of vertical space.\n- Use `env` only for non-secret values needed by one control.\n- Do not put secrets, tokens, or machine-specific private paths in a shared project config.\n- Prefer commands that are safe to start repeatedly and make sense in a terminal.\n- Do not invent unavailable scripts. Read package files, Makefiles, Procfiles, README docs, config files, and existing tooling first.\n- Keep shared project Docks portable for teammates. Put personal or machine-specific controls in the global Dock.\n\nDeliverable:\n- Create or update the appropriate dock.json.\n- Preserve existing useful controls unless the user asked to replace them.\n- Validate that the JSON parses.\n- Summarize what each control does and any commands the user should review before trusting the Dock config." + }, + "sessionRestore": { + "title": "Восстановление сессии", + "metaTitle": "Восстановление сессии", + "metaDescription": "Как cmux после перезапуска восстанавливает окна, рабочие пространства, панели, рабочие каталоги, scrollback, историю браузера и поддерживаемые сессии агентов.", + "intro": "cmux сохраняет форму вашей работы, чтобы после перезапуска приложения вернуть те же окна, рабочие пространства, панели, контекст терминала и состояние браузера.", + "restoredTitle": "Что восстанавливает cmux", + "restoredDesc": "После перезапуска cmux восстанавливает layout и метаданные, принадлежащие приложению:", + "restoredItemLayout": "Layout окон, рабочих пространств и панелей", + "restoredItemCwd": "Рабочие каталоги", + "restoredItemScrollback": "Terminal scrollback, best effort", + "restoredItemBrowser": "URL браузера и историю навигации", + "liveProcessCallout": "cmux не checkpoint-ит произвольное состояние живых процессов. tmux, vim, shell и неподдерживаемые терминальные приложения открываются как обычные терминалы, если у них нет собственной cmux resume-интеграции.", + "agentResumeTitle": "Возобновление сессий агентов", + "agentResumeDesc": "Поддерживаемые AI coding agents могут возобновляться, когда cmux захватил native session ID агента. Установите hooks после установки agent CLI, чтобы бинарь был в PATH:", + "setupBehavior": "cmux hooks setup устанавливает все найденные поддерживаемые интеграции и печатает сводку пропущенных агентов. Используйте имя агента, если нужна только одна интеграция.", + "surfaceBindingsTitle": "Пользовательские команды surface resume", + "surfaceBindingsDesc": "Опытные пользователи и интеграции могут привязать любую terminal surface к команде перезапуска. cmux сохраняет привязки, созданные публичным CLI или socket, для проверки и ручного возобновления.", + "surfaceBindingsNote": "Одобренные префиксы можно просмотреть или изменить в Settings > Terminal > Resume Commands. cmux автоматически запускает только привязки resume, помеченные как доверенные, например tmux-привязки, обнаруженные из живых процессов, или префиксы, одобренные пользователем. cmux по-прежнему не checkpoint-ит произвольную память процессов. Чувствительные ключи окружения, такие как токены, пароли, секреты и API-ключи, отбрасываются перед сохранением привязки возобновления. Одобрения также привязаны к рабочему каталогу и точным значениям окружения, если они есть.", + "supportedTitle": "Поддерживаемые агенты", + "agentHeader": "Агент", + "binaryHeader": "Бинарь", + "resumeHeader": "Команда resume", + "feedHeader": "Feed bridge", + "feedPermissionRequest": "PermissionRequest", + "feedPreToolUsePermissionRequest": "PreToolUse, PermissionRequest", + "feedBeforeShellExecution": "beforeShellExecution", + "feedPreToolUse": "PreToolUse", + "pluginEventBus": "plugin event bus", + "none": "нет", + "supportedNote": "Claude Code обрабатывается wrapper-ом cmux Claude, когда интеграция Claude включена в Settings. Rovo Dev также принимает rovo как alias для setup.", + "manualTitle": "Ручное восстановление", + "manualDesc": "cmux восстанавливает последний сохраненный snapshot при обычном запуске. Предыдущий snapshot также можно применить вручную:", + "manualItemMenu": "File > Reopen Previous Session", + "disableTitle": "Отключить автоматическое возобновление агентов", + "disableDesc": "Чтобы восстановить панели без запуска сохраненных agent resume-команд, отключите Settings > Terminal > Resume Agent Sessions on Reopen или задайте:", + "disableNote": "Это отключает только команды resume агентов. cmux всё равно восстанавливает layout, рабочие каталоги, scrollback и историю браузера.", + "underHoodTitle": "Как это работает", + "underHoodItemSnapshot": "cmux записывает версионированный JSON snapshot в ~/Library/Application Support/cmux/session-<bundle-id>.json, а также cache предыдущей сессии для ручного открытия.", + "underHoodItemScrollback": "Terminal scrollback хранится как ограниченный текст и при восстановлении проигрывается через временный файл. Это best effort, потому что терминальные приложения могут перерисовать или очистить экран.", + "underHoodItemHooks": "Agent hooks записывают ~/.cmuxterm/<agent>-hook-sessions.json с session ID агента, cmux workspace ID, surface ID, cwd, process ID при наличии и очищенной командой запуска.", + "underHoodItemResume": "При восстановлении cmux сначала перестраивает окна и панели. Если автоматическое возобновление агентов включено, он запускает одноразовую shell-команду, которая выполняет native resume-команду агента с сохраненным session ID.", + "underHoodMore": "Обычная <configLink>документация по конфигурации</configLink> описывает cmux.json. Восстановление сессии хранит layout приложения отдельно от настроек рендеринга терминала Ghostty." } }, "legal": { diff --git a/web/messages/th.json b/web/messages/th.json index ee126e993ce8..e4c5e4d11a4a 100644 --- a/web/messages/th.json +++ b/web/messages/th.json @@ -701,6 +701,7 @@ "gettingStarted": "เริ่มต้นใช้งาน", "concepts": "แนวคิด", "configuration": "การตั้งค่า", + "sessionRestore": "การกู้คืนเซสชัน", "customCommands": "คำสั่งที่กำหนดเอง", "keyboardShortcuts": "คีย์ลัด", "apiReference": "เอกสาร API", @@ -746,6 +747,49 @@ "agentPromptIntro": "ใช้ prompt นี้เมื่อต้องการให้ coding agent สร้าง Dock controls โดย prompt จะบอกให้ agent รัน `cmux docs dock`, ตรวจดูโปรเจกต์ และถามก่อนเดา", "agentPromptCodeTitle": "Agent prompt", "agentPrompt": "Set up cmux Dock controls for the current context.\n\nFirst, learn the feature before editing:\n1. Run `cmux docs dock` if the cmux CLI is available. If it is not, read https://cmux.com/docs/dock.\n2. Inspect the repository or current directory to understand the project type, scripts, package manager, dev servers, logs, task runners, test commands, and any existing TUI tools.\n3. If the desired Dock is ambiguous, ask the user what they want monitored or controlled before writing files.\n\nDock is cmux's right-sidebar terminal control area. A Dock config is JSON with a top-level `controls` array. Each control runs a command in its own Ghostty-backed terminal section using the user's login shell. Controls are useful for project dashboards, git/status views, dev server or build status, test watchers, log tails, queues, local services, or a custom TUI such as `cmux feed tui --opentui` when that feed is useful.\n\nChoose where to write the config:\n- In a repository or project directory, create or edit `.cmux/dock.json` so teammates can share it.\n- For a personal default outside a repo, create or edit `~/.config/cmux/dock.json`.\n- If both exist, project `.cmux/dock.json` is more specific for that project. Nested project configs apply to that directory tree; use the nearest relevant project config instead of writing unrelated controls globally.\n- If there is no repo and no clear project root, use the global config only after confirming the user wants a personal Dock.\n\nSchema:\n{\n \"controls\": [\n {\n \"id\": \"short-stable-id\",\n \"title\": \"Human label\",\n \"command\": \"safe command to run\",\n \"cwd\": \"optional/path\",\n \"height\": 220,\n \"env\": { \"NAME\": \"value\" }\n }\n ]\n}\n\nRules:\n- Keep ids stable, lowercase, and unique.\n- Use `cwd` for subdirectories; relative paths resolve from the config base.\n- Use `height` only when a control needs a fixed amount of vertical space.\n- Use `env` only for non-secret values needed by one control.\n- Do not put secrets, tokens, or machine-specific private paths in a shared project config.\n- Prefer commands that are safe to start repeatedly and make sense in a terminal.\n- Do not invent unavailable scripts. Read package files, Makefiles, Procfiles, README docs, config files, and existing tooling first.\n- Keep shared project Docks portable for teammates. Put personal or machine-specific controls in the global Dock.\n\nDeliverable:\n- Create or update the appropriate dock.json.\n- Preserve existing useful controls unless the user asked to replace them.\n- Validate that the JSON parses.\n- Summarize what each control does and any commands the user should review before trusting the Dock config." + }, + "sessionRestore": { + "title": "การกู้คืนเซสชัน", + "metaTitle": "การกู้คืนเซสชัน", + "metaDescription": "วิธีที่ cmux กู้คืนหน้าต่าง workspace pane working directory scrollback ประวัติ browser และ agent session ที่รองรับหลังเปิดใหม่", + "intro": "cmux บันทึกรูปร่างงานของคุณ เพื่อให้การเปิด app ใหม่พา window, workspace, pane, terminal context และ browser state เดิมกลับมา", + "restoredTitle": "สิ่งที่ cmux กู้คืน", + "restoredDesc": "หลังเปิดใหม่ cmux กู้คืน layout และ metadata ที่ app เป็นเจ้าของ:", + "restoredItemLayout": "layout ของ window, workspace และ pane", + "restoredItemCwd": "working directory", + "restoredItemScrollback": "terminal scrollback แบบ best effort", + "restoredItemBrowser": "Browser URL และ navigation history", + "liveProcessCallout": "cmux ไม่ checkpoint สถานะ live process ทั่วไป tmux, vim, shell และ terminal app ที่ไม่รองรับจะเปิดใหม่เป็น terminal ปกติ เว้นแต่จะมี cmux resume integration ของตัวเอง", + "agentResumeTitle": "Resume agent session", + "agentResumeDesc": "AI coding agent ที่รองรับสามารถ resume ได้เมื่อ cmux จับ native session ID ของ agent แล้ว ติดตั้ง hooks หลังติดตั้ง agent CLI เพื่อให้ binary อยู่ใน PATH:", + "setupBehavior": "cmux hooks setup ติดตั้ง integration ที่รองรับทั้งหมดที่พบ และพิมพ์สรุป agent ที่ข้ามไป ใช้ชื่อ agent เมื่อต้องการ integration เดียว", + "surfaceBindingsTitle": "คำสั่ง surface resume แบบกำหนดเอง", + "surfaceBindingsDesc": "ผู้ใช้ขั้นสูงและ integration สามารถผูก terminal surface ใดก็ได้กับคำสั่ง restart cmux เก็บ binding ที่สร้างผ่าน public CLI หรือ socket ไว้สำหรับตรวจสอบและ resume ด้วยตนเอง", + "surfaceBindingsNote": "prefix ที่อนุมัติแล้วสามารถตรวจสอบหรือแก้ไขได้ใน Settings > Terminal > Resume Commands cmux จะ auto-run เฉพาะ resume binding ที่ถูกทำเครื่องหมายว่า trusted เช่น tmux binding ที่ตรวจพบจาก process ที่กำลังทำงาน หรือ prefix ที่ user อนุมัติแล้ว cmux ยังไม่ checkpoint process memory ทั่วไป คีย์ environment ที่ละเอียดอ่อน เช่น token, password, secret และ API key จะถูกตัดทิ้งก่อนบันทึก resume binding การอนุมัติจะผูกกับไดเรกทอรีทำงานและค่า environment แบบตรงกันทุกค่าเมื่อมีอยู่ด้วย", + "supportedTitle": "Agent ที่รองรับ", + "agentHeader": "Agent", + "binaryHeader": "Binary", + "resumeHeader": "คำสั่ง resume", + "feedHeader": "Feed bridge", + "feedPermissionRequest": "PermissionRequest", + "feedPreToolUsePermissionRequest": "PreToolUse, PermissionRequest", + "feedBeforeShellExecution": "beforeShellExecution", + "feedPreToolUse": "PreToolUse", + "pluginEventBus": "plugin event bus", + "none": "ไม่มี", + "supportedNote": "Claude Code ถูกจัดการโดย cmux Claude wrapper เมื่อเปิด Claude integration ใน Settings Rovo Dev ยังรับ rovo เป็น setup alias ด้วย", + "manualTitle": "กู้คืนด้วยตนเอง", + "manualDesc": "cmux กู้คืน snapshot ล่าสุดที่บันทึกไว้ในการเปิดปกติ คุณยังสามารถใช้ snapshot ก่อนหน้าซ้ำด้วยตนเองได้:", + "manualItemMenu": "File > Reopen Previous Session", + "disableTitle": "ปิด automatic agent resume", + "disableDesc": "หากต้องการกู้คืน pane โดยไม่เปิดคำสั่ง agent resume ที่บันทึกไว้ ให้ปิด Settings > Terminal > Resume Agent Sessions on Reopen หรือกำหนด:", + "disableNote": "สิ่งนี้ปิดเฉพาะคำสั่ง agent resume เท่านั้น cmux ยัง restore layout, working directory, scrollback และ browser history", + "underHoodTitle": "วิธีทำงาน", + "underHoodItemSnapshot": "cmux เขียน JSON snapshot แบบมี version ไปที่ ~/Library/Application Support/cmux/session-<bundle-id>.json และ cache ของ session ก่อนหน้าเพื่อ reopen ด้วยตนเอง", + "underHoodItemScrollback": "terminal scrollback ถูกเก็บเป็นข้อความจำกัดขนาดและ replay ผ่าน temporary file ตอนกู้คืน เป็น best effort เพราะ terminal app สามารถ redraw หรือ clear หน้าจอได้", + "underHoodItemHooks": "Agent hooks เขียน ~/.cmuxterm/<agent>-hook-sessions.json พร้อม agent session ID, cmux workspace ID, surface ID, cwd, process ID ถ้ามี และ sanitized launch command", + "underHoodItemResume": "ตอนกู้คืน cmux สร้าง window และ pane ก่อน หากเปิด automatic agent resume มันจะเริ่ม one-shot shell command ที่รัน native resume command ของ agent ด้วย session ID ที่บันทึกไว้", + "underHoodMore": "<configLink>เอกสาร configuration</configLink> ปกติครอบคลุม cmux.json Session restore แยก app layout ออกจาก Ghostty terminal rendering settings" } }, "legal": { diff --git a/web/messages/tr.json b/web/messages/tr.json index e64c2a0f4d47..2c11ecb9ba2a 100644 --- a/web/messages/tr.json +++ b/web/messages/tr.json @@ -701,6 +701,7 @@ "gettingStarted": "Başlarken", "concepts": "Kavramlar", "configuration": "Yapılandırma", + "sessionRestore": "Oturum geri yükleme", "customCommands": "Özel Komutlar", "keyboardShortcuts": "Klavye Kısayolları", "apiReference": "API Referansı", @@ -746,6 +747,49 @@ "agentPromptIntro": "Bir coding agent’ın Dock kontrolleri oluşturmasını istediğinde bu promptu kullan. Agent’a `cmux docs dock` çalıştırmasını, projeyi incelemesini ve tahmin etmeden önce sormasını söyler.", "agentPromptCodeTitle": "Agent prompt", "agentPrompt": "Set up cmux Dock controls for the current context.\n\nFirst, learn the feature before editing:\n1. Run `cmux docs dock` if the cmux CLI is available. If it is not, read https://cmux.com/docs/dock.\n2. Inspect the repository or current directory to understand the project type, scripts, package manager, dev servers, logs, task runners, test commands, and any existing TUI tools.\n3. If the desired Dock is ambiguous, ask the user what they want monitored or controlled before writing files.\n\nDock is cmux's right-sidebar terminal control area. A Dock config is JSON with a top-level `controls` array. Each control runs a command in its own Ghostty-backed terminal section using the user's login shell. Controls are useful for project dashboards, git/status views, dev server or build status, test watchers, log tails, queues, local services, or a custom TUI such as `cmux feed tui --opentui` when that feed is useful.\n\nChoose where to write the config:\n- In a repository or project directory, create or edit `.cmux/dock.json` so teammates can share it.\n- For a personal default outside a repo, create or edit `~/.config/cmux/dock.json`.\n- If both exist, project `.cmux/dock.json` is more specific for that project. Nested project configs apply to that directory tree; use the nearest relevant project config instead of writing unrelated controls globally.\n- If there is no repo and no clear project root, use the global config only after confirming the user wants a personal Dock.\n\nSchema:\n{\n \"controls\": [\n {\n \"id\": \"short-stable-id\",\n \"title\": \"Human label\",\n \"command\": \"safe command to run\",\n \"cwd\": \"optional/path\",\n \"height\": 220,\n \"env\": { \"NAME\": \"value\" }\n }\n ]\n}\n\nRules:\n- Keep ids stable, lowercase, and unique.\n- Use `cwd` for subdirectories; relative paths resolve from the config base.\n- Use `height` only when a control needs a fixed amount of vertical space.\n- Use `env` only for non-secret values needed by one control.\n- Do not put secrets, tokens, or machine-specific private paths in a shared project config.\n- Prefer commands that are safe to start repeatedly and make sense in a terminal.\n- Do not invent unavailable scripts. Read package files, Makefiles, Procfiles, README docs, config files, and existing tooling first.\n- Keep shared project Docks portable for teammates. Put personal or machine-specific controls in the global Dock.\n\nDeliverable:\n- Create or update the appropriate dock.json.\n- Preserve existing useful controls unless the user asked to replace them.\n- Validate that the JSON parses.\n- Summarize what each control does and any commands the user should review before trusting the Dock config." + }, + "sessionRestore": { + "title": "Oturum geri yükleme", + "metaTitle": "Oturum geri yükleme", + "metaDescription": "cmux'un yeniden başlatmadan sonra pencereleri, workspace'leri, panelleri, çalışma dizinlerini, scrollback'i, tarayıcı geçmişini ve desteklenen agent oturumlarını nasıl geri yüklediği.", + "intro": "cmux çalışmanızın şeklini kaydeder, böylece app yeniden açıldığında aynı pencereler, workspace'ler, paneller, terminal context'i ve tarayıcı durumu geri gelir.", + "restoredTitle": "cmux neleri geri yükler", + "restoredDesc": "Yeniden açıldıktan sonra cmux app'e ait layout ve metadata'yı geri yükler:", + "restoredItemLayout": "Pencere, workspace ve panel layout'u", + "restoredItemCwd": "Çalışma dizinleri", + "restoredItemScrollback": "Terminal scrollback, best effort", + "restoredItemBrowser": "Tarayıcı URL'si ve gezinme geçmişi", + "liveProcessCallout": "cmux rastgele live process durumunu checkpoint etmez. tmux, vim, shell'ler ve desteklenmeyen terminal app'leri kendi cmux resume integration'ları yoksa normal terminal olarak yeniden açılır.", + "agentResumeTitle": "Agent oturumu resume", + "agentResumeDesc": "Desteklenen AI coding agent'ları, cmux agent'ın native session ID'sini yakaladığında resume edebilir. Agent CLI kurulduktan sonra binary PATH üzerinde olacak şekilde hooks kurun:", + "setupBehavior": "cmux hooks setup bulduğu tüm desteklenen integration'ları kurar ve atlanan agent'lar için özet yazar. Sadece tek integration istediğinizde agent adı kullanın.", + "surfaceBindingsTitle": "Özel surface resume komutları", + "surfaceBindingsDesc": "Gelişmiş kullanıcılar ve integration'lar herhangi bir terminal surface'ini restart komutuna bağlayabilir. cmux public CLI veya socket ile oluşturulan binding'leri inceleme ve manuel resume için saklar.", + "surfaceBindingsNote": "Onaylanan prefixler Settings > Terminal > Resume Commands içinde incelenebilir veya düzenlenebilir. cmux yalnızca güvenilir olarak işaretlediği resume binding'lerini otomatik çalıştırır, örneğin canlı processlerden algılanan tmux binding'leri veya kullanıcı onaylı prefixler. cmux rastgele process memory'yi hâlâ checkpoint etmez. Token, parola, gizli değer ve API anahtarı gibi hassas ortam anahtarları resume binding kaydedilmeden önce atılır. Onaylar, varsa çalışma dizinine ve tam ortam değerlerine de bağlanır.", + "supportedTitle": "Desteklenen agent'lar", + "agentHeader": "Agent", + "binaryHeader": "Binary", + "resumeHeader": "Resume komutu", + "feedHeader": "Feed bridge", + "feedPermissionRequest": "PermissionRequest", + "feedPreToolUsePermissionRequest": "PreToolUse, PermissionRequest", + "feedBeforeShellExecution": "beforeShellExecution", + "feedPreToolUse": "PreToolUse", + "pluginEventBus": "plugin event bus", + "none": "yok", + "supportedNote": "Claude Code, Settings'te Claude integration etkinse cmux Claude wrapper tarafından işlenir. Rovo Dev setup alias olarak rovo'yu da kabul eder.", + "manualTitle": "Manuel geri yükleme", + "manualDesc": "cmux normal açılışta son kaydedilen snapshot'ı geri yükler. Önceki snapshot'ı manuel olarak da tekrar uygulayabilirsiniz:", + "manualItemMenu": "File > Reopen Previous Session", + "disableTitle": "Otomatik agent resume'u kapat", + "disableDesc": "Kaydedilmiş agent resume komutlarını başlatmadan panelleri geri yüklemek için Settings > Terminal > Resume Agent Sessions on Reopen seçeneğini kapatın veya şunu ayarlayın:", + "disableNote": "Bu yalnızca agent resume komutlarını kapatır. cmux layout, çalışma dizinleri, scrollback ve tarayıcı geçmişini geri yüklemeye devam eder.", + "underHoodTitle": "Nasıl çalışır", + "underHoodItemSnapshot": "cmux version'lı JSON snapshot'ı ~/Library/Application Support/cmux/session-<bundle-id>.json dosyasına ve manuel reopen için previous-session cache'e yazar.", + "underHoodItemScrollback": "Terminal scrollback sınırlı metin olarak saklanır ve geri yüklemede geçici dosya üzerinden replay edilir. Terminal app'leri ekranı yeniden çizebildiği veya temizleyebildiği için bu best effort'tur.", + "underHoodItemHooks": "Agent hooks ~/.cmuxterm/<agent>-hook-sessions.json dosyasına agent session ID, cmux workspace ID, surface ID, cwd, varsa process ID ve sanitize edilmiş launch command yazar.", + "underHoodItemResume": "Geri yüklemede cmux önce pencereleri ve panelleri yeniden kurar. Otomatik agent resume açıksa, kaydedilen session ID ile agent'ın native resume komutunu çalıştıran tek seferlik shell komutu başlatır.", + "underHoodMore": "Normal <configLink>configuration docs</configLink> cmux.json'u kapsar. Session restore app layout'unu Ghostty terminal rendering ayarlarından ayrı tutar." } }, "legal": { diff --git a/web/messages/uk.json b/web/messages/uk.json index efa0fd206cb1..38f150f30fc7 100644 --- a/web/messages/uk.json +++ b/web/messages/uk.json @@ -786,6 +786,7 @@ "gettingStarted": "Початок роботи", "concepts": "Концепції", "configuration": "Конфігурація", + "sessionRestore": "Відновлення сесії", "customCommands": "Користувацькі команди", "keyboardShortcuts": "Клавіатурні скорочення", "apiReference": "Довідник API", @@ -831,6 +832,49 @@ "agentPromptIntro": "Використовуйте цей промпт, коли хочете, щоб coding agent створив елементи Dock. Він просить агента виконати `cmux docs dock`, оглянути проєкт і запитати перед тим, як припускати.", "agentPromptCodeTitle": "Промпт агента", "agentPrompt": "Set up cmux Dock controls for the current context.\n\nFirst, learn the feature before editing:\n1. Run `cmux docs dock` if the cmux CLI is available. If it is not, read https://cmux.com/docs/dock.\n2. Inspect the repository or current directory to understand the project type, scripts, package manager, dev servers, logs, task runners, test commands, and any existing TUI tools.\n3. If the desired Dock is ambiguous, ask the user what they want monitored or controlled before writing files.\n\nDock is cmux's right-sidebar terminal control area. A Dock config is JSON with a top-level `controls` array. Each control runs a command in its own Ghostty-backed terminal section using the user's login shell. Controls are useful for project dashboards, git/status views, dev server or build status, test watchers, log tails, queues, local services, or a custom TUI such as `cmux feed tui --opentui` when that feed is useful.\n\nChoose where to write the config:\n- In a repository or project directory, create or edit `.cmux/dock.json` so teammates can share it.\n- For a personal default outside a repo, create or edit `~/.config/cmux/dock.json`.\n- If both exist, project `.cmux/dock.json` is more specific for that project. Nested project configs apply to that directory tree; use the nearest relevant project config instead of writing unrelated controls globally.\n- If there is no repo and no clear project root, use the global config only after confirming the user wants a personal Dock.\n\nSchema:\n{\n \"controls\": [\n {\n \"id\": \"short-stable-id\",\n \"title\": \"Human label\",\n \"command\": \"safe command to run\",\n \"cwd\": \"optional/path\",\n \"height\": 220,\n \"env\": { \"NAME\": \"value\" }\n }\n ]\n}\n\nRules:\n- Keep ids stable, lowercase, and unique.\n- Use `cwd` for subdirectories; relative paths resolve from the config base.\n- Use `height` only when a control needs a fixed amount of vertical space.\n- Use `env` only for non-secret values needed by one control.\n- Do not put secrets, tokens, or machine-specific private paths in a shared project config.\n- Prefer commands that are safe to start repeatedly and make sense in a terminal.\n- Do not invent unavailable scripts. Read package files, Makefiles, Procfiles, README docs, config files, and existing tooling first.\n- Keep shared project Docks portable for teammates. Put personal or machine-specific controls in the global Dock.\n\nDeliverable:\n- Create or update the appropriate dock.json.\n- Preserve existing useful controls unless the user asked to replace them.\n- Validate that the JSON parses.\n- Summarize what each control does and any commands the user should review before trusting the Dock config." + }, + "sessionRestore": { + "title": "Відновлення сесії", + "metaTitle": "Відновлення сесії", + "metaDescription": "Як cmux після перезапуску відновлює вікна, workspaces, панелі, робочі каталоги, scrollback, історію браузера та підтримувані сесії агентів.", + "intro": "cmux зберігає форму вашої роботи, щоб після повторного запуску застосунок повернув ті самі вікна, workspaces, панелі, контекст термінала і стан браузера.", + "restoredTitle": "Що відновлює cmux", + "restoredDesc": "Після повторного запуску cmux відновлює layout і метадані, якими керує застосунок:", + "restoredItemLayout": "Layout вікон, workspaces і панелей", + "restoredItemCwd": "Робочі каталоги", + "restoredItemScrollback": "Terminal scrollback, best effort", + "restoredItemBrowser": "URL браузера та історію навігації", + "liveProcessCallout": "cmux не checkpoint-ить довільний стан живих процесів. tmux, vim, shell і непідтримувані термінальні застосунки відкриваються як звичайні термінали, якщо не мають власної cmux resume integration.", + "agentResumeTitle": "Відновлення сесій агентів", + "agentResumeDesc": "Підтримувані AI coding agents можуть відновлюватися, коли cmux захопив native session ID агента. Встановіть hooks після agent CLI, щоб binary був у PATH:", + "setupBehavior": "cmux hooks setup встановлює всі знайдені підтримувані integration і друкує підсумок пропущених агентів. Використовуйте назву агента, якщо потрібна лише одна integration.", + "surfaceBindingsTitle": "Власні команди surface resume", + "surfaceBindingsDesc": "Досвідчені користувачі та integration можуть прив'язати будь-яку terminal surface до команди перезапуску. cmux зберігає прив'язки, створені публічним CLI або socket, для перевірки й ручного відновлення.", + "surfaceBindingsNote": "Затверджені префікси можна переглянути або змінити в Settings > Terminal > Resume Commands. cmux автоматично запускає лише resume binding, позначені як довірені, наприклад tmux-прив'язки, виявлені з живих процесів, або префікси, затверджені користувачем. cmux і надалі не checkpoint-ить довільну пам'ять процесів. Чутливі ключі середовища, як-от токени, паролі, секрети та API-ключі, відкидаються перед збереженням прив’язки відновлення. Схвалення також прив’язуються до робочого каталогу й точних значень середовища, якщо вони є.", + "supportedTitle": "Підтримувані агенти", + "agentHeader": "Агент", + "binaryHeader": "Binary", + "resumeHeader": "Команда resume", + "feedHeader": "Feed bridge", + "feedPermissionRequest": "PermissionRequest", + "feedPreToolUsePermissionRequest": "PreToolUse, PermissionRequest", + "feedBeforeShellExecution": "beforeShellExecution", + "feedPreToolUse": "PreToolUse", + "pluginEventBus": "plugin event bus", + "none": "немає", + "supportedNote": "Claude Code обробляється cmux Claude wrapper, коли Claude integration увімкнена в Settings. Rovo Dev також приймає rovo як setup alias.", + "manualTitle": "Ручне відновлення", + "manualDesc": "cmux відновлює останній збережений snapshot під час звичайного запуску. Попередній snapshot також можна застосувати вручну:", + "manualItemMenu": "File > Reopen Previous Session", + "disableTitle": "Вимкнути автоматичне відновлення агентів", + "disableDesc": "Щоб відновити панелі без запуску збережених agent resume commands, вимкніть Settings > Terminal > Resume Agent Sessions on Reopen або задайте:", + "disableNote": "Це вимикає лише команди resume агентів. cmux все одно відновлює layout, робочі каталоги, scrollback і історію браузера.", + "underHoodTitle": "Як це працює", + "underHoodItemSnapshot": "cmux записує versioned JSON snapshot у ~/Library/Application Support/cmux/session-<bundle-id>.json, а також previous-session cache для ручного reopen.", + "underHoodItemScrollback": "Terminal scrollback зберігається як обмежений текст і відтворюється через тимчасовий файл під час відновлення. Це best effort, бо термінальні застосунки можуть перемалювати або очистити екран.", + "underHoodItemHooks": "Agent hooks записують ~/.cmuxterm/<agent>-hook-sessions.json з agent session ID, cmux workspace ID, surface ID, cwd, process ID за наявності та sanitized launch command.", + "underHoodItemResume": "Під час відновлення cmux спочатку перебудовує вікна й панелі. Якщо автоматичне відновлення агентів увімкнене, він запускає одноразову shell-команду, яка виконує native resume command агента зі збереженим session ID.", + "underHoodMore": "Звичайна <configLink>документація конфігурації</configLink> описує cmux.json. Session restore тримає app layout окремо від налаштувань рендерингу термінала Ghostty." } }, "legal": { diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json index 7898b77a285b..8c67839c8ee0 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -701,6 +701,7 @@ "gettingStarted": "入门指南", "concepts": "核心概念", "configuration": "配置", + "sessionRestore": "会话恢复", "customCommands": "自定义命令", "keyboardShortcuts": "快捷键", "apiReference": "API 参考", @@ -746,6 +747,49 @@ "agentPromptIntro": "当你希望编码 agent 创建 Dock 控件时使用此提示。它会要求 agent 运行 `cmux docs dock`、检查项目,并在猜测前先提问。", "agentPromptCodeTitle": "Agent prompt", "agentPrompt": "Set up cmux Dock controls for the current context.\n\nFirst, learn the feature before editing:\n1. Run `cmux docs dock` if the cmux CLI is available. If it is not, read https://cmux.com/docs/dock.\n2. Inspect the repository or current directory to understand the project type, scripts, package manager, dev servers, logs, task runners, test commands, and any existing TUI tools.\n3. If the desired Dock is ambiguous, ask the user what they want monitored or controlled before writing files.\n\nDock is cmux's right-sidebar terminal control area. A Dock config is JSON with a top-level `controls` array. Each control runs a command in its own Ghostty-backed terminal section using the user's login shell. Controls are useful for project dashboards, git/status views, dev server or build status, test watchers, log tails, queues, local services, or a custom TUI such as `cmux feed tui --opentui` when that feed is useful.\n\nChoose where to write the config:\n- In a repository or project directory, create or edit `.cmux/dock.json` so teammates can share it.\n- For a personal default outside a repo, create or edit `~/.config/cmux/dock.json`.\n- If both exist, project `.cmux/dock.json` is more specific for that project. Nested project configs apply to that directory tree; use the nearest relevant project config instead of writing unrelated controls globally.\n- If there is no repo and no clear project root, use the global config only after confirming the user wants a personal Dock.\n\nSchema:\n{\n \"controls\": [\n {\n \"id\": \"short-stable-id\",\n \"title\": \"Human label\",\n \"command\": \"safe command to run\",\n \"cwd\": \"optional/path\",\n \"height\": 220,\n \"env\": { \"NAME\": \"value\" }\n }\n ]\n}\n\nRules:\n- Keep ids stable, lowercase, and unique.\n- Use `cwd` for subdirectories; relative paths resolve from the config base.\n- Use `height` only when a control needs a fixed amount of vertical space.\n- Use `env` only for non-secret values needed by one control.\n- Do not put secrets, tokens, or machine-specific private paths in a shared project config.\n- Prefer commands that are safe to start repeatedly and make sense in a terminal.\n- Do not invent unavailable scripts. Read package files, Makefiles, Procfiles, README docs, config files, and existing tooling first.\n- Keep shared project Docks portable for teammates. Put personal or machine-specific controls in the global Dock.\n\nDeliverable:\n- Create or update the appropriate dock.json.\n- Preserve existing useful controls unless the user asked to replace them.\n- Validate that the JSON parses.\n- Summarize what each control does and any commands the user should review before trusting the Dock config." + }, + "sessionRestore": { + "title": "会话恢复", + "metaTitle": "会话恢复", + "metaDescription": "cmux 在重新启动后如何恢复窗口、workspace、pane、工作目录、scrollback、浏览器历史和受支持的 agent 会话。", + "intro": "cmux 会保存你的工作形态,让应用重新启动时能带回相同的窗口、workspace、pane、终端上下文和浏览器状态。", + "restoredTitle": "cmux 会恢复什么", + "restoredDesc": "重新启动后,cmux 会恢复应用拥有的布局和元数据:", + "restoredItemLayout": "窗口、workspace 和 pane 布局", + "restoredItemCwd": "工作目录", + "restoredItemScrollback": "终端 scrollback,尽力恢复", + "restoredItemBrowser": "浏览器 URL 和导航历史", + "liveProcessCallout": "cmux 不会 checkpoint 任意实时进程状态。tmux、vim、shell 和未支持的终端应用会作为普通终端重新打开,除非它们有自己的 cmux resume 集成。", + "agentResumeTitle": "Agent 会话恢复", + "agentResumeDesc": "受支持的 AI coding agent 在 cmux 捕获到 agent 的原生会话 ID 后可以恢复。请在安装 agent CLI 后安装 hooks,确保二进制在 PATH 上:", + "setupBehavior": "cmux hooks setup 会安装它能找到的所有受支持集成,并打印被跳过 agent 的摘要。只需要一个集成时请指定 agent 名称。", + "surfaceBindingsTitle": "自定义 surface resume 命令", + "surfaceBindingsDesc": "高级用户和集成可以把任意终端 surface 绑定到重启命令。cmux 会保存通过公开 CLI 或 socket 创建的绑定,用于检查和手动恢复。", + "surfaceBindingsNote": "已批准的前缀可在 Settings > Terminal > Resume Commands 中查看或编辑。cmux 只会自动运行被标记为可信的 resume 绑定,例如从运行中进程检测到的 tmux 绑定或用户批准的前缀。cmux 仍然不会 checkpoint 任意进程内存。 令牌、密码、密钥和 API key 等敏感环境变量键会在保存恢复绑定前被丢弃。 批准项也会绑定到工作目录以及存在时的精确环境变量值。", + "supportedTitle": "受支持的 agents", + "agentHeader": "Agent", + "binaryHeader": "二进制", + "resumeHeader": "Resume 命令", + "feedHeader": "Feed bridge", + "feedPermissionRequest": "PermissionRequest", + "feedPreToolUsePermissionRequest": "PreToolUse, PermissionRequest", + "feedBeforeShellExecution": "beforeShellExecution", + "feedPreToolUse": "PreToolUse", + "pluginEventBus": "plugin event bus", + "none": "无", + "supportedNote": "启用 Claude 集成时,Claude Code 由 cmux Claude wrapper 处理。Rovo Dev 也接受 rovo 作为 setup 别名。", + "manualTitle": "手动恢复", + "manualDesc": "cmux 正常启动时会恢复最后保存的 snapshot。你也可以手动重新应用上一个 snapshot:", + "manualItemMenu": "File > Reopen Previous Session", + "disableTitle": "关闭 agent 自动恢复", + "disableDesc": "如需只恢复 pane 而不启动保存的 agent resume 命令,请关闭 Settings > Terminal > Resume Agent Sessions on Reopen 或设置:", + "disableNote": "这只会关闭 agent resume 命令。cmux 仍会恢复布局、工作目录、scrollback 和浏览器历史。", + "underHoodTitle": "工作原理", + "underHoodItemSnapshot": "cmux 会把带版本的 JSON snapshot 写入 ~/Library/Application Support/cmux/session-<bundle-id>.json,并保存一个 previous-session cache 供手动重新打开。", + "underHoodItemScrollback": "终端 scrollback 会作为有界文本保存,并在恢复时通过临时文件 replay。这是尽力恢复,因为终端应用可以重绘或清屏。", + "underHoodItemHooks": "Agent hooks 会写入 ~/.cmuxterm/<agent>-hook-sessions.json,其中包含 agent 会话 ID、cmux workspace ID、surface ID、cwd、可用时的进程 ID,以及经过清理的启动命令。", + "underHoodItemResume": "恢复时,cmux 会先重建窗口和 pane。如果 agent 自动恢复已启用,它会启动一个一次性 shell 命令,用保存的会话 ID 运行 agent 的原生 resume 命令。", + "underHoodMore": "常规<configLink>配置文档</configLink>介绍 cmux.json。会话恢复会把应用布局与 Ghostty 终端渲染设置分开保存。" } }, "legal": { diff --git a/web/messages/zh-TW.json b/web/messages/zh-TW.json index f317d0611b73..0ff43b9ba429 100644 --- a/web/messages/zh-TW.json +++ b/web/messages/zh-TW.json @@ -701,6 +701,7 @@ "gettingStarted": "入門指南", "concepts": "概念", "configuration": "設定", + "sessionRestore": "工作階段還原", "customCommands": "自訂指令", "keyboardShortcuts": "鍵盤快捷鍵", "apiReference": "API 參考", @@ -746,6 +747,49 @@ "agentPromptIntro": "當你希望 coding agent 建立 Dock 控制項時,請使用這段 prompt。它會要求 agent 執行 `cmux docs dock`、檢查專案,並在猜測前先提問。", "agentPromptCodeTitle": "Agent prompt", "agentPrompt": "Set up cmux Dock controls for the current context.\n\nFirst, learn the feature before editing:\n1. Run `cmux docs dock` if the cmux CLI is available. If it is not, read https://cmux.com/docs/dock.\n2. Inspect the repository or current directory to understand the project type, scripts, package manager, dev servers, logs, task runners, test commands, and any existing TUI tools.\n3. If the desired Dock is ambiguous, ask the user what they want monitored or controlled before writing files.\n\nDock is cmux's right-sidebar terminal control area. A Dock config is JSON with a top-level `controls` array. Each control runs a command in its own Ghostty-backed terminal section using the user's login shell. Controls are useful for project dashboards, git/status views, dev server or build status, test watchers, log tails, queues, local services, or a custom TUI such as `cmux feed tui --opentui` when that feed is useful.\n\nChoose where to write the config:\n- In a repository or project directory, create or edit `.cmux/dock.json` so teammates can share it.\n- For a personal default outside a repo, create or edit `~/.config/cmux/dock.json`.\n- If both exist, project `.cmux/dock.json` is more specific for that project. Nested project configs apply to that directory tree; use the nearest relevant project config instead of writing unrelated controls globally.\n- If there is no repo and no clear project root, use the global config only after confirming the user wants a personal Dock.\n\nSchema:\n{\n \"controls\": [\n {\n \"id\": \"short-stable-id\",\n \"title\": \"Human label\",\n \"command\": \"safe command to run\",\n \"cwd\": \"optional/path\",\n \"height\": 220,\n \"env\": { \"NAME\": \"value\" }\n }\n ]\n}\n\nRules:\n- Keep ids stable, lowercase, and unique.\n- Use `cwd` for subdirectories; relative paths resolve from the config base.\n- Use `height` only when a control needs a fixed amount of vertical space.\n- Use `env` only for non-secret values needed by one control.\n- Do not put secrets, tokens, or machine-specific private paths in a shared project config.\n- Prefer commands that are safe to start repeatedly and make sense in a terminal.\n- Do not invent unavailable scripts. Read package files, Makefiles, Procfiles, README docs, config files, and existing tooling first.\n- Keep shared project Docks portable for teammates. Put personal or machine-specific controls in the global Dock.\n\nDeliverable:\n- Create or update the appropriate dock.json.\n- Preserve existing useful controls unless the user asked to replace them.\n- Validate that the JSON parses.\n- Summarize what each control does and any commands the user should review before trusting the Dock config." + }, + "sessionRestore": { + "title": "工作階段還原", + "metaTitle": "工作階段還原", + "metaDescription": "cmux 在重新啟動後如何還原視窗、workspace、pane、工作目錄、scrollback、瀏覽器歷史與支援的 agent 工作階段。", + "intro": "cmux 會儲存你的工作形態,讓應用程式重新啟動時帶回相同的視窗、workspace、pane、終端機脈絡和瀏覽器狀態。", + "restoredTitle": "cmux 會還原什麼", + "restoredDesc": "重新啟動後,cmux 會還原應用程式擁有的佈局與中繼資料:", + "restoredItemLayout": "視窗、workspace 與 pane 佈局", + "restoredItemCwd": "工作目錄", + "restoredItemScrollback": "終端機 scrollback,盡力還原", + "restoredItemBrowser": "瀏覽器 URL 與瀏覽歷史", + "liveProcessCallout": "cmux 不會 checkpoint 任意即時程序狀態。tmux、vim、shell 和未支援的終端機應用程式會作為一般終端機重新開啟,除非它們有自己的 cmux resume 整合。", + "agentResumeTitle": "Agent 工作階段還原", + "agentResumeDesc": "支援的 AI coding agent 在 cmux 擷取到 agent 的原生工作階段 ID 後可以還原。請在安裝 agent CLI 後安裝 hooks,確保 binary 位於 PATH:", + "setupBehavior": "cmux hooks setup 會安裝它找到的所有支援整合,並列印略過 agent 的摘要。只需要一個整合時請指定 agent 名稱。", + "surfaceBindingsTitle": "自訂 surface resume 命令", + "surfaceBindingsDesc": "進階使用者和整合可以把任何終端機 surface 綁定到重新啟動命令。cmux 會儲存透過公開 CLI 或 socket 建立的綁定,供檢查與手動還原。", + "surfaceBindingsNote": "已核准的前綴可在 Settings > Terminal > Resume Commands 中檢視或編輯。cmux 只會自動執行標記為可信任的 resume 綁定,例如從執行中程序偵測到的 tmux 綁定或使用者核准的前綴。cmux 仍然不會 checkpoint 任意程序記憶體。 權杖、密碼、祕密和 API key 等敏感環境變數鍵,會在儲存還原綁定前被丟棄。 核准項目也會綁定工作目錄,以及存在時的精確環境變數值。", + "supportedTitle": "支援的 agents", + "agentHeader": "Agent", + "binaryHeader": "Binary", + "resumeHeader": "Resume 命令", + "feedHeader": "Feed bridge", + "feedPermissionRequest": "PermissionRequest", + "feedPreToolUsePermissionRequest": "PreToolUse, PermissionRequest", + "feedBeforeShellExecution": "beforeShellExecution", + "feedPreToolUse": "PreToolUse", + "pluginEventBus": "plugin event bus", + "none": "無", + "supportedNote": "啟用 Claude 整合時,Claude Code 由 cmux Claude wrapper 處理。Rovo Dev 也接受 rovo 作為 setup 別名。", + "manualTitle": "手動還原", + "manualDesc": "cmux 正常啟動時會還原最後儲存的 snapshot。你也可以手動重新套用上一個 snapshot:", + "manualItemMenu": "File > Reopen Previous Session", + "disableTitle": "停用 agent 自動還原", + "disableDesc": "若要還原 pane 而不啟動已儲存的 agent resume 命令,請關閉 Settings > Terminal > Resume Agent Sessions on Reopen 或設定:", + "disableNote": "這只會停用 agent resume 命令。cmux 仍會還原佈局、工作目錄、scrollback 和瀏覽器歷史。", + "underHoodTitle": "運作方式", + "underHoodItemSnapshot": "cmux 會把帶版本的 JSON snapshot 寫入 ~/Library/Application Support/cmux/session-<bundle-id>.json,並儲存 previous-session cache 供手動重新開啟。", + "underHoodItemScrollback": "終端機 scrollback 會以有界文字儲存,並在還原時透過暫存檔 replay。這是盡力還原,因為終端機應用程式可以重繪或清除畫面。", + "underHoodItemHooks": "Agent hooks 會寫入 ~/.cmuxterm/<agent>-hook-sessions.json,內容包含 agent 工作階段 ID、cmux workspace ID、surface ID、cwd、可用時的程序 ID,以及清理過的啟動命令。", + "underHoodItemResume": "還原時,cmux 會先重建視窗和 pane。如果 agent 自動還原已啟用,它會啟動一次性 shell 命令,用儲存的工作階段 ID 執行 agent 的原生 resume 命令。", + "underHoodMore": "一般<configLink>設定文件</configLink>說明 cmux.json。工作階段還原會將應用程式佈局與 Ghostty 終端機渲染設定分開保存。" } }, "legal": {