@particle-academy/prism-harness 0.8.0 → 0.9.1

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.
package/dist/index.d.ts CHANGED
@@ -2,23 +2,28 @@ export { HarnessError } from './errors.js';
2
2
  export type { HarnessErrorCode, HarnessErrorOptions } from './errors.js';
3
3
  export type { JsonObject, JsonValue } from './json.js';
4
4
  export { isJsonObject } from './json.js';
5
- export type { AppendLogStore, Durability, SessionStore } from './stores/session-store.js';
5
+ export type { AppendLogStore, Durability, LockScope, SessionStore } from './stores/session-store.js';
6
6
  export { isDurable, supportsAppendLog } from './stores/session-store.js';
7
7
  export { MemorySessionStore } from './stores/memory-session-store.js';
8
8
  export { FileSessionStore } from './stores/file-session-store.js';
9
+ export type { PendingFileLockRelease } from './stores/file-owner-lock.js';
10
+ export type { FileLockIdentityStatus } from './stores/file-lock-identity.js';
9
11
  export { SessionStoreManager, SLOT_DURABLE, SLOT_EPHEMERAL } from './stores/store-manager.js';
10
12
  export type { StoreFactory, StoreManagerOptions } from './stores/store-manager.js';
11
13
  export { Session } from './session.js';
12
14
  export type { Participant, RunState, SessionOptions } from './session.js';
13
15
  export { Thread } from './thread.js';
14
16
  export type { ThreadMessage } from './thread.js';
15
- export { PendingSession, PrismHarness } from './harness.js';
16
- export type { HarnessOptions } from './harness.js';
17
+ export { AgentHarness, PendingSession, PrismHarness } from './harness.js';
18
+ export { ParticipantAdapters, ParticipantRef, SessionAddress } from './address.js';
19
+ export { AddressLimitExceeded, IndexLockScopeUnsupported, IndexRecoveryRequired, StoreAddressIndex } from './address-index.js';
20
+ export type { AddressIndexPage } from './address-index.js';
21
+ export type { AgentHarnessConfig, HarnessOptions } from './harness.js';
17
22
  export { AgentMode, ModeRegistry } from './modes.js';
18
23
  export type { ModeConfig, ModeRegistryConfig } from './modes.js';
19
24
  export { MAX_DEPTH, RunBudget, RunContext, RunLedger, Subagent, subagentFromConfig } from './subagents.js';
20
25
  export { ToolAuthorizer, ToolRegistry, authorizedTool } from './tools.js';
21
- export type { CallPolicy, HarnessTool, OfferPolicy, ToolAuthorizerOptions, ToolFactory, ToolProvider, } from './tools.js';
26
+ export type { CallPolicy, HarnessTool, OfferPolicy, ToolAuthorizerOptions, ToolFactory, RegistryOptions, ToolProvider, } from './tools.js';
22
27
  export { HarnessEvents } from './events.js';
23
28
  export type { HarnessEvent, HarnessListener, RunFailed, RunFinished, RunStarted } from './events.js';
24
29
  export { diagnose } from './doctor.js';
@@ -26,7 +31,7 @@ export type { DoctorFinding, DoctorReport, DoctorSubjects } from './doctor.js';
26
31
  export { AgentRuntime, recordApproval } from './runtime.js';
27
32
  export { assistantRow, threadView, toolResultEntry, toolResultRow } from './thread-rows.js';
28
33
  export type { ApprovalDecisionEntry, ApprovalRequestEntry, AssistantRow, ToolCallInput, ToolCallRow, ToolResultEntry, ToolResultRow, } from './thread-rows.js';
29
- export type { AgentResponse, AgentRuntimeOptions, LlmClient, LlmRequest, LlmResponse, LlmToolCall, PendingApproval, StructuredAgentResponse, } from './runtime.js';
34
+ export type { AgentResponse, AgentRuntimeOptions, EffectiveTool, RuntimeConfig, LlmClient, LlmRequest, LlmResponse, LlmToolCall, PendingApproval, StructuredAgentResponse, } from './runtime.js';
30
35
  export { schemaName, schemaProblems } from './structured.js';
31
36
  export { DEFAULT_LEASE_SECONDS, TASK_STATES, asAgentTask, attributesToRecord, canonicalTaskJson, isTaskState, isTerminalState, recordToAttributes, taskRecordFrom, withAgentTask, } from './tasks.js';
32
37
  export type { AgentTask, AgentTaskAttributeSource, AgentTaskAttributes, AgentTaskRecord, AgentTaskSource, LeasedAgentTask, TaskOutcome, TaskState, } from './tasks.js';
@@ -38,4 +43,5 @@ export { SkillRegistry } from './skills.js';
38
43
  export { SubagentRunner } from './subagent-runner.js';
39
44
  export type { SubagentOutcome, SubagentResult } from './subagent-runner.js';
40
45
  export { admitAttachments } from './attachments.js';
46
+ export { RegistrationCollision } from './registration.js';
41
47
  export type { Attachment, MediaLike } from './attachments.js';
package/dist/index.js CHANGED
@@ -6,7 +6,9 @@ export { FileSessionStore } from './stores/file-session-store.js';
6
6
  export { SessionStoreManager, SLOT_DURABLE, SLOT_EPHEMERAL } from './stores/store-manager.js';
7
7
  export { Session } from './session.js';
8
8
  export { Thread } from './thread.js';
9
- export { PendingSession, PrismHarness } from './harness.js';
9
+ export { AgentHarness, PendingSession, PrismHarness } from './harness.js';
10
+ export { ParticipantAdapters, ParticipantRef, SessionAddress } from './address.js';
11
+ export { AddressLimitExceeded, IndexLockScopeUnsupported, IndexRecoveryRequired, StoreAddressIndex } from './address-index.js';
10
12
  export { AgentMode, ModeRegistry } from './modes.js';
11
13
  export { MAX_DEPTH, RunBudget, RunContext, RunLedger, Subagent, subagentFromConfig } from './subagents.js';
12
14
  export { ToolAuthorizer, ToolRegistry, authorizedTool } from './tools.js';
@@ -21,3 +23,4 @@ export { agentCompletionTool } from './task-tools.js';
21
23
  export { SkillRegistry } from './skills.js';
22
24
  export { SubagentRunner } from './subagent-runner.js';
23
25
  export { admitAttachments } from './attachments.js';
26
+ export { RegistrationCollision } from './registration.js';
package/dist/modes.d.ts CHANGED
@@ -11,6 +11,9 @@ export interface ModeConfig {
11
11
  provider_options?: Record<string, unknown>;
12
12
  }
13
13
  export interface ModeRegistryConfig {
14
+ refuseDuplicates?: boolean;
15
+ owner?: string;
16
+ agents?: Record<string, ModeRegistryConfig>;
14
17
  default?: string;
15
18
  modes?: Record<string, ModeConfig>;
16
19
  }
@@ -53,7 +56,13 @@ export declare class AgentMode {
53
56
  export declare class ModeRegistry {
54
57
  #private;
55
58
  private readonly config;
56
- constructor(config?: ModeRegistryConfig);
59
+ private readonly agent;
60
+ private readonly root?;
61
+ constructor(config?: ModeRegistryConfig, agent?: string | null, root?: ModeRegistry | undefined);
62
+ forAgent(agent: string | null): ModeRegistry;
63
+ register(name: string, mode: ModeConfig, owner?: string): this;
64
+ replace(name: string, mode: ModeConfig, predecessorOwner: string, owner?: string): this;
65
+ setDefault(name: string): this;
57
66
  default(): string;
58
67
  /** Every mode name this application has configured. */
59
68
  names(): string[];
package/dist/modes.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { HarnessError } from './errors.js';
2
2
  import { isJsonObject } from './json.js';
3
3
  import { subagentFromConfig } from './subagents.js';
4
+ import { validateAgent } from './address.js';
5
+ import { RegistrationCollision, registrationOwner } from './registration.js';
4
6
  /**
5
7
  * What a mode lets a run do.
6
8
  *
@@ -47,8 +49,58 @@ export class AgentMode {
47
49
  }
48
50
  export class ModeRegistry {
49
51
  config;
50
- constructor(config = {}) {
52
+ agent;
53
+ root;
54
+ #agents = new Map();
55
+ #owners = new Map();
56
+ constructor(config = {}, agent = null, root) {
51
57
  this.config = config;
58
+ this.agent = agent;
59
+ this.root = root;
60
+ if (config.refuseDuplicates !== undefined && typeof config.refuseDuplicates !== 'boolean')
61
+ throw new TypeError('refuseDuplicates must be a boolean');
62
+ validateAgent(agent);
63
+ for (const name of Object.keys(config.modes ?? {}))
64
+ this.#owners.set(name, registrationOwner(config.owner, agent));
65
+ for (const key of ['config', 'agent', 'root'])
66
+ Object.defineProperty(this, key, { writable: false, configurable: false });
67
+ }
68
+ forAgent(agent) {
69
+ validateAgent(agent);
70
+ const root = this.root ?? this;
71
+ if (agent === null)
72
+ return root;
73
+ let view = root.#agents.get(agent);
74
+ if (!view) {
75
+ const configured = root.config.agents?.[agent] ?? {};
76
+ view = new ModeRegistry({ ...structuredClone(configured), refuseDuplicates: root.config.refuseDuplicates, default: configured.default ?? root.default() }, agent, root);
77
+ root.#agents.set(agent, view);
78
+ }
79
+ return view;
80
+ }
81
+ register(name, mode, owner) {
82
+ const label = registrationOwner(owner, this.agent), previous = this.#owners.get(name);
83
+ if ((this.agent !== null || this.config.refuseDuplicates === true) && previous !== undefined)
84
+ throw new RegistrationCollision(this.agent, name, previous, label);
85
+ this.#set(name, mode, label);
86
+ return this;
87
+ }
88
+ replace(name, mode, predecessorOwner, owner) {
89
+ const label = registrationOwner(owner, this.agent);
90
+ if (this.#owners.get(name) !== predecessorOwner)
91
+ throw new Error('Replacement must name the registered predecessor owner.');
92
+ this.#set(name, mode, label);
93
+ return this;
94
+ }
95
+ #set(name, mode, owner) {
96
+ this.config.modes ??= {};
97
+ this.config.modes[name] = structuredClone(mode);
98
+ this.#owners.set(name, owner);
99
+ }
100
+ setDefault(name) {
101
+ this.resolve(name);
102
+ this.config.default = name;
103
+ return this;
52
104
  }
53
105
  default() {
54
106
  const configured = this.config.default;
@@ -0,0 +1,6 @@
1
+ export declare class RegistrationCollision extends Error {
2
+ readonly code = "registration_collision";
3
+ constructor(agent: string | null, name: string, previous: string, incoming: string);
4
+ }
5
+ /** Diagnostic labels are host configuration, never permission grants. */
6
+ export declare function registrationOwner(owner: string | undefined, agent: string | null): string;
@@ -0,0 +1,15 @@
1
+ export class RegistrationCollision extends Error {
2
+ code = 'registration_collision';
3
+ constructor(agent, name, previous, incoming) {
4
+ super(`Harness registry collision: agent [${agent ?? 'legacy'}], name [${name}], owners [${previous}] and [${incoming}]. Use replace naming predecessor owner [${previous}].`);
5
+ }
6
+ }
7
+ /** Diagnostic labels are host configuration, never permission grants. */
8
+ export function registrationOwner(owner, agent) {
9
+ const label = owner ?? (agent === null ? 'legacy' : undefined);
10
+ if (typeof label !== 'string' || label.length === 0 || Buffer.byteLength(label) > 128
11
+ || Buffer.from(label, 'utf8').toString('utf8') !== label || /[\u0000-\u001f\u007f]/.test(label)) {
12
+ throw new TypeError('A named registration requires a non-empty UTF-8 owner label of at most 128 bytes without control characters.');
13
+ }
14
+ return label;
15
+ }
package/dist/runtime.d.ts CHANGED
@@ -16,6 +16,8 @@ import type { HarnessTool, ToolAuthorizer, ToolRegistry } from './tools.js';
16
16
  * The reference couples these because Prism is already a dependency there.
17
17
  */
18
18
  export interface LlmRequest {
19
+ /** Optional ceiling the model client must pass to its provider request. */
20
+ maxOutputTokens?: number;
19
21
  systemPrompt: string;
20
22
  /** The conversation so far, serialized — oldest first. */
21
23
  messages: readonly JsonObject[];
@@ -108,15 +110,34 @@ export interface PendingApproval {
108
110
  arguments: JsonObject;
109
111
  }
110
112
  export interface AgentRuntimeOptions {
113
+ /** Legacy compatibility default. Named agents always enforce their mode ceiling. */
114
+ narrowRequestedTools?: boolean;
115
+ config?: RuntimeConfig;
116
+ agents?: Record<string, RuntimeConfig>;
111
117
  client: LlmClient;
112
118
  modes: ModeRegistry;
113
119
  tools: ToolRegistry;
114
120
  authorizer?: ToolAuthorizer;
115
121
  events?: HarnessEvents;
116
122
  }
123
+ export interface RuntimeConfig {
124
+ provider?: string | null;
125
+ model?: string | null;
126
+ max_steps_ceiling?: number | null;
127
+ max_output_tokens_per_call?: number | null;
128
+ lock_ttl?: number;
129
+ lock_wait?: number;
130
+ }
131
+ export interface EffectiveTool {
132
+ name: string;
133
+ owner: string;
134
+ source: string;
135
+ }
117
136
  export declare class AgentRuntime {
118
137
  #private;
119
138
  constructor(options: AgentRuntimeOptions);
139
+ /** Inspect the offer without a run/handler or logical mutation; reads may purge expired entries. */
140
+ effectiveTools(session: Session, modeName?: string): Promise<EffectiveTool[]>;
120
141
  /**
121
142
  * Run a turn.
122
143
  *
@@ -149,19 +170,4 @@ export declare class AgentRuntime {
149
170
  */
150
171
  sendStructured(session: Session, prompt: string, schema: Readonly<JsonObject>, toolNames?: readonly string[], context?: RunContext, additionalContent?: readonly unknown[]): Promise<StructuredAgentResponse>;
151
172
  }
152
- /**
153
- * Answer a pending approval, durably.
154
- *
155
- * The decision is RECORDED IN THE THREAD, not held anywhere else. Who may
156
- * approve is the APPLICATION's decision, not this package's: the session is
157
- * already scoped to a participant, so nobody can answer another participant's
158
- * approval through it, but "this user may approve THIS action" is a question
159
- * only the host can answer. Authorize before calling.
160
- *
161
- * Nothing runs until the next `send()`, which acts on every decision recorded
162
- * by then and refuses any pending call still without one. With several pending,
163
- * record them all first.
164
- *
165
- * `approvalId` is `PendingApproval.id`, not the tool call id.
166
- */
167
173
  export declare function recordApproval(session: Session, approvalId: string, approved: boolean, reason?: string | null): Promise<void>;
package/dist/runtime.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { validateAgent } from './address.js';
2
3
  import { assistantRow, threadView, toolResultEntry, toolResultRow, } from './thread-rows.js';
3
4
  import { admitAttachments } from './attachments.js';
4
5
  import { HarnessError } from './errors.js';
@@ -32,12 +33,56 @@ export class AgentRuntime {
32
33
  #tools;
33
34
  #authorizer;
34
35
  #events;
36
+ #config;
37
+ #agents;
38
+ #narrowRequestedTools;
35
39
  constructor(options) {
40
+ if (options.narrowRequestedTools !== undefined && typeof options.narrowRequestedTools !== 'boolean')
41
+ throw new TypeError('narrowRequestedTools must be a boolean');
42
+ this.#narrowRequestedTools = options.narrowRequestedTools ?? false;
36
43
  this.#client = options.client;
37
44
  this.#modes = options.modes;
38
45
  this.#tools = options.tools;
39
46
  this.#authorizer = options.authorizer;
40
47
  this.#events = options.events;
48
+ this.#config = { ...options.config };
49
+ this.#agents = Object.fromEntries(Object.entries(options.agents ?? {}).map(([agent, config]) => {
50
+ validateAgent(agent);
51
+ validateRuntimeConfig(config);
52
+ return [agent, { ...config }];
53
+ }));
54
+ validateRuntimeConfig(this.#config);
55
+ }
56
+ #setting(session, key) {
57
+ const options = session.agent === null ? undefined : this.#agents[session.agent];
58
+ return options && Object.hasOwn(options, key) ? options[key] : this.#config[key];
59
+ }
60
+ #boundedRun(session, run) {
61
+ const steps = this.#setting(session, 'max_steps_ceiling'), output = this.#setting(session, 'max_output_tokens_per_call'), budget = run.budget;
62
+ if (steps == null && output == null)
63
+ return run;
64
+ return new RunContext(run.ledger, new RunBudget(steps == null ? budget.maxSteps : Math.min(budget.maxSteps, run.ledger.steps + steps), budget.maxCostUsd, budget.maxSeconds, output == null ? budget.maxOutputTokensPerCall : Math.min(budget.maxOutputTokensPerCall ?? output, output)), run.parentRunId, run.depth);
65
+ }
66
+ /** Inspect the offer without a run/handler or logical mutation; reads may purge expired entries. */
67
+ async effectiveTools(session, modeName) {
68
+ const mode = this.#modes.forAgent(session.agent).resolve(modeName ?? await session.mode());
69
+ return (await this.#offer(session, mode)).descriptors;
70
+ }
71
+ async #offer(session, mode, requested) {
72
+ return session.resolveReadOnly(mode.name, async (view) => {
73
+ const names = session.agent !== null || this.#narrowRequestedTools ? narrowedTools(mode.tools, requested) : (requested ?? mode.tools);
74
+ const detailed = await this.#tools.resolveDetailed(names, view);
75
+ const tools = new Map([...detailed].map(([name, entry]) => [name, entry.tool]));
76
+ if (view.agent !== null && tools.has('complete_task')) {
77
+ const authorizer = this.#authorizer?.forAgent(view.agent);
78
+ if (!authorizer?.enabled || !authorizer.hasCallPolicy())
79
+ throw new HarnessError('unsafe_authorization_configuration', 'Named task completion requires enabled authorization and an explicit call policy.');
80
+ }
81
+ const offered = this.#authorizer ? await this.#authorizer.allowed(view, tools) : [...tools.values()];
82
+ const ownership = new Map([...detailed.values()].map(entry => [entry.tool.name, entry]));
83
+ const descriptors = offered.map(tool => ({ name: tool.name, owner: ownership.get(tool.name).owner, source: ownership.get(tool.name).source }));
84
+ return { tools: offered, descriptors };
85
+ });
41
86
  }
42
87
  /**
43
88
  * Run a turn.
@@ -80,11 +125,11 @@ export class AgentRuntime {
80
125
  // Refused before a run exists: a bad attachment is a mistake in the call,
81
126
  // and it should not cost a run, events or budget.
82
127
  const attachments = admitAttachments(prompt, additionalContent);
83
- const mode = this.#modes.resolve(await session.mode());
84
- const provider = (await session.provider()) ?? 'unknown';
85
- const model = (await session.model()) ?? 'unknown';
128
+ const mode = this.#modes.forAgent(session.agent).resolve(await session.mode());
129
+ const provider = (await session.provider()) ?? this.#setting(session, 'provider') ?? 'unknown';
130
+ const model = (await session.model()) ?? this.#setting(session, 'model') ?? 'unknown';
86
131
  const runId = randomUUID();
87
- const run = context ?? RunContext.root(runId, new RunBudget(mode.maxSteps));
132
+ const run = this.#boundedRun(session, context ?? RunContext.root(runId, new RunBudget(mode.maxSteps)));
88
133
  const thread = session.thread();
89
134
  if (run.tooDeep()) {
90
135
  throw HarnessError.runNotPermitted(`This run is nested ${run.depth} deep, at or past the ceiling. Nobody debugs a tree that deep, ` +
@@ -103,8 +148,13 @@ export class AgentRuntime {
103
148
  at: new Date().toISOString(),
104
149
  });
105
150
  try {
106
- const resolved = await this.#tools.resolve(toolNames ?? mode.tools, session);
107
- const offered = this.#authorizer ? await this.#authorizer.allowed(session, resolved) : [...resolved.values()];
151
+ let offered;
152
+ if (session.agent === null && !this.#narrowRequestedTools) {
153
+ const resolved = await this.#tools.resolve(toolNames ?? mode.tools, session);
154
+ offered = this.#authorizer ? await this.#authorizer.allowed(session, resolved) : [...resolved.values()];
155
+ }
156
+ else
157
+ offered = (await this.#offer(session, mode, toolNames)).tools;
108
158
  const called = [];
109
159
  // Decisions recorded since the run stopped are acted on FIRST, before a
110
160
  // new prompt is recorded, so the results land after the calls they answer
@@ -164,6 +214,7 @@ export class AgentRuntime {
164
214
  provider,
165
215
  model,
166
216
  providerOptions: mode.providerOptions,
217
+ ...(run.budget.maxOutputTokensPerCall === null ? {} : { maxOutputTokens: run.budget.maxOutputTokensPerCall }),
167
218
  ...(schema === undefined ? {} : { schema }),
168
219
  });
169
220
  run.ledger.recordSteps(1);
@@ -279,7 +330,7 @@ export class AgentRuntime {
279
330
  }
280
331
  }
281
332
  await live.thread().record([toolResultRow([...work.results.values(), ...resolved], [...work.decisions.values()])], runId);
282
- }, RESOLUTION_LOCK_SECONDS, RESOLUTION_WAIT_SECONDS);
333
+ }, this.#setting(session, 'lock_ttl') ?? RESOLUTION_LOCK_SECONDS, this.#setting(session, 'lock_wait') ?? RESOLUTION_WAIT_SECONDS);
283
334
  }
284
335
  /**
285
336
  * The gated calls of the last tool-calling turn that have no result yet, with
@@ -398,6 +449,33 @@ export class AgentRuntime {
398
449
  *
399
450
  * `approvalId` is `PendingApproval.id`, not the tool call id.
400
451
  */
452
+ function validateRuntimeConfig(config) {
453
+ for (const key of ['provider', 'model']) {
454
+ const value = config[key];
455
+ if (value !== undefined && value !== null && (typeof value !== 'string' || value === ''))
456
+ throw new TypeError('Agent provider/model defaults must be nonempty strings or null.');
457
+ }
458
+ for (const key of ['max_steps_ceiling', 'max_output_tokens_per_call']) {
459
+ const value = config[key];
460
+ if (value !== undefined && value !== null && (!Number.isSafeInteger(value) || value < 1))
461
+ throw new TypeError('Agent step/output ceilings must be positive integers or null.');
462
+ }
463
+ for (const key of ['lock_ttl', 'lock_wait']) {
464
+ const value = config[key];
465
+ if (value !== undefined && (!Number.isSafeInteger(value) || value < (key === 'lock_ttl' ? 1 : 0)))
466
+ throw new TypeError('Agent lock settings must be whole seconds with positive TTL and nonnegative wait.');
467
+ }
468
+ }
469
+ function narrowedTools(ceiling, requested) {
470
+ if (requested === undefined || requested.includes('*'))
471
+ return ceiling;
472
+ if (ceiling.includes('*'))
473
+ return requested;
474
+ const refused = requested.find(name => !ceiling.includes(name));
475
+ if (refused !== undefined)
476
+ throw HarnessError.toolNotAvailable(refused, ceiling);
477
+ return requested;
478
+ }
401
479
  export async function recordApproval(session, approvalId, approved, reason = null) {
402
480
  const run = await session.run();
403
481
  await session.thread().record([toolResultRow([], [{ approval_id: approvalId, approved, reason }])], run?.id ?? null);
package/dist/session.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { SessionAddress } from './address.js';
2
+ import { StoreAddressIndex } from './address-index.js';
1
3
  import type { JsonObject } from './json.js';
2
4
  import type { SessionStore } from './stores/session-store.js';
3
5
  import { StoreTaskSource } from './store-task-source.js';
@@ -29,6 +31,10 @@ export interface SessionOptions {
29
31
  durable: SessionStore;
30
32
  /** How long ephemeral state lives. Null keeps it until forgotten. */
31
33
  ttlSeconds?: number | null;
34
+ agent?: string | null;
35
+ actor?: object;
36
+ addressIndex?: StoreAddressIndex;
37
+ maxAddressesPerParticipant?: number | null;
32
38
  }
33
39
  /**
34
40
  * One participant's live runtime, reconstructed per turn.
@@ -47,6 +53,8 @@ export interface SessionOptions {
47
53
  */
48
54
  export declare class Session {
49
55
  #private;
56
+ readonly address: SessionAddress;
57
+ readonly actor: object;
50
58
  readonly scope: string;
51
59
  readonly participant: Participant;
52
60
  constructor(options: SessionOptions);
@@ -64,6 +72,7 @@ export declare class Session {
64
72
  * ONE store and resolve the same session.
65
73
  */
66
74
  key(): string;
75
+ get agent(): string | null;
67
76
  mode(): Promise<string | null>;
68
77
  usingMode(mode: string): Promise<this>;
69
78
  model(): Promise<string | null>;
@@ -75,6 +84,7 @@ export declare class Session {
75
84
  forget(): Promise<this>;
76
85
  /** The stored conversation this session is bound to. */
77
86
  thread(): Thread;
87
+ findThread(): Promise<Thread | null>;
78
88
  /**
79
89
  * The agent task list this session is bound to.
80
90
  *
@@ -111,4 +121,6 @@ export declare class Session {
111
121
  * run inside this, not outside it.
112
122
  */
113
123
  lock<T>(callback: (session: this) => T | Promise<T>, ttlSeconds?: number, waitSeconds?: number): Promise<T>;
124
+ /** Refuse explicit logical mutations; ordinary get() retains expired-entry cleanup. */
125
+ resolveReadOnly<T>(mode: string, resolve: (session: Session) => T | Promise<T>): Promise<T>;
114
126
  }
package/dist/session.js CHANGED
@@ -1,4 +1,5 @@
1
- import { createHash } from 'node:crypto';
1
+ import { ParticipantRef, SessionAddress } from './address.js';
2
+ import { indexedStore, storeLockRank, StoreAddressIndex } from './address-index.js';
2
3
  import { isJsonObject } from './json.js';
3
4
  import { StoreTaskSource } from './store-task-source.js';
4
5
  import { Thread } from './thread.js';
@@ -18,18 +19,39 @@ import { Thread } from './thread.js';
18
19
  * - the thread and stored capabilities are DURABLE.
19
20
  */
20
21
  export class Session {
22
+ address;
23
+ actor;
24
+ #addressIndex;
25
+ #rawDurable;
26
+ #rawEphemeral;
27
+ #lockStore;
21
28
  scope;
22
29
  participant;
23
30
  #ephemeral;
24
31
  #durable;
25
32
  #ttlSeconds;
26
33
  #cachedState = null;
34
+ #resolutionOnly = false;
35
+ #resolvedMode = null;
27
36
  constructor(options) {
28
- this.participant = options.participant;
37
+ if (options.participant instanceof ParticipantRef && options.actor === undefined)
38
+ throw new Error('Typed participants require an explicit authorization actor.');
39
+ const strictIdentity = options.agent != null || options.participant instanceof ParticipantRef;
40
+ this.participant = options.participant instanceof ParticipantRef ? options.participant : (strictIdentity ? Object.freeze({ ...options.participant }) : options.participant);
41
+ this.address = new SessionAddress(this.participant, options.scope, options.agent ?? null);
42
+ this.actor = options.actor ?? this.participant;
43
+ const index = options.addressIndex ?? (this.address.agent !== null || options.participant instanceof ParticipantRef
44
+ ? new StoreAddressIndex(options.durable, options.maxAddressesPerParticipant ?? null) : undefined);
45
+ this.#addressIndex = index;
46
+ this.#rawDurable = options.durable;
47
+ this.#rawEphemeral = options.ephemeral;
29
48
  this.scope = options.scope;
30
- this.#ephemeral = options.ephemeral;
31
- this.#durable = options.durable;
49
+ this.#ephemeral = index ? indexedStore(options.ephemeral, index, this.address, 'ephemeral') : options.ephemeral;
50
+ this.#durable = index ? indexedStore(options.durable, index, this.address, 'durable') : options.durable;
51
+ this.#lockStore = index && storeLockRank(options.durable) > storeLockRank(options.ephemeral) ? this.#durable : this.#ephemeral;
32
52
  this.#ttlSeconds = options.ttlSeconds ?? null;
53
+ for (const key of strictIdentity ? ['address', 'actor', 'participant', 'scope'] : ['address', 'actor'])
54
+ Object.defineProperty(this, key, { writable: false, configurable: false });
33
55
  }
34
56
  /**
35
57
  * The address this session is resolved by.
@@ -45,11 +67,14 @@ export class Session {
45
67
  * ONE store and resolve the same session.
46
68
  */
47
69
  key() {
48
- const digest = createHash('sha1').update(this.participant.type).digest('hex').slice(0, 12);
49
- return `session:${digest}:${String(this.participant.id)}:${this.scope}`;
70
+ return this.address.agent === null && !(this.participant instanceof ParticipantRef)
71
+ ? new SessionAddress(this.participant, this.scope).sessionKey() : this.address.sessionKey();
50
72
  }
73
+ get agent() { return this.address.agent; }
51
74
  // -- ephemeral half --------------------------------------------------------
52
75
  async mode() {
76
+ if (this.#resolvedMode !== null)
77
+ return this.#resolvedMode;
53
78
  return this.#readString('mode');
54
79
  }
55
80
  async usingMode(mode) {
@@ -69,10 +94,11 @@ export class Session {
69
94
  }
70
95
  async state() {
71
96
  this.#cachedState ??= (await this.#ephemeral.get(this.#ephemeralKey())) ?? {};
72
- return this.#cachedState;
97
+ return this.#resolutionOnly ? structuredClone(this.#cachedState) : this.#cachedState;
73
98
  }
74
99
  /** Drop the ephemeral half. THE CONVERSATION IS UNTOUCHED. */
75
100
  async forget() {
101
+ this.#assertWritable();
76
102
  await this.#ephemeral.forget(this.#ephemeralKey());
77
103
  this.#cachedState = null;
78
104
  return this;
@@ -80,7 +106,13 @@ export class Session {
80
106
  // -- durable half ----------------------------------------------------------
81
107
  /** The stored conversation this session is bound to. */
82
108
  thread() {
83
- return new Thread(this.#durable, `${this.key()}:thread`);
109
+ this.#assertWritable();
110
+ const store = this.#addressIndex ? indexedStore(this.#rawDurable, this.#addressIndex, this.address, 'durable', 'thread') : this.#durable;
111
+ return new Thread(store, `${this.key()}:thread`);
112
+ }
113
+ async findThread() {
114
+ const thread = this.thread();
115
+ return await thread.exists() ? thread : null;
84
116
  }
85
117
  /**
86
118
  * The agent task list this session is bound to.
@@ -93,13 +125,15 @@ export class Session {
93
125
  * against a store as safe as one reached through here.
94
126
  */
95
127
  tasks(options = {}) {
96
- return new StoreTaskSource(this.#durable, `${this.key()}:tasks`, options);
128
+ this.#assertWritable();
129
+ const store = this.#addressIndex ? indexedStore(this.#rawDurable, this.#addressIndex, this.address, 'durable', 'tasks') : this.#durable;
130
+ return new StoreTaskSource(store, `${this.key()}:tasks`, options);
97
131
  }
98
132
  async capability(name) {
99
133
  const stored = (await this.#durable.get(this.#durableKey())) ?? {};
100
134
  const capabilities = isJsonObject(stored.capabilities) ? stored.capabilities : {};
101
135
  const capability = capabilities[name];
102
- return isJsonObject(capability) ? capability : null;
136
+ return isJsonObject(capability) ? (this.#resolutionOnly ? structuredClone(capability) : capability) : null;
103
137
  }
104
138
  async usingCapability(name, state) {
105
139
  return this.#writeCapabilities((capabilities) => ({ ...capabilities, [name]: state }));
@@ -149,7 +183,8 @@ export class Session {
149
183
  * run inside this, not outside it.
150
184
  */
151
185
  async lock(callback, ttlSeconds = 10, waitSeconds = 5) {
152
- return this.#ephemeral.withLock(this.key(), async () => {
186
+ this.#assertWritable();
187
+ return this.#lockStore.withLock(this.key(), async () => {
153
188
  // Re-read inside the lock. State written by whoever held it before us
154
189
  // is otherwise invisible to this instance, and acting on a stale read
155
190
  // is the thing the lock exists to prevent.
@@ -164,17 +199,37 @@ export class Session {
164
199
  #durableKey() {
165
200
  return `${this.key()}:durable`;
166
201
  }
202
+ /** Refuse explicit logical mutations; ordinary get() retains expired-entry cleanup. */
203
+ async resolveReadOnly(mode, resolve) {
204
+ const view = new Session({ participant: this.participant, scope: this.scope, agent: this.agent, actor: this.actor,
205
+ ephemeral: this.#rawEphemeral, durable: this.#rawDurable, addressIndex: this.#addressIndex, ttlSeconds: this.#ttlSeconds });
206
+ view.#resolutionOnly = true;
207
+ view.#resolvedMode = mode;
208
+ try {
209
+ return await resolve(view);
210
+ }
211
+ finally {
212
+ view.#resolutionOnly = false;
213
+ view.#cachedState = null;
214
+ }
215
+ }
216
+ #assertWritable() {
217
+ if (this.#resolutionOnly)
218
+ throw new Error('Harness tool resolution is read-only.');
219
+ }
167
220
  async #readString(key) {
168
221
  const value = (await this.state())[key];
169
222
  return typeof value === 'string' ? value : null;
170
223
  }
171
224
  async #write(key, value) {
225
+ this.#assertWritable();
172
226
  const state = { ...(await this.state()), [key]: value };
173
227
  await this.#ephemeral.put(this.#ephemeralKey(), state, this.#ttlSeconds);
174
228
  this.#cachedState = state;
175
229
  return this;
176
230
  }
177
231
  async #writeCapabilities(mutate) {
232
+ this.#assertWritable();
178
233
  const stored = (await this.#durable.get(this.#durableKey())) ?? {};
179
234
  const capabilities = isJsonObject(stored.capabilities) ? stored.capabilities : {};
180
235
  await this.#durable.put(this.#durableKey(), { ...stored, capabilities: mutate(capabilities) });
@@ -0,0 +1,20 @@
1
+ export interface LockIdentity {
2
+ readonly platform: 'linux' | 'darwin' | 'win32';
3
+ readonly boot_id: string;
4
+ readonly pid_namespace: string | null;
5
+ }
6
+ export interface FileLockIdentityStatus {
7
+ readonly sampled: boolean;
8
+ readonly recovery_available: boolean;
9
+ readonly definitive: boolean;
10
+ readonly retry_after_ms: number | null;
11
+ readonly sample_duration_ms: number;
12
+ readonly code: 'file_lock_identity_unavailable' | null;
13
+ }
14
+ /** Process metadata snapshot only; never runs a query or performs I/O. */
15
+ export declare function fileLockIdentityStatus(): FileLockIdentityStatus;
16
+ /** Definitive metadata is permanent; transient failure is sampled at most once per minute. */
17
+ export declare function nativeLockIdentity(): LockIdentity | null;
18
+ export declare function sameLockIdentity(owner: LockIdentity, local: LockIdentity): boolean;
19
+ /** Read-only signal-zero probe. Permission errors and reused live PIDs remain held. */
20
+ export declare function lockPidIsDead(pid: number): boolean;