Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>/
├── SKILL.md
└── references/ # optional, read only when the body says so
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions README.zh-TW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion plugins/wisdom/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
175 changes: 175 additions & 0 deletions plugins/wisdom/hooks/afk-notify.py
Original file line number Diff line number Diff line change
@@ -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/<session_id>`. 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())
35 changes: 34 additions & 1 deletion plugins/wisdom/hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -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": [
{
Expand All @@ -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
}
]
}
]
}
}
58 changes: 58 additions & 0 deletions plugins/wisdom/skills/afk/SKILL.md
Original file line number Diff line number Diff line change
@@ -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 "<base directory>/../../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 — <the question, on one line>
```

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