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
- Programs can report their status to Programa with the OSC 7501 Program Status Protocol. The sidebar shows working, blocked, done and error, an unfocused terminal gets a notification when a program finishes, fails or needs input, and `surface.list`, `system.tree` and `surface.wait` (`program_state`) expose it without ever returning the message text. Programa's own agent hooks emit it too, and it outranks hook and screen-detected state. See `docs/program-status.md`.
- Programa can be set as the default web browser. Links clicked in other apps open as a browser tab in the focused pane of the active window, including a link that launches Programa. Links Programa sends to an external browser skip Programa itself and go to the next installed browser.
- A ports button in the sidebar footer shows what is listening on a port in each workspace, plus dev servers left running after their terminal closed, with Open, Copy URL, Reveal and Stop. `programa list-ports` and `programa stop-port` and the `ports.list` and `ports.stop` socket methods do the same from the command line.
- New `agent.needs_input` socket method reports a surface as blocked on the user and posts its notification in one call, instead of three separate calls that could fail independently.
Expand Down
7 changes: 7 additions & 0 deletions CLI/CLI+AgentEventAdapters.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ extension ProgramaCLI {
sessionId: String? = nil,
pid: Int? = nil
) {
switch eventType {
case "turn.started", "item.started": emitProgramStatus(state: "working", app: provider)
case "turn.completed": emitProgramStatus(state: "done", app: provider)
default: break
}
reportAgentState(client: client, workspaceId: workspaceId, surfaceId: surfaceId, state: state, provider: provider, sessionId: sessionId, pid: pid)
reportAgentEvent(client: client, provider: provider, eventType: eventType, workspaceId: workspaceId, surfaceId: surfaceId, sessionId: sessionId, pid: pid)
}
Expand Down Expand Up @@ -96,6 +101,7 @@ extension ProgramaCLI {
surfaceId: String,
sessionId: String? = nil
) {
emitProgramStatus(state: "clear")
reportAgentEvent(client: client, provider: provider, eventType: eventType, workspaceId: workspaceId, surfaceId: surfaceId, sessionId: sessionId)
clearAgentState(client: client, workspaceId: workspaceId, surfaceId: surfaceId)
}
Expand Down Expand Up @@ -138,6 +144,7 @@ extension ProgramaCLI {
sessionId: String?,
pid: Int?
) {
emitProgramStatusForNotification(app: provider, classifiedSubtitle: classifiedSubtitle, body: body)
if agentStateForClassifiedNotificationSubtitle(classifiedSubtitle) == .blocked {
reportAgentNeedsInput(
client: client,
Expand Down
6 changes: 3 additions & 3 deletions CLI/CLI+Hooks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1270,7 +1270,7 @@ extension ProgramaCLI {
return raw
}

private func parseClaudeHookInput(rawInput: String) -> ClaudeHookParsedInput {
func parseClaudeHookInput(rawInput: String) -> ClaudeHookParsedInput {
let trimmed = rawInput.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty,
let data = trimmed.data(using: .utf8),
Expand Down Expand Up @@ -1559,7 +1559,7 @@ extension ProgramaCLI {
return nil
}

private func summarizeClaudeHookNotification(parsedInput: ClaudeHookParsedInput) -> (subtitle: String, body: String) {
func summarizeClaudeHookNotification(parsedInput: ClaudeHookParsedInput) -> (subtitle: String, body: String) {
guard let object = parsedInput.object else {
if let fallback = parsedInput.rawFallback, !fallback.isEmpty {
return classifyClaudeNotification(signal: fallback, message: fallback)
Expand Down Expand Up @@ -3121,7 +3121,7 @@ extension ProgramaCLI {
return result.reversed()
}

private func summarizeCodexHookNotification(parsedInput: ClaudeHookParsedInput) -> (subtitle: String, body: String) {
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)
Expand Down
156 changes: 156 additions & 0 deletions CLI/CLI+ProgramStatusEmitter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import Foundation

/// OSC 7501 (Program Status Protocol) emission from Programa's own agent hooks. The hook
/// writes the sequence to the agent's terminal in addition to its socket reports, so a
/// terminal that understands the protocol sees the same state over SSH or without a socket.
/// Every failure is silent: a hook must never fail or slow the agent.
enum ProgramStatusSequence {
/// The whole sequence stays within one tty write (macOS `PIPE_BUF`).
static let maxBytes = 512

/// Builds `ESC ] 7501 ; key=value:key=value ESC \`. `msg` is stripped of control
/// characters and truncated on a UTF-8 boundary so the full sequence is <= `maxBytes`.
static func build(state: String, kind: String? = nil, app: String? = nil, msg: String? = nil) -> [UInt8] {
var fields = ["state=\(state)"]
if let kind, !kind.isEmpty { fields.append("kind=\(kind)") }
if let app, isValidApp(app) { fields.append("app=\(app)") }

let prefix = Array("\u{1B}]7501;".utf8)
let suffix = Array("\u{1B}\\".utf8)
var body = Array(fields.joined(separator: ":").utf8)

if let msg {
let clean = sanitized(msg)
// ":msg=" costs 5 bytes; base64 expands n bytes to 4*ceil(n/3).
let room = maxBytes - prefix.count - suffix.count - body.count - 5
let rawBudget = (room / 4) * 3
if rawBudget > 0 {
let bytes = truncatedUTF8(clean, maxBytes: rawBudget)
if !bytes.isEmpty {
body += Array(":msg=\(Data(bytes).base64EncodedString())".utf8)
}
}
}
return prefix + body + suffix
}

/// `[A-Za-z0-9_.+-]{1,32}`
static func isValidApp(_ app: String) -> Bool {
guard (1...32).contains(app.utf8.count) else { return false }
return app.utf8.allSatisfy { byte in
switch byte {
case 0x30...0x39, 0x41...0x5A, 0x61...0x7A, 0x5F, 0x2E, 0x2B, 0x2D: return true
default: return false
}
}
}

/// Newlines and tabs become a space; other C0, DEL and C1 controls are dropped.
static func sanitized(_ value: String) -> String {
var out = String.UnicodeScalarView()
for scalar in value.unicodeScalars {
switch scalar.value {
case 0x09, 0x0A, 0x0D: out.append(" ")
case 0x00...0x1F, 0x7F, 0x80...0x9F: continue
default: out.append(scalar)
}
}
return String(out)
}

/// Longest prefix of `value`'s UTF-8 that is <= `maxBytes` and ends on a scalar boundary.
static func truncatedUTF8(_ value: String, maxBytes: Int) -> [UInt8] {
var result: [UInt8] = []
for scalar in value.unicodeScalars {
let encoded = Array(String(scalar).utf8)
if result.count + encoded.count > maxBytes { break }
result += encoded
}
return result
}
}

extension ProgramaCLI {
/// Emits one OSC 7501 status report to the agent's terminal. No-op unless
/// `PROGRAMA_SURFACE_ID` is set (also true over SSH when forwarded) and not inside tmux.
func emitProgramStatus(state: String, kind: String? = nil, app: String? = nil, msg: String? = nil) {
let env = ProcessInfo.processInfo.environment
guard let surface = env["PROGRAMA_SURFACE_ID"], !surface.isEmpty else { return }
if env["TMUX"] != nil { return }
let bytes = ProgramStatusSequence.build(state: state, kind: kind, app: app, msg: msg)
guard let fd = openProgramStatusTTY(env: env) else { return }
defer { close(fd) }
// A background hook process on a TOSTOP terminal would otherwise be stopped by SIGTTOU.
let previous = signal(SIGTTOU, SIG_IGN)
defer { _ = signal(SIGTTOU, previous) }
_ = bytes.withUnsafeBytes { write(fd, $0.baseAddress, $0.count) }
}

/// First tty that opens: /dev/tty, the raw TTY / SSH_TTY path, then ttyname() of fds 0-2.
/// Deliberately not `normalizedTTYName`, which strips `/dev/pts/3` to `3`.
private func openProgramStatusTTY(env: [String: String]) -> Int32? {
let flags = O_WRONLY | O_NOCTTY | O_NONBLOCK | O_CLOEXEC
var candidates = ["/dev/tty"]
for key in ["TTY", "SSH_TTY"] {
if let path = env[key], path.hasPrefix("/dev/") { candidates.append(path) }
}
for fd in 0...2 {
if let name = ttyname(Int32(fd)) { candidates.append(String(cString: name)) }
}
for path in candidates {
let fd = open(path, flags)
if fd >= 0 { return fd }
}
return nil
}

/// Fail-open path (socket unreachable): the only code that runs for the hook, so it maps
/// the subcommand to a status report itself, then returns the provider's ack.
func failOpenHookAckEmittingProgramStatus(command: String, commandArgs: [String]) -> String? {
guard let ack = failOpenHookAck(command: command) else { return nil }
let app: String
switch command {
case "claude-hook": app = "claude-code"
case "codex-hook": app = "codex"
case "opencode-hook": app = "opencode"
default: return ack
}
let raw = commandArgs.first?.lowercased() ?? ""
let subcommand = ["active": "session-start", "idle": "stop", "notify": "notification"][raw] ?? raw
switch subcommand {
case "prompt-submit", "pre-tool-use":
emitProgramStatus(state: "working", app: app)
case "stop":
emitProgramStatus(state: "done", app: app)
case "session-end":
emitProgramStatus(state: "clear")
case "notification":
let parsed = parseClaudeHookInput(rawInput: readAgentHookStdin())
let subtitle: String
let body: String
switch command {
case "claude-hook":
(subtitle, body) = summarizeClaudeHookNotification(parsedInput: parsed)
case "codex-hook":
(subtitle, body) = summarizeCodexHookNotification(parsedInput: parsed)
default:
let message = ["message", "body", "text", "reason", "description"]
.lazy.compactMap { parsed.object?[$0] as? String }.first { !$0.isEmpty }
(subtitle, body) = ("Permission", message ?? "OpenCode needs your attention")
}
emitProgramStatusForNotification(app: app, classifiedSubtitle: subtitle, body: body)
default:
break
}
return ack
}

/// Permission -> blocked/permission, Waiting -> blocked/question, anything else -> idle.
func emitProgramStatusForNotification(app: String, classifiedSubtitle: String, body: String) {
switch classifiedSubtitle {
case "Permission": emitProgramStatus(state: "blocked", kind: "permission", app: app, msg: body)
case "Waiting": emitProgramStatus(state: "blocked", kind: "question", app: app, msg: body)
default: emitProgramStatus(state: "idle", app: app, msg: body)
}
}
}
2 changes: 1 addition & 1 deletion CLI/CLICommandDispatcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ struct CLICommandDispatcher {
} catch {
// An agent hook must never fail because programa is not reachable (app quit,
// restarting, or the socket missing): it acks like a handled event and exits 0.
guard let ack = cli.failOpenHookAck(command: command) else { throw error }
guard let ack = cli.failOpenHookAckEmittingProgramStatus(command: command, commandArgs: commandArgs) else { throw error }
print(ack)
return
}
Expand Down
Loading
Loading