diff --git a/.gitignore b/.gitignore index db0caab..e2d662e 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ node_modules/ /dist/ /build/ /out/ +/release/ /coverage/ .vite/ .eslintcache diff --git a/README.md b/README.md index 7b1b436..0eaf5b8 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,59 @@ assistant is named **JARVIS** by default; the product is **Local Agent**. > [docs/phase-2-coding-actions.md](docs/phase-2-coding-actions.md) for the > design, the bounds, and what remains explicitly deferred (file creation and > deletion, arbitrary commands, dependency installation, browser and Windows -> automation, memory, and everything else Phase 2 has not reached yet). +> automation, and everything else Phase 2 has not reached yet). +> +> **Phase 2, Milestone 8 adds local memory.** Short notes you write about how +> you want to be worked with, in three scopes: `session`, held in memory for +> one run of the application and never written to disk; `project`, kept in a +> file addressed by a hash of the approved project's own root path, so one +> project's notes are in a file another project's session never opens; and +> `personal`. Nothing here grants anything — a memory record declares no field +> for a permission, a tool or a path, and nothing reads one out of its text — +> and **nothing here can be written by a model**: a record's `source` is an +> enum of `user` and `import`, both stamped by the main process, so there is no +> value a chat reply or an agent step could be stored under. Retrieval is a +> bounded keyword scan returning at most eight records, never the store. See +> [docs/phase-2-memory.md](docs/phase-2-memory.md) for the design, the +> credential screen and its stated limits, and what is deliberately absent +> (vector RAG, embeddings, cloud sync, telemetry, transcript storage and +> automatic inference). +> +> **Phase 2, Milestone 9 adds the workflow engine.** A saved, named, repeatable +> recipe for running an agent you already have: ordered steps, conditions, +> bounded retries, confirmation checkpoints, success criteria and limits. It +> sits at the narrow end of `workflow ⊆ agent profile ⊆ permission policy` — +> every step names a tool from the fixed Milestone 7 registry, must also be +> allowed by the agent profile the workflow selects, and is re-checked against +> that profile before it runs — so a workflow introduces **no new capability +> and cannot widen anything**. It **cannot start itself**: its trigger is an +> enum with one member, `manual`, so a schedule, a file watch, a Git hook and +> an inbox are not disabled anywhere, they are not representable. Retries are +> capped per step and every attempt counts against the run's step ceiling, so +> an unbounded loop is not expressible either. A run can be paused at the next +> step boundary or cancelled outright, and its steps are executed by the _same_ +> step runner an agent run uses — so every one of them reaches the unchanged +> permission engine and audit log. See +> [docs/phase-2-workflows.md](docs/phase-2-workflows.md) for the design, the +> check order, and why a configured rollback honestly reports "nothing to roll +> back" in this milestone. +> +> **Phase 2, Milestone 10 adds Windows automation.** A bounded layer over +> fifteen registered desktop actions — launching an approved application, +> opening an approved folder or website, focusing this application's own +> window, and running a registered script — every one fixed in reviewed +> source (`shared/automation/registry.ts`), never configuration. A request +> names an id from that closed enum and **nothing else**: no path, no URL, no +> argument, no command line. Every tool routes through the same action type, +> `automation.run`, on the confirmation floor, through the unmodified +> `handleActionProposal` pipeline. A run is reported successful only once +> Local Agent has observed a concrete verification signal — the process did +> not fail immediately, the OS shell reported no error, or the window was +> actually focused — never on the strength of having started. See +> [docs/phase-2-automation.md](docs/phase-2-automation.md) for the design and +> the two limitations stated plainly there: `focus-window` reaches only this +> application's own window, and cancelling a launch cannot kill a process that +> already started. All rights reserved. No licence has been granted for this project. @@ -198,18 +250,30 @@ in [docs/phase-2-chat-architecture.md](docs/phase-2-chat-architecture.md), and [docs/phase-2-provider-completion.md](docs/phase-2-provider-completion.md); the workspace design is in -[docs/phase-2-coding-workspace.md](docs/phase-2-coding-workspace.md). +[docs/phase-2-coding-workspace.md](docs/phase-2-coding-workspace.md); the +agent profile and orchestration design is in +[docs/phase-2-agent-profiles.md](docs/phase-2-agent-profiles.md); and the +memory design is in [docs/phase-2-memory.md](docs/phase-2-memory.md); and the +workflow design is in [docs/phase-2-workflows.md](docs/phase-2-workflows.md); +and the Windows automation design is in +[docs/phase-2-automation.md](docs/phase-2-automation.md). ## Where your data lives Application code lives in this repository. Everything else — settings, secrets, permission policy, audit logs, emergency-stop state and memory — lives outside it, under `%APPDATA%\Local-Agent\`, each in its own location. +Since Milestone 9, `workflows\workflows.json` holds workflow definitions — +never a credential, and never a trigger that could start one by itself. `settings.json` and the encrypted `secrets\secrets.enc` are now reachable from the running application through real, permission-gated IPC channels; permission policy and emergency-stop state are still loaded read-only at -startup, with no channel of their own yet. See -[docs/data-locations.md](docs/data-locations.md). +startup, with no channel of their own yet. Since Milestone 8, `memory\` holds +one file per persisted scope — never a credential, and never a session note, +which is held in memory and written nowhere. Windows automation, added in +Milestone 10, adds no file at all: its tool registry is fixed in reviewed +source, not user-editable configuration, so there is nothing for it to +persist. See [docs/data-locations.md](docs/data-locations.md). ## Requirements @@ -260,6 +324,40 @@ runner that launches the real Electron application for the end-to-end suite without extra scaffolding a Linux runner would need to work around a platform this project does not ship on. +## Packaging a Windows installer + +```bash +npm run package:win # builds the app, then produces a Windows NSIS installer +``` + +This runs `npm run build` and then `electron-builder --win nsis`, configured +in [electron-builder.json](electron-builder.json). The installer is written +to `release/Local Agent Setup .exe` (an unpacked, runnable copy also +lands in `release/win-unpacked/`); both are git-ignored, never committed. + +`files` in that config keeps the package to exactly what the running app +needs: the compiled `out/` (main process, preload) and built `dist/renderer/` +output, plus `package.json`, are included; `react`, `react-dom` and their +transitive `scheduler` dependency are excluded, since Vite already inlines +them into the renderer bundle and the main process never requires them at +runtime — the only `node_modules` package left in the packaged app is `zod`, +which the main process's schema validation genuinely needs unbundled. No +source file, test, secret, `.env`, or development-only file is included; +electron-builder excludes `devDependencies` automatically, and nothing under +`src/`, `tests/` or `docs/` is ever selected. + +The first NSIS build downloads NSIS's own build tooling (from +electron-builder's maintained, checksum-verified binaries release, cached +under `%LOCALAPPDATA%\electron-builder\Cache` afterward) — this is +electron-builder's standard, expected mechanism for producing a Windows +installer and happens once per machine. + +**Out of scope, deliberately:** code signing (the installer and its +executables are unsigned — `Get-AuthenticodeSignature` reports `NotSigned`), +auto-updates, and any publish/release step (`--publish never` is passed +explicitly, and nothing in this repository's configuration references an +update feed or a publish target). + ## Repository layout ``` @@ -302,9 +400,21 @@ src/main/ Privileged Electron main process. Owns the BrowserWindow, file; workspace-planner.ts), agent profiles and the bounded orchestrator (agent-profiles.ts — fail-safe, atomic storage that never persists a built-in; agent-orchestrator.ts — the - run loop, which decides nothing about permissions), and the - registered IPC channels (ipc.ts), of which chat:chunk is the - only main-to-renderer event. + run loop, which decides nothing about permissions), local + memory (memory-store.ts — fail-safe, atomic storage, with the + session scope held in memory and never written; + memory-service.ts — the scoped operations, which never learn + a path; memory-transfer.ts and memory-picker.ts — files + outside the application, at a path only a native dialog can + choose), the workflow engine (workflow-store.ts — fail-safe, + atomic storage that refuses to edit or delete a running + workflow; workflow-runner.ts — the manual run loop, which + decides nothing about permissions and executes no step + itself), Windows automation (windows-automation.ts — + performs exactly one registered tool from a fixed registry, + never a renderer-supplied path, URL or command), and the + registered IPC channels (ipc.ts), of which chat:chunk and + workflow:progress are the only main-to-renderer events. src/preload/ The single contextBridge. Exposes a narrow, explicitly enumerated, typed API — never ipcRenderer, never a generic invoke-any-channel function. Bundled into one file: a @@ -324,7 +434,19 @@ src/renderer/ React interface: App.tsx gates on onboardingCompleted, call window.localAgent), and agent/ is the Milestone 7 profile and run surface (Agents.tsx, the framework-independent agent-controller.ts, useAgent.ts, and ipc-agent-client.ts — - the third and last file permitted to call window.localAgent). + the third file permitted to call window.localAgent), and + memory/ is the Milestone 8 Memory Centre (Memory.tsx, the + framework-independent memory-controller.ts, useMemory.ts, and + ipc-memory-client.ts — the fourth file permitted to call + window.localAgent), and workflow/ is the Milestone 9 Workflow + Dashboard (Workflows.tsx, the framework-independent + workflow-controller.ts, useWorkflow.ts, and + ipc-workflow-client.ts — the fifth file permitted to call + window.localAgent), and automation/ is the Milestone 10 + Automation panel (Automation.tsx, the framework-independent + automation-controller.ts, useAutomation.ts, and + ipc-automation-client.ts — the sixth and last file permitted + to call window.localAgent). No Node, no Electron, no direct filesystem or network access anywhere else in this directory — only the bridge at window.localAgent. diff --git a/docs/architecture.md b/docs/architecture.md index d53b11b..3d4022a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -48,6 +48,36 @@ > to an action type Milestones 5 and 6 already defined, so a profile can only > ever _narrow_ what the permission policy already allowed. The permission > engine, executor, pipeline and audit writer have **zero diff** for the sixth +> consecutive milestone. Phase 2 Milestone 8 adds local memory +> (`main/memory-store.ts`, `main/memory-service.ts`, `main/memory-transfer.ts`, +> `main/memory-picker.ts`, over the pure `shared/memory/`) — ten more channels +> and five more action types, none of which is a new capability either: they +> read and write short user-authored notes inside `%APPDATA%\Local-Agent`, +> and a note grants nothing, because no field of one can name a permission, a +> tool or a path. Isolation between scopes is where the bytes are rather than +> a filter: session notes live only in memory and are never written, and a +> project's notes live in a file addressed by a hash of that project's own +> canonical root. The permission engine, executor, pipeline and audit writer +> have **zero diff** for the seventh consecutive milestone. Phase 2 Milestone 9 +> adds the workflow engine (`main/workflow-store.ts`, +> `main/workflow-runner.ts`, over the pure `shared/workflow/`) — nine more +> channels, one advisory progress event and three more action types, and once +> again not one new capability: a workflow is a saved recipe for running an +> agent profile that already exists, every step it may name is a Milestone 7 +> agent tool, and its steps are executed by the _same_ step runner an agent run +> uses. A workflow sits at the narrow end of +> `workflow ⊆ agent profile ⊆ permission policy` and cannot start itself: its +> trigger is an enum with one member. The permission engine, executor, pipeline +> and audit writer have **zero diff** for the eighth consecutive milestone. +> Phase 2 Milestone 10 adds Windows automation (`main/windows-automation.ts`, +> over the pure `shared/automation/`) — three more channels and two more +> action types, both routed through the unmodified `runAction` / +> `handleActionProposal` pipeline. Every one of the fifteen registered tools — +> launching an approved application, opening an approved folder or website, +> focusing this application's own window, running a registered script — is a +> fixed entry in reviewed source; a request names an id from that closed enum +> and never a path, a URL, an argument or a command line. The permission +> engine, executor, pipeline and audit writer have **zero diff** for the ninth > consecutive milestone. This document marks which parts exist today. --- @@ -100,35 +130,42 @@ call. See `docs/phase-2-coding-workspace.md`. ## Modules -| Module | Responsibility | Must not | -| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | -| `shared` **(exists)** | Zod schemas, derived types, constants. Pure data and pure functions. | Perform I/O; import Electron or Node built-ins; depend on `main`, `preload` or `renderer`. | -| `renderer` **(exists)** | All interface, including first-run onboarding. Renders state, collects input, sends requests over the bridge. | Touch Node APIs, the filesystem or `ipcRenderer`; read secrets; load remote content. | -| `preload` **(exists)** | The single bridge. Exposes an explicitly enumerated, typed API via `contextBridge`. | Expose `ipcRenderer`; provide a generic "invoke any channel" function; expose a Node primitive. | -| `main/ipc` **(exists)** | Receives every request, validates its payload and its response against a schema, and routes it through `main/action-runtime.ts`. | Execute anything itself; call `execute` or `main/secrets.ts` directly, bypassing the permission engine. | -| `main/action-runtime` **(exists)** | Loads the current policy and emergency state fresh per request and calls `handleActionProposal`. | Add a decision step of its own; cache policy or emergency state across requests. | -| `main/confirm` **(exists)** | `showNativeConfirmation` — the real `dialog.showMessageBox`, parented to the main window. | Be called from the renderer; be rendered as HTML; describe a secret in the dialog text. | -| `main/permissions` **(exists)** | Pure decision function. `(proposal, policy, emergencyState) → verdict`. | Perform I/O, show dialogs, or log. | -| `main/executor` **(exists)** | The **only** module that performs side effects. Requires a permission verdict as an argument. | Be called from `renderer` or `preload`; act without a decision; make its own policy judgements. | -| `main/action-pipeline` **(exists)** | Assembles `permissions → [confirm] → executor → audit` into one function every IPC handler calls. | Be bypassed by a handler that wires the pieces together itself. | -| `main/audit` **(exists)** | Append-only event writer with redaction and daily rotation. | Expose any update or delete function; write an unredacted secret. | -| `main/settings` **(exists)** | Loads, validates and atomically writes settings. Fails closed to safe defaults. | Store secrets; write without going through the executor. | -| `main/settings-service` **(exists)** | Reconciles `hasApiKey` against the secret store on every read and write; assembles onboarding/provider settings writes. | Accept `hasApiKey` as caller input; skip re-validating the full document before persisting. | -| `main/secrets` **(exists)** | Encrypts and decrypts via the **synchronous** `safeStorage` API (Windows DPAPI). Presence is answered from the file's shape, never by decrypting. | Return a plaintext secret across IPC; log a secret; fall back to storing plaintext. | -| `main/policy` **(exists)** | Loads and validates the permission policy file. Fails closed to `createDefaultPermissionPolicy()`. | Merge a partially-valid document; expose a write path. | -| `main/emergency` **(exists)** | Loads and atomically persists emergency-stop state; `engageEmergencyStop`/`resetEmergencyStop` are `perform` callbacks for the action pipeline. | Be bypassable by the renderer, a model, or the policy file; make its own permission decision. | -| `main/paths` **(exists)** | Single source of truth for user-data locations. | Accept a user-supplied path. | -| `main/directory-picker` **(exists)** | `showDirectoryPicker` — the real `dialog.showOpenDialog`. Takes no path, so the user alone chooses which directory becomes readable. | Be called from the renderer; accept, suggest or remember a location; create a directory. | -| `main/workspace-paths` **(exists)** | Canonical containment: join, check, `realpath`, check again. The layer that catches a symbolic link. | Trust a lexical check alone; put a path or an `errno` into a thrown error. | -| `main/workspace-session` **(exists)** | Holds the one approved project, in memory, for one application run. | Persist the approved path; accept a project that was not canonicalised and validated. | -| `main/workspace-inspector` **(exists)** | The only module that reads a user's own file: list, read, search. Bounded in every dimension. | Write, create, rename or delete anything; log; follow a link out; read an excluded path. | -| `main/workspace-planner` **(exists)** | Gathers observations about the approved project and calls the pure plan builder. | Call a model; produce content that could be applied; read the clock. | -| `main/workspace-changes` **(exists)** | The only module that writes a user's own file. Holds proposed change sets, diffs them, backs up, applies all-or-nothing, restores. | Create or delete a project file; write without a fresh containment and hash re-check; delete a backup. | -| `main/process-runner` **(exists)** | The only module that starts a process. `shell: false`, bounded in time and output, no terminal, allowlisted environment, tree-killed. | Use a shell; build a command from a request; inherit the parent environment; leave output unread. | -| `main/project-commands` **(exists)** | Reads the project's declared scripts and runs one registry entry inside the approved root. | Run a script the registry does not name; take an argument vector from anywhere but a literal. | -| `main/git-runner` **(exists)** | Read-only `status`/`diff`, plus one checkpoint commit on the branch already checked out. Hooks disabled for every invocation. | Reset, checkout, switch, clean, delete a branch, rewrite history, or contact a remote. | -| `main/agent-profiles` **(exists)** | Loads, validates and atomically writes agent profiles. Fails closed to the built-ins. Never persists a built-in. | Store a credential; merge a partially-valid document; let a stored profile claim a built-in identifier. | -| `main/agent-orchestrator` **(exists)** | Drives one bounded run: asks the pure decider what to do next, calls the injected step executor, classifies the result. | Decide that anything is permitted; call a model; reach the filesystem or a process itself. | +| Module | Responsibility | Must not | +| --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| `shared` **(exists)** | Zod schemas, derived types, constants. Pure data and pure functions. | Perform I/O; import Electron or Node built-ins; depend on `main`, `preload` or `renderer`. | +| `renderer` **(exists)** | All interface, including first-run onboarding. Renders state, collects input, sends requests over the bridge. | Touch Node APIs, the filesystem or `ipcRenderer`; read secrets; load remote content. | +| `preload` **(exists)** | The single bridge. Exposes an explicitly enumerated, typed API via `contextBridge`. | Expose `ipcRenderer`; provide a generic "invoke any channel" function; expose a Node primitive. | +| `main/ipc` **(exists)** | Receives every request, validates its payload and its response against a schema, and routes it through `main/action-runtime.ts`. | Execute anything itself; call `execute` or `main/secrets.ts` directly, bypassing the permission engine. | +| `main/action-runtime` **(exists)** | Loads the current policy and emergency state fresh per request and calls `handleActionProposal`. | Add a decision step of its own; cache policy or emergency state across requests. | +| `main/confirm` **(exists)** | `showNativeConfirmation` — the real `dialog.showMessageBox`, parented to the main window. | Be called from the renderer; be rendered as HTML; describe a secret in the dialog text. | +| `main/permissions` **(exists)** | Pure decision function. `(proposal, policy, emergencyState) → verdict`. | Perform I/O, show dialogs, or log. | +| `main/executor` **(exists)** | The **only** module that performs side effects. Requires a permission verdict as an argument. | Be called from `renderer` or `preload`; act without a decision; make its own policy judgements. | +| `main/action-pipeline` **(exists)** | Assembles `permissions → [confirm] → executor → audit` into one function every IPC handler calls. | Be bypassed by a handler that wires the pieces together itself. | +| `main/audit` **(exists)** | Append-only event writer with redaction and daily rotation. | Expose any update or delete function; write an unredacted secret. | +| `main/settings` **(exists)** | Loads, validates and atomically writes settings. Fails closed to safe defaults. | Store secrets; write without going through the executor. | +| `main/settings-service` **(exists)** | Reconciles `hasApiKey` against the secret store on every read and write; assembles onboarding/provider settings writes. | Accept `hasApiKey` as caller input; skip re-validating the full document before persisting. | +| `main/secrets` **(exists)** | Encrypts and decrypts via the **synchronous** `safeStorage` API (Windows DPAPI). Presence is answered from the file's shape, never by decrypting. | Return a plaintext secret across IPC; log a secret; fall back to storing plaintext. | +| `main/policy` **(exists)** | Loads and validates the permission policy file. Fails closed to `createDefaultPermissionPolicy()`. | Merge a partially-valid document; expose a write path. | +| `main/emergency` **(exists)** | Loads and atomically persists emergency-stop state; `engageEmergencyStop`/`resetEmergencyStop` are `perform` callbacks for the action pipeline. | Be bypassable by the renderer, a model, or the policy file; make its own permission decision. | +| `main/paths` **(exists)** | Single source of truth for user-data locations. | Accept a user-supplied path. | +| `main/directory-picker` **(exists)** | `showDirectoryPicker` — the real `dialog.showOpenDialog`. Takes no path, so the user alone chooses which directory becomes readable. | Be called from the renderer; accept, suggest or remember a location; create a directory. | +| `main/workspace-paths` **(exists)** | Canonical containment: join, check, `realpath`, check again. The layer that catches a symbolic link. | Trust a lexical check alone; put a path or an `errno` into a thrown error. | +| `main/workspace-session` **(exists)** | Holds the one approved project, in memory, for one application run. | Persist the approved path; accept a project that was not canonicalised and validated. | +| `main/workspace-inspector` **(exists)** | The only module that reads a user's own file: list, read, search. Bounded in every dimension. | Write, create, rename or delete anything; log; follow a link out; read an excluded path. | +| `main/workspace-planner` **(exists)** | Gathers observations about the approved project and calls the pure plan builder. | Call a model; produce content that could be applied; read the clock. | +| `main/workspace-changes` **(exists)** | The only module that writes a user's own file. Holds proposed change sets, diffs them, backs up, applies all-or-nothing, restores. | Create or delete a project file; write without a fresh containment and hash re-check; delete a backup. | +| `main/process-runner` **(exists)** | The only module that starts a process. `shell: false`, bounded in time and output, no terminal, allowlisted environment, tree-killed. | Use a shell; build a command from a request; inherit the parent environment; leave output unread. | +| `main/project-commands` **(exists)** | Reads the project's declared scripts and runs one registry entry inside the approved root. | Run a script the registry does not name; take an argument vector from anywhere but a literal. | +| `main/git-runner` **(exists)** | Read-only `status`/`diff`, plus one checkpoint commit on the branch already checked out. Hooks disabled for every invocation. | Reset, checkout, switch, clean, delete a branch, rewrite history, or contact a remote. | +| `main/agent-profiles` **(exists)** | Loads, validates and atomically writes agent profiles. Fails closed to the built-ins. Never persists a built-in. | Store a credential; merge a partially-valid document; let a stored profile claim a built-in identifier. | +| `main/agent-orchestrator` **(exists)** | Drives one bounded run: asks the pure decider what to do next, calls the injected step executor, classifies the result. | Decide that anything is permitted; call a model; reach the filesystem or a process itself. | +| `main/memory-store` **(exists)** | Loads, validates and atomically writes one memory scope. Fails closed to an empty store. Holds the session scope in memory, never on disk. | Take a project key or a path from a caller; merge a partially-valid document; write the session scope. | +| `main/memory-service` **(exists)** | The scoped memory operations, over an injected store accessor. Screens content for credentials; prunes expired records on write. | Learn which file a scope lives in; move a record between scopes; let a filesystem error escape. | +| `main/memory-transfer` **(exists)** | Reads an import file defensively and writes an export atomically, at a path the user chose in a native dialog. | Choose a path; claim an imported file is a memory document; leave a partial export behind. | +| `main/memory-picker` **(exists)** | The two native dialogs that gate memory content crossing the application boundary. | Take a path, a default directory or a suggestion from the renderer. | +| `main/workflow-store` **(exists)** | Loads, validates and atomically writes workflow definitions. Fails closed to an empty store. Refuses to edit or delete a workflow that is running. | Merge a partially-valid document; let a definition name a non-manual trigger; store a credential. | +| `main/workflow-runner` **(exists)** | Drives one manual run: asks the pure decider what to do next, calls the shared agent step executor, classifies the result. | Decide that anything is permitted; call a model; execute a step itself; start without a person. | +| `main/windows-automation` **(exists)** | Performs exactly one registered tool: resolves it, spawns or asks the OS shell, verifies a concrete success signal. | Accept a path, a URL or a command from a request; run through a shell; kill an already-launched process; schedule anything. | ## Dependency direction diff --git a/docs/data-locations.md b/docs/data-locations.md index 0020054..d7ee020 100644 --- a/docs/data-locations.md +++ b/docs/data-locations.md @@ -35,7 +35,10 @@ user-supplied or renderer-supplied path. > (disengaged vs. engaged), not the same safe default — plus atomic engage > and reset writes. Milestone 7 implements `secrets\secrets.enc`: encrypted > with the **synchronous** `safeStorage` API (Windows DPAPI), never a -> plaintext fallback. Its loader distinguishes a missing file from a corrupt +> plaintext fallback. Phase 2 Milestone 8 implements `memory\`: one file per +> scope, each written with the same fail-safe load and atomic write pattern, +> plus the session scope which is never written at all. Its loader +> distinguishes a missing file from a corrupt > one internally, the same way `state\emergency.json`'s does, but — unlike > the emergency state — both collapse to the identical safe answer for every > caller (no key present), since there is no "fails engaged" equivalent for a @@ -60,6 +63,8 @@ user-supplied or renderer-supplied path. │ └── policy.json permission policy ├── agents/ │ └── profiles.json agent profiles (no credential, ever) +├── workflows\ +│ └── workflows.json workflow definitions (manual trigger only) ├── secrets\ │ └── secrets.enc encrypted credentials ├── logs\ @@ -67,18 +72,30 @@ user-supplied or renderer-supplied path. │ └── audit-YYYY-MM-DD.jsonl append-only audit trail, one file per UTC day ├── state\ │ └── emergency.json emergency-stop state -└── memory\ reserved, unused in Phase 1 +└── memory\ + ├── personal.json personal memory (never a credential) + └── projects\ + └── .json one file per approved project ``` -| Path | Contains | Notes | -| ------------------------- | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `settings.json` | Assistant name, user name, language, provider selection, `hasApiKey` flag | **[implemented, M3, wired to IPC in M7]** **Never a credential.** Strict schema; unknown keys rejected; written atomically; `hasApiKey` reconciled against `secrets.enc` on every read and write. | -| `permissions\policy.json` | Permission rules | **[implemented, M5]** Human-readable and human-editable. Cannot widen the model beyond the code-enforced floor, even if hand-edited to try. | -| `agents\profiles.json` | Agent profiles | **[implemented, P2 M7]** Human-readable and human-editable. Holds user profiles only — built-ins are read from reviewed source on every load and are never written here, so a hand edit cannot redefine one. Cannot grant a permission: its decision vocabulary has no `allow`. Declares no field capable of holding a credential. | -| `secrets\secrets.enc` | API keys | **[implemented, M7]** Encrypted with the synchronous `safeStorage` API (Windows DPAPI). Never leaves the main process in plaintext; presence, not content, is all any IPC channel reports. | -| `logs\audit\` | One JSON object per line | **[implemented, M4]** Append-only writer, called for real since M7's IPC channels. Records denials and rejected confirmations exactly like a success. | -| `state\emergency.json` | Emergency-stop state | **[implemented, M6]** Missing file on first launch means _disengaged_. Malformed or unreadable existing file means _engaged_. Written atomically; not called from the running app yet. | -| `memory\` | Reserved | Nothing is written here in Phase 1. | +| Path | Contains | Notes | +| ----------------------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `settings.json` | Assistant name, user name, language, provider selection, `hasApiKey` flag | **[implemented, M3, wired to IPC in M7]** **Never a credential.** Strict schema; unknown keys rejected; written atomically; `hasApiKey` reconciled against `secrets.enc` on every read and write. | +| `permissions\policy.json` | Permission rules | **[implemented, M5]** Human-readable and human-editable. Cannot widen the model beyond the code-enforced floor, even if hand-edited to try. | +| `agents\profiles.json` | Agent profiles | **[implemented, P2 M7]** Human-readable and human-editable. Holds user profiles only — built-ins are read from reviewed source on every load and are never written here, so a hand edit cannot redefine one. Cannot grant a permission: its decision vocabulary has no `allow`. Declares no field capable of holding a credential. | +| `workflows\workflows.json` | Workflow definitions | **[implemented, P2 M9]** Human-readable and human-editable. Cannot grant a permission, cannot widen the agent profile it selects, and cannot start itself: its `trigger` enum has one member, so a hand edit claiming a schedule fails validation and the whole document is discarded. Declares no field capable of holding a credential. | +| `secrets\secrets.enc` | API keys | **[implemented, M7]** Encrypted with the synchronous `safeStorage` API (Windows DPAPI). Never leaves the main process in plaintext; presence, not content, is all any IPC channel reports. | +| `logs\audit\` | One JSON object per line | **[implemented, M4]** Append-only writer, called for real since M7's IPC channels. Records denials and rejected confirmations exactly like a success. | +| `state\emergency.json` | Emergency-stop state | **[implemented, M6]** Missing file on first launch means _disengaged_. Malformed or unreadable existing file means _engaged_. Written atomically; not called from the running app yet. | +| `memory\personal.json` | Personal memory records | **[implemented, P2 M8]** Human-readable and human-editable. Plain JSON, deliberately — but it holds notes _about a person_, so see `docs/security-model.md` limitations 0.5 and 0.6. Declares no field capable of holding a credential, and credential-shaped _values_ are screened at the write boundary on a best-effort basis. | +| `memory\projects\.json` | One approved project's memory records | **[implemented, P2 M8]** The file name is a truncated SHA-256 of the project's canonical root path, never the path itself: a directory listing of `%APPDATA%` should not disclose which projects someone has opened. Reading or writing one requires that project to be approved in the current session. | +| _(none — Windows automation)_ | — | **[implemented, P2 M10]** Windows automation adds no file here. Its tool registry is fixed in reviewed source (`shared/automation/registry.ts`), not user-editable configuration, so there is nothing for it to persist. See `docs/phase-2-automation.md`. | + +> **Session memory has no entry above, and that is the point.** The third +> memory scope is held in the main process for the lifetime of one run of the +> application and is never written anywhere. Closing Local Agent ends it; +> there is no file to find afterwards, and no code path in +> `src/main/memory-store.ts` that could create one. ## Why they are separate diff --git a/docs/phase-2-automation.md b/docs/phase-2-automation.md new file mode 100644 index 0000000..d9824e7 --- /dev/null +++ b/docs/phase-2-automation.md @@ -0,0 +1,158 @@ +# Phase 2, Milestone 10 — Windows automation + +A bounded, permission-controlled layer over a fixed handful of reversible +desktop actions: launching an approved application, opening an approved +folder, opening an approved website, focusing this application's own window, +and running a registered script. + +This document explains what the automation layer is here, what it +deliberately is **not**, and why each decision was made. It complements +[architecture.md](architecture.md) and [security-model.md](security-model.md); +where those disagree with this file, they were written to cover the whole +system and they win. + +--- + +## The registry is the whole vocabulary + +``` +shared/automation/registry.ts +``` + +Fifteen tools, fixed in reviewed source — not configuration, not a setting, +and not anything a renderer, a project or a model can extend at runtime. A +request names an id from this closed enum and nothing else: no path, no URL, +no argument, no window handle, no command line. "What can automation do?" has +the same answer as "what is in this one array", never a larger one. + +Every tool routes through a single action type, `automation.run`, exactly as +every `command.run` entry does for a project's own scripts — one permission +decision, one confirmation-floor entry, one audit shape, whatever the tool +actually does. + +## The five kinds + +- **`launch-app`** and **`run-script`** are mechanically identical: each + resolves to one literal executable name under `%SystemRoot%\System32` and a + literal, empty argument vector, started with `spawn(..., { shell: false })` + — the same rule `main/process-runner.ts` already applies. The only + difference is which list a tool appears in, so "an application" (Notepad, + Calculator, File Explorer, Paint) and "a registered script" (Task Manager, + System Information) read as what they are in the interface. +- **`open-folder`** resolves to one of the current user's own special + folders (Desktop, Documents, Downloads), or to the already-approved + project root. There is no field, anywhere, that can carry an arbitrary + filesystem path. +- **`open-website`** resolves to one literal, fixed `https://` URL to one of + four allowed hosts (github.com, developer.mozilla.org, npmjs.com, + nodejs.org). `shared/automation/validation.ts`'s `isSafeAutomationUrl` + checks this again immediately before the URL is opened — defence in depth + against a registry entry that, in a later edit, stopped being a literal. +- **`focus-window`** brings this application's own window to the foreground. + +## What automation is not + +**It cannot run an arbitrary command.** There is no field for a program path, +an argument, a working directory or a shell — not "disabled", not +representable. `windows-automation.ts` never imports `child_process` outside +`launchDetached`, which takes a resolved absolute path and a literal argument +array, never a string. + +**It cannot reach an arbitrary path or URL.** A folder is one of four fixed +targets; a website is one of four fixed hosts. Neither is ever built from +renderer input. + +**It cannot focus an arbitrary window.** Focusing a window that belongs to +another process would need either an unapproved native dependency (there is +no Win32 API surface reachable from plain Node/Electron for this) or +unrestricted shell access — both of which this milestone's own security +requirements forbid. `focus-window` is scoped to this application's own +window, stated plainly here rather than half-implemented. See **Known +limitations** below. + +**It cannot escalate.** No tool runs with elevation, touches a credential, a +registry key, a security setting, or reads keystrokes, the camera or the +microphone. Nothing here starts a background timer, a watcher, or anything +that runs without an explicit `automation:run` call the user just approved. + +**It cannot be scheduled.** There is no `automation.schedule`, no +`automation.watch` and no cron-like field anywhere in the request or the +registry. Every run is one `automation:run` call, answered once. + +--- + +## The canonical request path + +``` +request → plan → permission check → confirmation → execute → verify → audit +``` + +- **request** — `automation:run` carries a `runId` and a `toolId` from the + closed enum. Validated by `automationRunRequestSchema` before anything else + runs. +- **plan** — `findAutomationTool(toolId)` resolves the fixed definition; this + is also what `describeAutomationRun` uses to build the confirmation text. +- **permission check** — `automation.run` is decided by the unmodified + `decidePermission`, on the same action type every tool uses, against + whatever the current permission policy says. +- **confirmation** — `automation.run` is on the confirmation floor + (`CONFIRMATION_REQUIRED_ACTION_TYPES`), so no policy edit can turn it into + an unconfirmed `allow`. The native dialog states the tool's own label and + description, never a raw path or URL. +- **execute** — `main/windows-automation.ts`'s `runAutomationTool` performs + exactly one registered action, through the same `handleActionProposal` → + `execute` boundary every other privileged action uses. +- **verify** — a run is `verified: true` only when Local Agent observed a + concrete success signal: the launched process did not fail or exit + immediately, the OS shell reported no error opening a folder or a website, + or the window was actually brought to the foreground. Anything else throws + a typed `AutomationError` and is reported as a failure, never silently + treated as success. +- **audit** — one record per call, carrying the tool id and its kind — both + literals from reviewed source — and nothing that could identify a resolved + path, a URL or a window title. + +## Bounds + +| Dimension | Bound | Constant | +| --------------------------- | --------------------------- | -------------------------------- | +| Launch attempts | 1 initial + up to 2 retries | `AUTOMATION_MAX_ATTEMPTS` | +| Launch grace window | 5s per attempt | `AUTOMATION_LAUNCH_TIMEOUT_MS` | +| Shell call (folder/website) | 10s | `AUTOMATION_SHELL_TIMEOUT_MS` | +| Concurrent actions | 1 | `AUTOMATION_MAX_CONCURRENT_RUNS` | + +An unbounded retry loop is not representable: attempts are capped +independently of any run duration, and a cancelled or emergency-stopped +attempt stops retrying immediately rather than exhausting the bound. + +## Cancellation is not "kill the program" + +`automation:cancel` aborts the run's `AbortSignal`. For `open-folder`, +`open-website` and `focus-window` this is a real, immediate stop — nothing +has been handed to the OS shell yet, or the wait for it to answer is +abandoned. For `launch-app` and `run-script` it can only ever **abandon a +launch attempt in progress**: if the process already started, it is +deliberately left running. A user who asked Local Agent to open Notepad did +not ask for the power to close it again, and killing a just-launched GUI +process out from under someone would be a worse surprise than letting the +cancel be a no-op past that point. This mirrors the honest scope reduction +`docs/phase-2-workflows.md` already documents for workflow pause: a real +control, stated for exactly what it does and no more. + +If Cancel is pressed while the native confirmation is still open, the request +is marked cancelled before execution. Electron does not expose a way to +dismiss that native dialog programmatically, so it remains until the user +chooses an answer; approving it afterward still cannot start the action. + +## Known limitations + +- **`focus-window` only reaches this application's own window.** Focusing an + arbitrary third-party window needs a capability this codebase does not + have and this milestone does not add one to get it. +- **A launch attempt cannot be killed once it has started.** See above. +- **The tool registry is fixed in source, not user-editable.** There is no + UI to add, remove or rename a registered application, folder, website or + script. Widening the registry is a reviewed source change, not a runtime + configuration change — deliberately, since a user-editable allowlist of + launchable programs would be a materially larger attack surface than a + fixed one. diff --git a/docs/phase-2-memory.md b/docs/phase-2-memory.md new file mode 100644 index 0000000..31a2b56 --- /dev/null +++ b/docs/phase-2-memory.md @@ -0,0 +1,326 @@ +# Phase 2, Milestone 8 — local memory + +Short notes the user writes about how they want to be worked with, stored on +their own machine, retrievable a handful at a time. + +This document explains what memory is here, what it deliberately is **not**, +and why each of those decisions was made. It complements +[architecture.md](architecture.md) and [security-model.md](security-model.md); +where they disagree with this file, they are the ones that were written to +cover the whole system and they win. + +--- + +## What a memory is + +A record with these fields, and no others: + +| Field | Meaning | +| ------------------------- | ------------------------------------------------------------------- | +| `id` | A UUID, assigned by the main process. | +| `scope` | `session`, `project` or `personal`. | +| `category` | One of seven closed values (see below). | +| `content` | The note itself. Up to 2 000 characters. | +| `source` | `user` or `import`. Stamped by the main process. | +| `createdAt` / `updatedAt` | UTC ISO-8601, from the main process's clock. | +| `importance` | 1–5. Used only for ordering. | +| `confidence` | 0–100. Used only for ordering. | +| `expiresAt` | A UTC ISO-8601 instant, or `null`. | +| `pinned` | "Always relevant". The user's own signal, and it outranks the rest. | + +The seven categories are the seven memory types the milestone asked for: +`user-preference`, `assistant-setting`, `project-decision`, +`project-convention`, `active-task`, `completed-task`, `agent-preference`. A +closed vocabulary rather than a free-text tag, because a category is +displayed, filtered on and used to break ties in retrieval, and a free-text +tag would be one more unbounded user-controlled string reaching all three. + +## What a memory is not + +**It is not authority.** A record declares no field for a permission, a tool, +an action type, a provider, a command or a path, and nothing anywhere in this +codebase reads one out of `content`. The string is stored, displayed and +matched against a search query; that is the complete list of things done with +it. No memory can widen what an agent may do, change a permission decision, or +name something to run. + +**It is not written by a model.** `source` has exactly two members, `user` and +`import`, and each is stamped by the one handler that performs that operation +— the input schema has no `source` field for a caller to supply. There is no +value a chat reply, an agent step or an inference could be stored under, so +"never silently save model output as memory" is a property of the type rather +than a check that could be forgotten. There is also no `memory.capture`, no +`memory.infer` and no `memory.learn` action type, and nothing in the chat or +agent paths calls a memory writer. + +**It is not a transcript.** Nothing stores a conversation. A record is a note +someone typed into a form and saved, capped at 2 000 characters, 200 records +per scope. + +**It is not a credential store.** No field of a record can carry one by name — +every object is a `strictObject`, so a document containing `apiKey`, `token` +or `password` is refused outright rather than stored and ignored. Values are a +separate and weaker matter; see "The credential screen" below. + +--- + +## The three scopes are three storage backends + +This is the part worth reading closely, because isolation here is a property +of _where the bytes are_ rather than of a filter someone has to remember to +apply. + +### `session` + +Held in a closure created per `registerIpcHandlers` call, exactly like the +approved-project session. **Never written to disk at all.** There is no +`session` branch in any function in `main/memory-store.ts` that touches the +filesystem, so this is not a policy that could be changed by a configuration +mistake — there is nowhere for a session note to go. Closing Local Agent ends +it. + +### `project` + +One file per approved project: + +``` +%APPDATA%\Local-Agent\memory\projects\.json +``` + +`` is the first 32 hexadecimal characters of the SHA-256 of the project's +**canonical, lowercased** root path. Three consequences, each deliberate: + +- **The path is never stored.** A project path carries a user name, often a + client or employer name, and sometimes the existence of a project that is + itself confidential. A directory listing of `%APPDATA%` should not disclose + any of that. +- **The key is derived, never supplied.** No caller passes a path, a key or a + project name into the store module; `requireProjectMemoryFile()` in + `main/ipc.ts` reads the approved project from the session and derives the + name. There is no parameter through which one project's session could name + another project's file. +- **Lowercasing matters.** Windows paths are case-insensitive, so `C:\Work\App` + and `c:\work\app` are the same project, and two stores for it would silently + split someone's notes in half. + +Reading or writing this scope requires a project approved in the current +session. With none, every project operation fails with `MEMORY_NO_PROJECT` — +except a retrieval, which skips the scope rather than failing, because a +reminder is not worth refusing because one of three sources is unavailable. + +### `personal` + +One file, `%APPDATA%\Local-Agent\memory\personal.json`. The only scope that +outlives both the session and the project. + +### A record cannot change scope + +An update addresses a record by id _within_ the scope its own submitted record +names, and a stored record found in a different scope is refused rather than +relocated. There is no move operation. Editing a project note into a personal +one is therefore not expressible — which is what keeps project isolation from +depending on the renderer behaving. + +--- + +## Storage behaviour + +`main/memory-store.ts` mirrors `main/settings.ts`, `main/policy.ts` and +`main/agent-profiles.ts` in detail, because a memory file is the same _kind_ +of thing those are: a user-editable document the application must read without +trusting it. + +- **Loading never throws and never merges.** A missing file, an unreadable + one, an oversized one, malformed JSON, a `__proto__` key anywhere in it, a + document the schema rejects, or a document declaring a scope other than the + one asked for, all resolve the same way: an empty store. A document either + validates in full, as itself, or it is discarded in full. +- **The size check happens before the read.** A file that grew without bound + should not be pulled into the privileged process's memory to discover that + it is too large. +- **Writing is atomic and re-validated.** Checked against the schema + immediately before serialising, written to a uniquely named temporary file + in the same directory, flushed, and moved into place with a single rename. +- **Nothing is created by reading.** The `memory\` directory appears on the + first write and not before. + +### Expiry + +A record past its own `expiresAt` is filtered out of every list, search, +retrieval and export, and is dropped from the file on the next write to that +scope. **Reads never write**, so an expiry does not by itself cause disk +activity: the record disappears from every view immediately and leaves the +file the next time anything is saved. An `expiresAt` that will not parse +counts as _expired_, not as "no expiry" — treating unreadable state as "keep +using this indefinitely" is the wrong direction to fail in for a privacy +control. + +--- + +## Retrieval + +Bounded keyword retrieval, using the same `extractObjectiveKeywords` the +coding planner already uses — not a second extractor. The whole operation is a +substring scan over at most 200 short strings per scope. + +- A record is eligible if its content contains at least one keyword from the + objective, **or** if it is pinned. Pinning means "always relevant"; a pin + that only took effect when the objective happened to mention the right word + would not be a pin. +- Ordering is: relevance (a count of distinct matching keywords), then pinned, + then importance, then confidence, then recency, then a total tie-break on + `id`. Deterministic, so the behaviour is testable rather than incidentally + stable. +- Results are deduplicated _after_ ordering, by case- and + whitespace-insensitive content, so the survivor of two identical notes is + the higher-ranked one. +- The result is capped at **8 records**, and `memoryRetrievalResultSchema` + cannot carry more — the milestone's rule that the whole store is never + handed to a model is enforced by the type, not only by the code that fills + it. + +There is no vector index, no embedding service and no background indexing +anywhere in this path, and no dependency was added for any of it. + +> **Nothing calls retrieval from a model path yet.** `memory:retrieve` exists, +> is permission-gated and is tested, but no chat or agent code path invokes +> it. It is the documented seam for a later milestone, not a working +> integration, and it is described that way rather than implied to be wired. + +--- + +## Security + +### Everything goes through the existing pipeline + +Five new action types, all routed through the unmodified +`handleActionProposal`: + +| Action | Decision | Why | +| --------------- | --------- | ---------------------------------------------------------------------------------------------- | +| `memory.read` | `allow` | Reading the user's own notes back to them changes nothing and leaves the machine nowhere. | +| `memory.write` | `allow` | Stores text the user typed, in the app's own data directory, reversible by the same operation. | +| `memory.clear` | `confirm` | Destroys a whole scope at once and cannot be undone. | +| `memory.export` | `confirm` | Writes notes to a file outside the application, where its protections no longer apply. | +| `memory.import` | `confirm` | Brings content from outside into a store. | + +The last three are on the **confirmation floor**, so no policy edit can +downgrade them to `allow`. The first two are deliberately not: a native dialog +for every note saved or unpinned would train people to click through dialogs, +which is its own security problem, and the closest existing precedent — +`settings.write`, which also writes user-typed data into the app's own data +directory — is not on the floor either. + +### The audit trail carries no private content + +Memory proposals record an operation name, a scope, a bounded enum and at most +a _length_. **No record content, no search query, no objective and no file +path enters an audit record.** The audit log is append-only and therefore +cannot be redacted afterwards, which is exactly why nothing private is written +into it in the first place. This is asserted directly in +`tests/unit/main/ipc-memory.test.ts`. + +### Export and import + +Neither channel takes a path. `memory:export` and `memory:import` carry a +scope and nothing else; the file is chosen by the user in a native dialog the +main process owns (`main/memory-picker.ts`), exactly as `workspace:select` +already works. A compromised renderer can ask that the user be asked, and +nothing more. + +The confirmation comes **before** the dialog, and declining it means the +dialog never opens at all. + +An **export document** carries the records and the time, and nothing about +where they came from: no path, no project name, no project key, no machine +name, no user name. An export is the one artefact of this application designed +to leave the machine. + +An **import file** is untrusted content: + +- size-checked before it is read; +- parsed as JSON with no reviver, then screened for a prototype-polluting key; +- returned as `unknown` — `main/memory-transfer.ts` never claims it is a + memory document; +- validated against `memoryExportSchema`, including the `kind` marker, so a + file that is merely valid JSON is refused as "not a memory export" rather + than probed field by field for something that happens to fit; +- required to declare the same scope it is being imported into, so someone's + personal notes cannot land in a project store by accident; +- re-stamped per record: a **fresh id**, so a crafted file cannot collide with + or address an existing record; `source: 'import'`, so the interface can + always show that a note came from outside; and a new `updatedAt`. + +The document is all-or-nothing; individual records may be refused while the +rest import, and the count is reported. A record is refused if its content +looks like a credential or if the scope already holds the same note. The +summary reports _how many_ were refused and never _why per record_ — a +per-record reason would be one more way for imported content to shape what is +displayed. + +### The credential screen + +`src/shared/memory/secret-scan.ts` runs at the write boundary — `add`, +`update` and `import` — and refuses content matching a recognisable credential +shape: a provider key with a known prefix, an `Authorization: Bearer` header +pasted out of a browser's network tab, a PEM private-key block, or a +credential-shaped label followed by a value (reusing `SECRET_FIELD_NAMES`, the +codebase's single source of truth for what a credential-bearing name looks +like). It also refuses a long, mixed-case, high-entropy token with no known +prefix. + +Two things about it matter: + +1. **It is not in the persisted-record schema, deliberately.** A loader that + started refusing documents on a rule added later would discard a whole file + of legitimate notes the next time the rule tightened. Screening at the + boundary keeps new content out without putting existing content at risk. +2. **It is best effort and is described that way everywhere.** A credential is + just a string; a short database password or a passphrase made of real words + is indistinguishable from a note by any local rule. The screen reduces + accidents. It is not a guarantee, and the Memory Centre says so on the + form. + +It reports a _category_ — `known-key-prefix`, `bearer-token`, `private-key`, +`labelled-secret`, `high-entropy-token` — and never the matched text. Echoing +back the fragment that looked like a secret would put the secret into an error +path, which is the one place it must never reach. + +### Errors carry a code and nothing else + +`MEMORY_ERROR_CODES` is a closed list. A failure crossing the IPC boundary +carries one of those codes and never a message, a path, a file name or the +content of a record. The sentences a person reads live in +`src/renderer/memory/memory-controller.ts`, in its own reviewed +`FAILURE_MESSAGES` table, and none of them interpolates anything. + +--- + +## What was deliberately not built + +Vector RAG; embedding services; cloud memory sync; telemetry; full transcript +storage; automatic personality inference; camera or screen memory; browser or +email memory; self-modifying permissions; multi-agent memory coordination; +unbounded background indexing. + +No dependency was added for this milestone. + +--- + +## Files + +**Shared (pure).** `shared/memory/errors.ts`, `shared/memory/secret-scan.ts`, +`shared/memory/retrieval.ts`, `shared/memory/index.ts`, +`shared/schemas/memory.schema.ts`. + +**Main.** `main/memory-store.ts` (where the bytes are), +`main/memory-service.ts` (the operations, over an injected accessor), +`main/memory-transfer.ts` (files outside the application), +`main/memory-picker.ts` (the two native dialogs). + +**Renderer.** `renderer/memory/ipc-memory-client.ts` — the single file in that +directory permitted to touch `window.localAgent`, asserted by +`tests/unit/shared/chat-boundary-scan.test.ts` — +`renderer/memory/memory-controller.ts` (framework-independent, where the +testable behaviour lives), `renderer/memory/useMemory.ts`, +`renderer/memory/Memory.tsx`. diff --git a/docs/phase-2-workflows.md b/docs/phase-2-workflows.md new file mode 100644 index 0000000..4652ce0 --- /dev/null +++ b/docs/phase-2-workflows.md @@ -0,0 +1,313 @@ +# Phase 2, Milestone 9 — the workflow engine + +A saved, named, repeatable recipe for running an agent profile that already +exists. Started by a person, bounded in every dimension, and incapable of +doing anything its agent could not already do. + +This document explains what a workflow is here, what it deliberately is +**not**, and why each decision was made. It complements +[architecture.md](architecture.md), [security-model.md](security-model.md) and +[phase-2-agent-profiles.md](phase-2-agent-profiles.md); where those disagree +with this file, they were written to cover the whole system and they win. + +--- + +## The authority chain + +``` +workflow ⊆ agent profile ⊆ permission policy +``` + +This is the whole design in one line, and every other decision follows from +it. + +A workflow step names a **tool from the Milestone 7 agent registry** — seven +ids, fixed in reviewed source — and each of those maps to an action type +Milestones 5 and 6 already defined. So a workflow introduces no capability: +"what can a workflow do?" has the same answer as "what could an agent already +do, with the user's approval?", never a larger one. + +A workflow also selects an agent profile, and may only use tools and paths +**that profile already allows**. This is enforced twice: + +- `workflowStepsWithinProfile` when the workflow is **saved**, so a definition + that could never run is refused while someone can still fix it; +- `decideNextWorkflowStep` before **every single step**, because a profile can + be narrowed after a workflow was written, and the workflow file is + user-editable in between. + +The second is the control; the first is a courtesy. + +Nothing in either layer can produce an authorization. A `run` decision means +"the workflow declared this and the profile permits it" — `main/permissions.ts` +then decides the action itself, unchanged, on its own action type, and may +still deny it or require a confirmation the workflow knows nothing about. + +--- + +## What a workflow is not + +**It cannot start itself.** `trigger` is an enum with exactly one member, +`manual`. A schedule, a file watch, a Git hook and an inbox are not disabled +anywhere — they are not representable, and there is no `workflow.schedule`, +`workflow.watch` or `workflow.trigger` action type either. Adding background +autonomy later is a deliberate, reviewable edit to a named constant. + +**It cannot loop without bound.** Retries are capped per step +(`WORKFLOW_MAX_STEP_RETRIES`), and — the part that matters — **every attempt +counts as a step against `limits.maxSteps`**. There is no combination of +values that produces an unbounded loop, because the retry budget cannot +outlive the run budget. + +**It cannot evaluate an expression.** `condition` is a closed three-value +vocabulary: `always`, `if-previous-succeeded`, `if-previous-failed`. A +workflow definition is untrusted input — a user-editable file — and an +expression evaluator reading one would be a way to spend unbounded CPU in the +process that owns every privileged operation in this application. + +**It cannot carry a credential.** Every object is a `strictObject` with no +field capable of holding one, so a definition containing `apiKey`, `token` or +`password` is rejected outright rather than stored and quietly ignored. + +**It cannot run more than one at a time.** A second `workflow:run` while one +is in flight is refused, never queued — so "cancel" is never ambiguous about +what it cancels. + +--- + +## Execution + +``` +workflow → agent → plan → permission check → approved step → verification → result +``` + +The runner (`main/workflow-runner.ts`) is deliberately small. Every decision — +what to do next, whether a limit was reached, whether the agent permits the +step, whether a failure should be retried, whether the result counts as +verified — is made by the pure functions in `shared/workflow/execution.ts`, +testable with plain Vitest against a table of states. + +**The steps are executed by the agent step runner.** `main/ipc.ts` hands the +workflow runner the _same_ `createAgentStepRunner` closure an agent run uses, +with the same `AgentStepRequest` shape, dispatched through the same +`runWorkspaceAction`. There is no execution path in this milestone that +Milestone 7 did not already have — which is why "every privileged step passes +through the existing permission and audit pipeline" is true by construction +rather than by inspection. + +### The check order is the security property + +`decideNextWorkflowStep` evaluates its stop conditions before it ever returns +a step, in a fixed order: + +1. emergency stop +2. cancellation +3. pause +4. no approved project +5. workflow disabled +6. agent profile disabled +7. time ceiling +8. step ceiling +9. output ceiling +10. retry allowance +11. step condition (skip, not stop) +12. tool inside the agent's allowlist +13. path inside the agent's scope +14. the agent profile's own `deny` + +A state that satisfies more than one reports the most authoritative one. The +first three are refusals a person or the system made, so they outrank every +budget. + +### What is re-read between steps + +The emergency stop, the workflow definition and the agent profile — every +single iteration. Engaging the stop, disabling the workflow or disabling its +agent therefore takes effect on the **next step**, not at the end of the run. +"Stop" has to mean "stop what is happening", not only "start nothing new". + +In practice the emergency stop usually bites even earlier: the permission +engine refuses the step itself, so the run records a `denied` step and stops +with `step-denied`. The runner's own between-steps re-read is the backstop for +the window after a step finishes. + +### A refusal always stops the run + +`afterStepOutcome` treats a denial, a declined confirmation and an emergency +block as terminal, **whatever `failureBehavior` says**. Continuing past a +refusal would be the run arguing with an answer it had already received. This +is the milestone's "never continue after a failed safety check", and it is not +configurable — there is no value of `failureBehavior` that reaches it. + +`failureBehavior` applies only to a step that **ran and reported a failure** — +a lint script exiting non-zero, say, which is often exactly what the workflow +was written to find out. + +### Confirmation checkpoints + +A step with `checkpoint: true` raises a native dialog **before the action is +proposed at all**, naming the workflow, the step number, the tool, the action +type and the scope. So does a step the selected profile marks `confirm`. + +Both can only ever _add_ a prompt. Neither vocabulary has an `allow`, so +nothing here can remove a confirmation the floor requires or turn a denial +into a permission. Declining a checkpoint stops the run with `step-declined`. + +### Pause and cancel + +Two different things, deliberately: + +- **Pause** (`workflow:pause`) is cooperative. The run stops at the **next + step boundary**, keeping every step it already completed, and reports + `paused`. A step already under way finishes and is recorded. +- **Cancel** (`workflow:cancel`) aborts the run's `AbortSignal`, which also + kills a child process the run had started. + +Neither is permission-gated, for the reason `chat:cancel`, `command:cancel` +and `agent:cancel` are not: neither can start anything, read anything or reach +anything. + +> **There is no resume.** A paused run ends; starting again starts a new run +> from the first step. An indefinite suspension inside a time-bounded run +> would either be killed by the duration ceiling or be an unbounded wait, and +> neither is a useful "pause". This is a deliberate scope reduction and is +> stated rather than implied. + +### Progress + +`workflow:progress` is the **second** main → renderer push channel in this +codebase, and it carries the same four properties `chat:chunk` does: sent only +to the `WebContents` that started the run, bounded and validated before +sending, dropped silently on a validation failure, and **advisory** — the +authoritative record is the one `workflow:run` resolves with, never the sum of +these events. + +Its payload is counts, an index and two enums. There is no field for a +summary, a path, a command line or any output, because an advisory channel is +the wrong place to be the first thing that carries content. + +--- + +## Verification and success criteria + +A requirement is satisfied only by its own tool completing with +`outcome: 'success'`, and by nothing else. A denied step, a declined +confirmation, a failed command and a step that never ran are all equally +unsatisfying. **Nothing here can be satisfied by an assertion that the work +was done** — the vocabulary is inherited unchanged from Milestone 7 for +exactly that reason. + +A run that reached the end of its steps but did not meet its criteria is +reported as `failed` with `criteria-not-met`, so "the steps ran" is never +mistaken for "the work is verified". `requireAllStepsSucceed` is the second +half: a run with a failed step in it does not pass merely because its +verification tools happened to succeed. + +The schema refuses a requirement no step in the workflow can satisfy, so an +unsatisfiable definition is caught when it is written rather than at the end +of a run. + +--- + +## Rollback + +`rollback` is `none` or `restore-run-changes`, and the decision is made by a +tested pure function (`planWorkflowRollback`). + +**`restore-run-changes` restores change sets this run applied, and nothing +else** — never a change the user made by hand, never one from an earlier run. +The caller passes the ids the run itself collected, and the function has no +way to reach any other. + +> **In this milestone that list is always empty, and the honest consequence is +> reported rather than hidden.** No agent tool can write a file — there is no +> `workspace.write` tool for a workflow step to name — so a workflow run +> cannot produce a change set. A configured rollback therefore always resolves +> to `nothing-to-roll-back`, which is what the run record says and what the +> dashboard displays. +> +> The decision function's `restore` branch is real, reviewed and unit-tested, +> so the behaviour is already correct if a write tool is ever added. There is +> deliberately **no rollback executor**: writing one that could never fire +> would be dead code in the privileged process, which is worse than an honest +> declaration. +> +> The milestone brief asked for rollback "only for reversible operations +> already supported by the project". The project supports exactly one — +> `workspace.rollback`, restoring the backup taken before a `workspace.write` +> — and no workflow step can reach it. This is the faithful implementation of +> that instruction, not a gap in it. + +--- + +## Storage + +`workflows/workflows.json`, with the pattern `main/agent-profiles.ts` +established: + +- **Loading never throws and never merges.** A missing file, an unreadable + one, an oversized one, malformed JSON, a `__proto__` key anywhere in it, or + a document the schema rejects all resolve to an empty store. A document + either validates in full, as itself, or it is discarded in full. +- **The size check happens before the read.** +- **Writing is atomic and re-validated** — temp file, flush, single rename. +- **Nothing is created by reading.** + +A hand-edited file claiming `trigger: "schedule"` fails validation and the +whole document is discarded — the store cannot be talked into background +autonomy by editing it. + +### A running workflow is protected + +`WORKFLOW_RUNNING` refuses an **edit** or a **delete** of a workflow with a run +in flight. The predicate is supplied by `main/ipc.ts` from the in-flight run +map, which is the only thing that knows what is actually executing — never a +flag on disk that a crash could leave stale. + +**Disabling one while it runs is deliberately permitted.** The runner re-reads +the definition between steps, so that is how someone stops a run without +cancelling it outright; the run ends with `workflow-disabled`. Editing and +deleting stay refused, because those change what the run is executing rather +than only whether it may continue. + +### Duplicate creates a disabled copy + +Whatever the original was. A duplicate is a starting point someone is about to +edit, and a runnable copy appearing the moment it is duplicated is a surprise +in the permissive direction. + +--- + +## Security summary + +| Control | Where it lives | +| -------------------------------- | ----------------------------------------------------------------------- | +| No new capability | Every step tool is a Milestone 7 agent tool | +| No widening | `workflowStepsWithinProfile` at save; `decideNextWorkflowStep` per step | +| No background start | `WORKFLOW_TRIGGERS` has one member | +| No unbounded loop | Per-step retry cap × every attempt counts as a step | +| No unregistered tool or command | `z.enum(AGENT_TOOL_IDS)`; no command string field anywhere | +| Permission + audit on every step | The shared `createAgentStepRunner` → `runWorkspaceAction` | +| Confirmation floor | `workflow.write`, `workflow.run` | +| Checkpoints | Per-step, additive only | +| Emergency stop | Engine refuses the step; runner re-reads between steps | +| Bounded audit metadata | Ids, counts and lengths — never the objective or a path | +| No credential | `strictObject` everywhere; no field can hold one | + +--- + +## Files + +**Shared (pure).** `shared/workflow/errors.ts`, +`shared/workflow/execution.ts`, `shared/workflow/index.ts`, +`shared/schemas/workflow.schema.ts`. + +**Main.** `main/workflow-store.ts` (CRUD, atomic, fail-safe), +`main/workflow-runner.ts` (the loop and the dialog text). + +**Renderer.** `renderer/workflow/ipc-workflow-client.ts` — the single file in +that directory permitted to touch `window.localAgent`, asserted by +`tests/unit/shared/chat-boundary-scan.test.ts` — +`renderer/workflow/workflow-controller.ts` (framework-independent, where the +testable behaviour lives), `renderer/workflow/useWorkflow.ts`, +`renderer/workflow/Workflows.tsx`. diff --git a/docs/security-model.md b/docs/security-model.md index 0024760..339709c 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -98,9 +98,72 @@ cannot declare anything else. An action with no matching rule is denied. `secrets.clear`, `emergency.reset`, `app.exit`, and — since Phase 2 Milestone 6 — `workspace.write`, `workspace.rollback`, `command.run` and `git.checkpoint`, plus — since Milestone 7 — `agent.write` and `agent.run`, +and — since Milestone 8 — `memory.clear`, `memory.export` and `memory.import`, +plus — since Milestone 9 — `workflow.write` and `workflow.run`, plus — since +Milestone 10 — `automation.run`, cannot be downgraded to `allow` by editing the policy file. The schema rejects such a file. +`memory.read` and `memory.write` are deliberately **not** on that floor. +Saving or unpinning one note is an ordinary edit inside the application's own +data directory, reversible by the same operation that made it — the same +reasoning that keeps `settings.write` off the floor. The three that are on it +each stop being ordinary: clearing destroys a whole scope at once, exporting +writes the user's notes to a file outside the application where its +protections no longer apply, and importing brings content from outside into a +store. Prompting for every note saved would train people to click through +dialogs, which is its own security problem. + +**[enforced by type]** A **workflow can never start itself.** A workflow's +`trigger` is an enum with exactly one member, `manual`. A scheduled run, a +file-change trigger, a Git trigger and an email trigger are not disabled +anywhere in the codebase — they are not representable, because the enum has no +value for them and `workflowTriggerSchema` accepts nothing else. There is no +`workflow.schedule`, `workflow.watch` or `workflow.trigger` action type +either, and a hand-edited `workflows.json` claiming a non-manual trigger fails +validation and is discarded in full. Background autonomy is therefore +prevented by the type rather than by a check someone could forget. + +**[enforced by type]** A **workflow can never widen its agent.** Every step +names a tool from the fixed Milestone 7 registry, and each of those maps to an +action type Milestones 5 and 6 already defined — so a workflow introduces no +capability. It must also stay inside the allowlists of the agent profile it +selects, checked when the workflow is saved and again before every single +step, because a profile can be narrowed afterwards. The chain is +`workflow ⊆ agent profile ⊆ permission policy`, and a workflow sits at the +narrow end of it. Its steps are executed by the _same_ step runner an agent +run uses, so every one of them reaches the permission engine and the audit log +by the path Milestone 7 already established. + +**[enforced by type]** A **workflow can never loop without bound.** Retries are +capped per step, and every attempt counts as a step against the run's own step +ceiling, so the retry budget cannot outlive the run budget. Conditions are a +closed three-value enum rather than an expression language: a user-editable +definition cannot ask the privileged process to evaluate arbitrary logic. + +**[enforced by type]** An **automation action can never name its own target.** +A request carries a `toolId` from a fifteen-value enum, the fixed registry in +`shared/automation/registry.ts` — never a path, a URL, an argument or a +command line, because no field of the request or of a registry entry has that +shape. `launch-app` and `run-script` resolve only to a literal executable +under `%SystemRoot%\System32`, started with `shell: false`; `open-folder` +resolves only to one of four fixed special folders or the already-approved +project root; `open-website` resolves only to one of four fixed `https://` +hosts, checked again immediately before it is opened; `focus-window` reaches +only this application's own window. See `docs/phase-2-automation.md`. + +**[enforced by type]** A **memory can never be written by a model.** A memory +record's `source` is an enum of exactly two members, `user` and `import`, and +both are stamped in the main process by the one handler that performs each +operation — the input schema has no `source` field at all. There is no value a +chat reply, an agent step or an inference could be stored under, so "never +silently save model output as memory" is a property of the type rather than a +check somewhere that could be forgotten. In the same way, a memory record +declares no field for a permission, a tool, an action type, a provider, a +command or a path, and nothing in the codebase reads any of those out of its +content: a note is stored, displayed and matched against a search query, and +that is the entire set of things done with it. + **[enforced by type]** An **agent profile can never grant a permission.** A profile is user-editable configuration describing which of a fixed set of tools an agent may reach for, and its own decision vocabulary is @@ -833,6 +896,33 @@ These are real and are stated plainly rather than described as solved. it is recorded here rather than presented as equivalent to approving each action. +0.5. **A memory record's content is stored in plain text, and the credential +screen is best effort.** Phase 2 Milestone 8 keeps memory in +`memory\personal.json` and `memory\projects\.json`, both plain JSON +readable by anything running under the same Windows account. That is the +same exposure `settings.json` and `permissions\policy.json` already have, +and it is deliberate — these files are meant to be inspectable and +hand-editable — but memory holds notes _about a person_, which is a +different kind of content than a display name. Two consequences follow. +First, the store is not encrypted: DPAPI is reserved for the secret store, +where the whole point is that the content must never be readable. Second, +the value-level credential screen +(`src/shared/memory/secret-scan.ts`) catches _recognisable_ shapes — a +provider key with a known prefix, a pasted `Authorization: Bearer` header, +a PEM block, a `password=…` line — and cannot catch a credential that +looks like ordinary text. Someone determined to type a short database +password into a note will succeed. The control reduces accidents; it is not +a guarantee, and it is described that way in the interface as well as here. + +0.6. **Clearing or deleting a memory does not securely erase it.** Every write +is one atomic rename over the previous file, so the _application_ retains +nothing and no partial document is ever left behind — but the replaced +file's old blocks are not overwritten, and on a journalling or +copy-on-write filesystem, or with Volume Shadow Copy enabled, earlier +content may survive on the disk. No application can promise otherwise from +user space. A deleted note is gone from Local Agent; it is not guaranteed +to be gone from the drive. + 1. **The audit log is append-only by API, not tamper-proof.** A local user with the same privileges can edit the file directly with a text editor. Tamper-evidence (hash chaining or signing) is deferred beyond Phase 1. The @@ -1114,6 +1204,18 @@ These are real and are stated plainly rather than described as solved. deletes them — recovering from an older one is a manual operation the user performs with their own tools. +31. **`focus-window` reaches only this application's own window, and + `automation:cancel` cannot kill an already-launched application.** + Focusing a window belonging to another process would need either an + unapproved native dependency or unrestricted shell access, both of which + this milestone's own security requirements forbid, so the tool is scoped + to what this application can already do through Electron's own API rather + than half-implemented. Cancelling a launch in progress abandons the + attempt; a process that already started is left running deliberately, + because a user who asked to open an application did not ask for the power + to close it again. Both are stated plainly in `docs/phase-2-automation.md` + rather than presented as complete. + --- ## Reporting diff --git a/electron-builder.json b/electron-builder.json new file mode 100644 index 0000000..e721dd8 --- /dev/null +++ b/electron-builder.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json", + "appId": "com.localagent.desktop", + "productName": "Local Agent", + "copyright": "Local Agent", + "asar": true, + "directories": { + "output": "release" + }, + "files": [ + "out/**/*", + "dist/renderer/**/*", + "package.json", + "!node_modules/react/**", + "!node_modules/react-dom/**", + "!node_modules/scheduler/**" + ], + "win": { + "target": ["nsis"] + }, + "nsis": { + "oneClick": false, + "perMachine": false, + "allowToChangeInstallationDirectory": true, + "createDesktopShortcut": true, + "createStartMenuShortcut": true + } +} diff --git a/package-lock.json b/package-lock.json index d7e9a69..5b8da9a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@types/react-dom": "19.2.5", "@vitejs/plugin-react": "6.1.1", "electron": "^44.1.1", + "electron-builder": "26.15.3", "eslint": "10.8.0", "eslint-config-prettier": "10.1.8", "playwright": "1.62.1", @@ -43,6 +44,86 @@ "node": ">=22.12.0" } }, + "node_modules/@electron/asar": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", + "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^5.0.0", + "glob": "^7.1.6", + "minimatch": "^3.0.4" + }, + "bin": { + "asar": "bin/asar.js" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/@electron/asar/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/asar/node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@electron/asar/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@electron/fuses": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@electron/fuses/-/fuses-1.8.0.tgz", + "integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.1", + "fs-extra": "^9.0.1", + "minimist": "^1.2.5" + }, + "bin": { + "electron-fuses": "dist/bin.js" + } + }, + "node_modules/@electron/fuses/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@electron/get": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.1.0.tgz", @@ -64,6 +145,199 @@ "undici": "^7.24.4" } }, + "node_modules/@electron/notarize": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz", + "integrity": "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.1", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/notarize/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/osx-sign": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.3.3.tgz", + "integrity": "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "compare-version": "^0.1.2", + "debug": "^4.3.4", + "fs-extra": "^10.0.0", + "isbinaryfile": "^4.0.8", + "minimist": "^1.2.6", + "plist": "^3.0.5" + }, + "bin": { + "electron-osx-flat": "bin/electron-osx-flat.js", + "electron-osx-sign": "bin/electron-osx-sign.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/@electron/rebuild": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.2.0.tgz", + "integrity": "sha512-RKL/O+jGoXJMxrx/5771y1n0xTKmFuOYGO3gMmwypBM6rsH0kou0mswwdXA2JrhIkE4xyC7v9vGk0n6NPzgOxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.1.1", + "node-abi": "^4.2.0", + "node-api-version": "^0.2.1", + "node-gyp": "^12.2.0", + "read-binary-file-arch": "^1.0.6" + }, + "bin": { + "electron-rebuild": "lib/cli.js" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/universal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.3.tgz", + "integrity": "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "^3.3.1", + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.3.1", + "dir-compare": "^4.2.0", + "fs-extra": "^11.1.1", + "minimatch": "^9.0.3", + "plist": "^3.1.0" + }, + "engines": { + "node": ">=16.4" + } + }, + "node_modules/@electron/universal/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/universal/node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@electron/universal/node_modules/fs-extra": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", + "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/universal/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@electron/windows-sign": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", + "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "peer": true, + "dependencies": { + "cross-dirname": "^0.1.0", + "debug": "^4.3.4", + "fs-extra": "^11.1.1", + "minimist": "^1.2.8", + "postject": "^1.0.0-alpha.6" + }, + "bin": { + "electron-windows-sign": "bin/electron-windows-sign.js" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/windows-sign/node_modules/fs-extra": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", + "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", @@ -258,6 +532,19 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", @@ -265,6 +552,74 @@ "dev": true, "license": "MIT" }, + "node_modules/@malept/cross-spawn-promise": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", + "integrity": "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "license": "Apache-2.0", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/@malept/flatpak-bundler": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz", + "integrity": "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.0", + "lodash": "^4.17.15", + "tmp-promise": "^3.0.2" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@oxc-project/types": { "version": "0.143.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", @@ -275,6 +630,61 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.9.4.tgz", + "integrity": "sha512-GjzePcT9Iw8NzeOPf73iNS9xM+TBhd/FilAfP+RQGkTMQJTVWtytN3JHJACCjf/ABNau5S7mS3g+DcuxmRgYEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/json-schema": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", + "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/webcrypto": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.7.1.tgz", + "integrity": "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/json-schema": "^1.1.12", + "@peculiar/utils": "^2.0.2", + "tslib": "^2.8.1", + "webcrypto-core": "^1.9.2" + }, + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz", @@ -538,6 +948,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -545,6 +968,32 @@ "dev": true, "license": "MIT" }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -556,6 +1005,16 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -577,6 +1036,23 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/fs-extra": { + "version": "9.0.13", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", + "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -584,19 +1060,36 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/node": { - "version": "26.1.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", - "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==", + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~8.3.0" + "@types/node": "*" } }, - "node_modules/@types/react": { - "version": "19.2.18", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.1.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", + "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", "dev": true, "license": "MIT", @@ -614,6 +1107,16 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.66.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.66.0.tgz", @@ -987,6 +1490,26 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.15", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", + "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/abbrev": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/acorn": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", @@ -1010,6 +1533,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -1027,190 +1560,1211 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" + "node": ">=8" } }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, "engines": { - "node": "18 || 20 || >=22" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "node_modules/app-builder-lib": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.15.3.tgz", + "integrity": "sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^4.0.2" + "@electron/asar": "3.4.1", + "@electron/fuses": "^1.8.0", + "@electron/get": "^3.0.0", + "@electron/notarize": "2.5.0", + "@electron/osx-sign": "1.3.3", + "@electron/rebuild": "^4.0.4", + "@electron/universal": "2.0.3", + "@malept/flatpak-bundler": "^0.4.0", + "@noble/hashes": "^2.2.0", + "@peculiar/webcrypto": "^1.7.1", + "@types/fs-extra": "9.0.13", + "ajv": "^8.18.0", + "asn1js": "^3.0.10", + "async-exit-hook": "^2.0.1", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", + "chromium-pickle-js": "^0.2.0", + "ci-info": "4.3.1", + "debug": "^4.3.4", + "dotenv": "^16.4.5", + "dotenv-expand": "^11.0.6", + "ejs": "^3.1.8", + "electron-publish": "26.15.3", + "fs-extra": "^10.1.0", + "hosted-git-info": "^4.1.0", + "isbinaryfile": "^5.0.0", + "jiti": "^2.4.2", + "js-yaml": "^4.1.0", + "json5": "^2.2.3", + "lazy-val": "^1.0.5", + "minimatch": "^10.2.5", + "pkijs": "^3.4.0", + "plist": "3.1.0", + "proper-lockfile": "^4.1.2", + "resedit": "^1.7.0", + "semver": "~7.7.3", + "tar": "^7.5.7", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0", + "unzipper": "^0.12.3", + "which": "^5.0.0" }, "engines": { - "node": "20 || >=22" + "node": ">=14.0.0" + }, + "peerDependencies": { + "dmg-builder": "26.15.3", + "electron-builder-squirrel-windows": "26.15.3" } }, - "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "node_modules/app-builder-lib/node_modules/@electron/get": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-3.1.0.tgz", + "integrity": "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==", "dev": true, "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, "engines": { - "node": ">=18" + "node": ">=14" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" } }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", "dev": true, "license": "MIT", "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" }, "engines": { - "node": ">= 8" + "node": ">=6 <7 || >=8" } }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, - "license": "MIT" + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "node_modules/app-builder-lib/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/app-builder-lib/node_modules/ci-info": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" } + ], + "license": "MIT", + "engines": { + "node": ">=8" } }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "node_modules/app-builder-lib/node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/app-builder-lib/node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/app-builder-lib/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true, "license": "MIT" }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "node_modules/app-builder-lib/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/app-builder-lib/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, "engines": { - "node": ">=8" + "node": ">=10" } }, - "node_modules/electron": { - "version": "44.1.1", - "resolved": "https://registry.npmjs.org/electron/-/electron-44.1.1.tgz", - "integrity": "sha512-N2WCq2sbOkqQgvXJYx2lS6UiO8bF+Yr67trDnS6JKa2WxTCRQsAjGl57SUtdW9h6r5PlduBFjIhxhgd3dzv1hg==", + "node_modules/app-builder-lib/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", "dev": true, "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/which": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "dev": true, + "license": "ISC", "dependencies": { - "@electron-internal/extract-zip": "^1.0.1", - "@electron/get": "^5.0.0", - "@types/node": "^24.9.0" + "isexe": "^3.1.1" }, "bin": { - "electron": "cli.js", - "install-electron": "install.js" + "node-which": "bin/which.js" }, "engines": { - "node": ">= 22.12.0" + "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/electron/node_modules/@types/node": { - "version": "24.13.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", - "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true, - "license": "MIT", + "license": "Python-2.0" + }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "undici-types": "~7.18.0" + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" } }, - "node_modules/electron/node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", "dev": true, "license": "MIT" }, - "node_modules/env-paths": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", - "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "node_modules/async-exit-hook": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", + "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", "dev": true, "license": "MIT", "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=0.12.0" } }, - "node_modules/es-module-lexer": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", - "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "dev": true, "license": "MIT" }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", "dev": true, - "license": "MIT", + "license": "ISC", "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 4.0.0" } }, - "node_modules/eslint": { - "version": "10.8.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz", - "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", + "node_modules/aws4": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, "license": "MIT", - "workspaces": [ - "packages/*" + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/builder-util": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.15.3.tgz", + "integrity": "sha512-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.6", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.6", + "debug": "^4.3.4", + "fs-extra": "^10.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "js-yaml": "^4.1.0", + "sanitize-filename": "^1.6.3", + "source-map-support": "^0.5.19", + "stat-mode": "^1.0.0", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/builder-util-runtime": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz", + "integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/bytestreamjs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz", + "integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/chromium-pickle-js": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", + "integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/compare-version": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz", + "integrity": "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-dirname": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", + "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/dir-compare": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", + "integrity": "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^3.0.5", + "p-limit": "^3.1.0 " + } + }, + "node_modules/dir-compare/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/dir-compare/node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/dir-compare/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/dmg-builder": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.15.3.tgz", + "integrity": "sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", + "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron": { + "version": "44.1.1", + "resolved": "https://registry.npmjs.org/electron/-/electron-44.1.1.tgz", + "integrity": "sha512-N2WCq2sbOkqQgvXJYx2lS6UiO8bF+Yr67trDnS6JKa2WxTCRQsAjGl57SUtdW9h6r5PlduBFjIhxhgd3dzv1hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-internal/extract-zip": "^1.0.1", + "@electron/get": "^5.0.0", + "@types/node": "^24.9.0" + }, + "bin": { + "electron": "cli.js", + "install-electron": "install.js" + }, + "engines": { + "node": ">= 22.12.0" + } + }, + "node_modules/electron-builder": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.15.3.tgz", + "integrity": "sha512-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "dmg-builder": "26.15.3", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "simple-update-notifier": "2.0.0", + "yargs": "^17.6.2" + }, + "bin": { + "electron-builder": "cli.js", + "install-app-deps": "install-app-deps.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/electron-builder-squirrel-windows": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.15.3.tgz", + "integrity": "sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", + "electron-winstaller": "5.4.0" + } + }, + "node_modules/electron-publish": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.15.3.tgz", + "integrity": "sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^9.0.11", + "aws4": "^1.13.2", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "form-data": "^4.0.5", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "mime": "^2.5.2" + } + }, + "node_modules/electron-winstaller": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz", + "integrity": "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@electron/asar": "^3.2.1", + "debug": "^4.1.1", + "fs-extra": "^7.0.1", + "lodash": "^4.17.21", + "temp": "^0.9.0" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "@electron/windows-sign": "^1.1.2" + } + }, + "node_modules/electron-winstaller/node_modules/fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/electron-winstaller/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "peer": true, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-winstaller/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/electron/node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/electron/node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz", + "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" ], "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", @@ -1394,6 +2948,13 @@ "node": ">=12.0.0" } }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -1415,6 +2976,23 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1446,6 +3024,46 @@ "node": ">=16.0.0" } }, + "node_modules/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -1484,6 +3102,45 @@ "dev": true, "license": "ISC" }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1499,6 +3156,103 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -1512,6 +3266,113 @@ "node": ">=10.13.0" } }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -1519,6 +3380,134 @@ "dev": true, "license": "ISC" }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -1539,6 +3528,25 @@ "node": ">=0.8.19" } }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -1549,6 +3557,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -1562,6 +3580,26 @@ "node": ">=0.10.0" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isbinaryfile": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", + "integrity": "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -1569,6 +3607,57 @@ "dev": true, "license": "ISC" }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -1590,6 +3679,40 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -1600,6 +3723,13 @@ "json-buffer": "3.0.1" } }, + "node_modules/lazy-val": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", + "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", + "dev": true, + "license": "MIT" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -1903,64 +4033,352 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "4.35.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.35.0.tgz", + "integrity": "sha512-ymk4aIzxdPopw2giv8Fs1Ec6vybGkjmyxUwVqhkI4MCy2tVfXdkOGGWieWVjL0THgH+7a8lRdevyupoYj3Js/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.6.3" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/node-api-version": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz", + "integrity": "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + } + }, + "node_modules/node-gyp": { + "version": "12.4.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", + "integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "undici": "^6.25.0", + "which": "^6.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-gyp/node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/node-gyp/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/node-gyp/node_modules/undici": { + "version": "6.28.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.1.tgz", + "integrity": "sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==", "dev": true, "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" + "engines": { + "node": ">=18.17" } }, - "node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "node_modules/node-gyp/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "ISC", "dependencies": { - "brace-expansion": "^5.0.8" + "isexe": "^4.0.0" }, - "engines": { - "node": "18 || 20 || >=22" + "bin": { + "node-which": "bin/which.js" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "engines": { + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", "dev": true, "license": "MIT" }, - "node_modules/nanoid": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "node_modules/nopt": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", + "license": "ISC", + "dependencies": { + "abbrev": "^4.0.0" + }, "bin": { - "nanoid": "bin/nanoid.cjs" + "nopt": "bin/nopt.js" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } }, "node_modules/obug": { "version": "2.1.4", @@ -1976,6 +4394,16 @@ "node": ">=12.20.0" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -1994,6 +4422,16 @@ "node": ">= 0.8.0" } }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -2036,6 +4474,16 @@ "node": ">=8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -2053,6 +4501,21 @@ "dev": true, "license": "MIT" }, + "node_modules/pe-library": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz", + "integrity": "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2073,6 +4536,37 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkijs": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.0.tgz", + "integrity": "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@noble/hashes": "1.4.0", + "asn1js": "^3.0.6", + "bytestreamjs": "^2.0.1", + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/pkijs/node_modules/@noble/hashes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/playwright": { "version": "1.62.1", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", @@ -2120,6 +4614,21 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/plist": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", + "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, "node_modules/postcss": { "version": "8.5.26", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", @@ -2149,6 +4658,36 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postject": { + "version": "1.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", + "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "commander": "^9.4.0" + }, + "bin": { + "postject": "dist/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/postject/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -2175,6 +4714,23 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -2185,35 +4741,236 @@ "node": ">=0.4.0" } }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz", + "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/read-binary-file-arch": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", + "integrity": "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "bin": { + "read-binary-file-arch": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resedit": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", + "integrity": "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pe-library": "^0.4.1" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", "dev": true, "license": "MIT", "engines": { - "node": ">=6" + "node": ">= 4" } }, - "node_modules/react": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", - "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" + "node_modules/rimraf": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" } }, - "node_modules/react-dom": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", - "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", - "license": "MIT", + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, "dependencies": { - "scheduler": "^0.27.0" + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" }, - "peerDependencies": { - "react": "^19.2.8" + "engines": { + "node": ">=8.0" } }, "node_modules/rolldown": { @@ -2249,6 +5006,33 @@ "@rolldown/binding-win32-x64-msvc": "1.2.3" } }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/sanitize-filename": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.4.tgz", + "integrity": "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==", + "dev": true, + "license": "WTFPL OR ISC", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -2268,6 +5052,31 @@ "node": ">=10" } }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -2298,6 +5107,36 @@ "dev": true, "license": "ISC" }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2308,6 +5147,25 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -2315,6 +5173,16 @@ "dev": true, "license": "MIT" }, + "node_modules/stat-mode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", + "integrity": "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/std-env": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", @@ -2322,6 +5190,44 @@ "dev": true, "license": "MIT" }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/sumchecker": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", @@ -2335,6 +5241,92 @@ "node": ">= 8.0" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar": { + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/temp": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", + "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "mkdirp": "^0.5.1", + "rimraf": "~2.6.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/temp-file": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz", + "integrity": "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-exit-hook": "^2.0.1", + "fs-extra": "^10.0.0" + } + }, + "node_modules/tiny-async-pool": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", + "integrity": "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^5.5.0" + } + }, + "node_modules/tiny-async-pool/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -2379,6 +5371,36 @@ "node": ">=14.0.0" } }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tmp-promise": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", + "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tmp": "^0.2.0" + } + }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", + "dev": true, + "license": "WTFPL", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -2392,6 +5414,13 @@ "typescript": ">=4.8.4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -2405,6 +5434,20 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -2461,6 +5504,45 @@ "dev": true, "license": "MIT" }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unzipper": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.5.tgz", + "integrity": "sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "bluebird": "~3.7.2", + "duplexer2": "~0.1.4", + "fs-extra": "11.3.1", + "graceful-fs": "^4.2.2", + "node-int64": "^0.4.0" + } + }, + "node_modules/unzipper/node_modules/fs-extra": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.1.tgz", + "integrity": "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -2471,6 +5553,20 @@ "punycode": "^2.1.0" } }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", + "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "8.2.1", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", @@ -2639,6 +5735,20 @@ } } }, + "node_modules/webcrypto-core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz", + "integrity": "sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/json-schema": "^1.1.12", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -2682,6 +5792,87 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 92b55c5..147355c 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "test": "vitest run", "test:watch": "vitest", "test:e2e": "npm run build && vitest run --config vitest.e2e.config.ts", - "verify": "npm run typecheck && npm run lint && npm run format:check && npm test" + "verify": "npm run typecheck && npm run lint && npm run format:check && npm test", + "package:win": "npm run build && electron-builder --win nsis --publish never" }, "dependencies": { "react": "19.2.8", @@ -36,6 +37,7 @@ "@types/react-dom": "19.2.5", "@vitejs/plugin-react": "6.1.1", "electron": "^44.1.1", + "electron-builder": "26.15.3", "eslint": "10.8.0", "eslint-config-prettier": "10.1.8", "playwright": "1.62.1", diff --git a/src/main/agent-orchestrator.ts b/src/main/agent-orchestrator.ts index d43ffbb..695a73f 100644 --- a/src/main/agent-orchestrator.ts +++ b/src/main/agent-orchestrator.ts @@ -64,6 +64,20 @@ export interface AgentStepRequest { readonly requiresConfirmation: boolean; /** The run's objective, for the one tool that needs it. */ readonly objective: string; + /** + * The sentence shown when `requiresConfirmation` is set, if the caller has + * a more specific one than the default (Phase 2, Milestone 9). + * + * Additive and optional: omitted, the step executor shows the profile-gated + * wording it always showed, so agent runs are unchanged. A workflow + * checkpoint supplies its own sentence, naming the workflow and the step + * number, because "the active agent profile requires confirmation" would be + * the wrong explanation for a prompt the *workflow* asked for. + * + * It is built in the main process from stored state, exactly as the default + * is. Nothing from a request reaches it. + */ + readonly confirmationMessage?: string; } /** What the executor reports back. Never a message, never a path. */ diff --git a/src/main/index.ts b/src/main/index.ts index 3a01b83..043acef 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,10 +1,11 @@ -import { app, BrowserWindow, ipcMain, safeStorage, session } from 'electron'; +import { app, BrowserWindow, ipcMain, safeStorage, session, shell } from 'electron'; import { join } from 'node:path'; import { showNativeConfirmation } from './confirm'; import { showDirectoryPicker } from './directory-picker'; import { loadEmergencyState } from './emergency'; import { registerIpcHandlers } from './ipc'; +import { showMemoryExportPicker, showMemoryImportPicker } from './memory-picker'; import { resolveUserDataPaths } from './paths'; import { loadPermissionPolicy } from './policy'; import { loadSettings } from './settings'; @@ -129,6 +130,27 @@ app // because this is the one file that imports the live `electron` module // and holds the real window to parent the dialog to. selectProjectDirectory: () => showDirectoryPicker(window.isDestroyed() ? null : window), + // The two dialogs that gate memory content crossing the application + // boundary (Phase 2, Milestone 8). Built here for the same reason as + // the picker above: this is the one file holding the live `electron` + // module and the real window to parent a dialog to. + selectMemoryExportFile: (scope) => + showMemoryExportPicker(window.isDestroyed() ? null : window, scope), + selectMemoryImportFile: () => showMemoryImportPicker(window.isDestroyed() ? null : window), + // The Electron-backed halves of the automation executor (Phase 2, + // Milestone 10). Built here for the same reason the callbacks above + // are: this is the one file holding the live `electron` module and the + // real window. `main/windows-automation.ts` never imports `electron`. + automationOpenPath: (path) => shell.openPath(path), + automationOpenExternal: (url) => shell.openExternal(url), + automationSpecialFolder: (name) => app.getPath(name), + focusMainWindow: () => { + if (window.isDestroyed()) return false; + if (window.isMinimized()) window.restore(); + window.show(); + window.focus(); + return true; + }, nowFn: () => new Date().toISOString(), }); }) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index a8405b2..a902a7b 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -37,7 +37,40 @@ import { updateAgentProfile, } from './agent-profiles'; import { describeAgentRun, runAgentOrchestration } from './agent-orchestrator'; +import { + createWorkflow, + deleteWorkflow, + duplicateWorkflow, + findWorkflow, + readWorkflows, + requireWorkflow, + setWorkflowEnabled, + updateWorkflow, +} from './workflow-store'; +import { describeWorkflowCheckpoint, describeWorkflowRun, runWorkflow } from './workflow-runner'; +import { launchDetached, runAutomationTool } from './windows-automation'; +import type { AutomationRunDependencies } from './windows-automation'; import type { AgentStepOutcome, AgentStepRequest } from './agent-orchestrator'; +import { + addMemory, + buildMemoryExport, + clearMemoryScope, + deleteMemory, + importMemoryRecords, + listMemories, + retrieveRelevantMemories, + searchMemoryScope, + setMemoryPinned, + updateMemory, +} from './memory-service'; +import type { MemoryStoreAccess } from './memory-service'; +import { + createSessionMemoryStore, + loadMemoryStore, + resolveProjectMemoryFile, + writeMemoryStore, +} from './memory-store'; +import { readMemoryImportFile, writeMemoryExportFile } from './memory-transfer'; import { runAction } from './action-runtime'; import type { ActionRuntime } from './action-runtime'; import { resolveMainChatProvider } from './chat-provider-registry'; @@ -66,19 +99,35 @@ import { loadSettings } from './settings'; import { AgentError, isAgentErrorCode } from '../shared/agent/errors'; import type { AgentErrorCode } from '../shared/agent/errors'; import { buildAgentPlan } from '../shared/agent/orchestration'; -import { resolveActiveProfile, resolveAgentProvider } from '../shared/agent/registry'; +import { + findAgentProfile, + resolveActiveProfile, + resolveAgentProvider, +} from '../shared/agent/registry'; import type { AgentRegistry } from '../shared/agent/registry'; import { findAgentTool } from '../shared/agent/tools'; +import type { AgentToolDefinition } from '../shared/agent/tools'; +import { isWorkflowErrorCode, WorkflowError } from '../shared/workflow/errors'; +import type { WorkflowErrorCode } from '../shared/workflow/errors'; +import { describeWorkflowTools, workflowStepsWithinProfile } from '../shared/workflow/execution'; +import { AutomationError, isAutomationErrorCode } from '../shared/automation/errors'; +import type { AutomationErrorCode } from '../shared/automation/errors'; +import { AUTOMATION_TOOLS, findAutomationTool } from '../shared/automation/registry'; +import type { AutomationToolDefinition } from '../shared/automation/registry'; import { CHAT_PROVIDER_ERROR_CODES, ChatProviderError } from '../shared/chat/provider'; import type { ChatProviderResult } from '../shared/chat/provider'; +import { isMemoryErrorCode, MemoryError } from '../shared/memory/errors'; +import type { MemoryErrorCode } from '../shared/memory/errors'; import { AGENT_NAME_MAX_LENGTH, AGENT_STEP_SUMMARY_MAX_LENGTH, + AUTOMATION_MAX_CONCURRENT_RUNS, CHAT_STREAM_MAX_DELTA_LENGTH, COMMAND_MAX_CONCURRENT_RUNS, + MEMORY_RETRIEVAL_SCOPES, PROVIDERS_REQUIRING_API_KEY, } from '../shared/constants'; -import type { ModelProvider } from '../shared/constants'; +import type { MemoryScope, ModelProvider } from '../shared/constants'; import { describeCommandLine, findCodingCommand } from '../shared/workspace/command-registry'; import { collapseToSingleLine } from '../shared/workspace/text-safety'; import { @@ -180,6 +229,74 @@ import { IPC_AGENT_SELECT_CHANNEL, IPC_AGENT_SET_ENABLED_CHANNEL, IPC_AGENT_UPDATE_CHANNEL, + IPC_MEMORY_ADD_CHANNEL, + IPC_MEMORY_CLEAR_CHANNEL, + IPC_MEMORY_DELETE_CHANNEL, + IPC_MEMORY_EXPORT_CHANNEL, + IPC_MEMORY_IMPORT_CHANNEL, + IPC_MEMORY_LIST_CHANNEL, + IPC_MEMORY_RETRIEVE_CHANNEL, + IPC_MEMORY_SEARCH_CHANNEL, + IPC_MEMORY_SET_PINNED_CHANNEL, + IPC_MEMORY_UPDATE_CHANNEL, + memoryAddRequestSchema, + memoryAddResponseSchema, + memoryClearRequestSchema, + memoryClearResponseSchema, + memoryDeleteRequestSchema, + memoryDeleteResponseSchema, + memoryExportRequestSchema, + memoryExportResponseSchema, + memoryImportRequestSchema, + memoryImportResponseSchema, + memoryListRequestSchema, + memoryListResponseSchema, + memoryRetrieveRequestSchema, + memoryRetrieveResponseSchema, + memorySearchRequestSchema, + memorySearchResponseSchema, + memorySetPinnedRequestSchema, + memorySetPinnedResponseSchema, + memoryUpdateRequestSchema, + memoryUpdateResponseSchema, + IPC_AUTOMATION_CANCEL_CHANNEL, + IPC_AUTOMATION_LIST_CHANNEL, + IPC_AUTOMATION_RUN_CHANNEL, + automationCancelRequestSchema, + automationCancelResponseSchema, + automationListRequestSchema, + automationListResponseSchema, + automationRunRequestSchema, + automationRunResponseSchema, + IPC_WORKFLOW_CANCEL_CHANNEL, + IPC_WORKFLOW_CREATE_CHANNEL, + IPC_WORKFLOW_DELETE_CHANNEL, + IPC_WORKFLOW_DUPLICATE_CHANNEL, + IPC_WORKFLOW_LIST_CHANNEL, + IPC_WORKFLOW_PAUSE_CHANNEL, + IPC_WORKFLOW_PROGRESS_CHANNEL, + IPC_WORKFLOW_RUN_CHANNEL, + IPC_WORKFLOW_SET_ENABLED_CHANNEL, + IPC_WORKFLOW_UPDATE_CHANNEL, + workflowCancelRequestSchema, + workflowControlResponseSchema, + workflowCreateRequestSchema, + workflowCreateResponseSchema, + workflowDeleteRequestSchema, + workflowDeleteResponseSchema, + workflowDuplicateRequestSchema, + workflowDuplicateResponseSchema, + workflowListRequestSchema, + workflowListResponseSchema, + workflowPauseRequestSchema, + workflowProgressIpcEventSchema, + workflowRunRequestSchema, + workflowRunResponseSchema, + workflowSchema, + workflowSetEnabledRequestSchema, + workflowSetEnabledResponseSchema, + workflowUpdateRequestSchema, + workflowUpdateResponseSchema, } from '../shared/schemas'; import type { AgentProfile, @@ -187,6 +304,10 @@ import type { AgentRegistryResponse, AgentRun, AgentRunResponse, + AutomationCatalog, + AutomationListResponse, + AutomationRunResponse, + AutomationRunResult, ChatSendResponse, CodingPlan, CommandIdValue, @@ -200,6 +321,14 @@ import type { GitDiffValue, GitStatusResponse, GitStatusValue, + MemoryMutationResponse, + MemoryMutationSummary, + MemoryQueryResponse, + MemoryQueryResult, + MemoryRecord, + MemoryRecordResponse, + MemoryRetrievalResult, + MemoryRetrieveResponse, SecretsActionResponse, SecretStatusResult, SettingsActionResponse, @@ -217,6 +346,12 @@ import type { WorkspaceSearchResult, WorkspaceTree, WorkspaceTreeResponse, + Workflow, + WorkflowInput, + WorkflowListResponse, + WorkflowProgressEvent, + WorkflowRun, + WorkflowRunResponse, } from '../shared/schemas'; import { isWorkspaceErrorCode, WorkspaceError } from '../shared/workspace/errors'; import type { WorkspaceErrorCode } from '../shared/workspace/errors'; @@ -270,6 +405,42 @@ export interface IpcHandlerRuntime { * renderer sends can influence which directory is offered or chosen. */ readonly selectProjectDirectory: () => Promise; + /** + * Shows the native save dialog for a memory export and resolves to what the + * user chose, or `null` if they dismissed it (Phase 2, Milestone 8). + * + * Injected for the same reason `selectProjectDirectory` is, and narrow for + * the same reason: the only thing the caller may influence is the scope + * name in the suggested file name, which comes from an enum. The renderer + * cannot name a file to overwrite. + */ + readonly selectMemoryExportFile: (scope: MemoryScope) => Promise; + /** + * Shows the native open dialog for a memory import (Phase 2, Milestone 8). + * + * Takes no parameter at all: nothing the renderer sends can influence which + * file is offered or chosen, and the file that comes back is still treated + * as untrusted content by everything downstream. + */ + readonly selectMemoryImportFile: () => Promise; + /** + * The Electron-backed halves of the automation executor (Phase 2, Milestone + * 10). Injected for the same reason `selectProjectDirectory` is: the real + * implementations call `electron`'s `shell` and `app`, and hold the real + * `BrowserWindow`, all of which exist only inside a running application. + * `main/windows-automation.ts` never imports `electron` itself. + */ + readonly automationOpenPath: (path: string) => Promise; + readonly automationOpenExternal: (url: string) => Promise; + readonly automationSpecialFolder: (name: 'desktop' | 'documents' | 'downloads') => string; + readonly focusMainWindow: () => boolean; + /** + * Starts a detached process for `automation:run`. Defaults to the real + * {@link launchDetached} in `main/index.ts`; overridable so a test never + * has to actually spawn Notepad, Calculator or a System32 utility to + * exercise the handler. + */ + readonly automationLaunchProcess?: AutomationRunDependencies['launchProcess']; } /** @@ -540,6 +711,149 @@ function toAgentRunResponse(result: ActionResult): AgentRunResponse { }; } +/** + * The same defence in depth the workspace and agent vocabularies get, for the + * memory vocabulary (Phase 2, Milestone 8). + * + * A memory `perform` only ever throws a `MemoryError` translated to an + * `ActionExecutionError` carrying the same code, but `execute` has its own + * generic `EXECUTION_FAILED` for an unexpected throw, and that is not a + * member of this vocabulary. Degrading it keeps a response schema-valid + * rather than letting an internal code — whose text this milestone has not + * reviewed — cross into the renderer. + */ +function toMemoryErrorCode( + result: ActionResult, + fallback: MemoryErrorCode, +): MemoryErrorCode | undefined { + if (result.errorCode === undefined) return undefined; + return isMemoryErrorCode(result.errorCode) ? result.errorCode : fallback; +} + +function toMemoryQueryResponse(result: ActionResult): MemoryQueryResponse { + const errorCode = toMemoryErrorCode(result, 'MEMORY_READ_FAILED'); + return { + outcome: result.outcome, + ...(result.value === undefined ? {} : { result: result.value }), + ...(errorCode === undefined ? {} : { errorCode }), + }; +} + +function toMemoryRetrieveResponse( + result: ActionResult, +): MemoryRetrieveResponse { + const errorCode = toMemoryErrorCode(result, 'MEMORY_READ_FAILED'); + return { + outcome: result.outcome, + ...(result.value === undefined ? {} : { result: result.value }), + ...(errorCode === undefined ? {} : { errorCode }), + }; +} + +function toMemoryRecordResponse(result: ActionResult): MemoryRecordResponse { + const errorCode = toMemoryErrorCode(result, 'MEMORY_STORE_FAILED'); + return { + outcome: result.outcome, + ...(result.value === undefined ? {} : { record: result.value }), + ...(errorCode === undefined ? {} : { errorCode }), + }; +} + +/** + * The same defence in depth the workspace, agent and memory vocabularies get, + * for the workflow vocabulary (Phase 2, Milestone 9). + * + * A workflow `perform` throws a `WorkflowError`, an `AgentError` or a + * `WorkspaceError`, each translated to an `ActionExecutionError` carrying its + * own code — and `execute` has its own generic `EXECUTION_FAILED` besides. + * Only the workflow vocabulary is valid in a workflow response, so anything + * else degrades to the fallback rather than failing validation on the way + * out or letting an unreviewed code reach the renderer. + */ +function toWorkflowErrorCode( + result: ActionResult, + fallback: WorkflowErrorCode, +): WorkflowErrorCode | undefined { + if (result.errorCode === undefined) return undefined; + return isWorkflowErrorCode(result.errorCode) ? result.errorCode : fallback; +} + +function toWorkflowListResponse(result: ActionResult): WorkflowListResponse { + const errorCode = toWorkflowErrorCode(result, 'WORKFLOW_STORE_FAILED'); + return { + outcome: result.outcome, + ...(result.value === undefined ? {} : { workflows: [...result.value] }), + ...(errorCode === undefined ? {} : { errorCode }), + }; +} + +function toWorkflowRunResponse(result: ActionResult): WorkflowRunResponse { + const errorCode = toWorkflowErrorCode(result, 'WORKFLOW_RUN_FAILED'); + return { + outcome: result.outcome, + ...(result.value === undefined ? {} : { run: result.value }), + ...(errorCode === undefined ? {} : { errorCode }), + }; +} + +/** + * The same defence in depth the other vocabularies get, for the automation + * vocabulary (Phase 2, Milestone 10). + */ +function toAutomationErrorCode( + result: ActionResult, + fallback: AutomationErrorCode, +): AutomationErrorCode | undefined { + if (result.errorCode === undefined) return undefined; + return isAutomationErrorCode(result.errorCode) ? result.errorCode : fallback; +} + +/** The safe, display-only projection of one registry entry. Never the executable, args or URL. */ +function toAutomationToolSummary(tool: AutomationToolDefinition): { + id: AutomationToolDefinition['id']; + kind: AutomationToolDefinition['kind']; + label: string; + description: string; + requiresProject: boolean; +} { + return { + id: tool.id, + kind: tool.kind, + label: tool.label, + description: tool.description, + requiresProject: tool.requiresProject, + }; +} + +function toAutomationListResponse(result: ActionResult): AutomationListResponse { + const errorCode = toAutomationErrorCode(result, 'AUTOMATION_RUN_FAILED'); + return { + outcome: result.outcome, + ...(result.value === undefined ? {} : { catalog: result.value }), + ...(errorCode === undefined ? {} : { errorCode }), + }; +} + +function toAutomationRunResponse(result: ActionResult): AutomationRunResponse { + const errorCode = toAutomationErrorCode(result, 'AUTOMATION_RUN_FAILED'); + return { + outcome: result.outcome, + ...(result.value === undefined ? {} : { run: result.value }), + ...(errorCode === undefined ? {} : { errorCode }), + }; +} + +function toMemoryMutationResponse( + result: ActionResult, +): MemoryMutationResponse { + const errorCode = toMemoryErrorCode(result, 'MEMORY_STORE_FAILED'); + return { + outcome: result.outcome, + ...(result.value === undefined ? {} : { summary: result.value }), + ...(errorCode === undefined ? {} : { errorCode }), + }; +} + /** * How much of a run's output budget one step's result consumed. * @@ -1479,7 +1793,9 @@ export function registerIpcHandlers(ipcMain: IpcMain, runtime: IpcHandlerRuntime // decision vocabulary has no `allow`, so nothing here can remove a // confirmation the floor requires or turn a denial into a permission. if (request.requiresConfirmation) { - const answer = await runtime.requestConfirmation(describeProfileGatedStep(request)); + const answer = await runtime.requestConfirmation( + request.confirmationMessage ?? describeProfileGatedStep(request), + ); if (answer !== 'approved') { return { outcome: 'aborted', @@ -1711,4 +2027,858 @@ export function registerIpcHandlers(ipcMain: IpcMain, runtime: IpcHandlerRuntime inFlightAgentRuns.get(runId)?.abort(); return agentCancelResponseSchema.parse({ acknowledged: true }); }); + + // ------------------------------------------------------------------------- + // Local memory (Phase 2, Milestone 8) + // ------------------------------------------------------------------------- + + /** + * The session scope, held for the lifetime of this handler registration. + * + * Created per `registerIpcHandlers` call, exactly like + * {@link inFlightChatRequests} and the approved-project session, and for the + * same reason: one test's session notes can never leak into another's, and + * one application run's notes never survive into the next. Nothing writes + * this to disk — there is no branch below that could. + */ + const sessionMemory = createSessionMemoryStore(); + + /** + * Where one project's notes live, or a refusal. + * + * This is the whole of project isolation, and it is deliberately one + * function: the file name is *derived* from the canonically-resolved root + * of the project approved in this session, never supplied by a caller. + * There is no parameter anywhere in the memory channels through which a + * renderer could name a different project's store, because there is no + * parameter here at all. + */ + function requireProjectMemoryFile(): string { + const project = workspaceSession.get(); + if (project === null) throw new MemoryError('MEMORY_NO_PROJECT'); + return resolveProjectMemoryFile(runtime.userDataPaths.memoryProjectsDir, project.rootPath); + } + + /** + * The one place that knows which backing store a scope has. + * + * `main/memory-service.ts` is given this and never learns a path, which is + * what keeps "session memory is never written to disk" and "a project's + * notes are in that project's file" properties of one reviewed object + * rather than habits every operation has to keep. + */ + const memoryAccess: MemoryStoreAccess = { + read: (scope) => { + if (scope === 'session') return Promise.resolve(sessionMemory.read()); + if (scope === 'personal') { + return loadMemoryStore(runtime.userDataPaths.memoryPersonalFile, 'personal'); + } + return loadMemoryStore(requireProjectMemoryFile(), 'project'); + }, + write: (scope, store) => { + if (scope === 'session') { + sessionMemory.write(store); + return Promise.resolve(); + } + if (scope === 'personal') { + return writeMemoryStore(runtime.userDataPaths.memoryPersonalFile, store); + } + return writeMemoryStore(requireProjectMemoryFile(), store); + }, + }; + + /** + * Runs one memory operation as a permission-gated, audited action. + * + * The same shape as {@link runAgentAction}, differing only in which + * normalized error vocabulary it translates. + * + * The `parameters` every caller passes below are deliberately thin — an + * operation name, a scope, a bounded enum, and at most a *length*. **No + * record content, no search query, no objective and no file path ever + * enters an audit record.** The audit trail is meant to answer "what did + * this application do", and for memory that question is answerable without + * repeating the user's private notes into a second file that is + * append-only and therefore cannot be redacted afterwards. + */ + async function runMemoryAction( + actionType: ActionType, + parameters: Record, + perform: (now: string) => TValue | Promise, + buildConfirmation?: (now: string) => string | Promise, + ): Promise> { + const now = runtime.nowFn(); + const actionRuntime = buildActionRuntime(runtime, now); + const confirmationMessage = + buildConfirmation === undefined ? null : await buildConfirmation(now); + + return runAction( + actionRuntime, + newProposal(actionType, parameters), + confirmationMessage, + async () => { + try { + return await perform(now); + } catch (error) { + if (error instanceof MemoryError) { + throw new ActionExecutionError(error.code, error.message); + } + throw error; + } + }, + ); + } + + /** Plain English for a scope, for the two dialogs that name one. */ + function describeScope(scope: MemoryScope): string { + if (scope === 'session') return 'this session’s memory (never written to disk)'; + if (scope === 'project') return 'this project’s memory'; + return 'your personal memory'; + } + + /** + * How many records a scope currently holds, or `null` if that cannot be + * determined right now. + * + * The failure is swallowed deliberately: this only feeds a sentence in a + * confirmation dialog, and a dialog that cannot state a count should say so + * rather than turning a countable operation into an error before the user + * has even been asked. The real failure still surfaces — `perform` reads the + * same store and throws the normalized code. + */ + async function countMemories(scope: MemoryScope): Promise { + try { + return (await memoryAccess.read(scope)).records.length; + } catch { + return null; + } + } + + ipcMain.handle(IPC_MEMORY_LIST_CHANNEL, async (_event, ...args: unknown[]) => { + const [{ scope }] = memoryListRequestSchema.parse(args); + + const result = await runMemoryAction( + 'memory.read', + { operation: 'list', scope }, + (now) => listMemories(memoryAccess, scope, now), + ); + + return memoryListResponseSchema.parse(toMemoryQueryResponse(result)); + }); + + ipcMain.handle(IPC_MEMORY_SEARCH_CHANNEL, async (_event, ...args: unknown[]) => { + const [{ scope, query }] = memorySearchRequestSchema.parse(args); + + const result = await runMemoryAction( + 'memory.read', + // The query's length, never the query. What someone searched their own + // notes for is exactly as private as the notes. + { operation: 'search', scope, queryLength: query.length }, + (now) => searchMemoryScope(memoryAccess, scope, query, now), + ); + + return memorySearchResponseSchema.parse(toMemoryQueryResponse(result)); + }); + + ipcMain.handle(IPC_MEMORY_RETRIEVE_CHANNEL, async (_event, ...args: unknown[]) => { + const [{ objective }] = memoryRetrieveRequestSchema.parse(args); + + const result = await runMemoryAction( + 'memory.read', + { operation: 'retrieve', objectiveLength: objective.length }, + // Spans every readable scope, and is capped at `MEMORY_MAX_RETRIEVED` + // by the service — the response schema could not carry the whole store + // even if this asked for it. + (now) => retrieveRelevantMemories(memoryAccess, MEMORY_RETRIEVAL_SCOPES, objective, now), + ); + + return memoryRetrieveResponseSchema.parse(toMemoryRetrieveResponse(result)); + }); + + ipcMain.handle(IPC_MEMORY_ADD_CHANNEL, async (_event, ...args: unknown[]) => { + const [{ record }] = memoryAddRequestSchema.parse(args); + + const result = await runMemoryAction( + 'memory.write', + { + operation: 'add', + scope: record.scope, + category: record.category, + contentLength: record.content.length, + }, + // `source: 'user'` is stamped inside `addMemory`, never taken from the + // request — the input schema has no such field. + (now) => addMemory(memoryAccess, record, now, randomUUID), + ); + + return memoryAddResponseSchema.parse(toMemoryRecordResponse(result)); + }); + + ipcMain.handle(IPC_MEMORY_UPDATE_CHANNEL, async (_event, ...args: unknown[]) => { + const [{ id, record }] = memoryUpdateRequestSchema.parse(args); + + const result = await runMemoryAction( + 'memory.write', + { + operation: 'update', + scope: record.scope, + category: record.category, + contentLength: record.content.length, + }, + (now) => updateMemory(memoryAccess, id, record, now), + ); + + return memoryUpdateResponseSchema.parse(toMemoryRecordResponse(result)); + }); + + ipcMain.handle(IPC_MEMORY_SET_PINNED_CHANNEL, async (_event, ...args: unknown[]) => { + const [{ id, scope, pinned }] = memorySetPinnedRequestSchema.parse(args); + + const result = await runMemoryAction( + 'memory.write', + { operation: 'set-pinned', scope, pinned }, + (now) => setMemoryPinned(memoryAccess, id, scope, pinned, now), + ); + + return memorySetPinnedResponseSchema.parse(toMemoryRecordResponse(result)); + }); + + ipcMain.handle(IPC_MEMORY_DELETE_CHANNEL, async (_event, ...args: unknown[]) => { + const [{ id, scope }] = memoryDeleteRequestSchema.parse(args); + + const result = await runMemoryAction( + 'memory.write', + { operation: 'delete', scope }, + (now) => deleteMemory(memoryAccess, id, scope, now), + ); + + return memoryDeleteResponseSchema.parse(toMemoryMutationResponse(result)); + }); + + ipcMain.handle(IPC_MEMORY_CLEAR_CHANNEL, async (_event, ...args: unknown[]) => { + const [{ scope }] = memoryClearRequestSchema.parse(args); + + const result = await runMemoryAction( + 'memory.clear', + { operation: 'clear', scope }, + (now) => clearMemoryScope(memoryAccess, scope, now), + async () => { + const count = await countMemories(scope); + const subject = count === null ? 'every record' : `all ${String(count)} record(s)`; + return `Delete ${subject} in ${describeScope(scope)}? This cannot be undone.`; + }, + ); + + return memoryClearResponseSchema.parse(toMemoryMutationResponse(result)); + }); + + ipcMain.handle(IPC_MEMORY_EXPORT_CHANNEL, async (_event, ...args: unknown[]) => { + const [{ scope }] = memoryExportRequestSchema.parse(args); + + const result = await runMemoryAction( + 'memory.export', + { operation: 'export', scope }, + async (now) => { + const document = await buildMemoryExport(memoryAccess, scope, now); + // The file is chosen *after* the confirmation and by the user, in a + // native dialog this process owns. The renderer never sees a path and + // never supplies one. + const target = await runtime.selectMemoryExportFile(scope); + if (target === null) throw new MemoryError('MEMORY_FILE_SELECTION_CANCELLED'); + await writeMemoryExportFile(target, document); + return { scope, affected: document.records.length, rejected: 0 }; + }, + async () => { + const count = await countMemories(scope); + const subject = count === null ? 'the records' : `${String(count)} record(s)`; + return `Write ${subject} from ${describeScope(scope)} to a file outside Local Agent? You will choose the file next. Once exported, this application’s protections no longer apply to them.`; + }, + ); + + return memoryExportResponseSchema.parse(toMemoryMutationResponse(result)); + }); + + ipcMain.handle(IPC_MEMORY_IMPORT_CHANNEL, async (_event, ...args: unknown[]) => { + const [{ scope }] = memoryImportRequestSchema.parse(args); + + const result = await runMemoryAction( + 'memory.import', + { operation: 'import', scope }, + async (now) => { + const source = await runtime.selectMemoryImportFile(); + if (source === null) throw new MemoryError('MEMORY_FILE_SELECTION_CANCELLED'); + // Returned as `unknown`: nothing has yet claimed this file is a + // memory export. `importMemoryRecords` decides that, against the + // schema, and re-stamps every record it accepts. + const document = await readMemoryImportFile(source); + return importMemoryRecords(memoryAccess, scope, document, now, randomUUID); + }, + () => + `Read memory records from a file outside Local Agent into ${describeScope(scope)}? You will choose the file next. Its contents are untrusted: every record is validated, and anything that looks like a credential is refused.`, + ); + + return memoryImportResponseSchema.parse(toMemoryMutationResponse(result)); + }); + + // ------------------------------------------------------------------------- + // Workflows (Phase 2, Milestone 9) + // ------------------------------------------------------------------------- + + /** One in-flight workflow run, and the two ways to stop it. */ + interface InFlightWorkflowRun { + readonly workflowId: string; + readonly controller: AbortController; + /** Set by `workflow:pause`: stop cleanly at the next step boundary. */ + paused: boolean; + } + + /** + * The workflow runs in flight, keyed by run id. + * + * Created per `registerIpcHandlers` call, exactly like + * {@link inFlightAgentRuns}, so one test's run never survives into another + * and one application run's cancellation handle never survives into the + * next. This map is also the *only* thing that knows what is actually + * executing, which is why the store's "do not edit or delete a running + * workflow" rule is answered from here rather than from a flag on disk. + */ + const inFlightWorkflowRuns = new Map(); + + function isWorkflowRunning(workflowId: string): boolean { + for (const run of inFlightWorkflowRuns.values()) { + if (run.workflowId === workflowId) return true; + } + return false; + } + + /** + * Runs one workflow operation as a permission-gated, audited action. + * + * The same shape as {@link runAgentAction}, differing only in which + * normalized error vocabulary it translates. An `AgentError` and a + * `WorkspaceError` are translated too, because a workflow run reaches agent + * and workspace code and a failure there must not escape as a raw error. + */ + async function runWorkflowAction( + actionType: ActionType, + parameters: Record, + perform: (now: string) => TValue | Promise, + buildConfirmation?: (now: string) => string | Promise, + ): Promise> { + const now = runtime.nowFn(); + const actionRuntime = buildActionRuntime(runtime, now); + const confirmationMessage = + buildConfirmation === undefined ? null : await buildConfirmation(now); + + return runAction( + actionRuntime, + newProposal(actionType, parameters), + confirmationMessage, + async () => { + try { + return await perform(now); + } catch (error) { + if (error instanceof WorkflowError) { + throw new ActionExecutionError(error.code, error.message); + } + if (error instanceof AgentError) { + throw new ActionExecutionError(error.code, error.message); + } + if (error instanceof WorkspaceError) { + throw new ActionExecutionError(error.code, error.message); + } + throw error; + } + }, + ); + } + + /** + * Forwards one bounded progress event to the renderer that started the run. + * + * The second main → renderer push channel in this codebase, and it carries + * the same four properties {@link emitChatChunks} does: + * + * - **Only the requesting window is told.** The event goes to + * `event.sender` — the `WebContents` that invoked this `workflow:run` — + * not broadcast, and never to a destroyed one. + * - **Only bounded events are sent.** The payload is counts, an index and + * two enums; there is no field for a summary, a path or any output. + * - **Only valid events are sent.** One that fails + * {@link workflowProgressIpcEventSchema} is skipped silently. + * - **A failure here never fails the run.** Progress is advisory; the + * authoritative record is the one `workflow:run` resolves with, never the + * sum of these events. + */ + function emitWorkflowProgress(event: IpcMainInvokeEvent, progress: WorkflowProgressEvent): void { + const sender = event.sender; + if (sender.isDestroyed()) return; + + const payload = workflowProgressIpcEventSchema.safeParse(progress); + if (!payload.success) return; + sender.send(IPC_WORKFLOW_PROGRESS_CHANNEL, payload.data); + } + + /** + * The sentence shown before a workflow is created, changed or removed. + * + * States what the workflow would be allowed to reach for, in the same terms + * the run dialog uses, because "what does this workflow permit" is the only + * question that matters when approving an edit. The name is sanitized + * before it is interpolated: it is user-typed text about to be shown inside + * a security prompt, which is the worst place for something that can move + * the cursor or reorder itself. + */ + function describeWorkflowWrite( + verb: string, + workflowId: string, + input: WorkflowInput | null, + ): string { + const lines = [`${verb} the workflow "${workflowId}"?`]; + + if (input !== null) { + lines.push( + '', + `Agent: ${input.agentProfileId}`, + `Steps: ${String(input.steps.length)} (ceiling ${String(input.limits.maxSteps)} including retries)`, + `Limits: ${String(Math.round(input.limits.maxDurationMs / 1000))}s, ${String(input.limits.maxOutputBytes)} bytes`, + '', + 'Ordered steps:', + ...input.steps.map((step, index) => { + const scope = step.target === '' ? 'the whole approved project' : step.target; + const checkpoint = step.checkpoint ? ' [asks first]' : ''; + return ` ${String(index + 1)}. ${step.tool} on ${scope}${checkpoint}`; + }), + ); + } + + lines.push( + '', + 'A workflow cannot grant a permission. Every step is still decided by the permission policy on its own action type, and it can only use tools the selected agent already allows.', + ); + + return lines.join('\n'); + } + + /** Reads the selected agent profile, or throws the normalized refusal. */ + async function requireWorkflowAgent(workflow: Workflow): Promise { + const registry = await readAgentRegistry(runtime.userDataPaths.agentProfilesFile); + const profile = findAgentProfile(registry.profiles, workflow.agentProfileId); + if (profile === null) throw new WorkflowError('WORKFLOW_AGENT_NOT_FOUND'); + if (!profile.enabled) throw new WorkflowError('WORKFLOW_AGENT_DISABLED'); + return profile; + } + + /** + * Refuses a definition whose steps fall outside the selected agent. + * + * The first link of the authority chain, checked at save time so that a + * workflow which could never run is refused while someone can still fix it. + * It is not the control — `decideNextWorkflowStep` checks the same two + * things again before every step — but a definition that fails here would + * fail there too, and failing early is kinder. + */ + async function requireWorkflowWithinAgent(input: WorkflowInput, now: string): Promise { + const candidate = workflowSchema.safeParse({ ...input, createdAt: now, updatedAt: now }); + if (!candidate.success) throw new WorkflowError('WORKFLOW_INVALID'); + + const profile = await requireWorkflowAgent(candidate.data); + const violation = workflowStepsWithinProfile(candidate.data, profile); + if (violation !== null) throw new WorkflowError(violation); + } + + ipcMain.handle(IPC_WORKFLOW_LIST_CHANNEL, async (_event, ...args: unknown[]) => { + workflowListRequestSchema.parse(args); + + const result = await runWorkflowAction( + 'workflow.read', + { operation: 'list' }, + () => readWorkflows(runtime.userDataPaths.workflowsFile), + ); + + return workflowListResponseSchema.parse(toWorkflowListResponse(result)); + }); + + ipcMain.handle(IPC_WORKFLOW_CREATE_CHANNEL, async (_event, ...args: unknown[]) => { + const [{ workflow }] = workflowCreateRequestSchema.parse(args); + + const result = await runWorkflowAction( + 'workflow.write', + { + operation: 'create', + workflowId: workflow.id, + agentProfileId: workflow.agentProfileId, + stepCount: workflow.steps.length, + }, + async (now) => { + await requireWorkflowWithinAgent(workflow, now); + return createWorkflow(runtime.userDataPaths.workflowsFile, workflow, now); + }, + () => describeWorkflowWrite('Create', workflow.id, workflow), + ); + + return workflowCreateResponseSchema.parse(toWorkflowListResponse(result)); + }); + + ipcMain.handle(IPC_WORKFLOW_UPDATE_CHANNEL, async (_event, ...args: unknown[]) => { + const [{ workflowId, workflow }] = workflowUpdateRequestSchema.parse(args); + + const result = await runWorkflowAction( + 'workflow.write', + { + operation: 'update', + workflowId, + agentProfileId: workflow.agentProfileId, + stepCount: workflow.steps.length, + }, + async (now) => { + await requireWorkflowWithinAgent(workflow, now); + return updateWorkflow( + runtime.userDataPaths.workflowsFile, + workflowId, + workflow, + now, + isWorkflowRunning, + ); + }, + () => describeWorkflowWrite('Replace', workflowId, workflow), + ); + + return workflowUpdateResponseSchema.parse(toWorkflowListResponse(result)); + }); + + ipcMain.handle(IPC_WORKFLOW_DUPLICATE_CHANNEL, async (_event, ...args: unknown[]) => { + const [{ workflowId, newId }] = workflowDuplicateRequestSchema.parse(args); + + const result = await runWorkflowAction( + 'workflow.write', + { operation: 'duplicate', workflowId, newId }, + (now) => duplicateWorkflow(runtime.userDataPaths.workflowsFile, workflowId, newId, now), + // The copy is created disabled, which the dialog says plainly so that + // approving a duplicate is never mistaken for approving a second + // runnable workflow. + () => + `${describeWorkflowWrite('Duplicate', workflowId, null)}\n\nThe copy is created disabled.`, + ); + + return workflowDuplicateResponseSchema.parse(toWorkflowListResponse(result)); + }); + + ipcMain.handle(IPC_WORKFLOW_DELETE_CHANNEL, async (_event, ...args: unknown[]) => { + const [{ workflowId }] = workflowDeleteRequestSchema.parse(args); + + const result = await runWorkflowAction( + 'workflow.write', + { operation: 'delete', workflowId }, + () => deleteWorkflow(runtime.userDataPaths.workflowsFile, workflowId, isWorkflowRunning), + () => describeWorkflowWrite('Delete', workflowId, null), + ); + + return workflowDeleteResponseSchema.parse(toWorkflowListResponse(result)); + }); + + ipcMain.handle(IPC_WORKFLOW_SET_ENABLED_CHANNEL, async (_event, ...args: unknown[]) => { + const [{ workflowId, enabled }] = workflowSetEnabledRequestSchema.parse(args); + + const result = await runWorkflowAction( + 'workflow.write', + { operation: 'set-enabled', workflowId, enabled }, + (now) => setWorkflowEnabled(runtime.userDataPaths.workflowsFile, workflowId, enabled, now), + () => describeWorkflowWrite(enabled ? 'Enable' : 'Disable', workflowId, null), + ); + + return workflowSetEnabledResponseSchema.parse(toWorkflowListResponse(result)); + }); + + ipcMain.handle( + IPC_WORKFLOW_RUN_CHANNEL, + async (event: IpcMainInvokeEvent, ...args: unknown[]) => { + const [{ runId, workflowId, objective }] = workflowRunRequestSchema.parse(args); + + // Read once, up front, so the dialog the user reads and the run that + // follows describe the same workflow — and read from the *store*, never + // from the request, so a compromised renderer cannot widen a run by + // describing it differently. + // + // These reads happen *before* the pipeline because the confirmation + // dialog is built from what they return, so a failure here is a request + // that never became a proposal: nothing was decided and nothing was + // executed. It is answered with the normalized code rather than thrown, + // so the renderer sees an ordinary failure response — the same shape + // every other refusal takes — instead of a rejected invoke. + let prepared: { + workflow: Workflow; + profile: AgentProfile; + provider: ModelProvider; + tools: readonly AgentToolDefinition[]; + }; + try { + const workflow = await requireWorkflow(runtime.userDataPaths.workflowsFile, workflowId); + const profile = await requireWorkflowAgent(workflow); + prepared = { + workflow, + profile, + provider: await resolveRunProvider(profile, runtime.nowFn()), + tools: describeWorkflowTools(workflow), + }; + } catch (error) { + if (error instanceof WorkflowError) { + return workflowRunResponseSchema.parse({ + outcome: 'failure', + errorCode: error.code, + } satisfies WorkflowRunResponse); + } + throw error; + } + + const { workflow, profile, provider, tools } = prepared; + + const result = await runWorkflowAction( + 'workflow.run', + { + operation: 'run', + // Ids and counts, never the objective. An id is a bounded, lowercase + // slug the schema constrains to `[a-z0-9._-]`, and recording it is + // what lets the audit trail answer "which workflow authorized this". + // The objective is user content, so only its length is recorded. + workflowId: workflow.id, + agentProfileId: profile.id, + stepCount: workflow.steps.length, + objectiveLength: objective.length, + }, + async (now) => { + if (inFlightWorkflowRuns.size > 0) { + throw new WorkflowError('WORKFLOW_RUN_ALREADY_RUNNING'); + } + if (!workflow.enabled) throw new WorkflowError('WORKFLOW_DISABLED'); + + const controller = new AbortController(); + const inFlight: InFlightWorkflowRun = { + workflowId: workflow.id, + controller, + paused: false, + }; + inFlightWorkflowRuns.set(runId, inFlight); + + // The workflow's steps are executed by the *agent* step runner — + // the same closure, the same `runWorkspaceAction`, the same + // permission decision and the same audit record an agent run gets. + // There is no execution path here that Milestone 7 did not already + // have. + const runAgentStep = createAgentStepRunner(controller.signal); + + try { + return await runWorkflow({ + runId, + workflow, + profile, + provider, + objective, + startedAt: now, + nowFn: runtime.nowFn, + monotonicMs: () => Date.now(), + signal: controller.signal, + isPaused: () => inFlight.paused, + isEmergencyEngaged, + hasProject: () => workspaceSession.get() !== null, + refreshWorkflow: async () => + findWorkflow(await readWorkflows(runtime.userDataPaths.workflowsFile), workflow.id), + refreshProfile: async () => { + const registry = await readAgentRegistry(runtime.userDataPaths.agentProfilesFile); + return findAgentProfile(registry.profiles, workflow.agentProfileId); + }, + runStep: (request) => + runAgentStep({ + // A `WorkflowStep` carries every field an `AgentPlanStep` has, + // so the request handed to the shared executor is built from + // the stored definition and nothing else. + step: { + tool: request.step.tool, + target: request.step.target, + query: request.step.query, + }, + tool: request.tool, + requiresConfirmation: request.requiresConfirmation, + objective, + confirmationMessage: describeWorkflowCheckpoint(workflow, request), + }), + onProgress: (progress) => { + emitWorkflowProgress(event, progress); + }, + }); + } finally { + inFlightWorkflowRuns.delete(runId); + } + }, + () => describeWorkflowRun(workflow, profile, tools), + ); + + return workflowRunResponseSchema.parse(toWorkflowRunResponse(result)); + }, + ); + + ipcMain.handle(IPC_WORKFLOW_PAUSE_CHANNEL, (_event, ...args: unknown[]) => { + const [{ runId }] = workflowPauseRequestSchema.parse(args); + // Cooperative and idempotent: the run stops at the **next step + // boundary**, keeping every step it already completed, and reports + // `paused`. Ungated for the reason every other stop channel is: it cannot + // start an action, read anything or reach anything. + const run = inFlightWorkflowRuns.get(runId); + if (run !== undefined) run.paused = true; + return workflowControlResponseSchema.parse({ acknowledged: true }); + }); + + ipcMain.handle(IPC_WORKFLOW_CANCEL_CHANNEL, (_event, ...args: unknown[]) => { + const [{ runId }] = workflowCancelRequestSchema.parse(args); + // Best-effort and idempotent, exactly like `agent:cancel`. Aborting the + // run's signal also kills a child process the run had started, which is + // the difference between this and a pause. + inFlightWorkflowRuns.get(runId)?.controller.abort(); + return workflowControlResponseSchema.parse({ acknowledged: true }); + }); + + // ------------------------------------------------------------------------- + // Windows automation (Phase 2, Milestone 10) + // ------------------------------------------------------------------------- + + /** + * The one automation action allowed to be running, and how to stop it. + * + * Pending confirmations are cancellable too: reserve the request id before + * entering `runAction`, then let the signal prevent execution if the user + * cancels before approving. Active execution is tracked separately so two + * confirmations may be pending without racing the one-action limit. + */ + const inFlightAutomationRuns = new Map(); + const activeAutomationRuns = new Map(); + + /** + * The sentence shown before an automation tool runs. + * + * States what the tool actually is — never a raw path, URL or argument, + * because none of those exist on a registry entry — so the user is + * approving the same thing the interface displayed. + */ + function describeAutomationRun(toolId: string): string { + const tool = findAutomationTool(toolId); + if (tool === null) return `Run the automation action "${toolId}"?`; + + const lines = [`${tool.label}?`, '', tool.description]; + if (tool.requiresProject) { + lines.push('', 'This opens the root of the approved project.'); + } + lines.push( + '', + 'This is a registered Local Agent action. It cannot run an arbitrary command, open an arbitrary path or reach an arbitrary website.', + ); + return lines.join('\n'); + } + + ipcMain.handle(IPC_AUTOMATION_LIST_CHANNEL, async (_event, ...args: unknown[]) => { + automationListRequestSchema.parse(args); + const now = runtime.nowFn(); + const actionRuntime = buildActionRuntime(runtime, now); + + const result = await runAction( + actionRuntime, + newProposal('automation.read', {}), + null, + (): AutomationCatalog => ({ + tools: AUTOMATION_TOOLS.map(toAutomationToolSummary), + busy: inFlightAutomationRuns.size > 0, + }), + ); + + return automationListResponseSchema.parse(toAutomationListResponse(result)); + }); + + ipcMain.handle(IPC_AUTOMATION_RUN_CHANNEL, async (_event, ...args: unknown[]) => { + const [{ runId, toolId }] = automationRunRequestSchema.parse(args); + const now = runtime.nowFn(); + const actionRuntime = buildActionRuntime(runtime, now); + const tool = findAutomationTool(toolId); + const controller = new AbortController(); + const duplicateRunId = inFlightAutomationRuns.has(runId); + if (!duplicateRunId) inFlightAutomationRuns.set(runId, controller); + + try { + const result = await runAction( + actionRuntime, + // The id and its kind, never any resolved path or URL — both are + // literals in reviewed source, so recording them costs nothing and + // lets the audit trail answer "which tool ran". + newProposal('automation.run', { toolId, kind: tool?.kind ?? 'unknown' }), + describeAutomationRun(toolId), + async (): Promise => { + const startedAt = now; + + try { + if (tool === null) throw new AutomationError('AUTOMATION_TOOL_NOT_FOUND'); + if (duplicateRunId || activeAutomationRuns.size >= AUTOMATION_MAX_CONCURRENT_RUNS) { + throw new AutomationError('AUTOMATION_ALREADY_RUNNING'); + } + activeAutomationRuns.set(runId, controller); + if (controller.signal.aborted) throw new AutomationError('AUTOMATION_CANCELLED'); + + const outcome = await runAutomationTool({ + tool, + projectRoot: workspaceSession.get()?.rootPath ?? null, + systemRoot: process.env.SystemRoot ?? 'C:\\Windows', + signal: controller.signal, + isEmergencyEngaged, + dependencies: { + launchProcess: runtime.automationLaunchProcess ?? launchDetached, + openPath: runtime.automationOpenPath, + openExternal: runtime.automationOpenExternal, + getSpecialFolder: runtime.automationSpecialFolder, + focusMainWindow: runtime.focusMainWindow, + }, + }); + + return { + runId, + toolId: tool.id, + kind: tool.kind, + outcome: 'succeeded', + startedAt, + finishedAt: runtime.nowFn(), + durationMs: Math.max(0, Date.now() - Date.parse(startedAt)), + attempts: outcome.attempts, + timedOut: false, + cancelled: false, + stoppedByEmergency: false, + verified: true, + }; + } catch (error) { + // A failed `perform` never reaches the renderer as `result.value` + // — only the normalized `errorCode` does, exactly as a failed + // workflow or agent step reports. `AUTOMATION_TIMEOUT`, + // `AUTOMATION_CANCELLED` and `AUTOMATION_EMERGENCY_STOPPED` are + // stops Local Agent itself performed; every other code is a real + // failure. `toAutomationRunResponse` carries the distinction + // through the code alone. + if (error instanceof AutomationError) { + throw new ActionExecutionError(error.code, error.message); + } + throw error; + } + }, + ); + + return automationRunResponseSchema.parse(toAutomationRunResponse(result)); + } finally { + if (activeAutomationRuns.get(runId) === controller) activeAutomationRuns.delete(runId); + if (inFlightAutomationRuns.get(runId) === controller) inFlightAutomationRuns.delete(runId); + } + }); + + ipcMain.handle(IPC_AUTOMATION_CANCEL_CHANNEL, (_event, ...args: unknown[]) => { + const [{ runId }] = automationCancelRequestSchema.parse(args); + // Best-effort and idempotent, exactly like `command:cancel`. Aborting + // only ever abandons a launch attempt in progress — see + // `main/windows-automation.ts` for why an already-started application is + // deliberately left running. + inFlightAutomationRuns.get(runId)?.abort(); + return automationCancelResponseSchema.parse({ acknowledged: true }); + }); } diff --git a/src/main/memory-picker.ts b/src/main/memory-picker.ts new file mode 100644 index 0000000..7cf8df0 --- /dev/null +++ b/src/main/memory-picker.ts @@ -0,0 +1,98 @@ +/** + * The native memory export and import dialogs (Phase 2, Milestone 8). + * + * The counterpart to `main/directory-picker.ts`, and native for the same + * reason: these two dialogs are the *consent* for memory content crossing the + * application boundary in either direction. The renderer must not be able to + * choose the file, pre-fill it, forge the dialog, or dismiss it on the user's + * behalf. + * + * So the renderer supplies nothing but a scope. Neither function takes a + * path, and there is no default directory, no remembered location and no + * suggested one beyond a plain file name: whatever the dialog returns is what + * the user actually clicked on. A compromised renderer can ask that the user + * be asked, and nothing more. + * + * `properties` is deliberately minimal on both. The open dialog has + * `openFile` alone — no `multiSelections` (one file at a time) and no + * `openDirectory` (there is nothing here that reads a tree). The save dialog + * gets `showOverwriteConfirmation`, so overwriting an existing file is always + * the user's second, explicit decision. + */ + +import type { BrowserWindow, OpenDialogOptions, SaveDialogOptions } from 'electron'; +import { dialog } from 'electron'; + +import type { MemoryScope } from '../shared/constants'; + +/** + * A safe default file name for an export. + * + * Built from the scope enum and a fixed prefix — never from a project name, a + * path, or a record's content — so nothing user-controlled reaches a file + * name. The user can still type whatever they like in the dialog; this is + * only what it opens with. + */ +function defaultExportFileName(scope: MemoryScope): string { + return `local-agent-memory-${scope}.json`; +} + +const JSON_FILTERS = [{ name: 'Memory export', extensions: ['json'] }]; + +/** + * Shows the save dialog and resolves to the chosen file, or `null` when the + * user dismissed it. + * + * `null` is a first-class answer, not an error: cancelling is the user + * declining, and the caller turns it into the dedicated + * `MEMORY_FILE_SELECTION_CANCELLED` code rather than a generic failure. + */ +export async function showMemoryExportPicker( + window: BrowserWindow | null, + scope: MemoryScope, +): Promise { + const options: SaveDialogOptions = { + title: 'Local Agent — export memory', + buttonLabel: 'Export', + defaultPath: defaultExportFileName(scope), + message: 'These notes will be written outside Local Agent, where its protections do not apply.', + filters: JSON_FILTERS, + properties: ['showOverwriteConfirmation', 'createDirectory'], + }; + + const result = window + ? await dialog.showSaveDialog(window, options) + : await dialog.showSaveDialog(options); + + if (result.canceled) return null; + // Electron types `filePath` as a plain string here, unlike the open + // dialog's array, so an empty string is the only "nothing chosen" case + // left once `canceled` is false. + return result.filePath.length === 0 ? null : result.filePath; +} + +/** + * Shows the open dialog and resolves to the chosen file, or `null`. + * + * The returned path is *not* trusted as a memory export on the strength of + * having come from here — `main/memory-transfer.ts` still size-checks it, + * parses it defensively and hands back `unknown`, and the service still + * validates it against the schema. A dialog result is still input. + */ +export async function showMemoryImportPicker(window: BrowserWindow | null): Promise { + const options: OpenDialogOptions = { + title: 'Local Agent — import memory', + buttonLabel: 'Import', + message: 'Local Agent will read memory records from this file. Its contents are untrusted.', + filters: JSON_FILTERS, + properties: ['openFile'], + }; + + const result = window + ? await dialog.showOpenDialog(window, options) + : await dialog.showOpenDialog(options); + + if (result.canceled) return null; + const [chosen] = result.filePaths; + return chosen === undefined || chosen.length === 0 ? null : chosen; +} diff --git a/src/main/memory-service.ts b/src/main/memory-service.ts new file mode 100644 index 0000000..3484201 --- /dev/null +++ b/src/main/memory-service.ts @@ -0,0 +1,446 @@ +/** + * Memory operations (Phase 2, Milestone 8). + * + * The layer between `main/ipc.ts`, which decides *whether* an operation may + * happen, and `main/memory-store.ts`, which knows where the bytes are. Every + * function here is given a {@link MemoryStoreAccess} rather than a path, so + * this module never learns which file a scope lives in — which is what keeps + * "project notes are isolated" a property of the accessor rather than of + * every operation remembering to check. + * + * Five rules hold across everything below: + * + * - **An expired record is never returned and never re-written.** Reads + * filter by expiry; writes persist the filtered set. Reads never write, so + * an expiry does not by itself cause disk activity — the record disappears + * from every view immediately and leaves the file at the next write to + * that scope. + * - **A record cannot change scope.** Every mutation addresses a record + * within one scope and refuses a record found in another. There is no + * "move" operation, and editing a project note into a personal one is not + * expressible. + * - **Credential-shaped content is refused at the boundary.** `add`, + * `update` and `import` run {@link findLikelySecret} before anything is + * written. The loader deliberately does not, so a rule tightened later + * cannot start discarding files of notes that were already stored. + * - **Nothing here reads a clock or generates an id.** Both are injected, as + * they are for every other main-process module in this codebase, so tests + * are deterministic and one IPC call has one timestamp throughout. + * - **A failure is a {@link MemoryError} and nothing else.** No filesystem + * error, no Zod issue and no fragment of a record's content escapes this + * module — the caller receives a code from a closed vocabulary. + */ + +import { + MEMORY_MAX_RECORDS_PER_SCOPE, + MEMORY_MAX_RETRIEVED, + MEMORY_MAX_SEARCH_RESULTS, + MEMORY_SCHEMA_VERSION, +} from '../shared/constants'; +import type { MemoryScope } from '../shared/constants'; +import { MemoryError } from '../shared/memory/errors'; +import { findLikelySecret } from '../shared/memory/secret-scan'; +import { + activeMemories, + orderMemories, + retrieveMemories, + searchMemories, +} from '../shared/memory/retrieval'; +import { + MEMORY_EXPORT_KIND, + memoryExportSchema, + memoryRecordSchema, +} from '../shared/schemas/memory.schema'; +import type { + MemoryExport, + MemoryMutationSummary, + MemoryQueryResult, + MemoryRecord, + MemoryRecordInput, + MemoryRetrievalResult, + MemoryStore, +} from '../shared/schemas/memory.schema'; + +/** + * How one scope's store is reached. + * + * Built in `main/ipc.ts` from the resolved user-data paths, the session store + * and the approved-project session. A project scope with no approved project + * throws `MEMORY_NO_PROJECT` from `read` — the refusal lives in the accessor, + * at the one place that knows which project is open, rather than being + * repeated in every operation. + */ +export interface MemoryStoreAccess { + read(scope: MemoryScope): Promise; + write(scope: MemoryScope, store: MemoryStore): Promise; +} + +/** The scopes a retrieval spans, in the order ties are broken. */ +export type ReadableScopes = readonly MemoryScope[]; + +function withRecords(store: MemoryStore, records: readonly MemoryRecord[]): MemoryStore { + return { schemaVersion: MEMORY_SCHEMA_VERSION, scope: store.scope, records: [...records] }; +} + +/** + * Persists `records` to `scope`, dropping anything already expired. + * + * Every write goes through here, so "an expired record leaves the file at the + * next write" is one line in one place rather than a habit each operation has + * to keep. + */ +async function commit( + access: MemoryStoreAccess, + store: MemoryStore, + records: readonly MemoryRecord[], + now: string, +): Promise { + const pruned = activeMemories(records, now); + try { + await access.write(store.scope, withRecords(store, pruned)); + } catch (error) { + // A `MemoryError` from the accessor — `MEMORY_NO_PROJECT`, say — is + // already normalized and says something true; anything else is a + // filesystem or validation failure whose message must not travel. + if (error instanceof MemoryError) throw error; + throw new MemoryError('MEMORY_STORE_FAILED'); + } +} + +/** Refuses content that looks like a credential before it can be stored. */ +function requireNoSecret(content: string): void { + if (findLikelySecret(content) !== null) throw new MemoryError('MEMORY_SECRET_REJECTED'); +} + +function findRecord(store: MemoryStore, id: string): MemoryRecord { + const record = store.records.find((entry) => entry.id === id); + if (record === undefined) throw new MemoryError('MEMORY_NOT_FOUND'); + return record; +} + +// --------------------------------------------------------------------------- +// Reads +// --------------------------------------------------------------------------- + +/** + * One scope's records, most important and most recent first. + * + * Capped at {@link MEMORY_MAX_SEARCH_RESULTS} with `total` and `truncated` + * reported alongside, so a caller can always tell a complete answer from a + * capped one rather than drawing a conclusion from a silently trimmed list. + */ +export async function listMemories( + access: MemoryStoreAccess, + scope: MemoryScope, + now: string, +): Promise { + const store = await access.read(scope); + const active = orderMemories(activeMemories(store.records, now)); + return { + scope, + records: active.slice(0, MEMORY_MAX_SEARCH_RESULTS), + total: active.length, + truncated: active.length > MEMORY_MAX_SEARCH_RESULTS, + }; +} + +export async function searchMemoryScope( + access: MemoryStoreAccess, + scope: MemoryScope, + query: string, + now: string, +): Promise { + const store = await access.read(scope); + const outcome = searchMemories(store.records, query, now, MEMORY_MAX_SEARCH_RESULTS); + return { + scope, + records: [...outcome.records], + total: outcome.total, + truncated: outcome.truncated, + }; +} + +/** + * The small, relevant set — the shape a model would eventually be handed. + * + * Spans every scope the session can currently read, concatenated and then + * ranked together, so a highly relevant project note outranks an unrelated + * personal one rather than scope order deciding it. A scope that cannot be + * read right now (project, with nothing approved) is skipped rather than + * failing the whole retrieval: a reminder is not worth refusing because one + * of three sources is unavailable. + * + * Capped at {@link MEMORY_MAX_RETRIEVED}. The whole store is never returned + * from here, and the response schema could not carry it if it were. + */ +export async function retrieveRelevantMemories( + access: MemoryStoreAccess, + scopes: ReadableScopes, + objective: string, + now: string, +): Promise { + const collected: MemoryRecord[] = []; + for (const scope of scopes) { + try { + const store = await access.read(scope); + collected.push(...store.records); + } catch (error) { + if (error instanceof MemoryError && error.code === 'MEMORY_NO_PROJECT') continue; + throw error; + } + } + + const outcome = retrieveMemories(collected, objective, now, MEMORY_MAX_RETRIEVED); + return { records: [...outcome.records], considered: outcome.considered }; +} + +// --------------------------------------------------------------------------- +// Single-record writes +// --------------------------------------------------------------------------- + +/** + * Adds one record. + * + * `source` is stamped `'user'` here and nowhere else in this function's + * reach: the input schema has no such field, so a caller cannot claim its + * note came from an import — or, more importantly, cannot claim an imported + * record was typed by the person. + */ +export async function addMemory( + access: MemoryStoreAccess, + input: MemoryRecordInput, + now: string, + newId: () => string, +): Promise { + requireNoSecret(input.content); + + const store = await access.read(input.scope); + const active = activeMemories(store.records, now); + if (active.length >= MEMORY_MAX_RECORDS_PER_SCOPE) { + throw new MemoryError('MEMORY_LIMIT_REACHED'); + } + + const parsed = memoryRecordSchema.safeParse({ + ...input, + id: newId(), + source: 'user', + createdAt: now, + updatedAt: now, + }); + if (!parsed.success) throw new MemoryError('MEMORY_INVALID'); + + await commit(access, store, [...active, parsed.data], now); + return parsed.data; +} + +/** + * Replaces one record in place. + * + * `createdAt` and `source` are carried over from the stored record rather + * than taken from the request, so an edit can change neither when a note was + * written nor where it came from. The submitted scope must be the scope the + * record already lives in — a mismatch is a refusal, not a move. + */ +export async function updateMemory( + access: MemoryStoreAccess, + id: string, + input: MemoryRecordInput, + now: string, +): Promise { + requireNoSecret(input.content); + + const store = await access.read(input.scope); + const existing = findRecord(store, id); + if (existing.scope !== input.scope) throw new MemoryError('MEMORY_SCOPE_MISMATCH'); + + const parsed = memoryRecordSchema.safeParse({ + ...input, + id, + source: existing.source, + createdAt: existing.createdAt, + updatedAt: now, + }); + if (!parsed.success) throw new MemoryError('MEMORY_INVALID'); + + await commit( + access, + store, + store.records.map((entry) => (entry.id === id ? parsed.data : entry)), + now, + ); + return parsed.data; +} + +export async function setMemoryPinned( + access: MemoryStoreAccess, + id: string, + scope: MemoryScope, + pinned: boolean, + now: string, +): Promise { + const store = await access.read(scope); + const existing = findRecord(store, id); + + const updated: MemoryRecord = { ...existing, pinned, updatedAt: now }; + await commit( + access, + store, + store.records.map((entry) => (entry.id === id ? updated : entry)), + now, + ); + return updated; +} + +export async function deleteMemory( + access: MemoryStoreAccess, + id: string, + scope: MemoryScope, + now: string, +): Promise { + const store = await access.read(scope); + findRecord(store, id); + + await commit( + access, + store, + store.records.filter((entry) => entry.id !== id), + now, + ); + return { scope, affected: 1, rejected: 0 }; +} + +// --------------------------------------------------------------------------- +// Bulk operations +// --------------------------------------------------------------------------- + +/** + * Empties one scope. + * + * Writes an empty document rather than deleting the file, so the result is + * one atomic rename with the same crash safety as every other write: there is + * no window in which the store is missing rather than empty. Note the + * limitation this does *not* overcome — see `docs/security-model.md`: the + * replaced file's old blocks are not securely erased, because no application + * running on a journalling filesystem can promise that. + */ +export async function clearMemoryScope( + access: MemoryStoreAccess, + scope: MemoryScope, + now: string, +): Promise { + const store = await access.read(scope); + const affected = activeMemories(store.records, now).length; + await commit(access, store, [], now); + return { scope, affected, rejected: 0 }; +} + +/** + * Builds the document an export writes. + * + * Carries the records and the time, and **nothing about where they came + * from**: no path, no project name, no project key, no machine name, no user + * name. An export is the one artefact of this application designed to leave + * the machine, so it says what the notes are and nothing about the + * environment that produced them. + */ +export async function buildMemoryExport( + access: MemoryStoreAccess, + scope: MemoryScope, + now: string, +): Promise { + const store = await access.read(scope); + const parsed = memoryExportSchema.safeParse({ + kind: MEMORY_EXPORT_KIND, + schemaVersion: MEMORY_SCHEMA_VERSION, + exportedAt: now, + scope, + records: orderMemories(activeMemories(store.records, now)), + }); + if (!parsed.success) throw new MemoryError('MEMORY_EXPORT_FAILED'); + return parsed.data; +} + +/** + * Adds validated records from an export document into `scope`. + * + * Two different granularities of refusal, deliberately: + * + * - The **document** is all-or-nothing. If it is not a `MEMORY_EXPORT_KIND` + * document of this schema version, with a scope matching the target and a + * valid record list, nothing at all is imported. A file that is merely + * valid JSON is refused as "not a memory export" rather than being probed + * field by field for something that happens to fit. + * - Individual **records** may be refused while the rest import, and the + * count is reported. A record is refused if its content looks like a + * credential, or if the scope already holds the same note. Both are + * counted in `rejected`, which means "not imported" rather than naming a + * reason per record — a per-record reason would be one more way for + * imported content to shape what is displayed. + * + * Every imported record is re-stamped: a **fresh id**, so a crafted file + * cannot collide with or address an existing record; `source: 'import'`, so + * the interface can always show that this note came from outside; and + * `updatedAt: now`. `createdAt` is kept from the document, because when a + * note was originally written is the one piece of history worth carrying + * across machines, and it is a validated ISO-8601 string either way. + */ +export async function importMemoryRecords( + access: MemoryStoreAccess, + scope: MemoryScope, + document: unknown, + now: string, + newId: () => string, +): Promise { + const parsedDocument = memoryExportSchema.safeParse(document); + if (!parsedDocument.success) throw new MemoryError('MEMORY_IMPORT_INVALID'); + if (parsedDocument.data.scope !== scope) throw new MemoryError('MEMORY_IMPORT_INVALID'); + + const store = await access.read(scope); + const existing = activeMemories(store.records, now); + const seen = new Set(existing.map((record) => normalizeContent(record.content))); + + const accepted: MemoryRecord[] = []; + let rejected = 0; + + for (const candidate of parsedDocument.data.records) { + if (findLikelySecret(candidate.content) !== null) { + rejected += 1; + continue; + } + + const key = normalizeContent(candidate.content); + if (seen.has(key)) { + rejected += 1; + continue; + } + + const parsed = memoryRecordSchema.safeParse({ + ...candidate, + id: newId(), + scope, + source: 'import', + updatedAt: now, + }); + if (!parsed.success) { + rejected += 1; + continue; + } + + seen.add(key); + accepted.push(parsed.data); + } + + if (existing.length + accepted.length > MEMORY_MAX_RECORDS_PER_SCOPE) { + // Refused outright rather than importing however many happen to fit: a + // partial import that silently dropped the rest would look like success. + throw new MemoryError('MEMORY_LIMIT_REACHED'); + } + + await commit(access, store, [...existing, ...accepted], now); + return { scope, affected: accepted.length, rejected }; +} + +/** The same normalisation `dedupeMemories` uses, applied across documents. */ +function normalizeContent(content: string): string { + return content.toLowerCase().replace(/\s+/g, ' ').trim(); +} diff --git a/src/main/memory-store.ts b/src/main/memory-store.ts new file mode 100644 index 0000000..ec1e7eb --- /dev/null +++ b/src/main/memory-store.ts @@ -0,0 +1,255 @@ +/** + * Memory storage (Phase 2, Milestone 8). + * + * Reads and writes `memory/personal.json` and `memory/projects/.json`, + * and holds the session scope in memory. It mirrors `main/settings.ts`, + * `main/policy.ts` and `main/agent-profiles.ts` deliberately and in detail, + * because a memory file is the same *kind* of thing those are: a + * user-editable document this application must be able to read without + * trusting it. + * + * The properties carried over from those modules: + * + * - **Loading never throws and never merges.** A missing file, an unreadable + * one, an oversized one, malformed JSON, a `__proto__` key anywhere in it, + * a document the schema rejects, or a document declaring a scope other + * than the one that was asked for, all resolve the same way: an empty + * store for that scope. A document either validates in full, as itself, or + * it is discarded in full. There is no partial-trust path. + * - **Writing is atomic and re-validated.** The document is checked against + * the schema immediately before serialising, written to a uniquely named + * temporary file in the same directory, flushed, and moved into place with + * a single rename — so a crash or a concurrent write leaves either the + * previous complete document or the new one, never a partial write. + * + * And three of its own, all of which exist because this file holds the user's + * private notes rather than their configuration: + * + * - **The session scope is never written anywhere.** It lives in a closure + * created per `registerIpcHandlers` call, exactly like the approved-project + * session, and closing the application ends it. There is no file to find + * afterwards and no "session" branch in any function that touches disk. + * - **A project's notes are addressed by a hash of its canonical root path, + * never by the path itself.** A path carries a user name, often a client + * or employer name, and sometimes the existence of a project that is + * itself confidential. A directory listing of `%APPDATA%` should not + * disclose any of that, so the file name is a truncated SHA-256 digest. + * - **Loading one project's store cannot reach another's.** The file name is + * derived, never supplied: no caller passes a path, a key or a project + * name into this module, so there is no parameter through which one + * project's session could name another project's file. + * + * Every function takes a plain file path rather than a `UserDataPaths`, for + * the reason `loadSettings` does: a test points at a temporary directory and + * configures nothing else. + */ + +import { createHash, randomUUID } from 'node:crypto'; +import { mkdir, open, readFile, rename, rm, stat } from 'node:fs/promises'; +import { basename, dirname, join } from 'node:path'; + +import { + FORBIDDEN_OBJECT_KEYS, + MEMORY_PROJECT_KEY_LENGTH, + MEMORY_STORE_MAX_BYTES, +} from '../shared/constants'; +import type { MemoryScope } from '../shared/constants'; +import { createEmptyMemoryStore, memoryStoreSchema } from '../shared/schemas/memory.schema'; +import type { MemoryStore } from '../shared/schemas/memory.schema'; + +/** + * Mirrors `main/policy.ts`'s and `main/agent-profiles.ts`'s own copies + * exactly, including the reasoning: `JSON.parse` does not fall for a literal + * `__proto__` key, but nothing downstream of this loader is allowed to assume + * that. A fourth independent copy rather than a shared import, so this module + * gains no runtime dependency on an already-reviewed module from an earlier + * milestone for one small, self-contained, pure check. + */ +const MAX_MEMORY_JSON_DEPTH = 64; + +function containsForbiddenKey(value: unknown, depth = 0): boolean { + if (depth > MAX_MEMORY_JSON_DEPTH) return true; + if (value === null || typeof value !== 'object') return false; + + if (Array.isArray(value)) { + return value.some((element) => containsForbiddenKey(element, depth + 1)); + } + + const record = value as Record; + for (const key of Object.keys(record)) { + if (FORBIDDEN_OBJECT_KEYS.includes(key)) return true; + if (containsForbiddenKey(record[key], depth + 1)) return true; + } + return false; +} + +/** + * A destination-file rename can transiently fail on Windows when something + * else briefly holds the destination open. The same sharing-violation retry + * `main/settings.ts` performs, and for the same reason: each attempt is one + * whole-file rename, so retrying risks a delayed write, never a partial one. + */ +const RENAME_MAX_ATTEMPTS = 10; +const RENAME_RETRY_DELAY_MS = 20; + +function isTransientRenameError(error: unknown): boolean { + if (typeof error !== 'object' || error === null || !('code' in error)) return false; + const { code } = error; + return code === 'EPERM' || code === 'EBUSY' || code === 'EACCES'; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function renameWithRetry(from: string, to: string): Promise { + for (let attempt = 1; attempt <= RENAME_MAX_ATTEMPTS; attempt += 1) { + try { + await rename(from, to); + return; + } catch (error) { + if (attempt === RENAME_MAX_ATTEMPTS || !isTransientRenameError(error)) throw error; + await delay(RENAME_RETRY_DELAY_MS * attempt); + } + } +} + +/** + * The file-name key for one approved project. + * + * A truncated SHA-256 of the project's **canonical** root path. Canonical + * matters: two spellings of the same directory — a short (8.3) ancestor, a + * different case, a trailing separator — must resolve to the same notes, and + * the caller is responsible for passing the path `adoptProjectDirectory` + * already canonicalised. The path itself is never stored, never logged and + * never part of a record. + * + * Lowercased before hashing because Windows paths are case-insensitive: the + * same project opened as `C:\Work\App` and `C:\work\app` is the same project, + * and two stores for it would silently split someone's notes in half. + */ +export function memoryProjectKey(canonicalRootPath: string): string { + return createHash('sha256') + .update(canonicalRootPath.toLowerCase(), 'utf8') + .digest('hex') + .slice(0, MEMORY_PROJECT_KEY_LENGTH); +} + +/** Where one project's notes live. Derived, never supplied by a caller. */ +export function resolveProjectMemoryFile( + memoryProjectsDir: string, + canonicalRootPath: string, +): string { + return join(memoryProjectsDir, `${memoryProjectKey(canonicalRootPath)}.json`); +} + +/** + * Loads `storeFile`, failing safe on every problem. + * + * Never throws, and never creates a file. A first launch is indistinguishable, + * by design, from a corrupted one: both return an empty store, which loses + * nothing that was readable and is the only answer that cannot be wrong in + * the permissive direction. + * + * The size check happens before the read, not after: a file that grew without + * bound should not be pulled into memory to discover that it is too large. + * + * `expectedScope` is checked against the document's own declared scope, so a + * project file that claims to hold personal records — or a personal file + * moved into the projects directory — is refused rather than being silently + * re-labelled as whatever the caller asked for. + */ +export async function loadMemoryStore( + storeFile: string, + expectedScope: MemoryScope, +): Promise { + try { + const stats = await stat(storeFile); + if (stats.size > MEMORY_STORE_MAX_BYTES) return createEmptyMemoryStore(expectedScope); + } catch { + // Missing file (first launch) and any other stat failure fail the same + // way as an unreadable one. Neither the error nor the path is surfaced. + return createEmptyMemoryStore(expectedScope); + } + + let raw: string; + try { + raw = await readFile(storeFile, 'utf8'); + } catch { + return createEmptyMemoryStore(expectedScope); + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return createEmptyMemoryStore(expectedScope); + } + + if (containsForbiddenKey(parsed)) return createEmptyMemoryStore(expectedScope); + + const result = memoryStoreSchema.safeParse(parsed); + if (!result.success) return createEmptyMemoryStore(expectedScope); + if (result.data.scope !== expectedScope) return createEmptyMemoryStore(expectedScope); + + return result.data; +} + +/** + * Writes `store` to `storeFile` atomically. + * + * Re-validated against the schema immediately before serialising, so a caller + * cannot persist a document that only *claims* the type at compile time — + * which includes the cross-record rule that every record belongs to the + * store's own scope, the one rule that keeps a project's notes from ending up + * in another scope's file. + */ +export async function writeMemoryStore(storeFile: string, store: MemoryStore): Promise { + const validated = memoryStoreSchema.parse(store); + const payload = JSON.stringify(validated, null, 2); + + const dir = dirname(storeFile); + await mkdir(dir, { recursive: true }); + + const tempFile = join(dir, `${basename(storeFile)}.${randomUUID()}.tmp`); + + try { + const handle = await open(tempFile, 'w'); + try { + await handle.writeFile(payload, 'utf8'); + await handle.sync(); + } finally { + await handle.close(); + } + + await renameWithRetry(tempFile, storeFile); + } catch (error) { + await rm(tempFile, { force: true }).catch(() => undefined); + throw error; + } +} + +/** + * The session scope: one store, in memory, for the lifetime of one run. + * + * One per `registerIpcHandlers` call — never a module-level singleton, so one + * test's session memory can never leak into another's, exactly as + * `createWorkspaceSession` is built. + */ +export interface SessionMemoryStore { + read(): MemoryStore; + write(store: MemoryStore): void; +} + +export function createSessionMemoryStore(): SessionMemoryStore { + let store: MemoryStore = createEmptyMemoryStore('session'); + return { + read: () => store, + write: (next) => { + // Validated on the way in for the same reason the file writer validates + // on the way out: nothing persisted or served should rest on a caller's + // compile-time claim about its type. + store = memoryStoreSchema.parse(next); + }, + }; +} diff --git a/src/main/memory-transfer.ts b/src/main/memory-transfer.ts new file mode 100644 index 0000000..8bc5234 --- /dev/null +++ b/src/main/memory-transfer.ts @@ -0,0 +1,113 @@ +/** + * Reading and writing memory files outside the application (P2, M8). + * + * The only module in this milestone that touches a path the application does + * not own. Both directions are deliberately narrow: + * + * - **Neither function chooses a path.** The path always comes from a native + * dialog the main process owns (`main/memory-picker.ts`), so the renderer + * cannot name a file to read or a file to overwrite. A dialog result is + * still *input*, but it is input the user clicked on, which is the same + * consent model `workspace:select` already uses. + * - **An import is untrusted content.** It is size-checked before it is + * read, parsed as JSON with no reviver, screened for a prototype-polluting + * key, and returned as `unknown` — this module never claims it is a memory + * document. `main/memory-service.ts` decides that, against the schema. + * - **An export is written atomically.** A half-written export is worse than + * a failed one: someone who believes their notes are backed up will delete + * the originals. Temporary file, flush, single rename. + * - **No error escapes.** Every failure becomes a `MemoryError` carrying a + * code from the closed vocabulary. A filesystem error message would name + * the path, and a path is user data. + */ + +import { randomUUID } from 'node:crypto'; +import { open, readFile, rename, rm, stat } from 'node:fs/promises'; +import { basename, dirname, join } from 'node:path'; + +import { FORBIDDEN_OBJECT_KEYS, MEMORY_IMPORT_MAX_BYTES } from '../shared/constants'; +import { MemoryError } from '../shared/memory/errors'; +import type { MemoryExport } from '../shared/schemas/memory.schema'; + +const MAX_IMPORT_JSON_DEPTH = 64; + +/** The same guard `main/memory-store.ts` applies, on a far less trusted file. */ +function containsForbiddenKey(value: unknown, depth = 0): boolean { + if (depth > MAX_IMPORT_JSON_DEPTH) return true; + if (value === null || typeof value !== 'object') return false; + + if (Array.isArray(value)) { + return value.some((element) => containsForbiddenKey(element, depth + 1)); + } + + const record = value as Record; + for (const key of Object.keys(record)) { + if (FORBIDDEN_OBJECT_KEYS.includes(key)) return true; + if (containsForbiddenKey(record[key], depth + 1)) return true; + } + return false; +} + +/** + * Reads and parses the file the user chose, without interpreting it. + * + * The size check happens before the read, exactly as it does for this + * application's own stores: a file that is too large should not be pulled + * into the privileged process's memory in order to discover that. + */ +export async function readMemoryImportFile(sourcePath: string): Promise { + let size: number; + try { + size = (await stat(sourcePath)).size; + } catch { + throw new MemoryError('MEMORY_IMPORT_INVALID'); + } + if (size > MEMORY_IMPORT_MAX_BYTES) throw new MemoryError('MEMORY_IMPORT_TOO_LARGE'); + + let raw: string; + try { + raw = await readFile(sourcePath, 'utf8'); + } catch { + throw new MemoryError('MEMORY_IMPORT_INVALID'); + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new MemoryError('MEMORY_IMPORT_INVALID'); + } + + if (containsForbiddenKey(parsed)) throw new MemoryError('MEMORY_IMPORT_INVALID'); + return parsed; +} + +/** + * Writes an export document to the file the user chose. + * + * The temporary file is created beside the destination so the rename stays on + * one volume — a cross-volume rename is a copy, which would defeat the point. + * It is removed on every failure path, so a failed export leaves nothing + * behind in someone's Documents folder. + */ +export async function writeMemoryExportFile( + targetPath: string, + document: MemoryExport, +): Promise { + const payload = JSON.stringify(document, null, 2); + const tempFile = join(dirname(targetPath), `${basename(targetPath)}.${randomUUID()}.tmp`); + + try { + const handle = await open(tempFile, 'w'); + try { + await handle.writeFile(payload, 'utf8'); + await handle.sync(); + } finally { + await handle.close(); + } + await rename(tempFile, targetPath); + } catch { + await rm(tempFile, { force: true }).catch(() => undefined); + throw new MemoryError('MEMORY_EXPORT_FAILED'); + } +} diff --git a/src/main/paths.ts b/src/main/paths.ts index be54561..e9aa248 100644 --- a/src/main/paths.ts +++ b/src/main/paths.ts @@ -28,12 +28,18 @@ export interface UserDataPaths { readonly auditLogDir: string; readonly emergencyStateFile: string; readonly memoryDir: string; + /** Personal memory (Phase 2, Milestone 8). Never holds a credential. */ + readonly memoryPersonalFile: string; + /** One file per approved project, named from a hash of its canonical root. */ + readonly memoryProjectsDir: string; /** Pre-change backups (Phase 2, Milestone 6). Never inside a user project. */ readonly backupsDir: string; /** The empty directory `git` is pointed at instead of a repository's hooks. */ readonly gitHooksDir: string; /** Agent profiles (Phase 2, Milestone 7). Holds no credential. */ readonly agentProfilesFile: string; + /** Workflow definitions (Phase 2, Milestone 9). Holds no credential. */ + readonly workflowsFile: string; } /** @@ -53,8 +59,11 @@ export function resolveUserDataPaths(appDataDir: string): UserDataPaths { auditLogDir: join(userDataDir, USER_DATA_PATHS.auditLogDir), emergencyStateFile: join(userDataDir, USER_DATA_PATHS.emergencyStateFile), memoryDir: join(userDataDir, USER_DATA_PATHS.memoryDir), + memoryPersonalFile: join(userDataDir, USER_DATA_PATHS.memoryPersonalFile), + memoryProjectsDir: join(userDataDir, USER_DATA_PATHS.memoryProjectsDir), backupsDir: join(userDataDir, USER_DATA_PATHS.backupsDir), gitHooksDir: join(userDataDir, USER_DATA_PATHS.gitHooksDir), agentProfilesFile: join(userDataDir, USER_DATA_PATHS.agentProfilesFile), + workflowsFile: join(userDataDir, USER_DATA_PATHS.workflowsFile), }; } diff --git a/src/main/windows-automation.ts b/src/main/windows-automation.ts new file mode 100644 index 0000000..dd0eb54 --- /dev/null +++ b/src/main/windows-automation.ts @@ -0,0 +1,327 @@ +/** + * The Windows automation executor (Phase 2, Milestone 10). + * + * `runAutomationTool` turns one fixed registry entry + * (`shared/automation/registry.ts`) into a real desktop action. It is the + * only function in this codebase that does so, and it can only act on a + * {@link AutomationToolDefinition} — never a path, a URL or a command string + * a caller supplied, because no such field exists on that type. + * + * ## No shell, ever + * + * `launchDetached` calls `spawn` with `shell: false`, exactly as + * `main/process-runner.ts` does. There is no `cmd.exe`, no `powershell.exe` + * and no batch-file indirection anywhere in this module: every `launch-app` + * and `run-script` entry names a literal `.exe` under `%SystemRoot%\System32`, + * resolved by {@link resolveSystem32Executable} and nothing else. + * + * ## Launching is not waiting + * + * Unlike `runProcess`, which runs a build tool to completion and reports its + * exit code, `launchDetached` starts a GUI program the user expects to keep + * running — Notepad staying open is success, not a hang. So it does not wait + * for the child to exit: it waits out a short grace window + * (`AUTOMATION_LAUNCH_TIMEOUT_MS`) to catch an immediate failure (a missing + * executable, an instant crash), then reports success and leaves the process + * running, detached from this application's own lifetime. Cancelling an + * automation run therefore only ever abandons a *launch attempt in + * progress* — it deliberately never kills a program that already started, + * because a user who opened Notepad did not ask Local Agent to be able to + * close it again. + * + * ## Dependency injection at the Electron boundary + * + * `open-folder`, `open-website` and `focus-window` all need a live Electron + * module (`shell`, `app`, the real `BrowserWindow`) that only exists inside a + * running application. Exactly like `main/confirm.ts` and + * `main/directory-picker.ts`, those calls are not made here: `main/index.ts` + * builds the real callbacks once, from the real `electron` module, and passes + * them in as {@link AutomationRunDependencies}. This module never imports + * `electron` itself, which is what lets its own tests run under plain Node. + */ + +import type { ChildProcess } from 'node:child_process'; +import { spawn } from 'node:child_process'; +import { join } from 'node:path'; + +import { AutomationError } from '../shared/automation/errors'; +import type { AutomationToolDefinition } from '../shared/automation/registry'; +import { isSafeAutomationUrl } from '../shared/automation/validation'; +import { + AUTOMATION_LAUNCH_TIMEOUT_MS, + AUTOMATION_MAX_ATTEMPTS, + AUTOMATION_SHELL_TIMEOUT_MS, +} from '../shared/constants'; + +export interface LaunchOptions { + readonly timeoutMs: number; + readonly signal: AbortSignal; +} + +/** `started` is false for both a launch failure and an abandoned attempt. */ +export interface LaunchOutcome { + readonly started: boolean; +} + +export interface AutomationRunDependencies { + /** Starts a detached process. The real implementation is {@link launchDetached}. */ + readonly launchProcess: ( + executablePath: string, + args: readonly string[], + options: LaunchOptions, + ) => Promise; + /** `shell.openPath`. Resolves to `''` on success, or a short OS error string. */ + readonly openPath: (path: string) => Promise; + /** `shell.openExternal`. Rejects on failure. */ + readonly openExternal: (url: string) => Promise; + /** `app.getPath`, narrowed to the three special folders this milestone offers. */ + readonly getSpecialFolder: (name: 'desktop' | 'documents' | 'downloads') => string; + /** Brings the application's own window to the foreground. False if there is none. */ + readonly focusMainWindow: () => boolean; +} + +export interface RunAutomationToolOptions { + readonly tool: AutomationToolDefinition; + /** The approved project's root, or `null` when none is open this session. */ + readonly projectRoot: string | null; + /** `process.env.SystemRoot`, resolved by the caller so this stays testable. */ + readonly systemRoot: string; + readonly signal: AbortSignal; + readonly isEmergencyEngaged: () => Promise; + readonly dependencies: AutomationRunDependencies; +} + +export interface AutomationRunOutcome { + /** How many launch attempts this run made. Always 1 for every kind but `launch-app`/`run-script`. */ + readonly attempts: number; +} + +/** `%SystemRoot%\System32\` — never a path from a request. */ +export function resolveSystem32Executable(executable: string, systemRoot: string): string { + return join(systemRoot, 'System32', executable); +} + +/** + * Starts a detached process and waits out a grace window for an immediate + * failure, without waiting for the process to exit. See the module doc. + * + * Never throws: a process that could not be started is a reported outcome, + * exactly as `runProcess` treats a non-zero exit as a result rather than an + * error. Only a program that fails to *start at all* is `started: false`. + */ +export function launchDetached( + executablePath: string, + args: readonly string[], + options: LaunchOptions, +): Promise { + return new Promise((resolvePromise) => { + let settled = false; + let child: ChildProcess; + + try { + child = spawn(executablePath, [...args], { + detached: true, + stdio: 'ignore', + shell: false, + windowsHide: false, + }); + } catch { + resolvePromise({ started: false }); + return; + } + + function settle(started: boolean): void { + if (settled) return; + settled = true; + clearTimeout(timer); + options.signal.removeEventListener('abort', onAbort); + child.removeAllListeners(); + if (started) child.unref(); + resolvePromise({ started }); + } + + // The grace window elapsing without an error or an immediate exit is + // success: the process is presumed to be running normally, and is left + // to do so. + const timer = setTimeout(() => { + settle(true); + }, options.timeoutMs); + timer.unref(); + + function onAbort(): void { + // The attempt is abandoned; the process, if it already started, is + // deliberately not killed — see the module doc. + settle(false); + } + options.signal.addEventListener('abort', onAbort, { once: true }); + + child.once('error', () => { + settle(false); + }); + + // A program that exits within the grace window with a nonzero code (a + // bad argument, an instant crash) is a launch failure, not a success. + child.once('exit', (code) => { + settle(code === 0 || code === null); + }); + }); +} + +async function precheck( + signal: AbortSignal, + isEmergencyEngaged: () => Promise, +): Promise { + if (signal.aborted) throw new AutomationError('AUTOMATION_CANCELLED'); + if (await isEmergencyEngaged()) throw new AutomationError('AUTOMATION_EMERGENCY_STOPPED'); +} + +/** + * Races `operation` against a timeout and the run's own cancellation signal. + * + * Used only for `open-folder` and `open-website`, which hand off to the OS + * shell and are expected to settle almost immediately — unlike a launched GUI + * application, there is no reason for either to still be pending after + * `AUTOMATION_SHELL_TIMEOUT_MS`. + */ +function withTimeout(operation: Promise, timeoutMs: number, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(new AutomationError('AUTOMATION_CANCELLED')); + + return new Promise((resolvePromise, rejectPromise) => { + let settled = false; + + const timer = setTimeout(() => { + if (settled) return; + settled = true; + rejectPromise(new AutomationError('AUTOMATION_TIMEOUT')); + }, timeoutMs); + timer.unref(); + + function onAbort(): void { + if (settled) return; + settled = true; + clearTimeout(timer); + rejectPromise(new AutomationError('AUTOMATION_CANCELLED')); + } + signal.addEventListener('abort', onAbort, { once: true }); + + operation.then( + (value) => { + if (settled) return; + settled = true; + clearTimeout(timer); + signal.removeEventListener('abort', onAbort); + resolvePromise(value); + }, + (error: unknown) => { + if (settled) return; + settled = true; + clearTimeout(timer); + signal.removeEventListener('abort', onAbort); + rejectPromise( + error instanceof Error ? error : new AutomationError('AUTOMATION_VERIFICATION_FAILED'), + ); + }, + ); + }); +} + +async function runAppOrScript( + tool: Extract, + options: RunAutomationToolOptions, +): Promise { + const executablePath = resolveSystem32Executable(tool.executable, options.systemRoot); + + for (let attempts = 1; attempts <= AUTOMATION_MAX_ATTEMPTS; attempts += 1) { + await precheck(options.signal, options.isEmergencyEngaged); + + const outcome = await options.dependencies.launchProcess(executablePath, tool.args, { + timeoutMs: AUTOMATION_LAUNCH_TIMEOUT_MS, + signal: options.signal, + }); + + if (outcome.started) return { attempts }; + if (options.signal.aborted) throw new AutomationError('AUTOMATION_CANCELLED'); + } + + throw new AutomationError('AUTOMATION_LAUNCH_FAILED'); +} + +async function runOpenFolder( + tool: Extract, + options: RunAutomationToolOptions, +): Promise { + await precheck(options.signal, options.isEmergencyEngaged); + + let target: string; + if (tool.folder === 'project') { + if (options.projectRoot === null) throw new AutomationError('AUTOMATION_NO_PROJECT'); + target = options.projectRoot; + } else { + target = options.dependencies.getSpecialFolder(tool.folder); + } + + const errorText = await withTimeout( + options.dependencies.openPath(target), + AUTOMATION_SHELL_TIMEOUT_MS, + options.signal, + ); + if (errorText.length > 0) throw new AutomationError('AUTOMATION_VERIFICATION_FAILED'); + return { attempts: 1 }; +} + +async function runOpenWebsite( + tool: Extract, + options: RunAutomationToolOptions, +): Promise { + await precheck(options.signal, options.isEmergencyEngaged); + + if (!isSafeAutomationUrl(tool.url)) throw new AutomationError('AUTOMATION_LAUNCH_FAILED'); + + await withTimeout( + options.dependencies.openExternal(tool.url), + AUTOMATION_SHELL_TIMEOUT_MS, + options.signal, + ).catch((error: unknown) => { + if (error instanceof AutomationError) throw error; + throw new AutomationError('AUTOMATION_VERIFICATION_FAILED'); + }); + return { attempts: 1 }; +} + +async function runFocusWindow(options: RunAutomationToolOptions): Promise { + await precheck(options.signal, options.isEmergencyEngaged); + if (!options.dependencies.focusMainWindow()) { + throw new AutomationError('AUTOMATION_VERIFICATION_FAILED'); + } + return { attempts: 1 }; +} + +/** + * Performs exactly one registered automation tool. + * + * Throws {@link AutomationError} for every refusal and every failure — + * `main/ipc.ts` maps its `code` onto the normalized IPC response, exactly as + * it already does for a `WorkflowError` or a `WorkspaceError`. A resolved + * promise means the action reached a verified success state; there is no + * partial or ambiguous outcome. + */ +export async function runAutomationTool( + options: RunAutomationToolOptions, +): Promise { + const { tool } = options; + if (tool.requiresProject && options.projectRoot === null) { + throw new AutomationError('AUTOMATION_NO_PROJECT'); + } + + switch (tool.kind) { + case 'launch-app': + case 'run-script': + return runAppOrScript(tool, options); + case 'open-folder': + return runOpenFolder(tool, options); + case 'open-website': + return runOpenWebsite(tool, options); + case 'focus-window': + return runFocusWindow(options); + } +} diff --git a/src/main/workflow-runner.ts b/src/main/workflow-runner.ts new file mode 100644 index 0000000..2e999c7 --- /dev/null +++ b/src/main/workflow-runner.ts @@ -0,0 +1,381 @@ +/** + * The bounded workflow runner (Phase 2, Milestone 9). + * + * This is the loop that turns one stored definition into a sequence of + * already-existing actions, and it is deliberately small. Every decision it + * makes — what to do next, whether a limit has been reached, whether the + * selected agent permits the step, whether a failure should be retried, + * whether the result counts as verified — is made by the pure functions in + * `src/shared/workflow/execution.ts`, which are testable without a + * filesystem. What lives here is only the part that cannot be pure: reading + * the clock, re-reading state between steps, and calling the injected step + * executor. + * + * ## What this module is not + * + * It is **not** a second permission engine. It never decides that something + * is allowed. `runStep` is supplied by `main/ipc.ts` and is the *same + * executor an agent run uses* — the identical `AgentStepRequest` shape + * dispatched through the identical `runWorkspaceAction` — so each step is + * decided by `main/permissions.ts` on its own action type, confirmed natively + * where the floor requires it, and written to the audit log. There is no + * execution path in this milestone that an agent run did not already have. + * + * It is **not** a model client. No provider is called and no network request + * is made; the step sequence comes from the stored definition. + * `resolvedProvider` is recorded for transparency about what *would* carry a + * model call once one exists. + * + * ## What it re-reads between steps + * + * The emergency stop, the workflow definition and the agent profile, every + * single iteration. Engaging the stop, disabling the workflow or disabling + * its agent therefore takes effect on the next step rather than at the end of + * the run — "stop" has to mean "stop what is happening", not only "start + * nothing new". + */ + +import { + afterStepOutcome, + classifyWorkflowRun, + decideNextWorkflowStep, + evaluateWorkflowVerification, + planWorkflowRollback, + toRollbackOutcome, +} from '../shared/workflow/execution'; +import type { AgentStepOutcome } from './agent-orchestrator'; +import type { AgentToolDefinition } from '../shared/agent/tools'; +import type { AgentProfile } from '../shared/schemas/agent.schema'; +import type { ModelProvider } from '../shared/constants'; +import { workflowRunSchema } from '../shared/schemas/workflow.schema'; +import type { + Workflow, + WorkflowProgressEvent, + WorkflowRun, + WorkflowRunStep, + WorkflowStep, + WorkflowStopReason, +} from '../shared/schemas/workflow.schema'; +import type { AuditOutcome } from '../shared/types'; + +/** One step, as handed to the executor. */ +export interface WorkflowStepRequest { + readonly step: WorkflowStep; + readonly tool: AgentToolDefinition; + /** A checkpoint, or the profile's own `confirm`, asked before the pipeline. */ + readonly requiresConfirmation: boolean; + readonly stepIndex: number; + readonly attempt: number; +} + +export interface WorkflowRunnerOptions { + readonly runId: string; + /** The definition as it was when the run was approved. */ + readonly workflow: Workflow; + readonly profile: AgentProfile; + /** Which provider the selected agent resolved to. Recorded, not called. */ + readonly provider: ModelProvider; + /** The request in the user's own words, for the one tool that needs it. */ + readonly objective: string; + /** UTC ISO-8601 for the start of the run. */ + readonly startedAt: string; + /** UTC ISO-8601, read again when the run ends. */ + readonly nowFn: () => string; + /** Elapsed-time source, injected so a test can drive the clock. */ + readonly monotonicMs: () => number; + /** Cancellation, from the `workflow:cancel` channel. */ + readonly signal: AbortSignal; + /** + * Whether the user has asked the run to stop cleanly. + * + * Distinct from cancellation: a pause stops the run at the **next step + * boundary**, keeping everything already done, while a cancel aborts + * immediately and kills a child process the run had started. + */ + readonly isPaused: () => boolean; + /** Re-read from disk between steps, never cached for the run. */ + readonly isEmergencyEngaged: () => Promise; + /** Whether a project is approved *right now*. */ + readonly hasProject: () => boolean; + /** The definition as it is *now*, or `null` if it has been deleted. */ + readonly refreshWorkflow: () => Promise; + /** The selected profile as it is *now*, or `null` if it is gone. */ + readonly refreshProfile: () => Promise; + readonly runStep: (request: WorkflowStepRequest) => Promise; + /** Advisory progress, pushed to the renderer. Never carries content. */ + readonly onProgress: (event: WorkflowProgressEvent) => void; +} + +/** + * Runs one bounded workflow and returns its record. + * + * Never throws for an ordinary refusal: a denial, a decline, a limit, a pause + * and a cancellation are all outcomes recorded on the returned + * {@link WorkflowRun}, because a run that was stopped on purpose is not an + * error. The result is validated against `workflowRunSchema` before it is + * returned, so a malformed record fails here rather than at the IPC boundary. + */ +export async function runWorkflow(options: WorkflowRunnerOptions): Promise { + const { signal } = options; + + const steps: WorkflowRunStep[] = []; + const startMs = options.monotonicMs(); + + let workflow = options.workflow; + let profile = options.profile; + let stepIndex = 0; + let attempt = 0; + let previousOutcome: AuditOutcome | null = null; + let outputBytes = 0; + let anyStepFailed = false; + // Definite assignment: every path out of the loop below sets this before it + // breaks, and there is no other exit — so an initial value here would be + // dead, and a *wrong* dead default is exactly how a stopped run comes to be + // reported as a completed one. + let stopReason!: WorkflowStopReason; + + const emit = ( + phase: WorkflowProgressEvent['phase'], + detail: Partial = {}, + ): void => { + options.onProgress({ + runId: options.runId, + phase, + stepIndex: null, + attempt: 0, + tool: null, + totalSteps: workflow.steps.length, + completedSteps: steps.length, + outcome: null, + ...detail, + }); + }; + + emit('started'); + + for (;;) { + // Re-read every iteration rather than once. Engaging the emergency stop, + // disabling the workflow, disabling its agent or closing the project all + // take effect on the next step. + const emergencyEngaged = await options.isEmergencyEngaged(); + + const currentWorkflow = await options.refreshWorkflow(); + if (currentWorkflow === null) { + // Deleting a running workflow is refused by the store, so this is the + // backstop for a file edited by hand mid-run. Treated as "disabled" + // because the effect is the same: there is no definition to continue. + stopReason = 'workflow-disabled'; + break; + } + workflow = currentWorkflow; + + const currentProfile = await options.refreshProfile(); + if (currentProfile === null) { + stopReason = 'agent-denied'; + break; + } + profile = currentProfile; + + const decision = decideNextWorkflowStep(workflow, profile, { + stepIndex, + attempt, + completedSteps: steps.length, + previousOutcome, + elapsedMs: Math.max(0, options.monotonicMs() - startMs), + outputBytes, + cancelled: signal.aborted, + paused: options.isPaused(), + emergencyEngaged, + hasProject: options.hasProject(), + }); + + if (decision.kind === 'stop') { + stopReason = decision.reason; + break; + } + + if (decision.kind === 'skip') { + // A skipped step did not run, so it does not become the "previous + // outcome" for the next condition: that always refers to the last step + // that actually executed. + stepIndex += 1; + attempt = 0; + continue; + } + + emit(decision.requiresConfirmation ? 'awaiting-confirmation' : 'step-started', { + stepIndex: decision.stepIndex, + attempt: decision.attempt, + tool: decision.step.tool, + }); + + const stepStartedMs = options.monotonicMs(); + const result = await options.runStep({ + step: decision.step, + tool: decision.tool, + requiresConfirmation: decision.requiresConfirmation, + stepIndex: decision.stepIndex, + attempt: decision.attempt, + }); + const durationMs = Math.max(0, options.monotonicMs() - stepStartedMs); + + steps.push({ + index: steps.length, + stepIndex: decision.stepIndex, + attempt: decision.attempt, + tool: decision.step.tool, + actionType: decision.tool.actionType, + outcome: result.outcome, + summary: result.summary, + ...(result.errorCode === undefined ? {} : { errorCode: result.errorCode }), + durationMs, + outputBytes: result.outputBytes, + }); + outputBytes += result.outputBytes; + if (result.outcome === 'failure') anyStepFailed = true; + + emit('step-finished', { + stepIndex: decision.stepIndex, + attempt: decision.attempt, + tool: decision.step.tool, + outcome: result.outcome, + }); + + const follow = afterStepOutcome(workflow, decision.stepIndex, decision.attempt, result.outcome); + if (follow.kind === 'stop') { + stopReason = follow.reason; + break; + } + if (follow.kind === 'retry') { + attempt = decision.attempt; + continue; + } + + stepIndex = decision.stepIndex + 1; + attempt = 0; + previousOutcome = follow.settledOutcome; + } + + const verification = evaluateWorkflowVerification(workflow, steps); + const classified = classifyWorkflowRun(workflow, stopReason, verification, anyStepFailed); + + /** + * Change sets this run applied, in the order it applied them. + * + * **Always empty in this milestone**, and not because collecting them was + * forgotten: no agent tool can write a file — there is no `workspace.write` + * tool for a workflow step to name — so no step can produce a change set. + * The list exists because the rollback decision is about what *this run* + * did, and never about a change the user made by hand or one from an + * earlier run; a decision function given no such input would be deciding + * about something else. See `docs/phase-2-workflows.md`. + */ + const appliedChangeIds: readonly string[] = []; + const rollbackPlan = planWorkflowRollback(workflow.rollback, classified.status, appliedChangeIds); + const rollback = toRollbackOutcome(workflow.rollback, rollbackPlan, 0); + + emit('finished'); + + return workflowRunSchema.parse({ + runId: options.runId, + workflowId: workflow.id, + workflowName: workflow.name, + agentProfileId: profile.id, + provider: options.provider, + status: classified.status, + stopReason: classified.stopReason, + startedAt: options.startedAt, + finishedAt: options.nowFn(), + steps, + verification, + rollback, + totals: { + steps: steps.length, + outputBytes, + durationMs: Math.max(0, options.monotonicMs() - startMs), + }, + } satisfies WorkflowRun); +} + +/** + * The sentence shown in the native dialog before a workflow run starts. + * + * Built here, in the main process, from the *stored* definition and the + * *stored* profile — never from the request — so a compromised renderer can + * cause the question to be asked but cannot change what it says. It states + * every one of the milestone's "show limits and permissions" items in plain + * text: which agent, which ordered steps, which part of the project, the + * three ceilings, where it will stop to ask again, and what happens if + * something fails. + */ +export function describeWorkflowRun( + workflow: Workflow, + profile: AgentProfile, + tools: readonly AgentToolDefinition[], +): string { + const startsProcess = tools.some((tool) => tool.commandId !== null); + const checkpoints = workflow.steps.filter((step) => step.checkpoint).length; + + const lines = [ + `Run the workflow "${workflow.name}"?`, + '', + `Agent: ${profile.name}`, + `Steps: ${String(workflow.steps.length)} (ceiling ${String(workflow.limits.maxSteps)} including retries)`, + `Time limit: ${String(Math.round(workflow.limits.maxDurationMs / 1000))}s`, + `Output limit: ${String(workflow.limits.maxOutputBytes)} bytes`, + `On failure: ${workflow.failureBehavior === 'stop' ? 'stop the run' : 'continue to the next step'}`, + `Rollback: ${workflow.rollback === 'none' ? 'none' : 'restore changes this run applied'}`, + '', + 'Ordered steps:', + ...workflow.steps.map((step, index) => { + const scope = step.target === '' ? 'the whole approved project' : step.target; + const retries = + step.maxRetries > 0 + ? `, up to ${String(step.maxRetries)} retr${step.maxRetries === 1 ? 'y' : 'ies'}` + : ''; + const checkpoint = step.checkpoint ? ' [asks first]' : ''; + return ` ${String(index + 1)}. ${step.tool} on ${scope}${retries}${checkpoint}`; + }), + '', + 'It may use only these tools:', + ...tools.map((tool) => ` ${tool.label} (${tool.actionType})`), + '', + 'Limited to the parts of the project the agent covers:', + ...profile.approvedWorkspacePaths.map((path) => + path === '' ? ' the whole approved project' : ` ${path}`, + ), + ]; + + if (workflow.successCriteria.verification.length > 0) { + lines.push('', `Success requires: ${workflow.successCriteria.verification.join(', ')}`); + } + + lines.push( + '', + checkpoints > 0 + ? `${String(checkpoints)} step(s) will stop and ask again before running.` + : 'No step is marked as a checkpoint.', + startsProcess + ? 'This workflow can run the project’s own scripts. Each one is confirmed separately before it starts.' + : 'This workflow cannot start a process, change a file, or create a commit.', + ); + + return lines.join('\n'); +} + +/** The sentence shown when a workflow step is a confirmation checkpoint. */ +export function describeWorkflowCheckpoint( + workflow: Workflow, + request: WorkflowStepRequest, +): string { + return [ + `Checkpoint in the workflow "${workflow.name}".`, + '', + `Step: ${String(request.stepIndex + 1)} of ${String(workflow.steps.length)} (attempt ${String(request.attempt)})`, + `Tool: ${request.tool.label}`, + `Action: ${request.tool.actionType}`, + `Scope: ${request.step.target === '' ? 'the whole approved project' : request.step.target}`, + '', + 'Approving this skips nothing else: the permission policy still decides the action afterwards, and a confirmation the policy itself requires is still asked separately.', + ].join('\n'); +} diff --git a/src/main/workflow-store.ts b/src/main/workflow-store.ts new file mode 100644 index 0000000..649a133 --- /dev/null +++ b/src/main/workflow-store.ts @@ -0,0 +1,380 @@ +/** + * Workflow storage (Phase 2, Milestone 9). + * + * Reads and writes `workflows/workflows.json`. It mirrors + * `main/agent-profiles.ts` deliberately and in detail, because a workflow + * file is the same *kind* of thing a profile file is: user-editable + * configuration that shapes what the application will later do. + * + * The properties carried over: + * + * - **Loading never throws and never merges.** A missing file, an unreadable + * one, an oversized one, malformed JSON, a `__proto__` key anywhere in it, + * or a document the schema rejects all resolve the same way: an empty + * store. A document either validates in full, as itself, or it is + * discarded in full. There is no partial-trust path, so a file with nine + * good workflows and one malformed one yields none of them rather than + * nine. + * - **Writing is atomic and re-validated.** The document is checked against + * the schema immediately before serialising, written to a uniquely named + * temporary file in the same directory, flushed, and moved into place with + * a single rename — so a crash or a concurrent write leaves either the + * previous complete document or the new one, never a partial write. + * - **User data is preserved across every operation.** Each mutation reads + * the current store, changes exactly the one workflow it names, and writes + * the whole document back. No operation regenerates the list. + * + * And one rule of its own: + * + * - **A workflow with a run in flight is not editable.** Every mutation is + * given a predicate that says whether the workflow is currently executing, + * and refuses if it is. The predicate is supplied by `main/ipc.ts`, which + * owns the in-flight run map and is the only thing that knows what is + * actually running. + * + * Every function takes a plain file path rather than a `UserDataPaths`, for + * the reason `loadSettings` does: a test points at a temporary directory and + * configures nothing else. + */ + +import { randomUUID } from 'node:crypto'; +import { mkdir, open, readFile, rename, rm, stat } from 'node:fs/promises'; +import { basename, dirname, join } from 'node:path'; + +import { FORBIDDEN_OBJECT_KEYS, WORKFLOW_MAX_WORKFLOWS } from '../shared/constants'; +import { WORKFLOW_STORE_MAX_BYTES } from '../shared/constants'; +import { WorkflowError } from '../shared/workflow/errors'; +import { + createEmptyWorkflowStore, + workflowSchema, + workflowStoreSchema, +} from '../shared/schemas/workflow.schema'; +import type { Workflow, WorkflowInput, WorkflowStore } from '../shared/schemas/workflow.schema'; + +/** + * Mirrors the copies in `main/policy.ts`, `main/agent-profiles.ts` and + * `main/memory-store.ts` exactly, including the reasoning: `JSON.parse` does + * not fall for a literal `__proto__` key, but nothing downstream of this + * loader is allowed to assume that. A fifth independent copy rather than a + * shared import, so this module gains no runtime dependency on an + * already-reviewed module from an earlier milestone for one small, + * self-contained, pure check. + */ +const MAX_WORKFLOW_JSON_DEPTH = 64; + +function containsForbiddenKey(value: unknown, depth = 0): boolean { + if (depth > MAX_WORKFLOW_JSON_DEPTH) return true; + if (value === null || typeof value !== 'object') return false; + + if (Array.isArray(value)) { + return value.some((element) => containsForbiddenKey(element, depth + 1)); + } + + const record = value as Record; + for (const key of Object.keys(record)) { + if (FORBIDDEN_OBJECT_KEYS.includes(key)) return true; + if (containsForbiddenKey(record[key], depth + 1)) return true; + } + return false; +} + +/** + * A destination-file rename can transiently fail on Windows when something + * else briefly holds the destination open. The same sharing-violation retry + * `main/settings.ts` performs, and for the same reason: each attempt is one + * whole-file rename, so retrying risks a delayed write, never a partial one. + */ +const RENAME_MAX_ATTEMPTS = 10; +const RENAME_RETRY_DELAY_MS = 20; + +function isTransientRenameError(error: unknown): boolean { + if (typeof error !== 'object' || error === null || !('code' in error)) return false; + const { code } = error; + return code === 'EPERM' || code === 'EBUSY' || code === 'EACCES'; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function renameWithRetry(from: string, to: string): Promise { + for (let attempt = 1; attempt <= RENAME_MAX_ATTEMPTS; attempt += 1) { + try { + await rename(from, to); + return; + } catch (error) { + if (attempt === RENAME_MAX_ATTEMPTS || !isTransientRenameError(error)) throw error; + await delay(RENAME_RETRY_DELAY_MS * attempt); + } + } +} + +/** + * Loads `storeFile`, failing safe on every problem. + * + * Never throws, and never creates a file. A first launch is indistinguishable, + * by design, from a corrupted one: both return an empty store, which is the + * most restricted state rather than the most convenient one. + * + * The size check happens before the read, not after: a file that grew without + * bound should not be pulled into memory to discover that it is too large. + */ +export async function loadWorkflowStore(storeFile: string): Promise { + try { + const stats = await stat(storeFile); + if (stats.size > WORKFLOW_STORE_MAX_BYTES) return createEmptyWorkflowStore(); + } catch { + // Missing file (first launch) and any other stat failure fail the same + // way as an unreadable one. Neither the error nor the path is surfaced. + return createEmptyWorkflowStore(); + } + + let raw: string; + try { + raw = await readFile(storeFile, 'utf8'); + } catch { + return createEmptyWorkflowStore(); + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return createEmptyWorkflowStore(); + } + + if (containsForbiddenKey(parsed)) return createEmptyWorkflowStore(); + + const result = workflowStoreSchema.safeParse(parsed); + if (!result.success) return createEmptyWorkflowStore(); + + return result.data; +} + +/** + * Writes `store` to `storeFile` atomically. + * + * Re-validated against the schema immediately before serialising, so a caller + * cannot persist a document that only *claims* the type at compile time — + * which matters here because the value being persisted describes what a run + * will later reach for. + */ +export async function writeWorkflowStore(storeFile: string, store: WorkflowStore): Promise { + const validated = workflowStoreSchema.parse(store); + const payload = JSON.stringify(validated, null, 2); + + const dir = dirname(storeFile); + await mkdir(dir, { recursive: true }); + + const tempFile = join(dir, `${basename(storeFile)}.${randomUUID()}.tmp`); + + try { + const handle = await open(tempFile, 'w'); + try { + await handle.writeFile(payload, 'utf8'); + await handle.sync(); + } finally { + await handle.close(); + } + + await renameWithRetry(tempFile, storeFile); + } catch (error) { + await rm(tempFile, { force: true }).catch(() => undefined); + throw error; + } +} + +/** The workflow with that id, or `null`. */ +export function findWorkflow(workflows: readonly Workflow[], id: string): Workflow | null { + return workflows.find((workflow) => workflow.id === id) ?? null; +} + +/** Every stored workflow, in the order they were written. */ +export async function readWorkflows(storeFile: string): Promise { + return (await loadWorkflowStore(storeFile)).workflows; +} + +/** Reads one workflow, or throws the normalized "not found" error. */ +export async function requireWorkflow(storeFile: string, id: string): Promise { + const existing = findWorkflow(await readWorkflows(storeFile), id); + if (existing === null) throw new WorkflowError('WORKFLOW_NOT_FOUND'); + return existing; +} + +/** + * Persists a changed store and answers with the workflows as written. + * + * The list comes from the document that was *written*, not from the caller's + * in-memory idea of it, so the answer reflects what the next load will see. + */ +async function commit(storeFile: string, store: WorkflowStore): Promise { + try { + await writeWorkflowStore(storeFile, store); + } catch { + // The underlying error never crosses a boundary; the caller gets the + // normalized code and the store on disk is unchanged. + throw new WorkflowError('WORKFLOW_STORE_FAILED'); + } + return store.workflows; +} + +/** + * Whether a workflow is executing right now. + * + * Supplied by `main/ipc.ts` from the in-flight run map. Every mutation below + * consults it, which is how "do not delete the active workflow during + * execution" is enforced against what is actually running rather than + * against a flag on disk that a crash could leave stale. + */ +export type IsWorkflowRunning = (workflowId: string) => boolean; + +function requireNotRunning(id: string, isRunning: IsWorkflowRunning): void { + if (isRunning(id)) throw new WorkflowError('WORKFLOW_RUNNING'); +} + +/** + * Adds a workflow. + * + * `createdAt` and `updatedAt` are supplied here, never by the caller — + * `workflowInputSchema` has no field for either. + */ +export async function createWorkflow( + storeFile: string, + input: WorkflowInput, + now: string, +): Promise { + const store = await loadWorkflowStore(storeFile); + + if (findWorkflow(store.workflows, input.id) !== null) { + throw new WorkflowError('WORKFLOW_EXISTS'); + } + if (store.workflows.length >= WORKFLOW_MAX_WORKFLOWS) { + throw new WorkflowError('WORKFLOW_LIMIT_REACHED'); + } + + const workflow = workflowSchema.safeParse({ ...input, createdAt: now, updatedAt: now }); + if (!workflow.success) throw new WorkflowError('WORKFLOW_INVALID'); + + return commit(storeFile, { ...store, workflows: [...store.workflows, workflow.data] }); +} + +/** + * Replaces a workflow in place. + * + * `createdAt` is carried over from the existing definition rather than taken + * from the request, so an update cannot rewrite when a workflow was created. + * The submitted id must match the addressed one — a mismatch is a refusal, + * not a rename. + */ +export async function updateWorkflow( + storeFile: string, + workflowId: string, + input: WorkflowInput, + now: string, + isRunning: IsWorkflowRunning, +): Promise { + requireNotRunning(workflowId, isRunning); + + const store = await loadWorkflowStore(storeFile); + const existing = findWorkflow(store.workflows, workflowId); + if (existing === null) throw new WorkflowError('WORKFLOW_NOT_FOUND'); + if (input.id !== workflowId) throw new WorkflowError('WORKFLOW_INVALID'); + + const workflow = workflowSchema.safeParse({ + ...input, + createdAt: existing.createdAt, + updatedAt: now, + }); + if (!workflow.success) throw new WorkflowError('WORKFLOW_INVALID'); + + return commit(storeFile, { + ...store, + workflows: store.workflows.map((entry) => (entry.id === workflowId ? workflow.data : entry)), + }); +} + +/** + * Copies a workflow under a new id. + * + * The copy is created **disabled**, whatever the original was. A duplicate is + * a starting point someone is about to edit, and a runnable copy of a + * workflow appearing the moment it is duplicated is a surprise in the + * permissive direction. + */ +export async function duplicateWorkflow( + storeFile: string, + workflowId: string, + newId: string, + now: string, +): Promise { + const store = await loadWorkflowStore(storeFile); + const existing = findWorkflow(store.workflows, workflowId); + if (existing === null) throw new WorkflowError('WORKFLOW_NOT_FOUND'); + if (findWorkflow(store.workflows, newId) !== null) throw new WorkflowError('WORKFLOW_EXISTS'); + if (store.workflows.length >= WORKFLOW_MAX_WORKFLOWS) { + throw new WorkflowError('WORKFLOW_LIMIT_REACHED'); + } + + const copy = workflowSchema.safeParse({ + ...existing, + id: newId, + enabled: false, + createdAt: now, + updatedAt: now, + }); + if (!copy.success) throw new WorkflowError('WORKFLOW_INVALID'); + + return commit(storeFile, { ...store, workflows: [...store.workflows, copy.data] }); +} + +/** + * Removes a workflow. + * + * Refused while a run of it is in flight — the milestone's "do not delete the + * active workflow during execution" rule. The run re-reads the definition + * between steps, so deleting one mid-run would leave the runner unable to + * describe what it was doing. + */ +export async function deleteWorkflow( + storeFile: string, + workflowId: string, + isRunning: IsWorkflowRunning, +): Promise { + requireNotRunning(workflowId, isRunning); + + const store = await loadWorkflowStore(storeFile); + if (findWorkflow(store.workflows, workflowId) === null) { + throw new WorkflowError('WORKFLOW_NOT_FOUND'); + } + + return commit(storeFile, { + ...store, + workflows: store.workflows.filter((entry) => entry.id !== workflowId), + }); +} + +/** + * Enables or disables a workflow. + * + * Disabling one **while it runs** is deliberately permitted: the runner + * re-reads the definition between steps, so this is how someone stops a run + * without cancelling it outright, and the run ends with `workflow-disabled`. + * Editing and deleting stay refused, because those change what the run is + * executing rather than only whether it may continue. + */ +export async function setWorkflowEnabled( + storeFile: string, + workflowId: string, + enabled: boolean, + now: string, +): Promise { + const store = await loadWorkflowStore(storeFile); + const existing = findWorkflow(store.workflows, workflowId); + if (existing === null) throw new WorkflowError('WORKFLOW_NOT_FOUND'); + + const updated: Workflow = { ...existing, enabled, updatedAt: now }; + return commit(storeFile, { + ...store, + workflows: store.workflows.map((entry) => (entry.id === workflowId ? updated : entry)), + }); +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 98b50c1..4099d69 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -67,6 +67,63 @@ import { IPC_AGENT_SELECT_CHANNEL, IPC_AGENT_SET_ENABLED_CHANNEL, IPC_AGENT_UPDATE_CHANNEL, + IPC_MEMORY_ADD_CHANNEL, + IPC_MEMORY_CLEAR_CHANNEL, + IPC_MEMORY_DELETE_CHANNEL, + IPC_MEMORY_EXPORT_CHANNEL, + IPC_MEMORY_IMPORT_CHANNEL, + IPC_MEMORY_LIST_CHANNEL, + IPC_MEMORY_RETRIEVE_CHANNEL, + IPC_MEMORY_SEARCH_CHANNEL, + IPC_MEMORY_SET_PINNED_CHANNEL, + IPC_MEMORY_UPDATE_CHANNEL, + memoryAddResponseSchema, + memoryClearResponseSchema, + memoryDeleteResponseSchema, + memoryExportResponseSchema, + memoryImportResponseSchema, + memoryListResponseSchema, + memoryRetrieveResponseSchema, + memorySearchResponseSchema, + memorySetPinnedResponseSchema, + memoryUpdateResponseSchema, + type MemoryMutationResponse, + type MemoryQueryResponse, + type MemoryRecordInput, + type MemoryRecordResponse, + type MemoryRetrieveResponse, + type MemoryScopeValue, + IPC_WORKFLOW_CANCEL_CHANNEL, + IPC_WORKFLOW_CREATE_CHANNEL, + IPC_WORKFLOW_DELETE_CHANNEL, + IPC_WORKFLOW_DUPLICATE_CHANNEL, + IPC_WORKFLOW_LIST_CHANNEL, + IPC_WORKFLOW_PAUSE_CHANNEL, + IPC_WORKFLOW_PROGRESS_CHANNEL, + IPC_WORKFLOW_RUN_CHANNEL, + IPC_WORKFLOW_SET_ENABLED_CHANNEL, + IPC_WORKFLOW_UPDATE_CHANNEL, + workflowControlResponseSchema, + workflowCreateResponseSchema, + workflowDeleteResponseSchema, + workflowDuplicateResponseSchema, + workflowListResponseSchema, + workflowProgressIpcEventSchema, + workflowRunResponseSchema, + workflowSetEnabledResponseSchema, + workflowUpdateResponseSchema, + type WorkflowInput, + type WorkflowListResponse, + type WorkflowProgressEvent, + type WorkflowRunResponse, + IPC_AUTOMATION_CANCEL_CHANNEL, + IPC_AUTOMATION_LIST_CHANNEL, + IPC_AUTOMATION_RUN_CHANNEL, + automationCancelResponseSchema, + automationListResponseSchema, + automationRunResponseSchema, + type AutomationListResponse, + type AutomationRunResponse, type AgentProfileInput, type AgentRegistryResponse, type AgentRunResponse, @@ -385,6 +442,198 @@ const bridge = { agentCancelResponseSchema.parse(result); }, }, + /** + * Local memory (Phase 2, Milestone 8). + * + * Ten functions, ten fixed channels, and three properties worth checking + * first: + * + * - **Nothing here can name a file.** `exportScope` and `importScope` take + * a scope and nothing else; the file is chosen by the user in a native + * dialog the main process owns. There is no path parameter anywhere in + * this object. + * - **Nothing here can label a record's provenance.** The add and update + * payloads have no `source` field — the main process stamps `user` or + * `import`, so a compromised renderer cannot pass imported content off + * as something the person typed. + * - **Nothing here can move a record between scopes.** An update addresses + * a record inside the scope its own record names, and a stored record + * found elsewhere is refused rather than relocated. + */ + memory: { + list: async (scope: MemoryScopeValue): Promise => { + const result: unknown = await ipcRenderer.invoke(IPC_MEMORY_LIST_CHANNEL, { scope }); + return memoryListResponseSchema.parse(result); + }, + search: async (scope: MemoryScopeValue, query: string): Promise => { + const result: unknown = await ipcRenderer.invoke(IPC_MEMORY_SEARCH_CHANNEL, { + scope, + query, + }); + return memorySearchResponseSchema.parse(result); + }, + retrieve: async (objective: string): Promise => { + const result: unknown = await ipcRenderer.invoke(IPC_MEMORY_RETRIEVE_CHANNEL, { objective }); + return memoryRetrieveResponseSchema.parse(result); + }, + add: async (record: MemoryRecordInput): Promise => { + const result: unknown = await ipcRenderer.invoke(IPC_MEMORY_ADD_CHANNEL, { record }); + return memoryAddResponseSchema.parse(result); + }, + update: async (id: string, record: MemoryRecordInput): Promise => { + const result: unknown = await ipcRenderer.invoke(IPC_MEMORY_UPDATE_CHANNEL, { id, record }); + return memoryUpdateResponseSchema.parse(result); + }, + setPinned: async ( + id: string, + scope: MemoryScopeValue, + pinned: boolean, + ): Promise => { + const result: unknown = await ipcRenderer.invoke(IPC_MEMORY_SET_PINNED_CHANNEL, { + id, + scope, + pinned, + }); + return memorySetPinnedResponseSchema.parse(result); + }, + remove: async (id: string, scope: MemoryScopeValue): Promise => { + const result: unknown = await ipcRenderer.invoke(IPC_MEMORY_DELETE_CHANNEL, { id, scope }); + return memoryDeleteResponseSchema.parse(result); + }, + clear: async (scope: MemoryScopeValue): Promise => { + const result: unknown = await ipcRenderer.invoke(IPC_MEMORY_CLEAR_CHANNEL, { scope }); + return memoryClearResponseSchema.parse(result); + }, + exportScope: async (scope: MemoryScopeValue): Promise => { + const result: unknown = await ipcRenderer.invoke(IPC_MEMORY_EXPORT_CHANNEL, { scope }); + return memoryExportResponseSchema.parse(result); + }, + importScope: async (scope: MemoryScopeValue): Promise => { + const result: unknown = await ipcRenderer.invoke(IPC_MEMORY_IMPORT_CHANNEL, { scope }); + return memoryImportResponseSchema.parse(result); + }, + }, + /** + * Workflows (Phase 2, Milestone 9). + * + * Nine functions, nine fixed channels, one subscription, and three + * properties worth checking first: + * + * - **Nothing here can schedule anything.** A workflow's `trigger` is an + * enum with one member, `manual`, so a payload cannot express a + * schedule, a file watch, a Git hook or an inbox. There is no `schedule` + * function and no `watch` function. + * - **Nothing here can widen an agent.** A workflow selects a profile by + * id and may only use tools and paths that profile already allows, + * checked in the main process when it is saved and again before every + * step. + * - **`run` takes a workflow id and an objective.** It cannot carry a step + * list, a tool, a path, a command or a limit: what a run may do comes + * from the stored definition the main process reads. + * + * `pause` stops a run at the next step boundary; `cancel` aborts it and + * kills a child process it had started. Both are fire-and-forget, exactly + * like `agent.cancel`. + */ + workflow: { + list: async (): Promise => { + const result: unknown = await ipcRenderer.invoke(IPC_WORKFLOW_LIST_CHANNEL); + return workflowListResponseSchema.parse(result); + }, + create: async (workflow: WorkflowInput): Promise => { + const result: unknown = await ipcRenderer.invoke(IPC_WORKFLOW_CREATE_CHANNEL, { workflow }); + return workflowCreateResponseSchema.parse(result); + }, + update: async (workflowId: string, workflow: WorkflowInput): Promise => { + const result: unknown = await ipcRenderer.invoke(IPC_WORKFLOW_UPDATE_CHANNEL, { + workflowId, + workflow, + }); + return workflowUpdateResponseSchema.parse(result); + }, + duplicate: async (workflowId: string, newId: string): Promise => { + const result: unknown = await ipcRenderer.invoke(IPC_WORKFLOW_DUPLICATE_CHANNEL, { + workflowId, + newId, + }); + return workflowDuplicateResponseSchema.parse(result); + }, + remove: async (workflowId: string): Promise => { + const result: unknown = await ipcRenderer.invoke(IPC_WORKFLOW_DELETE_CHANNEL, { workflowId }); + return workflowDeleteResponseSchema.parse(result); + }, + setEnabled: async (workflowId: string, enabled: boolean): Promise => { + const result: unknown = await ipcRenderer.invoke(IPC_WORKFLOW_SET_ENABLED_CHANNEL, { + workflowId, + enabled, + }); + return workflowSetEnabledResponseSchema.parse(result); + }, + run: async ( + runId: string, + workflowId: string, + objective: string, + ): Promise => { + const result: unknown = await ipcRenderer.invoke(IPC_WORKFLOW_RUN_CHANNEL, { + runId, + workflowId, + objective, + }); + return workflowRunResponseSchema.parse(result); + }, + pause: async (runId: string): Promise => { + const result: unknown = await ipcRenderer.invoke(IPC_WORKFLOW_PAUSE_CHANNEL, { runId }); + workflowControlResponseSchema.parse(result); + }, + cancel: async (runId: string): Promise => { + const result: unknown = await ipcRenderer.invoke(IPC_WORKFLOW_CANCEL_CHANNEL, { runId }); + workflowControlResponseSchema.parse(result); + }, + /** + * Subscribes to advisory progress for a run in flight. + * + * Modelled exactly on `chat.onChunk`: the listener never receives the raw + * Electron event, every payload is re-validated here before it reaches + * the renderer, and an invalid one is dropped rather than forwarded. + */ + onProgress: (listener: (event: WorkflowProgressEvent) => void): (() => void) => { + const subscription = (_event: unknown, payload: unknown): void => { + const parsed = workflowProgressIpcEventSchema.safeParse(payload); + if (!parsed.success) return; + listener(parsed.data); + }; + ipcRenderer.on(IPC_WORKFLOW_PROGRESS_CHANNEL, subscription); + return () => { + ipcRenderer.removeListener(IPC_WORKFLOW_PROGRESS_CHANNEL, subscription); + }; + }, + }, + /** + * Windows automation (Phase 2, Milestone 10). + * + * Three functions, three fixed channels. `run` takes a tool id from the + * fixed registry and nothing else — no path, no URL, no argument, no + * command — so a compromised renderer can only ask for one of a closed set + * of registered actions, never name a target of its own. `cancel` is + * fire-and-forget, exactly like `workflow.cancel`. + */ + automation: { + list: async (): Promise => { + const result: unknown = await ipcRenderer.invoke(IPC_AUTOMATION_LIST_CHANNEL); + return automationListResponseSchema.parse(result); + }, + run: async (runId: string, toolId: string): Promise => { + const result: unknown = await ipcRenderer.invoke(IPC_AUTOMATION_RUN_CHANNEL, { + runId, + toolId, + }); + return automationRunResponseSchema.parse(result); + }, + cancel: async (runId: string): Promise => { + const result: unknown = await ipcRenderer.invoke(IPC_AUTOMATION_CANCEL_CHANNEL, { runId }); + automationCancelResponseSchema.parse(result); + }, + }, git: { status: async (): Promise => { const result: unknown = await ipcRenderer.invoke(IPC_GIT_STATUS_CHANNEL); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 5b1020d..10b3c68 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,15 +1,18 @@ import { useEffect, useState } from 'react'; import { Agents } from './agent/Agents'; +import { Automation } from './automation/Automation'; import { Chat } from './chat/Chat'; +import { Memory } from './memory/Memory'; import { Onboarding } from './Onboarding'; +import { Workflows } from './workflow/Workflows'; import { Workspace } from './workspace/Workspace'; import { createDefaultSettings } from '../shared/schemas'; import type { Settings } from '../shared/schemas'; type Phase = 'loading' | 'onboarding' | 'ready'; -/** Which surface the shell is showing (Phase 2, Milestones 5 and 7). */ -type View = 'chat' | 'workspace' | 'agents'; +/** Which surface the shell is showing (Phase 2, Milestones 5, 7, 8, 9 and 10). */ +type View = 'chat' | 'workspace' | 'agents' | 'memory' | 'workflows' | 'automation'; /** * Used only when `settings.get` itself did not succeed (denied, or the @@ -115,6 +118,33 @@ export function App() { > Agents + + + {view === 'chat' && } @@ -122,6 +152,9 @@ export function App() { )} {view === 'agents' && } + {view === 'memory' && } + {view === 'workflows' && } + {view === 'automation' && } ); } diff --git a/src/renderer/automation/Automation.tsx b/src/renderer/automation/Automation.tsx new file mode 100644 index 0000000..21351c9 --- /dev/null +++ b/src/renderer/automation/Automation.tsx @@ -0,0 +1,153 @@ +/** + * The Windows automation surface (Phase 2, Milestone 10). + * + * A sibling next to Chat, Workspace, Agents, Memory and Workflows, not a + * redesign: it reuses the existing panel, badge, hint and error classes + * rather than introducing a visual language of its own. + * + * What it is for, in the milestone's own terms: listing the fixed tool + * registry, running one manually, showing the awaiting-confirmation and + * in-progress state, showing success, failure and cancellation plainly, and + * showing the limits every action runs under. There is no form here that + * names a path, a URL or a command — every action is one button per + * registered tool. + */ + +import { AUTOMATION_LAUNCH_TIMEOUT_MS, AUTOMATION_SHELL_TIMEOUT_MS } from '../../shared/constants'; +import type { AutomationTool } from '../../shared/schemas'; +import { useAutomation } from './useAutomation'; + +const KIND_LABELS: Readonly> = { + 'launch-app': 'Application', + 'open-folder': 'Folder', + 'open-website': 'Website', + 'focus-window': 'Window', + 'run-script': 'Script', +}; + +function ToolCard(props: { + readonly tool: AutomationTool; + readonly running: boolean; + readonly canAct: boolean; + readonly onRun: () => void; +}) { + const { tool, running, canAct } = props; + return ( +
+
+

{tool.label}

+ {KIND_LABELS[tool.kind]} + {tool.requiresProject && Needs a project} + {running && Running} +
+

{tool.description}

+ +
+ ); +} + +export function Automation() { + const automation = useAutomation(); + const { state } = automation; + const canAct = automation.canAct && !state.remoteBusy; + + return ( +
+
+
+

Automation

+

+ Every action below is one of a fixed, registered set — launching an approved + application, opening an approved folder or website, focusing this window, or running a + registered script. Nothing here can run an arbitrary command or reach an arbitrary path. + Each one asks for confirmation first and is recorded in the audit log. +

+

+ A launch is given up to {Math.round(AUTOMATION_LAUNCH_TIMEOUT_MS / 1000)}s to prove it + did not fail immediately; opening a folder or a website is given up to{' '} + {Math.round(AUTOMATION_SHELL_TIMEOUT_MS / 1000)}s. Only one action may run at a time. +

+
+
+

+ {state.busy === null ? 'Idle' : `Working: ${state.busy}`} + {state.remoteBusy && state.busy === null ? ' — an action is already running' : ''} +

+ {state.activity !== null &&

{state.activity}

} +
+
+ + {state.error !== null && ( +
+

{state.error.message}

+ + {state.error.retryable && ( + + )} + + +
+ )} + + {!state.initialized &&

Loading the tool registry…

} + +
+
+
+

Registered tools

+
+ + {state.tools.map((tool) => ( + void automation.runTool(tool.id)} + /> + ))} + + {state.initialized && state.tools.length === 0 && ( +

No automation tools are registered.

+ )} +
+ +
+

Last result

+ {state.runningToolId !== null && ( +
+

Waiting for confirmation, or running…

+ +
+ )} + {state.lastRun === null ? ( +

No action has run yet.

+ ) : ( +
+

+ {state.lastRun.toolId} — {state.lastRun.outcome} +

+

+ {state.lastRun.attempts} attempt(s), {Math.round(state.lastRun.durationMs / 1000)}s. + {state.lastRun.timedOut && ' Timed out.'} + {state.lastRun.cancelled && ' Cancelled.'} + {state.lastRun.stoppedByEmergency && ' Stopped by the emergency stop.'} +

+

+ {state.lastRun.verified ? 'Verified.' : 'Not verified.'} +

+
+ )} +
+
+
+ ); +} diff --git a/src/renderer/automation/automation-controller.ts b/src/renderer/automation/automation-controller.ts new file mode 100644 index 0000000..579f55c --- /dev/null +++ b/src/renderer/automation/automation-controller.ts @@ -0,0 +1,278 @@ +/** + * Automation panel state management (Phase 2, Milestone 10). + * + * Framework-independent, exactly like the workflow, chat, workspace, agent + * and memory controllers, and for the same reason (`docs/security-model.md`, + * known limitation 18: no React Testing Library in this toolchain) — every + * behaviour worth testing lives here, where plain Vitest can drive it. + * + * The same two guarantees the workflow controller holds: + * + * - **One operation at a time, and the last one wins.** Every request takes + * a sequence number; a response whose number is no longer current is + * discarded rather than applied over newer state. + * - **No message from the main process is ever displayed.** Failures arrive + * as a bounded `AutomationErrorCode`, and the sentence the user reads + * comes from {@link FAILURE_MESSAGES} below — this file's own reviewed + * strings. + * + * It holds **no authority of its own.** Nothing here decides which tool may + * run: the fixed registry does, the permission engine decides the one action + * type every tool routes through, and the native dialog the main process owns + * is what a person actually approves. + * + * Never imports Electron, never touches `window.localAgent` (that is + * `ipc-automation-client.ts`'s single job), and never reaches the filesystem + * or the network. + */ + +import type { AutomationClient, AutomationFailure } from './ipc-automation-client'; +import type { AutomationErrorCode } from '../../shared/automation'; +import type { AutomationRunResult, AutomationTool } from '../../shared/schemas'; + +export type AutomationOperation = 'list' | 'run'; + +export interface AutomationUiError { + readonly message: string; + readonly retryable: boolean; +} + +export interface AutomationState { + readonly tools: readonly AutomationTool[]; + /** True once the first `list` call has answered, whatever it said. */ + readonly initialized: boolean; + /** True when the main process reports an action already running. */ + readonly remoteBusy: boolean; + readonly busy: AutomationOperation | null; + /** The id of the tool currently running, so Cancel has an address. */ + readonly runningToolId: string | null; + readonly runningRunId: string | null; + /** The most recent run's record, or `null`. */ + readonly lastRun: AutomationRunResult | null; + readonly activity: string | null; + readonly error: AutomationUiError | null; +} + +export type AutomationListener = (state: AutomationState) => void; + +/** + * One user-facing sentence per normalized failure code. + * + * Written here, in the renderer, deliberately: the main process's own + * messages never cross the IPC boundary, so these are the only automation + * error strings a person ever sees. + */ +const FAILURE_MESSAGES: Readonly> = { + AUTOMATION_TOOL_NOT_FOUND: 'That tool is not registered.', + AUTOMATION_NO_PROJECT: 'This action needs an approved project. Open one in the Workspace first.', + AUTOMATION_ALREADY_RUNNING: 'Another automation action is already running.', + AUTOMATION_LAUNCH_FAILED: 'The action could not be started.', + AUTOMATION_VERIFICATION_FAILED: 'The action ran, but Local Agent could not confirm it succeeded.', + AUTOMATION_TIMEOUT: 'The action did not finish in time and was stopped.', + AUTOMATION_CANCELLED: 'The action was cancelled.', + AUTOMATION_EMERGENCY_STOPPED: 'The emergency stop was engaged, so the action stopped.', + AUTOMATION_RUN_FAILED: 'The action did not complete.', +}; + +const DENIED_MESSAGE = + 'This action was refused. The emergency stop may be engaged, or the permission policy does not allow it.'; + +const DECLINED_MESSAGE = 'You declined the confirmation, so nothing was done.'; + +/** Codes that mean "trying again might help" rather than "the answer will be the same next time". */ +const RETRYABLE_CODES: readonly AutomationErrorCode[] = [ + 'AUTOMATION_LAUNCH_FAILED', + 'AUTOMATION_VERIFICATION_FAILED', + 'AUTOMATION_TIMEOUT', + 'AUTOMATION_ALREADY_RUNNING', + 'AUTOMATION_RUN_FAILED', +]; + +const INITIAL_STATE: AutomationState = { + tools: [], + initialized: false, + remoteBusy: false, + busy: null, + runningToolId: null, + runningRunId: null, + lastRun: null, + activity: null, + error: null, +}; + +export interface AutomationControllerDeps { + readonly client: AutomationClient; + /** Injectable for deterministic tests. Defaults to `crypto.randomUUID()`. */ + readonly newRunId?: () => string; +} + +export class AutomationController { + private readonly client: AutomationClient; + private readonly newRunId: () => string; + private readonly listeners = new Set(); + + private state: AutomationState = INITIAL_STATE; + + /** Incremented for every request. A stale response is discarded. */ + private sequence = 0; + private disposed = false; + + /** The last operation, so Retry has something to repeat. */ + private lastAttempt: (() => Promise) | null = null; + + constructor(deps: AutomationControllerDeps) { + this.client = deps.client; + this.newRunId = deps.newRunId ?? (() => crypto.randomUUID()); + } + + getState(): AutomationState { + return this.state; + } + + subscribe(listener: AutomationListener): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + dispose(): void { + this.disposed = true; + this.listeners.clear(); + } + + async initialize(): Promise { + if (this.state.initialized) return; + await this.refresh(); + } + + async refresh(): Promise { + if (this.state.busy !== null) return; + + this.lastAttempt = async (): Promise => { + await this.refresh(); + }; + + const sequence = this.beginOperation('list'); + const result = await this.client.list(); + if (this.isStale(sequence)) return; + + if (result.ok) { + this.patch({ + busy: null, + initialized: true, + tools: result.value.tools, + remoteBusy: result.value.busy, + error: null, + }); + return; + } + + this.patch({ busy: null, initialized: true, ...this.failurePatch(result.failure) }); + } + + /** + * Runs one registered tool. + * + * `toolId` is the only thing sent. What the tool actually does comes from + * the fixed registry the main process reads — this controller cannot name a + * path, a URL or a command, and has no field through which it could try. + */ + async runTool(toolId: string): Promise { + if (this.state.busy !== null) return; + + const runId = this.newRunId(); + this.lastAttempt = async (): Promise => { + await this.runTool(toolId); + }; + + const sequence = this.beginOperation('run'); + this.patch({ runningToolId: toolId, runningRunId: runId, lastRun: null }); + + const result = await this.client.run(runId, toolId); + if (this.isStale(sequence)) return; + + if (result.ok) { + this.patch({ + busy: null, + runningToolId: null, + runningRunId: null, + lastRun: result.value, + activity: describeRun(result.value), + error: null, + }); + return; + } + + this.patch({ + busy: null, + runningToolId: null, + runningRunId: null, + ...this.failurePatch(result.failure), + }); + } + + /** Best effort: abandons a launch attempt in progress. Never kills an already-started application. */ + async cancelRun(): Promise { + const runId = this.state.runningRunId; + if (runId === null) return; + await this.client.cancel(runId); + if (this.disposed) return; + this.patch({ activity: 'Asked the action to stop.' }); + } + + async retry(): Promise { + const attempt = this.lastAttempt; + if (attempt === null) return; + await attempt(); + } + + dismissError(): void { + this.patch({ error: null }); + } + + // ------------------------------------------------------------------------- + + private failurePatch(failure: AutomationFailure): Partial { + if (failure.kind === 'denied') { + return { error: { message: DENIED_MESSAGE, retryable: false }, activity: null }; + } + if (failure.kind === 'declined') { + return { activity: DECLINED_MESSAGE, error: null }; + } + return { + error: { + message: FAILURE_MESSAGES[failure.code], + retryable: RETRYABLE_CODES.includes(failure.code), + }, + activity: null, + }; + } + + private beginOperation(operation: AutomationOperation): number { + this.sequence += 1; + this.patch({ busy: operation, error: null }); + return this.sequence; + } + + private isStale(sequence: number): boolean { + return this.disposed || sequence !== this.sequence; + } + + private patch(changes: Partial): void { + this.state = { ...this.state, ...changes }; + for (const listener of this.listeners) listener(this.state); + } +} + +/** + * One plain sentence describing how a run ended. + * + * Built from the record's own bounded enums and counts, never from any raw + * text, so this cannot become a way for main-process wording to reach the + * screen indirectly. + */ +export function describeRun(run: AutomationRunResult): string { + const attempts = run.attempts > 1 ? ` after ${String(run.attempts)} attempts` : ''; + return `${run.outcome}${attempts}.`; +} diff --git a/src/renderer/automation/ipc-automation-client.ts b/src/renderer/automation/ipc-automation-client.ts new file mode 100644 index 0000000..86d2b7b --- /dev/null +++ b/src/renderer/automation/ipc-automation-client.ts @@ -0,0 +1,81 @@ +/** + * The renderer's one seam to Windows automation (Phase 2, Milestone 10). + * + * This is the only file under `src/renderer/automation` permitted to + * reference `window.localAgent` — `tests/unit/shared/chat-boundary-scan.test.ts` + * asserts that no other file in this directory does, exactly as it already + * does for the chat, workspace, agent, memory and workflow seams. Every other + * automation file in the renderer talks to this module, never to the bridge. + * + * **Nothing that crossed the boundary is trusted as text.** A failure yields + * only the bounded `AutomationErrorCode` enum; the sentence a user reads is + * always one of `automation-controller.ts`'s own reviewed strings, never + * anything reconstructed from a main-process message. + */ + +import type { AutomationErrorCode } from '../../shared/automation'; +import { isAutomationErrorCode } from '../../shared/automation'; +import type { AutomationCatalog, AutomationRunResult } from '../../shared/schemas'; + +export type AutomationFailure = + | { readonly kind: 'denied' } + | { readonly kind: 'declined' } + | { readonly kind: 'error'; readonly code: AutomationErrorCode }; + +export type AutomationResult = + | { readonly ok: true; readonly value: TValue } + | { readonly ok: false; readonly failure: AutomationFailure }; + +interface OutcomeCarrier { + readonly outcome: string; + readonly errorCode?: string | undefined; +} + +function failureFrom(response: OutcomeCarrier): AutomationFailure { + if (response.outcome === 'denied') return { kind: 'denied' }; + // `aborted` is what `execute` returns for a rejected confirmation. + if (response.outcome === 'aborted') return { kind: 'declined' }; + const code = response.errorCode; + return { + kind: 'error', + code: code !== undefined && isAutomationErrorCode(code) ? code : 'AUTOMATION_RUN_FAILED', + }; +} + +function unwrap( + response: OutcomeCarrier, + value: TValue | undefined, +): AutomationResult { + if (response.outcome === 'success' && value !== undefined) return { ok: true, value }; + return { ok: false, failure: failureFrom(response) }; +} + +/** + * The typed operations `AutomationController` depends on. + * + * An interface rather than a direct import so the controller can be tested + * against a fake with no `window` at all — the same injection every other + * renderer controller in this codebase uses. + */ +export interface AutomationClient { + list(): Promise>; + /** Runs one registered tool. Carries an id, never a path, URL or argument. */ + run(runId: string, toolId: string): Promise>; + cancel(runId: string): Promise; +} + +export function createIpcAutomationClient(): AutomationClient { + return { + async list() { + const response = await window.localAgent.automation.list(); + return unwrap(response, response.catalog); + }, + async run(runId, toolId) { + const response = await window.localAgent.automation.run(runId, toolId); + return unwrap(response, response.run); + }, + async cancel(runId) { + await window.localAgent.automation.cancel(runId); + }, + }; +} diff --git a/src/renderer/automation/useAutomation.ts b/src/renderer/automation/useAutomation.ts new file mode 100644 index 0000000..96fd176 --- /dev/null +++ b/src/renderer/automation/useAutomation.ts @@ -0,0 +1,57 @@ +/** + * Thin React binding over `AutomationController` (Phase 2, Milestone 10). + * + * Deliberately minimal, exactly like `workflow/useWorkflow.ts`: this hook + * owns no automation logic of its own. Everything observable here is + * delegated to the controller, which is unit-tested directly in + * `tests/unit/renderer/automation-controller.test.ts` without React, jsdom, + * or a filesystem. + */ + +import { useEffect, useRef, useState } from 'react'; + +import { AutomationController, type AutomationState } from './automation-controller'; +import { createIpcAutomationClient, type AutomationClient } from './ipc-automation-client'; + +export interface UseAutomationResult { + readonly state: AutomationState; + /** True while no request is in flight. */ + readonly canAct: boolean; + readonly refresh: () => Promise; + readonly runTool: (toolId: string) => Promise; + readonly cancelRun: () => Promise; + readonly retry: () => Promise; + readonly dismissError: () => void; +} + +export function useAutomation(client?: AutomationClient): UseAutomationResult { + const controllerRef = useRef(null); + controllerRef.current ??= new AutomationController({ + client: client ?? createIpcAutomationClient(), + }); + const controller = controllerRef.current; + + const [state, setState] = useState(() => controller.getState()); + + useEffect(() => { + setState(controller.getState()); + const unsubscribe = controller.subscribe(setState); + void controller.initialize(); + return () => { + unsubscribe(); + controller.dispose(); + }; + }, [controller]); + + return { + state, + canAct: state.busy === null, + refresh: () => controller.refresh(), + runTool: (toolId: string) => controller.runTool(toolId), + cancelRun: () => controller.cancelRun(), + retry: () => controller.retry(), + dismissError: () => { + controller.dismissError(); + }, + }; +} diff --git a/src/renderer/local-agent-bridge.d.ts b/src/renderer/local-agent-bridge.d.ts index 6da3e7d..4448c21 100644 --- a/src/renderer/local-agent-bridge.d.ts +++ b/src/renderer/local-agent-bridge.d.ts @@ -12,6 +12,8 @@ import type { AgentProfileInput, AgentRegistryResponse, AgentRunResponse, + AutomationListResponse, + AutomationRunResponse, ChatChunkEvent, ChatMessage, ChatSendResponse, @@ -22,6 +24,12 @@ import type { GitDiffResponse, GitStatusResponse, HealthCheckResponse, + MemoryMutationResponse, + MemoryQueryResponse, + MemoryRecordInput, + MemoryRecordResponse, + MemoryRetrieveResponse, + MemoryScopeValue, SecretsActionResponse, SettingsActionResponse, SettingsUpdateInput, @@ -31,6 +39,10 @@ import type { WorkspaceFileResponse, WorkspacePlanResponse, WorkspaceProjectResponse, + WorkflowInput, + WorkflowListResponse, + WorkflowProgressEvent, + WorkflowRunResponse, WorkspaceSearchResponse, WorkspaceTreeResponse, } from '../shared/schemas'; @@ -110,6 +122,76 @@ declare global { readonly run: (runId: string, objective: string) => Promise; readonly cancel: (runId: string) => Promise; }; + /** + * Local memory (Phase 2, Milestone 8). Note that `exportScope` and + * `importScope` take a scope and nothing else: the file is chosen by + * the user in a native dialog the main process owns, so there is no + * path parameter here to name a file to read or to overwrite. Note also + * that no write carries a `source`: whether a record was typed or + * imported is stamped in the main process, never claimed from here. + */ + readonly memory: { + readonly list: (scope: MemoryScopeValue) => Promise; + readonly search: (scope: MemoryScopeValue, query: string) => Promise; + /** The small relevant set — never the whole store. */ + readonly retrieve: (objective: string) => Promise; + readonly add: (record: MemoryRecordInput) => Promise; + readonly update: (id: string, record: MemoryRecordInput) => Promise; + readonly setPinned: ( + id: string, + scope: MemoryScopeValue, + pinned: boolean, + ) => Promise; + readonly remove: (id: string, scope: MemoryScopeValue) => Promise; + readonly clear: (scope: MemoryScopeValue) => Promise; + readonly exportScope: (scope: MemoryScopeValue) => Promise; + readonly importScope: (scope: MemoryScopeValue) => Promise; + }; + /** + * Workflows (Phase 2, Milestone 9). Note that there is no `schedule` + * and no `watch`: a workflow's trigger is an enum with one member, + * `manual`, so nothing here can arrange for one to start by itself. + * Note also that `run` takes a workflow id and an objective — there is + * no parameter for a step, a tool, a path, a command or a limit, + * because what a run may do comes from the stored definition the main + * process reads. + */ + readonly workflow: { + readonly list: () => Promise; + readonly create: (workflow: WorkflowInput) => Promise; + readonly update: ( + workflowId: string, + workflow: WorkflowInput, + ) => Promise; + readonly duplicate: (workflowId: string, newId: string) => Promise; + readonly remove: (workflowId: string) => Promise; + readonly setEnabled: ( + workflowId: string, + enabled: boolean, + ) => Promise; + readonly run: ( + runId: string, + workflowId: string, + objective: string, + ) => Promise; + /** Stops the run at the next step boundary, keeping what it has done. */ + readonly pause: (runId: string) => Promise; + /** Aborts the run now, killing a child process it had started. */ + readonly cancel: (runId: string) => Promise; + /** Subscribe to advisory progress; returns an unsubscribe function. */ + readonly onProgress: (listener: (event: WorkflowProgressEvent) => void) => () => void; + }; + /** + * Windows automation (Phase 2, Milestone 10). `run` takes a tool id + * from the fixed registry and nothing else — no path, no URL, no + * argument, no window handle, no command. + */ + readonly automation: { + readonly list: () => Promise; + readonly run: (runId: string, toolId: string) => Promise; + /** Best-effort: abandons a launch attempt in progress. */ + readonly cancel: (runId: string) => Promise; + }; /** * Git (Phase 2, Milestone 6). `checkpoint` takes no argument: nothing * here can name a ref, a branch, a remote or a commit message, and diff --git a/src/renderer/memory/Memory.tsx b/src/renderer/memory/Memory.tsx new file mode 100644 index 0000000..c07a009 --- /dev/null +++ b/src/renderer/memory/Memory.tsx @@ -0,0 +1,458 @@ +/** + * The Memory Centre (Phase 2, Milestone 8). + * + * A fourth sibling next to Chat, Workspace and Agents, not a redesign: it + * reuses the existing panel, badge, hint, field and error classes rather than + * introducing a visual language of its own. + * + * What it is for, in the milestone's own terms: listing and searching + * memories, showing each one's scope and source, pinning, editing, deleting, + * clearing a scope, exporting and importing — and **stating plainly that this + * is local-only**. That last point is why the privacy line is part of the + * header rather than buried in a settings page: someone looking at a list of + * notes about themselves should be able to see, without navigating anywhere, + * that the notes are on this machine and that nothing here uploads them. + * + * Nothing here is a security control. The form validates before sending + * purely so a person sees a mistake immediately; the main process + * re-validates everything it receives, runs its own credential screen, and is + * the only thing that decides what is stored. + */ + +import { useState } from 'react'; + +import { useMemory } from './useMemory'; +import { + MEMORY_CATEGORIES, + MEMORY_CONFIDENCE_DEFAULT, + MEMORY_CONFIDENCE_MAX, + MEMORY_CONFIDENCE_MIN, + MEMORY_CONTENT_MAX_LENGTH, + MEMORY_IMPORTANCE_DEFAULT, + MEMORY_IMPORTANCE_MAX, + MEMORY_IMPORTANCE_MIN, + MEMORY_SCOPES, +} from '../../shared/constants'; +import { memoryRecordInputSchema } from '../../shared/schemas'; +import type { MemoryRecord, MemoryRecordInput, MemoryScopeValue } from '../../shared/schemas'; + +/** One sentence per scope, describing where its records actually live. */ +const SCOPE_DESCRIPTIONS: Readonly> = { + session: + 'Held in memory for this run of Local Agent only. Never written to disk — closing the application ends it.', + project: + 'Stored in a file belonging to the project you approved in the Workspace. Another project never opens it.', + personal: + 'Stored on this machine, in Local Agent’s own data folder. The only scope that outlives both the session and the project.', +}; + +const SCOPE_LABELS: Readonly> = { + session: 'Session', + project: 'Project', + personal: 'Personal', +}; + +/** A blank record for the create form, in the scope currently shown. */ +function emptyDraft(scope: MemoryScopeValue): MemoryRecordInput { + return { + scope, + category: 'user-preference', + content: '', + importance: MEMORY_IMPORTANCE_DEFAULT, + confidence: MEMORY_CONFIDENCE_DEFAULT, + expiresAt: null, + pinned: false, + }; +} + +/** Strips the derived fields so an existing record can seed the form. */ +function draftFrom(record: MemoryRecord): MemoryRecordInput { + const { + id: _id, + source: _source, + createdAt: _createdAt, + updatedAt: _updatedAt, + ...input + } = record; + return input; +} + +/** The date part of an ISO timestamp, for display and for a `date` input. */ +function isoDate(value: string): string { + return value.slice(0, 10); +} + +function MemoryCard(props: { + readonly record: MemoryRecord; + readonly canAct: boolean; + readonly onEdit: () => void; + readonly onDelete: () => void; + readonly onTogglePinned: () => void; +}) { + const { record, canAct } = props; + + return ( +
+
+ {SCOPE_LABELS[record.scope]} + {record.category} + {/* Source is always shown: whether a note was typed here or came + from an imported file is exactly the kind of provenance a reader + needs in order to weigh it. */} + + {record.source === 'user' ? 'Typed here' : 'Imported'} + + {record.pinned && Pinned} +
+ +

{record.content}

+ +

+ Importance {record.importance}/{MEMORY_IMPORTANCE_MAX} · confidence {record.confidence}% · + updated {isoDate(record.updatedAt)} + {record.expiresAt !== null && ` · expires ${isoDate(record.expiresAt)}`} +

+ +
+ + + +
+
+ ); +} + +export function Memory() { + const memory = useMemory(); + const { state, canAct } = memory; + + const [queryText, setQueryText] = useState(''); + const [draft, setDraft] = useState(null); + const [editingId, setEditingId] = useState(null); + + // Validated here only so a mistake is visible immediately. The main process + // re-validates everything it receives and is what actually decides. + const validation = draft === null ? null : memoryRecordInputSchema.safeParse(draft); + const draftIssues = + validation === null || validation.success + ? [] + : validation.error.issues.map( + (issue) => `${issue.path.join('.') || 'memory'}: ${issue.message}`, + ); + + function updateDraft(changes: Partial): void { + setDraft((current) => (current === null ? current : { ...current, ...changes })); + } + + function closeDraft(): void { + setDraft(null); + setEditingId(null); + } + + async function submitDraft(): Promise { + if (draft === null || validation === null || !validation.success) return; + if (editingId === null) await memory.addMemory(validation.data); + else await memory.updateMemory(editingId, validation.data); + closeDraft(); + } + + return ( +
+
+
+

Memory

+

+ Short notes you write about how you want to be worked with. Nothing here is written by a + model, and nothing here grants a permission. +

+ {/* The privacy status the milestone requires, stated where it + cannot be missed rather than in a settings page. */} +

+ Local only. These notes stay on this machine. Local Agent never uploads + them, never syncs them, and never sends the whole store anywhere — only a handful of + relevant notes are ever retrieved at a time. +

+
+
+

+ {state.busy === null ? 'Idle' : `Working: ${state.busy}`} +

+ {state.activity !== null &&

{state.activity}

} +
+
+ + {state.error !== null && ( +
+

{state.error.message}

+ + {state.error.retryable && ( + + )} + + +
+ )} + + + +

{SCOPE_DESCRIPTIONS[state.scope]}

+ +
+
+
+

+ {SCOPE_LABELS[state.scope]} memories ({state.total}) +

+ +
+ +
{ + event.preventDefault(); + void memory.search(queryText); + }} + > + + { + setQueryText(event.target.value); + }} + /> + + {state.query.length > 0 && ( + + )} +
+ + {!state.initialized &&

Loading memories…

} + + {state.initialized && state.records.length === 0 && ( +

+ {state.query.length > 0 + ? 'No memory matches that search.' + : 'Nothing is remembered in this scope yet.'} +

+ )} + + {state.records.map((record) => ( + { + setDraft(draftFrom(record)); + setEditingId(record.id); + }} + onDelete={() => void memory.deleteMemory(record.id)} + onTogglePinned={() => void memory.setPinned(record.id, !record.pinned)} + /> + ))} + + {state.truncated && ( +

+ Showing the first {state.records.length} of {state.total}. Narrow the list with a + search. +

+ )} +
+ +
+

This scope

+

+ Clearing, exporting and importing each ask for confirmation in a dialog the main process + owns. Export and import choose the file in a native dialog — Local Agent never picks a + file for you. +

+
+ + + +
+

+ An imported file is untrusted: every record is validated, given a fresh identifier, + labelled as imported, and anything that looks like a key, token or password is refused. +

+ + {draft !== null && ( +
+

{editingId === null ? 'New memory' : 'Edit memory'}

+ +
+ +