kward 0.71.0 → 0.72.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 (116) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -1
  3. data/Gemfile.lock +2 -2
  4. data/README.md +4 -0
  5. data/doc/agent-tools.md +15 -6
  6. data/doc/authentication.md +22 -1
  7. data/doc/code-search.md +42 -2
  8. data/doc/configuration.md +106 -3
  9. data/doc/context-budgeting.md +136 -0
  10. data/doc/context-tools.md +16 -3
  11. data/doc/editor.md +394 -0
  12. data/doc/extensibility.md +16 -7
  13. data/doc/files.md +100 -0
  14. data/doc/getting-started.md +25 -18
  15. data/doc/git.md +122 -0
  16. data/doc/memory.md +24 -4
  17. data/doc/personas.md +34 -5
  18. data/doc/plugins.md +72 -1
  19. data/doc/releasing.md +37 -9
  20. data/doc/rpc.md +74 -4
  21. data/doc/session-management.md +35 -1
  22. data/doc/shell.md +286 -0
  23. data/doc/tabs.md +122 -0
  24. data/doc/troubleshooting.md +77 -1
  25. data/doc/usage.md +53 -7
  26. data/doc/web-search.md +12 -4
  27. data/doc/workspace-tools.md +51 -12
  28. data/examples/plugins/space_invaders.rb +377 -0
  29. data/lib/kward/agent.rb +1 -1
  30. data/lib/kward/cli/commands.rb +33 -2
  31. data/lib/kward/cli/git.rb +150 -0
  32. data/lib/kward/cli/interactive_turn.rb +73 -9
  33. data/lib/kward/cli/plugins.rb +54 -4
  34. data/lib/kward/cli/prompt_interface.rb +32 -1
  35. data/lib/kward/cli/runtime_helpers.rb +133 -3
  36. data/lib/kward/cli/sessions.rb +2 -2
  37. data/lib/kward/cli/settings.rb +218 -9
  38. data/lib/kward/cli/slash_commands.rb +415 -2
  39. data/lib/kward/cli/tabs.rb +695 -0
  40. data/lib/kward/cli.rb +158 -26
  41. data/lib/kward/config_files.rb +123 -1
  42. data/lib/kward/context_budget_meter.rb +44 -0
  43. data/lib/kward/conversation.rb +12 -4
  44. data/lib/kward/editor_mode.rb +25 -0
  45. data/lib/kward/ekwsh.rb +362 -0
  46. data/lib/kward/plugin_registry.rb +61 -0
  47. data/lib/kward/project_files.rb +52 -0
  48. data/lib/kward/prompt_history.rb +82 -0
  49. data/lib/kward/prompt_interface/composer_controller.rb +69 -1
  50. data/lib/kward/prompt_interface/composer_renderer.rb +109 -13
  51. data/lib/kward/prompt_interface/composer_state.rb +96 -27
  52. data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
  53. data/lib/kward/prompt_interface/editor/auto_indent.rb +509 -0
  54. data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
  55. data/lib/kward/prompt_interface/editor/controller.rb +1018 -0
  56. data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
  57. data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
  58. data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
  59. data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
  60. data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
  61. data/lib/kward/prompt_interface/editor/modes/modern.rb +353 -0
  62. data/lib/kward/prompt_interface/editor/modes/vibe.rb +1962 -0
  63. data/lib/kward/prompt_interface/editor/renderer.rb +243 -0
  64. data/lib/kward/prompt_interface/editor/search.rb +76 -0
  65. data/lib/kward/prompt_interface/editor/selections.rb +120 -0
  66. data/lib/kward/prompt_interface/editor/state.rb +1249 -0
  67. data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
  68. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +420 -0
  69. data/lib/kward/prompt_interface/editor/undo_history.rb +46 -0
  70. data/lib/kward/prompt_interface/editor/vibe_state.rb +44 -0
  71. data/lib/kward/prompt_interface/file_overlay.rb +211 -0
  72. data/lib/kward/prompt_interface/git_prompt.rb +299 -0
  73. data/lib/kward/prompt_interface/interactive/controller.rb +186 -0
  74. data/lib/kward/prompt_interface/interactive/renderer.rb +71 -0
  75. data/lib/kward/prompt_interface/interactive/state.rb +62 -0
  76. data/lib/kward/prompt_interface/key_handler.rb +387 -35
  77. data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
  78. data/lib/kward/prompt_interface/project_browser.rb +524 -0
  79. data/lib/kward/prompt_interface/question_prompt.rb +98 -50
  80. data/lib/kward/prompt_interface/runtime_state.rb +43 -0
  81. data/lib/kward/prompt_interface/screen.rb +16 -0
  82. data/lib/kward/prompt_interface/selection_prompt.rb +7 -13
  83. data/lib/kward/prompt_interface/stream_state.rb +7 -0
  84. data/lib/kward/prompt_interface/transcript_buffer.rb +6 -0
  85. data/lib/kward/prompt_interface.rb +286 -8
  86. data/lib/kward/prompts/commands.rb +5 -0
  87. data/lib/kward/prompts.rb +2 -0
  88. data/lib/kward/rpc/server.rb +42 -3
  89. data/lib/kward/rpc/session_manager.rb +35 -47
  90. data/lib/kward/rpc/session_tree_rows.rb +9 -115
  91. data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
  92. data/lib/kward/session_store.rb +44 -0
  93. data/lib/kward/session_tree_nodes.rb +136 -0
  94. data/lib/kward/session_tree_renderer.rb +9 -131
  95. data/lib/kward/tab_store.rb +47 -0
  96. data/lib/kward/text_boundary.rb +25 -0
  97. data/lib/kward/tools/context_budget_stats.rb +54 -0
  98. data/lib/kward/tools/context_for_task.rb +202 -0
  99. data/lib/kward/tools/read_file.rb +8 -4
  100. data/lib/kward/tools/registry.rb +62 -16
  101. data/lib/kward/tools/tool_call.rb +10 -0
  102. data/lib/kward/version.rb +1 -1
  103. data/lib/kward/workers/git_guard.rb +68 -0
  104. data/lib/kward/workers/live_view.rb +49 -0
  105. data/lib/kward/workers/manager.rb +288 -0
  106. data/lib/kward/workers/store.rb +72 -0
  107. data/lib/kward/workers/tool_policy.rb +23 -0
  108. data/lib/kward/workers/worker.rb +82 -0
  109. data/lib/kward/workers/write_lock.rb +38 -0
  110. data/lib/kward/workers.rb +7 -0
  111. data/lib/kward/workspace.rb +110 -24
  112. data/templates/default/fulldoc/html/css/kward.css +107 -36
  113. data/templates/default/kward_navigation.rb +12 -1
  114. data/templates/default/layout/html/layout.erb +4 -2
  115. data/templates/default/layout/html/setup.rb +6 -0
  116. metadata +53 -1
@@ -2,6 +2,8 @@ require_relative "../config_files"
2
2
  require_relative "../conversation"
3
3
  require_relative "ask_user_question"
4
4
  require_relative "code_search"
5
+ require_relative "context_budget_stats"
6
+ require_relative "context_for_task"
5
7
  require_relative "edit_file"
6
8
  require_relative "fetch_content"
7
9
  require_relative "fetch_raw"
@@ -45,7 +47,7 @@ module Kward
45
47
  # Tool schemas advertised to the model for the current frontend and config.
46
48
  #
47
49
  # @return [Array<Hash>] tool schemas currently advertised to the model
48
- attr_reader :schemas
50
+ attr_reader :schemas, :writer_id
49
51
 
50
52
  # Builds tool objects and the schema list for the current frontend/config.
51
53
  #
@@ -58,7 +60,7 @@ module Kward
58
60
  # @param web_search_enabled [Boolean, nil] override for web search exposure
59
61
  # @param skills [Array<ConfigFiles::Skill>, nil] override discovered skills
60
62
  # @param ask_user_question_enabled [Boolean, nil] override question exposure
61
- def initialize(workspace: Workspace.new, prompt: nil, web_search: WebSearch.new, web_fetch: WebFetch.new, code_search: CodeSearch.new, web_search_enabled: nil, skills: nil, ask_user_question_enabled: nil, tool_output_compactor: ToolOutputCompactor.new, telemetry_logger: TelemetryLogger.new)
63
+ def initialize(workspace: Workspace.new, prompt: nil, web_search: WebSearch.new, web_fetch: WebFetch.new, code_search: CodeSearch.new, web_search_enabled: nil, skills: nil, ask_user_question_enabled: nil, allowed_tool_names: nil, write_lock: nil, writer_id: nil, tool_output_compactor: ToolOutputCompactor.new, telemetry_logger: TelemetryLogger.new, context_budget_meter: nil)
62
64
  @workspace = workspace
63
65
  @prompt = prompt
64
66
  @web_search = web_search
@@ -67,8 +69,12 @@ module Kward
67
69
  @skills = skills
68
70
  @web_search_enabled = web_search_enabled
69
71
  @ask_user_question_enabled = ask_user_question_enabled
72
+ @allowed_tool_names = allowed_tool_names&.map(&:to_s)
73
+ @write_lock = write_lock
74
+ @writer_id = writer_id
70
75
  @tool_output_compactor = tool_output_compactor
71
76
  @telemetry_logger = telemetry_logger
77
+ @context_budget_meter = context_budget_meter
72
78
  @tools = build_tools.freeze
73
79
  @schemas = build_schema_tools.map(&:schema).freeze
74
80
  end
@@ -89,34 +95,60 @@ module Kward
89
95
  args = ToolCall.arguments(tool_call)
90
96
  tool = @tools[name]
91
97
 
92
- content = if tool
93
- tool.call(args, conversation, cancellation: cancellation)
94
- else
95
- "Unknown tool: #{name}"
96
- end
97
- content = Conversation.normalize_tool_content(content)
98
- duplicate_id = conversation.tool_output_artifact_id_for(tool_name: name, content: content)
99
- if conversation.tool_output_artifacts.key?(duplicate_id)
98
+ original_content = if tool
99
+ if mutation_tool?(name) && !write_lock_owned?
100
+ "Workspace write denied: another worker owns the write lock."
101
+ else
102
+ tool.call(args, conversation, cancellation: cancellation)
103
+ end
104
+ else
105
+ "Unknown tool: #{name}"
106
+ end
107
+ original_content = Conversation.normalize_tool_content(original_content)
108
+ duplicate_id = conversation.tool_output_artifact_id_for(tool_name: name, content: original_content)
109
+ content = original_content
110
+ if reusable_duplicate_output?(name) && conversation.tool_output_artifacts.key?(duplicate_id)
100
111
  content = "[Same as previous tool output #{duplicate_id}; not repeated. Use retrieve_tool_output to inspect it.]"
101
112
  end
102
113
 
103
114
  artifact_id = nil
104
115
  model_content = @tool_output_compactor.compact(name, content) do
105
- artifact_id ||= conversation.store_tool_output_artifact(tool_name: name, content: content)
116
+ artifact_id ||= conversation.store_tool_output_artifact(tool_name: name, content: original_content)
106
117
  end
107
- log_tool_output_compaction(name, artifact_id: artifact_id, before: content, after: model_content) if model_content != content
118
+ record_context_budget(conversation, name, before: original_content, after: model_content)
119
+ log_tool_output_compaction(name, artifact_id: artifact_id, before: original_content, after: model_content) if model_content != original_content
108
120
  conversation.append_tool(
109
121
  tool_call_id: tool_call["id"] || tool_call[:id],
110
122
  name: name,
111
123
  content: model_content
112
124
  )
113
- conversation.append_tool_execution(tool_call: tool_call, content: content)
125
+ conversation.append_tool_execution(tool_call: tool_call, content: original_content)
114
126
 
115
127
  model_content
116
128
  end
117
129
 
118
130
  private
119
131
 
132
+ def record_context_budget(conversation, name, before:, after:)
133
+ meter = conversation.respond_to?(:context_budget_meter) ? conversation.context_budget_meter : @context_budget_meter
134
+ return unless meter
135
+ return if name.to_s == "context_budget_stats"
136
+
137
+ saved = meter.record(tool_name: name, original_bytes: before.bytesize, returned_bytes: after.bytesize)
138
+ @telemetry_logger.log(
139
+ "compaction",
140
+ "context_budget",
141
+ "tool_name" => name,
142
+ "bytes_before" => before.bytesize,
143
+ "bytes_after" => after.bytesize,
144
+ "bytes_saved" => saved
145
+ )
146
+ end
147
+
148
+ def reusable_duplicate_output?(name)
149
+ name.to_s != "read_skill"
150
+ end
151
+
120
152
  def log_tool_output_compaction(name, artifact_id:, before:, after:)
121
153
  @telemetry_logger.log(
122
154
  "compaction",
@@ -129,18 +161,30 @@ module Kward
129
161
  )
130
162
  end
131
163
 
164
+ def mutation_tool?(name)
165
+ ToolCall.write_lock_required?(name)
166
+ end
167
+
168
+ def write_lock_owned?
169
+ return true unless @write_lock
170
+
171
+ @write_lock.owned_by?(@writer_id)
172
+ end
173
+
132
174
  def build_tools
133
- all_tools.to_h { |tool| [tool.name, tool] }
175
+ tools = all_tools
176
+ tools = tools.select { |tool| @allowed_tool_names.include?(tool.name) } if @allowed_tool_names
177
+ tools.to_h { |tool| [tool.name, tool] }
134
178
  end
135
179
 
136
180
  def build_schema_tools
137
181
  tools = @tools.values_at(
138
- "list_directory", "read_file", "write_file", "edit_file", "run_shell_command", "code_search", "summarize_file_structure", "retrieve_tool_output"
182
+ "list_directory", "read_file", "write_file", "edit_file", "run_shell_command", "code_search", "summarize_file_structure", "context_for_task", "context_budget_stats", "retrieve_tool_output"
139
183
  )
140
184
  tools.concat(@tools.values_at("web_search", "fetch_content", "fetch_raw")) if web_search_available?
141
185
  tools << @tools["read_skill"] if skills_available?
142
186
  tools << @tools["ask_user_question"] if ask_user_question_available?
143
- tools
187
+ tools.compact
144
188
  end
145
189
 
146
190
  def all_tools
@@ -162,6 +206,8 @@ module Kward
162
206
  Tools::RunShellCommand.new(workspace: @workspace),
163
207
  Tools::CodeSearch.new(code_search: @code_search),
164
208
  Tools::SummarizeFileStructure.new(workspace: @workspace),
209
+ Tools::ContextForTask.new(workspace: @workspace),
210
+ Tools::ContextBudgetStats.new(context_budget_meter: @context_budget_meter),
165
211
  Tools::RetrieveToolOutput.new
166
212
  ]
167
213
  end
@@ -18,6 +18,8 @@ module Kward
18
18
  "list_directory" => "list_directory",
19
19
  "code_search" => "code_search",
20
20
  "summarize_file_structure" => "summarize_file_structure",
21
+ "context_for_task" => "context_for_task",
22
+ "context_budget_stats" => "context_budget_stats",
21
23
  "retrieve_tool_output" => "retrieve_tool_output",
22
24
  "web_search" => "web_search",
23
25
  "fetch_content" => "fetch_content",
@@ -65,6 +67,14 @@ module Kward
65
67
  TOOL_NAME_MAP[name.to_s]
66
68
  end
67
69
 
70
+ def write_lock_required?(name)
71
+ %w[edit_file write_file run_shell_command edit write bash].include?(name.to_s)
72
+ end
73
+
74
+ def file_change_tool?(name)
75
+ %w[edit_file write_file edit write].include?(name.to_s)
76
+ end
77
+
68
78
  # Converts provider argument payloads into hashes.
69
79
  #
70
80
  # Providers normally send JSON strings, while tests and compatibility callers
data/lib/kward/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # Namespace for the Kward CLI agent runtime.
2
2
  module Kward
3
3
  # Current gem version.
4
- VERSION = "0.71.0"
4
+ VERSION = "0.72.0"
5
5
  end
@@ -0,0 +1,68 @@
1
+ require "open3"
2
+
3
+ module Kward
4
+ module Workers
5
+ # Small git boundary used by write-lane workers to keep implementation work isolated.
6
+ class GitGuard
7
+ def initialize(root: Dir.pwd)
8
+ @root = root.to_s
9
+ end
10
+
11
+ def repository?
12
+ success?("rev-parse", "--is-inside-work-tree")
13
+ end
14
+
15
+ def clean?
16
+ return true unless repository?
17
+
18
+ status.empty?
19
+ end
20
+
21
+ def dirty?
22
+ !clean?
23
+ end
24
+
25
+ def status
26
+ run("status", "--porcelain").stdout
27
+ end
28
+
29
+ def head
30
+ result = run("rev-parse", "--verify", "HEAD")
31
+ result.success? ? result.stdout.strip : nil
32
+ end
33
+
34
+ def commit_all(message)
35
+ add = run("add", "-A")
36
+ return Result.new(success: false, stdout: add.stdout, stderr: add.stderr) unless add.success?
37
+
38
+ commit = run("commit", "-m", message)
39
+ return Result.new(success: false, stdout: commit.stdout, stderr: commit.stderr) unless commit.success?
40
+
41
+ Result.new(success: true, stdout: commit.stdout, stderr: commit.stderr, commit: head)
42
+ end
43
+
44
+ private
45
+
46
+ Result = Struct.new(:success, :stdout, :stderr, :commit, keyword_init: true) do
47
+ def success?
48
+ success
49
+ end
50
+
51
+ def output
52
+ [stdout, stderr].compact.reject(&:empty?).join("\n")
53
+ end
54
+ end
55
+
56
+ def success?(*args)
57
+ run(*args).success?
58
+ end
59
+
60
+ def run(*args)
61
+ stdout, stderr, status = Open3.capture3("git", "-C", @root, *args)
62
+ Result.new(success: status.success?, stdout: stdout.to_s, stderr: stderr.to_s)
63
+ rescue Errno::ENOENT
64
+ Result.new(success: false, stdout: "", stderr: "git executable not found")
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,49 @@
1
+ module Kward
2
+ module Workers
3
+ # Drains a running worker's accumulated event history into a frontend renderer.
4
+ class LiveView
5
+ FINISHED_STATUSES = %w[ready failed cancelled archived].freeze
6
+
7
+ def initialize(worker:, agent:, renderer:, poll_interval: 0.05)
8
+ @worker = worker
9
+ @agent = agent
10
+ @renderer = renderer
11
+ @poll_interval = poll_interval
12
+ @seen_events = worker.event_history.length
13
+ @stop = false
14
+ @thread = nil
15
+ end
16
+
17
+ attr_reader :worker, :agent
18
+
19
+ def start
20
+ @thread = Thread.new { run }
21
+ @thread.report_on_exception = false
22
+ self
23
+ end
24
+
25
+ def stop
26
+ @stop = true
27
+ @thread&.join(0.2)
28
+ end
29
+
30
+ private
31
+
32
+ def run
33
+ until @stop
34
+ events = @worker.event_history[@seen_events..] || []
35
+ events.each { |event| @renderer.call(event, @agent) }
36
+ @seen_events += events.length
37
+ @renderer.call(:flush, @agent) if finished?
38
+ break if finished?
39
+
40
+ sleep @poll_interval
41
+ end
42
+ end
43
+
44
+ def finished?
45
+ FINISHED_STATUSES.include?(@worker.status)
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,288 @@
1
+ require "timeout"
2
+ require_relative "../agent"
3
+ require_relative "../cancellation"
4
+ require_relative "../conversation"
5
+ require_relative "../model/client"
6
+ require_relative "../session_store"
7
+ require_relative "../tools/registry"
8
+ require_relative "../workspace"
9
+ require_relative "git_guard"
10
+ require_relative "tool_policy"
11
+ require_relative "worker"
12
+
13
+ module Kward
14
+ module Workers
15
+ # Coordinates background worker execution and role-specific tool policy.
16
+ class Manager
17
+ DEFAULT_TIMEOUT_SECONDS = 180
18
+
19
+ def initialize(client_factory: -> { Client.new }, prompt: nil, workspace_root: Dir.pwd, timeout_seconds: DEFAULT_TIMEOUT_SECONDS, on_status_change: nil, session_store: nil, provider: nil, model: nil, reasoning_effort: nil, write_lock: nil, worker_store: nil, git_guard: nil, write_lane_available: -> { true })
20
+ @client_factory = client_factory
21
+ @prompt = prompt
22
+ @workspace_root = ConfigFiles.canonical_workspace_root(workspace_root)
23
+ @timeout_seconds = timeout_seconds
24
+ @on_status_change = on_status_change
25
+ @session_store = session_store
26
+ @provider = provider
27
+ @model = model
28
+ @reasoning_effort = reasoning_effort
29
+ @write_lock = write_lock
30
+ @worker_store = worker_store
31
+ @git_guard = git_guard || GitGuard.new(root: @workspace_root)
32
+ @write_lane_available = write_lane_available
33
+ @workers = {}
34
+ @mutex = Mutex.new
35
+ end
36
+
37
+ def start(role:, prompt:, title: nil, id: nil)
38
+ worker = build_worker(role: role, prompt: prompt, title: title, id: id)
39
+ enqueue(worker)
40
+ end
41
+
42
+ def continue(id, role:, prompt:, title: nil)
43
+ archived = nil
44
+ worker = build_worker(role: role, prompt: prompt, title: title, id: id)
45
+ @mutex.synchronize do
46
+ archived = @workers.delete(id.to_s)
47
+ @workers[worker.id] = worker
48
+ end
49
+ archived&.update_status("archived")
50
+ @worker_store&.archive(id) if archived || @worker_store&.find(id)
51
+ enqueue(worker, store: false)
52
+ end
53
+
54
+ def list
55
+ @mutex.synchronize { @workers.values.reject { |worker| worker.status == "archived" }.sort_by(&:created_at) }
56
+ end
57
+
58
+ def find(id)
59
+ @mutex.synchronize { @workers[id.to_s] }
60
+ end
61
+
62
+ def cancel(id)
63
+ worker = find(id) || raise(ArgumentError, "Unknown worker: #{id}")
64
+ worker.cancellation.cancel!
65
+ worker.thread.raise(Cancellation::CancelledError, "cancelled") if worker.thread&.alive?
66
+ update_status(worker, "cancelled")
67
+ end
68
+
69
+ def archive(id)
70
+ worker = find(id) || raise(ArgumentError, "Unknown worker: #{id}")
71
+ worker.cancellation.cancel! if %w[queued running].include?(worker.status)
72
+ worker.thread.raise(Cancellation::CancelledError, "cancelled") if worker.thread&.alive?
73
+ update_status(worker, "archived")
74
+ end
75
+
76
+ private
77
+
78
+ def build_worker(role:, prompt:, title: nil, id: nil)
79
+ Worker.new(
80
+ id: id || SecureRandom.hex(4),
81
+ title: title || title_for(prompt),
82
+ role: role,
83
+ prompt: prompt,
84
+ workspace_root: @workspace_root,
85
+ status: "queued"
86
+ )
87
+ end
88
+
89
+ def enqueue(worker, store: true)
90
+ @mutex.synchronize { @workers[worker.id] = worker }
91
+ @worker_store&.upsert(worker) if store
92
+ worker.thread = Thread.new { run_worker(worker) }
93
+ worker.thread.report_on_exception = false
94
+ worker
95
+ end
96
+
97
+ def run_worker(worker)
98
+ conversation = Conversation.new(
99
+ system_message: { role: "system", content: system_message(worker) },
100
+ workspace_root: worker.workspace_root,
101
+ provider: @provider,
102
+ model: @model,
103
+ reasoning_effort: @reasoning_effort
104
+ )
105
+ worker.conversation = conversation
106
+ attach_session(worker, conversation)
107
+ writer_id = wait_for_worker_writer(worker)
108
+ update_status(worker, "running")
109
+ registry = ToolRegistry.new(
110
+ workspace: Workspace.new(root: worker.workspace_root),
111
+ prompt: @prompt,
112
+ allowed_tool_names: ToolPolicy.allowed_tool_names(worker.role),
113
+ write_lock: @write_lock,
114
+ writer_id: writer_id
115
+ )
116
+ agent = Agent.new(client: @client_factory.call, tool_registry: registry, conversation: conversation)
117
+ report = Timeout.timeout(@timeout_seconds, WorkerTimeoutError) do
118
+ agent.ask(worker_prompt(worker), cancellation: worker.cancellation) do |event|
119
+ worker.record_event(event)
120
+ end
121
+ end
122
+ report = finalize_write_worker(worker, report)
123
+ update_status(worker, "ready", report: report, error: "")
124
+ rescue WorkerTimeoutError
125
+ update_status(worker, "failed", error: "Worker timed out after #{@timeout_seconds} seconds")
126
+ rescue Cancellation::CancelledError
127
+ update_status(worker, "cancelled")
128
+ rescue StandardError => e
129
+ update_status(worker, "failed", error: e.message)
130
+ ensure
131
+ release_worker_writer(worker)
132
+ end
133
+
134
+ def update_status(worker, status, **values)
135
+ return worker if worker.status == "archived" && status.to_s != "archived"
136
+
137
+ worker.update_status(status, **values)
138
+ @worker_store&.upsert(worker)
139
+ @on_status_change&.call(worker)
140
+ worker
141
+ end
142
+
143
+ def wait_for_worker_writer(worker)
144
+ return nil unless ToolPolicy.write_capable?(worker.role)
145
+
146
+ loop do
147
+ worker.cancellation.raise_if_cancelled!
148
+ wait_for_write_lane_available(worker)
149
+ wait_for_clean_workspace(worker)
150
+ return worker.id unless @write_lock
151
+
152
+ release_foreground_writer_if_clean
153
+ return worker.id if @write_lock.acquire(worker.id)
154
+
155
+ sleep 0.1
156
+ end
157
+ end
158
+
159
+ def wait_for_write_lane_available(worker)
160
+ until @write_lane_available.call
161
+ worker.cancellation.raise_if_cancelled!
162
+ sleep 0.1
163
+ end
164
+ end
165
+
166
+ def wait_for_clean_workspace(worker)
167
+ return unless @git_guard.repository?
168
+
169
+ until @git_guard.clean?
170
+ worker.cancellation.raise_if_cancelled!
171
+ sleep 0.5
172
+ end
173
+ end
174
+
175
+ def release_foreground_writer_if_clean
176
+ return unless @write_lock&.owned_by?("implementation")
177
+ return unless @git_guard.repository?
178
+ return unless @git_guard.clean?
179
+
180
+ @write_lock.release("implementation")
181
+ end
182
+
183
+ def finalize_write_worker(worker, report)
184
+ return report unless ToolPolicy.write_capable?(worker.role)
185
+ return report unless @git_guard.repository?
186
+ return report if @git_guard.clean?
187
+
188
+ commit = @git_guard.commit_all(commit_message(worker))
189
+ unless commit.success?
190
+ raise "Worker changed files but commit failed: #{commit.output}"
191
+ end
192
+
193
+ [report, "", "Committed workspace changes: #{commit.commit}"].join("\n")
194
+ end
195
+
196
+ def commit_message(worker)
197
+ "Kward worker #{worker.id}: #{worker.title}"
198
+ end
199
+
200
+ def release_worker_writer(worker)
201
+ return unless ToolPolicy.write_capable?(worker.role)
202
+
203
+ @write_lock&.release(worker.id)
204
+ end
205
+
206
+ def attach_session(worker, conversation)
207
+ return unless @session_store
208
+
209
+ session = @session_store.create(provider: @provider, model: @model, reasoning_effort: @reasoning_effort)
210
+ session.rename("#{worker.role}: #{worker.title}")
211
+ session.attach(conversation)
212
+ worker.session = session
213
+ @worker_store&.upsert(worker)
214
+ @on_status_change&.call(worker)
215
+ rescue StandardError
216
+ nil
217
+ end
218
+
219
+ def worker_prompt(worker)
220
+ return request_prompt(worker.prompt) if worker.role == "request"
221
+
222
+ worker.prompt
223
+ end
224
+
225
+ def system_message(worker)
226
+ return request_system_message if worker.role == "request"
227
+
228
+ "You are a Kward worker. Complete the user's task carefully."
229
+ end
230
+
231
+ def request_system_message
232
+ <<~SYSTEM
233
+ You are a Kward request worker running the read-only exploration phase.
234
+ Inspect the workspace, map relevant terrain, and produce a practical review for the user.
235
+ Do not edit files, write files, delete files, alter configuration, or claim implementation work was done.
236
+ SYSTEM
237
+ end
238
+
239
+ def request_prompt(prompt)
240
+ <<~PROMPT
241
+ #{prompt}
242
+
243
+ ---
244
+
245
+ You are handling this as a structured Kward background request.
246
+ First perform a read-only exploration phase. Inspect relevant files and documentation, reason about the request, and prepare a reviewable result for the user.
247
+ Do not modify files, write files, delete files, alter configuration, run destructive commands, or claim implementation work was done.
248
+
249
+ Return a concise request review with these sections:
250
+ # Request Review: <title>
251
+
252
+ ## Request
253
+ Restate the user's request.
254
+
255
+ ## Summary
256
+ The short answer.
257
+
258
+ ## Relevant files
259
+ Bullet list of likely files and why they matter.
260
+
261
+ ## Findings
262
+ What you discovered.
263
+
264
+ ## Recommended next step
265
+ A practical next step. If implementation appears useful, say so clearly, but do not implement it.
266
+
267
+ ## Risks
268
+ Important risks or unknowns.
269
+
270
+ ## Verification
271
+ Focused verification commands or checks.
272
+
273
+ ## Open questions
274
+ Decisions the user should make before proceeding.
275
+
276
+ End by asking: Should we proceed?
277
+ PROMPT
278
+ end
279
+
280
+ def title_for(prompt)
281
+ text = prompt.to_s.strip.gsub(/\s+/, " ")
282
+ text.empty? ? "Untitled worker" : text[0, 80]
283
+ end
284
+
285
+ class WorkerTimeoutError < StandardError; end
286
+ end
287
+ end
288
+ end
@@ -0,0 +1,72 @@
1
+ require "fileutils"
2
+ require "json"
3
+ require_relative "../config_files"
4
+
5
+ module Kward
6
+ module Workers
7
+ # JSON-backed metadata store for worker records.
8
+ class Store
9
+ def initialize(path: File.join(ConfigFiles.config_dir, "workers.json"))
10
+ @path = path
11
+ @mutex = Mutex.new
12
+ end
13
+
14
+ attr_reader :path
15
+
16
+ def upsert(worker)
17
+ record = worker.respond_to?(:to_h) ? worker.to_h : worker.to_h
18
+ update_records do |records|
19
+ index = records.index { |item| item["id"] == record["id"] }
20
+ index ? records[index] = record : records << record
21
+ end
22
+ record
23
+ end
24
+
25
+ def list(include_archived: false)
26
+ records = read_records
27
+ records = records.reject { |record| record["status"] == "archived" } unless include_archived
28
+ records.sort_by { |record| record["created_at"].to_s }
29
+ end
30
+
31
+ def find(id)
32
+ read_records.find { |record| record["id"] == id.to_s }
33
+ end
34
+
35
+ def archive(id)
36
+ record = nil
37
+ update_records do |records|
38
+ index = records.index { |item| item["id"] == id.to_s }
39
+ raise ArgumentError, "Unknown worker: #{id}" unless index
40
+
41
+ record = records[index].merge("status" => "archived")
42
+ records[index] = record
43
+ end
44
+ record
45
+ end
46
+
47
+ private
48
+
49
+ def read_records
50
+ @mutex.synchronize { read_records_unlocked }
51
+ end
52
+
53
+ def read_records_unlocked
54
+ return [] unless File.exist?(@path)
55
+
56
+ data = JSON.parse(File.read(@path))
57
+ data.is_a?(Array) ? data : []
58
+ rescue JSON::ParserError
59
+ raise "Invalid worker store JSON: #{@path}"
60
+ end
61
+
62
+ def update_records
63
+ @mutex.synchronize do
64
+ records = read_records_unlocked
65
+ yield records
66
+ FileUtils.mkdir_p(File.dirname(@path))
67
+ File.write(@path, JSON.pretty_generate(records) + "\n")
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,23 @@
1
+ module Kward
2
+ module Workers
3
+ # Tool allowlists for worker roles.
4
+ module ToolPolicy
5
+ READ_ONLY_TOOLS = %w[list_directory read_file code_search summarize_file_structure retrieve_tool_output web_search fetch_content fetch_raw read_skill].freeze
6
+
7
+ module_function
8
+
9
+ def allowed_tool_names(role)
10
+ case role.to_s
11
+ when "request", "read_only"
12
+ READ_ONLY_TOOLS
13
+ else
14
+ nil
15
+ end
16
+ end
17
+
18
+ def write_capable?(role)
19
+ allowed_tool_names(role).nil?
20
+ end
21
+ end
22
+ end
23
+ end