Skip to content

Commit e8f47b0

Browse files
aksOpsclaude
andcommitted
feat(serve,ui): V24 pane capture + Settings drawer
V24 live tmux pane capture: - GET /events/session/{name}/pane streams `tmux capture-pane -e -p` output at 1 Hz as SSE. Identical payloads are debounced so idle panes stay quiet; two consecutive capture errors end the stream. - internal/tmux/client.go adds CapturePane(name). - UI: PaneView renders ANSI-colored frames via a lightweight SGR → HTML converter (ui/src/lib/ansi.ts). New "Pane" tab in SessionDetail plus /s/:name/pane route. Settings drawer: - GET /api/config returns the resolved webhook URL/auth + attention thresholds. - PATCH /api/config allowlists the same fields (DisallowUnknownFields), validates ranges, writes atomically, and triggers a graceful in-process restart via the new Server.Shutdown(reason). - UI gear button in the dashboard top bar opens a right-side drawer with the webhook + threshold fields and a "Daemon restarting…" banner on success. Server plumbing: - Server now keeps a single *tmux.Client reference reused by ingest and the pane handler. - Run(ctx) wraps its ctx in a cancellable derived context and exposes Shutdown(reason) so in-process callers can trigger the same cascading-cancel path a SIGINT would. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d428dd2 commit e8f47b0

21 files changed

Lines changed: 2372 additions & 13 deletions

internal/serve/api/config_get.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package api
2+
3+
// Coordinator wiring (do NOT edit server.go here — this comment is the
4+
// integration contract). In server.go's registerRoutes, after the
5+
// existing mux.Handle lines, add:
6+
//
7+
// mux.Handle("GET /api/config", authHF(api.ConfigGet(config.ConfigPath())))
8+
// mux.Handle("PATCH /api/config", authHF(api.ConfigUpdate(config.ConfigPath(), s.Shutdown)))
9+
//
10+
// The second line assumes a Server.Shutdown(reason string) exists that
11+
// cancels the daemon's root context. If it does not yet, wrap the
12+
// Run(ctx) caller's cancel() via a thin closure at the call site.
13+
14+
import (
15+
"encoding/json"
16+
"net/http"
17+
18+
"github.com/RandomCodeSpace/ctm/internal/config"
19+
)
20+
21+
// ConfigGet returns the GET /api/config handler. The response is the
22+
// same allowlisted shape that PATCH /api/config accepts, so the UI can
23+
// seed the settings form with current values without parsing the full
24+
// on-disk config.
25+
//
26+
// Response body:
27+
//
28+
// {
29+
// "webhook_url": "...",
30+
// "webhook_auth": "...",
31+
// "attention": {
32+
// "error_rate_pct": 20,
33+
// "error_rate_window": 20,
34+
// "idle_minutes": 5,
35+
// "quota_pct": 85,
36+
// "context_pct": 90,
37+
// "yolo_unchecked_minutes": 30
38+
// }
39+
// }
40+
//
41+
// Thresholds are always returned in their Resolved() form so the UI
42+
// form shows the defaults the daemon is actually using rather than
43+
// zeroes for fields the user has never set.
44+
//
45+
// 405 on any non-GET method. 500 on config-load failure (rare — Load
46+
// auto-creates the file on first boot).
47+
func ConfigGet(cfgPath string) http.HandlerFunc {
48+
return func(w http.ResponseWriter, r *http.Request) {
49+
if r.Method != http.MethodGet {
50+
w.Header().Set("Allow", http.MethodGet)
51+
w.Header().Set("Cache-Control", "no-store")
52+
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
53+
return
54+
}
55+
56+
cfg, err := config.Load(cfgPath)
57+
if err != nil {
58+
writeJSONError(w, http.StatusInternalServerError, "load_config")
59+
return
60+
}
61+
62+
att := cfg.Serve.Attention.Resolved()
63+
body := configPayload{
64+
WebhookURL: cfg.Serve.WebhookURL,
65+
WebhookAuth: cfg.Serve.WebhookAuth,
66+
Attention: &attentionPayload{
67+
ErrorRatePct: att.ErrorRatePct,
68+
ErrorRateWindow: att.ErrorRateWindow,
69+
IdleMinutes: att.IdleMinutes,
70+
QuotaPct: att.QuotaPct,
71+
ContextPct: att.ContextPct,
72+
YoloUncheckedMinutes: att.YoloUncheckedMinutes,
73+
},
74+
}
75+
76+
w.Header().Set("Content-Type", "application/json")
77+
w.Header().Set("Cache-Control", "no-store")
78+
_ = json.NewEncoder(w).Encode(body)
79+
}
80+
}
81+
82+
// configPayload is the wire shape for both GET and PATCH /api/config.
83+
// On GET every field is populated; on PATCH clients may omit fields to
84+
// leave them unchanged. `omitempty` on Attention lets partial patches
85+
// skip the attention block entirely.
86+
type configPayload struct {
87+
WebhookURL string `json:"webhook_url"`
88+
WebhookAuth string `json:"webhook_auth"`
89+
Attention *attentionPayload `json:"attention,omitempty"`
90+
}
91+
92+
type attentionPayload struct {
93+
ErrorRatePct int `json:"error_rate_pct"`
94+
ErrorRateWindow int `json:"error_rate_window"`
95+
IdleMinutes int `json:"idle_minutes"`
96+
QuotaPct int `json:"quota_pct"`
97+
ContextPct int `json:"context_pct"`
98+
YoloUncheckedMinutes int `json:"yolo_unchecked_minutes"`
99+
}
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
package api
2+
3+
// Coordinator wiring (do NOT edit server.go here — this comment is the
4+
// integration contract). In server.go's registerRoutes, after the
5+
// existing mux.Handle lines, add:
6+
//
7+
// mux.Handle("GET /api/config", authHF(api.ConfigGet(config.ConfigPath())))
8+
// mux.Handle("PATCH /api/config", authHF(api.ConfigUpdate(config.ConfigPath(), s.Shutdown)))
9+
//
10+
// s.Shutdown(reason string) must cancel the daemon's root context so
11+
// Run(ctx) returns cleanly; callers (ctm attach / new / yolo) then
12+
// respawn via proc.EnsureServeRunning on the next user action.
13+
14+
import (
15+
"encoding/json"
16+
"fmt"
17+
"net/http"
18+
"os"
19+
"path/filepath"
20+
"time"
21+
22+
"github.com/RandomCodeSpace/ctm/internal/config"
23+
)
24+
25+
// Validation bounds for the six attention thresholds. Keep these in
26+
// sync with config.AttentionThresholds doc.
27+
const (
28+
maxPct = 100
29+
maxMinutes = 1440
30+
)
31+
32+
// shutdownDelay is how long we wait after responding 202 before
33+
// cancelling the daemon's root context. A small delay lets the HTTP
34+
// response body flush to the client; otherwise the SPA would race the
35+
// shutdown and see a truncated connection before it can show the
36+
// "daemon restarting" banner.
37+
const shutdownDelay = 1 * time.Second
38+
39+
// ConfigUpdate returns the PATCH /api/config handler.
40+
//
41+
// Body shape (all top-level keys optional; unknown keys → 400):
42+
//
43+
// {
44+
// "webhook_url": "https://...",
45+
// "webhook_auth": "Bearer ...",
46+
// "attention": {
47+
// "error_rate_pct": 20,
48+
// "error_rate_window": 20,
49+
// "idle_minutes": 5,
50+
// "quota_pct": 85,
51+
// "context_pct": 90,
52+
// "yolo_unchecked_minutes": 30
53+
// }
54+
// }
55+
//
56+
// On success, responds 202 Accepted with {"status":"restarting"} and
57+
// schedules shutdown(reason) to run after shutdownDelay so the response
58+
// flushes first. The caller (proc.EnsureServeRunning at the next
59+
// attach/new/yolo) respawns the daemon.
60+
//
61+
// Contract:
62+
// - 405 on non-PATCH.
63+
// - 400 on invalid JSON, unknown top-level keys, or out-of-range values.
64+
// - 500 if the on-disk config cannot be loaded or atomically replaced.
65+
// - 202 with {"status":"restarting"} on success.
66+
//
67+
// Write is atomic: marshal → WriteFile(<path>.tmp) → Rename. A crash
68+
// mid-write leaves the previous config intact.
69+
func ConfigUpdate(cfgPath string, shutdown func(reason string)) http.HandlerFunc {
70+
return func(w http.ResponseWriter, r *http.Request) {
71+
if r.Method != http.MethodPatch {
72+
w.Header().Set("Allow", http.MethodPatch)
73+
w.Header().Set("Cache-Control", "no-store")
74+
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
75+
return
76+
}
77+
78+
// Decode with DisallowUnknownFields so typos like "webhookUrl"
79+
// or dropped experimental keys surface as 400 instead of being
80+
// silently ignored. The allowlist is the wire contract.
81+
dec := json.NewDecoder(r.Body)
82+
dec.DisallowUnknownFields()
83+
var req configPayload
84+
if err := dec.Decode(&req); err != nil {
85+
writeJSONError(w, http.StatusBadRequest, sanitizeDecodeErr(err))
86+
return
87+
}
88+
89+
// Range-check thresholds before touching disk.
90+
if req.Attention != nil {
91+
if msg, ok := validateAttention(req.Attention); !ok {
92+
writeJSONError(w, http.StatusBadRequest, msg)
93+
return
94+
}
95+
}
96+
97+
cfg, err := config.Load(cfgPath)
98+
if err != nil {
99+
writeJSONError(w, http.StatusInternalServerError, "load_config")
100+
return
101+
}
102+
103+
// Deep-merge: only fields present in the patch body overwrite
104+
// existing config state. String fields are overwritten
105+
// verbatim (including empty strings, which lets users clear
106+
// webhook_url); attention thresholds merge field-by-field so a
107+
// partial attention block preserves the untouched thresholds.
108+
cfg.Serve.WebhookURL = req.WebhookURL
109+
cfg.Serve.WebhookAuth = req.WebhookAuth
110+
if req.Attention != nil {
111+
cfg.Serve.Attention = config.AttentionThresholds{
112+
ErrorRatePct: req.Attention.ErrorRatePct,
113+
ErrorRateWindow: req.Attention.ErrorRateWindow,
114+
IdleMinutes: req.Attention.IdleMinutes,
115+
QuotaPct: req.Attention.QuotaPct,
116+
ContextPct: req.Attention.ContextPct,
117+
YoloUncheckedMinutes: req.Attention.YoloUncheckedMinutes,
118+
}
119+
}
120+
121+
if err := writeAtomic(cfgPath, cfg); err != nil {
122+
writeJSONError(w, http.StatusInternalServerError, "write_config")
123+
return
124+
}
125+
126+
w.Header().Set("Content-Type", "application/json")
127+
w.Header().Set("Cache-Control", "no-store")
128+
w.WriteHeader(http.StatusAccepted)
129+
_ = json.NewEncoder(w).Encode(map[string]string{"status": "restarting"})
130+
131+
// Flush before cancelling: the response goroutine must return
132+
// so http.Server can write the body out before BaseContext is
133+
// cancelled. A tiny delay plus a background goroutine avoids
134+
// coupling the restart to the response writer's lifetime.
135+
if shutdown != nil {
136+
go func() {
137+
time.Sleep(shutdownDelay)
138+
shutdown("config change")
139+
}()
140+
}
141+
}
142+
}
143+
144+
// validateAttention enforces the documented ranges: percentages in
145+
// (0, 100], minute windows in (0, 1440]. Zero is rejected because the
146+
// config-layer Resolved() treats 0 as "use default", and the UI should
147+
// not be able to implicitly reset a threshold by POSTing 0 — it must
148+
// send the default value explicitly.
149+
func validateAttention(a *attentionPayload) (string, bool) {
150+
checks := []struct {
151+
name string
152+
val int
153+
max int
154+
}{
155+
{"error_rate_pct", a.ErrorRatePct, maxPct},
156+
{"quota_pct", a.QuotaPct, maxPct},
157+
{"context_pct", a.ContextPct, maxPct},
158+
{"error_rate_window", a.ErrorRateWindow, maxMinutes},
159+
{"idle_minutes", a.IdleMinutes, maxMinutes},
160+
{"yolo_unchecked_minutes", a.YoloUncheckedMinutes, maxMinutes},
161+
}
162+
for _, c := range checks {
163+
if c.val <= 0 {
164+
return fmt.Sprintf("%s must be > 0", c.name), false
165+
}
166+
if c.val > c.max {
167+
return fmt.Sprintf("%s must be <= %d", c.name, c.max), false
168+
}
169+
}
170+
return "", true
171+
}
172+
173+
// writeAtomic marshals cfg to JSON and swaps it into place via a
174+
// sibling .tmp file + rename. The rename is atomic on POSIX when src
175+
// and dst live on the same filesystem (always true here — both in
176+
// ~/.config/ctm/).
177+
func writeAtomic(path string, cfg config.Config) error {
178+
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
179+
return err
180+
}
181+
// Stamp the current schema version so the file round-trips cleanly
182+
// through the migrator on subsequent loads. Mirrors config.write().
183+
cfg.SchemaVersion = config.SchemaVersion
184+
data, err := json.MarshalIndent(cfg, "", " ")
185+
if err != nil {
186+
return err
187+
}
188+
tmp := path + ".tmp"
189+
if err := os.WriteFile(tmp, data, 0600); err != nil {
190+
return err
191+
}
192+
if err := os.Rename(tmp, path); err != nil {
193+
_ = os.Remove(tmp)
194+
return err
195+
}
196+
return nil
197+
}
198+
199+
// sanitizeDecodeErr maps json.Decoder errors to short, stable tokens.
200+
// Unknown-field errors are the load-bearing 400 signal so the UI can
201+
// render a helpful message; everything else collapses to
202+
// "invalid_request" to avoid leaking parser internals.
203+
func sanitizeDecodeErr(err error) string {
204+
if err == nil {
205+
return ""
206+
}
207+
msg := err.Error()
208+
const marker = "json: unknown field "
209+
if idx := indexOf(msg, marker); idx >= 0 {
210+
// e.g. `json: unknown field "foo"` → `unknown key: foo`
211+
return "unknown key: " + trimQuotes(msg[idx+len(marker):])
212+
}
213+
return "invalid_request"
214+
}
215+
216+
// indexOf is a tiny strings.Index shim to keep the import list lean
217+
// (one-file handler — we don't need strings elsewhere here).
218+
func indexOf(s, substr string) int {
219+
n := len(substr)
220+
for i := 0; i+n <= len(s); i++ {
221+
if s[i:i+n] == substr {
222+
return i
223+
}
224+
}
225+
return -1
226+
}
227+
228+
// trimQuotes strips leading/trailing ASCII double-quotes from s.
229+
// json's "unknown field" error format is `"field"` with the quotes
230+
// embedded in the message, so we peel them for a cleaner wire token.
231+
func trimQuotes(s string) string {
232+
if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
233+
return s[1 : len(s)-1]
234+
}
235+
return s
236+
}

0 commit comments

Comments
 (0)