claude_agent 0.7.12 → 0.7.14

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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/rules/testing.md +51 -10
  3. data/.claude/settings.json +1 -0
  4. data/ARCHITECTURE.md +237 -0
  5. data/CHANGELOG.md +53 -0
  6. data/CLAUDE.md +2 -0
  7. data/README.md +46 -1
  8. data/Rakefile +17 -0
  9. data/SPEC.md +214 -125
  10. data/lib/claude_agent/client/commands.rb +225 -0
  11. data/lib/claude_agent/client.rb +4 -206
  12. data/lib/claude_agent/content_blocks/generic_block.rb +39 -0
  13. data/lib/claude_agent/content_blocks/image_content_block.rb +54 -0
  14. data/lib/claude_agent/content_blocks/server_tool_result_block.rb +22 -0
  15. data/lib/claude_agent/content_blocks/server_tool_use_block.rb +48 -0
  16. data/lib/claude_agent/content_blocks/text_block.rb +19 -0
  17. data/lib/claude_agent/content_blocks/thinking_block.rb +19 -0
  18. data/lib/claude_agent/content_blocks/tool_result_block.rb +25 -0
  19. data/lib/claude_agent/content_blocks/tool_use_block.rb +134 -0
  20. data/lib/claude_agent/content_blocks.rb +8 -335
  21. data/lib/claude_agent/control_protocol/commands.rb +304 -0
  22. data/lib/claude_agent/control_protocol/lifecycle.rb +116 -0
  23. data/lib/claude_agent/control_protocol/messaging.rb +163 -0
  24. data/lib/claude_agent/control_protocol/primitives.rb +168 -0
  25. data/lib/claude_agent/control_protocol/request_handling.rb +231 -0
  26. data/lib/claude_agent/control_protocol.rb +50 -882
  27. data/lib/claude_agent/conversation.rb +8 -1
  28. data/lib/claude_agent/event_handler.rb +1 -0
  29. data/lib/claude_agent/get_session_info.rb +86 -0
  30. data/lib/claude_agent/hooks.rb +23 -2
  31. data/lib/claude_agent/list_sessions.rb +22 -13
  32. data/lib/claude_agent/message_parser.rb +26 -4
  33. data/lib/claude_agent/messages/conversation.rb +138 -0
  34. data/lib/claude_agent/messages/generic.rb +39 -0
  35. data/lib/claude_agent/messages/hook_lifecycle.rb +158 -0
  36. data/lib/claude_agent/messages/result.rb +80 -0
  37. data/lib/claude_agent/messages/streaming.rb +84 -0
  38. data/lib/claude_agent/messages/system.rb +67 -0
  39. data/lib/claude_agent/messages/task_lifecycle.rb +240 -0
  40. data/lib/claude_agent/messages/tool_lifecycle.rb +95 -0
  41. data/lib/claude_agent/messages.rb +11 -829
  42. data/lib/claude_agent/options/serializer.rb +194 -0
  43. data/lib/claude_agent/options.rb +11 -176
  44. data/lib/claude_agent/query.rb +0 -2
  45. data/lib/claude_agent/sandbox_settings.rb +3 -0
  46. data/lib/claude_agent/session.rb +0 -204
  47. data/lib/claude_agent/session_mutations.rb +148 -0
  48. data/lib/claude_agent/transport/subprocess.rb +2 -2
  49. data/lib/claude_agent/types/mcp.rb +30 -0
  50. data/lib/claude_agent/types/models.rb +146 -0
  51. data/lib/claude_agent/types/operations.rb +38 -0
  52. data/lib/claude_agent/types/sessions.rb +50 -0
  53. data/lib/claude_agent/types/tools.rb +32 -0
  54. data/lib/claude_agent/types.rb +6 -264
  55. data/lib/claude_agent/v2_session.rb +207 -0
  56. data/lib/claude_agent/version.rb +1 -1
  57. data/lib/claude_agent.rb +37 -3
  58. data/sig/claude_agent.rbs +144 -13
  59. metadata +33 -1
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module ClaudeAgent
6
+ # Session mutation operations: rename and tag sessions.
7
+ #
8
+ # Appends JSONL entries to session files to modify session metadata.
9
+ # Follows the TypeScript SDK pattern of file-level mutations.
10
+ #
11
+ # @example Rename a session
12
+ # ClaudeAgent.rename_session("abc-123-...", "My new title")
13
+ #
14
+ # @example Tag a session
15
+ # ClaudeAgent.tag_session("abc-123-...", "important")
16
+ #
17
+ # @example Clear a tag
18
+ # ClaudeAgent.tag_session("abc-123-...", nil)
19
+ #
20
+ module SessionMutations
21
+ # Unicode characters to strip from tag values (zero-width chars, directional marks)
22
+ UNICODE_SANITIZE_PATTERN = /[\u200B\u200C\u200D\uFEFF\u200E\u200F\u202A-\u202E\u2066-\u2069]/
23
+
24
+ module_function
25
+
26
+ # Rename a session by appending a custom-title entry to its session file.
27
+ #
28
+ # @param session_id [String] UUID of the session to rename
29
+ # @param title [String] New title for the session (must be non-empty)
30
+ # @param dir [String, nil] Project directory to find the session in.
31
+ # When nil, searches all projects.
32
+ # @return [void]
33
+ # @raise [ArgumentError] If session_id is not a valid UUID or title is empty
34
+ # @raise [Error] If the session file is not found
35
+ def rename_session(session_id, title, dir: nil)
36
+ validate_session_id!(session_id)
37
+ raise ArgumentError, "title must be a non-empty string" if title.nil? || title.to_s.strip.empty?
38
+
39
+ path = find_session_file(session_id, dir: dir)
40
+ raise Error, "Session not found: #{session_id}" unless path
41
+
42
+ entry = {
43
+ type: "custom-title",
44
+ customTitle: title.to_s,
45
+ sessionId: session_id
46
+ }
47
+
48
+ append_jsonl(path, entry)
49
+ end
50
+
51
+ # Tag a session by appending a tag entry to its session file.
52
+ #
53
+ # @param session_id [String] UUID of the session to tag
54
+ # @param tag [String, nil] Tag value. Pass nil to clear the tag.
55
+ # @param dir [String, nil] Project directory to find the session in.
56
+ # When nil, searches all projects.
57
+ # @return [void]
58
+ # @raise [ArgumentError] If session_id is not a valid UUID
59
+ # @raise [Error] If the session file is not found
60
+ def tag_session(session_id, tag, dir: nil)
61
+ validate_session_id!(session_id)
62
+
63
+ path = find_session_file(session_id, dir: dir)
64
+ raise Error, "Session not found: #{session_id}" unless path
65
+
66
+ sanitized_tag = tag.nil? ? "" : sanitize_unicode(tag.to_s)
67
+
68
+ entry = {
69
+ type: "tag",
70
+ tag: sanitized_tag,
71
+ sessionId: session_id
72
+ }
73
+
74
+ append_jsonl(path, entry)
75
+ end
76
+
77
+ # --- Private Helpers ---
78
+
79
+ # Validate that a string is a valid UUID.
80
+ # @param session_id [String]
81
+ # @raise [ArgumentError] If not a valid UUID
82
+ def validate_session_id!(session_id)
83
+ unless SessionPaths::UUID_PATTERN.match?(session_id.to_s)
84
+ raise ArgumentError, "Invalid session_id: #{session_id}. Must be a valid UUID."
85
+ end
86
+ end
87
+
88
+ # Find a session file by UUID, searching project directories.
89
+ # @param session_id [String] UUID
90
+ # @param dir [String, nil] Optional directory to scope the search
91
+ # @return [String, nil] Path to the .jsonl file or nil
92
+ def find_session_file(session_id, dir: nil)
93
+ filename = "#{session_id}.jsonl"
94
+
95
+ if dir
96
+ resolved = SessionPaths.realpath(dir)
97
+ project_dir = SessionPaths.find_project_dir(resolved)
98
+ return nil unless project_dir
99
+
100
+ path = File.join(project_dir, filename)
101
+ return path if File.exist?(path)
102
+
103
+ # Check worktrees
104
+ worktrees = SessionPaths.git_worktrees(resolved)
105
+ worktrees.each do |wt|
106
+ wt_project_dir = SessionPaths.find_project_dir(wt)
107
+ next unless wt_project_dir
108
+
109
+ wt_path = File.join(wt_project_dir, filename)
110
+ return wt_path if File.exist?(wt_path)
111
+ end
112
+
113
+ nil
114
+ else
115
+ # Search all project directories
116
+ base = SessionPaths.projects_dir
117
+ return nil unless File.directory?(base)
118
+
119
+ Dir.entries(base).each do |entry|
120
+ next if entry.start_with?(".")
121
+ dir_path = File.join(base, entry)
122
+ next unless File.directory?(dir_path)
123
+
124
+ path = File.join(dir_path, filename)
125
+ return path if File.exist?(path)
126
+ end
127
+
128
+ nil
129
+ end
130
+ end
131
+
132
+ # Sanitize a string by removing zero-width and directional Unicode characters.
133
+ # @param str [String]
134
+ # @return [String]
135
+ def sanitize_unicode(str)
136
+ str.gsub(UNICODE_SANITIZE_PATTERN, "")
137
+ end
138
+
139
+ # Atomically append a JSONL entry to a file.
140
+ # @param path [String] File path
141
+ # @param entry [Hash] JSON-serializable entry
142
+ def append_jsonl(path, entry)
143
+ File.open(path, "a") do |f|
144
+ f.write("#{JSON.generate(entry)}\n")
145
+ end
146
+ end
147
+ end
148
+ end
@@ -387,8 +387,8 @@ module ClaudeAgent
387
387
  @stderr.each_line do |line|
388
388
  # Call callback if provided, otherwise just drain
389
389
  @options.stderr_callback&.call(line.chomp)
390
- rescue
391
- # Ignore callback errors
390
+ rescue => e
391
+ logger.debug("transport") { "stderr callback error: #{e.message}" }
392
392
  end
393
393
  rescue IOError
394
394
  # Stream closed, exit thread
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgent
4
+ # Return type for mcp_server_status() (TypeScript SDK parity)
5
+ # Status values: "connected", "failed", "needs-auth", "pending"
6
+ #
7
+ # @example
8
+ # status = McpServerStatus.new(name: "filesystem", status: "connected", server_info: {name: "fs", version: "1.0"})
9
+ #
10
+ McpServerStatus = Data.define(:name, :status, :server_info, :error, :config, :scope, :tools) do
11
+ def initialize(name:, status:, server_info: nil, error: nil, config: nil, scope: nil, tools: nil)
12
+ super
13
+ end
14
+ end
15
+
16
+ # Result of set_mcp_servers() control method (TypeScript SDK parity)
17
+ #
18
+ # @example
19
+ # result = McpSetServersResult.new(
20
+ # added: ["server1"],
21
+ # removed: ["old-server"],
22
+ # errors: {"server2" => "Connection failed"}
23
+ # )
24
+ #
25
+ McpSetServersResult = Data.define(:added, :removed, :errors) do
26
+ def initialize(added: [], removed: [], errors: {})
27
+ super
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgent
4
+ # Return type for supported_models() (TypeScript SDK parity)
5
+ #
6
+ # @example
7
+ # model = ModelInfo.new(value: "claude-3-opus", display_name: "Claude 3 Opus", description: "Most capable")
8
+ # model.value # => "claude-3-opus"
9
+ # model.display_name # => "Claude 3 Opus"
10
+ #
11
+ ModelInfo = Data.define(:value, :display_name, :description, :supports_effort, :supported_effort_levels, :supports_adaptive_thinking, :supports_fast_mode, :supports_auto_mode) do
12
+ def initialize(value:, display_name: nil, description: nil, supports_effort: nil, supported_effort_levels: nil, supports_adaptive_thinking: nil, supports_fast_mode: nil, supports_auto_mode: nil)
13
+ super
14
+ end
15
+ end
16
+
17
+ # Per-model usage statistics returned in result messages (TypeScript SDK parity)
18
+ #
19
+ # @example
20
+ # usage = ModelUsage.new(input_tokens: 100, output_tokens: 50, cost_usd: 0.01, max_output_tokens: 4096)
21
+ #
22
+ ModelUsage = Data.define(
23
+ :input_tokens,
24
+ :output_tokens,
25
+ :cache_read_input_tokens,
26
+ :cache_creation_input_tokens,
27
+ :web_search_requests,
28
+ :cost_usd,
29
+ :context_window,
30
+ :max_output_tokens
31
+ ) do
32
+ def initialize(
33
+ input_tokens: 0,
34
+ output_tokens: 0,
35
+ cache_read_input_tokens: 0,
36
+ cache_creation_input_tokens: 0,
37
+ web_search_requests: 0,
38
+ cost_usd: 0.0,
39
+ context_window: nil,
40
+ max_output_tokens: nil
41
+ )
42
+ super
43
+ end
44
+ end
45
+
46
+ # Return type for account_info() (TypeScript SDK parity)
47
+ #
48
+ # @example
49
+ # info = AccountInfo.new(email: "user@example.com", organization: "Acme Corp")
50
+ #
51
+ AccountInfo = Data.define(:email, :organization, :subscription_type, :token_source, :api_key_source) do
52
+ def initialize(email: nil, organization: nil, subscription_type: nil, token_source: nil, api_key_source: nil)
53
+ super
54
+ end
55
+ end
56
+
57
+ # Return type for supported_agents() (TypeScript SDK v0.2.63 parity)
58
+ #
59
+ # @example
60
+ # agent = AgentInfo.new(name: "Explore", description: "Search agent", model: "haiku")
61
+ # agent.name # => "Explore"
62
+ # agent.description # => "Search agent"
63
+ #
64
+ AgentInfo = Data.define(:name, :description, :model) do
65
+ def initialize(name:, description: nil, model: nil)
66
+ super
67
+ end
68
+ end
69
+
70
+ # Agent definition for custom subagents (TypeScript SDK parity)
71
+ #
72
+ # @example Basic agent
73
+ # agent = AgentDefinition.new(
74
+ # description: "Runs tests and reports results",
75
+ # prompt: "You are a test runner...",
76
+ # tools: ["Read", "Grep", "Glob", "Bash"],
77
+ # model: "haiku"
78
+ # )
79
+ #
80
+ # @example Agent with skills and max_turns
81
+ # agent = AgentDefinition.new(
82
+ # description: "Research agent with specialized skills",
83
+ # prompt: "You are a research expert...",
84
+ # skills: ["web-search", "summarization"],
85
+ # max_turns: 10
86
+ # )
87
+ #
88
+ AgentDefinition = Data.define(
89
+ :description,
90
+ :prompt,
91
+ :tools,
92
+ :disallowed_tools,
93
+ :model,
94
+ :mcp_servers,
95
+ :critical_system_reminder,
96
+ :skills,
97
+ :max_turns
98
+ ) do
99
+ def initialize(
100
+ description:,
101
+ prompt:,
102
+ tools: nil,
103
+ disallowed_tools: nil,
104
+ model: nil,
105
+ mcp_servers: nil,
106
+ critical_system_reminder: nil,
107
+ skills: nil,
108
+ max_turns: nil
109
+ )
110
+ super
111
+ end
112
+
113
+ def to_h
114
+ result = {
115
+ description: description,
116
+ prompt: prompt
117
+ }
118
+ result[:tools] = tools if tools
119
+ result[:disallowedTools] = disallowed_tools if disallowed_tools
120
+ result[:model] = model if model
121
+ result[:mcpServers] = mcp_servers if mcp_servers
122
+ result[:criticalSystemReminder_EXPERIMENTAL] = critical_system_reminder if critical_system_reminder
123
+ result[:skills] = skills if skills
124
+ result[:maxTurns] = max_turns if max_turns
125
+ result
126
+ end
127
+ end
128
+
129
+ # Composite initialization result from supported_commands request (TypeScript SDK parity)
130
+ #
131
+ # @example
132
+ # result = InitializationResult.new(
133
+ # commands: [SlashCommand.new(name: "commit")],
134
+ # output_style: "default",
135
+ # available_output_styles: ["default", "concise"],
136
+ # models: [ModelInfo.new(value: "claude-sonnet")],
137
+ # account: AccountInfo.new(email: "user@example.com"),
138
+ # agents: [AgentInfo.new(name: "Explore")]
139
+ # )
140
+ #
141
+ InitializationResult = Data.define(:commands, :output_style, :available_output_styles, :models, :account, :agents, :fast_mode_state) do
142
+ def initialize(commands: [], output_style: nil, available_output_styles: [], models: [], account: nil, agents: [], fast_mode_state: nil)
143
+ super
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgent
4
+ # Task usage statistics for TaskNotificationMessage (TypeScript SDK parity)
5
+ #
6
+ # @example
7
+ # usage = TaskUsage.new(total_tokens: 5000, tool_uses: 3, duration_ms: 2500)
8
+ #
9
+ TaskUsage = Data.define(:total_tokens, :tool_uses, :duration_ms) do
10
+ def initialize(total_tokens: 0, tool_uses: 0, duration_ms: 0)
11
+ super
12
+ end
13
+ end
14
+
15
+ # Permission denial information in result messages (TypeScript SDK parity)
16
+ #
17
+ SDKPermissionDenial = Data.define(:tool_name, :tool_use_id, :tool_input) do
18
+ def initialize(tool_name:, tool_use_id:, tool_input:)
19
+ super
20
+ end
21
+ end
22
+
23
+ # Result of rewind_files() control method (TypeScript SDK parity)
24
+ #
25
+ # @example
26
+ # result = RewindFilesResult.new(
27
+ # can_rewind: true,
28
+ # files_changed: ["src/foo.rb", "src/bar.rb"],
29
+ # insertions: 10,
30
+ # deletions: 5
31
+ # )
32
+ #
33
+ RewindFilesResult = Data.define(:can_rewind, :error, :files_changed, :insertions, :deletions) do
34
+ def initialize(can_rewind:, error: nil, files_changed: nil, insertions: nil, deletions: nil)
35
+ super
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgent
4
+ # Session metadata returned by list_sessions (TypeScript SDK parity: SDKSessionInfo)
5
+ #
6
+ # @example
7
+ # session = SessionInfo.new(
8
+ # session_id: "abc-123",
9
+ # summary: "Fix login bug",
10
+ # last_modified: 1706000000000,
11
+ # file_size: 4096,
12
+ # custom_title: "Login fix",
13
+ # first_prompt: "Help me fix the login page",
14
+ # git_branch: "fix/login",
15
+ # cwd: "/Users/dev/myapp"
16
+ # )
17
+ #
18
+ SessionInfo = Data.define(
19
+ :session_id,
20
+ :summary,
21
+ :last_modified,
22
+ :file_size,
23
+ :custom_title,
24
+ :first_prompt,
25
+ :git_branch,
26
+ :cwd,
27
+ :tag,
28
+ :created_at
29
+ ) do
30
+ def initialize(session_id:, summary:, last_modified:, file_size:, custom_title: nil, first_prompt: nil, git_branch: nil, cwd: nil, tag: nil, created_at: nil)
31
+ super
32
+ end
33
+ end
34
+
35
+ # Message from a session transcript returned by get_session_messages (TypeScript SDK v0.2.59 parity)
36
+ #
37
+ # @example
38
+ # msg = SessionMessage.new(
39
+ # type: "user",
40
+ # uuid: "abc-123",
41
+ # session_id: "def-456",
42
+ # message: { "role" => "user", "content" => [{ "type" => "text", "text" => "Hello" }] }
43
+ # )
44
+ #
45
+ SessionMessage = Data.define(:type, :uuid, :session_id, :message, :parent_tool_use_id) do
46
+ def initialize(type:, uuid:, session_id:, message:, parent_tool_use_id: nil)
47
+ super
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgent
4
+ # Tools preset configuration (TypeScript SDK parity)
5
+ #
6
+ # @example
7
+ # preset = ToolsPreset.new(preset: "claude_code")
8
+ # options = ClaudeAgent::Options.new(tools: preset)
9
+ #
10
+ ToolsPreset = Data.define(:type, :preset) do
11
+ def initialize(type: "preset", preset: "claude_code")
12
+ super
13
+ end
14
+
15
+ def to_h
16
+ { type: type, preset: preset }
17
+ end
18
+ end
19
+
20
+ # Return type for supported_commands() (TypeScript SDK parity)
21
+ #
22
+ # @example
23
+ # cmd = SlashCommand.new(name: "commit", description: "Create a commit", argument_hint: "[message]")
24
+ # cmd.name # => "commit"
25
+ # cmd.description # => "Create a commit"
26
+ #
27
+ SlashCommand = Data.define(:name, :description, :argument_hint) do
28
+ def initialize(name:, description: nil, argument_hint: nil)
29
+ super
30
+ end
31
+ end
32
+ end