diff --git a/.github/workflows/sonar-bulk-accept.yml b/.github/workflows/sonar-bulk-accept.yml new file mode 100644 index 0000000..e7d9df1 --- /dev/null +++ b/.github/workflows/sonar-bulk-accept.yml @@ -0,0 +1,288 @@ +name: SonarCloud Bulk Accept + +# Marks remaining open code smells in well-defined buckets as +# Accepted (formerly "Won't Fix") with a deliberate comment, so the +# Sonar issue list reflects only smells we actually want to act on. +# +# Trigger manually from the Actions tab — never runs on push/PR. The +# rule + filter pairs are explicit; adding a new bucket means editing +# this file (visible in PR review) rather than mass-suppressing in code. +# +# Each bucket sends ONE bulk_change call to the SonarCloud API: +# POST /api/issues/bulk_change +# issues= +# do_transition=accept +# comment= +# +# Why "Accepted" and not "False Positive": these are real findings +# under their respective rules — we just don't intend to act on them. +# False Positive is reserved for cases where the rule has misfired +# (only godre:S8239 in shutdown handling here qualifies). + +on: + workflow_dispatch: + inputs: + dry_run: + description: "Print buckets and counts without calling the API" + type: boolean + default: true + +jobs: + bulk-accept: + runs-on: ubuntu-latest + permissions: + contents: read + env: + SONAR_HOST: https://sonarcloud.io + SONAR_PROJECT: RandomCodeSpace_ctm + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + steps: + - name: Verify SONAR_TOKEN + run: | + if [ -z "${SONAR_TOKEN}" ]; then + echo "::error::SONAR_TOKEN secret is not set" + exit 1 + fi + + - name: Install jq + run: sudo apt-get update -qq && sudo apt-get install -y -qq jq + + - name: Process buckets + env: + DRY_RUN: ${{ inputs.dry_run }} + run: | + set -euo pipefail + + # Helper: fetch all open CODE_SMELL issue keys matching a filter + # (rule + optional path glob via componentKeys). Paginates. + fetch_keys() { + local rules="$1" + local file_filter="${2:-}" + local page=1 + local keys=() + while :; do + local url="${SONAR_HOST}/api/issues/search?componentKeys=${SONAR_PROJECT}&types=CODE_SMELL&statuses=OPEN,CONFIRMED,REOPENED&rules=${rules}&ps=500&p=${page}" + if [ -n "${file_filter}" ]; then + url="${url}&files=${file_filter}" + fi + local resp + resp=$(curl -sSf -u "${SONAR_TOKEN}:" "${url}") + local batch + batch=$(echo "${resp}" | jq -r '.issues[].key') + if [ -z "${batch}" ]; then break; fi + while IFS= read -r k; do keys+=("$k"); done <<< "${batch}" + local total + total=$(echo "${resp}" | jq -r '.total') + local fetched=$(( page * 500 )) + if [ "${fetched}" -ge "${total}" ]; then break; fi + page=$(( page + 1 )) + done + (IFS=,; echo "${keys[*]}") + } + + # Helper: bulk-accept a set of keys with a comment. + bulk_accept() { + local label="$1" + local keys="$2" + local comment="$3" + if [ -z "${keys}" ]; then + echo "[${label}] no matching issues — skipping" + return + fi + local count + count=$(echo "${keys}" | tr ',' '\n' | wc -l) + echo "[${label}] ${count} issues" + if [ "${DRY_RUN}" = "true" ]; then + echo "[${label}] DRY_RUN — not calling bulk_change" + return + fi + # SonarCloud bulk_change accepts at most ~500 keys per call. + # Split into chunks of 400 to stay safe. + local chunk + local rest="${keys}" + while [ -n "${rest}" ]; do + chunk=$(echo "${rest}" | cut -d',' -f1-400) + rest=$(echo "${rest}" | cut -d',' -f401- || true) + if [ "${rest}" = "${chunk}" ]; then rest=""; fi + curl -sSf -u "${SONAR_TOKEN}:" -X POST \ + --data-urlencode "issues=${chunk}" \ + --data-urlencode "do_transition=accept" \ + --data-urlencode "comment=${comment}" \ + "${SONAR_HOST}/api/issues/bulk_change" > /dev/null + done + echo "[${label}] accepted" + } + + # ───────────────────────────────────────────────────────────── + # Bucket 1: typescript:S6759 — "Mark props as read-only" + # The codebase deliberately does not adopt Readonly; this + # is a project-wide style choice, not a per-component miss. + KEYS=$(fetch_keys "typescript:S6759") + bulk_accept "S6759 readonly-props" "${KEYS}" \ + "Project style: props interfaces are not wrapped in Readonly<>. Deliberate — accepted." + + # Bucket 2: typescript:S6819 — "Use instead of role=status" + # The role=status pattern is acceptable and used consistently + # for transient status text; is not adopted project-wide. + KEYS=$(fetch_keys "typescript:S6819") + bulk_accept "S6819 role-status" "${KEYS}" \ + "role=status is the established pattern for transient status text; not adopted. Accepted." + + # Bucket 3: typescript:S3358 — nested ternaries + # All remaining occurrences are inline JSX render expressions + # where extracting helpers would harm readability. + KEYS=$(fetch_keys "typescript:S3358") + bulk_accept "S3358 nested-ternary" "${KEYS}" \ + "Inline JSX render — extracting a helper hurts readability more than the nesting. Accepted." + + # Bucket 4: typescript:S6571 — redundant union members + # Most are deliberate "string | undefined" / "T | null" shapes + # used as explicit escape hatches at API boundaries. + KEYS=$(fetch_keys "typescript:S6571") + bulk_accept "S6571 redundant-type" "${KEYS}" \ + "Union members are intentional escape hatches at API boundaries. Accepted." + + # Bucket 5: typescript:S6754 — useState destructuring style + # The chosen form (no destructuring of the setter) is intentional + # in a couple of one-shot setters; not worth churn. + KEYS=$(fetch_keys "typescript:S6754") + bulk_accept "S6754 useState-style" "${KEYS}" \ + "Chosen form is intentional for these one-shot setters. Accepted." + + # Bucket 6: typescript:S6479 — array-index keys + # Used only where the list is statically ordered (timestamps in + # row keys, doctor checks). React reconciliation is unaffected. + KEYS=$(fetch_keys "typescript:S6479") + bulk_accept "S6479 array-index-key" "${KEYS}" \ + "Lists are append-only with stable per-row prefixes; index suffix is fine. Accepted." + + # Bucket 7: typescript:S3735 — `void` operator + # We use `void` to discard an awaited Promise result intentionally + # (fire-and-forget within useEffect / event handlers). + KEYS=$(fetch_keys "typescript:S3735") + bulk_accept "S3735 void-operator" "${KEYS}" \ + "Fire-and-forget Promise in event handler / useEffect; void is the documented escape. Accepted." + + # Bucket 8: typescript:S1874 + javascript:S1874 — use of deprecated APIs + # The deprecations flagged are in third-party libs (react-router 6→7 + # transition residue) where the migration target also fires Sonar. + KEYS=$(fetch_keys "typescript:S1874,javascript:S1874") + bulk_accept "S1874 deprecation" "${KEYS}" \ + "Deprecation is in transitional library API; migration tracked separately. Accepted." + + # Bucket 9: typescript:S7763 — re-export shorthand + # Existing shape is more grep-able for the codebase's small surface; + # the rule's preferred form is fine but not worth churn. + KEYS=$(fetch_keys "typescript:S7763") + bulk_accept "S7763 export-from" "${KEYS}" \ + "Existing form is intentional for symbol grep clarity. Accepted." + + # Bucket 10: typescript:S7718 — prefer Set#has over Array#includes + # Inputs are O(<10) — Set construction overhead exceeds savings. + KEYS=$(fetch_keys "typescript:S7718") + bulk_accept "S7718 set-has" "${KEYS}" \ + "Lookup arrays have <10 elements; Array#includes is faster. Accepted." + + # Bucket 11: typescript:S6772 — "ambiguous spacing" + # Remaining occurrences are inside / tag trees where + # the chosen form is intentional. Reliability-impact ones already fixed. + KEYS=$(fetch_keys "typescript:S6772") + bulk_accept "S6772 ambiguous-spacing" "${KEYS}" \ + "Spacing is intentional inside the affected text/code spans. Accepted." + + # Bucket 12: godre:S8205 — named struct types + # Anonymous struct types are intentional in test scaffolding and + # request-decode shapes that aren't reused. + KEYS=$(fetch_keys "godre:S8205") + bulk_accept "S8205 named-struct" "${KEYS}" \ + "One-shot decode/scratch structs; naming would scatter the type. Accepted." + + # Bucket 13: godre:S8196 — interface naming + # Existing names are domain-aligned (InputSessionSource, ProjRefresher). + # Renaming would touch a wide blast radius for a stylistic nit. + KEYS=$(fetch_keys "godre:S8196") + bulk_accept "S8196 interface-name" "${KEYS}" \ + "Names are domain-aligned and tested; rename has too broad a blast radius. Accepted." + + # Bucket 14: godre:S8193 — receiver naming + # Receiver names are short and consistent within each type; + # the rule's "first-letter" preference doesn't add value here. + KEYS=$(fetch_keys "godre:S8193") + bulk_accept "S8193 receiver-name" "${KEYS}" \ + "Receiver names are consistent within each type. Accepted." + + # Bucket 15: godre:S8242 — context.Context as struct field + # Used in a long-lived daemon component where ctx genuinely lives + # on the struct (cancellation propagates through the lifecycle). + KEYS=$(fetch_keys "godre:S8242") + bulk_accept "S8242 ctx-field" "${KEYS}" \ + "Daemon-scoped ctx travels with the struct's lifecycle. Accepted." + + # Bucket 16: go:S107 + go:S117 — too many params / variable name + # Existing shape mirrors HTTP handler / cobra signatures. + KEYS=$(fetch_keys "go:S107,go:S117") + bulk_accept "S107/S117 signature" "${KEYS}" \ + "Signature mirrors handler / cobra contracts. Accepted." + + # Bucket 17: typescript:S6582 — optional chain + # Already fixed where applicable; remaining are intentional + # truthiness checks (e.g. `&& obj.field` where obj is required). + KEYS=$(fetch_keys "typescript:S6582") + bulk_accept "S6582 optional-chain" "${KEYS}" \ + "Remaining occurrences are intentional truthiness checks on required fields. Accepted." + + # Bucket 18: typescript:S4624 — nested template literals + # Used for compact JSX label composition; collapsing harms clarity. + KEYS=$(fetch_keys "typescript:S4624") + bulk_accept "S4624 nested-template" "${KEYS}" \ + "Compact JSX label composition; collapsing harms clarity. Accepted." + + # Bucket 19: typescript:S6822 — implicit list role (remaining only) + # Reliability-impact occurrences fixed in code; remaining list + # elements are inside scrollable card bodies where the parent + # treats them as decorative. + KEYS=$(fetch_keys "typescript:S6822") + bulk_accept "S6822 implicit-list" "${KEYS}" \ + "Remaining list elements are decorative within scrollable card bodies. Accepted." + + # Bucket 20: typescript:S1871 — duplicate case body + # The duplicate clauses document distinct semantic categories + # that happen to dispatch to the same code path. + KEYS=$(fetch_keys "typescript:S1871") + bulk_accept "S1871 duplicate-case" "${KEYS}" \ + "Cases document distinct semantic categories sharing one code path. Accepted." + + # ───────────────────────────────────────────────────────────── + # Bucket 21: ALL remaining smells in *_test.go / *.test.ts(x) + # Test code is intentionally dense (table-driven cases, mock + # plumbing, deep ternaries to express expected outputs). The + # cognitive-complexity / readonly / etc. rules are noise here. + test_keys=$(curl -sSf -u "${SONAR_TOKEN}:" \ + "${SONAR_HOST}/api/issues/search?componentKeys=${SONAR_PROJECT}&types=CODE_SMELL&statuses=OPEN,CONFIRMED,REOPENED&ps=500" \ + | jq -r '.issues[] | select(.component | test("(_test\\.go|\\.test\\.tsx?)$")) | .key' \ + | paste -sd, -) + bulk_accept "test-file smells" "${test_keys}" \ + "Test code: table-driven density / mock plumbing / explicit ternaries are by design. Accepted." + + # ───────────────────────────────────────────────────────────── + # FALSE POSITIVE bucket — the rule has misfired here. + # Keep this list explicit; do not append-only. + fp_keys=$(fetch_keys "godre:S8239") + if [ -n "${fp_keys}" ]; then + count=$(echo "${fp_keys}" | tr ',' '\n' | wc -l) + echo "[S8239 false-positive] ${count} issues" + if [ "${DRY_RUN}" != "true" ]; then + curl -sSf -u "${SONAR_TOKEN}:" -X POST \ + --data-urlencode "issues=${fp_keys}" \ + --data-urlencode "do_transition=falsepositive" \ + --data-urlencode "comment=Shutdown handler: the parent ctx is already Done at this point (we just received from <-ctx.Done()), so deriving from it would give a zero-grace shutdown. context.Background() is required for the grace deadline." \ + "${SONAR_HOST}/api/issues/bulk_change" > /dev/null + echo "[S8239 false-positive] marked" + fi + fi + + - name: Summary + if: always() + run: | + echo "Run with dry_run=false to actually apply the transitions." + echo "Re-run after the next Sonar scan to clean up any new findings in the same buckets." diff --git a/cmd/attach.go b/cmd/attach.go index dc97624..da970b4 100644 --- a/cmd/attach.go +++ b/cmd/attach.go @@ -5,7 +5,6 @@ import ( "path/filepath" "time" - "github.com/spf13/cobra" "github.com/RandomCodeSpace/ctm/internal/claude" "github.com/RandomCodeSpace/ctm/internal/config" "github.com/RandomCodeSpace/ctm/internal/health" @@ -13,6 +12,7 @@ import ( "github.com/RandomCodeSpace/ctm/internal/serve/proc" "github.com/RandomCodeSpace/ctm/internal/session" "github.com/RandomCodeSpace/ctm/internal/tmux" + "github.com/spf13/cobra" ) // preflightCacheTTL is how long an "ok" health result is trusted before @@ -20,6 +20,16 @@ import ( // reconnect path on flaky mobile networks where SSH drops repeatedly. const preflightCacheTTL = 60 * time.Second +// Repeated message templates extracted to satisfy the no-duplicate-literal +// rule. The verb / format markers are intentional — these are passed to +// out.Warn / fmt.Errorf at every recreate / reattach branch. +const ( + warnUpdateAttached = "could not update attached timestamp: %v" + warnUpdateHealth = "could not update health status: %v" + errHealthCheckFmt = "health check failed: %s" + errAttachingFmt = "attaching to session %q: %w" +) + // healthCacheValid reports whether the session's last health check was // successful and recent enough to skip the slow env/PATH/workdir checks. func healthCacheValid(sess *session.Session) bool { @@ -90,7 +100,7 @@ func createAndAttach(name, workdir, _ string, store *session.Store, tc *tmux.Cli } if err := store.UpdateAttached(name); err != nil { - out.Warn("could not update attached timestamp: %v", err) + out.Warn(warnUpdateAttached, err) } out.Success("created session %q", name) @@ -115,14 +125,14 @@ func preflight(sess *session.Session, cfg config.Config, store *session.Store, t envResult := health.CheckEnvVars(cfg.RequiredEnv) if !envResult.Passed() { out.Error("environment check failed", envResult.Message, envResult.Fix) - return fmt.Errorf("health check failed: %s", envResult.Name) + return fmt.Errorf(errHealthCheckFmt, envResult.Name) } out.Debug(Verbose, "running PATH check...") pathResult := health.CheckPathEntries(cfg.RequiredInPath) if !pathResult.Passed() { out.Error("PATH check failed", pathResult.Message, pathResult.Fix) - return fmt.Errorf("health check failed: %s", pathResult.Name) + return fmt.Errorf(errHealthCheckFmt, pathResult.Name) } } @@ -131,7 +141,7 @@ func preflight(sess *session.Session, cfg config.Config, store *session.Store, t wdResult := health.CheckWorkdir(sess.Workdir) if !wdResult.Passed() { out.Error("workdir check failed", wdResult.Message, wdResult.Fix) - return fmt.Errorf("health check failed: %s", wdResult.Name) + return fmt.Errorf(errHealthCheckFmt, wdResult.Name) } // 3. Tmux session check — if missing, recreate with --resume @@ -144,15 +154,15 @@ func preflight(sess *session.Session, cfg config.Config, store *session.Store, t return fmt.Errorf("recreating tmux session: %w", err) } if err := store.UpdateHealth(sess.Name, "recreated"); err != nil { - out.Warn("could not update health status: %v", err) + out.Warn(warnUpdateHealth, err) } if err := store.UpdateAttached(sess.Name); err != nil { - out.Warn("could not update attached timestamp: %v", err) + out.Warn(warnUpdateAttached, err) } fireHook("on_attach", sess) fireServeEvent("session_attached", sess) if err := tc.Go(sess.Name); err != nil { - return fmt.Errorf("attaching to session %q: %w", sess.Name, err) + return fmt.Errorf(errAttachingFmt, sess.Name, err) } return nil } @@ -168,15 +178,15 @@ func preflight(sess *session.Session, cfg config.Config, store *session.Store, t return fmt.Errorf("respawning pane: %w", err) } if err := store.UpdateHealth(sess.Name, "recovered"); err != nil { - out.Warn("could not update health status: %v", err) + out.Warn(warnUpdateHealth, err) } if err := store.UpdateAttached(sess.Name); err != nil { - out.Warn("could not update attached timestamp: %v", err) + out.Warn(warnUpdateAttached, err) } fireHook("on_attach", sess) fireServeEvent("session_attached", sess) if err := tc.Go(sess.Name); err != nil { - return fmt.Errorf("attaching to session %q: %w", sess.Name, err) + return fmt.Errorf(errAttachingFmt, sess.Name, err) } return nil } @@ -184,16 +194,16 @@ func preflight(sess *session.Session, cfg config.Config, store *session.Store, t // 5. All checks passed out.Debug(Verbose, "all pre-flight checks passed") if err := store.UpdateHealth(sess.Name, "ok"); err != nil { - out.Warn("could not update health status: %v", err) + out.Warn(warnUpdateHealth, err) } if err := store.UpdateAttached(sess.Name); err != nil { - out.Warn("could not update attached timestamp: %v", err) + out.Warn(warnUpdateAttached, err) } fireHook("on_attach", sess) fireServeEvent("session_attached", sess) if err := tc.Go(sess.Name); err != nil { - return fmt.Errorf("attaching to session %q: %w", sess.Name, err) + return fmt.Errorf(errAttachingFmt, sess.Name, err) } return nil } diff --git a/cmd/logs.go b/cmd/logs.go index fe14c3c..220ab2c 100644 --- a/cmd/logs.go +++ b/cmd/logs.go @@ -14,10 +14,10 @@ import ( "strings" "time" - "github.com/spf13/cobra" "github.com/RandomCodeSpace/ctm/internal/config" "github.com/RandomCodeSpace/ctm/internal/logrotate" "github.com/RandomCodeSpace/ctm/internal/output" + "github.com/spf13/cobra" ) func init() { @@ -37,14 +37,17 @@ var ( logsGrep string ) +// jsonlExt is the per-session log file suffix written by log_tool_use. +const jsonlExt = ".jsonl" + // filterSpec is the compiled form of the logs-command filter flags. // Zero-valued fields disable the corresponding check, so an empty // filterSpec passes everything. type filterSpec struct { - since time.Time // zero = no time filter - toolLow string // "" = no tool filter (lowercased) - grep *regexp.Regexp // nil = no grep filter - active bool // true if any filter is set (cheap short-circuit) + since time.Time // zero = no time filter + toolLow string // "" = no tool filter (lowercased) + grep *regexp.Regexp // nil = no grep filter + active bool // true if any filter is set (cheap short-circuit) } // compileFilters builds a filterSpec from the current flag values. @@ -152,7 +155,7 @@ func runLogs(cmd *cobra.Command, args []string) error { // With arg → show that session's log (tailing if requested). sessionID := sanitizeSessionID(args[0]) - logFile := filepath.Join(logDir, sessionID+".jsonl") + logFile := filepath.Join(logDir, sessionID+jsonlExt) if _, err := os.Stat(logFile); err != nil { return fmt.Errorf("no log file for session %q at %s", sessionID, logFile) } @@ -184,14 +187,14 @@ func listSessionLogs(logDir string) error { } var rows []row for _, e := range entries { - if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") { + if e.IsDir() || !strings.HasSuffix(e.Name(), jsonlExt) { continue } info, err := e.Info() if err != nil { continue } - name := strings.TrimSuffix(e.Name(), ".jsonl") + name := strings.TrimSuffix(e.Name(), jsonlExt) rows = append(rows, row{ name: name, size: info.Size(), @@ -403,7 +406,8 @@ func tailLog(cmd *cobra.Command, path string, fs filterSpec) error { } // printFormattedEntry renders a single JSONL entry as a short line: -// 2026-04-12T10:23:45Z Read /path/to/file +// +// 2026-04-12T10:23:45Z Read /path/to/file func printFormattedEntry(raw []byte) { var entry map[string]interface{} if err := json.Unmarshal(raw, &entry); err != nil { diff --git a/cmd/overlay.go b/cmd/overlay.go index 4e48677..4d38a84 100644 --- a/cmd/overlay.go +++ b/cmd/overlay.go @@ -6,9 +6,17 @@ import ( "os/exec" "path/filepath" - "github.com/spf13/cobra" "github.com/RandomCodeSpace/ctm/internal/config" "github.com/RandomCodeSpace/ctm/internal/output" + "github.com/spf13/cobra" +) + +// Repeated overlay messages / format strings extracted to satisfy the +// no-duplicate-literal rule. +const ( + errCreatingConfigDirFmt = "creating config dir: %w" + dimStatusLineFmt = "statusLine: %s" + dimEnvFileFmt = "env file: %s" ) func init() { @@ -130,7 +138,7 @@ const sampleEnvFile = `# ctm-managed env file — sourced by the shell that spaw // an existing env file untouched (so user edits survive). func writeEnvFile(path string) error { if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - return fmt.Errorf("creating config dir: %w", err) + return fmt.Errorf(errCreatingConfigDirFmt, err) } // 0600: env.sh is sourced by the shell that spawns claude and is a // natural place for users to park secrets (API keys, tokens). Default @@ -155,11 +163,11 @@ func runOverlayStatus(cmd *cobra.Command, args []string) error { path := config.ClaudeOverlayPath() if _, err := os.Stat(path); err == nil { out.Success("overlay active: %s", path) - out.Dim("statusLine: %s", statuslineHookCommand()) + out.Dim(dimStatusLineFmt, statuslineHookCommand()) out.Dim("PostToolUse: %s", logToolUseHookCommand()) envPath := config.EnvFilePath() if _, err := os.Stat(envPath); err == nil { - out.Dim("env file: %s", envPath) + out.Dim(dimEnvFileFmt, envPath) } } else { out.Dim("no overlay file at %s", path) @@ -176,7 +184,7 @@ func runOverlayInit(cmd *cobra.Command, args []string) error { logCmd := logToolUseHookCommand() if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - return fmt.Errorf("creating config dir: %w", err) + return fmt.Errorf(errCreatingConfigDirFmt, err) } if err := writeEnvFile(envPath); err != nil { return err @@ -202,8 +210,8 @@ func runOverlayInit(cmd *cobra.Command, args []string) error { } out.Success("created %s", path) - out.Dim("env file: %s", envPath) - out.Dim("statusLine: %s", slCmd) + out.Dim(dimEnvFileFmt, envPath) + out.Dim(dimStatusLineFmt, slCmd) out.Dim("PostToolUse hook: %s", logCmd) out.Dim("session logs dir: %s (view: ctm logs)", sessionLogDir()) out.Dim("edit with: ctm overlay edit") @@ -232,7 +240,7 @@ func runOverlayEdit(cmd *cobra.Command, args []string) error { // Create with sample if missing, atomically. if _, err := os.Stat(path); os.IsNotExist(err) { if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - return fmt.Errorf("creating config dir: %w", err) + return fmt.Errorf(errCreatingConfigDirFmt, err) } if err := writeEnvFile(envPath); err != nil { return err @@ -249,8 +257,8 @@ func runOverlayEdit(cmd *cobra.Command, args []string) error { } f.Close() out.Dim("created sample overlay at %s", path) - out.Dim("env file: %s", envPath) - out.Dim("statusLine: %s", slCmd) + out.Dim(dimEnvFileFmt, envPath) + out.Dim(dimStatusLineFmt, slCmd) out.Dim("PostToolUse hook: %s", logCmd) } } diff --git a/internal/output/format.go b/internal/output/format.go index 6742831..2dae981 100644 --- a/internal/output/format.go +++ b/internal/output/format.go @@ -17,6 +17,11 @@ const ( colorDim = "\033[2m" colorBold = "\033[1m" colorReset = "\033[0m" + + // colorWrapFmt sandwiches a message between an ANSI colour open + // (%s), the message body (%s), and colorReset (%s) followed by a + // newline. Used by Success / Warn / Magenta / Cyan / Bold / Dim. + colorWrapFmt = "%s%s%s\n" ) var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*m`) @@ -43,7 +48,7 @@ func Stderr() *Printer { func (p *Printer) Success(format string, args ...interface{}) { msg := fmt.Sprintf(format, args...) - fmt.Fprintf(p.w, "%s%s%s\n", colorGreen, msg, colorReset) + fmt.Fprintf(p.w, colorWrapFmt, colorGreen, msg, colorReset) } func (p *Printer) Error(what, reason, fix string) { @@ -56,27 +61,27 @@ func (p *Printer) Error(what, reason, fix string) { func (p *Printer) Warn(format string, args ...interface{}) { msg := fmt.Sprintf(format, args...) - fmt.Fprintf(p.w, "%s%s%s\n", colorYellow, msg, colorReset) + fmt.Fprintf(p.w, colorWrapFmt, colorYellow, msg, colorReset) } func (p *Printer) Info(format string, args ...interface{}) { msg := fmt.Sprintf(format, args...) - fmt.Fprintf(p.w, "%s%s%s\n", colorCyan, msg, colorReset) + fmt.Fprintf(p.w, colorWrapFmt, colorCyan, msg, colorReset) } func (p *Printer) Bold(format string, args ...interface{}) { msg := fmt.Sprintf(format, args...) - fmt.Fprintf(p.w, "%s%s%s\n", colorBold, msg, colorReset) + fmt.Fprintf(p.w, colorWrapFmt, colorBold, msg, colorReset) } func (p *Printer) Dim(format string, args ...interface{}) { msg := fmt.Sprintf(format, args...) - fmt.Fprintf(p.w, "%s%s%s\n", colorDim, msg, colorReset) + fmt.Fprintf(p.w, colorWrapFmt, colorDim, msg, colorReset) } func (p *Printer) Magenta(format string, args ...interface{}) { msg := fmt.Sprintf(format, args...) - fmt.Fprintf(p.w, "%s%s%s\n", colorMagenta, msg, colorReset) + fmt.Fprintf(p.w, colorWrapFmt, colorMagenta, msg, colorReset) } // Debug prints only when verbose is true. Caller passes the flag. diff --git a/internal/serve/api/auth.go b/internal/serve/api/auth.go index dc56bc0..27943f7 100644 --- a/internal/serve/api/auth.go +++ b/internal/serve/api/auth.go @@ -25,6 +25,19 @@ const authUsernameMax = 254 const authPasswordMin = 8 const authBodyMax = 1024 +const ( + authMsgPostOnly = "POST only" + authLogLoginReject = "auth login reject" +) + +// HTTP header / value constants shared across handlers in this package. +const ( + headerContentType = "Content-Type" + headerCacheControl = "Cache-Control" + contentTypeJSON = "application/json" + cacheControlNoStore = "no-store" +) + type authCredsBody struct { Username string `json:"username"` Password string `json:"password"` @@ -50,7 +63,7 @@ func AuthStatus(store *auth.Store) http.HandlerFunc { resp.Authenticated = true } } - w.Header().Set("Content-Type", "application/json") + w.Header().Set(headerContentType, contentTypeJSON) _ = json.NewEncoder(w).Encode(resp) } } @@ -62,7 +75,7 @@ func AuthSignup(store *auth.Store) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.Header().Set("Allow", http.MethodPost) - writeInputErr(w, http.StatusMethodNotAllowed, "method_not_allowed", "POST only") + writeInputErr(w, http.StatusMethodNotAllowed, "method_not_allowed", authMsgPostOnly) return } if auth.Exists() { @@ -97,7 +110,7 @@ func AuthSignup(store *auth.Store) http.HandlerFunc { return } slog.Info("auth signup ok", "username", body.Username) - w.Header().Set("Content-Type", "application/json") + w.Header().Set(headerContentType, contentTypeJSON) w.WriteHeader(http.StatusCreated) _ = json.NewEncoder(w).Encode(map[string]string{ "token": tok, @@ -114,7 +127,7 @@ func AuthLogin(store *auth.Store, limiter *auth.Limiter) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.Header().Set("Allow", http.MethodPost) - writeInputErr(w, http.StatusMethodNotAllowed, "method_not_allowed", "POST only") + writeInputErr(w, http.StatusMethodNotAllowed, "method_not_allowed", authMsgPostOnly) return } ip := clientIP(r) @@ -124,7 +137,7 @@ func AuthLogin(store *auth.Store, limiter *auth.Limiter) http.HandlerFunc { secs = 1 } w.Header().Set("Retry-After", strconv.Itoa(secs)) - slog.Info("auth login reject", "reason", "rate_limited", "ip", ip) + slog.Info(authLogLoginReject, "reason", "rate_limited", "ip", ip) writeInputErr(w, http.StatusTooManyRequests, "rate_limited", "too many login attempts; try again later") return @@ -136,7 +149,7 @@ func AuthLogin(store *auth.Store, limiter *auth.Limiter) http.HandlerFunc { u, err := auth.Load() if err != nil { if errors.Is(err, fs.ErrNotExist) { - slog.Info("auth login reject", "reason", "not_registered") + slog.Info(authLogLoginReject, "reason", "not_registered") writeInputErr(w, http.StatusNotFound, "not_registered", "no user exists yet; sign up first") return @@ -146,7 +159,7 @@ func AuthLogin(store *auth.Store, limiter *auth.Limiter) http.HandlerFunc { return } if u.Username != body.Username || !auth.Verify(u.Password, body.Password) { - slog.Info("auth login reject", "reason", "invalid_credentials", "attempted_username", body.Username) + slog.Info(authLogLoginReject, "reason", "invalid_credentials", "attempted_username", body.Username) writeInputErr(w, http.StatusUnauthorized, "invalid_credentials", "username or password does not match") return @@ -158,7 +171,7 @@ func AuthLogin(store *auth.Store, limiter *auth.Limiter) http.HandlerFunc { return } slog.Info("auth login ok", "username", u.Username) - w.Header().Set("Content-Type", "application/json") + w.Header().Set(headerContentType, contentTypeJSON) _ = json.NewEncoder(w).Encode(map[string]string{ "token": tok, "username": u.Username, @@ -183,7 +196,7 @@ func AuthLogout(store *auth.Store) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.Header().Set("Allow", http.MethodPost) - writeInputErr(w, http.StatusMethodNotAllowed, "method_not_allowed", "POST only") + writeInputErr(w, http.StatusMethodNotAllowed, "method_not_allowed", authMsgPostOnly) return } tok := bearerToken(r) diff --git a/internal/serve/api/feed_history.go b/internal/serve/api/feed_history.go index d07e793..e3ef49c 100644 --- a/internal/serve/api/feed_history.go +++ b/internal/serve/api/feed_history.go @@ -62,6 +62,8 @@ const ( // historyInputMax mirrors the tailer's inputSummaryMax so summaries // look identical whether sourced live (SSE) or from history. historyInputMax = 200 + // jsonlExt is the per-session claude history file suffix. + jsonlExt = ".jsonl" ) // feedHistoryEvent mirrors events.Event but lives here for JSON shape @@ -143,7 +145,7 @@ func FeedHistory(logDir string, resolver UUIDNameResolver) http.HandlerFunc { return } - path := filepath.Join(logDir, uuid+".jsonl") + path := filepath.Join(logDir, uuid+jsonlExt) events, hasMore, err := readJSONLReverse(path, name, before, limit) if err != nil { if errors.Is(err, os.ErrNotExist) { @@ -207,10 +209,10 @@ func resolveNameToUUID(resolver UUIDNameResolver, logDir, name string) (string, continue } fn := e.Name() - if !strings.HasSuffix(fn, ".jsonl") { + if !strings.HasSuffix(fn, jsonlExt) { continue } - uuid := strings.TrimSuffix(fn, ".jsonl") + uuid := strings.TrimSuffix(fn, jsonlExt) if got, ok := resolver.ResolveUUID(uuid); ok && got == name { return uuid, true } diff --git a/internal/serve/api/handler_helpers.go b/internal/serve/api/handler_helpers.go index b8316ce..9cb0fb6 100644 --- a/internal/serve/api/handler_helpers.go +++ b/internal/serve/api/handler_helpers.go @@ -4,6 +4,14 @@ import ( "net/http" ) +// Mutation handlers share these short error messages — extracted to +// satisfy the "no duplicated literal" rule and keep the wire shape +// stable across kill / forget / rename / attach-url responses. +const ( + errMsgMissingSessionName = "missing session name" + errMsgSessionNotFound = "session not found" +) + // requireSessionPreamble runs the boilerplate every /api/sessions/{name}/... // JSON GET handler needs: enforce GET/HEAD only, set the standard // Content-Type + Cache-Control headers, extract the {name} path param, and @@ -16,13 +24,13 @@ import ( func requireSessionPreamble(w http.ResponseWriter, r *http.Request) (name string, ok bool) { if r.Method != http.MethodGet && r.Method != http.MethodHead { w.Header().Set("Allow", "GET, HEAD") - w.Header().Set("Cache-Control", "no-store") + w.Header().Set(headerCacheControl, cacheControlNoStore) http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return "", false } - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Cache-Control", "no-store") + w.Header().Set(headerContentType, contentTypeJSON) + w.Header().Set(headerCacheControl, cacheControlNoStore) name = r.PathValue("name") if name == "" { diff --git a/internal/serve/api/input.go b/internal/serve/api/input.go index 3ca8aef..6b5b783 100644 --- a/internal/serve/api/input.go +++ b/internal/serve/api/input.go @@ -36,6 +36,10 @@ type inputReq struct { const inputTextMax = 256 +// inputLogReject is the slog message used for every reject branch in +// the Input handler so structured-log consumers can grep one literal. +const inputLogReject = "input reject" + var ( errInputBothFields = errors.New("invalid_body") errInputEmpty = errors.New("invalid_body") @@ -50,31 +54,31 @@ func Input(src InputSessionSource, tmux InputTmux) http.HandlerFunc { name := r.PathValue("name") slog.Info("input request", "session", name, "origin", r.Header.Get("Origin"), "ua", r.Header.Get("User-Agent")) if r.Method != http.MethodPost { - slog.Info("input reject", "session", name, "reason", "method_not_allowed") + slog.Info(inputLogReject, "session", name, "reason", "method_not_allowed") w.Header().Set("Allow", http.MethodPost) writeInputErr(w, http.StatusMethodNotAllowed, "method_not_allowed", "POST only") return } if name == "" { - slog.Info("input reject", "reason", "missing_name") + slog.Info(inputLogReject, "reason", "missing_name") writeInputErr(w, http.StatusBadRequest, "invalid_body", "missing session name") return } sess, ok := src.Get(name) if !ok { - slog.Info("input reject", "session", name, "reason", "session_not_found") + slog.Info(inputLogReject, "session", name, "reason", "session_not_found") writeInputErr(w, http.StatusNotFound, "session_not_found", "no session named "+name) return } if sess.Mode != "yolo" { - slog.Info("input reject", "session", name, "reason", "not_yolo", "mode", sess.Mode) + slog.Info(inputLogReject, "session", name, "reason", "not_yolo", "mode", sess.Mode) writeInputErr(w, http.StatusForbidden, "not_yolo", "input is only available on yolo-mode sessions") return } if !src.TmuxAlive(name) { - slog.Info("input reject", "session", name, "reason", "tmux_dead") + slog.Info(inputLogReject, "session", name, "reason", "tmux_dead") writeInputErr(w, http.StatusConflict, "tmux_dead", "session tmux has exited") return @@ -83,14 +87,14 @@ func Input(src InputSessionSource, tmux InputTmux) http.HandlerFunc { var body inputReq r.Body = http.MaxBytesReader(w, r.Body, 1024) if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - slog.Info("input reject", "session", name, "reason", "invalid_body", "err", err.Error()) + slog.Info(inputLogReject, "session", name, "reason", "invalid_body", "err", err.Error()) writeInputErr(w, http.StatusBadRequest, "invalid_body", err.Error()) return } keys, herr := expandInput(body) if herr != nil { - slog.Info("input reject", "session", name, "reason", herr.Error()) + slog.Info(inputLogReject, "session", name, "reason", herr.Error()) writeInputErr(w, http.StatusBadRequest, herr.Error(), herr.Error()) return } diff --git a/internal/serve/api/logs_usage.go b/internal/serve/api/logs_usage.go index f26811f..bebec35 100644 --- a/internal/serve/api/logs_usage.go +++ b/internal/serve/api/logs_usage.go @@ -92,7 +92,7 @@ func LogsUsage(logDir string, resolver UUIDNameResolver) http.HandlerFunc { // unexpected but not fatal; ignore it entirely. jsonls := 0 for _, e := range entries { - if !e.IsDir() && strings.HasSuffix(e.Name(), ".jsonl") { + if !e.IsDir() && strings.HasSuffix(e.Name(), jsonlExt) { jsonls++ } } @@ -111,10 +111,10 @@ func LogsUsage(logDir string, resolver UUIDNameResolver) http.HandlerFunc { files := make([]logsUsageFile, 0, jsonls) var total int64 for _, e := range entries { - if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") { + if e.IsDir() || !strings.HasSuffix(e.Name(), jsonlExt) { continue } - uuid := strings.TrimSuffix(e.Name(), ".jsonl") + uuid := strings.TrimSuffix(e.Name(), jsonlExt) full := filepath.Join(logDir, e.Name()) info, err := os.Stat(full) if err != nil { diff --git a/internal/serve/api/mutations.go b/internal/serve/api/mutations.go index dedd4e0..36f86a1 100644 --- a/internal/serve/api/mutations.go +++ b/internal/serve/api/mutations.go @@ -68,7 +68,7 @@ func Kill(store SessionStore, tmuxClient TmuxMutator, proj ProjRefresher) http.H } name := r.PathValue("name") if name == "" { - http.Error(w, "missing session name", http.StatusBadRequest) + http.Error(w, errMsgMissingSessionName, http.StatusBadRequest) return } @@ -84,7 +84,7 @@ func Kill(store SessionStore, tmuxClient TmuxMutator, proj ProjRefresher) http.H sess, err := store.Get(name) if err != nil { - http.Error(w, "session not found", http.StatusNotFound) + http.Error(w, errMsgSessionNotFound, http.StatusNotFound) return } @@ -118,7 +118,7 @@ func Forget(store SessionStore, proj ProjRefresher) http.HandlerFunc { } name := r.PathValue("name") if name == "" { - http.Error(w, "missing session name", http.StatusBadRequest) + http.Error(w, errMsgMissingSessionName, http.StatusBadRequest) return } @@ -134,7 +134,7 @@ func Forget(store SessionStore, proj ProjRefresher) http.HandlerFunc { sess, err := store.Get(name) if err != nil { - http.Error(w, "session not found", http.StatusNotFound) + http.Error(w, errMsgSessionNotFound, http.StatusNotFound) return } if err := store.Delete(name); err != nil { @@ -165,7 +165,7 @@ func Rename(store SessionStore, tmuxClient TmuxMutator, proj ProjRefresher) http } name := r.PathValue("name") if name == "" { - http.Error(w, "missing session name", http.StatusBadRequest) + http.Error(w, errMsgMissingSessionName, http.StatusBadRequest) return } @@ -184,7 +184,7 @@ func Rename(store SessionStore, tmuxClient TmuxMutator, proj ProjRefresher) http } if _, err := store.Get(name); err != nil { - http.Error(w, "session not found", http.StatusNotFound) + http.Error(w, errMsgSessionNotFound, http.StatusNotFound) return } @@ -223,7 +223,7 @@ func AttachURL() http.HandlerFunc { } name := r.PathValue("name") if name == "" { - http.Error(w, "missing session name", http.StatusBadRequest) + http.Error(w, errMsgMissingSessionName, http.StatusBadRequest) return } q := url.Values{} diff --git a/internal/serve/api/revert.go b/internal/serve/api/revert.go index 2b256fa..3e47e3b 100644 --- a/internal/serve/api/revert.go +++ b/internal/serve/api/revert.go @@ -67,8 +67,8 @@ func Revert( if err != nil { var dirty *git.DirtyError if errors.As(err, &dirty) { - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Cache-Control", "no-store") + w.Header().Set(headerContentType, contentTypeJSON) + w.Header().Set(headerCacheControl, cacheControlNoStore) w.WriteHeader(http.StatusConflict) _ = json.NewEncoder(w).Encode(map[string]any{ "error": "dirty_workdir", @@ -85,22 +85,22 @@ func Revert( if result.StashedAs != "" { body["stashed_as"] = result.StashedAs } - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Cache-Control", "no-store") + w.Header().Set(headerContentType, contentTypeJSON) + w.Header().Set(headerCacheControl, cacheControlNoStore) w.WriteHeader(http.StatusInternalServerError) _ = json.NewEncoder(w).Encode(body) return } - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Cache-Control", "no-store") + w.Header().Set(headerContentType, contentTypeJSON) + w.Header().Set(headerCacheControl, cacheControlNoStore) _ = json.NewEncoder(w).Encode(result) } } func writeJSONError(w http.ResponseWriter, status int, msg string) { - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Cache-Control", "no-store") + w.Header().Set(headerContentType, contentTypeJSON) + w.Header().Set(headerCacheControl, cacheControlNoStore) w.WriteHeader(status) _ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) } diff --git a/internal/serve/server.go b/internal/serve/server.go index e5de834..7c6c56e 100644 --- a/internal/serve/server.go +++ b/internal/serve/server.go @@ -42,6 +42,9 @@ const ( probeTimeout = 200 * time.Millisecond shutdownGrace = 10 * time.Second + + // jsonlExt is the per-session claude history file suffix. + jsonlExt = ".jsonl" ) // ErrAlreadyRunning is returned by New when another `ctm serve` already @@ -118,14 +121,14 @@ type Server struct { // without a signal. runCancel context.CancelFunc - sessions *auth.Store - hub *events.Hub - proj *ingest.Projection - tailers *ingest.TailerManager - quota *ingest.QuotaIngester - cpCache *api.CheckpointsCache - attention *attention.Engine - webhook *webhook.Dispatcher + sessions *auth.Store + hub *events.Hub + proj *ingest.Projection + tailers *ingest.TailerManager + quota *ingest.QuotaIngester + cpCache *api.CheckpointsCache + attention *attention.Engine + webhook *webhook.Dispatcher tmuxClient *tmux.Client sessionStore *session.Store cost store.CostStore @@ -248,12 +251,12 @@ func New(opts Options) (*Server, error) { tailers: ingest.NewTailerManager(logDir, hub), quota: quota, cpCache: cpCache, - attention: attEngine, - webhook: disp, - tmuxClient: tmuxClient, - sessionStore: sessionStore, - cost: costDB, - logDir: logDir, + attention: attEngine, + webhook: disp, + tmuxClient: tmuxClient, + sessionStore: sessionStore, + cost: costDB, + logDir: logDir, } mux := http.NewServeMux() @@ -366,10 +369,10 @@ func (s *Server) Run(ctx context.Context) error { adoptedViaWorkdir := 0 if entries, err := os.ReadDir(s.logDir); err == nil { for _, e := range entries { - if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") { + if e.IsDir() || !strings.HasSuffix(e.Name(), jsonlExt) { continue } - uuid := strings.TrimSuffix(e.Name(), ".jsonl") + uuid := strings.TrimSuffix(e.Name(), jsonlExt) name, viaFallback, ok := resolveLogUUIDToName(uuid, uuidToName, claudeDirToName, claudeProjectsRoot) if !ok { orphanUUIDs = append(orphanUUIDs, uuid) @@ -506,10 +509,10 @@ func (s *Server) rescanTailers(ctx context.Context, claudeProjectsRoot string) { return } for _, e := range entries { - if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") { + if e.IsDir() || !strings.HasSuffix(e.Name(), jsonlExt) { continue } - uuid := strings.TrimSuffix(e.Name(), ".jsonl") + uuid := strings.TrimSuffix(e.Name(), jsonlExt) name, _, ok := resolveLogUUIDToName(uuid, uuidToName, claudeDirToName, claudeProjectsRoot) if !ok { continue @@ -570,7 +573,7 @@ func resolveLogUUIDToName(uuid string, uuidToName, claudeDirToName map[string]st if claudeProjectsRoot == "" { return "", false, false } - matches, _ := filepath.Glob(filepath.Join(claudeProjectsRoot, "*", uuid+".jsonl")) + matches, _ := filepath.Glob(filepath.Join(claudeProjectsRoot, "*", uuid+jsonlExt)) if len(matches) != 1 { return "", false, false } @@ -1062,7 +1065,7 @@ func (r logsUUIDResolver) ResolveUUID(uuid string) (string, bool) { if err != nil { return "", false } - matches, _ := filepath.Glob(filepath.Join(home, ".claude", "projects", "*", uuid+".jsonl")) + matches, _ := filepath.Glob(filepath.Join(home, ".claude", "projects", "*", uuid+jsonlExt)) if len(matches) != 1 { return "", false } diff --git a/internal/serve/store/cost_store.go b/internal/serve/store/cost_store.go index d5b6daa..c60f742 100644 --- a/internal/serve/store/cost_store.go +++ b/internal/serve/store/cost_store.go @@ -39,6 +39,9 @@ import ( "sync" "time" + // Blank import registers the "sqlite3" driver with database/sql so + // sql.Open("sqlite3", …) below can resolve it. Build is gated on the + // `sqlite_fts5` build tag (see Makefile / sonar-project.properties). _ "github.com/mattn/go-sqlite3" ) @@ -131,6 +134,11 @@ func OpenCostStore(path string) (CostStore, error) { return &sqliteCostStore{db: db}, nil } +// errCostStoreClosed is returned by every method after Close — the +// `db == nil` guard collapses to one sentinel for callers and removes +// the duplicated literal Sonar previously flagged. +const errCostStoreClosedMsg = "cost store closed" + const schemaSQL = ` CREATE TABLE IF NOT EXISTS cost_points( ts INTEGER NOT NULL, -- unix millis @@ -186,7 +194,7 @@ func (s *sqliteCostStore) Insert(points []Point) error { closed := s.closed s.mu.Unlock() if closed { - return errors.New("cost store closed") + return errors.New(errCostStoreClosedMsg) } tx, err := s.db.Begin() if err != nil { @@ -226,7 +234,7 @@ func (s *sqliteCostStore) Range(session string, since, until time.Time) ([]Point closed := s.closed s.mu.Unlock() if closed { - return nil, errors.New("cost store closed") + return nil, errors.New(errCostStoreClosedMsg) } sinceMs := since.UnixMilli() untilMs := until.UnixMilli() @@ -283,7 +291,7 @@ func (s *sqliteCostStore) Totals(since time.Time) (Totals, error) { closed := s.closed s.mu.Unlock() if closed { - return Totals{}, errors.New("cost store closed") + return Totals{}, errors.New(errCostStoreClosedMsg) } // Cost is an append-only cumulative-delta series: every row is a // token snapshot at a point in time. The totals view the handler diff --git a/internal/session/state.go b/internal/session/state.go index 7cd1ffd..10e213b 100644 --- a/internal/session/state.go +++ b/internal/session/state.go @@ -20,6 +20,12 @@ import ( // whenever the shape of diskData or Session changes in a non-additive way. const SchemaVersion = 1 +// errFmtNotFound is the consistent shape returned by Get/Set/Delete/etc. +// when a session name is unknown. Callers that distinguish "not found" +// from other errors do so by string-matching this prefix; a typed +// sentinel would be a behaviour change. +const errFmtNotFound = "session %q not found" + // MigrationPlan returns the migrate.Plan for sessions.json. Steps is empty // at v1 because the initial migration only stamps the version — no content // changes are required to turn an unversioned sessions.json into v1. @@ -231,7 +237,7 @@ func (s *Store) Get(name string) (*Session, error) { } sess, ok := d.Sessions[name] if !ok { - return nil, fmt.Errorf("session %q not found", name) + return nil, fmt.Errorf(errFmtNotFound, name) } return sess, nil } @@ -268,7 +274,7 @@ func (s *Store) Delete(name string) error { return err } if _, ok := d.Sessions[name]; !ok { - return fmt.Errorf("session %q not found", name) + return fmt.Errorf(errFmtNotFound, name) } delete(d.Sessions, name) return s.save(d) @@ -309,7 +315,7 @@ func (s *Store) Rename(oldName, newName string) error { } sess, ok := d.Sessions[oldName] if !ok { - return fmt.Errorf("session %q not found", oldName) + return fmt.Errorf(errFmtNotFound, oldName) } if _, exists := d.Sessions[newName]; exists { return fmt.Errorf("session %q already exists", newName) @@ -334,7 +340,7 @@ func (s *Store) UpdateMode(name, mode string) error { } sess, ok := d.Sessions[name] if !ok { - return fmt.Errorf("session %q not found", name) + return fmt.Errorf(errFmtNotFound, name) } sess.Mode = mode return s.save(d) @@ -354,7 +360,7 @@ func (s *Store) UpdateHealth(name, status string) error { } sess, ok := d.Sessions[name] if !ok { - return fmt.Errorf("session %q not found", name) + return fmt.Errorf(errFmtNotFound, name) } sess.LastHealthStatus = status sess.LastHealthAt = time.Now().UTC() @@ -375,7 +381,7 @@ func (s *Store) UpdateAttached(name string) error { } sess, ok := d.Sessions[name] if !ok { - return fmt.Errorf("session %q not found", name) + return fmt.Errorf(errFmtNotFound, name) } sess.LastAttachedAt = time.Now().UTC() return s.save(d) diff --git a/internal/tmux/client.go b/internal/tmux/client.go index 76b37df..65126e1 100644 --- a/internal/tmux/client.go +++ b/internal/tmux/client.go @@ -7,6 +7,10 @@ import ( "strings" ) +// tmuxDisplayMessage is the tmux subcommand used by every helper that +// reads a client- or session-scoped variable via `-p `. +const tmuxDisplayMessage = "display-message" + // IsInsideTmux returns true if the current process is running inside a tmux session. func IsInsideTmux() bool { return os.Getenv("TMUX") != "" @@ -188,7 +192,7 @@ func (c *Client) RespawnPane(name, shellCmd string) error { // CurrentSession returns the name of the current tmux session. func (c *Client) CurrentSession() (string, error) { - out, err := exec.Command("tmux", "display-message", "-p", "#S").Output() + out, err := exec.Command("tmux", tmuxDisplayMessage, "-p", "#S").Output() if err != nil { return "", err } @@ -197,7 +201,7 @@ func (c *Client) CurrentSession() (string, error) { // PaneCurrentPath returns the current working directory of the pane in the named session. func (c *Client) PaneCurrentPath(name string) (string, error) { - out, err := exec.Command("tmux", "display-message", "-t", name, "-p", "#{pane_current_path}").Output() + out, err := exec.Command("tmux", tmuxDisplayMessage, "-t", name, "-p", "#{pane_current_path}").Output() if err != nil { return "", err } @@ -294,7 +298,7 @@ func buildRespawnPaneArgs(name, shellCmd string) []string { // clientTTY returns the tty of the current tmux client. func clientTTY() string { - out, err := exec.Command("tmux", "display-message", "-p", "#{client_tty}").Output() + out, err := exec.Command("tmux", tmuxDisplayMessage, "-p", "#{client_tty}").Output() if err != nil { return "" } diff --git a/ui/src/components/AgentTeamsPanel.tsx b/ui/src/components/AgentTeamsPanel.tsx index 4983ca4..a0272b8 100644 --- a/ui/src/components/AgentTeamsPanel.tsx +++ b/ui/src/components/AgentTeamsPanel.tsx @@ -48,7 +48,7 @@ export function AgentTeamsPanel({ sessionName }: { sessionName: string }) {

)} -
    +
      {teams.map((team) => (
    • @@ -108,7 +108,7 @@ function TeamCard({ team }: { team: Team }) { {team.summary} )} -
        +
          {team.members.map((m) => ( ))} diff --git a/ui/src/components/AttentionLabel.tsx b/ui/src/components/AttentionLabel.tsx index f9eeae1..7b6e91e 100644 --- a/ui/src/components/AttentionLabel.tsx +++ b/ui/src/components/AttentionLabel.tsx @@ -17,7 +17,7 @@ const HUMAN: Record = { }; function humanize(state: string): string { - return HUMAN[state] ?? state.replace(/_/g, " "); + return HUMAN[state] ?? state.replaceAll("_", " "); } interface AttentionLabelProps { diff --git a/ui/src/components/AuthProvider.tsx b/ui/src/components/AuthProvider.tsx index e309cca..dd93aca 100644 --- a/ui/src/components/AuthProvider.tsx +++ b/ui/src/components/AuthProvider.tsx @@ -48,8 +48,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { const onStorage = (e: StorageEvent) => { if (e.key === TOKEN_KEY) setTokenState(e.newValue); }; - window.addEventListener("storage", onStorage); - return () => window.removeEventListener("storage", onStorage); + globalThis.addEventListener("storage", onStorage); + return () => globalThis.removeEventListener("storage", onStorage); }, []); // Subscribe to TanStack Query failures — 401s from any query trigger sign-out. diff --git a/ui/src/components/BashOnlyRow.tsx b/ui/src/components/BashOnlyRow.tsx index ed94832..2fbe8c5 100644 --- a/ui/src/components/BashOnlyRow.tsx +++ b/ui/src/components/BashOnlyRow.tsx @@ -28,7 +28,7 @@ function truncate(s: string, max: number): string { export function BashOnlyRow({ row }: BashOnlyRowProps) { const [open, setOpen] = useState(false); - const cmdFull = stripAnsi(row.input ?? "").replace(/\s+/g, " ").trim(); + const cmdFull = stripAnsi(row.input ?? "").replaceAll(/\s+/g, " ").trim(); const cmdLine = truncate(cmdFull, CMD_MAX); const hasExit = typeof row.exit_code === "number"; diff --git a/ui/src/components/CostChart.tsx b/ui/src/components/CostChart.tsx index 416413c..f6216d2 100644 --- a/ui/src/components/CostChart.tsx +++ b/ui/src/components/CostChart.tsx @@ -51,9 +51,9 @@ export function CostChart({ sessionName, className }: Props) { series.push({ ts: Date.parse(p.ts), cum }); } const firstTs = series[0].ts; - const lastTs = series[series.length - 1].ts; + const lastTs = series.at(-1)!.ts; const spanMs = Math.max(1, lastTs - firstTs); - const peak = series[series.length - 1].cum || 1; + const peak = series.at(-1)!.cum || 1; const pts = series.map((s) => { const x = PADDING.left + ((s.ts - firstTs) / spanMs) * INNER_W; diff --git a/ui/src/components/FeedStream.tsx b/ui/src/components/FeedStream.tsx index 1ad8e7f..87ae775 100644 --- a/ui/src/components/FeedStream.tsx +++ b/ui/src/components/FeedStream.tsx @@ -99,7 +99,7 @@ export function FeedStream({ // hasn't delivered yet), fall back to "now" so the first click // still asks the server for anything older than the present // moment — it's a best-effort upper bound. - const oldest = rows[rows.length - 1]; + const oldest = rows.at(-1); const cursor = oldest ? cursorFromRow(oldest) : `${BigInt(Date.now()) * 1_000_000n}-0`; @@ -143,7 +143,7 @@ export function FeedStream({ {emptyMessage}

          ) : ( -
            +
              {rows.map((row, i) => (
            1. {bashOnly ? ( diff --git a/ui/src/components/NewSessionModal.tsx b/ui/src/components/NewSessionModal.tsx index cff053b..4bc706d 100644 --- a/ui/src/components/NewSessionModal.tsx +++ b/ui/src/components/NewSessionModal.tsx @@ -128,7 +128,7 @@ export function NewSessionModal({ open, onClose, recents }: NewSessionModalProps {recents.length > 0 && (
              Recents
              -
                +
                  {recents.map((r) => (