kward 0.79.0 → 0.80.1

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 (126) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +6 -0
  3. data/CHANGELOG.md +65 -1
  4. data/Gemfile.lock +2 -2
  5. data/README.md +31 -38
  6. data/doc/agent-tools.md +9 -10
  7. data/doc/api.md +3 -3
  8. data/doc/authentication.md +79 -110
  9. data/doc/code-search.md +1 -1
  10. data/doc/composer.md +1 -1
  11. data/doc/configuration.md +69 -26
  12. data/doc/context-budgeting.md +6 -6
  13. data/doc/context-tools.md +2 -2
  14. data/doc/editor.md +7 -7
  15. data/doc/extensibility.md +7 -29
  16. data/doc/files.md +6 -6
  17. data/doc/getting-started.md +2 -2
  18. data/doc/git.md +8 -2
  19. data/doc/lifecycle-hooks.md +5 -4
  20. data/doc/local-models.md +2 -2
  21. data/doc/mcp.md +5 -5
  22. data/doc/permissions.md +13 -4
  23. data/doc/plugins.md +39 -4
  24. data/doc/prompt-templates.md +105 -0
  25. data/doc/providers.md +140 -0
  26. data/doc/releasing.md +10 -2
  27. data/doc/rpc.md +54 -31
  28. data/doc/sandboxing.md +22 -0
  29. data/doc/security.md +22 -7
  30. data/doc/session-management.md +2 -4
  31. data/doc/skills.md +2 -0
  32. data/doc/tabs.md +37 -0
  33. data/doc/telegram.md +138 -0
  34. data/doc/transports.md +209 -0
  35. data/doc/usage.md +11 -3
  36. data/doc/web-search.md +3 -3
  37. data/doc/workspace-tools.md +2 -2
  38. data/examples/plugins/stardate_footer.rb +10 -0
  39. data/examples/plugins/telegram/plugin.rb +39 -0
  40. data/examples/plugins/telegram/telegram_api.rb +136 -0
  41. data/examples/plugins/telegram/telegram_transport.rb +304 -0
  42. data/lib/kward/agent.rb +11 -6
  43. data/lib/kward/ansi.rb +1 -0
  44. data/lib/kward/auth/api_key_store.rb +99 -0
  45. data/lib/kward/cli/auth_commands.rb +72 -13
  46. data/lib/kward/cli/commands.rb +7 -0
  47. data/lib/kward/cli/git.rb +31 -8
  48. data/lib/kward/cli/memory_commands.rb +2 -2
  49. data/lib/kward/cli/plugins.rb +1 -0
  50. data/lib/kward/cli/prompt_interface.rb +44 -1
  51. data/lib/kward/cli/rendering.rb +6 -2
  52. data/lib/kward/cli/runtime_helpers.rb +5 -2
  53. data/lib/kward/cli/sessions.rb +1 -1
  54. data/lib/kward/cli/settings.rb +97 -33
  55. data/lib/kward/cli/slash_commands.rb +1 -1
  56. data/lib/kward/cli/tabs.rb +103 -37
  57. data/lib/kward/cli/transports.rb +67 -0
  58. data/lib/kward/cli/worktrees.rb +360 -0
  59. data/lib/kward/cli.rb +22 -0
  60. data/lib/kward/compactor.rb +7 -2
  61. data/lib/kward/config_files.rb +51 -12
  62. data/lib/kward/conversation.rb +13 -7
  63. data/lib/kward/git_worktree_manager.rb +291 -0
  64. data/lib/kward/hooks/audit_log.rb +5 -2
  65. data/lib/kward/model/azure_openai_config.rb +54 -0
  66. data/lib/kward/model/catalog.rb +113 -0
  67. data/lib/kward/model/client.rb +225 -20
  68. data/lib/kward/model/model_info.rb +49 -0
  69. data/lib/kward/model/payloads.rb +63 -4
  70. data/lib/kward/model/provider_catalog.rb +114 -0
  71. data/lib/kward/model/sources.rb +70 -0
  72. data/lib/kward/model/stream_parser.rb +83 -13
  73. data/lib/kward/permissions/policy.rb +18 -4
  74. data/lib/kward/plugin_chat_runtime.rb +374 -0
  75. data/lib/kward/plugin_registry.rb +99 -19
  76. data/lib/kward/private_file.rb +9 -3
  77. data/lib/kward/prompt_interface/approval_prompt.rb +2 -0
  78. data/lib/kward/prompt_interface/composer_renderer.rb +1 -5
  79. data/lib/kward/prompt_interface/editor/auto_indent.rb +46 -0
  80. data/lib/kward/prompt_interface/editor/buffer.rb +18 -4
  81. data/lib/kward/prompt_interface/editor/controller.rb +7 -7
  82. data/lib/kward/prompt_interface/editor/modes/vibe.rb +2 -2
  83. data/lib/kward/prompt_interface/editor/renderer.rb +8 -8
  84. data/lib/kward/prompt_interface/editor/state.rb +3 -9
  85. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +219 -19
  86. data/lib/kward/prompt_interface/file_overlay.rb +2 -2
  87. data/lib/kward/prompt_interface/git_prompt.rb +7 -3
  88. data/lib/kward/prompt_interface/interactive/controller.rb +4 -4
  89. data/lib/kward/prompt_interface/interactive/renderer.rb +4 -1
  90. data/lib/kward/prompt_interface/interactive/state.rb +2 -2
  91. data/lib/kward/prompt_interface/project_browser.rb +25 -6
  92. data/lib/kward/prompt_interface/runtime_state.rb +22 -6
  93. data/lib/kward/prompt_interface/screen.rb +3 -0
  94. data/lib/kward/prompt_interface/selection_prompt.rb +7 -1
  95. data/lib/kward/prompt_interface/transcript_buffer.rb +24 -2
  96. data/lib/kward/prompt_interface.rb +53 -12
  97. data/lib/kward/prompts/commands.rb +1 -1
  98. data/lib/kward/prompts/templates.rb +11 -6
  99. data/lib/kward/prompts.rb +6 -5
  100. data/lib/kward/rpc/auth_manager.rb +112 -152
  101. data/lib/kward/rpc/config_manager.rb +46 -6
  102. data/lib/kward/rpc/plugin_chat_manager.rb +55 -196
  103. data/lib/kward/rpc/prompt_bridge.rb +8 -4
  104. data/lib/kward/rpc/redactor.rb +1 -1
  105. data/lib/kward/rpc/server.rb +60 -10
  106. data/lib/kward/rpc/session_manager.rb +109 -30
  107. data/lib/kward/session_store.rb +84 -24
  108. data/lib/kward/skills/registry.rb +16 -11
  109. data/lib/kward/tab_driver.rb +6 -3
  110. data/lib/kward/telemetry/logger.rb +5 -2
  111. data/lib/kward/tools/git_commit.rb +39 -0
  112. data/lib/kward/tools/registry.rb +7 -2
  113. data/lib/kward/tools/tool_call.rb +1 -0
  114. data/lib/kward/transport/gateway.rb +253 -0
  115. data/lib/kward/transport/host.rb +343 -0
  116. data/lib/kward/transport/manager.rb +179 -0
  117. data/lib/kward/transport/plugin_chat_gateway.rb +186 -0
  118. data/lib/kward/transport/runtime.rb +41 -0
  119. data/lib/kward/transport/store.rb +101 -0
  120. data/lib/kward/transport.rb +204 -0
  121. data/lib/kward/version.rb +1 -1
  122. data/lib/kward/workspace_factory.rb +19 -2
  123. data/templates/default/fulldoc/html/js/kward.js +2 -0
  124. data/templates/default/kward_navigation.rb +7 -2
  125. data/templates/default/layout/html/setup.rb +4 -0
  126. metadata +26 -1
@@ -14,7 +14,7 @@ module Kward
14
14
  class Registry
15
15
  SkillSource = Struct.new(:root, :label, :scope, :precedence, keyword_init: true)
16
16
 
17
- def initialize(config_dir:, workspace_root:, project_skills_trusted:, skill_class:, max_file_bytes:, markdown_parser:, inside_directory:)
17
+ def initialize(config_dir:, workspace_root:, project_skills_trusted:, skill_class:, max_file_bytes:, markdown_parser:, inside_directory:, warning_sink: nil)
18
18
  @config_dir = config_dir
19
19
  @workspace_root = workspace_root
20
20
  @project_skills_trusted = project_skills_trusted
@@ -22,6 +22,7 @@ module Kward
22
22
  @max_file_bytes = max_file_bytes
23
23
  @markdown_parser = markdown_parser
24
24
  @inside_directory = inside_directory
25
+ @warning_sink = warning_sink
25
26
  end
26
27
 
27
28
  # Returns discovered, validated skills in precedence order.
@@ -36,7 +37,7 @@ module Kward
36
37
  next unless skill
37
38
 
38
39
  if seen[skill.name]
39
- warn "Warning: skipping duplicate Kward skill #{skill.name.inspect}: #{path}"
40
+ emit_warning "Warning: skipping duplicate Kward skill #{skill.name.inspect}: #{path}"
40
41
  next
41
42
  end
42
43
 
@@ -45,7 +46,7 @@ module Kward
45
46
  end
46
47
  end
47
48
  rescue StandardError => e
48
- warn "Warning: skipping Kward skills: #{e.message}"
49
+ emit_warning "Warning: skipping Kward skills: #{e.message}"
49
50
  []
50
51
  end
51
52
 
@@ -99,13 +100,13 @@ module Kward
99
100
  def scan_source(source)
100
101
  return [] unless Dir.exist?(source.root)
101
102
  if source.scope == :project && !@project_skills_trusted
102
- warn "Warning: skipping #{source.label} in #{source.root}: project skills are not trusted"
103
+ emit_warning "Warning: skipping #{source.label} in #{source.root}: project skills are not trusted"
103
104
  return []
104
105
  end
105
106
 
106
107
  Dir.glob(File.join(source.root, "*", "SKILL.md")).sort
107
108
  rescue StandardError => e
108
- warn "Warning: skipping #{source.label} in #{source.root}: #{e.message}"
109
+ emit_warning "Warning: skipping #{source.label} in #{source.root}: #{e.message}"
109
110
  []
110
111
  end
111
112
 
@@ -151,12 +152,12 @@ module Kward
151
152
  return warn_skip(path, "missing description") if description.empty?
152
153
  return warn_skip(path, "description exceeds 1024 characters") if description.length > 1024
153
154
 
154
- warn "Warning: Kward skill #{path}: name does not match parent directory" if name != File.basename(File.dirname(path))
155
- warn "Warning: Kward skill #{path}: name exceeds 64 characters" if name.length > 64
156
- warn "Warning: Kward skill #{path}: name contains invalid characters" unless valid_name?(name)
155
+ emit_warning "Warning: Kward skill #{path}: name does not match parent directory" if name != File.basename(File.dirname(path))
156
+ emit_warning "Warning: Kward skill #{path}: name exceeds 64 characters" if name.length > 64
157
+ emit_warning "Warning: Kward skill #{path}: name contains invalid characters" unless valid_name?(name)
157
158
 
158
159
  compatibility = optional_text(frontmatter["compatibility"])
159
- warn "Warning: Kward skill #{path}: compatibility exceeds 500 characters" if compatibility && compatibility.length > 500
160
+ emit_warning "Warning: Kward skill #{path}: compatibility exceeds 500 characters" if compatibility && compatibility.length > 500
160
161
 
161
162
  @skill_class.new(
162
163
  name: name,
@@ -169,7 +170,7 @@ module Kward
169
170
  allowed_tools: optional_text(frontmatter["allowed-tools"])
170
171
  )
171
172
  rescue StandardError => e
172
- warn "Warning: skipping Kward skill #{path}: #{e.message}"
173
+ emit_warning "Warning: skipping Kward skill #{path}: #{e.message}"
173
174
  nil
174
175
  end
175
176
 
@@ -183,9 +184,13 @@ module Kward
183
184
  end
184
185
 
185
186
  def warn_skip(path, reason)
186
- warn "Warning: skipping Kward skill #{path}: #{reason}"
187
+ emit_warning "Warning: skipping Kward skill #{path}: #{reason}"
187
188
  nil
188
189
  end
190
+
191
+ def emit_warning(message)
192
+ @warning_sink ? @warning_sink.call(message) : warn(message)
193
+ end
189
194
  end
190
195
  end
191
196
  end
@@ -3,11 +3,12 @@ module Kward
3
3
  # Adapts a session-backed agent to the tab runtime interface. Plugin tab
4
4
  # drivers implement the same small surface without becoming Kward sessions.
5
5
  class SessionTabDriver
6
- attr_reader :session, :agent
6
+ attr_reader :session, :agent, :worktree
7
7
 
8
- def initialize(session:, agent:)
8
+ def initialize(session:, agent:, worktree: nil)
9
9
  @session = session
10
10
  @agent = agent
11
+ @worktree = worktree
11
12
  end
12
13
 
13
14
  def messages
@@ -26,7 +27,9 @@ module Kward
26
27
  end
27
28
 
28
29
  def descriptor
29
- { "kind" => "session", "session_path" => session.path }
30
+ descriptor = { "kind" => "session", "session_path" => session.path }
31
+ descriptor["worktree"] = worktree.descriptor if worktree
32
+ descriptor
30
33
  end
31
34
 
32
35
  def session?
@@ -20,13 +20,14 @@ module Kward
20
20
  DEFAULT_MAX_BYTES = 10 * 1024 * 1024
21
21
 
22
22
  # Creates an object for telemetry event logging.
23
- def initialize(config_path: ConfigFiles.config_path, log_dir: nil, max_bytes: DEFAULT_MAX_BYTES, clock: Time, monotonic_clock: Process, error_output: $stderr)
23
+ def initialize(config_path: ConfigFiles.config_path, log_dir: nil, max_bytes: DEFAULT_MAX_BYTES, clock: Time, monotonic_clock: Process, error_output: $stderr, warning_sink: nil)
24
24
  @config_path = config_path
25
25
  @log_dir = log_dir
26
26
  @max_bytes = max_bytes.to_i.positive? ? max_bytes.to_i : DEFAULT_MAX_BYTES
27
27
  @clock = clock
28
28
  @monotonic_clock = monotonic_clock
29
29
  @error_output = error_output
30
+ @warning_sink = warning_sink
30
31
  @mutex = Mutex.new
31
32
  @warned = false
32
33
  end
@@ -192,7 +193,9 @@ module Kward
192
193
  return if @warned
193
194
 
194
195
  @warned = true
195
- @error_output&.puts("Warning: telemetry logging failed: #{error.message}")
196
+ message = "Warning: telemetry logging failed: #{error.message}"
197
+ sink = @warning_sink || ConfigFiles.warning_sink
198
+ sink ? sink.call(message) : @error_output&.puts(message)
196
199
  rescue StandardError
197
200
  nil
198
201
  end
@@ -0,0 +1,39 @@
1
+ require_relative "base"
2
+
3
+ # Namespace for model-callable tool wrappers.
4
+ module Kward
5
+ module Tools
6
+ # Tool wrapper for the trusted host-side Git commit operation.
7
+ class GitCommit < Base
8
+ def initialize(committer:)
9
+ @committer = committer
10
+ super(
11
+ "git_commit",
12
+ "Stage and commit changes in the active worktree. Omit paths to include all current changes.",
13
+ properties: {
14
+ message: { type: "string", description: "Commit message." },
15
+ paths: { type: "array", items: { type: "string" }, description: "Optional workspace-relative paths to include." }
16
+ },
17
+ required: ["message"]
18
+ )
19
+ end
20
+
21
+ def call(args, _conversation, cancellation: nil)
22
+ cancellation&.raise_if_cancelled!
23
+ message = argument(args, :message, "").to_s
24
+ paths = argument(args, :paths, nil)
25
+ return "Error: commit message is required" if message.strip.empty?
26
+ return "Error: paths must be an array" unless paths.nil? || paths.is_a?(Array)
27
+ return "Error: paths must contain strings" if paths && paths.any? { |path| !path.is_a?(String) }
28
+
29
+ paths = nil if paths&.empty?
30
+ result = @committer.call(message: message, paths: paths)
31
+ output = result[:output].to_s.strip
32
+ return "Git commit succeeded\n#{output}" if result[:success]
33
+
34
+ failure = output.empty? ? "Git commit failed." : "Git commit failed.\n#{output}"
35
+ "Error: #{failure}"
36
+ end
37
+ end
38
+ end
39
+ end
@@ -8,6 +8,7 @@ require_relative "context_for_task"
8
8
  require_relative "edit_file"
9
9
  require_relative "fetch_content"
10
10
  require_relative "fetch_raw"
11
+ require_relative "git_commit"
11
12
  require_relative "list_directory"
12
13
  require_relative "mcp_tool"
13
14
  require_relative "read_file"
@@ -78,7 +79,7 @@ module Kward
78
79
  # @param web_search_enabled [Boolean, nil] override for web search exposure
79
80
  # @param skills [Array<ConfigFiles::Skill>, nil] override discovered skills
80
81
  # @param ask_user_question_enabled [Boolean, nil] override question exposure
81
- 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, tool_output_compactor: ToolOutputCompactor.new, telemetry_logger: TelemetryLogger.new, context_budget_meter: nil, mcp_clients: nil, tool_approval: nil, approval_for_allowed_tools: false, permission_policy: nil, hook_manager: nil, hook_context: nil)
82
+ 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, tool_output_compactor: ToolOutputCompactor.new, telemetry_logger: TelemetryLogger.new, context_budget_meter: nil, mcp_clients: nil, tool_approval: nil, approval_for_allowed_tools: false, permission_policy: nil, hook_manager: nil, hook_context: nil, git_committer: nil)
82
83
  @workspace = workspace
83
84
  @prompt = prompt
84
85
  @web_search = web_search
@@ -96,6 +97,7 @@ module Kward
96
97
  @permission_policy = permission_policy || Permissions::Policy.from_config(ConfigFiles.read_config)
97
98
  @hook_manager = hook_manager
98
99
  @hook_context = hook_context
100
+ @git_committer = git_committer
99
101
  @mcp_clients = if mcp_clients
100
102
  mcp_clients
101
103
  elsif @allowed_tool_names
@@ -484,6 +486,7 @@ module Kward
484
486
 
485
487
  def build_schema_tools
486
488
  tools = @tools.values_at(*CORE_TOOL_NAMES)
489
+ tools << @tools["git_commit"] if @tools["git_commit"]
487
490
  tools.concat(@tools.values_at("web_search", "fetch_content", "fetch_raw")) if web_search_available?
488
491
  tools.concat(@tools.values.select { |tool| tool.is_a?(Tools::MCPTool) })
489
492
  tools << @tools["read_skill"] if skills_available?
@@ -492,7 +495,9 @@ module Kward
492
495
  end
493
496
 
494
497
  def all_tools
495
- core_tools + [
498
+ tools = core_tools
499
+ tools << Tools::GitCommit.new(committer: @git_committer) if @git_committer
500
+ tools + [
496
501
  Tools::WebSearch.new(web_search: @web_search),
497
502
  Tools::FetchContent.new(web_fetch: @web_fetch),
498
503
  Tools::FetchRaw.new(web_fetch: @web_fetch),
@@ -24,6 +24,7 @@ module Kward
24
24
  "web_search" => "web_search",
25
25
  "fetch_content" => "fetch_content",
26
26
  "fetch_raw" => "fetch_raw",
27
+ "git_commit" => "git_commit",
27
28
  "read_skill" => "read_skill",
28
29
  "ask_user_question" => "ask_user_question"
29
30
  }.freeze
@@ -0,0 +1,253 @@
1
+ require "base64"
2
+ require "digest"
3
+ require "thread"
4
+ require_relative "../deep_copy"
5
+ require_relative "store"
6
+
7
+ module Kward
8
+ module Transport
9
+ # Adapts the existing session manager to the transport host contract.
10
+ #
11
+ # The adapter deliberately uses the manager's public session and turn
12
+ # methods. It can be replaced by a frontend-neutral runtime gateway later
13
+ # without changing transport plugins.
14
+ class Gateway
15
+ POLL_INTERVAL = 0.05
16
+ TERMINAL_STATUSES = %w[completed canceled failed].freeze
17
+
18
+ def initialize(session_manager:, transport_id:, storage: nil, poll_interval: POLL_INTERVAL)
19
+ @session_manager = session_manager
20
+ @transport_id = transport_id.to_s
21
+ @storage = storage || Store.new(@transport_id)
22
+ @poll_interval = poll_interval
23
+ @subscriptions = []
24
+ @interaction_subscribers = []
25
+ @mutex = Mutex.new
26
+ @session_manager.subscribe_events { |method, payload| handle_runtime_event(method, payload) } if @session_manager.respond_to?(:subscribe_events)
27
+ end
28
+
29
+ def resolve_transport_session(transport_id:, conversation:, actor:, workspace_root: nil, name: nil, execution_profile: nil)
30
+ raise ArgumentError, "transport id does not match gateway" unless transport_id.to_s == @transport_id
31
+ if conversation.respond_to?(:transport_id) && conversation.transport_id.to_s != @transport_id
32
+ raise ArgumentError, "conversation transport does not match gateway"
33
+ end
34
+
35
+ binding_key = binding_key_for(conversation)
36
+ binding = @storage.get(binding_key)
37
+ session = if binding
38
+ resume_bound_session(binding, execution_profile: execution_profile)
39
+ else
40
+ @session_manager.create_session(workspace_root: workspace_root || Dir.pwd, name: name, **execution_profile_arguments(execution_profile))
41
+ end
42
+ persist_binding(binding_key, session)
43
+ session_handle(session)
44
+ end
45
+
46
+ def start_transport_turn(session_id:, input:, attachments: [], options: {}, streaming_behavior: nil, execution_profile: nil)
47
+ payload = @session_manager.start_turn(
48
+ session_id: session_id,
49
+ input: input,
50
+ attachments: normalize_attachments(attachments, execution_profile: execution_profile),
51
+ options: profile_options(options, execution_profile),
52
+ streaming_behavior: streaming_behavior,
53
+ execution_profile: execution_profile
54
+ )
55
+ { id: payload.fetch(:id), session_id: payload.fetch(:sessionId) }
56
+ end
57
+
58
+ def transport_transcript(session_id:)
59
+ @session_manager.transcript(session_id: session_id)
60
+ end
61
+
62
+ def transport_turn_events(turn_id:, after: nil)
63
+ payload = @session_manager.turn_events(turn_id: turn_id, after_sequence: after.to_i)
64
+ Array(payload[:events]).map { |event| normalize_event(event) }
65
+ end
66
+
67
+ def transport_turn_status(turn_id:)
68
+ @session_manager.turn_status(turn_id: turn_id)
69
+ end
70
+
71
+ def cancel_transport_turn(turn_id:)
72
+ @session_manager.cancel_turn(turn_id: turn_id)
73
+ end
74
+
75
+ def answer_transport_interaction(session_id:, request_id:, answer:)
76
+ if answer == true || answer == false
77
+ @session_manager.answer_tool_approval(
78
+ session_id: session_id,
79
+ approval_request_id: request_id,
80
+ approved: answer
81
+ )
82
+ else
83
+ answers = answer.is_a?(Array) ? answer : [{ question: request_id, answer: answer.to_s }]
84
+ @session_manager.answer_question(
85
+ session_id: session_id,
86
+ question_request_id: request_id,
87
+ answers: answers
88
+ )
89
+ end
90
+ end
91
+
92
+ def subscribe_transport_interactions(&block)
93
+ raise ArgumentError, "interaction subscription requires a block" unless block
94
+
95
+ @mutex.synchronize { @interaction_subscribers << block }
96
+ block
97
+ end
98
+
99
+ def subscribe_transport_turn(turn_id:, after: nil)
100
+ cursor = after.to_i
101
+ thread = Thread.new do
102
+ loop do
103
+ events = transport_turn_events(turn_id: turn_id, after: cursor)
104
+ events.each do |event|
105
+ cursor = event.sequence
106
+ yield event
107
+ end
108
+ status = transport_turn_status(turn_id: turn_id)
109
+ break if TERMINAL_STATUSES.include?(status[:status].to_s)
110
+
111
+ sleep @poll_interval
112
+ end
113
+ rescue StandardError
114
+ nil
115
+ end
116
+ @mutex.synchronize { @subscriptions << thread }
117
+ thread
118
+ end
119
+
120
+ def shutdown
121
+ subscriptions = @mutex.synchronize do
122
+ current = @subscriptions
123
+ @subscriptions = []
124
+ @interaction_subscribers = []
125
+ current
126
+ end
127
+ subscriptions.each(&:kill)
128
+ subscriptions.each(&:join)
129
+ nil
130
+ end
131
+
132
+ private
133
+
134
+ def resume_bound_session(binding, execution_profile: nil)
135
+ @session_manager.resume_session(
136
+ path: binding.fetch("path"),
137
+ workspace_root: binding.fetch("workspace_root"),
138
+ include_transcript: false,
139
+ **execution_profile_arguments(execution_profile)
140
+ )
141
+ rescue StandardError
142
+ @session_manager.create_session(workspace_root: binding.fetch("workspace_root"), **execution_profile_arguments(execution_profile))
143
+ end
144
+
145
+ def execution_profile_arguments(profile)
146
+ profile ? { execution_profile: profile } : {}
147
+ end
148
+
149
+ def persist_binding(key, session)
150
+ @storage.put(key, {
151
+ "path" => session.fetch(:path),
152
+ "workspace_root" => session.fetch(:workspaceRoot)
153
+ })
154
+ end
155
+
156
+ def session_handle(session)
157
+ Host::SessionHandle.new(
158
+ id: session.fetch(:id),
159
+ workspace_root: session.fetch(:workspaceRoot),
160
+ name: session[:name]
161
+ )
162
+ end
163
+
164
+ def binding_key_for(conversation)
165
+ external_id = conversation.respond_to?(:external_id) ? conversation.external_id : conversation.to_s
166
+ digest = Digest::SHA256.hexdigest("#{@transport_id}\0#{external_id}")
167
+ "binding:#{digest}"
168
+ end
169
+
170
+ def handle_runtime_event(method, payload)
171
+ request = case method
172
+ when "ui/question"
173
+ questions = Array(payload[:questions] || payload["questions"])
174
+ Transport.interaction_request(
175
+ id: payload[:questionRequestId] || payload["questionRequestId"],
176
+ session_id: payload[:sessionId] || payload["sessionId"],
177
+ turn_id: payload[:turnId] || payload["turnId"] || "unknown",
178
+ kind: :question,
179
+ prompt: questions.map { |question| question[:question] || question["question"] }.join("\\n"),
180
+ choices: questions
181
+ )
182
+ when "tool/approvalRequested"
183
+ Transport.interaction_request(
184
+ id: payload[:approvalRequestId] || payload["approvalRequestId"],
185
+ session_id: payload[:sessionId] || payload["sessionId"],
186
+ turn_id: payload[:turnId] || payload["turnId"] || "unknown",
187
+ kind: :tool_approval,
188
+ prompt: "Allow #{payload[:toolName] || payload["toolName"]}?",
189
+ choices: [{ id: "approve", label: "Approve" }, { id: "deny", label: "Deny" }],
190
+ metadata: { tool_call_id: payload[:toolCallId] || payload["toolCallId"], args: payload[:args] || payload["args"] }
191
+ )
192
+ end
193
+ return unless request
194
+
195
+ subscribers = @mutex.synchronize { @interaction_subscribers.dup }
196
+ subscribers.each do |subscriber|
197
+ begin
198
+ subscriber.call(request)
199
+ rescue StandardError
200
+ nil
201
+ end
202
+ end
203
+ end
204
+
205
+ def profile_options(options, profile)
206
+ return options unless profile
207
+
208
+ options = options.dup
209
+ case profile.tool_mode
210
+ when :none
211
+ options.delete("disabledTools")
212
+ options.delete(:disabled_tools)
213
+ options["allowedTools"] = []
214
+ when :allowlist
215
+ options.delete("disabledTools")
216
+ options.delete(:disabled_tools)
217
+ options["allowedTools"] = profile.allowed_tools
218
+ end
219
+ options["approvalMode"] = "none" if profile.approval_mode == :deny
220
+ options["approvalMode"] = "ask" if profile.approval_mode == :ask
221
+ options
222
+ end
223
+
224
+ def normalize_attachments(attachments, execution_profile: nil)
225
+ if execution_profile && !execution_profile.attachments && !Array(attachments).empty?
226
+ raise ArgumentError, "transport execution profile does not allow attachments"
227
+ end
228
+
229
+ Array(attachments).map do |attachment|
230
+ raise ArgumentError, "transport attachment must be a Transport::Attachment" unless attachment.is_a?(Attachment)
231
+ raise ArgumentError, "transport attachment URLs are not supported by the session gateway" if attachment.url
232
+
233
+ {
234
+ type: "image",
235
+ data: Base64.strict_encode64(attachment.data),
236
+ mimeType: attachment.mime_type,
237
+ name: attachment.name
238
+ }.compact
239
+ end
240
+ end
241
+
242
+ def normalize_event(event)
243
+ Transport.turn_event(
244
+ type: event.fetch(:type),
245
+ session_id: event.fetch(:sessionId),
246
+ turn_id: event.fetch(:turnId),
247
+ sequence: event.fetch(:sequence),
248
+ payload: event.fetch(:payload, {})
249
+ )
250
+ end
251
+ end
252
+ end
253
+ end