@particle-academy/prism-harness 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +179 -0
  3. package/dist/doctor.d.ts +35 -0
  4. package/dist/doctor.js +110 -0
  5. package/dist/errors.d.ts +199 -0
  6. package/dist/errors.js +201 -0
  7. package/dist/events.d.ts +67 -0
  8. package/dist/events.js +40 -0
  9. package/dist/harness.d.ts +51 -0
  10. package/dist/harness.js +70 -0
  11. package/dist/index.d.ts +36 -0
  12. package/dist/index.js +20 -0
  13. package/dist/json.d.ts +15 -0
  14. package/dist/json.js +15 -0
  15. package/dist/modes.d.ts +63 -0
  16. package/dist/modes.js +109 -0
  17. package/dist/runtime.d.ts +104 -0
  18. package/dist/runtime.js +244 -0
  19. package/dist/session.d.ts +114 -0
  20. package/dist/session.js +198 -0
  21. package/dist/skills.d.ts +37 -0
  22. package/dist/skills.js +73 -0
  23. package/dist/store-task-source.d.ts +169 -0
  24. package/dist/store-task-source.js +513 -0
  25. package/dist/stores/file-session-store.d.ts +34 -0
  26. package/dist/stores/file-session-store.js +284 -0
  27. package/dist/stores/memory-session-store.d.ts +22 -0
  28. package/dist/stores/memory-session-store.js +97 -0
  29. package/dist/stores/session-store.d.ts +65 -0
  30. package/dist/stores/session-store.js +3 -0
  31. package/dist/stores/store-manager.d.ts +32 -0
  32. package/dist/stores/store-manager.js +57 -0
  33. package/dist/subagent-runner.d.ts +45 -0
  34. package/dist/subagent-runner.js +89 -0
  35. package/dist/subagents.d.ts +182 -0
  36. package/dist/subagents.js +286 -0
  37. package/dist/task-tools.d.ts +55 -0
  38. package/dist/task-tools.js +136 -0
  39. package/dist/tasks.d.ts +257 -0
  40. package/dist/tasks.js +162 -0
  41. package/dist/thread.d.ts +49 -0
  42. package/dist/thread.js +88 -0
  43. package/dist/tools.d.ts +89 -0
  44. package/dist/tools.js +148 -0
  45. package/package.json +34 -0
@@ -0,0 +1,284 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdirSync } from 'node:fs';
3
+ import { mkdir, open, readFile, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { isJsonObject } from '../json.js';
6
+ import { HarnessError } from '../errors.js';
7
+ /**
8
+ * State on disk, as one JSON file per key. DURABLE, and it says so.
9
+ *
10
+ * The default durable driver for a port with no database and no dependencies.
11
+ * A real deployment points the durable slot at a database instead; this exists
12
+ * so the package WORKS ON INSTALL rather than requiring infrastructure before
13
+ * the first session can be opened — the same reason the PHP reference defaults
14
+ * both slots to `database`.
15
+ *
16
+ * ## Two properties that matter more than speed
17
+ *
18
+ * **Writes are atomic.** A payload is written to a temporary file and renamed
19
+ * over the target, because `rename` is atomic on both POSIX and Windows. A
20
+ * partial write here is a corrupted thread, and a process killed mid-write is
21
+ * ordinary rather than exotic.
22
+ *
23
+ * **The lock is cross-process.** It is an exclusive-create lockfile (`wx`),
24
+ * which is the one primitive that is atomic on every filesystem worth
25
+ * supporting — unlike an "is it there?" check followed by a create, which has a
26
+ * window between the two. Two workers on one machine genuinely exclude each
27
+ * other. Two workers on different machines sharing a network filesystem do NOT
28
+ * reliably, and no file lock can promise that; use a database or Redis there.
29
+ */
30
+ export class FileSessionStore {
31
+ #directory;
32
+ constructor(directory) {
33
+ this.#directory = directory;
34
+ mkdirSync(directory, { recursive: true });
35
+ }
36
+ durability() {
37
+ return 'durable';
38
+ }
39
+ async get(key) {
40
+ let raw;
41
+ try {
42
+ raw = await readFile(this.#pathFor(key), 'utf8');
43
+ }
44
+ catch (error) {
45
+ if (isMissing(error))
46
+ return null;
47
+ throw error;
48
+ }
49
+ let document;
50
+ try {
51
+ document = JSON.parse(raw);
52
+ }
53
+ catch (error) {
54
+ throw HarnessError.unmappableContent(`the stored payload for [${key}] is not valid JSON`, {
55
+ cause: error,
56
+ });
57
+ }
58
+ if (!isJsonObject(document)) {
59
+ throw HarnessError.unmappableContent(`the stored payload for [${key}] is not an object`);
60
+ }
61
+ const expiresAt = document.expires_at;
62
+ if (typeof expiresAt === 'number' && expiresAt <= Date.now()) {
63
+ await this.forget(key);
64
+ return null;
65
+ }
66
+ return isJsonObject(document.payload) ? document.payload : null;
67
+ }
68
+ async put(key, payload, ttlSeconds = null) {
69
+ const target = this.#pathFor(key);
70
+ const temporary = `${target}.${process.pid}.${Date.now()}.tmp`;
71
+ const document = JSON.stringify({
72
+ key,
73
+ payload,
74
+ expires_at: ttlSeconds === null ? null : Date.now() + ttlSeconds * 1000,
75
+ });
76
+ await mkdir(this.#directory, { recursive: true });
77
+ await writeFile(temporary, document, 'utf8');
78
+ try {
79
+ // Atomic on both POSIX and Windows. A reader either sees the whole old
80
+ // payload or the whole new one, never half of either.
81
+ await rename(temporary, target);
82
+ }
83
+ catch (error) {
84
+ await rm(temporary, { force: true });
85
+ throw error;
86
+ }
87
+ }
88
+ async forget(key) {
89
+ await rm(this.#pathFor(key), { force: true });
90
+ }
91
+ async withLock(key, callback, ttlSeconds = 10, waitSeconds = 5) {
92
+ const lockPath = `${this.#pathFor(key)}.lock`;
93
+ const deadline = Date.now() + waitSeconds * 1000;
94
+ for (;;) {
95
+ try {
96
+ // `wx` fails if the file exists. Exclusive CREATION is atomic; a
97
+ // check-then-create is not, and the window between the two is exactly
98
+ // where two workers both decide they hold the lock.
99
+ const handle = await open(lockPath, 'wx');
100
+ // ONE write, and the terminator goes in it. The newline is what lets a
101
+ // reader tell a complete stamp from a torn one, so it must never be a
102
+ // second write that could be the half that did not land. See
103
+ // `#expired`, and note the format is shared with the other two ports.
104
+ await handle.writeFile(stamp(Date.now() + ttlSeconds * 1000), 'utf8');
105
+ await handle.close();
106
+ try {
107
+ return await callback();
108
+ }
109
+ finally {
110
+ await this.#release(lockPath);
111
+ }
112
+ }
113
+ catch (error) {
114
+ if (!isHeld(error))
115
+ throw error;
116
+ // A lock whose TTL has passed belonged to a process that died holding
117
+ // it. Left alone it would wedge the key forever, which is worse than
118
+ // the small race in reclaiming it — and the reclaim is itself a
119
+ // create-exclusive, so only one waiter can win.
120
+ if (await this.#expired(lockPath, ttlSeconds)) {
121
+ await unlink(lockPath).catch(() => undefined);
122
+ continue;
123
+ }
124
+ if (Date.now() >= deadline) {
125
+ throw HarnessError.sessionLocked(key, waitSeconds);
126
+ }
127
+ await sleep(25);
128
+ }
129
+ }
130
+ }
131
+ /**
132
+ * Give the lock up, and NEVER leave it held on the way out.
133
+ *
134
+ * Deleting is the normal path, but on Windows it can fail: another waiter
135
+ * attempting to create the same path holds a transient handle, and the unlink
136
+ * then raises EPERM. Swallowing that — which is what this did first — LEAKS
137
+ * THE LOCK. The waiter then sees a lockfile whose TTL is still in the future,
138
+ * has no way to know its holder is gone, and blocks for the whole wait before
139
+ * failing. Not theoretical: `prism-harness-py` hit exactly this the first
140
+ * time two threads recorded a message concurrently, and this port had the
141
+ * same defect with no test that happened to reach it.
142
+ *
143
+ * So if the file cannot be removed, it is rewritten with an ALREADY-PAST
144
+ * expiry. Any waiter reclaims it on its next attempt, which is the same path
145
+ * a genuinely dead holder takes.
146
+ */
147
+ async #release(lockPath) {
148
+ for (let attempt = 0; attempt < 5; attempt += 1) {
149
+ try {
150
+ await unlink(lockPath);
151
+ return;
152
+ }
153
+ catch (error) {
154
+ if (isMissing(error))
155
+ return;
156
+ await sleep(5);
157
+ }
158
+ }
159
+ // Nothing left to try but mark it dead. The TTL is the backstop, and it is
160
+ // why the lockfile carries one at all. TERMINATED like any other stamp: an
161
+ // unterminated `0` reads as a torn write, and a waiter would then sit on
162
+ // the mtime bound for a whole ttl instead of reclaiming it at once — the
163
+ // exact delay this path exists to avoid.
164
+ await writeFile(lockPath, stamp(0), 'utf8').catch(() => undefined);
165
+ }
166
+ /**
167
+ * Whether this lock belonged to a holder that is gone.
168
+ *
169
+ * ## An UNFINISHED stamp is HELD, not expired in 1970
170
+ *
171
+ * Taking the lock is an exclusive create and THEN a write of the expiry —
172
+ * two operations, with a moment in between where the file exists and is
173
+ * incomplete. There are two ways to read it in that moment and both used to
174
+ * steal the lock:
175
+ *
176
+ * - **Empty.** `Number('')` is `0`, which is finite and certainly
177
+ * `<= Date.now()`.
178
+ * - **Torn.** A partial write leaves a PREFIX of the timestamp —
179
+ * `17356899` where `1735689900000` was meant — and every prefix of a
180
+ * number is a SMALLER number. It parses, it is finite, and it is decades
181
+ * in the past. This one is worse than the empty case precisely because it
182
+ * does not look broken.
183
+ *
184
+ * Either way a waiter concluded the holder was long dead, deleted the lock,
185
+ * and took one somebody was actively holding. Two callers, one key: the
186
+ * failure this lock exists to prevent, produced BY the mechanism meant to
187
+ * recover from a dead holder. The empty case surfaced as a flaky test — about
188
+ * one run in eight — which is how it survived; the torn case would not have
189
+ * surfaced at all.
190
+ *
191
+ * The fix is the TERMINATOR. A stamp is digits followed by a newline, written
192
+ * in one write, so anything else is an unfinished write rather than a small
193
+ * number. Unreadable then means WAIT — `session_locked` after the wait
194
+ * period, which is loud and recoverable — instead of deleting a live holder's
195
+ * lock. The format is shared with the PHP and Python ports, because a worker
196
+ * in any of the three may be pointed at the same store directory.
197
+ *
198
+ * ## …but only until it is older than the ttl
199
+ *
200
+ * Treating an empty lockfile as held forever would let a process that died
201
+ * inside that window wedge the key permanently, which is the worse failure
202
+ * of the two. The file's own mtime bounds it: the window is microseconds in a
203
+ * live process, so a file still empty a whole ttl later belonged to one that
204
+ * did not survive it. Nothing extra has to be written for that to work.
205
+ */
206
+ async #expired(lockPath, ttlSeconds) {
207
+ let raw;
208
+ try {
209
+ raw = await readFile(lockPath, 'utf8');
210
+ }
211
+ catch {
212
+ // Gone between the failed create and this read: treat it as not expired
213
+ // and let the next attempt take it cleanly.
214
+ return false;
215
+ }
216
+ // Digits AND a terminator, or this is not a finished stamp. Checked as a
217
+ // whole rather than by trying to parse and hoping a bad value fails to:
218
+ // the torn case parses perfectly and is the reason this check exists.
219
+ if (!STAMP.test(raw)) {
220
+ try {
221
+ const { mtimeMs } = await stat(lockPath);
222
+ return mtimeMs + ttlSeconds * 1000 <= Date.now();
223
+ }
224
+ catch {
225
+ return false;
226
+ }
227
+ }
228
+ const expiresAt = Number(raw.slice(0, -1));
229
+ return Number.isFinite(expiresAt) && expiresAt <= Date.now();
230
+ }
231
+ /**
232
+ * One file per key, named by a digest.
233
+ *
234
+ * A session key contains colons, which are legal on POSIX and not on Windows,
235
+ * and it is long enough to run into path ceilings once a scope is appended.
236
+ * The digest sidesteps both, and the key itself is stored INSIDE the file so
237
+ * the mapping stays inspectable.
238
+ */
239
+ #pathFor(key) {
240
+ return join(this.#directory, `${createHash('sha256').update(key).digest('hex').slice(0, 32)}.json`);
241
+ }
242
+ }
243
+ /**
244
+ * A finished lock stamp: digits, then a newline. NOTHING ELSE.
245
+ *
246
+ * The terminator is the only thing that distinguishes a complete timestamp from
247
+ * a torn one, because a prefix of a timestamp is a perfectly valid smaller
248
+ * timestamp. Shared with the PHP and Python ports — a worker in any of the
249
+ * three may be pointed at the same store directory, and one that does not
250
+ * emit the newline has its locks waited out rather than reclaimed everywhere
251
+ * else.
252
+ */
253
+ const STAMP = /^\d+\n$/;
254
+ function stamp(expiresAt) {
255
+ return `${String(Math.trunc(expiresAt))}\n`;
256
+ }
257
+ function isMissing(error) {
258
+ return typeof error === 'object' && error !== null && error.code === 'ENOENT';
259
+ }
260
+ /**
261
+ * "The lock is already held" — under four different names.
262
+ *
263
+ * POSIX says EEXIST. WINDOWS DOES NOT, reliably: deleting a file that another
264
+ * handle still has open leaves it in a pending-delete state, and a create
265
+ * attempt against that returns EPERM, EACCES or EBUSY instead. That window is
266
+ * not rare — it is exactly the moment one caller releases the lock while the
267
+ * next is trying to take it, which is the common case under contention rather
268
+ * than an edge.
269
+ *
270
+ * Treating those as "held, retry" is also the safe direction to be wrong in: a
271
+ * genuine permission problem retries until the wait expires and then reports
272
+ * `session_locked`, which is a survivable failure. Treating them as "not held"
273
+ * would hand the same lock to two callers.
274
+ */
275
+ function isHeld(error) {
276
+ const code = typeof error === 'object' && error !== null ? error.code : undefined;
277
+ return code === 'EEXIST' || code === 'EPERM' || code === 'EACCES' || code === 'EBUSY';
278
+ }
279
+ function sleep(ms) {
280
+ return new Promise((resolve) => {
281
+ const timer = setTimeout(resolve, ms);
282
+ timer.unref?.();
283
+ });
284
+ }
@@ -0,0 +1,22 @@
1
+ import type { JsonObject } from '../json.js';
2
+ import type { Durability, SessionStore } from './session-store.js';
3
+ /**
4
+ * State in this process's memory. VOLATILE, and it says so.
5
+ *
6
+ * The right home for the ephemeral slot in a test or a single-process tool, and
7
+ * REFUSED for the durable slot by the store manager — not as a technicality:
8
+ * the contents do not outlive the process, and the durable slot holds approvals
9
+ * a human has not answered yet.
10
+ *
11
+ * The lock is real but PROCESS-LOCAL. Two workers cannot see each other's
12
+ * locks, which is the whole reason a deployment uses something else; this
13
+ * serialises callers within one process and claims nothing beyond that.
14
+ */
15
+ export declare class MemorySessionStore implements SessionStore {
16
+ #private;
17
+ durability(): Durability;
18
+ get(key: string): Promise<JsonObject | null>;
19
+ put(key: string, payload: JsonObject, ttlSeconds?: number | null): Promise<void>;
20
+ forget(key: string): Promise<void>;
21
+ withLock<T>(key: string, callback: () => T | Promise<T>, _ttlSeconds?: number, waitSeconds?: number): Promise<T>;
22
+ }
@@ -0,0 +1,97 @@
1
+ import { clone } from '../json.js';
2
+ import { HarnessError } from '../errors.js';
3
+ /**
4
+ * State in this process's memory. VOLATILE, and it says so.
5
+ *
6
+ * The right home for the ephemeral slot in a test or a single-process tool, and
7
+ * REFUSED for the durable slot by the store manager — not as a technicality:
8
+ * the contents do not outlive the process, and the durable slot holds approvals
9
+ * a human has not answered yet.
10
+ *
11
+ * The lock is real but PROCESS-LOCAL. Two workers cannot see each other's
12
+ * locks, which is the whole reason a deployment uses something else; this
13
+ * serialises callers within one process and claims nothing beyond that.
14
+ */
15
+ export class MemorySessionStore {
16
+ #entries = new Map();
17
+ /** Key → the promise chain currently serialising work on it. */
18
+ #locks = new Map();
19
+ durability() {
20
+ return 'volatile';
21
+ }
22
+ async get(key) {
23
+ const entry = this.#entries.get(key);
24
+ if (entry === undefined) {
25
+ return null;
26
+ }
27
+ if (entry.expiresAt !== null && entry.expiresAt <= Date.now()) {
28
+ this.#entries.delete(key);
29
+ return null;
30
+ }
31
+ // A copy, so a caller mutating what it read cannot reach into the store.
32
+ return clone(entry.payload);
33
+ }
34
+ async put(key, payload, ttlSeconds = null) {
35
+ this.#entries.set(key, {
36
+ payload: clone(payload),
37
+ expiresAt: ttlSeconds === null ? null : Date.now() + ttlSeconds * 1000,
38
+ });
39
+ }
40
+ async forget(key) {
41
+ this.#entries.delete(key);
42
+ }
43
+ async withLock(key, callback, _ttlSeconds = 10, waitSeconds = 5) {
44
+ const previous = this.#locks.get(key) ?? Promise.resolve();
45
+ let release;
46
+ const held = new Promise((resolve) => {
47
+ release = resolve;
48
+ });
49
+ // CLAIMED SYNCHRONOUSLY, before the first `await`.
50
+ //
51
+ // Registering after awaiting is a check-then-act race: two callers arriving
52
+ // in the same tick both read an empty slot, both decide they hold the lock,
53
+ // and both run. That is the same shape as an `exists?`-then-`create` on the
54
+ // file driver, which is why that one uses exclusive creation instead — and
55
+ // it is not theoretical, it is what the serialisation test caught here.
56
+ //
57
+ // The chain resolves only once the previous holder has finished AND this
58
+ // one has released, so waiters queue in arrival order.
59
+ const chained = previous.then(() => held, () => held);
60
+ this.#locks.set(key, chained);
61
+ // The wait is bounded. A callback that never returns would otherwise hold
62
+ // every later caller forever, and "the lock timed out" is a far better
63
+ // failure than a request that hangs until something upstream gives up.
64
+ const acquired = await Promise.race([
65
+ previous.then(() => true, () => true),
66
+ sleep(waitSeconds * 1000).then(() => false),
67
+ ]);
68
+ if (!acquired) {
69
+ // Release before throwing, or everyone queued behind this caller waits on
70
+ // a lock that was never actually taken.
71
+ this.#releaseSlot(key, chained, release);
72
+ throw HarnessError.sessionLocked(key, waitSeconds);
73
+ }
74
+ try {
75
+ return await callback();
76
+ }
77
+ finally {
78
+ this.#releaseSlot(key, chained, release);
79
+ }
80
+ }
81
+ #releaseSlot(key, chained, release) {
82
+ release();
83
+ // Only clear the slot if nobody queued behind us. Deleting a slot another
84
+ // caller has already chained onto would let a third caller start while the
85
+ // second is still running.
86
+ if (this.#locks.get(key) === chained) {
87
+ this.#locks.delete(key);
88
+ }
89
+ }
90
+ }
91
+ function sleep(ms) {
92
+ return new Promise((resolve) => {
93
+ const timer = setTimeout(resolve, ms);
94
+ // Do not hold the event loop open just to fail a lock later.
95
+ timer.unref?.();
96
+ });
97
+ }
@@ -0,0 +1,65 @@
1
+ import type { JsonObject } from '../json.js';
2
+ /**
3
+ * Whether a store's contents survive a deploy.
4
+ *
5
+ * This is the distinction the whole state layer turns on. An in-memory map or a
6
+ * Redis used as a cache is the natural home for live session state — and a
7
+ * cache is disposable by definition. Something has to say which of the two a
8
+ * configured store actually is, because the package cannot detect it and
9
+ * guessing wrong loses a half-executed agent action rather than a cheap value.
10
+ */
11
+ export type Durability =
12
+ /**
13
+ * Contents may vanish at any time — a flush, an eviction, a deploy.
14
+ *
15
+ * Only safe for state whose loss degrades to a default: the active mode, the
16
+ * selected model, run bookkeeping. Ask again and you get a sensible answer.
17
+ */
18
+ 'volatile'
19
+ /**
20
+ * Contents survive until deliberately removed.
21
+ *
22
+ * Required for anything whose loss is a correctness failure rather than an
23
+ * inconvenience — a pending tool approval is a half-executed action waiting
24
+ * on a human, and it has to outlive the request, the worker, and a deploy.
25
+ */
26
+ | 'durable';
27
+ export declare function isDurable(durability: Durability): boolean;
28
+ /**
29
+ * Where session state lives between turns.
30
+ *
31
+ * A server handles a request and moves on, so a session cannot be an object
32
+ * held in memory the way a single-process agent's is — it has to be
33
+ * reconstructed from a store every time. This is that store.
34
+ *
35
+ * Implementations declare their OWN durability rather than having it inferred.
36
+ * Only the application knows whether its Redis is persistent or a disposable
37
+ * cache, and the difference decides whether losing the contents is a shrug or a
38
+ * lost agent action.
39
+ */
40
+ export interface SessionStore {
41
+ /** Null when nothing is stored. */
42
+ get(key: string): Promise<JsonObject | null>;
43
+ /** `ttlSeconds` of null keeps the payload until it is removed. */
44
+ put(key: string, payload: JsonObject, ttlSeconds?: number | null): Promise<void>;
45
+ forget(key: string): Promise<void>;
46
+ /**
47
+ * Run the callback while holding an EXCLUSIVE lock on the key.
48
+ *
49
+ * Two workers can resolve the same session at the same moment — a queued job
50
+ * finishing a run while the user sends another message is ordinary, not
51
+ * exotic. Whatever must not happen twice goes in here.
52
+ *
53
+ * Returns the callback's value. Throws `HarnessError` code `session_locked`
54
+ * if the lock cannot be acquired within `waitSeconds`, rather than running
55
+ * the callback anyway.
56
+ */
57
+ withLock<T>(key: string, callback: () => T | Promise<T>, ttlSeconds?: number, waitSeconds?: number): Promise<T>;
58
+ /**
59
+ * Whether this store's contents survive a deploy.
60
+ *
61
+ * Read by the manager when a slot is resolved: a store that reports itself
62
+ * volatile is refused for durable state instead of silently accepting it.
63
+ */
64
+ durability(): Durability;
65
+ }
@@ -0,0 +1,3 @@
1
+ export function isDurable(durability) {
2
+ return durability === 'durable';
3
+ }
@@ -0,0 +1,32 @@
1
+ import type { SessionStore } from './session-store.js';
2
+ export declare const SLOT_EPHEMERAL = "ephemeral";
3
+ export declare const SLOT_DURABLE = "durable";
4
+ export type StoreFactory = () => SessionStore;
5
+ export interface StoreManagerOptions {
6
+ /** Driver name per slot. Both default to whatever is registered as `default`. */
7
+ stores?: Partial<Record<'ephemeral' | 'durable', string>>;
8
+ /** Driver name → factory. Resolved lazily, and each driver is built at most once. */
9
+ drivers: Record<string, StoreFactory>;
10
+ }
11
+ /**
12
+ * Resolves the two state slots, and REFUSES a configuration that would lose work.
13
+ *
14
+ * State is split into two named slots rather than one store, because the halves
15
+ * have genuinely different requirements:
16
+ *
17
+ * - `ephemeral` — active mode, selected model, run bookkeeping. Losing it
18
+ * degrades to a default.
19
+ * - `durable` — threads and pending tool approvals. Losing it is a correctness
20
+ * failure, not a cache miss.
21
+ *
22
+ * The guard is the point of this class: a driver that reports itself volatile
23
+ * is refused for the durable slot AT RESOLVE TIME. Accepting it and finding out
24
+ * later is exactly the failure the package was written to avoid.
25
+ */
26
+ export declare class SessionStoreManager {
27
+ #private;
28
+ constructor(options: StoreManagerOptions);
29
+ ephemeral(): SessionStore;
30
+ durable(): SessionStore;
31
+ slot(slot: string): SessionStore;
32
+ }
@@ -0,0 +1,57 @@
1
+ import { HarnessError } from '../errors.js';
2
+ import { isDurable } from './session-store.js';
3
+ export const SLOT_EPHEMERAL = 'ephemeral';
4
+ export const SLOT_DURABLE = 'durable';
5
+ /**
6
+ * Resolves the two state slots, and REFUSES a configuration that would lose work.
7
+ *
8
+ * State is split into two named slots rather than one store, because the halves
9
+ * have genuinely different requirements:
10
+ *
11
+ * - `ephemeral` — active mode, selected model, run bookkeeping. Losing it
12
+ * degrades to a default.
13
+ * - `durable` — threads and pending tool approvals. Losing it is a correctness
14
+ * failure, not a cache miss.
15
+ *
16
+ * The guard is the point of this class: a driver that reports itself volatile
17
+ * is refused for the durable slot AT RESOLVE TIME. Accepting it and finding out
18
+ * later is exactly the failure the package was written to avoid.
19
+ */
20
+ export class SessionStoreManager {
21
+ #drivers;
22
+ #slots;
23
+ #resolved = new Map();
24
+ constructor(options) {
25
+ this.#drivers = options.drivers;
26
+ this.#slots = {
27
+ [SLOT_EPHEMERAL]: options.stores?.ephemeral ?? 'default',
28
+ [SLOT_DURABLE]: options.stores?.durable ?? 'default',
29
+ };
30
+ }
31
+ ephemeral() {
32
+ return this.slot(SLOT_EPHEMERAL);
33
+ }
34
+ durable() {
35
+ return this.slot(SLOT_DURABLE);
36
+ }
37
+ slot(slot) {
38
+ const cached = this.#resolved.get(slot);
39
+ if (cached !== undefined)
40
+ return cached;
41
+ const name = this.#slots[slot] ?? 'default';
42
+ const factory = this.#drivers[name];
43
+ if (factory === undefined) {
44
+ throw HarnessError.unknownStoreDriver(slot, name);
45
+ }
46
+ const store = factory();
47
+ // Checked HERE rather than at construction, so it fires in the same place
48
+ // whether the store is configured up front, swapped in a test, or changed
49
+ // at runtime — and so a misconfiguration cannot lie dormant until the first
50
+ // approval needs saving.
51
+ if (slot === SLOT_DURABLE && !isDurable(store.durability())) {
52
+ throw HarnessError.volatileDurableStore(slot, name);
53
+ }
54
+ this.#resolved.set(slot, store);
55
+ return store;
56
+ }
57
+ }
@@ -0,0 +1,45 @@
1
+ import type { PrismHarness } from './harness.js';
2
+ import type { AgentRuntime } from './runtime.js';
3
+ import type { Session } from './session.js';
4
+ import { RunContext, type Subagent } from './subagents.js';
5
+ import type { HarnessTool } from './tools.js';
6
+ export type SubagentOutcome =
7
+ /** It ran and returned. */
8
+ 'completed'
9
+ /** The tree had nothing left to spend. */
10
+ | 'exhausted'
11
+ /** The tree was stopped. */
12
+ | 'cancelled'
13
+ /** Refused before it began — depth, most often. */
14
+ | 'denied'
15
+ /** It ran and threw. */
16
+ | 'failed';
17
+ export interface SubagentResult {
18
+ subagent: string;
19
+ runId: string;
20
+ parentRunId: string;
21
+ outcome: SubagentOutcome;
22
+ text: string;
23
+ /** What to hand back to the model as the tool's result. */
24
+ toToolResult(): string;
25
+ }
26
+ /**
27
+ * Turns a declared `Subagent` into a TOOL the parent run can call.
28
+ *
29
+ * The parent is mid-run and holding its own session lock when this executes.
30
+ * Everything here is arranged so that fact stays harmless:
31
+ *
32
+ * - the child resolves its OWN session address, so it takes a different lock;
33
+ * - the child's authority comes from its declared mode, never the parent's;
34
+ * - the child's budget is drawn from the tree's remaining allowance;
35
+ * - EVERY way the child can end returns a framed result rather than throwing,
36
+ * because the parent is a legitimate audience for "that did not work" and
37
+ * tearing down the parent run would discard work it had already done.
38
+ */
39
+ export declare class SubagentRunner {
40
+ private readonly harness;
41
+ private readonly runtime;
42
+ constructor(harness: PrismHarness, runtime: AgentRuntime);
43
+ tool(subagent: Subagent, parent: Session, context: RunContext, parentRunId: string): HarnessTool;
44
+ run(subagent: Subagent, parent: Session, context: RunContext, parentRunId: string, task: string): Promise<SubagentResult>;
45
+ }