maf 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +131 -0
- data/README.md +11 -10
- data/assets/analyst +29 -51
- data/assets/coord +23 -20
- data/assets/dashboard +1 -5
- data/assets/dispatcher +69 -286
- data/assets/harness-hooks/context-watch.rb +5 -4
- data/assets/lima/maf.yaml.erb +61 -0
- data/lib/maf/bootstrap/script_planner.rb +1 -1
- data/lib/maf/cli.rb +3 -0
- data/lib/maf/flow/agent_links.rb +2 -2
- data/lib/maf/flow/bootstrapper.rb +5 -5
- data/lib/maf/flow/codex_hooks.rb +12 -1
- data/lib/maf/flow/generator.rb +1 -1
- data/lib/maf/flow/harness_linker.rb +2 -2
- data/lib/maf/flow/hook_files.rb +6 -0
- data/lib/maf/flow/hook_installer.rb +6 -20
- data/lib/maf/flow/manifest.rb +2 -2
- data/lib/maf/flow/mcp_config.rb +8 -22
- data/lib/maf/flow/mcp_installer.rb +3 -3
- data/lib/maf/flow/models.rb +6 -41
- data/lib/maf/flow/options.rb +4 -2
- data/lib/maf/flow/prompt_builder.rb +7 -14
- data/lib/maf/flow/prompt_text.rb +7 -11
- data/lib/maf/flow/report.rb +2 -4
- data/lib/maf/flow/role_files.rb +4 -19
- data/lib/maf/flow/validator.rb +2 -2
- data/lib/maf/flow.rb +4 -20
- data/lib/maf/menu.rb +1 -1
- data/lib/maf/migrate/role_files.rb +3 -1
- data/lib/maf/retire.rb +42 -6
- data/lib/maf/setup_agent/dispatch.rb +1 -1
- data/lib/maf/setup_agent/launcher.rb +15 -66
- data/lib/maf/setup_agent/pty_relay.rb +75 -0
- data/lib/maf/setup_agent/role_file.rb +1 -1
- data/lib/maf/setup_agent/runtime_hooks.rb +1 -12
- data/lib/maf/setup_agent.rb +1 -3
- data/lib/maf/shared/harness/base.rb +57 -0
- data/lib/maf/shared/harness/defaults.rb +140 -0
- data/lib/maf/shared/harness/output.rb +55 -0
- data/lib/maf/shared/harness/traits.rb +35 -0
- data/lib/maf/shared/harness/vm_credential.rb +36 -0
- data/lib/maf/shared/harness.rb +12 -0
- data/lib/maf/shared/harnesses/claude.rb +134 -0
- data/lib/maf/shared/harnesses/codex.rb +173 -0
- data/lib/maf/shared/harnesses/cursor.rb +64 -0
- data/lib/maf/shared/harnesses/hermes.rb +112 -0
- data/lib/maf/shared/harnesses/opencode.rb +169 -0
- data/lib/maf/shared/processes.rb +20 -0
- data/lib/maf/shared/sandbox/bwrap.rb +128 -0
- data/lib/maf/shared/sandbox/harness_files.rb +50 -0
- data/lib/maf/shared/sandbox/links.rb +28 -0
- data/lib/maf/shared/sandbox/paths.rb +124 -0
- data/lib/maf/shared/sandbox/references.rb +64 -0
- data/lib/maf/shared/sandbox/seatbelt.rb +86 -0
- data/lib/maf/shared/sandbox.rb +84 -0
- data/lib/maf/team_command.rb +3 -3
- data/lib/maf/uninstall/manifest.rb +10 -8
- data/lib/maf/uninstall/mcp_entries.rb +4 -5
- data/lib/maf/uninstall/notes.rb +13 -2
- data/lib/maf/uninstall/role_files.rb +11 -10
- data/lib/maf/uninstall/runner.rb +4 -3
- data/lib/maf/uninstall/scripts.rb +1 -1
- data/lib/maf/uninstall/vm_files.rb +16 -0
- data/lib/maf/uninstall.rb +20 -5
- data/lib/maf/untrack.rb +6 -26
- data/lib/maf/version.rb +1 -1
- data/lib/maf/vm/login.rb +52 -0
- data/lib/maf/vm/options.rb +41 -0
- data/lib/maf/vm/template.rb +44 -0
- data/lib/maf/vm.rb +94 -0
- data/lib/maf/worker_control.rb +4 -7
- data/lib/maf/workers.rb +27 -0
- metadata +30 -11
- data/lib/maf/setup_agent/hermes_launcher.rb +0 -34
- data/lib/maf/setup_agent/hermes_skill.rb +0 -26
- data/templates/claude.md.erb +0 -16
- data/templates/codex.md.erb +0 -7
- data/templates/hermes.md.erb +0 -12
- data/templates/opencode.md.erb +0 -24
data/assets/dispatcher
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
# dispatcher polls the inbox and the task board on a fixed interval.
|
|
12
12
|
#
|
|
13
13
|
# Usage:
|
|
14
|
-
# dispatcher ROLE
|
|
14
|
+
# dispatcher ROLE (--harness NAME | --command TEMPLATE) [--model M]
|
|
15
15
|
# [--skill NAME | --no-skill] [--interval S] [--max-turns N]
|
|
16
16
|
# [--timeout S] [--idle-timeout S] [--grace S]
|
|
17
17
|
# [--completion-signal TEXT] [--abort-signal TEXT]
|
|
@@ -19,15 +19,17 @@
|
|
|
19
19
|
# [--no-poll-tasks]
|
|
20
20
|
# [--full-harness] [--once] [--verbose]
|
|
21
21
|
#
|
|
22
|
-
# dispatcher tester
|
|
22
|
+
# dispatcher tester --harness hermes
|
|
23
23
|
# dispatcher reviewer --harness claude --model sonnet
|
|
24
24
|
# dispatcher backend-developer --harness opencode --model openrouter/qwen3-coder
|
|
25
25
|
# dispatcher reviewer --harness codex
|
|
26
|
-
# dispatcher reviewer --
|
|
26
|
+
# dispatcher reviewer --harness cursor --model gpt-5
|
|
27
|
+
# dispatcher reviewer --harness claude --once # one poll cycle, then exit
|
|
27
28
|
#
|
|
28
29
|
# Flags:
|
|
29
|
-
# --harness NAME
|
|
30
|
-
# opencode. Each one
|
|
30
|
+
# --harness NAME A built-in harness: one file in .maf/lib/maf/shared/harnesses/
|
|
31
|
+
# (claude, codex, cursor, hermes, opencode). Each one
|
|
32
|
+
# resumes sessions. --harness or --command is required.
|
|
31
33
|
# --command TEMPLATE Any other harness. A shell command with %{prompt},
|
|
32
34
|
# %{role}, %{skill}, and %{model} placeholders, each
|
|
33
35
|
# shell-escaped. The command must exit when done.
|
|
@@ -53,7 +55,8 @@
|
|
|
53
55
|
# --abort-signal TEXT The agent prints TEXT when it gives up. The run is a
|
|
54
56
|
# failure. Default: off.
|
|
55
57
|
# --cache-window S Resume a session only if its last run ended less than
|
|
56
|
-
# S seconds ago. Default:
|
|
58
|
+
# S seconds ago. Default: the cache window of the harness
|
|
59
|
+
# (codex: 300), else 3300
|
|
57
60
|
# (55 minutes). 0 means never resume.
|
|
58
61
|
# --max-session-runs N Start a fresh session after N runs in one session
|
|
59
62
|
# (default: 5). Each resume sends the whole old context
|
|
@@ -136,6 +139,8 @@ begin
|
|
|
136
139
|
require_relative "../lib/maf/shared/roles"
|
|
137
140
|
require_relative "../lib/maf/shared/peak_rate"
|
|
138
141
|
require_relative "../lib/maf/shared/git_identity"
|
|
142
|
+
require_relative "../lib/maf/shared/harness"
|
|
143
|
+
require_relative "../lib/maf/shared/sandbox"
|
|
139
144
|
rescue LoadError
|
|
140
145
|
abort "dispatcher: the maf shared library is missing. Run maf update."
|
|
141
146
|
end
|
|
@@ -171,29 +176,6 @@ module Dispatcher
|
|
|
171
176
|
Project = Maf::Shared::Project
|
|
172
177
|
LEADS = Maf::Shared::Roles::LEADS
|
|
173
178
|
|
|
174
|
-
# FlowConfig finds the MCP config of the flow in .maf/mcp/ (next to the
|
|
175
|
-
# coordination folder). The project's .mcp.json and opencode.json stay as
|
|
176
|
-
# they are. NOTE: lib/maf/setup_agent/launcher.rb passes the same files.
|
|
177
|
-
module FlowConfig
|
|
178
|
-
def self.path(config, name) = File.join(File.dirname(config.coord_dir), "mcp", name)
|
|
179
|
-
def self.claude_mcp(config) = File.exist?(file = path(config, "claude.json")) ? ["--mcp-config", file] : []
|
|
180
|
-
|
|
181
|
-
def self.opencode_env(config)
|
|
182
|
-
file = path(config, "opencode.json")
|
|
183
|
-
File.exist?(file) ? { "OPENCODE_CONFIG" => file } : {}
|
|
184
|
-
end
|
|
185
|
-
end
|
|
186
|
-
|
|
187
|
-
# EditGrant reads each agent's can_edit from .maf/config.json. On Hermes,
|
|
188
|
-
# a role with can_edit false starts without the file-writing toolsets.
|
|
189
|
-
module EditGrant
|
|
190
|
-
READ_ONLY_TOOLSETS = Maf::Shared::Roles::READ_ONLY_TOOLSETS
|
|
191
|
-
|
|
192
|
-
def self.read_only?(role)
|
|
193
|
-
Project.manifest.fetch("agents", []).any? { |agent| agent["role"] == role && agent["can_edit"] == false }
|
|
194
|
-
end
|
|
195
|
-
end
|
|
196
|
-
|
|
197
179
|
# Presence writes .maf/coordination/presence/<worker>.json once per cycle, so
|
|
198
180
|
# `coord who` shows the dispatcher as live while its pid runs.
|
|
199
181
|
module Presence
|
|
@@ -210,17 +192,6 @@ module Dispatcher
|
|
|
210
192
|
end
|
|
211
193
|
end
|
|
212
194
|
|
|
213
|
-
# HermesSkill resolves the skill flow.rb generated for a role: the name is
|
|
214
|
-
# <project>-<role>, and the dir is the manifest's hermes_dir (set by
|
|
215
|
-
# `flow.rb --hermes-dir`) or flow.rb's default ~/.hermes/skills.
|
|
216
|
-
module HermesSkill
|
|
217
|
-
DEFAULT_DIR = File.join(Dir.home, ".hermes", "skills")
|
|
218
|
-
|
|
219
|
-
def self.name(role) = "#{File.basename(Project.root)}-#{role}"
|
|
220
|
-
def self.dir = Project.manifest.fetch("hermes_dir", DEFAULT_DIR)
|
|
221
|
-
def self.installed?(role) = File.exist?(File.join(dir, name(role), "SKILL.md"))
|
|
222
|
-
end
|
|
223
|
-
|
|
224
195
|
# Session persists the agent's session ID across runs, per worker: two
|
|
225
196
|
# dispatchers for one role (different COORD_WORKER values) never share a
|
|
226
197
|
# session or a handoff note. The session file's mtime is the time of the
|
|
@@ -532,10 +503,7 @@ module Dispatcher
|
|
|
532
503
|
# CoordCall runs coord with this Ruby, so an old system Ruby on the
|
|
533
504
|
# shebang path never parses it.
|
|
534
505
|
module CoordCall
|
|
535
|
-
def self.command
|
|
536
|
-
local = File.expand_path(COORD_BIN)
|
|
537
|
-
[RbConfig.ruby, File.executable?(local) ? local : File.join(Project.root, COORD_BIN)]
|
|
538
|
-
end
|
|
506
|
+
def self.command = [RbConfig.ruby, File.join(Project.root, COORD_BIN)]
|
|
539
507
|
|
|
540
508
|
# Returns true when the command succeeds.
|
|
541
509
|
def self.run(env, *args)
|
|
@@ -676,10 +644,25 @@ module Dispatcher
|
|
|
676
644
|
|
|
677
645
|
def self.run(cmd, env:, timeout:, limits: Limits.new(0, GRACE))
|
|
678
646
|
reader, writer = IO.pipe
|
|
679
|
-
pid =
|
|
647
|
+
pid = start(env, cmd, writer)
|
|
680
648
|
collect(pid, Stream.new(reader.tap { writer.close }), Watch.new(timeout, limits))
|
|
681
|
-
|
|
682
|
-
|
|
649
|
+
end
|
|
650
|
+
|
|
651
|
+
def self.start(env, cmd, writer)
|
|
652
|
+
ready, done = IO.pipe
|
|
653
|
+
pid = fork { session_exec(env, cmd, writer, done) }
|
|
654
|
+
done.close
|
|
655
|
+
ready.read && ready.close
|
|
656
|
+
pid
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
def self.session_exec(env, cmd, writer, done)
|
|
660
|
+
Process.setsid
|
|
661
|
+
done.close
|
|
662
|
+
exec(env, *cmd, in: File::NULL, out: writer, err: writer)
|
|
663
|
+
rescue StandardError => e
|
|
664
|
+
writer.write(e.message)
|
|
665
|
+
exit!(127)
|
|
683
666
|
end
|
|
684
667
|
|
|
685
668
|
def self.collect(pid, stream, watch)
|
|
@@ -704,55 +687,16 @@ module Dispatcher
|
|
|
704
687
|
end
|
|
705
688
|
end
|
|
706
689
|
|
|
707
|
-
|
|
708
|
-
# its session ID from one specific event, never from free text, so a
|
|
709
|
-
# prompt or a handoff note that the agent echoes cannot supply a false ID.
|
|
710
|
-
module Events
|
|
711
|
-
def self.all(output) = output.lines.filter_map { |line| parse(line.strip) }
|
|
712
|
-
|
|
713
|
-
def self.last(output, &match) = all(output).reverse.find(&match) || {}
|
|
714
|
-
|
|
715
|
-
def self.parse(line)
|
|
716
|
-
event = line.start_with?("{") ? JSON.parse(line) : nil
|
|
717
|
-
event.is_a?(Hash) ? event : nil
|
|
718
|
-
rescue JSON::ParserError
|
|
719
|
-
nil
|
|
720
|
-
end
|
|
721
|
-
end
|
|
690
|
+
Events = Maf::Shared::Harness::Events
|
|
722
691
|
|
|
723
|
-
# TokenUsage
|
|
724
|
-
#
|
|
725
|
-
#
|
|
726
|
-
# never fails a run.
|
|
727
|
-
#
|
|
728
|
-
# input_tokens counts every input token, cache reads included.
|
|
729
|
-
# cached_input_tokens counts the cache reads, which cost a fraction of the
|
|
730
|
-
# input price. cache_write_input_tokens counts the cache writes, which cost
|
|
731
|
-
# more than the input price. A resume with a large cache write found a cold
|
|
732
|
-
# cache: the --cache-window is too long for the harness. Claude Code reports
|
|
733
|
-
# cache reads and cache writes outside its input_tokens, so they are added.
|
|
734
|
-
# Codex reports them inside.
|
|
692
|
+
# TokenUsage adds the token usage of one run to the totals of the worker in
|
|
693
|
+
# .maf/coordination/usage/<worker>.json. Each harness reads the usage of its
|
|
694
|
+
# run (Maf::Shared::Harness::Usage gives it one shape). A run without usage
|
|
695
|
+
# data adds nothing. A usage error never fails a run. A resume with a large
|
|
696
|
+
# cache write found a cold cache: the --cache-window is too long for the harness.
|
|
735
697
|
class TokenUsage
|
|
736
|
-
def self.normalize(data)
|
|
737
|
-
return nil unless data.is_a?(Hash)
|
|
738
|
-
|
|
739
|
-
{ "input_tokens" => input(data),
|
|
740
|
-
"cached_input_tokens" => int(data, "cached_input_tokens", "cache_read_input_tokens"),
|
|
741
|
-
"cache_write_input_tokens" => int(data, "cache_write_input_tokens", "cache_creation_input_tokens"),
|
|
742
|
-
"output_tokens" => int(data, "output_tokens", "completion_tokens") }
|
|
743
|
-
end
|
|
744
|
-
|
|
745
|
-
def self.input(data)
|
|
746
|
-
int(data, "input_tokens", "prompt_tokens") + int(data, "cache_read_input_tokens") +
|
|
747
|
-
int(data, "cache_creation_input_tokens")
|
|
748
|
-
end
|
|
749
|
-
|
|
750
|
-
def self.int(data, *names) = data.values_at(*names).compact.first.to_i
|
|
751
|
-
|
|
752
698
|
def self.label(usage) = usage.map { |key, value| "#{key.delete_suffix("_tokens")}=#{value}" }.join(" ")
|
|
753
699
|
|
|
754
|
-
def self.sum(list) = list.empty? ? nil : list.reduce { |total, usage| total.merge(usage) { |_, a, b| a + b } }
|
|
755
|
-
|
|
756
700
|
def initialize(coord_dir, worker)
|
|
757
701
|
@path = File.join(coord_dir, "usage", "#{worker}.json")
|
|
758
702
|
end
|
|
@@ -802,183 +746,24 @@ module Dispatcher
|
|
|
802
746
|
"cache_window" => @config.cache_window }
|
|
803
747
|
end
|
|
804
748
|
|
|
805
|
-
# PeakRate tells when a dispatched
|
|
806
|
-
#
|
|
749
|
+
# PeakRate tells when a dispatched run uses a model at its peak rate
|
|
750
|
+
# (Maf::Shared::PeakRate). The notice can show on a Chinese holiday.
|
|
807
751
|
module PeakRate
|
|
808
|
-
def self.deepseek?(config) = config.harness == "opencode" && OpencodeModel.for(config).to_s.include?("deepseek")
|
|
809
|
-
|
|
810
752
|
def self.notice(config, time = Time.now.utc)
|
|
811
|
-
return unless Maf::Shared::PeakRate.peak?(time) &&
|
|
753
|
+
return unless Maf::Shared::PeakRate.peak?(time) && Harness.for(config).peak_rate?(config)
|
|
812
754
|
|
|
813
755
|
"info: DeepSeek peak hours (#{time.strftime("%H:%M")} UTC): this run costs 2x the off-peak rate"
|
|
814
756
|
end
|
|
815
757
|
end
|
|
816
758
|
|
|
817
|
-
#
|
|
818
|
-
#
|
|
819
|
-
|
|
820
|
-
CONFIG_MODEL = /^\s*"model"\s*:\s*"([^"]+)"/
|
|
821
|
-
AGENT_MODEL = /\A---\n(?:(?!^---$).)*?^model:\s*(\S+)/m
|
|
822
|
-
|
|
823
|
-
def self.for(config) = config.model || agent(config.role) || configured
|
|
824
|
-
def self.agent(role) = find(File.join(".opencode", "agents", "#{role}.md"), AGENT_MODEL)
|
|
825
|
-
def self.configured = config_paths.lazy.filter_map { |path| find(path, CONFIG_MODEL) }.first
|
|
826
|
-
def self.config_paths = %w[opencode.json opencode.jsonc] + user_paths
|
|
827
|
-
def self.user_paths = %w[opencode.jsonc opencode.json].map { |name| File.join(home, name) }
|
|
828
|
-
def self.home = File.join(ENV.fetch("XDG_CONFIG_HOME", File.join(Dir.home, ".config")), "opencode")
|
|
829
|
-
def self.find(path, pattern) = File.exist?(path) ? File.read(path)[pattern, 1] : nil
|
|
830
|
-
end
|
|
831
|
-
|
|
759
|
+
# Harness picks the harness of a run. Each built-in harness is one file in
|
|
760
|
+
# lib/maf/shared/harnesses/ (installed in .maf/lib/maf/shared/harnesses/).
|
|
761
|
+
# Generic runs a user-provided shell template.
|
|
832
762
|
module Harness
|
|
833
|
-
# Adapter is the part every built-in adapter shares. Each adapter sets
|
|
834
|
-
# STALE_SESSION to the error its CLI prints for an unknown session ID.
|
|
835
|
-
module Adapter
|
|
836
|
-
def stale_session?(output) = output.include?(self::STALE_SESSION)
|
|
837
|
-
def resume(session_id, *flag) = session_id ? [*flag, session_id] : []
|
|
838
|
-
def present(value) = value.to_s.empty? ? nil : value
|
|
839
|
-
def usage(_output) = nil
|
|
840
|
-
def context(_output) = nil
|
|
841
|
-
def model(_output) = nil
|
|
842
|
-
end
|
|
843
|
-
|
|
844
|
-
# Hermes: `hermes chat --oneshot`, JSONL output, skill preload.
|
|
845
|
-
module Hermes
|
|
846
|
-
extend Adapter
|
|
847
|
-
STALE_SESSION = "Session not found"
|
|
848
|
-
|
|
849
|
-
def self.build_command(config, prompt, session_id)
|
|
850
|
-
cmd = %w[hermes chat --oneshot --yolo --format stream-json -Q] + options(config)
|
|
851
|
-
cmd += ["--max-turns", config.max_turns.to_s, "--run-budget", run_budget(config).to_s]
|
|
852
|
-
cmd + resume(session_id, "--resume") + ["-q", prompt]
|
|
853
|
-
end
|
|
854
|
-
|
|
855
|
-
def self.options(config)
|
|
856
|
-
tools = EditGrant.read_only?(config.role) ? ["-t", EditGrant::READ_ONLY_TOOLSETS] : []
|
|
857
|
-
[*(["--skills", config.skill] if config.skill), *(["--model", config.model] if config.model), *tools]
|
|
858
|
-
end
|
|
859
|
-
|
|
860
|
-
# Stop Hermes before the dispatcher's kill, so it can still write its result event.
|
|
861
|
-
def self.run_budget(config) = [config.timeout - Spawn::GRACE, 1].max
|
|
862
|
-
def self.result_event(output) = Events.last(output) { |event| event["type"] == "result" }
|
|
863
|
-
def self.result_text(output) = result_event(output)["text"]
|
|
864
|
-
def self.session_id(output) = present(result_event(output)["session_id"])
|
|
865
|
-
def self.usage(output) = TokenUsage.normalize(result_event(output)["usage"])
|
|
866
|
-
end
|
|
867
|
-
|
|
868
|
-
# Claude Code: `claude -p` prints one JSON object with session_id and
|
|
869
|
-
# result. `--agent` loads .claude/agents/<role>.md as the session's role.
|
|
870
|
-
# `--mcp-config` takes many values. Put `--` before the prompt, or claude
|
|
871
|
-
# reads the prompt as one more config file and exits.
|
|
872
|
-
module Claude
|
|
873
|
-
extend Adapter
|
|
874
|
-
STALE_SESSION = "No conversation found with session ID"
|
|
875
|
-
|
|
876
|
-
# LEAN starts no MCP server outside --mcp-config and lists no skill.
|
|
877
|
-
# The user's CLAUDE.md and hooks stay.
|
|
878
|
-
LEAN = %w[--strict-mcp-config --disable-slash-commands].freeze
|
|
879
|
-
|
|
880
|
-
def self.build_command(config, prompt, session_id)
|
|
881
|
-
cmd = %w[claude -p --output-format json --permission-mode bypassPermissions --agent] + [config.role]
|
|
882
|
-
cmd += ["--model", config.model] if config.model
|
|
883
|
-
cmd += LEAN if config.lean
|
|
884
|
-
cmd + FlowConfig.claude_mcp(config) + resume(session_id, "--resume") + ["--", prompt]
|
|
885
|
-
end
|
|
886
|
-
|
|
887
|
-
def self.result_event(output) = Events.last(output) { |event| event.key?("result") }
|
|
888
|
-
def self.result_text(output) = result_event(output)["result"]
|
|
889
|
-
def self.session_id(output) = present(result_event(output)["session_id"])
|
|
890
|
-
def self.usage(output) = TokenUsage.normalize(result_event(output)["usage"])
|
|
891
|
-
# The result names each model that the run used. The main model comes first.
|
|
892
|
-
def self.model(output) = result_event(output)["modelUsage"]&.keys&.first
|
|
893
|
-
end
|
|
894
|
-
|
|
895
|
-
# Codex: `codex exec` (or `codex exec resume ID`) with JSONL events. Codex
|
|
896
|
-
# has no agent flag, so a fresh session gets .codex/prompts/<role>.md
|
|
897
|
-
# in front of the prompt. A resumed session already has it.
|
|
898
|
-
module Codex
|
|
899
|
-
extend Adapter
|
|
900
|
-
STALE_SESSION = "no rollout found for thread id"
|
|
901
|
-
|
|
902
|
-
def self.build_command(config, prompt, session_id)
|
|
903
|
-
cmd = %w[codex exec] + (session_id ? ["resume"] : [])
|
|
904
|
-
cmd += %w[--json --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox]
|
|
905
|
-
cmd += ["-m", config.model] if config.model
|
|
906
|
-
cmd += Lean.flags if config.lean
|
|
907
|
-
cmd + resume(session_id) + [session_id ? prompt : role_prompt(config.role) + prompt]
|
|
908
|
-
end
|
|
909
|
-
|
|
910
|
-
# Lean drops the parts of the user's Codex setup that a one-shot run
|
|
911
|
-
# does not use. Each one adds tool schemas, skill lists, or MCP calls
|
|
912
|
-
# to every model call. Only the graphify server of this project stays.
|
|
913
|
-
module Lean
|
|
914
|
-
FEATURES = %w[plugins apps browser_use computer_use multi_agent].freeze
|
|
915
|
-
SERVER = /^\[mcp_servers\.([\w-]+)\]\s*$/
|
|
916
|
-
|
|
917
|
-
def self.flags = FEATURES.flat_map { |name| ["--disable", name] } + servers.flat_map { |name| off(name) }
|
|
918
|
-
def self.off(name) = ["-c", "mcp_servers.#{name}.enabled=false"]
|
|
919
|
-
def self.servers = File.exist?(config_path) ? File.read(config_path).scan(SERVER).flatten.uniq - [keep] : []
|
|
920
|
-
def self.keep = "graphify-#{File.basename(Project.root)}"
|
|
921
|
-
def self.config_path = File.join(ENV.fetch("CODEX_HOME", File.join(Dir.home, ".codex")), "config.toml")
|
|
922
|
-
end
|
|
923
|
-
|
|
924
|
-
def self.role_prompt(role)
|
|
925
|
-
path = File.join(".codex", "prompts", "#{role}.md")
|
|
926
|
-
File.exist?(path) ? "#{File.read(path)}\n\n" : ""
|
|
927
|
-
end
|
|
928
|
-
|
|
929
|
-
def self.result_text(output)
|
|
930
|
-
Events.last(output) { |event| event.dig("item", "type") == "agent_message" }.dig("item", "text")
|
|
931
|
-
end
|
|
932
|
-
|
|
933
|
-
def self.session_id(output)
|
|
934
|
-
present(Events.last(output) { |event| event["type"] == "thread.started" }["thread_id"])
|
|
935
|
-
end
|
|
936
|
-
|
|
937
|
-
# Codex reports the usage of each turn in a turn.completed event.
|
|
938
|
-
def self.usage(output)
|
|
939
|
-
turns = Events.all(output).select { |event| event["type"] == "turn.completed" }
|
|
940
|
-
TokenUsage.sum(turns.filter_map { |event| TokenUsage.normalize(event["usage"]) })
|
|
941
|
-
end
|
|
942
|
-
end
|
|
943
|
-
|
|
944
|
-
# opencode: `opencode run --format json`. `--agent` loads
|
|
945
|
-
# .opencode/agents/<role>.md. The result event shape is not verified, so
|
|
946
|
-
# the log shows no result text. opencode prints terminal escape codes and
|
|
947
|
-
# can glue two JSON objects onto one line, so the session ID is matched on
|
|
948
|
-
# the start of an event object (captured from a real run), not by parsing
|
|
949
|
-
# whole lines.
|
|
950
|
-
module Opencode
|
|
951
|
-
extend Adapter
|
|
952
|
-
STALE_SESSION = "Session not found"
|
|
953
|
-
EVENT_SESSION = /\{"type":"[\w.-]+","timestamp":\d+,"sessionID":"([^"]+)"/
|
|
954
|
-
|
|
955
|
-
def self.session_id(output) = output.scan(EVENT_SESSION).flatten.last
|
|
956
|
-
|
|
957
|
-
def self.build_command(config, prompt, session_id)
|
|
958
|
-
cmd = %w[opencode run --format json --agent] + [config.role]
|
|
959
|
-
cmd += ["-m", config.model] if config.model
|
|
960
|
-
cmd + resume(session_id, "--session") + [prompt]
|
|
961
|
-
end
|
|
962
|
-
|
|
963
|
-
def self.result_text(_output) = nil
|
|
964
|
-
|
|
965
|
-
# Each model call ends with a step_finish event. Its tokens.input
|
|
966
|
-
# excludes the cache reads and writes; reasoning is billed as output.
|
|
967
|
-
STEP_FINISH = /\{"type":"step_finish".*/
|
|
968
|
-
|
|
969
|
-
def self.steps(output) = output.scan(STEP_FINISH).filter_map { |json| Events.parse(json)&.dig("part", "tokens") }
|
|
970
|
-
def self.usage(output) = TokenUsage.sum(steps(output).map { |tokens| TokenUsage.normalize(flat(tokens)) })
|
|
971
|
-
def self.context(output) = steps(output).last&.then { |tokens| TokenUsage.input(flat(tokens)) }
|
|
972
|
-
|
|
973
|
-
def self.flat(tokens)
|
|
974
|
-
{ "input_tokens" => tokens["input"].to_i, "cache_read_input_tokens" => tokens.dig("cache", "read").to_i,
|
|
975
|
-
"cache_creation_input_tokens" => tokens.dig("cache", "write").to_i,
|
|
976
|
-
"output_tokens" => tokens["output"].to_i + tokens["reasoning"].to_i }
|
|
977
|
-
end
|
|
978
|
-
end
|
|
979
|
-
|
|
980
763
|
# Generic: a user-provided shell template. No session resume, no parsing.
|
|
981
764
|
module Generic
|
|
765
|
+
extend Maf::Shared::Harness::Run
|
|
766
|
+
|
|
982
767
|
PLACEHOLDERS = %i[prompt role skill model].freeze
|
|
983
768
|
|
|
984
769
|
def self.build_command(config, prompt, _session_id)
|
|
@@ -986,21 +771,14 @@ module Dispatcher
|
|
|
986
771
|
line = PLACEHOLDERS.reduce(config.command) { |cmd, key| cmd.gsub("%{#{key}}") { values[key].to_s.shellescape } }
|
|
987
772
|
["sh", "-c", line]
|
|
988
773
|
end
|
|
989
|
-
|
|
990
|
-
def self.stale_session?(_output) = false
|
|
991
|
-
def self.session_id(_output) = nil
|
|
992
|
-
def self.result_text(_output) = nil
|
|
993
|
-
def self.usage(_output) = nil
|
|
994
|
-
def self.context(_output) = nil
|
|
995
|
-
def self.model(_output) = nil
|
|
996
774
|
end
|
|
997
775
|
|
|
998
|
-
REGISTRY = { "hermes" => Hermes, "claude" => Claude, "codex" => Codex, "opencode" => Opencode }.freeze
|
|
999
|
-
|
|
1000
776
|
def self.for(config)
|
|
1001
777
|
return Generic if config.command
|
|
1002
778
|
|
|
1003
|
-
|
|
779
|
+
Maf::Shared::Harness[config.harness] ||
|
|
780
|
+
abort("dispatcher: unknown harness '#{config.harness}' (use #{Maf::Shared::Harness.names.join(", ")}). " \
|
|
781
|
+
"For another harness, use --command.")
|
|
1004
782
|
end
|
|
1005
783
|
end
|
|
1006
784
|
|
|
@@ -1060,7 +838,13 @@ module Dispatcher
|
|
|
1060
838
|
command = Project.manifest["verify"].to_s
|
|
1061
839
|
return true if command.empty? || LEADS.include?(config.role)
|
|
1062
840
|
|
|
1063
|
-
|
|
841
|
+
sandbox = Maf::Shared::Sandbox
|
|
842
|
+
run = -> { Spawn.run(sandboxed(command, config), env: env, timeout: config.timeout) }
|
|
843
|
+
check(sandbox.keeping_links(Harness::Generic, coord_dir: config.coord_dir, &run), command)
|
|
844
|
+
end
|
|
845
|
+
|
|
846
|
+
def self.sandboxed(command, config)
|
|
847
|
+
Maf::Shared::Sandbox.wrap(["sh", "-c", command], Harness::Generic, coord_dir: config.coord_dir)
|
|
1064
848
|
end
|
|
1065
849
|
|
|
1066
850
|
def self.check(result, command)
|
|
@@ -1256,10 +1040,14 @@ module Dispatcher
|
|
|
1256
1040
|
spawn(@harness.build_command(@config, text, session_id))
|
|
1257
1041
|
end
|
|
1258
1042
|
|
|
1043
|
+
def sandboxed(cmd) = Maf::Shared::Sandbox.wrap(cmd, @harness, coord_dir: @config.coord_dir)
|
|
1044
|
+
def keeping_links(&block) = Maf::Shared::Sandbox.keeping_links(@harness, coord_dir: @config.coord_dir, &block)
|
|
1045
|
+
|
|
1259
1046
|
def session_label(id) = id ? "#{id}, idle #{(@session.idle_seconds / 60).round}m" : "new"
|
|
1260
1047
|
|
|
1261
1048
|
def spawn(cmd)
|
|
1262
|
-
|
|
1049
|
+
limits = Limits.from(@config)
|
|
1050
|
+
result = keeping_links { Spawn.run(sandboxed(cmd), env: @env, timeout: @config.timeout, limits: limits) }
|
|
1263
1051
|
record(@harness.usage(result.output), result.output)
|
|
1264
1052
|
@model = @harness.model(result.output) || @model
|
|
1265
1053
|
result
|
|
@@ -1458,7 +1246,7 @@ module Dispatcher
|
|
|
1458
1246
|
env = { "COORD_DIR" => config.coord_dir, "COORD_ROLE" => config.role, "COORD_WORKER" => config.worker,
|
|
1459
1247
|
"COORD_DISPATCHED" => "1", "PWD" => Dir.pwd }
|
|
1460
1248
|
taskrc = File.join(config.coord_dir, "taskrc")
|
|
1461
|
-
env.merge(File.exist?(taskrc) ? { "TASKRC" => taskrc } : {},
|
|
1249
|
+
env.merge(File.exist?(taskrc) ? { "TASKRC" => taskrc } : {}, Harness.for(config).run_env(config))
|
|
1462
1250
|
end
|
|
1463
1251
|
|
|
1464
1252
|
def run
|
|
@@ -1524,6 +1312,7 @@ module Dispatcher
|
|
|
1524
1312
|
|
|
1525
1313
|
def start
|
|
1526
1314
|
Log.line("started: role=#{@config.role} #{harness_label} #{timing_label}")
|
|
1315
|
+
Log.line("sandbox: #{Maf::Shared::Sandbox.describe(Harness.for(@config), coord_dir: @config.coord_dir)}")
|
|
1527
1316
|
@mailbox.recover
|
|
1528
1317
|
@stop = StopSignal.new
|
|
1529
1318
|
end
|
|
@@ -1595,7 +1384,7 @@ module Dispatcher
|
|
|
1595
1384
|
SWITCHES = { "--no-skill" => [:no_skill, true], "--no-poll-tasks" => [:poll_tasks, false],
|
|
1596
1385
|
"--full-harness" => [:lean, false],
|
|
1597
1386
|
"--once" => [:once, true], "--verbose" => [:verbose, true] }.freeze
|
|
1598
|
-
DEFAULTS = {
|
|
1387
|
+
DEFAULTS = { interval: 60, idle_timeout: 0, grace: Spawn::GRACE,
|
|
1599
1388
|
poll_tasks: true, lean: true,
|
|
1600
1389
|
no_skill: false, once: false, verbose: false }.freeze
|
|
1601
1390
|
|
|
@@ -1603,16 +1392,15 @@ module Dispatcher
|
|
|
1603
1392
|
DEFAULT_SESSION_RUNS = 5
|
|
1604
1393
|
DEFAULT_CONTEXT = 150_000
|
|
1605
1394
|
|
|
1606
|
-
# Claude Code caches for 1 hour.
|
|
1607
|
-
#
|
|
1608
|
-
# fresh session with the handoff note.
|
|
1609
|
-
CACHE_WINDOWS = { "codex" => 300 }.freeze
|
|
1395
|
+
# Claude Code caches for 1 hour. A harness with a shorter cache sets its
|
|
1396
|
+
# cache_window. A cold resume costs more than a fresh session with the handoff note.
|
|
1610
1397
|
DEFAULT_CACHE_WINDOW = 3300
|
|
1611
1398
|
|
|
1612
1399
|
def self.parse(argv, env = ENV)
|
|
1613
1400
|
config = Config.new(**DEFAULTS)
|
|
1614
1401
|
parser(config).parse!(argv)
|
|
1615
1402
|
config.role = argv.shift || abort("dispatcher: role is required (usage: dispatcher ROLE)")
|
|
1403
|
+
abort "dispatcher: --harness NAME or --command TEMPLATE is required" unless config.harness || config.command
|
|
1616
1404
|
validate(finish(config, env))
|
|
1617
1405
|
end
|
|
1618
1406
|
|
|
@@ -1650,7 +1438,7 @@ module Dispatcher
|
|
|
1650
1438
|
config.coord_dir = File.expand_path(env.fetch("COORD_DIR", ".maf/coordination"))
|
|
1651
1439
|
config.worker = env.fetch("COORD_WORKER", "#{config.role}-bot")
|
|
1652
1440
|
config.poll_tasks = false if LEADS.include?(config.role)
|
|
1653
|
-
|
|
1441
|
+
Harness.for(config).prepare(limits(config))
|
|
1654
1442
|
end
|
|
1655
1443
|
|
|
1656
1444
|
def self.limits(config)
|
|
@@ -1662,19 +1450,14 @@ module Dispatcher
|
|
|
1662
1450
|
# "team": { "limits": { "frontend-developer": { "max_context": 80000 } } }.
|
|
1663
1451
|
# `maf worker restart ID --max-context N` writes them. A command-line flag wins.
|
|
1664
1452
|
# NOTE: assets/dashboard reads the limits of each run from the run history.
|
|
1453
|
+
def self.cache_window(config) = Harness.for(config).cache_window || DEFAULT_CACHE_WINDOW
|
|
1454
|
+
|
|
1665
1455
|
def self.session_limits(config, saved)
|
|
1666
|
-
config.cache_window ||= saved["cache_window"] ||
|
|
1456
|
+
config.cache_window ||= saved["cache_window"] || cache_window(config)
|
|
1667
1457
|
config.max_session_runs ||= saved["max_session_runs"] || DEFAULT_SESSION_RUNS
|
|
1668
1458
|
config.max_context ||= saved["max_context"] || DEFAULT_CONTEXT
|
|
1669
1459
|
config
|
|
1670
1460
|
end
|
|
1671
|
-
|
|
1672
|
-
# --no-skill drops a skill. Without --skill, the generated Hermes skill of the role loads.
|
|
1673
|
-
def self.skill(config)
|
|
1674
|
-
config.skill = nil if config.no_skill
|
|
1675
|
-
config.skill ||= HermesSkill.name(config.role) if !config.no_skill && HermesSkill.installed?(config.role)
|
|
1676
|
-
config
|
|
1677
|
-
end
|
|
1678
1461
|
end
|
|
1679
1462
|
end
|
|
1680
1463
|
|
|
@@ -151,7 +151,8 @@ module ContextWatch
|
|
|
151
151
|
# maf start gives the role file in the first prompt, not as a system
|
|
152
152
|
# prompt. /clear drops it, so a cleared session gets the role file again.
|
|
153
153
|
# The hook puts the file text in the context: a Read costs one model call.
|
|
154
|
-
|
|
154
|
+
# The flow keeps the role file of each harness in .maf/agents/<harness>/.
|
|
155
|
+
def role_path(harness, role) = File.join(".maf", "agents", harness, "#{role}.md")
|
|
155
156
|
|
|
156
157
|
def session_start
|
|
157
158
|
@store.save("session_id" => @input["session_id"], "asked" => nil)
|
|
@@ -170,10 +171,10 @@ module ContextWatch
|
|
|
170
171
|
end
|
|
171
172
|
|
|
172
173
|
# The harness guess comes first. The file that exists in the worktree wins.
|
|
174
|
+
# The role file of this harness first, then the file of another harness of the role.
|
|
173
175
|
def role_file
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
paths.find { |file| File.exist?(file) } || paths.first
|
|
176
|
+
own = role_path(harness, @env["COORD_ROLE"])
|
|
177
|
+
File.exist?(own) ? own : Dir.glob(role_path("*", @env["COORD_ROLE"])).min || own
|
|
177
178
|
end
|
|
178
179
|
|
|
179
180
|
def note_text
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
minimumLimaVersion: 2.2.0
|
|
2
|
+
|
|
3
|
+
base:
|
|
4
|
+
- template:_images/ubuntu-26.04
|
|
5
|
+
|
|
6
|
+
vmType: <%= vm_type %>
|
|
7
|
+
mountType: <%= mount_type %>
|
|
8
|
+
cpus: <%= cpus %>
|
|
9
|
+
memory: "<%= memory %>GiB"
|
|
10
|
+
disk: "<%= disk %>GiB"
|
|
11
|
+
|
|
12
|
+
mounts:
|
|
13
|
+
# JSON quoting is valid YAML, so a quote in the path stays in the path.
|
|
14
|
+
- location: <%= project.to_json %>
|
|
15
|
+
writable: true
|
|
16
|
+
|
|
17
|
+
containerd:
|
|
18
|
+
system: false
|
|
19
|
+
user: false
|
|
20
|
+
|
|
21
|
+
provision:
|
|
22
|
+
- mode: system
|
|
23
|
+
script: |
|
|
24
|
+
#!/bin/bash
|
|
25
|
+
set -eux -o pipefail
|
|
26
|
+
export DEBIAN_FRONTEND=noninteractive
|
|
27
|
+
command -v maf >/dev/null 2>&1 && exit 0
|
|
28
|
+
apt-get update
|
|
29
|
+
apt-get install -y ruby-full git tmux curl bubblewrap nodejs npm build-essential cmake cargo rustc uuid-dev libsqlite3-dev
|
|
30
|
+
cd /tmp
|
|
31
|
+
curl -fsSL -o task.tar.gz <%= taskwarrior_url %>
|
|
32
|
+
echo "<%= taskwarrior_sha256 %> task.tar.gz" | sha256sum -c -
|
|
33
|
+
tar xzf task.tar.gz && cd task-<%= taskwarrior %>
|
|
34
|
+
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build -j"$(nproc)" && cmake --install build
|
|
35
|
+
cd / && rm -rf /tmp/task.tar.gz /tmp/task-<%= taskwarrior %> /root/.cargo/registry && apt-get clean
|
|
36
|
+
gem install <%= gem_args %>
|
|
37
|
+
<% harness_installs.each do |name, (cli, install)| -%>
|
|
38
|
+
- mode: user
|
|
39
|
+
script: |
|
|
40
|
+
#!/bin/bash
|
|
41
|
+
set -eux -o pipefail
|
|
42
|
+
export PATH="$HOME/.local/bin:$HOME/.opencode/bin:$PATH"
|
|
43
|
+
command -v <%= cli %> >/dev/null 2>&1 && exit 0
|
|
44
|
+
<%= install %>
|
|
45
|
+
<% end -%>
|
|
46
|
+
- mode: user
|
|
47
|
+
script: |
|
|
48
|
+
#!/bin/bash
|
|
49
|
+
grep -q 'maf vm' ~/.bashrc && exit 0
|
|
50
|
+
echo 'export PATH="$HOME/.local/bin:$HOME/.opencode/bin:$PATH" # maf vm' >> ~/.bashrc
|
|
51
|
+
|
|
52
|
+
probes:
|
|
53
|
+
- description: maf is installed
|
|
54
|
+
script: |
|
|
55
|
+
#!/bin/bash
|
|
56
|
+
set -eux -o pipefail
|
|
57
|
+
if ! timeout 1800s bash -c "until command -v maf >/dev/null 2>&1; do sleep 5; done"; then
|
|
58
|
+
echo >&2 "maf is not installed yet"
|
|
59
|
+
exit 1
|
|
60
|
+
fi
|
|
61
|
+
hint: See /var/log/cloud-init-output.log in the VM.
|
|
@@ -20,7 +20,7 @@ module Maf
|
|
|
20
20
|
|
|
21
21
|
# The shared library that the scripts load. Each file is planned as a script.
|
|
22
22
|
def shared
|
|
23
|
-
Dir.
|
|
23
|
+
Dir.glob("**/*.rb", base: File.join(@project.assets, SHARED_SOURCE)).sort.map do |name|
|
|
24
24
|
script(File.join(SHARED_SOURCE, name), SHARED_SIGNATURE, @project.path(MAF_DIR, SHARED_DIR, name))
|
|
25
25
|
end
|
|
26
26
|
end
|
data/lib/maf/cli.rb
CHANGED
|
@@ -13,6 +13,7 @@ require_relative "team"
|
|
|
13
13
|
require_relative "team_command"
|
|
14
14
|
require_relative "worker_control"
|
|
15
15
|
require_relative "untrack"
|
|
16
|
+
require_relative "vm"
|
|
16
17
|
require_relative "version"
|
|
17
18
|
|
|
18
19
|
module Maf
|
|
@@ -54,6 +55,7 @@ module Maf
|
|
|
54
55
|
"uninstall" => "[--check] [--yes] [--force] remove the flow from the project",
|
|
55
56
|
"migrate" => "[--check] [--yes] move an old-layout install into .maf/",
|
|
56
57
|
"untrack" => "[--check] [--yes] remove the flow from git; the files stay (then commit)",
|
|
58
|
+
"vm" => "create|login|start|stop|delete|status|shell [ARGS] run the team in a Lima VM (macOS, Linux)",
|
|
57
59
|
"menu" => " interactive mode (also: maf without a command)",
|
|
58
60
|
"guide" => " print the setup guide for an AI agent (install.md)",
|
|
59
61
|
"version" => " print the maf version (also: --version)"
|
|
@@ -117,6 +119,7 @@ module Maf
|
|
|
117
119
|
def run_retire = Retire.new(SetupAgent::Project.root, @args.first || abort("usage: maf retire ROLE[_WORKER]")).run
|
|
118
120
|
def run_uninstall = Uninstall::Runner.new(["--project", Dir.pwd, *@args]).run
|
|
119
121
|
def run_menu = Menu.new.run
|
|
122
|
+
def run_vm = Vm.new(@args, SetupAgent::Project.root).run
|
|
120
123
|
def run_guide = puts(File.read(File.join(Flow::ROOT, "install.md")))
|
|
121
124
|
def run_version = puts("maf #{VERSION}")
|
|
122
125
|
def run_untrack = Untrack.new(SetupAgent::Project.root, @args).run
|
data/lib/maf/flow/agent_links.rb
CHANGED
|
@@ -13,7 +13,7 @@ module Maf
|
|
|
13
13
|
# Returns :create, :skip, or :refuse. :refuse means the folder holds files
|
|
14
14
|
# that the flow does not own, or a symlink to another place.
|
|
15
15
|
def link(harness)
|
|
16
|
-
path = File.join(@project,
|
|
16
|
+
path = File.join(@project, Harness.fetch(harness).role_dir)
|
|
17
17
|
return exclude(path) && :skip if linked?(path, harness)
|
|
18
18
|
return :refuse unless free?(path)
|
|
19
19
|
|
|
@@ -28,7 +28,7 @@ module Maf
|
|
|
28
28
|
# relative symlink in it instead, so the harness still finds the role.
|
|
29
29
|
# A user file with the same name stays, and its name is returned.
|
|
30
30
|
def link_files(harness)
|
|
31
|
-
dir = File.join(@project,
|
|
31
|
+
dir = File.join(@project, Harness.fetch(harness).role_dir)
|
|
32
32
|
prune(dir, harness)
|
|
33
33
|
role_files(harness).reject { |source| link_file(dir, source) }.map { |source| File.basename(source) }
|
|
34
34
|
end
|