yatfa 1.0.80 → 1.0.82

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.
package/agents.rb CHANGED
@@ -224,7 +224,7 @@ AGENT_CONFIGS = {
224
224
 
225
225
  'researcher' => {
226
226
  name: 'yatfa-autonomous-researcher',
227
- skills: ['researcher-tactical', 'researcher-strategic', 'researcher-digest', 'researcher-telegram-processor', 'memory', 'session-learnings', 'proposal-execution', 'proposal-rework-responder', 'memory-consolidation', 'retrospective', 'knowledge-management', 'sentry-investigation', 'agent-browser'],
227
+ skills: ['researcher-tactical', 'researcher-strategic', 'researcher-digest', 'researcher-telegram-processor', 'researcher-meta-learning', 'memory', 'session-learnings', 'proposal-execution', 'proposal-rework-responder', 'memory-consolidation', 'retrospective', 'knowledge-management', 'sentry-investigation', 'agent-browser'],
228
228
  banner: build_banner(
229
229
  header: RESEARCHER_HEADER,
230
230
  boundaries: RESEARCHER_BOUNDARIES,
@@ -15,6 +15,8 @@ require_relative "../lib/yatfa_agent/config"
15
15
  require_relative "../lib/yatfa_agent/docker"
16
16
  require_relative "../lib/yatfa_agent/agent"
17
17
  require_relative "../lib/yatfa_agent/setup"
18
+ require_relative "../lib/yatfa_agent/updater"
19
+ require_relative "../lib/yatfa_agent/installer"
18
20
  require_relative "../agents"
19
21
 
20
22
  def current_version
@@ -66,6 +68,8 @@ def show_usage
66
68
  puts " npx yatfa version # Show version"
67
69
  puts " npx yatfa setup # Interactive setup wizard"
68
70
  puts " npx yatfa [worker|planner|reviewer|researcher|claw] # Run agent (prompts to attach)"
71
+ puts " npx yatfa update-agents [--once] # Update stale agent containers"
72
+ puts " npx yatfa install-updater [--uninstall] # Install/remove auto-update service"
69
73
  puts ""
70
74
  puts "Options:"
71
75
  puts " --no-prompt, -d Run without prompts (for CI/scripts)"
@@ -100,6 +104,21 @@ if command == "setup"
100
104
  exit 0
101
105
  end
102
106
 
107
+ # Handle update-agents command (runs on host, no project config needed)
108
+ if command == "update-agents"
109
+ once = ARGV.include?("--once")
110
+ interval = ENV.fetch("YATFA_UPDATE_INTERVAL", (4 * 60 * 60).to_s).to_i
111
+ YatfaAgent::Updater.run(once: once, interval: interval)
112
+ exit 0
113
+ end
114
+
115
+ # Handle install-updater command (runs on host, no project config needed)
116
+ if command == "install-updater"
117
+ uninstall = ARGV.include?("--uninstall")
118
+ YatfaAgent::Installer.run(uninstall: uninstall)
119
+ exit 0
120
+ end
121
+
103
122
  # Resolve project root by walking up the directory tree.
104
123
  # This allows running `npx yatfa` from subfolders inside the project.
105
124
  # Skipped for setup (which creates new config in cwd).
@@ -14,6 +14,19 @@ module YatfaAgent
14
14
  !result.empty?
15
15
  end
16
16
 
17
+ # Get container creation time
18
+ def self.container_creation_time(container_name)
19
+ result = IO.popen(["docker", "inspect", "--format", "{{.Created}}", container_name], err: File::NULL, &:read).strip
20
+ return nil if result.empty?
21
+ Time.parse(result)
22
+ rescue ArgumentError
23
+ nil
24
+ end
25
+
26
+ def self.format_time(t)
27
+ t.strftime("%H:%M")
28
+ end
29
+
17
30
  # Prompt user for input with a default value
18
31
  def self.prompt(message, default: nil, options: nil)
19
32
  loop do
@@ -47,6 +60,28 @@ module YatfaAgent
47
60
  # Handle running container: prompt user for action
48
61
  def self.handle_running_container(agent_type, container_name, config, agent_configs)
49
62
  puts "⚠️ #{agent_type} agent is already running (#{container_name})"
63
+
64
+ # Check if container was created before last deploy
65
+ deploy_manifest = File.join(File.dirname(__FILE__), '..', '..', 'deploy', '.deploy-manifest.json')
66
+ version_file = File.join(File.dirname(__FILE__), '..', '..', 'VERSION')
67
+ stale = false
68
+
69
+ if File.exist?(version_file)
70
+ current_version = File.read(version_file).strip
71
+ container_version = IO.popen(["docker", "exec", container_name, "printenv", "YATFA_VERSION_NUM"], err: File::NULL, &:read).strip
72
+ if !container_version.empty? && container_version != current_version
73
+ puts " 🆕 New version deployed (container: #{container_version}, current: #{current_version})"
74
+ stale = true
75
+ end
76
+ elsif File.exist?(deploy_manifest)
77
+ deploy_time = File.mtime(deploy_manifest)
78
+ container_created = container_creation_time(container_name)
79
+ if container_created && container_created < deploy_time
80
+ puts " 🆕 New deploy detected (container from #{format_time(container_created)}, deploy at #{format_time(deploy_time)})"
81
+ stale = true
82
+ end
83
+ end
84
+
50
85
  puts ""
51
86
 
52
87
  if non_interactive?
@@ -55,7 +90,8 @@ module YatfaAgent
55
90
  exit 0
56
91
  end
57
92
 
58
- choice = prompt(" [A]ttach / [R]estart / [C]ancel? [A/r/c]: ", default: "A", options: %w[A R C])
93
+ default_choice = stale ? "R" : "A"
94
+ choice = prompt(" [A]ttach / [R]estart / [C]ancel? [#{default_choice == 'R' ? 'a/R/c' : 'A/r/c'}]: ", default: default_choice, options: %w[A R C])
59
95
 
60
96
  case choice
61
97
  when "A"
@@ -135,9 +171,10 @@ module YatfaAgent
135
171
 
136
172
  # Build Docker image only when we're about to create a new container
137
173
  # (Restart, Recreate, or no existing container)
138
- Docker.build_image(config)
174
+ # build_image returns the remote version to avoid a redundant fetch
175
+ remote_version = Docker.build_image(config)
139
176
 
140
- docker_cmd = build_docker_command(container_name, agent_type, config, agent_config, agent_def)
177
+ docker_cmd = build_docker_command(container_name, agent_type, config, agent_config, agent_def, remote_version: remote_version)
141
178
 
142
179
  success = system(*docker_cmd)
143
180
 
@@ -248,7 +285,7 @@ module YatfaAgent
248
285
 
249
286
  private
250
287
 
251
- def self.build_docker_command(container_name, agent_type, config, agent_config, agent_def)
288
+ def self.build_docker_command(container_name, agent_type, config, agent_config, agent_def, remote_version: nil)
252
289
  docker_cmd = [
253
290
  "docker", "run", "-d",
254
291
  "--name", container_name,
@@ -258,6 +295,19 @@ module YatfaAgent
258
295
  "--tmpfs", "/rails/log"
259
296
  ]
260
297
 
298
+ # Inject current version for staleness detection
299
+ version_file = File.join(File.dirname(__FILE__), '..', '..', 'VERSION')
300
+ if File.exist?(version_file)
301
+ version = File.read(version_file).strip
302
+ docker_cmd += ["-e", "YATFA_VERSION_NUM=#{version}"] unless version.empty?
303
+ end
304
+
305
+ # Add yatfa.version label for updater daemon to detect stale containers
306
+ # Uses deployed_at from remote version.json (already fetched by build_image)
307
+ if remote_version
308
+ docker_cmd += ["--label", "yatfa.version=#{remote_version}"]
309
+ end
310
+
261
311
  # Inject custom environment variables from config
262
312
  # Merge global env with agent-specific env (agent-specific takes precedence)
263
313
  merged_env = {}
@@ -117,6 +117,9 @@ module YatfaAgent
117
117
  write_build_stamp(remote_version) if remote_version
118
118
 
119
119
  puts "✅ Docker image built"
120
+
121
+ # Return the remote version so callers can reuse it (avoids duplicate fetch)
122
+ remote_version
120
123
  end
121
124
  end
122
125
  end
@@ -0,0 +1,251 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module YatfaAgent
6
+ module Installer
7
+ SERVICE_NAME = "yatfa-update-agents"
8
+
9
+ # Install or uninstall the updater system service
10
+ # @param uninstall [Boolean] if true, remove the service
11
+ def self.run(uninstall: false)
12
+ os = detect_os
13
+
14
+ case os
15
+ when :linux
16
+ handle_systemd(uninstall: uninstall)
17
+ when :macos
18
+ handle_launchd(uninstall: uninstall)
19
+ else
20
+ puts "❌ Unsupported operating system: #{os}"
21
+ puts " Supported: Linux (systemd), macOS (launchd)"
22
+ exit 1
23
+ end
24
+ end
25
+
26
+ # Detect the operating system
27
+ # @return [Symbol] :linux, :macos, or :unknown
28
+ def self.detect_os
29
+ case RUBY_PLATFORM
30
+ when /linux/
31
+ :linux
32
+ when /darwin/
33
+ :macos
34
+ else
35
+ :unknown
36
+ end
37
+ end
38
+
39
+ # Check if systemctl is available
40
+ # @return [Boolean]
41
+ def self.systemd_available?
42
+ system("which systemctl", out: File::NULL, err: File::NULL)
43
+ end
44
+
45
+ # ─── Systemd (Linux) ─────────────────────────────────────────────
46
+
47
+ def self.handle_systemd(uninstall: false)
48
+ unless systemd_available?
49
+ puts "❌ systemctl not found. Is systemd installed?"
50
+ exit 1
51
+ end
52
+
53
+ unit_path = systemd_unit_path
54
+ npx_path = find_npx_path
55
+
56
+ if uninstall
57
+ uninstall_systemd(unit_path)
58
+ else
59
+ install_systemd(unit_path, npx_path)
60
+ end
61
+ end
62
+
63
+ def self.systemd_unit_path
64
+ # Use user-level systemd (runs as current user, not root)
65
+ dir = File.expand_path("~/.config/systemd/user")
66
+ FileUtils.mkdir_p(dir)
67
+ File.join(dir, "#{SERVICE_NAME}.service")
68
+ end
69
+
70
+ def self.install_systemd(unit_path, npx_path)
71
+ project_root = Dir.pwd
72
+
73
+ unit_content = <<~UNIT
74
+ [Unit]
75
+ Description=YATFA Agent Updater - Auto-update agent containers
76
+ After=docker.service
77
+
78
+ [Service]
79
+ Type=simple
80
+ ExecStart=#{npx_path} yatfa update-agents
81
+ WorkingDirectory=#{project_root}
82
+ Restart=on-failure
83
+ RestartSec=60
84
+ Environment=PATH=#{File.dirname(npx_path)}:/usr/local/bin:/usr/bin:/bin
85
+
86
+ [Install]
87
+ WantedBy=default.target
88
+ UNIT
89
+
90
+ File.write(unit_path, unit_content)
91
+ puts "📝 Written systemd unit to #{unit_path}"
92
+
93
+ # Reload systemd
94
+ system("systemctl", "--user", "daemon-reload", out: File::NULL, err: File::NULL)
95
+ puts "✅ Reloaded systemd"
96
+
97
+ # Enable and start
98
+ system("systemctl", "--user", "enable", SERVICE_NAME, out: File::NULL, err: File::NULL)
99
+ puts "✅ Enabled #{SERVICE_NAME} (starts on login)"
100
+
101
+ if system("systemctl", "--user", "start", SERVICE_NAME, out: File::NULL, err: File::NULL)
102
+ puts "✅ Started #{SERVICE_NAME}"
103
+ else
104
+ puts "⚠️ Could not start #{SERVICE_NAME}. Check: systemctl --user status #{SERVICE_NAME}"
105
+ end
106
+
107
+ # Enable lingering so service runs even when user is logged out
108
+ username = ENV["USER"] || `whoami`.strip
109
+ unless system("loginctl", "enable-linger", username, out: File::NULL, err: File::NULL)
110
+ puts "⚠️ Could not enable linger. Service will stop when you log out."
111
+ puts " Fix: sudo loginctl enable-linger #{username}"
112
+ end
113
+
114
+ puts ""
115
+ puts "🎉 Updater installed as systemd user service"
116
+ puts ""
117
+ puts " Status: systemctl --user status #{SERVICE_NAME}"
118
+ puts " Logs: journalctl --user -u #{SERVICE_NAME} -f"
119
+ puts " Stop: systemctl --user stop #{SERVICE_NAME}"
120
+ puts " Uninstall: npx yatfa install-updater --uninstall"
121
+ end
122
+
123
+ def self.uninstall_systemd(unit_path)
124
+ # Stop and disable
125
+ system("systemctl", "--user", "stop", SERVICE_NAME, out: File::NULL, err: File::NULL)
126
+ system("systemctl", "--user", "disable", SERVICE_NAME, out: File::NULL, err: File::NULL)
127
+
128
+ # Remove unit file
129
+ FileUtils.rm_f(unit_path)
130
+ puts "🗑️ Removed #{unit_path}"
131
+
132
+ # Reload systemd
133
+ system("systemctl", "--user", "daemon-reload", out: File::NULL, err: File::NULL)
134
+ puts "✅ Reloaded systemd"
135
+
136
+ puts ""
137
+ puts "✅ Updater service uninstalled"
138
+ end
139
+
140
+ # ─── Launchd (macOS) ─────────────────────────────────────────────
141
+
142
+ def self.handle_launchd(uninstall: false)
143
+ plist_path = launchd_plist_path
144
+ npx_path = find_npx_path
145
+
146
+ if uninstall
147
+ uninstall_launchd(plist_path)
148
+ else
149
+ install_launchd(plist_path, npx_path)
150
+ end
151
+ end
152
+
153
+ def self.launchd_plist_path
154
+ dir = File.expand_path("~/Library/LaunchAgents")
155
+ FileUtils.mkdir_p(dir)
156
+ File.join(dir, "com.yatfa.#{SERVICE_NAME}.plist")
157
+ end
158
+
159
+ def self.install_launchd(plist_path, npx_path)
160
+ project_root = Dir.pwd
161
+
162
+ plist_content = <<~PLIST
163
+ <?xml version="1.0" encoding="UTF-8"?>
164
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
165
+ <plist version="1.0">
166
+ <dict>
167
+ <key>Label</key>
168
+ <string>com.yatfa.#{SERVICE_NAME}</string>
169
+ <key>ProgramArguments</key>
170
+ <array>
171
+ <string>#{npx_path}</string>
172
+ <string>yatfa</string>
173
+ <string>update-agents</string>
174
+ </array>
175
+ <key>WorkingDirectory</key>
176
+ <string>#{project_root}</string>
177
+ <key>RunAtLoad</key>
178
+ <true/>
179
+ <key>KeepAlive</key>
180
+ <true/>
181
+ <key>StandardOutPath</key>
182
+ <string>#{File.expand_path("~/.yatfa/updater.log")}</string>
183
+ <key>StandardErrorPath</key>
184
+ <string>#{File.expand_path("~/.yatfa/updater.log")}</string>
185
+ <key>EnvironmentVariables</key>
186
+ <dict>
187
+ <key>PATH</key>
188
+ <string>#{File.dirname(npx_path)}:/usr/local/bin:/usr/bin:/bin</string>
189
+ </dict>
190
+ </dict>
191
+ </plist>
192
+ PLIST
193
+
194
+ # Create log directory
195
+ FileUtils.mkdir_p(File.expand_path("~/.yatfa"))
196
+
197
+ File.write(plist_path, plist_content)
198
+ puts "📝 Written launchd plist to #{plist_path}"
199
+
200
+ # Load the plist
201
+ system("launchctl", "load", plist_path, out: File::NULL, err: File::NULL)
202
+ puts "✅ Loaded #{SERVICE_NAME}"
203
+
204
+ puts ""
205
+ puts "🎉 Updater installed as launchd agent"
206
+ puts ""
207
+ puts " Logs: tail -f ~/.yatfa/updater.log"
208
+ puts " Unload: launchctl unload #{plist_path}"
209
+ puts " Uninstall: npx yatfa install-updater --uninstall"
210
+ end
211
+
212
+ def self.uninstall_launchd(plist_path)
213
+ # Unload first
214
+ system("launchctl", "unload", plist_path, out: File::NULL, err: File::NULL)
215
+
216
+ # Remove plist
217
+ FileUtils.rm_f(plist_path)
218
+ puts "🗑️ Removed #{plist_path}"
219
+
220
+ puts ""
221
+ puts "✅ Updater service uninstalled"
222
+ end
223
+
224
+ # ─── Helpers ─────────────────────────────────────────────────────
225
+
226
+ # Find the full path to npx
227
+ # @return [String] path to npx
228
+ def self.find_npx_path
229
+ result = `which npx 2>/dev/null`.strip
230
+ return result unless result.empty?
231
+
232
+ # Common paths to check
233
+ candidates = [
234
+ "/usr/local/bin/npx",
235
+ "/usr/bin/npx",
236
+ File.expand_path("~/.npm/bin/npx"),
237
+ File.expand_path("~/.nvm/versions/node/*/bin/npx")
238
+ ]
239
+
240
+ candidates.each do |path|
241
+ # Handle glob patterns
242
+ Dir.glob(path).each do |expanded|
243
+ return expanded if File.executable?(expanded)
244
+ end
245
+ end
246
+
247
+ # Fallback
248
+ "npx"
249
+ end
250
+ end
251
+ end
@@ -0,0 +1,324 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module YatfaAgent
7
+ module Updater
8
+ DEFAULT_CHECK_INTERVAL = 4 * 60 * 60 # 4 hours in seconds
9
+ VERSION_LABEL = "yatfa.version"
10
+
11
+ # Run the updater daemon
12
+ # @param once [Boolean] if true, run a single check and exit
13
+ # @param interval [Integer] check interval in seconds (default 4h)
14
+ def self.run(once: false, interval: DEFAULT_CHECK_INTERVAL)
15
+ puts "🔄 YATFA Agent Updater"
16
+ puts " Mode: #{once ? 'single check' : "daemon (every #{interval / 3600}h)"}"
17
+ puts ""
18
+
19
+ if once
20
+ perform_check
21
+ else
22
+ loop do
23
+ perform_check
24
+ puts ""
25
+ puts "💤 Next check in #{interval / 3600} hours (at #{(Time.now + interval).strftime('%H:%M')})"
26
+ sleep(interval)
27
+ end
28
+ end
29
+ end
30
+
31
+ # Perform a single update check cycle
32
+ def self.perform_check
33
+ puts "🔍 [#{Time.now.strftime('%Y-%m-%d %H:%M:%S')}] Checking for updates..."
34
+
35
+ # 1. Fetch remote version
36
+ remote_version = Docker.fetch_remote_version
37
+ unless remote_version
38
+ puts "⚠️ Could not fetch remote version, skipping check"
39
+ return
40
+ end
41
+
42
+ puts " Remote version: #{remote_version[0..19]}..."
43
+
44
+ # 2. Find yatfa-managed containers
45
+ containers = list_yatfa_containers
46
+ if containers.empty?
47
+ puts " No yatfa-managed containers found"
48
+ return
49
+ end
50
+
51
+ puts " Found #{containers.size} yatfa-managed container(s)"
52
+
53
+ # 3. Check each container for staleness
54
+ stale = containers.select { |c| c[:version] != remote_version }
55
+ if stale.empty?
56
+ puts "✅ All containers are up to date"
57
+ return
58
+ end
59
+
60
+ puts " #{stale.size} container(s) need updates:"
61
+ stale.each do |c|
62
+ puts " - #{c[:name]} (version: #{c[:version]&.[](0..19) || 'unknown'})"
63
+ end
64
+
65
+ # 4. Update stale containers
66
+ stale.each do |container|
67
+ update_container(container, remote_version)
68
+ end
69
+
70
+ puts ""
71
+ puts "✅ Update cycle complete (#{stale.size} updated)"
72
+ end
73
+
74
+ # List all Docker containers that have the yatfa.version label
75
+ # @return [Array<Hash>] array of container info hashes
76
+ def self.list_yatfa_containers
77
+ result = IO.popen(
78
+ ["docker", "ps", "-a",
79
+ "--filter", "label=#{VERSION_LABEL}",
80
+ "--format", "{{.ID}}\t{{.Names}}\t{{.Status}}"],
81
+ err: File::NULL,
82
+ &:read
83
+ ).strip
84
+
85
+ return [] if result.empty?
86
+
87
+ result.lines.map do |line|
88
+ id, name, status = line.strip.split("\t")
89
+ version = get_container_label(id, VERSION_LABEL)
90
+ {
91
+ id: id,
92
+ name: name,
93
+ status: status,
94
+ version: version
95
+ }
96
+ end
97
+ end
98
+
99
+ # Get a specific label value from a container
100
+ # @param container_id [String] container ID or name
101
+ # @param label [String] label key
102
+ # @return [String, nil] label value
103
+ def self.get_container_label(container_id, label)
104
+ result = IO.popen(
105
+ ["docker", "inspect", "--format", "{{.Config.Labels.#{label}}}", container_id],
106
+ err: File::NULL,
107
+ &:read
108
+ ).strip
109
+ result.empty? ? nil : result
110
+ end
111
+
112
+ # Update a single stale container
113
+ # @param container [Hash] container info from list_yatfa_containers
114
+ # @param new_version [String] target version (deployed_at)
115
+ def self.update_container(container, new_version)
116
+ name = container[:name]
117
+ puts ""
118
+ puts "🔄 Updating #{name}..."
119
+
120
+ begin
121
+ # 1. Extract container config before stopping
122
+ config = inspect_container_config(container[:id])
123
+ unless config
124
+ puts " ❌ Could not inspect container #{name}, skipping"
125
+ return
126
+ end
127
+
128
+ # 2. Build fresh image FIRST (while old container still running)
129
+ # This ensures the old container survives if the build fails.
130
+ # Both Config.load and Docker.build_image call exit(1) on failure,
131
+ # which SystemExit catches here (not caught by rescue StandardError).
132
+ puts " Building fresh image..."
133
+ begin
134
+ project_root = find_project_root_for_container(config)
135
+ if project_root && Dir.exist?(project_root)
136
+ Dir.chdir(project_root) do
137
+ docker_config = load_config_for_container(project_root)
138
+ Docker.build_image(docker_config)
139
+ end
140
+ else
141
+ puts " ⚠️ Could not find project root, attempting build in cwd"
142
+ docker_config = Config.load
143
+ Docker.build_image(docker_config)
144
+ end
145
+ rescue SystemExit => e
146
+ puts " ❌ Build failed (exit #{e.status}), keeping old container running"
147
+ return
148
+ end
149
+
150
+ # 3. Only after successful build, stop and remove the old container
151
+ puts " Stopping old container..."
152
+ system("docker", "stop", name, out: File::NULL, err: File::NULL)
153
+ system("docker", "rm", name, out: File::NULL, err: File::NULL)
154
+
155
+ # 4. Recreate container with preserved config
156
+ puts " Starting new container..."
157
+ new_cmd = build_recreate_command(name, config, new_version)
158
+ success = system(*new_cmd)
159
+
160
+ if success
161
+ puts " ✅ #{name} updated successfully"
162
+ else
163
+ puts " ❌ Failed to start new container #{name}"
164
+ puts " Old container has been removed. Manual intervention may be needed."
165
+ end
166
+ rescue StandardError => e
167
+ puts " ❌ Error updating #{name}: #{e.message}"
168
+ end
169
+ end
170
+
171
+ # Inspect a container and extract its configuration
172
+ # @param container_id [String] container ID
173
+ # @return [Hash, nil] parsed container config
174
+ def self.inspect_container_config(container_id)
175
+ result = IO.popen(
176
+ ["docker", "inspect", "--format", "{{json .}}", container_id],
177
+ err: File::NULL,
178
+ &:read
179
+ ).strip
180
+ return nil if result.empty?
181
+
182
+ JSON.parse(result)
183
+ rescue JSON::ParserError
184
+ nil
185
+ end
186
+
187
+ # Find the project root directory for a container based on its config
188
+ # @param config [Hash] container inspect data
189
+ # @return [String, nil] project root path
190
+ def self.find_project_root_for_container(config)
191
+ # Try to find project root from mounted setup directory
192
+ mounts = config.dig("Mounts") || []
193
+ mounts.each do |mount|
194
+ next unless mount["Destination"] == "/tmp/setup"
195
+ source = mount["Source"]
196
+ # The source is .../setup, project root is its parent
197
+ return File.expand_path("..", source) if source && Dir.exist?(File.expand_path("..", source))
198
+ end
199
+
200
+ # Try REPOSITORIES_CONFIG env var to find workspace path
201
+ env = config.dig("Config", "Env") || []
202
+ env.each do |var|
203
+ if var.start_with?("REPOSITORIES_CONFIG=")
204
+ begin
205
+ repos = JSON.parse(var.sub("REPOSITORIES_CONFIG=", ""))
206
+ if repos.is_a?(Array) && repos.any?
207
+ path = repos.first["path_in_sandbox"]
208
+ if path
209
+ parent = File.dirname(path)
210
+ # Walk up looking for yatfa.env.rb
211
+ dir = parent
212
+ while dir != "/"
213
+ return dir if File.exist?(File.join(dir, "yatfa.env.rb"))
214
+ dir = File.dirname(dir)
215
+ end
216
+ end
217
+ end
218
+ rescue JSON::ParserError
219
+ nil
220
+ end
221
+ end
222
+ end
223
+
224
+ # Try CWD from mount sources
225
+ binds = config.dig("HostConfig", "Binds") || []
226
+ binds.each do |bind|
227
+ source = bind.split(":").first
228
+ next unless source
229
+ return File.dirname(source) if File.exist?(File.join(File.dirname(source), "yatfa.env.rb"))
230
+ end
231
+
232
+ nil
233
+ end
234
+
235
+ # Load config for a container from its project root
236
+ # @param project_root [String] path to project root
237
+ # @return [Hash] loaded config
238
+ def self.load_config_for_container(project_root)
239
+ config_file = File.join(project_root, "yatfa.env.rb")
240
+ if File.exist?(config_file)
241
+ config = eval(File.read(config_file), binding, config_file)
242
+ config = config.transform_keys(&:to_s)
243
+
244
+ # Normalize via JSON round-trip
245
+ JSON.parse(JSON.generate(config))
246
+ else
247
+ {}
248
+ end
249
+ end
250
+
251
+ # Build a docker run command to recreate a container with preserved config
252
+ # @param name [String] container name
253
+ # @param config [Hash] container inspect data
254
+ # @param new_version [String] target version (deployed_at)
255
+ # @return [Array<String>] docker command arguments
256
+ def self.build_recreate_command(name, config, new_version)
257
+ cmd = ["docker", "run", "-d", "--name", name]
258
+
259
+ # Preserve restart policy
260
+ restart = config.dig("HostConfig", "RestartPolicy", "Name")
261
+ cmd += ["--restart", restart || "unless-stopped"]
262
+
263
+ # Preserve network mode
264
+ network = config.dig("HostConfig", "NetworkMode")
265
+ cmd += ["--network", network] if network && network != "default"
266
+
267
+ # Preserve tmpfs mounts
268
+ tmpfs = config.dig("HostConfig", "Tmpfs") || {}
269
+ tmpfs.each_key do |path|
270
+ cmd += ["--tmpfs", path]
271
+ end
272
+
273
+ # Preserve volume mounts (excluding setup mount which is dev-only)
274
+ mounts = config.dig("Mounts") || []
275
+ mounts.each do |mount|
276
+ src = mount["Source"]
277
+ dst = mount["Destination"]
278
+ mode = mount["Mode"] || "rw"
279
+
280
+ # Skip mounts that no longer exist on host
281
+ next unless File.exist?(src)
282
+
283
+ if mode == "ro"
284
+ cmd += ["-v", "#{src}:#{dst}:ro"]
285
+ else
286
+ cmd += ["-v", "#{src}:#{dst}"]
287
+ end
288
+ end
289
+
290
+ # Preserve environment variables
291
+ env = config.dig("Config", "Env") || []
292
+ env.each do |var|
293
+ # Skip internal Docker env vars
294
+ next if var.start_with?("PATH=", "HOME=", "HOSTNAME=")
295
+ cmd += ["-e", var]
296
+ end
297
+
298
+ # Add updated version label
299
+ cmd += ["--label", "#{VERSION_LABEL}=#{new_version}"]
300
+
301
+ # Preserve the original Entrypoint
302
+ # Docker --entrypoint only accepts the executable, not arguments.
303
+ # If entrypoint has multiple elements (e.g. ["/bin/sh", "-c"]), use only
304
+ # the first as --entrypoint and prepend the rest before CMD.
305
+ entrypoint = config.dig("Config", "Entrypoint")
306
+ entrypoint_extra = []
307
+ if entrypoint.is_a?(Array) && entrypoint.any?
308
+ cmd += ["--entrypoint", entrypoint[0]]
309
+ entrypoint_extra = entrypoint[1..]
310
+ end
311
+
312
+ # Use the same image
313
+ image = config.dig("Config", "Image")
314
+ cmd += [image || "yatfa-sandbox"]
315
+
316
+ # Use the original CMD (prepended with any extra entrypoint args)
317
+ original_cmd = config.dig("Config", "Cmd")
318
+ full_cmd = entrypoint_extra + (original_cmd.is_a?(Array) ? original_cmd : [])
319
+ cmd += full_cmd if full_cmd.any?
320
+
321
+ cmd
322
+ end
323
+ end
324
+ end
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yatfa",
3
- "version": "1.0.80",
3
+ "version": "1.0.82",
4
4
  "description": "YATFA - Yet Another Tool For Agents",
5
5
  "bin": "./bin/run-yatfa-agent.rb",
6
6
  "files": [
@@ -330,6 +330,15 @@ def setup_claude_settings!
330
330
  puts " Opus model: #{llm_credentials['model_opus']}"
331
331
  end
332
332
 
333
+ # Configure context window if specified (value is in k-units, append 'k' suffix)
334
+ if llm_credentials["context_window"] && !llm_credentials["context_window"].to_s.empty?
335
+ context_window_value = llm_credentials["context_window"].to_i
336
+ if context_window_value > 0
337
+ settings_config["env"]["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] = "#{context_window_value}000"
338
+ puts " Context window: #{context_window_value}k tokens"
339
+ end
340
+ end
341
+
333
342
  if !llm_credentials["model_haiku"] && !llm_credentials["model_sonnet"] && !llm_credentials["model_opus"]
334
343
  puts " Using default Anthropic models"
335
344
  end
@@ -1035,4 +1044,4 @@ else
1035
1044
  # prepare_git_state! is now in entrypoint.sh
1036
1045
  bin_dir = download_agent_bridge!
1037
1046
  run_agent!(bin_dir)
1038
- end
1047
+ end