@thoth-agents/pi-core
Typed, versioned event contracts for the Thoth Pi ecosystem.
Package details
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, validatedTodoSnapshot.TODO_STATE_REQUEST:thoth:todo:state:request, version 1, validatedTodoStateRequest.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: numericid, stringsubject, optional stringdescription,activeFormandowner,status, and required numeric arrayblockedBy.TodoSnapshot:tasks: TodoTask[],nextId: number, andcounts: 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: requiredid,agent,mode,status,createdAt; optionaldisplayName,model,effort,startedAt,endedAt,lastActivityAt,usage,preview. IDs and agent names are non-empty strings.SubagentTaskUsage: optionalinputandoutputtoken counts andcost. 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 integertotal. Producers normalize missing persisted status counts to zero.previewis a string of at mostSUBAGENT_PREVIEW_MAX_LENGTH(800) UTF-16 code units; producers truncate before publication.SubagentsUsageSnapshot: exactlytotalCost(finite non-negative number) andrunCount(non-negative safe integer), cumulative for the envelope's parent session. There is noparentSessionIdin data. This channel replaces the rawthoth:subagent-usagebus event; the persistence discriminator is unchanged.
Background tasks
BackgroundTaskStatus:running | succeeded | failed | cancelled | timed_out.BackgroundTaskKind:process | command_watch.BackgroundTaskSummary: requiredid,kind,status,createdAt,startedAt; optional stringname, lifecycle timesendedAt,deadlineAt,lastCheckedAt,lastProgressAt,stopRequestedAt,dismissedAt, andexitCode,signal,progress,dismissed. IDs are non-empty strings. Metadata without a separate creation time uses itsstartedAtascreatedAt.exitCodeis a signed safe integer or null;signalis a non-empty string or null.dismissedis boolean.progressis a finite number or a string of at mostBACKGROUND_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.BackgroundCountsrequires 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): booleanaccepts only a validProviderLimitEntry; invalid reports returnfalse, 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 booleanoverageInUse,overageEnabled,isUsingOverage. Window keys/types are open strings, includingfive_hour,seven_day,seven_day_opus,seven_day_sonnet, andoverage. 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. WhenresetsAt <= now, that entry reads asallowedwith noutilizationfield; the stored observation is not changed and listeners are not notified by reads. Other metadata is retained.subscribeProviderLimits(listener: ProviderLimitListener): () => voidruns 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;
RenderKitThemeis a structuralThemesubset:fg, optionalboldandstrikethrough.ComponentandThemeColorare SDK/TUI types, not runtime dependencies. AllRender*option/result types are exported from the root.- Cards accept optional
title,body,sections: { title?, rows }[],footer,status,context,summary,isError,isSuccessandwrap. Status plus toolcontextrequests the standard footer unlessfooteris supplied;summarysupplies terminal summary text. OnlyisSuccess: trueselects a success border;isErrortakes precedence. Status alone does not change the border tone. PassisSuccessconsistently 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) => rowscallback; 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-rowbudget(default 8;Infinitydisables folding), and optional completeexpandHint. 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,invalidateand sharedstate. Options accept an explicitstatus,elapsedMs(producer-owned background runtime),labeland optional caller-ownedframe. A finite frame selects the ten-frame braille sequence (⠋,⠙, …), floored and wrapped, forrunning/in_progressonly; 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 styledglyph,elapsed,textand optional numericelapsedMs;elapsedis 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,statusandsuffix. Tree rows take pre-styledtext, optionaldepth,last,selectedandstatus. Both are width-aware. Keyboard/navigation behavior stays with the producer; usefgfor other SDK theme-role styling. To group content without truncation, measure the visible width oftreeRowwith emptytextand 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)useskit.toolFooterwhen available, otherwise a plain whole-second footer.getToolElapsedMs(options)gives explicitelapsedMsprecedence over shared context timing, starts timing on executing running renders and freezes it on terminal status; unknown time staysundefined. Neither helper creates timers. detachRenderContext(context)shallow-copies a render context withlastComponent: 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?)andresolveStatusGlyph(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)andpadPanelText(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.PanelRowis{ text, selected?, tone? };renderPanelRow(row, width, theme?)fills selected rows withselectedBgafter padding, andpanelHintRow(text)creates a muted hint.createPanelFrame({ theme?, text, borderTone? })composes styled border spans, side content, multi-column cells and junction/divider rows;textsupplies 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.matchesPanelKeyexposes native Pi matching without list-editor aliases. Literal terminal controls take precedence over custom bindings; ordinary input keeps its case.panelMouseClick(data)andpanelMouseWheelDelta(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;maxRowscaps only choices.PanelDiscardConfirmationprovidesrequest(dirty),active,handleInput(data)androws(): d discards, k/esc resumes.openPanelOverlay<T>(ctx, factory, options?)wrapsopenOwnedOverlaywith centered 96% width / 90% maximum height by default; owned-overlay options can override geometry. The factory receives(tui, theme, keys, close, host); passhost.maxHeightandhost.requestRenderto 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.