セッションごとのメモをメモ帳で書き、先頭の1行を入力欄の上に出す(/memo)

Windows 版の Claude Desktop(Code タブ)で使う、Claude Code の mod(plugin の中で動く hooks module)を2つ入れたマーケットプレイスです。 作った経緯と使ってみた感想は、ブログの記事に書きました: https://blog.sutachi.jp/posts/claude-code-mods-file-browser-session-memo/
| mod | 何をするか | 入口 |
|---|---|---|
| file-browser | 作業ディレクトリのファイルを木の形でペインに出します。フォルダは押すと開閉し、文字のファイル(.md・.txt・.csv・.json・.py など)はプレビューのペインで中身を見られます。それ以外のファイルは既定のアプリで開きます。各行の @ を押すと、入力欄に @<相対パス> が入ります(送信はしません)。 | /files・入力欄の上の帯の「ファイル一覧」 |
| session-memo | セッションごとにメモを1枚持てます。「メモを編集」を押すとメモ帳で開き、保存すると中身のある先頭の1行が入力欄の上の帯に出ます。/memo <文> と打てば、メモ帳を開かずに1行足せます。同じフォルダで前に使ったセッションのメモも開けます。 | /memo・帯の「メモを編集」 |
explorer.exe と notepad.exe で開きます。claude --version で確かめられます。これより古い版で動くかは確かめていません。allowManagedModsOnly が有効だと、ここから入れた mod は読み込まれません。GitHub から入れた plugin は、利用者自身の mod として扱われるためです。/plugin marketplace add AgemameSutachi/claude-code-mods
/plugin install file-browser@sutachi-mods
/plugin install session-memo@sutachi-mods
入れたら、Claude Code を開き直してください。
/plugin uninstall file-browser@sutachi-mods
/plugin uninstall session-memo@sutachi-mods
/plugin marketplace remove sutachi-mods
session-memo のメモの実体(~/.claude/session-memo/ の下の .txt)は、mod を消しても残ります。要らなければ、そのフォルダを手で消してください。
session.start(コマンドの登録)・command.run・ui.render(帯とペイン)だけです。道具の実行や許可に関わるイベントは使いません。explorer.exe にパスを1つ渡す形でだけ起動します(シェルは通しません)。~/.claude/session-memo/<セッション id>.txt)の下だけです。作業ディレクトリには書きません。外部のアプリは、notepad.exe にメモのパスを1つ渡す形でだけ起動します。@ 参照にできません(書き方を確かめていないため)。USERPROFILE も HOME も取れないとメモを置けません(帯に「家のフォルダが分からないのでメモを置けません」と出ます)。session-memo は、zenn で公開されている session-memo(yesodco さん・2026-10-06)の着想を借りました。コードは借りていません。
MIT(LICENSE)
hooks/register.js 412 lines1// セッションごとのメモ(Claude Code の mod)
2//
3// 【なぜ要るか】いくつものセッションを並べて使うと、どれが何の作業で次に何をするのかが見えなくなる。
4// 着想は zenn の session-memo(yesodco さん・2026-10-06)。コードは借りず自前で書いた。
5// 【何をするか】セッションごとに1枚のテキストのメモ。帯の「メモを編集」(または /memo)で Windows のメモ帳が開き、
6// 改行も貼り付けも普通に書ける。保存すると、入力欄の上の帯にメモの中身のある先頭の1行が出る(空なら「メモなし」)。
7// /memo <文> と打てば、メモ帳を開かずに1行足せる。同じ作業ディレクトリの前のセッションのメモは「前のメモ」のペインで開ける。
8// mods の Input は1行だけで
9// 複数行の指定が無い(2.1.288 の InputProps)ので、書くのはメモ帳に任せた。
10// 【保存】メモの実体は ~/.claude/session-memo/<セッション id>.txt(作業ディレクトリには書かない)。
11// どのファイルがどの作業ディレクトリのものかは $.store に、セッションごとに別の鍵(memo-file:<id>)で置く
12// (全セッションが1つの $.store を共有するので、鍵を分けて書き合いを避ける・資料の Save from more than one session)。
13// 前の版のメモ($.store の memo:<id>・作業・次・自由欄)は、初回に見出しつきのテキストにして各セッションの .txt の頭へ写す。
14// 前の版の鍵は消さない(読まないだけ)。
15// 【線】登録するのは session.start(コマンドの登録)・command.run・ui.render だけ。道具や許可に関わる
16// イベントは登録しない。$.fs.write はメモの置き場の下だけ(memoPath を通したパスだけ)。
17// $.process.spawn は notepad.exe にメモのパスを1つ渡す形だけ(シェルを通さない)。
18// 【静的解析の決まり】$ は最上位の関数にだけ渡す・$ の名前空間を変数に入れない・on のイベント名は文字列で書く。
19
20const PANE = 'session-memo-pane'
21// 前の版の鍵(読むだけ・消さない)
22const OLD_PREFIX = 'memo:'
23// セッションごとの「どのファイルか」の鍵
24const FILE_PREFIX = 'memo-file:'
25// 前の版のメモを写したかの印(全体で1つ)
26const MIGRATED_KEY = 'memo-v2-migrated'
27// 写したメモの見出し(2回写さないための印にもなる)
28const MIGRATED_MARK = '【前の版のメモ】'
29// $.store に残す memo-file の数(メモのファイルは消さない。古い索引だけ消す)
30const MAX_INDEX = 300
31// 1行で書く /memo <文> の長さ
32const MAX_LINE = 300
33// 帯に出す1行の長さ
34const BAND_CHARS = 120
35// ペインに出す前のメモの数と、ペインに出すメモの字数
36const PAST_SHOWN = 10
37const PANE_CHARS = 20000
38// 帯を描くたびにセッション id とメモの更新を確かめ直す間隔
39const RECHECK_MS = 3000
40
41let memoDir = null
42let sessionId = null
43let sessionCwd = null
44let checkedAt = 0
45let memoText = ''
46let memoMtime = -1
47let past = []
48let notice = ''
49// メモ帳を開く操作の知らせを出した時刻(帯にはこの後 BAND_NOTICE_MS の間だけ、メモの1行の代わりに出す)
50let noticeAt = 0
51const BAND_NOTICE_MS = 20000
52
53function messageOf(err) {
54 return String((err && err.message) || err)
55}
56
57// 比べるための形(/ 区切り・末尾の / なし・小文字。Windows は大小を区別しない)
58function comparable(path) {
59 return String(path || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
60}
61
62function two(n) {
63 return n < 10 ? '0' + n : String(n)
64}
65
66// 10/07 21:40 の形(この PC の時計の時刻)
67function stamp(ms) {
68 const d = new Date(ms)
69 return two(d.getMonth() + 1) + '/' + two(d.getDate()) + ' ' + two(d.getHours()) + ':' + two(d.getMinutes())
70}
71
72// 中身のある先頭の1行(無ければ '')
73function firstLine(text) {
74 for (const line of String(text || '').split(/\r?\n/)) {
75 if (line.trim()) return line.trim()
76 }
77 return ''
78}
79
80// メモの置き場(~/.claude/session-memo)。家のフォルダが分からなければ null
81async function ensureDir($) {
82 if (memoDir) return memoDir
83 const home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || ''
84 if (!home) return null
85 const sep = home.includes('\\') ? '\\' : '/'
86 memoDir = home.replace(/[\\/]+$/, '') + sep + '.claude' + sep + 'session-memo'
87 return memoDir
88}
89
90// セッション id からメモのパス。id が英数字・-・_ でなければ断る(置き場の外へ出さない)
91function memoPath(id) {
92 if (!memoDir) throw new Error('メモの置き場が決まっていません')
93 if (!/^[A-Za-z0-9_-]{1,80}$/.test(String(id))) throw new Error('セッション id の形が想定外です: ' + id)
94 const sep = memoDir.includes('\\') ? '\\' : '/'
95 return memoDir + sep + id + '.txt'
96}
97
98// メモのファイルを読む(無ければ '')
99async function readFile($, id) {
100 try {
101 return String(await $.fs.read(memoPath(id)))
102 } catch (err) {
103 return ''
104 }
105}
106
107// いまのセッションのメモを読み直す(id が替わったか、ファイルの更新時刻が変わったときだけ)
108async function ensureSession($, force) {
109 const now = Date.now()
110 if (!force && sessionId && now - checkedAt < RECHECK_MS) return
111 checkedAt = now
112 if (!(await ensureDir($))) {
113 // 押した直後の知らせ(段つき)は帯を描き直しても消さない
114 if (!(noticeAt && Date.now() - noticeAt < BAND_NOTICE_MS)) notice = '家のフォルダが分からないのでメモを置けません'
115 return
116 }
117 const id = await $.session.id()
118 if (id !== sessionId) {
119 sessionId = id
120 sessionCwd = await $.session.cwd()
121 memoMtime = -1
122 notice = ''
123 await loadPast($)
124 }
125 let mtime = 0
126 try {
127 mtime = (await $.fs.stat(memoPath(sessionId))).mtimeMs || 0
128 } catch (err) {
129 mtime = 0
130 }
131 if (force || mtime !== memoMtime) {
132 memoText = mtime ? await readFile($, sessionId) : ''
133 memoMtime = mtime
134 }
135}
136
137// このセッションのメモがどの作業ディレクトリのものかを残す(前のメモの一覧に使う)
138async function touchIndex($) {
139 await $.store.set(FILE_PREFIX + sessionId, { cwd: sessionCwd, updatedAt: Date.now() })
140 const keys = (await $.store.keys()).filter((key) => key.startsWith(FILE_PREFIX))
141 if (keys.length <= MAX_INDEX) return
142 const rows = []
143 for (const key of keys) {
144 const saved = await $.store.get(key)
145 rows.push({ key, at: (saved && Number(saved.updatedAt)) || 0 })
146 }
147 rows.sort((a, b) => a.at - b.at)
148 for (const row of rows.slice(0, rows.length - MAX_INDEX)) {
149 if (row.key !== FILE_PREFIX + sessionId) await $.store.delete(row.key)
150 }
151}
152
153// 同じ作業ディレクトリの、ほかのセッションのメモ(新しい順・中身のあるものだけ)
154async function loadPast($) {
155 const here = comparable(sessionCwd)
156 const rows = []
157 for (const key of await $.store.keys()) {
158 if (!key.startsWith(FILE_PREFIX) || key === FILE_PREFIX + sessionId) continue
159 const saved = await $.store.get(key)
160 if (!saved || comparable(saved.cwd) !== here) continue
161 rows.push({ id: key.slice(FILE_PREFIX.length), updatedAt: Number(saved.updatedAt) || 0 })
162 }
163 rows.sort((a, b) => b.updatedAt - a.updatedAt)
164 past = []
165 for (const row of rows) {
166 if (past.length >= PAST_SHOWN) break
167 const text = await readFile($, row.id)
168 if (text.trim()) past.push({ ...row, head: firstLine(text) })
169 }
170}
171
172// メモを書く(置き場の下の、このセッションのファイルだけ)
173async function writeMemo($, text) {
174 await $.fs.write(memoPath(sessionId), text)
175 memoText = text
176 memoMtime = -1
177 await touchIndex($)
178}
179
180// メモ帳を起こす。$.process.spawn は Promise ではなく async iterable を返し、子は最初の読み出しで起動し
181// (起動できなければそこで reject)、ループを抜けると殺される(index.d.ts の $.process.spawn)。
182// 閉じるまで待たないよう、ループは裏で回し続けて待たない。ループはモジュールと一緒に終わるので、
183// セッションを閉じるとメモ帳も閉じることがある(保存してから閉じるよう知らせる)
184function runNotepad($, id) {
185 void (async () => {
186 try {
187 // メモ帳は何も書かない。書いたとしても読み捨てる(読まずに溜めると子が止まる)
188 for await (const piece of $.process.spawn({ argv: ['notepad.exe', memoPath(id)] })) void piece
189 } catch (err) {
190 failAt($, '起動', messageOf(err))
191 }
192 })()
193}
194
195// メモ帳を開けなかったことを、どの段で止まったか(家のフォルダ・セッション・書き込み・起動)つきで必ず知らせる。
196// 黙って戻る道があると、押した人に止まった場所が分からない。どの道でも toast・notice に段を書く
197function failAt($, stage, detail) {
198 notice = 'メモ帳を開けませんでした(' + stage + '): ' + detail
199 noticeAt = Date.now()
200 $.ui.toast('メモ帳を開けませんでした(' + stage + ')')
201 $.ui.invalidate('ui.render')
202}
203
204// メモ帳で開く(無ければ空のファイルを作ってから。メモ帳は「作りますか」と聞かずに開ける)
205async function openNotepad($, id) {
206 try {
207 if (!(await $.fs.exists(memoPath(id)))) await $.fs.write(memoPath(id), '')
208 if (id === sessionId) await touchIndex($)
209 } catch (err) {
210 failAt($, '書き込み', messageOf(err))
211 return
212 }
213 runNotepad($, id)
214 // spawn は起動できたことを知らせない(メモ帳は何も書かない)ので、出てこないときの逃げ道も書く
215 notice = 'メモ帳を起こしました。保存すると帯に出ます(出てこなければ /memo <文> で1行ずつ足せます)'
216 noticeAt = Date.now()
217 $.ui.toast('メモ帳を起こしました。書いたら保存してください(セッションを閉じるとメモ帳も閉じることがあります)')
218 $.ui.invalidate('ui.render')
219}
220
221async function editMemo($) {
222 // 押下が届いたことを先に見せる(届いていないのか、途中で止まったのかを見分けるため)
223 notice = 'メモ帳を開いています…'
224 noticeAt = Date.now()
225 $.ui.invalidate('ui.render')
226 try {
227 if (!(await ensureDir($))) return failAt($, '家のフォルダ', 'USERPROFILE も HOME も取れません')
228 } catch (err) {
229 return failAt($, '家のフォルダ', messageOf(err))
230 }
231 try {
232 await ensureSession($, true)
233 } catch (err) {
234 return failAt($, 'セッション', messageOf(err))
235 }
236 if (!sessionId) return failAt($, 'セッション', 'セッション id が取れません')
237 await openNotepad($, sessionId)
238}
239
240async function openPane($) {
241 await ensureSession($, true)
242 await loadPast($)
243 return $.ui.open({ id: PANE, title: 'メモ', focus: true, closeOnEscape: true })
244}
245
246// 前の版のメモ1件を、見出しつきのテキストにする
247function oldMemoText(saved) {
248 const lines = [MIGRATED_MARK + (saved.updatedAt ? '(' + stamp(Number(saved.updatedAt)) + ' に書いたもの)' : '')]
249 if (typeof saved.task === 'string' && saved.task) lines.push('作業: ' + saved.task)
250 const next = Array.isArray(saved.next) ? saved.next.filter((line) => typeof line === 'string' && line) : []
251 if (next.length) {
252 lines.push('次にやること:')
253 for (const line of next) lines.push('- ' + line)
254 }
255 const free = Array.isArray(saved.free) ? saved.free.filter((line) => typeof line === 'string') : []
256 if (free.some((line) => line.trim())) {
257 lines.push('メモ(自由):')
258 for (const line of free) lines.push(line)
259 }
260 lines.push('(ここまで前の版のメモ)', '')
261 return lines.join('\n') + '\n'
262}
263
264// 前の版のメモを各セッションの .txt の頭へ写す(初回だけ・見出しが既にあれば写さない)。写した件数を印に残す
265async function migrate($) {
266 if (await $.store.get(MIGRATED_KEY)) return
267 if (!(await ensureDir($))) return
268 await $.store.set(MIGRATED_KEY, { count: 0, startedAt: Date.now() })
269 let count = 0
270 const failed = []
271 for (const key of await $.store.keys()) {
272 if (!key.startsWith(OLD_PREFIX)) continue
273 const id = key.slice(OLD_PREFIX.length)
274 const saved = await $.store.get(key)
275 if (!saved || typeof saved !== 'object') continue
276 try {
277 const path = memoPath(id)
278 const now = (await $.fs.exists(path)) ? String(await $.fs.read(path)) : ''
279 if (now.includes(MIGRATED_MARK)) continue
280 await $.fs.write(memoPath(id), oldMemoText(saved) + now)
281 await $.store.set(FILE_PREFIX + id, { cwd: saved.cwd || '', updatedAt: Number(saved.updatedAt) || 0 })
282 count += 1
283 } catch (err) {
284 failed.push(id)
285 }
286 }
287 await $.store.set(MIGRATED_KEY, { count, failed, finishedAt: Date.now() })
288}
289
290export function register(on) {
291 on('session.start', async ($, e, next) => {
292 await $.command.register({
293 name: 'memo',
294 description: 'このセッションのメモをメモ帳で開く。/memo <文> で1行足す',
295 argumentHint: '[足す1行]',
296 immediate: true,
297 })
298 sessionId = null
299 try {
300 await migrate($)
301 } catch (err) {
302 notice = '前の版のメモを写せませんでした: ' + messageOf(err)
303 }
304 return next(e)
305 })
306
307 on('command.run', { command: 'memo' }, async ($, e) => {
308 const line = String(e.args || '').replace(/[\r\n\t]+/g, ' ').trim().slice(0, MAX_LINE)
309 if (line) {
310 await ensureSession($, true)
311 if (!sessionId) return { text: notice }
312 try {
313 const now = await readFile($, sessionId)
314 await writeMemo($, now + (now && !now.endsWith('\n') ? '\n' : '') + line + '\n')
315 } catch (err) {
316 return { text: 'メモに書けませんでした: ' + messageOf(err) }
317 }
318 $.ui.invalidate('ui.render')
319 return { text: 'メモに1行足しました: ' + line }
320 }
321 await editMemo($)
322 return { text: notice }
323 })
324
325 // 入力欄の上の帯。メモの先頭の1行と「メモを編集」。後の mod の帯は同じ行の右に置く
326 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
327 if (e.props.hasSurvey) return next(e)
328 await ensureSession($, false)
329 const theirs = await next(e)
330 const { Box, Text, Button } = $.ui.resolve(e)
331 const head = firstLine(memoText)
332 // メモ帳を開く操作の直後は、どこまで進んだか(開いています…・起こしました・どの段で止まったか)を帯に出す。
333 // toast を見落としても、押した結果が帯に残る
334 const recent = notice && noticeAt && Date.now() - noticeAt < BAND_NOTICE_MS
335 const summary = recent
336 ? Text({ dimColor: true, wrap: 'truncate-end', children: [notice] })
337 : head
338 ? Text({ wrap: 'truncate-end', children: ['📝 ' + head.slice(0, BAND_CHARS)] })
339 : Text({ dimColor: true, wrap: 'truncate-end', children: ['メモなし'] })
340 // 前のメモは、いまのメモが空で、同じフォルダに前のメモがあるときだけ入口を出す(帯を広げない)
341 const pastButton =
342 !head && past.length
343 ? [Button({ key: 'memo-past', label: '前のメモ', plain: true, dimColor: true, onPress: () => openPane($) })]
344 : []
345 // 後の mod の帯(file-browser の「ファイル一覧」)は、帯が2段に膨らまないよう同じ行の右に置く。
346 return Box({
347 flexDirection: 'row',
348 columnGap: 2,
349 children: [
350 Box({ flexDirection: 'column', flexGrow: 1, children: [summary] }),
351 Button({ key: 'memo-edit', label: 'メモを編集', plain: true, dimColor: true, onPress: () => editMemo($) }),
352 ...pastButton,
353 ...(theirs ? [theirs] : []),
354 ],
355 })
356 })
357
358 // メモのペイン(いまのメモの全文と、同じフォルダの前のメモ)
359 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
360 if (e.requestId !== PANE) return next(e)
361 const { Box, Text, Button } = $.ui.resolve(e)
362 const body = memoText.trim()
363 ? memoText
364 .slice(0, PANE_CHARS)
365 .split(/\r?\n/)
366 .map((line) => Text({ wrap: 'wrap', children: [line || ' '] }))
367 : [Text({ dimColor: true, children: ['(まだ何も書いていません)'] })]
368 const pastRows = past.length
369 ? past.map((item) =>
370 Box({
371 key: 'memo-past-' + item.id,
372 flexDirection: 'row',
373 columnGap: 2,
374 children: [
375 Text({ dimColor: true, children: [stamp(item.updatedAt)] }),
376 Box({ flexGrow: 1, children: [Text({ wrap: 'truncate-end', children: [item.head] })] }),
377 Button({ key: 'memo-open-' + item.id, label: '開く', plain: true, dimColor: true, onPress: () => openNotepad($, item.id) }),
378 ],
379 }),
380 )
381 : [Text({ dimColor: true, children: ['(同じフォルダの前のメモはありません)'] })]
382 return Box({
383 flexDirection: 'column',
384 children: [
385 Box({
386 flexDirection: 'row',
387 columnGap: 2,
388 children: [
389 Text({ bold: true, children: ['このセッションのメモ'] }),
390 Button({ key: 'memo-notepad', label: 'メモ帳で開く', plain: true, onPress: () => editMemo($) }),
391 Button({
392 key: 'memo-reload',
393 label: '読み直す',
394 plain: true,
395 dimColor: true,
396 onPress: async () => {
397 await ensureSession($, true)
398 $.ui.invalidate('ui.render')
399 },
400 }),
401 ],
402 }),
403 ...body,
404 ...(notice ? [Text({ dimColor: true, children: [notice] })] : []),
405 Text({ children: [' '] }),
406 Text({ bold: true, children: ['同じフォルダの前のメモ(開くとメモ帳で開きます)'] }),
407 ...pastRows,
408 ],
409 })
410 })
411}
412