Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ This keeps all prompts and replies for the same Codex session in one Slack threa
- prompt replies, Bash tool activity, and final assistant messages posted from hook events
- local state file used to remember Slack thread timestamps across hook invocations
- a user prompt containing only `---` resets the current session thread without posting to Slack
- if a saved Slack thread timestamp becomes stale, the hook clears it, recreates the session thread, and retries the current event once
- Shared:
- long payloads are split into safe chunks before posting
- `.env` loading via `dotenv`
Expand Down Expand Up @@ -312,6 +313,7 @@ Notes:
- Hook config uses matcher groups. Each event contains an array of groups, and each group contains a `hooks` array of handlers.
- `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, and `Stop` are the event names.
- In hook mode, a prompt containing only `---` clears the saved Slack thread for that Codex session. The next user prompt starts a new Slack thread.
- If Slack rejects a saved `thread_ts` with a thread-not-found style error, hook mode now clears that saved value automatically and recreates the thread on the current event.
- This executable pins `BUNDLE_GEMFILE` to its own project, so it can be launched from other repositories without resolving the wrong `Gemfile`.
- If `rbenv` is installed, the executable also re-execs with the Ruby version declared in this project's `.ruby-version`, so another repository's `.ruby-version` does not take precedence.
- The hook implementation keeps normal successful runs quiet so Codex does not show extra debug-style output from the hook itself.
Expand Down
37 changes: 33 additions & 4 deletions lib/codex_notify/hook_runner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,13 @@ def thread_for(session_id)
@store.thread_ts_for(session_id)
end

def session_root_text(payload)
session_id = session_id_from(payload) || '__default__'
cwd = cwd_from(payload)
title = @title || "Codex session: #{File.basename(cwd)}"
HookFormatter.session_root_text(title:, cwd:, session_id:, user_name: @user_name)
end

def ensure_session_thread(payload, root_text: nil)
session_id = session_id_from(payload) || '__default__'
thread_ts = thread_for(session_id)
Expand All @@ -82,6 +89,28 @@ def ensure_session_thread(payload, root_text: nil)
[session_id, thread_ts, true]
end

def stale_thread_error?(error)
return false unless error.is_a?(SlackClient::Error)

%w[thread_not_found message_not_found invalid_ts].include?(error.error_code)
end

def post_with_thread_recovery(payload, text, fallback_root_text:)
session_id = session_id_from(payload) || '__default__'
thread_ts = thread_for(session_id)
return if thread_ts.nil?

@client.post(text, thread_ts: thread_ts)
rescue SlackClient::Error => e
raise unless stale_thread_error?(e)

@store.clear_thread(session_id)
_session_id, recovered_thread_ts, = ensure_session_thread(payload, root_text: fallback_root_text)
return if recovered_thread_ts.nil?

@client.post(text, thread_ts: recovered_thread_ts)
end

def handle_session_start(payload)
ensure_session_thread(payload)
end
Expand All @@ -101,21 +130,21 @@ def handle_user_prompt_submit(payload)
return if thread_ts.nil?
return if created

@client.post(prompt_text, thread_ts: thread_ts)
post_with_thread_recovery(payload, prompt_text, fallback_root_text: prompt_text)
end

def handle_pre_tool_use(payload)
_session_id, thread_ts, = ensure_session_thread(payload)
return if thread_ts.nil?

@client.post(HookFormatter.format_pre_tool(payload), thread_ts: thread_ts)
post_with_thread_recovery(payload, HookFormatter.format_pre_tool(payload), fallback_root_text: session_root_text(payload))
end

def handle_post_tool_use(payload)
_session_id, thread_ts, = ensure_session_thread(payload)
return if thread_ts.nil?

@client.post(HookFormatter.format_post_tool(payload), thread_ts: thread_ts)
post_with_thread_recovery(payload, HookFormatter.format_post_tool(payload), fallback_root_text: session_root_text(payload))
end

def handle_stop(payload)
Expand All @@ -124,7 +153,7 @@ def handle_stop(payload)

message = payload['last_assistant_message'] || payload.dig('payload', 'last_assistant_message')
unless message.nil? || message.to_s.empty?
@client.post(HookFormatter.assistant_text(message), thread_ts: thread_ts)
post_with_thread_recovery(payload, HookFormatter.assistant_text(message), fallback_root_text: session_root_text(payload))
end
end

Expand Down
14 changes: 13 additions & 1 deletion lib/codex_notify/slack_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@

module CodexNotify
class SlackClient
class Error < StandardError
attr_reader :response, :error_code

def initialize(message, response: nil, error_code: nil)
super(message)
@response = response
@error_code = error_code
end
end

SLACK_API = 'https://slack.com/api/chat.postMessage'

def initialize(token:, channel:)
Expand All @@ -28,7 +38,9 @@ def post(text, thread_ts: nil)
end

parsed = JSON.parse(response.body)
raise "Slack API error: #{parsed}" unless parsed['ok']
unless parsed['ok']
raise Error.new("Slack API error: #{parsed}", response: parsed, error_code: parsed['error'])
end

parsed
end
Expand Down
2 changes: 2 additions & 0 deletions session
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
codex resume 019d3d3a-4034-7570-9843-6189a0fb2749

99 changes: 99 additions & 0 deletions test/test_hook_cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,105 @@ def test_reset_marker_clears_thread_without_posting
end
end

def test_user_prompt_submit_recovers_when_saved_thread_ts_is_stale
with_tmpdir do |dir|
ENV['SLACK_BOT_TOKEN'] = 'xoxb-token'
ENV['SLACK_CHANNEL'] = 'C123'

state_file = dir.join('state.json')
state_file.write(JSON.generate({ 'threads' => { 'session-123' => 'stale-ts' } }))
payload = { 'session_id' => 'session-123', 'cwd' => '/tmp/app', 'prompt' => 'recovered prompt' }

posts = []
original_new = CodexNotify::SlackClient.instance_method(:post)
with_silenced_warnings do
CodexNotify::SlackClient.send(:define_method, :post) do |text, thread_ts: nil|
posts << [text, thread_ts]
if thread_ts == 'stale-ts'
raise CodexNotify::SlackClient::Error.new('Slack API error', error_code: 'thread_not_found')
end

{ 'ok' => true, 'ts' => (thread_ts || '2000.01') }
end
end

begin
exit_code = HookCLI.main(
['--env-file', 'missing.env', '--state-file', state_file.to_s, '--event', 'UserPromptSubmit'],
stdin: StringIO.new(JSON.generate(payload)),
stderr: StringIO.new,
stdout: StringIO.new
)

assert_equal 0, exit_code
ensure
with_silenced_warnings do
CodexNotify::SlackClient.send(:define_method, :post, original_new)
end
end

root_posts = posts.select { |(_text, thread_ts)| thread_ts.nil? }
reply_posts = posts.select { |(_text, thread_ts)| !thread_ts.nil? }

assert_equal 1, root_posts.size
assert_equal 2, reply_posts.size
assert_equal 'stale-ts', reply_posts.first.last
assert_equal '2000.01', reply_posts.last.last
assert_includes root_posts.first.first, 'recovered prompt'
assert_equal '2000.01', JSON.parse(state_file.read).dig('threads', 'session-123')
end
end

def test_stop_recovers_when_saved_thread_ts_is_stale
with_tmpdir do |dir|
ENV['SLACK_BOT_TOKEN'] = 'xoxb-token'
ENV['SLACK_CHANNEL'] = 'C123'

state_file = dir.join('state.json')
state_file.write(JSON.generate({ 'threads' => { 'session-123' => 'stale-ts' } }))
payload = { 'session_id' => 'session-123', 'cwd' => '/tmp/app', 'last_assistant_message' => 'done' }

posts = []
original_new = CodexNotify::SlackClient.instance_method(:post)
with_silenced_warnings do
CodexNotify::SlackClient.send(:define_method, :post) do |text, thread_ts: nil|
posts << [text, thread_ts]
if thread_ts == 'stale-ts'
raise CodexNotify::SlackClient::Error.new('Slack API error', error_code: 'thread_not_found')
end

{ 'ok' => true, 'ts' => (thread_ts || '3000.01') }
end
end

begin
exit_code = HookCLI.main(
['--env-file', 'missing.env', '--state-file', state_file.to_s, '--event', 'Stop'],
stdin: StringIO.new(JSON.generate(payload)),
stderr: StringIO.new,
stdout: StringIO.new
)

assert_equal 0, exit_code
ensure
with_silenced_warnings do
CodexNotify::SlackClient.send(:define_method, :post, original_new)
end
end

root_posts = posts.select { |(_text, thread_ts)| thread_ts.nil? }
reply_posts = posts.select { |(_text, thread_ts)| !thread_ts.nil? }

assert_equal 1, root_posts.size
assert_equal 2, reply_posts.size
assert_equal 'stale-ts', reply_posts.first.last
assert_equal '3000.01', reply_posts.last.last
assert_includes root_posts.first.first, 'Codex hook notification started.'
assert_includes reply_posts.last.first, 'done'
assert_equal '3000.01', JSON.parse(state_file.read).dig('threads', 'session-123')
end
end

private

def with_tmpdir
Expand Down
Loading