sublime-claude/
├── claude_code.py # Plugin entry point, command registry
├── session.py # Session management, bridge communication
├── rpc.py # JSON-RPC client
├── output.py # Structured output view with region tracking
├── bridge/
│ └── main.py # Python 3.10+ bridge using claude-agent-sdk
├── mcp/
│ └── server.py # MCP protocol server (stdio)
├── mcp_server.py # MCP socket server (Sublime context)
│
├── Core Utilities (Refactored 2024-12):
├── constants.py # Centralized config values & magic strings
├── logger.py # Unified logging (bridge & plugin)
├── error_handler.py # Reusable error handling patterns
├── session_state.py # Session state machine
├── settings.py # Shared settings loader
├── prompt_builder.py # Prompt construction utilities
├── tool_router.py # MCP tool routing (O(1) dispatch)
└── context_parser.py # @ trigger handling & context menus
Extracted Self-Contained Modules:
- constants.py (107 lines) - Centralized all magic strings, paths, config values
- logger.py (136 lines) - Unified logging; replaced 10+ duplicate file write blocks
- error_handler.py (199 lines) - Reusable decorators & helpers for JSON/file/Sublime errors
- session_state.py (204 lines) - Explicit state machine (UNINITIALIZED → READY → WORKING)
- prompt_builder.py (82 lines) - Fluent API for prompt construction
- tool_router.py (162 lines) - Registry-based tool dispatch; replaced 100+ line if/elif chain
- context_parser.py (186 lines) - @ trigger detection & context menu logic
Key Wins:
- Removed ~400 lines of duplicated code
- Faster tool routing (O(n) → O(1) dictionary lookup)
- Removed blackboard functionality (simplified architecture)
- Consistent error handling patterns
- Better separation of concerns
Performance: Noticeably faster due to optimized tool routing and reduced I/O overhead.
- Sublime caches imported modules aggressively
- Touching
claude_code.pytriggers reload of all.pyfiles in package root - Enum classes cause issues when cached - switched to plain string constants
- Dataclass definitions also get cached
Installation:
pip install claude-agent-sdkBasic Usage:
from claude_agent_sdk import ClaudeAgent, ClaudeAgentOptions
options = ClaudeAgentOptions(
cwd="/path/to/project",
allowed_tools=["Read", "Write", "Edit", "Bash", "Glob", "Grep"],
permission_mode="default", # or "acceptEdits", "bypassPermissions"
can_use_tool=my_permission_callback, # async callback
resume=session_id, # optional: resume from previous session
agents=my_agents_dict, # optional: custom subagents
)
agent = ClaudeAgent(options)
async for msg in agent.query("Your prompt here"):
# Handle messages
passMessage Flow:
SystemMessage → initialization (subtype: "init" or "compact_boundary")
AssistantMessage → contains ToolUseBlock (tool request) or TextBlock (response)
├─ ToolUseBlock → tool_name, tool_input, tool_use_id
└─ TextBlock → text content
UserMessage → contains ToolResultBlock (⚠️ NOT in AssistantMessage!)
└─ ToolResultBlock → tool_use_id, content (result or error)
ResultMessage → completion (session_id, duration_ms, total_cost_usd)
Permission Callback:
from claude_agent_sdk import PermissionResultAllow, PermissionResultDeny
async def can_use_tool(tool_name: str, tool_input: dict, context) -> PermissionResult:
# ⚠️ MUST return dataclass objects, NOT plain dicts
if user_allowed:
return PermissionResultAllow(updated_input=tool_input)
else:
return PermissionResultDeny(message="User denied")Subagents (AgentDefinition):
from claude_agent_sdk import AgentDefinition
agents = {
"my-agent": AgentDefinition(
description="When to use this agent",
prompt="System prompt for the agent",
tools=["Read", "Grep"], # restrict tools (optional)
model="haiku", # haiku/sonnet/opus (optional)
)
}
options = ClaudeAgentOptions(..., agents=agents)-
ToolResultBlock location - Results are in
UserMessage.content, NOTAssistantMessage# WRONG: looking for tool result in AssistantMessage # RIGHT: check UserMessage for ToolResultBlock if isinstance(msg, UserMessage): for block in msg.content: if isinstance(block, ToolResultBlock): # Handle result
-
Permission callback return type - Must return
PermissionResultAllow/PermissionResultDenydataclasses# WRONG: return {"allow": True} # RIGHT: return PermissionResultAllow(updated_input=tool_input)
-
Denied tool handling - SDK sends
TextBlockdirectly when denied, noToolResultBlock- Must manually mark tool as error in your UI when permission denied
-
AgentDefinition required - Agents dict must use
AgentDefinitionobjects# WRONG: agents = {"name": {"description": "...", "prompt": "..."}} # RIGHT: agents = {"name": AgentDefinition(description="...", prompt="...")}
-
Async iteration - Must use
async forto iterate messages# WRONG: for msg in agent.query(prompt) # RIGHT: async for msg in agent.query(prompt)
-
Session resume - Pass
resume=session_idto continue, get new session_id fromResultMessage -
Text interleaving - Text and tool calls arrive interleaved in time order
- Don't assume all tools come first then text
- Track events in arrival order for accurate display
-
Interrupt handling - Call
agent.interrupt()to stop, checkResultMessage.status == "interrupted"- After
client.interrupt(), let query task drain remaining messages (don't cancel immediately) - Set
self.interrupted = Trueflag before interrupt, check in query to return correct status - Cancel pending permission futures on interrupt (deny them)
- Timeout after 5s if drain takes too long, then force cancel
- After
- Read-only scratch view controlled by plugin
- Region-based rendering allows in-place updates
- Custom syntax highlighting (ClaudeOutput.sublime-syntax)
- Ayu Mirage theme (ClaudeOutput.hidden-tmTheme)
- Syntax-specific settings (ClaudeOutput.sublime-settings) for font size
- Prompt delimiters:
◎ prompt text ▶(supports multiline with indented continuation) - Working indicator:
⋯shown at bottom while processing @done(Xs)pops conversation context in syntax
Inline permission prompts in the output view:
[Y] Allow- one-time allow[N] Deny- deny (marks tool as ✘)[S] Allow 30s- auto-allow same tool for 30 seconds[A] Always- auto-allow this tool for entire session
Clickable buttons + keyboard shortcuts (Y/N/S/A keys).
Multiple permission requests are queued - only one shown at a time.
- Tools stored as ordered list (not dict by name) to support multiple calls to same tool
tool_donefinds last pending tool with matching name
- Sessions keyed by output view id (not window id) - allows multiple sessions per window
- Sessions saved to
.sessions.jsonwith name, project, cost, query count - Resume via session_id passed to SDK
plugin_loadedhook reconnects orphaned output views after Sublime restart- Closing output view stops its session
View title status indicators:
◉Active + working◇Active + idle•Inactive + working- (no prefix) Inactive + idle
- New Session command creates fresh view + immediately opens input prompt
- Enter key in output view opens input prompt
- Cmd+K clears output, Cmd+Z undoes clear (custom undo via
_cleared_content) - Cmd+Escape interrupts current query
- Context commands require active session
mcp_server.py- Unix socket server in Sublime, handles eval requestsmcp/server.py- MCP stdio server, connects to Sublime socket- Bridge loads MCP config from
.claude/settings.jsonor.mcp.json - Status bar shows loaded MCP servers on init
MCP Tools:
- Editor:
get_window_summary,find_file,get_symbols,goto_symbol,read_view - Terminal:
terminal_list,terminal_run,terminal_read,terminal_close - Blackboard:
bb_write,bb_read,bb_list,bb_delete - Sessions:
spawn_session,send_to_session,list_sessions - Alarms:
set_alarm,cancel_alarm - User:
ask_user- ask questions via quick panel - Custom:
sublime_eval,sublime_tool,list_tools
Editor tools:
get_window_summary()- Open files, active file with selection, project folders, layoutfind_file(query, pattern?, limit?)- Fuzzy find by partial name, optional glob filterget_symbols(query, file_path?, limit?)- Batch symbol lookup (comma-separated or JSON array)read_view(file_path?, view_name?, head?, tail?, grep?, grep_i?)- Read content from any view with head/tail/grep filtering
Terminal tools (uses Terminus plugin):
terminal_run(command, tag?)- Run command in terminal. PREFER over Bash for long-running/interactive commandsterminal_read(tag?, lines?)- Read terminal output (default 100 lines from end)terminal_list()- List open terminalsterminal_close(tag?)- Close a terminal
Blackboard patterns:
plan- implementation steps, architecture decisionswalkthrough- progress report for user (markdown)decisions- key choices and rationalecommands- project-specific commands that work- Data persists across sessions, survives context loss
Terminal usage (Terminus plugin required):
- Agent should use
terminal_runinstead ofBashfor long-running commands - Pattern:
terminal_run("make build", wait=2)→ returns output after wait - Opens new terminal automatically if none exists (tag:
claude-agent) - Output stays in Terminus view (avoids buffer explosion in Claude output)
Terminus API notes:
terminus_openargs:cmd,shell_cmd,cwd,title,tag,auto_close,focus,post_window_hooksterminus_send_stringargs:string,tag,visible_only- window command, uses tag to targetpost_window_hooks: list of[command, args]to run after terminal ready- Threading: MCP socket runs in background thread;
sublime.set_timeoutcallbacks don't run while sleeping - Solution: use
post_window_hooksto queue command on terminal open
Terminal wait implementation:
terminal_run(cmd, wait=N)- usessublime.set_timeout(do_read, delay_ms)for delay- This lets main thread process
terminus_open+post_window_hooksbefore reading - New terminal gets 1s extra startup delay (vs 0.2s for existing)
- Background thread waits on
Eventfor the delayed read to complete terminus_openmust be scheduled viaset_timeout(do_open, 10)to actually execute
- Loaded from
.claude/settings.jsonagentskey - Built-in agents merged with project-defined (project overrides)
Built-in agents:
planner- creates implementation plan, saves to blackboard (haiku)reporter- updates walkthrough/progress report (haiku)
Agent definition:
{
"description": "When to use this agent",
"prompt": "System prompt for the agent",
"tools": ["Read", "Grep"], // restrict available tools
"model": "haiku" // haiku/sonnet/opus
}Instead of polling for subsession completion or other events, sessions can set alarms to "sleep" and wake when events occur. The alarm fires by injecting a wake_prompt into the session.
Architecture:
- Bridge stores alarms and monitors events asynchronously
- Uses
asyncio.Eventfor subsession completion signaling - Uses
asyncio.sleepfor time-based alarms - When alarm fires, bridge injects wake_prompt as a new query
Available as MCP tools:
set_alarm(event_type, event_params, wake_prompt, alarm_id=None)cancel_alarm(alarm_id)
Event types:
time_elapsed- Fire after N seconds:{seconds: int}subsession_complete- Fire when subsession finishes:{subsession_id: str}(view_id)agent_complete- Same as subsession_complete:{agent_id: str}
Usage pattern:
- Main session spawns a subsession
- Main session sets alarm to wait for subsession completion
- Main session ends query (goes idle, but alarm keeps monitoring)
- When subsession completes, it sends notification to bridge
- Bridge fires alarm, injecting wake_prompt into main session
- Main session wakes up and continues with the wake_prompt
Example (MCP tools):
# Spawn a subsession to run tests
result = spawn_session("Run all tests and report results", name="test-runner")
subsession_id = str(result["view_id"])
# Set alarm to wake when tests complete
# This allows main session to end its query and go idle
set_alarm(
event_type="subsession_complete",
event_params={"subsession_id": subsession_id},
wake_prompt="The tests have completed. Check test-runner session output and summarize results."
)Implementation details:
- Alarms stored in
Bridge.alarmsdict (alarm_id → alarm config) - Monitoring tasks in
Bridge.alarm_tasks(alarm_id → asyncio.Task) - Subsession events in
Bridge.subsession_events(subsession_id → asyncio.Event) - When subsession query completes, it sends
subsession_completenotification - Bridge signals the event, waking any monitoring tasks
- Monitoring task calls
_fire_alarm()which injects wake_prompt viaclient.query()
API:
session.set_alarm(event_type, event_params, wake_prompt, alarm_id=None, callback=None)session.cancel_alarm(alarm_id, callback=None)- Bridge methods:
set_alarm(),cancel_alarm(),signal_subsession_complete(subsession_id)
Single-key shortcuts in output view (when idle):
F- "Fuck, read the damn docs" - re-read docs, continueR- Retry: read error, try different approachC- Continue
- TodoWrite tool input captured and displayed at end of response
- Icons:
○pending,▸in_progress,✓completed - Incomplete todos carry forward to next conversation
- When all done: shown once, then cleared for next conversation
- Edit tool shows inline diff in output view
- Uses ```diff fenced block with syntax highlighting
-lines (old) and+lines (new)
To modify a read-only view, need custom TextCommands:
claude_insert- insert at positionclaude_replace- replace region
Session Resume - MUST pass resume_id when reconnecting sessions:
# CORRECT - preserves conversation context
session = Session(window, resume_id=saved_session_id)
# WRONG - loses ALL conversation history (DO NOT do this for reconnects)
session = Session(window)Permission Block Tracking:
Use Sublime's tracked regions (add_regions/get_regions) for UI elements. Stored coordinates become stale when text shifts.
- Removing function parameters - likely breaks callers
- Changing default values - silent behavior change
- "Simplifying" by removing steps - those steps existed for a reason
- "Avoiding duplicates" by skipping operations - probably load-bearing
- Any change justified by "cleaner" or "simpler" - clean != correct
-
No Silent Behavior Changes - If changing HOW something works, explicitly state:
- What the old behavior was
- What the new behavior is
- Why the change is acceptable
-
Distrust Your Own Simplifications - When you want to remove code that seems unnecessary, STOP. Check git history. Ask the user.
-
Context Loss is the Enemy - Write critical decisions to blackboard/comments IMMEDIATELY. Don't trust that you'll remember.
-
Preserve Load-Bearing Code - Some code looks unnecessary but is critical. "To avoid duplicates" was used to justify breaking session resume - that was WRONG.
-
Name Things by Purpose -
resume_idis better thansession_idbecause it implies "this is FOR resuming" - harder to accidentally drop.
- Streaming text (currently waits for full response?)
- Image/file drag-drop to context
- Cost tracking dashboard
- Session search/filter
- Click to expand/collapse tool sections
- MCP tool parameters (pass args to saved tools)
- User-level settings: Bridge now loads from
~/.claude/settings.jsonand merges with project settings. User settings apply globally, project settings override. - Marketplace.json support: Plugin system now reads
.claude-plugin/marketplace.jsonto find plugin locations, compatible with official Claude Code plugin format. - Plugin format: Supports official
"plugin@marketplace": trueformat inenabledPlugins(backward compatible with old object format). - Auto-allowed MCP tools: Automatically allow tools without permission prompts
- Settings:
autoAllowedMcpToolsarray with patterns (e.g.,"mcp__*__*","Bash") - Command:
Claude: Manage Auto-Allowed Tools...to add/remove patterns via quick panel - Permission dialog: Press
[A] Alwaysto save tool to auto-allow list in project settings - Bridge checks patterns with
fnmatchbefore prompting
{ "autoAllowedMcpTools": [ "mcp__plugin_*", "Read", "Bash" ] } - Settings:
- Alarm system: Event-driven waiting instead of polling for subsession completion
- Sessions can set alarms to "sleep" and wake when events occur
- Supports
time_elapsed,subsession_complete, andagent_completeevents - Alarm fires by injecting wake_prompt into the session as a new query
- Uses
asyncio.Eventfor efficient async coordination - API:
session.set_alarm(event_type, event_params, wake_prompt, alarm_id=None) - Subsessions automatically notify bridge when they complete
- See "Alarm System" section in NOTES.md for usage patterns
- Mouse selection: Fixed issue where dragging to select text would make view unresponsive. Dynamic
read_onlytoggling based on cursor position now allows selection everywhere while protecting conversation history from edits. - Input mode protection: Conversation history is now truly read-only when in input mode. Typing, pasting, or any modification outside the input area is blocked via dynamic
read_onlystate. - Reset input mode: Fixed command to properly re-enter input mode after cleanup, not leave view in unusable state.
- Orphaned view reconnection: Fixed blank lines being added each time an orphaned view is reconnected after restart. Now checks if content already ends with newline before adding separator.
read_viewMCP tool: Read content from any view (file buffer or scratch) in Sublime Text with filtering.- Accepts
file_pathfor file buffers (absolute or relative to project) - Accepts
view_namefor scratch buffers (e.g., output panels, unnamed buffers) - Filtering options (applied in order: grep → head/tail):
head=N- Read first N linestail=N- Read last N linesgrep="pattern"- Filter lines matching regex (case-sensitive)grep_i="pattern"- Filter lines matching regex (case-insensitive)
- Returns:
content,size,line_count,original_line_count, and filter info
read_view(file_path="src/main.py", head=50) read_view(view_name="Output", grep="ERROR") read_view(file_path="log.txt", grep_i="warning", tail=100)
- Accepts
- Concurrent permission requests: Fixed bug where multiple tool permissions arriving simultaneously would clear earlier ones as "stale". Now properly queued and processed in order.
- Permission timeout reduced: 5min → 30s. Prevents long hangs when permission UI gets stuck.
- Session rename persistence:
session_idnow set immediately on resume, so renames save before first query completes.
- Tool status colors: Distinct muted colors for tool done (
#5a9484teal) and error (#a06a74mauve). No longer conflicts with diff highlighting.
- Queued prompt: Queue a prompt while Claude is working. Auto-sends when current query finishes.
- Type in input mode + Enter to queue (when working)
- Or use
Claude: Queue Promptcommand - Shows
⏳ <preview>...indicator in output view - Shows
[queued]in status bar spinner
- View session history:
Claude: View Session History...command to browse saved sessions and view user prompts from Claude's stored.jsonlfiles.
- Garbled output fix (
output.py:_do_render): Extended replacement region toview_sizewhen orphaned content exists after the conversation region. Prevents fragmented text appearing after⋯indicator.
- Edit diff format: Now uses
difflib.unified_difffor readable diffs with context lines and@@hunks, instead of listing all-then all+lines. - Bash output: Shows 3 head + 5 tail lines (was 5 head only). Better visibility of command results.
ask_userMCP tool: Ask user questions via quick panel. Workaround for missingAskUserQuestionsupport in Agent SDK.Returnsask_user("Which auth method?", ["OAuth", "JWT", "Session"]){"answer": "OAuth", "question": "..."}or{"cancelled": true}
- Multi-session per window
- Session resume/fork
- Permission prompts (Y/N/S/A)
- Blackboard (cross-session state)
- Built-in subagents (planner, reporter)
- Quick prompts (F/R/C)
- Todo display from TodoWrite
- Diff display for Edit tool
- MCP integration (editor, blackboard, sessions)
- Time-ordered events (text + tools interleaved as they arrive)
- View title status indicators (◉/◇/•)
- Smart auto-scroll (only when cursor near end)
- Session reconnect resets stale states