From 229fe53440f2f1b96918a5bb7e3a6b3610a52041 Mon Sep 17 00:00:00 2001 From: Chetan Raghuvanshi <145042127+chhhee10@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:32:57 +0000 Subject: [PATCH 001/115] feat(otel): add reversible opt-in agent setup and wizard step --- __tests__/e2e/cli/otel.e2e.test.ts | 54 ++++ __tests__/hooks/configure-wizard.test.ts | 5 +- __tests__/hooks/daemon-download.test.ts | 5 +- __tests__/hooks/fp-home.test.ts | 1 + __tests__/hooks/otel.test.ts | 277 ++++++++++++++++++ bin/failproofai.mjs | 46 ++- bun.lock | 6 + package.json | 2 + src/hooks/cloud-introspect.ts | 3 + src/hooks/collector-config.ts | 11 +- src/hooks/configure-wizard.ts | 39 +++ src/hooks/fp-config.ts | 28 ++ src/hooks/fp-home.ts | 11 + src/hooks/integrations.ts | 2 +- src/hooks/otel-cli.ts | 340 +++++++++++++++++++++++ src/hooks/otel-writers.ts | 187 +++++++++++++ src/hooks/safe-config-write.ts | 6 +- 17 files changed, 1014 insertions(+), 9 deletions(-) create mode 100644 __tests__/e2e/cli/otel.e2e.test.ts create mode 100644 __tests__/hooks/otel.test.ts create mode 100644 src/hooks/otel-cli.ts create mode 100644 src/hooks/otel-writers.ts diff --git a/__tests__/e2e/cli/otel.e2e.test.ts b/__tests__/e2e/cli/otel.e2e.test.ts new file mode 100644 index 000000000..8cb4ca7f5 --- /dev/null +++ b/__tests__/e2e/cli/otel.e2e.test.ts @@ -0,0 +1,54 @@ +// @vitest-environment node +import { afterAll, describe, expect, it } from "vitest"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +const root = mkdtempSync(join(tmpdir(), "fpai-otel-bundle-")); +const home = join(root, "home"); +const binary = join(root, "cli.mjs"); +const repo = resolve(__dirname, "../../.."); +// Like the installed tarball, external runtime deps sit next to the bundle. +symlinkSync(join(repo, "node_modules"), join(root, "node_modules"), "dir"); +mkdirSync(join(home, ".failproofai"), { recursive: true }); +mkdirSync(join(home, ".codex"), { recursive: true }); +writeFileSync(join(home, ".failproofai/credentials.json"), JSON.stringify({ + ingest: { url: "http://127.0.0.1:1/v1/events", key: "capture-test" }, +})); +const original = '# original\nmodel = "test"\n[projects."/work"]\ntrust_level = "trusted"\n'; +writeFileSync(join(home, ".codex/config.toml"), original); +const built = spawnSync("bun", ["build", "--target=node", "--format=esm", `--outfile=${binary}`, + "bin/failproofai.mjs", "--external", "posthog-node", "--external", "sql.js"], { + cwd: repo, encoding: "utf8", timeout: 30000, +}); +afterAll(() => rmSync(root, { recursive: true, force: true })); + +function cli(...args: string[]) { + return spawnSync("node", [binary, "otel", ...args], { + env: { ...process.env, HOME: home, USERPROFILE: home, FAILPROOFAI_HOME: join(home, ".failproofai"), + FAILPROOFAI_TELEMETRY_DISABLED: "1", FAILPROOFAI_INGEST_KEY: "", FAILPROOFAI_INGEST_URL: "" }, + encoding: "utf8", timeout: 15000, + }); +} + +describe("OTEL standalone bundled CLI", () => { + it("bundles JSONC and TOML without unresolved UMD relative imports", () => { + expect(built.status, built.stderr).toBe(0); + expect(existsSync(binary)).toBe(true); + expect(cli("enable", "--help").status).toBe(0); + }); + + it("enables and restores Codex from a Node-run standalone bundle against an older/unreachable server", () => { + const enabled = cli("enable", "codex", "--local", "--yes"); + expect(enabled.status, enabled.stderr + enabled.stdout).toBe(0); + expect(readFileSync(join(home, ".codex/config.toml"), "utf8")).toContain("/v1/logs"); + const status = cli("status"); + expect(status.status, status.stderr).toBe(0); + expect(status.stdout).toContain("unknown"); + expect(status.stdout).toContain("local relay"); + const disabled = cli("disable", "all"); + expect(disabled.status, disabled.stderr + disabled.stdout).toBe(0); + expect(readFileSync(join(home, ".codex/config.toml"), "utf8")).toBe(original); + }); +}); diff --git a/__tests__/hooks/configure-wizard.test.ts b/__tests__/hooks/configure-wizard.test.ts index 885eeaadf..6af2e1b9e 100644 --- a/__tests__/hooks/configure-wizard.test.ts +++ b/__tests__/hooks/configure-wizard.test.ts @@ -175,7 +175,7 @@ const headlessIO = () => ({ * churn that tempts someone to "fix" a test by loosening it. Naming the steps * keeps a reorder to a one-line change here. * - * Current order — selectOne: connect, review. + * Current order — selectOne: connect, optional OTEL, review. * multiSelect: assistants. * `undefined` means "this step is not reached in this test". * @@ -190,6 +190,9 @@ function drive(answers: { }) { const one = vi.mocked(selectOne); if ("connect" in answers) one.mockResolvedValueOnce(answers.connect as never); + // OTEL is an intentional new optional step after a successful key choice. + // Keep existing setup scenarios opted out rather than consuming "apply". + if (answers.connect === "key") one.mockResolvedValueOnce(false as never); if ("review" in answers) one.mockResolvedValueOnce(answers.review as never); } diff --git a/__tests__/hooks/daemon-download.test.ts b/__tests__/hooks/daemon-download.test.ts index 33f0a8c2f..c64e140cd 100644 --- a/__tests__/hooks/daemon-download.test.ts +++ b/__tests__/hooks/daemon-download.test.ts @@ -406,7 +406,8 @@ describe("hooks/daemon-download", () => { }); it("ensureFailproofaidBinary prefers the package and never touches the network", async () => { - installPlatformPackage("linux-x64"); + // This entry point resolves the real host, unlike the explicit-key helpers. + installPlatformPackage(`${process.platform}-${process.arch}`); // Any fetch at all fails this test: a machine that already has the // binary from npm must not wait on github.com to install it. const server = await startServer(); @@ -430,7 +431,7 @@ describe("hooks/daemon-download", () => { it("works on an air-gapped machine, where the download channel is switched off", async () => { // FAILPROOFAI_NO_DOWNLOAD gates fetching, not copying — on exactly these // machines npm is the only channel that can supply a daemon at all. - installPlatformPackage("linux-x64"); + installPlatformPackage(`${process.platform}-${process.arch}`); process.env.FAILPROOFAI_NO_DOWNLOAD = "1"; process.env.FAILPROOFAI_DAEMON_BASE_URL = "http://127.0.0.1:1/never"; diff --git a/__tests__/hooks/fp-home.test.ts b/__tests__/hooks/fp-home.test.ts index e20a2fde1..617749b93 100644 --- a/__tests__/hooks/fp-home.test.ts +++ b/__tests__/hooks/fp-home.test.ts @@ -228,6 +228,7 @@ describe("HOME_CLASSES", () => { fpcliDir: "fpcliDir", packsInstalledFile: "packsDir", packArtifactsDir: "packsDir", + otelAgentStateFile: "otelAgentStateDir", }; /** Every exported function that returns a path inside the home. */ diff --git a/__tests__/hooks/otel.test.ts b/__tests__/hooks/otel.test.ts new file mode 100644 index 000000000..b13c2d468 --- /dev/null +++ b/__tests__/hooks/otel.test.ts @@ -0,0 +1,277 @@ +// @vitest-environment node +import { beforeEach, afterEach, describe, it, expect, vi } from "vitest"; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, existsSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { spawnSync } from "node:child_process"; +import { runOtelCommand, chooseWizardOtel, OTEL_ORG_OFF, OTEL_WIZARD_QUESTION } from "../../src/hooks/otel-cli"; +import { agentConfigPath, editAgentConfig, parseAgentConfig, vscodeSettingsPath, agentOtelWrites } from "../../src/hooks/otel-writers"; +import { configFile, credentialsFile, otelAgentStateFile, otelAgentStateDir, otlpSpoolDir, resettablePaths } from "../../src/hooks/fp-home"; +import { readConfig, updateConfig } from "../../src/hooks/fp-config"; +import { agentSessionsEnabled } from "../../src/hooks/collector-config"; +import { introspectKey } from "../../src/hooks/cloud-introspect"; + +let home: string; +const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ permissions: ["events:add"] }))); +const originalEnv = { ...process.env }; +const options = () => ({ home, fetchImpl }); +function seed(path: string, text: string): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, text); +} +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "fpai-otel-test-")); + process.env.FAILPROOFAI_HOME = join(home, ".failproofai"); + delete process.env.FAILPROOFAI_INGEST_KEY; + delete process.env.FAILPROOFAI_INGEST_URL; + delete process.env.XDG_CONFIG_HOME; + seed(credentialsFile(), JSON.stringify({ ingest: { url: "https://cloud.example/v1/events", key: "capture-test-secret" } })); + seed(configFile(), JSON.stringify({ mode: { kind: "cloud" }, collector: { sessions: false }, future: { keep: true } })); + fetchImpl.mockReset().mockImplementation(async () => new Response(JSON.stringify({ permissions: ["events:add"] }))); +}); +afterEach(() => { + for (const key of ["FAILPROOFAI_HOME", "FAILPROOFAI_INGEST_KEY", "FAILPROOFAI_INGEST_URL", "XDG_CONFIG_HOME"]) { + if (originalEnv[key] === undefined) delete process.env[key]; + else process.env[key] = originalEnv[key]; + } + rmSync(home, { recursive: true, force: true }); +}); + +describe("managed OTEL settings", () => { + it("migrations never discard restore credentials or undelivered OTLP batches", () => { + expect(resettablePaths()).not.toContain(otelAgentStateDir()); + expect(resettablePaths()).not.toContain(otlpSpoolDir()); + }); + it("round-trips Claude byte-equal, backs up once, preserves unknown keys and keeps keys out of config", async () => { + const path = agentConfigPath("claude", home); + const before = '{\n "env": {"EXISTING": "yes", "OTEL_EXPORTER_OTLP_HEADERS": "Authorization=old-secret"},\n "hooks": {"keep": true}\n}\n'; + seed(path, before); + expect((await runOtelCommand(["enable", "claude"], options())).exitCode).toBe(0); + const enabled = JSON.parse(readFileSync(path, "utf8")); + expect(enabled.env.CLAUDE_CODE_ENABLE_TELEMETRY).toBe("1"); + expect(enabled.env.OTEL_EXPORTER_OTLP_ENDPOINT).toBe("https://cloud.example"); + expect(enabled.env.OTEL_LOG_USER_PROMPTS).toBe("1"); + expect(enabled.env.EXISTING).toBe("yes"); + expect(enabled.hooks).toEqual({ keep: true }); + expect(readFileSync(`${path}.failproofai-bak`, "utf8")).toBe(before); + const raw = readFileSync(configFile(), "utf8"); + expect(raw).not.toContain("old-secret"); + expect(raw).not.toContain("capture-test-secret"); + expect(statSync(otelAgentStateFile("claude")).mode & 0o777).toBe(0o600); + expect((await runOtelCommand(["enable", "claude", "--no-content", "--local"], options())).exitCode).toBe(0); + expect(readFileSync(`${path}.failproofai-bak`, "utf8")).toBe(before); + expect((await runOtelCommand(["disable", "claude"], options())).exitCode).toBe(0); + expect(readFileSync(path, "utf8")).toBe(before); + expect(JSON.parse(readFileSync(configFile(), "utf8")).future).toEqual({ keep: true }); + expect(readConfig().collector.otlp?.enabled).toBe(false); + }); + + it("restores only owned keys after unrelated Claude changes", async () => { + const path = agentConfigPath("claude", home); + seed(path, '{"env":{"KEEP":"x","OTEL_LOG_USER_PROMPTS":"old"},"theme":"old"}'); + await runOtelCommand(["enable", "claude"], options()); + const changed = JSON.parse(readFileSync(path, "utf8")); + changed.theme = "new"; + changed.env.NEW_KEY = "keep"; + writeFileSync(path, JSON.stringify(changed)); + await runOtelCommand(["disable", "claude"], options()); + expect(JSON.parse(readFileSync(path, "utf8"))).toEqual({ + env: { KEEP: "x", OTEL_LOG_USER_PROMPTS: "old", NEW_KEY: "keep" }, theme: "new", + }); + }); + + it("re-enabling after unrelated edits preserves them on disable and status still recognizes owned keys", async () => { + const path = agentConfigPath("claude", home); + seed(path, '{"theme":"old"}\n'); + await runOtelCommand(["enable", "claude"], options()); + const changed = JSON.parse(readFileSync(path, "utf8")); + changed.theme = "new"; + writeFileSync(path, JSON.stringify(changed)); + expect((await runOtelCommand(["status"], options())).lines.join("\n")).toContain("Claude Code: on by failproofai"); + await runOtelCommand(["enable", "claude", "--no-content"], options()); + await runOtelCommand(["disable", "claude"], options()); + expect(JSON.parse(readFileSync(path, "utf8")).theme).toBe("new"); + }); + + it("Codex changes only OTEL table regions with comments, nested exporter tables and multiline strings", async () => { + const path = agentConfigPath("codex", home); + const prefix = '# user config\nmodel = "test"\nprompt = """\n[not_a_table]\n"""\n[projects."/work"] # trust\ntrust_level = "trusted"\n\n'; + const otel = '# OTEL\n[otel] # existing\nlog_user_prompt = false\nenvironment = "keep"\n[otel.exporter.otlp-http]\nendpoint = "http://old/v1/logs"\nprotocol = "json"\n'; + const suffix = '# introduces MCP table\n\n[mcp_servers.keep]\n# comment kept\ncommand = "keep"\n'; + const before = prefix + otel + suffix; + seed(path, before); + const result = await runOtelCommand(["enable", "codex"], options()); + expect(result.exitCode, result.lines.join("\n")).toBe(0); + const next = readFileSync(path, "utf8"); + expect(next.startsWith(prefix + "# OTEL\n")).toBe(true); + expect(next.endsWith(suffix)).toBe(true); + const parsed = parseAgentConfig(next, "codex"); + expect(parsed.otel).toMatchObject({ + environment: "keep", + exporter: { "otlp-http": { endpoint: "https://cloud.example/v1/logs", headers: { Authorization: "Bearer capture-test-secret" } } }, + trace_exporter: { "otlp-http": { endpoint: "https://cloud.example/v1/traces" } }, + }); + await runOtelCommand(["disable", "codex"], options()); + expect(readFileSync(path, "utf8")).toBe(before); + }); + + it("Codex supports quoted OTEL headers, CRLF, no final newline; refuses root assignments and malformed files", () => { + const writes = agentOtelWrites("codex", "http://127.0.0.1:4318", "", true, false); + const before = 'model="x"\r\n["otel"]\r\nlog_user_prompt=true\r\n[other]\r\nvalue=1'; + const next = editAgentConfig(before, "codex", writes); + expect(next.startsWith('model="x"\r\n')).toBe(true); + expect(next.endsWith("[other]\r\nvalue=1")).toBe(true); + expect(() => editAgentConfig('otel = {log_user_prompt=true}\n', "codex", writes)).toThrow("root dotted"); + expect(() => editAgentConfig("[broken", "codex", writes)).toThrow(); + }); + + it.each(["gemini", "copilot"] as const)("writes %s with automatic auth-free relay and restores JSONC byte-equal", async agent => { + const path = agentConfigPath(agent, home); + const before = '{\n// user comment\n"editor.fontSize": 13,\n"telemetry": {"unknown": 42},\n}\n'; + seed(path, before); + const result = await runOtelCommand(["enable", agent, "--no-content"], options()); + expect(result.exitCode).toBe(0); + expect(result.lines.join("\n")).toContain("automatically"); + const text = readFileSync(path, "utf8"); + expect(text).toContain("// user comment"); + expect(text).not.toContain("capture-test-secret"); + expect(text).toContain("127.0.0.1:4318"); + const parsed = parseAgentConfig(text, agent); + expect(agent === "gemini" ? (parsed.telemetry as Record).logPrompts : parsed["github.copilot.chat.otel.captureContent"]).toBe(false); + expect(readConfig().collector.otlp?.enabled).toBe(true); + await runOtelCommand(["disable", agent], options()); + expect(readFileSync(path, "utf8")).toBe(before); + }); + + it("skips VS Code when absent and resolves macOS, Linux and Windows user paths", async () => { + expect((await runOtelCommand(["enable", "copilot"], options())).lines.join("\n")).toContain("not installed"); + expect(existsSync(agentConfigPath("copilot", home))).toBe(false); + expect(vscodeSettingsPath(home, "darwin")).toContain("Library/Application Support/Code/User/settings.json"); + expect(vscodeSettingsPath(home, "linux")).toContain(".config/Code/User/settings.json"); + const before = process.env.APPDATA; + process.env.APPDATA = join(home, "Roaming"); + expect(vscodeSettingsPath(home, "win32")).toBe(join(home, "Roaming/Code/User/settings.json")); + if (before === undefined) delete process.env.APPDATA; else process.env.APPDATA = before; + }); + + it("never overwrites an invalid config or creates a backup for it", async () => { + const path = agentConfigPath("claude", home); + seed(path, "{ invalid json"); + expect((await runOtelCommand(["enable", "claude"], options())).exitCode).toBe(1); + expect(readFileSync(path, "utf8")).toBe("{ invalid json"); + expect(existsSync(`${path}.failproofai-bak`)).toBe(false); + }); +}); + +describe("opt-in, one path and status", () => { + it("one-path question defaults No; --yes sets only Claude override and disable restores absence", async () => { + updateConfig({ collector: { sessions: true } }); + const path = agentConfigPath("claude", home); + const question = vi.fn(async () => false); + await runOtelCommand(["enable", "claude"], { ...options(), confirm: question }); + expect(question).toHaveBeenCalledWith("failproofai already sends Claude Code sessions to Cloud. Send them with OTEL instead? Transcript upload for Claude Code stops."); + expect(existsSync(path)).toBe(false); + // Real non-TTY prompt has the conservative default, too. + await runOtelCommand(["enable", "claude"], options()); + expect(existsSync(path)).toBe(false); + await runOtelCommand(["enable", "claude", "--yes"], options()); + expect(agentSessionsEnabled("claude")).toBe(false); + expect(agentSessionsEnabled("codex")).toBe(true); + updateConfig({ telemetry: { enabled: false } }); + expect(agentSessionsEnabled("claude")).toBe(false); + await runOtelCommand(["disable", "claude"], options()); + expect(readConfig().collector.agents?.claude?.sessions).toBeUndefined(); + expect(agentSessionsEnabled("claude")).toBe(true); + expect(existsSync(path)).toBe(false); + }); + + it("Gemini has no transcript source and does not ask a misleading switch question", async () => { + updateConfig({ collector: { sessions: true } }); + const confirm = vi.fn(async () => false); + const result = await runOtelCommand(["enable", "gemini"], { ...options(), confirm }); + expect(result.exitCode).toBe(0); + expect(confirm).not.toHaveBeenCalled(); + expect(readConfig().collector.agents?.gemini).toBeUndefined(); + }); + + it("restores explicit sessions override and keeps relay on until last local agent disables", async () => { + updateConfig({ collector: { agents: { claude: { sessions: true } }, otlp: { enabled: false, port: 14318 } } }); + await runOtelCommand(["enable", "claude", "--yes", "--local"], options()); + await runOtelCommand(["enable", "gemini"], options()); + await runOtelCommand(["disable", "claude"], options()); + expect(readConfig().collector.agents?.claude?.sessions).toBe(true); + expect(readConfig().collector.otlp?.enabled).toBe(true); + await runOtelCommand(["disable", "gemini"], options()); + expect(readConfig().collector.otlp?.enabled).toBe(false); + }); + + it("status shows all agents, Cloud/local, relay, last upload and unknown for old introspect", async () => { + await runOtelCommand(["enable", "claude"], options()); + await runOtelCommand(["enable", "gemini"], options()); + const result = await runOtelCommand(["status"], options()); + const output = result.lines.join("\n"); + for (const agent of ["Claude Code", "Codex", "Gemini CLI", "Copilot"]) expect(output).toContain(agent); + expect(output).toContain("on by failproofai"); + expect(output).toContain("https://cloud.example"); + expect(output).toContain("http://127.0.0.1:4318"); + expect(output).toContain("org: unknown"); + expect(output).toContain("Relay:"); + expect(output).toContain("last upload: none"); + const identity = await introspectKey("https://cloud.example", "key", fetchImpl); + expect(identity.kind === "ok" && identity.identity.otelIngest).toBeUndefined(); + }); + + it("reports unmanaged OTEL and warns but writes when org ingest is off", async () => { + seed(agentConfigPath("codex", home), '[otel]\nexporter = { "otlp-http" = { endpoint="http://external/v1/logs", protocol="json" } }\n'); + expect((await runOtelCommand(["status"], options())).lines.join("\n")).toContain("Codex: on, not managed"); + fetchImpl.mockImplementation(async () => new Response(JSON.stringify({ permissions: ["events:add"], otel_ingest: false }))); + const result = await runOtelCommand(["enable", "claude"], options()); + expect(result.exitCode).toBe(0); + expect(result.lines).toContain(OTEL_ORG_OFF); + expect((await runOtelCommand(["status"], options())).lines.join("\n")).toContain("org: off"); + }); + + it("missing key errors before writes; env is shell-safe and has the correct protocol/header", async () => { + const result = await runOtelCommand(["env", "--service", "service's name"], options()); + expect(result.lines.join("\n")).toContain("http/protobuf"); + expect(result.lines.join("\n")).toContain("Authorization=Bearer%20capture-test-secret"); + expect(result.lines.join("\n")).toContain("'service'\\''s name'"); + expect((await runOtelCommand(["env", "--local"], options())).lines.join("\n")).not.toContain("capture-test-secret"); + rmSync(credentialsFile()); + const error = await runOtelCommand(["enable", "claude"], options()); + expect(error.exitCode).toBe(1); + expect(error.lines.join("\n")).toContain("failproofai config"); + expect(existsSync(agentConfigPath("claude", home))).toBe(false); + }); + + it("wizard question defaults No and uses the shared enable entry point", async () => { + expect(await chooseWizardOtel()).toBe(false); + const choose = vi.fn(async () => true); + expect(await chooseWizardOtel(choose)).toBe(true); + expect(choose).toHaveBeenCalledWith(OTEL_WIZARD_QUESTION); + const source = readFileSync(join(__dirname, "../../src/hooks/configure-wizard.ts"), "utf8"); + expect(source).toContain('runOtelCommand(["enable", agent]'); + expect(source).toContain("!unattended && !preAnswered"); + }); + + it("analytics properties contain only agent, local and content", async () => { + const onEvent = vi.fn(); + await runOtelCommand(["enable", "claude", "--local", "--no-content"], { ...options(), onEvent }); + await runOtelCommand(["disable", "claude"], { ...options(), onEvent }); + expect(onEvent.mock.calls).toEqual([ + ["otel_enabled", { agent: "claude", local: true, content: false }], + ["otel_disabled", { agent: "claude", local: true, content: false }], + ]); + }); + + it.each([{ args: [] }, { args: ["enable"] }, { args: ["disable"] }, { args: ["status"] }, { args: ["env"] }])("real CLI help $args follows harness section style", ({ args }) => { + const result = spawnSync("bun", ["bin/failproofai.mjs", "otel", ...args, "--help"], { + cwd: join(__dirname, "../.."), env: { ...process.env, HOME: home, FAILPROOFAI_TELEMETRY_DISABLED: "1" }, + encoding: "utf8", timeout: 15000, + }); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("otel"); + expect(result.stdout.toLowerCase()).toContain("usage"); + expect(result.stdout.toLowerCase()).toContain("notes"); + }); +}); diff --git a/bin/failproofai.mjs b/bin/failproofai.mjs index 9367cd3b9..751fefd99 100755 --- a/bin/failproofai.mjs +++ b/bin/failproofai.mjs @@ -373,7 +373,7 @@ async function runCli() { // at all, only the paragraph in the top-level dump that this rewrite moved. // `help` and `publish` are new. `policy` and `pack` are canonicalized to // `policies` above and never reach this list. - const SUBCOMMANDS = ["policies", "audit", "config", "uninstall", "backfill", "flush", "harness", "jev", "publish", "update", "migrate", "help"]; + const SUBCOMMANDS = ["policies", "audit", "config", "uninstall", "backfill", "flush", "harness", "otel", "jev", "publish", "update", "migrate", "help"]; // ── help ───────────────────────────────────────────────────────────────── // // The index and the reference manual used to be the same document: 152 lines, @@ -491,7 +491,7 @@ async function runCli() { // and a full row each is what made this screen read as a manual. { label: "less often", - lines: ["policies show, harness, flush, backfill, migrate, uninstall, config --pause"], + lines: ["policies show, harness, otel, flush, backfill, migrate, uninstall", "config --pause"], }, ], footer: [ @@ -808,6 +808,48 @@ async function runCli() { // could reach it to notice. The pack lane is entered from // `policies add|remove|show` below, which is the only door it has. + if (args[0] === "otel") { + const subArgs = args.slice(1); + if (!subArgs.length || subArgs.includes("--help") || subArgs.includes("-h")) { + const verb = ["status", "enable", "disable", "env"].includes(subArgs[0]) ? subArgs[0] : null; + const usages = { + status: "failproofai otel status", + enable: "failproofai otel enable [--local] [--no-content] [--yes]", + disable: "failproofai otel disable ", + env: "failproofai otel env [--service ] [--local]", + }; + await printHelp({ + command: verb ? `otel ${verb}` : "otel", + tagline: "opt-in OpenTelemetry from agents and apps to FailproofAI Cloud", + sections: [ + { label: "usage", entries: (verb ? [usages[verb]] : Object.values(usages)).map(usage => [usage]) }, + { label: "agents", lines: ["claude, codex, gemini, copilot; all configures every available agent."] }, + { label: "options", entries: [ + ["--local", "Use the loopback OTLP relay. Automatic for Gemini CLI and Copilot."], + ["--no-content", "Do not capture prompt text or tool details."], + ["--yes", "Accept switching this agent from transcript upload to OTEL."], + ["--service ", "Service name for env (default: my-agent)."], + ] }, + { label: "notes", lines: [ + "Off by default. Run `failproofai config` first to configure your Cloud key.", + "Text capture is on unless --no-content. Disable restores only managed settings.", + "An org admin must enable Settings → OpenTelemetry in Cloud.", + ] }, + ], + }); + process.exit(0); + } + lastSubcommand = "otel"; + const { runOtelCommand } = await import("../src/hooks/otel-cli"); + const result = await runOtelCommand(subArgs, { onEvent: track }); + // env is shell input and must not be wrapped in a styled report. + if (subArgs[0] === "env" && result.exitCode === 0) console.log(result.lines.join("\n")); + else await printReport("otel", result.lines, { ok: result.exitCode === 0 }); + lastSubcommand = null; + await exitAfterFlush(result.exitCode); + return; + } + if (args[0] === "harness") { const subArgs = args.slice(1); if (subArgs.length === 0 || subArgs.includes("--help") || subArgs.includes("-h")) { diff --git a/bun.lock b/bun.lock index af3ba23e6..1a88d0312 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,9 @@ "name": "failproofai", "dependencies": { "html-to-image": "^1.11.13", + "jsonc-parser": "^3.3.1", "posthog-node": "^5.50.0", + "smol-toml": "^1.9.1", "sql.js": "^1.14.1", "yaml": "^2.9.0", }, @@ -832,6 +834,8 @@ "json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="], + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="], "keyv": ["keyv@5.6.0", "", { "dependencies": { "@keyv/serialize": "^1.1.1" } }, "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw=="], @@ -1116,6 +1120,8 @@ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "smol-toml": ["smol-toml@1.9.1", "", {}, "sha512-j7WyPdf0CJoutMzMoPLF/bVXHtKZIT9Rb4eGeEbWw8X2t5oirjPgpL+OpaENZrILYaXqYwnVH+qgUwVbl+pUJQ=="], + "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], diff --git a/package.json b/package.json index 353052314..32d8fa621 100644 --- a/package.json +++ b/package.json @@ -107,7 +107,9 @@ }, "dependencies": { "html-to-image": "^1.11.13", + "jsonc-parser": "^3.3.1", "posthog-node": "^5.50.0", + "smol-toml": "^1.9.1", "sql.js": "^1.14.1", "yaml": "^2.9.0" }, diff --git a/src/hooks/cloud-introspect.ts b/src/hooks/cloud-introspect.ts index 84cfc996d..39a673824 100644 --- a/src/hooks/cloud-introspect.ts +++ b/src/hooks/cloud-introspect.ts @@ -44,6 +44,8 @@ export interface KeyIdentity { /** The EFFECTIVE permission set — what the server enforces. */ permissions: string[]; expiresAt?: string | null; + /** Missing on older Cloud versions; absence is unknown, never an error. */ + otelIngest?: boolean; } export type IntrospectResult = @@ -128,6 +130,7 @@ export async function introspectKey( orgName: typeof b.org_name === "string" ? b.org_name : undefined, permissions: b.permissions.filter((p): p is string => typeof p === "string"), expiresAt: typeof b.expires_at === "string" ? b.expires_at : null, + otelIngest: typeof b.otel_ingest === "boolean" ? b.otel_ingest : undefined, }, }; } diff --git a/src/hooks/collector-config.ts b/src/hooks/collector-config.ts index 5828ddf09..ee5091970 100644 --- a/src/hooks/collector-config.ts +++ b/src/hooks/collector-config.ts @@ -13,7 +13,7 @@ * the only thing that writes them. */ import { credentialsFile, failproofaiHome as layoutHome } from "./fp-home"; -import { readCredentials, writeCredentials, updateConfig } from "./fp-config"; +import { readConfig, readCredentials, writeCredentials, updateConfig } from "./fp-config"; /** * The hosted ingest endpoint — a COMPLETE endpoint, not a base to join onto. @@ -42,6 +42,8 @@ export interface IngestCredential { export interface CollectorSettings { /** Ship agent session transcripts. Separate opt-in from having a key. */ sessions: boolean; + agents?: Record; + otlp?: { enabled: boolean; port?: number }; /** Ship hook activity. */ hooks: boolean; hooksVerbosity?: "all" | "decisions" | "off"; @@ -60,6 +62,11 @@ export function failproofaiHome(): string { return layoutHome(); } +export function agentSessionsEnabled(agent: string): boolean { + const collector = readConfig().collector; + return collector.agents?.[agent]?.sessions ?? collector.sessions; +} + export function ingestPath(): string { return credentialsFile(); } @@ -122,6 +129,8 @@ export function writeCollectorSettings(settings: CollectorSettings): void { redact: settings.redact ?? "minimal", environment: settings.environment ?? "local", machineId: settings.machineId, + agents: settings.agents ?? readConfig().collector.agents, + otlp: settings.otlp ?? readConfig().collector.otlp, }, }); } diff --git a/src/hooks/configure-wizard.ts b/src/hooks/configure-wizard.ts index 2d1c4fd44..9269a1708 100644 --- a/src/hooks/configure-wizard.ts +++ b/src/hooks/configure-wizard.ts @@ -34,6 +34,7 @@ import { CORE_SOURCE } from "./pack-store"; import { selectOne, + multiSelect, promptText, intro, outro, @@ -1271,6 +1272,18 @@ export async function runConfigureWizard( } } } + // Optional OTEL choice is never implied by unattended setup or a pasted key. + const { chooseWizardOtel, runOtelCommand } = await import("./otel-cli"); + const otelWanted = !unattended && !preAnswered && connect !== null && await chooseWizardOtel( + async message => await selectOne({ + message, + choices: [ + { label: "No", value: false, hint: "keep current telemetry" }, + { label: "Yes", value: true, hint: "choose OTEL agents after connecting" }, + ], + stdin, stdout, + }) === true, + ); // 5 — Review & apply // // The last question, and the only one a headless run skips outright rather @@ -1557,6 +1570,32 @@ export async function runConfigureWizard( } } + if (otelWanted && connected) { + const selected = await multiSelect({ + message: "Which agents should send OpenTelemetry?", + choices: [ + { label: "Gemini CLI", value: "gemini", checked: false }, + { label: "Copilot (VS Code)", value: "copilot", checked: false }, + { label: "Claude Code", value: "claude", checked: false }, + { label: "Codex", value: "codex", checked: false }, + ], + stdin, stdout, + }); + if (Array.isArray(selected)) for (const agent of selected) { + const result = await runOtelCommand(["enable", agent], { + onEvent: emit, + confirm: async message => await selectOne({ + message, + choices: [ + { label: "No", value: false, hint: "keep transcript upload" }, + { label: "Yes", value: true, hint: "switch to OTEL" }, + ], + stdin, stdout, + }) === true, + }); + for (const line of result.lines) stdout.write(`${line}\n`); + } + } await applied; // Only now — a completed apply — is the launcher considered "seen", so // first-run onboarding stops offering itself on every command. diff --git a/src/hooks/fp-config.ts b/src/hooks/fp-config.ts index d9ffc9e5a..2c866c528 100644 --- a/src/hooks/fp-config.ts +++ b/src/hooks/fp-config.ts @@ -281,6 +281,8 @@ export interface FpConfig { }; collector: { sessions: boolean; + agents?: Record; + otlp?: { enabled: boolean; port?: number }; hooks: boolean; hooksVerbosity: "all" | "decisions" | "off"; redact: "minimal" | "off"; @@ -443,6 +445,25 @@ function readSources(raw: unknown): FpConfig["collector"]["sources"] { return Object.keys(out).length > 0 ? out : undefined; } +function readAgentSessions(raw: unknown): FpConfig["collector"]["agents"] { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined; + const agents = Object.fromEntries(Object.entries(raw).flatMap(([agent, settings]) => { + if (!settings || typeof settings !== "object" || Array.isArray(settings)) return []; + const sessions = (settings as Record).sessions; + return typeof sessions === "boolean" ? [[agent, { sessions }]] : []; + })); + return Object.keys(agents).length ? agents : undefined; +} + +function readOtlpSettings(raw: unknown): FpConfig["collector"]["otlp"] { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined; + const settings = raw as Record; + return { + enabled: settings.enabled === true, + ...(typeof settings.port === "number" && Number.isInteger(settings.port) ? { port: settings.port } : {}), + }; +} + /** * {@link readConfig}, plus the raw object it was projected from. * @@ -532,6 +553,8 @@ export function projectConfig(parsed: Record): FpConfig { typeof collector.environment === "string" ? collector.environment : "local", machineId: typeof collector.machine_id === "string" ? collector.machine_id : undefined, sources: readSources(collector.sources), + agents: readAgentSessions(collector.agents), + otlp: readOtlpSettings(collector.otlp), }, // Only an explicit `false` switches it off. Absent, or any other value, // reads as on — the shipped default, and what a config with no @@ -594,6 +617,9 @@ const OWNED_CONFIG_KEYS: readonly (readonly string[])[] = [ ["collector", "environment"], ["collector", "machine_id"], ["collector", "sources", "*", "extra_paths"], + ["collector", "agents", "*", "sessions"], + ["collector", "otlp", "enabled"], + ["collector", "otlp", "port"], ["telemetry", "enabled"], ["audit", "auto"], ["audit", "interval_days"], @@ -669,6 +695,8 @@ export function writeConfig(config: FpConfig, raw?: Record): vo // Omitted when unset rather than written null: the Rust side treats an // absent machine_id as "derive one", and an explicit null is not that. ...(c.machineId ? { machine_id: c.machineId } : {}), + ...(c.agents ? { agents: c.agents } : {}), + ...(c.otlp ? { otlp: c.otlp } : {}), // Extra capture paths per harness. Emitted ONLY when non-empty, so a // machine that never configured one gets a file byte-identical to what // this function produced before the field existed — the same reasoning as diff --git a/src/hooks/fp-home.ts b/src/hooks/fp-home.ts index 395bfe2ae..344ca93aa 100644 --- a/src/hooks/fp-home.ts +++ b/src/hooks/fp-home.ts @@ -139,6 +139,14 @@ export const versionFile = (home?: string) => atHome(home, "VERSION"); /** Non-secret configuration. World-readable by design; never holds a token. */ export const configFile = (home?: string) => atHome(home, "config.json"); +export const otelAgentStateDir = (home?: string) => atHome(home, "state", "otel-agents"); +export const otelAgentStateFile = (agent: string) => { + if (!["claude", "codex", "gemini", "copilot"].includes(agent)) throw new Error("Unknown OTEL agent."); + return resolve(otelAgentStateDir(), `${agent}.json`); +}; +export const otlpSpoolDir = (home?: string) => atHome(home, "state", "spool-otlp"); +export const otlpHealthFile = (home?: string) => atHome(home, "state", "otlp-health.json"); + /** * Every credential, owner-only. * @@ -576,6 +584,7 @@ export const HOME_CLASSES: readonly { path: (home?: string) => string; class: Da // the collector preferences, `[audit] auto`, the telemetry opt-out, and // `collector.sources.*.extra_paths` — the entire output of `harness add-path`. { path: configFile, class: "user-typed" }, + { path: otelAgentStateDir, class: "user-typed" }, // The builtin enable/disable set and per-policy params. `resetHome` used to // clear this and carry eight named keys back over; it is simply kept now. { path: globalPolicyConfigFile, class: "user-typed" }, @@ -602,6 +611,7 @@ export const HOME_CLASSES: readonly { path: (home?: string) => string; class: Da // these is PERMANENT and not merely slow is `cursorsDir` below: the watermark // has already advanced past them, so nothing will ever read that range again. { path: spoolDir, class: "undelivered" }, + { path: otlpSpoolDir, class: "undelivered" }, { path: failedDir, class: "undelivered" }, // The SDK spool (`events/` + `failed/`), same argument. { path: customAgentsDir, class: "undelivered" }, @@ -657,6 +667,7 @@ export const HOME_CLASSES: readonly { path: (home?: string) => string; class: Da { path: auditCacheDir, class: "derived" }, { path: auditScheduleFile, class: "derived" }, { path: collectorHealthFile, class: "derived" }, + { path: otlpHealthFile, class: "derived" }, { path: codexSessionPathsFile, class: "derived" }, { path: shimsDir, class: "derived" }, { path: sessionPauseDir, class: "derived" }, diff --git a/src/hooks/integrations.ts b/src/hooks/integrations.ts index bb1bf5e68..3d1c2ae76 100644 --- a/src/hooks/integrations.ts +++ b/src/hooks/integrations.ts @@ -85,7 +85,7 @@ export class UnreadableAgentConfigError extends Error { } } -function readJsonFile(path: string): Record { +export function readJsonFile(path: string): Record { if (!existsSync(path)) return {}; let raw: string; try { diff --git a/src/hooks/otel-cli.ts b/src/hooks/otel-cli.ts new file mode 100644 index 000000000..38441d100 --- /dev/null +++ b/src/hooks/otel-cli.ts @@ -0,0 +1,340 @@ +/** Opt-in agent setup; no OTLP translation or changes to the events uploader. */ +import { + chmodSync, existsSync, mkdirSync, readFileSync, + statSync, unlinkSync, writeFileSync, +} from "node:fs"; +import { dirname } from "node:path"; +import { createHash } from "node:crypto"; +import { homedir } from "node:os"; +import { connect } from "node:net"; +import { readConfig, readCredentials } from "./fp-config"; +import { configFile, otelAgentStateFile, otlpHealthFile } from "./fp-home"; +import { writeConfigFileAtomic } from "./safe-config-write"; +import { agentSessionsEnabled } from "./collector-config"; +import { introspectKey } from "./cloud-introspect"; +import { readJsonFile } from "./integrations"; +import { selectOne } from "./tui"; +import { + AGENT_LABELS, OTEL_AGENTS, agentConfigPath, agentOtelWrites, editAgentConfig, + getSetting, parseAgentConfig, vscodeInstalled, + type JsonObject, type OtelAgent, type SettingWrite, +} from "./otel-writers"; + +export const OTEL_ORG_OFF = "OTEL ingest is off for your org. An admin can turn it on in Settings → OpenTelemetry."; +export const OTEL_WIZARD_QUESTION = "Also send OpenTelemetry from agents? (Gemini CLI, Copilot, or switch Claude Code / Codex)"; +export interface OtelResult { + lines: string[]; + exitCode: number; + events?: { name: "otel_enabled" | "otel_disabled"; agent: OtelAgent; local: boolean; content: boolean }[]; +} +interface AgentRecord { + file: string; + local: boolean; + content: boolean; + destination: string; + keys: { path: string[]; hadPrevious: boolean; previous?: unknown; private?: boolean }[]; + previousSessions?: boolean; + switchedSessions: boolean; +} +interface PrivateState { + original: string | null; + originalMode?: number; + writtenHash: string; + previous: SettingWrite[]; +} +export interface OtelOptions { + home?: string; + fetchImpl?: typeof fetch; + confirm?: (question: string) => Promise; + onEvent?: (name: string, properties: { agent: OtelAgent; local: boolean; content: boolean }) => void | Promise; +} + +function rawConfig(): JsonObject { + return readJsonFile(configFile()); +} +function object(value: unknown): JsonObject { + if (value === undefined) return {}; + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid OTEL configuration object."); + return value as JsonObject; +} +function records(raw: JsonObject): Partial> { + return object(object(raw.otel).agents) as Partial>; +} +function saveConfig(raw: JsonObject): void { + writeConfigFileAtomic(configFile(), `${JSON.stringify(raw, null, 2)}\n`, { backup: false }); +} +function digest(text: string): string { + return createHash("sha256").update(text).digest("hex"); +} +function privateState(agent: OtelAgent): PrivateState { + return JSON.parse(readFileSync(otelAgentStateFile(agent), "utf8")) as PrivateState; +} +function credential(): { key: string; origin: string } { + const ingest = readCredentials().ingest; + const key = process.env.FAILPROOFAI_INGEST_KEY || ingest?.key; + if (!key) throw new Error("No Cloud API key configured. Run `failproofai config` first."); + const url = new URL(process.env.FAILPROOFAI_INGEST_URL || ingest?.url || "https://app.befailproof.ai"); + if (!["https:", "http:"].includes(url.protocol) || url.username || url.password) throw new Error("Invalid Cloud ingest URL."); + return { key, origin: url.origin }; +} +function backupOnce(path: string, original: string | null): void { + if (existsSync(`${path}.failproofai-bak`)) return; + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(`${path}.failproofai-bak`, original ?? "", { flag: "wx", mode: 0o600 }); + chmodSync(`${path}.failproofai-bak`, 0o600); +} +function savePrivate(agent: OtelAgent, state: PrivateState): void { + const path = otelAgentStateFile(agent); + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + writeConfigFileAtomic(path, JSON.stringify(state), { backup: false, mode: 0o600 }); + chmodSync(path, 0o600); +} +function configureRelay(raw: JsonObject): void { + const collector = object(raw.collector); + raw.collector = collector; + const local = Object.values(records(raw)).some(record => record?.local); + if (local) collector.otlp = { ...object(collector.otlp), enabled: true }; + else if (collector.otlp) collector.otlp = { ...object(collector.otlp), enabled: false }; +} +function hasExternalOtel(agent: OtelAgent, config: JsonObject): boolean { + if (agent === "claude") return getSetting(config, ["env", "CLAUDE_CODE_ENABLE_TELEMETRY"]) === "1"; + if (agent === "codex") { + return ["exporter", "trace_exporter", "metrics_exporter"].some(k => { + const value = getSetting(config, ["otel", k]); + return value !== undefined && value !== "none" && value !== "statsig"; + }); + } + return getSetting(config, agent === "gemini" ? ["telemetry", "enabled"] : ["github.copilot.chat.otel.enabled"]) === true; +} +async function defaultConfirm(question: string): Promise { + return await selectOne({ + message: question, + choices: [ + { label: "No", value: false, hint: "keep transcript upload" }, + { label: "Yes", value: true, hint: "switch this agent to OTEL" }, + ], + }) === true; +} +export async function chooseWizardOtel( + choose: (question: string) => Promise = defaultConfirm, +): Promise { + return choose(OTEL_WIZARD_QUESTION); +} +function restoreAgent(agent: OtelAgent, raw: JsonObject): void { + const all = records(raw); + const record = all[agent]; + if (!record) return; + const state = privateState(agent); + if (existsSync(record.file)) { + const current = readFileSync(record.file, "utf8"); + if (digest(current) === state.writtenHash) { + if (state.original === null) unlinkSync(record.file); + else writeConfigFileAtomic(record.file, state.original, { backup: false, mode: state.originalMode }); + } else { + const restored = editAgentConfig(current, agent, state.previous); + writeConfigFileAtomic(record.file, restored, { backup: false, mode: state.originalMode }); + } + } + if (record.switchedSessions) { + const collector = object(raw.collector); + const agents = object(collector.agents); + const settings = object(agents[agent]); + if (record.previousSessions === undefined) delete settings.sessions; + else settings.sessions = record.previousSessions; + if (Object.keys(settings).length) agents[agent] = settings; + else delete agents[agent]; + if (Object.keys(agents).length) collector.agents = agents; + else delete collector.agents; + } + delete all[agent]; + object(raw.otel).agents = all; +} +async function enableAgent(agent: OtelAgent, yes: boolean, localFlag: boolean, content: boolean, options: OtelOptions): Promise { + const { key, origin } = credential(); + const raw = rawConfig(); + const previousConfig = structuredClone(raw); + const file = agentConfigPath(agent, options.home ?? homedir()); + if (agent === "copilot" && !vscodeInstalled(file)) { + return { lines: ["VS Code is not installed (no user settings directory); skipping Copilot."], exitCode: 0 }; + } + const existing = records(raw)[agent]; + // Gemini has no transcript source in this collector. + const transcriptsEnabled = agent !== "gemini" && agentSessionsEnabled(agent); + if (!existing && transcriptsEnabled && !yes) { + const question = `failproofai already sends ${AGENT_LABELS[agent]} sessions to Cloud. Send them with OTEL instead? Transcript upload for ${AGENT_LABELS[agent]} stops.`; + if (!await (options.confirm ?? defaultConfirm)(question)) return { lines: [`${AGENT_LABELS[agent]} unchanged (default No).`], exitCode: 0 }; + } + const local = localFlag || agent === "gemini" || agent === "copilot"; + const port = readConfig().collector.otlp?.port ?? 4318; + if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("collector.otlp.port must be between 1 and 65535."); + const destination = local ? `http://127.0.0.1:${port}` : origin; + const original = existsSync(file) ? readFileSync(file, "utf8") : null; + const parsed = parseAgentConfig(original ?? "", agent); + const writes = agentOtelWrites(agent, destination, key, local, content); + const next = editAgentConfig(original ?? "", agent, writes); + // On repeated enable keep the ORIGINAL previous values, not our last write. + const state = existing ? privateState(agent) : { + original, + originalMode: original === null ? undefined : statSync(file).mode & 0o777, + writtenHash: "", + previous: writes.map(w => ({ path: w.path, value: getSetting(parsed, w.path) })), + }; + if (existing && original !== null && digest(original) !== state.writtenHash) { + // Re-enabling must not turn the fast byte-restore path into permission to + // discard changes made after the previous enable. + state.original = editAgentConfig(original, agent, state.previous); + } + const collector = object(raw.collector); + const agents = object(collector.agents); + const previousSessions = object(agents[agent]).sessions as boolean | undefined; + const record: AgentRecord = { + file, local, content, destination, + keys: state.previous.map(w => ({ + path: w.path, + hadPrevious: w.value !== undefined, + // Prior agent credentials belong in owner-only state, never config.json. + ...(w.path.some(k => /headers|exporter/i.test(k)) ? { private: true } : { previous: w.value }), + })), + previousSessions: existing ? existing.previousSessions : previousSessions, + switchedSessions: existing ? existing.switchedSessions : transcriptsEnabled, + }; + if (record.switchedSessions) { + agents[agent] = { ...object(agents[agent]), sessions: false }; + collector.agents = agents; + } + raw.collector = collector; + const otel = object(raw.otel); + otel.agents = { ...records(raw), [agent]: record }; + raw.otel = otel; + configureRelay(raw); + // Recovery state and one-time backup precede the first agent-file write. + backupOnce(file, original); + savePrivate(agent, { ...state, writtenHash: digest(next) }); + try { + // Publish ownership BEFORE the agent settings so a killed CLI can always + // disable/restore what it wrote, including the one-path transcript choice. + saveConfig(raw); + writeConfigFileAtomic(file, next, { backup: false, mode: 0o600 }); + // Agent files now contain a credential, even if previously world-readable. + chmodSync(file, 0o600); + } catch (err) { + if (original !== null) writeConfigFileAtomic(file, original, { backup: false, mode: state.originalMode }); + else if (existsSync(file)) unlinkSync(file); + saveConfig(previousConfig); + throw err; + } + const lines = [`${AGENT_LABELS[agent]} OTEL enabled → ${local ? "local relay" : "Cloud"} (${content ? "text capture on" : "no content"}).`]; + if (local && !localFlag) lines.push(`${AGENT_LABELS[agent]} settings cannot carry an auth header; using the local relay automatically.`); + if (local) lines.push("The daemon picks this up within seconds — no restart. Use `failproofai otel status` to check the relay."); + const result = await introspectKey(origin, key, options.fetchImpl, 3000); + if (result.kind === "ok" && result.identity.otelIngest === false) lines.push(OTEL_ORG_OFF); + await options.onEvent?.("otel_enabled", { agent, local, content }); + return { lines, exitCode: 0, events: [{ name: "otel_enabled", agent, local, content }] }; +} +function relayListening(port: number): Promise { + return new Promise(resolve => { + const socket = connect({ host: "127.0.0.1", port }); + const done = (ok: boolean) => { socket.destroy(); resolve(ok); }; + socket.setTimeout(200); + socket.once("connect", () => done(true)); + socket.once("error", () => done(false)); + socket.once("timeout", () => done(false)); + }); +} +async function status(options: OtelOptions): Promise { + let org = "unknown"; + try { + const { origin, key } = credential(); + const result = await introspectKey(origin, key, options.fetchImpl, 3000); + if (result.kind === "ok" && result.identity.otelIngest !== undefined) org = result.identity.otelIngest ? "on" : "off"; + } catch { /* Status works on an unconfigured machine too. */ } + const all = records(rawConfig()); + const lines = [`Org OTEL ingest: ${org}`]; + for (const agent of OTEL_AGENTS) { + const record = all[agent]; + let parsed: JsonObject = {}; + let unreadable = false; + try { + const file = record?.file ?? agentConfigPath(agent, options.home ?? homedir()); + if (existsSync(file)) parsed = parseAgentConfig(readFileSync(file, "utf8"), agent); + } catch { unreadable = true; } + const on = hasExternalOtel(agent, parsed); + let managed = false; + if (record && on) { + try { + const state = privateState(agent); + const expected = parseAgentConfig(editAgentConfig(state.original ?? "", agent, + agentOtelWrites(agent, record.destination, credential().key, record.local, record.content)), agent); + managed = record.keys.every(k => JSON.stringify(getSetting(parsed, k.path)) === JSON.stringify(getSetting(expected, k.path))); + } catch { /* Missing recovery state or key. */ } + } + const dest = record?.destination ?? getSetting(parsed, agent === "claude" ? ["env", "OTEL_EXPORTER_OTLP_ENDPOINT"] : + agent === "gemini" ? ["telemetry", "otlpEndpoint"] : + agent === "copilot" ? ["github.copilot.chat.otel.otlpEndpoint"] : ["otel", "exporter", "otlp-http", "endpoint"]); + const destination = dest ? `${record?.local || String(dest).startsWith("http://127.0.0.1:") ? "local relay" : "Cloud"} (${dest})` : "none"; + lines.push(`${AGENT_LABELS[agent]}: ${unreadable ? "unreadable settings" : on ? managed ? "on by failproofai" : "on, not managed" : "off"} · destination: ${destination} · org: ${org}`); + } + let health: JsonObject = {}; + try { health = JSON.parse(readFileSync(otlpHealthFile(), "utf8")) as JsonObject; } catch { /* No relay yet. */ } + const requested = readConfig().collector.otlp?.enabled === true; + const live = requested && health.relay === "listening" && await relayListening(readConfig().collector.otlp?.port ?? 4318); + lines.push(`Relay: ${live ? "listening on 127.0.0.1" : requested ? health.relay === "port_busy" ? "off (port busy)" : "off (daemon not listening)" : "off"} · last upload: ${health.last_upload ?? "none"}`); + return { lines, exitCode: 0 }; +} + +export async function runOtelCommand(args: string[], options: OtelOptions = {}): Promise { + try { + const [verb, ...rest] = args; + const flags = new Set(verb === "enable" ? ["--local", "--no-content", "--yes"] : verb === "env" ? ["--local", "--service"] : []); + const unexpected = rest.find((arg, i) => arg.startsWith("-") && !flags.has(arg) && rest[i - 1] !== "--service"); + if (unexpected) throw new Error(`Unexpected argument: ${unexpected}`); + if (verb === "status") { + if (rest.length) throw new Error("status takes no arguments."); + return await status(options); + } + if (verb === "env") { + const { origin, key } = credential(); + const index = rest.indexOf("--service"); + const service = index >= 0 ? rest[index + 1] : "my-agent"; + if (!service || service.startsWith("-")) throw new Error("Missing value after --service."); + const consumed = rest.filter((_, i) => i !== index && i !== index + 1 || index < 0).filter(x => x !== "--local"); + if (consumed.length) throw new Error("env takes only --service and --local."); + const local = rest.includes("--local"); + const endpoint = local ? `http://127.0.0.1:${readConfig().collector.otlp?.port ?? 4318}` : origin; + const quote = (s: string) => `'${s.replace(/'/g, "'\\''")}'`; + const values = { + OTEL_EXPORTER_OTLP_ENDPOINT: endpoint, OTEL_EXPORTER_OTLP_PROTOCOL: "http/protobuf", + OTEL_EXPORTER_OTLP_HEADERS: local ? "" : `Authorization=Bearer%20${encodeURIComponent(key)}`, + OTEL_SERVICE_NAME: service, + }; + return { lines: Object.entries(values).map(([k, v]) => `export ${k}=${quote(v)}`), exitCode: 0 }; + } + if (verb !== "enable" && verb !== "disable") throw new Error("Expected status, enable, disable or env. Run `failproofai otel --help`."); + const target = rest[0]; + if (!target || (target !== "all" && !OTEL_AGENTS.includes(target as OtelAgent))) throw new Error(`Choose an agent: ${OTEL_AGENTS.join(", ")}, all.`); + if (rest.slice(1).some(x => !flags.has(x))) throw new Error("Unexpected positional argument."); + const agents = target === "all" ? [...OTEL_AGENTS] : [target as OtelAgent]; + const result: OtelResult = { lines: [], exitCode: 0, events: [] }; + for (const agent of agents) { + if (verb === "enable") { + const enabled = await enableAgent(agent, rest.includes("--yes"), rest.includes("--local"), !rest.includes("--no-content"), options); + result.lines.push(...enabled.lines); + result.events?.push(...enabled.events ?? []); + } else { + const raw = rawConfig(); + const record = records(raw)[agent]; + if (!record) { result.lines.push(`${AGENT_LABELS[agent]}: no managed OTEL settings.`); continue; } + restoreAgent(agent, raw); + configureRelay(raw); + saveConfig(raw); + unlinkSync(otelAgentStateFile(agent)); + result.lines.push(`${AGENT_LABELS[agent]} OTEL disabled; previous settings restored.`); + await options.onEvent?.("otel_disabled", { agent, local: record.local, content: record.content }); + result.events?.push({ name: "otel_disabled", agent, local: record.local, content: record.content }); + } + } + return result; + } catch (err) { + return { lines: [err instanceof Error ? err.message : String(err)], exitCode: 1 }; + } +} diff --git a/src/hooks/otel-writers.ts b/src/hooks/otel-writers.ts new file mode 100644 index 000000000..ed872b759 --- /dev/null +++ b/src/hooks/otel-writers.ts @@ -0,0 +1,187 @@ +/** + * Agent-owned OTEL configuration. JSONC edits preserve comments; the TOML + * scanner identifies tables without mistaking a multiline string for one. + * smol-toml validates the complete document before any modification. + */ +import { parse as parseToml } from "smol-toml"; +import { applyEdits, modify, parse as parseJsonc, type ParseError } from "jsonc-parser/lib/esm/main.js"; +import { homedir } from "node:os"; +import { resolve } from "node:path"; +import { existsSync } from "node:fs"; + +export const OTEL_AGENTS = ["claude", "codex", "gemini", "copilot"] as const; +export type OtelAgent = typeof OTEL_AGENTS[number]; +export const AGENT_LABELS: Record = { + claude: "Claude Code", codex: "Codex", gemini: "Gemini CLI", copilot: "Copilot", +}; +export type JsonObject = Record; +export interface SettingWrite { path: string[]; value?: unknown } + +export function vscodeSettingsPath(home = homedir(), platform = process.platform): string { + if (platform === "darwin") return resolve(home, "Library/Application Support/Code/User/settings.json"); + if (platform === "win32") return resolve(process.env.APPDATA ?? resolve(home, "AppData/Roaming"), "Code/User/settings.json"); + return resolve(process.env.XDG_CONFIG_HOME ?? resolve(home, ".config"), "Code/User/settings.json"); +} + +export function agentConfigPath(agent: OtelAgent, home = homedir()): string { + return agent === "copilot" ? vscodeSettingsPath(home) : + resolve(home, `.${agent}`, agent === "codex" ? "config.toml" : "settings.json"); +} + +export function vscodeInstalled(path: string): boolean { + return existsSync(resolve(path, "..")); +} + +export function parseAgentConfig(text: string, agent: OtelAgent): JsonObject { + if (agent === "codex") return parseToml(text) as JsonObject; + if (!text.trim()) return {}; + const errors: ParseError[] = []; + const value: unknown = parseJsonc(text, errors, { allowTrailingComma: true }); + if (errors.length || !value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Refusing to modify an invalid agent settings object."); + } + return value as JsonObject; +} + +export function getSetting(object: JsonObject, path: string[]): unknown { + let value: unknown = object; + for (const key of path) { + if (!value || typeof value !== "object") return undefined; + value = (value as JsonObject)[key]; + } + return value; +} + +function setSetting(object: JsonObject, path: string[], value: unknown): void { + const [key, ...rest] = path; + if (!rest.length) { + if (value === undefined) delete object[key]; + else object[key] = value; + return; + } + const child = object[key]; + if (child !== undefined && (!child || typeof child !== "object" || Array.isArray(child))) { + throw new Error(`Refusing to replace a non-object ${key} setting.`); + } + const nested = (child ?? {}) as JsonObject; + setSetting(nested, rest, value); + if (Object.keys(nested).length) object[key] = nested; + else delete object[key]; +} + +function tomlValue(value: unknown): string { + if (typeof value === "string") return JSON.stringify(value); + if (typeof value === "boolean" || typeof value === "number") return String(value); + if (value instanceof Date) return value.toISOString(); + if (Array.isArray(value)) return `[${value.map(tomlValue).join(", ")}]`; + if (value && typeof value === "object") return `{ ${Object.entries(value).map(([k, v]) => `${JSON.stringify(k)} = ${tomlValue(v)}`).join(", ")} }`; + throw new Error("Unsupported TOML value."); +} + +function tables(text: string): { start: number; otel: boolean }[] { + const result: { start: number; otel: boolean }[] = []; + let multiline: string | null = null; + let offset = 0; + for (const line of text.match(/[^\n]*\n|[^\n]+$/g) ?? []) { + if (!multiline) { + const header = /^\s*(\[\[?.*?\]\]?)\s*(?:#.*)?(?:\r?\n)?$/.exec(line); + if (header) { + const sample = parseToml(`${header[1]}\n__failproofai_probe = true\n`); + result.push({ start: offset, otel: Object.hasOwn(sample, "otel") }); + } + } + let quote: string | null = null; + for (let i = 0; i < line.length; i++) { + if (multiline) { + if (line.startsWith(multiline, i) && (multiline === "'''" || line[i - 1] !== "\\")) { + i += 2; multiline = null; + } + } else if (quote) { + if (quote === '"' && line[i] === "\\") i++; + else if (line[i] === quote) quote = null; + } else if (line[i] === "#") break; + else if (line.startsWith('"""', i) || line.startsWith("'''", i)) { + multiline = line.slice(i, i + 3); i += 2; + } else if (line[i] === '"' || line[i] === "'") quote = line[i]; + } + offset += line.length; + } + return result; +} + +export function editAgentConfig(text: string, agent: OtelAgent, writes: SettingWrite[]): string { + const parsed = parseAgentConfig(text, agent); + if (agent !== "codex") { + let next = text.trim() ? text : "{}\n"; + for (const write of writes) { + setSetting(parsed, write.path, write.value); + next = applyEdits(next, modify(next, write.path, write.value, { + formattingOptions: { insertSpaces: true, tabSize: 2 }, + })); + } + for (const root of ["env", "telemetry"]) { + if (getSetting(parsed, [root]) === undefined) { + next = applyEdits(next, modify(next, [root], undefined, {})); + } + } + return next; + } + for (const write of writes) setSetting(parsed, write.path, write.value); + const headers = tables(text); + if (Object.hasOwn(parseToml(text.slice(0, headers[0]?.start ?? text.length)), "otel")) { + throw new Error("Codex OTEL must use an [otel] table, not a root dotted or inline assignment."); + } + const newline = text.includes("\r\n") ? "\r\n" : "\n"; + const otel = parsed.otel as JsonObject | undefined; + const replacement = otel ? `[otel]${newline}${Object.entries(otel).map(([k, v]) => `${JSON.stringify(k)} = ${tomlValue(v)}${newline}`).join("")}` : ""; + let next = text; + const ranges = headers.map((h, i) => { + const end = headers[i + 1]?.start ?? text.length; + // Comments introducing the NEXT table are not part of the table we own. + const tail = /(?<=\n)(?:[ \t]*(?:#[^\r\n]*)?\r?\n)+$/.exec(text.slice(h.start, end)); + return { ...h, end: tail ? h.start + tail.index : end }; + }).filter(h => h.otel); + for (let i = ranges.length - 1; i >= 0; i--) { + const range = ranges[i]; + next = next.slice(0, range.start) + (i === 0 ? replacement : "") + next.slice(range.end); + } + if (!ranges.length && replacement) next += `${next && !next.endsWith("\n") ? newline : ""}${replacement}`; + parseToml(next); + return next; +} + +export function agentOtelWrites(agent: OtelAgent, endpoint: string, key: string, local: boolean, content: boolean): SettingWrite[] { + const auth = local ? {} : { Authorization: `Bearer ${key}` }; + const env: JsonObject = { + CLAUDE_CODE_ENABLE_TELEMETRY: "1", + OTEL_LOGS_EXPORTER: "otlp", OTEL_METRICS_EXPORTER: "otlp", + OTEL_EXPORTER_OTLP_ENDPOINT: endpoint, OTEL_EXPORTER_OTLP_PROTOCOL: "http/protobuf", + OTEL_EXPORTER_OTLP_HEADERS: local ? "" : `Authorization=Bearer%20${encodeURIComponent(key)}`, + OTEL_LOG_USER_PROMPTS: content ? "1" : "0", + OTEL_LOG_TOOL_DETAILS: content ? "1" : "0", + OTEL_LOG_ASSISTANT_RESPONSES: content ? "1" : "0", + OTEL_LOG_TOOL_CONTENT: content ? "1" : "0", + }; + if (agent === "claude") { + for (const signal of ["LOGS", "METRICS"]) { + env[`OTEL_EXPORTER_OTLP_${signal}_ENDPOINT`] = `${endpoint}/v1/${signal.toLowerCase()}`; + env[`OTEL_EXPORTER_OTLP_${signal}_PROTOCOL`] = "http/protobuf"; + env[`OTEL_EXPORTER_OTLP_${signal}_HEADERS`] = env.OTEL_EXPORTER_OTLP_HEADERS; + } + return Object.entries(env).map(([k, value]) => ({ path: ["env", k], value })); + } + if (agent === "codex") { + const exporter = (signal: string) => ({ "otlp-http": { endpoint: `${endpoint}/v1/${signal}`, protocol: "json", headers: auth } }); + return Object.entries({ + exporter: exporter("logs"), trace_exporter: exporter("traces"), metrics_exporter: exporter("metrics"), + log_user_prompt: content, + log_agent_responses: content, + }).map(([k, value]) => ({ path: ["otel", k], value })); + } + if (agent === "gemini") return Object.entries({ + enabled: true, target: "local", otlpEndpoint: endpoint, otlpProtocol: "http", logPrompts: content, useCollector: true, outfile: undefined, + }).map(([k, value]) => ({ path: ["telemetry", k], value })); + return Object.entries({ + enabled: true, exporterType: "otlp-http", otlpEndpoint: endpoint, captureContent: content, + }).map(([k, value]) => ({ path: [`github.copilot.chat.otel.${k}`], value })); +} diff --git a/src/hooks/safe-config-write.ts b/src/hooks/safe-config-write.ts index ecf7cf603..74d1593ea 100644 --- a/src/hooks/safe-config-write.ts +++ b/src/hooks/safe-config-write.ts @@ -53,6 +53,8 @@ export function configBackupPath(path: string): string { /** Hooks for tests to simulate an interruption at the one step that matters. */ export interface AtomicWriteDeps { + /** Explicit permissions for a newly credential-bearing file. */ + mode?: number; rename?: (from: string, to: string) => void; /** Keep `.failproofai-backup` of the previous version (default true). */ backup?: boolean; @@ -144,9 +146,9 @@ export function writeConfigFileAtomic(path: string, content: string, deps: Atomi let mode: number | undefined; try { // A link being replaced lends nothing: its target's mode is someone else's. - mode = replacingLink ? undefined : statSync(target).mode & 0o777; + mode = deps.mode ?? (replacingLink ? undefined : statSync(target).mode & 0o777); } catch { - mode = undefined; // a new file: the process umask decides, as writeFileSync would + mode = deps.mode; // otherwise the process umask decides, as writeFileSync would } const temporary = join(dir, `.${basename(target)}.failproofai-${process.pid}-${randomBytes(4).toString("hex")}.tmp`); From 0eaf89e38715605781cf04fab650007b7a4d4a6c Mon Sep 17 00:00:00 2001 From: Chetan Raghuvanshi <145042127+chhhee10@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:32:57 +0000 Subject: [PATCH 002/115] feat(otel): add durable loopback relay and per-agent transcript lanes --- CHANGELOG.md | 6 + Cargo.lock | 60 ++ crates/failproofaid/src/main.rs | 16 +- crates/failproofaid/tests/otlp_relay_e2e.rs | 148 ++++ crates/fpai-collect/Cargo.toml | 4 +- crates/fpai-collect/src/config.rs | 47 ++ crates/fpai-collect/src/lib.rs | 1 + crates/fpai-collect/src/otlp.rs | 710 ++++++++++++++++++++ docs/agenteye/opentelemetry.mdx | 130 ++++ docs/docs.json | 2 + docs/reference/failproof-cli.mdx | 1 + docs/reference/otel.mdx | 67 ++ 12 files changed, 1190 insertions(+), 2 deletions(-) create mode 100644 crates/failproofaid/tests/otlp_relay_e2e.rs create mode 100644 crates/fpai-collect/src/otlp.rs create mode 100644 docs/agenteye/opentelemetry.mdx create mode 100644 docs/reference/otel.mdx diff --git a/CHANGELOG.md b/CHANGELOG.md index e1576b070..5a20f6cd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 1.0.11-beta.0 — 2026-10-10 + +### Features + +- Add opt-in coding-agent OpenTelemetry setup, reversible per-agent transcript switching, and a loopback OTLP relay (PR pending). + ## 1.0.10 — 2026-10-05 Action needed if you use `instruct` policies with Hermes: an `instruct` no longer stops the call it reminds about. The call runs and the reminder is placed first in its result, so a policy written to prevent an action (for example, a direct API write that should go through an approved script) must become a `deny` before you upgrade. Hermes 0.20.0 is supported again: on 1.0.9 the plugin loaded there with no hooks and every tool call ran unchecked. After upgrading, run `failproofai update` and restart each Hermes gateway profile by name. Collects 1.0.10-beta.0 to beta.1 below. diff --git a/Cargo.lock b/Cargo.lock index 943a3344c..fd3a0981b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -33,6 +33,52 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + [[package]] name = "base64" version = "0.22.1" @@ -283,7 +329,9 @@ dependencies = [ name = "fpai-collect" version = "1.0.11-beta.0" dependencies = [ + "axum", "getrandom 0.3.4", + "httpdate", "notify", "reqwest", "rusqlite", @@ -844,12 +892,24 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "mio" version = "1.2.2" diff --git a/crates/failproofaid/src/main.rs b/crates/failproofaid/src/main.rs index 10e386723..c2fb565e3 100644 --- a/crates/failproofaid/src/main.rs +++ b/crates/failproofaid/src/main.rs @@ -886,7 +886,13 @@ fn collector_tasks() -> Vec { })); } - if cfg.settings.sessions { + if cfg.settings.sessions + || cfg + .settings + .agents + .values() + .any(|a| a.sessions == Some(true)) + { // Session transcripts, gated on the `sessions` opt-in because — unlike // hook activity — these carry prompts, file contents and whatever was // pasted into a terminal. @@ -1252,6 +1258,14 @@ fn collector_tasks() -> Vec { } } + // Filter only transcript tasks; hook activity and delivery are untouched. + tasks.retain(|task| { + let source = task.name.split(':').next().unwrap_or(&task.name); + !HARNESS_KEYS.contains(&source) && source != "claude-subagent" + || cfg.settings.sessions_for(source) + }); + tasks.extend(fpai_collect::otlp::tasks(home, ingest, &cfg.settings.otlp)); + tasks.extend([ // Latency: delivers a batch within milliseconds of it being published. fpai_collect::TaskSpec::new("spool-watcher", move |sd| { diff --git a/crates/failproofaid/tests/otlp_relay_e2e.rs b/crates/failproofaid/tests/otlp_relay_e2e.rs new file mode 100644 index 000000000..3cc43774e --- /dev/null +++ b/crates/failproofaid/tests/otlp_relay_e2e.rs @@ -0,0 +1,148 @@ +//! A relay bind failure and live enable/disable must never share fate with the +//! daemon's enforcement socket. Real binary, loopback HTTP and Unix Ping. +use std::io::{BufRead, Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::os::unix::net::UnixStream; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use fpai_ipc::{ClientMessage, PROTOCOL_VERSION, ServerMessage, read_message, write_message}; + +struct Daemon { + child: Child, + home: PathBuf, + log: Arc>, +} +impl Drop for Daemon { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + let _ = std::fs::remove_dir_all(&self.home); + } +} +fn settings(home: &Path, port: u16, enabled: bool) { + let body = serde_json::json!({ + "mode": {"kind":"cloud"}, "collector": { + "sessions": true, "agents": {"claude":{"sessions":false},"codex":{"sessions":false}}, + "hooks": false, "otlp": {"enabled":enabled,"port":port} + }, "telemetry":{"enabled":false}, "audit":{"auto":false} + }); + std::fs::write(home.join("config.json"), body.to_string()).unwrap(); +} +fn start(port: u16, enabled: bool) -> Daemon { + let home = std::env::temp_dir().join(format!( + "fpai-otlp-daemon-{}", + fpai_collect::uploader::new_request_id() + )); + std::fs::create_dir_all(&home).unwrap(); + settings(&home, port, enabled); + std::fs::write( + home.join("credentials.json"), + r#"{"ingest":{"url":"http://127.0.0.1:59999/v1/events","key":"test"}}"#, + ) + .unwrap(); + let mut child = Command::new(env!("CARGO_BIN_EXE_failproofaid")) + .env("HOME", &home) + .env("FAILPROOFAI_HOME", &home) + .env( + "FAILPROOFAI_DAEMON_SOCKET", + home.join("run/failproofaid.sock"), + ) + .env("FAILPROOFAI_TELEMETRY_DISABLED", "1") + .env("FAILPROOFAI_COLLECTOR_CONFIG_POLL_MS", "100") + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let log = Arc::new(Mutex::new(String::new())); + let sink = log.clone(); + let pipe = child.stderr.take().unwrap(); + std::thread::spawn(move || { + for line in std::io::BufReader::new(pipe).lines().map_while(Result::ok) { + let mut log = sink.lock().unwrap(); + log.push_str(&line); + log.push('\n'); + } + }); + Daemon { child, home, log } +} +fn wait(predicate: impl Fn() -> bool) { + let start = Instant::now(); + while !predicate() { + assert!(start.elapsed() < Duration::from_secs(20), "timed out"); + std::thread::sleep(Duration::from_millis(50)); + } +} +fn ping(daemon: &Daemon) { + let mut stream = UnixStream::connect(daemon.home.join("run/failproofaid.sock")).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + write_message( + &mut stream, + &ClientMessage::Ping { + protocol_version: PROTOCOL_VERSION, + }, + ) + .unwrap(); + let response: ServerMessage = read_message(&mut stream).unwrap(); + assert!(matches!(response, ServerMessage::Pong { .. })); +} + +#[test] +fn busy_port_logs_once_and_daemon_keeps_answering() { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + let mut daemon = start(port, true); + wait(|| daemon.log.lock().unwrap().contains("OTLP relay off:")); + ping(&daemon); + std::thread::sleep(Duration::from_millis(500)); + assert!(daemon.child.try_wait().unwrap().is_none()); + assert_eq!( + daemon + .log + .lock() + .unwrap() + .matches("OTLP relay off:") + .count(), + 1 + ); + let health = std::fs::read_to_string(daemon.home.join("state/otlp-health.json")).unwrap(); + assert!(health.contains("port_busy")); + // Both default and subagent Claude tasks must be absent. + let log = daemon.log.lock().unwrap(); + assert!(!log.contains("source=\"claude\"")); + assert!(!log.contains("source=\"claude-subagent\"")); + assert!(!log.contains("source=\"codex\"")); +} + +#[test] +fn relay_starts_and_stops_on_config_reload_without_daemon_restart() { + let reserved = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = reserved.local_addr().unwrap().port(); + drop(reserved); + let daemon = start(port, false); + wait(|| daemon.home.join("run/failproofaid.sock").exists()); + // The socket is bound before the collector manager snapshots its config. + // Wait for the first deployment before testing a subsequent hand edit. + wait(|| daemon.log.lock().unwrap().contains("collector started")); + std::thread::sleep(Duration::from_millis(250)); + ping(&daemon); + assert!(TcpStream::connect(("127.0.0.1", port)).is_err()); + settings(&daemon.home, port, true); + wait(|| TcpStream::connect(("127.0.0.1", port)).is_ok()); + let mut stream = TcpStream::connect(("127.0.0.1", port)).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + stream.write_all(b"POST /v1/logs HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}").unwrap(); + let mut response = String::new(); + stream.read_to_string(&mut response).unwrap(); + assert!(response.starts_with("HTTP/1.1 200")); + ping(&daemon); + settings(&daemon.home, port, false); + wait(|| TcpStream::connect(("127.0.0.1", port)).is_err()); + ping(&daemon); +} diff --git a/crates/fpai-collect/Cargo.toml b/crates/fpai-collect/Cargo.toml index 6edb91135..1131036c2 100644 --- a/crates/fpai-collect/Cargo.toml +++ b/crates/fpai-collect/Cargo.toml @@ -13,7 +13,9 @@ serde_json = "1" # The collector is the only async part of the daemon. `failproofaid`'s socket # server stays blocking std threads on purpose (see supervisor.rs), so this # runtime is created here and never leaks into the enforcement path. -tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "fs", "sync", "io-util"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "fs", "sync", "io-util", "net"] } +axum = { version = "0.8", default-features = false, features = ["http1", "tokio"] } +httpdate = "1" tracing = "0.1" # `rustls-tls` rather than the default native-tls: the daemon cross-compiles to # four targets and pulling OpenSSL onto each of them is the kind of build diff --git a/crates/fpai-collect/src/config.rs b/crates/fpai-collect/src/config.rs index 6acb75e80..feec16d1a 100644 --- a/crates/fpai-collect/src/config.rs +++ b/crates/fpai-collect/src/config.rs @@ -130,6 +130,12 @@ pub struct Settings { /// sending them. #[serde(default)] pub sessions: bool, + /// Absent per-agent override inherits the global sessions opt-in. + #[serde(default)] + pub agents: BTreeMap, + /// Raw OTLP relay is additive and disabled by default. + #[serde(default)] + pub otlp: OtlpSettings, /// Ship hook activity. Defaults to TRUE once ingest is configured — it /// carries decisions and tool names, never file contents, and it is the /// capability that justifies collecting from inside failproofai at all. @@ -175,6 +181,33 @@ pub struct SourceSettings { pub extra_paths: Vec, } +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +pub struct AgentSettings { + #[serde(default)] + pub sessions: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct OtlpSettings { + #[serde(default)] + pub enabled: bool, + #[serde(default = "default_otlp_port")] + pub port: u16, +} + +fn default_otlp_port() -> u16 { + 4318 +} + +impl Default for OtlpSettings { + fn default() -> Self { + Self { + enabled: false, + port: default_otlp_port(), + } + } +} + fn default_true() -> bool { true } @@ -187,6 +220,8 @@ impl Default for Settings { fn default() -> Self { Settings { sessions: false, + agents: BTreeMap::new(), + otlp: OtlpSettings::default(), hooks: true, hooks_verbosity: HooksVerbosity::default(), redact: Redact::default(), @@ -198,6 +233,18 @@ impl Default for Settings { } impl Settings { + pub fn sessions_for(&self, agent: &str) -> bool { + let agent = if agent == "claude-subagent" { + "claude" + } else { + agent + }; + self.agents + .get(agent) + .and_then(|a| a.sessions) + .unwrap_or(self.sessions) + } + /// Raw `extra_paths` entries for one source, env override winning whole. /// /// `FAILPROOFAI__EXTRA_PATHS`, comma-separated — the same diff --git a/crates/fpai-collect/src/lib.rs b/crates/fpai-collect/src/lib.rs index bc1f1ce7c..9bc8fec86 100644 --- a/crates/fpai-collect/src/lib.rs +++ b/crates/fpai-collect/src/lib.rs @@ -17,6 +17,7 @@ pub mod delivery; pub mod extra_paths; pub mod filetail; pub mod health; +pub mod otlp; pub mod redact; pub mod sources; pub mod spool; diff --git a/crates/fpai-collect/src/otlp.rs b/crates/fpai-collect/src/otlp.rs new file mode 100644 index 000000000..9b24b0578 --- /dev/null +++ b/crates/fpai-collect/src/otlp.rs @@ -0,0 +1,710 @@ +//! Opaque OTLP/HTTP relay. No translation, decompression or event-uploader reuse. +//! Each batch is a JSON metadata line followed by the exact request bytes. +//! SDK exporters publish the same format; only atomic .jsonl files are read. +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Write}; +use std::net::{Ipv4Addr, SocketAddr}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, SystemTime}; + +use axum::Router; +use axum::body::{Body, to_bytes}; +use axum::extract::{Request, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::Response; +use axum::routing::post; +use serde::{Deserialize, Serialize}; + +use crate::config::{Ingest, OtlpSettings}; +use crate::supervisor::{Shutdown, TaskError, TaskSpec}; +use crate::uploader::{ParkedName, new_request_id}; + +pub const MAX_BODY_BYTES: usize = 8 * 1024 * 1024; +const PATHS: [&str; 3] = ["/v1/traces", "/v1/logs", "/v1/metrics"]; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Metadata { + pub path: String, + pub content_type: String, + pub encoding: Option, +} + +#[derive(Default, Serialize, Deserialize)] +struct RelayHealth { + relay: String, + last_upload: Option, +} + +struct Relay { + spool: PathBuf, + failed: PathBuf, + health_file: PathBuf, + health: Mutex, + client: reqwest::Client, + origin: String, + key: String, + receiving: tokio::sync::Semaphore, +} + +impl Relay { + fn new(home: &Path, ingest: &Ingest) -> Result { + let url = reqwest::Url::parse(&ingest.url).map_err(|e| TaskError(e.to_string()))?; + let origin = url.origin().ascii_serialization(); + let health_file = home.join("state/otlp-health.json"); + let mut health = fs::read(&health_file) + .ok() + .and_then(|b| serde_json::from_slice::(&b).ok()) + .unwrap_or_default(); + health.relay = "off".to_string(); + Ok(Self { + spool: home.join("state/spool-otlp"), + // Nested under failed, isolated from the NDJSON events sweeper. + failed: home.join("state/failed/otlp"), + health_file, + health: Mutex::new(health), + client: reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(30)) + .build() + .map_err(|e| TaskError(e.to_string()))?, + origin, + key: ingest.key.clone(), + receiving: tokio::sync::Semaphore::new(16), + }) + } + + fn report(&self, relay: Option<&str>, upload: Option) { + let Ok(mut health) = self.health.lock() else { + return; + }; + if let Some(relay) = relay { + health.relay = relay.to_string(); + } + if let Some(upload) = upload { + health.last_upload = Some(upload); + } + if let Ok(bytes) = serde_json::to_vec(&*health) { + let _ = atomic_write(&self.health_file, &bytes); + } + } + + async fn deliver(&self, path: &Path, sd: &Shutdown) { + let bytes = match tokio::fs::read(path).await { + Ok(bytes) => bytes, + Err(_) => return, + }; + let Some(split) = bytes.iter().position(|b| *b == b'\n') else { + self.park(path, None).await; + return; + }; + let metadata: Metadata = match serde_json::from_slice(&bytes[..split]) { + Ok(metadata) => metadata, + Err(_) => { + self.park(path, None).await; + return; + } + }; + if !PATHS.contains(&metadata.path.as_str()) || bytes.len() - split - 1 > MAX_BODY_BYTES { + self.park(path, Some(400)).await; + return; + } + let body = &bytes[split + 1..]; + let mut status = None; + for attempt in 0..5 { + if sd.is_set() { + return; + } + let mut request = self + .client + .post(format!("{}{}", self.origin, metadata.path)) + .bearer_auth(&self.key) + .header("Content-Type", &metadata.content_type) + .body(body.to_vec()); + if let Some(encoding) = &metadata.encoding { + request = request.header("Content-Encoding", encoding); + } + let response = request.send().await; + let mut delay = Duration::from_secs(1 << attempt); + match response { + Ok(response) => { + let code = response.status().as_u16(); + status = Some(code); + if code == 200 { + let content_type = response + .headers() + .get("content-type") + .and_then(|h| h.to_str().ok()) + .unwrap_or("") + .to_string(); + // Bound response size too; an ingest proxy need not be trusted. + let mut response = response; + let mut result = Vec::new(); + while let Ok(Some(chunk)) = response.chunk().await { + if result.len() + chunk.len() > 64 * 1024 { + break; + } + result.extend_from_slice(&chunk); + } + let rejected = rejected_count(&result, &content_type); + if rejected > 0 { + tracing::warn!(rejected, signal = %metadata.path, "OTLP partial_success delivered"); + } + self.report(None, Some(format!("200 delivered; rejected={rejected}"))); + let _ = tokio::fs::remove_file(path).await; + return; + } + self.report(None, Some(format!("{code} parked"))); + if !matches!(code, 429 | 502 | 503 | 504) { + break; + } + delay = retry_after(response.headers()).unwrap_or(delay); + } + Err(_) => self.report(None, Some("network error; retained".to_string())), + } + if attempt < 4 && !sd.sleep(delay).await { + return; + } + } + self.park(path, status).await; + } + + async fn park(&self, path: &Path, status: Option) { + if tokio::fs::create_dir_all(&self.failed).await.is_err() { + return; + } + let Some(name) = path.file_name().and_then(|s| s.to_str()) else { + return; + }; + let mut parked = ParkedName::parse(name); + parked.attempt += 1; + parked.client_status = status.filter(|c| (400..500).contains(c) && *c != 429); + parked.poison = parked.attempt >= 3; + let target = self.failed.join(parked.render()); + if tokio::fs::rename(path, target).await.is_ok() { + let _ = File::open(&self.failed).and_then(|f| f.sync_all()); + } + } +} + +fn retry_after(headers: &HeaderMap) -> Option { + let value = headers.get("retry-after")?.to_str().ok()?; + if let Ok(seconds) = value.parse::() { + return Some(Duration::from_secs(seconds)); + } + Some( + httpdate::parse_http_date(value) + .ok()? + .duration_since(SystemTime::now()) + .unwrap_or_default(), + ) +} + +fn varint(bytes: &[u8], cursor: &mut usize) -> Option { + let mut value = 0; + for shift in (0..64).step_by(7) { + let byte = *bytes.get(*cursor)?; + *cursor += 1; + value |= u64::from(byte & 127) << shift; + if byte & 128 == 0 { + return Some(value); + } + } + None +} + +/// Export response field 1 is partial_success; its field 1 is rejected count. +/// Only response metadata is read — payload translation belongs to Cloud. +fn rejected_count(bytes: &[u8], content_type: &str) -> u64 { + if content_type.contains("json") || bytes.first() == Some(&b'{') { + let Ok(value) = serde_json::from_slice::(bytes) else { + return 0; + }; + let partial = value + .get("partialSuccess") + .or_else(|| value.get("partial_success")); + return partial + .and_then(|p| { + [ + "rejectedSpans", + "rejectedLogRecords", + "rejectedDataPoints", + "rejected_spans", + "rejected_log_records", + "rejected_data_points", + ] + .iter() + .find_map(|key| { + p.get(key) + .and_then(|n| n.as_u64().or_else(|| n.as_str()?.parse().ok())) + }) + }) + .unwrap_or(0); + } + let mut cursor = 0; + if varint(bytes, &mut cursor) != Some(10) { + return 0; + } + let Some(length) = varint(bytes, &mut cursor) else { + return 0; + }; + let Some(end) = cursor.checked_add(length as usize) else { + return 0; + }; + let Some(partial) = bytes.get(cursor..end) else { + return 0; + }; + cursor = 0; + if varint(partial, &mut cursor) != Some(8) { + return 0; + } + varint(partial, &mut cursor).unwrap_or(0) +} + +fn atomic_write(path: &Path, bytes: &[u8]) -> io::Result<()> { + let dir = path + .parent() + .ok_or_else(|| io::Error::other("missing directory"))?; + fs::create_dir_all(dir)?; + let temp = dir.join(format!(".{}.tmp", new_request_id())); + let result = (|| { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(&temp)?; + file.write_all(bytes)?; + file.sync_all()?; + fs::rename(&temp, path)?; + File::open(dir)?.sync_all() + })(); + if result.is_err() { + let _ = fs::remove_file(temp); + } + result +} + +pub fn spool_request(spool: &Path, metadata: &Metadata, body: &[u8]) -> io::Result { + if !PATHS.contains(&metadata.path.as_str()) || body.len() > MAX_BODY_BYTES { + return Err(io::Error::other("invalid OTLP path or body size")); + } + let mut bytes = serde_json::to_vec(metadata)?; + bytes.push(b'\n'); + bytes.extend_from_slice(body); + let path = spool.join(format!("otlp-{}.jsonl", new_request_id())); + atomic_write(&path, &bytes)?; + Ok(path) +} + +async fn receive(State(relay): State>, request: Request) -> Response { + let Ok(_permit) = relay.receiving.try_acquire() else { + return Response::builder().status(503).body(Body::empty()).unwrap(); + }; + let path = request.uri().path().to_string(); + let content_type = request + .headers() + .get("content-type") + .and_then(|h| h.to_str().ok()) + .unwrap_or("application/x-protobuf") + .to_string(); + if !matches!( + content_type.split(';').next(), + Some("application/json" | "application/x-protobuf" | "application/protobuf") + ) { + return Response::builder().status(415).body(Body::empty()).unwrap(); + } + let encoding = request + .headers() + .get("content-encoding") + .and_then(|h| h.to_str().ok()) + .map(str::to_string); + let is_json = content_type.contains("json"); + let body = match tokio::time::timeout( + Duration::from_secs(30), + to_bytes(request.into_body(), MAX_BODY_BYTES), + ) + .await + { + Ok(Ok(body)) => body, + Ok(Err(_)) => { + return Response::builder() + .status(StatusCode::PAYLOAD_TOO_LARGE) + .body(Body::empty()) + .unwrap(); + } + Err(_) => { + return Response::builder() + .status(StatusCode::REQUEST_TIMEOUT) + .body(Body::empty()) + .unwrap(); + } + }; + let spool = relay.spool.clone(); + let metadata = Metadata { + path, + content_type, + encoding, + }; + match tokio::task::spawn_blocking(move || spool_request(&spool, &metadata, &body)).await { + Ok(Ok(_)) => Response::builder() + .status(200) + .header( + "Content-Type", + if is_json { + "application/json" + } else { + "application/x-protobuf" + }, + ) + .body(Body::from(if is_json { + b"{}".as_slice() + } else { + b"".as_slice() + })) + .unwrap(), + _ => Response::builder().status(503).body(Body::empty()).unwrap(), + } +} + +async fn listen(relay: Arc, port: u16, sd: Shutdown) -> Result<(), TaskError> { + // Binding an IPv4 address, never a configurable host, makes loopback-only + // structural: no config or environment variable can expose the listener. + let address = SocketAddr::from((Ipv4Addr::LOCALHOST, port)); + let listener = match tokio::net::TcpListener::bind(address).await { + Ok(listener) => listener, + Err(err) => { + tracing::warn!(port, %err, "OTLP relay off: loopback port unavailable; daemon continues"); + relay.report(Some("port_busy"), None); + return Ok(()); // Clean exit: supervisor does not restart/log repeatedly. + } + }; + relay.report(Some("listening"), None); + let app = Router::new() + .route("/v1/traces", post(receive)) + .route("/v1/logs", post(receive)) + .route("/v1/metrics", post(receive)) + .with_state(relay.clone()); + let result = axum::serve(listener, app) + .with_graceful_shutdown(async move { while sd.sleep(Duration::from_millis(100)).await {} }) + .await; + relay.report(Some("off"), None); + result.map_err(|e| TaskError(e.to_string())) +} + +async fn deliver_loop(relay: Arc, sd: Shutdown) -> Result<(), TaskError> { + let mut last_failed = tokio::time::Instant::now(); + while !sd.is_set() { + for directory in [&relay.spool, &relay.failed] { + if directory == &relay.failed && last_failed.elapsed() < Duration::from_secs(3600) { + continue; + } + if directory == &relay.failed { + last_failed = tokio::time::Instant::now(); + } + let Ok(mut entries) = tokio::fs::read_dir(directory).await else { + continue; + }; + let mut delivered = 0; + while let Ok(Some(entry)) = entries.next_entry().await { + let name = entry.file_name().to_string_lossy().to_string(); + if !name.starts_with("otlp-") || !name.ends_with(".jsonl") { + continue; + } + if entry.file_type().await.is_ok_and(|t| t.is_file()) { + relay.deliver(&entry.path(), &sd).await; + delivered += 1; + if delivered >= 32 || sd.is_set() { + break; + } + } + } + } + if !sd.sleep(Duration::from_millis(250)).await { + break; + } + } + Ok(()) +} + +pub fn tasks(home: PathBuf, ingest: Ingest, settings: &OtlpSettings) -> Vec { + if !settings.enabled { + return Vec::new(); + } + let relay = match Relay::new(&home, &ingest) { + Ok(relay) => Arc::new(relay), + Err(err) => { + tracing::warn!(%err, "OTLP relay disabled"); + return Vec::new(); + } + }; + let delivery = relay.clone(); + let port = settings.port; + vec![ + TaskSpec::new("otlp-relay", move |sd| listen(relay.clone(), port, sd)), + TaskSpec::new("otlp-delivery", move |sd| { + deliver_loop(delivery.clone(), sd) + }), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::AtomicBool; + use wiremock::{ + Mock, MockServer, ResponseTemplate, + matchers::{header, method, path}, + }; + + fn temp() -> PathBuf { + let dir = std::env::temp_dir().join(format!("fpai-otlp-{}", new_request_id())); + fs::create_dir_all(&dir).unwrap(); + dir + } + fn shutdown() -> Shutdown { + Shutdown::for_test(Arc::new(AtomicBool::new(false))) + } + fn batch(relay: &Relay, body: &[u8]) -> PathBuf { + spool_request( + &relay.spool, + &Metadata { + path: "/v1/logs".into(), + content_type: "application/json".into(), + encoding: Some("gzip".into()), + }, + body, + ) + .unwrap() + } + + #[tokio::test] + async fn durable_opaque_forward_and_partial_success() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/logs")) + .and(header("authorization", "Bearer test")) + .and(header("content-type", "application/json")) + .and(header("content-encoding", "gzip")) + .respond_with( + ResponseTemplate::new(200).set_body_json( + serde_json::json!({"partialSuccess":{"rejectedLogRecords":"2"}}), + ), + ) + .expect(1) + .mount(&server) + .await; + let home = temp(); + let relay = Relay::new( + &home, + &Ingest { + url: format!("{}/v1/events", server.uri()), + key: "test".into(), + }, + ) + .unwrap(); + let body = b"\x1f\x8b\x00opaque-gzip"; + let file = batch(&relay, body); + let stored = fs::read(&file).unwrap(); + assert!(stored.ends_with(body)); + relay.deliver(&file, &shutdown()).await; + assert!(!file.exists()); + assert_eq!(server.received_requests().await.unwrap()[0].body, body); + assert!( + fs::read_to_string(&relay.health_file) + .unwrap() + .contains("rejected=2") + ); + fs::remove_dir_all(home).unwrap(); + } + + #[tokio::test] + async fn retries_503_retry_after_then_delivers() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(503).insert_header("Retry-After", "1")) + .up_to_n_times(1) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server) + .await; + let home = temp(); + let relay = Relay::new( + &home, + &Ingest { + url: server.uri(), + key: "test".into(), + }, + ) + .unwrap(); + let file = batch(&relay, b"{}"); + let start = tokio::time::Instant::now(); + relay.deliver(&file, &shutdown()).await; + assert!(start.elapsed() >= Duration::from_secs(1)); + assert!(!file.exists()); + fs::remove_dir_all(home).unwrap(); + } + + #[tokio::test] + async fn parks_400_and_404_without_retry_or_loss() { + for code in [400, 404] { + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(code)) + .expect(1) + .mount(&server) + .await; + let home = temp(); + let relay = Relay::new( + &home, + &Ingest { + url: server.uri(), + key: "test".into(), + }, + ) + .unwrap(); + let file = batch(&relay, b"unchanged"); + let original = fs::read(&file).unwrap(); + relay.deliver(&file, &shutdown()).await; + assert!(!file.exists()); + let parked = fs::read_dir(&relay.failed) + .unwrap() + .next() + .unwrap() + .unwrap() + .path(); + assert!( + parked + .to_str() + .unwrap() + .contains(&format!(".a1.c{code}.jsonl")) + ); + assert_eq!(fs::read(&parked).unwrap(), original); + fs::remove_dir_all(home).unwrap(); + } + } + + #[tokio::test] + async fn busy_port_exits_cleanly_without_supervisor_failure() { + let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) + .await + .unwrap(); + let port = listener.local_addr().unwrap().port(); + let home = temp(); + let relay = Arc::new( + Relay::new( + &home, + &Ingest { + url: "http://127.0.0.1:1".into(), + key: "test".into(), + }, + ) + .unwrap(), + ); + assert!(listen(relay.clone(), port, shutdown()).await.is_ok()); + assert!( + fs::read_to_string(&relay.health_file) + .unwrap() + .contains("port_busy") + ); + fs::remove_dir_all(home).unwrap(); + } + + #[tokio::test] + async fn loopback_receiver_limits_and_acknowledges_only_durable_batches() { + let home = temp(); + let relay = Arc::new( + Relay::new( + &home, + &Ingest { + url: "http://127.0.0.1:1".into(), + key: "test".into(), + }, + ) + .unwrap(), + ); + let reserved = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) + .await + .unwrap(); + let port = reserved.local_addr().unwrap().port(); + drop(reserved); + let flag = Arc::new(AtomicBool::new(false)); + let task = tokio::spawn(listen( + relay.clone(), + port, + Shutdown::for_test(flag.clone()), + )); + let client = reqwest::Client::new(); + let url = format!("http://127.0.0.1:{port}/v1/traces"); + let mut response = None; + for _ in 0..30 { + match client + .post(&url) + .header("Content-Type", "application/x-protobuf") + .body(vec![0, 255, 1]) + .send() + .await + { + Ok(r) => { + response = Some(r); + break; + } + Err(_) => tokio::time::sleep(Duration::from_millis(10)).await, + } + } + assert_eq!(response.unwrap().status(), 200); + let stored = fs::read_dir(&relay.spool) + .unwrap() + .next() + .unwrap() + .unwrap() + .path(); + assert!(fs::read(stored).unwrap().ends_with(&[0, 255, 1])); + assert_eq!( + client + .post(url) + .body(vec![0; MAX_BODY_BYTES + 1]) + .send() + .await + .unwrap() + .status(), + 413 + ); + flag.store(true, std::sync::atomic::Ordering::Relaxed); + task.await.unwrap().unwrap(); + fs::remove_dir_all(home).unwrap(); + } + + #[test] + fn protobuf_partial_success_and_retry_dates() { + assert_eq!(rejected_count(&[10, 2, 8, 3], "application/x-protobuf"), 3); + assert_eq!(rejected_count(&[], "application/x-protobuf"), 0); + let mut headers = HeaderMap::new(); + headers.insert( + "retry-after", + httpdate::fmt_http_date(SystemTime::now() + Duration::from_secs(10)) + .parse() + .unwrap(), + ); + assert!(retry_after(&headers).unwrap() >= Duration::from_secs(8)); + } + + #[test] + fn per_agent_overrides_and_relay_are_opt_in() { + let settings: crate::config::Settings = serde_json::from_value(serde_json::json!({ + "sessions": true, "agents": { "claude": { "sessions": false }, "codex": {} } + })) + .unwrap(); + assert!(!settings.sessions_for("claude")); + assert!(!settings.sessions_for("claude-subagent")); + assert!(settings.sessions_for("codex")); + assert!(!settings.otlp.enabled); + assert_eq!(settings.otlp.port, 4318); + } +} diff --git a/docs/agenteye/opentelemetry.mdx b/docs/agenteye/opentelemetry.mdx new file mode 100644 index 000000000..adf5237e9 --- /dev/null +++ b/docs/agenteye/opentelemetry.mdx @@ -0,0 +1,130 @@ +--- +title: "OpenTelemetry" +description: "Send coding-agent and application telemetry to FailproofAI Cloud with OTLP." +icon: "radio" +--- + +OpenTelemetry lets you send agent traces, logs, and metrics to FailproofAI Cloud using the standard OTLP/HTTP protocol. It is optional. Your existing FailproofAI integrations continue working without enabling it. + +## Turn on ingest for your organisation + +An organisation admin opens **Settings → OpenTelemetry** and turns on ingest. Use a Cloud API key with `events:add` permission, the same permission used for agent reporting. + +If ingest is off, Cloud refuses OTLP exports. Your local setup can still be prepared; run `failproofai otel status` to check the organisation switch before starting an agent. + +## Connect a coding agent + +Connect your machine with `failproofai config`, then choose an agent: + +```bash +failproofai otel enable claude +failproofai otel enable codex +failproofai otel enable gemini +failproofai otel enable copilot +``` + +The setup command chooses Cloud or a local authenticated relay as appropriate. Gemini CLI and Copilot use the local relay automatically. Start your configured FailproofAI daemon before using those integrations. + +If FailproofAI already sends an agent's session transcripts, setup asks whether to switch that agent to OTEL. The default is No. Accepting stops transcript uploads for that agent; it does not change policy enforcement or reporting from other agents. + +## Connect an application + +Use your application's OpenTelemetry instrumentation with these environment variables: + +```bash +export OTEL_EXPORTER_OTLP_ENDPOINT=https://app.befailproof.ai +export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf +export OTEL_EXPORTER_OTLP_HEADERS='Authorization=Bearer%20' +export OTEL_SERVICE_NAME=my-agent +``` + +Replace `` with your API key. Keep the secret out of shell history, screen recordings, and committed configuration. For a configured machine, `failproofai otel env --service my-agent` prints the block with its existing key. Its output contains a credential. + +Cloud accepts OTLP/HTTP exports for traces, logs, and metrics, as protobuf or JSON, including gzip-compressed requests. It does not accept OTLP/gRPC. + +## Capture prompt text + + +Text capture can include prompts, tool arguments, and other sensitive content. `failproofai otel enable` turns it on. Add `--no-content` to keep prompt and tool-detail capture off. + + +```bash +failproofai otel enable claude --no-content +failproofai otel enable codex --no-content +``` + +For an agent you configure manually, use its own content-capture controls: + + + + Add these variables to your agent's environment alongside the application block above: + + ```bash + export CLAUDE_CODE_ENABLE_TELEMETRY=1 + export OTEL_LOGS_EXPORTER=otlp + export OTEL_METRICS_EXPORTER=otlp + export OTEL_LOG_USER_PROMPTS=1 + export OTEL_LOG_TOOL_DETAILS=1 + ``` + + Set both content variables to `0` to avoid capturing prompts and tool details. + + + In Codex's user configuration, use its OTEL settings: + + ```toml + [otel] + log_user_prompt = true + exporter = { otlp-http = { endpoint = "https://app.befailproof.ai/v1/logs", protocol = "json", headers = { Authorization = "Bearer " } } } + trace_exporter = { otlp-http = { endpoint = "https://app.befailproof.ai/v1/traces", protocol = "json", headers = { Authorization = "Bearer " } } } + metrics_exporter = { otlp-http = { endpoint = "https://app.befailproof.ai/v1/metrics", protocol = "json", headers = { Authorization = "Bearer " } } } + ``` + + Codex uses the complete endpoint you provide. Set `log_user_prompt` to `false` to leave prompts out. + + + Use Gemini's telemetry settings with the local relay: + + ```json + { + "telemetry": { + "enabled": true, + "target": "local", + "otlpEndpoint": "http://127.0.0.1:4318", + "otlpProtocol": "http", + "useCollector": true, + "logPrompts": true + } + } + ``` + + Run `failproofai otel enable gemini` to enable the authenticated relay. Set `logPrompts` to `false` for no prompt capture. + + + Use these VS Code user settings: + + ```json + { + "github.copilot.chat.otel.enabled": true, + "github.copilot.chat.otel.exporterType": "otlp-http", + "github.copilot.chat.otel.otlpEndpoint": "http://127.0.0.1:4318", + "github.copilot.chat.otel.captureContent": true + } + ``` + + Run `failproofai otel enable copilot` to enable the authenticated relay. Set `captureContent` to `false` to omit content. + + + +## Check or undo setup + +```bash +failproofai otel status +failproofai otel disable all +``` + +Status distinguishes settings managed by FailproofAI from telemetry you configured elsewhere. Disable restores the previous managed settings and the agent's previous transcript-upload choice. + + + Command options, local relay setup, and troubleshooting. + diff --git a/docs/docs.json b/docs/docs.json index ec8079b32..50ddf63e7 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -122,6 +122,7 @@ "group": "Plug in your agent", "pages": [ "start/integrations", + "agenteye/opentelemetry", "start/integrations/custom-agents", "start/integrations/langchain", "start/integrations/crewai", @@ -241,6 +242,7 @@ "expanded": false, "pages": [ "reference/failproof-cli", + "reference/otel", "reference/local-dashboard", "policies/local-configuration", "reference/cloud-cli", diff --git a/docs/reference/failproof-cli.mdx b/docs/reference/failproof-cli.mdx index e9eabfd50..fc30731e0 100644 --- a/docs/reference/failproof-cli.mdx +++ b/docs/reference/failproof-cli.mdx @@ -58,6 +58,7 @@ Run `failproofai` without arguments to open the local policy dashboard. | `failproofai audit --status` | Show the report address, interval, and next scheduled scan | | `failproofai audit --no-schedule` | Stop recurring scans without deleting audit history | | `failproofai harness list` | List extra capture paths | +| `failproofai otel status` | Inspect optional [OpenTelemetry reporting](/reference/otel) | | `failproofai jev --url --key-stdin` | Set Jev up in one step; the provider is taken from the URL's host | | `failproofai jev setup --provider --key-stdin` | Let [Jev](/reference/jev-providers) judge tool calls through your own endpoint and key | | `failproofai jev setup --provider failproofai` | Let Jev judge tool calls [through FailproofAI Cloud](/reference/jev-cloud), with this machine's Cloud key | diff --git a/docs/reference/otel.mdx b/docs/reference/otel.mdx new file mode 100644 index 000000000..462d2d303 --- /dev/null +++ b/docs/reference/otel.mdx @@ -0,0 +1,67 @@ +--- +title: "OpenTelemetry CLI" +description: "Opt in to OTEL for coding agents, inspect delivery, and restore your previous settings." +icon: "radio" +--- + +`failproofai otel` configures optional OpenTelemetry reporting to FailproofAI Cloud. First connect your machine with `failproofai config` and ask an organisation admin to enable **Settings → OpenTelemetry**. + +## Enable an agent + +```bash +failproofai otel enable claude +failproofai otel enable codex --local +failproofai otel enable gemini --no-content +failproofai otel enable copilot +failproofai otel enable all --yes +``` + +The supported agents are `claude`, `codex`, `gemini`, and `copilot` (VS Code). If VS Code has not been launched on the machine, setup skips Copilot and tells you why. + +`--local` sends to the local relay instead of directly to Cloud. Gemini CLI and Copilot use the relay automatically because their settings cannot store the authentication header. + +The relay listens only on your machine. Your configured FailproofAI daemon picks up the change within seconds; you do not need to restart it. If another application owns the relay port, status reports it as off and the daemon keeps running. + + +Text capture is on by default. Add `--no-content` to omit prompts and tool details. Agent versions can expose different content controls; review what your agent exports before using it with sensitive data. + + +### Choose one reporting path + +If transcript reporting is on for an agent, setup asks: + +> failproofai already sends Claude Code sessions to Cloud. Send them with OTEL instead? Transcript upload for Claude Code stops. + +The default is No. `--yes` accepts the switch without a prompt. Other agents and policy enforcement are unaffected. + +## Inspect setup + +```bash +failproofai otel status +``` + +Status shows every agent, whether its settings are managed by FailproofAI, its destination, the organisation's ingest switch, relay state, and the last relay upload result. + +`unknown` for the organisation switch means the server did not provide it or could not be reached. It is not an error and does not prevent configuration on an older Cloud version. + +If Cloud does not support OTEL yet, the relay retains rejected exports instead of discarding them. If organisation ingest is off, ask an admin to turn it on before starting your agents. + +## Print configuration for an application + +```bash +failproofai otel env --service my-agent +failproofai otel env --service my-agent --local +``` + +The output is a shell environment block for an OTLP/HTTP exporter. Cloud output includes the configured API key; treat it as a secret. Local output omits the key. To use local output, first enable a local agent so the relay is running. + +## Restore previous settings + +```bash +failproofai otel disable claude +failproofai otel disable all +``` + +Disable restores only the settings FailproofAI wrote, keeps unrelated changes, and restores each agent's prior transcript-reporting choice. When the last managed local agent is disabled, the relay stops accepting exports. + +Every command supports `--help`. See [OpenTelemetry in Cloud](/agenteye/opentelemetry) for application and coding-agent examples. From 240aec6d43327e2cdcb854e3098de2dbb9e906d2 Mon Sep 17 00:00:00 2001 From: Chetan Raghuvanshi <145042127+chhhee10@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:33:45 +0000 Subject: [PATCH 003/115] docs(otel): link changelog to draft PR 880 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a20f6cd3..af796d156 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Features -- Add opt-in coding-agent OpenTelemetry setup, reversible per-agent transcript switching, and a loopback OTLP relay (PR pending). +- Add opt-in coding-agent OpenTelemetry setup, reversible per-agent transcript switching, and a loopback OTLP relay (#880). ## 1.0.10 — 2026-10-05 From aa5c771e78441f561017b87b07ab6598e7e14f48 Mon Sep 17 00:00:00 2001 From: Chetan Raghuvanshi <145042127+chhhee10@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:37:43 +0000 Subject: [PATCH 004/115] fix(ci): update vulnerable existing build and SDK test dependencies --- CHANGELOG.md | 4 + bun.lock | 81 +++---- package.json | 5 +- sdk/python/uv.lock | 397 +++++++++++++++++++------------ sdk/typescript/package-lock.json | 6 +- 5 files changed, 302 insertions(+), 191 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af796d156..6d77b78c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - Add opt-in coding-agent OpenTelemetry setup, reversible per-agent transcript switching, and a loopback OTLP relay (#880). +### Dependencies + +- Update vulnerable existing build and SDK test lockfile dependencies reported by the PR's OSV scan (#880). + ## 1.0.10 — 2026-10-05 Action needed if you use `instruct` policies with Hermes: an `instruct` no longer stops the call it reminds about. The call runs and the reminder is placed first in its result, so a policy written to prevent an action (for example, a direct API write that should go through an approved script) must become a `deny` before you upgrade. Hermes 0.20.0 is supported again: on 1.0.9 the plugin loaded there with no hooks and every tool call ran unchecked. After upgrading, run `failproofai update` and restart each Hermes gateway profile by name. Collects 1.0.10-beta.0 to beta.1 below. diff --git a/bun.lock b/bun.lock index 1a88d0312..ccf6a2cf9 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ "eslint-config-next": "^16.3.2", "jsdom": "^30.0.1", "lucide-react": "^1.33.0", - "next": "^16.3.2", + "next": "16.3.8", "react": "^19.2.4", "react-dom": "^19.2.4", "tailwind-merge": "^3.4.0", @@ -45,7 +45,8 @@ "eslint-plugin-react-hooks": "7.0.1", "nanoid": "3.3.18", "postcss": "8.5.26", - "sharp": "0.35.4", + "sharp": "0.35.5", + "source-map-js": "1.2.2", "undici": "7.29.1", "vite": "8.0.16", }, @@ -146,57 +147,57 @@ "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], - "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.3" }, "os": "darwin", "cpu": "arm64" }, "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA=="], + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg=="], - "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.3" }, "os": "darwin", "cpu": "x64" }, "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw=="], + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.4" }, "os": "darwin", "cpu": "x64" }, "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw=="], - "@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.4", "", { "dependencies": { "@img/sharp-wasm32": "0.35.4" }, "os": "freebsd" }, "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA=="], + "@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.5", "", { "dependencies": { "@img/sharp-wasm32": "0.35.5" }, "os": "freebsd" }, "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw=="], - "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg=="], + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw=="], - "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw=="], + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ=="], - "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA=="], + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.4", "", { "os": "linux", "cpu": "arm" }, "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw=="], - "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A=="], + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A=="], - "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w=="], + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ=="], - "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.3", "", { "os": "linux", "cpu": "none" }, "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ=="], + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.4", "", { "os": "linux", "cpu": "none" }, "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q=="], - "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q=="], + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ=="], - "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA=="], + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw=="], - "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw=="], + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA=="], - "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw=="], + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg=="], - "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.3" }, "os": "linux", "cpu": "arm" }, "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A=="], + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.4" }, "os": "linux", "cpu": "arm" }, "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ=="], - "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.3" }, "os": "linux", "cpu": "arm64" }, "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ=="], + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.4" }, "os": "linux", "cpu": "arm64" }, "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ=="], - "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.3" }, "os": "linux", "cpu": "ppc64" }, "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ=="], + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA=="], - "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.3" }, "os": "linux", "cpu": "none" }, "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA=="], + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.4" }, "os": "linux", "cpu": "none" }, "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ=="], - "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.3" }, "os": "linux", "cpu": "s390x" }, "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ=="], + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.4" }, "os": "linux", "cpu": "s390x" }, "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA=="], - "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.3" }, "os": "linux", "cpu": "x64" }, "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA=="], + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.4" }, "os": "linux", "cpu": "x64" }, "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog=="], - "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" }, "os": "linux", "cpu": "arm64" }, "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA=="], + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" }, "os": "linux", "cpu": "arm64" }, "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg=="], - "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.3" }, "os": "linux", "cpu": "x64" }, "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg=="], + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.4" }, "os": "linux", "cpu": "x64" }, "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A=="], - "@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.4", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA=="], + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.5", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ=="], - "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.4", "", { "dependencies": { "@img/sharp-wasm32": "0.35.4" }, "cpu": "none" }, "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA=="], + "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.5", "", { "dependencies": { "@img/sharp-wasm32": "0.35.5" }, "cpu": "none" }, "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA=="], - "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw=="], + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q=="], - "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw=="], + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A=="], - "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.4", "", { "os": "win32", "cpu": "x64" }, "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg=="], + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.5", "", { "os": "win32", "cpu": "x64" }, "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], @@ -216,25 +217,25 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], - "@next/env": ["@next/env@16.3.6", "", {}, "sha512-x9Vblze1EbtltQYnNH38xCPWU3TVfBd1eXqA3+w9+BTpedkkdNpAaltXlGQ/nsc1+E0mVTNrtcbX3GoO09zeLQ=="], + "@next/env": ["@next/env@16.3.8", "", {}, "sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw=="], "@next/eslint-plugin-next": ["@next/eslint-plugin-next@16.3.6", "", { "dependencies": { "@eslint-community/eslint-utils": "4.9.1", "fast-glob": "3.3.1" } }, "sha512-jowwDX+7DOlDIjJLgTMxudw+k37QnWu1JkZLkSi9MaJBfDYcfhAPMKBhXL0idYzFN/AGg//axnOR4cLkHX/Rng=="], - "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-E/7GEqaUkt8mk/T8v9lAnrhzR06kdq1ZBkC12F8tAMkdIadwNp3H1KqHynDHrpcTlGCUdq/qu6vUL2aYVyYBdw=="], + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA=="], - "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-yBE893/nDWTlaiBD1p+qgt7NUen4U5R6FXyH0s67Npq1S3E0cVSef1WIXC2xBRgQvwAvJq6DnS6Y6PrY0cy4Ew=="], + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q=="], - "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.3.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-KJDpjBqBPYlvkivmyrp+Qys6k/7ksbqGQvRVc6ZEGfR+cjQxx+nUkJaWmNZJsmoOrqYNbaXByF8wa0lBwDhB3Q=="], + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.3.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA=="], - "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.3.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-mqNg2K+hvWskSRb/QM+Ix412DvBsuSF0XV+frTSw5vmoucNnIlynFwKYew8D01bfATErMOM7Bujrf0BA5DRKFA=="], + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.3.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg=="], - "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.3.6", "", { "os": "linux", "cpu": "x64" }, "sha512-nFncBNGAYouRHjRVaITs9beZRfhX4ssVwpnvPIAbkZVH6LtGoAVlH4bJ8Cnf9SOo9bsXgPFer/GdHtEE3JNOkw=="], + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.3.8", "", { "os": "linux", "cpu": "x64" }, "sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ=="], - "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.6", "", { "os": "linux", "cpu": "x64" }, "sha512-5Mf3cHDGR/Iz0ng2Bj3zUR3p5QS9YK3Hn2QiAfavFmyF48zwThAjpFoiTKNIcOHLYS4zEk+gzyJ/9deQ2ZB8yQ=="], + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.8", "", { "os": "linux", "cpu": "x64" }, "sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA=="], - "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-0jkJy0C2kbrJWTk4YLa3xk80pVBpx8FCHJym7CnUfDAXe/FWv5qT7SQJbR0KuemyxaEDlEx5WT4VQJoTW+/9Qw=="], + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw=="], - "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.6", "", { "os": "win32", "cpu": "x64" }, "sha512-/YXjI1e5OXcZ7YpxRwgP/1jAV/SBKTzeVKqN2mk7mLpcICsyn3Gl5+dIfDTJp70M0ccMhyMMRso4v6mPDCGepg=="], + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.8", "", { "os": "win32", "cpu": "x64" }, "sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], @@ -982,7 +983,7 @@ "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], - "next": ["next@16.3.6", "", { "dependencies": { "@next/env": "16.3.6", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.6", "@next/swc-darwin-x64": "16.3.6", "@next/swc-linux-arm64-gnu": "16.3.6", "@next/swc-linux-arm64-musl": "16.3.6", "@next/swc-linux-x64-gnu": "16.3.6", "@next/swc-linux-x64-musl": "16.3.6", "@next/swc-win32-arm64-msvc": "16.3.6", "@next/swc-win32-x64-msvc": "16.3.6", "sharp": "^0.35.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-L+otWM/aQbYTx98aZhgEoMb4bZAXx1YVW4UMA/vuCyCoWG5HJyZUili8QAkqzrcC+5///tsz3s0M+SlyB5bLMw=="], + "next": ["next@16.3.8", "", { "dependencies": { "@next/env": "16.3.8", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.8", "@next/swc-darwin-x64": "16.3.8", "@next/swc-linux-arm64-gnu": "16.3.8", "@next/swc-linux-arm64-musl": "16.3.8", "@next/swc-linux-x64-gnu": "16.3.8", "@next/swc-linux-x64-musl": "16.3.8", "@next/swc-win32-arm64-msvc": "16.3.8", "@next/swc-win32-x64-msvc": "16.3.8", "sharp": "^0.35.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw=="], "node-exports-info": ["node-exports-info@1.6.0", "", { "dependencies": { "array.prototype.flatmap": "^1.3.3", "es-errors": "^1.3.0", "object.entries": "^1.1.9", "semver": "^6.3.1" } }, "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw=="], @@ -1106,7 +1107,7 @@ "set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="], - "sharp": ["sharp@0.35.4", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.4", "@img/sharp-darwin-x64": "0.35.4", "@img/sharp-freebsd-wasm32": "0.35.4", "@img/sharp-libvips-darwin-arm64": "1.3.3", "@img/sharp-libvips-darwin-x64": "1.3.3", "@img/sharp-libvips-linux-arm": "1.3.3", "@img/sharp-libvips-linux-arm64": "1.3.3", "@img/sharp-libvips-linux-ppc64": "1.3.3", "@img/sharp-libvips-linux-riscv64": "1.3.3", "@img/sharp-libvips-linux-s390x": "1.3.3", "@img/sharp-libvips-linux-x64": "1.3.3", "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", "@img/sharp-libvips-linuxmusl-x64": "1.3.3", "@img/sharp-linux-arm": "0.35.4", "@img/sharp-linux-arm64": "0.35.4", "@img/sharp-linux-ppc64": "0.35.4", "@img/sharp-linux-riscv64": "0.35.4", "@img/sharp-linux-s390x": "0.35.4", "@img/sharp-linux-x64": "0.35.4", "@img/sharp-linuxmusl-arm64": "0.35.4", "@img/sharp-linuxmusl-x64": "0.35.4", "@img/sharp-webcontainers-wasm32": "0.35.4", "@img/sharp-win32-arm64": "0.35.4", "@img/sharp-win32-ia32": "0.35.4", "@img/sharp-win32-x64": "0.35.4" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA=="], + "sharp": ["sharp@0.35.5", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.5", "@img/sharp-darwin-x64": "0.35.5", "@img/sharp-freebsd-wasm32": "0.35.5", "@img/sharp-libvips-darwin-arm64": "1.3.4", "@img/sharp-libvips-darwin-x64": "1.3.4", "@img/sharp-libvips-linux-arm": "1.3.4", "@img/sharp-libvips-linux-arm64": "1.3.4", "@img/sharp-libvips-linux-ppc64": "1.3.4", "@img/sharp-libvips-linux-riscv64": "1.3.4", "@img/sharp-libvips-linux-s390x": "1.3.4", "@img/sharp-libvips-linux-x64": "1.3.4", "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", "@img/sharp-libvips-linuxmusl-x64": "1.3.4", "@img/sharp-linux-arm": "0.35.5", "@img/sharp-linux-arm64": "0.35.5", "@img/sharp-linux-ppc64": "0.35.5", "@img/sharp-linux-riscv64": "0.35.5", "@img/sharp-linux-s390x": "0.35.5", "@img/sharp-linux-x64": "0.35.5", "@img/sharp-linuxmusl-arm64": "0.35.5", "@img/sharp-linuxmusl-x64": "0.35.5", "@img/sharp-webcontainers-wasm32": "0.35.5", "@img/sharp-win32-arm64": "0.35.5", "@img/sharp-win32-ia32": "0.35.5", "@img/sharp-win32-x64": "0.35.5" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], @@ -1124,7 +1125,7 @@ "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], diff --git a/package.json b/package.json index 32d8fa621..b36778204 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,7 @@ "eslint-config-next": "^16.3.2", "jsdom": "^30.0.1", "lucide-react": "^1.33.0", - "next": "^16.3.2", + "next": "16.3.8", "react": "^19.2.4", "react-dom": "^19.2.4", "tailwind-merge": "^3.4.0", @@ -120,7 +120,8 @@ "vite": "8.0.16", "undici": "7.29.1", "brace-expansion": "5.0.12", - "sharp": "0.35.4", + "sharp": "0.35.5", + "source-map-js": "1.2.2", "browserslist": "4.28.8" } } diff --git a/sdk/python/uv.lock b/sdk/python/uv.lock index 106a304d4..3f4607c88 100644 --- a/sdk/python/uv.lock +++ b/sdk/python/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", @@ -272,7 +272,7 @@ wheels = [ [[package]] name = "banks" -version = "2.5.0" +version = "2.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "deprecated" }, @@ -281,10 +281,11 @@ dependencies = [ { name = "jinja2" }, { name = "platformdirs" }, { name = "pydantic" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6d/f0/ce5b3105a8551fdcedb509ab5340066247b3cd1b28e79f9296be2d6d3bf4/banks-2.5.0.tar.gz", hash = "sha256:fdd4fd54b84dbe31cb51a1173c960697c73d683a52fb0b1d1957a557a8d6fcc8", size = 194585, upload-time = "2026-08-08T15:54:16.548Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/92/0449c32e718c10e4af40a3e040c830a77cdaeec6e9066622ee60e4c93e6e/banks-2.5.1.tar.gz", hash = "sha256:a7ca5c250b605ae90cfd6e4ca5dd6ce2ee377d5d59021b6feba44e345910026d", size = 196398, upload-time = "2026-09-10T10:15:34.569Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/9c/7dc7b15cecc03baa47d413b2599487b1fab270169b00feaa5e74978845f3/banks-2.5.0-py3-none-any.whl", hash = "sha256:8804c58f23e41a5aabe9ecfdf64235cdf1d5f3b16ad95ec2a54b6dc738dc1dfc", size = 38620, upload-time = "2026-08-08T15:54:15.505Z" }, + { url = "https://files.pythonhosted.org/packages/3f/6f/06c0824ee2611de47a0fc731fa57f6042d3f573025d9f504adfd65e50d51/banks-2.5.1-py3-none-any.whl", hash = "sha256:b7c91365626bab4b46197d24ce04cd69540730ed669a50a399f240318ba3dcfb", size = 39462, upload-time = "2026-09-10T10:15:35.606Z" }, ] [[package]] @@ -1025,7 +1026,8 @@ llamaindex = [ { name = "llama-index-core" }, ] pydantic-ai = [ - { name = "pydantic-ai-slim" }, + { name = "pydantic-ai-slim", version = "2.54.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pydantic-ai-slim", version = "2.55.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] [package.metadata] @@ -2079,7 +2081,7 @@ wheels = [ [[package]] name = "langgraph-sdk" -version = "0.4.2" +version = "0.4.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -2088,9 +2090,9 @@ dependencies = [ { name = "orjson" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b4/2b/bd8ac26d4e97f6df88ef05ce5b6a38945a3903e1025d926f4752aa88aa97/langgraph_sdk-0.4.2.tar.gz", hash = "sha256:b88f0f5f6328ac0680d6790614a905b2bcfa257f2276dba4e38f0e86db0aa738", size = 348327, upload-time = "2026-06-01T17:51:19.856Z" } +sdist = { url = "https://files.pythonhosted.org/packages/78/85/54edf4902c0378510f09a009f64e3d3f5644aeeb51d13cf67ec380a5245b/langgraph_sdk-0.4.6.tar.gz", hash = "sha256:ea5b1749d70987d741c9b9fe2b7e1c5f7da7e4d102f64e390333c1890c9bbd5b", size = 350632, upload-time = "2026-10-06T14:17:53.641Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/05/aac507337cceae773c2cc9ab91eb6301963af7aeeb55b4217a00e15aff17/langgraph_sdk-0.4.2-py3-none-any.whl", hash = "sha256:75fa5096c1177ce39c847096a8fe3745ffd480ddb412995f836e9f5f884c43dd", size = 160521, upload-time = "2026-06-01T17:51:18.849Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c7/69d13026db3577c157877dea47a9dc5fffd3dcde2d1ab8009fa8f7e5f6a6/langgraph_sdk-0.4.6-py3-none-any.whl", hash = "sha256:57122b02bda41d37a97a5e09eb018aa8c9b3dc85c433880073779bd9324b7eea", size = 162390, upload-time = "2026-10-06T14:17:52.134Z" }, ] [[package]] @@ -2502,140 +2504,183 @@ wheels = [ [[package]] name = "multidict" -version = "6.7.1" +version = "6.9.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/84/0b/19348d4c98980c4851d2f943f8ebafdece2ae7ef737adcfa5994ce8e5f10/multidict-6.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5", size = 77176, upload-time = "2026-01-26T02:42:59.784Z" }, - { url = "https://files.pythonhosted.org/packages/ef/04/9de3f8077852e3d438215c81e9b691244532d2e05b4270e89ce67b7d103c/multidict-6.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8", size = 44996, upload-time = "2026-01-26T02:43:01.674Z" }, - { url = "https://files.pythonhosted.org/packages/31/5c/08c7f7fe311f32e83f7621cd3f99d805f45519cd06fafb247628b861da7d/multidict-6.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cdea2e7b2456cfb6694fb113066fd0ec7ea4d67e3a35e1f4cbeea0b448bf5872", size = 44631, upload-time = "2026-01-26T02:43:03.169Z" }, - { url = "https://files.pythonhosted.org/packages/b7/7f/0e3b1390ae772f27501199996b94b52ceeb64fe6f9120a32c6c3f6b781be/multidict-6.7.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17207077e29342fdc2c9a82e4b306f1127bf1ea91f8b71e02d4798a70bb99991", size = 242561, upload-time = "2026-01-26T02:43:04.733Z" }, - { url = "https://files.pythonhosted.org/packages/dd/f4/8719f4f167586af317b69dd3e90f913416c91ca610cac79a45c53f590312/multidict-6.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4f49cb5661344764e4c7c7973e92a47a59b8fc19b6523649ec9dc4960e58a03", size = 242223, upload-time = "2026-01-26T02:43:06.695Z" }, - { url = "https://files.pythonhosted.org/packages/47/ab/7c36164cce64a6ad19c6d9a85377b7178ecf3b89f8fd589c73381a5eedfd/multidict-6.7.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a9fc4caa29e2e6ae408d1c450ac8bf19892c5fca83ee634ecd88a53332c59981", size = 222322, upload-time = "2026-01-26T02:43:08.472Z" }, - { url = "https://files.pythonhosted.org/packages/f5/79/a25add6fb38035b5337bc5734f296d9afc99163403bbcf56d4170f97eb62/multidict-6.7.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c5f0c21549ab432b57dcc82130f388d84ad8179824cc3f223d5e7cfbfd4143f6", size = 254005, upload-time = "2026-01-26T02:43:10.127Z" }, - { url = "https://files.pythonhosted.org/packages/4a/7b/64a87cf98e12f756fc8bd444b001232ffff2be37288f018ad0d3f0aae931/multidict-6.7.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7dfb78d966b2c906ae1d28ccf6e6712a3cd04407ee5088cd276fe8cb42186190", size = 251173, upload-time = "2026-01-26T02:43:11.731Z" }, - { url = "https://files.pythonhosted.org/packages/4b/ac/b605473de2bb404e742f2cc3583d12aedb2352a70e49ae8fce455b50c5aa/multidict-6.7.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b0d9b91d1aa44db9c1f1ecd0d9d2ae610b2f4f856448664e01a3b35899f3f92", size = 243273, upload-time = "2026-01-26T02:43:13.063Z" }, - { url = "https://files.pythonhosted.org/packages/03/65/11492d6a0e259783720f3bc1d9ea55579a76f1407e31ed44045c99542004/multidict-6.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dd96c01a9dcd4889dcfcf9eb5544ca0c77603f239e3ffab0524ec17aea9a93ee", size = 238956, upload-time = "2026-01-26T02:43:14.843Z" }, - { url = "https://files.pythonhosted.org/packages/5f/a7/7ee591302af64e7c196fb63fe856c788993c1372df765102bd0448e7e165/multidict-6.7.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:067343c68cd6612d375710f895337b3a98a033c94f14b9a99eff902f205424e2", size = 233477, upload-time = "2026-01-26T02:43:16.025Z" }, - { url = "https://files.pythonhosted.org/packages/9c/99/c109962d58756c35fd9992fed7f2355303846ea2ff054bb5f5e9d6b888de/multidict-6.7.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5884a04f4ff56c6120f6ccf703bdeb8b5079d808ba604d4d53aec0d55dc33568", size = 243615, upload-time = "2026-01-26T02:43:17.84Z" }, - { url = "https://files.pythonhosted.org/packages/d5/5f/1973e7c771c86e93dcfe1c9cc55a5481b610f6614acfc28c0d326fe6bfad/multidict-6.7.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8affcf1c98b82bc901702eb73b6947a1bfa170823c153fe8a47b5f5f02e48e40", size = 249930, upload-time = "2026-01-26T02:43:19.06Z" }, - { url = "https://files.pythonhosted.org/packages/5d/a5/f170fc2268c3243853580203378cd522446b2df632061e0a5409817854c7/multidict-6.7.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0d17522c37d03e85c8098ec8431636309b2682cf12e58f4dbc76121fb50e4962", size = 243807, upload-time = "2026-01-26T02:43:20.286Z" }, - { url = "https://files.pythonhosted.org/packages/de/01/73856fab6d125e5bc652c3986b90e8699a95e84b48d72f39ade6c0e74a8c/multidict-6.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:24c0cf81544ca5e17cfcb6e482e7a82cd475925242b308b890c9452a074d4505", size = 239103, upload-time = "2026-01-26T02:43:21.508Z" }, - { url = "https://files.pythonhosted.org/packages/e7/46/f1220bd9944d8aa40d8ccff100eeeee19b505b857b6f603d6078cb5315b0/multidict-6.7.1-cp310-cp310-win32.whl", hash = "sha256:d82dd730a95e6643802f4454b8fdecdf08667881a9c5670db85bc5a56693f122", size = 41416, upload-time = "2026-01-26T02:43:22.703Z" }, - { url = "https://files.pythonhosted.org/packages/68/00/9b38e272a770303692fc406c36e1a4c740f401522d5787691eb38a8925a8/multidict-6.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cf37cbe5ced48d417ba045aca1b21bafca67489452debcde94778a576666a1df", size = 46022, upload-time = "2026-01-26T02:43:23.77Z" }, - { url = "https://files.pythonhosted.org/packages/64/65/d8d42490c02ee07b6bbe00f7190d70bb4738b3cce7629aaf9f213ef730dd/multidict-6.7.1-cp310-cp310-win_arm64.whl", hash = "sha256:59bc83d3f66b41dac1e7460aac1d196edc70c9ba3094965c467715a70ecb46db", size = 43238, upload-time = "2026-01-26T02:43:24.882Z" }, - { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, - { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, - { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, - { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, - { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, - { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, - { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, - { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, - { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, - { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, - { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, - { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, - { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, - { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, - { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, - { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, - { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, - { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, - { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, - { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, - { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, - { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, - { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, - { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, - { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, - { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, - { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, - { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, - { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, - { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, - { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, - { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, - { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, - { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, - { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, - { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, - { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, - { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, - { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, - { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, - { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, - { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, - { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, - { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, - { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, - { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, - { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, - { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, - { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, - { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, - { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, - { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, - { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, - { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, - { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, - { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, - { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, - { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, - { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, - { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, - { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, - { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, - { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, - { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, - { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, - { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, - { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, - { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, - { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, - { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, - { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, - { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, - { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, - { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, - { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, - { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, - { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, - { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, - { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, - { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, - { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, - { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, - { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, - { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, - { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, - { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, - { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, - { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, - { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, - { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, - { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, - { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, - { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, - { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, - { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, - { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, - { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, - { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, - { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, - { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, - { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, - { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, - { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, - { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, - { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, - { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, - { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, - { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, - { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/d6/99/1d4d69c3512d0ddbfa3a1b69cfd9a151012ab2eb4eabbb096201b1f0b7d8/multidict-6.9.1.tar.gz", hash = "sha256:0f06e60fa190aa7abd0914c2a766736fdc8e9f34878c4346338534b73d1b20e2", size = 182404, upload-time = "2026-09-21T17:59:05.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/1c/fb2114207afc7f00ab9897ae5beeee27de5ec58ea8b9623e879f2c56f003/multidict-6.9.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:aef74e9beabbd6c4aafc091dabff86d046ccf013ce1e4396c0fbb01b4cad9de8", size = 97307, upload-time = "2026-09-21T17:54:14.957Z" }, + { url = "https://files.pythonhosted.org/packages/b7/23/1ab8c7349adbb30c8cf41b88b0794740df8ea9f7de2e382da040a2f5bd8e/multidict-6.9.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1ca5ebe6d454f1e5496cf052386a559f1080bf2de75bf327ebca0a6003b79f19", size = 58830, upload-time = "2026-09-21T17:54:16.261Z" }, + { url = "https://files.pythonhosted.org/packages/e0/89/b845535014a9ad50537ca50204aaf6351c5368822a3acd9da854fb2a5a34/multidict-6.9.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02f6d0c4b70f783305e73f9944d8efe6be1022550f0974ba0ae9d8893c0350fa", size = 57645, upload-time = "2026-09-21T17:54:17.491Z" }, + { url = "https://files.pythonhosted.org/packages/fe/4f/28ebb40a6f8e8aa7be78bcbe4bf9a1ac9518c40ef88accf2d4cd6ed7a7e0/multidict-6.9.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8050e75af7e4c6e2d5260b84eeedb618f3e452e66473432e085b5d1b81429299", size = 301535, upload-time = "2026-09-21T17:54:18.946Z" }, + { url = "https://files.pythonhosted.org/packages/33/51/0de5fc980df34d1ffed9ef68f60ce86c2d4baa3d422598a53f8730c56757/multidict-6.9.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4736d350371337825cac1793c9f7c40701c32a03912548c2a7608e51679cbb96", size = 298796, upload-time = "2026-09-21T17:54:20.52Z" }, + { url = "https://files.pythonhosted.org/packages/55/58/dd86067bdd2e961fdee8ec659fabc50f4e76403616b508714ec2686b0a49/multidict-6.9.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:02fe09dc197b8ae7e355371e51e5dce2f39060cd5a8badf94904527e23a3f188", size = 282392, upload-time = "2026-09-21T17:54:21.86Z" }, + { url = "https://files.pythonhosted.org/packages/2f/52/5464a2675579e3adb48b7cd4707a59da499660f0e22c024bc7281d2d9bdf/multidict-6.9.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ad4528cdce058b684f75fad1faf4a6a6c992fe2f08376370ca66e4ce5916a84a", size = 314538, upload-time = "2026-09-21T17:54:23.366Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/7f2b3fafe064181669157ce872a097b08a0546fb6fd2481e8c5604b47ec3/multidict-6.9.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8885e3808aedbd6725b921fb67dacaae0678933561ddd47c2b01315198c70e2e", size = 315675, upload-time = "2026-09-21T17:54:24.808Z" }, + { url = "https://files.pythonhosted.org/packages/bd/53/f65ed68b96eea2b4f6fa4f9031bdfccd8f3097c429e3d371c545d1c40ca4/multidict-6.9.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3b6e6840421c83ccb60398e333b44f910b0907bb409597685ab2eedd1e22eab", size = 303685, upload-time = "2026-09-21T17:54:26.236Z" }, + { url = "https://files.pythonhosted.org/packages/1a/5f/ade05795c6fb9adfccd61695a0f0509bc793583f589c4b1b87f27215a139/multidict-6.9.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c9648ed33dc8179e4ec04bbc73bd7f0038e1e81217a69467f61f02a78bf07e88", size = 280306, upload-time = "2026-09-21T17:54:27.685Z" }, + { url = "https://files.pythonhosted.org/packages/60/b2/c996349a61e0f43c7c95877943a67476261bf906ff993670afbd33ff5bf4/multidict-6.9.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d9d6544790ba50438a9c1a903c3c4afb1ec8a7832db5518549275b40ef0dd4b1", size = 291691, upload-time = "2026-09-21T17:54:29.004Z" }, + { url = "https://files.pythonhosted.org/packages/0e/87/8b7016d1084854729c80acfae71c5a51ea7e7c9411d66c3e2729772f5723/multidict-6.9.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:1c9f3c25df6c9d3bbae4f6fd3f514b1c5c740a2110f56ccf36069c85417e289c", size = 290357, upload-time = "2026-09-21T17:54:30.491Z" }, + { url = "https://files.pythonhosted.org/packages/7a/3d/801653b6aa9343a7430cecdefa1bf71ff24432224c02f15cc15e47837652/multidict-6.9.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:cf22b43f35b7dbb9f71e8ee2041b5c00029cdfc28ede3a2f31cf8906f9a6c126", size = 305831, upload-time = "2026-09-21T17:54:32.132Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f3/9fc997861920bdbf70db82bdce5c802ec6477754c4bd78cff2898e3c63f9/multidict-6.9.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8ed78ccad4c7b421804f5d524b7740946a7ef75d89dc0dac52e9d7c24c472410", size = 308995, upload-time = "2026-09-21T17:54:33.571Z" }, + { url = "https://files.pythonhosted.org/packages/ce/5f/278b5a2c99d6de27914b49158dd6f2a6481e53359142cc366dff04821a2f/multidict-6.9.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e3b1aa25f01238886a6baed9e13b9a9240ed344346c79ae280ae65e8603b21d5", size = 279240, upload-time = "2026-09-21T17:54:35.025Z" }, + { url = "https://files.pythonhosted.org/packages/3d/db/0922594c601a691eb4365a83f4f92104ef87a9728d8066fa25c99349e754/multidict-6.9.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:bc94a68ea5e18f8e85dc6b522bcb53093f692c8eb62b4837ac047da73956cbe4", size = 305487, upload-time = "2026-09-21T17:54:36.555Z" }, + { url = "https://files.pythonhosted.org/packages/64/cb/07bca86d4a361a87128f3019540d7689800fdcad44e3b3ad08f1f035ed74/multidict-6.9.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:73533644e1f69ea1164d56cc6505f564c6c44072b646b66d67df2d31fed0348c", size = 301997, upload-time = "2026-09-21T17:54:37.903Z" }, + { url = "https://files.pythonhosted.org/packages/e1/e3/99591bf626613ea35779d2460949b45e59e589dc9058d6693c923eccd4e5/multidict-6.9.1-cp310-cp310-win32.whl", hash = "sha256:66987aa68b0f7c2a1cc5f388ca962b8ed92b10f79de38d6d0d8c716154f519d9", size = 51205, upload-time = "2026-09-21T17:54:39.152Z" }, + { url = "https://files.pythonhosted.org/packages/ae/18/10c008aac77297aa618631e8726ece31668f9dc9d9c848e4b5d2e68eb3d3/multidict-6.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:a32b78c1e52ebd8e247bb68300b90b233300d8816faa008ed0713bc539fb6af0", size = 59049, upload-time = "2026-09-21T17:54:40.465Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b1/da73a58bef2613571902a824f1e11185207ba756215c99038f6d220bc167/multidict-6.9.1-cp310-cp310-win_arm64.whl", hash = "sha256:ab64ace1a68682d191d9bedd9d4c939406ad86b1d9f628180410644249ad46c2", size = 54610, upload-time = "2026-09-21T17:54:41.605Z" }, + { url = "https://files.pythonhosted.org/packages/e3/24/3823efc330630a1f132bcd0a9b182ddec3c53f452f551c8e8d3aaf5a2d20/multidict-6.9.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:910d4260512660484c0dc1588a316fbb35a40c081c36fc51d1225351af17cfe4", size = 96739, upload-time = "2026-09-21T17:54:42.815Z" }, + { url = "https://files.pythonhosted.org/packages/6a/69/36331fe1d3aeb0c525a9972d6cf82791075a395aa96f026f060009d691ae/multidict-6.9.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:33fa55b990f81c2927e01399ace0d18926c69d69baa8cdaa819424132fb97987", size = 58514, upload-time = "2026-09-21T17:54:44.064Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ca/5860651f782078ef13f2761c2593f4ba066d76f626b2f30f23a7f838b775/multidict-6.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:369b5aa01b241cd3fea6890bdbb11a1425d87bf1515831500d518f4223e9d72c", size = 57303, upload-time = "2026-09-21T17:54:45.304Z" }, + { url = "https://files.pythonhosted.org/packages/c7/9f/c56e2fa223cb5d182660e1a09eec38880f96b7cd22769582f74447c60418/multidict-6.9.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:803f8b575a71b1b299d677c28db0653459c79b5308874efec813f17b7457c7f1", size = 320076, upload-time = "2026-09-21T17:54:46.826Z" }, + { url = "https://files.pythonhosted.org/packages/59/97/eda4fe0cad51f96096363ed32d3e0f8df28dab00beff4d92adc9db517726/multidict-6.9.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8a65621b98984a62e59403009591b8a5a7736273aefe1cab64cfb85b365cc07", size = 315489, upload-time = "2026-09-21T17:54:48.25Z" }, + { url = "https://files.pythonhosted.org/packages/24/14/10b1ded0a4085a51c3054125f854da4f47ce4fec724a2d2503231b83f3db/multidict-6.9.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:13849a1d4f54c3809ae721e9e83ab28f5ea602f33660cb84eb6ef261eac706c1", size = 296716, upload-time = "2026-09-21T17:54:49.749Z" }, + { url = "https://files.pythonhosted.org/packages/dc/14/04d155dd18528443cb9009f3a9f35f7417773797d072c95104ff17b8b9b7/multidict-6.9.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dd9a137a4a9becda3094f3831cd026380f75f6855e051eefe4c73ade524f1cc3", size = 330244, upload-time = "2026-09-21T17:54:51.26Z" }, + { url = "https://files.pythonhosted.org/packages/8b/90/284d10b3a9e5b312a8ec5b7a175fc7d560eefdc99886320449891a197992/multidict-6.9.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9ae9614c317c50836689ce2dfde07c05fe0b16378562e2221746c3913ede3c80", size = 331288, upload-time = "2026-09-21T17:54:52.595Z" }, + { url = "https://files.pythonhosted.org/packages/27/50/f420de3683f9b047fc5588064d84043ef57a7152ef451b8373dfc7068d22/multidict-6.9.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8f1e362c9352b50ed120f001046fdbb80810c9d56580f4c3fc13bbe30823387", size = 320663, upload-time = "2026-09-21T17:54:53.91Z" }, + { url = "https://files.pythonhosted.org/packages/01/ab/0120a650d7ce4fe167299c13a13cc4ee2dc72458a9c0e9e9330b824b73c9/multidict-6.9.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dd655518f136febd96c05131a76a863e32fc2a1d7acd4e3c959e3ceb77d8345", size = 291139, upload-time = "2026-09-21T17:54:55.313Z" }, + { url = "https://files.pythonhosted.org/packages/b3/3f/c66342f73ee5cd2c9b1dda6cfce89b5f348c48921b8c8b6e59aafbbfe31a/multidict-6.9.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c5d675da8f1cb5650271c8ad5e95c0a3e5a183c105e72d953b12877b1c8d0fd", size = 310044, upload-time = "2026-09-21T17:54:56.745Z" }, + { url = "https://files.pythonhosted.org/packages/05/0d/e44b90d9e77e44ec3473c4c7ac7ea95764d7e8fbf6d5a085ef9bff7ce0c2/multidict-6.9.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:658f90f49cf5af2441cad0a2b801c3ef520471989a1ec55bcb25b255b2ca8d2f", size = 303279, upload-time = "2026-09-21T17:54:58.178Z" }, + { url = "https://files.pythonhosted.org/packages/97/8a/7741f7c23c211fae31ab546f0ba41569dae7671cf7ca30d5afb59e9d92aa/multidict-6.9.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:43124fe172ada86d03ac3c8dc8179091341f6724d5e5d5b160e1587e4cd3761b", size = 326177, upload-time = "2026-09-21T17:54:59.825Z" }, + { url = "https://files.pythonhosted.org/packages/e8/63/0cdbfbbe36316b2ef4b8e011afaca2cf291a73fe4fabf34dd0b22e3ee48a/multidict-6.9.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2f0adc22a4eb31e545221d93fc73a0f6a8cc2379d0f4309f71d1d17ba938b82", size = 323526, upload-time = "2026-09-21T17:55:01.43Z" }, + { url = "https://files.pythonhosted.org/packages/98/05/0c86e9678e78f4596df0ed7f59d2aa755107da3f2a92f1b7fa150e94cbb8/multidict-6.9.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7fc59e9ba821b220944ccfe0f89c9dc4745f6d097569992356eb03869f21e953", size = 290278, upload-time = "2026-09-21T17:55:02.993Z" }, + { url = "https://files.pythonhosted.org/packages/95/8e/71bd7f43c4883cba6c5f96db88bd2311d52e74ad7680be5915938490d56f/multidict-6.9.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:87cc632c88ee5dc80e12681047839304d98ee5c9a708d686505767001c9b8b9a", size = 321142, upload-time = "2026-09-21T17:55:04.48Z" }, + { url = "https://files.pythonhosted.org/packages/2d/00/bf59d6bb22a4c152e4039574c29490caf3c65107715121807b4bd7cde244/multidict-6.9.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b828cf64d62dc09ac183f03c1aeedd164ade96a2ce4934109452edf29de1dd13", size = 319045, upload-time = "2026-09-21T17:55:06.119Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3c/f27f4f045198bf1c4bcd6eb3b6cf06be2dceaceb6e67222b903fce7a6749/multidict-6.9.1-cp311-cp311-win32.whl", hash = "sha256:2c1aeb92eea59d824f004341b26d5e4b47a8a441cf9726769b9a90abf9d0e08f", size = 51309, upload-time = "2026-09-21T17:55:07.531Z" }, + { url = "https://files.pythonhosted.org/packages/91/17/c2064aef64efc007bc89a571f843e5772f27946eb9bc018731bf8016e319/multidict-6.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:5f89dad732280e7a10b74d40b91364f88e13c3f2c08c2ef83a8cd42f7a61af2e", size = 59148, upload-time = "2026-09-21T17:55:08.936Z" }, + { url = "https://files.pythonhosted.org/packages/45/a1/3bab1827edd813dfb90712af5cbb7fc1473ed4d9c871d103df4f4bb950c2/multidict-6.9.1-cp311-cp311-win_arm64.whl", hash = "sha256:5800368526647146978389dfaa46da3356291e9f0fff9a4ef12e8c2bef964a0d", size = 54258, upload-time = "2026-09-21T17:55:10.191Z" }, + { url = "https://files.pythonhosted.org/packages/d9/0d/4b5afb6d3e545c9af0cdfe2db8f6f4c6664568c23d863d888674e447e6a4/multidict-6.9.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:29138fef49828542e859828107e42e50d0e587c513b7eb4b2d92bade2b0860fe", size = 98360, upload-time = "2026-09-21T17:55:11.508Z" }, + { url = "https://files.pythonhosted.org/packages/89/ab/1b9ca66251899981b21138b87da9d5a9c2c81af12b1ea7d19466972f7fe2/multidict-6.9.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:19e31815d41cefc365489e591d105d2baceb2f65aa75d29471fbdbda8651e006", size = 59979, upload-time = "2026-09-21T17:55:12.866Z" }, + { url = "https://files.pythonhosted.org/packages/36/eb/6ae44062466c26c8469ef43f2481a6a48d8cea0587b2d54514ec92e2adfd/multidict-6.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6ed30be8918e18c8bed0a2e8b70639ecf02feb61ed00ca2e41cfcb2a50fa3f42", size = 57736, upload-time = "2026-09-21T17:55:14.223Z" }, + { url = "https://files.pythonhosted.org/packages/b9/5c/a67817593019257a4ac8b0d1b4c426030e637c047b0692ef405439ecea7a/multidict-6.9.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:637f4ae36264bd7b8d9a60193acddc1d735ad52e8ed53a19931ea6d921fea8e5", size = 333097, upload-time = "2026-09-21T17:55:15.591Z" }, + { url = "https://files.pythonhosted.org/packages/90/bf/599ae2e6222822d88a247a8a7ae82fe6fd25d5700757b79603d5edafe6a0/multidict-6.9.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35fc236507fb1b3138f0af5ecd5f94ed752d4d6d826248eae425f86204013eea", size = 334196, upload-time = "2026-09-21T17:55:17.015Z" }, + { url = "https://files.pythonhosted.org/packages/15/10/d8aac5acacbe7f5c117866c776ec26d5f15a868759b6f37ad8e7ed3b5b02/multidict-6.9.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e58952f04772f59f11c6e007471449809a30165188669bca8fdb19dde40a8f24", size = 317465, upload-time = "2026-09-21T17:55:18.412Z" }, + { url = "https://files.pythonhosted.org/packages/19/0a/714f796f7293a8b1c5c3f465a26996231d5450c54e85d64ef1258c091134/multidict-6.9.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d35a4f1c63f07fbb8c8f9946dea98b21eddf6c57421585f71d91864be3ba2a24", size = 345914, upload-time = "2026-09-21T17:55:20.306Z" }, + { url = "https://files.pythonhosted.org/packages/da/b1/e37fbf769c567be277bcf32df6234035a4384677fcc1bd852752be3d6b93/multidict-6.9.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b5f8771aaaed7f80e84a4e471d2f29ab6721e4595075e54d03ae1ed951b2000a", size = 351163, upload-time = "2026-09-21T17:55:22.21Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5b/db08419c1e1f7c9d60cfd2787b2b517d7ae4ebbda8281b48a33eb5141467/multidict-6.9.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:976fd7689d69ec78d67d31d38d396d8adb562f7e8368279f76aed4aa451fa06d", size = 336881, upload-time = "2026-09-21T17:55:23.699Z" }, + { url = "https://files.pythonhosted.org/packages/9b/07/cc9bc8a62651d2d53ab93ee4993b3a71b7cb78eb8ebfc9c757a5b6698617/multidict-6.9.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:95052e8777a86bae87c0bd0b5ab22d809e3d1d02bf69e3e66ddda5ba75a05805", size = 303405, upload-time = "2026-09-21T17:55:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/b6/0c/8e912afafa70e944dbb8bec4b66ca6e008511395278c0d3dd0e89567536a/multidict-6.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1a8adfcaf96f587ab138476eaddef95f29b8a2a8a9afbfea8d2fd62180995d02", size = 324265, upload-time = "2026-09-21T17:55:26.989Z" }, + { url = "https://files.pythonhosted.org/packages/66/6a/62c2af80fb085e6234805017af857b8e913dfac7a55e9c1349c27768c58a/multidict-6.9.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1a53de2772cfb74559df2eb4456ec4eeb908435ec55a84b69370d9d745d62aa8", size = 322085, upload-time = "2026-09-21T17:55:28.732Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6b/35bf801b336fd960811207203ffdcdc24acc3558b3e7ff2e1c914b141e70/multidict-6.9.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f2ce963299d42fa3f22a90adc0fdf174792ffef5ff4c7ffb68260548fb05580", size = 338107, upload-time = "2026-09-21T17:55:30.303Z" }, + { url = "https://files.pythonhosted.org/packages/80/41/495ef65bf5bba29d142d81b3fe1b8154b919bed70e96491b1e93c3c26f0f/multidict-6.9.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5b30ddf7234e611ca877575b62840e6af5977f92f1f9d532eedbb05a44ff8004", size = 339631, upload-time = "2026-09-21T17:55:32.012Z" }, + { url = "https://files.pythonhosted.org/packages/19/bd/057fdff5f4e04dcd40a960e38f77d19d3c4b67dd243ffa5f43718edb29fc/multidict-6.9.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:63ada7ee2e9345695f9e9bc4c65d72222253f07b1ac94fd0e37555cc6f3c7f60", size = 302080, upload-time = "2026-09-21T17:55:33.817Z" }, + { url = "https://files.pythonhosted.org/packages/3f/de/9ace933ee8dad808632523726f42255b09600087219e3d4ead7369820910/multidict-6.9.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3c95601ed98fad3f6e2f8fe809c3b526b0fab31ef525e00a155e227f3d17f58a", size = 341107, upload-time = "2026-09-21T17:55:35.471Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ac/7c1204406097bfc5c283d4a3287d61166807d189917567df4c1318484fc4/multidict-6.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c148e8b596000dd3e4bfe206e70f3e666be18d72032e0012555f2373c52e35d6", size = 333482, upload-time = "2026-09-21T17:55:37.002Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c0/a70c32ea3299ebe00f44533740cb46905717c51faf29bbd3ce8bb5886d9d/multidict-6.9.1-cp312-cp312-win32.whl", hash = "sha256:f9dad513626a33670f17cddc6078e30e311f444c957e8dbfc5b2b4603c8b4edb", size = 52548, upload-time = "2026-09-21T17:55:38.529Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2e/8c9c2591df01e5692ad1bf92febb082a17b1c6c3a19aa8b7ff49988fd989/multidict-6.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:a16a1dc8529f9e734a41c3b856f3eae7ebacdc061dde3f8a844e0c7889c97203", size = 59622, upload-time = "2026-09-21T17:55:39.86Z" }, + { url = "https://files.pythonhosted.org/packages/d0/95/59f4472ec512bc180fd207899594c7803ece96262ff9aaa3a4b6d7da6940/multidict-6.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:361f7206cf341ba94fb015688f5c8b480f8e63bd58a4c14a48aeca7851a241cc", size = 54930, upload-time = "2026-09-21T17:55:41.155Z" }, + { url = "https://files.pythonhosted.org/packages/eb/45/ddf7c76860f5f553a23ad3ca38463ccf5247081618977742c8dc4625355e/multidict-6.9.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:d7bf9e43282d69561618e8a0ea33368d532ebef42f15c096f427090521dd74f3", size = 63266, upload-time = "2026-09-21T17:55:42.676Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d3/f4ae5945ea2de597eaeb4eca3a74c59783ace54482c9b49435442b63efa8/multidict-6.9.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:03d47df72f084f757c1cb771188d5f4e3a805e4abc4d67e32509272343ae9382", size = 55771, upload-time = "2026-09-21T17:55:44.016Z" }, + { url = "https://files.pythonhosted.org/packages/93/7d/15468239920040d01c686e5ae669e6382f7bf31fc3e5e0a6b8f1c3b32d3b/multidict-6.9.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6bc94fe17c3c56e5418f79515b786b101845f70609b0d19d0c1ba13448e5633a", size = 57116, upload-time = "2026-09-21T17:55:45.315Z" }, + { url = "https://files.pythonhosted.org/packages/17/1b/b958f06aac2d8b1e485eb1c105b1b159cbf3868249e5142142451577dad2/multidict-6.9.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8f2973bbd2bebd9d2e0cd6394c1292a1a19ccd56bdcbe1e174059f1a39be5b40", size = 97692, upload-time = "2026-09-21T17:55:46.674Z" }, + { url = "https://files.pythonhosted.org/packages/93/6c/d6cfe18e61010166d7237d7527c775eb9843e7078feadea45b7628751b60/multidict-6.9.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:de7738b8c0bb74c4cc16bbd7fb49fc2bcf6430dba11b3432cd52768ae40933e8", size = 59556, upload-time = "2026-09-21T17:55:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/46/46/9b4c1127cece0289fb207d04aae5116382ddfcb2a4dc8e0cb49a33f3c7b3/multidict-6.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e5ccad4b7bac125722f48d6f862bed3b514d8526deea06316bb72f152cd30a7c", size = 57357, upload-time = "2026-09-21T17:55:49.998Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fc/c18b07100a6064573e49e538d13d47eb2b5221f48080512df78aef54ab04/multidict-6.9.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a49ff5cdb33654cb7d6a3c377aa2a83ddefaa1db31eb10bcf3c180aa84f9af8a", size = 330433, upload-time = "2026-09-21T17:55:51.435Z" }, + { url = "https://files.pythonhosted.org/packages/62/ff/52a0082adeb69656b634609d4fb0ae65456deaae4cca3d5a12656626dc50/multidict-6.9.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b22ff30006a2f28f8bff878fb93413cbe3a4d1fd517c28081d848c90e9cfd2c8", size = 332487, upload-time = "2026-09-21T17:55:52.937Z" }, + { url = "https://files.pythonhosted.org/packages/39/3e/80bd4729635a7af371ff4dcc312594cf96e73f98854491be8740792de430/multidict-6.9.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3adf06c66041aa21eeb8a71e82379b74773298c8e6d3d839b151aae441a99b94", size = 316911, upload-time = "2026-09-21T17:55:54.751Z" }, + { url = "https://files.pythonhosted.org/packages/15/f4/7ea4a907e053e924e2e1694d90560f79f72056504bfd5e8483695f9527d2/multidict-6.9.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:963a8d8f97057082679523d0fd4c53a38f86bc58cabe4556faef682ae53fa2fa", size = 344950, upload-time = "2026-09-21T17:55:56.5Z" }, + { url = "https://files.pythonhosted.org/packages/0d/16/9642ae41fbfbdc546aa481c2b07ae1bc087a94ab5f5020b4d9ab77da401f/multidict-6.9.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b66ccc5c2cdd26e74fa5d4c29ffae424cc6148bf93ce574821783fb3b6d452c5", size = 350264, upload-time = "2026-09-21T17:55:58.073Z" }, + { url = "https://files.pythonhosted.org/packages/93/f2/e06c8e42074d0a4b8419bfe92afbd1d262190b69549ecbcd2dffa4f103c2/multidict-6.9.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fd79c521f6290c69125fa2b85fa65d9e657e6a8ffaf722dc881b926bef4aa5c", size = 336213, upload-time = "2026-09-21T17:55:59.723Z" }, + { url = "https://files.pythonhosted.org/packages/98/43/d7cf9ef4700e7c25244590d1b4f20cf12cbed86beba6c17be4d9e48c1099/multidict-6.9.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d804e4caf5d5da37d6dac1325da5629ebef1e27a294c2b568b295814aa36c7b", size = 302555, upload-time = "2026-09-21T17:56:01.666Z" }, + { url = "https://files.pythonhosted.org/packages/7d/3a/54209920324bc3928f5f2ba28b01a6dd957a9d48c3aaff77e38faaf5668d/multidict-6.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bb0d664505f4b112f384cffeee82e91e3f6448d8e574989479db4439b68cba05", size = 322247, upload-time = "2026-09-21T17:56:03.265Z" }, + { url = "https://files.pythonhosted.org/packages/33/50/df96f961b178b621ab0adbf9221d4fd21534e1064fd9f5e85c1fa27fea55/multidict-6.9.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9e14d17773b1b3c758ff153659a1824608a0cb562c45f484b5ed8a433428444a", size = 321787, upload-time = "2026-09-21T17:56:04.894Z" }, + { url = "https://files.pythonhosted.org/packages/a7/9b/37f354562a8f82f9c1f63d3a94300fc82126d50320175f0702bbd544f87c/multidict-6.9.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:083735b7f395894e43adb278d5dae901448883a835ff8f1977e285fefdb10418", size = 335498, upload-time = "2026-09-21T17:56:06.783Z" }, + { url = "https://files.pythonhosted.org/packages/1e/44/78e366efc6c004185295cc9eb9711c10f13eadf3b547603b9c5526329976/multidict-6.9.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b65121091567847a8cb520d364ab22ba90e00d3cc55fa9eb34bb439f0684bcd1", size = 338244, upload-time = "2026-09-21T17:56:09.162Z" }, + { url = "https://files.pythonhosted.org/packages/ad/2b/ab5bd3964691abe4d14b43bdbdb8a621b77cea2ca352dc93159ec0a4575b/multidict-6.9.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ea999ae6e80e66ad5eea287860951b033d0104ca34d6d87c7b5125ebe0e12721", size = 301641, upload-time = "2026-09-21T17:56:11.227Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a8/f6bc899f5aafaba755edc8e4bb934bc00b1e405f8d3fbc1dd10283738074/multidict-6.9.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:095d900c242e00fbe5f321ee072e7278b4153e78c5ce9c1efde167d62c1e4771", size = 340162, upload-time = "2026-09-21T17:56:13.076Z" }, + { url = "https://files.pythonhosted.org/packages/eb/03/a8fc809ef364b8c231c065ea2ea2f97ae86d4f9a0e36b5721759fd606778/multidict-6.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6441cc837aea58be7d9baef1b2383eb8311ab9303f500f99ac90b584cd78bb14", size = 332826, upload-time = "2026-09-21T17:56:15.175Z" }, + { url = "https://files.pythonhosted.org/packages/f2/27/32dde80245e2024e9bb7a6ca3c2fb7c695dc74014cba557cc35be6f91e24/multidict-6.9.1-cp313-cp313-win32.whl", hash = "sha256:9c4880d017555d70dea367dd49271830842d48e3891c2da97da7ce8c4abcee40", size = 52378, upload-time = "2026-09-21T17:56:16.959Z" }, + { url = "https://files.pythonhosted.org/packages/be/78/1bac2987edac273a6b27fa50e9204bc3466c94a7b4f6a44828d730f8810f/multidict-6.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:ac51cd64bae51c462ea58ad2492c9b8209667a4ef60c45c4a304518b67598d5d", size = 59449, upload-time = "2026-09-21T17:56:18.336Z" }, + { url = "https://files.pythonhosted.org/packages/e1/32/2a77ce19eea48cb9b3521a202b513ba3349aafa702c8317be0b645ffb74b/multidict-6.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:37a9ebe00c698279213d56e6c64e1962ab1e092918270649b397cac3dc196ca4", size = 54713, upload-time = "2026-09-21T17:56:19.774Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0f/c6041015aa2cdc11e1dff0cae58898ae5525d19e35efd65a4ecf3c6ea235/multidict-6.9.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:fc0dcb22fa9aeabfe3fa4e0430099acff985ec5d77a851382f76cc6146e780e5", size = 62336, upload-time = "2026-09-21T17:56:21.181Z" }, + { url = "https://files.pythonhosted.org/packages/23/db/95bd0afb130a0149c955f1f066143528d60864e64f41ff5a1e1313609361/multidict-6.9.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:024123f0ab402ab33828e24eb80fa8f25167d0d3783ba5f287e39ed741e6abf9", size = 54892, upload-time = "2026-09-21T17:56:22.63Z" }, + { url = "https://files.pythonhosted.org/packages/94/cc/8aa34ae8d09498e8da003f485c03371b55cf1bc6d56cff861b3a7de61251/multidict-6.9.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:af1b5a92315048c3e36bbebfa7d4760a9c3e910bc4166f11b20d77d20d6bcfca", size = 56251, upload-time = "2026-09-21T17:56:24.36Z" }, + { url = "https://files.pythonhosted.org/packages/52/48/22baa3b95375096369b04c4348af14139570f302f951773d2c68bf0ccf2e/multidict-6.9.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b2483da477932ad1983d1d33c18bc3771c6fb00cfbaaed70a875fd547ef8e840", size = 96345, upload-time = "2026-09-21T17:56:25.777Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3e/b96779dcac28ec6d491fd821112a0156b519b6701bba186cdf6f6df737f4/multidict-6.9.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:db4d697b18b6ef5528b1f36bfa25072cd2a421869f5963bc0e92c8a34b9e2800", size = 58901, upload-time = "2026-09-21T17:56:27.364Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b4/8e6d950f02ca6ecc00ecf671f2ddb5dab7017671a8d197326f75621f5786/multidict-6.9.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:854fd2f1bc6e8a56b89910b5cd7261a8b40f13ebb31572da985ce59c7da0886d", size = 56491, upload-time = "2026-09-21T17:56:28.901Z" }, + { url = "https://files.pythonhosted.org/packages/ed/8f/212ed7e03282c7bc271e3796bf985b53d5727c2827361e3dd9c5ee6b093f/multidict-6.9.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:03731f6fc036180700c9dc2308205a48e5ca6f3ff03087739ab746f294022201", size = 332297, upload-time = "2026-09-21T17:56:30.495Z" }, + { url = "https://files.pythonhosted.org/packages/a0/97/555aab000e03ecba85c0a1837fd5674d8bf10949490df7800da3a40660ce/multidict-6.9.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989261c5f1735a165f2e4e87cf6d5f17ab734fa18f9ad0383d5adfcdaefce701", size = 329197, upload-time = "2026-09-21T17:56:32.652Z" }, + { url = "https://files.pythonhosted.org/packages/3d/25/f3539b5bdc147beba66015795dfe589eedcc580348fb1a8c69b1df4196b5/multidict-6.9.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4c9e5d05b126b267ac048a89a0e2d9b48b1b648add62d4872906304a8590610", size = 308456, upload-time = "2026-09-21T17:56:34.657Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7d/4665cad8fc326787d218879be879d86dfe32f006c1f88ae47b04d3b52fd5/multidict-6.9.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9d0a21cf76153de8f2d96a877991d6bc59b9ab5180b949e50db73cc193b4a694", size = 343382, upload-time = "2026-09-21T17:56:36.606Z" }, + { url = "https://files.pythonhosted.org/packages/41/01/e1e9abe27f492b22dede492bc423015597412b14ce074f7475f4ecd80185/multidict-6.9.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7386aa18d98d6b8af44b92173654ec469237fda35f8e8523e43b581a86f476a", size = 346848, upload-time = "2026-09-21T17:56:38.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/8d54118bc228e64e1087f1729647b93bbea225eda4a3f914663a6f410bca/multidict-6.9.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b69651732c64afb691e50cdc3387cae305e0eeff8804fe3e3ce203876494932a", size = 333612, upload-time = "2026-09-21T17:56:40.041Z" }, + { url = "https://files.pythonhosted.org/packages/76/e1/9509dafc1fc68948e75842a74533ddfa64af57f2b17810e9cacfa68a829b/multidict-6.9.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1fee9a16d88a1c4865610de31ef5c666671020d7a81d1f510eaf3c97d00ebeba", size = 304246, upload-time = "2026-09-21T17:56:42.313Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b6/96b93808e1bec0b51d49d89859e49b82a94f7b2e58eca0fc44ffcafd3ae5/multidict-6.9.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7814bbee202acd3bd240204c17d8b87a4c48c81064fd8674dbe527d94d5a4290", size = 321983, upload-time = "2026-09-21T17:56:44.399Z" }, + { url = "https://files.pythonhosted.org/packages/d9/1c/08e5184df026b9d8e05a6dce5da22628e46bc4a7c7f03b8bf3c6939a8c46/multidict-6.9.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:95f273bea318a194f656527ee2ed19494327bc500b8e87d9358e3222579aa28d", size = 315927, upload-time = "2026-09-21T17:56:46.208Z" }, + { url = "https://files.pythonhosted.org/packages/d7/5d/9ff39e7387f6fcc9a81af5dbf055fde08149849a04f8df10ca5b086b0d39/multidict-6.9.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:73bcafa21a78d0776b3ee7cd2a63c66f968eb7db8e8d594e32d7329950f6e828", size = 336827, upload-time = "2026-09-21T17:56:48.021Z" }, + { url = "https://files.pythonhosted.org/packages/5a/be/30fbb2cab22203128928066de94be247dc9a3230bf2bfc54437ac3b5f9f7/multidict-6.9.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1a938761c77e0e6edb0c93d02f4e988d44a69e5195e5a3b893e5553311347132", size = 336317, upload-time = "2026-09-21T17:56:49.778Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f0/2480aef6b7d8e7ab89b064ae14f12ec069570dab3a5c13e0155912293247/multidict-6.9.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:37245ca4105386194dd1d292a6f2aae09bfe1bd7ac6f9ec25093e3cf8e9b143b", size = 303258, upload-time = "2026-09-21T17:56:51.829Z" }, + { url = "https://files.pythonhosted.org/packages/4c/04/dbe59cf4ea3778600e8e70e30b31aa9118ad6da2844bc9f717804cbaa000/multidict-6.9.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f0700527dd5bfa8b7204b08330542f4f388899d3c14d885d8a368992e0eb562d", size = 337232, upload-time = "2026-09-21T17:56:53.537Z" }, + { url = "https://files.pythonhosted.org/packages/f4/8c/abe55548f06f12878588f08eb803c5a391283f01243f784cb00adac79231/multidict-6.9.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6b7e54fd883671d1a8810704851c044b7173287c552b9f5c3d9e0eb9f00ae194", size = 330261, upload-time = "2026-09-21T17:56:55.335Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f1/ba67f668478f152bdfa18abe72e7588bd078bf8b42c172fcf542fcf45826/multidict-6.9.1-cp314-cp314-win32.whl", hash = "sha256:e81ae656b9935ac4528a71f96bb7a14d949778ed1897c573d3e7ebb9187f8841", size = 51715, upload-time = "2026-09-21T17:56:57.048Z" }, + { url = "https://files.pythonhosted.org/packages/33/81/014bb2128aedc8157d2848f0ec8a0209c6b822fb88deea012834e1c4a4e8/multidict-6.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:acddcac38adc8342ba48aba98896faa7928854bebb62542362138655b5367ee3", size = 58621, upload-time = "2026-09-21T17:56:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/66/34/8f43c03c2d8821a2320b8c560d8a49d0be2920627edfac3f546922f91969/multidict-6.9.1-cp314-cp314-win_arm64.whl", hash = "sha256:32217133dddc58c927805cf6c0731d8144584176b768042ee886d51e71860bc9", size = 53959, upload-time = "2026-09-21T17:57:00.15Z" }, + { url = "https://files.pythonhosted.org/packages/08/64/4f2a5eeea10b6d42634ba168ec6efc732ef218772e57b8239cd485c202f6/multidict-6.9.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0b2fb8c349d1103863750b5d8cb5ace766917f4b35f3d883c8f778853eaa9f76", size = 107694, upload-time = "2026-09-21T17:57:01.785Z" }, + { url = "https://files.pythonhosted.org/packages/32/ce/c68d08ac2096c1528ff20d77fd10f7e401db3e534ac5a1c7726e36a77c2f/multidict-6.9.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:56d834b74c993a7d7cb2b8ab33a0d55c3e0d4a3d2f2da2808a4ad3d79189711b", size = 64766, upload-time = "2026-09-21T17:57:03.52Z" }, + { url = "https://files.pythonhosted.org/packages/3a/9a/9f5c270c88fe289b4876fe28f9932a70e015a7916aff1e5b0325f7597b11/multidict-6.9.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:783ba7d845d79ce976afd9c1e91a4e5714671defa198ee789e8b23316083a485", size = 62723, upload-time = "2026-09-21T17:57:05.104Z" }, + { url = "https://files.pythonhosted.org/packages/7c/63/c9a0b131354dd949426166cef712d708f436a8cf261114f8d5d2aec440ca/multidict-6.9.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d718fa1b5f0d0dd75e86fbbc5b0c93ea3a5d65216c85c61cd5d7cdddfe08455", size = 290445, upload-time = "2026-09-21T17:57:06.687Z" }, + { url = "https://files.pythonhosted.org/packages/9d/41/ed27d1d20ba91f4e13e8f7dcfaa592614ae0e1d5f45e03171d23c3b098ac/multidict-6.9.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a313bad717dde740959d50850c315b75fd4eb0c5e6b4dc8535db0f1c369be125", size = 304375, upload-time = "2026-09-21T17:57:08.717Z" }, + { url = "https://files.pythonhosted.org/packages/66/6f/a7b26167173c3b73d6e5ebb0856c5154ad005156c3a2fa4062329921819c/multidict-6.9.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ef01d29fca550ab871fd99154f82c6472aacf8e7def272dfb07b460123850390", size = 281746, upload-time = "2026-09-21T17:57:10.713Z" }, + { url = "https://files.pythonhosted.org/packages/34/1d/da27bdd49b0cb84263288f5f48dbd915f2d17fd228fee2078ca607e80277/multidict-6.9.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f3bd290711c6e9486173a6ee7cd4e7f00c3971c7908c1ec1b6e5437c5e4c6f9", size = 311499, upload-time = "2026-09-21T17:57:12.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/36/ab0299a7abf53e38ff75d96c64480f1e2ff8edde985f93f7a1ad5e08bd94/multidict-6.9.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:81cdc537e0a42e3c0170752fcadbe450246d5c3b4b7231d6eb9672456605ac94", size = 313553, upload-time = "2026-09-21T17:57:14.548Z" }, + { url = "https://files.pythonhosted.org/packages/5a/44/e21be702efc9c9ba097c8be874c68860b1cb6cf9c85611263039bf95b37e/multidict-6.9.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8b193bf7a443c97d81c47052f60c486071a4bdef4a573fa2514f920089414d45", size = 299898, upload-time = "2026-09-21T17:57:16.192Z" }, + { url = "https://files.pythonhosted.org/packages/00/8d/e93bc0dc947c7f872e502cfaa7ad9d98f001e1c93cfa6f4940457a6e187a/multidict-6.9.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7a0c98f6a636adf0d7edd60c61589eaf52d149239af754d0bfeb0effedba53a8", size = 273054, upload-time = "2026-09-21T17:57:17.793Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c9/945b44493fbe7af26ffe1e43c506b376daeab070fc1a0923bef9bbf572eb/multidict-6.9.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6e6b7e3a1520c39a2772f414cd9ddf5995f77e4e823dfa1522af383addd465a3", size = 294751, upload-time = "2026-09-21T17:57:19.631Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/0969a9b38b814e5fa3af1b0a57708c49b3d9b3d55c208bac6e98b87bb028/multidict-6.9.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ca65cced0d67a9039e93bcd98a369920e499bf98296844ff56ead01c9085321f", size = 284523, upload-time = "2026-09-21T17:57:21.313Z" }, + { url = "https://files.pythonhosted.org/packages/1d/1e/c32ddf53234c228b1a374ddabca19f542ac7f32752f5155982abe2304833/multidict-6.9.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:372c063f37480f62c1ae32dc3a1a0a5942b180c883f99789075a8a8ec3c4e709", size = 290275, upload-time = "2026-09-21T17:57:23.206Z" }, + { url = "https://files.pythonhosted.org/packages/5d/09/7a99a3daafd987330abb865a2d36e432fd8bf18e790ddda55c5de15eb98f/multidict-6.9.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:827c92145b3b976b39430129c89d213b250dacbe5fce678e9a03940e6e848983", size = 303350, upload-time = "2026-09-21T17:57:25.086Z" }, + { url = "https://files.pythonhosted.org/packages/2e/09/7e6ebaecc4e226d7fe27973c271d58b75c14c24e84c23736d21dc39997dd/multidict-6.9.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9b0124b9c17e9890f0819b2e7a5f65ec9a2f5aabd6c8e7ad090c1675cf70dee6", size = 272232, upload-time = "2026-09-21T17:57:27.193Z" }, + { url = "https://files.pythonhosted.org/packages/1f/6e/420e9e879b21cbdb5036214ee238efabcfab7bf262d3fc124132050f79ff/multidict-6.9.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:23136f5a564654eb61061ec6d5620a4c1ea32c8f552b65e9982a12b72bff601b", size = 300525, upload-time = "2026-09-21T17:57:29.004Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7f/c6b0896850b3ceb190ca302760d63148843371f027c5dde3d1d732343a08/multidict-6.9.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f2524ec55b3e65cbe235a8b3c36e2af3b635be02ed05e20c94e70c5c943c009e", size = 295933, upload-time = "2026-09-21T17:57:30.837Z" }, + { url = "https://files.pythonhosted.org/packages/29/f2/f0ff3384a756227741c72f4ee94cadf1f9900df0ae961e48639f96115b1c/multidict-6.9.1-cp314-cp314t-win32.whl", hash = "sha256:35ba0263bae5dd3ad5aad767cc9afc01a8598c7dae30f1b3b2de98b1b32c28bd", size = 57776, upload-time = "2026-09-21T17:57:32.835Z" }, + { url = "https://files.pythonhosted.org/packages/8d/87/013e1eed30ad46bec1eb68947bc9caf55ba2fd0d6aed04fe964e3b44c23b/multidict-6.9.1-cp314-cp314t-win_amd64.whl", hash = "sha256:7c8d5882ba25ac0282258be435d8a05aa0cbcacfce15799154a338f847f159b9", size = 64881, upload-time = "2026-09-21T17:57:34.489Z" }, + { url = "https://files.pythonhosted.org/packages/7f/95/64dd049bb4c53426e2fee7afb874daaa8b4426c41878785883a94511ecf8/multidict-6.9.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ac348379cf4de5538a0a213be1532d289aa801ec5d267c5909446b9ea2f8e2c3", size = 58582, upload-time = "2026-09-21T17:57:36.19Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2c/26bc1723592cfe965b575652a7749026474b0625b04b04564e1fba265dc5/multidict-6.9.1-cp315-cp315-android_24_x86_64.whl", hash = "sha256:f04551dce5a7db8c9659f2e4245494c182d0663b83661803e08d46bfcae5eda1", size = 62339, upload-time = "2026-09-21T17:57:37.967Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c5/614d2eb9995232d066aae7f17d1d454060b6b5d882b22fbaf6150b05ef1d/multidict-6.9.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:c0c88085affd35c33e124e36930c5ad96aff9294ce195eaa0fd9cec962b64a82", size = 55009, upload-time = "2026-09-21T17:57:39.746Z" }, + { url = "https://files.pythonhosted.org/packages/7f/24/f7179ecdf9cdd3581ace3292c3b883a1a500a374aa4e619bf6eb521c62e5/multidict-6.9.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a8b75dd3d3638d9a19f23e84af4ffab3b8940422c0df2da2a77005ef5aa3d7ea", size = 56273, upload-time = "2026-09-21T17:57:41.544Z" }, + { url = "https://files.pythonhosted.org/packages/ac/72/c9dffcecccd1f8b9a58f960ec4eaecab62c4edb08d5bb984d4de67fe3fdb/multidict-6.9.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:006c4478de0a1876f4834e14255776286f09b9846b505fe63f67f9d173a9487c", size = 96390, upload-time = "2026-09-21T17:57:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3f/76542360e655cecdb7c158c7af88c69c4db0d2aac3421659d41214927229/multidict-6.9.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2784090c30a586d5b45197bd9c32f87fb927216cde302f6cfd76d76577e90f08", size = 58900, upload-time = "2026-09-21T17:57:45.105Z" }, + { url = "https://files.pythonhosted.org/packages/04/15/f28c71af461dd4ff15653a165cfd8ddaf01f3387ff80d2dd0de057d527d0/multidict-6.9.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:7c708566da8014b120a64b1eb6d200c6c0c8cb36296383723cdb6fc82038270b", size = 56540, upload-time = "2026-09-21T17:57:46.913Z" }, + { url = "https://files.pythonhosted.org/packages/19/de/5ee4050c3ddad88c605a89fc1cd18e89abd40a64f183e6fb5276b6eb7f52/multidict-6.9.1-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:042fb0196047e786936a730bd302de83143950da45f2c16078da8f35e1cf7919", size = 331818, upload-time = "2026-09-21T17:57:48.825Z" }, + { url = "https://files.pythonhosted.org/packages/70/e2/2d4e3c3ee70f583334ff236fe6a3babf7a05ce56169a15df65453af647c0/multidict-6.9.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2212a0c842c723d919ea4a22a9296cb6b244b386e4e6ea92adfc7fbf3095519", size = 329800, upload-time = "2026-09-21T17:57:50.91Z" }, + { url = "https://files.pythonhosted.org/packages/d6/14/f64bd05667343a7250cdd99f81bda2270e3959f00832441f10c7f904e1c1/multidict-6.9.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd882aa29bf402b62bf1fd7c19fd5df4b6528cf468a908864b39368572b662a9", size = 312048, upload-time = "2026-09-21T17:57:53.053Z" }, + { url = "https://files.pythonhosted.org/packages/64/bf/a91256ce00199e7544e3eda79c4e9544eb7388c38fa22f11638c2e357ccb/multidict-6.9.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c57541034d12b215ab0a2bfa371d1a8a198da18176d0426c27105b9161a6862d", size = 343676, upload-time = "2026-09-21T17:57:55.069Z" }, + { url = "https://files.pythonhosted.org/packages/14/48/628b978413518159228c2b2ec38645362ceb6427e935d83d716e26490a49/multidict-6.9.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d73fed4e37158ff00cd271871170b79e40138db51e625fd352fa17c6acb34f67", size = 347028, upload-time = "2026-09-21T17:57:57.157Z" }, + { url = "https://files.pythonhosted.org/packages/8c/73/5f9dc2b5ac7dd7a82458ae9cd283b99c185834f23442ace295362dcc5e45/multidict-6.9.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:79da2491348b30810728050b4a8ec0416f85884125c2fd44655b3d01150d9c3e", size = 335670, upload-time = "2026-09-21T17:57:59.126Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/6f319ee000f5ab3fa0055b194ad706fddd552c60c76a21d581c87db1d46c/multidict-6.9.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b4da208a63434d21a3df64d29758e650fc4aa8cb05848554b76949c296539cca", size = 307804, upload-time = "2026-09-21T17:58:01.2Z" }, + { url = "https://files.pythonhosted.org/packages/3f/24/61be5fe1616d033532e8d63dc789c0f35b9793917834713eefe050589ba9/multidict-6.9.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:877ca17fdcfdf5c397493a71e5ff97a87bb181417fe717fdadc77c08c09301ac", size = 323409, upload-time = "2026-09-21T17:58:03.383Z" }, + { url = "https://files.pythonhosted.org/packages/22/a6/eab8aaf59892169a0f4e8e78a5516a6caa6abd8e96e6410111258f4ef0ef/multidict-6.9.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:40aec5299e1ed71fbb988389059da381c3c1a60e0c649acceb2a35d9b128848e", size = 315746, upload-time = "2026-09-21T17:58:05.372Z" }, + { url = "https://files.pythonhosted.org/packages/42/b1/d1e14bab80a74babf6e2ac971a878b84fdcf3a972f3ec3400ca12260fac5/multidict-6.9.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:4a409ccc42aefec904038695d5d7fd6d8f3af2721b7b6401d55135ec7d6d298e", size = 334529, upload-time = "2026-09-21T17:58:07.35Z" }, + { url = "https://files.pythonhosted.org/packages/f5/50/a05b1d36f6d3363a5a334cebbf11624c9c7a69d66fd23345eb53b1d43b7e/multidict-6.9.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:0a5769559e3312dd96731fbe15b4abb6033368ac1cad5a98dadd21946a4c7d6c", size = 336455, upload-time = "2026-09-21T17:58:09.112Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0d/bf33cb097380252b08e1177b5ae0cab822c47ce80d236f3f724953c76721/multidict-6.9.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:03fac50ddfd8302175b77863a015eccfae76767cda5506eef86df559ba861e1f", size = 305865, upload-time = "2026-09-21T17:58:10.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ee/a2bd133391204b1cec5a5caf327dc9a06710439b7403220860a8fb164560/multidict-6.9.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:5ecace251ccfa705bf3d7d35c5032cf750f5c629809740405697bce5c118c4a4", size = 337531, upload-time = "2026-09-21T17:58:12.846Z" }, + { url = "https://files.pythonhosted.org/packages/a8/32/c0972486f81a06c3bbb23063c85afaac53c51608f7e35bcb101a1006408b/multidict-6.9.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:ecc89dbd4155b2f8a47f4bbd89242a35ed15e8e0ec581cab5ac65fa38407329d", size = 332464, upload-time = "2026-09-21T17:58:14.673Z" }, + { url = "https://files.pythonhosted.org/packages/69/4f/5cbbe852b45d8a7f0ab3ed042386e4894e463010eb230603596cb15d0fee/multidict-6.9.1-cp315-cp315-win32.whl", hash = "sha256:a3ffe881246d28a862f1985824f484cb7361f44d6b99c4c620436ef56462f38d", size = 51714, upload-time = "2026-09-21T17:58:16.409Z" }, + { url = "https://files.pythonhosted.org/packages/d4/77/1fa118386deeae4e7a48870fa5cb073e748b0ac5265a6990651cefc48068/multidict-6.9.1-cp315-cp315-win_amd64.whl", hash = "sha256:59c123d0e948d760a5f930f316cfefa07e8d632ab84327c0693ee6a88171154f", size = 58619, upload-time = "2026-09-21T17:58:18.369Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ee/51c45748acfce9cb69d89dcfcd09c874b385407fd6993e885d6905659865/multidict-6.9.1-cp315-cp315-win_arm64.whl", hash = "sha256:31199204b3ced121ff5407a2c342326a5d27e3870abbf94bd80dbd2451b7bc8f", size = 53969, upload-time = "2026-09-21T17:58:20.102Z" }, + { url = "https://files.pythonhosted.org/packages/aa/57/89d2d3dedfae2558853c1770c7cbcdfd5211eb4c4090b1e8cb570544a490/multidict-6.9.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:8879510a76940670517ea1cb589978da44b86e286ec3e50d664ef330817afce7", size = 107679, upload-time = "2026-09-21T17:58:21.98Z" }, + { url = "https://files.pythonhosted.org/packages/c8/df/0c2a28870181c1762f90952b897f6db46c934ce073efe0442604cbc276db/multidict-6.9.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:aec65b53a07f580606593f877eefbb29a45939bfc0d3fe6e6d9f42b41b749f68", size = 64753, upload-time = "2026-09-21T17:58:23.622Z" }, + { url = "https://files.pythonhosted.org/packages/43/7e/9c6a3e7619459407d8958f7e56ab3dda7f0738f5aed9bb4ad48076e8de83/multidict-6.9.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:14c56f73e78faa1f68bbb826197cd5871994e70e841b8590829c35912ece5c64", size = 62732, upload-time = "2026-09-21T17:58:25.46Z" }, + { url = "https://files.pythonhosted.org/packages/38/eb/fd56c9d83ba0cdc3cf66f9acb278b24ef1f043b620b96d145685a3b57338/multidict-6.9.1-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:38c9986f9ce50c459b10de216a05f4bd7ed5ac63887d56e500a52bb464b861ce", size = 288648, upload-time = "2026-09-21T17:58:27.115Z" }, + { url = "https://files.pythonhosted.org/packages/df/ef/51e79c2442b0f56ac4ac580108d713078b31297e028d468fc220971a305d/multidict-6.9.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15db6a102cbaf1949cf028ecf080aac76d20bcd29ad4e092574db6c6b7af78a5", size = 304919, upload-time = "2026-09-21T17:58:29.143Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e4/774700cc5739353fe88a7204ed30e78a9403d98d4bb2496cc91f096d2e71/multidict-6.9.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd82c4977196681a499bb6ca9e462afbc5c91c1c15b6a991dfbd72733fea4dd2", size = 289094, upload-time = "2026-09-21T17:58:30.938Z" }, + { url = "https://files.pythonhosted.org/packages/ca/41/0bec327bc3a8271825376b6d8a469d1f80e3ba1e2e730e019836e29663c3/multidict-6.9.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bb58ba73a3f96f9a3e46b1fab69929d7edbbddb3133ee74b5c3c54074a53c4f1", size = 312007, upload-time = "2026-09-21T17:58:32.964Z" }, + { url = "https://files.pythonhosted.org/packages/c3/7a/9906bf5f1fe20e8e755affccc1a69ecd8aebb079ac65386e5ffc8eefbe75/multidict-6.9.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c6eecfab7ce4cd9487ff8ba936fe38cdfd68c04faf3d5c710363c6a8e695659", size = 314657, upload-time = "2026-09-21T17:58:35.285Z" }, + { url = "https://files.pythonhosted.org/packages/33/92/cf6d665a45663bbe216420fe632da10e9f4f842b07ce02bc3ddf8eb882b8/multidict-6.9.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c62e71e6289d78c0108d8aeb495f8bd3cad4bc1632fedddc9297ddf287ecc20", size = 301563, upload-time = "2026-09-21T17:58:37.472Z" }, + { url = "https://files.pythonhosted.org/packages/97/4c/9f1b7945526905d58b4b2d422e2ea81326dda71f9bdc9c32c8002639234a/multidict-6.9.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:539c2cd5fed0947c135cd7eabaaac55f48300dfa1de0f3ca4edb5efa6606f471", size = 278540, upload-time = "2026-09-21T17:58:39.551Z" }, + { url = "https://files.pythonhosted.org/packages/5c/d9/133aa472fc1be181de29a0c2e1c4cd778280882f532b404395f646742e46/multidict-6.9.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:3100d169ceb7bc8f05f89a6db11d1b21f119975fc26f29dc45a472e3569f0879", size = 294858, upload-time = "2026-09-21T17:58:41.791Z" }, + { url = "https://files.pythonhosted.org/packages/bd/38/21f2305dd20ac5038fa581b5d7e0d8dbbd70dc6b50231c22ec173dacddda/multidict-6.9.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:696477ad71385c4795e3b8e4cf10b0d2c28c2a1ca6a955e031cb1e62993e9ee3", size = 285461, upload-time = "2026-09-21T17:58:43.844Z" }, + { url = "https://files.pythonhosted.org/packages/ad/21/465a43c214d1d7e2d0c974e727d4b5935f8a0868838b5df9d0984c83c1dc/multidict-6.9.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:f5844e7befc707367807586f550fa97e23dcfef0728f02b98c7bc498a961a5df", size = 288964, upload-time = "2026-09-21T17:58:46.16Z" }, + { url = "https://files.pythonhosted.org/packages/2d/58/72a3e8d56c1e05146d2d1841dbaacf8986375eded1e5a58d30eef191a580/multidict-6.9.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:abc7c2e4b47bfe6a9aea434d3fdebb9597ee636e914e92ba352f2068b9142f4c", size = 303924, upload-time = "2026-09-21T17:58:48.395Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b9/911084c04530a682a54536b4f738d3992a593b5ff6fea1d6a1f02f3acc6b/multidict-6.9.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:7ab379f95caee071a37cbd8be86d4c65accc651d391f9f97748fef006f38769c", size = 278479, upload-time = "2026-09-21T17:58:50.494Z" }, + { url = "https://files.pythonhosted.org/packages/45/eb/9a333346c02c928a9e82534638bce85697c10c46b4901fcd70af19357356/multidict-6.9.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:557a4e1708df428ebe6c3081c83a273d275dad2446ce0d81fc648ac71afdb18d", size = 301650, upload-time = "2026-09-21T17:58:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/67/2f/1d936c4e45c8199db6082b6836e9182e31aa393de0de652637790831ca4b/multidict-6.9.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:475d04d5192eba487a3e2f935976340baa24529046e9c1c9c7a3b7bf80445ae1", size = 298024, upload-time = "2026-09-21T17:58:55.03Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0c/ca62ae2882b89a4cfeb0bbca873a4afdb5fc32994c4f8a37a7cb52ede7a0/multidict-6.9.1-cp315-cp315t-win32.whl", hash = "sha256:10083a8a0f4e1b26b599889e90b9802504ce5d3f7722f925bbb7ca47dd22a7c1", size = 57810, upload-time = "2026-09-21T17:58:57.141Z" }, + { url = "https://files.pythonhosted.org/packages/02/8b/b2c2805c3eb6fa4cdf5512fb95ac6174dde71e34dd601c5be52f4df5dc82/multidict-6.9.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e96ca64383efa107262ee3949f047af5ee4f1845ba09463466c04d35a83bd3ec", size = 64984, upload-time = "2026-09-21T17:58:59.446Z" }, + { url = "https://files.pythonhosted.org/packages/fd/34/03f1d204d698c408f4754c969882609b6b7a902c0e3d16a7033f219cdeda/multidict-6.9.1-cp315-cp315t-win_arm64.whl", hash = "sha256:501ed8b02a5990c67a91c732843609d43a6be1f7576fcdfc867331239f37fbd3", size = 58742, upload-time = "2026-09-21T17:59:01.726Z" }, + { url = "https://files.pythonhosted.org/packages/be/59/e26cb779be4c591d1a910f59d29aca9fba4de70349840a833beba2652371/multidict-6.9.1-py3-none-any.whl", hash = "sha256:7bf6478188f4e47bf5686e8a33da4ae28bf43b1b2528d9ee144d28492bfac60b", size = 19176, upload-time = "2026-09-21T17:59:03.501Z" }, ] [[package]] @@ -3906,22 +3951,54 @@ wheels = [ [[package]] name = "pydantic-ai-slim" -version = "2.50.0" +version = "2.54.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11' and sys_platform == 'win32'", + "python_full_version < '3.11' and sys_platform != 'win32'", +] +dependencies = [ + { name = "anyio" }, + { name = "exceptiongroup" }, + { name = "genai-prices" }, + { name = "griffelib" }, + { name = "httpx2" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "pydantic-graph", version = "2.54.0", source = { registry = "https://pypi.org/simple" } }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/54/c0/b5e33b82b05c87a03e2c18089e89671da5c8fb0ba0da143011917d102745/pydantic_ai_slim-2.54.0.tar.gz", hash = "sha256:e39fd5ded83c5f2f1b271f42707de04fba420c4fd2c3bbc809d8d57614b904df", size = 1802066, upload-time = "2026-10-03T03:38:51.982Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/2c/93dbb3ba7d13eea6b1c4af02da6a3ab84ed087747b7e801ab675523b3e59/pydantic_ai_slim-2.54.0-py3-none-any.whl", hash = "sha256:b64d2394f1b38d8b02c9a7916ce7bd331cfc25df41372e68d847fd71801fe112", size = 2115426, upload-time = "2026-10-03T03:38:39.601Z" }, +] + +[[package]] +name = "pydantic-ai-slim" +version = "2.55.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform != 'win32'", +] dependencies = [ { name = "anyio" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "genai-prices" }, { name = "griffelib" }, { name = "httpx2" }, { name = "opentelemetry-api" }, { name = "pydantic" }, - { name = "pydantic-graph" }, + { name = "pydantic-graph", version = "2.55.0", source = { registry = "https://pypi.org/simple" } }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/27/9a/a5a37121adfc6771aefc0d84a41b8fec690ee0951403925cb7cff9452c21/pydantic_ai_slim-2.50.0.tar.gz", hash = "sha256:aa1ab6bfca4590e09e70c9147c4712bdc3e435a6e38d63a4c84a30254b80965f", size = 1630594, upload-time = "2026-09-25T05:03:32.812Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/d6/3526056afb236ac3c01b07ae05f1fefc97cbee59f8e824b96ffd5405bbfe/pydantic_ai_slim-2.55.0.tar.gz", hash = "sha256:50b5ee70d3a0509c1f36bec759249e69f4dc060f1a816b1bbf0730af54297376", size = 1878726, upload-time = "2026-10-09T19:40:52.951Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/91/a91b43280c24f2fb014eed24ce3ac305d87b00721247741109a48803d569/pydantic_ai_slim-2.50.0-py3-none-any.whl", hash = "sha256:50bd9a355d31698f191b50070f5ab9c57faea2a59a5bd67715c1e23aa1d2a3b3", size = 1928462, upload-time = "2026-09-25T05:03:24.765Z" }, + { url = "https://files.pythonhosted.org/packages/93/cc/16c6bc21748e3705a4c1f8e7d71dfc54228dbf142f8506a36689acb1e9c9/pydantic_ai_slim-2.55.0-py3-none-any.whl", hash = "sha256:7fcea628e78c2f247af94504534f0577151496166998d74ec145505040b19c68", size = 2197589, upload-time = "2026-10-09T19:40:38.767Z" }, ] [[package]] @@ -4044,17 +4121,45 @@ wheels = [ [[package]] name = "pydantic-graph" -version = "2.50.0" +version = "2.54.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11' and sys_platform == 'win32'", + "python_full_version < '3.11' and sys_platform != 'win32'", +] +dependencies = [ + { name = "anyio" }, + { name = "logfire-api" }, + { name = "pydantic" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/6f/b5cb10c937c8d00a219eb5a07aeb991d0c44d09d5ac31a087cd1ad40dbcd/pydantic_graph-2.54.0.tar.gz", hash = "sha256:948ff7249c28643ff028b47fab22b8a5b36912c59bb947c2a9b9bc18de1d5fa5", size = 45970, upload-time = "2026-10-03T03:38:55.987Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/31/0d46d4003cb9e452bcf97f56f7c948006b5ad8036fd2ddb6fd7d5c73e8ff/pydantic_graph-2.54.0-py3-none-any.whl", hash = "sha256:465b87d5507af1341ba31b949af8619009c5010068159e52cf9b9806eb674fa4", size = 53423, upload-time = "2026-10-03T03:38:45.385Z" }, +] + +[[package]] +name = "pydantic-graph" +version = "2.55.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform != 'win32'", +] dependencies = [ { name = "anyio" }, { name = "logfire-api" }, { name = "pydantic" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ad/bf/230e4c04be14ed0f9c677210e375e4c65e9dac35781601cacdbde7f9fcce/pydantic_graph-2.50.0.tar.gz", hash = "sha256:15a4ab9594baec4c6ce74e7af1b7742f8e546d79c9896a4fea18c1bd83b2b536", size = 45254, upload-time = "2026-09-25T05:03:35.146Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/22/4e25465059448c4322e5f53f33e7b25ceb404c2d4dda8cfb0f497f7fb54b/pydantic_graph-2.55.0.tar.gz", hash = "sha256:461de7d0c33af634c3adeb21712f527f15878edf826d216184f024795e9c80c1", size = 45824, upload-time = "2026-10-09T19:40:57.238Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/3e/4ae59d3988f342bb8dd4a1f23b7add85cce879dffb75c1ab5ef4be35fc55/pydantic_graph-2.50.0-py3-none-any.whl", hash = "sha256:e43d9742a9924e1e5344fe3cf81a7f8c55d7a524fbc83bec523f284d0f1a31e5", size = 52763, upload-time = "2026-09-25T05:03:28.221Z" }, + { url = "https://files.pythonhosted.org/packages/59/b5/630fc830897cfb0e458dea8ffcac5dd58791784220255637e99817b04cdd/pydantic_graph-2.55.0-py3-none-any.whl", hash = "sha256:774a64db707e759e22a4b2d6f8849986331fac009bb3f36ba3ed0cb8eee7a150", size = 53274, upload-time = "2026-10-09T19:40:44.876Z" }, ] [[package]] diff --git a/sdk/typescript/package-lock.json b/sdk/typescript/package-lock.json index 931b15380..e4d40e44b 100644 --- a/sdk/typescript/package-lock.json +++ b/sdk/typescript/package-lock.json @@ -2250,9 +2250,9 @@ "license": "ISC" }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "peer": true, From df1ee96eed1f844c3388c8bc7b3c9c1a4304e0b3 Mon Sep 17 00:00:00 2001 From: Chetan Raghuvanshi <145042127+chhhee10@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:40:45 +0000 Subject: [PATCH 005/115] docs(otel): clarify metrics acknowledgement in paired server --- docs/agenteye/opentelemetry.mdx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/agenteye/opentelemetry.mdx b/docs/agenteye/opentelemetry.mdx index adf5237e9..d355f6d00 100644 --- a/docs/agenteye/opentelemetry.mdx +++ b/docs/agenteye/opentelemetry.mdx @@ -42,6 +42,8 @@ Replace `` with your API key. Keep the secret out of shell history, screen Cloud accepts OTLP/HTTP exports for traces, logs, and metrics, as protobuf or JSON, including gzip-compressed requests. It does not accept OTLP/gRPC. +Metrics are acknowledged for exporter compatibility but are not stored or displayed. Supported agent traces and coding-agent logs become sessions; fields the agent does not export remain absent. + ## Capture prompt text From 45195ccb2a47e3b798fee277d2f401f47e77ee50 Mon Sep 17 00:00:00 2001 From: chhhee10 Date: Sun, 11 Oct 2026 02:38:47 +0530 Subject: [PATCH 006/115] feat(tui): palette for the 2026-10 screen language Step 1 of the TUI redesign handoff: add the redesign's colour roles to HUES and expose them through paint() and brandAnsi(), so no caller ever hard-codes an escape. - ink3 (#767f8b, labels, heading meta, key hints) and track (#3e434c, the empty part of a progress bar) are the designed hex at 24-bit and the SGR 2 dim attribute below it. - err (#f07178) is the red for failed lines: 203 at the 256 tier, 31 at the basic tier. - ink2 is the identity: descriptions keep the terminal's own foreground. Deviations from the handoff, as agreed in the review (D12): - The handoff suggested fixed 256 greys (248/243/238) and a basic-tier bright black (90). A fixed grey fights every theme that is not ours, and 90 is the background colour in Solarized Dark, so ink3 and track stay the dim attribute below 24-bit, as dim already does. - The designed ink2 #a7adb6 is tuned for dark terminals and measures about 2.2:1 on a light one. Nothing detects the background, so descriptions are never painted. - err uses 203 instead of the suggested 204: 204 is nearest by distance but differs from the brand pink (168) only in its red channel and reads as pink at that tier. Audit cache keys are unchanged (engineVersion 10c367f637740d67, detectorVersion 895c2fedece85e93): the roles are properties on existing objects, so the bundle gains no top-level name. --- CHANGELOG.md | 6 +++++ __tests__/hooks/tui-kit.test.ts | 48 +++++++++++++++++++++++++++++++++ src/hooks/tui.ts | 34 +++++++++++++++++++---- 3 files changed, 83 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1576b070..51707823b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 1.0.11-beta.0 — 2026-10-11 + +### Features + +- New terminal look for the CLI, step one: the palette gains the redesign's label grey, progress-track grey and a red for failures. Descriptions keep the terminal's own colour, and the greys fall back to the dim attribute below 24-bit, so every screen stays readable on light themes (#PR) + ## 1.0.10 — 2026-10-05 Action needed if you use `instruct` policies with Hermes: an `instruct` no longer stops the call it reminds about. The call runs and the reminder is placed first in its result, so a policy written to prevent an action (for example, a direct API write that should go through an approved script) must become a `deny` before you upgrade. Hermes 0.20.0 is supported again: on 1.0.9 the plugin loaded there with no hooks and every tool call ran unchecked. After upgrading, run `failproofai update` and restart each Hermes gateway profile by name. Collects 1.0.10-beta.0 to beta.1 below. diff --git a/__tests__/hooks/tui-kit.test.ts b/__tests__/hooks/tui-kit.test.ts index 1d16893bc..c30e805da 100644 --- a/__tests__/hooks/tui-kit.test.ts +++ b/__tests__/hooks/tui-kit.test.ts @@ -164,6 +164,54 @@ describe("colour tiers", () => { }); }); +describe("the 2026-10 roles", () => { + const INK3_24 = "38;2;118;127;139"; // #767f8b + const TRACK_24 = "38;2;62;67;76"; // #3e434c + const ERR_24 = "38;2;240;113;120"; // #f07178 + + it("paints ink3 and track in their designed greys at 24-bit", () => { + expect(withEnv(TRUECOLOR, () => paint(true).ink3("x"))).toBe(`\x1B[${INK3_24}mx\x1B[0m`); + expect(withEnv(TRUECOLOR, () => paint(true).track("x"))).toBe(`\x1B[${TRACK_24}mx\x1B[0m`); + }); + + it("keeps both greys as the dim ATTRIBUTE below 24-bit, never a fixed colour", () => { + // A cube grey fights every theme that is not ours, and the basic tier's + // bright black (90) IS the background in Solarized Dark. + for (const env of [ANSI256, BASIC]) { + expect(withEnv(env, () => paint(true).ink3("x"))).toBe("\x1B[2mx\x1B[0m"); + expect(withEnv(env, () => paint(true).track("x"))).toBe("\x1B[2mx\x1B[0m"); + } + }); + + it("leaves ink2 in the terminal's own foreground at every tier", () => { + // The designed #a7adb6 is about 2.2:1 on a light ground and nothing detects + // which ground we are on, so descriptions are never painted. + for (const env of [TRUECOLOR, ANSI256, BASIC]) { + expect(withEnv(env, () => paint(true).ink2("Block sudo"))).toBe("Block sudo"); + } + }); + + it("paints err as a red at every tier, and not the brand pink's neighbour at 256", () => { + expect(withEnv(TRUECOLOR, () => paint(true).err("x"))).toBe(`\x1B[${ERR_24}mx\x1B[0m`); + // 204 is nearest by distance but differs from pink's 168 only in red. + expect(withEnv(ANSI256, () => paint(true).err("x"))).toBe("\x1B[38;5;203mx\x1B[0m"); + expect(withEnv(ANSI256, () => paint(true).err("x"))).not.toContain("38;5;204"); + expect(withEnv(BASIC, () => paint(true).err("x"))).toBe("\x1B[31mx\x1B[0m"); + }); + + it("routes the new roles through brandAnsi too, so no caller hard-codes them", () => { + expect(withEnv(TRUECOLOR, () => brandAnsi("err"))).toBe(`\x1B[${ERR_24}m`); + expect(withEnv(ANSI256, () => brandAnsi("ink3"))).toBe("\x1B[2m"); + expect(withEnv(BASIC, () => brandAnsi("track"))).toBe("\x1B[2m"); + }); + + it("emits ZERO escapes for the new roles when colour is off", () => { + const c = withEnv(TRUECOLOR, () => paint(false)); + const painted = [c.ink2("a"), c.ink3("b"), c.track("c"), c.err("d")].join(""); + expect(painted).toBe("abcd"); + }); +}); + describe("the logomark follows the tier", () => { const tty = { isTTY: true, columns: 80, write: vi.fn(() => true) } as unknown as TTYOut; diff --git a/src/hooks/tui.ts b/src/hooks/tui.ts index bbe7fa103..897a63ec6 100644 --- a/src/hooks/tui.ts +++ b/src/hooks/tui.ts @@ -126,11 +126,11 @@ interface Hue { * xterm-256 index for the middle tier, verified as the nearest cube entry by * Euclidean distance rather than eyeballed. * - * Optional because `dim` has no honest answer: its `basic` is SGR 2, an - * ATTRIBUTE that steps down whatever foreground the user's theme is already - * using, and the cube's nearest grey (243, #767676) is a fixed colour that - * fights every theme that is not ours. So dim keeps the attribute at all - * three tiers and this stays undefined. + * Optional because the greys (`dim`, `ink3`, `track`) have no honest answer: + * their `basic` is SGR 2, an ATTRIBUTE that steps down whatever foreground the + * user's theme is already using, and the cube's nearest grey (243, #767676) + * is a fixed colour that fights every theme that is not ours. So a grey keeps + * the attribute below 24-bit and this stays undefined. */ c256?: number; basic: string; @@ -149,6 +149,23 @@ const HUES = { pink: { rgb: [228, 88, 125], c256: 168, basic: "95" }, // #e4587d — selection, enabled, the mark, the brand warn: { rgb: [227, 179, 65], c256: 179, basic: "33" }, dim: { rgb: [107, 118, 132], basic: "2" }, + // The 2026-10 screen language's two greys. Both are the designed hex at 24-bit + // and the SGR 2 ATTRIBUTE below it, for the reason `dim` gives above: a fixed + // cube grey fights every theme that is not ours, and the basic tier's "bright + // black" (90) is the BACKGROUND colour in Solarized Dark, so a label drawn in + // it would vanish. + // + // There is no `ink2`: descriptions use the terminal's own foreground at every + // tier. The designed #a7adb6 is tuned for a dark ground, nothing here can tell + // which ground it is on, and on a light one it measures about 2.2:1 — half of + // what today's dim descriptions get. `paint().ink2` is therefore the identity. + ink3: { rgb: [118, 127, 139], basic: "2" }, // #767f8b — kv labels, heading meta, key hints + track: { rgb: [62, 67, 76], basic: "2" }, // #3e434c — the empty part of a progress bar, never text + // A state, never branding: the ✕ on a failed line. 203 rather than 204, which + // is the nearest cube entry by distance but differs from the brand pink's 168 + // only in its red channel and reads as pink at that tier — the one deliberate, + // eyeballed exception to the nearest-entry rule above. + err: { rgb: [240, 113, 120], c256: 203, basic: "31" }, // #f07178 } satisfies Record; export function colorsEnabled(out: TTYOut): boolean { @@ -237,6 +254,13 @@ export function paint(on: boolean) { // to `pink` there, at which point it is deleted. softPink: mk(HUES.pink), warn: mk(HUES.warn), + // The 2026-10 roles. `ink2` is the terminal's own foreground on purpose — + // see the note on HUES — so it is the identity at every tier, and exists + // so a call site says which role it means rather than leaving text bare. + ink2: (s: string): string => s, + ink3: mk(HUES.ink3), + track: mk(HUES.track), + err: mk(HUES.err), }; } From 157670fb80a20f9c5b67f3b389a202abfe30df6b Mon Sep 17 00:00:00 2001 From: chhhee10 Date: Sun, 11 Oct 2026 02:46:44 +0530 Subject: [PATCH 007/115] feat(tui): building blocks for the 2026-10 screen language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 2 of the TUI redesign handoff: header, head, rows, kv, cmd, the ●/○ state glyphs, ok/caution/fail, keys, bar, logo and helpPage, which match reference/screens.js FP.primitives. They are pure builders over RenderOpts, colour on or off, so every screen built from them can be asserted at any width without a pty. Deviations from the handoff, as agreed in the review: - Nothing copyable is ever cut (D9). Commands, URLs, paths, ids and kv values are left whole. Only prose (row descriptions) is shortened, with `…`, and only when a caller sets `fit` for a terminal; piped output is never cut. - Columns are derived from the block, with a guaranteed two-space gap, instead of the reference's fixed widths. The longest real policy name (require-no-conflicts-before-stop) is exactly the 32 columns the design fixes, so a fixed column runs it into its description. - The logomark returns nothing below its minimum width. The old ▮▮ one-liner is retired with that mark. - Without colour, a progress bar's empty part is blank rather than a run of ━, which would show no progress at all. - Everything lives inside one exported factory, screenKit(). A new top-level name that matches a local in a builtin policy or an audit detector (cmd, row, head, …) makes the bundler rename that local and rescans every user's audit history. The bundle gains one name, and engineVersion 10c367f637740d67 / detectorVersion 895c2fedece85e93 are unchanged. --- __tests__/hooks/tui-kit.test.ts | 169 ++++++++++++++++++++++++++++++++ src/hooks/tui.ts | 139 ++++++++++++++++++++++++++ 2 files changed, 308 insertions(+) diff --git a/__tests__/hooks/tui-kit.test.ts b/__tests__/hooks/tui-kit.test.ts index c30e805da..db4f12cce 100644 --- a/__tests__/hooks/tui-kit.test.ts +++ b/__tests__/hooks/tui-kit.test.ts @@ -24,6 +24,7 @@ import { renderBrandLogo, rows, rule, + screenKit, stack, table, title, @@ -897,3 +898,171 @@ describe("rule — the one accent every sectioned surface carries", () => { expect(visibleWidth(plain)).toBe(80 - INDENT.length); }); }); + + +describe("screenKit — the 2026-10 building blocks", () => { + const strip = (s: string) => s.replace(/\x1B\[[0-9;]*m/g, ""); + const plain = screenKit({ version: "1.0.11", cols: 104, color: false }); + + it("opens every screen with the wordmark, version and context", () => { + expect(plain.header("Policies")).toBe("failproof ai v1.0.11 · Policies"); + expect(plain.header()).toBe("failproof ai v1.0.11"); + }); + + it("paints the wordmark's `il` pink and bold, and the version grey", () => { + const k = withEnv(TRUECOLOR, () => screenKit({ version: "1.0.11", color: true })); + const h = k.header("Status"); + expect(h).toContain(`\x1B[1;${PINK_24}mil\x1B[0m`); + expect(h).toContain("\x1B[38;2;118;127;139mv1.0.11\x1B[0m"); + expect(strip(h)).toBe("failproof ai v1.0.11 · Status"); + }); + + it("uppercases headings and keeps their meta grey and as written", () => { + expect(plain.head("Dashboard")).toBe("DASHBOARD"); + expect(plain.head("Which agents should failproofai trace?", "8 of 9 selected")).toBe( + "WHICH AGENTS SHOULD FAILPROOFAI TRACE? 8 of 9 selected", + ); + }); + + it("lines up kv values where the reference does", () => { + // Copied from the reference's launch screen, plain. + expect( + plain.kv([ + ["url", "http://127.0.0.1:8020 ● live"], + ["policies", "10 on from FailproofAI/policies@06b802b"], + ["agents", "9 traced: Claude Code, Codex, Copilot, Cursor and 5 more"], + ["cloud", "Connected as chetanraghuvanshi85"], + ]), + ).toEqual([ + " url http://127.0.0.1:8020 ● live", + " policies 10 on from FailproofAI/policies@06b802b", + " agents 9 traced: Claude Code, Codex, Copilot, Cursor and 5 more", + " cloud Connected as chetanraghuvanshi85", + ]); + }); + + it("widens the kv column for a long label instead of running it into its value", () => { + const [line] = plain.kv([["would block", "7: block-env-files 4"]]); + expect(line).toBe(" would block 7: block-env-files 4"); + }); + + it("never cuts a kv value, however narrow the terminal", () => { + const url = "https://app.befailproof.ai/settings/machines/0b1c2d3e-0000-4000-8000-000000000001"; + const k = screenKit({ cols: 40, color: false, fit: true }); + expect(k.kv([["dashboard", url]])[0]).toContain(url); + }); + + it("renders a `command description` row at the reference's column", () => { + expect(plain.rows([["command", "What it does, in a short sentence"]], 14)).toEqual([ + " command What it does, in a short sentence", + ]); + }); + + it("always leaves two spaces after the widest name — the longest real policy name is 32", () => { + const name = "require-no-conflicts-before-stop"; + expect(name).toHaveLength(32); + const [line] = plain.rows([[name, "Require no merge conflicts before stopping"]], 32); + expect(line).toBe(` ${name} Require no merge conflicts before stopping`); + }); + + it("shortens a description to fit the terminal, but never the name", () => { + const k = screenKit({ cols: 40, color: false, fit: true }); + const [line] = k.rows([["failproofai policies show ", "See what a pack holds before installing it"]]); + expect(line.startsWith(" failproofai policies show ")).toBe(true); + const [short] = k.rows([["--wait", "Wait until everything is delivered to the cloud dashboard"]]); + expect(visibleWidth(short)).toBeLessThanOrEqual(40); + expect(short.endsWith("…")).toBe(true); + }); + + it("never shortens anything when fitting is off — piped output is never cut", () => { + const k = screenKit({ cols: 40, color: false }); + const [line] = k.rows([["--wait", "Wait until everything is delivered to the cloud dashboard"]]); + expect(line).toBe(" --wait Wait until everything is delivered to the cloud dashboard"); + }); + + it("states results, cautions and failures with one glyph and an inline fix", () => { + expect(plain.ok("Done, said in the past tense")).toBe("✓ Done, said in the past tense"); + expect(plain.ok("Published acme/guards", "in 2.1s")).toBe("✓ Published acme/guards in 2.1s"); + expect(plain.caution("Needs attention", "the fix")).toBe("▲ Needs attention · the fix"); + expect(plain.fail("Failed, said plainly", "the fix")).toBe("✕ Failed, said plainly · the fix"); + }); + + it("paints the state glyphs by role and every fix as a command", () => { + const k = withEnv(TRUECOLOR, () => screenKit({ color: true })); + expect(k.ok("x")).toContain(`\x1B[${MINT_24}m✓`); + expect(k.caution("x", "failproofai config")).toContain(`\x1B[${PINK_24}mfailproofai config`); + expect(k.fail("x")).toContain("\x1B[38;2;240;113;120m✕"); + }); + + it("joins key hints with a spaced dot", () => { + expect(plain.keys(["↑↓ move", "space toggle", "enter confirm"])).toBe( + "↑↓ move · space toggle · enter confirm", + ); + }); + + it("draws a progress bar: pink fill and a grey track in colour, a blank track without it", () => { + expect(plain.bar(10, 0.6)).toBe("━━━━━━ "); + const k = withEnv(TRUECOLOR, () => screenKit({ color: true })); + const painted = k.bar(10, 0.6); + expect(painted).toContain(`\x1B[${PINK_24}m━━━━━━\x1B[0m`); + expect(painted).toContain("\x1B[38;2;62;67;76m━━━━\x1B[0m"); + // No empty colour span at either end. + expect(k.bar(10, 0)).not.toContain(`\x1B[${PINK_24}m\x1B[0m`); + expect(k.bar(10, 1)).not.toContain("38;2;62;67;76"); + }); + + it("shows the logomark only where it fits, and never the retired ▮▮ one-liner", () => { + const art = screenKit({ cols: 80, color: false }).logo(); + expect(art).toHaveLength(10); + for (const line of art) expect(line.startsWith(" ")).toBe(true); + expect(art.join("")).not.toContain("\x1B"); + expect(screenKit({ cols: 21, color: false }).logo()).toEqual([]); + expect(art.join("")).not.toContain("▮"); + }); + + it("builds a command's --help from usage, options and examples, and nothing else", () => { + const page = plain.helpPage({ + name: "flush", + usage: [["failproofai flush [options]", "Send queued events to cloud now"]], + options: [ + ["--wait", "Wait until everything is delivered"], + ["--timeout ", "How long to wait (default 60)"], + ], + optionsCol: 20, + }); + expect(page).toEqual([ + "failproof ai v1.0.11 · flush", + "", + "USAGE", + " failproofai flush [options] Send queued events to cloud now", + "", + "OPTIONS", + // The reference's option column, exactly. + " --wait Wait until everything is delivered", + " --timeout How long to wait (default 60)", + ]); + }); + + it("emits no escape at all when colour is off", () => { + const all = [ + plain.header("x"), + plain.head("x", "m"), + ...plain.kv([["a", "b"]]), + ...plain.rows([["a", "b"]]), + plain.ok("x", "d"), + plain.caution("x", "f"), + plain.fail("x", "f"), + plain.keys(["a"]), + plain.bar(5, 0.5), + plain.on, + plain.off, + plain.cmd("failproofai config"), + ].join("\n"); + expect(all).not.toContain("\x1B"); + }); + + it("defaults the header's version to this build's", async () => { + const { version } = await import("../../package.json"); + expect(screenKit().header()).toBe(`failproof ai v${version}`); + }); +}); diff --git a/src/hooks/tui.ts b/src/hooks/tui.ts index 897a63ec6..95a36c2f2 100644 --- a/src/hooks/tui.ts +++ b/src/hooks/tui.ts @@ -18,6 +18,7 @@ * 24-bit, the xterm-256 cube, or the 16 basic ANSI hues. */ import * as readline from "node:readline"; +import { version as cliVersion } from "../../package.json"; export type TTYIn = NodeJS.ReadableStream & { isTTY?: boolean; @@ -1755,3 +1756,141 @@ export function helpScreen(spec: HelpScreenSpec, opts?: RenderOpts): string[] { if (spec.footer?.length) groups.push(spec.footer.map((l) => `${INDENT}${c.dim(l)}`)); return stack(...groups); } + +// ── the 2026-10 screen language ───────────────────────────────────────────── +/** + * The redesign's building blocks: open text on the terminal — the wordmark + * header, UPPERCASE headings at column 0, rows indented two, colour by role, and + * no boxes or rules. The byte-level target is `reference/screens.js` → + * `FP.primitives` in the design handoff; the bytes differ only where the tiers + * above say how a colour is spelled. + * + * Commands, URLs, paths and ids are never cut: they are the part a person + * copies, and half of one runs the wrong thing. Prose may be shortened with `…` + * when `fit` is on, which a caller sets only for a terminal — piped output is + * never cut. + * + * ONE exported factory rather than a dozen functions, on purpose. The CLI ships + * as a single scope-hoisted bundle, and a new top-level name that matches a + * local inside a builtin policy or an audit detector (`cmd`, `row`, `head`, …) + * makes the bundler rename that local. That changes the function source the + * audit cache keys on (`engineVersion` / `detectorVersion`), and every user's + * history is rescanned from zero at upgrade. Everything here lives inside the + * factory, so the bundle gains exactly one name. + */ +export interface ScreenKitOpts extends RenderOpts { + /** The version the header shows. Defaults to this build's. */ + version?: string; + /** Shorten prose that does not fit `cols`, ending on `…`. Terminal only. */ + fit?: boolean; +} + +export function screenKit(opts: ScreenKitOpts = {}) { + const { cols, c } = ctx(opts); + const colour = opts.color ?? false; + const version = opts.version ?? cliVersion; + const sep = c.ink3(" · "); + // Never paint nothing: an empty span is two escapes with no text between. + const tint = (paintFn: (s: string) => string, s: string): string => (s ? paintFn(s) : ""); + + /** Typeable: every command on a screen is pink. */ + const cmd = (s: string): string => c.pink(s); + const on = c.guide(RADIO_ON); + const off = c.ink3(RADIO_OFF); + + /** Prose shortened to `room` columns when fitting is on; untouched otherwise. */ + const prose = (s: string, room: number): string => + opts.fit && visibleWidth(s) > room ? fit(s, Math.max(1, room)) : s; + + /** `failproof ai v1.0.11 · Context` — the line every screen opens with. */ + const header = (context?: string): string => + `${c.bold("fa")}${c.pinkBold("il")}${c.bold("proof ai")} ${c.ink3(`v${version}`)}` + + (context ? `${sep}${context}` : ""); + + /** An UPPERCASE heading, with optional grey meta after it. */ + const head = (text: string, meta?: string): string => + c.bold(text.toUpperCase()) + (meta ? ` ${c.ink3(meta)}` : ""); + + /** + * `name description` rows on one column for the block: at least `minCol` + * wide, and always two spaces past the widest name. The design fixes its + * columns by hand, and the longest real policy name is exactly the 32 it + * fixes, so a fixed column runs that name into its description. Names are + * never cut; descriptions are prose and give up the room. + */ + const rows = (items: Array<[string, string?]>, minCol = 0): string[] => { + const width = Math.max(minCol, ...items.map(([name, desc]) => (desc ? visibleWidth(name) + 2 : 0))); + return items.map(([name, desc]) => { + if (!desc) return `${INDENT}${name}`; + const lead = `${INDENT}${pad(name, width)}`; + return lead + prose(c.ink2(desc), cols - visibleWidth(lead)); + }); + }; + + /** + * Grey labels and their values, on one column at least nine wide — where the + * reference starts its values. Values are never cut: they are URLs, ids and + * counts, and a line that outgrows the terminal is left for it to wrap. + */ + const kv = (items: Array<[string, string]>, minLabel = 9): string[] => { + const width = Math.max(minLabel, ...items.map(([label]) => visibleWidth(label))) + 2; + return items.map(([label, value]) => + value ? `${INDENT}${pad(c.ink3(label), width)}${value}` : `${INDENT}${c.ink3(label)}`, + ); + }; + + /** `✓ Done, said in the past tense.` with optional grey detail. */ + const ok = (text: string, detail?: string): string => + `${c.guide("✓")} ${text}${detail ? ` ${c.ink3(detail)}` : ""}`; + + /** `▲ Needs attention. · the fix` */ + const caution = (text: string, fix?: string): string => + `${c.warn("▲")} ${text}${fix ? `${sep}${cmd(fix)}` : ""}`; + + /** `✕ Failed, said plainly. · the fix` */ + const fail = (text: string, fix?: string): string => + `${c.err("✕")} ${text}${fix ? `${sep}${cmd(fix)}` : ""}`; + + /** Lowercase key hints at the foot of an interactive screen. */ + const keys = (list: string[]): string => c.ink3(list.join(" · ")); + + /** + * A progress bar `width` cells wide and `fraction` full: the filled part pink, + * the rest the track grey. With colour off the rest is blank, because one + * unbroken run of `━` would show no progress at all. + */ + const bar = (width: number, fraction: number): string => { + const filled = Math.round(Math.max(0, Math.min(1, fraction)) * width); + const rest = Math.max(0, width - filled); + return tint(c.pink, "━".repeat(filled)) + (colour ? tint(c.track, "━".repeat(rest)) : " ".repeat(rest)); + }; + + /** + * The logomark with a two-space margin, or nothing on a terminal too narrow + * to draw it — the old `▮▮` one-liner is retired with the rest of that mark. + * Callers show it only on a TTY, and only on bare `failproofai` and at the + * start of a `config` run. + */ + const logo = (): string[] => { + if (cols < LOGO_MIN_COLS) return []; + const tier: ColorTier = colour ? colorTier() : "basic"; + return renderLogo(tier).map((line) => `${INDENT}${line}`); + }; + + /** A command's `--help`: usage, options and examples. Nothing else. */ + const helpPage = (spec: { + name: string; + usage: Array<[string, string?]>; + options?: Array<[string, string]>; + examples?: string[]; + /** The options column, when the design fixes a wider one than the data needs. */ + optionsCol?: number; + }): string[] => { + const out = [header(spec.name), "", head("Usage"), ...rows(spec.usage)]; + if (spec.options?.length) out.push("", head("Options"), ...rows(spec.options, spec.optionsCol ?? 0)); + if (spec.examples?.length) out.push("", head("Examples"), ...spec.examples.map((e) => `${INDENT}${cmd(e)}`)); + return out; + }; + + return { cols, sep, cmd, on, off, header, head, rows, kv, ok, caution, fail, keys, bar, logo, helpPage }; +} From fe691049171167c0866115b20cb71e0ce892cae7 Mon Sep 17 00:00:00 2001 From: chhhee10 Date: Sun, 11 Oct 2026 02:57:15 +0530 Subject: [PATCH 008/115] feat(config): remember which agents failproofai traces Step 6a of the TUI redesign handoff. config.json gains an optional `agents: { selected, seen }`: the remembered answer to the new setup's "which agents should failproofai trace?". `selected` will drive hooks, collection and backfill. `seen` is what was detected at the last setup, so a newly installed agent can be offered as new. - Absent means today's behaviour, every agent. writeConfig emits nothing in that case, so an untouched config file keeps its exact bytes, and a garbage value reads as absent. A broken file can never quietly switch tracing off. - updateConfig carries the selection forward unless a patch replaces it (`null` clears it). Every mutation path goes through it, the wizard's own connect step among them, and an unrelated write that dropped the key would silently turn every unticked agent back on. - The ids are kept as written, minus non-strings and repeats. The daemon owns the list of real agents and warns about an unknown one. - isAgentTraced(id) is the one predicate per-agent surfaces ask. Audit cache keys unchanged (engineVersion 10c367f637740d67, detectorVersion 895c2fedece85e93). --- __tests__/hooks/config-round-trip.test.ts | 60 ++++++++++++++++++++++ src/hooks/fp-config.ts | 62 +++++++++++++++++++++++ 2 files changed, 122 insertions(+) diff --git a/__tests__/hooks/config-round-trip.test.ts b/__tests__/hooks/config-round-trip.test.ts index d76648f9a..5880dc904 100644 --- a/__tests__/hooks/config-round-trip.test.ts +++ b/__tests__/hooks/config-round-trip.test.ts @@ -28,6 +28,7 @@ import { readCredentialsRaw, writeCredentials, DEFAULT_CONFIG, + isAgentTraced, } from "../../src/hooks/fp-config"; let home: string; @@ -288,3 +289,62 @@ describe("credentials.json preserves keys this build does not own", () => { expect(credentials.cloud?.token).toBe("t"); }); }); + + +describe("config.json agents: the remembered answer to \"which agents should failproofai trace?\"", () => { + it("is absent on a machine that never chose, and writes no key — absent means every agent", () => { + writeConfig(structuredClone(DEFAULT_CONFIG), {}); + expect(readFile(configFile()).agents).toBeUndefined(); + expect(readConfig().agents).toBeUndefined(); + expect(isAgentTraced("goose")).toBe(true); + }); + + it("round-trips selected and seen", () => { + writeConfig({ ...structuredClone(DEFAULT_CONFIG), agents: { selected: ["claude", "codex"], seen: ["claude", "codex", "goose"] } }, {}); + expect(readFile(configFile()).agents).toEqual({ selected: ["claude", "codex"], seen: ["claude", "codex", "goose"] }); + expect(readConfig().agents).toEqual({ selected: ["claude", "codex"], seen: ["claude", "codex", "goose"] }); + }); + + it("survives every unrelated update — the wizard's own connect step writes twice after it", () => { + seedConfig({ mode: { kind: "oss" }, audit: { auto: false, interval_days: 7 }, agents: { selected: ["claude"], seen: ["claude", "goose"] } }); + updateConfig({ mode: "cloud" }); + updateConfig({ collector: { sessions: true } }); + expect(readFile(configFile()).agents).toEqual({ selected: ["claude"], seen: ["claude", "goose"] }); + }); + + it("is replaced by a patch that names it and removed by null, pruning the table", () => { + seedConfig({ mode: { kind: "oss" }, audit: { auto: false, interval_days: 7 }, agents: { selected: ["claude"], seen: ["claude"] } }); + updateConfig({ agents: { selected: ["codex"], seen: ["claude", "codex"] } }); + expect(readFile(configFile()).agents).toEqual({ selected: ["codex"], seen: ["claude", "codex"] }); + updateConfig({ agents: null }); + expect(readFile(configFile()).agents).toBeUndefined(); + expect(readConfig().agents).toBeUndefined(); + }); + + it("keeps an unknown sibling a newer build wrote inside the agents table", () => { + seedConfig({ mode: { kind: "oss" }, audit: { auto: false, interval_days: 7 }, agents: { selected: ["claude"], seen: [], future_knob: 1 } }); + updateConfig({ agents: { selected: ["claude", "codex"], seen: ["claude", "codex"] } }); + expect(readFile(configFile()).agents.future_knob).toBe(1); + }); + + it("reads a garbage value as no selection, so a broken file never switches tracing off", () => { + for (const agents of ["all", ["claude"], { selected: "claude" }, { seen: ["claude"] }, null]) { + seedConfig({ mode: { kind: "oss" }, audit: { auto: false, interval_days: 7 }, agents }); + expect(readConfig().agents).toBeUndefined(); + expect(isAgentTraced("claude")).toBe(true); + } + }); + + it("keeps the names as written — the daemon is the one that warns about an unknown id — minus non-strings and repeats", () => { + seedConfig({ mode: { kind: "oss" }, audit: { auto: false, interval_days: 7 }, agents: { selected: ["claude", 3, "claude", "", "future-agent"], seen: "x" } }); + expect(readConfig().agents).toEqual({ selected: ["claude", "future-agent"], seen: [] }); + }); + + it("answers isAgentTraced from the selection once there is one", () => { + seedConfig({ mode: { kind: "oss" }, audit: { auto: false, interval_days: 7 }, agents: { selected: ["claude"], seen: ["claude", "goose"] } }); + expect(isAgentTraced("claude")).toBe(true); + expect(isAgentTraced("goose")).toBe(false); + // An empty selection is a real choice, not "no selection". + expect(isAgentTraced("claude", { ...readConfig(), agents: { selected: [], seen: [] } })).toBe(false); + }); +}); diff --git a/src/hooks/fp-config.ts b/src/hooks/fp-config.ts index d9ffc9e5a..41ec050a4 100644 --- a/src/hooks/fp-config.ts +++ b/src/hooks/fp-config.ts @@ -368,6 +368,23 @@ export interface FpConfig { /** Days between scheduled runs. Wall clock, so it survives suspend. */ intervalDays: number; }; + /** + * Which agents failproofai traces: the remembered answer to `failproofai + * config`'s "which agents should failproofai trace?". + * + * `selected` drives every per-agent thing at once — which CLIs get hooks, + * which harnesses the daemon's collector reads, and which `backfill` re-sends. + * `seen` is every agent that was detected at the last setup, which is how a + * newly installed agent is recognised and offered as "new on this machine". + * Ids are the integration ids (`claude`, `codex`, …). + * + * ABSENT means today's behaviour, everything, and that is what every machine + * set up before this existed has. So `writeConfig` emits nothing in that case, + * and the daemon — which reads `agents.selected` from this same file — keeps + * collecting every harness. The direction matters: an unreadable or missing + * selection must never quietly switch tracing OFF. + */ + agents?: { selected: string[]; seen: string[] }; } /** @@ -443,6 +460,26 @@ function readSources(raw: unknown): FpConfig["collector"]["sources"] { return Object.keys(out).length > 0 ? out : undefined; } +/** + * `agents` → the in-memory shape. Kept as written (strings only, de-duplicated + * in order): the daemon owns the list of real agent ids and warns about an + * unknown one, so dropping it here would hide that warning behind a value the + * CLI had already rewritten — the same reasoning as {@link readSources}. + * + * `undefined` unless `selected` is an array, because absent means everything + * and a garbage value must fail in that direction too. + */ +function readAgents(raw: unknown): FpConfig["agents"] { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined; + const ids = (v: unknown): string[] => + Array.isArray(v) + ? [...new Set(v.filter((e): e is string => typeof e === "string" && e.trim() !== ""))] + : []; + const table = raw as Record; + if (!Array.isArray(table.selected)) return undefined; + return { selected: ids(table.selected), seen: ids(table.seen) }; +} + /** * {@link readConfig}, plus the raw object it was projected from. * @@ -552,6 +589,7 @@ export function projectConfig(parsed: Record): FpConfig { ? audit.reports_consented_at : undefined, }, + agents: readAgents(parsed.agents), // Same shape as `audit.auto` above and for the same reason: only an // explicit `true` opts in. Anything else — absent, misspelled, `"yes"` — // reads as off, because the failure direction is a machine that starts @@ -598,6 +636,8 @@ const OWNED_CONFIG_KEYS: readonly (readonly string[])[] = [ ["audit", "auto"], ["audit", "interval_days"], ["audit", "reports_consented_at"], + ["agents", "selected"], + ["agents", "seen"], ]; const isPlainObject = (v: unknown): v is Record => @@ -708,6 +748,12 @@ export function writeConfig(config: FpConfig, raw?: Record): vo ? {} : { reports_consented_at: config.audit.reportsConsentedAt }), }, + // Written ONLY when a selection exists — absent means "every agent", and a + // machine that never chose keeps a file byte-identical to what this + // function wrote before the field existed. + ...(config.agents + ? { agents: { selected: config.agents.selected, seen: config.agents.seen } } + : {}), }; // Start from the previous bytes, strip the keys this build owns — so an // omission above really removes — then lay the projection on top. What is left @@ -726,6 +772,8 @@ export function updateConfig(patch: { collector?: Partial; telemetry?: Partial; audit?: Partial; + /** Replace the agent selection; `null` removes it (back to "every agent"). */ + agents?: FpConfig["agents"] | null; }): FpConfig { // `readConfigRaw`, not `readConfig` — this is the function EVERY mutation path // goes through (the wizard, `harness add-path`, `healDaemonFlag`, the telemetry @@ -738,11 +786,25 @@ export function updateConfig(patch: { collector: { ...current.collector, ...patch.collector }, telemetry: { ...current.telemetry, ...patch.telemetry }, audit: { ...current.audit, ...patch.audit }, + // Carried forward when the patch does not name it. Every other mutation + // path goes through here — the wizard's own connect step among them — and + // a selection dropped by an unrelated write would silently turn tracing + // back on for every agent the user switched off. + agents: patch.agents === undefined ? current.agents : (patch.agents ?? undefined), }; writeConfig(next, raw); return next; } +/** + * Whether failproofai traces this agent: hooked, collected and backfilled. + * True for every agent when no selection has been saved, which is today's + * behaviour and what every machine set up before the selection existed has. + */ +export function isAgentTraced(id: string, config: FpConfig = readConfig()): boolean { + return !config.agents || config.agents.selected.includes(id); +} + // ── credentials.json ───────────────────────────────────────────────────────── export interface FpCredentials { From 78825ee4b184ec3b3d65e0e686a1a8b99119e99b Mon Sep 17 00:00:00 2001 From: chhhee10 Date: Sun, 11 Oct 2026 03:02:05 +0530 Subject: [PATCH 009/115] feat(policies): one shared answer to "is this machine enforcing?" Groundwork for the redesigned policies and launch screens, which both warn when policies are not enforcing. One predicate, notEnforcingReason(), answers for both so they can never disagree. It returns "no-hooks" (no agent calls failproofai), "no-policies" (wired, nothing on, counting policies switched off after install) or "observe-only", and null when something enforces. It reads files only, so it is cheap enough for the launch path: installed packs, cloud-managed policies, configured custom policy files, convention files on disk (counted, never imported) and the legacy migration shim. Deliberately not causes, because each needs its own words: - A refused pack and a down daemon on a daemon-configured machine fail closed and DENY calls. "Not enforcing" would say the opposite of what is happening. - A session pause belongs to one session, not to the machine. Audit cache keys unchanged. --- __tests__/hooks/not-enforcing.test.ts | 125 ++++++++++++++++++++++++++ src/hooks/manager.ts | 68 ++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 __tests__/hooks/not-enforcing.test.ts diff --git a/__tests__/hooks/not-enforcing.test.ts b/__tests__/hooks/not-enforcing.test.ts new file mode 100644 index 000000000..e7a86cfad --- /dev/null +++ b/__tests__/hooks/not-enforcing.test.ts @@ -0,0 +1,125 @@ +// @vitest-environment node +/** + * `notEnforcingReason` — the one answer to "are the policies on this machine + * actually enforcing?", shared by the policies listing and the launch screen so + * the two can never disagree. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ARTIFACT = "export const hooks = [];\n"; +const DIGEST = createHash("sha256").update(ARTIFACT).digest("hex"); + +let home: string; +let project: string; +let packRoot: string; +let saved: Record; + +function wireClaude(): void { + mkdirSync(join(home, ".claude"), { recursive: true }); + writeFileSync( + join(home, ".claude", "settings.json"), + JSON.stringify({ + hooks: { + PreToolUse: [ + { matcher: "*", hooks: [{ type: "command", command: "npx -y failproofai --hook PreToolUse", __failproofai_hook__: true }] }, + ], + }, + }), + ); +} + +function installPack(over: Record = {}): void { + writeFileSync( + join(packRoot, "installed.json"), + JSON.stringify({ + schemaVersion: 1, + packs: [ + { + id: "acme/finance", + version: "1.2.0", + source: "github:acme/finance@v1.2.0", + entry: `artifacts/${DIGEST}.mjs`, + sha256: DIGEST, + policies: [ + { name: "block-big-refund", description: "Block big refunds", category: "Finance", defaultEnabled: true, match: {} }, + { name: "require-note", description: "Require a note", category: "Finance", defaultEnabled: true, match: {} }, + ], + ...over, + }, + ], + }), + ); +} + +async function reason() { + const { notEnforcingReason } = await import("@/src/hooks/manager"); + return notEnforcingReason(project); +} + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "fpai-enforcing-home-")); + project = mkdtempSync(join(tmpdir(), "fpai-enforcing-proj-")); + packRoot = mkdtempSync(join(tmpdir(), "fpai-enforcing-packs-")); + mkdirSync(join(packRoot, "artifacts"), { recursive: true }); + writeFileSync(join(packRoot, "artifacts", `${DIGEST}.mjs`), ARTIFACT); + saved = { FAILPROOFAI_HOME: process.env.FAILPROOFAI_HOME, FAILPROOFAI_PACK_DIR: process.env.FAILPROOFAI_PACK_DIR }; + process.env.FAILPROOFAI_HOME = home; + process.env.FAILPROOFAI_PACK_DIR = packRoot; + // Agent settings resolve from the OS home, not FAILPROOFAI_HOME. + vi.stubEnv("HOME", home); + vi.stubEnv("USERPROFILE", home); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + for (const dir of [home, project, packRoot]) rmSync(dir, { recursive: true, force: true }); +}); + +describe("notEnforcingReason", () => { + it("says no agent calls failproofai when nothing is wired, whatever is installed", async () => { + installPack(); + expect(await reason()).toBe("no-hooks"); + }); + + it("says no policies when an agent is wired but nothing is switched on", async () => { + wireClaude(); + expect(await reason()).toBe("no-policies"); + }); + + it("is null once a wired machine has a pack policy on", async () => { + wireClaude(); + installPack(); + expect(await reason()).toBeNull(); + }); + + it("calls an observe-only pack observe-only, never enforcing", async () => { + wireClaude(); + installPack({ effect: "observe" }); + expect(await reason()).toBe("observe-only"); + }); + + it("counts a policy the user switched off after install as off", async () => { + wireClaude(); + installPack({ enabled: ["block-big-refund"] }); + writeFileSync( + join(home, "policies-config.json"), + JSON.stringify({ enabledPolicies: [], disabledCustomPolicies: ["pack:acme/finance@1.2.0:block-big-refund"] }), + ); + expect(await reason()).toBe("no-policies"); + }); + + it("counts convention policy files on disk without importing them", async () => { + wireClaude(); + mkdirSync(join(project, ".failproofai", "policies"), { recursive: true }); + writeFileSync(join(project, ".failproofai", "policies", "team-policies.mjs"), "throw new Error('must not be imported');\n"); + expect(await reason()).toBeNull(); + }); +}); diff --git a/src/hooks/manager.ts b/src/hooks/manager.ts index 322975be3..e2d4baff8 100644 --- a/src/hooks/manager.ts +++ b/src/hooks/manager.ts @@ -319,6 +319,74 @@ export function integrationsInstalledAt(scope: HookScope, cwd?: string): Integra }); } +/** + * Why the policies on this machine are not enforcing, or `null` when they are. + * + * ONE answer for every screen that warns about it — `failproofai policies`, the + * dashboard launch screen — so two screens can never disagree about whether + * this machine is protected. The redesign shows one generic warning whatever + * the cause (decision D18); the cause is returned anyway, for tests and so a + * screen that wants the specific fix can have it. + * + * Cheap enough for the launch path on purpose: it reads files and imports no + * policy file and probes no daemon. + * + * Deliberately NOT causes, because each needs its own words: + * - A refused pack and a down daemon on a daemon-configured machine. Both + * FAIL CLOSED: they DENY tool calls rather than let them through, so "not + * enforcing" would tell the user the opposite of what is happening. + * - A session pause. It belongs to one session, not to the machine, and + * `config --status` lists it. + */ +export type NotEnforcingReason = "no-hooks" | "no-policies" | "observe-only"; + +export function notEnforcingReason(cwd?: string): NotEnforcingReason | null { + const wired = deduplicateScopes(HOOK_SCOPES, cwd).some((scope) => hooksInstalledInSettings(scope, cwd)); + if (!wired) return "no-hooks"; + + const config = readMergedHooksConfig(cwd); + const disabled = new Set(config.disabledCustomPolicies ?? []); + let observing = false; + + // Installed packs: a policy enforces when its pack is not observe-only, it + // was taken at install, and nobody switched it off afterwards. + try { + for (const pack of readInstalledPacks().packs) { + const taken = pack.enabled ?? pack.policies.map((policy) => policy.name); + const on = taken.filter((name) => !disabled.has(`pack:${pack.id}@${pack.version}:${name}`)); + if (on.length === 0) continue; + if (pack.effect === "observe") observing = true; + else return null; + } + } catch { + // An unreadable manifest is reported by the listing itself. + } + + // Cloud-managed policies, pushed from a deployment. + try { + for (const artifact of readActiveCloudManagedPolicies()) { + if (artifact.effect === "observe") observing = true; + else return null; + } + } catch { + // No deployment, or an unreadable one: nothing enforces from there. + } + + // A configured custom policy file, or convention files on disk. Loading them + // to ask what they hold would import user code on every launch, so their + // presence counts: the listing below reports a file that fails to load. + if (configuredCustomPolicyPaths(config).length > 0) return null; + const projectDir = resolve(findProjectConfigDir(cwd ?? process.cwd()), ".failproofai", "policies"); + if (discoverPolicyFiles(projectDir).length > 0 || discoverPolicyFiles(customPoliciesDir()).length > 0) { + return null; + } + + // A machine still on the pre-pack migration shim runs its legacy builtins. + if (config.enabledPolicies.length > 0 && !hasInstalledRegexPacks()) return null; + + return observing ? "observe-only" : "no-policies"; +} + function safeSettingsPath( integration: Integration, scope: HookScope, From f3fc4191d3a63064e953ee0552e21d7197a8f8c7 Mon Sep 17 00:00:00 2001 From: chhhee10 Date: Sun, 11 Oct 2026 03:15:00 +0530 Subject: [PATCH 010/115] feat(tui): pickers in the 2026-10 screen language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 5 of the TUI redesign handoff. selectOne and multiSelect are drawn like the design's policiesAdd and agentPicker screens: - an UPPERCASE heading with grey meta, by default a live "n of m selected" that updates on every toggle - a pink `›` cursor gutter and a bold cursor row; ■ ticked and □ not - bold section names, `↑/↓ n more` when the list scrolls - the key hints at the foot Answered prompts collapse to the heading and `✓ `, or to the caller's own lines. The spine glyphs are gone from both. - The window is 12 rows, not 8. Twelve reproduces every frame in the design; eight cannot show the nine agents of its setup screen. The WINDOW identifier itself is kept, because the audit cache keys on bundled names. - Plain `a` toggles all, as the design's hints say. ctrl+a stays as an alias. - New opt-in options for the config flow's agents step: a meta function, per-row tags recomputed on every toggle, the ✕ message for minSelected, and a caller-defined collapsed form. selectOne gains `meta`, `collapsed` and `spaceSelects`. - The `policies -i` / `--install` / `--uninstall` agent menu was install-prompt.ts's own raw-mode loop, with a two-write (non-atomic) repaint and a colour gate that ignored pipes. It now uses selectOne. It keeps its "not installed" section of forward installs, Space still picks and Esc still exits 130. Its aggregate row reads "All N agents", as the design spells it. - The two raw yellow warnings in the same file are now ▲ lines through the palette, so they respect NO_COLOR and pipes, and they drop a stale six-agent list. - install-prompt.ts's searchable policy picker had no caller since `--install` stopped choosing policies. It is deleted with its tests (approved), and the manager tests keep their real assertion: setup writes the policy selection back unchanged. - The pack pickers in pack-cli.ts take the heading meta instead of hand-written hint strings. The pack picker is preceded by the design's `failproof ai v… · Add ` header, the per-pack agent picker shows display names, and Jev checks are still stated before the list. Audit cache keys unchanged (engineVersion 10c367f637740d67, detectorVersion 895c2fedece85e93). Full unit suite green. --- CHANGELOG.md | 1 + __tests__/hooks/install-prompt.test.ts | 82 +--- __tests__/hooks/manager.test.ts | 10 - __tests__/hooks/new-telemetry.test.ts | 10 - __tests__/hooks/tui-kit.test.ts | 123 +++++- src/hooks/install-prompt.ts | 515 ++----------------------- src/hooks/pack-cli.ts | 28 +- src/hooks/tui.ts | 153 +++++--- 8 files changed, 286 insertions(+), 636 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51707823b..b1c4cde0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Features +- Pickers in the new look: an UPPERCASE heading with a live "n of m selected", a `›` cursor and `■ □` boxes, twelve rows before scrolling, plain `a` to toggle all, and the key hints at the foot. The `policies -i` agent menu now uses the shared picker (#PR) - New terminal look for the CLI, step one: the palette gains the redesign's label grey, progress-track grey and a red for failures. Descriptions keep the terminal's own colour, and the greys fall back to the dim attribute below 24-bit, so every screen stays readable on light themes (#PR) ## 1.0.10 — 2026-10-05 diff --git a/__tests__/hooks/install-prompt.test.ts b/__tests__/hooks/install-prompt.test.ts index 1559a6bab..efc5dec1f 100644 --- a/__tests__/hooks/install-prompt.test.ts +++ b/__tests__/hooks/install-prompt.test.ts @@ -13,80 +13,6 @@ describe("hooks/install-prompt", () => { vi.restoreAllMocks(); }); - /** - * `manager.ts` writes whatever this returns straight into `enabledPolicies`, - * and then prints only what survived — so anything this function drops is - * configuration destroyed with nothing on screen to say so. - */ - describe("never drops a configured policy name it does not recognise", () => { - const nonTty = () => - Object.defineProperty(process.stdin, "isTTY", { value: false, writable: true, configurable: true }); - - it("carries qualified, beta and pack names through the non-TTY path", async () => { - nonTty(); - const { promptPolicySelection } = await import("../../src/hooks/install-prompt"); - - const configured = [ - "block-sudo", - // A form the ENFORCEMENT path explicitly accepts — - // `registerBuiltinPolicies` canonicalizes both spellings — yet the - // catalog is keyed by the bare name, so an intersection deleted it. - "failproofai/block-sudo", - "pack/acme/finance@1.2.0/block-refunds", - "some-policy-this-build-has-never-heard-of", - ]; - - expect(await promptPolicySelection(configured)).toEqual(configured); - }); - - it("still returns the defaults when nothing was configured", async () => { - // The path every fresh install takes must be unchanged. - nonTty(); - const { promptPolicySelection } = await import("../../src/hooks/install-prompt"); - const { BUILTIN_POLICIES } = await import("../../src/hooks/builtin-policies"); - - const expected = BUILTIN_POLICIES.filter((p) => p.defaultEnabled && !p.beta).map((p) => p.name); - expect(await promptPolicySelection()).toEqual(expected); - }); - }); - - it("returns default-enabled policies when stdin is not a TTY", async () => { - Object.defineProperty(process.stdin, "isTTY", { - value: false, - writable: true, - configurable: true, - }); - - const { promptPolicySelection } = await import("../../src/hooks/install-prompt"); - const selected = await promptPolicySelection(); - - expect(selected).toContain("sanitize-jwt"); - expect(selected).toContain("protect-env-vars"); - expect(selected).toContain("block-env-files"); - expect(selected).toContain("block-sudo"); - expect(selected).toContain("block-curl-pipe-sh"); - expect(selected).toContain("block-push-master"); - expect(selected).toContain("block-failproofai-commands"); - expect(selected).not.toContain("block-rm-rf"); - expect(selected).not.toContain("block-force-push"); - expect(selected).not.toContain("block-secrets-write"); - // 12 before `block-self-pause` merged into `block-failproofai-commands`. - expect(selected).toHaveLength(11); - }); - - it("returns preSelected when stdin is not a TTY and preSelected is provided", async () => { - Object.defineProperty(process.stdin, "isTTY", { - value: false, - writable: true, - configurable: true, - }); - - const { promptPolicySelection } = await import("../../src/hooks/install-prompt"); - const selected = await promptPolicySelection(["block-sudo", "block-rm-rf"]); - - expect(selected).toEqual(["block-sudo", "block-rm-rf"]); - }); - describe("resolveTargetClis", () => { it("returns explicit cli list as-is regardless of action", async () => { const { resolveTargetClis } = await import("../../src/hooks/install-prompt"); @@ -184,7 +110,7 @@ describe("hooks/install-prompt", () => { expect(undetected).toEqual(["copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose"]); expect(options[0]).toMatchObject({ isAll: true, detected: true, value: ["claude", "codex"] }); - expect(options[0].label).toBe("Install for all 2 detected"); + expect(options[0].label).toBe("All 2 agents"); // Detected rows preserve order and carry detected=true expect(options.slice(1, 3)).toEqual([ @@ -209,7 +135,7 @@ describe("hooks/install-prompt", () => { ]); }); - it("uninstall action: only detected rows, no undetected (and verb is 'Remove from')", async () => { + it("uninstall action: only detected rows, no undetected", async () => { const { buildCliMenuOptions } = await import("../../src/hooks/install-prompt"); const { options, undetected } = buildCliMenuOptions( ["claude", "codex", "copilot"], @@ -218,7 +144,7 @@ describe("hooks/install-prompt", () => { expect(undetected).toEqual([]); expect(options).toHaveLength(4); // 1 aggregate + 3 detected - expect(options[0].label).toBe("Remove from all 3 detected"); + expect(options[0].label).toBe("All 3 agents"); expect(options.every((o) => o.detected)).toBe(true); }); @@ -231,7 +157,7 @@ describe("hooks/install-prompt", () => { expect(undetected).toEqual([]); expect(options).toHaveLength(13); // aggregate + 12 detected - expect(options[0].label).toBe("Install for all 12 detected"); + expect(options[0].label).toBe("All 12 agents"); }); it("install with 1 detected + many undetected: skips aggregate row (1 ≯ 1)", async () => { diff --git a/__tests__/hooks/manager.test.ts b/__tests__/hooks/manager.test.ts index 2139903d3..87b3ae864 100644 --- a/__tests__/hooks/manager.test.ts +++ b/__tests__/hooks/manager.test.ts @@ -33,12 +33,6 @@ vi.mock("../../src/hooks/safe-config-write", async () => { }; }); -vi.mock("../../src/hooks/install-prompt", () => ({ - promptPolicySelection: vi.fn(() => - Promise.resolve(["block-sudo", "block-env-files", "sanitize-jwt"]), - ), -})); - vi.mock("../../src/hooks/hooks-config", () => ({ readHooksConfig: vi.fn(() => ({ enabledPolicies: [] })), // listHooks resolves the project root by walking up to the nearest @@ -194,12 +188,10 @@ describe("hooks/manager", () => { vi.mocked(readScopedHooksConfig).mockReturnValue({ enabledPolicies: ["block-sudo"] }); const { installHooks } = await import("../../src/hooks/manager"); - const { promptPolicySelection } = await import("../../src/hooks/install-prompt"); const { writeScopedHooksConfig } = await import("../../src/hooks/hooks-config"); await installHooks(); - expect(promptPolicySelection).not.toHaveBeenCalled(); // Written back UNCHANGED — not re-derived, not defaulted, not widened. expect(writeScopedHooksConfig).toHaveBeenCalledWith( { enabledPolicies: ["block-sudo"] }, @@ -214,11 +206,9 @@ describe("hooks/manager", () => { const { installHooks } = await import("../../src/hooks/manager"); const { writeScopedHooksConfig } = await import("../../src/hooks/hooks-config"); - const { promptPolicySelection } = await import("../../src/hooks/install-prompt"); await installHooks(["all"]); - expect(promptPolicySelection).not.toHaveBeenCalled(); expect(writeScopedHooksConfig).toHaveBeenCalledWith( { enabledPolicies: expect.arrayContaining(["block-sudo", "block-rm-rf", "sanitize-jwt"]) }, "user", diff --git a/__tests__/hooks/new-telemetry.test.ts b/__tests__/hooks/new-telemetry.test.ts index 96742e6e3..8a4ba1b4c 100644 --- a/__tests__/hooks/new-telemetry.test.ts +++ b/__tests__/hooks/new-telemetry.test.ts @@ -28,16 +28,6 @@ vi.mock("node:child_process", () => ({ execSync: vi.fn(), })); -vi.mock("../../src/hooks/install-prompt", async () => { - const actual = await vi.importActual( - "../../src/hooks/install-prompt", - ); - return { - ...actual, - promptPolicySelection: vi.fn(() => Promise.resolve(["block-sudo"])), - }; -}); - vi.mock("../../src/hooks/integrations", () => ({ detectInstalledClis: vi.fn(() => ["claude"]), getIntegration: vi.fn((id: string) => ({ diff --git a/__tests__/hooks/tui-kit.test.ts b/__tests__/hooks/tui-kit.test.ts index db4f12cce..d434f1e55 100644 --- a/__tests__/hooks/tui-kit.test.ts +++ b/__tests__/hooks/tui-kit.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect, vi } from "vitest"; import { type TTYIn, multiSelect, + selectOne, INDENT, CHIP_WIDTH, brandAnsi, @@ -755,8 +756,9 @@ describe("a repaint is one atomic frame", () => { const written = await drawTwice(); // The cursor-up-and-clear must never arrive on its own — that lone write is // precisely the blank frame. + // Case-insensitive: the redesign prints the question as an UPPERCASE heading. const clearOnly = written.filter( - (c) => /\u001b\[\d+A\u001b\[J/.test(c) && !c.includes("pick"), + (c) => /\u001b\[\d+A\u001b\[J/.test(c) && !/pick/i.test(c), ); expect(clearOnly).toEqual([]); }); @@ -1066,3 +1068,122 @@ describe("screenKit — the 2026-10 building blocks", () => { expect(screenKit().header()).toBe(`failproof ai v${version}`); }); }); + + +describe("pickers in the 2026-10 language", () => { + const plainText = (s: string) => s.replace(/\u001b\[[0-9;?]*[A-Za-z]/g, ""); + + /** Drive a prompt through a real PassThrough, sending `keys` after the first frame. */ + const drive = async (start: (io: { stdin: TTYIn; stdout: TTYOut }) => Promise, keys: string[], columns = 100) => { + const written: string[] = []; + const stdout = { isTTY: true, columns, write: (chunk: string) => { written.push(chunk); return true; } } as unknown as TTYOut; + const stdin = new PassThrough() as unknown as TTYIn & PassThrough; + (stdin as unknown as { isTTY: boolean }).isTTY = true; + (stdin as unknown as { setRawMode: (on: boolean) => void }).setRawMode = () => {}; + const pending = start({ stdin: stdin as unknown as TTYIn, stdout }); + for (const k of keys) stdin.write(k); + const result = await pending; + // Cursor show/hide go out as writes of their own, which strip to nothing. + const frames = written.map(plainText).filter((f) => f.trim() !== ""); + return { result, frames, last: frames[frames.length - 1] ?? "", all: frames.join("") }; + }; + + const POLICIES = ["block-sudo", "block-rm-rf", "block-env-files"].map((name, i) => ({ + label: name, + value: name, + hint: `Description ${i}`, + checked: i === 0, + })); + + it("draws the heading in UPPERCASE with a live count, a › cursor and ■ □ boxes", async () => { + const { frames } = await drive( + (io) => multiSelect({ message: "Choose policies", choices: POLICIES, ...io }), + ["\u001b"], + ); + const first = frames[0]; + expect(first).toContain("CHOOSE POLICIES 1 of 3 selected"); + expect(first).toContain(" › ■ block-sudo"); + expect(first).toContain(" □ block-rm-rf"); + expect(first).toContain("↑↓ move · space toggle · a all · enter confirm · esc cancel"); + // No spine glyphs from the old flow. + expect(first).not.toMatch(/[│◆◇❯◼◻]/); + }); + + it("toggles everything with a plain `a`, and keeps ctrl+a as an alias", async () => { + const plainA = await drive((io) => multiSelect({ message: "pick", choices: POLICIES, ...io }), ["a", "\r"]); + expect(plainA.result).toEqual(["block-sudo", "block-rm-rf", "block-env-files"]); + const ctrlA = await drive((io) => multiSelect({ message: "pick", choices: POLICIES, ...io }), ["\u0001", "\r"]); + expect(ctrlA.result).toEqual(["block-sudo", "block-rm-rf", "block-env-files"]); + }); + + it("says the caller's minimum with ✕ and stays put", async () => { + const none = POLICIES.map((p) => ({ ...p, checked: false })); + const { all } = await drive( + (io) => multiSelect({ message: "Which agents should failproofai trace?", choices: none, minSelected: 1, minMessage: "pick at least one agent", ...io }), + ["\r", "\u001b"], + ); + expect(all).toContain("✕ pick at least one agent"); + }); + + it("recomputes a row's tag on every toggle", async () => { + const { frames } = await drive( + (io) => + multiSelect({ + message: "pick", + choices: POLICIES, + tag: (value, on) => (value === "block-rm-rf" && on ? "will be added" : undefined), + ...io, + }), + ["\u001b[B", " ", "\u001b"], + ); + expect(frames[0]).not.toContain("will be added"); + expect(frames.some((f) => f.includes("block-rm-rf") && f.includes("will be added"))).toBe(true); + }); + + it("collapses to `✓ ` under the heading, or to the caller's own lines", async () => { + const plain = await drive((io) => multiSelect({ message: "Choose policies", choices: POLICIES, ...io }), ["\r"]); + expect(plain.last).toContain("CHOOSE POLICIES"); + expect(plain.last).toContain("✓ block-sudo"); + const custom = await drive( + (io) => multiSelect({ message: "Choose policies", choices: POLICIES, collapsed: (v) => ["AGENTS", `✓ Tracing ${v.length} agents`], ...io }), + ["\r"], + ); + expect(custom.last).toContain("✓ Tracing 1 agents"); + }); + + it("shows twelve rows before it scrolls, and says how many more are below", async () => { + const many = Array.from({ length: 15 }, (_, i) => ({ label: `policy-${i}`, value: `p${i}` })); + const { frames } = await drive((io) => multiSelect({ message: "pick", choices: many, ...io }), ["\u001b"]); + expect(frames[0]).toContain("policy-11"); + expect(frames[0]).not.toContain("policy-12"); + expect(frames[0]).toContain("↓ 3 more"); + }); + + it("selectOne: grey meta after the heading, a bold cursor row, and the redesign's key hints", async () => { + const { frames, result } = await drive( + (io) => + selectOne({ + message: "Choose agents", + meta: "9 found on this machine", + choices: [ + { label: "All 9 agents", value: "all" }, + { label: "Claude Code", value: "claude" }, + ], + ...io, + }), + ["\u001b[B", "\r"], + ); + expect(frames[0]).toContain("CHOOSE AGENTS 9 found on this machine"); + expect(frames[0]).toContain(" › All 9 agents"); + expect(frames[0]).toContain("↑↓ move · enter select · esc cancel"); + expect(result).toBe("claude"); + }); + + it("selectOne picks on space only when the caller asks for it", async () => { + const choices = [{ label: "A", value: "a" }, { label: "B", value: "b" }]; + const asked = await drive((io) => selectOne({ message: "pick", choices, spaceSelects: true, ...io }), [" "]); + expect(asked.result).toBe("a"); + const notAsked = await drive((io) => selectOne({ message: "pick", choices, ...io }), [" ", "\u001b"]); + expect(notAsked.result).toBeNull(); + }); +}); diff --git a/src/hooks/install-prompt.ts b/src/hooks/install-prompt.ts index b5ed4a8e1..9afd4181b 100644 --- a/src/hooks/install-prompt.ts +++ b/src/hooks/install-prompt.ts @@ -1,48 +1,19 @@ /** - * Interactive searchable multi-select prompt for choosing hook policies. - * Uses raw mode stdin with node:readline for keypress handling. - * No external dependencies. + * Which agent CLIs a `policies --install / --uninstall` acts on, asked with the + * shared `selectOne` when more than one is detected. * - * Rendering strategy: track line count, use cursor-up + clear-to-end-of-screen - * (\x1B[NA\x1B[J) to avoid flickering. Lines are truncated to terminal width to - * ensure lastLineCount stays accurate when the terminal is narrow. - * - * Keybindings: ↑↓ navigate · Space toggle · Ctrl+A all · Ctrl+S save · Esc clear search + * This file used to carry two hand-rolled raw-mode menus of its own: this one, + * with a two-write (non-atomic) repaint and an escape-gate that ignored pipes, + * and a searchable policy picker that nothing had called since `--install` + * stopped choosing policies. The picker is gone and the menu is `selectOne`, + * so it draws, windows and repaints like every other prompt in the CLI. */ -import * as readline from "node:readline"; -// Brand palette, ANSI-aware truncation, and glyphs shared with the configure -// wizard (tui.ts) so every failproofai menu reads as one system. -import { - paint, - truncate as truncateLine, - BAR, - STEP_ACTIVE as STEP, - STEP_DONE as DONE, - CHECK_ON as BOX_ON, - CHECK_OFF as BOX_OFF, -} from "./tui"; -import { BUILTIN_POLICIES } from "./builtin-policies"; +import { colorsEnabled, optsFor, screenKit, selectOne, type SelectChoice } from "./tui"; import { detectInstalledClis, getIntegration, listInstallableIds } from "./integrations"; import { type IntegrationType } from "./types"; import { trackHookEvent } from "./hook-telemetry"; import { getInstanceId } from "../../lib/telemetry-id"; -interface SelectItem { - name: string; - description: string; - category: string; - selected: boolean; - beta: boolean; -} - -type DisplayRow = - | { kind: "header"; category: string; enabledCount: number; totalCount: number } - | { kind: "item"; item: SelectItem; filteredIndex: number }; - -export interface PromptOptions { - includeBeta?: boolean; -} - /** Whether the prompt is being shown for an install or an uninstall flow. * Drives heading + hint text so `policies --uninstall` no longer says * "Install Hooks". */ @@ -93,15 +64,17 @@ export async function resolveTargetClis( // Uninstall flow: no agent CLIs detected — nothing to remove from. Default to // claude so removeHooks operates over Claude's scopes (no-op if no settings file). console.log( - "\x1B[33mWarning: no agent CLI binary found in PATH (claude, codex, copilot, cursor-agent, opencode, pi)." + - "Defaulting to Claude Code; nothing will be removed if no settings file exists.\x1B[0m", + screenKit({ color: colorsEnabled(process.stdout) }).caution( + "No agent CLI was found on PATH, so this acts on Claude Code. Nothing is removed if it has no settings file.", + ), ); fireDetectionEvent(["claude"], "defaulted_to_claude"); return ["claude"]; } console.log( - "\x1B[33mWarning: no agent CLI binary found in PATH (claude, codex, copilot, cursor-agent, opencode, pi)." + - "Defaulting to Claude Code; hooks will activate when an agent is installed.\x1B[0m", + screenKit({ color: colorsEnabled(process.stdout) }).caution( + "No agent CLI was found on PATH, so hooks go into Claude Code. They take effect once it is installed.", + ), ); fireDetectionEvent(["claude"], "defaulted_to_claude"); return ["claude"]; @@ -159,9 +132,8 @@ export function buildCliMenuOptions( const options: CliMenuOption[] = []; if (detected.length > 1) { - const verb = action === "uninstall" ? "Remove from" : "Install for"; options.push({ - label: `${verb} all ${detected.length} detected`, + label: `All ${detected.length} agents`, value: detected, detected: true, isAll: true, @@ -187,446 +159,35 @@ export function buildCliMenuOptions( } /** - * Interactive arrow-key single-select for "install/remove for which CLI?" when - * multiple agent CLIs are detected. + * "Install / remove hooks for which agent?" when more than one is detected. * - * Layout: - * • DETECTED section: an "Install for all detected" option (only when >1 - * detected) followed by each detected CLI individually. - * • NOT INSTALLED section (install action only): each undetected CLI as a - * forward-install option, so users can prep hooks before adding the CLI. - * - * Cursor skips section headers — it only lands on selectable item rows. + * Detected agents first — with an "All N agents" row when there are several — + * then, for an install, every undetected agent as a forward install, so hooks + * can be in place before the agent is. Esc exits 130, as it always has: the + * caller has no "cancelled" path, and an install that quietly picked something + * would be worse. */ async function promptCliTargetSelection( detected: IntegrationType[], action: CliPromptAction = "install", ): Promise { - const { options, undetected } = buildCliMenuOptions(detected, action); - - type DisplayRow = - | { kind: "header"; title: string; hint?: string } - | { kind: "blank" } - | { kind: "item"; option: CliMenuOption; itemIndex: number }; - - function buildDisplayRows(): DisplayRow[] { - const rows: DisplayRow[] = []; - let itemIndex = 0; - - rows.push({ - kind: "header", - title: `Detected (${detected.length})`, - }); - for (const opt of options) { - if (opt.detected) { - rows.push({ kind: "item", option: opt, itemIndex: itemIndex++ }); - } - } - - if (undetected.length > 0) { - rows.push({ kind: "blank" }); - rows.push({ - kind: "header", - title: `Not installed (${undetected.length})`, - hint: "install hooks ahead of time", - }); - for (const opt of options) { - if (!opt.detected) { - rows.push({ kind: "item", option: opt, itemIndex: itemIndex++ }); - } - } - } - - return rows; - } - - let cursor = 0; - let lastLineCount = 0; - let cursorHidden = false; - - function hideCursor(): void { - if (!cursorHidden) { - process.stdout.write("\x1B[?25l"); - cursorHidden = true; - } - } - function showCursor(): void { - if (cursorHidden) { - process.stdout.write("\x1B[?25h"); - cursorHidden = false; - } - } - - // Shared brand painter from tui.ts (NO_COLOR-gated like the rest of this file). - const { dim, bold, guide: teal, pink, pinkBold } = paint(!process.env.NO_COLOR); - - const heading = - action === "uninstall" ? "Remove failproofai hooks" : "Install failproofai hooks"; - - function render(): void { - const cols = process.stdout.columns || 100; - hideCursor(); - - const g = dim(BAR); - const lines: string[] = [g, `${teal(STEP)} ${bold(heading)}`]; - - for (const row of buildDisplayRows()) { - if (row.kind === "blank") { - lines.push(g); - continue; - } - if (row.kind === "header") { - const hint = row.hint ? ` ${dim("· " + row.hint)}` : ""; - lines.push(`${g} ${dim(row.title)}${hint}`); - continue; - } - - const opt = row.option; - const isActive = row.itemIndex === cursor; - const caret = isActive ? pink("❯") : " "; - const marker = opt.isAll ? teal("★") : opt.detected ? pink("●") : dim("○"); - const label = isActive - ? pinkBold(opt.label) - : opt.detected - ? opt.label - : dim(opt.label); - lines.push(`${g} ${caret} ${marker} ${label}`); - } - - lines.push(g); - lines.push(`${g} ${dim("↑/↓ move · ↵ select · esc cancel")}`); - - if (lastLineCount > 0) { - process.stdout.write(`\x1B[${lastLineCount}A\x1B[J`); - } - process.stdout.write(lines.map((l) => truncateLine(l, cols)).join("\n") + "\n"); - lastLineCount = lines.length; - } - - const itemCount = options.length; - - return new Promise((resolve) => { - render(); - readline.emitKeypressEvents(process.stdin); - const wasRaw = process.stdin.isRaw; - if (process.stdin.setRawMode) process.stdin.setRawMode(true); - process.stdin.resume(); - - function cleanup(): void { - showCursor(); - process.stdin.removeListener("keypress", onKey); - if (process.stdin.setRawMode) process.stdin.setRawMode(wasRaw ?? false); - process.stdin.pause(); - } - - function onKey(_str: string | undefined, key: readline.Key): void { - if (!key) return; - if ((key.ctrl && (key.name === "c" || key.name === "d")) || key.name === "escape") { - cleanup(); - process.stdout.write("\n"); - process.exit(130); // SIGINT-equivalent - } - if (key.name === "up") { - cursor = cursor > 0 ? cursor - 1 : itemCount - 1; - render(); - } else if (key.name === "down") { - cursor = cursor < itemCount - 1 ? cursor + 1 : 0; - render(); - } else if (key.name === "return" || key.name === "space") { - cleanup(); - process.stdout.write("\n"); - resolve(options[cursor].value); - } - } - - process.stdin.on("keypress", onKey); + const { options } = buildCliMenuOptions(detected, action); + const kit = screenKit(optsFor(process.stdout)); + process.stdout.write(`\n${kit.header(action === "uninstall" ? "Remove hooks" : "Install hooks")}\n\n`); + const choices: SelectChoice[] = options.map((option) => ({ + label: option.label, + value: option.value, + section: option.detected ? "found on this machine" : "not installed, set up ahead of time", + })); + const picked = await selectOne({ + message: "Choose agents", + meta: `${detected.length} found on this machine`, + choices, + spaceSelects: true, }); -} - -/** - * Show interactive searchable policy selector. - * @param preSelected — policy names to pre-check (e.g. from existing config). - * When omitted, uses each policy's defaultEnabled flag. - * @param options — prompt options (e.g. includeBeta) - */ -export async function promptPolicySelection( - preSelected?: string[], - options: PromptOptions = {}, -): Promise { - const { includeBeta = false } = options; - - // If stdin is not a TTY (piped/CI), return defaults - if (!process.stdin.isTTY) { - const available = BUILTIN_POLICIES.filter((p) => includeBeta || !p.beta); - // Returned UNCHANGED, deliberately. This used to intersect with the builtin - // catalog, and manager.ts writes the result straight back — so a piped - // `policies --install` silently deleted every enabled name the catalog did - // not contain: a `failproofai/`-qualified name (a form the enforcement path - // explicitly accepts, `registerBuiltinPolicies` canonicalizes both), a beta - // policy enabled with `--beta`, and now any pack policy. The line that - // reports what was enabled prints only the survivors, so the loss was - // invisible. Carrying an unknown name costs nothing — registration looks it - // up in a Set and finds nothing — while dropping one destroys configuration. - if (preSelected) return [...preSelected]; - return available.filter((p) => p.defaultEnabled).map((p) => p.name); - } - - const preSelectedSet = preSelected ? new Set(preSelected) : null; - - const items: SelectItem[] = BUILTIN_POLICIES - .filter((p) => includeBeta || !p.beta) - .map((p) => ({ - name: p.name, - description: p.description, - category: p.category, - selected: preSelectedSet ? preSelectedSet.has(p.name) : p.defaultEnabled, - beta: !!p.beta, - })); - - // Everything the user has enabled that this picker cannot show a row for, for - // the same reason as the non-TTY branch above: the picker's universe is the - // builtin catalog, and resolving from it alone deletes anything else. Kept in - // their original order and appended to whatever the user picks. - const carried = preSelected - ? preSelected.filter((name) => !items.some((i) => i.name === name)) - : []; - - const total = items.length; - const WINDOW_SIZE = 10; - - // Shared brand painter from tui.ts, bound to role names so a reader never has - // to decode which hue a role happens to use today (glyphs imported above). - const useColor = !process.env.NO_COLOR; - const c = paint(useColor); - const { dim, bold } = c; - const stepMark = c.guide; // ◆ step marker + live selected-count - const activeName = c.pinkBold; // row under the cursor - const selectedMark = c.pink; // checked boxes / selected names - const betaTag = c.softPink; // "beta" pill - // Fixed name column so descriptions align into a clean, scannable second column. - const NAME_COL = Math.min(34, Math.max(10, ...items.map((i) => i.name.length))); - - let cursor = 0; - let search = ""; - let lastLineCount = 0; - let cursorHidden = false; - - function hideCursor(): void { - if (!cursorHidden) { - process.stdout.write("\x1B[?25l"); - cursorHidden = true; - } - } - - function showCursor(): void { - if (cursorHidden) { - process.stdout.write("\x1B[?25h"); - cursorHidden = false; - } - } - - function getFiltered(): SelectItem[] { - if (!search) return items; - const q = search.toLowerCase(); - return items.filter( - (i) => i.name.toLowerCase().includes(q) || i.description.toLowerCase().includes(q), - ); - } - - // Build display rows: category header rows interspersed with item rows. - // Categories appear in the order they first appear in BUILTIN_POLICIES. - function buildDisplayRows(filtered: SelectItem[]): DisplayRow[] { - // Single pass: compute category order, enabled counts, and total counts together. - const categoryOrder: string[] = []; - const categoryEnabledCount = new Map(); - const categoryTotalCount = new Map(); - for (const p of items) { - if (!categoryEnabledCount.has(p.category)) { - categoryOrder.push(p.category); - categoryEnabledCount.set(p.category, 0); - categoryTotalCount.set(p.category, 0); - } - categoryTotalCount.set(p.category, categoryTotalCount.get(p.category)! + 1); - if (p.selected) categoryEnabledCount.set(p.category, categoryEnabledCount.get(p.category)! + 1); - } - - const filteredByCategory = new Map(); - for (const item of filtered) { - const bucket = filteredByCategory.get(item.category) ?? []; - bucket.push(item); - filteredByCategory.set(item.category, bucket); - } - - const rows: DisplayRow[] = []; - let idx = 0; - for (const cat of categoryOrder) { - const catFiltered = filteredByCategory.get(cat); - if (!catFiltered || catFiltered.length === 0) continue; - rows.push({ kind: "header", category: cat, enabledCount: categoryEnabledCount.get(cat)!, totalCount: categoryTotalCount.get(cat)! }); - for (const item of catFiltered) { - rows.push({ kind: "item", item, filteredIndex: idx++ }); - } - } - return rows; + if (picked === null) { + process.stdout.write("\n"); + process.exit(130); // SIGINT-equivalent } - - function render(): void { - const cols = process.stdout.columns || 100; - hideCursor(); - - const filtered = getFiltered(); - const shown = filtered.length; - if (shown > 0 && cursor >= shown) cursor = shown - 1; - const selectedCount = items.filter((i) => i.selected).length; - - const g = dim(BAR); - const lines: string[] = []; - - // header - lines.push(g); - lines.push( - `${stepMark(STEP)} ${bold("Choose the policies to enable")} ${dim("·")} ` + - `${stepMark(String(selectedCount))} ${dim("selected")}`, - ); - - // search - const blockCursor = useColor ? "\x1B[7m \x1B[0m" : "_"; - const countLabel = search ? dim(`${shown}/${total} shown`) : dim(`${total} policies`); - lines.push(`${g} ${dim("filter ›")} ${search}${blockCursor} ${countLabel}`); - lines.push(g); - - if (shown === 0) { - lines.push(`${g} ${dim(`no policies match “${search}”`)}`); - for (let i = 0; i < WINDOW_SIZE + 1; i++) lines.push(g); - } else { - const displayRows = buildDisplayRows(filtered); - - let cursorDisplayRow = 0; - for (let i = 0; i < displayRows.length; i++) { - const row = displayRows[i]; - if (row.kind === "item" && row.filteredIndex === cursor) { - cursorDisplayRow = i; - break; - } - } - let windowStart = cursorDisplayRow - Math.floor(WINDOW_SIZE / 2); - windowStart = Math.max(0, windowStart); - windowStart = Math.min(windowStart, Math.max(0, displayRows.length - WINDOW_SIZE)); - const windowEnd = Math.min(displayRows.length, windowStart + WINDOW_SIZE); - - const aboveItems = displayRows.slice(0, windowStart).filter((r) => r.kind === "item").length; - lines.push(aboveItems > 0 ? `${g} ${dim(`↑ ${aboveItems} more`)}` : g); - - for (let i = windowStart; i < windowEnd; i++) { - const row = displayRows[i]; - if (row.kind === "header") { - lines.push(`${g} ${dim(row.category.toUpperCase())} ${dim(`${row.enabledCount}/${row.totalCount}`)}`); - } else { - const item = row.item; - const active = row.filteredIndex === cursor; - const box = item.selected ? selectedMark(BOX_ON) : dim(BOX_OFF); - const rawName = item.name.padEnd(NAME_COL); - const name = active ? activeName(rawName) : item.selected ? rawName : dim(rawName); - const beta = item.beta ? ` ${betaTag("beta")}` : ""; - const descWidth = Math.max(8, cols - NAME_COL - 8 - (item.beta ? 5 : 0)); - const desc = dim(truncateLine(item.description, descWidth)); - lines.push(`${g} ${box} ${name}${beta} ${desc}`); - } - } - for (let i = windowEnd - windowStart; i < WINDOW_SIZE; i++) lines.push(g); - - const belowItems = displayRows.slice(windowEnd).filter((r) => r.kind === "item").length; - lines.push(belowItems > 0 ? `${g} ${dim(`↓ ${belowItems} more`)}` : g); - } - - // footer - lines.push(g); - lines.push(`${g} ${dim("↑/↓ move · space select · ctrl+a all · ↵ save · type to filter · esc clear")}`); - - if (lastLineCount > 0) process.stdout.write(`\x1B[${lastLineCount}A\x1B[J`); - process.stdout.write(lines.map((l) => truncateLine(l, cols)).join("\n") + "\n"); - lastLineCount = lines.length; - } - - return new Promise((resolve) => { - render(); - - process.stdin.setRawMode(true); - process.stdin.resume(); - // Use a single data→keypress pipeline with no readline.Interface. - // readline.createInterface would register its own competing data listener - // and its close() call would unexpectedly pause stdin, breaking arrow keys. - readline.emitKeypressEvents(process.stdin); - - function keypressHandler(_str: string | undefined, key: readline.Key): void { - if (!key) return; - - if (key.ctrl && key.name === "c") { - cleanup(); - process.exit(0); - } - - const filtered = getFiltered(); - - if (key.name === "up") { - if (filtered.length > 0) { - cursor = cursor > 0 ? cursor - 1 : filtered.length - 1; - } - render(); - } else if (key.name === "down") { - if (filtered.length > 0) { - cursor = cursor < filtered.length - 1 ? cursor + 1 : 0; - } - render(); - } else if (key.name === "space") { - const item = filtered[cursor]; - if (item) item.selected = !item.selected; - render(); - } else if (key.name === "return" || (key.ctrl && key.name === "s")) { - // Save — collapse the picker to a one-line summary, then resolve. - cleanup(); - const selected = items.filter((i) => i.selected).map((i) => i.name); - if (lastLineCount > 0) process.stdout.write(`\x1B[${lastLineCount}A\x1B[J`); - // Carried entries are NAMED, not counted. "3 kept" reads as fine right - // up until someone needs to know which three. - const summary = carried.length > 0 - ? `${selected.length} selected, kept ${carried.join(", ")}` - : `${selected.length} selected`; - process.stdout.write( - `${dim(BAR)}\n${dim(DONE)} Policies\n${dim(BAR)} ${dim(summary)}\n`, - ); - resolve([...selected, ...carried]); - } else if (key.name === "escape") { - // Clear search filter - search = ""; - cursor = 0; - render(); - } else if (key.ctrl && key.name === "a") { - // Toggle all visible items - const allSelected = filtered.length > 0 && filtered.every((i) => i.selected); - for (const item of filtered) item.selected = !allSelected; - render(); - } else if (key.name === "backspace" || key.name === "delete") { - if (search.length > 0) { - search = search.slice(0, -1); - cursor = 0; - render(); - } - } else if (_str && _str.length === 1 && !key.ctrl && !key.meta) { - // All printable characters (including 'a', 's') go to search - search += _str; - cursor = 0; - render(); - } - } - - function cleanup(): void { - showCursor(); - process.stdin.removeListener("keypress", keypressHandler); - process.stdin.setRawMode(false); - process.stdin.pause(); - } - - process.stdin.on("keypress", keypressHandler); - }); + return picked; } diff --git a/src/hooks/pack-cli.ts b/src/hooks/pack-cli.ts index 2cb7cae10..aeeb6c0d3 100644 --- a/src/hooks/pack-cli.ts +++ b/src/hooks/pack-cli.ts @@ -12,7 +12,7 @@ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdir import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { INTEGRATION_TYPES } from "./types"; import { PACK_COMMIT_RE, PACK_VERSION_RE } from "./pack-manifest"; -import { detectInstalledClis } from "./integrations"; +import { detectInstalledClis, getIntegration } from "./integrations"; import { packParamsProblem, parsePackIdentity, @@ -59,6 +59,7 @@ import { optsFor, rows as kitRows, rule, + screenKit, stack, table, title, @@ -777,10 +778,9 @@ export async function runPolicyPicker( const before = new Set(rows.filter((r) => r.checked).map((r) => r.value)); const picked = await multiSelect({ - message: "Which policies should be on?", + message: "Choose policies", choices: rows, summaryNoun: "policies", - hint: "space toggles · ctrl+a all · ↵ confirm · what is on now is ticked", stdin, stdout, }); @@ -2612,7 +2612,7 @@ async function pickClis( ): Promise { const detected = new Set(detectInstalledClis()); const choices: MultiChoice[] = INTEGRATION_TYPES.map((id) => ({ - label: id, + label: getIntegration(id).displayName, value: id, checked: true, hint: detected.has(id) ? "installed here" : "", @@ -2621,8 +2621,9 @@ async function pickClis( message: "Which agents should this pack guard?", choices, minSelected: 1, + minMessage: "Pick at least one agent.", summaryNoun: "agents", - hint: `space toggles · ctrl+a all · ↵ confirm · ${detected.size} detected on this machine`, + meta: (checked) => `${checked.length} of ${choices.length} selected · ${detected.size} found on this machine`, stdin: io.stdin, stdout: io.stdout, }); @@ -2710,21 +2711,18 @@ async function pickFromSource( }); } } - const on = preview.policies.filter((p) => p.defaultEnabled).length; - // Said BEFORE the list, because the list cannot say it: a pack's Jev checks are - // not rows here, and they arrive whole. Somebody ticking boxes should know - // that is part of what they are agreeing to. + const kit = screenKit(optsFor(io.stdout)); + const lead = ["", kit.header(`Add ${preview.id}@${preview.version}`), ""]; + // Said BEFORE the list because the list cannot say it: Jev checks are not + // rows here, and they arrive whole with the pack. if (preview.semantic.length > 0) { - io.stdout.write( - `\n This pack also brings ${semanticPhrase(preview.semantic.length)} (not selectable), ` + - `${howJevAsksChecks(preview)}.\n See: failproofai policies show ${source}\n\n`, - ); + lead.push(`Also brings ${semanticPhrase(preview.semantic.length)}, ${howJevAsksChecks(preview)}.`, ""); } + io.stdout.write(lead.join("\n") + "\n"); const picked = await multiSelect({ - message: `${preview.id}@${preview.version} — which of these should be on?`, + message: "Choose policies", choices: rows, summaryNoun: "policies", - hint: `space toggles · ctrl+a all · ↵ confirm · ${on} of ${rows.length} are the publisher's defaults`, stdin: io.stdin, stdout: io.stdout, }); diff --git a/src/hooks/tui.ts b/src/hooks/tui.ts index 95a36c2f2..ac2a57044 100644 --- a/src/hooks/tui.ts +++ b/src/hooks/tui.ts @@ -41,6 +41,12 @@ export interface SelectOneOptions { choices: SelectChoice[]; /** Static info lines rendered under the question (e.g. a review summary). */ body?: string[]; + /** Grey text after the heading — what the list is, e.g. "9 found on this machine". */ + meta?: string; + /** The lines the prompt collapses to once answered, instead of `✓