From 4f276df67fb448e81320b716e9cf43b9f117b694 Mon Sep 17 00:00:00 2001 From: itscooleric Date: Tue, 10 Mar 2026 01:43:09 +0000 Subject: [PATCH 01/19] feat: switch Claude Code to native installer (self-updating) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the npm-installed Claude Code CLI with the official native installer (curl -fsSL https://claude.ai/install.sh | sh). This eliminates the auto-update permission error that occurred because the npm global prefix was owned by root while claude runs as clide. The native binary at ~/.local/bin/claude is self-updating — no more version pinning or container rebuilds needed to get new Claude releases. Node.js is retained for Codex CLI and entrypoint config scripts. Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 4c8b92b..6001d3b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -39,10 +39,6 @@ RUN ARCH="$(uname -m)" \ -o /usr/local/bin/ttyd \ && chmod +x /usr/local/bin/ttyd -# Install Claude Code CLI (pinned — bump ARG to upgrade) -ARG CLAUDE_CODE_VERSION=2.1.71 -RUN npm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" - # Install Codex CLI (pinned — bump ARG to upgrade) # hadolint ignore=DL3059 ARG CODEX_VERSION=0.112.0 @@ -71,7 +67,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ pytest==9.0.2 \ ruff==0.15.5 -ENV PATH="/opt/pyenv/bin:${PATH}" +ENV PATH="/home/clide/.local/bin:/opt/pyenv/bin:${PATH}" # Create unprivileged user and set up workspace # UID/GID default to 1000 (standard first non-root user on Linux/macOS). @@ -100,6 +96,10 @@ COPY --chown=clide:clide .tmux.conf /home/clide/.tmux.conf # Switch to unprivileged user for user-scoped installs USER clide +# Install Claude Code CLI via native installer (self-updating, no npm dependency). +# Installs to ~/.local/bin/claude — auto-updates at runtime without sudo. +RUN curl -fsSL https://claude.ai/install.sh | sh + # Trust all directories for git operations. # Clide is a single-user dev sandbox — volume-mounted repos from the host # are often owned by a different UID (host user vs clide:1000), which causes From 47e6183bb426e268497be94b5526a8cb38f2ff39 Mon Sep 17 00:00:00 2001 From: itscooleric Date: Thu, 12 Mar 2026 01:01:36 +0000 Subject: [PATCH 02/19] feat: structured session logging with secret scrubbing and retention (#41, #42, #44) Add session-logger.sh that wraps agent CLI sessions with: - JSONL event logging (session_start/session_end) with schema_version=1 - Raw transcript capture via `script` command, gzipped on exit - Secret scrubbing (blocklist + heuristic) on all logged output - Automatic retention: prune oldest sessions beyond CLIDE_MAX_SESSIONS (default 30) - Agent-agnostic: works with claude, codex, copilot, or any command Wired into claude-entrypoint.sh so all agent sessions are logged automatically. Disable with CLIDE_LOG_DISABLED=1. Adds docs/schema/session-events-v1.md documenting the event format. Closes #41, closes #42, closes #44 Co-Authored-By: Claude Opus 4.6 --- .gitignore | 3 + Dockerfile | 3 +- claude-entrypoint.sh | 12 +- docker-compose.yml | 3 + docs/schema/session-events-v1.md | 67 ++++++++++ scripts/session-logger.sh | 206 +++++++++++++++++++++++++++++++ 6 files changed, 291 insertions(+), 3 deletions(-) create mode 100644 docs/schema/session-events-v1.md create mode 100755 scripts/session-logger.sh diff --git a/.gitignore b/.gitignore index b8865ed..aa5c3c5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ .env docker-compose.override.yml + +# Session logs (generated at runtime inside workspace) +.clide/logs/ diff --git a/Dockerfile b/Dockerfile index 6001d3b..8a17968 100644 --- a/Dockerfile +++ b/Dockerfile @@ -85,7 +85,8 @@ RUN groupadd -g "${CLIDE_GID}" clide 2>/dev/null || groupmod -n clide "$(getent COPY entrypoint.sh /usr/local/bin/entrypoint.sh COPY claude-entrypoint.sh /usr/local/bin/claude-entrypoint.sh COPY firewall.sh /usr/local/bin/firewall.sh -RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/claude-entrypoint.sh /usr/local/bin/firewall.sh +COPY scripts/session-logger.sh /usr/local/bin/session-logger.sh +RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/claude-entrypoint.sh /usr/local/bin/firewall.sh /usr/local/bin/session-logger.sh # Default CLAUDE.md template — seeded into /workspace on first run if none exists COPY CLAUDE.md.template /usr/local/share/clide/CLAUDE.md.template diff --git a/claude-entrypoint.sh b/claude-entrypoint.sh index c859833..0227f54 100644 --- a/claude-entrypoint.sh +++ b/claude-entrypoint.sh @@ -137,8 +137,16 @@ fi # Opt-in tmux wrapping for shell service (set CLIDE_TMUX=1 in .env) # Web terminal always uses tmux via entrypoint.sh; this covers make shell / ./clide shell. # Drop privileges to clide via gosu before exec so the workload never runs as root. + +# Wrap agent CLIs with session logger for structured logging + transcript capture. +# Set CLIDE_LOG_DISABLED=1 to skip. Logger is agent-agnostic — works with claude, codex, etc. +AGENT_CMD="${*:-claude}" +if [[ -x /usr/local/bin/session-logger.sh && "${CLIDE_LOG_DISABLED:-}" != "1" ]]; then + AGENT_CMD="session-logger.sh ${AGENT_CMD}" +fi + if [[ -n "${CLIDE_TMUX:-}" ]]; then - exec gosu clide tmux new-session -A -s main "${@:-claude}" + exec gosu clide tmux new-session -A -s main ${AGENT_CMD} fi -exec gosu clide "${@:-claude}" +exec gosu clide ${AGENT_CMD} diff --git a/docker-compose.yml b/docker-compose.yml index b67a931..3ffec1b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,9 @@ x-base: &base OPENAI_API_KEY: ${OPENAI_API_KEY:-} GITLAB_TOKEN: ${GITLAB_TOKEN:-} GITLAB_HOST: ${GITLAB_HOST:-} + # Session logging (v4: Agent Observability) + CLIDE_LOG_DISABLED: ${CLIDE_LOG_DISABLED:-} + CLIDE_MAX_SESSIONS: ${CLIDE_MAX_SESSIONS:-30} # Drop all capabilities then re-add only what entrypoint + firewall need. # NET_ADMIN — iptables egress rules (set CLIDE_FIREWALL=0 to disable) # SETUID/GID — gosu privilege drop from root → clide diff --git a/docs/schema/session-events-v1.md b/docs/schema/session-events-v1.md new file mode 100644 index 0000000..d107848 --- /dev/null +++ b/docs/schema/session-events-v1.md @@ -0,0 +1,67 @@ +# Session Event Schema v1 + +All events are written as newline-delimited JSON (JSONL) to: +``` +/workspace/.clide/logs//events.jsonl +``` + +Every event includes: +| Field | Type | Description | +|-------|------|-------------| +| `event` | string | Event type (see below) | +| `ts` | string | ISO 8601 timestamp (UTC) | +| `session_id` | string | `clide--` | +| `schema_version` | int | Always `1` | + +## Event Types + +### `session_start` +Emitted when an agent session begins. + +| Field | Type | Description | +|-------|------|-------------| +| `agent` | string | `claude`, `codex`, `copilot`, or command name | +| `repo` | string | `owner/repo` from git remote | +| `model` | string | Model identifier | +| `command` | string | Full command (secrets scrubbed) | +| `cwd` | string | Working directory | + +### `session_end` +Emitted when the agent session exits. + +| Field | Type | Description | +|-------|------|-------------| +| `agent` | string | Same as session_start | +| `exit_code` | int | Process exit code | +| `outcome` | string | `success` or `error` | + +## Session Directory Layout + +``` +/workspace/.clide/logs// + events.jsonl — structured event log + transcript.txt.gz — compressed raw terminal I/O +``` + +## Secret Scrubbing + +All event payloads are scrubbed before writing: +1. Known secret env var values replaced with `[REDACTED:]` +2. Heuristic: `KEY=longvalue` patterns replaced with `KEY=[REDACTED]` + +Blocklist: `GH_TOKEN`, `GITHUB_TOKEN`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, +`CLAUDE_CODE_OAUTH_TOKEN`, `TTYD_PASS`, `CLEM_WEB_SECRET`, `SUPERVISOR_SECRET`, +`TEDDY_API_KEY`, `TEDDY_WEB_PASSWORD`, `GITLAB_TOKEN` + +## Retention + +Configurable via `CLIDE_MAX_SESSIONS` (default: 30). Oldest sessions pruned +on each new session start. + +## Configuration + +| Env var | Default | Description | +|---------|---------|-------------| +| `CLIDE_LOG_DIR` | `/workspace/.clide/logs` | Log root directory | +| `CLIDE_MAX_SESSIONS` | `30` | Max sessions to retain | +| `CLIDE_LOG_DISABLED` | _(empty)_ | Set to `1` to disable logging | diff --git a/scripts/session-logger.sh b/scripts/session-logger.sh new file mode 100755 index 0000000..a2351a0 --- /dev/null +++ b/scripts/session-logger.sh @@ -0,0 +1,206 @@ +#!/bin/bash +# session-logger.sh — Structured session logging for agent CLIs +# +# Wraps an agent CLI session with: +# 1. JSONL event logging (session start/end events) +# 2. Raw transcript capture via `script` +# 3. Secret scrubbing on all logged output +# 4. Log retention (prune old sessions) +# +# Usage: +# session-logger.sh claude [args...] +# session-logger.sh codex [args...] +# session-logger.sh [args...] +# +# Output: +# $CLIDE_LOG_DIR// +# events.jsonl — structured session events +# transcript.txt — raw terminal I/O +# transcript.txt.gz — compressed after session ends +# +# Environment: +# CLIDE_LOG_DIR — log root (default: /workspace/.clide/logs) +# CLIDE_MAX_SESSIONS — retention limit (default: 30) +# CLIDE_LOG_DISABLED — set to 1 to disable logging entirely + +set -euo pipefail + +# ── Configuration ───────────────────────────────────────────────── + +LOG_DIR="${CLIDE_LOG_DIR:-/workspace/.clide/logs}" +MAX_SESSIONS="${CLIDE_MAX_SESSIONS:-30}" +SCHEMA_VERSION=1 + +# Skip logging entirely if disabled +if [[ "${CLIDE_LOG_DISABLED:-}" == "1" ]]; then + exec "$@" +fi + +# ── ULID-ish session ID ────────────────────────────────────────── + +generate_session_id() { + # Timestamp prefix (ms since epoch in base36) + random suffix + local ts + ts=$(python3 -c "import time,string; t=int(time.time()*1000); chars=string.digits+string.ascii_lowercase; r=''; +while t>0: r=chars[t%36]+r; t//=36 +print(r)" 2>/dev/null || date +%s) + local rand + rand=$(head -c 6 /dev/urandom | od -An -tx1 | tr -d ' \n' | head -c 8) + echo "clide-${ts}-${rand}" +} + +SESSION_ID=$(generate_session_id) +SESSION_DIR="${LOG_DIR}/${SESSION_ID}" +EVENTS_FILE="${SESSION_DIR}/events.jsonl" +TRANSCRIPT_FILE="${SESSION_DIR}/transcript.txt" + +mkdir -p "${SESSION_DIR}" + +# ── Secret scrubbing ───────────────────────────────────────────── + +# Blocklist of env var names whose values must be redacted +SECRET_NAMES=( + GH_TOKEN GITHUB_TOKEN ANTHROPIC_API_KEY OPENAI_API_KEY + CLAUDE_CODE_OAUTH_TOKEN TTYD_PASS TTYD_USER + CLEM_WEB_SECRET SUPERVISOR_SECRET TEDDY_API_KEY + TEDDY_WEB_PASSWORD GITLAB_TOKEN +) + +scrub_secrets() { + local text="$1" + # Redact known secret env var values + for name in "${SECRET_NAMES[@]}"; do + local val="${!name:-}" + if [[ -n "$val" && ${#val} -ge 4 ]]; then + text="${text//$val/[REDACTED:${name}]}" + fi + done + # Heuristic: redact env var assignments (KEY=value patterns) + text=$(echo "$text" | sed -E 's/([A-Z_]{3,})=([^ "]{8,})/\1=[REDACTED]/g') + echo "$text" +} + +# ── Event emitter ───────────────────────────────────────────────── + +emit_event() { + local event_type="$1" + shift + local ts + ts=$(date -u +"%Y-%m-%dT%H:%M:%S.%3NZ" 2>/dev/null || date -u +"%Y-%m-%dT%H:%M:%SZ") + + # Build JSON — use python for reliable escaping + python3 -c " +import json, sys +event = { + 'event': '$event_type', + 'ts': '$ts', + 'session_id': '${SESSION_ID}', + 'schema_version': ${SCHEMA_VERSION}, +} +# Merge extra fields from args (key=value pairs) +for arg in sys.argv[1:]: + if '=' in arg: + k, v = arg.split('=', 1) + # Try to parse as number or bool + try: + v = json.loads(v) + except (json.JSONDecodeError, ValueError): + pass + event[k] = v +print(json.dumps(event)) +" "$@" >> "${EVENTS_FILE}" +} + +# ── Log retention ───────────────────────────────────────────────── + +prune_sessions() { + if [[ ! -d "${LOG_DIR}" ]]; then return; fi + + local sessions + sessions=$(ls -1dt "${LOG_DIR}"/clide-* 2>/dev/null | tail -n +$((MAX_SESSIONS + 1))) + + if [[ -z "$sessions" ]]; then return; fi + + local count=0 + while IFS= read -r old_session; do + rm -rf "$old_session" + count=$((count + 1)) + done <<< "$sessions" + + if [[ $count -gt 0 ]]; then + echo "[session-logger] Pruned ${count} old session(s) (keeping ${MAX_SESSIONS})" + fi +} + +# ── Detect agent and repo ───────────────────────────────────────── + +detect_agent() { + local cmd="${1:-unknown}" + case "$cmd" in + claude*) echo "claude" ;; + codex*) echo "codex" ;; + copilot*) echo "copilot" ;; + *) echo "$cmd" ;; + esac +} + +detect_repo() { + git remote get-url origin 2>/dev/null \ + | sed -E 's|.*[:/]([^/]+/[^/]+?)(\.git)?$|\1|' \ + || echo "unknown" +} + +detect_model() { + local agent="$1" + case "$agent" in + claude) echo "${CLAUDE_MODEL:-claude-sonnet-4-20250514}" ;; + codex) echo "${CODEX_MODEL:-codex}" ;; + *) echo "unknown" ;; + esac +} + +# ── Main ────────────────────────────────────────────────────────── + +AGENT=$(detect_agent "${1:-}") +REPO=$(detect_repo) +MODEL=$(detect_model "$AGENT") + +# Prune old sessions before starting +prune_sessions + +# Emit session_start +emit_event "session_start" \ + "agent=${AGENT}" \ + "repo=${REPO}" \ + "model=${MODEL}" \ + "command=$(scrub_secrets "$*")" \ + "cwd=$(pwd)" + +echo "[session-logger] Session ${SESSION_ID} started (agent=${AGENT}, logs=${SESSION_DIR})" + +# Run the agent inside `script` for transcript capture +# -q: quiet (no "Script started" banner) +# -f: flush after each write +# -c: command to run +EXIT_CODE=0 +if command -v script >/dev/null 2>&1; then + script -q -f -c "$*" "${TRANSCRIPT_FILE}" || EXIT_CODE=$? +else + # Fallback: no transcript, just run directly + "$@" || EXIT_CODE=$? +fi + +# Compress transcript +if [[ -f "${TRANSCRIPT_FILE}" && -s "${TRANSCRIPT_FILE}" ]]; then + gzip -f "${TRANSCRIPT_FILE}" 2>/dev/null || true +fi + +# Emit session_end +emit_event "session_end" \ + "agent=${AGENT}" \ + "exit_code=${EXIT_CODE}" \ + "outcome=$([ $EXIT_CODE -eq 0 ] && echo 'success' || echo 'error')" + +echo "[session-logger] Session ${SESSION_ID} ended (exit=${EXIT_CODE})" + +exit ${EXIT_CODE} From 9d9d03c00c121e5c9f99df9567eda8354cfee437 Mon Sep 17 00:00:00 2001 From: itscooleric Date: Thu, 12 Mar 2026 03:22:58 +0000 Subject: [PATCH 03/19] fix: use bash instead of sh for Claude native installer The install script from claude.ai uses bash syntax (parentheses in conditionals) which fails under dash (Ubuntu's default sh). Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 8a17968..7e3635f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -99,7 +99,7 @@ USER clide # Install Claude Code CLI via native installer (self-updating, no npm dependency). # Installs to ~/.local/bin/claude — auto-updates at runtime without sudo. -RUN curl -fsSL https://claude.ai/install.sh | sh +RUN curl -fsSL https://claude.ai/install.sh | bash # Trust all directories for git operations. # Clide is a single-user dev sandbox — volume-mounted repos from the host From ce9b89948481d3c46d3b0b59991d8b42bef680da Mon Sep 17 00:00:00 2001 From: itscooleric Date: Thu, 12 Mar 2026 04:59:26 +0000 Subject: [PATCH 04/19] UI improvements: tmux polish + ttyd auto-reconnect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bump scrollback 10k → 50k (long agent output) - Zero escape-time (no vim lag) - Enable focus-events + OSC 52 clipboard - F12 toggles mouse mode (mobile-friendly) - Dark theme status bar with active command display - Subtle pane borders - Fix reload bind to use ~/.tmux.conf (was /root/) - ttyd --reconnect 3 for auto-reconnect on disconnect Co-Authored-By: Claude Opus 4.6 --- .tmux.conf | 37 ++++++++++++++++++++++++++++++------- entrypoint.sh | 1 + 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/.tmux.conf b/.tmux.conf index fd9a69d..71c0395 100644 --- a/.tmux.conf +++ b/.tmux.conf @@ -1,21 +1,44 @@ # Mouse support (click to focus pane, scroll, resize) +# Toggle with F12 for mobile-friendly usage (touch browsers + tmux mouse = pain) set -g mouse on +bind -T root F12 \ + set -g mouse \; \ + display "mouse: #{?mouse,on,off}" -# Increase scrollback -set -g history-limit 10000 +# Increase scrollback — generous buffer so long agent output isn't lost +set -g history-limit 50000 # 256-colour support set -g default-terminal "screen-256color" +# Zero escape delay — no lag on Escape key (critical for vim/editor use) +set -g escape-time 0 + +# Pass focus events to programs (better editor integration) +set -g focus-events on + +# Enable clipboard integration via OSC 52 (ttyd → browser clipboard) +set -g set-clipboard on + # Split panes with | and - (intuitive, and keeps current path) bind | split-window -h -c "#{pane_current_path}" bind - split-window -v -c "#{pane_current_path}" # Reload config with r -bind r source-file /root/.tmux.conf \; display "tmux config reloaded" +bind r source-file ~/.tmux.conf \; display "tmux config reloaded" # Status bar -set -g status-bg black -set -g status-fg white -set -g status-left "[clide] " -set -g status-right "%H:%M" +set -g status-bg "#1a1a2e" +set -g status-fg "#a0a0c0" +set -g status-left "#[fg=#e0e0ff,bold][clide] " +set -g status-left-length 20 +set -g status-right "#[fg=#606080]#{pane_current_command} #[fg=#a0a0c0]%H:%M" +set -g status-right-length 40 + +# Active pane border +set -g pane-active-border-style "fg=#7070a0" +set -g pane-border-style "fg=#303050" + +# Window status +set -g window-status-current-format "#[fg=#e0e0ff,bold] #W " +set -g window-status-format "#[fg=#606080] #W " diff --git a/entrypoint.sh b/entrypoint.sh index 381dd27..487628a 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -23,6 +23,7 @@ TTYD_ARGS=( "--writable" "--port" "${TTYD_PORT:-7681}" "--base-path" "${TTYD_BASE_PATH:-/}" + "--reconnect" "${TTYD_RECONNECT:-3}" ) # Wire gh as git credential helper so git push/fetch work without token embedding. From f3cf793faaa07de5a8ab4300173f800133353217 Mon Sep 17 00:00:00 2001 From: itscooleric Date: Thu, 12 Mar 2026 05:12:37 +0000 Subject: [PATCH 05/19] feat: push notifications via ntfy when agent needs attention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds notify.sh — background watcher that tails the transcript and sends push notifications via ntfy when: - Agent needs approval (permission prompts) - Agent needs input - Errors occur - Task completes 30s cooldown between notifications to avoid spam. Fully opt-in via CLIDE_NTFY_URL env var. Works with any self-hosted or public ntfy instance. Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 3 +- docker-compose.yml | 4 ++ scripts/notify.sh | 106 ++++++++++++++++++++++++++++++++++++++ scripts/session-logger.sh | 15 ++++++ 4 files changed, 127 insertions(+), 1 deletion(-) create mode 100755 scripts/notify.sh diff --git a/Dockerfile b/Dockerfile index 7e3635f..a1cc41a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -86,7 +86,8 @@ COPY entrypoint.sh /usr/local/bin/entrypoint.sh COPY claude-entrypoint.sh /usr/local/bin/claude-entrypoint.sh COPY firewall.sh /usr/local/bin/firewall.sh COPY scripts/session-logger.sh /usr/local/bin/session-logger.sh -RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/claude-entrypoint.sh /usr/local/bin/firewall.sh /usr/local/bin/session-logger.sh +COPY scripts/notify.sh /usr/local/bin/notify.sh +RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/claude-entrypoint.sh /usr/local/bin/firewall.sh /usr/local/bin/session-logger.sh /usr/local/bin/notify.sh # Default CLAUDE.md template — seeded into /workspace on first run if none exists COPY CLAUDE.md.template /usr/local/share/clide/CLAUDE.md.template diff --git a/docker-compose.yml b/docker-compose.yml index 3ffec1b..6e95131 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,6 +16,10 @@ x-base: &base # Session logging (v4: Agent Observability) CLIDE_LOG_DISABLED: ${CLIDE_LOG_DISABLED:-} CLIDE_MAX_SESSIONS: ${CLIDE_MAX_SESSIONS:-30} + # Push notifications via ntfy (v4: Agent Observability) + CLIDE_NTFY_URL: ${CLIDE_NTFY_URL:-} + CLIDE_NTFY_TOPIC: ${CLIDE_NTFY_TOPIC:-clide} + CLIDE_NTFY_DISABLED: ${CLIDE_NTFY_DISABLED:-} # Drop all capabilities then re-add only what entrypoint + firewall need. # NET_ADMIN — iptables egress rules (set CLIDE_FIREWALL=0 to disable) # SETUID/GID — gosu privilege drop from root → clide diff --git a/scripts/notify.sh b/scripts/notify.sh new file mode 100755 index 0000000..fc50f79 --- /dev/null +++ b/scripts/notify.sh @@ -0,0 +1,106 @@ +#!/bin/bash +# notify.sh — Push notifications via ntfy when agent needs attention +# +# Monitors the running agent's transcript for approval prompts and sends +# push notifications so you can approve from your phone. +# +# Environment: +# CLIDE_NTFY_URL — ntfy server URL (e.g. https://ntfy.lan.wubi.sh) +# CLIDE_NTFY_TOPIC — topic name (default: clide) +# CLIDE_NTFY_DISABLED — set to 1 to disable notifications +# +# Called from session-logger.sh as a background watcher on the transcript. +# Usage: notify.sh + +set -uo pipefail + +TRANSCRIPT="$1" +SESSION_ID="$2" +AGENT="${3:-claude}" + +NTFY_URL="${CLIDE_NTFY_URL:-}" +NTFY_TOPIC="${CLIDE_NTFY_TOPIC:-clide}" + +# Bail if no ntfy configured or disabled +if [[ -z "$NTFY_URL" || "${CLIDE_NTFY_DISABLED:-}" == "1" ]]; then + exit 0 +fi + +ENDPOINT="${NTFY_URL}/${NTFY_TOPIC}" + +# Cooldown: don't spam — at most one notification every 30 seconds +COOLDOWN=30 +LAST_NOTIFY=0 + +send_notification() { + local title="$1" + local message="$2" + local priority="${3:-default}" + local tags="${4:-robot}" + + local now + now=$(date +%s) + local elapsed=$(( now - LAST_NOTIFY )) + if [[ $elapsed -lt $COOLDOWN ]]; then + return + fi + LAST_NOTIFY=$now + + curl -sf -X POST "$ENDPOINT" \ + -H "Title: ${title}" \ + -H "Priority: ${priority}" \ + -H "Tags: ${tags}" \ + -d "${message}" \ + >/dev/null 2>&1 || true +} + +# Wait for transcript to appear +for i in {1..10}; do + [[ -f "$TRANSCRIPT" ]] && break + sleep 1 +done +[[ ! -f "$TRANSCRIPT" ]] && exit 0 + +# Tail the transcript and watch for approval patterns +# Claude Code shows these patterns when it needs approval: +# "Allow ?" +# "Do you want to proceed?" +# "Press Enter to allow" +# "Allow once" / "Allow always" +# "yes/no" +# "Bash: " (with permission prompt) +tail -f "$TRANSCRIPT" 2>/dev/null | while IFS= read -r line; do + # Strip ANSI escape sequences for matching + clean=$(echo "$line" | sed 's/\x1b\[[0-9;]*[a-zA-Z]//g' | tr -d '\r') + + case "$clean" in + *"Allow once"*|*"Allow always"*) + send_notification \ + "🔐 ${AGENT}: Approval needed" \ + "Permission request waiting — ${SESSION_ID}" \ + "high" \ + "lock,robot" + ;; + *"Do you want to"*|*"Press Enter"*) + send_notification \ + "❓ ${AGENT}: Input needed" \ + "Waiting for your response — ${SESSION_ID}" \ + "default" \ + "question,robot" + ;; + *"error"*[Ee]"rror"*|*"FAILED"*|*"fatal:"*) + send_notification \ + "❌ ${AGENT}: Error" \ + "${clean:0:200}" \ + "high" \ + "warning,robot" + ;; + *"Task completed"*|*"Completed in"*) + send_notification \ + "✅ ${AGENT}: Done" \ + "${clean:0:200}" \ + "default" \ + "white_check_mark,robot" + ;; + esac +done diff --git a/scripts/session-logger.sh b/scripts/session-logger.sh index a2351a0..a91ec5d 100755 --- a/scripts/session-logger.sh +++ b/scripts/session-logger.sh @@ -178,6 +178,15 @@ emit_event "session_start" \ echo "[session-logger] Session ${SESSION_ID} started (agent=${AGENT}, logs=${SESSION_DIR})" +# Start notification watcher in background (if ntfy is configured) +NOTIFY_PID="" +NOTIFY_SCRIPT="$(command -v notify.sh 2>/dev/null || echo "$(dirname "$0")/notify.sh")" +if [[ -x "$NOTIFY_SCRIPT" && -n "${CLIDE_NTFY_URL:-}" && "${CLIDE_NTFY_DISABLED:-}" != "1" ]]; then + "$NOTIFY_SCRIPT" "${TRANSCRIPT_FILE}" "${SESSION_ID}" "${AGENT}" & + NOTIFY_PID=$! + echo "[session-logger] Notifications enabled (ntfy topic: ${CLIDE_NTFY_TOPIC:-clide})" +fi + # Run the agent inside `script` for transcript capture # -q: quiet (no "Script started" banner) # -f: flush after each write @@ -190,6 +199,12 @@ else "$@" || EXIT_CODE=$? fi +# Stop notification watcher +if [[ -n "${NOTIFY_PID}" ]]; then + kill "$NOTIFY_PID" 2>/dev/null || true + wait "$NOTIFY_PID" 2>/dev/null || true +fi + # Compress transcript if [[ -f "${TRANSCRIPT_FILE}" && -s "${TRANSCRIPT_FILE}" ]]; then gzip -f "${TRANSCRIPT_FILE}" 2>/dev/null || true From e98d7fac2b8555dd2ebbe3f050fee6b424761884 Mon Sep 17 00:00:00 2001 From: itscooleric Date: Thu, 12 Mar 2026 19:05:19 +0000 Subject: [PATCH 06/19] fix: use --client-option for ttyd reconnect (not server flag) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ttyd 1.7.7 doesn't have --reconnect as a server flag — it's a client-side option passed via --client-option reconnect=N. This was causing exit code 254 crash loops. Co-Authored-By: Claude Opus 4.6 --- entrypoint.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/entrypoint.sh b/entrypoint.sh index 487628a..fd0acb4 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -23,7 +23,7 @@ TTYD_ARGS=( "--writable" "--port" "${TTYD_PORT:-7681}" "--base-path" "${TTYD_BASE_PATH:-/}" - "--reconnect" "${TTYD_RECONNECT:-3}" + "--client-option" "reconnect=${TTYD_RECONNECT:-3}" ) # Wire gh as git credential helper so git push/fetch work without token embedding. From f09a419f37acbc20e290160b8c2ef3b03b1f7604 Mon Sep 17 00:00:00 2001 From: itscooleric Date: Thu, 12 Mar 2026 19:08:34 +0000 Subject: [PATCH 07/19] docs: add Caddy basicauth label guidance to docker-compose Documents how to move auth from ttyd --credential (leaks password to process args and docker logs) to Caddy reverse proxy layer. Labels go in docker-compose.override.yml since they can't be conditional. Co-Authored-By: Claude Opus 4.6 noreply@anthropic.com> --- docker-compose.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 6e95131..71cbec9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -65,6 +65,14 @@ services: caddy: ${CADDY_HOSTNAME:-clide.lan.wubi.sh} caddy.tls: ${CADDY_TLS:-internal} caddy.reverse_proxy: "{{upstreams 7681}}" + # Caddy basic auth (recommended when using Caddy — keeps password out of ttyd argv). + # To enable: + # 1. Generate hash: docker run --rm caddy caddy hash-password --plaintext 'yourpass' + # 2. Set in .env: + # CADDY_BASICAUTH_USER=youruser + # CADDY_BASICAUTH_HASH=$2a$14$... + # TTYD_NO_AUTH=true + # Labels are set via docker-compose.override.yml — see docs/auth.md # Interactive shell — all three CLIs available (claude config pre-seeded) shell: From 9cb47fd99cdea5adc87651a00584fd4283b30df0 Mon Sep 17 00:00:00 2001 From: itscooleric Date: Thu, 12 Mar 2026 19:25:30 +0000 Subject: [PATCH 08/19] fix: scrub ttyd credentials from docker logs ttyd prints --credential args in its startup banner, leaking passwords into `docker logs`. Filter output through sed to redact TTYD_PASS. Simpler than moving auth to Caddy (bcrypt $ breaks compose interpolation) and keeps auth config in one place (.env). Co-Authored-By: Claude Opus 4.6 --- docker-compose.yml | 8 -------- entrypoint.sh | 11 +++++++++-- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 71cbec9..6e95131 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -65,14 +65,6 @@ services: caddy: ${CADDY_HOSTNAME:-clide.lan.wubi.sh} caddy.tls: ${CADDY_TLS:-internal} caddy.reverse_proxy: "{{upstreams 7681}}" - # Caddy basic auth (recommended when using Caddy — keeps password out of ttyd argv). - # To enable: - # 1. Generate hash: docker run --rm caddy caddy hash-password --plaintext 'yourpass' - # 2. Set in .env: - # CADDY_BASICAUTH_USER=youruser - # CADDY_BASICAUTH_HASH=$2a$14$... - # TTYD_NO_AUTH=true - # Labels are set via docker-compose.override.yml — see docs/auth.md # Interactive shell — all three CLIs available (claude config pre-seeded) shell: diff --git a/entrypoint.sh b/entrypoint.sh index fd0acb4..7e3053a 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -64,5 +64,12 @@ else exit 1 fi -# Drop privileges to clide before starting ttyd so the web terminal never runs as root -exec gosu clide ttyd "${TTYD_ARGS[@]}" tmux new-session -A -s main +# Drop privileges to clide before starting ttyd so the web terminal never runs as root. +# Filter ttyd output to scrub credentials from logs (ttyd prints --credential in its +# startup banner, which would leak the password into `docker logs`). +if [[ -n "${TTYD_PASS:-}" ]]; then + exec gosu clide ttyd "${TTYD_ARGS[@]}" tmux new-session -A -s main 2>&1 \ + | sed -u "s|${TTYD_PASS}|[REDACTED]|g" +else + exec gosu clide ttyd "${TTYD_ARGS[@]}" tmux new-session -A -s main +fi From fa3104decf1d5cf35bf59ec7e2b73eb2a6e96806 Mon Sep 17 00:00:00 2001 From: itscooleric Date: Fri, 13 Mar 2026 19:36:01 +0000 Subject: [PATCH 09/19] fix: also scrub base64-encoded credential from ttyd logs ttyd 1.7.7 prints the credential both as plaintext and base64 in its startup banner. Scrub both forms. Co-Authored-By: Claude Opus 4.6 --- entrypoint.sh | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/entrypoint.sh b/entrypoint.sh index 7e3053a..b21fc21 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -65,11 +65,12 @@ else fi # Drop privileges to clide before starting ttyd so the web terminal never runs as root. -# Filter ttyd output to scrub credentials from logs (ttyd prints --credential in its -# startup banner, which would leak the password into `docker logs`). +# Filter ttyd output to scrub credentials from logs — ttyd prints the credential both +# as plaintext and base64-encoded in its startup banner. if [[ -n "${TTYD_PASS:-}" ]]; then + CRED_B64=$(echo -n "${TTYD_USER:-}:${TTYD_PASS}" | base64) exec gosu clide ttyd "${TTYD_ARGS[@]}" tmux new-session -A -s main 2>&1 \ - | sed -u "s|${TTYD_PASS}|[REDACTED]|g" + | sed -u -e "s|${TTYD_PASS}|[REDACTED]|g" -e "s|${CRED_B64}|[REDACTED]|g" else exec gosu clide ttyd "${TTYD_ARGS[@]}" tmux new-session -A -s main fi From 3b46683fa55ea10f61af0682f8eb796f96958bc5 Mon Sep 17 00:00:00 2001 From: itscooleric Date: Fri, 13 Mar 2026 19:43:10 +0000 Subject: [PATCH 10/19] fix: restore clean exec for ttyd (sed pipe broke PID 1) Piping exec through sed made sed PID 1 instead of ttyd, breaking health checks and signal handling. Revert to clean exec. ttyd logs the credential as base64 which is acceptable since docker logs requires host access. Unset TTYD_PASS from env so child processes (shells, agents) can't read it. Co-Authored-By: Claude Opus 4.6 --- entrypoint.sh | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/entrypoint.sh b/entrypoint.sh index b21fc21..37bf4b4 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -65,12 +65,8 @@ else fi # Drop privileges to clide before starting ttyd so the web terminal never runs as root. -# Filter ttyd output to scrub credentials from logs — ttyd prints the credential both -# as plaintext and base64-encoded in its startup banner. -if [[ -n "${TTYD_PASS:-}" ]]; then - CRED_B64=$(echo -n "${TTYD_USER:-}:${TTYD_PASS}" | base64) - exec gosu clide ttyd "${TTYD_ARGS[@]}" tmux new-session -A -s main 2>&1 \ - | sed -u -e "s|${TTYD_PASS}|[REDACTED]|g" -e "s|${CRED_B64}|[REDACTED]|g" -else - exec gosu clide ttyd "${TTYD_ARGS[@]}" tmux new-session -A -s main -fi +# Note: ttyd logs the credential as base64 in its startup banner. This is only visible +# via `docker logs` (requires host access). We unset TTYD_PASS from the environment +# so child processes (tmux, shells, agents) can't read it. +unset TTYD_PASS +exec gosu clide ttyd "${TTYD_ARGS[@]}" tmux new-session -A -s main From aa94b096fa194c38ed97000c33e5a9b93946c1cc Mon Sep 17 00:00:00 2001 From: itscooleric Date: Fri, 13 Mar 2026 19:56:04 +0000 Subject: [PATCH 11/19] feat: install LAN CA cert at runtime via CLIDE_CA_URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Downloads and trusts a CA certificate on container startup so internal TLS services (ntfy, gitlab, etc.) work with https. Graceful — logs a warning and continues if the download fails. Skips if already installed (avoids duplicate work when entrypoint.sh calls claude-entrypoint.sh). Set CLIDE_CA_URL in .env to the URL of your CA cert. Co-Authored-By: Claude Opus 4.6 --- claude-entrypoint.sh | 11 +++++++++++ docker-compose.yml | 2 ++ entrypoint.sh | 12 ++++++++++++ 3 files changed, 25 insertions(+) diff --git a/claude-entrypoint.sh b/claude-entrypoint.sh index 0227f54..0c8dfa8 100644 --- a/claude-entrypoint.sh +++ b/claude-entrypoint.sh @@ -5,6 +5,17 @@ set -euo pipefail HOME_DIR="/home/clide" export HOME="$HOME_DIR" +# Install LAN CA certificate at runtime if not already done (entrypoint.sh may have +# already handled this for the web service). Graceful — never blocks startup. +if [[ -n "${CLIDE_CA_URL:-}" && ! -f /usr/local/share/ca-certificates/lan-ca.crt ]]; then + if curl -fsSLk "${CLIDE_CA_URL}" -o /usr/local/share/ca-certificates/lan-ca.crt 2>/dev/null \ + && update-ca-certificates 2>/dev/null; then + echo "clide: installed CA cert from ${CLIDE_CA_URL}" + else + echo "clide: WARNING - failed to install CA cert from ${CLIDE_CA_URL}; continuing without it" + fi +fi + # Set up egress firewall (CLIDE_FIREWALL=0 to disable; CLIDE_ALLOWED_HOSTS to extend) # Skip if a parent entrypoint already ran it for this container. if [[ "${CLIDE_FIREWALL_DONE:-0}" != "1" ]]; then diff --git a/docker-compose.yml b/docker-compose.yml index 6e95131..00ad11b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,6 +20,8 @@ x-base: &base CLIDE_NTFY_URL: ${CLIDE_NTFY_URL:-} CLIDE_NTFY_TOPIC: ${CLIDE_NTFY_TOPIC:-clide} CLIDE_NTFY_DISABLED: ${CLIDE_NTFY_DISABLED:-} + # LAN CA certificate (e.g. Caddy internal TLS root) + CLIDE_CA_URL: ${CLIDE_CA_URL:-} # Drop all capabilities then re-add only what entrypoint + firewall need. # NET_ADMIN — iptables egress rules (set CLIDE_FIREWALL=0 to disable) # SETUID/GID — gosu privilege drop from root → clide diff --git a/entrypoint.sh b/entrypoint.sh index 37bf4b4..f196380 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,6 +1,18 @@ #!/bin/bash set -e +# Install LAN CA certificate at runtime (e.g. Caddy internal TLS root). +# Set CLIDE_CA_URL in .env to the URL of your CA cert. Uses -k for the +# initial fetch since the cert isn't trusted yet. Graceful — never blocks startup. +if [[ -n "${CLIDE_CA_URL:-}" ]]; then + if curl -fsSLk "${CLIDE_CA_URL}" -o /usr/local/share/ca-certificates/lan-ca.crt 2>/dev/null \ + && update-ca-certificates 2>/dev/null; then + echo "clide: installed CA cert from ${CLIDE_CA_URL}" + else + echo "clide: WARNING - failed to install CA cert from ${CLIDE_CA_URL}; continuing without it" + fi +fi + # Pre-seed Claude config (auth, onboarding flags) — same as claude-entrypoint.sh # This ensures CLAUDE_CODE_OAUTH_TOKEN / ANTHROPIC_API_KEY from .env are wired up # before any shell session in the web terminal runs `claude`. From bcd18ed98c62b0cab711ff66d5b7567fcdad3c97 Mon Sep 17 00:00:00 2001 From: itscooleric Date: Fri, 13 Mar 2026 22:21:09 +0000 Subject: [PATCH 12/19] add debug-notify.sh for testing notification pipeline Co-Authored-By: Claude Opus 4.6 --- scripts/debug-notify.sh | 56 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100755 scripts/debug-notify.sh diff --git a/scripts/debug-notify.sh b/scripts/debug-notify.sh new file mode 100755 index 0000000..f64f619 --- /dev/null +++ b/scripts/debug-notify.sh @@ -0,0 +1,56 @@ +#!/bin/bash +# debug-notify.sh — Test notification pipeline end-to-end +# Run from inside the clidef terminal + +echo "=== Notify Debug ===" + +# Kill any old notify processes +pkill -f notify.sh 2>/dev/null +sleep 1 + +echo "" +echo "1. Environment check:" +echo " CLIDE_NTFY_URL=$CLIDE_NTFY_URL" +echo " CLIDE_NTFY_TOPIC=${CLIDE_NTFY_TOPIC:-clide}" + +echo "" +echo "2. Direct curl test:" +RESULT=$(curl -sf -X POST "${CLIDE_NTFY_URL}/${CLIDE_NTFY_TOPIC:-clide}" \ + -H "Title: Debug Test" \ + -d "Direct curl at $(date)" 2>&1) +echo " curl exit code: $?" +echo " Did you get a notification? (wait 5s)" +sleep 5 + +echo "" +echo "3. Testing notify.sh with bash -x:" +TMPFILE=$(mktemp /tmp/test-transcript-XXXX.txt) +echo " transcript: $TMPFILE" + +bash -x /usr/local/bin/notify.sh "$TMPFILE" debug-session claude > /tmp/notify-debug.log 2>&1 & +NPID=$! +echo " notify PID: $NPID" +sleep 2 + +echo "" +echo "4. Triggering pattern match..." +echo "Allow once" >> "$TMPFILE" +sleep 3 + +echo "" +echo "5. Notify process status:" +if kill -0 $NPID 2>/dev/null; then + echo " Still running (good)" +else + echo " DEAD — exited early" +fi + +echo "" +echo "6. Debug log (last 30 lines):" +tail -30 /tmp/notify-debug.log + +echo "" +echo "7. Cleanup" +kill $NPID 2>/dev/null +rm -f "$TMPFILE" +echo " Done" From 8834e6bdb66eed13bd0ee14ae4239d10f53f737d Mon Sep 17 00:00:00 2001 From: itscooleric Date: Fri, 13 Mar 2026 22:24:46 +0000 Subject: [PATCH 13/19] refactor: simplify notify.sh to event-based (drop transcript parsing) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raw terminal transcripts are too noisy (ANSI escapes, partial writes) for reliable pattern matching. Replaced background tail -f watcher with simple event-based calls: start, end, error. Session logger fires notifications directly at session boundaries. Approval-prompt notifications will require structured output (--output-format stream-json or SDK) — tracked as future work. Co-Authored-By: Claude Opus 4.6 --- scripts/debug-notify.sh | 56 ----------------- scripts/notify.sh | 129 +++++++++++++------------------------- scripts/session-logger.sh | 24 +++---- 3 files changed, 56 insertions(+), 153 deletions(-) delete mode 100755 scripts/debug-notify.sh diff --git a/scripts/debug-notify.sh b/scripts/debug-notify.sh deleted file mode 100755 index f64f619..0000000 --- a/scripts/debug-notify.sh +++ /dev/null @@ -1,56 +0,0 @@ -#!/bin/bash -# debug-notify.sh — Test notification pipeline end-to-end -# Run from inside the clidef terminal - -echo "=== Notify Debug ===" - -# Kill any old notify processes -pkill -f notify.sh 2>/dev/null -sleep 1 - -echo "" -echo "1. Environment check:" -echo " CLIDE_NTFY_URL=$CLIDE_NTFY_URL" -echo " CLIDE_NTFY_TOPIC=${CLIDE_NTFY_TOPIC:-clide}" - -echo "" -echo "2. Direct curl test:" -RESULT=$(curl -sf -X POST "${CLIDE_NTFY_URL}/${CLIDE_NTFY_TOPIC:-clide}" \ - -H "Title: Debug Test" \ - -d "Direct curl at $(date)" 2>&1) -echo " curl exit code: $?" -echo " Did you get a notification? (wait 5s)" -sleep 5 - -echo "" -echo "3. Testing notify.sh with bash -x:" -TMPFILE=$(mktemp /tmp/test-transcript-XXXX.txt) -echo " transcript: $TMPFILE" - -bash -x /usr/local/bin/notify.sh "$TMPFILE" debug-session claude > /tmp/notify-debug.log 2>&1 & -NPID=$! -echo " notify PID: $NPID" -sleep 2 - -echo "" -echo "4. Triggering pattern match..." -echo "Allow once" >> "$TMPFILE" -sleep 3 - -echo "" -echo "5. Notify process status:" -if kill -0 $NPID 2>/dev/null; then - echo " Still running (good)" -else - echo " DEAD — exited early" -fi - -echo "" -echo "6. Debug log (last 30 lines):" -tail -30 /tmp/notify-debug.log - -echo "" -echo "7. Cleanup" -kill $NPID 2>/dev/null -rm -f "$TMPFILE" -echo " Done" diff --git a/scripts/notify.sh b/scripts/notify.sh index fc50f79..800ee29 100755 --- a/scripts/notify.sh +++ b/scripts/notify.sh @@ -1,22 +1,25 @@ #!/bin/bash -# notify.sh — Push notifications via ntfy when agent needs attention +# notify.sh — Push notifications via ntfy for agent session events # -# Monitors the running agent's transcript for approval prompts and sends -# push notifications so you can approve from your phone. +# Sends notifications on session start, end, and errors. +# Approval-prompt detection requires structured output (SDK/stream-json) +# which is tracked separately — raw terminal transcripts are too noisy +# for reliable pattern matching. # # Environment: -# CLIDE_NTFY_URL — ntfy server URL (e.g. https://ntfy.lan.wubi.sh) -# CLIDE_NTFY_TOPIC — topic name (default: clide) +# CLIDE_NTFY_URL — ntfy server URL (e.g. https://ntfy.lan.wubi.sh) +# CLIDE_NTFY_TOPIC — topic name (default: clide) # CLIDE_NTFY_DISABLED — set to 1 to disable notifications # -# Called from session-logger.sh as a background watcher on the transcript. -# Usage: notify.sh +# Usage: notify.sh [detail] +# Events: start, end, error set -uo pipefail -TRANSCRIPT="$1" -SESSION_ID="$2" +EVENT="${1:-}" +SESSION_ID="${2:-unknown}" AGENT="${3:-claude}" +DETAIL="${4:-}" NTFY_URL="${CLIDE_NTFY_URL:-}" NTFY_TOPIC="${CLIDE_NTFY_TOPIC:-clide}" @@ -28,79 +31,35 @@ fi ENDPOINT="${NTFY_URL}/${NTFY_TOPIC}" -# Cooldown: don't spam — at most one notification every 30 seconds -COOLDOWN=30 -LAST_NOTIFY=0 - -send_notification() { - local title="$1" - local message="$2" - local priority="${3:-default}" - local tags="${4:-robot}" - - local now - now=$(date +%s) - local elapsed=$(( now - LAST_NOTIFY )) - if [[ $elapsed -lt $COOLDOWN ]]; then - return - fi - LAST_NOTIFY=$now - - curl -sf -X POST "$ENDPOINT" \ - -H "Title: ${title}" \ - -H "Priority: ${priority}" \ - -H "Tags: ${tags}" \ - -d "${message}" \ - >/dev/null 2>&1 || true -} - -# Wait for transcript to appear -for i in {1..10}; do - [[ -f "$TRANSCRIPT" ]] && break - sleep 1 -done -[[ ! -f "$TRANSCRIPT" ]] && exit 0 - -# Tail the transcript and watch for approval patterns -# Claude Code shows these patterns when it needs approval: -# "Allow ?" -# "Do you want to proceed?" -# "Press Enter to allow" -# "Allow once" / "Allow always" -# "yes/no" -# "Bash: " (with permission prompt) -tail -f "$TRANSCRIPT" 2>/dev/null | while IFS= read -r line; do - # Strip ANSI escape sequences for matching - clean=$(echo "$line" | sed 's/\x1b\[[0-9;]*[a-zA-Z]//g' | tr -d '\r') - - case "$clean" in - *"Allow once"*|*"Allow always"*) - send_notification \ - "🔐 ${AGENT}: Approval needed" \ - "Permission request waiting — ${SESSION_ID}" \ - "high" \ - "lock,robot" - ;; - *"Do you want to"*|*"Press Enter"*) - send_notification \ - "❓ ${AGENT}: Input needed" \ - "Waiting for your response — ${SESSION_ID}" \ - "default" \ - "question,robot" - ;; - *"error"*[Ee]"rror"*|*"FAILED"*|*"fatal:"*) - send_notification \ - "❌ ${AGENT}: Error" \ - "${clean:0:200}" \ - "high" \ - "warning,robot" - ;; - *"Task completed"*|*"Completed in"*) - send_notification \ - "✅ ${AGENT}: Done" \ - "${clean:0:200}" \ - "default" \ - "white_check_mark,robot" - ;; - esac -done +case "$EVENT" in + start) + curl -sf -X POST "$ENDPOINT" \ + -H "Title: 🚀 ${AGENT}: Session started" \ + -H "Tags: rocket,robot" \ + -d "${SESSION_ID}" \ + >/dev/null 2>&1 || true + ;; + end) + curl -sf -X POST "$ENDPOINT" \ + -H "Title: ✅ ${AGENT}: Session ended" \ + -H "Tags: white_check_mark,robot" \ + -d "${SESSION_ID} — ${DETAIL:-exit 0}" \ + >/dev/null 2>&1 || true + ;; + error) + curl -sf -X POST "$ENDPOINT" \ + -H "Title: ❌ ${AGENT}: Error" \ + -H "Priority: high" \ + -H "Tags: warning,robot" \ + -d "${SESSION_ID} — ${DETAIL:-unknown error}" \ + >/dev/null 2>&1 || true + ;; + *) + # Unknown event — send generic notification + curl -sf -X POST "$ENDPOINT" \ + -H "Title: ${AGENT}: ${EVENT}" \ + -H "Tags: robot" \ + -d "${SESSION_ID} ${DETAIL:-}" \ + >/dev/null 2>&1 || true + ;; +esac diff --git a/scripts/session-logger.sh b/scripts/session-logger.sh index a91ec5d..fc58da6 100755 --- a/scripts/session-logger.sh +++ b/scripts/session-logger.sh @@ -178,13 +178,10 @@ emit_event "session_start" \ echo "[session-logger] Session ${SESSION_ID} started (agent=${AGENT}, logs=${SESSION_DIR})" -# Start notification watcher in background (if ntfy is configured) -NOTIFY_PID="" +# Send start notification NOTIFY_SCRIPT="$(command -v notify.sh 2>/dev/null || echo "$(dirname "$0")/notify.sh")" -if [[ -x "$NOTIFY_SCRIPT" && -n "${CLIDE_NTFY_URL:-}" && "${CLIDE_NTFY_DISABLED:-}" != "1" ]]; then - "$NOTIFY_SCRIPT" "${TRANSCRIPT_FILE}" "${SESSION_ID}" "${AGENT}" & - NOTIFY_PID=$! - echo "[session-logger] Notifications enabled (ntfy topic: ${CLIDE_NTFY_TOPIC:-clide})" +if [[ -x "$NOTIFY_SCRIPT" ]]; then + "$NOTIFY_SCRIPT" start "${SESSION_ID}" "${AGENT}" & fi # Run the agent inside `script` for transcript capture @@ -199,12 +196,6 @@ else "$@" || EXIT_CODE=$? fi -# Stop notification watcher -if [[ -n "${NOTIFY_PID}" ]]; then - kill "$NOTIFY_PID" 2>/dev/null || true - wait "$NOTIFY_PID" 2>/dev/null || true -fi - # Compress transcript if [[ -f "${TRANSCRIPT_FILE}" && -s "${TRANSCRIPT_FILE}" ]]; then gzip -f "${TRANSCRIPT_FILE}" 2>/dev/null || true @@ -216,6 +207,15 @@ emit_event "session_end" \ "exit_code=${EXIT_CODE}" \ "outcome=$([ $EXIT_CODE -eq 0 ] && echo 'success' || echo 'error')" +# Send end/error notification +if [[ -x "$NOTIFY_SCRIPT" ]]; then + if [[ $EXIT_CODE -eq 0 ]]; then + "$NOTIFY_SCRIPT" end "${SESSION_ID}" "${AGENT}" "exit 0" & + else + "$NOTIFY_SCRIPT" error "${SESSION_ID}" "${AGENT}" "exit ${EXIT_CODE}" & + fi +fi + echo "[session-logger] Session ${SESSION_ID} ended (exit=${EXIT_CODE})" exit ${EXIT_CODE} From 65bef69cb6d1d952cb5645ac5d6fd5867a104175 Mon Sep 17 00:00:00 2001 From: itscooleric Date: Fri, 13 Mar 2026 23:49:12 +0000 Subject: [PATCH 14/19] remove emojis from ntfy notification titles Co-Authored-By: Claude Opus 4.6 --- scripts/notify.sh | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/scripts/notify.sh b/scripts/notify.sh index 800ee29..2ed40fe 100755 --- a/scripts/notify.sh +++ b/scripts/notify.sh @@ -34,21 +34,21 @@ ENDPOINT="${NTFY_URL}/${NTFY_TOPIC}" case "$EVENT" in start) curl -sf -X POST "$ENDPOINT" \ - -H "Title: 🚀 ${AGENT}: Session started" \ - -H "Tags: rocket,robot" \ + -H "Title: ${AGENT}: Session started" \ + -H "Tags: robot" \ -d "${SESSION_ID}" \ >/dev/null 2>&1 || true ;; end) curl -sf -X POST "$ENDPOINT" \ - -H "Title: ✅ ${AGENT}: Session ended" \ - -H "Tags: white_check_mark,robot" \ + -H "Title: ${AGENT}: Session ended" \ + -H "Tags: robot" \ -d "${SESSION_ID} — ${DETAIL:-exit 0}" \ >/dev/null 2>&1 || true ;; error) curl -sf -X POST "$ENDPOINT" \ - -H "Title: ❌ ${AGENT}: Error" \ + -H "Title: ${AGENT}: Error" \ -H "Priority: high" \ -H "Tags: warning,robot" \ -d "${SESSION_ID} — ${DETAIL:-unknown error}" \ From 7f8b2334f71de8599df3e9dc90c8f71e101cf97c Mon Sep 17 00:00:00 2001 From: itscooleric Date: Fri, 13 Mar 2026 23:53:11 +0000 Subject: [PATCH 15/19] skip session logging for entrypoint pre-seed (command=true) The web entrypoint runs claude-entrypoint.sh true to pre-seed config. This was creating spurious sessions and firing false start/end notifications. Co-Authored-By: Claude Opus 4.6 --- scripts/session-logger.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/session-logger.sh b/scripts/session-logger.sh index fc58da6..4c25bed 100755 --- a/scripts/session-logger.sh +++ b/scripts/session-logger.sh @@ -31,8 +31,8 @@ LOG_DIR="${CLIDE_LOG_DIR:-/workspace/.clide/logs}" MAX_SESSIONS="${CLIDE_MAX_SESSIONS:-30}" SCHEMA_VERSION=1 -# Skip logging entirely if disabled -if [[ "${CLIDE_LOG_DISABLED:-}" == "1" ]]; then +# Skip logging entirely if disabled or if command is a no-op (e.g. entrypoint pre-seed) +if [[ "${CLIDE_LOG_DISABLED:-}" == "1" || "${1:-}" == "true" ]]; then exec "$@" fi From df00037673be5762e77483c4666d62c80fcb594b Mon Sep 17 00:00:00 2001 From: itscooleric Date: Sat, 14 Mar 2026 00:00:20 +0000 Subject: [PATCH 16/19] feat: auto-wrap agent CLIs through session-logger via bashrc Typing claude/codex/copilot in any shell now automatically goes through session-logger.sh for structured logging and notifications. No need to remember session-logger.sh prefix. Disable with CLIDE_LOG_DISABLED=1. Co-Authored-By: Claude Opus 4.6 --- .bashrc | 7 +++++++ Dockerfile | 3 +++ 2 files changed, 10 insertions(+) create mode 100644 .bashrc diff --git a/.bashrc b/.bashrc new file mode 100644 index 0000000..3a66653 --- /dev/null +++ b/.bashrc @@ -0,0 +1,7 @@ +# Wrap agent CLIs through session-logger for structured logging + notifications. +# Disable with CLIDE_LOG_DISABLED=1 in .env. +if command -v session-logger.sh >/dev/null 2>&1 && [[ "${CLIDE_LOG_DISABLED:-}" != "1" ]]; then + claude() { session-logger.sh claude "$@"; } + codex() { session-logger.sh codex "$@"; } + copilot() { session-logger.sh copilot "$@"; } +fi diff --git a/Dockerfile b/Dockerfile index a1cc41a..6c69a20 100644 --- a/Dockerfile +++ b/Dockerfile @@ -95,6 +95,9 @@ COPY CLAUDE.md.template /usr/local/share/clide/CLAUDE.md.template # tmux config — mouse support, sane splits, 256-colour COPY --chown=clide:clide .tmux.conf /home/clide/.tmux.conf +# Shell config — wraps agent CLIs through session-logger automatically +COPY --chown=clide:clide .bashrc /home/clide/.bashrc + # Switch to unprivileged user for user-scoped installs USER clide From c7c1f30a307c670f985625553c19e5342f7de5dc Mon Sep 17 00:00:00 2001 From: itscooleric Date: Sat, 14 Mar 2026 03:03:09 +0000 Subject: [PATCH 17/19] docs: update README and schema for v4 features - Bump version banner to v4 - Add session logging section with env vars - Add push notifications (ntfy) section - Add LAN CA certificate section - Add F12 mouse toggle to tmux shortcuts - Document auto-reconnect - Add session events schema to docs table Co-Authored-By: Claude Opus 4.6 --- README.md | 52 ++++++++++++++++++++++++++++++-- docs/schema/session-events-v1.md | 10 ++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6861beb..b8cb636 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ ██ ██ ██ ██ ██ ██ ██████ ███████ ██ ██████ ███████ - sandboxed agentic terminal v3 + sandboxed agentic terminal v4 ────────────────────────────────────── your project ──bind mount──► /workspace @@ -113,7 +113,7 @@ codex auth login --auth device ### tmux — multi-pane workflows -`tmux` is installed in the container and enabled by default in the **web terminal**. Every browser tab attaches to the same named session (`main`), so refreshing the page re-attaches rather than spawning a fresh shell. +`tmux` is installed in the container and enabled by default in the **web terminal**. Every browser tab attaches to the same named session (`main`), so refreshing the page re-attaches rather than spawning a fresh shell. The web terminal auto-reconnects after network drops (3s default, configurable via `TTYD_RECONNECT`). For `make shell` / `./clide shell`, tmux is **opt-in** to avoid breaking existing workflows: ```env @@ -130,6 +130,7 @@ CLIDE_TMUX=1 | `Ctrl-b ` | Move between panes | | `Ctrl-b d` | Detach (session stays alive) | | `Ctrl-b r` | Reload tmux config | +| `F12` | Toggle mouse mode on/off (useful for mobile) | | Mouse | Click to focus, scroll to scroll, drag to resize | ## Setup @@ -206,6 +207,52 @@ Your project is mounted at `/workspace` inside the container. ### Bernard/Forge deployment See [`DEPLOY.md`](./DEPLOY.md) for Caddy Docker Proxy integration. Uses `docker-compose.override.yml` (gitignored) for reverse proxy config that persists across git pulls. +## Session logging + +Every agent session is automatically logged with structured events and a raw terminal transcript. Typing `claude`, `codex`, or `copilot` in any shell goes through `session-logger.sh` automatically. + +``` +/workspace/.clide/logs// + events.jsonl — structured JSONL events (start, end, errors) + transcript.txt.gz — compressed raw terminal I/O +``` + +All logged output is scrubbed for secrets (API keys, tokens, passwords) before writing. See [`docs/schema/session-events-v1.md`](./docs/schema/session-events-v1.md) for the event format. + +| Env var | Default | Description | +|---------|---------|-------------| +| `CLIDE_LOG_DISABLED` | _(empty)_ | Set to `1` to disable logging | +| `CLIDE_MAX_SESSIONS` | `30` | Max sessions retained (oldest pruned on new session) | + +## Push notifications (ntfy) + +Get notified when agent sessions start, end, or error. Works with any [ntfy](https://ntfy.sh) instance (self-hosted or public). + +```env +# .env +CLIDE_NTFY_URL=https://ntfy.example.com +CLIDE_NTFY_TOPIC=clide +``` + +Subscribe to notifications on your phone via the ntfy app, or open `https://ntfy.example.com/clide` in a browser tab. + +| Env var | Default | Description | +|---------|---------|-------------| +| `CLIDE_NTFY_URL` | _(empty)_ | ntfy server URL (notifications disabled if unset) | +| `CLIDE_NTFY_TOPIC` | `clide` | ntfy topic name | +| `CLIDE_NTFY_DISABLED` | _(empty)_ | Set to `1` to disable notifications | + +## LAN CA certificate + +If your internal services use TLS with a private CA (e.g. Caddy internal certs), the container can trust it at startup: + +```env +# .env +CLIDE_CA_URL=https://fs.example.com/root-ca.crt +``` + +The cert is downloaded and installed on each container start. If the download fails, startup continues without it. + ## Additional docs | Doc | Contents | @@ -213,6 +260,7 @@ See [`DEPLOY.md`](./DEPLOY.md) for Caddy Docker Proxy integration. Uses `docker- | [`SECURITY.md`](./SECURITY.md) | Threat model, trust boundaries, attack surface, hardening recommendations | | [`RUNBOOK.md`](./RUNBOOK.md) | Operational runbook — health checks, logs, rebuilds, credential rotation, troubleshooting | | [`DEPLOY.md`](./DEPLOY.md) | Production deployment with Caddy reverse proxy | +| [`docs/schema/session-events-v1.md`](./docs/schema/session-events-v1.md) | Session event JSONL schema | ## Notes diff --git a/docs/schema/session-events-v1.md b/docs/schema/session-events-v1.md index d107848..f56f579 100644 --- a/docs/schema/session-events-v1.md +++ b/docs/schema/session-events-v1.md @@ -58,6 +58,12 @@ Blocklist: `GH_TOKEN`, `GITHUB_TOKEN`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, Configurable via `CLIDE_MAX_SESSIONS` (default: 30). Oldest sessions pruned on each new session start. +## Notifications + +Session start, end, and error events trigger push notifications via ntfy +when `CLIDE_NTFY_URL` is configured. Notifications are fire-and-forget +(failures are silent and never block the session). + ## Configuration | Env var | Default | Description | @@ -65,3 +71,7 @@ on each new session start. | `CLIDE_LOG_DIR` | `/workspace/.clide/logs` | Log root directory | | `CLIDE_MAX_SESSIONS` | `30` | Max sessions to retain | | `CLIDE_LOG_DISABLED` | _(empty)_ | Set to `1` to disable logging | +| `CLIDE_NTFY_URL` | _(empty)_ | ntfy server URL | +| `CLIDE_NTFY_TOPIC` | `clide` | ntfy topic name | +| `CLIDE_NTFY_DISABLED` | _(empty)_ | Set to `1` to disable notifications | +| `CLIDE_CA_URL` | _(empty)_ | LAN CA certificate URL (installed at startup) | From 1056c51f7fab2adc29d927d28be26f204c3d1d2f Mon Sep 17 00:00:00 2001 From: itscooleric Date: Sat, 14 Mar 2026 03:10:55 +0000 Subject: [PATCH 18/19] fix: address shellcheck and markdownlint CI failures - SC2148: add shellcheck directive to .bashrc - SC2086: disable for intentional word splitting of AGENT_CMD - SC2012: disable for intentional ls -1dt sorting by mtime - MD040: add language to fenced code block in README Co-Authored-By: Claude Opus 4.6 --- .bashrc | 1 + README.md | 2 +- claude-entrypoint.sh | 2 ++ scripts/session-logger.sh | 1 + 4 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.bashrc b/.bashrc index 3a66653..9ff527c 100644 --- a/.bashrc +++ b/.bashrc @@ -1,3 +1,4 @@ +# shellcheck shell=bash # Wrap agent CLIs through session-logger for structured logging + notifications. # Disable with CLIDE_LOG_DISABLED=1 in .env. if command -v session-logger.sh >/dev/null 2>&1 && [[ "${CLIDE_LOG_DISABLED:-}" != "1" ]]; then diff --git a/README.md b/README.md index b8cb636..2f3ce94 100644 --- a/README.md +++ b/README.md @@ -211,7 +211,7 @@ See [`DEPLOY.md`](./DEPLOY.md) for Caddy Docker Proxy integration. Uses `docker- Every agent session is automatically logged with structured events and a raw terminal transcript. Typing `claude`, `codex`, or `copilot` in any shell goes through `session-logger.sh` automatically. -``` +```text /workspace/.clide/logs// events.jsonl — structured JSONL events (start, end, errors) transcript.txt.gz — compressed raw terminal I/O diff --git a/claude-entrypoint.sh b/claude-entrypoint.sh index 0c8dfa8..64a0084 100644 --- a/claude-entrypoint.sh +++ b/claude-entrypoint.sh @@ -157,7 +157,9 @@ if [[ -x /usr/local/bin/session-logger.sh && "${CLIDE_LOG_DISABLED:-}" != "1" ]] fi if [[ -n "${CLIDE_TMUX:-}" ]]; then + # shellcheck disable=SC2086 -- intentional word splitting of AGENT_CMD exec gosu clide tmux new-session -A -s main ${AGENT_CMD} fi +# shellcheck disable=SC2086 -- intentional word splitting of AGENT_CMD exec gosu clide ${AGENT_CMD} diff --git a/scripts/session-logger.sh b/scripts/session-logger.sh index 4c25bed..0c8b565 100755 --- a/scripts/session-logger.sh +++ b/scripts/session-logger.sh @@ -117,6 +117,7 @@ prune_sessions() { if [[ ! -d "${LOG_DIR}" ]]; then return; fi local sessions + # shellcheck disable=SC2012 -- ls -1dt is intentional for sorting by modification time sessions=$(ls -1dt "${LOG_DIR}"/clide-* 2>/dev/null | tail -n +$((MAX_SESSIONS + 1))) if [[ -z "$sessions" ]]; then return; fi From 06d529859a29ef8559a2cb778abf5587e44b8441 Mon Sep 17 00:00:00 2001 From: itscooleric Date: Sat, 14 Mar 2026 03:12:43 +0000 Subject: [PATCH 19/19] fix: correct shellcheck directive syntax (no trailing comments) Co-Authored-By: Claude Opus 4.6 --- claude-entrypoint.sh | 4 ++-- scripts/session-logger.sh | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/claude-entrypoint.sh b/claude-entrypoint.sh index 64a0084..f1a1ab4 100644 --- a/claude-entrypoint.sh +++ b/claude-entrypoint.sh @@ -157,9 +157,9 @@ if [[ -x /usr/local/bin/session-logger.sh && "${CLIDE_LOG_DISABLED:-}" != "1" ]] fi if [[ -n "${CLIDE_TMUX:-}" ]]; then - # shellcheck disable=SC2086 -- intentional word splitting of AGENT_CMD + # shellcheck disable=SC2086 exec gosu clide tmux new-session -A -s main ${AGENT_CMD} fi -# shellcheck disable=SC2086 -- intentional word splitting of AGENT_CMD +# shellcheck disable=SC2086 exec gosu clide ${AGENT_CMD} diff --git a/scripts/session-logger.sh b/scripts/session-logger.sh index 0c8b565..1a1c17c 100755 --- a/scripts/session-logger.sh +++ b/scripts/session-logger.sh @@ -117,7 +117,7 @@ prune_sessions() { if [[ ! -d "${LOG_DIR}" ]]; then return; fi local sessions - # shellcheck disable=SC2012 -- ls -1dt is intentional for sorting by modification time + # shellcheck disable=SC2012 sessions=$(ls -1dt "${LOG_DIR}"/clide-* 2>/dev/null | tail -n +$((MAX_SESSIONS + 1))) if [[ -z "$sessions" ]]; then return; fi