operon-agents 0.1.0-alpha.3 → 0.1.0-alpha.4
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/app-server/main.js +2 -2
- package/dist/cron/extension.js +2 -2
- package/dist/extensions/index.d.ts +3 -3
- package/dist/extensions/index.js +13 -5
- package/dist/extensions/loader.d.ts +2 -2
- package/dist/extensions/loader.js +4 -4
- package/dist/extensions/manager.d.ts +37 -13
- package/dist/extensions/manager.js +93 -45
- package/dist/extensions/runtime.d.ts +11 -10
- package/dist/extensions/runtime.js +42 -36
- package/dist/extensions/services.d.ts +66 -59
- package/dist/extensions/services.js +106 -233
- package/dist/extensions/types.d.ts +74 -29
- package/dist/harness.d.ts +173 -74
- package/dist/harness.js +414 -90
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/local.d.ts +7 -3
- package/dist/local.js +64 -27
- package/dist/tokens.d.ts +6 -0
- package/dist/tokens.js +7 -0
- package/package.json +2 -2
package/dist/app-server/main.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* redirect console.log/info/debug to stderr so a stray log can never corrupt the
|
|
10
10
|
* protocol stream.
|
|
11
11
|
*/
|
|
12
|
-
import { defineModel, DiskSessionRepository } from "operon-agents-core";
|
|
12
|
+
import { defineModel, DiskSessionRepository, T } from "operon-agents-core";
|
|
13
13
|
import { createHarness } from "../harness.js";
|
|
14
14
|
import { nodeStreamTransport } from "./codec.js";
|
|
15
15
|
import { AppServer } from "./server.js";
|
|
@@ -63,7 +63,7 @@ async function main() {
|
|
|
63
63
|
const harness = createHarness({
|
|
64
64
|
model: args.model ?? "anthropic/claude-opus-4-8",
|
|
65
65
|
resolveModel,
|
|
66
|
-
...(args.home !== undefined ? {
|
|
66
|
+
...(args.home !== undefined ? { harness: (scope) => scope.register(T.SessionRepository, new DiskSessionRepository(args.home)) } : {}),
|
|
67
67
|
...(args.workdir !== undefined ? { workDir: args.workdir } : {}),
|
|
68
68
|
permission: { mode: args.permission ?? "yolo" },
|
|
69
69
|
});
|
package/dist/cron/extension.js
CHANGED
|
@@ -29,8 +29,8 @@ export function normalizeCronTaskInput(manager, input) {
|
|
|
29
29
|
export function cronExtension(options = {}) {
|
|
30
30
|
return {
|
|
31
31
|
id: "cron",
|
|
32
|
-
async
|
|
33
|
-
// One manager per session:
|
|
32
|
+
async session(api) {
|
|
33
|
+
// One manager per session: session() runs per session open, so scheduling state is
|
|
34
34
|
// session-scoped exactly as the capability's openSession was.
|
|
35
35
|
const manager = new CronManager(options);
|
|
36
36
|
manager.attach({
|
|
@@ -3,11 +3,11 @@ import type { ExtensionDefinition, ExtensionHost } from "./types.ts";
|
|
|
3
3
|
export { ExtensionRuntime } from "./runtime.ts";
|
|
4
4
|
export { ExtensionLoader, createExtensionLoader } from "./loader.ts";
|
|
5
5
|
export { ServiceRegistry, ServiceUnavailableError, deadServiceHandle } from "./services.ts";
|
|
6
|
-
export { HarnessExtensionManager, stageDefinition } from "./manager.ts";
|
|
6
|
+
export { HarnessExtensionManager, stageDefinition, assertOneSharedHalf } from "./manager.ts";
|
|
7
7
|
export type { ExtensionHostBridge, StagingOptions, HeldSession, StagedDefinition } from "./manager.ts";
|
|
8
|
-
export type { ServiceOptions, ServiceUnavailableReason } from "./services.ts";
|
|
8
|
+
export type { ServiceOptions, ServiceTier, ServiceUnavailableReason } from "./services.ts";
|
|
9
9
|
export type { ExtensionManifest, ExtensionFileState, ExtensionFileStatus, ExtensionAttachTarget } from "./loader.ts";
|
|
10
|
-
export type { ExtensionActions, ExtensionAPI, ExtensionCommand, ExtensionCommandResult, ExtensionRecordEntry, ExtensionSteerOptions, ExtensionDefinition, ExtensionHostContext, ExtensionEventContext, ExtensionEventMap, ExtensionEventName, ExtensionHandler, ExtensionHost, ExtensionModelRequestEvent, ExtensionModelRequestResult, ExtensionModelResponseEvent, ExtensionResultMap, ExtensionRunSettledEvent, ExtensionRunSettledResult, ExtensionRunStartEvent, ExtensionRunStartResult, ExtensionSessionContext,
|
|
10
|
+
export type { ExtensionActions, ExtensionAPI, ExtensionCommand, ExtensionCommandResult, ExtensionRecordEntry, ExtensionSteerOptions, ExtensionDefinition, ExtensionHostContext, ExtensionWorkspaceContext, 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, ModelProvider, SessionEndReason, SessionStartReason, } from "./types.ts";
|
|
11
11
|
/**
|
|
12
12
|
* Build one isolated, session-scoped runtime for programmatic extensions.
|
|
13
13
|
*
|
package/dist/extensions/index.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { ExtensionRuntime } from "./runtime.js";
|
|
2
|
+
import { HT } from "../tokens.js";
|
|
2
3
|
export { ExtensionRuntime } from "./runtime.js";
|
|
3
4
|
export { ExtensionLoader, createExtensionLoader } from "./loader.js";
|
|
4
5
|
export { ServiceRegistry, ServiceUnavailableError, deadServiceHandle } from "./services.js";
|
|
5
|
-
export { HarnessExtensionManager, stageDefinition } from "./manager.js";
|
|
6
|
+
export { HarnessExtensionManager, stageDefinition, assertOneSharedHalf } from "./manager.js";
|
|
6
7
|
/**
|
|
7
8
|
* Build one isolated, session-scoped runtime for programmatic extensions.
|
|
8
9
|
*
|
|
@@ -15,11 +16,20 @@ export function extensionsCapability(definitions, options = {}) {
|
|
|
15
16
|
const runtime = new ExtensionRuntime(definitions, options.host, options.params);
|
|
16
17
|
return {
|
|
17
18
|
name: "extensions",
|
|
18
|
-
|
|
19
|
+
provides: [
|
|
20
|
+
{
|
|
21
|
+
token: HT.Extensions,
|
|
22
|
+
create: async (ctx) => {
|
|
23
|
+
await runtime.open(ctx);
|
|
24
|
+
return runtime;
|
|
25
|
+
},
|
|
26
|
+
dispose: () => runtime.close(),
|
|
27
|
+
},
|
|
28
|
+
],
|
|
19
29
|
toolProviders: [{ id: "extensions", listTools: () => runtime.listTools() }],
|
|
20
30
|
toolFilters: [runtime.filterTools],
|
|
21
31
|
gates: { compaction: runtime.compactionGate },
|
|
22
|
-
// Stable array reference — `
|
|
32
|
+
// Stable array reference — `session` (the provision's `harness`) fills it before any run assembles.
|
|
23
33
|
injectors: runtime.listInjectors(),
|
|
24
34
|
hooks: {
|
|
25
35
|
beforeRun: async (ctx) => runtime.beforeRun(ctx, ctx.input),
|
|
@@ -32,8 +42,6 @@ export function extensionsCapability(definitions, options = {}) {
|
|
|
32
42
|
authorizeToolExecution: async (ctx) => runtime.authorizeToolCall(ctx, ctx.toolCall.name, ctx.toolCall.id, ctx.tool, ctx.args, ctx.plan),
|
|
33
43
|
finalizeToolResult: async (ctx) => runtime.afterToolResult(ctx, ctx.toolCall.name, ctx.toolCall.id, ctx.tool, ctx.args, ctx.result),
|
|
34
44
|
},
|
|
35
|
-
openSession: (ctx) => runtime.open(ctx),
|
|
36
|
-
closeSession: () => runtime.close(),
|
|
37
45
|
start: (ctx) => runtime.attachRun(ctx),
|
|
38
46
|
stop: () => runtime.detachRun(),
|
|
39
47
|
};
|
|
@@ -30,7 +30,7 @@ export declare function compareVersions(a: string, b: string): number;
|
|
|
30
30
|
/**
|
|
31
31
|
* What must be known about an extension BEFORE its code is imported — find it (`id`), import
|
|
32
32
|
* it (`entry`, gated by `engine`), show it (`version`, `name`, `description`) — and nothing more. What the
|
|
33
|
-
* extension provides (`
|
|
33
|
+
* extension provides (`harness`) and consumes (`uses`) lives on the definition it exports, the
|
|
34
34
|
* same on both channels. Only `id` is validated; unknown fields pass through untouched, so a
|
|
35
35
|
* manifest written for a newer loader still loads here.
|
|
36
36
|
*/
|
|
@@ -102,7 +102,7 @@ export declare class ExtensionLoader {
|
|
|
102
102
|
}>;
|
|
103
103
|
/** The definitions currently held. On the standalone-loader path these reach sessions through
|
|
104
104
|
* `attachExtension`/`reloadInto`; a harness built with `extensionDir` registers them itself
|
|
105
|
-
* (`harness.extensions.load`), which is the path that also runs `
|
|
105
|
+
* (`harness.extensions.load`), which is the path that also runs `harness` halves. */
|
|
106
106
|
definitions(): ExtensionDefinition[];
|
|
107
107
|
/** Convenience: reload a extension and swap it on a live session (detach old, attach new). */
|
|
108
108
|
reloadInto(session: ExtensionAttachTarget, id: string): Promise<ExtensionDefinition>;
|
|
@@ -155,7 +155,7 @@ export class ExtensionLoader {
|
|
|
155
155
|
}
|
|
156
156
|
/** The definitions currently held. On the standalone-loader path these reach sessions through
|
|
157
157
|
* `attachExtension`/`reloadInto`; a harness built with `extensionDir` registers them itself
|
|
158
|
-
* (`harness.extensions.load`), which is the path that also runs `
|
|
158
|
+
* (`harness.extensions.load`), which is the path that also runs `harness` halves. */
|
|
159
159
|
definitions() {
|
|
160
160
|
return [...this.loaded.values()];
|
|
161
161
|
}
|
|
@@ -241,11 +241,11 @@ export class ExtensionLoader {
|
|
|
241
241
|
}
|
|
242
242
|
let candidate = moduleExports.default;
|
|
243
243
|
// A factory default export lets a extension construct per-load state; called with nothing —
|
|
244
|
-
// everything a extension may use arrives later through its
|
|
244
|
+
// everything a extension may use arrives later through its session(ctx).
|
|
245
245
|
if (typeof candidate === "function")
|
|
246
246
|
candidate = candidate();
|
|
247
247
|
if (!isExtensionDefinition(candidate)) {
|
|
248
|
-
throw new Error(`extension "${scanned.manifest.id}" default export is not an extension definition ({ id,
|
|
248
|
+
throw new Error(`extension "${scanned.manifest.id}" default export is not an extension definition ({ id, session })`);
|
|
249
249
|
}
|
|
250
250
|
if (candidate.id !== scanned.manifest.id) {
|
|
251
251
|
throw new Error(`extension id mismatch: manifest says "${scanned.manifest.id}", code says "${candidate.id}"`);
|
|
@@ -277,7 +277,7 @@ export function createExtensionLoader(options) {
|
|
|
277
277
|
function isExtensionDefinition(value) {
|
|
278
278
|
return (typeof value === "object" && value !== null
|
|
279
279
|
&& typeof value.id === "string"
|
|
280
|
-
&& typeof value.
|
|
280
|
+
&& typeof value.session === "function");
|
|
281
281
|
}
|
|
282
282
|
function messageOf(error) {
|
|
283
283
|
return error instanceof Error ? error.message : String(error);
|
|
@@ -12,9 +12,27 @@ export interface HeldSession {
|
|
|
12
12
|
readonly uses: readonly string[];
|
|
13
13
|
}[];
|
|
14
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* The workspace-tier side of the bridge: `workspace` halves live in the harness's workspace
|
|
17
|
+
* scopes, which only the harness can reach. `staged` maps workspace key → the instance the new
|
|
18
|
+
* half produced for it (staging runs BEFORE the barrier, so a throwing half changes nothing).
|
|
19
|
+
*/
|
|
20
|
+
export interface WorkspaceHalfBridge {
|
|
21
|
+
/** Run `definition.workspace` against every live workspace; nothing is registered. */
|
|
22
|
+
stage(definition: ExtensionDefinition): Promise<Map<string, unknown>>;
|
|
23
|
+
/** In the quiet moment: give every live workspace its staged instance (replace where one is
|
|
24
|
+
* registered, register where none is; compose fresh for a workspace born after staging).
|
|
25
|
+
* `staged === undefined` ⇒ the definition has no `workspace` half: unregister everywhere. */
|
|
26
|
+
swap(definition: ExtensionDefinition, staged: Map<string, unknown> | undefined): Promise<void>;
|
|
27
|
+
/** Unregister `id` from every live workspace (unload). */
|
|
28
|
+
unregister(id: string): Promise<void>;
|
|
29
|
+
/** Dispose staged instances that were never published (a failed act). */
|
|
30
|
+
discard(id: string, staged: Map<string, unknown>): Promise<void>;
|
|
31
|
+
}
|
|
15
32
|
/** The narrow harness face the manager operates through (no import cycle with harness.ts). */
|
|
16
33
|
export interface ExtensionHostBridge {
|
|
17
34
|
readonly services: ServiceRegistry;
|
|
35
|
+
readonly workspaces: WorkspaceHalfBridge;
|
|
18
36
|
createSession(options?: Record<string, unknown>): Promise<{
|
|
19
37
|
readonly id: string;
|
|
20
38
|
}>;
|
|
@@ -24,16 +42,22 @@ export interface ExtensionHostBridge {
|
|
|
24
42
|
withBarrier(extensionIds: readonly string[], timeoutMs: number, fn: (held: () => readonly HeldSession[]) => Promise<void>): Promise<void>;
|
|
25
43
|
warn(message: string): void;
|
|
26
44
|
}
|
|
27
|
-
/**
|
|
45
|
+
/**
|
|
46
|
+
* A definition carries at most ONE shared half. Its service (and `ctx.shared`) lives at exactly
|
|
47
|
+
* one tier — the tier of the half that produced it — so consumers and the session half never
|
|
48
|
+
* have to ask which. Checked wherever a definition is registered (by value or from a file).
|
|
49
|
+
*/
|
|
50
|
+
export declare function assertOneSharedHalf(definition: ExtensionDefinition): void;
|
|
51
|
+
/** What staging one `harness`-bearing definition produced, before anything is published. */
|
|
28
52
|
export interface StagedDefinition {
|
|
29
|
-
/** The one service `
|
|
30
|
-
* definition has no `
|
|
53
|
+
/** The one service `harness` returned, to register under the extension's id; absent when the
|
|
54
|
+
* definition has no `harness`. */
|
|
31
55
|
readonly service?: {
|
|
32
56
|
readonly name: string;
|
|
33
57
|
readonly instance: unknown;
|
|
34
58
|
readonly options: ServiceOptions;
|
|
35
59
|
};
|
|
36
|
-
/** The definition itself — its `
|
|
60
|
+
/** The definition itself — its `session` is the per-session half, mounted into every session. */
|
|
37
61
|
readonly sessionDef: ExtensionDefinition;
|
|
38
62
|
}
|
|
39
63
|
export interface StagingOptions {
|
|
@@ -49,9 +73,9 @@ export interface StagingOptions {
|
|
|
49
73
|
warn(message: string): void;
|
|
50
74
|
}
|
|
51
75
|
/**
|
|
52
|
-
* Run a `
|
|
53
|
-
* service it returns — nothing is registered here, so a throwing `
|
|
54
|
-
* Shared by the file manager and the harness's by-value channel. Stays synchronous when `
|
|
76
|
+
* Run a `harness`-bearing definition's `harness` half against a staging host and collect the one
|
|
77
|
+
* service it returns — nothing is registered here, so a throwing `harness` publishes nothing.
|
|
78
|
+
* Shared by the file manager and the harness's by-value channel. Stays synchronous when `harness`
|
|
55
79
|
* is, which the by-value channel relies on to have a synchronous extension's service visible the
|
|
56
80
|
* moment `createHarness` returns.
|
|
57
81
|
*/
|
|
@@ -61,7 +85,7 @@ export declare class HarnessExtensionManager {
|
|
|
61
85
|
private readonly bridge;
|
|
62
86
|
/** Root of the extensions' data folders (`<root>/<id>`) — outside every code folder. */
|
|
63
87
|
private readonly dataRoot;
|
|
64
|
-
/** Every loaded extension by id, and whether its `
|
|
88
|
+
/** Every loaded extension by id, and whether its `harness` published a service. */
|
|
65
89
|
private readonly loaded;
|
|
66
90
|
/** Definitions newborn sessions receive: every loaded extension's per-session half. */
|
|
67
91
|
private readonly sessionDefs;
|
|
@@ -75,10 +99,10 @@ export declare class HarnessExtensionManager {
|
|
|
75
99
|
sessionDefinitions(): ExtensionDefinition[];
|
|
76
100
|
/**
|
|
77
101
|
* Load (and approve) one extension — a USER action, like `ExtensionLoader.load`. Runs the
|
|
78
|
-
* definition's `
|
|
102
|
+
* definition's `harness` (if any) and publishes the result as its service, then hands the
|
|
79
103
|
* definition to future sessions. Loading an already-loaded extension reloads it: every
|
|
80
104
|
* session holding it meets at its run boundary, the per-session half is swapped (and the
|
|
81
|
-
* service replaced), and everyone resumes — the same act whether or not there is a `
|
|
105
|
+
* service replaced), and everyone resumes — the same act whether or not there is a `harness`.
|
|
82
106
|
*/
|
|
83
107
|
load(id: string, options?: {
|
|
84
108
|
readonly timeoutMs?: number;
|
|
@@ -88,14 +112,14 @@ export declare class HarnessExtensionManager {
|
|
|
88
112
|
readonly timeoutMs?: number;
|
|
89
113
|
}): Promise<void>;
|
|
90
114
|
/**
|
|
91
|
-
* Unload an extension. A `
|
|
115
|
+
* Unload an extension. A `harness`-bearing one is refused while any session still runs an
|
|
92
116
|
* extension that `uses` its service — named, so the caller knows what to detach. Its own
|
|
93
117
|
* per-session half does not block (it is removed as part of the unload).
|
|
94
118
|
*/
|
|
95
119
|
unload(id: string): Promise<void>;
|
|
96
120
|
private register;
|
|
97
|
-
/** Run `
|
|
98
|
-
* `
|
|
121
|
+
/** Run `harness` with a staging host: the service is COLLECTED, not registered, so a throwing
|
|
122
|
+
* `harness` publishes nothing. Services default to replaceable — reload is their point. */
|
|
99
123
|
private stage;
|
|
100
124
|
/** Sessions still running an extension that `uses` `id`'s service — read off the attached
|
|
101
125
|
* definitions themselves, so by-value and file consumers count alike. No graph walk: the
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The file-extension manager (design: docs/architecture.md §5.4-5.5). An extension
|
|
3
|
-
* is a FILE whose default export is an `ExtensionDefinition`; the manager runs its `
|
|
3
|
+
* is a FILE whose default export is an `ExtensionDefinition`; the manager runs its `harness` half
|
|
4
4
|
* once if it has one, registers the result as a service under the extension's `id`, and hands
|
|
5
|
-
* the SAME definition to every session (its `
|
|
5
|
+
* the SAME definition to every session (its `session` is the per-session half). One extension,
|
|
6
6
|
* one approval, one reload — the manager turns a reload into the coordinated act: barrier →
|
|
7
7
|
* swap the session half → replace the service → release.
|
|
8
8
|
*
|
|
@@ -10,26 +10,36 @@
|
|
|
10
10
|
* single one as for ordinary extensions — manual load/reload IS the approval (mtime-scoped).
|
|
11
11
|
* Nothing here auto-loads or watches.
|
|
12
12
|
*
|
|
13
|
-
* `stageDefinition` — running `
|
|
14
|
-
* throwing `
|
|
15
|
-
* (`createHarness({ extensions })` runs a `
|
|
13
|
+
* `stageDefinition` — running `harness` against a staging host and collecting the one service, so a
|
|
14
|
+
* throwing `harness` publishes nothing — is shared with the harness's by-value channel
|
|
15
|
+
* (`createHarness({ extensions })` runs a `harness`-bearing definition through it with no loader).
|
|
16
16
|
*/
|
|
17
17
|
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
|
+
/**
|
|
22
|
+
* A definition carries at most ONE shared half. Its service (and `ctx.shared`) lives at exactly
|
|
23
|
+
* one tier — the tier of the half that produced it — so consumers and the session half never
|
|
24
|
+
* have to ask which. Checked wherever a definition is registered (by value or from a file).
|
|
25
|
+
*/
|
|
26
|
+
export function assertOneSharedHalf(definition) {
|
|
27
|
+
if (definition.harness !== undefined && definition.workspace !== undefined) {
|
|
28
|
+
throw new Error(`extension "${definition.id}" declares both a harness and a workspace half — a definition carries ONE shared half, so its service lives at exactly one tier`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
21
31
|
function isPromiseLike(value) {
|
|
22
32
|
return typeof value === "object" && value !== null && typeof value.then === "function";
|
|
23
33
|
}
|
|
24
34
|
/**
|
|
25
|
-
* Run a `
|
|
26
|
-
* service it returns — nothing is registered here, so a throwing `
|
|
27
|
-
* Shared by the file manager and the harness's by-value channel. Stays synchronous when `
|
|
35
|
+
* Run a `harness`-bearing definition's `harness` half against a staging host and collect the one
|
|
36
|
+
* service it returns — nothing is registered here, so a throwing `harness` publishes nothing.
|
|
37
|
+
* Shared by the file manager and the harness's by-value channel. Stays synchronous when `harness`
|
|
28
38
|
* is, which the by-value channel relies on to have a synchronous extension's service visible the
|
|
29
39
|
* moment `createHarness` returns.
|
|
30
40
|
*/
|
|
31
41
|
export function stageDefinition(definition, options) {
|
|
32
|
-
if (definition.
|
|
42
|
+
if (definition.harness === undefined)
|
|
33
43
|
return { sessionDef: definition };
|
|
34
44
|
const host = {
|
|
35
45
|
createSession: (sessionOptions) => options.createSession(sessionOptions),
|
|
@@ -42,7 +52,7 @@ export function stageDefinition(definition, options) {
|
|
|
42
52
|
service: { name: definition.id, instance, options: { replaceable: options.defaultReplaceable } },
|
|
43
53
|
sessionDef: definition,
|
|
44
54
|
});
|
|
45
|
-
const result = definition.
|
|
55
|
+
const result = definition.harness(host);
|
|
46
56
|
return isPromiseLike(result) ? Promise.resolve(result).then(finish) : finish(result);
|
|
47
57
|
}
|
|
48
58
|
const DEFAULT_BARRIER_TIMEOUT_MS = 30_000;
|
|
@@ -51,7 +61,7 @@ export class HarnessExtensionManager {
|
|
|
51
61
|
bridge;
|
|
52
62
|
/** Root of the extensions' data folders (`<root>/<id>`) — outside every code folder. */
|
|
53
63
|
dataRoot;
|
|
54
|
-
/** Every loaded extension by id, and whether its `
|
|
64
|
+
/** Every loaded extension by id, and whether its `harness` published a service. */
|
|
55
65
|
loaded = new Map();
|
|
56
66
|
/** Definitions newborn sessions receive: every loaded extension's per-session half. */
|
|
57
67
|
sessionDefs = new Map();
|
|
@@ -69,16 +79,23 @@ export class HarnessExtensionManager {
|
|
|
69
79
|
}
|
|
70
80
|
/**
|
|
71
81
|
* Load (and approve) one extension — a USER action, like `ExtensionLoader.load`. Runs the
|
|
72
|
-
* definition's `
|
|
82
|
+
* definition's `harness` (if any) and publishes the result as its service, then hands the
|
|
73
83
|
* definition to future sessions. Loading an already-loaded extension reloads it: every
|
|
74
84
|
* session holding it meets at its run boundary, the per-session half is swapped (and the
|
|
75
|
-
* service replaced), and everyone resumes — the same act whether or not there is a `
|
|
85
|
+
* service replaced), and everyone resumes — the same act whether or not there is a `harness`.
|
|
76
86
|
*/
|
|
77
87
|
async load(id, options = {}) {
|
|
78
88
|
const definition = await this.loader.load(id);
|
|
79
|
-
// `uses`
|
|
80
|
-
// provider revokes the approval the import just recorded, so a
|
|
81
|
-
// behind. Same rule for both kinds: load providers first.
|
|
89
|
+
// Shape and `uses` live on the definition, so they are readable only after the import; a
|
|
90
|
+
// bad definition or a missing provider revokes the approval the import just recorded, so a
|
|
91
|
+
// failed load leaves nothing behind. Same rule for both kinds: load providers first.
|
|
92
|
+
try {
|
|
93
|
+
assertOneSharedHalf(definition);
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
await this.loader.unload(id);
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
82
99
|
for (const name of definition.uses ?? []) {
|
|
83
100
|
if (!this.bridge.services.has(name)) {
|
|
84
101
|
await this.loader.unload(id);
|
|
@@ -92,7 +109,7 @@ export class HarnessExtensionManager {
|
|
|
92
109
|
return this.load(id, options);
|
|
93
110
|
}
|
|
94
111
|
/**
|
|
95
|
-
* Unload an extension. A `
|
|
112
|
+
* Unload an extension. A `harness`-bearing one is refused while any session still runs an
|
|
96
113
|
* extension that `uses` its service — named, so the caller knows what to detach. Its own
|
|
97
114
|
* per-session half does not block (it is removed as part of the unload).
|
|
98
115
|
*/
|
|
@@ -116,6 +133,10 @@ export class HarnessExtensionManager {
|
|
|
116
133
|
this.sessionDefs.delete(id);
|
|
117
134
|
if (record.hasService)
|
|
118
135
|
await this.bridge.services.unregister(id);
|
|
136
|
+
if (record.hasWorkspace) {
|
|
137
|
+
await this.bridge.workspaces.unregister(id);
|
|
138
|
+
this.bridge.services.undeclareWorkspace(id);
|
|
139
|
+
}
|
|
119
140
|
this.loaded.delete(id);
|
|
120
141
|
await this.loader.unload(id);
|
|
121
142
|
}
|
|
@@ -124,47 +145,74 @@ export class HarnessExtensionManager {
|
|
|
124
145
|
// ==========================================================================
|
|
125
146
|
async register(id, definition, options) {
|
|
126
147
|
const staged = await this.stage(definition);
|
|
148
|
+
// The workspace half is staged the same way, once per live workspace: run BEFORE anything
|
|
149
|
+
// is published, so a throwing half leaves every workspace on what it had.
|
|
150
|
+
const hasWorkspace = definition.workspace !== undefined;
|
|
151
|
+
const stagedWorkspaces = hasWorkspace ? await this.bridge.workspaces.stage(definition) : undefined;
|
|
127
152
|
const previous = this.loaded.get(id);
|
|
153
|
+
const record = { hasService: staged.service !== undefined, hasWorkspace };
|
|
128
154
|
if (previous === undefined) {
|
|
129
155
|
// First load: register the service, then hand the session half to future sessions.
|
|
130
156
|
// Already-open sessions are not touched (a reload only re-attaches to sessions that hold
|
|
131
|
-
// the half) — attach it explicitly where an open session should get it.
|
|
157
|
+
// the half) — attach it explicitly where an open session should get it. Workspaces
|
|
158
|
+
// already open DO get their instance now: a workspace half is composed on first use, and
|
|
159
|
+
// for them first use is this load.
|
|
132
160
|
if (staged.service !== undefined) {
|
|
133
161
|
this.bridge.services.register(staged.service.name, staged.service.instance, staged.service.options);
|
|
134
162
|
}
|
|
163
|
+
if (hasWorkspace)
|
|
164
|
+
this.bridge.services.declareWorkspace(id);
|
|
135
165
|
this.sessionDefs.set(id, staged.sessionDef);
|
|
136
|
-
|
|
166
|
+
if (hasWorkspace)
|
|
167
|
+
await this.bridge.workspaces.swap(definition, stagedWorkspaces);
|
|
168
|
+
this.loaded.set(id, record);
|
|
137
169
|
return;
|
|
138
170
|
}
|
|
139
|
-
// Reload: one coordinated act — barrier on the session half, swap half + service in the
|
|
140
|
-
// quiet moment. Version skew between the
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
if (
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
171
|
+
// Reload: one coordinated act — barrier on the session half, swap half + service(s) in the
|
|
172
|
+
// quiet moment. Version skew between the halves is structurally impossible.
|
|
173
|
+
try {
|
|
174
|
+
await this.bridge.withBarrier([id], options.timeoutMs ?? DEFAULT_BARRIER_TIMEOUT_MS, async (held) => {
|
|
175
|
+
for (const session of held()) {
|
|
176
|
+
if (session.attachedExtensionIds().includes(id))
|
|
177
|
+
await session.detachExtension(id);
|
|
178
|
+
}
|
|
179
|
+
if (staged.service !== undefined) {
|
|
180
|
+
if (this.bridge.services.has(id))
|
|
181
|
+
await this.bridge.services.replace(id, staged.service.instance);
|
|
182
|
+
else
|
|
183
|
+
this.bridge.services.register(staged.service.name, staged.service.instance, staged.service.options);
|
|
184
|
+
}
|
|
185
|
+
else if (previous.hasService) {
|
|
186
|
+
await this.bridge.services.unregister(id);
|
|
187
|
+
}
|
|
188
|
+
this.sessionDefs.set(id, staged.sessionDef);
|
|
189
|
+
if (hasWorkspace) {
|
|
190
|
+
this.bridge.services.declareWorkspace(id);
|
|
191
|
+
await this.bridge.workspaces.swap(definition, stagedWorkspaces);
|
|
192
|
+
}
|
|
193
|
+
else if (previous.hasWorkspace) {
|
|
194
|
+
// The new version dropped its workspace half: every workspace's instance goes with it.
|
|
195
|
+
await this.bridge.workspaces.swap(definition, undefined);
|
|
196
|
+
this.bridge.services.undeclareWorkspace(id);
|
|
197
|
+
}
|
|
198
|
+
for (const session of held()) {
|
|
199
|
+
await session.attachExtension(staged.sessionDef);
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
if (stagedWorkspaces !== undefined)
|
|
205
|
+
await this.bridge.workspaces.discard(id, stagedWorkspaces);
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
this.loaded.set(id, record);
|
|
161
209
|
}
|
|
162
|
-
/** Run `
|
|
163
|
-
* `
|
|
210
|
+
/** Run `harness` with a staging host: the service is COLLECTED, not registered, so a throwing
|
|
211
|
+
* `harness` publishes nothing. Services default to replaceable — reload is their point. */
|
|
164
212
|
async stage(definition) {
|
|
165
|
-
// The data folder exists before `
|
|
213
|
+
// The data folder exists before `harness` runs, so the extension can use it right away.
|
|
166
214
|
const dataDir = join(this.dataRoot, definition.id);
|
|
167
|
-
if (definition.
|
|
215
|
+
if (definition.harness !== undefined)
|
|
168
216
|
await mkdir(dataDir, { recursive: true });
|
|
169
217
|
return stageDefinition(definition, {
|
|
170
218
|
defaultReplaceable: true,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { HeadlessCommand } from "operon-agents-core";
|
|
2
|
-
import type { AssistantMessage,
|
|
2
|
+
import type { AssistantMessage, RunContext, CompactionGate, ChatModel, ConversationContext, Injector, LlmRequest, Message, ProvisionContext, StepStopReason, TerminalStepStopReason, Tool, ToolPlan, ToolResult, Usage } from "operon-agents-core";
|
|
3
3
|
import type { ExtensionDefinition, ExtensionHost, ExtensionModelRequestResult, ExtensionStepEndResult, ExtensionStepStartResult, ExtensionToolAuthorizeResult, ExtensionToolCallResult, SessionEndReason, SessionStartReason } from "./types.ts";
|
|
4
4
|
/** Shared shape of the loop-hook contexts the dispatch methods are fed from. */
|
|
5
5
|
interface StepOrigin {
|
|
@@ -13,7 +13,7 @@ export declare class ExtensionRuntime {
|
|
|
13
13
|
private readonly definitions;
|
|
14
14
|
private readonly handlers;
|
|
15
15
|
private readonly tools;
|
|
16
|
-
/** Stable reference: `extensionsCapability` hands this array to the assembler, and `
|
|
16
|
+
/** Stable reference: `extensionsCapability` hands this array to the assembler, and `session`
|
|
17
17
|
* (which runs at openSession, before any run assembles) fills it in place. */
|
|
18
18
|
private readonly injectors;
|
|
19
19
|
/** Slash commands by normalized name — surfaced to the session's registry via the
|
|
@@ -25,10 +25,10 @@ export declare class ExtensionRuntime {
|
|
|
25
25
|
* memoized log read — zero extra traversal) + everything recorded this session. */
|
|
26
26
|
private recordSnapshot;
|
|
27
27
|
private readonly recordWrites;
|
|
28
|
-
/** Per-extension teardown:
|
|
28
|
+
/** Per-extension teardown: session()'s cleanup + every registration, undone in reverse. */
|
|
29
29
|
private readonly scopes;
|
|
30
30
|
/** Extensions whose contributions are currently honored. An id leaves on detach (and on
|
|
31
|
-
*
|
|
31
|
+
* session() failure), which turns every `actions`/`api` closure the extension still holds
|
|
32
32
|
* into a warn-and-noop — a detached extension must not keep steering the session. */
|
|
33
33
|
private readonly live;
|
|
34
34
|
/** Changes wait here until a quiet point: mid-run requests apply at the run's stop
|
|
@@ -37,6 +37,7 @@ export declare class ExtensionRuntime {
|
|
|
37
37
|
/** Serializes flushes so concurrent attach/detach apply strictly in submission order. */
|
|
38
38
|
private flushChain;
|
|
39
39
|
private readonly memoryState;
|
|
40
|
+
/** The session binding: its scope (machine, store, events, steer, log reader, controls) + id + signal. */
|
|
40
41
|
private session;
|
|
41
42
|
private run;
|
|
42
43
|
/** The conversation shard the in-flight decision point belongs to; backs `actions.record`. */
|
|
@@ -49,18 +50,18 @@ export declare class ExtensionRuntime {
|
|
|
49
50
|
/** Harness reach. Absent when the capability is built standalone (bare Runner / tests). */
|
|
50
51
|
private readonly host;
|
|
51
52
|
/** This session's per-session extension arguments, by extension id. A value of `false` means
|
|
52
|
-
* "skip this extension for this session"; any other value is passed to `
|
|
53
|
+
* "skip this extension for this session"; any other value is passed to `session` as `params`. */
|
|
53
54
|
private readonly params;
|
|
54
55
|
constructor(definitions: readonly ExtensionDefinition[], host?: ExtensionHost, params?: Readonly<Record<string, unknown>>);
|
|
55
|
-
open(ctx:
|
|
56
|
-
attachRun(ctx:
|
|
56
|
+
open(ctx: ProvisionContext, reason?: SessionStartReason): Promise<void>;
|
|
57
|
+
attachRun(ctx: RunContext): void;
|
|
57
58
|
detachRun(): Promise<void>;
|
|
58
59
|
close(reason?: SessionEndReason): Promise<void>;
|
|
59
60
|
/**
|
|
60
|
-
* Add an extension to the live session. Resolves once its `
|
|
61
|
+
* Add an extension to the live session. Resolves once its `session` has run and its
|
|
61
62
|
* contributions are in place — with no run in flight that is immediately; mid-run it is
|
|
62
63
|
* the current run's stop boundary, so an in-flight turn never sees its registry change.
|
|
63
|
-
* Rejects on duplicate/empty id or when `
|
|
64
|
+
* Rejects on duplicate/empty id or when `session` throws.
|
|
64
65
|
*/
|
|
65
66
|
attach(definition: ExtensionDefinition): Promise<void>;
|
|
66
67
|
/** Ids of currently attached extensions — the reshape coordinator's affected-set probe. */
|
|
@@ -83,7 +84,7 @@ export declare class ExtensionRuntime {
|
|
|
83
84
|
private drainPending;
|
|
84
85
|
private attachNow;
|
|
85
86
|
private detachNow;
|
|
86
|
-
/** Runs one extension's `
|
|
87
|
+
/** Runs one extension's `session` and files its scope. Failure ⇒ skipped, contributions undone. */
|
|
87
88
|
private setupExtension;
|
|
88
89
|
/**
|
|
89
90
|
* Durable audit trail: replay needs "tool X only exists after record N" to reconstruct the
|