diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fdbbc874..2372e48bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/CLI/CLI+AgentEventAdapters.swift b/CLI/CLI+AgentEventAdapters.swift index 2a8c2bbc2..4305fec3a 100644 --- a/CLI/CLI+AgentEventAdapters.swift +++ b/CLI/CLI+AgentEventAdapters.swift @@ -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) } @@ -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) } @@ -138,6 +144,7 @@ extension ProgramaCLI { sessionId: String?, pid: Int? ) { + emitProgramStatusForNotification(app: provider, classifiedSubtitle: classifiedSubtitle, body: body) if agentStateForClassifiedNotificationSubtitle(classifiedSubtitle) == .blocked { reportAgentNeedsInput( client: client, diff --git a/CLI/CLI+Hooks.swift b/CLI/CLI+Hooks.swift index 155b0aba4..ef58f8696 100644 --- a/CLI/CLI+Hooks.swift +++ b/CLI/CLI+Hooks.swift @@ -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), @@ -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) @@ -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) diff --git a/CLI/CLI+ProgramStatusEmitter.swift b/CLI/CLI+ProgramStatusEmitter.swift new file mode 100644 index 000000000..cf692c493 --- /dev/null +++ b/CLI/CLI+ProgramStatusEmitter.swift @@ -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) + } + } +} diff --git a/CLI/CLICommandDispatcher.swift b/CLI/CLICommandDispatcher.swift index c0ceb4bac..3ae1e7cd0 100644 --- a/CLI/CLICommandDispatcher.swift +++ b/CLI/CLICommandDispatcher.swift @@ -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 } diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index d7fc3834a..e7be6c5cb 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -24,6 +24,9 @@ NRSP0076A1B2C3D4E5F60719 /* Workspace+Surfaces.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRSP0075A1B2C3D4E5F60719 /* Workspace+Surfaces.swift */; }; NRSP0074A1B2C3D4E5F60719 /* Workspace+SidebarTelemetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRSP0073A1B2C3D4E5F60719 /* Workspace+SidebarTelemetry.swift */; }; AGST0001A1B2C3D4E5F60719 /* AgentActivityState.swift in Sources */ = {isa = PBXBuildFile; fileRef = AGST0002A1B2C3D4E5F60719 /* AgentActivityState.swift */; }; + 7501A0000000000000000003 /* GhosttyApp+ProgramStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7501A0000000000000000004 /* GhosttyApp+ProgramStatus.swift */; }; + 7501A0000000000000000001 /* ProgramStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7501A0000000000000000002 /* ProgramStatus.swift */; }; + 7501A0000000000000000007 /* TerminalController+SurfaceWaitProgram.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7501A0000000000000000008 /* TerminalController+SurfaceWaitProgram.swift */; }; C7A3000001 /* AgentEventNormalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A3000002 /* AgentEventNormalizer.swift */; }; AGSV0001A1B2C3D4E5F60719 /* AgentSupervision.swift in Sources */ = {isa = PBXBuildFile; fileRef = AGSV0002A1B2C3D4E5F60719 /* AgentSupervision.swift */; }; AGSV0003A1B2C3D4E5F60719 /* TerminalController+AgentSupervision.swift in Sources */ = {isa = PBXBuildFile; fileRef = AGSV0004A1B2C3D4E5F60719 /* TerminalController+AgentSupervision.swift */; }; @@ -240,6 +243,7 @@ B900003DA1B2C3D4E5F60719 /* CLI+AgentWrappers.swift in Sources */ = {isa = PBXBuildFile; fileRef = B900003CA1B2C3D4E5F60719 /* CLI+AgentWrappers.swift */; }; B900003FA1B2C3D4E5F60719 /* CLI+Hooks.swift in Sources */ = {isa = PBXBuildFile; fileRef = B900003EA1B2C3D4E5F60719 /* CLI+Hooks.swift */; }; C7A3000005 /* CLI+AgentEventAdapters.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A3000006 /* CLI+AgentEventAdapters.swift */; }; + 7501C0000000000000000001 /* CLI+ProgramStatusEmitter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7501C0000000000000000002 /* CLI+ProgramStatusEmitter.swift */; }; H1HC0002 /* CLI+HookCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = H1HC0001 /* CLI+HookCommands.swift */; }; H1CL0002 /* CLICommandDispatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = H1CL0001 /* CLICommandDispatcher.swift */; }; H1HK0002 /* HookInstallationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = H1HK0001 /* HookInstallationCoordinator.swift */; }; @@ -299,6 +303,7 @@ B6BF3DC98DB1495E57900199 /* TabManagerUnitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42092CDB2109E250F7F2A76E /* TabManagerUnitTests.swift */; }; DCC935C5F55C1DCB33E25521 /* WorkspacePullRequestSidebarTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14A7DC53B9CA33BE2A421711 /* WorkspacePullRequestSidebarTests.swift */; }; 08F8EF4F188B4E255A7C813B /* AgentActivityStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B7B486518E6CD0E6F36894B /* AgentActivityStateTests.swift */; }; + 7501A0000000000000000005 /* ProgramStatusStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7501A0000000000000000006 /* ProgramStatusStoreTests.swift */; }; C7A3000003 /* AgentEventNormalizerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A3000004 /* AgentEventNormalizerTests.swift */; }; 0F2C25F9170130F8DC09DD1B /* WorkspaceManualUnreadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D301919B10F22B8708E8883 /* WorkspaceManualUnreadTests.swift */; }; CA39C0304FE351A21C372429 /* SidebarWidthPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE0171AF1F49F7547191CEE5 /* SidebarWidthPolicyTests.swift */; }; @@ -411,6 +416,9 @@ NRSP0075A1B2C3D4E5F60719 /* Workspace+Surfaces.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+Surfaces.swift"; sourceTree = ""; }; NRSP0073A1B2C3D4E5F60719 /* Workspace+SidebarTelemetry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+SidebarTelemetry.swift"; sourceTree = ""; }; AGST0002A1B2C3D4E5F60719 /* AgentActivityState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AgentActivityState.swift"; sourceTree = ""; }; + 7501A0000000000000000002 /* ProgramStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgramStatus.swift; sourceTree = ""; }; + 7501A0000000000000000008 /* TerminalController+SurfaceWaitProgram.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+SurfaceWaitProgram.swift"; sourceTree = ""; }; + 7501A0000000000000000004 /* GhosttyApp+ProgramStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyApp+ProgramStatus.swift"; sourceTree = ""; }; C7A3000002 /* AgentEventNormalizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentEventNormalizer.swift; sourceTree = ""; }; AGSV0002A1B2C3D4E5F60719 /* AgentSupervision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentSupervision.swift; sourceTree = ""; }; AGSV0004A1B2C3D4E5F60719 /* TerminalController+AgentSupervision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+AgentSupervision.swift"; sourceTree = ""; }; @@ -628,6 +636,7 @@ B900003CA1B2C3D4E5F60719 /* CLI+AgentWrappers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CLI+AgentWrappers.swift"; sourceTree = ""; }; B900003EA1B2C3D4E5F60719 /* CLI+Hooks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CLI+Hooks.swift"; sourceTree = ""; }; C7A3000006 /* CLI+AgentEventAdapters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CLI+AgentEventAdapters.swift"; sourceTree = ""; }; + 7501C0000000000000000002 /* CLI+ProgramStatusEmitter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CLI+ProgramStatusEmitter.swift"; sourceTree = ""; }; H1HC0001 /* CLI+HookCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CLI+HookCommands.swift"; sourceTree = ""; }; H1CL0001 /* CLICommandDispatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLICommandDispatcher.swift; sourceTree = ""; }; H1HK0001 /* HookInstallationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HookInstallationCoordinator.swift; sourceTree = ""; }; @@ -681,6 +690,7 @@ 42092CDB2109E250F7F2A76E /* TabManagerUnitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManagerUnitTests.swift; sourceTree = ""; }; 14A7DC53B9CA33BE2A421711 /* WorkspacePullRequestSidebarTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspacePullRequestSidebarTests.swift; sourceTree = ""; }; 8B7B486518E6CD0E6F36894B /* AgentActivityStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentActivityStateTests.swift; sourceTree = ""; }; + 7501A0000000000000000006 /* ProgramStatusStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgramStatusStoreTests.swift; sourceTree = ""; }; C7A3000004 /* AgentEventNormalizerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentEventNormalizerTests.swift; sourceTree = ""; }; 1D301919B10F22B8708E8883 /* WorkspaceManualUnreadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceManualUnreadTests.swift; sourceTree = ""; }; EE0171AF1F49F7547191CEE5 /* SidebarWidthPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWidthPolicyTests.swift; sourceTree = ""; }; @@ -853,6 +863,9 @@ NRSP0075A1B2C3D4E5F60719 /* Workspace+Surfaces.swift */, NRSP0073A1B2C3D4E5F60719 /* Workspace+SidebarTelemetry.swift */, AGST0002A1B2C3D4E5F60719 /* AgentActivityState.swift */, + 7501A0000000000000000002 /* ProgramStatus.swift */, + 7501A0000000000000000008 /* TerminalController+SurfaceWaitProgram.swift */, + 7501A0000000000000000004 /* GhosttyApp+ProgramStatus.swift */, C7A3000002 /* AgentEventNormalizer.swift */, AGSV0002A1B2C3D4E5F60719 /* AgentSupervision.swift */, AGOV0002A1B2C3D4E5F60719 /* AgentOverviewWindow.swift */, @@ -1031,6 +1044,7 @@ B900003CA1B2C3D4E5F60719 /* CLI+AgentWrappers.swift */, B900003EA1B2C3D4E5F60719 /* CLI+Hooks.swift */, C7A3000006 /* CLI+AgentEventAdapters.swift */, + 7501C0000000000000000002 /* CLI+ProgramStatusEmitter.swift */, H1HC0001 /* CLI+HookCommands.swift */, H1CL0001 /* CLICommandDispatcher.swift */, C7A10001 /* V2MethodNames.swift */, @@ -1176,6 +1190,7 @@ 42092CDB2109E250F7F2A76E /* TabManagerUnitTests.swift */, 14A7DC53B9CA33BE2A421711 /* WorkspacePullRequestSidebarTests.swift */, 8B7B486518E6CD0E6F36894B /* AgentActivityStateTests.swift */, + 7501A0000000000000000006 /* ProgramStatusStoreTests.swift */, C7A3000004 /* AgentEventNormalizerTests.swift */, AMDT000010 /* AgentManifestTests.swift */, CQTT000002 /* ClaudeQuotaSnapshotParserTests.swift */, @@ -1380,6 +1395,9 @@ NRSP0076A1B2C3D4E5F60719 /* Workspace+Surfaces.swift in Sources */, NRSP0074A1B2C3D4E5F60719 /* Workspace+SidebarTelemetry.swift in Sources */, AGST0001A1B2C3D4E5F60719 /* AgentActivityState.swift in Sources */, + 7501A0000000000000000001 /* ProgramStatus.swift in Sources */, + 7501A0000000000000000007 /* TerminalController+SurfaceWaitProgram.swift in Sources */, + 7501A0000000000000000003 /* GhosttyApp+ProgramStatus.swift in Sources */, C7A3000001 /* AgentEventNormalizer.swift in Sources */, AGSV0001A1B2C3D4E5F60719 /* AgentSupervision.swift in Sources */, AGOV0001A1B2C3D4E5F60719 /* AgentOverviewWindow.swift in Sources */, @@ -1614,6 +1632,7 @@ B6BF3DC98DB1495E57900199 /* TabManagerUnitTests.swift in Sources */, DCC935C5F55C1DCB33E25521 /* WorkspacePullRequestSidebarTests.swift in Sources */, 08F8EF4F188B4E255A7C813B /* AgentActivityStateTests.swift in Sources */, + 7501A0000000000000000005 /* ProgramStatusStoreTests.swift in Sources */, C7A3000003 /* AgentEventNormalizerTests.swift in Sources */, AMDT000009 /* AgentManifestTests.swift in Sources */, CQTT000001 /* ClaudeQuotaSnapshotParserTests.swift in Sources */, @@ -1656,6 +1675,7 @@ B900003DA1B2C3D4E5F60719 /* CLI+AgentWrappers.swift in Sources */, B900003FA1B2C3D4E5F60719 /* CLI+Hooks.swift in Sources */, C7A3000005 /* CLI+AgentEventAdapters.swift in Sources */, + 7501C0000000000000000001 /* CLI+ProgramStatusEmitter.swift in Sources */, H1HC0002 /* CLI+HookCommands.swift in Sources */, H1CL0002 /* CLICommandDispatcher.swift in Sources */, C7A10002 /* V2MethodNames.swift in Sources */, diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 59f8e9ec0..8a82eda3b 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -1,6 +1,108 @@ { "sourceLanguage": "en", "strings": { + "notification.programStatus.blocked": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Needs your input" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "入力が必要です" + } + } + } + }, + "notification.programStatus.blockedAuth": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Needs you to sign in" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サインインが必要です" + } + } + } + }, + "notification.programStatus.blockedPermission": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Needs your permission" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "許可が必要です" + } + } + } + }, + "notification.programStatus.blockedQuestion": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Has a question for you" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "確認したいことがあります" + } + } + } + }, + "notification.programStatus.done": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Finished" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "完了しました" + } + } + } + }, + "notification.programStatus.error": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "失敗しました" + } + } + } + }, "settings.notifications.permission.subtitle.notDetermined": { "extractionState": "manual", "localizations": { @@ -12445,6 +12547,40 @@ } } }, + "sidebar.agentIndicator.done": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Done" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "完了" + } + } + } + }, + "sidebar.agentIndicator.error": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Error" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エラー" + } + } + } + }, "sidebar.agentIndicator.idle": { "extractionState": "manual", "localizations": { diff --git a/Sources/AgentActivityState.swift b/Sources/AgentActivityState.swift index faf125e91..f137a984d 100644 --- a/Sources/AgentActivityState.swift +++ b/Sources/AgentActivityState.swift @@ -51,6 +51,18 @@ enum AgentActivityState: String, Codable, CaseIterable, Sendable { enum AgentStateSource: String, Codable, CaseIterable, Sendable { case hooks case inferred + /// OSC 7501 program status reported by the program itself. Outranks hooks while the root + /// record is active (see `Workspace.updatePanelAgentState`). + case program + + /// Authority order: program 2, hooks 1, inferred 0. + var rank: Int { + switch self { + case .program: return 2 + case .hooks: return 1 + case .inferred: return 0 + } + } } /// Identifies the process/session behind a reported `AgentPresence`, when known. Hook reports @@ -73,6 +85,9 @@ struct AgentPresence: Equatable, Sendable { var source: AgentStateSource var lastEventAt: Date var sessionKey: AgentSessionKey? + /// The root record's state when `source == .program`; `state` holds its legacy projection + /// (done and error both read as `.idle`). Nil for every other source. + var programState: ProgramStatusState? /// Set by the watchdog sweep once `isStale` first turns true, and reset by every write. /// Staleness itself is computed from `lastEventAt`; this stored flag only makes the /// transition a real value change, so the `removeDuplicates` sidebar publisher fires. @@ -85,8 +100,24 @@ struct AgentPresence: Equatable, Sendable { /// True when this presence is non-idle and hasn't been refreshed within `threshold`. /// Staleness never clears presence on its own; it only dims/suffixes the indicator. + /// Program presence is never stale: OSC 7501 has no heartbeat, so a long build that reported + /// `working` once is still working. func isStale(now: Date, threshold: TimeInterval = AgentPresence.staleThreshold) -> Bool { - state != .idle && now.timeIntervalSince(lastEventAt) > threshold + source != .program && state != .idle && now.timeIntervalSince(lastEventAt) > threshold + } +} + +extension AgentPresence { + /// Worst-first ordering for the sidebar badge: blocked > working > error > done > idle. + /// Error and done rank inside the idle tier, so a blocked or working sibling still wins. + fileprivate var indicatorSeverity: Int { + let tierBonus: Int + switch programState { + case .error: tierBonus = 2 + case .done: tierBonus = 1 + default: tierBonus = 0 + } + return state.severity * 10 + (state == .idle ? tierBonus : 0) } } @@ -98,6 +129,8 @@ struct SidebarAgentIndicator: Equatable { case blocked case working case idle + case done + case error } let systemImage: String @@ -113,8 +146,8 @@ struct SidebarAgentIndicator: Equatable { static func make(for workspace: Workspace, now: Date = Date()) -> SidebarAgentIndicator? { let winner = workspace.panelAgentPresence.values.reduce(nil as AgentPresence?) { partial, presence in guard let partial else { return presence } - if presence.state.severity != partial.state.severity { - return presence.state.severity > partial.state.severity ? presence : partial + if presence.indicatorSeverity != partial.indicatorSeverity { + return presence.indicatorSeverity > partial.indicatorSeverity ? presence : partial } // Equal severity: the freshest report wins, so dictionary order never decides the // stale flag. @@ -142,6 +175,20 @@ struct SidebarAgentIndicator: Equatable { tint: .working, isStale: isStale ) + case .idle where winner.programState == .done: + return SidebarAgentIndicator( + systemImage: "checkmark.circle.fill", + label: String(localized: "sidebar.agentIndicator.done", defaultValue: "Done"), + tint: .done, + isStale: false + ) + case .idle where winner.programState == .error: + return SidebarAgentIndicator( + systemImage: "xmark.octagon.fill", + label: String(localized: "sidebar.agentIndicator.error", defaultValue: "Error"), + tint: .error, + isStale: false + ) case .idle: return SidebarAgentIndicator( systemImage: "moon.fill", diff --git a/Sources/AgentOverviewWindow.swift b/Sources/AgentOverviewWindow.swift index 16c72a550..90b3a8ecd 100644 --- a/Sources/AgentOverviewWindow.swift +++ b/Sources/AgentOverviewWindow.swift @@ -30,7 +30,11 @@ enum AgentOverviewFriendlyState: Equatable, Sendable { } static func from(presence: AgentPresence?) -> Self { - from(activityState: presence?.state) + switch presence?.programState { + case .done: return .done + case .error: return .failed + default: return from(activityState: presence?.state) + } } var label: String { diff --git a/Sources/AgentScreenDetectionEngine.swift b/Sources/AgentScreenDetectionEngine.swift index ad1f44d07..3f5e7c0ee 100644 --- a/Sources/AgentScreenDetectionEngine.swift +++ b/Sources/AgentScreenDetectionEngine.swift @@ -201,7 +201,7 @@ final class AgentScreenDetectionEngine: @unchecked Sendable { for (panelId, panel) in workspace.panels { guard let terminalPanel = panel as? TerminalPanel else { continue } guard !alreadyCandidates.contains(panelId) else { continue } - guard workspace.panelAgentStateSources[panelId] != .hooks else { continue } + guard (workspace.panelAgentStateSources[panelId]?.rank ?? AgentStateSource.inferred.rank) < AgentStateSource.hooks.rank else { continue } // Viewport only: a full-scrollback read on every tick costs main-thread // time proportional to history size, and detection needs only the tail. guard let text = TerminalController.shared.readTerminalText( @@ -254,7 +254,7 @@ final class AgentScreenDetectionEngine: @unchecked Sendable { surfaceGone = true return } - guard ws.panelAgentStateSources[surfaceId] != .hooks else { + guard (ws.panelAgentStateSources[surfaceId]?.rank ?? AgentStateSource.inferred.rank) < AgentStateSource.hooks.rank else { hooksOwned = true return } diff --git a/Sources/GhosttyApp+ProgramStatus.swift b/Sources/GhosttyApp+ProgramStatus.swift new file mode 100644 index 000000000..27818f4bf --- /dev/null +++ b/Sources/GhosttyApp+ProgramStatus.swift @@ -0,0 +1,213 @@ +// Routes OSC 7501 program status actions from Ghostty's action callback to the per-terminal +// `ProgramStatusStore`, then projects the root record into the workspace's agent presence. +import Foundation +import Bonsplit + +/// The action callback can fire at high rates from a misbehaving program, so events queue per +/// surface and drain in one main-queue hop. A report replaces an earlier queued report for the +/// same id unless a prompt, reset or clear sits between them. +final class ProgramStatusDispatcher: @unchecked Sendable { + static let shared = ProgramStatusDispatcher() + + enum Event: Equatable { + case report(ProgramStatusReport) + case prompt + case resetAll + } + + private struct Key: Hashable { + let tabId: UUID + let surfaceId: UUID + } + + private let lock = NSLock() + private var pending: [Key: [Event]] = [:] + + /// Safe to call from any thread. `body` must already be copied out of Ghostty's buffer. + static func handle( + tabId: UUID, + surfaceId: UUID, + event: ghostty_action_program_status_event_e, + state: ghostty_action_program_status_state_e, + body: Data + ) { + let parsed: Event? + switch event { + case GHOSTTY_PROGRAM_STATUS_EVENT_REPORT: + let legacy: ProgramStatusState? + switch state { + case GHOSTTY_PROGRAM_STATUS_STATE_IDLE: legacy = .idle + case GHOSTTY_PROGRAM_STATUS_STATE_WORKING: legacy = .working + case GHOSTTY_PROGRAM_STATUS_STATE_DONE: legacy = .done + case GHOSTTY_PROGRAM_STATUS_STATE_BLOCKED: legacy = .blocked + case GHOSTTY_PROGRAM_STATUS_STATE_ERROR: legacy = .error + default: legacy = nil // CLEAR + } + if legacy == nil, state != GHOSTTY_PROGRAM_STATUS_STATE_CLEAR { + parsed = nil + } else { + parsed = ProgramStatusReport.parse(state: legacy, body: body).map(Event.report) + } + case GHOSTTY_PROGRAM_STATUS_EVENT_PROMPT: + parsed = .prompt + case GHOSTTY_PROGRAM_STATUS_EVENT_RESET_ALL: + parsed = .resetAll + default: + parsed = nil + } + guard let parsed else { return } + shared.enqueue(Key(tabId: tabId, surfaceId: surfaceId), parsed) + } + + private func enqueue(_ key: Key, _ event: Event) { + lock.lock() + var events = pending[key] ?? [] + let wasEmpty = events.isEmpty + Self.coalesce(&events, event) + pending[key] = events + lock.unlock() + guard wasEmpty else { return } + DispatchQueue.main.async { + MainActor.assumeIsolated { self.drain(key) } + } + } + + private static func coalesce(_ events: inout [Event], _ event: Event) { + switch event { + case .report(let report) where report.state != nil: + for index in events.indices.reversed() { + guard case .report(let queued) = events[index], queued.state != nil else { break } + // Only a same-state update (progress, text) may replace a queued report; a state + // change must reach waiters and notifications even inside one burst. + if queued.id == report.id { + guard queued.state == report.state else { break } + events[index] = event + return + } + } + case .prompt, .resetAll: + if events.last == event { return } + default: + break + } + events.append(event) + } + + @MainActor + private func drain(_ key: Key) { + lock.lock() + let events = pending.removeValue(forKey: key) ?? [] + lock.unlock() + guard let manager = AppDelegate.shared?.tabManagerFor(tabId: key.tabId), + let workspace = manager.tabs.first(where: { $0.id == key.tabId }), + let panel = workspace.panels[key.surfaceId] as? TerminalPanel else { return } + + var lastRootState = panel.programStatus.root?.state + var transitions: [ProgramStatusRecord?] = [] + var projectRoot = false + for event in events { + defer { + let root = panel.programStatus.root + if root?.state != lastRootState { + transitions.append(root) + lastRootState = root?.state + } + } + switch event { + case .report(let report): + if case .applied(let rootTouched) = panel.programStatus.apply(report) { + projectRoot = projectRoot || rootTouched + } +#if DEBUG + dlog( + "programStatus.report surface=\(key.surfaceId.uuidString.prefix(5)) " + + "id=\(report.id) state=\(report.state?.rawValue ?? "clear") " + + "records=\(panel.programStatus.count)" + ) +#endif + case .prompt: + projectRoot = panel.programStatus.dropTransient() || projectRoot +#if DEBUG + dlog("programStatus.prompt surface=\(key.surfaceId.uuidString.prefix(5)) records=\(panel.programStatus.count)") +#endif + case .resetAll: + projectRoot = panel.programStatus.resetAll() || projectRoot +#if DEBUG + dlog("programStatus.reset surface=\(key.surfaceId.uuidString.prefix(5))") +#endif + } + } + guard projectRoot else { return } + + // Only a change to the root record projects. Re-projecting on every event would let a + // resting `done` record overwrite a hook agent that legitimately took over the surface. + if let root = panel.programStatus.root { + manager.updateSurfaceAgentState( + tabId: key.tabId, + surfaceId: key.surfaceId, + state: root.state.legacyAgentState, + source: .program, + programState: root.state + ) + } else { + manager.clearSurfaceAgentState(tabId: key.tabId, surfaceId: key.surfaceId, source: .program) + } + + for root in transitions { + ProgramStateWaitRegistry.shared.notify(surfaceId: key.surfaceId, newState: root?.state) + if let root { + postNotificationIfNeeded(for: root, manager: manager, workspace: workspace, surfaceId: key.surfaceId) + } + } + } + + /// Notifies when the root record enters blocked, done or error on a surface the user is not + /// looking at. The hook-managed path already posts for the same moment, so it is skipped. + /// Title is Programa's tab title and the body is Programa's own wording: `notification.list` + /// returns notification text over the socket, and the spec forbids revealing a record's + /// `msg` or `title` back to programs. + @MainActor + private func postNotificationIfNeeded( + for root: ProgramStatusRecord, + manager: TabManager, + workspace: Workspace, + surfaceId: UUID + ) { + guard root.state == .blocked || root.state == .done || root.state == .error else { return } + guard !workspace.hasHookManagedAgent else { return } + let isFocused = manager.selectedTabId == workspace.id + && manager.focusedSurfaceId(for: workspace.id) == surfaceId + guard !(isFocused && AppFocusState.isAppFocused()) else { return } + + let body = Self.defaultNotificationBody(for: root) + TerminalNotificationStore.shared.addNotification( + tabId: workspace.id, + surfaceId: surfaceId, + title: manager.titleForTab(workspace.id) ?? "Terminal", + subtitle: "", + body: body, + cooldownKey: "program-status-\(surfaceId.uuidString)", + cooldownInterval: 10 + ) + } + + private static func defaultNotificationBody(for root: ProgramStatusRecord) -> String { + switch root.state { + case .blocked: + switch root.kind { + case .permission: + return String(localized: "notification.programStatus.blockedPermission", defaultValue: "Needs your permission") + case .question: + return String(localized: "notification.programStatus.blockedQuestion", defaultValue: "Has a question for you") + case .auth: + return String(localized: "notification.programStatus.blockedAuth", defaultValue: "Needs you to sign in") + case nil: + return String(localized: "notification.programStatus.blocked", defaultValue: "Needs your input") + } + case .error: + return String(localized: "notification.programStatus.error", defaultValue: "Failed") + default: + return String(localized: "notification.programStatus.done", defaultValue: "Finished") + } + } +} diff --git a/Sources/GhosttyApp.swift b/Sources/GhosttyApp.swift index a6920128d..f3c918701 100644 --- a/Sources/GhosttyApp.swift +++ b/Sources/GhosttyApp.swift @@ -2147,6 +2147,17 @@ class GhosttyApp { } } return true + case GHOSTTY_ACTION_PROGRAM_STATUS: + // OSC 7501 program status. `data` is only valid during this callback, so copy it + // before the dispatcher queues the event for main. + guard let tabId = callbackTabId, + let surfaceId = callbackSurfaceId else { return true } + let status = action.action.program_status + let body = status.data.map { Data(bytes: $0, count: Int(status.len)) } ?? Data() + ProgramStatusDispatcher.handle( + tabId: tabId, surfaceId: surfaceId, event: status.event, state: status.state, body: body + ) + return true case GHOSTTY_ACTION_COMMAND_FINISHED: // OSC 133 semantic prompt tracking. Plumbing-only: stores exit code + duration on // `Workspace.lastCommand`; no UI reads this yet. diff --git a/Sources/Panels/TerminalPanel.swift b/Sources/Panels/TerminalPanel.swift index 190ff86ba..ca8c36d69 100644 --- a/Sources/Panels/TerminalPanel.swift +++ b/Sources/Panels/TerminalPanel.swift @@ -13,6 +13,9 @@ final class TerminalPanel: Panel, ObservableObject { /// The underlying terminal surface let surface: TerminalSurface + /// OSC 7501 program status records for this terminal. Not persisted. + let programStatus = ProgramStatusStore() + /// The workspace ID this panel belongs to private(set) var workspaceId: UUID diff --git a/Sources/ProgramStatus.swift b/Sources/ProgramStatus.swift new file mode 100644 index 000000000..f10efe276 --- /dev/null +++ b/Sources/ProgramStatus.swift @@ -0,0 +1,274 @@ +// OSC 7501 Program Status Protocol: the per-terminal record store and the report parser. +// +// Ghostty's parser has already validated every report before the action reaches Swift. The +// checks here are defense in depth, so the store never holds a record the sidebar or the socket +// could not safely show. See docs/plans/osc7501-program-status.md (D6, D7). +import Foundation + +enum ProgramStatusState: String, Sendable, CaseIterable { + case idle, working, done, blocked, error + + /// A program that is working or blocked is mid-task. `done`, `error` and `idle` are resting + /// values that survive a shell prompt. + var isActive: Bool { self == .working || self == .blocked } + + /// The `agent_state` value the root record projects to. `agent_state` keeps its three wire + /// values; `AgentPresence.programState` carries the finer distinction. + var legacyAgentState: AgentActivityState { + switch self { + case .working: return .working + case .blocked: return .blocked + case .idle, .done, .error: return .idle + } + } +} + +enum ProgramStatusKind: String, Sendable { + case permission, question, auth +} + +struct ProgramStatusRecord: Equatable, Sendable { + /// Empty string is the root record (the program itself). + var id: String + var state: ProgramStatusState + var kind: ProgramStatusKind? + var progress: Int? + /// As the program sent it. `ProgramStatusStore.record(id:)` returns the value resolved + /// through ancestors. + var app: String? + var title: String? + var msg: String? + var updatedAt: Date +} + +/// One validated report. `state == nil` is the `clear` operation: it removes `id` and every +/// descendant, or every record when `id` is empty. +struct ProgramStatusReport: Equatable, Sendable { + static let maxMsgBytes = 2048 + static let maxTitleBytes = 192 + static let maxIdBytes = 128 + static let maxIdSegments = 8 + static let maxSegmentLength = 32 + + var state: ProgramStatusState? + var id: String + var kind: ProgramStatusKind? + var progress: Int? + var app: String? + var title: String? + var msg: String? + + /// Parses the bytes after `7501;`. Returns nil when the report must be discarded whole. + /// Pairs split on `:`, keys on the first `=`; malformed pairs are skipped, unknown keys + /// ignored, and the last value of a repeated key wins. + static func parse(state: ProgramStatusState?, body: Data) -> ProgramStatusReport? { + guard let text = String(data: body, encoding: .utf8) else { return nil } + var values: [Substring: Substring] = [:] + for pair in text.split(separator: ":", omittingEmptySubsequences: true) { + guard let eq = pair.firstIndex(of: "=") else { continue } + // Ghostty's parser trims ASCII whitespace from keys and values; match it so both + // sides agree on which record a report addresses. + values[trimASCIIWhitespace(pair[pair.startIndex.. Bool { + if id.isEmpty { return true } + guard id.utf8.count <= maxIdBytes else { return false } + let segments = id.split(separator: "/", omittingEmptySubsequences: false) + guard segments.count <= maxIdSegments else { return false } + return segments.allSatisfy { isName($0, maxLength: maxSegmentLength) } + } + + private static func isName(_ value: Substring, maxLength: Int) -> Bool { + guard !value.isEmpty, value.utf8.count <= maxLength else { return false } + return value.utf8.allSatisfy { byte in + switch byte { + case UInt8(ascii: "a")...UInt8(ascii: "z"), + UInt8(ascii: "A")...UInt8(ascii: "Z"), + UInt8(ascii: "0")...UInt8(ascii: "9"), + UInt8(ascii: "_"), UInt8(ascii: "."), UInt8(ascii: "+"), UInt8(ascii: "-"): + return true + default: + return false + } + } + } + + private static func trimASCIIWhitespace(_ value: Substring) -> Substring { + let isSpace: (Character) -> Bool = { " \t\n\r\u{0B}\u{0C}".contains($0) } + guard let start = value.firstIndex(where: { !isSpace($0) }), + let end = value.lastIndex(where: { !isSpace($0) }) else { return "" } + return value[start...end] + } + + private static func parseProgress(_ value: Substring) -> Int? { + guard !value.isEmpty, value.utf8.allSatisfy({ $0 >= 0x30 && $0 <= 0x39 }) else { return nil } + guard let number = Int(value), number <= 100 else { return nil } + return number + } + + /// Returns a nil `value` for an absent or empty field, and nil itself when the field is + /// invalid and the report must be discarded. + private static func decodeText(_ encoded: Substring?, maxBytes: Int) -> DecodedText? { + guard let encoded, !encoded.isEmpty else { return DecodedText(value: nil) } + var padded = String(encoded) + if padded.utf8.count % 4 != 0 { + padded += String(repeating: "=", count: 4 - padded.utf8.count % 4) + } + guard let data = Data(base64Encoded: padded), + !data.isEmpty, + data.count <= maxBytes, + let string = String(data: data, encoding: .utf8) else { return nil } + for scalar in string.unicodeScalars { + let v = scalar.value + if v <= 0x1F || v == 0x7F || (0x80...0x9F).contains(v) { return nil } + } + return DecodedText(value: string) + } + + private struct DecodedText { + let value: String? + } +} + +/// Result of applying a report to the store. +enum ProgramStatusChange: Equatable { + /// The report would create a record past `ProgramStatusStore.capacity` and was discarded. + case rejected + /// The report was applied. `rootTouched` is true when it wrote or removed the root record. + case applied(rootTouched: Bool) +} + +/// Records for one terminal, keyed by id (`""` is the root). Lives on `TerminalPanel`, so it +/// travels with the panel across detach/reattach and is never persisted. +@MainActor +final class ProgramStatusStore { + static let capacity = 256 + + private var records: [String: ProgramStatusRecord] = [:] + + var count: Int { records.count } + var isEmpty: Bool { records.isEmpty } + + /// The root record: the program's own status. + var root: ProgramStatusRecord? { record(id: "") } + + /// The record for `id`, with `app` resolved to the nearest ancestor that has one. + func record(id: String) -> ProgramStatusRecord? { + guard var record = records[id] else { return nil } + if record.app == nil { + record.app = inheritedApp(for: id) + } + return record + } + + @discardableResult + func apply(_ report: ProgramStatusReport, at now: Date = Date()) -> ProgramStatusChange { + guard let state = report.state else { + return .applied(rootTouched: clear(id: report.id)) + } + if records[report.id] == nil, records.count >= Self.capacity { + return .rejected + } + records[report.id] = ProgramStatusRecord( + id: report.id, + state: state, + kind: report.kind, + progress: report.progress, + app: report.app, + title: report.title, + msg: report.msg, + updatedAt: now + ) + return .applied(rootTouched: report.id.isEmpty) + } + + /// A shell prompt means the foreground program ended: drop working and blocked records at + /// every depth. Returns true when the root record was removed. + @discardableResult + func dropTransient() -> Bool { + let hadRoot = records[""] != nil + records = records.filter { !$0.value.state.isActive } + return hadRoot && records[""] == nil + } + + /// Full terminal reset (RIS). Returns true when the root record was removed. + @discardableResult + func resetAll() -> Bool { + let hadRoot = records[""] != nil + records.removeAll() + return hadRoot + } + + /// Removes `id` and every descendant (`a` removes `a/b`, not `ab`); an empty `id` removes + /// everything. Returns true when the root record was removed. + private func clear(id: String) -> Bool { + if id.isEmpty { return resetAll() } + let prefix = id + "/" + records = records.filter { $0.key != id && !$0.key.hasPrefix(prefix) } + return false + } + + private func inheritedApp(for id: String) -> String? { + var parts = id.split(separator: "/", omittingEmptySubsequences: false).map(String.init) + while !parts.isEmpty { + parts.removeLast() + if let app = records[parts.joined(separator: "/")]?.app { return app } + } + return nil + } +} + +extension ProgramStatusRecord { + /// The socket representation of a record. It never carries `msg`, `title` or child ids: + /// any process in a Programa terminal can reach the socket, and the protocol forbids + /// revealing record text back to programs. + var wirePayload: [String: Any] { + [ + "state": state.rawValue, + "kind": kind?.rawValue ?? NSNull(), + "progress": progress ?? NSNull(), + "app": app ?? NSNull(), + "has_message": msg != nil, + "updated_at": updatedAt.timeIntervalSince1970, + ] + } +} + +extension Workspace { + /// `program_status` for `surface.list` and `system.tree`: the root record, or null. + func programStatusWire(panelId: UUID) -> Any { + (panels[panelId] as? TerminalPanel)?.programStatus.root?.wirePayload ?? NSNull() + } +} diff --git a/Sources/TabItemView.swift b/Sources/TabItemView.swift index 7a62d65a1..d15e240ee 100644 --- a/Sources/TabItemView.swift +++ b/Sources/TabItemView.swift @@ -287,6 +287,8 @@ struct TabItemView: View, Equatable { case .blocked: return .red case .working: return programaAccentColor() case .idle: return activeSecondaryColor(0.6) + case .done: return .green + case .error: return .red } } diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index ba2c8b7d9..39a0dc5ed 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -2216,18 +2216,21 @@ class TabManager: ObservableObject { surfaceId: UUID, state: AgentActivityState, source: AgentStateSource = .hooks, - sessionKey: AgentSessionKey? = nil + sessionKey: AgentSessionKey? = nil, + programState: ProgramStatusState? = nil ) -> Bool { guard let tab = workspace(withId: tabId), tab.panels[surfaceId] != nil else { return false } - tab.updatePanelAgentState(panelId: surfaceId, state: state, source: source, sessionKey: sessionKey) + tab.updatePanelAgentState( + panelId: surfaceId, state: state, source: source, sessionKey: sessionKey, programState: programState + ) return true } /// Clears a previously reported agent activity state for a surface (e.g. on hook /// session-end). No-ops if the workspace/panel don't exist. - func clearSurfaceAgentState(tabId: UUID, surfaceId: UUID) { + func clearSurfaceAgentState(tabId: UUID, surfaceId: UUID, source: AgentStateSource = .hooks) { guard let tab = workspace(withId: tabId) else { return } - tab.clearPanelAgentState(panelId: surfaceId) + tab.clearPanelAgentState(panelId: surfaceId, source: source) } private func normalizeDirectory(_ directory: String) -> String { diff --git a/Sources/TerminalController+Subscriptions.swift b/Sources/TerminalController+Subscriptions.swift index ff2a8f314..773393fa4 100644 --- a/Sources/TerminalController+Subscriptions.swift +++ b/Sources/TerminalController+Subscriptions.swift @@ -351,10 +351,16 @@ final class SocketEventBroadcaster: @unchecked Sendable { return subscriptions.values.reduce(into: Set()) { $0.formUnion($1.outputSurfaceIds) } } - func publishAgentState(workspaceId: UUID, surfaceId: UUID, state: AgentActivityState?, source: AgentStateSource? = nil) { + func publishAgentState( + workspaceId: UUID, + surfaceId: UUID, + state: AgentActivityState?, + source: AgentStateSource? = nil, + programStatus: [String: Any]? = nil + ) { let subs = subscribers(for: .agentState) guard !subs.isEmpty else { return } - let frame: [String: Any] = [ + var frame: [String: Any] = [ "event": "agent_state", "workspace_id": workspaceId.uuidString, "surface_id": surfaceId.uuidString, @@ -362,6 +368,7 @@ final class SocketEventBroadcaster: @unchecked Sendable { "source": source.map { $0.rawValue } ?? NSNull(), "ts": Date().timeIntervalSince1970 ] + if let programStatus { frame["program_status"] = programStatus } for sub in subs { sub.enqueue(frame) } } diff --git a/Sources/TerminalController+Surface.swift b/Sources/TerminalController+Surface.swift index ee12f8007..de73fe411 100644 --- a/Sources/TerminalController+Surface.swift +++ b/Sources/TerminalController+Surface.swift @@ -144,7 +144,8 @@ extension TerminalController { "index_in_pane": v2OrNull(indexInPaneByPanelId[panel.id]), "selected_in_pane": v2OrNull(selectedInPaneByPanelId[panel.id]), "agent_state": v2OrNull(ws.panelAgentStates[panel.id]?.rawValue), - "agent_state_source": v2OrNull(ws.panelAgentStateSources[panel.id]?.rawValue) + "agent_state_source": v2OrNull(ws.panelAgentStateSources[panel.id]?.rawValue), + "program_status": ws.programStatusWire(panelId: panel.id) ] return item } diff --git a/Sources/TerminalController+SurfaceWait.swift b/Sources/TerminalController+SurfaceWait.swift index 544422daf..912cfba9b 100644 --- a/Sources/TerminalController+SurfaceWait.swift +++ b/Sources/TerminalController+SurfaceWait.swift @@ -237,15 +237,27 @@ extension TerminalController { let patternRaw = v2RawString(params, "pattern").flatMap { $0.isEmpty ? nil : $0 } let waitForExit = v2Bool(params, "exit") ?? false let agentStateRaw = v2String(params, "agent_state") + let programStateRaw = v2String(params, "program_state") - let conditionsProvided = [patternRaw != nil, waitForExit, agentStateRaw != nil].filter { $0 }.count + let conditionsProvided = [patternRaw != nil, waitForExit, agentStateRaw != nil, programStateRaw != nil] + .filter { $0 }.count guard conditionsProvided == 1 else { return .err( code: "invalid_params", - message: "Provide exactly one of 'pattern' (regex string), 'exit' (true), or 'agent_state' (idle|working|blocked|any_change)", + message: "Provide exactly one of 'pattern' (regex string), 'exit' (true), 'agent_state' (idle|working|blocked|any_change), or 'program_state' (idle|working|done|blocked|error|cleared|any_change)", data: nil ) } + if let programStateRaw { + guard let condition = ProgramStateWaitCondition(rawValue: programStateRaw) else { + return .err( + code: "invalid_params", + message: "Invalid program_state -- use: idle, working, done, blocked, error, cleared, any_change", + data: nil + ) + } + return v2SurfaceWaitProgramState(params: params, condition: condition, timeoutMs: timeoutMs, deadline: deadline) + } var agentStateCondition: AgentStateWaitCondition? if let agentStateRaw { diff --git a/Sources/TerminalController+SurfaceWaitProgram.swift b/Sources/TerminalController+SurfaceWaitProgram.swift new file mode 100644 index 000000000..d984c1eeb --- /dev/null +++ b/Sources/TerminalController+SurfaceWaitProgram.swift @@ -0,0 +1,172 @@ +// `surface.wait` with `program_state`: blocks until a terminal's OSC 7501 root record reaches a +// state. A sibling of `AgentStateWaitRegistry` so `agent.prompt`'s agent_state watch is untouched. +import Foundation + +/// The `program_state` condition of `surface.wait`. `cleared` means the surface has no root +/// record; `any_change` resolves on the next root-state transition, never on the state at +/// registration time. +enum ProgramStateWaitCondition: String { + case idle, working, done, blocked, error, cleared + case anyChange = "any_change" + + func isSatisfied(by state: ProgramStatusState?) -> Bool { + switch self { + case .idle: return state == .idle + case .working: return state == .working + case .done: return state == .done + case .blocked: return state == .blocked + case .error: return state == .error + case .cleared: return state == nil + case .anyChange: return false + } + } + + func firesOn(transitionTo state: ProgramStatusState?) -> Bool { + self == .anyChange || isSatisfied(by: state) + } +} + +/// Pending `program_state` waiters keyed by surface id. `notify` is called on the main thread +/// from the single mutation points of the root record (`ProgramStatusDispatcher` and +/// `Workspace.resetSidebarContext`), the same ordering argument as `AgentStateWaitRegistry`. +final class ProgramStateWaitRegistry: @unchecked Sendable { + static let shared = ProgramStateWaitRegistry() + + private struct Entry { + let token: UUID + let condition: ProgramStateWaitCondition + let callback: (ProgramStatusState?) -> Void + } + + private let lock = NSLock() + private var waiters: [UUID: [Entry]] = [:] + + @discardableResult + func addWaiter( + surfaceId: UUID, + condition: ProgramStateWaitCondition, + callback: @escaping (ProgramStatusState?) -> Void + ) -> UUID { + let token = UUID() + lock.lock() + waiters[surfaceId, default: []].append(Entry(token: token, condition: condition, callback: callback)) + lock.unlock() + return token + } + + func removeWaiter(surfaceId: UUID, token: UUID) { + lock.lock() + waiters[surfaceId]?.removeAll { $0.token == token } + if waiters[surfaceId]?.isEmpty == true { waiters.removeValue(forKey: surfaceId) } + lock.unlock() + } + + func notify(surfaceId: UUID, newState: ProgramStatusState?) { + lock.lock() + let current = waiters[surfaceId] ?? [] + let fired = current.filter { $0.condition.firesOn(transitionTo: newState) } + let remaining = current.filter { !$0.condition.firesOn(transitionTo: newState) } + if remaining.isEmpty { + waiters.removeValue(forKey: surfaceId) + } else { + waiters[surfaceId] = remaining + } + lock.unlock() + for entry in fired { entry.callback(newState) } + } +} + +extension TerminalController { + nonisolated func v2SurfaceWaitProgramState( + params: [String: Any], + condition: ProgramStateWaitCondition, + timeoutMs: Int, + deadline: Date + ) -> V2CallResult { + let semaphore = DispatchSemaphore(value: 0) + var setupError: V2CallResult? + var workspaceId: UUID? + var surfaceId: UUID? + var windowId: UUID? + var alreadySatisfied = false + var waiterToken: UUID? + // Written once, either in the main-thread hop below or from the registry callback before + // it signals the semaphore, so reading it after a successful wait needs no extra lock. + var resolved: ProgramStatusState? + + v2MainSync { + guard let tabManager = self.v2ResolveTabManager(params: params) else { + setupError = .err(code: "unavailable", message: "TabManager not available", data: nil) + return + } + guard let ws = self.v2ResolveWorkspace(params: params, tabManager: tabManager) else { + setupError = .err(code: "not_found", message: "Workspace not found", data: nil) + return + } + guard let id = self.v2UUID(params, "surface_id") ?? ws.focusedPanelId else { + setupError = .err(code: "not_found", message: "No focused surface", data: nil) + return + } + workspaceId = ws.id + surfaceId = id + windowId = self.v2ResolveWindowId(tabManager: tabManager) + guard let panel = ws.panels[id] else { + setupError = .err(code: "not_found", message: "Surface not found", data: ["surface_id": id.uuidString]) + return + } + guard let terminal = panel as? TerminalPanel else { + setupError = .err(code: "invalid_params", message: "Surface is not a terminal", data: ["surface_id": id.uuidString]) + return + } + let current = terminal.programStatus.root?.state + if condition.isSatisfied(by: current) { + alreadySatisfied = true + resolved = current + } else { + waiterToken = ProgramStateWaitRegistry.shared.addWaiter(surfaceId: id, condition: condition) { newState in + resolved = newState + semaphore.signal() + } + } + } + + if let setupError { return setupError } + guard let workspaceId, let surfaceId else { + return .err(code: "internal_error", message: "Failed to resolve surface", data: nil) + } + + func result(waited: Bool, closed: Bool = false) -> V2CallResult { + var payload: [String: Any] = [ + "workspace_id": workspaceId.uuidString, + "workspace_ref": self.v2Ref(kind: .workspace, uuid: workspaceId), + "surface_id": surfaceId.uuidString, + "surface_ref": self.v2Ref(kind: .surface, uuid: surfaceId), + "window_id": self.v2OrNull(windowId?.uuidString), + "window_ref": self.v2Ref(kind: .window, uuid: windowId), + "condition": "program_state", + "waited": waited, + "program_state": self.v2OrNull(resolved?.rawValue), + ] + if closed { payload["outcome"] = "closed" } + return .ok(payload) + } + + if alreadySatisfied { return result(waited: false) } + guard let waiterToken else { + return .err(code: "internal_error", message: "Failed to register program_state watcher", data: nil) + } + let outcome = v2WatchedWait(semaphore, until: deadline, surfaceId: surfaceId) + if case .signaled = outcome { return result(waited: true) } + ProgramStateWaitRegistry.shared.removeWaiter(surfaceId: surfaceId, token: waiterToken) + switch outcome { + case .surfaceClosed: return result(waited: true, closed: true) + case .clientGone: return v2ClientGoneError + case .signaled, .timedOut: + return .err( + code: "timeout", + message: "Surface's program_state did not reach '\(condition.rawValue)' before timeout", + data: ["timeout_ms": timeoutMs] + ) + } + } +} diff --git a/Sources/TerminalController+System.swift b/Sources/TerminalController+System.swift index 32eeb9113..99796e359 100644 --- a/Sources/TerminalController+System.swift +++ b/Sources/TerminalController+System.swift @@ -311,6 +311,7 @@ extension TerminalController { "branch": v2OrNull(workspace.panelGitBranches[panel.id]?.branch ?? workspace.gitBranch?.branch ?? workspace.worktreeBranch), "agent_state": v2OrNull(workspace.panelAgentStates[panel.id]?.rawValue), "agent_state_source": v2OrNull(workspace.panelAgentStateSources[panel.id]?.rawValue), + "program_status": workspace.programStatusWire(panelId: panel.id), "helpers": workspaceHelpers .filter { $0.surfaceId == panel.id } .map { $0.payload() }, diff --git a/Sources/TerminalController+Telemetry.swift b/Sources/TerminalController+Telemetry.swift index 0e0a7c111..84819f0e7 100644 --- a/Sources/TerminalController+Telemetry.swift +++ b/Sources/TerminalController+Telemetry.swift @@ -294,7 +294,8 @@ extension TerminalController { // response now echoes. let source: AgentStateSource if let rawSource = v2RawString(params, "source") { - guard let parsedSource = AgentStateSource(rawValue: rawSource.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()) else { + guard let parsedSource = AgentStateSource(rawValue: rawSource.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()), + parsedSource != .program else { return .err(code: "invalid_params", message: "Invalid source — use: hooks, inferred", data: nil) } source = parsedSource diff --git a/Sources/Workspace+Bonsplit.swift b/Sources/Workspace+Bonsplit.swift index 26bd9f9e8..791ef593a 100644 --- a/Sources/Workspace+Bonsplit.swift +++ b/Sources/Workspace+Bonsplit.swift @@ -726,7 +726,7 @@ extension Workspace: @preconcurrency BonsplitDelegate { panelAgentPresence.removeValue(forKey: panelId) } else { AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: id, surfaceId: panelId) - clearPanelAgentState(panelId: panelId) + clearPanelAgentState(panelId: panelId, force: true) } if progressSourcePanelId == panelId { progress = nil diff --git a/Sources/Workspace+SidebarTelemetry.swift b/Sources/Workspace+SidebarTelemetry.swift index 3b781abfe..d86877094 100644 --- a/Sources/Workspace+SidebarTelemetry.swift +++ b/Sources/Workspace+SidebarTelemetry.swift @@ -252,15 +252,26 @@ extension Workspace { /// (never reported) or already `.inferred`. If it's `.hooks`, the write is silently /// dropped — belt-and-suspenders: the screen-manifest engine's Phase A should already skip /// sampling a hooks-owned surface, but this is the authoritative guard regardless. + /// - `source == .program` (OSC 7501) always writes, and `programState` carries the root + /// record's finer state. While the current presence is `.program`, `.hooks`/`.inferred` + /// writes are dropped if its state is active (working/blocked), and, if it is resting + /// (idle/done/error), accepted only when they report an active state. Accepting only active + /// claims keeps a hook `idle` from overwriting a program `done` badge. func updatePanelAgentState( panelId: UUID, state: AgentActivityState, source: AgentStateSource = .hooks, sessionKey: AgentSessionKey? = nil, + programState: ProgramStatusState? = nil, at now: Date = Date() ) { let current = panelAgentPresence[panelId] + if source != .program, let current, current.source == .program, + current.state != .idle || state == .idle { + return + } + if source == .inferred, let current, current.source == .hooks { // Hooks always win: dropped write must not move lastEventAt either, so a // hooks-owned surface's staleness clock keeps ticking from its last real hook @@ -273,13 +284,14 @@ extension Workspace { // keeps the key an earlier report established, so the liveness sweep never loses // the pid it needs to clear this surface when the process dies. let resolvedSessionKey: AgentSessionKey? - if source == .inferred { + if source != .hooks { resolvedSessionKey = nil } else { resolvedSessionKey = sessionKey ?? current?.sessionKey } - guard current?.state != state || current?.source != source || current?.sessionKey != resolvedSessionKey else { + guard current?.state != state || current?.source != source || current?.sessionKey != resolvedSessionKey + || current?.programState != programState else { // Same state from the same writer: only the liveness clock moves. Without this a // long-running agent that keeps reporting `working` would read as stale at the // threshold. No wait-registry or socket fan-out, since nothing observable changed. @@ -291,14 +303,23 @@ extension Workspace { state: state, source: source, lastEventAt: now, - sessionKey: resolvedSessionKey + sessionKey: resolvedSessionKey, + programState: programState ) // Event-driven half of surface.wait's `agent_state` condition (#166 task 2) and // agent.prompt's internal working/idle watch (#166 task 3) -- see // AgentStateWaitRegistry's doc comment in TerminalController+SurfaceWait.swift for why // this is the single safe place to fire from. AgentStateWaitRegistry.shared.notify(surfaceId: panelId, newState: state, source: source) - SocketEventBroadcaster.shared.publishAgentState(workspaceId: id, surfaceId: panelId, state: state, source: source) + SocketEventBroadcaster.shared.publishAgentState( + workspaceId: id, + surfaceId: panelId, + state: state, + source: source, + programStatus: source == .program + ? (panels[panelId] as? TerminalPanel)?.programStatus.root?.wirePayload + : nil + ) #if DEBUG dlog( "surface.agentState workspace=\(id.uuidString.prefix(5)) " + @@ -307,8 +328,12 @@ extension Workspace { #endif } - func clearPanelAgentState(panelId: UUID) { - guard panelAgentPresence[panelId] != nil else { return } + /// Clears a surface's agent presence. A `.program` clear removes only program-owned presence; + /// a hooks/inferred clear is ignored while the presence is program-owned. `force` removes the + /// presence whoever owns it (permanent panel close). + func clearPanelAgentState(panelId: UUID, source: AgentStateSource = .hooks, force: Bool = false) { + guard let current = panelAgentPresence[panelId] else { return } + if !force, (source == .program) != (current.source == .program) { return } panelAgentPresence.removeValue(forKey: panelId) AgentStateWaitRegistry.shared.notify(surfaceId: panelId, newState: nil, source: nil) SocketEventBroadcaster.shared.publishAgentState(workspaceId: id, surfaceId: panelId, state: nil, source: nil) @@ -339,6 +364,11 @@ extension Workspace { // it out here too -- otherwise a surface.wait `agent_state` (or a subscribed client) // watching a surface whose state got wiped by a sidebar reset would hang until timeout // instead of observing the transition to "no state". + for (panelId, panel) in panels { + if (panel as? TerminalPanel)?.programStatus.resetAll() == true { + ProgramStateWaitRegistry.shared.notify(surfaceId: panelId, newState: nil) + } + } let clearedAgentSurfaceIds = Array(panelAgentPresence.keys) panelAgentPresence.removeAll() for surfaceId in clearedAgentSurfaceIds { diff --git a/contracts/v2/methods.json b/contracts/v2/methods.json index 8c1b23f09..0556943fa 100644 --- a/contracts/v2/methods.json +++ b/contracts/v2/methods.json @@ -3817,6 +3817,9 @@ "pattern": { "type": "string" }, + "program_state": { + "type": "string" + }, "surface_id": { "format": "surface:N", "type": "string" diff --git a/docs/ghostty-fork.md b/docs/ghostty-fork.md index ff2846f2a..5a36576f3 100644 --- a/docs/ghostty-fork.md +++ b/docs/ghostty-fork.md @@ -13,12 +13,14 @@ When we change the fork, update this document and the parent submodule SHA. ## Current fork changes -The pinned fork head is `bccfc8333fecf707dd918d46e3849fc8ed72cca0` on fork `main`. It is built on +The pinned fork head is `9d8aeccac` on the fork branch `osc7501-program-status`, which is +fork `main`'s `bccfc8333fecf707dd918d46e3849fc8ed72cca0` plus the OSC 7501 patch group. Never +delete that branch: the pin and its xcframework release point at it. It is built on `ghostty-org/ghostty` `main` at `c8634f3fce12f8189ed058e018195eb693f8562b` and Zig 0.16.0, and -carries the ten patch groups below: display-link restart, resize stale-frame mitigation, OSC 99 +carries the eleven patch groups below: display-link restart, resize stale-frame mitigation, OSC 99 notifications, theme picker hooks, color scheme mode 2031, keyboard copy mode selection API, -layer-background flag, occluded-surface throttle, offscreen renderer realization, and session -introspection and revival APIs (which include the PTY tee). +layer-background flag, occluded-surface throttle, offscreen renderer realization, session +introspection and revival APIs (which include the PTY tee), and OSC 7501 program status. Prebuilt framework for the pinned head: @@ -160,6 +162,33 @@ tend to conflict together during rebases. - Restores surface revival through an existing PTY master fd and running child PID without taking ownership of or signaling that process. - Programa uses the fork's `ghostty_surface_set_pty_tee_cb` callback for its session WAL. That callback runs before VT parsing. There is no separate output-tap API. +### 11) OSC 7501 program status + +- Commits: `79173c0da` (backport of upstream `bae2c3cdb`, ghostty-org/ghostty#14560), + `9d8aeccac` (apprt action) +- Files: + - `include/ghostty.h`, `include/ghostty/vt/osc.h` + - `src/terminal/osc.zig`, `src/terminal/osc/parsers.zig`, + `src/terminal/osc/parsers/program_status.zig`, `src/terminal/osc/kitty_metadata.zig` + - `src/terminal/stream.zig`, `src/terminal/stream_readonly.zig`, `src/terminal/stream_terminal.zig` + - `src/termio/stream_handler.zig`, `src/apprt/surface.zig`, `src/apprt/action.zig`, + `src/Surface.zig`, `src/apprt/gtk/class/application.zig` +- Summary: + - Parses OSC 7501 (Program Status Protocol) and forwards each validated report body to the + embedder as `GHOSTTY_ACTION_PROGRAM_STATUS`. Programa keeps the records. + - Sends `RESET_ALL` on a full reset (RIS) and `PROMPT` on OSC 133;A, both only after the + terminal has seen a report. + - Answers the support query `OSC 7501 ; ?` with the same bytes and terminator. +- Conflict notes: + - The backport takes only the parser and stream action. Upstream's libghostty-vt C API + (`c/terminal.zig`, `vt/terminal.h`) and `stream_terminal.zig` effect were left out because the + pinned tree lacks the effects they build on. `stream_terminal.zig` and `stream_readonly.zig` + ignore `.program_status`. + - `Parser.nextSlice` is a byte loop over `next`; upstream's version appends to the capture in + bulk. Take upstream's on the next sync. + - `GHOSTTY_OSC_COMMAND_PROGRAM_STATUS` is 26 here and 28 upstream, because the pin lacks the + Kitty desktop notification and unknown-OSC commands. Take upstream's value on the next sync. + ## Upstreamed fork changes ### cursor-click-to-move respects OSC 133 click-to-move diff --git a/docs/program-status.md b/docs/program-status.md new file mode 100644 index 000000000..0fb6c0b13 --- /dev/null +++ b/docs/program-status.md @@ -0,0 +1,81 @@ +# Program status (OSC 7501) + +A program running in a Programa terminal can report what it is doing with the Program Status +Protocol, an OSC 7501 escape sequence. Programa shows the result in the sidebar, posts +notifications, and exposes it on the socket. + +```sh +printf '\e]7501;state=working:progress=40:app=cargo\e\\' +printf '\e]7501;state=done:msg=%s\e\\' "$(printf 'Build finished' | base64)" +printf '\e]7501;state=clear\e\\' +``` + +## Keys + +Pairs are separated by `:` and split on the first `=`. Unknown keys are ignored. If a key +repeats, the last value wins. + +| Key | Meaning | +|---|---| +| `state` | Required. `idle`, `working`, `done`, `blocked`, `error`, or `clear`. | +| `id` | The record this report is about. Without it the report is about the program itself (the root record). A `/` makes a child: `deploy/us-east` is a child of `deploy`. Up to 8 segments of 1-32 characters from `A-Za-z0-9_.+-`, 128 bytes in total. | +| `kind` | Only read when `state=blocked`: `permission`, `question`, or `auth`. | +| `progress` | Only read when `state` is `working` or `blocked`: an integer from 0 to 100. | +| `app` | A stable name for the program, 1-32 characters from `A-Za-z0-9_.+-`. A child without `app` uses its nearest ancestor's. | +| `title`, `msg` | Standard base64 text, padding optional. At most 192 and 2048 decoded bytes, valid UTF-8, no control characters. | + +A report replaces its record completely. A report with an invalid id, invalid text or an unknown +state is discarded whole. `state=clear` removes the record named by `id` and every descendant +(`a` removes `a/b`, not `ab`); without `id` it removes all records. Each terminal holds at most +256 records: a report that would create the 257th is discarded, and updates to existing ids +still apply. Records are not saved with the session. + +## Lifetimes + +- A shell prompt (shell integration) removes `working` and `blocked` records at every depth. + `done`, `error` and `idle` survive prompts. +- A full terminal reset (`printf '\ec'`) removes all records. +- Closing the surface removes its records. A program that exits without a shell prompt being + printed keeps its record until the next report, a clear, or a reset. + +## What Programa shows + +Only the root record drives the UI. Child records are stored and counted against the limit but +are not displayed. + +- Sidebar: `working` and `blocked` show as before; `done` and `error` show a check and a stop + icon. A blocked or working surface in the same workspace outranks them. +- Notifications: when the root record enters `blocked`, `done` or `error` on a surface you are + not looking at, Programa posts one notification, at most one per 10 seconds per surface. The + notification title is the Programa tab title and the body names the state. The program's + `title` and `msg` stay out of it, because `notification.list` returns notification text over + the socket and the spec forbids revealing record contents back to programs. Workspaces with a hook-managed agent are skipped, since the hooks notify. + There is no setting for this. +- Socket: `surface.list` and `system.tree` return `program_status`, and `surface.wait` accepts + `program_state`. Record text and child ids are never returned. See `docs/socket-api.md`. +- `agent_state` projects the root record: `working` and `blocked` map to themselves, and `idle`, + `done` and `error` map to `idle`. The surface's `agent_state_source` reads `"program"`. + +## Authority + +OSC 7501 outranks hooks, and hooks outrank screen inference. + +1. A program report always applies. +2. While the root record is `working` or `blocked`, hook and inferred reports are ignored, and so + is a hook clear. +3. While the root record is `idle`, `done` or `error`, a hook report is accepted only if it says + `working` or `blocked`. Then the hook owns the surface's `agent_state`, and the record stays + visible in `program_status`. + +`surface.report_agent_state` rejects `source: "program"`. + +## Hooks emit it too + +The Claude Code, Codex and OpenCode hooks write OSC 7501 to the terminal as well as reporting to +the socket, so the report also reaches a terminal where the socket is out of reach, such as an SSH +session. They emit only when `PROGRAMA_SURFACE_ID` is set, and they skip emission inside tmux +(`TMUX` is set), where the sequence would need passthrough and can reach the wrong pane. + +## Not covered + +OSC 9;4 progress is a separate feature and is not mapped to program status. diff --git a/docs/socket-api.md b/docs/socket-api.md index 154c278f8..7db6c992e 100644 --- a/docs/socket-api.md +++ b/docs/socket-api.md @@ -351,7 +351,7 @@ until a condition is met, instead of the caller polling `surface.read_text` in a request, one response — no subscription/unsubscribe pair to manage. CLI: `programa wait-surface`. -Request params (exactly one of `pattern` / `exit` / `agent_state` is required): +Request params (exactly one of `pattern` / `exit` / `agent_state` / `program_state` is required): | Field | Type | Required | Notes | |---|---|---|---| @@ -359,6 +359,7 @@ Request params (exactly one of `pattern` / `exit` / `agent_state` is required): | `pattern` | string | one of `pattern`/`exit`/`agent_state` | Regex (ICU/`NSRegularExpression` syntax) matched against the surface's current text (screen + scrollback) on every check. | | `exit` | bool | one of `pattern`/`exit`/`agent_state` | Wait for the surface's child process to exit. | | `agent_state` | string | one of `pattern`/`exit`/`agent_state` | Wait for the surface's #164 agent activity state to reach `idle`, `working`, `blocked`, or transition at all (`any_change`). | +| `program_state` | string | one of the conditions | Wait for the surface's OSC 7501 root record to reach `idle`, `working`, `done`, `blocked`, `error`, `cleared` (no root record), or transition at all (`any_change`). See `docs/program-status.md`. | | `timeout_ms` | int | no | Default `30000`, at most `3600000` (one hour). Also accepts `timeout` (same units) for convenience. | | `lines` | int | no | Caps how many trailing lines of scrollback `pattern` rereads per check (default `2000`); does not apply to `exit`/`agent_state`. | @@ -390,8 +391,11 @@ Response (`ok: true`): lifecycle hook — Claude Code/Codex/OpenCode) or `"inferred"` (the screen-manifest detection engine pattern-matching the terminal buffer for agents with no installed hooks). `null` iff `state` is `null`. Hooks always win: a surface's source only ever reads `"inferred"` if no - hook has ever reported for it. Manifest schema/authoring guidance for the `"inferred"` tier: + hook has ever reported for it. `"program"` means the program in the terminal reported its own + status with OSC 7501 (`docs/program-status.md`); `surface.report_agent_state` rejects + `source: "program"` with `invalid_params`. Manifest schema/authoring guidance for the `"inferred"` tier: `docs/agent-detection-manifests.md`. +- `program_state` is only present for `program_state` waits and is the root record's state at resolution (`null` when the surface has no root record). A surface with no root record satisfies only `cleared`, never `idle`. - Timeout: `{"ok": false, "error": {"code": "timeout", "message": "...", "data": {"timeout_ms": N}}}`. - If the target surface does not exist when the call arrives, the response is `not_found`. - If the surface closes while the wait is in flight, the wait resolves at once with @@ -437,6 +441,21 @@ does for child-exit) at a fixed ~100ms interval on the connection's own thread; `TerminalController+Telemetry.swift`'s `v2SurfaceReportAgentState`/`v2SurfaceClearAgentState` via `DispatchQueue.main.async`). +### `program_status` on `surface.list` and `system.tree` + +Every terminal surface entry carries `program_status`: the surface's OSC 7501 root record, or +`null` when it has none. `agent_state` keeps its three values; `program_status.state` is how a +client tells `done` and `error` from `idle`. + +```json +"program_status": {"state": "done", "kind": null, "progress": null, "app": "claude-code", "has_message": true, "updated_at": 1759800000.0} +``` + +Record text is never on the wire. The object has no `msg`, no `title` and no child ids, because +any process in a Programa terminal can read the socket and the protocol forbids revealing record +contents back to programs. `has_message` says whether a message exists. `app` is the identifier +the program chose, limited to 1-32 characters of `A-Za-z0-9_.+-`. + ## `surface.resolve_tty` Finds the terminal surface attached to a tty, for CLI hooks that bind their caller by tty. @@ -619,6 +638,7 @@ request they're a response to) — each is its own single-line JSON object with - `agent_state.source` is the same additive `"hooks" | "inferred" | null` sibling documented under `surface.wait`'s `agent_state` condition above (screen-manifest detection, docs/plans/screen-manifest-detection.md) — `null` iff `state` is `null`. +- `agent_state.program_status` is present only when `source` is `"program"`, with the same object `surface.list` returns. Frames fire when the presence changes, so a `done` to `error` change is delivered although both read `agent_state: "idle"`. - `output.text` is the *newly appended* tail since the last tick for that surface, capped at 4000 characters — never the full buffer, and never per-byte (see Backpressure below). - `workspace_lifecycle.kind` is `created`, `closed`, or `renamed`. `renamed` fires only from the diff --git a/ghostty b/ghostty index bccfc8333..9d8aeccac 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit bccfc8333fecf707dd918d46e3849fc8ed72cca0 +Subproject commit 9d8aeccac8a76906864c684b340af09f29d13487 diff --git a/programaTests/AgentActivityStateTests.swift b/programaTests/AgentActivityStateTests.swift index ec7073e07..9892dc877 100644 --- a/programaTests/AgentActivityStateTests.swift +++ b/programaTests/AgentActivityStateTests.swift @@ -558,4 +558,80 @@ final class AgentActivityStateTests: XCTestCase { XCTAssertEqual(freshIndicator?.tint, .blocked) XCTAssertFalse(freshIndicator?.isStale ?? true) } + + // MARK: - OSC 7501 program authority (docs/plans/osc7501-program-status.md D7) + + func testProgramActiveBlocksHooksAndInferredWrites() { + let workspace = Workspace(title: "Test") + let panel = UUID() + workspace.updatePanelAgentState(panelId: panel, state: .working, source: .program, programState: .working) + workspace.updatePanelAgentState(panelId: panel, state: .blocked, source: .hooks) + workspace.updatePanelAgentState(panelId: panel, state: .idle, source: .hooks) + workspace.updatePanelAgentState(panelId: panel, state: .blocked, source: .inferred) + XCTAssertEqual(workspace.panelAgentStates[panel], .working) + XCTAssertEqual(workspace.panelAgentStateSources[panel], .program) + XCTAssertEqual(workspace.panelAgentPresence[panel]?.programState, .working) + } + + func testRestingProgramAcceptsOnlyActiveHookClaims() { + let workspace = Workspace(title: "Test") + let panel = UUID() + workspace.updatePanelAgentState(panelId: panel, state: .idle, source: .program, programState: .done) + workspace.updatePanelAgentState(panelId: panel, state: .idle, source: .hooks) + XCTAssertEqual(workspace.panelAgentStateSources[panel], .program) + XCTAssertEqual(workspace.panelAgentPresence[panel]?.programState, .done) + + workspace.updatePanelAgentState(panelId: panel, state: .working, source: .hooks) + XCTAssertEqual(workspace.panelAgentStates[panel], .working) + XCTAssertEqual(workspace.panelAgentStateSources[panel], .hooks) + XCTAssertNil(workspace.panelAgentPresence[panel]?.programState) + } + + func testProgramWriteAlwaysAppliesOverHooks() { + let workspace = Workspace(title: "Test") + let panel = UUID() + workspace.updatePanelAgentState(panelId: panel, state: .working, source: .hooks) + workspace.updatePanelAgentState(panelId: panel, state: .blocked, source: .program, programState: .blocked) + XCTAssertEqual(workspace.panelAgentStates[panel], .blocked) + XCTAssertEqual(workspace.panelAgentStateSources[panel], .program) + } + + func testHooksClearIgnoredWhileProgramOwnedAndProgramClearOnlyRemovesProgram() { + let workspace = Workspace(title: "Test") + let panel = UUID() + workspace.updatePanelAgentState(panelId: panel, state: .working, source: .program, programState: .working) + workspace.clearPanelAgentState(panelId: panel) + XCTAssertNotNil(workspace.panelAgentPresence[panel]) + + workspace.clearPanelAgentState(panelId: panel, source: .program) + XCTAssertNil(workspace.panelAgentPresence[panel]) + + workspace.updatePanelAgentState(panelId: panel, state: .working, source: .hooks) + workspace.clearPanelAgentState(panelId: panel, source: .program) + XCTAssertNotNil(workspace.panelAgentPresence[panel]) + + workspace.updatePanelAgentState(panelId: panel, state: .working, source: .program, programState: .working) + workspace.clearPanelAgentState(panelId: panel, force: true) + XCTAssertNil(workspace.panelAgentPresence[panel]) + } + + func testDoneToErrorFiresPresenceChangeDespiteSameLegacyState() { + let workspace = Workspace(title: "Test") + let panel = UUID() + workspace.updatePanelAgentState(panelId: panel, state: .idle, source: .program, programState: .done) + let before = workspace.panelAgentPresence[panel] + workspace.updatePanelAgentState( + panelId: panel, state: .idle, source: .program, programState: .error, at: Date().addingTimeInterval(1) + ) + XCTAssertEqual(workspace.panelAgentPresence[panel]?.programState, .error) + XCTAssertNotEqual(workspace.panelAgentPresence[panel], before) + } + + func testProgramPresenceIsNeverStale() { + let workspace = Workspace(title: "Test") + let panel = UUID() + let longAgo = Date().addingTimeInterval(-3600) + workspace.updatePanelAgentState(panelId: panel, state: .working, source: .program, programState: .working, at: longAgo) + XCTAssertEqual(workspace.panelAgentPresence[panel]?.isStale(now: Date()), false) + } } diff --git a/programaTests/ProgramStatusStoreTests.swift b/programaTests/ProgramStatusStoreTests.swift new file mode 100644 index 000000000..6d7157036 --- /dev/null +++ b/programaTests/ProgramStatusStoreTests.swift @@ -0,0 +1,235 @@ +// OSC 7501 program status: report parsing and the per-terminal record store. +import XCTest +import AppKit + +#if canImport(Programa_DEV) +@testable import Programa_DEV +#elseif canImport(Programa) +@testable import Programa +#endif + +@MainActor +final class ProgramStatusStoreTests: XCTestCase { + private func b64(_ text: String) -> String { Data(text.utf8).base64EncodedString() } + + private func parse(_ state: ProgramStatusState?, _ body: String) -> ProgramStatusReport? { + ProgramStatusReport.parse(state: state, body: Data(body.utf8)) + } + + private func report(_ state: ProgramStatusState?, id: String = "", app: String? = nil) throws -> ProgramStatusReport { + var body = id.isEmpty ? "" : "id=\(id)" + if let app { body += (body.isEmpty ? "" : ":") + "app=\(app)" } + return try XCTUnwrap(parse(state, body)) + } + + // MARK: Parsing + + func testParsesAllFields() throws { + let parsed = try XCTUnwrap(parse( + .blocked, + "id=deploy/us-east:kind=question:progress=40:app=cargo:title=\(b64("Deploy")):msg=\(b64("Pick one"))" + )) + XCTAssertEqual(parsed.id, "deploy/us-east") + XCTAssertEqual(parsed.kind, .question) + XCTAssertEqual(parsed.progress, 40) + XCTAssertEqual(parsed.app, "cargo") + XCTAssertEqual(parsed.title, "Deploy") + XCTAssertEqual(parsed.msg, "Pick one") + } + + func testLastValueWinsUnknownKeysAndMalformedPairsIgnored() throws { + let parsed = try XCTUnwrap(parse(.working, "app=one:bogus=1:junk:app=two:progress=10:progress=20")) + XCTAssertEqual(parsed.app, "two") + XCTAssertEqual(parsed.progress, 20) + XCTAssertEqual(parsed.id, "") + } + + func testKindIgnoredUnlessBlockedAndProgressIgnoredWhenResting() throws { + XCTAssertNil(try XCTUnwrap(parse(.working, "kind=auth")).kind) + XCTAssertEqual(try XCTUnwrap(parse(.blocked, "kind=auth")).kind, .auth) + XCTAssertNil(try XCTUnwrap(parse(.blocked, "kind=bogus")).kind) + XCTAssertNil(try XCTUnwrap(parse(.done, "progress=50")).progress) + } + + func testProgressOutOfRangeAndBadAppReadAsNil() throws { + XCTAssertNil(try XCTUnwrap(parse(.working, "progress=101")).progress) + XCTAssertNil(try XCTUnwrap(parse(.working, "progress=-1")).progress) + XCTAssertNil(try XCTUnwrap(parse(.working, "progress=abc")).progress) + XCTAssertEqual(try XCTUnwrap(parse(.working, "progress=100")).progress, 100) + XCTAssertNil(try XCTUnwrap(parse(.working, "app=bad name")).app) + XCTAssertNil(try XCTUnwrap(parse(.working, "app=\(String(repeating: "a", count: 33))")).app) + } + + func testBase64PaddingIsOptional() throws { + XCTAssertEqual(try XCTUnwrap(parse(.done, "msg=aGk")).msg, "hi") + XCTAssertEqual(try XCTUnwrap(parse(.done, "msg=aGk=")).msg, "hi") + } + + func testInvalidTextDiscardsWholeReport() { + XCTAssertNil(parse(.done, "msg=!!!!")) + XCTAssertNil(parse(.done, "msg=\(b64("a\u{07}b"))")) + XCTAssertNil(parse(.done, "msg=\(b64("a\u{7F}b"))")) + XCTAssertNil(parse(.done, "title=\(b64("a\u{85}b"))")) + XCTAssertNil(parse(.done, "msg=\(b64(String(repeating: "x", count: 2049)))")) + XCTAssertNotNil(parse(.done, "msg=\(b64(String(repeating: "x", count: 2048)))")) + XCTAssertNil(parse(.done, "title=\(b64(String(repeating: "x", count: 193)))")) + XCTAssertNotNil(parse(.done, "title=\(b64(String(repeating: "x", count: 192)))")) + } + + func testInvalidIdDiscardsWholeReport() { + XCTAssertNil(parse(.working, "id=a//b")) + XCTAssertNil(parse(.working, "id=/a")) + XCTAssertNil(parse(.working, "id=a/")) + XCTAssertNil(parse(.working, "id=a b")) + XCTAssertNil(parse(.working, "id=\(String(repeating: "a", count: 33))")) + XCTAssertNil(parse(.working, "id=a/b/c/d/e/f/g/h/i")) + XCTAssertNotNil(parse(.working, "id=a/b/c/d/e/f/g/h")) + let longId = (0..<5).map { _ in String(repeating: "a", count: 30) }.joined(separator: "/") + XCTAssertNil(parse(.working, "id=\(longId)")) + } + + func testClearReportHasNoState() throws { + let parsed = try XCTUnwrap(parse(nil, "id=a")) + XCTAssertNil(parsed.state) + XCTAssertEqual(parsed.id, "a") + } + + /// Ghostty trims keys and values; a clear with a padded `id` must still target that record + /// rather than falling through to clear-all. + func testKeysAndValuesAreTrimmedLikeGhostty() throws { + let parsed = try XCTUnwrap(parse(nil, " id = build ")) + XCTAssertEqual(parsed.id, "build") + XCTAssertEqual(try XCTUnwrap(parse(.working, "progress= 40 ")).progress, 40) + } + + // MARK: Store + + func testApplyUpsertsRootAndProjectsLegacyState() throws { + let store = ProgramStatusStore() + XCTAssertEqual(store.apply(try report(.working)), .applied(rootTouched: true)) + XCTAssertEqual(store.root?.state, .working) + XCTAssertEqual(store.apply(try report(.done)), .applied(rootTouched: true)) + XCTAssertEqual(store.root?.state, .done) + XCTAssertEqual(store.count, 1) + XCTAssertEqual(ProgramStatusState.working.legacyAgentState, .working) + XCTAssertEqual(ProgramStatusState.blocked.legacyAgentState, .blocked) + for state in [ProgramStatusState.idle, .done, .error] { + XCTAssertEqual(state.legacyAgentState, .idle) + } + } + + func testCapacityAcceptsRecord256RejectsNewRecord257AndAllowsUpdates() throws { + let store = ProgramStatusStore() + for index in 0.. None: + """Send text without ProgramaClient.send_surface's backslash unescaping, which would + collapse the double backslash of the string terminator before the shell sees it.""" + client._call("surface.send_text", {"surface_id": surface_id, "text": text}) + + +def _osc(body: str) -> str: + return f"printf '{OSC}{body}{ST}'" + + +def _run(client: ProgramaClient, surface_id: str, command: str) -> None: + _send_raw(client, surface_id, command + "\n") + + +def _emit_held(client: ProgramaClient, surface_id: str, body: str, hold_s: int = HOLD_S) -> None: + """Emit one OSC 7501 and keep the command running so no prompt (133;A) drops the record.""" + _run(client, surface_id, f"{_osc(body)}; sleep {hold_s}") + + +def _emit_marked(client: ProgramaClient, workspace_id: str, surface_id: str, commands: list[str]) -> None: + """Run commands then echo a unique marker and wait for the marker line. Proves the shell + executed every printf before the caller asserts a negative (no change).""" + marker = f"PROGRAMA_7501_{secrets.token_hex(4)}" + _run(client, surface_id, "; ".join(commands + [f"echo {marker}"])) + _wait_for( + lambda: _scrollback_has_exact_line(client, workspace_id, surface_id, marker), + timeout_s=10.0, + ) + + +def _wait( + client: ProgramaClient, workspace_id: str, surface_id: str, *, timeout_ms: int = WAIT_TIMEOUT_MS, **cond: str +) -> dict: + params = {"workspace_id": workspace_id, "surface_id": surface_id, "timeout_ms": timeout_ms, **cond} + # Client-side read timeout must exceed the server-side wait timeout. + return client._call("surface.wait", params, timeout_s=(timeout_ms / 1000.0) + 5.0) or {} + + +def _surface(client: ProgramaClient, workspace_id: str, surface_id: str) -> dict: + listed = client._call("surface.list", {"workspace_id": workspace_id}) or {} + for row in listed.get("surfaces") or []: + if str(row.get("id")) == surface_id: + return row + raise ProgramaClientError(f"surface {surface_id} not in surface.list: {listed}") + + +def _poll_surface(client, workspace_id, surface_id, pred, what: str, timeout_s: float = 10.0) -> dict: + """Poll surface.list with a deadline, for conditions surface.wait cannot express.""" + deadline = time.time() + timeout_s + last: dict = {} + while time.time() < deadline: + last = _surface(client, workspace_id, surface_id) + if pred(last): + return last + time.sleep(0.1) + raise ProgramaClientError(f"timed out waiting for {what}; last surface entry: {last}") + + +def _reset(client: ProgramaClient, workspace_id: str, surface_id: str) -> None: + """Interrupt any held command, then RIS clears every record (case 10 asserts RIS itself).""" + client._call("surface.send_key", {"surface_id": surface_id, "key": "ctrl+c"}) + _emit_marked(client, workspace_id, surface_id, [RIS]) + _poll_surface( + client, workspace_id, surface_id, + lambda s: s.get("program_status") is None, "program_status null after RIS", + ) + + +def _must_have_no_text_fields(status: dict, where: str) -> None: + for banned in ("msg", "title", "message", "children"): + _must(banned not in status, f"{where}: program_status must not expose {banned!r}: {status}") + + +def main() -> int: + workspace_id = "" + + try: + with ProgramaClient(SOCKET_PATH) as client: + workspace_id = client.new_workspace() + client.select_workspace(workspace_id) + surfaces = client.list_surfaces(workspace_id) + _must(bool(surfaces), f"new workspace should have at least one surface: {surfaces}") + surface_id = surfaces[0][1] + _wait_for_surface_command_roundtrip(client, workspace_id, surface_id) + + baseline = _surface(client, workspace_id, surface_id) + _must(baseline.get("program_status") is None, f"program_status should start null: {baseline}") + + # 1. working -> agent_state working, source program, program_status.state working. + _emit_held(client, surface_id, "state=working:app=fake") + res = _wait(client, workspace_id, surface_id, program_state="working") + _must(res.get("program_state") == "working", f"case 1 wait: {res}") + row = _surface(client, workspace_id, surface_id) + ps = row.get("program_status") or {} + _must(row.get("agent_state") == "working", f"case 1 agent_state: {row}") + _must(row.get("agent_state_source") == "program", f"case 1 agent_state_source: {row}") + _must(ps.get("state") == "working" and ps.get("app") == "fake", f"case 1 program_status: {ps}") + _must(isinstance(ps.get("updated_at"), (int, float)), f"case 1 updated_at: {ps}") + _must_have_no_text_fields(ps, "case 1") + agent_wait = _wait(client, workspace_id, surface_id, agent_state="working") + _must(agent_wait.get("state") == "working", f"case 1 agent_state wait: {agent_wait}") + + # 2. blocked with kind=permission. + _reset(client, workspace_id, surface_id) + _emit_held(client, surface_id, "state=blocked:kind=permission") + res = _wait(client, workspace_id, surface_id, program_state="blocked") + _must(res.get("program_state") == "blocked", f"case 2 wait: {res}") + row = _surface(client, workspace_id, surface_id) + ps = row.get("program_status") or {} + _must(row.get("agent_state") == "blocked", f"case 2 agent_state: {row}") + _must(ps.get("state") == "blocked" and ps.get("kind") == "permission", f"case 2 program_status: {ps}") + + # 3. done with a base64 msg: reads as idle, has_message true, message text never on the wire. + _reset(client, workspace_id, surface_id) + secret_text = f"secret-body-{secrets.token_hex(6)}" + b64 = base64.b64encode(secret_text.encode()).decode() + _emit_marked(client, workspace_id, surface_id, [_osc(f"state=done:msg={b64}")]) + res = _wait(client, workspace_id, surface_id, program_state="done") + _must(res.get("program_state") == "done", f"case 3 wait: {res}") + row = _surface(client, workspace_id, surface_id) + ps = row.get("program_status") or {} + _must(row.get("agent_state") == "idle", f"case 3 done must read as agent_state idle: {row}") + _must(row.get("agent_state_source") == "program", f"case 3 source: {row}") + _must(ps.get("state") == "done" and ps.get("has_message") is True, f"case 3 program_status: {ps}") + _must_have_no_text_fields(ps, "case 3") + for payload in (res, row): + blob = repr(payload) + _must(secret_text not in blob and b64 not in blob, f"case 3 message leaked onto the wire: {payload}") + + # 4. A prompt (133;A) drops working but keeps done and error. 133;A is printed + # explicitly so the case does not depend on the test shell's own integration. + _reset(client, workspace_id, surface_id) + _emit_marked(client, workspace_id, surface_id, [_osc("state=working"), PROMPT_MARK]) + _poll_surface( + client, workspace_id, surface_id, + lambda s: s.get("program_status") is None, "case 4 working dropped by prompt", + ) + _emit_marked(client, workspace_id, surface_id, [_osc("state=done"), PROMPT_MARK]) + row = _surface(client, workspace_id, surface_id) + _must((row.get("program_status") or {}).get("state") == "done", f"case 4 done must survive a prompt: {row}") + _emit_marked(client, workspace_id, surface_id, [_osc("state=error"), PROMPT_MARK]) + row = _surface(client, workspace_id, surface_id) + _must((row.get("program_status") or {}).get("state") == "error", f"case 4 error must survive a prompt: {row}") + _must(row.get("agent_state") == "idle", f"case 4 error must read as idle: {row}") + + # 5. Authority. Hooks idle cannot end a program working claim; while the program + # says done, a hooks working is accepted. + _reset(client, workspace_id, surface_id) + _emit_held(client, surface_id, "state=working") + _wait(client, workspace_id, surface_id, program_state="working") + client._call( + "surface.report_agent_state", + {"workspace_id": workspace_id, "surface_id": surface_id, "state": "idle"}, + ) + row = _surface(client, workspace_id, surface_id) + _must(row.get("agent_state") == "working", f"case 5 hooks idle must not override program working: {row}") + _must(row.get("agent_state_source") == "program", f"case 5 source after hooks idle: {row}") + _reset(client, workspace_id, surface_id) + _emit_marked(client, workspace_id, surface_id, [_osc("state=done")]) + _wait(client, workspace_id, surface_id, program_state="done") + client._call( + "surface.report_agent_state", + {"workspace_id": workspace_id, "surface_id": surface_id, "state": "working"}, + ) + row = _surface(client, workspace_id, surface_id) + _must(row.get("agent_state") == "working", f"case 5 hooks working must win over program done: {row}") + _must(row.get("agent_state_source") == "hooks", f"case 5 source after hooks working: {row}") + + # 6. A socket client cannot impersonate the terminal tier. + error_text = "" + try: + client._call( + "surface.report_agent_state", + {"workspace_id": workspace_id, "surface_id": surface_id, "state": "working", "source": "program"}, + ) + except ProgramaClientError as exc: + error_text = str(exc) + _must("invalid_params" in error_text, f"case 6 source=program must be invalid_params, got: {error_text!r}") + + # 7. Child records. Children are not on the wire, so assert the root is unaffected and + # a later root update still applies, including after 257 children (cap is 256). + _reset(client, workspace_id, surface_id) + _emit_held(client, surface_id, "state=working:app=root") + _wait(client, workspace_id, surface_id, program_state="working") + # Held command owns the shell; children go through a second surface-less path: wait + # for the hold to finish by interrupting it, then re-assert the root via a done record + # that survives prompts. + client._call("surface.send_key", {"surface_id": surface_id, "key": "ctrl+c"}) + _emit_marked(client, workspace_id, surface_id, [ + _osc("state=done:app=root"), + _osc("state=working:id=a"), + _osc("state=working:id=a/b"), + _osc("state=clear:id=a"), + ]) + ps = _surface(client, workspace_id, surface_id).get("program_status") or {} + _must(ps.get("state") == "done" and ps.get("app") == "root", f"case 7 root must be unaffected by child ops: {ps}") + flood = f"for i in $(seq 1 257); do printf '{OSC}state=working:id=c%s{ST}' \"$i\"; done" + _emit_marked(client, workspace_id, surface_id, [flood]) + _emit_marked(client, workspace_id, surface_id, [_osc("state=error:app=root")]) + ps = _surface(client, workspace_id, surface_id).get("program_status") or {} + _must(ps.get("state") == "error", f"case 7 root update must still apply after 257 child records: {ps}") + + # 8. Malformed input changes nothing: control char in decoded msg, unknown state. + _reset(client, workspace_id, surface_id) + _emit_marked(client, workspace_id, surface_id, [_osc("state=done:app=keep")]) + before = _surface(client, workspace_id, surface_id).get("program_status") or {} + _must(before.get("state") == "done", f"case 8 precondition: {before}") + bad_b64 = base64.b64encode(b"bad\x07body").decode() # decodes to a BEL control char + _emit_marked(client, workspace_id, surface_id, [ + _osc(f"state=done:app=keep:msg={bad_b64}"), + _osc("state=bogus"), + ]) + after = _surface(client, workspace_id, surface_id).get("program_status") or {} + _must( + after.get("state") == "done" and after.get("app") == "keep" + and after.get("has_message") is False + and after.get("updated_at") == before.get("updated_at"), + f"case 8 malformed sequences must not change program_status: before={before} after={after}", + ) + + # 9. Query smoke check; the byte-exact reply is covered by the T-3 Zig test. + _reset(client, workspace_id, surface_id) + _run( + client, surface_id, + "stty -echo; printf '\\033]7501;?\\033\\\\'; IFS= read -r -t 2 -d $'\\\\' reply; stty echo; " + "printf 'REPLY=%q\\n' \"$reply\"", + ) + reply_re = re.compile(r"^REPLY=.*7501") + _wait_for( + lambda: any( + reply_re.match(line.strip()) and "%q" not in line + for line in _surface_scrollback_text(client, workspace_id, surface_id).splitlines() + ), + timeout_s=10.0, + ) + + # 10. RIS clears everything. + _emit_held(client, surface_id, "state=working:app=ris") + _wait(client, workspace_id, surface_id, program_state="working") + client._call("surface.send_key", {"surface_id": surface_id, "key": "ctrl+c"}) + _emit_marked(client, workspace_id, surface_id, [RIS]) + row = _poll_surface( + client, workspace_id, surface_id, + lambda s: s.get("program_status") is None, "case 10 program_status null after RIS", + ) + _must(row.get("agent_state_source") != "program", f"case 10 source after RIS: {row}") + + client.close_workspace(workspace_id) + workspace_id = "" + finally: + if workspace_id: + try: + with ProgramaClient(SOCKET_PATH) as cleanup_client: + cleanup_client.close_workspace(workspace_id) + except Exception: + pass + + print("PASS: OSC 7501 program status drives agent_state and program_status per spec") + return 0 + + +if __name__ == "__main__": + sys.exit(main())