The backend has GET /calendar/agent/status?agent_id=<id> (read an agent's
runtime status, no side effects) but the CLI only exposed `hf agent status
--set` (sets the CALLER's own status). So delegate-task / on-call-handoff
status gates — which need to check whether the RECEIVER/incoming agent is
idle before pinging/handing off — had no CLI path; agents had to guess from
is_active.
Add `hf agent status-of <agent-id>` wrapping the GET endpoint
(X-Claw-Identifier header, no token). Prints {agent_id, status}; surfaces the
backend's 404 for unknown agents so callers can fail-open/closed.
Verified on sim: `hf agent status-of plxrec2` → idle; --json → {"agent_id",
"status"}; unknown agent → 404 "Agent not found".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
208 lines
5.8 KiB
Go
208 lines
5.8 KiB
Go
// 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"
|
|
neturl "net/url"
|
|
"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 <status>`.
|
|
//
|
|
// Supported statuses (mirrors backend `AgentStatus` enum):
|
|
// idle | busy | on_call | exhausted | offline
|
|
//
|
|
// For `exhausted`, an optional `--reason <rate_limit|billing>` and
|
|
// `--recovery-at <ISO-8601>` 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 <idle|busy|on_call|exhausted|offline> [--reason <rate_limit|billing>] [--recovery-at <iso>]")
|
|
}
|
|
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 <status> 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",
|
|
)
|
|
}
|
|
|
|
// RunAgentStatusOf implements `hf agent status-of <agent-id>` — read ANOTHER
|
|
// agent's current runtime status without side effects, wrapping
|
|
// `GET /calendar/agent/status?agent_id=<id>`. Used by the delegate-task and
|
|
// on-call-handoff status gates (which previously referenced this endpoint with
|
|
// no CLI to reach it). Like RunAgentStatus this is header-identified, not
|
|
// token-authed; the X-Claw-Identifier comes from CLAW_IDENTIFIER env or
|
|
// hostname. Prints `{agent_id, status}`; backend returns 404 for unknown
|
|
// agents so callers can fail-open or fail-closed.
|
|
func RunAgentStatusOf(targetAgentID string) {
|
|
targetAgentID = strings.TrimSpace(targetAgentID)
|
|
if targetAgentID == "" {
|
|
output.Error("usage: hf agent status-of <agent-id>")
|
|
}
|
|
|
|
clawID := strings.TrimSpace(os.Getenv("CLAW_IDENTIFIER"))
|
|
if clawID == "" {
|
|
if h, err := os.Hostname(); err == nil {
|
|
clawID = h
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
|
|
url := strings.TrimRight(cfg.BaseURL, "/") + "/calendar/agent/status?agent_id=" + neturl.QueryEscape(targetAgentID)
|
|
req, err := http.NewRequest(http.MethodGet, url, nil)
|
|
if err != nil {
|
|
output.Errorf("cannot build request: %v", err)
|
|
}
|
|
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()
|
|
|
|
buf := new(bytes.Buffer)
|
|
_, _ = buf.ReadFrom(resp.Body)
|
|
if resp.StatusCode/100 != 2 {
|
|
output.Errorf("backend returned %d: %s", resp.StatusCode, buf.String())
|
|
}
|
|
|
|
if output.JSONMode {
|
|
fmt.Println(buf.String())
|
|
return
|
|
}
|
|
var parsed struct {
|
|
AgentID string `json:"agent_id"`
|
|
Status string `json:"status"`
|
|
}
|
|
if err := json.Unmarshal(buf.Bytes(), &parsed); err != nil {
|
|
output.Errorf("cannot parse response: %v", err)
|
|
}
|
|
output.PrintKeyValue(
|
|
"agent_id", parsed.AgentID,
|
|
"status", parsed.Status,
|
|
)
|
|
}
|