@modelprofile.com/flexharness
Provider-neutral model-session runtime with durable history, permissions, typed events, and pluggable local or remote tool execution.
Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.
Install
pnpm add @modelprofile.com/flexharness
Node.js 24 or newer is required.
Overview
FlexHarness owns scope isolation, public session and message projections, permission decisions, event delivery, cancellation, and persistence coordination. Each session is backed by a SmartAgent AgentSession, which owns the canonical private conversation and runtime event history. Model selection, tool execution, and optional execution-context creation remain application-defined extension points. The package depends on SmartAgent but does not expose AI SDK or SmartAI imports as part of its API.
Core Setup
import {
FlexHarness,
JsonFileFlexHarnessStores,
type IFlexResolvedModel,
type TFlexAgentToolSet,
} from '@modelprofile.com/flexharness';
interface IProjectScope {
projectRoot: string;
}
const stores = new JsonFileFlexHarnessStores({
directory: '/var/lib/my-app/model-sessions',
});
const harness = new FlexHarness<IProjectScope>({
scopeResolver: {
async resolveScope(scopeId) {
const project = await projectRegistry.get(scopeId);
return {
// Aliases that resolve to this same key share sessions and save ordering.
storageKey: project.accountAndProjectKey,
scope: { projectRoot: project.root },
};
},
},
modelResolver: {
async resolveModel({ scope, modelHint, signal }): Promise<IFlexResolvedModel> {
const configured = await modelRegistry.resolve({ scope, modelHint, signal });
return {
model: configured.model,
identity: {
provider: configured.providerId,
model: configured.modelId,
displayName: configured.label,
},
providerOptions: configured.providerOptions,
};
},
},
toolProvider: {
async provideTools(context) {
const tools: TFlexAgentToolSet = await createProjectTools({
root: context.scope.projectRoot,
signal: context.signal,
requestPermission: context.requestPermission,
});
return {
tools,
close: async () => closeProjectTools(tools),
};
},
},
stores,
builtInTools: {
renameSession: true,
projectManagement: {
// task, goal, and scratchpad default to true when this block exists.
},
},
toolOutputLimits: {
maxDepth: 12,
maxBytes: 256 * 1024,
},
callbackLimits: {
maxEvents: 10_000,
maxOutputBytes: 1024 * 1024,
maxParts: 2_000,
},
promptQueueLimits: {
maxOutstandingPromptsPerSession: 16,
maxOutstandingBytesPerSession: 64 * 1024 * 1024,
maxPendingAdmissions: 64,
maxPendingAdmissionBytes: 128 * 1024 * 1024,
maxTerminalEntriesPerSession: 64,
},
reversionLimits: {
maxCompletedTurns: 100,
maxSegments: 300,
maxExcludedRunIds: 1000,
maxPendingReversionReleases: 1000,
},
subagents: [
{
name: 'researcher',
description: 'Research a focused question and return one final answer.',
modelHint: 'reasoning-model',
system: 'Investigate the assigned question. Return a concise evidence-based answer.',
maxSteps: 8,
},
],
maxSubagentDepth: 1,
maxSubagentCallsPerRun: 32,
externalErrorProjector: (_error, context) => ({
name: 'ModelOperationError',
message: `The ${context.source} operation failed.`,
code: 'MODEL_OPERATION_FAILED',
}),
});
modelRegistry, projectRegistry, createProjectTools, and closeProjectTools in this example are application-owned integrations. FlexHarness passes the same run AbortSignal to the model resolver and tool provider.
Resource Tool Providers
resourceToolProviderResolver composes zero or more resource-owned providers with the existing application toolProvider. The resolver runs fresh for every prompt and returns the current resource attachment descriptors:
resourceToolProviderResolver: {
async resolveResourceToolProviders({ scope, sessionId, runId, signal }) {
const attachments = await resourceRegistry.listAttached({
scope,
sessionId,
runId,
signal,
});
return attachments.map((attachment) => ({
resourceId: attachment.resourceId,
attachmentRevision: attachment.attachmentRevision,
provider: createResourceToolProvider(attachment),
}));
},
},
Each descriptor uses the existing IFlexToolProvider<TScope> contract. Its provider receives the normal run context and must return a fresh run-scoped handle. The original toolProvider remains optional and its tool names remain unchanged. Resource tool names are deterministic and bounded:
resourceIdentityis the lowercase hexadecimal SHA-256 ofJSON.stringify([resourceId, attachmentRevision]).- The namespace is
resource_plus the first 16 digest characters. - The exposed name is
<namespace>__<stem>__<toolDigest>.stemreplaces characters outside[A-Za-z0-9_-]with_, keeps the first 16 characters, and falls back totool;toolDigestis the first 12 lowercase hexadecimal characters of SHA-256 over the original tool name.
The resolver accepts at most 128 descriptors per run. resourceId must be non-empty and at most 512 UTF-8 bytes, attachmentRevision must be a non-negative safe integer, and each original resource tool name must be non-empty and at most 512 UTF-8 bytes. FlexHarness rejects duplicate resourceId values even across revisions, duplicate derived namespaces, and duplicate final exposed tool names before model execution. Descriptor identity and namespace validation completes before any application or resource provider is acquired.
Resource permission requests are scoped with the complete 64-character resourceIdentity, not the shortened tool namespace. FlexHarness rewrites kind to resource.<resourceIdentity>.<providerKind> and an optional rememberKey to resource:<resourceIdentity>:<providerRememberKey>. Harness-owned metadata contains resourceId, attachmentRevision, resourceIdentity, and toolNamespace; provider metadata is nested under providerMetadata, so it cannot override attachment identity.
FlexHarness owns every acquired handle. Normal close and partial-failure cleanup run in reverse acquisition order, attempt every handle, aggregate multiple failures, and retain failed cleanup for retirement or disposal retry. Cancellation uses the same path. If model resolution fails while a resource provider is still settling, a late returned handle remains tracked and disposal waits for its closure. Application and resource providers may not define a harness built-in name while that built-in is enabled for the current run. Disabled names are not reserved.
Project Management Tools
Harness-owned project tools are opt-in and session-local:
builtInTools: {
renameSession: true,
projectManagement: {
task: true,
goal: true,
scratchpad: true,
},
},
renameSession enables rename_session. The projectManagement block enables the public project-management APIs and contains the model-tool flags; task, goal, and scratchpad each default to enabled unless explicitly set to false. Without that block, the public project-management APIs reject with FlexHarnessValidationError, while the required stores.projectManagement domain still participates in session cleanup. With no builtInTools configuration, none of these four tools is present. Constructor options are copied and frozen.
Project-management records use FLEX_PROJECT_MANAGEMENT_SCHEMA_VERSION, currently 1, and form a strict live-or-tombstone union:
interface IFlexProjectManagementSnapshot {
schemaVersion: 1;
revision: number;
sessionGenerationId: string;
sessionGenerationSequence: number;
goal?: string;
scratchpad: string;
tasks: Array<{
id: string;
content: string;
status: 'pending' | 'in_progress' | 'completed' | 'cancelled';
priority: 'high' | 'medium' | 'low';
createdAt: string;
updatedAt: string;
}>;
}
interface IFlexProjectManagementTombstone {
schemaVersion: 1;
revision: number;
sessionGenerationId: string;
sessionGenerationSequence: number;
deletedAt: string;
}
type TFlexProjectManagementRecord =
| IFlexProjectManagementSnapshot
| IFlexProjectManagementTombstone;
The tools use strict action-discriminated inputs:
task:list,create,update,delete, orclear. Create defaults topendingandmedium.goal:get,set, orclear.scratchpad:get,set,append, orclear. Append concatenates the supplied content exactly.rename_session: sets the active session title and returns the authoritative session.
Every project action returns the authoritative revision and state; task mutations also return the affected task, and clear returns the removed tasks. Reads never save. A set, clear, append, update, idempotent create, or empty task clear that makes no state change returns the current revision without writing. Mutations load once, apply once, validate the complete next snapshot, and issue one compare-and-swap save at revision + 1. FlexHarness never retries or merges an external conflict.
Tool task creation accepts an optional id. When omitted, FlexHarness requires the stable SmartAgent toolCallId and derives task_ plus the SHA-256 of JSON.stringify(['flexharness-project-task-v1', storageKey, sessionId, runId, toolCallId]). Repeating an explicit or deterministic ID with identical content, status, and priority is idempotent; different creation data conflicts. Application callers must supply an explicit id to createProjectTask() because no tool-call identity exists at that boundary.
The same engine is available to applications:
await harness.getProjectState(scopeId, sessionId);
await harness.listProjectTasks(scopeId, sessionId);
await harness.createProjectTask(scopeId, sessionId, { id, content, status, priority });
await harness.updateProjectTask(scopeId, sessionId, { id, content, status, priority });
await harness.deleteProjectTask(scopeId, sessionId, id);
await harness.clearProjectTasks(scopeId, sessionId);
await harness.getProjectGoal(scopeId, sessionId);
await harness.setProjectGoal(scopeId, sessionId, goal);
await harness.clearProjectGoal(scopeId, sessionId);
await harness.getProjectScratchpad(scopeId, sessionId);
await harness.setProjectScratchpad(scopeId, sessionId, content);
await harness.appendProjectScratchpad(scopeId, sessionId, content);
await harness.clearProjectScratchpad(scopeId, sessionId);
Public writes use { actor: 'application' }. Tool writes use { actor: 'agent', runId, toolCallId, agent? }, allowing custom stores to preserve attribution. Project side effects commit independently of the later model outcome and are intentionally outside transcript undo/redo.
FLEX_PROJECT_MANAGEMENT_LIMITS exports the hard UTF-8 and aggregate limits: goal 8 KiB, scratchpad 128 KiB, task content 8 KiB, task ID 512 bytes, title 2048 bytes, 512 tasks, and a 1 MiB serialized snapshot. The aggregate bound leaves room for worst-case JSON escaping of a controller-valid scratchpad. Loaded snapshots reject extra fields, duplicate IDs, invalid status/priority/timestamps, non-JSON data, wrong schema/revision, and every exceeded bound before use.
IFlexProjectManagementStore is exact per (storageKey, sessionId): load, CAS save, CAS tombstoneSession, and purgeNamespace must not collapse multiple sessions or storage namespaces. load() returns TFlexProjectManagementRecord | undefined and receives the current session generation as an optional third argument; FlexHarness always supplies it, while two-argument callers and stores remain compatible. Same-generation live saves use normal revision CAS, and a same-generation tombstone permanently rejects later live saves. A higher sessionGenerationSequence with a different strong sessionGenerationId may replace only an older tombstone using expected revision 0; it cannot replace a live record. This resets the PM revision for a recreated core session while stale saves and tombstones from older generations remain fenced. Deleting a recreated session that made no PM writes still replaces the prior-generation tombstone with a revision-1 tombstone for the new generation.
Every newly created core session exposes and persists a strong random sessionGenerationId plus its monotonic sessionGenerationSequence. A legacy scope session without those fields is assigned a deterministic bounded ID derived from its immutable storageKey, sessionId, and createdAt; FlexHarness persists the repaired scope snapshot before accepting work. Grouped core deletion tombstones retain both fields after live metadata is removed.
Normal Flex session cleanup always waits in-flight local project operations, then loads and CAS-tombstones stores.projectManagement, regardless of whether PM tools are enabled in that harness. If a concurrent same-generation save wins first, cleanup reloads and retries within a bounded attempt count; unresolved conflict or store failure retains the core Flex session cleanup tombstone for a later retry. The durable PM tombstone is not physically removed during normal session cleanup.
purgeNamespace(storageKey) is the explicit destructive reclamation operation and physically removes every live record and tombstone in that exact PM namespace. Applications may call it only after serializing every scope alias, preventing new admission, awaiting retireScope() on every harness owner, and deleting or purging the application-owned core scope namespace. retireScope() itself remains non-destructive and never calls purgeNamespace(). Purging PM first, purging only one alias, or racing a stale harness can remove the fence that makes session-generation reuse safe.
InMemoryFlexProjectManagementStore is the standalone in-memory implementation. InMemoryFlexHarnessStores and JsonFileFlexHarnessStores include projectManagement as a required bundle member. assertFlexProjectManagementSnapshot() validates live records, assertFlexProjectManagementTombstone() validates tombstones, and assertFlexProjectManagementRecord() validates the union. createEmptyFlexProjectManagementSnapshot(sessionGenerationId, sessionGenerationSequence) returns a revision-0 live state for the supplied current generation.
Foreground Subagents
subagents enables a harness-owned built-in tool named delegate. It is available only when at least one definition exists and the current session depth is below maxSubagentDepth. An application or resource toolProvider must not return its own delegate tool while the built-in is enabled for that run. The built-in is foreground-only: the parent tool call does not complete until the child prompt reaches a terminal outcome.
The model calls it with this exact input shape:
interface IDelegateInput {
description: string;
prompt: string;
subagentType: string;
taskId?: string;
}
Before creating or resuming a child, FlexHarness requests permission on the parent run with kind: 'subagent.start', the parent toolCallId, and bounded agent/task metadata. The controller answers it through the normal permission APIs. This request has no rememberKey, so always is invalid; controllers use once or reject.
Each new invocation creates a durable child IFlexSession with immutable parentSessionId, origin parentRunId, origin parentToolCallId, agent, and depth. New public roots persist depth: 0; legacy schema-1 roots may omit it. These fields are harness-owned; public createSession() remains limited to sessionId and title. Child sessions reject direct prompt(), startPrompt(), enqueuePrompt(), and schedulePrompt() calls and run only through the foreground delegate tool. The model and tool resolver contexts receive optional immutable parentSessionId and agent values so integrations can apply agent-specific model and tool policy. Child prompts use the definition's modelHint, system, and maxSteps.
The parent tool part receives childSessionId in a cumulative part.updated event as soon as the child is acquired. If child model resolution completes, a later cumulative update adds model; failures before model resolution leave it absent. The terminal tool part retains every value that became available. A successful delegate call always has model identity and returns bounded JSON:
{
taskId: 'subagent_...',
status: 'completed',
text: 'The child final answer, limited to 64 KiB.',
model: { provider: '...', model: '...', displayName: '...', variant: '...' },
}
Omitting taskId creates a deterministic child for the parent session, run, and tool call. Repeating that same invocation does not create another child. If the deterministic child already has messages, FlexHarness reports an uncertain prior execution and never silently reruns it. This preserves SmartAgent's durable parent tool intent as crash authority; controllers use listUncertainToolExecutions() and reconcileToolExecution() for uncertain parent calls.
Supplying taskId deliberately resumes an idle, live child from a later run of the same immutable parent session and the same configured agent. It starts a new child prompt while retaining the child's original parent run and tool-call origin. A child owned by another parent or agent, a deleted child, an active child, a same-run resume, or a second acquisition of the same child within one later parent run is rejected. Parent cancellation propagates only to the exact child run started by that delegate call.
Limits are validated and frozen at construction: at most 32 unique definitions; names are non-empty and at most 128 UTF-8 bytes; descriptions 2048 bytes; optional model hints 512 bytes; optional system prompts 64 KiB; and optional maxSteps a positive safe integer. maxSubagentDepth defaults to 1 and must be a positive safe integer at most 8. maxSubagentCallsPerRun defaults to 32 and must be a positive safe integer at most 128. A call slot is consumed synchronously at the start of every schema-valid delegate execution, before semantic bounds, subagent type/depth validation, permission, or child work. Inputs rejected by the tool schema never start delegate execution and do not consume a slot. After successful semantic validation, the child ID is reserved for the rest of the parent run, including after permission rejection or later failure. Permission rejection creates no child session. Omitting taskId reserves a deterministic new child ID; supplying taskId reserves and resumes that existing child after permission. Delegate descriptions are non-empty and at most 256 UTF-8 bytes, prompts non-empty and at most 64 KiB, subagent types at most 128 bytes, and task IDs at most 512 bytes.
Sessions And Prompts
const session = await harness.createSession('project:billing', {
title: 'Invoice import',
});
const result = await harness.prompt(
'project:billing',
session.sessionId,
[
{ type: 'text', text: 'Extract the invoice totals.' },
{
type: 'file',
data: invoicePdfBase64,
mediaType: 'application/pdf',
name: 'invoice.pdf',
},
],
{ modelHint: 'document-model', maxSteps: 12 },
);
console.log(result.assistantMessage.parts);
console.log(result.usage);
TFlexPrompt is deliberately JSON-safe. It accepts a string or an ordered array of:
{ type: 'text', text }{ type: 'image', data, mediaType?, name? }{ type: 'file', data, mediaType, name? }
Attachment data is a string containing base64, a data URL, or a remote URL. Public input never requires Buffer or URL objects. Remote URL strings are converted only at the private SmartAgent invocation boundary.
Attachment payloads are never copied into public audit messages or events. Public attachment parts contain metadata only:
{
type: 'attachment',
partId: '...',
attachmentType: 'file',
source: 'inline-base64', // or data-url / remote-url
sizeBytes: 48231, // omitted when it cannot be determined
mediaType: 'application/pdf',
name: 'invoice.pdf',
}
When the turn succeeds, the original string remains only in canonical private Agent events so a later model turn can receive the attachment again. Failed, cancelled, resolver-failed, cleanup-failed, and persistence-failed turns do not add it to future context.
The main session methods are:
await harness.listSessions(scopeId);
await harness.createSession(scopeId, { sessionId, title });
await harness.getSession(scopeId, sessionId);
await harness.getMessages(scopeId, sessionId);
await harness.listMessagePage(scopeId, sessionId, { limit: 50, before: cursor });
await harness.getMessage(scopeId, sessionId, messageId);
await harness.listSlashCommands(scopeId, sessionId);
const command = await harness.executeSlashCommand(scopeId, sessionId, '/init focus on tests');
if (command.type === 'prompt-admission') {
console.log(command.admission.queueId, command.admission.runId);
await command.admission.completion;
}
await harness.updateSession(scopeId, sessionId, { title: 'Renamed', archived: true });
await harness.updateSession(scopeId, sessionId, { title: null, archived: false });
await harness.getProjectState(scopeId, sessionId);
await harness.createProjectTask(scopeId, sessionId, { id: 'tests', content: 'Add tests' });
await harness.setProjectGoal(scopeId, sessionId, 'Ship the next release');
await harness.appendProjectScratchpad(scopeId, sessionId, 'One durable note.');
await harness.deleteSession(scopeId, sessionId);
await harness.prompt(scopeId, sessionId, prompt, options);
const queued = await harness.enqueuePrompt(scopeId, sessionId, prompt, options);
console.log(queued.queueId);
await queued.completion;
const admission = await harness.startPrompt(scopeId, sessionId, prompt, options);
console.log(admission.queueId);
console.log(admission.runId);
await admission.completion;
const scheduled = await harness.schedulePrompt(
scopeId,
sessionId,
'refresh-index',
prompt,
{ debounceMs: 250 },
);
await harness.cancelScheduledPrompt(scopeId, sessionId, scheduled.scheduleKey);
await harness.getPromptQueueEntry(scopeId, sessionId, queued.queueId);
await harness.listPromptQueueEntries(scopeId, sessionId);
await harness.cancelPrompt(scopeId, sessionId, queued.queueId);
await harness.abort(scopeId, sessionId);
await harness.listPendingPermissions(scopeId, sessionId);
await harness.respondToPermission(scopeId, sessionId, permissionId, 'once');
await harness.pushRuntimeEvent(scopeId, sessionId, { type: 'workspace.changed', path: 'src/' });
await harness.listUncertainToolExecutions(scopeId, sessionId);
await harness.reconcileToolExecution(scopeId, sessionId, intentId, {
resolution: 'executed',
output: { committed: true },
});
const reversion = await harness.getSessionReversionInfo(scopeId, sessionId);
console.log(reversion.undoAvailable, reversion.redoAvailable, reversion.groups);
const undone = await harness.undoSession(scopeId, sessionId);
console.log(undone.revertedRunId);
const redone = await harness.redoSession(scopeId, sessionId);
console.log(redone.restoredRunId);
await harness.compactSession(scopeId, sessionId);
await harness.archiveSessionEvents(scopeId, sessionId, compactionEventId);
await harness.listBackgroundExecutions(scopeId, sessionId);
await harness.getBackgroundExecution(scopeId, sessionId, executionId);
await harness.abortBackgroundExecution(scopeId, sessionId, executionId);
await harness.retireScope(scopeId);
await harness.dispose();
Only one run may be active in a session. Additional prompts enter a bounded FIFO owned by FlexHarness, while different sessions can run concurrently. enqueuePrompt() resolves with { queueId, completion } after the immutable prompt and options have been accepted into that runtime queue and prompt.queued has been emitted. startPrompt() keeps its durable-admission behavior: it waits for its FIFO turn and resolves with { queueId, runId, completion } only after the canonical generation claim, run ID, and initial public audit messages have been durably reserved and the corresponding start events have been emitted. prompt() preserves the simpler behavior by awaiting completion internally.
Queue entries expose queued, starting, scheduled, running, completed, failed, and cancelled status through getPromptQueueEntry() and listPromptQueueEntries(). The list is ordered by process-local queueSequence. getPromptQueueEntry() throws FlexHarnessNotFoundError for an unknown or evicted ID. cancelPrompt() cancels one exact queue ID: a waiting entry leaves the FIFO and releases capacity immediately but remains queryable as cancelled until terminal retention evicts it; a promoted entry uses the canonical run cancellation path. Cancelling a terminal entry returns false, while an unknown ID throws. abort() remains scoped to the currently active run.
The displayed queue limits are the defaults. Outstanding count and byte limits apply per session and include every non-terminal queued or active prompt until it settles. Pending-admission limits apply to the complete harness while scope aliases are unresolved. Terminal retention applies per session. Exceeding an admission limit throws FlexHarnessQueueFullError.
Queue payloads, status records, and prompt.* queue events are process-local. The existing stores do not have a private generic queue domain: projections are deliberately redacted, Agent events are canonical conversation transactions, and jobs are SmartAgent background executions. FlexHarness therefore never writes a never-started prompt into those unrelated domains. A process restart drops never-started entries; a prompt that reached durable run admission continues to use the existing canonical recovery policy and is repaired to a safe terminal state instead of being replayed.
schedulePrompt() waits for its FIFO turn, performs the same durable admission, exposes session status scheduled, and starts model preparation after its bounded debounceMs delay. Schedule keys remain unique across waiting and active prompts. cancelScheduledPrompt() returns true only while the matching schedule key can still be cancelled. Cancelling while it is still waiting rejects the schedulePrompt() call itself; cancelling after durable admission rejects the returned completion and marks its reserved audit messages cancelled.
The reservation save is the admission point. A save failure produces no start events or active audit. If disposal begins while that save is in flight and the save commits, admission still resolves and its completion settles as cancelled; disposal waits for terminal finalization.
listMessagePage() returns the newest contiguous page in chronological order. limit must be an integer from 1 through 50 and defaults to 50. nextCursor is opaque, limited to 4096 UTF-8 bytes, bound to the resolved storage namespace and session, and remains stable when newer messages are appended. Mismatched and stale cursors fail validation. getMessage() performs an exact lookup. Transfer identifiers are limited to 512 bytes, text and reasoning parts to 96 KiB, complete messages to 480 KiB, and complete page envelopes to 512 KiB. A page may therefore contain fewer messages than requested. Oversized text is truncated and an otherwise oversized parts collection is replaced with an explicit elision marker; metadata that still cannot fit fails validation. Canonical private Agent events are unchanged.
updateSession() supports title replacement, explicit title clearing with null, and archive state through archived. Title-only updates remain available while prompts are queued or running, while permission is pending, and after archival. Requests containing archived are rejected while the session has any outstanding prompt or pending permission; a mixed title-and-archive request is rejected atomically without changing the title. Archived sessions expose archivedAt. Deleting a session cascades through its complete descendant subtree. One durable root-keyed tombstone group hides every newly affected live session, and the delete also joins any already-separate descendant cleanup groups without rewriting their roots. FlexHarness then cancels queued and active subtree work, emits terminal queue events, waits for admitted initialization, and purges runtime queue status while cleaning runtime and persisted domains child-first. The requested root tombstone is removed last after every domain confirms cleanup; project-management cleanup confirmation is a retained durable project tombstone rather than physical removal. A successful live deleteSession() call emits session.deleted for each session it newly tombstoned; retries of an existing tombstone and automatic load, retirement, or disposal cleanup emit no deletion events. Direct deletion of a descendant cascades only through that descendant's subtree. Cleanup authority follows the resolved storage namespace, so scope aliases share the same groups. A partial failure retains durable ownership for retry by a later deleteSession(), namespace load, retireScope(), or dispose() call.
abort() returns true only while cancellation is still accepted. Terminal persistence is the run's commit point; once it starts, abort() returns false and the already-fixed terminal outcome completes while the session remains busy.
Slash Commands
parseSlashCommand() is the public strict pure parser. It accepts at most 768 KiB, returns not-command for input not starting with /, malformed for invalid slash syntax, and parsed with the exact input, lowercase command name, separator-stripped raw argument text, and OpenCode-compatible quoted tokenization. Command names match [a-z][a-z0-9-]{0,63}. Single and double quotes group tokens and are stripped; escapes are not interpreted.
Applications register immutable custom commands at construction:
const harness = new FlexHarness({
// scopeResolver, modelResolver, and other options...
slashCommands: [
{
name: 'review-area',
description: 'Review one area of the workspace.',
template: 'Review $1 with these additional constraints: $ARGUMENTS',
},
{
name: 'refresh-index',
description: 'Refresh the application-owned workspace index.',
async handler({ scopeId, scope, storageKey, sessionId, rawArguments, arguments, signal }) {
return indexer.refresh({
scopeId,
scope,
storageKey,
sessionId,
rawArguments,
arguments,
signal,
});
},
},
],
});
compact, init, undo, and redo are reserved. listSlashCommands() verifies the scope and session and returns immutable data-only descriptors with kind, placeholder hints, current immediate availability, and workspaceReversion. compact, undo, redo, and custom handlers require an otherwise idle command session. Prompt templates and init use the normal bounded FIFO and may wait behind an active prompt. Their descriptors report availability from the same queue-admission conditions used by execution: lifecycle, slash ownership, pending reversion, root-session eligibility, outstanding count, and estimated prompt bytes. A dynamic capacity race may still produce FlexHarnessQueueFullError during admission. Only one slash-command execution may own a session at a time.
At most 128 custom commands may be registered. Every registration must be a plain object with exactly one of template or handler; names must match [a-z][a-z0-9-]{0,63}, be unique, and not use a reserved name. Optional descriptions must be non-empty and at most 2048 UTF-8 bytes. Templates must be non-empty and at most 768 KiB, and the expanded prompt must also fit 768 KiB. Registrations are copied and frozen during construction.
/undo and /redo, plus undoSession() and redoSession(), move a durable history cursor. The direct methods return { revertedRunId } and { restoredRunId }; the slash forms return { type: 'operation', name: 'undo' | 'redo' }. Each committed cursor move emits one session.history.changed event with direction, runId, and the selected session identity. A committed branch emits the same event with direction: 'branch' and no runId, so controllers should refresh the complete selected session. Capture finalization, cleanup, and metadata-only changes do not emit this event.
A completed root-session turn defines an operation-group boundary. Failed and cancelled turns after it belong to that group; leading failed or cancelled turns belong to the first completed group. No completed boundary means there is nothing to undo. Undo applies selected segments in reverse order and redo applies them in forward order. Without turnReversionProvider, only transcript and future model context move. Hidden messages disappear from getMessages(), message pages, exact message lookup, and future model context. Starting a new prompt, template, handler, or compaction from an undone position commits a branch: hidden messages and segments are removed durably and cannot be redone. Successful event archival also commits hidden redo history; a missing compaction or failed archive leaves it intact.
Two horizons bound undo. Retention pruning removes the oldest complete visible units when reversionLimits is exceeded. Explicit event archival marks covered turns context-unavailable and prunes complete prefixes that can no longer be rebuilt; FlexHarness never crosses that archive horizon. Manual compaction without archival retains the original events and remains undoable. Schema-1 projection history and sessions migrated from 2.x have no reversion segments, so historical turns are not retroactively undoable; newly written turns are tracked normally.
Session metadata archival through updateSession(..., { archived: true }) only sets archivedAt. It does not archive Agent events, retire captures, or remove undo history.
executeSlashCommand() is the authoritative parser and lookup boundary. Its result distinguishes not-command, malformed, unknown, completed operation, bounded handler-result, and prompt-admission. A prompt admission contains the normal { queueId, runId, completion }; await admission.completion for the model result. Unknown commands are never admitted as literal prompts. Known unavailable commands and invalid arguments throw typed FlexHarness errors. Options accept modelHint, system, maxSteps, and signal; commands do not accept attachments. Aborting a template or init execution cancels its exact queued or started prompt without affecting another queue entry.
Templates replace every $ARGUMENTS with untouched raw argument text. $1 through the highest referenced positional placeholder use tokenized arguments, with the highest position receiving all remaining tokens joined by spaces. Missing positions become empty. A template with no placeholders appends non-empty raw arguments after a blank line. /init uses the OpenCode 1.18.15 AGENTS.md initialization prompt with provider-neutral active-workspace wording.
Handler context is frozen and contains only the resolved scope identity, session identity, raw and tokenized arguments, and an AbortSignal. Handler results are converted with the configured toolOutputLimits; void becomes JSON null. Handler failures use externalErrorProjector with source slashCommand. Same-session command overlap is rejected, including reentry from a handler. Scope retirement and disposal abort and await active handlers; prompt-admission commands transfer immediately to the normal prompt lifecycle.
Workspace Reversion Provider
reversionPolicy defaults to transcript-optional, preserving the V1 behavior described above. Set it to workspace-required when transcript and workspace traversal must move together. This policy requires an IFlexTurnReversionProviderV2 at construction.
Applications using the original protocol can continue to provide all six unchanged IFlexTurnReversionProvider operations:
import type { IFlexTurnReversionProvider } from '@modelprofile.com/flexharness';
const turnReversionProvider: IFlexTurnReversionProvider<IProjectScope> = {
prepare: (context) => workspaceSnapshots.prepare(context),
inspectCapture: (context) => workspaceSnapshots.inspectCapture(context),
finalize: (context) => workspaceSnapshots.finalize(context),
inspectApply: (context) => workspaceSnapshots.inspectApply(context),
apply: (context) => workspaceSnapshots.apply(context),
release: (context) => workspaceSnapshots.release(context),
};
Protocol 2 adds the protocolVersion discriminant and a tagged finalized outcome. The prepare, apply, apply-inspection, and release contexts remain the V1 shapes:
import type {
IFlexTurnReversionProviderV2,
} from '@modelprofile.com/flexharness';
const turnReversionProvider: IFlexTurnReversionProviderV2<IProjectScope> = {
protocolVersion: 2,
prepare: (context) => workspaceHistory.prepare(context),
inspectCapture: (context) => workspaceHistory.inspectCapture(context),
async finalize(context) {
const capture = await workspaceHistory.finalize(context);
if (capture.changedPaths.length === 0) {
return {
disposition: 'no-change',
reference: capture.cleanupReference,
};
}
if (!capture.revertible) {
return {
disposition: 'nonrevertible',
reference: capture.cleanupReference,
reasonCode: 'git.unmerged',
affectedWorkspaces: [{ id: capture.workspaceId, label: capture.workspaceLabel }],
};
}
return {
disposition: 'revertible',
reference: capture.reference,
affectedWorkspaces: [{ id: capture.workspaceId, label: capture.workspaceLabel }],
};
},
inspectApply: (context) => workspaceHistory.inspectApply(context),
apply: (context) => workspaceHistory.apply(context),
release: (context) => workspaceHistory.release(context),
};
const harness = new FlexHarness<IProjectScope>({
// scopeResolver, modelResolver, stores, and other options...
turnReversionProvider,
reversionPolicy: 'workspace-required',
});
V1 finalize() returns a JSON reference, and a finalized V1 inspectCapture() result is { status: 'finalized', reference }. V2 finalize() returns revertible, no-change, or nonrevertible, and a finalized V2 inspection is { status: 'finalized', outcome } with the same complete tagged outcome. Every V2 outcome carries a normalized cleanup reference. A revertible outcome also carries affectedWorkspaces. A no-change outcome omits that list or supplies an empty list. A nonrevertible outcome carries a stable reasonCode and may carry affected workspaces.
Affected workspace descriptors contain only stable, non-whitespace id and display label strings. A result accepts at most 64 unique descriptors; IDs are limited to 512 UTF-8 bytes, labels to 2048 bytes, and reason codes to 128 bytes matching [A-Za-z0-9][A-Za-z0-9._-]*. References use the configured JSON normalization depth and byte limit with an absolute 256 KiB cap.
Controllers query safe history metadata with one method:
const info = await harness.getSessionReversionInfo(scopeId, sessionId);
for (const group of info.groups) {
console.log(
group.runId,
group.kind,
group.visibility,
group.affectedWorkspaces,
group.affectedWorkspacesTruncated,
);
}
The immutable result exposes undoAvailable, redoAvailable, and groups classified as candidate, barrier, or no-change. Group metadata contains at most 64 unique affected workspaces; affectedWorkspacesTruncated is true when additional unique descriptors were omitted. It never includes capture IDs or provider references.
workspaceSnapshots is application-owned. Every context contains scopeId, scope, storageKey, sessionId, runId, deterministic captureId, and an AbortSignal. Apply contexts additionally contain the normalized reference, deterministic per-segment operationId, and direction; release contexts contain the reference.
The protocol is durable and inspectable:
- FlexHarness persists a
preparingcapture intent before callingprepare(), before model or tool execution. The provider must establish exclusive capture ownership for thatstorageKeyand retain it untilrelease()succeeds. inspectCapture()returnsmissing,prepared,finalized, orunknown. A finalized V1 result includesreference; a finalized V2 result includes the complete taggedoutcome.missingis safe only while the durable state is stillpreparing; a missing prepared or finalizing capture, anunknownresult, or an inspection failure fences the namespace.finalize()closes the capture and returns its JSON-safe V1 reference or complete V2 outcome. FlexHarness may call it during normal finalization or recovery afterinspectCapture()reportsprepared. Failed and cancelled root turns are captured too. A root capture spans foreground subagent effects, although child transcript records remain separate.- Before undo or redo, FlexHarness persists an apply write-ahead record.
inspectApply()must report the exact durable outcome for the suppliedoperationId:not-appliedmeans no effect occurred,appliedmeans the complete effect occurred, andunknownmeans the provider cannot prove either result. FlexHarness callsapply()only fornot-applied; after an apply error it inspects again, and anunknownresult or inspection failure fences the namespace. Progress is persisted after each segment, and the transcript cursor moves only after the complete unit succeeds. Known zero-progress failures leave the cursor unchanged and can be retried; partial progress retains the write-ahead record and resumes after restart. Caller cancellation is honored until the first workspace segment makes progress; recovery then continues with fresh bounded maintenance signals until the unit and cursor commit. Providers must treatoperationIdidempotently. release()relinquishes the capture after no-change or nonrevertible V2 finalization, branch commitment, retention or archive pruning, session deletion, or other durable removal. It must be idempotent: an unacknowledged release remains persisted and is retried before FlexHarness discards the reference.
Under workspace-required, a group is a barrier when any segment is pending, nonrevertible, or legacy transcript-only history. It is a candidate when at least one segment is revertible and none is a barrier; otherwise it is no-change. Only candidates can be traversed. No-change groups after a candidate travel with that candidate until the next candidate or barrier. Leading no-change groups remain visible. A retained barrier blocks older groups, while later candidates remain undoable. A mixed revertible/nonrevertible group is a barrier.
Inspection, recovery, finalization, and release use fresh maintenance signals bounded by agentSessionPolicy.generationLeaseCleanupTimeoutMs, which defaults to 30 seconds. Providers must observe every supplied signal and must serialize ownership for a storage namespace. A provider with the matching persisted protocolVersion must remain configured whenever a capture-backed session is reopened, retired, disposed, or deleted. Capture-backed recovery and deletion fail closed without it.
Workspace reversion is generic and application-defined. It does not reverse network, database, billing, or other side effects unless the provider deliberately captures them. References are normalized with toolOutputLimits and have an absolute 256 KiB encoded cap.
reversionLimits field |
Default | Hard maximum |
|---|---|---|
maxCompletedTurns |
100 | 1000 |
maxSegments |
300 | 3000 |
maxExcludedRunIds |
1000 | 10000 |
maxPendingReversionReleases |
1000 | 10000 |
Every configured value must be an integer from 1 through its hard maximum. Pruning removes complete prefixes rather than splitting an undo unit. Excluded run IDs prevent a committed branch from re-entering model context, while pending releases retain provider ownership until acknowledgement.
retireScope() stops runtime ownership for the complete resolved storage namespace without deleting its durable snapshot. It does not load a namespace that has no cached or in-flight state. For loaded state, it preserves and waits for persistence that has already started, while later queued reads, writes, and run admissions reject with FlexHarnessAbortError. It cancels queued prompts and cancellable runs, rejects pending permissions, emits queue terminal events, waits for committing runs, terminal persistence, queue drains, tool-handle closure, and detached tool-provider cleanup, then purges queue status and clears and evicts the cached state. Failed cleanup ownership remains cached so a later retireScope() or dispose() call can retry it. Calls through storage-key aliases share the same retirement drain. A later call can load the durable namespace again after successful retirement if the application still resolves it.
Normal retirement-induced cancellation does not make retireScope() reject. Unexpected failures observed through run finalization or scoped cleanup are surfaced without dropping the resources that still require cleanup. One such failure is thrown directly; multiple failures are reported through FlexHarnessRunError. Calling retirement or disposal again retries retained cleanup ownership.
Applications removing a scope must stop and serialize new admission across every alias before calling retireScope(), await retirement, and only then remove or purge application-owned durable records. FlexHarness cannot discover aliases before the application resolver returns. Integrations must not use retirement itself as durable deletion.
History And Audit Behavior
The model context is built by the session's canonical SmartAgent event history as:
- Previous canonically accepted generations.
- The normalized current user message.
- SmartAgent's result messages.
A failed or cancelled prompt remains visible through getMessages(), with failed or cancelled status, but is not included in future model context. Canonical Agent events remain private and are not exposed by the session or message APIs.
Resolved model identity contains provider and model IDs plus optional display name and effective variant. The identity, including its variant, is attached to a failed assistant message when resolution completed before a later failure, matching the provider/model behavior. Prompt results contain it only on success.
Public audit history is safe to send to controllers: attachment parts contain source and size metadata, never inline base64, data URLs, or remote URL payloads. Canonical private Agent events retain those values solely for subsequent model turns.
Sessions expose idle, scheduled, running, waiting_permission, failed, and cancelled status. Persisted non-terminal activity is repaired from canonical Agent generation outcomes after process restart; incomplete messages and parts normalize to cancelled unless an accepted hidden terminal stage can be promoted.
Permissions
Tools request permission through the run-scoped provider context:
await context.requestPermission({
kind: 'filesystem.write',
description: 'Write generated files into the project',
toolCallId,
rememberKey: 'filesystem.write:project-output',
metadata: { target: 'generated/' },
});
Pending requests are runtime-only and queryable with listPendingPermissions(). A controller answers with:
once: allow this request.always: allow and remember the request'srememberKeyfor this session.reject: reject the tool execution.
always is invalid when the request has no rememberKey. Remembered decisions are persisted before the waiting tool resolves. If persistence fails, the key is rolled back and the request remains pending so the response can be retried. Concurrent response attempts are serialized and exactly one successful response settles a request.
Tool Output Safety
FlexHarness wraps every provided tool execute method before SmartAgent receives it. Direct outputs and every AsyncIterable yield are converted into bounded JSON-safe values. Circular references, functions, symbols, bigint values, dates, URLs, and binary values receive deterministic descriptions or records. Returned error objects and unreadable getter values receive fixed descriptions without their original messages. Thrown errors and iterator failures remain failures but are converted to the safe external-error projection before SmartAgent observes them.
toolOutputLimits in the complete setup above bounds traversal depth and encoded bytes. The normalizer enforces its byte allowance incrementally: oversized strings are replaced before entering output, and arrays/objects stop reading entries once only truncation metadata fits.
Streaming callbacks use run-local synchronous state rather than one persistence promise per source delta. Text and reasoning accumulate only in the run-local terminal projection while each source delta remains an immediate exact public event. Every distinct async-iterable tool output appears immediately as a bounded cumulative part.updated snapshot while the tool remains running, including the final yielded value before completion. Only the authoritative part.completed output enters the terminal projection, and failed or interrupted tools discard their transient output. callbackLimits bounds callback events, accumulated output bytes, and part count; overflow aborts internally with FlexHarnessCallbackOverflowError and the turn is recorded as failed. Reservation and terminal finalization are the normal persistence checkpoints, with permission state changes as explicit additional checkpoints.
Model resolver, tool provider, AgentSession, tool execution, tool callback, tool cleanup, and run-persistence failures cross an untrusted error boundary. By default they become a fixed immutable FlexHarnessExternalError before completion rejection, persistence, events, or detached-cleanup reporting. Raw external messages and aggregate members are not retained. A failed onToolCallFinish callback stores and accounts for only the bounded projected message; it does not otherwise reject completion, although exceeding the configured callback limits still fails the run. Scope resolution and the initial store load happen before a run exists and remain outside this boundary.
externalErrorProjector receives one of modelResolver, toolProvider, agentSession, toolExecution, toolCallback, toolCleanup, persistence, slashCommand, or turnReversion as its source. It may synchronously return an application-approved plain data object { name, message, code? }, limited to a 128-byte name, 2048-byte message, and optional 128-byte code. Accessors, extra keys, throwing projectors, and malformed or oversized results fall back to the fixed error. Even exported FlexHarness error subclasses thrown by external integrations are reprojected. Internally created cancellation, callback-overflow, and permission errors retain their typed behavior.
normalizeJsonValue() is also exported for integrations that need the same conversion independently.
Agent Runtime Operations
pushRuntimeEvent() appends a validated JSON event through the canonical AgentSession event store. It is intended for controller-owned context such as workspace changes or external notifications; invalid or non-JSON values fail before persistence.
Transactional tool calls persist an execution intent before the tool side effect starts. After an interrupted process, listUncertainToolExecutions() exposes intents whose outcome cannot be proven. A controller must inspect the external system and call reconcileToolExecution() with executed, not-executed, or abandoned-unknown before allowing dependent work to continue. Reconciliation output is normalized using the same tool-output limits.
agentSessionPolicy forwards bounded SmartAgent session controls for context building, compaction, event retention, change-listener pressure, lease cleanup, archived transaction tombstones, and context-overflow retries. A configured contextCompactor receives the projected model messages, only the filtered model-visible covered events, SmartAgent's existing reason and abortSignal, and the exact resolved scopeId, scope, storageKey, and sessionId for the invocation causing compaction. The invocation context remains isolated when aliases share one storage key, so integrations can resolve the correct model without global mutable state. If no events are eligible for compaction, compactSession() returns without calling the compactor or writing a compaction event; otherwise it writes the canonical event. archiveSessionEvents() moves events covered by that compaction into the configured Agent event archive store and returns public archive metadata.
executionContextProvider can construct a SmartAgent execution context for each session. FlexHarness supplies the resolved scope, storage key, and the session's private job store. The public background APIs expose only execution ID, type, state, exit code, and timestamps; command payloads, stdout, and stderr remain private. The provider's optional close() is owned by session deletion, scope retirement, and harness disposal.
Events
const unsubscribe = harness.subscribe((event) => {
switch (event.type) {
case 'part.delta':
applyExactDelta(
event.sessionId,
event.messageIndex,
event.partIndex,
event.partType,
event.delta,
event.baseTextUtf8Bytes,
event.textUtf8Bytes,
);
break;
case 'permission.requested':
showPermission(event.request);
break;
case 'session.updated':
renderSession(event.session);
break;
case 'session.deleted':
removeSession(event.sessionId);
break;
case 'run.finished':
markRunFinished(event.runId, event.status);
break;
case 'session.history.changed':
refreshSelectedSession(event.sessionId);
break;
case 'prompt.queued':
case 'prompt.started':
case 'prompt.running':
case 'prompt.finished':
renderQueueStatus(event.queueId, event.entry.status);
break;
}
});
unsubscribe();
Events are discriminated, deeply immutable values with one global sequence within each FlexHarness instance. Listener exceptions are isolated from runs and other listeners. Every accepted queue entry emits prompt.queued and exactly one prompt.finished. Durable promotion additionally emits prompt.started, and actual model preparation emits prompt.running; cancellation or failure can omit either intermediate event. Existing durable run/message terminal events precede prompt.finished. Every callback-backed streamed text part emits exactly one part.completed event before the corresponding run.finished event. Every part.started, part.delta, part.updated, and part.completed event carries zero-based messageIndex and partIndex coordinates from the session's authoritative message and part sequences.
part.started, part.updated, and part.completed are snapshot events with a complete immutable part. A newly streamed text part emits part.started with empty text before its first delta; reasoning parts also start empty. part.delta is a separate delta-only event: it has no cumulative part, and carries partType, the required exact source delta, and baseTextUtf8Bytes/textUtf8Bytes for the cumulative text before and after that delta. The counters remain correct when a UTF-16 surrogate pair is split across callbacks. Exact deltas are never truncated, including a single delta above the 96 KiB message-transfer text limit. part.updated remains a cumulative replacement snapshot for running tool output or metadata, not a text delta. session.history.changed carries direction: 'undo' | 'redo' | 'branch'; runId is present for undo and redo. Events contain public IDs, exact delta payloads, and public snapshots only; they do not expose prompt payloads, the resolved scope object, storage key, model object, provider options, or raw storage key.
Part events narrow through the exported TFlexPartEvent union. IFlexPartEventBase contains their shared coordinates, IFlexPartSnapshotEvent owns snapshot events and their complete part, and IFlexPartDeltaEvent owns exact delta-only events and their UTF-8 counters.
Migrating Part Events to 5.x
Version 5.x replaces cumulative part.delta payloads with the exact delta-only contract above. Consumers must stop reading event.part from part.delta; use event.partType, event.delta, event.baseTextUtf8Bytes, and event.textUtf8Bytes, then hydrate or settle from the complete part carried by snapshot events. IFlexPartChangedEvent has been removed; use TFlexPartEvent, IFlexPartSnapshotEvent, or IFlexPartDeltaEvent according to the required narrowing. New streamed text and reasoning parts start with empty text, so consumers must apply subsequent deltas in sequence within that harness instance.
Stores
Current FlexHarness persistence is separated by trust and lifecycle domain through IFlexHarnessStores:
scopes: session metadata and deletion tombstones for a resolved storage namespace.projections: public audit messages and hidden terminal stages per session.permissions: remembered permission keys per session.projectManagement: generation-fenced task, goal, and scratchpad state per session.agentEvents: canonical private SmartAgent events and archives per session.jobs: private background execution state per session.
InMemoryFlexHarnessStores implements all six required domains with revision-based compare-and-swap behavior for tests and ephemeral processes. It is the default when stores is omitted. Custom IFlexHarnessStores implementations must provide projectManagement even when project-management tools are disabled, because deletion cleanup always writes the generation fence.
Custom Agent event and job providers may implement releaseSession(storageKey, sessionId) to release session-bound wrappers, handles, or caches without deleting durable data. FlexHarness calls these hooks only after the corresponding AgentSession or execution context has released runtime ownership. A failed release remains owned for a later retirement or disposal retry. deleteSession() remains the separate destructive operation for durable session data.
JsonFileFlexHarnessStores stores the domains in separate scopes, projections, permissions, projectManagement, events, archives, and jobs directories. Storage and session identifiers are SHA-256 hashed for filenames. Passing the store bundle supplies lifecycle persistence but does not enable any built-in tool. It provides:
- Strict domain-specific schema validation and optimistic revisions.
- Static process-wide queues shared by all store instances for the same absolute file.
- Revision re-reads inside the queue before every save.
- Atomic temporary-file write, file fsync, rename, and parent-directory fsync.
- Directory mode
0700and file mode0600, including existing paths. - Stale temporary-file cleanup and strict snapshot validation.
After every harness using a JsonFileFlexHarnessStores instance has been disposed and no store operation remains active, call await stores.dispose() to retry and drain any file handle whose earlier close failed. A failed store disposal retains that handle so the call can be retried.
The JSON stores are explicitly not cross-process safe. When several processes can access the same storage namespace, every core IFlexHarnessStores domain and stores.projectManagement must use database-backed or equivalent cross-process CAS. Process-local CAS for the core stores or for PM alone is insufficient: session generation creation, cleanup tombstones, PM replacement, and stale-writer rejection must all retain their respective atomic preconditions across processes.
Direct store operations and non-run session mutations surface conflicts as FlexHarnessStoreConflictError or the corresponding SmartAgent store conflict. Malformed, wrong-schema, or non-JSON snapshots are surfaced as FlexHarnessStoreFormatError. A write or deletion that changed its target but cannot confirm parent-directory durability surfaces FlexHarnessStoreCommitUncertainError with the affected path, operation, and cause. Run persistence failures cross the external error boundary and therefore become FlexHarnessExternalError. FlexHarness does not merge conflicts.
Each domain serializes its own mutations. A successful run first persists a hidden completed projection, then finalizes the canonical Agent generation as accepted, then promotes the hidden projection publicly. Recovery uses the canonical generation outcome to promote an accepted stage or publish a failed/cancelled projection. Failed and cancelled generations remain auditable but never enter future model context.
Projection stores accept schema version 1, 2, or 3 from load(), but every save() receives the current schema-3 shape. Schema 3 records explicit reversion protocol, transcript/workspace provenance, and V2 disposition. A terminal V2 segment must be conclusively revertible, no-change, or nonrevertible; a pending V2 capture remains owned by its capture WAL and is never treated as transcript history.
A loaded schema-1 projection has no reversion state. Schema-2 workspaceCaptured segments migrate as protocol-1 workspace/revertible history without discarding references; transcript-only segments migrate as protocol-1 transcript provenance. Under workspace-required, that legacy transcript history is a barrier. The next projection mutation writes schema 3. Custom stores must preserve strict compare-and-swap revisions across schema-1 and schema-2 read-upgrade-write cycles, including uncertain-save reconciliation.
Migrating From 2.x
Version 3.x replaces the single IFlexHarnessStore snapshot with the split stores above. Run migration while every process that can access the storage namespace is stopped.
import {
JsonFileFlexHarnessStores,
} from '@modelprofile.com/flexharness';
import {
migrateLegacyFlexHarnessSnapshot,
type IFlexLegacyHarnessSnapshot,
} from '@modelprofile.com/flexharness/migration';
const storageKey = 'account/project';
const legacySnapshot: IFlexLegacyHarnessSnapshot = await loadLegacySnapshot(storageKey);
const stores = new JsonFileFlexHarnessStores({
directory: '/var/lib/my-app/model-sessions-v3',
});
await migrateLegacyFlexHarnessSnapshot(storageKey, legacySnapshot, stores);
loadLegacySnapshot() is application-owned access to the snapshot written by the 2.x store. The migration validates the complete source and every public run before writing. A run left streaming by a process crash is deterministically repaired to the same cancelled state that the 2.x loader produced in memory. The migration then converts private model messages into generationless canonical Agent conversation events, records terminal SmartAgent transactions for completed, failed, and cancelled public runs, and writes schema-3 projections with empty reversion state. Migrated history therefore remains visible and auditable but is not retroactively undoable; turns created after migration receive normal reversion segments and optional workspace captures. The migration preflights the scope, projection, permission, Agent event, and job destinations before any write, applies missing per-session domains first, and publishes scope discovery last. It is safe to rerun after no work, a completed prefix, or a complete migration when existing destination content is identical. It fails closed when a destination contains conflicting content or non-empty jobs. Keep the legacy snapshot until the migrated application has loaded and verified every storage namespace.
Shutdown
Model and tool resolution share the run signal. Synchronous throws are observed as resolver failures; the first failure aborts that signal and finalizes immediately without waiting for an unresponsive sibling. A detached application or resource tool provider that resolves later is observed and every acquired handle is closed; disposal waits for that settlement and reports a late close failure.
Tool-handle close settles before a turn can be successful. After model generation resolves, FlexHarness stages its terminal projection before canonical acceptance; earlier execution failures are finalized as interrupted and then published from that durable outcome. Cleanup failure prevents canonical acceptance. If canonical finalization or public promotion fails, FlexHarness fences the namespace; the next load repairs public state from the durable canonical outcome and any hidden terminal stage.
dispose() is asynchronous and idempotent. It marks the harness closed, settles unresolved queue admissions, cancels queued prompts and cancellable active runs, rejects pending permissions, emits queue terminal events, waits for committing runs, queue drains, all run finalizers, state save tails, and tracked detached tool-provider cleanup, purges runtime queue records, then clears listeners. Loaded state caches are cleared after all cleanup succeeds; a failed drain retains its cache and cleanup ownership so a later dispose() call can retry it. Multiple run or cleanup failures are reported through FlexHarnessRunError.
If dispose() overlaps a storage namespace already being retired, both calls await the same storage drain and cleanup runs once. A retirement call begun after disposal starts rejects with FlexHarnessClosedError.
Cancellation is cooperative: model resolvers, tool providers, AgentSession model execution, tools, execution contexts, and cleanup functions must observe the supplied AbortSignal and settle tracked work. After a sibling resolver fails, FlexHarness deliberately does not wait for an unresponsive model resolver; a detached tool provider remains tracked because any late handle must be closed. A process host that needs a hard shutdown deadline must enforce that deadline outside FlexHarness and terminate only the process it owns.
License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the repository license file.
Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
Trademarks
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
Company Information
Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.