kward 0.78.0 → 0.80.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 (137) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +13 -2
  3. data/.github/workflows/pages.yml +1 -1
  4. data/CHANGELOG.md +82 -1
  5. data/Gemfile.lock +2 -2
  6. data/README.md +33 -38
  7. data/doc/agent-tools.md +10 -11
  8. data/doc/api.md +3 -3
  9. data/doc/authentication.md +79 -110
  10. data/doc/code-search.md +1 -1
  11. data/doc/composer.md +1 -1
  12. data/doc/configuration.md +136 -27
  13. data/doc/context-budgeting.md +6 -6
  14. data/doc/context-tools.md +2 -2
  15. data/doc/editor.md +7 -7
  16. data/doc/extensibility.md +9 -29
  17. data/doc/files.md +6 -6
  18. data/doc/getting-started.md +3 -3
  19. data/doc/git.md +8 -2
  20. data/doc/lifecycle-hooks.md +5 -4
  21. data/doc/local-models.md +130 -0
  22. data/doc/mcp.md +5 -5
  23. data/doc/permissions.md +15 -5
  24. data/doc/plugins.md +39 -4
  25. data/doc/prompt-templates.md +105 -0
  26. data/doc/providers.md +140 -0
  27. data/doc/releasing.md +11 -3
  28. data/doc/rpc.md +65 -31
  29. data/doc/sandboxing.md +142 -0
  30. data/doc/security.md +31 -10
  31. data/doc/session-management.md +2 -4
  32. data/doc/skills.md +10 -0
  33. data/doc/tabs.md +37 -0
  34. data/doc/telegram.md +138 -0
  35. data/doc/transports.md +209 -0
  36. data/doc/usage.md +11 -3
  37. data/doc/web-search.md +3 -3
  38. data/doc/workspace-tools.md +4 -4
  39. data/examples/plugins/stardate_footer.rb +10 -0
  40. data/examples/plugins/telegram/plugin.rb +39 -0
  41. data/examples/plugins/telegram/telegram_api.rb +136 -0
  42. data/examples/plugins/telegram/telegram_transport.rb +304 -0
  43. data/kward.gemspec +1 -1
  44. data/lib/kward/ansi.rb +1 -0
  45. data/lib/kward/auth/api_key_store.rb +99 -0
  46. data/lib/kward/cli/auth_commands.rb +72 -13
  47. data/lib/kward/cli/commands.rb +7 -0
  48. data/lib/kward/cli/doctor.rb +21 -0
  49. data/lib/kward/cli/git.rb +31 -8
  50. data/lib/kward/cli/plugins.rb +1 -0
  51. data/lib/kward/cli/prompt_interface.rb +7 -0
  52. data/lib/kward/cli/rendering.rb +6 -2
  53. data/lib/kward/cli/runtime_helpers.rb +4 -2
  54. data/lib/kward/cli/sessions.rb +1 -1
  55. data/lib/kward/cli/settings.rb +162 -36
  56. data/lib/kward/cli/slash_commands.rb +109 -1
  57. data/lib/kward/cli/tabs.rb +102 -37
  58. data/lib/kward/cli/transports.rb +67 -0
  59. data/lib/kward/cli/worktrees.rb +360 -0
  60. data/lib/kward/cli.rb +22 -1
  61. data/lib/kward/config_files.rb +106 -4
  62. data/lib/kward/conversation.rb +27 -8
  63. data/lib/kward/git_worktree_manager.rb +291 -0
  64. data/lib/kward/local_command_runner.rb +2 -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 +364 -23
  68. data/lib/kward/model/model_info.rb +60 -1
  69. data/lib/kward/model/payloads.rb +64 -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 +141 -39
  73. data/lib/kward/pan/index.html.erb +50 -0
  74. data/lib/kward/pan/server.rb +49 -2
  75. data/lib/kward/permissions/policy.rb +18 -4
  76. data/lib/kward/plugin_chat_runtime.rb +374 -0
  77. data/lib/kward/plugin_registry.rb +81 -8
  78. data/lib/kward/private_file.rb +9 -3
  79. data/lib/kward/prompt_interface/approval_prompt.rb +2 -0
  80. data/lib/kward/prompt_interface/composer_renderer.rb +1 -5
  81. data/lib/kward/prompt_interface/editor/auto_indent.rb +46 -0
  82. data/lib/kward/prompt_interface/editor/buffer.rb +18 -4
  83. data/lib/kward/prompt_interface/editor/controller.rb +43 -10
  84. data/lib/kward/prompt_interface/editor/modes/vibe.rb +2 -2
  85. data/lib/kward/prompt_interface/editor/renderer.rb +8 -8
  86. data/lib/kward/prompt_interface/editor/state.rb +3 -9
  87. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +184 -9
  88. data/lib/kward/prompt_interface/file_overlay.rb +2 -2
  89. data/lib/kward/prompt_interface/git_prompt.rb +7 -3
  90. data/lib/kward/prompt_interface/interactive/controller.rb +4 -4
  91. data/lib/kward/prompt_interface/interactive/renderer.rb +4 -1
  92. data/lib/kward/prompt_interface/interactive/state.rb +2 -2
  93. data/lib/kward/prompt_interface/project_browser.rb +25 -6
  94. data/lib/kward/prompt_interface/runtime_state.rb +22 -6
  95. data/lib/kward/prompt_interface/screen.rb +3 -0
  96. data/lib/kward/prompt_interface/selection_prompt.rb +7 -1
  97. data/lib/kward/prompt_interface/transcript_buffer.rb +24 -2
  98. data/lib/kward/prompt_interface.rb +71 -12
  99. data/lib/kward/prompts/commands.rb +3 -1
  100. data/lib/kward/prompts.rb +22 -10
  101. data/lib/kward/rpc/auth_manager.rb +112 -152
  102. data/lib/kward/rpc/config_manager.rb +46 -6
  103. data/lib/kward/rpc/plugin_chat_manager.rb +56 -194
  104. data/lib/kward/rpc/prompt_bridge.rb +8 -4
  105. data/lib/kward/rpc/redactor.rb +1 -1
  106. data/lib/kward/rpc/server.rb +102 -14
  107. data/lib/kward/rpc/session_manager.rb +151 -38
  108. data/lib/kward/sandbox/capabilities.rb +39 -0
  109. data/lib/kward/sandbox/command_runner.rb +28 -0
  110. data/lib/kward/sandbox/environment.rb +24 -0
  111. data/lib/kward/sandbox/linux_bubblewrap_runner.rb +71 -0
  112. data/lib/kward/sandbox/macos_seatbelt_runner.rb +96 -0
  113. data/lib/kward/sandbox/passthrough_runner.rb +13 -0
  114. data/lib/kward/sandbox/policy.rb +74 -0
  115. data/lib/kward/sandbox/runner_factory.rb +55 -0
  116. data/lib/kward/sandbox/unavailable_runner.rb +21 -0
  117. data/lib/kward/sandbox.rb +9 -0
  118. data/lib/kward/session_store.rb +110 -24
  119. data/lib/kward/skills/capture.rb +144 -0
  120. data/lib/kward/tab_driver.rb +6 -3
  121. data/lib/kward/tools/git_commit.rb +39 -0
  122. data/lib/kward/tools/registry.rb +26 -6
  123. data/lib/kward/tools/tool_call.rb +1 -0
  124. data/lib/kward/transport/gateway.rb +253 -0
  125. data/lib/kward/transport/host.rb +343 -0
  126. data/lib/kward/transport/manager.rb +179 -0
  127. data/lib/kward/transport/plugin_chat_gateway.rb +186 -0
  128. data/lib/kward/transport/runtime.rb +41 -0
  129. data/lib/kward/transport/store.rb +101 -0
  130. data/lib/kward/transport.rb +204 -0
  131. data/lib/kward/version.rb +1 -1
  132. data/lib/kward/workspace.rb +18 -3
  133. data/lib/kward/workspace_factory.rb +34 -0
  134. data/templates/default/fulldoc/html/js/kward.js +3 -0
  135. data/templates/default/kward_navigation.rb +8 -1
  136. data/templates/default/layout/html/setup.rb +5 -0
  137. metadata +41 -2
@@ -0,0 +1,144 @@
1
+ require "fileutils"
2
+ require "json"
3
+ require "tempfile"
4
+ require "yaml"
5
+ require_relative "../compaction/token_estimator"
6
+ require_relative "../config_files"
7
+ require_relative "../message_access"
8
+
9
+ # Namespace for the Kward CLI agent runtime.
10
+ module Kward
11
+ module Skills
12
+ # Generates and persists reviewed personal skills from saved session branches.
13
+ class Capture
14
+ Draft = Struct.new(:content, :source_path, :name, :description, keyword_init: true)
15
+
16
+ class Error < StandardError; end
17
+ class SourceTooLargeError < Error; end
18
+ class InvalidDraftError < Error; end
19
+ class ConflictError < Error; end
20
+
21
+ OUTPUT_TOKEN_RESERVE = 4_096
22
+ PROMPT_TOKEN_RESERVE = 1_024
23
+ NAME_PATTERN = /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/
24
+
25
+ def initialize(session_store:, client:, config_dir: ConfigFiles.config_dir, token_estimator: Compaction::TokenEstimator.new)
26
+ @session_store = session_store
27
+ @client = client
28
+ @config_dir = config_dir
29
+ @token_estimator = token_estimator
30
+ end
31
+
32
+ def generate(session_path, cancellation: nil)
33
+ ensure_personal_session_path!(session_path)
34
+ source = @session_store.capture_branch(session_path)
35
+ raise Error, "Session has no active branch to capture" if source[:entries].empty?
36
+
37
+ source_json = JSON.pretty_generate(source)
38
+ ensure_source_fits!(source_json)
39
+ response = @client.chat(draft_messages(source_json), tools: [], cancellation: cancellation)
40
+ draft(source_path: source[:path], content: MessageAccess.content(response).to_s)
41
+ end
42
+
43
+ def save(content, overwrite: false)
44
+ reviewed_draft = draft(content: content)
45
+ path = skill_path(reviewed_draft.name)
46
+ if File.exist?(path) && !overwrite
47
+ raise ConflictError, "A personal skill named #{reviewed_draft.name.inspect} already exists"
48
+ end
49
+
50
+ FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
51
+ write_atomically(path, reviewed_draft.content)
52
+ reviewed_draft
53
+ end
54
+
55
+ def skill_path(name)
56
+ File.join(@config_dir, "skills", name.to_s, "SKILL.md")
57
+ end
58
+
59
+ private
60
+
61
+ def ensure_personal_session_path!(session_path)
62
+ sessions_root = File.realpath(File.join(@config_dir, "sessions"))
63
+ source_path = File.realpath(session_path)
64
+ return if source_path.start_with?("#{sessions_root}#{File::SEPARATOR}")
65
+
66
+ raise Error, "Skill capture source must be a persisted Kward session"
67
+ rescue Errno::ENOENT
68
+ raise Error, "Skill capture source must be a persisted Kward session"
69
+ end
70
+
71
+ def ensure_source_fits!(source_json)
72
+ context_window = @client.current_context_window
73
+ return unless context_window
74
+
75
+ available_tokens = context_window.to_i - OUTPUT_TOKEN_RESERVE - PROMPT_TOKEN_RESERVE
76
+ source_tokens = @token_estimator.estimate_tokens(source_json)
77
+ return if source_tokens <= available_tokens
78
+
79
+ raise SourceTooLargeError,
80
+ "Selected session needs about #{source_tokens} tokens, but only #{[available_tokens, 0].max} are available for capture"
81
+ end
82
+
83
+ def draft_messages(source_json)
84
+ [
85
+ {
86
+ role: "system",
87
+ content: <<~INSTRUCTIONS
88
+ Create one reusable Kward Agent Skill from the saved session supplied by the user.
89
+ Return only a complete SKILL.md document. Include YAML frontmatter with a lowercase
90
+ hyphenated `name` and a concise `description`, followed by practical reusable
91
+ instructions. Derive guidance from demonstrated workflow, commands, and conventions;
92
+ omit credentials, private values, and project-specific incidental details. Do not
93
+ claim the skill grants permissions or tool access.
94
+ INSTRUCTIONS
95
+ },
96
+ {
97
+ role: "user",
98
+ content: "Saved session active branch (complete persisted records):\n\n#{source_json}"
99
+ }
100
+ ]
101
+ end
102
+
103
+ def draft(source_path: nil, content:)
104
+ metadata, body = parse_skill(content)
105
+ name = metadata.fetch("name", "").to_s.strip
106
+ description = metadata.fetch("description", "").to_s.strip
107
+ raise InvalidDraftError, "Skill frontmatter must include a name" if name.empty?
108
+ raise InvalidDraftError, "Skill name must use lowercase letters, digits, and hyphens" unless NAME_PATTERN.match?(name)
109
+ raise InvalidDraftError, "Skill frontmatter must include a description" if description.empty?
110
+ raise InvalidDraftError, "Skill description exceeds 1024 characters" if description.length > 1024
111
+ raise InvalidDraftError, "Skill instructions must not be empty" if body.strip.empty?
112
+
113
+ Draft.new(content: normalize_content(content), source_path: source_path, name: name, description: description)
114
+ end
115
+
116
+ def parse_skill(content)
117
+ match = content.to_s.match(/\A---\r?\n(.*?)\r?\n---\r?\n(.*)\z/m)
118
+ raise InvalidDraftError, "Skill must begin with YAML frontmatter" unless match
119
+
120
+ metadata = YAML.safe_load(match[1], permitted_classes: [], aliases: false)
121
+ raise InvalidDraftError, "Skill frontmatter must be a mapping" unless metadata.is_a?(Hash)
122
+
123
+ [metadata.transform_keys(&:to_s), match[2]]
124
+ rescue Psych::SyntaxError => error
125
+ raise InvalidDraftError, "Invalid skill frontmatter: #{error.message}"
126
+ end
127
+
128
+ def normalize_content(content)
129
+ "#{content.to_s.rstrip}\n"
130
+ end
131
+
132
+ def write_atomically(path, content)
133
+ Tempfile.create(["SKILL", ".tmp"], File.dirname(path)) do |file|
134
+ file.write(content)
135
+ file.flush
136
+ file.fsync
137
+ File.chmod(0o600, file.path)
138
+ File.rename(file.path, path)
139
+ end
140
+ File.chmod(0o600, path)
141
+ end
142
+ end
143
+ end
144
+ 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?
@@ -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"
@@ -49,6 +50,19 @@ module Kward
49
50
  # explicit tool errors.
50
51
  # @api public
51
52
  class ToolRegistry
53
+ CORE_TOOL_NAMES = %w[
54
+ list_directory
55
+ read_file
56
+ write_file
57
+ edit_file
58
+ run_shell_command
59
+ code_search
60
+ summarize_file_structure
61
+ context_for_task
62
+ context_budget_stats
63
+ retrieve_tool_output
64
+ ].freeze
65
+
52
66
  # Tool schemas advertised to the model for the current frontend and config.
53
67
  #
54
68
  # @return [Array<Hash>] tool schemas currently advertised to the model
@@ -65,7 +79,7 @@ module Kward
65
79
  # @param web_search_enabled [Boolean, nil] override for web search exposure
66
80
  # @param skills [Array<ConfigFiles::Skill>, nil] override discovered skills
67
81
  # @param ask_user_question_enabled [Boolean, nil] override question exposure
68
- 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)
69
83
  @workspace = workspace
70
84
  @prompt = prompt
71
85
  @web_search = web_search
@@ -83,6 +97,7 @@ module Kward
83
97
  @permission_policy = permission_policy || Permissions::Policy.from_config(ConfigFiles.read_config)
84
98
  @hook_manager = hook_manager
85
99
  @hook_context = hook_context
100
+ @git_committer = git_committer
86
101
  @mcp_clients = if mcp_clients
87
102
  mcp_clients
88
103
  elsif @allowed_tool_names
@@ -462,13 +477,16 @@ module Kward
462
477
  def build_tools
463
478
  tools = all_tools
464
479
  tools = tools.select { |tool| @allowed_tool_names.include?(tool.name) } if @allowed_tool_names
465
- tools.to_h { |tool| [tool.name, tool] }
480
+ tools.each_with_object({}) do |tool, result|
481
+ raise ArgumentError, "Duplicate tool name: #{tool.name}" if result.key?(tool.name)
482
+
483
+ result[tool.name] = tool
484
+ end
466
485
  end
467
486
 
468
487
  def build_schema_tools
469
- tools = @tools.values_at(
470
- "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"
471
- )
488
+ tools = @tools.values_at(*CORE_TOOL_NAMES)
489
+ tools << @tools["git_commit"] if @tools["git_commit"]
472
490
  tools.concat(@tools.values_at("web_search", "fetch_content", "fetch_raw")) if web_search_available?
473
491
  tools.concat(@tools.values.select { |tool| tool.is_a?(Tools::MCPTool) })
474
492
  tools << @tools["read_skill"] if skills_available?
@@ -477,7 +495,9 @@ module Kward
477
495
  end
478
496
 
479
497
  def all_tools
480
- core_tools + [
498
+ tools = core_tools
499
+ tools << Tools::GitCommit.new(committer: @git_committer) if @git_committer
500
+ tools + [
481
501
  Tools::WebSearch.new(web_search: @web_search),
482
502
  Tools::FetchContent.new(web_fetch: @web_fetch),
483
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