diff --git a/.bashrc b/.bashrc new file mode 100644 index 0000000..9ff527c --- /dev/null +++ b/.bashrc @@ -0,0 +1,8 @@ +# 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 + claude() { session-logger.sh claude "$@"; } + codex() { session-logger.sh codex "$@"; } + copilot() { session-logger.sh copilot "$@"; } +fi 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/.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/Dockerfile b/Dockerfile index 4c8b92b..6c69a20 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). @@ -89,7 +85,9 @@ 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 +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 @@ -97,9 +95,16 @@ 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 +# 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 | bash + # 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 diff --git a/README.md b/README.md index 6861beb..2f3ce94 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. + +```text +/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/claude-entrypoint.sh b/claude-entrypoint.sh index c859833..f1a1ab4 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 @@ -137,8 +148,18 @@ 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}" + # shellcheck disable=SC2086 + exec gosu clide tmux new-session -A -s main ${AGENT_CMD} fi -exec gosu clide "${@:-claude}" +# shellcheck disable=SC2086 +exec gosu clide ${AGENT_CMD} diff --git a/docker-compose.yml b/docker-compose.yml index b67a931..00ad11b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,15 @@ 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} + # 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:-} + # 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/docs/schema/session-events-v1.md b/docs/schema/session-events-v1.md new file mode 100644 index 0000000..f56f579 --- /dev/null +++ b/docs/schema/session-events-v1.md @@ -0,0 +1,77 @@ +# 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. + +## 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 | +|---------|---------|-------------| +| `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) | diff --git a/entrypoint.sh b/entrypoint.sh index 381dd27..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`. @@ -23,6 +35,7 @@ TTYD_ARGS=( "--writable" "--port" "${TTYD_PORT:-7681}" "--base-path" "${TTYD_BASE_PATH:-/}" + "--client-option" "reconnect=${TTYD_RECONNECT:-3}" ) # Wire gh as git credential helper so git push/fetch work without token embedding. @@ -63,5 +76,9 @@ else exit 1 fi -# Drop privileges to clide before starting ttyd so the web terminal never runs as root +# Drop privileges to clide before starting ttyd so the web terminal never runs as root. +# 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 diff --git a/scripts/notify.sh b/scripts/notify.sh new file mode 100755 index 0000000..2ed40fe --- /dev/null +++ b/scripts/notify.sh @@ -0,0 +1,65 @@ +#!/bin/bash +# notify.sh — Push notifications via ntfy for agent session events +# +# 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_DISABLED — set to 1 to disable notifications +# +# Usage: notify.sh [detail] +# Events: start, end, error + +set -uo pipefail + +EVENT="${1:-}" +SESSION_ID="${2:-unknown}" +AGENT="${3:-claude}" +DETAIL="${4:-}" + +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}" + +case "$EVENT" in + start) + curl -sf -X POST "$ENDPOINT" \ + -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: 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 new file mode 100755 index 0000000..1a1c17c --- /dev/null +++ b/scripts/session-logger.sh @@ -0,0 +1,222 @@ +#!/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 or if command is a no-op (e.g. entrypoint pre-seed) +if [[ "${CLIDE_LOG_DISABLED:-}" == "1" || "${1:-}" == "true" ]]; 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 + # shellcheck disable=SC2012 + 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})" + +# Send start notification +NOTIFY_SCRIPT="$(command -v notify.sh 2>/dev/null || echo "$(dirname "$0")/notify.sh")" +if [[ -x "$NOTIFY_SCRIPT" ]]; then + "$NOTIFY_SCRIPT" start "${SESSION_ID}" "${AGENT}" & +fi + +# 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')" + +# 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}