2026-07-29 17:57:07 +02:00
|
|
|
#!/usr/bin/env bash
|
|
|
|
|
# Start the task manager: wire the project, sort out the port, serve the board.
|
|
|
|
|
#
|
|
|
|
|
# ./.task-manager/start.sh # foreground; Ctrl-C stops the board
|
|
|
|
|
# ./.task-manager/start.sh --no-open # extra args pass through to board.py
|
|
|
|
|
#
|
2026-08-01 07:32:54 +02:00
|
|
|
# Port logic (BOARD_PORT from env, else manager/local/.env, else 26071):
|
2026-07-29 17:57:07 +02:00
|
|
|
# - our own board already answering there -> just open the browser
|
|
|
|
|
# - port free -> start on it
|
2026-08-01 07:32:54 +02:00
|
|
|
# - held by something else -> wait a few seconds for it to
|
|
|
|
|
# clear (a restart races its own predecessor's shutdown far more often
|
|
|
|
|
# than a stranger takes the port), and only then take the next free port
|
|
|
|
|
# AND persist it to manager/local/.env, so the hooks and agents (which
|
|
|
|
|
# read the same file) follow the board instead of reporting into the void.
|
|
|
|
|
#
|
|
|
|
|
# The probe binds exactly as the board does — 127.0.0.1 with SO_REUSEADDR,
|
|
|
|
|
# which is what ThreadingHTTPServer sets — because a probe stricter than the
|
|
|
|
|
# server lies: a socket the just-stopped board left in TIME_WAIT would read
|
|
|
|
|
# as occupied and hop the board off a port its user deliberately pinned.
|
2026-07-29 17:57:07 +02:00
|
|
|
set -euo pipefail
|
|
|
|
|
|
|
|
|
|
TM="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
|
|
|
MANAGER="$TM/manager"
|
|
|
|
|
CORE="$MANAGER/core"
|
|
|
|
|
ENV_FILE="$MANAGER/local/.env"
|
|
|
|
|
|
2026-08-01 07:32:54 +02:00
|
|
|
# Seconds to let a held port clear before walking off it. Shutdown is quick;
|
|
|
|
|
# this is grace for a predecessor, not patience for a squatter. Anything
|
|
|
|
|
# that isn't a number falls back to the default rather than failing a start
|
|
|
|
|
# on an arithmetic comparison.
|
|
|
|
|
busy_wait="${BOARD_PORT_WAIT:-5}"
|
|
|
|
|
case "$busy_wait" in ''|*[!0-9]*) busy_wait=5 ;; esac
|
|
|
|
|
|
2026-07-29 17:57:07 +02:00
|
|
|
port="${BOARD_PORT:-}"
|
|
|
|
|
if [ -z "$port" ] && [ -f "$ENV_FILE" ]; then
|
|
|
|
|
port="$(sed -n 's/^[[:space:]]*BOARD_PORT[[:space:]]*=[[:space:]]*//p' "$ENV_FILE" | tail -1 | tr -d "'\"")"
|
|
|
|
|
fi
|
|
|
|
|
port="${port:-26071}"
|
|
|
|
|
|
|
|
|
|
# Idempotent project wiring; a project without .claude/ still gets the board.
|
|
|
|
|
python3 "$TM/install.py" || true
|
|
|
|
|
echo
|
|
|
|
|
|
|
|
|
|
is_free() {
|
|
|
|
|
python3 - "$1" <<'PY'
|
|
|
|
|
import socket, sys
|
|
|
|
|
s = socket.socket()
|
2026-08-01 07:32:54 +02:00
|
|
|
# The board's ThreadingHTTPServer sets this; a probe without it would call a
|
|
|
|
|
# TIME_WAIT remnant "busy" on a port the server itself could bind.
|
|
|
|
|
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
2026-07-29 17:57:07 +02:00
|
|
|
try:
|
|
|
|
|
s.bind(("127.0.0.1", int(sys.argv[1])))
|
|
|
|
|
except OSError:
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
finally:
|
|
|
|
|
s.close()
|
|
|
|
|
PY
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
is_our_board() {
|
|
|
|
|
python3 -c '
|
|
|
|
|
import json, sys, urllib.request
|
|
|
|
|
try:
|
|
|
|
|
with urllib.request.urlopen("http://127.0.0.1:%s/api/state" % sys.argv[1], timeout=2) as r:
|
|
|
|
|
data = json.load(r)
|
|
|
|
|
except Exception:
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
sys.exit(0 if data.get("board", {}).get("root") == sys.argv[2] else 1)
|
|
|
|
|
' "$1" "$TM/tasks"
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-01 07:32:54 +02:00
|
|
|
open_board() {
|
|
|
|
|
echo "Board already running at http://127.0.0.1:$1/ — opening it."
|
|
|
|
|
python3 -m webbrowser -t "http://127.0.0.1:$1/" >/dev/null
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 17:57:07 +02:00
|
|
|
if is_our_board "$port"; then
|
2026-08-01 07:32:54 +02:00
|
|
|
open_board "$port"
|
2026-07-29 17:57:07 +02:00
|
|
|
exit 0
|
|
|
|
|
fi
|
|
|
|
|
|
2026-08-01 07:32:54 +02:00
|
|
|
if ! is_free "$port"; then
|
|
|
|
|
# Held by someone. Give it a moment: the usual holder is the board this
|
|
|
|
|
# start is replacing, and it lets go within a second or two.
|
|
|
|
|
echo "Port $port is busy — waiting up to ${busy_wait}s for it to clear."
|
|
|
|
|
waited=0
|
|
|
|
|
while [ "$waited" -lt "$busy_wait" ] && ! is_free "$port"; do
|
|
|
|
|
sleep 1
|
|
|
|
|
waited=$((waited + 1))
|
|
|
|
|
if is_our_board "$port"; then # a board came up during the wait
|
|
|
|
|
open_board "$port"
|
|
|
|
|
exit 0
|
|
|
|
|
fi
|
|
|
|
|
done
|
|
|
|
|
fi
|
|
|
|
|
|
2026-07-29 17:57:07 +02:00
|
|
|
if ! is_free "$port"; then
|
|
|
|
|
original=$port
|
|
|
|
|
for offset in $(seq 1 20); do
|
|
|
|
|
candidate=$((original + offset))
|
|
|
|
|
if is_free "$candidate"; then port=$candidate; break; fi
|
|
|
|
|
done
|
|
|
|
|
if [ "$port" = "$original" ]; then
|
|
|
|
|
echo "error: ports $original-$((original + 20)) all busy — set BOARD_PORT yourself." >&2
|
|
|
|
|
exit 1
|
|
|
|
|
fi
|
2026-08-01 07:32:54 +02:00
|
|
|
echo "Port $original is held by another process — using $port instead."
|
|
|
|
|
echo "Rewriting BOARD_PORT in manager/local/.env: $original → $port, so the hooks and agents follow the live board."
|
|
|
|
|
echo "To reclaim $original: free it, then set BOARD_PORT=$original in manager/local/.env."
|
2026-07-29 17:57:07 +02:00
|
|
|
python3 - "$ENV_FILE" "$port" <<'PY'
|
|
|
|
|
import pathlib, sys
|
|
|
|
|
path, port = pathlib.Path(sys.argv[1]), sys.argv[2]
|
|
|
|
|
lines = path.read_text(encoding="utf-8").splitlines() if path.exists() else []
|
|
|
|
|
out, replaced = [], False
|
|
|
|
|
for line in lines:
|
|
|
|
|
if line.strip().startswith("BOARD_PORT"):
|
|
|
|
|
out.append(f"BOARD_PORT={port}")
|
|
|
|
|
replaced = True
|
|
|
|
|
else:
|
|
|
|
|
out.append(line)
|
|
|
|
|
if not replaced:
|
|
|
|
|
out.append(f"BOARD_PORT={port}")
|
|
|
|
|
path.write_text("\n".join(out) + "\n", encoding="utf-8")
|
|
|
|
|
PY
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
exec python3 "$CORE/board.py" --port "$port" "$@"
|