From 15f26c55bb3603f093d50ddb93150808cd116ddf Mon Sep 17 00:00:00 2001 From: Surma Date: Sat, 7 Nov 2026 23:01:42 +0000 Subject: [PATCH] Wait for background workers before finish notices and cancel tools on shutdown --- extensions/lineage/server.ts | 22 +- host/js/bundle.js | 20 +- host/js/chain-test-bundle.js | 15 +- host/js/environment.ts | 9 +- host/js/foundation-test-bundle.js | 2509 +++++++++++++++++++++++---- host/js/foundation-test.ts | 33 +- host/js/harness-api-test-bundle.js | 5 + host/js/input.ts | 8 +- host/js/messages-test-bundle.js | 20 +- host/js/messages-test.ts | 18 +- host/js/p2-test-bundle.js | 15 +- host/js/test-bundle.js | 15 +- host/js/undo-harness-test-bundle.js | 15 +- host/src/harness/input.rs | 6 +- host/tests/harness_shutdown.rs | 135 ++ 15 files changed, 2520 insertions(+), 325 deletions(-) create mode 100644 host/tests/harness_shutdown.rs diff --git a/extensions/lineage/server.ts b/extensions/lineage/server.ts index ba3a991..4b3d494 100644 --- a/extensions/lineage/server.ts +++ b/extensions/lineage/server.ts @@ -7,7 +7,7 @@ import type { ConversationId, ConversationRecord, Cursor, EntryId, JsonObject, M import type { ModelThinkingLevel } from "@earendil-works/pi-ai" import { onCommit } from "../messages/compat.ts" import { Started as BoardStarted } from "../messages/addresses.ts" -import { Standing, Started } from "../subagents/api.ts" +import { Standing, Started, Subagents } from "../subagents/api.ts" import { Branch, Branched, LineageApi, Rooted, Roots } from "./api.ts" import { TallyDoc, SubmissionLanded } from "./refs.ts" import { readMemory, applyMemoryOps, memoryBrief, memoryIndex, memoryText, memorizeTask, Memorized, MEMORY_RULES, MEMORY_SLUG, LINE_CHARS, BODY_CHARS } from "./memory.ts" @@ -174,6 +174,24 @@ async function liveTask(tx: Tx, id: ConversationId, kind: string) { return false } +/** Include background reporters: their deliveries can start another coordinator turn. */ +async function waitForBranchIdle(host: ExtensionHost, id: ConversationId, ctx: Context): Promise { + const harness = host.harness() + const conversation = await harness.conversation(id, ctx) + if (!conversation) return + for (;;) { + await conversation.waitForIdle(ctx) + const state = await harness.snapshot(Subagents, id, ctx) + const actions = Object.values(state?.actions ?? {}) + for (const action of actions) await harness.waitForTask(action.reporter, ctx) + for (const child of Object.values(state?.agents ?? {})) + await waitForBranchIdle(host, child.conversationId, ctx) + await conversation.waitForIdle(ctx) + const current = await harness.snapshot(Subagents, id, ctx) + if (Object.keys(current?.actions ?? {}).length === actions.length) return + } +} + function lineage(host: ExtensionHost) { /** * Delivers a new branch's prompt as its first input; created with the branch, so a crash cannot leave it without @@ -230,7 +248,7 @@ function lineage(host: ExtensionHost) { written.resolve() }) try { - await (await runtime.conversation(target, ctx))?.waitForIdle(ctx) + await waitForBranchIdle(host, target, ctx) // A fork inherits its source's entries; only an answer written in the target itself counts as its own. const view = await runtime.context(target, ctx) said = '' diff --git a/host/js/bundle.js b/host/js/bundle.js index 9ecf34b..6415216 100644 --- a/host/js/bundle.js +++ b/host/js/bundle.js @@ -68776,13 +68776,23 @@ function environment(cwd = "/", conversation, invocation) { if (!appended.ok) throw new Error(appended.error.message); } }; + let rid; + const abort = () => { + if (rid !== void 0) { + Deno.core.close(rid); + rid = void 0; + } + }; try { - const rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, void 0, void 0, conversations, tag); + context.abortSignal?.throwIfAborted(); + rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, void 0, void 0, conversations, tag); + context.abortSignal?.addEventListener("abort", abort, { once: true }); const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() }; let result; while (true) { const chunk = await ops.op_exec_next(rid); if (!chunk.stream) { + rid = void 0; result = chunk; break; } @@ -68796,6 +68806,9 @@ function environment(cwd = "/", conversation, invocation) { return ok({ exitCode: result.exitCode, ...spillPath ? { spillPath } : {} }); } catch (error) { return { ok: false, error: Object.assign(new Error(String(error)), { code: "unknown", ...spillPath ? { spillPath } : {} }) }; + } finally { + context.abortSignal?.removeEventListener("abort", abort); + abort(); } }, cleanup: async () => { @@ -69812,6 +69825,11 @@ async function serve(harness2, context, afterSubmit, api2) { }; while (true) { const input = await Deno.core.ops.op_input(); + if (input?.type === "shutdown") { + await harness2.close(context); + await Promise.all(pending); + return; + } if (input === null || input.type === "stop") { await harness2.waitForIdle(context); await Promise.all(pending); diff --git a/host/js/chain-test-bundle.js b/host/js/chain-test-bundle.js index cca19ec..44e1c25 100644 --- a/host/js/chain-test-bundle.js +++ b/host/js/chain-test-bundle.js @@ -21374,13 +21374,23 @@ function environment(cwd = "/", conversation, invocation) { if (!appended.ok) throw new Error(appended.error.message); } }; + let rid; + const abort = () => { + if (rid !== void 0) { + Deno.core.close(rid); + rid = void 0; + } + }; try { - const rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, void 0, void 0, conversations, tag); + context.abortSignal?.throwIfAborted(); + rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, void 0, void 0, conversations, tag); + context.abortSignal?.addEventListener("abort", abort, { once: true }); const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() }; let result; while (true) { const chunk = await ops.op_exec_next(rid); if (!chunk.stream) { + rid = void 0; result = chunk; break; } @@ -21394,6 +21404,9 @@ function environment(cwd = "/", conversation, invocation) { return ok({ exitCode: result.exitCode, ...spillPath ? { spillPath } : {} }); } catch (error) { return { ok: false, error: Object.assign(new Error(String(error)), { code: "unknown", ...spillPath ? { spillPath } : {} }) }; + } finally { + context.abortSignal?.removeEventListener("abort", abort); + abort(); } }, cleanup: async () => { diff --git a/host/js/environment.ts b/host/js/environment.ts index 584a878..909a34a 100644 --- a/host/js/environment.ts +++ b/host/js/environment.ts @@ -119,13 +119,17 @@ export function environment(cwd = '/', conversation?: string | number | string[] if (!appended.ok) throw new Error(appended.error.message); } }; + let rid: number | undefined; + const abort = () => { if (rid !== undefined) { Deno.core.close(rid); rid = undefined; } }; try { - const rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, undefined, undefined, conversations, tag); + context.abortSignal?.throwIfAborted(); + rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, undefined, undefined, conversations, tag); + context.abortSignal?.addEventListener('abort', abort, { once: true }); const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() }; let result; while (true) { const chunk = await ops.op_exec_next(rid); - if (!chunk.stream) { result = chunk; break; } + if (!chunk.stream) { rid = undefined; result = chunk; break; } await output(decoders[chunk.stream].decode(new Uint8Array(chunk.bytes), { stream: true }), chunk.stream); } if (result.error) throw new Error(result.error); @@ -135,6 +139,7 @@ export function environment(cwd = '/', conversation?: string | number | string[] } return ok({ exitCode: result.exitCode, ...(spillPath ? { spillPath } : {}) }); } catch (error) { return { ok: false, error: Object.assign(new Error(String(error)), { code: 'unknown', ...(spillPath ? { spillPath } : {}) }) }; } + finally { context.abortSignal?.removeEventListener('abort', abort); abort(); } }, cleanup: async () => {}, }; diff --git a/host/js/foundation-test-bundle.js b/host/js/foundation-test-bundle.js index 5706d8e..920917a 100644 --- a/host/js/foundation-test-bundle.js +++ b/host/js/foundation-test-bundle.js @@ -167,21 +167,21 @@ var require_dist = __commonJS({ const parseObj = () => { index++; skipBlank(); - const obj = {}; + const obj2 = {}; try { while (jsonString[index] !== "}") { skipBlank(); if (index >= length && options_1.Allow.OBJ & allow) - return obj; + return obj2; const key = parseStr(); skipBlank(); index++; try { const value2 = parseAny(); - obj[key] = value2; + obj2[key] = value2; } catch (e) { if (options_1.Allow.OBJ & allow) - return obj; + return obj2; else throw e; } @@ -191,19 +191,19 @@ var require_dist = __commonJS({ } } catch (e) { if (options_1.Allow.OBJ & allow) - return obj; + return obj2; else markPartialJSON("Expected '}' at end of object"); } index++; - return obj; + return obj2; }; const parseArr = () => { index++; - const arr = []; + const arr2 = []; try { while (jsonString[index] !== "]") { - arr.push(parseAny()); + arr2.push(parseAny()); skipBlank(); if (jsonString[index] === ",") { index++; @@ -211,12 +211,12 @@ var require_dist = __commonJS({ } } catch (e) { if (options_1.Allow.ARR & allow) { - return arr; + return arr2; } markPartialJSON("Expected ']' at end of array"); } index++; - return arr; + return arr2; }; const parseNum = () => { if (index === 0) { @@ -583,13 +583,13 @@ function lazyStream(model2, setup, observer) { } // node_modules/@earendil-works/pi-ai/dist/auth/context.js -var __rewriteRelativeImportExtension = function(path, preserveJsx) { - if (typeof path === "string" && /^\.\.?\//.test(path)) { - return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function(m, tsx, d, ext, cm) { +var __rewriteRelativeImportExtension = function(path2, preserveJsx) { + if (typeof path2 === "string" && /^\.\.?\//.test(path2)) { + return path2.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function(m, tsx, d, ext, cm) { return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : d + ext + "." + cm.toLowerCase() + "js"; }); } - return path; + return path2; }; var importNodeModule = (specifier) => import(__rewriteRelativeImportExtension(specifier)); function getProcessEnv() { @@ -602,10 +602,10 @@ function defaultProviderAuthContext() { const value2 = getProcessEnv()?.[name]; return typeof value2 === "string" && value2.trim().length > 0 ? value2 : void 0; }, - async fileExists(path) { + async fileExists(path2) { try { const fs = await importNodeModule("node:fs/promises"); - let resolved = path; + let resolved = path2; if (resolved.startsWith("~")) { const os = await importNodeModule("node:os"); resolved = os.homedir() + resolved.slice(1); @@ -771,7 +771,7 @@ async function resolveProviderAuthWithSignal(provider2, credentials, authContext function overlayEnvAuthContext(base, env) { return { env: async (name) => env[name] || await base.env(name), - fileExists: (path) => base.fileExists(path) + fileExists: (path2) => base.fileExists(path2) }; } var DEFAULT_OAUTH_MINIMUM_VALIDITY_MS = 5 * 60 * 1e3; @@ -2103,11 +2103,11 @@ var StreamingJsonParser = class { return; } const slot = this.#valueSlot(); - const path = this.#reportedPath(slot); - this.#string = { target: "value", slot, text: "", escape: "", path, change: void 0 }; + const path2 = this.#reportedPath(slot); + this.#string = { target: "value", slot, text: "", escape: "", path: path2, change: void 0 }; this.#place(slot, ""); - if (path !== void 0) - this.#string.change = this.#report({ kind: "set", path, value: "" }); + if (path2 !== void 0) + this.#string.change = this.#report({ kind: "set", path: path2, value: "" }); } /** The path of a value at `slot` in the innermost open container, when its changes are reported. */ #reportedPath(slot) { @@ -2192,9 +2192,9 @@ var StreamingJsonParser = class { const value2 = scalarValue(token); if (value2 !== void 0) { this.#place(slot, value2); - const path = this.#reportedPath(slot); - if (path !== void 0) - this.#report({ kind: "set", path, value: value2 }); + const path2 = this.#reportedPath(slot); + if (path2 !== void 0) + this.#report({ kind: "set", path: path2, value: value2 }); } this.#valueEnded(this.#stack.at(-1)); } @@ -2209,21 +2209,21 @@ var StreamingJsonParser = class { } #openContainer(container) { const slot = this.#valueSlot(); - const path = this.#reportedPath(slot); + const path2 = this.#reportedPath(slot); this.#place(slot, container); this.#stack.push({ container, slot, - path, - reportsInside: path !== void 0 && path.length < MAX_REPORTED_DEPTH, + path: path2, + reportsInside: path2 !== void 0 && path2.length < MAX_REPORTED_DEPTH, size: 0, key: void 0, phase: Array.isArray(container) ? "value" : "key" }); this.#pathCost += 1; this.#dirty = true; - if (path !== void 0) - this.#report({ kind: "set", path, value: Array.isArray(container) ? [] : {} }); + if (path2 !== void 0) + this.#report({ kind: "set", path: path2, value: Array.isArray(container) ? [] : {} }); } #closeContainer() { const frame = this.#stack.pop(); @@ -2848,6 +2848,55 @@ function fauxProvider(options = {}) { }; } +// node_modules/@earendil-works/pi-schema/dist/builders.js +function str(options = {}) { + return { type: "string", ...options }; +} +function num(options = {}) { + return { type: "number", ...options }; +} +function bool(options = {}) { + return { type: "boolean", ...options }; +} +function lit(value2, options = {}) { + return { const: value2, ...options }; +} +function union(schemas, options = {}) { + return { anyOf: schemas, ...options }; +} +function arr(items, options = {}) { + return { type: "array", items, ...options }; +} +var Optional = class { + schema; + constructor(schema) { + this.schema = schema; + } +}; +function opt(schema) { + return new Optional(schema); +} +function obj(properties, options = {}) { + const { open, ...annotations } = options; + const props = {}; + const required = []; + for (const [name, property] of Object.entries(properties)) { + if (property instanceof Optional) + props[name] = property.schema; + else { + props[name] = property; + required.push(name); + } + } + return { + type: "object", + properties: props, + ...required.length > 0 ? { required } : {}, + ...open === true ? {} : { additionalProperties: false }, + ...annotations + }; +} + // node_modules/@earendil-works/pi-schema/dist/resolver.js var DEFAULT_BASE = "https://json-schema.invalid/root.json"; var Resolver = class { @@ -2970,10 +3019,10 @@ var Resolver = class { for (const key of SCHEMA_KEYWORDS) this.index(node[key], b); for (const key of MAP_KEYWORDS) { - const map = node[key]; - if (isSchemaObject(map)) - for (const k of Object.keys(map)) - this.index(map[k], b); + const map2 = node[key]; + if (isSchemaObject(map2)) + for (const k of Object.keys(map2)) + this.index(map2[k], b); } for (const key of ARRAY_KEYWORDS) { const list = node[key]; @@ -3415,10 +3464,10 @@ function itemSchemaAt(schema, index) { return schema.prefixItems[index]; return schema.items; } -function mapItems(items, map) { +function mapItems(items, map2) { let out; for (let i = 0; i < items.length; i++) { - const next = map(items[i], i); + const next = map2(items[i], i); if (next === items[i]) continue; out ??= items.slice(); @@ -3663,9 +3712,9 @@ function indirect(slot) { function scopedCheck(scope, uri, check) { return (v) => { scope.push(uri); - const ok = check(v); + const ok2 = check(v); scope.pop(); - return ok; + return ok2; }; } function typeCheck(types) { @@ -4023,9 +4072,9 @@ function withFreshAnnotations(check) { function scopedAnnotating(scope, uri, check) { return (v, a) => { scope.push(uri); - const ok = check(v, a); + const ok2 = check(v, a); scope.pop(); - return ok; + return ok2; }; } function isolateAnnotations(check) { @@ -4683,11 +4732,11 @@ var Explainer = class { return this.c.check(node, base)(v); } // For $dynamicRef schemas this check runs outside the original dynamic scope, so their errors are approximate. - walk(node, base, v, path, out) { + walk(node, base, v, path2, out) { if (node === true) return; if (node === false) { - out.push({ path, keyword: "false", message: "no value is allowed here" }); + out.push({ path: path2, keyword: "false", message: "no value is allowed here" }); return; } if (!isSchemaObject(node)) @@ -4696,11 +4745,11 @@ var Explainer = class { if (this.ok(node, b, v)) return; const start = out.length; - const push = (keyword, message, at = path) => { + const push = (keyword, message, at = path2) => { out.push({ path: at, keyword, message }); }; if (this.c.draft === "7" && typeof node.$ref === "string") { - this.walkRef(node.$ref, b, v, path, out); + this.walkRef(node.$ref, b, v, path2, out); return; } if (node.type !== void 0) { @@ -4722,56 +4771,56 @@ var Explainer = class { if (typeof v === "number") this.walkNumber(node, v, push); if (isObject(v)) - this.walkObject(node, b, v, path, out, push); + this.walkObject(node, b, v, path2, out, push); if (Array.isArray(v)) - this.walkArray(node, b, v, path, out, push); + this.walkArray(node, b, v, path2, out, push); if (Array.isArray(node.allOf)) for (const s of node.allOf) - this.walk(s, b, v, path, out); + this.walk(s, b, v, path2, out); if (Array.isArray(node.anyOf)) - this.walkUnion(node.anyOf, b, v, path, out, false); + this.walkUnion(node.anyOf, b, v, path2, out, false); if (Array.isArray(node.oneOf)) - this.walkUnion(node.oneOf, b, v, path, out, true); + this.walkUnion(node.oneOf, b, v, path2, out, true); if (node.not !== void 0 && this.ok(node.not, b, v)) push("not", "must not match the schema in not"); if (node.if !== void 0) { const branch = this.ok(node.if, b, v) ? node.then : node.else; if (branch !== void 0) - this.walk(branch, b, v, path, out); + this.walk(branch, b, v, path2, out); } if (typeof node.$ref === "string") - this.walkRef(node.$ref, b, v, path, out); + this.walkRef(node.$ref, b, v, path2, out); if (out.length === start && (node.unevaluatedProperties !== void 0 || node.unevaluatedItems !== void 0)) { - this.walkUnevaluated(node, b, v, path, out); + this.walkUnevaluated(node, b, v, path2, out); } if (out.length === start && typeof node.$dynamicRef === "string") { push("$dynamicRef", "does not match the dynamically referenced schema"); } } - walkRef(ref, base, v, path, out) { + walkRef(ref, base, v, path2, out) { const target = this.c.r.resolve(ref, base); if (target.node === false && this.c.r.isUnresolved(ref, base)) { - out.push({ path, keyword: "$ref", message: `unresolved reference ${ref}` }); + out.push({ path: path2, keyword: "$ref", message: `unresolved reference ${ref}` }); return; } if (!isSchemaObject(target.node) || typeof v !== "object" || v === null) { - this.walk(target.node, target.base, v, path, out); + this.walk(target.node, target.base, v, path2, out); return; } this.memo ??= /* @__PURE__ */ new Map(); let values = this.memo.get(target.node); const known = values?.get(v); - if (known !== void 0 && known.path === path) { + if (known !== void 0 && known.path === path2) { out.push(...known.errors); return; } const errors = []; - this.walk(target.node, target.base, v, path, errors); + this.walk(target.node, target.base, v, path2, errors); if (values === void 0) { values = /* @__PURE__ */ new Map(); this.memo.set(target.node, values); } - values.set(v, { path, errors }); + values.set(v, { path: path2, errors }); out.push(...errors); } walkString(node, v, push) { @@ -4813,16 +4862,16 @@ var Explainer = class { push("multipleOf", `expected a multiple of ${node.multipleOf}`); } } - walkObject(node, b, v, path, out, push) { + walkObject(node, b, v, path2, out, push) { const properties = isSchemaObject(node.properties) ? node.properties : {}; const required = Array.isArray(node.required) ? node.required : []; for (const k of required) { if (typeof k === "string" && (!Object.hasOwn(v, k) || v[k] === void 0)) - push("required", "missing", pointer(path, k)); + push("required", "missing", pointer(path2, k)); } for (const k of Object.keys(properties)) { if (Object.hasOwn(v, k) && v[k] !== void 0) - this.walk(properties[k], b, v[k], pointer(path, k), out); + this.walk(properties[k], b, v[k], pointer(path2, k), out); } const patterns = isSchemaObject(node.patternProperties) ? Object.entries(node.patternProperties) : []; for (const k of Object.keys(v)) { @@ -4830,17 +4879,17 @@ var Explainer = class { for (const [p, s] of patterns) { if (sharedRegex(p).test(k)) { matched = true; - this.walk(s, b, v[k], pointer(path, k), out); + this.walk(s, b, v[k], pointer(path2, k), out); } } if (!matched && node.additionalProperties !== void 0) { if (node.additionalProperties === false) - push("additionalProperties", "unknown field", pointer(path, k)); + push("additionalProperties", "unknown field", pointer(path2, k)); else - this.walk(node.additionalProperties, b, v[k], pointer(path, k), out); + this.walk(node.additionalProperties, b, v[k], pointer(path2, k), out); } if (node.propertyNames !== void 0 && !this.ok(node.propertyNames, b, k)) { - push("propertyNames", "invalid property name", pointer(path, k)); + push("propertyNames", "invalid property name", pointer(path2, k)); } } const count = Object.keys(v).length; @@ -4858,19 +4907,19 @@ var Explainer = class { if (!Object.hasOwn(v, k)) continue; if (!Array.isArray(d)) - this.walk(d, b, v, path, out); + this.walk(d, b, v, path2, out); else for (const r of d) if (!Object.hasOwn(v, r)) - push("dependentRequired", `required when ${k} is present`, pointer(path, r)); + push("dependentRequired", `required when ${k} is present`, pointer(path2, r)); } if (this.c.draft !== "7" && isSchemaObject(node.dependentSchemas)) { for (const [k, s] of Object.entries(node.dependentSchemas)) if (Object.hasOwn(v, k)) - this.walk(s, b, v, path, out); + this.walk(s, b, v, path2, out); } } - walkArray(node, b, v, path, out, push) { + walkArray(node, b, v, path2, out, push) { let prefix = []; let rest; if (Array.isArray(node.items)) { @@ -4884,7 +4933,7 @@ var Explainer = class { for (let i = 0; i < v.length; i++) { const s = i < prefix.length ? prefix[i] : rest; if (s !== void 0) - this.walk(s, b, v[i], `${path}/${i}`, out); + this.walk(s, b, v[i], `${path2}/${i}`, out); } if (typeof node.minItems === "number" && v.length < node.minItems) { push("minItems", `expected at least ${node.minItems} items`); @@ -4903,13 +4952,13 @@ var Explainer = class { } } } - walkUnion(branches, b, v, path, out, exactlyOne) { + walkUnion(branches, b, v, path2, out, exactlyOne) { const matches = branches.filter((s) => this.ok(s, b, v)).length; if (matches === 1 || !exactlyOne && matches > 0) return; const keyword = exactlyOne ? "oneOf" : "anyOf"; if (matches > 1) { - out.push({ path, keyword, message: "matches more than one alternative" }); + out.push({ path: path2, keyword, message: "matches more than one alternative" }); return; } const tag = this.tagOf(branches, b); @@ -4917,18 +4966,18 @@ var Explainer = class { const i = tag.values.indexOf(v[tag.key]); if (i < 0) out.push({ - path: pointer(path, tag.key), + path: pointer(path2, tag.key), keyword, message: `expected one of ${listValues(tag.values)}` }); else - this.walk(branches[i], b, v, path, out); + this.walk(branches[i], b, v, path2, out); return; } let best; for (const s of branches) { const errors = []; - this.walk(s, b, v, path, errors); + this.walk(s, b, v, path2, errors); if (errors.length === 0) continue; const declared = isObject(v) ? this.declaredKeys(s, b, v) : 0; @@ -4936,7 +4985,7 @@ var Explainer = class { if (better) best = { errors, declared }; } - out.push(...best?.errors ?? [{ path, keyword, message: "matches no alternative" }]); + out.push(...best?.errors ?? [{ path: path2, keyword, message: "matches no alternative" }]); } /** How many of the keys of `v` the branch `s` (through a `$ref`) declares in `properties`. */ declaredKeys(s, b, v) { @@ -4960,7 +5009,7 @@ var Explainer = class { } return void 0; } - walkUnevaluated(node, b, v, path, out) { + walkUnevaluated(node, b, v, path2, out) { const { unevaluatedProperties: unevaluatedProperties2, unevaluatedItems: unevaluatedItems2, ...rest } = node; const a = newAnnotations(); this.c.annotating(rest, b)(v, a); @@ -4969,9 +5018,9 @@ var Explainer = class { if (a.props?.has(k)) continue; if (unevaluatedProperties2 === false) { - out.push({ path: pointer(path, k), keyword: "unevaluatedProperties", message: "unknown field" }); + out.push({ path: pointer(path2, k), keyword: "unevaluatedProperties", message: "unknown field" }); } else - this.walk(unevaluatedProperties2, b, v[k], pointer(path, k), out); + this.walk(unevaluatedProperties2, b, v[k], pointer(path2, k), out); } } if (Array.isArray(v) && unevaluatedItems2 !== void 0 && !a.allItems) { @@ -4979,9 +5028,9 @@ var Explainer = class { if (a.indexes?.has(i)) continue; if (unevaluatedItems2 === false) { - out.push({ path: `${path}/${i}`, keyword: "unevaluatedItems", message: "unexpected item" }); + out.push({ path: `${path2}/${i}`, keyword: "unevaluatedItems", message: "unexpected item" }); } else - this.walk(unevaluatedItems2, b, v[i], `${path}/${i}`, out); + this.walk(unevaluatedItems2, b, v[i], `${path2}/${i}`, out); } } } @@ -5000,8 +5049,8 @@ function containsMessage(min, max) { return `expected at most ${max} matching ${max === 1 ? "item" : "items"}`; return `expected between ${min} and ${max} matching items`; } -function pointer(path, key) { - return `${path}/${String(key).replace(/~/g, "~0").replace(/\//g, "~1")}`; +function pointer(path2, key) { + return `${path2}/${String(key).replace(/~/g, "~0").replace(/\//g, "~1")}`; } function typeName(v) { if (v === null) @@ -5168,10 +5217,10 @@ function applyImmutableTrusted(target, operations) { permuteTrusted(copied2.target, operation[2]); continue; } - const path = operation[1]; - const copied = copyPath(root2, path, path.length - 1, owned); + const path2 = operation[1]; + const copied = copyPath(root2, path2, path2.length - 1, owned); root2 = copied.root; - const key = path[path.length - 1]; + const key = path2[path2.length - 1]; switch (operation[0]) { case "s": defineData2(copied.target, key, operation[2]); @@ -5202,7 +5251,7 @@ function applyImmutableTrusted(target, operations) { } return root2; } -function copyPath(root2, path, length, owned) { +function copyPath(root2, path2, length, owned) { if (!isContainer(root2)) throw new TypeError("Trusted operation root is not a container"); let copiedRoot = root2; @@ -5212,7 +5261,7 @@ function copyPath(root2, path, length, owned) { } let destination = copiedRoot; for (let index = 0; index < length; index++) { - const segment = path[index]; + const segment = path2[index]; const child = read(destination, segment); if (!isContainer(child)) throw new TypeError("Trusted operation path is not a container"); @@ -6679,8 +6728,8 @@ function emitOperations(context) { } const denseRegions = /* @__PURE__ */ new Map(); for (const [array, candidates] of denseIndices) { - const path = resolvePath(array); - if (path === void 0 || path.some((segment) => typeof segment === "string" && RESERVED_SEGMENTS.has(segment))) { + const path2 = resolvePath(array); + if (path2 === void 0 || path2.some((segment) => typeof segment === "string" && RESERVED_SEGMENTS.has(segment))) { continue; } const regions = buildDenseRegions(candidates); @@ -6688,50 +6737,50 @@ function emitOperations(context) { denseRegions.set(array, regions); } for (const node of context.dirty) { - const path = resolvePath(node); - if (path === void 0) + const path2 = resolvePath(node); + if (path2 === void 0) continue; - if (hasCoveringDenseRegion(node, path, denseRegions)) + if (hasCoveringDenseRegion(node, path2, denseRegions)) continue; - emissionPaths.set(node, path); + emissionPaths.set(node, path2); if (!Array.isArray(nodeBase(node)) && hasReservedMutation(node)) - forcedFolds.set(node, path); - const reservedAt = path.findIndex((segment) => typeof segment === "string" && RESERVED_SEGMENTS.has(segment)); + forcedFolds.set(node, path2); + const reservedAt = path2.findIndex((segment) => typeof segment === "string" && RESERVED_SEGMENTS.has(segment)); if (reservedAt >= 0) { let ancestor = node; - for (let depth = path.length; depth > reservedAt; depth--) + for (let depth = path2.length; depth > reservedAt; depth--) ancestor = nodeParent(ancestor); - forcedFolds.set(ancestor, path.slice(0, reservedAt)); + forcedFolds.set(ancestor, path2.slice(0, reservedAt)); } } - for (const [node, path] of forcedFolds) - emissionPaths.set(node, path); + for (const [node, path2] of forcedFolds) + emissionPaths.set(node, path2); for (const node of denseRegions.keys()) { - const path = resolvePath(node); - if (path !== void 0 && !hasCoveringDenseRegion(node, path, denseRegions)) - emissionPaths.set(node, path); + const path2 = resolvePath(node); + if (path2 !== void 0 && !hasCoveringDenseRegion(node, path2, denseRegions)) + emissionPaths.set(node, path2); } let maxDepth = 0; - for (const path of emissionPaths.values()) - maxDepth = Math.max(maxDepth, path.length); + for (const path2 of emissionPaths.values()) + maxDepth = Math.max(maxDepth, path2.length); const buckets = Array.from({ length: maxDepth + 1 }, () => []); - for (const [node, path] of emissionPaths) - buckets[path.length].push(node); + for (const [node, path2] of emissionPaths) + buckets[path2.length].push(node); const folded = /* @__PURE__ */ new Set(); for (const bucket of buckets) { for (const node of bucket) { - const path = emissionPaths.get(node); + const path2 = emissionPaths.get(node); if (hasPlacementAncestor(node) || hasFoldedAncestor(node, folded)) continue; if (forcedFolds.has(node)) { - emitSet(operations, path, cloneNode(node)); + emitSet(operations, path2, cloneNode(node)); folded.add(node); continue; } if (Array.isArray(nodeBase(node))) - emitArrayOperations(node, path, operations, denseRegions.get(node)); + emitArrayOperations(node, path2, operations, denseRegions.get(node)); else - emitObjectOperations(node, path, operations); + emitObjectOperations(node, path2, operations); if (operations.length > MAX_DELTA_OPERATIONS) { locatedDenseArray = void 0; return [["r", cloneNode(context.root)]]; @@ -6750,10 +6799,10 @@ function emitSimpleObjectOperations(context) { if (Array.isArray(nodeBase(parent))) return void 0; } - const path = resolvePath(node); - if (path === void 0) + const path2 = resolvePath(node); + if (path2 === void 0) continue; - for (const segment of path) { + for (const segment of path2) { if (typeof segment === "string" && RESERVED_SEGMENTS.has(segment)) return void 0; } @@ -6804,24 +6853,24 @@ function locateDenseArrayPosition(node) { } return false; } -function hasCoveringDenseRegion(node, path, denseRegions) { +function hasCoveringDenseRegion(node, path2, denseRegions) { for (let parent = nodeParent(node); parent !== void 0; parent = nodeParent(parent)) { const regions = denseRegions.get(parent); if (regions === void 0) continue; const parentPath = resolvePath(parent); - if (parentPath === void 0 || path.length <= parentPath.length) + if (parentPath === void 0 || path2.length <= parentPath.length) continue; let matches = true; for (let index2 = 0; index2 < parentPath.length; index2++) { - if (path[index2] !== parentPath[index2]) { + if (path2[index2] !== parentPath[index2]) { matches = false; break; } } if (!matches) continue; - const index = path[parentPath.length]; + const index = path2[parentPath.length]; if (typeof index === "number" && regionContaining(regions, index)) return true; } @@ -6915,8 +6964,8 @@ function hasPlacementAncestor(node) { } return false; } -function emitObjectWrite(node, path, operations, key, value2) { - const nextPath = [...path, key]; +function emitObjectWrite(node, path2, operations, key, value2) { + const nextPath = [...path2, key]; const appended = node.appends?.get(key); if (appended !== void 0) { operations.push(["a", nextPath, appended]); @@ -6927,33 +6976,33 @@ function emitObjectWrite(node, path, operations, key, value2) { const emitted = emitChangedValue(operations, nextPath, before, after); return isContainer2(before) && isContainer2(after) && !emitted; } -function emitObjectOperations(node, path, operations) { +function emitObjectOperations(node, path2, operations) { if (node.readded !== void 0) { for (const key of node.readded) { if (Object.hasOwn(nodeBase(node), key)) - operations.push(["d", [...path, key]]); + operations.push(["d", [...path2, key]]); } } let normalizedContainerWrite = false; if (node.writeKey !== void 0) - normalizedContainerWrite = emitObjectWrite(node, path, operations, node.writeKey, objectValue(node, node.writeKey)); + normalizedContainerWrite = emitObjectWrite(node, path2, operations, node.writeKey, objectValue(node, node.writeKey)); if (node.writes !== void 0) { for (const [key, valueIndex] of node.writes) { if (operations.length > MAX_DELTA_OPERATIONS) return normalizedContainerWrite; - if (emitObjectWrite(node, path, operations, key, storedValue(node.context, valueIndex))) + if (emitObjectWrite(node, path2, operations, key, storedValue(node.context, valueIndex))) normalizedContainerWrite = true; } } if (node.deleteKey !== void 0 && Object.hasOwn(nodeBase(node), node.deleteKey)) { - operations.push(["d", [...path, node.deleteKey]]); + operations.push(["d", [...path2, node.deleteKey]]); } if (node.deletes !== void 0) { for (const key of node.deletes) { if (operations.length > MAX_DELTA_OPERATIONS) return normalizedContainerWrite; if (Object.hasOwn(nodeBase(node), key)) - operations.push(["d", [...path, key]]); + operations.push(["d", [...path2, key]]); } } return normalizedContainerWrite; @@ -7049,14 +7098,14 @@ function rangeLength(pieces, start, end) { length += pieces[index].length; return length; } -function emitArrayOperations(node, path, operations, denseRegions) { +function emitArrayOperations(node, path2, operations, denseRegions) { const overlay = arrayOverlay(node); const base = nodeBase(node); if (denseRegions !== void 0) { for (const region of denseRegions) { if (operations.length > MAX_DELTA_OPERATIONS) return; - operations.push(["p", path, region.start, region.length, cloneArrayRegion(node, region)]); + operations.push(["p", path2, region.start, region.length, cloneArrayRegion(node, region)]); } } if (overlay.structural) { @@ -7064,10 +7113,10 @@ function emitArrayOperations(node, path, operations, denseRegions) { for (let index = 0; index < plan.removeRuns.length; index += 2) { if (operations.length > MAX_DELTA_OPERATIONS) return; - operations.push(["p", path, plan.removeRuns[index], plan.removeRuns[index + 1], []]); + operations.push(["p", path2, plan.removeRuns[index], plan.removeRuns[index + 1], []]); } if (plan.permutation !== void 0) - operations.push(["m", path, plan.permutation]); + operations.push(["m", path2, plan.permutation]); const pieces = piecesOf(overlay); for (let run2 = 0; run2 < plan.insertRuns.length; run2 += 3) { if (operations.length > MAX_DELTA_OPERATIONS) @@ -7081,7 +7130,7 @@ function emitArrayOperations(node, path, operations, denseRegions) { items.push(cloneStored(entryValueAt(node, piece, sourceIndex), node.context)); } } - operations.push(["p", path, logicalIndex, 0, items]); + operations.push(["p", path2, logicalIndex, 0, items]); } } for (const [baseIndex, valueIndex] of overlay.baseOverrides ?? []) { @@ -7090,7 +7139,7 @@ function emitArrayOperations(node, path, operations, denseRegions) { const index = findEntryIndex(overlay, 1, baseIndex, void 0); if (index === void 0 || denseRegions !== void 0 && regionContaining(denseRegions, index)) continue; - emitChangedValue(operations, [...path, index], base[baseIndex], cloneStored(storedValue(node.context, valueIndex), node.context)); + emitChangedValue(operations, [...path2, index], base[baseIndex], cloneStored(storedValue(node.context, valueIndex), node.context)); } } function cloneArrayRegion(node, region) { @@ -7103,32 +7152,32 @@ function cloneArrayRegion(node, region) { } return result; } -function emitChangedValue(operations, path, before, after) { +function emitChangedValue(operations, path2, before, after) { if (!isContainer2(after) && before === after) return false; if (isContainer2(before) && isContainer2(after) && equalTrustedJson(before, after)) return false; if (typeof before === "string" && typeof after === "string") { if (after.length > before.length && after.slice(0, before.length) === before) { - operations.push(["a", path, ownedSlice2(after, before.length)]); + operations.push(["a", path2, ownedSlice2(after, before.length)]); return true; } const shared = overlap(before, after, 65536); if (shared > 0) { - operations.push(["t", path, before.length - shared]); + operations.push(["t", path2, before.length - shared]); if (after.length > shared) - operations.push(["a", path, ownedSlice2(after, shared)]); + operations.push(["a", path2, ownedSlice2(after, shared)]); return true; } } - operations.push(["s", path, after]); + operations.push(["s", path2, after]); return true; } -function emitSet(operations, path, value2) { - if (path.length === 0) +function emitSet(operations, path2, value2) { + if (path2.length === 0) operations.push(["r", value2]); else - operations.push(["s", path, value2]); + operations.push(["s", path2, value2]); } function resolvePath(node) { if (node.preparedPath !== void 0) @@ -7322,27 +7371,27 @@ var REPLACE_TAKE = { kind: "replace" }; var PendingOps = class { #state = EMPTY; /** Queue the ops of one commit. */ - push(ops) { - if (ops.length === 0) + push(ops2) { + if (ops2.length === 0) return; const state = this.#state; - if (ops[0][0] === "r") { - this.#state = { kind: "batch", ops }; + if (ops2[0][0] === "r") { + this.#state = { kind: "batch", ops: ops2 }; return; } switch (state.kind) { case "empty": - this.#state = { kind: "batch", ops }; + this.#state = { kind: "batch", ops: ops2 }; return; case "batch": { const folding = { kind: "folding", ops: [], leaves: /* @__PURE__ */ new Map() }; this.#state = folding; if (this.#fold(state.ops, folding)) - this.#fold(ops, folding); + this.#fold(ops2, folding); return; } case "folding": - this.#fold(ops, state); + this.#fold(ops2, state); return; case "replace": return; @@ -7373,8 +7422,8 @@ var PendingOps = class { } } /** Fold `ops` into `folding`; false once the queue overflowed into `replace`. */ - #fold(ops, folding) { - for (const op2 of ops) { + #fold(ops2, folding) { + for (const op2 of ops2) { switch (op2[0]) { case "r": folding.ops.length = 0; @@ -7418,8 +7467,8 @@ function mergeLeaf(op2, folding) { folding.leaves.set(key, folding.ops.length); return false; } -function pathKey(path) { - return JSON.stringify(path); +function pathKey(path2) { + return JSON.stringify(path2); } function merge2(previous, next) { if (next[0] === "s") @@ -7535,8 +7584,8 @@ function assertPermutation(value2) { seen[index] = 1; } } -function assertSafePath(path) { - for (const seg of path) { +function assertSafePath(path2) { + for (const seg of path2) { if (typeof seg === "string") { if (RESERVED_SEGMENTS.has(seg)) throw new UnsafePathError(seg); @@ -7551,29 +7600,29 @@ function assertIndexInRange(parent, index) { } var PathError = class extends Error { path; - constructor(path) { - super(`unresolvable path: ${JSON.stringify(path)}`); - this.path = path; + constructor(path2) { + super(`unresolvable path: ${JSON.stringify(path2)}`); + this.path = path2; this.name = "PathError"; } }; -function apply(target, ops) { - return applyOps(target, ops); +function apply(target, ops2) { + return applyOps(target, ops2); } -function applyOps(target, ops) { +function applyOps(target, ops2) { let root2 = target; - for (const op2 of ops) { + for (const op2 of ops2) { assertValidOp(op2); if (op2[0] === "r") { root2 = op2[1]; continue; } - const path = op2[1]; - assertSafePath(path); + const path2 = op2[1]; + assertSafePath(path2); if (op2[0] === "p") { - const target_ = path.length === 0 ? root2 : resolve(root2, path); + const target_ = path2.length === 0 ? root2 : resolve(root2, path2); if (!Array.isArray(target_)) - throw new PathError(path); + throw new PathError(path2); target_.splice(op2[2], op2[3]); const chunkSize = 1e4; for (let offset = 0; offset < op2[4].length; offset += chunkSize) { @@ -7582,16 +7631,16 @@ function applyOps(target, ops) { continue; } if (op2[0] === "m") { - const target_ = path.length === 0 ? root2 : resolve(root2, path); + const target_ = path2.length === 0 ? root2 : resolve(root2, path2); if (!Array.isArray(target_) || target_.length !== op2[2].length) - throw new PathError(path); + throw new PathError(path2); const previous = target_.slice(); for (let index = 0; index < op2[2].length; index++) target_[index] = previous[op2[2][index]]; continue; } - const parent = resolve(root2, path.slice(0, -1)); - const key = path[path.length - 1]; + const parent = resolve(root2, path2.slice(0, -1)); + const key = path2[path2.length - 1]; if (Array.isArray(parent)) { if (typeof key !== "number") throw new UnsafePathError(key); @@ -7608,7 +7657,7 @@ function applyOps(target, ops) { case "d": if (Array.isArray(parent)) { if (typeof key !== "number" || key >= parent.length) - throw new PathError(path); + throw new PathError(path2); parent.splice(key, 1); } else delete parent[key]; @@ -7616,14 +7665,14 @@ function applyOps(target, ops) { case "a": { const current = read2(); if (typeof current !== "string") - throw new PathError(path); + throw new PathError(path2); write(`${current}${op2[2]}`); break; } case "t": { const current = read2(); if (typeof current !== "string") - throw new PathError(path); + throw new PathError(path2); write(current.slice(op2[2])); break; } @@ -7631,14 +7680,14 @@ function applyOps(target, ops) { } return root2; } -function applyImmutable(target, ops) { - return applyImmutableBatches(target, [ops]); +function applyImmutable(target, ops2) { + return applyImmutableBatches(target, [ops2]); } function applyImmutableBatches(target, batches) { let root2 = target; const owned = /* @__PURE__ */ new WeakSet(); - for (const ops of batches) { - for (const op2 of ops) { + for (const ops2 of batches) { + for (const op2 of ops2) { assertValidOp(op2); if (op2[0] === "r") { root2 = op2[1]; @@ -7658,11 +7707,11 @@ function applyImmutableBatches(target, batches) { return root2; } function appendCopy(root2, op2, owned) { - const [, path, index, deleteCount, items] = op2; + const [, path2, index, deleteCount, items] = op2; if (deleteCount !== 0) return void 0; let target = root2; - for (const segment of path) { + for (const segment of path2) { const valid = typeof segment === "string" ? !RESERVED_SEGMENTS.has(segment) && isObj(target) && !Array.isArray(target) : Number.isInteger(segment) && segment >= 0 && Array.isArray(target); if (!valid || !Object.hasOwn(target, segment)) return void 0; @@ -7672,11 +7721,11 @@ function appendCopy(root2, op2, owned) { return void 0; const appended = target.concat(items); owned.add(appended); - if (path.length === 0) + if (path2.length === 0) return appended; - const parentPath = path.slice(0, -1); + const parentPath = path2.slice(0, -1); const copiedRoot = copyContainers(root2, parentPath, owned); - Object.defineProperty(resolve(copiedRoot, parentPath), path[path.length - 1], { + Object.defineProperty(resolve(copiedRoot, parentPath), path2[path2.length - 1], { value: appended, writable: true, enumerable: true, @@ -7684,16 +7733,16 @@ function appendCopy(root2, op2, owned) { }); return copiedRoot; } -function copyContainers(root2, path, owned) { +function copyContainers(root2, path2, owned) { const copy2 = (value2) => { if (Array.isArray(value2)) return value2.slice(); if (!isObj(value2)) - throw new PathError(path); + throw new PathError(path2); return Object.getPrototypeOf(value2) === null ? Object.assign(/* @__PURE__ */ Object.create(null), value2) : { ...value2 }; }; if (!isObj(root2)) - throw new PathError(path); + throw new PathError(path2); let copiedRoot; if (owned.has(root2)) copiedRoot = root2; @@ -7702,14 +7751,14 @@ function copyContainers(root2, path, owned) { owned.add(copiedRoot); } let destination = copiedRoot; - for (const segment of path) { + for (const segment of path2) { if (!Object.hasOwn(destination, segment)) - throw new PathError(path); + throw new PathError(path2); if (Array.isArray(destination) && typeof segment !== "number") throw new UnsafePathError(segment); const child = destination[segment]; if (!isObj(child)) - throw new PathError(path); + throw new PathError(path2); if (owned.has(child)) { destination = child; continue; @@ -7726,23 +7775,23 @@ function copyContainers(root2, path, owned) { } return copiedRoot; } -function resolveValue(root2, path) { +function resolveValue(root2, path2) { let node = root2; - for (const seg of path) { + for (const seg of path2) { if (!isObj(node)) - throw new PathError(path); + throw new PathError(path2); if (Array.isArray(node) && typeof seg !== "number") throw new UnsafePathError(seg); if (!Object.hasOwn(node, seg)) - throw new PathError(path); + throw new PathError(path2); node = node[seg]; } return node; } -function resolve(root2, path) { - const node = resolveValue(root2, path); +function resolve(root2, path2) { + const node = resolveValue(root2, path2); if (!isObj(node)) - throw new PathError(path); + throw new PathError(path2); return node; } @@ -7982,8 +8031,8 @@ var Watch = class extends WatchLifecycle { this.#retired = false; if (lone !== void 0) return lone; - const ops = batch.kind === "ops" ? batch.ops : [["r", value2]]; - return { kind: "value", value: value2, ops, retired }; + const ops2 = batch.kind === "ops" ? batch.ops : [["r", value2]]; + return { kind: "value", value: value2, ops: ops2, retired }; } /** The pending failure, or the value after one as a root replacement (no ops were kept since the failure). */ #nextAfterFailure() { @@ -8541,19 +8590,19 @@ var ConversationTails = class { return this.#markers.size; } }; -function use(map, key) { - const value2 = map.get(key); +function use(map2, key) { + const value2 = map2.get(key); if (value2 === void 0) return void 0; - map.delete(key); - map.set(key, value2); + map2.delete(key); + map2.set(key, value2); return value2; } -function put(map, key, value2, capacity) { - map.delete(key); - map.set(key, value2); - if (map.size > capacity) - map.delete(map.keys().next().value); +function put(map2, key, value2, capacity) { + map2.delete(key); + map2.set(key, value2); + if (map2.size > capacity) + map2.delete(map2.keys().next().value); } function freeze2(value2) { if (value2 === null || typeof value2 !== "object" || Object.isFrozen(value2)) @@ -9654,7 +9703,7 @@ function describe(plan, record) { const change = plan.change; if (change === void 0 || !publishes(plan)) return void 0; - const ops = change.loaded === void 0 ? EMPTY_OPERATIONS : change.prepared.ops; + const ops2 = change.loaded === void 0 ? EMPTY_OPERATIONS : change.prepared.ops; return { type: "document", kind: "change", @@ -9662,11 +9711,11 @@ function describe(plan, record) { conversationId, version: change.version, value: change.prepared.value, - ops + ops: ops2 }; } -function storeHeld(hold, ops, value2, version) { - hold.pending.push(ops); +function storeHeld(hold, ops2, value2, version) { + hold.pending.push(ops2); const taken = hold.pending.take(); if (taken.kind === "replace") return { content: { version, kind: "base", value: value2 }, ops: [["r", value2]] }; @@ -10742,7 +10791,7 @@ var SessionImpl = class { return { kind: "adoption", error }; } const deadline = this.#clock() + withinMs; - for (const { loaded, ops, base } of held2) { + for (const { loaded, ops: ops2, base } of held2) { const id = loaded.record.id; let hold = this.#holds.get(id); if (hold === void 0) { @@ -10750,7 +10799,7 @@ var SessionImpl = class { hold = { loaded, pending: new PendingOps(), base: false, deadline, unpin }; this.#holds.set(id, hold); } - hold.pending.push(ops); + hold.pending.push(ops2); if (base) hold.base = true; if (deadline < hold.deadline) @@ -12596,10 +12645,10 @@ function uuidv7(timestampMs) { const effectiveTimestamp = timestampMs === void 0 ? Math.max(requestedTimestamp, lastOrdinaryTimestamp) : timestampMs; if (timestampMs === void 0) lastOrdinaryTimestamp = effectiveTimestamp; - const bytes = new Uint8Array(16); - globalThis.crypto.getRandomValues(bytes); + const bytes2 = new Uint8Array(16); + globalThis.crypto.getRandomValues(bytes2); if (sequence === void 0) { - sequence = BigInt(bytes[1]) << 32n | BigInt(bytes[2]) << 24n | BigInt(bytes[3]) << 16n | BigInt(bytes[4]) << 8n | BigInt(bytes[5]); + sequence = BigInt(bytes2[1]) << 32n | BigInt(bytes2[2]) << 24n | BigInt(bytes2[3]) << 16n | BigInt(bytes2[4]) << 8n | BigInt(bytes2[5]); } else { if (sequence === MAX_SEQUENCE) throw new RangeError("UUIDv7 generator sequence exhausted"); @@ -12607,15 +12656,15 @@ function uuidv7(timestampMs) { } const timestamp = BigInt(effectiveTimestamp); for (let index = 5; index >= 0; index--) { - bytes[index] = Number(timestamp >> BigInt((5 - index) * 8)) & 255; + bytes2[index] = Number(timestamp >> BigInt((5 - index) * 8)) & 255; } - bytes[6] = 112 | Number(sequence >> 37n & 0x0fn); - bytes[7] = Number(sequence >> 29n & 0xffn); - bytes[8] = 128 | Number(sequence >> 23n & 0x3fn); - bytes[9] = Number(sequence >> 15n & 0xffn); - bytes[10] = Number(sequence >> 7n & 0xffn); - bytes[11] = Number((sequence & 0x7fn) << 1n) | bytes[11] & 1; - const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")); + bytes2[6] = 112 | Number(sequence >> 37n & 0x0fn); + bytes2[7] = Number(sequence >> 29n & 0xffn); + bytes2[8] = 128 | Number(sequence >> 23n & 0x3fn); + bytes2[9] = Number(sequence >> 15n & 0xffn); + bytes2[10] = Number(sequence >> 7n & 0xffn); + bytes2[11] = Number((sequence & 0x7fn) << 1n) | bytes2[11] & 1; + const hex = Array.from(bytes2, (byte) => byte.toString(16).padStart(2, "0")); return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10).join("")}`; } @@ -15017,7 +15066,7 @@ var TaskScheduler = class { from.set(id, via); pending.push(id); }; - const path = (end) => { + const path2 = (end) => { const steps = []; for (let at = end; at !== void 0; at = from.get(at)) steps.unshift(at); @@ -15027,7 +15076,7 @@ var TaskScheduler = class { reach(id, void 0); for (let id = pending.pop(); id !== void 0; id = pending.pop()) { if (id === task) - return path(id); + return path2(id); const record = overlay.tasks.get(id) ?? this.#liveRecord(id); if (record === void 0 || record.state.status === "terminal") continue; @@ -15399,20 +15448,20 @@ function overlayOf(tx) { const edges = new Map(tx.stagedConversations().map((record) => [record.id, record.owner?.taskId ?? null])); return { tasks, edges }; } -function addTo(map, key, value2) { - const values = map.get(key); +function addTo(map2, key, value2) { + const values = map2.get(key); if (values === void 0) - map.set(key, /* @__PURE__ */ new Set([value2])); + map2.set(key, /* @__PURE__ */ new Set([value2])); else values.add(value2); } -function deleteFrom(map, key, value2) { - const values = map.get(key); +function deleteFrom(map2, key, value2) { + const values = map2.get(key); if (values === void 0) return; values.delete(value2); if (values.size === 0) - map.delete(key); + map2.delete(key); } function memoOf(record, name) { const memos = record?.memos; @@ -15484,73 +15533,73 @@ function sanitizeOutput(text) { return text.replace(ESCAPE_SEQUENCE, "").replace(INVALID_OUTPUT, ""); } function boundOutput(text, limits) { - const bytes = encoder.encode(text); - const [from, to] = limits.retain === "head" ? headRange(bytes, limits) : tailRange(bytes, limits); - const kept = bytes.subarray(from, to); + const bytes2 = encoder.encode(text); + const [from, to] = limits.retain === "head" ? headRange(bytes2, limits) : tailRange(bytes2, limits); + const kept = bytes2.subarray(from, to); return { - text: kept.length === bytes.length ? text : decoder.decode(kept), + text: kept.length === bytes2.length ? text : decoder.decode(kept), bytes: kept.length, - droppedBytes: bytes.length - kept.length, - droppedLines: lineCount(bytes) - lineCount(kept) + droppedBytes: bytes2.length - kept.length, + droppedLines: lineCount(bytes2) - lineCount(kept) }; } -function headRange(bytes, limits) { +function headRange(bytes2, limits) { if (limits.maxLines === 0 || limits.maxBytes === 0) return [0, 0]; - let end = bytes.length; + let end = bytes2.length; let lines2 = 0; - for (let index = bytes.indexOf(NEWLINE); index !== -1; index = bytes.indexOf(NEWLINE, index + 1)) { + for (let index = bytes2.indexOf(NEWLINE); index !== -1; index = bytes2.indexOf(NEWLINE, index + 1)) { if (++lines2 === limits.maxLines) { end = index + 1; break; } } if (end > limits.maxBytes) { - const newline = bytes.lastIndexOf(NEWLINE, limits.maxBytes - 1); - end = newline === -1 ? characterEnd(bytes, limits.maxBytes) : newline + 1; + const newline = bytes2.lastIndexOf(NEWLINE, limits.maxBytes - 1); + end = newline === -1 ? characterEnd(bytes2, limits.maxBytes) : newline + 1; } return [0, end]; } -function tailRange(bytes, limits) { +function tailRange(bytes2, limits) { if (limits.maxLines === 0 || limits.maxBytes === 0) - return [bytes.length, bytes.length]; - const last = bytes[bytes.length - 1] === NEWLINE ? bytes.length - 2 : bytes.length - 1; + return [bytes2.length, bytes2.length]; + const last = bytes2[bytes2.length - 1] === NEWLINE ? bytes2.length - 2 : bytes2.length - 1; let start = 0; let lines2 = 1; - for (let index = last < 0 ? -1 : bytes.lastIndexOf(NEWLINE, last); index !== -1; ) { + for (let index = last < 0 ? -1 : bytes2.lastIndexOf(NEWLINE, last); index !== -1; ) { if (lines2 === limits.maxLines) { start = index + 1; break; } lines2++; - index = index === 0 ? -1 : bytes.lastIndexOf(NEWLINE, index - 1); + index = index === 0 ? -1 : bytes2.lastIndexOf(NEWLINE, index - 1); } - if (bytes.length - start > limits.maxBytes) { - const from = bytes.length - limits.maxBytes; - const newline = bytes.indexOf(NEWLINE, from - 1); - start = newline !== -1 && newline + 1 < bytes.length ? newline + 1 : characterStart(bytes, from); + if (bytes2.length - start > limits.maxBytes) { + const from = bytes2.length - limits.maxBytes; + const newline = bytes2.indexOf(NEWLINE, from - 1); + start = newline !== -1 && newline + 1 < bytes2.length ? newline + 1 : characterStart(bytes2, from); } - return [start, bytes.length]; + return [start, bytes2.length]; } -function characterEnd(bytes, index) { +function characterEnd(bytes2, index) { let end = index; - while (end > 0 && ((bytes[end] ?? 0) & 192) === 128) + while (end > 0 && ((bytes2[end] ?? 0) & 192) === 128) end--; return end; } -function characterStart(bytes, index) { +function characterStart(bytes2, index) { let start = index; - while (start < bytes.length && ((bytes[start] ?? 0) & 192) === 128) + while (start < bytes2.length && ((bytes2[start] ?? 0) & 192) === 128) start++; return start; } -function lineCount(bytes) { - if (bytes.length === 0) +function lineCount(bytes2) { + if (bytes2.length === 0) return 0; let newlines = 0; - for (let index = bytes.indexOf(NEWLINE); index !== -1; index = bytes.indexOf(NEWLINE, index + 1)) + for (let index = bytes2.indexOf(NEWLINE); index !== -1; index = bytes2.indexOf(NEWLINE, index + 1)) newlines++; - return newlines + (bytes[bytes.length - 1] === NEWLINE ? 0 : 1); + return newlines + (bytes2[bytes2.length - 1] === NEWLINE ? 0 : 1); } var OutputBuffer = class { #limits; @@ -15615,15 +15664,15 @@ var OutputBuffer = class { #accept(text) { if (text.length === 0) return false; - const bytes = utf8ByteLength(text); + const bytes2 = utf8ByteLength(text); const newlines = countNewlines(text); - this.#totalBytes += bytes; + this.#totalBytes += bytes2; this.#totalNewlines += newlines; this.#endsWithNewline = text.endsWith("\n"); if (this.#full) return true; - this.#chunks.push({ text, bytes, newlines }); - this.#storedBytes += bytes; + this.#chunks.push({ text, bytes: bytes2, newlines }); + this.#storedBytes += bytes2; this.#storedNewlines += newlines; if (this.#limits.retain === "head") { this.#full = this.#storedBytes > this.#limits.maxBytes || this.#storedNewlines >= this.#limits.maxLines; @@ -15649,9 +15698,9 @@ var OutputBuffer = class { const keptLines = storedLines - kept.droppedLines; if (this.#limits.retain === "tail" || this.#chunks.length > 1) { const text = this.#limits.retain === "tail" ? tailMargin(stored, this.#limits) : stored; - const bytes = this.#limits.retain === "tail" ? utf8ByteLength(text) : this.#storedBytes; - this.#chunks = text === "" ? [] : [{ text, bytes, newlines: countNewlines(text) }]; - this.#storedBytes = bytes; + const bytes2 = this.#limits.retain === "tail" ? utf8ByteLength(text) : this.#storedBytes; + this.#chunks = text === "" ? [] : [{ text, bytes: bytes2, newlines: countNewlines(text) }]; + this.#storedBytes = bytes2; this.#storedNewlines = this.#chunks[0]?.newlines ?? 0; } return { @@ -15662,11 +15711,11 @@ var OutputBuffer = class { } }; function tailMargin(text, limits) { - const bytes = encoder.encode(text); - const byteStart = bytes.length > limits.maxBytes ? characterEnd(bytes, bytes.length - limits.maxBytes - 1) : 0; + const bytes2 = encoder.encode(text); + const byteStart = bytes2.length > limits.maxBytes ? characterEnd(bytes2, bytes2.length - limits.maxBytes - 1) : 0; let lineStart = 0; let newlines = 0; - for (let index = bytes.lastIndexOf(NEWLINE); index !== -1; index = bytes.lastIndexOf(NEWLINE, index - 1)) { + for (let index = bytes2.lastIndexOf(NEWLINE); index !== -1; index = bytes2.lastIndexOf(NEWLINE, index - 1)) { if (++newlines > limits.maxLines) { lineStart = index; break; @@ -15675,7 +15724,7 @@ function tailMargin(text, limits) { break; } const start = Math.max(byteStart, lineStart); - return start === 0 ? text : decoder.decode(bytes.subarray(start)); + return start === 0 ? text : decoder.decode(bytes2.subarray(start)); } function lines(newlines, terminated) { return newlines + (terminated ? 0 : 1); @@ -15688,24 +15737,24 @@ function utf8ByteLength(content) { const firstNonAscii = content.search(nonAsciiPattern); if (firstNonAscii === -1) return content.length; - let bytes = firstNonAscii; + let bytes2 = firstNonAscii; for (let i = firstNonAscii; i < content.length; i++) { const code = content.charCodeAt(i); if (code <= 127) - bytes += 1; + bytes2 += 1; else if (code <= 2047) - bytes += 2; + bytes2 += 2; else if (code >= 55296 && code <= 56319 && i + 1 < content.length) { const next = content.charCodeAt(i + 1); if (next >= 56320 && next <= 57343) { - bytes += 4; + bytes2 += 4; i++; } else - bytes += 3; + bytes2 += 3; } else - bytes += 3; + bytes2 += 3; } - return bytes; + return bytes2; } function countNewlines(text) { let count = 0; @@ -15776,8 +15825,8 @@ var Progress = class { this.#dirty = false; const waiters = this.#waiters.splice(0); const started = this.#clock.now(); - this.#inFlight = this.#write().then((bytes) => { - this.#nextAt = started + Math.max(this.#minIntervalMs, bytes * 1e3 / PROGRESS_BYTES_PER_SECOND); + this.#inFlight = this.#write().then((bytes2) => { + this.#nextAt = started + Math.max(this.#minIntervalMs, bytes2 * 1e3 / PROGRESS_BYTES_PER_SECOND); for (const waiter of waiters) waiter.resolve(); }, (error) => { @@ -16354,6 +16403,91 @@ ${JSON.stringify(toolCall.arguments, null, 2)}`; // node_modules/@earendil-works/pi-durable/dist/truncate.js var DEFAULT_MAX_LINES = 2e3; var DEFAULT_MAX_BYTES = 50 * 1024; +function splitLinesForCounting(content) { + if (content.length === 0) + return []; + const lines2 = content.split("\n"); + if (content.endsWith("\n")) + lines2.pop(); + return lines2; +} +function formatSize(bytes2) { + if (bytes2 < 1024) { + return `${bytes2}B`; + } else if (bytes2 < 1024 * 1024) { + return `${(bytes2 / 1024).toFixed(1)}KB`; + } else { + return `${(bytes2 / (1024 * 1024)).toFixed(1)}MB`; + } +} +function truncateHeadOf(prefix, totals, options = {}) { + const maxLines = options.maxLines ?? DEFAULT_MAX_LINES; + const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + const totalBytes = totals.bytes; + const lines2 = splitLinesForCounting(prefix); + const totalLines = totals.lines; + if (totalLines <= maxLines && totalBytes <= maxBytes) { + return { + content: prefix, + truncated: false, + truncatedBy: null, + totalLines, + totalBytes, + outputLines: totalLines, + outputBytes: totalBytes, + lastLinePartial: false, + firstLineExceedsLimit: false, + maxLines, + maxBytes + }; + } + const firstLineBytes = utf8ByteLength(lines2[0]); + if (firstLineBytes > maxBytes) { + return { + content: "", + truncated: true, + truncatedBy: "bytes", + totalLines, + totalBytes, + outputLines: 0, + outputBytes: 0, + lastLinePartial: false, + firstLineExceedsLimit: true, + maxLines, + maxBytes + }; + } + const outputLinesArr = []; + let outputBytesCount = 0; + let truncatedBy = "lines"; + for (let i = 0; i < lines2.length && i < maxLines; i++) { + const line = lines2[i]; + const lineBytes = utf8ByteLength(line) + (i > 0 ? 1 : 0); + if (outputBytesCount + lineBytes > maxBytes) { + truncatedBy = "bytes"; + break; + } + outputLinesArr.push(line); + outputBytesCount += lineBytes; + } + if (truncatedBy !== "bytes") + truncatedBy = outputLinesArr.length < totalLines ? "lines" : "bytes"; + const outputContent = outputLinesArr.join("\n"); + const finalOutputBytes = utf8ByteLength(outputContent); + return { + content: outputContent, + truncated: true, + truncatedBy, + totalLines, + totalBytes, + outputLines: outputLinesArr.length, + outputBytes: finalOutputBytes, + lastLinePartial: false, + firstLineExceedsLimit: false, + maxLines, + maxBytes + }; +} // node_modules/@earendil-works/pi-durable/dist/harness/usage.js var UsageDoc = defineDoc({ @@ -16740,15 +16874,15 @@ function publishProgress(runtime, reported, enabled, context) { const current = { text: snapshot.text, details: reported.details, diagnostics: reported.diagnostics.length }; const added = reported.diagnostics.slice(written.diagnostics, current.diagnostics); const detailsChanged = current.details !== written.details; - let bytes = 0; + let bytes2 = 0; if (snapshot.text !== written.text) { const shared = snapshot.text.startsWith(written.text) ? written.text.length : overlap(written.text, snapshot.text, 65536); - bytes += utf8ByteLength(snapshot.text.slice(shared)); + bytes2 += utf8ByteLength(snapshot.text.slice(shared)); } if (detailsChanged) - bytes += utf8ByteLength(JSON.stringify(current.details ?? null)); + bytes2 += utf8ByteLength(JSON.stringify(current.details ?? null)); if (added.length > 0) - bytes += utf8ByteLength(JSON.stringify(added)); + bytes2 += utf8ByteLength(JSON.stringify(added)); await runtime.commit(async (tx) => { const slot = toolSlot(await tx.doc(LiveDoc, runtime.conversationId), runtime.taskId); if (slot === void 0) @@ -16771,7 +16905,7 @@ function publishProgress(runtime, reported, enabled, context) { return void 0; }, context, PROGRESS_COMMIT); written = current; - return bytes; + return bytes2; }, (error) => { if (!runtime.signal.aborted) runtime.report(error); @@ -16852,8 +16986,8 @@ function structuredOutputError(tool, result) { if (validator.check(value2)) return void 0; const [first] = validator.errors(value2); - const path = first?.path.slice(1).split("/").map((segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~")).join(".") || "root"; - return `Tool ${tool.name} returned structuredOutput that does not match its schema: ${path}: ${first?.message ?? "invalid"}`; + const path2 = first?.path.slice(1).split("/").map((segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~")).join(".") || "root"; + return `Tool ${tool.name} returned structuredOutput that does not match its schema: ${path2}: ${first?.message ?? "invalid"}`; } async function settle2(runtime, input, call, ending, result, context, durationMs) { const nestedIds = (await runtime.ownedTasks(context)).filter((record) => record.kind === ToolTask.definition.name && record.input.kind === "nested").map((record) => record.id); @@ -18614,6 +18748,9 @@ async function failNoModel2(runtime, ref, context) { function defineExtension(extension) { return extension; } +function defineTool(tool) { + return tool; +} function hook(task, handlers) { return { task: task.definition.name, handlers }; } @@ -18796,14 +18933,14 @@ var TaskGraphView = class { const mount = this.#mount; if (mount === void 0) return; - const ops = []; + const ops2 = []; for (const [key, record] of mount.records) { const blocked = this.#blocking.blocked(record); if (blocked === mount.value.tasks[key]?.blocked) continue; - ops.push(blocked === void 0 ? ["d", ["tasks", key, "blocked"]] : ["s", ["tasks", key, "blocked"], blocked]); + ops2.push(blocked === void 0 ? ["d", ["tasks", key, "blocked"]] : ["s", ["tasks", key, "blocked"], blocked]); } - publish(mount, ops, BACKGROUND_CONTEXT, (error) => this.#session.report(error)); + publish(mount, ops2, BACKGROUND_CONTEXT, (error) => this.#session.report(error)); }).catch(() => { }); } @@ -18827,7 +18964,7 @@ var TaskGraphView = class { } }; function advance(mount, publication, context, blocked, report2) { - const ops = []; + const ops2 = []; const changed = /* @__PURE__ */ new Map(); const node = (key) => changed.has(key) ? changed.get(key) : mount.value.tasks[key]; for (const change of publication.changes) { @@ -18840,7 +18977,7 @@ function advance(mount, publication, context, blocked, report2) { mount.records.delete(key); if (previous === void 0) continue; - ops.push(["d", ["tasks", key]]); + ops2.push(["d", ["tasks", key]]); changed.set(key, void 0); continue; } @@ -18849,7 +18986,7 @@ function advance(mount, publication, context, blocked, report2) { const next = nodeOf2(record, previous?.conversations ?? [], blocked(subject)); if (previous !== void 0 && JSON.stringify(next) === JSON.stringify(previous)) continue; - ops.push(["s", ["tasks", key], next]); + ops2.push(["s", ["tasks", key], next]); changed.set(key, next); } const created = /* @__PURE__ */ new Map(); @@ -18862,16 +18999,16 @@ function advance(mount, publication, context, blocked, report2) { } for (const [key, ids] of created) { const conversations = [...node(key).conversations, ...ids].sort((a, b) => a - b); - ops.push(["s", ["tasks", key, "conversations"], conversations]); + ops2.push(["s", ["tasks", key, "conversations"], conversations]); } - publish(mount, ops, context, report2); + publish(mount, ops2, context, report2); } -function publish(mount, ops, context, report2) { - if (ops.length === 0) +function publish(mount, ops2, context, report2) { + if (ops2.length === 0) return; - mount.value = applyImmutable(mount.value, ops); + mount.value = applyImmutable(mount.value, ops2); const frameContext = withoutAbortSignal(context); - const call = { kind: "value", value: mount.value, ops, retired: false }; + const call = { kind: "value", value: mount.value, ops: ops2, retired: false }; for (const observer of [...mount.observers]) contained(() => observer.advance(call, frameContext), report2); } @@ -19072,19 +19209,19 @@ function advance2(id, mount, publication, context, report2) { const kind = change.record.kind; if (!MOUNTED_KINDS.has(kind) || change.record.key !== void 0) continue; - const path = ["docs", kind]; + const path2 = ["docs", kind]; const mounted = mount.incarnations.get(kind); if (change.kind === "retire") { if (mounted?.id !== change.record.id) continue; mount.incarnations.delete(kind); - docOps.push(["d", path]); + docOps.push(["d", path2]); } else if (mounted?.id === change.record.id && mounted.version === change.version) { for (const op2 of change.ops) - docOps.push(prefixed(op2, path)); + docOps.push(prefixed(op2, path2)); } else { mount.incarnations.set(kind, { id: change.record.id, version: change.version }); - docOps.push(["s", path, change.value]); + docOps.push(["s", path2, change.value]); } } if (docOps.length === 0 && appended.length === 0) @@ -19097,24 +19234,24 @@ function advance2(id, mount, publication, context, report2) { const previous = windowed.value; let entries2 = previous.entries; let before = previous.before; - let ops = docOps; + let ops2 = docOps; if (appended.length > 0) { - ops = [...docOps, ["p", ["entries"], entries2.length, 0, inserted]]; + ops2 = [...docOps, ["p", ["entries"], entries2.length, 0, inserted]]; entries2 = entries2.concat(appended); if (entries2.length > windowed.max) { const drop = entries2.length - windowed.min; before = entries2[drop - 1].id; entries2 = entries2.slice(drop); - ops.push(["p", ["entries"], 0, drop, []], ["s", ["before"], before]); + ops2.push(["p", ["entries"], 0, drop, []], ["s", ["before"], before]); } } windowed.value = view(mount.conversation, entries2, before, mount.docs); - const call = { kind: "value", value: windowed.value, ops, retired: false }; + const call = { kind: "value", value: windowed.value, ops: ops2, retired: false }; contained(() => windowed.observer.advance(call, frameContext), report2); } } function prefixed(op2, prefix) { - const at = (path) => [...prefix, ...path]; + const at = (path2) => [...prefix, ...path2]; switch (op2[0]) { case "r": return ["s", prefix, op2[1]]; @@ -20437,8 +20574,8 @@ var SqliteStorage = class _SqliteStorage { // sqlite.ts var op = (kind, sql = "", params = []) => Deno.core.ops.op_sql(kind, sql, params.map((p) => p instanceof Uint8Array ? Array.from(p) : typeof p === "bigint" ? Number(p) : p)); -async function openStorage(path) { - op("open", path); +async function openStorage(path2) { + op("open", path2); let tail = Promise.resolve(); const queue = (call) => { const result = tail.then(call); @@ -20486,6 +20623,11 @@ async function serve(harness2, context, afterSubmit, api) { }; while (true) { const input = await Deno.core.ops.op_input(); + if (input?.type === "shutdown") { + await harness2.close(context); + await Promise.all(pending); + return; + } if (input === null || input.type === "stop") { await harness2.waitForIdle(context); await Promise.all(pending); @@ -20511,6 +20653,1730 @@ async function serve(harness2, context, afterSubmit, api) { } } +// node_modules/@earendil-works/pi-durable/dist/env/decode.js +function rangeDecoder() { + return new TextDecoder("utf-8", { ignoreBOM: true }); +} +function startsWithBom(firstBytes) { + return firstBytes[0] === 239 && firstBytes[1] === 187 && firstBytes[2] === 191; +} + +// node_modules/@earendil-works/pi-durable/dist/env/line-scan.js +var NEWLINE2 = 10; +var encoder2 = new TextEncoder(); +function decodedBytes(text) { + return text === "" ? 0 : encoder2.encode(text).length; +} +var LineScanner = class { + #startLine; + #endLine; + #position = 0; + #newlines = 0; + #lineStart = 0; + #start; + #end; + #firstLineEnd; + #lastLineStart; + #selectedBytes = 0; + #firstLineBytes = 0; + #selection; + #firstLine; + /** The first bytes, held until it is known whether they are a byte-order mark. */ + #head = []; + #bom = false; + /** `startLine` and `endLine` must be non-negative integers with `endLine > startLine`; `endLine` absent: to the end. */ + constructor(startLine, endLine = Number.POSITIVE_INFINITY) { + if (!Number.isSafeInteger(startLine) || startLine < 0 || !(endLine > startLine)) { + throw new RangeError("Invalid line range"); + } + if (endLine !== Number.POSITIVE_INFINITY && !Number.isSafeInteger(endLine)) + throw new RangeError("Invalid line range"); + this.#startLine = startLine; + this.#endLine = endLine; + if (startLine === 0) + this.#begin(0); + } + push(chunk) { + if (this.#head !== void 0) { + const take = Math.min(3 - this.#head.length, chunk.length); + this.#head.push(...chunk.subarray(0, take)); + if (this.#head.length < 3) + return; + this.#releaseHead(); + chunk = chunk.subarray(take); + } + this.#process(chunk); + } + #releaseHead() { + const head = Uint8Array.from(this.#head ?? []); + this.#head = void 0; + this.#bom = startsWithBom(head); + this.#process(head); + } + #process(chunk) { + const base = this.#position; + let from = 0; + for (let index = chunk.indexOf(NEWLINE2); index !== -1; index = chunk.indexOf(NEWLINE2, index + 1)) { + this.#feed(chunk, base, from, index); + const line = this.#newlines; + const position = base + index; + if (line === this.#startLine) + this.#endFirstLine(position); + if (line === this.#endLine - 1) + this.#endSelection(position); + this.#feed(chunk, base, index, index + 1); + from = index + 1; + this.#newlines++; + this.#lineStart = position + 1; + if (this.#newlines === this.#startLine) + this.#begin(this.#lineStart); + if (this.#newlines === this.#endLine - 1) + this.#lastLineStart = this.#lineStart; + } + this.#feed(chunk, base, from, chunk.length); + this.#position += chunk.length; + } + finish() { + if (this.#head !== void 0) + this.#releaseHead(); + const size = this.#position; + if (this.#start === void 0) { + return { + newlines: this.#newlines, + start: size, + end: size, + firstLineEnd: size, + lastLineStart: size, + selectedBytes: 0, + firstLineBytes: 0 + }; + } + if (this.#firstLineEnd === void 0) + this.#endFirstLine(size); + if (this.#end === void 0) + this.#endSelection(size); + return { + newlines: this.#newlines, + start: this.#start, + end: this.#end ?? size, + firstLineEnd: this.#firstLineEnd ?? size, + // A selection that reaches past the last line ends with the last line. + lastLineStart: this.#lastLineStart ?? this.#lineStart, + selectedBytes: this.#selectedBytes, + firstLineBytes: this.#firstLineBytes + }; + } + #begin(start) { + this.#start = start; + if (this.#startLine === this.#endLine - 1) + this.#lastLineStart = start; + this.#selection = rangeDecoder(); + this.#firstLine = rangeDecoder(); + } + #endFirstLine(position) { + this.#firstLineEnd = position; + if (this.#firstLine !== void 0) + this.#firstLineBytes += decodedBytes(this.#firstLine.decode()); + this.#firstLine = void 0; + } + #endSelection(position) { + this.#end = position; + if (this.#selection !== void 0) + this.#selectedBytes += decodedBytes(this.#selection.decode()); + this.#selection = void 0; + } + /** Feed `chunk[from, to)`, which starts at file offset `base`, to the decoders of the ranges still open. */ + #feed(chunk, base, from, to) { + if (this.#bom && base + from < 3) + from = Math.min(to, 3 - base); + if (to <= from) + return; + const bytes2 = chunk.subarray(from, to); + if (this.#selection !== void 0) { + this.#selectedBytes += decodedBytes(this.#selection.decode(bytes2, { stream: true })); + } + if (this.#firstLine !== void 0) { + this.#firstLineBytes += decodedBytes(this.#firstLine.decode(bytes2, { stream: true })); + } + } +}; + +// node_modules/@earendil-works/pi-durable/dist/env/index.js +function getOrThrow(result) { + if (!result.ok) + throw result.error; + return result.value; +} + +// node_modules/@earendil-works/pi-durable/dist/tools/env.js +function requireEnv(api) { + if (api.env === void 0) + throw new Error("No execution environment is configured"); + return api.env; +} + +// node_modules/@earendil-works/pi-durable/dist/tools/bash.js +var MAX_TIMEOUT_SECONDS = 2147483647 / 1e3; +var bashSchema = obj({ + command: str({ description: "Bash command to execute" }), + timeout: opt(num({ description: "Timeout in seconds (optional, no default timeout)" })) +}, { open: true }); +var powershellSchema = obj({ + command: str({ description: "PowerShell command to execute" }), + timeout: opt(num({ description: "Timeout in seconds (optional, no default timeout)" })) +}, { open: true }); +var shellStructuredOutputSchema = obj({ + output: str({ description: "Combined stdout and stderr: the retained tail, as the model sees it" }), + truncated: bool({ description: "Whether earlier output was dropped" }), + fullOutputPath: opt(str({ description: "File with the complete output, when it was spilled" })), + exitCode: num() +}, { open: true }); +function validateTimeout(timeout) { + if (timeout === void 0) + return; + if (!Number.isFinite(timeout) || timeout <= 0) { + throw new Error("Invalid timeout: must be a finite number of seconds"); + } + if (timeout > MAX_TIMEOUT_SECONDS) { + throw new Error(`Invalid timeout: maximum is ${MAX_TIMEOUT_SECONDS} seconds`); + } +} +async function prepareExecution(command, options, api, context) { + const execution = { + command: options?.commandPrefix ? `${options.commandPrefix} +${command}` : command, + cwd: requireEnv(api).cwd, + env: {}, + inheritEnv: true + }; + await options?.prepare?.(execution, api, context); + return execution; +} +async function runCommand(commands, execution, timeout, api, context) { + const env = requireEnv(api); + let result; + for (const command of commands) { + result = await env.exec(command, { + cwd: execution.cwd, + env: execution.env, + inheritEnv: execution.inheritEnv, + ...timeout === void 0 ? {} : { timeout }, + onOutput: (text, _context, info) => api.output(text, info.skipped), + spill: { afterBytes: DEFAULT_MAX_BYTES, afterLines: DEFAULT_MAX_LINES }, + // An environment may then omit output outside the retained tail and report the omission. + ...api.outputWindow === void 0 ? {} : { window: api.outputWindow } + }, context); + if (result.ok || result.error.code !== "spawn_error") + break; + } + if (result === void 0) + throw new Error("No command to run"); + const spillPath = result.ok ? result.value.spillPath : result.error.spillPath; + if (spillPath !== void 0) { + api.diagnostic({ severity: "info", code: "full_output", message: `Full output: ${spillPath}` }); + } + if (!result.ok) { + if (result.error.code === "aborted" && context.abortSignal?.aborted) + throw result.error; + if (result.error.code === "timeout") + throw new Error(`Command timed out after ${timeout} seconds`); + if (result.error.code === "aborted") + throw new Error("Command aborted"); + throw result.error; + } + const exitCode = result.value.exitCode; + const retained = api.retainedOutput(); + const structuredOutput = { + output: retained.text, + truncated: retained.truncated, + ...spillPath === void 0 ? {} : { fullOutputPath: spillPath }, + exitCode + }; + if (exitCode === 0) + return { structuredOutput }; + const exited = { severity: "error", code: "exit_code", message: `Command exited with code ${exitCode}` }; + return { structuredOutput, isError: true, diagnostics: [exited] }; +} +function createBashTool(options) { + return defineTool({ + name: "bash", + description: `Execute a bash command in the current working directory. Returns combined stdout and stderr. Output is truncated to last ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.`, + parameters: bashSchema, + structuredOutputSchema: shellStructuredOutputSchema, + outputLimits: { retain: "tail" }, + async execute(args, api, context) { + validateTimeout(args.timeout); + const execution = await prepareExecution(args.command, options, api, context); + return runCommand([execution.command], execution, args.timeout, api, context); + } + }); +} + +// node_modules/diff/libesm/diff/base.js +var Diff = class { + diff(oldStr, newStr, options = {}) { + let callback; + if (typeof options === "function") { + callback = options; + options = {}; + } else if ("callback" in options) { + callback = options.callback; + } + const oldString = this.castInput(oldStr, options); + const newString = this.castInput(newStr, options); + const oldTokens = this.removeEmpty(this.tokenize(oldString, options)); + const newTokens = this.removeEmpty(this.tokenize(newString, options)); + return this.diffWithOptionsObj(oldTokens, newTokens, options, callback); + } + diffWithOptionsObj(oldTokens, newTokens, options, callback) { + var _a; + const done = (value2) => { + value2 = this.postProcess(value2, options); + if (callback) { + setTimeout(function() { + callback(value2); + }, 0); + return void 0; + } else { + return value2; + } + }; + const newLen = newTokens.length, oldLen = oldTokens.length; + let editLength = 1; + let maxEditLength = newLen + oldLen; + if (options.maxEditLength != null) { + maxEditLength = Math.min(maxEditLength, options.maxEditLength); + } + const maxExecutionTime = (_a = options.timeout) !== null && _a !== void 0 ? _a : Infinity; + const abortAfterTimestamp = Date.now() + maxExecutionTime; + const bestPath = [{ oldPos: -1, lastComponent: void 0 }]; + let newPos = this.extractCommon(bestPath[0], newTokens, oldTokens, 0, options); + if (bestPath[0].oldPos + 1 >= oldLen && newPos + 1 >= newLen) { + return done(this.buildValues(bestPath[0].lastComponent, newTokens, oldTokens)); + } + let minDiagonalToConsider = -Infinity, maxDiagonalToConsider = Infinity; + const execEditLength = () => { + for (let diagonalPath = Math.max(minDiagonalToConsider, -editLength); diagonalPath <= Math.min(maxDiagonalToConsider, editLength); diagonalPath += 2) { + let basePath; + const removePath = bestPath[diagonalPath - 1], addPath = bestPath[diagonalPath + 1]; + if (removePath) { + bestPath[diagonalPath - 1] = void 0; + } + let canAdd = false; + if (addPath) { + const addPathNewPos = addPath.oldPos - diagonalPath; + canAdd = addPath && 0 <= addPathNewPos && addPathNewPos < newLen; + } + const canRemove = removePath && removePath.oldPos + 1 < oldLen; + if (!canAdd && !canRemove) { + bestPath[diagonalPath] = void 0; + continue; + } + if (!canRemove || canAdd && removePath.oldPos < addPath.oldPos) { + basePath = this.addToPath(addPath, true, false, 0, options); + } else { + basePath = this.addToPath(removePath, false, true, 1, options); + } + newPos = this.extractCommon(basePath, newTokens, oldTokens, diagonalPath, options); + if (basePath.oldPos + 1 >= oldLen && newPos + 1 >= newLen) { + return done(this.buildValues(basePath.lastComponent, newTokens, oldTokens)) || true; + } else { + bestPath[diagonalPath] = basePath; + if (basePath.oldPos + 1 >= oldLen) { + maxDiagonalToConsider = Math.min(maxDiagonalToConsider, diagonalPath - 1); + } + if (newPos + 1 >= newLen) { + minDiagonalToConsider = Math.max(minDiagonalToConsider, diagonalPath + 1); + } + } + } + editLength++; + }; + if (callback) { + (function exec() { + setTimeout(function() { + if (editLength > maxEditLength || Date.now() > abortAfterTimestamp) { + return callback(void 0); + } + if (!execEditLength()) { + exec(); + } + }, 0); + })(); + } else { + while (editLength <= maxEditLength && Date.now() <= abortAfterTimestamp) { + const ret = execEditLength(); + if (ret) { + return ret; + } + } + } + } + addToPath(path2, added, removed, oldPosInc, options) { + const last = path2.lastComponent; + if (last && !options.oneChangePerToken && last.added === added && last.removed === removed) { + return { + oldPos: path2.oldPos + oldPosInc, + lastComponent: { count: last.count + 1, added, removed, previousComponent: last.previousComponent } + }; + } else { + return { + oldPos: path2.oldPos + oldPosInc, + lastComponent: { count: 1, added, removed, previousComponent: last } + }; + } + } + extractCommon(basePath, newTokens, oldTokens, diagonalPath, options) { + const newLen = newTokens.length, oldLen = oldTokens.length; + let oldPos = basePath.oldPos, newPos = oldPos - diagonalPath, commonCount = 0; + while (newPos + 1 < newLen && oldPos + 1 < oldLen && this.equals(oldTokens[oldPos + 1], newTokens[newPos + 1], options)) { + newPos++; + oldPos++; + commonCount++; + if (options.oneChangePerToken) { + basePath.lastComponent = { count: 1, previousComponent: basePath.lastComponent, added: false, removed: false }; + } + } + if (commonCount && !options.oneChangePerToken) { + basePath.lastComponent = { count: commonCount, previousComponent: basePath.lastComponent, added: false, removed: false }; + } + basePath.oldPos = oldPos; + return newPos; + } + equals(left, right, options) { + if (options.comparator) { + return options.comparator(left, right); + } else { + return left === right || !!options.ignoreCase && left.toLowerCase() === right.toLowerCase(); + } + } + removeEmpty(array) { + const ret = []; + for (let i = 0; i < array.length; i++) { + if (array[i]) { + ret.push(array[i]); + } + } + return ret; + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + castInput(value2, options) { + return value2; + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + tokenize(value2, options) { + return Array.from(value2); + } + join(chars) { + return chars.join(""); + } + postProcess(changeObjects, options) { + return changeObjects; + } + get useLongestToken() { + return false; + } + buildValues(lastComponent, newTokens, oldTokens) { + const components = []; + let nextComponent; + while (lastComponent) { + components.push(lastComponent); + nextComponent = lastComponent.previousComponent; + delete lastComponent.previousComponent; + lastComponent = nextComponent; + } + components.reverse(); + const componentLen = components.length; + let componentPos = 0, newPos = 0, oldPos = 0; + for (; componentPos < componentLen; componentPos++) { + const component = components[componentPos]; + if (!component.removed) { + if (!component.added && this.useLongestToken) { + let value2 = newTokens.slice(newPos, newPos + component.count); + value2 = value2.map(function(value3, i) { + const oldValue = oldTokens[oldPos + i]; + return oldValue.length > value3.length ? oldValue : value3; + }); + component.value = this.join(value2); + } else { + component.value = this.join(newTokens.slice(newPos, newPos + component.count)); + } + newPos += component.count; + if (!component.added) { + oldPos += component.count; + } + } else { + component.value = this.join(oldTokens.slice(oldPos, oldPos + component.count)); + oldPos += component.count; + } + } + return components; + } +}; + +// node_modules/diff/libesm/diff/line.js +var LineDiff = class extends Diff { + constructor() { + super(...arguments); + this.tokenize = tokenize; + } + equals(left, right, options) { + if (options.ignoreWhitespace) { + if (!options.newlineIsToken || !left.includes("\n")) { + left = left.trim(); + } + if (!options.newlineIsToken || !right.includes("\n")) { + right = right.trim(); + } + } else if (options.ignoreNewlineAtEof && !options.newlineIsToken) { + if (left.endsWith("\n")) { + left = left.slice(0, -1); + } + if (right.endsWith("\n")) { + right = right.slice(0, -1); + } + } + return super.equals(left, right, options); + } +}; +var lineDiff = new LineDiff(); +function diffLines(oldStr, newStr, options) { + return lineDiff.diff(oldStr, newStr, options); +} +function tokenize(value2, options) { + if (options.stripTrailingCr) { + value2 = value2.replace(/\r\n/g, "\n"); + } + const retLines = [], linesAndNewlines = value2.split(/(\n|\r\n)/); + if (!linesAndNewlines[linesAndNewlines.length - 1]) { + linesAndNewlines.pop(); + } + for (let i = 0; i < linesAndNewlines.length; i++) { + const line = linesAndNewlines[i]; + if (i % 2 && !options.newlineIsToken) { + retLines[retLines.length - 1] += line; + } else { + retLines.push(line); + } + } + return retLines; +} + +// node_modules/diff/libesm/patch/create.js +var INCLUDE_HEADERS = { + includeIndex: true, + includeUnderline: true, + includeFileHeaders: true +}; +var FILE_HEADERS_ONLY = { + includeIndex: false, + includeUnderline: false, + includeFileHeaders: true +}; +function structuredPatch(oldFileName, newFileName, oldStr, newStr, oldHeader, newHeader, options) { + let optionsObj; + if (!options) { + optionsObj = {}; + } else if (typeof options === "function") { + optionsObj = { callback: options }; + } else { + optionsObj = options; + } + if (typeof optionsObj.context === "undefined") { + optionsObj.context = 4; + } + const context = optionsObj.context; + if (optionsObj.newlineIsToken) { + throw new Error("newlineIsToken may not be used with patch-generation functions, only with diffing functions"); + } + if (!optionsObj.callback) { + return diffLinesResultToPatch(diffLines(oldStr, newStr, optionsObj)); + } else { + const { callback } = optionsObj; + diffLines(oldStr, newStr, Object.assign(Object.assign({}, optionsObj), { callback: (diff) => { + const patch = diffLinesResultToPatch(diff); + callback(patch); + } })); + } + function diffLinesResultToPatch(diff) { + if (!diff) { + return; + } + diff.push({ value: "", lines: [] }); + function contextLines(lines2) { + return lines2.map(function(entry2) { + return " " + entry2; + }); + } + const hunks = []; + let oldRangeStart = 0, newRangeStart = 0, curRange = [], oldLine = 1, newLine = 1; + for (let i = 0; i < diff.length; i++) { + const current = diff[i], lines2 = current.lines || splitLines(current.value); + current.lines = lines2; + if (current.added || current.removed) { + if (!oldRangeStart) { + const prev = diff[i - 1]; + oldRangeStart = oldLine; + newRangeStart = newLine; + if (prev) { + curRange = context > 0 ? contextLines(prev.lines.slice(-context)) : []; + oldRangeStart -= curRange.length; + newRangeStart -= curRange.length; + } + } + for (const line of lines2) { + curRange.push((current.added ? "+" : "-") + line); + } + if (current.added) { + newLine += lines2.length; + } else { + oldLine += lines2.length; + } + } else { + if (oldRangeStart) { + if (lines2.length <= context * 2 && i < diff.length - 2) { + for (const line of contextLines(lines2)) { + curRange.push(line); + } + } else { + const contextSize = Math.min(lines2.length, context); + for (const line of contextLines(lines2.slice(0, contextSize))) { + curRange.push(line); + } + const hunk = { + oldStart: oldRangeStart, + oldLines: oldLine - oldRangeStart + contextSize, + newStart: newRangeStart, + newLines: newLine - newRangeStart + contextSize, + lines: curRange + }; + hunks.push(hunk); + oldRangeStart = 0; + newRangeStart = 0; + curRange = []; + } + } + oldLine += lines2.length; + newLine += lines2.length; + } + } + for (const hunk of hunks) { + for (let i = 0; i < hunk.lines.length; i++) { + if (hunk.lines[i].endsWith("\n")) { + hunk.lines[i] = hunk.lines[i].slice(0, -1); + } else { + hunk.lines.splice(i + 1, 0, "\\ No newline at end of file"); + i++; + } + } + } + return { + oldFileName, + newFileName, + oldHeader, + newHeader, + hunks + }; + } +} +function formatPatch(patch, headerOptions) { + if (!headerOptions) { + headerOptions = INCLUDE_HEADERS; + } + if (Array.isArray(patch)) { + if (patch.length > 1 && !headerOptions.includeFileHeaders) { + throw new Error("Cannot omit file headers on a multi-file patch. (The result would be unparseable; how would a tool trying to apply the patch know which changes are to which file?)"); + } + return patch.map((p) => formatPatch(p, headerOptions)).join("\n"); + } + const ret = []; + if (headerOptions.includeIndex && patch.oldFileName == patch.newFileName) { + ret.push("Index: " + patch.oldFileName); + } + if (headerOptions.includeUnderline) { + ret.push("==================================================================="); + } + if (headerOptions.includeFileHeaders) { + ret.push("--- " + patch.oldFileName + (typeof patch.oldHeader === "undefined" ? "" : " " + patch.oldHeader)); + ret.push("+++ " + patch.newFileName + (typeof patch.newHeader === "undefined" ? "" : " " + patch.newHeader)); + } + for (let i = 0; i < patch.hunks.length; i++) { + const hunk = patch.hunks[i]; + if (hunk.oldLines === 0) { + hunk.oldStart -= 1; + } + if (hunk.newLines === 0) { + hunk.newStart -= 1; + } + ret.push("@@ -" + hunk.oldStart + "," + hunk.oldLines + " +" + hunk.newStart + "," + hunk.newLines + " @@"); + for (const line of hunk.lines) { + ret.push(line); + } + } + return ret.join("\n") + "\n"; +} +function createTwoFilesPatch(oldFileName, newFileName, oldStr, newStr, oldHeader, newHeader, options) { + if (typeof options === "function") { + options = { callback: options }; + } + if (!(options === null || options === void 0 ? void 0 : options.callback)) { + const patchObj = structuredPatch(oldFileName, newFileName, oldStr, newStr, oldHeader, newHeader, options); + if (!patchObj) { + return; + } + return formatPatch(patchObj, options === null || options === void 0 ? void 0 : options.headerOptions); + } else { + const { callback } = options; + structuredPatch(oldFileName, newFileName, oldStr, newStr, oldHeader, newHeader, Object.assign(Object.assign({}, options), { callback: (patchObj) => { + if (!patchObj) { + callback(void 0); + } else { + callback(formatPatch(patchObj, options.headerOptions)); + } + } })); + } +} +function splitLines(text) { + const hasTrailingNl = text.endsWith("\n"); + const result = text.split("\n").map((line) => line + "\n"); + if (hasTrailingNl) { + result.pop(); + } else { + result.push(result.pop().slice(0, -1)); + } + return result; +} + +// node_modules/@earendil-works/pi-durable/dist/tools/edit-diff.js +function detectLineEnding(content) { + const crlfIdx = content.indexOf("\r\n"); + const lfIdx = content.indexOf("\n"); + if (lfIdx === -1) + return "\n"; + if (crlfIdx === -1) + return "\n"; + return crlfIdx < lfIdx ? "\r\n" : "\n"; +} +function normalizeToLF(text) { + return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); +} +function restoreLineEndings(text, ending) { + return ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text; +} +function normalizeForFuzzyMatch(text) { + return text.normalize("NFKC").split("\n").map((line) => line.trimEnd()).join("\n").replace(/[\u2018\u2019\u201A\u201B]/g, "'").replace(/[\u201C\u201D\u201E\u201F]/g, '"').replace(/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/g, "-").replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " "); +} +function splitLinesWithEndings(content) { + return content.match(/[^\n]*\n|[^\n]+/g) ?? []; +} +function getLineSpans(content) { + let offset = 0; + return splitLinesWithEndings(content).map((line) => { + const span = { start: offset, end: offset + line.length }; + offset = span.end; + return span; + }); +} +function getReplacementLineRange(lines2, replacement) { + const replacementStart = replacement.matchIndex; + const replacementEnd = replacement.matchIndex + replacement.matchLength; + let startLine = -1; + for (let i = 0; i < lines2.length; i++) { + const line = lines2[i]; + if (replacementStart >= line.start && replacementStart < line.end) { + startLine = i; + break; + } + } + if (startLine === -1) { + throw new Error("Replacement range is outside the base content."); + } + let endLine = startLine; + while (endLine < lines2.length && lines2[endLine].end < replacementEnd) { + endLine++; + } + if (endLine >= lines2.length) { + throw new Error("Replacement range is outside the base content."); + } + return { startLine, endLine: endLine + 1 }; +} +function applyReplacements(content, replacements, offset = 0) { + let result = content; + for (let i = replacements.length - 1; i >= 0; i--) { + const replacement = replacements[i]; + const matchIndex = replacement.matchIndex - offset; + result = result.substring(0, matchIndex) + replacement.newText + result.substring(matchIndex + replacement.matchLength); + } + return result; +} +function applyReplacementsPreservingUnchangedLines(originalContent, baseContent, replacements) { + const originalLines = splitLinesWithEndings(originalContent); + const baseLines = getLineSpans(baseContent); + if (originalLines.length !== baseLines.length) { + throw new Error("Cannot preserve unchanged lines because the base content has a different line count."); + } + const groups = []; + const sortedReplacements = [...replacements].sort((a, b) => a.matchIndex - b.matchIndex); + for (const replacement of sortedReplacements) { + const range = getReplacementLineRange(baseLines, replacement); + const current = groups[groups.length - 1]; + if (current && range.startLine < current.endLine) { + current.endLine = Math.max(current.endLine, range.endLine); + current.replacements.push(replacement); + continue; + } + groups.push({ ...range, replacements: [replacement] }); + } + let originalLineIndex = 0; + let result = ""; + for (const group of groups) { + result += originalLines.slice(originalLineIndex, group.startLine).join(""); + const groupStartOffset = baseLines[group.startLine].start; + const groupEndOffset = baseLines[group.endLine - 1].end; + result += applyReplacements(baseContent.slice(groupStartOffset, groupEndOffset), group.replacements, groupStartOffset); + originalLineIndex = group.endLine; + } + result += originalLines.slice(originalLineIndex).join(""); + return result; +} +function fuzzyFindText(content, oldText) { + const exactIndex = content.indexOf(oldText); + if (exactIndex !== -1) { + return { + found: true, + index: exactIndex, + matchLength: oldText.length, + usedFuzzyMatch: false, + contentForReplacement: content + }; + } + const fuzzyContent = normalizeForFuzzyMatch(content); + const fuzzyOldText = normalizeForFuzzyMatch(oldText); + const fuzzyIndex = fuzzyContent.indexOf(fuzzyOldText); + if (fuzzyIndex === -1) { + return { + found: false, + index: -1, + matchLength: 0, + usedFuzzyMatch: false, + contentForReplacement: content + }; + } + return { + found: true, + index: fuzzyIndex, + matchLength: fuzzyOldText.length, + usedFuzzyMatch: true, + contentForReplacement: fuzzyContent + }; +} +function stripBom(content) { + return content.startsWith("\uFEFF") ? { bom: "\uFEFF", text: content.slice(1) } : { bom: "", text: content }; +} +function countOccurrences(content, oldText) { + const fuzzyContent = normalizeForFuzzyMatch(content); + const fuzzyOldText = normalizeForFuzzyMatch(oldText); + return fuzzyContent.split(fuzzyOldText).length - 1; +} +function getNotFoundError(path2, editIndex, totalEdits) { + if (totalEdits === 1) { + return new Error(`Could not find the exact text in ${path2}. The old text must match exactly including all whitespace and newlines.`); + } + return new Error(`Could not find edits[${editIndex}] in ${path2}. The oldText must match exactly including all whitespace and newlines.`); +} +function getDuplicateError(path2, editIndex, totalEdits, occurrences) { + if (totalEdits === 1) { + return new Error(`Found ${occurrences} occurrences of the text in ${path2}. The text must be unique. Please provide more context to make it unique.`); + } + return new Error(`Found ${occurrences} occurrences of edits[${editIndex}] in ${path2}. Each oldText must be unique. Please provide more context to make it unique.`); +} +function getEmptyOldTextError(path2, editIndex, totalEdits) { + if (totalEdits === 1) { + return new Error(`oldText must not be empty in ${path2}.`); + } + return new Error(`edits[${editIndex}].oldText must not be empty in ${path2}.`); +} +function getNoChangeError(path2, totalEdits) { + if (totalEdits === 1) { + return new Error(`No changes made to ${path2}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.`); + } + return new Error(`No changes made to ${path2}. The replacements produced identical content.`); +} +function applyEditsToNormalizedContent(normalizedContent, edits, path2) { + const normalizedEdits = edits.map((edit) => ({ + oldText: normalizeToLF(edit.oldText), + newText: normalizeToLF(edit.newText) + })); + for (let i = 0; i < normalizedEdits.length; i++) { + if (normalizedEdits[i].oldText.length === 0) { + throw getEmptyOldTextError(path2, i, normalizedEdits.length); + } + } + const initialMatches = normalizedEdits.map((edit) => fuzzyFindText(normalizedContent, edit.oldText)); + const usedFuzzyMatch = initialMatches.some((match) => match.usedFuzzyMatch); + const replacementBaseContent = usedFuzzyMatch ? normalizeForFuzzyMatch(normalizedContent) : normalizedContent; + const matchedEdits = []; + for (let i = 0; i < normalizedEdits.length; i++) { + const edit = normalizedEdits[i]; + const matchResult = fuzzyFindText(replacementBaseContent, edit.oldText); + if (!matchResult.found) { + throw getNotFoundError(path2, i, normalizedEdits.length); + } + const occurrences = countOccurrences(replacementBaseContent, edit.oldText); + if (occurrences > 1) { + throw getDuplicateError(path2, i, normalizedEdits.length, occurrences); + } + matchedEdits.push({ + editIndex: i, + matchIndex: matchResult.index, + matchLength: matchResult.matchLength, + newText: edit.newText + }); + } + matchedEdits.sort((a, b) => a.matchIndex - b.matchIndex); + for (let i = 1; i < matchedEdits.length; i++) { + const previous = matchedEdits[i - 1]; + const current = matchedEdits[i]; + if (previous.matchIndex + previous.matchLength > current.matchIndex) { + throw new Error(`edits[${previous.editIndex}] and edits[${current.editIndex}] overlap in ${path2}. Merge them into one edit or target disjoint regions.`); + } + } + const baseContent = normalizedContent; + const newContent = usedFuzzyMatch ? applyReplacementsPreservingUnchangedLines(normalizedContent, replacementBaseContent, matchedEdits) : applyReplacements(replacementBaseContent, matchedEdits); + if (baseContent === newContent) { + throw getNoChangeError(path2, normalizedEdits.length); + } + return { baseContent, newContent }; +} +function generateUnifiedPatch(path2, oldContent, newContent, contextLines = 4) { + return createTwoFilesPatch(path2, path2, oldContent, newContent, void 0, void 0, { + context: contextLines, + headerOptions: FILE_HEADERS_ONLY + }); +} +function generateDiffString(oldContent, newContent, contextLines = 4) { + const parts = diffLines(oldContent, newContent); + const output = []; + const oldLines = oldContent.split("\n"); + const newLines = newContent.split("\n"); + const maxLineNum = Math.max(oldLines.length, newLines.length); + const lineNumWidth = String(maxLineNum).length; + let oldLineNum = 1; + let newLineNum = 1; + let lastWasChange = false; + let firstChangedLine; + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + const raw = part.value.split("\n"); + if (raw[raw.length - 1] === "") { + raw.pop(); + } + if (part.added || part.removed) { + if (firstChangedLine === void 0) { + firstChangedLine = newLineNum; + } + for (const line of raw) { + if (part.added) { + const lineNum = String(newLineNum).padStart(lineNumWidth, " "); + output.push(`+${lineNum} ${line}`); + newLineNum++; + } else { + const lineNum = String(oldLineNum).padStart(lineNumWidth, " "); + output.push(`-${lineNum} ${line}`); + oldLineNum++; + } + } + lastWasChange = true; + } else { + const nextPartIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed); + const hasLeadingChange = lastWasChange; + const hasTrailingChange = nextPartIsChange; + if (hasLeadingChange && hasTrailingChange) { + if (raw.length <= contextLines * 2) { + for (const line of raw) { + const lineNum = String(oldLineNum).padStart(lineNumWidth, " "); + output.push(` ${lineNum} ${line}`); + oldLineNum++; + newLineNum++; + } + } else { + const leadingLines = raw.slice(0, contextLines); + const trailingLines = raw.slice(raw.length - contextLines); + const skippedLines = raw.length - leadingLines.length - trailingLines.length; + for (const line of leadingLines) { + const lineNum = String(oldLineNum).padStart(lineNumWidth, " "); + output.push(` ${lineNum} ${line}`); + oldLineNum++; + newLineNum++; + } + output.push(` ${"".padStart(lineNumWidth, " ")} ...`); + oldLineNum += skippedLines; + newLineNum += skippedLines; + for (const line of trailingLines) { + const lineNum = String(oldLineNum).padStart(lineNumWidth, " "); + output.push(` ${lineNum} ${line}`); + oldLineNum++; + newLineNum++; + } + } + } else if (hasLeadingChange) { + const shownLines = raw.slice(0, contextLines); + const skippedLines = raw.length - shownLines.length; + for (const line of shownLines) { + const lineNum = String(oldLineNum).padStart(lineNumWidth, " "); + output.push(` ${lineNum} ${line}`); + oldLineNum++; + newLineNum++; + } + if (skippedLines > 0) { + output.push(` ${"".padStart(lineNumWidth, " ")} ...`); + oldLineNum += skippedLines; + newLineNum += skippedLines; + } + } else if (hasTrailingChange) { + const skippedLines = Math.max(0, raw.length - contextLines); + if (skippedLines > 0) { + output.push(` ${"".padStart(lineNumWidth, " ")} ...`); + oldLineNum += skippedLines; + newLineNum += skippedLines; + } + for (const line of raw.slice(skippedLines)) { + const lineNum = String(oldLineNum).padStart(lineNumWidth, " "); + output.push(` ${lineNum} ${line}`); + oldLineNum++; + newLineNum++; + } + } else { + oldLineNum += raw.length; + newLineNum += raw.length; + } + lastWasChange = false; + } + } + return { diff: output.join("\n"), firstChangedLine }; +} + +// node_modules/@earendil-works/pi-durable/dist/tools/file-mutation-queue.js +var queues = /* @__PURE__ */ new Map(); +async function mutationKey(env, path2, context) { + const absolutePath = getOrThrow(await env.absolutePath(path2, context)); + return `${env.id}\0${await canonical(env, absolutePath, context)}`; +} +async function canonical(env, absolutePath, context) { + const result = await env.canonicalPath(absolutePath, context); + if (result.ok) + return result.value; + if (result.error.code === "not_supported") + return absolutePath; + if (result.error.code !== "not_found") + throw result.error; + const parent = getOrThrow(await env.joinPath([absolutePath, ".."], context)); + if (parent === absolutePath || !absolutePath.startsWith(parent)) + return absolutePath; + const name = absolutePath.slice(parent.length + (/[/\\]$/.test(parent) ? 0 : 1)); + return getOrThrow(await env.joinPath([await canonical(env, parent, context), name], context)); +} +async function withFileMutationQueue(env, path2, fn, context) { + const key = await mutationKey(env, path2, context); + const previous = queues.get(key) ?? Promise.resolve(); + let release = () => { + }; + const done = new Promise((resolve2) => { + release = resolve2; + }); + const tail = previous.then(() => done); + queues.set(key, tail); + await previous; + try { + return await fn(); + } finally { + release(); + if (queues.get(key) === tail) + queues.delete(key); + } +} + +// node_modules/@earendil-works/pi-durable/dist/tools/path-utils.js +var UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g; +var NARROW_NO_BREAK_SPACE = "\u202F"; +function normalizeToolPath(path2) { + const normalized = path2.replace(UNICODE_SPACES, " "); + return normalized.startsWith("@") ? normalized.slice(1) : normalized; +} +async function resolveToolPath(env, path2, context) { + return getOrThrow(await env.absolutePath(normalizeToolPath(path2), context)); +} +async function resolveReadToolPath(env, path2, context) { + const resolved = await resolveToolPath(env, path2, context); + const variants = [ + resolved, + resolved.replace(/ (AM|PM)\./gi, `${NARROW_NO_BREAK_SPACE}$1.`), + resolved.normalize("NFD"), + resolved.replace(/'/g, "\u2019"), + resolved.normalize("NFD").replace(/'/g, "\u2019") + ]; + for (const variant of new Set(variants)) { + if (getOrThrow(await env.exists(variant, context))) + return variant; + } + return resolved; +} + +// node_modules/@earendil-works/pi-durable/dist/tools/edit.js +var replaceEditSchema = obj({ + oldText: str({ + description: "Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call." + }), + newText: str({ description: "Replacement text for this targeted edit." }) +}, { open: true }); +var editSchema = obj({ + path: str({ description: "Path to the file to edit (relative or absolute)" }), + edits: arr(replaceEditSchema, { + description: "One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead." + }) +}, { open: true }); +function isSingleEditInput(value2) { + if (!value2 || typeof value2 !== "object" || Array.isArray(value2)) + return false; + const edit = value2; + return typeof edit.oldText === "string" && typeof edit.newText === "string"; +} +function prepareEditArguments(input) { + if (!input || typeof input !== "object" || Array.isArray(input)) + return input; + const args = { ...input }; + if (typeof args.edits === "string") { + try { + const parsed = JSON.parse(args.edits); + if (Array.isArray(parsed)) { + args.edits = parsed; + } else if (isSingleEditInput(parsed)) { + args.edits = [parsed]; + } + } catch { + } + } else if (isSingleEditInput(args.edits)) { + args.edits = [args.edits]; + } + const legacy = args; + if (typeof legacy.oldText !== "string" || typeof legacy.newText !== "string") + return legacy; + const edits = Array.isArray(legacy.edits) ? [...legacy.edits] : []; + edits.push({ oldText: legacy.oldText, newText: legacy.newText }); + const { oldText: _oldText, newText: _newText, ...rest } = legacy; + return { ...rest, edits }; +} +function validateEditInput(input) { + if (!Array.isArray(input.edits) || input.edits.length === 0) { + throw new Error("Edit tool input is invalid. edits must contain at least one replacement."); + } + return { path: input.path, edits: input.edits }; +} +function editAccessError(path2, error) { + return new Error(`Could not edit file: ${path2}. Error code: ${error.code}.`, { cause: error }); +} +function createEditTool() { + return defineTool({ + name: "edit", + description: "Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.", + parameters: editSchema, + prepareArguments: prepareEditArguments, + async execute(args, api, context) { + const { path: path2, edits } = validateEditInput(args); + const env = requireEnv(api); + const absolutePath = await resolveToolPath(env, path2, context); + return withFileMutationQueue(env, absolutePath, async () => { + if (context.abortSignal?.aborted) + throw new Error("Operation aborted"); + const info = await env.fileInfo(absolutePath, context); + if (!info.ok) + throw editAccessError(path2, info.error); + if (info.value.kind !== "file" && info.value.kind !== "symlink") { + throw new Error(`Could not edit file: ${path2}. Path is not a file.`); + } + const readResult = await env.readTextFile(absolutePath, context); + if (!readResult.ok) + throw editAccessError(path2, readResult.error); + if (context.abortSignal?.aborted) + throw new Error("Operation aborted"); + const { bom, text: content } = stripBom(readResult.value); + const originalEnding = detectLineEnding(content); + const normalizedContent = normalizeToLF(content); + const { baseContent, newContent } = applyEditsToNormalizedContent(normalizedContent, edits, path2); + if (context.abortSignal?.aborted) + throw new Error("Operation aborted"); + const finalContent = bom + restoreLineEndings(newContent, originalEnding); + const writeResult = await env.writeFile(absolutePath, finalContent, context); + if (!writeResult.ok) + throw editAccessError(path2, writeResult.error); + if (context.abortSignal?.aborted) + throw new Error("Operation aborted"); + const diffResult = generateDiffString(baseContent, newContent); + const details = { + diff: diffResult.diff, + patch: generateUnifiedPatch(path2, baseContent, newContent), + ...diffResult.firstChangedLine === void 0 ? {} : { firstChangedLine: diffResult.firstChangedLine } + }; + return { + output: [{ type: "text", text: `Successfully replaced ${edits.length} block(s) in ${path2}.` }], + details + }; + }, context); + } + }); +} + +// node_modules/@earendil-works/pi-durable/dist/tools/image.js +var PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10]; +var HEADER_BYTES = 32; +var BLOCK_BYTES = 64 * 1024; +async function detectSupportedImageMimeTypeOf(source) { + const header = await source.read(0, HEADER_BYTES); + if (!startsWith(header, PNG_SIGNATURE)) + return detectSupportedImageMimeType(header); + return isPng(header) && !await isAnimatedPngOf(source) ? "image/png" : void 0; +} +async function isAnimatedPngOf(source) { + let block = new Uint8Array(0); + let blockStart = 0; + const bytesAt = async (offset2, length) => { + if (offset2 < blockStart || offset2 + length > blockStart + block.length) { + blockStart = offset2; + block = await source.read(offset2, BLOCK_BYTES); + } + return block.subarray(offset2 - blockStart, offset2 - blockStart + length); + }; + let offset = PNG_SIGNATURE.length; + while (offset + 8 <= source.size) { + const chunkHeader = await bytesAt(offset, 8); + const chunkLength = readUint32BE(chunkHeader, 0); + if (startsWithAscii(chunkHeader, 4, "acTL")) + return true; + if (startsWithAscii(chunkHeader, 4, "IDAT")) + return false; + const nextOffset = offset + 8 + chunkLength + 4; + if (nextOffset <= offset || nextOffset > source.size) + return false; + offset = nextOffset; + } + return false; +} +function detectSupportedImageMimeType(buffer) { + if (startsWith(buffer, [255, 216, 255])) + return buffer[3] === 247 ? void 0 : "image/jpeg"; + if (startsWith(buffer, PNG_SIGNATURE)) + return isPng(buffer) && !isAnimatedPng(buffer) ? "image/png" : void 0; + if (startsWithAscii(buffer, 0, "GIF87a") || startsWithAscii(buffer, 0, "GIF89a")) + return "image/gif"; + if (startsWithAscii(buffer, 0, "RIFF") && startsWithAscii(buffer, 8, "WEBP")) + return "image/webp"; + if (startsWithAscii(buffer, 0, "BM") && isBmp(buffer)) + return "image/bmp"; + return void 0; +} +function isPng(buffer) { + return buffer.length >= 16 && readUint32BE(buffer, PNG_SIGNATURE.length) === 13 && startsWithAscii(buffer, 12, "IHDR"); +} +function isAnimatedPng(buffer) { + let offset = PNG_SIGNATURE.length; + while (offset + 8 <= buffer.length) { + const chunkLength = readUint32BE(buffer, offset); + const chunkTypeOffset = offset + 4; + if (startsWithAscii(buffer, chunkTypeOffset, "acTL")) + return true; + if (startsWithAscii(buffer, chunkTypeOffset, "IDAT")) + return false; + const nextOffset = offset + 8 + chunkLength + 4; + if (nextOffset <= offset || nextOffset > buffer.length) + return false; + offset = nextOffset; + } + return false; +} +function imageDimensions(bytes2, mimeType) { + switch (mimeType) { + case "image/png": + return bytes2.length < 24 ? void 0 : { width: readUint32BE(bytes2, 16), height: readUint32BE(bytes2, 20) }; + case "image/gif": + return bytes2.length < 10 ? void 0 : { width: readUint16LE(bytes2, 6), height: readUint16LE(bytes2, 8) }; + case "image/webp": + if (bytes2.length < 30) + return void 0; + if (startsWithAscii(bytes2, 12, "VP8 ")) + return { width: readUint16LE(bytes2, 26) & 16383, height: readUint16LE(bytes2, 28) & 16383 }; + if (startsWithAscii(bytes2, 12, "VP8L")) { + const bits = readUint32LE(bytes2, 21); + return { width: (bits & 16383) + 1, height: (bits >>> 14 & 16383) + 1 }; + } + if (startsWithAscii(bytes2, 12, "VP8X")) { + const uint24 = (offset) => readUint16LE(bytes2, offset) + ((bytes2[offset + 2] ?? 0) << 16); + return { width: uint24(24) + 1, height: uint24(27) + 1 }; + } + return void 0; + case "image/jpeg": + for (let offset = 2; offset + 9 <= bytes2.length; ) { + if (bytes2[offset] !== 255) + return void 0; + const marker = bytes2[offset + 1]; + if (marker === 255) { + offset++; + continue; + } + if (marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204) { + return { width: readUint16BE(bytes2, offset + 7), height: readUint16BE(bytes2, offset + 5) }; + } + offset += 2 + readUint16BE(bytes2, offset + 2); + } + return void 0; + default: + return void 0; + } +} +function isBmp(buffer) { + if (buffer.length < 26) + return false; + const declaredFileSize = readUint32LE(buffer, 2); + const pixelDataOffset = readUint32LE(buffer, 10); + const dibHeaderSize = readUint32LE(buffer, 14); + if (declaredFileSize !== 0 && declaredFileSize < 26) + return false; + if (pixelDataOffset < 14 + dibHeaderSize) + return false; + if (declaredFileSize !== 0 && pixelDataOffset >= declaredFileSize) + return false; + let colorPlanes; + let bitsPerPixel; + if (dibHeaderSize === 12) { + colorPlanes = readUint16LE(buffer, 22); + bitsPerPixel = readUint16LE(buffer, 24); + } else if (dibHeaderSize >= 40 && dibHeaderSize <= 124) { + if (buffer.length < 30) + return false; + colorPlanes = readUint16LE(buffer, 26); + bitsPerPixel = readUint16LE(buffer, 28); + } else { + return false; + } + return colorPlanes === 1 && [1, 4, 8, 16, 24, 32].includes(bitsPerPixel); +} +function readUint16LE(buffer, offset) { + return (buffer[offset] ?? 0) + ((buffer[offset + 1] ?? 0) << 8); +} +function readUint16BE(buffer, offset) { + return ((buffer[offset] ?? 0) << 8) + (buffer[offset + 1] ?? 0); +} +function readUint32BE(buffer, offset) { + return (buffer[offset] ?? 0) * 16777216 + ((buffer[offset + 1] ?? 0) << 16) + ((buffer[offset + 2] ?? 0) << 8) + (buffer[offset + 3] ?? 0); +} +function readUint32LE(buffer, offset) { + return (buffer[offset] ?? 0) + ((buffer[offset + 1] ?? 0) << 8) + ((buffer[offset + 2] ?? 0) << 16) + (buffer[offset + 3] ?? 0) * 16777216; +} +function startsWith(buffer, bytes2) { + if (buffer.length < bytes2.length) + return false; + return bytes2.every((byte, index) => buffer[index] === byte); +} +function startsWithAscii(buffer, offset, text) { + if (buffer.length < offset + text.length) + return false; + for (let index = 0; index < text.length; index++) { + if (buffer[offset + index] !== text.charCodeAt(index)) + return false; + } + return true; +} + +// node_modules/@earendil-works/pi-durable/dist/tools/image-processor.js +var DEFAULT_IMAGE_LIMITS = { maxWidth: 2e3, maxHeight: 2e3, maxBytes: 4.5 * 1024 * 1024 }; +var INLINE_IMAGE_TYPES = /* @__PURE__ */ new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]); +function toBase64(bytes2) { + const parts = []; + for (let i = 0; i < bytes2.length; i += 8192) { + parts.push(String.fromCharCode.apply(null, bytes2.subarray(i, i + 8192))); + } + return btoa(parts.join("")); +} +function base64Length(byteLength) { + return Math.ceil(byteLength / 3) * 4; +} + +// node_modules/@earendil-works/pi-durable/dist/tools/read.js +var readSchema = obj({ + path: str({ description: "Path to the file to read (relative or absolute)" }), + offset: opt(num({ description: "Line number to start reading from (1-indexed)" })), + limit: opt(num({ description: "Maximum number of lines to read" })) +}, { open: true }); +var readStructuredOutputSchema = union([ + str(), + obj({ type: lit("image"), data: str(), mimeType: str() }, { open: true }) +]); +var READ_CHUNK = 64 * 1024; +function sliceIndex(value2) { + return Number.isNaN(value2) ? 0 : Math.trunc(value2); +} +async function readHead(reader, start, end, skipBom, context) { + const decoder2 = rangeDecoder(); + let text = ""; + let newlines = 0; + for (let position = skipBom && start === 0 ? 3 : start; position < end; ) { + const bytes2 = getOrThrow(await reader.read(position, Math.min(READ_CHUNK, end - position), context)); + if (bytes2.length === 0) + break; + position += bytes2.length; + const decoded = decoder2.decode(bytes2, { stream: true }); + text += decoded; + for (let index = decoded.indexOf("\n"); index !== -1; index = decoded.indexOf("\n", index + 1)) + newlines++; + if (newlines >= DEFAULT_MAX_LINES || utf8ByteLength(text) > DEFAULT_MAX_BYTES + 1) + return text; + } + return text + decoder2.decode(); +} +function createReadTool(options = {}) { + const formats2 = options.images === void 0 ? "jpg, png, gif, webp" : "jpg, png, gif, webp, bmp"; + return defineTool({ + name: "read", + description: `Read the contents of a file. Supports text files and images (${formats2}). Images are sent as attachments. For text files, output is truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.`, + parameters: readSchema, + structuredOutputSchema: readStructuredOutputSchema, + async execute(args, api, context) { + const { path: path2, offset, limit } = args; + const env = requireEnv(api); + const absolutePath = await resolveReadToolPath(env, path2, context); + const reader = getOrThrow(await env.openBinaryReader(absolutePath, void 0, context)); + try { + for (let attempt = 0; ; attempt++) { + const before = getOrThrow(await reader.info(context)); + const mimeType = await detectSupportedImageMimeTypeOf({ + size: before.size, + read: async (position, length) => getOrThrow(await reader.read(position, length, context)) + }); + const result = mimeType === void 0 ? await readText(reader, path2, offset, limit, context) : await readImage(reader, before, path2, mimeType, options.images, await imageModel(api, context), context); + const after = getOrThrow(await reader.info(context)); + const unchanged = after.size === before.size && after.mtimeMs === before.mtimeMs; + if (unchanged || mimeType === void 0 && after.size > before.size) + return result; + if (attempt === 1) + throw new Error(`${path2} changed while it was read`); + } + } finally { + await reader.close(context); + } + } + }); +} +async function readImage(reader, info, path2, mimeType, processor, { vision, limits }, context) { + let image; + const notes = []; + if (processor !== void 0) { + const bytes2 = getOrThrow(await reader.read(0, info.size, context)); + const prepared = await processor.prepare(bytes2, mimeType, limits); + if (prepared === void 0) + return imageError(`${path2} is an image (${mimeType}) that cannot be prepared for the model`); + image = prepared; + if (prepared.convertedFrom !== void 0) + notes.push(`Converted from ${prepared.convertedFrom} to ${prepared.mimeType}.`); + if (prepared.resized !== void 0) { + const { from, to } = prepared.resized; + const scale = (from.width / to.width).toFixed(2); + notes.push(`Resized from ${from.width}x${from.height} to ${to.width}x${to.height}. Multiply coordinates by ${scale} to map them to the original.`); + } + } else { + if (!INLINE_IMAGE_TYPES.has(mimeType)) { + return imageError(`${path2} is an image (${mimeType}) that needs converting, and no image processor is configured`); + } + const unshrinkable = "and no image processor is configured to shrink it"; + if (base64Length(info.size) > limits.maxBytes) { + return imageError(`${path2} is an image (${mimeType}) of ${formatSize(info.size)}, too large to send, ${unshrinkable}`); + } + const bytes2 = getOrThrow(await reader.read(0, info.size, context)); + const size = imageDimensions(bytes2, mimeType); + if (size === void 0) + return imageError(`${path2} is an image (${mimeType}) whose size cannot be read`); + if (size.width > limits.maxWidth || size.height > limits.maxHeight) { + return imageError(`${path2} is an image (${mimeType}) of ${size.width}x${size.height}, larger than ${limits.maxWidth}x${limits.maxHeight}, ${unshrinkable}`); + } + image = { data: toBase64(bytes2), mimeType }; + } + if (!vision) + notes.push("The current model does not support images; it sees a placeholder instead."); + const diagnostics = [ + { severity: "info", code: "image", message: [`Read image file [${image.mimeType}].`, ...notes].join(" ") } + ]; + const block = { type: "image", data: image.data, mimeType: image.mimeType }; + return { output: [block], structuredOutput: block, diagnostics }; +} +async function imageModel(api, context) { + const ref = (await api.agent(context)).model; + const model2 = ref === void 0 ? void 0 : api.models.getModel(ref.provider, ref.modelId); + const resize = model2?.inputLimits?.images?.resize; + return { + vision: model2?.input.includes("image") ?? true, + limits: { + maxWidth: resize?.maxWidth ?? DEFAULT_IMAGE_LIMITS.maxWidth, + maxHeight: resize?.maxHeight ?? DEFAULT_IMAGE_LIMITS.maxHeight, + maxBytes: resize?.maxBytes ?? DEFAULT_IMAGE_LIMITS.maxBytes + } + }; +} +function imageError(message) { + return { output: [], isError: true, diagnostics: [{ severity: "error", code: "unsupported_image", message }] }; +} +async function readText(reader, path2, offset, limit, context) { + const startLine = offset ? Math.max(0, offset - 1) : 0; + const startLineDisplay = startLine + 1; + const sliceStart = sliceIndex(startLine); + const scanStart2 = Number.isSafeInteger(sliceStart) ? sliceStart : 0; + const requestedEnd = limit === void 0 ? void 0 : Math.max(scanStart2 + 1, sliceIndex(startLine + limit)); + const scanEnd = requestedEnd !== void 0 && Number.isSafeInteger(requestedEnd) ? requestedEnd : void 0; + const scanOf = async (endLine) => getOrThrow(await reader.scanLines({ startLine: scanStart2, ...endLine === void 0 ? {} : { endLine } }, context)); + let scan = await scanOf(scanEnd); + const totalFileLines = scan.newlines + 1; + if (startLine >= totalFileLines) { + throw new Error(`Offset ${offset} is beyond end of file (${totalFileLines} lines total)`); + } + let userLimitedLines; + let selectedLineCount = totalFileLines - sliceStart; + if (limit !== void 0) { + const endLine = Math.min(startLine + limit, totalFileLines); + userLimitedLines = endLine - startLine; + const relativeEnd = sliceIndex(endLine); + const sliceEnd = relativeEnd < 0 ? Math.max(totalFileLines + relativeEnd, 0) : relativeEnd; + selectedLineCount = Math.max(0, sliceEnd - sliceStart); + if (selectedLineCount > 0 && relativeEnd < 0) + scan = await scanOf(sliceEnd); + } + const empty = selectedLineCount === 0; + const endsWithNewline = !empty && scan.lastLineStart === scan.end && scan.lastLineStart > scan.start; + const totals = { + lines: empty || scan.selectedBytes === 0 ? 0 : selectedLineCount - (endsWithNewline ? 1 : 0), + bytes: empty ? 0 : scan.selectedBytes + }; + const firstBytes = getOrThrow(await reader.read(0, 3, context)); + const head = empty ? "" : await readHead(reader, scan.start, scan.end, startsWithBom(firstBytes), context); + const { content: headText, ...truncation } = truncateHeadOf(head, totals); + const diagnostics = []; + let outputText = headText; + let details; + if (truncation.firstLineExceedsLimit) { + const integral = Number.isInteger(startLine); + const lineBytes = new TextEncoder().encode(integral ? head.split("\n")[0] ?? "" : ""); + const lineSize = integral ? scan.firstLineBytes : 0; + const end = characterEnd(lineBytes, DEFAULT_MAX_BYTES); + outputText = new TextDecoder().decode(lineBytes.subarray(0, end)); + diagnostics.push({ + severity: "warn", + code: "truncated", + message: `Line ${startLineDisplay} is ${formatSize(lineSize)}, exceeds the ${formatSize(DEFAULT_MAX_BYTES)} limit; showing its first ${formatSize(end)}. Use bash: sed -n '${startLineDisplay}p' ${path2} | tail -c +${end + 1}` + }); + details = { truncation: { ...truncation, outputBytes: end, outputLines: 1 } }; + } else if (truncation.truncated) { + const endLineDisplay = startLineDisplay + truncation.outputLines - 1; + const nextOffset = endLineDisplay + 1; + const limitText = truncation.truncatedBy === "lines" ? "" : ` (${formatSize(DEFAULT_MAX_BYTES)} limit)`; + diagnostics.push({ + severity: "info", + code: "truncated", + message: `Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}${limitText}. Use offset=${nextOffset} to continue.` + }); + details = { truncation }; + } else if (userLimitedLines !== void 0 && startLine + userLimitedLines < totalFileLines) { + const remaining = totalFileLines - (startLine + userLimitedLines); + const nextOffset = startLine + userLimitedLines + 1; + diagnostics.push({ + severity: "info", + message: `${remaining} more lines in file. Use offset=${nextOffset} to continue.` + }); + } + return { + output: outputText === "" ? [] : [{ type: "text", text: outputText }], + structuredOutput: outputText, + ...details === void 0 ? {} : { details }, + diagnostics + }; +} + +// node_modules/@earendil-works/pi-durable/dist/tools/write.js +var writeSchema = obj({ + path: str({ description: "Path to the file to write (relative or absolute)" }), + content: str({ description: "Content to write to the file" }) +}, { open: true }); +function createWriteTool() { + return defineTool({ + name: "write", + description: "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.", + parameters: writeSchema, + async execute(args, api, context) { + const { path: path2, content } = args; + const env = requireEnv(api); + const absolutePath = await resolveToolPath(env, path2, context); + return withFileMutationQueue(env, absolutePath, async () => { + if (context.abortSignal?.aborted) + throw new Error("Operation aborted"); + getOrThrow(await env.writeFile(absolutePath, content, context)); + if (context.abortSignal?.aborted) + throw new Error("Operation aborted"); + return { output: [{ type: "text", text: `Successfully wrote to ${path2}` }] }; + }, context); + } + }); +} + +// node_modules/@earendil-works/pi-durable/dist/tools/index.js +function createCodingTools(options = {}) { + return defineExtension({ + name: "coding-tools", + tools: [createReadTool(options), createWriteTool(), createEditTool(), createBashTool()] + }); +} +var CodingTools = createCodingTools(); + +// environment.ts +var CodingTools2 = { ...CodingTools, tools: CodingTools.tools.map((tool) => ({ + ...tool, + execute: (args, api, context) => tool.execute(args, { ...api, env: api.env.forTool(api.callId, tool.name) }, context) +})) }; +var Started = defineDoc({ + kind: "board.started", + version: 1, + checkpointWhen: () => false, + scope: "conversation", + history: "latest", + fork: "initial", + initial: () => ({}) +}); +var ops = Deno.core.ops; +var ok = (value2) => ({ ok: true, value: value2 }); +var path = (cwd, input) => { + const parts = []; + for (const p of (input.startsWith("/") ? input : `${cwd}/${input}`).split("/")) { + if (p === "..") parts.pop(); + else if (p && p !== ".") parts.push(p); + } + return "/" + parts.join("/"); +}; +function environment(cwd = "/", conversation, invocation) { + const conversations = Array.isArray(conversation) ? conversation : conversation === void 0 ? [] : [String(conversation)]; + const tag = invocation && { id: invocation.id, pid: 0, program: invocation.tool, conversations }; + const call = (kind, p, bytes2 = []) => ops.op_file(kind, path(cwd, p), bytes2, tag); + const env = { + id: "world", + cwd, + forTool: (id, tool) => environment(cwd, conversations, { id, tool }), + absolutePath: async (p) => ok(path(cwd, p)), + canonicalPath: async () => ({ ok: false, error: { code: "not_supported", message: "The VFS does not expose canonical paths" } }), + joinPath: async (parts) => ok(path(cwd, parts.join("/"))), + fileInfo: (p) => call("stat", p), + exists: async (p) => { + const result = await call("stat", p); + return result.ok ? ok(true) : result.error.code === "not_found" ? ok(false) : result; + }, + readBinaryFile: async (p) => { + const r = await call("read", p); + return r.ok ? ok(new Uint8Array(r.value)) : r; + }, + readTextFile: async (p) => { + const r = await env.readBinaryFile(p); + return r.ok ? ok(new TextDecoder().decode(r.value)) : r; + }, + writeFile: async (p, content) => { + const full = path(cwd, p); + const parent = full.slice(0, full.lastIndexOf("/")) || "/"; + const created = await env.createDir(parent, { recursive: true }); + if (!created.ok) return created; + return call("write", full, Array.from(typeof content === "string" ? new TextEncoder().encode(content) : content)); + }, + createDir: async (p, options) => { + const full = path(cwd, p); + if (!options?.recursive) return call("mkdir", full); + let current = ""; + for (const part of full.split("/").filter(Boolean)) { + current += "/" + part; + const exists = await call("stat", current); + if (!exists.ok) { + const r = await call("mkdir", current); + if (!r.ok && (await call("stat", current)).value?.kind !== "directory") return r; + } + } + return ok(void 0); + }, + openBinaryReader: async (p) => { + const data = await env.readBinaryFile(p); + if (!data.ok) return data; + const info = await env.fileInfo(p); + return ok({ + info: async () => info, + read: async (offset, length) => ok(data.value.slice(offset, offset + length)), + scanLines: async (options) => { + const scanner = new LineScanner(options.startLine, options.endLine); + scanner.push(data.value); + return ok(scanner.finish()); + }, + close: async () => { + } + }); + }, + exec: async (command, options, context) => { + let spillPath; + let pending = "", bytes2 = 0, lines2 = 0; + const output = async (text, stream) => { + options?.onOutput?.(text, context, { stream }); + if (!options?.spill) return; + bytes2 += new TextEncoder().encode(text).length; + lines2 += text.split("\n").length - 1; + if (!spillPath) { + pending += text; + if (bytes2 <= options.spill.afterBytes && lines2 + (pending.endsWith("\n") ? 0 : 1) <= options.spill.afterLines) return; + spillPath = `/tmp/bash-${crypto.randomUUID()}.log`; + const created = await env.writeFile(spillPath, pending); + if (!created.ok) throw new Error(created.error.message); + pending = ""; + } else { + const appended = await call("append", spillPath, Array.from(new TextEncoder().encode(text))); + if (!appended.ok) throw new Error(appended.error.message); + } + }; + let rid; + const abort = () => { + if (rid !== void 0) { + Deno.core.close(rid); + rid = void 0; + } + }; + try { + context.abortSignal?.throwIfAborted(); + rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, void 0, void 0, conversations, tag); + context.abortSignal?.addEventListener("abort", abort, { once: true }); + const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() }; + let result; + while (true) { + const chunk = await ops.op_exec_next(rid); + if (!chunk.stream) { + rid = void 0; + result = chunk; + break; + } + await output(decoders[chunk.stream].decode(new Uint8Array(chunk.bytes), { stream: true }), chunk.stream); + } + if (result.error) throw new Error(result.error); + for (const stream of ["stdout", "stderr"]) { + const text = decoders[stream].decode(); + if (text) await output(text, stream); + } + return ok({ exitCode: result.exitCode, ...spillPath ? { spillPath } : {} }); + } catch (error) { + return { ok: false, error: Object.assign(new Error(String(error)), { code: "unknown", ...spillPath ? { spillPath } : {} }) }; + } finally { + context.abortSignal?.removeEventListener("abort", abort); + abort(); + } + }, + cleanup: async () => { + } + }; + return env; +} + // foundation-test.ts var assert = (value2, message) => { if (!value2) throw new Error(message); @@ -20573,12 +22439,25 @@ models.setProvider(faux.provider); var registry = createRegistry(); registry.install(Notes); registry.install(defineExtension({ name: "hold-test", tasks: [Park] })); +var Shutdown = defineExtension({ name: "shutdown-test", tools: [defineTool({ name: "long_tool", description: "Run a long world process", parameters: obj({}), replay: "safe", execute: async (_args, _api, ctx) => { + const env = environment("/"); + const resume = (await env.readTextFile("/resume")).ok; + const work = env.exec(["/bin/sleep.wasm", resume ? "0" : "600"], {}, ctx); + if (!resume) { + await until(async () => (await env.readTextFile("/proc/processes")).value?.includes("600")); + await env.writeFile("/sleep-ready", "yes"); + } + const result = await work; + assert(result.ok, `Sleep failed: ${JSON.stringify(result)}`); + return { output: [{ type: "text", text: "sleep completed" }] }; +} })] }); +registry.install(Shutdown); var storage = await openStorage(globalThis.storagePath); var harness = await Harness.open(storage, { models, registry, settings: { followUpMode: "all" } }, BACKGROUND_CONTEXT); var scenario = globalThis.foundationCase; var root = await harness.root(BACKGROUND_CONTEXT, { agent: { model: { provider: "faux", modelId: "faux-1" }, - extensions: scenario === "placement" ? [Notes] : [] + extensions: scenario === "placement" ? [Notes] : scenario === "shutdown" ? [Shutdown] : [] } }); try { if (scenario === "placement" || scenario === "followups") { @@ -20650,6 +22529,20 @@ try { await until(() => second); await harness.waitForIdle(BACKGROUND_CONTEXT); equal((await harness.getTask(task, BACKGROUND_CONTEXT))?.state.status, "terminal"); + } else if (scenario === "shutdown") { + faux.setResponses([fauxAssistantMessage([fauxToolCall("long_tool", {})], { stopReason: "toolUse" }), fauxAssistantMessage("resumed answer")]); + const resumed = (await environment("/").readTextFile("/resume")).ok; + if (!resumed) await root.submit({ type: "input", content: "sleep now", requestId: "sleep-request" }, BACKGROUND_CONTEXT); + harness.resume(); + if (resumed) { + faux.setResponses([fauxAssistantMessage("resumed answer")]); + await harness.waitForIdle(BACKGROUND_CONTEXT); + const transcript = JSON.stringify(await root.context(BACKGROUND_CONTEXT)); + assert(transcript.includes("resumed answer"), "Shutdown did not resume"); + assert(transcript.includes("sleep completed"), "Shutdown did not replay the interrupted tool"); + const submissions = await storage.scanSubmissions({ conversationId: root.id }, 100, void 0, BACKGROUND_CONTEXT); + equal(submissions.items.length, 1); + } else await serve(harness, BACKGROUND_CONTEXT); } else if (scenario === "headless") { faux.setResponses([fauxAssistantMessage("channel answer")]); await serve(harness, BACKGROUND_CONTEXT); diff --git a/host/js/foundation-test.ts b/host/js/foundation-test.ts index 0db05b5..52d2a68 100644 --- a/host/js/foundation-test.ts +++ b/host/js/foundation-test.ts @@ -2,9 +2,11 @@ import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context'; import { createModels } from '@earendil-works/pi-ai/models'; import { fauxProvider, fauxAssistantMessage, fauxToolCall } from '@earendil-works/pi-ai/providers/faux'; -import { Harness, createRegistry, defineDoc, defineExtension, defineTask, GenerationTask, hook, InboxDoc } from '@earendil-works/pi-durable'; +import { Harness, createRegistry, defineDoc, defineExtension, defineTask, GenerationTask, hook, InboxDoc, defineTool } from '@earendil-works/pi-durable'; import { openStorage } from './sqlite'; import { serve } from './input'; +import { environment } from './environment'; +import { obj } from '@earendil-works/pi-schema'; const assert = (value: any, message: string) => { if (!value) throw new Error(message); }; const equal = (a: any, b: any) => assert(JSON.stringify(a) === JSON.stringify(b), `Expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}`); const until = async (condition: () => any) => { @@ -47,11 +49,24 @@ models.setProvider(faux.provider); const registry = createRegistry(); registry.install(Notes); registry.install(defineExtension({ name: 'hold-test', tasks: [Park] })); +const Shutdown = defineExtension({name: 'shutdown-test', tools: [defineTool({name: 'long_tool', description: 'Run a long world process', parameters: obj({}), replay: 'safe', execute: async (_args, _api, ctx) => { + const env = environment('/'); + const resume = (await env.readTextFile('/resume')).ok; + const work = env.exec(['/bin/sleep.wasm', resume ? '0' : '600'], {}, ctx); + if (!resume) { + await until(async () => (await env.readTextFile('/proc/processes')).value?.includes('600')); + await env.writeFile('/sleep-ready', 'yes'); + } + const result = await work; + assert(result.ok, `Sleep failed: ${JSON.stringify(result)}`); + return {output: [{type: 'text', text: 'sleep completed'}]}; +}})]}); +registry.install(Shutdown); const storage = await openStorage(globalThis.storagePath); const harness = await Harness.open(storage, { models, registry, settings: { followUpMode: 'all' } }, context); const scenario = globalThis.foundationCase; const root = await harness.root(context, { agent: { model: { provider: 'faux', modelId: 'faux-1' }, - extensions: scenario === 'placement' ? [Notes] : [] } }); + extensions: scenario === 'placement' ? [Notes] : scenario === 'shutdown' ? [Shutdown] : [] } }); try { if (scenario === 'placement' || scenario === 'followups') { faux.setResponses([async () => { @@ -113,6 +128,20 @@ try { await until(() => second); await harness.waitForIdle(context); equal((await harness.getTask(task, context))?.state.status, 'terminal'); + } else if (scenario === 'shutdown') { + faux.setResponses([fauxAssistantMessage([fauxToolCall('long_tool', {})], {stopReason: 'toolUse'}), fauxAssistantMessage('resumed answer')]); + const resumed = (await environment('/').readTextFile('/resume')).ok; + if (!resumed) await root.submit({type: 'input', content: 'sleep now', requestId: 'sleep-request'}, context); + harness.resume(); + if (resumed) { + faux.setResponses([fauxAssistantMessage('resumed answer')]); + await harness.waitForIdle(context); + const transcript = JSON.stringify(await root.context(context)); + assert(transcript.includes('resumed answer'), 'Shutdown did not resume'); + assert(transcript.includes('sleep completed'), 'Shutdown did not replay the interrupted tool'); + const submissions = await storage.scanSubmissions({conversationId: root.id}, 100, undefined, context); + equal(submissions.items.length, 1); + } else await serve(harness, context); } else if (scenario === 'headless') { faux.setResponses([fauxAssistantMessage('channel answer')]); await serve(harness, context); diff --git a/host/js/harness-api-test-bundle.js b/host/js/harness-api-test-bundle.js index ce41898..fa39aa3 100644 --- a/host/js/harness-api-test-bundle.js +++ b/host/js/harness-api-test-bundle.js @@ -31971,6 +31971,11 @@ async function serve(harness2, context, afterSubmit, api2) { }; while (true) { const input = await Deno.core.ops.op_input(); + if (input?.type === "shutdown") { + await harness2.close(context); + await Promise.all(pending); + return; + } if (input === null || input.type === "stop") { await harness2.waitForIdle(context); await Promise.all(pending); diff --git a/host/js/input.ts b/host/js/input.ts index 2e07348..82c09c7 100644 --- a/host/js/input.ts +++ b/host/js/input.ts @@ -1,4 +1,4 @@ -// Channel submissions target any conversation. EOF and Stop close the harness cleanly. +// EOF and Stop drain submissions. Shutdown cancels invocations and preserves durable tasks for restart. export async function serve(harness: any, context: any, afterSubmit?: () => Promise, api?: any) { const pending = new Set>(); const track = (work: Promise) => { @@ -7,6 +7,12 @@ export async function serve(harness: any, context: any, afterSubmit?: () => Prom }; while (true) { const input = await Deno.core.ops.op_input(); + if (input?.type === 'shutdown') { + // Close cancels invocations without terminal outcomes, so durable tasks resume after restart. + await harness.close(context); + await Promise.all(pending); + return; + } if (input === null || input.type === 'stop') { await harness.waitForIdle(context); await Promise.all(pending); diff --git a/host/js/messages-test-bundle.js b/host/js/messages-test-bundle.js index 229ba59..8bf541c 100644 --- a/host/js/messages-test-bundle.js +++ b/host/js/messages-test-bundle.js @@ -31706,6 +31706,13 @@ var harness = await Harness.open(storage, { models, registry, settings: { get ex var root = await harness.root(BACKGROUND_CONTEXT, { agent: { model: { provider: "faux", modelId: "faux-1" }, cwd: "/src/sausage" } }); var gate = Promise.withResolvers(); var holding = /* @__PURE__ */ new Set(); +var workerGate = Promise.withResolvers(); +var workerStarted = false; +var worker = defineExtension({ name: "worker-hold", tools: [defineTool({ name: "worker_hold", description: "Hold a background worker", parameters: obj({}), replay: "safe", execute: async (_args, _api, ctx) => { + workerStarted = true; + await awaitWithContext(workerGate.promise, ctx); + return { output: [{ type: "text", text: "worker released" }] }; +} })] }); var held2 = defineExtension({ name: "hold", tools: [defineTool({ name: "hold", description: "Wait for the test gate", parameters: obj({}), replay: "safe", execute: async (_args, api, ctx) => { holding.add(api.conversationId); await awaitWithContext(gate.promise, ctx); @@ -31748,11 +31755,12 @@ var route = (transcript) => { const input = JSON.parse(text.slice(5)); return fauxAssistantMessage([fauxToolCall(input.name, input.args)], { stopReason: "toolUse" }); } + if (last.role === "user" && text === "HOLD WORKER") return fauxAssistantMessage([fauxToolCall("worker_hold", {})], { stopReason: "toolUse" }); if (last.role === "user" && text === "HOLD") return fauxAssistantMessage([fauxToolCall("hold", {})], { stopReason: "toolUse" }); return fauxAssistantMessage(last.role === "user" && /^(Reply|Message|Note) from/.test(text) ? "Received" : `Answer: ${text}`); }; faux.setResponses([route]); -var loader = await loadExtensions(harness, registry, storage, models, BACKGROUND_CONTEXT, [held2]); +var loader = await loadExtensions(harness, registry, storage, models, BACKGROUND_CONTEXT, [held2, worker]); assert(!loader.state().error, `Extension load: ${loader.state().error}`); var snapshot = await fs.read("/src/sausage/.git/sausage-last-good"); var base = `file:///run/sausage/extensions/${snapshot}/extensions`; @@ -31885,13 +31893,23 @@ try { assert((await entries2(branch)).some((e) => e.kind === "lineage.note" && e.data.source === "main" && e.data.text.includes("Catch-up commit") && e.data.text.includes(`#${branch.id}`)), "Attributed commit catch-up"); assert((await entries2(branch)).some((e) => e.kind === "lineage.note" && e.data.source === "main" && e.data.text.includes("Use memory")), "Tally catch-up must include the new branch"); await harness.waitForIdle(BACKGROUND_CONTEXT); + await tool(branch, "subagent", { action: "start", agent: "coordinator", task: "HOLD WORKER", background: true }); + await until2(() => workerStarted); + await branch.waitForIdle(BACKGROUND_CONTEXT); await tool(primary, "notify", { id: branch.id, wake: false }); + await new Promise((resolve2) => setTimeout(resolve2, 50)); + assert(!(await entries2(primary)).some((e) => e.kind === "lineage.note" && e.data.source === "finished"), "Idle coordinator with a live worker was called finished"); + workerGate.resolve(); await until2(async () => (await entries2(primary)).some((e) => e.kind === "lineage.note" && e.data.source === "finished")); await harness.waitForIdle(BACKGROUND_CONTEXT); + const finished = (await entries2(primary)).filter((e) => e.kind === "lineage.note" && e.data.source === "finished"); + assert(finished.length === 1, "Worker completion must notify once"); + assert(finished[0].data.text.includes("worker released"), "Notice must contain the coordinator answer after the worker report"); assert((await harness.snapshot(Roots, root.id, BACKGROUND_CONTEXT)).roots.includes(root.id), "Listed root"); } } finally { gate.resolve(); + workerGate.resolve(); await loader.close(); await harness.close(BACKGROUND_CONTEXT); } diff --git a/host/js/messages-test.ts b/host/js/messages-test.ts index c8995a1..e886d21 100644 --- a/host/js/messages-test.ts +++ b/host/js/messages-test.ts @@ -16,6 +16,9 @@ const registry = createRegistry(), storage = await openStorage(globalThis.storag const harness = await Harness.open(storage, {models, registry, settings: {get extensions() {return registry.snapshot().installed()}}}, context); const root = await harness.root(context, {agent: {model: {provider: 'faux', modelId: 'faux-1'}, cwd: '/src/sausage'}}); const gate = Promise.withResolvers(), holding = new Set(); +const workerGate = Promise.withResolvers(); +let workerStarted = false; +const worker = defineExtension({name: 'worker-hold', tools: [defineTool({name: 'worker_hold', description: 'Hold a background worker', parameters: obj({}), replay: 'safe', execute: async (_args, _api, ctx) => {workerStarted = true; await awaitWithContext(workerGate.promise, ctx); return {output: [{type: 'text', text: 'worker released'}]}}})]}); const held = defineExtension({name: 'hold', tools: [defineTool({name: 'hold', description: 'Wait for the test gate', parameters: obj({}), replay: 'safe', execute: async (_args, api, ctx) => {holding.add(api.conversationId); await awaitWithContext(gate.promise, ctx); return {output: [{type: 'text', text: 'released'}]}}})]}); if (globalThis.messagesMode === 'kill') { const install = registry.install.bind(registry); @@ -41,11 +44,12 @@ const route = (transcript: any): any => { const last = transcript.messages.findLast((m: any) => m.role !== 'system'); const text = typeof last.content === 'string' ? last.content : last.content.filter((p: any) => p.type === 'text').map((p: any) => p.text).join(''); if (last.role === 'user' && text.startsWith('CALL ')) {const input = JSON.parse(text.slice(5)); return fauxAssistantMessage([fauxToolCall(input.name, input.args)], {stopReason: 'toolUse'})} + if (last.role === 'user' && text === 'HOLD WORKER') return fauxAssistantMessage([fauxToolCall('worker_hold', {})], {stopReason: 'toolUse'}); if (last.role === 'user' && text === 'HOLD') return fauxAssistantMessage([fauxToolCall('hold', {})], {stopReason: 'toolUse'}); return fauxAssistantMessage(last.role === 'user' && /^(Reply|Message|Note) from/.test(text) ? 'Received' : `Answer: ${text}`); }; faux.setResponses([route]); -const loader = await loadExtensions(harness, registry, storage, models, context, [held]); +const loader = await loadExtensions(harness, registry, storage, models, context, [held, worker]); assert(!loader.state().error, `Extension load: ${loader.state().error}`); const snapshot = await fs.read('/src/sausage/.git/sausage-last-good'); const base = `file:///run/sausage/extensions/${snapshot}/extensions`; @@ -162,9 +166,19 @@ try { assert((await entries(branch)).some((e: any) => e.kind === 'lineage.note' && e.data.source === 'main' && e.data.text.includes('Catch-up commit') && e.data.text.includes(`#${branch.id}`)), 'Attributed commit catch-up'); assert((await entries(branch)).some((e: any) => e.kind === 'lineage.note' && e.data.source === 'main' && e.data.text.includes('Use memory')), 'Tally catch-up must include the new branch'); await harness.waitForIdle(context); + await tool(branch, 'subagent', {action: 'start', agent: 'coordinator', task: 'HOLD WORKER', background: true}); + await until(() => workerStarted); + await branch.waitForIdle(context); await tool(primary, 'notify', {id: branch.id, wake: false}); + // Allow the notify invocation to reach its wait. The active worker must prevent delivery. + await new Promise(resolve => setTimeout(resolve, 50)); + assert(!(await entries(primary)).some((e: any) => e.kind === 'lineage.note' && e.data.source === 'finished'), 'Idle coordinator with a live worker was called finished'); + workerGate.resolve(); await until(async () => (await entries(primary)).some((e: any) => e.kind === 'lineage.note' && e.data.source === 'finished')); await harness.waitForIdle(context); + const finished = (await entries(primary)).filter((e: any) => e.kind === 'lineage.note' && e.data.source === 'finished'); + assert(finished.length === 1, 'Worker completion must notify once'); + assert(finished[0].data.text.includes('worker released'), 'Notice must contain the coordinator answer after the worker report'); assert((await harness.snapshot(Roots, root.id, context)).roots.includes(root.id), 'Listed root'); } -} finally {gate.resolve(); await loader.close(); await harness.close(context)} +} finally {gate.resolve(); workerGate.resolve(); await loader.close(); await harness.close(context)} diff --git a/host/js/p2-test-bundle.js b/host/js/p2-test-bundle.js index 98d1a07..4fa5f91 100644 --- a/host/js/p2-test-bundle.js +++ b/host/js/p2-test-bundle.js @@ -21353,13 +21353,23 @@ function environment(cwd = "/", conversation, invocation) { if (!appended.ok) throw new Error(appended.error.message); } }; + let rid; + const abort = () => { + if (rid !== void 0) { + Deno.core.close(rid); + rid = void 0; + } + }; try { - const rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, void 0, void 0, conversations, tag); + context.abortSignal?.throwIfAborted(); + rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, void 0, void 0, conversations, tag); + context.abortSignal?.addEventListener("abort", abort, { once: true }); const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() }; let result; while (true) { const chunk = await ops.op_exec_next(rid); if (!chunk.stream) { + rid = void 0; result = chunk; break; } @@ -21373,6 +21383,9 @@ function environment(cwd = "/", conversation, invocation) { return ok({ exitCode: result.exitCode, ...spillPath ? { spillPath } : {} }); } catch (error) { return { ok: false, error: Object.assign(new Error(String(error)), { code: "unknown", ...spillPath ? { spillPath } : {} }) }; + } finally { + context.abortSignal?.removeEventListener("abort", abort); + abort(); } }, cleanup: async () => { diff --git a/host/js/test-bundle.js b/host/js/test-bundle.js index 36ad816..fe9952c 100644 --- a/host/js/test-bundle.js +++ b/host/js/test-bundle.js @@ -21353,13 +21353,23 @@ function environment(cwd = "/", conversation, invocation) { if (!appended.ok) throw new Error(appended.error.message); } }; + let rid; + const abort = () => { + if (rid !== void 0) { + Deno.core.close(rid); + rid = void 0; + } + }; try { - const rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, void 0, void 0, conversations, tag); + context.abortSignal?.throwIfAborted(); + rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, void 0, void 0, conversations, tag); + context.abortSignal?.addEventListener("abort", abort, { once: true }); const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() }; let result2; while (true) { const chunk = await ops.op_exec_next(rid); if (!chunk.stream) { + rid = void 0; result2 = chunk; break; } @@ -21373,6 +21383,9 @@ function environment(cwd = "/", conversation, invocation) { return ok({ exitCode: result2.exitCode, ...spillPath ? { spillPath } : {} }); } catch (error) { return { ok: false, error: Object.assign(new Error(String(error)), { code: "unknown", ...spillPath ? { spillPath } : {} }) }; + } finally { + context.abortSignal?.removeEventListener("abort", abort); + abort(); } }, cleanup: async () => { diff --git a/host/js/undo-harness-test-bundle.js b/host/js/undo-harness-test-bundle.js index 53b3e40..cc1d300 100644 --- a/host/js/undo-harness-test-bundle.js +++ b/host/js/undo-harness-test-bundle.js @@ -21353,13 +21353,23 @@ function environment(cwd = "/", conversation, invocation) { if (!appended.ok) throw new Error(appended.error.message); } }; + let rid; + const abort = () => { + if (rid !== void 0) { + Deno.core.close(rid); + rid = void 0; + } + }; try { - const rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, void 0, void 0, conversations, tag); + context.abortSignal?.throwIfAborted(); + rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, void 0, void 0, conversations, tag); + context.abortSignal?.addEventListener("abort", abort, { once: true }); const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() }; let result2; while (true) { const chunk = await ops.op_exec_next(rid); if (!chunk.stream) { + rid = void 0; result2 = chunk; break; } @@ -21373,6 +21383,9 @@ function environment(cwd = "/", conversation, invocation) { return ok({ exitCode: result2.exitCode, ...spillPath ? { spillPath } : {} }); } catch (error) { return { ok: false, error: Object.assign(new Error(String(error)), { code: "unknown", ...spillPath ? { spillPath } : {} }) }; + } finally { + context.abortSignal?.removeEventListener("abort", abort); + abort(); } }, cleanup: async () => { diff --git a/host/src/harness/input.rs b/host/src/harness/input.rs index 1160542..92b6709 100644 --- a/host/src/harness/input.rs +++ b/host/src/harness/input.rs @@ -24,6 +24,7 @@ pub enum Input { reply: mpsc::UnboundedSender, }, Stop, + Shutdown, } pub type Sender = mpsc::UnboundedSender; @@ -78,7 +79,8 @@ pub(super) fn console(sender: Sender) { }); } -pub(super) fn stop_signal( +/// Register SIGTERM and SIGINT before the harness starts. +pub fn stop_signal( sender: Sender, ) -> std::io::Result + Send> { // Register before V8 starts, so a stop cannot arrive before the listener. @@ -89,6 +91,6 @@ pub(super) fn stop_signal( _ = interrupt.recv() => {} _ = terminate.recv() => {} } - let _ = sender.send(Input::Stop); + let _ = sender.send(Input::Shutdown); }) } diff --git a/host/tests/harness_shutdown.rs b/host/tests/harness_shutdown.rs new file mode 100644 index 0000000..ab7b662 --- /dev/null +++ b/host/tests/harness_shutdown.rs @@ -0,0 +1,135 @@ +//! SIGTERM cancels a world tool and preserves its durable invocation for restart. +use sausage::{ + cred::Cred, + harness::{input, run_module_with_input}, + world::World, +}; +use std::{ + path::Path, + process::{Command, Stdio}, + sync::Arc, + time::{Duration, Instant}, +}; + +#[tokio::test(flavor = "current_thread")] +async fn shutdown_child() -> anyhow::Result<()> { + let Ok(path) = std::env::var("SHUTDOWN_WORLD") else { + return Ok(()); + }; + let world = World::open(Path::new(&path))?; + let processes = world.processes.clone(); + let cred = Arc::new(Cred::open(&world.host_dir)?); + let exec = sausage::process::exec(World { + root: world.root.clone(), + host_dir: world.host_dir.clone(), + processes: processes.clone(), + })?; + let (sender, receiver) = input::channel(); + let signal = tokio::spawn(input::stop_signal(sender)?); + run_module_with_input( + world, + exec, + cred, + receiver, + concat!( + "globalThis.foundationCase = 'shutdown';\n", + include_str!("../js/foundation-test-bundle.js") + ), + ) + .await?; + signal.abort(); + tokio::time::timeout(Duration::from_secs(1), async { + while !processes.lock().unwrap().is_empty() { + tokio::task::yield_now().await; + } + }) + .await?; + Ok(()) +} + +#[test] +fn sigterm_cancels_tool_and_resumes() -> anyhow::Result<()> { + let source = Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); + let target = Path::new(env!("CARGO_TARGET_TMPDIR")).parent().unwrap(); + assert!( + Command::new("cargo") + .args([ + "build", + "--release", + "--locked", + "-p", + "sausage-coreutils", + "--bin", + "sleep", + "--manifest-path", + ]) + .arg(source.join("userland/Cargo.toml")) + .args(["--target", "wasm32-wasip2", "--target-dir"]) + .arg(target) + .args(["-j", "2"]) + .status()? + .success() + ); + let dir = tempfile::tempdir()?; + World::open(dir.path())?; + std::fs::create_dir_all(dir.path().join("world/bin"))?; + std::fs::copy( + target.join("wasm32-wasip2/release/sleep.wasm"), + dir.path().join("world/bin/sleep.wasm"), + )?; + let spawn = || { + Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "shutdown_child", "--nocapture"]) + .env("SHUTDOWN_WORLD", dir.path()) + .stdin(Stdio::null()) + .spawn() + }; + let mut child = spawn()?; + let deadline = Instant::now() + Duration::from_secs(60); + while !dir.path().join("world/sleep-ready").exists() { + if let Some(status) = child.try_wait()? { + anyhow::bail!("Tool child exited early: {status}"); + } + if Instant::now() > deadline { + child.kill()?; + child.wait()?; + anyhow::bail!("World sleep did not start"); + } + std::thread::yield_now(); + } + assert!( + Command::new("kill") + .args(["-TERM", &child.id().to_string()]) + .status()? + .success() + ); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if let Some(status) = child.try_wait()? { + assert!(status.success()); + break; + } + if Instant::now() > deadline { + child.kill()?; + child.wait()?; + anyhow::bail!("SIGTERM exceeded five seconds"); + } + std::thread::yield_now(); + } + std::fs::write(dir.path().join("world/resume"), "yes")?; + let mut child = spawn()?; + let deadline = Instant::now() + Duration::from_secs(20); + loop { + if let Some(status) = child.try_wait()? { + assert!(status.success()); + break; + } + if Instant::now() > deadline { + child.kill()?; + child.wait()?; + anyhow::bail!("Restart did not complete"); + } + std::thread::yield_now(); + } + Ok(()) +}