SLOPSHOPPER

tag-feed

Xのハッシュタグの新着を Grok(Hermes) で拾い、パネルに流し続ける。X API は使わない

newpanecommandstatusprocessnetwork
★ 1v0.1.0MITupdated 2026-10-15marulimo/claude-code-mods/mods/tag-feed
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tag-feed
│ ┃ タグフィード ✕ › fix the failing auth test and add an audit log call │ ┃ タグ未設定 │ ┃ タグ未設定 ⏺ Read(src/auth.ts) │ ┃ タグ >: #ClaudeCode など(空白区切りで複数) ⎿ 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 │ │ › /tag-feed │ ⎿ tag-feed: パネルを開きました。タグを入力してください。 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · タグフィード
タグ未設定 タグ未設定 タグ >: #ClaudeCode など(空白区切りで複数) ⏎ 切り替え [ 今すぐ取得 ] [ 止める ]
README

Claude Code Mods

Claude Code の Mod(10/1 公開の拡張機能)6本をまとめたリポジトリです。

インストール

ターミナルの Claude Code で、<mod> を下記の名前に置き換えて実行します。

/plugin install <mod> --marketplace marulimo/claude-code-mods

続けて y を入力し、インストール先のスコープを選びます。デスクトップアプリの Code タブではこのコマンドは使えないため、ターミナルでインストールしてください。user スコープに入れれば、デスクトップのセッションでも読み込まれます。

Mod の設定項目は各 mods/<mod>/.claude-plugin/plugin.json の userConfig に定義しています。実行コマンドは PATH 上で見つかる必要があります。別の場所にある場合は設定で実行ファイルのパスを指定してください。

ask-codex

右側のパネルから、Devin SWE-2 への相談と Codex への作業依頼を送ります。送った分は Devin と Codex の利用枠で動き、Claude の使用量は使いません。単独のリポジトリ marulimo/ask-codex でも同じ版を公開しています。

  • 使い方: パネル上部の「Devin(相談)」「Codex(作業)」で送り先を選び、入力欄に書いて Enter。「推論」で強さを選びます(送り先ごとに覚えます)。コマンドでも送れます: /devin <質問>、/codex <依頼>、/codex-panel。
  • 必要なもの: 使う送り先の CLI です(片方だけでも動きます)。Codex CLI はログイン済み、Devin CLI はログイン済みで SWE-2 が使えること。
  • 設定: Codex の権限(workspace-write)、Codex の推論強度(high)、Codex のモデル(空なら CLI の既定)、codex の場所(空なら自動で探す)、Devin の強さ(high)、devin の場所(空なら自動で探す)、Devin を動かすフォルダー(空なら ~/.ask-codex/devin-chat)です。
  • 注意: Codex の権限を danger-full-access にすると、確認もサンドボックスもなしで作業フォルダーを書き換えます。Devin は中身の空な専用フォルダーで、その1回だけフォルダーの信頼確認を省いて動かします。失敗の記録は ~/.ask-codex/errors.log に残ります。
  • 詳しくは mods/ask-codex/README.md を参照してください。

ai-usage-hub

Claude、Codex、Grok、Gemini、Devin の使用率とリセット時刻をパネルに表示します。Claude のセッション情報と外部 collector の結果を使います。

  • コマンド: /usage-hub。起動時にもパネルを開き、5分おきに取得します。
  • 必要なもの: Node.js と、各サービスの使用率を集める collector が別途必要です。collector とそのログイン処理はこのリポジトリには含まれません。
  • 設定: userConfig はありません。環境変数 AI_USAGE_NODE(既定 node)と AI_USAGE_DASH_DIR(既定は USERPROFILE または HOME の下の ai-usage-dashboard)を使います。
  • 呼び出し: collector のフォルダーを作業ディレクトリとして、次を実行します。終了コード 0 で、標準出力に JSON だけを返してください。
node <AI_USAGE_DASH_DIR>/server.mjs --json codex,grok,gemini,devin,claude

JSON は次の形です。groups は必須配列で、グループには文字列の name と配列の rows が必要です。行には文字列の label と有限の数値または null の used が必要です。used は使用率の百分率です。

{
  "groups": [
    {
      "name": "CODEX",
      "rows": [
        { "label": "5時間", "used": 25, "reset": "2026-10-16T00:00:00Z" },
        { "label": "週次", "used": null, "state": "データなし" }
      ],
      "stale": false,
      "note": null
    }
  ]
}

認識するグループ名は CLAUDE、CODEX、GROK、GEMINI、DEVIN です。省略可能な項目は、行の reset(日時文字列)、state(状態文字列)、star(真なら Claude の Fable 枠)、グループの stale(真なら古いデータ)、note(文字列)です。Claude の補完行は 5時間 と 週次 で始まるラベルを使います。取得に失敗したときは前回値を残すため、表示の失敗時刻も確認してください。

ai-router

入力を送信すると、依頼に適した AI の判定結果をトーストとステータス行に表示します。Claude への送信は判定の完了を待ちません。

  • コマンド: ありません。通常の入力送信時に動きます。スラッシュコマンド、空白を除いて8文字未満の入力などは判定しません。
  • 必要なもの: Python と外部の判定スクリプトです。スクリプト、利用先 API の設定や認証は同梱しません。
  • 設定: pythonPath(python)、routerScript(空)、backend(jev、または decisions)、privateFolders(空)、privateWords(空)です。
  • routerScript が空なら実行せず、「判定スクリプト未設定」と表示します。
  • privateFolders は除外する絶対パス、privateWords は除外する語句をカンマ区切りで指定します。フォルダー自身とその配下、語句の部分一致を大文字小文字を区別せず判定します。既定ではどちらも除外しません。
  • 注意: 設定すると入力全文を判定スクリプトの標準入力へ渡します。外部送信の有無はスクリプト側の実装にも依存します。

呼び出しは次の形です。入力全文を標準入力から読み、12秒以内に終了コード 0 と標準出力の JSON を返す必要があります。

python <routerScript> --backend jev

backend を decisions にすると末尾の引数も decisions になります。成功結果の例です。

{
  "ok": true,
  "label": "候補A",
  "entry": "作業の入口",
  "top": [{ "id": "candidate_a", "p": 0.82 }, { "id": "candidate_b", "p": 0.11 }]
}

成功時は ok: true、文字列の label と entry、1件以上の top が必要です。p は 0〜1 の有限数なら百分率で表示します。2件目の id と p が有効なら次点も表示します。失敗時は {"ok":false,"reason":"service_unavailable"} の形を返せます。reason は英字で始まる英数字・アンダースコア80文字以内だけを表示します。それ以外は一般的な失敗表示になります。

side-chat

Claude が考えている間、Dots 役(ChatGPT)と GrokBot 役(Grok)がパネルで交互に、いまの会話の話題について雑談します。直近の会話(最大8発言・1発言300字・合計1500字)、このターンの使用ツール名、直近の雑談を送り先に渡します。

  • 送らないもの: ツールの出力とコードブロックは渡しません。設定 privateWords の語句を含む発言は丸ごと省き、APIキー・トークン・メールアドレス・32文字以上の英数字列は伏せ字にします。
  • コマンド: /side-chat、/side-chat off、/side-chat on です。
  • 必要なもの: ログイン済みの Codex CLI と、hermes_cli を読み込める Python / Hermes Agent です。Grok は xai-oauth 経由で X Premium の枠を使います。
  • 設定: codexPath(codex)、gptModel(gpt-6.1-sol)、python(py)、pythonVersion(-3.13)、grokModel(grok-4.3)、startDelaySec(20秒)、gapSec(20秒)、maxLines(6)、privateFolders(空)、privateWords(空)です。Python ランチャーに版指定が不要なら pythonVersion を空にします。
  • privateFolders は除外する絶対パスをカンマ区切りで指定します。設定したフォルダーとその配下では送りません。既定では除外しません。
  • privateWords は雑談の材料に渡さない語句をカンマ区切りで指定します。大文字小文字を区別せず部分一致で判定します。既定では除外しません。
  • 注意: 雑談ごとに Codex / Grok の利用枠を使います。不要な場合は off で止めてください。

tag-feed

X のハッシュタグに合う投稿を探し、パネルに新着を表示します。タグを登録すると定期取得します。

  • コマンド: /tag-feed #タグ #別のタグ、/tag-feed です。パネルには即時取得と停止・再開のボタンがあります。
  • 必要なもの: hermes_cli を読み込める Python / Hermes Agent と、Grok の xai-oauth ログインです。
  • 設定: python(py)、pythonVersion(-3.13)、model(grok-4.3)、intervalMinutes(10分、最短2分)です。
  • 注意: X API は使いません。Grok の検索で投稿 URL を探し、X の埋め込み用の公開エンドポイントから本文を取得します。取得ごとに Grok の X Premium 枠を使います。検索結果や公開エンドポイントの状況により取得できない投稿もあります。

x-post

本文に登録タグを付け、ブラウザで X の投稿画面や返信画面を開きます。最後の「ポスト」は人が押します。この Mod が自分で投稿することはありません。

  • コマンド: /x 本文、/x-reply <投稿URL> 本文、/x-tag [add|rm|clear] [タグ]、/x-mode on|off、/x-account @名前、/x-pane です。
  • ペイン: 上にハッシュタグ(入力欄で追加、押すと削除)とメンション、下に投稿欄(返信先と本文。Enter で投稿画面を開く)を並べます。本文はハッシュタグ込みのプレビューと文字数の目安を出します。
  • 必要なもの: X にログインしたブラウザです。メンション検索には別途、PATH 上の Hermes Agent(hermes)と Grok のログインが必要です。hermes が無くても投稿はできます。
  • 設定: userConfig はありません。タグ、推論中だけ投稿可能にするモード、自分のアカウントを上記コマンドで保存します。アカウントの既定値は空で、未設定ならペインに /x-account @名前 で設定 と出ます。hermes のモデルとプロバイダーは環境変数 X_POST_HERMES_MODEL(既定 grok-4.3)と X_POST_HERMES_PROVIDER(既定 xai-oauth)で変えられます。
  • 上限: タグは10個、1つ50文字までです。本文がタグ込みで280を超えると投稿画面を開きません(X に合わせ、日本語などは1文字2、URL は23と数える目安です)。
  • 注意: X API キーは不要です。メンション検索は「更新」を押したときだけ動き、1回30〜40秒かかり Grok の枠を使います。検索なので取りこぼしがあり、X の通知画面の代わりにはなりません。ブラウザは Windows では rundll32、macOS では open、それ以外では xdg-open で開きます。開けなければコマンドが返すリンクから開いてください。デスクトップ版ではペインの幅を Mod から変えられないので、狭いときは境目をドラッグしてください。

動作環境とライセンス

作者は Windows 11 で動かしています。他 OS は未確認です。Mod 対応の Claude Code が必要です。

MIT ライセンスです。詳しくは LICENSE を参照してください。

Source 2 files
hooks/register.tsx 323 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Feed, Post } from '../types'
5
6const PANE = 'tag-feed'
7const posts = atom({ plugin: 'tag-feed', key: 'posts' } as const, [] as Post[])
8const feed = atom({ plugin: 'tag-feed', key: 'feed' } as const, {
9  tags: [],
10  isPaused: false,
11  isFetching: false,
12} as Feed)
13
14const MAX_POSTS = 300
15const MAX_SEEN = 3000
16
17type Config = { python: string; pythonVersion: string; model: string; intervalMs: number }
18
19// 投稿URLから status id を拾う。x.com/i/status/ の形も来る
20const STATUS_RE = /https?:\/\/(?:www\.)?(?:x|twitter)\.com\/(?:[A-Za-z0-9_]+|i(?:\/web)?)\/status\/(\d{6,25})/g
21
22export function normalizeTags(raw: string): string[] {
23  return [
24    ...new Set(
25      raw
26        .split(/[\s,、]+/)
27        .map(s => s.replace(/^[##]/, '').trim())
28        .filter(Boolean),
29    ),
30  ]
31}
32
33export function extractIds(text: string): string[] {
34  return [...new Set([...text.matchAll(STATUS_RE)].map(m => m[1] ?? ''))].filter(Boolean)
35}
36
37// 埋め込み(publish/syndication)と同じ計算。id は数値の近似で足りる
38export function syndicationToken(id: string): string {
39  return ((Number(id) / 1e15) * Math.PI).toString(36).replace(/(0+|\.)/g, '')
40}
41
42function decodeEntities(s: string): string {
43  return s
44    .replace(/&lt;/g, '<')
45    .replace(/&gt;/g, '>')
46    .replace(/&quot;/g, '"')
47    .replace(/&#39;/g, "'")
48    .replace(/&amp;/g, '&')
49}
50
51// Grok は関係ない投稿を混ぜることがあるので、本文かハッシュタグ欄にタグがあるものだけ通す
52export function hasTag(text: string, hashtags: string[], tags: string[]): boolean {
53  const lower = text.toLowerCase()
54  const own = hashtags.map(h => h.toLowerCase())
55  return tags.some(t => {
56    const k = t.toLowerCase()
57    return own.includes(k) || lower.includes(`#${k}`) || lower.includes(`#${k}`)
58  })
59}
60
61export function parseTweet(id: string, body: string): (Post & { hashtags: string[] }) | undefined {
62  let j: any
63  try {
64    j = JSON.parse(body)
65  } catch {
66    return undefined
67  }
68  if (!j || j.__typename === 'TweetTombstone' || !j.user || typeof j.text !== 'string') return undefined
69  const handle = String(j.user.screen_name ?? '')
70  return {
71    id,
72    handle,
73    name: String(j.user.name ?? handle),
74    text: decodeEntities(j.text),
75    time: String(j.created_at ?? ''),
76    url: `https://x.com/${handle || 'i'}/status/${id}`,
77    hashtags: (j.entities?.hashtags ?? []).map((h: any) => String(h.text ?? '')),
78  }
79}
80
81function grokPrompt(tags: string[]): string {
82  const q = tags.map(t => `#${t}`).join(' または ')
83  return (
84    `Xで、ハッシュタグ ${q} が付いた投稿を検索してください。` +
85    'できるだけ新しい投稿から順に、最大20件。' +
86    '出力は投稿URL(https://x.com/ユーザー名/status/数字 の形)だけを1行に1つ並べてください。' +
87    '説明・要約・本文・前置きは不要です。'
88  )
89}
90
91async function askGrok($: EngineInterface, cfg: Config, tags: string[]): Promise<string> {
92  const code =
93    'import sys\n' +
94    'from hermes_cli.main import main\n' +
95    `sys.argv=['hermes','-z',sys.argv[1],'-m',sys.argv[2],'--provider','xai-oauth']\n` +
96    'main()\n'
97  const argv = [
98    cfg.python,
99    ...(cfg.pythonVersion ? [cfg.pythonVersion] : []),
100    '-c',
101    code,
102    grokPrompt(tags),
103    cfg.model,
104  ]
105  const r = await $.process.run(argv, {
106    timeoutMs: 5 * 60_000,
107    env: { PYTHONIOENCODING: 'utf-8', PYTHONUTF8: '1' },
108  })
109  if (r.exitCode !== 0) throw new Error(`hermes exit ${r.exitCode}: ${r.stderr.slice(-400)}`)
110  return r.stdout
111}
112
113async function fetchTweet($: EngineInterface, id: string) {
114  const url = `https://cdn.syndication.twimg.com/tweet-result?id=${id}&lang=ja&token=${syndicationToken(id)}`
115  try {
116    const res = await $.http.fetch(url)
117    return res.ok ? parseTweet(id, res.text) : undefined
118  } catch {
119    return undefined
120  }
121}
122
123function patchFeed($: EngineInterface, fields: Partial<Feed>) {
124  return update($, feed, f => ({ ...f, ...fields }))
125}
126
127async function loadSeen($: EngineInterface): Promise<string[]> {
128  const v = await $.store.get('seen')
129  return Array.isArray(v) ? (v as string[]) : []
130}
131
132export async function runOnce($: EngineInterface, cfg: Config) {
133  const now = await read($, feed)
134  if (now.isFetching || now.tags.length === 0) return
135  await patchFeed($, { isFetching: true, error: undefined })
136  try {
137    const tags = now.tags
138    const out = await askGrok($, cfg, tags)
139    const seen = await loadSeen($)
140    const seenSet = new Set(seen)
141    const fresh = extractIds(out).filter(id => !seenSet.has(id))
142
143    const got = await Promise.all(fresh.map(id => fetchTweet($, id)))
144    const kept: Post[] = []
145    let dropped = 0
146    got.forEach(p => {
147      if (p && hasTag(p.text, p.hashtags, tags)) {
148        const { hashtags: _h, ...post } = p
149        kept.push(post)
150      } else {
151        dropped++
152      }
153    })
154    kept.sort((a, b) => Date.parse(b.time) - Date.parse(a.time))
155
156    // 取れなかったものも seen に入れ、毎回 Grok が返す同じURLで枠を使い直さない
157    await $.store.set('seen', [...fresh, ...seen].slice(0, MAX_SEEN))
158    if (kept.length) {
159      await update($, posts, list => {
160        const merged = [...kept, ...list.filter(x => !kept.some(k => k.id === x.id))]
161        merged.sort((a, b) => Date.parse(b.time) - Date.parse(a.time))
162        return merged.slice(0, MAX_POSTS)
163      })
164      await $.store.set('posts', (await read($, posts)).slice(0, 100))
165    }
166    await patchFeed($, { lastRunAt: await $.clock.now(), lastAdded: kept.length, lastDropped: dropped })
167    $.ui.status(kept.length ? `#${tags[0]} 新着${kept.length}件` : undefined)
168  } catch (err) {
169    await patchFeed($, { lastRunAt: await $.clock.now(), error: String(err).slice(0, 500) })
170  } finally {
171    await patchFeed($, { isFetching: false })
172  }
173}
174
175function fmtTime(iso: string): string {
176  const d = new Date(iso)
177  if (Number.isNaN(d.getTime())) return ''
178  const jst = new Date(d.getTime() + 9 * 3600_000)
179  const p = (n: number) => String(n).padStart(2, '0')
180  return `${p(jst.getUTCMonth() + 1)}/${p(jst.getUTCDate())} ${p(jst.getUTCHours())}:${p(jst.getUTCMinutes())}`
181}
182
183let timer: Timer | undefined
184
185async function setTags($: EngineInterface, cfg: Config, raw: string) {
186  const tags = normalizeTags(raw)
187  await $.store.set('tags', tags)
188  await $.store.set('seen', [])
189  await update($, posts, () => [])
190  await $.store.set('posts', [])
191  await patchFeed($, { tags, error: undefined, lastAdded: undefined, lastDropped: undefined })
192  if (tags.length) void runOnce($, cfg)
193  return tags
194}
195
196async function togglePause($: EngineInterface) {
197  const f = await read($, feed)
198  await patchFeed($, { isPaused: !f.isPaused })
199  await $.store.set('paused', !f.isPaused)
200}
201
202export const register: Register = (on, options) => {
203  const minutes = Math.max(2, Number(options.intervalMinutes) || 10)
204  const cfg: Config = {
205    python: String(options.python || 'py'),
206    pythonVersion: String(options.pythonVersion ?? ''),
207    model: String(options.model || 'grok-4.3'),
208    intervalMs: minutes * 60_000,
209  }
210  on('session.start', async ($, e, next) => {
211    await $.command.register({
212      name: 'tag-feed',
213      description: 'Xのハッシュタグの新着をパネルに流す。引数でタグを指定(例: /tag-feed #ClaudeCode)',
214      argumentHint: '[#タグ ...]',
215    })
216    const storedTags = await $.store.get('tags')
217    const storedPosts = await $.store.get('posts')
218    const paused = (await $.store.get('paused')) === true
219    const tags = Array.isArray(storedTags) ? (storedTags as string[]) : []
220    if (Array.isArray(storedPosts)) await update($, posts, () => storedPosts as Post[])
221    await patchFeed($, { tags, isPaused: paused, isFetching: false })
222
223    timer?.cancel()
224    timer = $.clock.every(cfg.intervalMs, () => {
225      void (async () => {
226        const f = await read($, feed)
227        if (!f.isPaused) await runOnce($, cfg)
228      })()
229    })
230    if (tags.length && !paused) {
231      void $.ui.open({ id: PANE, title: `#${tags.join(' #')}` })
232      void runOnce($, cfg)
233    }
234    return next(e)
235  })
236
237  on('session.end', async ($, e, next) => {
238    timer?.cancel()
239    return next(e)
240  })
241
242  on('command.run', { command: 'tag-feed' }, async ($, e) => {
243    const arg = e.args.trim()
244    if (arg) {
245      const tags = await setTags($, cfg, arg)
246      await $.ui.open({ id: PANE, title: `#${tags.join(' #')}`, focus: true })
247      return { text: `#${tags.join(' #')} を${minutes}分おきに取りに行きます。最初の取得は1〜2分かかります。` }
248    }
249    const f = await read($, feed)
250    await $.ui.open({ id: PANE, title: f.tags.length ? `#${f.tags.join(' #')}` : 'タグフィード', focus: true })
251    return { text: f.tags.length ? 'パネルを開きました。' : 'パネルを開きました。タグを入力してください。' }
252  })
253
254  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
255    const ui = $.ui.resolve(e) as any
256    const { Box, Text, Button, Input, Link } = ui
257    const list = await read($, posts)
258    const f = await read($, feed)
259    const now = await $.clock.now()
260    const ago = f.lastRunAt ? Math.round((now - f.lastRunAt) / 60_000) : undefined
261    const room = Math.max(3, Math.floor(((e.viewport?.rows ?? 40) - 8) / 4))
262    const shown = e.surface === 'terminal' ? list.slice(0, room) : list.slice(0, 100)
263
264    const state = f.isFetching
265      ? 'Grokで検索中…'
266      : f.isPaused
267        ? '停止中'
268        : f.tags.length
269          ? `${minutes}分おきに取得${ago !== undefined ? `(前回 ${ago}分前・新着${f.lastAdded ?? 0}件${f.lastDropped ? `・除外${f.lastDropped}件` : ''})` : ''}`
270          : 'タグ未設定'
271
272    return (
273      <Box flexDirection="column" gap={1}>
274        <Box flexDirection="column">
275          <Text bold>{f.tags.length ? `#${f.tags.join(' #')}` : 'タグ未設定'}</Text>
276          <Text dimColor>{state}</Text>
277          {f.error ? <Text color="red">取得エラー: {f.error}</Text> : null}
278          {Input ? (
279            <Input
280              key="tags"
281              label="タグ >"
282              placeholder="#ClaudeCode など(空白区切りで複数)"
283              submitLabel="切り替え"
284              value=""
285              onSubmit={(v: string) => {
286                if (v.trim()) {
287                  void setTags($, cfg, v).then(t => $.ui.open({ id: PANE, title: `#${t.join(' #')}` }))
288                }
289              }}
290            />
291          ) : (
292            <Text dimColor>タグは /tag-feed #タグ で切り替え</Text>
293          )}
294          <Box flexDirection="row" gap={1}>
295            <Button
296              key="now"
297              label="今すぐ取得"
298              variant="primary"
299              onPress={() => void runOnce($, cfg)}
300            />
301            <Button key="pause" label={f.isPaused ? '再開' : '止める'} onPress={() => togglePause($)} />
302          </Box>
303        </Box>
304        {shown.length === 0 && f.tags.length > 0 ? (
305          <Text dimColor>{f.isFetching ? '最初の取得中です(1〜2分)' : 'まだ投稿がありません。'}</Text>
306        ) : null}
307        {shown.map(p => (
308          <Box key={p.id} flexDirection="column">
309            <Text>
310              <Text bold>{p.name}</Text>
311              <Text dimColor>
312                {' '}@{p.handle} {fmtTime(p.time)}
313              </Text>
314            </Text>
315            <Text>{p.text}</Text>
316            {Link ? <Link key={`l-${p.id}`} href={p.url} label="Xで開く" /> : <Text dimColor>{p.url}</Text>}
317          </Box>
318        ))}
319      </Box>
320    )
321  })
322}
323
types/index.d.ts 25 lines
1export type Post = {
2  id: string
3  handle: string
4  name: string
5  text: string
6  time: string
7  url: string
8}
9
10export type Feed = {
11  tags: string[]
12  isPaused: boolean
13  isFetching: boolean
14  lastRunAt?: number
15  lastAdded?: number
16  lastDropped?: number
17  error?: string
18}
19
20declare module 'claude-code' {
21  interface PluginState {
22    'tag-feed': { posts: Post[]; feed: Feed }
23  }
24}
25