Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ jobs:
- name: Build
run: nix build --quiet

- name: Unit tests
run: >-
nix shell nixpkgs#tmux --quiet --command
nix run .#tests --
--skip 'Terminal relay'
--skip 'claude integration'

build-darwin:
name: Build (aarch64-darwin)
runs-on: macos-14
Expand All @@ -54,3 +61,24 @@ jobs:

- name: Build
run: nix build -L

integration:
name: Claude Integration Tests
needs: build
runs-on: nixos
environment: claude-tests
steps:
- uses: actions/checkout@v4

- uses: cachix/cachix-action@v15
with:
name: paolino
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}

- name: Integration tests
run: |
export PATH="/run/current-system/sw/bin:$PATH"
nix shell nixpkgs#tmux --quiet --command \
nix run .#tests -- \
--match 'claude integration' \
--skip 'resumes conversation'
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
Auto-generated from all feature plans. Last updated: 2026-03-29

## Active Technologies
- Haskell, GHC 9.8.4 via haskell.nix + typed-process (existing), servant/servant-server, aeson, warp, websockets, stm, posix-pty (003-dual-mode-sessions)
- N/A (in-memory TVar state) (003-dual-mode-sessions)

- Haskell, GHC 9.8.4 via haskell.nix + typed-process (new), aeson, warp, websockets, stm, posix-pty (002-git-library-bindings)

Expand All @@ -22,6 +24,7 @@ tests/
Haskell, GHC 9.8.4 via haskell.nix: Follow standard conventions

## Recent Changes
- 003-dual-mode-sessions: Added Haskell, GHC 9.8.4 via haskell.nix + typed-process (existing), servant/servant-server, aeson, warp, websockets, stm, posix-pty

- 002-git-library-bindings: Added Haskell, GHC 9.8.4 via haskell.nix + typed-process (new), aeson, warp, websockets, stm, posix-pty

Expand Down
5 changes: 5 additions & 0 deletions agent-daemon.cabal
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ library
AgentDaemon.Git
AgentDaemon.Recovery
AgentDaemon.Server
AgentDaemon.Structured
AgentDaemon.Terminal
AgentDaemon.Tmux
AgentDaemon.Types
Expand Down Expand Up @@ -95,7 +96,9 @@ test-suite e2e-tests
AgentDaemon.BranchSpec
AgentDaemon.GitSpec
AgentDaemon.RecoverySpec
AgentDaemon.StructuredSpec
AgentDaemon.TerminalSpec
AgentDaemon.TypesSpec

hs-source-dirs: test
default-language: GHC2021
Expand All @@ -116,8 +119,10 @@ test-suite e2e-tests
, process >=1.6 && <1.7
, servant >=0.20 && <0.21
, servant-client >=0.20 && <0.21
, stm >=2.5 && <2.6
, temporary >=1.3 && <1.4
, text >=2.0 && <2.2
, time >=1.12 && <1.15
, warp >=3.3 && <3.5
, websockets >=0.12 && <0.14

Expand Down
1 change: 1 addition & 0 deletions nix/project.nix
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ let
in {
packages = {
main = project.hsPkgs.agent-daemon.components.exes.agent-daemon;
tests = project.hsPkgs.agent-daemon.components.tests.e2e-tests;
inherit static;
};
devShells.default = project.shell;
Expand Down
35 changes: 35 additions & 0 deletions specs/003-dual-mode-sessions/checklists/requirements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Specification Quality Checklist: Dual-Mode Sessions

**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-03-29
**Feature**: [spec.md](../spec.md)

## Content Quality

- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed

## Requirement Completeness

- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified

## Feature Readiness

- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification

## Notes

- Assumption about SSE vs WebSocket for streaming is documented — planning phase should confirm.
- Permission callback forwarding depends on the stream-json protocol structure — research needed.
74 changes: 74 additions & 0 deletions specs/003-dual-mode-sessions/data-model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Data Model: Dual-Mode Sessions

## New Entities

### SessionMode

Enumeration of how the claude process runs inside a tmux session.

- **Terminal**: TUI mode (existing). Claude runs interactively, terminal I/O bridged via PTY/WebSocket.
- **Structured**: Stream-JSON mode. Claude runs with `--output-format stream-json --input-format stream-json`. Daemon writes prompts to stdin, reads NDJSON from stdout.

### StructuredProcess

Handle to a running claude process in structured mode. Held in memory (not persisted).

- **processHandle**: The typed-process handle for the running claude CLI
- **processStdin**: Write end for sending JSON messages
- **processStdout**: Read end for receiving NDJSON events
- **claudeSessionId**: The UUID from the `system/init` event, used for `--resume`
- **promptInProgress**: Whether a prompt is currently being processed (mutual exclusion)

### PromptRequest

A text prompt sent by a non-terminal client.

- **prompt**: The text content to send to claude

### StreamEvent

A single NDJSON line from the claude process stdout, forwarded to the client as an SSE event.

- **eventType**: `system`, `assistant`, `result`, `stream_event`
- **payload**: The raw JSON object

## Modified Entities

### Session (extended)

Add fields:
- **sessionMode**: `Terminal | Structured` — current mode
- **sessionClaudeId**: Optional UUID — the claude conversation session ID, captured from `system/init` on first structured run. Used for `--resume`.
- **sessionProcess**: Optional handle to the structured process (only present in Structured mode)

### SessionState (unchanged)

No changes needed. The existing states (Creating, Running, Attached, Stopping, Failed) apply to both modes.

## State Transitions

```
[Create Session] → Creating → Running (Terminal mode, default)
[Switch to Structured]
Running (Structured mode)
[Switch to Terminal]
Running (Terminal mode)
```

Mode switches:
1. Kill current claude process (in tmux for terminal, process handle for structured)
2. Respawn with appropriate flags + `--resume <claudeSessionId>`
3. Update sessionMode

## Validation Rules

- Mode switch only allowed when session is in Running state
- Prompt endpoint only accepts requests when session is in Structured mode
- Only one prompt at a time per session (promptInProgress flag)
- claudeSessionId must be captured before any `--resume` can work
100 changes: 100 additions & 0 deletions specs/003-dual-mode-sessions/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# Implementation Plan: Dual-Mode Sessions

**Branch**: `003-dual-mode-sessions` | **Date**: 2026-03-29 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `/specs/003-dual-mode-sessions/spec.md`

## Summary

Add a second session mode ("structured") where the claude process runs with `--output-format stream-json --input-format stream-json` instead of as a TUI. The daemon manages the process via `typed-process`, exposes a prompt endpoint that accepts text and streams back JSON events via SSE, and supports switching between modes with `--resume` to preserve conversation history. See [research.md](research.md) for the stream-json protocol details.

## Technical Context

**Language/Version**: Haskell, GHC 9.8.4 via haskell.nix
**Primary Dependencies**: typed-process (existing), servant/servant-server, aeson, warp, websockets, stm, posix-pty
**Storage**: N/A (in-memory TVar state)
**Testing**: hspec (existing suite + new tests)
**Target Platform**: x86_64-linux, aarch64-darwin
**Project Type**: daemon (WebSocket + REST server)
**Constraints**: Must preserve all existing terminal-mode functionality unchanged
**Scale/Scope**: ~500 lines new code, 3 modules modified, 2 new modules

## Constitution Check

*Constitution is not yet defined for this project. No gates to evaluate.*

## Project Structure

### Documentation (this feature)

```text
specs/003-dual-mode-sessions/
├── spec.md
├── plan.md # This file
├── research.md # Stream-JSON protocol research
├── data-model.md # SessionMode, StructuredProcess types
└── tasks.md # Created by /speckit.tasks
```

### Source Code (repository root)

```text
src/AgentDaemon/
├── Types.hs # MODIFIED — add SessionMode, extend Session
├── Structured.hs # NEW — structured process management (spawn, prompt, kill)
├── Api.hs # MODIFIED — add mode switch + prompt endpoints
├── Api/Types.hs # MODIFIED — add new servant routes
├── Tmux.hs # MODIFIED — extract claude launch for reuse
├── Server.hs # MINOR — no changes expected (SSE is plain HTTP)
└── ... # unchanged
```

**Structure Decision**: One new module `AgentDaemon.Structured` handles the stream-json process lifecycle. Api.hs gets new handlers. Types.hs gets the mode enum and extended Session type.

## Design

### Phase 1: Types & Mode (User Story 1 + 3 — P1, P3)

1. **Add `SessionMode`** to `AgentDaemon.Types`:
- `data SessionMode = Terminal | Structured`
- JSON instances for API responses
2. **Extend `Session`** with:
- `sessionMode :: SessionMode` (default `Terminal`)
- `sessionClaudeId :: Maybe Text` (claude conversation UUID)
3. **Add mode to API responses** — `GET /sessions` now includes `mode` field
4. **Add mode switch endpoint** to servant API:
- `POST /sessions/:id/mode` with body `{"mode": "structured"}` or `{"mode": "terminal"}`
5. **Implement mode switch handler** in `Api.hs`:
- Validate session is Running
- Kill current claude process
- Respawn with new flags + `--resume`
- Update session mode in TVar

### Phase 2: Structured Process (User Story 2 — P2)

1. **Create `AgentDaemon.Structured`**:
- `spawnStructured :: FilePath -> Maybe Text -> IO StructuredProcess` — spawn claude with stream-json flags, optionally `--resume`
- `sendPrompt :: StructuredProcess -> Text -> IO ()` — write user message JSON to stdin
- `readEvents :: StructuredProcess -> (StreamEvent -> IO ()) -> IO ()` — read NDJSON lines, invoke callback per event
- `killStructured :: StructuredProcess -> IO ()` — terminate the process
- Parse `system/init` event to capture `claudeSessionId`
2. **Add prompt endpoint** to servant API:
- `POST /sessions/:id/prompt` with body `{"prompt": "text"}`
- Response: SSE stream (chunked transfer encoding with `text/event-stream`)
3. **Implement prompt handler**:
- Validate session is in Structured mode
- Check promptInProgress flag (reject if busy)
- Write prompt to process stdin
- Stream NDJSON events back as SSE until `result` event
- Clear promptInProgress flag

### Phase 3: Integration & Polish

1. **Mode switch for terminal→structured**: Kill claude in tmux (send Ctrl-C + wait), spawn structured process
2. **Mode switch for structured→terminal**: Kill structured process, respawn claude in tmux with `sendKeys`
3. **Recovery**: On daemon restart, recovered sessions default to Terminal mode (existing behavior)
4. **Tests**: Integration tests for mode switching, prompt flow, error cases
5. **Edge cases**: Handle process crash during mode switch, reject switch during transitional states

## Complexity Tracking

No constitution violations to justify.
64 changes: 64 additions & 0 deletions specs/003-dual-mode-sessions/research.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Research: Dual-Mode Sessions

## Decision: Use stream-json CLI protocol directly via typed-process

### Rationale

The claude CLI natively supports structured I/O via `--output-format stream-json --input-format stream-json`. The daemon can spawn the claude process with `typed-process`, write JSON to stdin, and read NDJSON from stdout — no SDK dependency needed.

### Claude CLI Flags

**Structured mode invocation:**
```
claude -p \
--output-format stream-json \
--input-format stream-json \
--verbose \
--dangerously-skip-permissions \
--resume <session-id>
```

- `-p` / `--print`: non-interactive mode (required for stdin/stdout I/O)
- `--verbose`: required when using `--output-format stream-json`
- `--dangerously-skip-permissions`: bypasses all permission prompts
- `--resume <session-id>`: resumes conversation history from a previous session

### Stream-JSON Protocol

**Output (stdout)** — newline-delimited JSON, one object per line:

| `type` | When | Key fields |
|--------|------|------------|
| `system` (subtype `init`) | First line | `session_id`, `tools[]`, `model` |
| `assistant` | Complete turn | `message.content[]`, `session_id` |
| `result` | End of run | `result`, `is_error`, `duration_ms`, `total_cost_usd` |

**Input (stdin)** — newline-delimited JSON:

```json
{"type":"user","session_id":"","message":{"role":"user","content":"prompt text"},"parent_tool_use_id":null}
```

### Session ID Management

The `system/init` event returns a `session_id` UUID. This must be captured on first run and passed to `--resume` on subsequent runs (mode switches). The session ID is distinct from the daemon's `SessionId` — it's the claude conversation ID.

### Permission Bypass

`--dangerously-skip-permissions` is sufficient. No need for `--permission-prompt-tool` or control_response messages on stdin. This eliminates the entire permission callback complexity.

### Alternatives Considered

| Option | Pros | Cons |
|--------|------|------|
| claude-agent-sdk (Python) | Full SDK | Python dependency, overkill |
| MCP protocol | Standard | Wrong level of abstraction |
| **CLI stream-json** | Native, no deps, typed-process | Must parse NDJSON ourselves |

### Response Streaming to HTTP Clients

**Decision: Server-Sent Events (SSE)**

SSE is a natural fit: unidirectional server→client stream over HTTP. The daemon reads NDJSON lines from the claude process stdout and forwards each as an SSE event. Clients connect with `EventSource` or `curl`.

Alternative (WebSocket) is more complex and bidirectional capability isn't needed for prompt responses.
Loading
Loading