A live, filterable viewer for NotePlan's plugin log output.
NotePlan writes every console.log from a plugin (and from plugin WebViews) into a rolling
log file inside its app container (in addition to NotePlan's own log).
nplog is a terminal-based program that tails that file, strips it down to just the plugin
output, and puts a regex filter box on screen that you can retype at any moment without
restarting anything.
It also has a headless --json mode for scripts
and AI agents. The canonical agent skill is
agents/skills/nplog/SKILL.md (see also root
AGENTS.md), with symlinks under .claude/skills/ and .cursor/skills/
so agents use the tool rather than grepping the log by hand.
- It follows log rotation. NotePlan starts a brand-new timestamped file
(
co.noteplan.NotePlan3 2026-07-28--20-41-00-962.log) every time it relaunches. A plaintail -fstays pinned to the old file and silently goes quiet.nplognotices the newer file, prints a--- switching to … ---marker, and keeps going. - You can change the filter without losing history.
grepdecides once, at the moment the line streams past.nplogkeeps the lines in memory, so retyping the filter re-searches everything it has already read. - Context modes. Log lines are rarely useful alone — you usually want the few lines that came after the one that matched.
- Pretty-printed objects stay intact.
console.logof an object spans a dozen physical log lines;grephands you one orphaned field.nplogtreats the whole object as a single entry — see Multi-line objects. - Runs are visually separated. Output arrives in bursts; a rule marks where each batch of work begins so you're not hunting for the boundary — see Run separators.
Node.js (any recent version). No npm packages — it is a single dependency-free script.
Works in any terminal emulator, including iTerm2. It only uses the universally-supported
ANSI/VT100 basics — alternate screen (?1049h), cursor positioning, erase-line, reverse
video, dim, and SGR colour (256-colour for the WARN orange) — plus Node's own raw-mode key
handling, which is
terminal-agnostic.
Verified on macOS Terminal.app and iTerm2. It should be equally happy in Ghostty, kitty, WezTerm, Alacritty, Warp, and the VS Code / Zed integrated terminals.
Two deliberate choices help here: every binding is a control key, and the help line collapses to a shorter form on narrower windows rather than wrapping.
From the root of this repo:
./scripts/nplog/install.shThat symlinks nplog into ~/.local/bin so it will run from your shell.
Pass a different directory to override the location, e.g.
./scripts/nplog/install.sh /usr/local/bin.
Because it's a symlink, a later git pull updates the tool you actually run — there's no
need to re-install. If ~/.local/bin isn't on your PATH, the installer tells you what to add
to your shell profile. It also detects (or asks, once) which NotePlan install you have —
needed for --plugin — and remembers the answer in
~/.config/nplog/config.json.
You can also skip installing and just run it in place: node scripts/nplog/nplog.
Want to see it without waiting for NotePlan to log something? A sample log is committed alongside it:
nplog --file scripts/nplog/sample.logCanonical skill: agents/skills/nplog/SKILL.md.
Hub for all agent docs: AGENTS.md.
Claude Code / Cursor in this repo: each tool folder has a SKILL.md file
symlink to agents/skills/.../SKILL.md (e.g. .claude/skills/nplog/SKILL.md).
Do not replace those with directory symlinks -- git cannot stage paths through
a directory symlink.
To make the Claude skill available in every directory, not just this repo:
mkdir -p ~/.claude/skills
ln -s "$PWD/agents/skills/nplog" ~/.claude/skills/nplogA symlink rather than a copy, so git pull keeps it current. The skill invokes
node scripts/nplog/nplog, which assumes the repo root is the working directory; if you
link it globally, run scripts/nplog/install.sh too so a bare nplog is on PATH.
Other AI tools (Windsurf, plain API agents): point them at
agents/skills/nplog/SKILL.md or at the Headless mode
section below -- the CLI itself is tool-agnostic.
nplog # follow the newest log
nplog dashboard # start with a filter already applied
nplog 'Section|Perspective' # regex alternation
nplog --mode 5 # start in "match + next 5 lines" mode
nplog --show-time # start with per-line timestamps shown (hidden by default)
nplog --idle-gap 10 # only rule off lulls of 10s+ (0 = never)
nplog --no-separators # no run/idle rules at all
nplog --raw # keep non-JSLog lines (NotePlan's own native logging)
nplog --file "/path/to/some.log" # pin one file instead of auto-following
nplog --plugin jgclark.Dashboard # fastest data for one plugin -- see belowEverything is a control key, because compact Mac keyboards have no PgUp / PgDn / Home / End.
| Key | Action |
|---|---|
| (type anything) | edit the filter — applies live |
← / → |
jump to the previous / next run boundary, putting it at the top of the page — see Jumping between runs |
Tab |
cycle context: match only → match+1 lines → +5 → +10 → back |
^T |
show / hide timestamps |
^G |
show / hide the run + idle rules |
^U |
clear the filter text |
^L |
clear the display (drop buffer history, keep following, start fresh) |
^B / ^F |
page back / forward |
^P / ^N |
one line previous / next |
^A / ^E |
jump to top / end (^E also resumes following) |
^C |
quit |
The bottom line names each control, e.g. TAB: context (match+N lines); the status bar above
it shows the setting currently in effect.
If you do have a full-size keyboard (or use Fn+arrow), PgUp, PgDn, Home, End and the
arrow keys work too.
filter (^U to clear): getRemindersGenerated|refreshSomeSections re ctx:match only 52/8791 (416 rows) FOLLOW
The (^U to clear) hint is always there, filter typed or not — that's also why it isn't repeated
in the bottom help line.
In --plugin mode the plugin ID sits immediately left
of the filter, so the narrowed scope stays visible at all times:
jgclark.Dashboard filter (^U to clear): Reminder re ctx:match only 12/318 FOLLOW
re— the filter compiled as a regex. If it can't (say you've typedSection(and haven't closed the paren yet) this readsLITERAL(bad re)and the text is matched literally instead. It never errors out mid-typing.no-time— appears whenever timestamps are hidden, which is the default (^Tor--show-timeto reveal them).ctx:match only— current context mode.52/8791— matching entries shown / total entries in the buffer.(416 rows)— appears only when those entries occupy more screen rows than there are entries, i.e. when some match is a pretty-printed object. This is whymatch onlycan still fill the screen: one matching entry can be a 12-line object. See Multi-line objects.FOLLOW/SCROLL— whether new output auto-scrolls. Scrolling up switches toSCROLL;^Ereturns toFOLLOW.
Matches are highlighted, and ┈┈┈ rules mark places where lines were skipped between
non-adjacent matches.
The same prefix repeats on every line and crowds out the message you're reading. The timestamp is hidden by default (see Run separators below for why that's safe to do) rather than just dimmed — the other two repeating bits stay visible but dimmed, still there when you want them, but out of the way:
- the routine severity markers —
| DEBUG |,| INFO | - the source tag —
[WebView]
| DEBUG | refreshSomeSections :: Starting for TB
With ^T (or --show-time) the timestamp comes back, dimmed the same way:
2026-07-29 15:09:36 | DEBUG | refreshSomeSections :: Starting for TB
└────── dimmed ─────┘ └── your message, full brightness ──┘
WARN and ERROR are the exception — they're the one part of the prefix that is signal, so
they're coloured (orange and red) instead of dimmed. They're deliberately muted rather than
bold: enough to catch your eye while scanning, without shouting on every other line.
Note these two use emoji delimiters rather than pipes — see LOG_LEVEL_STRINGS in
helpers/dev.js, which is the source of truth for all four:
2026-07-27 18:34:37 🥺 WARN 🥺 sendToHTMLWindow :: Window does not exist <- orange
[WebView Error] 2026-07-24 08:06:37 ❗️ ERROR ❗️ No para/content in item <- red
The [WebView Error] tag is coloured red too, rather than dimmed like the plain [WebView]
tag. NotePlan's own native ERROR: lines (visible only with --raw) are also picked up.
Two deliberate exceptions:
- Filter matches always win. A hit is highlighted even where it lands inside dimmed text,
so searching
DEBUGstill shows you where it matched. - Object bodies are never dimmed. The lines of a multi-line object are data, so they render at full brightness even when a value happens to look like a timestamp.
Filtering always runs against the full line, so you can still search for a time or a severity even while they're hidden or dimmed.
Plugin output arrives in bursts. Finding where one batch of work ended and the next began means
scanning timestamps by eye, so nplog draws a rule at each boundary:
setPluginData :: Sending changeMessage: "Finished ..."
───────────────────────────── 2026-07-29 09:14:57 47s idle Executing function 'onMessageFromHTMLView' ─────────────
routeRequestsFromReact received actionType="refreshEnabled..."
This is why timestamps can be hidden by default. Each rule leads with a
YYYY-MM-DD HH:MM:SS clock — the moment the boundary was crossed — so scrolling past the
separators gives you a bird's-eye sense of where you are in the log time-wise without needing a
timestamp on every single line. ^T (or --show-time) brings the per-line timestamps back if
you need exact times on entries between separators.
Two things trigger a rule:
- A run start. NotePlan logs
Executing function 'name'when it invokes a plugin entry point. That line becomes the rule rather than sitting beneath it, so the boundary costs one row instead of two. - A lull. When the log goes quiet for
--idle-gapseconds (default 3) the rule reports how long. Useful because plenty of runs are triggered by timers or refreshes and never log anExecuting functionline at all — on a real day's log, lulls catch a few hundred boundaries that the run marker alone misses.
When a lull and a run coincide they merge into the one rule, as above. The label starts in the
same column as your message text, so the rules line up with the log rather than cutting across
it. ^G toggles them off; --idle-gap 0 keeps run rules but drops lull rules.
Rules also survive filtering, which is when they earn their keep — with a filter applied you're looking at scattered matches, and the rule tells you which run each group came from. A boundary hidden by the filter still surfaces: the rule is drawn for the whole stretch of log between two visible entries, so it reports real idle time rather than time the filter hid.
← and → jump to the previous / next run boundary and put it at the top of the page, so
you can read forward from where the work actually started. This is usually what you want when
you're asking "where did I kick this off?" — and it beats filtering, because you often don't
yet know what to filter for.
The footer shows where you landed:
run 7/12 2026-07-30 16:03:00 47s idle Executing function 'onMessageFromHTMLView'
← goes back in time, → forward. Both stop at the ends rather than wrapping, and say so.
Only the run and idle rules count as boundaries — plus the
nplog started watching here rule in --plugin mode, so there's always somewhere to land even
before the first reset. A bare --plugin reset notice deliberately does not count on its
own: NotePlan rewriting _MCP-console.log is a file-truncation artifact that doesn't reliably
line up with a new run. When a reset does coincide with a real idle gap — which is the normal
case when you trigger a command — the two merge into one navigable rule carrying both facts:
───── _MCP-console.log was reset by NotePlan (new run) 2026-07-30 17:40:36 1h40m idle ─────
A boundary inside the final page can't literally reach the top — there aren't enough rows below
it — so the view stops at the bottom while the footer still names the boundary you asked for.
Pressing ← again continues from that boundary rather than from where the screen stopped.
When a plugin pretty-prints an object or array, NotePlan writes it across many physical log
lines — and only the first carries the JSLog: marker; the rest just repeat the timestamp:
2026-07-28 14:46:20 JSLog: 2026-07-28 14:46:20 | DEBUG | => Reminder: :: {
2026-07-28 14:46:20 "title": "You should cancel the auto renew on Medawar",
2026-07-28 14:46:20 "listname": "Reminders",
2026-07-28 14:46:20 }
nplog detects the unbalanced { (or [), and keeps absorbing following lines until the
brace balances — storing the whole thing as one entry:
2026-07-28 14:46:20 | DEBUG | => Reminder: :: {
"title": "You should cancel the auto renew on Medawar",
"listname": "Reminders",
}
The repeated timestamps on the continuation lines are dropped, and — since NotePlan flattens the original indentation — the body is re-indented by nesting depth, so it reads as real JSON again. Closing braces line up with their openers:
2026-07-29 14:40:33 | DEBUG | => Reminder: :: {
"title": "Send out the newsletter",
"occurences": {
"weekly": true,
"days": [
"mon",
"tue"
]
},
"time": "10:00"
}
Because it's one entry:
- Filtering on any field shows you the entire object, not a lone line.
listnamematching 178 objects reports178, not 178 fragments. - The
+1/+5/+10context modes count entries, so "+1" means the next log entry — not the next line of the object you're already looking at. - A regex can span the newlines (
.is dotAll here), soReminder.*listnamematches.
Robustness details, all verified against real logs:
- Brace counting ignores braces inside string values, so
"tricky {"or a value of"]"can't throw off the count. - A new
<timestamp> JSLog:line always ends the block, so a truncated or malformed object can't swallow the rest of the log. - Blocks render as they stream — you see the object growing, rather than waiting for the closing brace.
- A block left open when the log rotates is closed at the switch.
- Sometimes the
{lands on the line after theJSLog:header; that fragment starts its own block rather than being dropped.
Everything above is the interactive viewer. --json turns the same parser into a
one-shot command that prints NDJSON and exits — no TTY needed, no ANSI codes, nothing
to scrape.
nplog --json --last-run # the most recent plugin invocation
nplog --since 10m --json # the last 10 minutes
nplog --since 10m --json --min-level warnExit code 1 if any emitted entry was an ERROR. That alone answers "did it work?" without reading a single line.
An x-callback returns immediately while the plugin runs asynchronously, so reading the log straight after firing one shows nothing. Bracket the action instead:
CURSOR=$(nplog --mark)
open "noteplan://x-callback-url/runPlugin?pluginID=jgclark.Dashboard&command=Show%20Dashboard"
nplog --since "$CURSOR" --follow --wait-idle 5 --jsonYou cannot know in advance how long a command takes — a Dashboard refresh ping-pongs asynchronously between plugin and WebView for anything from two to thirty seconds. So don't guess a duration:
--followstreams entries as they arrive, so you see progress instead of waiting blind.--wait-idle 5stops once the log has been quiet for five seconds. A run still in flight writes something within five seconds, so silence that long means it finished.--timeout(default 90s) is the hard cap.
Drop --follow if you only want the final batch in one shot.
The log flushes in batches, so don't set a short timeout. Measured on a live system: after an action the file sat untouched for 24 seconds, then gained 80 lines at once. Historically the gap between an event and its flush has been as much as two hours.
--wait-idletherefore never treats "quiet" as "finished" until it has actually seen output, and the summary reportssawOutput/timedOutso you can tell "the action logged nothing" from "I looked too early".
| Form | Example | Meaning |
|---|---|---|
| cursor | "…962.log:11993599" |
byte-exact, from --mark |
| duration | 10m, 30s, 2h, 10 |
back from now (bare number = minutes) |
| wall clock | 19:36, 19:36:56, "2026-07-29 19:36" |
local time; a future time means yesterday |
Durations and times filter on each entry's own timestamp — the real event time, not the flush time (see the two-timestamp trap).
One JSON object per entry, then a summary:
{"seq":0,"ts":"2026-07-29T09:14:59-07:00","level":"error","source":"plugin","run":"onMessageFromHTMLView","text":"…"}
{"summary":true,"file":"…","emitted":6,"entriesScanned":17,"droppedByMaxEntries":0,"cursor":"…","hasError":true}level—debug/info/warn/error/nullsource—pluginorwebviewrun— whichExecuting functioninvocation this entry belongs to, so an error can be attributed to the call that caused ittext— the entire entry; a pretty-printed object arrives whole, newlines and all. Width clipping is a display concern and does not apply herecursor— pass to the next--sincedroppedByMaxEntries— truncation is always reported, never silent
--mode N works here too, so you can pull a match plus the next N entries:
nplog --json --mode 10 '\[DIAG\]' # the DIAG line and what happened after itThree traps, each returning empty — which reads as "no errors" and is worse than a crash:
| Naive attempt | What happens |
|---|---|
grep '^JSLog:' |
zero matches; the timestamp comes first |
grep '| ERROR |' |
zero matches; WARN/ERROR use emoji delimiters |
| filter on the leading timestamp | wrong on ~2/3 of lines, by up to hours |
There is a second, tempting source: <Plugins>/<id>/_MCP-console.log, which the NotePlan
MCP's noteplan_plugins action:"log" reads. It is already scoped to one plugin and
already has the JSLog: marker stripped. nplog does not use it by default.
Measured on a live system:
| main log | _MCP-console.log |
|
|---|---|---|
| retention | append-only, whole session | truncated on every plugin invocation |
| completeness (same second) | 66 unique lines | 32 — a subset, nothing it had was missing here |
| promptness | lags, flushes in batches | written immediately |
The truncation is disqualifying as a default: Dashboard refreshes on a timer, so the run you care about is wiped seconds later by an unrelated background refresh. Watching one file through three invocations, it went 9 lines → 20,982 bytes → 5,314 bytes — it shrank.
The main log is a strict superset and the only durable record, so that is what nplog reads
by default. Its one weakness — the flush lag — is handled by --wait-idle refusing to call
an untouched file "finished". When speed matters more than durability, opt in explicitly
with --plugin below.
Agents working in this repo get this as a skill — see
agents/skills/nplog/SKILL.md.
nplog --plugin jgclark.DashboardTails that plugin's _MCP-console.log instead of the main log — same interactive viewer,
filters, context modes, and --json headless mode, just a different, much lower-latency
source. In practice it wins the race against the main log's flush lag essentially every time
(see the investigation in AGENTS.md if you want the numbers).
Repeat --plugin to watch several at once, threaded together by timestamp:
nplog --plugin jgclark.Dashboard --plugin np.SharedEvery line gets a dimmed [PluginTag] prefix so you can tell the sources apart, and the status
bar lists them all. This is how you follow a flow that crosses plugins — a Dashboard action that
goes through np.Shared's bridge, say — without giving up the low latency.
[Shared] 2026-07-30 16:00:01 | DEBUG | bridge::runPluginCommand …
[Dashboard] 2026-07-30 16:00:01 | DEBUG | routeRequestsFromReact …
A multi-line object from one plugin stays intact even if the other logs into the middle of it — each source is parsed independently and only merged once its entry is assembled. Equal timestamps keep arrival order (MCP stamps are whole seconds, so ties are the norm), and nplog's own rules act as anchors that nothing gets reordered across.
One --plugin behaves exactly as before: no tag prefix, no merging.
The tradeoff from the table above still applies: _MCP-console.log only ever holds the
current run — NotePlan truncates and rewrites it on every invocation of that plugin.
nplog keeps streaming right through a reset (no restart needed) and drops a marker in the
buffer when it happens:
--- _MCP-console.log was reset by NotePlan (new run) ---
If you see that a lot, it just means the plugin runs often (a timer-driven refresh, say) —
not that anything is wrong. If you need the complete history across multiple runs rather
than just the fastest view of the current one, use the default main-log mode instead, or see
utils/log-timing.js for comparing the two.
Run separators are idle-only in this mode. The named-run rules you get on the main log —
Executing function 'onMessageFromHTMLView' — are NotePlan's own notice that it invoked a
plugin entry point, not something the plugin's console.log produced, so they never reach
_MCP-console.log (measured: 33 in the main log over the same window, 0 here). You still get
idle-lull rules, just not ones labelled with the command that started the
work. If you specifically need to see which command kicked off a run, use the main log.
First-time setup: --plugin needs to know where NotePlan's container lives, which
differs between the App Store and Setapp builds. ./install.sh detects this automatically,
or asks once if it can't tell, and remembers the answer in ~/.config/nplog/config.json. Set
NPLOG_APP_SUPPORT_DIR yourself to override it (or skip install.sh and let auto-detection
run every time).
What you see on startup. The file already holds the tail of a run that began before nplog did, so that seeded content is book-ended to make it legible rather than looking like a normal complete run:
───── start of retained output -- NotePlan overwrites this file each run, so nothing earlier survives ─────
… whatever was already in the file …
───── nplog started watching here -- 2026-07-30 17:40:31 ─────
The second rule is a jump target, so ← gets you to "only what I
actually watched" straight away.
If the plugin hasn't logged anything yet this session, nplog warns rather than erroring,
and keeps watching for the file to appear:
nplog: /Users/you/Library/.../Plugins/some.plugin/_MCP-console.log
doesn't exist yet.
'some.plugin' may not have logged anything this NotePlan session yet, ...
The filter is a case-insensitive JavaScript regular expression, so:
| Pattern | Finds |
|---|---|
dashboard |
plain substring |
Section|Perspective |
either word |
^\[WebView\] |
only WebView lines |
ERROR|WARN |
problems only |
refresh.*Sections |
wildcards between terms |
:: \d+ items |
digits |
Anything that isn't valid regex is matched literally, so you can paste an arbitrary log
fragment (sections [TB) without escaping it.
~/Library/Containers/co.noteplan.NotePlan3/Data/Library/Application Support/co.noteplan.NotePlan3/Logs/
Files are named co.noteplan.NotePlan3 <timestamp>.log; beta builds may nest a second
Logs/ directory inside that one, and both are searched. If you have override the location with the
NPLOG_DIR environment variable, e.g. if you use Setapp.
NPLOG_DIR=/some/other/Logs nplog
e.g.
NPLOG_DIR=~/Library/Containers/co.noteplan.NotePlan-setapp/Data/Library/Application nplogNotePlan tags plugin output with a JSLog: marker (basically all the plugin console contents)
2026-07-29 11:34:54 JSLog: [WebView Log] 2026-07-29 11:34:54 | DEBUG | Webview :: sendActionToPlugin
nplog keeps only those lines (plus the continuation lines of a multi-line object), drops
everything up to and including the marker, and abbreviates [WebView Log] to [WebView].
Use --raw to see NotePlan's own non-plugin logging too.
A handful of lines are dropped entirely regardless of filter — known-noisy messages that carry
no signal on any viewing (e.g. Skipping plugin command 'unknown': ..., which NotePlan logs
constantly and harmlessly). The list lives in noise-exclusions.js; add a
regex there for anything else that just buries real output.
Gotcha if you write your own filter: don't anchor on
^JSLog:. NotePlan added the leading2026-07-29 11:34:54timestamp at some point, and that anchor silently stopped matching — the originalnoteplan_log_filtered.shemitted nothing for months rather than erroring. Strip the timestamp first, then anchor onJSLog:.
See CHANGELOG.md for release notes. Check your installed version with
nplog --version.
- Runs on the terminal's alternate screen, so it never pollutes your real scrollback, and quitting leaves the terminal exactly as it was.
- Holds the most recent 50,000 entries in memory (
MAX_LINESin the script) — a multi-line object counts as one. On startup it seeds from the last 2 MB of the current file so there's immediate history to search. - Polls once a second for new bytes and for rotation.
- A block that never closes is capped at 500 continuation lines (
MAX_CONT_LINES).
nplog is most useful in its own terminal tab, next to npc plugin:dev and your test watcher —
you edit, the plugin rebuilds, and you watch the log react.
If you drive your dev environment from a personal .hyperlayout file (gitignored, so yours is
your own), give it a dedicated tab rather than a narrow pane — the filter bar and context modes
want the width:
"dashboard": [
[ "npc plugin:dev jgclark.Dashboard -nc" ],
[ "npm run test:watch" ],
[ "nplog" ]
]