SLOPSHOPPER

watch

memory-kit watch: delivers a home's declared inboxes into the session as prompts (idle-safe), and reports, information only, idleness and context growth past…

newguardcommandprompttimer
v0.5.0no licenseupdated 2026-10-16mitselek/memory-kit/adapters/claude-code/watch
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · watch
› fix the failing auth test and add an audit log call ⏺ 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 › /watch ⎿ watch: home: /work/app ⎿ watch: no watch_files in /work/app/memory-kit.conf; no doorbells. ⎿ watch: idle: off; context: off; poll every 5 s ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

watch

A Claude Code mod (function hooks) for a memory-kit home, kit issue #9, reshaped 2026-10-16. It is a separate plugin from the obligations pane, which stays read-only. A home can take either one without the other. Built against Claude Code 2.1.296's plugin API and checked live on 2.1.299 (early access; the API moves between releases).

The watch is information only. It never reads a file's meaning, never empties, moves or archives anything, and never says what a signal means: that is the home's own rule. On one timer (pollSeconds, default 5) it watches two kinds of things, both declared in the home's memory-kit.conf (absent means off):

  1. Doorbells (watch_files): when a declared file grows, the watch rings once -- one $.prompt.submit, which queues until the session is idle and starts a turn of its own, marked as coming from the plugin.
  2. Session sensors (watch_idle_minutes, watch_context_percent): one report when the session has sat idle past the mark, one when its context crosses each declared percent of the window.

What it costs: every ring or report is a full turn (kit #17). The turn carries the session's whole context, so on a long session one prompt costs hundreds of thousands of input tokens, even when it says nothing new. Declare only what the session must act on.

Doorbells

watch_files:
  - label: "hub mail"
    path: comms/inboxes/team-lead.json
    instruction: "consume the inbox or new messages won't land"
    inject: false
  - label: ""
    path: ~/spool/links.txt
    instruction: ""
    inject: true

Every entry carries the same four fields, empty where unused:

  • label -- what the ring arrives under, as [label]. Empty: the file's name.
  • path -- absolute (POSIX, C:\, C:/ or UNC), ~/ for the user's home (HOME, else USERPROFILE on Windows), or relative to the kit home. A * in a segment matches entries of that directory (never a dotfile); each matched file is its own bell.
  • instruction -- home-authored text the ring carries verbatim. The watch relays the home's own words; it still decides nothing. Empty: the ring is just the bell.
  • inject -- true: the ring includes the grown tail's non-blank lines, not just the bell. For short, line-shaped files (a spool of URLs, one-line mail summaries): the harness cuts very long lines, so a file of essays wants inject: false and a session that reads the file itself.

A ring is growth. The watch remembers each file's last-seen size; bigger rings once, and everything else only moves the bookmark: created at 0 bytes is not growth, shrinking and truncating are not growth, a deleted file counts 0 (so delete-then-write rings). A file that sits unconsumed is not nagged: no new growth, no new ring. All of one poll's rings go as one prompt. A ring the session's guards refuse rolls its bookmark back and rings again at a later poll.

Consuming is the home's own step. An inbox-style file is consumed by reading it and then deleting or emptying it -- either resets the bookmark, so the next real write rings again. A tail-style file (inject: true) needs no consuming: the bookmark simply advances, and the file may grow forever. Say the habit in the entry's instruction, where the session will read it.

A ring reads [hub mail] /home/u/kit/comms/inboxes/team-lead.json grew. consume the inbox or new messages won't land -- with inject, the new lines follow, one per line.

Never silently idle (kit #14): a watch_files the watch cannot read, an entry without a path, an unknown field, an inject that is not true/false -- each is one visible notice (notify, else a transcript line), never a debug line alone, and the good entries still run. A conf still declaring watch_sources (watch 0.4.x and older) gets one migration notice: declare watch_files, and let the home consume its own files.

Session sensors

watch_idle_minutes: 50
watch_context_percent: 50, 60, 70, 80
  • Idle: when the session has sat that many minutes with no prompt, tool call or turn end, one report: [watch] This session has been idle for 52 min .... If the conf declares a watch_retire_step, the report quotes it as the home's own declared step. The report is armed by a human prompt and disarmed by firing, so one working stretch gets one report -- the watch's own rings never arm it, and a session no human spoke to is never pinged idle.
  • Context: percent marks of the window (1-99), against the exact $.session.usage() reading. Crossing a mark gives one report naming the load and the mark; each mark fires once per session; a jump over several names the highest. A compaction that shrinks the context does not repeat old marks.

Both are information only: what a report means -- checkpoint, retire, nothing -- is the home's rule (CLAUDE.md or its kin), not the watch's.

A remote hub: a spool fed by a peek-only poller (kit #16)

The watch reads local files only, and it never touches a hub. For mail on a remote hub, such as Lelle's stationmaster reached with evr-mail recv, a poller peeks at the hub and appends one line per new item to a local spool. The home declares that spool as an injecting doorbell:

watch_files:
  - label: lelle
    path: inbound/lelle-spool.txt
    instruction: "collect with recv, then ack BY ID what you read"
    inject: true

Each ring carries the new lines, from=… id=… subject=…. The session then collects and acks the mail with its own recv, after recording it. Custody stays with the session: the poller only looks, and the watch only rings. The spool is never consumed; the bookmark advances.

Ack only what you read, by id (kit #39). evr-mail recv without --peek acks everything waiting on the hub, not what the peek showed: mail that arrived after the spool line is acked too. framework-research acked two messages unread that way on 2026-10-15; both needed resends. The rule: ack by id the messages you have read and recorded, and nothing else; an ack with no ids acks nothing. Until a home's mail tool acks by id: recv prints every message before it acks it, so read and record everything that run printed, not only the ones the ring named.

spool/spool-poll.py (Python 3, stdlib only) is the poller, generic over the command:

python3 memory-kit/adapters/claude-code/watch/spool/spool-poll.py \
  --spool inbound/lelle-spool.txt \
  --match '^--- \d+/\d+\s+from=(?P<from>\S+)\s+id=(?P<id>\S+)(?:\nsubject: (?P<subject>.*))?' \
  --line 'from={from} id={id} subject={subject}' --healthy '^collected' \
  -- python3 tools/evr-mail.py recv --peek
  • Peek only. The command after -- must only look (recv --peek), never collect or ack. The poller cannot check that: it is the home's choice of command.
  • Items. Each match of --match in the command's output (a Python regex, multiline) is one item, keyed by its named group id. --line writes the spool line from the named groups; an absent group is empty. Without --line, the line is the match itself, on one line. A line never holds a line break.
  • Each item once. Seen ids persist in .<stem>-seen.json beside the spool (the last 500, --keep). So a peeked but uncollected item is not spooled again, across restarts too.
  • Health. A run fails when the command cannot start, times out (--timeout, 90 s), exits non-zero, or prints nothing matching --healthy. After 3 failures in a row (--tolerate), the spool gets one health: <why> line, then at most one per 30 min (--health-every) while the failures go on, and health: recovered once a run succeeds. Each health line grows the spool, so it rings: a full turn.
  • Every 30 s (--every).
  • One poller per spool. It holds .<stem>.lock beside the spool and writes its pid there (flock on POSIX, msvcrt on Windows). A second poller finds the lock held, says so and exits 0.
  • JSON instead of a regex (--json, kit #39, hr2): give the peek command its JSON form and --json in place of --match; the first JSON value must be a list of objects with an id.
  • A reminder for mail left waiting (--remind N, minutes, off by default; kit #39): an id still in the peek N minutes after first seen gets one waiting: ... line.
  • Liveness (kit #39): each successful run stamps .<stem>.alive; --status prints the poller's state (exit 0 running and fresh); --keep-alive makes sure one poller runs and returns at once -- put it in the home's SessionStart hook (see spool/spool-poll.py --help and the adapters README). --once polls once, for a scheduler.

A session outside its home: kit_home (kit #18)

User-scope options apply to every session on the machine. That covers home set in the user's settings, not only the home's own sessions. Use a pointer conf instead: put this in the folder the session starts in:

kit_home: ../desk        # absolute, or relative to this conf's own folder
  • What counts: the home it names. That home's memory-kit.conf declares the doorbells and sensors, with paths relative to that home. Other keys left in the pointer conf are ignored, and watch_files left there are a visible notice.
  • Precedence: the start folder's pointer first. It wins over the user-scope home option. Then the option, then the start folder itself.
  • One hop, never chained. If the target's own conf points on, that is refused, and so is a loop. kit_home: . is just the home.
  • Never silent: a target with no memory-kit.conf, a chained pointer or a ~ path is one visible notice, and the watch rings nothing. It never falls back to the start folder. /watch names the home and the pointer.
  • A session whose home came from the user-scope home option but which started outside that home rings nothing there (one notice); its own session sensors still run.
  • The lint and the tools follow the same pointer (lint/kit_home.py). This plugin's copy of the rules is hooks/kit-home.ts, identical in all three plugins; lint/run-fixtures.sh fails when the copies differ.

Options (userConfig)

  • home: the kit home whose memory-kit.conf declares the doorbells and sensors. Empty: the session's start directory. Set user-wide, it applies to every session on the machine: prefer a kit_home pointer conf (above), which also wins over this option.
  • pollSeconds (5, at least 5): the poll interval. It reads local files only.
  • Setting options from a shell: claude plugin configure watch@memory-kit --values-stdin takes a JSON object of strings only, e.g. {"pollSeconds": "5"} (found by mvox, 2026-10-14).
  • retireOnIdle, idleMinutes, growthPercent are gone (watch 0.5.0): a conf setting retireOnIdle gets one notice pointing at watch_idle_minutes and watch_context_percent.

/watch

Answers with the home (and pointer), each doorbell (label, path, inject, instruction), every problem in the declaration, and the sensor marks.

Live findings (2026-10-14, Claude Code 2.1.299, throwaway tmux sessions)

  • $.prompt.submit from a timer wakes a session idle at the prompt. This held before any turn and after one (44 s idle). It resolved about 60 ms later, as the turn started. Two submits in a row ran as two turns, in order. The model reads it as "The watch plugin sent a message: …".
  • $.session.usage() while idle gives the last response's input. Before the first response: context.tokens and percent undefined, window 1000000. After a turn: the last response's input tokens, unchanged until the next turn. So the sensors read a stable figure.
  • A prompt the plugin submits does not reach its own prompt.submit hook ("re-entry" in the debug log). Activity is therefore taken from prompts, tool calls and turn ends.
  • A typed prompt arrives with origin.kind === 'composer'.
  • $.ui.notify in tmux answered no-channel, hence the transcript fallback.

Limits

  • A ring is growth in the text $.fs.read returns, compared only with itself. A same-size overwrite is invisible; that is the definition, not an accident.
  • The watch does not watch directories, only files (a * path is expanded per poll, so a new matching file is picked up then).
  • Two sessions in one home each keep their own bookmarks: both ring. Information is per session; consuming is not, so the home's rules say who consumes.

Install

It travels with the kit, beside the pane, in the same memory-kit marketplace:

claude plugin install watch@memory-kit

The marketplace entry to add upstream (adapters/claude-code/.claude-plugin/marketplace.json):

{
  "name": "watch",
  "source": "./watch",
  "description": "Rings a doorbell when a home's declared files grow, and reports the session's idleness and context marks. Information only."
}

For one session only: claude --plugin-dir ./memory-kit/adapters/claude-code/watch.

Scope. This installs at user scope: every session on the machine loads the plugin. What it does in a folder that is not a kit home, and the project-scope alternative: Install scope.

Develop

claude plugin validate .
claude plugin test .          # tests/*.test.ts; files, clock, store and the session are mocked
tsc -p .                      # once Claude Code has laid .claude-plugin/types/ (gitignored)
Source 4 files
hooks/register.ts 372 lines
1// The watch mod (memory-kit issue #9). Information only, on one timer:
2//  1. doorbells: when a file the home declares (watch_files) grows, ring once -- one
3//     $.prompt.submit, which queues until the session is idle and wakes it then. The watch never
4//     reads a file's meaning and never empties, moves or archives it; consuming (read, then
5//     delete or empty) is the home's own step, and it is what re-arms the bell;
6//  2. session reports: idleness past watch_idle_minutes and context past each
7//     watch_context_percent mark. The watch never says what a ring or report means; that is the
8//     home's own rule.
9
10import { atom, read } from 'claude-code'
11import type { EngineInterface, Register } from 'claude-code'
12
13import {
14  bellLabel,
15  combineRings,
16  confContextPercent,
17  confIdleMinutes,
18  confValue,
19  contextReport,
20  expandHome,
21  idleReport,
22  isHuman,
23  isInside,
24  marksCrossed,
25  matchSegment,
26  MINUTE,
27  pathSegments,
28  readDoorbells,
29  ringText,
30} from './watch'
31import type { Doorbell } from './watch'
32import { resolveKitHome } from './kit-home'
33import type { KitHome } from './kit-home'
34
35const startCwd = atom({ plugin: 'watch', key: 'startCwd' } as const, '')
36/** Context marks already reported this session: once per mark, reloads included. */
37const reportedMarks = atom({ plugin: 'watch', key: 'reportedMarks' } as const, [] as number[])
38/** Each watched file's last-seen size, by expanded path: growth past it rings. */
39const bellSizes = atom({ plugin: 'watch', key: 'bellSizes' } as const, {} as Record<string, number>)
40
41type Settings = { home: string; pollMs: number }
42
43const num = (v: unknown, fallback: number) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : fallback)
44
45// Module state: a reload starts it over, which only ever holds a report back (not armed yet).
46/** Armed by a human prompt, disarmed by the one idle report: one report per working stretch,
47 *  and the report's own turn can never re-arm it. */
48let idleArmed = false
49let lastActivity = 0
50/** Set when register() saw a retired option: told once at session start. */
51let retiredOption: string | undefined
52let polling = false
53const logged = new Set<string>()
54
55/** One debug line per distinct message for the module's life: a poll every 5 s must not spam. */
56function logOnce($: EngineInterface, text: string): void {
57  if (logged.has(text)) return
58  logged.add(text)
59  $.ui.log(`watch: ${text}`, { to: 'debug' })
60}
61
62async function readOrEmpty($: EngineInterface, path: string): Promise<string> {
63  try {
64    return (await $.fs.exists(path)) ? await $.fs.read(path) : ''
65  } catch {
66    return ''
67  }
68}
69
70/** A file's text, undefined when it is missing or unreadable. */
71async function readMaybe($: EngineInterface, path: string): Promise<string | undefined> {
72  try {
73    return (await $.fs.exists(path)) ? await $.fs.read(path) : undefined
74  } catch {
75    return undefined
76  }
77}
78
79/**
80 * The kit home (kit #18). The start folder's memory-kit.conf decides first: a `kit_home:` pointer
81 * names the home, one hop, and wins over the user-scope `home` option, which applies to every
82 * session on the machine. Without a pointer, the option, else the start folder.
83 */
84async function homeOf($: EngineInterface, settings: Settings): Promise<Placed> {
85  const start = (await read($, startCwd)) || (await $.session.cwd())
86  const found = await resolveKitHome(start, p => readMaybe($, p))
87  if ('problem' in found || found.pointer !== undefined || settings.home === '') return { found, start, viaOption: false }
88  return { found: { home: expandHome(settings.home, start, '') ?? start, ignored: [] }, start, viaOption: true }
89}
90
91/** The kit home as found, the folder the session started in, and whether the home came from the `home` option. */
92type Placed = { found: KitHome; start: string; viaOption: boolean }
93
94/** A usable home: where it is, where the session started, and whether the `home` option named it. */
95type Home = { home: string; start: string; viaOption: boolean }
96
97/** The home, or one visible notice when the pointer is broken; also when it leaves watch_files behind. */
98async function usableHome($: EngineInterface, settings: Settings): Promise<Home | undefined> {
99  const { found, start, viaOption } = await homeOf($, settings)
100  if ('problem' in found) {
101    await tellOnce($, `${found.problem}. The watch rings nothing until it is fixed.`)
102    return undefined
103  }
104  if (found.pointer !== undefined && found.ignored.includes('watch_files'))
105    await tellOnce($, `watch_files in ${found.pointer} are ignored: that conf points at the home (kit_home), and they count in ${found.home}/memory-kit.conf.`)
106  return { home: found.home, start, viaOption }
107}
108
109/** The files a path names; `*` in a segment matches the directory's entries (never dotfiles). */
110async function expand($: EngineInterface, path: string): Promise<string[]> {
111  if (!path.includes('*')) return [path]
112  const segments = pathSegments(path)
113  let bases = [segments[0] ?? '']
114  for (const segment of segments.slice(1)) {
115    const next: string[] = []
116    for (const base of bases) {
117      if (!segment.includes('*')) {
118        next.push(`${base}/${segment}`)
119        continue
120      }
121      try {
122        // '' is the POSIX root; a bare drive ('C:') is that drive's root, not its current directory.
123        const dir = base === '' ? '/' : /^[A-Za-z]:$/.test(base) ? `${base}/` : base
124        for (const entry of await $.fs.list(dir)) if (matchSegment(segment, entry.name)) next.push(`${base}/${entry.name}`)
125      } catch {
126        // A directory that is not there matches nothing.
127      }
128    }
129    bases = next.sort()
130  }
131  return bases
132}
133
134/**
135 * One prompt, fire and forget (its submit resolves only when its turn starts, which a poll must
136 * not wait for). A refusal runs `undo`, so a lost ring's baseline rolls back and the growth
137 * rings again at a later poll.
138 */
139function ask($: EngineInterface, text: string, undo?: () => Promise<void>): void {
140  void $.prompt.submit({ text }).then(
141    r => {
142      if (r.drop !== undefined) {
143        logOnce($, `prompt refused: ${r.drop}`)
144        return undo?.()
145      }
146      return
147    },
148    err => {
149      logOnce($, `prompt refused: ${String(err)}`)
150      return undo?.()
151    },
152  )
153}
154
155/** The last-seen sizes, validated out of $.state. */
156async function sizesOf($: EngineInterface): Promise<Record<string, number>> {
157  const raw = await read($, bellSizes)
158  if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return {}
159  return Object.fromEntries(Object.entries(raw).filter((e): e is [string, number] => typeof e[1] === 'number'))
160}
161
162/**
163 * The doorbells: every declared file that grew since its last-seen size rings, all of one poll's
164 * rings as one prompt. Sizes are character counts of what $.fs.read returns -- compared only with
165 * themselves. A missing file counts 0, so delete-then-write rings and create-at-0 does not.
166 */
167async function doorbellCheck($: EngineInterface, home: string, conf: string): Promise<void> {
168  const confPath = `${home}/memory-kit.conf`
169  if (/^watch_sources:/m.test(conf))
170    await tellOnce($, `watch_sources in ${confPath} is gone (watch 0.5.0): declare watch_files entries (label, path, instruction, inject); consuming a file is the home's own step.`)
171  const d = readDoorbells(conf)
172  for (const p of d.problems) await tellOnce($, `watch_files in ${confPath}: ${p}`)
173  if (d.bells.length === 0) return
174
175  const userHome = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? ''
176  const sizes = await sizesOf($)
177  const grown: { bell: Doorbell; file: string; old: number; content: string }[] = []
178  let changed = false
179  for (const bell of d.bells) {
180    const path = expandHome(bell.path, home, userHome)
181    if (path === undefined) {
182      await tellOnce($, `watch_files in ${confPath}: skipped "${bell.path}": no HOME or USERPROFILE for ~`)
183      continue
184    }
185    for (const file of await expand($, path)) {
186      const content = await readOrEmpty($, file)
187      const old = sizes[file] ?? 0
188      if (content.length > old) grown.push({ bell, file, old, content })
189      if (content.length !== old) {
190        sizes[file] = content.length
191        changed = true
192      }
193    }
194  }
195  if (changed) await $.state.set({ plugin: 'watch', key: 'bellSizes' }, sizes)
196  if (grown.length === 0) return
197  $.ui.log(`watch: ring (${grown.map(g => bellLabel(g.bell, g.file)).join(', ')})`, { to: 'debug' })
198  ask($, combineRings(grown.map(g => ringText(g.bell, g.file, g.old, g.content))), async () => {
199    const now = await sizesOf($)
200    for (const g of grown) now[g.file] = Math.min(now[g.file] ?? g.old, g.old)
201    await $.state.set({ plugin: 'watch', key: 'bellSizes' }, now)
202  })
203}
204
205/**
206 * The session reports, both information only. A conf line that cannot be read is said once, never
207 * a silent off. Each context mark reports once per session; the idle report fires once per
208 * working stretch (armed by a human prompt, disarmed by the report itself).
209 */
210async function reportCheck($: EngineInterface, conf: string): Promise<void> {
211  const idle = confIdleMinutes(conf)
212  if (idle.problem !== undefined) logOnce($, `watch_idle_minutes: ${idle.problem}`)
213  const marks = confContextPercent(conf)
214  if (marks.problem !== undefined) logOnce($, `watch_context_percent: ${marks.problem}`)
215  if (idle.values.length === 0 && marks.values.length === 0) return
216
217  const now = await $.clock.now()
218  const { context } = await $.session.usage()
219
220  const idleMinutes = idle.values[0]
221  if (idleMinutes !== undefined && idleArmed && now - lastActivity >= idleMinutes * MINUTE) {
222    idleArmed = false
223    $.ui.log(`watch: idle report (${Math.round((now - lastActivity) / MINUTE)} min)`, { to: 'debug' })
224    ask($, idleReport(Math.round((now - lastActivity) / MINUTE), confValue(conf, 'watch_retire_step')))
225  }
226
227  if (marks.values.length > 0 && context.tokens !== undefined) {
228    const done = await read($, reportedMarks)
229    const reported = Array.isArray(done) ? done.filter((m): m is number => typeof m === 'number') : []
230    const crossed = marksCrossed(marks.values, reported, context.tokens, context.window)
231    if (crossed.length > 0) {
232      await $.state.set({ plugin: 'watch', key: 'reportedMarks' }, [...reported, ...crossed])
233      const top = crossed[crossed.length - 1]
234      if (top !== undefined) {
235        $.ui.log(`watch: context report (${crossed.join('%, ')}% crossed)`, { to: 'debug' })
236        ask($, contextReport(top, context.tokens, context.window))
237      }
238    }
239  }
240}
241
242/**
243 * One notice to the human. A terminal without a notification channel (tmux, live check
244 * 2026-10-14: `no-channel`) or with a bare bell shows no text, so the text also goes to the
245 * transcript as a dim line.
246 */
247async function tell($: EngineInterface, text: string): Promise<void> {
248  let shown = false
249  try {
250    const sent = await $.ui.notify(text, { title: 'watch' })
251    shown = sent.isSent && sent.channel !== 'terminal_bell'
252  } catch {
253    // Refused: the transcript line below carries it.
254  }
255  // The engine already heads a plugin's transcript line with its name (seen live).
256  if (!shown) $.ui.log(text)
257}
258
259const told = new Set<string>()
260
261/** tell, once per distinct text for the module's life. */
262async function tellOnce($: EngineInterface, text: string): Promise<void> {
263  if (told.has(text)) return
264  told.add(text)
265  await tell($, text)
266}
267
268/** What /watch answers: the doorbells active now, what was skipped and why, the session sensors. */
269async function status($: EngineInterface, settings: Settings): Promise<string> {
270  const { found, start, viaOption } = await homeOf($, settings)
271  if ('problem' in found) return `${found.problem}.\nThe watch rings nothing until it is fixed.`
272  const home = found.home
273  const conf = await readOrEmpty($, `${home}/memory-kit.conf`)
274  const confPath = `${home}/memory-kit.conf`
275  const d = readDoorbells(conf)
276  const lines: string[] = [`home: ${home}${found.pointer !== undefined ? ` (kit_home in ${found.pointer})` : ''}`]
277  if (!d.declared) lines.push(`no watch_files in ${confPath}; no doorbells.`)
278  else {
279    const n = d.bells.length
280    lines.push(`${n} doorbell${n === 1 ? '' : 's'}, from ${confPath}`)
281    for (const b of d.bells)
282      lines.push(`  [${b.label === '' ? `(${b.path.split(/[\\/]/).pop()})` : b.label}] ${b.path}${b.inject ? '  inject' : ''}${b.instruction === '' ? '' : `  "${b.instruction}"`}`)
283    for (const p of d.problems) lines.push(`  problem: ${p}`)
284    if (viaOption && !isInside(start, home)) lines.push(`ringing here: no; this session started outside the home (${start})`)
285  }
286  const idle = confIdleMinutes(conf)
287  const marks = confContextPercent(conf)
288  const idleSay = idle.problem !== undefined ? `unreadable (${idle.problem})` : idle.values[0] !== undefined ? `report at ${idle.values[0]} min` : 'off'
289  const markSay = marks.problem !== undefined ? `unreadable (${marks.problem})` : marks.values.length > 0 ? `report at ${marks.values.join(', ')}% of the window` : 'off'
290  lines.push(`idle: ${idleSay}; context: ${markSay}; poll every ${settings.pollMs / 1000} s`)
291  return lines.join('\n')
292}
293
294async function tick($: EngineInterface, settings: Settings): Promise<void> {
295  if (polling) return
296  polling = true
297  try {
298    const at = await usableHome($, settings)
299    if (at === undefined) return
300    const conf = await readOrEmpty($, `${at.home}/memory-kit.conf`)
301    // The `home` option applies to every session on the machine; a session started outside that
302    // home stays silent there (the session sensors are this session's own and still run).
303    if (at.viaOption && !isInside(at.start, at.home)) {
304      if (readDoorbells(conf).declared)
305        await tellOnce(
306          $,
307          `This session started in ${at.start}, outside the watch's home ${at.home} (the home option), so it rings nothing here. To watch the home from another folder, put "kit_home: ${at.home}" in that folder's memory-kit.conf.`,
308        )
309    } else await doorbellCheck($, at.home, conf)
310    await reportCheck($, conf)
311  } catch (err) {
312    logOnce($, `poll failed: ${String(err)}`)
313  } finally {
314    polling = false
315  }
316}
317
318async function markActivity($: EngineInterface): Promise<void> {
319  lastActivity = await $.clock.now()
320}
321
322export const register: Register = (on, options) => {
323  const settings: Settings = {
324    home: typeof options.home === 'string' ? options.home.trim() : '',
325    pollMs: Math.max(5, num(options.pollSeconds, 5)) * 1_000,
326  }
327  // The retire options moved into memory-kit.conf (watch 0.5.0): say so, never silently.
328  if (options.retireOnIdle === true) {
329    retiredOption = 'the retireOnIdle option is gone: declare watch_idle_minutes and watch_context_percent in memory-kit.conf (watch 0.5.0, information-only reports)'
330  }
331  let timer: { cancel: () => void } | undefined
332
333  on('session.start', async ($, e, next) => {
334    const started = await next(e)
335    if ((await read($, startCwd)) === '') await $.state.set({ plugin: 'watch', key: 'startCwd' }, e.cwd)
336    if (retiredOption !== undefined) await tellOnce($, retiredOption)
337    await markActivity($)
338    await $.command.register({ name: 'watch', description: 'Show the doorbells and session reports active in this home' })
339    timer?.cancel()
340    timer = $.clock.every(settings.pollMs, () => void tick($, settings))
341    // Not awaited: a ring waits for its turn to start, which a start hook must not hold up.
342    void tick($, settings)
343    return started
344  })
345
346  on('command.run', { command: 'watch' }, async $ => ({ text: await status($, settings) }))
347
348  // Activity, for the idle clock. Observers: a failure here never touches the prompt or the call.
349  on('prompt.submit', async ($, e, next) => {
350    try {
351      await markActivity($)
352      // Only the human arms the idle report: the watch's own rings and reports never do,
353      // so one working stretch gets one report, not one every idle interval.
354      if (isHuman(e.origin)) idleArmed = true
355    } catch {
356      // The idle clock is not worth a lost prompt.
357    }
358    return next(e)
359  }).catch(($, e, next) => next(e))
360
361  on('turn.complete', async ($, e, next) => {
362    const done = await next(e)
363    await markActivity($)
364    return done
365  }).catch(($, e, next) => next(e))
366
367  on('tool.call', async ($, e, next) => {
368    await markActivity($)
369    return next(e)
370  }).catch(($, e, next) => next(e))
371}
372
hooks/watch.ts 272 lines
1// Pure logic of the watch mod: no `$`, no I/O. Everything here is unit-tested.
2//
3// The watch is information only (kit #9, reshaped 2026-10-16). It rings doorbells on files the
4// home declares and reports the session's idleness and context growth; what any ring or report
5// means is the home's own rule. Declared per kit home in memory-kit.conf:
6//
7//   watch_files:
8//     - label: ""
9//       path: comms/inboxes/team-lead.json
10//       instruction: "consume the inbox or new messages won't land"
11//       inject: false
12//   watch_idle_minutes: 50
13//   watch_context_percent: 50, 60, 70, 80
14
15import type { PromptOrigin } from 'claude-code'
16
17// ---------------------------------------------------------------------------------------------
18// The conf.
19
20/** Strips a trailing ` # comment` and one pair of enclosing quotes. */
21function unquote(raw: string): string {
22  const text = raw.trim()
23  const quoted = /^(["'])(.*)\1(\s+#.*)?$/.exec(text)
24  if (quoted) return quoted[2] ?? ''
25  const hash = text.search(/(^|\s)#/)
26  return (hash === -1 ? text : text.slice(0, hash)).trim()
27}
28
29/** A top-level `key: value` of memory-kit.conf, unquoted; undefined when absent or empty. */
30export function confValue(conf: string, key: string): string | undefined {
31  for (const raw of conf.split(/\r?\n/)) {
32    if (!raw.startsWith(`${key}:`)) continue
33    const v = unquote(raw.slice(key.length + 1))
34    return v === '' ? undefined : v
35  }
36  return undefined
37}
38
39// ---------------------------------------------------------------------------------------------
40// Doorbells. A doorbell is a labeled file: when the file GROWS, the watch rings once -- one
41// prompt at the next turn seam. The watch never reads a file's meaning, never empties, moves or
42// archives it; consuming (read, then delete or empty) is the home's own step, and it is what
43// re-arms the bell. Created at 0 bytes is not growth; shrinking only resets the remembered size.
44
45/** One declared doorbell. Every field is always present in the conf; empty means its default. */
46export type Doorbell = {
47  /** The ring's label; '' means the file's name. */
48  label: string
49  path: string
50  /** Home-authored text the ring carries verbatim; '' means just the bell. */
51  instruction: string
52  /** Ring with the grown tail's lines included (for short, line-shaped files), not just the bell. */
53  inject: boolean
54}
55
56export const DOORBELL_KEYS = ['label', 'path', 'instruction', 'inject'] as const
57
58/**
59 * What `watch_files:` declares: a block list of entries, each a map of the four keys
60 * (`- label: ...` then indented `path:`, `instruction:`, `inject:`). `declared` is whether the
61 * key is there; every entry that cannot be used is a problem said out loud, never silently off.
62 * An explicit empty list is a home ringing nothing on purpose.
63 */
64export type DoorbellList = { declared: boolean; bells: Doorbell[]; problems: string[] }
65
66export function readDoorbells(conf: string): DoorbellList {
67  const lines = conf.split(/\r?\n/)
68  const at = lines.findIndex(l => /^watch_files:/.test(l))
69  if (at === -1) return { declared: false, bells: [], problems: [] }
70  const rest = (lines[at] ?? '').slice('watch_files:'.length).trim()
71  if (rest !== '' && !rest.startsWith('#'))
72    return { declared: true, bells: [], problems: ['expected a block list of entries ("- label: ..." lines), not an inline value'] }
73
74  const bells: Doorbell[] = []
75  const problems: string[] = []
76  let entry: Partial<Record<(typeof DOORBELL_KEYS)[number], string>> | undefined
77  let entryBad = false
78  const close = () => {
79    if (entry !== undefined && !entryBad) {
80      const n = bells.length + problems.length + 1
81      if (entry.path === undefined || entry.path === '') problems.push(`entry ${n}: no path`)
82      else if (entry.inject !== undefined && entry.inject !== '' && entry.inject !== 'true' && entry.inject !== 'false')
83        problems.push(`entry ${n}: inject must be true or false, not "${entry.inject}"`)
84      else bells.push({ label: entry.label ?? '', path: entry.path, instruction: entry.instruction ?? '', inject: entry.inject === 'true' })
85    }
86    entry = undefined
87    entryBad = false
88  }
89  for (const raw of lines.slice(at + 1)) {
90    if (/^\s*(#.*)?$/.test(raw)) continue
91    // Entries may sit at column 0 under the key, as yaml.safe_dump writes them; any other
92    // column-0 line that is not a list item is the next conf key.
93    if (!/^\s/.test(raw) && !/^-(\s|$)/.test(raw)) break
94    const item = /^\s*-\s+(.*)$/.exec(raw)
95    const field = /^\s*([A-Za-z_]+):(.*)$/.exec(item?.[1] ?? raw.trim())
96    if (item) close()
97    if (!field) {
98      if (!entryBad) problems.push(`not a "key: value" line: ${raw.trim()}`)
99      entryBad = true
100      if (item) entry = {}
101      continue
102    }
103    if (entry === undefined && !item) {
104      problems.push(`a field outside any entry (missing "- "?): ${raw.trim()}`)
105      continue
106    }
107    if (item) entry = {}
108    const key = field[1] ?? ''
109    if (!(DOORBELL_KEYS as readonly string[]).includes(key)) {
110      problems.push(`unknown field "${key}" (label, path, instruction, inject)`)
111      entryBad = true
112      continue
113    }
114    if (entry !== undefined && entry[key as (typeof DOORBELL_KEYS)[number]] !== undefined) {
115      problems.push(`field "${key}" given twice in one entry`)
116      entryBad = true
117      continue
118    }
119    if (entry !== undefined) entry[key as (typeof DOORBELL_KEYS)[number]] = unquote(field[2] ?? '')
120  }
121  close()
122  if (bells.length === 0 && problems.length === 0) problems.push('no entries under it')
123  return { declared: true, bells, problems }
124}
125
126const baseOf = (path: string) => path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1)
127
128/** A doorbell's effective label: its own, else the file's name. */
129export function bellLabel(bell: Doorbell, file: string): string {
130  return bell.label !== '' ? bell.label : baseOf(file)
131}
132
133/**
134 * One ring. Information plus the home's own instruction; with `inject`, the grown tail's
135 * non-blank lines ride along (meant for short, line-shaped files: the harness cuts long lines).
136 */
137export function ringText(bell: Doorbell, file: string, oldSize: number, content: string): string {
138  const head = `[${bellLabel(bell, file)}] ${file} grew.${bell.instruction === '' ? '' : ` ${bell.instruction}`}`
139  if (!bell.inject) return head
140  const tail = content
141    .slice(oldSize)
142    .split(/\r?\n/)
143    .map(l => l.trimEnd())
144    .filter(l => l.trim() !== '')
145  return [head, ...tail].join('\n')
146}
147
148/** One prompt for one poll's rings: every delivered prompt is a full-context turn. */
149export function combineRings(rings: readonly string[]): string {
150  if (rings.length === 1) return rings[0] ?? ''
151  return [`[watch] ${rings.length} doorbells:`, ...rings].join('\n')
152}
153
154// ---------------------------------------------------------------------------------------------
155// Paths.
156
157const ABSOLUTE = /^(\/|[A-Za-z]:[\\/]|\\\\)/
158
159/** `~/` from the user's home, a relative path from the kit home; undefined when `~` has no home. */
160export function expandHome(path: string, kitHome: string, userHome: string): string | undefined {
161  if (path === '~' || path.startsWith('~/')) return userHome === '' ? undefined : `${userHome}${path.slice(1)}`
162  return ABSOLUTE.test(path) ? path : `${kitHome}/${path}`
163}
164
165/** One path segment against a pattern with `*` wildcards. A dotfile never matches a wildcard. */
166export function matchSegment(pattern: string, name: string): boolean {
167  if (!pattern.includes('*')) return pattern === name
168  if (name.startsWith('.') && !pattern.startsWith('.')) return false
169  const re = new RegExp(`^${pattern.split('*').map(p => p.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*')}$`)
170  return re.test(name)
171}
172
173/** A path's segments for glob expansion, on either separator; a leading '' is the POSIX root. */
174export function pathSegments(path: string): string[] {
175  return path.split(/[\\/]/)
176}
177
178/** A path with `\\` as `/`, `.` and `..` resolved, no trailing slash: for comparing, never for opening. */
179export function normalPath(path: string): string {
180  const out: string[] = []
181  const parts = path.replaceAll('\\', '/').split('/')
182  for (const [i, part] of parts.entries()) {
183    if (part === '.' || (part === '' && i > 0)) continue
184    if (part === '..' && out.length > 1) out.pop()
185    else if (part !== '..') out.push(part)
186  }
187  return out.length === 1 && out[0] === '' ? '/' : out.join('/')
188}
189
190/** A drive-letter or UNC path is Windows (for case folding); anything else is not. */
191export function isWindowsPath(path: string): boolean {
192  return /^([A-Za-z]:[\\/]|\\\\)/.test(path)
193}
194
195/** Whether `dir` is `home` or lies inside it. A Windows path folds case. Symlinks are not followed. */
196export function isInside(dir: string, home: string): boolean {
197  const fold = (p: string) => (isWindowsPath(p) ? p.toLowerCase() : p)
198  const d = fold(normalPath(dir))
199  const h = fold(normalPath(home))
200  return d === h || d.startsWith(h.endsWith('/') ? h : `${h}/`)
201}
202
203// ---------------------------------------------------------------------------------------------
204// Prompts.
205
206/** A prompt the human gave: typed, through Remote Control, or a Slack ping. */
207export function isHuman(origin: PromptOrigin): boolean {
208  return origin.kind === 'composer' || origin.kind === 'bridge' || origin.kind === 'slack-ping'
209}
210
211// ---------------------------------------------------------------------------------------------
212// Session sensors: idle and context marks. Information only, declared in memory-kit.conf;
213// absent means off.
214
215export const MINUTE = 60_000
216export const HOUR = 60 * MINUTE
217
218/** A conf number list that could not be used, said out loud (never silently off). */
219export type ConfNumbers = { declared: boolean; values: number[]; problem?: string }
220
221/** `watch_idle_minutes: 50` -- one value, whole minutes, at least 1. */
222export function confIdleMinutes(conf: string): ConfNumbers {
223  return confNumbers(conf, 'watch_idle_minutes', 1)
224}
225
226/** `watch_context_percent: 50, 60, 70, 80` -- percent marks of the window, 1-99, sorted. */
227export function confContextPercent(conf: string): ConfNumbers {
228  const read = confNumbers(conf, 'watch_context_percent', Infinity)
229  if (read.problem !== undefined) return read
230  if (read.values.some(v => v > 99)) return { declared: true, values: [], problem: 'marks are percent of the window: 1-99' }
231  return { ...read, values: [...read.values].sort((a, b) => a - b) }
232}
233
234function confNumbers(conf: string, key: string, max: number): ConfNumbers {
235  const raw = confValue(conf, key)
236  if (raw === undefined) return { declared: false, values: [] }
237  const parts = raw.split(',').map(p => p.trim())
238  const values = parts.map(p => (/^[0-9]+$/.test(p) ? Number(p) : NaN))
239  if (values.some(v => Number.isNaN(v) || v < 1)) return { declared: true, values: [], problem: `not a list of whole numbers >= 1: "${raw}"` }
240  if (values.length > max) return { declared: true, values: [], problem: `one value expected: "${raw}"` }
241  return { declared: true, values }
242}
243
244/**
245 * The marks newly crossed: declared, at or below the context's percent of the window, not yet
246 * reported this session. Several at once (a big jump) report together, highest last.
247 */
248export function marksCrossed(marks: readonly number[], reported: readonly number[], tokens: number | undefined, window: number): number[] {
249  if (tokens === undefined || window <= 0) return []
250  const pct = (tokens / window) * 100
251  return marks.filter(m => m <= pct && !reported.includes(m))
252}
253
254/** The context report: information only; the home's rules say what this level means. */
255export function contextReport(mark: number, tokens: number, window: number): string {
256  const pct = Math.round((tokens / window) * 100)
257  return [
258    `[watch] Context has reached ${pct}% of the window (${tokens.toLocaleString('en-US')} of ${window.toLocaleString('en-US')} tokens), past the ${mark}% mark declared in memory-kit.conf.`,
259    'This is information only; what to do at this level is this home\'s own rule.',
260  ].join(' ')
261}
262
263/**
264 * The idle report: information only. A declared watch_retire_step is quoted as the home's own
265 * declared step, decided by the home, not by the watch.
266 */
267export function idleReport(idleMinutes: number, retireStep: string | undefined): string {
268  const head = `[watch] This session has been idle for ${idleMinutes} min (the watch_idle_minutes mark in memory-kit.conf).`
269  const tail = retireStep === undefined ? 'This is information only; what idleness means here is this home\'s own rule.' : `The home's declared step for this (watch_retire_step): ${retireStep}.`
270  return `${head} ${tail}`
271}
272
hooks/kit-home.ts 83 lines
1// kit_home: a memory-kit.conf key naming the kit home (kit #18). Pure: the caller hands in how
2// to read a file.
3//
4// IDENTICAL in adapters/claude-code/{obligations-pane,watch,kit-update}/hooks/kit-home.ts: a
5// plugin loads only its own folder, so each carries a copy; lint/run-fixtures.sh fails when the
6// copies differ. The same rules for Python are lint/kit_home.py, with the same messages.
7//
8// If the start folder's memory-kit.conf says `kit_home: <path>` (absolute, or relative to that
9// conf's folder), the home is the folder it names: that home's conf, memory-kit/VERSION, memory
10// directory, update:/upstream: and watch_files count, and the pointer conf's other keys are
11// ignored (reported in `ignored`). One hop, never chained: the target's conf may not point on (a
12// loop is a chain too). `kit_home: .` is just the home. A target with no memory-kit.conf, and a
13// `~` path (not expanded), are problems said plainly, never a silent fall back.
14
15export const KIT_HOME_KEY = 'kit_home'
16
17export type KitHome = { home: string; pointer?: string; ignored: string[] } | { problem: string; pointer: string }
18
19/** POSIX (/...), a drive letter (C:\ or C:/) or UNC (\\server\share). */
20export function isAbsolute(path: string): boolean {
21  return /^(\/|[A-Za-z]:[\\/]|\\\\)/.test(path)
22}
23
24/** `.` and `..` resolved, `/` as the separator, no trailing slash; a root (/, C:/, //server/share) kept. */
25export function normalizePath(path: string): string {
26  const unc = /^\\\\|^\/\//.test(path)
27  const drive = /^[A-Za-z]:[\\/]/.exec(path)?.[0].slice(0, 2)
28  const root = unc ? '//' : drive !== undefined ? `${drive}/` : path.startsWith('/') ? '/' : ''
29  const parts: string[] = []
30  for (const part of path.slice(unc ? 2 : drive !== undefined ? 3 : root.length).split(/[\\/]+/)) {
31    if (part === '' || part === '.') continue
32    if (part === '..' && parts.length > 0 && parts[parts.length - 1] !== '..') parts.pop()
33    else if (part !== '..' || root === '') parts.push(part)
34  }
35  return `${root}${parts.join('/')}` || '.'
36}
37
38const sameAs = (a: string, b: string) => {
39  const [x, y] = [normalizePath(a), normalizePath(b)]
40  return /^[A-Za-z]:\//.test(x) || x.startsWith('//') ? x.toLowerCase() === y.toLowerCase() : x === y
41}
42
43/** A top-level `key: value` of memory-kit.conf, a trailing comment and quotes stripped; '' when absent. */
44export function confValueOf(conf: string, key: string): string {
45  for (const line of conf.split(/\r?\n/)) {
46    if (!line.startsWith(`${key}:`)) continue
47    const raw = line.slice(key.length + 1)
48    const hash = raw.search(/\s#/)
49    return (hash === -1 ? raw : raw.slice(0, hash)).trim().replace(/^["']|["']$/g, '')
50  }
51  return ''
52}
53
54/** What a reader says when the folder it took for the home has no memory-kit.conf (kit #31): silence
55 *  there looked the same as "nothing newer". lint/kit_home.py no_conf_notice says it alike. */
56export function noConfNotice(folder: string): string {
57  return `${normalizePath(folder)} has no memory-kit.conf, so it is not a kit home; if the home is elsewhere, put kit_home: <path> in a memory-kit.conf here`
58}
59
60const topKeys = (conf: string) => conf.split(/\r?\n/).flatMap(line => /^([A-Za-z_][\w-]*):/.exec(line)?.[1] ?? [])
61
62/** The home for a start folder, by its memory-kit.conf's kit_home (see the head of this file). */
63export async function resolveKitHome(start: string, read: (path: string) => Promise<string | undefined>): Promise<KitHome> {
64  const from = normalizePath(start)
65  const pointer = `${from}/memory-kit.conf`
66  const conf = await read(pointer)
67  const raw = conf === undefined ? '' : confValueOf(conf, KIT_HOME_KEY)
68  if (raw === '') return { home: from, ignored: [] }
69  if (raw.startsWith('~'))
70    return { pointer, problem: `${pointer}: kit_home: ${raw} -- write it absolute or relative to this conf's folder (~ is not expanded)` }
71  const target = normalizePath(isAbsolute(raw) ? raw : `${from}/${raw}`)
72  if (sameAs(target, from)) return { home: from, ignored: [] }
73  const targetConf = await read(`${target}/memory-kit.conf`)
74  if (targetConf === undefined) return { pointer, problem: `${pointer}: kit_home: ${raw} -> ${target}: no memory-kit.conf there, so it is not a kit home` }
75  const onward = confValueOf(targetConf, KIT_HOME_KEY)
76  if (onward !== '' && !sameAs(isAbsolute(onward) ? onward : `${target}/${onward}`, target))
77    return {
78      pointer,
79      problem: `${pointer}: kit_home: ${raw} -> ${target}, whose own conf points on (kit_home: ${onward}); a pointer is followed one hop, never chained`,
80    }
81  return { home: target, pointer, ignored: topKeys(conf ?? '').filter(k => k !== KIT_HOME_KEY) }
82}
83
types/index.d.ts 15 lines
1// The watch mod's $.state contract. Self-contained: no imports.
2
3declare module 'claude-code' {
4  interface PluginState {
5    watch: {
6      /** The session's working directory at its first session.start: the default kit home. */
7      startCwd: string
8      /** Each watched file's last-seen size, by expanded path: growth past it rings the doorbell. */
9      bellSizes: Record<string, number>
10      /** Context marks already reported this session: once per mark, reloads included. */
11      reportedMarks: number[]
12    }
13  }
14}
15