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
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,13 @@ This is the original mode. It tails a Codex session log under `~/.codex/sessions
This mode uses Codex Hooks instead of transcript tailing.

- One Slack thread per Codex `session_id`
- `SessionStart` creates the session thread root if needed
- `UserPromptSubmit` posts the user prompt into that thread
- The first `UserPromptSubmit` becomes the Slack thread root
- Later `UserPromptSubmit` events in the same session are posted as replies in that thread
- `SessionStart` is accepted but does not post a Slack message
- `PreToolUse` and `PostToolUse` can post Bash tool activity
- `Stop` posts `last_assistant_message` for the completed turn

This keeps all prompts and replies for the same Codex session in one Slack thread and does not require tailing a session log.
This keeps all prompts and replies for the same Codex session in one Slack thread and does not require tailing a session log. It also avoids a separate "hook started" root message.

## How It Works

Expand All @@ -53,7 +54,7 @@ This keeps all prompts and replies for the same Codex session in one Slack threa

1. Codex invokes `bin/codex-notify-hook` for configured hook events.
2. The hook command reads the JSON payload from standard input.
3. A per-session Slack thread is created and stored on first use.
3. The first `UserPromptSubmit` creates the per-session Slack thread and stores its thread timestamp.
4. Later hook events for the same `session_id` are posted into the same thread.

## Supported Behavior
Expand All @@ -66,8 +67,10 @@ This keeps all prompts and replies for the same Codex session in one Slack threa
- optional thread replies for `command_execution`, `file_change`, `web_search`, and other completed items
- Hook mode:
- one Slack thread per Codex session
- prompt, Bash tool activity, and final assistant messages posted from hook events
- the first user prompt becomes the thread root
- 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
- Shared:
- long payloads are split into safe chunks before posting
- `.env` loading via `dotenv`
Expand Down Expand Up @@ -305,6 +308,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.
- This executable pins `BUNDLE_GEMFILE` to its own project, so it can be launched from other repositories without resolving the wrong `Gemfile`.
- The hook implementation keeps normal successful runs quiet so Codex does not show extra debug-style output from the hook itself.

Expand Down
52 changes: 34 additions & 18 deletions lib/codex_notify/hook_runner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

module CodexNotify
class HookRunner
RESET_THREAD_PROMPT = '---'

EVENT_ALIASES = {
'userpromptsubmit' => 'UserPromptSubmit',
'pretooluse' => 'PreToolUse',
Expand Down Expand Up @@ -67,53 +69,67 @@ def thread_for(session_id)
@store.thread_ts_for(session_id)
end

def ensure_session_thread(payload)
def ensure_session_thread(payload, root_text: nil)
session_id = session_id_from(payload) || '__default__'
thread_ts = thread_for(session_id)
return [session_id, thread_ts] if thread_ts

cwd = cwd_from(payload)
title = @title || "Codex session: #{File.basename(cwd)}"
root_text = HookFormatter.session_root_text(
title: title,
cwd: cwd,
session_id: session_id,
user_name: @user_name
)
return [session_id, thread_ts, false] if thread_ts

return [session_id, nil, false] if root_text.nil? || root_text.to_s.empty?

response = @client.post(root_text)
thread_ts = response.fetch('ts').to_s
@store.save_thread_ts(session_id, thread_ts)
[session_id, thread_ts]
[session_id, thread_ts, true]
end

def handle_session_start(payload)
ensure_session_thread(payload)
end

def handle_user_prompt_submit(payload)
_session_id, thread_ts = ensure_session_thread(payload)
prompt = payload['prompt'] || payload.dig('payload', 'prompt')
unless prompt.nil? || prompt.to_s.empty?
@client.post(HookFormatter.prompt_text(user_name: @user_name, prompt: prompt), thread_ts: thread_ts)
return if prompt.nil? || prompt.to_s.empty?

session_id = session_id_from(payload) || '__default__'
if reset_thread_prompt?(prompt)
@store.clear_thread(session_id)
return
end

prompt_text = HookFormatter.prompt_text(user_name: @user_name, prompt: prompt)
_session_id, thread_ts, created = ensure_session_thread(payload, root_text: prompt_text)
return if thread_ts.nil?
return if created

@client.post(prompt_text, thread_ts: thread_ts)
end

def handle_pre_tool_use(payload)
_session_id, thread_ts = ensure_session_thread(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)
end

def handle_post_tool_use(payload)
_session_id, thread_ts = ensure_session_thread(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)
end

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

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)
end
end

def reset_thread_prompt?(prompt)
prompt.to_s.strip == RESET_THREAD_PROMPT
end
end
end
113 changes: 104 additions & 9 deletions test/test_hook_cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -63,22 +63,20 @@ def test_user_prompt_submit_reuses_same_thread_for_same_session
reply_posts = posts.select { |(_text, thread_ts)| !thread_ts.nil? }

assert_equal 1, root_posts.size
assert_equal 2, reply_posts.size
assert reply_posts.all? { |(_text, thread_ts)| thread_ts == '1000.01' }
assert_equal 1, reply_posts.size
assert_equal '1000.01', reply_posts.first.last
assert_includes root_posts.first.first, 'hello'
end
end

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

state_file = dir.join('state.json')
payload = {
'session_id' => 'session-123',
'cwd' => '/tmp/app',
'last_assistant_message' => 'done'
}
prompt_payload = { 'session_id' => 'session-123', 'cwd' => '/tmp/app', 'prompt' => 'hello' }
stop_payload = { 'session_id' => 'session-123', 'cwd' => '/tmp/app', 'last_assistant_message' => 'done' }

posts = []
original_new = CodexNotify::SlackClient.instance_method(:post)
Expand All @@ -90,9 +88,16 @@ def test_stop_posts_last_assistant_message
end

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

HookCLI.main(
['--env-file', 'missing.env', '--state-file', state_file.to_s, '--event', 'Stop'],
stdin: StringIO.new(JSON.generate(payload)),
stdin: StringIO.new(JSON.generate(stop_payload)),
stderr: StringIO.new,
stdout: StringIO.new
)
Expand All @@ -108,6 +113,96 @@ def test_stop_posts_last_assistant_message
end
end

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

state_file = dir.join('state.json')
payload = { 'session_id' => 'session-123', 'cwd' => '/tmp/app' }

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]
{ 'ok' => true, 'ts' => (thread_ts || '1000.01') }
end
end

begin
HookCLI.main(
['--env-file', 'missing.env', '--state-file', state_file.to_s, '--event', 'SessionStart'],
stdin: StringIO.new(JSON.generate(payload)),
stderr: StringIO.new,
stdout: StringIO.new
)
ensure
with_silenced_warnings do
CodexNotify::SlackClient.send(:define_method, :post, original_new)
end
end

assert_empty posts
end
end

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

state_file = dir.join('state.json')
session_payload = { 'session_id' => 'session-123', 'cwd' => '/tmp/app' }

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]
ts = thread_ts || (posts.count { |(_t, ts_value)| ts_value.nil? } == 1 ? '1000.01' : '2000.01')
{ 'ok' => true, 'ts' => ts }
end
end

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

HookCLI.main(
['--env-file', 'missing.env', '--state-file', state_file.to_s, '--event', 'UserPromptSubmit'],
stdin: StringIO.new(JSON.generate(session_payload.merge('prompt' => '---'))),
stderr: StringIO.new,
stdout: StringIO.new
)

HookCLI.main(
['--env-file', 'missing.env', '--state-file', state_file.to_s, '--event', 'UserPromptSubmit'],
stdin: StringIO.new(JSON.generate(session_payload.merge('prompt' => 'second prompt'))),
stderr: StringIO.new,
stdout: StringIO.new
)
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 2, root_posts.size
assert_equal 0, reply_posts.size
assert_includes root_posts[0].first, 'first prompt'
assert_includes root_posts[1].first, 'second prompt'
refute posts.any? { |(text, _thread_ts)| text.include?('---') }
end
end

private

def with_tmpdir
Expand Down
Loading