diff --git a/.gitignore b/.gitignore index 45e77007..8377bf29 100644 --- a/.gitignore +++ b/.gitignore @@ -77,6 +77,9 @@ Resources/docs build/ .DS_Store +# Prowl-generated handoff artifacts can include terminal/session excerpts. +.prowl/handoff/ + # Release-only credentials (analytics, crash reporting) Config/Secrets.env .build-benchmark/ diff --git a/ProwlCLI/Commands/HandoffCommand.swift b/ProwlCLI/Commands/HandoffCommand.swift index 61eae372..d9425120 100644 --- a/ProwlCLI/Commands/HandoffCommand.swift +++ b/ProwlCLI/Commands/HandoffCommand.swift @@ -45,7 +45,7 @@ struct HandoffToCommand: ParsableCommand { abstract: "Save + archive the handoff, then launch the receiving agent in a new tab." ) - @Argument(help: "The agent to hand off to: claude or codex.") + @Argument(help: "The agent to hand off to. Supported: \(HandoffAgentSupport.supportedAgentsDescription).") var agent: String @OptionGroup var selector: SelectorOptions @@ -59,11 +59,11 @@ struct HandoffToCommand: ParsableCommand { mutating func run() throws { try CLIExecution.run(command: "handoff", output: options.outputMode, colorEnabled: options.colorEnabled) { - let normalizedAgent = agent.lowercased() - guard ["claude", "codex"].contains(normalizedAgent) else { + let rawAgent = agent.lowercased() + guard let normalizedAgent = HandoffAgentSupport.normalize(rawAgent) else { throw ExitError( code: CLIErrorCode.invalidArgument, - message: "handoff to requires an agent of: claude, codex." + message: "handoff to requires an agent of: \(HandoffAgentSupport.supportedAgentsDescription)." ) } let envelope = CommandEnvelope( diff --git a/ProwlCLI/Output/OutputRenderer.swift b/ProwlCLI/Output/OutputRenderer.swift index 31cb6741..e192a1ad 100644 --- a/ProwlCLI/Output/OutputRenderer.swift +++ b/ProwlCLI/Output/OutputRenderer.swift @@ -297,6 +297,7 @@ enum OutputRenderer { case .save: lines.append("Handoff \("saved".green.bold) \("changed:".dim) \(payload.changedFileCount) files") lines.append(" \("artifact:".dim) \(payload.artifactPath)") + lines.append(contentsOf: renderHandoffSession(payload.sessionContext)) lines.append(contentsOf: renderHandoffRepos(payload.repos)) case .toAgent: let to = payload.toAgent ?? "?" @@ -306,6 +307,7 @@ enum OutputRenderer { if let archived = payload.archivedPath { lines.append(" \("archived:".dim) \(archived)") } + lines.append(contentsOf: renderHandoffSession(payload.sessionContext)) if let pane = payload.launchedPane { lines.append(" \("launched:".dim) \(to.green) → \(pane.paneTitle.green) \(pane.paneID.dim)") } else { @@ -327,6 +329,13 @@ enum OutputRenderer { } } + private static func renderHandoffSession(_ session: HandoffSessionPayload?) -> [String] { + guard let session, let excerptPath = session.excerptPath else { return [] } + return [ + " \("session:".dim) \(excerptPath) \(session.confidence.dim)" + ] + } + private static func renderPane(_ payload: PaneCommandPayload) -> String { let wt = payload.target.worktree let pane = payload.target.pane diff --git a/ProwlCLITests/ProwlCLIIntegrationTests.swift b/ProwlCLITests/ProwlCLIIntegrationTests.swift index 471414c8..951a7408 100644 --- a/ProwlCLITests/ProwlCLIIntegrationTests.swift +++ b/ProwlCLITests/ProwlCLIIntegrationTests.swift @@ -1614,8 +1614,33 @@ final class ProwlCLIIntegrationTests: XCTestCase { } } + func testHandoffToAcceptsDetectedAgentToken() throws { + let socketPath = temporarySocketPath(suffix: "handoff-to-gemini") + let response = try CommandResponse( + ok: true, + command: "handoff", + schemaVersion: "prowl.cli.handoff.v1", + data: RawJSON(encoding: makeHandoffPayload(action: .toAgent)) + ) + + let (requestData, result) = try runWithMockServer( + socketPath: socketPath, + response: response, + args: ["handoff", "to", "gemini", "--no-launch", "--json"] + ) + + XCTAssertEqual(result.exitCode, 0) + let envelope = try JSONDecoder().decode(CommandEnvelope.self, from: requestData) + if case .handoff(let input) = envelope.command { + XCTAssertEqual(input.toAgent, "gemini") + XCTAssertFalse(input.launch) + } else { + XCTFail("Expected handoff command envelope") + } + } + func testHandoffToRejectsUnknownAgentBeforeTransport() throws { - let result = try runProwl(args: ["handoff", "to", "gemini", "--json"]) + let result = try runProwl(args: ["handoff", "to", "unknown-agent", "--json"]) XCTAssertNotEqual(result.exitCode, 0) let payload = try jsonObject(from: result.stdout) @@ -1672,6 +1697,14 @@ final class ProwlCLIIntegrationTests: XCTestCase { ], changedFileCount: 3, archivedPath: "handoff/archive/2026-06-12T1430-codex-to-claude.md", + sessionContext: HandoffSessionPayload( + agent: "codex", + paneID: "pane-0", + paneTitle: "codex", + source: "terminal-scrollback", + confidence: "fallback", + excerptPath: "handoff/sessions/2026-06-12T1430-pane-0.md" + ), launchedPane: HandoffPanePayload( worktreeID: "App:/Projects/App", worktreeName: "App", @@ -1691,6 +1724,7 @@ final class ProwlCLIIntegrationTests: XCTestCase { XCTAssertEqual(result.exitCode, 0) XCTAssertTrue(result.stdout.contains("codex → claude"), "Missing transition header: \(result.stdout)") XCTAssertTrue(result.stdout.contains("artifact:"), "Missing artifact line: \(result.stdout)") + XCTAssertTrue(result.stdout.contains("session:"), "Missing session line: \(result.stdout)") XCTAssertTrue(result.stdout.contains("launched:"), "Missing launched line: \(result.stdout)") XCTAssertTrue(result.stdout.contains("pane-9"), "Missing launched pane id: \(result.stdout)") } diff --git a/docs/README.md b/docs/README.md index 8711a839..493451c5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -58,7 +58,7 @@ its keyboard shortcuts, detailed behavior, settings, and gotchas. | [`components/settings.md`](components/settings.md) | The Settings window (`⌘,`): every tab and what it controls. | | [`components/updates.md`](components/updates.md) | Sparkle auto-updates: channels, auto-check, `⌘⇧U`. | | [`components/cli.md`](components/cli.md) | The `prowl` CLI — let an agent inspect and drive panes (`list`, `read`, `send`, `key`, `focus`, `tab`, `pane`, `open`, `handoff`). | -| [`components/handoff.md`](components/handoff.md) | Hand a task off between Codex and Claude Code: the `.prowl/handoff/` artifact, the protocol both agents follow, `prowl handoff`, and the command-palette action. | +| [`components/handoff.md`](components/handoff.md) | Hand a task off between agents: the `.prowl/handoff/` artifact, captured session context, the protocol agents follow, `prowl handoff`, and the command-palette action. | ## Reference (exact lookups) @@ -73,7 +73,7 @@ its keyboard shortcuts, detailed behavior, settings, and gotchas. |------------|------| | Run many agents side by side and see them all | [`components/canvas.md`](components/canvas.md), [`components/shelf.md`](components/shelf.md) | | Give one agent a task that spans several repos | [`components/workspaces.md`](components/workspaces.md) | -| Hand a task off between Codex and Claude Code | [`components/handoff.md`](components/handoff.md) | +| Hand a task off between agents | [`components/handoff.md`](components/handoff.md) | | Send one command to every agent simultaneously | [`components/canvas.md`](components/canvas.md) (broadcast) | | Spin up a new branch/worktree for a new agent | [`components/repositories-and-worktrees.md`](components/repositories-and-worktrees.md) | | Find / run any action by name | [`components/command-palette.md`](components/command-palette.md) | diff --git a/docs/components/cli.md b/docs/components/cli.md index 01074c8a..3cd892bb 100644 --- a/docs/components/cli.md +++ b/docs/components/cli.md @@ -224,23 +224,24 @@ launch the receiving agent. Centred on [workspaces](workspaces.md), but works fo any runnable target. Three subcommands: ```bash -prowl handoff save [target] [--note "…"] # refresh the auto context appendix -prowl handoff to [target] [--note "…"] [--no-launch] -prowl handoff status [target] +prowl handoff save [target] [--note "…"] # refresh appendix + session excerpt +prowl handoff to [target] [--note "…"] [--no-launch] +prowl handoff status [target] ``` - **`save`** — refresh the `## Context Appendix (auto)` block in `.prowl/handoff/current.md` from live git state (per-repo branch + change - counts, changed-file list, detected outgoing agent) and append a line to - `.prowl/handoff/log.md`. It only rewrites the auto block between the - `PROWL AUTOGEN` markers; the agent-authored prose sections are left untouched. - Seeds `current.md` from a template on first run. -- **`to `** — `save`, then archive the current artifact to + counts, changed-file list, detected outgoing agent, and captured session + excerpt) and append a line to `.prowl/handoff/log.md`. It only rewrites the + auto block between the `PROWL AUTOGEN` markers; the agent-authored prose + sections are left untouched. Seeds `current.md` from a template on first run. +- **`to `** — `save`, then archive the current artifact to `.prowl/handoff/archive/--to-.md`, then launch the receiving agent in a **new tab** whose kickoff prompt points it at `.prowl/handoff/current.md`. Returns the launched `pane`. `--no-launch` - archives + saves only (you take over manually). Only `claude` and `codex` are - accepted. + archives + saves only (you take over manually). Accepted tokens match Prowl's + detected-agent list: `pi`, `claude`, `codex`, `gemini`, `cursor-agent`, + `cline`, `opencode`, `copilot`, `kimi`, `droid`, `amp`. - **`status`** — report the artifact path, whether it exists, the agent currently detected in the target, and the last handoff-log line. @@ -252,7 +253,7 @@ prowl handoff save --note "ui done, api next" --json The outgoing agent is whatever Prowl detects in the target's pane (see `pane.agent` in [`list`](#prowl-list)). Response payload includes `action`, `artifact_path`, `outgoing_agent`, `to_agent`, `repos`, `changed_file_count`, -`archived_path`, and `launched_pane`. Full feature guide: +`archived_path`, `session_context`, and `launched_pane`. Full feature guide: [handoff](handoff.md). ## Transport & app launch diff --git a/docs/components/handoff.md b/docs/components/handoff.md index f1ed6fe4..0df3542f 100644 --- a/docs/components/handoff.md +++ b/docs/components/handoff.md @@ -1,8 +1,8 @@ -# Handoff — Codex ↔ Claude Code +# Handoff — Agent To Agent > How to hand a task off between coding agents inside a Prowl workspace: a durable -> artifact both agents read and write, the `prowl handoff` command, and a -> command-palette action. +> artifact agents read and write, an auto-captured session excerpt, the +> `prowl handoff` command, and a command-palette action. **Keywords:** handoff, hand off, codex, claude, switch agent, takeover, `.prowl/handoff`, current.md, prowl handoff, cross-agent, workspace @@ -10,11 +10,11 @@ ## Why -Codex and Claude Code are independent processes; each keeps its own conversation -context. When you switch from one to the other, the first agent's in-memory -context is **not** visible to the second. The only durable channel between them is -the filesystem. Handoff makes that channel a first-class, structured artifact so -the receiving agent (or you) can resume cold. +Coding agents are independent processes; each keeps its own conversation +context. When you switch from one to another, the first agent's in-memory context +is **not** visible to the second. The only durable channel between them is the +filesystem. Handoff makes that channel a first-class, structured artifact so the +receiving agent (or you) can resume cold. Handoff is centred on [workspaces](workspaces.md) — one task, several repos, a shared root — but works for any runnable target. @@ -28,6 +28,7 @@ Everything lives under the target's `.prowl/handoff/` directory: current.md active handoff artifact (the cross-agent contract) log.md append-only handoff history archive/--to-.md + sessions/-.md ``` `current.md` has two parts: @@ -37,9 +38,13 @@ Everything lives under the target's `.prowl/handoff/` directory: outgoing agent maintains these; they carry intent and decisions. - **A Prowl-generated `## Context Appendix (auto)`** between `` / `` markers — the - detected outgoing agent, each repo's branch and change counts, and the changed - files. Prowl regenerates *only* this block on every `save`; it never touches - the prose. + detected outgoing agent, a pointer to the captured session excerpt, each + repo's branch and change counts, and the changed files. Prowl regenerates + *only* this block on every `save`; it never touches the prose. + +`sessions/-.md` is a normalized excerpt from the outgoing pane. Today it +captures the current terminal screen/scrollback and records the detected agent, +pane, source, confidence, and native transcript path when one is available. ## The protocol @@ -51,7 +56,7 @@ reads it): ## Handoff protocol (this is a Prowl workspace) - On start: read `.prowl/handoff/current.md` and `.prowl/workspace.json`. Continue from "Next Steps". - Before you stop or hand off: update `.prowl/handoff/current.md` so another agent can resume cold. -- To hand the task to the other agent, run: `prowl handoff to claude` (or `codex`). +- To hand the task to another agent, run: `prowl handoff to `. - Never commit/push or run destructive git unless asked. Do not put secrets in the handoff file. ``` @@ -61,16 +66,17 @@ step — the receiving agent then opens in a new tab pointed at the artifact. ## The `prowl handoff` command ```bash -prowl handoff save [target] [--note "…"] # refresh the auto appendix + log -prowl handoff to [target] [--note "…"] [--no-launch] -prowl handoff status [target] +prowl handoff save [target] [--note "…"] # refresh appendix + session excerpt + log +prowl handoff to [target] [--note "…"] [--no-launch] +prowl handoff status [target] ``` - **`save`** refreshes the auto appendix from live git state and logs a line. - **`to `** does `save`, archives the current artifact, then launches the receiving agent in a **new tab** whose kickoff prompt points it at - `.prowl/handoff/current.md`. `--no-launch` archives + saves only. Only `claude` - and `codex` are accepted. + `.prowl/handoff/current.md`. `--no-launch` archives + saves only. Accepted + tokens follow Prowl's detected-agent list: `pi`, `claude`, `codex`, `gemini`, + `cursor-agent`, `cline`, `opencode`, `copilot`, `kimi`, `droid`, `amp`. - **`status`** reports the artifact path, whether it exists, the detected current agent, and the last log line. @@ -96,6 +102,9 @@ a workspace. if the launch is interrupted. - Keep secrets/tokens out of the handoff file (the protocol asks agents not to write them). +- Add `.prowl/handoff/` to the workspace's `.gitignore`; session excerpts can + contain terminal context that belongs in local handoff state, not source + control. ## Gotchas diff --git a/docs/components/workspaces.md b/docs/components/workspaces.md index 88d17f9d..bab60677 100644 --- a/docs/components/workspaces.md +++ b/docs/components/workspaces.md @@ -214,8 +214,8 @@ where they are on disk, and what role each repo plays in the task. ## Handing off between agents -A workspace is the natural unit for handing a task from Codex to Claude Code (or -back). Prowl keeps a durable handoff artifact under -`.prowl/handoff/current.md` and ships `prowl handoff to ` plus -command-palette actions to refresh it and launch the receiving agent. See +A workspace is the natural unit for handing a task from one detected agent to +another. Prowl keeps a durable handoff artifact under +`.prowl/handoff/current.md` and ships `prowl handoff to ` plus +command-palette actions for the common Claude Code/Codex path. See [handoff](handoff.md). diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index 77321190..5d93a01b 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -333,6 +333,18 @@ struct SupacodeApp: App { else { return nil } return state.activeSurfaceID(for: tabID) }, + handoffSessionContext: { worktreeID in + guard let state = terminalManager.stateIfExists(for: worktreeID), + let tabID = state.tabManager.selectedTabId, + let surfaceID = state.activeSurfaceID(for: tabID) + else { return nil } + return makeHandoffSessionContext( + worktreeID: worktreeID, + paneID: surfaceID, + paneTitle: nil, + terminalManager: terminalManager + ) + }, latestUnreadNotification: { terminalManager.latestUnreadNotificationLocation() }, @@ -405,6 +417,31 @@ struct SupacodeApp: App { return client } + private static func makeHandoffSessionContext( + worktreeID: Worktree.ID, + paneID: UUID, + paneTitle: String?, + terminalManager: WorktreeTerminalManager + ) -> HandoffStore.SessionContext? { + guard let state = terminalManager.stateIfExists(for: worktreeID), + let surface = state.surfaceView(for: paneID) + else { + return nil + } + + let detectedAgent = state.surfaceAgentStates[paneID]?.detectedAgent?.rawValue + let title = paneTitle ?? state.paneTitle(surfaceID: paneID, fallbackTabTitle: "") + let screenText = surface.readScreenContentsForCLI() ?? surface.readViewportContentsForCLI() + return HandoffStore.SessionContext( + agent: detectedAgent, + paneID: paneID.uuidString, + paneTitle: title.isEmpty ? nil : title, + source: screenText == nil ? "terminal-unavailable" : "terminal-scrollback", + confidence: "fallback", + excerptText: screenText + ) + } + private static func makeTargetResolver( appStore: StoreOf, terminalManager: WorktreeTerminalManager @@ -628,7 +665,13 @@ struct SupacodeApp: App { worktreeName: resolved.worktreeName, rootPath: resolved.worktreeRootPath, paneID: resolved.paneID.uuidString, - outgoingAgent: agent + outgoingAgent: agent, + sessionContext: makeHandoffSessionContext( + worktreeID: resolved.worktreeID, + paneID: resolved.paneID, + paneTitle: resolved.paneTitle, + terminalManager: terminalManager + ) ) } }, diff --git a/supacode/CLIService/HandoffCommandHandler.swift b/supacode/CLIService/HandoffCommandHandler.swift index 537c70f7..89c79300 100644 --- a/supacode/CLIService/HandoffCommandHandler.swift +++ b/supacode/CLIService/HandoffCommandHandler.swift @@ -10,6 +10,7 @@ struct HandoffResolvedTarget: Sendable, Equatable { let rootPath: String let paneID: String let outgoingAgent: String? + let sessionContext: HandoffStore.SessionContext? } /// The pane the receiving agent was launched into. @@ -27,7 +28,7 @@ final class HandoffCommandHandler: CommandHandler { typealias LaunchProvider = @MainActor (HandoffResolvedTarget, String) -> HandoffLaunchedPane? /// Agents this command can launch (it injects an agent-specific kickoff command). - static let supportedAgents = ["claude", "codex"] + static let supportedAgents = HandoffAgentSupport.supportedAgents private let resolveProvider: ResolveProvider private let launchProvider: LaunchProvider @@ -49,10 +50,10 @@ final class HandoffCommandHandler: CommandHandler { } if input.action == .toAgent { - guard let toAgent = input.toAgent, Self.supportedAgents.contains(toAgent) else { + guard let rawAgent = input.toAgent, let toAgent = HandoffAgentSupport.normalize(rawAgent) else { return errorResponse( code: CLIErrorCode.invalidArgument, - message: "handoff to requires an agent of: \(Self.supportedAgents.joined(separator: ", "))." + message: "handoff to requires an agent of: \(HandoffAgentSupport.supportedAgentsDescription)." ) } _ = toAgent @@ -91,7 +92,12 @@ final class HandoffCommandHandler: CommandHandler { let note = input.note do { let result = try await Task.detached { - try store.save(outgoingAgent: outgoing, note: note, now: timestamp) + try store.save( + outgoingAgent: outgoing, + sessionContext: target.sessionContext, + note: note, + now: timestamp + ) }.value return success(payload: makePayload(action: .save, save: result)) } catch { @@ -110,7 +116,7 @@ final class HandoffCommandHandler: CommandHandler { store: HandoffStore, timestamp: Date ) async -> CommandResponse { - guard let toAgent = input.toAgent else { + guard let rawAgent = input.toAgent, let toAgent = HandoffAgentSupport.normalize(rawAgent) else { return errorResponse(code: CLIErrorCode.invalidArgument, message: "handoff to requires an agent.") } let outgoing = target.outgoingAgent @@ -121,7 +127,12 @@ final class HandoffCommandHandler: CommandHandler { do { // Refresh the appendix, then archive the current artifact before launching. saveResult = try await Task.detached { - try store.save(outgoingAgent: outgoing, note: nil, now: timestamp) + try store.save( + outgoingAgent: outgoing, + sessionContext: target.sessionContext, + note: nil, + now: timestamp + ) }.value archivedPath = try await Task.detached { try store.archiveCurrent(from: from, toAgent: toAgent, now: timestamp) @@ -168,6 +179,16 @@ final class HandoffCommandHandler: CommandHandler { action: .status, artifactPath: status.artifactPath, outgoingAgent: target.outgoingAgent, + sessionContext: target.sessionContext.map { + HandoffSessionPayload( + agent: $0.agent, + paneID: $0.paneID, + paneTitle: $0.paneTitle, + source: $0.source, + confidence: $0.confidence, + transcriptPath: $0.transcriptPath + ) + }, exists: status.exists, lastLog: status.lastLogLine ) @@ -180,6 +201,7 @@ final class HandoffCommandHandler: CommandHandler { let instruction = "Take over this Prowl workspace task. Read .prowl/handoff/current.md (the full handoff) " + "and .prowl/workspace.json (repo layout, if present), then continue from Next Steps. " + + "If current.md lists a Session Context excerpt, read that file before changing code. " + "Ask before any commit/push or destructive git." return "\(agent) \"\(instruction)\"" } @@ -210,6 +232,7 @@ final class HandoffCommandHandler: CommandHandler { }, changedFileCount: save.totalChangedFiles, archivedPath: archivedPath, + sessionContext: save.sessionContext, launchedPane: launched.map { HandoffPanePayload( worktreeID: $0.worktreeID, diff --git a/supacode/CLIService/Shared/HandoffAgentSupport.swift b/supacode/CLIService/Shared/HandoffAgentSupport.swift new file mode 100644 index 00000000..c525a472 --- /dev/null +++ b/supacode/CLIService/Shared/HandoffAgentSupport.swift @@ -0,0 +1,27 @@ +import Foundation + +public enum HandoffAgentSupport { + public static let supportedAgents = [ + "pi", + "claude", + "codex", + "gemini", + "cursor-agent", + "cline", + "opencode", + "copilot", + "kimi", + "droid", + "amp", + ] + + public static var supportedAgentsDescription: String { + supportedAgents.joined(separator: ", ") + } + + public static func normalize(_ agent: String) -> String? { + let normalized = agent.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard supportedAgents.contains(normalized) else { return nil } + return normalized + } +} diff --git a/supacode/CLIService/Shared/HandoffCommandPayload.swift b/supacode/CLIService/Shared/HandoffCommandPayload.swift index 9920e404..f83917c9 100644 --- a/supacode/CLIService/Shared/HandoffCommandPayload.swift +++ b/supacode/CLIService/Shared/HandoffCommandPayload.swift @@ -11,6 +11,8 @@ public struct HandoffCommandPayload: Codable, Sendable, Equatable { public let changedFileCount: Int /// Archived copy of the previous artifact, relative to the handoff dir (for `to`). public let archivedPath: String? + /// Auto-captured outgoing session context saved alongside `current.md`. + public let sessionContext: HandoffSessionPayload? /// The pane the receiving agent was launched into (for `to` with launch). public let launchedPane: HandoffPanePayload? /// Whether `current.md` exists (for `status`). @@ -26,6 +28,7 @@ public struct HandoffCommandPayload: Codable, Sendable, Equatable { case repos case changedFileCount = "changed_file_count" case archivedPath = "archived_path" + case sessionContext = "session_context" case launchedPane = "launched_pane" case exists case lastLog = "last_log" @@ -39,6 +42,7 @@ public struct HandoffCommandPayload: Codable, Sendable, Equatable { repos: [HandoffRepoPayload] = [], changedFileCount: Int = 0, archivedPath: String? = nil, + sessionContext: HandoffSessionPayload? = nil, launchedPane: HandoffPanePayload? = nil, exists: Bool? = nil, lastLog: String? = nil @@ -50,12 +54,51 @@ public struct HandoffCommandPayload: Codable, Sendable, Equatable { self.repos = repos self.changedFileCount = changedFileCount self.archivedPath = archivedPath + self.sessionContext = sessionContext self.launchedPane = launchedPane self.exists = exists self.lastLog = lastLog } } +public struct HandoffSessionPayload: Codable, Sendable, Equatable { + public let agent: String? + public let paneID: String + public let paneTitle: String? + public let source: String + public let confidence: String + public let excerptPath: String? + public let transcriptPath: String? + + enum CodingKeys: String, CodingKey { + case agent + case paneID = "pane_id" + case paneTitle = "pane_title" + case source + case confidence + case excerptPath = "excerpt_path" + case transcriptPath = "transcript_path" + } + + public nonisolated init( + agent: String?, + paneID: String, + paneTitle: String?, + source: String, + confidence: String, + excerptPath: String? = nil, + transcriptPath: String? = nil + ) { + self.agent = agent + self.paneID = paneID + self.paneTitle = paneTitle + self.source = source + self.confidence = confidence + self.excerptPath = excerptPath + self.transcriptPath = transcriptPath + } +} + public struct HandoffRepoPayload: Codable, Sendable, Equatable { public let name: String public let branch: String? diff --git a/supacode/Clients/Terminal/TerminalClient.swift b/supacode/Clients/Terminal/TerminalClient.swift index ba895d7a..4859ec17 100644 --- a/supacode/Clients/Terminal/TerminalClient.swift +++ b/supacode/Clients/Terminal/TerminalClient.swift @@ -9,6 +9,7 @@ struct TerminalClient { /// synchronously before an async dispatch races against AppKit focus reshuffle /// (e.g. when a palette dismisses and the leftmost pane reclaims first responder). var selectedSurfaceID: @MainActor @Sendable (Worktree.ID) -> UUID? + var handoffSessionContext: @MainActor @Sendable (Worktree.ID) -> HandoffStore.SessionContext? var latestUnreadNotification: @MainActor @Sendable () -> NotificationLocation? var focusSurface: @MainActor @Sendable (Worktree.ID, UUID) -> Bool var markNotificationRead: @MainActor @Sendable (Worktree.ID, UUID) -> Void @@ -83,6 +84,7 @@ extension TerminalClient: DependencyKey { events: { fatalError("TerminalClient.events not configured") }, canvasFocusedWorktreeID: { nil }, selectedSurfaceID: { _ in nil }, + handoffSessionContext: { _ in nil }, latestUnreadNotification: { nil }, focusSurface: { _, _ in false }, markNotificationRead: { _, _ in }, @@ -94,6 +96,7 @@ extension TerminalClient: DependencyKey { events: { AsyncStream { $0.finish() } }, canvasFocusedWorktreeID: { nil }, selectedSurfaceID: { _ in nil }, + handoffSessionContext: { _ in nil }, latestUnreadNotification: { nil }, focusSurface: { _, _ in false }, markNotificationRead: { _, _ in }, diff --git a/supacode/Domain/Handoff/HandoffStore.swift b/supacode/Domain/Handoff/HandoffStore.swift index f26672b7..387c24b3 100644 --- a/supacode/Domain/Handoff/HandoffStore.swift +++ b/supacode/Domain/Handoff/HandoffStore.swift @@ -9,6 +9,7 @@ import Foundation /// current.md active handoff artifact (the cross-agent contract) /// log.md append-only handoff history /// archive/--to-.md +/// sessions/-.md /// ``` /// /// The artifact has two parts: agent-maintained semantic sections (Objective, @@ -37,6 +38,7 @@ nonisolated struct HandoffStore: Sendable { var currentURL: URL { handoffDirectory.appending(path: "current.md") } var logURL: URL { handoffDirectory.appending(path: "log.md") } var archiveDirectory: URL { handoffDirectory.appending(path: "archive", directoryHint: .isDirectory) } + var sessionDirectory: URL { handoffDirectory.appending(path: "sessions", directoryHint: .isDirectory) } // MARK: - Markers @@ -87,14 +89,43 @@ nonisolated struct HandoffStore: Sendable { let deletions: Int } - struct SaveResult: Sendable, Equatable { + struct SaveResult: Sendable { let artifactPath: String let outgoingAgent: String? + let sessionContext: HandoffSessionPayload? let repos: [RepoSummary] let changedFiles: [String] var totalChangedFiles: Int { repos.reduce(0) { $0 + $1.changedFileCount } } } + struct SessionContext: Sendable, Equatable { + let agent: String? + let paneID: String + let paneTitle: String? + let source: String + let confidence: String + let transcriptPath: String? + let excerptText: String? + + init( + agent: String?, + paneID: String, + paneTitle: String?, + source: String, + confidence: String, + transcriptPath: String? = nil, + excerptText: String? + ) { + self.agent = agent + self.paneID = paneID + self.paneTitle = paneTitle + self.source = source + self.confidence = confidence + self.transcriptPath = transcriptPath + self.excerptText = excerptText + } + } + struct StatusResult: Sendable, Equatable { let artifactPath: String let exists: Bool @@ -109,6 +140,7 @@ nonisolated struct HandoffStore: Sendable { func ensureScaffold() throws { let fileManager = FileManager.default try fileManager.createDirectory(at: archiveDirectory, withIntermediateDirectories: true) + try fileManager.createDirectory(at: sessionDirectory, withIntermediateDirectories: true) if !fileManager.fileExists(atPath: currentURL.path(percentEncoded: false)) { try Self.template.write(to: currentURL, atomically: true, encoding: .utf8) } @@ -119,13 +151,20 @@ nonisolated struct HandoffStore: Sendable { /// Refresh the AUTOGEN appendix in `current.md` from live git state and append /// a `save` line to the log. Leaves the agent-authored prose untouched. @discardableResult - func save(outgoingAgent: String?, note: String?, now: Date) throws -> SaveResult { + func save( + outgoingAgent: String?, + sessionContext: SessionContext? = nil, + note: String?, + now: Date + ) throws -> SaveResult { try ensureScaffold() let repos = repoSummaries() let changedFiles = changedFilePaths(for: repos) + let savedSessionContext = try writeSessionContext(sessionContext, now: now) let appendix = buildAppendix( outgoingAgent: outgoingAgent, + sessionContext: savedSessionContext, repos: repos, changedFiles: changedFiles, now: now @@ -142,6 +181,7 @@ nonisolated struct HandoffStore: Sendable { return SaveResult( artifactPath: currentURL.path(percentEncoded: false), outgoingAgent: outgoingAgent, + sessionContext: savedSessionContext, repos: repos, changedFiles: changedFiles ) @@ -261,10 +301,72 @@ nonisolated struct HandoffStore: Sendable { return files } + // MARK: - Session context + + private func writeSessionContext(_ context: SessionContext?, now: Date) throws -> HandoffSessionPayload? { + guard let context else { return nil } + + let fileName = "\(Self.fileStamp(now))-\(Self.slug(context.paneID)).md" + let destination = sessionDirectory.appending(path: fileName) + let relativePath = "handoff/sessions/\(fileName)" + let payload = HandoffSessionPayload( + agent: context.agent, + paneID: context.paneID, + paneTitle: context.paneTitle, + source: context.source, + confidence: context.confidence, + excerptPath: relativePath, + transcriptPath: context.transcriptPath + ) + + let markdown = Self.renderSessionContext(context, payload: payload, now: now) + try markdown.write(to: destination, atomically: true, encoding: .utf8) + return payload + } + + private static func renderSessionContext( + _ context: SessionContext, + payload: HandoffSessionPayload, + now: Date + ) -> String { + let text = trimmedSessionExcerpt(context.excerptText) + var lines: [String] = [] + lines.append("# Handoff Session Context") + lines.append("") + lines.append("- Captured: \(iso(now))") + lines.append("- Agent: \(payload.agent ?? "unknown")") + lines.append("- Pane: \(payload.paneID)\(payload.paneTitle.map { " (\($0))" } ?? "")") + lines.append("- Source: \(payload.source)") + lines.append("- Confidence: \(payload.confidence)") + lines.append("- Native transcript: \(payload.transcriptPath ?? "unknown")") + lines.append("") + lines.append("## Terminal Excerpt") + lines.append("") + if text.isEmpty { + lines.append("_No terminal text was captured._") + } else { + lines.append("````text") + lines.append(text) + lines.append("````") + } + lines.append("") + return lines.joined(separator: "\n") + } + + private static func trimmedSessionExcerpt(_ text: String?) -> String { + guard let text else { return "" } + let lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + let lineLimited = lines.suffix(240).joined(separator: "\n") + guard lineLimited.count > 20_000 else { return lineLimited } + let suffix = String(lineLimited.suffix(20_000)) + return "[... earlier terminal content truncated ...]\n" + suffix + } + // MARK: - Appendix builder (pure) func buildAppendix( outgoingAgent: String?, + sessionContext: HandoffSessionPayload?, repos: [RepoSummary], changedFiles: [String], now: Date @@ -275,6 +377,17 @@ nonisolated struct HandoffStore: Sendable { lines.append("- Generated: \(Self.iso(now))") lines.append("- Outgoing agent (detected): \(outgoingAgent ?? "unknown")") lines.append("- Workspace: \(workspaceTitle ?? "(none)") (\(rootURL.path(percentEncoded: false)))") + lines.append("- Session Context:") + if let sessionContext { + lines.append(" - Agent: \(sessionContext.agent ?? "unknown")") + lines.append(" - Source: \(sessionContext.source)") + lines.append(" - Confidence: \(sessionContext.confidence)") + lines.append(" - Pane: \(sessionContext.paneID)") + lines.append(" - Context excerpt: .prowl/\(sessionContext.excerptPath ?? "handoff/sessions/unknown.md")") + lines.append(" - Native transcript: \(sessionContext.transcriptPath ?? "unknown")") + } else { + lines.append(" - (not captured)") + } lines.append("- Repos & branches:") if repos.isEmpty { lines.append(" - (none)") diff --git a/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift b/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift index f4df05ab..a40bfe3a 100644 --- a/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift +++ b/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift @@ -293,10 +293,11 @@ extension AppFeature { .first { $0.worktreeID == worktree.id }?.agent.rawValue let kickoff = HandoffCommandHandler.kickoff(for: agent) let rootURL = worktree.workingDirectory + let sessionContext = terminalClient.handoffSessionContext(worktree.id) return .run { _ in let store = HandoffStore(rootURL: rootURL) let now = Date() - _ = try? store.save(outgoingAgent: outgoing, note: nil, now: now) + _ = try? store.save(outgoingAgent: outgoing, sessionContext: sessionContext, note: nil, now: now) _ = try? store.archiveCurrent(from: outgoing ?? "agent", toAgent: agent, now: now) try? store.appendLog("\(outgoing ?? "agent") → \(agent) (command palette)", now: now) await terminalClient.send( diff --git a/supacodeTests/HandoffCommandHandlerTests.swift b/supacodeTests/HandoffCommandHandlerTests.swift index be6b14e4..5f9f124a 100644 --- a/supacodeTests/HandoffCommandHandlerTests.swift +++ b/supacodeTests/HandoffCommandHandlerTests.swift @@ -22,6 +22,14 @@ struct HandoffCommandHandlerTests { private func makeHandler( root: URL, outgoingAgent: String?, + sessionContext: HandoffStore.SessionContext? = HandoffStore.SessionContext( + agent: "codex", + paneID: "pane-0", + paneTitle: "codex", + source: "terminal-scrollback", + confidence: "fallback", + excerptText: "working on handoff" + ), launched: HandoffLaunchedPane? = HandoffLaunchedPane( worktreeID: "ws", worktreeName: "Workspace", tabID: "tab-1", paneID: "pane-1", paneTitle: "claude" ), @@ -35,7 +43,8 @@ struct HandoffCommandHandlerTests { worktreeName: "Workspace", rootPath: root.path(percentEncoded: false), paneID: "pane-0", - outgoingAgent: outgoingAgent + outgoingAgent: outgoingAgent, + sessionContext: sessionContext ) ) }, @@ -63,9 +72,14 @@ struct HandoffCommandHandlerTests { let payload = try #require(try response.data?.decode(as: HandoffCommandPayload.self)) #expect(payload.action == .save) #expect(payload.outgoingAgent == "codex") + let session = try #require(payload.sessionContext) + #expect(session.excerptPath?.hasPrefix("handoff/sessions/") == true) let store = HandoffStore(rootURL: root) #expect(FileManager.default.fileExists(atPath: store.currentURL.path(percentEncoded: false))) + let content = try String(contentsOf: store.currentURL, encoding: .utf8) + #expect(content.contains("Session Context:")) + #expect(content.contains(".prowl/handoff/sessions/")) } @Test func toRefreshesArchivesAndLaunches() async throws { @@ -93,6 +107,7 @@ struct HandoffCommandHandlerTests { // The launched agent's kickoff command targets the handoff artifact. #expect(launchedKickoff?.hasPrefix("claude ") == true) #expect(launchedKickoff?.contains(".prowl/handoff/current.md") == true) + #expect(launchedKickoff?.contains("Session Context excerpt") == true) // Log records the transition. let store = HandoffStore(rootURL: root) @@ -122,19 +137,37 @@ struct HandoffCommandHandlerTests { #expect(launchCalled == false) } - @Test func toRejectsUnsupportedAgent() async throws { + @Test func toAcceptsDetectedAgentToken() async throws { + let root = try makeTempRoot() + defer { remove(root) } + let handler = makeHandler(root: root, outgoingAgent: "codex") + + let response = await handler.handle( + envelope: envelope(HandoffInput(action: .toAgent, toAgent: "gemini", launch: false)) + ) + + #expect(response.ok) + let payload = try #require(try response.data?.decode(as: HandoffCommandPayload.self)) + #expect(payload.toAgent == "gemini") + } + + @Test func toRejectsUnknownAgent() async throws { let root = try makeTempRoot() defer { remove(root) } let handler = makeHandler(root: root, outgoingAgent: "codex") let response = await handler.handle( - envelope: envelope(HandoffInput(action: .toAgent, toAgent: "gemini")) + envelope: envelope(HandoffInput(action: .toAgent, toAgent: "unknown-agent")) ) #expect(response.ok == false) #expect(response.error?.code == CLIErrorCode.invalidArgument) } + @Test func supportedAgentsMatchDetectedAgents() { + #expect(HandoffAgentSupport.supportedAgents == DetectedAgent.allCases.map(\.rawValue)) + } + @Test func toReportsFailureWhenLaunchReturnsNil() async throws { let root = try makeTempRoot() defer { remove(root) } diff --git a/supacodeTests/HandoffStoreTests.swift b/supacodeTests/HandoffStoreTests.swift index adb662f7..c988f7ca 100644 --- a/supacodeTests/HandoffStoreTests.swift +++ b/supacodeTests/HandoffStoreTests.swift @@ -107,6 +107,39 @@ struct HandoffStoreTests { #expect(result.totalChangedFiles == 0) } + @Test func saveWritesSessionContextExcerpt() throws { + let root = try makeTempRoot() + defer { remove(root) } + let store = HandoffStore(rootURL: root) + let sessionContext = HandoffStore.SessionContext( + agent: "codex", + paneID: "pane-123", + paneTitle: "codex", + source: "terminal-scrollback", + confidence: "fallback", + excerptText: "Implemented handoff session context.\nNext: run tests." + ) + + let result = try store.save( + outgoingAgent: "codex", + sessionContext: sessionContext, + note: nil, + now: fixedDate + ) + + let session = try #require(result.sessionContext) + #expect(session.excerptPath?.hasPrefix("handoff/sessions/") == true) + let excerptPath = try #require(session.excerptPath?.replacing("handoff/", with: "")) + let excerptURL = store.handoffDirectory.appending(path: excerptPath) + let excerpt = try String(contentsOf: excerptURL, encoding: .utf8) + #expect(excerpt.contains("# Handoff Session Context")) + #expect(excerpt.contains("Implemented handoff session context.")) + + let current = try String(contentsOf: store.currentURL, encoding: .utf8) + #expect(current.contains("Session Context:")) + #expect(current.contains(".prowl/handoff/sessions/")) + } + @Test func saveTwiceDoesNotDuplicateAutogenBlock() throws { let root = try makeTempRoot() defer { remove(root) }