codex-notify 1.0.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.
Files changed (36) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +50 -0
  3. data/LICENSE +21 -0
  4. data/README.md +782 -0
  5. data/bin/codex-notify +6 -0
  6. data/bin/codex-notify-hook +6 -0
  7. data/lib/codex_notify/cli.rb +112 -0
  8. data/lib/codex_notify/config.rb +113 -0
  9. data/lib/codex_notify/config_diagnostics.rb +61 -0
  10. data/lib/codex_notify/config_migrator.rb +148 -0
  11. data/lib/codex_notify/config_support.rb +157 -0
  12. data/lib/codex_notify/destination_name.rb +18 -0
  13. data/lib/codex_notify/destination_resolver.rb +68 -0
  14. data/lib/codex_notify/durable_slack_publisher.rb +70 -0
  15. data/lib/codex_notify/env_source_loader.rb +91 -0
  16. data/lib/codex_notify/hook_cli.rb +90 -0
  17. data/lib/codex_notify/hook_config.rb +110 -0
  18. data/lib/codex_notify/hook_event.rb +16 -0
  19. data/lib/codex_notify/hook_formatter.rb +61 -0
  20. data/lib/codex_notify/hook_input_validator.rb +241 -0
  21. data/lib/codex_notify/hook_runner.rb +181 -0
  22. data/lib/codex_notify/hook_store.rb +133 -0
  23. data/lib/codex_notify/hook_thread_publisher.rb +107 -0
  24. data/lib/codex_notify/log_event_parser.rb +230 -0
  25. data/lib/codex_notify/message_formatter.rb +115 -0
  26. data/lib/codex_notify/outbox_commands.rb +43 -0
  27. data/lib/codex_notify/secret_protection.rb +37 -0
  28. data/lib/codex_notify/session_log.rb +63 -0
  29. data/lib/codex_notify/slack_client.rb +117 -0
  30. data/lib/codex_notify/slack_delivery_worker.rb +269 -0
  31. data/lib/codex_notify/slack_outbox.rb +206 -0
  32. data/lib/codex_notify/stream_processor.rb +141 -0
  33. data/lib/codex_notify/trusted_config_loader.rb +159 -0
  34. data/lib/codex_notify/version.rb +5 -0
  35. data/lib/codex_notify.rb +36 -0
  36. metadata +96 -0
data/README.md ADDED
@@ -0,0 +1,782 @@
1
+ # codex-notify
2
+
3
+ [![CI](https://github.com/koichiro/codex-notify/actions/workflows/ci.yml/badge.svg)](https://github.com/koichiro/codex-notify/actions/workflows/ci.yml)
4
+ [![Coverage](https://img.shields.io/badge/coverage-90%25-brightgreen)](#development)
5
+ [![Ruby](https://img.shields.io/badge/ruby-3.4%2B-red)](#development)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-yellow.svg)](./LICENSE)
7
+
8
+ `codex-notify` is a small Ruby CLI tool that posts compact Slack notifications from Codex.
9
+
10
+ It supports two modes:
11
+
12
+ - log tail mode, which tails Codex session log files
13
+ - hook mode, which posts directly from Codex Hooks
14
+
15
+ It is intended for lightweight run visibility without a separate service.
16
+
17
+ ## Purpose
18
+
19
+ - Send Codex activity to Slack without building a separate service
20
+ - Keep Slack notifications focused on prompts, responses, and optional tool activity
21
+ - Support both session-log tailing and Codex Hooks
22
+
23
+ ## Modes
24
+
25
+ ### Log Tail Mode
26
+
27
+ This is the original mode. It tails a Codex session log under `~/.codex/sessions` and posts updates as new items are appended.
28
+
29
+ ### Hook Mode
30
+
31
+ This mode uses Codex Hooks instead of transcript tailing.
32
+
33
+ - One Slack thread per Codex `session_id`
34
+ - The first `UserPromptSubmit` becomes the Slack thread root
35
+ - Later `UserPromptSubmit` events in the same session are posted as replies in that thread
36
+ - `SessionStart` does not post in normal mode and creates a diagnostic session root in debug mode
37
+ - `SessionStart.source = startup` and `SessionStart.source = clear` reset the saved Slack thread for that session
38
+ - `SessionStart.source = resume` keeps using the existing Slack thread
39
+ - `PermissionRequest` posts when Codex is waiting for approval
40
+ - `PreToolUse` and `PostToolUse` post Bash tool activity only in debug mode
41
+ - `Stop` posts `last_assistant_message` for the completed turn
42
+
43
+ This keeps all prompts and replies for the same Codex session in one Slack thread and does not require tailing a session log. Normal mode also avoids a separate "hook started" root message.
44
+
45
+ ## How It Works
46
+
47
+ ### Log Tail Mode
48
+
49
+ 1. The script loads configuration from the XDG config file, legacy `.env` files, environment variables, and CLI flags.
50
+ 2. It posts a small root Slack message showing that monitoring has started.
51
+ 3. It finds a Codex session log file under `~/.codex/sessions` or uses the file you specify.
52
+ 4. It tails that log from the end, so existing history is not reposted on startup.
53
+ 5. Each newly detected user prompt is posted as a new Slack thread root.
54
+ 6. Codex responses and optional tool events are posted into that prompt's thread.
55
+
56
+ ### Hook Mode
57
+
58
+ 1. Codex invokes `codex-notify-hook` for configured hook events.
59
+ 2. The hook command reads up to 1 MiB of JSON payload from standard input.
60
+ 3. The first `UserPromptSubmit` creates the per-session Slack thread and stores its thread timestamp.
61
+ 4. Later hook events for the same `session_id` are posted into the same thread.
62
+
63
+ ## Supported Behavior
64
+
65
+ - Log tail mode:
66
+ - one monitoring-start message with run title and working directory
67
+ - monitoring-start message also includes the configured user label and session ID
68
+ - one new Slack thread for each new user prompt
69
+ - thread replies for assistant responses and concise failure notices
70
+ - optional thread replies for `command_execution`, `file_change`, `web_search`, and other completed items
71
+ - Hook mode:
72
+ - one Slack thread per Codex session
73
+ - the first user prompt becomes the thread root
74
+ - long root events use their first chunk as the root and post the remaining chunks as replies
75
+ - user prompts, approval requests, and final assistant messages posted from hook events
76
+ - session starts and Bash tool activity posted only in debug mode
77
+ - local state file used to remember Slack thread timestamps across hook invocations
78
+ - a user prompt containing only `---` resets the current session thread without posting to Slack
79
+ - if a saved Slack thread timestamp becomes stale, the hook clears it, recreates the session thread, and retries the current event once
80
+ - Shared:
81
+ - long payloads are split with titles and formatting overhead included in the Slack-safe limit
82
+ - continuation chunks have an explicit `(cont.)` title; block-formatted chunks have balanced code fences
83
+ - outbound messages are persisted to a local outbox before the first Slack request
84
+ - HTTP 429 responses honor `Retry-After`; transient failures resume on a later invocation
85
+ - trusted XDG YAML configuration plus legacy `.env` loading via `dotenv`
86
+
87
+ ## Project Layout
88
+
89
+ ```text
90
+ .
91
+ ├── .codex/
92
+ │ └── hooks.json.example
93
+ ├── .env.sample
94
+ ├── .gitignore
95
+ ├── CHANGELOG.md
96
+ ├── README.md
97
+ ├── bin/
98
+ │ ├── codex-notify-hook
99
+ │ └── codex-notify
100
+ ├── Rakefile
101
+ ├── lib/
102
+ │ └── codex_notify/
103
+ │ ├── cli.rb
104
+ │ ├── config_support.rb
105
+ │ ├── hook_cli.rb
106
+ │ ├── hook_config.rb
107
+ │ ├── hook_formatter.rb
108
+ │ ├── hook_runner.rb
109
+ │ ├── hook_store.rb
110
+ │ └── slack_client.rb
111
+ └── test/
112
+ ├── test_cli.rb
113
+ └── test_hook_cli.rb
114
+ ```
115
+
116
+ ## Installation
117
+
118
+ codex-notify requires Ruby 3.4 or newer. Install the public gem into the Ruby
119
+ environment that will run Codex and its Hooks:
120
+
121
+ ```bash
122
+ gem install codex-notify
123
+ ```
124
+
125
+ The gem installs both commands. Confirm their locations and the installed
126
+ version from the same Ruby environment that will run them:
127
+
128
+ ```bash
129
+ command -v codex-notify
130
+ command -v codex-notify-hook
131
+ gem list --local --exact codex-notify
132
+ ruby -rcodex_notify -e 'puts CodexNotify::VERSION'
133
+ ```
134
+
135
+ To upgrade, update the gem in that Ruby environment:
136
+
137
+ ```bash
138
+ gem update codex-notify
139
+ ```
140
+
141
+ To uninstall it:
142
+
143
+ ```bash
144
+ gem uninstall codex-notify
145
+ ```
146
+
147
+ Installation, upgrades, and uninstallation do not rewrite Codex Hook
148
+ configuration, the XDG configuration file, Hook thread state, or the durable
149
+ outbox. Ruby managers such as rbenv and asdf normally keep separate gems for
150
+ each Ruby installation. After switching or upgrading Ruby, reinstall the gem,
151
+ rerun `command -v codex-notify-hook`, and update Hook configuration if the
152
+ absolute executable path changed.
153
+
154
+ Maintainers can install a locally built package for pre-release verification:
155
+
156
+ ```bash
157
+ gem install /path/to/codex-notify-X.Y.Z.gem
158
+ ```
159
+
160
+ This local-package path is for development and release validation; normal users
161
+ should install from RubyGems.org.
162
+
163
+ ## Configuration
164
+
165
+ Create the trusted user configuration under the XDG configuration directory. When
166
+ `XDG_CONFIG_HOME` is set, codex-notify uses
167
+ `$XDG_CONFIG_HOME/codex-notify/config.yml`; otherwise it uses
168
+ `~/.config/codex-notify/config.yml`.
169
+
170
+ ```bash
171
+ mkdir -p "${XDG_CONFIG_HOME:-$HOME/.config}/codex-notify"
172
+ touch "${XDG_CONFIG_HOME:-$HOME/.config}/codex-notify/config.yml"
173
+ chmod 600 "${XDG_CONFIG_HOME:-$HOME/.config}/codex-notify/config.yml"
174
+ ```
175
+
176
+ ```yaml
177
+ default_destination:
178
+ token: xoxb-your-token
179
+ channel: C0123456789
180
+
181
+ destinations:
182
+ PROJECT_A:
183
+ token: xoxb-project-a-token
184
+ channel: C1111111111
185
+ PROJECT_B:
186
+ channel: C2222222222 # reuse the default token
187
+ ```
188
+
189
+ The YAML schema intentionally has no version field. It accepts only
190
+ `env_policy`, `default_destination`, and `destinations`. Destination names are
191
+ normalized to uppercase and must contain only `A-Z`, `0-9`, and `_`.
192
+
193
+ Environment variables remain supported:
194
+
195
+ - `SLACK_BOT_TOKEN`: Slack bot token used for `chat.postMessage`
196
+ - `SLACK_CHANNEL`: Slack channel ID to receive the run thread
197
+ - `SLACK_BOT_TOKEN__NAME`: legacy-compatible Slack bot token for named destination `NAME`
198
+ - `SLACK_CHANNEL__NAME`: legacy-compatible required channel for named destination `NAME`
199
+ - `CODEX_NOTIFY_DESTINATION`: named Hook destination selected by a repository or process environment
200
+ - `CODEX_NOTIFY_ENV_POLICY`: repository env policy; defaults to `restricted`, with `legacy` retained temporarily as a trusted compatibility switch
201
+ - `CODEX_NOTIFY_USER_NAME`: Label used for user messages in Slack, default is the local system user
202
+ - `CODEX_PROMPT`: Optional initial prompt to post as a user message when monitoring begins
203
+ - `CODEX_NOTIFY_TITLE`: Optional title used for the root Slack message or hook session thread
204
+ - `CODEX_NOTIFY_MODE`: Hook notification mode, `normal` (default) or `debug`
205
+ - `CODEX_NOTIFY_OUTBOX_DIR`: optional private directory for durable pending Slack deliveries
206
+
207
+ Passing the token with `--token` is deprecated because command-line arguments can be exposed in process listings and shell history. Prefer the permission-restricted XDG config file or `SLACK_BOT_TOKEN` in the process environment. On systems with Unix permissions, codex-notify warns when a loaded config or env file is readable by group or other users.
208
+
209
+ Configuration is resolved in this order: explicit CLI values, process environment,
210
+ an explicit `--config PATH`, an explicit `--env-file PATH`, the default XDG
211
+ config file, an automatically discovered repository `.env`, and finally the
212
+ legacy codex-notify project-root `.env`. Explicit YAML and the default XDG file
213
+ are layered, so missing values in the explicit file may fall back to the default
214
+ file. The project-root `.env` remains supported during migration, but emits a
215
+ value-free deprecation warning when it supplies trusted credentials, profiles,
216
+ or the environment policy.
217
+
218
+ The XDG config path is never discovered relative to the current repository.
219
+ An explicitly set `XDG_CONFIG_HOME` must be absolute. YAML is loaded without
220
+ aliases, arbitrary Ruby objects, or symbols.
221
+
222
+ ### Migrating a legacy env file
223
+
224
+ When running from a source checkout, create the XDG YAML file explicitly from
225
+ the checkout-root `.env`:
226
+
227
+ ```bash
228
+ bundle exec bin/codex-notify --migrate-config --env-file .env
229
+ ```
230
+
231
+ Outside a source checkout, including an installed-gem execution, select the
232
+ legacy env file explicitly with `--env-file PATH`. The command never searches
233
+ the gem installation directory for an implicit migration source.
234
+
235
+ To select both paths explicitly:
236
+
237
+ ```bash
238
+ codex-notify --migrate-config \
239
+ --env-file /path/to/legacy.env \
240
+ --config /path/to/config.yml
241
+ ```
242
+
243
+ With the default XDG output path, an installed command can migrate an explicit
244
+ legacy file with:
245
+
246
+ ```bash
247
+ codex-notify --migrate-config --env-file /path/to/legacy.env
248
+ ```
249
+
250
+ The migration copies only `CODEX_NOTIFY_ENV_POLICY`, the default Slack token
251
+ and channel, and named destination tokens and channels. Routing and presentation
252
+ settings remain in the env file. Destination names are normalized and every
253
+ named destination must have its own channel.
254
+
255
+ The command creates the output with mode `0600` and refuses to overwrite an
256
+ existing file. It does not modify or delete the source env file and never prints
257
+ credential values. After reviewing the generated YAML and verifying routing in
258
+ a new Codex session, remove migrated secrets from the legacy file manually.
259
+
260
+ ## Usage
261
+
262
+ ### Log Tail Mode
263
+
264
+ `codex-notify` reads Codex session logs directly, so piping Codex output into this tool is not required.
265
+
266
+ Codex should still be started with `--no-alt-screen`, because that is the supported way to keep its execution output compatible with this workflow.
267
+
268
+ Start a new Codex run:
269
+
270
+ ```bash
271
+ codex --no-alt-screen
272
+ ```
273
+
274
+ Resume the previous Codex session:
275
+
276
+ ```bash
277
+ codex --no-alt-screen resume
278
+ ```
279
+
280
+ Run `codex-notify` separately:
281
+
282
+ ```bash
283
+ codex-notify
284
+ ```
285
+
286
+ Monitor a specific session file:
287
+
288
+ ```bash
289
+ codex-notify --session-file ~/.codex/sessions/2026/03/10/rollout-....jsonl
290
+ ```
291
+
292
+ Process the current contents once and exit:
293
+
294
+ ```bash
295
+ codex-notify --once
296
+ ```
297
+
298
+ In normal follow mode, `codex-notify` starts from the end of the session log and only posts prompts and responses appended after the monitor starts.
299
+
300
+ With explicit non-secret flags:
301
+
302
+ ```bash
303
+ codex-notify \
304
+ --channel "$SLACK_CHANNEL" \
305
+ --user-name "koichiro" \
306
+ --title "Codex run: my-project" \
307
+ --prompt "Investigate failing tests"
308
+ ```
309
+
310
+ Including tool events:
311
+
312
+ ```bash
313
+ codex-notify --include-tools
314
+ ```
315
+
316
+ Using a custom env file:
317
+
318
+ ```bash
319
+ codex-notify --env-file .env.local
320
+ ```
321
+
322
+ Using a custom sessions directory:
323
+
324
+ ```bash
325
+ codex-notify --sessions-dir ~/.codex/sessions
326
+ ```
327
+
328
+ Without `--no-alt-screen`, Codex switches to its alternate screen UI and the execution logs used by this tool are not emitted in the expected form.
329
+
330
+ ### Hook Mode
331
+
332
+ Codex Hooks can be used instead of session-log tailing.
333
+
334
+ 1. Enable hooks in `~/.codex/config.toml`.
335
+ 2. Place `codex-notify-hook` at a stable absolute path.
336
+ 3. Create `~/.codex/hooks.json` or `<repo>/.codex/hooks.json`.
337
+ 4. Set `SLACK_BOT_TOKEN` and `SLACK_CHANNEL`.
338
+ 5. Restart Codex, review and trust the hook definition, and run it normally.
339
+
340
+ Example `~/.codex/config.toml` addition:
341
+
342
+ ```toml
343
+ [features]
344
+ hooks = true
345
+ ```
346
+
347
+ Hooks are enabled by default in current Codex releases, so this setting is only needed if hooks were previously disabled. `codex_hooks` is a deprecated compatibility alias; use `hooks` for new configuration.
348
+
349
+ Discover the installed Hook executable from the active Ruby environment:
350
+
351
+ ```bash
352
+ command -v codex-notify-hook
353
+ ```
354
+
355
+ Confirm that the result is an absolute path and use it for every Hook command.
356
+ Codex runs hooks from the current project working directory, so relative paths
357
+ are fragile when one Hook definition is used across multiple repositories. The
358
+ examples below use `/absolute/path/to/codex-notify-hook` as a placeholder.
359
+
360
+ Example hook config:
361
+
362
+ ```json
363
+ {
364
+ "hooks": {
365
+ "SessionStart": [
366
+ {
367
+ "hooks": [
368
+ {
369
+ "type": "command",
370
+ "command": "/absolute/path/to/codex-notify-hook --event SessionStart"
371
+ }
372
+ ]
373
+ }
374
+ ],
375
+ "UserPromptSubmit": [
376
+ {
377
+ "hooks": [
378
+ {
379
+ "type": "command",
380
+ "command": "/absolute/path/to/codex-notify-hook --event UserPromptSubmit"
381
+ }
382
+ ]
383
+ }
384
+ ],
385
+ "PreToolUse": [
386
+ {
387
+ "matcher": "Bash",
388
+ "hooks": [
389
+ {
390
+ "type": "command",
391
+ "command": "/absolute/path/to/codex-notify-hook --event PreToolUse"
392
+ }
393
+ ]
394
+ }
395
+ ],
396
+ "PostToolUse": [
397
+ {
398
+ "matcher": "Bash",
399
+ "hooks": [
400
+ {
401
+ "type": "command",
402
+ "command": "/absolute/path/to/codex-notify-hook --event PostToolUse"
403
+ }
404
+ ]
405
+ }
406
+ ],
407
+ "PermissionRequest": [
408
+ {
409
+ "hooks": [
410
+ {
411
+ "type": "command",
412
+ "command": "/absolute/path/to/codex-notify-hook --event PermissionRequest"
413
+ }
414
+ ]
415
+ }
416
+ ],
417
+ "Stop": [
418
+ {
419
+ "hooks": [
420
+ {
421
+ "type": "command",
422
+ "command": "/absolute/path/to/codex-notify-hook --event Stop"
423
+ }
424
+ ]
425
+ }
426
+ ]
427
+ }
428
+ }
429
+ ```
430
+
431
+ The hook command reads each event payload from standard input:
432
+
433
+ ```bash
434
+ /absolute/path/to/codex-notify-hook --event UserPromptSubmit
435
+ ```
436
+
437
+ Useful options:
438
+
439
+ - `--title "Codex session: my-project"`: override the Slack thread title
440
+ - `--user-name "koichiro"`: override the user label
441
+ - `--state-file ~/.codex-notify-hook/state.json`: change where session thread mappings are stored
442
+ - `--mode normal|debug`: choose normal notifications or detailed debug notifications
443
+ - `--config PATH`: layer an explicit trusted YAML file above the default XDG config
444
+ - `--migrate-config`: create trusted YAML from the project-root or explicitly selected env file
445
+ - `--env-file .env.local`: load a different env file
446
+ - `--destination PROJECT_A`: select a trusted named Slack destination
447
+ - `--outbox-dir PATH`: override the durable delivery spool; repository `.env` files cannot set this in Hook mode
448
+ - `--outbox-status`: list delivery IDs and sanitized queue state without printing message text
449
+ - `--drain-outbox`: retry eligible queued deliveries without reading a Hook payload
450
+ - `--retry-outbox ID`: move one failed or acknowledgement-ambiguous delivery back to pending
451
+
452
+ `--token` remains available for compatibility but is deprecated. Prefer the
453
+ default or explicit `0600` YAML config file.
454
+
455
+ #### Migrating a checkout-based Hook
456
+
457
+ Existing Hook definitions may invoke a checkout path such as
458
+ `/path/to/codex-notify/bin/codex-notify-hook`. Migrate them without changing
459
+ their routing or state:
460
+
461
+ 1. Install the gem into the Ruby environment that will run the Hook.
462
+ 2. Run `command -v codex-notify-hook` and record the returned absolute path.
463
+ 3. Replace the checkout executable in every event under `~/.codex/hooks.json`
464
+ or `<repo>/.codex/hooks.json`. Preserve arguments such as `--event`,
465
+ `--destination`, `--state-file`, and `--outbox-dir`.
466
+ 4. Restart Codex, inspect the changed definition with `/hooks`, and trust it.
467
+ 5. Start a new Codex session and verify notification routing before retiring
468
+ the checkout path.
469
+
470
+ This path change does not move or rewrite the XDG `config.yml`, Hook thread
471
+ state, outbox, or Slack destination settings. If credentials still live in a
472
+ checkout `.env`, migrate them separately with
473
+ `--migrate-config --env-file PATH`; installed-gem execution never searches its
474
+ installation directory for a legacy `.env`.
475
+
476
+ ### Hook data and destination
477
+
478
+ The configured `SLACK_BOT_TOKEN` determines the Slack workspace, and `SLACK_CHANNEL` determines the default destination channel. Hook definitions are the allowlist: configure only the event types you intend to send. `codex-notify-hook` accepts only the following supported events and rejects other event names.
479
+
480
+ #### Named destination profiles
481
+
482
+ Named profiles let a repository select a preconfigured destination without storing Slack credentials or raw channel IDs in that repository. Define profiles in the trusted XDG YAML file:
483
+
484
+ ```yaml
485
+ default_destination:
486
+ token: xoxb-default-token
487
+ channel: C0000000000
488
+
489
+ destinations:
490
+ PROJECT_A:
491
+ token: xoxb-project-a-token
492
+ channel: C1111111111
493
+ PROJECT_B:
494
+ channel: C2222222222
495
+ ```
496
+
497
+ A repository may then select the profile in its `.env` without owning the credentials:
498
+
499
+ ```env
500
+ CODEX_NOTIFY_DESTINATION=PROJECT_A
501
+ CODEX_NOTIFY_TITLE=Project A
502
+ ```
503
+
504
+ Destination names are normalized to uppercase and must contain only `A-Z`, `0-9`, and `_`. A destination-specific token takes precedence over the default token, while a destination-specific channel is required. A named destination never falls back to the default channel; an unknown or incomplete profile exits with status `2` before reading the Hook payload, posting to Slack, or updating state.
505
+
506
+ Explicit `--token` and `--channel` values remain highest priority, although `--token` is deprecated. Profile definitions from an automatically discovered repository `.env` are always ignored; named profile credentials must come from trusted YAML, the process environment, an explicitly supplied `--env-file`, or the deprecated tool-root `.env`.
507
+
508
+ #### Repository env policy and migration
509
+
510
+ The default `restricted` policy treats an automatically discovered repository
511
+ `.env` as an untrusted routing and presentation source. It may provide only
512
+ `CODEX_NOTIFY_DESTINATION`, `CODEX_NOTIFY_TITLE`, `CODEX_NOTIFY_USER_NAME`, and
513
+ `CODEX_NOTIFY_MODE`. Raw Slack credentials, raw channels, profile definitions,
514
+ and `CODEX_NOTIFY_ENV_POLICY` are ignored. Diagnostics identify ignored Slack
515
+ key names and the source path without printing their values.
516
+
517
+ An explicitly supplied `--env-file PATH` remains an intentional trusted source
518
+ and may contain credentials. Process environment values, explicit CLI values,
519
+ and trusted YAML configuration also retain their documented precedence.
520
+
521
+ For temporary compatibility, trusted configuration may explicitly restore the
522
+ legacy repository credential behavior:
523
+
524
+ ```yaml
525
+ env_policy: legacy
526
+ ```
527
+
528
+ Legacy mode emits a prominent value-free warning on every invocation where a
529
+ repository token or raw channel is actually selected. A repository `.env`
530
+ cannot enable legacy mode itself. This escape hatch is temporary and is planned
531
+ for removal in a future major release.
532
+
533
+ Before migration:
534
+
535
+ ```env
536
+ # Repository .env
537
+ SLACK_BOT_TOKEN=xoxb-work-token
538
+ SLACK_CHANNEL=C1111111111
539
+ ```
540
+
541
+ After migration:
542
+
543
+ ```yaml
544
+ # $XDG_CONFIG_HOME/codex-notify/config.yml
545
+ destinations:
546
+ PROJECT_A:
547
+ token: xoxb-work-token
548
+ channel: C1111111111
549
+ ```
550
+
551
+ ```env
552
+ # Repository .env
553
+ CODEX_NOTIFY_DESTINATION=PROJECT_A
554
+ ```
555
+
556
+ Migration checklist:
557
+
558
+ 1. Run `--migrate-config`, or manually create a `0600` XDG `config.yml` and translate the trusted settings.
559
+ 2. Review the generated default destination, named destinations, and policy.
560
+ 3. Replace repository credentials with `CODEX_NOTIFY_DESTINATION=NAME`.
561
+ 4. Verify routing in a new Codex session.
562
+ 5. Remove trusted credentials and policy settings from the project-root `.env`.
563
+
564
+ Migration runs only when explicitly requested. codex-notify never deletes
565
+ credentials automatically.
566
+
567
+ | Hook event | Mode | Data sent to Slack |
568
+ | --- | --- | --- |
569
+ | `SessionStart` | debug only | working directory, user label, and session ID |
570
+ | `UserPromptSubmit` | normal and debug | user prompt |
571
+ | `PreToolUse` | debug only | Bash command/tool input |
572
+ | `PostToolUse` | debug only | exit code, command output, and stderr |
573
+ | `PermissionRequest` | normal and debug | tool name and approval description |
574
+ | `Stop` | normal and debug | final assistant message |
575
+
576
+ > **Security warning:** debug mode sends commands and their output to Slack. These values can contain credentials, environment variables, file contents, or other sensitive data. Enable debug mode only for channels and sessions where that disclosure is acceptable. Omit `PreToolUse` and `PostToolUse` from the Hook configuration when tool activity should never be sent.
577
+
578
+ Before each Slack API request, codex-notify applies best-effort redaction to both log-tail and Hook messages. It masks common secret-bearing keys (such as token, password, secret, API key, and Authorization), explicit secret CLI flags, and several well-known token formats. Redaction cannot recognize every arbitrary or encoded secret, so it is an additional safeguard rather than a substitute for limiting Hook types, avoiding sensitive debug sessions, and restricting the Slack destination.
579
+
580
+ ### Durable Slack delivery
581
+
582
+ Both modes format, chunk, and redact each logical notification before atomically
583
+ placing it in a private local outbox. The Slack transport uses 5-second connect,
584
+ 10-second write, and 20-second read timeouts. A normal invocation uses a
585
+ 10-second retry/sleep budget while draining eligible work; an in-progress HTTP
586
+ attempt remains governed by its separate timeouts. HTTP `429` waits use Slack's
587
+ `Retry-After` value; other definite transient failures use bounded exponential
588
+ backoff. Hook invocations remain quiet and return `0` after a notification is
589
+ durably queued, even when delivery is deferred to a later invocation.
590
+
591
+ Confirmed root timestamps and chunk progress are persisted, so a later process
592
+ continues from the first unconfirmed chunk. If a connection fails after a
593
+ request may have reached Slack, the acknowledgement is ambiguous: codex-notify
594
+ does not retry it again in the same invocation, permits at most three automatic
595
+ attempts across invocations, and then moves it to `needs-review`. Exactly-once
596
+ delivery is not guaranteed; one duplicate remains possible for each ambiguous
597
+ attempt. Definite failures are retried until delivered or manually handled.
598
+
599
+ The outbox never stores Slack tokens, Authorization headers, raw Hook payloads,
600
+ or raw JSONL events. It stores the final best-effort-redacted notification text,
601
+ which may still be sensitive. Its directories and files are created with modes
602
+ `0700` and `0600`. The queue is bounded to 10,000 non-delivered jobs and 64 MiB;
603
+ when full, it rejects new work with exit code `1` instead of evicting an existing
604
+ notification.
605
+
606
+ Hook mode defaults to `<state-file>.outbox`; log-tail mode defaults to
607
+ `~/.codex-notify/outbox`. Inspect or recover a queue with the same credentials
608
+ and path used by the normal command:
609
+
610
+ ```bash
611
+ /absolute/path/to/codex-notify-hook --outbox-status
612
+ /absolute/path/to/codex-notify-hook --drain-outbox
613
+ /absolute/path/to/codex-notify-hook --retry-outbox DELIVERY_ID
614
+ ```
615
+
616
+ Status output contains IDs, timestamps, statuses, and sanitized error codes,
617
+ but never notification text. A failed or `needs-review` job blocks newer work
618
+ for the same session until it is retried; other sessions may continue. Session
619
+ reset events advance a local generation and cancel older queued work so it
620
+ cannot attach to the new Slack thread.
621
+
622
+ ### Hook input contract
623
+
624
+ Hook input must be a non-empty JSON object. The event name may be supplied with
625
+ `--event` or by the payload's `hook_event_name` / `event` field. If more than one
626
+ source supplies an event name, the normalized names must agree. Supported event
627
+ names are `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`,
628
+ `PermissionRequest`, and `Stop`; the existing case-insensitive aliases with
629
+ spaces, `_`, or `-` are also accepted.
630
+
631
+ Hook input is limited to 1 MiB (1,048,576 bytes). Larger input is rejected before
632
+ JSON parsing, Slack posting, or state updates, with exit code `2`. The limit is
633
+ measured in bytes, including JSON syntax and multibyte string content.
634
+
635
+ Every event must include a non-empty string session ID as `session_id`,
636
+ `sessionId`, or `session.id`. Events without a valid session ID are rejected and
637
+ are never assigned to a shared default Slack thread.
638
+
639
+ Required event fields:
640
+
641
+ | Event | Required payload fields |
642
+ | --- | --- |
643
+ | `SessionStart` | non-empty string `source` |
644
+ | `UserPromptSubmit` | non-empty string `prompt` |
645
+ | `PreToolUse` | non-empty string `tool_name` and `tool_input` (legacy `command` is accepted) |
646
+ | `PostToolUse` | non-empty string `tool_name` and `tool_response` (legacy result fields are accepted) |
647
+ | `PermissionRequest` | non-empty string `tool_name` and object `tool_input` |
648
+ | `Stop` | string `last_assistant_message`; an empty string is a valid no-op |
649
+
650
+ The existing supported nested `payload` paths for prompts, permission requests,
651
+ tool results, and assistant messages remain accepted. Additional fields are
652
+ ignored for forward compatibility.
653
+
654
+ Hook command exit codes are:
655
+
656
+ - `0`: the event was handled, suppressed intentionally, delivered, or durably queued for retry
657
+ - `1`: a runtime failure occurred, such as a Slack or state-file error
658
+ - `2`: configuration or Hook input was invalid
659
+
660
+ Input errors write a concise `ERROR:` line to stderr without including the full
661
+ payload or credentials.
662
+
663
+ Notes:
664
+
665
+ - Hook config uses matcher groups. Each event contains an array of groups, and each group contains a `hooks` array of handlers.
666
+ - `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PermissionRequest`, and `Stop` are the event names.
667
+ - Normal mode posts user prompts, approval requests, and final assistant messages. Debug mode additionally posts session starts and tool activity.
668
+ - Current Codex releases require non-managed command hooks to be reviewed and trusted. In Codex CLI, use `/hooks` to inspect and trust a new or changed hook definition. Until it is trusted, Codex skips it.
669
+ - Current hook payloads provide Bash commands under `tool_input.command` and completed tool results under `tool_response`. Legacy payload shapes remain supported by `codex-notify-hook`.
670
+ - 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.
671
+ - 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.
672
+ - Installed executables load `codex_notify` through the active Ruby environment and do not depend on a checkout `Gemfile`, `.ruby-version`, or `lib/` path.
673
+ - The hook implementation keeps normal successful runs quiet so Codex does not show extra debug-style output from the hook itself.
674
+ - When using the macOS ChatGPT/Codex app, use an absolute hook command path and keep credentials in the XDG config file. GUI apps may not inherit the same `PATH` or environment variables as an interactive shell, so the default `~/.config/codex-notify/config.yml` path is usually the most predictable choice. The executable uses Ruby from its shebang, so verify that the GUI environment can resolve the intended Ruby. A Ruby-manager shim or an absolute gem executable path may change after a Ruby upgrade; reinstall the gem, rediscover the path, and update and retrust the Hook definition when necessary. Installed execution does not require checkout-local Bundler setup.
675
+
676
+ Hook mode does not require `--no-alt-screen`, because it does not depend on session-log tailing.
677
+
678
+ ## Development
679
+
680
+ Install development dependencies in a source checkout:
681
+
682
+ ```bash
683
+ bundle install
684
+ ```
685
+
686
+ Run tests:
687
+
688
+ ```bash
689
+ bundle exec rake
690
+ ```
691
+
692
+ `Rakefile` also loads `bundler/setup`, so `rake` can be run without `bundle
693
+ exec` after `bundle install`. The explicit form above makes it clear that these
694
+ are checkout development commands.
695
+
696
+ The test suite uses `minitest`, runs through `rake`, and enforces 90% line coverage for files under `lib/`.
697
+
698
+ Ruby 3.4 or newer is supported. CI covers the minimum supported 3.4 series and the 4.0 series used by the project's `.ruby-version`.
699
+
700
+ Run either executable from a source checkout through Bundler:
701
+
702
+ ```bash
703
+ bundle exec bin/codex-notify --help
704
+ bundle exec bin/codex-notify-hook --help
705
+ ```
706
+
707
+ These commands run the checkout code. In contrast, unqualified
708
+ `codex-notify` and `codex-notify-hook` run the versions installed in the active
709
+ Ruby environment. Checkout Bundler and `.ruby-version` behavior is not a
710
+ runtime requirement of the installed commands.
711
+
712
+ ### Gem packaging
713
+
714
+ The gem version is defined once as `CodexNotify::VERSION`. The initial package
715
+ uses version `1.0.0`. Releases follow Semantic Versioning, with compatibility
716
+ and migration details recorded in `CHANGELOG.md`.
717
+
718
+ Before preparing a package, update `CodexNotify::VERSION` in
719
+ `lib/codex_notify/version.rb`, then run the full tests. Build the gem locally,
720
+ inspect its packaged file list, and verify an isolated installation outside the
721
+ source checkout:
722
+
723
+ ```bash
724
+ bundle exec rake
725
+ bundle exec rake gem
726
+ bundle exec rake package:contents
727
+ bundle exec rake package:verify
728
+ ```
729
+
730
+ Maintainers are responsible for confirming that the package version and
731
+ metadata are correct, both executables pass the isolated verification, and the
732
+ file list contains only the intended `lib/`, `bin/`, CHANGELOG, README, and
733
+ license files. It must not contain `.env`, `.session`, `.bundle/`, `vendor/`,
734
+ Git metadata, tests, local state, tokens, channel IDs, payloads, or publish
735
+ credentials.
736
+
737
+ Build artifacts are written under `pkg/` and must not be committed. Tagging and
738
+ release notes should describe the validated version and compatibility impact.
739
+
740
+ ### Maintainer release workflow
741
+
742
+ RubyGems.org publication uses the `Release` GitHub Actions workflow. It is the
743
+ only supported publishing path; do not run `gem push` or `rake release` from a
744
+ development checkout. The Rake release tasks exist for the official RubyGems
745
+ release action and reject execution outside the approved workflow context.
746
+
747
+ Before the first release, configure a pending RubyGems Trusted Publisher with
748
+ these exact identifiers:
749
+
750
+ - GitHub owner: `koichiro`
751
+ - Repository: `codex-notify`
752
+ - Workflow filename: `release.yml`
753
+ - GitHub environment: `release`
754
+ - Gem name: `codex-notify`
755
+
756
+ Pending publishers expire 12 hours after creation. Create or recreate the
757
+ pending publisher only after all release checks are ready, then complete the
758
+ first publication within that window.
759
+
760
+ The GitHub `release` environment must allow deployments only from `main` and
761
+ should require maintainer approval. Do not add a RubyGems API key, password, or
762
+ credential file to the repository or environment; publication authenticates
763
+ with a short-lived GitHub Actions OIDC token.
764
+
765
+ To release, open **Actions**, select **Release**, choose **Run workflow** from
766
+ `main`, and enter the stable SemVer value already present in
767
+ `CodexNotify::VERSION`. The workflow verifies the input, gemspec, current
768
+ `main` commit, remote tag, RubyGems version state, test coverage, package
769
+ contents, and isolated installation before requesting approval for the
770
+ `release` environment. After approval it repeats the mutable checks, creates
771
+ the annotated `v<version>` tag, publishes the gem, and creates the matching
772
+ GitHub Release with the reviewed `.github/release-notes/v<version>.md`, the gem,
773
+ and its SHA-256 checksum.
774
+
775
+ A failed publishing job may be retried only when RubyGems.org does not contain
776
+ the requested version and an existing tag, if any, is annotated and points to
777
+ the exact same release commit. Never delete, move, or replace a published tag
778
+ or gem version. If RubyGems publication succeeded but GitHub Release creation
779
+ failed, do not rerun publication: verify the immutable tag and public gem, then
780
+ create or finish only the GitHub Release and record the public gem's SHA-256.
781
+ Conflicting tags, commits, versions, existing releases, or unavailable registry
782
+ state must be investigated and fixed forward with a new version when needed.