AFKSwitch by augbastos — Tell your agents when you're away and when you're back.

<img src="assets/readme/hero-light.png" alt="AFKSwitch — away and back presence for your agents">
AFKSwitch by augbastos — Tell your agents when you're away and when you're back.
Claude Code knows what its agents are doing. AFKSwitch tells them whether you are at the computer.
[ AFK □■ ] you're here
[ AFK ■□ ] you're away
<img src="assets/readme/afkswitch-demo.gif" alt="Real Claude Code capture: present, one click sets AFK and tells the other session, the other session keeps working and reports, one click back">
/plugin marketplace add augbastos/afkswitch
/plugin install afkswitch@afkswitch
/reload-plugins
Needs Claude Code 2.1.287+ and python3 (Python 3.9+); the switch appears above the prompt. AFKSwitch is a Claude Code Mod.
/afk / /back./afk [optional context] I'm physically away
/back [optional context] I'm physically present again
In Claude Code, /afk saves your presence and tells the live local sessions the host can reach; the report names any it could not reach. Authorized work continues, and only steps that need you wait. /back collects a summary with what needs you first, then what got done. Optional context is information, never permission; /back context belongs to that return event and is not saved in the state file.
The Claude Code terminal switch sits above the prompt:
[ AFK □■ ] Present
[ AFK ■□ ] Away
[ AFK □□ ] Before first use (press to start AFK), or unknown
When away, AFK is orange (#F28C28). The cells move, so the state is readable without color in monochrome terminals too.
Click it once, or focus it with ctrl+x tab and press Enter. One press = one explicit action: the same /afk or /back skill, without context. The switch never flips optimistically: it shows only state confirmed by a fresh read. Before first use (no state file yet), the neutral switch (□□) is pressable and starts AFK. An unreadable or invalid state stays neutral (□□) and cannot be pressed. Double presses are blocked while an action is pending. One-time onboarding lines explain the switch; saving… marks a pending action and not switched — try /afk or /back marks a failed or unconfirmed action. The skill starts a model turn; the host's permissions and costs still apply.
python3 on PATH, for the state helper and the read-only sync hooks. The text skills can also discover python or py -3, but the bundled command hooks call python3 directly./afk or /back, peers are notified, and each session's switch redraws on its next event. See the compatibility record.Marketplace installs from GitHub load the switch on Claude Code 2.1.287+; module loading was verified on 2026-10-02, tested on one machine. The Claude plugin directory copy currently ships without the switch (v0.6.3-directory) while the directory reviews mods. Install from GitHub using the commands above for the switch. If your build does not load installed mods, use claude --plugin-dir <clone> or CLAUDE_CODE_PLUGIN_DIRS to load the clone. The visual switch is supported on the terminal only.
/back ends AFK, including through the switch. Time passing, a remote reply, or a session restart never means you returned.Capabilities from the adapter matrix:
| Host | State | Sync | Notify peers | Collect return status | Visual switch |
|---|---|---|---|---|---|
| Claude Code | yes | yes | yes | yes | terminal, with modules |
| Codex | yes | no | no | no | no |
| Antigravity CLI | yes | no | no | no | no |
| Generic local agent | yes | before each turn, if the host calls the helper | no | no | no |
Claude Code sync runs at session start and prompt submission. Codex and Antigravity ship experimental hook files, without default wiring or verified live-host sync. Their peer notification and return-status collection are unsupported. The switch's installation options are described above; UI tests do not prove live model behavior. See tested versions and evidence for the verification limits.
Privacy: state stays local in ~/.afkswitch/state.json, or in state.json under AFKSWITCH_STATE_DIR when set. It contains status, time, optional AFK context, and a change counter. The helper briefly creates a lock while writing and preserves an unreadable old file as state.json.corrupt-<time>.
AFKSwitch runs no server and makes no network requests of its own. Messages between sessions travel through the host's own mechanisms and are governed by the host.
Read-only sync hooks inspect state and a bounded session transcript tail, then exit. The switch reads only state. There is no daemon, polling, account, or telemetry. Claude Code peer messages can include your optional context; host data handling applies.
Read the Privacy policy and Security policy.
git clone https://github.com/augbastos/afkswitch.git
claude --plugin-dir ./afkswitch
codex plugin marketplace add https://github.com/augbastos/afkswitch
codex plugin add afkswitch@afkswitch
Use $afkswitch:afk and $afkswitch:back. The state folder must be writable; see Codex sandbox setup.
The plugin ships SessionStart and UserPromptSubmit command hooks. They only read state and the session transcript tail to sync presence; they never write either file. The switch is a hooks module (hooks/switch.tsx). It hooks session.start, prompt.submit and turn.complete to re-read state and redraw, and command.run only to notice when its own command starts; it passes every event on unchanged and never edits your prompt. It draws through ui.render on AbovePrompt. A press runs /afkswitch:afk when present or before first use, or /afkswitch:back when away, once per press and without context. See the presence specification for the state contract.
Changelog · Details and usage · Compatibility · Presence specification · Writing an adapter · Conformance tests · Support · Contributing · MIT license
hooks/switch.tsx 210 lines1import type { EngineInterface, Register } from 'claude-code'
2
3type Status = 'available' | 'afk'
4type ReadStatus = Status | 'missing' | null
5
6let showOnboarding = false
7type Cache = {
8 initialized: boolean
9 lastGood: ReadStatus
10 readable: boolean
11 pending: boolean
12 awaiting: Status | null
13 started: boolean
14 failed: boolean
15 revision: number
16}
17
18// Mirror parse_since/context_problem/problems_v1/problems_v2 in the state helper.
19function validSince(value: unknown): boolean {
20 if (typeof value !== 'string') return false
21 const parts = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|[+-](\d{2}):(\d{2}))$/.exec(value)
22 if (!parts || parts[0] !== value) return false
23 const [, y, m, d, hour, minute, s, zone, oh, om] = parts
24 const year = Number(y), month = Number(m), day = Number(d)
25 const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0)
26 const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
27 return year >= 1 && month >= 1 && month <= 12 && day >= 1 && day <= days[month - 1] &&
28 Number(hour) < 24 && Number(minute) < 60 && Number(s) < 60 &&
29 (zone === 'Z' || Number(oh) * 60 + Number(om) < 1440)
30}
31
32const validContext = (value: unknown): boolean => value === null ||
33 (typeof value === 'string' && [...value].length <= 2048 &&
34 !/[\u0000-\u0008\u000b-\u001f\u007f-\u009f\ud800-\udfff]/u.test(value))
35
36async function readStatus($: EngineInterface): Promise<ReadStatus> {
37 const override = await $.env.get('AFKSWITCH_STATE_DIR')
38 const home = override ? undefined : (await $.env.get('HOME')) || (await $.env.get('USERPROFILE'))
39 const directory = override || (home ? `${home}/.afkswitch` : undefined)
40 if (!directory) return null
41
42 try {
43 const path = `${directory.replace(/[\\/]+$/, '')}/state.json`
44 if (await $.fs.exists(path) === false) return 'missing'
45 const text = await $.fs.read(path)
46 const value: unknown = JSON.parse(text.replace(/^\uFEFF/, ''))
47 if (value === null || typeof value !== 'object' || Array.isArray(value)) return null
48 const state = value as Record<string, unknown>
49 const keys = state.version === 1
50 ? ['version', 'status', 'since', 'context']
51 : ['version', 'status', 'since', 'context', 'generation']
52 if (Object.keys(state).length !== keys.length || !keys.every(key => Object.hasOwn(state, key))) return null
53 if (state.version !== 1 && state.version !== 2) return null
54 if (state.status !== 'afk' && state.status !== 'available') return null
55 if (!validSince(state.since) || !validContext(state.context)) return null
56 if (state.version === 2 &&
57 (typeof state.generation !== 'number' || !Number.isInteger(state.generation) || state.generation < 1 ||
58 (state.status === 'available' && state.context !== null))) return null
59 return state.status
60 } catch {
61 // Unreadable, malformed and future state are unknown, never present.
62 return null
63 }
64}
65
66async function refresh($: EngineInterface, cache: Cache): Promise<ReadStatus> {
67 const request = ++cache.revision
68 try {
69 const status = await readStatus($)
70 if (request === cache.revision) {
71 cache.initialized = true
72 cache.readable = status !== null
73 if (status !== null) cache.lastGood = status
74 if (cache.awaiting && status === cache.awaiting) {
75 cache.pending = false
76 cache.awaiting = null
77 cache.started = false
78 cache.failed = false
79 }
80 }
81 return status
82 } catch (error) {
83 if (request === cache.revision) {
84 cache.initialized = false
85 cache.readable = false
86 }
87 throw error
88 }
89}
90
91// A drawing cache only: the helper used by the skills remains the single writer.
92// A hook that throws is skipped by the engine and the chain continues without it.
93export const register: Register = on => {
94 const cache: Cache = {
95 initialized: false, lastGood: null, readable: false,
96 pending: false, awaiting: null, started: false, failed: false, revision: 0,
97 }
98
99 on('session.start', async ($, e, next) => {
100 showOnboarding = false
101 try {
102 const seen = await $.store.get('onboardingVersion')
103 if (!(typeof seen === 'number' && seen >= 1)) {
104 showOnboarding = true
105 await $.store.set('onboardingVersion', 1)
106 }
107 } catch { showOnboarding = false }
108 await refresh($, cache)
109 cache.failed = false
110 $.ui.invalidate('ui.render')
111 return next(e)
112 })
113
114 on('prompt.submit', async ($, e, next) => {
115 showOnboarding = false
116 $.ui.invalidate('ui.render')
117 await refresh($, cache)
118 $.ui.invalidate('ui.render')
119 return next(e)
120 })
121
122 on('command.run', ($, e, next) => {
123 if (cache.awaiting && e.origin?.kind === 'plugin' && e.origin.name === 'afkswitch' &&
124 (e.command === 'afkswitch:afk' || e.command === 'afkswitch:back')) cache.started = true
125 return next(e)
126 })
127
128 on('turn.complete', async ($, e, next) => {
129 await refresh($, cache)
130 if (cache.awaiting) {
131 if (cache.started) {
132 cache.pending = false
133 cache.awaiting = null
134 cache.started = false
135 cache.failed = true
136 }
137 } else cache.failed = false
138 $.ui.invalidate('ui.render')
139 return next(e)
140 })
141
142 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
143 // This release advertises the terminal only; remote paint is not verified.
144 if (e.surface !== 'terminal') return next(e)
145 if (!cache.initialized) await refresh($, cache)
146
147 const status = cache.readable ? cache.lastGood : null
148 const cells = status === 'afk' ? '■□' : status === 'available' ? '□■' : '□□'
149 // Capture explicit intent from this drawing; never toggle a later disk value.
150 const intended: Status | null = status === 'available' || status === 'missing' ? 'afk' : status === 'afk' ? 'available' : null
151 const { Box, Text, Button } = $.ui.resolve(e)
152 const original = await next(e)
153
154 const press = async () => {
155 // The synchronous guard also covers two presses of an old drawing.
156 if (cache.pending || intended === null) return
157 showOnboarding = false
158 cache.pending = true
159 cache.failed = false
160 try {
161 $.ui.invalidate('ui.render')
162 const current = await refresh($, cache)
163 if (current === intended) return
164 // Do not act from a file we cannot currently understand.
165 if (current === null) {
166 cache.failed = true
167 return
168 }
169 // Set before running: an idle session fires command.run inside this call.
170 cache.awaiting = intended
171 cache.started = false
172 intended === 'afk'
173 ? await $.command.run({ command: 'afkswitch:afk' })
174 : await $.command.run({ command: 'afkswitch:back' })
175 } catch {
176 cache.awaiting = null
177 cache.started = false
178 cache.failed = true
179 // Show disk truth even if command dispatch or completion failed.
180 try { await refresh($, cache) } catch { cache.readable = false }
181 } finally {
182 cache.pending = cache.awaiting !== null
183 // A redraw error must not escape a button handler into the host.
184 try { $.ui.invalidate('ui.render') } catch { /* text commands still work */ }
185 }
186 }
187
188 return (
189 <Box flexDirection="column">
190 {original}
191 <Box flexDirection="row" alignSelf="flex-start" borderStyle="single" paddingX={1} flexShrink={0}>
192 <Text color={status === 'afk' ? '#F28C28' : undefined} dimColor={status !== 'afk'}>AFK</Text>
193 <Text>{' '}</Text>
194 {cache.pending || intended === null
195 ? <Text dimColor={status !== 'afk'}>{cells}</Text>
196 : <Button key="afkswitch-toggle" label={cells} plain onPress={press} />}
197 {cache.pending || cache.failed ? <Text>{' '}</Text> : null}
198 {cache.pending ? <Text dimColor>saving…</Text> : null}
199 {cache.failed ? <Text color="#F28C28">not switched — try /afk or /back</Text> : null}
200 </Box>
201 {showOnboarding ? <Box flexDirection="column">
202 <Text dimColor>{"AFKSwitch is ready — [ AFK □■ ] means you're here."}</Text>
203 <Text dimColor>{"Click it when you leave; click again when you're back."}</Text>
204 <Text dimColor>Use /afk [note] or /back [note] for optional context.</Text>
205 </Box> : null}
206 </Box>
207 )
208 })
209}
210