antechamber 0.2.4 → 0.2.5
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/CHANGELOG.md +23 -0
- package/dist/lifecycle.d.ts +1 -0
- package/dist/lifecycle.js +4 -0
- package/dist/mcp.d.ts +70 -0
- package/dist/mcp.js +494 -0
- package/dist/misc-routes.js +2 -3
- package/dist/monitor-sessions.d.ts +48 -0
- package/dist/monitor-sessions.js +295 -0
- package/dist/pi-event-translation.d.ts +2 -0
- package/dist/pi-event-translation.js +3 -0
- package/dist/pi-rpc.js +2 -0
- package/dist/routes-v1.js +6 -4
- package/dist/routes-v2.js +11 -5
- package/dist/sessions.d.ts +6 -0
- package/dist/sessions.js +19 -1
- package/dist/types.d.ts +5 -0
- package/dist/v2.js +2 -0
- package/extensions/omo-dag/package.json +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,29 @@ and this project uses [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.5] - 2026-10-15
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- OmO terminal monitors now appear in OpenChamber as read-only child sessions of
|
|
15
|
+
the session that started them. They are listed under Subagents and keep a live
|
|
16
|
+
sidebar dot while the watch runs, and their transcript shows the watched
|
|
17
|
+
command, filter, event count and how the watch ended. A session with a live
|
|
18
|
+
watch is no longer evicted as idle, which used to stop the watch.
|
|
19
|
+
- MCP servers configured in OmO now appear in OpenChamber's MCP status, and the
|
|
20
|
+
Authorize button for a server that needs sign-in runs OmO's native
|
|
21
|
+
`/mcp auth` flow in the running session. Status and authorization use the
|
|
22
|
+
most recently used live session in the directory. The flow never adds a
|
|
23
|
+
message to the conversation, and it never revives a dormant session.
|
|
24
|
+
- Completing an MCP sign-in with a pasted code or redirect URL (OpenChamber's
|
|
25
|
+
callback page or paste box) now works through `/mcp/{name}/auth/callback`.
|
|
26
|
+
The code goes to OmO's waiting loopback listener, or to
|
|
27
|
+
`/mcp auth-complete` when OmO parked the flow.
|
|
28
|
+
- OpenChamber 2.x now lists OmO MCP servers (`GET /api/mcp`) and can sign in
|
|
29
|
+
through `mcp_*` integrations. These run OmO's headless `/mcp auth-start` and
|
|
30
|
+
`/mcp auth-complete` with a paste-the-code step. A completed sign-in emits
|
|
31
|
+
`mcp.status.changed`.
|
|
32
|
+
|
|
10
33
|
## [0.2.4] - 2026-10-15
|
|
11
34
|
|
|
12
35
|
### Added
|
package/dist/lifecycle.d.ts
CHANGED
|
@@ -30,6 +30,7 @@ export declare class SessionLifecycle {
|
|
|
30
30
|
private stopping;
|
|
31
31
|
constructor(sessions: () => Iterable<ManagedSession>, options?: LifecycleOptions);
|
|
32
32
|
touch(s: ManagedSession): void;
|
|
33
|
+
lastUsed(s: ManagedSession): number;
|
|
33
34
|
private idle;
|
|
34
35
|
/** Protect the caller across awaits, including admission of a dormant session. */
|
|
35
36
|
use<T>(s: ManagedSession, action: () => Promise<T>): Promise<T>;
|
package/dist/lifecycle.js
CHANGED
|
@@ -45,6 +45,9 @@ export class SessionLifecycle {
|
|
|
45
45
|
touch(s) {
|
|
46
46
|
this.used.set(s, this.clock.now());
|
|
47
47
|
}
|
|
48
|
+
lastUsed(s) {
|
|
49
|
+
return this.used.get(s) ?? 0;
|
|
50
|
+
}
|
|
48
51
|
idle(s) {
|
|
49
52
|
return (!this.active.has(s) &&
|
|
50
53
|
s.pi !== undefined &&
|
|
@@ -52,6 +55,7 @@ export class SessionLifecycle {
|
|
|
52
55
|
s.status.type === "idle" &&
|
|
53
56
|
!s.hasPendingInteraction() &&
|
|
54
57
|
!s.hasRunningChildren() &&
|
|
58
|
+
!s.hasBackgroundWork?.() &&
|
|
55
59
|
s.piSessionId !== undefined &&
|
|
56
60
|
s.piPath !== undefined);
|
|
57
61
|
}
|
package/dist/mcp.d.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode MCP routes (v1 `/mcp*`, v2 `/api/mcp` and `mcp_*` integrations) backed by OmO's MCP service.
|
|
3
|
+
*
|
|
4
|
+
* OpenChamber scopes MCP calls by directory only, while OmO owns MCP per session process.
|
|
5
|
+
* Everything therefore targets the most recently used LIVE session in the directory; a
|
|
6
|
+
* dormant session is never revived just to answer an MCP request.
|
|
7
|
+
*
|
|
8
|
+
* Status comes from the read-only `get_loaded_surfaces` RPC. Authorization drives OmO's
|
|
9
|
+
* documented `/mcp` subcommands (docs/mcp.md "Auth"):
|
|
10
|
+
* - `/mcp auth <name>`: OmO opens a loopback listener and the browser, exchanges the code
|
|
11
|
+
* and reconnects. With `mcp.oauthCallbackUrl` set it parks a pending flow instead.
|
|
12
|
+
* - `/mcp auth-start <name>` then `/mcp auth-complete <name> <redirect-url>`: the headless
|
|
13
|
+
* paste flow; the pending PKCE state lives in that session process, so completion must
|
|
14
|
+
* reach the same process.
|
|
15
|
+
* Commands are sent straight to the agent, outside the conversation queue: they neither
|
|
16
|
+
* wait behind a running turn nor create a user message, and the session is held active so
|
|
17
|
+
* the reaper cannot stop the process (and its listener or pending state) mid-flow.
|
|
18
|
+
*/
|
|
19
|
+
import type { RouteServices } from "./route-services.js";
|
|
20
|
+
type OCMcpStatus = {
|
|
21
|
+
status: "connected";
|
|
22
|
+
} | {
|
|
23
|
+
status: "disabled";
|
|
24
|
+
} | {
|
|
25
|
+
status: "failed";
|
|
26
|
+
error: string;
|
|
27
|
+
} | {
|
|
28
|
+
status: "needs_auth";
|
|
29
|
+
} | {
|
|
30
|
+
status: "needs_client_registration";
|
|
31
|
+
error: string;
|
|
32
|
+
};
|
|
33
|
+
type V2McpStatus = {
|
|
34
|
+
status: "connected";
|
|
35
|
+
} | {
|
|
36
|
+
status: "pending";
|
|
37
|
+
} | {
|
|
38
|
+
status: "disabled";
|
|
39
|
+
} | {
|
|
40
|
+
status: "failed";
|
|
41
|
+
error: string;
|
|
42
|
+
} | {
|
|
43
|
+
status: "needs_auth";
|
|
44
|
+
error: string;
|
|
45
|
+
};
|
|
46
|
+
interface OmoMcpServer {
|
|
47
|
+
name: string;
|
|
48
|
+
status?: string;
|
|
49
|
+
authStatus?: string;
|
|
50
|
+
}
|
|
51
|
+
interface RouteResult {
|
|
52
|
+
status: number;
|
|
53
|
+
body?: unknown;
|
|
54
|
+
}
|
|
55
|
+
/** OmO's loopback listener gives up after 5 minutes; leave room for the reconnect that follows. */
|
|
56
|
+
export declare const MCP_AUTH_COMMAND_TIMEOUT_MS: number;
|
|
57
|
+
/** OmO's runtime states fold onto OpenCode's; lazy servers are usable, so they read as connected. */
|
|
58
|
+
export declare function openCodeMcpStatus(server: OmoMcpServer): OCMcpStatus;
|
|
59
|
+
export declare function v2McpStatus(server: OmoMcpServer): V2McpStatus;
|
|
60
|
+
/** The URL OmO prints on its own line after naming the server (`auth` and `auth-start` both do). */
|
|
61
|
+
export declare function authorizationUrl(text: string, name: string): string | undefined;
|
|
62
|
+
/**
|
|
63
|
+
* The redirect `/mcp auth-complete` expects. OpenChamber sends either the bare code (callback
|
|
64
|
+
* page, v1 paste box) or the whole redirect address; a bare code is rebuilt from the
|
|
65
|
+
* authorization request's own `redirect_uri` and `state`.
|
|
66
|
+
*/
|
|
67
|
+
export declare function redirectInput(authorization: string, input: string): string | undefined;
|
|
68
|
+
export declare function mcpRoute(services: RouteServices, method: string, path: string, directory: string, body: () => Promise<unknown>): Promise<RouteResult | undefined>;
|
|
69
|
+
export declare function mcpRouteV2(services: RouteServices, method: string, path: string, directory: string, body: () => Promise<unknown>): Promise<RouteResult | undefined>;
|
|
70
|
+
export {};
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode MCP routes (v1 `/mcp*`, v2 `/api/mcp` and `mcp_*` integrations) backed by OmO's MCP service.
|
|
3
|
+
*
|
|
4
|
+
* OpenChamber scopes MCP calls by directory only, while OmO owns MCP per session process.
|
|
5
|
+
* Everything therefore targets the most recently used LIVE session in the directory; a
|
|
6
|
+
* dormant session is never revived just to answer an MCP request.
|
|
7
|
+
*
|
|
8
|
+
* Status comes from the read-only `get_loaded_surfaces` RPC. Authorization drives OmO's
|
|
9
|
+
* documented `/mcp` subcommands (docs/mcp.md "Auth"):
|
|
10
|
+
* - `/mcp auth <name>`: OmO opens a loopback listener and the browser, exchanges the code
|
|
11
|
+
* and reconnects. With `mcp.oauthCallbackUrl` set it parks a pending flow instead.
|
|
12
|
+
* - `/mcp auth-start <name>` then `/mcp auth-complete <name> <redirect-url>`: the headless
|
|
13
|
+
* paste flow; the pending PKCE state lives in that session process, so completion must
|
|
14
|
+
* reach the same process.
|
|
15
|
+
* Commands are sent straight to the agent, outside the conversation queue: they neither
|
|
16
|
+
* wait behind a running turn nor create a user message, and the session is held active so
|
|
17
|
+
* the reaper cannot stop the process (and its listener or pending state) mid-flow.
|
|
18
|
+
*/
|
|
19
|
+
import { randomUUID } from "node:crypto";
|
|
20
|
+
import { errorBody } from "./http.js";
|
|
21
|
+
/** Bounds how long an HTTP caller waits on a read-only RPC; the transport keeps its own budget. */
|
|
22
|
+
const MCP_READ_WAIT_MS = 5_000;
|
|
23
|
+
/** OmO announces the authorization URL before it waits for the browser. */
|
|
24
|
+
const MCP_AUTH_URL_WAIT_MS = 30_000;
|
|
25
|
+
/** OmO's loopback listener gives up after 5 minutes; leave room for the reconnect that follows. */
|
|
26
|
+
export const MCP_AUTH_COMMAND_TIMEOUT_MS = 10 * 60_000;
|
|
27
|
+
/** A pending paste flow is forgotten after this; OmO's own state is replaced by the next start. */
|
|
28
|
+
const MCP_AUTH_ATTEMPT_TTL_MS = 10 * 60_000;
|
|
29
|
+
/** Names are passed as one slash-command argument, so they must not split or quote. */
|
|
30
|
+
const SERVER_NAME = /^[^\s"'\\]+$/;
|
|
31
|
+
const INTEGRATION_PREFIX = "mcp_";
|
|
32
|
+
const OAUTH_METHOD_ID = "oauth";
|
|
33
|
+
const flows = new Map();
|
|
34
|
+
const admissions = new Map();
|
|
35
|
+
function isRecord(value) {
|
|
36
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
37
|
+
}
|
|
38
|
+
function errorMessage(error) {
|
|
39
|
+
return error instanceof Error ? error.message : String(error);
|
|
40
|
+
}
|
|
41
|
+
const flowKey = (directory, name) => `${directory}\0${name}`;
|
|
42
|
+
/** OmO's runtime states fold onto OpenCode's; lazy servers are usable, so they read as connected. */
|
|
43
|
+
export function openCodeMcpStatus(server) {
|
|
44
|
+
if (server.status === "needs_client_registration")
|
|
45
|
+
return {
|
|
46
|
+
status: "needs_client_registration",
|
|
47
|
+
error: "OAuth client registration is required; configure a client ID for this server in OmO",
|
|
48
|
+
};
|
|
49
|
+
if (server.status === "needs_auth")
|
|
50
|
+
return { status: "needs_auth" };
|
|
51
|
+
if (server.status === "disabled" || server.status === "untrusted")
|
|
52
|
+
return { status: "disabled" };
|
|
53
|
+
if (server.status === "degraded")
|
|
54
|
+
return { status: "failed", error: "MCP server is degraded; see /mcp logs in OmO" };
|
|
55
|
+
if (server.status !== "connected" && server.authStatus === "notLoggedIn")
|
|
56
|
+
return { status: "needs_auth" };
|
|
57
|
+
return { status: "connected" };
|
|
58
|
+
}
|
|
59
|
+
export function v2McpStatus(server) {
|
|
60
|
+
const status = openCodeMcpStatus(server);
|
|
61
|
+
if (status.status === "needs_auth")
|
|
62
|
+
return { status: "needs_auth", error: "Sign-in required" };
|
|
63
|
+
if (status.status === "needs_client_registration")
|
|
64
|
+
return { status: "needs_auth", error: status.error };
|
|
65
|
+
if (status.status === "connected" && server.status === "connecting")
|
|
66
|
+
return { status: "pending" };
|
|
67
|
+
return status;
|
|
68
|
+
}
|
|
69
|
+
const oauthCapable = (server) => server.authStatus === "notLoggedIn" || server.authStatus === "oAuth";
|
|
70
|
+
function liveSession(sessions, directory) {
|
|
71
|
+
const s = sessions.liveSessions(directory)[0];
|
|
72
|
+
return s?.pi === undefined ? undefined : { s, pi: s.pi };
|
|
73
|
+
}
|
|
74
|
+
async function loadedServers(pi) {
|
|
75
|
+
const response = await pi.command({ type: "get_loaded_surfaces" }, undefined, AbortSignal.timeout(MCP_READ_WAIT_MS));
|
|
76
|
+
// Older OmO builds have no loaded-surfaces RPC: report no servers rather than a failure.
|
|
77
|
+
if (!response.success || !isRecord(response.data) || !Array.isArray(response.data.mcpServers))
|
|
78
|
+
return [];
|
|
79
|
+
return response.data.mcpServers.flatMap((server) => isRecord(server) && typeof server.name === "string"
|
|
80
|
+
? [
|
|
81
|
+
{
|
|
82
|
+
name: server.name,
|
|
83
|
+
...(typeof server.status === "string" ? { status: server.status } : {}),
|
|
84
|
+
...(typeof server.authStatus === "string" ? { authStatus: server.authStatus } : {}),
|
|
85
|
+
},
|
|
86
|
+
]
|
|
87
|
+
: []);
|
|
88
|
+
}
|
|
89
|
+
async function directoryServers(sessions, directory) {
|
|
90
|
+
const live = liveSession(sessions, directory);
|
|
91
|
+
return live === undefined ? [] : loadedServers(live.pi).catch(() => []);
|
|
92
|
+
}
|
|
93
|
+
async function hasMcpCommand(pi) {
|
|
94
|
+
const response = await pi.command({ type: "get_commands" }, undefined, AbortSignal.timeout(MCP_READ_WAIT_MS));
|
|
95
|
+
if (!response.success || !isRecord(response.data) || !Array.isArray(response.data.commands))
|
|
96
|
+
return false;
|
|
97
|
+
return response.data.commands.some((command) => isRecord(command) && command.name === "mcp" && command.source === "extension");
|
|
98
|
+
}
|
|
99
|
+
/** The URL OmO prints on its own line after naming the server (`auth` and `auth-start` both do). */
|
|
100
|
+
export function authorizationUrl(text, name) {
|
|
101
|
+
if (!text.includes(name))
|
|
102
|
+
return undefined;
|
|
103
|
+
const candidate = /(?:^|\n)(https?:\/\/\S+)/.exec(text)?.[1];
|
|
104
|
+
if (candidate === undefined)
|
|
105
|
+
return undefined;
|
|
106
|
+
try {
|
|
107
|
+
return new URL(candidate).toString();
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* The redirect `/mcp auth-complete` expects. OpenChamber sends either the bare code (callback
|
|
115
|
+
* page, v1 paste box) or the whole redirect address; a bare code is rebuilt from the
|
|
116
|
+
* authorization request's own `redirect_uri` and `state`.
|
|
117
|
+
*/
|
|
118
|
+
export function redirectInput(authorization, input) {
|
|
119
|
+
const pasted = input.trim();
|
|
120
|
+
if (pasted === "" || /\s/.test(pasted))
|
|
121
|
+
return undefined;
|
|
122
|
+
try {
|
|
123
|
+
const url = new URL(pasted);
|
|
124
|
+
if (url.searchParams.has("code") || url.searchParams.has("error"))
|
|
125
|
+
return url.toString();
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// Not an address: treat it as the authorization code itself.
|
|
129
|
+
}
|
|
130
|
+
const request = new URL(authorization).searchParams;
|
|
131
|
+
const redirect = request.get("redirect_uri");
|
|
132
|
+
if (redirect === null)
|
|
133
|
+
return undefined;
|
|
134
|
+
const url = new URL(redirect);
|
|
135
|
+
url.searchParams.set("code", pasted);
|
|
136
|
+
const state = request.get("state");
|
|
137
|
+
if (state !== null)
|
|
138
|
+
url.searchParams.set("state", state);
|
|
139
|
+
return url.toString();
|
|
140
|
+
}
|
|
141
|
+
/** Only the listener OmO announced for this flow may receive the pasted redirect. */
|
|
142
|
+
function sameCallback(target, authorization) {
|
|
143
|
+
const announced = new URL(authorization).searchParams.get("redirect_uri");
|
|
144
|
+
if (announced === null)
|
|
145
|
+
return false;
|
|
146
|
+
const expected = new URL(announced);
|
|
147
|
+
return target.origin === expected.origin && target.pathname === expected.pathname;
|
|
148
|
+
}
|
|
149
|
+
function runMcpCommand(sessions, s, pi, name, args, observe = {}) {
|
|
150
|
+
const errors = [];
|
|
151
|
+
const onEvent = (event) => {
|
|
152
|
+
const entry = event.entry;
|
|
153
|
+
if (event.type !== "entry_appended" || !isRecord(entry))
|
|
154
|
+
return;
|
|
155
|
+
if (entry.type !== "custom" || entry.customType !== "mcp-auth" || typeof entry.data !== "string")
|
|
156
|
+
return;
|
|
157
|
+
const done = `MCP server ${name} `;
|
|
158
|
+
if (entry.data.startsWith(`${done}authorized`) || entry.data.startsWith(`${done}authenticated`))
|
|
159
|
+
observe.authorized?.();
|
|
160
|
+
const found = authorizationUrl(entry.data, name);
|
|
161
|
+
if (found !== undefined)
|
|
162
|
+
observe.url?.(found);
|
|
163
|
+
};
|
|
164
|
+
const onNotify = (request) => {
|
|
165
|
+
if (request.method !== "notify" || request.notifyType !== "error" || typeof request.message !== "string")
|
|
166
|
+
return;
|
|
167
|
+
if (request.message.includes(`MCP server ${name} `) || request.message.includes(`MCP server: ${name}`))
|
|
168
|
+
errors.push(request.message);
|
|
169
|
+
};
|
|
170
|
+
pi.onEvent(onEvent);
|
|
171
|
+
pi.onExtensionUI(onNotify);
|
|
172
|
+
return sessions.lifecycle
|
|
173
|
+
.use(s, () => pi.command({ type: "prompt", message: `/mcp ${args}`, expandPromptTemplates: true }, MCP_AUTH_COMMAND_TIMEOUT_MS))
|
|
174
|
+
.then((response) => {
|
|
175
|
+
if (!response.success)
|
|
176
|
+
throw new Error(response.error ?? "OmO rejected the /mcp command");
|
|
177
|
+
const disposition = isRecord(response.data) ? response.data.disposition : undefined;
|
|
178
|
+
if (disposition !== "handled")
|
|
179
|
+
throw new Error(`OmO did not handle /mcp (disposition: ${String(disposition)})`);
|
|
180
|
+
if (errors.length > 0)
|
|
181
|
+
throw new Error(errors.join("\n"));
|
|
182
|
+
})
|
|
183
|
+
.finally(() => {
|
|
184
|
+
pi.off("event", onEvent);
|
|
185
|
+
pi.off("extension_ui_request", onNotify);
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
function announce(services, directory, name) {
|
|
189
|
+
services.broadcast(directory, { type: "mcp.tools.changed", properties: { server: name } });
|
|
190
|
+
}
|
|
191
|
+
function pruneFlows() {
|
|
192
|
+
const now = Date.now();
|
|
193
|
+
for (const [key, flow] of flows)
|
|
194
|
+
if (!flow.running && (flow.s.pi !== flow.pi || now - flow.created > MCP_AUTH_ATTEMPT_TTL_MS))
|
|
195
|
+
flows.delete(key);
|
|
196
|
+
}
|
|
197
|
+
/** Concurrent clicks share one admission, so a second `/mcp auth` never races the first. */
|
|
198
|
+
function admitFlow(services, directory, name, command) {
|
|
199
|
+
const key = flowKey(directory, name);
|
|
200
|
+
const pending = admissions.get(key);
|
|
201
|
+
if (pending !== undefined)
|
|
202
|
+
return pending.then((admission) => "flow" in admission && admission.flow.command !== command ? inProgress(name) : admission);
|
|
203
|
+
const admission = startFlow(services, directory, name, command).finally(() => admissions.delete(key));
|
|
204
|
+
admissions.set(key, admission);
|
|
205
|
+
return admission;
|
|
206
|
+
}
|
|
207
|
+
const inProgress = (name) => ({
|
|
208
|
+
error: { status: 409, body: errorBody("McpAuthError", `MCP server ${name} authorization is already in progress`) },
|
|
209
|
+
});
|
|
210
|
+
/** Validates the target and starts (or joins) a flow; never sends a prompt without a `/mcp` command. */
|
|
211
|
+
async function startFlow(services, directory, name, command) {
|
|
212
|
+
if (!SERVER_NAME.test(name))
|
|
213
|
+
return { error: { status: 400, body: errorBody("InvalidRequestError", "invalid MCP server name") } };
|
|
214
|
+
const live = liveSession(services.sessions, directory);
|
|
215
|
+
if (live === undefined)
|
|
216
|
+
return {
|
|
217
|
+
error: {
|
|
218
|
+
status: 400,
|
|
219
|
+
body: errorBody("McpUnavailableError", "MCP authorization needs a running OmO session in this directory"),
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
const { s, pi } = live;
|
|
223
|
+
const key = flowKey(directory, name);
|
|
224
|
+
pruneFlows();
|
|
225
|
+
const existing = flows.get(key);
|
|
226
|
+
if (existing?.running && existing.pi === pi)
|
|
227
|
+
return existing.command === command ? { flow: existing } : inProgress(name);
|
|
228
|
+
const servers = await loadedServers(pi);
|
|
229
|
+
if (!servers.some((server) => server.name === name))
|
|
230
|
+
return { error: { status: 404, body: errorBody("McpServerNotFoundError", `MCP server not found: ${name}`) } };
|
|
231
|
+
if (!(await hasMcpCommand(pi)))
|
|
232
|
+
return { error: { status: 400, body: errorBody("UnsupportedError", "This OmO build has no /mcp command") } };
|
|
233
|
+
const url = Promise.withResolvers();
|
|
234
|
+
const flow = {
|
|
235
|
+
s,
|
|
236
|
+
pi,
|
|
237
|
+
command,
|
|
238
|
+
attemptID: randomUUID(),
|
|
239
|
+
created: Date.now(),
|
|
240
|
+
url: url.promise,
|
|
241
|
+
done: Promise.resolve(),
|
|
242
|
+
running: true,
|
|
243
|
+
authorized: false,
|
|
244
|
+
};
|
|
245
|
+
flow.done = runMcpCommand(services.sessions, s, pi, name, `${command} ${name}`, {
|
|
246
|
+
url: (found) => {
|
|
247
|
+
flow.authorizationUrl ??= found;
|
|
248
|
+
url.resolve(found);
|
|
249
|
+
},
|
|
250
|
+
authorized: () => {
|
|
251
|
+
flow.authorized = true;
|
|
252
|
+
},
|
|
253
|
+
})
|
|
254
|
+
.then(() => {
|
|
255
|
+
if (flow.authorized)
|
|
256
|
+
announce(services, directory, name);
|
|
257
|
+
})
|
|
258
|
+
.catch((error) => {
|
|
259
|
+
flow.error = errorMessage(error);
|
|
260
|
+
throw error;
|
|
261
|
+
})
|
|
262
|
+
.finally(() => {
|
|
263
|
+
flow.running = false;
|
|
264
|
+
});
|
|
265
|
+
// The caller that started the flow may already have its URL; a later failure is still reported.
|
|
266
|
+
void flow.done.catch((error) => console.error(`[mcp] authorization of ${name} failed: ${errorMessage(error)}`));
|
|
267
|
+
flows.set(key, flow);
|
|
268
|
+
return { flow };
|
|
269
|
+
}
|
|
270
|
+
async function awaitAuthorizationUrl(flow, name) {
|
|
271
|
+
const wait = AbortSignal.timeout(MCP_AUTH_URL_WAIT_MS);
|
|
272
|
+
const timedOut = new Promise((resolve) => wait.addEventListener("abort", () => resolve("timeout")));
|
|
273
|
+
try {
|
|
274
|
+
const url = await Promise.race([flow.url, flow.done.then(() => undefined), timedOut]);
|
|
275
|
+
if (url === "timeout")
|
|
276
|
+
return { error: `OmO did not produce an authorization URL for ${name}` };
|
|
277
|
+
if (url === undefined)
|
|
278
|
+
return { error: `MCP server ${name} finished without a browser step` };
|
|
279
|
+
return { url };
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
return { error: errorMessage(error) };
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
async function currentStatus(pi, name) {
|
|
286
|
+
return (await loadedServers(pi)).find((server) => server.name === name);
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Finishes a flow with the code or redirect the browser produced. A flow still waiting on
|
|
290
|
+
* OmO's loopback listener receives the redirect there (antechamber always runs beside its
|
|
291
|
+
* agent); a parked flow is finished with `/mcp auth-complete` in the process that holds its state.
|
|
292
|
+
*/
|
|
293
|
+
async function completeFlow(services, directory, name, input) {
|
|
294
|
+
const key = flowKey(directory, name);
|
|
295
|
+
const flow = flows.get(key);
|
|
296
|
+
const fail = (status, name_, message) => ({
|
|
297
|
+
error: { status, body: errorBody(name_, message) },
|
|
298
|
+
});
|
|
299
|
+
if (flow === undefined || flow.s.pi !== flow.pi || Date.now() - flow.created > MCP_AUTH_ATTEMPT_TTL_MS)
|
|
300
|
+
return fail(400, "McpAuthError", `No pending authorization for ${name}; start it again`);
|
|
301
|
+
if (flow.authorized)
|
|
302
|
+
return { server: await currentStatus(flow.pi, name) };
|
|
303
|
+
if (flow.error !== undefined)
|
|
304
|
+
return fail(400, "McpAuthError", `Authorization of ${name} failed (${flow.error}); start it again`);
|
|
305
|
+
if (typeof input !== "string" || flow.authorizationUrl === undefined)
|
|
306
|
+
return fail(400, "InvalidRequestError", "code is required");
|
|
307
|
+
const redirect = redirectInput(flow.authorizationUrl, input);
|
|
308
|
+
if (redirect === undefined)
|
|
309
|
+
return fail(400, "InvalidRequestError", "code must be an authorization code or redirect URL");
|
|
310
|
+
try {
|
|
311
|
+
if (flow.running && flow.command === "auth") {
|
|
312
|
+
const target = new URL(redirect);
|
|
313
|
+
if (!sameCallback(target, flow.authorizationUrl))
|
|
314
|
+
return fail(400, "McpAuthError", `OmO is waiting for ${name} on its loopback callback; paste that redirect`);
|
|
315
|
+
const delivered = await fetch(target, { redirect: "manual", signal: AbortSignal.timeout(MCP_READ_WAIT_MS) });
|
|
316
|
+
if (!delivered.ok) {
|
|
317
|
+
// OmO ends the flow on a rejected callback; settle it so a retry cannot join the dead flow.
|
|
318
|
+
await flow.done.catch(() => undefined);
|
|
319
|
+
return fail(400, "McpAuthError", `OmO rejected the authorization response for ${name}; start it again`);
|
|
320
|
+
}
|
|
321
|
+
await flow.done;
|
|
322
|
+
}
|
|
323
|
+
else {
|
|
324
|
+
await runMcpCommand(services.sessions, flow.s, flow.pi, name, `auth-complete ${name} ${redirect}`, {
|
|
325
|
+
authorized: () => {
|
|
326
|
+
flow.authorized = true;
|
|
327
|
+
},
|
|
328
|
+
});
|
|
329
|
+
announce(services, directory, name);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
catch (error) {
|
|
333
|
+
flow.error = errorMessage(error);
|
|
334
|
+
return fail(400, "McpAuthError", flow.error);
|
|
335
|
+
}
|
|
336
|
+
if (!flow.authorized)
|
|
337
|
+
return fail(400, "McpAuthError", flow.error ?? `MCP server ${name} did not complete authorization`);
|
|
338
|
+
return { server: await currentStatus(flow.pi, name) };
|
|
339
|
+
}
|
|
340
|
+
function decodeName(raw) {
|
|
341
|
+
try {
|
|
342
|
+
return decodeURIComponent(raw);
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
return undefined;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
const invalidName = { status: 400, body: errorBody("InvalidRequestError", "invalid MCP server name") };
|
|
349
|
+
export async function mcpRoute(services, method, path, directory, body) {
|
|
350
|
+
if (method === "GET" && path === "/mcp") {
|
|
351
|
+
const servers = await directoryServers(services.sessions, directory);
|
|
352
|
+
return { status: 200, body: Object.fromEntries(servers.map((server) => [server.name, openCodeMcpStatus(server)])) };
|
|
353
|
+
}
|
|
354
|
+
const match = /^\/mcp\/([^/]+)\/auth(\/authenticate|\/callback)?$/.exec(path);
|
|
355
|
+
if (method !== "POST" || match === null)
|
|
356
|
+
return undefined;
|
|
357
|
+
const name = decodeName(match[1]);
|
|
358
|
+
if (name === undefined)
|
|
359
|
+
return invalidName;
|
|
360
|
+
if (match[2] === "/callback") {
|
|
361
|
+
const payload = await body();
|
|
362
|
+
const result = await completeFlow(services, directory, name, isRecord(payload) ? payload.code : undefined);
|
|
363
|
+
if ("error" in result)
|
|
364
|
+
return result.error;
|
|
365
|
+
return {
|
|
366
|
+
status: 200,
|
|
367
|
+
body: result.server === undefined ? { status: "disabled" } : openCodeMcpStatus(result.server),
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
const admitted = await admitFlow(services, directory, name, "auth");
|
|
371
|
+
if ("error" in admitted)
|
|
372
|
+
return admitted.error;
|
|
373
|
+
const { flow } = admitted;
|
|
374
|
+
if (match[2] === "/authenticate") {
|
|
375
|
+
try {
|
|
376
|
+
await flow.done;
|
|
377
|
+
}
|
|
378
|
+
catch (error) {
|
|
379
|
+
return { status: 400, body: errorBody("McpAuthError", errorMessage(error)) };
|
|
380
|
+
}
|
|
381
|
+
if (!flow.authorized)
|
|
382
|
+
return {
|
|
383
|
+
status: 400,
|
|
384
|
+
body: errorBody("McpAuthError", `MCP server ${name} is waiting for its authorization code; paste it to finish`),
|
|
385
|
+
};
|
|
386
|
+
const server = await currentStatus(flow.pi, name);
|
|
387
|
+
return { status: 200, body: server === undefined ? { status: "disabled" } : openCodeMcpStatus(server) };
|
|
388
|
+
}
|
|
389
|
+
const announced = await awaitAuthorizationUrl(flow, name);
|
|
390
|
+
if ("error" in announced)
|
|
391
|
+
return { status: 400, body: errorBody("McpAuthError", announced.error) };
|
|
392
|
+
return {
|
|
393
|
+
status: 200,
|
|
394
|
+
body: { authorizationUrl: announced.url, oauthState: new URL(announced.url).searchParams.get("state") ?? "" },
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
function integrationInfo(server) {
|
|
398
|
+
return {
|
|
399
|
+
id: `${INTEGRATION_PREFIX}${server.name}`,
|
|
400
|
+
name: server.name,
|
|
401
|
+
metadata: { source: "mcp" },
|
|
402
|
+
methods: [{ id: OAUTH_METHOD_ID, type: "oauth", label: "Sign in with OAuth" }],
|
|
403
|
+
connections: [],
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
function attemptTime(flow) {
|
|
407
|
+
return { created: flow.created, expires: flow.created + MCP_AUTH_ATTEMPT_TTL_MS };
|
|
408
|
+
}
|
|
409
|
+
function flowForAttempt(directory, name, attemptID) {
|
|
410
|
+
const flow = flows.get(flowKey(directory, name));
|
|
411
|
+
return flow?.attemptID === attemptID ? flow : undefined;
|
|
412
|
+
}
|
|
413
|
+
export async function mcpRouteV2(services, method, path, directory, body) {
|
|
414
|
+
const located = (status, data) => ({ status, body: { location: { directory }, data } });
|
|
415
|
+
if (method === "GET" && path === "/api/mcp") {
|
|
416
|
+
const servers = await directoryServers(services.sessions, directory);
|
|
417
|
+
return located(200, servers.map((server) => ({
|
|
418
|
+
name: server.name,
|
|
419
|
+
status: v2McpStatus(server),
|
|
420
|
+
...(oauthCapable(server) ? { integrationID: `${INTEGRATION_PREFIX}${server.name}` } : {}),
|
|
421
|
+
})));
|
|
422
|
+
}
|
|
423
|
+
if (method === "GET" && path === "/api/integration") {
|
|
424
|
+
const servers = await directoryServers(services.sessions, directory);
|
|
425
|
+
return located(200, servers.filter(oauthCapable).map(integrationInfo));
|
|
426
|
+
}
|
|
427
|
+
const match = /^\/api\/integration\/mcp_([^/]+)(\/connect\/oauth(?:\/([^/]+)(\/complete)?)?)?$/.exec(path);
|
|
428
|
+
if (match === null)
|
|
429
|
+
return undefined;
|
|
430
|
+
const name = decodeName(match[1]);
|
|
431
|
+
if (name === undefined)
|
|
432
|
+
return invalidName;
|
|
433
|
+
const [, , connect, rawAttempt, complete] = match;
|
|
434
|
+
if (connect === undefined) {
|
|
435
|
+
if (method !== "GET")
|
|
436
|
+
return undefined;
|
|
437
|
+
const server = (await directoryServers(services.sessions, directory)).find((candidate) => candidate.name === name && oauthCapable(candidate));
|
|
438
|
+
if (server === undefined)
|
|
439
|
+
return { status: 404, body: errorBody("NotFoundError", `integration not found: ${INTEGRATION_PREFIX}${name}`) };
|
|
440
|
+
return located(200, integrationInfo(server));
|
|
441
|
+
}
|
|
442
|
+
if (rawAttempt === undefined) {
|
|
443
|
+
if (method !== "POST")
|
|
444
|
+
return undefined;
|
|
445
|
+
const payload = await body();
|
|
446
|
+
if (isRecord(payload) && payload.methodID !== undefined && payload.methodID !== OAUTH_METHOD_ID)
|
|
447
|
+
return { status: 404, body: errorBody("IntegrationMethodNotFoundError", "unknown integration method") };
|
|
448
|
+
const admitted = await admitFlow(services, directory, name, "auth-start");
|
|
449
|
+
if ("error" in admitted)
|
|
450
|
+
return admitted.error;
|
|
451
|
+
const announced = await awaitAuthorizationUrl(admitted.flow, name);
|
|
452
|
+
if ("error" in announced)
|
|
453
|
+
return { status: 400, body: errorBody("McpAuthError", announced.error) };
|
|
454
|
+
return located(200, {
|
|
455
|
+
attemptID: admitted.flow.attemptID,
|
|
456
|
+
url: announced.url,
|
|
457
|
+
instructions: "Approve access in the browser, then paste the final redirect URL (or the code it carries) to finish.",
|
|
458
|
+
mode: "code",
|
|
459
|
+
time: attemptTime(admitted.flow),
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
const attemptID = decodeName(rawAttempt);
|
|
463
|
+
const flow = attemptID === undefined ? undefined : flowForAttempt(directory, name, attemptID);
|
|
464
|
+
const missing = {
|
|
465
|
+
status: 404,
|
|
466
|
+
body: errorBody("IntegrationAttemptNotFoundError", `authorization attempt not found: ${rawAttempt}`),
|
|
467
|
+
};
|
|
468
|
+
if (complete !== undefined) {
|
|
469
|
+
if (method !== "POST")
|
|
470
|
+
return undefined;
|
|
471
|
+
if (flow === undefined)
|
|
472
|
+
return missing;
|
|
473
|
+
const payload = await body();
|
|
474
|
+
const result = await completeFlow(services, directory, name, isRecord(payload) ? payload.code : undefined);
|
|
475
|
+
return "error" in result ? result.error : { status: 204 };
|
|
476
|
+
}
|
|
477
|
+
if (method === "DELETE") {
|
|
478
|
+
if (flow !== undefined)
|
|
479
|
+
flows.delete(flowKey(directory, name));
|
|
480
|
+
return { status: 204 };
|
|
481
|
+
}
|
|
482
|
+
if (method !== "GET")
|
|
483
|
+
return undefined;
|
|
484
|
+
if (flow === undefined)
|
|
485
|
+
return missing;
|
|
486
|
+
const time = attemptTime(flow);
|
|
487
|
+
if (flow.authorized)
|
|
488
|
+
return located(200, { status: "complete", time });
|
|
489
|
+
if (flow.error !== undefined)
|
|
490
|
+
return located(200, { status: "failed", message: flow.error, time });
|
|
491
|
+
if (Date.now() > time.expires)
|
|
492
|
+
return located(200, { status: "expired", time });
|
|
493
|
+
return located(200, { status: "pending", time });
|
|
494
|
+
}
|
package/dist/misc-routes.js
CHANGED
|
@@ -45,15 +45,14 @@ export async function miscRoute(method, path, url, directory) {
|
|
|
45
45
|
if (method === "GET" && ["/skill", "/lsp", "/formatter", "/experimental/tool/ids"].includes(path)) {
|
|
46
46
|
return { status: 200, body: [] };
|
|
47
47
|
}
|
|
48
|
-
if (method === "GET" &&
|
|
48
|
+
if (method === "GET" && path === "/provider/auth")
|
|
49
49
|
return { status: 200, body: {} };
|
|
50
50
|
if ((method === "PUT" && /^\/auth\/[^/]+$/.test(path)) ||
|
|
51
51
|
(method === "POST" && /^\/provider\/[^/]+\/oauth\/(authorize|callback)$/.test(path)) ||
|
|
52
52
|
(method === "PATCH" && path === "/config")) {
|
|
53
53
|
return unsupported("Not supported with OmO; configure providers in OmO");
|
|
54
54
|
}
|
|
55
|
-
if ((["POST", "DELETE"].includes(method) &&
|
|
56
|
-
/^\/mcp\/[^/]+\/(connect|disconnect|auth(?:\/(?:callback|authenticate))?)$/.test(path)) ||
|
|
55
|
+
if ((["POST", "DELETE"].includes(method) && /^\/mcp\/[^/]+\/(connect|disconnect|auth)$/.test(path)) ||
|
|
57
56
|
(method === "POST" && path === "/experimental/control-plane/move-session")) {
|
|
58
57
|
return unsupported("Not supported with OmO; configure MCP and session directories in OmO");
|
|
59
58
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ManagedSession } from "./sessions.js";
|
|
2
|
+
import type { OCEvent, OCMessageWithParts } from "./types.js";
|
|
3
|
+
/** Names OmO's terminal extension passes to `pi.rpc.emit` (core/extensions/builtin/monitor-state-event.js). */
|
|
4
|
+
export declare const MONITOR_STATE_EVENT = "terminal_monitor_state";
|
|
5
|
+
export declare const MONITOR_ENDED_EVENT = "terminal_monitor_ended";
|
|
6
|
+
export interface MonitorSnapshot {
|
|
7
|
+
id: string;
|
|
8
|
+
description: string;
|
|
9
|
+
command: string | null;
|
|
10
|
+
filter: string | null;
|
|
11
|
+
paused: boolean;
|
|
12
|
+
persistent: boolean;
|
|
13
|
+
startedAtMs: number;
|
|
14
|
+
deadlineMs: number | null;
|
|
15
|
+
fireCount: number;
|
|
16
|
+
lastFiredAtMs: number | null;
|
|
17
|
+
}
|
|
18
|
+
interface MonitorEnding {
|
|
19
|
+
reason: string;
|
|
20
|
+
exitCode: number | null;
|
|
21
|
+
endedAtMs: number;
|
|
22
|
+
}
|
|
23
|
+
export declare function parseMonitor(value: unknown): MonitorSnapshot | undefined;
|
|
24
|
+
export declare function monitorSummary(monitor: MonitorSnapshot, ending?: MonitorEnding): string;
|
|
25
|
+
export declare class MonitorSessions {
|
|
26
|
+
private readonly sessions;
|
|
27
|
+
private readonly emit;
|
|
28
|
+
private readonly watches;
|
|
29
|
+
/** Children the user deleted; a later snapshot of the same watch must not resurrect them. */
|
|
30
|
+
private readonly dismissed;
|
|
31
|
+
constructor(sessions: Map<string, ManagedSession>, emit: (directory: string, event: OCEvent) => void);
|
|
32
|
+
wire(parent: ManagedSession): void;
|
|
33
|
+
owns(id: string): boolean;
|
|
34
|
+
messages(id: string): OCMessageWithParts[];
|
|
35
|
+
observe(parent: ManagedSession, ev: Record<string, unknown>): void;
|
|
36
|
+
/** The agent process is gone: OmO's in-process watches died with it. */
|
|
37
|
+
stopParent(parentID: string): void;
|
|
38
|
+
/** Forget a deleted child, or every child of a deleted parent. */
|
|
39
|
+
remove(id: string): void;
|
|
40
|
+
private running;
|
|
41
|
+
private childID;
|
|
42
|
+
private applyState;
|
|
43
|
+
private applyEnded;
|
|
44
|
+
private end;
|
|
45
|
+
private create;
|
|
46
|
+
private publish;
|
|
47
|
+
}
|
|
48
|
+
export {};
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OmO terminal monitors as read-only child sessions. OpenChamber has no surface for
|
|
3
|
+
* background watches, but it already lists child sessions under Subagents and gives each
|
|
4
|
+
* a live dot, so a watch is busy while OmO reports it and idle once it ends.
|
|
5
|
+
*/
|
|
6
|
+
import { getAgentName } from "./agent-name.js";
|
|
7
|
+
import { ocIdFor } from "./session-store.js";
|
|
8
|
+
/** Names OmO's terminal extension passes to `pi.rpc.emit` (core/extensions/builtin/monitor-state-event.js). */
|
|
9
|
+
export const MONITOR_STATE_EVENT = "terminal_monitor_state";
|
|
10
|
+
export const MONITOR_ENDED_EVENT = "terminal_monitor_ended";
|
|
11
|
+
function record(value) {
|
|
12
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
13
|
+
}
|
|
14
|
+
function finite(value) {
|
|
15
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
16
|
+
}
|
|
17
|
+
function text(value) {
|
|
18
|
+
return typeof value === "string" && value !== "" ? value : null;
|
|
19
|
+
}
|
|
20
|
+
export function parseMonitor(value) {
|
|
21
|
+
if (!record(value) || typeof value.id !== "string" || value.id === "")
|
|
22
|
+
return undefined;
|
|
23
|
+
const startedAtMs = finite(value.startedAtMs);
|
|
24
|
+
if (startedAtMs === null)
|
|
25
|
+
return undefined;
|
|
26
|
+
return {
|
|
27
|
+
id: value.id,
|
|
28
|
+
description: text(value.description) ?? value.id,
|
|
29
|
+
command: text(value.command),
|
|
30
|
+
filter: text(value.filter),
|
|
31
|
+
paused: value.paused === true,
|
|
32
|
+
persistent: value.persistent === true,
|
|
33
|
+
startedAtMs,
|
|
34
|
+
deadlineMs: finite(value.deadlineMs),
|
|
35
|
+
fireCount: finite(value.fireCount) ?? 0,
|
|
36
|
+
lastFiredAtMs: finite(value.lastFiredAtMs),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function endingLabel(ending) {
|
|
40
|
+
switch (ending.reason) {
|
|
41
|
+
case "exit":
|
|
42
|
+
return ending.exitCode === null ? "command exited" : `command exited with code ${ending.exitCode}`;
|
|
43
|
+
case "timeout":
|
|
44
|
+
return "deadline reached";
|
|
45
|
+
case "killed":
|
|
46
|
+
return "stopped";
|
|
47
|
+
case "disposed":
|
|
48
|
+
return "suspended by an OmO shutdown or reload";
|
|
49
|
+
case "agent-exit":
|
|
50
|
+
return "OmO process exited";
|
|
51
|
+
default:
|
|
52
|
+
return "ended";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function iso(ms) {
|
|
56
|
+
return ms === null ? undefined : new Date(ms).toISOString();
|
|
57
|
+
}
|
|
58
|
+
export function monitorSummary(monitor, ending) {
|
|
59
|
+
const deadline = monitor.persistent ? "none (persistent)" : iso(monitor.deadlineMs);
|
|
60
|
+
return [
|
|
61
|
+
`Status: ${ending ? endingLabel(ending) : monitor.paused ? "paused" : "watching"}`,
|
|
62
|
+
monitor.filter !== null ? `Filter: ${monitor.filter}` : undefined,
|
|
63
|
+
`Events: ${monitor.fireCount}`,
|
|
64
|
+
monitor.lastFiredAtMs !== null ? `Last event: ${iso(monitor.lastFiredAtMs)}` : undefined,
|
|
65
|
+
`Started: ${iso(monitor.startedAtMs)}`,
|
|
66
|
+
ending ? `Ended: ${iso(ending.endedAtMs)}` : deadline !== undefined ? `Deadline: ${deadline}` : undefined,
|
|
67
|
+
]
|
|
68
|
+
.filter((line) => line !== undefined)
|
|
69
|
+
.join("\n");
|
|
70
|
+
}
|
|
71
|
+
export class MonitorSessions {
|
|
72
|
+
sessions;
|
|
73
|
+
emit;
|
|
74
|
+
watches = new Map();
|
|
75
|
+
/** Children the user deleted; a later snapshot of the same watch must not resurrect them. */
|
|
76
|
+
dismissed = new Set();
|
|
77
|
+
constructor(sessions, emit) {
|
|
78
|
+
this.sessions = sessions;
|
|
79
|
+
this.emit = emit;
|
|
80
|
+
}
|
|
81
|
+
wire(parent) {
|
|
82
|
+
parent.hasBackgroundWork = () => this.running(parent.info.id);
|
|
83
|
+
}
|
|
84
|
+
owns(id) {
|
|
85
|
+
return this.watches.has(id);
|
|
86
|
+
}
|
|
87
|
+
messages(id) {
|
|
88
|
+
return this.watches.get(id)?.session.messages ?? [];
|
|
89
|
+
}
|
|
90
|
+
observe(parent, ev) {
|
|
91
|
+
if (!record(ev.data))
|
|
92
|
+
return;
|
|
93
|
+
if (ev.name === MONITOR_STATE_EVENT && Array.isArray(ev.data.monitors))
|
|
94
|
+
this.applyState(parent, ev.data.monitors);
|
|
95
|
+
else if (ev.name === MONITOR_ENDED_EVENT)
|
|
96
|
+
this.applyEnded(parent, ev.data);
|
|
97
|
+
}
|
|
98
|
+
/** The agent process is gone: OmO's in-process watches died with it. */
|
|
99
|
+
stopParent(parentID) {
|
|
100
|
+
for (const watch of this.watches.values()) {
|
|
101
|
+
if (watch.session.info.parentID !== parentID || watch.ending !== undefined)
|
|
102
|
+
continue;
|
|
103
|
+
this.end(watch, { reason: "agent-exit", exitCode: null, endedAtMs: Date.now() });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** Forget a deleted child, or every child of a deleted parent. */
|
|
107
|
+
remove(id) {
|
|
108
|
+
for (const [childID, watch] of this.watches) {
|
|
109
|
+
if (childID !== id && watch.session.info.parentID !== id)
|
|
110
|
+
continue;
|
|
111
|
+
this.watches.delete(childID);
|
|
112
|
+
this.dismissed.add(childID);
|
|
113
|
+
if (this.sessions.get(childID) !== watch.session)
|
|
114
|
+
continue;
|
|
115
|
+
this.sessions.delete(childID);
|
|
116
|
+
this.emit(watch.session.info.directory, { type: "session.deleted", properties: { info: watch.session.info } });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
running(parentID) {
|
|
120
|
+
for (const watch of this.watches.values()) {
|
|
121
|
+
if (watch.session.info.parentID === parentID && watch.ending === undefined)
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
childID(parent, monitorID) {
|
|
127
|
+
return ocIdFor(`${parent.info.id}/monitor/${monitorID}`);
|
|
128
|
+
}
|
|
129
|
+
applyState(parent, entries) {
|
|
130
|
+
const live = new Set();
|
|
131
|
+
for (const entry of entries) {
|
|
132
|
+
const monitor = parseMonitor(entry);
|
|
133
|
+
if (!monitor)
|
|
134
|
+
continue;
|
|
135
|
+
const id = this.childID(parent, monitor.id);
|
|
136
|
+
live.add(id);
|
|
137
|
+
if (this.dismissed.has(id))
|
|
138
|
+
continue;
|
|
139
|
+
const watch = this.watches.get(id);
|
|
140
|
+
if (watch === undefined)
|
|
141
|
+
this.create(parent, id, monitor);
|
|
142
|
+
else {
|
|
143
|
+
watch.monitor = monitor;
|
|
144
|
+
delete watch.ending;
|
|
145
|
+
this.publish(watch);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// A snapshot lists every live watch; one that is missing has ended. Its reason may follow.
|
|
149
|
+
for (const [id, watch] of this.watches) {
|
|
150
|
+
if (watch.session.info.parentID !== parent.info.id || watch.ending !== undefined || live.has(id))
|
|
151
|
+
continue;
|
|
152
|
+
this.end(watch, { reason: "ended", exitCode: null, endedAtMs: Date.now() });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
applyEnded(parent, data) {
|
|
156
|
+
if (typeof data.id !== "string")
|
|
157
|
+
return;
|
|
158
|
+
const watch = this.watches.get(this.childID(parent, data.id));
|
|
159
|
+
if (!watch)
|
|
160
|
+
return;
|
|
161
|
+
const fireCount = finite(data.fireCount);
|
|
162
|
+
if (fireCount !== null)
|
|
163
|
+
watch.monitor.fireCount = fireCount;
|
|
164
|
+
this.end(watch, {
|
|
165
|
+
reason: typeof data.reason === "string" ? data.reason : "ended",
|
|
166
|
+
exitCode: finite(data.exitCode),
|
|
167
|
+
endedAtMs: finite(data.endedAtMs) ?? Date.now(),
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
end(watch, ending) {
|
|
171
|
+
watch.ending = ending;
|
|
172
|
+
this.publish(watch);
|
|
173
|
+
}
|
|
174
|
+
create(parent, id, monitor) {
|
|
175
|
+
const now = Date.now();
|
|
176
|
+
const session = {
|
|
177
|
+
info: {
|
|
178
|
+
id,
|
|
179
|
+
parentID: parent.info.id,
|
|
180
|
+
projectID: parent.info.projectID,
|
|
181
|
+
directory: parent.info.directory,
|
|
182
|
+
title: `Monitor: ${monitor.description}`,
|
|
183
|
+
version: parent.info.version,
|
|
184
|
+
time: { created: monitor.startedAtMs, updated: now },
|
|
185
|
+
},
|
|
186
|
+
pi: undefined,
|
|
187
|
+
messages: [],
|
|
188
|
+
model: parent.model,
|
|
189
|
+
streaming: undefined,
|
|
190
|
+
status: { type: "idle" },
|
|
191
|
+
toolByCallId: new Map(),
|
|
192
|
+
tree: { messageEntries: {} },
|
|
193
|
+
hasPendingInteraction: () => false,
|
|
194
|
+
hasRunningChildren: () => false,
|
|
195
|
+
};
|
|
196
|
+
const watch = { session, monitor };
|
|
197
|
+
this.watches.set(id, watch);
|
|
198
|
+
this.sessions.set(id, session);
|
|
199
|
+
this.emit(session.info.directory, { type: "session.created", properties: { info: session.info } });
|
|
200
|
+
this.publish(watch);
|
|
201
|
+
}
|
|
202
|
+
publish(watch) {
|
|
203
|
+
const { session, monitor, ending } = watch;
|
|
204
|
+
const directory = session.info.directory;
|
|
205
|
+
const sessionID = session.info.id;
|
|
206
|
+
const base = sessionID.replace(/^ses_/, "");
|
|
207
|
+
const model = session.model ?? { providerID: "omo", modelID: "monitor" };
|
|
208
|
+
const started = monitor.startedAtMs;
|
|
209
|
+
const command = monitor.command ?? monitor.description;
|
|
210
|
+
const input = { command, description: monitor.description };
|
|
211
|
+
const output = monitorSummary(monitor, ending);
|
|
212
|
+
const tool = {
|
|
213
|
+
id: `prt_${base}_tool`,
|
|
214
|
+
sessionID,
|
|
215
|
+
messageID: `msg_${base}_1`,
|
|
216
|
+
type: "tool",
|
|
217
|
+
callID: `call_${base}`,
|
|
218
|
+
tool: "bash",
|
|
219
|
+
state: ending
|
|
220
|
+
? {
|
|
221
|
+
status: "completed",
|
|
222
|
+
input,
|
|
223
|
+
output,
|
|
224
|
+
title: monitor.description,
|
|
225
|
+
metadata: { monitor, ending },
|
|
226
|
+
time: { start: started, end: ending.endedAtMs },
|
|
227
|
+
}
|
|
228
|
+
: {
|
|
229
|
+
status: "running",
|
|
230
|
+
input,
|
|
231
|
+
title: monitor.description,
|
|
232
|
+
metadata: { monitor, output },
|
|
233
|
+
time: { start: started },
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
const messages = [
|
|
237
|
+
{
|
|
238
|
+
info: {
|
|
239
|
+
id: `msg_${base}_0`,
|
|
240
|
+
sessionID,
|
|
241
|
+
role: "user",
|
|
242
|
+
time: { created: started },
|
|
243
|
+
agent: getAgentName(),
|
|
244
|
+
model,
|
|
245
|
+
},
|
|
246
|
+
parts: [
|
|
247
|
+
{
|
|
248
|
+
id: `prt_${base}_request`,
|
|
249
|
+
sessionID,
|
|
250
|
+
messageID: `msg_${base}_0`,
|
|
251
|
+
type: "text",
|
|
252
|
+
text: "The agent started a background monitor",
|
|
253
|
+
synthetic: true,
|
|
254
|
+
time: { start: started, end: started },
|
|
255
|
+
},
|
|
256
|
+
],
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
info: {
|
|
260
|
+
id: `msg_${base}_1`,
|
|
261
|
+
sessionID,
|
|
262
|
+
role: "assistant",
|
|
263
|
+
time: { created: started, ...(ending ? { completed: ending.endedAtMs } : {}) },
|
|
264
|
+
parentID: `msg_${base}_0`,
|
|
265
|
+
modelID: model.modelID,
|
|
266
|
+
providerID: model.providerID,
|
|
267
|
+
mode: getAgentName(),
|
|
268
|
+
path: { cwd: directory, root: directory },
|
|
269
|
+
cost: 0,
|
|
270
|
+
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
271
|
+
...(ending ? { finish: "stop" } : {}),
|
|
272
|
+
},
|
|
273
|
+
parts: [tool],
|
|
274
|
+
},
|
|
275
|
+
];
|
|
276
|
+
const before = new Map((session.messages ?? []).map((message) => [message.info.id, JSON.stringify(message)]));
|
|
277
|
+
session.messages = messages;
|
|
278
|
+
for (const message of messages) {
|
|
279
|
+
if (before.get(message.info.id) === JSON.stringify(message))
|
|
280
|
+
continue;
|
|
281
|
+
this.emit(directory, { type: "message.updated", properties: { info: message.info } });
|
|
282
|
+
for (const part of message.parts)
|
|
283
|
+
this.emit(directory, { type: "message.part.updated", properties: { part } });
|
|
284
|
+
}
|
|
285
|
+
const status = ending ? "idle" : "busy";
|
|
286
|
+
if (session.status.type === status)
|
|
287
|
+
return;
|
|
288
|
+
session.status = { type: status };
|
|
289
|
+
session.info.time.updated = Date.now();
|
|
290
|
+
this.emit(directory, { type: "session.updated", properties: { info: session.info } });
|
|
291
|
+
this.emit(directory, { type: "session.status", properties: { sessionID, status: session.status } });
|
|
292
|
+
if (status === "idle")
|
|
293
|
+
this.emit(directory, { type: "session.idle", properties: { sessionID } });
|
|
294
|
+
}
|
|
295
|
+
}
|
|
@@ -3,6 +3,7 @@ import type { OCAssistantMessage, OCMessageWithParts, OCModelRef, OCEvent, OCSes
|
|
|
3
3
|
import type { ManagedSession } from "./sessions.js";
|
|
4
4
|
import type { SessionStore } from "./session-store.js";
|
|
5
5
|
import type { SubagentBridge } from "./subagents.js";
|
|
6
|
+
import type { MonitorSessions } from "./monitor-sessions.js";
|
|
6
7
|
import { type TodoUpdated } from "./todo-bridge.js";
|
|
7
8
|
export interface PiUsage {
|
|
8
9
|
input?: number;
|
|
@@ -38,6 +39,7 @@ export declare function compactionMessages(s: ManagedSession, key: string, summa
|
|
|
38
39
|
interface PiEventServices {
|
|
39
40
|
readonly emit: (directory: string, event: OCEvent | TodoUpdated) => void;
|
|
40
41
|
readonly subagents: SubagentBridge;
|
|
42
|
+
readonly monitors: MonitorSessions;
|
|
41
43
|
readonly store: Pick<SessionStore, "update">;
|
|
42
44
|
readonly dirtyDiffs: Set<string>;
|
|
43
45
|
readonly ensureMessages: (s: ManagedSession) => OCMessageWithParts[];
|
|
@@ -284,6 +284,9 @@ export class PiEventTranslator {
|
|
|
284
284
|
case "compaction_end":
|
|
285
285
|
this.onCompactionEvent(s, ev);
|
|
286
286
|
break;
|
|
287
|
+
case "extension_event":
|
|
288
|
+
this.services.monitors.observe(s, ev);
|
|
289
|
+
break;
|
|
287
290
|
default:
|
|
288
291
|
break; // turn_start, queue_update — future milestones
|
|
289
292
|
}
|
package/dist/pi-rpc.js
CHANGED
|
@@ -93,6 +93,8 @@ export class PiRpcClient extends EventEmitter {
|
|
|
93
93
|
...(process.env.SENPI_RPC_CLIENT_CAPABILITIES ?? "").split(","),
|
|
94
94
|
"auto_title_sessions",
|
|
95
95
|
"question",
|
|
96
|
+
// Extension `pi.rpc.emit` frames, e.g. the terminal extension's monitor state.
|
|
97
|
+
"extension_events",
|
|
96
98
|
]
|
|
97
99
|
.map((value) => value.trim())
|
|
98
100
|
.filter(Boolean)),
|
package/dist/routes-v1.js
CHANGED
|
@@ -3,6 +3,7 @@ import { execFile } from "node:child_process";
|
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { errorBody, json } from "./http.js";
|
|
5
5
|
import { miscRoute } from "./misc-routes.js";
|
|
6
|
+
import { mcpRoute } from "./mcp.js";
|
|
6
7
|
import { getAgentName } from "./agent-name.js";
|
|
7
8
|
import { nativePromptInput, parsePromptParts } from "./prompt-parts.js";
|
|
8
9
|
import { RequestBodyError } from "./http.js";
|
|
@@ -63,6 +64,11 @@ function parseV1Prompt(body, defaultModel) {
|
|
|
63
64
|
export async function handleV1(services, req, res, url, path, method, directory) {
|
|
64
65
|
if (await handleV1GlobalRoute(services, res, path, method))
|
|
65
66
|
return;
|
|
67
|
+
const mcp = await mcpRoute(services, method, path, directory, () => services.body(req));
|
|
68
|
+
if (mcp !== undefined) {
|
|
69
|
+
json(res, mcp.status, mcp.body);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
66
72
|
// --- sessions ---
|
|
67
73
|
const misc = await miscRoute(method, path, url, directory);
|
|
68
74
|
if (misc !== undefined) {
|
|
@@ -376,10 +382,6 @@ export async function handleV1(services, req, res, url, path, method, directory)
|
|
|
376
382
|
json(res, 200, []);
|
|
377
383
|
return;
|
|
378
384
|
}
|
|
379
|
-
if (path === "/mcp" && method === "GET") {
|
|
380
|
-
json(res, 200, {});
|
|
381
|
-
return;
|
|
382
|
-
}
|
|
383
385
|
if (path === "/command" && method === "GET") {
|
|
384
386
|
json(res, 200, (await getCommands(directory)).map((command) => ({
|
|
385
387
|
...command,
|
package/dist/routes-v2.js
CHANGED
|
@@ -6,6 +6,7 @@ import { promisify } from "node:util";
|
|
|
6
6
|
import { errorBody, json, RequestBodyError } from "./http.js";
|
|
7
7
|
import { nativePromptInput, parsePromptParts, v2PromptAttachments, v2PromptParts } from "./prompt-parts.js";
|
|
8
8
|
import { debugLog, miscRoute } from "./misc-routes.js";
|
|
9
|
+
import { mcpRouteV2 } from "./mcp.js";
|
|
9
10
|
import { OPENCODE_API_VERSION } from "./version.js";
|
|
10
11
|
import { toV2Permission } from "./approvals.js";
|
|
11
12
|
import { formQuestionAnswer, questionFormID, toQuestionForm } from "./question-forms.js";
|
|
@@ -192,8 +193,12 @@ export async function handleV2(services, req, res, url, path, method, directory)
|
|
|
192
193
|
res.writeHead(204).end();
|
|
193
194
|
return;
|
|
194
195
|
}
|
|
195
|
-
|
|
196
|
-
|
|
196
|
+
const mcp = await mcpRouteV2(services, method, path, directory, () => services.body(req));
|
|
197
|
+
if (mcp !== undefined) {
|
|
198
|
+
if (mcp.status === 204)
|
|
199
|
+
res.writeHead(204).end();
|
|
200
|
+
else
|
|
201
|
+
json(res, mcp.status, mcp.body);
|
|
197
202
|
return;
|
|
198
203
|
}
|
|
199
204
|
const integrationMatch = path.match(/^\/api\/integration\/([^/]+)$/);
|
|
@@ -226,9 +231,10 @@ export async function handleV2(services, req, res, url, path, method, directory)
|
|
|
226
231
|
});
|
|
227
232
|
return;
|
|
228
233
|
}
|
|
229
|
-
// Read-only inventories OpenChamber loads at bootstrap
|
|
230
|
-
// web-search providers
|
|
231
|
-
|
|
234
|
+
// Read-only inventories OpenChamber loads at bootstrap. OmO has no OpenCode-style shells or
|
|
235
|
+
// web-search providers, so each is an honest empty list (mutations stay 400);
|
|
236
|
+
// OmO monitors appear as child sessions instead (monitor-sessions.ts).
|
|
237
|
+
if ((path === "/api/shell" || path === "/api/websearch/provider") && method === "GET") {
|
|
232
238
|
json(res, 200, withLocation([]));
|
|
233
239
|
return;
|
|
234
240
|
}
|
package/dist/sessions.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { ApprovalBridge, type PendingPermission, type PendingQuestion } from "./
|
|
|
9
9
|
import type { TodoBridge, Todo, TodoUpdated } from "./todo-bridge.js";
|
|
10
10
|
import { type SessionTreeState } from "./session-tree.js";
|
|
11
11
|
import { SubagentBridge } from "./subagents.js";
|
|
12
|
+
import { MonitorSessions } from "./monitor-sessions.js";
|
|
12
13
|
import { SessionStore, type TitleSource } from "./session-store.js";
|
|
13
14
|
import type { OCAssistantMessage, OCEvent, OCMessageWithParts, OCModelRef, OCPart, OCPromptPart, OCSession, OCSessionStatus, OCToolPart } from "./types.js";
|
|
14
15
|
export { ADAPTER_VERSION } from "./version.js";
|
|
@@ -65,6 +66,8 @@ export interface ManagedSession extends ThinkingState {
|
|
|
65
66
|
/** Hooks wired by the approvals and child-task bridges. */
|
|
66
67
|
hasPendingInteraction: () => boolean;
|
|
67
68
|
hasRunningChildren: () => boolean;
|
|
69
|
+
/** Live OmO background work (monitors) that keeps the agent process from idle eviction. */
|
|
70
|
+
hasBackgroundWork?: () => boolean;
|
|
68
71
|
}
|
|
69
72
|
type EventSink = (directory: string, event: OCEvent | TodoUpdated) => void;
|
|
70
73
|
export declare class SessionManager {
|
|
@@ -82,6 +85,7 @@ export declare class SessionManager {
|
|
|
82
85
|
private readonly store;
|
|
83
86
|
readonly lifecycle: SessionLifecycle;
|
|
84
87
|
readonly subagents: SubagentBridge;
|
|
88
|
+
readonly monitors: MonitorSessions;
|
|
85
89
|
/** directory → last scan freshness (pi dir mtime + wall clock). */
|
|
86
90
|
private scanCache;
|
|
87
91
|
/** freshness of the all-directories scan (see listAll). */
|
|
@@ -100,6 +104,8 @@ export declare class SessionManager {
|
|
|
100
104
|
private wirePi;
|
|
101
105
|
/** pi writes `<ts>_<sessionId>.jsonl` at startup — poll briefly for it. */
|
|
102
106
|
private resolvePiFile;
|
|
107
|
+
/** Sessions with a running agent in `directory`, most recently used first; never revives a dormant one. */
|
|
108
|
+
liveSessions(directory: string): ManagedSession[];
|
|
103
109
|
list(directory?: string): OCSession[];
|
|
104
110
|
/**
|
|
105
111
|
* Every restorable session on disk (all directories) plus live ones. Backs the
|
package/dist/sessions.js
CHANGED
|
@@ -16,6 +16,7 @@ import { getAgentName, getExposedAgentNames } from "./agent-name.js";
|
|
|
16
16
|
import { RequestBodyError } from "./http.js";
|
|
17
17
|
import { bindMessageEntries, durableShellTree, forkConversation, getEntries, navigate, readSessionTree, revertConversation, SessionTreeError, unrevertConversation, } from "./session-tree.js";
|
|
18
18
|
import { SubagentBridge } from "./subagents.js";
|
|
19
|
+
import { MonitorSessions } from "./monitor-sessions.js";
|
|
19
20
|
import { sessionDiff } from "./session-diff.js";
|
|
20
21
|
import { announceSessionNotice, slashTitlePrompt } from "./session-commands.js";
|
|
21
22
|
import { ocIdFor, piSessionDir, piSessionsRootMtime, scanAllPiSessions, scanPiSessions, SessionStore, readSessionTitle, } from "./session-store.js";
|
|
@@ -40,6 +41,7 @@ export class SessionManager {
|
|
|
40
41
|
store;
|
|
41
42
|
lifecycle;
|
|
42
43
|
subagents;
|
|
44
|
+
monitors;
|
|
43
45
|
/** directory → last scan freshness (pi dir mtime + wall clock). */
|
|
44
46
|
scanCache = new Map();
|
|
45
47
|
/** freshness of the all-directories scan (see listAll). */
|
|
@@ -50,9 +52,11 @@ export class SessionManager {
|
|
|
50
52
|
this.store = store ?? new SessionStore();
|
|
51
53
|
this.lifecycle = new SessionLifecycle(() => this.sessions.values(), lifecycleOptions);
|
|
52
54
|
this.subagents = new SubagentBridge(this.store, this.sessions, this.emit, (session, raw) => parsePiSessionText(session, getAgentName(), raw));
|
|
55
|
+
this.monitors = new MonitorSessions(this.sessions, this.emit);
|
|
53
56
|
this.events = new PiEventTranslator({
|
|
54
57
|
emit: this.emit,
|
|
55
58
|
subagents: this.subagents,
|
|
59
|
+
monitors: this.monitors,
|
|
56
60
|
store: this.store,
|
|
57
61
|
dirtyDiffs: this.dirtyDiffs,
|
|
58
62
|
ensureMessages: (s) => this.ensureMessages(s),
|
|
@@ -122,6 +126,7 @@ export class SessionManager {
|
|
|
122
126
|
this.sessions.set(info.id, managed);
|
|
123
127
|
published = true;
|
|
124
128
|
this.subagents.wire(managed);
|
|
129
|
+
this.monitors.wire(managed);
|
|
125
130
|
this.lifecycle.touch(managed);
|
|
126
131
|
this.wirePi(managed, pi);
|
|
127
132
|
if (options.model !== undefined)
|
|
@@ -236,6 +241,7 @@ export class SessionManager {
|
|
|
236
241
|
});
|
|
237
242
|
delete s.interruptRequested;
|
|
238
243
|
this.subagents.stopParent(s.info.id);
|
|
244
|
+
this.monitors.stopParent(s.info.id);
|
|
239
245
|
void this.lifecycle
|
|
240
246
|
.dormant(s)
|
|
241
247
|
.catch((error) => console.error("[lifecycle] exit cleanup failed:", error));
|
|
@@ -276,6 +282,14 @@ export class SessionManager {
|
|
|
276
282
|
}
|
|
277
283
|
return undefined;
|
|
278
284
|
}
|
|
285
|
+
/** Sessions with a running agent in `directory`, most recently used first; never revives a dormant one. */
|
|
286
|
+
liveSessions(directory) {
|
|
287
|
+
if (this.disposed)
|
|
288
|
+
return [];
|
|
289
|
+
return [...this.sessions.values()]
|
|
290
|
+
.filter((s) => s.pi !== undefined && s.info.directory === directory)
|
|
291
|
+
.sort((a, b) => this.lifecycle.lastUsed(b) - this.lifecycle.lastUsed(a));
|
|
292
|
+
}
|
|
279
293
|
list(directory) {
|
|
280
294
|
this.refreshNativeTitles();
|
|
281
295
|
this.subagents.restoreAll();
|
|
@@ -319,8 +333,9 @@ export class SessionManager {
|
|
|
319
333
|
this.sessions.delete(sessionId);
|
|
320
334
|
this.emit(s.info.directory, { type: "session.deleted", properties: { info: s.info } });
|
|
321
335
|
await this.lifecycle.dormant(s);
|
|
322
|
-
const piPath = this.subagents.owns(sessionId) ? undefined : (s.piPath ?? s.restored?.piPath);
|
|
336
|
+
const piPath = this.subagents.owns(sessionId) || this.monitors.owns(sessionId) ? undefined : (s.piPath ?? s.restored?.piPath);
|
|
323
337
|
this.subagents.remove(sessionId);
|
|
338
|
+
this.monitors.remove(sessionId);
|
|
324
339
|
if (piPath !== undefined) {
|
|
325
340
|
try {
|
|
326
341
|
unlinkSync(piPath);
|
|
@@ -1133,6 +1148,8 @@ export class SessionManager {
|
|
|
1133
1148
|
ensureMessages(s) {
|
|
1134
1149
|
if (this.subagents.owns(s.info.id))
|
|
1135
1150
|
return this.subagents.messages(s.info.id);
|
|
1151
|
+
if (this.monitors.owns(s.info.id))
|
|
1152
|
+
return this.monitors.messages(s.info.id);
|
|
1136
1153
|
if (s.messages === undefined) {
|
|
1137
1154
|
const admissions = s.tree.localShellTurns?.length;
|
|
1138
1155
|
s.messages = parsePiSessionFile(s, getAgentName());
|
|
@@ -1260,6 +1277,7 @@ export class SessionManager {
|
|
|
1260
1277
|
if (reg?.connectorOwned === true)
|
|
1261
1278
|
this.createdSessions.set(sc.piSessionId, sc.directory);
|
|
1262
1279
|
this.subagents.wire(managed);
|
|
1280
|
+
this.monitors.wire(managed);
|
|
1263
1281
|
return managed;
|
|
1264
1282
|
}
|
|
1265
1283
|
/** Resolve a bare session id (e.g. deep link after restart) via known directories. */
|
package/dist/types.d.ts
CHANGED
|
@@ -265,6 +265,11 @@ export type OCEvent = {
|
|
|
265
265
|
} | {
|
|
266
266
|
type: "server.connected";
|
|
267
267
|
properties: Record<string, unknown>;
|
|
268
|
+
} | {
|
|
269
|
+
type: "mcp.tools.changed";
|
|
270
|
+
properties: {
|
|
271
|
+
server: string;
|
|
272
|
+
};
|
|
268
273
|
} | {
|
|
269
274
|
type: "session.created";
|
|
270
275
|
properties: {
|
package/dist/v2.js
CHANGED