SLOPSHOPPER

cc-sticky-probe

M0 probe for cc-sticky-notes: logs what the Mods API really does

newbandrowstoastpromptmodel
v0.0.1no licenseupdated 2026-10-07k7term1a/cc-sticky-notes/probes/m0
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cc-sticky-probe
› fix the failing auth test and add an audit log call ● cc-sticky-probe: PROBE session.start e={"cwd":"/work/app","surface":"terminal","isInteractive":true} ● cc-sticky-probe: PROBE version={"version":"2.1.289","base":"2.1.289","builtAt":"2026-10-03T00:00:00Z"} ⏺ Read(src/auth.ts) ⎿ 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 ● cc-sticky-probe: PROBE id=preview-session root=/work/app cwd=/work/app ● cc-sticky-probe: PROBE surfaces=["terminal"] plugin.root=/plugins/cc-sticky-probe [ open ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
[ open ]
README

cc-sticky-notes

一個 Claude Code mod:在專案 session 裡隨口問的「為什麼 / 這是什麼」不會進主線對話,而是變成「便利貼」,在背景回答、顯示在右側 pane,並存成一棵同專案所有 session 共用的樹。主線的 context 保持乾淨,追問也能一路掛在同一條旁支下。

版本 0.0.1,給朋友試用中。設計與決策:PLAN.md;Mods API 實測紀錄:PROBE.md。

需求

  • Claude Code 2.1.288 以上(Mods 是 early access,在 2.1.288 / 2.1.289、Windows 的 Claude Desktop Code 分頁與終端機測過)。
  • 選用:TypeSafe 的 Jev API key(自動分類用)、OpenAI API key(統整層可改用 OpenAI)。沒有也能用,見下方「金鑰」。

安裝

git clone https://github.com/k7term1a/cc-sticky-notes.git

終端機:每次啟動時帶上資料夾路徑。

claude --plugin-dir /path/to/cc-sticky-notes

Claude Desktop(Code 分頁):在 ~/.claude/settings.json 的 env 加上這個資料夾,之後開的 session 都會載入。

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "C:\\Users\\you\\cc-sticky-notes",
    "CLAUDE_CODE_PLUGIN_DIR_WATCH": "1"
  }
}
  • 改完要開新的 session(已經開著的 session 不會重新讀設定)。
  • CLAUDE_CODE_PLUGIN_DIR_WATCH 是選用的:設了之後,mod 的檔案一更新(例如 git pull),開著的 session 就會自動重載。
  • 裝好後在輸入框下方右側會看到 cc-sticky-note。打 /sn doctor 可以檢查金鑰有沒有讀到。

金鑰(選用)

建一個 ~/.claude/sticky-notes/.env(Windows 是 C:\Users\<你>\.claude\sticky-notes\.env):

TYPESAFE_API_KEY=apikey_...
OPENAI_API_KEY=sk-...
  • 依序找:環境變數 → ~/.claude/sticky-notes/.env → mod 資料夾裡的 .env。不會讀你專案自己的 .env(那裡常有那個專案自己的 OpenAI key)。
  • 金鑰值不會被印出、記錄或存進 store;/sn doctor 只告訴你找到沒有、從哪裡找到。
  • 沒有 Jev key:不做自動分類,所有輸入照常進主線,不會跳任何對話框。便利貼只能用 /sn new <問題> 或 pane 的追問框建立。
  • 沒有 OpenAI key:完全不影響,預設本來就用 Claude(你登入的訂閱)寫標題和摘要。

怎麼用

直接在輸入框問

像平常一樣打字。Jev 判斷這句是「專案任務」還是「知識旁問」:

  • 專案任務:照常送給 Claude,什麼都不會變。
  • 知識旁問(例如「CRDT 是什麼?」):這句不會進主線。畫面上出現一張卡片:
  A hook blocked your prompt
  Prompt dropped by a hook: cc-sticky-note:Jev 96% 判斷是旁支 → 便利貼「CRDT 是什麼?」

幾秒後答案出現在右側 pane(會自動打開),對話裡留一行灰字預覽。灰字和卡片 Claude 都讀不到。

  • Jev 不太確定時(信心低於 60%):跳出「這句要進主線還是旁支?(32%)」,你選。
  • 專案相關的疑問(「為什麼我們這裡用 X?」):照常問 Claude,並在樹上留一個書籤。

追問

問完一則之後,輸入框下方會顯示 cc-sticky-note <正在追問的題目>。這時在輸入框直接打字,Jev 會判斷是不是在延續這個話題;是的話就掛在同一條旁支下。想結束追問,按 pane 裡的「回到主線」或打 /sn back。

也可以在 pane 裡點任何一則便利貼,接著打字就是追問它;或用 pane 卡片裡的追問框(不經 Jev,直接掛在那一則下面)。

右側 pane

打 /sn 開關。內容:

  • 便利貼:整棵樹,追問會縮排列在下面。● 是這個 session 正在追問的、◀ 是目前選中的、… 是還在回答、↑ 是已回到主線的。
  • 選中的那一則:時間、誰答的(「Claude(看得到專案)」或「無專案脈絡」)、第幾層、前面幾層的一行摘要、問題與完整答案、追問框、「回到主線」。

指令

/sn 和 /sticky-note 完全一樣,/sn 比較好打。

指令作用
/sn開 / 關右側 pane
/sn new <問題>手動開一張便利貼(不經 Jev)
/sn back結束追問,回到主線
/sn feedback good剛剛那句 Jev 分對了
`/sn feedback bad <main\sidebar\followup\project>`分錯了,正確應該是哪一種(也可以打「錯 主線」)
/sn calibrate用你標記過的資料,看各門檻下 Jev 的準確率
/sn doctor檢查兩把金鑰找到沒有、從哪裡找到

幫忙測試

最有用的回饋是 Jev 分錯的時候:被攔成便利貼的其實是任務,或任務被當成旁問。打 /sn feedback bad main(或 sidebar 等),再截圖給我。

設定

下列欄位可以在 Claude Code 的設定選單(/config,終端機)調整:

欄位預設說明
autoOpenPanetrue答案回來時自動打開 pane 並選中
routeThreshold0.6Jev 信心低於這個就問你
followupThreshold0.6判成追問的門檻
summaryProviderclaude標題 / 摘要用誰寫:claude 或 openai
claudeModel / claudeEfforthaiku / low寫標題摘要的 Claude 模型與 effort(旁答本身一律用主線的模型)
openaiModel / openaiReasoningEffortgpt-5-mini / minimalsummaryProvider = openai 時用

已知限制

  • 被攔下的那句,Desktop 一定會顯示那張「A hook blocked your prompt」卡片,標題改不掉。Claude 讀不到這句,但它仍會留在本機的 session 紀錄檔裡。
  • 旁答用的是主線同一個模型(Claude Code 的限制),算在你的訂閱用量裡;有 prompt cache,通常 1–3 秒。
  • 剛重開或 resume 一個 session、主線還沒跑過任何回合之前,旁答拿不到專案脈絡,pane 會標「無專案脈絡」。
  • Jev 會收到你最近 3 句主線 prompt 與最後一則回覆(各取前 300 字)、正在追問的題目,以及新的這句(判斷用)。介意的話不要設 Jev key。
  • 便利貼存在本機:~/.claude/plugins/store/cc-sticky-notes_*.json。用 --plugin-dir 載入和正式安裝可能是不同的檔。
  • 側邊欄外觀還很陽春,下一版會改。

開發

claude plugin validate .
claude plugin test .
npx -p typescript@5.6 tsc -p .

tsconfig.json extends .claude-plugin/types/tsconfig.json,那是引擎載入 mod 時寫出來的型別(已 git-ignore),所以第一次 type-check 前要先載入一次 mod。

  • hooks/register.tsx:所有 hook,以及 portsOf($)。只有這個檔碰 $(PROBE.md P1)。
  • hooks/tree.ts、route.ts、jev.ts、redact.ts、digest.ts、answer.ts:邏輯模組,純函式或吃 Ports。
  • hooks/notes.ts:流程(路由 → 便利貼 → 背景回答 → 路由樣本)。
  • hooks/feedback.ts:路由樣本、/sn feedback、calibrate。
  • hooks/secrets.ts:金鑰從哪裡來。
  • hooks/pane.tsx:右側 pane 與輸入框下方的狀態列。
  • probes/m0/:M0 探針 mod(cc-sticky-probe),獨立的 plugin。
Source 1 files
hooks/register.tsx 268 lines
1// M0 probe. Every line it logs starts with "PROBE " so the stream-json output can be grepped.
2// Driven by the prompt prefix: "probe-<name> <rest>". Unknown prompts pass through.
3import type { Register } from 'claude-code'
4
5export const register: Register = on => {
6
7  on('session.start', async ($, e, next) => {
8    const out = (s: string) => $.ui.log(`PROBE ${s}`)
9    out(`session.start e=${JSON.stringify(e)}`)
10    out(`version=${JSON.stringify(await $.session.version())}`)
11    out(`id=${await $.session.id()} root=${await $.session.root()} cwd=${await $.session.cwd()}`)
12    out(`surfaces=${JSON.stringify(await $.session.surfaces())} plugin.root=${$.plugin.root}`)
13    out(`env TYPESAFE_API_KEY set=${(await $.env.get('TYPESAFE_API_KEY')) !== undefined}`)
14    out(`env OPENAI_API_KEY set=${(await $.env.get('OPENAI_API_KEY')) !== undefined}`)
15    // store: who was here before (cross-session persistence)
16    const id = await $.session.id()
17    const visits = ((await $.store.get('visits')) as string[] | undefined) ?? []
18    out(`store visits(before)=${JSON.stringify(visits)}`)
19    await $.store.set('visits', [...visits, id].slice(-10))
20    await $.store.set(`mark:${id}`, await $.clock.now())
21    out(`store keys=${JSON.stringify(await $.store.keys())}`)
22    return next(e)
23  })
24
25  on('turn.start', async ($, e, next) => {
26    $.ui.log(`PROBE turn.start turnId=${e.turnId} text=${JSON.stringify(e.text.slice(0, 60))}`)
27    return next(e)
28  })
29
30  on('turn.complete', async ($, e, next) => {
31    $.ui.log(`PROBE turn.complete keys=${Object.keys(e).join(',')}`)
32    // second look at the store: did a concurrent session write meanwhile?
33    $.ui.log(`PROBE store keys at turn.complete=${JSON.stringify(await $.store.keys())}`)
34    return next(e)
35  })
36
37  on('prompt.submit', async ($, e, next) => {
38    const out = (s: string) => $.ui.log(`PROBE ${s}`)
39    out(`prompt.submit keys=${Object.keys(e).join(',')} turnId=${e.turnId} wait=${e.wait} origin=${JSON.stringify(e.origin)}`)
40    const m = /^probe-([a-z-]+)\s*([\s\S]*)$/.exec(e.text)
41    if (!m) return next(e)
42    const [, name, rest] = m
43    const t0 = await $.clock.now()
44    const ms = async () => (await $.clock.now()) - t0
45
46    switch (name) {
47      case 'drop':
48        return { drop: 'cc-sticky-probe: dropped on purpose' }
49
50      case 'fork': {
51        const r = await $.model.fork({ prompt: rest || 'In one sentence: what is TCP backpressure?' })
52        out(`fork ms=${await ms()} result=${JSON.stringify(r).slice(0, 600)}`)
53        return { drop: 'cc-sticky-probe: fork answered off the transcript' }
54      }
55
56      case 'fork-history': {
57        // Can a fork carry side-thread history? Only via the prompt text (ModelForkRequest is { prompt }).
58        const history = 'Q1: What is TCP backpressure?\nA1: When a receiver is slower than a sender, buffers fill and the sender is told to slow down.\n'
59        const r = await $.model.fork({ prompt: `[side thread so far]\n${history}\n[follow-up]\n${rest || 'How does that relate to Node streams? One sentence.'}` })
60        out(`fork-history ms=${await ms()} result=${JSON.stringify(r).slice(0, 600)}`)
61        return { drop: 'cc-sticky-probe: fork-history done' }
62      }
63
64      case 'fork-bg': {
65        // Drop first, answer in the background.
66        $.clock.after(0, async () => {
67          const r = await $.model.fork({ prompt: rest || 'One sentence: what is a hash map?' })
68          $.ui.log(`PROBE fork-bg ms=${(await $.clock.now()) - t0} result=${JSON.stringify(r).slice(0, 400)}`)
69        })
70        return { drop: 'cc-sticky-probe: fork-bg scheduled' }
71      }
72
73      case 'complete': {
74        const r = await $.model.complete({
75          model: 'haiku',
76          effort: 'low',
77          system: 'Reply with a JSON object {"title": string (<=12 chars), "summary": string (3 sentences)}.',
78          prompt: rest || 'Q: What is TCP backpressure? A: Receiver slower than sender, sender slows down.',
79          maxTokens: 300,
80          timeoutMs: 20000,
81        })
82        out(`complete ms=${await ms()} result=${JSON.stringify(r).slice(0, 600)}`)
83        return { drop: 'cc-sticky-probe: complete done' }
84      }
85
86      case 'model': {
87        // Does $.model.complete honour an alias and a full id? (claudeModel userConfig)
88        for (const model of (rest || 'sonnet claude-haiku-4-5-20251001 opus').split(/\s+/)) {
89          try {
90            const r = await $.model.complete({ model, prompt: 'Reply with only the name of the model you are.', maxTokens: 40, effort: 'low', timeoutMs: 30000 })
91            out(`model ${model} ms=${await ms()} result=${JSON.stringify(r).slice(0, 300)}`)
92          } catch (err) {
93            out(`model ${model} threw ${String(err).slice(0, 200)}`)
94          }
95        }
96        return { drop: 'cc-sticky-probe: model done' }
97      }
98
99      case 'classify': {
100        try {
101          const label = await $.model.classify(rest || 'why does TCP need a three-way handshake?', [
102            'main_task', 'sidebar_knowledge', 'project_question',
103          ])
104          out(`classify ms=${await ms()} label=${JSON.stringify(label)}`)
105        } catch (err) {
106          out(`classify threw ${String(err)}`)
107        }
108        return { drop: 'cc-sticky-probe: classify done' }
109      }
110
111      case 'messages': {
112        const msgs = await $.session.messages()
113        if (Array.isArray(msgs)) {
114          out(`messages n=${msgs.length}`)
115          for (const one of msgs.slice(-4)) {
116            out(`  msg role=${one.role} keys=${Object.keys(one).join(',')} text=${JSON.stringify(one.text.slice(0, 80))} toolUses=${one.toolUses.length}`)
117          }
118        } else out(`messages deny=${JSON.stringify(msgs)}`)
119        out(`turns=${await $.session.turns()} usage=${JSON.stringify(await $.session.usage()).slice(0, 400)}`)
120        return { drop: 'cc-sticky-probe: messages done' }
121      }
122
123      case 'http': {
124        for (const url of ['https://api.openai.com/v1/models', 'https://api.typesafe.ai/v1/systemone']) {
125          try {
126            const r = await $.http.fetch(url, { method: url.includes('typesafe') ? 'POST' : 'GET', headers: { 'content-type': 'application/json' }, body: url.includes('typesafe') ? '{}' : undefined })
127            out(`http ${url} status=${r.status} ok=${r.ok} headerKeys=${Object.keys(r.headers).slice(0, 8).join(',')} text=${JSON.stringify(r.text.slice(0, 200))}`)
128          } catch (err) {
129            out(`http ${url} threw ${String(err)}`)
130          }
131        }
132        return { drop: 'cc-sticky-probe: http done' }
133      }
134
135      case 'jev': {
136        // Real Jev call with the four PLAN.md questions; logs the raw response.
137        const key = await $.env.get('TYPESAFE_API_KEY')
138        if (!key) {
139          out('jev: no TYPESAFE_API_KEY')
140          return { drop: 'cc-sticky-probe: jev skipped' }
141        }
142        const prompt = rest || 'TCP 的 backpressure 是什麼原理?'
143        const body = {
144          model: 'jev-latest',
145          state: `[recent main-line context]\nuser: 把 auth 改成 session cookie\nassistant: 已改好,剩 token 輪替。\n\n[sidebar state]\nactive_thread_title: none\nactive_thread_last_question: none\nmain_turns_since_last_sidebar: 2\n\n[new prompt]\n${prompt}`,
146          questions: {
147            route: {
148              type: 'choice',
149              instructions: 'Where should the new prompt go?',
150              criteria: {
151                main_task: 'Advances the project: an instruction, a code change, or a decision about what Claude just did',
152                sidebar_knowledge: 'Asks about a principle, concept or background knowledge; does not ask Claude to change anything',
153                project_question: 'A question about this project itself whose answer belongs in the main conversation',
154              },
155            },
156            is_followup: { type: 'noul', instructions: 'Does the new prompt continue the topic of active_thread?' },
157            needs_project_ctx: { type: 'noul', instructions: "Does answering the new prompt require seeing the project's code or conversation?" },
158            tag: { type: 'choice', instructions: 'Which topic tag fits the new prompt?', criteria: { network: 'About network', db: 'About db', __new__: 'None of the existing tags fits' } },
159          },
160        }
161        const r = await $.http.fetch('https://api.typesafe.ai/v1/systemone', {
162          method: 'POST',
163          headers: { 'content-type': 'application/json', authorization: `Bearer ${key}` },
164          body: JSON.stringify(body),
165        })
166        out(`jev ms=${await ms()} status=${r.status} text=${r.text.slice(0, 1500)}`)
167        return { drop: 'cc-sticky-probe: jev done' }
168      }
169
170      case 'openai': {
171        const key = await $.env.get('OPENAI_API_KEY')
172        if (!key) {
173          out('openai: no OPENAI_API_KEY')
174          return { drop: 'cc-sticky-probe: openai skipped' }
175        }
176        const r = await $.http.fetch('https://api.openai.com/v1/chat/completions', {
177          method: 'POST',
178          headers: { 'content-type': 'application/json', authorization: `Bearer ${key}` },
179          body: JSON.stringify({
180            model: rest || 'gpt-5-mini',
181            max_completion_tokens: 400,
182            reasoning_effort: 'minimal',
183            messages: [
184              { role: 'system', content: 'Reply with JSON only: {"title": "<=12 chars", "summary": "three sentences"}' },
185              { role: 'user', content: 'Q: What is TCP backpressure? A: When the receiver is slower, buffers fill and the sender slows down.' },
186            ],
187          }),
188        })
189        out(`openai ms=${await ms()} status=${r.status} text=${r.text.slice(0, 1500)}`)
190        return { drop: 'cc-sticky-probe: openai done' }
191      }
192
193      case 'agents': {
194        try {
195          const r = await $.tool.call({ tool: 'ListAgents' })
196          out(`ListAgents result=${JSON.stringify(r).slice(0, 800)}`)
197        } catch (err) {
198          out(`ListAgents threw ${String(err)}`)
199        }
200        return { drop: 'cc-sticky-probe: agents done' }
201      }
202
203      case 'suggest': {
204        const r = await $.prompt.suggest({ text: '正在追問:probe' })
205        out(`suggest result=${JSON.stringify(r)}`)
206        return { drop: 'cc-sticky-probe: suggest done' }
207      }
208
209      case 'store-race': {
210        // Two processes run this at once. Separate keys + one shared read-modify-write key.
211        const id = await $.session.id()
212        await $.store.set(`race:${id}`, t0)
213        const before = ((await $.store.get('race-list')) as string[] | undefined) ?? []
214        await $.clock.sleep(3000)
215        await $.store.set('race-list', [...before, id])
216        out(`store-race id=${id} keysAfter=${JSON.stringify((await $.store.keys()).filter(k => k.startsWith('race')))} list=${JSON.stringify(await $.store.get('race-list'))}`)
217        return { drop: 'cc-sticky-probe: store-race done' }
218      }
219
220      case 'store-read': {
221        out(`store-read keys=${JSON.stringify((await $.store.keys()).filter(k => k.startsWith('race')))} list=${JSON.stringify(await $.store.get('race-list'))}`)
222        return { drop: 'cc-sticky-probe: store-read done' }
223      }
224
225      case 'budget': {
226        // Does a long $ call count against the 10 s budget? $.clock.sleep does; http does not.
227        try {
228          await $.http.fetch('https://httpbin.org/delay/12')
229          out(`budget http 12s ok ms=${await ms()}`)
230        } catch (err) {
231          out(`budget http threw ${String(err)} ms=${await ms()}`)
232        }
233        return { drop: 'cc-sticky-probe: budget done' }
234      }
235
236      case 'context': {
237        // project_question path: pass through with extra context
238        return next({ ...e, text: rest ?? '', context: [...(e.context ?? []), '[sticky-notes] project question; answer briefly'] })
239      }
240
241      default:
242        return next(e)
243    }
244  })
245
246  // UI probe: a badge beside the original user row, and a hotkey the type says is refused.
247  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
248    if (!e.props.text.includes('badge-me')) return next(e)
249    const { Box, Button } = $.ui.resolve(e)
250    const original = await next(e)
251    return (
252      <Box flexDirection="row">
253        {original}
254        <Button key="badge" plain label="📌 1" onPress={() => $.ui.toast('badge pressed')} />
255      </Box>
256    )
257  })
258
259  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
260    const { Box, Button } = $.ui.resolve(e)
261    return (
262      <Box>
263        <Button key="alt" label="open" hotkey="⌥s" onPress={() => {}} />
264      </Box>
265    )
266  })
267}
268