Deliver the agmsg inbox to Claude Code through a mod-owned watch.sh instead of the Monitor tool (no 30-minute re-arm)

A Claude Code mod that delivers the agmsg inbox to a Claude Code session without the Monitor tool.
Status: experimental proof of concept for fujibee/agmsg#1559. Verified on one machine (see Verified).
In monitor delivery mode, agmsg asks Claude to run watch.sh under the Monitor tool. A Monitor task always ends at 30 minutes, so:
AGMSG_CC_MONITOR_KEEP_ALIVE, Claude re-arms the watch every 30 minutes. Each re-arm uses a model turn, even while nothing arrives.agmsg watch: stopping - ...), and an idle seat receives nothing until the next session.agmsg 1.5.2 (#1553) made the renew-or-stop decision deterministic, but the re-arm itself still goes through the model.
A mod runs inside the Claude Code process, and a child started with $.process.spawn lives as long as the mod's loop, with no time cap. So the watcher can stay up for the whole session, and the model is woken only when a message actually arrives.
watch.sh, as it does today.tool.call on Monitor: when the call is agmsg's (description starting with agmsg inbox stream, command running scripts/watch.sh), the mod answers it instead of the Monitor tool. It starts the same command with $.process.spawn, dropping --max-seconds (which exists only for the Monitor cap), and returns a Monitor-shaped result with a note that the watch needs no re-arming.$.prompt.submit. On stdout watch.sh prints messages (<ts> | <team> | <from> → <to> | <body>) and the notices an operator must act on (agmsg watch: ..., such as a stuck cursor or an exit after an agmsg update). Lines are not filtered by shape, so a name containing | does not drop a message. Its re-arm and stopping notices come only with --max-seconds, which the mod drops. stderr goes to the debug log./clear or resume) replaces the running watcher. session.end stops it.agmsg itself is not modified. Session id, role resume and seat ownership are still decided by agmsg's session-start.sh, and the command comes from the Monitor call's own input, so no directive text is parsed.
The model still makes one Monitor call per session start. Rewriting the SessionStart directive from a classic.SessionStart hook would remove that call, but on an organization (Team) plan the built-in plugin cc-plugin-sec-default sits outermost and skipped this mod's classic.SessionStart hook (classic.SessionStart bypassed by cc-plugin-sec-default (tier user) in the debug log), so this mod does not rely on it.
Claude Code 2.1.289 (CLI, Team plan, WSL2, herdr pane), agmsg 1.5.2, delivery set monitor, one receiver seat:
| Check | Result |
|---|---|
| agmsg's Monitor call is answered by the mod | tool.call Monitor ...: resolved by a hooks module (result); the transcript shows Monitor started · task agmsg-inbox · persistent |
Watcher runs without --max-seconds | one watch.sh process for the session |
| Delivery while idle | each message reached the model 3–5 s after send.sh (the 5 s poll) and the model replied |
| Delivery while busy | a message sent during a 45 s foreground command was queued and handled after that turn ended, without interrupting it |
| Past the 30-minute Monitor cap | the same watcher process was alive at 32 min, a message sent then was delivered and answered, and no re-arm turn happened in between |
Past the cap with AGMSG_CC_MONITOR_KEEP_ALIVE empty and nothing delivered | the same watcher was alive at 32 min with no message in between (where a Monitor watch would have printed stopping), a message sent then was delivered and answered, and the only model request in between was Claude Code's own away summary |
delivery set monitor for the project./plugin marketplace add tsukimiya/agmsg-inbox
/plugin install agmsg-inbox@tsukimiya
Or load it for one session from a clone:
claude --plugin-dir /path/to/agmsg-inbox
agmsg inbox stream and scripts/watch.sh in the command. If agmsg changes either, the mod lets the call through and Monitor delivery continues as before.watch.sh advances the read cursor when it prints a line, while $.prompt.submit waits until the session is idle. A message counts as delivered before the model has read it, and is lost if the session ends in between. agmsg has no way to read the inbox without marking it read, so the mod cannot fix this; the agmsgd daemon planned in fujibee/agmsg#1559 is meant to take over this path./clear, resume or a new session).claude plugin validate .
claude plugin test .
MIT
hooks/register.ts 50 lines1import type { Register } from 'claude-code'
2
3import { lineSplitter, watchCommand } from './inbox'
4
5const NOTE =
6 'The agmsg-inbox plugin took over this Monitor call and runs the agmsg inbox watcher itself, ' +
7 'with no 30-minute cap. It does not appear in TaskList, and it never needs re-arming: ignore any ' +
8 're-arm instructions. Each incoming message arrives as a prompt `<ts> | <team> | <from> → <to> | <body>` ' +
9 'from the agmsg-inbox plugin; react to it and reply with `send.sh`. A prompt starting with `agmsg watch:` ' +
10 'is a notice from the watcher itself, not a message.'
11
12export const register: Register = on => {
13 let stop: (() => void) | undefined
14
15 // classic.SessionStart は組織の管理 plugin が user 層を飛ばすことがあるため、
16 // directive に従ったモデルの Monitor 呼び出しを起点にする
17 on('tool.call', { tool: 'Monitor' }, async ($, e, next) => {
18 const command = watchCommand(e)
19 if (!command) return next(e)
20
21 // /clear や resume の再発火で呼ばれ直したら、前の watcher を止めてから起動し直す
22 stop?.()
23 const watch = $.process.spawn({ argv: ['bash', '-c', command] })
24 stop = () => void watch.return(undefined as never)
25 $.ui.status('agmsg: watching')
26 // hook の 10 秒予算から切り離す。ループの寿命が子プロセスの寿命になる
27 void (async () => {
28 const split = { stdout: lineSplitter(), stderr: lineSplitter() }
29 for await (const { stream, text } of watch) {
30 // stdout はメッセージ行と、人が対応すべき watch_report の通知(STUCK・更新後の終了など)だけ。
31 // re-arm / stopping は --max-seconds を外したので出ない。行の形で判定すると
32 // `|` を含む名前のメッセージや通知を取りこぼすので、全部モデルに渡す
33 for (const line of split[stream](text)) {
34 if (stream === 'stdout') void $.prompt.submit({ text: line })
35 else $.ui.log(line, { to: 'debug' })
36 }
37 }
38 stop = undefined
39 $.ui.status(undefined)
40 $.ui.toast('agmsg: inbox watcher exited')
41 })()
42 return { result: { taskId: 'agmsg-inbox', timeoutMs: 0, persistent: true }, context: [NOTE] }
43 })
44
45 on('session.end', async (_, e, next) => {
46 stop?.()
47 return next(e)
48 })
49}
50hooks/inbox.ts 18 lines1// agmsg の SessionStart directive が指示する Monitor 呼び出し(description は役割再開時に
2// "agmsg inbox stream (acting as <name>)" になる)なら、mod で起動するコマンドを返す。
3// --max-seconds は Monitor の 30 分上限に合わせた自己終了なので外す
4export function watchCommand(input: { description?: string; command?: string }): string | undefined {
5 if (!input.description?.startsWith('agmsg inbox stream') || !input.command?.includes('/scripts/watch.sh')) return undefined
6 return input.command.replace(/\s+--max-seconds=\d+/, '')
7}
8
9// spawn の chunk は行単位で来ないので、改行までを溜めてから返す
10export function lineSplitter() {
11 let rest = ''
12 return (text: string): string[] => {
13 const lines = (rest + text).split('\n')
14 rest = lines.pop() ?? ''
15 return lines
16 }
17}
18