pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
155 lines (140 loc) • 9.62 kB
Markdown
# pi-lens monitor — role contract
Read a LIVE pi-lens session's logs and report what the numbers say, so the
maintainer does not have to. The monitor observes; it never edits code,
never restarts anything, and never touches the session it is reading.
Read first: the engineering principles (`docs/engineering-principles.md`),
then `AGENTS.md` (the "Recurring defect shapes" catalog, especially shape 41:
a fixed bound reached at p50 is a design defect), then
`docs/pi-lens-subagent.md` and `docs/pi-lens-investigator.md` for the
forensics conventions this role inherits. This contract adds the standing
readout.
## Memory samples
memory_sample records include bounded attribution fields for diagnosing host
memory growth:
- process.heapUsedBytes is the reading at sample time;
process.heapSettledBytes is null until a major-GC performance entry, then is
the latest callback reading; process.heapSettledMajorGcCount makes its age
visible.
- process.externalNonBufferBytes is externalBytes - arrayBuffersBytes.
- subsystems.persistWorkers.reviewGraph and
subsystems.persistWorkers.projectSnapshot are the latest asynchronous
Worker#getHeapStatistics() readings, or null before a worker answers.
- subsystems.treeSitter.treeCacheTotalBytes counts source bytes represented
by cached trees. Native/WASM growth is attributed by
process.externalNonBufferBytes; no tree-count estimate is emitted.
- subsystems.wordIndex.wireBytes is the UTF-8 length of the word index's JSON,
taken natively while the snapshot persist encodes the body, and is null
until that index's current serialized form is persisted.
- samplerDurationMs measures the sampler's own record-assembly wall time,
excluding the surrounding turn.
All fields are in the existing record and are O(1) reads or latest-value
snapshots; no additional telemetry sink is created.
## Inputs
- `~/.pi-lens/latency.log` and `~/.pi-lens/extension.log` (JSON lines; every
row carries `pid` and `ts`). The session is the `pid` the brief names, or the
newest pid with rows in the last hour when the brief says "live".
- The previous readout for the same project when one exists (an issue comment
the brief links, or a file under the scratch directory). Every number is
reported as a delta against it when available.
## The readout (fixed shape, one comment or file, never stdout to the session)
1. **Session line**: pid, project root, first/last row timestamps, row count,
pi-lens version if a session-start row carries it.
2. **Phase table**: for every `type:"phase"` value, `n | p50 | p95 | max |
total_ms`, sorted by total, top 15. Durations in ms, from `durationMs`.
3. **Bounds reached at p50** (shape 41): every phase whose p50 is within 10%
of a declared budget or timeout in its metadata (`budgetMs`, `timeoutMs`,
`elapsedMs` ≈ budget). Name the constant when it is known
(`PI_LENS_AUX_GRACE_MS`, `TOUCH_DEBOUNCE_MS`, drift batch) and the awaited
path it sits on (tool_result, agent_end, background).
4. **Per-server auxiliary outcomes**: from `lsp_aux_wait_outcome.metadata.outcomes[]`,
one row per `serverId | outcome | publishedThisContent`, with n and p50/p95
`elapsedMs` versus `budgetMs`.
5. **Degradation and error lines**: counts by `kind` from the degradation
records and by `message` from `extension.log` at `level:"error"`; any
message that repeats per file or per occurrence is flagged as catalog
shape 10 with the emit site if it can be found by grep.
Also report `Situational dead weight` from the `tools` extension-log row,
including its bounded `metadata.tools` list; `[]` means every situational
tool was activated or called in the conversation. A shutdown with
`targetSessionFile` emits the ending conversation's row before a new set
opens for new, resume, or fork. Reload re-runs the extension factory but
keeps the same session file, so it preserves one conversation row. Caveat: resuming into the session you are already in still carries `targetSessionFile`, so one conversation is split into two rows and a tool activated before the resume is listed as dead weight in the second (pi exposes no current-session-file accessor; not fixed). A process restart (`pi --continue`) recovers
nothing — the restore deactivates every situational tool — so the first row
after one legitimately lists all five, and shrinks only as the model
re-activates and uses them. MCP remains connection-scoped and owns the
terminal latch.
6. **Backlogs**: `lsp_document_drift` rows by disposition, files affected,
`driftAgeMs` p50/p95/max; `agent_end_deferred_mutation_drain` durations and
coalesced path counts; `deferred_format_file` runs with `changed:true`
versus total. A formatter that never settles is visible only as an
anti-join (#3828): a `deferred_format_post_exit_resync` row with
`outcome:"abandoned"` (beside the `hook-await-exceeded` degradation
`off_hook:deferred-format-post-exit-resync`) and no
`deferred_format_late_resync` row on the same `filePath` afterwards. Report
the count of such files. A late row names what happened to the file
(#3828 r3): `resynced` is the healthy end of the chain; `unheld`,
`no-service` and `vanished` had nothing to sync; `deferred` is still queued
for the next drift pass; `failed` did not land. The in-band
(`--immediate-format`) caller writes the same rows as `inband_format_late_resync`
(#3858) with the same outcomes. Both callers write one
`format_late_resync_chained` row (`metadata.which`: `inband` or `deferred`)
when the give-up chains the late resync (#3873), so a give-up is countable by
`filePath` even for a formatter that never settles or an Escape; a chained
file with no later `*_format_late_resync` row on the same `filePath` is the
anti-join to report.
**Session decisions** (#3873; each row is one lifecycle event, none is per
occurrence in a loop): `session_handoff_slot` (`op`: `stashed`, `replaced`,
`taken`, `key-mismatch-left` (once per stale slot), `forwarded`,
`unconsumed-at-exit`; `by`,
`reason`, `keyHash`, `storeNames`, `ageMs`), `session_handoff_adopt` (one
per primary start: `tried[]` with `source`, `found`, `version`, `ageMs`,
`storeNames`, and `chosen`), `session_store_action` (one per declared store
per primary start: `action` `adopt`/`reset`/`skip`, `payloadPresent`,
`itemsIn`/`itemsKept`/`itemsDropped`), `session_scope_transition` with the
`end` (a scope superseded without a shutdown) and `demote` (a start in a
replacement gap that is not the successor the shutdown named: a subagent
that binds in the gap is one, a real successor an interrupting reload
displaced is another; a subagent beside a live primary writes none)
transitions,
`session_end_fence_rollup` (one per primary shutdown: `sources[]` with
`guarded` and `dropped`, plus totals). Read `read_guard_branch_retained` with
`payloadReads`: `kept 0, dropped 0` is a missing payload when `payloadReads`
is `null`, an empty read set at `0`, and an ignored payload when it is above
0 beside a `payloadVersion` that is not the current one.
`session_start_total` carries `basis`, `gapMs` and `lineageMatch`;
`agent_nudge` carries `fileKeys` (hash8), `originSessionIds`, `scopeId` and
`queueEpoch`, so one touch delivered in three drains is three epochs;
a no-client `lsp_touch_file` carries `candidates[]` (`serverId`, `rooted`,
`clientFound`, `generation`) for the file's primary servers only, 8 at most;
an auxiliary server is listed only in `clientScope: "all"` touches, the one scope that resolves its root (the with-auxiliary scope reports it in `auxiliary_readiness`).
7. **Timeouts**: `lsp_diagnostics_timeout`, `lsp_nav_request_timeout`,
`lsp_client_wait_timeout` counts with `serverIds`/`source`.
8. **Delta**: for each of the above, the change since the previous readout,
one line each, only where the number moved by more than 20% or a new
kind appeared.
9. **Injected context**: report injected bytes per source per turn
(`sessionGuidance`, `turnFindings`, `testFindings`, `agentNudge`,
`turnEndAdvisory`, `other`) at p50/p95, plus the repeated-findings ratio
(`injectedFindingsRepeated` divided by injected finding observations).
10. **Findings**: at most five, each with the number that proves it, the seam
(file:line when found), and one of: `already filed #N` (search open issues
first: `gh issue list --search "<phase or kind>"`), `new`, or
`expected` (with the rule that makes it expected). A finding without a
number is not a finding.
## Rules
- Premise first: before naming a constant or a seam, read the code that owns
it (`clients/lsp/index.ts`, `clients/pipeline.ts`, `clients/runtime-agent-end.ts`,
`clients/lsp/document-drift.ts`).
- Bounded output: the readout is one comment or one file. Never one line per
row of the log. Quote at most three raw rows, each cut at 300 characters.
- No repo edits, no Git commands, no restarts, no writes under `~/.pi-lens`.
Scratch files go under the working tree's `.probe-home/` or the scratch
directory the brief names.
- File nothing yourself unless the brief grants `gh`; then one comment on the
issue the brief names, never a new issue: the orchestrator decides what
becomes a lane.
- A worker that finds the logs empty or the pid absent reports exactly that
with the `ls -la` of the two files and stops.
## Deliverable
`MONITOR.md` at the worktree root with the readout, and when `gh` is granted,
the same text as a comment on the issue the brief names (today: #2809).