Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
8a4a466
feat(eventlog): port DSH SurfaceManager surface correction protocol
Patel230 Aug 21, 2026
7c081c7
feat(eventlog): surface-driven projection for replace ops
Patel230 Aug 21, 2026
b9bd0f6
feat(acp): port ACP content admission
Patel230 Aug 21, 2026
c016a9d
docs(plans): mark ACP protocol and content admission delivered
Patel230 Aug 21, 2026
12dec3d
feat(acp): mount attachment store for inline image prompts in CLI
Patel230 Aug 21, 2026
42a603e
feat(permissions): port fx opaque approval-token escalation
Patel230 Aug 21, 2026
74565af
feat(permissions): port fx stable-id rule remember/list/revoke
Patel230 Aug 21, 2026
2300447
feat(terminal): port fx fxtape terminal capture and replay
Patel230 Aug 21, 2026
a9f7acf
feat(trace): port fx /trace diagnostic markdown report
Patel230 Aug 21, 2026
f8e62a6
feat(replay): port fx replay --frames-dir frame artifacts + manifest
Patel230 Aug 21, 2026
c136a16
feat(tape): add tape status and commit checkpoint commands
Patel230 Aug 21, 2026
0b5b377
feat(issue): add GitHub issue draft command (fx issue parity)
Patel230 Aug 21, 2026
7e7a562
feat(usage): track local LLM token usage and spend (fx usage parity)
Patel230 Aug 21, 2026
5b3a7f1
feat(session): migrate saved sessions to the current format (fx sessi…
Patel230 Aug 21, 2026
5eb9074
feat(record): capture live REPL output to an fxtape (fx --record parity)
Patel230 Aug 21, 2026
d87683e
test(testaudit): exempt tape prompt-glyph matcher to unblock CI
Patel230 Aug 21, 2026
ac6d88b
fix(record): compile --record on windows (build-tagged SIGWINCH handler)
Patel230 Aug 21, 2026
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
12 changes: 12 additions & 0 deletions cmd/acp.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@ import (
"io"
"os"
"os/signal"
"path/filepath"
"syscall"

"github.com/GrayCodeAI/hawk/internal/acp"
"github.com/GrayCodeAI/hawk/internal/attachment"
hawkconfig "github.com/GrayCodeAI/hawk/internal/config"
"github.com/GrayCodeAI/hawk/internal/engine"
"github.com/GrayCodeAI/hawk/internal/observability/logger"
"github.com/GrayCodeAI/hawk/internal/storage"
"github.com/spf13/cobra"
)

Expand Down Expand Up @@ -43,5 +46,14 @@ func runACP(cmd *cobra.Command, _ []string) error {
defer stop()

srv := acp.NewServer(factory)

// Mount a durable attachment store for inline image admission, gated on
// the resolved active model's vision support. When no deployment (or a
// non-vision model) is configured, image capability stays false and the
// server rejects image prompts rather than advertising support.
store := attachment.NewFSStore(filepath.Join(storage.StateDir(), "attachments"))
effectiveModel, _ := effectiveModelAndProvider(settings)
srv.SetAttachmentStore(store, engine.ModelSupportsVision(effectiveModel))

return srv.ServeStdio(ctx)
}
12 changes: 10 additions & 2 deletions cmd/chat_print.go
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,14 @@ func runRepl() error {
return err
}

if recordPath != "" {
restoreRec, recErr := startRecording(recordPath)
if recErr != nil {
return fmt.Errorf("record: %w", recErr)
}
defer restoreRec()
}

ctx := context.Background()
var countdown bool
if timeout > 0 {
Expand Down Expand Up @@ -350,7 +358,7 @@ func runRepl() error {
continue
}
if output != "" {
_, _ = fmt.Fprintln(os.Stdout, output)
_, _ = fmt.Fprintln(replOut, output)
}
continue
}
Expand All @@ -369,7 +377,7 @@ func runRepl() error {
switch ev.Type {
case "content":
if outputFormat == "text" {
fmt.Print(ev.Content)
_, _ = fmt.Fprint(replOut, ev.Content)
} else if outputFormat == "stream-json" {
writePrintEvent(sessionID, "content", ev.Content, "")
}
Expand Down
132 changes: 132 additions & 0 deletions cmd/issue.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package cmd

import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"

"github.com/spf13/cobra"
)

var (
issueTitle string
issueBody string
issueAssign string
issueLabels []string
issueDryRun bool
issueJSON bool
)

var issueCmd = &cobra.Command{
Use: "issue [context]",
Short: "Draft or publish a GitHub issue (fx issue parity)",
Long: `Draft or publish a GitHub issue for the current repository, mirroring
fx's "issue" command.

A title and body are generated from the optional <context> — describe a
problem, paste a stack trace, or leave it empty. Publishing creates the issue
through the GitHub CLI ("gh"), so that must be installed and authenticated.

Use --dry-run to preview the title and body without publishing anything.`,
Args: cobra.MaximumNArgs(1),
RunE: runIssue,
}

func init() {
issueCmd.Flags().StringVar(&issueTitle, "title", "", "issue title (default: generated from context)")
issueCmd.Flags().StringVar(&issueBody, "body", "", "issue body (default: generated from context)")
issueCmd.Flags().StringVar(&issueAssign, "assign", "", "add an assignee")
issueCmd.Flags().StringSliceVar(&issueLabels, "label", nil, "apply a label (repeatable)")
issueCmd.Flags().BoolVar(&issueDryRun, "dry-run", false, "preview the issue without publishing")
issueCmd.Flags().BoolVar(&issueJSON, "json", false, "output the draft as JSON (requires --dry-run)")
rootCmd.AddCommand(issueCmd)
}

func runIssue(cmd *cobra.Command, args []string) error {
ctx := strings.TrimSpace(strings.Join(args, " "))

title := issueTitle
if title == "" {
title = generateIssueTitle(ctx)
}
body := issueBody
if body == "" {
body = generateIssueBody(ctx)
}

if issueDryRun {
out := struct {
Title string `json:"title"`
Body string `json:"body"`
Assignee string `json:"assignee,omitempty"`
Labels []string `json:"labels,omitempty"`
}{Title: title, Body: body, Assignee: issueAssign, Labels: issueLabels}
if issueJSON {
raw, err := json.MarshalIndent(out, "", " ")
if err != nil {
return fmt.Errorf("issue: marshal json: %w", err)
}
_, _ = cmd.OutOrStdout().Write(raw)
_, _ = fmt.Fprintln(cmd.OutOrStdout())
return nil
}
cmd.Println("Issue preview (dry run — not published)")
cmd.Println("Title: " + title)
cmd.Println()
cmd.Print(body)
if len(issueLabels) > 0 {
cmd.Println()
cmd.Println("Labels: " + strings.Join(issueLabels, ", "))
}
return nil
}

if err := requireGH(); err != nil {
return err
}

ghArgs := []string{"issue", "create", "--title", title, "--body", body}
if issueAssign != "" {
ghArgs = append(ghArgs, "--assignee", issueAssign)
}
for _, l := range issueLabels {
ghArgs = append(ghArgs, "--label", l)
}

cc := exec.CommandContext(context.Background(), "gh", ghArgs...) // #nosec G204 -- fixed command 'gh' with args; title/body are data arguments, not the executable
cc.Stderr = os.Stderr
out, err := cc.Output()
if err != nil {
return fmt.Errorf("gh issue create failed: %w", err)
}
cmd.Println("Issue created: " + strings.TrimSpace(string(out)))
return nil
}

// generateIssueTitle derives a short title from the supplied context.
func generateIssueTitle(ctx string) string {
first := ctx
if i := strings.IndexByte(first, '\n'); i >= 0 {
first = first[:i]
}
first = strings.TrimSpace(first)
if first == "" {
return "Untitled report"
}
for _, c := range []string{"#", "##", "###", ">", "-", "*"} {
first = strings.TrimPrefix(first, c)
}
return strings.TrimSpace(first)
}

// generateIssueBody wraps the context in a fenced block so trace text is
// preserved verbatim.
func generateIssueBody(ctx string) string {
if ctx == "" {
return "No additional context was provided."
}
return "**Reported via hawk**\n\n```\n" + ctx + "\n```\n"
}
34 changes: 34 additions & 0 deletions cmd/issue_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package cmd

import (
"strings"
"testing"
)

func TestGenerateIssueTitle(t *testing.T) {
cases := []struct {
in string
want string
}{
{"", "Untitled report"},
{"panic: nil pointer dereference\n\ngoroutine 1", "panic: nil pointer dereference"},
{"# Crash on startup\n\nDetails here", "Crash on startup"},
{" \n\n\n", "Untitled report"},
{" - flaky test in parser", "flaky test in parser"},
}
for _, c := range cases {
if got := generateIssueTitle(c.in); got != c.want {
t.Errorf("generateIssueTitle(%q) = %q, want %q", c.in, got, c.want)
}
}
}

func TestGenerateIssueBody(t *testing.T) {
if got := generateIssueBody(""); !strings.Contains(got, "No additional context") {
t.Errorf("empty context body = %q, want 'No additional context'", got)
}
body := generateIssueBody("stack\nline 2")
if !strings.Contains(body, "```") || !strings.Contains(body, "stack\nline 2") {
t.Errorf("context body = %q, want fenced original text", body)
}
}
44 changes: 44 additions & 0 deletions cmd/record.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package cmd

import (
"io"
"os"

"github.com/GrayCodeAI/hawk/internal/terminal/tape"
)

// recordPath is set by --record; when non-empty, interactive REPL output is
// captured to an fxtape file.
var recordPath string

// replOut is where interactive REPL output is written. It defaults to
// os.Stdout and is swapped for a tape Recorder while --record is active so the
// live stream is both shown and captured.
var replOut io.Writer = os.Stdout

// startRecording begins capturing interactive REPL output to path as an
// fxtape (fx `--record` parity): stdout bytes are recorded as frames along
// with terminal resize events. It returns a cleanup function that restores the
// default writer and closes the tape.
func startRecording(path string) (func(), error) {
w, h := TermSize()
f, err := os.Create(path)
if err != nil {
return nil, err
}
rec, err := tape.NewRecorder(f, replOut, uint16(w), uint16(h), nil)
if err != nil {
_ = f.Close()
return nil, err
}
prev := replOut
replOut = rec
stopResize := watchTerminalResize(rec)

return func() {
stopResize()
replOut = prev
_ = rec.Close()
_ = f.Close()
}, nil
}
38 changes: 38 additions & 0 deletions cmd/record_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package cmd

import (
"fmt"
"os"
"path/filepath"
"testing"

"github.com/GrayCodeAI/hawk/internal/terminal/tape"
)

func TestStartRecordingCapturesOutput(t *testing.T) {
path := filepath.Join(t.TempDir(), "rec.fxtape")
restore, err := startRecording(path)
if err != nil {
t.Fatalf("startRecording: %v", err)
}
if _, err := fmt.Fprint(replOut, "live bytes"); err != nil {
t.Fatalf("Fprint: %v", err)
}
restore()

if replOut != os.Stdout {
t.Errorf("replOut not restored to os.Stdout after cleanup")
}

data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read tape: %v", err)
}
parsed, err := tape.Parse(data)
if err != nil {
t.Fatalf("parse tape: %v", err)
}
if len(parsed.Frames) != 1 || parsed.Frames[0].Kind != tape.KindStdout || string(parsed.Frames[0].Payload) != "live bytes" {
t.Errorf("frames = %+v, want single stdout frame 'live bytes'", parsed.Frames)
}
}
Loading
Loading