mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-22 01:02:01 -07:00
604 lines
28 KiB
BlitzBasic
Executable File
604 lines
28 KiB
BlitzBasic
Executable File
#!/usr/bin/env bb
|
|
|
|
(ns swarmforge
|
|
(:require [babashka.fs :as fs]
|
|
[babashka.process :as process]
|
|
[clojure.string :as str]))
|
|
|
|
(def session-prefix "swarmforge")
|
|
(def agent-window "swarm")
|
|
(def red "\u001b[0;31m")
|
|
(def green "\u001b[0;32m")
|
|
(def yellow "\u001b[1;33m")
|
|
(def cyan "\u001b[0;36m")
|
|
(def bold "\u001b[1m")
|
|
(def reset "\u001b[0m")
|
|
|
|
(defn sh [& args]
|
|
(apply process/sh args))
|
|
|
|
(defn sh-ok? [& args]
|
|
(zero? (:exit (apply process/sh (concat [{:continue true}] args)))))
|
|
|
|
(defn sh-out [& args]
|
|
(str/trim (:out (apply process/sh args))))
|
|
|
|
(defn command-exists? [command]
|
|
(sh-ok? "sh" "-c" (str "command -v " command " >/dev/null 2>&1")))
|
|
|
|
(defn env-long [name default-value]
|
|
(if-let [value (System/getenv name)]
|
|
(if (re-matches #"[0-9]+" value)
|
|
(Long/parseLong value)
|
|
default-value)
|
|
default-value))
|
|
|
|
(defn fail! [message]
|
|
(binding [*out* *err*]
|
|
(println message))
|
|
(System/exit 1))
|
|
|
|
(defn sq [value]
|
|
(str "'" (str/replace (str value) #"'" "'\"'\"'") "'"))
|
|
|
|
(defn normalize-terminal-backend [backend]
|
|
(case (str/lower-case backend)
|
|
("iterm" "iterm2" "iterm.app") "iterm2"
|
|
("terminal" "terminal-app" "terminal.app") "terminal-app"
|
|
("windows" "windows-terminal" "wt") "windows-terminal"
|
|
("none" "current" "fallback") "none"
|
|
(str/lower-case backend)))
|
|
|
|
(defn detect-terminal-backend []
|
|
(if-let [backend (System/getenv "SWARMFORGE_TERMINAL")]
|
|
(normalize-terminal-backend backend)
|
|
(cond
|
|
(command-exists? "osascript") (if (= (System/getenv "TERM_PROGRAM") "iTerm.app")
|
|
"iterm2"
|
|
"terminal-app")
|
|
(command-exists? "wt.exe") "windows-terminal"
|
|
:else "none")))
|
|
|
|
(defn display-name-for-role [role]
|
|
(->> (str/split (str/replace role #"[-_]" " ") #"\s+")
|
|
(remove str/blank?)
|
|
(map str/capitalize)
|
|
(str/join " ")))
|
|
|
|
(defn session-name-for-role [role]
|
|
(str session-prefix "-" role))
|
|
|
|
(defn worktree-path-for-name [worktrees-dir worktree]
|
|
(fs/path worktrees-dir worktree))
|
|
|
|
(defn tmux-agent-target [window pane-base-index session]
|
|
(str session ":" window "." pane-base-index))
|
|
|
|
(defn tmux-option [tmux-socket option scope default-value]
|
|
(let [args (case scope
|
|
:session ["tmux" "-S" tmux-socket "show-options" "-gqv" option]
|
|
:window ["tmux" "-S" tmux-socket "show-options" "-gwqv" option])
|
|
result (apply process/sh (concat [{:continue true}] args))
|
|
value (str/trim (:out result))]
|
|
(if (re-matches #"[0-9]+" value)
|
|
(Long/parseLong value)
|
|
default-value)))
|
|
|
|
(defn detect-tmux-base-indexes [ctx]
|
|
(fs/create-dirs (:tmux-socket-dir ctx))
|
|
(let [probe-session (when-not (sh-ok? "tmux" "-S" (:tmux-socket ctx) "info")
|
|
(let [session (str "swarmforge-probe-" (.pid (java.lang.ProcessHandle/current)))]
|
|
(sh "tmux" "-S" (:tmux-socket ctx) "new-session" "-d" "-s" session "sleep 60")
|
|
session))
|
|
window-base (tmux-option (:tmux-socket ctx) "base-index" :session 0)
|
|
pane-base (tmux-option (:tmux-socket ctx) "pane-base-index" :window 0)]
|
|
(when probe-session
|
|
(process/sh {:continue true} "tmux" "-S" (:tmux-socket ctx) "kill-session" "-t" probe-session))
|
|
(assoc ctx :tmux-window-base-index window-base :tmux-pane-base-index pane-base)))
|
|
|
|
(defn ensure-in-file! [file pattern]
|
|
(fs/create-dirs (fs/parent file))
|
|
(when-not (fs/exists? file)
|
|
(spit (str file) ""))
|
|
(let [lines (set (str/split-lines (slurp (str file))))]
|
|
(when-not (contains? lines pattern)
|
|
(spit (str file) (str pattern "\n") :append true))))
|
|
|
|
(defn ensure-initial-gitignore! [ctx]
|
|
(let [gitignore (fs/path (:working-dir ctx) ".gitignore")]
|
|
(if-not (fs/exists? gitignore)
|
|
(spit (str gitignore) ".swarmforge/\n.worktrees/\n")
|
|
(do
|
|
(ensure-in-file! gitignore ".swarmforge/")
|
|
(ensure-in-file! gitignore ".worktrees/")))))
|
|
|
|
(defn ensure-runtime-git-excludes! [ctx]
|
|
(let [exclude-file (fs/path (sh-out "git" "-C" (str (:working-dir ctx)) "rev-parse" "--git-path" "info/exclude"))]
|
|
(fs/create-dirs (fs/parent exclude-file))
|
|
(ensure-in-file! exclude-file ".swarmforge/")
|
|
(ensure-in-file! exclude-file ".worktrees/")))
|
|
|
|
(defn git-identity-ok? [dir]
|
|
(and (sh-ok? "git" "-C" dir "config" "user.name")
|
|
(sh-ok? "git" "-C" dir "config" "user.email")))
|
|
|
|
(defn initialize-git-repo! [ctx]
|
|
(when-not (fs/exists? (fs/path (:working-dir ctx) ".git"))
|
|
(let [dir (str (:working-dir ctx))]
|
|
(when-not (git-identity-ok? dir)
|
|
(fail! (str red "Error:" reset " Git identity not configured. Run:"
|
|
"\n git config --global user.name \"Your Name\""
|
|
"\n git config --global user.email \"you@example.com\"")))
|
|
(sh "git" "init" dir)
|
|
(sh "git" "-C" dir "branch" "-M" "master")
|
|
(ensure-initial-gitignore! ctx)
|
|
(sh "git" "-C" dir "add" ".")
|
|
(sh "git" "-C" dir "commit" "-m" "Initial swarmforge repository"))))
|
|
|
|
(defn parse-config [ctx]
|
|
(when-not (fs/exists? (:config-file ctx))
|
|
(fail! (str red "Error:" reset " Config not found at " (:config-file ctx))))
|
|
(when-not (fs/exists? (:constitution-file ctx))
|
|
(fail! (str red "Error:" reset " Constitution prompt not found at " (:constitution-file ctx))))
|
|
(let [roles-dir (:roles-dir ctx)
|
|
worktrees-dir (:worktrees-dir ctx)
|
|
working-dir (:working-dir ctx)]
|
|
(loop [lines (map-indexed vector (str/split-lines (slurp (str (:config-file ctx)))))
|
|
rows []
|
|
roles #{}
|
|
worktrees #{}]
|
|
(if-let [[line-index raw-line] (first lines)]
|
|
(let [line-no (inc line-index)
|
|
line (str/trim raw-line)]
|
|
(if (or (str/blank? line) (str/starts-with? line "#"))
|
|
(recur (next lines) rows roles worktrees)
|
|
(let [fields (str/split line #"\s+")]
|
|
(when (< (count fields) 4)
|
|
(fail! (str red "Error:" reset " Invalid config line " line-no ": " line)))
|
|
(let [[keyword role agent worktree & trailing] fields
|
|
agent (str/lower-case agent)
|
|
receive-mode (if (#{"task" "batch"} (first trailing))
|
|
(first trailing)
|
|
"task")
|
|
extra-arg-tokens (if (#{"task" "batch"} (first trailing))
|
|
(rest trailing)
|
|
trailing)
|
|
extra-args (when (seq extra-arg-tokens)
|
|
(str/join " " extra-arg-tokens))]
|
|
(when-not (= "window" keyword)
|
|
(fail! (str red "Error:" reset " Unknown config directive on line " line-no ": " keyword)))
|
|
(when (str/includes? role "_")
|
|
(fail! (str red "Error:" reset " Invalid role '" role "' on line " line-no ": role names may not contain underscores")))
|
|
(when (contains? roles role)
|
|
(fail! (str red "Error:" reset " Duplicate role '" role "' in " (:config-file ctx))))
|
|
(when (and (not (#{"none" "master"} worktree)) (contains? worktrees worktree))
|
|
(fail! (str red "Error:" reset " Duplicate worktree '" worktree "' in " (:config-file ctx))))
|
|
(when (or (str/includes? worktree "/") (#{"." ".."} worktree))
|
|
(fail! (str red "Error:" reset " Invalid worktree '" worktree "' for role '" role "'")))
|
|
(when-not (#{"claude" "codex" "copilot" "grok" "pi"} agent)
|
|
(fail! (str red "Error:" reset " Unsupported agent '" agent "' for role '" role "'")))
|
|
(when-not (#{"task" "batch"} receive-mode)
|
|
(fail! (str red "Error:" reset " Invalid receive mode '" receive-mode "' for role '" role "' on line " line-no ": expected task or batch")))
|
|
(when-not (fs/exists? (fs/path roles-dir (str role ".prompt")))
|
|
(fail! (str red "Error:" reset " Missing role prompt " (fs/path roles-dir (str role ".prompt")))))
|
|
(let [worktree-path (if (#{"none" "master"} worktree)
|
|
working-dir
|
|
(worktree-path-for-name worktrees-dir worktree))
|
|
row {:role role
|
|
:agent agent
|
|
:session (session-name-for-role role)
|
|
:display-name (display-name-for-role role)
|
|
:worktree-name worktree
|
|
:worktree-path worktree-path
|
|
:receive-mode receive-mode
|
|
:extra-args extra-args}]
|
|
(recur (next lines)
|
|
(conj rows row)
|
|
(conj roles role)
|
|
(cond-> worktrees (not (#{"none" "master"} worktree)) (conj worktree))))))))
|
|
(do
|
|
(when (empty? rows)
|
|
(fail! (str red "Error:" reset " No windows defined in " (:config-file ctx))))
|
|
(assoc ctx :roles rows))))))
|
|
|
|
(defn write-sessions-file! [ctx]
|
|
(spit (str (:sessions-file ctx))
|
|
(apply str
|
|
(map-indexed
|
|
(fn [index row]
|
|
(format "%d\t%s\t%s\t%s\t%s\n"
|
|
(inc index) (:role row) (:session row) (:display-name row) (:agent row)))
|
|
(:roles ctx)))))
|
|
|
|
(defn write-roles-file! [ctx]
|
|
(spit (str (:roles-file ctx))
|
|
(apply str
|
|
(for [row (:roles ctx)]
|
|
(format "%s\t%s\t%s\t%s\t%s\t%s\t%s\n"
|
|
(:role row)
|
|
(:worktree-name row)
|
|
(:worktree-path row)
|
|
(:session row)
|
|
(:display-name row)
|
|
(:agent row)
|
|
(:receive-mode row))))))
|
|
|
|
(def required-helpers
|
|
["handoff_lib.bb" "swarm_handoff.sh" "swarm_handoff.bb"
|
|
"ready_for_next.sh" "ready_for_next.bb"
|
|
"done_with_current.sh" "done_with_current.bb"
|
|
"ready_for_next_task.sh" "ready_for_next_task.bb"
|
|
"done_with_current_task.sh" "done_with_current_task.bb"
|
|
"ready_for_next_batch.sh" "ready_for_next_batch.bb"
|
|
"done_with_current_batch.sh" "done_with_current_batch.bb"
|
|
"handoffd.bb" "stop_handoff_daemon.bb" "stop_handoff_daemon.sh"
|
|
"swarm-cleanup.sh" "swarm-window-watchdog.sh" "swarm-window-watchdog.bb"
|
|
"swarm-terminal-adapter.sh" "swarmforge.sh" "swarmforge.bb"])
|
|
|
|
(def terminal-helpers
|
|
["terminal-app.sh" "iterm2.sh" "ghostty.sh" "windows-terminal.sh" "none.sh"])
|
|
|
|
(defn check-helper-scripts! [ctx]
|
|
(doseq [helper required-helpers]
|
|
(let [path (fs/path (:script-dir ctx) helper)]
|
|
(when-not (and (fs/exists? path) (fs/executable? path))
|
|
(fail! (str red "Error:" reset " Required helper script not found or not executable: " path)))))
|
|
(doseq [helper terminal-helpers]
|
|
(let [path (fs/path (:script-dir ctx) "terminal-adapters" helper)]
|
|
(when-not (and (fs/exists? path) (fs/executable? path))
|
|
(fail! (str red "Error:" reset " Required terminal adapter not found or not executable: " path))))))
|
|
|
|
(defn prepare-workspace! [ctx]
|
|
(doseq [dir [(:state-dir ctx) (:notify-dir ctx) (:prompts-dir ctx)
|
|
(:worktrees-dir ctx) (:tmux-socket-dir ctx) (:daemon-dir ctx)]]
|
|
(fs/create-dirs dir))
|
|
(spit (str (:tmux-socket-file ctx)) (str (:tmux-socket ctx) "\n"))
|
|
(check-helper-scripts! ctx)
|
|
(write-sessions-file! ctx)
|
|
(write-roles-file! ctx))
|
|
|
|
(defn prepare-worktrees! [ctx]
|
|
(doseq [row (:roles ctx)
|
|
:let [worktree-name (:worktree-name row)
|
|
worktree-path (:worktree-path row)
|
|
branch-name (str "swarmforge-" worktree-name)]
|
|
:when (not (#{"none" "master"} worktree-name))]
|
|
(when-not (or (fs/exists? (fs/path worktree-path ".git"))
|
|
(fs/directory? (fs/path worktree-path ".git")))
|
|
(when-not (sh-ok? "git" "-C" (str (:working-dir ctx))
|
|
"worktree" "add" "--force" "-B" branch-name (str worktree-path) "HEAD")
|
|
(fail! (str red "Error:" reset " Failed to create worktree '" worktree-name "'"))))))
|
|
|
|
(defn prepare-handoff-dirs! [ctx]
|
|
(doseq [row (:roles ctx)
|
|
dir ["outbox/tmp" "sent" "failed" "inbox/new" "inbox/in_process" "inbox/completed"]]
|
|
(fs/create-dirs (fs/path (:worktree-path row) ".swarmforge" "handoffs" dir))))
|
|
|
|
(defn write-tmux-env-file! [ctx]
|
|
(spit (str (:tmux-env-file ctx))
|
|
(str (sh-out "tmux" "-S" (:tmux-socket ctx) "display-message" "-p" "#{socket_path},#{pid},#{pane_id}") "\n")))
|
|
|
|
(defn sync-worktree-scripts! [ctx]
|
|
(doseq [row (:roles ctx)
|
|
:let [worktree-path (:worktree-path row)]
|
|
:when (not= (str worktree-path) (str (:working-dir ctx)))]
|
|
(let [role-scripts-dir (fs/path worktree-path "swarmforge" "scripts")
|
|
role-state-dir (fs/path worktree-path ".swarmforge")]
|
|
(fs/create-dirs role-scripts-dir)
|
|
(doseq [entry (fs/list-dir (:script-dir ctx))]
|
|
(let [target (fs/path role-scripts-dir (fs/file-name entry))]
|
|
(if (fs/directory? entry)
|
|
(fs/copy-tree entry target {:replace-existing true})
|
|
(fs/copy entry target {:replace-existing true}))))
|
|
(fs/create-dirs (fs/path role-state-dir "notify"))
|
|
(fs/copy (:sessions-file ctx) (fs/path role-state-dir "sessions.tsv") {:replace-existing true})
|
|
(fs/copy (:roles-file ctx) (fs/path role-state-dir "roles.tsv") {:replace-existing true})
|
|
(fs/copy (:tmux-socket-file ctx) (fs/path role-state-dir "tmux-socket") {:replace-existing true})
|
|
(fs/copy (:tmux-env-file ctx) (fs/path role-state-dir "tmux-env") {:replace-existing true}))))
|
|
|
|
(defn check-dependency! [command]
|
|
(when-not (command-exists? command)
|
|
(fail! (str red "Error:" reset " '" command "' is required but not installed."))))
|
|
|
|
(defn check-backend-dependencies! [ctx]
|
|
(doseq [agent (map :agent (:roles ctx))]
|
|
(check-dependency! agent)))
|
|
|
|
(defn create-role-session! [ctx session title]
|
|
(sh "tmux" "-S" (:tmux-socket ctx) "new-session" "-d" "-s" session "-n" agent-window)
|
|
(sh "tmux" "-S" (:tmux-socket ctx) "rename-window" "-t" (str session ":" agent-window) title)
|
|
(sh "tmux" "-S" (:tmux-socket ctx) "set-window-option" "-t" (str session ":" title) "allow-rename" "off"))
|
|
|
|
(defn write-agent-instruction-file! [role prompt-file]
|
|
(spit (str prompt-file)
|
|
(str "Read swarmforge/constitution.prompt, then read every file it refers to recursively, and obey all of those instructions.\n"
|
|
"Read swarmforge/roles/" role ".prompt, then read every file it refers to recursively, and follow all of those instructions.\n")))
|
|
|
|
(defn extra-args-prefix [row]
|
|
(let [args (:extra-args row)]
|
|
(if (str/blank? args) "" (str args " "))))
|
|
|
|
(defn grok-wants-auto-approve? [row]
|
|
(when-let [args (:extra-args row)]
|
|
(or (str/includes? args "--always-approve")
|
|
(str/includes? args "--yolo")
|
|
(re-find #"--permission-mode\s+bypassPermissions" args))))
|
|
|
|
(defn grok-permission-prefix [row]
|
|
;; acceptEdits only auto-approves file edits; bypassPermissions is the
|
|
;; CLI-enforced mode that matches --always-approve / --yolo.
|
|
(if (grok-wants-auto-approve? row)
|
|
"--permission-mode bypassPermissions "
|
|
"--permission-mode acceptEdits "))
|
|
|
|
(defn launch-command [ctx index row]
|
|
(let [role (:role row)
|
|
agent (:agent row)
|
|
display (:display-name row)
|
|
role-worktree (:worktree-path row)
|
|
role-script-dir (if (= (str role-worktree) (str (:working-dir ctx)))
|
|
(:script-dir ctx)
|
|
(fs/path role-worktree "swarmforge" "scripts"))
|
|
prompt-file (fs/path (:prompts-dir ctx) (str role ".md"))
|
|
base (str "export SWARMFORGE_ROLE=" (sq role)
|
|
" && export PATH=" (sq (str role-script-dir)) ":$PATH"
|
|
" && cd " (sq (str role-worktree))
|
|
" && ")]
|
|
(write-agent-instruction-file! role prompt-file)
|
|
(cond-> (str base
|
|
(case agent
|
|
"claude" (str "claude --append-system-prompt-file " (sq (str prompt-file)) " --permission-mode acceptEdits -n " (sq (str "SwarmForge " display)) " " (extra-args-prefix row) "\"$(cat " (sq (str prompt-file)) ")\"")
|
|
"codex" (str "codex -C " (sq (str role-worktree)) " " (extra-args-prefix row) "\"$(cat " (sq (str prompt-file)) ")\"")
|
|
"copilot" (str "copilot -C " (sq (str role-worktree)) " --name " (sq (str "SwarmForge " display)) " " (extra-args-prefix row) "-i \"$(cat " (sq (str prompt-file)) ")\"")
|
|
"grok" (str "grok --cwd " (sq (str role-worktree)) " " (grok-permission-prefix row) (extra-args-prefix row) "--rules \"$(cat " (sq (str prompt-file)) ")\" --verbatim \"$(cat " (sq (str prompt-file)) ")\"")
|
|
"pi" (str "pi -a --name " (sq (str "SwarmForge " display)) " " (extra-args-prefix row) "\"$(cat " (sq (str prompt-file)) ")\"")))
|
|
(= index 0)
|
|
(str "; exit_code=$?; SWARMFORGE_TERMINAL_BACKEND=" (sq (:terminal-backend ctx))
|
|
" nohup " (sq (str (fs/path (:script-dir ctx) "swarm-cleanup.sh")))
|
|
" " (sq (:tmux-socket ctx))
|
|
" " (sq (str (:window-ids-file ctx)))
|
|
(apply str (map #(str " " (sq (:session %))) (:roles ctx)))
|
|
" >/dev/null 2>&1 & disown; exit $exit_code"))))
|
|
|
|
(defn launch-role! [ctx index row]
|
|
(let [session (:session row)
|
|
display (:display-name row)
|
|
prompt-file (fs/path (:prompts-dir ctx) (str (:role row) ".md"))
|
|
command (launch-command ctx index row)]
|
|
(sh "tmux" "-S" (:tmux-socket ctx) "send-keys" "-t"
|
|
(tmux-agent-target display (:tmux-pane-base-index ctx) session)
|
|
command "Enter")
|
|
(println (str " " cyan "[" display "]" reset " started in session " session))))
|
|
|
|
(defn stop-handoff-daemon! [ctx]
|
|
(process/sh {:continue true}
|
|
"bb" (str (fs/path (:script-dir ctx) "stop_handoff_daemon.bb"))
|
|
(str (:working-dir ctx))))
|
|
|
|
(defn uname []
|
|
(str/trim (:out (process/sh {:continue true} "uname" "-s"))))
|
|
|
|
(defn linux-systemd-running? []
|
|
(let [result (process/sh {:continue true} "systemctl" "is-system-running")
|
|
state (str/trim (:out result))]
|
|
(#{"running" "degraded"} state)))
|
|
|
|
(defn sleep-inhibitor-prefix []
|
|
(when-not (= "0" (System/getenv "SWARMFORGE_PREVENT_SLEEP"))
|
|
(case (uname)
|
|
"Darwin" (when (command-exists? "caffeinate")
|
|
["caffeinate" "-dims"])
|
|
"Linux" (when (and (command-exists? "systemd-inhibit")
|
|
(command-exists? "systemctl")
|
|
(linux-systemd-running?))
|
|
["systemd-inhibit"
|
|
"--what=sleep:idle"
|
|
"--who=SwarmForge"
|
|
"--why=SwarmForge swarm is active"])
|
|
nil)))
|
|
|
|
(defn start-handoff-daemon! [ctx]
|
|
(fs/delete-if-exists (fs/path (:daemon-dir ctx) "stop"))
|
|
(let [command (into (vec (sleep-inhibitor-prefix))
|
|
[(str (fs/path (:script-dir ctx) "handoffd.bb"))
|
|
(str (:working-dir ctx))])]
|
|
(process/process command
|
|
{:out (str (:handoff-daemon-log ctx))
|
|
:err :out})
|
|
(println (str green "Started handoff daemon"
|
|
(when (> (count command) 2) " with OS sleep prevention")
|
|
"."
|
|
reset))))
|
|
|
|
(defn adapter-script [ctx command & args]
|
|
(let [script (str "SCRIPT_DIR=" (sq (str (:script-dir ctx))) "\n"
|
|
"WORKING_DIR=" (sq (str (:working-dir ctx))) "\n"
|
|
"TMUX_SOCKET=" (sq (:tmux-socket ctx)) "\n"
|
|
"source " (sq (str (fs/path (:script-dir ctx) "swarm-terminal-adapter.sh")))
|
|
" && load_terminal_backend " (sq (:terminal-backend ctx))
|
|
" && " command
|
|
(apply str (map #(str " " (sq %)) args)))]
|
|
["bash" "-c" script]))
|
|
|
|
(defn terminal-call [ctx command & args]
|
|
(apply process/sh (apply adapter-script ctx command args)))
|
|
|
|
(defn terminal-call-ok? [ctx command & args]
|
|
(zero? (:exit (apply process/sh (concat [{:continue true}] (apply adapter-script ctx command args))))))
|
|
|
|
(defn terminal-call-out [ctx command & args]
|
|
(str/trim (:out (apply terminal-call ctx command args))))
|
|
|
|
(defn open-terminal-surfaces! [ctx]
|
|
(if (terminal-call-ok? ctx "terminal_backend_can_open_sessions")
|
|
(do
|
|
(println (str "Opening separate " (terminal-call-out ctx "terminal_backend_label") " surfaces for each session..."))
|
|
(when (terminal-call-ok? ctx "terminal_backend_tracks_windows")
|
|
(spit (str (:window-ids-file ctx)) "")
|
|
(spit (str (:window-state-file ctx)) ""))
|
|
(loop [rows (:roles ctx)
|
|
index 0
|
|
previous-window-id ""]
|
|
(when-let [row (first rows)]
|
|
(let [window-id (terminal-call-out ctx "terminal_open_session" (:session row) (str "SwarmForge " (:display-name row)) previous-window-id)]
|
|
(if (terminal-call-ok? ctx "terminal_backend_tracks_windows")
|
|
(do
|
|
(spit (str (:window-ids-file ctx)) (str window-id "\n") :append true)
|
|
(spit (str (:window-state-file ctx))
|
|
(format "%d\t%s\t%s\t%s\n" (inc index) window-id (:session row) (str "SwarmForge " (:display-name row)))
|
|
:append true)
|
|
(recur (next rows) (inc index) window-id))
|
|
(recur (next rows) (inc index) previous-window-id)))))
|
|
(if (terminal-call-ok? ctx "terminal_backend_tracks_windows")
|
|
(process/process [(str (fs/path (:script-dir ctx) "swarm-window-watchdog.sh"))
|
|
(str (:window-state-file ctx))
|
|
(str (:window-ids-file ctx))
|
|
"1"
|
|
(:tmux-socket ctx)
|
|
(str (:working-dir ctx))
|
|
(:terminal-backend ctx)]
|
|
{:out (str (:window-watchdog-log ctx))
|
|
:err :out})
|
|
(println (str yellow (terminal-call-out ctx "terminal_backend_label") " surfaces are not trackable; window watchdog is disabled for this backend." reset))))
|
|
(do
|
|
(println (str yellow "No terminal backend found; attaching current shell to '" (-> ctx :roles first :session) "' instead." reset))
|
|
(sh "tmux" "-S" (:tmux-socket ctx) "attach-session" "-t" (-> ctx :roles first :session)))))
|
|
|
|
(defn context [working-dir]
|
|
(let [working-dir (fs/absolutize (fs/path working-dir))
|
|
script-dir (fs/parent *file*)
|
|
swarm-forge-dir (fs/path working-dir "swarmforge")
|
|
state-dir (fs/path working-dir ".swarmforge")
|
|
daemon-dir (fs/path state-dir "daemon")
|
|
crc (java.util.zip.CRC32.)
|
|
_ (.update crc (.getBytes (str working-dir) java.nio.charset.StandardCharsets/UTF_8))
|
|
socket-id (str (.getValue crc))
|
|
tmux-socket-dir (fs/path "/tmp" (str "swarmforge-" (or (System/getenv "UID") (System/getProperty "user.name"))))
|
|
tmux-socket (str (fs/path tmux-socket-dir (str socket-id ".sock")))]
|
|
{:working-dir working-dir
|
|
:script-dir script-dir
|
|
:swarm-forge-dir swarm-forge-dir
|
|
:worktrees-dir (fs/path working-dir ".worktrees")
|
|
:config-file (fs/path swarm-forge-dir "swarmforge.conf")
|
|
:roles-dir (fs/path swarm-forge-dir "roles")
|
|
:constitution-file (fs/path swarm-forge-dir "constitution.prompt")
|
|
:state-dir state-dir
|
|
:notify-dir (fs/path state-dir "notify")
|
|
:window-ids-file (fs/path state-dir "window-ids")
|
|
:window-state-file (fs/path state-dir "windows.tsv")
|
|
:window-watchdog-log (fs/path state-dir "window-watchdog.log")
|
|
:sessions-file (fs/path state-dir "sessions.tsv")
|
|
:roles-file (fs/path state-dir "roles.tsv")
|
|
:prompts-dir (fs/path state-dir "prompts")
|
|
:daemon-dir daemon-dir
|
|
:handoff-daemon-log (fs/path daemon-dir "handoffd.log")
|
|
:tmux-socket-dir tmux-socket-dir
|
|
:tmux-socket tmux-socket
|
|
:tmux-socket-file (fs/path state-dir "tmux-socket")
|
|
:tmux-env-file (fs/path state-dir "tmux-env")
|
|
:tmux-window-base-index 0
|
|
:tmux-pane-base-index 0}))
|
|
|
|
(defn prepare-ctx [ctx]
|
|
(-> ctx
|
|
parse-config
|
|
(assoc :terminal-backend (detect-terminal-backend))))
|
|
|
|
(defn test-parse! [root]
|
|
(let [ctx (prepare-ctx (context root))]
|
|
(prepare-workspace! ctx)
|
|
(doseq [row (:roles ctx)]
|
|
(println (str (:role row) " " (:display-name row) " " (:worktree-path row) " "
|
|
(:receive-mode row)
|
|
(when-let [extra (:extra-args row)] (str " " extra)))))
|
|
(print (slurp (str (:roles-file ctx))))
|
|
(print (slurp (str (:sessions-file ctx))))))
|
|
|
|
(defn run-main! [root]
|
|
(check-dependency! "tmux")
|
|
(check-dependency! "git")
|
|
(check-dependency! "bb")
|
|
(let [ctx (-> (context root)
|
|
detect-tmux-base-indexes)]
|
|
(initialize-git-repo! ctx)
|
|
(ensure-runtime-git-excludes! ctx)
|
|
(let [ctx (prepare-ctx ctx)]
|
|
(check-backend-dependencies! ctx)
|
|
(prepare-workspace! ctx)
|
|
(prepare-worktrees! ctx)
|
|
(prepare-handoff-dirs! ctx)
|
|
(let [ctx (assoc ctx :terminal-backend (detect-terminal-backend))]
|
|
(stop-handoff-daemon! ctx)
|
|
(doseq [row (:roles ctx)]
|
|
(when (sh-ok? "tmux" "-S" (:tmux-socket ctx) "has-session" "-t" (:session row))
|
|
(println (str yellow "Existing SwarmForge session found: " (:session row) ". Killing it..." reset))
|
|
(sh "tmux" "-S" (:tmux-socket ctx) "kill-session" "-t" (:session row))))
|
|
(println (str cyan bold))
|
|
(println " SwarmForge v1.0 Starting")
|
|
(println " Disciplined agents build better software")
|
|
(println reset)
|
|
(println (str green "Launching SwarmForge tmux sessions..." reset))
|
|
(doseq [row (:roles ctx)]
|
|
(create-role-session! ctx (:session row) (:display-name row)))
|
|
(write-tmux-env-file! ctx)
|
|
(sync-worktree-scripts! ctx)
|
|
(start-handoff-daemon! ctx)
|
|
(println (str green "Starting agents..." reset))
|
|
(let [delay-ms (env-long "SWARMFORGE_AGENT_START_DELAY_MS" 1500)]
|
|
(doseq [[index row] (map-indexed vector (:roles ctx))]
|
|
(when (pos? index)
|
|
(Thread/sleep delay-ms))
|
|
(launch-role! ctx index row)))
|
|
(println)
|
|
(println (str green bold "SwarmForge is ready." reset))
|
|
(println "Working directory:" (str (:working-dir ctx)))
|
|
(println "Sessions:")
|
|
(doseq [row (:roles ctx)]
|
|
(println (str " " (:display-name row) ": " (:session row))))
|
|
(println)
|
|
(println (str green "Tip: Write a handoff draft and run swarm_handoff.sh while the swarm is running." reset))
|
|
(println (str green "Tip: Reattach manually with 'tmux -S " (:tmux-socket ctx) " attach-session -t <session-name>' if needed." reset))
|
|
(println)
|
|
(open-terminal-surfaces! ctx)))))
|
|
|
|
(defn test-terminal-bridge! [root backend]
|
|
(let [local-script-dir (fs/path root "swarmforge" "scripts")
|
|
ctx (cond-> (assoc (context root) :terminal-backend backend)
|
|
(fs/exists? local-script-dir) (assoc :script-dir local-script-dir))]
|
|
(println (terminal-call-out ctx "terminal_open_session" "swarmforge-specifier" "SwarmForge Specifier" ""))))
|
|
|
|
(defn test-tmux-base-indexes! [tmux-socket]
|
|
(let [ctx (detect-tmux-base-indexes {:tmux-socket tmux-socket
|
|
:tmux-socket-dir (str (fs/parent (fs/path tmux-socket)))})]
|
|
(println (:tmux-window-base-index ctx) (:tmux-pane-base-index ctx))))
|
|
|
|
(defn test-launch-command! [root agent & [extra-args]]
|
|
(let [ctx (assoc (context root) :terminal-backend "none")
|
|
row {:role "coder"
|
|
:agent agent
|
|
:session "swarmforge-coder"
|
|
:display-name "Coder"
|
|
:worktree-name "master"
|
|
:worktree-path (fs/path root)
|
|
:receive-mode "task"
|
|
:extra-args extra-args}]
|
|
(fs/create-dirs (:prompts-dir ctx))
|
|
(println (launch-command ctx 1 row))))
|
|
|
|
(defn test-sleep-inhibitor-prefix! []
|
|
(println (str/join " " (or (sleep-inhibitor-prefix) []))))
|
|
|
|
(defn -main [& args]
|
|
(case (first args)
|
|
"--test-parse" (test-parse! (or (second args) (System/getProperty "user.dir")))
|
|
"--test-terminal-bridge" (test-terminal-bridge! (or (second args) (System/getProperty "user.dir")) (nth args 2))
|
|
"--test-launch-command" (apply test-launch-command!
|
|
(or (second args) (System/getProperty "user.dir"))
|
|
(drop 2 args))
|
|
"--test-agent-start-delay" (println (env-long "SWARMFORGE_AGENT_START_DELAY_MS" 1500))
|
|
"--test-sleep-inhibitor-prefix" (test-sleep-inhibitor-prefix!)
|
|
"--test-tmux-base-indexes" (test-tmux-base-indexes! (second args))
|
|
(run-main! (or (first args) (System/getProperty "user.dir")))))
|
|
|
|
(apply -main *command-line-args*)
|