Repository navigation
Description
Problem Statement
I often have several thirdshift Runs going at once, across several machines: mostly DigitalOcean Linux servers, sometimes my MacBook. The only way to know that a Run has finished, and whether it ended ready for review, merged or as a Failed run, is to keep checking each terminal window. That uses up attention I want for other work.
I also repeat the same choices on every command. If I want every Run on a machine to be a Merge run, I have to remember merge every time. The session log location is fixed with no way to change it.
Solution
A Run can send a Run notification: an email, sent when the Run ends whatever its outcome, that names the issue, the outcome, the pull request and the machine it ran on. I ask for one with --email (or the bare word email), optionally followed by the address to send to. Without an address, it goes to the default address in my User config.
The User config is a TOML file at ~/.thirdshift/config.toml, next to the session logs thirdshift already writes to ~/.thirdshift/logs/. It sets per-machine defaults:
- the default notification address and sender
- whether every Run sends a notification without the flag
- whether every Run is a Merge run without the
mergeword - where session logs go
--no-email and --no-merge override the "always" settings for a single Run. With no User config, or one that says nothing about a setting, a Run does exactly what its command asks, as today.
Emails go through Resend's HTTP API, using the key in the RESEND_API_KEY environment variable, so the same setup works on DigitalOcean (which blocks outbound SMTP ports) and on a MacBook (see ADR 0005). The config never holds the key, so it can be version controlled. thirdshift email-test [<address>] sends a test notification so I can check my setup without starting a Run.
User Stories
- As a user running several Runs at once, I want an email when a Run ends, so that I don't have to keep checking terminal windows.
- As a user, I want to ask for a Run notification with
--email, so that I only get emails for Runs I care about. - As a user, I want the bare word
emailto work like--email, the waymergeand--mergeboth work, so that I don't have to remember which form to use. - As a user, I want to type an address right after
--email(e.g.--email jacob@stephens.page), so that I can send one Run's notification somewhere other than my default. - As a user, I want
--emailwith no address to use my User config's default address, so that I don't retype my address every time. - As a user, I want to type the address without brackets or
=, so that the command is natural to type and doesn't trip zsh's glob matching. - As a user, I want the email flag to work before or after the Issue URL, and before or after
merge, so that I don't have to remember an order. - As a user, I want a Run notification when the Run ends ready for review, so that I know a pull request is waiting for me.
- As a user, I want a Run notification when a Merge run ends merged, so that I know the work has shipped.
- As a user, I want a Run notification when a Run ends as a Failed run, so that I can look into it promptly.
- As a user, I want a Run notification when a Run is interrupted, including by a closed terminal or SIGTERM, so that a Run that died while I was away doesn't go unnoticed.
- As a user, I want the subject to show the repository, issue number, issue title and outcome (e.g.
[thirdshift] acme/widgets#123 Add export button: ready for review), so that I can triage from my inbox without opening the email. - As a user, I want the body to include the pull request URL when there is one, so that I can go straight to it.
- As a user, I want the body to include the failure cause for a Failed run, so that I know what went wrong before I get back to the machine.
- As a user, I want the body to include the session log path, so that I know where to look on the machine.
- As a user who runs thirdshift on several machines, I want the body to include the hostname, so that I know which machine the Run was on.
- As a user, I want the body to include how long the Run took, so that I get a feel for how long Runs take.
- As a user, I want a User config at
~/.thirdshift/config.toml, next to the existing logs folder, so that everything thirdshift owns is in one place. - As a user, I want to set a default notification address (
email.to) in the User config, so that--emailalone is enough. - As a user, I want to set
email.always = true, so that every Run on that machine sends a Run notification without the flag. - As a user, I want to set
merge.always = true, so that every Run on that machine is a Merge run without themergeword. - As a user whose config says
email.always = true, I want--no-email(orno-email) to skip the notification for one Run, so that I can opt out without editing the config. - As a user whose config says
merge.always = true, I want--no-merge(orno-merge) to make one Run stop at ready for review, so that a change I want a human to review can't self-merge. - As a user with no User config, I want Runs to behave exactly as they do today, so that the feature changes nothing until I opt in.
- As a user with a User config that doesn't mention email, I want no notification unless I pass the flag, so that defaults stay conservative.
- As a user with a User config that doesn't mention merging, I want a Run to be a Merge run only when I pass
merge, so that nothing self-merges unexpectedly. - As a user, I want to set the sender with
email.from, so that I can send from an address on my verified Resend domain. - As a user without a verified Resend domain, I want
email.fromto default toonboarding@resend.dev, so that notifications to my own Resend account address work with onlyemail.toand the API key set. - As a user, I want the Resend API key read only from
RESEND_API_KEY, never from the config file, so that I can version control my config safely. - As a user, I want to set where session logs are written with
logs.dir, defaulting to~/.thirdshift/logs, so that I can put logs somewhere else on a given machine. - As a user, I want a leading
~inlogs.direxpanded to my home folder, so that I can write paths the way I would in the shell. - As a user, I want a relative
logs.dirrejected with a clear error, so that logs don't end up in whatever directory I happened to launch from. - As a user who asked for a Run notification with no address given and no
email.toconfigured, I want the Run to stop at preflight before any work, so that I don't find out hours later that no email was coming. - As a user who asked for a Run notification without
RESEND_API_KEYset, I want the Run to stop at preflight with a message naming the variable, so that I can fix it immediately. - As a user, I want a failed send (network error, bad key, Resend error) to be a warning on stderr that never changes the Run's outcome or exit code, so that a notification problem can't make a good pull request look like a failure.
- As a user, I want the warning for a failed send to include Resend's error, so that I can fix my setup.
- As a user, I want a config file that can't be parsed to stop the Run with an error naming the file, so that a broken config is never silently ignored.
- As a user, I want an unknown key or section (e.g.
alway = true,[emial]) to stop the Run with an error naming it, so that a typo inmerge.alwayscan't silently change whether my Runs self-merge. - As a user, I want
update,versionandhelpto never read the User config, so that a broken config can't stop me from updating or getting help. - As a user setting up notifications, I want
thirdshift email-test, so that I can check that email works without starting a Run. - As a user, I want
thirdshift email-test <address>to send to that address, andthirdshift email-testto send to my configuredemail.to, so that it behaves like--email. - As a user, I want
email-testto go through the same checks and sending code as a Run notification, so that a passing test means real notifications will work. - As a user, I want the test email to be clearly labelled as a test and show the hostname, the time and the sender used, so that I can tell it apart from a real notification.
- As a user, I want
email-testto print "accepted by Resend; check your inbox" and exit 0 when the send succeeds, so that I know what was and wasn't verified. - As a user, I want
email-testto print Resend's error word for word and exit non-zero when the send fails, so that I can fix my setup. - As a user, I want the help text to list the email flags,
--no-mergeandemail-test, and mention the User config, so that I can find these features. - As a user, I want the README to document the User config's keys,
RESEND_API_KEYand theonboarding@resend.devrestriction, so that I can set up notifications without reading the source. - As a user, I want contradictory arguments such as
--emailwith--no-email, ormergewith--no-merge, rejected as argument errors, so that my intent is never guessed. - As a user, I want a repeated flag rejected as an argument error, so that a mistyped command doesn't quietly do something other than I meant.
- As a user on my MacBook, I want notifications to work the same as on Linux, so that it doesn't matter which machine I start a Run on.
Implementation Decisions
-
Domain terms. Run notification and User config are defined in
CONTEXT.md. Merge run now includes a Run made a Merge run by the User config. Use these terms in code, help text and messages. -
Delivery (ADR 0005). Notifications are sent with a single HTTPS POST to Resend's send-email endpoint: a bearer token from
RESEND_API_KEY, and a JSON body withfrom,to,subjectand a plain-text body. Nomail/sendmail, no SMTP. The HTTP client isreqwestover rustls, already in the dependency tree through the updater. Nothing may pull in OpenSSL (ADR 0003). The send has a bounded timeout (around 30 seconds) so an unreachable API can't hang the end of a Run. -
Resend endpoint override. The Resend base URL can be overridden with the environment variable
THIRDSHIFT_RESEND_URL, for tests only, like the existingTHIRDSHIFT_POLL_MSandTHIRDSHIFT_CI_GRACE_MS. It is not documented for users. -
User config module. A new module loads
~/.thirdshift/config.toml(under$HOME) into a typed settings value, with all fields optional:[email]:to(string),from(string, defaultonboarding@resend.dev),always(bool, default false)[merge]:always(bool, default false)[logs]:dir(string, default~/.thirdshift/logs)
A missing file is the same as an empty one. A file that can't be parsed, or that has an unknown key or section, is an error naming the file and the key. A leading
~inlogs.diris expanded to$HOME, and a relativelogs.diris an error. Parsing uses thetomlcrate with serde derive, denying unknown fields. -
When the config is read. It is read at the start of a Run and by
email-test, before any work.update,versionandhelpnever read it. A config error is reported like a preflight failure, before the worktree exists. -
Argument parsing. A Run accepts, in any order around the Issue URL:
merge/--mergeemail/--email, optionally followed by an address. The next token is the address only if it contains@and doesn't start withhttps://.no-email/--no-emailno-merge/--no-merge
Each can appear at most once.
mergewithno-merge, oremailwithno-email, is an argument error (exit 2, with the help text, as today). Any other extra token remains an argument error. -
Resolving what a Run does. Command flags beat the User config:
- The Run is a Merge run if
mergewas given, ormerge.alwaysis true andno-mergewas not given. - It sends a Run notification if
emailwas given, oremail.alwaysis true andno-emailwas not given. - The address is the flag's address, else
email.to.
- The Run is a Merge run if
-
Preflight for notifications. When a Run will send a notification, preflight checks locally, before any other work, that an address is known and
RESEND_API_KEYis set and not empty. There is no network call to validate the key. A failed check stops the Run before any work, with a message naming what's missing, and sends nothing. -
When the notification is sent. Once arguments, config and the notification preflight are accepted, every way the Run ends sends exactly one Run notification: ready for review, merged, Failed run (including later preflight failures such as an Origin match mismatch) and interrupted. It is sent after the Run's outcome is final and its cleanup is done, just before the process exits. Its result never changes the exit code. A failed send prints a warning with Resend's error on stderr.
-
Notification content.
- Subject:
[thirdshift] <owner>/<repo>#<n> <issue title>: <outcome>, where the outcome isready for review,mergedorfailed, orinterruptedfor an interruption. If the issue title isn't known (e.g. the Run failed before fetching it), it is left out. - Plain-text body: pull request URL (if any), failure cause (if failed), session log path (if any), hostname, and how long the Run took.
- Subject:
-
Log location. The session log path is built from the resolved
logs.dirinstead of the hard-coded~/.thirdshift/logs. The filename scheme is unchanged. -
email-testcommand.thirdshift email-test [<address>]reads the User config, resolves the address the same way (argument, elseemail.to), runs the same notification preflight, and sends through the same sending code. The subject is marked as a test, and the body shows the hostname, time and sender. On success it prints "accepted by Resend; check your inbox" and exits 0. On failure it prints the error (Resend's text verbatim) and exits non-zero. -
Hostname. Read from the OS (
libc::gethostname, already a dependency), falling back to "unknown host". -
Help and README. Help lists the new forms. The README documents the User config keys with an example,
RESEND_API_KEY, theonboarding@resend.devlimitation, andemail-test.
Testing Decisions
- What makes a good test here. Tests exercise the compiled binary from outside, as a user would: the command line, the User config file, environment variables, the email that reaches the Resend stand-in, stdout/stderr and the exit code. They never call internal functions or assert on internal types.
- Seam 1 (existing): the end-to-end harness that runs the compiled
thirdshiftagainst a temp$HOME, real git, a bare origin, and the fakeghandclaudeexecutables. The User config is aconfig.tomlthe test writes to the scenario'shome/.thirdshift/. It gets a helper for writing the config. - Seam 2 (new): a Resend stand-in. The harness starts a small local HTTP server, using only the standard library, and points
THIRDSHIFT_RESEND_URLat it. It records each request (method, path, authorization header, JSON body) and replies with a status and body the test chooses. Tests assert on recorded requests, or on there being none. - Behaviour to cover:
- A Run ready for review, a Merge run that merges, a Failed run and an interrupted Run each send exactly one notification, with the right subject, PR URL, failure cause, log path and hostname.
- A Run without the flag and without
email.alwayssends nothing. email.alwayssends without the flag, and--no-emailsuppresses it.merge.alwaysmakes a Merge run, and--no-mergesuppresses it.- An address after the flag wins over
email.to, and a bare flag usesemail.to. Every bare-word and--form works, in any position. - Contradictory or repeated flags are argument errors with exit 2.
- A missing address or a missing
RESEND_API_KEYstops the Run at preflight: noclaudesession, noghcalls that change anything, no request to the stand-in. - A stand-in reply of 401 or 500 leaves the Run's outcome and exit code unchanged and prints a warning with Resend's message.
- An unparsable config, an unknown key or section, and a relative
logs.direach stop the Run before any work.update,versionandhelpstill succeed with a broken config. logs.dir(with~) puts session logs there.email-testsucceeds (exit 0 and the "accepted by Resend" line), fails (non-zero exit with Resend's error verbatim), uses the argument overemail.to, and fails the preflight checks.
- Prior art:
tests/command_surface.rs: help text, argument errors and exit codestests/fresh_run.rs,tests/merge_run.rs,tests/failed_run.rs: Run outcomes through the fakestests/preflight.rs: stopping before any worktests/update.rs: a command that runs outside a Runtests/support/mod.rs: the harness the stand-in and config helper extend
Out of Scope
- Email providers other than Resend, including Amazon SES (SigV4 signing) and SMTP. The config is shaped so a provider can be added later.
- Push or desktop notifications (ntfy, Pushover, OS notifications) and a user-defined "run this command at the end of a Run" hook.
- Reading the Resend API key from the config file.
- A network call at preflight to validate the key.
- Multiple recipients, HTML email and templating.
- A
$THIRDSHIFT_CONFIGoverride or an XDG location for the User config. - Shell tab completion.
Further Notes
- Decided in a grilling session; decisions are recorded in
CONTEXT.md(Run notification, User config, updated Merge run) anddocs/adr/0005-run-notifications-through-resend.md. - The evidence behind the delivery choice (no mail server on fresh Ubuntu/Rocky, blocked ports on DigitalOcean and the major clouds, macOS Postfix with no relay) is in
docs/research/run-notifications-email.md. - Resend accepting a message doesn't prove it was delivered. Neither a Run nor
email-testcan know more than "accepted".
Activity
JacobStephens2 commentedon Oct 2, 2026
Closing: all five Tickets (#111 to #115) are closed, so the Spec is done and was only left open. While it stayed open and labelled
ready-for-agent, every Pickup run took it and did nothing, which is filed as #303.