@nicknisi/pi-shared
v0.6.0
Published
Shared helpers for Nick's pi extensions (one-off LLM provider access, TUI utilities)
Maintainers
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: a set of TUI utilities (gradient text, an ANSI-stripping render probe, 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 tui-utils.ts, searchable-select-list.ts, subagents.ts, subagents-dir.ts (PI_SUBAGENTS_DIR paths), workflow.ts (runWorkflow), patches.ts (parseUnifiedDiff for worktree patch handoffs), and tools.ts (declareUnlessSearchable).
TUI utilities (tui-utils.ts)
import {
columns,
formatDirectory,
gradientText,
sanitizeTerminalLabel,
// ...
} from '@nicknisi/pi-shared';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. |
| 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)), 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 contractselectList is exposed for direct list manipulation. Use setItems(items) to refresh dynamic rows while preserving the current filter and selected value.
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
onUsage: (usage) => updateDisplay(usage.totalTokens), // cumulative after each response
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.
onSupervisorRequestregisters a<namespace>_contact_supervisortool 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_CHILDare set (i.e. your extension is itself running inside a pi-subagents child).Live usage.
onUsagereceives cumulative input, output, total-token, and optional cost data after each completed assistant response. The current total is also mirrored onto the liveRunRecord, so registry-backed views can display token burn before the run settles. Observer errors never fail the 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 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, viagit add -A+git diff --cached HEAD— is captured untruncated to<runId>.patchbeside the run artifact, andRunRecord.worktreerecords{ path, repoRoot, patchPath, changedFiles }. Merge-back is the caller's decision (central integration). Fails fast withkind: 'crashed'when cwd isn't in a git repo.Persisted run artifacts. With
artifactsDironcreateSubagentRuntime, every run persists its record (status, timing, usage, bounded output, last-20-events transcript, worktree handoff, andownerSessionwhen supplied) to<dir>/<namespace>/<runId>.jsonon each status transition and usage update — 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.patchsiblings and worktrees — and reaps ghostrunning/queuedrecords whosehostPidbelongs to a dead process (markedaborted, and their worktrees removed, so the fleet shows the truth after a host crash without leaking detached worktrees).Standard pi session mirror (dual-write).
spawn({ ownerSession })records ownership onRunRecord.ownerSessionwithout enabling a mirror.spawn({ parentSession })uses the same path as the owner and ALSO persists the run as a standard pi session JSONL via the realSessionManagerinto the default sessions dir, with the header'sparentSessionlinked to the owning session — so runs are inspectable with pi's native/resume,/tree, and--fork. The child mirror path is recorded separately onRunRecord.sessionFile. Additive: the bespoke.jsonartifact is still written and is whatreadRunArtifacts/fleet read. Opt-in: whenparentSessionis omitted, no mirror is written (so other shared-runtime consumers are unaffected). A run that produced no assistant turn is not mirrored, sosessionFileisundefined.Timeout/abort/budgets.
timeoutMs(default 15 min,0disables) andsignalboth map tosession.abort();maxTurns/maxToolCallsabort the child when exceeded. All three producekind: 'aborted'with a reason-specific error.Parent model registry. Pass
parent: () => ctx(the latestExtensionContext, read at each spawn) tocreateSubagentRuntimeand children share the parent session's model runtime, so providers registered at runtime (pi.registerProvider) carry over with their auth and headers. A spawn with nomodelthen defaults to the parent's current model; an explicitmodelresolves against the same registry. pi has no public accessor for a registry's runtime, soparentModelRuntime(registry)readsModelRegistry's privateruntimefield (checked on pi-coding-agent 0.84, 0.87 and 1.1) and, if it's gone, replays the parent's runtime-registered providers into a fresh runtime (losing runtime--api-keykeys and host credential stores). Ifparentis configured but returns nothing or throws (a stale context), the spawn fails closed with kindcrashedinstead of falling back to the user's default provider. Withoutparent, models resolve from a freshModelRuntime.create()as before.PI_SUBAGENTS_DIR.subagentsDir(leaf)returns<$PI_SUBAGENTS_DIR>/<leaf>when the env var is set, elseundefined(keep your default). The runtime uses it for worktrees (worktrees), session mirrors (sessions) and workflow run dirs (workflow-runs); extensions use it forruns(theirartifactsDir) andpatches. Read at call time.
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 content-addressed: each stage artifact carries a per-stage key (sha256 over the stage's resolved prompt, model, tools, systemPrompt, outputSchema, needs, retries, maxTurns, maxToolCalls, timeoutMs, foreach shape, hasGate, sharesTree, worktree — function-valued prompts hash via .toString(), so edits to prompt closures ARE covered — plus the content keys of its upstream needs stages, Merkle-style), and status.json carries a stageKeys map plus a whole-spec specHash (back-compat). Resume replays a previously-ok stage ONLY if its key still matches — an unchanged prefix replays free; a changed stage re-runs itself and (via upstream chaining) everything downstream. Old runDirs without stageKeys fall back to the whole-spec specHash. 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 asharesTreestage completes ok in a git repo, its bounded (64KB)git diff HEADsnapshot flows to dependents viactx.treeDiffs[stageId]. - Resource exclusion. A
sharesTreestage 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 toconcurrency. Conservative by design — declaresharesTreeon anything that reads or writes the working tree. - Failure containment. A failed dependency transitively skips dependents (
kind: 'skipped'). An exhaustedtokenBudgetskips unstarted stages (kind: 'budget_exceeded'). Workflowok= every stage ok. foreach. Static array or{ from: stageId, pick? }resolving items from a dependency's ok outcome (pickdefaults to the outcome'sdata). Each item is an independent spawn counting individually against concurrency; the stage outcome'soutputis the JSON array of per-item outputs (datais the array of validated per-item data whenoutputSchemais 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 tomaxGateAttempts, default 2), exhaustion or a gate exception fails the stage asgate_failed. The engine treats runtimeemptyoutcomes as failures; use gates to enforce real content contracts ("PASS with no findings" is not a pass).retries. Re-spawns oncrashed/emptyonly —abortedandschema_invalidfail immediately.- Control artifacts + resume. Every run writes
status.json(with astageKeysmap + whole-specspecHash) andstages/<id>.json(outcome + treeDiff + per-stagecontentKey) under its runDir — default<agentDir>/workflow-runs/<name>-<timestamp>— enablingresumeFromto replay previously-ok stages whose content key still matches and reload their outcomes (and tree diffs) from disk. Aresume_summaryevent (onProgress) reports how many stages were replayed vs. re-run.
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: header, llm-council, statusline, subagents, workflows.
Tool exposure (tools.ts)
import { declareUnlessSearchable } from '@nicknisi/pi-shared';
pi.on('session_start', () => declareUnlessSearchable(pi, ['my_tool']));For tools registered with exposure: 'deferred': when pi's built-in tool_search is not active, adds them to the active tool set so the model can still reach them. With tool_search active it does nothing and the tools stay out of the system prompt. It only declares tools pi still has registered: an explicit --tools allowlist or --exclude-tools removes unnamed extension tools from the registry, so the fallback never re-enables them.
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/sharedpi would load the package and find no extension entry point.
Dependencies
Peer dependencies (all *, provided by the pi runtime):
@earendil-works/pi-ai— types.@earendil-works/pi-coding-agent—ExtensionContexttype; theRenderableNodeshape targets its component tree.@earendil-works/pi-tui—truncateToWidth,visibleWidth(layout), andContainer,Input,SelectList,getKeybindings(searchable select).
Runtime npm dependencies:
typebox—outputSchemavalidation insubagents.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
SearchableSelectListhardcodes thetui.select.*keybinding IDs from pi-tui. If those IDs are renamed, navigation silently stops routing.package.jsonfileslists every shipped source plusdist;pnpm pack/publish produces a working tarball.- The package is marked
private: trueand ships TypeScript sources (exports: { ".": "./index.ts" }); consumers must run in pi's TS-loading extension environment, not plain Node.
