operon-agents 0.1.0-alpha.15 → 0.1.0-alpha.17
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/protocol.d.ts +1 -1
- package/dist/app-server/protocol.js +2 -1
- package/dist/extensions/runtime.d.ts +2 -2
- package/dist/extensions/runtime.js +5 -5
- package/dist/harness.d.ts +48 -1
- package/dist/harness.js +79 -19
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/workspace-mcp.d.ts +62 -0
- package/dist/workspace-mcp.js +222 -0
- package/package.json +2 -2
|
@@ -204,7 +204,7 @@ export interface SessionInvokeResult {
|
|
|
204
204
|
* This list stays explicit, one entry per reachable operation: it is a security boundary, so a
|
|
205
205
|
* new capability operation should have to be named here to become remotely callable.
|
|
206
206
|
*/
|
|
207
|
-
export declare const INVOKABLE_METHODS: readonly ["setModel", "setThinking", "setPermissionMode", "goal.create", "goal.snapshot", "goal.pause", "goal.resume", "goal.cancel", "goal.changeBudget", "plan.setEnabled", "plan.data", "compaction.request", "compaction.pending", "compaction.cancel", "skills.listSkills", "skills.activateSkill", "plugins.summaries", "plugins.info", "plugins.installSummary", "plugins.setEnabled", "plugins.setMcpServerEnabled", "plugins.remove", "plugins.reload", "background.list", "background.readOutput", "background.readOutputDelta", "background.stop", "background.detach", "background.listSubagents", "background.reconcileSubagents", "background.listWorkflows", "background.getWorkflow", "runCommand", "getRecords", "getContextBreakdown", "mcp.list", "mcp.listTools", "mcp.reconnect"];
|
|
207
|
+
export declare const INVOKABLE_METHODS: readonly ["setModel", "setThinking", "setPermissionMode", "goal.create", "goal.snapshot", "goal.pause", "goal.resume", "goal.cancel", "goal.changeBudget", "plan.setEnabled", "plan.data", "compaction.request", "compaction.pending", "compaction.cancel", "skills.listSkills", "skills.activateSkill", "plugins.summaries", "plugins.info", "plugins.installSummary", "plugins.setEnabled", "plugins.setMcpServerEnabled", "plugins.remove", "plugins.reload", "background.list", "background.readOutput", "background.readOutputDelta", "background.stop", "background.detach", "background.listSubagents", "background.reconcileSubagents", "background.listWorkflows", "background.getWorkflow", "runCommand", "getRecords", "getAuditRecords", "getContextBreakdown", "mcp.list", "mcp.listTools", "mcp.reconnect"];
|
|
208
208
|
export type InvokableMethod = (typeof INVOKABLE_METHODS)[number];
|
|
209
209
|
/** `session/event` — an AgentEvent verbatim (already carries `sessionId` + `address`). */
|
|
210
210
|
export type SessionEventParams = AgentEvent;
|
|
@@ -95,8 +95,9 @@ export const INVOKABLE_METHODS = [
|
|
|
95
95
|
// slash commands — the serializable control surface for extensions (cron et al.):
|
|
96
96
|
// per-feature cron RPCs are gone with the capability, /cron rides this instead.
|
|
97
97
|
"runCommand",
|
|
98
|
-
// conversation log
|
|
98
|
+
// conversation log, and the audit shard beside it
|
|
99
99
|
"getRecords",
|
|
100
|
+
"getAuditRecords",
|
|
100
101
|
// context introspection
|
|
101
102
|
"getContextBreakdown",
|
|
102
103
|
// mcp
|
|
@@ -83,8 +83,8 @@ export declare class ExtensionRuntime {
|
|
|
83
83
|
/** Runs one extension's `session` and files its scope. Failure ⇒ skipped, contributions undone. */
|
|
84
84
|
private setupExtension;
|
|
85
85
|
/**
|
|
86
|
-
* Durable audit trail
|
|
87
|
-
*
|
|
86
|
+
* Durable audit trail of when this extension joined or left the session — written to the
|
|
87
|
+
* session's audit shard, which nothing replays from.
|
|
88
88
|
*/
|
|
89
89
|
private logChange;
|
|
90
90
|
listTools(): readonly Tool[];
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ServiceUnavailableError, isProbeProperty } from "./services.js";
|
|
2
|
-
import { tagToolSource } from "operon-agents-core";
|
|
2
|
+
import { appendAudit, tagToolSource } from "operon-agents-core";
|
|
3
3
|
/** Decision points block the loop, so they get room to do real work (network, subprocess). */
|
|
4
4
|
const DECISION_TIMEOUT_MS = 30_000;
|
|
5
5
|
/** Observers must not become latency: a slow one is dropped, not waited on. */
|
|
@@ -162,8 +162,8 @@ export class ExtensionRuntime {
|
|
|
162
162
|
}
|
|
163
163
|
}
|
|
164
164
|
/**
|
|
165
|
-
* Durable audit trail
|
|
166
|
-
*
|
|
165
|
+
* Durable audit trail of when this extension joined or left the session — written to the
|
|
166
|
+
* session's audit shard, which nothing replays from.
|
|
167
167
|
*/
|
|
168
168
|
async logChange(kind) {
|
|
169
169
|
const extensionId = this.id;
|
|
@@ -171,7 +171,7 @@ export class ExtensionRuntime {
|
|
|
171
171
|
if (!store)
|
|
172
172
|
return;
|
|
173
173
|
try {
|
|
174
|
-
await store
|
|
174
|
+
await appendAudit(store, { type: "custom", name: `extensions.${kind}`, data: { extensionId } });
|
|
175
175
|
}
|
|
176
176
|
catch (error) {
|
|
177
177
|
await this.warn(extensionId, `failed to journal extension ${kind}: ${messageOf(error)}`);
|
|
@@ -697,7 +697,7 @@ export class ExtensionRuntime {
|
|
|
697
697
|
return this.revocable(extensionId, {
|
|
698
698
|
steer: (content, options) => runtime.enqueue(extensionId, content, "steering", options?.metadata),
|
|
699
699
|
followUp: (content, options) => runtime.enqueue(extensionId, content, "follow_up", options?.metadata),
|
|
700
|
-
//
|
|
700
|
+
// Written by the engine to the session's audit shard as `extension:<id>:<name>`, and seen
|
|
701
701
|
// by `records()` straight away.
|
|
702
702
|
record: (name, data) => {
|
|
703
703
|
const session = runtime.session;
|
package/dist/harness.d.ts
CHANGED
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
* low-level "assemble it yourself / stateless server" path, import from
|
|
12
12
|
* `operon-agents/core` instead.
|
|
13
13
|
*/
|
|
14
|
-
import { type AgentEvent, type EventPublicationMode, 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, ListenerSink, type PermissionManagerOptions, type PermissionMode, type PendingRunInterrupt, type QuestionRequest, type QuestionResult, type Responder, type RunHandle, type RunResult, type SessionStore, 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 PluginManager, type ApprovalRequestOptions, type Environment, type EnvironmentFactory, type SessionRepository, type ResolvedAgentProfile, type SubagentProvider } from "operon-agents-core";
|
|
14
|
+
import { type AgentEvent, type EventPublicationMode, 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, ListenerSink, type PermissionManagerOptions, type PermissionMode, type PendingRunInterrupt, type QuestionRequest, type QuestionResult, type Responder, type RunHandle, type RunResult, type SessionStore, 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 } from "operon-agents-core";
|
|
15
15
|
import type { SkillsService, CompactionService, BackgroundManager, McpServersHandle, GoalStore, PlanMode, Logger, ModelRuntime, TracingProcessor } from "operon-agents-core";
|
|
16
|
+
import { type WorkspaceMcpPool } from "./workspace-mcp.ts";
|
|
16
17
|
import { HarnessExtensionManager, ServiceRegistry, type ExtensionDefinition, type ServiceOptions } from "./extensions/index.ts";
|
|
17
18
|
import { type CommandResult, type TodoItem } from "operon-agents-core";
|
|
18
19
|
export type ApprovalHandler = (request: ApprovalRequest, options?: ApprovalRequestOptions) => Promise<ApprovalResponse> | ApprovalResponse;
|
|
@@ -60,6 +61,19 @@ export interface DefaultCapabilitiesOptions {
|
|
|
60
61
|
* session's wins for this session only. Built, connected and shut down with the session.
|
|
61
62
|
*/
|
|
62
63
|
readonly sessionMcpServers?: Record<string, McpServerConfig>;
|
|
64
|
+
/**
|
|
65
|
+
* Share MCP connections per DIRECTORY instead of building them per session: every server this
|
|
66
|
+
* bundle would otherwise connect for the session — `mcpServers`, the plugins', and `servers`
|
|
67
|
+
* here (the directory's own, e.g. its `mcp.json`) — is borrowed from `pool` under `workDir`, so
|
|
68
|
+
* the sessions open in one directory run one set. `sessionMcpServers` stay the session's own,
|
|
69
|
+
* layered over it. Ignored when `shared.mcpServers` is set (a process-wide set already covers
|
|
70
|
+
* every directory) and for a session on its own environment. See {@link WorkspaceMcpPool}.
|
|
71
|
+
*/
|
|
72
|
+
readonly workspaceMcp?: {
|
|
73
|
+
readonly pool: WorkspaceMcpPool;
|
|
74
|
+
readonly workDir: string;
|
|
75
|
+
readonly servers?: Record<string, McpServerConfig>;
|
|
76
|
+
};
|
|
63
77
|
/**
|
|
64
78
|
* Installed-plugin manager. When set, the framework self-drives the plugin's contributions into
|
|
65
79
|
* this capability set: its skills (merged into the skills capability), its MCP servers (merged
|
|
@@ -93,6 +107,17 @@ export interface DefaultCapabilitiesOptions {
|
|
|
93
107
|
* to run is the host's statement, not the kernel's.
|
|
94
108
|
*/
|
|
95
109
|
readonly allowedMcpTransports?: readonly McpTransportKind[];
|
|
110
|
+
/** Credentials the host hands out per connect for this session's http MCP servers. See
|
|
111
|
+
* `McpCredentialProvider`. */
|
|
112
|
+
readonly mcpCredentials?: McpCredentialProvider;
|
|
113
|
+
/**
|
|
114
|
+
* The session belongs to one tenant among many (a managed host's default): nothing the process
|
|
115
|
+
* shares reaches it — no process-wide MCP set, skill scan, OAuth service or directory pool —
|
|
116
|
+
* MCP runs over http only, and no server may read its token from the process environment.
|
|
117
|
+
* Each is refused rather than skipped where skipping would hide a misconfiguration. See
|
|
118
|
+
* docs/server-profile.md.
|
|
119
|
+
*/
|
|
120
|
+
readonly strictIsolation?: boolean;
|
|
96
121
|
}
|
|
97
122
|
/**
|
|
98
123
|
* The standard coding-agent capability bundle (goal / plan / todo / skills /
|
|
@@ -286,6 +311,11 @@ export interface SessionCapabilityContext {
|
|
|
286
311
|
/** The session brought its own environment (`{ environment }` on this open): harness-shared objects
|
|
287
312
|
* derived from the harness's environment (the skill scan) do not apply to it. */
|
|
288
313
|
readonly ownEnvironment: boolean;
|
|
314
|
+
/** {@link OpenSessionOptionsBase.mcpCredentials} for this open. */
|
|
315
|
+
readonly mcpCredentials?: McpCredentialProvider;
|
|
316
|
+
/** {@link OpenSessionOptionsBase.strictIsolation} for this open. A custom factory owns honouring
|
|
317
|
+
* it the way `defaultCapabilities` does. */
|
|
318
|
+
readonly strictIsolation: boolean;
|
|
289
319
|
}
|
|
290
320
|
/**
|
|
291
321
|
* Options common to every way of opening a session.
|
|
@@ -313,6 +343,16 @@ interface OpenSessionOptionsBase<TContext = unknown> {
|
|
|
313
343
|
* shared one for this session; a custom factory can decide otherwise.
|
|
314
344
|
*/
|
|
315
345
|
readonly mcpServers?: Record<string, McpServerConfig>;
|
|
346
|
+
/**
|
|
347
|
+
* Where this session's http MCP servers get their credentials, asked on every connect: the
|
|
348
|
+
* seam a host builds a per-user vault on. Not persisted — a resume passes it again.
|
|
349
|
+
*/
|
|
350
|
+
readonly mcpCredentials?: McpCredentialProvider;
|
|
351
|
+
/**
|
|
352
|
+
* This session is one tenant's among many: see `DefaultCapabilitiesOptions.strictIsolation`.
|
|
353
|
+
* A managed host's worker sets it on every open unless it runs single-tenant.
|
|
354
|
+
*/
|
|
355
|
+
readonly strictIsolation?: boolean;
|
|
316
356
|
/**
|
|
317
357
|
* Per-session argument for each registered extension, by extension id — handed to that
|
|
318
358
|
* extension's `session(api, ctx)` as `ctx.params`. A value of `false` skips the extension for
|
|
@@ -587,6 +627,7 @@ export declare class HarnessSession<TContext = unknown> {
|
|
|
587
627
|
/** PROBE tier: absent without an MCP capability. */
|
|
588
628
|
get mcp(): McpServersHandle | undefined;
|
|
589
629
|
getRecords(...args: Parameters<Session["getRecords"]>): ReturnType<Session["getRecords"]>;
|
|
630
|
+
getAuditRecords(): ReturnType<Session["getAuditRecords"]>;
|
|
590
631
|
getContextBreakdown(): ReturnType<Session["getContextBreakdown"]>;
|
|
591
632
|
/** Where this session's tools run. A host uses it to run a command in the same place the agent
|
|
592
633
|
* does — one shell, one filesystem, one working directory. */
|
|
@@ -675,6 +716,12 @@ export declare class Harness<TContext = unknown> {
|
|
|
675
716
|
/** The extension whose `harness` is running right now — its `createSession` must refuse. */
|
|
676
717
|
private runningCreate;
|
|
677
718
|
constructor(options: HarnessOptions<TContext>);
|
|
719
|
+
/**
|
|
720
|
+
* Which session-facing parts the `harness` hook built for the whole process — what a
|
|
721
|
+
* multi-tenant host must not have, since every session on the process would share them.
|
|
722
|
+
* Infrastructure (repository, logger, model runtime) is not listed: sharing it is the point.
|
|
723
|
+
*/
|
|
724
|
+
processSharedParts(): Promise<readonly ("mcpServers" | "oauthService" | "skillRegistry")[]>;
|
|
678
725
|
/** The session repository, once the `harness` hook has had its say. */
|
|
679
726
|
private repository;
|
|
680
727
|
/** The fallback repository, built once so two opens share one in-memory store. */
|
package/dist/harness.js
CHANGED
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
* low-level "assemble it yourself / stateless server" path, import from
|
|
12
12
|
* `operon-agents/core` instead.
|
|
13
13
|
*/
|
|
14
|
-
import {
|
|
14
|
+
import { readSessionTails, SessionProjection, ListenerSink, agentEventFromRecord, newAgentEventId, Agent, buildAgentFromProfile, INTERRUPTION_STATE_KEY, flattenPendingInterrupts, parseInterruptionState, Runner, Session, emptyUsage, isGuardrailTripwireError, askUserQuestionTool, backgroundCapability, compactionCapability, filesystemTools, goalCapability, LocalEnvironment, McpOAuthService, MemorySessionRepository, SessionRepositoryNotFoundError, planCapability, skillsCapability, SkillRegistry, mcpServersCapability, pluginsCapability, userHooksCapability, taskCapability, workflowCapability, DEFAULT_AGENT_PROFILES, DEFAULT_AGENT_PROFILE_NAME, DEFAULT_SUBAGENT_NAMES, profileSubagentProvider, DEFAULT_ADDRESS, envLogger, noopLogger, } from "operon-agents-core";
|
|
15
15
|
import { mkdirSync } from "node:fs";
|
|
16
|
+
import { workspaceMcpCapability } from "./workspace-mcp.js";
|
|
16
17
|
import { join } from "node:path";
|
|
17
18
|
import { extensionCapability, extensionRuntimeOf, extensionsCapability, HarnessExtensionManager, ServiceRegistry, stageDefinition } from "./extensions/index.js";
|
|
18
19
|
import { createExtensionCommandRegistry } from "operon-agents-core";
|
|
@@ -41,9 +42,15 @@ const EXTENSION_PARAMS_STATE_KEY = "extensions:params";
|
|
|
41
42
|
export function defaultCapabilities(options = {}) {
|
|
42
43
|
const manager = options.pluginManager ?? options.shared?.pluginManager;
|
|
43
44
|
// A session on its own environment ignores the process-wide registry: it was scanned elsewhere.
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
const
|
|
45
|
+
const strict = options.strictIsolation === true;
|
|
46
|
+
// A session on its own environment, or one tenant among many, gets nothing the process shares.
|
|
47
|
+
const isolated = strict || options.ownEnvironment === true;
|
|
48
|
+
const sharedSkills = isolated ? undefined : options.shared?.skillRegistry;
|
|
49
|
+
const sharedServers = isolated ? undefined : options.shared?.mcpServers;
|
|
50
|
+
const oauthService = options.oauthService ?? (strict ? undefined : options.shared?.oauthService);
|
|
51
|
+
const allowedMcpTransports = strict
|
|
52
|
+
? (options.allowedMcpTransports ?? ["http", "stdio"]).filter((kind) => kind === "http")
|
|
53
|
+
: options.allowedMcpTransports;
|
|
47
54
|
// Shared registry so the plugin session-start injector can render a skill the skills capability
|
|
48
55
|
// loaded (its roots include the plugin skill dirs below). A process-wide registry is scanned
|
|
49
56
|
// once for every session; a session-level one is scanned here.
|
|
@@ -68,16 +75,37 @@ export function defaultCapabilities(options = {}) {
|
|
|
68
75
|
const sessionMcp = options.sessionMcpServers ?? {};
|
|
69
76
|
const mcpOptions = {
|
|
70
77
|
...(oauthService !== undefined ? { oauthService } : {}),
|
|
71
|
-
...(
|
|
78
|
+
...(allowedMcpTransports !== undefined ? { allowedTransports: allowedMcpTransports } : {}),
|
|
79
|
+
...(options.mcpCredentials !== undefined ? { credentials: options.mcpCredentials } : {}),
|
|
72
80
|
};
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
+
// Everything this session would connect that is not private to it: the directory's servers
|
|
82
|
+
// last, so they win over a same-named plugin server the way they did as a session overlay.
|
|
83
|
+
const shareable = {
|
|
84
|
+
...(options.mcpServers ?? {}),
|
|
85
|
+
...(manager?.mcpServerConfigs() ?? {}),
|
|
86
|
+
...(options.workspaceMcp?.servers ?? {}),
|
|
87
|
+
};
|
|
88
|
+
if (strict) {
|
|
89
|
+
// The process environment has no tenant boundary: a token read from it is every tenant's.
|
|
90
|
+
for (const [name, config] of Object.entries({ ...shareable, ...sessionMcp })) {
|
|
91
|
+
if (config.bearerTokenEnvVar !== undefined) {
|
|
92
|
+
throw new Error(`MCP server "${name}" reads its token from the process environment (bearerTokenEnvVar), which every tenant on this host shares; hand it out per session with mcpCredentials instead.`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const pooled = sharedServers === undefined && !isolated ? options.workspaceMcp : undefined;
|
|
97
|
+
if (pooled !== undefined && Object.keys(shareable).length > 0) {
|
|
98
|
+
// Borrowed per directory: the capability views the pool's set, `sessionMcp` is its overlay.
|
|
99
|
+
capabilities.push(workspaceMcpCapability({ pool: pooled.pool, workDir: pooled.workDir, servers: shareable, overlay: sessionMcp, overlayOptions: mcpOptions }));
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
// With process-shared connections, the capability is a VIEW over them and `configs` is this
|
|
103
|
+
// session's overlay. Without, everything this session sees is its own — session servers last,
|
|
104
|
+
// so the same name still resolves to the session's.
|
|
105
|
+
const mcpConfigs = sharedServers !== undefined ? sessionMcp : { ...shareable, ...sessionMcp };
|
|
106
|
+
if (sharedServers !== undefined || Object.keys(mcpConfigs).length > 0) {
|
|
107
|
+
capabilities.push(mcpServersCapability(mcpConfigs, { ...mcpOptions, ...(sharedServers !== undefined ? { sharedServers } : {}) }));
|
|
108
|
+
}
|
|
81
109
|
}
|
|
82
110
|
// Plugins: load the manager + render the plugin's session-start skill from the shared registry
|
|
83
111
|
// (skills opens before this, so by injection time the registry holds the plugin skills).
|
|
@@ -685,6 +713,9 @@ export class HarnessSession {
|
|
|
685
713
|
getRecords(...args) {
|
|
686
714
|
return this.core.getRecords(...args);
|
|
687
715
|
}
|
|
716
|
+
getAuditRecords() {
|
|
717
|
+
return this.core.getAuditRecords();
|
|
718
|
+
}
|
|
688
719
|
// ── context introspection — where the model's window is spent, as of the last turn ──
|
|
689
720
|
getContextBreakdown() {
|
|
690
721
|
return this.core.getContextBreakdown();
|
|
@@ -907,6 +938,22 @@ export class Harness {
|
|
|
907
938
|
// Surfaced to whoever opens a session; never an unhandled rejection on its own.
|
|
908
939
|
this.sharedReady.catch(() => undefined);
|
|
909
940
|
}
|
|
941
|
+
/**
|
|
942
|
+
* Which session-facing parts the `harness` hook built for the whole process — what a
|
|
943
|
+
* multi-tenant host must not have, since every session on the process would share them.
|
|
944
|
+
* Infrastructure (repository, logger, model runtime) is not listed: sharing it is the point.
|
|
945
|
+
*/
|
|
946
|
+
async processSharedParts() {
|
|
947
|
+
await this.sharedReady;
|
|
948
|
+
const parts = [];
|
|
949
|
+
if (this.parts.mcpServers !== undefined)
|
|
950
|
+
parts.push("mcpServers");
|
|
951
|
+
if (this.parts.oauthService !== undefined)
|
|
952
|
+
parts.push("oauthService");
|
|
953
|
+
if (this.parts.skillRegistry !== undefined)
|
|
954
|
+
parts.push("skillRegistry");
|
|
955
|
+
return parts;
|
|
956
|
+
}
|
|
910
957
|
/** The session repository, once the `harness` hook has had its say. */
|
|
911
958
|
async repository() {
|
|
912
959
|
await this.sharedReady;
|
|
@@ -1366,7 +1413,17 @@ export class Harness {
|
|
|
1366
1413
|
}
|
|
1367
1414
|
/** Resolve the capability set for one session — the `session` hook, called fresh every time. */
|
|
1368
1415
|
async resolveCapabilities(ctx, params = {}) {
|
|
1369
|
-
|
|
1416
|
+
// A session on its own environment (a sandbox) does not get the process's MCP connections or
|
|
1417
|
+
// skill scan: both describe the HARNESS's machine, not the one this session's tools run on.
|
|
1418
|
+
const base = this.options.session !== undefined
|
|
1419
|
+
? await this.options.session(ctx)
|
|
1420
|
+
: defaultCapabilities({
|
|
1421
|
+
shared: this.parts,
|
|
1422
|
+
ownEnvironment: ctx.ownEnvironment,
|
|
1423
|
+
strictIsolation: ctx.strictIsolation,
|
|
1424
|
+
...(ctx.mcpServers !== undefined ? { sessionMcpServers: ctx.mcpServers } : {}),
|
|
1425
|
+
...(ctx.mcpCredentials !== undefined ? { mcpCredentials: ctx.mcpCredentials } : {}),
|
|
1426
|
+
});
|
|
1370
1427
|
// Every definition registered on this harness: by value (`extensions`, harness halves
|
|
1371
1428
|
// included — their per-session `session` is mounted here like any other), then from files.
|
|
1372
1429
|
const extensions = [...this.valueDefs.values(), ...(this.extensions?.sessionDefinitions() ?? [])];
|
|
@@ -1446,12 +1503,13 @@ export class Harness {
|
|
|
1446
1503
|
// Environment: this call's override → the harness's default → a LocalEnvironment at the
|
|
1447
1504
|
// session's own workDir. The session only OPERATES a caller-supplied environment.
|
|
1448
1505
|
const environment = opts.environment ?? this.parts.environment ?? new LocalEnvironment(workDir);
|
|
1449
|
-
// ONE open-time log read, shared:
|
|
1450
|
-
//
|
|
1451
|
-
//
|
|
1506
|
+
// ONE open-time log read, shared: every shard from its newest checkpoint on (each
|
|
1507
|
+
// checkpoint carries the history and lifecycle before it). The projection seeds from it,
|
|
1508
|
+
// and Session.open receives it as `preloadedLog` so its capability restore + context
|
|
1509
|
+
// pre-build fold the same records — same IO, and the same `Message` objects.
|
|
1452
1510
|
// Attach still happens BEFORE Session.open: no events flow yet, so the seed cannot
|
|
1453
1511
|
// race the subscription, and anything capabilities emit during open is already folded.
|
|
1454
|
-
let preloadedLog = await
|
|
1512
|
+
let preloadedLog = await readSessionTails(store);
|
|
1455
1513
|
// A fresh session has an empty log; anything else is a reopen. Telemetry is the only reader.
|
|
1456
1514
|
const resumed = preloadedLog.length > 0;
|
|
1457
1515
|
projection = await SessionProjection.attach({ id, store, events }, preloadedLog);
|
|
@@ -1465,7 +1523,9 @@ export class Harness {
|
|
|
1465
1523
|
sessionId: id,
|
|
1466
1524
|
workDir,
|
|
1467
1525
|
ownEnvironment: opts.environment !== undefined,
|
|
1526
|
+
strictIsolation: opts.strictIsolation === true,
|
|
1468
1527
|
...(opts.mcpServers !== undefined ? { mcpServers: opts.mcpServers } : {}),
|
|
1528
|
+
...(opts.mcpCredentials !== undefined ? { mcpCredentials: opts.mcpCredentials } : {}),
|
|
1469
1529
|
}, params);
|
|
1470
1530
|
// Everything the session runs on, by value. The repository owns the store's lifetime.
|
|
1471
1531
|
core = await Session.open({
|
|
@@ -1582,7 +1642,7 @@ export class Harness {
|
|
|
1582
1642
|
export async function closeOrphanedTurns(sessionId, store) {
|
|
1583
1643
|
if ((await store.getState(INTERRUPTION_STATE_KEY)) !== null)
|
|
1584
1644
|
return [];
|
|
1585
|
-
const projection = await SessionProjection.attach({ id: sessionId, store, events: new ListenerSink() }, await
|
|
1645
|
+
const projection = await SessionProjection.attach({ id: sessionId, store, events: new ListenerSink() }, await readSessionTails(store));
|
|
1586
1646
|
const records = await closeOrphanedProjectionFrames(sessionId, store, projection);
|
|
1587
1647
|
const events = [];
|
|
1588
1648
|
for (const record of records) {
|
package/dist/index.d.ts
CHANGED
|
@@ -16,6 +16,7 @@ export * from "./extensions/index.ts";
|
|
|
16
16
|
export * from "./cron/index.ts";
|
|
17
17
|
export * from "./providers.ts";
|
|
18
18
|
export * from "./local.ts";
|
|
19
|
+
export * from "./workspace-mcp.ts";
|
|
19
20
|
export * from "operon-agents-core";
|
|
20
21
|
export * as llm from "operon-agents-core/llm";
|
|
21
22
|
export * as mcp from "operon-agents-core/mcp";
|
package/dist/index.js
CHANGED
|
@@ -20,6 +20,8 @@ export * from "./cron/index.js";
|
|
|
20
20
|
export * from "./providers.js";
|
|
21
21
|
// Deployment composition roots: local vs server injection presets (Invariant 7 — no engine "mode").
|
|
22
22
|
export * from "./local.js";
|
|
23
|
+
// Per-directory MCP sharing for hosts that open many sessions in one folder (a desktop app).
|
|
24
|
+
export * from "./workspace-mcp.js";
|
|
23
25
|
// The curated core engine, re-exported for convenience; `operon-agents/core` is the dedicated
|
|
24
26
|
// primitives entrypoint.
|
|
25
27
|
export * from "operon-agents-core";
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { type Capability, type McpServerConfig, type McpServersCapabilityOptions, type McpServersHandle } from "operon-agents-core";
|
|
2
|
+
/** How long a directory's connections outlive its last session by default: five minutes. */
|
|
3
|
+
export declare const DEFAULT_WORKSPACE_MCP_IDLE_MS: number;
|
|
4
|
+
export interface WorkspaceMcpPoolOptions extends Omit<McpServersCapabilityOptions, "sharedServers"> {
|
|
5
|
+
/**
|
|
6
|
+
* How long a directory's connections stay up after the last session using them closes, in ms.
|
|
7
|
+
* A session opened there within the window reuses them. `0` closes them at once. Defaults to
|
|
8
|
+
* {@link DEFAULT_WORKSPACE_MCP_IDLE_MS}.
|
|
9
|
+
*/
|
|
10
|
+
readonly idleMs?: number;
|
|
11
|
+
}
|
|
12
|
+
/** One session's hold on a directory's connections. `release` is idempotent. */
|
|
13
|
+
export interface WorkspaceMcpLease {
|
|
14
|
+
readonly servers: McpServersHandle;
|
|
15
|
+
release(): Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
export declare class WorkspaceMcpPool {
|
|
18
|
+
private readonly options;
|
|
19
|
+
/** The generation a NEW borrow in each directory gets. */
|
|
20
|
+
private readonly current;
|
|
21
|
+
/** Every generation not yet shut down — current ones and retired ones still on loan. */
|
|
22
|
+
private readonly live;
|
|
23
|
+
private closing;
|
|
24
|
+
constructor(options?: WorkspaceMcpPoolOptions);
|
|
25
|
+
/** Directories whose connections are currently up (on loan or idling). */
|
|
26
|
+
get size(): number;
|
|
27
|
+
/**
|
|
28
|
+
* Borrow the connections for `workDir`, building them if this directory has none for exactly
|
|
29
|
+
* these `configs`. Resolves once they have connected — fault-isolated, so a server that failed
|
|
30
|
+
* is a status on the handle, never a rejection. Rejects for what `createMcpServers` refuses
|
|
31
|
+
* up front (a transport this host does not run), before anything is built.
|
|
32
|
+
*/
|
|
33
|
+
acquire(workDir: string, configs: Record<string, McpServerConfig>): Promise<WorkspaceMcpLease>;
|
|
34
|
+
/** Shut every directory's connections down, on loan or not. Later borrows throw. Idempotent. */
|
|
35
|
+
close(): Promise<void>;
|
|
36
|
+
/** The last lease came back: close a retired generation now, a current one after the grace. */
|
|
37
|
+
private idle;
|
|
38
|
+
private shutdown;
|
|
39
|
+
private assertOpen;
|
|
40
|
+
}
|
|
41
|
+
export interface WorkspaceMcpCapabilityOptions {
|
|
42
|
+
readonly pool: WorkspaceMcpPool;
|
|
43
|
+
/** The session's working directory — the key the connections are shared under. */
|
|
44
|
+
readonly workDir: string;
|
|
45
|
+
/** The servers every session in `workDir` shares. */
|
|
46
|
+
readonly servers: Record<string, McpServerConfig>;
|
|
47
|
+
/** The session's own servers, layered over the shared ones (same name: the session's wins). */
|
|
48
|
+
readonly overlay?: Record<string, McpServerConfig>;
|
|
49
|
+
/**
|
|
50
|
+
* Options for the overlay's connections; the pooled ones were configured on the pool. Its
|
|
51
|
+
* `allowedTransports` is enforced on BOTH — the host's statement of what it runs does not get
|
|
52
|
+
* weaker because a server happens to be shared.
|
|
53
|
+
*/
|
|
54
|
+
readonly overlayOptions?: Omit<McpServersCapabilityOptions, "sharedServers">;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The `mcp` capability over a directory's pooled connections: borrows them at `openSession`,
|
|
58
|
+
* gives them back at `closeSession` (session close or detach alike). In between it is exactly
|
|
59
|
+
* `mcpServersCapability` viewing a shared set — same tools, same `session.mcp`, same overlay.
|
|
60
|
+
*/
|
|
61
|
+
export declare function workspaceMcpCapability(options: WorkspaceMcpCapabilityOptions): Capability;
|
|
62
|
+
//# sourceMappingURL=workspace-mcp.d.ts.map
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP connections shared by every session opened in the same directory.
|
|
3
|
+
*
|
|
4
|
+
* A desktop host keeps one process up all day and opens many conversations in one project at
|
|
5
|
+
* once. Each of them used to connect the project's MCP servers again — an stdio server is a child
|
|
6
|
+
* process, so N chats in one folder meant N copies of every server. The pool keys one set of
|
|
7
|
+
* connections by the directory's real path and lends it to each session that opens there.
|
|
8
|
+
*
|
|
9
|
+
* This is deliberately NOT a lifetime tier of the engine (that was the workspace `Scope`, removed
|
|
10
|
+
* for the rules it cost). Core still knows two lifetimes, the process and the session; the pool
|
|
11
|
+
* is a host-side object the session hook hands to `defaultCapabilities`, and the MCP capability
|
|
12
|
+
* borrows from it at `openSession` and gives back at `closeSession`. Because the borrowing lives
|
|
13
|
+
* inside the capability's own open/close, a failed open undoes it like everything else the
|
|
14
|
+
* session opened — no hold is taken anywhere the session cannot see.
|
|
15
|
+
*
|
|
16
|
+
* Lifetime of one directory's set, a "generation":
|
|
17
|
+
* - built on the first borrow, connected once; concurrent borrows share that one connect;
|
|
18
|
+
* - kept while any session holds it, then for `idleMs` more, so closing the last chat and
|
|
19
|
+
* starting the next one does not respawn every server;
|
|
20
|
+
* - replaced, not mutated, when a borrow arrives with a different configuration (an edited
|
|
21
|
+
* `mcp.json`, a plugin enabled): new sessions get a new generation, the sessions already
|
|
22
|
+
* holding the old one keep it, and it closes when the last of them gives it back.
|
|
23
|
+
*
|
|
24
|
+
* Sharing is the point, so it is total: two sessions in one directory drive the SAME server.
|
|
25
|
+
* One with state of its own (a browser) is therefore shared state between them; a reconnect or an
|
|
26
|
+
* OAuth sign-in in one session is seen by all. A server that must be private belongs in the
|
|
27
|
+
* session's own `mcpServers`, which are layered over the shared set and never pooled.
|
|
28
|
+
*/
|
|
29
|
+
import { realpath } from "node:fs/promises";
|
|
30
|
+
import { resolve } from "node:path";
|
|
31
|
+
import { createMcpServers, mcpServersCapability, McpTransportNotPermittedError, } from "operon-agents-core";
|
|
32
|
+
/** How long a directory's connections outlive its last session by default: five minutes. */
|
|
33
|
+
export const DEFAULT_WORKSPACE_MCP_IDLE_MS = 5 * 60_000;
|
|
34
|
+
export class WorkspaceMcpPool {
|
|
35
|
+
options;
|
|
36
|
+
/** The generation a NEW borrow in each directory gets. */
|
|
37
|
+
current = new Map();
|
|
38
|
+
/** Every generation not yet shut down — current ones and retired ones still on loan. */
|
|
39
|
+
live = new Set();
|
|
40
|
+
closing;
|
|
41
|
+
constructor(options = {}) {
|
|
42
|
+
this.options = options;
|
|
43
|
+
}
|
|
44
|
+
/** Directories whose connections are currently up (on loan or idling). */
|
|
45
|
+
get size() {
|
|
46
|
+
return this.current.size;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Borrow the connections for `workDir`, building them if this directory has none for exactly
|
|
50
|
+
* these `configs`. Resolves once they have connected — fault-isolated, so a server that failed
|
|
51
|
+
* is a status on the handle, never a rejection. Rejects for what `createMcpServers` refuses
|
|
52
|
+
* up front (a transport this host does not run), before anything is built.
|
|
53
|
+
*/
|
|
54
|
+
async acquire(workDir, configs) {
|
|
55
|
+
this.assertOpen();
|
|
56
|
+
const key = await canonicalDir(workDir);
|
|
57
|
+
this.assertOpen();
|
|
58
|
+
const fingerprint = fingerprintOf(configs);
|
|
59
|
+
let generation = this.current.get(key);
|
|
60
|
+
if (generation !== undefined && generation.fingerprint !== fingerprint) {
|
|
61
|
+
this.current.delete(key);
|
|
62
|
+
if (generation.leases === 0)
|
|
63
|
+
void this.shutdown(generation);
|
|
64
|
+
generation = undefined;
|
|
65
|
+
}
|
|
66
|
+
if (generation === undefined) {
|
|
67
|
+
const { idleMs: _idleMs, ...serverOptions } = this.options;
|
|
68
|
+
const servers = createMcpServers(configs, serverOptions);
|
|
69
|
+
// Pooled connections belong to no session: their failures reach each borrower through
|
|
70
|
+
// `onStatusChange`, which the MCP capability subscribes to when it opens.
|
|
71
|
+
generation = { key, fingerprint, servers, ready: servers.connect({ sessionId: "" }), leases: 0 };
|
|
72
|
+
this.current.set(key, generation);
|
|
73
|
+
this.live.add(generation);
|
|
74
|
+
}
|
|
75
|
+
const held = generation;
|
|
76
|
+
held.leases += 1;
|
|
77
|
+
if (held.idle !== undefined) {
|
|
78
|
+
clearTimeout(held.idle);
|
|
79
|
+
held.idle = undefined;
|
|
80
|
+
}
|
|
81
|
+
let released = false;
|
|
82
|
+
const release = async () => {
|
|
83
|
+
if (released)
|
|
84
|
+
return;
|
|
85
|
+
released = true;
|
|
86
|
+
held.leases -= 1;
|
|
87
|
+
if (held.leases === 0)
|
|
88
|
+
await this.idle(held);
|
|
89
|
+
};
|
|
90
|
+
try {
|
|
91
|
+
await held.ready;
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
await release();
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
return { servers: held.servers, release };
|
|
98
|
+
}
|
|
99
|
+
/** Shut every directory's connections down, on loan or not. Later borrows throw. Idempotent. */
|
|
100
|
+
close() {
|
|
101
|
+
this.closing ??= (async () => {
|
|
102
|
+
this.current.clear();
|
|
103
|
+
await Promise.allSettled([...this.live].map((generation) => this.shutdown(generation)));
|
|
104
|
+
})();
|
|
105
|
+
return this.closing;
|
|
106
|
+
}
|
|
107
|
+
/** The last lease came back: close a retired generation now, a current one after the grace. */
|
|
108
|
+
async idle(generation) {
|
|
109
|
+
const idleMs = this.options.idleMs ?? DEFAULT_WORKSPACE_MCP_IDLE_MS;
|
|
110
|
+
if (this.current.get(generation.key) !== generation || idleMs <= 0) {
|
|
111
|
+
if (this.current.get(generation.key) === generation)
|
|
112
|
+
this.current.delete(generation.key);
|
|
113
|
+
await this.shutdown(generation);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
generation.idle = setTimeout(() => {
|
|
117
|
+
generation.idle = undefined;
|
|
118
|
+
if (generation.leases !== 0 || this.current.get(generation.key) !== generation)
|
|
119
|
+
return;
|
|
120
|
+
this.current.delete(generation.key);
|
|
121
|
+
void this.shutdown(generation);
|
|
122
|
+
}, idleMs);
|
|
123
|
+
// An idling set must not be what keeps the process alive.
|
|
124
|
+
generation.idle.unref?.();
|
|
125
|
+
}
|
|
126
|
+
shutdown(generation) {
|
|
127
|
+
if (generation.idle !== undefined) {
|
|
128
|
+
clearTimeout(generation.idle);
|
|
129
|
+
generation.idle = undefined;
|
|
130
|
+
}
|
|
131
|
+
this.live.delete(generation);
|
|
132
|
+
generation.closed ??= generation.ready.catch(() => undefined).then(() => generation.servers.shutdown());
|
|
133
|
+
return generation.closed;
|
|
134
|
+
}
|
|
135
|
+
assertOpen() {
|
|
136
|
+
if (this.closing !== undefined)
|
|
137
|
+
throw new Error("WorkspaceMcpPool is closed");
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* The `mcp` capability over a directory's pooled connections: borrows them at `openSession`,
|
|
142
|
+
* gives them back at `closeSession` (session close or detach alike). In between it is exactly
|
|
143
|
+
* `mcpServersCapability` viewing a shared set — same tools, same `session.mcp`, same overlay.
|
|
144
|
+
*/
|
|
145
|
+
export function workspaceMcpCapability(options) {
|
|
146
|
+
const allowed = options.overlayOptions?.allowedTransports;
|
|
147
|
+
if (allowed !== undefined) {
|
|
148
|
+
for (const [name, config] of Object.entries(options.servers)) {
|
|
149
|
+
if (config.enabled !== false && !allowed.includes(config.transport)) {
|
|
150
|
+
throw new McpTransportNotPermittedError(name, config.transport, allowed);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
let lease;
|
|
155
|
+
/** The borrowed set while a lease is held; the view below is empty without one. */
|
|
156
|
+
let bound;
|
|
157
|
+
const view = {
|
|
158
|
+
list: () => bound?.list() ?? [],
|
|
159
|
+
listTools: async (name) => (bound !== undefined ? bound.listTools(name) : []),
|
|
160
|
+
reconnect: async (name) => {
|
|
161
|
+
if (bound === undefined)
|
|
162
|
+
throw new Error(`Unknown MCP server: ${name}`);
|
|
163
|
+
await bound.reconnect(name);
|
|
164
|
+
},
|
|
165
|
+
onStatusChange: (listener) => bound?.onStatusChange(listener) ?? (() => { }),
|
|
166
|
+
toolProviders: () => bound?.toolProviders() ?? [],
|
|
167
|
+
// The pool connects and shuts down; a viewer does neither.
|
|
168
|
+
connect: async () => { },
|
|
169
|
+
shutdown: async () => { },
|
|
170
|
+
};
|
|
171
|
+
const inner = mcpServersCapability(options.overlay ?? {}, { ...(options.overlayOptions ?? {}), sharedServers: view });
|
|
172
|
+
const giveBack = async () => {
|
|
173
|
+
const held = lease;
|
|
174
|
+
lease = undefined;
|
|
175
|
+
bound = undefined;
|
|
176
|
+
await held?.release();
|
|
177
|
+
};
|
|
178
|
+
return {
|
|
179
|
+
name: inner.name,
|
|
180
|
+
contract: inner.contract,
|
|
181
|
+
...(inner.toolProviders !== undefined ? { toolProviders: inner.toolProviders } : {}),
|
|
182
|
+
get service() {
|
|
183
|
+
return inner.service;
|
|
184
|
+
},
|
|
185
|
+
openSession: async (ctx) => {
|
|
186
|
+
lease = await options.pool.acquire(options.workDir, options.servers);
|
|
187
|
+
bound = lease.servers;
|
|
188
|
+
try {
|
|
189
|
+
await inner.openSession?.(ctx);
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
await giveBack();
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
closeSession: async (reason) => {
|
|
197
|
+
try {
|
|
198
|
+
await inner.closeSession?.(reason);
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
await giveBack();
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/** The directory's real path, so `./proj`, `/tmp/proj` and a symlink to it share one set. */
|
|
207
|
+
async function canonicalDir(workDir) {
|
|
208
|
+
const absolute = resolve(workDir);
|
|
209
|
+
try {
|
|
210
|
+
return await realpath(absolute);
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
return absolute;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/** Order-independent identity of a configuration: same servers, same settings, same string. */
|
|
217
|
+
function fingerprintOf(configs) {
|
|
218
|
+
return JSON.stringify(configs, (_key, value) => value !== null && typeof value === "object" && !Array.isArray(value)
|
|
219
|
+
? Object.fromEntries(Object.entries(value).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
|
|
220
|
+
: value);
|
|
221
|
+
}
|
|
222
|
+
//# sourceMappingURL=workspace-mcp.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "operon-agents",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.17",
|
|
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": "0.87.1",
|
|
77
|
-
"operon-agents-core": "0.1.0-alpha.
|
|
77
|
+
"operon-agents-core": "0.1.0-alpha.17"
|
|
78
78
|
},
|
|
79
79
|
"devDependencies": {
|
|
80
80
|
"@earendil-works/pi-ai": "0.87.1"
|