Files
oikos/cmd/nomos/main.go
dtoro 991e7d0900 feat(tasks): phase 6 — live TaskContextPanel (goal, plan, question, entities)
Replaces the chat right rail's ad-hoc Digest+Graph stack with a single
TaskContextPanel that renders the task's live working state, driven by the
always-on events stream (not the per-turn chat SSE) so it keeps updating
during server-side auto-continuation/resume:

- GoalHeader: goal + status pill (planning/executing/awaiting_input/done/
  failed), sourced from the sessions list.
- PlanProgress: ordered steps with live status icons + progress bar, hydrated
  via new GET /sessions/{id}/plan; clicking a step with a target opens its
  EntitySheet (no fake "jump to transcript" — bits-ui Collapsible content
  isn't force-mounted, so a DOM-scroll jump would silently no-op for
  collapsed tool groups).
- OperatorQuestion: the pinned structured question card (prompt/why/entity
  chips/option buttons/free-text), hydrated via new GET /sessions/{id}/
  questions; answering POSTs to the existing answer endpoint.
- SessionGraph upgraded to a live entity panel: entity.touched pulses the
  node (animated ring) and shows "Now touching <slug>"; health.changed shows
  a transient diff badge for touched entities.
- SessionDigest gains a success/failure/partial outcome banner and now also
  refetches when the task's status changes, not just on session switch.

Two bugs found and fixed while wiring this up:
- workspace.ts's status-refresh trigger only covered goal.set/task.status;
  question.raised/answered didn't refresh the sessions list, so GoalHeader's
  pill went stale after answering via the panel (resumeSession runs entirely
  server-side — no client 'done' event to piggyback a refresh on). Now every
  status-affecting event triggers the (debounced) refetch.
- Forgot to rebuild the nomos container after adding the /plan and
  /questions endpoints, so they silently fell through to the old default GET
  handler — caught via a live curl diff against the running container,
  not a code read.

Verified end-to-end against the live stack: goal/plan/question all update
without a reload as the agent works; answering a question via the panel
resumes the agent and the header pill correctly flips to Executing;
entity.touched pulses the live graph.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:52:36 +02:00

619 lines
17 KiB
Go

package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"strings"
"sync"
"syscall"
"time"
)
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: nomos serve")
os.Exit(1)
}
mcpURL := os.Getenv("NOMOS_MCP_URL")
if mcpURL == "" {
mcpURL = "http://localhost:8090/mcp"
}
agentSlug := os.Getenv("NOMOS_AGENT_SLUG")
if agentSlug == "" {
agentSlug = "agent:nomos"
}
databaseURL := os.Getenv("DATABASE_URL")
if databaseURL == "" {
databaseURL = os.Getenv("OIKOS_DATABASE_URL")
}
switch os.Args[1] {
case "serve":
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer cancel()
client, err := newMCPClient(mcpURL)
if err != nil {
slog.Error("nomos: mcp connect", "url", mcpURL, "error", err)
os.Exit(1)
}
st, err := newStore(ctx, databaseURL)
if err != nil {
slog.Error("nomos: db connect", "error", err)
os.Exit(1)
}
if st != nil {
defer st.close()
}
nAgent, err := newAgent(ctx, client, st, agentSlug)
if err != nil {
slog.Error("nomos: agent init", "error", err)
os.Exit(1)
}
// Event-driven auto-continuation: feed finished async executions back
// into the agent so an approved plan runs to completion (and recovers
// from failures) without the operator ticking it forward each step.
go nAgent.runContinuationWorker(ctx)
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte("ok"))
})
mux.HandleFunc("/query", func(w http.ResponseWriter, r *http.Request) {
handleQuery(w, r, client, agentSlug, mcpURL)
})
mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) {
handleChat(w, r, nAgent, st)
})
mux.HandleFunc("/sessions", func(w http.ResponseWriter, r *http.Request) {
handleSessionsList(w, r, st)
})
mux.HandleFunc("/sessions/", func(w http.ResponseWriter, r *http.Request) {
handleSessionDetail(w, r, st, nAgent)
})
addr := os.Getenv("NOMOS_LISTEN")
if addr == "" {
addr = ":8092"
}
srv := &http.Server{Addr: addr, Handler: mux}
go func() {
slog.Info("nomos: gateway listening", "addr", addr, "mcp", mcpURL, "db", databaseURL != "")
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
slog.Error("nomos: serve", "error", err)
}
}()
<-ctx.Done()
slog.Info("nomos: shutting down")
srv.Shutdown(context.Background())
client.close()
default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])
os.Exit(1)
}
}
func sseEvent(w http.ResponseWriter, flusher http.Flusher, event agentEvent) {
data, _ := json.Marshal(event)
fmt.Fprintf(w, "data: %s\n\n", data)
flusher.Flush()
}
func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", 405)
return
}
var req struct {
SessionID string `json:"session_id"`
Message string `json:"message"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad request: "+err.Error(), 400)
return
}
if req.Message == "" {
http.Error(w, "message is required", 400)
return
}
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming not supported", 500)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.WriteHeader(200)
ctx := r.Context()
sessionID := req.SessionID
if sessionID == "" {
title := truncate(req.Message, 80)
sess, err := st.createSession(ctx, title)
if err != nil {
slog.Error("nomos: create session", "error", err)
sessionID = "ephemeral"
} else {
sessionID = sess.ID
}
} else {
st.touchSession(ctx, sessionID)
}
slog.Info("nomos: chat", "session", sessionID, "message", truncate(req.Message, 100))
userMsg, _ := json.Marshal(map[string]any{"role": "user", "text": req.Message})
st.saveMessage(ctx, sessionID, "user", userMsg)
// If this task has a pending operator question, the incoming message IS the
// answer — close it so the panel clears. No separate resume needed: this
// chat turn is the resume, and the agent sees the question + answer in its
// replayed history.
if qid := st.openQuestionID(ctx, sessionID); qid != "" {
st.answerQuestion(ctx, sessionID, qid, req.Message)
}
sseEvent(w, flusher, agentEvent{Type: "session", Data: sessionID, SessionID: sessionID})
toolCalls := []map[string]any{}
var finalText string
a.chat(ctx, sessionID, req.Message, func(ev agentEvent) {
if ev.Type == "tool_use" || ev.Type == "tool_result" {
if m, ok := ev.Data.(map[string]any); ok {
m["type"] = ev.Type
toolCalls = append(toolCalls, m)
}
}
if ev.Type == "text" {
finalText, _ = ev.Data.(string)
}
sseEvent(w, flusher, ev)
})
assistantMsg, _ := json.Marshal(map[string]any{
"role": "assistant",
"text": finalText,
"tool_calls": toolCalls,
})
st.saveMessage(ctx, sessionID, "assistant", assistantMsg)
// Generate a meaningful title from the assistant's first answer
// instead of reusing the raw user message for every session.
if finalText != "" && sessionID != "ephemeral" {
title := truncate(finalText, 80)
if title != "" {
st.updateSessionTitle(ctx, sessionID, title)
}
}
}
func handleSessionsList(w http.ResponseWriter, r *http.Request, st *store) {
if st == nil {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"sessions": []any{}})
return
}
if r.Method == http.MethodOptions {
return
}
sessions, err := st.listSessions(r.Context())
if err != nil {
http.Error(w, err.Error(), 500)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"sessions": sessions})
}
func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store, a *agent) {
if st == nil {
http.Error(w, "not found", 404)
return
}
rest := strings.TrimPrefix(r.URL.Path, "/sessions/")
parts := strings.Split(rest, "/")
id := parts[0]
if id == "" {
http.Error(w, "session id required", 400)
return
}
// POST /sessions/{id}/questions/{qid}/answer — the operator answers a
// pinned question from the context panel; resume the agent with the answer.
if len(parts) == 4 && parts[1] == "questions" && parts[3] == "answer" {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", 405)
return
}
handleAnswerQuestion(w, r, st, a, id, parts[2])
return
}
// GET /sessions/{id}/plan and /sessions/{id}/questions — REST hydration for
// the context panel when it first opens a task; live events carry deltas
// from there.
if len(parts) == 2 && r.Method == http.MethodGet {
switch parts[1] {
case "plan":
steps, err := st.getPlanSteps(r.Context(), id)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"steps": steps})
return
case "questions":
questions, err := st.getQuestions(r.Context(), id)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"questions": questions})
return
}
}
switch r.Method {
case http.MethodDelete:
if err := st.deleteSession(r.Context(), id); err != nil {
http.Error(w, err.Error(), 500)
return
}
w.WriteHeader(204)
case http.MethodGet:
messages, err := st.getMessages(r.Context(), id)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"session_id": id, "messages": messages})
default:
http.Error(w, "method not allowed", 405)
}
}
// handleAnswerQuestion records the operator's answer to a pinned question and
// resumes the agent in the background with that answer injected. Returns 202 —
// the agent's response lands via the normal message-polling path, not this POST.
func handleAnswerQuestion(w http.ResponseWriter, r *http.Request, st *store, a *agent, sessionID, questionID string) {
var req struct {
Answer string `json:"answer"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Answer) == "" {
http.Error(w, "answer is required", 400)
return
}
prompt, _, _ := st.getQuestion(r.Context(), questionID)
if err := st.answerQuestion(r.Context(), sessionID, questionID, req.Answer); err != nil {
http.Error(w, err.Error(), 500)
return
}
if a != nil {
note := fmt.Sprintf("[System: the operator answered your question %q with: %q. "+
"Continue the task from here — do not re-ask.]", prompt, req.Answer)
go a.resumeSession(context.Background(), sessionID, note)
}
w.WriteHeader(202)
}
func handleQuery(w http.ResponseWriter, r *http.Request, client *mcpClient, agentSlug, mcpURL string) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", 405)
return
}
var req struct {
Query string `json:"query"`
Tool string `json:"tool"`
Args map[string]any `json:"args"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad request: "+err.Error(), 400)
return
}
start := time.Now()
if req.Tool != "" {
result, err := client.callTool(req.Tool, req.Args)
duration := time.Since(start).Milliseconds()
if err != nil {
slog.Error("nomos: query failed", "tool", req.Tool, "error", err)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"error": err.Error(),
"elapsed_ms": duration,
"agent_slug": agentSlug,
})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"result": result,
"elapsed_ms": duration,
"agent_slug": agentSlug,
"mcp_url": mcpURL,
})
return
}
if req.Query != "" {
if strings.Contains(strings.ToLower(req.Query), "what can you do") ||
strings.Contains(strings.ToLower(req.Query), "help") {
tools, err := client.listTools()
duration := time.Since(start).Milliseconds()
if err != nil {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"error": err.Error(),
"elapsed_ms": duration,
})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"message": "natural language queries belong to /chat. Use structured /query with 'tool' for direct MCP calls.",
"tools": tools,
"elapsed_ms": duration,
})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"message": "natural language queries belong to /chat. Use structured /query with 'tool' for direct MCP calls.",
"elapsed_ms": time.Since(start).Milliseconds(),
})
return
}
http.Error(w, "either 'tool' or 'query' required", 400)
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
// ─── MCP Streamable HTTP client ────────────────────────────────────────
type mcpClient struct {
baseURL string
sessionID string
http *http.Client
nextID int
mu sync.Mutex // MCP is one stateful session; serialize concurrent calls
}
func newMCPClient(baseURL string) (*mcpClient, error) {
c := &mcpClient{
baseURL: baseURL,
http: &http.Client{Timeout: 30 * time.Second},
}
resp, err := c.doRequest("initialize", map[string]any{
"protocolVersion": "2024-11-05",
"capabilities": map[string]any{},
"clientInfo": map[string]any{"name": "nomos", "version": "2.0"},
})
if err != nil {
return nil, fmt.Errorf("initialize: %w", err)
}
if resp.sessionID == "" {
return nil, fmt.Errorf("no session ID in initialize response")
}
c.sessionID = resp.sessionID
c.doRequest("notifications/initialized", map[string]any{})
slog.Info("nomos: mcp connected", "session", c.sessionID[:16]+"...")
return c, nil
}
type mcpJSONRPCResponse struct {
sessionID string
Result json.RawMessage `json:"result"`
Error json.RawMessage `json:"error"`
}
// errStaleSession signals that the MCP server rejected our session id (e.g.
// after an api/MCP restart), so the client should re-initialize and retry.
var errStaleSession = fmt.Errorf("mcp session stale")
// doRequest serializes MCP calls and transparently re-initializes the session
// if the server has forgotten it (common after an api redeploy), retrying the
// original call once. Without this, an api restart permanently breaks nomos
// until it is itself restarted.
func (c *mcpClient) doRequest(method string, params map[string]any) (*mcpJSONRPCResponse, error) {
c.mu.Lock()
defer c.mu.Unlock()
resp, err := c.send(method, params)
if err != nil && method != "initialize" && errors.Is(err, errStaleSession) {
slog.Warn("nomos: mcp session stale, reconnecting")
if rerr := c.reconnectLocked(); rerr != nil {
return nil, fmt.Errorf("mcp reconnect: %w (original: %v)", rerr, err)
}
return c.send(method, params)
}
return resp, err
}
// reconnectLocked re-initializes the MCP session. The caller must hold c.mu.
func (c *mcpClient) reconnectLocked() error {
c.sessionID = ""
resp, err := c.send("initialize", map[string]any{
"protocolVersion": "2024-11-05",
"capabilities": map[string]any{},
"clientInfo": map[string]any{"name": "nomos", "version": "2.0"},
})
if err != nil {
return err
}
if resp.sessionID == "" {
return fmt.Errorf("no session ID on re-initialize")
}
c.sessionID = resp.sessionID
_, _ = c.send("notifications/initialized", map[string]any{})
slog.Info("nomos: mcp reconnected", "session", c.sessionID[:16]+"...")
return nil
}
// send performs one MCP round-trip. It does not lock; callers hold c.mu.
func (c *mcpClient) send(method string, params map[string]any) (*mcpJSONRPCResponse, error) {
c.nextID++
body, _ := json.Marshal(map[string]any{
"jsonrpc": "2.0",
"method": method,
"params": params,
"id": c.nextID,
})
req, err := http.NewRequest(http.MethodPost, c.baseURL, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")
if c.sessionID != "" {
req.Header.Set("Mcp-Session-Id", c.sessionID)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
// A rejected/unknown session comes back as 4xx (commonly 400/404).
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusBadRequest {
return nil, errStaleSession
}
result := &mcpJSONRPCResponse{}
result.sessionID = resp.Header.Get("Mcp-Session-Id")
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
gotData := false
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "data: ") {
gotData = true
data := line[6:]
if err := json.Unmarshal([]byte(data), result); err != nil {
return nil, fmt.Errorf("parse response: %w", err)
}
}
}
if result.Error != nil {
return nil, fmt.Errorf("rpc error: %s", string(result.Error))
}
// Empty body with no result and a session set: the server likely dropped
// our session. Notifications legitimately return no data, so exempt them.
if !gotData && result.Result == nil && method != "notifications/initialized" {
return nil, errStaleSession
}
if result.sessionID != "" {
c.sessionID = result.sessionID
}
return result, nil
}
func (c *mcpClient) callTool(name string, args map[string]any) (any, error) {
resp, err := c.doRequest("tools/call", map[string]any{
"name": name,
"arguments": args,
})
if err != nil {
return nil, err
}
var toolResult struct {
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
}
if err := json.Unmarshal(resp.Result, &toolResult); err != nil {
return string(resp.Result), nil
}
var texts []string
for _, c := range toolResult.Content {
if c.Type == "text" {
var parsed any
if json.Unmarshal([]byte(c.Text), &parsed) == nil {
return parsed, nil
}
texts = append(texts, c.Text)
}
}
if len(texts) == 1 {
return texts[0], nil
}
return texts, nil
}
func (c *mcpClient) listTools() ([]string, error) {
resp, err := c.doRequest("tools/list", map[string]any{})
if err != nil {
return nil, err
}
var tr struct {
Tools []struct {
Name string `json:"name"`
Description string `json:"description"`
} `json:"tools"`
}
if err := json.Unmarshal(resp.Result, &tr); err != nil {
return nil, err
}
var names []string
for _, t := range tr.Tools {
names = append(names, t.Name)
}
return names, nil
}
func (c *mcpClient) close() {
}