yatfa 1.0.81 → 1.0.83

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
@@ -40,17 +42,38 @@ def check_version!
40
42
  remote = latest_npm_version
41
43
  return if remote.nil? || local == remote
42
44
 
43
- # Try to self-delete the outdated install so next run fetches fresh
45
+ # Guard against infinite re-exec loop
46
+ if ENV["YATFA_SELF_UPDATE"] == "1"
47
+ puts "⚠️ Still outdated after self-update: v#{local} (latest: v#{remote})"
48
+ puts " Run manually: npx yatfa@latest #{ARGV.join(' ')}"
49
+ return # continue anyway rather than blocking
50
+ end
51
+
52
+ puts "🔄 Auto-updating yatfa: v#{local} → v#{remote}..."
53
+
54
+ # Clean up ALL stale installs so npx fetches fresh from registry
44
55
  begin
45
56
  real_path = File.realpath(__FILE__)
46
57
  install_dir = File.expand_path("../..", real_path) # bin/../.. → package root
47
- FileUtils.rm_rf(install_dir)
58
+ FileUtils.rm_rf(install_dir) if install_dir.include?("node_modules/yatfa")
59
+
60
+ # Also clear npx cache (~/.npm/_npx/<hash>/node_modules/yatfa)
61
+ npx_cache = File.join(Dir.home, ".npm", "_npx")
62
+ if Dir.exist?(npx_cache)
63
+ Dir.glob(File.join(npx_cache, "*", "node_modules", "yatfa")).each do |cached|
64
+ FileUtils.rm_rf(cached)
65
+ end
66
+ end
48
67
  rescue StandardError
49
- # No permission or other issue just warn
68
+ # Best-effort cleanup fall through to re-exec regardless
50
69
  end
51
70
 
52
- puts "⚠️ Outdated version: v#{local} (latest: v#{remote})"
53
- puts " Re-run: npx yatfa@latest #{ARGV.join(' ')}"
71
+ # Re-exec with @latest to force a fresh fetch from the registry
72
+ ENV["YATFA_SELF_UPDATE"] = "1"
73
+ exec("npx", "yatfa@latest", *ARGV)
74
+ rescue StandardError => e
75
+ puts "⚠️ Auto-update failed: #{e.message}"
76
+ puts " Run manually: npx yatfa@latest #{ARGV.join(' ')}"
54
77
  exit 1
55
78
  end
56
79
 
@@ -66,6 +89,8 @@ def show_usage
66
89
  puts " npx yatfa version # Show version"
67
90
  puts " npx yatfa setup # Interactive setup wizard"
68
91
  puts " npx yatfa [worker|planner|reviewer|researcher|claw] # Run agent (prompts to attach)"
92
+ puts " npx yatfa update-agents [--once] # Update stale agent containers"
93
+ puts " npx yatfa install-updater [--uninstall] # Install/remove auto-update service"
69
94
  puts ""
70
95
  puts "Options:"
71
96
  puts " --no-prompt, -d Run without prompts (for CI/scripts)"
@@ -100,6 +125,21 @@ if command == "setup"
100
125
  exit 0
101
126
  end
102
127
 
128
+ # Handle update-agents command (runs on host, no project config needed)
129
+ if command == "update-agents"
130
+ once = ARGV.include?("--once")
131
+ interval = ENV.fetch("YATFA_UPDATE_INTERVAL", (4 * 60 * 60).to_s).to_i
132
+ YatfaAgent::Updater.run(once: once, interval: interval)
133
+ exit 0
134
+ end
135
+
136
+ # Handle install-updater command (runs on host, no project config needed)
137
+ if command == "install-updater"
138
+ uninstall = ARGV.include?("--uninstall")
139
+ YatfaAgent::Installer.run(uninstall: uninstall)
140
+ exit 0
141
+ end
142
+
103
143
  # Resolve project root by walking up the directory tree.
104
144
  # This allows running `npx yatfa` from subfolders inside the project.
105
145
  # Skipped for setup (which creates new config in cwd).
@@ -171,9 +171,10 @@ module YatfaAgent
171
171
 
172
172
  # Build Docker image only when we're about to create a new container
173
173
  # (Restart, Recreate, or no existing container)
174
- Docker.build_image(config)
174
+ # build_image returns the remote version to avoid a redundant fetch
175
+ remote_version = Docker.build_image(config)
175
176
 
176
- 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)
177
178
 
178
179
  success = system(*docker_cmd)
179
180
 
@@ -284,7 +285,7 @@ module YatfaAgent
284
285
 
285
286
  private
286
287
 
287
- 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)
288
289
  docker_cmd = [
289
290
  "docker", "run", "-d",
290
291
  "--name", container_name,
@@ -301,6 +302,16 @@ module YatfaAgent
301
302
  docker_cmd += ["-e", "YATFA_VERSION_NUM=#{version}"] unless version.empty?
302
303
  end
303
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
+
311
+ # Store project root as a label so the updater daemon can find
312
+ # Dockerfile.sandbox and yatfa.env.rb without reverse-engineering from mounts.
313
+ docker_cmd += ["--label", "yatfa.project-root=#{Dir.pwd}"]
314
+
304
315
  # Inject custom environment variables from config
305
316
  # Merge global env with agent-specific env (agent-specific takes precedence)
306
317
  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,334 @@
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
+ # Best: use the label stored at container creation time (works for all users)
192
+ label = config.dig("Config", "Labels", "yatfa.project-root")
193
+ if label && Dir.exist?(label)
194
+ return label
195
+ end
196
+
197
+ # Fallback: walk up from /tmp/setup mount source (dev-only, local clone of yatfa-public)
198
+ mounts = config.dig("Mounts") || []
199
+ mounts.each do |mount|
200
+ next unless mount["Destination"] == "/tmp/setup"
201
+ source = mount["Source"]
202
+ next unless source
203
+ dir = File.expand_path("..", source)
204
+ while dir != "/"
205
+ return dir if File.exist?(File.join(dir, "yatfa.env.rb"))
206
+ dir = File.dirname(dir)
207
+ end
208
+ end
209
+
210
+ # Try REPOSITORIES_CONFIG env var to find workspace path
211
+ env = config.dig("Config", "Env") || []
212
+ env.each do |var|
213
+ if var.start_with?("REPOSITORIES_CONFIG=")
214
+ begin
215
+ repos = JSON.parse(var.sub("REPOSITORIES_CONFIG=", ""))
216
+ if repos.is_a?(Array) && repos.any?
217
+ path = repos.first["path_in_sandbox"]
218
+ if path
219
+ parent = File.dirname(path)
220
+ # Walk up looking for yatfa.env.rb
221
+ dir = parent
222
+ while dir != "/"
223
+ return dir if File.exist?(File.join(dir, "yatfa.env.rb"))
224
+ dir = File.dirname(dir)
225
+ end
226
+ end
227
+ end
228
+ rescue JSON::ParserError
229
+ nil
230
+ end
231
+ end
232
+ end
233
+
234
+ # Try CWD from mount sources
235
+ binds = config.dig("HostConfig", "Binds") || []
236
+ binds.each do |bind|
237
+ source = bind.split(":").first
238
+ next unless source
239
+ return File.dirname(source) if File.exist?(File.join(File.dirname(source), "yatfa.env.rb"))
240
+ end
241
+
242
+ nil
243
+ end
244
+
245
+ # Load config for a container from its project root
246
+ # @param project_root [String] path to project root
247
+ # @return [Hash] loaded config
248
+ def self.load_config_for_container(project_root)
249
+ config_file = File.join(project_root, "yatfa.env.rb")
250
+ if File.exist?(config_file)
251
+ config = eval(File.read(config_file), binding, config_file)
252
+ config = config.transform_keys(&:to_s)
253
+
254
+ # Normalize via JSON round-trip
255
+ JSON.parse(JSON.generate(config))
256
+ else
257
+ {}
258
+ end
259
+ end
260
+
261
+ # Build a docker run command to recreate a container with preserved config
262
+ # @param name [String] container name
263
+ # @param config [Hash] container inspect data
264
+ # @param new_version [String] target version (deployed_at)
265
+ # @return [Array<String>] docker command arguments
266
+ def self.build_recreate_command(name, config, new_version)
267
+ cmd = ["docker", "run", "-d", "--name", name]
268
+
269
+ # Preserve restart policy
270
+ restart = config.dig("HostConfig", "RestartPolicy", "Name")
271
+ cmd += ["--restart", restart || "unless-stopped"]
272
+
273
+ # Preserve network mode
274
+ network = config.dig("HostConfig", "NetworkMode")
275
+ cmd += ["--network", network] if network && network != "default"
276
+
277
+ # Preserve tmpfs mounts
278
+ tmpfs = config.dig("HostConfig", "Tmpfs") || {}
279
+ tmpfs.each_key do |path|
280
+ cmd += ["--tmpfs", path]
281
+ end
282
+
283
+ # Preserve volume mounts (excluding setup mount which is dev-only)
284
+ mounts = config.dig("Mounts") || []
285
+ mounts.each do |mount|
286
+ src = mount["Source"]
287
+ dst = mount["Destination"]
288
+ mode = mount["Mode"] || "rw"
289
+
290
+ # Skip mounts that no longer exist on host
291
+ next unless File.exist?(src)
292
+
293
+ if mode == "ro"
294
+ cmd += ["-v", "#{src}:#{dst}:ro"]
295
+ else
296
+ cmd += ["-v", "#{src}:#{dst}"]
297
+ end
298
+ end
299
+
300
+ # Preserve environment variables
301
+ env = config.dig("Config", "Env") || []
302
+ env.each do |var|
303
+ # Skip internal Docker env vars
304
+ next if var.start_with?("PATH=", "HOME=", "HOSTNAME=")
305
+ cmd += ["-e", var]
306
+ end
307
+
308
+ # Add updated version label
309
+ cmd += ["--label", "#{VERSION_LABEL}=#{new_version}"]
310
+
311
+ # Preserve the original Entrypoint
312
+ # Docker --entrypoint only accepts the executable, not arguments.
313
+ # If entrypoint has multiple elements (e.g. ["/bin/sh", "-c"]), use only
314
+ # the first as --entrypoint and prepend the rest before CMD.
315
+ entrypoint = config.dig("Config", "Entrypoint")
316
+ entrypoint_extra = []
317
+ if entrypoint.is_a?(Array) && entrypoint.any?
318
+ cmd += ["--entrypoint", entrypoint[0]]
319
+ entrypoint_extra = entrypoint[1..]
320
+ end
321
+
322
+ # Use the same image
323
+ image = config.dig("Config", "Image")
324
+ cmd += [image || "yatfa-sandbox"]
325
+
326
+ # Use the original CMD (prepended with any extra entrypoint args)
327
+ original_cmd = config.dig("Config", "Cmd")
328
+ full_cmd = entrypoint_extra + (original_cmd.is_a?(Array) ? original_cmd : [])
329
+ cmd += full_cmd if full_cmd.any?
330
+
331
+ cmd
332
+ end
333
+ end
334
+ end
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yatfa",
3
- "version": "1.0.81",
3
+ "version": "1.0.83",
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