diff --git a/ProwlCLI/Commands/AgentsCommand.swift b/ProwlCLI/Commands/AgentsCommand.swift index 5ccfa532..b4ab3232 100644 --- a/ProwlCLI/Commands/AgentsCommand.swift +++ b/ProwlCLI/Commands/AgentsCommand.swift @@ -6,7 +6,8 @@ import ProwlCLIShared struct AgentsCommand: ParsableCommand { static let configuration = CommandConfiguration( commandName: "agents", - abstract: "List detected agent panes." + abstract: "List detected agent panes.", + subcommands: [AgentsReadCommand.self] ) @OptionGroup var options: GlobalOptions diff --git a/ProwlCLI/Commands/AgentsReadCommand.swift b/ProwlCLI/Commands/AgentsReadCommand.swift new file mode 100644 index 00000000..b7f8cc9f --- /dev/null +++ b/ProwlCLI/Commands/AgentsReadCommand.swift @@ -0,0 +1,60 @@ +import ArgumentParser +import Foundation +import ProwlCLIShared + +struct AgentsReadCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "read", + abstract: "Read an immediate semantic snapshot of a Codex or Claude Code agent." + ) + + @Argument(help: "Target agent pane handle (pN) or pane UUID from prowl agents.") + var pane: String + + @Option( + name: .long, + help: "Maximum result size in bytes (1–4194304, default: 1048576)." + ) + var maxBytes = AgentReadInput.defaultMaxBytes + + @Flag(name: .long, help: "Write only a complete trusted result, with no snapshot header.") + var resultOnly = false + + @OptionGroup var options: GlobalOptions + + mutating func run() throws { + try CLIExecution.run(command: "agents.read", output: options.outputMode, colorEnabled: options.colorEnabled) { + try validateOutputMode() + let envelope = CommandEnvelope( + output: options.outputMode, + command: .agentsRead(AgentReadInput(pane: pane, maxBytes: maxBytes, resultOnly: resultOnly)) + ) + try CLIRunner.execute(envelope) + } + } + + func validate() throws { + guard Self.isExplicitPaneTarget(pane) else { + throw ValidationError("agents read requires a pane handle (pN) or pane UUID.") + } + guard (1...AgentReadInput.maximumMaxBytes).contains(maxBytes) else { + throw ValidationError("--max-bytes must be between 1 and \(AgentReadInput.maximumMaxBytes).") + } + } + + func validateOutputMode() throws { + guard !(resultOnly && options.json) else { + throw ExitError(code: CLIErrorCode.invalidArgument, message: "--result-only cannot be combined with --json.") + } + } + + private static func isExplicitPaneTarget(_ value: String) -> Bool { + if UUID(uuidString: value) != nil { return true } + let normalized = value.lowercased() + guard normalized.hasPrefix("p") else { return false } + let digits = normalized.dropFirst() + return !digits.isEmpty + && digits.allSatisfy { $0.isASCII && $0.isNumber } + && Int(digits).map({ $0 > 0 }) == true + } +} diff --git a/ProwlCLI/Output/OutputRenderer.swift b/ProwlCLI/Output/OutputRenderer.swift index ee32c4b8..54a958bf 100644 --- a/ProwlCLI/Output/OutputRenderer.swift +++ b/ProwlCLI/Output/OutputRenderer.swift @@ -68,6 +68,14 @@ enum OutputRenderer { return } + if response.command == "agents.read", + let data = response.data, + let payload = try? data.decode(as: AgentReadCommandPayload.self) + { + renderAgentsRead(payload) + return + } + if response.command == "focus", let data = response.data, let payload = try? data.decode(as: FocusCommandPayload.self) @@ -251,6 +259,49 @@ enum OutputRenderer { }.joined(separator: "\n") } + private static func renderAgentsRead(_ payload: AgentReadCommandPayload) { + if let data = agentReadResultOnlyData(payload) { + FileHandle.standardOutput.write(data) + return + } + print(agentReadSnapshotText(payload)) + } + + static func agentReadResultOnlyData(_ payload: AgentReadCommandPayload) -> Data? { + guard payload.outputMode == .resultOnly, let text = payload.result.text else { return nil } + return Data(text.utf8) + } + + static func agentReadSnapshotText(_ payload: AgentReadCommandPayload) -> String { + var lines = [ + "Agent: \(payload.agent.type)", + "Status: \(payload.agent.status.rawValue)", + ] + if let reason = payload.agent.detectionReason { + lines.append("Reason: \(reason)") + } + lines.append("Changed: \(payload.agent.lastChangedAt)") + + let result = payload.result + if let error = result.error { + lines.append("Result: \(result.state.rawValue) (\(error.code))") + } else { + lines.append("Result: \(result.state.rawValue)") + } + + if let blocker = payload.blocker { + lines.append("") + lines.append("## Blocker") + lines.append(blocker.text) + } + if let text = result.text { + lines.append("") + lines.append("## Latest result") + lines.append(text) + } + return lines.joined(separator: "\n") + } + private static func agentStatusLabel(_ status: AgentsCommandStatus) -> String { switch status { case .blocked: diff --git a/ProwlCLI/Transport/SocketTransportClient.swift b/ProwlCLI/Transport/SocketTransportClient.swift index b685b79b..2f7420f6 100644 --- a/ProwlCLI/Transport/SocketTransportClient.swift +++ b/ProwlCLI/Transport/SocketTransportClient.swift @@ -11,6 +11,8 @@ import ProwlCLIShared #endif enum SocketTransportClient { + private static let maximumResponseLength = 32 * 1_024 * 1_024 + /// Send a command envelope to the Prowl app and receive a response. static func send(_ envelope: CommandEnvelope) throws -> Data { let socketPath = ProwlSocket.defaultPath @@ -46,7 +48,7 @@ enum SocketTransportClient { UInt32(bigEndian: $0.load(as: UInt32.self)) } - guard responseLength > 0, responseLength < 10_000_000 else { + guard responseLength > 0, responseLength <= maximumResponseLength else { throw ExitError( code: CLIErrorCode.transportFailed, message: "Invalid response length from app." diff --git a/ProwlCLITests/AgentReadOutputRendererTests.swift b/ProwlCLITests/AgentReadOutputRendererTests.swift new file mode 100644 index 00000000..f4191ae1 --- /dev/null +++ b/ProwlCLITests/AgentReadOutputRendererTests.swift @@ -0,0 +1,61 @@ +import Foundation +import ProwlCLIShared +import XCTest + +@testable import prowl + +final class AgentReadOutputRendererTests: XCTestCase { + func testResultOnlyDataIsVerbatimWithoutSyntheticNewline() { + let payload = makePayload( + outputMode: .resultOnly, + status: .done, + blocker: nil, + result: AgentReadResult(state: .complete, text: "Final result") + ) + + XCTAssertEqual(OutputRenderer.agentReadResultOnlyData(payload), Data("Final result".utf8)) + } + + func testSnapshotTextIncludesStatusAndVerbatimBlocker() { + let payload = makePayload( + status: .blocked, + blocker: "Do you want to proceed?\n❯ 1. Yes\n 2. No", + result: AgentReadResult( + state: .unavailable, + error: AgentReadResultError(code: "SESSION_UNRESOLVED", message: "No trusted session.") + ) + ) + + let text = OutputRenderer.agentReadSnapshotText(payload) + + XCTAssertTrue(text.contains("Status: blocked")) + XCTAssertTrue(text.contains("Result: unavailable (SESSION_UNRESOLVED)")) + XCTAssertTrue(text.contains("## Blocker\nDo you want to proceed?\n❯ 1. Yes\n 2. No")) + } + + private func makePayload( + outputMode: AgentReadOutputMode = .snapshot, + status: AgentsCommandStatus, + blocker: String?, + result: AgentReadResult + ) -> AgentReadCommandPayload { + AgentReadCommandPayload( + outputMode: outputMode, + target: ReadTarget( + worktree: ReadTargetWorktree(id: "/tmp/project", name: "main", path: "/tmp/project", rootPath: "/tmp/project", kind: "git"), + tab: ReadTargetTab(id: "2FC00CF0-3974-4E1B-BEF8-7A08A8E3B7C0", title: "Agent", selected: true), + pane: ReadTargetPane(id: "6E1A2A10-D99F-4E3F-920C-D93AA3C05764", title: "Claude", cwd: nil, focused: false) + ), + agent: AgentReadAgent( + type: "claude", + status: status, + rawState: status.rawValue, + detectionReason: "claude.blockedPrompt", + lastChangedAt: "2026-08-11T12:00:00Z", + session: nil + ), + blocker: blocker.map(AgentReadBlocker.init(text:)), + result: result + ) + } +} diff --git a/ProwlCLITests/AgentsCommandParsingTests.swift b/ProwlCLITests/AgentsCommandParsingTests.swift new file mode 100644 index 00000000..47a36046 --- /dev/null +++ b/ProwlCLITests/AgentsCommandParsingTests.swift @@ -0,0 +1,27 @@ +import XCTest + +@testable import prowl + +final class AgentsCommandParsingTests: XCTestCase { + func testListParsesWithoutSubcommand() throws { + _ = try AgentsCommand.parse([]) + } + + func testReadParsesExplicitPaneAndResultOptions() throws { + let command = try AgentsReadCommand.parse(["p7", "--max-bytes", "1024", "--result-only"]) + + XCTAssertEqual(command.pane, "p7") + XCTAssertEqual(command.maxBytes, 1024) + XCTAssertTrue(command.resultOnly) + } + + func testReadRequiresPaneHandleOrUUID() { + XCTAssertThrowsError(try AgentsReadCommand.parse(["main"])) + } + + func testReadRejectsResultOnlyJSONCombination() throws { + let command = try AgentsReadCommand.parse(["p7", "--result-only", "--json"]) + + XCTAssertThrowsError(try command.validateOutputMode()) + } +} diff --git a/docs-ai/013-prowl-cli/contracts/agents-read.md b/docs-ai/013-prowl-cli/contracts/agents-read.md new file mode 100644 index 00000000..0c1c3f82 --- /dev/null +++ b/docs-ai/013-prowl-cli/contracts/agents-read.md @@ -0,0 +1,130 @@ +# CLI Contract: `prowl agents read` + +> Living normative contract of entry 013. + +This file defines the immediate agent-snapshot command: + +```bash +prowl agents read [--max-bytes <1...4194304>] [--result-only] [--json] +``` + +## Scope + +V1 supports only a currently active **Codex** or **Claude Code** pane. The target is +always explicit: use the `pN` shown by text `prowl agents`, or the canonical +`.data.agents[].pane.id` from JSON `prowl agents`. Worktree/tab selectors, focus-derived +targets, wait mode, and timeouts are deliberately unsupported. + +The command takes one immediate snapshot. It is read-only; respond to a blocker with +the existing `prowl key --pane …` or `prowl send --pane …` commands. The read and a +later write are not atomic, so automation should re-read before a consequential action. + +## Snapshot semantics + +A successful snapshot always reports the currently observed agent status. A transcript +result is separate evidence and may be unavailable without invalidating the live +snapshot. This prevents an unresolvable transcript from hiding a useful blocker. + +- `status` is Prowl's stabilized `working`, `blocked`, `done`, or `idle` display state. +- `raw_state` and `detection_reason` are from the fresh active-screen classifier. +- `blocker.text`, when present, is the current typed-profile interaction region. It + preserves the question, visible options, current selection marker, and keyboard hints; + it is not a reconstructed or generic terminal viewport. +- Transcript access requires a fresh `exact` or `high` native session resolution. A + `medium` candidate is never read. + +## Success payload + +```json +{ + "ok": true, + "command": "agents.read", + "schema_version": "prowl.cli.agents.read.v1", + "data": { + "output_mode": "snapshot", + "target": { + "worktree": { "id": "/Projects/App", "name": "main", "path": "/Projects/App", "root_path": "/Projects/App", "kind": "git" }, + "tab": { "id": "2FC00CF0-3974-4E1B-BEF8-7A08A8E3B7C0", "title": "Agent", "selected": true }, + "pane": { "id": "6E1A2A10-D99F-4E3F-920C-D93AA3C05764", "title": "Claude", "cwd": "/Projects/App", "focused": false } + }, + "agent": { + "type": "claude", + "status": "blocked", + "raw_state": "blocked", + "detection_reason": "claude.blockedPrompt", + "last_changed_at": "2026-08-11T12:00:00Z" + }, + "blocker": { + "text": "Do you want to proceed?\n❯ 1. Yes\n 2. No\nEsc to cancel · Tab to amend" + }, + "result": { + "state": "unavailable", + "error": { + "code": "SESSION_UNRESOLVED", + "message": "No exact or high-confidence transcript session is available." + } + } + } +} +``` + +`target` follows the shared resolved-target shape in `read.md`. `agent.session` is +present only when the transcript result used an eligible session; it contains `id`, +`confidence` (`exact` or `high`), and `source`, never a local transcript path. + +### `result` + +| `state` | `text` | Meaning | +| --- | --- | --- | +| `complete` | required | A final answer from a complete, attributable native transcript turn. | +| `pending` | absent | The agent is working or blocked and has no earlier completed turn. | +| `unavailable` | absent | No exact/high transcript session is available. | +| `missing` | absent | Idle/done agent has no completed final answer in its trusted transcript. | +| `incomplete` | absent | The candidate turn is max-token-limited, malformed, unsupported, or not closed. | +| `too_large` | absent | A complete answer exceeds the requested `max_bytes`. | + +Non-`complete` states include `result.error` with the stable code +`SESSION_UNRESOLVED`, `RESULT_NOT_FOUND`, `RESULT_INCOMPLETE`, or +`RESULT_TOO_LARGE`, except `pending`, which is ordinary live activity rather than an +error. Partial text is never returned. + +Codex accepts only `event_msg.payload.type == "task_complete"` records with +`turn_id`, `completed_at`, and `last_agent_message`. Claude Code accepts a +`system/turn_duration` close record and a bounded same-session parent chain to an +assistant message containing only text blocks with `end_turn` or `stop_sequence`. + +## Text output + +Without `--json`, default output is uncoloured Markdown-like text. It always includes +`Agent`, `Status`, `Reason` when known, `Changed`, and `Result`; it then appends +`## Blocker` and/or `## Latest result` as applicable. + +`--result-only` is text-only and is mutually exclusive with `--json`. It succeeds only +for `result.state == "complete"`, writes the result bytes exactly to stdout (no heading +or synthetic trailing newline), and otherwise exits non-zero with the corresponding +result error code. + +## Limits + +`--max-bytes` defaults to 1,048,576 and accepts `1...4,194,304`. The limit applies to +the UTF-8 result text before response encoding. Oversized results are not truncated and +are never written to an output file. Socket frames permit up to 32 MiB, independently +of the smaller result limit. + +## Error payload and codes + +Normal snapshot failures use the standard error envelope with command +`"agents.read"` and schema `"prowl.cli.agents.read.v1"`. + +| Code | Meaning | +| --- | --- | +| `INVALID_ARGUMENT` | Invalid pane form, `max_bytes`, or `--result-only --json`. | +| `TARGET_NOT_FOUND` | The explicit pane handle/UUID no longer resolves. | +| `AGENT_NOT_FOUND` | The pane no longer hosts an active agent. | +| `AGENT_UNSUPPORTED` | The active agent is not Codex or Claude Code. | +| `AGENT_READ_FAILED` | Prowl cannot capture the active screen. | +| `BLOCKER_UNREADABLE` | A blocked screen was detected but no coherent interaction region could be extracted. | +| `SESSION_UNRESOLVED` | `--result-only` requested a result without exact/high session evidence. | +| `RESULT_NOT_FOUND` | `--result-only` requested a result but no completed answer exists. | +| `RESULT_INCOMPLETE` | `--result-only` requested a non-final or unsupported result. | +| `RESULT_TOO_LARGE` | `--result-only` requested a result over `max_bytes`. | diff --git a/docs-ai/013-prowl-cli/contracts/input.md b/docs-ai/013-prowl-cli/contracts/input.md index ebd83317..502fab35 100644 --- a/docs-ai/013-prowl-cli/contracts/input.md +++ b/docs-ai/013-prowl-cli/contracts/input.md @@ -8,12 +8,13 @@ This file defines **input-side** rules for the phase-1 CLI commands: - `open` - `list` +- `agents` (including `agents read`) - `focus` - `send` - `key` - `read` -It complements output contracts under `docs-ai/013-prowl-cli/contracts/{open,list,focus,send,key,read}.md`. +It complements output contracts under `docs-ai/013-prowl-cli/contracts/{open,list,focus,send,key,read,agents-read}.md`. --- @@ -38,6 +39,7 @@ prowl [target-selector] [command-args] [output-options] - `open` - `list` +- `agents` - `focus` - `send` - `key` @@ -188,6 +190,26 @@ prowl list [--json] - `list` MUST NOT accept target selectors in v1 (it is global discovery). - Extra positional args: `INVALID_ARGUMENT`. +## 5.2.1 `agents` + +### Grammar + +```bash +prowl agents [--json] +prowl agents read [--max-bytes <1...4194304>] [--result-only] [--json] +``` + +### Rules + +- Plain `agents` remains global roster discovery and accepts no target selector. +- `agents read` requires exactly one positional pane handle (`pN`) or pane UUID. It + intentionally accepts neither worktree/tab/focus targeting nor selector flags. +- `pN` is the current-process handle from text `agents`; JSON callers use the canonical + `.data.agents[].pane.id` UUID. +- `agents read` is an immediate snapshot: it has no timeout, wait flag, or polling mode. +- `--max-bytes` defaults to 1,048,576 and is bounded by 1...4,194,304. +- `--result-only` is text-only and cannot combine with `--json`. + ## 5.3 `focus` ### Grammar @@ -280,6 +302,7 @@ These tokens are reserved as first command token: - `send` - `key` - `read` +- `agents` If first token matches a reserved command, CLI MUST parse as subcommand unless forced by `--` path form. @@ -302,6 +325,8 @@ struct CommandEnvelope { enum Command { case open(OpenInput) case list(ListInput) + case agents(AgentsInput) + case agentsRead(AgentReadInput) case focus(FocusInput) case send(SendInput) case key(KeyInput) @@ -325,6 +350,7 @@ prowl focus --pane 6E1A2A10-D99F-4E3F-920C-D93AA3C05764 # explicit pane prowl read --pane p3 --last 200 # explicit pane handle prowl tab close --tab t4 --force # explicit tab handle prowl focus main # auto-resolve worktree name +prowl agents read p7 --json # immediate semantic agent snapshot prowl send "echo hello" # text to current pane prowl send 6E1A2A10-D99F-4E3F-920C-D93AA3C05764 "echo hi" # target + text printf 'git status' | prowl send --worktree Prowl --json diff --git a/docs-ai/013-prowl-cli/contracts/schema.md b/docs-ai/013-prowl-cli/contracts/schema.md index c43e9c20..d60a6c83 100644 --- a/docs-ai/013-prowl-cli/contracts/schema.md +++ b/docs-ai/013-prowl-cli/contracts/schema.md @@ -12,11 +12,12 @@ This file provides machine-validatable JSON Schema definitions for the v1 CLI ou - `send.md` - `key.md` - `read.md` +- `agents-read.md` ## Scope - JSON Schema dialect: **Draft 2020-12** -- Commands covered: `open`, `list`, `focus`, `send`, `key`, `read` +- Commands covered: `open`, `list`, `focus`, `send`, `key`, `read`, `agents.read` - Each command schema is represented as `oneOf(success, error)` - Shared objects are centralized in `$defs` and reused by command schemas @@ -719,6 +720,113 @@ This file provides machine-validatable JSON Schema definitions for the v1 CLI ou { "$ref": "#/$defs/readSuccess" }, { "$ref": "#/$defs/readError" } ] + }, + + "agentsReadSuccess": { + "type": "object", + "additionalProperties": false, + "required": ["ok", "command", "schema_version", "data"], + "properties": { + "ok": { "const": true }, + "command": { "const": "agents.read" }, + "schema_version": { "const": "prowl.cli.agents.read.v1" }, + "data": { + "type": "object", + "additionalProperties": false, + "required": ["output_mode", "target", "agent", "result"], + "properties": { + "output_mode": { "const": "snapshot" }, + "target": { "$ref": "#/$defs/resolvedTarget" }, + "agent": { + "type": "object", + "additionalProperties": false, + "required": ["type", "status", "raw_state", "last_changed_at"], + "properties": { + "type": { "type": "string", "enum": ["claude", "codex"] }, + "status": { "type": "string", "enum": ["blocked", "working", "done", "idle"] }, + "raw_state": { "type": "string", "enum": ["blocked", "working", "idle", "unknown"] }, + "detection_reason": { "type": "string" }, + "last_changed_at": { "type": "string", "format": "date-time" }, + "session": { + "type": "object", + "additionalProperties": false, + "required": ["id", "confidence", "source"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "confidence": { "type": "string", "enum": ["exact", "high"] }, + "source": { "type": "string", "enum": ["open_file", "transcript_match", "recent_file"] } + } + } + } + }, + "blocker": { + "type": "object", + "additionalProperties": false, + "required": ["text"], + "properties": { + "text": { "type": "string", "minLength": 1 } + } + }, + "result": { + "type": "object", + "additionalProperties": false, + "required": ["state"], + "properties": { + "state": { + "type": "string", + "enum": ["complete", "pending", "unavailable", "missing", "incomplete", "too_large"] + }, + "text": { "type": "string" }, + "error": { + "type": "object", + "additionalProperties": false, + "required": ["code", "message"], + "properties": { + "code": { + "type": "string", + "enum": ["SESSION_UNRESOLVED", "RESULT_NOT_FOUND", "RESULT_INCOMPLETE", "RESULT_TOO_LARGE"] + }, + "message": { "type": "string", "minLength": 1 } + } + } + } + } + } + } + } + }, + "agentsReadError": { + "type": "object", + "additionalProperties": false, + "required": ["ok", "command", "schema_version", "error"], + "properties": { + "ok": { "const": false }, + "command": { "const": "agents.read" }, + "schema_version": { "const": "prowl.cli.agents.read.v1" }, + "error": { + "type": "object", + "additionalProperties": false, + "required": ["code", "message"], + "properties": { + "code": { + "type": "string", + "enum": [ + "APP_NOT_RUNNING", "INVALID_ARGUMENT", "TARGET_NOT_FOUND", "AGENT_NOT_FOUND", + "AGENT_UNSUPPORTED", "AGENT_READ_FAILED", "BLOCKER_UNREADABLE", "SESSION_UNRESOLVED", + "RESULT_NOT_FOUND", "RESULT_INCOMPLETE", "RESULT_TOO_LARGE" + ] + }, + "message": { "type": "string", "minLength": 1 }, + "details": { "$ref": "#/$defs/errorDetails" } + } + } + } + }, + "agentsReadResponse": { + "oneOf": [ + { "$ref": "#/$defs/agentsReadSuccess" }, + { "$ref": "#/$defs/agentsReadError" } + ] } } } @@ -732,6 +840,7 @@ This file provides machine-validatable JSON Schema definitions for the v1 CLI ou - Validate `prowl send --json` output against `#/$defs/sendResponse` - Validate `prowl key --json` output against `#/$defs/keyResponse` - Validate `prowl read --json` output against `#/$defs/readResponse` +- Validate `prowl agents read --json` output against `#/$defs/agentsReadResponse` ## Non-goals (v1) diff --git a/docs-ai/059-agent-transcript-snapshots/000-plan.md b/docs-ai/059-agent-transcript-snapshots/000-plan.md new file mode 100644 index 00000000..54dc7474 --- /dev/null +++ b/docs-ai/059-agent-transcript-snapshots/000-plan.md @@ -0,0 +1,158 @@ +# 059 — Agent Transcript Snapshots: Plan + +| | | +| --- | --- | +| **Status** | Implemented | +| **Anchor date** | 2026-08-11 | +| **Primary PRs** | (fill in as they merge) | +| **Related** | [013 — Prowl CLI](../013-prowl-cli/000-plan.md), [045 — Native agent session detection](../045-native-agent-session-detection/000-plan.md), [issue #473](https://github.com/onevcat/Prowl/issues/473), [relay-tracker #53](https://github.com/onevcat/relay-tracker/issues/53) | + +## Background + +`prowl agents` identifies the detected coding-agent panes, but its only follow-up is +`prowl read`, which exposes rendered terminal text rather than the agent's current +semantic state or a trustworthy final answer. A coordinating agent therefore has to +screen-scrape a TUI, poll ambiguously, and cannot distinguish a completed result from +an in-progress transcript fragment. + +The command should be a snapshot, not a result waiter. Live state and a transcript +result are independent evidence: a session-resolution failure must not hide a current +blocker, while an untrusted session must never yield another agent's answer. + +## Goals + +- Add immediate, read-only `prowl agents read ` for Codex and Claude + Code, with clear `--help` and direct reuse of handles from `prowl agents`. +- Return the live detected status on every successful snapshot, and return the raw, + actionable current interaction region for a blocked agent. +- Return a latest final answer only after fresh, exact/high session attribution and a + complete native transcript turn; never use the sticky `PaneAgentState.session` as + authority and never return partial output. +- Keep a machine-readable, versioned JSON contract and a concise, uncoloured Markdown + text snapshot. Provide `--result-only` for exact raw-result stdout pipelines. +- Bound results to 1 MiB by default and 4 MiB maximum, and make the socket response + ceiling 32 MiB so a permitted payload can travel safely. + +### Non-goals + +- Do not alter `prowl read` viewport/scrollback semantics or add a terminal-text + fallback for this command. +- Do not add `agents send`/`agents answer`; writes remain `prowl send` and `prowl key` + operations and are not atomic with a prior read. +- Do not extend `AgentRuntimeAdapter`, `AgentSessionProfile`, or + `TranscriptFragmentCache`, and do not support transcript results for other detected + runtimes in this release. +- Do not promise cross-runtime elapsed-time or token accounting; existing screen + status timestamps remain the only common activity metadata. + +## Command and response contract + +```bash +prowl agents +prowl agents read [--max-bytes <1...4194304>] [--result-only] [--json] +``` + +`agents read` accepts exactly one explicit pane handle or UUID: no focus-derived target, +worktree/tab target, `--timeout`, or wait mode. It captures one immediate snapshot. +`pN` comes directly from text `prowl agents`; JSON callers pass +`.data.agents[].pane.id`. `--max-bytes` defaults to 1,048,576. `--result-only` is +text-mode only; it is invalid with `--json` and writes exactly `result.text` without a +header or synthetic newline. + +The default text renderer is a plain Markdown snapshot with `Agent`, `Status`, +`Reason`, and `Changed` fields, followed by either `Blocker` or `Latest result`. A +blocker preserves the current active-screen interaction region verbatim enough to show +the question, numbered choices, selected marker, and keyboard hints; V1 deliberately +does not invent a lossy structured-choice schema. + +The JSON response uses `prowl.cli.agents.read.v1` and carries resolved target metadata, +agent type/status/raw state/detection reason/last change, the trusted session evidence, +an optional blocker, and an independent result object. `result.state` is one of: + +| State | Meaning | +| --- | --- | +| `complete` | Exact/high session attribution and one verified, closed turn; includes `text`. | +| `pending` | Working agent has no earlier completed result. | +| `unavailable` | No exact/high current session can be established. | +| `missing` | Idle/done agent has no completed answer in its trusted transcript. | +| `incomplete` | A candidate terminal turn is incomplete, malformed, unsupported, or max-token limited. | +| `too_large` | A complete answer exceeds `max_bytes`; no text is returned. | + +A normal snapshot succeeds for every state above: state observation remains useful even +when transcript evidence is absent. `--result-only` asserts `complete`; every other +state is a non-zero error using `SESSION_UNRESOLVED`, `RESULT_NOT_FOUND`, +`RESULT_INCOMPLETE`, or `RESULT_TOO_LARGE`. Snapshot failures remain reserved for an +invalid/missing pane, no longer-active agent, unsupported runtime, unreadable active +screen, or unreadable blocker region. + +## Design / Approach + +1. Extend the nested `AgentsCommand` CLI grammar, shared input/envelope/router models, + payload models, error constants, response renderer, and app router composition for a + distinct `agents.read` wire command. Resolve only `TargetSelector.pane`, run an + on-demand detection pass for the resolved live surface, and preserve the established + display status (`working`, `blocked`, `done`, `idle`) alongside the freshly captured + raw detection/reason. +2. Add a small on-demand agent-read capture at the terminal/app boundary. It re-identifies + the foreground process, reads `readActiveContentsForCLI()`, records the matched + Codex/Claude screen rule, captures the relevant profile launch root and cwd, and + asks the session resolver for a fresh resolution. A new resolver entry point bypasses + the pid-result cache but may retain safe file/root parsing caches; only `exact` and + `high` resolutions are eligible for transcript reading. +3. Promote the existing Codex and Claude typed screen regions into narrowly scoped + blocker extractors. They must be gated by their current blocked rule and return the + raw interaction region, not a viewport or reconstructed prose. If no coherent + interaction region is available, fail with `BLOCKER_UNREADABLE` rather than claim an + actionable blocker. +4. Add an independent transcript-result reader beside the existing agent-detection + infrastructure. It reads a bounded, race-checked JSONL snapshot and has separate + Codex and Claude decoders: + - Codex accepts only a closed `event_msg.payload.type == "task_complete"` record + with a usable `turn_id`, `completed_at`, and `last_agent_message`. + - Claude accepts `system/turn_duration` as a close marker, follows its bounded + same-session parent chain back through summaries to an assistant message, and + accepts text-only content with `end_turn` or `stop_sequence`. + The reader makes no partial return: malformed/unclosed records, broken chains, + unsupported schema, unfinished tool turns, and `max_tokens` are `incomplete`. + A file mutation during reading gets one bounded retry before classification. +5. Use the reader only after exact/high attribution. For working/blocked snapshots, a + missing trusted session yields `unavailable` while preserving status/blocker; a + missing prior closed turn is `pending`. Idle/done snapshots retain their status and + report `missing`, `unavailable`, `incomplete`, or `too_large` rather than turning a + usable observation into a transport failure. +6. Lift both client and server framed-response validation from 10 MB to 32 MiB, while + applying the smaller per-result byte limit before payload encoding. The command never + writes overflow output to disk and never truncates a result. +7. Update the living CLI input/schema contracts, add the `agents read` contract, and + update the user manual and bundled `prowl-cli` skill with discovery, blocker, + `--result-only`, result-state, and non-atomic write examples. + +## Verification + +- Add Swift Testing coverage for envelope round trips, router dispatch, parser validation, + handler state/error mapping, text rendering, result-only byte-exact output, and the + 32 MiB transport boundary. +- Extend the existing Codex/Claude screen-profile fixture tests to assert blocker-region + extraction for permission, trust, hook-review, sign-in, and generic confirmation + screens, including quoted historical prompts that must not become blockers. +- Add redacted, version-pinned JSONL fixtures and pure decoder tests for valid terminal + turns; earlier-result lookup while working; Claude parent-summary traversal; missing + text; malformed/truncated JSONL; broken parent chains; unclosed tool turns; + `max_tokens`; oversized answers; and files changed during a read. +- Add app-composition tests proving the new handler is wired, only pane selectors are + accepted, fresh exact/high identity is required before transcript access, and the + normal snapshot still preserves a blocked screen when result evidence is unavailable. +- Run `make check`, focused test classes, `make test`, and `make build-app` after + implementation. Record actual commands and outcomes in `001-action.md`. + +## Alternatives & decisions + +| Alternative | Decision | +| --- | --- | +| Wait for a new final result and return old text on timeout | Rejected. The command is immediate snapshot inspection; no default wait or timeout exists. | +| Treat transcript failure as a top-level failure for every state | Rejected. It hides the useful current status/blocker; result availability is an independent JSON state. | +| Accept a freshly resolved `medium` session | Rejected. It can silently attribute another concurrent or stale same-cwd conversation. | +| Return generic terminal viewport text | Rejected. It cannot distinguish agent output from TUI chrome and violates the result completeness guarantee. | +| Parse structured blocker options | Deferred. Raw typed-profile interaction text is more complete and remains usable with existing `prowl key`/`prowl send`. | + +## Amendments diff --git a/docs-ai/059-agent-transcript-snapshots/001-action.md b/docs-ai/059-agent-transcript-snapshots/001-action.md new file mode 100644 index 00000000..69ea24b1 --- /dev/null +++ b/docs-ai/059-agent-transcript-snapshots/001-action.md @@ -0,0 +1,54 @@ +# 059 — Agent Transcript Snapshots: Action Log + +## Timeline + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-08-11 | Wrote the snapshot-first plan and established the CLI/result-state contract. | This entry | +| 2026-08-11 | Added `prowl agents read` for explicit Codex/Claude panes, live blocker extraction, fresh transcript attribution, bounded decoders, contracts, and user guidance. | Pending PR | + +## Outcome & current state (as of 2026-08-11) + +- `ProwlCLI/Commands/AgentsCommand.swift` and + `ProwlCLI/Commands/AgentsReadCommand.swift` expose `prowl agents read ` + with immediate-only targeting, a 1–4 MiB result cap, and text-only `--result-only`. +- `supacode/CLIService/AgentReadCommandHandler.swift` keeps live snapshot evidence + independent from transcript result evidence. It returns `complete`, `pending`, + `unavailable`, `missing`, `incomplete`, or `too_large` in `AgentReadResult`; only + `--result-only` turns non-complete states into command errors. +- `supacode/App/supacodeApp.swift` performs an on-demand active-screen detection pass, + re-identifies the foreground process, and calls the fresh resolver path. It permits + only exact/high transcript-backed Codex/Claude sessions. +- `supacode/Infrastructure/AgentDetection/AgentTranscriptResultReader.swift` reads a + bounded, stable JSONL tail with one mutation retry; it decodes closed Codex + `task_complete` and Claude `turn_duration` parent-chain results without returning + partial text. `AgentSessionResolver.resolveFresh` bypasses only the pid-result cache. +- `ClaudeScreenProfile` and `CodexScreenProfile` now export the raw blocked interaction + region used by the command. `ProwlCLI/Output/OutputRenderer.swift` renders the normal + status snapshot or writes result-only bytes directly without a synthetic newline. +- Shared models, routing, 32 MiB socket-frame guards, normative contracts, the user CLI + guide, and the bundled `prowl-cli` skill were updated together. + +## Verification + +| Command | Observed result | +| --- | --- | +| `make check` | Passed: changed-file formatting, strict swift-format lint, and SwiftLint. | +| `make test` | Passed: xcresult reported 2,313 tests with zero failures. | +| `make build-cli` | Passed. | +| `make test-cli-smoke` | Passed. | +| `make test-cli-integration` | Passed: 68 tests. | +| `make build-app` | Passed: Debug macOS app build with zero warnings. | + +## Deviations from plan + +- The decoder tests use compact synthetic JSONL records in + `supacodeTests/AgentTranscriptResultReaderTests.swift` instead of committing captured + transcript fixture files. They cover the observed Codex 0.146.1 and Claude Code + 2.1.226 completion shapes without recording user transcript content. + +## Open questions + +- Native transcript schemas are intentionally narrow. A future Codex or Claude Code + release that changes its completion markers must add a captured/redacted regression + case before broadening the decoder. diff --git a/docs-ai/README.md b/docs-ai/README.md index abee77c0..8c569df9 100644 --- a/docs-ai/README.md +++ b/docs-ai/README.md @@ -110,3 +110,4 @@ agent-facing manual for that). | 056 | [performance-optimization-2026-08](056-performance-optimization-2026-08/000-plan.md) | 2026-08-01 | August performance review series: exact cached line counts, opt-in Debug TCA logging, and agent screen-scan memoization | | 057 | [performance-benchmark-suite](057-performance-benchmark-suite/000-plan.md) | 2026-08-02 | Ratio-assertion benchmarks pinning the #644–#665 hot paths, `make bench` absolute-number reporting, and live measurement wrappers | | 058 | [unified-toolbar-layout](058-unified-toolbar-layout/000-plan.md) | 2026-08-07 | Remove redundant branch/title toolbar items and align the Agents + notifications cluster across Normal, Shelf, and Canvas | +| 059 | [agent-transcript-snapshots](059-agent-transcript-snapshots/000-plan.md) | 2026-08-11 | Immediate Codex/Claude agent snapshots with trustworthy transcript results and actionable blocker text | diff --git a/docs/components/cli.md b/docs/components/cli.md index 5c88b15e..d3d8b3cd 100644 --- a/docs/components/cli.md +++ b/docs/components/cli.md @@ -4,7 +4,7 @@ > an agent) can list panes, read their screens, run commands and capture output, > send keystrokes, focus, and open/close tabs and panes programmatically. -**Keywords:** prowl cli, command line, prowl list, prowl agents, prowl read, prowl send, prowl key, prowl focus, prowl tab, prowl pane, prowl open, prowl handoff, pane id, agent, automation, json, capture, socket +**Keywords:** prowl cli, command line, prowl list, prowl agents, prowl agents read, prowl read, prowl send, prowl key, prowl focus, prowl tab, prowl pane, prowl open, prowl handoff, pane id, agent, automation, json, capture, socket **Related:** [terminal](terminal.md) · [concepts](../concepts.md) · [active-agents](active-agents.md) · [agent-detection](agent-detection.md) · the bundled **`prowl-cli` skill** (`skills/prowl-cli/SKILL.md`) @@ -139,19 +139,49 @@ Each agent contains: `medium` session id must not be used for automatic resume without additional confirmation. -`prowl agents` is read-only. To jump to or operate on an agent, resolve -`.data.agents[].pane.id`, then use existing commands: +`prowl agents` is read-only. Text output is sorted for triage: `Blocked`, +`Working`, `Done`, then `Idle`. It prints a pane handle such as `p7`; JSON keeps +the canonical pane UUID. Either form now feeds the semantic snapshot command: ```bash +prowl agents read p7 +prowl agents read p7 --json pane="$(prowl agents --json | jq -r '.data.agents[] | select(.status=="blocked") | .pane.id' | head -n1)" -prowl focus --pane "$pane" -prowl read --pane "$pane" --last 120 --wait-stable +prowl agents read "$pane" --json ``` -Text output is sorted for triage: `Blocked`, `Working`, `Done`, then `Idle`. -It prints a pane handle such as `p7` for each agent; use it as -`prowl read --pane p7`, not as an `agents` subcommand. Empty output prints -`No agents found.`. +### `prowl agents read ` +Immediate, read-only semantic snapshot for a currently active **Codex** or +**Claude Code** pane. It requires an explicit `pN` handle or UUID from `agents`; +it never guesses from focus, accepts no worktree/tab selector, and has no wait or +timeout mode. + +Default text output always reports current `Status`, classifier `Reason`, last +state-change time, and a result state. A blocked snapshot includes the raw current +interaction under `## Blocker`, preserving the question, numbered choices, selected +row, and Enter/Esc hints. It is the right command for deciding what another agent is +waiting on; use `prowl key --pane "$pane" ...` to navigate/confirm a menu or +`prowl send --pane "$pane" ...` for free-form input. Those writes are not atomic with +the read, so re-read before a consequential choice. + +```bash +prowl agents read p7 +prowl agents read "$pane" --max-bytes 2097152 --json +prowl agents read p7 --result-only > /tmp/agent-result.txt +``` + +JSON is `prowl.cli.agents.read.v1`. `.data.result.state` is independent from live +agent state: `complete` includes trusted `text`; `pending` means a working/blocked +agent has not completed a turn; `unavailable`, `missing`, `incomplete`, and +`too_large` retain a successful live snapshot but include a reason under +`.data.result.error`. Prowl reads a transcript only after a fresh `exact` or `high` +session resolution — never a `medium` candidate — and never returns partial text. + +`--max-bytes` defaults to 1 MiB and accepts up to 4 MiB. `--result-only` is mutually +exclusive with `--json`; it writes exactly a complete trusted result to stdout, with +no heading or added newline. For every other result state it exits non-zero with +`SESSION_UNRESOLVED`, `RESULT_NOT_FOUND`, `RESULT_INCOMPLETE`, or +`RESULT_TOO_LARGE`. Empty `agents` roster output remains `No agents found.`. ### `prowl read [target]` Read a pane's content. @@ -370,6 +400,9 @@ artifacts and terminal excerpts do not appear in `git status`. | `SOCKET_PERMISSION_DENIED` | The socket exists but the client cannot connect, usually because a sandbox blocked the Unix socket. Allowlist the socket path, run outside the sandbox, or use matching `PROWL_CLI_SOCKET` values for both app and CLI. | | `TARGET_NOT_FOUND` | Selector matched nothing — re-run `list` and pick a UUID or current short handle. | | `TARGET_NOT_UNIQUE` | Selector matched several — be more specific (use `--pane`). | +| `AGENT_NOT_FOUND` / `AGENT_UNSUPPORTED` | `agents read` target no longer hosts an agent, or it is not Codex/Claude Code. Re-run `agents`. | +| `BLOCKER_UNREADABLE` | A blocked screen was detected but Prowl could not safely extract its current interaction text. Re-run `agents read` or inspect with `read`. | +| `SESSION_UNRESOLVED` / `RESULT_NOT_FOUND` / `RESULT_INCOMPLETE` / `RESULT_TOO_LARGE` | `agents read --result-only` could not provide one trustworthy complete result. Drop `--result-only` to retain the live snapshot and inspect `.data.result`. | | `NO_ACTIVE_PANE` | No pane for focused-target; pass an explicit `--pane`. | | `EMPTY_INPUT` | `send` got neither argv nor stdin (or both). | | `INVALID_ARGUMENT` | Bad flag/combo (e.g. `--capture --no-wait`) or out-of-range value. | @@ -404,7 +437,8 @@ prowl pane close --pane "$pane" --json - Resolve a UUID `pane.id` or current text `pN` before `read`/`send`/`key`/ `focus`/close — never trust tab titles. -- Use `prowl agents --json` when you need agent status; use `prowl list --json` +- Use `prowl agents --json` for discovery, then `prowl agents read ` for + a supported agent's status, blocker, and trustworthy result state; use `prowl list --json` when you need all panes, including ordinary shells. - `--capture` needs shell integration; otherwise `read --wait-stable` or file redirection. diff --git a/skills/prowl-cli/SKILL.md b/skills/prowl-cli/SKILL.md index b84e490b..4a2fc6b7 100644 --- a/skills/prowl-cli/SKILL.md +++ b/skills/prowl-cli/SKILL.md @@ -24,7 +24,16 @@ When you specifically need active agent status, prefer the agent roster: prowl agents --json ``` -`prowl agents` lists detected agent panes only. It is the right starting point for "which agents are blocked/working/done?", while `prowl list` remains the all-pane inventory, including ordinary shells. +`prowl agents` lists detected agent panes only. It is the right starting point for "which agents are blocked/working/done?", while `prowl list` remains the all-pane inventory, including ordinary shells. For a currently active Codex or Claude Code agent, follow it with an immediate semantic snapshot: + +```bash +prowl agents read p7 --json +# Or use the canonical UUID from .data.agents[].pane.id. +``` + +`agents read` has no focus fallback, timeout, or wait mode. Its JSON always contains the current +`.data.agent.status` and independent `.data.result.state`; do not assume `idle`/`done` means a +trusted result exists unless `result.state == "complete"`. If your session was launched from a Prowl pane, the focused pane is often you. Treat focused pane IDs as something to identify and avoid unless you intentionally want to operate on yourself. @@ -134,10 +143,24 @@ Key fields by command (see `docs/components/cli.md` for the full contract): - `read` → `.data.text`, `.data.line_count`, `.data.truncated`, `.data.mode` (`snapshot`|`last`), `.data.source` (`screen`|`scrollback`|`mixed`|`detection`), plus `.data.stabilized` / `.data.waited_ms` / `.data.samples` when `--wait-stable`. - `send` → `.data.input` (source/characters/bytes/trailing_enter_sent); `.data.wait.exit_code` and `.data.wait.duration_ms` when waiting; `.data.capture.text` / `.data.capture.line_count` / `.data.capture.truncated` when `--capture`. - `list` / `agents` → `.data.items[]` / `.data.agents[]`, each with `.pane.id`, `.tab.id`, and `.worktree.{id,name,path}`. Agent entries also include `.status`, `.raw_state`, and optional `.detection_reason`; list entries include `.task.status`. +- `agents read` → `.data.agent` (current status/reason), `.data.blocker.text` when blocked, and `.data.result`. Only `.data.result.state == "complete"` carries `.data.result.text`; `pending`, `unavailable`, `missing`, `incomplete`, and `too_large` deliberately carry no partial text. - `tab create` / `open` → `.data.target.{pane,tab,worktree}`. ## Reading Agent Output +For an active Codex or Claude Code pane, prefer `agents read` over terminal scraping: + +```bash +snapshot="$(prowl agents read p7 --json)" +printf '%s\n' "$snapshot" | jq -e '.ok == true and .data.agent.status == "blocked"' >/dev/null +printf '%s\n' "$snapshot" | jq -r '.data.blocker.text // empty' +``` + +The command is an immediate snapshot. A blocker is raw current TUI interaction text, so inspect it +before using `prowl key --pane p7 ...` or `prowl send --pane p7 ...`; read and write are not atomic. +Use `--result-only` only for a strict pipeline that needs exact raw result bytes and should fail on +anything except a complete, exact/high-attributed result. It cannot combine with `--json`. + `task.status` is useful for coordination but is not enough to prove the screen finished rendering. `idle` can arrive before a TUI has painted its final response. Prefer `read --wait-stable` for screen snapshots: @@ -224,7 +247,7 @@ pane="$(prowl agents --json | jq -r ' | select(.status == "blocked") | .pane.id ' | head -n 1)" -prowl read --pane "$pane" --last 120 --wait-stable --json +prowl agents read "$pane" --json ``` When no agent is blocked, use the same pattern with `working`, `done`, or `idle` depending on the task. The JSON payload also includes `.project.name`, `.project.branch`, `.worktree.path`, `.tab.title`, and `.pane.focused`, so automation can filter by human project label while still targeting the concrete pane. @@ -263,7 +286,7 @@ Avoid outer double quotes around payloads containing `$PWD`, `$VAR`, backticks, - Never target by tab title alone; use `pane.id` plus path/cwd. - Never omit `--pane` for `send`, `key`, `read`, or `focus` in automation. -- Use `prowl agents --json` for detected agent status; use `prowl list --json` for all panes and worktree-level `task.status`. +- Use `prowl agents --json` for discovery and `prowl agents read --json` for a supported agent's current semantic snapshot; use `prowl list --json` for all panes and worktree-level `task.status`. - `open /path` is a project/path navigation command. It may refocus an existing pane and is not a deterministic create command. - Use `tab create` when automation needs a fresh shell, and capture the returned `pane.id` before sending input. - Focused pane is not stable; `open` and `focus` change it. @@ -331,4 +354,4 @@ Write the briefing from your current working knowledge — required sections are ## Command Set -Current commands: `list`, `agents`, `read`, `send`, `key`, `focus`, `tab create`, `tab close`, `pane close`, `handoff to`, `handoff save`, and `open` (default). There is no CLI `quit`; close temporary tabs or panes with explicit `tab close` / `pane close` targets. +Current commands: `list`, `agents`, `agents read`, `read`, `send`, `key`, `focus`, `tab create`, `tab close`, `pane close`, `handoff to`, `handoff save`, and `open` (default). There is no CLI `quit`; close temporary tabs or panes with explicit `tab close` / `pane close` targets. diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index 1ea911f5..84383cf9 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -550,6 +550,116 @@ struct SupacodeApp: App { } } + private static func makeAgentReadRuntimeSnapshot( + pane: String, + appStore: StoreOf, + terminalManager: WorktreeTerminalManager + ) async -> Result { + let resolver = makeTargetResolver(appStore: appStore, terminalManager: terminalManager) + let resolved: ResolvedTarget + switch resolver.resolve(.pane(pane)) { + case .success(let target): + resolved = target + case .failure(let error): + let message = + switch error { + case .notFound(let message), .notUnique(let message): message + } + return .failure(.targetNotFound(message)) + } + + guard let state = terminalManager.stateIfExists(for: resolved.worktreeID), + let surface = state.surfaceView(for: resolved.paneID), + let tabID = state.tabId(containing: resolved.paneID) + else { + return .failure(.targetNotFound("Pane '\(pane)' is no longer available.")) + } + + _ = await state.detectAgentState(for: surface, tabId: tabID) + guard let agentState = state.surfaceAgentStates[resolved.paneID], + let detectedAgent = agentState.detectedAgent + else { + return .failure(.agentNotFound("Pane '\(pane)' does not host an active agent.")) + } + guard detectedAgent == .codex || detectedAgent == .claude else { + return .failure(.unsupportedAgent("Agent '\(detectedAgent.rawValue)' is not supported by agents read.")) + } + guard let activeText = surface.readActiveContentsForCLI() else { + return .failure(.activeScreenUnreadable) + } + + let detection = detectedAgent.detectScreen(in: activeText) + let canonicalScreen = AgentScreenSnapshot(canonicalText: agentDetectionRecentText(activeText)) + let blockerText: String? = + switch detectedAgent { + case .codex: CodexScreenProfile.blockerText(in: canonicalScreen) + case .claude: ClaudeScreenProfile.blockerText(in: canonicalScreen) + default: nil + } + if detection.state == .blocked, blockerText == nil { + return .failure(.blockerUnreadable) + } + + let job = await AgentProcessProbe.shared.foregroundJob( + processGroupID: surface.bridge.foregroundProcessGroupID(), + childPID: surface.bridge.childPID() + ) + guard let identified = job.flatMap(identifyAgentInJob), identified.agent == detectedAgent else { + return .failure(.agentNotFound("Pane '\(pane)' no longer hosts the selected agent.")) + } + + let freshResolution = await AgentSessionResolver.shared.resolveFresh( + identified: identified, + workingDirectory: state.activeAgentWorkingDirectory(surfaceID: resolved.paneID), + activeText: activeText, + configRoot: state.launchProfilesBySurface[resolved.paneID]?.configRoot(forDetected: detectedAgent) + ) + let transcriptSession = freshResolution.session.flatMap { session -> AgentSession? in + guard session.confidence == .exact || session.confidence == .high, + session.transcriptPath != nil + else { + return nil + } + return session + } + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + formatter.timeZone = TimeZone(secondsFromGMT: 0) + let status = AgentsCommandStatus(rawValue: agentState.displayState.rawValue) ?? .idle + return .success( + AgentReadRuntimeSnapshot( + target: agentReadTarget(from: resolved), + agent: detectedAgent, + status: status, + rawState: detection.state.rawValue, + detectionReason: detection.reason.identifier, + lastChangedAt: formatter.string(from: agentState.lastChangedAt), + blockerText: blockerText, + transcriptSession: transcriptSession + ) + ) + } + + private static func agentReadTarget(from target: ResolvedTarget) -> ReadTarget { + ReadTarget( + worktree: ReadTargetWorktree( + id: target.worktreeID, + name: target.worktreeName, + path: target.worktreePath, + rootPath: target.worktreeRootPath, + kind: target.worktreeKind.rawValue + ), + tab: ReadTargetTab(id: target.tabID.uuidString, title: target.tabTitle, selected: target.tabSelected), + pane: ReadTargetPane( + id: target.paneID.uuidString, + title: target.paneTitle, + cwd: target.paneCWD, + focused: target.paneFocused + ) + ) + } + // swiftlint:disable:next function_body_length static func makeCLICommandRouter( appStore: StoreOf, @@ -579,6 +689,20 @@ struct SupacodeApp: App { screenDetectionsBySurfaceID: screenDetectionsBySurfaceID ) } + let agentReadHandler = AgentReadCommandHandler( + snapshotProvider: { pane in + await Self.makeAgentReadRuntimeSnapshot( + pane: pane, + appStore: appStore, + terminalManager: terminalManager + ) + }, + resultProvider: { agent, path, maxBytes in + await Task.detached(priority: .userInitiated) { + AgentTranscriptResultReader.read(agent: agent, at: path, maxBytes: maxBytes) + }.value + } + ) let sendHandler = SendCommandHandler( resolveProvider: { selector in let resolver = TargetResolver { @@ -819,6 +943,7 @@ struct SupacodeApp: App { openHandler: openHandler, listHandler: listHandler, agentsHandler: agentsHandler, + agentsReadHandler: agentReadHandler, focusHandler: focusHandler, sendHandler: sendHandler, keyHandler: keyHandler, diff --git a/supacode/CLIService/AgentReadCommandHandler.swift b/supacode/CLIService/AgentReadCommandHandler.swift new file mode 100644 index 00000000..27ac0d82 --- /dev/null +++ b/supacode/CLIService/AgentReadCommandHandler.swift @@ -0,0 +1,168 @@ +import Foundation + +nonisolated struct AgentReadRuntimeSnapshot: Sendable { + let target: ReadTarget + let agent: DetectedAgent + let status: AgentsCommandStatus + let rawState: String + let detectionReason: String? + let lastChangedAt: String + let blockerText: String? + /// Only an exact/high, transcript-backed fresh resolution belongs here. + let transcriptSession: AgentSession? +} + +nonisolated enum AgentReadSnapshotError: Error, Sendable { + case targetNotFound(String) + case agentNotFound(String) + case unsupportedAgent(String) + case activeScreenUnreadable + case blockerUnreadable +} + +@MainActor +final class AgentReadCommandHandler: CommandHandler { + typealias SnapshotProvider = @MainActor (String) async -> Result + typealias ResultProvider = @Sendable (DetectedAgent, URL, Int) async -> AgentTranscriptResult + + private let snapshotProvider: SnapshotProvider + private let resultProvider: ResultProvider + + init( + snapshotProvider: @escaping SnapshotProvider, + resultProvider: @escaping ResultProvider + ) { + self.snapshotProvider = snapshotProvider + self.resultProvider = resultProvider + } + + func handle(envelope: CommandEnvelope) async -> CommandResponse { + guard case .agentsRead(let input) = envelope.command else { + return errorResponse(code: CLIErrorCode.agentReadFailed, message: "Invalid command.") + } + guard (1...AgentReadInput.maximumMaxBytes).contains(input.maxBytes) else { + return errorResponse( + code: CLIErrorCode.invalidArgument, + message: "--max-bytes must be between 1 and \(AgentReadInput.maximumMaxBytes)." + ) + } + + let snapshot: AgentReadRuntimeSnapshot + switch await snapshotProvider(input.pane) { + case .success(let resolved): + snapshot = resolved + case .failure(let error): + return mapSnapshotError(error) + } + + let result = await makeResult(from: snapshot, maxBytes: input.maxBytes) + if input.resultOnly, result.state != .complete { + let error = result.error ?? error(for: result.state) + return errorResponse(code: error.code, message: error.message) + } + + let payload = AgentReadCommandPayload( + outputMode: input.resultOnly ? .resultOnly : .snapshot, + target: snapshot.target, + agent: AgentReadAgent( + type: snapshot.agent.rawValue, + status: snapshot.status, + rawState: snapshot.rawState, + detectionReason: snapshot.detectionReason, + lastChangedAt: snapshot.lastChangedAt, + session: snapshot.transcriptSession.map { + AgentReadSession(id: $0.id, confidence: $0.confidence.rawValue, source: $0.source.rawValue) + } + ), + blocker: snapshot.blockerText.map(AgentReadBlocker.init(text:)), + result: result + ) + + do { + return try CommandResponse( + ok: true, + command: "agents.read", + schemaVersion: "prowl.cli.agents.read.v1", + data: RawJSON(encoding: payload) + ) + } catch { + return errorResponse(code: CLIErrorCode.agentReadFailed, message: "Failed to encode agent snapshot.") + } + } + + private func makeResult(from snapshot: AgentReadRuntimeSnapshot, maxBytes: Int) async -> AgentReadResult { + guard let session = snapshot.transcriptSession, let path = session.transcriptPath else { + return failedResult(.unavailable) + } + + let transcript = await resultProvider(snapshot.agent, path, maxBytes) + switch transcript.state { + case .complete: + guard let text = transcript.text else { return failedResult(.incomplete) } + return AgentReadResult(state: .complete, text: text) + case .missing: + switch snapshot.status { + case .working, .blocked: + return AgentReadResult(state: .pending) + case .idle, .done: + return failedResult(.missing) + } + case .incomplete: + return failedResult(.incomplete) + case .tooLarge: + return failedResult(.tooLarge) + } + } + + private func failedResult(_ state: AgentReadResultState) -> AgentReadResult { + AgentReadResult(state: state, error: error(for: state)) + } + + private func error(for state: AgentReadResultState) -> AgentReadResultError { + switch state { + case .complete: + AgentReadResultError(code: CLIErrorCode.agentReadFailed, message: "Missing complete agent result.") + case .pending, .missing: + AgentReadResultError(code: CLIErrorCode.resultNotFound, message: "No completed agent result is available.") + case .unavailable: + AgentReadResultError( + code: CLIErrorCode.sessionUnresolved, + message: "No exact or high-confidence transcript session is available." + ) + case .incomplete: + AgentReadResultError( + code: CLIErrorCode.resultIncomplete, + message: "The latest agent result is incomplete or unsupported." + ) + case .tooLarge: + AgentReadResultError( + code: CLIErrorCode.resultTooLarge, + message: "The latest agent result exceeds --max-bytes." + ) + } + } + + private func mapSnapshotError(_ error: AgentReadSnapshotError) -> CommandResponse { + switch error { + case .targetNotFound(let message): + errorResponse(code: CLIErrorCode.targetNotFound, message: message) + case .agentNotFound(let message): + errorResponse(code: CLIErrorCode.agentNotFound, message: message) + case .unsupportedAgent(let message): + errorResponse(code: CLIErrorCode.agentUnsupported, message: message) + case .activeScreenUnreadable: + errorResponse(code: CLIErrorCode.agentReadFailed, message: "Failed to read the agent's active screen.") + case .blockerUnreadable: + errorResponse(code: CLIErrorCode.blockerUnreadable, message: "Failed to read the current agent blocker.") + } + } + + private func errorResponse(code: String, message: String) -> CommandResponse { + CommandResponse( + ok: false, + command: "agents.read", + schemaVersion: "prowl.cli.agents.read.v1", + error: CommandError(code: code, message: message) + ) + } +} diff --git a/supacode/CLIService/CLICommandRouter.swift b/supacode/CLIService/CLICommandRouter.swift index e4698e0f..18dd3350 100644 --- a/supacode/CLIService/CLICommandRouter.swift +++ b/supacode/CLIService/CLICommandRouter.swift @@ -8,6 +8,7 @@ final class CLICommandRouter { private let openHandler: any CommandHandler private let listHandler: any CommandHandler private let agentsHandler: any CommandHandler + private let agentsReadHandler: any CommandHandler private let focusHandler: any CommandHandler private let sendHandler: any CommandHandler private let keyHandler: any CommandHandler @@ -20,6 +21,7 @@ final class CLICommandRouter { openHandler: any CommandHandler = StubCommandHandler(command: "open"), listHandler: any CommandHandler = StubCommandHandler(command: "list"), agentsHandler: any CommandHandler = StubCommandHandler(command: "agents"), + agentsReadHandler: any CommandHandler = StubCommandHandler(command: "agents.read"), focusHandler: any CommandHandler = StubCommandHandler(command: "focus"), sendHandler: any CommandHandler = StubCommandHandler(command: "send"), keyHandler: any CommandHandler = StubCommandHandler(command: "key"), @@ -31,6 +33,7 @@ final class CLICommandRouter { self.openHandler = openHandler self.listHandler = listHandler self.agentsHandler = agentsHandler + self.agentsReadHandler = agentsReadHandler self.focusHandler = focusHandler self.sendHandler = sendHandler self.keyHandler = keyHandler @@ -49,6 +52,7 @@ final class CLICommandRouter { case .open: handler = openHandler case .list: handler = listHandler case .agents: handler = agentsHandler + case .agentsRead: handler = agentsReadHandler case .focus: handler = focusHandler case .send: handler = sendHandler case .key: handler = keyHandler diff --git a/supacode/CLIService/CLISocketServer.swift b/supacode/CLIService/CLISocketServer.swift index b2721cbe..b46078d7 100644 --- a/supacode/CLIService/CLISocketServer.swift +++ b/supacode/CLIService/CLISocketServer.swift @@ -11,6 +11,8 @@ import Foundation @MainActor final class CLISocketServer { + private static let maximumFrameLength = 32 * 1_024 * 1_024 + private let router: CLICommandRouter private let socketPath: String private let lockPath: String @@ -199,7 +201,7 @@ final class CLISocketServer { let length = lengthData.withUnsafeBytes { UInt32(bigEndian: $0.load(as: UInt32.self)) } - guard length > 0, length < 10_000_000 else { return } + guard length > 0, length <= Self.maximumFrameLength else { return } let requestData = try Self.fdRead(fildes: clientFD, count: Int(length)) @@ -215,6 +217,7 @@ final class CLISocketServer { let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys] let responseData = try encoder.encode(response) + guard responseData.count > 0, responseData.count <= Self.maximumFrameLength else { return } var responseLength = UInt32(responseData.count).bigEndian try withUnsafeBytes(of: &responseLength) { try Self.fdWrite(fildes: clientFD, buffer: $0) } diff --git a/supacode/CLIService/Shared/AgentReadCommandPayload.swift b/supacode/CLIService/Shared/AgentReadCommandPayload.swift new file mode 100644 index 00000000..c45d649b --- /dev/null +++ b/supacode/CLIService/Shared/AgentReadCommandPayload.swift @@ -0,0 +1,121 @@ +import Foundation + +public struct AgentReadCommandPayload: Codable, Equatable, Sendable { + public let outputMode: AgentReadOutputMode + public let target: ReadTarget + public let agent: AgentReadAgent + public let blocker: AgentReadBlocker? + public let result: AgentReadResult + + enum CodingKeys: String, CodingKey { + case outputMode = "output_mode" + case target + case agent + case blocker + case result + } + + public init( + outputMode: AgentReadOutputMode, + target: ReadTarget, + agent: AgentReadAgent, + blocker: AgentReadBlocker?, + result: AgentReadResult + ) { + self.outputMode = outputMode + self.target = target + self.agent = agent + self.blocker = blocker + self.result = result + } +} + +public enum AgentReadOutputMode: String, Codable, Equatable, Sendable { + case snapshot + case resultOnly = "result_only" +} + +public struct AgentReadAgent: Codable, Equatable, Sendable { + public let type: String + public let status: AgentsCommandStatus + public let rawState: String + public let detectionReason: String? + public let lastChangedAt: String + public let session: AgentReadSession? + + enum CodingKeys: String, CodingKey { + case type + case status + case rawState = "raw_state" + case detectionReason = "detection_reason" + case lastChangedAt = "last_changed_at" + case session + } + + public init( + type: String, + status: AgentsCommandStatus, + rawState: String, + detectionReason: String?, + lastChangedAt: String, + session: AgentReadSession? + ) { + self.type = type + self.status = status + self.rawState = rawState + self.detectionReason = detectionReason + self.lastChangedAt = lastChangedAt + self.session = session + } +} + +public struct AgentReadSession: Codable, Equatable, Sendable { + public let id: String + public let confidence: String + public let source: String + + public init(id: String, confidence: String, source: String) { + self.id = id + self.confidence = confidence + self.source = source + } +} + +public struct AgentReadBlocker: Codable, Equatable, Sendable { + public let text: String + + public init(text: String) { + self.text = text + } +} + +public struct AgentReadResult: Codable, Equatable, Sendable { + public let state: AgentReadResultState + public let text: String? + public let error: AgentReadResultError? + + public init(state: AgentReadResultState, text: String? = nil, error: AgentReadResultError? = nil) { + self.state = state + self.text = text + self.error = error + } +} + +public enum AgentReadResultState: String, Codable, Equatable, Sendable { + case complete + case pending + case unavailable + case missing + case incomplete + case tooLarge = "too_large" +} + +public struct AgentReadResultError: Codable, Equatable, Sendable { + public let code: String + public let message: String + + public init(code: String, message: String) { + self.code = code + self.message = message + } +} diff --git a/supacode/CLIService/Shared/AgentsCommandPayload.swift b/supacode/CLIService/Shared/AgentsCommandPayload.swift index a1038874..b3397f24 100644 --- a/supacode/CLIService/Shared/AgentsCommandPayload.swift +++ b/supacode/CLIService/Shared/AgentsCommandPayload.swift @@ -82,7 +82,7 @@ public struct AgentsCommandSession: Codable, Equatable { } } -public enum AgentsCommandStatus: String, Codable, Equatable { +public enum AgentsCommandStatus: String, Codable, Equatable, Sendable { case blocked case working case done diff --git a/supacode/CLIService/Shared/CommandEnvelope.swift b/supacode/CLIService/Shared/CommandEnvelope.swift index c252d0c3..ad0f07c9 100644 --- a/supacode/CLIService/Shared/CommandEnvelope.swift +++ b/supacode/CLIService/Shared/CommandEnvelope.swift @@ -17,6 +17,7 @@ public enum Command: Codable, Sendable { case open(OpenInput) case list(ListInput) case agents(AgentsInput) + case agentsRead(AgentReadInput) case focus(FocusInput) case send(SendInput) case key(KeyInput) @@ -30,6 +31,7 @@ public enum Command: Codable, Sendable { case .open: "open" case .list: "list" case .agents: "agents" + case .agentsRead: "agents.read" case .focus: "focus" case .send: "send" case .key: "key" diff --git a/supacode/CLIService/Shared/ErrorCodes.swift b/supacode/CLIService/Shared/ErrorCodes.swift index 1dbc5a6b..6171d4b0 100644 --- a/supacode/CLIService/Shared/ErrorCodes.swift +++ b/supacode/CLIService/Shared/ErrorCodes.swift @@ -22,6 +22,14 @@ public enum CLIErrorCode { // Agents public static let agentsFailed = "AGENTS_FAILED" + public static let agentNotFound = "AGENT_NOT_FOUND" + public static let agentUnsupported = "AGENT_UNSUPPORTED" + public static let agentReadFailed = "AGENT_READ_FAILED" + public static let blockerUnreadable = "BLOCKER_UNREADABLE" + public static let sessionUnresolved = "SESSION_UNRESOLVED" + public static let resultNotFound = "RESULT_NOT_FOUND" + public static let resultIncomplete = "RESULT_INCOMPLETE" + public static let resultTooLarge = "RESULT_TOO_LARGE" // Focus public static let focusFailed = "FOCUS_FAILED" diff --git a/supacode/CLIService/Shared/InputModels.swift b/supacode/CLIService/Shared/InputModels.swift index 9adff204..b69457cf 100644 --- a/supacode/CLIService/Shared/InputModels.swift +++ b/supacode/CLIService/Shared/InputModels.swift @@ -30,6 +30,27 @@ public struct AgentsInput: Codable, Sendable { public init() {} } +public struct AgentReadInput: Codable, Sendable { + public static let defaultMaxBytes = 1_024 * 1_024 + public static let maximumMaxBytes = 4 * 1_024 * 1_024 + + public let pane: String + public let maxBytes: Int + public let resultOnly: Bool + + enum CodingKeys: String, CodingKey { + case pane + case maxBytes = "max_bytes" + case resultOnly = "result_only" + } + + public init(pane: String, maxBytes: Int = Self.defaultMaxBytes, resultOnly: Bool = false) { + self.pane = pane + self.maxBytes = maxBytes + self.resultOnly = resultOnly + } +} + public struct FocusInput: Codable, Sendable { public let selector: TargetSelector diff --git a/supacode/Infrastructure/AgentDetection/AgentSessionResolver.swift b/supacode/Infrastructure/AgentDetection/AgentSessionResolver.swift index 6fae15af..bdc65749 100644 --- a/supacode/Infrastructure/AgentDetection/AgentSessionResolver.swift +++ b/supacode/Infrastructure/AgentDetection/AgentSessionResolver.swift @@ -221,6 +221,14 @@ actor AgentSessionResolver { let now: Date } + private struct ResolveInput { + let identified: IdentifiedAgentProcess + let workingDirectory: URL? + let activeText: String + let configRoot: URL? + let now: Date + } + /// Unresolved lookups retry quickly while the narrow scan stays cheap, then /// back off exponentially while the pane stays ambiguous; wide fallback /// scans (full history trees) start at the slow end. 15 s cap keeps a @@ -311,13 +319,53 @@ actor AgentSessionResolver { configRoot: URL? = nil, now: Date = Date() ) -> AgentSessionResolution { + resolve( + ResolveInput( + identified: identified, + workingDirectory: workingDirectory, + activeText: activeText, + configRoot: configRoot, + now: now + ), + bypassResultCache: false + ) + } + + /// Recomputes session attribution from current process/screen evidence instead + /// of replaying a `PaneAgentState` or pid-result cache entry. File/root parsing + /// caches remain valid optimizations because they are keyed by file identity. + func resolveFresh( + identified: IdentifiedAgentProcess, + workingDirectory: URL?, + activeText: String, + configRoot: URL? = nil, + now: Date = Date() + ) -> AgentSessionResolution { + resolve( + ResolveInput( + identified: identified, + workingDirectory: workingDirectory, + activeText: activeText, + configRoot: configRoot, + now: now + ), + bypassResultCache: true + ) + } + + private func resolve(_ input: ResolveInput, bypassResultCache: Bool) -> AgentSessionResolution { + let identified = input.identified + let workingDirectory = input.workingDirectory + let activeText = input.activeText + let configRoot = input.configRoot + let now = input.now let process = identified.process guard let startedAt = ProcessDetection.processStartDate(pid: process.pid) else { return AgentSessionResolution(session: nil, isFresh: true) } let key = CacheKey(pid: process.pid, startedAt: startedAt) let cached = cache[key] - if let cached { + if !bypassResultCache, let cached { let lifetime = Self.cacheLifetime( hasSession: cached.session != nil, usedWideScan: cached.usedWideScan, diff --git a/supacode/Infrastructure/AgentDetection/AgentTranscriptResultReader.swift b/supacode/Infrastructure/AgentDetection/AgentTranscriptResultReader.swift new file mode 100644 index 00000000..77e13c71 --- /dev/null +++ b/supacode/Infrastructure/AgentDetection/AgentTranscriptResultReader.swift @@ -0,0 +1,233 @@ +import Foundation + +nonisolated enum AgentTranscriptResultState: Equatable, Sendable { + case complete + case missing + case incomplete + case tooLarge +} + +nonisolated struct AgentTranscriptResult: Equatable, Sendable { + let state: AgentTranscriptResultState + let text: String? + + static func complete(_ text: String) -> Self { + Self(state: .complete, text: text) + } + + static func failure(_ state: AgentTranscriptResultState) -> Self { + Self(state: state, text: nil) + } +} + +/// Reads a closed, final agent response from a single native transcript snapshot. +/// +/// This intentionally accepts only the narrow, observed Codex and Claude Code +/// completion schemas. Unknown or partially-written JSONL is not a weaker form of +/// success: callers must preserve the live snapshot while withholding transcript text. +nonisolated enum AgentTranscriptResultReader { + /// Reads a stable file snapshot. A concurrent append gets one immediate retry; + /// accepting a moving file would make a JSONL boundary ambiguous. + static func read(agent: DetectedAgent, at url: URL, maxBytes: Int) -> AgentTranscriptResult { + let fileManager = FileManager.default + for _ in 0..<2 { + guard let before = attributes(at: url, fileManager: fileManager), + let tail = tailData(at: url, byteLimit: tailByteLimit(for: maxBytes)), + let after = attributes(at: url, fileManager: fileManager), + before.size == after.size, + before.modifiedAt == after.modifiedAt, + let jsonl = completeJSONL(tail) + else { + continue + } + return decode(agent: agent, jsonl: jsonl, maxBytes: maxBytes) + } + return .failure(.incomplete) + } + + static func decode( + agent: DetectedAgent, + jsonl: String, + maxBytes: Int + ) -> AgentTranscriptResult { + guard maxBytes > 0 else { return .failure(.tooLarge) } + guard jsonl.isEmpty || jsonl.hasSuffix("\n") else { return .failure(.incomplete) } + let records = decodeRecords(jsonl) + guard let records else { return .failure(.incomplete) } + + switch agent { + case .codex: + return decodeCodex(records: records, maxBytes: maxBytes) + case .claude: + return decodeClaude(records: records, maxBytes: maxBytes) + default: + return .failure(.incomplete) + } + } + + private struct TranscriptTail { + let data: Data + let startsMidRecord: Bool + } + + /// A JSON string can be larger than its UTF-8 result after escaping. Three + /// times the requested result plus 1 MiB leaves room for one maximal latest + /// record and its close markers, while the 16 MiB cap keeps giant histories + /// out of the process even when callers request the 4 MiB result maximum. + private static func tailByteLimit(for maxBytes: Int) -> UInt64 { + let scaled = max(0, maxBytes) * 3 + 1_024 * 1_024 + return UInt64(min(16 * 1_024 * 1_024, max(1_024 * 1_024, scaled))) + } + + private static func tailData(at url: URL, byteLimit: UInt64) -> TranscriptTail? { + guard let handle = try? FileHandle(forReadingFrom: url) else { return nil } + defer { try? handle.close() } + guard let size = try? handle.seekToEnd() else { return nil } + let offset = size > byteLimit ? size - byteLimit : 0 + do { + try handle.seek(toOffset: offset) + guard let data = try handle.readToEnd() else { return nil } + return TranscriptTail(data: data, startsMidRecord: offset > 0) + } catch { + return nil + } + } + + private static func completeJSONL(_ tail: TranscriptTail) -> String? { + let data: Data + if tail.startsMidRecord { + guard let firstNewline = tail.data.firstIndex(of: 0x0A) else { return nil } + data = tail.data.suffix(from: tail.data.index(after: firstNewline)) + } else { + data = tail.data + } + return String(data: data, encoding: .utf8) + } + + private static func attributes( + at url: URL, + fileManager: FileManager + ) -> (size: UInt64, modifiedAt: Date)? { + guard let attributes = try? fileManager.attributesOfItem(atPath: url.path), + let size = attributes[.size] as? NSNumber, + let modifiedAt = attributes[.modificationDate] as? Date + else { + return nil + } + return (size.uint64Value, modifiedAt) + } + + private static func decodeRecords(_ jsonl: String) -> [[String: Any]]? { + var records: [[String: Any]] = [] + for line in jsonl.split(separator: "\n", omittingEmptySubsequences: true) { + guard let data = line.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { + return nil + } + records.append(object) + } + return records + } + + private static func decodeCodex(records: [[String: Any]], maxBytes: Int) -> AgentTranscriptResult { + guard let payload = records.reversed().compactMap(codexCompletionPayload).first else { + return .failure(.missing) + } + guard !containsMaxTokens(payload), + payload["turn_id"] is String, + payload["completed_at"] is String, + let text = payload["last_agent_message"] as? String + else { + return .failure(.incomplete) + } + return bounded(text, maxBytes: maxBytes) + } + + private static func codexCompletionPayload(_ record: [String: Any]) -> [String: Any]? { + guard record["type"] as? String == "event_msg", + let payload = record["payload"] as? [String: Any], + payload["type"] as? String == "task_complete" + else { + return nil + } + return payload + } + + private static func decodeClaude(records: [[String: Any]], maxBytes: Int) -> AgentTranscriptResult { + guard let closeRecord = records.reversed().first(where: isClaudeTurnDuration) else { + return .failure(.missing) + } + guard let closeSessionID = closeRecord["sessionId"] as? String, + var parentID = closeRecord["parentUuid"] as? String + else { + return .failure(.incomplete) + } + + var recordsByUUID: [String: [String: Any]] = [:] + for record in records { + guard let uuid = record["uuid"] as? String else { continue } + guard recordsByUUID[uuid] == nil else { return .failure(.incomplete) } + recordsByUUID[uuid] = record + } + + for _ in 0..<32 { + guard let record = recordsByUUID[parentID], record["sessionId"] as? String == closeSessionID else { + return .failure(.incomplete) + } + if record["type"] as? String == "assistant" { + return decodeClaudeAssistant(record, maxBytes: maxBytes) + } + guard let nextParentID = record["parentUuid"] as? String, nextParentID != parentID else { + return .failure(.incomplete) + } + parentID = nextParentID + } + return .failure(.incomplete) + } + + private static func isClaudeTurnDuration(_ record: [String: Any]) -> Bool { + record["type"] as? String == "system" && record["subtype"] as? String == "turn_duration" + } + + private static func decodeClaudeAssistant( + _ record: [String: Any], + maxBytes: Int + ) -> AgentTranscriptResult { + guard let message = record["message"] as? [String: Any], + let stopReason = message["stop_reason"] as? String, + stopReason == "end_turn" || stopReason == "stop_sequence", + let content = message["content"] as? [[String: Any]], + !content.isEmpty + else { + return .failure(.incomplete) + } + + var text = "" + for block in content { + guard block["type"] as? String == "text", let value = block["text"] as? String else { + return .failure(.incomplete) + } + text += value + } + guard !text.isEmpty else { return .failure(.incomplete) } + return bounded(text, maxBytes: maxBytes) + } + + private static func bounded(_ text: String, maxBytes: Int) -> AgentTranscriptResult { + text.utf8.count <= maxBytes ? .complete(text) : .failure(.tooLarge) + } + + private static func containsMaxTokens(_ value: Any) -> Bool { + if let text = value as? String { + return text.lowercased().contains("max_tokens") + } + if let array = value as? [Any] { + return array.contains(where: containsMaxTokens) + } + if let object = value as? [String: Any] { + return object.values.contains(where: containsMaxTokens) + } + return false + } +} diff --git a/supacode/Infrastructure/AgentDetection/ClaudeScreenProfile.swift b/supacode/Infrastructure/AgentDetection/ClaudeScreenProfile.swift index dc398265..8b2e6413 100644 --- a/supacode/Infrastructure/AgentDetection/ClaudeScreenProfile.swift +++ b/supacode/Infrastructure/AgentDetection/ClaudeScreenProfile.swift @@ -44,6 +44,16 @@ enum ClaudeScreenProfile { return AgentScreenDetection(state: .idle, reason: .noRuleMatched) } + /// Raw current interaction text for an actionable blocked screen. Keep the + /// rendered choices and keyboard hints intact rather than inventing option fields. + nonisolated static func blockerText(in snapshot: AgentScreenSnapshot) -> String? { + let regions = ClaudeScreenRegions(snapshot: snapshot) + guard hasBlockedPrompt(regions) else { return nil } + let lines = + regions.currentInteractionLines.isEmpty ? Array(snapshot.lines.suffix(18)) : regions.currentInteractionLines + return lines.joined(separator: "\n").trimmingCharacters(in: .newlines) + } + nonisolated private static func hasViewerChrome(_ regions: ClaudeScreenRegions) -> Bool { if regions.bottomChromeLines.contains(where: { line in line.contains("⌕ Search…") || line.lowercased().contains("ctrl+r to toggle") diff --git a/supacode/Infrastructure/AgentDetection/CodexScreenProfile.swift b/supacode/Infrastructure/AgentDetection/CodexScreenProfile.swift index 4d4d7ea0..c3d5537b 100644 --- a/supacode/Infrastructure/AgentDetection/CodexScreenProfile.swift +++ b/supacode/Infrastructure/AgentDetection/CodexScreenProfile.swift @@ -44,6 +44,17 @@ enum CodexScreenProfile { return AgentScreenDetection(state: .idle, reason: .noRuleMatched) } + /// Raw current interaction text for an actionable blocked screen. This deliberately + /// preserves TUI selection markers and keyboard hints instead of reconstructing options. + nonisolated static func blockerText(in snapshot: AgentScreenSnapshot) -> String? { + let regions = CodexScreenRegions(snapshot: snapshot) + guard detect(in: snapshot).state == .blocked else { return nil } + if regions.selectedChoice != nil || hasSignInPrompt(regions) { + return snapshot.text.trimmingCharacters(in: .newlines) + } + return nil + } + nonisolated private static func hasDirectoryTrustPrompt(_ regions: CodexScreenRegions) -> Bool { hasSelectedChoice( regions, diff --git a/supacodeTests/AgentReadCommandHandlerTests.swift b/supacodeTests/AgentReadCommandHandlerTests.swift new file mode 100644 index 00000000..1ae01edf --- /dev/null +++ b/supacodeTests/AgentReadCommandHandlerTests.swift @@ -0,0 +1,121 @@ +import Foundation +import Testing + +@testable import supacode + +@MainActor +struct AgentReadCommandHandlerTests { + @Test func blockedSnapshotPreservesBlockerWhenTranscriptIsUnavailable() async throws { + let handler = AgentReadCommandHandler( + snapshotProvider: { _ in + .success(self.makeSnapshot(status: .blocked, blockerText: "Do you want to proceed?\n❯ 1. Yes\n 2. No")) + }, + resultProvider: { _, _, _ in + Issue.record("Unexpected transcript read") + return .failure(.incomplete) + } + ) + + let response = await handler.handle( + envelope: CommandEnvelope(output: .json, command: .agentsRead(AgentReadInput(pane: "p7"))) + ) + + #expect(response.ok) + let payload = try #require(try response.data?.decode(as: AgentReadCommandPayload.self)) + #expect(payload.agent.status == .blocked) + #expect(payload.blocker?.text.contains("1. Yes") == true) + #expect(payload.result.state == .unavailable) + #expect(payload.result.error?.code == CLIErrorCode.sessionUnresolved) + } + + @Test func workingSnapshotMapsMissingTrustedHistoryToPending() async throws { + let handler = AgentReadCommandHandler( + snapshotProvider: { _ in .success(self.makeSnapshot(status: .working, session: self.makeSession())) }, + resultProvider: { _, _, _ in .failure(.missing) } + ) + + let response = await handler.handle( + envelope: CommandEnvelope(output: .json, command: .agentsRead(AgentReadInput(pane: "p7"))) + ) + + #expect(response.ok) + let payload = try #require(try response.data?.decode(as: AgentReadCommandPayload.self)) + #expect(payload.result.state == .pending) + #expect(payload.result.text == nil) + #expect(payload.result.error == nil) + } + + @Test func resultOnlyTurnsUnavailableSnapshotResultIntoCommandFailure() async { + let handler = AgentReadCommandHandler( + snapshotProvider: { _ in .success(self.makeSnapshot(status: .idle)) }, + resultProvider: { _, _, _ in + Issue.record("Unexpected transcript read") + return .failure(.incomplete) + } + ) + + let response = await handler.handle( + envelope: CommandEnvelope( + output: .text, + command: .agentsRead(AgentReadInput(pane: "p7", resultOnly: true)) + ) + ) + + #expect(response.ok == false) + #expect(response.error?.code == CLIErrorCode.sessionUnresolved) + } + + @Test func resultOnlySucceedsForCompleteTrustedResult() async throws { + let handler = AgentReadCommandHandler( + snapshotProvider: { _ in .success(self.makeSnapshot(status: .done, session: self.makeSession())) }, + resultProvider: { _, _, _ in .complete("Final result") } + ) + + let response = await handler.handle( + envelope: CommandEnvelope( + output: .text, + command: .agentsRead(AgentReadInput(pane: "p7", resultOnly: true)) + ) + ) + + #expect(response.ok) + let payload = try #require(try response.data?.decode(as: AgentReadCommandPayload.self)) + #expect(payload.outputMode == .resultOnly) + #expect(payload.result.state == .complete) + #expect(payload.result.text == "Final result") + } + + private func makeSnapshot( + status: AgentsCommandStatus, + blockerText: String? = nil, + session: AgentSession? = nil + ) -> AgentReadRuntimeSnapshot { + AgentReadRuntimeSnapshot( + target: ReadTarget( + worktree: ReadTargetWorktree( + id: "/tmp/project", name: "main", path: "/tmp/project", rootPath: "/tmp/project", kind: "git" + ), + tab: ReadTargetTab(id: "2FC00CF0-3974-4E1B-BEF8-7A08A8E3B7C0", title: "Agent", selected: true), + pane: ReadTargetPane( + id: "6E1A2A10-D99F-4E3F-920C-D93AA3C05764", title: "Claude", cwd: "/tmp/project", focused: false + ) + ), + agent: .claude, + status: status, + rawState: status == .done ? "idle" : status.rawValue, + detectionReason: "claude.blockedPrompt", + lastChangedAt: "2026-08-11T12:00:00Z", + blockerText: blockerText, + transcriptSession: session + ) + } + + private func makeSession() -> AgentSession { + AgentSession( + id: "019f4f1b-3650-7661-a56d-351f02f01139", + transcriptPath: URL(fileURLWithPath: "/tmp/session.jsonl"), + source: .openFile, + confidence: .exact + ) + } +} diff --git a/supacodeTests/AgentTranscriptResultReaderTests.swift b/supacodeTests/AgentTranscriptResultReaderTests.swift new file mode 100644 index 00000000..6959af46 --- /dev/null +++ b/supacodeTests/AgentTranscriptResultReaderTests.swift @@ -0,0 +1,178 @@ +import Foundation +import Testing + +@testable import supacode + +struct AgentTranscriptResultReaderTests { + @Test func decodesLatestClosedCodexTaskCompletion() throws { + let result = AgentTranscriptResultReader.decode( + agent: .codex, + jsonl: try jsonl([ + codexCompletion(turnID: "turn-1", completedAt: "2026-08-11T12:00:00Z", text: "First result"), + codexCompletion(turnID: "turn-2", completedAt: "2026-08-11T12:01:00Z", text: "Final result"), + ]), + maxBytes: 1_024 + ) + + #expect(result.state == .complete) + #expect(result.text == "Final result") + } + + @Test func reportsCodexMaxTokensAsIncompleteWithoutText() throws { + let result = AgentTranscriptResultReader.decode( + agent: .codex, + jsonl: try jsonl([ + codexCompletion( + turnID: "turn-1", + completedAt: "2026-08-11T12:00:00Z", + text: "Partial result", + status: "max_tokens" + ) + ]), + maxBytes: 1_024 + ) + + #expect(result.state == .incomplete) + #expect(result.text == nil) + } + + @Test func followsClaudeTurnDurationParentChainToClosedAssistantText() throws { + let result = AgentTranscriptResultReader.decode( + agent: .claude, + jsonl: try jsonl([ + claudeAssistant(uuid: "assistant-1", sessionID: "session-1", text: "Claude final result"), + claudeSystem( + subtype: "stop_hook_summary", + uuid: "summary-1", + sessionID: "session-1", + parentUUID: "assistant-1" + ), + claudeSystem( + subtype: "turn_duration", + uuid: "duration-1", + sessionID: "session-1", + parentUUID: "summary-1" + ), + ]), + maxBytes: 1_024 + ) + + #expect(result.state == .complete) + #expect(result.text == "Claude final result") + } + + @Test func rejectsClaudeToolOnlyAssistantTurnAsIncomplete() throws { + let result = AgentTranscriptResultReader.decode( + agent: .claude, + jsonl: try jsonl([ + claudeAssistant(uuid: "assistant-1", sessionID: "session-1", content: [["type": "tool_use", "name": "Bash"]]), + claudeSystem( + subtype: "turn_duration", + uuid: "duration-1", + sessionID: "session-1", + parentUUID: "assistant-1" + ), + ]), + maxBytes: 1_024 + ) + + #expect(result.state == .incomplete) + #expect(result.text == nil) + } + + @Test func rejectsUnclosedJSONLWithoutReturningPartialText() throws { + let jsonl = + try jsonl([ + codexCompletion(turnID: "turn-1", completedAt: "2026-08-11T12:00:00Z", text: "Final result") + ]) + "{\"type\":\"event_msg\"\n" + let result = AgentTranscriptResultReader.decode(agent: .codex, jsonl: jsonl, maxBytes: 1_024) + + #expect(result.state == .incomplete) + #expect(result.text == nil) + } + + @Test func reportsOversizedCompleteResultWithoutTruncatingIt() throws { + let result = AgentTranscriptResultReader.decode( + agent: .codex, + jsonl: try jsonl([ + codexCompletion(turnID: "turn-1", completedAt: "2026-08-11T12:00:00Z", text: "0123456789") + ]), + maxBytes: 5 + ) + + #expect(result.state == .tooLarge) + #expect(result.text == nil) + } + + @Test func readsOnlyTheBoundedTranscriptTail() throws { + let url = FileManager.default.temporaryDirectory.appending(path: "prowl-result-\(UUID().uuidString).jsonl") + defer { try? FileManager.default.removeItem(at: url) } + let oversizedHistory = + "{\"type\":\"event_msg\",\"payload\":{\"type\":\"agent_message\",\"text\":\"" + + String(repeating: "x", count: 1_100_000) + "\"}}\n" + let completion = try jsonl([ + codexCompletion(turnID: "turn-1", completedAt: "2026-08-11T12:00:00Z", text: "Final result") + ]) + try (oversizedHistory + completion).write(to: url, atomically: true, encoding: .utf8) + + let result = AgentTranscriptResultReader.read(agent: .codex, at: url, maxBytes: 1_024) + + #expect(result.state == .complete) + #expect(result.text == "Final result") + } + + private func codexCompletion( + turnID: String, + completedAt: String, + text: String, + status: String? = nil + ) -> [String: Any] { + var payload: [String: Any] = [ + "type": "task_complete", + "turn_id": turnID, + "completed_at": completedAt, + "last_agent_message": text, + ] + payload["status"] = status + return ["type": "event_msg", "payload": payload] + } + + private func claudeAssistant( + uuid: String, + sessionID: String, + text: String? = nil, + content: [[String: Any]]? = nil + ) -> [String: Any] { + [ + "type": "assistant", + "uuid": uuid, + "sessionId": sessionID, + "message": [ + "stop_reason": "end_turn", + "content": content ?? [["type": "text", "text": text ?? ""]], + ], + ] + } + + private func claudeSystem( + subtype: String, + uuid: String, + sessionID: String, + parentUUID: String + ) -> [String: Any] { + [ + "type": "system", + "subtype": subtype, + "uuid": uuid, + "sessionId": sessionID, + "parentUuid": parentUUID, + ] + } + + private func jsonl(_ records: [[String: Any]]) throws -> String { + try records.map { record in + let data = try JSONSerialization.data(withJSONObject: record, options: [.sortedKeys]) + return try #require(String(data: data, encoding: .utf8)) + "\n" + }.joined() + } +} diff --git a/supacodeTests/CLICommandEnvelopeTests.swift b/supacodeTests/CLICommandEnvelopeTests.swift index 65db6dfb..c71e3953 100644 --- a/supacodeTests/CLICommandEnvelopeTests.swift +++ b/supacodeTests/CLICommandEnvelopeTests.swift @@ -71,6 +71,23 @@ struct CLICommandEnvelopeTests { } } + @Test func envelopeAgentsReadRoundTrips() throws { + let envelope = CommandEnvelope( + output: .text, + command: .agentsRead(AgentReadInput(pane: "p7", maxBytes: 1_024, resultOnly: true)) + ) + let data = try JSONEncoder().encode(envelope) + let decoded = try JSONDecoder().decode(CommandEnvelope.self, from: data) + + if case .agentsRead(let input) = decoded.command { + #expect(input.pane == "p7") + #expect(input.maxBytes == 1_024) + #expect(input.resultOnly) + } else { + Issue.record("Expected .agentsRead command") + } + } + @Test func envelopeSendWithSelectorRoundTrips() throws { let envelope = CommandEnvelope( output: .json, @@ -247,6 +264,7 @@ struct CLICommandEnvelopeTests { (.open(OpenInput(path: nil)), "open"), (.list(ListInput()), "list"), (.agents(AgentsInput()), "agents"), + (.agentsRead(AgentReadInput(pane: "p7")), "agents.read"), (.focus(FocusInput()), "focus"), (.send(SendInput(text: "x")), "send"), (.key(KeyInput(rawToken: "tab", token: "tab")), "key"), @@ -266,6 +284,7 @@ struct CLICommandEnvelopeTests { .open(OpenInput(path: "/tmp")), .list(ListInput()), .agents(AgentsInput()), + .agentsRead(AgentReadInput(pane: "p7")), .focus(FocusInput()), .send(SendInput(text: "test")), .key(KeyInput(rawToken: "enter", token: "enter")), diff --git a/supacodeTests/CLICommandRouterTests.swift b/supacodeTests/CLICommandRouterTests.swift index 4cc7e9cd..9f7afea2 100644 --- a/supacodeTests/CLICommandRouterTests.swift +++ b/supacodeTests/CLICommandRouterTests.swift @@ -67,6 +67,18 @@ struct CLICommandRouterTests { #expect(response.error?.code == "NOT_IMPLEMENTED") } + @MainActor + @Test func routerDispatchesAgentsReadToAgentsReadHandler() async { + let router = CLICommandRouter() + let envelope = CommandEnvelope( + output: .json, + command: .agentsRead(AgentReadInput(pane: "p7")) + ) + let response = await router.route(envelope) + #expect(response.command == "agents.read") + #expect(response.error?.code == "NOT_IMPLEMENTED") + } + @MainActor @Test func routerDispatchesKeyToKeyHandler() async { let router = CLICommandRouter() @@ -137,6 +149,7 @@ struct CLICommandRouterTests { .open(OpenInput()), .list(ListInput()), .agents(AgentsInput()), + .agentsRead(AgentReadInput(pane: "p7")), .focus(FocusInput()), .send(SendInput(text: "x")), .key(KeyInput(rawToken: "tab", token: "tab")), diff --git a/supacodeTests/CLITransportProtocolTests.swift b/supacodeTests/CLITransportProtocolTests.swift index 87001e00..dd31c823 100644 --- a/supacodeTests/CLITransportProtocolTests.swift +++ b/supacodeTests/CLITransportProtocolTests.swift @@ -83,8 +83,8 @@ struct CLITransportProtocolTests { } @Test func maxReasonablePayloadLengthEncodes() { - // 10MB is the max accepted by both client and server - let maxLength: UInt32 = 9_999_999 + // 32 MiB is the max accepted by both client and server. + let maxLength: UInt32 = 32 * 1_024 * 1_024 var encoded = UInt32(maxLength).bigEndian var data = Data() withUnsafeBytes(of: &encoded) { data.append(contentsOf: $0) } diff --git a/supacodeTests/ClaudeScreenProfileTests.swift b/supacodeTests/ClaudeScreenProfileTests.swift index 5ed61562..62e79637 100644 --- a/supacodeTests/ClaudeScreenProfileTests.swift +++ b/supacodeTests/ClaudeScreenProfileTests.swift @@ -91,6 +91,19 @@ struct ClaudeScreenProfileTests { #expect(detection.reason == .matched(ClaudeScreenProfile.RuleID.blockedPrompt)) } + @Test func blockerTextPreservesClaudeQuestionChoicesAndKeyboardHints() throws { + let fixture = try AgentScreenFixtureCorpus.load() + .first { $0.relativePath == "claude/2.1.223/blocked/command-permission.txt" } + let text = try #require(fixture).text + + let blocker = ClaudeScreenProfile.blockerText(in: AgentScreenSnapshot(canonicalText: text)) + + #expect(blocker?.contains("Do you want to proceed?") == true) + #expect(blocker?.contains("❯ 1. Yes") == true) + #expect(blocker?.contains("3. No") == true) + #expect(blocker?.contains("Esc to cancel · Tab to amend · ctrl+e to explain") == true) + } + @Test func unstructuredScreenUsesExplicitFallback() { let detection = ClaudeScreenProfile.detect( in: AgentScreenSnapshot(canonicalText: "screen without live Claude chrome") diff --git a/supacodeTests/CodexScreenProfileTests.swift b/supacodeTests/CodexScreenProfileTests.swift index e670b356..d27a5a2d 100644 --- a/supacodeTests/CodexScreenProfileTests.swift +++ b/supacodeTests/CodexScreenProfileTests.swift @@ -50,4 +50,17 @@ struct CodexScreenProfileTests { #expect(detection.state == .blocked) #expect(detection.reason == .matched(CodexScreenProfile.RuleID.confirmationChoices)) } + + @Test func blockerTextPreservesCodexQuestionChoicesAndKeyboardHints() throws { + let fixture = try AgentScreenFixtureCorpus.load() + .first { $0.relativePath == "codex/0.146.1/blocked/command-permission.txt" } + let text = try #require(fixture).text + + let blocker = CodexScreenProfile.blockerText(in: AgentScreenSnapshot(canonicalText: text)) + + #expect(blocker?.contains("Would you like to run the following command?") == true) + #expect(blocker?.contains("› 1. Yes, proceed (y)") == true) + #expect(blocker?.contains("3. No, and tell Codex what to do differently (esc)") == true) + #expect(blocker?.contains("Press enter to confirm or esc to cancel") == true) + } } diff --git a/supacodeTests/SupacodeAppCLITests.swift b/supacodeTests/SupacodeAppCLITests.swift index c4c8826e..0ac1fede 100644 --- a/supacodeTests/SupacodeAppCLITests.swift +++ b/supacodeTests/SupacodeAppCLITests.swift @@ -17,6 +17,9 @@ struct SupacodeAppCLITests { let agentsResponse = await router.route( CommandEnvelope(output: .json, command: .agents(AgentsInput())) ) + let agentReadResponse = await router.route( + CommandEnvelope(output: .json, command: .agentsRead(AgentReadInput(pane: "p7"))) + ) let keyResponse = await router.route( CommandEnvelope(output: .json, command: .key(KeyInput(rawToken: "enter", token: "enter"))) ) @@ -25,9 +28,11 @@ struct SupacodeAppCLITests { ) #expect(agentsResponse.command == "agents") + #expect(agentReadResponse.command == "agents.read") #expect(keyResponse.command == "key") #expect(readResponse.command == "read") #expect(agentsResponse.error?.code != "NOT_IMPLEMENTED") + #expect(agentReadResponse.error?.code != "NOT_IMPLEMENTED") #expect(keyResponse.error?.code != "NOT_IMPLEMENTED") #expect(readResponse.error?.code != "NOT_IMPLEMENTED") }