~kris/9p

llm9p

68199d2ad65152fa84d9c9be5008dc7659afdda0 — pdfinn 7 months ago
feat: Initial implementation of llm9p - LLM as 9P filesystem

Exposes Claude as a 9P filesystem, enabling interaction through
standard file operations:

- ask: write prompt, read response (shim pattern)
- model: read/write current model name
- temperature: read/write sampling temperature
- tokens: read-only token count from last response
- new: write to reset conversation
- context: read JSON history, write to add system message
- _example: usage documentation
- stream/chunk: blocking read for streaming responses

Includes:
- Full 9P2000 protocol implementation (stdlib only)
- Anthropic SDK integration with conversation state
- Streaming support

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
A  => .gitignore +11 -0
@@ 1,11 @@
# Binary
llm9p

# IDE
.idea/
.vscode/
*.swp
*.swo

# OS
.DS_Store

A  => CLAUDE.md +271 -0
@@ 1,271 @@
# llm9p - Development Guide

This guide is for Claude Code and developers working on the llm9p codebase.

## Quick Reference

### Build and Run

```bash
# Build
go build -o llm9p ./cmd/llm9p

# Run
ANTHROPIC_API_KEY=sk-... ./llm9p -addr :5640

# Run with debug logging
ANTHROPIC_API_KEY=sk-... ./llm9p -addr :5640 -debug
```

### Testing

```bash
# Run all tests
go test ./...

# Run with coverage
go test -cover ./...

# Test specific package
go test ./internal/protocol/...
go test ./internal/llmfs/...
```

### Development Workflow

```bash
# Install dependencies
go mod tidy

# Format code
go fmt ./...

# Vet code
go vet ./...

# Build
go build -o llm9p ./cmd/llm9p
```

## Architecture Overview

### Project Structure

```
llm9p/
├── cmd/
│   └── llm9p/
│       └── main.go           # Entry point, CLI flags, server setup
├── internal/
│   ├── protocol/             # 9P2000 protocol implementation
│   │   ├── protocol.go       # Message types, constants, encoding
│   │   ├── message.go        # Individual message types
│   │   ├── server.go         # Connection handling
│   │   └── fs.go             # File/Dir interfaces, base implementations
│   ├── llm/                  # LLM client wrapper
│   │   └── client.go         # Anthropic API integration
│   └── llmfs/                # LLM filesystem implementation
│       ├── root.go           # Root directory construction
│       ├── ask.go            # Ask file (shim pattern)
│       ├── state.go          # Model, temperature files
│       ├── tokens.go         # Read-only token counter
│       ├── new.go            # Conversation reset trigger
│       ├── context.go        # Conversation history
│       ├── example.go        # Usage examples
│       └── stream.go         # Streaming interface
├── go.mod
├── go.sum
├── README.md
└── CLAUDE.md
```

### Key Components

1. **Protocol Layer (`internal/protocol/`)**
   - Implements 9P2000 protocol
   - No external dependencies (stdlib only)
   - `File` and `Dir` interfaces define the filesystem abstraction

2. **LLM Client (`internal/llm/client.go`)**
   - Wraps Anthropic SDK
   - Manages conversation state
   - Supports both sync and streaming responses
   - Tracks token usage

3. **LLM Filesystem (`internal/llmfs/`)**
   - Implements each file in the LLM filesystem
   - `AskFile` is the core interaction point
   - State files (`model`, `temperature`) modify client settings
   - `ChunkFile` provides streaming access

## Adding a New File

1. Create a new file in `internal/llmfs/`:

```go
package llmfs

import (
    "github.com/NERVsystems/llm9p/internal/llm"
    "github.com/NERVsystems/llm9p/internal/protocol"
)

type MyFile struct {
    *protocol.BaseFile
    client *llm.Client
}

func NewMyFile(client *llm.Client) *MyFile {
    return &MyFile{
        BaseFile: protocol.NewBaseFile("myfile", 0666),
        client:   client,
    }
}

func (f *MyFile) Read(p []byte, offset int64) (int, error) {
    // Implement read
}

func (f *MyFile) Write(p []byte, offset int64) (int, error) {
    // Implement write
}

func (f *MyFile) Stat() protocol.Stat {
    s := f.BaseFile.Stat()
    // Update s.Length if dynamic
    return s
}
```

2. Add to root directory in `internal/llmfs/root.go`:

```go
root.AddChild(NewMyFile(client))
```

## Protocol Implementation Notes

### Message Flow

1. Client sends T-message (request)
2. Server responds with R-message (response)
3. Each message has a tag for matching requests/responses

### Key 9P Operations

- `Tversion/Rversion` - Protocol negotiation
- `Tattach/Rattach` - Connect to filesystem
- `Twalk/Rwalk` - Navigate directory tree
- `Topen/Ropen` - Open a file
- `Tread/Rread` - Read from file
- `Twrite/Rwrite` - Write to file
- `Tclunk/Rclunk` - Close a fid

### File Interfaces

```go
// File is the interface that files must implement
type File interface {
    Stat() Stat
    Open(mode uint8) error
    Read(p []byte, offset int64) (int, error)
    Write(p []byte, offset int64) (int, error)
    Close() error
}

// Dir extends File with directory operations
type Dir interface {
    File
    Children() []File
    Lookup(name string) (File, error)
}
```

## Debugging

### Enable Debug Logging

```bash
ANTHROPIC_API_KEY=sk-... ./llm9p -debug
```

This logs all 9P messages sent and received.

### Test with 9p Client

```bash
# Using 9pfuse
9pfuse localhost:5640 /mnt/llm

# Using Plan 9's 9p tool
9p -a localhost:5640 ls llm
9p -a localhost:5640 read llm/model
9p -a localhost:5640 write llm/ask "Hello"
9p -a localhost:5640 read llm/ask
```

### Common Issues

**"file not found"**
- Check file name spelling
- Ensure file is added to root directory

**"permission denied"**
- Check file mode (read-only files have mode 0444)
- Write-only files have mode 0222

**Connection refused**
- Ensure server is running
- Check address/port

**API errors**
- Check ANTHROPIC_API_KEY is set
- Check API key is valid
- Check rate limits

## Code Style

### Error Handling

Files should handle errors gracefully and expose them to the user:

```go
func (f *AskFile) Write(p []byte, offset int64) (int, error) {
    response, err := f.client.Ask(ctx, prompt)
    if err != nil {
        // Store error so it can be read back
        f.lastResponse = "Error: " + err.Error()
        return len(p), nil
    }
    f.lastResponse = response
    return len(p), nil
}
```

### Stat Implementation

Always implement `Stat()` to return accurate `Length`:

```go
func (f *MyFile) Stat() protocol.Stat {
    s := f.BaseFile.Stat()
    s.Length = uint64(len(f.content))
    return s
}
```

## Future Enhancements

- [ ] Multiple conversation support (via subdirectories)
- [ ] Prompt templates
- [ ] Response caching
- [ ] Rate limiting
- [ ] Authentication
- [ ] Unix socket support
- [ ] Integration tests

## Resources

- [9P Protocol Specification](http://man.cat-v.org/plan_9/5/intro)
- [Anthropic API Documentation](https://docs.anthropic.com/)
- [Plan 9 from User Space](https://9fans.github.io/plan9port/)

A  => README.md +146 -0
@@ 1,146 @@
# llm9p

An LLM (Claude) exposed as a 9P filesystem.

llm9p enables users, scripts, and AI agents to interact with an LLM through standard filesystem operations. Write a prompt to a file, read the response from the same file.

## Installation

```bash
go install github.com/NERVsystems/llm9p/cmd/llm9p@latest
```

Or build from source:

```bash
git clone https://github.com/NERVsystems/llm9p
cd llm9p
go build -o llm9p ./cmd/llm9p
```

## Usage

### Start the Server

```bash
export ANTHROPIC_API_KEY=sk-ant-...
./llm9p -addr :5640
```

### Mount the Filesystem

Using 9pfuse (Plan 9 from User Space):

```bash
mkdir -p /mnt/llm
9pfuse localhost:5640 /mnt/llm
```

On macOS with plan9port:

```bash
9 mount localhost:5640 /mnt/llm
```

### Interact with the LLM

```bash
# Ask a question
echo "What is 2+2?" > /mnt/llm/ask
cat /mnt/llm/ask

# View token usage
cat /mnt/llm/tokens

# Change model
echo "claude-3-haiku-20240307" > /mnt/llm/model

# Adjust temperature
echo "0.5" > /mnt/llm/temperature

# View conversation history
cat /mnt/llm/context

# Add a system message
echo "You are a helpful coding assistant." > /mnt/llm/context

# Reset conversation
echo "" > /mnt/llm/new

# View help
cat /mnt/llm/_example
```

## Filesystem Schema

```
/llm/
├── ask              # Write prompt, read response (same file)
├── model            # Read/write: current model name
├── temperature      # Read/write: temperature float (0.0-2.0)
├── tokens           # Read-only: last response token count
├── new              # Write anything to start fresh conversation
├── context          # Read: conversation history; Write: add system message
├── _example         # Read-only: usage examples
└── stream/          # Streaming interface
    └── chunk        # Read blocks until next chunk, EOF on completion
```

### File Behaviors

| File | Read | Write |
|------|------|-------|
| `ask` | Returns last LLM response | Sends prompt to LLM, stores response |
| `model` | Returns current model name | Sets model for subsequent requests |
| `temperature` | Returns current temperature | Sets temperature (0.0-2.0) |
| `tokens` | Returns last response token count | Permission denied |
| `new` | Permission denied | Any write resets conversation state |
| `context` | Returns JSON conversation history | Appends system message to context |
| `_example` | Returns usage examples | Permission denied |
| `stream/chunk` | Blocks until next chunk, returns it | Permission denied |

## Shell Scripting

```bash
#!/bin/sh
# ask.sh - Simple LLM query script

if [ -z "$1" ]; then
    echo "Usage: $0 <question>"
    exit 1
fi

echo "$1" > /mnt/llm/ask
cat /mnt/llm/ask
```

## Configuration

### Command Line Flags

| Flag | Default | Description |
|------|---------|-------------|
| `-addr` | `:5640` | Address to listen on |
| `-debug` | `false` | Enable debug logging |

### Environment Variables

| Variable | Required | Description |
|----------|----------|-------------|
| `ANTHROPIC_API_KEY` | Yes | Your Anthropic API key |

## Default Settings

- **Model**: `claude-sonnet-4-20250514`
- **Temperature**: `0.7`
- **Max Tokens**: `4096`

## Requirements

- Go 1.21+
- Anthropic API key
- 9P client (9pfuse, plan9port, or native Plan 9)

## License

MIT

A  => cmd/llm9p/main.go +80 -0
@@ 1,80 @@
// llm9p exposes an LLM (Claude) as a 9P filesystem.
//
// Usage:
//
//	ANTHROPIC_API_KEY=sk-... llm9p -addr :5640
//
// Mount with:
//
//	9pfuse localhost:5640 /mnt/llm
//
// Interact:
//
//	echo "What is 2+2?" > /mnt/llm/ask
//	cat /mnt/llm/ask
package main

import (
	"context"
	"flag"
	"fmt"
	"log"
	"net"
	"os"
	"os/signal"
	"syscall"

	"github.com/NERVsystems/llm9p/internal/llm"
	"github.com/NERVsystems/llm9p/internal/llmfs"
	"github.com/NERVsystems/llm9p/internal/protocol"
)

func main() {
	addr := flag.String("addr", ":5640", "Address to listen on")
	debug := flag.Bool("debug", false, "Enable debug logging")
	flag.Parse()

	// Get API key from environment
	apiKey := os.Getenv("ANTHROPIC_API_KEY")
	if apiKey == "" {
		fmt.Fprintln(os.Stderr, "Error: ANTHROPIC_API_KEY environment variable not set")
		os.Exit(1)
	}

	// Create LLM client
	client := llm.NewClient(apiKey)

	// Create filesystem
	root := llmfs.NewRoot(client)

	// Create 9P server
	server := protocol.NewServer(root)
	server.SetDebug(*debug)

	// Listen
	listener, err := net.Listen("tcp", *addr)
	if err != nil {
		log.Fatalf("Failed to listen on %s: %v", *addr, err)
	}

	log.Printf("llm9p listening on %s", *addr)
	log.Printf("Mount with: 9pfuse %s /mnt/llm", listener.Addr())

	// Handle shutdown gracefully
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	go func() {
		sigCh := make(chan os.Signal, 1)
		signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
		<-sigCh
		log.Println("Shutting down...")
		cancel()
		listener.Close()
	}()

	// Serve
	if err := server.Serve(ctx, listener); err != nil && ctx.Err() == nil {
		log.Fatalf("Server error: %v", err)
	}
}

A  => go.mod +12 -0
@@ 1,12 @@
module github.com/NERVsystems/llm9p

go 1.21

require github.com/anthropics/anthropic-sdk-go v0.2.0-beta.3

require (
	github.com/tidwall/gjson v1.14.4 // indirect
	github.com/tidwall/match v1.1.1 // indirect
	github.com/tidwall/pretty v1.2.1 // indirect
	github.com/tidwall/sjson v1.2.5 // indirect
)

A  => go.sum +12 -0
@@ 1,12 @@
github.com/anthropics/anthropic-sdk-go v0.2.0-beta.3 h1:b5t1ZJMvV/l99y4jbz7kRFdUp3BSDkI8EhSlHczivtw=
github.com/anthropics/anthropic-sdk-go v0.2.0-beta.3/go.mod h1:AapDW22irxK2PSumZiQXYUFvsdQgkwIWlpESweWZI/c=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=

A  => internal/llm/client.go +335 -0
@@ 1,335 @@
// Package llm provides a wrapper around the Anthropic API for use with the 9P filesystem.
package llm

import (
	"context"
	"encoding/json"
	"fmt"
	"sync"

	"github.com/anthropics/anthropic-sdk-go"
	"github.com/anthropics/anthropic-sdk-go/option"
)

// Message represents a single message in a conversation
type Message struct {
	Role    string `json:"role"`    // "user" or "assistant"
	Content string `json:"content"` // message content
}

// Client wraps the Anthropic API client with conversation state
type Client struct {
	client      anthropic.Client
	mu          sync.RWMutex
	model       string
	temperature float64
	messages    []Message
	lastTokens  int
	streaming   bool
	streamChan  chan string
	streamDone  chan struct{}
}

// NewClient creates a new LLM client
func NewClient(apiKey string) *Client {
	client := anthropic.NewClient(option.WithAPIKey(apiKey))
	return &Client{
		client:      client,
		model:       "claude-sonnet-4-20250514",
		temperature: 0.7,
		messages:    make([]Message, 0),
	}
}

// Model returns the current model name
func (c *Client) Model() string {
	c.mu.RLock()
	defer c.mu.RUnlock()
	return c.model
}

// SetModel sets the model for subsequent requests
func (c *Client) SetModel(model string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.model = model
}

// Temperature returns the current temperature
func (c *Client) Temperature() float64 {
	c.mu.RLock()
	defer c.mu.RUnlock()
	return c.temperature
}

// SetTemperature sets the temperature for subsequent requests
func (c *Client) SetTemperature(temp float64) error {
	if temp < 0.0 || temp > 2.0 {
		return fmt.Errorf("temperature must be between 0.0 and 2.0")
	}
	c.mu.Lock()
	defer c.mu.Unlock()
	c.temperature = temp
	return nil
}

// LastTokens returns the token count from the last response
func (c *Client) LastTokens() int {
	c.mu.RLock()
	defer c.mu.RUnlock()
	return c.lastTokens
}

// Messages returns a copy of the conversation history
func (c *Client) Messages() []Message {
	c.mu.RLock()
	defer c.mu.RUnlock()
	result := make([]Message, len(c.messages))
	copy(result, c.messages)
	return result
}

// MessagesJSON returns the conversation history as JSON
func (c *Client) MessagesJSON() ([]byte, error) {
	c.mu.RLock()
	defer c.mu.RUnlock()
	return json.MarshalIndent(c.messages, "", "  ")
}

// AddSystemMessage adds a system message to the context
func (c *Client) AddSystemMessage(content string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	// System messages are prepended to conversations
	c.messages = append([]Message{{Role: "system", Content: content}}, c.messages...)
}

// Reset clears the conversation history
func (c *Client) Reset() {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.messages = make([]Message, 0)
	c.lastTokens = 0
}

// Ask sends a prompt to the LLM and returns the response
func (c *Client) Ask(ctx context.Context, prompt string) (string, error) {
	c.mu.Lock()
	// Add user message to history
	c.messages = append(c.messages, Message{Role: "user", Content: prompt})

	// Build the API messages
	apiMessages := make([]anthropic.MessageParam, 0, len(c.messages))
	var systemBlocks []anthropic.TextBlockParam

	for _, msg := range c.messages {
		switch msg.Role {
		case "system":
			// Collect system messages
			systemBlocks = append(systemBlocks, anthropic.TextBlockParam{
				Text: msg.Content,
			})
		case "user":
			apiMessages = append(apiMessages, anthropic.NewUserMessage(
				anthropic.NewTextBlock(msg.Content),
			))
		case "assistant":
			apiMessages = append(apiMessages, anthropic.NewAssistantMessage(
				anthropic.NewTextBlock(msg.Content),
			))
		}
	}

	model := c.model
	temp := c.temperature
	c.mu.Unlock()

	// Build request params
	params := anthropic.MessageNewParams{
		Model:       anthropic.Model(model),
		MaxTokens:   4096,
		Messages:    apiMessages,
		Temperature: anthropic.Float(temp),
	}

	// Add system prompt if present
	if len(systemBlocks) > 0 {
		params.System = systemBlocks
	}

	// Make the API call
	response, err := c.client.Messages.New(ctx, params)
	if err != nil {
		// Remove the user message on error
		c.mu.Lock()
		if len(c.messages) > 0 {
			c.messages = c.messages[:len(c.messages)-1]
		}
		c.mu.Unlock()
		return "", fmt.Errorf("API error: %w", err)
	}

	// Extract response text
	var responseText string
	for _, block := range response.Content {
		if block.Type == "text" {
			responseText += block.Text
		}
	}

	// Update state
	c.mu.Lock()
	c.messages = append(c.messages, Message{Role: "assistant", Content: responseText})
	c.lastTokens = int(response.Usage.InputTokens + response.Usage.OutputTokens)
	c.mu.Unlock()

	return responseText, nil
}

// StartStream begins streaming a response for the given prompt
func (c *Client) StartStream(ctx context.Context, prompt string) error {
	c.mu.Lock()
	if c.streaming {
		c.mu.Unlock()
		return fmt.Errorf("stream already in progress")
	}

	// Add user message to history
	c.messages = append(c.messages, Message{Role: "user", Content: prompt})

	// Build the API messages
	apiMessages := make([]anthropic.MessageParam, 0, len(c.messages))
	var systemBlocks []anthropic.TextBlockParam

	for _, msg := range c.messages {
		switch msg.Role {
		case "system":
			systemBlocks = append(systemBlocks, anthropic.TextBlockParam{
				Text: msg.Content,
			})
		case "user":
			apiMessages = append(apiMessages, anthropic.NewUserMessage(
				anthropic.NewTextBlock(msg.Content),
			))
		case "assistant":
			apiMessages = append(apiMessages, anthropic.NewAssistantMessage(
				anthropic.NewTextBlock(msg.Content),
			))
		}
	}

	model := c.model
	temp := c.temperature

	c.streaming = true
	c.streamChan = make(chan string, 100)
	c.streamDone = make(chan struct{})
	c.mu.Unlock()

	// Start streaming in a goroutine
	go func() {
		defer func() {
			c.mu.Lock()
			c.streaming = false
			close(c.streamChan)
			close(c.streamDone)
			c.mu.Unlock()
		}()

		// Build request params
		params := anthropic.MessageNewParams{
			Model:       anthropic.Model(model),
			MaxTokens:   4096,
			Messages:    apiMessages,
			Temperature: anthropic.Float(temp),
		}

		if len(systemBlocks) > 0 {
			params.System = systemBlocks
		}

		// Use streaming
		stream := c.client.Messages.NewStreaming(ctx, params)

		var fullResponse string
		var inputTokens, outputTokens int64

		for stream.Next() {
			event := stream.Current()

			switch event.Type {
			case "content_block_delta":
				delta := event.Delta
				if delta.Type == "text_delta" {
					chunk := delta.Text
					fullResponse += chunk
					select {
					case c.streamChan <- chunk:
					case <-ctx.Done():
						return
					}
				}
			case "message_delta":
				outputTokens = event.Usage.OutputTokens
			case "message_start":
				inputTokens = event.Message.Usage.InputTokens
			}
		}

		if err := stream.Err(); err != nil {
			// Send error as chunk
			select {
			case c.streamChan <- fmt.Sprintf("\n[Error: %v]", err):
			case <-ctx.Done():
			}
			// Remove user message on error
			c.mu.Lock()
			if len(c.messages) > 0 {
				c.messages = c.messages[:len(c.messages)-1]
			}
			c.mu.Unlock()
			return
		}

		// Update state with complete response
		c.mu.Lock()
		c.messages = append(c.messages, Message{Role: "assistant", Content: fullResponse})
		c.lastTokens = int(inputTokens + outputTokens)
		c.mu.Unlock()
	}()

	return nil
}

// ReadStreamChunk reads the next chunk from the stream, blocking until available
// Returns empty string and false when stream is complete
func (c *Client) ReadStreamChunk() (string, bool) {
	c.mu.RLock()
	streamChan := c.streamChan
	c.mu.RUnlock()

	if streamChan == nil {
		return "", false
	}

	chunk, ok := <-streamChan
	return chunk, ok
}

// IsStreaming returns whether a stream is currently in progress
func (c *Client) IsStreaming() bool {
	c.mu.RLock()
	defer c.mu.RUnlock()
	return c.streaming
}

// WaitStream waits for the current stream to complete
func (c *Client) WaitStream() {
	c.mu.RLock()
	done := c.streamDone
	c.mu.RUnlock()

	if done != nil {
		<-done
	}
}

A  => internal/llmfs/ask.go +79 -0
@@ 1,79 @@
package llmfs

import (
	"context"
	"io"
	"strings"
	"sync"

	"github.com/NERVsystems/llm9p/internal/llm"
	"github.com/NERVsystems/llm9p/internal/protocol"
)

// AskFile is the main interaction file - write a prompt, read the response
type AskFile struct {
	*protocol.BaseFile
	client       *llm.Client
	mu           sync.RWMutex
	lastResponse string
}

// NewAskFile creates the ask file
func NewAskFile(client *llm.Client) *AskFile {
	return &AskFile{
		BaseFile: protocol.NewBaseFile("ask", 0666),
		client:   client,
	}
}

func (f *AskFile) Read(p []byte, offset int64) (int, error) {
	f.mu.RLock()
	content := f.lastResponse
	f.mu.RUnlock()

	// Add newline if not present
	if content != "" && !strings.HasSuffix(content, "\n") {
		content += "\n"
	}

	if offset >= int64(len(content)) {
		return 0, io.EOF
	}
	n := copy(p, content[offset:])
	return n, nil
}

func (f *AskFile) Write(p []byte, offset int64) (int, error) {
	prompt := strings.TrimSpace(string(p))
	if prompt == "" {
		return len(p), nil // Empty write is a no-op
	}

	// Make the API call
	response, err := f.client.Ask(context.Background(), prompt)
	if err != nil {
		// Store error as response so it can be read
		f.mu.Lock()
		f.lastResponse = "Error: " + err.Error()
		f.mu.Unlock()
		return len(p), nil // Return success so client knows write completed
	}

	f.mu.Lock()
	f.lastResponse = response
	f.mu.Unlock()

	return len(p), nil
}

func (f *AskFile) Stat() protocol.Stat {
	s := f.BaseFile.Stat()
	f.mu.RLock()
	content := f.lastResponse
	f.mu.RUnlock()
	if content != "" && !strings.HasSuffix(content, "\n") {
		content += "\n"
	}
	s.Length = uint64(len(content))
	return s
}

A  => internal/llmfs/context.go +56 -0
@@ 1,56 @@
package llmfs

import (
	"io"
	"strings"

	"github.com/NERVsystems/llm9p/internal/llm"
	"github.com/NERVsystems/llm9p/internal/protocol"
)

// ContextFile exposes the conversation history
// Read: returns JSON of conversation history
// Write: appends a system message to context
type ContextFile struct {
	*protocol.BaseFile
	client *llm.Client
}

// NewContextFile creates the context file
func NewContextFile(client *llm.Client) *ContextFile {
	return &ContextFile{
		BaseFile: protocol.NewBaseFile("context", 0666),
		client:   client,
	}
}

func (f *ContextFile) Read(p []byte, offset int64) (int, error) {
	content, err := f.client.MessagesJSON()
	if err != nil {
		return 0, err
	}
	// Add newline
	content = append(content, '\n')

	if offset >= int64(len(content)) {
		return 0, io.EOF
	}
	n := copy(p, content[offset:])
	return n, nil
}

func (f *ContextFile) Write(p []byte, offset int64) (int, error) {
	// Writing appends a system message to the context
	msg := strings.TrimSpace(string(p))
	if msg != "" {
		f.client.AddSystemMessage(msg)
	}
	return len(p), nil
}

func (f *ContextFile) Stat() protocol.Stat {
	s := f.BaseFile.Stat()
	content, _ := f.client.MessagesJSON()
	s.Length = uint64(len(content) + 1) // +1 for newline
	return s
}

A  => internal/llmfs/example.go +62 -0
@@ 1,62 @@
package llmfs

import (
	"github.com/NERVsystems/llm9p/internal/protocol"
)

const exampleContent = `LLM 9P Filesystem Usage Examples
=================================

Basic Interaction:
  echo "What is 2+2?" > ask     # Send prompt to LLM
  cat ask                        # Read response

Configuration:
  cat model                      # View current model
  echo "claude-3-haiku-20240307" > model   # Change model
  cat temperature                # View current temperature (0.0-2.0)
  echo "0.5" > temperature       # Set temperature

Conversation Management:
  cat context                    # View conversation history (JSON)
  echo "You are a helpful assistant." > context  # Add system message
  echo "" > new                  # Reset conversation

Token Usage:
  cat tokens                     # View tokens from last response

Streaming (Advanced):
  echo "Tell me a story" > ask   # Start generating
  cat stream/chunk               # Read chunks as they arrive (blocks)

Shell Scripting:
  #!/bin/sh
  # Ask the LLM and get response
  echo "$1" > /mnt/llm/ask
  cat /mnt/llm/ask

Mounting (Linux/macOS):
  # Using 9pfuse (Plan 9 from User Space)
  9pfuse localhost:5640 /mnt/llm

  # Using mount_9p (macOS with plan9port)
  mount_9p localhost:5640 /mnt/llm

Environment:
  ANTHROPIC_API_KEY must be set when starting the server

Files:
  ask          Read/write: prompt goes in, response comes out
  model        Read/write: current model name
  temperature  Read/write: sampling temperature (0.0-2.0)
  tokens       Read-only: token count from last response
  new          Write-only: any write resets conversation
  context      Read: JSON history; Write: add system message
  _example     Read-only: this help text
  stream/chunk Read-only: streaming chunks (blocking)
`

// NewExampleFile creates the _example file with usage examples
func NewExampleFile() *protocol.StaticFile {
	return protocol.NewStaticFile("_example", []byte(exampleContent))
}

A  => internal/llmfs/new.go +36 -0
@@ 1,36 @@
package llmfs

import (
	"github.com/NERVsystems/llm9p/internal/llm"
	"github.com/NERVsystems/llm9p/internal/protocol"
)

// NewFile is a write-only file that resets the conversation when written to
type NewFile struct {
	*protocol.BaseFile
	client *llm.Client
}

// NewNewFile creates the new file
func NewNewFile(client *llm.Client) *NewFile {
	return &NewFile{
		BaseFile: protocol.NewBaseFile("new", 0222),
		client:   client,
	}
}

func (f *NewFile) Read(p []byte, offset int64) (int, error) {
	return 0, protocol.ErrPermission
}

func (f *NewFile) Write(p []byte, offset int64) (int, error) {
	// Any write resets the conversation
	f.client.Reset()
	return len(p), nil
}

func (f *NewFile) Stat() protocol.Stat {
	s := f.BaseFile.Stat()
	s.Length = 0
	return s
}

A  => internal/llmfs/root.go +28 -0
@@ 1,28 @@
// Package llmfs implements the LLM filesystem exposed via 9P.
package llmfs

import (
	"github.com/NERVsystems/llm9p/internal/llm"
	"github.com/NERVsystems/llm9p/internal/protocol"
)

// NewRoot creates the root directory of the LLM filesystem
func NewRoot(client *llm.Client) protocol.Dir {
	root := protocol.NewStaticDir("llm")

	// Add all files
	root.AddChild(NewAskFile(client))
	root.AddChild(NewModelFile(client))
	root.AddChild(NewTemperatureFile(client))
	root.AddChild(NewTokensFile(client))
	root.AddChild(NewNewFile(client))
	root.AddChild(NewContextFile(client))
	root.AddChild(NewExampleFile())

	// Add stream directory
	streamDir := protocol.NewStaticDir("stream")
	streamDir.AddChild(NewChunkFile(client))
	root.AddChild(streamDir)

	return root
}

A  => internal/llmfs/state.go +91 -0
@@ 1,91 @@
package llmfs

import (
	"fmt"
	"io"
	"strconv"
	"strings"

	"github.com/NERVsystems/llm9p/internal/llm"
	"github.com/NERVsystems/llm9p/internal/protocol"
)

// ModelFile exposes the current model name (read/write)
type ModelFile struct {
	*protocol.BaseFile
	client *llm.Client
}

// NewModelFile creates the model file
func NewModelFile(client *llm.Client) *ModelFile {
	return &ModelFile{
		BaseFile: protocol.NewBaseFile("model", 0666),
		client:   client,
	}
}

func (f *ModelFile) Read(p []byte, offset int64) (int, error) {
	content := f.client.Model() + "\n"
	if offset >= int64(len(content)) {
		return 0, io.EOF
	}
	n := copy(p, content[offset:])
	return n, nil
}

func (f *ModelFile) Write(p []byte, offset int64) (int, error) {
	model := strings.TrimSpace(string(p))
	if model == "" {
		return 0, fmt.Errorf("model name cannot be empty")
	}
	f.client.SetModel(model)
	return len(p), nil
}

func (f *ModelFile) Stat() protocol.Stat {
	s := f.BaseFile.Stat()
	s.Length = uint64(len(f.client.Model()) + 1) // +1 for newline
	return s
}

// TemperatureFile exposes the current temperature (read/write)
type TemperatureFile struct {
	*protocol.BaseFile
	client *llm.Client
}

// NewTemperatureFile creates the temperature file
func NewTemperatureFile(client *llm.Client) *TemperatureFile {
	return &TemperatureFile{
		BaseFile: protocol.NewBaseFile("temperature", 0666),
		client:   client,
	}
}

func (f *TemperatureFile) Read(p []byte, offset int64) (int, error) {
	content := fmt.Sprintf("%.2f\n", f.client.Temperature())
	if offset >= int64(len(content)) {
		return 0, io.EOF
	}
	n := copy(p, content[offset:])
	return n, nil
}

func (f *TemperatureFile) Write(p []byte, offset int64) (int, error) {
	tempStr := strings.TrimSpace(string(p))
	temp, err := strconv.ParseFloat(tempStr, 64)
	if err != nil {
		return 0, fmt.Errorf("invalid temperature: %w", err)
	}
	if err := f.client.SetTemperature(temp); err != nil {
		return 0, err
	}
	return len(p), nil
}

func (f *TemperatureFile) Stat() protocol.Stat {
	s := f.BaseFile.Stat()
	content := fmt.Sprintf("%.2f\n", f.client.Temperature())
	s.Length = uint64(len(content))
	return s
}

A  => internal/llmfs/stream.go +53 -0
@@ 1,53 @@
package llmfs

import (
	"io"

	"github.com/NERVsystems/llm9p/internal/llm"
	"github.com/NERVsystems/llm9p/internal/protocol"
)

// ChunkFile provides streaming access to LLM responses
// Reading blocks until the next chunk is available, then returns it
// Returns EOF when the stream is complete
type ChunkFile struct {
	*protocol.BaseFile
	client *llm.Client
}

// NewChunkFile creates the stream/chunk file
func NewChunkFile(client *llm.Client) *ChunkFile {
	return &ChunkFile{
		BaseFile: protocol.NewBaseFile("chunk", 0444),
		client:   client,
	}
}

func (f *ChunkFile) Read(p []byte, offset int64) (int, error) {
	// If no stream is active, return EOF
	if !f.client.IsStreaming() {
		return 0, io.EOF
	}

	// Block until we get a chunk
	chunk, ok := f.client.ReadStreamChunk()
	if !ok {
		// Stream ended
		return 0, io.EOF
	}

	// Copy the chunk to the buffer
	n := copy(p, chunk)
	return n, nil
}

func (f *ChunkFile) Write(p []byte, offset int64) (int, error) {
	return 0, protocol.ErrPermission
}

func (f *ChunkFile) Stat() protocol.Stat {
	s := f.BaseFile.Stat()
	// Length is unknown for streaming
	s.Length = 0
	return s
}

A  => internal/llmfs/tokens.go +43 -0
@@ 1,43 @@
package llmfs

import (
	"fmt"
	"io"

	"github.com/NERVsystems/llm9p/internal/llm"
	"github.com/NERVsystems/llm9p/internal/protocol"
)

// TokensFile exposes the last response token count (read-only)
type TokensFile struct {
	*protocol.BaseFile
	client *llm.Client
}

// NewTokensFile creates the tokens file
func NewTokensFile(client *llm.Client) *TokensFile {
	return &TokensFile{
		BaseFile: protocol.NewBaseFile("tokens", 0444),
		client:   client,
	}
}

func (f *TokensFile) Read(p []byte, offset int64) (int, error) {
	content := fmt.Sprintf("%d\n", f.client.LastTokens())
	if offset >= int64(len(content)) {
		return 0, io.EOF
	}
	n := copy(p, content[offset:])
	return n, nil
}

func (f *TokensFile) Write(p []byte, offset int64) (int, error) {
	return 0, protocol.ErrPermission
}

func (f *TokensFile) Stat() protocol.Stat {
	s := f.BaseFile.Stat()
	content := fmt.Sprintf("%d\n", f.client.LastTokens())
	s.Length = uint64(len(content))
	return s
}

A  => internal/protocol/fs.go +230 -0
@@ 1,230 @@
package protocol

import (
	"io"
	"sync/atomic"
	"time"
)

// File is the interface that files must implement.
// This is the core abstraction for anything exposed via 9P.
type File interface {
	// Stat returns the file's metadata
	Stat() Stat

	// Open prepares the file for reading/writing
	Open(mode uint8) error

	// Read reads up to len(p) bytes starting at offset
	Read(p []byte, offset int64) (n int, err error)

	// Write writes len(p) bytes starting at offset
	Write(p []byte, offset int64) (n int, err error)

	// Close releases any resources
	Close() error
}

// Dir is the interface that directories must implement
type Dir interface {
	File

	// Children returns the directory's children
	Children() []File

	// Lookup finds a child by name
	Lookup(name string) (File, error)
}

// pathCounter generates unique path IDs for qids
var pathCounter uint64

func NextPath() uint64 {
	return atomic.AddUint64(&pathCounter, 1)
}

// BaseFile provides a default implementation of common File methods
type BaseFile struct {
	Name_   string
	Mode_   uint32
	Uid_    string
	Gid_    string
	Qid_    Qid
	Mtime_  time.Time
	Length_ uint64
}

// NewBaseFile creates a new base file
func NewBaseFile(name string, mode uint32) *BaseFile {
	now := time.Now()
	qtype := QTFILE
	if mode&DMDIR != 0 {
		qtype = QTDIR
	}
	return &BaseFile{
		Name_:  name,
		Mode_:  mode,
		Uid_:   "llm",
		Gid_:   "llm",
		Mtime_: now,
		Qid_: Qid{
			Type:    qtype,
			Version: 0,
			Path:    NextPath(),
		},
	}
}

func (f *BaseFile) Stat() Stat {
	return Stat{
		Type:   0,
		Dev:    0,
		Qid:    f.Qid_,
		Mode:   f.Mode_,
		Atime:  uint32(f.Mtime_.Unix()),
		Mtime:  uint32(f.Mtime_.Unix()),
		Length: f.Length_,
		Name:   f.Name_,
		Uid:    f.Uid_,
		Gid:    f.Gid_,
		Muid:   f.Uid_,
	}
}

func (f *BaseFile) Open(mode uint8) error                   { return nil }
func (f *BaseFile) Close() error                            { return nil }
func (f *BaseFile) Read(p []byte, offset int64) (int, error)  { return 0, io.EOF }
func (f *BaseFile) Write(p []byte, offset int64) (int, error) { return 0, ErrPermission }

// SetLength updates the file length
func (f *BaseFile) SetLength(n uint64) {
	f.Length_ = n
	f.Mtime_ = time.Now()
	f.Qid_.Version++
}

// StaticFile is a file with static content
type StaticFile struct {
	*BaseFile
	Content []byte
}

// NewStaticFile creates a file with static content
func NewStaticFile(name string, content []byte) *StaticFile {
	f := &StaticFile{
		BaseFile: NewBaseFile(name, 0444),
		Content:  content,
	}
	f.Length_ = uint64(len(content))
	return f
}

func (f *StaticFile) Read(p []byte, offset int64) (int, error) {
	if offset >= int64(len(f.Content)) {
		return 0, io.EOF
	}
	n := copy(p, f.Content[offset:])
	return n, nil
}

// StaticDir is a directory with static children
type StaticDir struct {
	*BaseFile
	children map[string]File
	order    []string // preserve order for listing
}

// NewStaticDir creates a new static directory
func NewStaticDir(name string) *StaticDir {
	return &StaticDir{
		BaseFile: NewBaseFile(name, DMDIR|0555),
		children: make(map[string]File),
		order:    make([]string, 0),
	}
}

// AddChild adds a child to the directory
func (d *StaticDir) AddChild(f File) {
	name := f.Stat().Name
	if _, exists := d.children[name]; !exists {
		d.order = append(d.order, name)
	}
	d.children[name] = f
}

func (d *StaticDir) Children() []File {
	result := make([]File, len(d.order))
	for i, name := range d.order {
		result[i] = d.children[name]
	}
	return result
}

func (d *StaticDir) Lookup(name string) (File, error) {
	if f, ok := d.children[name]; ok {
		return f, nil
	}
	return nil, ErrNotFound
}

func (d *StaticDir) Read(p []byte, offset int64) (int, error) {
	// Directory read returns packed stat entries
	var buf []byte
	for _, f := range d.Children() {
		stat := f.Stat()
		entry := make([]byte, 256)
		n := stat.Encode(entry)
		buf = append(buf, entry[:n]...)
	}

	if offset >= int64(len(buf)) {
		return 0, io.EOF
	}

	n := copy(p, buf[offset:])
	return n, nil
}

// DynamicFile is a file whose content is generated on read
type DynamicFile struct {
	*BaseFile
	Generator func() []byte
}

// NewDynamicFile creates a file with dynamic content
func NewDynamicFile(name string, generator func() []byte) *DynamicFile {
	return &DynamicFile{
		BaseFile:  NewBaseFile(name, 0444),
		Generator: generator,
	}
}

func (f *DynamicFile) Read(p []byte, offset int64) (int, error) {
	content := f.Generator()
	if offset >= int64(len(content)) {
		return 0, io.EOF
	}
	n := copy(p, content[offset:])
	return n, nil
}

func (f *DynamicFile) Stat() Stat {
	s := f.BaseFile.Stat()
	s.Length = uint64(len(f.Generator()))
	return s
}

// Errors
type Error string

func (e Error) Error() string { return string(e) }

const (
	ErrNotFound   Error = "file not found"
	ErrPermission Error = "permission denied"
	ErrNotDir     Error = "not a directory"
	ErrIsDir      Error = "is a directory"
	ErrBadFid     Error = "bad fid"
	ErrFidInUse   Error = "fid already in use"
	ErrBadOffset  Error = "bad offset"
)

A  => internal/protocol/message.go +381 -0
@@ 1,381 @@
package protocol

import (
	"encoding/binary"
	"fmt"
)

// Message is the interface implemented by all 9P messages
type Message interface {
	Type() uint8
	Encode(buf []byte) int
}

// Request messages (T-messages)

// TversionMsg negotiates protocol version
type TversionMsg struct {
	Msize   uint32 // maximum message size
	Version string // protocol version string
}

func (m *TversionMsg) Type() uint8 { return Tversion }

func (m *TversionMsg) Encode(buf []byte) int {
	binary.LittleEndian.PutUint32(buf[0:4], m.Msize)
	return 4 + EncodeString(buf[4:], m.Version)
}

func DecodeTversion(buf []byte) (*TversionMsg, error) {
	if len(buf) < 6 {
		return nil, fmt.Errorf("Tversion too short")
	}
	m := &TversionMsg{
		Msize: binary.LittleEndian.Uint32(buf[0:4]),
	}
	m.Version, _ = DecodeString(buf[4:])
	return m, nil
}

// RversionMsg is the response to Tversion
type RversionMsg struct {
	Msize   uint32
	Version string
}

func (m *RversionMsg) Type() uint8 { return Rversion }

func (m *RversionMsg) Encode(buf []byte) int {
	binary.LittleEndian.PutUint32(buf[0:4], m.Msize)
	return 4 + EncodeString(buf[4:], m.Version)
}

// TattachMsg attaches to a filesystem
type TattachMsg struct {
	Fid   uint32 // fid to use for this connection
	Afid  uint32 // auth fid (NoFid if no auth)
	Uname string // user name
	Aname string // attach name (filesystem to attach)
}

func (m *TattachMsg) Type() uint8 { return Tattach }

func (m *TattachMsg) Encode(buf []byte) int {
	binary.LittleEndian.PutUint32(buf[0:4], m.Fid)
	binary.LittleEndian.PutUint32(buf[4:8], m.Afid)
	n := 8
	n += EncodeString(buf[n:], m.Uname)
	n += EncodeString(buf[n:], m.Aname)
	return n
}

func DecodeTattach(buf []byte) (*TattachMsg, error) {
	if len(buf) < 12 {
		return nil, fmt.Errorf("Tattach too short")
	}
	m := &TattachMsg{
		Fid:  binary.LittleEndian.Uint32(buf[0:4]),
		Afid: binary.LittleEndian.Uint32(buf[4:8]),
	}
	n := 8
	var sn int
	m.Uname, sn = DecodeString(buf[n:])
	n += sn
	m.Aname, _ = DecodeString(buf[n:])
	return m, nil
}

// RattachMsg is the response to Tattach
type RattachMsg struct {
	Qid Qid
}

func (m *RattachMsg) Type() uint8 { return Rattach }

func (m *RattachMsg) Encode(buf []byte) int {
	return m.Qid.Encode(buf)
}

// TwalkMsg walks a path
type TwalkMsg struct {
	Fid    uint32   // starting fid
	Newfid uint32   // fid for the result
	Names  []string // path components to walk
}

func (m *TwalkMsg) Type() uint8 { return Twalk }

func (m *TwalkMsg) Encode(buf []byte) int {
	binary.LittleEndian.PutUint32(buf[0:4], m.Fid)
	binary.LittleEndian.PutUint32(buf[4:8], m.Newfid)
	binary.LittleEndian.PutUint16(buf[8:10], uint16(len(m.Names)))
	n := 10
	for _, name := range m.Names {
		n += EncodeString(buf[n:], name)
	}
	return n
}

func DecodeTwalk(buf []byte) (*TwalkMsg, error) {
	if len(buf) < 10 {
		return nil, fmt.Errorf("Twalk too short")
	}
	m := &TwalkMsg{
		Fid:    binary.LittleEndian.Uint32(buf[0:4]),
		Newfid: binary.LittleEndian.Uint32(buf[4:8]),
	}
	nwname := binary.LittleEndian.Uint16(buf[8:10])
	m.Names = make([]string, nwname)
	n := 10
	for i := range m.Names {
		var sn int
		m.Names[i], sn = DecodeString(buf[n:])
		n += sn
	}
	return m, nil
}

// RwalkMsg is the response to Twalk
type RwalkMsg struct {
	Qids []Qid // qids for each successfully walked element
}

func (m *RwalkMsg) Type() uint8 { return Rwalk }

func (m *RwalkMsg) Encode(buf []byte) int {
	binary.LittleEndian.PutUint16(buf[0:2], uint16(len(m.Qids)))
	n := 2
	for i := range m.Qids {
		n += m.Qids[i].Encode(buf[n:])
	}
	return n
}

// TopenMsg opens a file
type TopenMsg struct {
	Fid  uint32
	Mode uint8
}

func (m *TopenMsg) Type() uint8 { return Topen }

func (m *TopenMsg) Encode(buf []byte) int {
	binary.LittleEndian.PutUint32(buf[0:4], m.Fid)
	buf[4] = m.Mode
	return 5
}

func DecodeTopen(buf []byte) (*TopenMsg, error) {
	if len(buf) < 5 {
		return nil, fmt.Errorf("Topen too short")
	}
	return &TopenMsg{
		Fid:  binary.LittleEndian.Uint32(buf[0:4]),
		Mode: buf[4],
	}, nil
}

// RopenMsg is the response to Topen
type RopenMsg struct {
	Qid    Qid
	Iounit uint32
}

func (m *RopenMsg) Type() uint8 { return Ropen }

func (m *RopenMsg) Encode(buf []byte) int {
	n := m.Qid.Encode(buf)
	binary.LittleEndian.PutUint32(buf[n:n+4], m.Iounit)
	return n + 4
}

// TreadMsg reads from a file
type TreadMsg struct {
	Fid    uint32
	Offset uint64
	Count  uint32
}

func (m *TreadMsg) Type() uint8 { return Tread }

func (m *TreadMsg) Encode(buf []byte) int {
	binary.LittleEndian.PutUint32(buf[0:4], m.Fid)
	binary.LittleEndian.PutUint64(buf[4:12], m.Offset)
	binary.LittleEndian.PutUint32(buf[12:16], m.Count)
	return 16
}

func DecodeTread(buf []byte) (*TreadMsg, error) {
	if len(buf) < 16 {
		return nil, fmt.Errorf("Tread too short")
	}
	return &TreadMsg{
		Fid:    binary.LittleEndian.Uint32(buf[0:4]),
		Offset: binary.LittleEndian.Uint64(buf[4:12]),
		Count:  binary.LittleEndian.Uint32(buf[12:16]),
	}, nil
}

// RreadMsg is the response to Tread
type RreadMsg struct {
	Data []byte
}

func (m *RreadMsg) Type() uint8 { return Rread }

func (m *RreadMsg) Encode(buf []byte) int {
	binary.LittleEndian.PutUint32(buf[0:4], uint32(len(m.Data)))
	copy(buf[4:], m.Data)
	return 4 + len(m.Data)
}

// TwriteMsg writes to a file
type TwriteMsg struct {
	Fid    uint32
	Offset uint64
	Data   []byte
}

func (m *TwriteMsg) Type() uint8 { return Twrite }

func (m *TwriteMsg) Encode(buf []byte) int {
	binary.LittleEndian.PutUint32(buf[0:4], m.Fid)
	binary.LittleEndian.PutUint64(buf[4:12], m.Offset)
	binary.LittleEndian.PutUint32(buf[12:16], uint32(len(m.Data)))
	copy(buf[16:], m.Data)
	return 16 + len(m.Data)
}

func DecodeTwrite(buf []byte) (*TwriteMsg, error) {
	if len(buf) < 16 {
		return nil, fmt.Errorf("Twrite too short")
	}
	count := binary.LittleEndian.Uint32(buf[12:16])
	if len(buf) < int(16+count) {
		return nil, fmt.Errorf("Twrite data truncated")
	}
	return &TwriteMsg{
		Fid:    binary.LittleEndian.Uint32(buf[0:4]),
		Offset: binary.LittleEndian.Uint64(buf[4:12]),
		Data:   buf[16 : 16+count],
	}, nil
}

// RwriteMsg is the response to Twrite
type RwriteMsg struct {
	Count uint32
}

func (m *RwriteMsg) Type() uint8 { return Rwrite }

func (m *RwriteMsg) Encode(buf []byte) int {
	binary.LittleEndian.PutUint32(buf[0:4], m.Count)
	return 4
}

// TclunkMsg closes a fid
type TclunkMsg struct {
	Fid uint32
}

func (m *TclunkMsg) Type() uint8 { return Tclunk }

func (m *TclunkMsg) Encode(buf []byte) int {
	binary.LittleEndian.PutUint32(buf[0:4], m.Fid)
	return 4
}

func DecodeTclunk(buf []byte) (*TclunkMsg, error) {
	if len(buf) < 4 {
		return nil, fmt.Errorf("Tclunk too short")
	}
	return &TclunkMsg{
		Fid: binary.LittleEndian.Uint32(buf[0:4]),
	}, nil
}

// RclunkMsg is the response to Tclunk
type RclunkMsg struct{}

func (m *RclunkMsg) Type() uint8 { return Rclunk }

func (m *RclunkMsg) Encode(buf []byte) int {
	return 0
}

// TstatMsg requests file stats
type TstatMsg struct {
	Fid uint32
}

func (m *TstatMsg) Type() uint8 { return Tstat }

func (m *TstatMsg) Encode(buf []byte) int {
	binary.LittleEndian.PutUint32(buf[0:4], m.Fid)
	return 4
}

func DecodeTstat(buf []byte) (*TstatMsg, error) {
	if len(buf) < 4 {
		return nil, fmt.Errorf("Tstat too short")
	}
	return &TstatMsg{
		Fid: binary.LittleEndian.Uint32(buf[0:4]),
	}, nil
}

// RstatMsg is the response to Tstat
type RstatMsg struct {
	Stat Stat
}

func (m *RstatMsg) Type() uint8 { return Rstat }

func (m *RstatMsg) Encode(buf []byte) int {
	// Rstat has an extra 2-byte length prefix for the stat
	statBuf := buf[2:]
	n := m.Stat.Encode(statBuf)
	binary.LittleEndian.PutUint16(buf[0:2], uint16(n))
	return 2 + n
}

// RerrorMsg indicates an error
type RerrorMsg struct {
	Ename string
}

func (m *RerrorMsg) Type() uint8 { return Rerror }

func (m *RerrorMsg) Encode(buf []byte) int {
	return EncodeString(buf, m.Ename)
}

// TflushMsg cancels a pending request
type TflushMsg struct {
	Oldtag uint16
}

func (m *TflushMsg) Type() uint8 { return Tflush }

func (m *TflushMsg) Encode(buf []byte) int {
	binary.LittleEndian.PutUint16(buf[0:2], m.Oldtag)
	return 2
}

func DecodeTflush(buf []byte) (*TflushMsg, error) {
	if len(buf) < 2 {
		return nil, fmt.Errorf("Tflush too short")
	}
	return &TflushMsg{
		Oldtag: binary.LittleEndian.Uint16(buf[0:2]),
	}, nil
}

// RflushMsg is the response to Tflush
type RflushMsg struct{}

func (m *RflushMsg) Type() uint8 { return Rflush }

func (m *RflushMsg) Encode(buf []byte) int {
	return 0
}

A  => internal/protocol/protocol.go +327 -0
@@ 1,327 @@
// Package protocol implements the 9P2000 protocol for the LLM filesystem.
//
// This is a minimal, clean implementation focused on the subset of 9P
// needed for LLM interaction. It is designed to be:
//   - Zero external dependencies (stdlib only)
//   - LLM-friendly (self-describing, good errors)
//   - Simple to understand and maintain
//
// The 9P protocol uses a simple request-response model over a bidirectional
// stream. Each message has a 4-byte size, 1-byte type, and 2-byte tag,
// followed by type-specific payload.
package protocol

import (
	"encoding/binary"
	"fmt"
	"io"
)

// Protocol constants
const (
	// Version is the protocol version we implement
	Version = "9P2000"

	// MaxMessageSize is the maximum size of a 9P message
	MaxMessageSize = 8192

	// NoTag is used for Tversion/Rversion which don't use tags
	NoTag uint16 = 0xFFFF

	// NoFid represents an invalid fid
	NoFid uint32 = 0xFFFFFFFF
)

// Message types (T = request from client, R = response from server)
const (
	Tversion uint8 = 100
	Rversion uint8 = 101
	Tauth    uint8 = 102
	Rauth    uint8 = 103
	Tattach  uint8 = 104
	Rattach  uint8 = 105
	Terror   uint8 = 106 // never sent
	Rerror   uint8 = 107
	Tflush   uint8 = 108
	Rflush   uint8 = 109
	Twalk    uint8 = 110
	Rwalk    uint8 = 111
	Topen    uint8 = 112
	Ropen    uint8 = 113
	Tcreate  uint8 = 114
	Rcreate  uint8 = 115
	Tread    uint8 = 116
	Rread    uint8 = 117
	Twrite   uint8 = 118
	Rwrite   uint8 = 119
	Tclunk   uint8 = 120
	Rclunk   uint8 = 121
	Tremove  uint8 = 122
	Rremove  uint8 = 123
	Tstat    uint8 = 124
	Rstat    uint8 = 125
	Twstat   uint8 = 126
	Rwstat   uint8 = 127
)

// Open modes
const (
	OREAD  uint8 = 0  // open for read
	OWRITE uint8 = 1  // open for write
	ORDWR  uint8 = 2  // open for read/write
	OEXEC  uint8 = 3  // execute (unused in our context)
	OTRUNC uint8 = 16 // truncate file first
)

// File modes (high bits of Stat.Mode)
const (
	DMDIR    uint32 = 0x80000000 // directory
	DMAPPEND uint32 = 0x40000000 // append only
	DMEXCL   uint32 = 0x20000000 // exclusive use
	DMTMP    uint32 = 0x04000000 // temporary file
)

// Qid represents a unique file identifier
type Qid struct {
	Type    uint8  // QTDIR, QTFILE, etc.
	Version uint32 // version number for cache coherence
	Path    uint64 // unique path identifier
}

// Qid types
const (
	QTDIR    uint8 = 0x80 // directory
	QTAPPEND uint8 = 0x40 // append-only
	QTEXCL   uint8 = 0x20 // exclusive use
	QTTMP    uint8 = 0x04 // temporary
	QTFILE   uint8 = 0x00 // regular file
)

// Stat represents file metadata
type Stat struct {
	Size   uint16 // size of this stat structure (for wire format)
	Type   uint16 // server type
	Dev    uint32 // server device
	Qid    Qid    // unique id
	Mode   uint32 // permissions and flags
	Atime  uint32 // last access time
	Mtime  uint32 // last modification time
	Length uint64 // file length
	Name   string // file name
	Uid    string // owner
	Gid    string // group
	Muid   string // last modifier
}

// Encoder handles encoding messages to the wire format
type Encoder struct {
	w   io.Writer
	buf []byte
}

// NewEncoder creates a new encoder
func NewEncoder(w io.Writer) *Encoder {
	return &Encoder{
		w:   w,
		buf: make([]byte, MaxMessageSize),
	}
}

// Decoder handles decoding messages from the wire format
type Decoder struct {
	r   io.Reader
	buf []byte
}

// NewDecoder creates a new decoder
func NewDecoder(r io.Reader) *Decoder {
	return &Decoder{
		r:   r,
		buf: make([]byte, MaxMessageSize),
	}
}

// ReadMessage reads a complete 9P message from the stream
func (d *Decoder) ReadMessage() (msgType uint8, tag uint16, payload []byte, err error) {
	// Read 4-byte size
	if _, err := io.ReadFull(d.r, d.buf[:4]); err != nil {
		return 0, 0, nil, fmt.Errorf("reading size: %w", err)
	}
	size := binary.LittleEndian.Uint32(d.buf[:4])

	if size < 7 {
		return 0, 0, nil, fmt.Errorf("message too small: %d", size)
	}
	if size > MaxMessageSize {
		return 0, 0, nil, fmt.Errorf("message too large: %d", size)
	}

	// Read rest of message
	remaining := size - 4
	if _, err := io.ReadFull(d.r, d.buf[:remaining]); err != nil {
		return 0, 0, nil, fmt.Errorf("reading message: %w", err)
	}

	msgType = d.buf[0]
	tag = binary.LittleEndian.Uint16(d.buf[1:3])
	payload = d.buf[3:remaining]

	return msgType, tag, payload, nil
}

// WriteMessage writes a complete 9P message to the stream
func (e *Encoder) WriteMessage(msgType uint8, tag uint16, payload []byte) error {
	size := uint32(4 + 1 + 2 + len(payload))
	if size > MaxMessageSize {
		return fmt.Errorf("message too large: %d", size)
	}

	binary.LittleEndian.PutUint32(e.buf[0:4], size)
	e.buf[4] = msgType
	binary.LittleEndian.PutUint16(e.buf[5:7], tag)
	copy(e.buf[7:], payload)

	_, err := e.w.Write(e.buf[:size])
	return err
}

// String encoding helpers

func EncodeString(buf []byte, s string) int {
	binary.LittleEndian.PutUint16(buf[0:2], uint16(len(s)))
	copy(buf[2:], s)
	return 2 + len(s)
}

func DecodeString(buf []byte) (string, int) {
	if len(buf) < 2 {
		return "", 0
	}
	size := binary.LittleEndian.Uint16(buf[0:2])
	if len(buf) < int(2+size) {
		return "", 0
	}
	return string(buf[2 : 2+size]), int(2 + size)
}

// Qid encoding

func (q *Qid) Encode(buf []byte) int {
	buf[0] = q.Type
	binary.LittleEndian.PutUint32(buf[1:5], q.Version)
	binary.LittleEndian.PutUint64(buf[5:13], q.Path)
	return 13
}

func DecodeQid(buf []byte) (Qid, int) {
	if len(buf) < 13 {
		return Qid{}, 0
	}
	return Qid{
		Type:    buf[0],
		Version: binary.LittleEndian.Uint32(buf[1:5]),
		Path:    binary.LittleEndian.Uint64(buf[5:13]),
	}, 13
}

// Stat encoding

func (s *Stat) Encode(buf []byte) int {
	// Skip size field, we'll fill it at the end
	n := 2

	// Fixed fields
	binary.LittleEndian.PutUint16(buf[n:n+2], s.Type)
	n += 2
	binary.LittleEndian.PutUint32(buf[n:n+4], s.Dev)
	n += 4
	n += s.Qid.Encode(buf[n:])
	binary.LittleEndian.PutUint32(buf[n:n+4], s.Mode)
	n += 4
	binary.LittleEndian.PutUint32(buf[n:n+4], s.Atime)
	n += 4
	binary.LittleEndian.PutUint32(buf[n:n+4], s.Mtime)
	n += 4
	binary.LittleEndian.PutUint64(buf[n:n+8], s.Length)
	n += 8

	// Variable fields
	n += EncodeString(buf[n:], s.Name)
	n += EncodeString(buf[n:], s.Uid)
	n += EncodeString(buf[n:], s.Gid)
	n += EncodeString(buf[n:], s.Muid)

	// Fill in size (total - 2 for size field itself)
	s.Size = uint16(n - 2)
	binary.LittleEndian.PutUint16(buf[0:2], s.Size)

	return n
}

func DecodeStat(buf []byte) (Stat, int) {
	if len(buf) < 2 {
		return Stat{}, 0
	}

	s := Stat{}
	s.Size = binary.LittleEndian.Uint16(buf[0:2])

	if len(buf) < int(s.Size)+2 {
		return Stat{}, 0
	}

	n := 2
	s.Type = binary.LittleEndian.Uint16(buf[n : n+2])
	n += 2
	s.Dev = binary.LittleEndian.Uint32(buf[n : n+4])
	n += 4

	var qn int
	s.Qid, qn = DecodeQid(buf[n:])
	n += qn

	s.Mode = binary.LittleEndian.Uint32(buf[n : n+4])
	n += 4
	s.Atime = binary.LittleEndian.Uint32(buf[n : n+4])
	n += 4
	s.Mtime = binary.LittleEndian.Uint32(buf[n : n+4])
	n += 4
	s.Length = binary.LittleEndian.Uint64(buf[n : n+8])
	n += 8

	var sn int
	s.Name, sn = DecodeString(buf[n:])
	n += sn
	s.Uid, sn = DecodeString(buf[n:])
	n += sn
	s.Gid, sn = DecodeString(buf[n:])
	n += sn
	s.Muid, sn = DecodeString(buf[n:])
	n += sn

	return s, int(s.Size) + 2
}

// MessageName returns the human-readable name of a message type
func MessageName(t uint8) string {
	names := map[uint8]string{
		Tversion: "Tversion", Rversion: "Rversion",
		Tauth: "Tauth", Rauth: "Rauth",
		Tattach: "Tattach", Rattach: "Rattach",
		Rerror: "Rerror",
		Tflush: "Tflush", Rflush: "Rflush",
		Twalk: "Twalk", Rwalk: "Rwalk",
		Topen: "Topen", Ropen: "Ropen",
		Tcreate: "Tcreate", Rcreate: "Rcreate",
		Tread: "Tread", Rread: "Rread",
		Twrite: "Twrite", Rwrite: "Rwrite",
		Tclunk: "Tclunk", Rclunk: "Rclunk",
		Tremove: "Tremove", Rremove: "Rremove",
		Tstat: "Tstat", Rstat: "Rstat",
		Twstat: "Twstat", Rwstat: "Rwstat",
	}
	if name, ok := names[t]; ok {
		return name
	}
	return fmt.Sprintf("unknown(%d)", t)
}

A  => internal/protocol/server.go +346 -0
@@ 1,346 @@
package protocol

import (
	"context"
	"fmt"
	"io"
	"log"
	"net"
	"sync"
)

// Server is a 9P file server
type Server struct {
	root    Dir
	debug   bool
	mu      sync.Mutex
	clients map[net.Conn]*clientState
}

// clientState tracks state for a single client connection
type clientState struct {
	fids  map[uint32]File
	msize uint32
}

// NewServer creates a new 9P server with the given root directory
func NewServer(root Dir) *Server {
	return &Server{
		root:    root,
		clients: make(map[net.Conn]*clientState),
	}
}

// SetDebug enables debug logging
func (s *Server) SetDebug(debug bool) {
	s.debug = debug
}

// Serve handles incoming connections on the listener
func (s *Server) Serve(ctx context.Context, listener net.Listener) error {
	for {
		conn, err := listener.Accept()
		if err != nil {
			select {
			case <-ctx.Done():
				return ctx.Err()
			default:
				log.Printf("accept error: %v", err)
				continue
			}
		}

		go s.handleConn(conn)
	}
}

// ServeConn handles a single connection (useful for testing)
func (s *Server) ServeConn(conn net.Conn) {
	s.handleConn(conn)
}

func (s *Server) handleConn(conn net.Conn) {
	defer conn.Close()

	state := &clientState{
		fids:  make(map[uint32]File),
		msize: MaxMessageSize,
	}

	s.mu.Lock()
	s.clients[conn] = state
	s.mu.Unlock()

	defer func() {
		s.mu.Lock()
		delete(s.clients, conn)
		s.mu.Unlock()
	}()

	dec := NewDecoder(conn)
	enc := NewEncoder(conn)
	buf := make([]byte, MaxMessageSize)

	for {
		msgType, tag, payload, err := dec.ReadMessage()
		if err != nil {
			if err != io.EOF {
				log.Printf("read error: %v", err)
			}
			return
		}

		if s.debug {
			log.Printf("< %s tag=%d len=%d", MessageName(msgType), tag, len(payload))
		}

		resp, respType := s.handleMessage(state, msgType, payload, buf)

		if s.debug {
			log.Printf("> %s tag=%d len=%d", MessageName(respType), tag, len(resp))
		}

		if err := enc.WriteMessage(respType, tag, resp); err != nil {
			log.Printf("write error: %v", err)
			return
		}
	}
}

func (s *Server) handleMessage(state *clientState, msgType uint8, payload []byte, buf []byte) ([]byte, uint8) {
	switch msgType {
	case Tversion:
		return s.handleVersion(state, payload, buf)
	case Tattach:
		return s.handleAttach(state, payload, buf)
	case Twalk:
		return s.handleWalk(state, payload, buf)
	case Topen:
		return s.handleOpen(state, payload, buf)
	case Tread:
		return s.handleRead(state, payload, buf)
	case Twrite:
		return s.handleWrite(state, payload, buf)
	case Tclunk:
		return s.handleClunk(state, payload, buf)
	case Tstat:
		return s.handleStat(state, payload, buf)
	case Tflush:
		return s.handleFlush(state, payload, buf)
	default:
		return s.errorResponse(buf, fmt.Sprintf("unknown message type: %d", msgType))
	}
}

func (s *Server) errorResponse(buf []byte, msg string) ([]byte, uint8) {
	resp := &RerrorMsg{Ename: msg}
	n := resp.Encode(buf)
	return buf[:n], Rerror
}

func (s *Server) handleVersion(state *clientState, payload []byte, buf []byte) ([]byte, uint8) {
	msg, err := DecodeTversion(payload)
	if err != nil {
		return s.errorResponse(buf, err.Error())
	}

	// Negotiate message size
	msize := msg.Msize
	if msize > MaxMessageSize {
		msize = MaxMessageSize
	}
	state.msize = msize

	// Check version - accept both 9P2000 and Styx (Inferno's name)
	version := msg.Version
	if msg.Version != Version && msg.Version != "Styx" {
		version = "unknown"
	}

	if s.debug {
		log.Printf("Version negotiation: client=%q responding=%q msize=%d", msg.Version, version, msize)
	}

	resp := &RversionMsg{Msize: msize, Version: version}
	n := resp.Encode(buf)
	return buf[:n], Rversion
}

func (s *Server) handleAttach(state *clientState, payload []byte, buf []byte) ([]byte, uint8) {
	msg, err := DecodeTattach(payload)
	if err != nil {
		return s.errorResponse(buf, err.Error())
	}

	if _, exists := state.fids[msg.Fid]; exists {
		return s.errorResponse(buf, ErrFidInUse.Error())
	}

	state.fids[msg.Fid] = s.root

	resp := &RattachMsg{Qid: s.root.Stat().Qid}
	n := resp.Encode(buf)
	return buf[:n], Rattach
}

func (s *Server) handleWalk(state *clientState, payload []byte, buf []byte) ([]byte, uint8) {
	msg, err := DecodeTwalk(payload)
	if err != nil {
		return s.errorResponse(buf, err.Error())
	}

	file, exists := state.fids[msg.Fid]
	if !exists {
		return s.errorResponse(buf, ErrBadFid.Error())
	}

	if msg.Fid != msg.Newfid {
		if _, exists := state.fids[msg.Newfid]; exists {
			return s.errorResponse(buf, ErrFidInUse.Error())
		}
	}

	// Walk the path
	qids := make([]Qid, 0, len(msg.Names))
	current := file

	for _, name := range msg.Names {
		dir, ok := current.(Dir)
		if !ok {
			return s.errorResponse(buf, ErrNotDir.Error())
		}

		next, err := dir.Lookup(name)
		if err != nil {
			// Return partial walk
			break
		}

		qids = append(qids, next.Stat().Qid)
		current = next
	}

	// Only update fid if we walked at least one element (or no elements requested)
	if len(qids) == len(msg.Names) {
		state.fids[msg.Newfid] = current
	}

	resp := &RwalkMsg{Qids: qids}
	n := resp.Encode(buf)
	return buf[:n], Rwalk
}

func (s *Server) handleOpen(state *clientState, payload []byte, buf []byte) ([]byte, uint8) {
	msg, err := DecodeTopen(payload)
	if err != nil {
		return s.errorResponse(buf, err.Error())
	}

	file, exists := state.fids[msg.Fid]
	if !exists {
		return s.errorResponse(buf, ErrBadFid.Error())
	}

	if err := file.Open(msg.Mode); err != nil {
		return s.errorResponse(buf, err.Error())
	}

	resp := &RopenMsg{
		Qid:    file.Stat().Qid,
		Iounit: 0, // 0 means use msize - overhead
	}
	n := resp.Encode(buf)
	return buf[:n], Ropen
}

func (s *Server) handleRead(state *clientState, payload []byte, buf []byte) ([]byte, uint8) {
	msg, err := DecodeTread(payload)
	if err != nil {
		return s.errorResponse(buf, err.Error())
	}

	file, exists := state.fids[msg.Fid]
	if !exists {
		return s.errorResponse(buf, ErrBadFid.Error())
	}

	// Limit read size to available buffer
	count := msg.Count
	maxData := state.msize - 4 - 1 - 2 - 4 // size, type, tag, count
	if count > maxData {
		count = maxData
	}

	data := make([]byte, count)
	n, err := file.Read(data, int64(msg.Offset))
	if err != nil && err != io.EOF {
		return s.errorResponse(buf, err.Error())
	}

	resp := &RreadMsg{Data: data[:n]}
	rn := resp.Encode(buf)
	return buf[:rn], Rread
}

func (s *Server) handleWrite(state *clientState, payload []byte, buf []byte) ([]byte, uint8) {
	msg, err := DecodeTwrite(payload)
	if err != nil {
		return s.errorResponse(buf, err.Error())
	}

	file, exists := state.fids[msg.Fid]
	if !exists {
		return s.errorResponse(buf, ErrBadFid.Error())
	}

	n, err := file.Write(msg.Data, int64(msg.Offset))
	if err != nil {
		return s.errorResponse(buf, err.Error())
	}

	resp := &RwriteMsg{Count: uint32(n)}
	rn := resp.Encode(buf)
	return buf[:rn], Rwrite
}

func (s *Server) handleClunk(state *clientState, payload []byte, buf []byte) ([]byte, uint8) {
	msg, err := DecodeTclunk(payload)
	if err != nil {
		return s.errorResponse(buf, err.Error())
	}

	file, exists := state.fids[msg.Fid]
	if !exists {
		return s.errorResponse(buf, ErrBadFid.Error())
	}

	file.Close()
	delete(state.fids, msg.Fid)

	resp := &RclunkMsg{}
	n := resp.Encode(buf)
	return buf[:n], Rclunk
}

func (s *Server) handleStat(state *clientState, payload []byte, buf []byte) ([]byte, uint8) {
	msg, err := DecodeTstat(payload)
	if err != nil {
		return s.errorResponse(buf, err.Error())
	}

	file, exists := state.fids[msg.Fid]
	if !exists {
		return s.errorResponse(buf, ErrBadFid.Error())
	}

	resp := &RstatMsg{Stat: file.Stat()}
	n := resp.Encode(buf)
	return buf[:n], Rstat
}

func (s *Server) handleFlush(state *clientState, payload []byte, buf []byte) ([]byte, uint8) {
	// We don't have async operations to cancel, so just respond OK
	resp := &RflushMsg{}
	n := resp.Encode(buf)
	return buf[:n], Rflush
}