Skip to content
Kward Search API index

Class: Kward::RPC::SessionManager

Inherits:
Object
  • Object
show all
Includes:
MemoryMethods
Defined in:
lib/kward/rpc/session_manager.rb

Overview

Owns RPC-visible session lifecycle, async turn queues, and frontend events.

Server handles JSON-RPC framing/dispatch; SessionManager handles the product state behind those methods. It creates/resumes SessionStore sessions, builds agents with RPC prompt bridges, serializes turn events for clients, coordinates cancellation and follow-up queues, and integrates memory/plugin hooks for RPC sessions.

Keep JSON-RPC wire shape normalization in the RPC::*Normalizer classes, persistence in SessionStore, and model/tool behavior in Agent and ToolRegistry. This class should coordinate those pieces rather than own their low-level mechanics.

Defined Under Namespace

Classes: RpcSession, Turn

Constant Summary collapse

RECENT_EVENT_LIMIT =
1_000
RPC_ATTACHMENT_MAX_BYTES =
AttachmentNormalizer::MAX_BYTES
RPC_IMAGE_MIME_TYPES =
AttachmentNormalizer::IMAGE_MIME_TYPES
STREAMING_BEHAVIORS =
["newTurn", "followUp", "steer"].freeze
1.0
WORKER_STOP =
Object.new.freeze

Instance Method Summary collapse

Methods included from MemoryMethods

#memory_add, #memory_add_core, #memory_auto_summary_disable, #memory_auto_summary_enable, #memory_disable, #memory_enable, #memory_forget, #memory_inspect, #memory_list, #memory_manager, #memory_promote, #memory_relax, #memory_status, #memory_summarize, #memory_why

Constructor Details

#initialize(server:, client: Client.new, config_dir: ConfigFiles.config_dir, config_manager: ConfigManager.new(config_path: File.join(config_dir, "config.json")), context_usage: ContextUsage.new, session_trash: SessionTrash.new) ⇒ SessionManager

Creates an object for RPC session lifecycle and turn coordination.



71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
# File 'lib/kward/rpc/session_manager.rb', line 71

def initialize(
  server:,
  client: Client.new,
  config_dir: ConfigFiles.config_dir,
  config_manager: ConfigManager.new(config_path: File.join(config_dir, "config.json")),
  context_usage: ContextUsage.new,
  session_trash: SessionTrash.new
)
  @server = server
  @client = client
  @config_dir = config_dir
  @config_manager = config_manager
  @context_usage = context_usage
  @session_metrics = SessionMetrics.new(context_usage: context_usage)
  @session_trash = session_trash
  @sessions = {}
  @turns = {}
  @mutex = Mutex.new
end

Instance Method Details

#active_sessionsObject



147
148
149
# File 'lib/kward/rpc/session_manager.rb', line 147

def active_sessions
  { sessions: @mutex.synchronize { @sessions.values.map { |rpc_session| session_payload(rpc_session) } } }
end

#answer_question(session_id:, question_request_id:, answers:) ⇒ Object



411
412
413
414
415
# File 'lib/kward/rpc/session_manager.rb', line 411

def answer_question(session_id:, question_request_id:, answers:)
  rpc_session = fetch_session(session_id)
  rpc_session.prompt.answer(question_request_id, answers)
  { ok: true }
end

#answer_tool_approval(session_id:, approval_request_id:, approved:) ⇒ Object



417
418
419
420
421
# File 'lib/kward/rpc/session_manager.rb', line 417

def answer_tool_approval(session_id:, approval_request_id:, approved:)
  rpc_session = fetch_session(session_id)
  rpc_session.prompt.answer_tool_approval(approval_request_id, approved: approved)
  { ok: true }
end

#available_modelsObject



464
465
466
467
468
469
470
# File 'lib/kward/rpc/session_manager.rb', line 464

def available_models
  models = @client.respond_to?(:available_models) ? Array(@client.available_models) : []
  normalized = models.map { |model| normalize_model(model) }
  current = current_model
  normalized << current if normalized.none? { |model| model[:provider] == current[:provider] && model[:id] == current[:id] }
  normalized
end

#cancel_turn(turn_id:) ⇒ Object



380
381
382
383
384
385
386
387
388
389
# File 'lib/kward/rpc/session_manager.rb', line 380

def cancel_turn(turn_id:)
  turn = fetch_turn(turn_id)
  turn.cancel_requested = true
  turn.cancellation&.cancel!
  emit_turn_event(turn, "turnCancelRequested", {})
  if turn.status == "queued"
    finish_turn(turn, "canceled")
  end
  turn_payload(turn)
end

#cleanup_unused_sessionsObject

Closes idle empty sessions left behind by UI lifecycle transitions.



313
314
315
316
317
318
319
320
321
322
323
# File 'lib/kward/rpc/session_manager.rb', line 313

def cleanup_unused_sessions
  rpc_sessions = @mutex.synchronize { @sessions.values.dup }
  rpc_sessions.reverse_each do |rpc_session|
    next unless session_idle?(rpc_session)
    next unless rpc_session.session.respond_to?(:delete_if_unused)
    next unless rpc_session.session.delete_if_unused

    remove_live_session(rpc_session)
  end
  { closed: true }
end

#clone_session(session_id:) ⇒ Object

Creates an independent copy of the current conversation branch.



168
169
170
171
172
173
174
175
176
# File 'lib/kward/rpc/session_manager.rb', line 168

def clone_session(session_id:)
  source = fetch_session(session_id)
  session, conversation = source.store.create_independent_from_conversation(source.conversation, parent_session: source.session)
  rpc_session = build_rpc_session(source.store, session, conversation, source.workspace_root)
  remember_session(rpc_session)
  cleanup_other_unused_sessions(rpc_session)
  emit_footer_update(rpc_session)
  session_payload(rpc_session)
end

#close_session(session_id:) ⇒ Object

Stops workers and removes an RPC session from the live session map.



306
307
308
309
310
# File 'lib/kward/rpc/session_manager.rb', line 306

def close_session(session_id:)
  rpc_session = fetch_session(session_id)
  close_rpc_session(rpc_session)
  { closed: true }
end

#compact_session(session_id:, custom_instructions: "") ⇒ Object

Compacts an RPC session and emits start/end events for UI progress.



179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
# File 'lib/kward/rpc/session_manager.rb', line 179

def compact_session(session_id:, custom_instructions: "")
  rpc_session = fetch_session(session_id)
  emit_session_event(rpc_session, "compactionStart", {})
  result = Compactor.new(conversation: rpc_session.conversation, client: @client, settings: compaction_settings).compact(custom_instructions: custom_instructions)
  payload = {
    summary: result.summary,
    firstKeptEntryId: result.first_kept_entry_id,
    tokensBefore: result.tokens_before,
    details: result.details
  }.compact
  emit_session_event(rpc_session, "compactionEnd", { result: payload, aborted: false, willRetry: false, errorMessage: nil })
  payload
rescue StandardError => e
  emit_session_event(rpc_session, "compactionEnd", { result: nil, aborted: true, willRetry: false, errorMessage: e.message }) if rpc_session
  raise e
end

#create_session(workspace_root: Dir.pwd, name: nil, resume_last: false) ⇒ Object

Creates a new RPC session or resumes the remembered session when allowed.

Returns the normalized session payload expected by RPC clients. The RPC session id is separate from the persisted session id so one persisted file can be closed and reopened by different client connections.



96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
# File 'lib/kward/rpc/session_manager.rb', line 96

def create_session(workspace_root: Dir.pwd, name: nil, resume_last: false)
  workspace_root = validate_workspace_root(workspace_root)
  store = SessionStore.new(config_dir: @config_dir, cwd: workspace_root)
  if resume_last && session_auto_resume_enabled? && name.to_s.strip.empty?
    path = store.remembered_last_session_path
    return resume_session(path: path, workspace_root: workspace_root, include_transcript: true) if path
  end

  conversation = new_conversation(workspace_root: workspace_root)
  session = store.create(provider: conversation.provider, model: conversation.model, reasoning_effort: conversation.reasoning_effort)
  session.rename(name) unless name.to_s.strip.empty?
  session.attach(conversation)
  rpc_session = build_rpc_session(store, session, conversation, workspace_root)
  remember_session(rpc_session)
  cleanup_other_unused_sessions(rpc_session)
  emit_footer_update(rpc_session)
  session_payload(rpc_session)
end

#current_modelObject



472
473
474
475
476
477
478
# File 'lib/kward/rpc/session_manager.rb', line 472

def current_model
  provider = @client.respond_to?(:current_provider) ? @client.current_provider : nil
  model = @client.respond_to?(:current_model) ? @client.current_model : nil
  reasoning_effort = @client.respond_to?(:current_reasoning_effort) ? @client.current_reasoning_effort : nil
  context_window = @client.respond_to?(:current_context_window) ? @client.current_context_window : nil
  ModelInfo.current_payload(provider: provider, model: model, reasoning_effort: reasoning_effort, context_window: context_window)
end

#delete_session(session_id:) ⇒ Object

Deletes the backing session file through the configured trash strategy.



297
298
299
300
301
302
303
# File 'lib/kward/rpc/session_manager.rb', line 297

def delete_session(session_id:)
  rpc_session = fetch_session(session_id)
  path = rpc_session.session.path
  close_rpc_session(rpc_session, delete_unused: false)
  deleted = @session_trash.delete(path)
  { deleted: deleted, path: path }
end

#export_session(session_id:, path: nil, format: nil) ⇒ Object

Exports the current transcript in markdown or JSON format.



287
288
289
290
291
292
293
294
# File 'lib/kward/rpc/session_manager.rb', line 287

def export_session(session_id:, path: nil, format: nil)
  rpc_session = fetch_session(session_id)
  format = export_format(format)
  path = export_path(rpc_session, path, format)
  content = export_content(rpc_session.conversation, format)
  File.write(path, content)
  { path: path, format: format }
end

#fork_messages(session_id:) ⇒ Object

Lists user-message entries that can be used as fork points.



197
198
199
200
201
202
203
204
205
206
207
# File 'lib/kward/rpc/session_manager.rb', line 197

def fork_messages(session_id:)
  rpc_session = fetch_session(session_id)
  {
    messages: session_tree_helper(rpc_session).entries.filter_map do |record|
      message = record["message"]
      next unless message.is_a?(Hash) && message_role(message) == "user"

      { entryId: record["id"], text: display_message_text(message) }
    end
  }
end

#fork_session(session_id:, entry_id:) ⇒ Object

Creates a new session from history before the selected user message.

Raises:

  • (ArgumentError)


210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
# File 'lib/kward/rpc/session_manager.rb', line 210

def fork_session(session_id:, entry_id:)
  source = fetch_session(session_id)
  tree = session_tree_helper(source)
  entries = tree.entries
  resolved_entry_id = tree.resolve_entry_id(entry_id, entries: entries)
  selected_index = entries.index { |record| record["id"].to_s == resolved_entry_id.to_s }
  selected = selected_index && entries[selected_index]
  raise ArgumentError, "Unknown fork entryId: #{entry_id}" unless selected

  message = selected["message"]
  raise ArgumentError, "Entry is not forkable: #{entry_id}" unless message.is_a?(Hash) && message_role(message) == "user"

  session, conversation = source.store.create_independent_from_messages(
    entries[0...selected_index].filter_map { |record| record["message"] },
    provider: source.conversation.provider,
    model: source.conversation.model,
    reasoning_effort: source.conversation.reasoning_effort,
    parent_session: source.session
  )

  rpc_session = build_rpc_session(source.store, session, conversation, source.workspace_root)
  remember_session(rpc_session)
  cleanup_other_unused_sessions(rpc_session)
  {
    session: session_payload(rpc_session),
    text: full_message_text(message),
    cancelled: false
  }
end

#in_flight_steer_supported?Boolean

Returns:

  • (Boolean)


495
496
497
# File 'lib/kward/rpc/session_manager.rb', line 495

def in_flight_steer_supported?
  supports_in_flight_steer?
end

#list_sessions(workspace_root: Dir.pwd, limit: nil, current_session_path: nil) ⇒ Object



138
139
140
141
142
143
144
145
# File 'lib/kward/rpc/session_manager.rb', line 138

def list_sessions(workspace_root: Dir.pwd, limit: nil, current_session_path: nil)
  root = validate_workspace_root(workspace_root)
  store = SessionStore.new(config_dir: @config_dir, cwd: root)
  requested_limit = limit.to_i if limit
  requested_limit = nil unless requested_limit&.positive?
  store.recent(limit: requested_limit, keep_empty_path: current_session_path)
       .map { |info| session_info_payload(info, workspace_root: root) }
end

#list_turns(session_id: nil, active: false) ⇒ Object



404
405
406
407
408
409
# File 'lib/kward/rpc/session_manager.rb', line 404

def list_turns(session_id: nil, active: false)
  turns = @mutex.synchronize { @turns.values.dup }
  turns.select! { |turn| turn.session_id == session_id.to_s } if session_id
  turns.select! { |turn| ["queued", "running"].include?(turn.status) } if active
  { turns: turns.map { |turn| turn_payload(turn) } }
end

Moves the active branch to a tree entry, optionally summarizing abandoned history.

Raises:

  • (ArgumentError)


254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
# File 'lib/kward/rpc/session_manager.rb', line 254

def navigate_tree(session_id:, entry_id:, summarize: false, custom_instructions: nil)
  rpc_session = fetch_session(session_id)
  tree = session_tree_helper(rpc_session)
  entries = tree.entries
  resolved_entry_id = tree.resolve_entry_id(entry_id, entries: entries)
  entry = rpc_session.store.session_entry(rpc_session.session.path, resolved_entry_id)
  raise ArgumentError, "Unknown tree entryId: #{entry_id}" unless entry

  raise ArgumentError, "Tree entry is not selectable: #{entry_id}" unless tree.selectable_entry?(entry)

  message = entry["message"]
  user_entry = tree.user_entry?(entry)
  target_leaf = user_entry ? entry["parentId"] : entry["id"]
  editor_text = user_entry ? full_message_text(message) : nil
  previous_leaf = rpc_session.session.leaf_id

  if summarize
    summary = summarize_branch(rpc_session, from_id: previous_leaf, to_id: target_leaf, custom_instructions: custom_instructions)
    target_leaf = rpc_session.session.append_branch_summary(target_leaf, from_id: previous_leaf, summary: summary, details: {})
  elsif target_leaf
    rpc_session.session.branch(target_leaf)
  end

  reload_rpc_session(rpc_session)
  {
    session: session_payload(rpc_session),
    editorText: editor_text,
    cancelled: false,
    aborted: false
  }.compact
end

#plugin_commandsObject



460
461
462
# File 'lib/kward/rpc/session_manager.rb', line 460

def plugin_commands
  plugin_registry.commands
end

#refresh_client_configObject



542
543
544
545
546
# File 'lib/kward/rpc/session_manager.rb', line 542

def refresh_client_config
  @client.reload_config if @client.respond_to?(:reload_config)
  refresh_session_runtime_contexts
  refresh_session_tool_registries
end

#reload_pluginsObject



548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
# File 'lib/kward/rpc/session_manager.rb', line 548

def reload_plugins
  registry = PluginRegistry.load(reserved_commands: reserved_plugin_command_names)
  sessions = @mutex.synchronize do
    @plugin_registry = registry
    @sessions.values
  end
  sessions.each do |rpc_session|
    rpc_session.conversation.plugin_registry = registry if rpc_session.conversation.respond_to?(:plugin_registry=)
    rpc_session.conversation.refresh_system_message! if rpc_session.conversation.respond_to?(:refresh_system_message!)
    rebuild_session_tools(rpc_session)
    if registry.footer_renderer
      start_footer_worker(rpc_session)
      emit_footer_update(rpc_session)
    else
      stop_footer_worker(rpc_session)
      clear_footer_update(rpc_session)
    end
  end
end

#rename_session(session_id:, name:) ⇒ Object

Renames the persisted session attached to an RPC session id.



161
162
163
164
165
# File 'lib/kward/rpc/session_manager.rb', line 161

def rename_session(session_id:, name:)
  rpc_session = fetch_session(session_id)
  rpc_session.session.rename(name)
  session_payload(rpc_session)
end

#resume_session(path:, workspace_root: nil, include_transcript: false) ⇒ Object



115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
# File 'lib/kward/rpc/session_manager.rb', line 115

def resume_session(path:, workspace_root: nil, include_transcript: false)
  root = validate_workspace_root(workspace_root || Dir.pwd)
  store = SessionStore.new(config_dir: @config_dir, cwd: root)
  location = store.session_location(path)
  root = validate_workspace_root(location[:cwd])
  store = SessionStore.new(config_dir: @config_dir, cwd: root)
  session, conversation = store.load(
    location[:path],
    workspace: configured_workspace(root),
    provider: current_model[:provider],
    model: current_model_id,
    reasoning_effort: current_reasoning_effort
  )
  rpc_session = build_rpc_session(store, session, conversation, root)
  remember_session(rpc_session)
  cleanup_other_unused_sessions(rpc_session)
  emit_footer_update(rpc_session)
  payload = session_payload(rpc_session)
  payload[:messages] = TranscriptNormalizer.new(rpc_session.conversation.messages).normalize if include_transcript
  payload[:resumed] = true
  payload
end

#run_command(session_id:, command:, arguments: "") ⇒ Object



423
424
425
426
427
428
429
430
# File 'lib/kward/rpc/session_manager.rb', line 423

def run_command(session_id:, command:, arguments: "")
  name = command.to_s.delete_prefix("/")
  return { ok: false, error: "unsupported", reason: "clientClipboardOwnedByUi" } if name == "copy"
  return run_skill_command(session_id: session_id, name: name.delete_prefix("skill:")) if name.start_with?("skill:")
  return run_skill_command(session_id: session_id, name: arguments) if name == "skill"

  run_plugin_command(session_id: session_id, command: name, arguments: arguments)
end

#run_plugin_command(session_id:, command:, arguments: "") ⇒ Object



450
451
452
453
454
455
456
457
458
# File 'lib/kward/rpc/session_manager.rb', line 450

def run_plugin_command(session_id:, command:, arguments: "")
  rpc_session = fetch_session(session_id)
  command = plugin_registry.command_for(command.to_s.delete_prefix("/")) || raise(ArgumentError, "Unknown plugin command: #{command}")
  output = []
  context = plugin_context(rpc_session, args: arguments.to_s, say_callback: lambda { |message| output << message.to_s })
  result = command.handler.call(arguments.to_s, context)
  output = rpc_session.plugin_output.shift(rpc_session.plugin_output.length) + output
  { command: command.name, output: output, result: result.nil? ? nil : result.to_s }
end

#run_skill_command(session_id:, name:) ⇒ Object

Raises:

  • (ArgumentError)


432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
# File 'lib/kward/rpc/session_manager.rb', line 432

def run_skill_command(session_id:, name:)
  skill_name = name.to_s.strip
  raise ArgumentError, "Missing skill name" if skill_name.empty?

  rpc_session = fetch_session(session_id)
  tool_call = {
    "id" => "skill_#{skill_name.gsub(/[^a-zA-Z0-9_-]/, "_")}",
    "type" => "function",
    "function" => {
      "name" => "read_skill",
      "arguments" => JSON.dump({ name: skill_name })
    }
  }
  rpc_session.conversation.append_assistant("role" => "assistant", "content" => nil, "tool_calls" => [tool_call])
  result = rpc_session.tool_registry.dispatch(tool_call, rpc_session.conversation)
  { command: "skill:#{skill_name}", output: [result.start_with?("Error:") ? result : "Activated skill: #{skill_name}"], result: result }
end

#runtime_state(session_id:) ⇒ Object



499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
# File 'lib/kward/rpc/session_manager.rb', line 499

def runtime_state(session_id:)
  rpc_session = fetch_session(session_id)
  model = session_model(rpc_session)
  compaction_settings = self.compaction_settings
  auto_compaction_reserve_tokens = compaction_reserve_tokens(
    context_window: model[:contextWindow],
    compaction_settings: compaction_settings
  )
  session = session_payload(rpc_session)
  RuntimePayloads.state(
    session: session,
    model: model,
    streaming: streaming?(rpc_session),
    steering_supported: supports_in_flight_steer?,
    auto_compaction_reserve_tokens: auto_compaction_reserve_tokens,
    active_persona_label: active_persona_label(rpc_session),
    message_count: @session_metrics.message_count(rpc_session.conversation),
    pending_count: pending_turn_count(rpc_session.id),
    compaction_enabled: compaction_settings.enabled,
    workspace_guardrails_enabled: workspace_guardrails_enabled?
  )
end

#runtime_stats(session_id:) ⇒ Object



522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
# File 'lib/kward/rpc/session_manager.rb', line 522

def runtime_stats(session_id:)
  rpc_session = fetch_session(session_id)
  session = session_payload(rpc_session)
  counts = @session_metrics.message_stats(rpc_session.conversation)
  model = session_model(rpc_session)
  compaction_settings = self.compaction_settings
  auto_compaction_reserve_tokens = compaction_reserve_tokens(
    context_window: model[:contextWindow],
    compaction_settings: compaction_settings
  )
  RuntimePayloads.stats(
    session: session,
    counts: counts,
    model: model,
    auto_compaction_reserve_tokens: auto_compaction_reserve_tokens,
    context_usage: @session_metrics.context_usage(rpc_session, model, client: @client),
    compaction_enabled: compaction_settings.enabled
  )
end

#session_model(rpc_session) ⇒ Object



480
481
482
483
484
485
486
487
488
489
490
491
492
493
# File 'lib/kward/rpc/session_manager.rb', line 480

def session_model(rpc_session)
  current = current_model
  provider = rpc_session.conversation.provider || current[:provider]
  model = rpc_session.conversation.model || current[:id]
  reasoning_effort = rpc_session.conversation.reasoning_effort || current_reasoning_effort
  reasoning_effort = nil unless ModelInfo.reasoning_supported?(provider, model)
  context_window = context_window_for(provider, model)
  ModelInfo.current_payload(
    provider: provider,
    model: model,
    reasoning_effort: reasoning_effort,
    context_window: context_window
  )
end

#session_modified_at(session) ⇒ Object



576
577
578
# File 'lib/kward/rpc/session_manager.rb', line 576

def session_modified_at(session)
  File.exist?(session.path) ? File.mtime(session.path) : nil
end

#session_payload(rpc_session) ⇒ Object



568
569
570
571
572
573
574
# File 'lib/kward/rpc/session_manager.rb', line 568

def session_payload(rpc_session)
  RuntimePayloads.session(
    rpc_session,
    modified_at: session_modified_at(rpc_session.session),
    active_persona_label: active_persona_label(rpc_session)
  )
end

#session_tree(session_id:) ⇒ Object

Returns the flattened session tree rows consumed by RPC clients.



241
242
243
244
# File 'lib/kward/rpc/session_manager.rb', line 241

def session_tree(session_id:)
  rpc_session = fetch_session(session_id)
  { items: flatten_session_tree(rpc_session) }
end

#set_tree_label(session_id:, entry_id:, label: nil) ⇒ Object

Persists a label override for one tree entry.



247
248
249
250
251
# File 'lib/kward/rpc/session_manager.rb', line 247

def set_tree_label(session_id:, entry_id:, label: nil)
  rpc_session = fetch_session(session_id)
  rpc_session.session.append_label_change(entry_id, label)
  { ok: true }
end

#shutdown_sessionsObject

Stops all live RPC session workers during server shutdown.



326
327
328
329
330
# File 'lib/kward/rpc/session_manager.rb', line 326

def shutdown_sessions
  rpc_sessions = @mutex.synchronize { @sessions.values.dup }
  rpc_sessions.reverse_each { |rpc_session| close_rpc_session(rpc_session) if session_idle?(rpc_session) }
  { closed: true }
end

#start_turn(session_id:, input:, streaming_behavior: nil, attachments: [], options: {}, context: nil) ⇒ Object

Queues or starts an async model turn for an RPC session.

streaming_behavior controls busy-session behavior: create a new turn, queue a follow-up, or steer the running turn when the active provider supports native steering. The returned turn id is used for status, cancellation, and event replay.



344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
# File 'lib/kward/rpc/session_manager.rb', line 344

def start_turn(session_id:, input:, streaming_behavior: nil, attachments: [], options: {}, context: nil)
  rpc_session = fetch_session(session_id)
  normalized_options = normalize_turn_options(options)
  normalized_context = TurnContext.normalize(context)
  normalized_attachments = normalize_attachments(attachments)
  plugin_command, plugin_arguments = plugin_command_turn(input, normalized_attachments)
  display_input = input.to_s if input.is_a?(String)
  content = plugin_command ? input.to_s : user_turn_content(turn_input_with_context(expand_prompt_input(input), normalized_context), normalized_attachments)
  streaming_behavior = validate_streaming_behavior(default_streaming_behavior(rpc_session, streaming_behavior), rpc_session: rpc_session)
  if streaming_behavior == "steer"
    return steer_running_turn(rpc_session, content)
  end
  turn = Turn.new(
    id: SecureRandom.uuid,
    session_id: rpc_session.id,
    input: content,
    display_input: display_input,
    status: "queued",
    cancel_requested: false,
    cancellation: Cancellation.new,
    created_at: now,
    events: [],
    next_sequence: 1,
    streaming_behavior: streaming_behavior,
    plugin_command_name: plugin_command&.name,
    plugin_arguments: plugin_arguments,
    options: normalized_options,
    tool_registry: scoped_tool_registry(rpc_session, normalized_options)
  )
  @mutex.synchronize { @turns[turn.id] = turn }
  rpc_session.queue << turn.id
  ensure_worker(rpc_session)
  emit_turn_event(turn, "turnQueued", { status: "queued" })
  turn_payload(turn)
end

#tool_schemas(session_id: nil) ⇒ Object



151
152
153
154
155
156
157
158
# File 'lib/kward/rpc/session_manager.rb', line 151

def tool_schemas(session_id: nil)
  registry = if session_id
               fetch_session(session_id).tool_registry
             else
               build_tool_registry(Dir.pwd, nil)
             end
  { tools: registry.schemas }
end

#transcript(session_id:) ⇒ Object

Returns the normalized transcript for the active RPC session.



333
334
335
336
# File 'lib/kward/rpc/session_manager.rb', line 333

def transcript(session_id:)
  rpc_session = fetch_session(session_id)
  { session: session_payload(rpc_session), messages: TranscriptNormalizer.new(rpc_session.conversation.messages).normalize }
end

#turn_events(turn_id:, after_sequence: 0) ⇒ Object



395
396
397
398
399
400
401
402
# File 'lib/kward/rpc/session_manager.rb', line 395

def turn_events(turn_id:, after_sequence: 0)
  turn = fetch_turn(turn_id)
  after_sequence = after_sequence.to_i
  {
    turn: turn_payload(turn),
    events: turn.events.select { |event| event[:sequence].to_i > after_sequence }
  }
end

#turn_status(turn_id:) ⇒ Object



391
392
393
# File 'lib/kward/rpc/session_manager.rb', line 391

def turn_status(turn_id:)
  turn_payload(fetch_turn(turn_id))
end

#validate_workspace_root(root) ⇒ Object



580
581
582
583
584
585
# File 'lib/kward/rpc/session_manager.rb', line 580

def validate_workspace_root(root)
  expanded = File.expand_path(root.to_s.empty? ? Dir.pwd : root.to_s)
  raise "Workspace root is not an existing directory: #{expanded}" unless File.directory?(expanded)

  File.realpath(expanded)
end