Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
a4ddbd6
feat(desktop): Electron desktop app skeleton (read-only v1)
AbirAbbas Jul 10, 2026
ab49032
feat(release): add windows/amd64 build target to goreleaser
AbirAbbas Jul 10, 2026
0300bdb
fix(control-plane): build-tagged process stop/liveness for windows
AbirAbbas Jul 10, 2026
df6121d
fix(control-plane): platform-aware log tailing for af logs
AbirAbbas Jul 10, 2026
bbe63dc
fix(control-plane): .exe-aware Go node binary naming on windows
AbirAbbas Jul 10, 2026
ba52ae2
test(control-plane): cover windows-only branches via goos-parameteriz…
AbirAbbas Jul 10, 2026
b04f0b6
fix(control-plane): remove agentfield.yaml symlink that breaks startu…
AbirAbbas Jul 13, 2026
0ce7a00
fix(control-plane): default local storage paths whenever mode is local
AbirAbbas Jul 13, 2026
6e0b479
fix(control-plane): probe python candidates for a working interpreter
AbirAbbas Jul 13, 2026
fe972e2
fix(control-plane): force PYTHONUTF8=1 for spawned python agent nodes
AbirAbbas Jul 13, 2026
6bc5fc4
test(control-plane): make the services package compile under GOOS=win…
AbirAbbas Jul 13, 2026
097d511
fix(desktop): don't report an unrelated service on 8080 as a running …
AbirAbbas Jul 13, 2026
08a4072
feat(desktop): executions, dashboard metrics, and a curated install flow
AbirAbbas Jul 13, 2026
e09ee7a
feat(desktop): mac-first chrome with sidebar navigation and dashboard
AbirAbbas Jul 13, 2026
cb3296b
build(desktop): package as an installable app via electron-builder
AbirAbbas Jul 13, 2026
bbd2b37
docs(desktop): update README for sidebar views, install flow, packaging
AbirAbbas Jul 13, 2026
17d88c8
feat(desktop): brand icon set rendered from the .af mark
AbirAbbas Jul 13, 2026
c1699b7
feat(desktop): windows tray icon and agentfield:// deep links
AbirAbbas Jul 13, 2026
87e8c1f
feat(af-tray): open the desktop app via deep link, plus a real icns
AbirAbbas Jul 13, 2026
9e44291
docs(desktop): tray, deep links, and icon pipeline in the README
AbirAbbas Jul 13, 2026
00432cf
fix(desktop): only re-render the tray when its state changes
AbirAbbas Jul 13, 2026
c5c378e
feat(desktop): catalog every installable node in the Agent-Field org
AbirAbbas Jul 13, 2026
ecb8d2c
feat(desktop): agent lifecycle controls and autopilot settings
AbirAbbas Jul 13, 2026
5a579f4
docs(desktop): lifecycle controls, settings, and autostart in the README
AbirAbbas Jul 13, 2026
42b3c6f
feat(desktop): bundle the af CLI with smart resolution and updates
AbirAbbas Jul 13, 2026
8b5b303
ci: desktop app on macOS+Windows runners, compile the darwin tray per PR
AbirAbbas Jul 13, 2026
e045113
docs(desktop): bundled CLI, resolution order, and update flow in README
AbirAbbas Jul 13, 2026
0c7decf
feat(skillkit): agentfield-use skill — teach coding agents to call in…
AbirAbbas Jul 13, 2026
00920ad
feat(desktop): agent keys — manifest-driven secrets entry and revoke,…
AbirAbbas Jul 13, 2026
f94f078
fix(run): port probe blind to live listeners on Windows; readiness ch…
AbirAbbas Jul 13, 2026
026d4a8
fix(sdk/python): harness CLI providers work on Windows — PATHEXT reso…
AbirAbbas Jul 14, 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
27 changes: 25 additions & 2 deletions .github/workflows/control-plane.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

70 changes: 70 additions & 0 deletions .github/workflows/desktop.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

54 changes: 30 additions & 24 deletions .goreleaser.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion control-plane/agentfield.yaml

This file was deleted.

Binary file modified control-plane/cmd/af-tray/assets/appicon.icns
Binary file not shown.
30 changes: 30 additions & 0 deletions control-plane/cmd/af-tray/shared.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,36 @@ func uiPageURL(page string) string {
return fmt.Sprintf("http://localhost:%d/ui/%s", serverPort(), page)
}

// ---- Desktop app deep links --------------------------------------------------
//
// The AgentField desktop app (desktop/ in this repo) registers the
// agentfield:// URL scheme. When it is installed, the tray opens views there;
// when it is not, `open agentfield://…` fails fast (no handler registered)
// and the tray falls back to the web UI in the browser (see openPage in
// tray_darwin.go).

// deepLinkForPage maps a web-UI page name to the desktop-app view showing the
// same thing. Unknown/empty pages land on the app's dashboard.
func deepLinkForPage(page string) string {
switch page {
case "agents":
return "agentfield://agents"
case "executions":
return "agentfield://activity"
default:
return "agentfield://dashboard"
}
}

// browserURLForPage is the web-UI fallback for the same page: the server root
// for the default page, /ui/<page> otherwise.
func browserURLForPage(page string) string {
if page == "" {
return dashboardURL()
}
return uiPageURL(page)
}

// checkHealth reports whether the given URL answers HTTP 200 within a short
// timeout. The control plane's /health endpoint returns 200 when healthy and
// 503 when not, so only a 200 counts as "running".
Expand Down
30 changes: 30 additions & 0 deletions control-plane/cmd/af-tray/shared_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,36 @@ func TestURLsUsePort(t *testing.T) {
}
}

// Contract: every page the tray can open has a desktop-app deep link, and
// unknown/empty pages land on the app's dashboard instead of failing. The
// scheme must stay "agentfield" — it is what the desktop app registers.
func TestDeepLinkForPage(t *testing.T) {
cases := map[string]string{
"": "agentfield://dashboard",
"dashboard": "agentfield://dashboard",
"agents": "agentfield://agents",
"executions": "agentfield://activity",
"whatever": "agentfield://dashboard",
}
for page, want := range cases {
if got := deepLinkForPage(page); got != want {
t.Errorf("deepLinkForPage(%q) = %q, want %q", page, got, want)
}
}
}

// Contract: the browser fallback opens the server root for the default page
// and the embedded web UI for named pages, on the resolved port.
func TestBrowserURLForPage(t *testing.T) {
t.Setenv("AGENTFIELD_PORT", "1234")
if got, want := browserURLForPage(""), "http://localhost:1234"; got != want {
t.Errorf("browserURLForPage(\"\") = %q, want %q", got, want)
}
if got, want := browserURLForPage("agents"), "http://localhost:1234/ui/agents"; got != want {
t.Errorf("browserURLForPage(\"agents\") = %q, want %q", got, want)
}
}

// Contract: a server is "running" only when /health answers HTTP 200; a 503
// (unhealthy) or an unreachable server both read as not running.
func TestCheckHealth(t *testing.T) {
Expand Down
28 changes: 16 additions & 12 deletions control-plane/cmd/af-tray/tray_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -249,15 +249,15 @@ func onReady() {
case <-mOpen.ClickedCh:
openDashboard()
case <-mMore.ClickedCh:
openURL(uiPageURL("agents"))
openPage("agents")
case <-mAgentsOpen.ClickedCh:
openURL(uiPageURL("agents"))
openPage("agents")
case <-mSuccess.ClickedCh:
openURL(uiPageURL("executions"))
openPage("executions")
case <-mResponse.ClickedCh:
openURL(uiPageURL("executions"))
openPage("executions")
case <-mMemory.ClickedCh:
openURL(uiPageURL("dashboard"))
openPage("dashboard")
case <-mEnterKey.ClickedCh:
handleEnterAPIKey()
refresh()
Expand Down Expand Up @@ -339,14 +339,18 @@ func notify(title, body string) {
_ = exec.Command("osascript", "-e", script).Start()
}

func openDashboard() {
_ = exec.Command("open", dashboardURL()).Start()
}
func openDashboard() { openPage("") }

// openURL opens an arbitrary URL in the default browser (used for dashboard
// deep-links from the metric rows).
func openURL(u string) {
_ = exec.Command("open", u).Start()
// openPage opens a view, preferring the AgentField desktop app over the
// browser: `open agentfield://…` succeeds only when something has registered
// the scheme (the desktop app does on install) and fails fast when nothing
// has — in which case the same page opens as web UI in the default browser.
// Run (not Start) on the deep link because the fallback needs its exit code.
func openPage(page string) {
if exec.Command("open", deepLinkForPage(page)).Run() == nil {
return
}
_ = exec.Command("open", browserURLForPage(page)).Start()
}

func openLogs() {
Expand Down
8 changes: 8 additions & 0 deletions control-plane/cmd/af/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,14 @@ func loadConfig(configFile string) (*config.Config, error) {
// Set default storage mode to local if not specified
if cfg.Storage.Mode == "" {
cfg.Storage.Mode = "local"
}
// Default the local storage paths whenever they are unset — not only when
// the mode was defaulted. A config file may set mode: "local" while leaving
// the paths empty (the sample config/agentfield.yaml does exactly that,
// and it is picked up automatically when af runs next to it), which
// previously skipped this block and failed startup with "database path is
// empty". Mirrors cmd/agentfield-server.
if cfg.Storage.Mode == "local" {
// Use the universal path management system
if cfg.Storage.Local.DatabasePath == "" {
dbPath, err := utils.GetDatabasePath()
Expand Down
40 changes: 37 additions & 3 deletions control-plane/internal/cli/logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import (
"errors"
"fmt"
"os"
"os/exec" // Added missing import
"os/exec"
"path/filepath"
"runtime"
"strings"

"github.com/Agent-Field/agentfield/control-plane/internal/logger"
"github.com/Agent-Field/agentfield/control-plane/internal/packages"
Expand Down Expand Up @@ -104,16 +106,48 @@ func (lv *LogViewer) ViewLogs(agentNodeName string) error {

// tailLogs shows the last N lines of the log file
func (lv *LogViewer) tailLogs(logFile string, lines int) error {
cmd := exec.Command("tail", "-n", fmt.Sprintf("%d", lines), logFile)
cmd := tailCommand(logFile, lines, false)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}

// followLogs follows the log file in real-time
func (lv *LogViewer) followLogs(logFile string) error {
cmd := exec.Command("tail", "-f", logFile)
cmd := tailCommand(logFile, 10, true)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}

// tailCommand builds the platform command that prints the last n lines of a
// file, optionally following it.
func tailCommand(logFile string, n int, follow bool) *exec.Cmd {
program, args := tailCommandArgs(runtime.GOOS, logFile, n, follow)
return exec.Command(program, args...)
}

// tailCommandArgs returns the program and arguments that tail a log file on
// the given GOOS. Unix uses tail(1); Windows has no tail, so PowerShell's
// Get-Content stands in (compile-verified only, not yet tested on a real
// Windows machine). Pure so both platform branches are unit-testable anywhere.
func tailCommandArgs(goos, logFile string, n int, follow bool) (string, []string) {
if goos == "windows" {
script := fmt.Sprintf("Get-Content -LiteralPath %s -Tail %d", psSingleQuote(logFile), n)
if follow {
script += " -Wait"
}
return "powershell", []string{"-NoProfile", "-Command", script}
}
args := []string{"-n", fmt.Sprintf("%d", n)}
if follow {
args = append(args, "-f")
}
return "tail", append(args, logFile)
}

// psSingleQuote quotes s as a PowerShell single-quoted string literal, where
// the only escape is doubling embedded single quotes.
func psSingleQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
}
Loading
Loading