SLOPSHOPPER

session-memo

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

newpanebandcommandtoastprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-memo
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ session-memo │ ⏺ 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 › /memo ⎿ session-memo: メモ帳を起こしました。保存すると帯に出ます(出てこなければ /memo <文> で1行ずつ足せます) メモ帳を起こしました。保存すると帯に出ます(出てこなければ /memo <文> で1行ずつ足せます) メモを編集 ⟨Claude Code' ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
メモ帳を起こしました。保存すると帯に出ます(出てこなければ /memo <文> で1行ずつ足せます) メモを編集
README

Claude Code mods: file-browser と session-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・帯の「メモを編集」

動く環境

  • Windows 版の Claude Desktop(Code タブ)を前提にしています。 外部のアプリは explorer.exe と notepad.exe で開きます。
  • mod を読み込める版の Claude Code が要ります(2.1.288 の型で書きました)。版は 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(帯とペイン)だけです。道具の実行や許可に関わるイベントは使いません。
  • file-browser はファイルを読むだけで、書き込みはしません。作業ディレクトリの外を指すもの(リンクの行き先を含む)は、開きも展開もしません(リンクの行そのものは一覧に出ます)。外部のアプリは、explorer.exe にパスを1つ渡す形でだけ起動します(シェルは通しません)。
  • session-memo がファイルを書くのは、メモの置き場(~/.claude/session-memo/<セッション id>.txt)の下だけです。作業ディレクトリには書きません。外部のアプリは、notepad.exe にメモのパスを1つ渡す形でだけ起動します。

分かっている限界

  • file-browser が1つのフォルダに出すのは 300 件まで、木全体では 600 行までです。プレビューで出すのは先頭の 90,000 字までです。
  • 空白を含む名前のファイルは @ 参照にできません(書き方を確かめていないため)。
  • session-memo のメモ帳は、セッションを閉じると一緒に閉じることがあります。書いたら保存してください。
  • session-memo は、環境変数 USERPROFILE も HOME も取れないとメモを置けません(帯に「家のフォルダが分からないのでメモを置けません」と出ます)。

謝辞

session-memo は、zenn で公開されている session-memo(yesodco さん・2026-10-06)の着想を借りました。コードは借りていません。

ライセンス

MIT(LICENSE)

Source 1 files
hooks/register.js 412 lines
1// セッションごとのメモ(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