From 6ace6f259484c2c0cdc2ecc1b89883a989769662 Mon Sep 17 00:00:00 2001 From: hanghang zhang Date: Fri, 22 May 2026 19:08:27 +0100 Subject: [PATCH] feat(cli): add 'hf agent status' wrapper for POST /calendar/agent/status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plan-schedule workflow needs to report agent runtime status (idle/busy/on_call/exhausted/offline) at the end of planning, but the cli had no wrapper for this — workflows were dropping inline curl in the middle of their procedure to hit the backend. This adds 'hf agent status --set [--reason ...] [--recovery-at ...]'. The endpoint identifies the agent purely from X-Agent-ID + X-Claw-Identifier headers (no token), so the cli reads AGENT_ID from env and falls back to hostname() for CLAW_IDENTIFIER if it isn't set — same convention the openclaw plugin uses. Refuses to send if AGENT_ID env is missing, since this only makes sense from a pcexec/agent runtime context. Surface entry added so 'hf --help' lists it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/hf/main.go | 13 ++++ internal/commands/agent.go | 140 +++++++++++++++++++++++++++++++++++++ internal/help/surface.go | 7 ++ 3 files changed, 160 insertions(+) create mode 100644 internal/commands/agent.go diff --git a/cmd/hf/main.go b/cmd/hf/main.go index 08577f3..64b807c 100644 --- a/cmd/hf/main.go +++ b/cmd/hf/main.go @@ -54,6 +54,19 @@ func main() { discordID = filtered[1] } commands.RunUserUpdateDiscordID(filtered[0], discordID, tokenFlag) + case "agent": + // `hf agent ` — currently only `status` is implemented (wraps + // `POST /calendar/agent/status`, identifies caller via + // AGENT_ID/CLAW_IDENTIFIER env, no token needed). + if len(args) < 2 { + output.Error("usage: hf agent status --set ") + } + switch args[1] { + case "status": + commands.RunAgentStatus(args[2:]) + default: + output.Errorf("unknown agent subcommand: %s", args[1]) + } default: if group, ok := findGroup(args[0]); ok { handleGroup(group, args[1:]) diff --git a/internal/commands/agent.go b/internal/commands/agent.go new file mode 100644 index 0000000..e091217 --- /dev/null +++ b/internal/commands/agent.go @@ -0,0 +1,140 @@ +// Package commands — agent runtime-status command (`hf agent status`). +// +// Wraps the plugin-facing `POST /calendar/agent/status` endpoint so agents +// driven from `pcexec` (which sets AGENT_ID/CLAW_IDENTIFIER env) can report +// their status from a workflow without writing curl in the middle of a +// `flow.md` procedure. +// +// The endpoint itself is unauthenticated at the HTTP layer — it identifies +// the agent purely from X-Agent-ID + X-Claw-Identifier headers — so this +// command does NOT call `ResolveToken`. Calling it from outside a pcexec +// session will fail because AGENT_ID/CLAW_IDENTIFIER won't be set. +package commands + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + "time" + + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/config" + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/output" +) + +// RunAgentStatus implements `hf agent status --set `. +// +// Supported statuses (mirrors backend `AgentStatus` enum): +// idle | busy | on_call | exhausted | offline +// +// For `exhausted`, an optional `--reason ` and +// `--recovery-at ` can be provided. +func RunAgentStatus(args []string) { + target := "" + reason := "" + recoveryAt := "" + + for i := 0; i < len(args); i++ { + switch args[i] { + case "--set": + if i+1 >= len(args) { + output.Error("usage: hf agent status --set [--reason ] [--recovery-at ]") + } + target = args[i+1] + i++ + case "--reason": + if i+1 >= len(args) { + output.Error("--reason requires a value") + } + reason = args[i+1] + i++ + case "--recovery-at": + if i+1 >= len(args) { + output.Error("--recovery-at requires an ISO-8601 timestamp") + } + recoveryAt = args[i+1] + i++ + default: + output.Errorf("unknown flag: %s", args[i]) + } + } + + if target == "" { + output.Error("--set is required") + } + + agentID := strings.TrimSpace(os.Getenv("AGENT_ID")) + clawID := strings.TrimSpace(os.Getenv("CLAW_IDENTIFIER")) + if clawID == "" { + // Match the plugin convention: hostname fallback when CLAW_IDENTIFIER + // is unset. Most pcexec callers won't have it set in env. + if h, err := os.Hostname(); err == nil { + clawID = h + } + } + if agentID == "" { + output.Error("AGENT_ID env is not set — run via pcexec or export AGENT_ID first") + } + if clawID == "" { + output.Error("CLAW_IDENTIFIER env not set and hostname() failed — set CLAW_IDENTIFIER explicitly") + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + + body := map[string]interface{}{ + "agent_id": agentID, + "claw_identifier": clawID, + "status": target, + } + if reason != "" { + body["exhaust_reason"] = reason + } + if recoveryAt != "" { + body["recovery_at"] = recoveryAt + } + + payload, err := json.Marshal(body) + if err != nil { + output.Errorf("cannot marshal payload: %v", err) + } + + url := strings.TrimRight(cfg.BaseURL, "/") + "/calendar/agent/status" + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(payload)) + if err != nil { + output.Errorf("cannot build request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Agent-ID", agentID) + req.Header.Set("X-Claw-Identifier", clawID) + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + output.Errorf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode/100 != 2 { + buf := new(bytes.Buffer) + _, _ = buf.ReadFrom(resp.Body) + output.Errorf("backend returned %d: %s", resp.StatusCode, buf.String()) + } + + if output.JSONMode { + buf := new(bytes.Buffer) + _, _ = buf.ReadFrom(resp.Body) + fmt.Println(buf.String()) + return + } + output.PrintKeyValue( + "agent_id", agentID, + "claw_identifier", clawID, + "status", target, + "ok", "true", + ) +} diff --git a/internal/help/surface.go b/internal/help/surface.go index b0bde2e..5e88b0e 100644 --- a/internal/help/surface.go +++ b/internal/help/surface.go @@ -29,6 +29,13 @@ func CommandSurface() []Group { {Name: "version", Description: "Show CLI version", Permitted: true}, {Name: "health", Description: "Check API health", Permitted: true}, {Name: "config", Description: "View and manage CLI configuration", Permitted: true}, + { + Name: "agent", + Description: "Runtime status reporting for the calling agent (uses AGENT_ID/CLAW_IDENTIFIER env)", + SubCommands: []Command{ + {Name: "status", Description: "Report runtime status: hf agent status --set ", Permitted: true}, + }, + }, { Name: "user", Description: "Manage users", -- 2.49.1