pwn 0.5.781 → 0.5.783

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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/documentation/Installation.md +33 -9
  3. data/documentation/diagrams/dot/extrospection-world-awareness.dot +37 -92
  4. data/documentation/diagrams/dot/memory-skills-detailed.dot +33 -98
  5. data/documentation/diagrams/dot/overall-pwn-architecture.dot +54 -32
  6. data/documentation/diagrams/dot/pwn-ai-feedback-learning-loop.dot +53 -30
  7. data/documentation/diagrams/dot/reinforcement-learning.dot +46 -111
  8. data/documentation/diagrams/dot/swarm-multi-agent.dot +36 -61
  9. data/documentation/diagrams/extrospection-world-awareness.svg +139 -510
  10. data/documentation/diagrams/memory-skills-detailed.svg +126 -475
  11. data/documentation/diagrams/overall-pwn-architecture.svg +116 -266
  12. data/documentation/diagrams/pwn-ai-feedback-learning-loop.svg +158 -244
  13. data/documentation/diagrams/reinforcement-learning.svg +238 -529
  14. data/documentation/diagrams/swarm-multi-agent.svg +104 -233
  15. data/documentation/pwn-REPL.md +31 -3
  16. data/documentation/pwn-ai-Agent.md +62 -2
  17. data/etc/default_cron/session_cleanup.rb +335 -42
  18. data/lib/pwn/ai/agent/curriculum.rb +2 -2
  19. data/lib/pwn/ai/agent/learning.rb +11 -4
  20. data/lib/pwn/ai/agent/mistakes.rb +22 -3
  21. data/lib/pwn/ai/agent/tools/sessions.rb +6 -6
  22. data/lib/pwn/ai/http_retry.rb +3 -0
  23. data/lib/pwn/ai/router.rb +3 -1
  24. data/lib/pwn/cron.rb +1 -1
  25. data/lib/pwn/memory.rb +62 -6
  26. data/lib/pwn/plugins/repl/ai.rb +13 -3
  27. data/lib/pwn/plugins/repl/ai_console.rb +37 -21
  28. data/lib/pwn/session_trace.rb +1 -1
  29. data/lib/pwn/sessions.rb +305 -80
  30. data/lib/pwn/version.rb +1 -1
  31. data/spec/lib/pwn/ai/agent/learning_spec.rb +65 -2
  32. data/spec/lib/pwn/ai/agent/swarm_spec.rb +3 -3
  33. data/spec/lib/pwn/memory_spec.rb +58 -1
  34. data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +191 -6
  35. data/spec/lib/pwn/plugins/repl/ai_result_chronology_spec.rb +2 -2
  36. data/spec/lib/pwn/plugins/repl/ai_spec.rb +28 -11
  37. data/spec/lib/pwn/session_cleanup_default_spec.rb +2 -2
  38. data/spec/lib/pwn/session_cleanup_payload_spec.rb +467 -36
  39. data/spec/lib/pwn/sessions_spec.rb +116 -16
  40. data/third_party/pwn_rdoc.jsonl +12 -0
  41. metadata +1 -1
data/lib/pwn/ai/router.rb CHANGED
@@ -226,7 +226,9 @@ module PWN
226
226
  private_class_method def self.env_dig(opts = {})
227
227
  return {} unless defined?(PWN::Env) && PWN::Env.respond_to?(:dig)
228
228
 
229
- val = PWN::Env.dig(*opts[:keys])
229
+ keys = opts[:keys]
230
+ config = Thread.current[:pwn_session_title_config]
231
+ val = config && keys.first == :ai ? config.dig(*keys.drop(1)) : PWN::Env.dig(*keys)
230
232
  if val.is_a?(Hash)
231
233
  val.transform_keys { |key| key.is_a?(String) ? key.to_sym : key }
232
234
  else
data/lib/pwn/cron.rb CHANGED
@@ -255,7 +255,7 @@ module PWN
255
255
  {
256
256
  name: 'session_cleanup',
257
257
  schedule: '45 3 * * *',
258
- ruby: "require File.join(PWN::Cron.cron_dir, 'session_cleanup.rb'); PWN::SessionCleanup.run(dry_run: false)",
258
+ ruby: "require File.join(PWN::Cron.cron_dir, 'session_cleanup.rb'); PWN::SessionCleanup.maintain(dry_run: false)",
259
259
  enabled: true
260
260
  },
261
261
  {
data/lib/pwn/memory.rb CHANGED
@@ -23,6 +23,31 @@ module PWN
23
23
  EPHEMERAL_KEY_PREFIXES = %w[session_].freeze
24
24
  EPHEMERAL_TTL_SECS = 7 * 86_400
25
25
 
26
+ # Permanent writer inode; nested calls share ownership only in this thread
27
+ # and process. Never unlink this lock when replacing a data file.
28
+ public_class_method def self.transaction(opts = {})
29
+ path = File.expand_path(opts[:path] || MEMORY_FILE)
30
+ return yield if transaction_owned?(path: path)
31
+
32
+ FileUtils.mkdir_p(File.dirname(path))
33
+ File.open("#{path}.write.lock", File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) do |lock|
34
+ lock.flock(File::LOCK_EX)
35
+ owners = (Thread.current[:pwn_store_transactions] ||= {})
36
+ owners[path] = Process.pid
37
+ begin
38
+ yield
39
+ ensure
40
+ owners.delete(path)
41
+ end
42
+ end
43
+ end
44
+
45
+ # Test ownership without mistaking an inherited post-fork marker for a lock.
46
+ public_class_method def self.transaction_owned?(opts = {})
47
+ path = File.expand_path(opts[:path] || MEMORY_FILE)
48
+ Thread.current[:pwn_store_transactions]&.fetch(path, nil) == Process.pid
49
+ end
50
+
26
51
  # Supported Method Parameters::
27
52
  # memory = PWN::Memory.load
28
53
  public_class_method def self.load
@@ -47,6 +72,8 @@ module PWN
47
72
  # Supported Method Parameters::
48
73
  # PWN::Memory.save(mem: memory_hash)
49
74
  public_class_method def self.save(opts = {})
75
+ return transaction(path: MEMORY_FILE) { save(opts) } unless PWN::Memory.transaction_owned?(path: MEMORY_FILE)
76
+
50
77
  mem = opts[:mem] ||= {}
51
78
  force = opts[:force] ? true : false
52
79
  FileUtils.mkdir_p(File.dirname(MEMORY_FILE))
@@ -58,8 +85,15 @@ module PWN
58
85
  warn "[pwn-ai/memory] refusing empty overwrite of #{File.size(path)}B #{path} (pass force:true to clear)"
59
86
  return load_raw_or_empty(path: path)
60
87
  end
88
+ atomic_write(path: path, body: JSON.pretty_generate(mem))
89
+ mem
90
+ end
91
+
92
+ # Caller holds the permanent store transaction across read and replacement.
93
+ private_class_method def self.atomic_write(opts = {})
94
+ path = opts[:path]
95
+ body = opts[:body]
61
96
  tmp = File.join(File.dirname(path), ".#{File.basename(path)}.#{Process.pid}.tmp")
62
- body = JSON.pretty_generate(mem)
63
97
  File.open(tmp, File::WRONLY | File::CREAT | File::TRUNC, 0o644) do |f|
64
98
  f.flock(File::LOCK_EX)
65
99
  f.write(body)
@@ -67,7 +101,7 @@ module PWN
67
101
  f.fsync
68
102
  end
69
103
  File.rename(tmp, path)
70
- mem
104
+ File.open(File.dirname(path), File::RDONLY, &:fsync)
71
105
  ensure
72
106
  FileUtils.rm_f(tmp) if defined?(tmp) && tmp && File.exist?(tmp)
73
107
  end
@@ -93,6 +127,8 @@ module PWN
93
127
  # ttl: 'optional - seconds until stale (M3; consolidate evicts stale low-conf first)'
94
128
  # )
95
129
  public_class_method def self.remember(opts = {})
130
+ return transaction(path: MEMORY_FILE) { remember(opts) } unless PWN::Memory.transaction_owned?(path: MEMORY_FILE)
131
+
96
132
  key = opts[:key]
97
133
  value = opts[:value]
98
134
  category = opts[:category] || :fact
@@ -479,7 +515,9 @@ module PWN
479
515
  # force: 'optional - Boolean bypass protect policy (default false)'
480
516
  # )
481
517
  # Refuses PROTECT_KEY_PREFIXES / PROTECT_CATEGORIES unless force:true.
482
- public_class_method def self.forget(opts = {}) # rubocop:disable Naming/PredicateMethod
518
+ public_class_method def self.forget(opts = {})
519
+ return transaction(path: MEMORY_FILE) { forget(opts) } unless PWN::Memory.transaction_owned?(path: MEMORY_FILE)
520
+
483
521
  key = opts[:key]
484
522
  raise 'ERROR: key is required' if key.nil?
485
523
 
@@ -498,6 +536,8 @@ module PWN
498
536
  # PWN::Memory.clear(force: true)
499
537
  # Requires force:true — protected prefs/SOPs must not vanish via bare clear.
500
538
  public_class_method def self.clear(opts = {})
539
+ return transaction(path: MEMORY_FILE) { clear(opts) } unless PWN::Memory.transaction_owned?(path: MEMORY_FILE)
540
+
501
541
  force = if opts.is_a?(Hash)
502
542
  opts[:force] ? true : false
503
543
  else
@@ -514,6 +554,8 @@ module PWN
514
554
  # context = PWN::Memory.to_context(limit: 20)
515
555
  # (used internally by pwn-ai hook to inject into system prompt)
516
556
  public_class_method def self.to_context(opts = {})
557
+ return transaction(path: MEMORY_FILE) { to_context(opts) } unless PWN::Memory.transaction_owned?(path: MEMORY_FILE)
558
+
517
559
  limit = (opts[:limit] || 12).to_i
518
560
  limit = 12 if limit <= 0
519
561
  mem = load
@@ -556,14 +598,20 @@ module PWN
556
598
  end
557
599
 
558
600
  private_class_method def self.archive_expired!(opts = {})
601
+ return transaction(path: MEMORY_FILE) { archive_expired!(opts) } unless PWN::Memory.transaction_owned?(path: MEMORY_FILE)
602
+
559
603
  rows = opts[:rows]
560
604
  return if rows.nil? || rows.empty?
561
605
 
606
+ mem = load
607
+ rows = rows.select { |key, value| mem[key] == value }
608
+ return if rows.empty?
609
+
562
610
  arch = File.file?(ARCHIVE_FILE) ? JSON.parse(File.read(ARCHIVE_FILE), symbolize_names: true) : {}
611
+ raise IOError, 'Invalid memory archive' unless arch.is_a?(Hash)
612
+
563
613
  arch.merge!(rows)
564
- FileUtils.mkdir_p(File.dirname(ARCHIVE_FILE))
565
- File.write(ARCHIVE_FILE, JSON.pretty_generate(arch))
566
- mem = load
614
+ atomic_write(path: ARCHIVE_FILE, body: JSON.pretty_generate(arch))
567
615
  rows.each_key { |k| mem.delete(k) }
568
616
  save(mem: mem, force: mem.empty?)
569
617
  rescue StandardError
@@ -590,6 +638,8 @@ module PWN
590
638
  # Compact overlong values and drop expired ephemeral session_* keys.
591
639
  # Never removes protected prefs/SOPs/fix lessons. Safe with empty-save guard.
592
640
  public_class_method def self.lean!(opts = {})
641
+ return transaction(path: MEMORY_FILE) { lean!(opts) } unless PWN::Memory.transaction_owned?(path: MEMORY_FILE)
642
+
593
643
  dry = opts[:dry_run] ? true : false
594
644
  vmax = (opts[:value_max_chars] || VALUE_MAX_CHARS).to_i
595
645
  ttl_secs = (opts[:ephemeral_ttl_secs] || EPHEMERAL_TTL_SECS).to_i
@@ -650,6 +700,12 @@ module PWN
650
700
  # Display Usage for this Module
651
701
  public_class_method def self.help
652
702
  puts "USAGE:
703
+ # Serialize a complete read-modify-write operation.
704
+ #{self}.transaction(path: 'optional - data store path, defaults to memory') { operation }
705
+
706
+ # Check current process and thread ownership.
707
+ #{self}.transaction_owned?(path: 'optional - data store path, defaults to memory')
708
+
653
709
  # Run load and return its result
654
710
  #{self}.load
655
711
 
@@ -129,7 +129,9 @@ module PWN
129
129
  case cmd
130
130
  when 'list', nil
131
131
  output.puts('Session dependencies: referenced by these files; text mentions are not links.')
132
- PWN::Plugins::REPL.pwn_ai_print_result(value: PWN::Sessions.list, output: output)
132
+ PWN::Plugins::REPL.pwn_ai_print_result(value: PWN::Sessions.catalog(offset: args[1], limit: args[2] || 25, current_session_id: pry_instance.config.pwn_ai_session_id), output: output)
133
+ when 'cleanup'
134
+ PWN::Plugins::REPL.pwn_ai_run_sessions(args: args)
133
135
  when 'resume'
134
136
  sid = args[1]
135
137
  hist = PWN::Sessions.to_response_history(session_id: sid)
@@ -262,7 +264,7 @@ module PWN
262
264
  '/mcp' => %w[list backends use current connect disconnect ping tools call status help],
263
265
  '/model' => %w[list],
264
266
  '/swarm' => %w[mission agents status create use spawn retire dm debate broadcast tail steer pause resume cancel help],
265
- '/sessions' => %w[list resume delete stats],
267
+ '/sessions' => %w[list resume delete stats cleanup],
266
268
 
267
269
  '/skills' => %w[list recall],
268
270
  '/learning' => %w[list requeue]
@@ -599,6 +601,8 @@ module PWN
599
601
  end
600
602
  true
601
603
  rescue StandardError => e
604
+ raise if opts[:raise_errors]
605
+
602
606
  warn "[pwn-ai] #{cmd}: #{e.class}: #{e.message}"
603
607
  true
604
608
  end
@@ -1038,7 +1042,13 @@ module PWN
1038
1042
  case sub
1039
1043
  when 'list'
1040
1044
  puts('Session dependencies: referenced by these files; text mentions are not links.')
1041
- pwn_ai_print_result(value: PWN::Sessions.list)
1045
+ pwn_ai_print_result(value: PWN::Sessions.catalog(offset: args[1], limit: args[2] || 25, current_session_id: opts[:pry]&.config&.pwn_ai_session_id))
1046
+ when 'cleanup'
1047
+ raise ArgumentError, 'usage: /sessions cleanup [--dry-run]' unless args.drop(1).empty? || args.drop(1) == ['--dry-run']
1048
+
1049
+ require File.expand_path('../../../../etc/default_cron/session_cleanup', __dir__)
1050
+ result = PWN::SessionCleanup.maintain(dry_run: args.include?('--dry-run'), current_session_id: opts[:pry]&.config&.pwn_ai_session_id)
1051
+ pwn_ai_print_result(value: PWN::SessionCleanup.summary(result))
1042
1052
  when 'resume'
1043
1053
  sid = PWN::Sessions.resolve(identifier: opts[:identifier] || args.drop(1).join(' '))
1044
1054
  lease = PWN::Sessions.activate(session_id: sid)
@@ -637,15 +637,14 @@ module PWN
637
637
  finish_request_metrics
638
638
  @running = false
639
639
  @control = nil
640
- queue_title_update(text) if text
640
+ queue_title_update(*text) if text
641
641
  when :session_title
642
642
  @session_metadata = text if text && text[:id] == @pry.config.pwn_ai_session_id
643
643
  when :title_done
644
644
  @title_worker.join
645
645
  @title_worker = nil
646
- pending = @title_pending
647
- @title_pending = nil
648
- queue_title_update(pending) if pending
646
+ pending = @title_pending&.shift
647
+ queue_title_update(*pending) if pending
649
648
  else
650
649
  add(type, text)
651
650
  end
@@ -654,13 +653,16 @@ module PWN
654
653
  nil
655
654
  end
656
655
 
657
- def queue_title_update(session_id)
656
+ def queue_title_update(session_id, ai_config = Marshal.load(Marshal.dump(PWN::Env[:ai])))
657
+ @title_pending ||= {}
658
658
  if @title_worker
659
- @title_pending = session_id
659
+ @title_pending[session_id] = ai_config
660
660
  return
661
661
  end
662
662
  @title_worker = Thread.new do
663
- @events << [:session_title, PWN::Sessions.refresh_title(session_id: session_id)]
663
+ @events << [:session_title, PWN::Sessions.refresh_title(session_id: session_id, ai_config: ai_config, on_error: lambda { |kind|
664
+ @events << [:warning, "Session title refresh unavailable for #{session_id} (#{kind}); previous title retained. Resume this session to retry."]
665
+ })]
664
666
  ensure
665
667
  @events << [:title_done, nil]
666
668
  end
@@ -689,8 +691,9 @@ module PWN
689
691
  PWN::Plugins::Log.mirror_tui!(msg: "#{type.to_s.upcase}\n#{clean}") if @pry.config.pwn_ai_debug && type != :notice
690
692
  end
691
693
 
692
- def start_request(request, command: false)
694
+ def start_request(request, command: false, &operation)
693
695
  session_id = @pry.config.pwn_ai_session_id
696
+ title_config = Marshal.load(Marshal.dump(PWN::Env[:ai]))
694
697
  @locals = Thread.current.keys.select { |key| key.to_s.start_with?('pwn_') }.to_h { |key| [key, Thread.current[key]] }
695
698
  @thread_variables = Thread.current.thread_variables.select { |key| key.to_s.start_with?('pwn_') }.to_h { |key| [key, Thread.current.thread_variable_get(key)] }
696
699
  # A fresh forwarding pipe prevents unread input crossing requests.
@@ -725,16 +728,24 @@ module PWN
725
728
  end
726
729
  if command
727
730
  control.checkpoint(messages: [], phase: :tool)
728
- if request.match?(%r{\A/model(?:\s|$)})
731
+ if operation
732
+ operation.call
733
+ elsif request.match?(%r{\A/model(?:\s|$)})
729
734
  REPL.pwn_ai_run_model(args: request.split.drop(1), prompt: lambda { |selection|
730
735
  control.checkpoint(messages: [])
731
736
  @events << [:model_prompt, selection]
732
737
  :deferred
733
738
  })
734
739
  else
735
- REPL.pwn_ai_dispatch_slash!(request: request, pry: @pry)
740
+ REPL.pwn_ai_dispatch_slash!(request: request, pry: @pry, raise_errors: true)
741
+ end
742
+ # Maintenance is one safe transaction: never label its committed
743
+ # deletions cancelled merely because stop was requested mid-write.
744
+ if request.match?(%r{\A/sessions\s+cleanup(?:\s|$)})
745
+ @events << [:notice, 'Session cleanup finished; completed work is not undone.']
746
+ else
747
+ control.checkpoint(messages: [])
736
748
  end
737
- control.checkpoint(messages: [])
738
749
  else
739
750
  workspace = File.realpath(Dir.pwd)
740
751
  workspace_lock = File.open(File.join(Dir.tmpdir, "pwn-solve-#{Digest::SHA256.hexdigest(workspace)}.lock"), File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) # rubocop:disable Style/FileOpen -- worker ensure owns the lease
@@ -757,7 +768,7 @@ module PWN
757
768
  @events << [:warning, "#{e.class}: #{e.message}"]
758
769
  ensure
759
770
  workspace_lock&.close
760
- @events << [:done, command ? nil : session_id]
771
+ @events << [:done, command ? nil : [session_id, title_config]]
761
772
  end
762
773
  @worker.report_on_exception = false
763
774
  @control = ready.pop
@@ -810,7 +821,7 @@ module PWN
810
821
  clear_view
811
822
  elsif ['/sessions', '/sessions picker'].include?(line.strip)
812
823
  open_operator(:sessions)
813
- elsif line.strip == '/sessions cleanup'
824
+ elsif line.strip == '/sessions cleanup --dry-run'
814
825
  open_operator(:cleanup)
815
826
  elsif line.strip == '/evidence'
816
827
  open_operator(:evidence)
@@ -837,7 +848,7 @@ module PWN
837
848
  open_vault_edit(line)
838
849
  elsif line.strip == '/system-role'
839
850
  open_system_role
840
- elsif line.match?(%r{\A/(?:mcp(?:\s|$)|cron\s+run(?:\s|$)|model(?:\s|$))})
851
+ elsif line.match?(%r{\A/(?:mcp(?:\s|$)|cron\s+run(?:\s|$)|model(?:\s|$)|sessions\s+cleanup(?:\s|$))})
841
852
  start_request(line, command: true)
842
853
  elsif line.start_with?('/') || line.start_with?('ai.profile ', 'ai.memory ')
843
854
  if line.match?(%r{\A/sessions[[:space:]]+resume(?:[[:space:]]|$)})
@@ -845,6 +856,7 @@ module PWN
845
856
 
846
857
  REPL.pwn_ai_dispatch_slash!(request: line, pry: @pry)
847
858
  @session_metadata = PWN::Sessions.metadata(session_id: @pry.config.pwn_ai_session_id)
859
+ queue_title_update(@pry.config.pwn_ai_session_id)
848
860
  elsif !REPL.pwn_ai_dispatch_slash!(request: line, pry: @pry)
849
861
  add(:warning, 'Unknown command. Use / or Tab for commands.')
850
862
  end
@@ -2027,7 +2039,7 @@ module PWN
2027
2039
  view[:rows] = PWN::Sessions.list.sort_by { |row| [row[:mtime].to_s, row[:id]] } if view[:kind] == :sessions
2028
2040
  if view[:kind] == :cleanup
2029
2041
  require File.expand_path('../../../../etc/default_cron/session_cleanup', __dir__)
2030
- view[:plan] = PWN::SessionCleanup.run(current_session_id: session_id)
2042
+ view[:plan] = PWN::SessionCleanup.maintain(dry_run: true, current_session_id: session_id)
2031
2043
  view[:rows] = view[:plan][:records].map do |row|
2032
2044
  category = row[:skip] || row[:apply_blocked_by] || (if row[:classification] == :named
2033
2045
  :summarized
@@ -2352,7 +2364,7 @@ module PWN
2352
2364
  ['START HERE · local guide; no work is launched here.',
2353
2365
  'Write a task in MISSION CONTROL. /readiness checks routes, tools and workspace locally.',
2354
2366
  'Ctrl+S opens the team workspace. v previews your exact mission draft before confirmation.',
2355
- '/evidence inspects actual checks and revisions. /sessions finds stable IDs; /sessions cleanup previews retention.',
2367
+ '/evidence inspects actual checks and revisions. /sessions cleanup runs maintenance; add --dry-run to preview.',
2356
2368
  '/learning inbox reviews retained evidence. /steer queues guidance; Ctrl+C requests cancellation, not undo.',
2357
2369
  '1 Insert: Inspect this workspace and explain its entry points without changing files.',
2358
2370
  '2 Insert: Add a regression test for the reported bug, fix it, and execute the test.',
@@ -2390,13 +2402,17 @@ module PWN
2390
2402
  end
2391
2403
 
2392
2404
  def apply_operator_cleanup
2405
+ return add(:warning, 'Maintenance waits until the active request or swarm finishes.') if busy?
2406
+
2393
2407
  view = @operator_view
2394
- approved = view[:plan][:records].select { |row| view[:plan][:candidate_ids].include?(row[:id]) }.to_h { |row| [row[:id], row[:files]] }
2395
- result = PWN::SessionCleanup.run(dry_run: false, current_session_id: @pry.config.pwn_ai_session_id, approved_candidates: approved)
2408
+ approved = view[:plan][:records].select { |row| view[:plan][:candidate_ids].include?(row[:id]) }.to_h { |row| [row[:id], { files: row[:files], dependency_records: row[:dependency_records] }] }
2409
+ session_id = @pry.config.pwn_ai_session_id
2396
2410
  view.delete(:confirm)
2397
- view[:notice] = "Deleted: #{result[:deleted_ids].join(', ')}; backup: #{result[:archive_dir] || 'none'}; skipped: #{result[:skipped]}"
2398
- add(:command, REPL.pwn_ai_result_text(value: result))
2399
- refresh_operator
2411
+ @operator_view = nil
2412
+ start_request('/sessions cleanup (approved preview)', command: true) do
2413
+ result = PWN::SessionCleanup.run(dry_run: false, current_session_id: session_id, approved_candidates: approved)
2414
+ @events << [:command, REPL.pwn_ai_result_text(value: result)]
2415
+ end
2400
2416
  end
2401
2417
 
2402
2418
  def operator_viewport(page)
@@ -119,7 +119,7 @@ module PWN
119
119
  id = opts[:session_id].to_s
120
120
  raise ArgumentError, 'Invalid session id' unless id.match?(/\A[A-Za-z0-9][A-Za-z0-9_-]{0,127}\z/)
121
121
 
122
- dir = File.join(PWN::Sessions.sessions_dir, id)
122
+ dir = File.dirname(PWN::Sessions.support_path(kind: :trace, session_id: id))
123
123
  raise ArgumentError, 'Symlinked trace directory' if File.symlink?(dir)
124
124
 
125
125
  File.join(dir, 'trace.jsonl')