Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p
## [Unreleased]

### Added
- Codex integration now matches Claude Code: "Needs input" notifications, guaranteed cleanup when a session dies without a clean stop, an "Install Codex Integration…" menu item, and `programa codex install-integration` naming (the old install-hooks name still works). Also fixed duplicate desktop notifications for Codex sessions — suppression of raw terminal notifications now applies to any hook-managed agent, not just Claude Code.
- File menu: "Install Claude Code Integration…" opens a terminal running `programa claude install-integration`, which shows the exact diff it wants to make to your Claude settings and asks before writing. It durably registers Programa's lifecycle hooks so the integration works from any terminal, not just inside Programa; hooks silently no-op elsewhere. Fully reversible with `programa claude uninstall-integration`; your own hooks are never touched.
- Closing a terminal is now undoable for 5 seconds: Cmd+Shift+T brings it back with its process still running — whether you closed it or an agent did. The confirmation dialog still appears when a command is actively running.

Expand Down
181 changes: 179 additions & 2 deletions CLI/CLI+Hooks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1259,6 +1259,20 @@ extension ProgramaCLI {
"command": codexHookCommand("stop"),
"timeout": 10
] as [String: Any]]
] as [String: Any]],
"Notification": [[
"hooks": [[
"type": "command",
"command": codexHookCommand("notification"),
"timeout": 10
] as [String: Any]]
] as [String: Any]],
"SessionEnd": [[
"hooks": [[
"type": "command",
"command": codexHookCommand("session-end"),
"timeout": 1
] as [String: Any]]
] as [String: Any]]
] as [String: Any]
]
Expand Down Expand Up @@ -2000,14 +2014,171 @@ extension ProgramaCLI {
throw error
}

case "notification", "notify":
var summary = summarizeCodexHookNotification(parsedInput: parsedInput)

let mappedSession = parsedInput.sessionId.flatMap { try? sessionStore.lookup(sessionId: $0) }
let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook(
preferred: mappedSession?.workspaceId,
fallback: workspaceArg,
client: client
)
if let mappedSession,
let savedBody = mappedSession.lastBody, !savedBody.isEmpty,
summary.body.contains("needs your attention") || summary.body.contains("needs your input") {
summary = (subtitle: mappedSession.lastSubtitle ?? summary.subtitle, body: savedBody)
}

let surfaceId = try resolvePreferredSurfaceIdForClaudeHook(
preferred: mappedSession?.surfaceId,
fallback: surfaceArg,
workspaceId: workspaceId,
client: client
)
let agentPIDKey = codexAgentPIDKey(sessionId: parsedInput.sessionId ?? mappedSession?.sessionId)
let codexPid = mappedSession?.pid ?? inferredCodexAgentPID()

let title = "Codex"
let subtitle = sanitizeNotificationField(summary.subtitle)
let body = sanitizeNotificationField(summary.body)

if let sessionId = parsedInput.sessionId {
try? sessionStore.upsert(
sessionId: sessionId,
workspaceId: workspaceId,
surfaceId: surfaceId,
cwd: parsedInput.cwd,
pid: codexPid,
lastSubtitle: summary.subtitle,
lastBody: summary.body
)
}
if let codexPid {
_ = try? client.sendV2(method: "workspace.set_agent_pid", params: [
"workspace_id": workspaceId,
"key": agentPIDKey,
"pid": codexPid,
])
}

_ = try? client.sendV2(method: "notification.create_for_target", params: [
"workspace_id": workspaceId,
"surface_id": surfaceId,
"title": title,
"subtitle": subtitle,
"body": body,
])
_ = try? setCodexStatus(
client: client,
workspaceId: workspaceId,
value: "Needs input",
icon: "bell.fill",
color: "#4C8DFF"
)
print("{}")

case "session-end":
do {
// Final cleanup when Codex process exits (e.g. Ctrl+C or kill), covering
// the case where Stop never fires. If Stop already consumed the session,
// consumedSession is nil here and we skip to avoid wiping the completion
// notification that Stop just delivered.
let mappedSession = parsedInput.sessionId.flatMap { try? sessionStore.lookup(sessionId: $0) }
let fallbackWorkspaceId = try? resolvePreferredWorkspaceIdForClaudeHook(
preferred: mappedSession?.workspaceId,
fallback: workspaceArg,
client: client
)
let fallbackSurfaceId: String? = {
guard let fallbackWorkspaceId else { return nil }
return try? resolvePreferredSurfaceIdForClaudeHook(
preferred: mappedSession?.surfaceId,
fallback: surfaceArg,
workspaceId: fallbackWorkspaceId,
client: client
)
}()
let consumedSession = try? sessionStore.consume(
sessionId: parsedInput.sessionId,
workspaceId: fallbackWorkspaceId,
surfaceId: fallbackSurfaceId
)
if let consumedSession {
let workspaceId = consumedSession.workspaceId
let agentPIDKey = codexAgentPIDKey(sessionId: parsedInput.sessionId ?? consumedSession.sessionId)
_ = try? clearCodexStatus(client: client, workspaceId: workspaceId)
_ = try? client.sendV2(method: "workspace.clear_agent_pid", params: ["workspace_id": workspaceId, "key": agentPIDKey])
_ = try? client.sendV2(method: "notification.clear", params: ["workspace_id": workspaceId])
}
print("{}")
} catch {
if shouldIgnoreClaudeHookTeardownError(error) {
print("{}")
return
}
throw error
}

case "help", "--help", "-h":
print("programa codex-hook <session-start|prompt-submit|stop> [--workspace <id>] [--surface <id>]")
print("programa codex-hook <session-start|prompt-submit|stop|notification|session-end> [--workspace <id>] [--surface <id>]")

default:
throw CLIError(message: "Unknown codex-hook subcommand: \(subcommand)")
}
}

private func summarizeCodexHookNotification(parsedInput: ClaudeHookParsedInput) -> (subtitle: String, body: String) {
guard let object = parsedInput.object else {
if let fallback = parsedInput.rawFallback, !fallback.isEmpty {
return classifyCodexNotification(signal: fallback, message: fallback)
}
return ("Waiting", "Codex is waiting for your input")
}

let nested = (object["notification"] as? [String: Any]) ?? (object["data"] as? [String: Any]) ?? [:]
let signalParts = [
firstString(in: object, keys: ["event", "event_name", "hook_event_name", "type", "kind"]),
firstString(in: object, keys: ["notification_type", "matcher", "reason"]),
firstString(in: nested, keys: ["type", "kind", "reason"])
]
let messageCandidates = [
firstString(in: object, keys: ["message", "body", "text", "prompt", "error", "description"]),
firstString(in: nested, keys: ["message", "body", "text", "prompt", "error", "description"])
]
let message = messageCandidates.compactMap { $0 }.first ?? "Codex needs your input"
let normalizedMessage = normalizedSingleLine(message)
let signal = signalParts.compactMap { $0 }.joined(separator: " ")
var classified = classifyCodexNotification(signal: signal, message: normalizedMessage)

classified.body = truncate(classified.body, maxLength: 180)
return classified
}

private func classifyCodexNotification(signal: String, message: String) -> (subtitle: String, body: String) {
let lower = "\(signal) \(message)".lowercased()
if lower.contains("permission") || lower.contains("approve") || lower.contains("approval") || lower.contains("permission_prompt") {
let body = message.isEmpty ? "Approval needed" : message
return ("Permission", body)
}
if lower.contains("error") || lower.contains("failed") || lower.contains("exception") {
let body = message.isEmpty ? "Codex reported an error" : message
return ("Error", body)
}
if lower.contains("complet") || lower.contains("finish") || lower.contains("done") || lower.contains("success") {
let body = message.isEmpty ? "Task completed" : message
return ("Completed", body)
}
if lower.contains("idle") || lower.contains("wait") || lower.contains("input") || lower.contains("idle_prompt") {
let body = message.isEmpty ? "Waiting for input" : message
return ("Waiting", body)
}
// Use the message directly if it's meaningful (not a generic placeholder).
if !message.isEmpty, message != "Codex needs your input" {
return ("Attention", message)
}
return ("Attention", "Codex needs your attention")
}

private func setCodexStatus(
client: SocketClient,
workspaceId: String,
Expand All @@ -2024,6 +2195,10 @@ extension ProgramaCLI {
])
}

private func clearCodexStatus(client: SocketClient, workspaceId: String) throws {
_ = try client.sendV2(method: "workspace.clear_status", params: ["workspace_id": workspaceId, "key": "codex"])
}

private func codexAgentPIDKey(sessionId: String?) -> String {
guard let sessionId = sessionId?.trimmingCharacters(in: .whitespacesAndNewlines),
!sessionId.isEmpty else {
Expand Down Expand Up @@ -2125,7 +2300,7 @@ extension ProgramaCLI {
"""
case "codex-hook":
return """
Usage: programa codex-hook <session-start|prompt-submit|stop> [flags]
Usage: programa codex-hook <session-start|prompt-submit|stop|notification|session-end> [flags]

Hook for Codex CLI integration. Reads JSON from stdin.
Gracefully no-ops when not running inside programa.
Expand All @@ -2134,6 +2309,8 @@ extension ProgramaCLI {
session-start Register a Codex session
prompt-submit Set Running status on user prompt
stop Send completion notification, set Idle
notification Send an attention-classified notification, set Needs input
session-end Final cleanup when the Codex process exits (Ctrl+C/kill)

Flags:
--workspace <id|ref> Target workspace (default: $PROGRAMA_WORKSPACE_ID)
Expand Down
18 changes: 10 additions & 8 deletions CLI/programa.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1452,10 +1452,10 @@ struct ProgramaCLI {
// Codex hooks management (no socket needed)
if command == "codex" {
let sub = commandArgs.first?.lowercased() ?? "help"
if sub == "install-hooks" {
if sub == "install-hooks" || sub == "install-integration" {
try runCodexInstallHooks()
return
} else if sub == "uninstall-hooks" {
} else if sub == "uninstall-hooks" || sub == "uninstall-integration" {
try runCodexUninstallHooks()
return
}
Expand Down Expand Up @@ -1585,12 +1585,14 @@ struct ProgramaCLI {
CommandDescriptor(names: ["omc"], helpLines: ["omc [omc-args...]"], connectionPolicy: .local, helpPolicy: .passthrough, execute: nil),
CommandDescriptor(
names: ["codex"],
helpLines: ["codex <install-hooks|uninstall-hooks>"],
helpLines: ["codex <install-integration|uninstall-integration>"],
connectionPolicy: .local,
detailedUsage: """
Usage: programa codex <install-hooks|uninstall-hooks>
Usage: programa codex <install-integration|uninstall-integration>
programa codex <install-hooks|uninstall-hooks> (legacy aliases, still supported)

Install or remove Programa's Codex notification hooks.
Install or remove Programa's Codex notification hooks in
~/.codex/hooks.json (or $CODEX_HOME/hooks.json).
""",
execute: nil
),
Expand Down Expand Up @@ -5271,7 +5273,7 @@ struct ProgramaCLI {
guard ProcessInfo.processInfo.environment["PROGRAMA_SURFACE_ID"] != nil else { return }
let parsed = try parse(values: ["workspace", "surface"], maxPositionals: 1)
if let subcommand = parsed.positional.first?.lowercased(),
!["session-start", "prompt-submit", "stop", "help", "--help", "-h"].contains(subcommand) {
!["session-start", "prompt-submit", "stop", "notification", "notify", "session-end", "help", "--help", "-h"].contains(subcommand) {
throw CLIError(message: "codex-hook: unknown event \(subcommand)")
}

Expand Down Expand Up @@ -5354,8 +5356,8 @@ struct ProgramaCLI {
return
case "codex":
let parsed = try parse(booleans: ["yes", "y"], minPositionals: 1, maxPositionals: 1)
guard ["install-hooks", "uninstall-hooks"].contains(parsed.positional[0].lowercased()) else {
throw CLIError(message: "codex: expected install-hooks or uninstall-hooks")
guard ["install-hooks", "uninstall-hooks", "install-integration", "uninstall-integration"].contains(parsed.positional[0].lowercased()) else {
throw CLIError(message: "codex: expected install-integration or uninstall-integration")
}
case "claude":
let parsed = try parse(booleans: ["yes", "y"], minPositionals: 1, maxPositionals: 1)
Expand Down
17 changes: 17 additions & 0 deletions Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -5857,6 +5857,23 @@
}
}
},
"menu.file.installCodexIntegration": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Install Codex Integration…"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "Codex 連携をインストール…"
}
}
}
},
"menu.file.reopenClosedBrowserPanel": {
"extractionState": "manual",
"localizations": {
Expand Down
24 changes: 24 additions & 0 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4722,6 +4722,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser
return workspace.id
}

/// Opens a new workspace and runs `programa codex install-integration` in it,
/// mirroring openClaudeIntegrationInstaller: no working-directory override, since
/// the installer targets the user's global Codex settings, not a project.
@discardableResult
func openCodexIntegrationInstaller(event: NSEvent? = nil, debugSource: String = "unspecified") -> UUID? {
discardOrphanedMainWindowContexts()
guard let context = preferredMainWindowContextForWorkspaceCreation(event: event, debugSource: debugSource) else {
openNewMainWindow(nil)
return nil
}
guard let window = resolvedWindow(for: context) else {
discardOrphanedMainWindowContext(context)
openNewMainWindow(nil)
return nil
}
setActiveMainWindow(window)

let workspace = context.tabManager.addWorkspace(
initialTerminalInput: "programa codex install-integration\n",
select: true
)
return workspace.id
}

private func preferredMainWindowContextForWorkspaceCreation(
event: NSEvent? = nil,
debugSource: String = "unspecified"
Expand Down
14 changes: 8 additions & 6 deletions Sources/GhosttyApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1451,12 +1451,13 @@ class GhosttyApp {
let tabId = tabManager.selectedTabId else {
return false
}
// Suppress OSC notifications for workspaces with active Claude hook sessions.
// The hook system manages notifications with proper lifecycle tracking;
// raw OSC notifications would duplicate or outlive the structured hooks.
// Suppress OSC notifications for workspaces with active hook-managed agent
// sessions (Claude Code, Codex, etc.). The hook system manages notifications
// with proper lifecycle tracking; raw OSC notifications would duplicate or
// outlive the structured hooks.
let owningManager = AppDelegate.shared?.tabManagerFor(tabId: tabId) ?? tabManager
if let workspace = owningManager.tabs.first(where: { $0.id == tabId }),
workspace.agentPIDs["claude_code"] != nil {
workspace.hasHookManagedAgent {
return true
}
let tabTitle = owningManager.titleForTab(tabId) ?? "Terminal"
Expand Down Expand Up @@ -1731,10 +1732,11 @@ class GhosttyApp {
let actionBody = action.action.desktop_notification.body
.flatMap { String(cString: $0) } ?? ""
performOnMain {
// Suppress OSC notifications for workspaces with active Claude hook sessions.
// Suppress OSC notifications for workspaces with active hook-managed agent
// sessions (Claude Code, Codex, etc.).
let owningManager = AppDelegate.shared?.tabManagerFor(tabId: tabId) ?? AppDelegate.shared?.tabManager
if let workspace = owningManager?.tabs.first(where: { $0.id == tabId }),
workspace.agentPIDs["claude_code"] != nil {
workspace.hasHookManagedAgent {
return
}
let tabTitle = owningManager?.titleForTab(tabId) ?? "Terminal"
Expand Down
9 changes: 9 additions & 0 deletions Sources/ProgramaApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,15 @@ struct programaApp: App {
) {
AppDelegate.shared?.openClaudeIntegrationInstaller(debugSource: "menu.installClaudeIntegration")
}

Button(
String(
localized: "menu.file.installCodexIntegration",
defaultValue: "Install Codex Integration…"
)
) {
AppDelegate.shared?.openCodexIntegrationInstaller(debugSource: "menu.installCodexIntegration")
}
}

// Close tab/workspace
Expand Down
7 changes: 7 additions & 0 deletions Sources/Workspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,13 @@ final class Workspace: Identifiable, ObservableObject {
var agentPIDs: [String: pid_t] = [:]
var restoredTerminalScrollbackByPanelId: [UUID: String] = [:]

/// True when any hook-managed agent (Claude Code, Codex, etc.) is registered for this
/// workspace. Used to suppress raw OSC desktop notifications in favor of the structured
/// hook-driven notification lifecycle.
var hasHookManagedAgent: Bool {
!agentPIDs.isEmpty
}

func sidebarObservationSignal<Value: Equatable>(
_ publisher: Published<Value>.Publisher
) -> AnyPublisher<Void, Never> {
Expand Down
Loading
Loading