SLOPSHOPPER

agentctl

agentctl Mod: agentctl CLI (tools/agentctl) が ~/.agentctl/claude/<sessionId>/inbox/ に置いたメッセージを $.prompt.submit で Claude に届け、中断の依頼を $.turn.abort で行い、結果を acks/…

newprocesstimer
v0.1.0no licenseupdated 2026-09-27masahide/agent-kit/plugins/agentctl
A shopper browsing a rack in a slop shop
README

agentctl Mod

agentctl CLI (tools/agentctl) が Claude Code のセッションにメッセージを届けるための Claude Mod と、agent に agentctl の使い方を教える付属スキル (skills/agentctl) です。使い方は docs/agentctl/usage.md、背景と決定は docs/agentctl/plan.md にあります。

対象は Claude Code 2.1.283 の Claude Mods (function hooks、早期アクセス) です。CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 が要ります。

何をするか

セッションの一覧と状態は、CLI が Claude Code 本体のセッション記録 (~/.claude/sessions/<pid>.json) から読みます。この Mod がするのは、受信箱のメッセージを Claude に届けることだけです。

~/.agentctl/claude/<sessionId>/
  mod.json            Mod が session.start で書く ({ v, sessionId, pid, modVersion, startedAt })
  meta.json           CLI だけが書く (名前、アーカイブの印)
  inbox/<id>.json     CLI が書く ({ v, id, kind: "prompt"|"interrupt", text?, createdAt })
  acks/<id>.json      Mod が書く ({ v, id, status, detail?, at })
  • session.start: mod.json を書きます。pid は sh -c 'echo $PPID' で取った claude の pid で、CLI はこれをセッション記録の pid と照合して、前のプロセスが残した mod.json を使いません。Windows では sh が無い (pid は 0) か、Git Bash の sh で MSYS の pid になって一致しないので、CLI は startedAt がプロセスの起動より後なら、pid が違っても Mod が載っているとみなします。ホームは USERPROFILE、無ければ HOME です。AGENTCTL_HOME があれば ~/.agentctl の代わりに使います。
  • 500 ms ごとに inbox/ を $.fs.list し、まだ ack の無いメッセージを古い順に処理します。
  • prompt: turn の実行中なら先に queued の ack を書き、$.prompt.submit が返ったら (次の turn が始まったら) submitted に書き換えます。{ drop } なら dropped、throw したら error です。
  • interrupt: main の turn の実行中なら $.turn.abort で止めて aborted、そうでなければ no_turn です。
  • turn.start / turn.complete (main のみ): 実行中の turn の id を覚えます。
  • Mod はファイルを消しません。処理済みの受信箱と ack は CLI が消します。

Claude には、投入した文が「The agentctl plugin sent a message:」を前に付けた user turn として見えます (Claude Code がそう見せます)。

確かめ方

CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin validate plugins/agentctl
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test plugins/agentctl
npx -y -p typescript tsc -p plugins/agentctl --noEmit   # 先に /plugin-types で .claude/types を作る

claude plugin validate の印字:

> ./register.ts hooks: turn.start, turn.complete, session.start
> ./register.ts calls: $.clock.every, $.clock.now, $.env.get, $.fs.exists, $.fs.list, $.fs.read, $.fs.write, $.process.run, $.prompt.submit, $.session.id, $.turn.abort, $.ui.log
> ./register.ts env reads: AGENTCTL_HOME, HOME, USERPROFILE

ファイル

パス中身
hooks/register.tsフックの登録と受信箱の監視
hooks/inbox.ts受信箱の処理の純関数 (ファイル名の選別、メッセージの検査、ack の作成)
hooks/protocol.ts共有ディレクトリのファイルの形。CLI の internal/claude/protocol.go と同じ
skills/agentctl/SKILL.md付属スキル。Codex でも ~/.codex/skills/ に写せば使える
tests/fixtures/protocol/*.jsonファイルの見本。Go のテストも読む
tests/fixtures/protocol.ts同じ見本の TS 版 (Mod のテストは JSON を import できないため)。ずれは Go の TestProtocolFixtures が見つける
Source 3 files
hooks/register.ts 144 lines
1import type { On, Timer } from 'claude-code'
2
3import { idOf, makeAck, parseMessage, pendingNames, submitStatus } from './inbox'
4import { MOD_VERSION, PROTOCOL_VERSION, type AckStatus, type InboxMessage, type ModFile } from './protocol'
5
6/** 受信箱を見る間隔 (ms) */
7const POLL_INTERVAL_MS = 500
8
9/**
10 * agentctl Mod。
11 *
12 * - session.start で ~/.agentctl/claude/<sessionId>/mod.json を書き、受信箱の監視を始めます。
13 * - 受信箱の prompt は `$.prompt.submit` で Claude に届けます。turn の実行中なら先に `queued` の ack を書き、
14 *   次の turn として始まったら `submitted` に書き換えます (`$.prompt.submit` の Promise は turn が始まるまで返らないため)。
15 * - interrupt は、main の turn の実行中なら `$.turn.abort` で止めて `aborted`、そうでなければ `no_turn` を書きます。
16 * - Mod はファイルを消しません。処理済みの受信箱と ack は CLI が消します。
17 */
18export function register(on: On) {
19  /** 実行中の main の turn (subagent の turn は数えない) */
20  let turnId: string | undefined
21  let timer: Timer | undefined
22
23  // subagent の実行は turn.start を出さない (型定義の説明) ので、ここに来るのは main の turn だけ
24  on('turn.start', async ($, e, next) => {
25    turnId = e.turnId
26    return next(e)
27  })
28
29  on('turn.complete', async ($, e, next) => {
30    if (!e.agentId) {
31      turnId = undefined
32    }
33    return next(e)
34  })
35
36  on('session.start', async ($, e, next) => {
37    const result = await next(e)
38
39    // Windows では HOME が無いことがあるので USERPROFILE を先に見る (Go の os.UserHomeDir と同じ場所になる)
40    const home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME'))
41    const sessionId = await $.session.id()
42    if (!home || !sessionId) {
43      $.ui.log('agentctl: HOME かセッション id が分からないので、受信箱を開けません')
44      return result
45    }
46    const base = (await $.env.get('AGENTCTL_HOME')) || `${home}/.agentctl`
47    const dir = `${base}/claude/${sessionId}`
48
49    const writeAck = (id: string, status: AckStatus, detail?: string) =>
50      $.clock.now().then(now => $.fs.write(`${dir}/acks/${id}.json`, JSON.stringify(makeAck(id, status, now, detail))))
51
52    // `sh` の親が claude のプロセスです。CLI はこの pid をセッション記録の pid と照合し、
53    // 前のプロセスが残した mod.json を使わないようにします。Windows では sh が無い (0 を書く) か、
54    // Git Bash の sh で MSYS の pid になり一致しないので、CLI は startedAt がプロセスの起動より後かどうかでも照合します。
55    let pid = 0
56    try {
57      const out = await $.process.run(['sh', '-c', 'echo $PPID'])
58      pid = Number.parseInt(out.stdout.trim(), 10) || 0
59    } catch {
60      pid = 0
61    }
62    const mod: ModFile = {
63      v: PROTOCOL_VERSION,
64      sessionId,
65      pid,
66      modVersion: MOD_VERSION,
67      startedAt: await $.clock.now(),
68    }
69    await $.fs.write(`${dir}/mod.json`, JSON.stringify(mod))
70
71    const submit = async (message: InboxMessage) => {
72      try {
73        const res = await $.prompt.submit({ text: message.text ?? '' })
74        const { status, detail } = submitStatus(res)
75        await writeAck(message.id, status, detail)
76      } catch (error) {
77        await writeAck(message.id, 'error', String(error))
78      }
79    }
80
81    const interrupt = async (message: InboxMessage) => {
82      if (!turnId) {
83        await writeAck(message.id, 'no_turn')
84        return
85      }
86      try {
87        await $.turn.abort({ turnId })
88        await writeAck(message.id, 'aborted')
89      } catch (error) {
90        // 止める前に turn が終わっていた
91        await writeAck(message.id, 'no_turn', String(error))
92      }
93    }
94
95    // /clear や resume で session.start がまた来たら、前の監視を止めて新しいセッションの受信箱を見る
96    timer?.cancel()
97    const done = new Set<string>()
98    let polling = false
99    timer = $.clock.every(POLL_INTERVAL_MS, async () => {
100      if (polling) {
101        return
102      }
103      polling = true
104      try {
105        let entries: unknown
106        try {
107          entries = await $.fs.list(`${dir}/inbox`)
108        } catch {
109          return // 受信箱がまだ無い
110        }
111        for (const name of pendingNames(entries, done)) {
112          done.add(name)
113          if (await $.fs.exists(`${dir}/acks/${name}`)) {
114            continue // 前のプロセスが処理済み
115          }
116          let message: InboxMessage | undefined
117          try {
118            message = parseMessage(await $.fs.read(`${dir}/inbox/${name}`))
119          } catch {
120            message = undefined
121          }
122          if (!message) {
123            await writeAck(idOf(name), 'error', 'unreadable inbox message')
124            continue
125          }
126          if (message.kind === 'interrupt') {
127            await interrupt(message)
128            continue
129          }
130          if (turnId) {
131            await writeAck(message.id, 'queued')
132          }
133          // 実行中なら turn が終わるまで返らないので、監視を止めないよう待たない
134          void submit(message)
135        }
136      } finally {
137        polling = false
138      }
139    })
140
141    return result
142  })
143}
144
hooks/inbox.ts 66 lines
1import { PROTOCOL_VERSION, type Ack, type AckStatus, type InboxMessage } from './protocol'
2
3/**
4 * `$.fs.list` の結果から、まだ処理していないメッセージのファイル名を古い順に返します。
5 *
6 * CLI は一時名 (`<id>.json.tmp`) で書いてから rename するので、`.json` で終わる名前だけを見ます。
7 */
8export function pendingNames(entries: unknown, done: ReadonlySet<string>): string[] {
9  if (!Array.isArray(entries)) {
10    return []
11  }
12  const names: string[] = []
13  for (const entry of entries) {
14    const name = typeof entry === 'string' ? entry : (entry as { name?: unknown } | null)?.name
15    const kind = typeof entry === 'string' ? 'file' : (entry as { kind?: unknown } | null)?.kind
16    if (typeof name !== 'string' || kind !== 'file' || !name.endsWith('.json') || name.startsWith('.') || done.has(name)) {
17      continue
18    }
19    names.push(name)
20  }
21  return names.sort()
22}
23
24/** 受信箱のファイルを読みます。形が違えば undefined */
25export function parseMessage(text: string): InboxMessage | undefined {
26  let value: unknown
27  try {
28    value = JSON.parse(text)
29  } catch {
30    return undefined
31  }
32  if (typeof value !== 'object' || value === null) {
33    return undefined
34  }
35  const m = value as Record<string, unknown>
36  if (m.v !== PROTOCOL_VERSION || typeof m.id !== 'string' || m.id === '' || typeof m.createdAt !== 'number') {
37    return undefined
38  }
39  if (m.kind === 'interrupt') {
40    return { v: PROTOCOL_VERSION, id: m.id, kind: 'interrupt', createdAt: m.createdAt }
41  }
42  if (m.kind === 'prompt' && typeof m.text === 'string' && m.text.trim() !== '') {
43    return { v: PROTOCOL_VERSION, id: m.id, kind: 'prompt', text: m.text, createdAt: m.createdAt }
44  }
45  return undefined
46}
47
48/** ack を作ります */
49export function makeAck(id: string, status: AckStatus, at: number, detail?: string): Ack {
50  return detail ? { v: PROTOCOL_VERSION, id, status, detail, at } : { v: PROTOCOL_VERSION, id, status, at }
51}
52
53/** `$.prompt.submit` の結果を ack の状態にします (`{ drop }` は受け付けられなかった) */
54export function submitStatus(result: unknown): { status: AckStatus; detail?: string } {
55  if (typeof result === 'object' && result !== null && 'drop' in result) {
56    const drop = (result as { drop?: unknown }).drop
57    return { status: 'dropped', detail: typeof drop === 'string' ? drop : 'dropped' }
58  }
59  return { status: 'submitted' }
60}
61
62/** ファイル名 `<id>.json` から id を取ります */
63export function idOf(name: string): string {
64  return name.slice(0, -'.json'.length)
65}
66
hooks/protocol.ts 45 lines
1/**
2 * 共有ディレクトリ ~/.agentctl/claude/<sessionId>/ のファイルの形。
3 *
4 * CLI (tools/agentctl/internal/claude/protocol.go) も同じ形を読み書きします。形を変えるときは
5 * tests/fixtures/protocol/*.json の見本から直し、Go と Mod の両方のテストを通します。
6 */
7
8export const PROTOCOL_VERSION = 1
9
10/** この Mod の版。mod.json に書きます */
11export const MOD_VERSION = '0.1.0'
12
13/** mod.json: Mod が session.start で書く */
14export type ModFile = {
15  v: 1
16  sessionId: string
17  pid: number
18  modVersion: string
19  /** epoch ms */
20  startedAt: number
21}
22
23/** inbox/<id>.json: CLI だけが書く */
24export type InboxMessage = {
25  v: 1
26  id: string
27  kind: 'prompt' | 'interrupt'
28  text?: string
29  /** epoch ms */
30  createdAt: number
31}
32
33/** ack の状態。queued だけは途中の状態で、turn が始まると submitted に書き換える */
34export type AckStatus = 'queued' | 'submitted' | 'dropped' | 'aborted' | 'no_turn' | 'error'
35
36/** acks/<id>.json: Mod だけが書く */
37export type Ack = {
38  v: 1
39  id: string
40  status: AckStatus
41  detail?: string
42  /** epoch ms */
43  at: number
44}
45