From 2455649b391b57d6d39e51eeb5ad6742c0ad4b10 Mon Sep 17 00:00:00 2001 From: Arach Tchoupani Date: Tue, 2 Jun 2026 15:05:17 -0400 Subject: [PATCH 1/6] feat: in-app session initiation across modalities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a single flexible POST /api/sessions endpoint plus Scout app affordances to start new conversations from inside the app, instead of relying on the orphaned HUD runner (which targeted the removed /api/runner/* routes). Backend (packages/web): - POST /api/sessions: one payload expresses every modality by which fields are set — new conversation in a project, "same agent" fresh, continue an agent's session with full context, or seed-from-message. When target.agentId is set it resolves the agent's harness/model/ projectRoot/name/harnessSessionId as defaults. Wraps askScoutQuestion; options reuse the existing /api/agent-config/snapshot. - askScoutQuestion: thread executionTargetSessionId into the broker execution block so session:"existing" can resume a real harness session with full context (verified the /deliver path honors it). - seed.branchFrom is accepted and echoed but inert — a forward-compatible hook for upcoming context-forking work. Scout app (apps/macos): - New ScoutSessionService.swift: SessionInitiationSpec/Result service (via ScoutWeb.baseURL) + ScoutSessionDraft + ScoutSessionComposer modal. - Three entry points in ScoutRootView: "+" in the conversation-list header (new in project), a message context menu ("New conversation from this message…", prefilled + editable, plus copy actions), and ScoutAgentInspector "New session" / "Continue" buttons (fresh vs. full-context resume; Continue shown only when a session is resolvable). Also carries in-progress UI refinements (message-list scroll sentinel / auto-scroll, markdown table, send/mic buttons, scroll styling, composer padding) that were already in the working tree. --- apps/macos/Sources/Scout/ScoutFileLink.swift | 225 ++++++++++ apps/macos/Sources/Scout/ScoutRootView.swift | 416 +++++++++++++++--- .../Sources/Scout/ScoutScrollStyle.swift | 97 ++-- .../Sources/Scout/ScoutSessionService.swift | 373 ++++++++++++++++ .../client/screens/conversation-screen.css | 2 +- packages/web/server/core/broker/service.ts | 4 + .../web/server/create-openscout-web-server.ts | 159 +++++++ 7 files changed, 1172 insertions(+), 104 deletions(-) create mode 100644 apps/macos/Sources/Scout/ScoutFileLink.swift create mode 100644 apps/macos/Sources/Scout/ScoutSessionService.swift diff --git a/apps/macos/Sources/Scout/ScoutFileLink.swift b/apps/macos/Sources/Scout/ScoutFileLink.swift new file mode 100644 index 00000000..13a6fba6 --- /dev/null +++ b/apps/macos/Sources/Scout/ScoutFileLink.swift @@ -0,0 +1,225 @@ +import AppKit +import Foundation +import Quartz +import SwiftUI + +// MARK: - Link encoding + +/// Custom URL scheme used to carry a file path (and optional line) through a +/// SwiftUI `Text` link so it can be intercepted by `OpenURLAction`. +enum ScoutFileLink { + static let scheme = "openscout-file" + + static func url(path: String, line: Int?) -> URL? { + var components = URLComponents() + components.scheme = scheme + components.host = "open" + var items = [URLQueryItem(name: "path", value: path)] + if let line { items.append(URLQueryItem(name: "line", value: String(line))) } + components.queryItems = items + return components.url + } + + static func parse(_ url: URL) -> (path: String, line: Int?)? { + guard url.scheme == scheme, + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return nil } + let items = components.queryItems ?? [] + guard let path = items.first(where: { $0.name == "path" })?.value, !path.isEmpty else { return nil } + let line = items.first(where: { $0.name == "line" })?.value.flatMap(Int.init) + return (path, line) + } +} + +// MARK: - Detection + +/// Finds file-path-like tokens in plain text. Matches absolute (`/…`), home +/// (`~/…`), and relative paths that carry a file extension, with an optional +/// `:line` / `:line:col` suffix. +enum ScoutFilePathDetector { + struct Match { + /// Range (over the source string) of the whole token, including any `:line` suffix. + let nsRange: NSRange + let path: String + let line: Int? + } + + private static let regex: NSRegularExpression = { + // group 1: path (requires at least one `/`); group 2: line; trailing col ignored. + let pattern = #"(~?/?(?:[A-Za-z0-9._+\-]+/)+[A-Za-z0-9._+\-]+)(?::(\d+))?(?::\d+)?"# + return try! NSRegularExpression(pattern: pattern) + }() + + static func matches(in text: String) -> [Match] { + guard !text.isEmpty else { return [] } + let ns = text as NSString + let full = NSRange(location: 0, length: ns.length) + var out: [Match] = [] + regex.enumerateMatches(in: text, range: full) { result, _, _ in + guard let result else { return } + let pathRange = result.range(at: 1) + guard pathRange.location != NSNotFound else { return } + + var whole = result.range + var path = ns.substring(with: pathRange) + // A path never legitimately ends in a dot — trim sentence punctuation. + while path.hasSuffix(".") { + path.removeLast() + whole.length -= 1 + } + guard !path.isEmpty, isLikelyPath(path) else { return } + + // Skip URLs (the `//host/path` portion of e.g. `https://…`). + let lookbackStart = max(0, whole.location - 3) + let lookback = ns.substring(with: NSRange(location: lookbackStart, length: whole.location - lookbackStart)) + if lookback.hasSuffix("://") || lookback.hasSuffix(":/") { return } + + var line: Int? + let lineRange = result.range(at: 2) + if lineRange.location != NSNotFound { + line = Int(ns.substring(with: lineRange)) + } + out.append(Match(nsRange: whole, path: path, line: line)) + } + return out + } + + private static func isLikelyPath(_ token: String) -> Bool { + if token.hasPrefix("/") || token.hasPrefix("~/") { return true } + let last = token.split(separator: "/").last.map(String.init) ?? token + return last.contains(".") // relative path must carry an extension + } +} + +// MARK: - AttributedString linkifying + +enum ScoutFileLinkifier { + /// Applies tappable `openscout-file://` links over any file paths found in + /// the already-parsed attributed text. + static func apply(to attributed: AttributedString, accent: Color) -> AttributedString { + var result = attributed + let plain = String(result.characters) + let matches = ScoutFilePathDetector.matches(in: plain) + guard !matches.isEmpty else { return result } + + for match in matches { + guard let range = Range(match.nsRange, in: plain), + let url = ScoutFileLink.url(path: match.path, line: match.line) else { continue } + let startOffset = plain.distance(from: plain.startIndex, to: range.lowerBound) + let length = plain.distance(from: range.lowerBound, to: range.upperBound) + let start = result.index(result.startIndex, offsetByCharacters: startOffset) + let end = result.index(start, offsetByCharacters: length) + result[start.. URL? { + if let custom = UserDefaults.standard.string(forKey: editorDefaultsKey), + FileManager.default.fileExists(atPath: custom) { + return URL(fileURLWithPath: custom) + } + let candidates = [ + "/Applications/Cursor.app", + "\(NSHomeDirectory())/Applications/Cursor.app", + "/Applications/Visual Studio Code.app", + "/Applications/VSCodium.app", + "/Applications/Zed.app", + "/Applications/Sublime Text.app", + ] + return candidates.first { FileManager.default.fileExists(atPath: $0) }.map { URL(fileURLWithPath: $0) } + } + + private static func cliBinary(forApp app: URL) -> URL? { + let name = app.deletingPathExtension().lastPathComponent + let binNames: [String] + switch name { + case "Cursor": binNames = ["cursor", "code"] + case "Visual Studio Code": binNames = ["code"] + case "VSCodium": binNames = ["codium", "code"] + default: return nil + } + let binDir = app.appendingPathComponent("Contents/Resources/app/bin") + return binNames + .map { binDir.appendingPathComponent($0) } + .first { FileManager.default.isExecutableFile(atPath: $0.path) } + } +} diff --git a/apps/macos/Sources/Scout/ScoutRootView.swift b/apps/macos/Sources/Scout/ScoutRootView.swift index ed423314..bbe7782e 100644 --- a/apps/macos/Sources/Scout/ScoutRootView.swift +++ b/apps/macos/Sources/Scout/ScoutRootView.swift @@ -17,6 +17,21 @@ struct ScoutRootView: View { @State private var agentContentMode: ScoutAgentContentMode = .roster @State private var channelFilter: ScoutChannelFilter = .all @State private var draft = "" + /// Per-conversation unsent drafts, so a message isn't lost when navigating + /// to another chat/section. Keyed by cId; the active draft mirrors into + /// `draft` and is swapped on selection change. + @State private var drafts: [String: String] = [:] + /// Set when the user hits send while dictating: we commit the recording and + /// fire the send once the final transcript has been spliced in. + @State private var pendingSendAfterDictation = false + /// Whether the transcript is pinned to the latest message. True while the + /// bottom is in view; flips false when the user scrolls up into history so + /// incoming messages don't yank them out of the zone they're reading. + @State private var followLatest = true + /// Pending one-shot jump to the newest message when a conversation opens. + @State private var pendingInitialJump = true + + private static let messageListBottomAnchor = "scout.messageList.bottom" @State private var suggestions: [MessageSuggestion] = [] @State private var selectedSuggestionIndex = 0 @State private var currentSuggestionTrigger: MessageSuggestionTrigger? @@ -29,6 +44,9 @@ struct ScoutRootView: View { @State private var observeRestoresInspectorCollapsed = false @State private var agentPreviewPanelAgent: ScoutAgent? @State private var agentPreviewRestoresInspectorCollapsed = false + /// Non-nil while the new-session composer is presented. Configured by each + /// entry point (list "+", message context menu, agent inspector). + @State private var sessionDraft: ScoutSessionDraft? @FocusState private var composerFocused: Bool @AppStorage("scout.navigationSidebar.labelWidth") private var navigationSidebarLabelWidth = 142.0 @AppStorage("scout.conversationList.width") private var conversationListWidth = 286.0 @@ -108,6 +126,79 @@ struct ScoutRootView: View { store.stop() tail.stop() } + .onChange(of: store.selectedCId) { oldCId, newCId in + // Preserve the in-progress draft for the chat we're leaving and + // restore any draft saved for the one we're entering. + if let oldCId { drafts[oldCId] = draft } + draft = newCId.flatMap { drafts[$0] } ?? "" + } + .overlay { + if let sessionDraft { + ScoutSessionComposer(draft: sessionDraft) { + self.sessionDraft = nil + } onComplete: { result in + handleSessionStarted(result) + } + .transition(.opacity) + } + } + } + + private func startNewConversation() { + sessionDraft = ScoutSessionDraft( + title: "New conversation", + target: .project, + projectPath: defaultProjectPath, + mode: .fresh, + instructions: "", + fromMessageId: nil, + fromConversationId: nil + ) + } + + private func startConversationFromMessage(_ message: ScoutMessage, agent: ScoutAgent?) { + let target: ScoutSessionDraft.Target = agent.map { .agent($0) } ?? .project + sessionDraft = ScoutSessionDraft( + title: "New conversation from message", + target: target, + projectPath: agent?.projectRoot?.nilIfEmpty ?? defaultProjectPath, + mode: .fresh, + instructions: message.body, + fromMessageId: message.id, + fromConversationId: message.cId + ) + } + + private func startSessionWithAgent(_ agent: ScoutAgent, mode: ScoutSessionDraft.Mode) { + sessionDraft = ScoutSessionDraft( + title: mode == .continueContext ? "Continue session" : "New session", + target: .agent(agent), + projectPath: agent.projectRoot?.nilIfEmpty ?? "", + mode: mode, + instructions: "", + fromMessageId: nil, + fromConversationId: nil + ) + } + + private func handleSessionStarted(_ result: SessionInitiationResult) { + sessionDraft = nil + section = .comms + store.refresh(force: true) + if let cId = result.conversationId?.nilIfEmpty { + store.selectChannel(cId) + } + if let agentId = result.agentId?.nilIfEmpty { + store.selectAgent(agentId) + } + } + + /// Best-guess project root for a brand-new conversation: the selected + /// agent's root, else any roster agent that exposes one. + private var defaultProjectPath: String { + store.selectedAgent?.projectRoot?.nilIfEmpty + ?? store.agents.compactMap { $0.projectRoot?.nilIfEmpty }.first + ?? "" } private var sidebarEntries: [HudSidebarEntry] { @@ -170,7 +261,8 @@ struct ScoutRootView: View { channels: commsListChannels, totalCount: store.channels.count, selectedCId: store.selectedCId, - width: CGFloat(conversationListWidth) + width: CGFloat(conversationListWidth), + onNewConversation: { startNewConversation() } ) { channel in store.selectChannel(channel.cId) } @@ -264,21 +356,54 @@ struct ScoutRootView: View { ScoutMessageRow( message: message, agent: agent(for: message), - previewAgent: previewAgent + previewAgent: previewAgent, + onNewFromMessage: { + startConversationFromMessage(message, agent: agent(for: message)) + } ) .id(message.id) } + + // Bottom sentinel: visible only when scrolled to the + // latest message, so we can tell whether to keep + // following or leave the reader in their zone. + Color.clear + .frame(height: 1) + .id(Self.messageListBottomAnchor) + .onAppear { followLatest = true } + .onDisappear { followLatest = false } } } - .padding(HudSpacing.huge) + .padding(EdgeInsets( + top: HudSpacing.huge, + leading: HudSpacing.huge, + bottom: HudSpacing.huge, + trailing: HudSpacing.md + )) .frame(maxWidth: .infinity, alignment: .topLeading) .scoutOverlayScrollers() } .scrollIndicators(.visible) - .onChange(of: store.messages.count) { _, _ in - if let last = store.messages.last { + .onAppear { + if !store.messages.isEmpty { + proxy.scrollTo(Self.messageListBottomAnchor, anchor: .bottom) + pendingInitialJump = false + } + } + .onChange(of: store.selectedCId) { _, _ in + // Opening a conversation lands on the newest message unless the + // user deliberately scrolls up afterwards. + pendingInitialJump = true + followLatest = true + } + .onChange(of: store.messages.last?.id) { _, _ in + guard !store.messages.isEmpty else { return } + if pendingInitialJump { + pendingInitialJump = false + proxy.scrollTo(Self.messageListBottomAnchor, anchor: .bottom) + } else if followLatest { withAnimation(.easeOut(duration: 0.16)) { - proxy.scrollTo(last.id, anchor: .bottom) + proxy.scrollTo(Self.messageListBottomAnchor, anchor: .bottom) } } } @@ -290,20 +415,47 @@ struct ScoutRootView: View { HStack(alignment: .top, spacing: HudSpacing.md) { composerInputWell + if isDictating { + ScoutWaveform(tint: isDictationProcessing ? HudPalette.muted : HudPalette.accent) + .frame(width: 26, height: 18) + .padding(.top, HudSpacing.xs + 8) + .transition(.opacity) + } + + ScoutMicButton(box: 34, glyph: 15, action: toggleDictation) + .padding(.top, HudSpacing.xs) + ScoutSendButton( - isEnabled: composerCanSend, + isEnabled: composerReady, isSending: store.isSending, - action: sendDraft + action: requestSend ) .padding(.top, HudSpacing.xs) } + .animation(.easeOut(duration: 0.16), value: isDictating) + .onChange(of: vox.state) { _, newState in + guard pendingSendAfterDictation else { return } + switch newState { + case .idle: + // Final transcript has already been spliced (it lands on + // $lastFinalText before state flips to idle), so send now. + pendingSendAfterDictation = false + sendDraft() + case .unavailable: + pendingSendAfterDictation = false + default: + break + } + } if let status = composerStatusText { Text(status) .font(HudFont.mono(9)) .foregroundStyle(HudPalette.dim) - .frame(maxWidth: .infinity, alignment: .trailing) - .padding(.horizontal, HudSpacing.xs) + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, HudSpacing.xs) } } .padding(.horizontal, HudSpacing.xxl) @@ -357,22 +509,25 @@ struct ScoutRootView: View { private var composerInputWell: some View { HStack(alignment: .top, spacing: HudSpacing.md) { - ScoutMicButton(box: 28, glyph: 14, action: toggleDictation) - .padding(.top, 1) - ZStack(alignment: .topLeading) { TextField(showDictationPreview ? "" : composerPlaceholder, text: $draft, axis: .vertical) .textFieldStyle(.plain) .font(HudFont.mono(11)) .foregroundStyle(HudPalette.ink) + // Accent caret to match the HUD (not the system blue), and + // hidden while dictating so the waveform is the only cue. + .tint(showDictationPreview ? Color.clear : HudPalette.accent) .lineLimit(1...5) .focused($composerFocused) .disabled(store.selectedCId == nil || store.isSending) .onKeyPress(phases: .down) { press in if press.key == .return { if applySelectedSuggestion() { return .handled } - if press.modifiers.contains(.shift) { return .ignored } - sendDraft() + if press.modifiers.contains(.shift) { + draft.append("\n") + return .handled + } + requestSend() return .handled } return .ignored @@ -394,7 +549,7 @@ struct ScoutRootView: View { } if showDictationPreview { - ScoutDictationPreview(text: vox.partial.isEmpty ? voxStatusLine : vox.partial) + ScoutDictationPreview(text: vox.partial) .allowsHitTesting(false) } } @@ -457,6 +612,13 @@ struct ScoutRootView: View { && !store.isSending } + /// Whether the send button should read as *enabled* — i.e. we have a target + /// to talk to. Lit whenever a conversation is selected (not gated on having + /// typed text yet); the actual send still no-ops on an empty draft. + private var composerReady: Bool { + store.selectedCId != nil && !store.isSending + } + private var composerSuggestionX: CGFloat { guard composerInputFrame.width > 0 else { return HudSpacing.xxl } return max(HudSpacing.xs, composerInputFrame.minX) @@ -493,6 +655,11 @@ struct ScoutRootView: View { } } + private var isDictationProcessing: Bool { + if case .processing = vox.state { return true } + return false + } + private var voxStatusLine: String { if !vox.partial.isEmpty { return vox.partial } switch vox.state { @@ -507,10 +674,25 @@ struct ScoutRootView: View { return nil } + /// Send entry point. While dictating, commit the recording first and let + /// the dictation→idle transition fire the actual send once the transcript + /// has landed — so one tap finishes transcription and sends in one shot. + private func requestSend() { + if isDictating { + guard composerReady else { return } + pendingSendAfterDictation = true + vox.stop() + return + } + sendDraft() + } + private func sendDraft() { let body = draft guard composerCanSend else { return } draft = "" + if let cId = store.selectedCId { drafts[cId] = nil } + followLatest = true composerFocused = true clearSuggestions(resetDismissedSignature: true) Task { await store.send(body) } @@ -539,6 +721,21 @@ struct ScoutRootView: View { draft = ScoutDictationBuffer.appending(trimmed, to: draft) ScoutVoxService.shared.consumeFinalText() composerFocused = true + moveComposerCaretToEnd() + } + + /// After splicing dictated text, drop the field's selection and park the + /// caret at the very end so you can keep typing/editing cleanly instead of + /// landing on an all-selected or mid-string insertion point. + private func moveComposerCaretToEnd() { + #if os(macOS) + DispatchQueue.main.async { + guard let textView = NSApp.keyWindow?.firstResponder as? NSTextView else { return } + let end = (textView.string as NSString).length + textView.setSelectedRange(NSRange(location: end, length: 0)) + textView.scrollRangeToVisible(NSRange(location: end, length: 0)) + } + #endif } private func refreshSuggestions() { @@ -733,6 +930,9 @@ struct ScoutRootView: View { }, openProfile: { ScoutWeb.open(path: "/agents/\(agent.id)?tab=profile") + }, + startSession: { mode in + startSessionWithAgent(agent, mode: mode) } ) .id("preview-\(agent.id)") @@ -776,11 +976,13 @@ struct ScoutRootView: View { } if let agent = store.selectedAgent { - ScoutAgentInspector(agent: agent, selectedChannel: store.selectedChannel) { - observeAgent(agent) - } openProfile: { - ScoutWeb.open(path: "/agents/\(agent.id)?tab=profile") - } + ScoutAgentInspector( + agent: agent, + selectedChannel: store.selectedChannel, + openObserve: { observeAgent(agent) }, + openProfile: { ScoutWeb.open(path: "/agents/\(agent.id)?tab=profile") }, + startSession: { mode in startSessionWithAgent(agent, mode: mode) } + ) } else if let channel = store.selectedChannel { ScoutChannelInspector(channel: channel) } else { @@ -1191,6 +1393,7 @@ private struct ScoutConversationListBar: View { let totalCount: Int let selectedCId: String? let width: CGFloat + let onNewConversation: () -> Void let select: (ScoutChannel) -> Void var body: some View { @@ -1218,11 +1421,22 @@ private struct ScoutConversationListBar: View { .foregroundStyle(HudPalette.dim) .lineLimit(1) } trailing: { - if isLoading { - ProgressView() - .controlSize(.small) - } else { - HudBadge("\(channels.count)", tint: HudPalette.muted) + HStack(spacing: HudSpacing.md) { + if isLoading { + ProgressView() + .controlSize(.small) + } else { + HudBadge("\(channels.count)", tint: HudPalette.muted) + } + Button(action: onNewConversation) { + Image(systemName: "plus") + .font(HudFont.ui(12, weight: .semibold)) + } + .buttonStyle(.plain) + .foregroundStyle(HudPalette.accent) + .frame(width: 26, height: 26) + .contentShape(Rectangle()) + .help("New conversation") } } } @@ -1711,6 +1925,7 @@ private struct ScoutMessageRow: View { let message: ScoutMessage let agent: ScoutAgent? let previewAgent: (ScoutAgent) -> Void + let onNewFromMessage: () -> Void @State private var isHoveringAgent = false @@ -1736,11 +1951,36 @@ private struct ScoutMessageRow: View { RoundedRectangle(cornerRadius: HudRadius.card) .stroke(message.isOperator ? HudSurface.tintBorder(HudPalette.accent) : HudHairline.standard, lineWidth: 1) ) + .contextMenu { + Button { + onNewFromMessage() + } label: { + Label("New conversation from this message…", systemImage: "bubble.left.and.text.bubble.right") + } + Divider() + Button { + copyToPasteboard(message.body) + } label: { + Label("Copy message", systemImage: "doc.on.doc") + } + Button { + copyToPasteboard(message.id) + } label: { + Label("Copy message ID", systemImage: "number") + } + } if !message.isOperator { Spacer(minLength: 80) } } .frame(maxWidth: .infinity, alignment: message.isOperator ? .trailing : .leading) } + private func copyToPasteboard(_ value: String) { + #if os(macOS) + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(value, forType: .string) + #endif + } + @ViewBuilder private var actorChip: some View { if let agent { @@ -1832,6 +2072,15 @@ private struct ScoutMarkdownView: View { } .frame(maxWidth: .infinity, alignment: .leading) .textSelection(.enabled) + .environment(\.openURL, OpenURLAction { url in + guard let link = ScoutFileLink.parse(url) else { return .systemAction } + if NSEvent.modifierFlags.contains(.command) { + ScoutFileOpener.openInEditor(path: link.path, line: link.line) + } else { + ScoutFilePreview.show(path: link.path) + } + return .handled + }) } @ViewBuilder @@ -1893,10 +2142,11 @@ private struct ScoutMarkdownView: View { } private func inline(_ body: String) -> AttributedString { - (try? AttributedString( + let parsed = (try? AttributedString( markdown: body, options: AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace) )) ?? AttributedString(body) + return ScoutFileLinkifier.apply(to: parsed, accent: HudPalette.accent) } } @@ -1968,31 +2218,21 @@ private extension MessageSuggestionAgent { private struct ScoutDictationPreview: View { let text: String - @State private var caretLit = false private var displayText: String { text.trimmingCharacters(in: .whitespacesAndNewlines) } var body: some View { - HStack(spacing: HudSpacing.xs) { - if !displayText.isEmpty { - Text(displayText) - .font(HudFont.mono(11)) - .foregroundStyle(HudPalette.muted) - .lineLimit(1) - .truncationMode(.tail) - } - RoundedRectangle(cornerRadius: 0.5, style: .continuous) - .fill(HudPalette.accent.opacity(caretLit ? 0.95 : 0.25)) - .frame(width: 1, height: 13) - } - .frame(maxWidth: .infinity, alignment: .leading) - .onAppear { - withAnimation(.easeInOut(duration: 0.48).repeatForever(autoreverses: true)) { - caretLit = true - } - } + // Live partial transcript only — no blinking caret. The recording cue + // is the waveform near the mic; the textual state lives in the status row. + Text(displayText) + .font(HudFont.mono(11)) + .foregroundStyle(HudPalette.muted) + .lineLimit(1) + .truncationMode(.tail) + .opacity(displayText.isEmpty ? 0 : 1) + .frame(maxWidth: .infinity, alignment: .leading) } } @@ -2031,9 +2271,10 @@ private struct ScoutSendButton: View { .scaleEffect(0.62) .tint(HudPalette.dim) } else { - Image(systemName: "arrow.up") - .font(.system(size: 13, weight: .bold)) + Image(systemName: "paperplane.fill") + .font(.system(size: 13, weight: .semibold)) .foregroundStyle(iconColor) + .offset(x: -1, y: 1) } } @@ -2063,13 +2304,41 @@ private struct ScoutSendButton: View { // toggle Vox dictation. Visual state mirrors ScoutVoxService.state: // idle/probing → faint stroke · recording → accent stroke + pulsing halo // processing → muted stroke that breathes · unavailable → dim + dashed. +// Lightweight equalizer-style waveform shown while dictating. Decorative +// (synthetic, not amplitude-driven) — replaces the recording pulse with a +// calmer, single activity cue. Bars stay out of phase via fixed per-bar +// durations rather than any RNG. +private struct ScoutWaveform: View { + var tint: Color + @State private var animate = false + + private let lows: [CGFloat] = [4, 6, 5, 7, 4] + private let highs: [CGFloat] = [12, 17, 14, 18, 11] + private let durations: [Double] = [0.50, 0.62, 0.44, 0.70, 0.54] + + var body: some View { + HStack(alignment: .center, spacing: 2) { + ForEach(lows.indices, id: \.self) { i in + Capsule(style: .continuous) + .fill(tint.opacity(0.85)) + .frame(width: 2.5, height: animate ? highs[i] : lows[i]) + .animation( + .easeInOut(duration: durations[i]).repeatForever(autoreverses: true), + value: animate + ) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .onAppear { animate = true } + } +} + private struct ScoutMicButton: View { let box: CGFloat let glyph: CGFloat let action: () -> Void @ObservedObject private var vox = ScoutVoxService.shared - @State private var pulse = false @State private var hovering = false private var isRecording: Bool { vox.state.isCaptureActive } @@ -2101,11 +2370,13 @@ private struct ScoutMicButton: View { .fill(micFillColor) .frame(width: box, height: box) - if isRecording { - Circle() - .fill(HudPalette.accent.opacity(pulse ? 0.20 : 0.08)) - .frame(width: box, height: box) - } + Circle() + .stroke( + isRecording ? HudPalette.accent.opacity(0.5) : Color.clear, + lineWidth: HudStrokeWidth.thin + ) + .frame(width: box, height: box) + ScoutMicGlyphShape() .stroke( strokeColor, @@ -2117,7 +2388,6 @@ private struct ScoutMicButton: View { ) ) .frame(width: glyph, height: glyph) - .opacity(isProcessing && pulse ? 0.55 : 1.0) } .frame(width: box, height: box) .contentShape(Rectangle()) @@ -2126,22 +2396,14 @@ private struct ScoutMicButton: View { .help(tooltip) .onHover { hovering = $0 } .task { if vox.state == .probing { await vox.probe() } } - .onChange(of: vox.state) { _, newValue in - pulse = false - if newValue == .recording || newValue == .starting || newValue == .processing { - withAnimation(.easeInOut(duration: 0.55).repeatForever(autoreverses: true)) { - pulse = true - } - } - } } private var micFillColor: Color { if isRecording { - return HudPalette.accent.opacity(pulse ? 0.13 : 0.08) + return HudPalette.accent.opacity(0.13) } if isProcessing { - return HudSurface.hover.opacity(pulse ? 0.88 : 0.62) + return HudSurface.hover.opacity(0.7) } if hovering { return HudSurface.hover.opacity(0.86) @@ -2222,10 +2484,11 @@ private struct ScoutMarkdownTable: View { } private func inline(_ body: String) -> AttributedString { - (try? AttributedString( + let parsed = (try? AttributedString( markdown: body, options: AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace) )) ?? AttributedString(body) + return ScoutFileLinkifier.apply(to: parsed, accent: HudPalette.accent) } } @@ -2285,6 +2548,7 @@ private struct ScoutAgentInspector: View { let selectedChannel: ScoutChannel? let openObserve: () -> Void let openProfile: () -> Void + let startSession: (ScoutSessionDraft.Mode) -> Void var body: some View { VStack(alignment: .leading, spacing: HudSpacing.xl) { @@ -2334,6 +2598,22 @@ private struct ScoutAgentInspector: View { } } + HudCard { + VStack(alignment: .leading, spacing: HudSpacing.md) { + HudSectionLabel("New session") + HStack { + HudButton("New session", icon: "plus.bubble", style: .secondary) { + startSession(.fresh) + } + if agent.harnessSessionId?.nilIfEmpty != nil { + HudButton("Continue", icon: "arrow.uturn.forward", style: .secondary) { + startSession(.continueContext) + } + } + } + } + } + HStack { HudButton("Observe", icon: "eye", style: .primary(.green), action: openObserve) HudButton("Profile", icon: "person.text.rectangle", style: .secondary, action: openProfile) @@ -2348,6 +2628,7 @@ private struct ScoutAgentPreviewPanel: View { let onClose: () -> Void let openObserve: () -> Void let openProfile: () -> Void + let startSession: (ScoutSessionDraft.Mode) -> Void var body: some View { VStack(spacing: 0) { @@ -2359,7 +2640,8 @@ private struct ScoutAgentPreviewPanel: View { agent: agent, selectedChannel: selectedChannel, openObserve: openObserve, - openProfile: openProfile + openProfile: openProfile, + startSession: startSession ) .padding(HudSpacing.xl) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/apps/macos/Sources/Scout/ScoutScrollStyle.swift b/apps/macos/Sources/Scout/ScoutScrollStyle.swift index 2c8b1a5c..9289c721 100644 --- a/apps/macos/Sources/Scout/ScoutScrollStyle.swift +++ b/apps/macos/Sources/Scout/ScoutScrollStyle.swift @@ -5,54 +5,76 @@ import AppKit #if os(macOS) -/// A thin, HUD-coherent overlay scroller. Subtly tinted, slot-less knob that -/// hugs the trailing edge so Scout's scroll areas read as intentional chrome -/// rather than the raw system scroller. +enum ScoutScrollbarMetrics { + /// Width of the reserved scroller lane (content is inset by this). + static let laneWidth: CGFloat = 12 + /// Thickness of the knob/track pill within the lane. + static let pillThickness: CGFloat = 6 + /// Inset of the pill from the ends of the track. + static let pillInset: CGFloat = 2 + static let knobAlpha: CGFloat = 0.34 + static let trackAlpha: CGFloat = 0.07 +} + +/// A slim, HUD-coherent scroller. Draws a persistent faint track plus a brighter +/// rounded knob so it's always clear a scroll area exists, while staying tight to +/// the panel edge via a narrow reserved lane. final class ScoutHudScroller: NSScroller { override class var isCompatibleWithOverlayScrollers: Bool { true } + /// Keep the reserved lane narrow so content sits tight to the divider/border. + override class func scrollerWidth( + for controlSize: NSControl.ControlSize, + scrollerStyle: NSScroller.Style + ) -> CGFloat { + ScoutScrollbarMetrics.laneWidth + } + override func drawKnobSlot(in slotRect: NSRect, highlight flag: Bool) { - // Slot-less: keep the bar minimal so it floats over HUD chrome. + let pill = pillRect(in: slotRect) + let radius = min(pill.width, pill.height) / 2 + NSColor.white.withAlphaComponent(ScoutScrollbarMetrics.trackAlpha).setFill() + NSBezierPath(roundedRect: pill, xRadius: radius, yRadius: radius).fill() } override func drawKnob() { let knobRect = rect(for: .knob) guard knobRect.width > 0, knobRect.height > 0 else { return } + let pill = pillRect(in: knobRect) + let radius = min(pill.width, pill.height) / 2 + NSColor.white.withAlphaComponent(ScoutScrollbarMetrics.knobAlpha).setFill() + NSBezierPath(roundedRect: pill, xRadius: radius, yRadius: radius).fill() + } - // Pull the knob a hair off the very edge and slim it down. - let thickness: CGFloat = 4 - let inset: CGFloat = 2 - let drawRect: NSRect - if knobRect.width >= knobRect.height { - // Horizontal scroller. - drawRect = NSRect( - x: knobRect.minX + inset, - y: knobRect.maxY - thickness - inset, - width: max(knobRect.width - inset * 2, thickness), - height: thickness + /// Slim pill centered within the lane, inset from the track ends. + private func pillRect(in rect: NSRect) -> NSRect { + let thickness = ScoutScrollbarMetrics.pillThickness + let inset = ScoutScrollbarMetrics.pillInset + let vertical = bounds.height >= bounds.width + if vertical { + return NSRect( + x: rect.midX - thickness / 2, + y: rect.minY + inset, + width: thickness, + height: max(rect.height - inset * 2, thickness) ) } else { - // Vertical scroller. - drawRect = NSRect( - x: knobRect.maxX - thickness - inset, - y: knobRect.minY + inset, - width: thickness, - height: max(knobRect.height - inset * 2, thickness) + return NSRect( + x: rect.minX + inset, + y: rect.midY - thickness / 2, + width: max(rect.width - inset * 2, thickness), + height: thickness ) } - - let radius = thickness / 2 - let path = NSBezierPath(roundedRect: drawRect, xRadius: radius, yRadius: radius) - NSColor.white.withAlphaComponent(0.22).setFill() - path.fill() } } /// Invisible AppKit probe that restyles the enclosing `NSScrollView`'s /// scrollers. SwiftUI otherwise honours the user's "Show scroll bars" setting, -/// which can render wide legacy scrollers that sit far from the panel edge in a -/// gray gutter. Forcing the overlay style + a slim tinted knob keeps every Scout -/// scroll area tight to its divider/border and visually consistent. +/// which can render wide gray legacy scrollers or auto-hiding overlay scrollers +/// that give no persistent hint the area scrolls. We pin a slim legacy-style +/// scroller (always visible while scrollable, with a faint track) so every Scout +/// scroll area reads as deliberate HUD chrome and stays tight to its edge. private struct ScoutScrollerStyler: NSViewRepresentable { func makeNSView(context: Context) -> ProbeView { ProbeView() } @@ -69,26 +91,29 @@ private struct ScoutScrollerStyler: NSViewRepresentable { func applyStyle() { DispatchQueue.main.async { [weak self] in guard let scrollView = self?.enclosingScrollView else { return } - scrollView.scrollerStyle = .overlay + // Legacy style keeps the bar persistently visible while the area + // is scrollable, instead of fading like overlay scrollers. + scrollView.scrollerStyle = .legacy + scrollView.autohidesScrollers = true scrollView.scrollerInsets = NSEdgeInsetsZero scrollView.drawsBackground = false if !(scrollView.verticalScroller is ScoutHudScroller) { let scroller = ScoutHudScroller() - scroller.scrollerStyle = .overlay + scroller.scrollerStyle = .legacy scrollView.verticalScroller = scroller } - scrollView.verticalScroller?.scrollerStyle = .overlay - scrollView.horizontalScroller?.scrollerStyle = .overlay + scrollView.verticalScroller?.scrollerStyle = .legacy + scrollView.horizontalScroller?.scrollerStyle = .legacy } } } } extension View { - /// Apply Scout's HUD overlay scrollbar treatment. Attach to the content - /// *inside* a `ScrollView` so the probe can resolve the enclosing scroll - /// view. Pairs with `.scrollIndicators(.visible)` on the `ScrollView`. + /// Apply Scout's HUD scrollbar treatment. Attach to the content *inside* a + /// `ScrollView` so the probe can resolve the enclosing scroll view. Pairs + /// with `.scrollIndicators(.visible)` on the `ScrollView`. func scoutOverlayScrollers() -> some View { background( ScoutScrollerStyler() diff --git a/apps/macos/Sources/Scout/ScoutSessionService.swift b/apps/macos/Sources/Scout/ScoutSessionService.swift new file mode 100644 index 00000000..3505e7aa --- /dev/null +++ b/apps/macos/Sources/Scout/ScoutSessionService.swift @@ -0,0 +1,373 @@ +import HudsonShell +import HudsonUI +import ScoutNativeCore +import ScoutSharedUI +import SwiftUI +#if os(macOS) +import AppKit +#endif + +// MARK: - Network spec / result + +/// Flexible session-initiation request. Mirrors `POST /api/sessions`: every +/// modality (new conversation in a project, "same agent" fresh, continue an +/// agent's session with full context, seed-from-message) is expressed by which +/// fields are set rather than by a dedicated endpoint. +struct SessionInitiationSpec { + enum Session: String { case new, existing, any } + + var targetAgentId: String? + var projectPath: String? + var harness: String? + var model: String? + var session: Session? + var targetSessionId: String? + var persistence: String? + var agentName: String? + var displayName: String? + var instructions: String? + var fromMessageId: String? + var fromConversationId: String? + + func jsonBody() -> [String: Any] { + var target: [String: Any] = [:] + if let targetAgentId { target["agentId"] = targetAgentId } + if let projectPath { target["projectPath"] = projectPath } + + var execution: [String: Any] = [:] + if let harness { execution["harness"] = harness } + if let model { execution["model"] = model } + if let session { execution["session"] = session.rawValue } + if let targetSessionId { execution["targetSessionId"] = targetSessionId } + + var agent: [String: Any] = [:] + if let persistence { agent["persistence"] = persistence } + if let agentName { agent["name"] = agentName } + if let displayName { agent["displayName"] = displayName } + + var seed: [String: Any] = [:] + if let instructions, !instructions.isEmpty { seed["instructions"] = instructions } + if let fromMessageId { seed["fromMessageId"] = fromMessageId } + if let fromConversationId { seed["fromConversationId"] = fromConversationId } + + var body: [String: Any] = [:] + if !target.isEmpty { body["target"] = target } + if !execution.isEmpty { body["execution"] = execution } + if !agent.isEmpty { body["agent"] = agent } + if !seed.isEmpty { body["seed"] = seed } + return body + } +} + +struct SessionInitiationResult: Decodable { + let ok: Bool? + let conversationId: String? + let agentId: String? + let flightId: String? + let messageId: String? +} + +enum SessionInitiationError: LocalizedError { + case invalidResponse + case httpStatus(Int, String) + + var errorDescription: String? { + switch self { + case .invalidResponse: + return "Scout returned an invalid response." + case .httpStatus(let status, let message): + return message.isEmpty ? "Scout returned HTTP \(status)." : message + } + } +} + +enum SessionInitiationService { + static func start(_ spec: SessionInitiationSpec) async throws -> SessionInitiationResult { + let url = ScoutWeb.baseURL().appending(path: "api/sessions") + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: spec.jsonBody()) + + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw SessionInitiationError.invalidResponse + } + guard (200..<300).contains(http.statusCode) else { + throw SessionInitiationError.httpStatus(http.statusCode, Self.decodeError(data)) + } + return try JSONDecoder().decode(SessionInitiationResult.self, from: data) + } + + private static func decodeError(_ data: Data) -> String { + guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let message = object["error"] as? String else { + return "" + } + return message + } +} + +// MARK: - Composer draft + +/// In-flight composer state shared by every entry point (the "+" in the +/// conversation list, a message context menu, the agent inspector). The entry +/// point configures the draft; the composer builds the `SessionInitiationSpec`. +struct ScoutSessionDraft: Identifiable { + enum Mode: Hashable { case fresh, continueContext } + enum Target { + case agent(ScoutAgent) + case project + } + + let id = UUID() + var title: String + var target: Target + var projectPath: String + var mode: Mode + var instructions: String + var fromMessageId: String? + var fromConversationId: String? + + var agent: ScoutAgent? { + if case let .agent(agent) = target { return agent } + return nil + } + + /// Whether continuing the same harness session (full context) is possible — + /// requires the agent to expose a resolvable session id. + var canContinue: Bool { + agent?.harnessSessionId?.nilIfEmpty != nil + } +} + +// MARK: - Composer + +/// Modal sheet that turns a `ScoutSessionDraft` into a session-initiation call. +/// Renders its own dimmed backdrop so the host only needs `if let draft`. +struct ScoutSessionComposer: View { + let onClose: () -> Void + let onComplete: (SessionInitiationResult) -> Void + + @State private var draft: ScoutSessionDraft + @State private var isSubmitting = false + @State private var errorText: String? + @FocusState private var instructionsFocused: Bool + + init( + draft: ScoutSessionDraft, + onClose: @escaping () -> Void, + onComplete: @escaping (SessionInitiationResult) -> Void + ) { + self.onClose = onClose + self.onComplete = onComplete + _draft = State(initialValue: draft) + } + + var body: some View { + ZStack { + Color.black.opacity(0.42) + .ignoresSafeArea() + .onTapGesture { if !isSubmitting { onClose() } } + + card + .frame(width: 460) + .padding(HudSpacing.xxl) + } + .onExitCommand { if !isSubmitting { onClose() } } + } + + private var card: some View { + VStack(alignment: .leading, spacing: HudSpacing.xl) { + header + targetSection + instructionsSection + if let errorText { + Text(errorText) + .font(HudFont.mono(10)) + .foregroundStyle(HudPalette.accent) + .fixedSize(horizontal: false, vertical: true) + } + footer + } + .padding(HudSpacing.xxl) + .background( + RoundedRectangle(cornerRadius: HudRadius.card, style: .continuous) + .fill(HudPalette.surface) + ) + .overlay( + RoundedRectangle(cornerRadius: HudRadius.card, style: .continuous) + .stroke(HudHairline.standard, lineWidth: 1) + ) + .shadow(color: Color.black.opacity(0.35), radius: 30, y: 12) + } + + private var header: some View { + VStack(alignment: .leading, spacing: HudSpacing.xs) { + Text(draft.title) + .font(HudFont.ui(16, weight: .semibold)) + .foregroundStyle(HudPalette.ink) + Text(subtitle) + .font(HudFont.mono(10)) + .foregroundStyle(HudPalette.dim) + .lineLimit(1) + } + } + + private var subtitle: String { + switch draft.target { + case .agent(let agent): + return draft.mode == .continueContext + ? "Continue \(agent.displayName) with full context" + : "Fresh session with \(agent.displayName)" + case .project: + return "Start a new agent in a project" + } + } + + @ViewBuilder + private var targetSection: some View { + switch draft.target { + case .agent(let agent): + VStack(alignment: .leading, spacing: HudSpacing.md) { + HStack(spacing: HudSpacing.md) { + Image(systemName: "person.crop.circle") + .font(HudFont.ui(12, weight: .semibold)) + .foregroundStyle(HudPalette.accent) + Text(agent.displayName) + .font(HudFont.ui(12, weight: .semibold)) + .foregroundStyle(HudPalette.ink) + Text(agent.detail) + .font(HudFont.mono(9)) + .foregroundStyle(HudPalette.dim) + .lineLimit(1) + } + if draft.canContinue { + modePicker + } + } + case .project: + HudField("Project path", text: $draft.projectPath, icon: "folder") + } + } + + private var modePicker: some View { + HStack(spacing: HudSpacing.xs) { + modeButton(.fresh, title: "Fresh session", icon: "plus.bubble") + modeButton(.continueContext, title: "Continue (full context)", icon: "arrow.uturn.forward") + } + .padding(3) + .background(RoundedRectangle(cornerRadius: HudRadius.standard, style: .continuous).fill(HudSurface.inset)) + .overlay(RoundedRectangle(cornerRadius: HudRadius.standard, style: .continuous).stroke(HudHairline.standard, lineWidth: HudStrokeWidth.thin)) + } + + private func modeButton(_ mode: ScoutSessionDraft.Mode, title: String, icon: String) -> some View { + Button { + draft.mode = mode + } label: { + HStack(spacing: HudSpacing.xs) { + Image(systemName: icon) + .font(HudFont.ui(10, weight: .semibold)) + Text(title) + .font(HudFont.mono(9, weight: .semibold)) + } + .foregroundStyle(draft.mode == mode ? HudPalette.ink : HudPalette.muted) + .frame(maxWidth: .infinity) + .frame(height: 26) + .background( + RoundedRectangle(cornerRadius: HudRadius.standard, style: .continuous) + .fill(draft.mode == mode ? HudSurface.selected(HudPalette.accent) : Color.clear) + ) + } + .buttonStyle(.plain) + } + + private var instructionsSection: some View { + VStack(alignment: .leading, spacing: HudSpacing.xs) { + HudSectionLabel(draft.mode == .continueContext ? "Follow-up" : "Instructions") + TextEditor(text: $draft.instructions) + .font(HudFont.ui(12)) + .foregroundStyle(HudPalette.ink) + .scrollContentBackground(.hidden) + .focused($instructionsFocused) + .frame(minHeight: 96) + .padding(6) + .background(HudSurface.inset) + .overlay( + RoundedRectangle(cornerRadius: HudRadius.standard, style: .continuous) + .stroke(HudHairline.standard, lineWidth: HudStrokeWidth.thin) + ) + } + } + + private var footer: some View { + HStack { + HudButton("Cancel", style: .ghost) { onClose() } + .disabled(isSubmitting) + Spacer() + if isSubmitting { + ProgressView().controlSize(.small) + } + HudButton(startTitle, icon: "paperplane.fill", style: .primary(.green)) { + submit() + } + .disabled(isSubmitting || !canSubmit) + } + } + + private var startTitle: String { + draft.mode == .continueContext ? "Continue" : "Start" + } + + private var canSubmit: Bool { + switch draft.target { + case .agent: + if draft.mode == .continueContext { return draft.canContinue } + return true + case .project: + return !draft.projectPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + } + + private func makeSpec() -> SessionInitiationSpec { + var spec = SessionInitiationSpec() + spec.persistence = "sticky" + let trimmed = draft.instructions.trimmingCharacters(in: .whitespacesAndNewlines) + spec.instructions = trimmed.isEmpty ? nil : trimmed + spec.fromMessageId = draft.fromMessageId + spec.fromConversationId = draft.fromConversationId + + switch draft.target { + case .agent(let agent): + spec.targetAgentId = agent.id + spec.agentName = agent.name.nilIfEmpty + if draft.mode == .continueContext { + spec.session = .existing + spec.targetSessionId = agent.harnessSessionId?.nilIfEmpty + } else { + spec.session = .new + } + case .project: + spec.projectPath = draft.projectPath.trimmingCharacters(in: .whitespacesAndNewlines) + spec.session = .new + } + return spec + } + + private func submit() { + guard !isSubmitting, canSubmit else { return } + isSubmitting = true + errorText = nil + let spec = makeSpec() + Task { + do { + let result = try await SessionInitiationService.start(spec) + isSubmitting = false + onComplete(result) + } catch { + isSubmitting = false + errorText = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + } + } + } +} diff --git a/packages/web/client/screens/conversation-screen.css b/packages/web/client/screens/conversation-screen.css index 3906212b..61a530c8 100644 --- a/packages/web/client/screens/conversation-screen.css +++ b/packages/web/client/screens/conversation-screen.css @@ -1182,7 +1182,7 @@ button.s-thread-msg-actor:focus-visible { /* ── Composer ── */ .s-thread-compose { - padding: 10px 20px 18px; + padding: 10px 0 18px 20px; border-top: 1px solid color-mix(in srgb, var(--border) 40%, transparent); background: transparent; flex-shrink: 0; diff --git a/packages/web/server/core/broker/service.ts b/packages/web/server/core/broker/service.ts index 017c5972..b16f7e25 100644 --- a/packages/web/server/core/broker/service.ts +++ b/packages/web/server/core/broker/service.ts @@ -2105,6 +2105,7 @@ export async function askScoutQuestion(input: { executionHarness?: AgentHarness; executionModel?: string; executionSession?: "new" | "existing" | "any"; + executionTargetSessionId?: string; projectAgent?: ScoutProjectAgentSpec; currentDirectory?: string; source?: string; @@ -2164,6 +2165,9 @@ export async function askScoutQuestion(input: { ...(input.executionHarness ? { harness: input.executionHarness } : {}), ...(input.executionModel?.trim() ? { model: input.executionModel.trim() } : {}), session: input.executionSession ?? "new", + ...(input.executionTargetSessionId?.trim() + ? { targetSessionId: input.executionTargetSessionId.trim() } + : {}), }, ...(input.projectAgent ? { projectAgent: input.projectAgent } : {}), ensureAwake: true, diff --git a/packages/web/server/create-openscout-web-server.ts b/packages/web/server/create-openscout-web-server.ts index 3208337d..87c85031 100644 --- a/packages/web/server/create-openscout-web-server.ts +++ b/packages/web/server/create-openscout-web-server.ts @@ -7,6 +7,7 @@ import { homedir } from "node:os"; import { Hono, type Context } from "hono"; import type { AgentEndpoint, + AgentHarness, CollaborationEvent, CollaborationKind, ConversationDefinition, @@ -591,6 +592,36 @@ function optionalFiniteNumber(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } +const EXECUTION_SESSION_PREFERENCES = new Set(["new", "existing", "any"]); + +function normalizeExecutionSession( + value: unknown, +): "new" | "existing" | "any" | undefined { + const normalized = optionalString(value)?.trim(); + return normalized && EXECUTION_SESSION_PREFERENCES.has(normalized) + ? (normalized as "new" | "existing" | "any") + : undefined; +} + +const KNOWN_AGENT_HARNESSES = new Set([ + "codex", + "claude", + "flue", + "cursor", + "native", + "worker", + "bridge", + "http", + "pi", +]); + +function coerceAgentHarness(value: unknown): AgentHarness | undefined { + const normalized = optionalString(value)?.trim(); + return normalized && KNOWN_AGENT_HARNESSES.has(normalized) + ? (normalized as AgentHarness) + : undefined; +} + function recordInput(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) ? value as Record @@ -2614,6 +2645,134 @@ export async function createOpenScoutWebServer( const agent = queryAgentById(c.req.param("id")); return agent ? c.json(agent) : c.json({ error: "agent not found" }, 404); }); + // Flexible session initiation. A single payload expresses every modality — + // start fresh in a project, start "the same agent" fresh, continue an + // agent's existing harness session with full context, or seed a new + // conversation from a message — by setting different fields. See + // docs/agent for the modality matrix; `seed.branchFrom` is accepted now and + // reserved for forthcoming context-forking work (currently inert). + app.post("/api/sessions", async (c) => { + const body = (await c.req.json().catch(() => ({}))) as { + target?: { agentId?: string; projectPath?: string }; + execution?: { + harness?: string; + model?: string; + session?: string; + targetSessionId?: string; + }; + agent?: { persistence?: string; name?: string; displayName?: string }; + seed?: { + instructions?: string; + fromMessageId?: string; + fromConversationId?: string; + branchFrom?: { sessionId?: string; messageId?: string }; + }; + }; + + const targetAgentId = optionalString(body.target?.agentId)?.trim(); + const agent = targetAgentId ? queryAgentById(targetAgentId) : null; + if (targetAgentId && !agent) { + return c.json({ error: `agent ${targetAgentId} not found` }, 404); + } + + // Resolve a project path: explicit wins, else inherit the agent's root. + const projectPath = + optionalString(body.target?.projectPath)?.trim() || + agent?.projectRoot?.trim() || + undefined; + if (!targetAgentId && !projectPath) { + return c.json( + { error: "target.agentId or target.projectPath is required" }, + 400, + ); + } + + // Execution preferences fall back to the resolved agent so "same agent" + // keeps its harness/model. + const session = normalizeExecutionSession(body.execution?.session); + const harness = + coerceAgentHarness(body.execution?.harness) ?? + coerceAgentHarness(agent?.harness); + const model = + optionalString(body.execution?.model)?.trim() || + agent?.model?.trim() || + undefined; + let targetSessionId = optionalString(body.execution?.targetSessionId)?.trim(); + if (session === "existing" && !targetSessionId) { + targetSessionId = agent?.harnessSessionId?.trim() || undefined; + } + if (session === "existing" && !targetSessionId) { + return c.json( + { + error: + "session 'existing' requires execution.targetSessionId or an agent with a resolvable session", + }, + 400, + ); + } + + // Sticky reuse of the same agentName is what makes M3/M4 "the same agent". + const persistence = + body.agent?.persistence === "one_time" ? "one_time" : "sticky"; + const agentName = + optionalString(body.agent?.name)?.trim() || agent?.name?.trim() || undefined; + const displayName = optionalString(body.agent?.displayName)?.trim() || undefined; + + const instructions = optionalString(body.seed?.instructions)?.trim(); + const fromMessageId = optionalString(body.seed?.fromMessageId)?.trim(); + const fromConversationId = optionalString(body.seed?.fromConversationId)?.trim(); + const branchFrom = body.seed?.branchFrom; + + const result = await askScoutQuestion({ + senderId: resolveOperatorName().trim() || "operator", + ...(targetAgentId + ? { targetLabel: targetAgentId, targetAgentId } + : { + target: { kind: "project_path", projectPath: projectPath! }, + }), + body: instructions && instructions.length > 0 ? instructions : "New session started.", + ...(harness ? { executionHarness: harness } : {}), + ...(model ? { executionModel: model } : {}), + ...(session ? { executionSession: session } : {}), + ...(targetSessionId ? { executionTargetSessionId: targetSessionId } : {}), + projectAgent: { + persistence, + ...(agentName ? { agentName } : {}), + ...(displayName ? { displayName } : {}), + }, + currentDirectory: projectPath ?? currentDirectory, + source: "scout-session-initiation", + }); + + if (!result.usedBroker) { + return c.json({ error: "broker unreachable" }, 502); + } + if (result.unresolvedTarget) { + return c.json( + { + error: `could not start session: ${result.unresolvedTarget}`, + targetDiagnostic: result.targetDiagnostic ?? null, + }, + 409, + ); + } + + return c.json({ + ok: true, + conversationId: result.conversationId ?? null, + messageId: result.messageId ?? null, + flightId: result.flight?.id ?? null, + agentId: result.flight?.targetAgentId ?? targetAgentId ?? null, + provenance: + fromMessageId || fromConversationId || branchFrom + ? { + fromMessageId: fromMessageId ?? null, + fromConversationId: fromConversationId ?? null, + branchFrom: branchFrom ?? null, + } + : null, + }); + }); app.get("/api/observe/agents", async (c) => { const ids = c.req.query("ids") ?.split(",") From 19b9e875775be81ed5a9ed3f604cade6ae78587d Mon Sep 17 00:00:00 2001 From: Arach Tchoupani Date: Tue, 2 Jun 2026 21:05:51 -0400 Subject: [PATCH 2/6] design(studio): agent inspector card study MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settled direction for the Scout sidebar per-agent card: - no AVAILABLE tag — presence rides the avatar dot - identity header is clickable to the agent profile - one cohesive card with Runtime / Workspace / Session / Skills sections - Observe lives inside the Session block (you observe a session) and only appears when there's a live session; reads as a button at rest - New session is a quiet inline link - session id rendered as the real opaque id (prefix + suffix), not a relay label --- .../app/studies/agent-inspector-card/page.tsx | 338 ++++++++++++++++++ design/studio/lib/studio-pages.ts | 10 + 2 files changed, 348 insertions(+) create mode 100644 design/studio/app/studies/agent-inspector-card/page.tsx diff --git a/design/studio/app/studies/agent-inspector-card/page.tsx b/design/studio/app/studies/agent-inspector-card/page.tsx new file mode 100644 index 00000000..6ba9d9b1 --- /dev/null +++ b/design/studio/app/studies/agent-inspector-card/page.tsx @@ -0,0 +1,338 @@ +import { Fragment } from "react"; + +/** + * Agent Inspector Card — study (locked direction: variant C, refined). + * + * The per-agent card in the Scout macOS sidebar inspector. A DM shows one + * card per participant. Settled design: + * + * · no "AVAILABLE" tag — state rides the presence dot on the avatar + * · the whole identity header is clickable → opens the agent's profile + * · the card is ONE cohesive concept with internal sections + * · Observe lives WITH the live session (you observe a session), not + * floating top-right — it only appears when there's a session to watch + * · "New session" is a quiet inline link at the foot (continuing is the + * default action elsewhere, so it stays unemphasized) + * · Observe / New session read as buttons at rest but never out-shout + * the agent identity + * + * Ports to: apps/macos/Sources/Scout/ScoutRootView.swift (ScoutAgentInspector) + */ + +type AgentState = "working" | "available" | "needs-attention" | "idle" | "offline"; + +interface InspectorAgent { + name: string; + id: string; + state: AgentState; + role: string; + harness: string; + transport: string; + model: string; + node: string; + branch: string; + path: string; + cid: string; + session?: { id: string; started: string }; + skills?: string[]; +} + +const STATE_COLOR: Record = { + working: "var(--status-warn-fg)", + "needs-attention": "var(--status-error-fg)", + available: "var(--status-ok-fg)", + idle: "var(--scout-accent)", + offline: "var(--studio-ink-faint)", +}; + +const DEWEY: InspectorAgent = { + name: "Dewey", + id: "dewey.main.arts-mac-mini-local", + state: "available", + role: "Relay agent", + harness: "claude", + transport: "claude_stream_json", + model: "—", + node: "arts-mac-mini-local", + branch: "main", + path: "~/dev/dewey", + cid: "c.960f31ec", + session: { id: "3e9c6337-7aec-4367-b43d-291c873fd60e", started: "6m" }, + skills: ["docs.audit", "docs.score"], +}; + +const SCOUT: InspectorAgent = { + name: "Scout", + id: "scoutbot", + state: "working", + role: "Relay agent", + harness: "codex", + transport: "codex_app_server", + model: "gpt-5.5", + node: "arts-mac-mini-local", + branch: "main", + path: "~/dev/openscout", + cid: "c.960f31ec", + session: { id: "0199a2f1-8c4d-7b2a-9e10-4f6db8c1a233", started: "2m" }, +}; + +const ATLAS: InspectorAgent = { + name: "Atlas", + id: "atlas.main.arts-mac-mini-local", + state: "idle", + role: "Session agent", + harness: "claude", + transport: "claude_stream_json", + model: "opus-4.7", + node: "arts-mac-mini-local", + branch: "design/atlas-iconography", + path: "~/dev/atlas", + cid: "c.7f21aa0c", + // no session → no Observe row +}; + +function avatarColor(_name: string): string { + return "oklch(0.42 0.008 80)"; +} + +export default function AgentInspectorCardPage() { + return ( +
+
+
+ · studies · macos · agent-inspector-card +
+

+ Agent inspector card +

+

+ The per-agent card in the Scout sidebar. No AVAILABLE{" "} + tag (state rides the dot), header click opens the profile, the + card is one cohesive unit. Observe lives with the + live session — it only shows when there's a session to + watch. New session is a quiet inline link. Rendered at + the real inspector width (~300px). +

+
+ +
+ + + + + + +
+ +

+ In a DM — two cards stacked +

+

+ Dewey {"<>"} Scout, stacked in the narrow inspector column. +

+
+ + +
+
+ ); +} + +function Labeled({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+
+ {label} +
+ {children} +
+ ); +} + +/* ── The card ───────────────────────────────────────────────────── */ + +function AgentCard({ agent }: { agent: InspectorAgent }) { + return ( +
+ + +
+ +
+ {agent.session ? ( + <> + + + + ) : null} + {agent.skills?.length ? ( + <> + + + + ) : null} + +
+ ); +} + +/** Clickable identity header → profile. State rides the presence dot only. */ +function CardHeader({ agent }: { agent: InspectorAgent }) { + const stateColor = STATE_COLOR[agent.state]; + return ( + + ); +} + +/** Live session block — the only home for Observe. */ +function SessionSection({ session }: { session: NonNullable }) { + return ( +
+
+
+ Session +
+ +
+
+ id + + {session.id.slice(0, 8)} + …{session.id.slice(-4)} + + started + {session.started} ago +
+
+ ); +} + +/** Reads as a button at rest (hairline border + faint inset), muted; warms + * to observe-green on hover so it never out-shouts the identity. */ +function ObserveButton() { + return ( + + ); +} + +function NewSessionLink() { + return ( + + ); +} + +function Section({ label, rows }: { label: string; rows: [string, string][] }) { + return ( +
+
+ {label} +
+
+ {rows.map(([k, v]) => ( + + {k} + {v} + + ))} +
+
+ ); +} + +function Skills({ skills }: { skills: string[] }) { + return ( +
+
+ Skills +
+
+ {skills.map((s) => ( + + {s} + + ))} +
+
+ ); +} + +function Divider() { + return
; +} + +/* ── Icons ──────────────────────────────────────────────────────── */ + +function EyeIcon() { + return ( + + + + + ); +} + +function PlusIcon() { + return ( + + + + ); +} diff --git a/design/studio/lib/studio-pages.ts b/design/studio/lib/studio-pages.ts index 05cf0f7a..5c7df99c 100644 --- a/design/studio/lib/studio-pages.ts +++ b/design/studio/lib/studio-pages.ts @@ -147,6 +147,16 @@ export const STUDIO_PAGES: StudioPage[] = [ source: ["packages/web/client/scout/inspector/AgentsInspector.tsx"], blurb: "Info-dense agent tile — identity · state · task · project · capabilities.", }, + { + href: "/studies/agent-inspector-card", + label: "Agent Inspector Card", + bucket: "studies", + surface: "macos", + family: "agent-cards", + status: "draft", + source: ["apps/macos/Sources/Scout/ScoutRootView.swift"], + blurb: "Per-agent sidebar card — no AVAILABLE tag, header→profile, Observe top-right, New-session CTA explored three ways.", + }, { href: "/studies/session-search", label: "Session Search", From dda3a05a8a5ee3ac1c8662ba1a7bf78e6df666d7 Mon Sep 17 00:00:00 2001 From: Arach Tchoupani Date: Tue, 2 Jun 2026 21:44:34 -0400 Subject: [PATCH 3/6] =?UTF-8?q?=E2=9C=A8=20Add=20image=20attachments=20+?= =?UTF-8?q?=20refine=20conversation=20display?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Image attachments (human→agent context, agent→agent links): - Ephemeral blob route (POST/GET /api/blobs): base64 image → TTL'd tmp cache, returns a fetchable absolute URL; optimized for first-fetch delivery, never touches the DB - Thread attachments through /api/send into the broker conversation and broadcast send helpers (persistence/rendering already supported them) - MCP messages_reply accepts link-backed image attachments - macOS composer: ⌘V paste (screenshots), drag-drop, and a paperclip picker → upload → send, with a thumbnail strip and image-only sends Conversation/channel display: - DM peer labels and channel-name helpers on ScoutChannel - Conversation list/row and agent/channel inspector refinements - Session service updates --- apps/desktop/src/core/broker/service.ts | 43 + apps/desktop/src/core/mcp/scout-mcp.ts | 24 + .../macos/Sources/Scout/ScoutCommsStore.swift | 128 ++- apps/macos/Sources/Scout/ScoutModels.swift | 40 +- apps/macos/Sources/Scout/ScoutRootView.swift | 782 ++++++++++++++---- .../Sources/Scout/ScoutSessionService.swift | 101 ++- packages/web/server/core/broker/service.ts | 44 + .../web/server/create-openscout-web-server.ts | 67 +- packages/web/server/image-blob-store.ts | 147 ++++ 9 files changed, 1165 insertions(+), 211 deletions(-) create mode 100644 packages/web/server/image-blob-store.ts diff --git a/apps/desktop/src/core/broker/service.ts b/apps/desktop/src/core/broker/service.ts index c15330ea..37b5c91e 100644 --- a/apps/desktop/src/core/broker/service.ts +++ b/apps/desktop/src/core/broker/service.ts @@ -1,5 +1,6 @@ import { mkdir, readFile, unlink, writeFile } from "node:fs/promises"; import { basename, join, resolve } from "node:path"; +import { randomUUID } from "node:crypto"; import { buildScoutReturnAddress as buildScoutReturnAddressRecord, @@ -30,6 +31,7 @@ import { type CollaborationPriority, type CollaborationRecord, type CollaborationWaitingOn, + type MessageAttachment, type MessageRecord, type ScoutInvocationLifecycle, type ScoutDeliverResponse, @@ -3144,12 +3146,52 @@ export async function sendScoutMessage(input: { }; } +/** Input shape for an attachment supplied by a caller (MCP). */ +export type OutgoingAttachmentInput = { + id?: string; + mediaType: string; + fileName?: string; + blobKey?: string; + url?: string; +}; + +/** + * Validate caller-supplied attachments and mint ids where absent. Drops any + * attachment lacking a media type or a way to fetch it (url/blobKey). Returns + * undefined when nothing usable remains, to keep the broker payload clean. + */ +export function normalizeOutgoingAttachments( + attachments: OutgoingAttachmentInput[] | undefined, +): MessageAttachment[] | undefined { + if (!attachments?.length) { + return undefined; + } + const normalized: MessageAttachment[] = []; + for (const attachment of attachments) { + const mediaType = attachment?.mediaType?.trim(); + const url = attachment?.url?.trim(); + const blobKey = attachment?.blobKey?.trim(); + if (!mediaType || (!url && !blobKey)) { + continue; + } + normalized.push({ + id: attachment.id?.trim() || `att-${randomUUID()}`, + mediaType, + fileName: attachment.fileName?.trim() || undefined, + url: url || undefined, + blobKey: blobKey || undefined, + }); + } + return normalized.length > 0 ? normalized : undefined; +} + export async function replyToScoutMessage(input: { senderId: string; body: string; conversationId: string; replyToMessageId: string; shouldSpeak?: boolean; + attachments?: OutgoingAttachmentInput[]; createdAtMs?: number; currentDirectory?: string; source?: string; @@ -3227,6 +3269,7 @@ export async function replyToScoutMessage(input: { class: conversation.kind === "system" ? "system" : "agent", body: input.body, speech: speechText ? { text: speechText } : undefined, + attachments: normalizeOutgoingAttachments(input.attachments), audience: notifiedActorIds.length > 0 ? { notify: notifiedActorIds, reason: "thread_reply" } : undefined, diff --git a/apps/desktop/src/core/mcp/scout-mcp.ts b/apps/desktop/src/core/mcp/scout-mcp.ts index bc23005c..bd1c93e8 100644 --- a/apps/desktop/src/core/mcp/scout-mcp.ts +++ b/apps/desktop/src/core/mcp/scout-mcp.ts @@ -44,6 +44,7 @@ import { sendScoutMessage, sendScoutMessageToAgentIds, replyToScoutMessage, + type OutgoingAttachmentInput, type ScoutManagedLocalSessionAttachment, updateScoutWorkItem, waitForScoutFlight, @@ -296,6 +297,23 @@ const mentionAgentIdsInputSchema = z .describe("Exact Scout agent ids to target directly when you already know them") .optional(); +const attachmentsInputSchema = z + .array( + z.object({ + mediaType: z + .string() + .describe("MIME type, e.g. image/png or image/jpeg"), + url: z + .string() + .describe("HTTP(S) URL where the attachment can be fetched"), + fileName: z.string().optional(), + }), + ) + .describe( + "Link-backed attachments (e.g. images). Each needs a mediaType and a fetchable url; agents should pass URLs they already have rather than uploading bytes.", + ) + .optional(); + export type ScoutMcpAgentCandidate = { agentId: string; label: string; @@ -442,6 +460,7 @@ type ScoutMcpDependencies = { conversationId: string; replyToMessageId: string; shouldSpeak?: boolean; + attachments?: OutgoingAttachmentInput[]; currentDirectory: string; source?: string; }) => Promise; @@ -2757,6 +2776,7 @@ function defaultScoutMcpDependencies( conversationId, replyToMessageId, shouldSpeak, + attachments, currentDirectory, source, }) => @@ -2766,6 +2786,7 @@ function defaultScoutMcpDependencies( conversationId, replyToMessageId, shouldSpeak, + attachments, currentDirectory, source, }), @@ -3243,6 +3264,7 @@ export function createScoutMcpServer(options: { conversationId: z.string().optional(), replyToMessageId: z.string().optional(), shouldSpeak: z.boolean().optional(), + attachments: attachmentsInputSchema, }), outputSchema: replyResultSchema, annotations: { @@ -3259,6 +3281,7 @@ export function createScoutMcpServer(options: { conversationId, replyToMessageId, shouldSpeak, + attachments, }) => { const resolvedCurrentDirectory = resolveToolCurrentDirectory( currentDirectory, @@ -3299,6 +3322,7 @@ export function createScoutMcpServer(options: { conversationId: resolvedConversationId, replyToMessageId: resolvedReplyToMessageId, shouldSpeak, + attachments, currentDirectory: resolvedCurrentDirectory, source: "scout-mcp", }); diff --git a/apps/macos/Sources/Scout/ScoutCommsStore.swift b/apps/macos/Sources/Scout/ScoutCommsStore.swift index 199048bb..0cf5346d 100644 --- a/apps/macos/Sources/Scout/ScoutCommsStore.swift +++ b/apps/macos/Sources/Scout/ScoutCommsStore.swift @@ -2,6 +2,7 @@ import Combine import Foundation #if os(macOS) import AppKit +import UniformTypeIdentifiers #endif @MainActor @@ -142,22 +143,39 @@ final class ScoutCommsStore: ObservableObject { } } - func send(_ body: String) async { + func send(_ body: String, images: [ScoutComposerImage] = []) async { let trimmed = body.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty, let selectedCId, !isSending else { return } + guard let selectedCId, !isSending, !trimmed.isEmpty || !images.isEmpty else { return } isSending = true defer { isSending = false } do { + // Upload images first and turn each into a link-backed attachment. + // We want the blob present before the message lands, so the agent's + // first fetch succeeds — so this completes before /api/send. + var attachments: [[String: String]] = [] + for image in images { + let uploaded = try await uploadImage(image) + attachments.append([ + "mediaType": uploaded.mediaType, + "url": uploaded.url, + "fileName": uploaded.fileName ?? image.fileName, + ]) + } + let url = ScoutWeb.baseURL().appending(path: "api/send") var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.httpBody = try JSONSerialization.data(withJSONObject: [ + var payload: [String: Any] = [ "body": trimmed, "cId": selectedCId, "conversationId": selectedCId, - ]) + ] + if !attachments.isEmpty { + payload["attachments"] = attachments + } + request.httpBody = try JSONSerialization.data(withJSONObject: payload) let (_, response) = try await URLSession.shared.data(for: request) guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { throw ScoutCommsError.sendFailed @@ -170,6 +188,24 @@ final class ScoutCommsStore: ObservableObject { } } + /// Push an image to the ephemeral blob route and get back a fetchable URL. + private func uploadImage(_ image: ScoutComposerImage) async throws -> ScoutBlobUploadResponse { + let url = ScoutWeb.baseURL().appending(path: "api/blobs") + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: [ + "data": image.data.base64EncodedString(), + "mediaType": image.mediaType, + "fileName": image.fileName, + ]) + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw ScoutCommsError.sendFailed + } + return try decoder.decode(ScoutBlobUploadResponse.self, from: data) + } + private func loadChannels(force: Bool) { if channelsTask != nil { return } if !force, pollTask == nil { return } @@ -309,6 +345,90 @@ enum ScoutCommsError: LocalizedError { } } +/// An image staged in the composer, ready to upload as an attachment. Holds +/// raw bytes (not an NSImage) so it stays Sendable across the upload task. +struct ScoutComposerImage: Identifiable, Sendable { + let id = UUID() + let data: Data + let mediaType: String + let fileName: String +} + +/// Response from POST /api/blobs — the link-backed attachment to send. +struct ScoutBlobUploadResponse: Decodable { + let url: String + let mediaType: String + let fileName: String? +} + +#if os(macOS) +/// Builds composer images from pasteboard, dropped files, or picked files, +/// sniffing the media type so the attachment carries a correct MIME. +enum ScoutImageIntake { + static func fromPasteboard() -> [ScoutComposerImage] { + let pb = NSPasteboard.general + // Copied image files (Finder, etc.) come through as file URLs. + if let urls = pb.readObjects( + forClasses: [NSURL.self], + options: [.urlReadingContentsConformToTypes: [UTType.image.identifier]] + ) as? [URL], !urls.isEmpty { + let images = urls.compactMap(fromFileURL) + if !images.isEmpty { return images } + } + // Raw PNG bytes (some apps put these directly on the pasteboard). + if let data = pb.data(forType: .png) { + return [ScoutComposerImage(data: data, mediaType: "image/png", fileName: "pasted-image.png")] + } + // Screenshots usually land as TIFF — re-encode to PNG. + if let tiff = pb.data(forType: .tiff), + let rep = NSBitmapImageRep(data: tiff), + let png = rep.representation(using: .png, properties: [:]) { + return [ScoutComposerImage(data: png, mediaType: "image/png", fileName: "pasted-image.png")] + } + return [] + } + + static func fromFileURL(_ url: URL) -> ScoutComposerImage? { + guard let data = try? Data(contentsOf: url) else { return nil } + let resolved = mediaType(forExtension: url.pathExtension.lowercased()) + ?? sniffMediaType(data) + guard let resolved, resolved.hasPrefix("image/") else { return nil } + return ScoutComposerImage(data: data, mediaType: resolved, fileName: url.lastPathComponent) + } + + private static func mediaType(forExtension ext: String) -> String? { + switch ext { + case "png": return "image/png" + case "jpg", "jpeg": return "image/jpeg" + case "gif": return "image/gif" + case "webp": return "image/webp" + case "heic": return "image/heic" + case "tiff", "tif": return "image/tiff" + case "bmp": return "image/bmp" + default: return nil + } + } + + private static func sniffMediaType(_ data: Data) -> String? { + let bytes = [UInt8](data.prefix(12)) + if bytes.count >= 4, bytes[0] == 0x89, bytes[1] == 0x50, bytes[2] == 0x4E, bytes[3] == 0x47 { + return "image/png" + } + if bytes.count >= 3, bytes[0] == 0xFF, bytes[1] == 0xD8, bytes[2] == 0xFF { + return "image/jpeg" + } + if bytes.count >= 3, bytes[0] == 0x47, bytes[1] == 0x49, bytes[2] == 0x46 { + return "image/gif" + } + if bytes.count >= 12, bytes[0] == 0x52, bytes[1] == 0x49, bytes[2] == 0x46, bytes[3] == 0x46, + bytes[8] == 0x57, bytes[9] == 0x45, bytes[10] == 0x42, bytes[11] == 0x50 { + return "image/webp" + } + return nil + } +} +#endif + enum ScoutWeb { private static let fallbackURL = URL(string: "http://127.0.0.1:3200")! diff --git a/apps/macos/Sources/Scout/ScoutModels.swift b/apps/macos/Sources/Scout/ScoutModels.swift index 8d1db991..b72ee7fb 100644 --- a/apps/macos/Sources/Scout/ScoutModels.swift +++ b/apps/macos/Sources/Scout/ScoutModels.swift @@ -29,13 +29,6 @@ enum ScoutSection: String, CaseIterable, Identifiable { enum ScoutChannelScope { case direct case shared - - var label: String { - switch self { - case .direct: return "Private" - case .shared: return "Shared" - } - } } struct ScoutChannel: Identifiable, Decodable, Sendable { @@ -66,6 +59,32 @@ struct ScoutChannel: Identifiable, Decodable, Sendable { return .shared } + /// A DM is named by its other participant(s); operator (you) is implied. + /// Agent-to-agent DMs (no operator) read as "agent1 <> agent2". + var directPeerLabel: String { + let peers = participantDisplayNames.filter { $0 != "Operator" } + if peers.count >= 2 { + return peers.joined(separator: " <> ") + } + let names = peers.isEmpty ? participantDisplayNames : peers + return names.joined(separator: ", ").nilIfEmpty ?? displayTitle + } + + /// Channel name without any leading "#" decoration. + var channelName: String { + displayTitle.trimmingCharacters(in: CharacterSet(charactersIn: "# ")).nilIfEmpty ?? displayTitle + } + + /// Title shown next to the type icon (the icon already conveys #/person). + var rowTitle: String { + scope == .direct ? directPeerLabel : channelName + } + + /// Self-describing title where there is no type icon (header, inspector). + var displayHandle: String { + scope == .direct ? directPeerLabel : "#\(channelName)" + } + var cIdShort: String { if cId.hasPrefix("c.") { return "cId \(String(cId.dropFirst(2).prefix(8)))" @@ -80,13 +99,6 @@ struct ScoutChannel: Identifiable, Decodable, Sendable { } var participantDisplayNames: [String] { - if scope == .direct { - let peer = agentName?.nilIfEmpty - ?? participantIds.first(where: { displayName(for: $0) != "Operator" }).map(displayName(for:)) - ?? displayTitle - return uniqueMemberNames(["Operator", peer]) - } - let names = participantIds.map(displayName(for:)) return uniqueMemberNames(names.isEmpty ? [displayTitle] : names) } diff --git a/apps/macos/Sources/Scout/ScoutRootView.swift b/apps/macos/Sources/Scout/ScoutRootView.swift index bbe7782e..215ce85e 100644 --- a/apps/macos/Sources/Scout/ScoutRootView.swift +++ b/apps/macos/Sources/Scout/ScoutRootView.swift @@ -5,6 +5,7 @@ import ScoutSharedUI import SwiftUI #if os(macOS) import AppKit +import UniformTypeIdentifiers #endif struct ScoutRootView: View { @@ -38,6 +39,9 @@ struct ScoutRootView: View { @State private var dismissedSuggestionSignature: String? @State private var conversationListResizePreviewWidth: CGFloat? @State private var composerInputFrame: CGRect = .zero + /// Images staged in the composer (pasted, dropped, or picked), uploaded as + /// link-backed attachments on send. + @State private var pendingImages: [ScoutComposerImage] = [] @State private var observeSidecarAgent: ScoutAgent? @State private var observeSidecarStagingWidth = ScoutObserveSidecarMetrics.peekWidth @State private var observeSidecarResizePreviewWidth: CGFloat? @@ -48,8 +52,8 @@ struct ScoutRootView: View { /// entry point (list "+", message context menu, agent inspector). @State private var sessionDraft: ScoutSessionDraft? @FocusState private var composerFocused: Bool - @AppStorage("scout.navigationSidebar.labelWidth") private var navigationSidebarLabelWidth = 142.0 - @AppStorage("scout.conversationList.width") private var conversationListWidth = 286.0 + @AppStorage("scout.navigationSidebar.labelWidth.v2") private var navigationSidebarLabelWidth = 88.0 + @AppStorage("scout.conversationList.width.v2") private var conversationListWidth = 224.0 @AppStorage("scout.inspector.width") private var inspectorWidth = 320.0 @AppStorage("scout.observeSidecar.width") private var observeSidecarWidth = Double(ScoutObserveSidecarMetrics.defaultWidth) @@ -77,7 +81,7 @@ struct ScoutRootView: View { isCompact: $railCompact, labelWidth: navigationSidebarLabelWidthBinding, accent: manifest.accent, - minLabelWidth: 112, + minLabelWidth: 76, maxLabelWidth: 260, collapseLabelWidth: 44, railHeader: { @@ -131,6 +135,9 @@ struct ScoutRootView: View { // restore any draft saved for the one we're entering. if let oldCId { drafts[oldCId] = draft } draft = newCId.flatMap { drafts[$0] } ?? "" + // Staged images are tied to the chat that was open; don't carry + // them into a different conversation. + pendingImages = [] } .overlay { if let sessionDraft { @@ -142,6 +149,10 @@ struct ScoutRootView: View { .transition(.opacity) } } + .overlay(alignment: .bottomLeading) { + ScoutDesignPreviewPanel() + .padding(HudSpacing.xl) + } } private func startNewConversation() { @@ -259,23 +270,14 @@ struct ScoutRootView: View { query: $store.channelQuery, filter: $channelFilter, channels: commsListChannels, - totalCount: store.channels.count, selectedCId: store.selectedCId, - width: CGFloat(conversationListWidth), + width: conversationListResizePreviewWidth ?? CGFloat(conversationListWidth), onNewConversation: { startNewConversation() } ) { channel in store.selectChannel(channel.cId) } .overlay(alignment: .trailing) { ZStack(alignment: .trailing) { - if let conversationListResizePreviewWidth { - Rectangle() - .fill(HudPalette.accent.opacity(0.62)) - .frame(width: HudStrokeWidth.standard) - .offset(x: conversationListResizePreviewWidth - CGFloat(conversationListWidth)) - .allowsHitTesting(false) - } - ScoutConversationResizeHandle( width: conversationListWidthBinding, previewWidth: $conversationListResizePreviewWidth, @@ -302,41 +304,20 @@ struct ScoutRootView: View { } } + // One clean line: just the conversation's handle. The cId and participant + // strip that used to ride a second row are redundant with the inspector + // card (which lists members + cId), so they're gone here. private var chatHeader: some View { - ScoutColumnHeader(horizontalPadding: HudSpacing.huge) { - Text(store.selectedChannel?.displayTitle ?? "Scout") - .font(HudFont.ui(22, weight: .semibold)) + HStack(spacing: HudSpacing.md) { + Text(store.selectedChannel?.displayHandle ?? "Scout") + .font(HudFont.ui(18, weight: .semibold)) .foregroundStyle(HudPalette.ink) .lineLimit(1) - } secondary: { - HStack(spacing: HudSpacing.md) { - if let channel = store.selectedChannel { - HudBadge(channel.scope.label, tint: channel.scope == .direct ? HudPalette.statusInfo : HudPalette.statusOk) - HudBadge(channel.cIdShort, tint: HudPalette.muted) - ScoutMemberStrip(members: selectedChannelMembers) { agent in - previewAgent(agent) - } - } else { - HudBadge("No channel", tint: HudPalette.muted) - } - } - } trailing: { - HStack(spacing: HudSpacing.xl) { - if let agent = store.selectedAgent { - HudButton("Agent", icon: "person.crop.circle", style: .secondary) { - previewAgent(agent) - } - } - - HudButton("Open Web", icon: "safari", style: .ghost) { - if let cId = store.selectedCId { - ScoutWeb.open(path: "/c/\(cId)") - } else { - ScoutWeb.open(path: "/messages") - } - } - } + .truncationMode(.tail) + Spacer(minLength: 0) } + .padding(.horizontal, HudSpacing.huge) + .frame(height: 42, alignment: .center) .background(ScoutDesign.bg) } @@ -412,6 +393,9 @@ struct ScoutRootView: View { private var composer: some View { VStack(alignment: .leading, spacing: HudSpacing.md) { + if !pendingImages.isEmpty { + composerAttachmentStrip + } HStack(alignment: .top, spacing: HudSpacing.md) { composerInputWell @@ -422,6 +406,9 @@ struct ScoutRootView: View { .transition(.opacity) } + composerAttachButton + .padding(.top, HudSpacing.xs) + ScoutMicButton(box: 34, glyph: 15, action: toggleDictation) .padding(.top, HudSpacing.xs) @@ -521,6 +508,11 @@ struct ScoutRootView: View { .focused($composerFocused) .disabled(store.selectedCId == nil || store.isSending) .onKeyPress(phases: .down) { press in + // Intercept ⌘V only when the pasteboard holds an image + // (e.g. a screenshot); otherwise let text paste through. + if press.characters == "v", press.modifiers.contains(.command) { + return addImagesFromPasteboard() ? .handled : .ignored + } if press.key == .return { if applySelectedSuggestion() { return .handled } if press.modifiers.contains(.shift) { @@ -583,6 +575,96 @@ struct ScoutRootView: View { x: 0, y: 3 ) + .dropDestination(for: URL.self) { urls, _ in + addImages(from: urls) + } + } + + // MARK: - Composer attachments + + private var composerAttachButton: some View { + Button(action: presentImagePicker) { + Image(systemName: "paperclip") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(HudPalette.muted) + .frame(width: 34, height: 34) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help("Attach image") + .disabled(store.selectedCId == nil || store.isSending) + } + + private var composerAttachmentStrip: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: HudSpacing.sm) { + ForEach(pendingImages) { image in + composerAttachmentChip(image) + } + } + .padding(.horizontal, HudSpacing.xs) + .padding(.vertical, 2) + } + } + + private func composerAttachmentChip(_ image: ScoutComposerImage) -> some View { + ZStack(alignment: .topTrailing) { + Group { + if let nsImage = NSImage(data: image.data) { + Image(nsImage: nsImage) + .resizable() + .aspectRatio(contentMode: .fill) + } else { + Image(systemName: "photo") + .foregroundStyle(HudPalette.muted) + } + } + .frame(width: 52, height: 52) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(ScoutDesign.hairlineStrong, lineWidth: HudStrokeWidth.thin) + ) + + Button { + pendingImages.removeAll { $0.id == image.id } + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 14)) + .foregroundStyle(.white, .black.opacity(0.55)) + } + .buttonStyle(.plain) + .help("Remove attachment") + .offset(x: 5, y: -5) + } + } + + /// Stage images pulled from the system pasteboard. Returns false when the + /// pasteboard holds no image (so a normal text paste can proceed). + private func addImagesFromPasteboard() -> Bool { + guard store.selectedCId != nil, !store.isSending else { return false } + let images = ScoutImageIntake.fromPasteboard() + guard !images.isEmpty else { return false } + pendingImages.append(contentsOf: images) + return true + } + + @discardableResult + private func addImages(from urls: [URL]) -> Bool { + let images = urls.compactMap(ScoutImageIntake.fromFileURL) + guard !images.isEmpty else { return false } + pendingImages.append(contentsOf: images) + return true + } + + private func presentImagePicker() { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = true + panel.canChooseDirectories = false + panel.canChooseFiles = true + panel.allowedContentTypes = [.image] + guard panel.runModal() == .OK else { return } + addImages(from: panel.urls) } private var composerWellFill: Color { @@ -600,14 +682,14 @@ struct ScoutRootView: View { } private var composerPlaceholder: String { - if let title = store.selectedChannel?.displayTitle, !title.isEmpty { + if let title = store.selectedChannel?.displayHandle, !title.isEmpty { return "Message \(title)" } return "Message" } private var composerCanSend: Bool { - !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + (!draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !pendingImages.isEmpty) && store.selectedCId != nil && !store.isSending } @@ -638,6 +720,10 @@ struct ScoutRootView: View { if isDictating { return voxStatusLine } if let reason = voxUnavailableReason { return reason } if store.isSending { return "Sending..." } + if !pendingImages.isEmpty { + let noun = pendingImages.count == 1 ? "image" : "images" + return "\(pendingImages.count) \(noun) attached · ↵ send · ⌘V or ⊕ to add" + } if draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return "Type / for commands · @ for agents · session: for sessions" } @@ -690,12 +776,14 @@ struct ScoutRootView: View { private func sendDraft() { let body = draft guard composerCanSend else { return } + let images = pendingImages draft = "" + pendingImages = [] if let cId = store.selectedCId { drafts[cId] = nil } followLatest = true composerFocused = true clearSuggestions(resetDismissedSignature: true) - Task { await store.send(body) } + Task { await store.send(body, images: images) } } private func toggleDictation() { @@ -716,6 +804,9 @@ struct ScoutRootView: View { } private func spliceDictatedFinal(_ text: String) { + // While the New-conversation composer is up it owns dictation; don't + // also splice into the (hidden) chat composer behind it. + guard sessionDraft == nil else { return } let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } draft = ScoutDictationBuffer.appending(trimmed, to: draft) @@ -869,12 +960,13 @@ struct ScoutRootView: View { } private var inspectorHeader: some View { - HStack(spacing: HudSpacing.md) { - HudSectionLabel(section == .tail ? "Tail" : (store.selectedAgent == nil ? "Context" : "Agent")) + let multiAgent = channelAgentMembers.count >= 2 + return HStack(spacing: HudSpacing.md) { + HudSectionLabel(section == .tail ? "Tail" : (multiAgent ? "Agents" : (store.selectedAgent == nil ? "Context" : "Agent"))) Spacer() if section == .tail { HudBadge(tail.isFollowing ? "Live" : "Paused", tint: tail.isFollowing ? HudPalette.statusOk : HudPalette.muted, dot: tail.isFollowing) - } else if let agent = store.selectedAgent { + } else if !multiAgent, let agent = store.selectedAgent { HudBadge(agent.state.label, tint: agent.state.tint, dot: true) } } @@ -975,7 +1067,16 @@ struct ScoutRootView: View { } } - if let agent = store.selectedAgent { + let members = channelAgentMembers + if members.count >= 2 { + ScoutAgentCardStack( + agents: members, + selectedChannel: store.selectedChannel, + openObserve: { observeAgent($0) }, + openProfile: { ScoutWeb.open(path: "/agents/\($0.id)?tab=profile") }, + startSession: { agent in startSessionWithAgent(agent, mode: .fresh) } + ) + } else if let agent = store.selectedAgent { ScoutAgentInspector( agent: agent, selectedChannel: store.selectedChannel, @@ -1027,7 +1128,7 @@ struct ScoutRootView: View { Binding { CGFloat(navigationSidebarLabelWidth) } set: { nextWidth in - navigationSidebarLabelWidth = Double(min(max(nextWidth, 112), 260)) + navigationSidebarLabelWidth = Double(min(max(nextWidth, 76), 260)) } } @@ -1066,6 +1167,18 @@ struct ScoutRootView: View { } } + /// Distinct, resolved agents participating in the selected channel. + private var channelAgentMembers: [ScoutAgent] { + var seen = Set() + var result: [ScoutAgent] = [] + for member in selectedChannelMembers { + guard let agent = member.agent, !seen.contains(agent.id) else { continue } + seen.insert(agent.id) + result.append(agent) + } + return result + } + private func filterChannels(_ channels: [ScoutChannel]) -> [ScoutChannel] { let trimmed = store.channelQuery.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return channels } @@ -1264,7 +1377,7 @@ enum ScoutDesign { static let columnHeaderPrimaryRowHeight: CGFloat = 28 static let columnHeaderLineGap: CGFloat = 2 static let columnHeaderTrailingTopOffset: CGFloat = 2 - static let conversationListWidthRange: ClosedRange = 230...430 + static let conversationListWidthRange: ClosedRange = 188...440 static let inspectorWidthRange: ClosedRange = 260...520 static let conversationResizeHandleWidth: CGFloat = 12 @@ -1319,8 +1432,8 @@ private enum ScoutChannelFilter: String, CaseIterable, Identifiable { var title: String { switch self { case .all: return "All" - case .direct: return "Private" - case .shared: return "Shared" + case .direct: return "Direct" + case .shared: return "Channels" } } @@ -1390,12 +1503,13 @@ private struct ScoutConversationListBar: View { @Binding var query: String @Binding var filter: ScoutChannelFilter let channels: [ScoutChannel] - let totalCount: Int let selectedCId: String? let width: CGFloat let onNewConversation: () -> Void let select: (ScoutChannel) -> Void + @AppStorage(ScoutDesignPreview.glow) private var glowOn = false + var body: some View { VStack(spacing: 0) { header @@ -1406,47 +1520,56 @@ private struct ScoutConversationListBar: View { } .frame(width: width) .frame(maxHeight: .infinity) - .background(ScoutDesign.chrome) + .background { + ZStack { + ScoutDesign.chrome + if glowOn { ScoutAmbientGlow() } + } + } } private var header: some View { - ScoutColumnHeader(horizontalPadding: HudSpacing.xxl) { + HStack(spacing: HudSpacing.md) { Text("Conversations") - .font(HudFont.ui(14, weight: .semibold)) + .font(HudFont.ui(13, weight: .semibold)) .foregroundStyle(HudPalette.ink) .lineLimit(1) - } secondary: { - Text("\(totalCount) cIds") - .font(HudFont.mono(9)) - .foregroundStyle(HudPalette.dim) - .lineLimit(1) - } trailing: { - HStack(spacing: HudSpacing.md) { - if isLoading { - ProgressView() - .controlSize(.small) - } else { - HudBadge("\(channels.count)", tint: HudPalette.muted) - } - Button(action: onNewConversation) { - Image(systemName: "plus") - .font(HudFont.ui(12, weight: .semibold)) + + if isLoading { + ProgressView() + .controlSize(.small) + } + + Spacer(minLength: 0) + + Button(action: onNewConversation) { + HStack(spacing: HudSpacing.xs) { + Image(systemName: "square.and.pencil") + .font(HudFont.ui(11, weight: .semibold)) + Text("New") + .font(HudFont.ui(11, weight: .semibold)) } - .buttonStyle(.plain) .foregroundStyle(HudPalette.accent) - .frame(width: 26, height: 26) - .contentShape(Rectangle()) - .help("New conversation") + .padding(.horizontal, HudSpacing.md) + .padding(.vertical, HudSpacing.xs) + .background(Capsule().fill(HudSurface.tintGhost(HudPalette.accent))) + .overlay(Capsule().stroke(HudSurface.tintBorder(HudPalette.accent), lineWidth: 1)) + .contentShape(Capsule()) } + .buttonStyle(.plain) + .help("New conversation") } + .padding(.horizontal, HudSpacing.xxl) + .frame(height: 42, alignment: .center) } private var controls: some View { - VStack(spacing: HudSpacing.lg) { + HStack(spacing: HudSpacing.md) { HudField("Search", text: $query, icon: "magnifyingglass") ScoutConversationFilterControl(selection: $filter) } .padding(.horizontal, HudSpacing.xxl) + .padding(.top, HudSpacing.md) .padding(.bottom, HudSpacing.xxl) } @@ -1489,37 +1612,33 @@ private struct ScoutConversationListBar: View { } } +/// Compact icon-only scope toggle. Tucked onto the search row rather than +/// taking a full row of its own — the active scope reads from the accent fill; +/// each segment names itself on hover. private struct ScoutConversationFilterControl: View { @Binding var selection: ScoutChannelFilter var body: some View { - HStack(spacing: HudSpacing.xs) { + HStack(spacing: 2) { ForEach(ScoutChannelFilter.allCases) { option in Button { selection = option } label: { - HStack(spacing: HudSpacing.xs) { - Image(systemName: option.icon) - .font(HudFont.ui(10, weight: .semibold)) - Text(option.title) - .font(HudFont.mono(9, weight: .semibold)) - } - .foregroundStyle(selection == option ? HudPalette.ink : HudPalette.muted) - .frame(maxWidth: .infinity) - .frame(height: 26) - .background( - RoundedRectangle(cornerRadius: HudRadius.standard, style: .continuous) - .fill(selection == option ? HudSurface.selected(HudPalette.accent) : Color.clear) - ) - .overlay( - RoundedRectangle(cornerRadius: HudRadius.standard, style: .continuous) - .stroke(selection == option ? HudSurface.tintBorder(HudPalette.accent) : Color.clear, lineWidth: HudStrokeWidth.thin) - ) + Image(systemName: option.icon) + .font(HudFont.ui(11, weight: .semibold)) + .foregroundStyle(selection == option ? HudPalette.ink : HudPalette.muted) + .frame(width: 30, height: 24) + .background( + RoundedRectangle(cornerRadius: HudRadius.standard - 2, style: .continuous) + .fill(selection == option ? HudSurface.selected(HudPalette.accent) : Color.clear) + ) + .contentShape(Rectangle()) } .buttonStyle(.plain) + .help(option.title) } } - .padding(3) + .padding(2) .background(RoundedRectangle(cornerRadius: HudRadius.standard, style: .continuous).fill(HudSurface.inset)) .overlay(RoundedRectangle(cornerRadius: HudRadius.standard, style: .continuous).stroke(ScoutDesign.hairline, lineWidth: HudStrokeWidth.thin)) } @@ -1531,6 +1650,7 @@ private struct ScoutConversationRow: View { let action: () -> Void @State private var isHovering = false + @AppStorage(ScoutDesignPreview.accents) private var accentsOn = false var body: some View { Button(action: action) { @@ -1543,7 +1663,7 @@ private struct ScoutConversationRow: View { VStack(alignment: .leading, spacing: HudSpacing.xs) { HStack(alignment: .firstTextBaseline, spacing: HudSpacing.sm) { - Text(channel.displayTitle) + Text(channel.rowTitle) .font(HudFont.ui(13, weight: isSelected ? .semibold : .medium)) .foregroundStyle(HudPalette.ink) .lineLimit(1) @@ -1562,9 +1682,6 @@ private struct ScoutConversationRow: View { .lineLimit(2) HStack(spacing: HudSpacing.sm) { - Text(channel.scope.label.uppercased()) - .font(HudFont.mono(8, weight: .semibold)) - .foregroundStyle(channel.scope == .direct ? HudPalette.statusInfo : HudPalette.statusOk) Text(channel.cIdShort) .font(HudFont.mono(8)) .foregroundStyle(HudPalette.dim) @@ -1583,9 +1700,21 @@ private struct ScoutConversationRow: View { .frame(maxWidth: .infinity, alignment: .leading) .background(rowBackground) .overlay(alignment: .leading) { - Rectangle() - .fill(isSelected ? HudPalette.accent : Color.clear) - .frame(width: 2) + if isSelected { + ZStack(alignment: .leading) { + if accentsOn { + // Soft bloom behind the rule so selection feels lit. + Rectangle() + .fill(HudPalette.accent) + .frame(width: 3) + .blur(radius: 4) + .opacity(0.85) + } + Rectangle() + .fill(HudPalette.accent) + .frame(width: 2) + } + } } .overlay(alignment: .bottom) { HudDivider(color: ScoutDesign.hairline) @@ -1827,7 +1956,7 @@ private struct ScoutCompactChannelRow: View { .frame(width: 20) VStack(alignment: .leading, spacing: 2) { - Text(channel.displayTitle) + Text(channel.rowTitle) .font(HudFont.ui(12, weight: isSelected ? .semibold : .medium)) .foregroundStyle(HudPalette.ink) .lineLimit(1) @@ -2216,7 +2345,7 @@ private extension MessageSuggestionAgent { } } -private struct ScoutDictationPreview: View { +struct ScoutDictationPreview: View { let text: String private var displayText: String { @@ -2333,7 +2462,7 @@ private struct ScoutWaveform: View { } } -private struct ScoutMicButton: View { +struct ScoutMicButton: View { let box: CGFloat let glyph: CGFloat let action: () -> Void @@ -2543,6 +2672,160 @@ private struct ScoutAgentCard: View { } } +// MARK: - Design preview (toggleable look-and-feel experiments) + +/// UserDefaults keys for the three live "make it awesomer" experiments. Each is +/// off by default so the app ships at its current baseline; the floating +/// `ScoutDesignPreviewPanel` flips them so before/after is one click apart. +/// Every consumer reads the same key via `@AppStorage`, so a flip re-renders +/// all affected surfaces at once. +enum ScoutDesignPreview { + static let depth = "scout.design.preview.depth" + static let accents = "scout.design.preview.accents" + static let glow = "scout.design.preview.glow" +} + +/// "Glow": the conversation list reads as backlit — light leaks in around the +/// panel's edges (rim light), as if a source sits behind it, brightest at the +/// top where the light originates. The interior stays dark so text never +/// competes with it. Same single white light source as Depth. +private struct ScoutAmbientGlow: View { + var body: some View { + ZStack { + // Rim light bleeding in from behind the panel's edges. The stroke's + // outward blur is clipped at the panel bound, leaving an inner halo. + Rectangle() + .stroke(Color.white.opacity(0.12), lineWidth: 2) + .blur(radius: 11) + + // The source sits behind-and-above: a brighter bloom hugging the top. + LinearGradient( + colors: [Color.white.opacity(0.10), Color.clear], + startPoint: .top, + endPoint: UnitPoint(x: 0.5, y: 0.22) + ) + } + .allowsHitTesting(false) + } +} + +/// "Depth": one consistent light source — a 1px top-edge highlight that fades +/// downward plus a soft, wide shadow — so graphite cards read as lifted objects +/// rather than flat outlined boxes. No-op when the flag is off. +private struct ScoutDepthModifier: ViewModifier { + var radius: CGFloat = HudRadius.card + @AppStorage(ScoutDesignPreview.depth) private var depthOn = false + + func body(content: Content) -> some View { + content + .overlay { + if depthOn { + RoundedRectangle(cornerRadius: radius, style: .continuous) + .strokeBorder( + LinearGradient( + colors: [Color.white.opacity(0.10), Color.white.opacity(0.0)], + startPoint: .top, + endPoint: .bottom + ), + lineWidth: 1 + ) + .allowsHitTesting(false) + } + } + .shadow(color: Color.black.opacity(depthOn ? 0.35 : 0), + radius: depthOn ? 14 : 0, x: 0, y: depthOn ? 6 : 0) + } +} + +extension View { + func scoutDepth(radius: CGFloat = HudRadius.card) -> some View { + modifier(ScoutDepthModifier(radius: radius)) + } +} + +/// "Accents": a section eyebrow that grows a small hanging accent tick when the +/// flag is on — an editorial marker that makes labels feel deliberate. +private struct ScoutEyebrow: View { + let text: String + @AppStorage(ScoutDesignPreview.accents) private var accentsOn = false + + var body: some View { + HStack(spacing: HudSpacing.sm) { + if accentsOn { + RoundedRectangle(cornerRadius: 0.5, style: .continuous) + .fill(HudPalette.accent) + .frame(width: 2, height: 9) + } + HudSectionLabel(text) + } + } +} + +/// Tiny floating control to flip the three look-and-feel experiments on/off +/// live, so before/after is one click apart. Collapses to a single chip. +private struct ScoutDesignPreviewPanel: View { + @AppStorage(ScoutDesignPreview.depth) private var depth = false + @AppStorage(ScoutDesignPreview.accents) private var accents = false + @AppStorage(ScoutDesignPreview.glow) private var glow = false + @AppStorage("scout.design.preview.panelExpanded") private var expanded = true + + var body: some View { + VStack(alignment: .leading, spacing: expanded ? HudSpacing.md : 0) { + Button { + withAnimation(.easeOut(duration: 0.16)) { expanded.toggle() } + } label: { + HStack(spacing: HudSpacing.sm) { + Image(systemName: "sparkles") + .font(HudFont.ui(10, weight: .semibold)) + .foregroundStyle(anyOn ? HudPalette.accent : HudPalette.muted) + Text("DESIGN") + .font(HudFont.mono(9, weight: .bold)) + .tracking(1.5) + .foregroundStyle(HudPalette.muted) + Spacer(minLength: HudSpacing.lg) + Image(systemName: expanded ? "chevron.down" : "chevron.up") + .font(HudFont.ui(8, weight: .bold)) + .foregroundStyle(HudPalette.dim) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help("Toggle design experiments") + + if expanded { + toggleRow("Depth", isOn: $depth) + toggleRow("Accents", isOn: $accents) + toggleRow("Glow", isOn: $glow) + } + } + .padding(.horizontal, HudSpacing.lg) + .padding(.vertical, HudSpacing.md) + .frame(width: expanded ? 168 : nil) + .background(RoundedRectangle(cornerRadius: HudRadius.card, style: .continuous).fill(HudPalette.surface)) + .overlay(RoundedRectangle(cornerRadius: HudRadius.card, style: .continuous).stroke(HudHairline.standard, lineWidth: 1)) + .shadow(color: Color.black.opacity(0.4), radius: 16, x: 0, y: 8) + } + + private var anyOn: Bool { depth || accents || glow } + + private func toggleRow(_ title: String, isOn: Binding) -> some View { + HStack(spacing: HudSpacing.sm) { + Text(title) + .font(HudFont.ui(11, weight: .medium)) + .foregroundStyle(HudPalette.ink) + Spacer(minLength: HudSpacing.md) + Toggle("", isOn: isOn) + .labelsHidden() + .toggleStyle(.switch) + .controlSize(.mini) + .tint(HudPalette.accent) + } + } +} + +/// One self-contained agent card: identity, runtime, workspace, optional +/// special skills, and the per-agent actions all live inside a single card so +/// the agent reads as one cohesive concept rather than a stack of fragments. private struct ScoutAgentInspector: View { let agent: ScoutAgent let selectedChannel: ScoutChannel? @@ -2550,76 +2833,227 @@ private struct ScoutAgentInspector: View { let openProfile: () -> Void let startSession: (ScoutSessionDraft.Mode) -> Void + /// Conversation / work-requests / result-delivery / observe are table + /// stakes every agent has — not "abilities". Only surface skills beyond + /// that baseline, when an agent actually loads them. + private var specialCapabilities: [String] { + let baseline: Set = ["chat", "invoke", "deliver", "observe"] + return agent.capabilities.filter { + !baseline.contains($0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()) + } + } + + /// The agent's live harness session, if one is bound. Observe lives with + /// this — you observe a *session* — so it (and Observe) only appear when + /// there's something to watch. + private var sessionId: String? { agent.harnessSessionId?.nilIfEmpty } + var body: some View { - VStack(alignment: .leading, spacing: HudSpacing.xl) { - HudCard { - VStack(alignment: .leading, spacing: HudSpacing.md) { + HudCard { + VStack(alignment: .leading, spacing: HudSpacing.lg) { + identity + HudDivider(color: ScoutDesign.hairline) + runtime + HudDivider(color: ScoutDesign.hairline) + workspace + if sessionId != nil { + HudDivider(color: ScoutDesign.hairline) + sessionSection + } + if !specialCapabilities.isEmpty { + HudDivider(color: ScoutDesign.hairline) + skills + } + ScoutNewSessionLink(action: { startSession(.fresh) }) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .scoutDepth() + } + + /// Clickable identity header → profile. State rides the presence dot on + /// the avatar (no "AVAILABLE" tag); Observe now lives in the Session block. + private var identity: some View { + Button(action: openProfile) { + HStack(alignment: .top, spacing: HudSpacing.md) { + avatar + VStack(alignment: .leading, spacing: 2) { Text(agent.displayName) - .font(HudFont.ui(18, weight: .semibold)) + .font(HudFont.ui(16, weight: .semibold)) .foregroundStyle(HudPalette.ink) + .lineLimit(1) Text(agent.id) - .font(HudFont.mono(10)) + .font(HudFont.mono(9)) .foregroundStyle(HudPalette.dim) - .textSelection(.enabled) - .fixedSize(horizontal: false, vertical: true) - HudBadge(agent.state.label, tint: agent.state.tint, dot: true) + .lineLimit(1) + .truncationMode(.middle) } - .frame(maxWidth: .infinity, alignment: .leading) + Spacer(minLength: 0) } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help("Open \(agent.displayName)'s profile") + } - HudCard { - VStack(alignment: .leading, spacing: HudSpacing.md) { - HudSectionLabel("Runtime") - HudKVRow("Role", value: agent.roleLabel) - HudKVRow("Harness", value: agent.harness?.nilIfEmpty ?? "—") - HudKVRow("Transport", value: agent.transport?.nilIfEmpty ?? "—") - ScoutAgentModelRow(agent: agent) - HudKVRow("Node", value: agent.nodeName?.nilIfEmpty ?? "—") - } + private var avatar: some View { + Text(String(agent.displayName.first.map(String.init) ?? "?").uppercased()) + .font(HudFont.mono(11, weight: .bold)) + .foregroundStyle(HudPalette.bg) + .frame(width: 30, height: 30) + .background(Circle().fill(HudPalette.muted)) + .overlay(alignment: .bottomTrailing) { + Circle() + .fill(agent.state.tint) + .frame(width: 9, height: 9) + .overlay(Circle().stroke(HudPalette.surface, lineWidth: 2)) } + } - HudCard { - VStack(alignment: .leading, spacing: HudSpacing.md) { - HudSectionLabel("Workspace") - HudKVRow("Branch", value: agent.branchLabel) - HudKVRow("Path", value: agent.workspace) - if let selectedChannel { - HudKVRow("cId", value: selectedChannel.cIdShort) - } - } + private var runtime: some View { + VStack(alignment: .leading, spacing: HudSpacing.sm) { + ScoutEyebrow(text: "Runtime") + HudKVRow("Role", value: agent.roleLabel) + HudKVRow("Harness", value: agent.harness?.nilIfEmpty ?? "—") + HudKVRow("Transport", value: agent.transport?.nilIfEmpty ?? "—") + ScoutAgentModelRow(agent: agent) + HudKVRow("Node", value: agent.nodeName?.nilIfEmpty ?? "—") + } + } + + private var workspace: some View { + VStack(alignment: .leading, spacing: HudSpacing.sm) { + ScoutEyebrow(text: "Workspace") + HudKVRow("Branch", value: agent.branchLabel) + HudKVRow("Path", value: agent.workspace) + if let selectedChannel { + HudKVRow("cId", value: selectedChannel.cIdShort) } + } + } - if !agent.capabilities.isEmpty { - HudCard { - VStack(alignment: .leading, spacing: HudSpacing.md) { - HudSectionLabel("Abilities") - ScoutAgentAbilityList(capabilities: agent.capabilities) - } - } + /// Live session block — the only home for Observe. The label and Observe + /// share the top line; the session's real id and last-activity sit below. + private var sessionSection: some View { + VStack(alignment: .leading, spacing: HudSpacing.sm) { + HStack(alignment: .center, spacing: HudSpacing.sm) { + ScoutEyebrow(text: "Session") + Spacer(minLength: HudSpacing.sm) + ScoutObserveChip(action: openObserve) + } + if let sessionId { + HudKVRow("id", value: Self.shortSession(sessionId)) } + HudKVRow("Active", value: agent.updatedLabel) + } + } - HudCard { - VStack(alignment: .leading, spacing: HudSpacing.md) { - HudSectionLabel("New session") - HStack { - HudButton("New session", icon: "plus.bubble", style: .secondary) { - startSession(.fresh) - } - if agent.harnessSessionId?.nilIfEmpty != nil { - HudButton("Continue", icon: "arrow.uturn.forward", style: .secondary) { - startSession(.continueContext) - } - } - } - } + /// Real session ids are opaque (UUID-ish); show head + tail like the tail + /// view does, so it reads as an id rather than a relay label. + private static func shortSession(_ id: String) -> String { + let trimmed = id.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.count > 14 else { return trimmed } + return "\(trimmed.prefix(8))…\(trimmed.suffix(4))" + } + + private var skills: some View { + VStack(alignment: .leading, spacing: HudSpacing.sm) { + ScoutEyebrow(text: "Skills") + ScoutAgentAbilityList(capabilities: specialCapabilities) + } + } +} + +/// Quiet-but-clearly-clickable Observe chip. At rest it reads as a button +/// (hairline border + faint inset), warming to observe-green on hover — +/// present without out-shouting the agent identity above it. +private struct ScoutObserveChip: View { + let action: () -> Void + @State private var hovering = false + + var body: some View { + Button(action: action) { + HStack(spacing: HudSpacing.xs) { + Image(systemName: "eye") + .font(HudFont.ui(10, weight: .semibold)) + Text("OBSERVE") + .font(HudFont.mono(9, weight: .semibold)) + } + .foregroundStyle(hovering ? HudPalette.statusOk : HudPalette.muted) + .padding(.horizontal, HudSpacing.sm) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 5, style: .continuous) + .fill(hovering ? HudPalette.statusOk.opacity(0.12) : HudSurface.inset) + ) + .overlay( + RoundedRectangle(cornerRadius: 5, style: .continuous) + .stroke(hovering ? HudPalette.statusOk.opacity(0.5) : Color.white.opacity(0.22), lineWidth: HudStrokeWidth.thin) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { hovering = $0 } + .help("Observe") + } +} + +/// Unemphasized "New session" link — muted at rest, accent on hover, since +/// continuing a conversation is already the default action in the sidebar. +private struct ScoutNewSessionLink: View { + let action: () -> Void + @State private var hovering = false + + var body: some View { + Button(action: action) { + HStack(spacing: HudSpacing.xs) { + Image(systemName: "plus") + .font(HudFont.ui(9, weight: .bold)) + Text("NEW SESSION") + .font(HudFont.mono(10, weight: .semibold)) } + .foregroundStyle(hovering ? HudPalette.accent : HudPalette.muted) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { hovering = $0 } + } +} + +/// Lays out every agent in a DM as its own card — side by side when the column +/// is wide enough, otherwise stacked. +private struct ScoutAgentCardStack: View { + let agents: [ScoutAgent] + let selectedChannel: ScoutChannel? + let openObserve: (ScoutAgent) -> Void + let openProfile: (ScoutAgent) -> Void + let startSession: (ScoutAgent) -> Void - HStack { - HudButton("Observe", icon: "eye", style: .primary(.green), action: openObserve) - HudButton("Profile", icon: "person.text.rectangle", style: .secondary, action: openProfile) + var body: some View { + ViewThatFits(in: .horizontal) { + HStack(alignment: .top, spacing: HudSpacing.lg) { + ForEach(agents) { agent in + card(for: agent) + .frame(minWidth: 230, maxWidth: .infinity, alignment: .top) + } + } + VStack(spacing: HudSpacing.lg) { + ForEach(agents) { agent in + card(for: agent) + } } } } + + private func card(for agent: ScoutAgent) -> some View { + ScoutAgentInspector( + agent: agent, + selectedChannel: selectedChannel, + openObserve: { openObserve(agent) }, + openProfile: { openProfile(agent) }, + startSession: { _ in startSession(agent) } + ) + } } private struct ScoutAgentPreviewPanel: View { @@ -2694,20 +3128,15 @@ private struct ScoutAgentModelRow: View { let agent: ScoutAgent var body: some View { - VStack(alignment: .leading, spacing: HudSpacing.xs) { - HudKVRow( - "Model", - value: agent.modelDisplayValue, - valueColor: agent.model?.nilIfEmpty == nil ? HudPalette.muted : HudPalette.ink - ) - if let note = agent.modelDisplayNote { - Text(note) - .font(HudFont.mono(HudTextSize.xxs)) - .foregroundStyle(HudPalette.dim) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .trailing) - } - } + // An unset model is conveyed by the muted "Default" value alone; the + // "why" lives in a tooltip rather than a wrapping sentence that ate two + // lines of the card. + HudKVRow( + "Model", + value: agent.modelDisplayValue, + valueColor: agent.model?.nilIfEmpty == nil ? HudPalette.muted : HudPalette.ink + ) + .help(agent.modelDisplayNote ?? agent.modelDisplayValue) } } @@ -2869,11 +3298,10 @@ private struct ScoutChannelInspector: View { VStack(alignment: .leading, spacing: HudSpacing.xl) { HudCard { VStack(alignment: .leading, spacing: HudSpacing.md) { - HudSectionLabel("Channel") - Text(channel.displayTitle) + HudSectionLabel(channel.scope == .direct ? "Direct message" : "Channel") + Text(channel.displayHandle) .font(HudFont.ui(18, weight: .semibold)) .foregroundStyle(HudPalette.ink) - HudBadge(channel.scope.label, tint: channel.scope == .direct ? HudPalette.statusInfo : HudPalette.statusOk) } } diff --git a/apps/macos/Sources/Scout/ScoutSessionService.swift b/apps/macos/Sources/Scout/ScoutSessionService.swift index 3505e7aa..4c60dddd 100644 --- a/apps/macos/Sources/Scout/ScoutSessionService.swift +++ b/apps/macos/Sources/Scout/ScoutSessionService.swift @@ -153,6 +153,7 @@ struct ScoutSessionComposer: View { @State private var isSubmitting = false @State private var errorText: String? @FocusState private var instructionsFocused: Bool + @ObservedObject private var vox = ScoutVoxService.shared init( draft: ScoutSessionDraft, @@ -175,6 +176,49 @@ struct ScoutSessionComposer: View { .padding(HudSpacing.xxl) } .onExitCommand { if !isSubmitting { onClose() } } + .onReceive(vox.$lastFinalText) { spliceDictatedFinal($0) } + } + + private var isDictating: Bool { vox.state.isCaptureActive } + + private var showDictationPreview: Bool { + draft.instructions.isEmpty && (vox.state.isCaptureActive || vox.state.isProcessing) + } + + private var messagePlaceholder: String { + switch draft.target { + case .agent(let agent): + return draft.mode == .continueContext + ? "Message \(agent.displayName)…" + : "What should \(agent.displayName) start on?" + case .project: + return "What should the new agent start on?" + } + } + + private func toggleDictation() { + instructionsFocused = true + Task { + switch ScoutDictationController.toggleDecision(for: vox.state) { + case .probeThenStartIfIdle: + await vox.probe() + if case .idle = vox.state { vox.start() } + case .start: + vox.start() + case .stop: + vox.stop() + case .ignore: + break + } + } + } + + private func spliceDictatedFinal(_ text: String) { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + draft.instructions = ScoutDictationBuffer.appending(trimmed, to: draft.instructions) + ScoutVoxService.shared.consumeFinalText() + instructionsFocused = true } private var card: some View { @@ -284,22 +328,53 @@ struct ScoutSessionComposer: View { private var instructionsSection: some View { VStack(alignment: .leading, spacing: HudSpacing.xs) { - HudSectionLabel(draft.mode == .continueContext ? "Follow-up" : "Instructions") - TextEditor(text: $draft.instructions) - .font(HudFont.ui(12)) - .foregroundStyle(HudPalette.ink) - .scrollContentBackground(.hidden) - .focused($instructionsFocused) - .frame(minHeight: 96) - .padding(6) - .background(HudSurface.inset) - .overlay( - RoundedRectangle(cornerRadius: HudRadius.standard, style: .continuous) - .stroke(HudHairline.standard, lineWidth: HudStrokeWidth.thin) - ) + HudSectionLabel(draft.mode == .continueContext ? "Follow-up message" : "First message") + messageWell } } + private var messageWell: some View { + HStack(alignment: .bottom, spacing: HudSpacing.sm) { + ZStack(alignment: .topLeading) { + if draft.instructions.isEmpty && !showDictationPreview { + Text(messagePlaceholder) + .font(HudFont.ui(12)) + .foregroundStyle(HudPalette.dim) + .padding(.horizontal, 5) + .padding(.vertical, 8) + .allowsHitTesting(false) + } + + TextEditor(text: $draft.instructions) + .font(HudFont.ui(12)) + .foregroundStyle(HudPalette.ink) + .tint(showDictationPreview ? Color.clear : HudPalette.accent) + .scrollContentBackground(.hidden) + .focused($instructionsFocused) + .frame(minHeight: 64, maxHeight: 132) + + if showDictationPreview { + ScoutDictationPreview(text: vox.partial) + .padding(.horizontal, 5) + .padding(.vertical, 8) + .allowsHitTesting(false) + } + } + + ScoutMicButton(box: 30, glyph: 14, action: toggleDictation) + .padding(.bottom, 2) + } + .padding(6) + .background( + RoundedRectangle(cornerRadius: HudRadius.standard, style: .continuous) + .fill(HudSurface.inset) + ) + .overlay( + RoundedRectangle(cornerRadius: HudRadius.standard, style: .continuous) + .stroke(instructionsFocused ? HudSurface.tintBorder(HudPalette.accent) : HudHairline.standard, lineWidth: HudStrokeWidth.thin) + ) + } + private var footer: some View { HStack { HudButton("Cancel", style: .ghost) { onClose() } diff --git a/packages/web/server/core/broker/service.ts b/packages/web/server/core/broker/service.ts index b16f7e25..2e910330 100644 --- a/packages/web/server/core/broker/service.ts +++ b/packages/web/server/core/broker/service.ts @@ -28,6 +28,7 @@ import { type NodeDefinition, type AgentSelector, type AgentSelectorCandidate, + type MessageAttachment, type MessageRecord, type ScoutDeliverResponse, type ScoutDispatchRecord, @@ -1598,12 +1599,52 @@ async function ensureBrokerDirectConversationBetween( }; } +/** Input shape for an attachment supplied by a caller (HTTP / MCP). */ +export type OutgoingAttachmentInput = { + id?: string; + mediaType: string; + fileName?: string; + blobKey?: string; + url?: string; +}; + +/** + * Validate caller-supplied attachments and mint ids where absent. Drops any + * attachment lacking a media type or a way to fetch it (url/blobKey). Returns + * undefined when nothing usable remains, to keep the broker payload clean. + */ +export function normalizeOutgoingAttachments( + attachments: OutgoingAttachmentInput[] | undefined, +): MessageAttachment[] | undefined { + if (!attachments?.length) { + return undefined; + } + const normalized: MessageAttachment[] = []; + for (const attachment of attachments) { + const mediaType = attachment?.mediaType?.trim(); + const url = attachment?.url?.trim(); + const blobKey = attachment?.blobKey?.trim(); + if (!mediaType || (!url && !blobKey)) { + continue; + } + normalized.push({ + id: attachment.id?.trim() || `att-${randomUUID()}`, + mediaType, + fileName: attachment.fileName?.trim() || undefined, + url: url || undefined, + blobKey: blobKey || undefined, + }); + } + return normalized.length > 0 ? normalized : undefined; +} + export async function sendScoutMessage(input: { senderId: string; body: string; channel?: string; explicitTargetAgentIds?: string[]; shouldSpeak?: boolean; + attachments?: OutgoingAttachmentInput[]; createdAtMs?: number; executionHarness?: AgentHarness; currentDirectory?: string; @@ -1779,6 +1820,7 @@ export async function sendScoutMessage(input: { .filter((target) => validTargets.includes(target.agentId)) .map((target) => ({ actorId: target.agentId, label: target.label })), speech: speechText ? { text: speechText } : undefined, + attachments: normalizeOutgoingAttachments(input.attachments), audience: validTargets.length > 0 ? { notify: validTargets, reason: "mention" } : undefined, visibility: conversation.visibility, policy: "durable", @@ -1823,6 +1865,7 @@ export async function sendScoutConversationMessage(input: { conversationId: string; senderId: string; body: string; + attachments?: OutgoingAttachmentInput[]; createdAtMs?: number; currentDirectory?: string; source?: string; @@ -1894,6 +1937,7 @@ export async function sendScoutConversationMessage(input: { mentions: mentionResolution.resolved .filter((target) => validTargets.includes(target.agentId)) .map((target) => ({ actorId: target.agentId, label: target.label })), + attachments: normalizeOutgoingAttachments(input.attachments), audience: validTargets.length > 0 ? { notify: validTargets, reason: "mention" } : undefined, visibility: conversation.visibility, policy: "durable", diff --git a/packages/web/server/create-openscout-web-server.ts b/packages/web/server/create-openscout-web-server.ts index 87c85031..7cc1cf4b 100644 --- a/packages/web/server/create-openscout-web-server.ts +++ b/packages/web/server/create-openscout-web-server.ts @@ -33,6 +33,11 @@ import { registerScoutWebAssets, type ScoutWebAssetMode, } from "./server-core.ts"; +import { + getImageBlob, + ImageBlobError, + putImageBlob, +} from "./image-blob-store.ts"; import { queryAgentById, queryAgents, @@ -65,6 +70,7 @@ import { markScoutConversationRead, readScoutUnblockRequests, resolveScoutBrokerUrl, + type OutgoingAttachmentInput, sendScoutConversationMessage, sendScoutDirectMessage, sendScoutMessage, @@ -3574,15 +3580,68 @@ export async function createOpenScoutWebServer( } }); + // Ephemeral image attachments. Bytes are uploaded here, stored in a cache + // dir with a TTL, and handed back as an absolute URL that any consumer (the + // browser, the Mac app, or an agent) can fetch. Nothing lands in the DB. + app.post("/api/blobs", async (c) => { + const body = (await c.req.json().catch(() => null)) as { + data?: string; + mediaType?: string; + fileName?: string; + } | null; + if (!body?.data || !body.mediaType) { + return c.json({ error: "data and mediaType are required" }, 400); + } + try { + const stored = await putImageBlob({ + data: body.data, + mediaType: body.mediaType, + fileName: body.fileName, + }); + const origin = options.publicOrigin?.trim() || new URL(c.req.url).origin; + return c.json({ + id: stored.id, + url: `${origin.replace(/\/$/, "")}/api/blobs/${stored.id}`, + mediaType: stored.mediaType, + fileName: stored.fileName, + size: stored.size, + }); + } catch (error) { + if (error instanceof ImageBlobError) { + return c.json({ error: error.message }, error.status as 400); + } + const message = error instanceof Error ? error.message : String(error); + return c.json({ error: message }, 500); + } + }); + + app.get("/api/blobs/:id", (c) => { + const entry = getImageBlob(c.req.param("id")); + if (!entry) { + return c.json({ error: "not found" }, 404); + } + const headers: Record = { + "content-type": entry.mediaType, + "cache-control": "private, max-age=3600", + "content-length": String(entry.size), + }; + if (entry.fileName) { + headers["content-disposition"] = + `inline; filename="${entry.fileName.replace(/"/g, "")}"`; + } + return new Response(Bun.file(entry.path), { headers }); + }); + app.post("/api/send", async (c) => { - const { body, cId, conversationId, threadId } = (await c.req.json()) as { + const { body, cId, conversationId, threadId, attachments } = (await c.req.json()) as { body: string; cId?: string; conversationId?: string; threadId?: string; + attachments?: OutgoingAttachmentInput[]; }; - if (!body?.trim()) { - return c.json({ error: "body is required" }, 400); + if (!body?.trim() && !attachments?.length) { + return c.json({ error: "body or attachments are required" }, 400); } const routeCId = cId ?? conversationId; @@ -3637,6 +3696,7 @@ export async function createOpenScoutWebServer( conversationId: routedConversationId, senderId, body: body.trim(), + attachments, currentDirectory, source: "scout-web", }); @@ -3650,6 +3710,7 @@ export async function createOpenScoutWebServer( senderId, body: body.trim(), ...(channel ? { channel } : {}), + attachments, currentDirectory, }); diff --git a/packages/web/server/image-blob-store.ts b/packages/web/server/image-blob-store.ts new file mode 100644 index 00000000..37c27691 --- /dev/null +++ b/packages/web/server/image-blob-store.ts @@ -0,0 +1,147 @@ +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// Ephemeral, session-scoped image storage. These blobs are caches, not +// records: they live just long enough for an agent to fetch an attachment the +// first time it sees a message. We optimize for delivery success (the file is +// present and fast on first fetch), not durability — if a blob is gone after +// its TTL, that is expected and fine. Nothing here touches the database. + +const BLOB_DIR = join(tmpdir(), "openscout-image-blobs"); +// Comfortable window so a blob is reliably present on the agent's first fetch. +// Generous on purpose: first-fetch reliability beats reclaiming disk early. +const BLOB_TTL_MS = 6 * 60 * 60 * 1000; // 6 hours +const SWEEP_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes +const MAX_BLOB_BYTES = 25 * 1024 * 1024; // 25 MB + +export type ImageBlobEntry = { + id: string; + path: string; + mediaType: string; + fileName?: string; + size: number; + expiresAt: number; +}; + +export type PutImageBlobInput = { + /** Base64-encoded image bytes (no data: prefix). */ + data: string; + mediaType: string; + fileName?: string; +}; + +export type PutImageBlobResult = { + id: string; + mediaType: string; + fileName?: string; + size: number; +}; + +export class ImageBlobError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + this.name = "ImageBlobError"; + } +} + +const entries = new Map(); +let sweepTimer: ReturnType | null = null; +let dirReady: Promise | null = null; + +function ensureDir(): Promise { + if (!dirReady) { + dirReady = mkdir(BLOB_DIR, { recursive: true }).then(() => undefined); + } + return dirReady; +} + +function ensureSweeper(): void { + if (sweepTimer) { + return; + } + sweepTimer = setInterval(() => { + void sweepExpired(); + }, SWEEP_INTERVAL_MS); + // Don't keep the process alive just to reclaim cache files. + sweepTimer.unref?.(); +} + +async function sweepExpired(now = Date.now()): Promise { + for (const entry of [...entries.values()]) { + if (entry.expiresAt <= now) { + entries.delete(entry.id); + await rm(entry.path, { force: true }).catch(() => {}); + } + } +} + +function normalizeMediaType(raw: string): string { + const value = raw.trim().toLowerCase(); + if (!value.startsWith("image/")) { + throw new ImageBlobError("Only image attachments are supported", 415); + } + return value; +} + +export async function putImageBlob( + input: PutImageBlobInput, +): Promise { + const mediaType = normalizeMediaType(input.mediaType); + if (!input.data) { + throw new ImageBlobError("Missing image data", 400); + } + + const bytes = Buffer.from(input.data, "base64"); + if (bytes.length === 0) { + throw new ImageBlobError("Image data is empty or not valid base64", 400); + } + if (bytes.length > MAX_BLOB_BYTES) { + throw new ImageBlobError("Image exceeds the maximum allowed size", 413); + } + + await ensureDir(); + ensureSweeper(); + + const id = randomUUID(); + const path = join(BLOB_DIR, id); + await writeFile(path, bytes); + + const entry: ImageBlobEntry = { + id, + path, + mediaType, + fileName: input.fileName?.trim() || undefined, + size: bytes.length, + expiresAt: Date.now() + BLOB_TTL_MS, + }; + entries.set(id, entry); + + return { + id: entry.id, + mediaType: entry.mediaType, + fileName: entry.fileName, + size: entry.size, + }; +} + +/** + * Resolve a blob for serving. Returns null when unknown or expired. Reads never + * delete the blob — an agent may fetch the same attachment more than once. + */ +export function getImageBlob(id: string): ImageBlobEntry | null { + const entry = entries.get(id); + if (!entry) { + return null; + } + if (entry.expiresAt <= Date.now()) { + entries.delete(id); + void rm(entry.path, { force: true }).catch(() => {}); + return null; + } + return entry; +} From 0848524d264afaf5d0fdb7d21a50411eb24d76f9 Mon Sep 17 00:00:00 2001 From: Arach Date: Tue, 2 Jun 2026 22:11:51 -0400 Subject: [PATCH 4/6] =?UTF-8?q?=E2=9C=A8=20Add=20ACP=20stdio=20session=20a?= =?UTF-8?q?dapter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a first-class ACP client adapter for subprocess-backed JSON-RPC stdio agents, including prompt/session handling, permission request mapping, guarded file access, adapter spec coverage, and pairing runtime registration. --- .../src/core/pairing/runtime/runtime.ts | 2 + ...co-056-managed-process-adapter-contract.md | 10 +- packages/agent-sessions/README.md | 4 +- .../agent-sessions/src/adapters/acp.test.ts | 360 +++++ packages/agent-sessions/src/adapters/acp.ts | 1203 +++++++++++++++++ .../src/adapters/acp/adapter.spec.json | 137 ++ .../src/adapters/spec/adapter-spec.test.ts | 18 + packages/agent-sessions/src/index.ts | 1 + .../server/core/pairing/runtime/runtime.ts | 2 + 9 files changed, 1733 insertions(+), 4 deletions(-) create mode 100644 packages/agent-sessions/src/adapters/acp.test.ts create mode 100644 packages/agent-sessions/src/adapters/acp.ts create mode 100644 packages/agent-sessions/src/adapters/acp/adapter.spec.json diff --git a/apps/desktop/src/core/pairing/runtime/runtime.ts b/apps/desktop/src/core/pairing/runtime/runtime.ts index 1056af53..d4a43f2c 100644 --- a/apps/desktop/src/core/pairing/runtime/runtime.ts +++ b/apps/desktop/src/core/pairing/runtime/runtime.ts @@ -1,6 +1,7 @@ import { homedir } from "node:os"; import { + createAcpAdapter as createAcp, createClaudeCodeAdapter as createClaudeCode, createCodexAdapter as createCodex, createOpenAiCompatAdapter as createOpenAI, @@ -30,6 +31,7 @@ export type StartedPairingRuntime = { export function createPairingAdapterRegistry(configAdapters?: Record) { const adapters: Record = { "claude-code": createClaudeCode, + acp: createAcp, codex: createCodex, pi: createPi, opencode: createOpenCode, diff --git a/docs/eng/sco-056-managed-process-adapter-contract.md b/docs/eng/sco-056-managed-process-adapter-contract.md index 7c9cf08e..b2acf7fe 100644 --- a/docs/eng/sco-056-managed-process-adapter-contract.md +++ b/docs/eng/sco-056-managed-process-adapter-contract.md @@ -52,6 +52,12 @@ exists. Scout should still define its own adapter boundary so ACP, MCP stdio, OpenAI-compatible local processes, and Scout-native JSONL processes can be handled consistently. +Current implementation note: `@openscout/agent-sessions` includes a concrete +ACP stdio client adapter for launching ACP agents as subprocess-backed sessions. +This proposal still defines the broader managed-process profile and runtime +contract that should make ACP, MCP stdio, and Scout-native process adapters +inspectable through one common configuration surface. + ## Principles 1. The broker does not spawn arbitrary processes directly; runtime adapters do. @@ -215,8 +221,8 @@ approval. 5. Map protocol updates into SCO-042 observed events. 6. Add permission prompt ingress into durable unblock requests where the protocol supports it. -7. Add ACP stdio as an adapter profile only after the generic contract is - proven. +7. Align the existing ACP stdio adapter with the managed-process profile once + the generic contract is proven. ## Acceptance Criteria diff --git a/packages/agent-sessions/README.md b/packages/agent-sessions/README.md index ed6f61cb..53ddc13c 100644 --- a/packages/agent-sessions/README.md +++ b/packages/agent-sessions/README.md @@ -14,8 +14,8 @@ plane. ## What This Package Owns - protocol primitives and adapter types -- adapter implementations for Codex, Claude Code, pi, opencode, OpenAI-compatible - processes, and the echo test harness +- adapter implementations for ACP stdio agents, Codex, Claude Code, pi, + opencode, OpenAI-compatible processes, and the echo test harness - in-memory session state and replay helpers - `SessionRegistry` - a browser-safe `./client` boundary for trace consumers diff --git a/packages/agent-sessions/src/adapters/acp.test.ts b/packages/agent-sessions/src/adapters/acp.test.ts new file mode 100644 index 00000000..8dc071c8 --- /dev/null +++ b/packages/agent-sessions/src/adapters/acp.test.ts @@ -0,0 +1,360 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import type { PairingEvent } from "../protocol/primitives.js"; +import { createAdapter } from "./acp.js"; + +const tempPaths = new Set(); + +afterEach(() => { + for (const path of tempPaths) { + rmSync(path, { recursive: true, force: true }); + } + tempPaths.clear(); +}); + +function writeFakeAcpExecutable(baseDirectory: string, body: string): string { + const executablePath = join(baseDirectory, `fake-acp-${crypto.randomUUID()}`); + writeFileSync(executablePath, body, "utf8"); + chmodSync(executablePath, 0o755); + return executablePath; +} + +function createEventCollector() { + const events: PairingEvent[] = []; + const listeners = new Set<() => void>(); + + return { + events, + push(event: PairingEvent) { + events.push(event); + for (const listener of listeners) { + listener(); + } + }, + async waitFor(predicate: (events: PairingEvent[]) => boolean, timeoutMs = 5_000): Promise { + if (predicate(events)) { + return; + } + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + listeners.delete(check); + reject(new Error(`Timed out waiting for events after ${timeoutMs}ms.`)); + }, timeoutMs); + + const check = () => { + if (!predicate(events)) { + return; + } + clearTimeout(timeout); + listeners.delete(check); + resolve(); + }; + + listeners.add(check); + }); + }, + }; +} + +describe("AcpAdapter", () => { + test("creates an ACP session and normalizes prompt updates", async () => { + const tempRoot = mkdtempSync(join(tmpdir(), "openscout-acp-adapter-")); + tempPaths.add(tempRoot); + const methodLogPath = join(tempRoot, "methods.log"); + + const executable = writeFakeAcpExecutable(tempRoot, `#!/usr/bin/env bun +import { appendFileSync } from "node:fs"; +import readline from "node:readline"; + +const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); +const methodLogPath = process.env.METHOD_LOG; + +for await (const line of rl) { + const trimmed = line.trim(); + if (!trimmed) continue; + const message = JSON.parse(trimmed); + const id = message.id; + const method = message.method; + const params = message.params ?? {}; + + if (methodLogPath && method) { + appendFileSync(methodLogPath, \`\${method}\\n\`); + } + + if (method === "initialize") { + console.log(JSON.stringify({ + jsonrpc: "2.0", + id, + result: { + protocolVersion: 1, + agentCapabilities: { + promptCapabilities: { image: true }, + sessionCapabilities: { close: {} } + }, + agentInfo: { name: "fake-acp", title: "Fake ACP", version: "1.0.0" }, + authMethods: [] + } + })); + continue; + } + + if (method === "session/new") { + console.log(JSON.stringify({ jsonrpc: "2.0", id, result: { sessionId: "acp-session-1" } })); + continue; + } + + if (method === "session/prompt") { + appendFileSync(methodLogPath, \`prompt:\${params.prompt?.[0]?.text ?? ""}\\n\`); + console.log(JSON.stringify({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: params.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "hello from " } + } + } + })); + console.log(JSON.stringify({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: params.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "acp" } + } + } + })); + console.log(JSON.stringify({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: params.sessionId, + update: { + sessionUpdate: "tool_call", + toolCallId: "call-1", + title: "Inspect file", + kind: "read", + status: "pending" + } + } + })); + console.log(JSON.stringify({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: params.sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: "call-1", + status: "completed", + content: [{ type: "content", content: { type: "text", text: "done" } }] + } + } + })); + console.log(JSON.stringify({ jsonrpc: "2.0", id, result: { stopReason: "end_turn" } })); + continue; + } + + if (method === "session/close") { + console.log(JSON.stringify({ jsonrpc: "2.0", id, result: {} })); + continue; + } +} +`); + + const sessionId = `acp-test-${crypto.randomUUID()}`; + const adapter = createAdapter({ + sessionId, + name: "Fake ACP", + cwd: tempRoot, + env: { METHOD_LOG: methodLogPath }, + options: { + command: executable, + startupTimeoutMs: 2_000, + requestTimeoutMs: 2_000, + promptTimeoutMs: 2_000, + }, + }); + + const collector = createEventCollector(); + adapter.on("event", (event) => collector.push(event)); + + await adapter.start(); + adapter.send({ sessionId, text: "say hi" }); + + await collector.waitFor((events) => events.some((event) => event.event === "turn:end")); + + const sessionUpdates = collector.events.filter((event) => event.event === "session:update"); + const sessionUpdate = sessionUpdates.at(-1); + const textDeltas = collector.events + .filter((event) => event.event === "block:delta") + .map((event) => event.text) + .join(""); + const actionOutput = collector.events.find((event) => event.event === "block:action:output"); + const turnEnd = collector.events.find((event) => event.event === "turn:end"); + const methodLog = readFileSync(methodLogPath, "utf8"); + + expect(sessionUpdate).toBeDefined(); + if (sessionUpdate?.event === "session:update") { + expect(sessionUpdate.session.adapterType).toBe("acp"); + expect(sessionUpdate.session.providerMeta?.acp).toMatchObject({ + acpSessionId: "acp-session-1", + }); + } + expect(textDeltas).toBe("hello from acp"); + expect(actionOutput).toEqual(expect.objectContaining({ output: "done" })); + expect(turnEnd).toEqual(expect.objectContaining({ event: "turn:end", status: "completed" })); + expect(methodLog).toContain("initialize\nsession/new\nsession/prompt\n"); + expect(methodLog).toContain("prompt:say hi"); + + await adapter.shutdown(); + }); + + test("answers ACP permission requests through decide()", async () => { + const tempRoot = mkdtempSync(join(tmpdir(), "openscout-acp-permission-")); + tempPaths.add(tempRoot); + const decisionLogPath = join(tempRoot, "decision.log"); + + const executable = writeFakeAcpExecutable(tempRoot, `#!/usr/bin/env bun +import { appendFileSync } from "node:fs"; +import readline from "node:readline"; + +const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); +const decisionLogPath = process.env.DECISION_LOG; +let promptRequestId = null; +let acpSessionId = "acp-session-permission"; + +for await (const line of rl) { + const trimmed = line.trim(); + if (!trimmed) continue; + const message = JSON.parse(trimmed); + const id = message.id; + const method = message.method; + const params = message.params ?? {}; + + if (method === "initialize") { + console.log(JSON.stringify({ + jsonrpc: "2.0", + id, + result: { + protocolVersion: 1, + agentCapabilities: {}, + agentInfo: { name: "fake-acp-permission", version: "1.0.0" }, + authMethods: [] + } + })); + continue; + } + + if (method === "session/new") { + console.log(JSON.stringify({ jsonrpc: "2.0", id, result: { sessionId: acpSessionId } })); + continue; + } + + if (method === "session/prompt") { + promptRequestId = id; + console.log(JSON.stringify({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: params.sessionId, + update: { + sessionUpdate: "tool_call", + toolCallId: "call-perm", + title: "Run protected action", + kind: "execute", + status: "pending" + } + } + })); + console.log(JSON.stringify({ + jsonrpc: "2.0", + id: "permission-1", + method: "session/request_permission", + params: { + sessionId: params.sessionId, + toolCall: { + toolCallId: "call-perm", + title: "Run protected action", + status: "pending" + }, + options: [ + { optionId: "allow-once", name: "Allow once", kind: "allow_once" }, + { optionId: "reject-once", name: "Reject", kind: "reject_once" } + ] + } + })); + continue; + } + + if (id === "permission-1" && message.result) { + appendFileSync(decisionLogPath, JSON.stringify(message.result) + "\\n"); + console.log(JSON.stringify({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: acpSessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: "call-perm", + status: "completed", + content: [{ type: "content", content: { type: "text", text: "approved" } }] + } + } + })); + console.log(JSON.stringify({ jsonrpc: "2.0", id: promptRequestId, result: { stopReason: "end_turn" } })); + continue; + } +} +`); + + const sessionId = `acp-permission-${crypto.randomUUID()}`; + const adapter = createAdapter({ + sessionId, + name: "Fake ACP Permission", + cwd: tempRoot, + env: { DECISION_LOG: decisionLogPath }, + options: { + command: executable, + startupTimeoutMs: 2_000, + requestTimeoutMs: 2_000, + promptTimeoutMs: 5_000, + }, + }); + + const collector = createEventCollector(); + adapter.on("event", (event) => collector.push(event)); + + await adapter.start(); + adapter.send({ sessionId, text: "run it" }); + + await collector.waitFor((events) => events.some((event) => event.event === "block:action:approval")); + const approval = collector.events.find((event) => event.event === "block:action:approval"); + expect(approval).toBeDefined(); + if (approval?.event !== "block:action:approval") { + throw new Error("Expected approval event."); + } + + adapter.decide(approval.turnId, approval.blockId, "approve"); + await collector.waitFor((events) => events.some((event) => event.event === "turn:end")); + + const decisionLog = readFileSync(decisionLogPath, "utf8"); + const turnEnd = collector.events.find((event) => event.event === "turn:end"); + const approvedOutput = collector.events.find( + (event) => event.event === "block:action:output" && event.output === "approved", + ); + + expect(decisionLog).toContain('"optionId":"allow-once"'); + expect(approvedOutput).toBeDefined(); + expect(turnEnd).toEqual(expect.objectContaining({ event: "turn:end", status: "completed" })); + + await adapter.shutdown(); + }); +}); diff --git a/packages/agent-sessions/src/adapters/acp.ts b/packages/agent-sessions/src/adapters/acp.ts new file mode 100644 index 00000000..a21f6340 --- /dev/null +++ b/packages/agent-sessions/src/adapters/acp.ts @@ -0,0 +1,1203 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, isAbsolute, relative, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { BaseAdapter, type AdapterConfig } from "../protocol/adapter.js"; +import type { + Action, + Block, + BlockStatus, + Prompt, + SessionStatus, + Turn, + TurnStatus, +} from "../protocol/primitives.js"; + +type JsonRpcId = string | number | null; + +type JsonRpcResponse = { + id: JsonRpcId; + result?: unknown; + error?: { + code?: number; + message?: string; + data?: unknown; + }; +}; + +type JsonRpcRequest = { + id: JsonRpcId; + method: string; + params?: unknown; +}; + +type JsonRpcNotification = { + method: string; + params?: unknown; +}; + +type JsonRpcMessage = JsonRpcResponse | JsonRpcRequest | JsonRpcNotification; + +type PendingRequest = { + method: string; + resolve: (value: unknown) => void; + reject: (error: Error) => void; + timeout: ReturnType | null; +}; + +type AcpContentBlock = { + type: string; + text?: string; + data?: string; + mimeType?: string; + uri?: string; + name?: string; + title?: string | null; + resource?: { + uri?: string; + text?: string; + blob?: string; + mimeType?: string; + }; +}; + +type AcpToolCallContent = + | { type: "content"; content?: AcpContentBlock } + | { type: "diff"; path?: string; oldText?: string | null; newText?: string | null } + | { type: "terminal"; terminalId?: string }; + +type AcpToolCallUpdate = { + toolCallId?: string; + title?: string | null; + kind?: string | null; + status?: "pending" | "in_progress" | "completed" | "failed" | null; + content?: AcpToolCallContent[] | null; + rawInput?: unknown; + rawOutput?: unknown; +}; + +type AcpSessionUpdate = { + sessionUpdate?: string; + content?: AcpContentBlock; + entries?: Array<{ content?: string; priority?: string; status?: string }>; + toolCallId?: string; + title?: string | null; + kind?: string | null; + status?: "pending" | "in_progress" | "completed" | "failed" | null; + rawInput?: unknown; + rawOutput?: unknown; + sessionId?: string; + [key: string]: unknown; +}; + +type AcpInitializeResponse = { + protocolVersion?: number; + agentCapabilities?: { + loadSession?: boolean; + promptCapabilities?: { + image?: boolean; + embeddedContext?: boolean; + }; + sessionCapabilities?: { + resume?: Record; + close?: Record; + }; + [key: string]: unknown; + }; + agentInfo?: { + name?: string; + title?: string; + version?: string; + } | null; + authMethods?: Array<{ id?: string; methodId?: string; name?: string }>; +}; + +type ActiveTurnState = { + turnId: string; + blockIndex: number; + textBlockId: string | null; + reasoningBlockId: string | null; + blocks: Map; + endedBlocks: Set; + toolBlocksByCallId: Map; + ended: boolean; +}; + +type PendingPermission = { + requestId: JsonRpcId; + turnId: string; + blockId: string; + allowOptionId: string | null; + rejectOptionId: string | null; +}; + +type AcpAdapterOptions = { + command: string; + args: string[]; + cwd: string; + protocolVersion: number; + startupTimeoutMs: number; + requestTimeoutMs: number; + promptTimeoutMs: number | null; + sessionId: string | null; + sessionMode: "new" | "load" | "resume" | "auto"; + authMethodId: string | null; + mcpServers: unknown[]; + additionalDirectories: string[]; + readTextFile: boolean; + writeTextFile: boolean; + clientInfo: { + name: string; + title: string; + version: string; + }; +}; + +const DEFAULT_STARTUP_TIMEOUT_MS = 30_000; +const DEFAULT_REQUEST_TIMEOUT_MS = 60_000; +const JSON_RPC_PARSE_ERROR = -32700; +const JSON_RPC_METHOD_NOT_FOUND = -32601; +const JSON_RPC_INVALID_PARAMS = -32602; +const JSON_RPC_INTERNAL_ERROR = -32603; +const ACP_PROTOCOL_VERSION = 1; + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function stringValue(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function stringArray(value: unknown): string[] { + return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : []; +} + +function numberValue(value: unknown, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : fallback; +} + +function booleanValue(value: unknown, fallback: boolean): boolean { + return typeof value === "boolean" ? value : fallback; +} + +function parseOptions(config: AdapterConfig): AcpAdapterOptions { + const raw = isRecord(config.options) ? config.options : {}; + const command = stringValue(raw.command) ?? stringValue(process.env.OPENSCOUT_ACP_COMMAND); + if (!command) { + throw new Error("ACP adapter requires options.command, for example { command: \"codex\", args: [\"acp\"] }."); + } + + const cwd = config.cwd ?? process.cwd(); + const sessionModeRaw = stringValue(raw.sessionMode); + const sessionMode = sessionModeRaw === "load" || sessionModeRaw === "resume" || sessionModeRaw === "new" + ? sessionModeRaw + : "auto"; + + return { + command, + args: stringArray(raw.args), + cwd, + protocolVersion: numberValue(raw.protocolVersion, ACP_PROTOCOL_VERSION), + startupTimeoutMs: numberValue(raw.startupTimeoutMs, DEFAULT_STARTUP_TIMEOUT_MS), + requestTimeoutMs: numberValue(raw.requestTimeoutMs, DEFAULT_REQUEST_TIMEOUT_MS), + promptTimeoutMs: typeof raw.promptTimeoutMs === "number" && Number.isFinite(raw.promptTimeoutMs) && raw.promptTimeoutMs > 0 + ? raw.promptTimeoutMs + : null, + sessionId: stringValue(raw.sessionId), + sessionMode, + authMethodId: stringValue(raw.authMethodId), + mcpServers: Array.isArray(raw.mcpServers) ? raw.mcpServers : [], + additionalDirectories: stringArray(raw.additionalDirectories), + readTextFile: booleanValue(raw.readTextFile, true), + writeTextFile: booleanValue(raw.writeTextFile, booleanValue(raw.allowWriteTextFile, false)), + clientInfo: { + name: stringValue(raw.clientName) ?? "openscout", + title: stringValue(raw.clientTitle) ?? "OpenScout", + version: stringValue(raw.clientVersion) ?? "0.0.0", + }, + }; +} + +function parseJsonLine(line: string): JsonRpcMessage | null { + try { + return JSON.parse(line) as JsonRpcMessage; + } catch { + return null; + } +} + +function isResponse(message: unknown): message is JsonRpcResponse { + return Boolean( + isRecord(message) + && "id" in message + && ("result" in message || "error" in message) + ); +} + +function isRequest(message: unknown): message is JsonRpcRequest { + return Boolean( + isRecord(message) + && "id" in message + && typeof message.method === "string" + && !("result" in message) + && !("error" in message) + ); +} + +function isNotification(message: unknown): message is JsonRpcNotification { + return Boolean( + isRecord(message) + && typeof message.method === "string" + && !("id" in message) + ); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function mapSessionStatus(status: string | null | undefined): SessionStatus { + switch (status) { + case "idle": + return "idle"; + case "working": + case "running": + case "in_progress": + return "active"; + case "failed": + case "error": + return "error"; + default: + return "active"; + } +} + +function mapTurnStatus(stopReason: unknown): TurnStatus { + return stopReason === "cancelled" ? "stopped" : "completed"; +} + +function mapToolStatus(status: AcpToolCallUpdate["status"]): Action["status"] { + switch (status) { + case "pending": + return "pending"; + case "in_progress": + return "running"; + case "completed": + return "completed"; + case "failed": + return "failed"; + default: + return "running"; + } +} + +function textFromContent(content: AcpContentBlock | undefined): string { + if (!content) { + return ""; + } + + switch (content.type) { + case "text": + return content.text ?? ""; + case "resource": { + const resource = content.resource; + if (!resource) { + return ""; + } + if (typeof resource.text === "string") { + return resource.text; + } + return resource.uri ? `[resource: ${resource.uri}]` : ""; + } + case "resource_link": + return [content.name, content.uri].filter(Boolean).join(" "); + case "image": + return content.uri ? `[image: ${content.uri}]` : "[image]"; + case "audio": + return "[audio]"; + default: + return content.text ?? ""; + } +} + +function diffText(content: Extract): string { + const path = content.path ?? "unknown path"; + const oldText = content.oldText ?? ""; + const newText = content.newText ?? ""; + return `Diff ${path}\n--- old\n${oldText}\n+++ new\n${newText}`; +} + +function outputFromToolContent(content: AcpToolCallContent[] | null | undefined): string { + if (!content?.length) { + return ""; + } + + return content + .map((entry) => { + switch (entry.type) { + case "content": + return textFromContent(entry.content); + case "diff": + return diffText(entry); + case "terminal": + return entry.terminalId ? `Terminal ${entry.terminalId}` : "Terminal output"; + } + }) + .filter(Boolean) + .join("\n"); +} + +function toolName(input: AcpToolCallUpdate): string { + return input.title?.trim() || input.kind?.trim() || "ACP tool call"; +} + +function buildToolAction(input: AcpToolCallUpdate): Action { + return { + kind: "tool_call", + toolName: toolName(input), + toolCallId: input.toolCallId ?? crypto.randomUUID(), + status: mapToolStatus(input.status), + output: outputFromToolContent(input.content), + input: input.rawInput, + result: input.rawOutput, + }; +} + +function toolUpdateFromSessionUpdate(update: AcpSessionUpdate): AcpToolCallUpdate { + return { + toolCallId: typeof update.toolCallId === "string" ? update.toolCallId : undefined, + title: typeof update.title === "string" ? update.title : null, + kind: typeof update.kind === "string" ? update.kind : null, + status: update.status ?? null, + content: Array.isArray(update.content) ? update.content as AcpToolCallContent[] : null, + rawInput: update.rawInput, + rawOutput: update.rawOutput, + }; +} + +function promptToContent(prompt: Prompt, includeImages: boolean): AcpContentBlock[] { + const blocks: AcpContentBlock[] = []; + if (prompt.text.trim()) { + blocks.push({ type: "text", text: prompt.text }); + } + + for (const file of prompt.files ?? []) { + const uri = isAbsolute(file) ? pathToFileURL(file).href : file; + blocks.push({ + type: "resource_link", + uri, + name: file.split("/").pop() || file, + }); + } + + if (includeImages) { + for (const image of prompt.images ?? []) { + blocks.push({ + type: "image", + mimeType: image.mimeType, + data: image.data, + }); + } + } + + if (!blocks.length) { + blocks.push({ type: "text", text: "" }); + } + + return blocks; +} + +function isPathInside(root: string, filePath: string): boolean { + const rel = relative(root, filePath); + return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); +} + +export class AcpAdapter extends BaseAdapter { + readonly type = "acp"; + + private readonly acpOptions: AcpAdapterOptions; + private process: ChildProcessWithoutNullStreams | null = null; + private lineBuffer = ""; + private requestId = 0; + private pendingRequests = new Map(); + private currentSessionId: string | null = null; + private agentCapabilities: AcpInitializeResponse["agentCapabilities"] = {}; + private activeTurn: ActiveTurnState | null = null; + private pendingPermissions = new Map(); + private promptQueue: Promise = Promise.resolve(); + + constructor(config: AdapterConfig) { + super(config); + this.acpOptions = parseOptions(config); + (this.session as { adapterType: string }).adapterType = this.type; + } + + async start(): Promise { + await this.startProcess(); + } + + send(prompt: Prompt): void { + this.promptQueue = this.promptQueue.then( + () => this.runPrompt(prompt), + () => this.runPrompt(prompt), + ); + } + + interrupt(): void { + if (this.currentSessionId) { + this.notify("session/cancel", { sessionId: this.currentSessionId }); + } + this.cancelPendingPermissions(); + this.finishTurn("stopped"); + } + + decide(turnId: string, blockId: string, decision: "approve" | "deny"): void { + const key = this.permissionKey(turnId, blockId); + const pending = this.pendingPermissions.get(key); + if (!pending) { + return; + } + + this.pendingPermissions.delete(key); + const selected = decision === "approve" ? pending.allowOptionId : pending.rejectOptionId; + if (selected) { + this.writeResult(pending.requestId, { + outcome: { + outcome: "selected", + optionId: selected, + }, + }); + } else { + this.writeResult(pending.requestId, { + outcome: { outcome: "cancelled" }, + }); + } + + this.emit("event", { + event: "block:action:status", + sessionId: this.session.id, + turnId, + blockId, + status: decision === "approve" ? "running" : "failed", + }); + } + + async shutdown(): Promise { + this.cancelPendingPermissions(); + if (this.currentSessionId && this.agentCapabilities?.sessionCapabilities?.close) { + await this.request("session/close", { sessionId: this.currentSessionId }, 2_000).catch(() => undefined); + } + + for (const [, pending] of this.pendingRequests) { + if (pending.timeout) clearTimeout(pending.timeout); + pending.reject(new Error("ACP adapter shut down.")); + } + this.pendingRequests.clear(); + + if (this.process && !this.process.killed) { + this.process.kill(); + } + this.process = null; + this.setStatus("closed"); + } + + private async startProcess(): Promise { + if (this.process && !this.process.killed && this.process.exitCode === null) { + return; + } + + const options = this.acpOptions; + const child = spawn(options.command, options.args, { + cwd: options.cwd, + env: { ...process.env, ...(this.config.env ?? {}) }, + stdio: ["pipe", "pipe", "pipe"], + }); + + this.process = child; + this.lineBuffer = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + + child.stdout.on("data", (chunk: string) => this.handleStdoutChunk(chunk)); + child.stderr.on("data", (chunk: string) => { + if (chunk.trim()) { + this.updateProviderMeta({ lastStderr: chunk.trim().slice(-4_000) }); + } + }); + child.once("error", (error) => { + this.failSession(new Error(`ACP agent failed to start: ${errorMessage(error)}`)); + }); + child.once("exit", (code, signal) => { + if (this.session.status === "closed") { + return; + } + this.failSession( + new Error( + `ACP agent exited` + + (code !== null ? ` with code ${code}` : "") + + (signal ? ` (${signal})` : ""), + ), + ); + }); + + const initialized = await this.request("initialize", { + protocolVersion: options.protocolVersion, + clientCapabilities: { + fs: { + readTextFile: options.readTextFile, + writeTextFile: options.writeTextFile, + }, + terminal: false, + }, + clientInfo: options.clientInfo, + }, options.startupTimeoutMs); + + if (initialized.protocolVersion !== ACP_PROTOCOL_VERSION) { + throw new Error(`ACP protocol version ${String(initialized.protocolVersion)} is not supported.`); + } + + this.agentCapabilities = initialized.agentCapabilities ?? {}; + this.updateProviderMeta({ + acpProtocolVersion: initialized.protocolVersion, + agentInfo: initialized.agentInfo ?? null, + agentCapabilities: this.agentCapabilities, + command: [options.command, ...options.args].join(" "), + }); + + if (options.authMethodId) { + await this.request("authenticate", { methodId: options.authMethodId }, options.startupTimeoutMs); + } + + await this.openSession(); + this.setStatus("idle"); + } + + private async openSession(): Promise { + const options = this.acpOptions; + const baseParams = { + cwd: options.cwd, + mcpServers: options.mcpServers, + ...(options.additionalDirectories.length + ? { additionalDirectories: options.additionalDirectories } + : {}), + }; + + const requestedSessionId = options.sessionId; + if (requestedSessionId && options.sessionMode !== "new") { + const canResume = Boolean(this.agentCapabilities?.sessionCapabilities?.resume); + const canLoad = this.agentCapabilities?.loadSession === true; + + if ((options.sessionMode === "resume" || options.sessionMode === "auto") && canResume) { + await this.request("session/resume", { + ...baseParams, + sessionId: requestedSessionId, + }, options.startupTimeoutMs); + this.setAcpSessionId(requestedSessionId); + return; + } + + if ((options.sessionMode === "load" || options.sessionMode === "auto") && canLoad) { + await this.request("session/load", { + ...baseParams, + sessionId: requestedSessionId, + }, options.startupTimeoutMs); + this.setAcpSessionId(requestedSessionId); + return; + } + + throw new Error(`ACP agent does not advertise support for resuming or loading session ${requestedSessionId}.`); + } + + const created = await this.request<{ sessionId?: string }>("session/new", baseParams, options.startupTimeoutMs); + if (!created.sessionId) { + throw new Error("ACP agent did not return a sessionId from session/new."); + } + this.setAcpSessionId(created.sessionId); + } + + private async runPrompt(prompt: Prompt): Promise { + if (!this.currentSessionId) { + this.emit("error", new Error("ACP session is not ready.")); + return; + } + + const turnId = crypto.randomUUID(); + const turn: Turn = { + id: turnId, + sessionId: this.session.id, + status: "started", + startedAt: new Date().toISOString(), + blocks: [], + }; + this.activeTurn = { + turnId, + blockIndex: 0, + textBlockId: null, + reasoningBlockId: null, + blocks: new Map(), + endedBlocks: new Set(), + toolBlocksByCallId: new Map(), + ended: false, + }; + + this.setStatus("active"); + this.emit("event", { event: "turn:start", sessionId: this.session.id, turn }); + + try { + const response = await this.request<{ stopReason?: string }>("session/prompt", { + sessionId: this.currentSessionId, + prompt: promptToContent(prompt, this.agentCapabilities?.promptCapabilities?.image === true), + }, this.acpOptions.promptTimeoutMs); + this.finishTurn(mapTurnStatus(response.stopReason)); + } catch (error) { + this.emit("event", { + event: "turn:error", + sessionId: this.session.id, + turnId, + message: errorMessage(error), + }); + this.finishTurn("failed"); + } finally { + if (this.activeTurn?.turnId === turnId) { + this.activeTurn = null; + } + if (this.session.status !== "closed" && this.session.status !== "error") { + this.setStatus("idle"); + } + } + } + + private handleStdoutChunk(chunk: string): void { + this.lineBuffer += chunk; + while (true) { + const newlineIndex = this.lineBuffer.indexOf("\n"); + if (newlineIndex === -1) { + break; + } + + const line = this.lineBuffer.slice(0, newlineIndex).trim(); + this.lineBuffer = this.lineBuffer.slice(newlineIndex + 1); + if (!line) { + continue; + } + + const message = parseJsonLine(line); + if (!message) { + this.writeError(null, JSON_RPC_PARSE_ERROR, "Invalid JSON-RPC message."); + continue; + } + + if (isResponse(message)) { + this.handleResponse(message); + continue; + } + + if (isRequest(message)) { + void this.handleRequest(message); + continue; + } + + if (isNotification(message)) { + this.handleNotification(message); + } + } + } + + private handleResponse(message: JsonRpcResponse): void { + const pending = this.pendingRequests.get(message.id); + if (!pending) { + return; + } + + this.pendingRequests.delete(message.id); + if (pending.timeout) { + clearTimeout(pending.timeout); + } + + if (message.error) { + pending.reject(new Error(message.error.message || `${pending.method} failed.`)); + return; + } + + pending.resolve(message.result); + } + + private async handleRequest(message: JsonRpcRequest): Promise { + try { + switch (message.method) { + case "session/request_permission": + await this.handlePermissionRequest(message); + return; + case "fs/read_text_file": + await this.handleReadTextFile(message); + return; + case "fs/write_text_file": + await this.handleWriteTextFile(message); + return; + default: + this.writeError(message.id, JSON_RPC_METHOD_NOT_FOUND, `Unsupported ACP client method: ${message.method}`); + } + } catch (error) { + this.writeError(message.id, JSON_RPC_INTERNAL_ERROR, errorMessage(error)); + } + } + + private handleNotification(message: JsonRpcNotification): void { + if (message.method !== "session/update") { + return; + } + + const params = isRecord(message.params) ? message.params : {}; + const update = isRecord(params.update) ? params.update as AcpSessionUpdate : null; + if (!update) { + return; + } + + this.handleSessionUpdate(update); + } + + private handleSessionUpdate(update: AcpSessionUpdate): void { + switch (update.sessionUpdate) { + case "agent_message_chunk": + this.appendContentText("text", update.content); + return; + case "agent_thought_chunk": + this.appendContentText("reasoning", update.content); + return; + case "tool_call": + this.upsertToolBlock(toolUpdateFromSessionUpdate(update)); + return; + case "tool_call_update": + this.upsertToolBlock(toolUpdateFromSessionUpdate(update)); + return; + case "plan": + this.emitPlan(update); + return; + case "session_info_update": + this.updateProviderMeta({ acpSessionInfo: update }); + if (typeof update.status === "string") { + this.setStatus(mapSessionStatus(update.status)); + } + return; + case "usage_update": + this.updateProviderMeta({ usage: update }); + return; + default: + return; + } + } + + private appendContentText(kind: "text" | "reasoning", content: AcpContentBlock | undefined): void { + const active = this.activeTurn; + if (!active) { + return; + } + + const text = textFromContent(content); + if (!text) { + return; + } + + const existingBlockId = kind === "text" ? active.textBlockId : active.reasoningBlockId; + const blockId = existingBlockId ?? this.startTextBlock(kind); + if (!blockId) { + return; + } + + this.emit("event", { + event: "block:delta", + sessionId: this.session.id, + turnId: active.turnId, + blockId, + text, + }); + } + + private startTextBlock(kind: "text" | "reasoning"): string | null { + const active = this.activeTurn; + if (!active) { + return null; + } + + const block: Block = { + id: crypto.randomUUID(), + turnId: active.turnId, + type: kind, + text: "", + status: "streaming", + index: active.blockIndex++, + }; + active.blocks.set(block.id, block); + if (kind === "text") { + active.textBlockId = block.id; + } else { + active.reasoningBlockId = block.id; + } + this.emit("event", { + event: "block:start", + sessionId: this.session.id, + turnId: active.turnId, + block, + }); + return block.id; + } + + private upsertToolBlock(input: AcpToolCallUpdate): string | null { + const active = this.activeTurn; + if (!active) { + return null; + } + + const toolCallId = input.toolCallId ?? crypto.randomUUID(); + const existingBlockId = active.toolBlocksByCallId.get(toolCallId); + if (!existingBlockId) { + const block: Block = { + id: crypto.randomUUID(), + turnId: active.turnId, + type: "action", + status: "streaming", + index: active.blockIndex++, + action: buildToolAction({ ...input, toolCallId }), + }; + active.blocks.set(block.id, block); + active.toolBlocksByCallId.set(toolCallId, block.id); + this.emit("event", { + event: "block:start", + sessionId: this.session.id, + turnId: active.turnId, + block, + }); + const output = outputFromToolContent(input.content); + if (output) { + this.emit("event", { + event: "block:action:output", + sessionId: this.session.id, + turnId: active.turnId, + blockId: block.id, + output, + }); + } + return block.id; + } + + const actionStatus = mapToolStatus(input.status); + this.emit("event", { + event: "block:action:status", + sessionId: this.session.id, + turnId: active.turnId, + blockId: existingBlockId, + status: actionStatus, + meta: { + ...(input.rawOutput !== undefined ? { rawOutput: input.rawOutput } : {}), + ...(input.rawInput !== undefined ? { rawInput: input.rawInput } : {}), + }, + }); + + const output = outputFromToolContent(input.content); + if (output) { + this.emit("event", { + event: "block:action:output", + sessionId: this.session.id, + turnId: active.turnId, + blockId: existingBlockId, + output, + }); + } + + if (input.status === "completed" || input.status === "failed") { + this.endBlock(existingBlockId, input.status === "failed" ? "failed" : "completed"); + } + + return existingBlockId; + } + + private emitPlan(update: AcpSessionUpdate): void { + const active = this.activeTurn; + if (!active || !Array.isArray(update.entries) || !update.entries.length) { + return; + } + + const text = update.entries + .map((entry) => { + const status = entry.status ? ` [${entry.status}]` : ""; + const priority = entry.priority ? ` (${entry.priority})` : ""; + return `- ${entry.content ?? "Plan item"}${priority}${status}`; + }) + .join("\n"); + const blockId = this.startTextBlock("reasoning"); + if (!blockId) { + return; + } + this.emit("event", { + event: "block:delta", + sessionId: this.session.id, + turnId: active.turnId, + blockId, + text: `Plan:\n${text}`, + }); + this.endBlock(blockId, "completed"); + } + + private async handlePermissionRequest(message: JsonRpcRequest): Promise { + const active = this.activeTurn; + if (!active) { + this.writeResult(message.id, { outcome: { outcome: "cancelled" } }); + return; + } + + const params = isRecord(message.params) ? message.params : {}; + const toolCall = isRecord(params.toolCall) ? params.toolCall as AcpToolCallUpdate : {}; + const options = Array.isArray(params.options) ? params.options.filter(isRecord) : []; + const toolCallId = toolCall.toolCallId ?? crypto.randomUUID(); + const blockId = this.upsertToolBlock({ ...toolCall, toolCallId, status: "pending" }); + if (!blockId) { + this.writeResult(message.id, { outcome: { outcome: "cancelled" } }); + return; + } + + const allowOptionId = this.permissionOption(options, "allow"); + const rejectOptionId = this.permissionOption(options, "reject"); + this.pendingPermissions.set(this.permissionKey(active.turnId, blockId), { + requestId: message.id, + turnId: active.turnId, + blockId, + allowOptionId, + rejectOptionId, + }); + + this.emit("event", { + event: "block:action:approval", + sessionId: this.session.id, + turnId: active.turnId, + blockId, + approval: { + version: 1, + description: toolName(toolCall), + risk: "medium", + }, + }); + } + + private async handleReadTextFile(message: JsonRpcRequest): Promise { + if (!this.acpOptions.readTextFile) { + this.writeError(message.id, JSON_RPC_METHOD_NOT_FOUND, "fs/read_text_file is disabled."); + return; + } + + const params = isRecord(message.params) ? message.params : {}; + const requestedPath = stringValue(params.path); + if (!requestedPath) { + this.writeError(message.id, JSON_RPC_INVALID_PARAMS, "fs/read_text_file requires params.path."); + return; + } + + const filePath = this.resolveAllowedPath(requestedPath); + const raw = await readFile(filePath, "utf8"); + const line = typeof params.line === "number" && params.line > 0 ? Math.floor(params.line) : null; + const limit = typeof params.limit === "number" && params.limit >= 0 ? Math.floor(params.limit) : null; + const content = line !== null || limit !== null + ? raw.split(/\r?\n/).slice(Math.max((line ?? 1) - 1, 0), limit === null ? undefined : Math.max((line ?? 1) - 1, 0) + limit).join("\n") + : raw; + this.writeResult(message.id, { content }); + } + + private async handleWriteTextFile(message: JsonRpcRequest): Promise { + if (!this.acpOptions.writeTextFile) { + this.writeError(message.id, JSON_RPC_METHOD_NOT_FOUND, "fs/write_text_file is disabled."); + return; + } + + const params = isRecord(message.params) ? message.params : {}; + const requestedPath = stringValue(params.path); + if (!requestedPath || typeof params.content !== "string") { + this.writeError(message.id, JSON_RPC_INVALID_PARAMS, "fs/write_text_file requires params.path and params.content."); + return; + } + + const filePath = this.resolveAllowedPath(requestedPath); + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, params.content, "utf8"); + this.writeResult(message.id, {}); + } + + private request(method: string, params?: unknown, timeoutMs: number | null = this.acpOptions.requestTimeoutMs): Promise { + const id = ++this.requestId; + return new Promise((resolve, reject) => { + const timeout = timeoutMs + ? setTimeout(() => { + this.pendingRequests.delete(id); + reject(new Error(`ACP request timed out: ${method}`)); + }, timeoutMs) + : null; + + this.pendingRequests.set(id, { + method, + resolve: (value) => resolve(value as T), + reject, + timeout, + }); + + this.writeMessage({ + jsonrpc: "2.0", + id, + method, + ...(params === undefined ? {} : { params }), + }); + }); + } + + private notify(method: string, params?: unknown): void { + this.writeMessage({ + jsonrpc: "2.0", + method, + ...(params === undefined ? {} : { params }), + }); + } + + private writeResult(id: JsonRpcId, result: unknown): void { + this.writeMessage({ + jsonrpc: "2.0", + id, + result, + }); + } + + private writeError(id: JsonRpcId, code: number, message: string, data?: unknown): void { + this.writeMessage({ + jsonrpc: "2.0", + id, + error: { + code, + message, + ...(data === undefined ? {} : { data }), + }, + }); + } + + private writeMessage(message: Record): void { + const line = JSON.stringify(message); + if (!this.process?.stdin.writable) { + return; + } + this.process.stdin.write(`${line}\n`); + } + + private setAcpSessionId(sessionId: string): void { + this.currentSessionId = sessionId; + this.updateProviderMeta({ acpSessionId: sessionId }); + } + + private updateProviderMeta(meta: Record): void { + this.session.providerMeta = { + ...this.session.providerMeta, + acp: { + ...(isRecord(this.session.providerMeta?.acp) ? this.session.providerMeta.acp : {}), + ...meta, + }, + }; + this.emit("event", { event: "session:update", session: { ...this.session } }); + } + + private finishTurn(status: TurnStatus): void { + const active = this.activeTurn; + if (!active || active.ended) { + return; + } + + for (const blockId of active.blocks.keys()) { + if (!active.endedBlocks.has(blockId)) { + this.endBlock(blockId, status === "failed" ? "failed" : "completed"); + } + } + + active.ended = true; + this.emit("event", { + event: "turn:end", + sessionId: this.session.id, + turnId: active.turnId, + status, + }); + } + + private endBlock(blockId: string, status: BlockStatus): void { + const active = this.activeTurn; + if (!active || active.endedBlocks.has(blockId)) { + return; + } + + active.endedBlocks.add(blockId); + if (active.textBlockId === blockId) { + active.textBlockId = null; + } + if (active.reasoningBlockId === blockId) { + active.reasoningBlockId = null; + } + this.emit("event", { + event: "block:end", + sessionId: this.session.id, + turnId: active.turnId, + blockId, + status, + }); + } + + private cancelPendingPermissions(): void { + for (const [, pending] of this.pendingPermissions) { + this.writeResult(pending.requestId, { + outcome: { outcome: "cancelled" }, + }); + this.emit("event", { + event: "block:action:status", + sessionId: this.session.id, + turnId: pending.turnId, + blockId: pending.blockId, + status: "failed", + }); + } + this.pendingPermissions.clear(); + } + + private permissionOption(options: Record[], kind: "allow" | "reject"): string | null { + const match = options.find((option) => { + const optionKind = stringValue(option.kind); + return optionKind?.startsWith(kind) ?? false; + }) ?? options.find((option) => { + const name = stringValue(option.name)?.toLowerCase(); + return name?.includes(kind === "allow" ? "allow" : "reject") ?? false; + }); + + return stringValue(match?.optionId); + } + + private permissionKey(turnId: string, blockId: string): string { + return `${turnId}:${blockId}`; + } + + private resolveAllowedPath(inputPath: string): string { + if (!isAbsolute(inputPath)) { + throw new Error(`ACP file path must be absolute: ${inputPath}`); + } + + const filePath = resolve(inputPath); + const roots = [this.acpOptions.cwd, ...this.acpOptions.additionalDirectories].map((root) => resolve(root)); + if (!roots.some((root) => isPathInside(root, filePath))) { + throw new Error(`ACP file path is outside the configured workspace roots: ${inputPath}`); + } + return filePath; + } + + private failSession(error: Error): void { + for (const [, pending] of this.pendingRequests) { + if (pending.timeout) clearTimeout(pending.timeout); + pending.reject(error); + } + this.pendingRequests.clear(); + this.cancelPendingPermissions(); + this.emit("error", error); + this.setStatus("error"); + } +} + +export const createAdapter = (config: AdapterConfig) => new AcpAdapter(config); diff --git a/packages/agent-sessions/src/adapters/acp/adapter.spec.json b/packages/agent-sessions/src/adapters/acp/adapter.spec.json new file mode 100644 index 00000000..07ac7c93 --- /dev/null +++ b/packages/agent-sessions/src/adapters/acp/adapter.spec.json @@ -0,0 +1,137 @@ +{ + "$schema": "../spec/adapter-spec.v1.schema.json", + "specVersion": "1.0.0", + "adapterId": "acp", + "displayName": "Agent Client Protocol", + "implementation": { + "package": "@openscout/agent-sessions", + "entrypoint": "src/adapters/acp.ts", + "factoryExport": "createAdapter", + "className": "AcpAdapter" + }, + "upstream": { + "kind": "official_protocol", + "name": "Agent Client Protocol v1", + "transport": "jsonrpc-stdio-jsonl", + "sources": [ + { + "kind": "url", + "ref": "https://agentclientprotocol.com/protocol/v1/overview" + }, + { + "kind": "url", + "ref": "https://agentclientprotocol.com/protocol/v1/transports" + }, + { + "kind": "url", + "ref": "https://agentclientprotocol.com/protocol/v1/schema" + }, + { + "kind": "file", + "ref": "src/adapters/acp.ts" + } + ] + }, + "sessionModel": { + "lifecycle": "long_lived_process", + "conversationScope": "session", + "resumeSupport": "optional", + "resumeOptionKey": "sessionId", + "turnSteering": "new_turn_only", + "concurrentTurns": "single", + "persistence": [ + "process_state", + "upstream_session_id" + ] + }, + "capabilities": { + "promptInputs": { + "text": true, + "images": "native", + "files": "reference_text" + }, + "outputBlocks": [ + "text", + "reasoning", + "action", + "error" + ], + "actionKinds": [ + "tool_call" + ], + "streaming": { + "text": true, + "reasoning": true, + "actionOutput": true, + "actionStatus": true + }, + "interactive": { + "questions": "none", + "approvals": "native", + "serverRequests": "native" + }, + "control": { + "interrupt": true, + "shutdown": true + } + }, + "nativeProtocol": { + "outboundRequests": [ + "initialize", + "authenticate", + "session/new", + "session/load", + "session/resume", + "session/prompt", + "session/close" + ], + "outboundNotifications": [ + "session/cancel" + ], + "inboundNotifications": [ + "session/update" + ], + "inboundServerRequests": [ + "session/request_permission", + "fs/read_text_file", + "fs/write_text_file" + ], + "messageFormats": [ + "jsonrpc_request", + "jsonrpc_notification", + "jsonrpc_response", + "jsonrpc_error", + "jsonl_transport" + ], + "serverRequestStrategy": "resolve_and_reject" + }, + "normalizedSurface": { + "adapterMethods": [ + "start", + "send", + "interrupt", + "shutdown" + ], + "optionalAdapterMethods": [ + "decide" + ], + "emitsPairingEvents": [ + "session:update", + "turn:start", + "block:start", + "block:delta", + "block:action:output", + "block:action:status", + "block:action:approval", + "block:end", + "turn:error", + "turn:end" + ] + }, + "limitations": [ + "The adapter is a client for ACP agents over stdio; it does not expose OpenScout itself as an ACP agent server.", + "Client-side terminal methods are not advertised or implemented.", + "Client-side file writes are disabled by default and require options.allowWriteTextFile.", + "Tool calls are normalized as generic tool_call action blocks rather than preserving every ACP tool kind as a first-class Pairing action kind." + ] +} diff --git a/packages/agent-sessions/src/adapters/spec/adapter-spec.test.ts b/packages/agent-sessions/src/adapters/spec/adapter-spec.test.ts index 795fd484..715cfcd5 100644 --- a/packages/agent-sessions/src/adapters/spec/adapter-spec.test.ts +++ b/packages/agent-sessions/src/adapters/spec/adapter-spec.test.ts @@ -57,4 +57,22 @@ describe("adapter spec v1", () => { }, }); }); + + test("acp spec captures the JSON-RPC stdio client shape", () => { + const spec = readSpec("acp/adapter.spec.json"); + const errors = validateAdapterSpec(spec, "acp/adapter.spec.json"); + + expect(errors).toEqual([]); + expect(spec.adapterId).toBe("acp"); + expect(spec.upstream).toMatchObject({ + kind: "official_protocol", + transport: "jsonrpc-stdio-jsonl", + }); + expect(spec.nativeProtocol).toMatchObject({ + inboundServerRequests: expect.arrayContaining([ + "session/request_permission", + "fs/read_text_file", + ]), + }); + }); }); diff --git a/packages/agent-sessions/src/index.ts b/packages/agent-sessions/src/index.ts index 1be12751..6e568fb5 100644 --- a/packages/agent-sessions/src/index.ts +++ b/packages/agent-sessions/src/index.ts @@ -43,6 +43,7 @@ export { export type { CodexObservedTopologyOptions, } from "./adapters/codex/topology.js"; +export { createAdapter as createAcpAdapter } from "./adapters/acp.js"; export { createAdapter as createOpenAiCompatAdapter } from "./adapters/openai-compat.js"; export { createAdapter as createOpencodeAdapter } from "./adapters/opencode.js"; export { createAdapter as createPiAdapter } from "./adapters/pi.js"; diff --git a/packages/web/server/core/pairing/runtime/runtime.ts b/packages/web/server/core/pairing/runtime/runtime.ts index 1056af53..d4a43f2c 100644 --- a/packages/web/server/core/pairing/runtime/runtime.ts +++ b/packages/web/server/core/pairing/runtime/runtime.ts @@ -1,6 +1,7 @@ import { homedir } from "node:os"; import { + createAcpAdapter as createAcp, createClaudeCodeAdapter as createClaudeCode, createCodexAdapter as createCodex, createOpenAiCompatAdapter as createOpenAI, @@ -30,6 +31,7 @@ export type StartedPairingRuntime = { export function createPairingAdapterRegistry(configAdapters?: Record) { const adapters: Record = { "claude-code": createClaudeCode, + acp: createAcp, codex: createCodex, pi: createPi, opencode: createOpenCode, From fa7f1b0c98d27e3ab671f53a21f5860d91d4ce79 Mon Sep 17 00:00:00 2001 From: Arach Date: Tue, 2 Jun 2026 22:19:19 -0400 Subject: [PATCH 5/6] =?UTF-8?q?=F0=9F=92=84=20Refresh=20landing=20page=20e?= =?UTF-8?q?xperience?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update the landing page layout, styling, and Scout console treatment with the new mac roster asset. --- landing/public/mac/hud-agents-roster.png | Bin 0 -> 83264 bytes landing/src/app/globals.css | 538 +++++++++++++++++++---- landing/src/app/layout.tsx | 50 +-- landing/src/app/page.tsx | 336 ++++++-------- landing/src/components/scout-console.tsx | 19 +- 5 files changed, 607 insertions(+), 336 deletions(-) create mode 100644 landing/public/mac/hud-agents-roster.png diff --git a/landing/public/mac/hud-agents-roster.png b/landing/public/mac/hud-agents-roster.png new file mode 100644 index 0000000000000000000000000000000000000000..dc8b499f5b124ce9fdf279a9f726c4916a2d52d8 GIT binary patch literal 83264 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 literal 0 HcmV?d00001 diff --git a/landing/src/app/globals.css b/landing/src/app/globals.css index 2b0cdad8..83a610bc 100644 --- a/landing/src/app/globals.css +++ b/landing/src/app/globals.css @@ -1,66 +1,95 @@ @import "tailwindcss"; :root { - --background: #08090a; - --foreground: #f0f0f0; - --muted: #919191; - --secondary: #b5b5b5; - --accent: #3dacff; - --accent-soft: #3dacff18; - --border: #1e1f22; - --border-strong: #303136; - --surface: #0e0f11; - --surface-elevated: #171819; - --shell-bg: #f4f6f8; - --shell-panel: rgba(247, 249, 252, 0.9); - --shell-ink: #252a31; - --shell-copy: #464c55; - --shell-dim: #7e8793; - --shell-muted: #b6bcc5; - --shell-line: rgba(15, 23, 42, 0.09); - --shell-line-strong: rgba(15, 23, 42, 0.16); - --shell-accent: #5b84ff; - --site-page-bg: #f5f1ea; - --site-page-bg-strong: rgba(245, 241, 234, 0.95); - --site-docs-bg: #f7f3ec; - --site-docs-bg-strong: rgba(247, 243, 236, 0.95); - --site-surface: rgba(255, 253, 248, 0.96); - --site-surface-strong: #fffdf8; - --site-panel: #faf6ee; - --site-panel-muted: #ede7db; - --site-panel-rail: #f4eee2; - --site-border: #d9cfb9; - --site-border-strong: #c8bea4; - --site-border-soft: rgba(26, 22, 18, 0.10); - --site-ink: #1a1612; - --site-copy: #3b342b; - --site-muted: #6b6356; - --site-muted-soft: #968b79; - --site-accent: #b5421c; - --site-accent-soft: rgba(181, 66, 28, 0.08); - --site-accent-soft-strong: #f7e9d6; - --site-accent-border: #e8d2b1; - --site-ink-contrast: #f5f4ef; - --site-ink-hover: #2a2a28; - --site-overlay: rgba(17, 17, 16, 0.72); - --site-progress: rgba(17, 17, 16, 0.2); - --site-grid: #c8c4ba; - --site-glow-a: rgba(168, 85, 28, 0.05); - --site-glow-b: rgba(168, 85, 28, 0.08); - --site-glow-c: rgba(212, 140, 70, 0.06); - --site-status-online: #2f8a4f; - --site-status-online-soft: rgba(47, 138, 79, 0.18); - --site-card-shadow: - 0 2px 8px rgba(17, 17, 16, 0.03); - --site-card-shadow-hover: - 0 4px 12px rgba(17, 17, 16, 0.06), - 0 20px 48px rgba(17, 17, 16, 0.08); - --site-panel-shadow: - 0 1px 2px rgba(17, 17, 16, 0.04), - 0 24px 80px rgba(17, 17, 16, 0.08); - --site-toggle-shadow: - 0 16px 40px rgba(17, 17, 16, 0.16); - --site-danger: #b2442e; + /* ── Basel — reductive system: near-monochrome on white, one grotesque, + a single rationed red. Token names are kept stable so the whole site + re-themes from this block; every legacy "warm/RFC" value is gone. ── */ + + /* Monochrome ramp — paper → near-black ink */ + --paper: oklch(0.993 0 0); + --paper-2: oklch(0.972 0 0); + --paper-3: oklch(0.940 0 0); + --ink: oklch(0.205 0 0); + --ink-2: oklch(0.400 0 0); + --ink-3: oklch(0.560 0 0); + --ink-faint: oklch(0.720 0 0); + --line: oklch(0.885 0 0); /* hairline divider */ + --line-soft: oklch(0.925 0 0); /* faintest divider */ + --rule: oklch(0.205 0 0); /* heavy structural rule = ink */ + + /* The one chromatic colour — spent once per view */ + --red: oklch(0.575 0.218 27); + --red-soft: oklch(0.575 0.218 27 / 0.10); + + /* One family. All historical font slots resolve to Archivo. */ + --font-geist-sans: var(--font-archivo), system-ui, sans-serif; + --font-geist-mono: var(--font-archivo), system-ui, sans-serif; + --font-display: var(--font-archivo), system-ui, sans-serif; + --font-mono-display: var(--font-archivo), system-ui, sans-serif; + --font-spectral: var(--font-archivo), system-ui, sans-serif; + + /* Global / Tailwind theme tokens */ + --background: var(--paper); + --foreground: var(--ink); + --muted: var(--ink-3); + --secondary: var(--ink-2); + --accent: var(--red); + --accent-soft: var(--red-soft); + --border: var(--line); + --border-strong: var(--rule); + --surface: var(--paper); + --surface-elevated: var(--paper-2); + + /* App-shell demo surfaces (broker console, etc.) */ + --shell-bg: var(--paper-2); + --shell-panel: var(--paper); + --shell-ink: var(--ink); + --shell-copy: var(--ink-2); + --shell-dim: var(--ink-3); + --shell-muted: var(--ink-faint); + --shell-line: var(--line); + --shell-line-strong: var(--rule); + --shell-accent: var(--red); + + /* Marketing-site surfaces */ + --site-page-bg: var(--paper); + --site-page-bg-strong: var(--paper); + --site-docs-bg: var(--paper-2); + --site-docs-bg-strong: var(--paper-2); + --site-surface: var(--paper); + --site-surface-strong: var(--paper); + --site-panel: var(--paper-2); + --site-panel-muted: var(--paper-2); + --site-panel-rail: var(--paper-2); + --site-border: var(--line); + --site-border-strong: var(--rule); + --site-border-soft: var(--line-soft); + --site-ink: var(--ink); + --site-copy: var(--ink-2); + --site-muted: var(--ink-3); + --site-muted-soft: var(--ink-faint); + --site-accent: var(--red); + --site-accent-soft: var(--red-soft); + --site-accent-soft-strong: oklch(0.575 0.218 27 / 0.16); + --site-accent-border: oklch(0.575 0.218 27 / 0.40); + --site-ink-contrast: var(--paper); + --site-ink-hover: oklch(0 0 0); + --site-overlay: oklch(0.205 0 0 / 0.72); + --site-progress: oklch(0.205 0 0 / 0.20); + --site-grid: var(--line); + /* No glows in Basel */ + --site-glow-a: transparent; + --site-glow-b: transparent; + --site-glow-c: transparent; + /* Idle status is neutral ink — red is reserved for the active mark */ + --site-status-online: var(--ink-3); + --site-status-online-soft: oklch(0.560 0 0 / 0.18); + /* No shadows in Basel — separation comes from hairlines + whitespace */ + --site-card-shadow: none; + --site-card-shadow-hover: none; + --site-panel-shadow: none; + --site-toggle-shadow: none; + --site-danger: var(--red); } html[data-site-theme="light"] { @@ -150,8 +179,8 @@ body { } ::selection { - background: #2a57cb; - color: white; + background: var(--red); + color: #fff; } /* ── entrance animations ── */ @@ -479,7 +508,7 @@ html[data-site-theme="dark"] .dot-grid { color: var(--site-ink); background: var(--site-panel-muted); border: 1px solid var(--site-border); - border-radius: 6px; + border-radius: 9px; cursor: pointer; transition: border-color 0.18s ease, background-color 0.18s ease; } @@ -532,7 +561,7 @@ html[data-site-theme="dark"] .dot-grid { .rfc-hero__viewer-row { display: flex; justify-content: flex-end; - margin-bottom: 1.5rem; + margin-bottom: 0.5rem; } .rfc-hero__title--full { @@ -609,6 +638,62 @@ html[data-site-theme="dark"] .dot-grid { color: var(--site-ink); } +/* Hero masthead — RFC-style header fields grounding the editorial column so it + carries weight down the page beside the taller live console. Definition list: + uppercase key column, dot-delimited values, hairline-ruled rows. */ +.rfc-hero__masthead { + margin: 2.25rem 0 0; + padding-top: 1.15rem; + border-top: 1px solid var(--site-border); + display: flex; + flex-direction: column; + max-width: 46ch; +} + +.rfc-hero__masthead-row { + display: grid; + grid-template-columns: 7rem minmax(0, 1fr); + gap: 1rem; + align-items: baseline; + padding: 0.62rem 0; + border-bottom: 1px solid var(--site-border-soft); +} + +.rfc-hero__masthead-row:last-child { + border-bottom: 0; +} + +.rfc-hero__masthead-key { + margin: 0; + font-family: var(--font-geist-mono), ui-monospace, monospace; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--site-muted); +} + +.rfc-hero__masthead-val { + margin: 0; + font-family: var(--font-geist-mono), ui-monospace, monospace; + font-size: 12.5px; + letter-spacing: 0.01em; + line-height: 1.5; + color: var(--site-ink); + display: flex; + flex-wrap: wrap; + align-items: baseline; +} + +.rfc-hero__masthead-token { + white-space: nowrap; +} + +.rfc-hero__masthead-sep { + color: var(--site-muted-soft); + margin: 0 0.5rem; +} + /* Hero split: editorial left, console right, console top-aligned with the headline for that "page-of-the-spec next to the live system" feel. */ .rfc-hero__split { @@ -624,12 +709,27 @@ html[data-site-theme="dark"] .dot-grid { .rfc-hero__console-col { min-width: 0; + position: relative; +} + +/* The "are you an agent?" toggle perches on the console's top-left shoulder — + a small playful control on the live system, not a chip floating over the + right margin. Absolutely placed so it never pushes the headline down. */ +.rfc-hero__viewer-perch { + position: absolute; + left: 2px; + bottom: calc(100% + 0.55rem); + z-index: 4; } @media (max-width: 920px) { .rfc-hero__split { grid-template-columns: 1fr; - gap: 2rem; + gap: 3.25rem; + } + /* On the stacked layout the perch sits in the gap above the console. */ + .rfc-hero__viewer-perch { + bottom: calc(100% + 0.65rem); } } @@ -671,7 +771,7 @@ html[data-site-theme="dark"] .viewer-toggle-floater { margin: 0; cursor: pointer; font-family: var(--font-geist-mono), ui-monospace, monospace; - font-size: 12px; + font-size: 11px; letter-spacing: 0.04em; color: var(--site-muted); } @@ -1128,11 +1228,65 @@ main { counter-reset: rfc-section; } color: var(--site-copy); } +/* §4 host-integration cards — boxed, equal-height, softened corners. */ +.integration-block { + display: flex; + flex-direction: column; + gap: 0.7rem; + height: 100%; + padding: 1.15rem 1.2rem 1.05rem; + background: var(--site-panel-muted); + border: 1px solid var(--site-border); + border-radius: 14px; + transition: border-color 0.18s ease, background-color 0.18s ease; +} + +.integration-block:hover { + border-color: var(--site-accent-border); + background: var(--site-surface); +} + .integration-block__header { display: flex; align-items: center; gap: 0.75rem; - min-height: 2.25rem; +} + +.integration-block__heading { + display: flex; + flex-direction: column; + gap: 0.18rem; + min-width: 0; +} + +.integration-block__name { + font-family: var(--font-spectral), ui-serif, Georgia, serif; + font-weight: 600; + font-size: 1rem; + line-height: 1.2; + letter-spacing: -0.005em; + color: var(--site-ink); +} + +.integration-block__install { + display: block; + font-family: var(--font-geist-mono), ui-monospace, monospace; + font-size: 11px; + line-height: 1.5; + color: var(--site-copy); + background: var(--site-page-bg); + border: 1px solid var(--site-border-soft); + border-radius: 8px; + padding: 0.45rem 0.6rem; + overflow-wrap: anywhere; +} + +.integration-block__links { + margin-top: auto; + padding-top: 0.15rem; + display: flex; + flex-wrap: wrap; + gap: 0.35rem 1.1rem; } .integration-block__mark { @@ -2279,16 +2433,19 @@ html[data-site-theme="dark"] .docs-markdown :is(h1, h2, h3, h4) > a[title="Link ────────────────────────────────────────────────────────── */ .scout-console { - --term-bg: #15110C; - --term-bg-2: #1B1611; - --term-rule: #2E261C; - --term-ink: #D7CCB6; - --term-dim: #8E826B; - --term-rust: #E2854E; - --term-rust-soft: rgba(226, 133, 78, 0.10); - --term-rust-soft-strong: rgba(226, 133, 78, 0.18); + /* Tuned to match the native menu-bar HUD: neutral near-black, green accent. */ + --term-bg: #0E100D; + --term-bg-2: #14171260; + --term-rule: #23271F; + --term-ink: #D7DACE; + --term-dim: #7F8674; + /* "rust" is the historical accent token — repointed to the HUD green so the + active tab, live dot, send action, and operator mark all read green. */ + --term-rust: #86BC63; + --term-rust-soft: rgba(134, 188, 99, 0.12); + --term-rust-soft-strong: rgba(134, 188, 99, 0.20); --term-blue: #7AA4C9; - --term-green: #8FB36A; + --term-green: #86BC63; --term-amber: #D5B070; background: var(--term-bg); @@ -2296,7 +2453,7 @@ html[data-site-theme="dark"] .docs-markdown :is(h1, h2, h3, h4) > a[title="Link font-family: var(--font-geist-mono), ui-monospace, monospace; font-size: 12px; border: 1px solid #0a0805; - border-radius: 10px; + border-radius: 16px; overflow: hidden; display: flex; flex-direction: column; @@ -2324,10 +2481,10 @@ html[data-site-theme="dark"] .scout-console { gap: 12px; padding: 10px 14px; border-bottom: 1px solid #000; - background: linear-gradient(to bottom, #2A2118 0%, #1F1812 55%, #1A140E 100%); + background: linear-gradient(to bottom, #1B1E18 0%, #15171300 55%, #101210 100%); box-shadow: - inset 0 -1px 0 rgba(255, 240, 210, 0.04), - inset 0 1px 0 rgba(255, 240, 210, 0.08); + inset 0 -1px 0 rgba(230, 240, 220, 0.04), + inset 0 1px 0 rgba(230, 240, 220, 0.07); color: var(--term-dim); font-size: 10.5px; letter-spacing: 0.04em; @@ -2434,12 +2591,19 @@ html[data-site-theme="dark"] .scout-console { content: ""; position: absolute; left: 0; - top: 0; + right: 0; bottom: -1px; - width: 2px; + height: 2px; background: var(--term-rust); } +/* Numbered slot marks, like the HUD's "1 agents · 2 activity ·" tab strip. */ +.scout-console__tab-mark { + font-variant-numeric: tabular-nums; + font-weight: 600; + opacity: 0.85; +} + .scout-console__tab.is-on .scout-console__tab-mark { color: var(--term-rust); } @@ -2449,6 +2613,96 @@ html[data-site-theme="dark"] .scout-console { border-bottom: 0; } +/* Composer bar pinned to the bottom of the console — reads like a modern + message input (rounded field + send affordance) rather than a flat strip. */ +.scout-console__cmdbar { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border-top: 1px solid var(--term-rule); + background: linear-gradient(to top, rgba(0, 0, 0, 0.30), transparent); + flex-shrink: 0; +} + +.scout-console__cmdbar-field { + flex: 1; + min-width: 0; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 7px 11px; + background: rgba(0, 0, 0, 0.32); + border: 1px solid var(--term-rule); + border-radius: 9px; + box-shadow: + inset 0 1px 2px rgba(0, 0, 0, 0.35), + inset 0 0 0 0.5px rgba(255, 240, 210, 0.02); +} + +.scout-console__cmdbar-glyph { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--term-green); + box-shadow: 0 0 6px rgba(134, 188, 99, 0.7); + flex-shrink: 0; +} + +.scout-console__cmdbar-hint { + color: var(--term-dim); + font-size: 11px; + letter-spacing: 0.02em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.scout-console__cmdbar-cue { + color: var(--term-ink); +} + +/* Blinking insertion caret — sells the "live input" read. */ +.scout-console__cmdbar-caret { + width: 1.5px; + height: 13px; + margin-left: auto; + background: var(--term-green); + border-radius: 1px; + flex-shrink: 0; + animation: scout-caret 1.1s steps(1) infinite; +} + +@keyframes scout-caret { + 0%, 50% { opacity: 0.9; } + 51%, 100% { opacity: 0; } +} + +.scout-console__cmdbar-send { + display: inline-flex; + align-items: center; + gap: 5px; + flex-shrink: 0; + padding: 6px 9px 6px 11px; + border-radius: 9px; + background: var(--term-rust-soft); + border: 1px solid rgba(134, 188, 99, 0.30); + color: var(--term-rust); + font-size: 10.5px; + letter-spacing: 0.04em; + text-transform: lowercase; +} + +.scout-console__cmdbar-send kbd { + font-family: inherit; + font-size: 10px; + color: var(--term-rust); + background: rgba(134, 188, 99, 0.16); + border-radius: 5px; + padding: 0 5px; + line-height: 1.5; +} + /* stage = rail + body */ .scout-console__stage { flex: 1; @@ -2964,3 +3218,123 @@ html[data-site-theme="dark"] .scout-console { display: block; color: var(--site-copy); } + +/* ══════════════════════════════════════════════════════════ + BASEL — reductive enforcement layer + Scoped to the marketing landing (.site-marketing). Sharp corners, no + shadows, no glows, no frosted chrome — separation comes from hairlines + and whitespace. One grotesque (Archivo); hierarchy from scale + weight, + kept deliberately light rather than poster-black. + ══════════════════════════════════════════════════════════ */ + +/* Basel stays flat and quiet — no shadows, no glows, no frosted chrome — but + corners are no longer hard-zeroed. Each component keeps its own modest radius + so the system reads as intentionally soft, not brittle. */ +.site-marketing, +.site-marketing *, +.site-marketing *::before, +.site-marketing *::after { + box-shadow: none !important; + text-shadow: none !important; +} + +/* The broker console is the one surface allowed depth — it reads as a live + window floating on the page, not a flat panel pinned to the grid. */ +.site-marketing .scout-console { + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.5), + 0 18px 40px -24px rgba(28, 22, 16, 0.32), + 0 6px 16px -12px rgba(28, 22, 16, 0.20) !important; +} + +/* No frosted chrome — surfaces sit on the grid, not floating above it. */ +.site-marketing .rfc-doc-strip, +.site-marketing .viewer-toggle-floater { + -webkit-backdrop-filter: none !important; + backdrop-filter: none !important; + background: var(--site-page-bg) !important; +} + +/* When the doc-strip sticks, mark it with the heavy black structural rule + that opens every Basel section, not a soft tinted hairline. */ +.site-marketing .rfc-doc-strip.is-stuck { + border-bottom-color: var(--rule) !important; +} + +/* Display type — Archivo, kept on the lighter side of the ramp. The headline + reads as a calm grotesque statement; weight is a nudge, never a slab. */ +.site-marketing .rfc-hero__title--full { + font-weight: 400; + letter-spacing: -0.03em; + /* A touch smaller — calm grotesque, lets the hero breathe. */ + font-size: clamp(1.6rem, 3vw, 2.7rem); +} +.site-marketing + .rfc-hero__title--full + .rfc-hero__title-line + + .rfc-hero__title-line { + font-weight: 400; /* red mono kicker line stays light */ +} +.site-marketing .rfc-section-title { + font-weight: 500; +} +.site-marketing .rfc-block__title, +.site-marketing .rfc-figure__caption-title { + font-weight: 500; +} +.site-marketing .integration-block__mark { + font-weight: 600; +} + +/* Whitespace does the separating — keep the ambient dot-grid barely there. */ +.site-marketing .dot-grid { + opacity: 0.2; +} + +/* Registration / corner crosses — a small Swiss "design control" placed just + inside the corners of framed surfaces (commands, figures, the terminal). + Eight thin accent strokes form a "+" at each corner; subtle by size + alpha. */ +.basel-crosses { + position: relative; +} + +/* Overlay holding the four corner marks; sits above the framed surface. */ +.basel-crosses__marks { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 3; +} + +/* Each mark is a small "+" built from two 1px bars in the accent. */ +.basel-cross { + position: absolute; + width: 9px; + height: 9px; + opacity: 0.6; + color: var(--site-accent); +} +.basel-cross::before, +.basel-cross::after { + content: ""; + position: absolute; + background: currentColor; +} +.basel-cross::before { + top: 50%; + left: 0; + width: 100%; + height: 1px; + transform: translateY(-50%); +} +.basel-cross::after { + left: 50%; + top: 0; + width: 1px; + height: 100%; + transform: translateX(-50%); +} +.basel-cross--tl { top: 6px; left: 6px; } +.basel-cross--tr { top: 6px; right: 6px; } +.basel-cross--bl { bottom: 6px; left: 6px; } +.basel-cross--br { bottom: 6px; right: 6px; } diff --git a/landing/src/app/layout.tsx b/landing/src/app/layout.tsx index 30108213..15914b59 100644 --- a/landing/src/app/layout.tsx +++ b/landing/src/app/layout.tsx @@ -1,39 +1,19 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; -import { Instrument_Serif, IBM_Plex_Mono, Spectral } from "next/font/google"; +import { Archivo } from "next/font/google"; import { GoogleAnalyticsTag } from "@/components/google-analytics-tag"; -import { SITE_THEME_INIT_SCRIPT } from "@/lib/site-theme"; import "./globals.css"; -const geistSans = Geist({ - variable: "--font-geist-sans", +// Basel — one grotesque across the whole system. Every legacy font variable +// (--font-spectral / --font-mono-display / --font-geist-* / --font-display) is +// aliased to this single family in globals.css, so hierarchy comes from weight +// and scale alone, never from a second face. +const archivo = Archivo({ + variable: "--font-archivo", subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); - -const instrumentSerif = Instrument_Serif({ - variable: "--font-display", - subsets: ["latin"], - weight: "400", + weight: ["300", "400", "500", "600", "700", "800", "900"], style: ["normal", "italic"], }); -const plexMono = IBM_Plex_Mono({ - variable: "--font-mono-display", - subsets: ["latin"], - weight: ["400", "500", "600", "700"], -}); - -const spectral = Spectral({ - variable: "--font-spectral", - subsets: ["latin"], - weight: ["400", "500", "600"], -}); - export const metadata: Metadata = { title: "OpenScout — Local Agent Broker", metadataBase: new URL("https://openscout.app"), @@ -78,19 +58,9 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - - -