Side pane showing the memory-kit obligations tier (memory/obligations.yaml), soonest expiry first; read-only

A Claude Code side pane (a function-hooks mod) that shows a memory-kit home's obligations tier: memory/obligations.yaml, soonest expiry first. Built against Claude Code 2.1.296's plugin API (early access; the API moves between releases).
N expired · M due ≤7d, then the scroll position i/n. For example memory-kit 0.1.0-alpha.5 0 expired · 1 due ≤7d 1/6 (since 0.3.0, kit #4; it showed the date and time before).<home>/memory-kit/VERSION, the home's pinned kit, not the pane's own version. The home is the folder that holds the memory directory (for a memoryDir session that runs outside it), else <cwd>: the first of the two with a VERSION. Without one the slot is empty: never a guess.memory-kit 0.1.0-alpha.5 · alpha.6 out. A newer pre-release of the same version shows its pre-release part alone; a different major, minor or patch shows the whole version, as · 0.2.0 out (kit #7, see Release check).expires is before today.expires falls between today and today + 7 days, both ends included. Today is the local date, which the header no longer shows.expires (expired first), ties by since, then file order.…), so the task text starts on the first line.urls/<name> in the text becomes a dark-blue underlined link (OSC 8 in the terminal; Ghostty clicks it). Links cited only in check or ref trail the text.urls/<name> with no file, or with an unusable one, is drawn struck through and grey.memory/urls/veo-214, and cite urls/veo-214. The pane recognises no ticket patterns on purpose; see the kit lesson link-a-ticket-with-a-url-file (#6).owed_to (kit #29) reads actor -> owed_to: text, as tools/obligations-due.py prints it; the party owed is drawn as an actor is (bold, its own colour, 20 columns). A row without one reads actor text, as before.check and since are not shown, to keep rows light.─ rule across the pane, no frame): one line for the session's main agent: the name in 10 columns (longer names get 9 chars + …), a colour-coded model letter, and the context weight such as 127k/1M. The letters are F = Fable, O = Opus, S = Sonnet, H = Haiku, and a dim · when the model is unknown. One more line per live subagent or teammate follows, in the same name and model columns, then its context load (dimmed unless running). The load is the latest model step's input, cache read, cache write and output tokens added together. It reads 84k/1M when the agent runs on the main loop's model, whose window the session reports, and 84k alone otherwise: windows are never guessed. An agent with no step seen yet shows a dim · and no load.e.props.bodyColumns). The footer subtracts its padding (one column each side) and shows used/window only when the whole line fits, otherwise used alone. Its lines wrap rather than truncate, so at extreme widths a figure moves to the next row instead of getting an ellipsis.k and j hotkeys step one while the pane has the keyboard. They live on two Buttons inside a Box that is not displayed.no obligations file here (<path>). An empty list says no obligations. A file the parser rejects says obligations.yaml unreadable: line N: <why>.Width: the harness decides. Unasked, the pane opens docked only on a wide terminal (144 columns, 110 once asked), otherwise it waits. /obligations opens it at any width.
<memory dir>/obligations.yaml. The memory directory is, in order:kit_home pointer in <cwd>/memory-kit.conf: <home>/<memory_dir> of the home it names (since kit #18; see "A session outside its home"). It wins over the option.memoryDir option, when set. A relative path resolves against <cwd>. Absolute is POSIX (/...), a drive letter (C:\... or C:/...) or UNC (\\server\share), since 0.3.0 (kit #8); the same holds for memory_dir. For a lead session that runs outside its home (a team folder, a sibling repo).<cwd>/<memory_dir>, with memory_dir read from <cwd>/memory-kit.conf.<cwd>/memory.<cwd> is the directory the session started in, not where it is now (since 0.2.1, kit issue #3). A shell cd into another repo mid-session does not switch the ledger. The start directory is kept in $.state, so a mod reload (which fires session.start again, with the directory the session is in by then) keeps it too. Only when no start was seen does the pane fall back to $.session.cwd().
Left empty, one install therefore serves every kit home. The value is stored in settings under pluginConfigs; set in the user's settings, it points every session on the machine at that one home. For a session that starts outside its home, use a kit_home pointer conf in its start folder instead (kit #18): the lint and the tools follow it too, so it no longer makes that folder look like a home.
memory-kit/types/memory-obligations.ts, a list of {obligation, actor, since, check, expires, ref}.memory/urls/<name> holds one URL on line 1 (memory-kit/types/memory-urls.ts). The pane reads these files and never writes them.hooks/obligations.ts. The mod environment has no bundler and no node_modules, so a YAML library is not an option.obligations: or obligations: [], and list items of flat key: value maps with double-quoted, single-quoted or plain scalars.obligations:, as yaml.safe_dump writes them, or be indented (since 0.3.0, kit #5). One file uses one indent throughout.''). Double quotes take YAML's escapes, \x, \u, \U and an escaped line end included. A plain null or ~ is empty.yaml.safe_dump(data, sort_keys=False) writes, with or without allow_unicode, parses to what PyYAML reads back. The test fixture is that real output (tests/fixtures/make-safe-dump.py).since or expires, or a missing obligation, actor or expires.types/memory-obligations.ts is in PROPOSED-types-doc.md, for upstream to apply.Since 0.3.0 (kit #7), the pane notices a newer kit release by itself.
$.http.fetch: https://codeberg.org/api/v1/repos/<owner>/<repo>/tags?limit=50. That is the newest 50 tags, newest first.mitselek/memory-kit unless the home's memory-kit.conf (the home whose VERSION the header shows) has an upstream: key. Its value is owner/repo on Codeberg, e.g. upstream: someone/memory-kit. Any other value, a full URL included, makes no request and writes one debug line.v<major>.<minor>.<patch> with an optional -alpha.N, -beta.N or -rc.N. They compare by semver precedence (alpha.10 > alpha.9, alpha < beta < rc < the release). Other tags are ignored.$.store under upstream (url -> { checkedAt, latest }). The store is the plugin's own and shared by every session, so ten open sessions make one request.$.http.fetch has no timeout of its own, so a request not answered in 10 s (on $.clock.after) is a failed check.debug log line and never draws an error. A failure counts as a check, so the next try comes 6 h later.checkUpdates: false makes no request at all and shows no note.UPGRADE.md, run by the home's assistant.kit_home (kit #18)User-scope options apply to every session on the machine. That covers memoryDir set in the user's settings, not only the home's own sessions. On the pepys desk a user-wide home made every session on the box drain the desk's spool, and six notifications were lost. 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
memory-kit.conf counts (memory_dir, upstream:), with paths relative to that home, and so does its memory-kit/VERSION. Other keys left in the pointer conf are ignored.memoryDir option. Then the option, then the start folder itself.kit_home: . is just the home.C:\ or C:/) or UNC. ~ is not expanded: write the path absolute or relative to the conf.memory-kit/VERSION shows memory-kit ? in the header and asks upstream nothing.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.The pane never writes: not obligations, not urls/. Mihkel ruled on 2026-10-14 that it stays read-only for good. Resolving or advancing an obligation happens in the file, by whoever owns it.
From the kit (any home that vendors memory-kit at memory-kit/), in a terminal at the home's root:
claude plugin marketplace add ./memory-kit/adapters/claude-code
claude plugin install obligations-pane@memory-kit
A kit upgrade (subtree pull) does not update the installed plugin: the harness keeps its own copy from the install (kit #23). After a pull that changed it, claude plugin marketplace update memory-kit, claude plugin update obligations-pane@memory-kit, then /reload-plugins in a running session; tools/kit-update.py runs the first two itself when claude is on PATH (#37).
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.
Optional: /plugin configure obligations-pane@memory-kit to set the footer's agent name. /obligations opens the pane at any width.
Or for one session only: claude --plugin-dir ./memory-kit/adapters/claude-code/obligations-pane.
Options (userConfig):
agentName is the footer name of the main agent. Left empty, the footer uses the working directory's name.memoryDir points the pane at a home's memory directory (see Data source). Set user-wide, it applies to every session on the machine: prefer a kit_home pointer conf, which also wins over this option.autoOpen (default on) opens the pane at session start, but only where an obligations file exists.checkUpdates (default on) runs the release check (see Release check). Off, the pane makes no network request at all.One key in the home's memory-kit.conf is read besides memory_dir: upstream: owner/repo (see Release check). The lint ignores keys it does not know.
claude plugin validate .
claude plugin test . # tests/*.test.ts; no test reaches the network
python3 tests/fixtures/make-safe-dump.py # regenerates the yaml.safe_dump fixture (PyYAML)
tsc -p . # once Claude Code has laid .claude-plugin/types/ (gitignored)
tsc -p . needs the type declarations, which Claude Code writes into .claude-plugin/types/ when it loads the mod from a watched folder. Before that, type-check with a tsconfig kept outside the folder whose include names the claude-code.d.ts that the plugin-authoring skill writes, plus hooks, types and tests.
For live testing in a session, link a working copy into the session's dev-mods folder. Claude Code then asks once whether to enable hot reloading for that session:
ln -s <working copy>/obligations-pane ~/.claude/dev-mods/<session-id>/obligations-pane
The pane over the workshop's real memory/obligations.yaml at 56 columns on 2026-10-14 01:14 EEST, with one teammate running; the header line is redrawn in the 0.3.0 format. The capture is a text approximation of the drawn tree. Colours are not shown: the background is pastel yellow, the header is dark, Mihkel is pink, the Prusa actor gets a palette colour, and the link is dark blue.
memory-kit 0.1.0-alpha.5 0 expired · 1 due ≤7d 1/6
2026-10-21 Mihkel Start print M3T06 on keldri (on the
printer since 2026-10-13 07:28, not started).
2026-10-28 Mihkel Inspection of M3T02 (queue entry 10:
2-strand outer skin above z 3.0, floor centre links).
2026-10-28 Mihkel M3T04 findings still open (entry
13): layer 1 and stepped top, floor joins, strength by
hand.
2026-10-28 Mihkel Findings for M3T06: columns, slip
seam, kink blobs, inner-skin fan (queue entry 15
checklist).
2026-10-28 Mihkel Rule GATE 3 of the memory-kit
adoption (deletions) once a cold start has run on the
new tree.
2026-12-31 Prusa (prusa3d/Prus… Explain why the INDX
start gcode retracts an extra 0.04 mm (asked on their
settings repo). prusa-start-ooze-issue
────────────────────────────────────────────────────────
workshop O 127k/1M
obligatio… O
.d.ts)In short: $.agent.list() has no model and excludes the main loop, and Link has no colour prop.
$.agent.list() lists subagents and teammates only, not the main loop, and its rows carry no model.$.session.model().turn.step usage reports, falling back to the model its agent.spawn resolved. Agents spawned before the mod loaded show a dim · until their next step.$.session.usage() with no arguments is free and gives context.tokens and context.window for the main loop only.turn.step: a streaming event, so the hook is an async generator. It does yield* next(e), records result.usage under e.agentId (absent on the main loop), and returns the result unchanged.claude plugin test, $.turn.step(...)'s .result came back undefined. The test reads the generator's own return value instead.Link has no colour prop. The dark blue comes from a Text child inside the Link, since a Link's children may be inline elements.Pane reads e.props.scroll.bodyRows for its height. The engine scrolls a focused pane's body with the arrow keys.ui.scroll (matcher { component: 'Pane', requestId }) fires before the window moves, for the wheel and the scroll keys. The hook steps the index by sign(e.by) and answers {} without next(e), so the engine's window stays and the header and footer stay fixed. The $.state write redraws the pane, so no $.ui.invalidate is needed.$ must be declared at the module's top level. claude plugin validate refuses $ passed to a closure.hooks/register.tsx 538 lines1// Obligations pane: shows memory/obligations.yaml of the session's working directory in a side
2// pane, soonest expiry first. Read-only for good (Mihkel, 2026-10-14): it never writes.
3
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, Register } from 'claude-code'
6
7import type { ObligationRow, PaneAgent, PaneSnapshot } from '../types'
8import { resolveKitHome } from './kit-home'
9import {
10 actorColor,
11 basename,
12 cachedCheck,
13 clampIndex,
14 confValue,
15 contextWeight,
16 FETCH_TIMEOUT_MS,
17 countsLabel,
18 countsOf,
19 DATE_FG,
20 EXPIRED_DATE_BG,
21 fitActor,
22 fitName,
23 fitWeight,
24 FOOTER_CHROME,
25 RULE_COLOR,
26 HEADER_BG,
27 HEADER_FG,
28 isAbsolutePath,
29 isFresh,
30 isoDate,
31 kitLabel,
32 kitVersionOf,
33 LINK_FG,
34 liveAgents,
35 MODEL_COLORS,
36 memoryDirOf,
37 modelLetter,
38 newestRelease,
39 offsetFromZ,
40 PANE_BG,
41 parentDir,
42 parseObligations,
43 recordStep,
44 segmentsOf,
45 sortByExpires,
46 statusOf,
47 step,
48 subagentWeight,
49 tagNamesOf,
50 trailingLinksOf,
51 updateNote,
52 upstreamTagsUrl,
53 urlNamesOf,
54 urlOfFile,
55 wheelStep,
56} from './obligations'
57import type { Segment } from './obligations'
58
59export const PANE = 'obligations'
60export const TITLE = 'Obligations'
61export const REFRESH_MS = 20_000
62
63const snapshot = atom({ plugin: 'obligations-pane', key: 'snapshot' } as const, null)
64const index = atom({ plugin: 'obligations-pane', key: 'index' } as const, 0)
65const models = atom({ plugin: 'obligations-pane', key: 'models' } as const, {})
66const loads = atom({ plugin: 'obligations-pane', key: 'loads' } as const, {})
67const mainModel = atom({ plugin: 'obligations-pane', key: 'mainModel' } as const, '')
68const startCwd = atom({ plugin: 'obligations-pane', key: 'startCwd' } as const, '')
69
70/** The pane's userConfig, trimmed. */
71export type Settings = { agentName: string; memoryDir: string; checkUpdates: boolean }
72
73/** A file's text, '' when it is missing or unreadable. */
74async function readOrEmpty($: EngineInterface, path: string): Promise<string> {
75 try {
76 return (await $.fs.exists(path)) ? await $.fs.read(path) : ''
77 } catch {
78 return ''
79 }
80}
81
82/**
83 * The memory directory: the memoryDir option when set, else <cwd>/<memory_dir> from
84 * <cwd>/memory-kit.conf, else <cwd>/memory. A relative path resolves against cwd; a Windows
85 * absolute one (C:\, C:/, UNC) does not (kit #8).
86 */
87function memoryRoot(cwd: string, conf: string, memoryDir: string): string {
88 const dir = memoryDir !== '' ? memoryDir : (memoryDirOf(conf) ?? 'memory')
89 return isAbsolutePath(dir) ? dir : `${cwd}/${dir}`
90}
91
92/** The $.store key holding every upstream's last check (url -> UpstreamCheck), shared across sessions. */
93const STORE_KEY = 'upstream'
94
95let zoneOffset: number | undefined
96
97/** Minutes east of UTC. The environment's own zone first; the host's `date +%z` when it says UTC. */
98async function offsetOf($: EngineInterface, now: number): Promise<number> {
99 const own = -new Date(now).getTimezoneOffset()
100 if (own !== 0) return own
101 if (zoneOffset !== undefined) return zoneOffset
102 try {
103 const { exitCode, stdout } = await $.process.run(['date', '+%z'], { timeoutMs: 2_000 })
104 zoneOffset = exitCode === 0 ? (offsetFromZ(stdout) ?? 0) : 0
105 } catch {
106 zoneOffset = 0
107 }
108 return zoneOffset
109}
110
111async function readObligations(
112 $: EngineInterface,
113 path: string,
114): Promise<Pick<PaneSnapshot, 'source' | 'error' | 'items'>> {
115 if (!(await $.fs.exists(path))) return { source: 'missing', error: '', items: [] }
116 let text: string
117 try {
118 text = await $.fs.read(path)
119 } catch (err) {
120 return { source: 'error', error: `cannot read: ${String(err)}`, items: [] }
121 }
122 const parsed = parseObligations(text)
123 if (parsed.kind === 'error') return { source: 'error', error: `line ${parsed.line}: ${parsed.message}`, items: [] }
124 return { source: 'ok', error: '', items: sortByExpires(parsed.items) }
125}
126
127async function resolveUrls($: EngineInterface, root: string, items: readonly ObligationRow[]) {
128 const urls: Record<string, string> = {}
129 const names = [...new Set(items.flatMap(urlNamesOf))].slice(0, 100)
130 for (const name of names) {
131 try {
132 const url = urlOfFile(await $.fs.read(`${root}/urls/${name}`))
133 if (url !== undefined) urls[name] = url
134 } catch {
135 // Missing urls/<name>: drawn as a dead reference. The pane never writes urls/.
136 }
137 }
138 return urls
139}
140
141let badUpstreamLogged = false
142
143/**
144 * The release check's inputs (kit #4, #7): the home's memory-kit/VERSION, the tags URL to ask
145 * ('' when no check is made: checkUpdates off, no VERSION, or an unusable `upstream:`), and
146 * the newest release the shared cache knows for it. No request here.
147 *
148 * The home is the one that holds the memory directory (a memoryDir session runs outside it),
149 * else the cwd: the first with a memory-kit/VERSION. Its memory-kit.conf names `upstream:`.
150 */
151async function kitOf($: EngineInterface, cwd: string, root: string, checkUpdates: boolean, pointed?: string) {
152 let home = ''
153 let kitVersion = ''
154 // A pointed home (kit_home) is the only candidate; without its VERSION the slot says "?" (the
155 // pointer promised a kit home) and nothing is asked upstream.
156 if (pointed !== undefined && kitVersionOf(await readOrEmpty($, `${pointed}/memory-kit/VERSION`)) === undefined)
157 return { kitVersion: '?', upstreamUrl: '', latest: '' }
158 for (const candidate of pointed !== undefined ? [pointed] : [...new Set([parentDir(root), cwd])]) {
159 kitVersion = kitVersionOf(await readOrEmpty($, `${candidate}/memory-kit/VERSION`)) ?? ''
160 if (kitVersion !== '') {
161 home = candidate
162 break
163 }
164 }
165 if (!checkUpdates || kitVersion === '') return { kitVersion, upstreamUrl: '', latest: '' }
166 const upstream = confValue(await readOrEmpty($, `${home}/memory-kit.conf`), 'upstream')
167 const url = upstreamTagsUrl(upstream)
168 if (url === undefined) {
169 if (!badUpstreamLogged) $.ui.log(`obligations-pane: upstream check skipped: unusable upstream "${upstream}"`, { to: 'debug' })
170 badUpstreamLogged = true
171 return { kitVersion, upstreamUrl: '', latest: '' }
172 }
173 let latest = ''
174 try {
175 latest = cachedCheck(await $.store.get(STORE_KEY), url)?.latest ?? ''
176 } catch {
177 // No store: the check below fills it.
178 }
179 return { kitVersion, upstreamUrl: url, latest }
180}
181
182/** Gathers everything the pane draws and writes it to state in one go. No network. */
183export async function refresh($: EngineInterface, settings: Settings): Promise<void> {
184 const { agentName, memoryDir, checkUpdates } = settings
185 const now = await $.clock.now()
186 // The home is where the session started (kit issue #3): a shell cd into another repo
187 // mid-session must not switch the ledger. $.session.cwd() only when no start was seen.
188 const cwd = (await read($, startCwd)) || (await $.session.cwd())
189 // kit #18: the start folder's memory-kit.conf may point at the home (kit_home:, one hop). The
190 // pointer wins over the user-scope memoryDir option, which applies to every session.
191 const found = await resolveKitHome(cwd, async p => ((await $.fs.exists(p)) ? $.fs.read(p) : undefined))
192 const pointed = !('problem' in found) && found.pointer !== undefined ? found.home : undefined
193 const home = pointed ?? cwd
194 const conf = await readOrEmpty($, `${home}/memory-kit.conf`)
195 const root = memoryRoot(home, conf, pointed !== undefined ? '' : memoryDir)
196 const path = 'problem' in found ? found.pointer : `${root}/obligations.yaml`
197 const ledger: Pick<PaneSnapshot, 'source' | 'error' | 'items'> =
198 'problem' in found ? { source: 'pointer', error: found.problem, items: [] } : await readObligations($, path)
199 const urls = await resolveUrls($, root, ledger.items)
200
201 let tokens: number | null = null
202 let window: number | null = null
203 try {
204 const { context } = await $.session.usage()
205 tokens = context.tokens ?? null
206 window = context.window
207 } catch {
208 // No usage yet (no session bound): the footer shows "-".
209 }
210 let model = ''
211 try {
212 model = await $.session.model()
213 } catch {
214 // Unknown model draws "?".
215 }
216 const known = await read($, models)
217 let agents: PaneAgent[] = []
218 try {
219 agents = (await $.agent.list()).map(a => ({
220 id: a.id,
221 name: a.name ?? a.teammateId?.split('@')[0] ?? a.description ?? a.type,
222 model: known[a.id] ?? '',
223 status: a.status,
224 }))
225 } catch {
226 // No agent list: the footer shows the main agent alone.
227 }
228
229 const value: PaneSnapshot = {
230 path,
231 ...ledger,
232 urls,
233 ...('problem' in found ? { kitVersion: '', upstreamUrl: '', latest: '' } : await kitOf($, cwd, root, checkUpdates, pointed)),
234 now,
235 offsetMinutes: await offsetOf($, now),
236 self: { name: agentName || basename(cwd), model, tokens, window },
237 agents: liveAgents(agents),
238 }
239 await $.state.set({ plugin: 'obligations-pane', key: 'snapshot' }, value)
240}
241
242let lastWheelStep: number | undefined
243
244/** One wheel event: at most one item, and nothing within the throttle of the last step. */
245async function wheel($: EngineInterface, by: number): Promise<void> {
246 const { delta, lastStep } = wheelStep(by, await $.clock.now(), lastWheelStep)
247 lastWheelStep = lastStep
248 if (delta === 0) return
249 const snap = await read($, snapshot)
250 const length = snap?.items.length ?? 0
251 await update($, index, i => step(i, delta, length))
252}
253
254/**
255 * Asks upstream for its tags when the shared cache is older than 6 h, and records the newest
256 * release in $.store and the snapshot. Only reads: it never pulls. A failure is one debug line
257 * and keeps the last known result; it counts as a check, so the next try is 6 h later.
258 */
259async function checkUpstream($: EngineInterface): Promise<void> {
260 const url = (await read($, snapshot))?.upstreamUrl ?? ''
261 if (url === '') return
262 const now = await $.clock.now()
263 const stored = await $.store.get(STORE_KEY)
264 const cached = cachedCheck(stored, url)
265 if (cached !== undefined && isFresh(cached.checkedAt, now)) return
266
267 let latest = cached?.latest ?? ''
268 let timeout: { cancel: () => void } | undefined
269 try {
270 // A request that never settles would hold `checking` for good: it fails at 10 s instead.
271 const expired = new Promise<never>((_, reject) => {
272 timeout = $.clock.after(FETCH_TIMEOUT_MS, () => reject(new Error(`timed out after ${FETCH_TIMEOUT_MS / 1000} s`)))
273 })
274 const answer = await Promise.race([$.http.fetch(url, { headers: { accept: 'application/json' } }), expired])
275 const names = answer.ok ? tagNamesOf(answer.text) : undefined
276 if (names === undefined) throw new Error(answer.ok ? 'not a tags list' : `HTTP ${answer.status}`)
277 latest = newestRelease(names) ?? ''
278 } catch (err) {
279 $.ui.log(`obligations-pane: upstream check failed (${url}): ${String(err)}`, { to: 'debug' })
280 } finally {
281 timeout?.cancel()
282 }
283 const all = typeof stored === 'object' && stored !== null ? (stored as Record<string, unknown>) : {}
284 await $.store.set(STORE_KEY, { ...all, [url]: { checkedAt: now, latest } })
285 await update($, snapshot, s => (s === null || s.upstreamUrl !== url ? s : { ...s, latest }))
286}
287
288/** refresh, never throwing: a failure goes to the debug log and the last snapshot stays. */
289async function safeRefresh($: EngineInterface, settings: Settings): Promise<void> {
290 try {
291 await refresh($, settings)
292 } catch (err) {
293 $.ui.log(`obligations-pane: refresh failed: ${String(err)}`, { to: 'debug' })
294 }
295}
296
297let checking: Promise<void> | undefined
298
299/** checkUpstream, one at a time and never throwing. */
300async function safeCheck($: EngineInterface): Promise<void> {
301 if (checking !== undefined) return checking
302 checking = checkUpstream($).catch(err => $.ui.log(`obligations-pane: upstream check failed: ${String(err)}`, { to: 'debug' }))
303 try {
304 await checking
305 } finally {
306 checking = undefined
307 }
308}
309
310/** The local refresh, then the release check when one is due, not awaited: no hook waits on the network. */
311async function refreshAll($: EngineInterface, settings: Settings): Promise<void> {
312 await safeRefresh($, settings)
313 void safeCheck($)
314}
315
316export const register: Register = (on, options) => {
317 const settings: Settings = {
318 agentName: typeof options.agentName === 'string' ? options.agentName.trim() : '',
319 memoryDir: typeof options.memoryDir === 'string' ? options.memoryDir.trim() : '',
320 checkUpdates: options.checkUpdates !== false,
321 }
322 const autoOpen = options.autoOpen !== false
323 let timer: { cancel: () => void } | undefined
324
325 on('session.start', async ($, e, next) => {
326 const started = await next(e)
327 // Kept in $.state, which outlives a reload: a reload fires session.start again with the
328 // directory the session is in by then, so only the first start sets it.
329 if ((await read($, startCwd)) === '') await $.state.set({ plugin: 'obligations-pane', key: 'startCwd' }, e.cwd)
330 await $.command.register({ name: 'obligations', description: 'Open the obligations pane (memory/obligations.yaml)' })
331 await safeRefresh($, settings)
332 timer?.cancel()
333 timer = $.clock.every(REFRESH_MS, () => void refreshAll($, settings))
334 const snap = await read($, snapshot)
335 if (autoOpen && snap?.source !== 'missing') void $.ui.open({ id: PANE, title: TITLE })
336 // After the pane is up, and not awaited: the release check may wait on the network.
337 void safeCheck($)
338 return started
339 })
340
341 on('command.run', { command: 'obligations' }, async $ => {
342 await safeRefresh($, settings)
343 const opened = await $.ui.open({ id: PANE, title: TITLE })
344 return { text: opened.isPlaced ? 'Obligations pane opened.' : 'Obligations pane waits for a wider terminal.' }
345 })
346
347 on('turn.complete', async ($, e, next) => {
348 const done = await next(e)
349 await refreshAll($, settings)
350 return done
351 })
352
353 // agent.list() carries no model: remember what each spawn resolved to. An observer: if it
354 // fails, the spawn goes on (next(e) replays a call that already settled, runs it otherwise).
355 // Context load per subagent: the latest step's usage. Observe only: every chunk passes
356 // through, the result is returned unchanged, and a failure here never touches the step.
357 on('turn.step', async function* ($, e, next) {
358 const result = yield* next(e)
359 try {
360 const usage = result.usage
361 if (usage !== null) {
362 if (e.agentId === undefined) await $.state.set({ plugin: 'obligations-pane', key: 'mainModel' }, usage.model)
363 else await update($, loads, l => recordStep(l, e.agentId, usage))
364 }
365 } catch {
366 // A lost load figure is not worth a failed hook.
367 }
368 return result
369 })
370
371 on('agent.spawn', async ($, e, next) => {
372 const result = await next(e)
373 const id = result.agentId
374 if (result.deny === undefined && id !== undefined) {
375 await update($, models, m => ({ ...m, [id]: result.model }))
376 await safeRefresh($, settings)
377 }
378 return result
379 }).catch(($, e, next) => next(e))
380
381 // Mouse wheel (and scroll keys) over the pane: strictly one obligation per event, by e.by's
382 // sign, then 150 ms of ignored events (a trackpad burst is one item); clamped to the list.
383 // No next(e): the engine's window stays, so the header and footer stay fixed; the index
384 // write redraws the pane.
385 on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e) => {
386 await wheel($, e.by)
387 return {}
388 }).catch(() => ({}))
389
390 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
391 const { Box, Text, Button, Link } = $.ui.resolve(e)
392 const snap = await read($, snapshot)
393 const at = await read($, index)
394 const agentLoads = await read($, loads)
395 const mainId = await read($, mainModel)
396 const rows = e.props.scroll.bodyRows
397
398 if (snap === null) {
399 return (
400 <Box backgroundColor={PANE_BG} flexDirection="column" height={rows}>
401 <Text color={DATE_FG}>loading obligations…</Text>
402 </Box>
403 )
404 }
405
406 const today = isoDate(snap.now, snap.offsetMinutes)
407 const items = snap.items
408 const first = clampIndex(at, items.length)
409 const move = (delta: number) => () => update($, index, i => step(i, delta, items.length))
410
411 const header = (
412 <Box key="header" backgroundColor={HEADER_BG} flexDirection="row" justifyContent="space-between" paddingX={1} flexShrink={0}>
413 <Text color={HEADER_FG} bold wrap="truncate-end">
414 {/* `?? ''`: a snapshot a 0.2.x module wrote before a hot reload has neither field. */}
415 {[kitLabel(snap.kitVersion ?? '', updateNote(snap.kitVersion ?? '', snap.latest ?? '')), snap.source === 'ok' ? countsLabel(countsOf(items, today)) : '']
416 .filter(part => part !== '')
417 .join(' ')}
418 </Text>
419 {items.length > 1 && (
420 <Box flexDirection="row" flexShrink={0}>
421 <Text color={HEADER_FG}>
422 {first + 1}/{items.length}
423 </Text>
424 {/* Not drawn: they only carry the k / j hotkeys. The wheel scrolls (ui.scroll). */}
425 <Box display="none">
426 <Button key="up" plain hotkey="k" onPress={move(-1)}>
427 ▲
428 </Button>
429 <Button key="down" plain hotkey="j" onPress={move(1)}>
430 ▼
431 </Button>
432 </Box>
433 </Box>
434 )}
435 </Box>
436 )
437
438 const segment = (s: Segment, i: number) =>
439 s.kind === 'text' ? (
440 s.text
441 ) : s.kind === 'link' ? (
442 <Link key={`l${i}`} href={s.href}>
443 <Text color={LINK_FG} underline>
444 {s.name}
445 </Text>
446 </Link>
447 ) : (
448 <Text key={`d${i}`} color="#666666" strikethrough>
449 urls/{s.name}
450 </Text>
451 )
452
453 const row = (item: ObligationRow) => {
454 const status = statusOf(item, today)
455 const trailing = trailingLinksOf(item, snap.urls)
456 return (
457 <Box key={`row-${item.line}`} flexDirection="column" flexShrink={0} paddingX={1}>
458 <Text color={DATE_FG}>
459 <Text bold color={DATE_FG} {...(status === 'expired' ? { backgroundColor: EXPIRED_DATE_BG } : {})}>
460 {item.expires}
461 </Text>{' '}
462 <Text bold color={actorColor(item.actor)}>
463 {fitActor(item.actor)}
464 </Text>
465 {/* kit #29: 'actor -> owed_to:', as tools/obligations-due.py prints it. An array, not a
466 fragment: the engine refused a fragment here (Box inside an inline element). */}
467 {item.owed_to !== ''
468 ? [
469 ' -> ',
470 <Text key="owed" bold color={actorColor(item.owed_to)}>
471 {fitActor(item.owed_to)}
472 </Text>,
473 ':',
474 ]
475 : null}{' '}
476 {segmentsOf(item.obligation, snap.urls).map(segment)}
477 {trailing.map((s, i) => [' ', segment(s, 100 + i)])}
478 </Text>
479 </Box>
480 )
481 }
482
483 const body =
484 snap.source === 'missing' ? (
485 <Text color={DATE_FG}>no obligations file here ({snap.path})</Text>
486 ) : snap.source === 'pointer' ? (
487 <Text color="#9e0000">kit_home pointer broken: {snap.error}</Text>
488 ) : snap.source === 'error' ? (
489 <Text color="#9e0000">obligations.yaml unreadable: {snap.error}</Text>
490 ) : items.length === 0 ? (
491 <Text color={DATE_FG}>no obligations</Text>
492 ) : (
493 items.slice(first).map(row)
494 )
495
496 const agentLine = (key: string, name: string, model: string, tail: string, dim: boolean) => {
497 const letter = modelLetter(model)
498 return (
499 // wrap, never truncate: the figure is the point of the line (fitWeight keeps it short).
500 <Text key={key} color={DATE_FG} dimColor={dim} wrap="wrap">
501 {fitName(name)}{' '}
502 <Text bold color={MODEL_COLORS[letter]} dimColor={letter === '·'}>
503 {letter}
504 </Text>
505 {tail !== '' ? ` ${fitWeight(tail, e.props.bodyColumns - FOOTER_CHROME)}` : ''}
506 </Text>
507 )
508 }
509
510 const footer = (
511 <Box key="footer" flexDirection="column" flexShrink={0}>
512 <Text key="rule" color={RULE_COLOR} wrap="truncate-end">
513 {'─'.repeat(Math.max(0, e.props.bodyColumns))}
514 </Text>
515 <Box key="lines" flexDirection="column" paddingX={1}>
516 {agentLine('self', snap.self.name, snap.self.model, contextWeight(snap.self.tokens ?? undefined, snap.self.window ?? undefined), false)}
517 {snap.agents.map(a => {
518 const load = agentLoads[a.id]
519 const model = load?.model ?? a.model
520 const weight = subagentWeight(load, mainId !== '' ? mainId : snap.self.model, snap.self.window)
521 return agentLine(`agent-${a.id}`, a.name, model, weight, a.status !== 'running')
522 })}
523 </Box>
524 </Box>
525 )
526
527 return (
528 <Box backgroundColor={PANE_BG} flexDirection="column" height={rows}>
529 {header}
530 <Box key="body" flexDirection="column" flexGrow={1} flexShrink={1} overflow="hidden" gap={1} paddingTop={1}>
531 {body}
532 </Box>
533 {footer}
534 </Box>
535 )
536 })
537}
538hooks/kit-home.ts 83 lines1// 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}
83hooks/obligations.ts 772 lines1// Pure logic of the obligations pane: no `$`, no I/O. Everything here is unit-tested.
2//
3// Shape of the data: memory-kit/types/memory-obligations.ts (obligations: [{obligation, actor,
4// owed_to, since, check, expires, ref}]). URL tier: memory-kit/types/memory-urls.ts
5// (memory/urls/<name>, one URL on line 1).
6
7import type { AgentLoad, ObligationRow, PaneAgent } from '../types'
8
9// ---------------------------------------------------------------------------------------------
10// Parsing: a small strict parser for exactly the obligations shape. The mod environment has no
11// node_modules and no bundler, so no YAML dependency. Anything outside the shape is an error
12// with a line number, never a silent guess.
13
14export type ParseResult =
15 | { kind: 'ok'; items: ObligationRow[] }
16 | { kind: 'error'; line: number; message: string }
17
18const KEYS = ['obligation', 'actor', 'owed_to', 'since', 'check', 'expires', 'ref'] as const
19type Key = (typeof KEYS)[number]
20const REQUIRED: readonly Key[] = ['obligation', 'actor', 'expires']
21const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
22
23class ParseError extends Error {
24 constructor(
25 readonly line: number,
26 message: string,
27 ) {
28 super(message)
29 }
30}
31
32/** Strips a trailing ` # comment` from a plain (unquoted) scalar. */
33function plainScalar(raw: string): string {
34 const hash = raw.search(/(^|\s)#/)
35 return (hash === -1 ? raw : raw.slice(0, hash)).trim()
36}
37
38const ESCAPES: Record<string, string> = {
39 '0': '\0', a: '\x07', b: '\b', t: '\t', n: '\n', v: '\v', f: '\f', r: '\r', e: '\x1b',
40 ' ': ' ', '"': '"', '/': '/', '\\': '\\', N: '\x85', _: '\xa0', L: '
', P: '
',
41}
42const HEX_ESCAPES: Record<string, number> = { x: 2, u: 4, U: 8 }
43
44/**
45 * Line folding inside a flow scalar: `text` holds the scalar's lines joined by '\n'. Each
46 * line is trimmed (the first only at its end, the last only at its start); one break
47 * becomes a space, each empty line between two lines a '\n'.
48 */
49function fold(lines: readonly string[]): string {
50 let out = ''
51 let empties = 0
52 lines.forEach((raw, i) => {
53 let line = i === 0 ? raw : raw.trimStart()
54 if (i < lines.length - 1) line = line.trimEnd()
55 if (i > 0 && line === '' && i < lines.length - 1) {
56 empties++
57 return
58 }
59 if (i > 0) out += empties > 0 ? '\n'.repeat(empties) : ' '
60 empties = 0
61 out += line
62 })
63 return out
64}
65
66/**
67 * One scalar value: double-quoted, single-quoted, or plain, as YAML reads it. `raw` is the
68 * value's first line and any continuation lines, joined by '\n' (yaml.safe_dump folds long
69 * values onto indented continuation lines). `null` and `~` written plain are empty.
70 */
71export function parseScalar(raw: string, line: number): string {
72 const text = raw.trim()
73 if (text.startsWith('"')) {
74 // The scalar's lines, escapes marked; folded once the closing quote is found.
75 const parts: string[] = []
76 let current = ''
77 let lineNo = line
78 for (let i = 1; i < text.length; i++) {
79 const c = text[i] ?? ''
80 if (c === '"') {
81 const rest = text.slice(i + 1).trim()
82 if (rest !== '' && !rest.startsWith('#')) throw new ParseError(lineNo, 'text after closing quote')
83 parts.push(current)
84 return foldQuoted(parts)
85 }
86 if (c === '\n') {
87 lineNo++
88 parts.push(current)
89 current = ''
90 continue
91 }
92 if (c === '\\') {
93 const n = text[++i] ?? ''
94 if (n === '\n') {
95 // Escaped break: the lines join with nothing between, the next line's indent dropped.
96 lineNo++
97 while (text[i + 1] === ' ') i++
98 continue
99 }
100 const width = HEX_ESCAPES[n]
101 if (width !== undefined) {
102 const hex = text.slice(i + 1, i + 1 + width)
103 if (!new RegExp(`^[0-9A-Fa-f]{${width}}$`).test(hex)) throw new ParseError(lineNo, `bad escape \\${n}${hex}`)
104 current += escapeMark(String.fromCodePoint(Number.parseInt(hex, 16)))
105 i += width
106 continue
107 }
108 const mapped = ESCAPES[n]
109 if (mapped === undefined) throw new ParseError(lineNo, `unsupported escape \\${n}`)
110 current += escapeMark(mapped)
111 continue
112 }
113 current += c
114 }
115 throw new ParseError(line, 'unterminated double-quoted string')
116 }
117 if (text.startsWith("'")) {
118 let out = ''
119 for (let i = 1; i < text.length; i++) {
120 const c = text[i]
121 if (c === "'") {
122 if (text[i + 1] === "'") {
123 out += "'"
124 i++
125 continue
126 }
127 const rest = text.slice(i + 1).trim()
128 if (rest !== '' && !rest.startsWith('#')) throw new ParseError(line + lineCount(text.slice(0, i)), 'text after closing quote')
129 return fold(out.split('\n'))
130 }
131 out += c
132 }
133 throw new ParseError(line, 'unterminated single-quoted string')
134 }
135 const lines = text.split('\n')
136 lines.forEach((l, i) => {
137 if (i < lines.length - 1 && plainScalar(l) !== l.trim()) throw new ParseError(line + i, 'a comment cannot continue onto the next line')
138 })
139 const plain = fold(lines.map(plainScalar))
140 if (/^[|>]/.test(plain)) throw new ParseError(line, 'block scalars (| or >) are not supported')
141 if (/^[[{&*!]/.test(plain)) throw new ParseError(line, `unsupported value: ${plain.slice(0, 20)}`)
142 if (plain === '~' || /^(null|Null|NULL)$/.test(plain)) return ''
143 return plain
144}
145
146function lineCount(text: string): number {
147 return text.split('\n').length - 1
148}
149
150// Escaped characters are content, never trimmed or folded: each is carried as a mark with
151// no whitespace in it (U+0000, the code point in hex, ';') until folding is done.
152const escapeMark = (c: string) => `\u0000${(c.codePointAt(0) ?? 0).toString(16)};`
153
154function foldQuoted(parts: readonly string[]): string {
155 return fold(parts).replace(/\u0000([0-9a-f]+);/g, (_, hex: string) => String.fromCodePoint(Number.parseInt(hex, 16)))
156}
157
158/** Splits `key: value` (value may be empty). */
159function keyValue(text: string, line: number): [string, string] {
160 const m = /^([A-Za-z_][\w-]*):(\s+(.*))?$/.exec(text)
161 if (!m) throw new ParseError(line, `expected "key: value", got: ${text.slice(0, 40)}`)
162 return [m[1] ?? '', m[3] ?? '']
163}
164
165/**
166 * Parses memory/obligations.yaml. Accepts: comments, blank lines, one top-level key
167 * `obligations:` holding a block list of flat maps (or `[]`), scalar values quoted or plain.
168 * The list items may sit at column 0 under the key, as yaml.safe_dump writes them, or be
169 * indented; a value may continue on lines indented deeper than its key (folded as YAML does).
170 * Every item records the 1-based line it starts on: file order for ties, and a stable row key.
171 */
172export function parseObligations(text: string): ParseResult {
173 try {
174 return { kind: 'ok', items: parseOrThrow(text) }
175 } catch (err) {
176 if (err instanceof ParseError) return { kind: 'error', line: err.line, message: err.message }
177 throw err
178 }
179}
180
181function parseOrThrow(text: string): ObligationRow[] {
182 const lines = text.replace(/^/, '').split(/\r?\n/)
183 const items: ObligationRow[] = []
184 let seenTop = false
185 let isEmptyList = false
186 let itemIndent = -1
187 let keyIndent = -1
188 let current: (Partial<Record<Key, string>> & { line: number }) | undefined
189 // The key whose value is still open: continuation lines join it until the next key or item.
190 let pending: { key: string; raw: string[]; line: number; blanks: number } | undefined
191
192 const flush = () => {
193 if (!pending) return
194 const { key, raw, line } = pending
195 pending = undefined
196 if (!current) throw new ParseError(line, 'key outside a list item')
197 if (!(KEYS as readonly string[]).includes(key)) throw new ParseError(line, `unknown key "${key}"`)
198 if (current[key as Key] !== undefined) throw new ParseError(line, `duplicate key "${key}"`)
199 // A value that starts on the next line: the scalar is the continuation alone.
200 const value = raw[0]?.trim() === '' && raw.length > 1 ? raw.slice(1) : raw
201 current[key as Key] = parseScalar(value.join('\n'), line)
202 }
203
204 const close = () => {
205 flush()
206 if (!current) return
207 for (const key of REQUIRED) {
208 if (current[key] === undefined || current[key] === '') {
209 throw new ParseError(current.line, `item is missing "${key}"`)
210 }
211 }
212 for (const key of ['since', 'expires'] as const) {
213 const v = current[key]
214 if (v !== undefined && v !== '' && !ISO_DATE.test(v)) {
215 throw new ParseError(current.line, `"${key}" is not an ISO date (YYYY-MM-DD): ${v}`)
216 }
217 }
218 items.push({
219 obligation: current.obligation ?? '',
220 actor: current.actor ?? '',
221 owed_to: current.owed_to ?? '',
222 since: current.since ?? '',
223 check: current.check ?? '',
224 expires: current.expires ?? '',
225 ref: current.ref ?? '',
226 line: current.line,
227 })
228 current = undefined
229 }
230
231 const openKey = (text: string, line: number) => {
232 flush()
233 const [key, value] = keyValue(text, line)
234 pending = { key, raw: [value], line, blanks: 0 }
235 }
236
237 /** Inside an open quoted value, every line is content, a '#' or a blank line included. */
238 const inOpenQuote = (): boolean => {
239 if (!pending) return false
240 const value = pending.raw.join('\n').trim()
241 if (value.startsWith("'")) return !/^'(?:[^']|'')*'/.test(value)
242 if (value.startsWith('"')) return !/^"(?:[^"\\]|\\[\s\S])*"/.test(value)
243 return false
244 }
245
246 lines.forEach((raw, i) => {
247 const line = i + 1
248 const blank = /^\s*(#.*)?$/.test(raw)
249 if (raw.includes('\t') && !inOpenQuote()) throw new ParseError(line, 'tab in indentation')
250 const indent = raw.length - raw.trimStart().length
251
252 // A continuation line: deeper than the open value's key, or anything inside an open quote.
253 if (pending && (inOpenQuote() || (!blank && indent > keyIndent))) {
254 if (raw.trim() === '') {
255 pending.blanks++
256 return
257 }
258 const quoted = /^\s*['"]/.test(pending.raw.join('\n'))
259 if (!quoted && /^[A-Za-z_][\w-]*:(\s|$)/.test(raw.trim())) {
260 throw new ParseError(line, 'key at inconsistent indent (nested values are not supported)')
261 }
262 for (; pending.blanks > 0; pending.blanks--) pending.raw.push('')
263 pending.raw.push(raw)
264 return
265 }
266 if (blank) {
267 if (pending && raw.trim() === '') pending.blanks++
268 return
269 }
270 if (pending) pending.blanks = 0
271 const body = raw.trim()
272 const isItem = body.startsWith('- ') || body === '-'
273
274 if (indent === 0 && !(isItem && seenTop)) {
275 if (seenTop) throw new ParseError(line, `unexpected top-level line: ${body.slice(0, 40)}`)
276 if (isItem) throw new ParseError(line, 'expected "obligations:" first')
277 const [key, value] = keyValue(body, line)
278 if (key !== 'obligations') throw new ParseError(line, `unknown top-level key "${key}"`)
279 seenTop = true
280 const v = plainScalar(value)
281 if (v === '[]') isEmptyList = true
282 else if (v !== '') throw new ParseError(line, '"obligations" must be a list')
283 return
284 }
285 if (!seenTop) throw new ParseError(line, 'expected "obligations:" first')
286 if (isEmptyList) throw new ParseError(line, 'content after "obligations: []"')
287
288 if (isItem) {
289 if (itemIndent === -1) itemIndent = indent
290 if (indent !== itemIndent) throw new ParseError(line, 'list item at inconsistent indent')
291 close()
292 current = { line }
293 const rest = body.slice(1).trimStart()
294 if (rest === '') return
295 const firstKeyIndent = indent + (body.length - rest.length)
296 if (keyIndent === -1) keyIndent = firstKeyIndent
297 else if (firstKeyIndent !== keyIndent) throw new ParseError(line, 'key at inconsistent indent')
298 openKey(rest, line)
299 return
300 }
301
302 if (!current) throw new ParseError(line, 'expected a list item ("- ")')
303 if (keyIndent === -1) {
304 if (indent <= itemIndent) throw new ParseError(line, 'key at inconsistent indent')
305 keyIndent = indent
306 }
307 if (indent !== keyIndent) throw new ParseError(line, 'key at inconsistent indent (nested values are not supported)')
308 openKey(body, line)
309 })
310
311 if (!seenTop) {
312 // A file holding only comments is an empty ledger, not an error.
313 if (lines.every(l => /^\s*(#.*)?$/.test(l))) return []
314 throw new ParseError(1, 'expected "obligations:"')
315 }
316 close()
317 return items
318}
319
320/** A top-level `key: value` of a kit home's memory-kit.conf, unquoted; undefined when absent or empty. */
321export function confValue(conf: string, key: string): string | undefined {
322 for (const raw of conf.split(/\r?\n/)) {
323 if (!raw.startsWith(`${key}:`)) continue
324 const v = plainScalar(raw.slice(key.length + 1)).replace(/^["']|["']$/g, '')
325 return v === '' ? undefined : v
326 }
327 return undefined
328}
329
330/**
331 * An absolute path: POSIX (/...), drive letter (C:\ or C:/) or UNC (\\server\share). The mod
332 * has no Node, so no path.isAbsolute (kit #8).
333 */
334export function isAbsolutePath(path: string): boolean {
335 return /^(\/|[A-Za-z]:[\\/]|\\\\)/.test(path)
336}
337
338/** The directory holding `path`, POSIX or Windows separators: '/' at the root, '.' for a bare name. */
339export function parentDir(path: string): string {
340 const trimmed = path.replace(/[\\/]+$/, '')
341 const cut = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
342 if (cut === -1) return '.'
343 return cut === 0 ? trimmed.slice(0, 1) : trimmed.slice(0, cut)
344}
345
346/** Reads `memory_dir:` from a kit home's memory-kit.conf; undefined when absent. */
347export function memoryDirOf(conf: string): string | undefined {
348 return confValue(conf, 'memory_dir')
349}
350
351// ---------------------------------------------------------------------------------------------
352// The home's kit version (kit #4) and the upstream release check (kit #7).
353
354/** The version a home's memory-kit/VERSION names: line 1, trimmed; undefined when empty. */
355export function kitVersionOf(text: string): string | undefined {
356 const first = (text.split(/\r?\n/)[0] ?? '').trim()
357 return first === '' ? undefined : first
358}
359
360export type Release = { major: number; minor: number; patch: number; pre?: { kind: 'alpha' | 'beta' | 'rc'; n: number } }
361
362const NUM = '(0|[1-9]\\d*)'
363const RELEASE = new RegExp(`^v?${NUM}\\.${NUM}\\.${NUM}(?:-(alpha|beta|rc)\\.${NUM})?$`)
364
365/** A release tag or version: v<maj>.<min>.<patch>, optional -alpha.N, -beta.N or -rc.N; else undefined. */
366export function parseRelease(tag: string): Release | undefined {
367 const m = RELEASE.exec(tag.trim())
368 if (!m) return undefined
369 const core = { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]) }
370 return m[4] === undefined ? core : { ...core, pre: { kind: m[4] as 'alpha' | 'beta' | 'rc', n: Number(m[5]) } }
371}
372
373const PRE_ORDER = { alpha: 0, beta: 1, rc: 2 } as const
374
375/** Semver precedence: core numbers, then a release above its pre-releases, then alpha < beta < rc, then N. */
376export function compareRelease(a: Release, b: Release): number {
377 const core = a.major - b.major || a.minor - b.minor || a.patch - b.patch
378 if (core !== 0) return core
379 if (a.pre === undefined || b.pre === undefined) return (a.pre === undefined ? 1 : 0) - (b.pre === undefined ? 1 : 0)
380 return PRE_ORDER[a.pre.kind] - PRE_ORDER[b.pre.kind] || a.pre.n - b.pre.n
381}
382
383const versionText = (r: Release) =>
384 `${r.major}.${r.minor}.${r.patch}${r.pre === undefined ? '' : `-${r.pre.kind}.${r.pre.n}`}`
385
386/** The newest release among tag names, as a version without the `v`; undefined when none is a release. */
387export function newestRelease(names: readonly string[]): string | undefined {
388 let best: Release | undefined
389 for (const name of names) {
390 const r = parseRelease(name)
391 if (r !== undefined && (best === undefined || compareRelease(r, best) > 0)) best = r
392 }
393 return best === undefined ? undefined : versionText(best)
394}
395
396/**
397 * What the header adds when upstream is ahead: the pre-release alone (`alpha.6`) when the
398 * core is the same, else the whole version. '' when not newer, or either side is not a release.
399 */
400export function updateNote(local: string, latest: string): string {
401 const mine = parseRelease(local)
402 const theirs = parseRelease(latest)
403 if (mine === undefined || theirs === undefined || compareRelease(theirs, mine) <= 0) return ''
404 const sameCore = mine.major === theirs.major && mine.minor === theirs.minor && mine.patch === theirs.patch
405 return sameCore && theirs.pre !== undefined ? `${theirs.pre.kind}.${theirs.pre.n}` : versionText(theirs)
406}
407
408/** The header's version slot: `memory-kit <version>`, ` · <note> out` when upstream is ahead; '' without a version. */
409export function kitLabel(version: string, note: string): string {
410 if (version === '') return ''
411 return note === '' ? `memory-kit ${version}` : `memory-kit ${version} · ${note} out`
412}
413
414export const DEFAULT_UPSTREAM = 'mitselek/memory-kit'
415const REPO_PART = '[A-Za-z0-9_][A-Za-z0-9_.-]*'
416const OWNER_REPO = new RegExp(`^${REPO_PART}/${REPO_PART}$`)
417
418/**
419 * The tags API URL for memory-kit.conf's `upstream:` (owner/repo on Codeberg; the kit's own
420 * repository when absent). undefined for any other value: no request is made.
421 */
422export function upstreamTagsUrl(upstream: string | undefined): string | undefined {
423 const repo = upstream ?? DEFAULT_UPSTREAM
424 if (!OWNER_REPO.test(repo) || repo.split('/').some(p => /^\.+$/.test(p))) return undefined
425 return `https://codeberg.org/api/v1/repos/${repo}/tags?limit=50`
426}
427
428/** Tag names from a tags API answer (a JSON list of `{ name }`); undefined for anything else. */
429export function tagNamesOf(text: string): string[] | undefined {
430 let data: unknown
431 try {
432 data = JSON.parse(text)
433 } catch {
434 return undefined
435 }
436 if (!Array.isArray(data)) return undefined
437 const names: string[] = []
438 for (const tag of data) {
439 const name = (tag as { name?: unknown } | null)?.name
440 if (typeof name !== 'string') return undefined
441 names.push(name)
442 }
443 return names
444}
445
446/** A tags request not answered by then counts as a failed check: $.http.fetch has no timeout of its own. */
447export const FETCH_TIMEOUT_MS = 10_000
448
449/** At most one request per upstream in this long, across every session ($.store). */
450export const UPDATE_INTERVAL_MS = 6 * 3_600_000
451
452/** One upstream's last check: when, and the newest release then known ('' for none). */
453export type UpstreamCheck = { checkedAt: number; latest: string }
454
455/** A check made within the interval. A clock that went back makes it stale. */
456export function isFresh(checkedAt: number, now: number): boolean {
457 const age = now - checkedAt
458 return age >= 0 && age < UPDATE_INTERVAL_MS
459}
460
461/** The stored check for `url` from the store's `upstream` value (url -> check); undefined when absent or malformed. */
462export function cachedCheck(stored: unknown, url: string): UpstreamCheck | undefined {
463 if (typeof stored !== 'object' || stored === null) return undefined
464 const entry = (stored as Record<string, unknown>)[url] as Partial<UpstreamCheck> | undefined
465 if (typeof entry?.checkedAt !== 'number' || typeof entry.latest !== 'string') return undefined
466 return { checkedAt: entry.checkedAt, latest: entry.latest }
467}
468
469// ---------------------------------------------------------------------------------------------
470// Dates. The plugin environment's local time zone is not trusted: the offset is passed in.
471
472/** `YYYY-MM-DD` of an instant at a UTC offset (minutes east of UTC). */
473export function isoDate(now: number, offsetMinutes: number): string {
474 return new Date(now + offsetMinutes * 60_000).toISOString().slice(0, 10)
475}
476
477/** `YYYY-MM-DD HH:MM` of an instant at a UTC offset. */
478export function isoMinute(now: number, offsetMinutes: number): string {
479 return new Date(now + offsetMinutes * 60_000).toISOString().slice(0, 16).replace('T', ' ')
480}
481
482/** Adds whole days to an ISO date. */
483export function addDays(date: string, days: number): string {
484 const t = Date.parse(`${date}T00:00:00Z`)
485 return new Date(t + days * 86_400_000).toISOString().slice(0, 10)
486}
487
488/** Parses `date +%z` output (`+0300`, `-0530`) into minutes east of UTC. */
489export function offsetFromZ(z: string): number | undefined {
490 const m = /^([+-])(\d{2})(\d{2})$/.exec(z.trim())
491 if (!m) return undefined
492 const minutes = Number(m[2]) * 60 + Number(m[3])
493 return m[1] === '-' ? -minutes : minutes
494}
495
496// ---------------------------------------------------------------------------------------------
497// Ordering and counts.
498
499/** Soonest expiry first (so expired rows lead); ties by `since`, then file order. */
500export function sortByExpires(items: readonly ObligationRow[]): ObligationRow[] {
501 return [...items].sort(
502 (a, b) => a.expires.localeCompare(b.expires) || a.since.localeCompare(b.since) || a.line - b.line,
503 )
504}
505
506export type Status = 'expired' | 'due' | 'later'
507
508/** expired: expires before today. due: today .. today+7 inclusive. */
509export function statusOf(item: ObligationRow, today: string): Status {
510 if (item.expires < today) return 'expired'
511 if (item.expires <= addDays(today, 7)) return 'due'
512 return 'later'
513}
514
515export function countsOf(items: readonly ObligationRow[], today: string): { expired: number; due: number } {
516 let expired = 0
517 let due = 0
518 for (const item of items) {
519 const s = statusOf(item, today)
520 if (s === 'expired') expired++
521 else if (s === 'due') due++
522 }
523 return { expired, due }
524}
525
526export function countsLabel(c: { expired: number; due: number }): string {
527 return `${c.expired} expired · ${c.due} due ≤7d`
528}
529
530// ---------------------------------------------------------------------------------------------
531// Scrolling: an index into the sorted list, stepped one item at a time.
532
533export function clampIndex(index: number, length: number): number {
534 if (length <= 0 || !Number.isFinite(index)) return 0
535 return Math.min(Math.max(0, Math.trunc(index)), length - 1)
536}
537
538export function step(index: number, delta: number, length: number): number {
539 return clampIndex(clampIndex(index, length) + delta, length)
540}
541
542// ---------------------------------------------------------------------------------------------
543// Footer columns.
544
545export const NAME_WIDTH = 10
546export const ACTOR_WIDTH = 20
547
548/** At most `width` code points: longer is width-1 chars + "…". No padding. */
549export function capName(name: string, width: number): string {
550 const chars = Array.from(name.trim())
551 return chars.length > width ? chars.slice(0, width - 1).join('') + '…' : chars.join('')
552}
553
554/** Footer column: exactly 10 code points, capped as capName, shorter right-padded. */
555export function fitName(name: string, width = NAME_WIDTH): string {
556 const capped = capName(name, width)
557 return capped + ' '.repeat(width - Array.from(capped).length)
558}
559
560/** Row actor: capped at 20 so the task text starts on the first line. */
561export function fitActor(actor: string): string {
562 return capName(actor, ACTOR_WIDTH)
563}
564
565/** '·' (drawn dim) when the model is unknown: a question mark reads as an error. */
566export type ModelLetter = 'F' | 'O' | 'S' | 'H' | '·'
567
568export function modelLetter(model: string | undefined): ModelLetter {
569 const m = (model ?? '').toLowerCase()
570 if (m.includes('fable')) return 'F'
571 if (m.includes('opus')) return 'O'
572 if (m.includes('sonnet')) return 'S'
573 if (m.includes('haiku')) return 'H'
574 return '·'
575}
576
577export const MODEL_COLORS: Record<ModelLetter, string> = {
578 F: '#b0006a',
579 O: '#c24e00',
580 S: '#005fb8',
581 H: '#1b7a2e',
582 '·': '#666666',
583}
584
585/** 127k, 1M, 1.5M, 950. */
586export function formatTokens(n: number): string {
587 if (n >= 1_000_000) return `${Number((n / 1_000_000).toFixed(1))}M`
588 if (n >= 1_000) return `${Math.round(n / 1_000)}k`
589 return String(Math.round(n))
590}
591
592export function contextWeight(tokens: number | undefined, window: number | undefined): string {
593 const used = tokens === undefined ? '-' : formatTokens(tokens)
594 return window === undefined || window <= 0 ? used : `${used}/${formatTokens(window)}`
595}
596
597/** Live agents (anything not finished), oldest first as listed. */
598export function liveAgents(agents: readonly PaneAgent[]): PaneAgent[] {
599 return agents.filter(a => a.status !== 'completed' && a.status !== 'failed' && a.status !== 'killed')
600}
601
602// ---------------------------------------------------------------------------------------------
603// Colours. Pane background is a bright pastel; actor colours are saturated darks that read on it.
604
605export const PANE_BG = '#fff3b0'
606export const HEADER_BG = '#1f1b3a'
607export const HEADER_FG = '#fff3b0'
608export const DATE_FG = '#000000'
609export const EXPIRED_DATE_BG = '#ffb4a8'
610export const LINK_FG = '#00008b'
611export const MIHKEL_COLOR = '#d4006f'
612
613/** Saturated, dark enough for the pastel; no plain blue (links are dark blue), no Mihkel pink. */
614export const ACTOR_PALETTE = [
615 '#7b1fa2', // purple
616 '#00796b', // teal
617 '#b34700', // burnt orange
618 '#2e7d32', // green
619 '#5d4037', // brown
620 '#6a1b9a', // violet
621 '#00838f', // cyan-teal
622 '#9e2a2b', // brick
623 '#556b2f', // olive
624 '#8e24aa', // orchid
625] as const
626
627/** FNV-1a, 32-bit. Deterministic across sessions and machines. */
628export function hash(text: string): number {
629 let h = 0x811c9dc5
630 for (const ch of text) {
631 h ^= ch.codePointAt(0) ?? 0
632 h = Math.imul(h, 0x01000193) >>> 0
633 }
634 return h >>> 0
635}
636
637export function actorColor(actor: string): string {
638 const key = actor.trim().toLowerCase()
639 if (key === 'mihkel') return MIHKEL_COLOR
640 return ACTOR_PALETTE[hash(key) % ACTOR_PALETTE.length] ?? ACTOR_PALETTE[0]
641}
642
643// ---------------------------------------------------------------------------------------------
644// urls/<name> links.
645
646const URL_REF = /urls\/([a-z0-9][a-z0-9-]*)/g
647
648/** Every urls/<name> cited in a row's text, check or ref, each once, in order. */
649export function urlNamesOf(item: Pick<ObligationRow, 'obligation' | 'check' | 'ref'>): string[] {
650 const names: string[] = []
651 for (const field of [item.obligation, item.check, item.ref]) {
652 for (const m of field.matchAll(URL_REF)) {
653 const name = m[1] ?? ''
654 if (!names.includes(name)) names.push(name)
655 }
656 }
657 return names
658}
659
660/** The URL a memory/urls/<name> file holds: line 1, trimmed; undefined when unusable. */
661export function urlOfFile(text: string): string | undefined {
662 const first = (text.split(/\r?\n/)[0] ?? '').trim()
663 if (first === '' || /\s/.test(first) || first.length > 2048) return undefined
664 return first
665}
666
667export type Segment =
668 | { kind: 'text'; text: string }
669 | { kind: 'link'; name: string; href: string }
670 | { kind: 'dead'; name: string }
671
672/** Splits text at each urls/<name>, resolved against `urls` (name -> URL). */
673export function segmentsOf(text: string, urls: Readonly<Record<string, string>>): Segment[] {
674 const out: Segment[] = []
675 let at = 0
676 for (const m of text.matchAll(URL_REF)) {
677 const start = m.index ?? 0
678 if (start > at) out.push({ kind: 'text', text: text.slice(at, start) })
679 const name = m[1] ?? ''
680 const href = urls[name]
681 out.push(href === undefined ? { kind: 'dead', name } : { kind: 'link', name, href })
682 at = start + m[0].length
683 }
684 if (at < text.length) out.push({ kind: 'text', text: text.slice(at) })
685 return out
686}
687
688/** Links cited in check or ref but not in the obligation text: drawn after the text. */
689export function trailingLinksOf(item: ObligationRow, urls: Readonly<Record<string, string>>): Segment[] {
690 const inText = new Set(segmentsOf(item.obligation, {}).flatMap(s => (s.kind === 'text' ? [] : [s.name])))
691 return urlNamesOf(item)
692 .filter(name => !inText.has(name))
693 .map(name => {
694 const href = urls[name]
695 return href === undefined ? { kind: 'dead' as const, name } : { kind: 'link' as const, name, href }
696 })
697}
698
699/** Last path component; used as the default agent name. */
700export function basename(path: string): string {
701 const parts = path.split('/').filter(p => p !== '')
702 return parts[parts.length - 1] ?? path
703}
704
705// ---------------------------------------------------------------------------------------------
706// Subagent context load, from turn.step usage.
707
708/** The token counts one step reported (ModelUsage) and the model that answered. */
709export type StepUsage = {
710 input_tokens: number
711 output_tokens: number
712 cache_read_input_tokens: number
713 cache_creation_input_tokens: number
714 model: string
715}
716
717/** A loop's context load after a step: everything the request read plus what it wrote. */
718export function contextLoad(u: StepUsage): number {
719 return u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens + u.output_tokens
720}
721
722/**
723 * Records a subagent's latest step (latest wins). The main loop (no agentId) and steps
724 * with no usage leave the map as it was: the main line has its own figures.
725 */
726export function recordStep(
727 loads: Readonly<Record<string, AgentLoad>>,
728 agentId: string | undefined,
729 usage: StepUsage | null,
730): Record<string, AgentLoad> {
731 if (agentId === undefined || usage === null) return { ...loads }
732 return { ...loads, [agentId]: { tokens: contextLoad(usage), model: usage.model } }
733}
734
735/**
736 * A subagent's load as the footer shows it: used/window when it runs on the main loop's
737 * model (whose window the session reports), the used figure alone otherwise, '' when unknown.
738 */
739export function subagentWeight(load: AgentLoad | undefined, mainModel: string, mainWindow: number | null): string {
740 if (load === undefined) return ''
741 const same = mainModel !== '' && load.model.trim().toLowerCase() === mainModel.trim().toLowerCase()
742 return contextWeight(load.tokens, same && mainWindow !== null ? mainWindow : undefined)
743}
744
745/** Columns the footer takes around its lines: paddingX 1 each side, as the body rows. */
746export const FOOTER_CHROME = 2
747
748export const RULE_COLOR = '#b8a24a'
749
750/**
751 * The figure a footer line can show in `available` columns: `used/window` when the whole line
752 * (name 10, space, letter, space, figure) fits, else `used` alone. Never cut inside the figure.
753 */
754export function fitWeight(weight: string, available: number): string {
755 if (weight === '' || NAME_WIDTH + 3 + Array.from(weight).length <= available) return weight
756 return weight.split('/')[0] ?? weight
757}
758
759/** After a wheel step, further ui.scroll events are ignored this long: one burst, one item. */
760export const WHEEL_THROTTLE_MS = 150
761
762/**
763 * One wheel event: the items to step (-1, 0 or 1, never |by|) and the new last-step time.
764 * An event within WHEEL_THROTTLE_MS after the last step is ignored.
765 */
766export function wheelStep(by: number, now: number, lastStep: number | undefined): { delta: number; lastStep: number | undefined } {
767 const gap = lastStep === undefined ? Infinity : now - lastStep
768 // A clock that went backwards (a reset) starts a fresh burst.
769 if (by === 0 || (gap >= 0 && gap < WHEEL_THROTTLE_MS)) return { delta: 0, lastStep }
770 return { delta: Math.sign(by), lastStep: now }
771}
772types/index.d.ts 71 lines1// The obligations pane's $.state contract. Self-contained: no imports.
2
3/** One obligation as parsed from memory/obligations.yaml, plus the line its item starts on. */
4export type ObligationRow = {
5 obligation: string
6 actor: string
7 /** Who is waiting (kit #29); '' on a row without one. */
8 owed_to: string
9 since: string
10 check: string
11 expires: string
12 ref: string
13 /** 1-based line of the item's "- " in the file: file order for ties, and the row key. */
14 line: number
15}
16
17/** One live subagent or teammate as the footer lists it. */
18export type PaneAgent = {
19 id: string
20 name: string
21 /** Model id or alias as the spawn resolved it; '' when unknown. */
22 model: string
23 status: string
24}
25
26/** A subagent's context load from its latest model step, and the model that answered it. */
27export type AgentLoad = { tokens: number; model: string }
28
29/** Everything the pane draws, gathered by one refresh. Rendering does no I/O. */
30export type PaneSnapshot = {
31 /** Absolute path of the obligations file this snapshot read (or looked for). */
32 path: string
33 /** 'pointer': the start folder's kit_home pointer is broken (kit #18); `error` says why, `path` is that conf. */
34 source: 'ok' | 'missing' | 'error' | 'pointer'
35 /** For source 'error': what went wrong, with the line when known. */
36 error: string
37 items: ObligationRow[]
38 /** urls/<name> -> URL, for every name the items cite that resolved. */
39 urls: Record<string, string>
40 /** Milliseconds since the epoch when the snapshot was taken. */
41 now: number
42 /** Local UTC offset in minutes east, for "today". */
43 offsetMinutes: number
44 /** The home's memory-kit/VERSION, line 1; '' when there is none (kit #4). */
45 kitVersion: string
46 /** The tags URL the release check asks; '' when none is made: checkUpdates off, no VERSION, or an unusable upstream (kit #7). */
47 upstreamUrl: string
48 /** The newest upstream release known for upstreamUrl, without the `v`; '' when none. */
49 latest: string
50 self: { name: string; model: string; tokens: number | null; window: number | null }
51 agents: PaneAgent[]
52}
53
54declare module 'claude-code' {
55 interface PluginState {
56 'obligations-pane': {
57 snapshot: PaneSnapshot | null
58 /** Index of the first obligation shown (scroll position). */
59 index: number
60 /** agentId -> model, captured at agent.spawn (agent.list carries no model). */
61 models: Record<string, string>
62 /** agentId -> load of its latest turn.step (subagents only). */
63 loads: Record<string, AgentLoad>
64 /** The model id the main loop's latest step reported ('' before one). */
65 mainModel: string
66 /** The session's working directory at its first session.start: the home the pane reads. */
67 startCwd: string
68 }
69 }
70}
71