Skip to content
Merged
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
84 changes: 84 additions & 0 deletions .github/actions/setup-go-cached/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# =============================================================================
# Set up Go with a job-scoped build cache.
#
# This exists because actions/setup-go's built-in `cache: true` is actively
# harmful in a repo with more than one Go job.
#
# setup-go derives its cache key from the platform, the Go version, and a hash
# of go.sum — and nothing else. Every Go job on the same runner OS therefore
# competes for one key. actions/cache never overwrites an existing key, so the
# first job to reach its post-run step wins and every other job on that OS
# silently inherits whatever that one job happened to have compiled.
#
# The job that wins is the one that finishes first, which is the one that did
# the least work. In this repo that was `gofmt` — it runs `gofmt -l -s .`,
# compiles nothing, and finished in ten seconds. It was writing an empty
# GOCACHE under the shared key, and Build, Test and golangci-lint were all
# restoring that empty cache and recompiling the world every run. Measured on
# the cache list: six of eight Linux entries were ~7.5 KB, against 150 MB for
# the two runs where a real job happened to win the race. macOS and Windows
# were unaffected at ~270 MB, because only one job ever runs there — which is
# exactly the tell that this is a collision and not a Go problem.
#
# The fix is to scope the key per job. Each job then caches and restores its
# own compiled output, and a job that compiles nothing can no longer speak for
# one that does.
#
# Keying on go.sum alone (no run id / sha) is deliberate. The key is frozen
# until the dependency graph changes, so the cache is written once per go.sum
# and reused unchanged after that. Compiled dependencies — grpc, protobuf and
# the race-instrumented standard library above all — dominate a cold build,
# and they are precisely what does not change between commits. A rolling key
# would also cache this repo's own packages, but at the cost of re-uploading
# a few hundred MB on every run and churning through the repository's 10 GB
# cache budget in a handful of runs.
# =============================================================================

name: Set up Go with a job-scoped build cache
description: >
Installs the Go toolchain named in go.mod and restores GOCACHE and
GOMODCACHE under a cache key scoped to the calling job, so that jobs which
compile nothing cannot evict the caches of jobs that do.

inputs:
job:
description: >
Cache scope for the calling job — any short stable name unique within
this repository (e.g. "build", "test", "lint"). Two jobs sharing a value
share a cache, which is only correct when they compile the same thing.
required: true

runs:
using: composite
steps:
# cache: false — this action's whole purpose is to replace setup-go's
# cache with one that is scoped correctly. Leaving it on would restore the
# shared key over the top of ours and re-create the collision.
- name: Install Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
cache: false

# Ask the toolchain where its caches are rather than hardcoding paths:
# GOCACHE and GOMODCACHE differ across Linux, macOS and Windows, and this
# action runs on all three.
- name: Locate the Go caches
id: paths
shell: bash
run: |
echo "build=$(go env GOCACHE)" >> "$GITHUB_OUTPUT"
echo "mod=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT"

- name: Restore Go build and module caches
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
${{ steps.paths.outputs.build }}
${{ steps.paths.outputs.mod }}
key: go-${{ runner.os }}-${{ inputs.job }}-${{ hashFiles('go.sum') }}
# A dependency bump changes the key and would otherwise start from
# nothing. The prefix match reuses the previous graph's compiled
# output, so only what actually changed is rebuilt.
restore-keys: |
go-${{ runner.os }}-${{ inputs.job }}-
7 changes: 6 additions & 1 deletion .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,13 @@ updates:
commit-message:
prefix: "deps"

# "/" covers .github/workflows. Composite actions are not picked up by it —
# each one's directory has to be listed, or the SHAs pinned inside it go
# stale silently while the workflows around it stay current.
- package-ecosystem: "github-actions"
directory: "/"
directories:
- "/"
- "/.github/actions/setup-go-cached"
schedule:
interval: "weekly"
cooldown:
Expand Down
29 changes: 19 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,10 @@ jobs:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

# cache: true keys GOMODCACHE + GOCACHE on go.sum — shared by every
# job in this workflow that sets up Go the same way.
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
uses: ./.github/actions/setup-go-cached
with:
go-version-file: go.mod
cache: true
job: build

- name: Download modules
run: go mod download
Expand Down Expand Up @@ -107,11 +104,12 @@ jobs:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

# Scoped per OS by runner.os inside the action, so the three legs of
# this matrix keep three separate caches rather than fighting over one.
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
uses: ./.github/actions/setup-go-cached
with:
go-version-file: go.mod
cache: true
job: test

- name: Test with race detector + coverage
run: go test -race -shuffle=on -covermode=atomic -coverprofile=coverage.out ./...
Expand Down Expand Up @@ -156,11 +154,17 @@ jobs:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

# No cache at all, deliberately: gofmt parses source and neither
# resolves modules nor compiles anything, so there is nothing to cache
# and nothing to restore. This job used to carry setup-go's default
# cache, and because it finishes faster than any other Go job it was the
# one that won the shared cache key and wrote an empty GOCACHE for
# everything else to restore. See .github/actions/setup-go-cached.
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
cache: true
cache: false

- name: Check gofmt -s
run: |
Expand Down Expand Up @@ -198,11 +202,16 @@ jobs:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

# No cache, measured rather than assumed: this job only compiles the
# protoc plugins named by go.mod's tool directives, which is about eight
# seconds of work. Restoring the 26 MB that produces costs more than it
# saves — the job ran 15s uncached against 20s cached. A job has to be
# big enough to profit from a cache, and this one is not.
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
cache: true
cache: false

# setup_only: we drive buf explicitly below rather than using the
# action's opinionated defaults. Version pinned for reproducibility;
Expand Down
10 changes: 7 additions & 3 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,15 @@ jobs:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

# Scoped separately from ci.yml's jobs: golangci-lint's type-checking
# pass populates GOCACHE with export data the plain build does not
# produce, and it is the job that benefits most from getting its own
# cache back rather than another job's. The action keeps its own small
# analysis cache alongside this one; the two are unrelated.
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
uses: ./.github/actions/setup-go-cached
with:
go-version-file: go.mod
cache: true
job: lint

# Full-repo enforcement, not only-new-issues: the baseline is clean,
# and diff-scoped linting can miss issues surfaced by base-branch drift.
Expand Down
22 changes: 19 additions & 3 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,28 @@ jobs:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

# gosec runs as an installed binary rather than via securego/gosec,
# which is a Docker action. A container cannot see the runner's GOCACHE
# or GOMODCACHE, so that action re-downloaded the whole module graph and
# recompiled it on every run — 2m27s, the slowest job outside the test
# matrix, essentially all of it work the cache already holds. gosec type-
# checks the program through go/packages, so it benefits from the same
# compiled export data every other Go job here does.
- name: Set up Go
uses: ./.github/actions/setup-go-cached
with:
job: gosec

# Version-pinned to match what the action ran. Installed into bin/ per
# the repo's build-output rule; Dependabot does not track this pin, so
# bump it deliberately.
- name: Install gosec
run: GOBIN="$GITHUB_WORKSPACE/bin" go install github.com/securego/gosec/v2/cmd/gosec@v2.28.0

# -exclude-generated: pkg/*/proto/v1 is 100% buf-generated output;
# findings there are upstream-generator noise, not actionable here.
- name: Run gosec
uses: securego/gosec@9e75c0576c9878035d4221392108d458abe10fc3 # v2.28.0
with:
args: -no-fail -exclude-generated -fmt sarif -out results.sarif ./...
run: bin/gosec -no-fail -exclude-generated -fmt sarif -out results.sarif ./...

- name: Upload SARIF to GitHub code scanning
uses: github/codeql-action/upload-sarif@e0647621c2984b5ed2f768cb892365bf2a616ad1 # v4.37.2
Expand Down
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ An AI coding harness built as a Go microkernel: the kernel owns plugin lifecycle

## Current state

Kernel-side packages in `internal/` and the `pkg/` SDK are real, tested Go. There is no `cmd/` binary yet, and most plugin categories exist only as spec. Implementation is spec-first: before writing code, confirm the relevant spec exists and is settled; if it has open questions bearing on the task, raise them instead of coding against an assumption. Don't start new implementation work without being asked.
Kernel-side packages in `internal/` and the `pkg/` SDK are real, tested Go. Three `cmd/` binaries exist: `agent` (the kernel, currently non-interactive — no REPL), `anthropic` (the reference model-provider plugin, and the template for any new plugin binary), and `tui` (the reference terminal shell, currently driven by a scripted demo source because no kernel-side frontend-attach path exists yet). Most other plugin categories exist only as spec. Implementation is spec-first: before writing code, confirm the relevant spec exists and is settled; if it has open questions bearing on the task, raise them instead of coding against an assumption. Don't start new implementation work without being asked.

The terminal shell's design — region layout, focus model, keymap layers, and the TTY-ownership constraint that follows from a frontend being a go-plugin subprocess — is [`docs/first-party/frontends/tui.md`](docs/first-party/frontends/tui.md). It is descriptive, not normative: the protocol deliberately leaves focus, keybindings, resize, and scrollback to each frontend.

## Toolchain, testing, and CI

Expand Down
131 changes: 131 additions & 0 deletions cmd/tui/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// Command tui runs the reference terminal shell for PluggableHarness Agent.
//
// The shell is a frontend provider: in its finished form the kernel launches it
// as a hashicorp/go-plugin subprocess and drives it over a bidirectional Attach
// stream. That kernel-side attach path does not exist yet, so this binary
// currently runs the shell against a scripted demo source, which is what makes
// the layout, focus model, and keymap reviewable ahead of the wiring.
//
// The terminal is opened directly rather than using stdin/stdout, because under
// go-plugin those streams belong to the handshake and the host's logger. That
// is the real code path, exercised here so it does not need revisiting when the
// bridge lands.
package main

import (
"context"
"flag"
"fmt"
"log/slog"
"os"
"os/signal"
"syscall"
"time"

tea "charm.land/bubbletea/v2"

"github.com/pluggableharness/agent/internal/tui/shell"
"github.com/pluggableharness/agent/internal/tui/theme"
)

func main() {
themeName := flag.String("theme", "dark", "color theme: dark or light")
step := flag.Duration("step", 120*time.Millisecond, "delay between scripted demo events")
logLevel := flag.String("log-level", "warn", "log level: debug, info, warn, error")
flag.Parse()

if err := run(*themeName, *step, *logLevel); err != nil {
// Diagnostics go to stderr, never to the painted surface. Under
// go-plugin the host collects this as structured plugin output.
fmt.Fprintf(os.Stderr, "tui: %v\n", err)
os.Exit(1)
}
}

func run(themeName string, step time.Duration, logLevel string) error {
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: parseLevel(logLevel),
})))

th, ok := theme.ByName(themeName)
if !ok {
slog.Warn("unknown theme, falling back", "requested", themeName, "using", th.Name)
}

tty, err := openTTY()
if err != nil {
// No controlling terminal: the shell degrades to not attaching rather
// than taking down whatever launched it.
return fmt.Errorf("tui: open terminal: %w", err)
}
defer func() {
if cerr := tty.Close(); cerr != nil {
slog.Warn("closing terminal", "error", cerr)
}
}()

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

outbox := make(chan shell.Action, 64)
model := shell.New(
shell.WithTheme(th),
shell.WithEmitter(func(a shell.Action) {
select {
case outbox <- a:
default:
slog.Warn("outbox full, dropping action")
}
}),
)

// Alt-screen is declared by the model's View in Bubble Tea v2, not as a
// program option.
prog := tea.NewProgram(model,
tea.WithContext(ctx),
tea.WithInput(tty),
tea.WithOutput(tty),
)

go drainOutbox(ctx, outbox)
go func() {
src := shell.DemoSource{Step: step}
if rerr := src.Run(ctx, prog.Send); rerr != nil {
slog.Error("event source stopped", "error", rerr)
}
}()

if _, err := prog.Run(); err != nil {
return fmt.Errorf("tui: run: %w", err)
}

return nil
}

// drainOutbox stands in for the Attach stream's writer goroutine. The real
// bridge translates each Action into a ClientEvent and writes it to the stream
// in arrival order, which matters because the kernel processes client events in
// arrival order per session.
func drainOutbox(ctx context.Context, outbox <-chan shell.Action) {
for {
select {
case <-ctx.Done():
return
case a := <-outbox:
slog.Debug("client action", "action", fmt.Sprintf("%T", a))
}
}
}

func parseLevel(s string) slog.Level {
switch s {
case "debug":
return slog.LevelDebug
case "info":
return slog.LevelInfo
case "error":
return slog.LevelError
default:
return slog.LevelWarn
}
}
16 changes: 16 additions & 0 deletions cmd/tui/tty_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
//go:build !windows

package main

import "os"

// openTTY opens the controlling terminal for direct read/write.
//
// The shell must never render to stdout or read stdin: when it runs as a
// go-plugin subprocess the handshake line is written to stdout and the host
// pipes stdout and stderr into its own logger, so painting there would corrupt
// the handshake and reading there would compete with the plugin transport.
// Opening the controlling terminal sidesteps both.
func openTTY() (*os.File, error) {
return os.OpenFile("/dev/tty", os.O_RDWR, 0)
}
17 changes: 17 additions & 0 deletions cmd/tui/tty_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
//go:build windows

package main

import "os"

// openTTY opens the Windows console device for direct read/write.
//
// This is the Windows half of the same constraint the unix build documents:
// stdout carries the go-plugin handshake and is piped into the host's logger,
// so the shell paints to the console device instead. CONIN$/CONOUT$ are the
// console equivalents of /dev/tty, but they are two separate handles rather
// than one bidirectional file, so the caller receives the output handle and
// Bubble Tea opens console input itself.
func openTTY() (*os.File, error) {
return os.OpenFile("CONOUT$", os.O_RDWR, 0)
}
Loading
Loading