Skip to content

feat(chat): DM continuity — a reply to a run's DM goes back into that run (v0.297.0) - #543

Merged
vikasprogrammer merged 1 commit into
mainfrom
feat/dm-session-binding
Aug 3, 2026
Merged

feat(chat): DM continuity — a reply to a run's DM goes back into that run (v0.297.0)#543
vikasprogrammer merged 1 commit into
mainfrom
feat/dm-session-binding

Conversation

@vikasprogrammer

Copy link
Copy Markdown
Owner

Follow-up to #540, which fixed the Slack-side half of "I get these messages and have no way to respond." This fixes the product half.

The gap

ask_human and approvals were already answerable straight from the DM (question_dms / approval_dms). Every other push was one-way — an agent's notify, and the session-lifecycle DMs (finished / crashed / waiting). The human got pinged with nowhere to reply:

  • the Inbox card is an update, which has no reply affordance
  • a DM reply fell through to the intent router and spawned a fresh session that knew nothing about the run that asked

Reported from the field: an agent asked for a design decision via notify, said it was holding the PR, and the answer had no way back to it.

The fix

A third binding, session_dms, written by notifyMember / notifySessionEvent, and Automations.continueSessionDm on the way back in — the DM-keyed analogue of thread continuity (continueSlackThread), which keys on channel+thread that a DM doesn't have:

  • delivered — the run is live → the message is typed straight into the running claude
  • revived — reaped/ended → revive the SAME row, --resumeing the transcript, seeded with the message
  • none — falls through to the approval, question and router paths exactly as before

Ordered after both decision paths (a pending approval/question is the more specific claim on the same reply) and before the router. Both platforms.

Guards

Its two siblings self-expire — the bound row leaves pending and stops matching. A session has no such terminal state, so the claim is bounded another way:

  • 24h window (SESSION_DM_WINDOW_MS) — a run that pinged you last month can't swallow today's unrelated "hey"
  • archived and unresumable rows excluded — no pinned claude id ⇒ reviving would start a blank transcript, which a fresh spawn already does better
  • visibility re-checked against the current member — the binding proves we DM'd them, not that they're still on the team or still assigned
  • an explicit /other-agent … still redirects to the router

On success the run's chat egress is pointed at the DM (bindReplyChannel, INSERT OR IGNORE — it never steals a run already answering in a thread), so its reply comes back where the human is talking; the socket acks by agent name, since a console-spawned run has no chat egress until it relaunches and silence would read as the reply being swallowed all over again.

Also fixed

All three DM bindings were written before deliverDM — but deliverDM is where a member with no linked Slack handle is discovered from their email and persisted to the identity map. A first-time recipient was therefore DM'd a question or approval whose reply matched nothing. The bind loop now runs after delivery, through one shared bindDmRecipients.

Verified

New scripts/dm-continuity-test.cjs — 29 assertions over the binding, every guard above, the three continuation outcomes, the reply-channel adopt/never-steal rule, and the audit line; wired into npm run test:governance.

GOVERNANCE CONFORMANCE: 159/159 passed
TIER-A POLICY:            18/18 passed
CAPABILITY REGISTRY:      18/18 passed
IDLE REAPER:              29/29 passed
DM CONTINUITY:            29/29 passed

Plus npm run typecheck, npm run build, cd web && npm run build. Schema is CREATE TABLE IF NOT EXISTS in the boot pass — no migration step.

🤖 Generated with Claude Code

… run (v0.297.0)

`ask_human` and approvals were already answerable from the DM (question_dms /
approval_dms). Every other push was one-way: an agent's `notify` and the session
lifecycle DMs pinged a human who then had nowhere to reply — the Inbox card is an
`update` with no reply box, and a DM reply fell through to the intent router,
spawning a FRESH session that knew nothing about the run that asked.

Adds the third binding, `session_dms`, written by notifyMember/notifySessionEvent,
and Automations.continueSessionDm on the way back in — the DM-keyed analogue of
thread continuity: deliver into the live claude, else revive the same transcript.
Checked after both decision paths and before the router.

A session has no "no longer pending" state to expire against, so the claim is
bounded instead: a 24h window, archived/unresumable rows excluded, visibility
re-checked against the current member, and an explicit /other-agent still
redirects. On success the run's chat egress is pointed at the DM (INSERT OR
IGNORE — never steals a run already answering in a thread) and the socket acks by
agent name.

Also fixes all three bindings running before deliverDM, which is where a member
with no linked Slack handle is auto-linked from their email — so a first-time
recipient was DM'd a question whose reply then matched nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vikasprogrammer
vikasprogrammer merged commit dc90794 into main Aug 3, 2026
1 check passed
@vikasprogrammer
vikasprogrammer deleted the feat/dm-session-binding branch August 3, 2026 12:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant