diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e0e96d6..311babe9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### ✨ Features +- **macOS**: Modernized the Codex agent hook to use low-noise `UserPromptSubmit` and `Stop` registrations in `~/.codex/hooks.json`. Existing Mori `notify` registrations migrate during refresh; unrelated hooks, notify entries, and config-file symlinks are preserved. +- **macOS**: Pi now waits for `agent_settled` before reporting waiting, and Claude Code/Droid refreshes remove only Mori's obsolete `PreToolUse` command while preserving unrelated hooks. - **iOS (MoriRemote)**: Rebuilt the remote companion around a secure, pane-native tmux control runtime. It supports saved workspaces, password or OpenSSH private-key authentication, explicit host-key trust, local terminal history/selection, agent status, and adaptive iPhone/iPad presentation. MoriRemote now follows Remux client sizing: phone viewport and software-keyboard changes resize/reflow the shared tmux window, so other attached Mac clients may visibly reflow. - **iOS (MoriRemote)**: Restored the current Remux three-capsule terminal dock and its measured safe-area layout: Ctrl/Esc/Tab, Sessions/Agents, and Home/Keypad/Keyboard. Sessions opens the complete Navigator, Agents jumps directly to agent status, Home opens the library, and Keypad retains terminal shortcuts and photo/clipboard image upload. Modal forms still suspend the terminal responder, and Chinese and other IMEs still commit marked text exactly once. - **iOS (MoriRemote)**: Server profiles now discover their live tmux sessions after SSH login, so users choose a session instead of manually creating workspace records. The terminal’s Sessions button refreshes and lists every tmux session on the current host, including sessions not yet connected on the phone. diff --git a/CHANGELOG.zh-Hans.md b/CHANGELOG.zh-Hans.md index 505f1807..923947a9 100644 --- a/CHANGELOG.zh-Hans.md +++ b/CHANGELOG.zh-Hans.md @@ -9,6 +9,8 @@ ### ✨ 新功能 +- **macOS**:Codex agent hook 改用 `~/.codex/hooks.json` 中低噪声的 `UserPromptSubmit` 和 `Stop` 注册。刷新时会迁移已有 Mori `notify` 注册,同时保留无关 hook、notify 条目和配置文件软链接。 +- **macOS**:Pi 现等待 `agent_settled` 后才报告等待;Claude Code/Droid 刷新时仅移除 Mori 过时的 `PreToolUse` 命令,并保留无关钩子。 - **iOS(MoriRemote)**:远程伴侣已重构为安全、以 pane 为原生单位的 tmux 控制运行时。支持保存工作区、密码或 OpenSSH 私钥认证、显式主机密钥信任、本地终端历史/选择、agent 状态和自适应 iPhone/iPad 界面。MoriRemote 现遵循 Remux 的客户端尺寸规则:手机视口和软件键盘变化会调整/重排共享 tmux 窗口,因此其他已连接的 Mac 客户端也可能发生可见重排。 - **iOS(MoriRemote)**:恢复当前 Remux 的三胶囊终端 Dock 与按实际高度预留的安全区布局:Ctrl/Esc/Tab、会话/Agent、主页/按键面板/键盘。会话打开完整 Navigator,Agent 直接进入状态列表;主页打开资源库;按键面板继续提供终端快捷键和照片/剪贴板图片上传。模态表单仍会暂停终端响应器,中文等输入法的组合文本仍只会提交一次。 - **iOS(MoriRemote)**:服务器配置现在会在 SSH 登录后自动发现实时 tmux 会话,用户直接选择,不再需要手动创建工作区记录。终端的 Sessions 按钮会刷新并列出当前主机上的全部 tmux 会话,包括手机尚未连接的会话。 diff --git a/Packages/MoriUI/Sources/MoriUI/GhosttySettingsView.swift b/Packages/MoriUI/Sources/MoriUI/GhosttySettingsView.swift index 80ddc6ba..5b332f6f 100644 --- a/Packages/MoriUI/Sources/MoriUI/GhosttySettingsView.swift +++ b/Packages/MoriUI/Sources/MoriUI/GhosttySettingsView.swift @@ -1804,28 +1804,28 @@ private struct AgentHookSettingsContent: View { agentCard( name: .localized("Claude Code"), icon: "terminal", - description: .localized("Adds hooks to ~/.claude/settings.json for prompt submit, tool use, stop, and notification events."), + description: .localized("Adds low-noise prompt submit, stop, and notification hooks to ~/.claude/settings.json."), isEnabled: $model.claudeEnabled ) agentCard( name: .localized("Codex CLI"), icon: "chevron.left.forwardslash.chevron.right", - description: .localized("Adds a notify entry to ~/.codex/config.toml for agent turn completion events."), + description: .localized("Adds low-noise prompt and stop hooks to ~/.codex/hooks.json."), isEnabled: $model.codexEnabled ) agentCard( name: .localized("Pi"), icon: "sparkle", - description: .localized("Registers an extension in Pi's settings.json for agent start, end, and tool execution events."), + description: .localized("Registers an extension in Pi's settings.json for agent start and settled events."), isEnabled: $model.piEnabled ) agentCard( name: .localized("Droid"), icon: "cpu", - description: .localized("Adds hooks to ~/.factory/settings.json for prompt submit, tool use, stop, and notification events."), + description: .localized("Adds low-noise prompt submit, stop, and notification hooks to ~/.factory/settings.json."), isEnabled: $model.droidEnabled ) } diff --git a/Packages/MoriUI/Sources/MoriUI/Resources/en.lproj/Localizable.strings b/Packages/MoriUI/Sources/MoriUI/Resources/en.lproj/Localizable.strings index d1be5b75..6cc8a4d1 100644 --- a/Packages/MoriUI/Sources/MoriUI/Resources/en.lproj/Localizable.strings +++ b/Packages/MoriUI/Sources/MoriUI/Resources/en.lproj/Localizable.strings @@ -6,8 +6,8 @@ "Open Project (⇧⌘O)" = "Open Project (⇧⌘O)"; "Agent Dashboard (⇧⌘A)" = "Agent Dashboard (⇧⌘A)"; "Settings (⌘,)" = "Settings (⌘,)"; -"Adds a notify entry to ~/.codex/config.toml for agent turn completion events." = "Adds a notify entry to ~/.codex/config.toml for agent turn completion events."; -"Adds hooks to ~/.claude/settings.json for prompt submit, tool use, stop, and notification events." = "Adds hooks to ~/.claude/settings.json for prompt submit, tool use, stop, and notification events."; +"Adds low-noise prompt and stop hooks to ~/.codex/hooks.json." = "Adds low-noise prompt and stop hooks to ~/.codex/hooks.json."; +"Adds low-noise prompt submit, stop, and notification hooks to ~/.claude/settings.json." = "Adds low-noise prompt submit, stop, and notification hooks to ~/.claude/settings.json."; "Agent" = "Agent"; "Attention" = "Attention"; "Agent Hooks" = "Agent Hooks"; @@ -48,7 +48,7 @@ "Directional Pane Nav" = "Directional Pane Nav"; "Distribute extra padding evenly around the terminal content to center it within the window." = "Distribute extra padding evenly around the terminal content to center it within the window."; "Droid" = "Droid"; -"Adds hooks to ~/.factory/settings.json for prompt submit, tool use, stop, and notification events." = "Adds hooks to ~/.factory/settings.json for prompt submit, tool use, stop, and notification events."; +"Adds low-noise prompt submit, stop, and notification hooks to ~/.factory/settings.json." = "Adds low-noise prompt submit, stop, and notification hooks to ~/.factory/settings.json."; "Equalize Panes" = "Equalize Panes"; "Error" = "Error"; "Expand Projects" = "Expand Projects"; @@ -94,7 +94,7 @@ "Previous Pane" = "Previous Pane"; "Previous Tab" = "Previous Tab"; "Previous Worktree" = "Previous Worktree"; -"Registers an extension in Pi's settings.json for agent start, end, and tool execution events." = "Registers an extension in Pi's settings.json for agent start, end, and tool execution events."; +"Registers an extension in Pi's settings.json for agent start and settled events." = "Registers an extension in Pi's settings.json for agent start and settled events."; "Remove Project…" = "Remove Project…"; "Import Existing Worktrees" = "Import Existing Worktrees"; "Remove Worktree…" = "Remove Worktree…"; diff --git a/Packages/MoriUI/Sources/MoriUI/Resources/zh-Hans.lproj/Localizable.strings b/Packages/MoriUI/Sources/MoriUI/Resources/zh-Hans.lproj/Localizable.strings index a6d98d40..d10a6a5e 100644 --- a/Packages/MoriUI/Sources/MoriUI/Resources/zh-Hans.lproj/Localizable.strings +++ b/Packages/MoriUI/Sources/MoriUI/Resources/zh-Hans.lproj/Localizable.strings @@ -6,8 +6,8 @@ "Open Project (⇧⌘O)" = "打开项目 (⇧⌘O)"; "Agent Dashboard (⇧⌘A)" = "代理面板 (⇧⌘A)"; "Settings (⌘,)" = "设置 (⌘,)"; -"Adds a notify entry to ~/.codex/config.toml for agent turn completion events." = "向 ~/.codex/config.toml 添加通知条目,用于代理回合完成事件。"; -"Adds hooks to ~/.claude/settings.json for prompt submit, tool use, stop, and notification events." = "向 ~/.claude/settings.json 添加钩子,用于提示提交、工具使用、停止和通知事件。"; +"Adds low-noise prompt and stop hooks to ~/.codex/hooks.json." = "向 ~/.codex/hooks.json 添加低噪声的提示提交和停止钩子。"; +"Adds low-noise prompt submit, stop, and notification hooks to ~/.claude/settings.json." = "向 ~/.claude/settings.json 添加低噪声的提示提交、停止和通知钩子。"; "Agent" = "代理"; "Attention" = "待处理"; "Agent Hooks" = "代理钩子"; @@ -48,7 +48,7 @@ "Directional Pane Nav" = "方向键面板导航"; "Distribute extra padding evenly around the terminal content to center it within the window." = "在终端内容周围均匀分配额外边距,使其在窗口中居中。"; "Droid" = "Droid"; -"Adds hooks to ~/.factory/settings.json for prompt submit, tool use, stop, and notification events." = "向 ~/.factory/settings.json 添加钩子,用于提示提交、工具使用、停止和通知事件。"; +"Adds low-noise prompt submit, stop, and notification hooks to ~/.factory/settings.json." = "向 ~/.factory/settings.json 添加低噪声的提示提交、停止和通知钩子。"; "Equalize Panes" = "均分面板"; "Error" = "错误"; "Expand Projects" = "展开项目列表"; @@ -94,7 +94,7 @@ "Previous Pane" = "上一个面板"; "Previous Tab" = "上一个标签页"; "Previous Worktree" = "上一个工作区"; -"Registers an extension in Pi's settings.json for agent start, end, and tool execution events." = "在 Pi 的 settings.json 中注册扩展,用于代理启动、结束和工具执行事件。"; +"Registers an extension in Pi's settings.json for agent start and settled events." = "在 Pi 的 settings.json 中注册扩展,用于代理启动和完全静止事件。"; "Remove Project…" = "移除项目…"; "Import Existing Worktrees" = "导入已有 Worktree"; "Remove Worktree…" = "移除工作树…"; diff --git a/Sources/Mori/App/AgentHookConfigurator.swift b/Sources/Mori/App/AgentHookConfigurator.swift index 28519647..30dea537 100644 --- a/Sources/Mori/App/AgentHookConfigurator.swift +++ b/Sources/Mori/App/AgentHookConfigurator.swift @@ -21,6 +21,12 @@ enum AgentHookConfigurator { /// Droid hook event names (same lifecycle events as Claude Code). private static let droidEvents = ["UserPromptSubmit", "Stop", "Notification"] + /// Removed in 30dca6a; migrate existing Mori registrations without touching other hooks. + private static let obsoleteToolUseEvent = "PreToolUse" + + /// Codex's low-noise lifecycle events. Tool-level events intentionally remain unregistered. + private static let codexEvents = ["UserPromptSubmit", "Stop"] + private static let home = FileManager.default.homeDirectoryForCurrentUser /// Mori config directory: $XDG_CONFIG_HOME/mori or ~/.config/mori @@ -43,6 +49,22 @@ enum AgentHookConfigurator { hooksDir.appendingPathComponent("mori-codex-hook.sh").path } + private static var codexHooksURL: URL { + home.appendingPathComponent(".codex/hooks.json") + } + + private static var codexConfigURL: URL { + home.appendingPathComponent(".codex/config.toml") + } + + /// The default path also recognizes installations created before an XDG override. + private static var knownCodexHookPaths: Set { + [ + codexHookPath, + home.appendingPathComponent(".config/mori/hooks/mori-codex-hook.sh").path, + ] + } + private static var droidHookPath: String { hooksDir.appendingPathComponent("mori-droid-hook.sh").path } @@ -93,11 +115,9 @@ enum AgentHookConfigurator { return hookEntryExists(in: hooks, event: "Stop", command: "\(claudeHookPath) Stop") } - /// Check if Codex CLI hook is installed in ~/.codex/config.toml. + /// Check modern hooks.json and the legacy top-level TOML notify registration. static func isCodexHookInstalled() -> Bool { - let configURL = home.appendingPathComponent(".codex/config.toml") - guard let content = try? String(contentsOf: configURL, encoding: .utf8) else { return false } - return content.contains(codexHookPath) + isModernCodexHookInstalled() || legacyCodexHookInstalled() } /// Check if Droid hooks are installed in ~/.factory/settings.json. @@ -137,7 +157,10 @@ enum AgentHookConfigurator { installCommonScript() guard let source = loadBundledResource("mori-codex-hook", ext: "sh"), let path = installScript(name: "mori-codex-hook", source: source) else { return } - configureCodexSettings(hookPath: path) + // Establish the replacement first. Cross-file migration cannot be atomic, so a + // temporary duplicate is safer than dropping state reporting on a failed write. + guard configureCodexHooks(hookPath: path) else { return } + _ = removeLegacyCodexHookRegistration() } /// Install Droid hook and register in ~/.factory/settings.json. @@ -166,9 +189,8 @@ enum AgentHookConfigurator { var json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], var hooks = json["hooks"] as? [String: Any] { var changed = false - for event in claudeEvents { - let command = "\(claudeHookPath) \(event)" - if removeHookEntry(from: &hooks, event: event, command: command) { + for event in claudeEvents + [obsoleteToolUseEvent] { + if removeHookCommands(from: &hooks, event: event, commands: ["\(claudeHookPath) \(event)"]) { changed = true } } @@ -189,28 +211,9 @@ enum AgentHookConfigurator { try? FileManager.default.removeItem(atPath: claudeHookPath) } - /// Remove Codex CLI hook from ~/.codex/config.toml and delete hook script. + /// Remove modern and legacy Codex registrations, then delete the dedicated script. static func uninstallCodexHook() { - let configURL = home.appendingPathComponent(".codex/config.toml") - if let content = try? String(contentsOf: configURL, encoding: .utf8), - content.contains(codexHookPath) { - var lines = content.components(separatedBy: "\n") - if let idx = lines.firstIndex(where: { $0.hasPrefix("notify") && $0.contains(codexHookPath) }) { - var entries = parseTomlStringArray(lines[idx]) - entries.removeAll { $0 == codexHookPath } - if entries.isEmpty { - // Remove the notify line and the comment above it - lines.remove(at: idx) - if idx > 0 && lines[idx - 1] == "# Mori agent status hook" { - lines.remove(at: idx - 1) - } - } else { - lines[idx] = "notify = [\(entries.map { "\"\($0)\"" }.joined(separator: ", "))]" - } - } - let cleaned = lines.joined(separator: "\n") - try? cleaned.write(to: configURL, atomically: true, encoding: .utf8) - } + guard removeModernCodexHookRegistration(), removeLegacyCodexHookRegistration() else { return } try? FileManager.default.removeItem(atPath: codexHookPath) } @@ -220,9 +223,8 @@ enum AgentHookConfigurator { var json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], var hooks = json["hooks"] as? [String: Any] { var changed = false - for event in droidEvents { - let command = "\(droidHookPath) \(event)" - if removeHookEntry(from: &hooks, event: event, command: command) { + for event in droidEvents + [obsoleteToolUseEvent] { + if removeHookCommands(from: &hooks, event: event, commands: ["\(droidHookPath) \(event)"]) { changed = true } } @@ -312,7 +314,11 @@ enum AgentHookConfigurator { } var hooks = settings["hooks"] as? [String: Any] ?? [:] - var changed = false + var changed = removeHookCommands( + from: &hooks, + event: obsoleteToolUseEvent, + commands: ["\(hookPath) \(obsoleteToolUseEvent)"] + ) for event in claudeEvents { let command = "\(hookPath) \(event)" @@ -334,39 +340,127 @@ enum AgentHookConfigurator { // MARK: - Codex CLI - private static func configureCodexSettings(hookPath: String) { - let configURL = home.appendingPathComponent(".codex/config.toml") + private static func isModernCodexHookInstalled() -> Bool { + guard let data = try? Data(contentsOf: codexHooksURL), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let hooks = json["hooks"] as? [String: Any] else { return false } + return codexEvents.contains { event in + knownCodexHookPaths.contains { path in + hookEntryExists(in: hooks, event: event, command: "\(path) \(event)") + } + } + } - let existing = (try? String(contentsOf: configURL, encoding: .utf8)) ?? "" + private static func legacyCodexHookInstalled() -> Bool { + guard let content = try? String(contentsOf: codexConfigURL, encoding: .utf8) else { return false } + return topLevelNotifyEntries(in: content).contains { entry in + knownCodexHookPaths.contains(entry) || embeddedNotifyContainsMoriHook(entry) + } + } - if existing.contains(hookPath) { return } + /// Upsert only Mori's two commands, retaining every other JSON field and hook. + private static func configureCodexHooks(hookPath: String) -> Bool { + var settings: [String: Any] = [:] + if FileManager.default.fileExists(atPath: codexHooksURL.path) { + guard let data = try? Data(contentsOf: codexHooksURL), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return false } + settings = json + } else { + try? FileManager.default.createDirectory( + at: codexHooksURL.deletingLastPathComponent(), withIntermediateDirectories: true + ) + } - try? FileManager.default.createDirectory( - at: configURL.deletingLastPathComponent(), withIntermediateDirectories: true - ) + var hooks: [String: Any] + if let existingHooks = settings["hooks"] { + guard let parsedHooks = existingHooks as? [String: Any] else { return false } + hooks = parsedHooks + } else { + hooks = [:] + } - var lines = existing.components(separatedBy: "\n") - if let idx = lines.firstIndex(where: { $0.hasPrefix("notify") && $0.contains("[") }) { - // Append to existing notify array, preserving other entries - let existingLine = lines[idx] - let entries = parseTomlStringArray(existingLine) - if !entries.contains(hookPath) { - var updated = entries - updated.append(hookPath) - lines[idx] = "notify = [\(updated.map { "\"\($0)\"" }.joined(separator: ", "))]" + var changed = false + for event in codexEvents { + let command = "\(hookPath) \(event)" + if !hookEntryExists(in: hooks, event: event, command: command) { + var eventHooks: [[String: Any]] + if let existingEventHooks = hooks[event] { + guard let parsedEventHooks = existingEventHooks as? [[String: Any]] else { return false } + eventHooks = parsedEventHooks + } else { + eventHooks = [] + } + eventHooks.append(["hooks": [["type": "command", "command": command]]]) + hooks[event] = eventHooks + changed = true + } + } + + guard changed else { return true } + settings["hooks"] = hooks + return writeJSON(settings, to: codexHooksURL) + } + + private static func removeModernCodexHookRegistration() -> Bool { + guard FileManager.default.fileExists(atPath: codexHooksURL.path) else { return true } + guard let data = try? Data(contentsOf: codexHooksURL), + var settings = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return false } + guard var hooks = settings["hooks"] as? [String: Any] else { return settings["hooks"] == nil } + + var changed = false + for event in codexEvents { + if removeHookCommands(from: &hooks, event: event, commands: knownCodexHookPaths.map { "\($0) \(event)" }) { + changed = true } + } + guard changed else { return true } + if hooks.isEmpty { + settings.removeValue(forKey: "hooks") } else { - // Insert before the first [section] header to keep it top-level - let notifyLines = ["# Mori agent status hook", "notify = [\"\(hookPath)\"]", ""] - if let sectionIdx = lines.firstIndex(where: { $0.hasPrefix("[") }) { - lines.insert(contentsOf: notifyLines, at: sectionIdx) + settings["hooks"] = hooks + } + return writeJSON(settings, to: codexHooksURL) + } + + /// Remove only Mori paths from the legacy *top-level* `notify` array. + /// A conservative parser means unsupported TOML is left untouched rather than rewritten. + private static func removeLegacyCodexHookRegistration() -> Bool { + guard let content = try? String(contentsOf: codexConfigURL, encoding: .utf8) else { return true } + var lines = content.components(separatedBy: "\n") + var inSection = false + var changed = false + + for index in lines.indices { + let trimmed = lines[index].trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("[") { inSection = true } + guard !inSection, isTopLevelNotifyLine(trimmed), let entries = parseTomlStringArray(lines[index]) else { continue } + + let updated = entries.compactMap { entry -> String? in + if knownCodexHookPaths.contains(entry) { return nil } + guard let payload = entry.data(using: .utf8), + var nested = try? JSONSerialization.jsonObject(with: payload) as? [String] else { return entry } + let originalCount = nested.count + nested.removeAll { knownCodexHookPaths.contains($0) } + guard nested.count != originalCount, + let encoded = try? JSONSerialization.data(withJSONObject: nested), + let value = String(data: encoded, encoding: .utf8) else { return entry } + return value + } + guard updated.count != entries.count || updated != entries else { continue } + if updated.isEmpty { + lines.remove(at: index) + if index > 0, + lines[index - 1].trimmingCharacters(in: .whitespaces) == "# Mori agent status hook" { + lines.remove(at: index - 1) + } } else { - if !lines.last!.isEmpty { lines.append("") } - lines.append(contentsOf: notifyLines) + guard let encoded = encodeTomlStringArray(updated) else { return false } + lines[index] = "notify = \(encoded)" } + changed = true + break // TOML permits one top-level `notify` key. } - let config = lines.joined(separator: "\n") - try? config.write(to: configURL, atomically: true, encoding: .utf8) + return !changed || writeText(lines.joined(separator: "\n"), to: codexConfigURL) } // MARK: - Droid @@ -379,7 +473,11 @@ enum AgentHookConfigurator { } var hooks = settings["hooks"] as? [String: Any] ?? [:] - var changed = false + var changed = removeHookCommands( + from: &hooks, + event: obsoleteToolUseEvent, + commands: ["\(hookPath) \(obsoleteToolUseEvent)"] + ) for event in droidEvents { let command = "\(hookPath) \(event)" @@ -459,40 +557,95 @@ enum AgentHookConfigurator { } @discardableResult - private static func removeHookEntry( - from hooks: inout [String: Any], event: String, command: String - ) -> Bool { - guard var entries = hooks[event] as? [[String: Any]] else { return false } - let originalCount = entries.count - entries.removeAll { entry in - guard let hookList = entry["hooks"] as? [[String: Any]] else { return false } - return hookList.contains { $0["command"] as? String == command } - } - guard entries.count != originalCount else { return false } - hooks[event] = entries - return true - } - - private static func writeJSON(_ object: [String: Any], to url: URL) { + private static func writeJSON(_ object: [String: Any], to url: URL) -> Bool { guard let data = try? JSONSerialization.data( withJSONObject: object, options: [.prettyPrinted, .sortedKeys] - ) else { return } + ) else { return false } // `.atomic` uses rename(), which replaces a symlink target with a // regular file instead of following it. Resolve first so users whose // settings.json is a symlink into a dotfiles repo keep the link intact. let resolved = url.resolvingSymlinksInPath() - try? data.write(to: resolved, options: .atomic) + do { + try data.write(to: resolved, options: .atomic) + return true + } catch { + return false + } } - /// Parse a TOML string array like `notify = ["/a", "/b"]` into `["/a", "/b"]`. - private static func parseTomlStringArray(_ line: String) -> [String] { - guard let open = line.firstIndex(of: "["), - let close = line.lastIndex(of: "]") else { return [] } - let inner = line[line.index(after: open).. Bool { + do { + try text.write(to: url.resolvingSymlinksInPath(), atomically: true, encoding: .utf8) + return true + } catch { + return false } } + + private static func removeHookCommands( + from hooks: inout [String: Any], event: String, commands: [String] + ) -> Bool { + guard var entries = hooks[event] as? [[String: Any]] else { return false } + var changed = false + for index in entries.indices.reversed() { + guard let hookList = entries[index]["hooks"] as? [[String: Any]] else { continue } + let retained = hookList.filter { hook in + guard let command = hook["command"] as? String else { return true } + return !commands.contains(command) + } + guard retained.count != hookList.count else { continue } + changed = true + if retained.isEmpty { + entries.remove(at: index) + } else { + entries[index]["hooks"] = retained + } + } + guard changed else { return false } + if entries.isEmpty { + hooks.removeValue(forKey: event) + } else { + hooks[event] = entries + } + return true + } + + private static func topLevelNotifyEntries(in content: String) -> [String] { + var inSection = false + for line in content.components(separatedBy: "\n") { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("[") { inSection = true } + guard !inSection, isTopLevelNotifyLine(trimmed), let entries = parseTomlStringArray(line) else { continue } + return entries + } + return [] + } + + private static func embeddedNotifyContainsMoriHook(_ entry: String) -> Bool { + guard let payload = entry.data(using: .utf8), + let nested = try? JSONSerialization.jsonObject(with: payload) as? [String] else { return false } + return nested.contains { knownCodexHookPaths.contains($0) } + } + + private static func isTopLevelNotifyLine(_ trimmed: String) -> Bool { + guard let equals = trimmed.firstIndex(of: "=") else { return false } + return trimmed[.. [String]? { + guard let open = line.firstIndex(of: "["), + let close = line.lastIndex(of: "]"), open < close else { return nil } + let array = String(line[open...close]) + guard let data = array.data(using: .utf8) else { return nil } + return try? JSONSerialization.jsonObject(with: data) as? [String] + } + + /// A JSON array of strings is also valid TOML; reject values we cannot encode safely. + private static func encodeTomlStringArray(_ values: [String]) -> String? { + guard let data = try? JSONSerialization.data( + withJSONObject: values, options: [.withoutEscapingSlashes] + ) else { return nil } + return String(data: data, encoding: .utf8) + } } diff --git a/Sources/Mori/Resources/mori-codex-hook.sh b/Sources/Mori/Resources/mori-codex-hook.sh index e2bc9c3e..4fd38069 100644 --- a/Sources/Mori/Resources/mori-codex-hook.sh +++ b/Sources/Mori/Resources/mori-codex-hook.sh @@ -9,23 +9,26 @@ AGENT_NAME="codex" # Bail if not inside tmux [ -z "${TMUX:-}" ] && exit 0 -# Determine hook type. -# Legacy notify: JSON payload as first arg with "type" field, e.g. {"type":"agent-turn-complete",...} -# Modern codex_hooks: first arg is the event name (UserPromptSubmit, Stop, etc.) -# No args: treat as done. +# Determine hook type. Modern hooks pass an explicit event argument. Legacy `notify` +# passed a JSON payload as arg 1, so retain its completion mapping for Codex processes +# that were already running when Mori migrated their registration. RAW_ARG="${1:-}" -if [ -z "$RAW_ARG" ]; then - HOOK_TYPE="Stop" -elif echo "$RAW_ARG" | grep -q '^{'; then - # JSON arg — extract "type" field - HOOK_TYPE="$(echo "$RAW_ARG" | sed -n 's/.*"type" *: *"\([^"]*\)".*/\1/p')" - [ -z "$HOOK_TYPE" ] && HOOK_TYPE="Stop" -else - HOOK_TYPE="$RAW_ARG" -fi - -# Drain stdin if present (modern hooks send JSON on stdin) +case "$RAW_ARG" in + UserPromptSubmit|Stop) + HOOK_TYPE="$RAW_ARG" + ;; + \{*) + HOOK_TYPE="$(printf '%s' "$RAW_ARG" | sed -n 's/.*"type"[[:space:]]*:[[:space:]]*"\([^\"]*\)".*/\1/p')" + ;; + *) + # Empty, malformed, and unknown events must not manufacture a waiting state. + exit 0 + ;; +esac + +# Modern Codex hooks send their payload on stdin. Consume it even though the +# explicit event argument is sufficient, so the producer never blocks on a full pipe. cat > /dev/null 2>&1 || true # shellcheck source=mori-hook-common.sh @@ -36,7 +39,7 @@ case "$HOOK_TYPE" in UserPromptSubmit) set_state "working" ;; - Stop|Notification|agent-turn-complete) + Stop|agent-turn-complete) set_state "waiting" ;; *) diff --git a/Sources/Mori/Resources/mori-pi-extension.ts b/Sources/Mori/Resources/mori-pi-extension.ts index e80d7641..dbdecfc4 100644 --- a/Sources/Mori/Resources/mori-pi-extension.ts +++ b/Sources/Mori/Resources/mori-pi-extension.ts @@ -24,11 +24,7 @@ export default function (pi: any) { await setState("working"); }); - pi.on("agent_end", async () => { + pi.on("agent_settled", async () => { await setState("waiting"); }); - - pi.on("tool_execution_start", async () => { - await setState("working"); - }); } diff --git a/docs/agent-hooks.md b/docs/agent-hooks.md index ebe9d069..16cccfd4 100644 --- a/docs/agent-hooks.md +++ b/docs/agent-hooks.md @@ -1,6 +1,6 @@ # Agent Hooks -Mori integrates with coding agents (Claude Code, Codex CLI, Pi) to display their status in tab names and send notifications. Hooks are manually enabled/disabled in **Settings > Agent Hooks**. Once enabled, Mori refreshes the installed hook files on every launch so `~/.config/mori/` stays aligned with the current app bundle. +Mori integrates with coding agents (Claude Code, Codex CLI, Pi, and Droid) to display their status in tab names and send notifications. Hooks are manually enabled/disabled in **Settings > Agent Hooks**. Once enabled, Mori refreshes the installed hook files on every launch so `~/.config/mori/` stays aligned with the current app bundle. ## How It Works @@ -22,19 +22,25 @@ During Mori's 5-second polling cycle, it reads these options directly from tmux - `Notification` (completion notification) 4. On future launches, Mori re-copies the hook script from its bundled resources if the installed file is stale or missing. +During installation and refresh, Claude Code and Droid remove only their obsolete Mori `PreToolUse` command left by older releases, preserving other hooks in the same configuration. + Run Claude Code in a tmux window. Tab renames to `claude` while working (⚡ badge shown in sidebar), then shows waiting badge (❗) when the turn completes. Click the waiting badge to reply inline. ### Codex CLI 1. Open Mori Settings > Agent Hooks 2. Toggle **Codex CLI** on -3. Mori creates/updates `~/.codex/config.toml` with: - ```toml - # Mori agent status hook - notify = ["/Users/you/.config/mori/hooks/mori-codex-hook.sh"] +3. Mori creates/updates `~/.codex/hooks.json` with only these lifecycle hooks: + ```json + { + "hooks": { + "UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "/Users/you/.config/mori/hooks/mori-codex-hook.sh UserPromptSubmit" }] }], + "Stop": [{ "hooks": [{ "type": "command", "command": "/Users/you/.config/mori/hooks/mori-codex-hook.sh Stop" }] }] + } + } ``` -**Important:** The `notify` entry must be **top-level** in the TOML file (before any `[section]` headers). Mori enforces this automatically. +Mori leaves unrelated Codex hooks intact. On refresh, an existing legacy Mori `notify` registration in `~/.codex/config.toml` is migrated to this format and removed without changing other `notify` entries. On future launches, Mori re-copies the hook script from its bundled resources if the installed file is stale or missing. @@ -71,25 +77,22 @@ Responds to Claude Code hook events: Drains stdin (Claude Code pipes JSON hook data) and bails silently if not in tmux. ### mori-codex-hook.sh (Codex CLI) -Handles legacy Codex `notify` hook (JSON as arg 1): -```bash -# Legacy format (Codex < 2.0) -notify = ["/path/to/mori-codex-hook.sh"] -# Hook receives: mori-codex-hook.sh '{"type":"agent-turn-complete",...}' -``` +Handles the two registered Codex events: +- `UserPromptSubmit` → state: `"working"` +- `Stop` → state: `"waiting"` -Also supports modern event-based format if Codex adds explicit hook events. +The script also accepts legacy `notify` JSON (`{"type":"agent-turn-complete",...}`) as a completion event so already-running Codex processes remain compatible during migration. Empty, malformed, and unrecognized arguments do not change state. ### mori-pi-extension.ts (Pi) TypeScript extension listening to Pi events: -- `agent_start`, `tool_execution_start` → state: `"working"` -- `agent_end` → state: `"waiting"` +- `agent_start` → state: `"working"` +- `agent_settled` → state: `"waiting"` after all automatic retries, compaction retries, and queued follow-ups finish ## Disabling Hooks Toggle the agent off in Settings > Agent Hooks. Mori removes: - Hook script from `~/.config/mori/hooks/` -- Hook entries from agent config files (`~/.claude/settings.json`, `~/.codex/config.toml`, etc.) +- Hook entries from agent config files (`~/.claude/settings.json`, `~/.codex/hooks.json`, and legacy Mori entries in `~/.codex/config.toml`, etc.) If you manually delete hook scripts, toggle off then back on to reinstall. @@ -116,7 +119,7 @@ Click the notification to focus the window. Bundled `.app` builds use `UNUserNot - Verify the hook script exists: `ls ~/.config/mori/hooks/` - Check the agent config file was updated correctly: - Claude Code: `cat ~/.claude/settings.json | grep mori` - - Codex: `cat ~/.codex/config.toml | grep mori` + - Codex: `cat ~/.codex/hooks.json | grep mori` - Pi: `cat ~/.pi/agent/settings.json | grep mori` - Ensure you're running the agent _inside a tmux session_. Hooks only work in tmux. @@ -128,17 +131,17 @@ Click the notification to focus the window. Bundled `.app` builds use `UNUserNot **Hook script permission denied:** - Reinstall via Settings > Agent Hooks toggle (off then on). Mori sets `0755` permissions on install. -**Codex notify line doesn't get added:** -- Verify `~/.codex/config.toml` exists and is readable. +**Codex hooks don't get added:** +- Verify `~/.codex/hooks.json` is readable JSON. - If you added Mori hooks after Codex created the file, toggle the hook off/on to retry. -- Check that the file doesn't have syntax errors (TOML parser may reject it). +- Mori preserves unrecognized or malformed hook files rather than overwriting them; repair invalid JSON first. ## Implementation Details **AgentHookConfigurator** (Swift): - Detects installed hooks: `isClaudeHookInstalled()`, `isCodexHookInstalled()`, `isPiExtensionInstalled()` - Installs/uninstalls: `installClaudeHook()`, `uninstallClaudeHook()`, etc. -- Handles agent config file mutations (JSON for Claude/Pi, TOML for Codex) +- Handles agent config file mutations (JSON for Claude/Codex/Pi; legacy TOML cleanup for Codex) **WorkspaceManager** (Swift): - Reads `@mori-agent-state` and `@mori-agent-name` during 5s tmux poll diff --git a/docs/agent-hooks.zh-Hans.md b/docs/agent-hooks.zh-Hans.md index 3667a84e..36961fd7 100644 --- a/docs/agent-hooks.zh-Hans.md +++ b/docs/agent-hooks.zh-Hans.md @@ -1,6 +1,6 @@ # Agent Hooks(代理钩子) -Mori 可与编程代理(Claude Code、Codex CLI、Pi)集成,在标签页名称中显示其状态并发送通知。钩子通过**设置 > Agent Hooks** 手动启用/禁用。只要已经启用,Mori 每次启动都会刷新已安装的 hook 文件,让 `~/.config/mori/` 始终与当前应用 bundle 保持一致。 +Mori 可与编程代理(Claude Code、Codex CLI、Pi 和 Droid)集成,在标签页名称中显示其状态并发送通知。钩子通过**设置 > Agent Hooks** 手动启用/禁用。只要已经启用,Mori 每次启动都会刷新已安装的 hook 文件,让 `~/.config/mori/` 始终与当前应用 bundle 保持一致。 ## 工作原理 @@ -22,19 +22,25 @@ Mori 可与编程代理(Claude Code、Codex CLI、Pi)集成,在标签页 - `Notification`(完成通知) 4. 后续每次启动 Mori 时,如果 `~/.config/mori/` 里的 hook 脚本缺失或已过期,都会从当前 bundle 重新复制。 +安装和刷新时,Claude Code 和 Droid 还会仅移除旧版遗留的 Mori `PreToolUse` 命令;同一配置中的其他钩子会被保留。 + 在 tmux 窗口中运行 Claude Code。运行期间标签页会重命名为 `claude`(侧边栏显示 ⚡ 徽章),一轮完成后显示等待徽章(❗)。 ### Codex CLI 1. 打开 Mori 设置 > Agent Hooks 2. 开启 **Codex CLI** -3. Mori 会创建/更新 `~/.codex/config.toml`: - ```toml - # Mori agent status hook - notify = ["/Users/you/.config/mori/hooks/mori-codex-hook.sh"] +3. Mori 会创建/更新 `~/.codex/hooks.json`,且只注册以下生命周期钩子: + ```json + { + "hooks": { + "UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "/Users/you/.config/mori/hooks/mori-codex-hook.sh UserPromptSubmit" }] }], + "Stop": [{ "hooks": [{ "type": "command", "command": "/Users/you/.config/mori/hooks/mori-codex-hook.sh Stop" }] }] + } + } ``` -**注意:** `notify` 条目必须位于 TOML 文件的**顶层**(在任何 `[section]` 标题之前)。Mori 会自动确保这一点。 +Mori 不会改动无关的 Codex 钩子。刷新时,`~/.codex/config.toml` 中已有的旧版 Mori `notify` 注册会迁移到此格式;移除旧项时不会改动其他 `notify` 条目。 后续每次启动 Mori 时,如果 `~/.config/mori/hooks/mori-codex-hook.sh` 缺失或已过期,都会从当前 bundle 重新复制。 @@ -71,25 +77,22 @@ Mori 可与编程代理(Claude Code、Codex CLI、Pi)集成,在标签页 读取 stdin(Claude Code 通过管道传入 JSON 钩子数据),不在 tmux 中时静默退出。 ### mori-codex-hook.sh(Codex CLI) -处理旧版 Codex `notify` 钩子(JSON 作为参数 1): -```bash -# 旧版格式 (Codex < 2.0) -notify = ["/path/to/mori-codex-hook.sh"] -# 钩子接收: mori-codex-hook.sh '{"type":"agent-turn-complete",...}' -``` +处理两个已注册的 Codex 事件: +- `UserPromptSubmit` → 状态:`"working"` +- `Stop` → 状态:`"waiting"` -如果 Codex 添加了显式钩子事件,也支持新版基于事件的格式。 +脚本也接受旧版 `notify` JSON(`{"type":"agent-turn-complete",...}`)并将其视为完成事件,确保迁移时已经运行的 Codex 进程仍可兼容。空参数、畸形参数和未知参数不会改变状态。 ### mori-pi-extension.ts(Pi) 监听 Pi 事件的 TypeScript 扩展: -- `agent_start`、`tool_execution_start` → 状态:`"working"` -- `agent_end` → 状态:`"waiting"` +- `agent_start` → 状态:`"working"` +- `agent_settled` → 在全部自动重试、压缩重试和排队后续任务完成后变为 `"waiting"` ## 禁用钩子 在设置 > Agent Hooks 中关闭对应代理。Mori 会移除: - `~/.config/mori/hooks/` 中的钩子脚本 -- 代理配置文件中的钩子条目(`~/.claude/settings.json`、`~/.codex/config.toml` 等) +- 代理配置文件中的钩子条目(`~/.claude/settings.json`、`~/.codex/hooks.json`,以及 `~/.codex/config.toml` 中旧版 Mori 条目等) 如果手动删除了钩子脚本,先关闭再重新开启即可重新安装。 @@ -116,7 +119,7 @@ notify = ["/path/to/mori-codex-hook.sh"] - 确认钩子脚本存在:`ls ~/.config/mori/hooks/` - 检查代理配置文件是否正确更新: - Claude Code:`cat ~/.claude/settings.json | grep mori` - - Codex:`cat ~/.codex/config.toml | grep mori` + - Codex:`cat ~/.codex/hooks.json | grep mori` - Pi:`cat ~/.pi/agent/settings.json | grep mori` - 确保代理在 _tmux 会话内_ 运行。钩子仅在 tmux 中有效。 @@ -128,17 +131,17 @@ notify = ["/path/to/mori-codex-hook.sh"] **钩子脚本权限被拒绝:** - 通过设置 > Agent Hooks 重新安装(先关闭再开启)。Mori 安装时会设置 `0755` 权限。 -**Codex notify 行未被添加:** -- 确认 `~/.codex/config.toml` 存在且可读。 +**Codex 钩子未被添加:** +- 确认 `~/.codex/hooks.json` 是可读的 JSON。 - 如果在 Codex 创建文件后才添加 Mori 钩子,请关闭/开启钩子以重试。 -- 检查文件是否有语法错误(TOML 解析器可能会拒绝)。 +- Mori 会保留无法识别或格式错误的钩子文件而不覆盖;请先修复无效 JSON。 ## 实现细节 **AgentHookConfigurator**(Swift): - 检测已安装的钩子:`isClaudeHookInstalled()`、`isCodexHookInstalled()`、`isPiExtensionInstalled()` - 安装/卸载:`installClaudeHook()`、`uninstallClaudeHook()` 等 -- 处理代理配置文件变更(Claude/Pi 用 JSON,Codex 用 TOML) +- 处理代理配置文件变更(Claude/Codex/Pi 用 JSON;Codex 的旧 TOML 仅作清理) **WorkspaceManager**(Swift): - 在 5 秒 tmux 轮询中读取 `@mori-agent-state` 和 `@mori-agent-name`