UNPKG

pi-lens

Version:

Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo

282 lines (245 loc) • 20.4 kB
# Subagent-extension compatibility (#476) pi-lens's subagent-compatibility features — #475 (subagent light mode), #474 (instance registry + orphan LSP reaper), and #473 (concurrent-session guard) — were all built on **reverse-engineered facts** about two third-party pi extensions and the pi SDK itself. Nobody has promised us these stay true across their releases. This doc records exactly what we depend on, where, and how the nightly `compat-smoke` workflow (`.github/workflows/compat-smoke.yml`) verifies it. See issue #476 for the design rationale. ## Pinned contracts Versions below are what was installed and verified while building this smoke (2026-07). Re-verify against current versions any time the nightly alerts — `scripts/compat-contracts.mjs` prints the versions it actually installed on every run. | # | Contract | Depended on by | Third-party file (as of the versions below) | Verified against | |---|----------|-----------------|-------------------------------------------------|-------------------| | 1 | `PI_SUBAGENT_CHILD` is set to the literal string `"1"` in every spawned child's env; `PI_SUBAGENT_RUN_ID` / `PI_SUBAGENT_CHILD_AGENT` are set alongside it for best-effort identity. | `clients/subagent-mode.ts` (`isSubagentSession()`, `getSubagentIdentity()`) | `pi-subagents@0.34.0` — `src/runs/shared/pi-args.ts` (`SUBAGENT_CHILD_ENV`/`SUBAGENT_RUN_ID_ENV`/`SUBAGENT_CHILD_AGENT_ENV` consts + the `env[SUBAGENT_CHILD_ENV] = "1"` assignment) | `checkNicobailonChildEnv` | | 1b | avtc-pi-subagent sets `PI_SUBAGENT_CHILD_AGENT` + `PI_SUBAGENT_PARENT_PID` (never `PI_SUBAGENT_CHILD`) on the per-spawn subagent env; `isSubagentSession()` treats the PAIR (both non-empty) as an additional subagent signal (#507). | `clients/subagent-mode.ts` (`isSubagentSession()`, `getSubagentIdentity()`) | `avtc-pi-subagent@1.0.3` — `src/process-runner.ts` (`subagentEnv.PI_SUBAGENT_CHILD_AGENT = agent.name` + `subagentEnv.PI_SUBAGENT_PARENT_PID = String(process.pid)`) | `checkAvtcChildEnv` | | 2a | The pi SDK's extension loader keeps a **process-global** cache (`extensionCache = new Map()`). This is what makes an in-process `bindExtensions()` reuse pi-lens's own module-scope singletons instead of a fresh isolated instance. | `clients/session-lifecycle.ts` (the whole premise of the concurrent-session guard) | `@earendil-works/pi-coding-agent@0.80.6` — `dist/core/extensions/loader.js` | `checkSdkExtensionCache` | | 2b | `AgentSession.bindExtensions()` **unconditionally** emits a `session_start`-typed event (`this._extensionRunner.emit(this._sessionStartEvent)`). | Same as 2a — this is why an in-process subagent bind re-triggers pi-lens's `session_start` handler at all. | `@earendil-works/pi-coding-agent@0.80.6` — `dist/core/agent-session.js` (`bindExtensions()`, ~line 1717) | `checkSdkBindExtensionsEmitsSessionStart` | | 2c | `_extensionRunner.invalidate(...)` is called from the sequential session-replacement path (`newSession`/`fork`/`switchSession`/`reload`'s dispose route), never from a concurrent sibling bind. | `clients/session-lifecycle.ts` (`probeCtxActive()` — the asymmetry this whole guard relies on) | `@earendil-works/pi-coding-agent@0.80.6` — `dist/core/agent-session.js` (~line 551) | `checkSdkInvalidateCalled` | | 2d | The stale-ctx error thrown by an invalidated context's accessors contains the literal fragment `"stale after session replacement"`. | `clients/session-lifecycle.ts` (`probeCtxActive()` matches on this exact fragment) | `@earendil-works/pi-coding-agent@0.80.6` — `dist/core/agent-session.js` (the `invalidate(...)` message string) | `checkSdkStaleCtxMessage` | | 3 | tintinweb's subagent runner constructs a `DefaultResourceLoader` and calls `session.bindExtensions(...)` on a freshly created `AgentSession`, **inside the same Node process** as the parent pi session. | `clients/session-lifecycle.ts` (the concurrent-secondary case #473 exists to protect against) | `@tintinweb/pi-subagents@0.13.0` — `src/agent-runner.ts` (`new DefaultResourceLoader({...})` ~line 433, `await session.bindExtensions({...})` ~line 597) | `checkTintinwebInProcessBind` | All seven checks live in `scripts/lib/compat-contracts.mjs` as pure, unit-tested regex matchers against RESILIENT semantic shapes (never a line number — those drift on every third-party release). `scripts/compat-contracts.mjs` is the orchestration script: it `npm install`s the four packages (SDK, `pi-subagents`, `avtc-pi-subagent`, `@tintinweb/pi-subagents`) into a scratch directory, reads the specific files above, and runs every check. ## The three env levers | Env var | Default | Effect | |---------|---------|--------| | `PI_LENS_SUBAGENT_FULL` | unset (light mode auto-detects) | Set to `1` to force full (non-light) behavior even inside a detected subagent child session — nicobailon/pi-subagents (`PI_SUBAGENT_CHILD=1`) or avtc-pi-subagent (`PI_SUBAGENT_CHILD_AGENT` + `PI_SUBAGENT_PARENT_PID` pair, #507) — disables the light-mode heavyweight-scan skip for either vocabulary. | | `PI_LENS_CONCURRENT_SESSION_GUARD` | unset (guard enabled) | Set to `0` to disable the #473 concurrent-session guard entirely — every `session_start` classifies as sequential replacement (pre-#473 behavior: a concurrent in-process bind would run the full reset, tearing down the parent's live LSP fleet). | | `PI_LENS_INSTANCE_REGISTRY` | unset (registry enabled) | Set to `0` to disable the #474 cross-process instance registry — no orphan-LSP reaping happens at `session_start`, but also no new risk (registry writes are best-effort and already fail open). | ## What each smoke layer asserts ### Layer A — pinned-contract verification (`scripts/compat-contracts.mjs`) No `pi` process, no LLM turn. Installs the real packages (table above) and mechanically re-checks all six contracts against the installed source. Exit 0 = all pass; exit 1 = at least one contract check failed (real drift); exit 2 = infrastructure failure (npm install of the third-party packages itself failed — usually a registry/network issue, not a contract regression). ### Layer B — real-pi behavioral smoke (`scripts/compat-smoke-behavioral.mjs`) Installs the packed pi-lens tarball into a real `pi` (the same "pi-load" mechanism `.github/workflows/install-smoke.yml` already uses) and drives `pi --mode rpc` so `session_start` fires and pi-lens loads — no LLM turn needed, matching `scripts/rpc-load-check.mjs`'s no-model-required design. **Every invocation sets `PI_LENS_STARTUP_MODE=full`.** pi-lens's cold-start-quick optimization (see AGENTS.md "Session-start critical path") forces the *first* `session_start` of any process onto the fast "quick" path regardless of `--print` — that path returns before the subagent-light -mode check (and the heavyweight-scan skip it gates) ever runs. This was confirmed empirically while building this smoke: without the override, the "quick mode active" line appears in `sessionstart.log` and no `subagent_light_mode` phase is ever logged, producing a false negative. With `PI_LENS_STARTUP_MODE=full`, the phase logs deterministically on the very first session. Assertions: 1. **Subagent light mode engages** — with `PI_SUBAGENT_CHILD=1` set, `subagent_light_mode` is logged to `~/.pi-lens/latency.log` (a `type: "phase"` entry) and none of the seven heavyweight-scan phases (`knip`/`jscpd`/`madge`/`dead-code`/`govulncheck`/`gitleaks`/`trivy`) are logged for that run. 2. **`PI_LENS_SUBAGENT_FULL=1` overrides it off** — with both `PI_SUBAGENT_CHILD=1` and `PI_LENS_SUBAGENT_FULL=1` set, no `subagent_light_mode` phase is logged for that run. 3. **Zero surviving LSP-server processes after a clean exit** — pi is asked to shut down gracefully (closing its RPC stdin, which pi's own RPC mode treats as a shutdown trigger — NOT a `SIGKILL`, which would skip `session_shutdown` and make this assertion meaningless). A grace period (a few seconds) is given for the async teardown (`session_shutdown` → LSP fast teardown → child `SIGTERM`) to complete, then the process table is diffed against a pre-run snapshot for any *new* process whose command line matches a narrow LSP-server marker list (`typescript-language-server`, `ast-grep lsp`, `pyright-langserver`, …, `scripts/lib/process-scan.mjs`). This is the #472 orphan class #474 fixed. 4. **avtc-pi-subagent PAIR engages light mode** (#507/#518) — with only `PI_SUBAGENT_CHILD_AGENT` + `PI_SUBAGENT_PARENT_PID` set (no `PI_SUBAGENT_CHILD`), `subagent_light_mode` is logged and none of the seven heavyweight-scan phases are — same detection as assertion 1, exercised through the second vocabulary. 5. **avtc-pi-subagent LONE var does NOT engage light mode** (#507/#518) — the inverse guard: with only `PI_SUBAGENT_CHILD_AGENT` set (no `PI_SUBAGENT_PARENT_PID`), no `subagent_light_mode` phase is logged. `subagent-mode.ts`'s doc comment requires the PAIR — a lone var set by some unrelated tool must not trigger light mode. 6. **`concurrent_session_bind` (#473) — NOT asserted, documented TODO.** The guard is fully wired on master (PR #477): `index.ts`'s `session_start` handler calls `decideSessionStart()` and logs a `concurrent_session_bind` latency phase for a concurrent-secondary bind — so the phase exists to observe. The blocker is DRIVING it keylessly: reproducing tintinweb's in-process model for real (mirroring `agent-runner.ts`'s `createAgentSession()` + `DefaultResourceLoader` + `bindExtensions()` sequence) requires full session construction, which in turn needs model/provider config — not cheaply stubbable without a real model key, and #476 explicitly asks not to ship something flaky here. The unit + behavioral coverage in `tests/clients/session-lifecycle.test.ts` guards the classifier and the no-reset contract in-repo; what Layer B cannot yet add is the end-to-end SDK-driven variant. **Revisit if the pi SDK grows a model-free session constructor or stub provider** — at that point add a Layer B assertion analogous to 1-3 checking the `concurrent_session_bind` phase and the absence of a second LSP fleet teardown. ## What to do when the nightly alerts `compat-smoke.yml` never reds itself on a contract/behavioral failure — both layers run under `continue-on-error: true`. Instead, a failure opens (or refreshes the body of) a single tracking issue titled **"compat-smoke: third-party contract drift detected"** — search for it by title before assuming a NEW investigation is needed; the workflow already create-or-updates it, never duplicates. 1. Read the linked run log — both layers print a `[PASS]`/`[FAIL]` line per check with a one-line detail on exactly what didn't match. 2. Find the failed check's row in the pinned-contracts table above and go read the current third-party source at the referenced file — a Layer A failure means the semantic shape genuinely changed upstream (a renamed env var, a moved `emit()` call, a reworded error message, ...). 3. Update the corresponding matcher in `scripts/lib/compat-contracts.mjs` (and its test in `tests/scripts/compat-contracts.test.ts`) to match the new shape, update the pinned-contracts table's version/line reference above, and fix whichever pi-lens module (`subagent-mode.ts` / `session-lifecycle.ts` / `instance-reaper.ts`) actually depended on the old shape if the drift broke real behavior — not just the check. 4. A Layer B failure (an assertion, not an infra error) means real pi-lens behavior regressed under a real `pi` — treat it like any other bug: write a fixture-level test if the gap wasn't otherwise covered, then fix. 5. Close the tracking issue once the underlying check is green again — it will reopen (well, get a fresh create — see the note in the issue body about not manually closing while still failing) if it recurs. ## Known-extension assessments (no contract pinned) Extensions assessed against the #473/#474/#475 interaction surfaces and found to need NO nightly contract check. Recorded so nobody re-derives this; re-assess if their execution model visibly changes. ### plannotator (`@plannotator/pi-extension`, assessed 2026-07-10 at ~v0.22) Browser-based plan/review UI; ~30k npm downloads/mo. **Benign coexistence.** Never binds an `AgentSession` in-process (#473 class N/A) and never spawns child `pi`s at session start (#475 class N/A). Namespaces fully disjoint (`PLANNOTATOR_*`, `~/.pi/plannotator*.json`). Its planning-phase `tool_call` blocking coexists additively with the read-guard (either may block; plan files are newly-created so never trip `zero_read`). Two second-order notes: (a) its own spawned agent jobs are cleaned up via `process.once("exit")` — its own #472-class orphan risk on hard kills, internal to plannotator (our reaper cleans pi-lens's servers inside any orphaned child `pi` it leaves); (b) its on-demand "run agent: pi" jobs spawn child pis WITHOUT a subagent marker, so pi-lens runs at full weight there — low-frequency, user-triggered, accepted. ### pi-dynamic-workflows (`@quintinshaw/pi-dynamic-workflows`, assessed 2026-07-10 at v2.12.0) vm-sandboxed orchestration scripts fanning out to up to 16 in-process subagents. **Benign coexistence via a THIRD execution model**: it calls `createAgentSession()` directly and never `bindExtensions()`/`reload()` — the only two SDK paths that emit `session_start` — so pi-lens's handler never runs for its subagents. No #473 hazard by construction; no #475 child processes; no #472 kills (disposal via `session.dispose()` in `finally`); zero namespace overlap (`~/.pi/workflows/`). **Known gap, by their design, not a bug**: because pi-lens never binds into those subagent sessions, subagent-written edits get NO pi-lens diagnostics, read-guard, or dispatch — pi-lens is a bystander until the parent session touches the files. Worktree-isolated agents (`.pi/worktrees/<slug>`) are additionally out of the parent's project scope. If a user reports "pi-lens didn't catch X in my workflow run", this is why. ### pi-subagents-worktrees (`@gotgenes/pi-subagents` + `@gotgenes/pi-subagents-worktrees`, assessed 2026-07-10 at ~v0.x) Friendly fork of `@tintinweb/pi-subagents` with worktree isolation extracted into a pluggable `WorkspaceProvider`: each opted-in subagent runs in a detached `git worktree` (`git worktree add --detach <tmpdir> HEAD`; unchanged worktrees are removed, changed ones are committed to a `pi-agent-<id>` branch). **A fourth execution model, mechanically the tintinweb one with a relabeled cwd**: in-process `AgentSession` + `bindExtensions({})` per child — the same SDK mechanics the tintinweb contracts (2a/2b/2c/2d) already pin, so **no new Layer A contract and no Layer B addition is warranted**. Findings that matter to pi-lens: - **No `PI_SUBAGENT_CHILD` env marker is ever set** — this fork identifies its own children via a session-id-keyed `globalThis` registry (`Symbol.for("@gotgenes/pi-permission-system:subagent-registry")`), not env vars. `subagent-mode.ts`'s `isSubagentSession()` therefore never fires for it — **correctly**: the #473 concurrent-session guard classifies the child's `bindExtensions` `session_start` as `concurrent-secondary` and suppresses `handleSessionStart` entirely, so light mode has nothing to throttle. Do not "fix" light-mode detection to catch this case. - The #473 classifier is cwd-agnostic by construction (ctx-liveness + session-id only), so the different-cwd child binds safely; the `concurrent_session_bind` phase already logs `sameCwd` for observability. - **Known-acceptable gap, same class as pi-dynamic-workflows**: worktree children get zero pi-lens coverage (no diagnostics, read-guard, or LSP — the worktree is never a pi-lens project root because pi-lens never runs there). Edits surface to pi-lens only if/when the parent session touches the resulting branch/files. ### pi-delegate (`drsh4dow/pi-delegate`, Codeberg, assessed 2026-07-10) Single-tool isolation extension: one fresh child agent per `delegate` call, bounded task, distilled report back; explicitly not a workflow engine. **Execution model = the pi-dynamic-workflows shape**: bare `createAgentSession({...})` with a `DefaultResourceLoader` — it **never calls `bindExtensions()`** (grep-verified in source + tests), so `session_start` never fires in the child and pi-lens is a bystander there (no read-guard, diagnostics, or dispatch on the child's edits). Nuance worth recording: the loader's `additionalExtensionPaths` makes pi-lens's TOOLS discoverable in the child's `getAllTools()` — discoverable-but-inert, since no lifecycle binding ever happens. No #473 hazard, no #475 relevance, child disposed via `dispose()` in a `finally`. **No contract pinned, no smoke coverage needed.** Version-drift flag: imports the pre-rename `@mariozechner/*` SDK scope — re-assess if this extension gets adopted against a current pi. ### avtc-pi-feature-flow / avtc-pi-subagent (assessed 2026-07-10 at avtc-pi-subagent@1.0.3) Feature pipeline (design→plan→implement→verify→review→UAT) fanning out child agents via its bundled `avtc-pi-subagent` engine: **real child-process spawns of `pi --mode rpc` / `--mode json -p` — the nicobailon shape — but with its OWN env vocabulary**: sets `PI_SUBAGENT_CHILD_AGENT` + `PI_SUBAGENT_PARENT_PID`, **never `PI_SUBAGENT_CHILD=1`** (grep-verified). `subagent-mode.ts` `isSubagentSession()` now detects this vocabulary too (#507, fixed 2026-07-10): the pair `PI_SUBAGENT_CHILD_AGENT` + `PI_SUBAGENT_PARENT_PID` (both non-empty) is treated as an additional subagent signal, alongside nicobailon's `PI_SUBAGENT_CHILD=1`. The pair is required rather than either var alone — a lone var set by some unrelated tool must not trigger light mode by itself. `PI_LENS_SUBAGENT_FULL=1` remains the universal opt-out for both vocabularies. `getSubagentIdentity()` now also reports which vocabulary matched (`marker: "pi-subagents" | "avtc-pi-subagent"`), surfaced in the `subagent_light_mode` latency phase so dogfooding can tell the ecosystems apart. Its per-feature worktree isolation is the same bystander situation as pi-dynamic-workflows' isolated worktrees — no separate row needed for that. Closed (#518, #507 itself was already CLOSED by PR #508): `avtc.child-env` is now a Layer A pinned contract in `scripts/lib/compat-contracts.mjs` (`checkAvtcChildEnv`, verified against `src/process-runner.ts`'s `subagentEnv.PI_SUBAGENT_CHILD_AGENT = agent.name` + `subagentEnv.PI_SUBAGENT_PARENT_PID = String(process.pid)` assignments), and Layer B (`scripts/compat-smoke-behavioral.mjs`) gained two behavioral assertions: the PAIR engages light mode, and a LONE `PI_SUBAGENT_CHILD_AGENT` (without `PI_SUBAGENT_PARENT_PID`) correctly does NOT. ### pi-swarm (`gjczone/pi-swarm`, assessed 2026-07-11) Agent-swarm orchestration ported from MoonshotAI/kimi-code: a `Swarm` tool spawning 1–128 parallel subagents in isolated git worktrees, plus a collaborative "Team" mode with a file-based JSONL mailbox (polling, no bus). **Execution model = real out-of-process children — but with NO subagent vocabulary at all**: `spawnSubagent` (`src/shared/spawner.ts:141-168`) builds `pi --print <task>` CLI args and spawns via `child_process.spawn` with `env: { ...process.env }` (`spawner.ts:397`) — a verbatim spread, zero additions. No `PI_SUBAGENT_CHILD=1`, no `PI_SUBAGENT_CHILD_AGENT`/ `PI_SUBAGENT_PARENT_PID` pair, no `bindExtensions`/`createAgentSession` in-process shape (grep-verified). Extensions DO activate in the children (they are full `pi` processes), so pi-lens runs there — but `classifySubagentSession()` sees no marker, light mode (#475) never engages, and every child pays full startup cost (LSP pre-warm + heavyweight scans), multiplied by up to 128 parallel worktrees. This is a **detection gap pi-lens cannot close unilaterally**: there is nothing in the child environment to key on. Fix is a one-line upstream change (adopt either existing vocabulary at `spawner.ts:397`); upstream ask is approval-gated per house policy. Active, well-maintained project (strict CI, last commit 2026-07-10). Its subagent tool-blocklist (`pi-invoke.ts:29-35`, prevents recursive swarms) is sound design, unrelated to pi-lens. **No contract to pin until upstream adds markers.**