diff --git a/Sources/NucleicApp/NucleicApp.swift b/Sources/NucleicApp/NucleicApp.swift index 93d3faf4..1f5c96ce 100644 --- a/Sources/NucleicApp/NucleicApp.swift +++ b/Sources/NucleicApp/NucleicApp.swift @@ -24,11 +24,12 @@ struct NucleicApp: App { _store = State(initialValue: AppStore( database: database, worktrees: worktrees, transcriptsDir: transcriptsDir ) { _ in - // Interactive multi-turn session: keep stdin open so the user can - // keep chatting (closeStdinAfterPrompt: false). Our approval server is - // authoritative (--permission-mode default), child is hermetic - // (--strict-mcp-config). - ClaudeCodeBackend(configuration: .init(closeStdinAfterPrompt: false)) + // Each chat turn is a single-shot run (stdin closed), with follow-ups + // resumed via --resume. This is the only reliable way to use the + // approval server: Claude's streaming-input mode hangs alongside + // --permission-prompt-tool. Approval server is authoritative + // (--permission-mode default); child is hermetic (--strict-mcp-config). + ClaudeCodeBackend(configuration: .init(closeStdinAfterPrompt: true)) }) } catch { fatalError("Could not open the Nucleic store at \(support.path): \(error)") diff --git a/Sources/NucleicApp/RootView.swift b/Sources/NucleicApp/RootView.swift index a5dfaa13..8e71976e 100644 --- a/Sources/NucleicApp/RootView.swift +++ b/Sources/NucleicApp/RootView.swift @@ -5,7 +5,6 @@ import NucleicCore /// from UX_MACOS. struct RootView: View { @Environment(AppStore.self) private var store - @State private var showingNewSession = false @State private var showingAddProject = false var body: some View { @@ -13,17 +12,29 @@ struct RootView: View { NavigationSplitView { List(selection: $store.openSessionID) { if store.projects.isEmpty { - Text("No projects yet").foregroundStyle(.secondary) + Text("Add a project to begin").foregroundStyle(.secondary) } ForEach(store.projects) { project in - Section(project.name) { + Section { let sessions = store.summaries(for: project.id) if sessions.isEmpty { - Text("No sessions").font(.caption).foregroundStyle(.tertiary) + Text("No chats yet").font(.caption).foregroundStyle(.secondary) } ForEach(sessions) { summary in SessionRow(summary: summary).tag(Optional(summary.id)) } + } header: { + HStack { + Text(project.name) + Spacer() + Button { + Task { await store.newSession(in: project) } + } label: { + Image(systemName: "plus.circle") + } + .buttonStyle(.plain) + .help("New chat in \(project.name)") + } } } } @@ -35,23 +46,16 @@ struct RootView: View { Label("Add Project", systemImage: "folder.badge.plus") } } - ToolbarItem { - Button { showingNewSession = true } label: { - Label("New Session", systemImage: "plus") - } - .disabled(store.projects.isEmpty) - } } } detail: { if store.openSessionID != nil { SessionDetailView() } else { ContentUnavailableView( - "No session selected", systemImage: "sparkles", - description: Text("Create a session to run an agent in an isolated worktree.")) + "No chat selected", systemImage: "bubble.left.and.bubble.right", + description: Text("Pick a project in the sidebar and press + to start a chat.")) } } - .sheet(isPresented: $showingNewSession) { NewSessionSheet() } .sheet(isPresented: $showingAddProject) { AddProjectSheet() } .overlay(alignment: .bottom) { if let error = store.lastError { diff --git a/Sources/NucleicApp/SessionDetailView.swift b/Sources/NucleicApp/SessionDetailView.swift index 74cc4aca..a62cd136 100644 --- a/Sources/NucleicApp/SessionDetailView.swift +++ b/Sources/NucleicApp/SessionDetailView.swift @@ -26,6 +26,12 @@ struct SessionDetailView: View { } .toolbar { ToolbarItemGroup { + if isBusy { + Button { Task { await store.interruptOpenSession() } } label: { + Label("Stop", systemImage: "stop.circle") + } + .help("Interrupt the running turn") + } Button { Task { await store.refreshOpenStatus() } } label: { Label("Refresh", systemImage: "arrow.clockwise") } diff --git a/Sources/NucleicApp/Sheets.swift b/Sources/NucleicApp/Sheets.swift index 6d422546..13ad81b6 100644 --- a/Sources/NucleicApp/Sheets.swift +++ b/Sources/NucleicApp/Sheets.swift @@ -2,59 +2,6 @@ import SwiftUI import AppKit import NucleicCore -/// Create a session: pick a project, name it, give the agent a prompt -/// (UX_MACOS "New-session sheet"). -struct NewSessionSheet: View { - @Environment(AppStore.self) private var store - @Environment(\.dismiss) private var dismiss - - @State private var projectID: ProjectID? - @State private var title = "" - @State private var prompt = "" - @State private var creating = false - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - Text("New Session").font(.title2).bold() - Picker("Project", selection: $projectID) { - ForEach(store.projects) { project in - Text(project.name).tag(Optional(project.id)) - } - } - TextField("Title (becomes the branch name)", text: $title) - Text("Prompt").font(.caption).foregroundStyle(.secondary) - TextEditor(text: $prompt) - .font(.body).frame(minHeight: 120) - .overlay(RoundedRectangle(cornerRadius: 6).stroke(.quaternary)) - HStack { - Spacer() - Button("Cancel") { dismiss() } - Button("Create") { Task { await create() } } - .keyboardShortcut(.defaultAction) - .buttonStyle(.borderedProminent) - .disabled(projectID == nil || title.isEmpty || prompt.isEmpty || creating) - } - } - .padding(20) - .frame(width: 460) - .onAppear { if projectID == nil { projectID = store.projects.first?.id } } - } - - private func create() async { - guard let projectID, let project = store.project(projectID) else { return } - creating = true - defer { creating = false } - do { - let id = try await store.createSession(in: project, title: title, prompt: prompt) - store.openSessionID = id - dismiss() - } catch { - store.lastError = "Create session failed: \(error)" - dismiss() - } - } -} - /// Register a git repository as a project. struct AddProjectSheet: View { @Environment(AppStore.self) private var store diff --git a/Sources/NucleicCore/AppStore.swift b/Sources/NucleicCore/AppStore.swift index e996cf00..13256287 100644 --- a/Sources/NucleicCore/AppStore.swift +++ b/Sources/NucleicCore/AppStore.swift @@ -104,11 +104,27 @@ public final class AppStore { // MARK: - Session lifecycle - /// Create a worktree, open a transcript, spawn a controller, persist, and start - /// the agent — the end-to-end "new session" flow (PLAN M1). + /// Create a randomly-named chat under a project and open it. The session's + /// worktree is created immediately; the agent doesn't run until the user sends + /// the first message in the chat composer. + @discardableResult + public func newSession(in project: Project) async -> SessionID? { + do { + let id = try await createSession(in: project, title: RandomName.generate(), prompt: "") + openSessionID = id + return id + } catch { + lastError = "New session failed: \(error)" + return nil + } + } + + /// Create a worktree, open a transcript, spawn a (conversational) controller, and + /// persist. If `prompt` is non-empty the first turn starts immediately; otherwise + /// the session waits for the user's first message (PLAN M1). @discardableResult public func createSession( - in project: Project, title: String, prompt: String, base: GitRef? = nil + in project: Project, title: String, prompt: String = "", base: GitRef? = nil ) async throws -> SessionID { let sessionID = SessionID.generate() let backendID = project.defaultBackend ?? .claudeCode @@ -122,22 +138,25 @@ public final class AppStore { sessionID: sessionID, backend: backendID, worktree: worktree.path, createdAt: now()) let writer = try TranscriptWriter(url: transcriptURL, header: header) + let trimmedPrompt = prompt.trimmingCharacters(in: .whitespacesAndNewlines) let session = Session( id: sessionID, projectID: project.id, backend: backendID, - title: title, status: .running, + title: title, status: trimmedPrompt.isEmpty ? .awaitingInput : .running, worktreePath: worktree.path, branch: worktree.branch, baseSHA: worktree.baseSHA, transcriptPath: transcriptURL.path, createdAt: now(), updatedAt: now()) let controller = SessionController( session: session, backend: backendFactory(session), transcript: writer, worktreeManager: worktrees, project: project, worktree: worktree, - metadataStore: database, now: now) + metadataStore: database, conversational: true, now: now) controllers[sessionID] = controller try? await database.saveSession(session) upsertSummary(SessionSummary(session, pendingApprovalCount: 0)) observe(controller, sessionID) - await controller.start(prompt: AgentInput(text: prompt)) + if !trimmedPrompt.isEmpty { + await controller.start(prompt: AgentInput(text: trimmedPrompt)) + } return sessionID } diff --git a/Sources/NucleicCore/Backend.swift b/Sources/NucleicCore/Backend.swift index 71067d0e..3eff258d 100644 --- a/Sources/NucleicCore/Backend.swift +++ b/Sources/NucleicCore/Backend.swift @@ -149,12 +149,18 @@ public struct ResumeSpec: Sendable { public let sessionID: SessionID public let backendSessionID: String public let worktree: WorktreePath + /// The follow-up user message for this resumed turn (nil = re-attach only). + public let prompt: AgentInput? public let fork: Bool - public init(sessionID: SessionID, backendSessionID: String, worktree: WorktreePath, fork: Bool = false) { + public init( + sessionID: SessionID, backendSessionID: String, worktree: WorktreePath, + prompt: AgentInput? = nil, fork: Bool = false + ) { self.sessionID = sessionID self.backendSessionID = backendSessionID self.worktree = worktree + self.prompt = prompt self.fork = fork } } diff --git a/Sources/NucleicCore/Claude/ClaudeCodeBackend.swift b/Sources/NucleicCore/Claude/ClaudeCodeBackend.swift index ffbe2ffd..d0857922 100644 --- a/Sources/NucleicCore/Claude/ClaudeCodeBackend.swift +++ b/Sources/NucleicCore/Claude/ClaudeCodeBackend.swift @@ -104,7 +104,7 @@ public actor ClaudeCodeBackend: AgentBackend { let run = RunSpec( sessionID: resume.sessionID, worktree: resume.worktree, - prompt: AgentInput(parts: [])) + prompt: resume.prompt ?? AgentInput(parts: [])) return makeStream(run: run, resumeArgs: args) } @@ -230,7 +230,11 @@ public actor ClaudeCodeBackend: AgentBackend { let stderrTail = StderrTail() let stderrTask = Task { for try await line in handle.stderrLines { - stderrTail.append(String(decoding: line, as: UTF8.self)) + let text = String(decoding: line, as: UTF8.self) + stderrTail.append(text) + if ProcessInfo.processInfo.environment["NUCLEIC_DEBUG_STDERR"] != nil { + FileHandle.standardError.write(Data(("claude-stderr: " + text + "\n").utf8)) + } } } diff --git a/Sources/NucleicCore/RandomName.swift b/Sources/NucleicCore/RandomName.swift new file mode 100644 index 00000000..5f1f66c7 --- /dev/null +++ b/Sources/NucleicCore/RandomName.swift @@ -0,0 +1,32 @@ +import Foundation + +/// Generates a friendly session name as three hyphenated dictionary words +/// (e.g. `amber-quiet-otter`), used as both the display title and the worktree +/// branch slug when the user creates a chat without naming it. +public enum RandomName { + private static let adjectives = [ + "amber", "brisk", "calm", "dapper", "eager", "fuzzy", "gentle", "hazy", + "ivory", "jolly", "keen", "lucid", "mellow", "nimble", "olive", "plucky", + "quiet", "rustic", "sleek", "tidy", "upbeat", "vivid", "warm", "zesty", + "bold", "clever", "dusky", "frosty", "golden", "humble", + ] + private static let textures = [ + "ancient", "breezy", "coral", "dewy", "ember", "feather", "glass", "harbor", + "iris", "jade", "kelp", "lunar", "maple", "north", "opal", "pebble", + "quartz", "river", "spruce", "thistle", "umber", "velvet", "willow", "yarn", + "cedar", "drift", "fern", "grove", "meadow", "slate", + ] + private static let animals = [ + "otter", "falcon", "lynx", "heron", "bison", "marten", "raven", "shrew", + "tapir", "vole", "wren", "yak", "badger", "civet", "dingo", "egret", + "ferret", "gecko", "ibis", "koala", "lemur", "newt", "panda", "quail", + "robin", "seal", "toad", "urchin", "viper", "weasel", + ] + + public static func generate() -> String { + let a = adjectives.randomElement() ?? "amber" + let b = textures.randomElement() ?? "quiet" + let c = animals.randomElement() ?? "otter" + return "\(a)-\(b)-\(c)" + } +} diff --git a/Sources/NucleicCore/SessionController.swift b/Sources/NucleicCore/SessionController.swift index 36c24033..5f075b45 100644 --- a/Sources/NucleicCore/SessionController.swift +++ b/Sources/NucleicCore/SessionController.swift @@ -11,6 +11,13 @@ public actor SessionController { private let backend: any AgentBackend private let transcript: TranscriptWriter private let now: @Sendable () -> Date + /// Conversational (chat) mode: each turn is its own single-shot run (the first + /// via `start`, follow-ups via `resume(prompt:)` — the only reliable way to use + /// the approval server, since Claude's streaming-input mode hangs alongside + /// `--permission-prompt-tool`). A turn's terminal `runFinished` therefore means + /// "awaiting the next message", not "session over". The session ends only on + /// explicit `shutdown`/`discard`. + private let conversational: Bool // Optional git wiring — present for real sessions, nil in pure pipeline tests. private let worktreeManager: (any WorktreeManaging)? @@ -35,6 +42,7 @@ public actor SessionController { project: Project? = nil, worktree: Worktree? = nil, metadataStore: (any SessionMetadataStore)? = nil, + conversational: Bool = false, now: @escaping @Sendable () -> Date = { Date() } ) { self.session = session @@ -44,6 +52,7 @@ public actor SessionController { self.project = project self.worktree = worktree self.metadataStore = metadataStore + self.conversational = conversational self.now = now } @@ -136,12 +145,35 @@ public actor SessionController { } } - /// Queue a follow-up user turn (interactive sessions). The user's text is - /// injected into the transcript first so chat history stays complete. + /// Submit a user turn. The user's text is injected into the transcript first so + /// chat history stays complete. In conversational mode each message is its own + /// single-shot run: the first starts the session, later ones `resume` it (which + /// also carries the message), so context is retained across turns. public func sendInput(_ input: AgentInput) async throws { if let text = input.plainText, !text.isEmpty { await ingest(synthetic(.userText(makeUserChunk(text)))) } + + if conversational { + guard runTask == nil else { return } // a turn is already running + session.status = .running + session.updatedAt = now() + let worktreePath = session.worktreePath ?? worktree?.path ?? "" + if let backendSessionID = session.backendSessionID { + let spec = ResumeSpec( + sessionID: session.id, backendSessionID: backendSessionID, + worktree: worktreePath, prompt: input) + consume(backend.resume(spec), injectingUserText: nil) + } else { + // No turn has run yet → this message starts the session. + let run = RunSpec( + sessionID: session.id, worktree: worktreePath, prompt: input, + model: session.model, approvalPolicy: .interactive) + consume(backend.start(run), injectingUserText: nil) + } + return + } + try await backend.send(input) if !session.status.isTerminal { session.status = .running @@ -154,6 +186,18 @@ public actor SessionController { return TextChunk(messageID: "user-\(userTurnCount)", text: text, isPartial: false) } + /// In conversational mode a turn's terminal `runFinished` means "ready for the + /// next message", not "session over". + private func derivedStatus(_ current: SessionStatus, _ kind: AgentEvent.Kind) -> SessionStatus { + if conversational, case .runFinished(let finished) = kind { + switch finished.outcome { + case .completed, .interrupted, .maxTurns: return .awaitingInput + case .errored: return .error + } + } + return SessionStatus.transition(current, on: kind) + } + /// Resolve an outstanding approval. First responder wins inside the backend's /// coordinator; the resulting `approvalResolved` flows back through `ingest`. public func respondToApproval(_ id: ApprovalID, _ decision: Decision) async throws { @@ -167,6 +211,10 @@ public actor SessionController { public func shutdown() async { runTask?.cancel() await backend.shutdown() + if !session.status.isTerminal { + session.status = .finished + session.updatedAt = now() + } try? await transcript.sync() try? await transcript.close() finishSubscribers() @@ -230,7 +278,7 @@ public actor SessionController { } // 2. Derived status + metadata. - session.status = SessionStatus.transition(session.status, on: canonical.kind) + session.status = derivedStatus(session.status, canonical.kind) session.lastSeq = canonical.seq session.updatedAt = now() @@ -281,9 +329,20 @@ public actor SessionController { private func finishRun() async { runTask = nil - // The stream ended (process exited). A well-behaved backend already emitted a - // terminal runFinished; if not (backend died mid-turn), synthesize one so the - // session never lingers in a non-terminal state with the process gone. + if conversational { + // A turn ended; the session stays alive for the next message. Keep + // subscribers attached. Status was set to awaitingInput/error by the + // turn's runFinished; if the stream died without one, settle to awaiting. + if !session.status.isTerminal && session.status != .awaitingInput { + session.status = .awaitingInput + session.updatedAt = now() + } + try? await transcript.sync() + return + } + // One-shot: a well-behaved backend already emitted a terminal runFinished; if + // not (backend died mid-turn), synthesize one so the session never lingers + // non-terminal with the process gone. if !session.status.isTerminal { await ingest(synthetic(.runFinished(RunFinished(outcome: .interrupted)))) } diff --git a/Sources/nucleic-spike/Spike.swift b/Sources/nucleic-spike/Spike.swift index da4925cd..8244207c 100644 --- a/Sources/nucleic-spike/Spike.swift +++ b/Sources/nucleic-spike/Spike.swift @@ -15,6 +15,7 @@ struct Spike { var executable = "claude" var includePartialMessages = false var capture = false + var keepStdin = false var approvalMode: ApprovalMode = .interactive var allowedTools = ["Read", "Glob", "Grep"] var outputDirectory: String? @@ -53,7 +54,7 @@ struct Spike { executable: options.executable, includePartialMessages: options.includePartialMessages, allowedTools: options.allowedTools, - closeStdinAfterPrompt: true, + closeStdinAfterPrompt: !options.keepStdin, captureDirectory: options.capture ? outputBase.appendingPathComponent("capture") : nil) let backend = ClaudeCodeBackend(configuration: configuration) @@ -81,7 +82,9 @@ struct Spike { let stream: AsyncThrowingStream if let resume = options.resume { stream = backend.resume( - ResumeSpec(sessionID: sessionID, backendSessionID: resume, worktree: options.cwd)) + ResumeSpec( + sessionID: sessionID, backendSessionID: resume, worktree: options.cwd, + prompt: options.prompt.map { AgentInput(text: $0) })) } else { stream = backend.start( RunSpec( @@ -223,6 +226,7 @@ struct Spike { case "--claude": options.executable = try value(for: argument) case "--partial": options.includePartialMessages = true case "--capture": options.capture = true + case "--keep-stdin": options.keepStdin = true case "--out": options.outputDirectory = try value(for: argument) case "--approvals": let raw = try value(for: argument) diff --git a/Tests/NucleicCoreTests/AppStoreTests.swift b/Tests/NucleicCoreTests/AppStoreTests.swift index 19e7ffd8..751b0d18 100644 --- a/Tests/NucleicCoreTests/AppStoreTests.swift +++ b/Tests/NucleicCoreTests/AppStoreTests.swift @@ -49,14 +49,14 @@ struct AppStoreTests { in: store.project(projectID)!, title: "make a file", prompt: "go") store.openSessionID = sessionID - await waitFor { store.summaries.first?.status == .finished } + await waitFor { store.summaries.first?.status == .awaitingInput } // Settle the live diffstat deterministically (the in-stream refresh is // best-effort and can be skipped under heavy parallel git load). await store.refreshOpenStatus() let summary = try #require(store.summaries.first) #expect(summary.title == "make a file") - #expect(summary.status == .finished) + #expect(summary.status == .awaitingInput) // conversational: ready for next message #expect((summary.diffStat?.filesChanged ?? 0) >= 1) #expect(store.summaries(for: projectID).count == 1) @@ -79,7 +79,7 @@ struct AppStoreTests { let sessionID = try await store.createSession(in: project, title: "ship it", prompt: "go") store.openSessionID = sessionID - await waitFor { store.summaries.first?.status == .finished } + await waitFor { store.summaries.first?.status == .awaitingInput } let result = await store.integrateOpenSession(strategy: .squash) guard case .clean = result else { @@ -97,7 +97,7 @@ struct AppStoreTests { let sessionID = try await store.createSession(in: project, title: "scrap", prompt: "go") store.openSessionID = sessionID - await waitFor { store.summaries.first?.status == .finished } + await waitFor { store.summaries.first?.status == .awaitingInput } let wtPath = (repo.container as NSString).appendingPathComponent(".nucleic-worktrees/repo/scrap") #expect(FileManager.default.fileExists(atPath: wtPath)) diff --git a/Tests/NucleicCoreTests/SessionControllerTests.swift b/Tests/NucleicCoreTests/SessionControllerTests.swift index 2bee5322..6fcdf8da 100644 --- a/Tests/NucleicCoreTests/SessionControllerTests.swift +++ b/Tests/NucleicCoreTests/SessionControllerTests.swift @@ -9,7 +9,8 @@ private func makeController( backend: any AgentBackend, worktreeManager: (any WorktreeManaging)? = nil, project: Project? = nil, - worktree: Worktree? = nil + worktree: Worktree? = nil, + conversational: Bool = false ) throws -> (controller: SessionController, transcriptURL: URL, cleanup: @Sendable () -> Void) { let dir = (NSTemporaryDirectory() as NSString) .appendingPathComponent("nucleic-sc-\(UUID().uuidString)") @@ -27,7 +28,7 @@ private func makeController( let controller = SessionController( session: session, backend: backend, transcript: writer, worktreeManager: worktreeManager, project: project, worktree: worktree, - now: { fixedNow }) + conversational: conversational, now: { fixedNow }) return (controller, url, { try? FileManager.default.removeItem(atPath: dir) }) } @@ -183,6 +184,52 @@ struct SessionControllerTests { #expect(assistantTexts == ["First answer.", "Second answer."]) } + @Test func conversationalModeResumesPerTurnAndStaysAlive() async throws { + // Each turn is its own single-shot run that emits a terminal runFinished; + // in conversational mode that means "awaiting input", and the next message + // resumes — modeling per-turn `claude --resume`. + let turnCount = LockedBox(0) + let backend = ScriptedBackend { e, _ in + let n = turnCount.get() + 1 + turnCount.set(n) + e.emit(.sessionStarted(SessionStarted( + backendSessionID: "be-conv", model: "m", cwd: "/tmp", toolNames: []))) + e.emit(.assistantText(TextChunk(messageID: "a\(n)", text: "answer \(n)", isPartial: false))) + e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil))) + e.emit(.runFinished(RunFinished(outcome: .completed))) + } + let (controller, _, cleanup) = try makeController(backend: backend, conversational: true) + defer { cleanup() } + + let stream = await controller.subscribe() + let collected = Task { () -> [AgentEvent] in + var events: [AgentEvent] = [] + for await event in stream { events.append(event) } + return events + } + + await controller.start(prompt: AgentInput(text: "q1")) + await waitForStatus(controller, .awaitingInput) + // Terminal runFinished does NOT end a conversational session. + #expect(await controller.snapshot.session.status == .awaitingInput) + + try await controller.sendInput(AgentInput(text: "q2")) + await waitForStatus(controller, .awaitingInput) + + await controller.shutdown() + let events = await collected.value + + let userTexts = events.compactMap { e -> String? in + if case .userText(let c) = e.kind { return c.text } else { return nil } + } + let answers = events.compactMap { e -> String? in + if case .assistantText(let c) = e.kind { return c.text } else { return nil } + } + #expect(userTexts == ["q1", "q2"]) + #expect(answers == ["answer 1", "answer 2"]) + #expect(turnCount.get() == 2) // two separate runs (start + resume) + } + @Test func streamErrorSynthesizesTerminalStatus() async throws { struct Boom: Error {} let backend = ScriptedBackend { e, _ in