Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,6 @@ coverage/
*.db-wal
*.db-shm
.DS_Store
.run-guard/*
.cli-gateway/*
nohup.out
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,14 @@ This file is for coding agents (Codex/Claude/etc) working in this repository.
- DB schema/migrations: `src/db/migrations.ts`
- Scheduler: `src/scheduler/scheduler.ts`
- Channel sinks: `src/channels/discord.ts`, `src/channels/telegram.ts`, `src/channels/feishu.ts`
- Process guard/restart bridge: `scripts/run-guard.sh`, `scripts/restart-watcher.sh`

## UI

- UI modes: `verbose` (default) and `summary`.
- Per-conversation override: `/ui verbose|summary` (stored in DB `ui_prefs`).
- Per-conversation runtime prefs include `/workspace` and `/cli` (persist across `/new`).
- Permission allowlist can be managed per conversation via `/whitelist` (`tool_kind` + optional prefix scoped, persisted via `tool_policies` and `tool_allow_prefixes`).

## Local dev

Expand Down Expand Up @@ -55,6 +57,7 @@ ACP sessions are process-local; after restart/GC the agent will start with no st

Current mitigation:
- Context replay on fresh ACP sessions via `CONTEXT_REPLAY_*` (DB-backed replay of recent runs).
- Discord fresh sessions also prepend channel topic/description as global context when available.

If you change how context is built or injected, update:
- `src/gateway/history.ts`
Expand Down
31 changes: 27 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ npm run start:guard
Restart/stop/status/logs:

```bash
bash scripts/run-guard.sh restart
bash scripts/run-guard.sh request-restart
bash scripts/run-guard.sh stop
bash scripts/run-guard.sh status
bash scripts/run-guard.sh logs
Expand All @@ -98,10 +98,10 @@ Custom command is supported:

```bash
bash scripts/run-guard.sh start -- npm run dev
bash scripts/run-guard.sh restart -- npm run dev
bash scripts/run-guard.sh request-restart -- npm run dev
```

`start`/`restart` automatically runs:
`start`/`request-restart` automatically runs:

```bash
npm i
Expand All @@ -111,6 +111,21 @@ npm run build
Then guard keeps restarting the app on abnormal exit with exponential backoff.
Before each launch attempt, guard also checks `gateway.lock` under `CLI_GATEWAY_HOME` (or `~/.cli-gateway`), terminates the lock PID if still alive, and removes stale lock files.

Sandbox-friendly restart bridge:

- Run `scripts/restart-watcher.sh` on the host (outside sandbox). It watches `.run-guard/restart.request` and calls `run-guard.sh restart`.
- From sandbox, only send a restart request marker:

```bash
bash scripts/run-guard.sh request-restart
```

- Host watcher startup example:

```bash
nohup bash scripts/restart-watcher.sh >> .run-guard/restart-watcher.log 2>&1 &
```

Useful env vars:

- `RESTART_BASE_DELAY_SECONDS` (default `2`)
Expand All @@ -120,6 +135,8 @@ Useful env vars:
- `STOP_TIMEOUT_SECONDS` (default `20`)
- `SKIP_UPDATE=1` to skip `npm i` + `npm run build`
- `GUARD_STATE_DIR` to override pid/log directory (default `./.run-guard`)
- `RESTART_REQUEST_SOURCE` payload source for `request-restart` (default `manual`)
- `RESTART_REQUEST_COOLDOWN_SECONDS` watcher debounce window (default `10`)

## Feishu setup (MVP)

Expand All @@ -135,6 +152,7 @@ Feishu currently runs in webhook event-subscription mode:
- `/new` start a fresh ACP session for this conversation
- `/allow <n>` select a pending permission option by index (fallback)
- `/deny` reject a pending permission request (fallback)
- `/whitelist list|add|del|clear` manage per-conversation permission whitelist by `tool_kind` (optional prefix scope)
- `/cron help|list|add|del|enable|disable` manage scheduled prompts
- `/last` show last run output for this session
- `/replay [runId]` replay stored `session/update` output for a run (best-effort)
Expand All @@ -149,15 +167,19 @@ Telegram note:
- Chat-scoped command menu is synced best-effort from `cli-inline` commands. Commands with `-` are mapped to `_` in Telegram UI.

Discord note:
- Built-in commands are available as slash commands (`/help`, `/ui`, `/cli`, `/workspace`, `/new`, `/last`, `/replay`, `/allow`, `/deny`, `/cron`).
- Built-in commands are available as slash commands (`/help`, `/ui`, `/cli`, `/workspace`, `/new`, `/last`, `/replay`, `/allow`, `/deny`, `/whitelist`, `/cron`).
- Slash commands are synced at startup (global + per-guild best-effort). Global command propagation may take time on Discord side.
- ACP `cli-inline` dynamic commands are not yet exposed as Discord slash commands.
- Inbound message processing uses reaction acks (`🤔` while running, then `🕊` on success or `😢` on error), aligned with Telegram behavior.
- On fresh ACP sessions, the channel topic/description is injected as a global context block before the user prompt.

## Security model (default)

- File system and terminal tool calls are restricted to the active workspace root (per conversation; see `/workspace`).
- Tool execution is **deny-by-default**; the user must approve via ACP permission flow.
- You can pre-allow specific `tool_kind` values per conversation via `/whitelist add <tool_kind>` (`read|edit|delete|move|search|execute|think|fetch|switch_mode|other`).
- You can also scope allow rules by prefix: `/whitelist add read /abs/path/prefix` (path kinds) or `/whitelist add execute npm run` (argument prefix). Non-matching calls still require approval.
- If an agent calls a tool directly without first sending `session/request_permission`, the gateway synthesizes an interactive permission prompt and blocks the tool call until approved/denied.
- Approvals are interactive on Discord/Telegram (buttons). Discord permission cards also add reaction shortcuts (`👍` allow, `👎` deny; `✅`/`❌` still accepted); `/allow`/`/deny` remain as fallback.
- You can persist policy choices (e.g. `allow_always` / `reject_always`) per conversation.

Expand Down Expand Up @@ -189,6 +211,7 @@ To reduce this, `cli-gateway` can replay recent conversation runs from the DB in

- Config keys: `contextReplayEnabled`, `contextReplayRuns`, `contextReplayMaxChars`
- Default: enabled, last 8 runs, max 12k chars (used only on fresh ACP sessions)
- Discord-only: fresh sessions also include channel topic/description as global context.

## Status

Expand Down
4 changes: 4 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,18 @@ This document lists current gaps (vs a "production gateway") and the planned dir
- Process guard script for auto-restart on abnormal exit (`scripts/run-guard.sh`).
- Process guard supports daemon lifecycle commands (`start/stop/restart/status/logs`) with `nohup` background mode and auto `npm i && npm run build` on `start/restart`.
- Process guard now pre-cleans `gateway.lock` (kill lock PID if alive, remove stale lock) before each launch attempt.
- Added sandbox-safe restart bridge: `run-guard.sh request-restart` marker + host-side `scripts/restart-watcher.sh`.
- Feishu inbound webhook + outbound send (MVP).
- First-run interactive config wizard (TTY) + lock directory bootstrap.
- Default UI mode switched to `summary` (conversation-level `/ui` override still supported).
- Tool-call UI now tracks lifecycle (`start`/`update`/`complete`) keyed by tool-call id to reduce duplicate tool messages.
- Conversation preferences can now be changed before first prompt (`/ui`, `/workspace`, `/cli`) and survive `/new` session reset.
- ACP transport now fails fast on child exit/bootstrap timeout, returning explicit errors instead of leaving runs hanging.
- Discord permission approvals now support emoji reactions (`✅`/`👍` allow, `❌`/`👎` deny) in addition to buttons.
- Gateway now synthesizes interactive permission prompts when an agent calls tools directly without `session/request_permission`, preserving deny-by-default UX.
- Agent text streaming now auto-splits around tool calls (text-only updates keep editing one message; post-tool assistant output resumes in a new message).
- Fresh Discord sessions now inject channel topic/description as global context (alongside context replay when enabled).
- Added `/whitelist` command to manage per-conversation permission allowlist by `tool_kind` with optional path/argument prefix scoping.

## Suggested Next Steps (Priority)

Expand Down
52 changes: 52 additions & 0 deletions scripts/restart-watcher.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#!/usr/bin/env bash

set -euo pipefail

SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"

STATE_DIR="${GUARD_STATE_DIR:-${PROJECT_ROOT}/.run-guard}"
REQ_FILE="${STATE_DIR}/restart.request"
BUSY_FILE="${STATE_DIR}/restart.busy"
COOLDOWN_SECONDS="${RESTART_REQUEST_COOLDOWN_SECONDS:-10}"
PROCESSING_FILE="${REQ_FILE}.processing.$$"

if ! [[ "${COOLDOWN_SECONDS}" =~ ^[0-9]+$ ]]; then
COOLDOWN_SECONDS=10
fi

mkdir -p "${STATE_DIR}"

last_restart_ts=0

cleanup_busy() {
rm -f "${BUSY_FILE}"
}

trap cleanup_busy EXIT INT TERM

echo "[watcher] watching ${REQ_FILE}"
echo "[watcher] cooldown=${COOLDOWN_SECONDS}s"

while true; do
if [[ -f "${REQ_FILE}" && ! -f "${BUSY_FILE}" ]]; then
now="$(date +%s)"

if (( now - last_restart_ts < COOLDOWN_SECONDS )); then
echo "[watcher] request ignored (cooldown)"
rm -f "${REQ_FILE}"
else
touch "${BUSY_FILE}"

if mv "${REQ_FILE}" "${PROCESSING_FILE}" 2>/dev/null; then
echo "[watcher] restart requested at $(date -Iseconds)"
bash "${PROJECT_ROOT}/scripts/run-guard.sh" restart || true
fi

rm -f "${PROCESSING_FILE}" "${BUSY_FILE}"
last_restart_ts="${now}"
fi
fi

sleep 1
done
70 changes: 61 additions & 9 deletions scripts/run-guard.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ PID_FILE="${STATE_DIR}/guard.pid"
APP_PID_FILE="${STATE_DIR}/app.pid"
CMD_FILE="${STATE_DIR}/command.args"
LOG_FILE="${STATE_DIR}/guard.log"
RESTART_REQUEST_FILE="${STATE_DIR}/restart.request"

BASE_DELAY="${RESTART_BASE_DELAY_SECONDS:-2}"
MAX_DELAY="${RESTART_MAX_DELAY_SECONDS:-30}"
Expand Down Expand Up @@ -181,7 +182,9 @@ resolve_command() {
fi

if [[ -f "${CMD_FILE}" ]]; then
mapfile -t loaded < "${CMD_FILE}"
while IFS= read -r line || [[ -n "${line}" ]]; do
loaded+=("${line}")
done < "${CMD_FILE}"
if [[ ${#loaded[@]} -gt 0 ]]; then
RESOLVED_CMD=("${loaded[@]}")
return
Expand Down Expand Up @@ -225,7 +228,11 @@ start_guard() {

cleanup_gateway_lock

resolve_command "${cmd[@]}"
if [[ ${#cmd[@]} -gt 0 ]]; then
resolve_command "${cmd[@]}"
else
resolve_command
fi
cmd=("${RESOLVED_CMD[@]}")
save_command "${cmd[@]}"

Expand Down Expand Up @@ -293,7 +300,11 @@ restart_guard() {
local -a cmd=("$@")

stop_guard
start_guard "${cmd[@]}"
if [[ ${#cmd[@]} -gt 0 ]]; then
start_guard "${cmd[@]}"
else
start_guard
fi
}

status_guard() {
Expand Down Expand Up @@ -338,13 +349,32 @@ logs_guard() {
tail -n "${LOG_TAIL_LINES}" "${LOG_FILE}"
}

request_restart() {
local source="${RESTART_REQUEST_SOURCE:-manual}"
local now_iso
local tmp_file

ensure_state_dir
now_iso="$(date -Iseconds)"
tmp_file="${RESTART_REQUEST_FILE}.tmp.$$"

printf '{"requestedAt":"%s","source":"%s"}\n' "${now_iso}" "${source}" > "${tmp_file}"
mv "${tmp_file}" "${RESTART_REQUEST_FILE}"

echo "[guard] restart request queued: ${RESTART_REQUEST_FILE}"
}

run_loop() {
local -a cmd=("$@")
local attempt=0
local child_pid=""

ensure_state_dir
resolve_command "${cmd[@]}"
if [[ ${#cmd[@]} -gt 0 ]]; then
resolve_command "${cmd[@]}"
else
resolve_command
fi
cmd=("${RESOLVED_CMD[@]}")

echo "$$" > "${PID_FILE}"
Expand Down Expand Up @@ -422,11 +452,13 @@ Usage:
bash scripts/run-guard.sh [start] [-- <command...>]
bash scripts/run-guard.sh stop
bash scripts/run-guard.sh restart [-- <command...>]
bash scripts/run-guard.sh request-restart
bash scripts/run-guard.sh status
bash scripts/run-guard.sh logs [-f]

Notes:
- `start`/`restart` will run `npm i` and `npm run build` before launching.
- `request-restart` only drops a marker file; `scripts/restart-watcher.sh` consumes it.
- Default command is `node dist/main.js`.
- Legacy form `bash scripts/run-guard.sh npm run dev` is still supported.

Expand All @@ -438,12 +470,13 @@ Useful env vars:
STOP_TIMEOUT_SECONDS (default: 20)
SKIP_UPDATE=1 to skip npm i/build
GUARD_STATE_DIR to change pid/log directory
RESTART_REQUEST_SOURCE to annotate request-restart payload source
EOF_USAGE
}

is_known_action() {
case "$1" in
start|stop|restart|status|logs|help|_run-loop)
start|stop|restart|request-restart|status|logs|help|_run-loop)
return 0
;;
*)
Expand All @@ -470,25 +503,44 @@ main() {

case "${action}" in
start)
start_guard "${args[@]}"
if [[ ${#args[@]} -gt 0 ]]; then
start_guard "${args[@]}"
else
start_guard
fi
;;
stop)
stop_guard
;;
restart)
restart_guard "${args[@]}"
if [[ ${#args[@]} -gt 0 ]]; then
restart_guard "${args[@]}"
else
restart_guard
fi
;;
request-restart)
request_restart
;;
status)
status_guard
;;
logs)
logs_guard "${args[@]}"
if [[ ${#args[@]} -gt 0 ]]; then
logs_guard "${args[@]}"
else
logs_guard
fi
;;
help)
usage
;;
_run-loop)
run_loop "${args[@]}"
if [[ ${#args[@]} -gt 0 ]]; then
run_loop "${args[@]}"
else
run_loop
fi
;;
*)
usage
Expand Down
Loading
Loading