Ends a turn left on an open dialog (permission prompt, question) for 30 min by default and tells the agent the user is /afk; /dialog-timeout <minutes>|off|on

Moved. This plugin now lives in federbenjamin/stopstopping as
dialog-timeout@stopstopping, alongside its siblings. This repository is archived and receives no updates.
<a href="LICENSE"><img src="https://img.shields.io/github/license/federbenjamin/dialog-timeout" alt="License"></a>
A Claude Code plugin for people who leave the agent working while they step away. When the agent stops on a permission prompt or a question and nobody answers, the session sits there for hours. This plugin waits 30 minutes, ends that turn, and sends the agent automessage: user is /afk. The afk skill that comes with it then has the agent carry on without you instead of waiting.
Needs Claude Code; tested on 2.1.295.
claude plugin install dialog-timeout --marketplace federbenjamin/dialog-timeout
It starts in the next interactive session, with a 30-minute timeout.
automessage: user is /afk as your next message.afk skill. It tells the agent to keep working through what is queued, decide what it can, write the questions only you can answer to afk-questions.md (in docs/afk/ unless your agent instructions name a folder for docs), and end with a handoff doc beside it. Run /dialog-timeout:afk yourself when you step away./dialog-timeout changes the minutes, or turns it off, in every open and future session at once. One session can take its own minutes from DIALOG_TIMEOUT_MINUTES.dialog-timeout off while it is off./dialog-timeout 10
| Command | Effect |
|---|---|
/dialog-timeout <minutes> | Set the timeout, in whole minutes, and turn it on |
/dialog-timeout off | Dialogs wait for you, in every session |
/dialog-timeout on | Turn it back on at the last set minutes |
/dialog-timeout | Show the current setting |
Set DIALOG_TIMEOUT_MINUTES in the environment a session starts in to give that session its own minutes:
DIALOG_TIMEOUT_MINUTES=2 claude
That session uses those minutes in place of the stored ones, and every other session keeps the stored ones. A script that starts unattended sessions can set it so they give up sooner. /dialog-timeout off still wins: while it is off, no session ends a turn, whatever the variable says. In that session, /dialog-timeout shows the stored setting and adds (this session: 2 min, from DIALOG_TIMEOUT_MINUTES), and setting minutes there changes the stored ones for the other sessions. A value that is not a whole number of at least 1 is ignored.
Every 30 seconds while a turn runs, the plugin asks Claude Code's prompt box whether a dialog holds it. The dialog's age counts from the first check that saw it, so a timeout fires up to 30 seconds late. When the dialog has been open for the set minutes, the plugin ends the turn, closing the dialog unanswered, and submits the away message as you.
claude -p and other non-interactive sessions are never touched.Report a problem or ask a question in Issues. Pull requests are welcome; see CONTRIBUTING.md, and report a security issue as SECURITY.md says.
Run the plugin from a checkout:
claude --plugin-dir .
Checks, from the repo root:
claude plugin test .
tsc -p .
claude plugin validate .
tsc -p . reads the engine's types from .claude-plugin/types/, which Claude Code writes the first time it loads the plugin from this folder (any claude --plugin-dir . run).
MIT © Benjamin Feder. See LICENSE.
hooks/register.ts 151 lines1import type { Register } from 'claude-code'
2
3// Ends a main turn that has sat on an open dialog (a permission prompt, an AskUserQuestion) for
4// the set minutes, then submits AFK_TEXT so the agent runs the afk skill and moves on. No tool
5// hooks: while a main turn runs, each tick asks the prompt box whether a dialog holds the keys
6// ($.prompt.fill refuses with `dialog`); the dialog's age counts from the first tick that saw it.
7// `/dialog-timeout <minutes>|off|on` sets it for every session: it lives in $.store, read on each
8// tick, so sessions already open follow a change. DIALOG_TIMEOUT_MINUTES in the session's
9// environment replaces the minutes for that session alone; `off` still wins over it.
10
11export const DEFAULT_MINUTES = 30
12export const TICK_MS = 30_000
13export const AFK_TEXT = 'automessage: user is /afk'
14export const COMMAND = 'dialog-timeout'
15const SETTING = 'setting'
16const PERSON_ORIGINS: ReadonlySet<string> = new Set(['composer', 'bridge', 'slack-ping'])
17
18export type DialogTimeoutSetting = {
19 minutes: number // how long a dialog may stay open before the turn ends with /afk
20 isOff: boolean // /dialog-timeout off; `on` restores `minutes`
21}
22
23const fresh = (): DialogTimeoutSetting => ({ minutes: DEFAULT_MINUTES, isOff: false })
24
25// The stored setting, or the default for a value that is missing or not a setting.
26export function settingFrom(value: unknown): DialogTimeoutSetting {
27 const v = (value ?? {}) as Partial<DialogTimeoutSetting>
28 return {
29 minutes: typeof v.minutes === 'number' && Number.isInteger(v.minutes) && v.minutes >= 1 ? v.minutes : DEFAULT_MINUTES,
30 isOff: v.isOff === true,
31 }
32}
33
34// DIALOG_TIMEOUT_MINUTES as whole minutes of at least 1; any other value is ignored.
35export function envMinutesFrom(value: string | undefined): number | undefined {
36 const v = value?.trim() ?? ''
37 return /^\d+$/.test(v) && Number(v) >= 1 ? Number(v) : undefined
38}
39
40// The setting this session runs on: the stored one, with the environment's minutes in place of its own.
41export function sessionSetting(stored: unknown, envMinutes: number | undefined): DialogTimeoutSetting {
42 const s = settingFrom(stored)
43 return envMinutes === undefined ? s : { ...s, minutes: envMinutes }
44}
45
46// One tick's reading: when the open dialog was first seen (0 for none), and whether it is overdue.
47export function observe(openSince: number, isDialog: boolean, now: number, timeoutMs: number): { openSince: number; isOverdue: boolean } {
48 if (!isDialog) return { openSince: 0, isOverdue: false }
49 const since = openSince || now
50 return { openSince: since, isOverdue: now - since >= timeoutMs }
51}
52
53// `/dialog-timeout <minutes>|off|on`: the stored setting after the args, and the line the command
54// prints, which names the environment's minutes when they are what this session runs on.
55export function dialogTimeoutCommand(args: string, s: DialogTimeoutSetting, envMinutes?: number): { setting: DialogTimeoutSetting; text: string } {
56 const { setting, text } = storedCommand(args, s)
57 const note = envMinutes !== undefined && !setting.isOff ? ` (this session: ${envMinutes} min, from DIALOG_TIMEOUT_MINUTES)` : ''
58 return { setting, text: text + note }
59}
60
61function storedCommand(args: string, s: DialogTimeoutSetting): { setting: DialogTimeoutSetting; text: string } {
62 const arg = args.trim().toLowerCase()
63 if (arg === 'off') return { setting: { ...s, isOff: true }, text: 'dialog-timeout off: open dialogs wait for you, in every session' }
64 if (arg === 'on' || /^\d+$/.test(arg)) {
65 const minutes = arg === 'on' ? s.minutes : Number(arg)
66 if (minutes >= 1) return { setting: { minutes, isOff: false }, text: `dialog-timeout ${minutes} min: a dialog left open that long ends the turn with /afk` }
67 }
68 const state = s.isOff ? `off (${s.minutes} min when on)` : `${s.minutes} min`
69 return { setting: s, text: `dialog-timeout is ${state}; /dialog-timeout <minutes>, off, or on` }
70}
71
72const statusFor = (s: DialogTimeoutSetting): string | undefined => (s.isOff ? 'dialog-timeout off' : undefined)
73
74export const register: Register = on => {
75 let setting = fresh()
76 let envMinutes: number | undefined
77 let turnId: string | undefined
78 let openSince = 0
79 let tick: { cancel: () => void } | undefined
80
81 on('session.start', async ($, e, next) => {
82 tick?.cancel()
83 tick = undefined
84 if (e.isInteractive) {
85 envMinutes = envMinutesFrom(await $.env.get('DIALOG_TIMEOUT_MINUTES'))
86 setting = sessionSetting(await $.store.get(SETTING), envMinutes)
87 $.ui.status(statusFor(setting))
88 tick = $.clock.every(TICK_MS, async () => {
89 if (turnId === undefined) return
90 setting = sessionSetting(await $.store.get(SETTING), envMinutes)
91 if (setting.isOff) return
92 const { refusal } = await $.prompt.fill({ text: '', mode: 'append' })
93 const seen = observe(openSince, refusal === 'dialog', await $.clock.now(), setting.minutes * 60_000)
94 openSince = seen.openSince
95 if (!seen.isOverdue) return
96 const id = turnId
97 turnId = undefined
98 openSince = 0
99 try {
100 await $.turn.abort({ turnId: id })
101 await $.prompt.submit({ text: AFK_TEXT, asUser: true })
102 $.ui.status(`dialog-timeout: dialog open ${setting.minutes} min, sent /afk`)
103 } catch {
104 $.ui.status('dialog-timeout: could not end the waiting turn')
105 }
106 })
107 // After the timer, so a refused registration costs the command, not the timeout.
108 await $.command.register({
109 name: COMMAND,
110 description: 'Minutes an open dialog may wait before the turn ends with /afk, or off; every session',
111 argumentHint: '[minutes|off|on]',
112 immediate: true,
113 })
114 }
115 return next(e)
116 })
117
118 on('command.run', { command: COMMAND }, async ($, e) => {
119 const { setting: stored, text } = dialogTimeoutCommand(e.args, settingFrom(await $.store.get(SETTING)), envMinutes)
120 setting = sessionSetting(stored, envMinutes)
121 $.ui.status(statusFor(setting))
122 await $.store.set(SETTING, { ...stored })
123 return { text }
124 })
125
126 on('prompt.submit', async ($, e, next) => {
127 if (PERSON_ORIGINS.has(e.origin.kind)) $.ui.status(statusFor(setting))
128 return next(e)
129 })
130
131 on('turn.start', async ($, e, next) => {
132 turnId = e.turnId
133 openSince = 0
134 return next(e)
135 })
136
137 on('turn.complete', async ($, e, next) => {
138 if (!e.agentId && e.turnId === turnId) {
139 turnId = undefined
140 openSince = 0
141 }
142 return next(e)
143 })
144
145 on('session.end', async ($, e, next) => {
146 turnId = undefined
147 openSince = 0
148 return next(e)
149 })
150}
151