npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@particle-academy/prism-harness

v0.5.0

Published

Durable agent sessions for TypeScript — threads, session state and store drivers.

Readme

Prism Harness for TypeScript

Durable agent sessions — threads, session state and store drivers. The TypeScript port of particle-academy/prism-harness.

Zero runtime dependencies. Node 20+.

This package is private while coordinated parity work is in progress.

import { FileSessionStore, MemorySessionStore, PrismHarness } from '@particle-academy/prism-harness';

const harness = new PrismHarness({
  drivers: {
    memory: () => new MemorySessionStore(),
    files: () => new FileSessionStore('./storage/harness'),
  },
  stores: { ephemeral: 'memory', durable: 'files' },
});

const session = harness.for({ type: 'User', id: 7 }).session('support');

await session.usingMode('plan').then((s) => s.usingModel('claude-sonnet-4-5'));

await session.lock(async (live) => {
  // whatever must not happen twice
  await live.beginRun('run-1', 'plan', 'anthropic', 'claude-sonnet-4-5');
});

Resolved, never held

A server handles a request and moves on, so a session cannot be an object kept in memory the way a single-process agent's is. Every call rebuilds one from a store, which is what makes a fresh worker see the same mode, model and conversation as the request that set them.

The two halves

State is split into named slots, because the halves have genuinely different requirements:

| Slot | Holds | Losing it means | |---|---|---| | ephemeral | active mode, selected model, run bookkeeping | falls back to a default | | durable | threads, stored capabilities, agent task lists | work is gone |

A store that reports itself volatile is REFUSED for the durable slot, at the moment a session is opened. That is the guard the package exists for: a cache is disposable by definition, and the durable slot holds approvals a human has not answered yet. Accepting it and finding out later is exactly the failure this package was written to avoid.

Construct a PrismHarness with no drivers and it works — and then refuses durable state, loudly, with a message that names the fix. That is deliberate. A package that silently accepted an in-memory durable store would pass every test on one process and lose a half-executed action the first time it ran on two.

The same address as PHP

Session.key() is byte for byte what the reference builds: session: plus the sha1 of the participant type truncated to 12, the participant id, and the scope. Matching exactly is what lets a PHP app and a TypeScript agent share one store and resolve the same session. A different digest would give two conversations that look identical and never meet.

Threads

record() assigns positions inside the store lock, reading the current length and writing the new messages as one operation. Two turns landing concurrently would otherwise both read position 4 and both write position 5, silently losing a message — the race the reference tracks as prism-harness#2.

Attachments

A turn can carry media alongside its prompt:

import { Image } from '@particle-academy/prism';

await runtime.send(session, 'What is wrong with this layout?', undefined, undefined, [
  Image.fromBase64(screenshot, 'image/png'),
]);

Pass prism-ts media objects or media already serialized. Attachments are stored with the turn in the shape UserMessage.fromObject() rebuilds. Each one must be an image, document, audio or video that carries its bytes, a provider file id or document chunks. The same rules as the PHP reference and the Python port apply, pinned across all three by prism-parity's harness-turn-attachments corpus.

Refused, with a HarnessError whose code names the problem:

| Code | When | |---|---| | attachment_by_reference | built from a URL or read from a file | | attachment_not_media | anything other than those four media types | | attachment_empty | no bytes, no file id and no chunks | | attachment_without_prompt | attachments with an empty prompt |

A refusal happens before a run starts: no run, no events, nothing in the thread.

Provider options per mode

A mode can declare provider_options. They reach your model client unchanged, as request.providerOptions, on every step of every run in that mode. Pass them to your provider call (with prism-ts, withProviderOptions()):

new ModeRegistry({
  modes: { overseer: { system_prompt: '...', provider_options: { thinking: { type: 'adaptive' } } } },
});

A value that is not a map is refused when the mode is resolved, with mode_malformed.

What your model client receives

request.messages is the thread, oldest first, in the shape the PHP reference stores Prism's messages:

| type | Carries | |---|---| | user | content, and additional_content when the turn has attachments | | assistant | content, tool_calls (id, name, arguments, result_id, reasoning_id, reasoning_summary), additional_content, tool_approval_requests | | tool_result | tool_results (tool_call_id, tool_name, args, result, tool_call_result_id, artifacts), tool_approval_responses |

Consecutive tool result rows reach the client as one, so each call's result is sent to the provider once. Return a call's provider ids as resultId, reasoningId and reasoningSummary, and provider state for the turn as additionalContent: they are recorded and come back in messages.

Structured turns

When the answer is a document rather than prose, hand the turn a schema:

const schema = {
  name: 'plan',
  type: 'object',
  properties: {
    title: { type: 'string' },
    steps: { type: 'array', items: { type: 'string' } },
  },
  required: ['title', 'steps'],
};

const response = await runtime.sendStructured(session, 'Plan the release', schema);

response.structured; // { title: 'Ship it', steps: ['write', 'test'] }
response.text; // the document as the model wrote it

It is the same run as send() — the mode's system prompt, its tools, the step budget, the events — asking the provider for structured output. Your client receives the schema as request.schema and returns what it parsed as response.structured; with prism-ts that is Prism.structured().withSchema() and the response's structured.

The thread keeps the text, with the parsed object beside it. The assistant message is the raw document, and structured rides along in the row's additional_content. A later turn replays the conversation as messages and reads the text, so a thread that contains a structured answer reads like any other.

A document that misses the schema is refused, not repaired.

try {
  const plan = (await runtime.sendStructured(session, brief, schema)).structured;
} catch (error) {
  if (error instanceof HarnessError) {
    error.code; // structured_schema_violation, or structured_unreadable
    error.problems; // every way it missed, not the first
    error.document; // what the model actually said
  }
}

Nothing is coerced, nothing is trimmed to the fields that fit, and the result is never an empty document. An empty plan settles a batch as done, which reads exactly like a considered answer of "nothing to propose" — the failure this refusal exists to prevent. The exchange is still recorded, and the run is marked failed: a thread that omits the answer it did not like cannot explain the retry sitting next to it. The run row and the run.failed event name the CODE, not the message — the message quotes the values that missed, and an event carrying those would ship model output to every listener, which is the same reason tool arguments are names-only here.

The check reads the schema's own JSON Schema, so a hand-written schema is held to the same terms. schemaProblems() is exported if you want it directly. It checks declared types, required keys, enum members, array items, and — where a schema closes itself — keys nobody declared. It does not read $ref, allOf, oneOf or the numeric and string facets; what it cannot read, it passes, rather than reporting a constraint it did not actually check. The same rules as the PHP reference, message for message, so a document refused in one language is refused in the other for the same stated reason.

Approvals

A mode names the tools a person must approve:

guarded: { system_prompt: '...', tools: ['read', 'delete'], requires_approval: ['delete'] },

A step that calls one stops with finishReason: 'awaiting_approval'. The calls that need nobody have already run. Record a decision for every pending approval, then resume with an empty prompt:

const response = await runtime.send(session, 'Clean up the failed run');

if (response.finishReason === 'awaiting_approval') {
  for (const pending of response.pendingApprovals) {
    await recordApproval(session, pending.id, true); // or false, 'not on production'
  }

  await runtime.send(session, '');
}

On resume the approved calls run once and denied calls return their reason, and then the model continues. The model is not asked to make the calls again. A pending call with no decision is refused with "No approval response provided". A call that has a result never runs again. Who may approve is your application's decision: authorize before calling recordApproval().

Agent task lists

An agent given a goal has to keep working across many requests. It needs a list of what remains, and that list has to survive the request, the worker, a crash and a deploy.

const tasks = session.tasks();

await tasks.addMany(['read the issue', 'write the patch', 'run the gates']);

const task = await tasks.claim('worker-1');   // ONE atomic call

if (task !== null) {
  // …do the work…
  await tasks.release(task, 'worker-1', 'done');
}

await tasks.pending();   // a COUNT, which is what a loop needs to stop

There is no task model, no schema and no migration. Two contracts and adapters: AgentTaskSource is where tasks come from — claim, release, pending, find — and AgentTask is one unit of work. find() is on the contract because release() takes a task while every external caller holds only an id: a tool call carries {"id": "t-1"} and an HTTP route has /tasks/t-1.

StoreTaskSource is the default, backed by the durable slot; a consumer with an existing table wraps their own record with asAgentTask() or mixes withAgentTask() into their class. Both satisfy one contract.

Four states — todo, claimed, done, failed — and the transitions are pinned across all three languages:

| | | |---|---| | claim() is one call | Read-then-mark is two operations with a window between them, and two workers arriving in that window get the same task. | | A claim carries an owner AND an expiry | Five minutes by default. An expired claim returns the task to todo, never to failed: a worker dying is not the task failing, and conflating them burns a retry that never ran. | | claimed is written before the work begins | So "started and died" is distinguishable from "never started". | | done and failed are terminal | Re-releasing one is an error, not a silent no-op. | | A worker may extend its own lease | Only while it still holds it, and bounded by the RUN. RunLedger.exhaustion() refuses outright — cancelled, out of steps, out of money or out of time — and remainingSeconds() bounds what is granted. There is no second timeout to set, or to forget to set. The new expiry is now + granted even when that shortens the lease: the grant is what the run can still afford. | | A lease must be whole positive seconds | Zero or less is refused rather than clamped, and a fraction is refused rather than truncated — both are a value quietly becoming a different value, and claimed_until is an integer timestamp so a fractional lease could never have been honoured anyway. The check is Number.isInteger, which also refuses NaN, the infinities, and the string a JSON config would have delivered past the type annotation. | | release() names the worker | Not in the spec's sketch, and added because without it a completion tool can close any task in the list — including one another worker is midway through. A release by anyone but the holder is refused, and the refusal does not say who the holder is. |

A volatile store is refused, the same way the durable slot is. A half-finished task list that vanishes on a deploy is indistinguishable from a finished one.

An empty worker id or task id is refused, and nothing is trimmed first. trim, strip and String.prototype.trim each strip a different set of codepoints, so trimming would have three ports disagreeing about whether the same id is blank. A single space is a legal id. That is the prism-human-plus G-36 lesson applied before it costs anything.

An agent cannot mark its own task complete. release() is the application's call, made from evidence. A consumer who wants the agent to close its own tasks builds agentCompletionTool() and authorizes it through the existing ToolAuthorizer — which must be enabled, because an authorizer that is off allows every call and would grant the authority while reading as a control. If the model can declare its own work done, "run until the goal is met" becomes "run until it decides it is met", and a run that has stalled ends by declaring victory.

That tool requires an explicit outcome of done or failed. Anything else — "complete", null, or a missing argument — is refused rather than guessed at, because outcome is the one value the model supplies and it decides a terminal state. Guessing would let a malformed call produce the more privileged result.

It also checks the holder itself rather than trusting the source to. It is typed to the AgentTaskSource contract, and an interface cannot make an implementation check anything — release(task, worker, outcome) reads like "find it and set the state", which is what a third party will write. A task whose holder cannot be established at all is refused too: AgentTask is three methods, so a conforming source may expose no holder, and reading that silence as permission is the same mistake as inferring done from a missing outcome.

Stopping is the existing RunBudget — cost, turns and wall-clock. Dependency ordering is not here: drive fancy-flow for that. A task list that grows a scheduler has rebuilt a workflow engine badly.

Drivers

  • MemorySessionStore — volatile, and says so. Right for the ephemeral slot in a test or a single-process tool. Its lock is real but process-local.

  • FileSessionStore — durable. Atomic writes (write-then-rename) and a cross-process lock built on exclusive file creation, which is the one primitive that is atomic on every filesystem worth supporting. Two workers on one machine genuinely exclude each other; two machines over a network filesystem do not, and no file lock can promise that — use a database there.

    Its lockfile format is shared with the PHP and Python ports, because a worker in any of the three may be pointed at one store directory: the expiry in milliseconds, then a newline, written in a single write. The terminator is load-bearing. Without it there is nothing to tell a complete expiry from the first half of one, and every prefix of a timestamp is a smaller, older timestamp — so a torn write reads as a lock that expired decades ago and gets deleted out from under the worker holding it. Anything unterminated is treated as held, bounded by the file's own age so a process that died mid-write cannot wedge the key either.

Implement SessionStore for anything else. Declare your own durability(): only you know whether your Redis is persistent or a disposable cache, and that declaration is an assertion about your infrastructure, not a preference.