operon-agents 0.1.0-alpha.31 → 0.1.0-alpha.32

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.
@@ -4,11 +4,13 @@ import { ExtensionRuntime } from "./runtime.ts";
4
4
  export { ExtensionRuntime } from "./runtime.ts";
5
5
  export { ExtensionLoader, createExtensionLoader } from "./loader.ts";
6
6
  export { ServiceRegistry, ServiceUnavailableError, deadServiceHandle } from "./services.ts";
7
- export { HarnessExtensionManager, stageDefinition } from "./manager.ts";
8
- export type { ExtensionHostBridge, StagingOptions, HeldSession, StagedDefinition } from "./manager.ts";
7
+ export { HarnessExtensionManager, ExtensionSettingsInvalidError, stageDefinition } from "./manager.ts";
8
+ export type { ExtensionHostBridge, ExtensionStatus, StagingOptions, HeldSession, StagedDefinition } from "./manager.ts";
9
+ export { createFileSettingsStore, resolveSettings, validateSettings, validateSettingsSchema, SETTINGS_PERMISSION_MODES } from "./settings.ts";
10
+ export type { ExtensionSettingsStore, ExtensionSettingsView, SettingsFormat, SettingsIssue, SettingsSchema, SettingsSchemaType } from "./settings.ts";
9
11
  export type { ServiceOptions, ServiceUnavailableReason } from "./services.ts";
10
12
  export type { ExtensionManifest, ExtensionFileState, ExtensionFileStatus, ExtensionAttachTarget } from "./loader.ts";
11
- export type { ExtensionActions, ExtensionAPI, ExtensionCommand, ExtensionCommandResult, ExtensionRecordEntry, ExtensionSteerOptions, ExtensionDefinition, ExtensionHostContext, ExtensionEventContext, ExtensionEventMap, ExtensionEventName, ExtensionHandler, ExtensionHost, ExtensionModelRequestEvent, ExtensionModelRequestResult, ExtensionModelResponseEvent, ExtensionResultMap, ExtensionRunSettledEvent, ExtensionRunSettledResult, ExtensionRunStartEvent, ExtensionRunStartResult, ExtensionSessionContext, ExtensionSessionEventContext, ExtensionSessionEndEvent, ExtensionSessionStartEvent, ExtensionState, ExtensionToolSpec, ExtensionStepEndEvent, ExtensionStepEndResult, ExtensionStepStartEvent, ExtensionStepStartResult, ExtensionToolAuthorizeEvent, ExtensionToolAuthorizeResult, ExtensionToolCallEvent, ExtensionToolCallResult, ExtensionToolResultEvent, ExtensionCompactionBeforeEvent, ExtensionCompactionBeforeResult, ExtensionProviderHeadersEvent, ExtensionProviderHeadersResult, ExtensionProviderPayloadEvent, ExtensionProviderPayloadResult, ExtensionProviderResponseEvent, ProviderHeaders, HarnessSessionHandle, ExtensionSessionOptions, ModelProvider, SessionEndReason, SessionStartReason, } from "./types.ts";
13
+ export type { ExtensionActions, ExtensionAPI, ExtensionCommand, ExtensionCommandResult, ExtensionRecordEntry, ExtensionSteerOptions, ExtensionDefinition, ExtensionHostContext, ExtensionInheritedSetting, ExtensionEventContext, ExtensionEventMap, ExtensionEventName, ExtensionHandler, ExtensionHost, ExtensionModelRequestEvent, ExtensionModelRequestResult, ExtensionModelResponseEvent, ExtensionResultMap, ExtensionRunSettledEvent, ExtensionRunSettledResult, ExtensionRunStartEvent, ExtensionRunStartResult, ExtensionSessionContext, ExtensionSessionEventContext, ExtensionSessionEndEvent, ExtensionSessionStartEvent, ExtensionState, ExtensionToolSpec, ExtensionStepEndEvent, ExtensionStepEndResult, ExtensionStepStartEvent, ExtensionStepStartResult, ExtensionToolAuthorizeEvent, ExtensionToolAuthorizeResult, ExtensionToolCallEvent, ExtensionToolCallResult, ExtensionToolResultEvent, ExtensionCompactionBeforeEvent, ExtensionCompactionBeforeResult, ExtensionProviderHeadersEvent, ExtensionProviderHeadersResult, ExtensionProviderPayloadEvent, ExtensionProviderPayloadResult, ExtensionProviderResponseEvent, ProviderHeaders, HarnessSessionHandle, ExtensionSessionOptions, ModelProvider, SessionEndReason, SessionStartReason, } from "./types.ts";
12
14
  /** The extension runtime behind `capability`, or undefined when it is not an extension. */
13
15
  export declare function extensionRuntimeOf(capability: Capability): ExtensionRuntime | undefined;
14
16
  /**
@@ -3,7 +3,8 @@ import { ExtensionRuntime } from "./runtime.js";
3
3
  export { ExtensionRuntime } from "./runtime.js";
4
4
  export { ExtensionLoader, createExtensionLoader } from "./loader.js";
5
5
  export { ServiceRegistry, ServiceUnavailableError, deadServiceHandle } from "./services.js";
6
- export { HarnessExtensionManager, stageDefinition } from "./manager.js";
6
+ export { HarnessExtensionManager, ExtensionSettingsInvalidError, stageDefinition } from "./manager.js";
7
+ export { createFileSettingsStore, resolveSettings, validateSettings, validateSettingsSchema, SETTINGS_PERMISSION_MODES } from "./settings.js";
7
8
  /** The runtime behind each extension capability — how the harness reaches an extension it mounted. */
8
9
  const runtimes = new WeakMap();
9
10
  /** The extension runtime behind `capability`, or undefined when it is not an extension. */
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionDefinition } from "./types.ts";
2
+ import { type SettingsSchema } from "./settings.ts";
2
3
  /** The slice of a session the loader swaps extensions on — `HarnessSession` satisfies it. */
3
4
  export interface ExtensionAttachTarget {
4
5
  attachExtension(definition: ExtensionDefinition): Promise<void>;
@@ -47,6 +48,13 @@ export interface ExtensionManifest {
47
48
  readonly name?: string;
48
49
  /** One line for listings. */
49
50
  readonly description?: string;
51
+ /**
52
+ * The extension's settings, as a JSON Schema subset (see `settings.ts` for what is supported —
53
+ * anything else makes the manifest unusable, never silently ignored). Declared here rather than
54
+ * in code so a host can show and edit them before the extension is loaded or approved; the
55
+ * extension reads the values as `host.settings` / `ctx.settings`.
56
+ */
57
+ readonly settings?: SettingsSchema;
50
58
  }
51
59
  export type ExtensionFileState =
52
60
  /** Approved at the current mtime and produced a definition this loader holds. */
@@ -83,6 +91,11 @@ export declare class ExtensionLoader {
83
91
  manifestDir(id: string): Promise<string>;
84
92
  /** Every extension folder under the directory, with its trust/load state. */
85
93
  list(): Promise<ExtensionFileStatus[]>;
94
+ /** `list()`, with each usable folder's manifest beside its status. */
95
+ scanAll(): Promise<Array<{
96
+ readonly status: ExtensionFileStatus;
97
+ readonly manifest?: ExtensionManifest;
98
+ }>>;
86
99
  /**
87
100
  * Import one extension and approve it as it stands. The call itself is the consent — reserve
88
101
  * it for user-initiated actions, never for something an agent triggers on its own.
@@ -11,6 +11,7 @@ import { join, resolve } from "node:path";
11
11
  import { createRequire } from "node:module";
12
12
  import { pathToFileURL } from "node:url";
13
13
  import { z } from "zod";
14
+ import { validateSettingsSchema } from "./settings.js";
14
15
  /**
15
16
  * File-extension loader — the shell that turns a extension directory into extension VALUES for
16
17
  * `attachExtension`. The framework core stays file-blind: everything here ends in a plain
@@ -72,6 +73,7 @@ const ExtensionManifestSchema = z.looseObject({
72
73
  engine: z.string().regex(VERSION_RE, 'must be a version like "1.2.0"').optional(),
73
74
  name: z.string().optional(),
74
75
  description: z.string().optional(),
76
+ settings: z.unknown().optional(),
75
77
  });
76
78
  const APPROVALS_FILE = ".approvals.json";
77
79
  export class ExtensionLoader {
@@ -93,23 +95,30 @@ export class ExtensionLoader {
93
95
  }
94
96
  /** Every extension folder under the directory, with its trust/load state. */
95
97
  async list() {
98
+ return (await this.scanAll()).map((entry) => entry.status);
99
+ }
100
+ /** `list()`, with each usable folder's manifest beside its status. */
101
+ async scanAll() {
96
102
  const approvals = await this.readApprovals();
97
103
  const out = [];
98
104
  for (const entry of await this.readExtensionDirs()) {
99
105
  try {
100
106
  const scanned = await this.scan(entry);
101
107
  out.push({
102
- id: scanned.manifest.id,
103
- dir: scanned.dir,
104
- ...(scanned.manifest.version !== undefined ? { version: scanned.manifest.version } : {}),
105
- ...(scanned.manifest.engine !== undefined ? { engine: scanned.manifest.engine } : {}),
106
- ...(scanned.manifest.name !== undefined ? { name: scanned.manifest.name } : {}),
107
- ...(scanned.manifest.description !== undefined ? { description: scanned.manifest.description } : {}),
108
- state: this.stateOf(scanned, approvals),
108
+ manifest: scanned.manifest,
109
+ status: {
110
+ id: scanned.manifest.id,
111
+ dir: scanned.dir,
112
+ ...(scanned.manifest.version !== undefined ? { version: scanned.manifest.version } : {}),
113
+ ...(scanned.manifest.engine !== undefined ? { engine: scanned.manifest.engine } : {}),
114
+ ...(scanned.manifest.name !== undefined ? { name: scanned.manifest.name } : {}),
115
+ ...(scanned.manifest.description !== undefined ? { description: scanned.manifest.description } : {}),
116
+ state: this.stateOf(scanned, approvals),
117
+ },
109
118
  });
110
119
  }
111
120
  catch (error) {
112
- out.push({ id: entry, dir: join(this.directory, entry), state: "error", error: messageOf(error) });
121
+ out.push({ status: { id: entry, dir: join(this.directory, entry), state: "error", error: messageOf(error) } });
113
122
  }
114
123
  }
115
124
  return out;
@@ -199,6 +208,12 @@ export class ExtensionLoader {
199
208
  const issues = parsed.error.issues.map((issue) => `${issue.path.length > 0 ? `"${issue.path.join(".")}" ` : ""}${issue.message}`);
200
209
  throw new Error(`manifest.json: ${issues.join("; ")}`);
201
210
  }
211
+ if (parsed.data.settings !== undefined) {
212
+ const issues = validateSettingsSchema(parsed.data.settings);
213
+ if (issues.length > 0) {
214
+ throw new Error(`manifest.json "settings": ${issues.map((issue) => `${issue.path !== "" ? `${issue.path}: ` : ""}${issue.message}`).join("; ")}`);
215
+ }
216
+ }
202
217
  const manifest = parsed.data;
203
218
  if (manifest.engine !== undefined && compareVersions(FRAMEWORK_VERSION, manifest.engine) < 0) {
204
219
  throw new Error(`requires operon-agents >= ${manifest.engine}; this host runs ${FRAMEWORK_VERSION}`);
@@ -1,6 +1,19 @@
1
1
  import type { ExtensionDefinition, ExtensionHostContext } from "./types.ts";
2
2
  import { type ExtensionFileStatus } from "./loader.ts";
3
3
  import { ServiceRegistry, type ServiceOptions } from "./services.ts";
4
+ import { type ExtensionSettingsStore, type ExtensionSettingsView, type SettingsIssue } from "./settings.ts";
5
+ /** What `harness.extensions.list()` reports for one extension folder: its file status, and its
6
+ * settings when its manifest declares some. */
7
+ export interface ExtensionStatus extends ExtensionFileStatus {
8
+ readonly settings?: ExtensionSettingsView;
9
+ }
10
+ /** `setSettings` was handed a value its schema refuses; nothing was stored. */
11
+ export declare class ExtensionSettingsInvalidError extends Error {
12
+ readonly name = "ExtensionSettingsInvalidError";
13
+ readonly extensionId: string;
14
+ readonly issues: readonly SettingsIssue[];
15
+ constructor(extensionId: string, issues: readonly SettingsIssue[]);
16
+ }
4
17
  /** The session surface the barrier hands to a swap function. */
5
18
  export interface HeldSession {
6
19
  readonly id: string;
@@ -20,6 +33,7 @@ export interface ExtensionHostBridge {
20
33
  createSession(options: Parameters<ExtensionHostContext["createSession"]>[0], extensionId: string): Promise<{
21
34
  readonly id: string;
22
35
  }>;
36
+ readSessionMessages: ExtensionHostContext["readSessionMessages"];
23
37
  sessions(): readonly HeldSession[];
24
38
  /** Rendezvous every session holding one of `extensionIds` at its run boundary, run `fn` in
25
39
  * the quiet moment (held() reads live — sessions born gated during the barrier appear). */
@@ -47,8 +61,12 @@ export interface StagingOptions {
47
61
  readonly dataDir?: string;
48
62
  createSession: ExtensionHostContext["createSession"];
49
63
  resumeSession: ExtensionHostContext["resumeSession"];
64
+ readSessionMessages: ExtensionHostContext["readSessionMessages"];
50
65
  /** Resolve one of the definition's `uses` names to its service handle. */
51
66
  service(name: string): unknown;
67
+ /** The extension's resolved settings — `host.settings` and `ctx.settings`. Absent when it
68
+ * declares none (and always by value). */
69
+ readonly settings?: Readonly<Record<string, unknown>>;
52
70
  warn(message: string): void;
53
71
  }
54
72
  /**
@@ -68,12 +86,33 @@ export declare class HarnessExtensionManager {
68
86
  private readonly loaded;
69
87
  /** Definitions newborn sessions receive: every loaded extension's per-session half. */
70
88
  private readonly sessionDefs;
89
+ private readonly settingsStore;
90
+ /** The tail of each extension's settings writes — one at a time per extension. */
91
+ private readonly settingsWrites;
71
92
  constructor(options: {
72
93
  readonly directory: string;
73
94
  readonly dataDir: string;
95
+ readonly settings: ExtensionSettingsStore;
74
96
  readonly bridge: ExtensionHostBridge;
75
97
  });
76
- list(): Promise<ExtensionFileStatus[]>;
98
+ /** Every extension folder, with its trust/load state and — when it declares them — its settings. */
99
+ list(): Promise<ExtensionStatus[]>;
100
+ /**
101
+ * One extension's settings: the schema its manifest declares, the value it reads (stored, over
102
+ * the defaults) and the issues found reading it. `undefined` when it declares none. Works for an
103
+ * extension that is not loaded or not approved — only its manifest is read.
104
+ */
105
+ getSettings(id: string): Promise<ExtensionSettingsView | undefined>;
106
+ /**
107
+ * Replace one extension's settings. The value is checked against its schema first — on any issue
108
+ * this throws `ExtensionSettingsInvalidError` and stores nothing. Once stored, a loaded extension
109
+ * is reloaded the coordinated way (its sessions meet at their run boundary; none is
110
+ * interrupted), so its `harness` half runs again against the new value. One write at a time per
111
+ * extension. An extension not loaded just keeps the value for its next load.
112
+ */
113
+ setSettings(id: string, value: unknown, options?: {
114
+ readonly timeoutMs?: number;
115
+ }): Promise<void>;
77
116
  /** What every new session is born with. Wire into the harness's extension evaluation. */
78
117
  sessionDefinitions(): ExtensionDefinition[];
79
118
  /**
@@ -100,6 +139,9 @@ export declare class HarnessExtensionManager {
100
139
  /** Run `harness` with a staging host: the service is COLLECTED, not registered, so a throwing
101
140
  * `harness` publishes nothing. Services default to replaceable — reload is their point. */
102
141
  private stage;
142
+ /** The settings an extension reads, from its manifest and the store; `undefined` when it
143
+ * declares none. A store that cannot be read is reported, and the defaults are used. */
144
+ private settingsView;
103
145
  /** Sessions still running an extension that `uses` `id`'s service — read off the attached
104
146
  * definitions themselves, so by-value and file consumers count alike. No graph walk: the
105
147
  * graph is depth 1. */
@@ -18,6 +18,18 @@ import { mkdir } from "node:fs/promises";
18
18
  import { join, resolve } from "node:path";
19
19
  import { ExtensionLoader } from "./loader.js";
20
20
  import { ServiceRegistry } from "./services.js";
21
+ import { resolveSettings, validateSettings } from "./settings.js";
22
+ /** `setSettings` was handed a value its schema refuses; nothing was stored. */
23
+ export class ExtensionSettingsInvalidError extends Error {
24
+ name = "ExtensionSettingsInvalidError";
25
+ extensionId;
26
+ issues;
27
+ constructor(extensionId, issues) {
28
+ super(`settings for extension "${extensionId}" are invalid: ${issues.map((issue) => `${issue.path !== "" ? `${issue.path}: ` : ""}${issue.message}`).join("; ")}`);
29
+ this.extensionId = extensionId;
30
+ this.issues = issues;
31
+ }
32
+ }
21
33
  function isPromiseLike(value) {
22
34
  return typeof value === "object" && value !== null && typeof value.then === "function";
23
35
  }
@@ -29,23 +41,37 @@ function isPromiseLike(value) {
29
41
  * moment `createHarness` returns.
30
42
  */
31
43
  export function stageDefinition(definition, options) {
44
+ const settings = options.settings;
45
+ const sessionDef = settings === undefined ? definition : withSessionSettings(definition, settings);
32
46
  if (definition.harness === undefined)
33
- return { sessionDef: definition };
47
+ return { sessionDef };
34
48
  const host = {
35
49
  createSession: (sessionOptions) => options.createSession(sessionOptions),
36
50
  resumeSession: (id) => options.resumeSession(id),
51
+ readSessionMessages: (id, pageOptions) => options.readSessionMessages(id, pageOptions),
37
52
  services: Object.freeze(Object.fromEntries((definition.uses ?? []).map((name) => [name, options.service(name)]))),
38
53
  ...(options.dir !== undefined ? { dir: options.dir } : {}),
39
54
  ...(options.dataDir !== undefined ? { dataDir: options.dataDir } : {}),
55
+ ...(settings !== undefined ? { settings } : {}),
40
56
  warn: (message) => options.warn(`[extension ${definition.id}] ${message}`),
41
57
  };
42
58
  const finish = (instance) => ({
43
59
  service: { name: definition.id, instance, options: { replaceable: options.defaultReplaceable } },
44
- sessionDef: definition,
60
+ sessionDef,
45
61
  });
46
62
  const result = definition.harness(host);
47
63
  return isPromiseLike(result) ? Promise.resolve(result).then(finish) : finish(result);
48
64
  }
65
+ /** The definition, its `session` handed `settings` in its context. Everything else is read through
66
+ * to the original (a prototype link, so a definition that is a class instance keeps its methods). */
67
+ function withSessionSettings(definition, settings) {
68
+ const wrapped = Object.create(definition);
69
+ Object.defineProperty(wrapped, "session", {
70
+ enumerable: true,
71
+ value: (api, ctx) => definition.session.call(definition, api, { ...ctx, settings }),
72
+ });
73
+ return wrapped;
74
+ }
49
75
  const DEFAULT_BARRIER_TIMEOUT_MS = 30_000;
50
76
  export class HarnessExtensionManager {
51
77
  loader;
@@ -56,13 +82,61 @@ export class HarnessExtensionManager {
56
82
  loaded = new Map();
57
83
  /** Definitions newborn sessions receive: every loaded extension's per-session half. */
58
84
  sessionDefs = new Map();
85
+ settingsStore;
86
+ /** The tail of each extension's settings writes — one at a time per extension. */
87
+ settingsWrites = new Map();
59
88
  constructor(options) {
60
89
  this.loader = new ExtensionLoader({ directory: options.directory });
61
90
  this.dataRoot = resolve(options.dataDir);
91
+ this.settingsStore = options.settings;
62
92
  this.bridge = options.bridge;
63
93
  }
64
- list() {
65
- return this.loader.list();
94
+ /** Every extension folder, with its trust/load state and — when it declares them — its settings. */
95
+ async list() {
96
+ const out = [];
97
+ for (const { status, manifest } of await this.loader.scanAll()) {
98
+ const settings = manifest === undefined ? undefined : await this.settingsView(manifest);
99
+ out.push(settings === undefined ? status : { ...status, settings });
100
+ }
101
+ return out;
102
+ }
103
+ /**
104
+ * One extension's settings: the schema its manifest declares, the value it reads (stored, over
105
+ * the defaults) and the issues found reading it. `undefined` when it declares none. Works for an
106
+ * extension that is not loaded or not approved — only its manifest is read.
107
+ */
108
+ async getSettings(id) {
109
+ return this.settingsView(await this.loader.manifest(id));
110
+ }
111
+ /**
112
+ * Replace one extension's settings. The value is checked against its schema first — on any issue
113
+ * this throws `ExtensionSettingsInvalidError` and stores nothing. Once stored, a loaded extension
114
+ * is reloaded the coordinated way (its sessions meet at their run boundary; none is
115
+ * interrupted), so its `harness` half runs again against the new value. One write at a time per
116
+ * extension. An extension not loaded just keeps the value for its next load.
117
+ */
118
+ async setSettings(id, value, options = {}) {
119
+ const previous = this.settingsWrites.get(id) ?? Promise.resolve();
120
+ const write = previous.catch(() => undefined).then(async () => {
121
+ const schema = (await this.loader.manifest(id)).settings;
122
+ if (schema === undefined)
123
+ throw new Error(`extension "${id}" declares no settings`);
124
+ const issues = validateSettings(schema, value);
125
+ if (issues.length > 0)
126
+ throw new ExtensionSettingsInvalidError(id, issues);
127
+ await this.settingsStore.put(id, value);
128
+ const record = this.loaded.get(id);
129
+ if (record !== undefined)
130
+ await this.register(id, record.definition, options);
131
+ });
132
+ this.settingsWrites.set(id, write);
133
+ try {
134
+ await write;
135
+ }
136
+ finally {
137
+ if (this.settingsWrites.get(id) === write)
138
+ this.settingsWrites.delete(id);
139
+ }
66
140
  }
67
141
  /** What every new session is born with. Wire into the harness's extension evaluation. */
68
142
  sessionDefinitions() {
@@ -126,7 +200,7 @@ export class HarnessExtensionManager {
126
200
  async register(id, definition, options) {
127
201
  const staged = await this.stage(definition);
128
202
  const previous = this.loaded.get(id);
129
- const record = { hasService: staged.service !== undefined };
203
+ const record = { hasService: staged.service !== undefined, definition };
130
204
  if (previous === undefined) {
131
205
  // First load: register the service, then hand the session half to future sessions.
132
206
  // Already-open sessions are not touched (a reload only re-attaches to sessions that hold
@@ -168,16 +242,39 @@ export class HarnessExtensionManager {
168
242
  const dataDir = join(this.dataRoot, definition.id);
169
243
  if (definition.harness !== undefined)
170
244
  await mkdir(dataDir, { recursive: true });
245
+ const settings = await this.settingsView(await this.loader.manifest(definition.id));
246
+ for (const issue of settings?.issues ?? []) {
247
+ this.bridge.warn(`[extension ${definition.id}] setting ${issue.path !== "" ? `"${issue.path}"` : "(root)"} ${issue.message}`);
248
+ }
171
249
  return stageDefinition(definition, {
250
+ ...(settings !== undefined ? { settings: deepFreeze(settings.value) } : {}),
172
251
  defaultReplaceable: true,
173
252
  dir: await this.loader.manifestDir(definition.id),
174
253
  dataDir,
175
254
  createSession: (sessionOptions) => this.bridge.createSession(sessionOptions, definition.id),
176
255
  resumeSession: (id) => this.bridge.resumeSession(id, definition.id),
256
+ readSessionMessages: (id, pageOptions) => this.bridge.readSessionMessages(id, pageOptions),
177
257
  service: (name) => this.bridge.services.handle(name),
178
258
  warn: (message) => this.bridge.warn(message),
179
259
  });
180
260
  }
261
+ /** The settings an extension reads, from its manifest and the store; `undefined` when it
262
+ * declares none. A store that cannot be read is reported, and the defaults are used. */
263
+ async settingsView(manifest) {
264
+ const schema = manifest.settings;
265
+ if (schema === undefined)
266
+ return undefined;
267
+ let stored;
268
+ const issues = [];
269
+ try {
270
+ stored = await this.settingsStore.get(manifest.id);
271
+ }
272
+ catch (error) {
273
+ issues.push({ path: "", message: `the stored settings could not be read (${error instanceof Error ? error.message : String(error)}); using the defaults` });
274
+ }
275
+ const resolved = resolveSettings(schema, stored);
276
+ return { schema, value: resolved.value, issues: [...issues, ...resolved.issues] };
277
+ }
181
278
  /** Sessions still running an extension that `uses` `id`'s service — read off the attached
182
279
  * definitions themselves, so by-value and file consumers count alike. No graph walk: the
183
280
  * graph is depth 1. */
@@ -192,4 +289,13 @@ export class HarnessExtensionManager {
192
289
  return found;
193
290
  }
194
291
  }
292
+ /** Settings are handed to an extension read-only: every half sees the same value. */
293
+ function deepFreeze(value) {
294
+ if (typeof value === "object" && value !== null && !Object.isFrozen(value)) {
295
+ Object.freeze(value);
296
+ for (const child of Object.values(value))
297
+ deepFreeze(child);
298
+ }
299
+ return value;
300
+ }
195
301
  //# sourceMappingURL=manager.js.map
@@ -0,0 +1,82 @@
1
+ import type { PermissionMode } from "operon-agents-core";
2
+ /** One problem with a schema or a value, at a dotted path (`types.coder.permissionMode`; `""` is the root). */
3
+ export interface SettingsIssue {
4
+ readonly path: string;
5
+ readonly message: string;
6
+ }
7
+ export type SettingsSchemaType = "object" | "array" | "string" | "integer" | "number" | "boolean";
8
+ /** A string whose options the host provides. `model`: a `provider/model` id, rendered as the
9
+ * host's model picker (only checked to be a string — the host owns the model list).
10
+ * `permission-mode`: a `PermissionMode`, checked against that enum. */
11
+ export type SettingsFormat = "model" | "permission-mode";
12
+ /** The JSON Schema subset a manifest's `settings` is written in. */
13
+ export interface SettingsSchema {
14
+ readonly type: SettingsSchemaType;
15
+ readonly title?: string;
16
+ readonly description?: string;
17
+ readonly default?: unknown;
18
+ readonly enum?: readonly unknown[];
19
+ readonly properties?: Readonly<Record<string, SettingsSchema>>;
20
+ readonly required?: readonly string[];
21
+ readonly additionalProperties?: SettingsSchema;
22
+ readonly propertyNames?: {
23
+ readonly pattern: string;
24
+ };
25
+ readonly items?: SettingsSchema;
26
+ readonly minimum?: number;
27
+ readonly maximum?: number;
28
+ readonly maxLength?: number;
29
+ readonly pattern?: string;
30
+ readonly format?: SettingsFormat;
31
+ readonly "x-multiline"?: boolean;
32
+ readonly "x-order"?: number;
33
+ }
34
+ /** Everything a host needs to render and edit one extension's settings. */
35
+ export interface ExtensionSettingsView {
36
+ readonly schema: SettingsSchema;
37
+ /** The resolved value — what the extension reads as `host.settings`. */
38
+ readonly value: Readonly<Record<string, unknown>>;
39
+ /** Where the stored value did not fit the schema (each such field fell back to its default). */
40
+ readonly issues: readonly SettingsIssue[];
41
+ }
42
+ /** The `permission-mode` format's values. */
43
+ export declare const SETTINGS_PERMISSION_MODES: readonly PermissionMode[];
44
+ /**
45
+ * Check a manifest's `settings` against the supported subset. Empty ⇒ usable. The root must be an
46
+ * object schema (an extension's settings are a record of named fields), and every `default` must
47
+ * itself be a valid value.
48
+ */
49
+ export declare function validateSettingsSchema(schema: unknown): SettingsIssue[];
50
+ /**
51
+ * Check a value to be stored against `schema` — strict: a wrong type, an unknown key, a missing
52
+ * required field or a broken constraint is an issue, each at its path. Empty ⇒ it may be stored.
53
+ */
54
+ export declare function validateSettings(schema: SettingsSchema, value: unknown): SettingsIssue[];
55
+ /**
56
+ * Read a stored value through `schema` — lenient: what is missing takes its default, an unknown
57
+ * key is dropped, and an invalid field falls back to its default and is reported in `issues`.
58
+ * Never throws for a bad value; the result is always an object (the root is an object schema).
59
+ */
60
+ export declare function resolveSettings(schema: SettingsSchema, stored: unknown): {
61
+ readonly value: Record<string, unknown>;
62
+ readonly issues: SettingsIssue[];
63
+ };
64
+ /**
65
+ * Where extensions' settings values are kept, by extension id — what `setSettings` writes and
66
+ * every `harness` half run reads. A value is stored as it was given (validated, but without the
67
+ * defaults filled in), so a default an update changes still reaches a field the user never set.
68
+ * Replace it with `createHarness({ extensionSettings })` to keep settings in the host's own store.
69
+ */
70
+ export interface ExtensionSettingsStore {
71
+ /** The stored value, or `undefined` when none was ever stored. */
72
+ get(extensionId: string): Promise<unknown | undefined>;
73
+ put(extensionId: string, value: unknown): Promise<void>;
74
+ }
75
+ /**
76
+ * The default store: one JSON file per extension, `<directory>/<id>.json` — for a harness with an
77
+ * `extensionDir`, `<extensionDir>/.settings`. Apart from the extension's own `dataDir` on purpose:
78
+ * an extension cannot rewrite its settings past validation, and an update of its bundle leaves
79
+ * them alone. Writes go to a temporary file first and are renamed into place.
80
+ */
81
+ export declare function createFileSettingsStore(directory: string): ExtensionSettingsStore;
82
+ //# sourceMappingURL=settings.d.ts.map
@@ -0,0 +1,398 @@
1
+ /**
2
+ * Extension settings: a manifest declares them as a small JSON Schema subset, the framework keeps
3
+ * the values (validated, defaulted), the extension reads them as `host.settings`, and a host
4
+ * renders one generic form from the schema — no settings page per extension.
5
+ *
6
+ * The schema lives in the manifest, not the code, because the manifest is read before any code is
7
+ * imported: settings can be shown and edited while the extension is not loaded or not yet
8
+ * approved to run.
9
+ *
10
+ * The subset (anything else is refused when the manifest is read, never silently ignored):
11
+ *
12
+ * | keyword | meaning |
13
+ * |--------------------------------------------------|--------------------------------------------|
14
+ * | `type` | object, array, string, integer, number, boolean (required) |
15
+ * | `properties`, `required` | an object with fixed fields |
16
+ * | `additionalProperties` (a schema) + `propertyNames.pattern` | an object whose keys come and go (a table) |
17
+ * | `items` | the elements of an array |
18
+ * | `enum`, `default` | allowed values, the value when unset |
19
+ * | `title`, `description` | labels for the form |
20
+ * | `minimum`, `maximum`, `maxLength`, `pattern` | constraints |
21
+ * | `format` | a string whose options the host provides: `model`, `permission-mode` |
22
+ * | `x-multiline`, `x-order` | rendering hints |
23
+ *
24
+ * Reading is lenient, writing is strict. A stored value is read through `resolveSettings`: a
25
+ * missing field takes its default, an unknown key is dropped, an invalid field falls back to its
26
+ * default and is reported — an extension update that changed its schema must not stop it from
27
+ * loading. A value to be stored goes through `validateSettings` and is refused on any issue.
28
+ */
29
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
30
+ import { join as joinPath } from "node:path";
31
+ /** The `permission-mode` format's values. */
32
+ export const SETTINGS_PERMISSION_MODES = ["manual", "workspace", "auto", "yolo"];
33
+ const TYPES = new Set(["object", "array", "string", "integer", "number", "boolean"]);
34
+ const FORMATS = new Set(["model", "permission-mode"]);
35
+ const COMMON_KEYWORDS = ["type", "title", "description", "default", "enum", "x-order"];
36
+ const KEYWORDS_BY_TYPE = {
37
+ object: new Set([...COMMON_KEYWORDS, "properties", "required", "additionalProperties", "propertyNames"]),
38
+ array: new Set([...COMMON_KEYWORDS, "items"]),
39
+ string: new Set([...COMMON_KEYWORDS, "maxLength", "pattern", "format", "x-multiline"]),
40
+ integer: new Set([...COMMON_KEYWORDS, "minimum", "maximum"]),
41
+ number: new Set([...COMMON_KEYWORDS, "minimum", "maximum"]),
42
+ boolean: new Set(COMMON_KEYWORDS),
43
+ };
44
+ /** Known JSON Schema keywords this first version does not support — named in the refusal, so an
45
+ * author is told "not supported" rather than "unknown". */
46
+ const RESERVED = new Set(["x-secret", "oneOf", "anyOf", "allOf", "not", "$ref", "const", "minLength", "minItems", "maxItems", "uniqueItems", "exclusiveMinimum", "exclusiveMaximum", "multipleOf", "patternProperties", "if", "then", "else"]);
47
+ function join(path, key) {
48
+ return path === "" ? String(key) : `${path}.${key}`;
49
+ }
50
+ function isRecord(value) {
51
+ return typeof value === "object" && value !== null && !Array.isArray(value);
52
+ }
53
+ function describe(path) {
54
+ return path === "" ? "the root" : `"${path}"`;
55
+ }
56
+ // ============================================================================
57
+ // The schema itself
58
+ // ============================================================================
59
+ /**
60
+ * Check a manifest's `settings` against the supported subset. Empty ⇒ usable. The root must be an
61
+ * object schema (an extension's settings are a record of named fields), and every `default` must
62
+ * itself be a valid value.
63
+ */
64
+ export function validateSettingsSchema(schema) {
65
+ const issues = [];
66
+ checkSchema(schema, "", issues);
67
+ if (issues.length === 0 && schema.type !== "object") {
68
+ issues.push({ path: "", message: 'settings must be an object schema ("type": "object")' });
69
+ }
70
+ return issues;
71
+ }
72
+ function checkSchema(schema, path, issues) {
73
+ const at = (message, sub = path) => {
74
+ issues.push({ path: sub, message });
75
+ };
76
+ if (!isRecord(schema))
77
+ return at("must be a schema object");
78
+ const type = schema.type;
79
+ if (typeof type !== "string" || !TYPES.has(type)) {
80
+ return at(`needs a "type": one of ${[...TYPES].join(", ")}`);
81
+ }
82
+ const allowed = KEYWORDS_BY_TYPE[type];
83
+ for (const key of Object.keys(schema)) {
84
+ if (allowed.has(key))
85
+ continue;
86
+ if (RESERVED.has(key))
87
+ at(`"${key}" is not supported in extension settings`);
88
+ else if (Object.values(KEYWORDS_BY_TYPE).some((keys) => keys.has(key)))
89
+ at(`"${key}" does not apply to a ${type}`);
90
+ else
91
+ at(`unknown keyword "${key}"`);
92
+ }
93
+ for (const key of ["title", "description"]) {
94
+ if (schema[key] !== undefined && typeof schema[key] !== "string")
95
+ at(`"${key}" must be a string`);
96
+ }
97
+ if (schema["x-order"] !== undefined && typeof schema["x-order"] !== "number")
98
+ at('"x-order" must be a number');
99
+ if (schema["x-multiline"] !== undefined && typeof schema["x-multiline"] !== "boolean")
100
+ at('"x-multiline" must be a boolean');
101
+ for (const key of ["minimum", "maximum"]) {
102
+ if (schema[key] !== undefined && (typeof schema[key] !== "number" || !Number.isFinite(schema[key])))
103
+ at(`"${key}" must be a number`);
104
+ }
105
+ if (schema.maxLength !== undefined && !(Number.isInteger(schema.maxLength) && schema.maxLength >= 0))
106
+ at('"maxLength" must be a non-negative integer');
107
+ if (schema.pattern !== undefined)
108
+ checkPattern(schema.pattern, '"pattern"', path, issues);
109
+ if (schema.format !== undefined && (typeof schema.format !== "string" || !FORMATS.has(schema.format))) {
110
+ at(`"format" must be one of ${[...FORMATS].join(", ")}`);
111
+ }
112
+ if (type === "object") {
113
+ if (schema.properties !== undefined) {
114
+ if (!isRecord(schema.properties))
115
+ at('"properties" must be an object of schemas');
116
+ else
117
+ for (const [key, sub] of Object.entries(schema.properties))
118
+ checkSchema(sub, join(path, key), issues);
119
+ }
120
+ if (schema.required !== undefined) {
121
+ const properties = isRecord(schema.properties) ? schema.properties : {};
122
+ if (!Array.isArray(schema.required) || schema.required.some((key) => typeof key !== "string"))
123
+ at('"required" must be an array of property names');
124
+ else
125
+ for (const key of schema.required)
126
+ if (!(key in properties))
127
+ at(`"required" names "${key}", which is not in "properties"`);
128
+ }
129
+ if (schema.additionalProperties !== undefined) {
130
+ if (typeof schema.additionalProperties === "boolean")
131
+ at('"additionalProperties" must be a schema (unknown keys are always dropped)');
132
+ else
133
+ checkSchema(schema.additionalProperties, join(path, "*"), issues);
134
+ }
135
+ if (schema.propertyNames !== undefined) {
136
+ if (schema.additionalProperties === undefined)
137
+ at('"propertyNames" only applies with "additionalProperties"');
138
+ if (!isRecord(schema.propertyNames) || Object.keys(schema.propertyNames).some((key) => key !== "pattern") || schema.propertyNames.pattern === undefined) {
139
+ at('"propertyNames" supports only { "pattern": … }');
140
+ }
141
+ else
142
+ checkPattern(schema.propertyNames.pattern, '"propertyNames.pattern"', path, issues);
143
+ }
144
+ }
145
+ if (type === "array") {
146
+ if (schema.items === undefined)
147
+ at('an array needs "items"');
148
+ else
149
+ checkSchema(schema.items, join(path, "[]"), issues);
150
+ }
151
+ // Only once the schema is sound can its own values be checked against it.
152
+ if (issues.length > 0)
153
+ return;
154
+ if (schema.enum !== undefined) {
155
+ if (!Array.isArray(schema.enum) || schema.enum.length === 0)
156
+ at('"enum" must be a non-empty array');
157
+ else {
158
+ const leafOnly = { ...schema, enum: undefined, default: undefined };
159
+ schema.enum.forEach((value, index) => {
160
+ for (const issue of validateSettings(leafOnly, value))
161
+ at(`"enum" value ${index}: ${issue.message}`);
162
+ });
163
+ }
164
+ }
165
+ if (schema.default !== undefined) {
166
+ for (const issue of validateSettings(schema, schema.default)) {
167
+ at(`"default" is not a valid value${issue.path !== "" ? ` at "${issue.path}"` : ""}: ${issue.message}`);
168
+ }
169
+ }
170
+ }
171
+ function checkPattern(pattern, label, path, issues) {
172
+ if (typeof pattern !== "string") {
173
+ issues.push({ path, message: `${label} must be a string` });
174
+ return;
175
+ }
176
+ try {
177
+ new RegExp(pattern, "u");
178
+ }
179
+ catch (error) {
180
+ issues.push({ path, message: `${label} is not a valid regular expression: ${error instanceof Error ? error.message : String(error)}` });
181
+ }
182
+ }
183
+ // ============================================================================
184
+ // Values
185
+ // ============================================================================
186
+ /**
187
+ * Check a value to be stored against `schema` — strict: a wrong type, an unknown key, a missing
188
+ * required field or a broken constraint is an issue, each at its path. Empty ⇒ it may be stored.
189
+ */
190
+ export function validateSettings(schema, value) {
191
+ const issues = [];
192
+ checkValue(schema, value, "", issues, true);
193
+ return issues;
194
+ }
195
+ /** The problems with `value` itself, for a leaf; for a container, its shape and — strictly —
196
+ * everything under it. */
197
+ function checkValue(schema, value, path, issues, strict) {
198
+ const at = (message, sub = path) => {
199
+ issues.push({ path: sub, message });
200
+ };
201
+ switch (schema.type) {
202
+ case "object": {
203
+ if (!isRecord(value))
204
+ return at("must be an object");
205
+ if (!strict)
206
+ return;
207
+ for (const [key, item] of Object.entries(value)) {
208
+ const property = schema.properties?.[key];
209
+ if (property !== undefined) {
210
+ if (item !== undefined)
211
+ checkValue(property, item, join(path, key), issues, true);
212
+ continue;
213
+ }
214
+ if (schema.additionalProperties === undefined) {
215
+ at("is not a known setting", join(path, key));
216
+ continue;
217
+ }
218
+ const keyIssue = keyProblem(schema, key);
219
+ if (keyIssue !== undefined)
220
+ at(keyIssue, join(path, key));
221
+ else
222
+ checkValue(schema.additionalProperties, item, join(path, key), issues, true);
223
+ }
224
+ for (const key of schema.required ?? []) {
225
+ if (value[key] === undefined)
226
+ at("is required", join(path, key));
227
+ }
228
+ break;
229
+ }
230
+ case "array": {
231
+ if (!Array.isArray(value))
232
+ return at("must be an array");
233
+ if (!strict)
234
+ return;
235
+ value.forEach((item, index) => checkValue(schema.items, item, join(path, index), issues, true));
236
+ break;
237
+ }
238
+ case "string": {
239
+ if (typeof value !== "string")
240
+ return at("must be a string");
241
+ if (schema.maxLength !== undefined && [...value].length > schema.maxLength)
242
+ at(`must be at most ${schema.maxLength} characters`);
243
+ if (schema.pattern !== undefined && !new RegExp(schema.pattern, "u").test(value))
244
+ at(`must match ${schema.pattern}`);
245
+ if (schema.format === "permission-mode" && !SETTINGS_PERMISSION_MODES.includes(value)) {
246
+ at(`must be a permission mode: ${SETTINGS_PERMISSION_MODES.join(", ")}`);
247
+ }
248
+ break;
249
+ }
250
+ case "integer":
251
+ case "number": {
252
+ if (typeof value !== "number" || !Number.isFinite(value))
253
+ return at("must be a number");
254
+ if (schema.type === "integer" && !Number.isInteger(value))
255
+ return at("must be an integer");
256
+ if (schema.minimum !== undefined && value < schema.minimum)
257
+ at(`must be at least ${schema.minimum}`);
258
+ if (schema.maximum !== undefined && value > schema.maximum)
259
+ at(`must be at most ${schema.maximum}`);
260
+ break;
261
+ }
262
+ case "boolean": {
263
+ if (typeof value !== "boolean")
264
+ at("must be true or false");
265
+ break;
266
+ }
267
+ }
268
+ if (schema.enum !== undefined && !schema.enum.some((allowed) => sameValue(allowed, value))) {
269
+ at(`must be one of ${schema.enum.map((allowed) => JSON.stringify(allowed)).join(", ")}`);
270
+ }
271
+ }
272
+ function keyProblem(schema, key) {
273
+ const pattern = schema.propertyNames?.pattern;
274
+ return pattern !== undefined && !new RegExp(pattern, "u").test(key) ? `the key "${key}" must match ${pattern}` : undefined;
275
+ }
276
+ function sameValue(a, b) {
277
+ return a === b || JSON.stringify(a) === JSON.stringify(b);
278
+ }
279
+ /**
280
+ * Read a stored value through `schema` — lenient: what is missing takes its default, an unknown
281
+ * key is dropped, and an invalid field falls back to its default and is reported in `issues`.
282
+ * Never throws for a bad value; the result is always an object (the root is an object schema).
283
+ */
284
+ export function resolveSettings(schema, stored) {
285
+ const issues = [];
286
+ let source = stored;
287
+ if (stored !== undefined && !isRecord(stored)) {
288
+ issues.push({ path: "", message: "the stored settings are not an object; using the defaults" });
289
+ source = undefined;
290
+ }
291
+ const value = resolveValue(schema, source, "", issues);
292
+ return { value: isRecord(value) ? value : {}, issues };
293
+ }
294
+ function resolveValue(schema, value, path, issues) {
295
+ if (value === undefined)
296
+ return defaultOf(schema, path);
297
+ const problems = [];
298
+ checkValue(schema, value, path, problems, false);
299
+ if (problems.length > 0) {
300
+ issues.push(...problems.map((issue) => ({ ...issue, message: `${issue.message}; using the default` })));
301
+ return defaultOf(schema, path);
302
+ }
303
+ if (schema.type === "object") {
304
+ const record = value;
305
+ const out = {};
306
+ for (const [key, property] of Object.entries(schema.properties ?? {})) {
307
+ const resolved = resolveValue(property, record[key], join(path, key), issues);
308
+ if (resolved !== undefined)
309
+ out[key] = resolved;
310
+ }
311
+ if (schema.additionalProperties !== undefined) {
312
+ for (const [key, item] of Object.entries(record)) {
313
+ if (schema.properties?.[key] !== undefined || item === undefined)
314
+ continue;
315
+ const keyIssue = keyProblem(schema, key);
316
+ if (keyIssue !== undefined) {
317
+ issues.push({ path: join(path, key), message: `${keyIssue}; dropped` });
318
+ continue;
319
+ }
320
+ const resolved = resolveValue(schema.additionalProperties, item, join(path, key), issues);
321
+ if (resolved !== undefined)
322
+ out[key] = resolved;
323
+ }
324
+ }
325
+ const missing = (schema.required ?? []).filter((key) => out[key] === undefined);
326
+ if (missing.length > 0) {
327
+ issues.push({ path, message: `${missing.map((key) => `"${key}"`).join(", ")} ${missing.length > 1 ? "are" : "is"} required; using the default` });
328
+ return defaultOf(schema, path);
329
+ }
330
+ return out;
331
+ }
332
+ if (schema.type === "array") {
333
+ return value.flatMap((item, index) => {
334
+ const resolved = resolveValue(schema.items, item, join(path, index), issues);
335
+ return resolved === undefined ? [] : [resolved];
336
+ });
337
+ }
338
+ return value;
339
+ }
340
+ /**
341
+ * What an unset field reads as: its `default` (itself resolved, so an object default gains the
342
+ * defaults of the fields it leaves out); for an object without one, the defaults of its fields —
343
+ * `undefined` when that comes to nothing or misses a required field.
344
+ */
345
+ function defaultOf(schema, path) {
346
+ if (schema.default !== undefined) {
347
+ // A default was checked against the schema when the manifest was read.
348
+ return resolveValue({ ...schema, default: undefined }, structuredClone(schema.default), path, []);
349
+ }
350
+ if (schema.type !== "object" || schema.properties === undefined)
351
+ return path === "" && schema.type === "object" ? {} : undefined;
352
+ const out = {};
353
+ for (const [key, property] of Object.entries(schema.properties)) {
354
+ const value = defaultOf(property, join(path, key));
355
+ if (value !== undefined)
356
+ out[key] = value;
357
+ }
358
+ if ((schema.required ?? []).some((key) => out[key] === undefined))
359
+ return undefined;
360
+ return Object.keys(out).length > 0 || path === "" ? out : undefined;
361
+ }
362
+ /**
363
+ * The default store: one JSON file per extension, `<directory>/<id>.json` — for a harness with an
364
+ * `extensionDir`, `<extensionDir>/.settings`. Apart from the extension's own `dataDir` on purpose:
365
+ * an extension cannot rewrite its settings past validation, and an update of its bundle leaves
366
+ * them alone. Writes go to a temporary file first and are renamed into place.
367
+ */
368
+ export function createFileSettingsStore(directory) {
369
+ const fileOf = (extensionId) => {
370
+ if (!/^[A-Za-z0-9._-]+$/.test(extensionId) || extensionId.startsWith("."))
371
+ throw new Error(`not an extension id: ${JSON.stringify(extensionId)}`);
372
+ return joinPath(directory, `${extensionId}.json`);
373
+ };
374
+ let counter = 0;
375
+ return {
376
+ async get(extensionId) {
377
+ let text;
378
+ try {
379
+ text = await readFile(fileOf(extensionId), "utf8");
380
+ }
381
+ catch (error) {
382
+ if (error.code === "ENOENT")
383
+ return undefined;
384
+ throw error;
385
+ }
386
+ return JSON.parse(text);
387
+ },
388
+ async put(extensionId, value) {
389
+ const file = fileOf(extensionId);
390
+ await mkdir(directory, { recursive: true });
391
+ counter += 1;
392
+ const temporary = `${file}.${process.pid}.${counter}.tmp`;
393
+ await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, "utf8");
394
+ await rename(temporary, file);
395
+ },
396
+ };
397
+ }
398
+ //# sourceMappingURL=settings.js.map
@@ -24,7 +24,7 @@
24
24
  * `ExtensionRuntime` is the façade that hides which physical channel a decision point arrives on;
25
25
  * extension authors only ever see one `api.on(...)` table.
26
26
  */
27
- import type { AgentEvent, AssistantMessage, ChatModel, AgentInput, ContextBreakdown, ConversationContext, Injector, LlmRequest, Environment, Message, ModelManager, RunResult, SessionConfig, SessionSummary, PendingApprovalInterrupt, SessionStore, SteerContent, SteerOptions, SteerOrigin, SteerReceipt, StepStopReason, TerminalStepStopReason, ThinkingLevel, Tool, ToolPlan, ToolResult, ToolResultContent, ToolRunContext, Usage } from "operon-agents-core";
27
+ import type { AgentEvent, AssistantMessage, ChatModel, AgentInput, ContextBreakdown, ConversationContext, Injector, LlmRequest, Environment, Message, ModelManager, RunResult, SessionConfig, SessionSummary, PendingApprovalInterrupt, SessionStore, SteerContent, SteerOptions, SteerOrigin, SteerReceipt, StepStopReason, TerminalStepStopReason, ThinkingLevel, TranscriptPage, TranscriptPageOptions, Tool, ToolPlan, ToolResult, ToolResultContent, ToolRunContext, Usage } from "operon-agents-core";
28
28
  /** Per-extension key/value state, namespaced into the session store (memory without one). */
29
29
  /** A slash command an extension carries (see `ExtensionAPI.registerCommand`). The run
30
30
  * receives only the raw argument string — an extension command closes over what it needs. */
@@ -517,6 +517,8 @@ export interface ExtensionAPI {
517
517
  /** The same imperative surface handlers get via `ctx.actions`, usable from `session`. */
518
518
  readonly actions: ExtensionActions;
519
519
  }
520
+ /** What `ExtensionHostContext.createSession` can copy from its `from` session. */
521
+ export type ExtensionInheritedSetting = "model";
520
522
  /**
521
523
  * What an extension's `harness` half is handed — the harness reach it needs to build a
522
524
  * process-shared instance. Deliberately narrow: session creation (a spawn factory's one
@@ -524,9 +526,12 @@ export interface ExtensionAPI {
524
526
  * of the services it declared in `uses`. No service-registration call: the instance IS the
525
527
  * return value, registered under the extension's `id`.
526
528
  */
527
- export interface ExtensionHostContext<TServices extends Readonly<Record<string, unknown>> = Readonly<Record<string, unknown>>> {
529
+ export interface ExtensionHostContext<TServices extends Readonly<Record<string, unknown>> = Readonly<Record<string, unknown>>, TSettings = unknown> {
528
530
  /** Get a session by id, loading it if it is not loaded — `harness.resumeSession(id)`. */
529
531
  resumeSession(id: string): Promise<HarnessSessionHandle>;
532
+ /** One page of a session's transcript WITHOUT loading it — `harness.readSessionMessages(id)`.
533
+ * A parked session stays parked: reading never resumes it. */
534
+ readSessionMessages(id: string, options?: TranscriptPageOptions): Promise<TranscriptPage>;
530
535
  /**
531
536
  * Open a brand-new session on this harness — what a spawn factory calls. The options are
532
537
  * `harness.createSession`'s own (`title`, `workDir`, `ownerKey`, `config`, …); a key the harness
@@ -537,6 +542,11 @@ export interface ExtensionHostContext<TServices extends Readonly<Record<string,
537
542
  * otherwise, the host's `resolveSession` is told its source, and — because it runs for the same
538
543
  * party — where that session runs in an environment of its own (a sandbox), the new one must be
539
544
  * given one too: it is refused rather than opened on the harness's own machine.
545
+ *
546
+ * `inherit` names settings copied from that `from` session as it is right now, for each one
547
+ * `config` does not set itself — `"model"`: the model it currently runs on (a runtime
548
+ * `setModel` included). A source on the harness default, or on a model given as a live object,
549
+ * has no model to copy, and the new session takes the harness default. Requires `from`.
540
550
  */
541
551
  createSession(options?: {
542
552
  readonly title?: string;
@@ -544,6 +554,7 @@ export interface ExtensionHostContext<TServices extends Readonly<Record<string,
544
554
  readonly ownerKey?: string;
545
555
  readonly config?: SessionConfig;
546
556
  readonly from?: string;
557
+ readonly inherit?: readonly ExtensionInheritedSetting[];
547
558
  }): Promise<{
548
559
  readonly id: string;
549
560
  }>;
@@ -565,6 +576,14 @@ export interface ExtensionHostContext<TServices extends Readonly<Record<string,
565
576
  * by value it is opt-in (`createHarness({ extensionDataDir })`).
566
577
  */
567
578
  readonly dataDir?: string;
579
+ /**
580
+ * This extension's settings, as the user set them over the defaults its manifest declares
581
+ * (`manifest.json` `settings`) — read-only, and checked against that schema: a field that does
582
+ * not fit reads as its default. A settings change reloads the extension, so this half runs again
583
+ * with the new value. `undefined` when the manifest declares no settings, and always for an
584
+ * extension passed by value (its host gives it options in code instead).
585
+ */
586
+ readonly settings?: Readonly<TSettings>;
568
587
  warn(message: string): void;
569
588
  }
570
589
  /**
@@ -574,7 +593,7 @@ export interface ExtensionHostContext<TServices extends Readonly<Record<string,
574
593
  * others') and
575
594
  * arrives resolved.
576
595
  */
577
- export interface ExtensionSessionContext<TShared = unknown, TParams = unknown, TServices extends Readonly<Record<string, unknown>> = Readonly<Record<string, unknown>>> {
596
+ export interface ExtensionSessionContext<TShared = unknown, TParams = unknown, TServices extends Readonly<Record<string, unknown>> = Readonly<Record<string, unknown>>, TSettings = unknown> {
578
597
  /** Handle to this extension's own shared instance — its `harness` result, one per process;
579
598
  * `undefined` when the definition has no `harness` half. */
580
599
  readonly shared: TShared;
@@ -586,6 +605,9 @@ export interface ExtensionSessionContext<TShared = unknown, TParams = unknown, T
586
605
  * die with the extension (after detach every call throws `ServiceUnavailableError`).
587
606
  */
588
607
  readonly services: TServices;
608
+ /** The same settings the `harness` half was given (`ExtensionHostContext.settings`): the value
609
+ * as of the last load or settings change. `undefined` when the manifest declares none. */
610
+ readonly settings?: Readonly<TSettings>;
589
611
  }
590
612
  /**
591
613
  * An extension: a programmatic participant assembled into a session. One definition, handed to
@@ -604,11 +626,11 @@ export interface ExtensionSessionContext<TShared = unknown, TParams = unknown, T
604
626
  * session varies it with `createSession({ config: { extensions: { [id]: … } } })` — `ctx.params` — and opts
605
627
  * out of it entirely with `false`.
606
628
  *
607
- * `TShared`/`TParams`/`TServices` are for the author's own typing (use an object type alias for
629
+ * `TShared`/`TParams`/`TServices`/`TSettings` are for the author's own typing (use an object type alias for
608
630
  * `TServices`, not an interface); the framework holds definitions as `ExtensionDefinition` and
609
631
  * every concrete definition is assignable to it.
610
632
  */
611
- export interface ExtensionDefinition<TShared = unknown, TParams = unknown, TServices extends Readonly<Record<string, unknown>> = Readonly<Record<string, unknown>>> {
633
+ export interface ExtensionDefinition<TShared = unknown, TParams = unknown, TServices extends Readonly<Record<string, unknown>> = Readonly<Record<string, unknown>>, TSettings = unknown> {
612
634
  readonly id: string;
613
635
  /** Per-handler invocation budget. Defaults to 30s for decision points, 1s for observers. */
614
636
  readonly timeoutMs?: number;
@@ -619,10 +641,10 @@ export interface ExtensionDefinition<TShared = unknown, TParams = unknown, TServ
619
641
  */
620
642
  readonly forkParams?: "inherit" | "drop";
621
643
  /** The process-shared half: once per harness. See above. */
622
- harness?(host: ExtensionHostContext<TServices>): TShared | Promise<TShared>;
644
+ harness?(host: ExtensionHostContext<TServices, TSettings>): TShared | Promise<TShared>;
623
645
  /** Services this extension consumes, by name — other extensions' shared-half results (their
624
646
  * `id`s) or host-registered `services`. Resolved into `ctx.services` for `session`. */
625
647
  readonly uses?: readonly (keyof TServices & string)[];
626
- session(api: ExtensionAPI, ctx: ExtensionSessionContext<TShared, TParams, TServices>): void | (() => void | Promise<void>) | Promise<void | (() => void | Promise<void>)>;
648
+ session(api: ExtensionAPI, ctx: ExtensionSessionContext<TShared, TParams, TServices, TSettings>): void | (() => void | Promise<void>) | Promise<void | (() => void | Promise<void>)>;
627
649
  }
628
650
  //# sourceMappingURL=types.d.ts.map
package/dist/harness.d.ts CHANGED
@@ -1,7 +1,8 @@
1
+ import { type TranscriptPage, type TranscriptPageOptions } from "operon-agents-core";
1
2
  import { type TelemetryService, type AgentEventListener, type AgentInput, SessionProjection, type SessionSnapshot, type SnapshotOptions, type DirectoryEntry, type ProjectionObservation, type ProjectionListener, type ApprovalRequest, type ApprovalResponse, type Capability, type InterruptAnswer, type ChatModel, type RunOptions, ListenerSink, type AutoApprover, type PermissionMode, type PermissionRule, type PendingRunInterrupt, type QuestionRequest, type QuestionResult, type Responder, type RunHandle, type RunResult, type SessionHandle, type SessionSummary, type ListSessionsFilter, type OpenSessionOptions, type DeleteSessionOptions, type ThinkingLevel, type Tool, type SteerMessage, type SteerOptions, type SteerOrigin, type SteerReceipt, Agent, Runner, Session, McpOAuthService, SkillRegistry, type HookDef, type McpServerConfig, type McpTransportKind, type McpCredentialProvider, type PluginManager, type ApprovalRequestOptions, type Environment, type EnvironmentFactory, type SessionRepository, type ResolvedAgentProfile, type SubagentProvider, type SessionConfig, type HarnessConfig, type SessionPolicy, type SessionMcpConfig, type SkillsConfig } from "operon-agents-core";
2
3
  import type { SkillsService, CompactionService, BackgroundManager, McpServersHandle, McpServersCapabilityOptions, GoalStore, PlanMode, Logger, ModelManager, TracingProcessor } from "operon-agents-core";
3
4
  import { type WorkspaceMcpPool } from "./workspace-mcp.ts";
4
- import { HarnessExtensionManager, ServiceRegistry, type ExtensionDefinition, type ServiceOptions } from "./extensions/index.ts";
5
+ import { HarnessExtensionManager, ServiceRegistry, type ExtensionDefinition, type ExtensionSettingsStore, type ServiceOptions } from "./extensions/index.ts";
5
6
  import { type CommandResult, type TodoItem } from "operon-agents-core";
6
7
  export type ApprovalHandler = (request: ApprovalRequest, options?: ApprovalRequestOptions) => Promise<ApprovalResponse> | ApprovalResponse;
7
8
  export type QuestionHandler = (request: QuestionRequest, options?: {
@@ -128,8 +129,10 @@ export interface SessionResolveRequest<TContext = unknown> {
128
129
  /**
129
130
  * What the host resolves for one open. A `config` returned here is FINAL — the host saw what was
130
131
  * requested and what was stored and decided — and is not stored by the harness, so a host that
131
- * keeps configuration in its own database has exactly one copy. `bindings` are laid over the
132
- * requested ones key by key.
132
+ * keeps configuration in its own database has exactly one copy. What IS stored is unchanged by
133
+ * it: a reopen keeps the setup the session had, and a create stores what it was asked for (its
134
+ * `requested.config` — for a session an extension creates, that extension's parameters), so the
135
+ * next open's `stored` still carries it. `bindings` are laid over the requested ones key by key.
133
136
  */
134
137
  export interface SessionResolution<TContext = unknown> {
135
138
  readonly config?: SessionConfig;
@@ -398,6 +401,13 @@ export interface HarnessOptions<TContext = unknown> {
398
401
  * `undefined`). Kept outside the code folders so an update never touches state.
399
402
  */
400
403
  readonly extensionDataDir?: string;
404
+ /**
405
+ * Where file extensions' settings values are kept (`harness.extensions.setSettings`, read as
406
+ * `host.settings`). Defaults to one JSON file per extension in `<extensionDir>/.settings` — apart
407
+ * from the extensions' own data folders, so an extension cannot rewrite its settings past
408
+ * validation. Replace it to keep settings in the host's own store. Only with `extensionDir`.
409
+ */
410
+ readonly extensionSettings?: ExtensionSettingsStore;
401
411
  /**
402
412
  * Programmatic runtime extensions, registered once here — the by-value twin of `extensionDir`.
403
413
  * Every definition's `session` runs in every session; a session varies that with
@@ -964,6 +974,10 @@ export declare class Harness<TContext = unknown, TOutput = unknown> {
964
974
  * provided for it too.
965
975
  */
966
976
  private createFromExtension;
977
+ /** The model a session runs on right now, by name: the loaded instance's setting (a runtime
978
+ * `setModel` included), or — when it is not loaded here — the one stored with it. `undefined`
979
+ * when it runs on the harness default or on a model given as a live object. */
980
+ private currentModelOf;
967
981
  /** The session an extension is acting from, as the source of what it creates: the loaded
968
982
  * instance, or — when it is not loaded here — what is stored with it. */
969
983
  private sourceOf;
@@ -973,6 +987,18 @@ export declare class Harness<TContext = unknown, TOutput = unknown> {
973
987
  * reconciling background tasks, or subscribing Projection. Intended for read-only servers.
974
988
  * Soft-deleted sessions resolve only with `{ includeDeleted: true }` — audit/purge tooling. */
975
989
  inspectSession(id: string, options?: OpenSessionOptions): Promise<SessionHandle | undefined>;
990
+ /**
991
+ * One page of a session's transcript, newest page first — open or closed alike, and NEVER by
992
+ * opening it: a closed session stays closed (no Session built, no capabilities, no recovery).
993
+ * What happened, including messages a compaction has since summarized away; see
994
+ * `readTranscriptPage` for exactly what is read. Page backwards with the returned `next`.
995
+ *
996
+ * An open session is read through its own store after its pending appends have landed, so a
997
+ * message appended a moment ago is already on the page. Only durable records are read: text
998
+ * still streaming is not on any page — follow it live with `observeProjection`.
999
+ * Soft-deleted and unknown ids throw `SessionRepositoryNotFoundError`.
1000
+ */
1001
+ readSessionMessages(id: string, options?: TranscriptPageOptions): Promise<TranscriptPage>;
976
1002
  /** Read one catalog row without the O(N) `listSessions()` scan. */
977
1003
  getSessionSummary(id: string): Promise<SessionSummary | undefined>;
978
1004
  /** Close one open session by id (no-op if it isn't open). An open in flight for this id is
package/dist/harness.js CHANGED
@@ -14,12 +14,13 @@
14
14
  import { randomUUID } from "node:crypto";
15
15
  import { AsyncLocalStorage } from "node:async_hooks";
16
16
  import { closeDeadlines } from "./harness-test-hooks.js";
17
+ import { readTranscriptPage } from "operon-agents-core";
17
18
  import { readSessionTails, SessionProjection, ListenerSink, Agent, buildAgentFromProfile, INTERRUPTION_STATE_KEY, flattenPendingInterrupts, parseInterruptionState, Runner, Session, emptyUsage, isGuardrailTripwireError, askUserQuestionTool, createAutoApprover, createModelManager, backgroundCapability, compactionCapability, filesystemTools, goalCapability, LocalEnvironment, McpOAuthService, MemorySessionRepository, SessionRepositoryNotFoundError, SessionBusyError, SessionClosedError, SessionInterruptedError, SessionNotInterruptedError, SteerTargetNotFoundError, isAbortError, planCapability, skillsCapability, SkillRegistry, mcpServersCapability, pluginsCapability, userHooksCapability, taskCapability, workflowCapability, DEFAULT_AGENT_PROFILES, DEFAULT_AGENT_PROFILE_NAME, DEFAULT_SUBAGENT_NAMES, profileSubagentProvider, DEFAULT_ADDRESS, envLogger, noopLogger, parseSessionConfig, resolveSessionConfig, inlineMcpSecretPaths, sessionPolicyViolation, hookDefsFromConfig, SessionPolicySchema, SESSION_MODEL_KEY, SESSION_PERMISSION_KEY, SESSION_THINKING_KEY } from "operon-agents-core";
18
19
  import { recoverOrphanedTurns } from "operon-agents-core/internal";
19
20
  import { mkdirSync } from "node:fs";
20
21
  import { workspaceMcpCapability } from "./workspace-mcp.js";
21
22
  import { join } from "node:path";
22
- import { extensionCapability, extensionRuntimeOf, extensionsCapability, HarnessExtensionManager, ServiceRegistry, stageDefinition } from "./extensions/index.js";
23
+ import { extensionCapability, extensionRuntimeOf, extensionsCapability, HarnessExtensionManager, ServiceRegistry, stageDefinition, createFileSettingsStore } from "./extensions/index.js";
23
24
  import { createExtensionCommandRegistry } from "operon-agents-core";
24
25
  /** The harness has been closed and can open nothing further. */
25
26
  export class HarnessClosedError extends Error {
@@ -97,8 +98,10 @@ const SESSION_CONFIG_STATE_KEY = "session:config";
97
98
  const DEFAULT_PERMISSION_MODE = "workspace";
98
99
  /** Every key `createSession` takes — what an extension's options are checked against. */
99
100
  const CREATE_SESSION_OPTION_KEYS = new Set([
100
- "workDir", "ownerKey", "title", "config", "from",
101
+ "workDir", "ownerKey", "title", "config", "from", "inherit",
101
102
  ]);
103
+ /** What an extension's `createSession({ from, inherit })` can copy from its source. */
104
+ const INHERITABLE_SETTINGS = new Set(["model"]);
102
105
  function definedOnly(value) {
103
106
  return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined));
104
107
  }
@@ -1186,10 +1189,12 @@ export class Harness {
1186
1189
  : new HarnessExtensionManager({
1187
1190
  directory: options.extensionDir,
1188
1191
  dataDir: this.dataRoot ?? join(options.extensionDir, ".data"),
1192
+ settings: options.extensionSettings ?? createFileSettingsStore(join(options.extensionDir, ".settings")),
1189
1193
  bridge: {
1190
1194
  services: this.services,
1191
1195
  resumeSession: (id, extensionId) => this.resumeFrom(id, undefined, { kind: "extension", extensionId }),
1192
1196
  createSession: (sessionOptions, extensionId) => this.createFromExtension(sessionOptions, extensionId),
1197
+ readSessionMessages: (id, pageOptions) => this.readSessionMessages(id, pageOptions),
1193
1198
  sessions: () => [...this.activeSessions.values()],
1194
1199
  withBarrier: (extensionIds, timeoutMs, fn) => this.withBarrier(extensionIds, timeoutMs, fn),
1195
1200
  warn: (message) => console.warn(`[extensions] ${message}`),
@@ -1288,6 +1293,7 @@ export class Harness {
1288
1293
  return this.createFromExtension(sessionOptions, definition.id);
1289
1294
  },
1290
1295
  resumeSession: (id) => this.resumeFrom(id, undefined, { kind: "extension", extensionId: definition.id }),
1296
+ readSessionMessages: (id, pageOptions) => this.readSessionMessages(id, pageOptions),
1291
1297
  service: (name) => this.services.handle(name),
1292
1298
  warn: (message) => console.warn(`[extensions] ${message}`),
1293
1299
  });
@@ -1574,10 +1580,37 @@ export class Harness {
1574
1580
  if (unknown.length > 0) {
1575
1581
  throw new HarnessConfigError(`createSession from an extension got unknown option${unknown.length > 1 ? "s" : ""} ${unknown.map((key) => `"${key}"`).join(", ")}; per-extension parameters go in config.extensions`);
1576
1582
  }
1577
- const { from, ...rest } = (sessionOptions ?? {});
1583
+ const { from, inherit, ...rest } = (sessionOptions ?? {});
1578
1584
  if (from !== undefined && typeof from !== "string")
1579
1585
  throw new HarnessConfigError("createSession from an extension: `from` is the id of the session it is created on behalf of");
1580
- return this.createFrom({ ...rest, origin: { kind: "extension", extensionId } }, from === undefined ? undefined : await this.sourceOf(from));
1586
+ if (inherit !== undefined) {
1587
+ if (!Array.isArray(inherit) || inherit.some((key) => !INHERITABLE_SETTINGS.has(key))) {
1588
+ throw new HarnessConfigError(`createSession from an extension: \`inherit\` lists settings to copy from \`from\` — any of ${[...INHERITABLE_SETTINGS].map((key) => `"${key}"`).join(", ")}`);
1589
+ }
1590
+ if (from === undefined)
1591
+ throw new HarnessConfigError("createSession from an extension: `inherit` copies from the `from` session, so it needs one");
1592
+ }
1593
+ const source = from === undefined ? undefined : await this.sourceOf(from);
1594
+ let options = rest;
1595
+ if (from !== undefined && inherit?.includes("model") === true && rest.config?.model === undefined) {
1596
+ const model = await this.currentModelOf(from);
1597
+ if (model !== undefined)
1598
+ options = { ...rest, config: { ...(rest.config ?? {}), model } };
1599
+ }
1600
+ return this.createFrom({ ...options, origin: { kind: "extension", extensionId } }, source);
1601
+ }
1602
+ /** The model a session runs on right now, by name: the loaded instance's setting (a runtime
1603
+ * `setModel` included), or — when it is not loaded here — the one stored with it. `undefined`
1604
+ * when it runs on the harness default or on a model given as a live object. */
1605
+ async currentModelOf(sessionId) {
1606
+ const live = this.activeSessions.get(sessionId);
1607
+ if (live !== undefined) {
1608
+ const setting = live.modelSetting;
1609
+ return typeof setting === "string" ? setting : undefined;
1610
+ }
1611
+ const handle = await (await this.repository()).open(sessionId);
1612
+ const stored = await handle?.store.getState(SESSION_MODEL_KEY);
1613
+ return typeof stored === "string" ? stored : undefined;
1581
1614
  }
1582
1615
  /** The session an extension is acting from, as the source of what it creates: the loaded
1583
1616
  * instance, or — when it is not loaded here — what is stored with it. */
@@ -1606,6 +1639,31 @@ export class Harness {
1606
1639
  async inspectSession(id, options) {
1607
1640
  return (await this.repository()).open(id, options);
1608
1641
  }
1642
+ /**
1643
+ * One page of a session's transcript, newest page first — open or closed alike, and NEVER by
1644
+ * opening it: a closed session stays closed (no Session built, no capabilities, no recovery).
1645
+ * What happened, including messages a compaction has since summarized away; see
1646
+ * `readTranscriptPage` for exactly what is read. Page backwards with the returned `next`.
1647
+ *
1648
+ * An open session is read through its own store after its pending appends have landed, so a
1649
+ * message appended a moment ago is already on the page. Only durable records are read: text
1650
+ * still streaming is not on any page — follow it live with `observeProjection`.
1651
+ * Soft-deleted and unknown ids throw `SessionRepositoryNotFoundError`.
1652
+ */
1653
+ async readSessionMessages(id, options) {
1654
+ const live = this.getSession(id);
1655
+ const liveStore = live?.core.store;
1656
+ if (live !== undefined && liveStore !== undefined) {
1657
+ await live.core.flushEvents();
1658
+ return readTranscriptPage(liveStore, options);
1659
+ }
1660
+ const handle = await this.inspectSession(id);
1661
+ if (handle === undefined)
1662
+ throw new SessionRepositoryNotFoundError(id);
1663
+ // Not closed on purpose: a read holds nothing, and some repositories hand back the very
1664
+ // store a later open will use (the in-memory one) — closing it here would close that.
1665
+ return readTranscriptPage(handle.store, options);
1666
+ }
1609
1667
  /** Read one catalog row without the O(N) `listSessions()` scan. */
1610
1668
  async getSessionSummary(id) {
1611
1669
  return (await this.repository()).get(id);
@@ -2105,7 +2163,10 @@ export class Harness {
2105
2163
  const sessionTools = bindings.tools ?? (effective.tools !== undefined ? this.toolsNamed(id, effective.tools) : undefined);
2106
2164
  // Store the setup: only what was explicitly configured (never the harness defaults, so a
2107
2165
  // change to those still reaches this session), and never what the host resolves each time.
2108
- const storedConfig = withoutModel(fromResolver ? inherited : explicit);
2166
+ // A host's config replaces what is stored for THIS open only: a reopen keeps the setup it
2167
+ // had, and a create keeps what it was asked for — so what the caller set (an extension's
2168
+ // parameters for the session it spawns, say) survives a host that resolves every open.
2169
+ const storedConfig = withoutModel(fromResolver ? (operation === "create" ? asked : inherited) : explicit);
2109
2170
  const requires = SESSION_BINDING_KEYS.filter((key) => bindings[key] !== undefined || (operation !== "create" && stored.requires.includes(key)));
2110
2171
  const changed = JSON.stringify({ config: storedConfig, requires }) !== JSON.stringify({ config: stored.config, requires: stored.requires });
2111
2172
  if (changed) {
@@ -2387,7 +2448,7 @@ function isServiceDescriptor(value) {
2387
2448
  /** Every key `createHarness` takes. */
2388
2449
  const HARNESS_OPTION_KEYS = new Set([
2389
2450
  "config", "registry", "resolveSession", "context", "autoApprover", "modelManager", "infrastructure",
2390
- "sessionCapabilities", "telemetry", "services", "extensions", "extensionDir", "extensionDataDir", "extraSubagentProfiles",
2451
+ "sessionCapabilities", "telemetry", "services", "extensions", "extensionDir", "extensionDataDir", "extensionSettings", "extraSubagentProfiles",
2391
2452
  ]);
2392
2453
  /** Where each option `createHarness` used to take flat lives now. */
2393
2454
  const MOVED_HARNESS_OPTIONS = {
package/dist/index.d.ts CHANGED
@@ -17,10 +17,13 @@ export type { LocalDeploymentOptions } from "./local.ts";
17
17
  export { Agent, defineAgent, defineTool, handoff } from "operon-agents-core";
18
18
  export type { AgentConfig, AgentGuardrails, AgentRunContext, FunctionToolDef, HandoffOptions, OutputSchema, ResolveToolDef, Tool, ToolPlan, ToolResolveContext, ToolResult, ToolReturn, ToolRunContext, } from "operon-agents-core";
19
19
  export type { AgentEvent, AgentInput, ApprovalRequest, ApprovalResponse, CompletedRunResult, FailedRunResult, GuardrailBlockedRunResult, InterruptAnswer, InterruptedRunResult, PendingRunInterrupt, QuestionRequest, QuestionResult, RunError, RunResult, RunStatus, SteerReceipt, StoppedRunResult, } from "operon-agents-core";
20
+ export type { TranscriptCompactionItem, TranscriptItem, TranscriptMessageItem, TranscriptPage, TranscriptPageOptions, } from "operon-agents-core";
20
21
  export { GuardrailTripwireError, SessionBusyError, SessionClosedError, SessionInterruptedError, SessionNotInterruptedError, SessionRepositoryConflictError, SessionRepositoryNotFoundError, SteerTargetNotFoundError, } from "operon-agents-core";
21
22
  export type { AutoApprover, PermissionMode, PermissionRule } from "operon-agents-core";
22
23
  export { createModelManager, defineModel, LocalEnvironment } from "operon-agents-core";
23
24
  export type { ChatModel, Environment, EnvironmentFactory, ImageContent, Message, ModelManager, ModelSettings, TextContent, ThinkingLevel, Usage, } from "operon-agents-core";
24
25
  export type { ExtensionActions, ExtensionAPI, ExtensionDefinition } from "./extensions/index.ts";
26
+ export { ExtensionSettingsInvalidError } from "./extensions/index.ts";
27
+ export type { ExtensionSettingsStore, ExtensionSettingsView, ExtensionStatus, SettingsFormat, SettingsIssue, SettingsSchema } from "./extensions/index.ts";
25
28
  export type { McpServerConfig } from "operon-agents-core";
26
29
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -19,4 +19,6 @@ export { Agent, defineAgent, defineTool, handoff } from "operon-agents-core";
19
19
  export { GuardrailTripwireError, SessionBusyError, SessionClosedError, SessionInterruptedError, SessionNotInterruptedError, SessionRepositoryConflictError, SessionRepositoryNotFoundError, SteerTargetNotFoundError, } from "operon-agents-core";
20
20
  // Models, messages and where tools run.
21
21
  export { createModelManager, defineModel, LocalEnvironment } from "operon-agents-core";
22
+ // Extension settings — what a host renders and edits through `harness.extensions`.
23
+ export { ExtensionSettingsInvalidError } from "./extensions/index.js";
22
24
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "operon-agents",
3
- "version": "0.1.0-alpha.31",
3
+ "version": "0.1.0-alpha.32",
4
4
  "description": "Public SDK entrypoint for building agents.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -74,7 +74,7 @@
74
74
  "zod": "^4.4.3",
75
75
  "smol-toml": "^1.6.1",
76
76
  "@earendil-works/pi-ai": "1.5.0",
77
- "operon-agents-core": "0.1.0-alpha.31"
77
+ "operon-agents-core": "0.1.0-alpha.32"
78
78
  },
79
79
  "devDependencies": {
80
80
  "@earendil-works/pi-ai": "1.5.0"