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
20 changes: 17 additions & 3 deletions backend/internal/cli/preview.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"net/url"
"os"
"path/filepath"
"strings"

"github.com/spf13/cobra"
Expand All @@ -25,10 +26,10 @@ func newPreviewCommand(ctx *commandContext) *cobra.Command {
Long: "Open a URL in the desktop browser panel for the current session.\n\n" +
"With no argument it opens the workspace's static entry point, falling\n" +
"back to this session's existing preview target when no entry point exists.\n" +
"A local file can be opened by its absolute file:// URL\n" +
"(e.g. file:///home/me/proj/index.html). Use `ao preview clear` to empty the panel.",
"A relative path is resolved against the current directory.\n" +
"Use `ao preview clear` to empty the panel.",
Example: ` ao preview
ao preview file://$(pwd)/index.html
ao preview README.md
ao preview http://localhost:5173
ao preview clear`,
Args: cobra.MaximumNArgs(1),
Expand Down Expand Up @@ -56,6 +57,19 @@ func (c *commandContext) openPreview(ctx context.Context, target string) error {
if err != nil {
return err
}
// Resolve bare relative paths to absolute before sending to the daemon.
// Without this, a relative path like "README.md" would be stored verbatim
// and Electron's MarkdownHost could not resolve it — it would not match
// the file://, daemon-proxy, or HTTP patterns resolveLocalPath understands.
// The daemon already handles absolute paths via absolutePreviewFileURL
// (stats the file, converts to file:// URL).
if target != "" && !filepath.IsAbs(target) {
if abs, err := filepath.Abs(target); err == nil {
if _, err := os.Stat(abs); err == nil {
target = abs
}
}
}
return c.postJSON(ctx, path, previewAPIRequest{Url: target}, nil)
}

Expand Down
81 changes: 75 additions & 6 deletions backend/internal/cli/preview_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
Expand Down Expand Up @@ -91,6 +93,76 @@ func TestPreview_NoArgPostsEmptyURL(t *testing.T) {
}
}

func TestPreview_RelativePathResolvedToAbsolute(t *testing.T) {
t.Setenv("AO_SESSION_ID", "bb-12")
cfg := setConfigEnv(t)
srv, capture := previewServer(t, http.StatusOK, `{"ok":true}`)
writeRunFileFor(t, cfg, srv)

tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "test.md")
if err := os.WriteFile(tmpFile, []byte("# hello"), 0o644); err != nil {
t.Fatal(err)
}

origDir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(origDir) })

// Compute the expected path the same way the function does
// (filepath.Abs) so the comparison isn't sensitive to OS-level
// path differences (macOS /var→/private/var, Windows short names).
want, err := filepath.Abs("test.md")
if err != nil {
t.Fatal(err)
}

_, errOut, err := executeCLI(t, Deps{
ProcessAlive: func(int) bool { return true },
}, "preview", "test.md")
if err != nil {
t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut)
}
var req struct {
Url string `json:"url"`
}
if err := json.Unmarshal([]byte(capture.body), &req); err != nil {
t.Fatalf("decode body: %v\nbody=%s", err, capture.body)
}
if req.Url != want {
t.Errorf("captured url = %q, want %q", req.Url, want)
}
}

func TestPreview_RelativePathNonExistentPassedThrough(t *testing.T) {
t.Setenv("AO_SESSION_ID", "cc-34")
cfg := setConfigEnv(t)
srv, capture := previewServer(t, http.StatusOK, `{"ok":true}`)
writeRunFileFor(t, cfg, srv)

_, errOut, err := executeCLI(t, Deps{
ProcessAlive: func(int) bool { return true },
}, "preview", "nonexistent.md")
if err != nil {
t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut)
}
var req struct {
Url string `json:"url"`
}
if err := json.Unmarshal([]byte(capture.body), &req); err != nil {
t.Fatalf("decode body: %v\nbody=%s", err, capture.body)
}
// Should pass through verbatim when the file doesn't exist.
if req.Url != "nonexistent.md" {
t.Errorf("captured url = %q, want %q", req.Url, "nonexistent.md")
}
}

func TestPreviewClear_DeletesSessionPreview(t *testing.T) {
t.Setenv("AO_SESSION_ID", "aa-47")
cfg := setConfigEnv(t)
Expand Down Expand Up @@ -163,12 +235,9 @@ func TestPreview_HelpIncludesExamples(t *testing.T) {
if !strings.Contains(out, "EXAMPLES") && !strings.Contains(out, "Examples") {
t.Errorf("help output missing Examples section:\n%s", out)
}
// file:// URL example (not a relative path).
if !strings.Contains(out, "file://$(pwd)/index.html") {
t.Errorf("help output missing file:// example:\n%s", out)
}
if strings.Contains(out, "./dist/index.html") {
t.Errorf("help output still references relative ./dist/index.html:\n%s", out)
// Relative path example present.
if !strings.Contains(out, "README.md") {
t.Errorf("help output missing README.md example:\n%s", out)
}
}

Expand Down
2 changes: 2 additions & 0 deletions backend/internal/httpd/apispec/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1708,6 +1708,8 @@ components:
updatedAt:
format: date-time
type: string
workspacePath:
type: string
required:
- id
- projectId
Expand Down
5 changes: 5 additions & 0 deletions backend/internal/httpd/controllers/dto.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,11 @@ type CleanupSessionsQuery struct {
type SessionView struct {
domain.Session
Branch string `json:"branch,omitempty"`
// WorkspacePath is the session's git worktree directory (e.g.
// ~/.ao/worktrees/<projectId>/<sessionId>). The frontend needs this to
// resolve daemon-proxied preview file URLs to local paths for file watching.
// Pulled from the json:"-" domain Metadata.
WorkspacePath string `json:"workspacePath,omitempty"`
// PreviewURL is the browser preview target the desktop app opens for this
// session, set via POST /sessions/{sessionId}/preview. Empty (omitted) when
// no preview has been requested. Pulled from the json:"-" domain Metadata.
Expand Down
23 changes: 16 additions & 7 deletions backend/internal/httpd/controllers/sessions.go
Original file line number Diff line number Diff line change
Expand Up @@ -254,17 +254,26 @@ func (c *SessionsController) setPreview(w http.ResponseWriter, r *http.Request)
envelope.WriteJSON(w, http.StatusOK, SessionResponse{Session: sessionView(updated)})
}

// clearPreview resets a session's browser preview to empty (`ao preview
// clear`). Unlike setPreview with an empty url it never autodetects: it persists
// an empty target so the desktop browser panel returns to its blank state. The
// write still bumps the preview revision, so the panel hears the change over
// CDC even though the url field is now empty.
// clearPreview resets a session's browser preview (`ao preview clear`). It
// first checks for an index.html entry point in the workspace — if found, the
// preview reverts to it. Otherwise the panel clears to its blank initial state.
// The write always bumps the preview revision, so the panel hears the change
// over CDC regardless.
func (c *SessionsController) clearPreview(w http.ResponseWriter, r *http.Request) {
if c.Svc == nil {
apispec.NotImplemented(w, r, "DELETE", "/api/v1/sessions/{sessionId}/preview")
return
}
updated, err := c.Svc.SetPreview(r.Context(), sessionID(r), "")
sess, err := c.Svc.Get(r.Context(), sessionID(r))
if err != nil {
envelope.WriteError(w, r, err)
return
}
var previewURL string
if entry, ok := discoverPreviewEntry(sess.Metadata.WorkspacePath); ok {
previewURL = previewFileURL(r, sessionID(r), entry)
}
updated, err := c.Svc.SetPreview(r.Context(), sessionID(r), previewURL)
if err != nil {
envelope.WriteError(w, r, err)
return
Expand Down Expand Up @@ -664,7 +673,7 @@ func previewFileURL(r *http.Request, id domain.SessionID, entry string) string {
}

func sessionView(s domain.Session) SessionView {
return SessionView{Session: s, Branch: s.Metadata.Branch, PreviewURL: s.Metadata.PreviewURL, PreviewRevision: s.Metadata.PreviewRevision, PRs: sessionPRFacts(s.PRs)}
return SessionView{Session: s, Branch: s.Metadata.Branch, WorkspacePath: s.Metadata.WorkspacePath, PreviewURL: s.Metadata.PreviewURL, PreviewRevision: s.Metadata.PreviewRevision, PRs: sessionPRFacts(s.PRs)}
}

func sessionViews(sessions []domain.Session) []SessionView {
Expand Down
3 changes: 0 additions & 3 deletions backend/internal/httpd/controllers/sessions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -262,9 +262,6 @@ func TestSessionsAPI_ListSpawnGetAndActions(t *testing.T) {
if _, ok := rawList.Sessions[0]["metadata"]; ok {
t.Fatalf("list leaked metadata: %s", body)
}
if _, ok := rawList.Sessions[0]["workspacePath"]; ok {
t.Fatalf("list leaked workspacePath: %s", body)
}
if _, ok := rawList.Sessions[0]["prompt"]; ok {
t.Fatalf("list leaked prompt: %s", body)
}
Expand Down
12 changes: 11 additions & 1 deletion backend/internal/session_manager/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -1051,7 +1051,7 @@ func (m *Manager) buildSystemPrompt(ctx context.Context, kind domain.SessionKind
if base == "" {
return "", nil
}
return base + m.aoSkillPointer() + systemPromptGuard, nil
return base + m.aoSkillPointer() + m.aoMarkdownPreviewPointer() + systemPromptGuard, nil
}

// aoSkillPointer is appended to every agent system prompt. It points the agent
Expand All @@ -1068,6 +1068,16 @@ func (m *Manager) aoSkillPointer() string {
"When you need to use the `ao` CLI, read `" + skillFile + "` first (and the relevant `" + commandsGlob + "`) for the full command catalog, flags, and examples."
}

// aoMarkdownPreviewPointer is appended to every agent system prompt. It
// points the agent at the markdown-preview skill so that agents know they
// can produce `.md` output that renders in the Electron browser panel.
func (m *Manager) aoMarkdownPreviewPointer() string {
dir := skillassets.DirFor(m.dataDir, skillassets.MarkdownPreviewName)
skillFile := filepath.Join(dir, "SKILL.md")
return "\n\n" + "## Markdown preview\n\n" +
"When you need to show the user formatted output (reports, specs, summaries, READMEs), produce a `.md` file and tell the user to open it in the browser panel. Read `" + skillFile + "` for the full guide."
}

func (m *Manager) activeOrchestratorSessionID(ctx context.Context, project domain.ProjectID) (domain.SessionID, bool, error) {
recs, err := m.store.ListSessions(ctx, project)
if err != nil {
Expand Down
40 changes: 40 additions & 0 deletions backend/internal/skillassets/markdown-preview/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
name: markdown-preview
description: Render `.md` files and markdown text as styled HTML in the Electron browser panel, including file-watching for live refresh. Use when you want to show the user a rendered README, report, spec, or any markdown output.
trigger: You produce a `.md` file the user should preview, or want to display markdown content in the browser panel.
---

# Markdown Preview Skill

The AO Electron supervisor can render markdown (`--output.format markdown` / `.md` files) as styled HTML in the browser panel (the inspector rail's **Browser** tab). Rendering is done entirely on the main process with a strict CSP (`script-src 'none'`).

## How it works

- **File-based preview:** Any URL ending in `.md` that the user enters in the address bar (or that you open with `ao preview`) is detected by the renderer, fetched, rendered to HTML via `marked` + `DOMPurify`, and displayed.
- **File watching:** When a local `.md` file is opened from the worktree, the daemon watches it via the OS-native file watcher. Editing and saving the file triggers a live re-render in the browser panel (debounced at 300ms).

## Using from a session

1. **Generate a `.md` file** in the session worktree — for example a README, a bug report, a spec, or a summary.
2. **Auto-preview immediately:** After creating the file, run `ao preview <path-to-file>.md` to push it to the browser panel so the user sees it without extra steps.
3. **Multiple files:** If your task produces several `.md` files in one go (e.g., a report per issue), only auto-preview the **last** one — the panel can only show one at a time, and previous targets are immediately replaced.
4. **Live editing:** When the file is local and the user saves edits, the preview auto-refreshes.
5. **File deletion:** If the previewed file is deleted, the browser panel reverts to `index.html` (the default workspace entry point) or clears to blank if none exists — no error page is shown.

## `ao preview`

When running inside an AO session, `ao preview` opens any URL (including `file://` paths to `.md` files) in the browser panel:

```
ao preview path/to/file.md
ao preview https://example.com
```

The markdown preview is a client-side render — the daemon does not store or serve the rendered HTML. It is cached in the Electron main process per preview session.

## Security

- All rendered pages carry `script-src 'none'` — no JavaScript executes.
- HTML is sanitised through DOMPurify with a strict allowlist.
- Output is served from `app://md-preview/<id>`, an Electron custom protocol with no network exposure.
- The protocol handler only serves documents that the MarkdownHost cached; there is no general file serving.
58 changes: 35 additions & 23 deletions backend/internal/skillassets/skillassets.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
// Package skillassets embeds the using-ao skill (the ao CLI catalog) and
// installs it into the AO data dir at daemon boot. Worker sessions run in a
// worktree of whatever project they were spawned in, so a repo-relative
// skills/ path only resolves when that project happens to be the AO repo
// itself. Installing under the data dir gives every session, in any project, a
// stable absolute path to read.
// Package skillassets embeds skills (the ao CLI catalog, markdown preview,
// etc.) and installs them into the AO data dir at daemon boot. Worker sessions
// run in a worktree of whatever project they were spawned in, so a
// repo-relative skills/ path only resolves when that project happens to be the
// AO repo itself. Installing under the data dir gives every session, in any
// project, a stable absolute path to read.
//
// The embedded copy is the single source of truth. Install clobbers the
// Each embedded copy is the single source of truth. Install clobbers the
// on-disk copy on every boot, so a new daemon build always refreshes it and the
// two can never drift; there is no version marker or hash to keep in sync
// because the daemon binary already is the version.
Expand All @@ -20,36 +20,48 @@ import (
"embed"
)

//go:embed using-ao
//go:embed using-ao markdown-preview
var files embed.FS

// SkillName is the installed skill's directory name under <dataDir>/skills.
const SkillName = "using-ao"
// Skill directory names under <dataDir>/skills.
const (
UsingAoName = "using-ao"
MarkdownPreviewName = "markdown-preview"
)

// Dir returns the absolute directory the skill installs into for a given data
// dir. Callers building prompts use this so the path they cite always matches
// where Install writes.
// Dir returns the absolute directory for the using-ao skill. It is a
// convenience wrapper around DirFor.
func Dir(dataDir string) string {
return filepath.Join(dataDir, "skills", SkillName)
return DirFor(dataDir, UsingAoName)
}

// DirFor returns the absolute directory a named skill installs into for a
// given data dir. Callers building prompts use this so the path they cite
// always matches where Install writes.
func DirFor(dataDir, skillName string) string {
return filepath.Join(dataDir, "skills", skillName)
}

// Install writes the embedded using-ao skill into <dataDir>/skills/using-ao,
// Install writes every embedded skill into <dataDir>/skills/<skill-name>,
// replacing any existing copy. It runs once at daemon boot, before any session
// spawns, so a plain clobber-and-write needs no locking: there are no
// concurrent readers yet. A failure is returned but is non-fatal to boot (the
// skill enhances `ao --help`, it is not load-bearing).
// skills enhance `ao --help` and session output; they are not load-bearing).
func Install(dataDir string) error {
dest := Dir(dataDir)
if err := os.RemoveAll(dest); err != nil {
return fmt.Errorf("clear skill dir %q: %w", dest, err)
skillsDir := filepath.Join(dataDir, "skills")
if err := os.RemoveAll(skillsDir); err != nil {
return fmt.Errorf("clear skills dir %q: %w", skillsDir, err)
}
// embed.FS always uses forward-slash paths rooted at "using-ao"; map each
// onto <dataDir>/skills/<same path> with the platform separator.
return fs.WalkDir(files, SkillName, func(p string, d fs.DirEntry, err error) error {
// embed.FS always uses forward-slash paths; map each entry onto
// <dataDir>/skills/<same path> with the platform separator.
return fs.WalkDir(files, ".", func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
target := filepath.Join(dataDir, "skills", filepath.FromSlash(p))
if p == "." {
return nil // skip root
}
target := filepath.Join(skillsDir, filepath.FromSlash(p))
if d.IsDir() {
return os.MkdirAll(target, 0o750)
}
Expand Down
Loading
Loading