claude-office 0.1.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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/bin/claude-office +6 -0
- data/lib/claude_office/agents/agent.rb +78 -0
- data/lib/claude_office/agents/registry.rb +35 -0
- data/lib/claude_office/agents/sub_agent.rb +35 -0
- data/lib/claude_office/animation/frame_cycle.rb +22 -0
- data/lib/claude_office/animation/spring_mover.rb +48 -0
- data/lib/claude_office/app.rb +149 -0
- data/lib/claude_office/cli.rb +47 -0
- data/lib/claude_office/notification.rb +22 -0
- data/lib/claude_office/office/desk.rb +25 -0
- data/lib/claude_office/office/grid.rb +66 -0
- data/lib/claude_office/office/pathfinder.rb +53 -0
- data/lib/claude_office/rendering/renderer.rb +122 -0
- data/lib/claude_office/rendering/sprites.rb +40 -0
- data/lib/claude_office/rendering/theme.rb +59 -0
- data/lib/claude_office/transcript/events.rb +71 -0
- data/lib/claude_office/transcript/parser.rb +183 -0
- data/lib/claude_office/transcript/watcher.rb +99 -0
- data/lib/claude_office/version.rb +3 -0
- data/lib/claude_office.rb +19 -0
- metadata +138 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 0e027095a406422081e3db847e481224a96d6e2289983b66cec9076553c3d477
|
|
4
|
+
data.tar.gz: 98b602f6c16c80b79d146095500d2b38075685c6d7ed5e270a50bbcfea083ac0
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 2f43f8f32940f07f81c368eadcea57c788ec956177571d3e31609e92583b8e85f1a7d15dfb7978a8f6aba6868a4f4061f672a265dd3903777a143ebd41f08ef0
|
|
7
|
+
data.tar.gz: c5fa2675865370aebd98520465677c616c858698464a00bc01191aa099bc37e2b6f8cae38b21b168e349996727590ce3475ca15baee6c2ea369bb27b4a5f8abb
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Fernando Ruiz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/bin/claude-office
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
require_relative "sub_agent"
|
|
2
|
+
|
|
3
|
+
module ClaudeOffice
|
|
4
|
+
module Agents
|
|
5
|
+
class Agent
|
|
6
|
+
TOOL_ANIMATIONS = {
|
|
7
|
+
"Read" => :reading, "Glob" => :reading, "Grep" => :reading,
|
|
8
|
+
"WebFetch" => :reading, "WebSearch" => :reading,
|
|
9
|
+
"Edit" => :typing, "Write" => :typing, "NotebookEdit" => :typing,
|
|
10
|
+
"Bash" => :running,
|
|
11
|
+
"Task" => :typing,
|
|
12
|
+
"AskUserQuestion" => :waiting,
|
|
13
|
+
"EnterPlanMode" => :reading,
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
attr_reader :session_id, :state, :active_tools, :status_text,
|
|
17
|
+
:animation, :desk_position, :sub_agents, :position
|
|
18
|
+
|
|
19
|
+
def initialize(session_id:, desk_position:)
|
|
20
|
+
@session_id = session_id
|
|
21
|
+
@desk_position = desk_position
|
|
22
|
+
@position = desk_position.dup
|
|
23
|
+
@state = :idle
|
|
24
|
+
@active_tools = {}
|
|
25
|
+
@status_text = ""
|
|
26
|
+
@animation = :idle
|
|
27
|
+
@sub_agents = {}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def tool_started(tool_id, tool_name, status_text)
|
|
31
|
+
@active_tools[tool_id] = tool_name
|
|
32
|
+
@status_text = status_text
|
|
33
|
+
@animation = TOOL_ANIMATIONS.fetch(tool_name, :idle)
|
|
34
|
+
@state = :working
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def tool_done(tool_id)
|
|
38
|
+
tool_name = @active_tools.delete(tool_id)
|
|
39
|
+
|
|
40
|
+
if tool_name == "Task"
|
|
41
|
+
@sub_agents.delete(tool_id)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
if @active_tools.empty?
|
|
45
|
+
@state = :idle
|
|
46
|
+
@animation = :idle
|
|
47
|
+
@status_text = ""
|
|
48
|
+
else
|
|
49
|
+
last_tool = @active_tools.values.last
|
|
50
|
+
@animation = TOOL_ANIMATIONS.fetch(last_tool, :idle)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def turn_ended
|
|
55
|
+
@state = :waiting
|
|
56
|
+
@animation = :waiting
|
|
57
|
+
@active_tools.clear
|
|
58
|
+
@sub_agents.clear
|
|
59
|
+
@status_text = ""
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def new_turn
|
|
63
|
+
@state = :idle
|
|
64
|
+
@animation = :idle
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def sub_agent_tool_started(parent_tool_id, tool_id, tool_name, status_text)
|
|
68
|
+
@sub_agents[parent_tool_id] ||= SubAgent.new(parent_tool_id: parent_tool_id)
|
|
69
|
+
@sub_agents[parent_tool_id].tool_started(tool_id, tool_name, status_text)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def sub_agent_tool_done(parent_tool_id, tool_id)
|
|
73
|
+
sub = @sub_agents[parent_tool_id]
|
|
74
|
+
sub&.tool_done(tool_id)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
require_relative "agent"
|
|
2
|
+
|
|
3
|
+
module ClaudeOffice
|
|
4
|
+
module Agents
|
|
5
|
+
class Registry
|
|
6
|
+
attr_reader :agents
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
@agents = {}
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def add(session_id, desk_position)
|
|
13
|
+
agent = Agent.new(session_id: session_id, desk_position: desk_position)
|
|
14
|
+
@agents[session_id] = agent
|
|
15
|
+
agent
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def get(session_id)
|
|
19
|
+
@agents[session_id]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def remove(session_id)
|
|
23
|
+
@agents.delete(session_id)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def count
|
|
27
|
+
@agents.size
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def each(&block)
|
|
31
|
+
@agents.each_value(&block)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module ClaudeOffice
|
|
2
|
+
module Agents
|
|
3
|
+
class SubAgent
|
|
4
|
+
attr_reader :parent_tool_id, :active_tools, :status_text, :animation
|
|
5
|
+
|
|
6
|
+
TOOL_ANIMATIONS = {
|
|
7
|
+
"Read" => :reading, "Glob" => :reading, "Grep" => :reading,
|
|
8
|
+
"WebFetch" => :reading, "WebSearch" => :reading,
|
|
9
|
+
"Edit" => :typing, "Write" => :typing, "NotebookEdit" => :typing,
|
|
10
|
+
"Bash" => :running,
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
def initialize(parent_tool_id:)
|
|
14
|
+
@parent_tool_id = parent_tool_id
|
|
15
|
+
@active_tools = {}
|
|
16
|
+
@status_text = ""
|
|
17
|
+
@animation = :idle
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def tool_started(tool_id, tool_name, status_text)
|
|
21
|
+
@active_tools[tool_id] = tool_name
|
|
22
|
+
@status_text = status_text
|
|
23
|
+
@animation = TOOL_ANIMATIONS.fetch(tool_name, :idle)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def tool_done(tool_id)
|
|
27
|
+
@active_tools.delete(tool_id)
|
|
28
|
+
if @active_tools.empty?
|
|
29
|
+
@animation = :idle
|
|
30
|
+
@status_text = ""
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module ClaudeOffice
|
|
2
|
+
module Animation
|
|
3
|
+
class FrameCycle
|
|
4
|
+
attr_reader :frame
|
|
5
|
+
|
|
6
|
+
def initialize(frame_count:, frames_per_tick: 10)
|
|
7
|
+
@frame_count = frame_count
|
|
8
|
+
@frames_per_tick = frames_per_tick
|
|
9
|
+
@tick = 0
|
|
10
|
+
@frame = 0
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def advance
|
|
14
|
+
@tick += 1
|
|
15
|
+
if @tick >= @frames_per_tick
|
|
16
|
+
@tick = 0
|
|
17
|
+
@frame = (@frame + 1) % @frame_count
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
require "harmonica"
|
|
2
|
+
|
|
3
|
+
module ClaudeOffice
|
|
4
|
+
module Animation
|
|
5
|
+
class SpringMover
|
|
6
|
+
def initialize(fps: 30)
|
|
7
|
+
@spring = Harmonica::Spring.new(
|
|
8
|
+
delta_time: Harmonica.fps(fps),
|
|
9
|
+
angular_frequency: 5.0,
|
|
10
|
+
damping_ratio: 0.8
|
|
11
|
+
)
|
|
12
|
+
@x = 0.0
|
|
13
|
+
@y = 0.0
|
|
14
|
+
@vx = 0.0
|
|
15
|
+
@vy = 0.0
|
|
16
|
+
@target_x = 0.0
|
|
17
|
+
@target_y = 0.0
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def set_position(x, y)
|
|
21
|
+
@x = x.to_f
|
|
22
|
+
@y = y.to_f
|
|
23
|
+
@target_x = x.to_f
|
|
24
|
+
@target_y = y.to_f
|
|
25
|
+
@vx = 0.0
|
|
26
|
+
@vy = 0.0
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def set_target(x, y)
|
|
30
|
+
@target_x = x.to_f
|
|
31
|
+
@target_y = y.to_f
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def update
|
|
35
|
+
@x, @vx = @spring.update(@x, @vx, @target_x)
|
|
36
|
+
@y, @vy = @spring.update(@y, @vy, @target_y)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def position
|
|
40
|
+
[@x.round, @y.round]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def arrived?(threshold: 0.5)
|
|
44
|
+
(@x - @target_x).abs < threshold && (@y - @target_y).abs < threshold
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
require "bubbletea"
|
|
2
|
+
require "lipgloss"
|
|
3
|
+
require_relative "transcript/watcher"
|
|
4
|
+
require_relative "agents/registry"
|
|
5
|
+
require_relative "office/grid"
|
|
6
|
+
require_relative "office/pathfinder"
|
|
7
|
+
require_relative "rendering/renderer"
|
|
8
|
+
require_relative "animation/frame_cycle"
|
|
9
|
+
require_relative "notification"
|
|
10
|
+
|
|
11
|
+
module ClaudeOffice
|
|
12
|
+
class TickMessage < Bubbletea::Message; end
|
|
13
|
+
|
|
14
|
+
class App
|
|
15
|
+
include Bubbletea::Model
|
|
16
|
+
|
|
17
|
+
FPS = 30
|
|
18
|
+
GRID_WIDTH = 60
|
|
19
|
+
GRID_HEIGHT = 20
|
|
20
|
+
|
|
21
|
+
def initialize(project_dir:, sound: true)
|
|
22
|
+
@project_dir = project_dir
|
|
23
|
+
@sound = sound
|
|
24
|
+
@registry = Agents::Registry.new
|
|
25
|
+
@grid = Office::Grid.new(width: GRID_WIDTH, height: GRID_HEIGHT)
|
|
26
|
+
@pathfinder = Office::Pathfinder.new(@grid)
|
|
27
|
+
@renderer = Rendering::Renderer.new(width: GRID_WIDTH, height: GRID_HEIGHT)
|
|
28
|
+
@notification = Notification.new(sound_enabled: sound)
|
|
29
|
+
@frame_cycle = Animation::FrameCycle.new(frame_count: 2, frames_per_tick: 15)
|
|
30
|
+
@event_queue = Queue.new
|
|
31
|
+
@watcher = nil
|
|
32
|
+
@width = GRID_WIDTH
|
|
33
|
+
@height = GRID_HEIGHT
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def init
|
|
37
|
+
@watcher = Transcript::Watcher.new(@project_dir, @event_queue)
|
|
38
|
+
@watcher.start
|
|
39
|
+
|
|
40
|
+
[self, schedule_tick]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def update(message)
|
|
44
|
+
case message
|
|
45
|
+
when Bubbletea::KeyMessage
|
|
46
|
+
handle_key(message)
|
|
47
|
+
when Bubbletea::WindowSizeMessage
|
|
48
|
+
@width = message.width
|
|
49
|
+
@height = message.height
|
|
50
|
+
grid_w = [@width, GRID_WIDTH].min
|
|
51
|
+
grid_h = [message.height - 4, GRID_HEIGHT].min
|
|
52
|
+
@grid = Office::Grid.new(width: grid_w, height: grid_h)
|
|
53
|
+
@grid.layout_for(@registry.count)
|
|
54
|
+
@pathfinder = Office::Pathfinder.new(@grid)
|
|
55
|
+
@renderer = Rendering::Renderer.new(width: @grid.width, height: @grid.height)
|
|
56
|
+
[self, nil]
|
|
57
|
+
when TickMessage
|
|
58
|
+
handle_tick
|
|
59
|
+
else
|
|
60
|
+
[self, nil]
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def view
|
|
65
|
+
agents = []
|
|
66
|
+
@registry.each { |a| agents << a }
|
|
67
|
+
@renderer.render(grid: @grid, agents: agents, frame: @frame_cycle.frame)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def handle_key(message)
|
|
73
|
+
case message.to_s
|
|
74
|
+
when "q", "ctrl+c"
|
|
75
|
+
@watcher&.stop
|
|
76
|
+
[self, Bubbletea.quit]
|
|
77
|
+
else
|
|
78
|
+
[self, nil]
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def handle_tick
|
|
83
|
+
while (event = @event_queue.pop(true) rescue nil)
|
|
84
|
+
process_event(event)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
@frame_cycle.advance
|
|
88
|
+
|
|
89
|
+
[self, schedule_tick]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def process_event(event)
|
|
93
|
+
case event
|
|
94
|
+
when Transcript::AgentCreated
|
|
95
|
+
desk_pos = next_desk_position
|
|
96
|
+
@registry.add(event.session_id, desk_pos)
|
|
97
|
+
@grid.layout_for(@registry.count)
|
|
98
|
+
@pathfinder = Office::Pathfinder.new(@grid)
|
|
99
|
+
reassign_desk_positions
|
|
100
|
+
|
|
101
|
+
when Transcript::ToolStart
|
|
102
|
+
agent = @registry.get(event.session_id)
|
|
103
|
+
agent&.tool_started(event.tool_id, event.tool_name, event.status_text)
|
|
104
|
+
|
|
105
|
+
when Transcript::ToolDone
|
|
106
|
+
agent = @registry.get(event.session_id)
|
|
107
|
+
agent&.tool_done(event.tool_id)
|
|
108
|
+
|
|
109
|
+
when Transcript::TurnEnd
|
|
110
|
+
agent = @registry.get(event.session_id)
|
|
111
|
+
if agent
|
|
112
|
+
agent.turn_ended
|
|
113
|
+
@notification.agent_turn_ended(event.session_id)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
when Transcript::SubAgentToolStart
|
|
117
|
+
agent = @registry.get(event.session_id)
|
|
118
|
+
agent&.sub_agent_tool_started(
|
|
119
|
+
event.parent_tool_id, event.tool_id,
|
|
120
|
+
event.tool_name, event.status_text
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
when Transcript::SubAgentToolDone
|
|
124
|
+
agent = @registry.get(event.session_id)
|
|
125
|
+
agent&.sub_agent_tool_done(event.parent_tool_id, event.tool_id)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def next_desk_position
|
|
130
|
+
count = @registry.count
|
|
131
|
+
[3 + (count % 3) * 13, 3 + (count / 3) * 7]
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def reassign_desk_positions
|
|
135
|
+
index = 0
|
|
136
|
+
@registry.each do |agent|
|
|
137
|
+
if index < @grid.desks.length
|
|
138
|
+
desk = @grid.desks[index]
|
|
139
|
+
desk.assign(agent.session_id)
|
|
140
|
+
end
|
|
141
|
+
index += 1
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def schedule_tick
|
|
146
|
+
Bubbletea.tick(1.0 / FPS) { TickMessage.new }
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
require_relative "transcript/watcher"
|
|
2
|
+
|
|
3
|
+
module ClaudeOffice
|
|
4
|
+
class CLI
|
|
5
|
+
def self.run(args)
|
|
6
|
+
if args.include?("--version")
|
|
7
|
+
puts "claude-office #{ClaudeOffice::VERSION}"
|
|
8
|
+
return
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
if args.include?("--help")
|
|
12
|
+
puts <<~HELP
|
|
13
|
+
Usage: claude-office [PROJECT_PATH] [OPTIONS]
|
|
14
|
+
|
|
15
|
+
Watch Claude Code sessions as animated characters in a terminal office.
|
|
16
|
+
|
|
17
|
+
Arguments:
|
|
18
|
+
PROJECT_PATH Path to project (default: current directory)
|
|
19
|
+
|
|
20
|
+
Options:
|
|
21
|
+
--no-sound Disable terminal bell notifications
|
|
22
|
+
--version Show version
|
|
23
|
+
--help Show this help
|
|
24
|
+
HELP
|
|
25
|
+
return
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
project_path = args.reject { |a| a.start_with?("--") }.first || Dir.pwd
|
|
29
|
+
sound = !args.include?("--no-sound")
|
|
30
|
+
|
|
31
|
+
project_dir = Transcript::Watcher.claude_project_dir(project_path)
|
|
32
|
+
|
|
33
|
+
unless Dir.exist?(project_dir)
|
|
34
|
+
$stderr.puts "Error: No Claude Code data found for #{project_path}"
|
|
35
|
+
$stderr.puts "Expected: #{project_dir}"
|
|
36
|
+
$stderr.puts ""
|
|
37
|
+
$stderr.puts "Make sure you've run Claude Code in that directory at least once."
|
|
38
|
+
exit 1
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
require_relative "app"
|
|
42
|
+
|
|
43
|
+
app = App.new(project_dir: project_dir, sound: sound)
|
|
44
|
+
Bubbletea.run(app, alt_screen: true, mouse_cell_motion: true)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module ClaudeOffice
|
|
2
|
+
class Notification
|
|
3
|
+
def initialize(sound_enabled: true)
|
|
4
|
+
@sound_enabled = sound_enabled
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
def agent_waiting(session_id)
|
|
8
|
+
bell if @sound_enabled
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def agent_turn_ended(session_id)
|
|
12
|
+
bell if @sound_enabled
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def bell
|
|
18
|
+
print "\a"
|
|
19
|
+
$stdout.flush
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module ClaudeOffice
|
|
2
|
+
module Office
|
|
3
|
+
class Desk
|
|
4
|
+
attr_reader :position, :chair_position, :assigned_session_id
|
|
5
|
+
|
|
6
|
+
def initialize(position:, chair_position:)
|
|
7
|
+
@position = position
|
|
8
|
+
@chair_position = chair_position
|
|
9
|
+
@assigned_session_id = nil
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def assign(session_id)
|
|
13
|
+
@assigned_session_id = session_id
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def unassign
|
|
17
|
+
@assigned_session_id = nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def assigned?
|
|
21
|
+
!@assigned_session_id.nil?
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
require_relative "desk"
|
|
2
|
+
|
|
3
|
+
module ClaudeOffice
|
|
4
|
+
module Office
|
|
5
|
+
class Grid
|
|
6
|
+
DESKS_PER_ROW = 3
|
|
7
|
+
DESK_WIDTH = 7
|
|
8
|
+
DESK_HEIGHT = 3
|
|
9
|
+
DESK_SPACING_X = 6
|
|
10
|
+
DESK_SPACING_Y = 7
|
|
11
|
+
MARGIN_X = 3
|
|
12
|
+
MARGIN_Y = 2
|
|
13
|
+
|
|
14
|
+
attr_reader :width, :height, :desks, :tiles
|
|
15
|
+
|
|
16
|
+
def initialize(width:, height:)
|
|
17
|
+
@width = width
|
|
18
|
+
@height = height
|
|
19
|
+
@desks = []
|
|
20
|
+
@tiles = Array.new(height) { Array.new(width, :floor) }
|
|
21
|
+
place_walls
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def layout_for(agent_count)
|
|
25
|
+
@desks.clear
|
|
26
|
+
return if agent_count == 0
|
|
27
|
+
|
|
28
|
+
rows = (agent_count.to_f / DESKS_PER_ROW).ceil
|
|
29
|
+
agent_index = 0
|
|
30
|
+
|
|
31
|
+
rows.times do |row|
|
|
32
|
+
desks_in_row = [DESKS_PER_ROW, agent_count - agent_index].min
|
|
33
|
+
row_y = MARGIN_Y + 1 + row * DESK_SPACING_Y
|
|
34
|
+
|
|
35
|
+
desks_in_row.times do |col|
|
|
36
|
+
desk_x = MARGIN_X + 1 + col * (DESK_WIDTH + DESK_SPACING_X)
|
|
37
|
+
desk_pos = [desk_x, row_y]
|
|
38
|
+
chair_pos = [desk_x + DESK_WIDTH / 2, row_y + DESK_HEIGHT + 1]
|
|
39
|
+
|
|
40
|
+
@desks << Desk.new(position: desk_pos, chair_position: chair_pos)
|
|
41
|
+
agent_index += 1
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def walkable?(x, y)
|
|
47
|
+
return false if x < 0 || y < 0 || x >= @width || y >= @height
|
|
48
|
+
|
|
49
|
+
@tiles[y][x] == :floor
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def place_walls
|
|
55
|
+
@width.times do |x|
|
|
56
|
+
@tiles[0][x] = :wall
|
|
57
|
+
@tiles[@height - 1][x] = :wall
|
|
58
|
+
end
|
|
59
|
+
@height.times do |y|
|
|
60
|
+
@tiles[y][0] = :wall
|
|
61
|
+
@tiles[y][@width - 1] = :wall
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
module ClaudeOffice
|
|
2
|
+
module Office
|
|
3
|
+
class Pathfinder
|
|
4
|
+
DIRECTIONS = [[0, -1], [0, 1], [-1, 0], [1, 0]].freeze
|
|
5
|
+
|
|
6
|
+
def initialize(grid)
|
|
7
|
+
@grid = grid
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def find_path(start, goal)
|
|
11
|
+
return [start] if start == goal
|
|
12
|
+
return nil unless @grid.walkable?(start[0], start[1])
|
|
13
|
+
return nil unless @grid.walkable?(goal[0], goal[1])
|
|
14
|
+
|
|
15
|
+
queue = [start]
|
|
16
|
+
came_from = { start => nil }
|
|
17
|
+
|
|
18
|
+
until queue.empty?
|
|
19
|
+
current = queue.shift
|
|
20
|
+
|
|
21
|
+
if current == goal
|
|
22
|
+
return reconstruct_path(came_from, goal)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
DIRECTIONS.each do |dx, dy|
|
|
26
|
+
neighbor = [current[0] + dx, current[1] + dy]
|
|
27
|
+
next if came_from.key?(neighbor)
|
|
28
|
+
next unless @grid.walkable?(neighbor[0], neighbor[1])
|
|
29
|
+
|
|
30
|
+
came_from[neighbor] = current
|
|
31
|
+
queue << neighbor
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def reconstruct_path(came_from, goal)
|
|
41
|
+
path = [goal]
|
|
42
|
+
current = goal
|
|
43
|
+
|
|
44
|
+
while came_from[current]
|
|
45
|
+
current = came_from[current]
|
|
46
|
+
path.unshift(current)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
path
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
require "lipgloss"
|
|
2
|
+
require_relative "sprites"
|
|
3
|
+
require_relative "theme"
|
|
4
|
+
|
|
5
|
+
module ClaudeOffice
|
|
6
|
+
module Rendering
|
|
7
|
+
class Renderer
|
|
8
|
+
def initialize(width:, height:)
|
|
9
|
+
@width = width
|
|
10
|
+
@height = height
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def render(grid:, agents:, frame:)
|
|
14
|
+
title = render_title(grid)
|
|
15
|
+
office = render_office(grid, agents, frame)
|
|
16
|
+
status = render_status_bar(agents)
|
|
17
|
+
|
|
18
|
+
Lipgloss.join_vertical(:left, title, office, status)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def render_title(grid)
|
|
24
|
+
Theme::TITLE_STYLE
|
|
25
|
+
.width(@width)
|
|
26
|
+
.render("claude-office")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def render_office(grid, agents, frame)
|
|
30
|
+
lines = []
|
|
31
|
+
|
|
32
|
+
grid.height.times do |y|
|
|
33
|
+
line = ""
|
|
34
|
+
grid.width.times do |x|
|
|
35
|
+
case grid.tiles[y][x]
|
|
36
|
+
when :wall
|
|
37
|
+
line += Theme::WALL_STYLE.render(Sprites::WALL_CHAR)
|
|
38
|
+
else
|
|
39
|
+
line += Theme::FLOOR_STYLE.render(Sprites::FLOOR_CHAR)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
lines << line
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
office_str = lines.join("\n")
|
|
46
|
+
|
|
47
|
+
grid.desks.each do |desk|
|
|
48
|
+
desk_str = Sprites::DESK.map { |row| Theme::DESK_STYLE.render(row) }.join("\n")
|
|
49
|
+
office_str = overlay(office_str, desk_str, desk.position[0], desk.position[1])
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
agents.each do |agent|
|
|
53
|
+
face = Sprites.face_for(agent.animation, frame: frame)
|
|
54
|
+
style = Theme.agent_style(agent.state)
|
|
55
|
+
agent_str = style.render(face)
|
|
56
|
+
|
|
57
|
+
unless agent.status_text.empty?
|
|
58
|
+
status = Theme::STATUS_TEXT_STYLE.render("\"#{agent.status_text}\"")
|
|
59
|
+
agent_str = Lipgloss.join_vertical(:center, agent_str, status)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
agent.sub_agents.each_value do |sub|
|
|
63
|
+
sub_face = Sprites.sub_face_for(sub.animation)
|
|
64
|
+
sub_line = Theme::SUB_AGENT_STYLE.render("└─ #{sub_face}")
|
|
65
|
+
unless sub.status_text.empty?
|
|
66
|
+
sub_line += " " + Theme::STATUS_TEXT_STYLE.render("\"#{sub.status_text}\"")
|
|
67
|
+
end
|
|
68
|
+
agent_str = Lipgloss.join_vertical(:left, agent_str, sub_line)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
if agent.state == :waiting
|
|
72
|
+
bubble = Theme::SPEECH_BUBBLE_STYLE.render("Needs input!")
|
|
73
|
+
agent_str = Lipgloss.join_vertical(:center, bubble, agent_str)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
pos = agent.desk_position
|
|
77
|
+
char_x = pos[0] + 1
|
|
78
|
+
char_y = pos[1] + Sprites::DESK.length + 1
|
|
79
|
+
office_str = overlay(office_str, agent_str, char_x, char_y)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
office_str
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def render_status_bar(agents)
|
|
86
|
+
parts = agents.map do |agent|
|
|
87
|
+
state_str = agent.state.to_s
|
|
88
|
+
subs = agent.sub_agents.size
|
|
89
|
+
sub_info = subs > 0 ? " (#{subs} sub#{"s" if subs > 1})" : ""
|
|
90
|
+
"Agent: #{state_str}#{sub_info}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
parts << "q: quit"
|
|
94
|
+
bar_text = parts.join(" │ ")
|
|
95
|
+
|
|
96
|
+
Theme::STATUS_BAR_STYLE
|
|
97
|
+
.width(@width)
|
|
98
|
+
.render(bar_text)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def overlay(base, overlay_str, x, y)
|
|
102
|
+
base_lines = base.split("\n")
|
|
103
|
+
overlay_lines = overlay_str.split("\n")
|
|
104
|
+
|
|
105
|
+
overlay_lines.each_with_index do |oline, i|
|
|
106
|
+
target_y = y + i
|
|
107
|
+
next if target_y < 0 || target_y >= base_lines.length
|
|
108
|
+
|
|
109
|
+
base_line = base_lines[target_y]
|
|
110
|
+
visible_len = Lipgloss.width(oline)
|
|
111
|
+
|
|
112
|
+
before = base_line[0...x] || ""
|
|
113
|
+
after_start = x + visible_len
|
|
114
|
+
after = base_line[after_start..] || ""
|
|
115
|
+
base_lines[target_y] = before + oline + after
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
base_lines.join("\n")
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
module ClaudeOffice
|
|
2
|
+
module Rendering
|
|
3
|
+
module Sprites
|
|
4
|
+
FACES = {
|
|
5
|
+
idle: "(o_o)",
|
|
6
|
+
typing: "(o.o)~",
|
|
7
|
+
reading: "(o.O)",
|
|
8
|
+
running: "(>.<)",
|
|
9
|
+
waiting: "(-.-)zzZ",
|
|
10
|
+
walking: ["(o_o)/", "(o_o)\\"],
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
SUB_AGENT_FACES = {
|
|
14
|
+
idle: "(o_o)",
|
|
15
|
+
typing: "(o.o)",
|
|
16
|
+
reading: "(o.O)",
|
|
17
|
+
running: "(>.<)",
|
|
18
|
+
waiting: "(-.-)",
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
DESK = [
|
|
22
|
+
"┌─────┐",
|
|
23
|
+
"│ ▒▒▒ │",
|
|
24
|
+
"└──┬──┘",
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
27
|
+
FLOOR_CHAR = "░"
|
|
28
|
+
WALL_CHAR = "█"
|
|
29
|
+
|
|
30
|
+
def self.face_for(animation, frame: 0)
|
|
31
|
+
sprite = FACES.fetch(animation, FACES[:idle])
|
|
32
|
+
sprite.is_a?(Array) ? sprite[frame % sprite.length] : sprite
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.sub_face_for(animation)
|
|
36
|
+
SUB_AGENT_FACES.fetch(animation, SUB_AGENT_FACES[:idle])
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
require "lipgloss"
|
|
2
|
+
|
|
3
|
+
module ClaudeOffice
|
|
4
|
+
module Rendering
|
|
5
|
+
module Theme
|
|
6
|
+
FLOOR_STYLE = Lipgloss::Style.new
|
|
7
|
+
.foreground("#555555")
|
|
8
|
+
.background("#2D2D2D")
|
|
9
|
+
|
|
10
|
+
WALL_STYLE = Lipgloss::Style.new
|
|
11
|
+
.foreground("#777777")
|
|
12
|
+
.background("#444444")
|
|
13
|
+
|
|
14
|
+
DESK_STYLE = Lipgloss::Style.new
|
|
15
|
+
.foreground("#8B6914")
|
|
16
|
+
|
|
17
|
+
ACTIVE_AGENT_STYLE = Lipgloss::Style.new
|
|
18
|
+
.foreground("#00D4AA")
|
|
19
|
+
.bold(true)
|
|
20
|
+
|
|
21
|
+
WAITING_AGENT_STYLE = Lipgloss::Style.new
|
|
22
|
+
.foreground("#FFD700")
|
|
23
|
+
|
|
24
|
+
IDLE_AGENT_STYLE = Lipgloss::Style.new
|
|
25
|
+
.foreground("#AAAAAA")
|
|
26
|
+
|
|
27
|
+
STATUS_TEXT_STYLE = Lipgloss::Style.new
|
|
28
|
+
.foreground("#888888")
|
|
29
|
+
.italic(true)
|
|
30
|
+
|
|
31
|
+
SUB_AGENT_STYLE = Lipgloss::Style.new
|
|
32
|
+
.foreground("#77AADD")
|
|
33
|
+
|
|
34
|
+
SPEECH_BUBBLE_STYLE = Lipgloss::Style.new
|
|
35
|
+
.border(:rounded)
|
|
36
|
+
.border_foreground("#874BFD")
|
|
37
|
+
.padding(0, 1)
|
|
38
|
+
|
|
39
|
+
TITLE_STYLE = Lipgloss::Style.new
|
|
40
|
+
.bold(true)
|
|
41
|
+
.foreground("#FFFFFF")
|
|
42
|
+
.background("#333333")
|
|
43
|
+
.padding(0, 1)
|
|
44
|
+
|
|
45
|
+
STATUS_BAR_STYLE = Lipgloss::Style.new
|
|
46
|
+
.foreground("#CCCCCC")
|
|
47
|
+
.background("#333333")
|
|
48
|
+
.padding(0, 1)
|
|
49
|
+
|
|
50
|
+
def self.agent_style(state)
|
|
51
|
+
case state
|
|
52
|
+
when :working then ACTIVE_AGENT_STYLE
|
|
53
|
+
when :waiting then WAITING_AGENT_STYLE
|
|
54
|
+
else IDLE_AGENT_STYLE
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
module ClaudeOffice
|
|
2
|
+
module Transcript
|
|
3
|
+
class AgentCreated
|
|
4
|
+
attr_reader :session_id, :jsonl_path
|
|
5
|
+
|
|
6
|
+
def initialize(session_id:, jsonl_path:)
|
|
7
|
+
@session_id = session_id
|
|
8
|
+
@jsonl_path = jsonl_path
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class ToolStart
|
|
13
|
+
attr_reader :session_id, :tool_id, :tool_name, :status_text
|
|
14
|
+
|
|
15
|
+
def initialize(session_id:, tool_id:, tool_name:, status_text:)
|
|
16
|
+
@session_id = session_id
|
|
17
|
+
@tool_id = tool_id
|
|
18
|
+
@tool_name = tool_name
|
|
19
|
+
@status_text = status_text
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class ToolDone
|
|
24
|
+
attr_reader :session_id, :tool_id
|
|
25
|
+
|
|
26
|
+
def initialize(session_id:, tool_id:)
|
|
27
|
+
@session_id = session_id
|
|
28
|
+
@tool_id = tool_id
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class TurnEnd
|
|
33
|
+
attr_reader :session_id, :duration_ms
|
|
34
|
+
|
|
35
|
+
def initialize(session_id:, duration_ms:)
|
|
36
|
+
@session_id = session_id
|
|
37
|
+
@duration_ms = duration_ms
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
class SubAgentToolStart
|
|
42
|
+
attr_reader :session_id, :parent_tool_id, :tool_id, :tool_name, :status_text
|
|
43
|
+
|
|
44
|
+
def initialize(session_id:, parent_tool_id:, tool_id:, tool_name:, status_text:)
|
|
45
|
+
@session_id = session_id
|
|
46
|
+
@parent_tool_id = parent_tool_id
|
|
47
|
+
@tool_id = tool_id
|
|
48
|
+
@tool_name = tool_name
|
|
49
|
+
@status_text = status_text
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
class SubAgentToolDone
|
|
54
|
+
attr_reader :session_id, :parent_tool_id, :tool_id
|
|
55
|
+
|
|
56
|
+
def initialize(session_id:, parent_tool_id:, tool_id:)
|
|
57
|
+
@session_id = session_id
|
|
58
|
+
@parent_tool_id = parent_tool_id
|
|
59
|
+
@tool_id = tool_id
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
class TextOnly
|
|
64
|
+
attr_reader :session_id
|
|
65
|
+
|
|
66
|
+
def initialize(session_id:)
|
|
67
|
+
@session_id = session_id
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require_relative "events"
|
|
3
|
+
|
|
4
|
+
module ClaudeOffice
|
|
5
|
+
module Transcript
|
|
6
|
+
class Parser
|
|
7
|
+
BASH_COMMAND_MAX_LENGTH = 40
|
|
8
|
+
TASK_DESCRIPTION_MAX_LENGTH = 40
|
|
9
|
+
|
|
10
|
+
def initialize(session_id)
|
|
11
|
+
@session_id = session_id
|
|
12
|
+
@active_tools = {}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def register_active_tool(tool_id, tool_name)
|
|
16
|
+
@active_tools[tool_id] = tool_name
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def unregister_active_tool(tool_id)
|
|
20
|
+
@active_tools.delete(tool_id)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def parse_line(line)
|
|
24
|
+
record = JSON.parse(line)
|
|
25
|
+
type = record["type"]
|
|
26
|
+
|
|
27
|
+
case type
|
|
28
|
+
when "assistant"
|
|
29
|
+
parse_assistant(record)
|
|
30
|
+
when "user"
|
|
31
|
+
parse_user(record)
|
|
32
|
+
when "system"
|
|
33
|
+
parse_system(record)
|
|
34
|
+
when "progress"
|
|
35
|
+
parse_progress(record)
|
|
36
|
+
else
|
|
37
|
+
[]
|
|
38
|
+
end
|
|
39
|
+
rescue JSON::ParserError
|
|
40
|
+
[]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def parse_assistant(record)
|
|
46
|
+
content = record.dig("message", "content")
|
|
47
|
+
return [] unless content.is_a?(Array)
|
|
48
|
+
|
|
49
|
+
tool_uses = content.select { |b| b["type"] == "tool_use" }
|
|
50
|
+
|
|
51
|
+
if tool_uses.any?
|
|
52
|
+
tool_uses.map do |block|
|
|
53
|
+
tool_name = block["name"] || ""
|
|
54
|
+
input = block["input"] || {}
|
|
55
|
+
status = format_tool_status(tool_name, input)
|
|
56
|
+
|
|
57
|
+
register_active_tool(block["id"], tool_name)
|
|
58
|
+
|
|
59
|
+
ToolStart.new(
|
|
60
|
+
session_id: @session_id,
|
|
61
|
+
tool_id: block["id"],
|
|
62
|
+
tool_name: tool_name,
|
|
63
|
+
status_text: status
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
elsif content.any? { |b| b["type"] == "text" }
|
|
67
|
+
[TextOnly.new(session_id: @session_id)]
|
|
68
|
+
else
|
|
69
|
+
[]
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def parse_user(record)
|
|
74
|
+
content = record.dig("message", "content")
|
|
75
|
+
|
|
76
|
+
if content.is_a?(Array)
|
|
77
|
+
tool_results = content.select { |b| b["type"] == "tool_result" }
|
|
78
|
+
|
|
79
|
+
tool_results.map do |block|
|
|
80
|
+
tool_id = block["tool_use_id"]
|
|
81
|
+
unregister_active_tool(tool_id)
|
|
82
|
+
ToolDone.new(session_id: @session_id, tool_id: tool_id)
|
|
83
|
+
end
|
|
84
|
+
else
|
|
85
|
+
[]
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def parse_system(record)
|
|
90
|
+
return [] unless record["subtype"] == "turn_duration"
|
|
91
|
+
|
|
92
|
+
[TurnEnd.new(
|
|
93
|
+
session_id: @session_id,
|
|
94
|
+
duration_ms: record["durationMs"]
|
|
95
|
+
)]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def parse_progress(record)
|
|
99
|
+
parent_tool_id = record["parentToolUseID"]
|
|
100
|
+
return [] unless parent_tool_id
|
|
101
|
+
return [] unless @active_tools[parent_tool_id] == "Task"
|
|
102
|
+
|
|
103
|
+
data = record["data"]
|
|
104
|
+
return [] unless data.is_a?(Hash)
|
|
105
|
+
|
|
106
|
+
message = data.dig("message")
|
|
107
|
+
return [] unless message.is_a?(Hash)
|
|
108
|
+
|
|
109
|
+
msg_type = message["type"]
|
|
110
|
+
inner_content = message.dig("message", "content")
|
|
111
|
+
return [] unless inner_content.is_a?(Array)
|
|
112
|
+
|
|
113
|
+
case msg_type
|
|
114
|
+
when "assistant"
|
|
115
|
+
inner_content.select { |b| b["type"] == "tool_use" }.map do |block|
|
|
116
|
+
tool_name = block["name"] || ""
|
|
117
|
+
input = block["input"] || {}
|
|
118
|
+
status = format_tool_status(tool_name, input)
|
|
119
|
+
|
|
120
|
+
SubAgentToolStart.new(
|
|
121
|
+
session_id: @session_id,
|
|
122
|
+
parent_tool_id: parent_tool_id,
|
|
123
|
+
tool_id: block["id"],
|
|
124
|
+
tool_name: tool_name,
|
|
125
|
+
status_text: status
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
when "user"
|
|
129
|
+
inner_content.select { |b| b["type"] == "tool_result" }.map do |block|
|
|
130
|
+
SubAgentToolDone.new(
|
|
131
|
+
session_id: @session_id,
|
|
132
|
+
parent_tool_id: parent_tool_id,
|
|
133
|
+
tool_id: block["tool_use_id"]
|
|
134
|
+
)
|
|
135
|
+
end
|
|
136
|
+
else
|
|
137
|
+
[]
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def format_tool_status(tool_name, input)
|
|
142
|
+
case tool_name
|
|
143
|
+
when "Read"
|
|
144
|
+
"Reading #{basename(input["file_path"])}"
|
|
145
|
+
when "Edit"
|
|
146
|
+
"Editing #{basename(input["file_path"])}"
|
|
147
|
+
when "Write"
|
|
148
|
+
"Writing #{basename(input["file_path"])}"
|
|
149
|
+
when "Bash"
|
|
150
|
+
cmd = (input["command"] || "").to_s
|
|
151
|
+
truncated = cmd.length > BASH_COMMAND_MAX_LENGTH ? "#{cmd[0...BASH_COMMAND_MAX_LENGTH]}…" : cmd
|
|
152
|
+
"Running: #{truncated}"
|
|
153
|
+
when "Glob"
|
|
154
|
+
"Searching files"
|
|
155
|
+
when "Grep"
|
|
156
|
+
"Searching code"
|
|
157
|
+
when "WebFetch"
|
|
158
|
+
"Fetching web content"
|
|
159
|
+
when "WebSearch"
|
|
160
|
+
"Searching the web"
|
|
161
|
+
when "Task"
|
|
162
|
+
desc = (input["description"] || "").to_s
|
|
163
|
+
truncated = desc.length > TASK_DESCRIPTION_MAX_LENGTH ? "#{desc[0...TASK_DESCRIPTION_MAX_LENGTH]}…" : desc
|
|
164
|
+
truncated.empty? ? "Running subtask" : "Subtask: #{truncated}"
|
|
165
|
+
when "AskUserQuestion"
|
|
166
|
+
"Waiting for your answer"
|
|
167
|
+
when "EnterPlanMode"
|
|
168
|
+
"Planning"
|
|
169
|
+
when "NotebookEdit"
|
|
170
|
+
"Editing notebook"
|
|
171
|
+
else
|
|
172
|
+
"Using #{tool_name}"
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def basename(path)
|
|
177
|
+
return "" unless path.is_a?(String)
|
|
178
|
+
|
|
179
|
+
File.basename(path)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
require_relative "parser"
|
|
2
|
+
require_relative "events"
|
|
3
|
+
|
|
4
|
+
module ClaudeOffice
|
|
5
|
+
module Transcript
|
|
6
|
+
class Watcher
|
|
7
|
+
POLL_INTERVAL = 2.0
|
|
8
|
+
|
|
9
|
+
attr_reader :project_dir
|
|
10
|
+
|
|
11
|
+
def initialize(project_dir, event_queue)
|
|
12
|
+
@project_dir = project_dir
|
|
13
|
+
@event_queue = event_queue
|
|
14
|
+
@known_files = {}
|
|
15
|
+
@running = false
|
|
16
|
+
@thread = nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.project_slug(path)
|
|
20
|
+
path.gsub("/", "-")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.claude_project_dir(project_path)
|
|
24
|
+
slug = project_slug(File.expand_path(project_path))
|
|
25
|
+
File.join(Dir.home, ".claude", "projects", slug)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def start
|
|
29
|
+
@running = true
|
|
30
|
+
@thread = Thread.new { run_loop }
|
|
31
|
+
@thread.abort_on_exception = true
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def stop
|
|
35
|
+
@running = false
|
|
36
|
+
@thread&.join(2)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def scan_once
|
|
40
|
+
return unless Dir.exist?(@project_dir)
|
|
41
|
+
|
|
42
|
+
Dir.glob(File.join(@project_dir, "*.jsonl")).each do |path|
|
|
43
|
+
next if @known_files.key?(path)
|
|
44
|
+
|
|
45
|
+
session_id = File.basename(path, ".jsonl")
|
|
46
|
+
parser = Parser.new(session_id)
|
|
47
|
+
|
|
48
|
+
@known_files[path] = {
|
|
49
|
+
parser: parser,
|
|
50
|
+
offset: 0,
|
|
51
|
+
line_buffer: ""
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
@event_queue.push(AgentCreated.new(
|
|
55
|
+
session_id: session_id,
|
|
56
|
+
jsonl_path: path
|
|
57
|
+
))
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def read_new_lines
|
|
62
|
+
@known_files.each do |path, state|
|
|
63
|
+
read_file_lines(path, state)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def run_loop
|
|
70
|
+
while @running
|
|
71
|
+
scan_once
|
|
72
|
+
read_new_lines
|
|
73
|
+
sleep(POLL_INTERVAL)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def read_file_lines(path, state)
|
|
78
|
+
return unless File.exist?(path)
|
|
79
|
+
|
|
80
|
+
size = File.size(path)
|
|
81
|
+
return if size <= state[:offset]
|
|
82
|
+
|
|
83
|
+
data = File.binread(path, size - state[:offset], state[:offset])
|
|
84
|
+
state[:offset] = size
|
|
85
|
+
|
|
86
|
+
text = state[:line_buffer] + data.force_encoding("UTF-8")
|
|
87
|
+
lines = text.split("\n", -1)
|
|
88
|
+
state[:line_buffer] = lines.pop || ""
|
|
89
|
+
|
|
90
|
+
lines.each do |line|
|
|
91
|
+
next if line.strip.empty?
|
|
92
|
+
|
|
93
|
+
events = state[:parser].parse_line(line)
|
|
94
|
+
events.each { |e| @event_queue.push(e) }
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
require_relative "claude_office/version"
|
|
2
|
+
require_relative "claude_office/transcript/events"
|
|
3
|
+
require_relative "claude_office/transcript/parser"
|
|
4
|
+
require_relative "claude_office/transcript/watcher"
|
|
5
|
+
require_relative "claude_office/agents/agent"
|
|
6
|
+
require_relative "claude_office/agents/sub_agent"
|
|
7
|
+
require_relative "claude_office/agents/registry"
|
|
8
|
+
require_relative "claude_office/office/desk"
|
|
9
|
+
require_relative "claude_office/office/grid"
|
|
10
|
+
require_relative "claude_office/office/pathfinder"
|
|
11
|
+
require_relative "claude_office/rendering/sprites"
|
|
12
|
+
require_relative "claude_office/rendering/theme"
|
|
13
|
+
require_relative "claude_office/rendering/renderer"
|
|
14
|
+
require_relative "claude_office/animation/spring_mover"
|
|
15
|
+
require_relative "claude_office/animation/frame_cycle"
|
|
16
|
+
require_relative "claude_office/notification"
|
|
17
|
+
|
|
18
|
+
module ClaudeOffice
|
|
19
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: claude-office
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Fernando Ruiz
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: bubbletea
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.1'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: lipgloss
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0.2'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0.2'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: harmonica
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0.1'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0.1'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: rspec
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '3.13'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '3.13'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: rake
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '13.0'
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '13.0'
|
|
82
|
+
description: Watch Claude Code sessions come alive as kaomoji characters in a terminal
|
|
83
|
+
office. Characters walk, type, read, and wait based on real-time JSONL transcript
|
|
84
|
+
data.
|
|
85
|
+
email:
|
|
86
|
+
- fruizg0302@users.noreply.github.com
|
|
87
|
+
executables:
|
|
88
|
+
- claude-office
|
|
89
|
+
extensions: []
|
|
90
|
+
extra_rdoc_files: []
|
|
91
|
+
files:
|
|
92
|
+
- LICENSE
|
|
93
|
+
- bin/claude-office
|
|
94
|
+
- lib/claude_office.rb
|
|
95
|
+
- lib/claude_office/agents/agent.rb
|
|
96
|
+
- lib/claude_office/agents/registry.rb
|
|
97
|
+
- lib/claude_office/agents/sub_agent.rb
|
|
98
|
+
- lib/claude_office/animation/frame_cycle.rb
|
|
99
|
+
- lib/claude_office/animation/spring_mover.rb
|
|
100
|
+
- lib/claude_office/app.rb
|
|
101
|
+
- lib/claude_office/cli.rb
|
|
102
|
+
- lib/claude_office/notification.rb
|
|
103
|
+
- lib/claude_office/office/desk.rb
|
|
104
|
+
- lib/claude_office/office/grid.rb
|
|
105
|
+
- lib/claude_office/office/pathfinder.rb
|
|
106
|
+
- lib/claude_office/rendering/renderer.rb
|
|
107
|
+
- lib/claude_office/rendering/sprites.rb
|
|
108
|
+
- lib/claude_office/rendering/theme.rb
|
|
109
|
+
- lib/claude_office/transcript/events.rb
|
|
110
|
+
- lib/claude_office/transcript/parser.rb
|
|
111
|
+
- lib/claude_office/transcript/watcher.rb
|
|
112
|
+
- lib/claude_office/version.rb
|
|
113
|
+
homepage: https://github.com/fruizg0302/claude-office
|
|
114
|
+
licenses:
|
|
115
|
+
- MIT
|
|
116
|
+
metadata:
|
|
117
|
+
homepage_uri: https://github.com/fruizg0302/claude-office
|
|
118
|
+
source_code_uri: https://github.com/fruizg0302/claude-office
|
|
119
|
+
changelog_uri: https://github.com/fruizg0302/claude-office/blob/master/CHANGELOG.md
|
|
120
|
+
rubygems_mfa_required: 'true'
|
|
121
|
+
rdoc_options: []
|
|
122
|
+
require_paths:
|
|
123
|
+
- lib
|
|
124
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
125
|
+
requirements:
|
|
126
|
+
- - ">="
|
|
127
|
+
- !ruby/object:Gem::Version
|
|
128
|
+
version: 3.2.0
|
|
129
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
130
|
+
requirements:
|
|
131
|
+
- - ">="
|
|
132
|
+
- !ruby/object:Gem::Version
|
|
133
|
+
version: '0'
|
|
134
|
+
requirements: []
|
|
135
|
+
rubygems_version: 3.6.8
|
|
136
|
+
specification_version: 4
|
|
137
|
+
summary: A TUI companion for Claude Code — animated kaomoji agents in a virtual office
|
|
138
|
+
test_files: []
|