wrangle 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.
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+ require "securerandom"
7
+ require "time"
8
+
9
+ require_relative "errors"
10
+
11
+ module Wrangle
12
+ # Cross-session exclusive ownership for root surfaces.
13
+ class ScopeRegistry
14
+ Lease = Data.define(:path, :token, :mode)
15
+ Dispatch = Data.define(:path, :token)
16
+
17
+ def initialize(root: nil)
18
+ home = ENV["WRANGLE_HOME"] || File.join(Dir.home, ".wrangle")
19
+ @root = root || File.join(home, "scopes")
20
+ FileUtils.mkdir_p(@root, mode: 0o700)
21
+ File.chmod(0o700, @root)
22
+ end
23
+
24
+ def acquire(scope, mode: "exclusive")
25
+ valid = %w[exclusive cooperative].include?(mode)
26
+ raise ArgumentError, "Concurrency must be exclusive or cooperative" unless valid
27
+
28
+ ensure_no_unresolved_dispatch!(scope)
29
+ return Lease.new(path: nil, token: nil, mode:) if mode == "cooperative"
30
+
31
+ token = SecureRandom.hex(16)
32
+ path = path_for(scope)
33
+ payload = { "pid" => Process.pid, "token" => token, "scope_id" => scope.id,
34
+ "root" => scope.root, "driver" => "macos" }
35
+ create(path, payload)
36
+ Lease.new(path:, token:, mode:)
37
+ rescue Errno::EEXIST
38
+ reclaim(path) ? retry : raise(ScopeBusy, "Root surface #{scope.root} is held by another exclusive session")
39
+ end
40
+
41
+ def release(lease)
42
+ return unless lease&.path && File.file?(lease.path)
43
+
44
+ stored = read(lease.path)
45
+ File.unlink(lease.path) if stored["token"] == lease.token
46
+ rescue Errno::ENOENT, JSON::ParserError
47
+ nil
48
+ end
49
+
50
+ # A durable marker crosses the only dangerous gap: process death after dispatch begins but before
51
+ # a receipt exists. It contains no labels or text, and is never reclaimed merely because its PID died.
52
+ def begin_dispatch(scope, proposal_id:, revision:, operation:)
53
+ ensure_no_unresolved_dispatch!(scope)
54
+ token = SecureRandom.hex(16)
55
+ path = dispatch_path_for(scope)
56
+ payload = { "pid" => Process.pid, "token" => token, "scope_id" => scope.id,
57
+ "proposal_id" => proposal_id, "revision" => revision, "operation" => operation,
58
+ "started_at" => Time.now.utc.iso8601(3) }
59
+ create(path, payload)
60
+ Dispatch.new(path:, token:)
61
+ rescue Errno::EEXIST
62
+ raise DeliveryUnknown, unresolved_dispatch_message
63
+ end
64
+
65
+ def finish_dispatch(dispatch)
66
+ return unless dispatch&.path
67
+
68
+ stat = File.lstat(dispatch.path)
69
+ raise DeliveryUnknown, unresolved_dispatch_message unless stat.file? && !stat.symlink?
70
+
71
+ stored = read(dispatch.path)
72
+ raise DeliveryUnknown, unresolved_dispatch_message unless stored["token"] == dispatch.token
73
+
74
+ File.unlink(dispatch.path)
75
+ sync_root
76
+ rescue Errno::ENOENT, JSON::ParserError
77
+ raise DeliveryUnknown, unresolved_dispatch_message
78
+ end
79
+
80
+ private
81
+
82
+ def path_for(scope) = File.join(@root, "#{scope_digest(scope)}.json")
83
+ def dispatch_path_for(scope) = File.join(@root, "#{scope_digest(scope)}.dispatch.json")
84
+
85
+ def scope_digest(scope)
86
+ identity = ["macos", scope.pid, scope.process_instance, scope.root].join("\0")
87
+ Digest::SHA256.hexdigest(identity)
88
+ end
89
+
90
+ def create(path, payload)
91
+ File.open(path, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
92
+ file.write(JSON.generate(payload))
93
+ file.flush
94
+ file.fsync
95
+ end
96
+ sync_root
97
+ end
98
+
99
+ def ensure_no_unresolved_dispatch!(scope)
100
+ path = dispatch_path_for(scope)
101
+ return unless File.exist?(path) || File.symlink?(path)
102
+
103
+ stat = File.lstat(path)
104
+ raise DeliveryUnknown, unresolved_dispatch_message unless stat.file? && !stat.symlink?
105
+
106
+ read(path)
107
+ raise DeliveryUnknown, unresolved_dispatch_message
108
+ rescue Errno::ENOENT
109
+ nil
110
+ rescue JSON::ParserError
111
+ raise DeliveryUnknown, unresolved_dispatch_message
112
+ end
113
+
114
+ def unresolved_dispatch_message
115
+ "A previous action may have been delivered; inspect the application before starting another task"
116
+ end
117
+
118
+ def sync_root
119
+ File.open(@root, File::RDONLY, &:fsync)
120
+ end
121
+
122
+ def reclaim(path)
123
+ stat = File.lstat(path)
124
+ raise ScopeBusy, "Scope lease is not a regular owner-only file" unless stat.file? && !stat.symlink?
125
+
126
+ stored = read(path)
127
+ return false if process_alive?(stored["pid"])
128
+
129
+ File.unlink(path)
130
+ true
131
+ rescue Errno::ENOENT
132
+ true
133
+ rescue JSON::ParserError
134
+ raise ScopeBusy, "Scope lease is unreadable; remove #{path} after checking for a live session"
135
+ end
136
+
137
+ def read(path)
138
+ JSON.parse(File.read(path, 4096))
139
+ end
140
+
141
+ def process_alive?(pid)
142
+ return false unless pid.is_a?(Integer) && pid.positive?
143
+
144
+ Process.kill(0, pid)
145
+ true
146
+ rescue Errno::ESRCH
147
+ false
148
+ rescue Errno::EPERM
149
+ true
150
+ end
151
+ end
152
+ end
@@ -6,6 +6,7 @@ require "socket"
6
6
 
7
7
  require_relative "run_loop"
8
8
  require_relative "safari"
9
+ require_relative "timing"
9
10
 
10
11
  module Wrangle
11
12
  # A Safari session that outlives a single command.
@@ -37,11 +38,12 @@ module Wrangle
37
38
 
38
39
  # `session` is injectable so the protocol, the diffing, and the refusal shapes can be tested
39
40
  # against a fake bridge instead of a browser.
40
- def initialize(socket_path, options, session: nil, jev: nil)
41
+ def initialize(socket_path, options, session: nil, jev: nil, timing: Timing)
41
42
  @socket_path = socket_path
42
43
  @options = options
43
44
  @session = session
44
45
  @jev = jev
46
+ @timing = timing
45
47
  @page = nil
46
48
  @acted = 0
47
49
  @history = []
@@ -103,7 +105,7 @@ module Wrangle
103
105
  def watch(thinking)
104
106
  seen = @page && @page["fingerprint"]
105
107
  while thinking.alive?
106
- sleep(STEADY_POLL)
108
+ pause(STEADY_POLL)
107
109
  break unless thinking.alive?
108
110
 
109
111
  looked = @session.observe
@@ -156,7 +158,7 @@ module Wrangle
156
158
  deadline = now + budget
157
159
  loop do
158
160
  before = @page["fingerprint"]
159
- sleep(STEADY_POLL)
161
+ pause(STEADY_POLL)
160
162
  @page = @session.observe
161
163
  return @page if @page["fingerprint"] == before || now >= deadline
162
164
  end
@@ -181,10 +183,10 @@ module Wrangle
181
183
  if @options["window_id"]
182
184
  raise ArgumentError, "The MCP backend cannot attach to an existing window" if mcp?
183
185
 
184
- Safari.attach(window_id: @options["window_id"], display: @options["display"])
186
+ Safari.attach(window_id: @options["window_id"], display: @options["display"], timing: @timing)
185
187
  else
186
188
  Safari.open(@options.fetch("url"),
187
- display: @options["display"], bounds: @options["bounds"],
189
+ display: @options["display"], bounds: @options["bounds"], timing: @timing,
188
190
  **(mcp? ? { bridge: McpBridge.new } : {}))
189
191
  end
190
192
  end
@@ -290,7 +292,7 @@ module Wrangle
290
292
  stable = 0
291
293
  moved = departed?(baseline, page)
292
294
  until now >= deadline || (stable >= STABLE_ROUNDS && (moved || now >= floor))
293
- sleep SETTLE_POLL
295
+ pause(SETTLE_POLL)
294
296
  nxt = @session.observe
295
297
  stable = nxt["fingerprint"] == page["fingerprint"] ? stable + 1 : 0
296
298
  moved ||= departed?(baseline, nxt)
@@ -382,7 +384,7 @@ module Wrangle
382
384
  expect = request["expect"] && Regexp.new(request["expect"], Regexp::IGNORECASE)
383
385
  plan = Array(request["plan"]).filter_map { |goal| presence(goal) }
384
386
  plan = [request["goal"]] if plan.empty?
385
- summarise(request, RunLoop.new(self, request, expect).run(plan), expect)
387
+ summarise(request, RunLoop.new(self, request, expect, timing: @timing).run(plan), expect)
386
388
  end
387
389
 
388
390
  def summarise(request, steps, expect)
@@ -399,7 +401,8 @@ module Wrangle
399
401
 
400
402
  def presence(value) = value.nil? || value.to_s.empty? ? nil : value.to_s
401
403
 
402
- def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
404
+ def now = @timing.now
405
+ def pause(seconds) = @timing.sleep(seconds)
403
406
 
404
407
  def shutdown
405
408
  begin
@@ -407,7 +410,10 @@ module Wrangle
407
410
  rescue Wrangle::Error
408
411
  nil # A poisoned session refuses to close its window. That refusal is the correct outcome.
409
412
  end
410
- FileUtils.rm_f(@socket_path) if @socket_path
413
+ return unless @socket_path
414
+
415
+ FileUtils.rm_f(@socket_path)
416
+ FileUtils.rm_f("#{@socket_path}.pid")
411
417
  end
412
418
  end
413
419
 
@@ -0,0 +1,251 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "errors"
6
+ require_relative "macos_driver"
7
+ require_relative "macos_helper"
8
+
9
+ module Wrangle
10
+ # Binds a Tart VM name to one guest boot generation. Every guest-helper request verifies the VM
11
+ # before and after transport, so a stop, reboot, replacement, or guest-agent reconnect loses scope
12
+ # instead of silently attaching to a new desktop.
13
+ class TartVM
14
+ NAME = /\A[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}\z/
15
+ DEFAULT_TART = ENV.fetch("WRANGLE_TART", "tart")
16
+ BOOT_COMMAND = %w[/usr/sbin/sysctl -n kern.boottime].freeze
17
+ BOOT_PATTERN = /\bsec = (\d+), usec = (\d+)\b/
18
+ TIMEOUT = 30
19
+
20
+ attr_reader :name, :instance
21
+
22
+ def initialize(name:, tart: DEFAULT_TART, runner_factory: nil, environment: ENV)
23
+ raise ArgumentError, "A valid Tart VM name is required" unless name.is_a?(String) && name.match?(NAME)
24
+ raise ArgumentError, "A Tart executable is required" if tart.to_s.empty? || tart.to_s.include?("\0")
25
+
26
+ @name = name
27
+ @tart = tart
28
+ @environment = filtered_environment(environment)
29
+ @runner_factory = runner_factory || method(:runner)
30
+ @instance = capture_instance
31
+ end
32
+
33
+ def around
34
+ verify!
35
+ result = yield
36
+ verify!
37
+ result
38
+ end
39
+
40
+ def verify!
41
+ actual = capture_instance
42
+ return true if actual == instance
43
+
44
+ raise DriverRefusal.new(
45
+ "scope_changed", "The scoped Tart guest restarted or was replaced",
46
+ delivery: "not_delivered", retry_disposition: "unsafe"
47
+ )
48
+ rescue DriverRefusal
49
+ raise
50
+ rescue DriverError
51
+ raise DriverRefusal.new(
52
+ "scope_changed", "The scoped Tart guest is no longer available",
53
+ delivery: "not_delivered", retry_disposition: "unsafe"
54
+ )
55
+ end
56
+
57
+ private
58
+
59
+ def capture_instance
60
+ selected = inventory.select { |record| record["Name"] == name }
61
+ unless selected.one? && selected.first["Running"] == true
62
+ raise DriverUnavailable, "The exact Tart guest is not running"
63
+ end
64
+
65
+ boot = run(@tart, "exec", name, *BOOT_COMMAND)
66
+ match = BOOT_PATTERN.match(boot)
67
+ raise DriverError, "The Tart guest returned no stable boot identity" unless match
68
+
69
+ "tart-guest-v1:#{name}:#{match[1]}:#{match[2]}"
70
+ end
71
+
72
+ def inventory
73
+ parsed = JSON.parse(run(@tart, "list", "--format", "json"))
74
+ valid = parsed.is_a?(Array) && parsed.all?(Hash)
75
+ raise DriverError, "Tart returned an invalid VM inventory" unless valid
76
+
77
+ parsed
78
+ rescue JSON::ParserError
79
+ raise DriverError, "Tart returned invalid VM inventory JSON"
80
+ end
81
+
82
+ def run(*command)
83
+ result = @runner_factory.call(command).call
84
+ raise DriverUnavailable, "Tart guest transport is unavailable" unless result.status.success?
85
+
86
+ result.stdout
87
+ end
88
+
89
+ def runner(command)
90
+ MacOSHelper::Runner.new(command:, environment: @environment, timeout: TIMEOUT)
91
+ end
92
+
93
+ def filtered_environment(source)
94
+ MacOSHelper::SAFE_ENV.each_with_object({}) { |key, kept| kept[key] = source[key] if source.key?(key) }
95
+ end
96
+ end
97
+
98
+ # Presents the normal MacOSHelper contract over Tart's argument-only guest-agent transport.
99
+ class TartGuestHelper
100
+ def initialize(vm_guard:, guest_app:, helper:)
101
+ @vm_guard = vm_guard
102
+ @guest_app = guest_app
103
+ @helper = helper
104
+ end
105
+
106
+ def ping = guarded { @helper.ping }
107
+ def displays = guarded { @helper.displays }
108
+
109
+ def windows(app:, titles: false)
110
+ validate_app!(app)
111
+ guarded do
112
+ windows = @helper.windows(app:, titles:)
113
+ unless windows.is_a?(Array) && windows.all?(Hash)
114
+ raise DriverError, "The guest helper returned an invalid window inventory"
115
+ end
116
+
117
+ windows.map do |window|
118
+ process = window["process_instance"]
119
+ process.is_a?(String) ? window.merge("process_instance" => scoped_process(process)) : window
120
+ end
121
+ end
122
+ end
123
+
124
+ def enable_accessibility(pid:, process_instance:)
125
+ guarded { @helper.enable_accessibility(pid:, process_instance: guest_process(process_instance)) }
126
+ end
127
+
128
+ def snapshot(pid:, process_instance:, window_id:, bounds:, root_target: nil, max_depth: 18)
129
+ guarded do
130
+ @helper.snapshot(
131
+ pid:, process_instance: guest_process(process_instance), window_id:, bounds:, root_target:, max_depth:
132
+ )
133
+ end
134
+ end
135
+
136
+ def execute(*)
137
+ raise DriverRefusal.new(
138
+ "read_only", "The Tart guest prototype does not dispatch actions",
139
+ delivery: "not_delivered", retry_disposition: "unsafe"
140
+ )
141
+ end
142
+
143
+ private
144
+
145
+ def guarded(&) = @vm_guard.around(&)
146
+
147
+ def validate_app!(app)
148
+ return if app.to_s.casecmp?(@guest_app)
149
+
150
+ raise ScopeLost, "The Tart guest driver cannot leave its explicit application scope"
151
+ end
152
+
153
+ def scoped_process(process_instance) = "#{@vm_guard.instance}/#{process_instance}"
154
+
155
+ def guest_process(process_instance)
156
+ prefix = "#{@vm_guard.instance}/"
157
+ unless process_instance.is_a?(String) && process_instance.start_with?(prefix)
158
+ raise ScopeLost, "The process does not belong to the scoped Tart guest"
159
+ end
160
+
161
+ process_instance.delete_prefix(prefix)
162
+ end
163
+ end
164
+
165
+ # Read-only exact-window driver for one explicitly named Tart VM and one explicitly named guest
166
+ # application. It deliberately exposes no mutation path and never falls back to the host Tart view.
167
+ class TartGuestDriver
168
+ DEFAULT_HELPER = ENV.fetch(
169
+ "WRANGLE_TART_GUEST_HELPER",
170
+ "/Users/admin/Applications/Wrangle Helper.app/Contents/MacOS/wrangle-macos-helper"
171
+ )
172
+
173
+ attr_reader :vm_name, :vm_instance, :guest_app
174
+
175
+ def initialize(vm_name:, guest_app:, tart: TartVM::DEFAULT_TART, guest_helper: DEFAULT_HELPER,
176
+ guard: nil, helper: nil, driver: nil)
177
+ validate_helper!(guest_helper)
178
+ unless vm_name.is_a?(String) && vm_name.match?(TartVM::NAME)
179
+ raise ArgumentError, "A valid Tart VM name is required"
180
+ end
181
+ raise ArgumentError, "An explicit Tart guest application is required" if guest_app.to_s.strip.empty?
182
+
183
+ @guard = guard || TartVM.new(name: vm_name, tart:)
184
+ guest = helper || MacOSHelper.new(command: [tart, "exec", vm_name, guest_helper])
185
+ guarded = TartGuestHelper.new(vm_guard: @guard, guest_app:, helper: guest)
186
+ @driver = driver || MacOSDriver.new(helper: guarded, observation_driver: "tart_guest", read_only: true)
187
+ @vm_name = vm_name
188
+ @vm_instance = @guard.instance
189
+ @guest_app = guest_app
190
+ end
191
+
192
+ def doctor
193
+ result = @driver.doctor
194
+ capabilities = Hash(result["capabilities"]).merge("ax_dispatch" => "unavailable_read_only")
195
+ result.merge(
196
+ "environment" => "tart_guest", "vm" => vm_name, "guest_app" => guest_app,
197
+ "read_only" => true, "capabilities" => capabilities
198
+ )
199
+ end
200
+
201
+ def windows(app:, titles: false)
202
+ validate_app!(app)
203
+ @driver.windows(app:, titles:)
204
+ end
205
+
206
+ def displays = @driver.displays
207
+
208
+ def attach(window_id:, app:)
209
+ validate_app!(app)
210
+ @driver.attach(window_id:, app:).tap { |scope| validate_scope!(scope) }
211
+ end
212
+
213
+ def observe(scope, skeleton: true)
214
+ validate_scope!(scope)
215
+ @driver.observe(scope, skeleton:)
216
+ end
217
+
218
+ def drill(scope, ref:, snapshot_id:)
219
+ validate_scope!(scope)
220
+ @driver.drill(scope, ref:, snapshot_id:)
221
+ end
222
+
223
+ def execute(*)
224
+ {
225
+ "dispatch" => "not_delivered", "code" => "read_only",
226
+ "message" => "The Tart guest prototype does not dispatch actions", "retry_safe" => false
227
+ }
228
+ end
229
+
230
+ def close = @driver.close
231
+
232
+ private
233
+
234
+ def validate_scope!(scope)
235
+ return if scope.process_instance.to_s.start_with?("#{vm_instance}/")
236
+
237
+ raise ScopeLost, "The Tart guest scope is stale or belongs to another driver"
238
+ end
239
+
240
+ def validate_helper!(path)
241
+ valid = path.is_a?(String) && path.start_with?("/") && !path.include?("\0") && !path.include?("\n")
242
+ raise ArgumentError, "An absolute Tart guest helper path is required" unless valid
243
+ end
244
+
245
+ def validate_app!(app)
246
+ return if app.to_s.casecmp?(guest_app)
247
+
248
+ raise ScopeLost, "The Tart guest driver cannot leave its explicit application scope"
249
+ end
250
+ end
251
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wrangle
4
+ # Real monotonic time and sleeping are defaults, not hard-coded dependencies. Tests and embedded
5
+ # callers can supply the same two-method interface to advance deadlines deterministically.
6
+ module Timing
7
+ module_function
8
+
9
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
10
+ def sleep(seconds) = Kernel.sleep(seconds)
11
+ end
12
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Wrangle
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/wrangle.rb CHANGED
@@ -2,7 +2,24 @@
2
2
 
3
3
  require_relative "wrangle/version"
4
4
  require_relative "wrangle/errors"
5
+ require_relative "wrangle/timing"
5
6
  require_relative "wrangle/observation"
7
+ require_relative "wrangle/desktop_observation"
8
+ require_relative "wrangle/desktop_policy"
9
+ require_relative "wrangle/decision_provider"
10
+ require_relative "wrangle/provider_qualification"
11
+ require_relative "wrangle/provider_factory"
12
+ require_relative "wrangle/desktop_decider"
13
+ require_relative "wrangle/desktop_autonomy"
14
+ require_relative "wrangle/desktop_session_autonomy"
15
+ require_relative "wrangle/desktop_dispatch"
16
+ require_relative "wrangle/desktop_effect"
17
+ require_relative "wrangle/desktop_proposal"
18
+ require_relative "wrangle/event_log"
19
+ require_relative "wrangle/scope_registry"
20
+ require_relative "wrangle/macos_helper"
21
+ require_relative "wrangle/macos_driver"
22
+ require_relative "wrangle/tart_guest_driver"
6
23
  require_relative "wrangle/jxa_bridge"
7
24
  require_relative "wrangle/mcp_bridge"
8
25
  require_relative "wrangle/safari"
@@ -10,8 +27,10 @@ require_relative "wrangle/jev"
10
27
  require_relative "wrangle/action_space"
11
28
  require_relative "wrangle/decider"
12
29
  require_relative "wrangle/session_server"
30
+ require_relative "wrangle/desktop_session_server"
31
+ require_relative "wrangle/desktop_task"
13
32
 
14
- # Hand one Safari window to a program, and no more than that.
33
+ # Scoped computer use for macOS: one Safari window or one app window, and no more than that.
15
34
  #
16
35
  # Wrangle drives an ordinary Safari window through Apple Events. There is no automation session and
17
36
  # no extension, so the window stays a real one the user can see, keep, and take back at any moment.
@@ -1,17 +1,50 @@
1
1
  ---
2
2
  name: wrangle
3
- description: Drive a real Safari window from the shell - open a page, see what it offers, click, type, and read results back. Use for looking things up on sites that need a real logged-in browser (flights, prices, dashboards, portals), filling a web form, or when the user says "use my browser", "check this site", or "/wrangle". Not for fetching static pages; use a normal HTTP fetch for those.
4
- compatibility: macOS with Safari, and `wrangle` on PATH. Requires Safari > Settings > Advanced > "Allow JavaScript from Apple Events", plus a one-time Apple Events permission prompt.
3
+ description: Use a real, scoped macOS app or Safari window for requests such as "Check the #notifications channel in Boehs Slack", "use my browser", or "/wrangle". Prefer the computer tool when available; otherwise use the Safari CLI. Not for static public pages that normal HTTP can fetch.
4
+ compatibility: macOS with Wrangle on PATH. Desktop use requires the repository Pi computer extension, Jev credentials (or an explicitly configured alternative provider), and Accessibility permission. Safari additionally requires JavaScript from Apple Events and its one-time permission prompt.
5
5
  ---
6
6
 
7
7
  # Wrangle
8
8
 
9
- Drive one real Safari window through a persistent session. Every command below is a shell command.
9
+ Wrangle lets an agent work inside one real window without taking over the rest of the computer.
10
+
11
+ ## Natural desktop requests in Pi
12
+
13
+ When the `computer` tool is available, call it **once** with the complete natural goal and application
14
+ name. **Do not make the user learn Wrangle commands, window IDs, refs, proposal IDs, revisions,
15
+ receipts, AX terminology, or provider configuration.** Wrangle owns window selection and the bounded
16
+ read → typed decision → policy → action → verification loop.
17
+
18
+ For example, when the user says:
19
+
20
+ > Check the #notifications channel in Boehs Slack.
21
+
22
+ call `computer` once with that goal and `app: "Slack"`, then summarize the returned visible evidence.
23
+ Do not manually list, attach, observe, drill, find, preview, execute, or finish.
24
+
25
+ - A plain imperative authorizes necessary reversible navigation, not Send, Delete, purchases, or
26
+ settings changes. Consequential actions stop before delivery. This alpha cannot resume the bound
27
+ proposal, so explain the limitation and do not call `computer` again in a loop.
28
+ - Pass a literal only when it is exact non-secret text from the user's request. Never invent text.
29
+ - Never use shell commands during or after a computer task—not for debugging, checking the date, or
30
+ bypassing a refusal. Never substitute a raw accessibility helper or another computer-use tool.
31
+ - Wrangle stops at eight actions and three progressive drills. Failed or uncertain delivery is never
32
+ retried automatically.
33
+ - Say what happened in the app: “I checked #notifications…” rather than describing decisions,
34
+ candidates, drivers, or receipts.
35
+ - Wrangle releases control automatically on success or failure and **leaves the user's app window
36
+ open by design**. There is no separate finish call.
37
+ - If delivery is uncertain, say plainly that the action may or may not have happened and do not retry.
38
+
39
+ ## Safari CLI
40
+
41
+ Use the CLI when the `computer` tool is unavailable or the task specifically targets Safari. Every
42
+ command below is a shell command.
10
43
 
11
44
  **Never write a Ruby script for this.** The CLI is the entire interface. If you catch yourself
12
45
  writing `require "wrangle"`, stop — use `wrangle observe` and `wrangle act` instead.
13
46
 
14
- ## The loop
47
+ ### The loop
15
48
 
16
49
  ```bash
17
50
  wrangle open "https://example.com" --display 1 --settle 8 # start; prints the first observation
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: wrangle
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eric Boehs
@@ -10,10 +10,10 @@ cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies: []
12
12
  description: |
13
- Wrangle drives an ordinary Safari window through Apple Events: no automation session, no
14
- extension, no native helper. The window stays a real one you can see, keep, and take back.
15
- Scope is the safety boundary - one window id, one tab position, one expected URL, one document
16
- epoch - and losing any of them ends the session rather than starting a search for a substitute.
13
+ Wrangle gives an agent one exact application window or Safari tab and no more than that. Its
14
+ native macOS driver runs a bounded typed-decision loop with policy, fresh-target validation,
15
+ at-most-once dispatch, and independent effect verification. Safari uses Apple Events for fast
16
+ DOM-grounded actions. Losing scope ends the task rather than searching for a substitute.
17
17
  email:
18
18
  - ericboehs@gmail.com
19
19
  executables:
@@ -28,17 +28,39 @@ files:
28
28
  - lib/wrangle.rb
29
29
  - lib/wrangle/action_space.rb
30
30
  - lib/wrangle/decider.rb
31
+ - lib/wrangle/decision_provider.rb
32
+ - lib/wrangle/desktop_autonomy.rb
33
+ - lib/wrangle/desktop_decider.rb
34
+ - lib/wrangle/desktop_dispatch.rb
35
+ - lib/wrangle/desktop_effect.rb
36
+ - lib/wrangle/desktop_observation.rb
37
+ - lib/wrangle/desktop_policy.rb
38
+ - lib/wrangle/desktop_progressive_observation.rb
39
+ - lib/wrangle/desktop_proposal.rb
40
+ - lib/wrangle/desktop_session_autonomy.rb
41
+ - lib/wrangle/desktop_session_server.rb
42
+ - lib/wrangle/desktop_task.rb
31
43
  - lib/wrangle/errors.rb
44
+ - lib/wrangle/event_log.rb
32
45
  - lib/wrangle/jev.rb
33
46
  - lib/wrangle/js/bridge.js
34
47
  - lib/wrangle/js/page.js
35
48
  - lib/wrangle/js/snapshot.js
36
49
  - lib/wrangle/jxa_bridge.rb
50
+ - lib/wrangle/macos/helper.swift
51
+ - lib/wrangle/macos_driver.rb
52
+ - lib/wrangle/macos_helper.rb
37
53
  - lib/wrangle/mcp_bridge.rb
38
54
  - lib/wrangle/observation.rb
55
+ - lib/wrangle/provider_conformance.jsonl
56
+ - lib/wrangle/provider_factory.rb
57
+ - lib/wrangle/provider_qualification.rb
39
58
  - lib/wrangle/run_loop.rb
40
59
  - lib/wrangle/safari.rb
60
+ - lib/wrangle/scope_registry.rb
41
61
  - lib/wrangle/session_server.rb
62
+ - lib/wrangle/tart_guest_driver.rb
63
+ - lib/wrangle/timing.rb
42
64
  - lib/wrangle/version.rb
43
65
  - skills/wrangle/SKILL.md
44
66
  homepage: https://github.com/ericboehs/wrangle
@@ -63,7 +85,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
63
85
  - !ruby/object:Gem::Version
64
86
  version: '0'
65
87
  requirements: []
66
- rubygems_version: 4.0.16
88
+ rubygems_version: 4.0.20
67
89
  specification_version: 4
68
- summary: Hand one Safari window to a program, and no more than that.
90
+ summary: Scoped, policy-gated computer use for real macOS and Safari windows.
69
91
  test_files: []