@nicknisi/pi-shared

Shared helpers for Nick's pi extensions (one-off LLM provider access, TUI utilities)

Packages

Package details

package

Install @nicknisi/pi-shared from npm and Pi will load the resources declared by the package manifest.

$ pi install npm:@nicknisi/pi-shared
Package
@nicknisi/pi-shared
Version
0.2.0
Published
Aug 8, 2026
Downloads
79/mo · 79/wk
Author
nicknisi
License
MIT
Types
package
Size
203.9 KB
Dependencies
1 dependency · 3 peers

Security note

Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.

README

@nicknisi/pi-shared

Shared helper library for the extensions in this monorepo. It is not a pi extension itself — it exports no default extension entry, adds no commands, tools, keybindings, widgets, or events, and installing it into pi directly does nothing. Other packages depend on it via "@nicknisi/pi-shared": "workspace:*" and import from it like any library.

It exists to centralize things that several extensions were duplicating: one-off LLM calls on pi-ai's modern provider API (no /compat imports), a set of TUI utilities (gradient text, renderable-tree surgery, two-column layout, escape-sequence sanitization, render dispatch) plus a composable searchable select component, and an in-process subagent runtime for spawning focused child agent sessions.

Exports

index.ts re-exports everything from llm.ts, tui-utils.ts, searchable-select-list.ts, and subagents.ts.

LLM (llm.ts)

import { getModelProvider } from '@nicknisi/pi-shared';

getModelProvider(ctx, model) resolves the composed runtime provider for a model via ctx.modelRegistry.getProvider(model.provider). Unlike compat's global API dispatch, this honors models.json overrides and extension-registered providers. Throws No provider registered for <provider> if unregistered.

ctx only needs a modelRegistry (Pick<ExtensionContext, "modelRegistry">), so non-extension callers can pass a narrower object.

Migration cheat sheet from the compat API (documented in the source header):

// compat complete(model, ctx, opts)
getModelProvider(ctx, model).stream(model, ctx, opts).result();

// compat streamSimple(model, ctx, opts)
getModelProvider(ctx, model).streamSimple(model, ctx, opts);

Auth stays explicit: pass apiKey/headers from ctx.modelRegistry.getApiKeyAndHeaders(model) in the stream options.

TUI utilities (tui-utils.ts)

import {
  columns,
  formatDirectory,
  gradientText,
  hideLabeledSection,
  sanitizeTerminalLabel,
  // ...
} from '@nicknisi/pi-shared';

Six self-contained patterns, adapted from a pi dashboard extension:

Export What it does
gradientText(text, phase, palette?) Per-character truecolor gradient. phase shifts the gradient (use rowIndex * k to stagger multi-line art). Spaces pass through uncolored.
sampleGradient(position, palette?) Sample a wrapping gradient at position (0..1, wraps modulo 1); adjacent palette stops are linearly interpolated.
DEFAULT_PALETTE, RESET Default blue-leaning 6-stop RGB palette; ANSI reset code.
hideLabeledSection(root, label) Walk a renderable tree, find the first child whose first non-empty rendered line equals label, splice it out (plus one trailing blank-line sibling), and invalidate() the root. Returns true if removed. Original use: stripping pi's auto-injected [Themes] widget.
scheduleHideLabeledSection(root, label, delays?) Progressive-poll removal at [0, 50, 250, 1000] ms for asynchronously injected nodes; calls root.requestRender(true) on success. Returns timers.
clearHideTimers(timers) Teardown for the above.
columns(left, right, width) Two-column line layout. Pads the gap when both sides fit; otherwise shrinks left to ~45% and right to the remainder with a minimum 1-space gap, truncating via pi-tui's truncateToWidth.
sanitizeTerminalLabel(text) Strip OSC, CSI, other escape sequences, and C0/C1 control chars from user-controllable strings before rendering (prevents escape injection and layout breakage).
formatDirectory(cwd) Collapse $HOME to ~ for display, then sanitize.
renderedText(node, width = 200) Render a node to a throwaway 200-wide buffer and strip ANSI — introspect a component whose API only exposes render(width). Returns "" if render throws.
createRenderDispatcher() Reassignable render thunk so event sources can trigger redraws without knowing which surface is bound. bind(tui) points requestRender() at tui.requestRender(); unbind() clears it.

Shared structural types: RenderableNode (children?, invalidate(), render(width)), RequestRenderable (adds requestRender(force?)), RenderDispatcher. These are subsets of pi's own RenderableNode and are structurally compatible with @earendil-works/pi-coding-agent components.

Searchable select (searchable-select-list.ts)

import { SearchableSelectList } from '@nicknisi/pi-shared';

A pi-tui Component composing an Input above a SelectList. pi-tui removed SelectList.searchable; this is the manual filtering pattern pi's own model/theme selectors use. handleInput routes the tui.select.up / tui.select.down / tui.select.confirm / tui.select.cancel keybindings to the list and everything else to the input, whose value drives selectList.setFilter().

const list = new SearchableSelectList(items, maxVisible, theme);
list.onSelect = (item) => {
  /* ... */
};
list.onCancel = () => {
  /* ... */
};
// mount into an overlay/dialog; render(width) and handleInput(keyData) are the Component contract

selectList is exposed as a readonly field for direct list manipulation.

Subagents (subagents.ts)

import { createSubagentRuntime } from '@nicknisi/pi-shared';

In-process subagent runtime: spawn focused child agent sessions through pi's SDK (createAgentSession) — no subprocesses, no dependency on pi-subagents. Because pi's extension loader aliases @earendil-works/* imports to the running host, children are always version-matched to the pi that loaded the extension.

const subagents = createSubagentRuntime({ namespace: 'my-extension' });

const result = await subagents.spawn({
  model: 'anthropic/claude-haiku-4-5', // optional; pi default resolution when omitted
  prompt: 'Review src/auth.ts for …',
  tools: ['read', 'grep'], // allowlist; [] = none; omit = pi defaults
  systemPrompt: 'You are a security reviewer.', // appended to pi's default prompt
  outputSchema: MySchema, // optional TypeBox schema for the final message
  onSupervisorRequest: async ({ message }) => 'supervisor reply',
});

if (result.ok) {
  result.text; // final assistant message (last message only, never a concatenation)
  result.data; // parsed + validated output, when outputSchema was given
  result.usage; // { inputTokens, outputTokens, totalTokens, cost? }
} else {
  result.kind; // 'crashed' | 'empty' | 'schema_invalid' | 'aborted'
}

Design rules baked in:

  • Hermetic children. No user extensions, skills, prompt templates, themes, or context files load into a child unless explicitly requested (extensionPaths, skillPaths, includeContextFiles). A configured pi-subagents install never leaks in.
  • Tool scoping by construction. A child gets exactly the allowlisted built-ins plus the supervisor tool when requested. No spawn capability exists as a tool, so children cannot spawn children.
  • Never rejects. spawn() resolves a discriminated union. schema_invalid (unparseable/invalid JSON) is deliberately distinct from a schema-valid answer whose content reports failure — engines with retry loops key off that distinction.
  • The supervisor channel is a closure. onSupervisorRequest registers a <namespace>_contact_supervisor tool in the child wired straight to the parent handler — no filesystem protocol, no polling.
  • Ecosystem recursion guard, honored not namespaced. Spawning is refused when PI_SUBAGENT_DEPTH / PI_SUBAGENT_CHILD are set (i.e. your extension is itself running inside a pi-subagents child).
  • Run registry + background. listRuns() returns recent runs (newest first, capped at 200); activeCount() reports in-flight spawns. spawnDetached() launches without awaiting, returning { runId, done } for background work. Concurrency is capped per runtime (maxConcurrent, default 4) AND process-wide per module instance (GLOBAL_MAX_CHILDREN = 8), which covers the realistic stacking case — a codemode workflow nesting further spawns. pi's loader gives every extension its own evaluated copy of this module, so a cross-extension limiter is impossible without a file lock (overkill) — size each runtime accordingly.
  • Worktree isolation. spawn({ worktree: true }) runs the child in a detached git worktree (~/.pi/agent/subagent-worktrees/<runId>, from HEAD). On settle, the full delta — including untracked files, via git add -A + git diff --cached HEAD — is captured untruncated to <runId>.patch beside the run artifact, and RunRecord.worktree records { path, repoRoot, patchPath, changedFiles }. Merge-back is the caller's decision (central integration). Fails fast with kind: 'crashed' when cwd isn't in a git repo.
  • Persisted run artifacts. With artifactsDir on createSubagentRuntime, every run persists its record (status, timing, usage, bounded output, last-20-events transcript, worktree handoff) to <dir>/<namespace>/<runId>.json on each status transition — the cross-extension fleet view, since the on-disk root is the only layer pi's module isolation leaves shared. readRunArtifacts(rootDir) reads records across namespaces. sweepRunArtifactsOnce(rootDir) (call at extension init) garbage-collects records older than 7 days — with their .patch siblings and worktrees — and reaps ghost running/queued records whose hostPid belongs to a dead process (marked aborted, so the fleet shows the truth after a host crash).
  • Timeout/abort/budgets. timeoutMs (default 15 min, 0 disables) and signal both map to session.abort(); maxTurns / maxToolCalls abort the child when exceeded. All three produce kind: 'aborted' with a reason-specific error.

Also exported: resolveContainedAgentResource(kind, name, leaf) — containment-checked resolution of a bare name under the agent dir (~/.pi/agent/extensions/<name>/<leaf> etc.) for untrusted config input; returns null for anything with path separators or ... And sweepRunArtifacts(rootDir, { now?, retentionMs? }) for direct GC control (returns { deleted, reaped }).

Non-goal: crash isolation — children share the parent's event loop and memory (a pathological child can hurt the host session). Orchestration lives in workflow.ts (below).

Workflow engine (workflow.ts)

import { runWorkflow } from '@nicknisi/pi-shared';

Declarative multi-stage workflows over a SubagentRuntime. A workflow is a list of stages with explicit (needs) or implicit (linear chain — each stage depends on the previously declared one) dependencies; the engine schedules them over the runtime and resolves a WorkflowResult (never rejects).

const result = await runWorkflow(
  {
    name: 'review-pipeline',
    concurrency: 4, // scheduler cap; default 4
    tokenBudget: 500_000, // stop starting new stages once exceeded
    stages: [
      { id: 'scout', needs: [], agent: 'scout', prompt: 'Map the auth module…' },
      {
        id: 'reviewers',
        needs: ['scout'],
        foreach: ['correctness', 'tests', 'simplicity'], // one spawn per item
        prompt: (ctx, lens) => `Review as ${lens}. Context: ${(ctx.results.scout as any)?.output}`,
        gate: (outcome) => (outcome.output.length > 200 ? true : { revise: 'Too thin — be specific.' }),
      },
      {
        id: 'fix',
        needs: ['reviewers'],
        sharesTree: true, // declares it edits the shared working tree
        tools: ['read', 'edit', 'bash'],
        prompt: (ctx) => `Apply the feedback. Diff so far:\n${ctx.treeDiffs['fix'] ?? ''}`,
      },
    ],
  },
  runtime,
  { cwd: process.cwd(), resumeFrom: previousRunDir },
);

Full stage schema: { id, agent?, prompt (string | (ctx, item?, index?) => string), model?, tools?, systemPrompt?, outputSchema?, needs?, sharesTree?, worktree?, foreach?, gate?, maxGateAttempts?, retries?, maxTurns?, maxToolCalls?, timeoutMs? }. sharesTree and worktree are mutually exclusive (validated up front): sharesTree = edit the caller's tree with resource exclusion; worktree = isolated worktree per spawn with patch handoff. resumeFrom is guarded by a specHash in status.json — editing stage definitions between runs makes resume fail loudly instead of silently reusing stale outcomes (function-valued prompts hash as '<function>' and are not covered). prompt/gate receive a StageContext: results (typed outcomes of completed stages), treeDiffs, cwd, runDir. tools defaults to read-only (['read', 'grep', 'find', 'ls']) when omitted — a stage that edits files or runs bash must pass tools explicitly.

Semantics:

  • Two-channel handoff. (1) Typed results flow via ctx.results. (2) When a sharesTree stage completes ok in a git repo, its bounded (64KB) git diff HEAD snapshot flows to dependents via ctx.treeDiffs[stageId].
  • Resource exclusion. A sharesTree stage never runs concurrently with ANY other stage: while one is queued the scheduler pauses new starts and waits for the running set to drain. Non-tree stages overlap freely up to concurrency. Conservative by design — declare sharesTree on anything that reads or writes the working tree.
  • Failure containment. A failed dependency transitively skips dependents (kind: 'skipped'). An exhausted tokenBudget skips unstarted stages (kind: 'budget_exceeded'). Workflow ok = every stage ok.
  • foreach. Static array or { from: stageId, pick? } resolving items from a dependency's ok outcome (pick defaults to the outcome's data). Each item is an independent spawn counting individually against concurrency; the stage outcome's output is the JSON array of per-item outputs (data is the array of validated per-item data when outputSchema is set). One failed item fails the stage.
  • gate — the vacuous-pass defense. Runs on each ok outcome; { revise: feedback } re-spawns with the feedback appended (up to maxGateAttempts, default 2), exhaustion or a gate exception fails the stage as gate_failed. The engine treats runtime empty outcomes as failures; use gates to enforce real content contracts ("PASS with no findings" is not a pass).
  • retries. Re-spawns on crashed/empty only — aborted and schema_invalid fail immediately.
  • Control artifacts + resume. Every run writes status.json and stages/<id>.json (outcome + treeDiff) under its runDir — default <agentDir>/workflow-runs/<name>-<timestamp> — enabling resumeFrom to skip previously-ok stages and reload their outcomes (and tree diffs) from disk.

The engine depends only on the SubagentRuntime type, so tests drive it with a hand-rolled fake — see workflow.test.ts (run: pnpm test).

Usage

Consumers in this monorepo add the workspace dependency:

{
  "dependencies": {
    "@nicknisi/pi-shared": "workspace:*"
  }
}

and import individual functions as shown above. Known consumers: answer, btw, handoff, header, llm-council, session-name, statusline.

Configuration

None. No config files, no options, no environment variables.

Install

Not intended for direct installation — it registers nothing with pi. It is pulled in automatically as a workspace dependency of the extensions that use it. If you did run it:

pi install /Users/nicknisi/Developer/pi-extensions/packages/shared

pi would load the package and find no extension entry point.

Dependencies

Peer dependencies (all *, provided by the pi runtime):

  • @earendil-works/pi-ai — types (Api, Model, Provider) for getModelProvider.
  • @earendil-works/pi-coding-agentExtensionContext type; the RenderableNode shape targets its component tree.
  • @earendil-works/pi-tuitruncateToWidth, visibleWidth (layout), and Container, Input, SelectList, getKeybindings (searchable select).

Runtime npm dependencies:

  • typeboxoutputSchema validation in subagents.ts (Value.Check/Clean/Convert/Errors). At pi-load time the import is aliased to pi's bundled copy; the declared dependency covers non-pi consumers of the published package.

Caveats

  • getModelProvider depends on pi-ai's provider-owns-streaming architecture and ModelRegistry.getProvider() returning the composed runtime provider. Both are current pi internals and could change across pi versions; the compat API this replaces is the canary — if pi-ai restructures provider resolution again, this needs to move with it.
  • hideLabeledSection relies on rendering children to a throwaway buffer to compare visible text — it depends on render(width) being side-effect-free enough to call speculatively, and on target sections having a stable first visible line (pi's [Themes] widget label is a pi internal that could change).
  • scheduleHideLabeledSection is a timing heuristic ([0, 50, 250, 1000] ms) for asynchronously injected nodes; a sufficiently slow injection can slip past the last poll.
  • SearchableSelectList hardcodes the tui.select.* keybinding IDs from pi-tui. If those IDs are renamed, navigation silently stops routing.
  • package.json files lists every shipped source plus dist; pnpm pack/publish produces a working tarball.
  • The package is marked private: true and ships TypeScript sources (exports: { ".": "./index.ts" }); consumers must run in pi's TS-loading extension environment, not plain Node.