pwn 0.5.760 → 0.5.762

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.
@@ -249,7 +249,7 @@ module PWN
249
249
  end
250
250
  end
251
251
  PWN_AI_SLASH_COMMANDS = %w[
252
- /back /cron /debug /delegate /help /learning /memory /mcp /model /sessions /skills /steer /system-role /trace
252
+ /back /cron /debug /delegate /help /learning /memory /mcp /model /sessions /skills /steer /swarm /system-role /trace
253
253
  ].freeze
254
254
 
255
255
  PWN_AI_SLASH_SUBCOMMANDS = {
@@ -261,6 +261,7 @@ module PWN
261
261
  '/memory' => %w[list recall remember forget clear],
262
262
  '/mcp' => %w[list backends use current connect disconnect ping tools call status help],
263
263
  '/model' => %w[list],
264
+ '/swarm' => %w[mission agents status create use spawn retire dm debate broadcast tail steer pause resume cancel help],
264
265
  '/sessions' => %w[list resume delete stats],
265
266
  '/skills' => %w[list recall],
266
267
  '/learning' => %w[list requeue]
@@ -524,6 +525,10 @@ module PWN
524
525
  args = tokens[1..]
525
526
  pi = opts[:pry]
526
527
  case cmd
528
+ when '/swarm'
529
+ pi.config.pwn_ai_swarm ||= AISwarm::Controller.new(session_id: pi.config.pwn_ai_session_id)
530
+ result = pi.config.pwn_ai_swarm.execute(line: request, on_event: ->(_type, text) { puts text })
531
+ puts(result[:usage] || result[:error] || result.inspect)
527
532
  when '/steer'
528
533
  puts(args.empty? ? '[pwn-ai] Usage: /steer <instruction>' : '[pwn-ai] /steer: no active request; submit a normal request instead')
529
534
  when '/help'
@@ -105,6 +105,8 @@ module PWN
105
105
 
106
106
  # Read a duplicated terminal rather than stdin, which belongs to tools.
107
107
  class Keyboard
108
+ attr_writer :interval
109
+
108
110
  KEYS = {
109
111
  "\e[A" => :up, "\e[B" => :down, "\e[C" => :right, "\e[D" => :left,
110
112
  "\eOA" => :up, "\eOB" => :down, "\eOC" => :right, "\eOD" => :left,
@@ -120,7 +122,7 @@ module PWN
120
122
  end
121
123
 
122
124
  def call
123
- if @source.wait_readable(@buffer.empty? ? 0.04 : 0)
125
+ if @source.wait_readable(@buffer.empty? ? (@interval || 0.04) : 0)
124
126
  chunk = @source.read_nonblock(4096, exception: false)
125
127
  return :eof if chunk.nil? && @buffer.empty?
126
128
 
@@ -402,6 +404,7 @@ module PWN
402
404
  break if @leaving && !busy?
403
405
 
404
406
  draw
407
+ @getch.interval = @focus == :animation && %i[asteroids galaga frogger].include?(@banner_name) ? 0.01 : 0.04 if @getch.is_a?(Keyboard)
405
408
  handle(@getch.call)
406
409
  rescue Interrupt
407
410
  cancel
@@ -607,7 +610,7 @@ module PWN
607
610
  clear_view
608
611
  elsif line.match?(%r{\A/verbose(?:\s|$)})
609
612
  toggle_verbose(line)
610
- elsif line.start_with?('/swarm') && %w[status tail steer cancel help roster].include?(line.split[1])
613
+ elsif line.start_with?('/swarm') && %w[status tail steer cancel pause resume help agents].include?(line.split[1])
611
614
  run_swarm(line)
612
615
  else
613
616
  add(:warning, 'Request running. Settings and new requests wait until idle. Use /steer, /input, /swarm status|steer|cancel, /clear, or /verbose.')
@@ -690,7 +693,7 @@ module PWN
690
693
 
691
694
  return clear_view if key == "\u000c"
692
695
 
693
- if ["\u0007", "\u0013"].include?(key)
696
+ if key == "\u0013" || (key == "\u0007" && (@focus != :animation || @workspace))
694
697
  @workspace ? @workspace = nil : open_swarm
695
698
  return
696
699
  end
@@ -801,12 +804,22 @@ module PWN
801
804
  end
802
805
 
803
806
  def handle_animation(key)
804
- if key == :paste_start
807
+ if key == "\u0007"
808
+ names = PWN::Banner.mini_names
809
+ @banner_name = names[(names.index(@banner_name).to_i + 1) % names.length]
810
+ @banner_frame_seconds = PWN::Banner.mini_frame_seconds(name: @banner_name)
811
+ @banner_started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
812
+ @banner_game = @banner_game_key = @banner_game_tick = nil
813
+ elsif key == :paste_start
805
814
  @focus = :mission
806
815
  @paste = true
807
816
  @banner_game&.controls
808
817
  elsif [:up, :down, :left, :right, ' '].include?(key)
818
+ return if %i[asteroids galaga frogger].include?(@banner_name) && @banner_game_frame&.dig(:state, :explosion).to_i.positive?
819
+
809
820
  @banner_game&.key(key)
821
+ # Paint a discrete control immediately, not after the next idle tick.
822
+ @banner_game_tick = nil if %i[asteroids galaga frogger].include?(@banner_name)
810
823
  elsif ["\r", "\n", "\e", :newline].include?(key)
811
824
  @focus = :mission
812
825
  @banner_game&.controls
@@ -1049,11 +1062,11 @@ module PWN
1049
1062
  usage = @usage.snapshot
1050
1063
  cost = usage[:estimated_cost_usd] ? format('$%.4f est.', usage[:estimated_cost_usd]) : usage[:cost_status]
1051
1064
  swarm = @swarm&.snapshot || {}
1052
- active = Array(swarm[:jobs]).count { |job| %w[queued running steering cancelling].include?(job[:state]) }
1065
+ active = Array(swarm[:jobs]).count { |job| AISwarm::Controller::ACTIVE.include?(job[:state]) }
1053
1066
  ['REQUEST', state, "Elapsed #{elapsed}", "Completed tools #{@completed_tools}",
1054
1067
  "Events #{@event_count}", '', 'TOKENS', "#{usage[:input_tokens]} in · #{usage[:output_tokens]} out",
1055
1068
  "cached #{usage[:cached_tokens]} · calls #{usage[:calls]}", "Cost #{cost}",
1056
- '', 'SWARM', swarm[:swarm_id] || 'none', "#{active} active · ^G swarm",
1069
+ '', 'SWARM', swarm[:swarm_id] || 'none', "#{active} active · ^S swarm",
1057
1070
  '', 'VIEW', @verbose ? 'verbose' : 'compact · /verbose', scroll_status,
1058
1071
  '', 'LAST TOOL', @last_tool || 'not observed']
1059
1072
  end
@@ -1226,6 +1239,7 @@ module PWN
1226
1239
  cadence = cells ? @banner_frame_seconds : PWN::Banner::MINI_FRAME_SECONDS
1227
1240
  frame = ((now - @banner_started) / cadence).floor % PWN::Banner::MINI_FRAME_COUNT
1228
1241
  return playable_banner(size, now) if cells && @focus == :animation
1242
+ return automatic_banner(size, now) if cells && size >= 5 && %i[pacman galaga frogger].include?(@banner_name)
1229
1243
  return PWN::Banner.mini_cells(name: @banner_name, frame: frame, width: size, height: size, seed: @banner_seed, branding: false) if cells
1230
1244
 
1231
1245
  art = PWN::Banner.mini_frame(name: @banner_name, frame: frame, width: size, height: size, branding: false)
@@ -1235,6 +1249,24 @@ module PWN
1235
1249
  canvas
1236
1250
  end
1237
1251
 
1252
+ def automatic_banner(size, now)
1253
+ key = [@banner_session, @banner_name, size, @banner_seed]
1254
+ if @banner_demo_key != key
1255
+ @banner_demo_key = key
1256
+ @banner_demo = PWN::Banner::MiniGame.new(name: @banner_name, width: size, height: size, seed: @banner_seed, demo: true)
1257
+ @banner_demo.step
1258
+ @banner_demo_tick = nil
1259
+ end
1260
+ tick = (now / @banner_frame_seconds).floor
1261
+ if @banner_demo_tick != tick
1262
+ # Advance one frame, never build 1800 or catch up after a modal.
1263
+ @banner_demo_frame = @banner_demo.step
1264
+ @banner_demo_cells = @banner_demo.cells(@banner_demo_frame)
1265
+ @banner_demo_tick = tick
1266
+ end
1267
+ @banner_demo_cells
1268
+ end
1269
+
1238
1270
  def playable_banner(size, now)
1239
1271
  key = [@banner_session, @banner_name, size, @banner_seed]
1240
1272
  if @banner_game_key != key
@@ -1242,9 +1274,13 @@ module PWN
1242
1274
  @banner_game = PWN::Banner::MiniGame.new(name: @banner_name, width: size, height: size, seed: @banner_seed)
1243
1275
  @banner_game_tick = nil
1244
1276
  end
1245
- tick = (now / @banner_frame_seconds).floor
1277
+ # Human Snake input needs a full reaction interval on its tiny board;
1278
+ # decorative replay cadence is independent of player movement.
1279
+ cadence = @banner_name == :snake ? 0.2 : @banner_frame_seconds
1280
+ tick = (now / cadence).floor
1246
1281
  if @banner_game_tick != tick
1247
- @banner_game_cells = @banner_game.cells(@banner_game.step)
1282
+ @banner_game_frame = @banner_game.step
1283
+ @banner_game_cells = @banner_game.cells(@banner_game_frame)
1248
1284
  @banner_game_tick = tick
1249
1285
  end
1250
1286
  @banner_game_cells
@@ -1308,9 +1344,10 @@ module PWN
1308
1344
  def footer_text
1309
1345
  text = '/ menu · ^C=cancel · ^D=back · ^L=clear · ^T=toggle pane · ^S=swarm · ^R=search ^O=operations · ↑↓ history · Shift+Enter=newline · Enter=send'
1310
1346
  if @focus == :animation
1311
- controls = { pong: '↑↓ paddle', snake: '↑↓←→ steer', falling_blocks: '←→ move · ↓ faster · Space=rotate',
1312
- asteroids: '←→ turn · ↑↓ thrust/reverse · Space=fire' }.fetch(@banner_name, '↑↓←→ play')
1313
- return text.sub('↑↓ history', controls).sub('Shift+Enter=newline · Enter=send', 'Enter/Esc=draft')
1347
+ controls = { pacman: '↑↓←→ start/steer · ● power', snake: '↑↓←→ start/steer', falling_blocks: '←→ move · ↓ faster · Space=rotate',
1348
+ asteroids: '←→ turn · ↑↓ thrust/reverse · Space=fire', galaga: '↑↓←→ move · Space=fire',
1349
+ frogger: '↑↓←→ hop · logs → goals' }.fetch(@banner_name, '↑↓←→ play')
1350
+ return text.sub('↑↓ history', "^G=next game · #{controls}").sub('Shift+Enter=newline · Enter=send', 'Enter/Esc=draft')
1314
1351
  end
1315
1352
  @focus == :session ? text.sub('↑↓ history', '↑↓ scroll · HOME · PGUP · PGDN · END') : text
1316
1353
  end
@@ -1522,6 +1559,10 @@ module PWN
1522
1559
  when :home then view[:offset] = 0
1523
1560
  when :end then view[:offset] = 1_000_000
1524
1561
  when 'r' then refresh_swarm
1562
+ when 'v'
1563
+ view[:confirm] = ['mission', @editor.text] unless @editor.text.strip.empty?
1564
+ when 'p', 'u'
1565
+ view[:confirm] = [key == 'p' ? 'pause' : 'resume', item[:id]] if view[:tab] == :jobs && item
1525
1566
  when 'a', 'b', 'd' then prepare_swarm_mission(key, item)
1526
1567
  when 'c', "\u0003"
1527
1568
  return unless view[:tab] == :jobs && item
@@ -1545,9 +1586,9 @@ module PWN
1545
1586
  minimum = key == 'd' ? 2 : 1
1546
1587
  return view[:notice] = "Select at least #{minimum} agent(s) with Space." if names.length < minimum
1547
1588
 
1548
- command = { 'a' => 'ask', 'b' => 'broadcast', 'd' => 'debate' }.fetch(key)
1589
+ command = { 'a' => 'dm', 'b' => 'broadcast', 'd' => 'debate' }.fetch(key)
1549
1590
  view[:confirm] = case command
1550
- when 'ask' then [command, names.first, @editor.text]
1591
+ when 'dm' then [command, names.first, @editor.text]
1551
1592
  when 'broadcast' then [command, '--names', names.join(','), '--', @editor.text]
1552
1593
  else [command, names.join(','), '--', @editor.text]
1553
1594
  end
@@ -1577,17 +1618,21 @@ module PWN
1577
1618
 
1578
1619
  def execute_swarm_action
1579
1620
  args = @workspace.delete(:confirm)
1580
- result = @swarm.execute(line: Shellwords.join(['/swarm'] + args), on_event: ->(type, text) { @events << [type, text] }, usage_observer: @usage.method(:record))
1621
+ options = { line: Shellwords.join(['/swarm'] + args), on_event: ->(type, text) { @events << [type, text] }, usage_observer: @usage.method(:record) }
1622
+ options[:solve_request] = args.last if args.first == 'mission'
1623
+ result = @swarm.execute(options)
1581
1624
  @workspace[:notice] = result[:error] || "#{args.first}: accepted#{" · job #{result[:job_id]}" if result[:job_id]}"
1582
1625
  add(result[:ok] == false ? :warning : :notice, @workspace[:notice])
1583
1626
  refresh_swarm if args.first == 'spawn'
1584
- @workspace.merge!(tab: :jobs, index: 0, offset: 0, detail: true) if result[:ok] && %w[ask broadcast debate].include?(args.first)
1627
+ @workspace.merge!(tab: :jobs, index: 0, offset: 0, detail: true) if result[:ok] && %w[mission dm broadcast debate].include?(args.first)
1585
1628
  end
1586
1629
 
1587
1630
  def swarm_content
1588
1631
  view = @workspace
1589
1632
  if view[:confirm]
1590
1633
  args = view[:confirm]
1634
+ return [[false, "ACTION: mission\nTEAM: protagonist builds; antagonist assesses independently and critiques; verifier runs checks; integrator accepts or requests repair.\nOnly protagonist writes. Verification uses disposable artifact copies (not an OS sandbox).\nORIGINAL REQUEST:\n#{args.last}"]] if args.first == 'mission'
1635
+
1591
1636
  target = args.first == 'broadcast' ? args[2] : args[1]
1592
1637
  return [[false, "ACTION: #{args.first}\nTARGET: #{target}\n#{args.first == 'cancel' ? 'Cancel at a safe boundary; completed work is not undone.' : args.last}"]]
1593
1638
  end
@@ -1595,7 +1640,13 @@ module PWN
1595
1640
  item = items[view[:index]]
1596
1641
  rows = []
1597
1642
  if view[:detail] && item
1598
- item.each { |key, value| rows << [false, "#{key.to_s.upcase}: #{value}"] }
1643
+ item.each do |key, value|
1644
+ if key == :team
1645
+ rows.concat(solve_rows(value))
1646
+ else
1647
+ rows << [false, "#{key.to_s.upcase}: #{value}"]
1648
+ end
1649
+ end
1599
1650
  elsif items.empty?
1600
1651
  rows << [false, view[:tab] == :roster ? 'No agents. Press n to add a local persona (no model call).' : 'No jobs yet. Tab to roster; a sends your draft after confirmation.']
1601
1652
  else
@@ -1611,13 +1662,25 @@ module PWN
1611
1662
  rows
1612
1663
  end
1613
1664
 
1665
+ def solve_rows(team)
1666
+ rows = [[false, "ORIGINAL REQUEST: #{team[:request]}"], [false, "ROUND: #{team[:round]} · REVISION: #{team[:revision]}"]]
1667
+ (team[:roles] || {}).each do |role, state|
1668
+ routing = team.dig(:routing, role) || {}
1669
+ rows << [false, "#{role.upcase}: #{state} · #{routing[:engine]} / #{routing[:model] || 'provider default'}"]
1670
+ end
1671
+ (team[:requirements] || {}).each { |id, text| rows << [false, "#{id}: #{text}"] }
1672
+ (team[:artifacts] || {}).each { |path, sha| rows << [false, "ARTIFACT: #{path} · #{sha}"] }
1673
+ Array(team[:objections]).each { |text| rows << [false, "OPEN: #{text}"] }
1674
+ rows
1675
+ end
1676
+
1614
1677
  def draw_swarm
1615
1678
  @screen.erase
1616
1679
  view = @workspace
1617
1680
  box(0, 0, @height - 1, @width - 1, "SWARM WORKSPACE · #{view[:tab].to_s.upcase}")
1618
1681
  swarm_put(1, 'Tab roster/jobs · j/k move · Enter details', :footer)
1619
- swarm_put(2, 'Space pick · a ask · b broadcast · d debate', :footer)
1620
- swarm_put(3, 'n new · r refresh · s steer · c cancel', :footer)
1682
+ swarm_put(2, 'v Mission with team · Space pick · a dm · b broadcast · d debate', :footer)
1683
+ swarm_put(3, 'n new · r refresh · s steer · p pause · u resume · c cancel', :footer)
1621
1684
  swarm_put(4, 'PgUp/Dn Home/End scroll · Esc back', :footer)
1622
1685
  rows = swarm_content.flat_map { |selected, text| wrap(swarm_safe(text), @width - 7).map { |row| [selected, row] } }
1623
1686
  page = @height - 10
@@ -9,7 +9,7 @@ module PWN
9
9
  /back /clear /cron /debug /delegate /help /input /learning /mcp /memory /menu /model /system-role
10
10
  /sessions /skills /status /steer /swarm /trace /verbose
11
11
  ].freeze
12
- SWARM = %w[dashboard help roster status create use spawn retire ask debate broadcast tail steer cancel].freeze
12
+ SWARM = %w[dashboard help mission agents status create use spawn retire dm debate broadcast tail steer pause resume cancel].freeze
13
13
 
14
14
  public_class_method def self.complete(opts = {})
15
15
  line = opts[:line].to_s
@@ -24,7 +24,8 @@ module PWN
24
24
  else
25
25
  candidates(tokens: tokens, prefix: prefix, pry: opts[:pry], swarm: opts[:swarm])
26
26
  end
27
- items = values.map do |value|
27
+ # Every depth is resolved live; sort here so no provider can bypass ordering.
28
+ items = values.sort_by { |value| [value.downcase, value] }.map do |value|
28
29
  replacement = line_replace(line: line, token: current, value: value)
29
30
  { label: value, text: replacement[:text], cursor: replacement[:cursor] }
30
31
  end
@@ -252,10 +253,10 @@ module PWN
252
253
  jobs = Array(context[:jobs]).map { |job| job[:id] || job['id'] }
253
254
  agents = Array(context[:agents])
254
255
  case action
255
- when 'status', 'steer', 'cancel' then [filter(values: jobs + (action == 'cancel' ? ['all'] : []), prefix: prefix), 'job id']
256
+ when 'status', 'steer', 'cancel', 'pause', 'resume' then [filter(values: jobs + (action == 'cancel' ? ['all'] : []), prefix: prefix), 'job id']
256
257
  when 'use' then [filter(values: existing_swarms, prefix: prefix), 'swarm id']
257
258
  when 'spawn' then [[], position == 2 ? 'persona name' : 'role text']
258
- when 'retire', 'ask' then [filter(values: agents, prefix: prefix), 'persona']
259
+ when 'retire', 'dm' then [filter(values: agents, prefix: prefix), 'persona']
259
260
  when 'debate' then [filter(values: agents, prefix: prefix), 'comma-separated personas, then topic']
260
261
  when 'tail' then [[], 'message limit']
261
262
  else [[], 'free text']
@@ -35,7 +35,7 @@ module PWN
35
35
  # Session-scoped swarm jobs. Workers never draw the terminal.
36
36
  class Controller # rubocop:disable Metrics/ClassLength -- one session-scoped swarm command surface
37
37
  MAX_JOBS = 8
38
- ACTIVE = %w[queued running steering cancelling].freeze
38
+ ACTIVE = %w[queued running steering cancelling paused].freeze
39
39
 
40
40
  def initialize(session_id:)
41
41
  @session_id = session_id.to_s
@@ -46,6 +46,10 @@ module PWN
46
46
  end
47
47
 
48
48
  def execute(opts = {})
49
+ if (match = opts[:line].to_s.match(%r{\A\s*/swarm[ \t]+mission(?:[ \t](.*))?\z}m))
50
+ return solve([], opts.merge(solve_request: opts.fetch(:solve_request, match[1].to_s)))
51
+ end
52
+
49
53
  line = opts[:line].to_s.strip
50
54
  tokens = Shellwords.split(line)
51
55
  tokens.shift if tokens.first == '/swarm'
@@ -59,7 +63,7 @@ module PWN
59
63
  @mutex.synchronize do
60
64
  {
61
65
  swarm_id: @swarm_id,
62
- jobs: @jobs.values.map { |job| job.slice(:id, :agent, :state, :command, :result, :error, :last_tool).transform_values { |value| value.is_a?(String) ? value.dup : value } }
66
+ jobs: @jobs.values.map { |job| Marshal.load(Marshal.dump(job.slice(:id, :agent, :state, :command, :result, :error, :last_tool, :team))) }
63
67
  }
64
68
  end
65
69
  end
@@ -97,18 +101,20 @@ module PWN
97
101
  def dispatch(command, tokens, opts)
98
102
  case command
99
103
  when 'help', 'dashboard' then help_text
100
- when 'roster' then { ok: true, agents: roster, swarm_id: @swarm_id }
104
+ when 'agents' then { ok: true, agents: roster, swarm_id: @swarm_id }
101
105
  when 'status' then status(tokens.first)
102
106
  when 'create' then create(tokens.join(' '))
103
107
  when 'use' then use(tokens.first)
104
108
  when 'spawn' then spawn(tokens)
105
109
  when 'retire' then retire(tokens.first)
106
- when 'ask' then ask(tokens, opts)
110
+ when 'dm' then ask(tokens, opts)
111
+ when 'mission' then solve(tokens, opts)
107
112
  when 'debate' then debate(tokens, opts)
108
113
  when 'broadcast' then broadcast(tokens, opts)
109
114
  when 'tail' then tail(tokens.first)
110
115
  when 'steer' then steer(tokens, opts)
111
116
  when 'cancel' then cancel(tokens.first)
117
+ when 'pause', 'resume' then pause(tokens.first, command == 'pause')
112
118
  else { ok: false, error: "unknown /swarm command: #{command}", hint: help_text[:usage] }
113
119
  end
114
120
  end
@@ -116,7 +122,7 @@ module PWN
116
122
  def help_text
117
123
  {
118
124
  ok: true,
119
- usage: '/swarm roster|status [JOB]|create [topic]|use ID|spawn NAME ROLE [--engine E --model M --toolsets a,b]|retire NAME|ask NAME REQUEST|debate NAMES TOPIC [--rounds N]|broadcast REQUEST [--names a,b]|tail [N]|steer JOB INSTRUCTION|cancel JOB|all'
125
+ usage: '/swarm mission REQUEST|agents|status [JOB]|create [topic]|use ID|spawn NAME ROLE [--engine E --model M --toolsets a,b]|retire NAME|dm NAME REQUEST|debate NAMES TOPIC [--rounds N]|broadcast REQUEST [--names a,b]|tail [N]|steer JOB INSTRUCTION|pause JOB|resume JOB|cancel JOB|all'
120
126
  }
121
127
  end
122
128
 
@@ -161,13 +167,26 @@ module PWN
161
167
  def ask(tokens, opts)
162
168
  name = tokens.shift
163
169
  request = tokens.join(' ')
164
- return { ok: false, error: 'usage: ask NAME REQUEST' } if name.to_s.empty? || request.empty?
170
+ return { ok: false, error: 'usage: dm NAME REQUEST' } if name.to_s.empty? || request.empty?
165
171
 
166
- start_job(agent: name, command: 'ask', opts: opts) do |job|
172
+ start_job(agent: name, command: 'dm', opts: opts) do |job|
167
173
  PWN::AI::Agent::Swarm.ask(name: name, request: request, swarm_id: ensure_swarm, steering: job[:control], usage_observer: opts[:usage_observer], on_tool: tool_callback(job, opts))
168
174
  end
169
175
  end
170
176
 
177
+ def solve(tokens, opts)
178
+ request = opts.fetch(:solve_request, tokens.join(' '))
179
+ return { ok: false, error: 'usage: mission REQUEST' } if request.strip.empty?
180
+
181
+ start_job(agent: 'team', command: 'mission', opts: opts) do |job|
182
+ PWN::AI::Agent::Swarm.solve(
183
+ request: request, swarm_id: ensure_swarm, steering: job[:control],
184
+ workspace: opts[:workspace], usage_observer: opts[:usage_observer], on_tool: tool_callback(job, opts),
185
+ on_state: ->(state) { @mutex.synchronize { job[:team] = state } }
186
+ )
187
+ end
188
+ end
189
+
171
190
  def debate(tokens, opts)
172
191
  flags, positional = split_flags(tokens)
173
192
  names = positional.shift.to_s.split(',')
@@ -219,6 +238,16 @@ module PWN
219
238
  { ok: true, job_id: job[:id] }
220
239
  end
221
240
 
241
+ def pause(id, paused)
242
+ job = find_job(id)
243
+ return { ok: false, error: 'job not found' } unless job
244
+ return { ok: false, error: 'pause/resume applies to mission jobs only' } unless job[:command] == 'mission'
245
+ return { ok: false, error: 'job is no longer active' } unless update_active(job, paused ? 'paused' : 'running')
246
+
247
+ job[:control].pause(paused)
248
+ { ok: true, job_id: id, state: job[:state] }
249
+ end
250
+
222
251
  def cancel(id)
223
252
  return cancel_all if id.nil? || id == 'all'
224
253
 
@@ -245,6 +274,8 @@ module PWN
245
274
 
246
275
  def start_job(agent:, command:, opts:)
247
276
  return { ok: false, error: 'swarm controller is closed' } if @closed
277
+ return { ok: false, error: 'mission requires exclusive workspace ownership; wait or cancel active jobs' } if (command == 'mission' && busy?) || snapshot[:jobs].any? { |row| row[:command] == 'mission' && ACTIVE.include?(row[:state]) }
278
+
248
279
  return { ok: false, error: "job limit #{MAX_JOBS} reached" } if busy_count >= MAX_JOBS
249
280
 
250
281
  ensure_swarm
@@ -257,16 +288,18 @@ module PWN
257
288
  job[:worker] = Thread.new do
258
289
  locals.each { |key, value| Thread.current[key] = value }
259
290
  variables.each { |key, value| Thread.current.thread_variable_set(key, value) }
260
- job[:control] = JobControl.new(input: StringIO.new, output: StringIO.new)
291
+ control_class = command == 'mission' ? PWN::AI::Agent::Solve::Control : JobControl
292
+ job[:control] = control_class.new(input: StringIO.new, output: StringIO.new)
261
293
  Thread.current[:pwn_steering_input] = job[:control]
262
294
  ready << true
263
295
  update(job, 'running')
264
296
  result = yield(job)
265
297
  job[:control].check_stop
266
- update(job, 'completed', result: summarize(result))
267
- opts[:on_event]&.call(:assistant, "#{agent} [#{id}] #{command} complete\n#{summarize(result)}")
298
+ state = command == 'mission' ? result[:status] : 'completed'
299
+ update(job, state, result: summarize(result))
300
+ opts[:on_event]&.call(:assistant, "#{agent} [#{id}] #{command} #{state}\n#{summarize(result)}")
268
301
  result
269
- rescue JobControl::Stopped => e
302
+ rescue JobControl::Stopped, PWN::AI::Agent::Solve::Control::Stopped => e
270
303
  update(job, 'cancelled', error: e.message)
271
304
  opts[:on_event]&.call(:warning, "#{agent} [#{id}] #{e.message}")
272
305
  rescue StandardError => e
data/lib/pwn/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PWN
4
- VERSION = '0.5.760'
4
+ VERSION = '0.5.762'
5
5
  end
@@ -1081,7 +1081,8 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
1081
1081
  )
1082
1082
  ).to eq false
1083
1083
  src = File.read(described_class.method(:run).source_location.first)
1084
- expect(src).to match(/skip_plan.*catalog/)
1084
+ expect(src).to match(/skip_compass.*catalog/)
1085
+ expect(src).to match(/skip_plan.*skip_compass/)
1085
1086
  expect(src).to match(/force_plan && cal_state\[:cal\] && !skip_plan/)
1086
1087
  end
1087
1088