#!/usr/bin/env bash # # sf-from-source — run SF directly from this source checkout via node. # # Purpose: every local commit in this repo is live immediately without # rebuilding dist/. Human CLI invocations use this bash shim for better # shell integration (set -e, pipefail, etc.). # # Subagents: SF_BIN_PATH is exported as dist/loader.js (not this shim), so # all child pi processes spawned by the subagent extension use dist/loader.js # directly as their entry point. dist/loader.js is a proper Node.js shebang # entry point, avoiding the bash-script-vs-node parsing issue. # # Why node, not bun: # - bun doesn't ship node:sqlite (sf-db.ts falls back to filesystem- # derivation degraded mode under bun). # - bun's native-addon loader doesn't inherit the system library # search path under Nix (libz.so.1 not found for forge_engine.node). # - node 26.1+ has stable enough node:sqlite coverage for SF's database-first # runtime and supports # --experimental-strip-types so .ts runs directly. # - The src/resources/extensions/sf/tests/resolve-ts.mjs loader hook # already handles .js → .ts import-specifier remapping for runtime # resolution. # # Contract: # - Executable shim; human CLI entry point with full shell features. # - Exports SF_BIN_PATH=dist/loader.js so all child processes (including # subagent pi instances) use the Node.js entry point directly. # # Requirements: node >= 26.1 on PATH, # node_modules populated. set -euo pipefail # Default subagent dispatch to the swarm/messagebus path rather than # subprocess spawn. The opt-in flag has been stable since the # tests/subagent-via-swarm.test.mjs harness landed; making it the # wrapper default keeps subagent traffic on the uok message-bus # substrate (table uok_messages) instead of spawning child sf # processes. Set SF_SUBAGENT_VIA_SWARM=0 (or =false) before invoking # sf to opt out. : "${SF_SUBAGENT_VIA_SWARM:=1}" export SF_SUBAGENT_VIA_SWARM SCRIPT_DIR=$(cd -- "$(dirname -- "$(readlink -f "${BASH_SOURCE[0]}")")" &>/dev/null && pwd) SF_SOURCE_ROOT=$(cd -- "$SCRIPT_DIR/.." &>/dev/null && pwd) if [[ -n "${SF_NODE_BIN:-}" ]]; then NODE_BIN="$SF_NODE_BIN" elif [[ -x "$HOME/.local/bin/mise" ]]; then NODE_BIN=$(cd -- "$SF_SOURCE_ROOT" && "$HOME/.local/bin/mise" which node 2>/dev/null || true) NODE_BIN=${NODE_BIN:-node} else NODE_BIN=node fi IS_HEADLESS=0 if [[ "${1:-}" == "headless" ]]; then IS_HEADLESS=1 echo "[forge] Preparing source runtime for headless command..." fi # Single-writer project lock. Two SF processes writing to the same # .sf/sf.db over WAL cause torn pages and "database disk image is # malformed" corruption (observed 2026-05-17 in dogfood-5 — see # .sf/self-feedback for the recurrence pattern). Take a flock on # .sf/sf.lock before launching node; the lock is released # automatically when this process exits. Skip in known read-only # modes (logs, status, dash, sessions, list, version, help) where # concurrent reads are safe and useful. # # Top-level read-only commands skip the lock. ALSO skip when $1=headless # AND $2 is a read-only subcommand — otherwise the operator can't even # check SF's state while autonomous mode is running (regression observed # 2026-05-17 when `sf headless query` / `feedback list` / --help were # rejected with "Another sf is already running" despite being pure reads). case "${1:-} ${2:-}" in "logs "*|"status "*|"dash "*|"sessions "*|"list "*|"--version "*|"-v "*|"--help "*|"-h "*) : # top-level read-only — no lock needed ;; "headless --help"*|"headless -h"*|"headless --version"*|"headless -v"*|"headless query"*|"headless status"*|"headless usage"*|"headless reflect"*|"headless ") : # headless read-only subcommand — no lock needed ;; "headless feedback"*|"headless triage"*) # `feedback list` and `triage --list` are read-only; `feedback add/resolve` # and `triage --run/--apply` are writes. Allow the read-only forms only. if [[ "$*" == *" list"* || "$*" == *" --list"* || "$*" == *" --json"* ]]; then : # read-only inspect else __SF_NEEDS_LOCK=1 fi ;; *) __SF_NEEDS_LOCK=1 ;; esac case "${__SF_NEEDS_LOCK:-}" in 1) if [[ -z "${SF_SKIP_LOCK:-}" ]]; then SF_PROJECT_LOCK_DIR="$(pwd)/.sf" mkdir -p "$SF_PROJECT_LOCK_DIR" 2>/dev/null || true SF_PROJECT_LOCK_FILE="$SF_PROJECT_LOCK_DIR/sf.lock" # Open read+write WITHOUT truncating so collision branch can still # read the current holder before failing. Truncate happens AFTER # flock succeeds (below) so two racers don't clobber each other's # holder metadata. exec 200<>"$SF_PROJECT_LOCK_FILE" if ! flock -n 200; then holder=$(cat "$SF_PROJECT_LOCK_FILE" 2>/dev/null) [[ -z "$holder" ]] && holder="(no metadata)" echo "[forge] Another sf is already running in $(pwd)" >&2 echo "[forge] Lock holder: $holder" >&2 echo "[forge] If you're sure no other sf is running, remove $SF_PROJECT_LOCK_FILE and retry." >&2 echo "[forge] To bypass (NOT RECOMMENDED — risks WAL corruption): SF_SKIP_LOCK=1 sf ..." >&2 exit 75 # EX_TEMPFAIL fi # Truncate + write our holder metadata AFTER acquiring the lock. : > "$SF_PROJECT_LOCK_FILE" echo "pid=$$ args=$* cwd=$(pwd) started=$(date -Iseconds)" >&200 fi ;; esac # SF_BIN_PATH: absolute path to dist/loader.js (not this shim). # This is what the subagent extension spawns for child pi processes. # dist/loader.js is a proper Node.js entry point — bash scripts cannot be # spawned by Node.js as executables (Node parses them as JS, causing SyntaxError). export SF_BIN_PATH="$SF_SOURCE_ROOT/dist/loader.js" export SF_CLI_PATH="${SF_CLI_PATH:-$SCRIPT_DIR/sf-from-source}" "$NODE_BIN" "$SF_SOURCE_ROOT/scripts/ensure-source-resources.cjs" if [[ "$IS_HEADLESS" == "1" ]]; then echo "[forge] Launching source CLI..." fi ORIGINAL_ARGS=("$@") NEXT_ARGS=("${ORIGINAL_ARGS[@]}") while true; do set +e "$NODE_BIN" \ --import "$SF_SOURCE_ROOT/src/resources/extensions/sf/tests/resolve-ts.mjs" \ --experimental-strip-types \ --no-warnings \ "$SF_SOURCE_ROOT/src/loader.ts" "${NEXT_ARGS[@]}" status=$? set -e if [[ "$status" == "12" && "$IS_HEADLESS" != "1" && -t 0 && -t 1 ]]; then echo "[forge] Runtime reload requested — restarting source CLI with --continue..." NEXT_ARGS=("--continue") continue fi exit "$status" done