@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.
Files changed (49) hide show
  1. package/bundled-plugins/plugins/lark/dist/runtime.js +6 -7
  2. package/bundled-plugins/plugins/lark/dist/settings.js +6 -4
  3. package/bundled-plugins/plugins/lark/package.json +1 -1
  4. package/dist/browser-artifact-management.d.ts +4 -1
  5. package/dist/browser-artifact-management.js +18 -10
  6. package/dist/browser-artifacts.d.ts +1 -0
  7. package/dist/browser-artifacts.js +1 -0
  8. package/dist/chrome-for-testing-runtime.d.ts +27 -0
  9. package/dist/chrome-for-testing-runtime.js +193 -0
  10. package/dist/chrome-for-testing-store.d.ts +2 -0
  11. package/dist/chrome-for-testing-store.js +9 -0
  12. package/dist/daemon-server.d.ts +4 -1
  13. package/dist/daemon-server.js +137 -32
  14. package/dist/db.js +2 -0
  15. package/dist/entry-path.d.ts +3 -0
  16. package/dist/entry-path.js +12 -0
  17. package/dist/index-daemon.js +3 -87
  18. package/dist/lifecycle.d.ts +12 -0
  19. package/dist/lifecycle.js +16 -0
  20. package/dist/maintenance-lease.d.ts +39 -0
  21. package/dist/maintenance-lease.js +250 -0
  22. package/dist/plugin-host-rpc.d.ts +19 -1
  23. package/dist/plugin-host-rpc.js +163 -23
  24. package/dist/plugin-installer.js +59 -0
  25. package/dist/plugin-supervisor.d.ts +5 -0
  26. package/dist/plugin-supervisor.js +218 -29
  27. package/dist/pm2.d.ts +11 -2
  28. package/dist/pm2.js +116 -19
  29. package/dist/process.d.ts +12 -0
  30. package/dist/process.js +91 -0
  31. package/dist/session-launch-operations.d.ts +10 -1
  32. package/dist/session-launch-operations.js +15 -6
  33. package/dist/setup-service.d.ts +20 -0
  34. package/dist/setup-service.js +38 -0
  35. package/package.json +33 -20
  36. package/dist/browser-cli-args.d.ts +0 -20
  37. package/dist/browser-cli-args.js +0 -100
  38. package/dist/cli.d.ts +0 -2
  39. package/dist/cli.js +0 -550
  40. package/dist/control-client.d.ts +0 -122
  41. package/dist/control-client.js +0 -855
  42. package/dist/control-endpoint.d.ts +0 -14
  43. package/dist/control-endpoint.js +0 -124
  44. package/dist/desktop-browser-host-client.d.ts +0 -35
  45. package/dist/desktop-browser-host-client.js +0 -415
  46. package/dist/setup.d.ts +0 -20
  47. package/dist/setup.js +0 -386
  48. package/dist/update.d.ts +0 -25
  49. 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;
@@ -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.disposed)
155
- return;
163
+ if (this.disposePromise)
164
+ return this.disposePromise;
156
165
  this.disposed = true;
157
166
  const runs = [...this.activeRuns.values()];
158
- this.activeRuns.clear();
159
- this.activeSessionRuns.clear();
160
- this.activeRunsByOperation.clear();
161
- for (const run of runs) {
162
- run.interruptRequested = true;
163
- if (run.interruptResult && await run.interruptResult.catch(() => false))
164
- continue;
165
- if (this.services.interruptSession) {
166
- await this.services.interruptSession(run.sessionId).catch(() => false);
167
- }
168
- else {
169
- run.abort.abort();
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 replay = replayPluginSessionLaunch({
227
+ const canonicalReplay = replayPluginSessionLaunch({
186
228
  pluginId: this.principalId,
187
229
  scopeId,
188
230
  operationId,
189
231
  requestHash,
190
232
  });
191
- if (replay)
192
- return this.sessionEnsureResult(replay);
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 interruptResult = this.services.interruptSession
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.managedCwd are mutually exclusive");
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;