cascade-ai 0.80.0 → 0.81.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.cjs +310 -11
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +310 -11
- package/dist/cli.js.map +1 -1
- package/dist/desktop-core.cjs +530 -29
- package/dist/index.cjs +530 -29
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +328 -7
- package/dist/index.d.ts +328 -7
- package/dist/index.js +527 -30
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -11669,7 +11669,7 @@ function reconcilePrice(model, live) {
|
|
|
11669
11669
|
}
|
|
11670
11670
|
|
|
11671
11671
|
// src/constants.ts
|
|
11672
|
-
var CASCADE_VERSION = "0.
|
|
11672
|
+
var CASCADE_VERSION = "0.81.0";
|
|
11673
11673
|
var CASCADE_CONFIG_DIR = ".cascade";
|
|
11674
11674
|
var CASCADE_MD_FILE = "CASCADE.md";
|
|
11675
11675
|
var CASCADE_IGNORE_FILE = ".cascadeignore";
|
|
@@ -30907,8 +30907,95 @@ function requiredFieldFor(kind, input) {
|
|
|
30907
30907
|
}
|
|
30908
30908
|
}
|
|
30909
30909
|
|
|
30910
|
+
// src/tools/ask-user.ts
|
|
30911
|
+
var MAX_QUESTIONS = 5;
|
|
30912
|
+
var MAX_OPTIONS = 6;
|
|
30913
|
+
var AskUserTool = class extends BaseTool {
|
|
30914
|
+
name = "ask_user";
|
|
30915
|
+
description = "Ask the person a short questionnaire when their request is genuinely ambiguous, instead of guessing. Use it when two readings of the request would produce materially different work \u2014 which account, which date range, whether to include drafts \u2014 and NOT for anything you can settle yourself or from the conversation. Ask everything you need in ONE call: several questions together read as a form, the same questions one per turn read as an interrogation. You may get no answer (nobody is watching, or they did not reply); if so, proceed on your best reading and say which assumption you made.";
|
|
30916
|
+
inputSchema = {
|
|
30917
|
+
type: "object",
|
|
30918
|
+
properties: {
|
|
30919
|
+
questions: {
|
|
30920
|
+
type: "array",
|
|
30921
|
+
minItems: 1,
|
|
30922
|
+
maxItems: MAX_QUESTIONS,
|
|
30923
|
+
description: `The questions to ask together (at most ${MAX_QUESTIONS}).`,
|
|
30924
|
+
items: {
|
|
30925
|
+
type: "object",
|
|
30926
|
+
properties: {
|
|
30927
|
+
prompt: { type: "string", description: "The question, as you would say it to them." },
|
|
30928
|
+
kind: {
|
|
30929
|
+
type: "string",
|
|
30930
|
+
enum: ["choice", "multi", "text"],
|
|
30931
|
+
description: "choice = pick one, multi = pick any that apply, text = free-form answer."
|
|
30932
|
+
},
|
|
30933
|
+
options: {
|
|
30934
|
+
type: "array",
|
|
30935
|
+
items: { type: "string" },
|
|
30936
|
+
description: `The answers to offer, for choice and multi (at most ${MAX_OPTIONS}). Omit for text.`
|
|
30937
|
+
}
|
|
30938
|
+
},
|
|
30939
|
+
required: ["prompt", "kind"]
|
|
30940
|
+
}
|
|
30941
|
+
}
|
|
30942
|
+
},
|
|
30943
|
+
required: ["questions"]
|
|
30944
|
+
};
|
|
30945
|
+
ask;
|
|
30946
|
+
constructor(ask) {
|
|
30947
|
+
super();
|
|
30948
|
+
this.ask = ask;
|
|
30949
|
+
}
|
|
30950
|
+
// Asking a question changes nothing, so it needs no approval gate: a prompt
|
|
30951
|
+
// asking permission to show a prompt is a tax on the behaviour we want.
|
|
30952
|
+
isDangerous() {
|
|
30953
|
+
return false;
|
|
30954
|
+
}
|
|
30955
|
+
async execute(input, options) {
|
|
30956
|
+
const raw = Array.isArray(input["questions"]) ? input["questions"] : [];
|
|
30957
|
+
const questions = [];
|
|
30958
|
+
for (const [i, q] of raw.slice(0, MAX_QUESTIONS).entries()) {
|
|
30959
|
+
const item = q ?? {};
|
|
30960
|
+
const prompt = typeof item["prompt"] === "string" ? item["prompt"].trim() : "";
|
|
30961
|
+
if (!prompt) continue;
|
|
30962
|
+
const kind = item["kind"] === "multi" || item["kind"] === "text" ? item["kind"] : "choice";
|
|
30963
|
+
const options_ = Array.isArray(item["options"]) ? item["options"].filter((o) => typeof o === "string" && o.trim() !== "").slice(0, MAX_OPTIONS) : [];
|
|
30964
|
+
const resolved = kind !== "text" && options_.length < 2 ? "text" : kind;
|
|
30965
|
+
questions.push({
|
|
30966
|
+
// Positional, and assigned HERE. Letting the model supply ids invites
|
|
30967
|
+
// two questions sharing one, and the second answer would overwrite the
|
|
30968
|
+
// first with nothing to say it had.
|
|
30969
|
+
id: `q${i + 1}`,
|
|
30970
|
+
prompt,
|
|
30971
|
+
kind: resolved,
|
|
30972
|
+
...resolved === "text" ? {} : { options: options_ }
|
|
30973
|
+
});
|
|
30974
|
+
}
|
|
30975
|
+
if (questions.length === 0) {
|
|
30976
|
+
return "Error: ask_user needs at least one question with a prompt.";
|
|
30977
|
+
}
|
|
30978
|
+
const result = await this.ask(questions, options.signal);
|
|
30979
|
+
if (result.outcome !== "answered" || result.answers.length === 0) {
|
|
30980
|
+
const why = result.outcome === "no-listener" ? "There is nobody watching this run to answer" : result.outcome === "aborted" ? "The run was stopped while the question was open" : result.outcome === "answered" ? "They saw the questions and chose not to answer" : "They did not answer in time";
|
|
30981
|
+
return `${why}. Proceed on your best reading of the request, and say plainly which assumption you made.`;
|
|
30982
|
+
}
|
|
30983
|
+
const byId = new Map(result.answers.map((a) => [a.id, a.value]));
|
|
30984
|
+
const lines = questions.map((q) => {
|
|
30985
|
+
const value = byId.get(q.id);
|
|
30986
|
+
const said = Array.isArray(value) ? value.join(", ") : value;
|
|
30987
|
+
return `${q.prompt}
|
|
30988
|
+
\u2192 ${said && said.length > 0 ? said : "(no answer given)"}`;
|
|
30989
|
+
});
|
|
30990
|
+
return `They answered:
|
|
30991
|
+
|
|
30992
|
+
${lines.join("\n\n")}`;
|
|
30993
|
+
}
|
|
30994
|
+
};
|
|
30995
|
+
|
|
30910
30996
|
// src/core/cascade.ts
|
|
30911
30997
|
var ESCALATION_DECISION_TIMEOUT_MS = 5 * 6e4;
|
|
30998
|
+
var CLARIFICATION_TIMEOUT_MS = 2 * 6e4;
|
|
30912
30999
|
function buildContextualPrompt(prompt, history = []) {
|
|
30913
31000
|
const recent = history.slice(-6);
|
|
30914
31001
|
if (recent.length === 0) return prompt;
|
|
@@ -31187,6 +31274,112 @@ var Cascade = class _Cascade extends EventEmitter__default.default {
|
|
|
31187
31274
|
// timeout. Same class of bug as the twelve-failover dead-model race: a wave
|
|
31188
31275
|
// is concurrent, so per-run singletons are never safe inside it.
|
|
31189
31276
|
pendingEscalations = /* @__PURE__ */ new Map();
|
|
31277
|
+
/**
|
|
31278
|
+
* Questionnaires waiting on a person, keyed by request id.
|
|
31279
|
+
*
|
|
31280
|
+
* Exactly the shape `pendingEscalations` uses, and for the same reason: the
|
|
31281
|
+
* answer, the timeout and the abort all race, and one settle path guarded by
|
|
31282
|
+
* the map delete is what makes the first of them win.
|
|
31283
|
+
*/
|
|
31284
|
+
pendingClarifications = /* @__PURE__ */ new Map();
|
|
31285
|
+
/**
|
|
31286
|
+
* Put a gate's opening question to its listeners, and NOTICE when one fails.
|
|
31287
|
+
*
|
|
31288
|
+
* `emit` cannot do this job. It calls listeners synchronously and discards
|
|
31289
|
+
* what they return, so an `async` listener — the ordinary shape for anything
|
|
31290
|
+
* that has to reach a socket or a UI — does not fail in a way `emit` reports:
|
|
31291
|
+
* it returns a REJECTED PROMISE that nobody is holding. The `try` around the
|
|
31292
|
+
* emit sees nothing, the gate is never told delivery failed, and it parks for
|
|
31293
|
+
* its full timeout while Node raises an unhandled rejection that can take the
|
|
31294
|
+
* process down.
|
|
31295
|
+
*
|
|
31296
|
+
* `onFailed` is called for either shape, and it is safe to call more than
|
|
31297
|
+
* once: both gates route it to `settle`, which is guarded by a map delete, so
|
|
31298
|
+
* the first failure wins and the rest are no-ops.
|
|
31299
|
+
*
|
|
31300
|
+
* PER LISTENER, not around the loop. `emit` stops dispatching at the first
|
|
31301
|
+
* throw, so one broken host used to cost a working one its notification —
|
|
31302
|
+
* the same reason `announceLiveView` delivers its two listeners separately.
|
|
31303
|
+
* A synchronous failure settles the gate once the whole snapshot has been
|
|
31304
|
+
* delivered; an asynchronous one settles it when the rejection arrives, which
|
|
31305
|
+
* is still far better than the window-long park it replaces.
|
|
31306
|
+
*
|
|
31307
|
+
* SETTLED AFTER THE LOOP, not inside it, and that is not tidiness. `onFailed`
|
|
31308
|
+
* settles the gate, and settling emits the gate's CLOSURE — so failing
|
|
31309
|
+
* mid-dispatch announced "this request is over" and then went on handing the
|
|
31310
|
+
* OPENING event to the listeners after it. A host later in the list would
|
|
31311
|
+
* draw a form whose only closure notification had already gone past, and it
|
|
31312
|
+
* would stand there forever. Deliver to everyone, then decide.
|
|
31313
|
+
*
|
|
31314
|
+
* `rawListeners`, NOT `listeners`. Bypassing `emit` means taking on what
|
|
31315
|
+
* `emit` was doing, and `once` is the part that bites: `listeners()` hands
|
|
31316
|
+
* back the UNWRAPPED callback, so calling it never runs the wrapper that
|
|
31317
|
+
* deregisters it. A host that asked to be told once would be told every
|
|
31318
|
+
* time — and told through a transport it had set up for one gate only.
|
|
31319
|
+
* `rawListeners()` returns the wrapper, which removes the registration and
|
|
31320
|
+
* passes the underlying listener's return value straight through, so the
|
|
31321
|
+
* promise below is still the one being observed. Verified against Node.
|
|
31322
|
+
*
|
|
31323
|
+
* `.call(this, …)`, which is the third thing `emit` was doing. EventEmitter
|
|
31324
|
+
* invokes listeners with the emitter as the receiver, so
|
|
31325
|
+
* `function () { this.resolveClarification(...) }` is a legitimate host — and
|
|
31326
|
+
* calling it bare gave it `this === undefined`, which throws, which this
|
|
31327
|
+
* would then have read as the host failing to receive the question and
|
|
31328
|
+
* settled the gate unanswered. A working listener turned into a delivery
|
|
31329
|
+
* failure by the thing delivering it.
|
|
31330
|
+
*/
|
|
31331
|
+
deliverGate(event, payload, onFailed) {
|
|
31332
|
+
let delivered = false;
|
|
31333
|
+
let threw = false;
|
|
31334
|
+
const pending = [];
|
|
31335
|
+
for (const listener of this.rawListeners(event)) {
|
|
31336
|
+
try {
|
|
31337
|
+
const out = listener.call(this, payload);
|
|
31338
|
+
if (out && typeof out.then === "function") {
|
|
31339
|
+
pending.push(Promise.resolve(out).then(() => true, () => false));
|
|
31340
|
+
} else {
|
|
31341
|
+
delivered = true;
|
|
31342
|
+
}
|
|
31343
|
+
} catch {
|
|
31344
|
+
threw = true;
|
|
31345
|
+
}
|
|
31346
|
+
}
|
|
31347
|
+
if (delivered) return;
|
|
31348
|
+
if (pending.length === 0) {
|
|
31349
|
+
if (threw) onFailed();
|
|
31350
|
+
return;
|
|
31351
|
+
}
|
|
31352
|
+
void Promise.all(pending).then((results) => {
|
|
31353
|
+
if (!results.some(Boolean)) onFailed();
|
|
31354
|
+
});
|
|
31355
|
+
}
|
|
31356
|
+
/**
|
|
31357
|
+
* Tell listeners something that is over, and let none of them make it worse.
|
|
31358
|
+
*
|
|
31359
|
+
* The closing half of the gate family — `clarification:closed`,
|
|
31360
|
+
* `clarification:timeout`, `escalation:closed`, `escalation:timeout`. Each
|
|
31361
|
+
* was already wrapped in a `try` for the synchronous case, and each had the
|
|
31362
|
+
* same hole `deliverGate` had before last round: `emit` discards what a
|
|
31363
|
+
* listener returns, so an `async` host fails by returning a rejected promise
|
|
31364
|
+
* nobody is holding and Node raises an unhandled rejection.
|
|
31365
|
+
*
|
|
31366
|
+
* There is nothing to settle here — the gate is already closed, which is the
|
|
31367
|
+
* whole message — so unlike `deliverGate` this reports to no one. It exists
|
|
31368
|
+
* so that the four informational emits cannot take the process down, and so
|
|
31369
|
+
* the rule is written once rather than four times.
|
|
31370
|
+
*/
|
|
31371
|
+
announceGateClosed(event, payload) {
|
|
31372
|
+
for (const listener of this.rawListeners(event)) {
|
|
31373
|
+
try {
|
|
31374
|
+
const out = listener.call(this, payload);
|
|
31375
|
+
if (out && typeof out.then === "function") {
|
|
31376
|
+
void Promise.resolve(out).catch(() => {
|
|
31377
|
+
});
|
|
31378
|
+
}
|
|
31379
|
+
} catch {
|
|
31380
|
+
}
|
|
31381
|
+
}
|
|
31382
|
+
}
|
|
31190
31383
|
async requestEscalationDecision(ctx, taskId, signal) {
|
|
31191
31384
|
if (this.config.autonomy === "auto") return { action: "skip", automatic: true };
|
|
31192
31385
|
if (this.listenerCount("escalation:decision-required") === 0) return { action: "skip", automatic: true };
|
|
@@ -31199,23 +31392,28 @@ var Cascade = class _Cascade extends EventEmitter__default.default {
|
|
|
31199
31392
|
if (timeout) clearTimeout(timeout);
|
|
31200
31393
|
signal?.removeEventListener("abort", onAbort);
|
|
31201
31394
|
resolve2(decision);
|
|
31395
|
+
this.announceGateClosed("escalation:closed", { taskId, requestId, sectionId: ctx.sectionId });
|
|
31202
31396
|
};
|
|
31203
31397
|
const onAbort = () => settle({ action: "skip", automatic: true });
|
|
31204
31398
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
31205
31399
|
timeout = setTimeout(() => {
|
|
31206
|
-
this.emit("escalation:timeout", { taskId, requestId, sectionId: ctx.sectionId });
|
|
31207
31400
|
settle({ action: "timeout" });
|
|
31401
|
+
this.announceGateClosed("escalation:timeout", { taskId, requestId, sectionId: ctx.sectionId });
|
|
31208
31402
|
}, ESCALATION_DECISION_TIMEOUT_MS);
|
|
31209
31403
|
this.pendingEscalations.set(requestId, settle);
|
|
31210
|
-
this.
|
|
31211
|
-
|
|
31212
|
-
|
|
31213
|
-
|
|
31214
|
-
|
|
31215
|
-
|
|
31216
|
-
|
|
31217
|
-
|
|
31218
|
-
|
|
31404
|
+
this.deliverGate(
|
|
31405
|
+
"escalation:decision-required",
|
|
31406
|
+
{
|
|
31407
|
+
taskId,
|
|
31408
|
+
requestId,
|
|
31409
|
+
sectionId: ctx.sectionId,
|
|
31410
|
+
sectionTitle: ctx.sectionTitle,
|
|
31411
|
+
issues: ctx.issues,
|
|
31412
|
+
summary: ctx.summary,
|
|
31413
|
+
timeoutMs: ESCALATION_DECISION_TIMEOUT_MS
|
|
31414
|
+
},
|
|
31415
|
+
() => settle({ action: "skip", automatic: true })
|
|
31416
|
+
);
|
|
31219
31417
|
});
|
|
31220
31418
|
}
|
|
31221
31419
|
/**
|
|
@@ -31252,6 +31450,73 @@ var Cascade = class _Cascade extends EventEmitter__default.default {
|
|
|
31252
31450
|
releasePendingEscalations(action = "skip") {
|
|
31253
31451
|
for (const settle of Array.from(this.pendingEscalations.values())) settle({ action, automatic: true });
|
|
31254
31452
|
}
|
|
31453
|
+
// ── Clarification: asking instead of assuming ───────────────────────
|
|
31454
|
+
// The same gate shape as the escalation above, and for the same reasons. The
|
|
31455
|
+
// difference that matters: an unanswered escalation SKIPS a section, while an
|
|
31456
|
+
// unanswered question just means the model proceeds on its own reading — so
|
|
31457
|
+
// every non-answer here is benign, and none of them may park the run.
|
|
31458
|
+
/**
|
|
31459
|
+
* Put a questionnaire to whoever is watching, if anybody is.
|
|
31460
|
+
*
|
|
31461
|
+
* Returns `no-listener` rather than waiting when there is nothing attached to
|
|
31462
|
+
* answer — a scheduled run, an API caller with no socket, an autonomous run
|
|
31463
|
+
* that has already said it wants no prompts. That check is the whole reason
|
|
31464
|
+
* this can be offered to the model at all: a tool that might hang a headless
|
|
31465
|
+
* run forever is a tool that cannot be registered.
|
|
31466
|
+
*/
|
|
31467
|
+
async askUser(questions, signal) {
|
|
31468
|
+
const none = (outcome) => ({ outcome, answers: [] });
|
|
31469
|
+
if (this.unattended) return none("no-listener");
|
|
31470
|
+
if (this.config.autonomy === "auto") return none("no-listener");
|
|
31471
|
+
if (this.listenerCount("clarification:required") === 0) return none("no-listener");
|
|
31472
|
+
if (signal?.aborted) return none("aborted");
|
|
31473
|
+
const requestId = crypto.randomUUID();
|
|
31474
|
+
return await new Promise((resolve2) => {
|
|
31475
|
+
let timeout;
|
|
31476
|
+
const settle = (result) => {
|
|
31477
|
+
if (!this.pendingClarifications.delete(requestId)) return;
|
|
31478
|
+
if (timeout) clearTimeout(timeout);
|
|
31479
|
+
signal?.removeEventListener("abort", onAbort);
|
|
31480
|
+
resolve2(result);
|
|
31481
|
+
this.announceGateClosed("clarification:closed", { requestId });
|
|
31482
|
+
};
|
|
31483
|
+
const onAbort = () => settle(none("aborted"));
|
|
31484
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
31485
|
+
timeout = setTimeout(() => {
|
|
31486
|
+
settle(none("timeout"));
|
|
31487
|
+
this.announceGateClosed("clarification:timeout", { requestId });
|
|
31488
|
+
}, CLARIFICATION_TIMEOUT_MS);
|
|
31489
|
+
this.pendingClarifications.set(requestId, settle);
|
|
31490
|
+
this.deliverGate(
|
|
31491
|
+
"clarification:required",
|
|
31492
|
+
{ requestId, questions, timeoutMs: CLARIFICATION_TIMEOUT_MS },
|
|
31493
|
+
() => settle(none("no-listener"))
|
|
31494
|
+
);
|
|
31495
|
+
});
|
|
31496
|
+
}
|
|
31497
|
+
/**
|
|
31498
|
+
* Answer a questionnaire from a REPL / dashboard / cloud listener.
|
|
31499
|
+
*
|
|
31500
|
+
* `requestId` comes back on the `clarification:required` event and is what
|
|
31501
|
+
* makes an answer land on the question that asked. Optional for a host with
|
|
31502
|
+
* only one in flight, matching `resolveEscalation`: without it the oldest
|
|
31503
|
+
* outstanding request is answered, which is correct whenever there is one.
|
|
31504
|
+
*/
|
|
31505
|
+
resolveClarification(answers, requestId) {
|
|
31506
|
+
const result = { outcome: "answered", answers };
|
|
31507
|
+
if (requestId) {
|
|
31508
|
+
this.pendingClarifications.get(requestId)?.(result);
|
|
31509
|
+
return;
|
|
31510
|
+
}
|
|
31511
|
+
const oldest = this.pendingClarifications.keys().next();
|
|
31512
|
+
if (!oldest.done) this.pendingClarifications.get(oldest.value)?.(result);
|
|
31513
|
+
}
|
|
31514
|
+
/** Release every parked questionnaire — run teardown, so none outlive the run. */
|
|
31515
|
+
releasePendingClarifications() {
|
|
31516
|
+
for (const settle of Array.from(this.pendingClarifications.values())) {
|
|
31517
|
+
settle({ outcome: "aborted", answers: [] });
|
|
31518
|
+
}
|
|
31519
|
+
}
|
|
31255
31520
|
/**
|
|
31256
31521
|
* Resolve a pending boardroom plan approval from a REPL / dashboard listener.
|
|
31257
31522
|
* An optional `note` re-plans and re-asks; an optional `editedPlan` is applied
|
|
@@ -31276,12 +31541,14 @@ var Cascade = class _Cascade extends EventEmitter__default.default {
|
|
|
31276
31541
|
const timeout = setTimeout(() => {
|
|
31277
31542
|
if (this.pendingContextApproval) {
|
|
31278
31543
|
this.pendingContextApproval = void 0;
|
|
31544
|
+
this.announceGateClosed("context:approval-closed", {});
|
|
31279
31545
|
resolve2(true);
|
|
31280
31546
|
}
|
|
31281
31547
|
}, 12e4);
|
|
31282
31548
|
this.pendingContextApproval = (approved) => {
|
|
31283
31549
|
clearTimeout(timeout);
|
|
31284
31550
|
this.pendingContextApproval = void 0;
|
|
31551
|
+
this.announceGateClosed("context:approval-closed", {});
|
|
31285
31552
|
resolve2(approved);
|
|
31286
31553
|
};
|
|
31287
31554
|
this.emit("context:approval-required", info);
|
|
@@ -31472,6 +31739,34 @@ ${input}` }],
|
|
|
31472
31739
|
if ((this.config.tools?.disabledTools ?? []).includes("browser_control")) return;
|
|
31473
31740
|
this.toolRegistry.register(new BrowserControlTool(controller, release));
|
|
31474
31741
|
}
|
|
31742
|
+
/**
|
|
31743
|
+
* Declare that this host can put a questionnaire to a person and answer it.
|
|
31744
|
+
*
|
|
31745
|
+
* Explicit, and NOT done at construction, because registering a tool is a
|
|
31746
|
+
* promise the host has to keep. A host that registers `ask_user` without
|
|
31747
|
+
* listening for `clarification:required` gives the model a tool that always
|
|
31748
|
+
* answers "there is nobody watching this run" — while a person sits at the
|
|
31749
|
+
* terminal looking at it. That is worse than not offering the tool at all:
|
|
31750
|
+
* it costs a round trip and then tells the model something false.
|
|
31751
|
+
*
|
|
31752
|
+
* Only `cloud/server` bridges the protocol today. The CLI REPL and the
|
|
31753
|
+
* dashboard do not, so they do not call this, and their models are not shown
|
|
31754
|
+
* a capability nobody can serve. Wiring either of them is a matter of
|
|
31755
|
+
* listening for the event and calling `resolveClarification`, then calling
|
|
31756
|
+
* this.
|
|
31757
|
+
*
|
|
31758
|
+
* Still gated on the same conditions as the call itself, and `askUser`
|
|
31759
|
+
* re-checks them: `setUnattended` can land after this, so the tool can be
|
|
31760
|
+
* registered for a run that later turns out to have nobody on it. Then it
|
|
31761
|
+
* answers `no-listener` and the model proceeds on its own reading, which is
|
|
31762
|
+
* the same defence-in-depth `BrowserControlTool.revoke` provides.
|
|
31763
|
+
*/
|
|
31764
|
+
enableClarification() {
|
|
31765
|
+
if (this.unattended) return;
|
|
31766
|
+
if (this.config.autonomy === "auto") return;
|
|
31767
|
+
if ((this.config.tools?.disabledTools ?? []).includes("ask_user")) return;
|
|
31768
|
+
this.toolRegistry.register(new AskUserTool((questions, signal) => this.askUser(questions, signal)));
|
|
31769
|
+
}
|
|
31475
31770
|
registerMediaTools(workspacePath) {
|
|
31476
31771
|
const denied = new Set(this.config.tools?.disabledTools ?? []);
|
|
31477
31772
|
const permitted = (name) => !denied.has(name);
|
|
@@ -32417,6 +32712,10 @@ ${finalOutput}` : ""
|
|
|
32417
32712
|
this.releasePendingEscalations();
|
|
32418
32713
|
} catch {
|
|
32419
32714
|
}
|
|
32715
|
+
try {
|
|
32716
|
+
this.releasePendingClarifications();
|
|
32717
|
+
} catch {
|
|
32718
|
+
}
|
|
32420
32719
|
this.router.restoreTierModels();
|
|
32421
32720
|
this.router.setRunSignal(void 0);
|
|
32422
32721
|
if (this.taskAnalyzer) {
|
|
@@ -35491,6 +35790,13 @@ var RESTORE_FRAME_MS = 2e3;
|
|
|
35491
35790
|
var MAX_HUMAN_INPUT_QUEUE = 64;
|
|
35492
35791
|
var MAX_CAPTURE_TRANSITION_QUEUE = 16;
|
|
35493
35792
|
var MAX_WATCH_LIFECYCLE_QUEUE = 16;
|
|
35793
|
+
function notify(deliver) {
|
|
35794
|
+
try {
|
|
35795
|
+
void Promise.resolve(deliver()).catch(() => {
|
|
35796
|
+
});
|
|
35797
|
+
} catch {
|
|
35798
|
+
}
|
|
35799
|
+
}
|
|
35494
35800
|
var RemoteBrowserController = class {
|
|
35495
35801
|
provider;
|
|
35496
35802
|
maxSessions;
|
|
@@ -35516,6 +35822,36 @@ var RemoteBrowserController = class {
|
|
|
35516
35822
|
controlAnnounced = /* @__PURE__ */ new Map();
|
|
35517
35823
|
/** An embedder that wants every run's live view, told which run each is. */
|
|
35518
35824
|
onLiveViewAll;
|
|
35825
|
+
/** Told when a provider session could not be handed back. See `disposeRun`. */
|
|
35826
|
+
onReleaseFailed;
|
|
35827
|
+
/**
|
|
35828
|
+
* Tell the observer a session leaked, without letting it become the leak.
|
|
35829
|
+
*
|
|
35830
|
+
* Guarded, because this runs inside a `.catch` on the release: an observer
|
|
35831
|
+
* that throws — a logging or telemetry sink that is momentarily unavailable
|
|
35832
|
+
* is the ordinary case — would turn its own failure into a fresh rejection
|
|
35833
|
+
* from `disposeRun`, so `endRun` would reject before `forgetRun` and the
|
|
35834
|
+
* fire-and-forget `stopRun` path would raise an unhandled rejection.
|
|
35835
|
+
*
|
|
35836
|
+
* That is precisely the guarantee this whole change claims to preserve: a
|
|
35837
|
+
* release that fails must never fail the run. Reporting it cannot be the
|
|
35838
|
+
* thing that breaks it.
|
|
35839
|
+
*
|
|
35840
|
+
* TWO WAYS TO FAIL, not one. The `try` catches a synchronous throw; an
|
|
35841
|
+
* `async` sink — which is what a telemetry or logging callback usually is —
|
|
35842
|
+
* fails by returning a REJECTED PROMISE, which no `try` around a call that is
|
|
35843
|
+
* never awaited can see. Node then raises an unhandled rejection and can take
|
|
35844
|
+
* the process down: the observer would not merely have failed to report the
|
|
35845
|
+
* leak, it would have killed the run, which is the one outcome this function
|
|
35846
|
+
* exists to prevent, reached through the door the guard did not cover.
|
|
35847
|
+
*
|
|
35848
|
+
* Not awaited, though. The caller is a `.catch` on the release and is not
|
|
35849
|
+
* going to wait for a telemetry round trip; attaching a handler is what makes
|
|
35850
|
+
* the rejection observed, which is the whole requirement.
|
|
35851
|
+
*/
|
|
35852
|
+
reportReleaseFailure(runId, sessionId, err, expiresInMs) {
|
|
35853
|
+
notify(() => this.onReleaseFailed?.(runId, sessionId, err, expiresInMs));
|
|
35854
|
+
}
|
|
35519
35855
|
runs = /* @__PURE__ */ new Map();
|
|
35520
35856
|
/**
|
|
35521
35857
|
* Runs that have a pool slot but no browser yet.
|
|
@@ -35574,6 +35910,7 @@ var RemoteBrowserController = class {
|
|
|
35574
35910
|
const asked = Math.max(1, options.maxSessions ?? 1);
|
|
35575
35911
|
this.maxSessions = options.provider.isolatesSessions ? asked : 1;
|
|
35576
35912
|
this.onLiveViewAll = options.onLiveView;
|
|
35913
|
+
this.onReleaseFailed = options.onReleaseFailed;
|
|
35577
35914
|
this.ready = options.ready;
|
|
35578
35915
|
}
|
|
35579
35916
|
/**
|
|
@@ -35658,7 +35995,7 @@ var RemoteBrowserController = class {
|
|
|
35658
35995
|
const key = `${state.human}:${state.capturing}:${state.confirmed}`;
|
|
35659
35996
|
if (!force && this.controlAnnounced.get(runId) === key) return;
|
|
35660
35997
|
this.controlAnnounced.set(runId, key);
|
|
35661
|
-
listener(state);
|
|
35998
|
+
notify(() => listener(state));
|
|
35662
35999
|
}
|
|
35663
36000
|
/**
|
|
35664
36001
|
* Tell a run's listener what its browser situation is.
|
|
@@ -35669,9 +36006,32 @@ var RemoteBrowserController = class {
|
|
|
35669
36006
|
* apart — and would drop the Stop control for the first as if it were the
|
|
35670
36007
|
* second.
|
|
35671
36008
|
*/
|
|
36009
|
+
/*
|
|
36010
|
+
* Guarded HERE rather than at the call sites, which is the point.
|
|
36011
|
+
*
|
|
36012
|
+
* This is the fifth place in this changeset where a notification could take
|
|
36013
|
+
* down the thing it was notifying about — after the release observer,
|
|
36014
|
+
* `clarification:closed`, `clarification:timeout` and `escalation:timeout`.
|
|
36015
|
+
* The first four were fixed one door at a time, which is how a fifth door
|
|
36016
|
+
* came to exist: the announcement added on the reuse path propagated out of
|
|
36017
|
+
* `open()`, `act()` caught it as an action failure, and every later action
|
|
36018
|
+
* against a perfectly healthy browser was skipped.
|
|
36019
|
+
*
|
|
36020
|
+
* Putting the guard at the source closes all four call sites at once and
|
|
36021
|
+
* leaves no door for a sixth. The worst of them is not the reported one:
|
|
36022
|
+
* `disposeRun` announces `active: false` as its FIRST statement, so a
|
|
36023
|
+
* throwing listener aborted teardown before the screencast was stopped, the
|
|
36024
|
+
* context closed, or the session handed back — leaking the provider session
|
|
36025
|
+
* and never reaching `reportReleaseFailure`. The leak report would have been
|
|
36026
|
+
* skipped by exactly the failure it exists to report.
|
|
36027
|
+
*
|
|
36028
|
+
* Two separate guards, not one around both: these are different listeners —
|
|
36029
|
+
* the per-run panel and the global observer — and one failing must not cost
|
|
36030
|
+
* the other its notification.
|
|
36031
|
+
*/
|
|
35672
36032
|
announceLiveView(runId, liveViewUrl, active) {
|
|
35673
|
-
this.liveViewListeners.get(runId)?.({ active, ...liveViewUrl ? { liveViewUrl } : {} });
|
|
35674
|
-
this.onLiveViewAll?.(runId, liveViewUrl);
|
|
36033
|
+
notify(() => this.liveViewListeners.get(runId)?.({ active, ...liveViewUrl ? { liveViewUrl } : {} }));
|
|
36034
|
+
notify(() => this.onLiveViewAll?.(runId, liveViewUrl));
|
|
35675
36035
|
}
|
|
35676
36036
|
/**
|
|
35677
36037
|
* Hand back a session the run turned out not to want.
|
|
@@ -35882,7 +36242,8 @@ var RemoteBrowserController = class {
|
|
|
35882
36242
|
ping?.();
|
|
35883
36243
|
const deliver = held.onFrame;
|
|
35884
36244
|
if (deliver) {
|
|
35885
|
-
|
|
36245
|
+
const frame = { data: e.data, width, height, generation: held.watchGen ?? 0 };
|
|
36246
|
+
notify(() => deliver(frame));
|
|
35886
36247
|
}
|
|
35887
36248
|
}
|
|
35888
36249
|
void cdp.send("Page.screencastFrameAck", { sessionId: e?.sessionId }).catch(() => {
|
|
@@ -36411,8 +36772,7 @@ var RemoteBrowserController = class {
|
|
|
36411
36772
|
});
|
|
36412
36773
|
await held.browser.close().catch(() => {
|
|
36413
36774
|
});
|
|
36414
|
-
await this.provider.endSession(held.session.id).catch(() =>
|
|
36415
|
-
});
|
|
36775
|
+
await this.provider.endSession(held.session.id).catch((err) => this.reportReleaseFailure(runId, held.session.id, err, held.session.expiresInMs));
|
|
36416
36776
|
}
|
|
36417
36777
|
/**
|
|
36418
36778
|
* Run `disposeRun`, with the run id counted in `closing` until it settles.
|
|
@@ -36571,11 +36931,17 @@ var RemoteBrowserController = class {
|
|
|
36571
36931
|
if (this.ready === settling) this.ready = void 0;
|
|
36572
36932
|
}
|
|
36573
36933
|
const existing = this.runs.get(runId);
|
|
36574
|
-
if (existing && !existing.page.isClosed())
|
|
36934
|
+
if (existing && !existing.page.isClosed()) {
|
|
36935
|
+
this.announceLiveView(runId, existing.session.liveViewUrl, true);
|
|
36936
|
+
return existing;
|
|
36937
|
+
}
|
|
36575
36938
|
if (existing && existing.page.isClosed()) {
|
|
36576
36939
|
const fallback = existing.page.context().pages().filter((p) => !p.isClosed()).at(-1);
|
|
36577
36940
|
if (fallback) {
|
|
36578
36941
|
await this.adoptPage(runId, existing, fallback);
|
|
36942
|
+
if (this.runs.get(runId) === existing && !existing.abort.signal.aborted) {
|
|
36943
|
+
this.announceLiveView(runId, existing.session.liveViewUrl, true);
|
|
36944
|
+
}
|
|
36579
36945
|
return existing;
|
|
36580
36946
|
}
|
|
36581
36947
|
}
|
|
@@ -36624,8 +36990,7 @@ var RemoteBrowserController = class {
|
|
|
36624
36990
|
});
|
|
36625
36991
|
await browser?.close().catch(() => {
|
|
36626
36992
|
});
|
|
36627
|
-
await this.provider.endSession(session.id).catch(() =>
|
|
36628
|
-
});
|
|
36993
|
+
await this.provider.endSession(session.id).catch((releaseErr) => this.reportReleaseFailure(runId, session.id, releaseErr, session.expiresInMs));
|
|
36629
36994
|
throw err;
|
|
36630
36995
|
}
|
|
36631
36996
|
this.announceLiveView(runId, session.liveViewUrl, true);
|
|
@@ -36835,6 +37200,139 @@ function isCdpEndpoint(url) {
|
|
|
36835
37200
|
|
|
36836
37201
|
// src/browser/remote/steel.ts
|
|
36837
37202
|
var DEFAULT_BASE = "https://api.steel.dev";
|
|
37203
|
+
function isHostedSteel(url) {
|
|
37204
|
+
if (!url) return true;
|
|
37205
|
+
try {
|
|
37206
|
+
return hostKey(new URL(url).hostname) === hostKey(new URL(DEFAULT_BASE).hostname);
|
|
37207
|
+
} catch {
|
|
37208
|
+
return false;
|
|
37209
|
+
}
|
|
37210
|
+
}
|
|
37211
|
+
function isUsableSteelBase(url) {
|
|
37212
|
+
try {
|
|
37213
|
+
const u = new URL(url);
|
|
37214
|
+
if (u.protocol !== "http:" && u.protocol !== "https:") return false;
|
|
37215
|
+
if (u.username !== "" || u.password !== "") return false;
|
|
37216
|
+
const built = new URL(withoutTrailingSlashes(url) + PROBE_PATH);
|
|
37217
|
+
if (FETCH_BLOCKED_PORTS.has(built.port)) return false;
|
|
37218
|
+
if (built.search !== "" || built.hash !== "") return false;
|
|
37219
|
+
if (!built.pathname.endsWith(PROBE_PATH)) return false;
|
|
37220
|
+
if (isHostedSteel(url)) {
|
|
37221
|
+
if (built.pathname !== PROBE_PATH) return false;
|
|
37222
|
+
if (u.protocol !== "https:") return false;
|
|
37223
|
+
if (built.port !== "") return false;
|
|
37224
|
+
}
|
|
37225
|
+
return true;
|
|
37226
|
+
} catch {
|
|
37227
|
+
return false;
|
|
37228
|
+
}
|
|
37229
|
+
}
|
|
37230
|
+
function isUsableSteelKey(apiKey) {
|
|
37231
|
+
for (const ch of apiKey) {
|
|
37232
|
+
const c = ch.codePointAt(0) ?? 0;
|
|
37233
|
+
if (c === 9) continue;
|
|
37234
|
+
if (c >= 32 && c <= 126) continue;
|
|
37235
|
+
if (c >= 128 && c <= 255) continue;
|
|
37236
|
+
return false;
|
|
37237
|
+
}
|
|
37238
|
+
return true;
|
|
37239
|
+
}
|
|
37240
|
+
var API_KEY_HEADER = "steel-api-key";
|
|
37241
|
+
var FETCH_BLOCKED_PORTS = /* @__PURE__ */ new Set([
|
|
37242
|
+
"1",
|
|
37243
|
+
"7",
|
|
37244
|
+
"9",
|
|
37245
|
+
"11",
|
|
37246
|
+
"13",
|
|
37247
|
+
"15",
|
|
37248
|
+
"17",
|
|
37249
|
+
"19",
|
|
37250
|
+
"20",
|
|
37251
|
+
"21",
|
|
37252
|
+
"22",
|
|
37253
|
+
"23",
|
|
37254
|
+
"25",
|
|
37255
|
+
"37",
|
|
37256
|
+
"42",
|
|
37257
|
+
"43",
|
|
37258
|
+
"53",
|
|
37259
|
+
"69",
|
|
37260
|
+
"77",
|
|
37261
|
+
"79",
|
|
37262
|
+
"87",
|
|
37263
|
+
"95",
|
|
37264
|
+
"101",
|
|
37265
|
+
"102",
|
|
37266
|
+
"103",
|
|
37267
|
+
"104",
|
|
37268
|
+
"109",
|
|
37269
|
+
"110",
|
|
37270
|
+
"111",
|
|
37271
|
+
"113",
|
|
37272
|
+
"115",
|
|
37273
|
+
"117",
|
|
37274
|
+
"119",
|
|
37275
|
+
"123",
|
|
37276
|
+
"135",
|
|
37277
|
+
"137",
|
|
37278
|
+
"139",
|
|
37279
|
+
"143",
|
|
37280
|
+
"161",
|
|
37281
|
+
"179",
|
|
37282
|
+
"389",
|
|
37283
|
+
"427",
|
|
37284
|
+
"465",
|
|
37285
|
+
"512",
|
|
37286
|
+
"513",
|
|
37287
|
+
"514",
|
|
37288
|
+
"515",
|
|
37289
|
+
"526",
|
|
37290
|
+
"530",
|
|
37291
|
+
"531",
|
|
37292
|
+
"532",
|
|
37293
|
+
"540",
|
|
37294
|
+
"548",
|
|
37295
|
+
"554",
|
|
37296
|
+
"556",
|
|
37297
|
+
"563",
|
|
37298
|
+
"587",
|
|
37299
|
+
"601",
|
|
37300
|
+
"636",
|
|
37301
|
+
"989",
|
|
37302
|
+
"990",
|
|
37303
|
+
"993",
|
|
37304
|
+
"995",
|
|
37305
|
+
"1719",
|
|
37306
|
+
"1720",
|
|
37307
|
+
"1723",
|
|
37308
|
+
"2049",
|
|
37309
|
+
"3659",
|
|
37310
|
+
"4045",
|
|
37311
|
+
"4190",
|
|
37312
|
+
"5060",
|
|
37313
|
+
"5061",
|
|
37314
|
+
"6000",
|
|
37315
|
+
"6566",
|
|
37316
|
+
"6665",
|
|
37317
|
+
"6666",
|
|
37318
|
+
"6667",
|
|
37319
|
+
"6668",
|
|
37320
|
+
"6669",
|
|
37321
|
+
"6679",
|
|
37322
|
+
"6697",
|
|
37323
|
+
"10080"
|
|
37324
|
+
]);
|
|
37325
|
+
var PROBE_PATH = "/v1/sessions";
|
|
37326
|
+
function withoutTrailingSlashes(value) {
|
|
37327
|
+
let out = value;
|
|
37328
|
+
while (out.endsWith("/")) out = out.slice(0, -1);
|
|
37329
|
+
return out;
|
|
37330
|
+
}
|
|
37331
|
+
function hostKey(hostname) {
|
|
37332
|
+
let host = hostname.toLowerCase();
|
|
37333
|
+
while (host.endsWith(".")) host = host.slice(0, -1);
|
|
37334
|
+
return host;
|
|
37335
|
+
}
|
|
36838
37336
|
var CREATE_TIMEOUT_MS = 6e4;
|
|
36839
37337
|
var SteelProvider = class {
|
|
36840
37338
|
name = "Steel";
|
|
@@ -36844,9 +37342,7 @@ var SteelProvider = class {
|
|
|
36844
37342
|
base;
|
|
36845
37343
|
apiKey;
|
|
36846
37344
|
constructor(opts = {}) {
|
|
36847
|
-
|
|
36848
|
-
while (base.endsWith("/")) base = base.slice(0, -1);
|
|
36849
|
-
this.base = base;
|
|
37345
|
+
this.base = withoutTrailingSlashes(opts.url || DEFAULT_BASE);
|
|
36850
37346
|
this.apiKey = opts.apiKey;
|
|
36851
37347
|
}
|
|
36852
37348
|
async createSession(signal) {
|
|
@@ -36857,14 +37353,15 @@ var SteelProvider = class {
|
|
|
36857
37353
|
return {
|
|
36858
37354
|
id: details.id,
|
|
36859
37355
|
cdpUrl: details.websocketUrl,
|
|
36860
|
-
...details.debugUrl ? { liveViewUrl: details.debugUrl } : {}
|
|
37356
|
+
...details.debugUrl ? { liveViewUrl: details.debugUrl } : {},
|
|
37357
|
+
// Carried through because it explains an otherwise mysterious death. A
|
|
37358
|
+
// session nobody released dies at exactly this number, and without it a
|
|
37359
|
+
// leak looks identical to an orderly shutdown.
|
|
37360
|
+
...typeof details.timeout === "number" ? { expiresInMs: details.timeout } : {}
|
|
36861
37361
|
};
|
|
36862
37362
|
}
|
|
36863
37363
|
async endSession(id) {
|
|
36864
|
-
|
|
36865
|
-
await this.call("POST", `/v1/sessions/${encodeURIComponent(id)}/release`, void 0, {});
|
|
36866
|
-
} catch {
|
|
36867
|
-
}
|
|
37364
|
+
await this.call("POST", `/v1/sessions/${encodeURIComponent(id)}/release`, void 0, {});
|
|
36868
37365
|
}
|
|
36869
37366
|
async call(method, path30, signal, body) {
|
|
36870
37367
|
const timeout = AbortSignal.timeout(CREATE_TIMEOUT_MS);
|
|
@@ -36875,7 +37372,7 @@ var SteelProvider = class {
|
|
|
36875
37372
|
"content-type": "application/json",
|
|
36876
37373
|
// Sent only when configured — the self-hosted API takes no key, and an
|
|
36877
37374
|
// empty header is worse than none.
|
|
36878
|
-
...this.apiKey ? {
|
|
37375
|
+
...this.apiKey ? { [API_KEY_HEADER]: this.apiKey } : {}
|
|
36879
37376
|
},
|
|
36880
37377
|
body: JSON.stringify(body),
|
|
36881
37378
|
signal: composed
|
|
@@ -37541,6 +38038,7 @@ function applySyncBundle(bundle, config, cleanup) {
|
|
|
37541
38038
|
|
|
37542
38039
|
exports.AZURE_BASE_MODELS = AZURE_BASE_MODELS;
|
|
37543
38040
|
exports.AZURE_BASE_URL_TEMPLATE = AZURE_BASE_URL_TEMPLATE;
|
|
38041
|
+
exports.AskUserTool = AskUserTool;
|
|
37544
38042
|
exports.AuditLogger = AuditLogger;
|
|
37545
38043
|
exports.BrowserControlTool = BrowserControlTool;
|
|
37546
38044
|
exports.BrowserLease = BrowserLease;
|
|
@@ -37672,7 +38170,10 @@ exports.inferAzureBaseModel = inferAzureBaseModel;
|
|
|
37672
38170
|
exports.inlineRuns = inlineRuns;
|
|
37673
38171
|
exports.isCdpEndpoint = isCdpEndpoint;
|
|
37674
38172
|
exports.isDocumentFormat = isDocumentFormat;
|
|
38173
|
+
exports.isHostedSteel = isHostedSteel;
|
|
37675
38174
|
exports.isMcpToolName = isMcpToolName;
|
|
38175
|
+
exports.isUsableSteelBase = isUsableSteelBase;
|
|
38176
|
+
exports.isUsableSteelKey = isUsableSteelKey;
|
|
37676
38177
|
exports.matchImageLine = matchImageLine;
|
|
37677
38178
|
exports.mcpServerPrefix = mcpServerPrefix;
|
|
37678
38179
|
exports.mcpToolName = mcpToolName;
|