mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo-client.git
synced 2026-09-21 16:52:02 -07:00
Integrating swarm-forge into project
This commit is contained in:
Executable
+66
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env bb
|
||||
|
||||
(ns done-with-current
|
||||
(:require [babashka.fs :as fs]
|
||||
[babashka.process :as process]
|
||||
[clojure.java.shell :as sh]
|
||||
[clojure.string :as str]))
|
||||
|
||||
(def script-dir (fs/parent *file*))
|
||||
|
||||
(defn exit! [status message]
|
||||
(binding [*out* *err*]
|
||||
(println message))
|
||||
(System/exit status))
|
||||
|
||||
(defn command [& args]
|
||||
(apply sh/sh args))
|
||||
|
||||
(defn git-root []
|
||||
(let [result (command "git" "rev-parse" "--show-toplevel")]
|
||||
(when (zero? (:exit result))
|
||||
(str/trim (:out result)))))
|
||||
|
||||
(defn git-common-dir []
|
||||
(let [result (command "git" "rev-parse" "--git-common-dir")]
|
||||
(when (zero? (:exit result))
|
||||
(let [path (str/trim (:out result))]
|
||||
(if (fs/absolute? path)
|
||||
path
|
||||
(str (fs/absolutize path)))))))
|
||||
|
||||
(defn project-root []
|
||||
(if-let [root (git-root)]
|
||||
(if (fs/exists? (fs/path root ".swarmforge" "roles.tsv"))
|
||||
root
|
||||
(if-let [common (git-common-dir)]
|
||||
(let [candidate (str (fs/parent common))]
|
||||
(if (fs/exists? (fs/path candidate ".swarmforge" "roles.tsv"))
|
||||
candidate
|
||||
(exit! 1 "Cannot find SwarmForge project root")))
|
||||
(exit! 1 "Cannot find SwarmForge project root")))
|
||||
(exit! 1 "Cannot find SwarmForge project root")))
|
||||
|
||||
(defn role []
|
||||
(or (not-empty (System/getenv "SWARMFORGE_ROLE"))
|
||||
(exit! 1 "Set SWARMFORGE_ROLE.")))
|
||||
|
||||
(defn receive-mode [role-name]
|
||||
(let [roles (str/split-lines (slurp (str (fs/path (project-root) ".swarmforge" "roles.tsv"))))]
|
||||
(or (some (fn [line]
|
||||
(let [fields (str/split line #"\t" -1)]
|
||||
(when (= role-name (first fields))
|
||||
(not-empty (get fields 6 "task")))))
|
||||
roles)
|
||||
(exit! 1 (str "Unknown role: " role-name)))))
|
||||
|
||||
(defn run-helper! [script]
|
||||
(process/exec (str (fs/path script-dir script))))
|
||||
|
||||
(defn -main []
|
||||
(case (receive-mode (role))
|
||||
"batch" (run-helper! "done_with_current_batch.sh")
|
||||
"task" (run-helper! "done_with_current_task.sh")
|
||||
(exit! 2 (str "INVALID_RECEIVE_MODE: " (receive-mode (role)) " for role " (role)))))
|
||||
|
||||
(-main)
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
exec bb "$SCRIPT_DIR/done_with_current.bb" "$@"
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env bb
|
||||
|
||||
(ns done-with-current-batch
|
||||
(:require [babashka.fs :as fs]
|
||||
[babashka.process :as process]
|
||||
[clojure.string :as str]))
|
||||
|
||||
(def script-dir (fs/parent *file*))
|
||||
|
||||
(defn inbox-dir []
|
||||
(fs/path (System/getProperty "user.dir") ".swarmforge" "handoffs" "inbox"))
|
||||
|
||||
(defn timestamp []
|
||||
(.format java.time.format.DateTimeFormatter/ISO_INSTANT
|
||||
(java.time.Instant/now)))
|
||||
|
||||
(defn handoff-files [dir]
|
||||
(if (fs/exists? dir)
|
||||
(->> (fs/list-dir dir)
|
||||
(filter #(and (fs/regular-file? %) (str/ends-with? (fs/file-name %) ".handoff")))
|
||||
(sort-by #(fs/file-name %))
|
||||
vec)
|
||||
[]))
|
||||
|
||||
(defn batch-dirs [dir]
|
||||
(if (fs/exists? dir)
|
||||
(->> (fs/list-dir dir)
|
||||
(filter #(and (fs/directory? %) (str/starts-with? (fs/file-name %) "batch_")))
|
||||
(sort-by #(fs/file-name %))
|
||||
vec)
|
||||
[]))
|
||||
|
||||
(defn set-header! [file field value]
|
||||
(let [lines (str/split-lines (slurp (str file)))
|
||||
prefix (str field ": ")
|
||||
tmp (fs/create-temp-file {:dir (fs/parent file) :prefix ".headers."})
|
||||
result (loop [remaining lines
|
||||
out []
|
||||
inserted? false
|
||||
replaced? false]
|
||||
(if-let [line (first remaining)]
|
||||
(cond
|
||||
(and (not inserted?) (str/blank? line))
|
||||
(recur (next remaining)
|
||||
(conj (cond-> out (not replaced?) (conj (str prefix value))) line)
|
||||
true
|
||||
replaced?)
|
||||
|
||||
(and (not inserted?) (str/starts-with? line prefix))
|
||||
(recur (next remaining) (conj out (str prefix value)) inserted? true)
|
||||
|
||||
:else
|
||||
(recur (next remaining) (conj out line) inserted? replaced?))
|
||||
(cond-> out
|
||||
(and (not inserted?) (not replaced?)) (conj (str prefix value)))))]
|
||||
(spit (str tmp) (str (str/join "\n" result) "\n"))
|
||||
(fs/move tmp file {:replace-existing true})))
|
||||
|
||||
(defn fail! [status & lines]
|
||||
(binding [*out* *err*]
|
||||
(doseq [line lines]
|
||||
(println line)))
|
||||
(System/exit status))
|
||||
|
||||
(defn run-ready! []
|
||||
(process/exec (str (fs/path script-dir "ready_for_next_batch.sh"))))
|
||||
|
||||
(defn -main []
|
||||
(let [inbox (inbox-dir)
|
||||
in-process-dir (fs/path inbox "in_process")
|
||||
completed-dir (fs/path inbox "completed")]
|
||||
(doseq [dir [in-process-dir completed-dir]]
|
||||
(fs/create-dirs dir))
|
||||
(let [in-process-batches (batch-dirs in-process-dir)
|
||||
in-process-files (handoff-files in-process-dir)]
|
||||
(when (seq in-process-files)
|
||||
(fail! 2
|
||||
"CURRENT_WORK_IS_SINGLE_TASK: use done_with_current.sh."
|
||||
(str/join "\n" (map #(str "- " %) in-process-files))))
|
||||
(when (empty? in-process-batches)
|
||||
(fail! 1 "NO_CURRENT_BATCH"))
|
||||
(when (> (count in-process-batches) 1)
|
||||
(fail! 2
|
||||
"AMBIGUOUS_TASK_STATE: multiple batches are in process."
|
||||
(str/join "\n" (map #(str "- " %) in-process-batches))))
|
||||
(let [source-dir (first in-process-batches)
|
||||
batch-files (handoff-files source-dir)
|
||||
target-dir (fs/path completed-dir (fs/file-name source-dir))
|
||||
completed-at (timestamp)]
|
||||
(when (empty? batch-files)
|
||||
(fail! 2 (str "AMBIGUOUS_TASK_STATE: batch contains no tasks: " source-dir)))
|
||||
(when (fs/exists? target-dir)
|
||||
(fail! 2 (str "AMBIGUOUS_TASK_STATE: completed batch already exists: " target-dir)))
|
||||
(fs/create-dir target-dir)
|
||||
(doseq [source-file batch-files]
|
||||
(set-header! source-file "completed_at" completed-at)
|
||||
(let [target-file (fs/path target-dir (fs/file-name source-file))]
|
||||
(when (fs/exists? target-file)
|
||||
(fail! 2 (str "AMBIGUOUS_TASK_STATE: completed batch file already exists: " target-file)))
|
||||
(fs/move source-file target-file)
|
||||
(println "COMPLETED:" (str target-file))))
|
||||
(fs/delete source-dir)
|
||||
(println "COMPLETED_BATCH:" (str target-dir))
|
||||
(run-ready!)))))
|
||||
|
||||
(-main)
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
exec bb "$SCRIPT_DIR/done_with_current_batch.bb" "$@"
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bb
|
||||
|
||||
(ns done-with-current-task
|
||||
(:require [babashka.fs :as fs]
|
||||
[babashka.process :as process]
|
||||
[clojure.string :as str]))
|
||||
|
||||
(def script-dir (fs/parent *file*))
|
||||
|
||||
(defn inbox-dir []
|
||||
(fs/path (System/getProperty "user.dir") ".swarmforge" "handoffs" "inbox"))
|
||||
|
||||
(defn timestamp []
|
||||
(.format java.time.format.DateTimeFormatter/ISO_INSTANT
|
||||
(java.time.Instant/now)))
|
||||
|
||||
(defn handoff-files [dir]
|
||||
(if (fs/exists? dir)
|
||||
(->> (fs/list-dir dir)
|
||||
(filter #(and (fs/regular-file? %) (str/ends-with? (fs/file-name %) ".handoff")))
|
||||
(sort-by #(fs/file-name %))
|
||||
vec)
|
||||
[]))
|
||||
|
||||
(defn batch-dirs [dir]
|
||||
(if (fs/exists? dir)
|
||||
(->> (fs/list-dir dir)
|
||||
(filter #(and (fs/directory? %) (str/starts-with? (fs/file-name %) "batch_")))
|
||||
(sort-by #(fs/file-name %))
|
||||
vec)
|
||||
[]))
|
||||
|
||||
(defn set-header! [file field value]
|
||||
(let [lines (str/split-lines (slurp (str file)))
|
||||
prefix (str field ": ")
|
||||
tmp (fs/create-temp-file {:dir (fs/parent file) :prefix ".headers."})
|
||||
result (loop [remaining lines
|
||||
out []
|
||||
inserted? false
|
||||
replaced? false]
|
||||
(if-let [line (first remaining)]
|
||||
(cond
|
||||
(and (not inserted?) (str/blank? line))
|
||||
(recur (next remaining)
|
||||
(conj (cond-> out (not replaced?) (conj (str prefix value))) line)
|
||||
true
|
||||
replaced?)
|
||||
|
||||
(and (not inserted?) (str/starts-with? line prefix))
|
||||
(recur (next remaining) (conj out (str prefix value)) inserted? true)
|
||||
|
||||
:else
|
||||
(recur (next remaining) (conj out line) inserted? replaced?))
|
||||
(cond-> out
|
||||
(and (not inserted?) (not replaced?)) (conj (str prefix value)))))]
|
||||
(spit (str tmp) (str (str/join "\n" result) "\n"))
|
||||
(fs/move tmp file {:replace-existing true})))
|
||||
|
||||
(defn fail! [status & lines]
|
||||
(binding [*out* *err*]
|
||||
(doseq [line lines]
|
||||
(println line)))
|
||||
(System/exit status))
|
||||
|
||||
(defn run-ready! []
|
||||
(process/exec (str (fs/path script-dir "ready_for_next_task.sh"))))
|
||||
|
||||
(defn -main []
|
||||
(let [inbox (inbox-dir)
|
||||
in-process-dir (fs/path inbox "in_process")
|
||||
completed-dir (fs/path inbox "completed")]
|
||||
(doseq [dir [in-process-dir completed-dir]]
|
||||
(fs/create-dirs dir))
|
||||
(let [in-process-batches (batch-dirs in-process-dir)
|
||||
in-process-files (handoff-files in-process-dir)]
|
||||
(when (seq in-process-batches)
|
||||
(fail! 2
|
||||
"CURRENT_WORK_IS_BATCH: use done_with_current.sh."
|
||||
(str/join "\n" (map #(str "- " %) in-process-batches))))
|
||||
(when (empty? in-process-files)
|
||||
(fail! 1 "NO_CURRENT_TASK"))
|
||||
(when (> (count in-process-files) 1)
|
||||
(fail! 2
|
||||
"AMBIGUOUS_TASK_STATE: multiple tasks are in process."
|
||||
(str/join "\n" (map #(str "- " %) in-process-files))))
|
||||
(let [source-file (first in-process-files)
|
||||
target-file (fs/path completed-dir (fs/file-name source-file))]
|
||||
(set-header! source-file "completed_at" (timestamp))
|
||||
(when (fs/exists? target-file)
|
||||
(fail! 2 (str "AMBIGUOUS_TASK_STATE: completed file already exists: " target-file)))
|
||||
(fs/move source-file target-file)
|
||||
(println "COMPLETED:" (str target-file))
|
||||
(run-ready!)))))
|
||||
|
||||
(-main)
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
exec bb "$SCRIPT_DIR/done_with_current_task.bb" "$@"
|
||||
Executable
+190
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env bb
|
||||
|
||||
(ns handoff-lib
|
||||
(:require [babashka.fs :as fs]
|
||||
[babashka.process]
|
||||
[clojure.string :as str]))
|
||||
|
||||
(defn role []
|
||||
(or (System/getenv "SWARMFORGE_ROLE")
|
||||
(throw (ex-info "Set SWARMFORGE_ROLE." {:exit 1}))))
|
||||
|
||||
(defn state-dir []
|
||||
(fs/path (System/getProperty "user.dir") ".swarmforge" "handoffs"))
|
||||
|
||||
(defn inbox-dir []
|
||||
(fs/path (state-dir) "inbox"))
|
||||
|
||||
(defn project-root []
|
||||
(let [cwd (fs/cwd)
|
||||
direct (fs/path cwd ".swarmforge" "roles.tsv")]
|
||||
(if (fs/exists? direct)
|
||||
cwd
|
||||
(let [git-root (:out (babashka.process/sh {:continue true} "git" "rev-parse" "--show-toplevel"))
|
||||
root (when-not (str/blank? git-root) (fs/path (str/trim git-root)))]
|
||||
(if (and root (fs/exists? (fs/path root ".swarmforge" "roles.tsv")))
|
||||
root
|
||||
(let [common (:out (babashka.process/sh {:continue true} "git" "rev-parse" "--git-common-dir"))
|
||||
common-path (when-not (str/blank? common)
|
||||
(let [path (fs/path (str/trim common))]
|
||||
(if (fs/absolute? path) path (fs/absolutize path))))
|
||||
common-parent (some-> common-path fs/parent)]
|
||||
(if (and common-parent (fs/exists? (fs/path common-parent ".swarmforge" "roles.tsv")))
|
||||
common-parent
|
||||
(throw (ex-info "Cannot find SwarmForge project root" {:exit 1})))))))))
|
||||
|
||||
(defn roles-file []
|
||||
(fs/path (project-root) ".swarmforge" "roles.tsv"))
|
||||
|
||||
(defn role-rows []
|
||||
(->> (str/split-lines (slurp (str (roles-file))))
|
||||
(map #(str/split % #"\t" -1))))
|
||||
|
||||
(defn role-row [role-name]
|
||||
(or (some #(when (= role-name (first %)) %) (role-rows))
|
||||
(throw (ex-info (str "Unknown role: " role-name) {:exit 1}))))
|
||||
|
||||
(defn role-known? [role-name]
|
||||
(boolean (some #(= role-name (first %)) (role-rows))))
|
||||
|
||||
(defn role-worktree-name [role-name]
|
||||
(second (role-row role-name)))
|
||||
|
||||
(defn role-receive-mode [role-name]
|
||||
(let [mode (nth (role-row role-name) 6 "")]
|
||||
(if (str/blank? mode) "task" mode)))
|
||||
|
||||
(defn timestamp []
|
||||
(.format java.time.format.DateTimeFormatter/ISO_INSTANT
|
||||
(java.time.Instant/now)))
|
||||
|
||||
(defn id-timestamp []
|
||||
(.format (java.time.format.DateTimeFormatter/ofPattern "yyyyMMdd'T'HHmmss'Z'")
|
||||
(java.time.ZonedDateTime/now java.time.ZoneOffset/UTC)))
|
||||
|
||||
(defn valid-priority? [value]
|
||||
(boolean (re-matches #"[0-9][0-9]" value)))
|
||||
|
||||
(defn header-field [file field]
|
||||
(let [prefix (str field ": ")]
|
||||
(some (fn [line]
|
||||
(when (str/starts-with? line prefix)
|
||||
(subs line (count prefix))))
|
||||
(take-while (complement str/blank?)
|
||||
(str/split-lines (slurp (str file)))))))
|
||||
|
||||
(defn body [file]
|
||||
(let [[_ body] (str/split (slurp (str file)) #"\n\n" 2)]
|
||||
(or body "")))
|
||||
|
||||
(defn set-header! [file field value]
|
||||
(let [file (fs/path file)
|
||||
lines (str/split-lines (slurp (str file)))
|
||||
prefix (str field ": ")
|
||||
tmp (fs/create-temp-file {:dir (fs/parent file) :prefix ".headers."})
|
||||
result (loop [remaining lines
|
||||
out []
|
||||
inserted? false
|
||||
replaced? false]
|
||||
(if-let [line (first remaining)]
|
||||
(cond
|
||||
(and (not inserted?) (str/blank? line))
|
||||
(recur (next remaining)
|
||||
(conj (cond-> out (not replaced?) (conj (str prefix value))) line)
|
||||
true
|
||||
replaced?)
|
||||
|
||||
(and (not inserted?) (str/starts-with? line prefix))
|
||||
(recur (next remaining) (conj out (str prefix value)) inserted? true)
|
||||
|
||||
:else
|
||||
(recur (next remaining) (conj out line) inserted? replaced?))
|
||||
(cond-> out
|
||||
(and (not inserted?) (not replaced?)) (conj (str prefix value)))))]
|
||||
(spit (str tmp) (str (str/join "\n" result) "\n"))
|
||||
(fs/move tmp file {:replace-existing true})))
|
||||
|
||||
(defn print-task [file]
|
||||
(let [task-name (header-field file "task")]
|
||||
(println "TASK:" (str file))
|
||||
(println "FROM:" (or (header-field file "from") "unknown"))
|
||||
(println "TYPE:" (or (header-field file "type") "unknown"))
|
||||
(println "PRIORITY:" (or (header-field file "priority") "50"))
|
||||
(when task-name
|
||||
(println "TASK_NAME:" task-name))
|
||||
(println "PAYLOAD:")
|
||||
(print (body file))))
|
||||
|
||||
(defn handoff-files [dir]
|
||||
(if (fs/exists? dir)
|
||||
(->> (fs/list-dir dir)
|
||||
(filter #(and (fs/regular-file? %) (str/ends-with? (fs/file-name %) ".handoff")))
|
||||
(sort-by #(fs/file-name %))
|
||||
vec)
|
||||
[]))
|
||||
|
||||
(defn print-batch [batch-dir]
|
||||
(let [files (handoff-files batch-dir)]
|
||||
(when (empty? files)
|
||||
(throw (ex-info (str "AMBIGUOUS_TASK_STATE: batch contains no tasks: " batch-dir) {:exit 2})))
|
||||
(println "BATCH:" (str batch-dir))
|
||||
(println "COUNT:" (count files))
|
||||
(println "PRIORITY:" (or (header-field (first files) "priority") "50"))
|
||||
(doseq [[index file] (map-indexed vector files)]
|
||||
(println)
|
||||
(println "BATCH_ITEM:" (inc index))
|
||||
(print-task file))))
|
||||
|
||||
(defn next-sequence []
|
||||
(let [dir (state-dir)
|
||||
seq-file (fs/path dir "sequence")
|
||||
lock-dir (fs/path dir "sequence.lock")]
|
||||
(fs/create-dirs dir)
|
||||
(loop []
|
||||
(when-not (try (fs/create-dir lock-dir) true (catch Exception _ false))
|
||||
(Thread/sleep 50)
|
||||
(recur)))
|
||||
(try
|
||||
(let [last-value (if (fs/exists? seq-file)
|
||||
(str/trim (slurp (str seq-file)))
|
||||
"0")
|
||||
last-number (if (re-matches #"[0-9]+" last-value)
|
||||
(Long/parseLong last-value)
|
||||
0)
|
||||
next-number (inc last-number)]
|
||||
(spit (str seq-file) (format "%06d\n" next-number))
|
||||
(format "%06d" next-number))
|
||||
(finally
|
||||
(fs/delete-tree lock-dir)))))
|
||||
|
||||
(defn -main [& args]
|
||||
(try
|
||||
(case (first args)
|
||||
"role" (println (role))
|
||||
"state-dir" (println (state-dir))
|
||||
"inbox-dir" (println (inbox-dir))
|
||||
"project-root" (println (project-root))
|
||||
"role-known" (System/exit (if (role-known? (second args)) 0 1))
|
||||
"role-worktree-name" (println (role-worktree-name (second args)))
|
||||
"role-receive-mode" (println (role-receive-mode (second args)))
|
||||
"timestamp" (println (timestamp))
|
||||
"id-timestamp" (println (id-timestamp))
|
||||
"valid-priority" (System/exit (if (valid-priority? (second args)) 0 1))
|
||||
"header-field" (if-let [value (header-field (second args) (nth args 2))]
|
||||
(println value)
|
||||
(System/exit 1))
|
||||
"body" (print (body (second args)))
|
||||
"set-header" (set-header! (second args) (nth args 2) (nth args 3))
|
||||
"print-task" (print-task (second args))
|
||||
"print-batch" (print-batch (second args))
|
||||
"next-sequence" (println (next-sequence))
|
||||
(do
|
||||
(binding [*out* *err*]
|
||||
(println "Usage: handoff_lib.bb <command> [args...]"))
|
||||
(System/exit 2)))
|
||||
(catch clojure.lang.ExceptionInfo e
|
||||
(binding [*out* *err*]
|
||||
(println (ex-message e)))
|
||||
(System/exit (or (:exit (ex-data e)) 1)))))
|
||||
|
||||
(apply -main *command-line-args*)
|
||||
Executable
+202
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env bb
|
||||
|
||||
(ns handoffd
|
||||
(:require [babashka.fs :as fs]
|
||||
[clojure.java.io :as io]
|
||||
[clojure.java.shell :refer [sh]]
|
||||
[clojure.string :as str]))
|
||||
|
||||
(def poll-ms 1000)
|
||||
(def wake-message
|
||||
"You have new handoff mail. If idle, run ready_for_next.sh.")
|
||||
|
||||
(defn usage []
|
||||
(binding [*out* *err*]
|
||||
(println "Usage: handoffd.bb <project-root>"))
|
||||
(System/exit 1))
|
||||
|
||||
(def project-root
|
||||
(or (first *command-line-args*) (usage)))
|
||||
|
||||
(def state-dir (fs/path project-root ".swarmforge"))
|
||||
(def daemon-dir (fs/path state-dir "daemon"))
|
||||
(def roles-file (fs/path state-dir "roles.tsv"))
|
||||
(def socket-file (fs/path state-dir "tmux-socket"))
|
||||
(def pid-file (fs/path daemon-dir "handoffd.pid"))
|
||||
(def stop-file (fs/path daemon-dir "stop"))
|
||||
(def log-file (fs/path daemon-dir "handoffd.log"))
|
||||
(def stopping-flag (atom false))
|
||||
|
||||
(defn now []
|
||||
(.format (java.time.format.DateTimeFormatter/ISO_INSTANT)
|
||||
(java.time.Instant/now)))
|
||||
|
||||
(defn log! [& parts]
|
||||
(fs/create-dirs daemon-dir)
|
||||
(spit (str log-file)
|
||||
(str (now) " " (str/join " " parts) "\n")
|
||||
:append true))
|
||||
|
||||
(defn read-lines [path]
|
||||
(when (fs/exists? path)
|
||||
(str/split-lines (slurp (str path)))))
|
||||
|
||||
(defn load-roles []
|
||||
(into {}
|
||||
(for [line (read-lines roles-file)
|
||||
:when (not (str/blank? line))
|
||||
:let [[role worktree-name worktree-path session display agent receive-mode]
|
||||
(str/split line #"\t")]]
|
||||
[role {:role role
|
||||
:worktree-name worktree-name
|
||||
:worktree-path worktree-path
|
||||
:session session
|
||||
:display display
|
||||
:agent agent
|
||||
:receive-mode (or receive-mode "task")}])))
|
||||
|
||||
(defn parse-message [path]
|
||||
(let [content (slurp (str path))
|
||||
[header body] (str/split content #"\n\n" 2)
|
||||
headers (into {}
|
||||
(for [line (str/split-lines header)
|
||||
:let [[k v] (str/split line #": " 2)]
|
||||
:when (and k v)]
|
||||
[k v]))]
|
||||
{:headers headers
|
||||
:body (or body "")
|
||||
:content content}))
|
||||
|
||||
(defn render-message [headers body]
|
||||
(let [preferred ["id" "from" "to" "recipient" "priority" "type" "role" "commit"
|
||||
"message" "created_at" "enqueued_at" "dequeued_at" "completed_at"]
|
||||
remaining (->> (keys headers)
|
||||
(remove (set preferred))
|
||||
sort)
|
||||
ordered (concat preferred remaining)]
|
||||
(str (str/join "\n"
|
||||
(for [k ordered
|
||||
:let [v (get headers k)]
|
||||
:when v]
|
||||
(str k ": " v)))
|
||||
"\n\n"
|
||||
body)))
|
||||
|
||||
(defn add-delivery-headers [message recipient]
|
||||
(-> message
|
||||
(assoc-in [:headers "recipient"] recipient)
|
||||
(assoc-in [:headers "enqueued_at"] (now))))
|
||||
|
||||
(defn target-path [role-info filename]
|
||||
(fs/path (:worktree-path role-info)
|
||||
".swarmforge" "handoffs" "inbox" "new" filename))
|
||||
|
||||
(defn notify! [socket session]
|
||||
;; Wake-up universal: el CR va EMBEBIDO en el mismo write que el texto
|
||||
;; (pi-tui descarta un C-m suelto; texto+\r en un solo send-keys -l
|
||||
;; dispara el submit en pi). El C-j final es la "robustez" original de
|
||||
;; upstream para otros TUIs (claude/codex/grok); en pi es inofensivo
|
||||
;; (nueva línea en el editor vacío tras el submit).
|
||||
(let [send-text (sh "tmux" "-S" socket "send-keys" "-t" session "-l" (str wake-message "\r"))
|
||||
_ (Thread/sleep 300)
|
||||
send-line-feed (sh "tmux" "-S" socket "send-keys" "-t" session "C-j")]
|
||||
(when-not (zero? (:exit send-text))
|
||||
(throw (ex-info "tmux send text failed" send-text)))
|
||||
(when-not (zero? (:exit send-line-feed))
|
||||
(throw (ex-info "tmux send line feed failed" send-line-feed)))))
|
||||
|
||||
(defn move-with-collision [source target-dir]
|
||||
(fs/create-dirs target-dir)
|
||||
(let [base (fs/file-name source)
|
||||
target (fs/path target-dir base)]
|
||||
(if (fs/exists? target)
|
||||
(fs/move source
|
||||
(fs/path target-dir (str (now) "_" base))
|
||||
{:replace-existing false})
|
||||
(fs/move source target {:replace-existing false}))))
|
||||
|
||||
(defn fail! [path reason]
|
||||
(let [failed-dir (fs/path (fs/parent (fs/parent path)) "failed")]
|
||||
(log! "failed" (str path) reason)
|
||||
(spit (str path ".error") (str reason "\n"))
|
||||
(move-with-collision path failed-dir)))
|
||||
|
||||
(defn deliver! [roles socket sender-role path]
|
||||
(let [filename (fs/file-name path)
|
||||
message (parse-message path)
|
||||
headers (:headers message)
|
||||
recipients (some-> (get headers "to") (str/split #",") seq)]
|
||||
(if-not recipients
|
||||
(fail! path "missing to header")
|
||||
(do
|
||||
(doseq [recipient recipients]
|
||||
(let [role-info (get roles recipient)]
|
||||
(when-not role-info
|
||||
(throw (ex-info (str "unknown recipient " recipient) {:recipient recipient})))
|
||||
(let [target (target-path role-info filename)
|
||||
delivered (add-delivery-headers message recipient)]
|
||||
(fs/create-dirs (fs/parent target))
|
||||
(when-not (fs/exists? target)
|
||||
(spit (str target) (render-message (:headers delivered) (:body delivered))))
|
||||
(notify! socket (:session role-info)))))
|
||||
(move-with-collision path
|
||||
(fs/path (get-in roles [sender-role :worktree-path])
|
||||
".swarmforge" "handoffs" "sent"))
|
||||
(log! "delivered" (str path))))))
|
||||
|
||||
(defn outbox-files [role-info]
|
||||
(let [outbox (fs/path (:worktree-path role-info) ".swarmforge" "handoffs" "outbox")]
|
||||
(when (fs/exists? outbox)
|
||||
(->> (fs/list-dir outbox)
|
||||
(filter #(and (fs/regular-file? %)
|
||||
(str/ends-with? (fs/file-name %) ".handoff")))
|
||||
(sort-by #(fs/file-name %))))))
|
||||
|
||||
(defn should-stop? []
|
||||
(or @stopping-flag (fs/exists? stop-file)))
|
||||
|
||||
(defn sleep-poll! [ms]
|
||||
(loop [remaining ms]
|
||||
(when (and (pos? remaining) (not (should-stop?)))
|
||||
(let [step (min remaining 100)]
|
||||
(Thread/sleep step)
|
||||
(recur (- remaining step))))))
|
||||
|
||||
(defn poll-once! []
|
||||
(when-not (should-stop?)
|
||||
(let [roles (load-roles)
|
||||
socket (str/trim (slurp (str socket-file)))]
|
||||
(doseq [[role role-info] roles
|
||||
path (or (outbox-files role-info) [])
|
||||
:while (not (should-stop?))]
|
||||
(try
|
||||
(deliver! roles socket role path)
|
||||
(catch Exception e
|
||||
(log! "error" (str path) (.getMessage e))
|
||||
(try
|
||||
(fail! path (.getMessage e))
|
||||
(catch Exception nested
|
||||
(log! "failed-to-archive" (str path) (.getMessage nested))))))))))
|
||||
|
||||
(defn shutdown! []
|
||||
(reset! stopping-flag true)
|
||||
(try
|
||||
(fs/delete-if-exists pid-file)
|
||||
(log! "stopped")
|
||||
(catch Exception _ nil)))
|
||||
|
||||
(defn -main []
|
||||
(fs/create-dirs daemon-dir)
|
||||
(fs/delete-if-exists stop-file)
|
||||
(spit (str pid-file) (str (.pid (java.lang.ProcessHandle/current)) "\n"))
|
||||
(.addShutdownHook (Runtime/getRuntime) (Thread. shutdown!))
|
||||
(log! "started")
|
||||
(try
|
||||
(while (not (should-stop?))
|
||||
(poll-once!)
|
||||
(sleep-poll! poll-ms))
|
||||
(finally
|
||||
(fs/delete-if-exists pid-file)
|
||||
(log! "stopped"))))
|
||||
|
||||
(-main)
|
||||
Executable
+66
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env bb
|
||||
|
||||
(ns ready-for-next
|
||||
(:require [babashka.fs :as fs]
|
||||
[babashka.process :as process]
|
||||
[clojure.java.shell :as sh]
|
||||
[clojure.string :as str]))
|
||||
|
||||
(def script-dir (fs/parent *file*))
|
||||
|
||||
(defn exit! [status message]
|
||||
(binding [*out* *err*]
|
||||
(println message))
|
||||
(System/exit status))
|
||||
|
||||
(defn command [& args]
|
||||
(apply sh/sh args))
|
||||
|
||||
(defn git-root []
|
||||
(let [result (command "git" "rev-parse" "--show-toplevel")]
|
||||
(when (zero? (:exit result))
|
||||
(str/trim (:out result)))))
|
||||
|
||||
(defn git-common-dir []
|
||||
(let [result (command "git" "rev-parse" "--git-common-dir")]
|
||||
(when (zero? (:exit result))
|
||||
(let [path (str/trim (:out result))]
|
||||
(if (fs/absolute? path)
|
||||
path
|
||||
(str (fs/absolutize path)))))))
|
||||
|
||||
(defn project-root []
|
||||
(if-let [root (git-root)]
|
||||
(if (fs/exists? (fs/path root ".swarmforge" "roles.tsv"))
|
||||
root
|
||||
(if-let [common (git-common-dir)]
|
||||
(let [candidate (str (fs/parent common))]
|
||||
(if (fs/exists? (fs/path candidate ".swarmforge" "roles.tsv"))
|
||||
candidate
|
||||
(exit! 1 "Cannot find SwarmForge project root")))
|
||||
(exit! 1 "Cannot find SwarmForge project root")))
|
||||
(exit! 1 "Cannot find SwarmForge project root")))
|
||||
|
||||
(defn role []
|
||||
(or (not-empty (System/getenv "SWARMFORGE_ROLE"))
|
||||
(exit! 1 "Set SWARMFORGE_ROLE.")))
|
||||
|
||||
(defn receive-mode [role-name]
|
||||
(let [roles (str/split-lines (slurp (str (fs/path (project-root) ".swarmforge" "roles.tsv"))))]
|
||||
(or (some (fn [line]
|
||||
(let [fields (str/split line #"\t" -1)]
|
||||
(when (= role-name (first fields))
|
||||
(not-empty (get fields 6 "task")))))
|
||||
roles)
|
||||
(exit! 1 (str "Unknown role: " role-name)))))
|
||||
|
||||
(defn run-helper! [script]
|
||||
(process/exec (str (fs/path script-dir script))))
|
||||
|
||||
(defn -main []
|
||||
(case (receive-mode (role))
|
||||
"batch" (run-helper! "ready_for_next_batch.sh")
|
||||
"task" (run-helper! "ready_for_next_task.sh")
|
||||
(exit! 2 (str "INVALID_RECEIVE_MODE: " (receive-mode (role)) " for role " (role)))))
|
||||
|
||||
(-main)
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
exec bb "$SCRIPT_DIR/ready_for_next.bb" "$@"
|
||||
Executable
+148
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env bb
|
||||
|
||||
(ns ready-for-next-batch
|
||||
(:require [babashka.fs :as fs]
|
||||
[clojure.string :as str]))
|
||||
|
||||
(defn inbox-dir []
|
||||
(fs/path (System/getProperty "user.dir") ".swarmforge" "handoffs" "inbox"))
|
||||
|
||||
(defn timestamp []
|
||||
(.format java.time.format.DateTimeFormatter/ISO_INSTANT
|
||||
(java.time.Instant/now)))
|
||||
|
||||
(defn id-timestamp []
|
||||
(.format (java.time.format.DateTimeFormatter/ofPattern "yyyyMMdd'T'HHmmss'Z'")
|
||||
(java.time.ZonedDateTime/now java.time.ZoneOffset/UTC)))
|
||||
|
||||
(defn handoff-files [dir]
|
||||
(if (fs/exists? dir)
|
||||
(->> (fs/list-dir dir)
|
||||
(filter #(and (fs/regular-file? %) (str/ends-with? (fs/file-name %) ".handoff")))
|
||||
(sort-by #(fs/file-name %))
|
||||
vec)
|
||||
[]))
|
||||
|
||||
(defn batch-dirs [dir]
|
||||
(if (fs/exists? dir)
|
||||
(->> (fs/list-dir dir)
|
||||
(filter #(and (fs/directory? %) (str/starts-with? (fs/file-name %) "batch_")))
|
||||
(sort-by #(fs/file-name %))
|
||||
vec)
|
||||
[]))
|
||||
|
||||
(defn header-field [file field]
|
||||
(let [prefix (str field ": ")]
|
||||
(some (fn [line]
|
||||
(when (str/starts-with? line prefix)
|
||||
(subs line (count prefix))))
|
||||
(take-while (complement str/blank?) (str/split-lines (slurp (str file)))))))
|
||||
|
||||
(defn header-value [file field default]
|
||||
(or (header-field file field) default))
|
||||
|
||||
(defn body [file]
|
||||
(let [[_ body] (str/split (slurp (str file)) #"\n\n" 2)]
|
||||
(or body "")))
|
||||
|
||||
(defn set-header! [file field value]
|
||||
(let [lines (str/split-lines (slurp (str file)))
|
||||
prefix (str field ": ")
|
||||
tmp (fs/create-temp-file {:dir (fs/parent file) :prefix ".headers."})
|
||||
result (loop [remaining lines
|
||||
out []
|
||||
inserted? false
|
||||
replaced? false]
|
||||
(if-let [line (first remaining)]
|
||||
(cond
|
||||
(and (not inserted?) (str/blank? line))
|
||||
(recur (next remaining)
|
||||
(conj (cond-> out (not replaced?) (conj (str prefix value))) line)
|
||||
true
|
||||
replaced?)
|
||||
|
||||
(and (not inserted?) (str/starts-with? line prefix))
|
||||
(recur (next remaining) (conj out (str prefix value)) inserted? true)
|
||||
|
||||
:else
|
||||
(recur (next remaining) (conj out line) inserted? replaced?))
|
||||
(cond-> out
|
||||
(and (not inserted?) (not replaced?)) (conj (str prefix value)))))]
|
||||
(spit (str tmp) (str (str/join "\n" result) "\n"))
|
||||
(fs/move tmp file {:replace-existing true})))
|
||||
|
||||
(defn print-task [file]
|
||||
(let [task-name (header-field file "task")]
|
||||
(println "TASK:" (str file))
|
||||
(println "FROM:" (header-value file "from" "unknown"))
|
||||
(println "TYPE:" (header-value file "type" "unknown"))
|
||||
(println "PRIORITY:" (header-value file "priority" "50"))
|
||||
(when task-name
|
||||
(println "TASK_NAME:" task-name))
|
||||
(println "PAYLOAD:")
|
||||
(print (body file))))
|
||||
|
||||
(defn print-batch [batch-dir]
|
||||
(let [files (handoff-files batch-dir)]
|
||||
(when (empty? files)
|
||||
(binding [*out* *err*]
|
||||
(println "AMBIGUOUS_TASK_STATE: batch contains no tasks:" (str batch-dir)))
|
||||
(System/exit 2))
|
||||
(println "BATCH:" (str batch-dir))
|
||||
(println "COUNT:" (count files))
|
||||
(println "PRIORITY:" (header-value (first files) "priority" "50"))
|
||||
(doseq [[index file] (map-indexed vector files)]
|
||||
(println)
|
||||
(println "BATCH_ITEM:" (inc index))
|
||||
(print-task file))))
|
||||
|
||||
(defn fail! [status & lines]
|
||||
(binding [*out* *err*]
|
||||
(doseq [line lines]
|
||||
(println line)))
|
||||
(System/exit status))
|
||||
|
||||
(defn new-batch-dir [in-process-dir]
|
||||
(loop [suffix 1]
|
||||
(let [dir (fs/path in-process-dir (format "batch_%s_%06d" (id-timestamp) suffix))]
|
||||
(if (fs/exists? dir)
|
||||
(recur (inc suffix))
|
||||
dir))))
|
||||
|
||||
(defn -main []
|
||||
(let [inbox (inbox-dir)
|
||||
new-dir (fs/path inbox "new")
|
||||
in-process-dir (fs/path inbox "in_process")
|
||||
completed-dir (fs/path inbox "completed")]
|
||||
(doseq [dir [new-dir in-process-dir completed-dir]]
|
||||
(fs/create-dirs dir))
|
||||
(let [in-process-batches (batch-dirs in-process-dir)
|
||||
in-process-files (handoff-files in-process-dir)]
|
||||
(when (seq in-process-files)
|
||||
(fail! 2
|
||||
"TASK_IN_PROCESS_IS_SINGLE: use ready_for_next.sh or done_with_current.sh."
|
||||
(str/join "\n" (map #(str "- " %) in-process-files))))
|
||||
(when (> (count in-process-batches) 1)
|
||||
(fail! 2
|
||||
"AMBIGUOUS_TASK_STATE: multiple batches are already in process."
|
||||
(str/join "\n" (map #(str "- " %) in-process-batches))))
|
||||
(if (= 1 (count in-process-batches))
|
||||
(print-batch (first in-process-batches))
|
||||
(let [new-files (handoff-files new-dir)]
|
||||
(if (empty? new-files)
|
||||
(println "NO_TASK")
|
||||
(let [batch-priority (header-value (first new-files) "priority" "50")
|
||||
batch-dir (new-batch-dir in-process-dir)
|
||||
selected-files (filter #(= batch-priority (header-value % "priority" "50")) new-files)]
|
||||
(fs/create-dir batch-dir)
|
||||
(doseq [source-file selected-files]
|
||||
(let [target-file (fs/path batch-dir (fs/file-name source-file))]
|
||||
(when (fs/exists? target-file)
|
||||
(fail! 2 (str "AMBIGUOUS_TASK_STATE: target batch file already exists: " target-file)))
|
||||
(fs/move source-file target-file)
|
||||
(set-header! target-file "dequeued_at" (timestamp))))
|
||||
(when (empty? selected-files)
|
||||
(fail! 2 (str "AMBIGUOUS_TASK_STATE: no tasks selected for batch priority " batch-priority ".")))
|
||||
(print-batch batch-dir))))))))
|
||||
|
||||
(-main)
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
exec bb "$SCRIPT_DIR/ready_for_next_batch.bb" "$@"
|
||||
Executable
+120
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env bb
|
||||
|
||||
(ns ready-for-next-task
|
||||
(:require [babashka.fs :as fs]
|
||||
[clojure.string :as str]))
|
||||
|
||||
(defn state-dir []
|
||||
(fs/path (System/getProperty "user.dir") ".swarmforge" "handoffs"))
|
||||
|
||||
(defn inbox-dir []
|
||||
(fs/path (state-dir) "inbox"))
|
||||
|
||||
(defn timestamp []
|
||||
(.format java.time.format.DateTimeFormatter/ISO_INSTANT
|
||||
(java.time.Instant/now)))
|
||||
|
||||
(defn handoff-files [dir]
|
||||
(if (fs/exists? dir)
|
||||
(->> (fs/list-dir dir)
|
||||
(filter #(and (fs/regular-file? %) (str/ends-with? (fs/file-name %) ".handoff")))
|
||||
(sort-by #(fs/file-name %))
|
||||
vec)
|
||||
[]))
|
||||
|
||||
(defn batch-dirs [dir]
|
||||
(if (fs/exists? dir)
|
||||
(->> (fs/list-dir dir)
|
||||
(filter #(and (fs/directory? %) (str/starts-with? (fs/file-name %) "batch_")))
|
||||
(sort-by #(fs/file-name %))
|
||||
vec)
|
||||
[]))
|
||||
|
||||
(defn header-field [file field]
|
||||
(let [prefix (str field ": ")]
|
||||
(some (fn [line]
|
||||
(when (str/starts-with? line prefix)
|
||||
(subs line (count prefix))))
|
||||
(take-while (complement str/blank?) (str/split-lines (slurp (str file)))))))
|
||||
|
||||
(defn header-value [file field default]
|
||||
(or (header-field file field) default))
|
||||
|
||||
(defn body [file]
|
||||
(let [[_ body] (str/split (slurp (str file)) #"\n\n" 2)]
|
||||
(or body "")))
|
||||
|
||||
(defn set-header! [file field value]
|
||||
(let [lines (str/split-lines (slurp (str file)))
|
||||
prefix (str field ": ")
|
||||
tmp (fs/create-temp-file {:dir (fs/parent file) :prefix ".headers."})
|
||||
result (loop [remaining lines
|
||||
out []
|
||||
inserted? false
|
||||
replaced? false]
|
||||
(if-let [line (first remaining)]
|
||||
(cond
|
||||
(and (not inserted?) (str/blank? line))
|
||||
(recur (next remaining)
|
||||
(conj (cond-> out (not replaced?) (conj (str prefix value))) line)
|
||||
true
|
||||
replaced?)
|
||||
|
||||
(and (not inserted?) (str/starts-with? line prefix))
|
||||
(recur (next remaining) (conj out (str prefix value)) inserted? true)
|
||||
|
||||
:else
|
||||
(recur (next remaining) (conj out line) inserted? replaced?))
|
||||
(cond-> out
|
||||
(and (not inserted?) (not replaced?)) (conj (str prefix value)))))]
|
||||
(spit (str tmp) (str (str/join "\n" result) "\n"))
|
||||
(fs/move tmp file {:replace-existing true})))
|
||||
|
||||
(defn print-task [file]
|
||||
(let [task-name (header-field file "task")]
|
||||
(println "TASK:" (str file))
|
||||
(println "FROM:" (header-value file "from" "unknown"))
|
||||
(println "TYPE:" (header-value file "type" "unknown"))
|
||||
(println "PRIORITY:" (header-value file "priority" "50"))
|
||||
(when task-name
|
||||
(println "TASK_NAME:" task-name))
|
||||
(println "PAYLOAD:")
|
||||
(print (body file))))
|
||||
|
||||
(defn fail! [status & lines]
|
||||
(binding [*out* *err*]
|
||||
(doseq [line lines]
|
||||
(println line)))
|
||||
(System/exit status))
|
||||
|
||||
(defn -main []
|
||||
(let [inbox (inbox-dir)
|
||||
new-dir (fs/path inbox "new")
|
||||
in-process-dir (fs/path inbox "in_process")
|
||||
completed-dir (fs/path inbox "completed")]
|
||||
(doseq [dir [new-dir in-process-dir completed-dir]]
|
||||
(fs/create-dirs dir))
|
||||
(let [in-process-batches (batch-dirs in-process-dir)
|
||||
in-process-files (handoff-files in-process-dir)]
|
||||
(when (seq in-process-batches)
|
||||
(fail! 2
|
||||
"TASK_IN_PROCESS_IS_BATCH: use ready_for_next.sh or done_with_current.sh."
|
||||
(str/join "\n" (map #(str "- " %) in-process-batches))))
|
||||
(when (> (count in-process-files) 1)
|
||||
(fail! 2
|
||||
"AMBIGUOUS_TASK_STATE: multiple tasks are already in process."
|
||||
(str/join "\n" (map #(str "- " %) in-process-files))))
|
||||
(if (= 1 (count in-process-files))
|
||||
(print-task (first in-process-files))
|
||||
(let [new-files (handoff-files new-dir)]
|
||||
(if (empty? new-files)
|
||||
(println "NO_TASK")
|
||||
(let [source-file (first new-files)
|
||||
target-file (fs/path in-process-dir (fs/file-name source-file))]
|
||||
(when (fs/exists? target-file)
|
||||
(fail! 2 (str "AMBIGUOUS_TASK_STATE: target in-process file already exists: " target-file)))
|
||||
(fs/move source-file target-file)
|
||||
(set-header! target-file "dequeued_at" (timestamp))
|
||||
(print-task target-file))))))))
|
||||
|
||||
(-main)
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
exec bb "$SCRIPT_DIR/ready_for_next_task.bb" "$@"
|
||||
Executable
+45
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env bb
|
||||
|
||||
(ns stop-handoff-daemon
|
||||
(:require [babashka.fs :as fs]
|
||||
[babashka.process :as process]
|
||||
[clojure.string :as str]))
|
||||
|
||||
(def default-timeout-ms 5000)
|
||||
(def poll-ms 100)
|
||||
|
||||
(defn usage []
|
||||
(binding [*out* *err*]
|
||||
(println "Usage: stop_handoff_daemon.bb <project-root>"))
|
||||
(System/exit 1))
|
||||
|
||||
(defn process-alive? [pid]
|
||||
(zero? (:exit (process/sh {:continue true} "kill" "-0" pid))))
|
||||
|
||||
(defn stop! [project-root & {:keys [timeout-ms] :or {timeout-ms default-timeout-ms}}]
|
||||
(let [daemon-dir (fs/path project-root ".swarmforge" "daemon")
|
||||
pid-file (fs/path daemon-dir "handoffd.pid")
|
||||
stop-file (fs/path daemon-dir "stop")]
|
||||
(fs/create-dirs daemon-dir)
|
||||
(when-not (fs/exists? stop-file)
|
||||
(spit (str stop-file) ""))
|
||||
(when (fs/exists? pid-file)
|
||||
(let [pid (str/trim (slurp (str pid-file)))]
|
||||
(when (re-matches #"[0-9]+" pid)
|
||||
(when (process-alive? pid)
|
||||
(process/sh {:continue true} "kill" "-TERM" pid)
|
||||
(loop [waited 0]
|
||||
(when (and (< waited timeout-ms) (process-alive? pid))
|
||||
(Thread/sleep poll-ms)
|
||||
(recur (+ waited poll-ms))))
|
||||
(when (process-alive? pid)
|
||||
(process/sh {:continue true} "kill" "-KILL" pid)
|
||||
(Thread/sleep poll-ms)))))
|
||||
(fs/delete-if-exists pid-file))
|
||||
(fs/delete-if-exists stop-file)))
|
||||
|
||||
(defn -main [& args]
|
||||
(stop! (or (first args) (usage)))
|
||||
(System/exit 0))
|
||||
|
||||
(apply -main *command-line-args*)
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
exec bb "$SCRIPT_DIR/stop_handoff_daemon.bb" "$@"
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -lt 2 ]]; then
|
||||
echo "Usage: swarm-cleanup.sh <tmux-socket> <window-ids-file> [session ...]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TMUX_SOCKET="$1"
|
||||
WINDOW_IDS_FILE="$2"
|
||||
TERMINAL_BACKEND="${SWARMFORGE_TERMINAL_BACKEND:-terminal-app}"
|
||||
WORKING_DIR="$(cd "$(dirname "$WINDOW_IDS_FILE")/.." && pwd)"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
shift
|
||||
shift
|
||||
|
||||
has_command() {
|
||||
command -v "$1" &>/dev/null
|
||||
}
|
||||
|
||||
source "$SCRIPT_DIR/swarm-terminal-adapter.sh"
|
||||
load_terminal_backend "$TERMINAL_BACKEND"
|
||||
|
||||
if has_command bb; then
|
||||
bb "$SCRIPT_DIR/stop_handoff_daemon.bb" "$WORKING_DIR" 2>/dev/null || true
|
||||
else
|
||||
DAEMON_PID_FILE="$WORKING_DIR/.swarmforge/daemon/handoffd.pid"
|
||||
if [[ -f "$DAEMON_PID_FILE" ]]; then
|
||||
daemon_pid="$(< "$DAEMON_PID_FILE")"
|
||||
if [[ "$daemon_pid" =~ ^[0-9]+$ ]]; then
|
||||
kill -TERM "$daemon_pid" 2>/dev/null || true
|
||||
fi
|
||||
rm -f "$DAEMON_PID_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
for session in "$@"; do
|
||||
tmux -S "$TMUX_SOCKET" kill-session -t "$session" 2>/dev/null || true
|
||||
done
|
||||
|
||||
sleep 1
|
||||
|
||||
if [[ -f "$WINDOW_IDS_FILE" ]]; then
|
||||
while IFS= read -r window_id; do
|
||||
[[ -n "$window_id" ]] || continue
|
||||
terminal_close_window "$window_id"
|
||||
done < "$WINDOW_IDS_FILE"
|
||||
fi
|
||||
Executable
+60
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
TERMINAL_ADAPTERS_DIR="${SCRIPT_DIR:-$(cd "$(dirname "$0")" && pwd)}/terminal-adapters"
|
||||
|
||||
normalize_terminal_backend() {
|
||||
local backend="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')"
|
||||
|
||||
case "$backend" in
|
||||
iterm|iterm2|iterm.app)
|
||||
echo "iterm2"
|
||||
;;
|
||||
terminal|terminal-app|terminal.app)
|
||||
echo "terminal-app"
|
||||
;;
|
||||
windows|windows-terminal|wt)
|
||||
echo "windows-terminal"
|
||||
;;
|
||||
none|current|fallback)
|
||||
echo "none"
|
||||
;;
|
||||
*)
|
||||
echo "$backend"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
detect_terminal_backend() {
|
||||
if [[ -n "${SWARMFORGE_TERMINAL:-}" ]]; then
|
||||
normalize_terminal_backend "$SWARMFORGE_TERMINAL"
|
||||
return
|
||||
fi
|
||||
|
||||
if has_command osascript; then
|
||||
if [[ "${TERM_PROGRAM:-}" == "iTerm.app" ]]; then
|
||||
echo "iterm2"
|
||||
return
|
||||
fi
|
||||
echo "terminal-app"
|
||||
return
|
||||
fi
|
||||
|
||||
if has_command wt.exe; then
|
||||
echo "windows-terminal"
|
||||
return
|
||||
fi
|
||||
|
||||
echo "none"
|
||||
}
|
||||
|
||||
load_terminal_backend() {
|
||||
local backend="$1"
|
||||
local adapter_file="$TERMINAL_ADAPTERS_DIR/$backend.sh"
|
||||
|
||||
if [[ ! -r "$adapter_file" ]]; then
|
||||
echo "Unknown terminal backend '$backend'. Expected adapter file: $adapter_file" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
source "$adapter_file"
|
||||
}
|
||||
Executable
+119
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env bb
|
||||
|
||||
(ns swarm-window-watchdog
|
||||
(:require [babashka.fs :as fs]
|
||||
[babashka.process :as process]
|
||||
[clojure.string :as str]))
|
||||
|
||||
(def missing-threshold 3)
|
||||
|
||||
(defn sq [value]
|
||||
(str "'" (str/replace (str value) #"'" "'\"'\"'") "'"))
|
||||
|
||||
(defn rows [window-state-file]
|
||||
(when (fs/exists? window-state-file)
|
||||
(->> (str/split-lines (slurp (str window-state-file)))
|
||||
(remove str/blank?)
|
||||
(map #(zipmap [:index :window-id :session :title]
|
||||
(str/split % #"\t" -1)))
|
||||
vec)))
|
||||
|
||||
(defn write-rows! [window-state-file window-ids-file rows]
|
||||
(spit (str window-state-file)
|
||||
(apply str
|
||||
(for [{:keys [index window-id session title]} rows]
|
||||
(format "%s\t%s\t%s\t%s\n" index window-id session title))))
|
||||
(spit (str window-ids-file)
|
||||
(apply str (for [{:keys [window-id]} rows] (str window-id "\n")))))
|
||||
|
||||
(defn rewrite-window-id! [window-state-file window-ids-file target-index replacement-id]
|
||||
(write-rows! window-state-file
|
||||
window-ids-file
|
||||
(mapv #(if (= (:index %) target-index)
|
||||
(assoc % :window-id replacement-id)
|
||||
%)
|
||||
(rows window-state-file))))
|
||||
|
||||
(defn adapter-script [script-dir working-dir tmux-socket backend command & args]
|
||||
(let [script (str "SCRIPT_DIR=" (sq (str script-dir)) "\n"
|
||||
"WORKING_DIR=" (sq (str working-dir)) "\n"
|
||||
"TMUX_SOCKET=" (sq tmux-socket) "\n"
|
||||
"source " (sq (str (fs/path script-dir "swarm-terminal-adapter.sh")))
|
||||
" && load_terminal_backend " (sq backend)
|
||||
" && " command
|
||||
(apply str (map #(str " " (sq %)) args)))]
|
||||
["bash" "-c" script]))
|
||||
|
||||
(defn terminal-ok? [script-dir working-dir tmux-socket backend command & args]
|
||||
(zero? (:exit (apply process/sh (concat [{:continue true}]
|
||||
(apply adapter-script script-dir working-dir tmux-socket backend command args))))))
|
||||
|
||||
(defn terminal-out [script-dir working-dir tmux-socket backend command & args]
|
||||
(str/trim (:out (apply process/sh (apply adapter-script script-dir working-dir tmux-socket backend command args)))))
|
||||
|
||||
(defn tmux-session? [tmux-socket session]
|
||||
(zero? (:exit (process/sh {:continue true} "tmux" "-S" tmux-socket "has-session" "-t" session))))
|
||||
|
||||
(defn kill-session! [tmux-socket session]
|
||||
(process/sh {:continue true} "tmux" "-S" tmux-socket "kill-session" "-t" session))
|
||||
|
||||
(defn stop-handoff-daemon! [script-dir working-dir]
|
||||
(process/sh {:continue true}
|
||||
"bb" (str (fs/path script-dir "stop_handoff_daemon.bb"))
|
||||
(str working-dir)))
|
||||
|
||||
(defn kill-all-sessions! [script-dir window-state-file working-dir tmux-socket backend]
|
||||
(stop-handoff-daemon! script-dir working-dir)
|
||||
(doseq [{:keys [session]} (rows window-state-file)]
|
||||
(when-not (str/blank? session)
|
||||
(kill-session! tmux-socket session)))
|
||||
(doseq [{:keys [window-id]} (rows window-state-file)]
|
||||
(when-not (str/blank? window-id)
|
||||
(terminal-ok? script-dir working-dir tmux-socket backend "terminal_close_window" window-id))))
|
||||
|
||||
(defn -main [& args]
|
||||
(let [[window-state-file window-ids-file cleanup-owner-index tmux-socket working-dir backend] args
|
||||
window-state-file (fs/path window-state-file)
|
||||
window-ids-file (fs/path window-ids-file)
|
||||
backend (or backend "terminal-app")
|
||||
script-dir (fs/parent *file*)]
|
||||
(when (= "--rewrite-window-id" (first args))
|
||||
(let [[_ state ids target replacement] args]
|
||||
(rewrite-window-id! (fs/path state) (fs/path ids) target replacement)
|
||||
(System/exit 0)))
|
||||
(loop [missing-counts {}]
|
||||
(when (fs/exists? window-state-file)
|
||||
(let [current-rows (rows window-state-file)
|
||||
cleanup-row (some #(when (= cleanup-owner-index (:index %)) %) current-rows)]
|
||||
(when (and cleanup-row (tmux-session? tmux-socket (:session cleanup-row)))
|
||||
(let [cleanup-window-id (:window-id cleanup-row)]
|
||||
(if (terminal-ok? script-dir working-dir tmux-socket backend "terminal_window_exists" cleanup-window-id)
|
||||
(let [missing-counts (assoc missing-counts cleanup-owner-index 0)
|
||||
missing-counts
|
||||
(reduce
|
||||
(fn [counts {:keys [index window-id session title]}]
|
||||
(if (or (= index cleanup-owner-index)
|
||||
(not (tmux-session? tmux-socket session)))
|
||||
counts
|
||||
(if (terminal-ok? script-dir working-dir tmux-socket backend "terminal_window_exists" window-id)
|
||||
(assoc counts index 0)
|
||||
(let [count (inc (get counts index 0))]
|
||||
(if (< count missing-threshold)
|
||||
(assoc counts index count)
|
||||
(let [new-window-id (terminal-out script-dir working-dir tmux-socket backend
|
||||
"terminal_open_session" session title cleanup-window-id)]
|
||||
(when-not (str/blank? new-window-id)
|
||||
(rewrite-window-id! window-state-file window-ids-file index new-window-id))
|
||||
(assoc counts index 0)))))))
|
||||
missing-counts
|
||||
current-rows)]
|
||||
(Thread/sleep 2000)
|
||||
(recur missing-counts))
|
||||
(let [count (inc (get missing-counts cleanup-owner-index 0))]
|
||||
(if (>= count missing-threshold)
|
||||
(kill-all-sessions! script-dir window-state-file working-dir tmux-socket backend)
|
||||
(do
|
||||
(Thread/sleep 2000)
|
||||
(recur (assoc missing-counts cleanup-owner-index count)))))))))))))
|
||||
|
||||
(apply -main *command-line-args*)
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
exec bb "$SCRIPT_DIR/swarm-window-watchdog.bb" "$@"
|
||||
Executable
+338
@@ -0,0 +1,338 @@
|
||||
#!/usr/bin/env bb
|
||||
|
||||
(ns swarm-handoff
|
||||
(:require [babashka.fs :as fs]
|
||||
[clojure.java.shell :refer [sh]]
|
||||
[clojure.string :as str]))
|
||||
|
||||
(def usage-text
|
||||
(str "Usage: swarm_handoff.sh <draft-file>\n\n"
|
||||
"Draft formats:\n\n"
|
||||
"type: git_handoff\n"
|
||||
"to: <role>[,<role>...]\n"
|
||||
"priority: NN\n"
|
||||
"task: <short-stable-task-name>\n"
|
||||
"commit: <10-char-commit-abbrev>\n\n"
|
||||
"type: note\n"
|
||||
"to: <role>[,<role>...]\n"
|
||||
"priority: NN\n"
|
||||
"message: <one line, max 80 chars>"))
|
||||
|
||||
(def reserved-fields #{"id" "from" "role" "recipient" "created_at" "enqueued_at" "dequeued_at" "completed_at"})
|
||||
(def allowed-fields #{"type" "to" "priority" "task" "commit" "message"})
|
||||
(def allowed-types #{"git_handoff" "note"})
|
||||
|
||||
(defn usage []
|
||||
(binding [*out* *err*]
|
||||
(println usage-text)))
|
||||
|
||||
(defn exit! [status message]
|
||||
(binding [*out* *err*]
|
||||
(when message
|
||||
(println message)))
|
||||
(System/exit status))
|
||||
|
||||
(defn command
|
||||
([dir & args]
|
||||
(let [result (apply sh (concat args [:dir (str dir)]))]
|
||||
result)))
|
||||
|
||||
(defn git-root []
|
||||
(let [result (command "." "git" "rev-parse" "--show-toplevel")]
|
||||
(when (zero? (:exit result))
|
||||
(str/trim (:out result)))))
|
||||
|
||||
(defn git-common-dir []
|
||||
(let [result (command "." "git" "rev-parse" "--git-common-dir")]
|
||||
(when (zero? (:exit result))
|
||||
(let [path (str/trim (:out result))]
|
||||
(if (fs/absolute? path)
|
||||
(str (fs/path path))
|
||||
(str (fs/absolutize path)))))))
|
||||
|
||||
(defn project-root []
|
||||
(if-let [root (git-root)]
|
||||
(if (fs/exists? (fs/path root ".swarmforge" "roles.tsv"))
|
||||
root
|
||||
(if-let [common (git-common-dir)]
|
||||
(let [candidate (str (fs/parent common))]
|
||||
(if (fs/exists? (fs/path candidate ".swarmforge" "roles.tsv"))
|
||||
candidate
|
||||
(exit! 1 "Cannot find SwarmForge project root")))
|
||||
(exit! 1 "Cannot find SwarmForge project root")))
|
||||
(exit! 1 "Cannot find SwarmForge project root")))
|
||||
|
||||
(defn roles-file []
|
||||
(fs/path (project-root) ".swarmforge" "roles.tsv"))
|
||||
|
||||
(defn role-known? [role]
|
||||
(some (fn [line]
|
||||
(= role (first (str/split line #"\t"))))
|
||||
(str/split-lines (slurp (str (roles-file))))))
|
||||
|
||||
(defn sender-role []
|
||||
(if-let [role (not-empty (System/getenv "SWARMFORGE_ROLE"))]
|
||||
role
|
||||
(exit! 1 "Set SWARMFORGE_ROLE.")))
|
||||
|
||||
(defn state-dir []
|
||||
(fs/path (System/getProperty "user.dir") ".swarmforge" "handoffs"))
|
||||
|
||||
(defn timestamp []
|
||||
(.format java.time.format.DateTimeFormatter/ISO_INSTANT
|
||||
(java.time.Instant/now)))
|
||||
|
||||
(defn id-timestamp []
|
||||
(.format (java.time.format.DateTimeFormatter/ofPattern "yyyyMMdd'T'HHmmss'Z'")
|
||||
(.atZone (java.time.Instant/now) java.time.ZoneOffset/UTC)))
|
||||
|
||||
(defn valid-priority? [priority]
|
||||
(boolean (re-matches #"[0-9][0-9]" priority)))
|
||||
|
||||
(defn parse-draft [draft]
|
||||
(loop [lines (str/split-lines (slurp (str draft)))
|
||||
line-no 0
|
||||
body-seen? false
|
||||
headers {}
|
||||
ordered []
|
||||
errors []]
|
||||
(if-let [line (first lines)]
|
||||
(let [line-no (inc line-no)]
|
||||
(cond
|
||||
body-seen?
|
||||
(recur (next lines) line-no body-seen? headers ordered
|
||||
(cond-> errors
|
||||
(not (str/blank? line))
|
||||
(conj (format "Line %d: draft handoffs may contain headers only; payloads are generated by swarm_handoff.sh." line-no))))
|
||||
|
||||
(str/blank? line)
|
||||
(recur (next lines) line-no true headers ordered errors)
|
||||
|
||||
(not (str/includes? line ": "))
|
||||
(recur (next lines) line-no body-seen? headers ordered
|
||||
(conj errors (format "Line %d: expected 'field: value'." line-no)))
|
||||
|
||||
:else
|
||||
(let [[field value] (str/split line #": " 2)]
|
||||
(cond
|
||||
(or (str/blank? field) (str/blank? value))
|
||||
(recur (next lines) line-no body-seen? headers ordered
|
||||
(conj errors (format "Line %d: field and value must both be non-empty." line-no)))
|
||||
|
||||
(reserved-fields field)
|
||||
(recur (next lines) line-no body-seen? headers ordered
|
||||
(conj errors (format "Line %d: header '%s' is reserved and must not be written by agents." line-no field)))
|
||||
|
||||
(not (allowed-fields field))
|
||||
(recur (next lines) line-no body-seen? headers ordered
|
||||
(conj errors (format "Line %d: unknown header '%s'." line-no field)))
|
||||
|
||||
(contains? headers field)
|
||||
(recur (next lines) line-no body-seen? headers ordered
|
||||
(conj errors (format "Line %d: duplicate header '%s'." line-no field)))
|
||||
|
||||
:else
|
||||
(recur (next lines) line-no body-seen? (assoc headers field value) (conj ordered field) errors)))))
|
||||
{:headers headers :ordered ordered :errors errors})))
|
||||
|
||||
(defn validate-recipients [to]
|
||||
(if (str/blank? to)
|
||||
[[] []]
|
||||
(let [recipients (str/split to #"," -1)]
|
||||
[recipients
|
||||
(loop [remaining recipients seen #{} errors []]
|
||||
(if-let [recipient (first remaining)]
|
||||
(let [errors (cond-> errors
|
||||
(str/blank? recipient)
|
||||
(conj "Header 'to' contains an empty recipient.")
|
||||
(str/includes? recipient "_")
|
||||
(conj (format "Recipient role '%s' is invalid; role names may not contain underscores." recipient))
|
||||
(contains? seen recipient)
|
||||
(conj (format "Duplicate recipient '%s'." recipient))
|
||||
(and (not (str/blank? recipient)) (not (role-known? recipient)))
|
||||
(conj (format "Unknown recipient role '%s'." recipient)))]
|
||||
(recur (next remaining) (conj seen recipient) errors))
|
||||
errors))])))
|
||||
|
||||
(defn canonical-commit [commit]
|
||||
(let [matches (-> (command "." "git" "rev-parse" (str "--disambiguate=" commit))
|
||||
:out
|
||||
str/split-lines
|
||||
vec)]
|
||||
(cond
|
||||
(not= 1 (count matches))
|
||||
[nil (format "Header 'commit' must resolve to exactly one Git object; '%s' matched %d." commit (count matches))]
|
||||
|
||||
:else
|
||||
(let [object (first matches)
|
||||
object-type (str/trim (:out (command "." "git" "cat-file" "-t" object)))]
|
||||
(if (= "commit" object-type)
|
||||
[(str/trim (:out (command "." "git" "rev-parse" "--short=10" object))) nil]
|
||||
[nil (format "Header 'commit' must resolve to a commit; '%s' resolves to '%s'." commit object-type)])))))
|
||||
|
||||
(defn validate [headers ordered]
|
||||
(let [type (get headers "type")
|
||||
to (get headers "to")
|
||||
priority (get headers "priority")
|
||||
commit (get headers "commit")
|
||||
task-name (get headers "task")
|
||||
note-message (get headers "message")
|
||||
[recipients recipient-errors] (validate-recipients to)
|
||||
field-errors (for [field ordered
|
||||
:let [valid? (case [type field]
|
||||
["git_handoff" "type"] true
|
||||
["git_handoff" "to"] true
|
||||
["git_handoff" "priority"] true
|
||||
["git_handoff" "task"] true
|
||||
["git_handoff" "commit"] true
|
||||
["note" "type"] true
|
||||
["note" "to"] true
|
||||
["note" "priority"] true
|
||||
["note" "message"] true
|
||||
false)]
|
||||
:when (and type (not valid?))]
|
||||
(format "Header '%s' is not allowed for type '%s'." field type))
|
||||
base-errors (cond-> []
|
||||
(str/blank? type) (conj "Missing required header 'type'.")
|
||||
(str/blank? to) (conj "Missing required header 'to'.")
|
||||
(str/blank? priority) (conj "Missing required header 'priority'.")
|
||||
(and (not (str/blank? type)) (not (allowed-types type)))
|
||||
(conj (format "Header 'type' must be one of git_handoff or note; got '%s'." type))
|
||||
(and (not (str/blank? priority)) (not (valid-priority? priority)))
|
||||
(conj (format "Header 'priority' must be two digits from 00 to 99; got '%s'." priority)))
|
||||
[canonical commit-error]
|
||||
(if (= "git_handoff" type)
|
||||
(cond
|
||||
(str/blank? commit) [nil "Missing required header 'commit' for git_handoff."]
|
||||
(not (re-matches #"[0-9a-fA-F]{10}" commit))
|
||||
[nil (format "Header 'commit' must be exactly 10 hexadecimal characters; got '%s'." commit)]
|
||||
:else (canonical-commit commit))
|
||||
[nil nil])
|
||||
git-errors (cond-> []
|
||||
(= "git_handoff" type)
|
||||
(into (cond-> []
|
||||
(str/blank? task-name)
|
||||
(conj "Missing required header 'task' for git_handoff.")
|
||||
(> (count (or task-name "")) 80)
|
||||
(conj (format "Header 'task' must be no longer than 80 characters; got %d." (count task-name)))))
|
||||
(and (not= "git_handoff" type) (not (str/blank? commit)))
|
||||
(conj "Header 'commit' is only allowed for git_handoff.")
|
||||
(and (not= "git_handoff" type) (not (str/blank? task-name)))
|
||||
(conj "Header 'task' is only allowed for git_handoff.")
|
||||
commit-error
|
||||
(conj commit-error))
|
||||
note-errors (cond-> []
|
||||
(= "note" type)
|
||||
(into (cond-> []
|
||||
(str/blank? note-message)
|
||||
(conj "Missing required header 'message' for note.")
|
||||
(> (count (or note-message "")) 80)
|
||||
(conj (format "Header 'message' must be no longer than 80 characters; got %d." (count note-message)))))
|
||||
(and (not= "note" type) (not (str/blank? note-message)))
|
||||
(conj "Header 'message' is only allowed for note."))]
|
||||
{:recipients recipients
|
||||
:canonical-commit canonical
|
||||
:errors (vec (concat base-errors recipient-errors field-errors git-errors note-errors))}))
|
||||
|
||||
(defn next-sequence []
|
||||
(let [dir (state-dir)
|
||||
seq-file (fs/path dir "sequence")
|
||||
lock-dir (fs/path dir "sequence.lock")]
|
||||
(fs/create-dirs dir)
|
||||
(loop []
|
||||
(if (try
|
||||
(fs/create-dir lock-dir)
|
||||
true
|
||||
(catch java.nio.file.FileAlreadyExistsException _
|
||||
false))
|
||||
nil
|
||||
(do
|
||||
(Thread/sleep 50)
|
||||
(recur))))
|
||||
(try
|
||||
(let [last-value (if (fs/exists? seq-file)
|
||||
(try
|
||||
(Long/parseLong (str/trim (slurp (str seq-file))))
|
||||
(catch Exception _ 0))
|
||||
0)
|
||||
next-value (inc last-value)
|
||||
formatted (format "%06d" next-value)]
|
||||
(spit (str seq-file) (str formatted "\n"))
|
||||
formatted)
|
||||
(finally
|
||||
(fs/delete lock-dir)))))
|
||||
|
||||
(defn body [type sender canonical-commit note-message]
|
||||
(case type
|
||||
"git_handoff" (str "Re-read your role and constitution.\n\nmerge_and_process " sender " " canonical-commit)
|
||||
"note" (str "Re-read your role and constitution.\n\n" note-message)))
|
||||
|
||||
(defn write-handoff! [{:keys [headers recipients canonical-commit sender]}]
|
||||
(let [timestamp-id (id-timestamp)
|
||||
created-at (timestamp)
|
||||
sequence (next-sequence)
|
||||
id (str timestamp-id "_" sequence "_from_" sender)
|
||||
recipient-slug (str/join "_" recipients)
|
||||
priority (get headers "priority")
|
||||
type (get headers "type")
|
||||
filename (str priority "_" timestamp-id "_" sequence "_from_" sender "_to_" recipient-slug ".handoff")
|
||||
outbox-dir (fs/path (state-dir) "outbox")
|
||||
tmp-dir (fs/path outbox-dir "tmp")
|
||||
tmp-file (fs/path tmp-dir (str filename ".tmp"))
|
||||
outbox-file (fs/path outbox-dir filename)
|
||||
handoff-body (body type sender canonical-commit (get headers "message"))
|
||||
lines (cond-> [(str "id: " id)
|
||||
(str "from: " sender)
|
||||
(str "to: " (str/join "," recipients))
|
||||
(str "priority: " priority)
|
||||
(str "type: " type)]
|
||||
(= "git_handoff" type)
|
||||
(conj (str "role: " sender)
|
||||
(str "task: " (get headers "task"))
|
||||
(str "commit: " canonical-commit))
|
||||
(= "note" type)
|
||||
(conj (str "message: " (get headers "message")))
|
||||
true
|
||||
(conj (str "created_at: " created-at)
|
||||
""
|
||||
handoff-body))]
|
||||
(doseq [dir [tmp-dir outbox-dir (fs/path (state-dir) "sent") (fs/path (state-dir) "failed")]]
|
||||
(fs/create-dirs dir))
|
||||
(spit (str tmp-file) (str (str/join "\n" lines) "\n"))
|
||||
(fs/move tmp-file outbox-file)
|
||||
outbox-file))
|
||||
|
||||
(defn error-report [draft errors]
|
||||
(binding [*out* *err*]
|
||||
(println "HANDOFF INVALID:" (str draft))
|
||||
(println)
|
||||
(println "Errors:")
|
||||
(doseq [error errors]
|
||||
(println "-" error))
|
||||
(println)
|
||||
(println usage-text)))
|
||||
|
||||
(defn -main [& args]
|
||||
(when (not= 1 (count args))
|
||||
(usage)
|
||||
(System/exit 1))
|
||||
(let [draft (fs/path (first args))]
|
||||
(when-not (fs/regular-file? draft)
|
||||
(exit! 1 (str "Draft file not found: " draft)))
|
||||
(let [sender (sender-role)]
|
||||
(when-not (role-known? sender)
|
||||
(exit! 1 (str "Unknown sender role: " sender)))
|
||||
(let [{:keys [headers ordered errors]} (parse-draft draft)
|
||||
validation (validate headers ordered)
|
||||
all-errors (vec (concat errors (:errors validation)))]
|
||||
(when (seq all-errors)
|
||||
(error-report draft all-errors)
|
||||
(System/exit 2))
|
||||
(let [outbox-file (write-handoff! {:headers headers
|
||||
:recipients (:recipients validation)
|
||||
:canonical-commit (:canonical-commit validation)
|
||||
:sender sender})]
|
||||
(fs/delete draft)
|
||||
(println "HANDOFF QUEUED:" (str outbox-file)))))))
|
||||
|
||||
(apply -main *command-line-args*)
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
exec bb "$SCRIPT_DIR/swarm_handoff.bb" "$@"
|
||||
Executable
+603
@@ -0,0 +1,603 @@
|
||||
#!/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*)
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
exec bb "$SCRIPT_DIR/swarmforge.bb" "$@"
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
terminal_backend_label() {
|
||||
echo "Ghostty"
|
||||
}
|
||||
|
||||
terminal_backend_can_open_sessions() {
|
||||
return 0
|
||||
}
|
||||
|
||||
terminal_backend_tracks_windows() {
|
||||
return 0
|
||||
}
|
||||
|
||||
terminal_window_exists() {
|
||||
local window_id="$1"
|
||||
[[ -n "$window_id" ]] || return 1
|
||||
|
||||
local result
|
||||
result="$(osascript - "$window_id" <<'APPLESCRIPT' 2>/dev/null || true
|
||||
on run argv
|
||||
set targetId to item 1 of argv
|
||||
tell application "Ghostty"
|
||||
repeat with w in windows
|
||||
repeat with t in tabs of w
|
||||
if (id of t as string) is targetId then return "yes"
|
||||
end repeat
|
||||
end repeat
|
||||
end tell
|
||||
return "no"
|
||||
end run
|
||||
APPLESCRIPT
|
||||
)"
|
||||
|
||||
[[ "$result" == "yes" ]]
|
||||
}
|
||||
|
||||
terminal_open_session() {
|
||||
local session="$1"
|
||||
local title="$2"
|
||||
local sibling_id="${3:-}"
|
||||
|
||||
osascript - "$WORKING_DIR" "$session" "$title" "$TMUX_SOCKET" "$sibling_id" <<'APPLESCRIPT'
|
||||
on run argv
|
||||
set workingDir to item 1 of argv
|
||||
set tmuxSession to item 2 of argv
|
||||
set tmuxSocket to item 4 of argv
|
||||
set siblingTabId to item 5 of argv
|
||||
set initialCmd to "cd " & quoted form of workingDir & " && exec tmux -S " & quoted form of tmuxSocket & " attach-session -t " & quoted form of tmuxSession & linefeed
|
||||
|
||||
tell application "Ghostty"
|
||||
set cfg to new surface configuration
|
||||
set initial working directory of cfg to workingDir
|
||||
set initial input of cfg to initialCmd
|
||||
|
||||
if siblingTabId is not "" then
|
||||
set targetWin to missing value
|
||||
set siblingTab to missing value
|
||||
repeat with w in windows
|
||||
repeat with t in tabs of w
|
||||
if (id of t as string) is siblingTabId then
|
||||
set targetWin to w
|
||||
set siblingTab to t
|
||||
exit repeat
|
||||
end if
|
||||
end repeat
|
||||
if targetWin is not missing value then exit repeat
|
||||
end repeat
|
||||
if targetWin is not missing value then
|
||||
select tab siblingTab
|
||||
set newTab to new tab in targetWin with configuration cfg
|
||||
return id of newTab
|
||||
end if
|
||||
end if
|
||||
|
||||
try
|
||||
set targetWin to front window
|
||||
set newTab to new tab in targetWin with configuration cfg
|
||||
return id of newTab
|
||||
end try
|
||||
|
||||
set newWin to new window with configuration cfg
|
||||
return id of (first tab of newWin)
|
||||
end tell
|
||||
end run
|
||||
APPLESCRIPT
|
||||
}
|
||||
|
||||
terminal_close_window() {
|
||||
local window_id="$1"
|
||||
[[ -n "$window_id" ]] || return 0
|
||||
|
||||
osascript - "$window_id" <<'APPLESCRIPT' >/dev/null 2>&1 || true
|
||||
on run argv
|
||||
set targetId to item 1 of argv
|
||||
tell application "Ghostty"
|
||||
try
|
||||
repeat with w in windows
|
||||
repeat with t in tabs of w
|
||||
if (id of t as string) is targetId then
|
||||
close tab t
|
||||
return
|
||||
end if
|
||||
end repeat
|
||||
end repeat
|
||||
end try
|
||||
end tell
|
||||
end run
|
||||
APPLESCRIPT
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
terminal_backend_label() {
|
||||
echo "iTerm2"
|
||||
}
|
||||
|
||||
terminal_backend_can_open_sessions() {
|
||||
return 0
|
||||
}
|
||||
|
||||
terminal_backend_tracks_windows() {
|
||||
return 0
|
||||
}
|
||||
|
||||
terminal_window_exists() {
|
||||
local session_id="$1"
|
||||
[[ -n "$session_id" ]] || return 1
|
||||
|
||||
local result
|
||||
result="$(osascript - "$session_id" <<'APPLESCRIPT' 2>/dev/null || true
|
||||
on run argv
|
||||
set targetId to item 1 of argv
|
||||
tell application id "com.googlecode.iterm2"
|
||||
repeat with w in windows
|
||||
repeat with t in tabs of w
|
||||
repeat with s in sessions of t
|
||||
if (id of s) is targetId then return "yes"
|
||||
end repeat
|
||||
end repeat
|
||||
end repeat
|
||||
end tell
|
||||
return "no"
|
||||
end run
|
||||
APPLESCRIPT
|
||||
)"
|
||||
|
||||
[[ "$result" == "yes" ]]
|
||||
}
|
||||
|
||||
terminal_open_session() {
|
||||
local session="$1"
|
||||
local title="$2"
|
||||
|
||||
osascript - "$WORKING_DIR" "$session" "$title" "$TMUX_SOCKET" <<'APPLESCRIPT'
|
||||
on run argv
|
||||
set workingDir to item 1 of argv
|
||||
set tmuxSession to item 2 of argv
|
||||
set windowTitle to item 3 of argv
|
||||
set tmuxSocket to item 4 of argv
|
||||
set attachCmd to "cd " & quoted form of workingDir & " && exec tmux -S " & quoted form of tmuxSocket & " attach-session -t " & quoted form of tmuxSession
|
||||
|
||||
tell application id "com.googlecode.iterm2"
|
||||
activate
|
||||
set newWindow to (create window with default profile)
|
||||
set newSession to current session of newWindow
|
||||
tell newSession to write text attachCmd
|
||||
try
|
||||
set name of newSession to windowTitle
|
||||
end try
|
||||
return id of newSession
|
||||
end tell
|
||||
end run
|
||||
APPLESCRIPT
|
||||
}
|
||||
|
||||
terminal_close_window() {
|
||||
local session_id="$1"
|
||||
[[ -n "$session_id" ]] || return 0
|
||||
|
||||
osascript - "$session_id" <<'APPLESCRIPT' >/dev/null 2>&1 || true
|
||||
on run argv
|
||||
set targetId to item 1 of argv
|
||||
tell application id "com.googlecode.iterm2"
|
||||
repeat with w in windows
|
||||
repeat with t in tabs of w
|
||||
repeat with s in sessions of t
|
||||
if (id of s) is targetId then
|
||||
close w
|
||||
return
|
||||
end if
|
||||
end repeat
|
||||
end repeat
|
||||
end repeat
|
||||
end tell
|
||||
end run
|
||||
APPLESCRIPT
|
||||
}
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
terminal_backend_label() {
|
||||
echo "current shell"
|
||||
}
|
||||
|
||||
terminal_backend_can_open_sessions() {
|
||||
return 1
|
||||
}
|
||||
|
||||
terminal_backend_tracks_windows() {
|
||||
return 1
|
||||
}
|
||||
|
||||
terminal_window_exists() {
|
||||
return 1
|
||||
}
|
||||
|
||||
terminal_open_session() {
|
||||
return 1
|
||||
}
|
||||
|
||||
terminal_close_window() {
|
||||
return 0
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
terminal_backend_label() {
|
||||
echo "Terminal"
|
||||
}
|
||||
|
||||
terminal_backend_can_open_sessions() {
|
||||
return 0
|
||||
}
|
||||
|
||||
terminal_backend_tracks_windows() {
|
||||
return 0
|
||||
}
|
||||
|
||||
terminal_window_exists() {
|
||||
local window_id="$1"
|
||||
[[ -n "$window_id" ]] || return 1
|
||||
|
||||
local result
|
||||
result="$(osascript - "$window_id" <<'APPLESCRIPT' 2>/dev/null || true
|
||||
on run argv
|
||||
set targetId to item 1 of argv as integer
|
||||
tell application "Terminal"
|
||||
repeat with terminalWindow in windows
|
||||
if id of terminalWindow is targetId then return "yes"
|
||||
end repeat
|
||||
end tell
|
||||
return "no"
|
||||
end run
|
||||
APPLESCRIPT
|
||||
)"
|
||||
|
||||
[[ "$result" == "yes" ]]
|
||||
}
|
||||
|
||||
terminal_open_session() {
|
||||
local session="$1"
|
||||
local title="$2"
|
||||
|
||||
osascript - "$WORKING_DIR" "$session" "$title" "$TMUX_SOCKET" <<'APPLESCRIPT'
|
||||
on run argv
|
||||
set workingDir to item 1 of argv
|
||||
set tmuxSession to item 2 of argv
|
||||
set windowTitle to item 3 of argv
|
||||
set tmuxSocket to item 4 of argv
|
||||
|
||||
tell application "Terminal"
|
||||
activate
|
||||
set newTab to do script ""
|
||||
do script "cd " & quoted form of workingDir & " && exec tmux -S " & quoted form of tmuxSocket & " attach-session -t " & quoted form of tmuxSession in newTab
|
||||
set custom title of newTab to windowTitle
|
||||
return id of front window
|
||||
end tell
|
||||
end run
|
||||
APPLESCRIPT
|
||||
}
|
||||
|
||||
terminal_close_window() {
|
||||
local window_id="$1"
|
||||
[[ -n "$window_id" ]] || return 0
|
||||
|
||||
osascript - "$window_id" <<'APPLESCRIPT' >/dev/null 2>&1 || true
|
||||
on run argv
|
||||
set targetId to item 1 of argv as integer
|
||||
tell application "Terminal"
|
||||
try
|
||||
close (first window whose id is targetId) saving no
|
||||
end try
|
||||
end tell
|
||||
end run
|
||||
APPLESCRIPT
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
terminal_backend_label() {
|
||||
echo "Windows Terminal"
|
||||
}
|
||||
|
||||
terminal_backend_can_open_sessions() {
|
||||
return 0
|
||||
}
|
||||
|
||||
terminal_backend_tracks_windows() {
|
||||
return 1
|
||||
}
|
||||
|
||||
terminal_window_exists() {
|
||||
return 1
|
||||
}
|
||||
|
||||
terminal_open_session() {
|
||||
local session="$1"
|
||||
local title="$2"
|
||||
local escaped_working_dir
|
||||
local escaped_tmux_socket
|
||||
local escaped_session
|
||||
|
||||
escaped_working_dir="$(printf '%q' "$WORKING_DIR")"
|
||||
escaped_tmux_socket="$(printf '%q' "$TMUX_SOCKET")"
|
||||
escaped_session="$(printf '%q' "$session")"
|
||||
|
||||
wt.exe -w new --title "$title" wsl.exe -e bash -lc \
|
||||
"cd $escaped_working_dir && exec tmux -S $escaped_tmux_socket attach-session -t $escaped_session"
|
||||
}
|
||||
|
||||
terminal_close_window() {
|
||||
return 0
|
||||
}
|
||||
Reference in New Issue
Block a user