Mirrors each Claude session into an Obsidian note and takes replies that you write in that note.

obsidian-bridge is a mod for Claude Code. It keeps one note in your Obsidian vault for each Claude session. You can read the session on your phone, and you can send replies to Claude from the note.
Claude Sessions.✏️ Edited src/app.ts (+2 −1). It does not show the code, the command output or the diff.2026-10-15 6-20 PM - Fix the login bug.md. The first name comes from your first prompt. When the desktop app gives the session a name in its sidebar, the mod changes the note name to that name.status property of the note shows the state of the session: 🟢 working, 🟡 waiting for you, ❓ question on the Mac or ⚪ ended./send.Claude takes the reply only when the reply area stays the same for the settle time. The default settle time is 5 seconds. Claude then gets your text as the next prompt. If Claude is busy, it starts your message after the current turn. Text below a ticked box stays in the reply area for your next message.
A reply from the phone turns on away mode. In away mode, Claude does not open question dialogs on the Mac. Claude writes the question and the options in its reply, and you answer in the note. A prompt that you type on the Mac turns off away mode.
The mod writes to the note through the Obsidian command line interface (CLI) with obsidian eval. It changes the note only when the note is still the same as the copy that it read. Because of this, the mod does not overwrite your edits from the phone. If the CLI does not answer, the mod writes the note file directly.
/plugin install obsidian-bridge --marketplace utsavsingh899/claude-obsidian-bridge
y to accept.user scope installs the mod for all of your sessions.You can also install the mod from a shell:
claude plugin install obsidian-bridge --marketplace utsavsingh899/claude-obsidian-bridge
git clone https://github.com/utsavsingh899/claude-obsidian-bridge.git ~/.claude/mods/obsidian-bridge
CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json: {
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/Users/you/.claude/mods/obsidian-bridge"
}
}
| Command | What it does |
|---|---|
/obsidian | Shows the status: the mirror, away mode, the note path, an obsidian:// link, and how the mod writes the note. |
/obsidian on | Turns on the note for this session. |
/obsidian off | Writes the last entries, then turns off the note for this session. |
/obsidian away | Turns on away mode. Claude asks its questions in its reply, not in a dialog. |
/obsidian back | Turns off away mode. |
The options are the userConfig fields in .claude-plugin/plugin.json. To see the values, run this command in a shell:
claude plugin configure obsidian-bridge
| Option | Default | Description |
|---|---|---|
enabled | true | Make a note for each session. /obsidian on and /obsidian off change this for one session. |
vault | empty | The name of the vault, as the Obsidian CLI knows it. When it is empty, the mod uses the name of the vault folder. |
vaultPath | empty | The absolute path of the vault. When it is empty, the mod finds the vault in the Obsidian vault list. It selects the vault with the name in vault, then the open vault, then the first vault. |
folder | Claude Sessions | The folder in the vault for the session notes. |
cli | empty | The path of the Obsidian CLI. When it is empty, the mod tries the usual locations. |
pollSeconds | 3 | How often Claude reads the note for a reply, in seconds. |
settleSeconds | 5 | The time in seconds that the reply area must stay the same before Claude takes the reply. |
flushSeconds | 15 | How often Claude writes the collected tool steps into the note, in seconds. |
The note holds the text of your session. This text includes your prompts, the messages from Claude, the paths of the files that Claude reads and changes, and the path of the project folder. The mod writes this text into your vault. Your sync service then copies the text to your other devices and, for most services, to its servers. Do not use the mod for a session with secrets or other data that must not leave the Mac. To stop the note for one session, use /obsidian off.
The mod reads the session transcript in ~/.claude/projects only to find the session name. The mod itself sends no data to a server.
The note logic is in hooks/note.ts. The hooks are in hooks/register.ts.
To run the tests, use this command:
claude plugin test .
To check the manifests and the hooks, use this command:
claude plugin validate .
MIT. See LICENSE.
hooks/register.ts 558 lines1import type { Hook, PluginOptions, Register } from 'claude-code'
2
3import * as N from './note'
4
5type Engine = Parameters<Hook<'session.start'>>[0]
6
7type Options = {
8 isEnabled: boolean
9 vault: string
10 vaultPath: string
11 folder: string
12 cli: string
13 pollMs: number
14 settleMs: number
15 flushMs: number
16}
17
18type Outcome = 'ok' | 'conflict' | 'missing' | 'created' | 'exists' | 'error'
19
20const ME = 'obsidian-bridge'
21
22const AWAY_DENY =
23 'The user is away from the Mac and reads this session in an Obsidian note on the phone. ' +
24 'The user cannot answer this dialog. Write your question and the options as plain text in your reply, ' +
25 "then end your turn. The user's answer comes as the next prompt."
26
27// The module state. A hot reload starts it again; the note path and the
28// session modes are in `$.store`, so they stay.
29// A log entry that waits for the next write. Tool calls wait up to the flush
30// time; messages from you or Claude go out at the next tick.
31type Entry = { text: string; isUrgent: boolean; at: number }
32
33let items: Entry[] = []
34let props: Record<string, string> = {}
35let isBusy = false
36let hasTurnText = false
37// The files that the current turn changed, with the lines added and removed.
38const turnFiles = new Map<string, { added: number; removed: number }>()
39let firstPrompt: string | undefined
40let offsetMinutes = 0
41let home = ''
42let vaultRoot = ''
43// The vault name for the CLI: the `vault` option, or the name of the vault folder.
44let vaultName = ''
45let cliPath: string | undefined
46let cliCheckedAt = 0
47let settle: { zone: string; since: number } | undefined
48let lock: Promise<unknown> = Promise.resolve()
49let isTickQueued = false
50let conflictStreak = 0
51const shown = new Set<string>()
52// The transcript file of each session. Its "customTitle" lines hold the name
53// that the desktop app shows in its sidebar.
54const transcripts = new Map<string, string>()
55let lastTitleCheck = 0
56let isTitleDue = true
57
58function readOptions(options: PluginOptions): Options {
59 const num = (value: unknown, fallback: number): number =>
60 typeof value === 'number' && Number.isFinite(value) ? value : fallback
61 return {
62 isEnabled: options.enabled !== false,
63 vault: String(options.vault ?? ''),
64 vaultPath: String(options.vaultPath ?? ''),
65 folder: String(options.folder ?? 'Claude Sessions').replace(/^\/+|\/+$/g, ''),
66 cli: String(options.cli ?? ''),
67 pollMs: Math.max(1, num(options.pollSeconds, 3)) * 1000,
68 settleMs: Math.max(0, num(options.settleSeconds, 5)) * 1000,
69 flushMs: Math.max(1, num(options.flushSeconds, 15)) * 1000,
70 }
71}
72
73// Runs one job at a time, so two writes to the note never overlap.
74function serial<T>(job: () => Promise<T>): Promise<T> {
75 const run = lock.then(job, job)
76 lock = run.catch(() => undefined)
77 return run
78}
79
80function now(): { ms: number; date: string; time: string; fileTime: string } {
81 const ms = Date.now()
82 return { ms, ...N.clock(ms, offsetMinutes) }
83}
84
85function showOnce($: Engine, text: string): void {
86 if (shown.has(text)) return
87 shown.add(text)
88 $.ui.toast(`${ME}: ${text}`)
89}
90
91function pushBlock(block: string, isUrgent = true): void {
92 items.push({ text: block, isUrgent, at: Date.now() })
93 if (items.length > 500) items = items.slice(-500)
94}
95
96function units(entries: readonly Entry[]): string[] {
97 return entries.map(entry => entry.text)
98}
99
100function isDue(opt: Options): boolean {
101 const oldest = items[0]
102 return (
103 items.some(entry => entry.isUrgent) ||
104 (oldest !== undefined && Date.now() - oldest.at >= opt.flushMs) ||
105 Object.keys(props).length > 0
106 )
107}
108
109async function isOn($: Engine, sessionId: string, opt: Options): Promise<boolean> {
110 const mode = await $.store.get(`mode:${sessionId}`)
111 return mode === 'on' ? true : mode === 'off' ? false : opt.isEnabled
112}
113
114async function setup($: Engine, opt: Options): Promise<void> {
115 const [env, zone] = await Promise.all([
116 $.process.run(['/usr/bin/printenv', 'HOME']),
117 $.process.run(['/bin/date', '+%z']),
118 ])
119 home = env.stdout.trim()
120 offsetMinutes = N.parseOffset(zone.stdout)
121 vaultRoot = opt.vaultPath.replace(/^~(?=\/|$)/, home)
122 if (!vaultRoot) vaultRoot = await findVault($, opt.vault)
123 vaultName = opt.vault || vaultRoot.replace(/\/+$/, '').split('/').pop() || ''
124 await findCli($, opt)
125}
126
127async function findVault($: Engine, name: string): Promise<string> {
128 try {
129 const raw = await $.fs.read(`${home}/Library/Application Support/obsidian/obsidian.json`)
130 const vaults = Object.values((JSON.parse(raw) as { vaults?: Record<string, { path?: string; open?: boolean }> }).vaults ?? {})
131 const pick =
132 vaults.find(v => v.path?.split('/').pop() === name) ?? vaults.find(v => v.open) ?? vaults[0]
133 return pick?.path ?? ''
134 } catch {
135 return ''
136 }
137}
138
139async function findCli($: Engine, opt: Options): Promise<void> {
140 cliCheckedAt = Date.now()
141 const candidates = [
142 opt.cli,
143 '/usr/local/bin/obsidian',
144 '/opt/homebrew/bin/obsidian',
145 `${home}/.local/bin/obsidian`,
146 '/Applications/Obsidian.app/Contents/MacOS/obsidian-cli',
147 ].filter(Boolean)
148 for (const candidate of candidates) {
149 if (!(await $.fs.exists(candidate).catch(() => false))) continue
150 try {
151 const r = await $.process.run([candidate, ...N.vaultArgs(vaultName), 'version'], { timeoutMs: 15_000 })
152 const said = `${r.stdout}\n${r.stderr}`
153 if (r.exitCode === 0 && !/not enabled|error/i.test(said)) {
154 cliPath = candidate
155 return
156 }
157 } catch {
158 // Try the next place.
159 }
160 }
161 cliPath = undefined
162 showOnce($, 'The Obsidian CLI does not answer. Claude writes the note file directly until it answers.')
163}
164
165function absolute(path: string): string {
166 return `${vaultRoot}/${path}`
167}
168
169async function readNote($: Engine, path: string): Promise<string | undefined> {
170 try {
171 return await $.fs.read(absolute(path))
172 } catch {
173 return undefined
174 }
175}
176
177async function runApp($: Engine, opt: Options, payload: unknown): Promise<string> {
178 if (!cliPath) return 'no-cli'
179 const r = await $.process.run([cliPath, ...N.vaultArgs(vaultName), 'eval', `code=${N.appScript(payload)}`], {
180 timeoutMs: 20_000,
181 })
182 // `eval` prints the result as "=> value". An error prints "Error: ..." with exit code 0.
183 const found = /^=> (ok|conflict|missing|created|exists)\s*$/m.exec(r.stdout)
184 return found?.[1] ?? `unknown: ${(r.stdout + r.stderr).trim().slice(0, 200)}`
185}
186
187async function createNote($: Engine, opt: Options, path: string, content: string): Promise<Outcome> {
188 if (cliPath) {
189 const said = await runApp($, opt, { path, create: content })
190 if (said === 'created' || said === 'exists') return said
191 showOnce($, `Obsidian did not create the note (${said}). Claude writes the file directly.`)
192 }
193 if (await $.fs.exists(absolute(path))) return 'exists'
194 await $.fs.write(absolute(path), content)
195 return 'created'
196}
197
198// Applies the splices only when the note is still `before`.
199async function applyToNote($: Engine, opt: Options, path: string, before: string, splices: N.Splice[]): Promise<Outcome> {
200 const sorted = N.sortSplices(splices)
201 const after = N.applySplices(before, sorted)
202 if (cliPath) {
203 const said = await runApp($, opt, { path, hash: N.hash(before), splices: sorted })
204 conflictStreak = said === 'conflict' ? conflictStreak + 1 : 0
205 // Obsidian keeps a copy that does not match the file. Write the file directly.
206 if (said === 'conflict' && conflictStreak >= 5) {
207 showOnce($, 'Obsidian and the note file do not match. Claude wrote the file directly.')
208 conflictStreak = 0
209 return applyToFile($, path, before, after)
210 }
211 if (said === 'ok' || said === 'conflict' || said === 'missing') return said
212 // The CLI gave no clear answer. Look at the file to find out.
213 const current = await readNote($, path)
214 if (current === after) return 'ok'
215 if (current === undefined) return 'missing'
216 showOnce($, `Unclear answer from the Obsidian CLI: ${said}`)
217 return current === before ? 'conflict' : 'error'
218 }
219 return applyToFile($, path, before, after)
220}
221
222async function applyToFile($: Engine, path: string, before: string, after: string): Promise<Outcome> {
223 const current = await readNote($, path)
224 if (current === undefined) return 'missing'
225 if (current !== before) return 'conflict'
226 await $.fs.write(absolute(path), after)
227 return 'ok'
228}
229
230async function notePath($: Engine, sessionId: string): Promise<string | undefined> {
231 const path = await $.store.get(`note:${sessionId}`)
232 return typeof path === 'string' ? path : undefined
233}
234
235async function makeNote($: Engine, opt: Options, sessionId: string): Promise<string | undefined> {
236 const t = now()
237 const cwd = await $.session.cwd()
238 const project = cwd.split('/').pop() || 'session'
239 // The sidebar name comes after the first turn starts. Until then, use the first prompt.
240 const sessionName = await sessionTitle($, sessionId).catch(() => undefined)
241 const name = sessionName ?? N.slug(firstPrompt ?? 'session')
242 const template = N.newNote({
243 sessionId,
244 project,
245 cwd,
246 title: name,
247 date: t.date,
248 time: t.time,
249 settleSeconds: Math.round(opt.settleMs / 1000),
250 })
251 const content = N.applySplices(template, [
252 ...(items.length > 0 ? [N.logSplice(template, units(items))] : []),
253 ...N.propsSplices(template, { ...props, updated: t.time, ...(sessionName ? { session: sessionName } : {}) }),
254 ])
255 for (let copy = 1; copy <= 9; copy++) {
256 const path = N.notePathFor(opt.folder, `${t.date} ${t.fileTime}`, name, copy)
257 if (await $.fs.exists(absolute(path)).catch(() => false)) continue
258 if ((await createNote($, opt, path, content)) !== 'created') continue
259 await $.store.set(`note:${sessionId}`, path)
260 if (sessionName) await $.store.set(`named:${sessionId}`, sessionName)
261 items = []
262 props = {}
263 $.ui.toast(`Session note: ${path}`)
264 return path
265 }
266 showOnce($, `Could not make a note in ${opt.folder}.`)
267 return undefined
268}
269
270// The session name from the sidebar, or undefined when the session has none yet.
271async function sessionTitle($: Engine, sessionId: string): Promise<string | undefined> {
272 let transcript = transcripts.get(sessionId)
273 if (transcript === undefined) {
274 const found = await $.process.run(['/usr/bin/find', `${home}/.claude/projects`, '-maxdepth', '2', '-name', `${sessionId}.jsonl`])
275 transcript = found.stdout.trim().split('\n')[0] ?? ''
276 if (!transcript) return undefined
277 transcripts.set(sessionId, transcript)
278 }
279 const r = await $.process.run(['/usr/bin/grep', '-oE', '"customTitle":"([^"\\\\]|\\\\.)*"', transcript])
280 return N.parseTitle(r.stdout)
281}
282
283async function renameFile($: Engine, opt: Options, from: string, to: string): Promise<Outcome> {
284 if (cliPath) {
285 const said = await runApp($, opt, { path: from, rename: to })
286 if (said === 'ok' || said === 'exists' || said === 'missing') return said
287 }
288 const r = await $.process.run(['/bin/mv', '-n', absolute(from), absolute(to)])
289 return r.exitCode === 0 && (await $.fs.exists(absolute(to))) ? 'ok' : 'error'
290}
291
292// Renames the note to "<start date and time> - <session name>.md" when the
293// session gets a name or you rename the session in the sidebar.
294async function syncName($: Engine, opt: Options, sessionId: string): Promise<void> {
295 const path = await notePath($, sessionId)
296 if (path === undefined) return
297 const title = await sessionTitle($, sessionId)
298 if (title === undefined || title === (await $.store.get(`named:${sessionId}`))) return
299 const stamp = N.stampOf(path) ?? `${now().date} ${now().fileTime}`
300 let current = path
301 for (let copy = 1; copy <= 9; copy++) {
302 const target = N.notePathFor(opt.folder, stamp, title, copy)
303 if (target === path) break
304 if (await $.fs.exists(absolute(target)).catch(() => false)) continue
305 if ((await renameFile($, opt, path, target)) !== 'ok') return
306 current = target
307 await $.store.set(`note:${sessionId}`, target)
308 break
309 }
310 // Show the new name in the note heading and its properties too.
311 const before = await readNote($, current)
312 if (before === undefined) return
313 const heading = N.headingSplice(before, title)
314 const splices = [...N.propsSplices(before, { session: title }), ...(heading ? [heading] : [])]
315 if ((await applyToNote($, opt, current, before, splices)) !== 'ok') return
316 await $.store.set(`named:${sessionId}`, title)
317}
318
319async function writePending($: Engine, opt: Options, sessionId: string): Promise<void> {
320 if (items.length === 0 && Object.keys(props).length === 0) return
321 const path = await notePath($, sessionId)
322 if (path === undefined) {
323 await makeNote($, opt, sessionId)
324 return
325 }
326 for (let attempt = 0; attempt < 3; attempt++) {
327 const before = await readNote($, path)
328 if (before === undefined) {
329 // You deleted the note. Start a new one.
330 await $.store.delete(`note:${sessionId}`)
331 await makeNote($, opt, sessionId)
332 return
333 }
334 const writing = items.length
335 const values = { ...props, updated: now().time }
336 const splices = [...N.propsSplices(before, values)]
337 if (writing > 0) splices.push(N.logSplice(before, units(items.slice(0, writing))))
338 const outcome = await applyToNote($, opt, path, before, splices)
339 if (outcome === 'ok') {
340 items = items.slice(writing)
341 props = {}
342 return
343 }
344 if (outcome !== 'conflict') return
345 await $.clock.sleep(300)
346 }
347}
348
349async function takeReply($: Engine, opt: Options, sessionId: string): Promise<void> {
350 const path = await notePath($, sessionId)
351 if (path === undefined) return
352 const note = await readNote($, path)
353 if (note === undefined) return
354 const reply = N.parseReply(note)
355 if (reply?.message === undefined) {
356 settle = undefined
357 return
358 }
359 const t = now()
360 // Take the message only after the reply area stays the same for the settle time.
361 if (settle?.zone !== reply.zone) {
362 settle = { zone: reply.zone, since: t.ms }
363 return
364 }
365 if (t.ms - settle.since < opt.settleMs) return
366 const message = reply.message
367 const youBlock = message
368 ? N.callout('quote', `${t.time} · You (phone)`, message + (isBusy ? '\n\n_Claude starts this after the current turn._' : ''))
369 : ''
370 const status: Record<string, string> = message ? { status: '🟢 working', updated: t.time } : { updated: t.time }
371 // Write the entries that wait, so the log stays in order.
372 const outcome = await applyToNote($, opt, path, note, N.consumeSplices(note, reply, [...units(items), ...(youBlock ? [youBlock] : [])], status))
373 if (outcome !== 'ok') return
374 items = []
375 settle = undefined
376 if (!message) return
377 await $.store.set(`away:${sessionId}`, true)
378 void $.prompt.submit({ text: message, asUser: true }).catch(() => showOnce($, 'Could not send your reply to Claude.'))
379}
380
381async function tick($: Engine, opt: Options): Promise<void> {
382 if (isTickQueued) return
383 isTickQueued = true
384 await serial(async () => {
385 isTickQueued = false
386 try {
387 const sessionId = await $.session.id()
388 if (!(await isOn($, sessionId, opt))) {
389 items = []
390 props = {}
391 return
392 }
393 if (!cliPath && Date.now() - cliCheckedAt > 60_000) await findCli($, opt)
394 if (isDue(opt)) await writePending($, opt, sessionId)
395 if (isTitleDue || Date.now() - lastTitleCheck >= 30_000) {
396 lastTitleCheck = Date.now()
397 isTitleDue = false
398 await syncName($, opt, sessionId)
399 }
400 await takeReply($, opt, sessionId)
401 } catch (error) {
402 showOnce($, `The note update failed: ${error instanceof Error ? error.message : String(error)}`)
403 }
404 })
405}
406
407function statusText(path: string | undefined, isMirrored: boolean, isAway: boolean): string {
408 const link = path && vaultName ? `obsidian://open?vault=${encodeURIComponent(vaultName)}&file=${encodeURIComponent(path)}` : ''
409 return [
410 `Mirror: ${isMirrored ? 'on' : 'off'}`,
411 `Away mode: ${isAway ? 'on (Claude asks questions in its reply, not in a dialog)' : 'off'}`,
412 `Note: ${path ?? '(Claude makes it with the next update)'}`,
413 link ? `Open: ${link}` : '',
414 `Writes through: ${cliPath ? `Obsidian CLI (${cliPath})` : `the file system (${vaultRoot || 'vault not found'})`}`,
415 ]
416 .filter(Boolean)
417 .join('\n')
418}
419
420export const register: Register = (on, options) => {
421 const opt = readOptions(options)
422
423 on('session.start', async ($, e, next) => {
424 await $.command.register({
425 name: 'obsidian',
426 description: 'Show or change the Obsidian session note',
427 argumentHint: '[on|off|away|back]',
428 immediate: true,
429 })
430 await setup($, opt).catch(error => showOnce($, `Setup failed: ${String(error)}`))
431 $.clock.every(opt.pollMs, () => void tick($, opt))
432 return next(e)
433 })
434
435 on('prompt.submit', async ($, e, next) => {
436 const origin = e.origin
437 const isMine = origin.kind === 'plugin' && origin.name === ME
438 const isPerson = origin.kind === 'composer' || origin.kind === 'sdk' || origin.kind === 'bridge' || origin.kind === 'unclassified'
439 const typed = N.cleanPrompt(e.text)
440 if (!isMine && isPerson && typed && !typed.startsWith('/')) {
441 firstPrompt ??= typed
442 await $.store.delete(`away:${await $.session.id()}`)
443 pushBlock(N.callout('quote', `${now().time} · You (Mac)`, N.cut(typed, 4000)))
444 }
445 return next(e)
446 })
447
448 on('turn.start', async ($, e, next) => {
449 isBusy = true
450 hasTurnText = false
451 turnFiles.clear()
452 props.status = '🟢 working'
453 return next(e)
454 })
455
456 // Each text block of Claude's reply, exactly as the conversation keeps it.
457 on('session.append', async ($, e, next) => {
458 const stored = await next(e)
459 // No message: a hook refused the row, so the conversation does not keep it.
460 const message = stored.message
461 if (e.agentId === undefined && e.door === 'response' && message?.role === 'assistant') {
462 const text = message.content
463 .map(block => (block.type === 'text' ? block.text : ''))
464 .filter(Boolean)
465 .join('\n\n')
466 if (text.trim()) {
467 hasTurnText = true
468 pushBlock(N.callout('note', `${now().time} · Claude`, N.cut(text, 50_000)))
469 }
470 }
471 return stored
472 })
473
474 on('tool.call', async ($, e, next) => {
475 if (e.agentId !== undefined) return next(e)
476 const input = e as unknown as Record<string, unknown>
477 const tool = String(e.tool)
478 const t = now()
479 if (tool === 'AskUserQuestion') {
480 const asked = N.questionsText(input)
481 if (await $.store.get(`away:${await $.session.id()}`)) {
482 pushBlock(N.callout('question', `${t.time} · Claude has a question`, `${asked}\n\n_You are away, so Claude asks in its reply. Answer under Reply to Claude._`))
483 return { deny: AWAY_DENY }
484 }
485 pushBlock(N.callout('question', `${t.time} · Claude asks on the Mac`, asked))
486 props.status = '❓ question on the Mac'
487 }
488 const ran = await next(e)
489 const isError = ran.isError === true || ran.deny !== undefined
490 if (tool === 'mcp__ccd_turn__wrap_up') {
491 // The recap that the app shows at the end of a turn is a message from Claude.
492 const said = (key: string): string => (typeof input[key] === 'string' ? (input[key] as string) : '')
493 const body = [said('recap'), said('ask') ? `**Question:** ${said('ask')}` : ''].filter(Boolean).join('\n\n')
494 if (body) pushBlock(N.callout('abstract', `${t.time} · Claude · recap`, body))
495 return ran
496 }
497 const counts = isError ? undefined : N.lineCounts(tool, input, (ran as { result?: unknown }).result)
498 const file = typeof input.file_path === 'string' ? input.file_path : typeof input.notebook_path === 'string' ? input.notebook_path : ''
499 if (counts && file) {
500 const sum = turnFiles.get(file) ?? { added: 0, removed: 0 }
501 turnFiles.set(file, { added: sum.added + counts.added, removed: sum.removed + counts.removed })
502 }
503 const line = N.toolLine({ tool, input, cwd: await $.session.cwd(), home, time: t.time, counts, isError })
504 if (line) pushBlock(line, false)
505 return ran
506 }).catch(($, e, next) => next(e))
507
508 on('turn.complete', async ($, e, next) => {
509 const result = await next(e)
510 if (e.agentId !== undefined) return result
511 isBusy = false
512 const t = now()
513 // When no reply text came through session.append, log the final answer here.
514 if (!hasTurnText && e.answer.trim()) pushBlock(N.callout('note', `${t.time} · Claude`, N.cut(e.answer, 50_000)))
515 const kind = e.reason === 'answer' ? 'success' : e.reason === 'aborted' ? 'warning' : 'failure'
516 const label = e.reason === 'answer' ? 'Turn done' : e.reason === 'aborted' ? 'Turn stopped' : `Turn ended (${e.reason})`
517 pushBlock(N.callout(kind, `${t.time} · ${label} · ${N.duration(e.durationMs)}`, N.fileSummary(turnFiles)))
518 props.status = '🟡 waiting for you'
519 isTitleDue = true
520 // Write at once. A reload of the mod clears the entries that wait in memory.
521 const sessionId = await $.session.id()
522 if (await isOn($, sessionId, opt)) await serial(() => writePending($, opt, sessionId)).catch(() => undefined)
523 return result
524 })
525
526 on('session.end', async ($, e, next) => {
527 const t = now()
528 const why = e.reason === 'clear' ? 'A /clear started a new conversation. The next prompt starts a new note.' : ''
529 pushBlock(N.callout('info', `${t.time} · Session ended (${e.reason})`, why))
530 props.status = '⚪ ended'
531 await serial(() => writePending($, opt, e.sessionId)).catch(() => undefined)
532 firstPrompt = undefined
533 return next(e)
534 })
535
536 on('command.run', { command: 'obsidian' }, async ($, e) => {
537 const sessionId = await $.session.id()
538 const arg = e.args.trim().toLowerCase()
539 if (arg === 'on') {
540 await $.store.set(`mode:${sessionId}`, 'on')
541 pushBlock(N.callout('info', `${now().time} · Mirror on`))
542 } else if (arg === 'off') {
543 await serial(() => writePending($, opt, sessionId)).catch(() => undefined)
544 await $.store.set(`mode:${sessionId}`, 'off')
545 } else if (arg === 'away') {
546 await $.store.set(`away:${sessionId}`, true)
547 pushBlock(N.callout('info', `${now().time} · Away mode on`))
548 } else if (arg === 'back') {
549 await $.store.delete(`away:${sessionId}`)
550 } else if (arg) {
551 return { text: 'Use /obsidian, /obsidian on, /obsidian off, /obsidian away or /obsidian back.' }
552 }
553 const isMirrored = await isOn($, sessionId, opt)
554 const isAway = Boolean(await $.store.get(`away:${sessionId}`))
555 return { text: statusText(await notePath($, sessionId), isMirrored, isAway) }
556 })
557}
558hooks/note.ts 484 lines1// The pure logic of the session note. This file does not use `$`, so the
2// tests can run it directly.
3
4export const LOG_HEADING = '## 📜 Log'
5export const REPLY_HEADING = '## ✍️ Reply to Claude'
6export const SEND_BOX = '- [ ] Send'
7// The reply area after the heading line: one empty line to write on, then the box.
8export const EMPTY_ZONE = `\n${SEND_BOX}\n`
9
10// A ticked Send box, or a line with only "/send".
11const SEND_LINE = /^\s*(?:[-*+]\s+\[[xX]\]\s+send\b.*|\/send)\s*$/i
12// Lines of the template that are not part of a message.
13const TEMPLATE_LINE = /^\s*(?:[-*+]\s+\[ \]\s+send\b.*|<!--.*-->)\s*$/i
14
15export type Splice = { at: number; remove: number; insert: string }
16
17export type Reply = {
18 // The index where the reply area starts (after the heading line).
19 zoneStart: number
20 // The full reply area as it was read.
21 zone: string
22 // The text to send. Undefined when no send line is in the area.
23 message?: string
24 // The text that stays in the reply area after Claude takes the message.
25 keep: string
26}
27
28// FNV-1a over UTF-16 code units. The Obsidian side uses the same function.
29export function hash(text: string): string {
30 let x = 2166136261
31 for (let i = 0; i < text.length; i++) {
32 x ^= text.charCodeAt(i)
33 x = Math.imul(x, 16777619) >>> 0
34 }
35 return `${text.length}:${x}`
36}
37
38export function applySplices(text: string, splices: readonly Splice[]): string {
39 let out = text
40 for (const s of sortSplices(splices)) {
41 out = out.slice(0, s.at) + s.insert + out.slice(s.at + s.remove)
42 }
43 return out
44}
45
46// The last splice in the text comes first, so each index stays correct.
47export function sortSplices(splices: readonly Splice[]): Splice[] {
48 return [...splices].sort((a, b) => b.at - a.at)
49}
50
51function findReplyHeading(note: string): { start: number; zoneStart: number } | undefined {
52 let from = note.length
53 while (from >= 0) {
54 const i = note.lastIndexOf(REPLY_HEADING, from)
55 if (i < 0) return undefined
56 const end = note.indexOf('\n', i)
57 const lineEnd = end < 0 ? note.length : end
58 const isLineStart = i === 0 || note[i - 1] === '\n'
59 if (isLineStart && note.slice(i, lineEnd).trim() === REPLY_HEADING) {
60 return { start: i, zoneStart: end < 0 ? note.length : end + 1 }
61 }
62 from = i - 1
63 }
64 return undefined
65}
66
67function trimBlankLines(lines: readonly string[]): string {
68 const kept = lines.filter(line => !TEMPLATE_LINE.test(line))
69 while (kept.length > 0 && !kept[0]?.trim()) kept.shift()
70 while (kept.length > 0 && !kept[kept.length - 1]?.trim()) kept.pop()
71 return kept.join('\n')
72}
73
74// Reads the reply area. The message is the text above the first send line.
75// When nothing is above it, the message is the text below it.
76export function parseReply(note: string): Reply | undefined {
77 const heading = findReplyHeading(note)
78 if (heading === undefined) return undefined
79 const zone = note.slice(heading.zoneStart)
80 const lines = zone.split('\n')
81 const send = lines.findIndex(line => SEND_LINE.test(line))
82 if (send < 0) return { zoneStart: heading.zoneStart, zone, keep: zone }
83 const above = trimBlankLines(lines.slice(0, send))
84 const below = trimBlankLines(lines.slice(send + 1))
85 return above
86 ? { zoneStart: heading.zoneStart, zone, message: above, keep: below }
87 : { zoneStart: heading.zoneStart, zone, message: below, keep: '' }
88}
89
90function isListLine(line: string): boolean {
91 return /^\s*[-*+] /.test(line)
92}
93
94// Puts log units above the reply heading. Tool lines in a row stay one list.
95// A blank line goes between all other units. When the heading is gone, it
96// adds a new reply area at the end.
97export function logSplice(note: string, units: readonly string[]): Splice {
98 const parts = units.map(unit => unit.replace(/\n+$/, '')).filter(Boolean)
99 const heading = findReplyHeading(note)
100 const end = heading?.start ?? note.length
101 const before = note.slice(0, end).replace(/\n+$/, '')
102 let last = before.slice(before.lastIndexOf('\n') + 1)
103 let text = ''
104 for (const part of parts) {
105 const gap = before.length === 0 && text === '' ? '' : isListLine(last) && isListLine(part) ? '\n' : '\n\n'
106 text += gap + part
107 last = part.slice(part.lastIndexOf('\n') + 1)
108 }
109 const tail = heading ? '\n\n' : `\n\n${REPLY_HEADING}\n${EMPTY_ZONE}`
110 return { at: before.length, remove: end - before.length, insert: text + tail }
111}
112
113// Sets frontmatter values. It changes only the lines of these keys.
114export function propsSplices(note: string, props: Readonly<Record<string, string>>): Splice[] {
115 if (!note.startsWith('---\n')) return []
116 const close = note.indexOf('\n---', 3)
117 if (close < 0) return []
118 const frontmatter = note.slice(0, close + 1)
119 const out: Splice[] = []
120 for (const [key, value] of Object.entries(props)) {
121 const line = `${key}: ${JSON.stringify(value)}`
122 const found = new RegExp(`^${key}:.*$`, 'm').exec(frontmatter)
123 out.push(
124 found
125 ? { at: found.index, remove: found[0].length, insert: line }
126 : { at: close + 1, remove: 0, insert: `${line}\n` },
127 )
128 }
129 return out
130}
131
132// The splices that take a message: log it, clear the reply area, set the props.
133export function consumeSplices(
134 note: string,
135 reply: Reply,
136 units: readonly string[],
137 props: Readonly<Record<string, string>>,
138): Splice[] {
139 const zone = reply.keep ? `${reply.keep}\n${SEND_BOX}\n` : EMPTY_ZONE
140 return [
141 ...propsSplices(note, props),
142 ...(units.length > 0 ? [logSplice(note, units)] : []),
143 { at: reply.zoneStart, remove: reply.zone.length, insert: zone },
144 ]
145}
146
147// Escapes each "<" outside code. Obsidian reads text such as <name> as an
148// HTML tag, and a tag with no end can hide the rest of the note.
149export function escapeTags(markdown: string): string {
150 let fence: string | undefined
151 return markdown
152 .split('\n')
153 .map(line => {
154 const mark = /^\s*(`{3,}|~{3,})/.exec(line)?.[1]
155 if (fence !== undefined) {
156 if (mark && mark[0] === fence[0] && mark.length >= fence.length && line.trim() === mark) fence = undefined
157 return line
158 }
159 if (mark) {
160 fence = mark
161 return line
162 }
163 // Leave inline code as it is.
164 return line
165 .split(/(`+[^`]*`+)/)
166 .map((part, i) => (i % 2 === 1 ? part : part.replace(/</g, '<')))
167 .join('')
168 })
169 .join('\n')
170}
171
172export function callout(kind: string, title: string, body = '', isFolded = false): string {
173 const head = `> [!${kind}]${isFolded ? '-' : ''} ${escapeTags(title)}`
174 const lines = body
175 ? escapeTags(body.replace(/\r\n?/g, '\n')).split('\n').map(line => (line ? `> ${line}` : '>'))
176 : []
177 return `${[head, ...lines].join('\n')}\n\n`
178}
179
180export function cut(text: string, max: number): string {
181 return text.length > max ? `${text.slice(0, max)}\n\n… (cut. The full text is in the Claude app.)` : text
182}
183
184function short(text: string, max = 100): string {
185 const one = text.replace(/\s+/g, ' ').trim()
186 return one.length > max ? `${one.slice(0, max - 1)}…` : one
187}
188
189function code(text: string): string {
190 return `\`${text.replace(/`/g, "'")}\``
191}
192
193// Tools that add no useful line to the log.
194const QUIET_TOOLS = new Set(['ToolSearch', 'mcp__ccd_turn__update_plan', 'mcp__ccd_turn__wrap_up', 'AskUserQuestion'])
195
196export type LineCounts = { added: number; removed: number; isNew: boolean }
197
198type Hunk = { lines?: unknown }
199
200function countLines(text: string): number {
201 return text ? text.replace(/\n$/, '').split('\n').length : 0
202}
203
204// How many lines a file change added and removed, from the tool result.
205// Undefined when the tool changed no file or the call failed.
206export function lineCounts(tool: string, input: Readonly<Record<string, unknown>>, result: unknown): LineCounts | undefined {
207 if (typeof result !== 'object' || result === null) return undefined
208 const r = result as Record<string, unknown>
209 const str = (value: unknown): string => (typeof value === 'string' ? value : '')
210 if (tool === 'Write' && r.type === 'create') {
211 return { added: countLines(str(r.content) || str(input.content)), removed: 0, isNew: true }
212 }
213 if ((tool === 'Edit' || tool === 'Write') && Array.isArray(r.structuredPatch)) {
214 const lines = (r.structuredPatch as Hunk[]).flatMap(h => (Array.isArray(h.lines) ? (h.lines as unknown[]) : []))
215 return {
216 added: lines.filter(line => typeof line === 'string' && line.startsWith('+')).length,
217 removed: lines.filter(line => typeof line === 'string' && line.startsWith('-')).length,
218 isNew: false,
219 }
220 }
221 if (tool === 'NotebookEdit') {
222 return { added: countLines(str(r.new_source)), removed: countLines(str(r.old_source)), isNew: false }
223 }
224 return undefined
225}
226
227function changes(counts: { added: number; removed: number }): string {
228 return `+${counts.added} −${counts.removed}`
229}
230
231// One short list line for a tool call: what happened, with no code, input or output.
232export function toolLine(o: {
233 tool: string
234 input: Readonly<Record<string, unknown>>
235 cwd: string
236 home: string
237 time: string
238 counts?: LineCounts
239 isError: boolean
240}): string | undefined {
241 if (QUIET_TOOLS.has(o.tool)) return undefined
242 const text = (key: string): string => {
243 const value = o.input[key]
244 return typeof value === 'string' ? value : ''
245 }
246 const path = (key: string): string => {
247 const value = text(key)
248 if (o.cwd && value.startsWith(`${o.cwd}/`)) return code(value.slice(o.cwd.length + 1))
249 return code(o.home && value.startsWith(`${o.home}/`) ? `~${value.slice(o.home.length)}` : value)
250 }
251 const counted = o.counts ? ` (${changes(o.counts)})` : ''
252 const what = ((): string => {
253 switch (o.tool) {
254 case 'Bash':
255 return text('description')
256 ? `🖥️ ${short(text('description'))}`
257 : `🖥️ Ran ${code(text('command').trim().split(/\s+/)[0] ?? 'a command')}`
258 case 'Read':
259 return `📖 Read ${path('file_path')}`
260 case 'Edit':
261 return `✏️ Edited ${path('file_path')}${counted}`
262 case 'Write':
263 return `📝 ${o.counts?.isNew ? 'Created' : 'Wrote'} ${path('file_path')}${counted}`
264 case 'NotebookEdit':
265 return `✏️ Edited ${path('notebook_path')}${counted}`
266 case 'Grep':
267 return '🔎 Searched the code'
268 case 'Glob':
269 return '🔎 Looked for files'
270 case 'WebFetch':
271 return `🌐 Opened ${short(text('url').replace(/^https?:\/\//, ''), 60)}`
272 case 'WebSearch':
273 return `🌐 Searched the web for ${code(short(text('query'), 60))}`
274 case 'Agent':
275 case 'Task':
276 return `🤖 Started a subagent: ${short(text('description'), 80)}`
277 case 'TodoWrite':
278 return '🗒️ Updated the task list'
279 case 'Skill':
280 return `🧩 Used the ${code(text('skill'))} skill`
281 }
282 const mcp = /^mcp__(.+?)__(.+)$/.exec(o.tool)
283 return mcp ? `🔌 ${short(mcp[1] ?? '', 30)} · ${mcp[2]}` : `🔧 ${o.tool}`
284 })()
285 return `- ${o.time} · ${escapeTags(what)}${o.isError ? ' ❌' : ''}`
286}
287
288// The files that one turn changed, in one line.
289export function fileSummary(files: ReadonlyMap<string, { added: number; removed: number }>): string {
290 if (files.size === 0) return 'No files changed.'
291 let added = 0
292 let removed = 0
293 for (const counts of files.values()) {
294 added += counts.added
295 removed += counts.removed
296 }
297 return `${files.size} ${files.size === 1 ? 'file' : 'files'} changed · ${changes({ added, removed })} lines`
298}
299
300type Question = { question?: unknown; options?: unknown }
301type Option = { label?: unknown; description?: unknown }
302
303export function questionsText(input: Readonly<Record<string, unknown>>): string {
304 const questions = Array.isArray(input.questions) ? (input.questions as Question[]) : []
305 return questions
306 .map(q => {
307 const options = Array.isArray(q.options) ? (q.options as Option[]) : []
308 const rows = options.map(o => `- ${String(o.label ?? '')}${o.description ? ` — ${String(o.description)}` : ''}`)
309 return [`**${String(q.question ?? '')}**`, ...rows].join('\n')
310 })
311 .join('\n\n')
312}
313
314function pad(n: number): string {
315 return String(n).padStart(2, '0')
316}
317
318// Local date and 12-hour time. `offsetMinutes` is the zone offset from `date +%z`.
319export function clock(ms: number, offsetMinutes: number): { date: string; time: string; fileTime: string } {
320 const d = new Date(ms + offsetMinutes * 60_000)
321 const h24 = d.getUTCHours()
322 const h = h24 % 12 || 12
323 const m = pad(d.getUTCMinutes())
324 const ampm = h24 < 12 ? 'AM' : 'PM'
325 return {
326 date: `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`,
327 time: `${h}:${m} ${ampm}`,
328 fileTime: `${h}-${m} ${ampm}`,
329 }
330}
331
332export function parseOffset(text: string): number {
333 const m = /^([+-])(\d\d)(\d\d)$/.exec(text.trim())
334 if (!m) return 0
335 const minutes = Number(m[2]) * 60 + Number(m[3])
336 return m[1] === '-' ? -minutes : minutes
337}
338
339export function duration(ms: number): string {
340 const s = Math.round(ms / 1000)
341 if (s < 60) return `${s}s`
342 const m = Math.floor(s / 60)
343 if (m < 60) return `${m}m ${s % 60}s`
344 return `${Math.floor(m / 60)}h ${m % 60}m`
345}
346
347// A safe file-name part from free text.
348export function slug(text: string, maxLength = 48, maxWords = 8): string {
349 const words = text
350 .replace(/[\\/:*?"<>|#^[\]{}`]/g, ' ')
351 .replace(/\s+/g, ' ')
352 .trim()
353 .replace(/^\.+/, '')
354 .split(' ')
355 .slice(0, maxWords)
356 .join(' ')
357 return words.slice(0, maxLength).trim() || 'session'
358}
359
360// The note path: the start date and time, then the session name.
361export function notePathFor(folder: string, stamp: string, name: string, copy = 1): string {
362 return `${folder}/${stamp} - ${slug(name, 80, 20)}${copy > 1 ? ` (${copy})` : ''}.md`
363}
364
365// The start date and time at the front of a note file name.
366export function stampOf(path: string): string | undefined {
367 const name = path.split('/').pop() ?? ''
368 return /^(\d{4}-\d\d-\d\d \d{1,2}-\d\d [AP]M)/.exec(name)?.[1]
369}
370
371// The newest session name in the `grep -o` output of the transcript's
372// "customTitle" fields. The desktop app writes this name in its sidebar.
373export function parseTitle(grepOutput: string): string | undefined {
374 const last = grepOutput.trim().split('\n').pop() ?? ''
375 if (!last.startsWith('"customTitle":')) return undefined
376 try {
377 const value = (JSON.parse(`{${last}}`) as { customTitle?: unknown }).customTitle
378 return typeof value === 'string' && value.trim() ? value.trim() : undefined
379 } catch {
380 return undefined
381 }
382}
383
384// Replaces the first H1 line after the frontmatter.
385export function headingSplice(note: string, text: string): Splice | undefined {
386 const close = note.startsWith('---\n') ? note.indexOf('\n---', 3) + 4 : 0
387 const found = /^# .*$/m.exec(note.slice(close))
388 if (!found) return undefined
389 return { at: close + found.index, remove: found[0].length, insert: `# ${text}` }
390}
391
392export function newNote(o: {
393 sessionId: string
394 project: string
395 cwd: string
396 title: string
397 date: string
398 time: string
399 settleSeconds: number
400}): string {
401 return [
402 '---',
403 `claude_session: ${JSON.stringify(o.sessionId)}`,
404 `project: ${JSON.stringify(o.project)}`,
405 `cwd: ${JSON.stringify(o.cwd)}`,
406 `status: ${JSON.stringify('🟢 working')}`,
407 `started: ${JSON.stringify(`${o.date} ${o.time}`)}`,
408 `updated: ${JSON.stringify(o.time)}`,
409 'tags:',
410 ' - claude-session',
411 '---',
412 `# ${o.title}`,
413 '',
414 callout(
415 'info',
416 'How to reply from your phone',
417 [
418 `1. Write your message under **Reply to Claude**, above the **Send** box.`,
419 '2. Tick the **Send** box. You can also write a line with only `/send`.',
420 `3. Claude takes the message when the reply area stays the same for ${o.settleSeconds} seconds.`,
421 '4. Text below a ticked box stays in the area for your next message.',
422 ].join('\n'),
423 true,
424 ).trimEnd(),
425 '',
426 LOG_HEADING,
427 '',
428 REPLY_HEADING,
429 EMPTY_ZONE,
430 ].join('\n')
431}
432
433// The prompt as you typed it. The app wraps the text that it adds in tags with
434// a hyphen, such as <system-reminder> and <side-sessions-event>. This removes
435// those blocks. A prompt with only such blocks did not come from you.
436export function cleanPrompt(text: string): string {
437 return text
438 .replace(/<([a-z][a-z0-9]*-[a-z0-9-]*)(\s[^>]*)?>[\s\S]*?<\/\1>/g, '')
439 .replace(/\n{3,}/g, '\n\n')
440 .trim()
441}
442
443// The `vault=` argument for the Obsidian CLI. No name: the CLI uses its active vault.
444export function vaultArgs(name: string): string[] {
445 return name ? [`vault=${name}`] : []
446}
447
448const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
449
450export function base64(text: string): string {
451 const b = new TextEncoder().encode(text)
452 let out = ''
453 for (let i = 0; i < b.length; i += 3) {
454 const n = ((b[i] ?? 0) << 16) | ((b[i + 1] ?? 0) << 8) | (b[i + 2] ?? 0)
455 out += B64[(n >> 18) & 63]
456 out += B64[(n >> 12) & 63]
457 out += i + 1 < b.length ? B64[(n >> 6) & 63] : '='
458 out += i + 2 < b.length ? B64[n & 63] : '='
459 }
460 return out
461}
462
463// The JavaScript that runs in Obsidian through `obsidian eval`. It has no
464// backslashes and no line breaks, because the CLI changes `\n` in values.
465// It applies the splices only when the note still has the expected hash.
466export function appScript(payload: unknown): string {
467 return [
468 '(async()=>{',
469 `const P=JSON.parse(new TextDecoder().decode(Uint8Array.from(atob("${base64(JSON.stringify(payload))}"),c=>c.charCodeAt(0))));`,
470 'const H=s=>{let x=2166136261;for(let i=0;i<s.length;i++){x^=s.charCodeAt(i);x=Math.imul(x,16777619)>>>0}return s.length+":"+x};',
471 'const f=app.vault.getAbstractFileByPath(P.path);',
472 'if(P.rename!==undefined){if(!f)return "missing";if(app.vault.getAbstractFileByPath(P.rename))return "exists";',
473 'await app.fileManager.renameFile(f,P.rename);return "ok"}',
474 'if(P.create!==undefined){if(f)return "exists";',
475 'const d=P.path.split("/").slice(0,-1).join("/");',
476 'if(d&&!app.vault.getAbstractFileByPath(d))await app.vault.createFolder(d);',
477 'await app.vault.create(P.path,P.create);return "created"}',
478 'if(!f)return "missing";let r="conflict";',
479 'await app.vault.process(f,c=>{if(H(c)!==P.hash)return c;let o=c;',
480 'for(const s of P.splices)o=o.slice(0,s.at)+s.insert+o.slice(s.at+s.remove);r="ok";return o});',
481 'return r})()',
482 ].join('')
483}
484