Tool-local maintainer / parser notes for scripts/nplog/. This is not the repo-wide
agent hub -- that is root AGENTS.md. Agent how-to for reading logs:
agents/skills/nplog/SKILL.md.
Read this before changing how nplog renders, or before producing a screenshot for the README.
sample.log in this directory is a committed fixture built to exercise every display
feature at once. Always screenshot from it rather than from your live NotePlan log — a real log
gives a different picture every run, so you can't tell a rendering regression from a data
change.
# from the repo root
nplog --file scripts/nplog/sample.log--file pins that one log and disables rotation-following, so the view is stable.
-
Open a fresh terminal window. Don't reuse one with scrollback in it.
-
Don't run
clearfirst. iTerm intercepts the clear-scrollback escape with an "A control sequence attempted to clear scrollback history" banner that lands on top of your screenshot. A new window is already clean. -
Size the window so the content exactly fills it.
sample.logrenders as 29 rows plus 2 status rows, so ~32 rows is right. Too tall leaves a dead band of empty background under the log; too short and the first rule scrolls off the top (the view follows the tail). On a Retina Mac these bounds give 32 rows × 167 cols:osascript -e 'tell application "iTerm2" to set bounds of window id <ID> to {120, 120, 1300, 585}' -
Crop to the window, don't capture the whole desktop:
screencapture -x -o -R120,120,1180,465 ~/Downloads/nplog-screenshot.png-Rtakesx,y,width,heightin screen points and matches the bounds above.-xsuppresses the shutter sound;-oomits the window shadow.Write to
~/Downloads, not~/Desktop. A cloud-sync client (Dropbox, iCloud Desktop & Documents) may be managing~/Desktop, in which case a file written there can be relocated out from under you — the write reports success and the file still isn't where you left it. After capturing, confirm the file really exists before relying on it. -
Check the status bar reads
17/17 (29 rows). If the entry count differs, the ingest logic changed — investigate before publishing the image. -
Confirm iTerm actually brought your window to the front before capturing, or you will screenshot whatever was on top instead.
selectalone is not enough:osascript -e 'tell application "iTerm2" to activate' test "$(osascript -e 'tell application "iTerm2" to return id of current window')" = "<ID>"
Screenshots are not committed to the repo. Upload the PNG to the GitHub README via the web
editor, which rehosts it under user-attachments; the README's <img> tag points there.
Keep all of these when editing it, or the screenshot stops proving anything:
| Feature | How it appears |
|---|---|
| dimmed timestamps | every line |
| dimmed routine levels | | DEBUG |, | INFO | |
| orange WARN | 🥺 WARN 🥺 — two of them |
| red ERROR | ❗️ ERROR ❗️ — two of them |
| dimmed source tag | [WebView Log] → rendered [WebView] |
| red error source tag | [WebView Error ❗️] → rendered [WebView Error] |
| multi-line object | the => Reminder: block, with a nested object and a nested array so depth-aware indentation is visible |
| native non-JSLog lines | [PluginRefresh] start, initFunc result: — hidden by default, and the reason --raw shows more |
| filterable text | refreshSomeSections appears on several lines; good for demoing highlight + the ┈┈┈ gap rule |
| run separator | two Executing function 'onMessageFromHTMLView' lines, which become the rules themselves |
| idle separator | a deliberate 47-second gap (09:14:10 → 09:14:57) so the lull rule appears, merged with the run rule |
| outer vs inner timestamps | every line's outer flush stamp is 09:15:02, deliberately unlike the inner ones — a fixture where they matched would hide the bug below |
To show the filter/highlight instead of the default view, type into the running instance rather
than passing an argument — e.g. refreshSomeSections — then screenshot.
The single easiest thing to get wrong. LOG_LEVEL_STRINGS in
helpers/dev.js is the source of truth:
['| DEBUG |', '| INFO |', '🥺 WARN 🥺', '❗️ ERROR ❗️', 'none']DEBUG and INFO are pipe-delimited and space-padded to equal width. WARN and ERROR are delimited by emoji. A pipe-only regex silently matches zero real WARN/ERROR lines — it looks like the feature works, because DEBUG/INFO still style correctly.
Two traps in the patterns (WARN_LEVEL_RE / ERROR_LEVEL_RE):
❗️is two code points: U+2757 plus a U+FE0F variation selector. Written as a literal,❗️?looks like an optional❗but the?actually applies to the invisible selector. The code uses explicit❗️?escapes so this can't be misread.- At least one delimiter must be required, or a bare "ERROR" inside an ordinary message gets
coloured. One real line had only a trailing
🥺, so each side is optional provided the other is present.
Validate any change to these against every log you have, checking both coverage and over-matching:
ls ~/Library/Containers/co.noteplan.NotePlan3/Data/Library/Application\ Support/co.noteplan.NotePlan3/Logs/Last verified: 1210/1210 WARN and 1336/1336 ERROR lines styled, with no false positives on
prose like "handling WARN cases" or "errorCount = 3".
The other easiest thing to get wrong, and it produced a whole class of nonsense output.
2026-07-29 15:51:09 JSLog: 2026-07-29 15:49:21 | DEBUG | routeRequestsFromReact ...
└─ outer: when NotePlan flushed ─┘ └─ inner: when the plugin logged ─┘
Output is written in batches, so the two disagree on roughly two thirds of real lines, by as
much as a couple of hours. The inner one is the real event time and the one displayed on
screen, so all timing arithmetic must use it (payloadStamp(), not the raw line prefix). Deriving
idle gaps from the outer stamp produced lulls that never happened and rules labelled with a time
later than the lines beneath them.
Two consequences to preserve:
- Lines like
Executing function 'x'carry no inner timestamp. They inherit the previous entry's, so they don't invent a gap — which also means a measured lull lands on the line after the run marker, not on it. That is why the lull/run merge searches both directions. - A fixture whose outer and inner stamps agree cannot catch a regression here.
sample.logdeliberately uses a single late flush stamp for every line.
A multi-line entry begins when a line ends with { or [. Unbalanced depth alone is
far too loose — NotePlan's own native logging contains prose like
[IAPHandler Received a purchase update
whose stray [ made the parser swallow the next several hundred lines into one 40 KB
entry, which then got classified as an ERROR because the word appeared somewhere inside
it. opensStructure() is the guard; don't relax it.
Logs/co.noteplan.NotePlan3 <ts>.log |
Plugins/<id>/_MCP-console.log |
|
|---|---|---|
| scope | everything, every plugin | one plugin |
| prefix | <timestamp> JSLog: on every line |
marker already stripped |
| retention | append-only for the whole session | truncated on every plugin invocation |
| promptness | lags; flushes in batches | written immediately |
The second is what the NotePlan MCP's noteplan_plugins action:"log" reads, and it is
not used by default. Measured on a live system for the same second: 32 unique
lines there vs 66 in the main log, with nothing present there and missing here. Watching
one file across three Dashboard invocations it went 9 lines → 20,982 bytes → 5,314 bytes
— it shrank, because each invocation truncates it. Since the Dashboard refreshes on a
timer, an unrelated background refresh wipes the run you were investigating within
seconds. The main log is a strict superset and the only durable record, hence the default.
--plugin ID opts into that file explicitly when speed matters more than durability
(utils/log-timing.js's investigation found it wins the completeness/speed race within a
single run essentially every time it's not truncated out from under you). Its lines carry
no JSLog: marker, so entryPayload() returns null for every one of them -- that's
why --plugin routes through a dedicated consumePluginLine() rather than consumeLine().
Critically, consumePluginLine() does not apply LEADING_TS_RE-stripping the way the
--raw/markerOptional fallback in consumeLine() does: those lines already have only
ONE timestamp (no outer flush-timestamp to strip, unlike every line in the main log), and
stripping it would silently break payloadStamp()'s gap arithmetic and drop the timestamp
from what's displayed. markerOptional itself is still unwired to any flag -- it predates
--plugin and was an earlier, incomplete idea for parsing this same file; it's dead code,
not what --plugin actually uses.
_MCP-console.log is truncated and rewritten in place on every plugin invocation.
Tailer's naive size < position check (still what the main log uses, since it's
practically never truncated) is not reliable for this: confirmed the rewrite doesn't
change the inode, and if the new content regrows past the old read position before the
next poll, a size-only check misses it -- nplog would then silently read stale-position
bytes into unrelated new content and display a plausible-looking but WRONG line,
indistinguishable from a genuinely torn line in the source file. captureTailFingerprint()
/ wasFileRewritten() fix this by re-verifying the last TAIL_FINGERPRINT_LEN (64) bytes
against disk on every poll, regardless of size. If you ever see --plugin mode behave
oddly around a reset, check this before assuming NotePlan's write is corrupt.
Executing function 'X' never appears in _MCP-console.log -- measured 33 occurrences
in the main log over a window where the plugin file had 0. It's NotePlan's own notice that
it invoked a plugin entry point, not plugin console.log output. Consequence: --plugin
mode gets idle-lull separators but never named-run separators, since RUN_START_RE has
nothing to match. That's structural, not a bug -- don't "fix" it by synthesizing run rules
from plugin output; use the main log when you need to see which command started a run.
Repeated --plugin merges several logs, via state-swapping rather than a refactor. Each
source needs its own byte position, partial-line carry, rewrite fingerprint, open multi-line
entry and last-seen timestamp — the files truncate independently. Rather than thread a context
argument through every read/parse function, withSource() swaps a source's state into the
existing module globals for its turn and saves it back after. The consequence worth knowing:
the single-source paths execute byte-for-byte the code they always did, so the
well-exercised case can't regress from the multi-source work. activeSource names whichever
source is swapped in, which is why shiftOpenEntries() skips it — its openEntry is the
global one, and adjusting both would double-count.
Merging inserts by timestamp (insertionIndex) instead of appending. Two traps there, both
already paid for:
- A
NaNstamp must not stop the backscan. It did at first, and one untimestamped line at the head of a source's output then pinned every later entry from that source behind it — nothing merged at all. Same shape as the idle-gapNaNbug above; the fix is likewise to step over stampless entries rather than treat them as a wall. - Notices must stop it. "started watching here" / "log was reset" are positional markers, so reordering across them would move the marker relative to what it marks.
Ordering is display-only: it's off under --follow, because streamPending() emits by absolute
buffer index and mid-buffer insertion would break that. Headless consumers get ts per record.
Container path resolution (resolveContainerDir()) matters for --plugin because App
Store and Setapp NotePlan use different Containers bundle IDs, hence different paths for
both Logs/ and Plugins/. Precedence: NPLOG_APP_SUPPORT_DIR env var, then
~/.config/nplog/config.json (written by install.sh, which detects or asks once), then
auto-detect by checking which container directory actually exists on disk. NPLOG_DIR
(pre-existing) still overrides just the Logs path specifically, for back-compat.
The main log's one weakness. Measured live: after firing an x-callback the file sat untouched for 24 seconds, then gained 80 lines in one write. The outer-vs-inner timestamp gap has historically reached two hours.
This makes a naive settle loop actively harmful: a quiet-timer started right after an
action fires immediately, emits zero entries, and the caller concludes the action logged
nothing. --wait-idle therefore tracks "has anything arrived at all" separately from
"has it gone quiet", refuses to finish until it has seen output, and reports sawOutput /
timedOut in the summary so a missed read is distinguishable from a silent run.
Corollaries: the default --timeout is 90s, not 30s; and --follow exists because no
fixed wait can be right when a Dashboard refresh ping-pongs asynchronously for anything
from 2 to 30 seconds.
stamps[i] is the timestamp for lines[i]. Nothing enforces that structurally, so any
code that adds or removes entries must do it to both: pushLine() appends to each, and
the MAX_LINES ring-buffer trim splices each. ^L (clear display) originally did
lines.length = 0 without clearing stamps, which silently offset every subsequent entry
from its timestamp.
Worth knowing because the failure is nowhere near the cause and looks like a separator
bug, not a buffer bug: idle rules start appearing mid-burst between lines that are actually
contiguous, labelled with a clock minutes away from the lines around them (a stale
pre-clear stamp). If you see a separator whose clock disagrees with its neighbours' visible
timestamps, suspect a stamps/lines desync before suspecting separatorForRange().
buildRows() emits two kinds of {sep: true} row and they are not interchangeable:
- run/idle rules carry
boundary: true. These are real breaks in the work, and they're whatjumpToBoundary()(←/→) navigates between. - notice rules (the
--pluginreset marker, the main-logswitching to …marker) do not. They render identically, but a_MCP-console.logreset is a NotePlan file-rewrite artifact that does not reliably coincide with a new run -- jumping to one lands you somewhere misleading. If you add another notice type, leaveboundaryoff it unless it genuinely marks the start of a run.
Two traps that both hid boundaries, worth knowing because neither looks like a boundary bug:
- Gaps must be measured against the nearest real timestamp, not
stamps[i-1]. Notices carry no timestamp (NaN), andNaNon either side of a subtraction makes the deltaNaN, so the lull spanning a notice vanished. That killed exactly the boundary you most want: the one between the pre-reset run and the post-reset run, with the reset notice sitting in the middle.prevFiniteStamp()walks back past stampless entries (capped, so it can't go quadratic). - The anti-stacking guard in
buildRows()must not suppress a boundary behind a notice. It originally readif (sep && !(prevRow && prevRow.sep)), which was right when only run/idle rules wereseprows — but notices becameseprows too, so a reset notice silently ate the idle rule for the gap right after it. They now merge: the notice row is upgraded toboundary: trueand the idle/clock detail is appended to its label.
jumpToBoundary() keeps its own lastBoundaryRow rather than reading state.scroll back,
because a boundary inside the final page can't reach the top -- render clamps scroll to
maxScroll. Measuring the next jump from the clamped position would silently skip that
boundary, so the intended row is remembered and used as the reference when it still matches.
lastBoundaryRow is cleared on ^L (row indices no longer mean anything) and by any key
other than ←/→ and ↑/↓.
- Styling happens in one pass.
styleLine()paints a per-character style array and then emits runs. Applying dim and highlight as separate nested escape sequences does not work — the inner reset cancels the outer style for the rest of the line. Match beats colour beats dim. [22mvs[0m. Dim spans close with[22m(intensity only, preserving colour); coloured spans need[0mto clear colour too.- Object bodies are never styled as chrome. Continuation rows are data — a value like
"created": "2026-07-29 10:00:00"must not be dimmed as if it were a log timestamp. - Every row is written with absolute cursor positioning (
ESC[row;1H) and an erase-line. That makes the display self-correcting: an over-long line clipped by the terminal can't corrupt later rows or the status bar. Truncation usesslice(0, cols), which counts UTF-16 code units rather than display columns, so a line of emoji can come up a little short of the margin — harmless, and deliberately not "fixed" with a width table. - Headless mode must never touch the alternate screen.
--json/--markbranch out ofmain()before the TTY check, because they are meant to be piped. If you add interactive setup tomain(), put it after that branch. sleep()usesAtomics.wait, not a spin loop. The settle loop in--wait-idleis synchronous by design, but pollingDate.now()pegged a core — which matters for a command an agent may call repeatedly.- Reading the screen back over AppleScript is unreliable while output streams.
contents of current sessioncan return a half-drawn frame, which looks like a rendering bug (a status bar readingDEBUinstead ofDEBUG). Verify against a static log with--file, or take a screenshot, before believing it. noise-exclusions.jsdrops lines entirely, before they reach the buffer. This is not the same thing as the dimmed chrome instyleLine()(timestamp/severity/source tag, still visible, still filterable) — an excluded line is gone from both the interactive view and--json, unrecoverable by any filter, and doesn't count towardentriesScanned. Only add a pattern here for noise that has zero signal on every viewing (a benign warning NotePlan logs constantly); if you'd ever want to see it with a broader filter, it belongs inNOISE_SPANS(dimmed) instead, not here.- Timestamps are hidden by default (
showTime: false); separators carry the clock instead.separatorClock()inseparatorForRange()readsstamps[to]—tobeing the entry the boundary is entering — and relies onpayloadStamp()always resolving to a real number (it backfills fromlastStampMswhen a line has no timestamp of its own, e.g.Executing functionlines), so it's effectively never NaN in practice. If you ever changepayloadStampto allow NaN through,separatorClockalready no-ops on non-finite input, but the separator would then silently lose its clock — worth a deliberate decision, not an accident.
utils/log-timing.js— standalone, not part of nplog's own runtime. Tails one log file and reports wall-clock-now minus each complete line's own timestamp (single-file mode), or checks two files for completeness against each other (--compare-file, labeled Full-Log for--file— the main JSLog file — vs. MCP-Log for--compare-file, one plugin's_MCP-console.log). Which side is faster is not reported (MCP-Log wins essentially every race, already established) — only whether a line ever showed up on both sides.- Matching is by
PREFIX_LEN(100) leading characters, not full text, FIFO per distinct prefix. A prefix match with a differing tail is a near match (both full tails printed side by side), not a hard gap — two log lines sharing their first 100 chars are almost always "the same line" with a differing trailing detail. Trade-off: two genuinely different lines that happen to share a 100-char prefix would incorrectly pair up. ../noise-exclusions.js(the same list nplog's live viewer uses) is applied before a line is even counted — dropped lines show up asExcludedin the per-file stats row, never as a gap. Add a pattern there, not here, if a new noise message dominates gap reports.- One-sided "gap" lines (no exact or near match within
--gap-timeout-ms, default 2 minutes — Full-Log flush lag has been observed past a minute in practice, documented historically up to two hours, so anything shorter mislabels merely-slow lines as missing). --record FILEappends every processed (post-exclusion) line from both sides as NDJSON ({side, wallMs, payload}, plus{side, event: 'truncation', wallMs}boundary events -- see below);--replay FILEre-runs the exact same matching/reporting logic against a recorded file with zero live tailing — the way to iterate on the matching algorithm itself (tweakPREFIX_LEN, add an exclusion) without re-triggering NotePlan every time. Both live comparator and replay share onemakeComparator()closure factory so the two paths can't drift apart._MCP-console.logtruncates on every plugin invocation rather than appending -- it's a single-run rolling buffer, not a history.Tailerdetects this via_wasRewritten(), and when detected,orphanPendingFor()immediately drains whatever's still waiting to match into the truncated side into a distinctorphanedgap reason -- that content belongs to a run whose MCP-Log output is now permanently gone, so there's no timeout worth waiting out, and leaving it pending risks a stale line from the old run cross-matching a coincidentally identical line the new run logs. Reported separately in the summary fromtimeout/exitgaps because it means something different: structural (multiple runs happened in the comparison window), not a within-run completeness failure.size < positionalone is NOT a reliable truncation signal -- confirmed empirically that an in-placeO_TRUNCrewrite doesn't change the inode, and if the new content regrows to match or exceed the old position before the next poll, a size-only check misses the truncation entirely. The consequence isn't just a missed gap --Tailerwould silently read starting at the stalepositioninto unrelated new content and produce a plausible-looking but wrong line, indistinguishable from a genuinely torn line in the source file. This was directly responsible for at least some of the "torn line" artifacts seen in early manual investigation of the raw log file, before this existed to catch it. Fixed by_captureFingerprint()/_wasRewritten(): the lastFINGERPRINT_LEN(64) bytes ending atpositionare re-verified against disk on every poll regardless of size, so a same-size or larger rewrite is still caught. If you ever see aTailerfor a different truncating file behave oddly, check this first before assuming the source file is corrupt.Taileralso tolerates the compare file not existing yet (e.g. a plugin that hasn't logged anything this session).- Press
cwhile running live to reset all accumulated stats/pending state without restarting the process, so a comparison window can be scoped to exactly one triggered action.
- Matching is by