SLOPSHOPPER

AFKSwitch

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

newbandprompt
★ 1v0.6.6MITupdated 2026-10-02augbastos/afkswitch
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · afkswitch
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ⟨Claude Code's own drawing⟩ ┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ │ AFK □□ │ └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ │ AFK □□ │ └──────────────────────────────────────────────────────────────────────────────────────────────────┘
README

AFKSwitch

<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">

Install

/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.

Try it in 30 seconds

  1. Open two Claude Code sessions in two terminals with AFKSwitch loaded.
  2. Click the switch in one session to set AFK.
  3. Watch the other session get told and continue its authorized work.
  4. Click again to return and collect status.

Trust

  • MIT licensed.
  • Presence is stored in a local state file.
  • No AFKSwitch server, account, telemetry, or network requests.
  • Presence is not permission.
  • One writer: the bundled state helper.
  • Explicit transitions only: a click or /afk / /back.

What it does

/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.

Requirements

  • Python 3.9+, available as 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.
  • For the switch: Claude Code with function-hooks modules. Live-tested on 2.1.287: one click runs /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.

Three rules

  1. One writer: the helper. Only the bundled helper saves presence. The switch and sync hooks read it; peers are notified only after a successful save and read-back.
  2. Explicit actions only. Only /back ends AFK, including through the switch. Time passing, a remote reply, or a session restart never means you returned.
  3. Presence is not permission. Away does not mean unreachable or authorize new actions. Only the step that needs you waits; independent authorized work continues.

Compatibility

Capabilities from the adapter matrix:

HostStateSyncNotify peersCollect return statusVisual switch
Claude Codeyesyesyesyesterminal, with modules
Codexyesnononono
Antigravity CLIyesnononono
Generic local agentyesbefore each turn, if the host calls the helpernonono

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.

What AFKSwitch reads, writes, and sends

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.

Other installation options

Local clone

git clone https://github.com/augbastos/afkswitch.git
claude --plugin-dir ./afkswitch

Codex

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.

How it works

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.

Links

Changelog · Details and usage · Compatibility · Presence specification · Writing an adapter · Conformance tests · Support · Contributing · MIT license

Source 1 files
hooks/switch.tsx 210 lines
1import 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