From 7ea33cef9c9f51827177481602a263bf65c5d223 Mon Sep 17 00:00:00 2001 From: cmj Date: Tue, 6 Oct 2026 10:34:29 +0800 Subject: [PATCH 1/2] feat(afk): notify from a hook when the agent stops or needs the user The hook is the half of afk mode that reaches the user: while a session's marker exists, Stop and a non-idle Notification ring the terminal, and the text says whether the work is done or needs them. It reads that from the last line of the final message, so a missing line degrades to a neutral ping rather than to silence. The terminal is found through the TUI's pid because /dev/tty cannot be opened from a hook's child process. Terminal.app shows no OSC 9, so it also gets a Notification Center message, with the text passed as argv and never parsed as AppleScript. Bump the plugin to 3.2.0: an installed copy is re-fetched only when the version moves. Refs: #45 Co-Authored-By: Claude Opus 5.5 --- .claude-plugin/marketplace.json | 2 +- plugins/wisdom/.claude-plugin/plugin.json | 2 +- plugins/wisdom/hooks/afk-notify.py | 175 ++++++++++++++++++++++ plugins/wisdom/hooks/hooks.json | 35 ++++- 4 files changed, 211 insertions(+), 3 deletions(-) create mode 100644 plugins/wisdom/hooks/afk-notify.py diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 23c65ab..4815fb9 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ { "name": "wisdom", "description": "Single-purpose skills to plan, build, review and ship software, led by smith.", - "version": "3.1.0", + "version": "3.2.0", "source": "./plugins/wisdom" } ] diff --git a/plugins/wisdom/.claude-plugin/plugin.json b/plugins/wisdom/.claude-plugin/plugin.json index 95bf650..536396b 100644 --- a/plugins/wisdom/.claude-plugin/plugin.json +++ b/plugins/wisdom/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "wisdom", "description": "Single-purpose skills to plan, build, review and ship software, led by smith.", - "version": "3.1.0", + "version": "3.2.0", "license": "MIT", "author": { "name": "cmj", diff --git a/plugins/wisdom/hooks/afk-notify.py b/plugins/wisdom/hooks/afk-notify.py new file mode 100644 index 0000000..b3a4b11 --- /dev/null +++ b/plugins/wisdom/hooks/afk-notify.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +"""Afk notification hook, and the switch for the mode it serves. + +`afk-notify.py on` / `off` writes or removes this session's marker, +`~/.claude/wisdom/afk/`. With no argument it is a hook: while the +marker exists, `Stop` and a permission `Notification` ring the terminal (BEL + +OSC 9, plus a Notification Center message under Terminal.app, which shows no +OSC 9), and `SessionEnd` removes the marker. +Silent no-op when the mode is off or the terminal cannot be reached. +""" +import json +import os +import re +import subprocess +import sys + +LIMIT = 120 +TITLE = "Claude Code" +# The message and title arrive as argv, so no text is ever parsed as AppleScript. +OSASCRIPT = ( + "osascript", + "-e", "on run argv", + "-e", "display notification (item 1 of argv) with title (item 2 of argv)", + "-e", "end run", +) + + +def marker(session_id): + # The id becomes a file name; anything else is not an id. + if not isinstance(session_id, str) or not re.fullmatch(r"[\w-]+", session_id): + return "" + return os.path.join(os.path.expanduser("~"), ".claude", "wisdom", "afk", session_id) + + +def switch(turn_on): + path = marker(os.environ.get("CLAUDE_CODE_SESSION_ID")) + if not path: + print("afk: no session id in CLAUDE_CODE_SESSION_ID, mode unchanged") + return + try: + if turn_on: + os.makedirs(os.path.dirname(path), exist_ok=True) + open(path, "w").close() + elif os.path.exists(path): + os.remove(path) + except OSError as error: + print(f"afk: mode unchanged, {error}") + return + print("afk: on" if turn_on else "afk: off") + + +def clean(text): + # An escape or a BEL in the text would end the OSC sequence it is sent in. + text = "".join(char for char in text if char.isprintable()).strip() + return text if len(text) <= LIMIT else text[: LIMIT - 1] + "…" + + +def last_message(data): + message = data.get("last_assistant_message") + if isinstance(message, str) and message.strip(): + return message + try: + with open(str(data.get("transcript_path") or ""), "rb") as handle: + handle.seek(0, os.SEEK_END) + handle.seek(max(0, handle.tell() - 65536)) + lines = handle.read().decode("utf-8", "replace").splitlines() + except OSError: + return "" + for line in reversed(lines): + try: + entry = json.loads(line) + if entry.get("type") != "assistant": + continue + content = entry["message"]["content"] + except (ValueError, AttributeError, KeyError, TypeError): + continue + if isinstance(content, str): + return content + return "\n".join( + block.get("text") or "" + for block in content + if isinstance(block, dict) and block.get("type") == "text" + ) + return "" + + +def verdict(message): + lines = [line.strip(" \t`*_") for line in message.splitlines() if line.strip()] + last = lines[-1] if lines else "" + if last == "AFK: DONE": + return "done" + ask = re.fullmatch(r"AFK: ASK\b[\s—–:-]*(.*)", last) + if ask: + return clean("needs you: " + clean(ask.group(1))).rstrip(": ") + # A missing line still pings: silence would read as "still working". + return "stopped" + + +def tty(): + override = os.environ.get("AFK_NOTIFY_TTY") + if override: + return override + # /dev/tty is not openable from a hook's child process; ask for the TUI's. + pid = os.environ.get("CLAUDE_PID") or str(os.getppid()) + try: + name = subprocess.run( + ["ps", "-o", "tty=", "-p", pid], + capture_output=True, text=True, timeout=5, + ).stdout.strip() + except (OSError, subprocess.SubprocessError): + return "" + return "/dev/" + name if re.fullmatch(r"[\w/]+", name) else "" + + +def notify(text): + path = tty() + if path: + try: + with open(path, "w", encoding="utf-8") as handle: + handle.write(f"\a\x1b]9;{text}\a") + except OSError: + pass + if os.environ.get("TERM_PROGRAM") == "Apple_Terminal": + try: + subprocess.run( + [*OSASCRIPT, text, TITLE], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5, + ) + except (OSError, subprocess.SubprocessError): + pass + + +def main() -> int: + if len(sys.argv) > 1: + if sys.argv[1] in ("on", "off"): + switch(sys.argv[1] == "on") + return 0 + + try: + data = json.load(sys.stdin) if not sys.stdin.isatty() else {} + except (json.JSONDecodeError, ValueError): + data = {} + if not isinstance(data, dict): + return 0 + + # The marker was written under the environment's id; a hook that does not + # see that variable, or sees another, still has the payload's. + for session_id in (os.environ.get("CLAUDE_CODE_SESSION_ID"), data.get("session_id")): + path = marker(session_id) + if path and os.path.exists(path): + break + else: + return 0 + + event = data.get("hook_event_name") + if event == "SessionEnd": + try: + os.remove(path) + except OSError: + pass + elif event == "Stop": + notify(verdict(last_message(data))) + elif event == "Notification": + message = data.get("message") if isinstance(data.get("message"), str) else "" + kind = data.get("notification_type") + # Only known noise is skipped, so an unknown needs-you event still + # pings: the idle reminder would ring again a minute after Stop did. + noise = kind in ("idle_prompt", "auth_success") if kind else "waiting for" in message.lower() + if not noise: + notify(clean("needs you: " + clean(message)).rstrip(": ")) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/wisdom/hooks/hooks.json b/plugins/wisdom/hooks/hooks.json index 3be75da..2ebfaf4 100644 --- a/plugins/wisdom/hooks/hooks.json +++ b/plugins/wisdom/hooks/hooks.json @@ -1,5 +1,5 @@ { - "description": "Load the per-project lingua language at session start; dispatch magic words on every prompt.", + "description": "Load the per-project lingua language at session start; dispatch magic words on every prompt; notify on stop or a permission request while afk mode is on.", "hooks": { "SessionStart": [ { @@ -23,6 +23,39 @@ } ] } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/afk-notify.py", + "timeout": 10 + } + ] + } + ], + "Notification": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/afk-notify.py", + "timeout": 10 + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/afk-notify.py", + "timeout": 10 + } + ] + } ] } } From 96a2c9a73fd3ccd5c7d5c91f036191757d81f3b0 Mon Sep 17 00:00:00 2001 From: cmj Date: Tue, 6 Oct 2026 10:36:17 +0800 Subject: [PATCH 2/2] feat(afk): keep working to the end and say why each turn stopped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the afk skill, a standing mode for when the user steps away: the agent decides the ordinary itself, stops only when the task is done, for a question only the user can answer, or before a hard-to-undo action they did not ask for, and ends every turn with `AFK: DONE` or `AFK: ASK — ` for the hook to turn into the notification. The mode changes when the agent stops, never what it may do: no permission is widened, and a permission request is itself the needs-you ping. The READMEs say so, with the Terminal.app and tmux caveats. Closes #45 Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- README.md | 8 +++++ README.zh-TW.md | 6 ++++ plugins/wisdom/skills/afk/SKILL.md | 58 ++++++++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 plugins/wisdom/skills/afk/SKILL.md diff --git a/CLAUDE.md b/CLAUDE.md index b181d40..d0c2e73 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,7 @@ entry point and coordinates the rest. `README.md` is the roster and the design; .claude-plugin/marketplace.json # registers the wisdom plugin plugins/wisdom/ ├── .claude-plugin/plugin.json -├── hooks/ # lingua's SessionStart hook +├── hooks/ # hooks.json and the scripts it runs └── skills// ├── SKILL.md └── references/ # optional, read only when the body says so diff --git a/README.md b/README.md index 75b49f5..f349c7f 100644 --- a/README.md +++ b/README.md @@ -78,9 +78,17 @@ other skills in turn. Every skill also works on its own. | `assets` | Build media assets one confirmed change per round | `build assets` | | `compact` | Re-render the previous answer as a dense table | `compact it` | | `brief` | Keep answers short and discuss one topic at a time | `brief me` | +| `afk` | Work to the end and ping you when done or needed | `afk mode` | | `lingua` | Answer in your chosen language, remembered per project | `reply in` | | `shortcut` | List every magic word, or dispatch one the hook missed | — | +`afk` rings the terminal (bell + OSC 9) when the agent stops; `afk off` ends it. It does not +change the permission mode: a permission request still stops the agent, and is the +needs-you ping. Terminal.app shows no OSC 9, so there the message arrives through +Notification Center, listed under Script Editor — allow its notifications. Under tmux only +the bell gets through unless passthrough is on. Claude Code's own notification channel, if +you set one, pings as well. + ## How smith runs ```text diff --git a/README.zh-TW.md b/README.zh-TW.md index 6c45ce7..d41197a 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -75,9 +75,15 @@ v3 從零重寫,圍繞三條規則。 | `assets` | 一輪一個確認過的修改,逐步產出媒體素材 | `build assets` | | `compact` | 把上一個回答重新整理成精簡表格 | `compact it` | | `brief` | 讓回答保持簡短,一次只討論一個主題 | `brief me` | +| `afk` | 一路做到完成,做完或需要你時才通知 | `afk mode` | | `lingua` | 以你選定的語言回答,並依專案記住 | `reply in` | | `shortcut` | 列出所有 magic word,或手動派送 hook 漏掉的那個 | — | +`afk` 在 agent 停下時讓終端機響鈴(bell + OSC 9);`afk off` 結束。它不會改變 permission +mode:權限請求仍會讓 agent 停下,那就是「需要你」的通知。Terminal.app 不顯示 OSC 9,因此改由 +通知中心送出訊息,列在 Script Editor 名下——請允許它的通知。在 tmux 中,除非開啟 +passthrough,否則只有響鈴會通過。若你設定了 Claude Code 自己的通知管道,它也會一併通知。 + ## smith 如何運作 ```text diff --git a/plugins/wisdom/skills/afk/SKILL.md b/plugins/wisdom/skills/afk/SKILL.md new file mode 100644 index 0000000..963abea --- /dev/null +++ b/plugins/wisdom/skills/afk/SKILL.md @@ -0,0 +1,58 @@ +--- +name: afk +description: Keeps the agent working to the end and pings the terminal only when it is done or needs the user. Use when the user steps away from a long task. +license: MIT +metadata: + shortcut: "afk mode" +--- + +# Afk + +The user is away from the keyboard. Apply these rules for the rest of the session: work to +the end, and stop only when the terminal should call the user back. They change when you +stop, never what you may do: the mode widens no permission and skips no confirmation the +user would otherwise get. + +## Turn on + +Run the switch with Bash, by its path from this skill's base directory (printed when the +skill loads): + +```bash +python3 "/../../hooks/afk-notify.py" on +``` + +Read what it prints, not its exit code. `afk: on` means the notifications are armed; +anything else means the mode did not turn on — tell the user what it printed, and that they +will get no ping. + +## Rules + +1. **Work to the end.** Carry the task through to completion. No progress check-ins, no + "shall I continue?", no stopping to report a finished step. +2. **Decide the ordinary yourself.** An obstacle with a reasonable way round, or a choice + that is easy to reverse, is yours to make. Note each decision and its reason, and list + them in the final report. +3. **Stop for three things only:** the task is complete; a question only the user can + answer; or the next action is hard to undo or faces outward (a push, a release, a + deletion, a message sent in the user's name) and the user has not asked for it in this + conversation. One they did ask for is carried out, and any permission prompt it raises + still reaches them. +4. **Ask in plain text.** End the turn with the question written in the message, not + through a dialog tool — the hook carries the message's last line into the notification. +5. **End every turn with one fixed line.** The last line of any message that ends a turn is + exactly one of: + + ```text + AFK: DONE + AFK: ASK — + ``` + + Nothing follows it. Without the line the user is pinged with a bare "stopped" and cannot + tell which it was. + +## Stop + +`afk off` ends the mode for the rest of the session: run the same script with `off`, expect +`afk: off`, and drop the fixed line. An answer to an `AFK: ASK` is not a request to stop — +keep working under the rules.