Fix no-response hang; per-turn-resume chat; direct-to-chat UX + Stop

ROOT CAUSE of "no responses ever appear": Claude's streaming-input mode
(stdin kept open) hangs when combined with --permission-prompt-tool — it emits
init then produces nothing. Verified by reproduction: single-shot + approval
server works; open-stdin + approval server hangs; open-stdin without the approval
server works.

Fix — conversational sessions now run one single-shot process PER TURN, with
follow-ups via native `claude --resume <id>` carrying the message. Verified
against real Claude: turn 1 "my word is ZORP" → turn 2 (resume) "what was it?" →
"ZORP" (context retained). This is the only reliable path with approvals.

- ResumeSpec carries a `prompt`; ClaudeCodeBackend.resume sends it.
- SessionController `conversational` mode: a turn's terminal runFinished means
  awaitingInput (session stays alive); sendInput resumes (or starts the first
  turn). shutdown ends it. Single-shot/one-shot behavior unchanged for non-chat.
- AppStore: sessions are conversational; createSession's prompt is optional
  (create-without-start), plus newSession(in:) for random-named chats.

UX per request:
- New chat goes straight to the chat interface with a random three-word name
  (RandomName, e.g. amber-quiet-otter); the worktree is created up front and the
  agent runs on the first message. Per-project "+" in the sidebar; sessions nest
  under their project/directory.
- Agent Stop control (interrupt) in the session toolbar while a turn runs.

Tests: conversational per-turn-resume SessionController test; AppStore tests
updated for the awaiting-input-between-turns model. Full suite 80 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
2026-06-12 02:16:25 -07:00
co-authored by Claude Opus 4.8
parent 50b15ef09d
commit 6c46010429
12 changed files with 223 additions and 94 deletions
+6 -5
View File
@@ -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)")
+17 -13
View File
@@ -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 {
@@ -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")
}
-53
View File
@@ -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
+25 -6
View File
@@ -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
}
+7 -1
View File
@@ -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
}
}
@@ -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))
}
}
}
+32
View File
@@ -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)"
}
}
+65 -6
View File
@@ -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))))
}
+6 -2
View File
@@ -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<AgentEvent, Error>
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)
+4 -4
View File
@@ -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))
@@ -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