claude-inbox 0.6.0 → 0.6.1
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 +4 -4
- data/exe/claude-inbox +2 -125
- data/lib/claude_inbox/cli.rb +201 -0
- data/lib/claude_inbox/job_state.rb +10 -2
- data/lib/claude_inbox/remote/listener.rb +6 -7
- data/lib/claude_inbox/session.rb +4 -0
- data/lib/claude_inbox/store/row.rb +1 -1
- data/lib/claude_inbox.rb +1 -1
- data/mod/hooks/register.tsx +11 -5
- data/mod/tests/pane.test.tsx +32 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cea52c058efc847eede80d9633f507fb0bcc88352351d2e8df98d57d531cbd57
|
|
4
|
+
data.tar.gz: 73953113b090f01dfd646bbcb9295be96d2f6616ecccab116b9f75c2f43f69ed
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '080712923012ed2b164ce10dc6e4fbbf511319b7065ee54da07ffac2a6561deb62888f73012d8b7bf1ed3a78ae2d8fae078680fd4102a962bb4a05e345346c77'
|
|
7
|
+
data.tar.gz: dd9a7ca33067f30d0eb8cb98742bcc229cb981acfdfa0697966d59b43be9cf4c2451a6683378f6389b3226410261d615b9c5fb711df9c1019e659863ca76796c
|
data/exe/claude-inbox
CHANGED
|
@@ -2,129 +2,6 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require "claude_inbox"
|
|
5
|
+
require "claude_inbox/cli"
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
puts ClaudeInbox::VERSION
|
|
8
|
-
exit
|
|
9
|
-
end
|
|
10
|
-
|
|
11
|
-
require "claude_inbox/mod"
|
|
12
|
-
|
|
13
|
-
# The stable path to the Claude Code pane, for CLAUDE_CODE_PLUGIN_DIRS.
|
|
14
|
-
if ARGV.include?("--mod-dir")
|
|
15
|
-
puts ClaudeInbox::Mod.link(force: true)
|
|
16
|
-
exit
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
if ARGV.first == "install"
|
|
20
|
-
begin
|
|
21
|
-
installed = ClaudeInbox::Mod.install(force: ARGV.include?("--force"))
|
|
22
|
-
rescue JSON::ParserError
|
|
23
|
-
abort "claude-inbox: ~/.claude/settings.json is not valid JSON, so it was left alone"
|
|
24
|
-
end
|
|
25
|
-
installed.removed.each { |dir| puts "Removed #{dir} from ~/.claude/settings.json." }
|
|
26
|
-
puts "The pane now runs #{ClaudeInbox::Mod::COMMAND || "claude-inbox from your PATH"}." if installed.command_set
|
|
27
|
-
puts installed.added ? "Added the pane to ~/.claude/settings.json. New Claude Code sessions load it; /inbox opens it." :
|
|
28
|
-
"The pane is already in ~/.claude/settings.json."
|
|
29
|
-
exit
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
if ARGV.first == "update"
|
|
33
|
-
require "claude_inbox/updater"
|
|
34
|
-
updater = ClaudeInbox::Updater.new
|
|
35
|
-
begin
|
|
36
|
-
update = updater.check(force: true)
|
|
37
|
-
rescue ClaudeInbox::Updater::Error => e
|
|
38
|
-
abort "claude-inbox: #{e.message}"
|
|
39
|
-
end
|
|
40
|
-
unless update
|
|
41
|
-
puts((updater.kind == :gem) ? "claude-inbox #{ClaudeInbox::VERSION} is the latest release." : "main is up to date.")
|
|
42
|
-
exit
|
|
43
|
-
end
|
|
44
|
-
puts update.installing
|
|
45
|
-
applied = updater.apply
|
|
46
|
-
abort "claude-inbox: #{applied.output.strip}" unless applied.success?
|
|
47
|
-
puts((update.kind == :gem) ? "Installed claude-inbox #{update.latest}." : "main is at #{update.latest}.")
|
|
48
|
-
exit
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
require "claude_inbox/app"
|
|
52
|
-
require "claude_inbox/config"
|
|
53
|
-
require "claude_inbox/updater"
|
|
54
|
-
require "claude_inbox/writer_lock"
|
|
55
|
-
require "tmpdir"
|
|
56
|
-
|
|
57
|
-
fixture = ARGV.include?("--fixture")
|
|
58
|
-
# A fixture run takes the listen flags, for trying the API with curl, but
|
|
59
|
-
# not the environment or the config file, which are for the real inbox.
|
|
60
|
-
begin
|
|
61
|
-
argv = fixture ? ARGV : ClaudeInbox::Config.argv(ARGV)
|
|
62
|
-
listen = ClaudeInbox::Remote::Listener.options(argv, fixture ? {} : ENV)
|
|
63
|
-
rescue ArgumentError => e
|
|
64
|
-
abort "claude-inbox: #{e.message}"
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
# Crash traces go here instead of the alt screen when set.
|
|
68
|
-
$stderr.reopen(ENV["CLAUDE_INBOX_STDERR"], "a") if ENV["CLAUDE_INBOX_STDERR"]
|
|
69
|
-
|
|
70
|
-
opts = {}
|
|
71
|
-
if fixture
|
|
72
|
-
agents = argv[argv.index("--fixture") + 1]
|
|
73
|
-
fixtures = File.dirname(agents)
|
|
74
|
-
opts[:client] = ClaudeInbox::FixtureClient.new(agents, logs: File.binread(File.join(fixtures, "logs_raw.txt")))
|
|
75
|
-
opts[:store] = ClaudeInbox::Store.new(path: nil)
|
|
76
|
-
opts[:pull_requests] = ClaudeInbox::PullRequests.new(cache_path: File.join(fixtures, "gh-pr-status-cache.json"), resolved_path: nil, gh: nil)
|
|
77
|
-
# Its own token, lock and images, so a test inbox can neither rotate the
|
|
78
|
-
# token phones are paired with nor hold the real inbox's lock.
|
|
79
|
-
dir = File.join(Dir.tmpdir, "claude-inbox-fixture")
|
|
80
|
-
opts[:listen] = listen&.merge(fixture: true, pairing: ClaudeInbox::Remote::Pairing.new(path: File.join(dir, "listen.json")),
|
|
81
|
-
lock_path: File.join(dir, "listen.lock"), images_dir: File.join(dir, "images"))
|
|
82
|
-
else
|
|
83
|
-
# The only place the reaper is armed, and only with --reap: deleting
|
|
84
|
-
# sessions is opt-in. Everything else — tests, --fixture, anything
|
|
85
|
-
# embedding App — gets one that deletes nothing. The listener, likewise,
|
|
86
|
-
# comes only from this file, and only when asked for.
|
|
87
|
-
opts[:client] = ClaudeInbox::AgentsClient.new
|
|
88
|
-
opts[:store] = ClaudeInbox::Store.new
|
|
89
|
-
opts[:reaper] = argv.include?("--reap") ? ClaudeInbox::Reaper.new(opts[:client], opts[:store]) : ClaudeInbox::Reaper.disabled
|
|
90
|
-
opts[:snapshot] = ClaudeInbox::Snapshot.new
|
|
91
|
-
opts[:switch] = ClaudeInbox::Switch.new
|
|
92
|
-
opts[:actions] = ClaudeInbox::Actions.new
|
|
93
|
-
opts[:pane_offer] = ClaudeInbox::Mod::Offer.new
|
|
94
|
-
opts[:listen] = listen
|
|
95
|
-
opts[:updater] = ClaudeInbox::Updater.new unless argv.include?("--no-update-check")
|
|
96
|
-
# The typed arguments, not the config's: a relaunch reads the config again.
|
|
97
|
-
opts[:argv] = ARGV
|
|
98
|
-
end
|
|
99
|
-
opts[:color] = false if argv.include?("--no-color")
|
|
100
|
-
|
|
101
|
-
if argv.include?("--headless")
|
|
102
|
-
abort "claude-inbox: --headless takes no --fixture" if fixture
|
|
103
|
-
require "claude_inbox/headless"
|
|
104
|
-
exit(ClaudeInbox::Headless.new(client: opts[:client], store: opts[:store], updater: opts[:updater]).run ? 0 : 1)
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
# Only what a check within the day already found: a cold launch never waits
|
|
108
|
-
# on the network. The relaunch is the new version's launch; a failure leaves
|
|
109
|
-
# this one to start as usual.
|
|
110
|
-
if argv.include?("--auto-update") && (update = opts[:updater]&.cached)
|
|
111
|
-
puts update.installing
|
|
112
|
-
applied = opts[:updater].apply
|
|
113
|
-
applied.success? ? opts[:updater].relaunch(ARGV) : warn("claude-inbox: #{applied.output.strip}")
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
# The first inbox you can see is the snapshot's writer: a headless one gives
|
|
117
|
-
# way to it, and a second inbox reads along, writing and deleting nothing.
|
|
118
|
-
# Held for the life of this process by this reference.
|
|
119
|
-
writer_lock = fixture ? ClaudeInbox::WriterLock.disabled : ClaudeInbox::WriterLock.new
|
|
120
|
-
unless writer_lock.take(role: "inbox")
|
|
121
|
-
opts[:store] = ClaudeInbox::Store.new(writable: false, relay: ClaudeInbox::Actions.new)
|
|
122
|
-
opts[:reaper] = ClaudeInbox::Reaper.disabled
|
|
123
|
-
opts[:snapshot] = ClaudeInbox::Snapshot.disabled
|
|
124
|
-
opts[:actions] = ClaudeInbox::Actions.disabled
|
|
125
|
-
opts[:pane_offer] = ClaudeInbox::Mod::Offer.disabled
|
|
126
|
-
end
|
|
127
|
-
# So the pane settings.json names is the installed version's, after a gem update too.
|
|
128
|
-
ClaudeInbox::Mod.link unless fixture
|
|
129
|
-
|
|
130
|
-
ClaudeInbox::App.new(**opts).run
|
|
7
|
+
exit ClaudeInbox::CLI.new(ARGV).run
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
require_relative "app"
|
|
6
|
+
require_relative "config"
|
|
7
|
+
require_relative "headless"
|
|
8
|
+
require_relative "mod"
|
|
9
|
+
require_relative "updater"
|
|
10
|
+
require_relative "writer_lock"
|
|
11
|
+
|
|
12
|
+
module ClaudeInbox
|
|
13
|
+
# What `claude-inbox` does with its arguments: install the pane, update
|
|
14
|
+
# claude-inbox, or launch the inbox. `run` is true when it succeeded.
|
|
15
|
+
class CLI
|
|
16
|
+
USAGE = <<~USAGE
|
|
17
|
+
Usage: claude-inbox [options]
|
|
18
|
+
claude-inbox install [--force]
|
|
19
|
+
claude-inbox update
|
|
20
|
+
USAGE
|
|
21
|
+
|
|
22
|
+
# `lock` stands in for the writer lock a live launch takes.
|
|
23
|
+
def initialize(argv, env: ENV, config: Config::PATH, lock: nil, out: $stdout)
|
|
24
|
+
@argv = argv
|
|
25
|
+
@env = env
|
|
26
|
+
@config = config
|
|
27
|
+
@lock = lock
|
|
28
|
+
@out = out
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def run
|
|
32
|
+
case @argv.first
|
|
33
|
+
when "install" then install
|
|
34
|
+
when "update" then update
|
|
35
|
+
else launch
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The launch flags, the config file's before the typed ones. A fixture
|
|
40
|
+
# run takes the listen flags, for trying the API with curl, but not the
|
|
41
|
+
# environment or the config file, which are for the real inbox.
|
|
42
|
+
def options
|
|
43
|
+
@options ||= begin
|
|
44
|
+
typed = parse(@argv)
|
|
45
|
+
options = typed[:fixture] ? typed : parse(Config.argv(@argv, path: @config))
|
|
46
|
+
env = typed[:fixture] ? {} : @env
|
|
47
|
+
options.merge(listen: Remote::Listener.options(env, flag: options[:listen], modes: options[:allow_modes]))
|
|
48
|
+
end
|
|
49
|
+
rescue OptionParser::ParseError, ArgumentError => e
|
|
50
|
+
abort "claude-inbox: #{e.message}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The keywords App is built with. A live launch takes the writer lock
|
|
54
|
+
# here: the first inbox you can see is the snapshot's writer, and a
|
|
55
|
+
# second one reads along, writing and deleting nothing.
|
|
56
|
+
def app_options
|
|
57
|
+
opts = options[:fixture] ? fixture_options : live_options
|
|
58
|
+
opts[:color] = options[:color]
|
|
59
|
+
return opts if options[:fixture] || writer_lock.take(role: "inbox")
|
|
60
|
+
opts.merge(store: Store.new(writable: false, relay: Actions.new), reaper: Reaper.disabled,
|
|
61
|
+
snapshot: Snapshot.disabled, actions: Actions.disabled, pane_offer: Mod::Offer.disabled)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def install
|
|
67
|
+
force = false
|
|
68
|
+
subcommand("install") { |p| p.on("--force", "Replace any other copy of the pane") { force = true } }
|
|
69
|
+
begin
|
|
70
|
+
installed = Mod.install(force: force)
|
|
71
|
+
rescue JSON::ParserError
|
|
72
|
+
abort "claude-inbox: ~/.claude/settings.json is not valid JSON, so it was left alone"
|
|
73
|
+
end
|
|
74
|
+
installed.removed.each { |dir| @out.puts "Removed #{dir} from ~/.claude/settings.json." }
|
|
75
|
+
@out.puts "The pane now runs #{Mod::COMMAND || "claude-inbox from your PATH"}." if installed.command_set
|
|
76
|
+
@out.puts(installed.added ? "Added the pane to ~/.claude/settings.json. New Claude Code sessions load it; /inbox opens it." :
|
|
77
|
+
"The pane is already in ~/.claude/settings.json.")
|
|
78
|
+
true
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def update
|
|
82
|
+
subcommand("update")
|
|
83
|
+
updater = Updater.new
|
|
84
|
+
begin
|
|
85
|
+
update = updater.check(force: true)
|
|
86
|
+
rescue Updater::Error => e
|
|
87
|
+
abort "claude-inbox: #{e.message}"
|
|
88
|
+
end
|
|
89
|
+
unless update
|
|
90
|
+
@out.puts((updater.kind == :gem) ? "claude-inbox #{VERSION} is the latest release." : "main is up to date.")
|
|
91
|
+
return true
|
|
92
|
+
end
|
|
93
|
+
@out.puts update.installing
|
|
94
|
+
applied = updater.apply
|
|
95
|
+
abort "claude-inbox: #{applied.output.strip}" unless applied.success?
|
|
96
|
+
@out.puts((update.kind == :gem) ? "Installed claude-inbox #{update.latest}." : "main is at #{update.latest}.")
|
|
97
|
+
true
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def launch
|
|
101
|
+
options
|
|
102
|
+
# Crash traces go here instead of the alt screen when set.
|
|
103
|
+
$stderr.reopen(@env["CLAUDE_INBOX_STDERR"], "a") if @env["CLAUDE_INBOX_STDERR"]
|
|
104
|
+
return headless if options[:headless]
|
|
105
|
+
auto_update if options[:auto_update]
|
|
106
|
+
# So the pane settings.json names is the installed version's, after a gem update too.
|
|
107
|
+
Mod.link unless options[:fixture]
|
|
108
|
+
App.new(**app_options).run
|
|
109
|
+
true
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def headless
|
|
113
|
+
abort "claude-inbox: --headless takes no --fixture" if options[:fixture]
|
|
114
|
+
Headless.new(client: AgentsClient.new, store: Store.new, updater: updater).run
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Only what a check within the day already found: a cold launch never waits
|
|
118
|
+
# on the network. The relaunch is the new version's launch; a failure leaves
|
|
119
|
+
# this one to start as usual.
|
|
120
|
+
def auto_update
|
|
121
|
+
return unless (update = updater&.cached)
|
|
122
|
+
@out.puts update.installing
|
|
123
|
+
applied = updater.apply
|
|
124
|
+
applied.success? ? updater.relaunch(@argv) : warn("claude-inbox: #{applied.output.strip}")
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def fixture_options
|
|
128
|
+
fixtures = File.dirname(options[:fixture])
|
|
129
|
+
# Its own token, lock and images, so a test inbox can neither rotate the
|
|
130
|
+
# token phones are paired with nor hold the real inbox's lock.
|
|
131
|
+
dir = File.join(Dir.tmpdir, "claude-inbox-fixture")
|
|
132
|
+
{client: FixtureClient.new(options[:fixture], logs: File.binread(File.join(fixtures, "logs_raw.txt"))),
|
|
133
|
+
store: Store.new(path: nil),
|
|
134
|
+
pull_requests: PullRequests.new(cache_path: File.join(fixtures, "gh-pr-status-cache.json"), resolved_path: nil, gh: nil),
|
|
135
|
+
listen: options[:listen]&.merge(fixture: true, pairing: Remote::Pairing.new(path: File.join(dir, "listen.json")),
|
|
136
|
+
lock_path: File.join(dir, "listen.lock"), images_dir: File.join(dir, "images"))}
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# The only place the reaper is armed, and only with --reap: deleting
|
|
140
|
+
# sessions is opt-in. Everything else — tests, --fixture, anything
|
|
141
|
+
# embedding App — gets one that deletes nothing. The listener, likewise,
|
|
142
|
+
# comes only from here, and only when asked for.
|
|
143
|
+
def live_options
|
|
144
|
+
client = AgentsClient.new
|
|
145
|
+
store = Store.new
|
|
146
|
+
{client: client, store: store, reaper: options[:reap] ? Reaper.new(client, store) : Reaper.disabled,
|
|
147
|
+
snapshot: Snapshot.new, switch: Switch.new, actions: Actions.new, pane_offer: Mod::Offer.new,
|
|
148
|
+
listen: options[:listen], updater: updater,
|
|
149
|
+
# The typed arguments, not the config's: a relaunch reads the config again.
|
|
150
|
+
argv: @argv}
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def updater
|
|
154
|
+
@updater ||= Updater.new if options[:update_check] && !options[:fixture]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Held for the life of this process by this reference.
|
|
158
|
+
def writer_lock = (@lock ||= WriterLock.new)
|
|
159
|
+
|
|
160
|
+
def parse(argv)
|
|
161
|
+
opts = {color: true, update_check: true}
|
|
162
|
+
parser = OptionParser.new(USAGE) do |p|
|
|
163
|
+
p.on("--version", "Print the version") {
|
|
164
|
+
@out.puts VERSION
|
|
165
|
+
exit
|
|
166
|
+
}
|
|
167
|
+
p.on("--mod-dir", "Print the pane's directory, for CLAUDE_CODE_PLUGIN_DIRS") {
|
|
168
|
+
@out.puts Mod.link(force: true)
|
|
169
|
+
exit
|
|
170
|
+
}
|
|
171
|
+
p.on("--fixture PATH", "Read sessions from an agents.json instead of the daemon") { |path| opts[:fixture] = path }
|
|
172
|
+
p.on("--headless", "Keep the pane's snapshot fresh without a screen") { opts[:headless] = true }
|
|
173
|
+
p.on("--reap", "Delete background sessions idle 14 days") { opts[:reap] = true }
|
|
174
|
+
p.on("--no-color", "Draw without color") { opts[:color] = false }
|
|
175
|
+
p.on("--auto-update", "Install a release found today before launching") { opts[:auto_update] = true }
|
|
176
|
+
p.on("--no-update-check", "Never look for a newer release") { opts[:update_check] = false }
|
|
177
|
+
# --listen-lan wins over --listen, whichever came last.
|
|
178
|
+
p.on("--listen[=PORT]", "Let a phone on this Mac start sessions") { |port| opts[:listen] = {lan: false, port: port} unless opts.dig(:listen, :lan) }
|
|
179
|
+
p.on("--listen-lan[=PORT]", "Let a phone on your network start sessions") { |port| opts[:listen] = {lan: true, port: port} }
|
|
180
|
+
p.on("--listen-allow-modes=MODES", "The permission modes a phone may start in") { |modes| opts[:allow_modes] = modes }
|
|
181
|
+
end
|
|
182
|
+
strictly(parser, argv)
|
|
183
|
+
opts
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def subcommand(name)
|
|
187
|
+
parser = OptionParser.new(USAGE)
|
|
188
|
+
yield parser if block_given?
|
|
189
|
+
strictly(parser, @argv.drop(1))
|
|
190
|
+
rescue OptionParser::ParseError => e
|
|
191
|
+
abort "claude-inbox #{name}: #{e.message}"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Exact names only, so a typo is an error rather than the flag it abbreviates.
|
|
195
|
+
def strictly(parser, argv)
|
|
196
|
+
parser.require_exact = true
|
|
197
|
+
rest = parser.parse(argv)
|
|
198
|
+
raise OptionParser::InvalidArgument, rest.first if rest.any?
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "time"
|
|
4
5
|
|
|
5
6
|
module ClaudeInbox
|
|
6
7
|
# What the daemon writes about a background session in
|
|
@@ -16,7 +17,7 @@ module ClaudeInbox
|
|
|
16
17
|
# `output.result` what it produced once done. It also carries the PR links the daemon scanned out of the
|
|
17
18
|
# transcript, which PullRequests reads through here, the color `/color`
|
|
18
19
|
# set on the session, which `claude agents --json` drops, and `intent`, the
|
|
19
|
-
# prompt the session was started from.
|
|
20
|
+
# prompt the session was started from, and `createdAt`, when it was.
|
|
20
21
|
#
|
|
21
22
|
# Never cached. A color changes the moment you type `/color`, so every poll
|
|
22
23
|
# asks the file again.
|
|
@@ -64,7 +65,7 @@ module ClaudeInbox
|
|
|
64
65
|
nil
|
|
65
66
|
end
|
|
66
67
|
|
|
67
|
-
attr_reader :pr_urls, :color, :intent, :bridge_id
|
|
68
|
+
attr_reader :pr_urls, :color, :intent, :bridge_id, :created_at
|
|
68
69
|
|
|
69
70
|
def initialize(hash)
|
|
70
71
|
@detail = hash["detail"]
|
|
@@ -77,6 +78,7 @@ module ClaudeInbox
|
|
|
77
78
|
@color = hash["color"]
|
|
78
79
|
@intent = hash["intent"]
|
|
79
80
|
@bridge_id = hash["bridgeSessionId"]
|
|
81
|
+
@created_at = time(hash["createdAt"])
|
|
80
82
|
@flags = hash["respawnFlags"] || []
|
|
81
83
|
end
|
|
82
84
|
|
|
@@ -122,5 +124,11 @@ module ClaudeInbox
|
|
|
122
124
|
def count_label = "#{tasks} #{plural("task", tasks)}"
|
|
123
125
|
|
|
124
126
|
def plural(word, n) = (n == 1) ? word : "#{word}s"
|
|
127
|
+
|
|
128
|
+
def time(iso)
|
|
129
|
+
iso && Time.iso8601(iso)
|
|
130
|
+
rescue ArgumentError
|
|
131
|
+
nil
|
|
132
|
+
end
|
|
125
133
|
end
|
|
126
134
|
end
|
|
@@ -67,13 +67,12 @@ module ClaudeInbox
|
|
|
67
67
|
Outcome = Data.define(:at, :via, :result, :count)
|
|
68
68
|
|
|
69
69
|
# App's `listen:` from the flags, else the environment; nil when neither
|
|
70
|
-
# asks for it.
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
# asks for it. `flag` is the listen flag's `lan:` and `port:`, and
|
|
71
|
+
# `modes` --listen-allow-modes, both as typed.
|
|
72
|
+
def self.options(env, flag: nil, modes: nil)
|
|
73
73
|
lan, port =
|
|
74
|
-
if
|
|
75
|
-
flag
|
|
76
|
-
[!flag[1].nil?, flag[2]]
|
|
74
|
+
if flag
|
|
75
|
+
flag.values_at(:lan, :port)
|
|
77
76
|
elsif !(value = env["CLAUDE_INBOX_LISTEN"].to_s.strip).empty?
|
|
78
77
|
m = value.match(/\A(?:(lan)(?::(.*))?|(\d+))\z/i)
|
|
79
78
|
raise ArgumentError, "CLAUDE_INBOX_LISTEN is a port, lan or lan:PORT, not #{value}" unless m
|
|
@@ -81,7 +80,7 @@ module ClaudeInbox
|
|
|
81
80
|
else
|
|
82
81
|
return nil
|
|
83
82
|
end
|
|
84
|
-
modes
|
|
83
|
+
modes ||= env["CLAUDE_INBOX_LISTEN_ALLOW_MODES"]
|
|
85
84
|
{port: port_option(port), lan: lan, allowed_modes: modes_option(modes)}
|
|
86
85
|
end
|
|
87
86
|
|
data/lib/claude_inbox/session.rb
CHANGED
|
@@ -78,6 +78,10 @@ module ClaudeInbox
|
|
|
78
78
|
# From `/color`; interactive sessions have no job file, so never one.
|
|
79
79
|
def color = job_state&.color
|
|
80
80
|
|
|
81
|
+
# A respawn moves `started_at` to the new process; the job file keeps
|
|
82
|
+
# when the session itself began.
|
|
83
|
+
def created_at = job_state&.created_at || started_at
|
|
84
|
+
|
|
81
85
|
# The prompt this session was started with.
|
|
82
86
|
def intent = job_state&.intent
|
|
83
87
|
|
|
@@ -71,7 +71,7 @@ module ClaudeInbox
|
|
|
71
71
|
def settled?
|
|
72
72
|
return true if hand_settled?
|
|
73
73
|
return false if UNSETTLEABLE.include?(session.effective_state)
|
|
74
|
-
started = session.
|
|
74
|
+
started = session.created_at.to_i
|
|
75
75
|
ours = session.prs.select(&:known?).reject { |pr| pr.resolved_before?(started) }
|
|
76
76
|
ours.any? && ours.all? { |pr| pr.resolved_since?(started) }
|
|
77
77
|
end
|
data/lib/claude_inbox.rb
CHANGED
data/mod/hooks/register.tsx
CHANGED
|
@@ -197,14 +197,20 @@ async function requestSwitch($: EngineInterface, host: Host, id: string): Promis
|
|
|
197
197
|
})
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
//
|
|
200
|
+
// Every key that takes the person out of the pane, to another session or the browser, calls
|
|
201
|
+
// this first: the pane would keep the keys while they are away, and their typing on coming
|
|
202
|
+
// back would land in it. The engine has no blur: reopening without `focus` is the one way
|
|
203
|
+
// to give the keys to the prompt.
|
|
203
204
|
async function handBackKeys($: EngineInterface): Promise<void> {
|
|
204
205
|
await $.ui.close({ id: PANE })
|
|
205
206
|
await $.ui.open(OPEN)
|
|
206
207
|
}
|
|
207
208
|
|
|
209
|
+
async function openInBrowser($: EngineInterface, url: string): Promise<void> {
|
|
210
|
+
await handBackKeys($)
|
|
211
|
+
await $.process.run(['open', url])
|
|
212
|
+
}
|
|
213
|
+
|
|
208
214
|
// Only while no inbox writes the snapshot; the gem's writer lock turns a second one away.
|
|
209
215
|
function startHeadless($: EngineInterface, host: Host, now: number): void {
|
|
210
216
|
if (host.isHeadlessRunning || now - host.headlessStartedAt < RESTART_S) return
|
|
@@ -410,8 +416,8 @@ export const register: Register = (on, options) => {
|
|
|
410
416
|
$.ui.toast(copied.isCopied ? `copied: claude attach ${row.id}` : 'no clipboard here'),
|
|
411
417
|
)
|
|
412
418
|
}),
|
|
413
|
-
key('o', () => (row?.pr ? void
|
|
414
|
-
key('w', () => (row?.remote ? void
|
|
419
|
+
key('o', () => (row?.pr ? void openInBrowser($, row.pr.url) : $.ui.toast('no pull request linked'))),
|
|
420
|
+
key('w', () => (row?.remote ? void openInBrowser($, row.remote) : $.ui.toast('no claude.ai/code page for this session'))),
|
|
415
421
|
key('q', () => void $.ui.close({ id: PANE })),
|
|
416
422
|
]
|
|
417
423
|
|
data/mod/tests/pane.test.tsx
CHANGED
|
@@ -220,6 +220,38 @@ test('a switch hands the keys back to the prompt before it asks, so a return lan
|
|
|
220
220
|
await ui.unmount()
|
|
221
221
|
})
|
|
222
222
|
|
|
223
|
+
test('o and w hand the keys back to the prompt before they open the browser', async ($, on) => {
|
|
224
|
+
mock.env(on, { HOME: '/Users/me' })
|
|
225
|
+
mock.clock(on, { now: 1_700_000_000_000 })
|
|
226
|
+
const remote = { ...ROWS[1], remote: 'https://claude.ai/code/session_b2' }
|
|
227
|
+
on('state.get', ($, e, next) => (e.key === 'rows' ? seeded([remote]) : e.key === 'writtenAt' ? seeded(1_700_000_000) : next(e)))
|
|
228
|
+
const calls: string[] = []
|
|
229
|
+
on('ui.close', ($, e) => {
|
|
230
|
+
calls.push(`close ${e.id}`)
|
|
231
|
+
return { value: undefined }
|
|
232
|
+
})
|
|
233
|
+
on('ui.open', ($, e) => {
|
|
234
|
+
calls.push(`open ${e.id}${e.focus ? ' focused' : ''}`)
|
|
235
|
+
return { value: { isPlaced: true } }
|
|
236
|
+
})
|
|
237
|
+
on('process.run', ($, e) => {
|
|
238
|
+
calls.push(e.argv.join(' '))
|
|
239
|
+
return { value: { code: 0, stdout: '', stderr: '' } }
|
|
240
|
+
})
|
|
241
|
+
const ui = await $.ui.mount({ ...PANE, surface: 'terminal' })
|
|
242
|
+
await ui.press({ key: 'o' })
|
|
243
|
+
await ui.press({ key: 'w' })
|
|
244
|
+
expect(calls).toEqual([
|
|
245
|
+
'close inbox',
|
|
246
|
+
'open inbox',
|
|
247
|
+
'open https://github.com/o/r/pull/7',
|
|
248
|
+
'close inbox',
|
|
249
|
+
'open inbox',
|
|
250
|
+
'open https://claude.ai/code/session_b2',
|
|
251
|
+
])
|
|
252
|
+
await ui.unmount()
|
|
253
|
+
})
|
|
254
|
+
|
|
223
255
|
test('a click on a row puts the cursor on it before it asks for the switch', async ($, on) => {
|
|
224
256
|
mock.env(on, { HOME: '/Users/me' })
|
|
225
257
|
mock.clock(on, { now: 1_700_000_000_000 })
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: claude-inbox
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.6.
|
|
4
|
+
version: 0.6.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jordan Byron
|
|
@@ -108,6 +108,7 @@ files:
|
|
|
108
108
|
- lib/claude_inbox/actions.rb
|
|
109
109
|
- lib/claude_inbox/agents_client.rb
|
|
110
110
|
- lib/claude_inbox/app.rb
|
|
111
|
+
- lib/claude_inbox/cli.rb
|
|
111
112
|
- lib/claude_inbox/config.rb
|
|
112
113
|
- lib/claude_inbox/debug.rb
|
|
113
114
|
- lib/claude_inbox/dialog.rb
|