rubyn-code 0.5.1 → 0.7.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.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +120 -3
  3. data/db/migrations/014_multi_agent_upgrade.rb +79 -0
  4. data/lib/rubyn_code/agent/conversation.rb +89 -3
  5. data/lib/rubyn_code/agent/llm_caller.rb +2 -2
  6. data/lib/rubyn_code/agent/loop.rb +49 -9
  7. data/lib/rubyn_code/agent/system_prompt_builder.rb +37 -2
  8. data/lib/rubyn_code/agent/tool_processor.rb +3 -1
  9. data/lib/rubyn_code/auth/oauth.rb +1 -1
  10. data/lib/rubyn_code/auth/token_store.rb +49 -4
  11. data/lib/rubyn_code/checkpoint/hook.rb +26 -0
  12. data/lib/rubyn_code/checkpoint/manager.rb +109 -0
  13. data/lib/rubyn_code/chisel/debt.rb +65 -0
  14. data/lib/rubyn_code/chisel/inspection.rb +93 -0
  15. data/lib/rubyn_code/chisel.rb +127 -0
  16. data/lib/rubyn_code/cli/commands/agents.rb +31 -0
  17. data/lib/rubyn_code/cli/commands/chisel.rb +52 -0
  18. data/lib/rubyn_code/cli/commands/chisel_audit.rb +19 -0
  19. data/lib/rubyn_code/cli/commands/chisel_debt.rb +28 -0
  20. data/lib/rubyn_code/cli/commands/chisel_gain.rb +30 -0
  21. data/lib/rubyn_code/cli/commands/chisel_review.rb +19 -0
  22. data/lib/rubyn_code/cli/commands/command_template.rb +50 -0
  23. data/lib/rubyn_code/cli/commands/context.rb +3 -1
  24. data/lib/rubyn_code/cli/commands/custom_command.rb +42 -0
  25. data/lib/rubyn_code/cli/commands/custom_loader.rb +69 -0
  26. data/lib/rubyn_code/cli/commands/goal.rb +87 -0
  27. data/lib/rubyn_code/cli/commands/learning.rb +62 -0
  28. data/lib/rubyn_code/cli/commands/loop.rb +58 -0
  29. data/lib/rubyn_code/cli/commands/mcp.rb +18 -5
  30. data/lib/rubyn_code/cli/commands/megaplan.rb +1 -1
  31. data/lib/rubyn_code/cli/commands/registry.rb +14 -9
  32. data/lib/rubyn_code/cli/commands/rewind.rb +65 -0
  33. data/lib/rubyn_code/cli/first_run.rb +1 -1
  34. data/lib/rubyn_code/cli/loop_runner.rb +98 -0
  35. data/lib/rubyn_code/cli/mention_expander.rb +92 -0
  36. data/lib/rubyn_code/cli/renderer.rb +3 -2
  37. data/lib/rubyn_code/cli/repl.rb +37 -14
  38. data/lib/rubyn_code/cli/repl_commands.rb +76 -2
  39. data/lib/rubyn_code/cli/repl_setup.rb +9 -1
  40. data/lib/rubyn_code/cli/stream_formatter.rb +3 -2
  41. data/lib/rubyn_code/cli/version_check.rb +10 -3
  42. data/lib/rubyn_code/config/defaults.rb +13 -1
  43. data/lib/rubyn_code/config/schema.json +4 -0
  44. data/lib/rubyn_code/config/settings.rb +17 -2
  45. data/lib/rubyn_code/context/manager.rb +29 -12
  46. data/lib/rubyn_code/debug.rb +11 -5
  47. data/lib/rubyn_code/goal/evaluator.rb +95 -0
  48. data/lib/rubyn_code/hooks/event_map.rb +56 -0
  49. data/lib/rubyn_code/hooks/external_dispatcher.rb +199 -0
  50. data/lib/rubyn_code/hooks/goal_hook.rb +88 -0
  51. data/lib/rubyn_code/hooks/response.rb +83 -0
  52. data/lib/rubyn_code/hooks/runner.rb +61 -3
  53. data/lib/rubyn_code/hooks/settings_json_loader.rb +109 -0
  54. data/lib/rubyn_code/hooks/subprocess_executor.rb +116 -0
  55. data/lib/rubyn_code/ide/handlers/plan_interview_answer_handler.rb +13 -13
  56. data/lib/rubyn_code/ide/handlers/plan_interview_cancel_handler.rb +1 -1
  57. data/lib/rubyn_code/ide/handlers/plan_interview_start_handler.rb +10 -10
  58. data/lib/rubyn_code/ide/handlers/plan_propose_handler.rb +1 -1
  59. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +9 -1
  60. data/lib/rubyn_code/ide/handlers/recover_ci_handler.rb +27 -16
  61. data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +1 -1
  62. data/lib/rubyn_code/index/codebase_index.rb +39 -1
  63. data/lib/rubyn_code/learning/porter.rb +129 -0
  64. data/lib/rubyn_code/llm/adapters/anthropic.rb +65 -16
  65. data/lib/rubyn_code/llm/adapters/openai.rb +1 -1
  66. data/lib/rubyn_code/llm/adapters/prompt_caching.rb +5 -1
  67. data/lib/rubyn_code/llm/adapters/token_caching.rb +54 -0
  68. data/lib/rubyn_code/llm/model_router.rb +2 -2
  69. data/lib/rubyn_code/mcp/client.rb +59 -0
  70. data/lib/rubyn_code/mcp/server_extras_bridge.rb +110 -0
  71. data/lib/rubyn_code/mcp/sse_transport.rb +2 -1
  72. data/lib/rubyn_code/mcp/tool_bridge.rb +16 -14
  73. data/lib/rubyn_code/megaplan/ci_recovery.rb +3 -3
  74. data/lib/rubyn_code/megaplan/interview_session.rb +8 -3
  75. data/lib/rubyn_code/megaplan/plan_proposer.rb +3 -3
  76. data/lib/rubyn_code/memory/search.rb +9 -5
  77. data/lib/rubyn_code/memory/session_persistence.rb +159 -21
  78. data/lib/rubyn_code/observability/cost_calculator.rb +3 -1
  79. data/lib/rubyn_code/output/diff_renderer.rb +62 -7
  80. data/lib/rubyn_code/skills/auto_suggest.rb +70 -2
  81. data/lib/rubyn_code/skills/registry_client.rb +4 -3
  82. data/lib/rubyn_code/sub_agents/agent_type.rb +17 -0
  83. data/lib/rubyn_code/sub_agents/catalog.rb +124 -0
  84. data/lib/rubyn_code/teams/agent_registry.rb +120 -0
  85. data/lib/rubyn_code/teams/mailbox.rb +99 -10
  86. data/lib/rubyn_code/teams/manager.rb +83 -5
  87. data/lib/rubyn_code/teams/teammate.rb +5 -1
  88. data/lib/rubyn_code/tools/ask_user.rb +15 -1
  89. data/lib/rubyn_code/tools/executor.rb +5 -3
  90. data/lib/rubyn_code/tools/spawn_agent.rb +47 -62
  91. data/lib/rubyn_code/tools/spawn_teammate.rb +7 -2
  92. data/lib/rubyn_code/tools/web_fetch.rb +1 -1
  93. data/lib/rubyn_code/tools/web_search.rb +4 -1
  94. data/lib/rubyn_code/version.rb +1 -1
  95. data/lib/rubyn_code.rb +45 -2
  96. data/skills/rubyn_self_test.md +322 -14
  97. data/skills/self_test/chisel_smoke.rb +84 -0
  98. data/skills/self_test/fixtures/chisel_sample.rb +64 -0
  99. metadata +37 -1
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module Teams
5
+ # Discovery service for active agents in the system.
6
+ #
7
+ # Provides a unified view of all agents (main loop, sub-agents, teammates)
8
+ # with status, lineage, and messaging capabilities.
9
+ class AgentRegistry
10
+ # @param manager [Manager] the teammate manager
11
+ # @param mailbox [Mailbox] the team mailbox
12
+ def initialize(manager:, mailbox:)
13
+ @manager = manager
14
+ @mailbox = mailbox
15
+ end
16
+
17
+ # Returns a snapshot of all registered agents with their status
18
+ # and unread message counts.
19
+ #
20
+ # @return [Array<Hash>] agent snapshots
21
+ def snapshot
22
+ @manager.list.map { |t| agent_snapshot(t) }
23
+ end
24
+
25
+ # Returns only active agents.
26
+ #
27
+ # @return [Array<Hash>] active agent snapshots
28
+ def active
29
+ @manager.active_teammates.map { |t| agent_snapshot(t) }
30
+ end
31
+
32
+ # Returns the full agent tree starting from all root agents.
33
+ #
34
+ # @return [Array<Hash>] nested tree structures
35
+ def forest
36
+ @manager.roots.map { |root| build_display_tree(root) }
37
+ end
38
+
39
+ # Returns lineage (ancestors) for a given agent.
40
+ #
41
+ # @param agent_id [String] the agent's ID
42
+ # @return [Array<Teammate>] ordered from root to immediate parent
43
+ def lineage(agent_id)
44
+ ancestors = []
45
+ current = @manager.find_by_id(agent_id)
46
+ return ancestors unless current
47
+
48
+ while current&.parent_agent_id
49
+ parent = @manager.find_by_id(current.parent_agent_id)
50
+ break unless parent
51
+
52
+ ancestors.unshift(parent)
53
+ current = parent
54
+ end
55
+
56
+ ancestors
57
+ end
58
+
59
+ # Returns a formatted status report of all agents.
60
+ #
61
+ # @return [String] human-readable status report
62
+ def status_report
63
+ agents = snapshot
64
+ return 'No agents registered.' if agents.empty?
65
+
66
+ lines = ['Agent Registry Status:', '']
67
+ agents.each do |agent|
68
+ icon = status_icon(agent[:status])
69
+ parent_info = agent[:parent_agent_id] ? " (child of #{agent[:parent_agent_id][0, 8]})" : ' (root)'
70
+ lines << " #{icon} #{agent[:name]} [#{agent[:role]}] — #{agent[:status]}#{parent_info}"
71
+ lines << " Unread: #{agent[:unread_count]}" if agent[:unread_count].positive?
72
+ end
73
+ lines.join("\n")
74
+ end
75
+
76
+ private
77
+
78
+ # Builds a snapshot hash for a single agent.
79
+ #
80
+ # @param teammate [Teammate]
81
+ # @return [Hash]
82
+ def agent_snapshot(teammate)
83
+ {
84
+ id: teammate.id,
85
+ name: teammate.name,
86
+ role: teammate.role,
87
+ status: teammate.status,
88
+ parent_agent_id: teammate.parent_agent_id,
89
+ unread_count: @mailbox.unread_count(teammate.name),
90
+ created_at: teammate.created_at
91
+ }
92
+ end
93
+
94
+ # Recursively builds a display tree with snapshot data.
95
+ #
96
+ # @param agent [Teammate]
97
+ # @return [Hash]
98
+ def build_display_tree(agent)
99
+ children = @manager.children_of(agent.id)
100
+ {
101
+ **agent_snapshot(agent),
102
+ children: children.map { |child| build_display_tree(child) }
103
+ }
104
+ end
105
+
106
+ # Returns a status icon for display.
107
+ #
108
+ # @param status [String]
109
+ # @return [String]
110
+ def status_icon(status)
111
+ case status
112
+ when 'active' then '🟢'
113
+ when 'idle' then '🟡'
114
+ when 'offline' then '⚫'
115
+ else '❓'
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -9,6 +9,8 @@ module RubynCode
9
9
  #
10
10
  # Messages are stored in the `mailbox_messages` table with structured
11
11
  # JSON content. Each message tracks read/unread state per recipient.
12
+ # Supports structured data payloads and correlation IDs for
13
+ # request/response tracking.
12
14
  class Mailbox
13
15
  # @param db [DB::Connection] the database connection
14
16
  def initialize(db)
@@ -22,8 +24,11 @@ module RubynCode
22
24
  # @param to [String] recipient agent name
23
25
  # @param content [String] message body
24
26
  # @param message_type [String] type of message (default: "message")
27
+ # @param correlation_id [String, nil] optional correlation ID for request/response pairing
28
+ # @param data [Hash, nil] optional structured data payload
25
29
  # @return [String] the message id
26
- def send(from:, to:, content:, message_type: 'message')
30
+ # rubocop:disable Metrics/ParameterLists
31
+ def send(from:, to:, content:, message_type: 'message', correlation_id: nil, data: nil)
27
32
  id = SecureRandom.uuid
28
33
  now = Time.now.utc.iso8601
29
34
 
@@ -36,25 +41,54 @@ module RubynCode
36
41
  timestamp: now
37
42
  })
38
43
 
44
+ data_json = data ? JSON.generate(data) : nil
45
+
39
46
  @db.execute(
40
47
  <<~SQL,
41
- INSERT INTO mailbox_messages (id, sender, recipient, message_type, payload, read, created_at)
42
- VALUES (?, ?, ?, ?, ?, 0, ?)
48
+ INSERT INTO mailbox_messages (id, sender, recipient, message_type, payload, correlation_id, data, read, created_at)
49
+ VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?)
43
50
  SQL
44
- [id, from, to, message_type, payload, now]
51
+ [id, from, to, message_type, payload, correlation_id, data_json, now]
45
52
  )
46
53
 
47
54
  id
48
55
  end
56
+ # rubocop:enable Metrics/ParameterLists
57
+
58
+ # Sends a structured message with typed data payload.
59
+ # Convenience wrapper around #send for machine-to-machine communication.
60
+ #
61
+ # @param from [String] sender agent name
62
+ # @param to [String] recipient agent name
63
+ # @param type [String] message type (e.g. 'task', 'result', 'error')
64
+ # @param data [Hash] structured data payload
65
+ # @param content [String] human-readable summary (default: auto-generated)
66
+ # @param correlation_id [String, nil] optional correlation ID
67
+ # @return [String] the message id
68
+ # rubocop:disable Metrics/ParameterLists
69
+ def send_structured(from:, to:, type:, data:, content: nil, correlation_id: nil)
70
+ content ||= "#{type}: #{data.inspect}"[0, 200]
71
+ correlation_id ||= SecureRandom.uuid
72
+
73
+ send(
74
+ from: from,
75
+ to: to,
76
+ content: content,
77
+ message_type: type,
78
+ correlation_id: correlation_id,
79
+ data: data
80
+ )
81
+ end
82
+ # rubocop:enable Metrics/ParameterLists
49
83
 
50
84
  # Reads all unread messages for the given agent and marks them as read.
51
85
  #
52
86
  # @param name [String] the recipient agent name
53
- # @return [Array<Hash>] parsed message hashes
87
+ # @return [Array<Hash>] parsed message hashes with optional :data key
54
88
  def read_inbox(name)
55
89
  rows = @db.query(
56
90
  <<~SQL,
57
- SELECT id, payload FROM mailbox_messages
91
+ SELECT id, payload, correlation_id, data FROM mailbox_messages
58
92
  WHERE recipient = ? AND read = 0
59
93
  ORDER BY created_at ASC
60
94
  SQL
@@ -64,7 +98,7 @@ module RubynCode
64
98
  return [] if rows.empty?
65
99
 
66
100
  ids = rows.map { |r| r['id'] }
67
- messages = rows.map { |r| JSON.parse(r['payload'], symbolize_names: true) }
101
+ messages = rows.map { |r| parse_message_row(r) }
68
102
 
69
103
  # Mark all fetched messages as read in a single statement
70
104
  placeholders = ids.map { '?' }.join(', ')
@@ -76,6 +110,24 @@ module RubynCode
76
110
  messages
77
111
  end
78
112
 
113
+ # Finds all messages matching a correlation ID.
114
+ # Useful for tracking request/response chains.
115
+ #
116
+ # @param correlation_id [String] the correlation ID to search for
117
+ # @return [Array<Hash>] matched messages ordered by creation time
118
+ def find_by_correlation_id(correlation_id)
119
+ rows = @db.query(
120
+ <<~SQL,
121
+ SELECT id, payload, correlation_id, data FROM mailbox_messages
122
+ WHERE correlation_id = ?
123
+ ORDER BY created_at ASC
124
+ SQL
125
+ [correlation_id]
126
+ ).to_a
127
+
128
+ rows.map { |r| parse_message_row(r) }
129
+ end
130
+
79
131
  # Broadcasts a message from one agent to all other agents.
80
132
  #
81
133
  # @param from [String] sender agent name
@@ -98,14 +150,14 @@ module RubynCode
98
150
  def pending_for(name)
99
151
  rows = @db.query(
100
152
  <<~SQL,
101
- SELECT payload FROM mailbox_messages
153
+ SELECT id, payload, correlation_id, data FROM mailbox_messages
102
154
  WHERE recipient = ? AND read = 0
103
155
  ORDER BY created_at ASC
104
156
  SQL
105
157
  [name]
106
158
  ).to_a
107
159
 
108
- rows.map { |r| JSON.parse(r['payload'], symbolize_names: true) }
160
+ rows.map { |r| parse_message_row(r) }
109
161
  end
110
162
 
111
163
  # Returns the count of unread messages for the given agent.
@@ -122,8 +174,22 @@ module RubynCode
122
174
 
123
175
  private
124
176
 
177
+ # Parses a message row into a hash, merging structured data if present.
178
+ #
179
+ # @param row [Hash] database row
180
+ # @return [Hash] parsed message with optional :data and :correlation_id keys
181
+ def parse_message_row(row)
182
+ msg = JSON.parse(row['payload'], symbolize_names: true)
183
+ msg[:correlation_id] = row['correlation_id'] if row['correlation_id']
184
+ msg[:data] = JSON.parse(row['data'], symbolize_names: true) if row['data']
185
+ msg
186
+ rescue JSON::ParserError
187
+ { content: row['payload'].to_s, parse_error: true }
188
+ end
189
+
125
190
  # Creates the mailbox_messages table if it does not already exist.
126
- # Schema must stay in sync with db/migrations/009_create_teams.sql.
191
+ # Schema must stay in sync with db/migrations/009_create_teams.sql
192
+ # and 014_multi_agent_upgrade.rb.
127
193
  def ensure_table!
128
194
  @db.execute(<<~SQL)
129
195
  CREATE TABLE IF NOT EXISTS mailbox_messages (
@@ -133,15 +199,38 @@ module RubynCode
133
199
  message_type TEXT NOT NULL DEFAULT 'message'
134
200
  CHECK(message_type IN ('message','task','result','error','broadcast','shutdown_request','shutdown_response','status_change')),
135
201
  payload TEXT NOT NULL,
202
+ correlation_id TEXT,
203
+ data TEXT,
136
204
  read INTEGER NOT NULL DEFAULT 0,
137
205
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
138
206
  )
139
207
  SQL
140
208
 
209
+ # Self-heal: add the columns the original migration lacked so
210
+ # that test databases bootstrapped before the multi-agent
211
+ # upgrade don't end up with a half-built table. Each ALTER
212
+ # is a no-op if the column already exists (SQLite returns an
213
+ # error which we swallow).
214
+ add_mailbox_column_if_missing('correlation_id')
215
+ add_mailbox_column_if_missing('data')
216
+
141
217
  @db.execute(<<~SQL)
142
218
  CREATE INDEX IF NOT EXISTS idx_mailbox_recipient_read
143
219
  ON mailbox_messages (recipient, read)
144
220
  SQL
221
+
222
+ @db.execute(<<~SQL)
223
+ CREATE INDEX IF NOT EXISTS idx_mailbox_correlation
224
+ ON mailbox_messages (correlation_id)
225
+ SQL
226
+ end
227
+
228
+ # @return [Boolean] true if the column was added
229
+ def add_mailbox_column_if_missing(column)
230
+ @db.execute("ALTER TABLE mailbox_messages ADD COLUMN #{column} TEXT")
231
+ true
232
+ rescue SQLite3::SQLException
233
+ false
145
234
  end
146
235
  end
147
236
  end
@@ -9,7 +9,7 @@ module RubynCode
9
9
  # CRUD manager for teammates backed by SQLite.
10
10
  #
11
11
  # Provides lifecycle management for agent teammates: spawning,
12
- # listing, status updates, and removal.
12
+ # listing, status updates, removal, and parent-child discovery.
13
13
  class Manager
14
14
  # @param db [DB::Connection] the database connection
15
15
  # @param mailbox [Mailbox] the team mailbox for inter-agent messaging
@@ -25,9 +25,10 @@ module RubynCode
25
25
  # @param role [String] the teammate's role description
26
26
  # @param persona [String, nil] optional persona prompt
27
27
  # @param model [String, nil] optional LLM model override
28
+ # @param parent_agent_id [String, nil] ID of the parent agent that spawned this teammate
28
29
  # @return [Teammate] the newly created teammate
29
30
  # @raise [Error] if a teammate with the given name already exists
30
- def spawn(name:, role:, persona: nil, model: nil)
31
+ def spawn(name:, role:, persona: nil, model: nil, parent_agent_id: nil)
31
32
  existing = get(name)
32
33
  raise Error, "Teammate '#{name}' already exists" if existing
33
34
 
@@ -37,10 +38,10 @@ module RubynCode
37
38
 
38
39
  @db.execute(
39
40
  <<~SQL,
40
- INSERT INTO teammates (id, name, role, persona, model, status, metadata, created_at)
41
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
41
+ INSERT INTO teammates (id, name, role, persona, model, status, parent_agent_id, metadata, created_at)
42
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
42
43
  SQL
43
- [id, name, role, persona, model, 'idle', metadata_json, now]
44
+ [id, name, role, persona, model, 'idle', parent_agent_id, metadata_json, now]
44
45
  )
45
46
 
46
47
  Teammate.new(
@@ -50,6 +51,7 @@ module RubynCode
50
51
  persona: persona,
51
52
  model: model,
52
53
  status: 'idle',
54
+ parent_agent_id: parent_agent_id,
53
55
  metadata: {},
54
56
  created_at: now
55
57
  )
@@ -74,6 +76,51 @@ module RubynCode
74
76
  row_to_teammate(rows.first)
75
77
  end
76
78
 
79
+ # Finds a teammate by ID.
80
+ #
81
+ # @param id [String]
82
+ # @return [Teammate, nil]
83
+ def find_by_id(id)
84
+ rows = @db.query('SELECT * FROM teammates WHERE id = ? LIMIT 1', [id]).to_a
85
+ return nil if rows.empty?
86
+
87
+ row_to_teammate(rows.first)
88
+ end
89
+
90
+ # Returns all direct children of the given parent agent.
91
+ #
92
+ # @param parent_id [String] the parent agent's ID
93
+ # @return [Array<Teammate>]
94
+ def children_of(parent_id)
95
+ rows = @db.query(
96
+ 'SELECT * FROM teammates WHERE parent_agent_id = ? ORDER BY created_at ASC',
97
+ [parent_id]
98
+ ).to_a
99
+ rows.map { |row| row_to_teammate(row) }
100
+ end
101
+
102
+ # Returns all root agents (those with no parent).
103
+ #
104
+ # @return [Array<Teammate>]
105
+ def roots
106
+ rows = @db.query(
107
+ 'SELECT * FROM teammates WHERE parent_agent_id IS NULL ORDER BY created_at ASC'
108
+ ).to_a
109
+ rows.map { |row| row_to_teammate(row) }
110
+ end
111
+
112
+ # Builds a full agent tree from a root agent ID.
113
+ # Returns a nested hash: { agent: Teammate, children: [{ agent:, children: }, ...] }
114
+ #
115
+ # @param root_id [String] the root agent's ID
116
+ # @return [Hash, nil] nested tree structure or nil if root not found
117
+ def agent_tree(root_id)
118
+ root = find_by_id(root_id)
119
+ return nil unless root
120
+
121
+ build_tree_node(root)
122
+ end
123
+
77
124
  # Updates a teammate's status.
78
125
  #
79
126
  # @param name [String]
@@ -120,6 +167,18 @@ module RubynCode
120
167
 
121
168
  private
122
169
 
170
+ # Recursively builds a tree node for an agent and its children.
171
+ #
172
+ # @param agent [Teammate]
173
+ # @return [Hash]
174
+ def build_tree_node(agent)
175
+ kids = children_of(agent.id)
176
+ {
177
+ agent: agent,
178
+ children: kids.map { |child| build_tree_node(child) }
179
+ }
180
+ end
181
+
123
182
  # Converts a database row hash to a Teammate value object.
124
183
  #
125
184
  # @param row [Hash]
@@ -134,6 +193,7 @@ module RubynCode
134
193
  persona: row['persona'],
135
194
  model: row['model'],
136
195
  status: row['status'],
196
+ parent_agent_id: row['parent_agent_id'],
137
197
  metadata: metadata,
138
198
  created_at: row['created_at']
139
199
  )
@@ -161,14 +221,32 @@ module RubynCode
161
221
  persona TEXT,
162
222
  model TEXT,
163
223
  status TEXT NOT NULL DEFAULT 'idle',
224
+ parent_agent_id TEXT,
164
225
  metadata TEXT NOT NULL DEFAULT '{}',
165
226
  created_at TEXT NOT NULL
166
227
  )
167
228
  SQL
168
229
 
230
+ # Self-heal: add columns that later migrations added to legacy
231
+ # tables. Same pattern as Mailbox#ensure_table!.
232
+ add_teammate_column_if_missing('parent_agent_id')
233
+ add_teammate_column_if_missing('metadata')
234
+
169
235
  @db.execute(<<~SQL)
170
236
  CREATE UNIQUE INDEX IF NOT EXISTS idx_teammates_name ON teammates (name)
171
237
  SQL
238
+
239
+ @db.execute(<<~SQL)
240
+ CREATE INDEX IF NOT EXISTS idx_teammates_parent ON teammates (parent_agent_id)
241
+ SQL
242
+ end
243
+
244
+ # @return [Boolean] true if the column was added
245
+ def add_teammate_column_if_missing(column)
246
+ @db.execute("ALTER TABLE teammates ADD COLUMN #{column} TEXT")
247
+ true
248
+ rescue SQLite3::SQLException
249
+ false
172
250
  end
173
251
  end
174
252
  end
@@ -8,7 +8,7 @@ module RubynCode
8
8
  VALID_STATUSES = %w[idle active offline].freeze
9
9
 
10
10
  Teammate = Data.define(
11
- :id, :name, :role, :persona, :model, :status, :metadata, :created_at
11
+ :id, :name, :role, :persona, :model, :status, :parent_agent_id, :metadata, :created_at
12
12
  ) do
13
13
  # @return [Boolean]
14
14
  def idle? = status == 'idle'
@@ -19,6 +19,9 @@ module RubynCode
19
19
  # @return [Boolean]
20
20
  def offline? = status == 'offline'
21
21
 
22
+ # @return [Boolean] true if this teammate was not spawned by another agent
23
+ def root? = parent_agent_id.nil?
24
+
22
25
  # @return [Hash]
23
26
  def to_h
24
27
  {
@@ -28,6 +31,7 @@ module RubynCode
28
31
  persona: persona,
29
32
  model: model,
30
33
  status: status,
34
+ parent_agent_id: parent_agent_id,
31
35
  metadata: metadata,
32
36
  created_at: created_at
33
37
  }
@@ -20,10 +20,24 @@ module RubynCode
20
20
  }.freeze
21
21
  RISK_LEVEL = :read # Never needs approval — it IS the approval mechanism
22
22
 
23
+ # The user may take a while to answer, so allow far longer than the
24
+ # default RPC timeout before giving up on the IDE round-trip.
25
+ IDE_ASK_TIMEOUT = 300 # seconds
26
+
23
27
  attr_writer :prompt_callback
24
28
 
29
+ def initialize(project_root:, ide_client: nil)
30
+ super(project_root: project_root)
31
+ @ide_client = ide_client
32
+ end
33
+
25
34
  def execute(question:)
26
- if @prompt_callback
35
+ if @ide_client
36
+ # IDE mode: round-trip the question through the extension's UI.
37
+ response = @ide_client.request('ide/askUser', { question: question }, timeout: IDE_ASK_TIMEOUT)
38
+ answer = response && response['answer']
39
+ answer.nil? || answer.empty? ? '[no response]' : answer
40
+ elsif @prompt_callback
27
41
  @prompt_callback.call(question)
28
42
  elsif $stdin.respond_to?(:tty?) && $stdin.tty?
29
43
  # Interactive fallback: prompt on stdin
@@ -56,8 +56,10 @@ module RubynCode
56
56
 
57
57
  def build_tool(tool_name)
58
58
  tool_class = Registry.get(tool_name)
59
- # IDE-aware tools accept an ide_client parameter.
60
- if @ide_client && tool_class.method(:new).parameters.any? { |_, name| name == :ide_client }
59
+ # IDE-aware tools accept an ide_client parameter. Inspect #initialize,
60
+ # not .method(:new) the latter reports Class#new's own [[:rest]]
61
+ # signature and never forwards to the constructor.
62
+ if @ide_client && tool_class.instance_method(:initialize).parameters.any? { |_, name| name == :ide_client }
61
63
  tool = tool_class.new(project_root: project_root, ide_client: @ide_client)
62
64
  else
63
65
  tool = tool_class.new(project_root: project_root)
@@ -140,7 +142,7 @@ module RubynCode
140
142
  path = resolve_cache_path(params)
141
143
  return unless path&.end_with?('.rb')
142
144
 
143
- @codebase_index.update!
145
+ @codebase_index.update_file!(path)
144
146
  rescue StandardError => e
145
147
  RubynCode::Debug.warn("CodebaseIndex incremental update failed: #{e.message}")
146
148
  end