@rynx-ai/daemon 0.1.11-beta.2 → 0.1.11-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bundled-plugins/plugins/lark/dist/runtime.js +6 -7
- package/bundled-plugins/plugins/lark/dist/settings.js +6 -4
- package/bundled-plugins/plugins/lark/package.json +1 -1
- package/dist/browser-artifact-management.d.ts +4 -1
- package/dist/browser-artifact-management.js +18 -10
- package/dist/browser-artifacts.d.ts +1 -0
- package/dist/browser-artifacts.js +1 -0
- package/dist/chrome-for-testing-runtime.d.ts +27 -0
- package/dist/chrome-for-testing-runtime.js +193 -0
- package/dist/chrome-for-testing-store.d.ts +2 -0
- package/dist/chrome-for-testing-store.js +9 -0
- package/dist/daemon-server.d.ts +4 -1
- package/dist/daemon-server.js +137 -32
- package/dist/db.js +2 -0
- package/dist/entry-path.d.ts +3 -0
- package/dist/entry-path.js +12 -0
- package/dist/index-daemon.js +3 -87
- package/dist/lifecycle.d.ts +12 -0
- package/dist/lifecycle.js +16 -0
- package/dist/maintenance-lease.d.ts +39 -0
- package/dist/maintenance-lease.js +250 -0
- package/dist/plugin-host-rpc.d.ts +19 -1
- package/dist/plugin-host-rpc.js +163 -23
- package/dist/plugin-installer.js +59 -0
- package/dist/plugin-supervisor.d.ts +5 -0
- package/dist/plugin-supervisor.js +218 -29
- package/dist/pm2.d.ts +11 -2
- package/dist/pm2.js +116 -19
- package/dist/process.d.ts +12 -0
- package/dist/process.js +91 -0
- package/dist/session-launch-operations.d.ts +10 -1
- package/dist/session-launch-operations.js +15 -6
- package/dist/setup-service.d.ts +20 -0
- package/dist/setup-service.js +38 -0
- package/package.json +33 -20
- package/dist/browser-cli-args.d.ts +0 -20
- package/dist/browser-cli-args.js +0 -100
- package/dist/cli.d.ts +0 -2
- package/dist/cli.js +0 -550
- package/dist/control-client.d.ts +0 -122
- package/dist/control-client.js +0 -855
- package/dist/control-endpoint.d.ts +0 -14
- package/dist/control-endpoint.js +0 -124
- package/dist/desktop-browser-host-client.d.ts +0 -35
- package/dist/desktop-browser-host-client.js +0 -415
- package/dist/setup.d.ts +0 -20
- package/dist/setup.js +0 -386
- package/dist/update.d.ts +0 -25
- package/dist/update.js +0 -133
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { rynxHome } from "@rynx-ai/core";
|
|
5
|
+
const LEASE_SCHEMA_VERSION = 1;
|
|
6
|
+
// The detached updater can spend 24h waiting for idle before cutover begins.
|
|
7
|
+
// Once accepted, retain a separate bounded recovery window long enough for the
|
|
8
|
+
// package switch and rollback even if the updater is killed at its outer limit.
|
|
9
|
+
const DEFAULT_LEASE_TTL_MS = 30 * 60 * 60 * 1_000;
|
|
10
|
+
const EXPIRY_RETRY_MS = 1_000;
|
|
11
|
+
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
12
|
+
const LEASE_TOKEN_PATTERN = /^[A-Za-z0-9_-]{32,256}$/;
|
|
13
|
+
export function maintenanceLeaseFile(env = process.env) {
|
|
14
|
+
return join(rynxHome(env), "maintenance-lease.json");
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Cross-process admission fence used only during daemon replacement.
|
|
18
|
+
*
|
|
19
|
+
* The raw bearer token is never persisted. The bounded expiry means a crashed
|
|
20
|
+
* upgrader cannot leave the daemon permanently unavailable, while atomic rename
|
|
21
|
+
* ensures a replacement daemon sees either the complete lease or no lease.
|
|
22
|
+
*/
|
|
23
|
+
export class MaintenanceLeaseStore {
|
|
24
|
+
filePath;
|
|
25
|
+
now;
|
|
26
|
+
ttlMs;
|
|
27
|
+
syncDirectory;
|
|
28
|
+
onExpired;
|
|
29
|
+
lease;
|
|
30
|
+
expiryTimer;
|
|
31
|
+
constructor(options = {}) {
|
|
32
|
+
this.filePath = options.filePath ?? maintenanceLeaseFile();
|
|
33
|
+
this.now = options.now ?? (() => Date.now());
|
|
34
|
+
this.ttlMs = options.ttlMs ?? DEFAULT_LEASE_TTL_MS;
|
|
35
|
+
this.syncDirectory = options.syncDirectory ?? fsyncDirectory;
|
|
36
|
+
this.onExpired = options.onExpired ?? (() => undefined);
|
|
37
|
+
if (!Number.isSafeInteger(this.ttlMs) || this.ttlMs <= 0) {
|
|
38
|
+
throw new Error("maintenance lease TTL must be a positive safe integer");
|
|
39
|
+
}
|
|
40
|
+
this.lease = this.readPersistedLease();
|
|
41
|
+
this.scheduleExpiry();
|
|
42
|
+
}
|
|
43
|
+
isActive() {
|
|
44
|
+
if (!this.lease)
|
|
45
|
+
return false;
|
|
46
|
+
if (this.lease.expiresAt > this.now())
|
|
47
|
+
return true;
|
|
48
|
+
this.expire();
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
isHeldBy(token) {
|
|
52
|
+
let tokenHash;
|
|
53
|
+
try {
|
|
54
|
+
tokenHash = hashLeaseToken(token);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
return Boolean(this.isActive()
|
|
60
|
+
&& this.lease
|
|
61
|
+
&& hashesEqual(this.lease.tokenHash, tokenHash));
|
|
62
|
+
}
|
|
63
|
+
acquire(token) {
|
|
64
|
+
const tokenHash = hashLeaseToken(token);
|
|
65
|
+
if (this.isActive()) {
|
|
66
|
+
if (this.lease && hashesEqual(this.lease.tokenHash, tokenHash)) {
|
|
67
|
+
const createdAt = this.now();
|
|
68
|
+
const renewed = {
|
|
69
|
+
schemaVersion: LEASE_SCHEMA_VERSION,
|
|
70
|
+
tokenHash,
|
|
71
|
+
createdAt,
|
|
72
|
+
expiresAt: createdAt + this.ttlMs,
|
|
73
|
+
};
|
|
74
|
+
// Fail closed in memory if persistence reaches an indeterminate I/O
|
|
75
|
+
// outcome; the caller sees the error, but this process must not reopen
|
|
76
|
+
// admission around a lease file that may already have been renamed.
|
|
77
|
+
this.lease = renewed;
|
|
78
|
+
try {
|
|
79
|
+
this.writePersistedLease(renewed);
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
this.scheduleExpiry();
|
|
83
|
+
}
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
throw new Error("a different maintenance lease is already active");
|
|
87
|
+
}
|
|
88
|
+
const createdAt = this.now();
|
|
89
|
+
const lease = {
|
|
90
|
+
schemaVersion: LEASE_SCHEMA_VERSION,
|
|
91
|
+
tokenHash,
|
|
92
|
+
createdAt,
|
|
93
|
+
expiresAt: createdAt + this.ttlMs,
|
|
94
|
+
};
|
|
95
|
+
this.lease = lease;
|
|
96
|
+
try {
|
|
97
|
+
this.writePersistedLease(lease);
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
this.scheduleExpiry();
|
|
101
|
+
}
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
release(token) {
|
|
105
|
+
if (!this.isActive())
|
|
106
|
+
return "not_active";
|
|
107
|
+
const lease = this.lease;
|
|
108
|
+
if (!lease)
|
|
109
|
+
return "not_active";
|
|
110
|
+
let tokenHash;
|
|
111
|
+
try {
|
|
112
|
+
tokenHash = hashLeaseToken(token);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return "denied";
|
|
116
|
+
}
|
|
117
|
+
if (!hashesEqual(lease.tokenHash, tokenHash))
|
|
118
|
+
return "denied";
|
|
119
|
+
this.clear();
|
|
120
|
+
return "released";
|
|
121
|
+
}
|
|
122
|
+
readPersistedLease() {
|
|
123
|
+
let parsed;
|
|
124
|
+
try {
|
|
125
|
+
parsed = JSON.parse(readFileSync(this.filePath, "utf8"));
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
if (error
|
|
129
|
+
&& typeof error === "object"
|
|
130
|
+
&& "code" in error
|
|
131
|
+
&& error.code === "ENOENT")
|
|
132
|
+
return undefined;
|
|
133
|
+
throw new Error("maintenance lease state is unreadable", { cause: error });
|
|
134
|
+
}
|
|
135
|
+
if (!isPersistedLease(parsed, this.ttlMs)) {
|
|
136
|
+
throw new Error("maintenance lease state is invalid");
|
|
137
|
+
}
|
|
138
|
+
if (parsed.expiresAt <= this.now()) {
|
|
139
|
+
this.clearFile();
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
return parsed;
|
|
143
|
+
}
|
|
144
|
+
writePersistedLease(lease) {
|
|
145
|
+
const parent = dirname(this.filePath);
|
|
146
|
+
mkdirSync(parent, { recursive: true, mode: 0o700 });
|
|
147
|
+
const temporary = `${this.filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
148
|
+
let descriptor;
|
|
149
|
+
try {
|
|
150
|
+
descriptor = openSync(temporary, "wx", 0o600);
|
|
151
|
+
writeFileSync(descriptor, `${JSON.stringify(lease)}\n`, "utf8");
|
|
152
|
+
fsyncSync(descriptor);
|
|
153
|
+
closeSync(descriptor);
|
|
154
|
+
descriptor = undefined;
|
|
155
|
+
renameSync(temporary, this.filePath);
|
|
156
|
+
this.syncDirectory(parent);
|
|
157
|
+
}
|
|
158
|
+
finally {
|
|
159
|
+
if (descriptor !== undefined)
|
|
160
|
+
closeSync(descriptor);
|
|
161
|
+
rmSync(temporary, { force: true });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
clear() {
|
|
165
|
+
this.clearFile();
|
|
166
|
+
this.lease = undefined;
|
|
167
|
+
this.cancelExpiry();
|
|
168
|
+
}
|
|
169
|
+
clearFile() {
|
|
170
|
+
rmSync(this.filePath, { force: true });
|
|
171
|
+
this.syncDirectory(dirname(this.filePath));
|
|
172
|
+
}
|
|
173
|
+
expire() {
|
|
174
|
+
this.clear();
|
|
175
|
+
this.onExpired();
|
|
176
|
+
}
|
|
177
|
+
scheduleExpiry(delayOverride) {
|
|
178
|
+
this.cancelExpiry();
|
|
179
|
+
if (!this.lease)
|
|
180
|
+
return;
|
|
181
|
+
const delay = delayOverride ?? Math.max(0, this.lease.expiresAt - this.now());
|
|
182
|
+
this.expiryTimer = setTimeout(() => {
|
|
183
|
+
this.expiryTimer = undefined;
|
|
184
|
+
if (!this.lease)
|
|
185
|
+
return;
|
|
186
|
+
if (this.lease.expiresAt > this.now()) {
|
|
187
|
+
this.scheduleExpiry();
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
this.expire();
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
// Keep the in-memory fence and retry cleanup. A failed durability
|
|
195
|
+
// operation must not turn an expired lease into open admission.
|
|
196
|
+
this.scheduleExpiry(EXPIRY_RETRY_MS);
|
|
197
|
+
}
|
|
198
|
+
}, Math.min(delay, MAX_TIMER_DELAY_MS));
|
|
199
|
+
this.expiryTimer.unref?.();
|
|
200
|
+
}
|
|
201
|
+
cancelExpiry() {
|
|
202
|
+
if (this.expiryTimer)
|
|
203
|
+
clearTimeout(this.expiryTimer);
|
|
204
|
+
this.expiryTimer = undefined;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
function fsyncDirectory(directory) {
|
|
208
|
+
let descriptor;
|
|
209
|
+
try {
|
|
210
|
+
descriptor = openSync(directory, "r");
|
|
211
|
+
fsyncSync(descriptor);
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
if (error
|
|
215
|
+
&& typeof error === "object"
|
|
216
|
+
&& "code" in error
|
|
217
|
+
&& error.code === "ENOENT")
|
|
218
|
+
return;
|
|
219
|
+
throw error;
|
|
220
|
+
}
|
|
221
|
+
finally {
|
|
222
|
+
if (descriptor !== undefined)
|
|
223
|
+
closeSync(descriptor);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
function hashLeaseToken(token) {
|
|
227
|
+
if (!LEASE_TOKEN_PATTERN.test(token)) {
|
|
228
|
+
throw new Error("maintenance lease token must be 32-256 base64url characters");
|
|
229
|
+
}
|
|
230
|
+
return createHash("sha256").update(token, "utf8").digest("hex");
|
|
231
|
+
}
|
|
232
|
+
function hashesEqual(left, right) {
|
|
233
|
+
const leftBuffer = Buffer.from(left, "hex");
|
|
234
|
+
const rightBuffer = Buffer.from(right, "hex");
|
|
235
|
+
return leftBuffer.length === rightBuffer.length
|
|
236
|
+
&& timingSafeEqual(leftBuffer, rightBuffer);
|
|
237
|
+
}
|
|
238
|
+
function isPersistedLease(value, maximumTtlMs) {
|
|
239
|
+
if (!value || typeof value !== "object")
|
|
240
|
+
return false;
|
|
241
|
+
const candidate = value;
|
|
242
|
+
return candidate.schemaVersion === LEASE_SCHEMA_VERSION
|
|
243
|
+
&& typeof candidate.tokenHash === "string"
|
|
244
|
+
&& /^[a-f0-9]{64}$/.test(candidate.tokenHash)
|
|
245
|
+
&& Number.isSafeInteger(candidate.createdAt)
|
|
246
|
+
&& Number.isSafeInteger(candidate.expiresAt)
|
|
247
|
+
&& candidate.createdAt >= 0
|
|
248
|
+
&& candidate.expiresAt > candidate.createdAt
|
|
249
|
+
&& candidate.expiresAt - candidate.createdAt <= maximumTtlMs;
|
|
250
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type AgentSpec, type AgentCapabilities, type AgentRuntimeId, type AgentSessionStore, type ConversationRuntime, type ResolvedExecutionSnapshot, type SessionRegistry, type SessionWorkspaceSnapshot } from "@rynx-ai/core";
|
|
1
|
+
import { type AdmissionReservation, type AgentSpec, type AgentCapabilities, type AgentRuntimeId, type AgentSessionStore, type ConversationRuntime, type ResolvedExecutionSnapshot, type SessionRegistry, type SessionWorkspaceSnapshot } from "@rynx-ai/core";
|
|
2
2
|
import { type PluginAgentSummary, type PluginCapability, type PluginHostEvent, type PluginResolvedSessionExecution, type PluginSessionExecutionSnapshot, type PluginSessionInteractionResolveDisposition } from "@rynx-ai/plugin-sdk";
|
|
3
3
|
import type { SessionInteractionResolution } from "@rynx-ai/protocol";
|
|
4
4
|
import { PluginRunnerError } from "@rynx-ai/plugin-runner";
|
|
@@ -18,6 +18,7 @@ export interface PluginAgentCatalogHost {
|
|
|
18
18
|
export interface PluginSessionLaunchSelection {
|
|
19
19
|
projectId?: string;
|
|
20
20
|
managedCwd?: string;
|
|
21
|
+
workspace?: SessionWorkspaceSnapshot;
|
|
21
22
|
agent?: string;
|
|
22
23
|
config?: AgentSpec;
|
|
23
24
|
provider?: AgentRuntimeId;
|
|
@@ -45,6 +46,7 @@ export interface PluginHostServices {
|
|
|
45
46
|
resolve(input: PluginSessionLaunchSelection): Promise<PluginResolvedSessionLaunch>;
|
|
46
47
|
};
|
|
47
48
|
interruptSession?(sessionId: string): Promise<boolean>;
|
|
49
|
+
terminateSession?(sessionId: string): Promise<boolean>;
|
|
48
50
|
resolveSessionInteraction?(sessionId: string, interactionId: string, resolution: SessionInteractionResolution): Promise<{
|
|
49
51
|
disposition: PluginSessionInteractionResolveDisposition;
|
|
50
52
|
}>;
|
|
@@ -56,6 +58,8 @@ export interface PluginHostServices {
|
|
|
56
58
|
sessionId: string;
|
|
57
59
|
disposition: "created" | "replayed";
|
|
58
60
|
}>;
|
|
61
|
+
admissionOpen?: () => boolean;
|
|
62
|
+
admissionReserve?: () => AdmissionReservation | undefined;
|
|
59
63
|
}
|
|
60
64
|
export interface PluginHostRpcOptions {
|
|
61
65
|
/** Manifest id returned by host.info. */
|
|
@@ -63,6 +67,8 @@ export interface PluginHostRpcOptions {
|
|
|
63
67
|
/** Marketplace-qualified principal used for ownership and proof scopes. */
|
|
64
68
|
principalId?: string;
|
|
65
69
|
daemonInstanceId: string;
|
|
70
|
+
productVersion: string;
|
|
71
|
+
cliVersion: string;
|
|
66
72
|
capabilities: readonly PluginCapability[];
|
|
67
73
|
services: PluginHostServices;
|
|
68
74
|
/** Canonical plugin-owned root permitted for the Workspace plugin's isolated
|
|
@@ -70,6 +76,8 @@ export interface PluginHostRpcOptions {
|
|
|
70
76
|
managedWorkspaceRoot?: string;
|
|
71
77
|
/** Resolving this promise is the stream's backpressure acknowledgement. */
|
|
72
78
|
emit(event: PluginHostEvent): void | Promise<void>;
|
|
79
|
+
/** Bounded graceful-stop window used while disposing this Host. */
|
|
80
|
+
disposeForceAfterMs?: number;
|
|
73
81
|
}
|
|
74
82
|
/**
|
|
75
83
|
* One authenticated plugin's capability-gated Host RPC surface.
|
|
@@ -82,21 +90,28 @@ export declare class PluginHostRpc {
|
|
|
82
90
|
private readonly pluginId;
|
|
83
91
|
private readonly principalId;
|
|
84
92
|
private readonly daemonInstanceId;
|
|
93
|
+
private readonly productVersion;
|
|
94
|
+
private readonly cliVersion;
|
|
85
95
|
private readonly capabilities;
|
|
86
96
|
private readonly services;
|
|
87
97
|
private readonly managedWorkspaceRoot;
|
|
88
98
|
private readonly emit;
|
|
99
|
+
private readonly disposeForceAfterMs;
|
|
89
100
|
private readonly activeRuns;
|
|
90
101
|
private readonly activeSessionRuns;
|
|
91
102
|
private readonly activeRunsByOperation;
|
|
92
103
|
private disposed;
|
|
104
|
+
private disposePromise?;
|
|
93
105
|
constructor(options: PluginHostRpcOptions);
|
|
94
106
|
call(method: string, params: unknown, signal?: AbortSignal): Promise<unknown>;
|
|
95
107
|
dispose(): Promise<void>;
|
|
96
108
|
private ensureSession;
|
|
109
|
+
private ensureSessionAdmitted;
|
|
97
110
|
private resolveManagedWorkspace;
|
|
111
|
+
private resolveWorkspaceSnapshot;
|
|
98
112
|
private sessionEnsureResult;
|
|
99
113
|
private runSession;
|
|
114
|
+
private runSessionAdmitted;
|
|
100
115
|
private pumpRun;
|
|
101
116
|
private releaseRun;
|
|
102
117
|
private steerSession;
|
|
@@ -110,6 +125,9 @@ export declare class PluginHostRpc {
|
|
|
110
125
|
private withOwnedSession;
|
|
111
126
|
private resolveSessionInteraction;
|
|
112
127
|
private forkSession;
|
|
128
|
+
private forkSessionAdmitted;
|
|
129
|
+
private reserveAdmission;
|
|
130
|
+
private withAdmission;
|
|
113
131
|
private setSessionTitle;
|
|
114
132
|
private resolveAgentExecution;
|
|
115
133
|
private issueControlLink;
|
package/dist/plugin-host-rpc.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { realpath } from "node:fs/promises";
|
|
2
|
+
import { realpath, stat } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { AgentRuntimeError, agentSpecSchema, resolvedExecutionSnapshotSchema, sessionWorkspaceSnapshotSchema, SteerController, } from "@rynx-ai/core";
|
|
5
5
|
import { PLUGIN_HOST_MAX_ACTIVE_RUNS, } from "@rynx-ai/plugin-sdk";
|
|
@@ -47,24 +47,31 @@ export class PluginHostRpc {
|
|
|
47
47
|
pluginId;
|
|
48
48
|
principalId;
|
|
49
49
|
daemonInstanceId;
|
|
50
|
+
productVersion;
|
|
51
|
+
cliVersion;
|
|
50
52
|
capabilities;
|
|
51
53
|
services;
|
|
52
54
|
managedWorkspaceRoot;
|
|
53
55
|
emit;
|
|
56
|
+
disposeForceAfterMs;
|
|
54
57
|
activeRuns = new Map();
|
|
55
58
|
activeSessionRuns = new Map();
|
|
56
59
|
activeRunsByOperation = new Map();
|
|
57
60
|
disposed = false;
|
|
61
|
+
disposePromise;
|
|
58
62
|
constructor(options) {
|
|
59
63
|
this.pluginId = requiredString(options.pluginId, "pluginId", 64);
|
|
60
64
|
this.principalId = requiredString(options.principalId ?? options.pluginId, "principalId", 160);
|
|
61
65
|
this.daemonInstanceId = requiredString(options.daemonInstanceId, "daemonInstanceId", 128);
|
|
66
|
+
this.productVersion = requiredString(options.productVersion, "productVersion", 128);
|
|
67
|
+
this.cliVersion = requiredString(options.cliVersion, "cliVersion", 128);
|
|
62
68
|
this.capabilities = new Set(options.capabilities);
|
|
63
69
|
this.services = options.services;
|
|
64
70
|
this.managedWorkspaceRoot = options.managedWorkspaceRoot
|
|
65
71
|
? path.resolve(options.managedWorkspaceRoot)
|
|
66
72
|
: undefined;
|
|
67
73
|
this.emit = options.emit;
|
|
74
|
+
this.disposeForceAfterMs = options.disposeForceAfterMs ?? 10_000;
|
|
68
75
|
}
|
|
69
76
|
async call(method, params, signal) {
|
|
70
77
|
if (this.disposed)
|
|
@@ -75,6 +82,8 @@ export class PluginHostRpc {
|
|
|
75
82
|
pluginId: this.pluginId,
|
|
76
83
|
installationId: this.services.installationId,
|
|
77
84
|
daemonInstanceId: this.daemonInstanceId,
|
|
85
|
+
productVersion: this.productVersion,
|
|
86
|
+
cliVersion: this.cliVersion,
|
|
78
87
|
installationProof: {
|
|
79
88
|
algorithm: this.services.installationProof.algorithm,
|
|
80
89
|
publicKey: this.services.installationProof.publicKey,
|
|
@@ -151,51 +160,85 @@ export class PluginHostRpc {
|
|
|
151
160
|
}
|
|
152
161
|
}
|
|
153
162
|
async dispose() {
|
|
154
|
-
if (this.
|
|
155
|
-
return;
|
|
163
|
+
if (this.disposePromise)
|
|
164
|
+
return this.disposePromise;
|
|
156
165
|
this.disposed = true;
|
|
157
166
|
const runs = [...this.activeRuns.values()];
|
|
158
|
-
this.
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
run.
|
|
167
|
+
this.disposePromise = (async () => {
|
|
168
|
+
const stopped = await Promise.allSettled(runs.map(async (run) => {
|
|
169
|
+
if (this.activeRuns.get(run.runId) !== run)
|
|
170
|
+
return;
|
|
171
|
+
run.interruptRequested = true;
|
|
172
|
+
const graceful = run.interruptResult ?? (this.services.interruptSession
|
|
173
|
+
? Promise.resolve().then(() => this.services.interruptSession(run.sessionId))
|
|
174
|
+
: Promise.resolve().then(() => {
|
|
175
|
+
run.abort.abort();
|
|
176
|
+
return false;
|
|
177
|
+
}));
|
|
178
|
+
const authoritative = forceStoppedInterrupt(graceful, this.disposeForceAfterMs, () => this.services.terminateSession?.(run.sessionId) ?? Promise.resolve(false));
|
|
179
|
+
run.interruptResult = authoritative;
|
|
180
|
+
if (!(await authoritative)) {
|
|
181
|
+
throw new Error(`could not prove Session ${run.sessionId} stopped`);
|
|
182
|
+
}
|
|
183
|
+
this.releaseRun(run);
|
|
184
|
+
}));
|
|
185
|
+
const failures = stopped
|
|
186
|
+
.filter((result) => result.status === "rejected")
|
|
187
|
+
.map((result) => result.reason);
|
|
188
|
+
if (failures.length > 0) {
|
|
189
|
+
throw new AggregateError(failures, "plugin Host could not stop every active run");
|
|
170
190
|
}
|
|
171
|
-
}
|
|
191
|
+
})();
|
|
192
|
+
return this.disposePromise;
|
|
172
193
|
}
|
|
173
194
|
async ensureSession(params) {
|
|
195
|
+
return this.withAdmission(() => this.ensureSessionAdmitted(params));
|
|
196
|
+
}
|
|
197
|
+
async ensureSessionAdmitted(params) {
|
|
174
198
|
const input = record(params);
|
|
175
199
|
const scopeId = requiredString(input.scopeId, "scopeId", 512);
|
|
176
200
|
const operationId = requiredString(input.operationId, "operationId", 512);
|
|
177
201
|
const parsedSelection = sessionLaunchSelection(input.session);
|
|
202
|
+
if (parsedSelection.managedCwd && !this.managedWorkspaceRoot) {
|
|
203
|
+
throw new PluginHostCallError("INVALID_PARAMS", "session.managedCwd is reserved for the built-in Workspace plugin");
|
|
204
|
+
}
|
|
205
|
+
const rawRequestHash = hashCanonicalJson(parsedSelection);
|
|
206
|
+
const replay = replayPluginSessionLaunch({
|
|
207
|
+
pluginId: this.principalId,
|
|
208
|
+
scopeId,
|
|
209
|
+
operationId,
|
|
210
|
+
requestHash: rawRequestHash,
|
|
211
|
+
allowRequestHashMismatch: true,
|
|
212
|
+
});
|
|
213
|
+
if (replay)
|
|
214
|
+
return this.sessionEnsureResult(replay);
|
|
178
215
|
const selection = parsedSelection.managedCwd
|
|
179
216
|
? {
|
|
180
217
|
...parsedSelection,
|
|
181
218
|
managedCwd: await this.resolveManagedWorkspace(parsedSelection.managedCwd),
|
|
182
219
|
}
|
|
183
|
-
: parsedSelection
|
|
220
|
+
: parsedSelection.workspace
|
|
221
|
+
? {
|
|
222
|
+
...parsedSelection,
|
|
223
|
+
workspace: await this.resolveWorkspaceSnapshot(parsedSelection.workspace),
|
|
224
|
+
}
|
|
225
|
+
: parsedSelection;
|
|
184
226
|
const requestHash = hashCanonicalJson(selection);
|
|
185
|
-
const
|
|
227
|
+
const canonicalReplay = replayPluginSessionLaunch({
|
|
186
228
|
pluginId: this.principalId,
|
|
187
229
|
scopeId,
|
|
188
230
|
operationId,
|
|
189
231
|
requestHash,
|
|
190
232
|
});
|
|
191
|
-
if (
|
|
192
|
-
return this.sessionEnsureResult(
|
|
233
|
+
if (canonicalReplay)
|
|
234
|
+
return this.sessionEnsureResult(canonicalReplay);
|
|
193
235
|
const resolved = await this.services.sessionLaunch.resolve(selection);
|
|
194
236
|
const ensured = ensurePluginSessionLaunch({
|
|
195
237
|
pluginId: this.principalId,
|
|
196
238
|
scopeId,
|
|
197
239
|
operationId,
|
|
198
240
|
requestHash,
|
|
241
|
+
rawRequestHash,
|
|
199
242
|
session: {
|
|
200
243
|
workspace: sessionWorkspaceSnapshotSchema.parse(resolved.workspace),
|
|
201
244
|
execution: resolvedExecutionSnapshotSchema.parse(resolved.execution),
|
|
@@ -231,6 +274,33 @@ export class PluginHostRpc {
|
|
|
231
274
|
}
|
|
232
275
|
return canonicalDirectory;
|
|
233
276
|
}
|
|
277
|
+
async resolveWorkspaceSnapshot(workspace) {
|
|
278
|
+
const directories = [workspace.cwd, ...workspace.allowedDirs];
|
|
279
|
+
if (directories.some((directory) => !path.isAbsolute(directory))) {
|
|
280
|
+
throw new PluginHostCallError("INVALID_PARAMS", "session.workspace paths must be absolute");
|
|
281
|
+
}
|
|
282
|
+
let canonical;
|
|
283
|
+
try {
|
|
284
|
+
canonical = await Promise.all(directories.map(async (directory) => {
|
|
285
|
+
const resolved = await realpath(directory);
|
|
286
|
+
if (!(await stat(resolved)).isDirectory()) {
|
|
287
|
+
throw new Error("not a directory");
|
|
288
|
+
}
|
|
289
|
+
return resolved;
|
|
290
|
+
}));
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
throw new PluginHostCallError("INVALID_PARAMS", "session.workspace paths must be existing directories");
|
|
294
|
+
}
|
|
295
|
+
const parsed = sessionWorkspaceSnapshotSchema.safeParse({
|
|
296
|
+
cwd: canonical[0],
|
|
297
|
+
allowedDirs: canonical.slice(1),
|
|
298
|
+
});
|
|
299
|
+
if (!parsed.success) {
|
|
300
|
+
throw new PluginHostCallError("INVALID_PARAMS", `invalid session.workspace: ${parsed.error.issues[0]?.message ?? "invalid snapshot"}`);
|
|
301
|
+
}
|
|
302
|
+
return parsed.data;
|
|
303
|
+
}
|
|
234
304
|
sessionEnsureResult(result) {
|
|
235
305
|
const session = this.ownedSession(result.sessionId);
|
|
236
306
|
return {
|
|
@@ -243,6 +313,9 @@ export class PluginHostRpc {
|
|
|
243
313
|
};
|
|
244
314
|
}
|
|
245
315
|
async runSession(params, signal) {
|
|
316
|
+
return this.withAdmission(() => this.runSessionAdmitted(params, signal));
|
|
317
|
+
}
|
|
318
|
+
async runSessionAdmitted(params, signal) {
|
|
246
319
|
const input = record(params);
|
|
247
320
|
const sessionId = requiredSessionId(input);
|
|
248
321
|
const operationId = optionalString(input.operationId, "operationId", 512);
|
|
@@ -411,6 +484,10 @@ export class PluginHostRpc {
|
|
|
411
484
|
async interruptRun(params) {
|
|
412
485
|
const input = record(params);
|
|
413
486
|
const run = this.activeRunByIdentity(input);
|
|
487
|
+
const forceAfterMs = optionalPositiveNumber(input.forceAfterMs, "forceAfterMs");
|
|
488
|
+
if (forceAfterMs !== undefined && forceAfterMs > 30_000) {
|
|
489
|
+
throw new PluginHostCallError("INVALID_PARAMS", "forceAfterMs must not exceed 30000");
|
|
490
|
+
}
|
|
414
491
|
if (run.interruptResult) {
|
|
415
492
|
return { interrupted: await run.interruptResult };
|
|
416
493
|
}
|
|
@@ -418,7 +495,7 @@ export class PluginHostRpc {
|
|
|
418
495
|
// the authoritative stop is pending wait for its result: true suppresses
|
|
419
496
|
// them, while false/error lets genuine native progress/terminal continue.
|
|
420
497
|
run.interruptRequested = true;
|
|
421
|
-
const
|
|
498
|
+
const graceful = this.services.interruptSession
|
|
422
499
|
// ConversationRuntime wires AbortSignal to this same native operation.
|
|
423
500
|
// Invoke it directly once so this boolean is the authoritative result;
|
|
424
501
|
// aborting first would trigger a duplicate, unobserved interrupt.
|
|
@@ -429,6 +506,9 @@ export class PluginHostRpc {
|
|
|
429
506
|
// effort and cannot prove that native execution stopped.
|
|
430
507
|
return false;
|
|
431
508
|
});
|
|
509
|
+
const interruptResult = forceAfterMs === undefined
|
|
510
|
+
? graceful
|
|
511
|
+
: forceStoppedInterrupt(graceful, forceAfterMs, () => this.services.terminateSession?.(run.sessionId) ?? Promise.resolve(false));
|
|
432
512
|
run.interruptResult = interruptResult;
|
|
433
513
|
try {
|
|
434
514
|
const interrupted = await interruptResult;
|
|
@@ -510,6 +590,9 @@ export class PluginHostRpc {
|
|
|
510
590
|
return await resolve(sessionId, interactionId, resolution);
|
|
511
591
|
}
|
|
512
592
|
async forkSession(params) {
|
|
593
|
+
return this.withAdmission(() => this.forkSessionAdmitted(params));
|
|
594
|
+
}
|
|
595
|
+
async forkSessionAdmitted(params) {
|
|
513
596
|
const input = record(params);
|
|
514
597
|
const sourceSessionId = requiredString(input.sourceSessionId, "sourceSessionId", 512);
|
|
515
598
|
const operationId = requiredString(input.operationId, "operationId", 512);
|
|
@@ -524,6 +607,24 @@ export class PluginHostRpc {
|
|
|
524
607
|
...(title ? { title } : {}),
|
|
525
608
|
});
|
|
526
609
|
}
|
|
610
|
+
reserveAdmission() {
|
|
611
|
+
const reservation = this.services.admissionReserve?.();
|
|
612
|
+
if (reservation)
|
|
613
|
+
return reservation;
|
|
614
|
+
if (!this.services.admissionReserve && this.services.admissionOpen?.() !== false) {
|
|
615
|
+
return { release() { } };
|
|
616
|
+
}
|
|
617
|
+
throw new PluginHostCallError("HOST_CALL_FAILED", "daemon maintenance is in progress", true);
|
|
618
|
+
}
|
|
619
|
+
async withAdmission(operation) {
|
|
620
|
+
const reservation = this.reserveAdmission();
|
|
621
|
+
try {
|
|
622
|
+
return await operation();
|
|
623
|
+
}
|
|
624
|
+
finally {
|
|
625
|
+
reservation.release();
|
|
626
|
+
}
|
|
627
|
+
}
|
|
527
628
|
setSessionTitle(params) {
|
|
528
629
|
const input = record(params);
|
|
529
630
|
const sessionId = requiredSessionId(input);
|
|
@@ -612,14 +713,15 @@ function sessionLaunchSelection(value) {
|
|
|
612
713
|
assertJsonValue(input, "session");
|
|
613
714
|
const projectId = optionalString(input.projectId, "session.projectId", 512);
|
|
614
715
|
const managedCwd = optionalString(input.managedCwd, "session.managedCwd", 16_384);
|
|
716
|
+
const workspace = optionalSessionWorkspace(input.workspace);
|
|
615
717
|
const agent = optionalAgentId(input.agent, "session.agent");
|
|
616
718
|
const config = input.config === undefined ? undefined : agentSpecSchema.parse(input.config);
|
|
617
719
|
const provider = optionalRuntimeValue(input.provider);
|
|
618
720
|
const model = optionalString(input.model, "session.model", 256);
|
|
619
721
|
const reasoningEffort = optionalString(input.reasoningEffort, "session.reasoningEffort", 128);
|
|
620
722
|
const title = optionalString(input.title, "session.title", 1_000);
|
|
621
|
-
if (Number(Boolean(projectId)) + Number(Boolean(managedCwd)) > 1) {
|
|
622
|
-
throw new PluginHostCallError("INVALID_PARAMS", "session.projectId and session.
|
|
723
|
+
if (Number(Boolean(projectId)) + Number(Boolean(managedCwd)) + Number(Boolean(workspace)) > 1) {
|
|
724
|
+
throw new PluginHostCallError("INVALID_PARAMS", "session.projectId, session.managedCwd, and session.workspace are mutually exclusive");
|
|
623
725
|
}
|
|
624
726
|
if (Number(Boolean(agent)) +
|
|
625
727
|
Number(Boolean(config)) +
|
|
@@ -630,6 +732,7 @@ function sessionLaunchSelection(value) {
|
|
|
630
732
|
return {
|
|
631
733
|
...(projectId ? { projectId } : {}),
|
|
632
734
|
...(managedCwd ? { managedCwd } : {}),
|
|
735
|
+
...(workspace ? { workspace } : {}),
|
|
633
736
|
...(agent ? { agent } : {}),
|
|
634
737
|
...(config ? { config } : {}),
|
|
635
738
|
...(provider ? { provider } : {}),
|
|
@@ -638,6 +741,17 @@ function sessionLaunchSelection(value) {
|
|
|
638
741
|
...(title ? { title } : {}),
|
|
639
742
|
};
|
|
640
743
|
}
|
|
744
|
+
function optionalSessionWorkspace(value) {
|
|
745
|
+
if (value === undefined)
|
|
746
|
+
return undefined;
|
|
747
|
+
const input = record(value);
|
|
748
|
+
const cwd = requiredPathString(input.cwd, "session.workspace.cwd", 16_384);
|
|
749
|
+
if (!Array.isArray(input.allowedDirs) || input.allowedDirs.length === 0) {
|
|
750
|
+
throw new PluginHostCallError("INVALID_PARAMS", "session.workspace.allowedDirs must be a non-empty string array");
|
|
751
|
+
}
|
|
752
|
+
const allowedDirs = input.allowedDirs.map((directory, index) => requiredPathString(directory, `session.workspace.allowedDirs[${index}]`, 16_384));
|
|
753
|
+
return { cwd, allowedDirs };
|
|
754
|
+
}
|
|
641
755
|
function sessionExecutionSnapshot(value) {
|
|
642
756
|
const input = record(value);
|
|
643
757
|
const runtime = optionalRuntimeValue(input.runtime);
|
|
@@ -718,6 +832,15 @@ function requiredString(value, label, maxLength) {
|
|
|
718
832
|
}
|
|
719
833
|
return value.trim();
|
|
720
834
|
}
|
|
835
|
+
function requiredPathString(value, label, maxLength) {
|
|
836
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
837
|
+
throw new PluginHostCallError("INVALID_PARAMS", `${label} is required`);
|
|
838
|
+
}
|
|
839
|
+
if (value.length > maxLength) {
|
|
840
|
+
throw new PluginHostCallError("INVALID_PARAMS", `${label} is too long`);
|
|
841
|
+
}
|
|
842
|
+
return value;
|
|
843
|
+
}
|
|
721
844
|
function requiredText(value, label, maxLength) {
|
|
722
845
|
if (typeof value !== "string" || !value.trim()) {
|
|
723
846
|
throw new PluginHostCallError("INVALID_PARAMS", `${label} is required`);
|
|
@@ -758,6 +881,23 @@ function optionalPositiveNumber(value, label) {
|
|
|
758
881
|
}
|
|
759
882
|
return value;
|
|
760
883
|
}
|
|
884
|
+
async function forceStoppedInterrupt(graceful, forceAfterMs, terminate) {
|
|
885
|
+
let timer;
|
|
886
|
+
const graceExpired = new Promise((resolve) => {
|
|
887
|
+
timer = setTimeout(() => resolve(false), forceAfterMs);
|
|
888
|
+
timer.unref?.();
|
|
889
|
+
});
|
|
890
|
+
try {
|
|
891
|
+
if (await Promise.race([graceful.catch(() => false), graceExpired])) {
|
|
892
|
+
return true;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
finally {
|
|
896
|
+
if (timer)
|
|
897
|
+
clearTimeout(timer);
|
|
898
|
+
}
|
|
899
|
+
return terminate();
|
|
900
|
+
}
|
|
761
901
|
function assertJsonValue(value, label, seen = new Set()) {
|
|
762
902
|
if (value === null || typeof value === "string" || typeof value === "boolean")
|
|
763
903
|
return;
|