@automatalabs/pi-acp 0.1.3 → 0.2.1

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.
@@ -0,0 +1,82 @@
1
+ import { type ChildProcess } from "node:child_process";
2
+ import type { BashOperations } from "@earendil-works/pi-coding-agent";
3
+ import type { PiAcpDeps } from "./deps.js";
4
+ interface ChildRecord {
5
+ pid: number;
6
+ pgid?: number;
7
+ child: ChildProcess;
8
+ leaderClosed: Promise<void>;
9
+ termination?: Promise<void>;
10
+ terminationSettled?: boolean;
11
+ }
12
+ export interface ChildProcessRegistryDeps extends Pick<PiAcpDeps, "graceMs" | "sleep"> {
13
+ platform?: NodeJS.Platform;
14
+ processGroupState?(pgid: number): "alive" | "gone" | "error";
15
+ killProcessGroup?(pgid: number): void;
16
+ taskkillTree?(pid: number, signal: AbortSignal): Promise<void>;
17
+ }
18
+ export interface SpawnLease {
19
+ register(child: ChildProcess): ChildRecord;
20
+ failed(): void;
21
+ }
22
+ export declare class ChildCleanupFailure extends Error {
23
+ readonly remainingChildren: number;
24
+ constructor(remainingChildren: number);
25
+ }
26
+ export declare function isTaskkillAlreadyGone(output: string): boolean;
27
+ /** One monotonic ownership epoch. A failed drain remains closing and is retryable. */
28
+ export declare class ChildProcessRegistry {
29
+ private readonly deps;
30
+ private state;
31
+ private readonly children;
32
+ private pendingSpawns;
33
+ private drain;
34
+ private generationSignal;
35
+ private changeWaiters;
36
+ constructor(deps: ChildProcessRegistryDeps);
37
+ private get platform();
38
+ get remainingChildren(): number;
39
+ get leaderPids(): readonly number[];
40
+ beginSpawn(): SpawnLease;
41
+ private registerChild;
42
+ /** Natural operation completion never proves away a surviving Unix process group. */
43
+ complete(record: ChildRecord): void;
44
+ /** Terminate one timed-out/aborted tool tree without closing admission for unrelated tools. */
45
+ terminateOne(record: ChildRecord): Promise<void>;
46
+ terminateAll(deadlineSignal: AbortSignal): Promise<void>;
47
+ private drainGeneration;
48
+ private groupState;
49
+ private startTermination;
50
+ private terminateRecord;
51
+ private changed;
52
+ private waitForChange;
53
+ }
54
+ export declare class ChildProcessRegistrySlot {
55
+ private readonly deps;
56
+ private epoch;
57
+ private cleanupFailed;
58
+ constructor(deps: ChildProcessRegistryDeps);
59
+ get registry(): ChildProcessRegistry;
60
+ get childCleanupFailed(): boolean;
61
+ get remainingChildren(): number;
62
+ beginSpawn(): SpawnLease;
63
+ latchFailure(): void;
64
+ clearFailure(): void;
65
+ /**
66
+ * Close admission on the current epoch synchronously and return that exact
67
+ * epoch with its drain. Rotation is deliberately a separate commit: a
68
+ * cancel generation must not publish a fresh spawn epoch until both Pi and
69
+ * this captured registry are idle.
70
+ */
71
+ closeEpoch(deadlineSignal: AbortSignal): {
72
+ epoch: ChildProcessRegistry;
73
+ drain: Promise<void>;
74
+ };
75
+ commitRotation(captured: ChildProcessRegistry): void;
76
+ /** Compatibility seam for focused registry tests; production uses the
77
+ * explicit closeEpoch/commitRotation transaction above. */
78
+ terminateAll(shouldRotate: () => boolean, deadlineSignal: AbortSignal): Promise<void>;
79
+ }
80
+ export declare function createTrackedBashOperations(slot: ChildProcessRegistrySlot, shellPath: string | undefined, deps: Pick<PiAcpDeps, "graceMs" | "sleep">, onCleanupFailure: () => void): BashOperations;
81
+ export {};
82
+ //# sourceMappingURL=child-process-registry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"child-process-registry.d.ts","sourceRoot":"","sources":["../src/child-process-registry.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,408 @@
1
+ import { constants } from "node:fs";
2
+ import { access } from "node:fs/promises";
3
+ import { spawn } from "node:child_process";
4
+ import { getShellConfig } from "@earendil-works/pi-coding-agent";
5
+ export class ChildCleanupFailure extends Error {
6
+ remainingChildren;
7
+ constructor(remainingChildren) {
8
+ super("child process cleanup failed");
9
+ this.remainingChildren = remainingChildren;
10
+ this.name = "ChildCleanupFailure";
11
+ }
12
+ }
13
+ export function isTaskkillAlreadyGone(output) {
14
+ return /ERROR:\s+The process "[^"]+" not found\./i.test(output)
15
+ || /Reason:\s+There is no running instance of the task\./i.test(output);
16
+ }
17
+ /** One monotonic ownership epoch. A failed drain remains closing and is retryable. */
18
+ export class ChildProcessRegistry {
19
+ deps;
20
+ state = "open";
21
+ children = new Map();
22
+ pendingSpawns = 0;
23
+ drain;
24
+ generationSignal;
25
+ changeWaiters = new Set();
26
+ constructor(deps) {
27
+ this.deps = deps;
28
+ }
29
+ get platform() { return this.deps.platform ?? process.platform; }
30
+ get remainingChildren() { return this.children.size; }
31
+ get leaderPids() { return [...this.children.keys()]; }
32
+ beginSpawn() {
33
+ if (this.state !== "open")
34
+ throw new Error("aborted");
35
+ this.pendingSpawns += 1;
36
+ let settled = false;
37
+ return {
38
+ failed: () => {
39
+ if (settled)
40
+ return;
41
+ settled = true;
42
+ this.pendingSpawns -= 1;
43
+ this.changed();
44
+ },
45
+ register: (child) => {
46
+ if (settled)
47
+ throw new Error("spawn lease already settled");
48
+ settled = true;
49
+ this.pendingSpawns -= 1;
50
+ const record = this.registerChild(child);
51
+ if (this.state !== "open") {
52
+ const signal = this.generationSignal;
53
+ if (signal)
54
+ this.startTermination(record, signal);
55
+ }
56
+ this.changed();
57
+ return record;
58
+ },
59
+ };
60
+ }
61
+ registerChild(child) {
62
+ if (!child.pid)
63
+ throw new Error("bash child spawned without pid");
64
+ let closeError;
65
+ const leaderClosed = new Promise((resolve, reject) => {
66
+ child.once("error", (error) => {
67
+ closeError = error;
68
+ reject(error);
69
+ });
70
+ child.once("close", () => closeError === undefined ? resolve() : reject(closeError));
71
+ });
72
+ // A rejected leader promise is always observed by normal execution or cleanup.
73
+ leaderClosed.catch(() => undefined);
74
+ const record = {
75
+ pid: child.pid,
76
+ pgid: this.platform === "win32" ? undefined : child.pid,
77
+ child,
78
+ leaderClosed,
79
+ };
80
+ this.children.set(record.pid, record);
81
+ return record;
82
+ }
83
+ /** Natural operation completion never proves away a surviving Unix process group. */
84
+ complete(record) {
85
+ if (this.children.get(record.pid) !== record)
86
+ return;
87
+ // A Windows leader close is not descendant-disappearance proof. Keep the
88
+ // ownership record until terminateRecord observes successful taskkill /T
89
+ // /F as well as leader close. Unix may discharge ownership with ESRCH for
90
+ // the process group plus the already-observed leader completion.
91
+ if (this.platform !== "win32" && this.groupState(record.pgid ?? record.pid) === "gone") {
92
+ this.children.delete(record.pid);
93
+ this.changed();
94
+ }
95
+ }
96
+ /** Terminate one timed-out/aborted tool tree without closing admission for unrelated tools. */
97
+ async terminateOne(record) {
98
+ if (this.children.get(record.pid) !== record)
99
+ return;
100
+ const deadline = new AbortController();
101
+ const timer = new AbortController();
102
+ const expiry = this.deps.sleep(this.deps.graceMs, timer.signal).then(() => {
103
+ deadline.abort(new ChildCleanupFailure(this.children.size));
104
+ throw new ChildCleanupFailure(this.children.size);
105
+ });
106
+ expiry.catch(() => undefined);
107
+ try {
108
+ await Promise.race([this.startTermination(record, deadline.signal), expiry]);
109
+ }
110
+ finally {
111
+ timer.abort();
112
+ if (record.termination) {
113
+ await record.termination.catch(() => undefined);
114
+ record.termination = undefined;
115
+ record.terminationSettled = false;
116
+ }
117
+ }
118
+ }
119
+ terminateAll(deadlineSignal) {
120
+ if (this.drain)
121
+ return this.drain;
122
+ if (this.state === "closed" && this.children.size === 0 && this.pendingSpawns === 0) {
123
+ return Promise.resolve();
124
+ }
125
+ this.state = "closing";
126
+ this.generationSignal = deadlineSignal;
127
+ for (const record of this.children.values()) {
128
+ record.termination = undefined;
129
+ record.terminationSettled = false;
130
+ this.startTermination(record, deadlineSignal);
131
+ }
132
+ this.drain = this.drainGeneration(deadlineSignal)
133
+ .then(() => { this.generationSignal = undefined; })
134
+ .finally(() => { this.drain = undefined; });
135
+ return this.drain;
136
+ }
137
+ async drainGeneration(deadlineSignal) {
138
+ while (true) {
139
+ for (const record of this.children.values())
140
+ this.startTermination(record, deadlineSignal);
141
+ if (this.pendingSpawns === 0 && this.children.size === 0) {
142
+ this.state = "closed";
143
+ return;
144
+ }
145
+ if (deadlineSignal.aborted)
146
+ throw new ChildCleanupFailure(this.children.size);
147
+ const terminations = [...this.children.values()]
148
+ .flatMap((record) => record.termination ? [record.termination] : []);
149
+ if (this.pendingSpawns === 0 && terminations.length > 0) {
150
+ await Promise.allSettled(terminations);
151
+ if (this.children.size > 0) {
152
+ if (deadlineSignal.aborted)
153
+ throw new ChildCleanupFailure(this.children.size);
154
+ // Every admitted record was attempted and at least one proof failed.
155
+ const allSettled = [...this.children.values()].every((record) => record.terminationSettled);
156
+ if (allSettled)
157
+ throw new ChildCleanupFailure(this.children.size);
158
+ }
159
+ continue;
160
+ }
161
+ try {
162
+ await Promise.race([this.waitForChange(), abortPromise(deadlineSignal)]);
163
+ }
164
+ catch {
165
+ throw new ChildCleanupFailure(this.children.size);
166
+ }
167
+ }
168
+ }
169
+ groupState(pgid) {
170
+ if (this.deps.processGroupState)
171
+ return this.deps.processGroupState(pgid);
172
+ try {
173
+ process.kill(-pgid, 0);
174
+ return "alive";
175
+ }
176
+ catch (error) {
177
+ const code = error.code;
178
+ if (code === "ESRCH")
179
+ return "gone";
180
+ if (code === "EPERM")
181
+ return "alive";
182
+ return "error";
183
+ }
184
+ }
185
+ startTermination(record, deadlineSignal) {
186
+ if (record.termination)
187
+ return record.termination;
188
+ record.terminationSettled = false;
189
+ record.termination = this.terminateRecord(record, deadlineSignal)
190
+ .finally(() => { record.terminationSettled = true; });
191
+ record.termination.catch(() => undefined);
192
+ return record.termination;
193
+ }
194
+ async terminateRecord(record, deadlineSignal) {
195
+ if (this.platform === "win32") {
196
+ const taskkill = this.deps.taskkillTree
197
+ ? this.deps.taskkillTree(record.pid, deadlineSignal)
198
+ : new Promise((resolve, reject) => {
199
+ const killer = spawn("taskkill", ["/PID", String(record.pid), "/T", "/F"], { windowsHide: true });
200
+ let output = "";
201
+ killer.stdout?.setEncoding("utf8");
202
+ killer.stderr?.setEncoding("utf8");
203
+ killer.stdout?.on("data", (chunk) => { output += chunk; });
204
+ killer.stderr?.on("data", (chunk) => { output += chunk; });
205
+ killer.once("error", reject);
206
+ killer.once("close", (code) => code === 0 || isTaskkillAlreadyGone(output)
207
+ ? resolve()
208
+ : reject(new Error(`taskkill exited ${code}`)));
209
+ });
210
+ try {
211
+ await raceAbort(taskkill, deadlineSignal);
212
+ }
213
+ catch (error) {
214
+ if (error.code !== "ESRCH")
215
+ throw error;
216
+ }
217
+ }
218
+ else {
219
+ if (this.deps.killProcessGroup) {
220
+ this.deps.killProcessGroup(record.pgid ?? record.pid);
221
+ }
222
+ else {
223
+ try {
224
+ process.kill(-(record.pgid ?? record.pid), "SIGKILL");
225
+ }
226
+ catch (error) {
227
+ if (error.code !== "ESRCH")
228
+ throw error;
229
+ }
230
+ }
231
+ }
232
+ await raceAbort(record.leaderClosed, deadlineSignal);
233
+ if (this.platform !== "win32") {
234
+ while (true) {
235
+ const state = this.groupState(record.pgid ?? record.pid);
236
+ if (state === "gone")
237
+ break;
238
+ if (state === "error")
239
+ throw new Error("child process-group probe failed");
240
+ await raceAbort(this.deps.sleep(10, deadlineSignal), deadlineSignal);
241
+ }
242
+ }
243
+ this.children.delete(record.pid);
244
+ this.changed();
245
+ }
246
+ changed() {
247
+ const waiters = this.changeWaiters;
248
+ this.changeWaiters = new Set();
249
+ for (const resolve of waiters)
250
+ resolve();
251
+ }
252
+ waitForChange() {
253
+ return new Promise((resolve) => {
254
+ this.changeWaiters.add(resolve);
255
+ });
256
+ }
257
+ }
258
+ function abortPromise(signal) {
259
+ return new Promise((_, reject) => {
260
+ if (signal.aborted)
261
+ reject(signal.reason);
262
+ else
263
+ signal.addEventListener("abort", () => reject(signal.reason), { once: true });
264
+ });
265
+ }
266
+ async function raceAbort(promise, signal) {
267
+ promise.catch(() => undefined);
268
+ return Promise.race([promise, abortPromise(signal)]);
269
+ }
270
+ export class ChildProcessRegistrySlot {
271
+ deps;
272
+ epoch;
273
+ cleanupFailed = false;
274
+ constructor(deps) {
275
+ this.deps = deps;
276
+ this.epoch = new ChildProcessRegistry(deps);
277
+ }
278
+ get registry() { return this.epoch; }
279
+ get childCleanupFailed() { return this.cleanupFailed; }
280
+ get remainingChildren() { return this.epoch.remainingChildren; }
281
+ beginSpawn() { return this.epoch.beginSpawn(); }
282
+ latchFailure() { this.cleanupFailed = true; }
283
+ clearFailure() { this.cleanupFailed = false; }
284
+ /**
285
+ * Close admission on the current epoch synchronously and return that exact
286
+ * epoch with its drain. Rotation is deliberately a separate commit: a
287
+ * cancel generation must not publish a fresh spawn epoch until both Pi and
288
+ * this captured registry are idle.
289
+ */
290
+ closeEpoch(deadlineSignal) {
291
+ const captured = this.epoch;
292
+ return { epoch: captured, drain: captured.terminateAll(deadlineSignal) };
293
+ }
294
+ commitRotation(captured) {
295
+ if (this.epoch === captured) {
296
+ this.epoch = new ChildProcessRegistry(this.deps);
297
+ this.cleanupFailed = false;
298
+ }
299
+ }
300
+ /** Compatibility seam for focused registry tests; production uses the
301
+ * explicit closeEpoch/commitRotation transaction above. */
302
+ async terminateAll(shouldRotate, deadlineSignal) {
303
+ const { epoch, drain } = this.closeEpoch(deadlineSignal);
304
+ await drain;
305
+ if (shouldRotate())
306
+ this.commitRotation(epoch);
307
+ }
308
+ }
309
+ class BashCleanupSentinel extends Error {
310
+ }
311
+ export function createTrackedBashOperations(slot, shellPath, deps, onCleanupFailure) {
312
+ return {
313
+ async exec(command, cwd, { onData, signal, timeout, env }) {
314
+ if (signal?.aborted)
315
+ throw new Error("aborted");
316
+ if (timeout !== undefined && (!Number.isFinite(timeout) || timeout <= 0)) {
317
+ throw new Error("Invalid timeout: must be a finite number of seconds");
318
+ }
319
+ const timeoutMs = timeout === undefined ? undefined : timeout * 1000;
320
+ if (timeoutMs !== undefined && timeoutMs > 2_147_483_647) {
321
+ throw new Error(`Invalid timeout: maximum is ${2_147_483_647 / 1000} seconds`);
322
+ }
323
+ try {
324
+ await access(cwd, constants.F_OK);
325
+ }
326
+ catch {
327
+ throw new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`);
328
+ }
329
+ // access() is asynchronous. A cancel may have closed the captured
330
+ // generation while it was pending, so re-check before taking a lease.
331
+ if (signal?.aborted)
332
+ throw new Error("aborted");
333
+ const shell = getShellConfig(shellPath);
334
+ const stdin = shell.commandTransport === "stdin";
335
+ const lease = slot.beginSpawn();
336
+ let child;
337
+ try {
338
+ child = spawn(shell.shell, stdin ? shell.args : [...shell.args, command], {
339
+ cwd,
340
+ detached: process.platform !== "win32",
341
+ env,
342
+ stdio: [stdin ? "pipe" : "ignore", "pipe", "pipe"],
343
+ windowsHide: true,
344
+ });
345
+ }
346
+ catch (error) {
347
+ lease.failed();
348
+ throw error;
349
+ }
350
+ let record;
351
+ try {
352
+ record = lease.register(child);
353
+ }
354
+ catch (error) {
355
+ lease.failed();
356
+ try {
357
+ child.kill("SIGKILL");
358
+ }
359
+ catch { /* no PID/tree was admitted */ }
360
+ throw error;
361
+ }
362
+ if (stdin) {
363
+ child.stdin?.on("error", () => undefined);
364
+ child.stdin?.end(command);
365
+ }
366
+ child.stdout?.on("data", onData);
367
+ child.stderr?.on("data", onData);
368
+ const timerController = new AbortController();
369
+ const timeoutResult = timeoutMs === undefined
370
+ ? new Promise(() => undefined)
371
+ : deps.sleep(timeoutMs, timerController.signal).then(() => ({ type: "timeout" }));
372
+ timeoutResult.catch(() => undefined);
373
+ const abortResult = signal
374
+ ? new Promise((resolve) => {
375
+ if (signal.aborted)
376
+ resolve({ type: "abort" });
377
+ else
378
+ signal.addEventListener("abort", () => resolve({ type: "abort" }), { once: true });
379
+ })
380
+ : new Promise(() => undefined);
381
+ const exitResult = new Promise((resolve, reject) => {
382
+ child.once("error", reject);
383
+ child.once("close", (exitCode) => resolve({ type: "exit", exitCode }));
384
+ });
385
+ try {
386
+ const outcome = await Promise.race([exitResult, abortResult, timeoutResult]);
387
+ if (outcome.type === "exit") {
388
+ slot.registry.complete(record);
389
+ return { exitCode: outcome.exitCode };
390
+ }
391
+ try {
392
+ await slot.registry.terminateOne(record);
393
+ }
394
+ catch {
395
+ slot.latchFailure();
396
+ onCleanupFailure();
397
+ throw new BashCleanupSentinel();
398
+ }
399
+ if (outcome.type === "abort")
400
+ throw new Error("aborted");
401
+ throw new Error(`timeout:${timeout}`);
402
+ }
403
+ finally {
404
+ timerController.abort();
405
+ }
406
+ },
407
+ };
408
+ }
package/dist/config.d.ts CHANGED
@@ -1,7 +1,18 @@
1
1
  import type { SessionConfigOption } from "@agentclientprotocol/sdk";
2
2
  import type { AgentSession, ModelRuntime } from "@earendil-works/pi-coding-agent";
3
- export declare const THINKING_LEVELS: readonly ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
4
- export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
3
+ import { type Api, type Model, type ModelThinkingLevel } from "@earendil-works/pi-ai";
4
+ export type ThinkingLevel = ModelThinkingLevel;
5
+ /** Additive ACP metadata understood by AgentPrism's generic config validator. */
6
+ export declare const CONFIG_OPTION_META_NAMESPACE = "@automatalabs/agentprism";
7
+ /**
8
+ * Pi's complete, ordered domain, derived once from pi's own model-aware helper.
9
+ * This is intentionally not a hardcoded mirror of pi's module-private ladder.
10
+ */
11
+ export declare const RECOGNIZED_THINKING_LEVELS: readonly ModelThinkingLevel[];
5
12
  export declare function thinkingLevelOption(session: AgentSession): SessionConfigOption;
6
- export declare function applyConfig(session: AgentSession, modelRuntime: ModelRuntime, configId: string, value: string | boolean): Promise<SessionConfigOption[]>;
13
+ export declare function modelOption(session: AgentSession, availableModels: readonly Model<Api>[]): SessionConfigOption;
14
+ export declare function applyConfig(session: AgentSession, modelRuntime: ModelRuntime, _availableModels: readonly Model<Api>[], configId: string, value: string | boolean): Promise<{
15
+ configOptions: SessionConfigOption[];
16
+ availableModels: readonly Model<Api>[];
17
+ }>;
7
18
  //# sourceMappingURL=config.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AACpE,OAAO,KAAK,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,iCAAiC,CAAC;AAGlF,eAAO,MAAM,eAAe,sEAAuE,CAAC;AACpG,MAAM,MAAM,aAAa,GAAG,CAAC,OAAO,eAAe,CAAC,CAAC,MAAM,CAAC,CAAC;AAE7D,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,YAAY,GAAG,mBAAmB,CAS9E;AAED,wBAAsB,WAAW,CAC/B,OAAO,EAAE,YAAY,EACrB,YAAY,EAAE,YAAY,EAC1B,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,GAAG,OAAO,GACtB,OAAO,CAAC,mBAAmB,EAAE,CAAC,CA4BhC"}
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AACpE,OAAO,KAAK,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,iCAAiC,CAAC;AAClF,OAAO,EAGL,KAAK,GAAG,EACR,KAAK,KAAK,EACV,KAAK,kBAAkB,EACxB,MAAM,uBAAuB,CAAC;AAG/B,MAAM,MAAM,aAAa,GAAG,kBAAkB,CAAC;AAE/C,iFAAiF;AACjF,eAAO,MAAM,4BAA4B,6BAA6B,CAAC;AASvE;;;GAGG;AACH,eAAO,MAAM,0BAA0B,EAAE,SAAS,kBAAkB,EAEnE,CAAC;AAOF,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,YAAY,GAAG,mBAAmB,CAe9E;AAED,wBAAgB,WAAW,CAAC,OAAO,EAAE,YAAY,EAAE,eAAe,EAAE,SAAS,KAAK,CAAC,GAAG,CAAC,EAAE,GAAG,mBAAmB,CAS9G;AAED,wBAAsB,WAAW,CAC/B,OAAO,EAAE,YAAY,EACrB,YAAY,EAAE,YAAY,EAC1B,gBAAgB,EAAE,SAAS,KAAK,CAAC,GAAG,CAAC,EAAE,EACvC,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,GAAG,OAAO,GACtB,OAAO,CAAC;IAAE,aAAa,EAAE,mBAAmB,EAAE,CAAC;IAAC,eAAe,EAAE,SAAS,KAAK,CAAC,GAAG,CAAC,EAAE,CAAA;CAAE,CAAC,CAwC3F"}
package/dist/config.js CHANGED
@@ -1,38 +1,79 @@
1
+ import { clampThinkingLevel, getSupportedThinkingLevels, } from "@earendil-works/pi-ai";
1
2
  import { adapterError } from "./errors.js";
2
- export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
3
+ /** Additive ACP metadata understood by AgentPrism's generic config validator. */
4
+ export const CONFIG_OPTION_META_NAMESPACE = "@automatalabs/agentprism";
5
+ const SYNTHETIC_ALL_THINKING_MODEL = {
6
+ reasoning: {},
7
+ thinkingLevelMap: new Proxy({}, {
8
+ get: (_target, property) => typeof property === "string" ? property : undefined,
9
+ }),
10
+ };
11
+ /**
12
+ * Pi's complete, ordered domain, derived once from pi's own model-aware helper.
13
+ * This is intentionally not a hardcoded mirror of pi's module-private ladder.
14
+ */
15
+ export const RECOGNIZED_THINKING_LEVELS = Object.freeze(getSupportedThinkingLevels(SYNTHETIC_ALL_THINKING_MODEL));
16
+ function supportedThinkingLevels(model) {
17
+ // With no selected model, the pi-derived domain is the only safe best-effort catalog.
18
+ return model ? getSupportedThinkingLevels(model) : RECOGNIZED_THINKING_LEVELS;
19
+ }
3
20
  export function thinkingLevelOption(session) {
21
+ const supported = supportedThinkingLevels(session.model);
4
22
  return {
5
23
  id: "thinkingLevel",
6
24
  name: "Thinking level",
7
25
  type: "select",
8
26
  category: "thought_level",
9
27
  currentValue: session.thinkingLevel,
10
- options: THINKING_LEVELS.map((value) => ({ value, name: value })),
28
+ options: supported.map((value) => ({ value, name: value })),
29
+ _meta: {
30
+ [CONFIG_OPTION_META_NAMESPACE]: {
31
+ recognizedValues: RECOGNIZED_THINKING_LEVELS,
32
+ },
33
+ },
11
34
  };
12
35
  }
13
- export async function applyConfig(session, modelRuntime, configId, value) {
36
+ export function modelOption(session, availableModels) {
37
+ return {
38
+ id: "model",
39
+ name: "Model",
40
+ type: "select",
41
+ category: "model",
42
+ currentValue: session.model ? `${session.model.provider}/${session.model.id}` : "",
43
+ options: availableModels.map((model) => ({ value: `${model.provider}/${model.id}`, name: model.name })),
44
+ };
45
+ }
46
+ export async function applyConfig(session, modelRuntime, _availableModels, configId, value) {
14
47
  if (configId !== "thinkingLevel" && configId !== "model") {
15
48
  throw adapterError("unknown_config_option");
16
49
  }
17
50
  if (typeof value !== "string")
18
51
  throw adapterError("invalid_config_type");
19
52
  if (configId === "thinkingLevel") {
20
- if (!THINKING_LEVELS.includes(value)) {
53
+ if (!RECOGNIZED_THINKING_LEVELS.includes(value)) {
21
54
  throw adapterError("invalid_config_value");
22
55
  }
23
- session.setThinkingLevel(value);
24
- return [thinkingLevelOption(session)];
56
+ const requested = value;
57
+ const supported = supportedThinkingLevels(session.model);
58
+ const effective = session.model && !supported.includes(requested)
59
+ ? clampThinkingLevel(session.model, requested)
60
+ : requested;
61
+ const availableModels = [...await modelRuntime.getAvailable()];
62
+ session.setThinkingLevel(effective);
63
+ return {
64
+ configOptions: [thinkingLevelOption(session), modelOption(session, availableModels)],
65
+ availableModels,
66
+ };
25
67
  }
26
68
  const separator = value.indexOf("/");
27
69
  if (separator <= 0 || separator === value.length - 1)
28
70
  throw adapterError("invalid_model");
29
71
  const provider = value.slice(0, separator);
30
72
  const modelId = value.slice(separator + 1);
31
- const model = modelRuntime.getModel(provider, modelId);
73
+ const availableModels = [...await modelRuntime.getAvailable()];
74
+ const model = availableModels.find((candidate) => candidate.provider === provider && candidate.id === modelId);
32
75
  if (!model)
33
76
  throw adapterError("invalid_model");
34
- if (!modelRuntime.hasConfiguredAuth(model.provider))
35
- throw adapterError("auth_error");
36
77
  try {
37
78
  await session.setModel(model);
38
79
  }
@@ -42,5 +83,8 @@ export async function applyConfig(session, modelRuntime, configId, value) {
42
83
  }
43
84
  throw error;
44
85
  }
45
- return [thinkingLevelOption(session)];
86
+ return {
87
+ configOptions: [thinkingLevelOption(session), modelOption(session, availableModels)],
88
+ availableModels,
89
+ };
46
90
  }
package/dist/deps.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { ModelRuntime, SessionManager, type CreateAgentSessionOptions, type CreateAgentSessionResult, type NewSessionOptions, type SessionInfo } from "@earendil-works/pi-coding-agent";
2
- import type { McpServerStdio } from "@agentclientprotocol/sdk";
3
- import { type McpClientHandle } from "./mcp-bridge.js";
2
+ import type { McpServer } from "@agentclientprotocol/sdk";
3
+ import { type McpClientHandle, type McpSessionBinding } from "./mcp-bridge.js";
4
4
  export interface PiAcpDeps {
5
5
  createAgentSession(opts: CreateAgentSessionOptions): Promise<CreateAgentSessionResult>;
6
6
  sessions: {
@@ -12,7 +12,7 @@ export interface PiAcpDeps {
12
12
  };
13
13
  modelRuntime: ModelRuntime;
14
14
  sessionDir?: string;
15
- connectMcpClient(server: McpServerStdio, signal: AbortSignal): Promise<McpClientHandle>;
15
+ connectMcpClient(server: McpServer, signal: AbortSignal, binding?: McpSessionBinding): Promise<McpClientHandle>;
16
16
  sleep(ms: number, signal: AbortSignal): Promise<void>;
17
17
  graceMs: number;
18
18
  mcpTimeoutMs: number;
@@ -1 +1 @@
1
- {"version":3,"file":"deps.d.ts","sourceRoot":"","sources":["../src/deps.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,YAAY,EACZ,cAAc,EAEd,KAAK,yBAAyB,EAC9B,KAAK,wBAAwB,EAC7B,KAAK,iBAAiB,EACtB,KAAK,WAAW,EACjB,MAAM,iCAAiC,CAAC;AACzC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAE/D,OAAO,EAA2B,KAAK,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAEhF,MAAM,WAAW,SAAS;IACxB,kBAAkB,CAAC,IAAI,EAAE,yBAAyB,GAAG,OAAO,CAAC,wBAAwB,CAAC,CAAC;IACvF,QAAQ,EAAE;QACR,MAAM,CAAC,GAAG,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,iBAAiB,GAAG,cAAc,CAAC;QACtF,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,EAAE,WAAW,CAAC,EAAE,MAAM,GAAG,cAAc,CAAC;QAC9E,QAAQ,CAAC,UAAU,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,iBAAiB,GAAG,cAAc,CAAC;QAClH,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC,CAAC;QAC/D,OAAO,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC,CAAC;KACtD,CAAC;IACF,YAAY,EAAE,YAAY,CAAC;IAC3B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gBAAgB,CAAC,MAAM,EAAE,cAAc,EAAE,MAAM,EAAE,WAAW,GAAG,OAAO,CAAC,eAAe,CAAC,CAAC;IACxF,KAAK,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACtD,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,EAAE,MAAM,CAAC;CACtB;AAED,wBAAgB,SAAS,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAgBxE;AAED,wBAAsB,WAAW,CAAC,OAAO,GAAE,OAAO,CAAC,SAAS,CAAM,GAAG,OAAO,CAAC,SAAS,CAAC,CAuBtF"}
1
+ {"version":3,"file":"deps.d.ts","sourceRoot":"","sources":["../src/deps.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,YAAY,EACZ,cAAc,EAEd,KAAK,yBAAyB,EAC9B,KAAK,wBAAwB,EAC7B,KAAK,iBAAiB,EACtB,KAAK,WAAW,EACjB,MAAM,iCAAiC,CAAC;AACzC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,0BAA0B,CAAC;AAE1D,OAAO,EAEL,KAAK,eAAe,EACpB,KAAK,iBAAiB,EACvB,MAAM,iBAAiB,CAAC;AAEzB,MAAM,WAAW,SAAS;IACxB,kBAAkB,CAAC,IAAI,EAAE,yBAAyB,GAAG,OAAO,CAAC,wBAAwB,CAAC,CAAC;IACvF,QAAQ,EAAE;QACR,MAAM,CAAC,GAAG,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,iBAAiB,GAAG,cAAc,CAAC;QACtF,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,EAAE,WAAW,CAAC,EAAE,MAAM,GAAG,cAAc,CAAC;QAC9E,QAAQ,CAAC,UAAU,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,iBAAiB,GAAG,cAAc,CAAC;QAClH,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC,CAAC;QAC/D,OAAO,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC,CAAC;KACtD,CAAC;IACF,YAAY,EAAE,YAAY,CAAC;IAC3B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gBAAgB,CACd,MAAM,EAAE,SAAS,EACjB,MAAM,EAAE,WAAW,EACnB,OAAO,CAAC,EAAE,iBAAiB,GAC1B,OAAO,CAAC,eAAe,CAAC,CAAC;IAC5B,KAAK,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACtD,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,EAAE,MAAM,CAAC;CACtB;AAED,wBAAgB,SAAS,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAgBxE;AAED,wBAAsB,WAAW,CAAC,OAAO,GAAE,OAAO,CAAC,SAAS,CAAM,GAAG,OAAO,CAAC,SAAS,CAAC,CAuBtF"}
package/dist/deps.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { ModelRuntime, SessionManager, createAgentSession, } from "@earendil-works/pi-coding-agent";
2
2
  import { DEFAULT_REQUEST_TIMEOUT_MSEC } from "@modelcontextprotocol/sdk/shared/protocol.js";
3
- import { connectDefaultMcpClient } from "./mcp-bridge.js";
3
+ import { connectDefaultMcpClient, } from "./mcp-bridge.js";
4
4
  export function realSleep(ms, signal) {
5
5
  return new Promise((resolve, reject) => {
6
6
  if (signal.aborted) {
@@ -34,6 +34,6 @@ export async function resolveDeps(partial = {}) {
34
34
  graceMs: partial.graceMs ?? 5_000,
35
35
  mcpTimeoutMs,
36
36
  connectMcpClient: partial.connectMcpClient ??
37
- ((server, signal) => connectDefaultMcpClient(server, signal, mcpTimeoutMs, sleep)),
37
+ ((server, signal, binding) => connectDefaultMcpClient(server, signal, mcpTimeoutMs, sleep, binding)),
38
38
  };
39
39
  }