SLOPSHOPPER

obligations-pane

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

newpanecommandprocessnetworktimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · obligations-pane
│ ┃ Obligations ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ ⏺ Read(src/auth.ts) │ ┃ no obligations file here ⎿ Read 6 lines │ ┃ (/work/app/memory/obligations.yaml) ⏺ Update(src/auth.ts) │ ┃ ───────────────────────────────────────────… ⎿ Added 2 lines, removed 1 line │ ┃ app O 97k/200k ⏺ 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 │ ┃ │ ┃ › /obligations │ ┃ ⎿ obligations-pane: Obligations pane opened. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Obligations
no obligations file here (/work/app/memory/obligations.yaml) ──────────────────────────────────────────────────────── app O 97k/200k
README

obligations-pane

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).

What it shows

  • Header (sticky, dark on the pastel), bold: the home's kit version, then the counts 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).
  • The version is line 1 of <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.
  • When upstream has a newer release: 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).
  • Expired means expires is before today.
  • Due means expires falls between today and today + 7 days, both ends included. Today is the local date, which the header no longer shows.
  • Body: one row per obligation, ordered by expires (expired first), ties by since, then file order.
  • The due date comes first, bold and black. An expired date also gets a salmon background.
  • The actor follows, bold, in a colour that is stable per party: a hash of the lower-cased name picks from a palette of dark colours that read on the pastel. "Mihkel" has a fixed colour of his own. A long actor is capped at 20 columns (19 chars + …), so the task text starts on the first line.
  • The obligation text comes last.
  • Every 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.
  • A urls/<name> with no file, or with an unusable one, is drawn struck through and grey.
  • Tickets (a Jira key, an issue number) link the same way: give the ticket its own URL file, e.g. 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).
  • A row with 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.
  • Footer (sticky, under one ─ 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.
  • Footer figures are never cut. The pane knows its width from the harness (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.
  • Scrolling: the mouse wheel over the pane steps strictly one obligation per event, in the direction of the tick. Further events are ignored for 150 ms after a step, so a trackpad or fast-wheel burst moves one item. The index is clamped to the list. The k and j hotkeys step one while the pane has the keyboard. They live on two Buttons inside a Box that is not displayed.
  • Refresh: every 20 s and at the end of every turn. The mod API has no file watch, so the file and VERSION are re-read each time. Each refresh also runs the release check, which asks the network at most once every 6 h.
  • Empty states: a missing file says 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.

Data source

  • <memory dir>/obligations.yaml. The memory directory is, in order:
  • a 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.
  • the 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.

  • Shape: memory-kit/types/memory-obligations.ts, a list of {obligation, actor, since, check, expires, ref}.
  • Links: memory/urls/<name> holds one URL on line 1 (memory-kit/types/memory-urls.ts). The pane reads these files and never writes them.
  • Parsing uses a small strict parser for exactly this shape, in hooks/obligations.ts. The mod environment has no bundler and no node_modules, so a YAML library is not an option.
  • Accepted: comments, obligations: or obligations: [], and list items of flat key: value maps with double-quoted, single-quoted or plain scalars.
  • The items may sit at column 0 under obligations:, as yaml.safe_dump writes them, or be indented (since 0.3.0, kit #5). One file uses one indent throughout.
  • A value may continue on lines indented deeper than its key and folds as YAML folds it. Single quotes double a quote (''). Double quotes take YAML's escapes, \x, \u, \U and an escaped line end included. A plain null or ~ is empty.
  • In short: whatever 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).
  • Rejected, with the line number: anything else, such as unknown keys, duplicate keys, block scalars, flow collections, nested maps, a non-ISO since or expires, or a missing obligation, actor or expires.
  • The proposed wording for the kit's types/memory-obligations.ts is in PROPOSED-types-doc.md, for upstream to apply.

Release check

Since 0.3.0 (kit #7), the pane notices a newer kit release by itself.

  • Source: the Forgejo tags API, anonymous, through $.http.fetch: https://codeberg.org/api/v1/repos/<owner>/<repo>/tags?limit=50. That is the newest 50 tags, newest first.
  • Upstream: 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.
  • Which tags count: release tags only, 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.
  • Interval: at most one request per upstream every 6 hours. The result is cached in $.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.
  • No VERSION file: no request, no comparison, no note.
  • Never waits on the network: no hook awaits the check; session start and turn end return once the local refresh is done. $.http.fetch has no timeout of its own, so a request not answered in 10 s (on $.clock.after) is a failed check.
  • Failure is silent: a refused request, a timeout, an error status or an answer that is not a tags list keeps the last known result, or shows nothing. It writes one debug log line and never draws an error. A failure counts as a check, so the next try comes 6 h later.
  • Off switch: checkUpdates: false makes no request at all and shows no note.
  • Read-only: the pane never pulls or upgrades anything. Upgrading stays UPGRADE.md, run by the home's assistant.

A session outside its home: 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
  • What counts: the home it names. That home's 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.
  • Precedence: the start folder's pointer first. It wins over the user-scope memoryDir 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.
  • Absolute paths: POSIX, a drive letter (C:\ or C:/) or UNC. ~ is not expanded: write the path absolute or relative to the conf.
  • Never silent: a broken pointer fills the pane with "kit_home pointer broken: <why>", and the pane opens itself to show it, never the start folder's ledger instead. A pointed home without memory-kit/VERSION shows memory-kit ? in the header and asks upstream nothing.
  • 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.

Read-only

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.

Install

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.

Develop

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

Capture

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

API notes (2.1.296, against the shipped .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.
  • The main agent's model comes from $.session.model().
  • A subagent's model is the one its latest 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.
  • A subagent's load comes from 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.
  • In 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.
  • A 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.
  • A function that receives $ must be declared at the module's top level. claude plugin validate refuses $ passed to a closure.
Source 4 files
hooks/register.tsx 538 lines
1// 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}
538
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
hooks/obligations.ts 772 lines
1// 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}
772
types/index.d.ts 71 lines
1// 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