@thoth-agents/pi-core

Typed, versioned event contracts for the Thoth Pi ecosystem.

Packages

Package details

package

Install @thoth-agents/pi-core from npm and Pi will load the resources declared by the package manifest.

$ pi install npm:@thoth-agents/pi-core
Package
@thoth-agents/pi-core
Version
0.7.0
Published
Nov 4, 2026
Downloads
1,453/mo · 448/wk
Author
eremesng
License
MIT
Types
package
Size
358.5 KB
Dependencies
0 dependencies · 2 peers

Security note

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

README

@thoth-agents/pi-core

Shared, typed event and rendering contracts for the Thoth Pi ecosystem. This is a library, not a Pi extension: importing it registers no tools or UI, and it has no pi.extensions entry. It ships TypeScript source with no build step. Event and render contracts use type-only Pi imports; the opt-in Work panel host lazily loads the optional SDK/TUI peers. Event helpers accept the minimal structural pi.events interface.

Requires Node >=22.19.0. Supports the event bus in Pi >=1.5.0; development and tests use SDK 1.5.0.

API

interface EventBus {
  emit(channel: string, data: unknown): void;
  on(channel: string, handler: (data: unknown) => void): () => void;
}

type Channel<T> = {
  readonly name: string;
  readonly version: number;
  readonly validate: (data: unknown) => data is T;
};
type ThothEnvelope<T> = {
  v: number;
  source: string;
  sessionId: string;
  at: number; // Unix milliseconds
  data: T;
};

defineChannel<T>(definition: Channel<T>): Channel<T>;
publish<T>(events: EventBus, channel: Channel<T>, snapshot: {
  sessionId: string; source: string; data: NoInfer<T>;
}): void;
request<T>(events: EventBus, requestChannel: Channel<T>, payload: {
  sessionId: string; source: string; data: NoInfer<T>;
}): void;
subscribe<T>(events: EventBus, channel: Channel<T>, options: {
  sessionId?: string;
  onSnapshot: (snapshot: ThothEnvelope<T>) => void;
}): () => void;
onRequest<T>(events: EventBus, requestChannel: Channel<T>, options: {
  sessionId?: string;
  onRequest: (request: ThothEnvelope<T>) => void;
}): () => void;
isTodoSnapshot(value: unknown): value is TodoSnapshot;
isTodoStateRequest(value: unknown): value is TodoStateRequest;
isSubagentsSnapshot(value: unknown): value is SubagentsSnapshot;
isSubagentsStateRequest(value: unknown): value is SubagentsStateRequest;
isSubagentsUsageSnapshot(value: unknown): value is SubagentsUsageSnapshot;
isSubagentsUsageRequest(value: unknown): value is SubagentsUsageRequest;
isBackgroundSnapshot(value: unknown): value is BackgroundSnapshot;
isBackgroundStateRequest(value: unknown): value is BackgroundStateRequest;

publish and request add the channel version and current timestamp to typed payload data. Every channel, including requests, uses a Channel<T> definition and the same { v, source, sessionId, at, data } envelope. subscribe and onRequest ignore unsupported versions, foreign session IDs when filtered, malformed envelope fields, and invalid payloads (including throwing validators). Without a session filter they receive all valid envelopes on that channel. Errors in callbacks are not swallowed. Returned functions unsubscribe from the bus.

onRequest passes the validated envelope to the producer. Producers must answer only for their session, and queue requests until state is ready when needed; these lifecycle concerns stay outside the library.

Tool-definition publication registry

publishToolDefinitions(definitions: readonly ToolDefinitionLike[]) shares full named tool definitions across bundled copies without loading their extensions. It returns a ToolDefinitionHandle with publish(more) to add/replace its tools and idempotent withdraw() to remove only its publications; publishing after withdrawal is ignored. getPublishedToolDefinition(name) returns the latest live definition, or undefined; withdrawing it reveals any earlier live publication. getToolDefinitionRegistryVersion() returns the change counter, incremented once per nonempty publication/withdrawal batch. The process-wide slot is Symbol.for('thoth-agents.pi-core.tool-definitions.v1'); incompatible state yields inert handles, no definition and version 0.

Windows jobs

The root and @thoth-agents/pi-core/windows-job (./windows-job) export launchInJob(options: JobLaunchOptions): Promise<LaunchedJob> and JobLauncherUnavailable. Options are { file, args, cwd, env }. The returned job exposes pid, stdin, stdout, stderr, exited: Promise<{ code: number }>, terminate(timeoutMs): Promise<void> and release(): Promise<void>. Launching requires Windows; unsupported platforms throw JobLauncherUnavailable.

Shared question service v1

Package authors can use registerQuestionService(provider) and getQuestionService() to share a question UI across separately bundled pi-core copies without importing another extension. The process-wide registry uses Symbol.for('thoth-agents.pi-core.question-service.v1'); lookup returns the most recent live compatible provider, or undefined when absent or incompatible. The returned handle's idempotent withdraw() removes only its own registration.

A QuestionServiceProvider implements ask(ctx, request, { signal }), returning a Promise<QuestionServiceResult>. Pass the caller's Pi UI context; the contract itself has no SDK runtime dependency. QuestionServiceRequest contains optional title and a questions array: each question has id, header, prompt, an explicit type (single, multi, text, confirm), and optional context, required, and options with value, label, description, preview, and recommended. Per-question allowCustom defaults to true; set it to false to disable free-text answers.

Results have status: 'answered' | 'cancelled' | 'unavailable' and answers keyed by question ID. Each answer has status: 'answered' | 'skipped' | 'unanswered', values, and optional labels, customText, and note. Cancellation includes abort, after UI disposal; unavailable means the provider cannot present UI and lets the consumer choose a fallback. Providers return structured results for expected cases; consumers should still guard against unexpected failures and validate answers before acting. pi-core supplies no UI; @thoth-agents/pi-questions-user registers a provider in UI sessions.

Task-list contract

  • TODO_STATE_CHANNEL: thoth:todo:state, version 1, validated TodoSnapshot.
  • TODO_STATE_REQUEST: thoth:todo:state:request, version 1, validated TodoStateRequest.
  • TodoStateRequest: Record<string, never>, an empty plain object ({}); the target session ID belongs to the envelope, not the request data.
  • TodoStatus: pending | in_progress | completed | deleted.
  • TodoTask: numeric id, string subject, optional string description, activeForm and owner, status, and required numeric array blockedBy.
  • TodoSnapshot: tasks: TodoTask[], nextId: number, and counts: TodoCounts.
  • TodoCounts: all four status keys, each a non-negative integer.

The snapshot contains no session identity; that lives in the envelope. IDs, blocker IDs and nextId are positive safe integers. Normalize absent blockers to [] when publishing. The validator checks structure, not graph invariants, count/task consistency or reducer transition rules. Additional object fields are allowed. Snapshots are complete state, including deleted tombstones, not deltas; consumers replace previous state.

Subscribe before requesting because a producer can respond synchronously:

import {
  onRequest, publish, request, subscribe,
  TODO_STATE_CHANNEL, TODO_STATE_REQUEST,
} from '@thoth-agents/pi-core';

const offProducer = onRequest(pi.events, TODO_STATE_REQUEST, {
  sessionId,
  onRequest: ({ sessionId }) => {
    publish(pi.events, TODO_STATE_CHANNEL, {
      sessionId,
      source: '@thoth-agents/pi-todo',
      data: currentSnapshot,
    });
  },
});
const offConsumer = subscribe(pi.events, TODO_STATE_CHANNEL, {
  sessionId,
  onSnapshot: ({ data }) => { currentSnapshot = data; },
});
request(pi.events, TODO_STATE_REQUEST, {
  sessionId,
  source: '@thoth-agents/example-consumer',
  data: {},
});
// Also publish complete snapshots after each mutation/replay.
// On disposal: offConsumer(); offProducer();

Transport is within one Pi session runtime. Cross-process delivery, child/parent bus sharing and persistence are not provided by this package.

Task summary and usage channels

All definitions, payload types and validators below are exported from the root entry, with no runtime Pi/TUI dependency. Each state publication is a complete, read-only snapshot, not a delta. Session identity belongs to the envelope.

Definition Channel Data
SUBAGENTS_STATE_CHANNEL thoth:subagents:state SubagentsSnapshot
SUBAGENTS_STATE_REQUEST thoth:subagents:state:request SubagentsStateRequest
BACKGROUND_STATE_CHANNEL thoth:background:state BackgroundSnapshot
BACKGROUND_STATE_REQUEST thoth:background:state:request BackgroundStateRequest
SUBAGENTS_USAGE_CHANNEL thoth:subagents:usage SubagentsUsageSnapshot
SUBAGENTS_USAGE_REQUEST thoth:subagents:usage:request SubagentsUsageRequest

SUBAGENTS_STATE_CHANNEL has version 2; all other definitions remain version 1. Producer sources are @thoth-agents/pi-subagents and @thoth-agents/pi-background-tasks respectively; request sources identify the consumer package. All three request types are Record<string, never> and accept only empty plain or null-prototype objects. Subscribe before requesting with data: {}, as in the task-list example above.

Subagents

  • SubagentStatus: queued | running | stopping | completed | failed | cancelled | interrupted.
  • SubagentMode: task | background.
  • SubagentEffort: off | minimal | low | medium | high | xhigh | max.
  • SubagentTaskSummary: required id, agent, mode, status, createdAt; optional displayName, model, effort, startedAt, endedAt, lastActivityAt, usage, preview. IDs and agent names are non-empty strings.
  • SubagentTaskUsage: optional input and output token counts and cost. Token counts are non-negative safe integers; cost is finite and non-negative.
  • SubagentsSnapshot: tasks: SubagentTaskSummary[], counts: SubagentsCounts, history: SubagentTaskSummary[], totals: SubagentsTotals. Tasks are the active session's in-memory tasks; history contains up to 100 persisted session task summaries selected and ordered by descending cost by the producer. History uses the same strict allow-list as tasks. Totals carry persisted session counts. Version 1 snapshots and snapshots without history are rejected.
  • SubagentsCounts: all seven status keys, each a non-negative safe integer. SubagentsTotals: the same keys plus required non-negative safe integer total. Producers normalize missing persisted status counts to zero.
  • preview is a string of at most SUBAGENT_PREVIEW_MAX_LENGTH (800) UTF-16 code units; producers truncate before publication.
  • SubagentsUsageSnapshot: exactly totalCost (finite non-negative number) and runCount (non-negative safe integer), cumulative for the envelope's parent session. There is no parentSessionId in data. This channel replaces the raw thoth:subagent-usage bus event; the persistence discriminator is unchanged.

Background tasks

  • BackgroundTaskStatus: running | succeeded | failed | cancelled | timed_out.
  • BackgroundTaskKind: process | command_watch.
  • BackgroundTaskSummary: required id, kind, status, createdAt, startedAt; optional string name, lifecycle times endedAt, deadlineAt, lastCheckedAt, lastProgressAt, stopRequestedAt, dismissedAt, and exitCode, signal, progress, dismissed. IDs are non-empty strings. Metadata without a separate creation time uses its startedAt as createdAt.
  • exitCode is a signed safe integer or null; signal is a non-empty string or null. dismissed is boolean. progress is a finite number or a string of at most BACKGROUND_PROGRESS_MAX_LENGTH (200) UTF-16 code units, not structured state or command output.
  • BackgroundSnapshot: tasks: BackgroundTaskSummary[], counts: BackgroundCounts, for the current cwd/session origin, including dismissed tasks. BackgroundCounts requires all five status keys as non-negative safe integers.

All lifecycle times in both summaries are non-negative safe integers in Unix milliseconds, matching envelope at; subagent producers convert ISO timestamps. Optional fields may be absent or undefined, but not null except exit code/signal. Unlike the task-list contract, these validators reject every unknown own key on snapshots, task entries, counts, totals and usage, including symbol and non-enumerable keys; custom-prototype objects are also rejected. Prompts, context, transcripts, results, thread snapshots, questions, commands, argv, environment, stdout/stderr and log paths have no place in these payloads. Validators check shape and bounds, not count/task consistency, timestamp ordering or lifecycle transition rules, and return false on throwing accessors or proxies.

Provider limits v1

Account-wide rate-limit observations are shared across in-process root and child sessions, including separately loaded pi-core copies. This registry has no event bus, UI, runtime Pi/TUI dependency, persistence, quota fetching, or timers.

import {
  reportProviderLimit, listProviderLimits, subscribeProviderLimits,
  type ProviderLimitEntry,
} from '@thoth-agents/pi-core';

reportProviderLimit({
  provider: 'claude-bridge', window: 'five_hour', status: 'allowed_warning',
  utilization: 0.9, resetsAt: Date.now() + 60_000, observedAt: Date.now(),
  windowType: 'five_hour', sessionId: observingSessionId,
});
const off = subscribeProviderLimits((entry) => {
  // Capture entry.sessionId attribution here, before the child session tears down.
});
const limits = listProviderLimits(); // Optional now argument in Unix milliseconds.
off(); // Idempotent; releases only this subscription.
  • reportProviderLimit(entry: unknown): boolean accepts only a valid ProviderLimitEntry; invalid reports return false, are ignored, and never throw. The latest report replaces the previous value for that provider/window, even if observed by another session.
  • Required fields: non-empty strings provider, window, sessionId; status (allowed | allowed_warning | rejected); observedAt (Unix milliseconds).
  • Optional fields: utilization (finite 0..1 fraction), resetsAt (Unix milliseconds), windowType (non-empty string), and boolean overageInUse, overageEnabled, isUsingOverage. Window keys/types are open strings, including five_hour, seven_day, seven_day_opus, seven_day_sonnet, and overage. Times are non-negative safe integers. Optional fields may be absent or undefined, not null. Plain and null-prototype objects are accepted; every unknown own key (including symbol/non-enumerable keys), custom prototype, or malformed field is rejected. Throwing accessors/proxies are ignored.
  • listProviderLimits(now?: number): ProviderLimitEntry[] returns immutable observation snapshots in insertion order. When resetsAt <= now, that entry reads as allowed with no utilization field; the stored observation is not changed and listeners are not notified by reads. Other metadata is retained.
  • subscribeProviderLimits(listener: ProviderLimitListener): () => void runs listeners synchronously with the immutable reported entry, not an expiry projection or a later replacement. Throwing listeners are isolated. There is no initial replay; subscribe before reporting when attribution matters. Each subscription is independent, even for the same callback. New listeners join the next report (including nested reports); disposal stops delivery.

The versioned ownership slot is globalThis[Symbol.for('thoth.pi-core.provider-limits.v1')]. Incompatible or malformed owners are left untouched: reporting returns false, listing returns [], and subscriptions return inert disposers. Consumers needing per-task history must capture reports themselves because registry replacement is account-wide, not session-scoped.

Render KIT v1

ThothRenderKit describes the theme-owned visual language. The render-kit contract owns no UI or timers. pi-thoth-theme supplies the kit at runtime; producers have no dependency on that package. The opt-in Work panel host owns its own widget and refresh lifecycle.

interface ThothRenderKit {
  readonly version: 1;
  resolveToolRenderers?(toolName: string, next: () => ToolRenderersLike | undefined): ToolRenderersLike | undefined;
  card(theme: RenderKitTheme, options: RenderCardOptions, width: number): string[];
  collapse(theme: RenderKitTheme, rows: readonly string[], options?: RenderCollapseOptions): string[];
  cachedComponent(render: (width: number) => string[]): Component;
  indicator(theme: RenderKitTheme, context?: RenderIndicatorContext, options?: RenderIndicatorOptions): RenderIndicator;
  toolFooter?(theme: RenderKitTheme, options: RenderToolFooterOptions): string;
  icon?(name: SemanticIconName): string | readonly string[];
  statusGlyph(theme: RenderKitTheme, status: RenderStatus): string;
  widgetHeading(theme: RenderKitTheme, options: RenderWidgetHeadingOptions, width: number): string;
  treeRow(theme: RenderKitTheme, options: RenderTreeRowOptions, width: number): string;
  fg(theme: RenderKitTheme, role: ThemeColor, text: string): string;
}
registerRenderKit(kit: ThothRenderKit, owner: object): RenderKitToken;
withdrawRenderKit(token: RenderKitToken): void;
getRenderKit(): ThothRenderKit | undefined;
formatDuration(ms: number): string;
  • RenderKitTheme is a structural Theme subset: fg, optional bold and strikethrough. Component and ThemeColor are SDK/TUI types, not runtime dependencies. All Render* option/result types are exported from the root.
  • Cards accept optional title, body, sections: { title?, rows }[], footer, status, context, summary, isError, isSuccess and wrap. Status plus tool context requests the standard footer unless footer is supplied; summary supplies terminal summary text. Only isSuccess: true selects a success border; isError takes precedence. Status alone does not change the border tone. Pass isSuccess consistently across split parts. Every section starts with a labeled or unlabeled divider. Status and footer follow the theme's language. Rows are pre-sanitized/pre-styled strings or a (contentWidth) => rows callback; the kit handles frame width, padding, truncation and optional wrapping. Widths are finite viewport cell counts; zero width produces no lines. part: 'full' (default) includes both borders, 'start' omits the bottom, and 'end' omits the top, allowing call/result slots to form one frame.
  • Collapse takes expanded, a non-negative content-row budget (default 8; Infinity disables folding), and optional complete expandHint. The hint is an extra row beyond the budget and appears only when rows are hidden. The implementation resolves the current expand key when no hint is supplied.
  • Each cached component caches independently by width. invalidate() clears every width; invalidate when its inputs or theme change. Do not cache kit discovery inside a component across renders.
  • Indicator context structurally accepts the SDK's executionStarted, isPartial, isError, invalidate and shared state. Options accept an explicit status, elapsedMs (producer-owned background runtime), label and optional caller-owned frame. A finite frame selects the ten-frame braille sequence (⠋, ⠙, …), floored and wrapped, for running/in_progress only; without it, the implementer's elapsed-time animation is unchanged. Widgets with their own refresh loop pass a frame without creating an indicator timer. This optional field is additive to v1; existing callers need no changes. The result has styled glyph, elapsed, text and optional numeric elapsedMs; elapsed is empty when unknown. Call it at render time. The implementer owns state, ticking and cleanup, scoped to its interactive UI session; a headless child's lifecycle must not freeze parent indicators.
  • Status names: pending, queued, in_progress, running, completed, failed, timedOut, resolved, expectedExit, cancelled, interrupted, stopping, deleted, blocked, unknown. Producers normalize domain statuses to these names rather than supplying hardcoded glyphs or colors.
  • Widget headings take title, optional { completed, total } counts, status and suffix. Tree rows take pre-styled text, optional depth, last, selected and status. Both are width-aware. Keyboard/navigation behavior stays with the producer; use fg for other SDK theme-role styling. To group content without truncation, measure the visible width of treeRow with empty text and the same row options, then subtract that rail width from the viewport. The theme reserves 5 cells at depth zero and 7 at depth one, selected or not (an optional status adds its own glyph and space).
  • Optional v1 methods: resolveToolRenderers(toolName, next) composes tool renderers; toolFooter(theme, { status, context?, elapsedMs?, summary? }) supplies the standard footer; icon(name) resolves UI-only semantic glyphs or frame arrays. Legacy v1 kits may omit all three.
  • Root helper renderToolFooter(kit, theme, options) uses kit.toolFooter when available, otherwise a plain whole-second footer. getToolElapsedMs(options) gives explicit elapsedMs precedence over shared context timing, starts timing on executing running renders and freezes it on terminal status; unknown time stays undefined. Neither helper creates timers.
  • detachRenderContext(context) shallow-copies a render context with lastComponent: undefined, preserving shared state and callbacks without mutating Pi's context. Use it before creating retained component closures or kit-rebuild inputs; otherwise each progress update can retain every previous result.
  • createKitRenderMemo() returns { render(width, build), invalidate() }, caching input-stable lines by the last width and kit identity while discovering the kit even on cache hits. Invalidate when other inputs change.
  • resolveIcon(name, fallback?), resolveFrames(name, fallback?) and resolveStatusGlyph(status, fallback?) resolve the current kit on each call, falling back to caller-supplied or native values on missing/invalid/throwing lookups. Frame resolution starts no timer.

The process-wide registry lives at globalThis[Symbol.for('thoth-agents.pi-core.render-kit.v1')], including across separately loaded library copies. It holds one { kit, owner, token } record; registration replaces the previous record and returns a fresh symbol token. Withdrawal is idempotent, removes only that token's registration, and never restores an older kit. Missing, unsupported-version or malformed kits are ignored. The theme registers only for its UI session with styling enabled, and withdraws only its own token. Headless children must not register or withdraw the parent kit.

Look up getRenderKit() inside each render, not at extension load time or component creation. Every migrated tool declares stable renderShell: 'self': when the kit is absent, the producer renders a native pi-tui Box(1, 1, bg) with toolPendingBg, toolSuccessBg or toolErrorBg, matching the SDK default shell. The SDK freezes shell choice at component construction, so kit presence must never control renderShell. Message/widget fallbacks retain native presentation.

formatDuration preserves the theme's millisecond semantics: non-finite or non-positive inputs give 0s; below 1 second it floors to ms; below 1 minute it rounds to one decimal second without .0; below 1 hour it gives Nm SSs; otherwise Nh MMm. No clock reads, side effects or locale dependencies.

Shared test kit

import { registerRenderKit, withdrawRenderKit } from '@thoth-agents/pi-core';
import { createTestRenderKit } from '@thoth-agents/pi-core/testing';
const token = registerRenderKit(createTestRenderKit(), {});
// Exercise kit-present rendering, then withdraw and re-render the same component.
withdrawRenderKit(token);

The fake emits simple ╭─ title, ├─ section, ╰─ status · footer markers (! for error cards), uncolored glyphs, deterministic collapse hints and real per-width caching. It has no timers, state mutation or wall-clock reads; elapsed text uses only an explicit elapsedMs. Caller-owned frame values select the same braille sequence as the real adapter, and tree rows reserve the same selection gutter/rail width whether selected or not. fg passes text through. Its clipping counts code points, not terminal cells, and it does not wrap or interpret ANSI: keep real-theme tests for terminal layout and ticker behavior. Producers share this helper rather than defining package-local kits.

Workspace package release

The nine workspace extensions that use pi-core declare it in devDependencies with workspace:^; pi-openai-fast does not depend on it. The root scripts/build-pi-extensions.mjs bundles pi-core into each consuming extension's dist/index.ts, keeping Pi SDK/TUI peers external. Published extensions therefore need no separate pi-core runtime dependency; pi-core itself ships source.

Every pi-packages/* package is versioned independently. The root v*.*.* tag release workflow (.github/workflows/release.yml) builds the workspace first, then publishes Pi packages with recursive pnpm publish --ignore-scripts through npm trusted publishing, before publishing the root package.

Shared animation

AnimationLevel keeps the values full (default), reduced, minimal and off. Levels select effects, not speed:

Level Enabled AnimationEffectKind values
full ambient, secondary, primary
reduced secondary, primary
minimal primary
off none

isAnimationEffectEnabled(kind, level?) reads this mapping, defaulting to the current shared level. setAnimationLevel(level) applies changes live; subscribeAnimationLevel(listener) observes them without starting a timer.

subscribeAnimationSteps({ kind, intendedMs, onStep }) requires an effect kind. Enabled effects emit consecutive frames starting at 0 every max(1, round(intendedMs / 100)) shared ticks. Disabled effects have no clock subscription and emit { frame: undefined, static: true, now } every second from an unref'd data-refresh timer. There is no immediate callback; level changes reset the tick wait and preserve the next frame. Renderers must use the predicate to ignore supplied or retained animation frames when their kind is disabled.

The low-level subscribeAnimationClock(listener) shares one unref'd, aligned 100 ms timer at every non-off level and stops after its last subscriber leaves. Callers using it directly must gate their effect subscriptions themselves. getAnimationPeriodMs(level?) returns 100, or undefined at off; getAnimationCycleSteps(cycleMs, level?) gives clock steps per cycle, not effect enablement. Reading these helpers never starts a timer. The v3 clock registry is separate from, and never reads or mutates, legacy v1/v2 clocks.

Work-panel rows are secondary: they animate at full/reduced, and use the mode-aware static running glyph with second-level refreshes at minimal/off.

Panel primitives and list editor

The ./panel entry also exports WORK_PANEL_ANIMATION_INTERVAL_MS (100 ms), the shared clock cadence for animated work rows. workPanelRenderStatus(row) exposes the host's presentation classification; only running and in_progress animate.

Import TUI panels from @thoth-agents/pi-core/panel, like the existing /history-panel subpath. This entry needs the optional pi-tui peer; the root entry remains importable without runtime Pi peers. No runtime coding-agent import is added.

The ./panel entrypoint exports registerEditorSlot for shared above-editor rows, active editor replacements, and terminal-input routing. Contributions retain editor callbacks and app actions. Expanded questions acquire focus through owned-overlay handles; collapsed questions return input to the editor, and teardown preserves visible foreign-overlay focus.

  • workPanelRowLineCount(row, width, measure?) measures the same responsive continuation lines as the row renderer, without styling offscreen rows.
  • renderWorkPanelRow(row, { width, now, theme, clip, measure?, selected?, last? }) renders the same data-row block as the host: semantic status glyphs, responsive identity/metric continuations, extra segments and completed strikethrough. Width is the available body width; returned lines include tree gutters and are clipped to that width. It does not apply retention, headings or row caps.
  • panelVisibleWidth(text), truncatePanelText(text, width) and padPanelText(text, width) use terminal cells and preserve ANSI/OSC styling.
  • renderPanelFrame({ title, rows, width, maxHeight?, theme? }) draws the titled rounded frame using current render-kit glyphs and tones, with a native fallback and tiny-size degradation. PanelRow is { text, selected?, tone? }; renderPanelRow(row, width, theme?) fills selected rows with selectedBg after padding, and panelHintRow(text) creates a muted hint. createPanelFrame({ theme?, text, borderTone? }) composes styled border spans, side content, multi-column cells and junction/divider rows; text supplies clipping, padding and measurement for legacy-compatible output. renderPanelCard({ title, body, width, maxHeight?, theme, text }) selects a semantic render-kit card or the native frame through the same primitive.
  • normalizePanelKey(data, matchesKey?) recognizes terminal/application arrows, home/end, enter, escape, backspace, space and Ctrl-U, including Pi's native CSI-u/kitty encodings by default; Ctrl-C is escape. matchesPanelKey exposes native Pi matching without list-editor aliases. Literal terminal controls take precedence over custom bindings; ordinary input keeps its case. panelMouseClick(data) and panelMouseWheelDelta(data) parse SGR/urxvt/X10 input, preserving the history helpers' behavior and exports.
  • panelViewport(count, cursor, budget, maxRows?) returns { start, end, notice? } with an exclusive end and cursor-centered window. The budget includes a range notice; maxRows caps only choices. PanelDiscardConfirmation provides request(dirty), active, handleInput(data) and rows(): d discards, k/esc resumes.
  • openPanelOverlay<T>(ctx, factory, options?) wraps openOwnedOverlay with centered 96% width / 90% maximum height by default; owned-overlay options can override geometry. The factory receives (tui, theme, keys, close, host); pass host.maxHeight and host.requestRender to the shell. Height is resolved from terminal rows on every render. In regular TUI mode, the host enables mouse reporting for wheel navigation and releases it on close, rejection or disposal (nested panels share a terminal lease). Fullscreen-owned tracking is left untouched.

createListEditor(options) returns a ListEditor component (render, handleInput, invalidate, getState) with openPicker(view, initialIndex?) and showOverview(). Options include overview, wideBreakpoint (outer width, 84 by default; use 102 for models), maxHeight, theme, matchesKey, optional pendingCount, onSave, onSaved, onCancel and requestRender.

A view supplies title, rows() and optional hints, header(context), footer(context), maxVisibleRows, navigation: 'clamp' | 'wrap' and filter. Rows are { id, label, dirty? }; labels may be functions of { layout: 'wide' | 'compact', width, index, selected, filter }. The width excludes frame padding and selection/dirty markers. Domain drafts and persistence stay in the adapter. onAction(key, row, editor) returns true to consume custom actions (e.g. opening another picker, toggling tools or resetting a profile).

Navigation and typed filters precede custom actions. Arrows/j/k move; home/end and g/G jump (g/G become text once a filter is nonempty). A view can set homeEndAliases: false to release g/G for custom actions or typed filters; home/end still jump, and other views retain the aliases by default. Filter views consume other printable text, including q/s; backspace removes one code point and Ctrl-U clears. Filters can supply text(row) or matches(row, lowercaseQuery). Opening/replacing a picker resets its filter/cursor; returning to overview restores its cursor. Wheel input moves selection without wrapping. An unhandled picker enter/esc/q returns to overview; an unhandled overview s saves and esc/q cancels.

onSave returns { success, error?, warning? } (or a promise), or undefined for success. Failures remain editable and retain discard protection even after a partial save clears dirty rows. Thrown persistence errors become visible failures. Save/cancel input is blocked while an asynchronous save is pending; completion callbacks fire once. Dirty cancellation asks for d discard or k/esc resume. Height fitting prioritizes save messages, hints and the selected row over metadata and footers; a terminal too short for a frame shows only its title.

Development

From the repository root:

pnpm --filter @thoth-agents/pi-core run typecheck
pnpm --filter @thoth-agents/pi-core run test

MIT; copyright thoth-agents contributors.

Work panel v2

registerWorkPanelProvider(pi: ExtensionAPI | ExtensionContext,
  provider: WorkPanelProvider): () => void;
ensureWorkPanel(ctx: ExtensionContext): Promise<() => void>;
isWorkPanelRootEditorInputActive(ctx: ExtensionContext): boolean | undefined;
bindWorkPanelLifecycle(pi: ExtensionAPI, ctx: ExtensionContext): () => void;
getWorkPanelLifecycle(ctx: ExtensionContext): WorkPanelLifecycleState;

WORK_PANEL_VERSION is 2. Providers carry version, id, label, priority, visibleCount, listRows(now), detail(id, now, { logTailLines? }), armCloseLabel(row) and close(id). Optional methods are summary, open, showSection, parentRow and onVisibleChanged. Use priorities 10/20/30 for Agents/Todos/Background. Lists retain provider ordering and empty sections hide. visibleCount() is advisory; the host derives interaction cues and the footer count from selectable rows in shown sections, excluding summaries and expired rows. summary() returns counter text or { text?, segments?, running?, queued?, completed?, failed?, cancelled?, total? }. Return an empty close label for items with no close action.

The process-wide Symbol.for('thoth.pi-core.work-panel') registry shares providers and in-flight installations across bundled copies. Each session manager/session id contributes one above-editor widget and input routing through the shared editor-slot owner, which installs one editor factory and terminal-input listener per session. Await ensure in session-start handlers; each call returns an idempotent, reference-counted release. The final release or final provider unregister tears down the host. Registering with pi also hooks session shutdown automatically. Context-only callers must release on shutdown themselves. Provider unregister is token-owned, so a stale disposer cannot remove a replacement registration. Session shutdown removes the host, not providers; extension owners unregister providers on unload.

Rows are plain data: id and primary, with optional name, status/tone, elapsed and navigator metadata. segments carry unstyled text and semantic roles. For responsive rows, identity carries the name, secondary label and attention segments; metrics is an array of { key?, segments, continuation?, columnsOnly? } groups. The host shrinks secondary labels before names, protects warning/error attention, joins metrics with the semantic separator, and moves them into wrapped continuations when they cannot fit inline. Each group's optional continuation supplies a compact counterpart. Fixed extraRows / extraSegments are also data. Continuations and informational summary: true rows are not selectable. The completed segment role renders dim text with the current theme's strikethrough (plain dim text if unavailable); providers must not pre-style rows. There is no row.render or other row callback.

Optional metric key is tools | tokens | context | speed | cost | elapsed | model. Keyed numeric groups reserve each formatted value's maximum width: tokens 5 cells (e.g. 1000k, 99.9M), context 6 (100.0%), speed and tools 3 digits, cost 6 (999.99, excluding currency), elapsed 7 (59m 59s, 99h 59m). Icons, labels, spaces and separators add their measured cell widths; tokens reserve both input and output independently. Keep each formatted value including its unit suffix in one segment. Larger formatted fields add their excess width; model and unkeyed groups use their measured width. Compact continuation values use the same reservations. Packing and wrapping use these reservations, but greedy text is not padded; identity clipping still uses the space actually left by the displayed metric text.

renderWorkPanelRow from @thoth-agents/pi-core/panel accepts the optional degrading metricLayout: 'columns'. All groups must be keyed; otherwise it retains greedy packing. Columns are right-aligned in their reserved widths and dropped in order: tokens, cost, model, then tools/context/speed; elapsed is retained last (clipped only if the available width cannot hold it). The host keeps the default 'greedy' layout and excludes columnsOnly: true groups from both inline metrics and continuations. Providers can use this for sidebar-only model·effort metadata without changing host rows. Use the same layout for height measurement: workPanelRowLineCount(row, width, measure, { metricLayout: 'columns' }). For non-dropping sidebar grids, use metricLayout: 'grid' and pass the same metricGrid: createWorkPanelMetricGrid(rows, measure) schema to render and measure. The schema aligns fixed-order wrapped columns across rows, including empty fields; model reserves 16 cells with ellipsis, while numbers beyond reservations widen shared columns without truncation. Inline fit reserves 24 identity cells for the whole panel; identity clips with ellipsis into the remaining room. The host's default layout is unchanged.

statusGlyph is a closed semantic union: RenderStatus | 'taskInProgress', never a literal glyph or function. running / in_progress resolve through the kit's indicator(..., { frame }); explicit running overrides use native spinner frames without a kit. Terminal statuses use resolveStatusGlyph; taskInProgress uses the semantic task icon (native ◇). Rows without an override retain the host's native status fallback. Invalid overrides fall back to the row status. Set refreshIntervalMs to request ticks (minimum 100ms); the host ticks only while that provider has running/in-progress rows. Notify through onVisibleChanged for all state changes; transient expiresAt rows also request a one-shot expiry render. Timers stop on teardown.

The preferred item cap is 3 per section (rowCap overrides it), not a hard limit: caps are exceeded when the total space budget permits. The entire panel, including its hint row, fits 12 rows or half the terminal height, whichever is smaller. Hidden items show +N more; navigation still traverses all items and keeps the selected item visible. At very short heights, lower-priority sections may be omitted to preserve the selected item's metrics. There are no blank separator rows. Render-kit discovery happens on every render, with unframed native output when absent.

Normal-mode section headings, sidebar headers and the editor-replaced summary share formatCompactWorkCounts from the root export. Work counts are positive-only status glyphs immediately followed by numbers, ordered running, queued, completed, failed, cancelled: ◐1 ○2 ✓30 ✗1 ■1. Each whole count uses its semantic theme role: accent, dim, success, error, muted. Todos uses plain completed/total (12/15), without a glyph; other work sources never show a total denominator. Queued is omitted below 36 columns of available width. When counts exceed their room, cancelled is dropped first, then queued; remaining counts may still need clipping. The formatter accepts { kind: 'todos' | 'work' | 'other', summary, width, room, measure } and returns count segments (join with one unstyled space), plain text or undefined. The caller supplies cell measurement and decides heading chrome and available room. Nonnumeric provider text/segments remain unchanged.

Numeric provider counters are authoritative; missing counters fall back to semantic row statuses before retention, never retention states. Timed-out work counts as failed, cancelled and interrupted work as cancelled, and expected exits as completed; resolved failures count as neither failed nor completed, matching Background. Headings keep their existing glyph, label and separator chrome.

While an editor-slot replacement is active (for example, an expanded question), these sections become one read-only summary row in priority order, excluding absorbed or empty sections. Each label is followed by the shared counts, for example Agents ◐1 ○2 ✓30 ✗1 ■1 · Todos 12/15 · Background ◐1 ✓2. Sections are joined with · and the row is cell-width clipped with an ellipsis, so longer summaries may hide later labels. A task list lacking a complete fraction keeps its normal N items heading fallback and shows only its label in summary mode; no total is invented. There is no row if no section is visible, no hint or focus cue, and no panel key is consumed. Any panel focus is released without dismissing overlays or detail cards; those cards still close when another UI actually takes focus. Collapsing, answering, cancelling or disposing the replacement restores the full panel on the next render. The read-only isEditorReplaced(tui) query from ./panel shares occupancy across bundled copies. When initializing a compatible registry, a new copy adds the query if absent, including after /reload; legacy owners without the query and incompatible records still keep the full panel. Provider contract v2, retention, linger and sidebar absorption are unchanged.

The panel's bottom-left hint row appears only when a selectable row exists across its shown sections: dim ← interact when unfocused and selection-aware controls when focused. The footer shows ← work · N, counting only selectable rows. Left focuses only with selectable rows and a truly empty root editor; otherwise it passes through. Unfocused up/down retain Pi's history behavior. Focused: up/down move across sections, Enter opens, x (only for closable items) requires two presses within 3 seconds, Esc/right release. Editor identity, focused component and overlay guards fail closed. Providers with a custom open (such as subagents) show their own UI instead of the generic detail card. The host suspends input before invoking open(id, ctx) and releases focus when it settles: custom UI providers must return a promise that resolves only after their UI closes. Without open, a centered, framed opaque detail card shows styled metadata, section headings and evidence. Its size is fixed for the largest item in the opened section, capped by the terminal; card up/down stays within that section and stops at its ends. Folding appears only for oversized content, log-tail l only when the provider supports it, and x only when the item is closable. The card auto-closes and releases panel focus when another overlay, custom UI, native dialog or replaced editor takes focus, leaving Esc to the new UI. SDK/TUI imports are lazy and optional; missing focus hooks disable interception instead of guessing.

isWorkPanelRootEditorInputActive(ctx) is a read-only query of that session's editor-identity, focused-component, overlay and suspension guards. It installs nothing, consumes no input and does not require an empty editor or Work focus. It returns true only when the installed host's root editor is input-active, false when guarded, and undefined when the session has no installed host. Separate task-mode controls can reuse it; callers own their no-host fallback.

Discovery and actions

All discovery exports live at the package root; no TUI import or installed host is needed to list or read sources.

listWorkPanelSources(): WorkPanelSource[];
subscribeWorkPanelRegistry(listener: (id: string) => void): () => void;
getWorkPanelSourceRows(id: string, options: { maxRows: number; respectRowCap?: boolean }): WorkPanelRow[];
invokeWorkPanelAction(ctx: ExtensionContext, id: string,
  rowId: string | undefined, action: 'open' | 'history' | 'close'):
  Promise<'ok' | 'unavailable' | 'missing'>;

Discovery defaults to the provider's display row cap. Read-only consumers with their own retention policy can pass respectRowCap: false; maxRows still bounds the returned data. Uncapped discovery also requests listRows(now, { includeHistory: true }); the host and capped discovery never request history. Providers may ignore this optional argument. Session scoping and completion timestamps remain provider-owned.

Sources report id, label, priority, version, revision, selectableHeading, selectableSummary and rowCap (default 3), sorted like the host sections. Optional summary exposes only running, completed, failed, total and text from the provider, as a fresh data-only copy. String summaries become { text }; absent or throwing summary reads leave it undefined without hiding the source or other providers. Discovery is still work-panel contract v2. Revisions increase per source on registration and every provider change, and continue across replacement/unregister/re-registration. Registry listeners run synchronously on register, unregister and provider change, receive the affected id, are isolated from failures, and stop after disposal. Subscription can claim an empty ownership slot but never installs UI; listing and reading do not claim it.

Reads retain provider ordering and data values, bounded by both the finite, floored requested maximum and the source cap. Missing/failed sources or invalid bounds return []. Reads do not apply host retention or height budgets; legacy render callbacks and executable top-level row members are discarded.

Actions require the caller's live same-session context, verified against the installed host's session-manager identity and session id. Open/history also require a UI-capable TUI context; stale, throwing, missing, uninstalled or disposed contexts/hosts fail closed without invoking providers. Pass undefined for the history row id. Open uses the provider UI or the host's generic detail card; open/history suspend host input until resolution or rejection. Close is an explicit invocation (the caller owns confirmation); unavailable close labels, parents and informational rows cannot close. missing means no source/item, unavailable means no usable host/context/action or an action failed, and ok means the action completed. Neither discovery nor actions expose provider closures.

The version-independent ownership slot remains first-owner-wins. Incompatible v1/v2 copies install no host, register no section and expose no discovery. Upgrade pi-subagents, pi-background-tasks and pi-todo together to >=0.3.0, then /reload; mixed versions hide incompatible sections whichever contract owns the slot. Subagent state is v2; background state, usage, requests and Render KIT remain v1.

Prompt retention

Providers may set retention: 'prompt', supply each item's state: 'running' | 'failed' | 'done' and terminal endedAt (Unix milliseconds), and implement openHistory(ctx). Normalize queued/stopping work to running, and timed-out work to failed. Presentation status, glyphs and formatting remain independent. Providers without this opt-in keep the behavior above, including the task-list's informational summaries and advisory row caps.

Opting-in extensions call bindWorkPanelLifecycle(pi, ctx) on session activation, before ensureWorkPanel(ctx). Only the first live binding for that session manager/session ID subscribes; later bindings return inert disposers. The owning binding's release, session replacement or shutdown unsubscribes and clears its state/candidates, allowing a fresh binding to take over. Binding installs no UI. getWorkPanelLifecycle returns a read-only snapshot { epoch, epochStartedAt, busy }; busy initializes from !ctx.isIdle() and is refreshed on agent_start/agent_settled, never agent_end.

Epoch boundaries are an observed-text heuristic, not an origin guarantee. The bridge retains the latest four non-blank interactive/RPC input texts observed while ctx.isIdle(). A before_agent_start advances the epoch only if its prompt exactly equals a retained candidate, consuming one matching occurrence. Earlier handlers' transforms are already part of the observed text. Later transforms or prompt expansion, handled inputs with no matching later run, distinct extension prompts/wake-ups, and steer/followUp entered while streaming do not advance it. Conversely, any run matching retained text advances it, including an extension run or another input transformed into a coincidentally equal prompt. Candidates survive unrelated/interleaved runs until matched, evicted or disposed.

While busy, or while that section has running items, rows eligible for the panel are all running items, all failures ending at/after epochStartedAt, and the three most recent completions ending at/after it. Missing/non-finite terminal endedAt values are ineligible. Provider ordering is retained; equal completion times prefer the provider's earlier rows. Legacy expiresAt does not apply to opted-in rows. Budget overflow counts only eligible rows, never older history or completions excluded by the hard cap. This filters the panel, not provider storage.

When idle with no running items, each opted-in section becomes one selectable heading: e.g. ◆ Agents · ✓4 ✗1. It stays visible while any completed, failed or cancelled count is nonzero, including cancelled-only history (◆ Agents · ■1); resolved-only history is hidden. Counts follow the shared semantic rules above; provide session counts explicitly when dismissed items are omitted from listRows. An empty opted-in section can still show those counts unless showSection suppresses it. Summary lines share the single cursor with item rows: left focuses, up/down move, Enter invokes openHistory(ctx), Esc/right release. They have no close action; the hint advertises history only when an opener exists. Like open, asynchronous openHistory must resolve only once its UI closes so host input stays suspended.

Sidebar UI preferences and decorative overlays

registerUIPreferences({ absorbedWorkPanelSources: ['agents'] }) returns an owner token. updateUIPreferences(token, preferences) replaces that owner's preferences; withdrawUIPreferences(token) releases them idempotently. getUIPreferences() returns an isolated snapshot of the union of all owners; subscribeUIPreferences(listener: () => void) returns an unsubscribe function. Registration and updates copy the supplied source list; stale tokens cannot update or withdraw another owner. Notifications are synchronous and isolate throwing listeners; subscription has no initial replay. These preferences are process-wide, shared across bundles under Symbol.for('thoth.pi-core.ui-preferences.v1'). Hosts refresh immediately on changes: absorbed sources neither render nor participate in selection or left-arrow focus. Releasing the last absorbing owner restores them. An owner may supply isActive?: () => boolean with its preferences. Each getUIPreferences() collection evaluates it; false or throwing predicates exclude only that owner's sources. The host collects on every render, selection and left-arrow focus check, so a foreign UI takeover restores rows without a separate refresh tick or polling. Owners should check their actual display ownership and still withdraw preferences when they detect display loss. Updates replace the predicate as well as the source list; omission means always active. This optional field retains the v1 registry key and shape compatibility: old hosts ignore it and still rely on explicit withdrawal; no mixed-version host upgrade is implied. Discovery and explicit source actions remain available to read-only consumers.

registerDecorativeOverlay(tui, component) marks a component for that TUI and returns an idempotent disposer. Register before mounting a non-capturing sidebar overlay. An undefined result means unsupported private overlay shape or a pre-feature first-owner editor/work-panel host: disable the regular-mode sidebar with a diagnostic, without mounting the overlay. Existing ownership slots are never upgraded in place; update related extensions and /reload. hasBlockingOverlay(tui) ignores registered decorative components, but yields to every other visible overlay, including non-capturing ones. Visibility uses hidden and options.visible(columns, rows), not rendered bounds; unsupported stacks fall back to hasOverlay() conservatively. Disposing a registration does not hide its overlay, so callers must also release the Pi overlay handle.

Shared session cost

These helpers are exported from the package root, with no runtime Pi/TUI imports. The theme status line and sidebar Session panel use the same arithmetic.

computeSessionCost(entries, { subscriptionProviders, providerOf } = {}) returns { cost, isSubscription }. It sums assistant/tool-result usage plus compaction, branch-summary and usage entries. providerOf: () => ctx.model?.provider samples the current provider for subscription classification; historical providers are not used. The caller supplies its configured subscription provider list.

combineSessionAndSubagentCost(sessionCost: number, latestSubagentCost = 0) returns the combined numeric cost (pass the result's cost, not the object). The subagent usage channel publishes cumulative snapshots: replace the previous snapshot with data.totalCost, never accumulate snapshots, and reset it when changing sessions. These helpers do not format output or subscribe to lifecycle events.

Shared path formatting

Root export formatCwd(cwd: string, home?: string): string abbreviates home as ~ with native platform separators, preserving paths outside home byte-for-byte. It uses only node:path, with no Pi/TUI dependency; the theme uses this same helper.

The render-kit v1 semantic glyph union includes elapsed: native fallback ◷ is used when an older kit omits or rejects the lookup. No render-kit version bump.