diff --git a/.agents/skills/formal-verify-materialize-3d/SKILL.md b/.agents/skills/formal-verify-materialize-3d/SKILL.md index b73db9f9..09b3e1a3 100644 --- a/.agents/skills/formal-verify-materialize-3d/SKILL.md +++ b/.agents/skills/formal-verify-materialize-3d/SKILL.md @@ -7,7 +7,7 @@ description: Model-check, with TLC, what the in-app update's restart can cut off Two TLA+ models describe code where a bad order of events harms a user and tests rarely reach that order. TLC (the TLA+ model checker) tries every order of steps that the models allow. -- `models/UpdateRestart.tla`. **Update now** waits for builds, closes the build gate, backs up, installs, and restarts. Builds, STL slices, and Studio turns race against it, and so do print queue writes. +- `models/UpdateRestart.tla`. **Update now** waits for builds, closes the build gate, backs up, installs, and restarts. Builds, STL slices, and the sidecar's chat work race against it, and so do print queue writes. - `models/AutoSend.tla`. Two auto-send runs race to send the first Pending job in the print queue to the printer, across reconnects and a printer switch. ## Checks @@ -16,7 +16,7 @@ Two TLA+ models describe code where a bad order of events harms a user and tests | Check | Property | Harm if it breaks | Recorded | |---|---|---|---| -| `no-build-cut-off` | The restart never kills a build, a slice, or a Studio turn, nor cuts a turn's last transcript write. | A half-made build or 3MF, a turn's agent killed mid-job, or a chat that never records how its turn ended. | holds | +| `no-build-cut-off` | The restart never kills a build, a slice, or the sidecar's chat work, nor cuts its last commit. | A half-made build or 3MF, or a turn's agent killed mid-job. | holds | | `gate-held-until-restart` | From the idle check to the restart, the gate is closed and no build runs. | A build starts that the restart then kills. | holds | | `failed-update-reopens` | A failed update lets builds start again. | The app refuses every build until it is quit. | holds | | `update-wakes` | When builds stop starting, a waiting update goes ahead. | **Update now** hangs with no build running. | holds | @@ -55,8 +55,8 @@ Exit 0 means every verdict matches the recorded one. Exit 1 means at least one d | `split-close` | `close_when_idle` reads `running`, then closes the gate in a second send. | `gate-held-until-restart` | | `late-subscribe` | `borrow_and_update` moves after the idle check, so a build that ends in between is never heard. | `update-wakes` | | `ungated-slice` | `slice_model` no longer calls `builds.enter()`. | `no-build-cut-off` | -| `ungated-turn` | `studio_send` no longer calls `take_pass`. | `no-build-cut-off` | -| `early-release` | A Studio turn drops its pass before its last transcript write. | `no-build-cut-off` | +| `ungated-turn` | A send no longer asks for the pass (`Busy.during` in `sidecar/src/busy.ts`). | `no-build-cut-off` | +| `early-release` | The sidecar gives its pass back while a task is still live. | `no-build-cut-off` | | `guard-dropped` | `update_install` binds the `Closed` guard to `_`, which drops it before the restart. | `no-build-cut-off` | | `guard-leaked` | An error path keeps the `Closed` guard alive. | `failed-update-reopens` | | `unguarded` | The claim in `auto_send_pending_job` no longer checks `queue_held`. | `one-run-at-a-time` | @@ -78,7 +78,7 @@ Then read each covered function that changed. Also look for new code that reache | Model action | Code | |---|---| -| `Enter`, `Leave` | `BuildGate::enter` and `Drop for Running` in `build_gate.rs`. Callers: `build_as` in `pipeline.rs`, `slice_model` in `commands.rs`. For a Studio turn, `BuildGate::enter_owned` through `take_pass` in `studio_send`, and `Drop for OwnedRunning` at the end of the turn's task in `studio/turn.rs`, after its last transcript write. | +| `Enter`, `Leave` | `BuildGate::enter` and `Drop for Running` in `build_gate.rs`. Callers: `build_as` in `pipeline.rs`, `slice_model` in `commands.rs`. For the sidecar's chat work, `BuildGate::enter_owned` through `take_pass` in `set_busy` (`sidecar.rs`), which `sidecar/src/busy.ts` asks for before a send commits (`Busy.during` in `sidecar/src/chat.ts`) and before work a crash cut off resumes (`busy.watch` in `sidecar/src/app.ts`), and `Drop for OwnedRunning` when it gives the pass back once no task is pending or running, after its last commit. A task that waits, sleeps, or holds a sign-in wait (`Busy.parked`) is not work: it resumes after a restart. `set_busy` refuses a request from a sidecar run that ended. A slice the sidecar asks for takes a pass of its own (`with_own_pass` in `sidecar.rs`), since it can outlive the sidecar run that asked for it. A turn a sign-in lets go waits for the pass while an update installs (`Busy.duringWhenAllowed`). | | `Downloaded`, `Top`, `CheckAndClose`, `CheckBusy`, `Wake` | `BuildGate::close_when_idle`. Its `subscribe`, `borrow_and_update`, one `send_if_modified`, and `changed().await`. | | `Reopen` | `Drop for Closed`, on each error return of `install_when_idle`. | | `Click`, `DownloadFails`, `BackupOk`, `BackupFails`, `InstallOk`, `InstallFails`, `Exit` | `update_install` and `install_when_idle` in `updater.rs`, then Tauri's `AppHandle::restart`, which ends in `process::exit(0)`. | diff --git a/.agents/skills/formal-verify-materialize-3d/models/UpdateRestart.tla b/.agents/skills/formal-verify-materialize-3d/models/UpdateRestart.tla index 5905e0b2..910fe5a3 100644 --- a/.agents/skills/formal-verify-materialize-3d/models/UpdateRestart.tla +++ b/.agents/skills/formal-verify-materialize-3d/models/UpdateRestart.tla @@ -8,10 +8,14 @@ (* install_when_idle *) (* src-tauri/src/fabrication/pipeline.rs build_as takes a pass *) (* src-tauri/src/commands.rs slice_model takes a pass *) -(* src-tauri/src/studio/turn.rs a Studio turn takes a pass *) -(* in studio_send, before its *) -(* first transcript line, and *) -(* drops it after its last *) +(* src-tauri/src/sidecar.rs the sidecar's work takes a *) +(* sidecar/src/busy.ts pass (set_busy) before a *) +(* sidecar/src/chat.ts send's first commit and *) +(* sidecar/src/app.ts before work a crash cut off *) +(* resumes, and drops it once *) +(* no task is pending or *) +(* running, after its last *) +(* commit *) (* src-tauri/src/print_queue.rs persist_queue *) (* Tauri's AppHandle::restart from an async command asks the event loop to *) (* exit, then calls std::process::exit(0), which stops every other thread *) @@ -22,7 +26,7 @@ EXTENDS Naturals CONSTANTS Builds, \* tasks that each hold at most one build pass at a time Slice, \* the member of Builds that is commands.rs slice_model - Turn, \* the member of Builds that is a Studio turn (studio/turn.rs) + Turn, \* the member of Builds that is the sidecar's work (busy.ts) MaxStarts, \* starts per build task. 0 means no limit. MaxUpdates, \* how many times update_install can run Mutant \* "none" is the code. The others are broken variants. @@ -36,7 +40,8 @@ VARIABLES closed, \* Builds.closed in the gate's watch channel unseen, \* the updater's watch::Receiver holds a change it has not seen build, \* build task -> "idle", "running", or "writing" (mutant - \* early-release: a turn still writing after its pass is gone) + \* early-release: the sidecar still committing after its pass + \* is gone) starts, \* build task -> starts so far, counted only when MaxStarts > 0 upd, \* where update_install is updates, \* update_install runs so far @@ -62,15 +67,19 @@ Init == ---------------------------------------------------------------------------- (* Builds. build_as and slice_model each call BuildGate::enter first and *) -(* hold the Running pass until they return. A Studio turn calls *) -(* BuildGate::enter_owned in studio_send and drops the pass at the end of *) -(* its task, after its last transcript write. *) +(* hold the Running pass until they return. The sidecar asks for a pass *) +(* (BuildGate::enter_owned in set_busy) before a send commits and before *) +(* work a crash cut off resumes, and gives it back once no task is pending *) +(* or running, after its last commit. Turn is that work. A task that *) +(* waits, sleeps, or holds a sign-in wait does none until something wakes *) +(* it, and it resumes after a restart, so the model has no step for it. A *) +(* request from a sidecar run that ended takes no pass. *) Gated(b) == /\ ~(Mutant = "ungated-slice" /\ b = Slice) /\ ~(Mutant = "ungated-turn" /\ b = Turn) -\* Mutant early-release: a turn drops its pass before its last transcript write. +\* Mutant early-release: the sidecar drops its pass before its last commit. EarlyRelease(b) == Mutant = "early-release" /\ b = Turn CanStart(b) == @@ -105,8 +114,8 @@ Leave(b) == ELSE UNCHANGED <> /\ UNCHANGED <> -\* Mutant early-release: the pass drops while the turn still has its last -\* transcript lines to write. +\* Mutant early-release: the pass drops while the sidecar still has its last +\* commit to make. ReleaseEarly(b) == /\ Alive /\ build[b] = "running" /\ EarlyRelease(b) /\ build' = [build EXCEPT ![b] = "writing"] @@ -256,8 +265,8 @@ Spec == Init /\ [][Next]_vars /\ Fairness ---------------------------------------------------------------------------- (* Properties. Each is one check in scripts/check.sh. *) -\* The restart never kills a build, a slice, or a Studio turn, nor cuts a -\* turn's last transcript write. +\* The restart never kills a build, a slice, or the sidecar's work, nor cuts +\* its last commit. NoBuildCutOff == upd = "exited" => \A b \in Builds : build[b] = "idle" \* From the idle check to the restart, the gate is closed and no build runs. diff --git a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 index a8bdde2a..25ac8f59 100644 --- a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 +++ b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 @@ -4,4 +4,7 @@ bfdae9bc19239959afa97b28ed24be9289b35a988fb59ee657e5086babce1665 src-tauri/src/ 581b0e828a547528a87785cd366c6e57252dea85823cc1402fcaf7b113a2255c src-tauri/src/printer/mod.rs dc6f6f11328e5f422b59be488099f9203bcfe352a1fadcc3f523debca4a7bbd8 src-tauri/src/commands.rs dc1709c75444259b23f19d9210b730ba014074dcffc42453537d1f3a16cd5430 src-tauri/src/fabrication/pipeline.rs -10eff3df2dbaf0b024c18e97cb60b8961bdafbe8130d63f374a099606a687b0a src-tauri/src/studio/turn.rs +b6a70183e6dc2ae4abe76391342f4ce2c17536ea07f8976252c78eb6c46b17ad src-tauri/src/sidecar.rs +672150b06f7a26b26c4af16eaf9d878dab762c6bf37a406c87cf118f9e71f439 sidecar/src/busy.ts +d49a7e7b10a56f7fbe4e079b07a84c26e6728a1ab3fa38697c95a4e0b3c7558c sidecar/src/chat.ts +6f89db18e99fbe649f2b10a7476c4e548a401aac196d60053d16c5ebb46b9fec sidecar/src/app.ts diff --git a/.agents/skills/verify-materialize-3d/features/studio.md b/.agents/skills/verify-materialize-3d/features/studio.md index fa2265ad..a5997af9 100644 --- a/.agents/skills/verify-materialize-3d/features/studio.md +++ b/.agents/skills/verify-materialize-3d/features/studio.md @@ -1,15 +1,16 @@ # Studio -The studio is the whole app. Each chat is a project folder in `~/Documents/Materialize 3D/-/` (an `e2e` build uses `/projects/`). A turn runs the local `claude` CLI with Opus 5.5 in that folder on the person's own login. The right panel shows whatever the agent writes: HTML in a sandboxed frame, STL, 3MF, and GLB in an orbit viewer, and pictures. The newest such file opens on its own. The Files tab lists every file, with Reveal in Finder and, for a 3MF, Open in Bambu Studio. The other recipes in this map describe screens the studio hides. +The studio is the whole app. Each chat is a project folder in `~/Documents/Materialize 3D/-/` (an `e2e` build uses `/projects/`), and a conversation in the sidecar's store, `/state/store.sqlite` (an `e2e` build: `/state/`). The sidecar, `Contents/MacOS/materialize-sidecar`, runs the agent through Pi Durable on the person's own inference login, with the app's own tools: `read`, `write`, and `edit` inside the project folder, `run` (uv), `slice` (the validated Bambu Studio, through Rust), and `astra` (GPT 6 Astra as a child task). The right panel shows whatever the agent writes: HTML in a sandboxed frame, STL, 3MF, and GLB in an orbit viewer, and pictures. The newest such file opens on its own. The Files tab lists every file, with Reveal in Finder and, for a 3MF, Open in Bambu Studio. The other recipes in this map describe screens the studio hides. ## Sub-features - `start-screen` holds "What are we making?", the `prompt` box, `Add picture`, `send`, and three suggestion chips that fill the box. -- `prompt` takes text, pasted pictures, and links. A picture dropped on the window or chosen with `Add picture` shows in `attachments` and is copied to the project's `refs/` on send. It reaches the model as an image block. -- `messages` shows each tool step on one line (`.tool`, with `.tool-running` while it runs), agent text, `file-cards` after a turn that wrote previewable files or a 3MF, and `.msg-error` for a failed or stopped turn. `working` shows while a turn runs, and `stop` replaces `send`. -- `stop` ends the agent and every tool it started (its process group). -- The agent runs lean: the person's inference login only (`ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `API_TIMEOUT_MS`, and `apiKeyHelper` from `~/.claude/settings.json`), `--setting-sources project`, no MCP servers, no CLAUDE.md or auto-memory, and the app's own `materialize-studio` plugin from the bundle's `studio-plugin` resource. Astra runs with `CODEX_HOME` set to `/astra`, which holds only the cliproxy provider and turns off the skills under `~/.agents/skills`. A turn's `system` `init` line in the transcript lists its tools, MCP servers, plugins, and skills. -- A turn cut off by quitting the app ends when the project reopens, with "This turn stopped when the app closed." +- `prompt` takes text, pasted pictures, and links. The text is saved as the project's draft a moment after typing stops, and comes back when the project opens. A picture dropped on the window or chosen with `Add picture` shows in `attachments` and is copied to the project's `refs/` on send. It reaches the model as an image block. +- `messages` shows committed chat lines only: each tool step on one line (`.tool`, with `.tool-running` until its result is committed), agent text, and `file-cards` after a turn that wrote previewable files or a 3MF. `working` shows while a turn runs, and `stop` replaces `send`. +- `chat-model` names the model the current turn runs with. A settings change applies from the next message. +- `waiting` shows when a turn stopped before its answer: the fix, and the control for it. A provider with no login shows `Sign in with Claude` or `Sign in with ChatGPT`, and for Anthropic and OpenCode Go a key form (`API key or token`, and for Anthropic `Proxy URL`). Once the login is saved, the turn goes on by itself. Any other failure shows its error and `Try again`. +- A turn cut off by quitting or a crash goes on from its last committed step when the app opens again. +- `engine` in the sidebar shows the sidecar's version, or why it stopped. - `files-tab` opens `files-list`, with "Print files" (3MF) above "All files". - `html-preview` is the iframe. `model-view` holds the orbit canvas. @@ -20,7 +21,7 @@ The studio is the whole app. Each chat is a project folder in `~/Documents/Mater ## Driving it with wd.ts -On macOS, drive it with `mac.ts` from an `e2e` build. Answer `Add picture` with `mac.ts dialog ` before the click. `PROJECT` is the one folder under `/data/projects/`. +On macOS, drive it with `mac.ts` from an `e2e` build. Answer `Add picture` with `mac.ts dialog ` before the click. A fresh data folder has no login, so the first turn waits for one. `ANTHROPIC_TOKEN` and `ANTHROPIC_PROXY` are the host's proxy token and URL. ```bash bun $S/mac.ts wait '[data-testid=start-screen]' 30000 @@ -28,20 +29,23 @@ bun $S/mac.ts dialog "$HOME/fixtures/sketch.png" bun $S/mac.ts click 'text=Add picture' bun $S/mac.ts type '[data-testid=prompt]' 'Make a cable clip like in this sketch, then slice it to a 3MF' bun $S/mac.ts click '[data-testid=send]' +bun $S/mac.ts wait '[data-testid=waiting]' 20000 +bun $S/mac.ts type '[aria-label="API key or token"]' "$ANTHROPIC_TOKEN" +bun $S/mac.ts type '[aria-label="Proxy URL"]' "$ANTHROPIC_PROXY" +bun $S/mac.ts click 'text=Save' bun $S/mac.ts wait '[data-testid=working]' 20000 -# A full job takes minutes. Wait for its end line in the transcript, then for the file card. -until grep -q m3d_turn_end "$PROJECT/.m3d/transcript.jsonl"; do sleep 10; done -bun $S/mac.ts wait '[data-testid=file-cards]' 30000 +# A full job takes minutes. The file cards appear when the turn's last step is committed. One mac.ts wait is one +# HTTP request, which Bun ends after about five minutes, so a long wait goes in rounds. +for _ in 1 2 3 4; do bun $S/mac.ts wait '[data-testid=file-cards]' 240000 && break; done bun $S/mac.ts click '[data-testid=files-tab]' bun $S/mac.ts click '[data-testid=files-list] button.primary' # Open in Bambu Studio ``` -The transcript is `/.m3d/transcript.jsonl`. A finished turn ends with an `m3d_turn_end` line. Wait on that line, not on a clock. - ## Gotchas -- A turn needs `claude` signed in on the host. Without it the turn ends at once with the CLI's error in `.msg-error`. -- Claude Code refuses a bare `sleep`. To test Stop, ask for `python3 -c 'import time; time.sleep(300)'` in the foreground **(observed 2026-11-06)**. +- The sidecar's stderr is in the app log, as `sidecar: `. Its first line lists the agent's tools. +- The sidecar reads none of the host's agent setup or environment. A login made with `claude` or `codex` on the host does not reach it. - `mac.ts` selectors are CSS or `text=`. It has no XPath. +- A `mac.ts wait` longer than about five minutes fails with "The operation timed out", because Bun's fetch ends the one request it makes **(observed 2026-11-07)**. Wait in rounds of four minutes. - The WebKit snapshot from `mac.ts shot` includes the iframe and the WebGL canvas **(observed 2026-11-06)**. - Bambu Studio's own window is outside the webview. Check its process with `pgrep -x BambuStudio` after Open in Bambu Studio. diff --git a/.github/workflows/formal-verification.yml b/.github/workflows/formal-verification.yml index 03d0c48d..84a294a7 100644 --- a/.github/workflows/formal-verification.yml +++ b/.github/workflows/formal-verification.yml @@ -28,7 +28,10 @@ on: - src-tauri/src/printer/mod.rs - src-tauri/src/commands.rs - src-tauri/src/fabrication/pipeline.rs - - src-tauri/src/studio/turn.rs + - src-tauri/src/sidecar.rs + - sidecar/src/busy.ts + - sidecar/src/chat.ts + - sidecar/src/app.ts - .agents/skills/formal-verify-materialize-3d/** - .github/workflows/formal-verification.yml push: diff --git a/scripts/release/check-app-bundle.sh b/scripts/release/check-app-bundle.sh index 0f870b3c..64f41dce 100755 --- a/scripts/release/check-app-bundle.sh +++ b/scripts/release/check-app-bundle.sh @@ -18,8 +18,8 @@ newline=$'\n' odd="$(cd "$app" && find . -name "*$newline*" -print -quit)" [[ -z "$odd" ]] || die "a path in the app bundle contains a newline: $(printf '%q' "$odd")" -# The app binary, the sidecar, the CAD helper and its runtime, the agent's studio plugin, the icons (Assets.car for -# macOS 26, icon.icns as the CFBundleIconFile), the license notices, the signature, and the stapled ticket +# The app binary, the sidecar, the CAD helper and its runtime, the icons (Assets.car for macOS 26, +# icon.icns as the CFBundleIconFile), the license notices, the signature, and the stapled ticket # (Contents/CodeResources). Every entry that is not a directory counts, symlinks included. expected="$(printf '%s\n' \ Contents/CodeResources Contents/Info.plist \ @@ -28,8 +28,6 @@ expected="$(printf '%s\n' \ Contents/Resources/THIRD_PARTY_NOTICES.md Contents/Resources/icon.icns \ Contents/Resources/cad-runtime/Image Contents/Resources/cad-runtime/job.img \ Contents/Resources/cad-runtime/rootfs.img \ - Contents/Resources/studio-plugin/.claude-plugin/plugin.json \ - Contents/Resources/studio-plugin/skills/fabricate/SKILL.md \ Contents/_CodeSignature/CodeResources | sort)" actual="$(cd "$app" && find . ! -type d | sed 's|^\./||' | sort)" [[ "$actual" == "$expected" ]] || die "unexpected files in the app bundle: diff --git a/sidecar/src/agent.ts b/sidecar/src/agent.ts index a5848355..5b01cf6c 100644 --- a/sidecar/src/agent.ts +++ b/sidecar/src/agent.ts @@ -24,6 +24,7 @@ import { NodeExecutionEnv } from '@earendil-works/pi-durable/env/node' import { createEditTool, createReadTool, createWriteTool } from '@earendil-works/pi-durable/tools' import { arr, int, obj, opt, str } from '@earendil-works/pi-schema' import type { Host } from './host' +import type { Need } from './protocol' import { isLoginFailure } from './inference' import { textOf } from './transcript' import workflow from './workflow.md' with { type: 'text' } @@ -41,6 +42,56 @@ const OUTPUT_TAIL = 20_000 /** The only variables a command the agent runs gets. */ const COMMAND_ENV = ['PATH', 'HOME', 'USER', 'LANG', 'TMPDIR'] as const +/** + * The environment a command the agent runs gets: the sidecar's own values of `COMMAND_ENV`, and nothing else, but for + * ~/.local/bin at the end of PATH. uv's installer puts uv there and adds it to the shell's startup files, which the + * PATH the app read at its start comes from, so a uv installed while the app runs is found without a restart. + */ +function commandEnv(): Record { + const vars: Record = {} + for (const name of COMMAND_ENV) { + const value = process.env[name] + if (value !== undefined) vars[name] = value + } + if (vars.HOME !== undefined) { + const local = join(vars.HOME, '.local', 'bin') + const path = vars.PATH === undefined ? [] : vars.PATH.split(':') + if (!path.includes(local)) vars.PATH = [...path, local].join(':') + } + return vars +} + +/** How long one toolchain check may run. A check still running then, as one stuck on a lock, counts as failed. */ +const CHECK_MS = 5_000 + +/** + * Whether `command` runs to a zero exit, within `CHECK_MS`, in the environment the agent's commands get. A missing + * program does not. It runs beside the sidecar's other work, so a stuck check holds up no chat. + */ +async function succeeds(command: string[]): Promise { + try { + const child = Bun.spawn(command, { env: commandEnv(), stdout: 'ignore', stderr: 'ignore', timeout: CHECK_MS }) + return (await child.exited) === 0 + } catch { + return false + } +} + +/** + * What the `run` tool lacks in this sidecar's environment: uv, which runs every build and check script, and a Python + * that uv can run them with. A Python uv would have to download first counts as missing, as a turn could not count on + * the network for it. + */ +export async function toolchainNeeds(): Promise { + if (!(await succeeds(['uv', '--version']))) { + return [{ kind: 'uv', fix: 'Install uv, the tool the agent runs its build scripts with (docs.astral.sh/uv), then check again.' }] + } + if (!(await succeeds(['uv', 'python', 'find']))) { + return [{ kind: 'python', fix: 'Install a Python for uv: run "uv python install" in Terminal, then check again.' }] + } + return [] +} + /** * Runs a command under a watch on the sidecar. Pi starts each command in a process group of its own, so it can end the * whole tree on a timeout or a stop, but a sidecar killed outright ends nothing. The watch ends the group, command and @@ -188,9 +239,9 @@ export interface AgentHost { /** * Resolves once the person is signed in to `provider`: at once when they are, after holding the turn with the fix * named on its chat when they are not. With `after`, only a login saved after that many saves will do, as the one - * then saved was refused. A stop ends the wait. + * then saved was refused. The tool's `task` gives the update pass back while it waits. A stop ends the wait. */ - signedIn(conversation: number, provider: string, context: Context, after: number | undefined): Promise + signedIn(conversation: number, task: string, provider: string, context: Context, after: number | undefined): Promise /** How many times the provider's login was saved. */ loginSaves(provider: string): Promise } @@ -228,11 +279,7 @@ export function agentTools({ host, projectOf, signedIn, loginSaves }: AgentHost) execute: async ({ args, timeoutSeconds }, api, context) => { const op = operation<{ exitCode: number; output: string }>(api, context) const { env } = op - const vars: Record = { M3D_OPERATION_ID: op.id } - for (const name of COMMAND_ENV) { - const value = process.env[name] - if (value !== undefined) vars[name] = value - } + const vars: Record = { ...commandEnv(), M3D_OPERATION_ID: op.id } const log = `${op.receipt}.log` const exited = `${op.receipt}.exit` // A command a crash cut off may still be ending: wait until its watch has ended it or it left its exit code. @@ -307,7 +354,7 @@ export function agentTools({ host, projectOf, signedIn, loginSaves }: AgentHost) // The login save each request goes out with, kept with the request, so a rerun after a crash finds the same one. const savedWith = async (requestId: string) => api.memo(`login:${requestId}`, await loginSaves(provider), context) // Astra runs on the person's ChatGPT sign-in. Without it the turn waits here, with the fix named, and goes on. - await signedIn(api.conversationId, provider, context, undefined) + await signedIn(api.conversationId, String(api.taskId), provider, context, undefined) const first = `astra:${api.taskId}` let saves = await savedWith(first) let submission = await conversation.submit({ type: 'input', content: task, requestId: first }, context) @@ -328,7 +375,7 @@ export function agentTools({ host, projectOf, signedIn, loginSaves }: AgentHost) // a continue it already sent as it was, without waiting again. const requestId = `astra:${api.taskId}:continue:${resumed}` const sent = await api.commit((tx) => tx.submissionByRequest(child, requestId), context) - if (sent === undefined) await signedIn(api.conversationId, provider, context, saves) + if (sent === undefined) await signedIn(api.conversationId, String(api.taskId), provider, context, saves) saves = await savedWith(requestId) submission = await conversation.submit({ type: 'continue', requestId }, context) } diff --git a/sidecar/src/app.ts b/sidecar/src/app.ts index 26228e2a..092abfd2 100644 --- a/sidecar/src/app.ts +++ b/sidecar/src/app.ts @@ -1,10 +1,14 @@ +import { existsSync, lstatSync, readdirSync, rmSync } from 'node:fs' +import { dirname, join } from 'node:path' import { parseArgs } from 'node:util' import type { MutableModels } from '@earendil-works/pi-ai' import { createRegistry } from '@earendil-works/pi-durable' import { agentExtension, agentTools, ProjectEnv } from './agent' +import { Busy } from './busy' import { Chats } from './chat' import { Host } from './host' import { inferenceModels, KeychainCredentials, Logins } from './inference' +import { legacyProjects } from './legacy' import { handlers } from './methods' import { EXIT, type SidecarEvent } from './protocol' import { Store, StoreInUse } from './store' @@ -44,7 +48,7 @@ export async function runSidecar({ argv, addProviders }: SidecarOptions): Promis const tools = agentTools({ host, projectOf: (conversation) => store.projectOf(conversation), - signedIn: (conversation, provider, context, after) => chats.loginFor(conversation, provider, context, after), + signedIn: (conversation, task, provider, context, after) => chats.loginFor(conversation, task, provider, context, after), loginSaves: (provider) => store.loginSaves(provider), }) const extension = agentExtension(tools) @@ -80,15 +84,64 @@ export async function runSidecar({ argv, addProviders }: SidecarOptions): Promis chats.signedIn(event.provider).catch((error: unknown) => console.error(`resuming turns after a sign-in failed: ${String(error)}`)) } } - const chats = new Chats({ store, models, extension, tools, version, emit }) + const busy = new Busy(host) + const chats = new Chats({ store, models, extension, tools, version, busy, emit }) const logins = new Logins(models, credentials, emit) console.error(`agent tools: ${tools.map((tool) => tool.name).join(', ')}`) - // The pipe opens before the reconcile, which asks Rust's Keychain whether a waited-on login is there now. - const serving = host.serve(handlers({ store, logins, chats, version }), process.stdin) + // The import asks Rust for nothing, so it ends before the pipe opens and the first call sees every old chat. Projects + // already in the index were imported before, and their transcripts are not read again. It runs again when the person + // asks, as after letting the app read a projects folder macOS kept from it. + const importOld = async () => { + const known = new Set((await store.projects()).map((project) => project.id)) + const { found, blocked } = legacyProjects(projects, known) + const imported = await store.importLegacy(found) + if (imported.length > 0) console.error(`imported ${imported.length} chats from before the store: ${imported.join(', ')}`) + return { imported: imported.length, blocked } + } + let oldChats = await importOld() + await store.reviseOldDrafts() + // A pasted picture is kept until it is sent, which copies it into its project, or its draft lets it go. A window that + // outlived a crash may still hold one it has not saved in a draft yet, so only pictures older than a day that no + // draft names are removed. A start screen send cut off after its project was made still sends the draft it moved, so + // that draft's pictures stay too. + const pictures = join(dirname(data), 'pictures') + const kept = [...Object.values((await store.state()).drafts), ...(await store.startedDrafts())] + const named = new Set(kept.flatMap((draft) => draft.attachments)) + const dayAgo = Date.now() - 86_400_000 + for (const folder of existsSync(pictures) ? readdirSync(pictures) : []) { + const path = join(pictures, folder) + // Each pasted picture has a folder of its own. Anything else here, as a file Finder leaves or a link, is not the + // sidecar's, so it is read without following it. + const stat = lstatSync(path) + if (!stat.isDirectory() || stat.mtimeMs > dayAgo) continue + if (!readdirSync(path).some((file) => named.has(join(path, file)))) rmSync(path, { recursive: true, force: true }) + } + const legacy = { + blocked: () => oldChats.blocked, + retry: async () => { + oldChats = await importOld() + return oldChats + }, + } + // The pipe opens before the pass is asked for and before the reconcile, which asks Rust's Keychain whether a + // waited-on login is there now. + const serving = host.serve(handlers({ store, logins, chats, version, legacy, pictures }), process.stdin) + // Work a crash cut off resumes only with the pass. Without it an update is installing, and the work resumes after + // the restart. + const resumable = await busy.watch(store.harness) host.emit({ type: 'ready', version }) - await chats.reconcile() + if (resumable) await chats.reconcile() + else { + // The update restarts the app, and the work resumes then. If the install fails instead, the gate opens again. + console.error('an update is installing, so the work a crash cut off waits for it') + void busy + .pass() + .then((held) => (held ? chats.reconcile() : undefined)) + .catch((error: unknown) => console.error(`resuming the work a crash cut off failed: ${String(error)}`)) + } await serving + busy.close() await chats.close() await store.close() process.exit(EXIT.done) diff --git a/sidecar/src/busy.ts b/sidecar/src/busy.ts new file mode 100644 index 00000000..00afb974 --- /dev/null +++ b/sidecar/src/busy.ts @@ -0,0 +1,179 @@ +/** + * Tells Rust while the store has work in flight, so an update waits to restart the app until the work is done. The + * pass is asked for before a turn's first commit and before work a crash cut off resumes, and given back once no send + * is committing and every live task is idle. A tool parked on a sign-in is idle, and so is a task that waits only on + * idle tasks: they do no work until the person signs in, and they resume after a restart, so an update may restart + * through them. A task that waits on a tool that finished is about to go on, so it is not idle. + * While an update installs, Rust refuses the pass, and the send with it. + */ +import { BACKGROUND_CONTEXT } from '@earendil-works/chord/context' +import type { Harness, TaskGraph } from '@earendil-works/pi-durable' +import type { Host } from './host' + +/** How long a refused pass waits before it asks again. An installing update restarts the app, or it fails and the gate opens. */ +const PASS_RETRY_MS = 1000 + +/** Waits `ms`, or less once `stop` aborts. */ +function pause(ms: number, stop: AbortSignal): Promise { + return new Promise((resolve) => { + const done = () => { + clearTimeout(timer) + stop.removeEventListener('abort', done) + resolve() + } + const timer = setTimeout(done, ms) + stop.addEventListener('abort', done) + }) +} + +/** Whether any live task does work now or is about to. A blocked task never runs here. */ +function working(graph: TaskGraph, parked: ReadonlySet): boolean { + const idle = (id: string): boolean => { + if (parked.has(id)) return true + const task = graph.tasks[id] + if (task === undefined || task.blocked !== undefined) return task !== undefined + if (task.state.status !== 'waiting') return false + // A finished child is gone from the graph. A task whose children all finished is about to go on, and one that + // still waits on a live child is idle when every live child is. + const live = task.state.on.map(String).filter((child) => graph.tasks[child] !== undefined) + return live.length > 0 && live.every(idle) + } + return Object.keys(graph.tasks).some((id) => !idle(id)) +} + +export class Busy { + readonly #host: Host + /** Whether Rust said yes to the pass and it was not given back since. */ + #held = false + #graph: TaskGraph = { tasks: {} } + /** The ids of running tool tasks parked on a sign-in, which do no work until the person signs in. */ + readonly #parked = new Set() + #sends = 0 + /** Ends every wait for the pass once the sidecar closes. */ + readonly #closing = new AbortController() + /** Changes reach Rust one at a time, in order. */ + #told: Promise = Promise.resolve() + + constructor(host: Host) { + this.#host = host + } + + /** + * Follows the store's live tasks from the graph as it is now, so work a crash cut off asks for the pass before it + * resumes. Resolves false when that work has no pass, which means an update is installing and restarts the app. + */ + async watch(harness: Harness): Promise { + const graph = await harness.watchTaskGraph(BACKGROUND_CONTEXT) + this.#graph = graph.value + const ok = await this.#settle().then( + () => true, + () => false, + ) + graph.start(async (call) => { + if (call.kind !== 'value') return + this.#graph = call.value + await this.#follow() + }) + return ok + } + + /** + * Settles for work already under way. A refusal means an update is installing and restarts the app soon: the work is + * committed step by step and resumes after it. The next change asks again. + */ + #follow(): Promise { + return this.#settle().catch((error: unknown) => { + console.error(`the update gate refused the engine's pass: ${error instanceof Error ? error.message : String(error)}`) + }) + } + + /** + * Asks for the pass until Rust gives it, for work that must not go on without it, and resolves true once it is held + * or no work needs it. While an update installs, it asks again each second. Resolves false once `stop` aborts or + * the sidecar closes. + */ + async pass(stop?: AbortSignal): Promise { + const ended = stop === undefined ? this.#closing.signal : AbortSignal.any([stop, this.#closing.signal]) + for (;;) { + if (ended.aborted) return false + try { + await this.#settle() + return true + } catch { + await pause(PASS_RETRY_MS, ended) + } + } + } + + /** Ends every wait for the pass, as the sidecar closes. */ + close(): void { + this.#closing.abort() + } + + /** Brings Rust's pass in line with the work now. Rejects with Rust's refusal when this call asked and was refused. */ + #settle(): Promise { + const told = this.#told.then(async () => { + const busy = this.#sends > 0 || working(this.#graph, this.#parked) + if (busy === this.#held) return + await this.#host.request('busy', { busy }) + this.#held = busy + }) + this.#told = told.catch(() => {}) + return told + } + + /** + * Holds the pass while `commit` runs, so a send never starts while an update installs. Throws Rust's refusal + * without committing anything. + */ + async during(commit: () => Promise): Promise { + this.#sends += 1 + try { + await this.#settle() + return await commit() + } finally { + this.#sends -= 1 + await this.#follow() + } + } + + /** + * `during` for work that goes on by itself, such as a turn a sign-in lets go: while an update installs it asks again + * each second instead of failing, so an install that fails does not leave the work waiting. Resolves undefined, with + * nothing committed, once the sidecar closes. An install that succeeds restarts the app, and the work goes on then. + */ + async duringWhenAllowed(commit: () => Promise): Promise { + this.#sends += 1 + try { + if (!(await this.pass())) return undefined + return await commit() + } finally { + this.#sends -= 1 + await this.#follow() + } + } + + /** + * Gives the pass back while the tool `task` waits on `wait` for a sign-in. Once the wait ends, the tool goes on only + * with the pass, so an update that started installing meanwhile holds it until the app restarts or the install fails. + * A stop through `stop`, or the sidecar closing, ends that wait too. + */ + async parked(task: string, wait: () => Promise, stop: AbortSignal | undefined): Promise { + this.#parked.add(task) + void this.#follow() + let result: T + try { + result = await wait() + } catch (error) { + this.#parked.delete(task) + await this.#follow() + throw error + } + this.#parked.delete(task) + if (await this.pass(stop)) return result + // A stop ends the tool. A sidecar that closes leaves it unfinished until Pi ends it as the store closes, so it + // resumes after the restart. + stop?.throwIfAborted() + return new Promise((_, reject) => stop?.addEventListener('abort', () => reject(stop.reason), { once: true })) + } +} diff --git a/sidecar/src/chat.ts b/sidecar/src/chat.ts index 520a6199..ea259fba 100644 --- a/sidecar/src/chat.ts +++ b/sidecar/src/chat.ts @@ -1,11 +1,11 @@ /** * Chats: one Pi conversation per project. A send commits the message, the settings its turn runs with, and the - * draft's removal together. A turn that stops before its answer waits with the fix named, and continues once it is - * fixed. The window sees committed entries only. + * removal of the draft that holds the message together. A turn that stops before its answer waits with the fix named, + * and continues once it is fixed. The window sees committed entries only. */ -import { mkdtempSync, readFileSync, realpathSync, rmSync, statSync } from 'node:fs' +import { constants, copyFileSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, statSync } from 'node:fs' import { tmpdir } from 'node:os' -import { extname, isAbsolute, join, relative } from 'node:path' +import { basename, extname, join, relative } from 'node:path' import type { Context } from '@earendil-works/chord' import { awaitWithContext, BACKGROUND_CONTEXT } from '@earendil-works/chord/context' import type { ImageContent, Models, TextContent } from '@earendil-works/pi-ai' @@ -23,9 +23,10 @@ import { } from '@earendil-works/pi-durable' import type { ConversationView } from '@earendil-works/pi-durable' import { leaves, WORKFLOW, WORKFLOW_VERSION } from './agent' -import type { ChatEntry, ChatView, SidecarEvent, Turn, Waiting } from './protocol' +import type { Busy } from './busy' +import { type ChatEntry, type ChatView, type Project, type SidecarEvent, START, type Turn, type Waiting } from './protocol' import { isLoginFailure } from './inference' -import { DraftsDoc, LoginSavesDoc, SettingsDoc, type Store, TurnDoc, WaitingDoc } from './store' +import { DRAFT_CHANGED, DraftsDoc, LoginSavesDoc, SettingsDoc, type Store, TurnDoc, WaitingDoc } from './store' import { chatEntry } from './transcript' const context: Context = BACKGROUND_CONTEXT @@ -40,6 +41,7 @@ export interface ChatsOptions { extension: Extension tools: readonly ToolRegistration[] version: string + busy: Busy emit(event: SidecarEvent): void } @@ -81,11 +83,53 @@ function userContent(folder: string, text: string, attachments: string[]): strin return [{ type: 'text', text: `${text}${note}` }, ...images] } +/** + * Copies a draft's pictures into the project, as `refs/draft-/`, and answers those paths. Each copy is + * written beside its place and then renamed into it, so a send of the same revision again, as after a crash or a + * refused send, finds every finished copy and makes no second one. + */ +function copyPictures(folder: string, rev: number, sources: string[]): string[] { + if (sources.length === 0) return [] + const rel = join('refs', `draft-${rev}`) + const dir = join(folder, rel) + // The agent could have made either folder a link. Pictures land inside the project or nowhere, so each folder is + // checked before anything is made in it. + for (const path of [join(folder, 'refs'), dir]) { + mkdirSync(path, { recursive: true }) + if (leaves(relative(realpathSync.native(folder), realpathSync.native(path)))) throw new Error('refs/ leads outside the project folder') + } + const names = new Set() + return sources.map((source) => { + const clean = basename(source).replace(/[^A-Za-z0-9._-]/g, '-') + const file = clean.replace(/\./g, '') === '' ? 'picture.png' : clean + const dot = file.lastIndexOf('.') + const [stem, ext] = dot > 0 ? [file.slice(0, dot), file.slice(dot)] : [file, ''] + // A Mac volume ignores case in names, so two pictures whose names differ only in case get two names. + let name = file + for (let n = 2; names.has(name.toLowerCase()); n++) name = `${stem}-${n}${ext}` + names.add(name.toLowerCase()) + const target = join(dir, name) + if (lstatSync(target, { throwIfNoEntry: false }) === undefined) { + // A picture that is gone can't be sent. Nor can a file that is not regular, such as a pipe, which would hold the copy. + if (statSync(source, { throwIfNoEntry: false })?.isFile() !== true) { + throw new Error(`A picture in this message can't be read: ${basename(source)}. Take it out and send again.`) + } + const part = join(dir, `.${name}.part`) + rmSync(part, { force: true }) + copyFileSync(source, part, constants.COPYFILE_EXCL) + renameSync(part, target) + } + return join(rel, name) + }) +} + export class Chats { readonly #o: ChatsOptions readonly #watches = new Map>() /** The newest entry each watched chat has sent the window. */ readonly #sent = new Map() + /** The last `ChatView.seq` given. */ + #seq = 0 /** Tools waiting for a sign-in, by provider. */ readonly #signIns = new Map void>>() @@ -128,7 +172,7 @@ export class Chats { const entries = await this.#entries(conversation) this.#sent.set(project, Math.max(this.#sent.get(project) ?? 0, entries.at(-1)?.id ?? 0)) const { running, waiting, turn } = await this.#state(conversation) - return { project, entries, running, waiting, turn } + return { project, entries, running, waiting, turn, seq: this.#nextSeq() } } async #watch(project: string, conversation: Conversation): Promise { @@ -140,26 +184,41 @@ export class Chats { }) } + /** + * The order of a read of a chat's state. It grows across sidecar runs too: a sidecar that starts again after a crash + * reads after the one before it, so the window, which still holds that one's views, takes the new run's views. It + * starts from the clock in microseconds, and each read is after the last. + */ + #nextSeq(): number { + this.#seq = Math.max(this.#seq + 1, Date.now() * 1000) + return this.#seq + } + async #changed(project: string, conversation: Conversation): Promise { + // Each read of the state takes its order at once, before anything else is awaited, so a chat.open that reads + // between them is ordered by when it read too. let state = await this.#state(conversation) + let seq = this.#nextSeq() const recorded = (await this.#o.store.harness.snapshot(WaitingDoc, conversation.id, context))?.failure ?? null if (state.failure !== undefined && state.failure.reason !== 'aborted' && state.failure.taskId !== recorded) { // The waiting document is not part of the view a watch follows, so its commit wakes no one: read it here. - await this.#read(conversation, state.failure, state.turn) + // A login saved since the turn went out lets it go on now: the sign-in that saved it found no wait to let go. + if (await this.#read(conversation, state.failure, state.turn)) this.#resume(project, state.failure.taskId) state = await this.#state(conversation) + seq = this.#nextSeq() } const entries = await this.#entries(conversation, this.#sent.get(project) ?? 0) if (entries.length > 0) this.#sent.set(project, entries.at(-1)?.id ?? 0) - this.#o.emit({ type: 'chat', project, entries, running: state.running, waiting: state.waiting, turn: state.turn }) + this.#o.emit({ type: 'chat', project, entries, running: state.running, waiting: state.waiting, turn: state.turn, seq }) } /** * Reads why a run stopped: a provider with no login, or a refused one, waits for a sign-in; anything else for a retry. - * A login missing or refused when a newer one was saved since the turn went out goes on with the newer one at once, - * since the sign-in that saved it found no wait to let go. The login read here may be older than that save, so only - * the save count, read in the commit, decides. A turn from another version of the app is left to wait. + * Resolves true when the login was missing or refused but a newer one was saved since the turn went out, so the + * wait can be let go at once. The login read here may be older than that save, so only the save count, read in the + * commit, decides. A turn from another version of the app is left to wait. */ - async #read(conversation: Conversation, failure: RunFailure, turn: Turn | null): Promise { + async #read(conversation: Conversation, failure: RunFailure, turn: Turn | null): Promise { const { models, store, version } = this.#o const provider = turn?.model.provider ?? 'anthropic' const name = models.getProvider(provider)?.name ?? provider @@ -170,40 +229,45 @@ export class Chats { login ? { kind: 'login', provider, name, fix: `Sign in to ${name}. The message picks up where it stopped.` } : { kind: 'error', detail, fix: 'Try again' } - await store.harness.commit(async (tx) => { + return store.harness.commit(async (tx) => { // A send that came after this failure started a new turn, which this failure must not hold. - if ((await tx.doc(TurnDoc, conversation.id)).turn?.requestId !== turn?.requestId) return + if ((await tx.doc(TurnDoc, conversation.id)).turn?.requestId !== turn?.requestId) return false const record = await tx.doc(WaitingDoc, conversation.id) record.failure = failure.taskId record.tool = false + record.waiting = waiting // Read in this commit, so a sign-in either lands before it and is seen here, or after it and finds this wait. const saves = (await tx.doc(LoginSavesDoc, store.root.id)).saves[provider] ?? 0 - if (login && record.saves !== null && saves > record.saves && turn?.app === version) { - await conversation.submit(tx, { type: 'continue' }) - record.waiting = null - record.saves = saves - return - } - record.waiting = waiting + return login && record.saves !== null && saves > record.saves && turn?.app === version }, context) } /** - * Sends a message. The message, the settings its turn runs with, and the draft's removal commit together. A send - * whose `requestId` was already committed changes nothing, so the window can send again after any failure, even - * once a picture it sent is gone. + * Sends the draft saved as revision `rev` under `key`, a project id or `START`, and answers the project it went to. + * Each step is safe to run again after a crash or a lost answer: the project a start screen revision made is found, + * not made again, a message already committed as `draft-` is not sent again, and the pictures are copied once. + * The message, the settings its turn runs with, and the removal of the draft commit together. Only that revision's + * content goes out. A draft saved after it is the next message and stays, even one that reads the same. */ - async send(project: string, requestId: string, text: string, attachments: string[]): Promise { - const { store, extension, tools, version } = this.#o - const conversation = await this.#conversation(project) + async send(key: string, rev: number): Promise { + const { store, extension, tools, version, busy } = this.#o + // A start screen send goes out as the draft its project took, even if the project's draft changed since, as after a + // crash before the message. A project's own send goes out as its draft, only while that is still this revision. + const started = key === START ? await store.startProject(rev) : undefined + const project = started?.project ?? (await store.project(key)) + const conversation = await this.#conversation(project.id) + const requestId = `draft-${rev}` if ((await store.harness.commit((tx) => tx.submissionByRequest(conversation.id, requestId), context)) !== undefined) { - await this.#watch(project, conversation) - return + await this.#watch(project.id, conversation) + return project } - const folder = store.projectFolder(project) - const content = userContent(folder, text, attachments) + const draft = started?.draft ?? (await store.draft(project.id)) + if (draft?.rev !== rev) throw new Error(DRAFT_CHANGED) + const folder = store.projectFolder(project.id) + const attachments = copyPictures(folder, rev, draft.attachments) + const content = userContent(folder, draft.text, attachments) try { - await store.harness.commit(async (tx) => { + await busy.during(() => store.harness.commit(async (tx) => { if ((await tx.submissionByRequest(conversation.id, requestId)) !== undefined) return const settings = await tx.doc(SettingsDoc, store.root.id) const model = { provider: settings.model.provider, modelId: settings.model.modelId } @@ -213,51 +277,70 @@ export class Chats { content, requestId, whenBusy: 'reject', - entry: { kind: 'm3d.user', data: { text, images: attachments } }, + entry: { kind: 'm3d.user', data: { text: draft.text, images: attachments } }, }) await configure(tx, conversation.id, { model, thinkingLevel: thinking, extensions: [extension], tools, instructions: WORKFLOW, cwd: folder }) const turn: Turn = { requestId, model, thinking, tools: tools.map((t) => t.name), instructions: WORKFLOW_VERSION, app: version, startedMs: Date.now() } ;(await tx.doc(TurnDoc, conversation.id)).turn = turn - delete (await tx.doc(DraftsDoc, store.root.id)).drafts[project] + const { drafts } = await tx.doc(DraftsDoc, store.root.id) + if (drafts[project.id]?.rev === rev) delete drafts[project.id] const waiting = await tx.doc(WaitingDoc, conversation.id) waiting.waiting = null waiting.tool = false waiting.saves = (await tx.doc(LoginSavesDoc, store.root.id)).saves[model.provider] ?? 0 - }, context) + }, context)) } catch (error) { if (error instanceof ConversationBusy) throw new Error('The agent is still working on the last message.') throw error } - await this.#watch(project, conversation) + await this.#watch(project.id, conversation) + return project } /** * Goes on from where the turn stopped, with the model, tools, and instructions it started with, and clears what it - * waited on. The turn records the app version that goes on with it. + * waited on. The turn records the app version that goes on with it. With `login`, the run failure a sign-in wait was + * recorded for, it goes on only while the turn still waits on that one, so when two paths let one wait go, as a + * sign-in and the read of a failure that crossed it, the turn goes on once, and a late one never lets go a newer + * wait from the same refused login. Such a turn goes on by itself, so it waits for the pass while an update + * installs, instead of failing and being left waiting when the install fails. */ - async #continue(project: string): Promise { + async #continue(project: string, login?: number): Promise { const { store, version } = this.#o const conversation = await this.#conversation(project) - await store.harness.commit(async (tx) => { - await conversation.submit(tx, { type: 'continue' }) - const waiting = await tx.doc(WaitingDoc, conversation.id) - waiting.waiting = null - const { turn } = await tx.doc(TurnDoc, conversation.id) - if (turn !== null) { - turn.app = version - waiting.saves = (await tx.doc(LoginSavesDoc, store.root.id)).saves[turn.model.provider] ?? 0 - } - }, context) + const commit = () => + store.harness.commit(async (tx) => { + const waiting = await tx.doc(WaitingDoc, conversation.id) + if (login !== undefined && (waiting.waiting?.kind !== 'login' || waiting.failure !== login)) return + await conversation.submit(tx, { type: 'continue' }) + waiting.waiting = null + const { turn } = await tx.doc(TurnDoc, conversation.id) + if (turn !== null) { + turn.app = version + waiting.saves = (await tx.doc(LoginSavesDoc, store.root.id)).saves[turn.model.provider] ?? 0 + } + }, context) + if (login === undefined) await this.#o.busy.during(commit) + else await this.#o.busy.duringWhenAllowed(commit) await this.#watch(project, conversation) } + /** + * Lets a sign-in wait go, in the background: it may wait out an installing update, which must not hold up the chat's + * other changes, the start, or a quit. The wait stays recorded until the turn goes on, so a failure here leaves the + * fix shown. + */ + #resume(project: string, login: number): void { + this.#continue(project, login).catch((error: unknown) => console.error(`going on after a sign-in failed: ${String(error)}`)) + } + /** * Resolves once the person is signed in to `provider`, for a running tool: with `after`, signed in with a login saved * after that many saves, as the one then saved was refused. Until then it holds the turn where it is, with the fix * named on its chat, and goes on once they sign in. A stop ends the wait. A wait the tool held before a restart is * let go once the sign-in is there. */ - async loginFor(conversation: number, provider: string, waiting: Context, after: number | undefined): Promise { + async loginFor(conversation: number, task: string, provider: string, waiting: Context, after: number | undefined): Promise { const { models, store } = this.#o const id = conversation as ConversationId const name = models.getProvider(provider)?.name ?? provider @@ -286,7 +369,7 @@ export class Chats { await hold({ kind: 'login', provider, name, fix }) await this.#show(project) } - await awaitWithContext(promise, waiting) + await this.#o.busy.parked(task, () => awaitWithContext(promise, waiting), waiting.abortSignal) } finally { waiters.delete(resolve) } @@ -349,8 +432,8 @@ export class Chats { if (turn !== null && turn !== undefined && turn.app !== version) { await this.#outdated(conversation) await this.#show(project.id) - } else { - await this.#continue(project.id) + } else if (waiting.failure !== null) { + this.#resume(project.id, waiting.failure) } } } @@ -399,7 +482,14 @@ export class Chats { if (record === undefined || record.waiting?.kind !== 'login' || record.tool || running) continue const waiting = record.waiting if (state.turn !== null && state.turn.app !== version) await this.#outdated(conversation.id) - else if (await this.#savedSince(record, waiting.provider)) await this.#continue(project.id) + else if (record.failure !== null && (await this.#savedSince(record, waiting.provider))) this.#resume(project.id, record.failure) + } + // The window may have read a chat before this, and what was set right here, such as a wait, reaches no watch. So + // each chat's state as reconciled goes to the window, an idle chat's too. + for (const project of projects) { + const conversation = await this.#conversation(project.id) + const { running, waiting, turn } = await this.#state(conversation) + this.#o.emit({ type: 'chat', project: project.id, entries: [], running, waiting, turn, seq: this.#nextSeq() }) } } diff --git a/sidecar/src/legacy.ts b/sidecar/src/legacy.ts new file mode 100644 index 00000000..ea160901 --- /dev/null +++ b/sidecar/src/legacy.ts @@ -0,0 +1,132 @@ +/** + * Chats from before the store: each project folder kept its record in `.m3d/project.json` and its chat in + * `.m3d/transcript.jsonl`, Claude Code stream-json events plus the app's own `m3d_*` events. Each one is imported + * once, as display entries, and its files stay as they are. The old `m3d_files` cards are not kept: the Files tab lists + * every file, and the preview opens the newest. + */ +import { type Dirent, existsSync, readdirSync, readFileSync, statSync } from 'node:fs' +import { join } from 'node:path' +import type { ImportedEntry } from './protocol' + +/** How much of an old tool result the chat keeps. */ +const RESULT_LIMIT = 4_000 + +/** What a step shows when the chat ended before its result came back, as when the old app quit mid-step. */ +const CUT_OFF = 'It stopped before it finished.' + +export type LegacyProject = { id: string; title: string; createdMs: number; entries: ImportedEntry[] } + +type Block = { type: string; text?: string; id?: string; name?: string; input?: Record; tool_use_id?: string; content?: unknown; is_error?: boolean } + +const str = (value: unknown) => (typeof value === 'string' ? value : '') + +function resultText(content: unknown): string { + if (typeof content === 'string') return content + if (!Array.isArray(content)) return '' + return content.map((block: Block) => block.text ?? `[${block.type}]`).join('\n') +} + +/** What a tool step did, in a line: the description or command a shell step carried, or the path a file step used. */ +function summary(input: Record): string { + return str(input.description) || str(input.command).split('\n')[0] || str(input.file_path ?? input.path) || str(input.skill) +} + +/** The chat a transcript holds. A line it cannot read is a cut-off write and ends it, as the old app read them. */ +export function readTranscript(text: string): ImportedEntry[] { + const entries: ImportedEntry[] = [] + const tools = new Map>() + const answered = new Set() + for (const line of text.split('\n')) { + if (line.trim() === '') continue + let parsed: unknown + try { + parsed = JSON.parse(line) + } catch { + break + } + // The old app read every line as a JSON value and matched only events, so it passed over anything else. + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) continue + const event = parsed as Record + // A helper agent's inner steps stayed inside its one line. + if (event.parent_tool_use_id) continue + // Content in any form but a list of blocks, such as the plain text a few old lines held, has no steps to show. + const content = (event.message as { content?: unknown } | undefined)?.content + const blocks = Array.isArray(content) ? content.filter((block): block is Block => typeof block === 'object' && block !== null) : [] + switch (event.type) { + case 'm3d_user': + entries.push({ role: 'user', text: str(event.text), images: Array.isArray(event.images) ? event.images.map(str) : [] }) + break + case 'assistant': + for (const block of blocks) { + if (block.type === 'text' && block.text?.trim()) entries.push({ role: 'assistant', text: block.text }) + if (block.type === 'tool_use' && block.id !== undefined && block.name !== undefined) { + const tool = { role: 'tool' as const, name: block.name, text: summary(block.input ?? {}), result: '', failed: false } + tools.set(block.id, tool) + entries.push(tool) + } + } + break + case 'user': + for (const block of blocks) { + const tool = block.type === 'tool_result' && block.tool_use_id !== undefined ? tools.get(block.tool_use_id) : undefined + if (tool === undefined) continue + answered.add(block.tool_use_id ?? '') + tool.result = resultText(block.content).slice(-RESULT_LIMIT) + tool.failed = block.is_error === true + } + break + case 'm3d_turn_end': + if (event.ok === false) entries.push({ role: 'assistant', text: `The turn stopped: ${str(event.error)}` }) + break + } + } + for (const [id, tool] of tools) { + if (answered.has(id)) continue + tool.result = CUT_OFF + tool.failed = true + } + return entries +} + +/** + * Every old project in the projects folder that has a record, but those in `known`, whose files are not read. One with + * no transcript yet comes with an empty chat. A project whose files cannot be read is skipped and named on stderr, as + * the old app skipped it. `blocked` names the folder when it could not be read at all, as when macOS keeps Documents + * from the app until the person allows it. The app still starts, and the import runs again at each start and when the + * person asks, so it comes once the folder can be read. + */ +export function legacyProjects(projectsDir: string, known: ReadonlySet): { found: LegacyProject[]; blocked: string | null } { + const blocked = (error: unknown) => { + console.error(`could not list ${projectsDir} for chats from before the store: ${error instanceof Error ? error.message : String(error)}`) + return { found: [], blocked: projectsDir } + } + let dirs: Dirent[] + try { + // No projects folder, or something else in its place, holds no old chats. + if (!statSync(projectsDir).isDirectory()) return { found: [], blocked: null } + dirs = readdirSync(projectsDir, { withFileTypes: true }) + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return { found: [], blocked: null } + return blocked(error) + } + const found = dirs.flatMap((dir) => { + const meta = join(projectsDir, dir.name, '.m3d') + if (known.has(dir.name) || !dir.isDirectory() || !existsSync(join(meta, 'project.json'))) return [] + const transcript = join(meta, 'transcript.jsonl') + try { + const record = JSON.parse(readFileSync(join(meta, 'project.json'), 'utf8')) as { title?: unknown; createdMs?: unknown } + return [ + { + id: dir.name, + title: str(record.title) || dir.name, + createdMs: typeof record.createdMs === 'number' ? record.createdMs : 0, + entries: existsSync(transcript) ? readTranscript(readFileSync(transcript, 'utf8')) : [], + }, + ] + } catch (error) { + console.error(`skipped the old chat ${dir.name}, which could not be read: ${error instanceof Error ? error.message : String(error)}`) + return [] + } + }) + return { found, blocked: null } +} diff --git a/sidecar/src/methods.ts b/sidecar/src/methods.ts index 6e1beb59..a2ef3455 100644 --- a/sidecar/src/methods.ts +++ b/sidecar/src/methods.ts @@ -1,7 +1,11 @@ -import { arr, bool, compile, lit, obj, opt, str, union } from '@earendil-works/pi-schema' +import { mkdirSync, writeFileSync } from 'node:fs' +import { basename, join } from 'node:path' +import { arr, bool, compile, int, lit, obj, opt, str, union } from '@earendil-works/pi-schema' +import { toolchainNeeds } from './agent' import type { Chats } from './chat' import type { Handlers } from './host' import type { Logins } from './inference' +import type { AppState, Need } from './protocol' import type { Store } from './store' export interface Services { @@ -9,6 +13,10 @@ export interface Services { logins: Logins chats: Chats version: string + /** The import of the chats from before the store: the folder it could not read, and a way to run it again. */ + legacy: { blocked(): string | null; retry(): Promise<{ imported: number; blocked: string | null }> } + /** The folder pasted pictures are kept in until they are sent, outside every project. */ + pictures: string } const step = union([lit('welcome'), lit('check'), lit('model'), lit('printer'), lit('ready')]) @@ -18,32 +26,59 @@ const printer = obj({ machine: str(), process: str(), filament: str() }) const provider = union([lit('anthropic'), lit('openai-codex'), lit('opencode-go')]) /** Every method the window can call, each parsed at the pipe before it runs. */ -export function handlers({ store, logins, chats, version }: Services): Handlers { +export function handlers({ store, logins, chats, version, legacy, pictures }: Services): Handlers { + const state = async (): Promise => ({ ...(await store.state()), importBlocked: legacy.blocked() }) return { 'app.ping': { params: compile(obj({})), run: async () => ({ version }) }, - 'state.get': { params: compile(obj({})), run: () => store.state() }, + 'state.get': { params: compile(obj({})), run: state }, 'setup.set': { params: compile(obj({ step, done: bool() })), run: async (setup) => { await store.setSetup(setup) - return store.state() + return state() }, }, 'settings.set': { params: compile(obj({ model: opt(model), thinking: opt(thinking), printer: opt(printer) })), run: async (change) => { await store.setSettings(change) - return store.state() + return state() + }, + }, + 'setup.check': { + params: compile(obj({})), + run: async () => { + // The chosen model must be one the app offers, and its provider must have a login. A refused or expired login + // still waits in the turn, with its fix. + const { settings } = await store.state() + const offered = await logins.status() + const login = offered.find((l) => l.provider === settings.model.provider) + const needs: Need[] = + login === undefined + ? [{ kind: 'model', fix: `Choose a model from ${offered.map((l) => l.name).join(', ')}. The app does not offer ${settings.model.provider}.` }] + : login.login === null + ? [{ kind: 'login', provider: login.provider, name: login.name, fix: `Sign in to ${login.name} before you send.` }] + : [] + return [...needs, ...(await toolchainNeeds())] + }, + }, + 'legacy.import': { params: compile(obj({})), run: () => legacy.retry() }, + 'drafts.picture': { + params: compile(obj({ name: str({ minLength: 1 }), data: str() })), + run: async ({ name, data }) => { + // Only a plain file name: the picture's folder is the sidecar's, not one the window names. + if (basename(name) !== name || name.startsWith('.')) throw new Error(`${name} is not a picture's file name`) + const folder = join(pictures, crypto.randomUUID()) + mkdirSync(folder, { recursive: true }) + const path = join(folder, name) + writeFileSync(path, Buffer.from(data, 'base64')) + return path }, }, 'drafts.set': { params: compile(obj({ key: str(), text: str(), attachments: arr(str()) })), - run: async ({ key, text, attachments }) => { - await store.setDraft(key, { text, attachments }) - return null - }, + run: async ({ key, text, attachments }) => ({ rev: await store.setDraft(key, { text, attachments }) }), }, - 'projects.create': { params: compile(obj({ title: str() })), run: ({ title }) => store.createProject(title) }, 'auth.status': { params: compile(obj({})), run: () => logins.status() }, 'auth.setKey': { params: compile(obj({ provider, key: str({ minLength: 1 }), baseUrl: opt(str({ minLength: 1 })) })), @@ -75,11 +110,8 @@ export function handlers({ store, logins, chats, version }: Services): Handlers }, 'chat.open': { params: compile(obj({ project: str() })), run: ({ project }) => chats.open(project) }, 'chat.send': { - params: compile(obj({ project: str(), requestId: str({ minLength: 1 }), text: str(), attachments: arr(str()) })), - run: async ({ project, requestId, text, attachments }) => { - await chats.send(project, requestId, text, attachments) - return null - }, + params: compile(obj({ key: str({ minLength: 1 }), rev: int() })), + run: async ({ key, rev }) => ({ project: await chats.send(key, rev) }), }, 'chat.stop': { params: compile(obj({ project: str() })), diff --git a/sidecar/src/protocol.ts b/sidecar/src/protocol.ts index fa38cb51..901833df 100644 --- a/sidecar/src/protocol.ts +++ b/sidecar/src/protocol.ts @@ -22,9 +22,21 @@ export type Settings = { /** `conversation` is the project's Pi conversation id. */ export type Project = { id: string; conversation: number; title: string; createdMs: number } -/** An unsent message, by project id, or `new` for the start screen. Attachments are paths inside the project. */ +/** The draft key of the start screen, whose message has no project yet. Every other draft is keyed by its project id. */ +export const START = 'new' + +/** + * An unsent message, by project id, or `START` for the start screen. Attachments are the paths of its pictures where + * they are now: a dropped file, or a pasted picture the sidecar keeps. A send copies them into its project. + */ export type Draft = { text: string; attachments: string[] } +/** + * A draft as the store keeps it. `rev` grows with every save of any draft, and is never used again, so a send can name + * the draft it went out with, and a newer one saved meanwhile is never taken for it. + */ +export type SavedDraft = Draft & { rev: number } + /** How a provider's login was made: an API key or proxy token, or a subscription sign-in. */ export type LoginKind = 'api_key' | 'oauth' @@ -48,8 +60,15 @@ export type LoginEvent = | { type: 'login'; provider: ProviderId; step: 'done' } | { type: 'login'; provider: ProviderId; step: 'failed'; detail: string } +/** A line of a chat from before the store, imported for show: the agent does not read it. */ +export type ImportedEntry = + | { role: 'user'; text: string; images: string[] } + | { role: 'assistant'; text: string } + | { role: 'tool'; name: string; text: string; result: string; failed: boolean } + /** One committed line of a chat. Tool calls fold under their assistant line; each result is its own line. */ export type ChatEntry = + | ({ id: number; kind: 'imported' } & ImportedEntry) | { id: number; kind: 'user'; text: string; images: string[] } | { id: number; kind: 'assistant'; text: string; calls: { id: string; name: string; args: unknown }[] } | { id: number; kind: 'tool'; callId: string; name: string; text: string; failed: boolean } @@ -71,9 +90,28 @@ export type Turn = { } /** A chat as the window shows it: committed entries, and whether a turn runs, waits, or neither. */ -export type ChatView = { project: string; entries: ChatEntry[]; running: boolean; waiting: Waiting | null; turn: Turn | null } +/** + * A chat as read at one point. `seq` grows with each read of a chat's state, across sidecar runs too, so a view read + * earlier, such as a change that arrives after `chat.open` answered, never takes the place of a newer one's `running`, + * `waiting`, and `turn`. + */ +export type ChatView = { project: string; entries: ChatEntry[]; running: boolean; waiting: Waiting | null; turn: Turn | null; seq: number } + +/** What the store keeps of the app. */ +export type StoredState = { setup: Setup; settings: Settings; projects: Project[]; drafts: Record } + +/** + * The app as the window sees it. `importBlocked` names the projects folder when the chats from before the store could + * not be read from it, as when macOS keeps Documents from the app, so the window can say how to let them in. + */ +export type AppState = StoredState & { importBlocked: string | null } -export type AppState = { setup: Setup; settings: Settings; projects: Project[]; drafts: Record } +/** Something the agent needs before a message can go out, and the one thing that gives it. */ +export type Need = + | { kind: 'login'; provider: ProviderId; name: string; fix: string } + | { kind: 'model'; fix: string } + | { kind: 'uv'; fix: string } + | { kind: 'python'; fix: string } /** What the window can ask the sidecar, by method name. */ export interface Methods { @@ -81,8 +119,14 @@ export interface Methods { 'state.get': { params: Record; result: AppState } 'setup.set': { params: Setup; result: AppState } 'settings.set': { params: Partial; result: AppState } - 'drafts.set': { params: { key: string } & Draft; result: null } - 'projects.create': { params: { title: string }; result: Project } + /** Saves a draft, and answers the revision it was saved as. */ + 'drafts.set': { params: { key: string } & Draft; result: { rev: number } } + /** Keeps a pasted picture in a file of its own, outside any project, and answers its path, for a draft to keep. */ + 'drafts.picture': { params: { name: string; data: string }; result: string } + /** What the agent needs before a send: the chosen model's sign-in, uv, and a Python uv can run. Empty when ready. */ + 'setup.check': { params: Record; result: Need[] } + /** Imports the chats from before the store again, as after the person let the app read the projects folder. */ + 'legacy.import': { params: Record; result: { imported: number; blocked: string | null } } 'auth.status': { params: Record; result: Login[] } /** A key, or for Anthropic a proxy's base URL and token. */ 'auth.setKey': { params: { provider: ProviderId; key: string; baseUrl?: string }; result: Login[] } @@ -90,8 +134,12 @@ export interface Methods { 'auth.answer': { params: { provider: ProviderId; text: string }; result: null } 'auth.logout': { params: { provider: ProviderId }; result: Login[] } 'chat.open': { params: { project: string }; result: ChatView } - /** `requestId` is the window's own id for this message: sending it again never sends twice. */ - 'chat.send': { params: { project: string; requestId: string; text: string; attachments: string[] }; result: null } + /** + * Sends the draft saved as revision `rev` under `key`, and answers the project it went to. From the start screen it + * makes that project first. Sent again with the same revision, as after a lost answer, it sends nothing more and + * answers the same project. + */ + 'chat.send': { params: { key: string; rev: number }; result: { project: Project } } 'chat.stop': { params: { project: string }; result: null } 'chat.retry': { params: { project: string }; result: null } /** The tools a project's agent is offered, as Pi resolves them. */ @@ -106,6 +154,8 @@ export interface HostRequests { 'keychain.set': { params: { account: string; secret: string }; result: null } 'keychain.delete': { params: { account: string }; result: null } slice: { params: SliceParams; result: SliceResult } + /** Holds or gives back the update gate's pass. Refused while an update installs. */ + busy: { params: { busy: boolean }; result: null } } export type HostRequest = keyof HostRequests diff --git a/sidecar/src/store.ts b/sidecar/src/store.ts index 7ef1e855..c0a8dab0 100644 --- a/sidecar/src/store.ts +++ b/sidecar/src/store.ts @@ -12,7 +12,8 @@ import type { Models } from '@earendil-works/pi-ai' import { type Conversation, defineDoc, type HarnessOptions, Harness, type Registry } from '@earendil-works/pi-durable' import { openNodeSqliteStorage } from '@earendil-works/pi-durable/storage/sqlite/node' import type { LoginIndex } from './inference' -import type { AppState, Draft, LoginKind, Project, Settings, Setup, Turn, Waiting } from './protocol' +import type { LegacyProject } from './legacy' +import { type Draft, type LoginKind, type Project, type SavedDraft, type Settings, type Setup, START, type StoredState, type Turn, type Waiting } from './protocol' const context = BACKGROUND_CONTEXT @@ -46,7 +47,12 @@ export const SettingsDoc = defineDoc({ checkpointWhen: always, }) -const ProjectsDoc = defineDoc<{ projects: Project[] }>({ +/** + * The projects, and for each start screen draft revision that made one, that project and the draft it moved there, so + * a send of that revision again makes no other project and still sends what went out, even after the project's draft + * changed. + */ +const ProjectsDoc = defineDoc<{ projects: Project[]; madeFor?: Record }>({ kind: 'm3d.projects', version: 1, scope: 'conversation', @@ -56,7 +62,7 @@ const ProjectsDoc = defineDoc<{ projects: Project[] }>({ checkpointWhen: always, }) -export const DraftsDoc = defineDoc<{ drafts: Record }>({ +export const DraftsDoc = defineDoc<{ drafts: Record; seq?: number }>({ kind: 'm3d.drafts', version: 1, scope: 'conversation', @@ -118,6 +124,9 @@ const LoginsDoc = defineDoc<{ logins: Record }>({ /** The store's SQLite storage, through Pi's own async adapter, which queues every operation and transaction. */ export const openStorage = (path: string) => openNodeSqliteStorage(path) +/** Why a send names a draft revision the store no longer holds: the person changed the draft since that save. */ +export const DRAFT_CHANGED = 'The draft changed before it went out. Send it again.' + /** Another process owns the store. */ export class StoreInUse extends Error {} @@ -179,9 +188,9 @@ export class Store implements LoginIndex { } /** The app's state at one point in the store's history: the four documents are read in one commit. */ - async state(): Promise { + async state(): Promise { const id = this.root.id - const state: AppState = await this.harness.commit( + const state: StoredState = await this.harness.commit( async (tx) => // A plain copy, since the documents a commit hands out are only valid inside it. JSON.parse( @@ -210,44 +219,79 @@ export class Store implements LoginIndex { }, context) } - async setDraft(key: string, draft: Draft): Promise { + /** Saves a draft, or removes an empty one, and answers its revision. A store from before revisions starts at 1. */ + async setDraft(key: string, draft: Draft): Promise { + return this.harness.commit(async (tx) => { + const doc = await tx.doc(DraftsDoc, this.root.id) + const rev = (doc.seq ?? 0) + 1 + doc.seq = rev + if (draft.text === '' && draft.attachments.length === 0) delete doc.drafts[key] + else doc.drafts[key] = { ...draft, rev } + return rev + }, context) + } + + /** Gives each draft saved before drafts had revisions one, so a send can take it away as it takes any other. */ + async reviseOldDrafts(): Promise { await this.harness.commit(async (tx) => { - const { drafts } = await tx.doc(DraftsDoc, this.root.id) - if (draft.text === '' && draft.attachments.length === 0) delete drafts[key] - else drafts[key] = draft + const doc = await tx.doc(DraftsDoc, this.root.id) + // A store from before revisions holds drafts without one, which this read is the only place to see. + for (const draft of Object.values(doc.drafts) as (Draft & { rev?: number })[]) { + if (draft.rev !== undefined) continue + doc.seq = (doc.seq ?? 0) + 1 + draft.rev = doc.seq + } }, context) } /** - * Makes a project: its conversation, its place in the index, and the start screen's draft moved to it, in one commit. - * Its folder is made inside that commit, before anything is kept, so a folder that cannot be made keeps nothing and - * the draft stays on the start screen. A crash after the folder leaves an empty folder, which the next id skips. + * The project the start screen's draft saved as `rev` goes to. The first time, it makes the project, titled by the + * draft's first line, and moves that draft to it, in one commit. Only the draft saved as `rev` moves, so a newer + * start screen draft is never sent in its place. The folder is made inside that commit, before anything is kept, so a + * folder that cannot be made keeps nothing. A crash after the folder leaves an empty folder, which the next id skips. + * Asked again for the same revision, as after a lost answer or a crash before the message, it answers the project + * that revision made and the draft it moved, and changes nothing. */ - async createProject(title: string): Promise { - const firstLine = (title.split('\n')[0] ?? '').trim().slice(0, 80) || 'New part' + async startProject(rev: number): Promise<{ project: Project; draft: SavedDraft }> { const now = new Date() // The person's own date, as the folder name shows it. const today = new Date(now.getTime() - now.getTimezoneOffset() * 60_000).toISOString().slice(0, 10) - const base = `${today}-${slug(firstLine)}` - const project = await this.harness.commit(async (tx) => { + return this.harness.commit(async (tx) => { const index = await tx.doc(ProjectsDoc, this.root.id) + const before = index.madeFor?.[rev] + const made = index.projects.find((p) => p.id === before?.project) + // A plain copy, since the documents a commit hands out are only valid inside it. + if (before !== undefined && made !== undefined) return JSON.parse(JSON.stringify({ project: made, draft: before.draft })) + const { drafts } = await tx.doc(DraftsDoc, this.root.id) + const draft = drafts[START] + if (draft?.rev !== rev) throw new Error(DRAFT_CHANGED) + const title = (draft.text.split('\n')[0] ?? '').trim().slice(0, 80) || 'New part' + const base = `${today}-${slug(title)}` const taken = new Set(index.projects.map((p) => p.id)) let id = base // A folder already on disk, which the store may not list, is never taken over. for (let n = 2; taken.has(id) || existsSync(join(this.projectsDir, id)); n++) id = `${base}-${n}` this.projectFolder(id) const conversation = await tx.createConversation({ ownership: { kind: 'ownerless' } }) - const created: Project = { id, conversation: conversation.id, title: firstLine, createdMs: now.getTime() } + const created: Project = { id, conversation: conversation.id, title, createdMs: now.getTime() } index.projects.push(created) - const { drafts } = await tx.doc(DraftsDoc, this.root.id) - const draft = drafts.new - if (draft !== undefined) { - drafts[id] = draft - delete drafts.new - } - return created + const moved: SavedDraft = { text: draft.text, attachments: [...draft.attachments], rev } + index.madeFor = { ...index.madeFor, [rev]: { project: id, draft: moved } } + drafts[id] = draft + delete drafts[START] + return JSON.parse(JSON.stringify({ project: created, draft: moved })) }, context) - return project + } + + /** The drafts start screen sends moved to their projects, which a send of the same revision again still sends. */ + async startedDrafts(): Promise { + const index = await this.harness.snapshot(ProjectsDoc, this.root.id, context) + return Object.values(index?.madeFor ?? {}).map(({ draft }) => draft) + } + + /** The draft saved under `key`, a project id or `START`. */ + async draft(key: string): Promise { + return (await this.harness.snapshot(DraftsDoc, this.root.id, context))?.drafts[key] } async projects(): Promise { @@ -287,6 +331,26 @@ export class Store implements LoginIndex { return (await this.harness.snapshot(LoginSavesDoc, this.root.id, context))?.saves[provider] ?? 0 } + /** + * Imports chats from before the store, each with its conversation and its place in the index in one commit. A + * project already in the index was imported before, so each is imported once. + */ + async importLegacy(legacy: LegacyProject[]): Promise { + const imported: string[] = [] + for (const old of legacy) { + const done = await this.harness.commit(async (tx) => { + const index = await tx.doc(ProjectsDoc, this.root.id) + if (index.projects.some((p) => p.id === old.id)) return false + const conversation = await tx.createConversation({ ownership: { kind: 'ownerless' } }) + for (const data of old.entries) await tx.appendEntry(conversation.id, { kind: 'm3d.imported', data }) + index.projects.push({ id: old.id, conversation: conversation.id, title: old.title, createdMs: old.createdMs }) + return true + }, context) + if (done) imported.push(old.id) + } + return imported + } + /** The project's folder, made if it is missing. */ projectFolder(id: string): string { const dir = join(this.projectsDir, id) diff --git a/sidecar/src/transcript.ts b/sidecar/src/transcript.ts index d2dc46ba..39eb0b94 100644 --- a/sidecar/src/transcript.ts +++ b/sidecar/src/transcript.ts @@ -3,7 +3,7 @@ * nothing on screen can vanish after a crash. */ import type { EntryRecord } from '@earendil-works/pi-durable' -import type { ChatEntry } from './protocol' +import type { ChatEntry, ImportedEntry } from './protocol' type Content = string | readonly ({ type: string; text?: string } | { type: 'toolCall'; id: string; name: string; arguments: unknown })[] @@ -18,6 +18,8 @@ export function textOf(content: Content | undefined): string { export function chatEntry(entry: EntryRecord): ChatEntry | undefined { const message = entry.model?.[0] switch (entry.kind) { + case 'm3d.imported': + return { id: entry.id, kind: 'imported', ...(entry.data as ImportedEntry) } case 'm3d.user': { const data = entry.data as { text: string; images: string[] } return { id: entry.id, kind: 'user', text: data.text, images: data.images } diff --git a/sidecar/test/agent.test.ts b/sidecar/test/agent.test.ts index d03d26f9..c69a87ba 100644 --- a/sidecar/test/agent.test.ts +++ b/sidecar/test/agent.test.ts @@ -21,11 +21,10 @@ function start(where: ReturnType, script: string): App { return spawnSidecar(where.data, where.projects, new Map(), { script, env: where.env }) } -async function newProject(app: App, modelId = 'faux-a'): Promise { - await app.call('settings.set', { model: { provider: 'faux', modelId } }) - const { id } = (await app.call('projects.create', { title: 'A 20 mm cube' })) as { id: string } - await app.call('chat.open', { project: id }) - return id +/** Sends a first message from the start screen, which makes its project, and answers that project. */ +async function firstSend(app: App, text: string): Promise { + await app.call('settings.set', { model: { provider: 'faux', modelId: 'faux-a' } }) + return (await app.sendDraft('new', text)).project } const hasAnswer = (entries: ChatEntry[], text: string) => entries.some((entry) => entry.kind === 'assistant' && entry.text === text) @@ -45,22 +44,21 @@ async function waitForFile(path: string, ms = 15_000) { } } -const send = (app: App, project: string, requestId: string, text: string) => app.call('chat.send', { project, requestId, text, attachments: [] }) +const saveDraft = async (app: App, key: string, text: string) => + ((await app.call('drafts.set', { key, text, attachments: [] })) as { rev: number }).rev describe('a send', () => { test('commits the message, the settings its turn runs with, and the draft removal together, and only once', async () => { const where = scratch() const app = start(where, 'models') await app.next() - const project = await newProject(app) - await app.call('drafts.set', { key: project, text: 'A 20 mm cube', attachments: [] }) - - await send(app, project, 'r1', 'A 20 mm cube') + await app.call('settings.set', { model: { provider: 'faux', modelId: 'faux-a' } }) + const { project, rev } = await app.sendDraft('new', 'A 20 mm cube') expect(((await app.call('state.get')) as { drafts: object }).drafts).toEqual({}) const view = (await app.call('chat.open', { project })) as ChatView expect(view.entries[0]).toMatchObject({ kind: 'user', text: 'A 20 mm cube' }) expect(view.turn).toMatchObject({ - requestId: 'r1', + requestId: `draft-${rev}`, model: { provider: 'faux', modelId: 'faux-a' }, thinking: 'high', tools: ['read', 'write', 'edit', 'run', 'slice', 'astra'], @@ -68,16 +66,16 @@ describe('a send', () => { }) await answered(app, project, 'answered by faux-a') - await send(app, project, 'r1', 'A 20 mm cube') + await app.call('chat.send', { key: project, rev }) const again = (await app.call('chat.open', { project })) as ChatView expect(again.entries.filter((entry) => entry.kind === 'user')).toHaveLength(1) // A send while a turn runs is refused whole: no message, and the draft stays. - await send(app, project, 'r2', 'please wait') + await app.sendDraft(project, 'please wait') await waitForFile(join(where.projects, project, 'started')) - await app.call('drafts.set', { key: project, text: 'make it red', attachments: [] }) - await expect(send(app, project, 'r3', 'make it red')).rejects.toThrow('The agent is still working on the last message.') - expect(((await app.call('state.get')) as { drafts: Record }).drafts[project]).toEqual({ text: 'make it red', attachments: [] }) + const red = await saveDraft(app, project, 'make it red') + await expect(app.call('chat.send', { key: project, rev: red })).rejects.toThrow('The agent is still working on the last message.') + expect(((await app.call('state.get')) as { drafts: Record }).drafts[project]).toEqual({ text: 'make it red', attachments: [], rev: red }) const refused = (await app.call('chat.open', { project })) as ChatView expect(refused.entries.filter((entry) => entry.kind === 'user').map((entry) => entry.kind === 'user' && entry.text)).toEqual(['A 20 mm cube', 'please wait']) writeFileSync(join(where.projects, project, 'go'), '') @@ -86,16 +84,33 @@ describe('a send', () => { }) }) +describe('a restart', () => { + test("sends each chat's state once it has reconciled, even a chat that is idle", async () => { + const where = scratch() + const first = start(where, 'models') + await first.next() + const project = await firstSend(first, 'hello') + await first.until((event) => event.type === 'chat' && event.project === project && event.running === false && (event.entries as ChatEntry[]).some((e) => e.kind === 'assistant'), 20_000) + first.child.kill('SIGKILL') + await first.child.exited + const reopened = start(where, 'models') + await reopened.next() + const state = await reopened.until((event) => event.type === 'chat' && event.project === project, 5_000) + expect(state.running).toBe(false) + await reopened.quit() + }, 30_000) +}) + describe('a crash mid-turn', () => { test('the reopened sidecar finishes the turn and never repeats a finished tool call', async () => { const where = scratch() const first = start(where, 'ops') await first.next() - const project = await newProject(first) - await send(first, project, 'r1', 'Build it') + const project = await firstSend(first, 'Build it') const log = join(where.projects, project, 'ops.log') await waitForFile(log) while (!readFileSync(log, 'utf8').includes('op2')) await Bun.sleep(25) + const before = ((await first.call('chat.open', { project })) as ChatView).seq first.child.kill('SIGKILL') await first.child.exited // The watch on op2 ends it within a second of the crash, before `go` would let it finish. @@ -103,7 +118,11 @@ describe('a crash mid-turn', () => { const reopened = start(where, 'ops') await reopened.next() - await reopened.call('chat.open', { project }) + // The window still holds the views the crashed sidecar sent, so the new one's must come after them, and the + // reopened sidecar sends each chat's state once it has reconciled, as the window may have read it before that. + const reconciled = await reopened.until((event) => event.type === 'chat' && event.project === project) + expect(reconciled.seq).toBeGreaterThan(before) + expect(((await reopened.call('chat.open', { project })) as ChatView).seq).toBeGreaterThan(before) writeFileSync(join(where.projects, project, 'go'), '') await answered(reopened, project, 'done') @@ -121,15 +140,14 @@ describe('settings', () => { const where = scratch() const app = start(where, 'models') await app.next() - const project = await newProject(app, 'faux-a') - await send(app, project, 'r1', 'please wait') + const project = await firstSend(app, 'please wait') await waitForFile(join(where.projects, project, 'started')) await app.call('settings.set', { model: { provider: 'faux', modelId: 'faux-b' } }) writeFileSync(join(where.projects, project, 'go'), '') await answered(app, project, 'answered by faux-a') expect(((await app.call('chat.open', { project })) as ChatView).turn?.model.modelId).toBe('faux-a') - await send(app, project, 'r2', 'and now?') + await app.sendDraft(project, 'and now?') await answered(app, project, 'answered by faux-b') expect(((await app.call('chat.open', { project })) as ChatView).turn?.model.modelId).toBe('faux-b') await app.quit() @@ -141,9 +159,8 @@ describe('the agent', () => { const where = scratch() const app = start(where, 'escape') await app.next() - const project = await newProject(app) + const project = await firstSend(app, 'read the hostname') expect(await app.call('agent.tools', { project })).toEqual(['read', 'write', 'edit', 'run', 'slice', 'astra']) - await send(app, project, 'r1', 'read the hostname') await answered(app, project, 'tried') const view = (await app.call('chat.open', { project })) as ChatView const read = view.entries.find((entry) => entry.kind === 'tool') diff --git a/sidecar/test/fixtures/legacy/2026-11-06-a-wall-clip/.m3d/project.json b/sidecar/test/fixtures/legacy/2026-11-06-a-wall-clip/.m3d/project.json new file mode 100644 index 00000000..553a01c3 --- /dev/null +++ b/sidecar/test/fixtures/legacy/2026-11-06-a-wall-clip/.m3d/project.json @@ -0,0 +1,6 @@ +{ + "title": "A wall clip for my cable", + "createdMs": 1794000000000, + "sessionId": "00000000-0000-4000-8000-000000000000", + "lean": true +} diff --git a/sidecar/test/fixtures/legacy/2026-11-06-a-wall-clip/.m3d/transcript.jsonl b/sidecar/test/fixtures/legacy/2026-11-06-a-wall-clip/.m3d/transcript.jsonl new file mode 100644 index 00000000..a9189ea3 --- /dev/null +++ b/sidecar/test/fixtures/legacy/2026-11-06-a-wall-clip/.m3d/transcript.jsonl @@ -0,0 +1,11 @@ +{"type":"m3d_user","text":"A wall clip for my cable","images":["refs/cable.jpg"]} +{"type":"system","subtype":"init","session_id":"s"} +{"type":"assistant","message":{"content":[{"type":"thinking","thinking":"..."}]}} +{"type":"assistant","message":{"content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"uv run build/model.py","description":"Build the clip"}}]}} +{"type":"assistant","parent_tool_use_id":"t1","message":{"content":[{"type":"text","text":"a helper's inner step"}]}} +{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"t1","content":"clip.stl written","is_error":false}]}} +{"type":"assistant","message":{"content":[{"type":"text","text":"The clip is in clip.stl."}]}} +{"type":"m3d_files","files":["clip.stl"]} +{"type":"m3d_turn_end","ok":true} +{"type":"m3d_user","text":"Make it thicker","images":[]} +{"type":"m3d_turn_end","ok":false,"error":"This turn stopped when the app closed."} diff --git a/sidecar/test/fixtures/scripted-sidecar.ts b/sidecar/test/fixtures/scripted-sidecar.ts index 0001e5f2..c6f8d237 100644 --- a/sidecar/test/fixtures/scripted-sidecar.ts +++ b/sidecar/test/fixtures/scripted-sidecar.ts @@ -44,6 +44,14 @@ const scripts: Record) => Ass if (results.length === 0) return call('astra', { task: 'Draw it as an HTML page.' }) return fauxAssistantMessage(fauxText(`parent saw: ${resultText(results[0])}`)) }, + /** Reads a file and hands Astra a task in the same step, then repeats what Astra answered. */ + 'astra-and-read': (messages) => { + const { results } = sinceUser(messages) + if (results.length === 0) { + return fauxAssistantMessage([fauxToolCall('read', { path: 'note.txt' }), fauxToolCall('astra', { task: 'Draw it as an HTML page.' })], { stopReason: 'toolUse' }) + } + return fauxAssistantMessage(fauxText('both done')) + }, /** Shows the command's environment: whether the sidecar's own variables reach it, and its operation id. */ env: (messages) => { const { results } = sinceUser(messages) @@ -102,7 +110,12 @@ await runSidecar({ argv: process.argv.slice(2), addProviders: (models) => { const faux = fauxProvider({ provider: 'faux', models: [{ id: 'faux-a' }, { id: 'faux-b' }] }) - faux.setResponses(Array.from({ length: 200 }, () => (context, _options, _state, model) => script(context.messages, model))) + // Every script answers `make the project` with `made`, so a test can make a project before its first real message. + faux.setResponses( + Array.from({ length: 200 }, () => (context, _options, _state, model) => + sinceUser(context.messages).text === 'make the project' ? fauxAssistantMessage(fauxText('made')) : script(context.messages, model), + ), + ) models.setProvider(faux.provider) const astra = fauxProvider({ provider: 'openai-codex', models: [{ id: 'gpt-6-astra' }] }) // Astra runs a command of its own first, in the project folder it shares. The provider refuses the key `refused`, diff --git a/sidecar/test/send.test.ts b/sidecar/test/send.test.ts new file mode 100644 index 00000000..f8354f60 --- /dev/null +++ b/sidecar/test/send.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, test } from 'bun:test' +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createModels, InMemoryCredentialStore } from '@earendil-works/pi-ai' +import { createRegistry } from '@earendil-works/pi-durable' +import type { ChatEntry, ChatView, SavedDraft, StoredState } from '../src/protocol' +import { Store } from '../src/store' +import { spawnSidecar } from './sidecar' + +type App = ReturnType + +function scratch() { + const dir = mkdtempSync(join(tmpdir(), 'm3d-send-')) + const bin = join(dir, 'bin') + mkdirSync(bin) + writeFileSync(join(bin, 'uv'), '#!/bin/sh\n[ "$1" = run ] && shift\nexec "$@"\n') + chmodSync(join(bin, 'uv'), 0o755) + return { dir, data: join(dir, 'state'), projects: join(dir, 'projects'), env: { PATH: `${bin}:${process.env.PATH}` } } +} + +async function start(where: ReturnType): Promise { + const app = spawnSidecar(where.data, where.projects, new Map(), { script: 'models', env: where.env }) + await app.next() + await app.call('settings.set', { model: { provider: 'faux', modelId: 'faux-a' } }) + return app +} + +const state = async (app: App) => (await app.call('state.get')) as StoredState +const drafts = async (app: App): Promise> => (await state(app)).drafts +const save = async (app: App, key: string, text: string, attachments: string[] = []) => + ((await app.call('drafts.set', { key, text, attachments })) as { rev: number }).rev +const send = async (app: App, key: string, rev: number) => ((await app.call('chat.send', { key, rev })) as { project: { id: string; title: string } }).project +const asked = async (app: App, project: string) => + ((await app.call('chat.open', { project })) as ChatView).entries.flatMap((entry) => (entry.kind === 'user' ? [entry] : [])) + +/** Waits until the chat holds this answer, already committed or in a later change. */ +async function answered(app: App, project: string, text: string) { + const has = (entries: ChatEntry[]) => entries.some((entry) => entry.kind === 'assistant' && entry.text === text) + if (has(((await app.call('chat.open', { project })) as ChatView).entries)) return + await app.until((event) => event.type === 'chat' && event.project === project && has(event.entries as ChatEntry[]), 30_000) +} + +describe('a send from the start screen', () => { + test('cut off after its project was made, goes on with what it went out as, and leaves a newer project draft alone', async () => { + const where = scratch() + const first = await start(where) + const rev = await save(first, 'new', 'A knob') + await first.quit() + // The sidecar stopped right after it made the project and moved the draft there, before the message. + const store = await Store.open({ + dataDir: where.data, + projectsDir: where.projects, + models: createModels({ credentials: new InMemoryCredentialStore() }), + registry: createRegistry(), + env: () => { + throw new Error('no conversation runs here') + }, + }) + const { project: made } = await store.startProject(rev) + await store.close() + const app = await start(where) + // The person found the project and saved a newer draft there before sending from the start screen again. + const newer = await save(app, made.id, 'A knob, in black') + expect((await send(app, 'new', rev)).id).toBe(made.id) + expect((await asked(app, made.id)).map((entry) => entry.text)).toEqual(['A knob']) + expect((await drafts(app))[made.id]).toEqual({ text: 'A knob, in black', attachments: [], rev: newer }) + await answered(app, made.id, 'answered by faux-a') + await app.quit() + }, 30_000) + + test('makes the project, sends the draft, and leaves no draft behind', async () => { + const where = scratch() + const app = await start(where) + const rev = await save(app, 'new', 'A knob\nfor a 6 mm shaft') + const project = await send(app, 'new', rev) + expect(project.title).toBe('A knob') + expect(await drafts(app)).toEqual({}) + expect((await asked(app, project.id)).map((entry) => entry.text)).toEqual(['A knob\nfor a 6 mm shaft']) + await app.quit() + }) + + test('moves only the draft it went out as: a newer start draft is never sent in its place, and stays', async () => { + const where = scratch() + const app = await start(where) + const older = await save(app, 'new', 'A knob') + const newer = await save(app, 'new', 'A dial') + await expect(send(app, 'new', older)).rejects.toThrow('The draft changed before it went out. Send it again.') + expect(await state(app)).toMatchObject({ projects: [], drafts: { new: { text: 'A dial', rev: newer } } }) + + const project = await send(app, 'new', newer) + const latest = await save(app, 'new', 'A shelf bracket') + // Sent again, as after a lost answer, it changes nothing: the newer start draft stays where it is. + expect(await send(app, 'new', newer)).toEqual(project) + expect(await drafts(app)).toEqual({ new: { text: 'A shelf bracket', attachments: [], rev: latest } }) + expect((await asked(app, project.id)).map((entry) => entry.text)).toEqual(['A dial']) + await app.quit() + }) + + test("sent again after a lost answer never touches the project's newer draft", async () => { + const where = scratch() + const app = await start(where) + const rev = await save(app, 'new', 'A knob') + const project = await send(app, 'new', rev) + await answered(app, project.id, 'answered by faux-a') + const newer = await save(app, project.id, 'Make it thinner') + expect(await send(app, 'new', rev)).toEqual(project) + expect(await drafts(app)).toEqual({ [project.id]: { text: 'Make it thinner', attachments: [], rev: newer } }) + expect(await asked(app, project.id)).toHaveLength(1) + expect((await state(app)).projects).toHaveLength(1) + await app.quit() + }) + + test('of only a picture goes to one project once, even sent again after the picture is gone', async () => { + const where = scratch() + const app = await start(where) + const photo = join(where.dir, 'photo.jpg') + writeFileSync(photo, 'jpg') + const rev = await save(app, 'new', '', [photo]) + const project = await send(app, 'new', rev) + expect(project.title).toBe('New part') + rmSync(photo) + expect(await send(app, 'new', rev)).toEqual(project) + expect((await state(app)).projects).toHaveLength(1) + const sent = await asked(app, project.id) + expect(sent).toHaveLength(1) + expect(sent[0]?.images).toEqual([`refs/draft-${rev}/photo.jpg`]) + expect(readdirSync(join(where.projects, project.id, 'refs', `draft-${rev}`))).toEqual(['photo.jpg']) + await app.quit() + }) + + test('of only a picture leaves the start screen empty, after a relaunch too', async () => { + const where = scratch() + const first = await start(where) + const photo = join(where.dir, 'photo.jpg') + writeFileSync(photo, 'jpg') + const rev = await save(first, 'new', '', [photo]) + const project = await send(first, 'new', rev) + expect(await drafts(first)).toEqual({}) + await first.quit() + + const second = await start(where) + expect(await state(second)).toMatchObject({ projects: [{ id: project.id }], drafts: {} }) + await second.quit() + }) +}) + +describe('a send from a project', () => { + test('of a revision that is no longer the draft sends nothing', async () => { + const where = scratch() + const app = await start(where) + const { project } = await app.sendDraft('new', 'A knob') + await answered(app, project, 'answered by faux-a') + const older = await save(app, project, 'Make it red') + const newer = await save(app, project, 'Make it blue') + await expect(send(app, project, older)).rejects.toThrow('The draft changed before it went out. Send it again.') + expect(await asked(app, project)).toHaveLength(1) + expect((await drafts(app))[project]).toEqual({ text: 'Make it blue', attachments: [], rev: newer }) + await app.quit() + }) + + test('with a picture that is gone fails and keeps the draft', async () => { + const where = scratch() + const app = await start(where) + const { project } = await app.sendDraft('new', 'A knob') + await answered(app, project, 'answered by faux-a') + const rev = await save(app, project, 'Like this', [join(where.dir, 'gone.jpg')]) + await expect(send(app, project, rev)).rejects.toThrow("A picture in this message can't be read: gone.jpg. Take it out and send again.") + expect(await asked(app, project)).toHaveLength(1) + expect((await drafts(app))[project]?.rev).toBe(rev) + await app.quit() + }) + + test('gives two pictures whose names differ only in case two copies, as a Mac volume holds them in one name', async () => { + const where = scratch() + const app = await start(where) + const { project } = await app.sendDraft('new', 'A knob') + await answered(app, project, 'answered by faux-a') + for (const folder of ['a', 'b']) mkdirSync(join(where.dir, folder)) + writeFileSync(join(where.dir, 'a', 'Photo.jpg'), 'first') + writeFileSync(join(where.dir, 'b', 'photo.jpg'), 'second') + const rev = await save(app, project, 'Like these', [join(where.dir, 'a', 'Photo.jpg'), join(where.dir, 'b', 'photo.jpg')]) + await send(app, project, rev) + const images = (await asked(app, project))[1]?.images ?? [] + expect(images).toHaveLength(2) + expect(new Set(images.map((image) => image.toLowerCase())).size).toBe(2) + await app.quit() + }, 30_000) + + test('copies its pictures once per revision, even when the first try was refused after the copy', async () => { + const where = scratch() + const app = await start(where) + const { project } = await app.sendDraft('new', 'please wait') + while (!existsSync(join(where.projects, project, 'started'))) await Bun.sleep(25) + const photo = join(where.dir, 'photo.jpg') + writeFileSync(photo, 'jpg') + const rev = await save(app, project, 'Like this', [photo, join(where.dir, 'photo.jpg')]) + await expect(send(app, project, rev)).rejects.toThrow('The agent is still working on the last message.') + writeFileSync(join(where.projects, project, 'go'), '') + await answered(app, project, 'answered by faux-a') + await send(app, project, rev) + expect(readdirSync(join(where.projects, project, 'refs', `draft-${rev}`)).sort()).toEqual(['photo-2.jpg', 'photo.jpg']) + expect((await asked(app, project))[1]?.images).toEqual([`refs/draft-${rev}/photo.jpg`, `refs/draft-${rev}/photo-2.jpg`]) + expect((await drafts(app))[project]).toBeUndefined() + await app.quit() + }, 30_000) +}) diff --git a/sidecar/test/sidecar.ts b/sidecar/test/sidecar.ts index 5f5a5d97..b7e4f8c1 100644 --- a/sidecar/test/sidecar.ts +++ b/sidecar/test/sidecar.ts @@ -2,18 +2,20 @@ type Message = Record /** * The sidecar as Rust runs it: a child process spoken to over stdin and stdout, one JSON object per line. The test - * plays Rust. Like Rust, it reads stdout all the time, since the sidecar stops when no one drains it, and it answers - * the sidecar's Keychain requests from `keychain`, which stands in for the macOS Keychain. `gate` may hold a request's - * answer, read when the request came, until it calls `answer`, as a slow Keychain does. + * plays Rust. Like Rust, it reads stdout all the time, since the sidecar stops when no one drains it. It answers the + * sidecar's Keychain requests from `keychain`, which stands in for the macOS Keychain, and its update-gate requests, + * recording each in `gate` and refusing the pass while `gate.installing` is set, as an installing update does. `hold` + * may hold a request's answer, read when the request came, until it calls `answer`, as a slow Keychain does. */ export function spawnSidecar( dataDir: string, projectsDir: string, keychain = new Map(), - options: { script?: string; env?: Record; version?: string; gate?: (request: Message, answer: () => void) => void } = {}, + options: { script?: string; env?: Record; version?: string; hold?: (request: Message, answer: () => void) => void } = {}, ) { const entry = options.script === undefined ? 'src/main.ts' : 'test/fixtures/scripted-sidecar.ts' - const child = Bun.spawn(['bun', entry, '--app-version', options.version ?? '9.9.9', '--data', dataDir, '--projects', projectsDir], { + // The bun running the tests, as a test may give the sidecar a PATH without one. + const child = Bun.spawn([process.execPath, entry, '--app-version', options.version ?? '9.9.9', '--data', dataDir, '--projects', projectsDir], { cwd: `${import.meta.dir}/..`, env: { ...process.env, ...options.env, ...(options.script === undefined ? {} : { M3D_SCRIPT: options.script }) }, stdin: 'pipe', @@ -21,9 +23,20 @@ export function spawnSidecar( stderr: 'pipe', }) const send = (message: unknown) => child.stdin.write(`${JSON.stringify(message)}\n`) + const gate = { installing: false, requests: [] as boolean[] } - /** Reads the Keychain now, and returns how to send that answer, now or later. */ + /** Reads the Keychain or the gate now, and returns how to send that answer, now or later. */ function answer(request: Message): () => void { + if (request.method === 'busy') { + const { busy } = request.params as { busy: boolean } + gate.requests.push(busy) + return () => + send( + busy && gate.installing + ? { kind: 'answer', id: request.id, ok: false, error: 'An update is installing.' } + : { kind: 'answer', id: request.id, ok: true, value: null }, + ) + } const params = request.params as { account: string; secret?: string } const value = request.method === 'keychain.get' @@ -53,8 +66,8 @@ export function spawnSidecar( buffered = buffered.slice(end + 1) if (message.kind === 'request') { const reply = answer(message) - if (options.gate === undefined) reply() - else options.gate(message, reply) + if (options.hold === undefined) reply() + else options.hold(message, reply) } else inbox.push(message) } @@ -95,6 +108,15 @@ export function spawnSidecar( return message.value } } + /** + * Saves a screen's draft and sends it, as the window does: `key` is a project id, or `new` for the start screen. + * Answers the project it went to and the draft revision it went out as. + */ + const sendDraft = async (key: string, text: string, attachments: string[] = []) => { + const { rev } = (await call('drafts.set', { key, text, attachments })) as { rev: number } + const { project } = (await call('chat.send', { key, rev })) as { project: { id: string } } + return { project: project.id, rev } + } /** Reads events until one matches, or fails after `ms`. */ const until = async (match: (event: Message) => boolean, ms = 15_000) => { const deadline = Date.now() + ms @@ -115,5 +137,5 @@ export function spawnSidecar( await stderrDone return errors } - return { child, next, send, call, until, quit, stderr } + return { child, next, send, call, sendDraft, until, quit, stderr, gate } } diff --git a/sidecar/test/store.test.ts b/sidecar/test/store.test.ts index 6aed12f5..6aefab1a 100644 --- a/sidecar/test/store.test.ts +++ b/sidecar/test/store.test.ts @@ -3,9 +3,11 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node: import { tmpdir } from 'node:os' import { join } from 'node:path' import { BACKGROUND_CONTEXT } from '@earendil-works/chord/context' +import { createModels, InMemoryCredentialStore } from '@earendil-works/pi-ai' +import { createRegistry } from '@earendil-works/pi-durable' import { registerStorageConformance } from '@earendil-works/pi-durable/testing' import { EXIT } from '../src/protocol' -import { openStorage } from '../src/store' +import { DraftsDoc, openStorage, Store } from '../src/store' import { spawnSidecar } from './sidecar' function scratch() { @@ -48,17 +50,16 @@ describe('app state', () => { const { data, projects } = scratch() const app = spawnSidecar(data, projects) await app.next() - await app.call('drafts.set', { key: 'new', text: 'A wall mount for my dock', attachments: [] }) await app.call('setup.set', { step: 'printer', done: false }) await app.call('settings.set', { model: { provider: 'openai-codex', modelId: 'gpt-6-astra' } }) - const project = (await app.call('projects.create', { title: 'A wall mount for my dock\nfrom a photo' })) as { id: string } - await app.call('drafts.set', { key: project.id, text: 'Make it 2 mm thicker', attachments: ['refs/dock.jpg'] }) + const project = { id: (await app.sendDraft('new', 'A wall mount for my dock\nfrom a photo')).project } + await app.call('drafts.set', { key: project.id, text: 'Make it 2 mm thicker', attachments: ['/Users/me/dock.jpg'] }) const before = await app.call('state.get') expect(before).toMatchObject({ setup: { step: 'printer', done: false }, settings: { model: { provider: 'openai-codex', modelId: 'gpt-6-astra' }, thinking: 'high' }, projects: [{ id: project.id, title: 'A wall mount for my dock' }], - drafts: { [project.id]: { text: 'Make it 2 mm thicker', attachments: ['refs/dock.jpg'] } }, + drafts: { [project.id]: { text: 'Make it 2 mm thicker', attachments: ['/Users/me/dock.jpg'] } }, }) expect(existsSync(join(projects, project.id))).toBe(true) expect(await app.quit()).toBe(EXIT.done) @@ -81,22 +82,24 @@ describe('app state', () => { const app = spawnSidecar(data, projects) await app.next() for (let round = 0; round < 20; round++) { - await app.call('drafts.set', { key: 'new', text: `Knob ${round}`, attachments: [] }) - // The read goes out right behind the commit that moves the draft, without waiting for it. - app.send({ kind: 'call', id: 1000 + round, method: 'projects.create', params: { title: `Knob ${round}` } }) + const { rev } = (await app.call('drafts.set', { key: 'new', text: `Knob ${round}`, attachments: [] })) as { rev: number } + // The read goes out right behind the send, whose first commit makes the project and moves the draft, without + // waiting for it. + app.send({ kind: 'call', id: 1000 + round, method: 'chat.send', params: { key: 'new', rev } }) app.send({ kind: 'call', id: 2000 + round, method: 'state.get', params: {} }) const replies = new Map() while (replies.size < 2) { const message = await app.next() if (message?.kind === 'reply') replies.set(message.id as number, message.value) } - const { id } = replies.get(1000 + round) as { id: string } + const { id } = (replies.get(1000 + round) as { project: { id: string } }).project const state = replies.get(2000 + round) as { projects: { id: string }[]; drafts: Record } const made = state.projects.some((p) => p.id === id) - // Before the commit: no project, the draft on the start screen. After it: the project holds the draft. - expect({ made, onProject: state.drafts[id]?.text, onStart: state.drafts.new?.text }).toEqual( - made ? { made, onProject: `Knob ${round}`, onStart: undefined } : { made, onProject: undefined, onStart: `Knob ${round}` }, - ) + // Before that commit: no project, the draft on the start screen. After it: the project holds the draft until the + // message goes out, and the start screen holds none. + expect(made ? state.drafts.new : state.drafts.new?.text).toEqual(made ? undefined : `Knob ${round}`) + if (made) expect([undefined, `Knob ${round}`]).toContain(state.drafts[id]?.text) + else expect(state.drafts[id]).toBeUndefined() } await app.quit() }) @@ -107,8 +110,7 @@ describe('app state', () => { writeFileSync(projects, '') const app = spawnSidecar(data, projects) await app.next() - await app.call('drafts.set', { key: 'new', text: 'A knob', attachments: [] }) - await expect(app.call('projects.create', { title: 'A knob' })).rejects.toThrow() + await expect(app.sendDraft('new', 'A knob')).rejects.toThrow() expect(await app.call('state.get')).toMatchObject({ projects: [], drafts: { new: { text: 'A knob', attachments: [] } } }) await app.quit() }) @@ -117,21 +119,37 @@ describe('app state', () => { const { data, projects } = scratch() const app = spawnSidecar(data, projects) await app.next() - const first = (await app.call('projects.create', { title: 'A knob' })) as { id: string } + const first = await app.sendDraft('new', 'A knob') // A folder the store does not list, such as one an older app made, under the id the next project would take. - mkdirSync(join(projects, `${first.id}-2`), { recursive: true }) - const second = (await app.call('projects.create', { title: 'A knob' })) as { id: string } - expect(second.id).toBe(`${first.id}-3`) + mkdirSync(join(projects, `${first.project}-2`), { recursive: true }) + const second = await app.sendDraft('new', 'A knob') + expect(second.project).toBe(`${first.project}-3`) await app.quit() }) - test('a project made from the start screen takes the start screen draft with it', async () => { + test('a draft saved before drafts had revisions gets one at start, so its send takes it away', async () => { const { data, projects } = scratch() + const old = await Store.open({ + dataDir: data, + projectsDir: projects, + models: createModels({ credentials: new InMemoryCredentialStore() }), + registry: createRegistry(), + env: () => { + throw new Error('no conversation runs here') + }, + }) + await old.harness.commit(async (tx) => { + // A draft as a store from before revisions holds it. + ;((await tx.doc(DraftsDoc, old.root.id)).drafts as Record).new = { text: 'A knob', attachments: [] } + }, BACKGROUND_CONTEXT) + await old.close() + const app = spawnSidecar(data, projects) await app.next() - await app.call('drafts.set', { key: 'new', text: 'A knob', attachments: ['refs/shaft.png'] }) - const project = (await app.call('projects.create', { title: 'A knob' })) as { id: string } - expect(((await app.call('state.get')) as { drafts: object }).drafts).toEqual({ [project.id]: { text: 'A knob', attachments: ['refs/shaft.png'] } }) + const { drafts } = (await app.call('state.get')) as { drafts: Record } + expect(drafts.new).toEqual({ text: 'A knob', attachments: [], rev: expect.any(Number) }) + await app.call('chat.send', { key: 'new', rev: drafts.new?.rev }) + expect(((await app.call('state.get')) as { drafts: object }).drafts).toEqual({}) await app.quit() }) }) diff --git a/sidecar/test/switch.test.ts b/sidecar/test/switch.test.ts new file mode 100644 index 00000000..921b6fa8 --- /dev/null +++ b/sidecar/test/switch.test.ts @@ -0,0 +1,384 @@ +import { describe, expect, test } from 'bun:test' +import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { createModels, InMemoryCredentialStore } from '@earendil-works/pi-ai' +import { createRegistry } from '@earendil-works/pi-durable' +import type { ChatEntry, ChatView } from '../src/protocol' +import { Store } from '../src/store' +import { spawnSidecar } from './sidecar' + +const LEGACY = join(import.meta.dir, 'fixtures', 'legacy') + +describe('chats from before the store', () => { + test('are imported once, as they were, and their files stay as they are', async () => { + const dir = mkdtempSync(join(tmpdir(), 'm3d-import-')) + const projects = join(dir, 'projects') + cpSync(LEGACY, projects, { recursive: true }) + const transcript = join(projects, '2026-11-06-a-wall-clip', '.m3d', 'transcript.jsonl') + const before = readFileSync(transcript) + + const first = spawnSidecar(join(dir, 'state'), projects) + await first.next() + const state = (await first.call('state.get')) as { projects: { id: string; title: string }[] } + expect(state.projects).toEqual([expect.objectContaining({ id: '2026-11-06-a-wall-clip', title: 'A wall clip for my cable', createdMs: 1794000000000 })]) + const view = (await first.call('chat.open', { project: '2026-11-06-a-wall-clip' })) as ChatView + const shown = view.entries.map(({ id: _, ...entry }) => entry) + expect(shown).toEqual([ + { kind: 'imported', role: 'user', text: 'A wall clip for my cable', images: ['refs/cable.jpg'] }, + { kind: 'imported', role: 'tool', name: 'Bash', text: 'Build the clip', result: 'clip.stl written', failed: false }, + { kind: 'imported', role: 'assistant', text: 'The clip is in clip.stl.' }, + { kind: 'imported', role: 'user', text: 'Make it thicker', images: [] }, + { kind: 'imported', role: 'assistant', text: 'The turn stopped: This turn stopped when the app closed.' }, + ]) + await first.quit() + + const second = spawnSidecar(join(dir, 'state'), projects) + await second.next() + expect(((await second.call('state.get')) as { projects: unknown[] }).projects).toHaveLength(1) + expect(((await second.call('chat.open', { project: '2026-11-06-a-wall-clip' })) as ChatView).entries).toEqual(view.entries) + await second.quit() + expect(readFileSync(transcript).equals(before)).toBe(true) + expect(existsSync(join(projects, '2026-11-06-a-wall-clip', '.m3d', 'project.json'))).toBe(true) + }) +}) + +describe('a picture pasted into a draft', () => { + test('is kept in a file of its own, outside any project, so the draft can keep it', async () => { + const dir = mkdtempSync(join(tmpdir(), 'm3d-picture-')) + const app = spawnSidecar(join(dir, 'state'), join(dir, 'projects')) + await app.next() + const path = (await app.call('drafts.picture', { name: 'pasted.png', data: Buffer.from('png bytes').toString('base64') })) as string + expect(path.startsWith(dir)).toBe(true) + expect(path.endsWith('pasted.png')).toBe(true) + expect(readFileSync(path, 'utf8')).toBe('png bytes') + await expect(app.call('drafts.picture', { name: '../escape.png', data: '' })).rejects.toThrow() + await app.quit() + }) + + test('is removed at the next start once no draft keeps it, and kept while one does', async () => { + const dir = mkdtempSync(join(tmpdir(), 'm3d-picture-')) + const first = spawnSidecar(join(dir, 'state'), join(dir, 'projects')) + await first.next() + const kept = (await first.call('drafts.picture', { name: 'kept.png', data: '' })) as string + const dropped = (await first.call('drafts.picture', { name: 'dropped.png', data: '' })) as string + await first.call('drafts.set', { key: 'new', text: 'like this', attachments: [kept] }) + await first.quit() + const second = spawnSidecar(join(dir, 'state'), join(dir, 'projects')) + await second.next() + expect(existsSync(kept)).toBe(true) + // A picture from a moment ago may still wait in a window that outlived a crash, so only old ones go. + expect(existsSync(dropped)).toBe(true) + await second.quit() + utimesSync(dirname(dropped), new Date(Date.now() - 2 * 86_400_000), new Date(Date.now() - 2 * 86_400_000)) + const third = spawnSidecar(join(dir, 'state'), join(dir, 'projects')) + await third.next() + expect(existsSync(kept)).toBe(true) + expect(existsSync(dropped)).toBe(false) + await third.quit() + }) + + test('the cleanup at start passes over a file beside the picture folders, such as one Finder leaves', async () => { + const dir = mkdtempSync(join(tmpdir(), 'm3d-picture-')) + mkdirSync(join(dir, 'pictures')) + const stray = join(dir, 'pictures', '.DS_Store') + writeFileSync(stray, '') + utimesSync(stray, new Date(Date.now() - 2 * 86_400_000), new Date(Date.now() - 2 * 86_400_000)) + const app = spawnSidecar(join(dir, 'state'), join(dir, 'projects')) + expect(await app.next()).toMatchObject({ kind: 'event', event: { type: 'ready' } }) + expect(existsSync(stray)).toBe(true) + await app.quit() + }) + + test("is kept while a first send cut off after its project was made still names it, though the project's draft changed", async () => { + const dir = mkdtempSync(join(tmpdir(), 'm3d-picture-')) + const first = spawnSidecar(join(dir, 'state'), join(dir, 'projects')) + await first.next() + const picture = (await first.call('drafts.picture', { name: 'knob.png', data: '' })) as string + const { rev } = (await first.call('drafts.set', { key: 'new', text: 'A knob', attachments: [picture] })) as { rev: number } + await first.quit() + // The sidecar made the project and moved the draft, then stopped before the message. + const store = await Store.open({ + dataDir: join(dir, 'state'), + projectsDir: join(dir, 'projects'), + models: createModels({ credentials: new InMemoryCredentialStore() }), + registry: createRegistry(), + env: () => { + throw new Error('no conversation runs here') + }, + }) + const { project } = await store.startProject(rev) + // The person saved a newer draft in the project, without the picture, before sending from the start screen again. + await store.setDraft(project.id, { text: 'A knob, in black', attachments: [] }) + await store.close() + utimesSync(dirname(picture), new Date(Date.now() - 2 * 86_400_000), new Date(Date.now() - 2 * 86_400_000)) + const second = spawnSidecar(join(dir, 'state'), join(dir, 'projects')) + await second.next() + expect(existsSync(picture)).toBe(true) + await second.quit() + }) + + test('the cleanup at start passes over a link to nothing in the pictures folder', async () => { + const dir = mkdtempSync(join(tmpdir(), 'm3d-picture-')) + mkdirSync(join(dir, 'pictures')) + symlinkSync(join(dir, 'gone'), join(dir, 'pictures', 'broken')) + const app = spawnSidecar(join(dir, 'state'), join(dir, 'projects')) + expect(await app.next()).toMatchObject({ kind: 'event', event: { type: 'ready' } }) + await app.quit() + }) +}) + +describe('the import of chats from before the store', () => { + const CLIP = join(LEGACY, '2026-11-06-a-wall-clip') + + test('skips a project whose record cannot be read, and names it, and passes over a line that is not an event', async () => { + const dir = mkdtempSync(join(tmpdir(), 'm3d-import-')) + const projects = join(dir, 'projects') + cpSync(CLIP, join(projects, '2026-11-06-bad-record'), { recursive: true }) + writeFileSync(join(projects, '2026-11-06-bad-record', '.m3d', 'project.json'), '{"title":') + cpSync(CLIP, join(projects, '2026-11-06-null-line'), { recursive: true }) + const transcript = join(projects, '2026-11-06-null-line', '.m3d', 'transcript.jsonl') + // Lines a cut-off or hand-edited transcript can hold: no event, content that is not a list, and a null block. + const odd = ['null', '{"type":"assistant","message":{"content":{"text":"x"}}}', '{"type":"assistant","message":{"content":[null]}}'] + writeFileSync(transcript, `${odd.join('\n')}\n${readFileSync(transcript, 'utf8')}`) + const app = spawnSidecar(join(dir, 'state'), projects) + const state = (await app.call('state.get')) as { projects: { id: string }[] } + expect(state.projects.map((p) => p.id)).toEqual(['2026-11-06-null-line']) + expect(((await app.call('chat.open', { project: '2026-11-06-null-line' })) as ChatView).entries).toHaveLength(5) + await app.quit() + expect(await app.stderr()).toContain('2026-11-06-bad-record') + }) + + test('keeps a project that has no chat yet, and shows a step a quit cut off as stopped', async () => { + const dir = mkdtempSync(join(tmpdir(), 'm3d-import-')) + const projects = join(dir, 'projects') + cpSync(CLIP, join(projects, '2026-11-06-no-chat'), { recursive: true }) + rmSync(join(projects, '2026-11-06-no-chat', '.m3d', 'transcript.jsonl')) + cpSync(CLIP, join(projects, '2026-11-06-cut-off'), { recursive: true }) + const cut = [ + { type: 'm3d_user', text: 'A hook', images: [] }, + { type: 'assistant', message: { content: [{ type: 'tool_use', id: 't1', name: 'Bash', input: { description: 'Build the hook' } }] } }, + ] + writeFileSync(join(projects, '2026-11-06-cut-off', '.m3d', 'transcript.jsonl'), cut.map((line) => JSON.stringify(line)).join('\n')) + const app = spawnSidecar(join(dir, 'state'), projects) + const state = (await app.call('state.get')) as { projects: { id: string }[] } + expect(state.projects.map((p) => p.id).sort()).toEqual(['2026-11-06-cut-off', '2026-11-06-no-chat']) + expect(((await app.call('chat.open', { project: '2026-11-06-no-chat' })) as ChatView).entries).toEqual([]) + const steps = ((await app.call('chat.open', { project: '2026-11-06-cut-off' })) as ChatView).entries.map(({ id: _, ...entry }) => entry) + expect(steps[1]).toEqual({ kind: 'imported', role: 'tool', name: 'Bash', text: 'Build the hook', result: 'It stopped before it finished.', failed: true }) + await app.quit() + }) + + // Root can list a folder whatever its mode, so there the folder cannot be made unreadable. + test.skipIf(process.getuid?.() === 0)('a projects folder that cannot be listed holds the start up for no one', async () => { + const dir = mkdtempSync(join(tmpdir(), 'm3d-import-')) + const projects = join(dir, 'projects') + cpSync(CLIP, join(projects, '2026-11-06-a-wall-clip'), { recursive: true }) + chmodSync(projects, 0o000) + try { + const app = spawnSidecar(join(dir, 'state'), projects) + const state = (await app.call('state.get')) as { projects: unknown[]; importBlocked: string | null } + expect(state.projects).toEqual([]) + expect(state.importBlocked).toContain(projects) + // The person allows the folder, and the import runs again at once. + chmodSync(projects, 0o755) + expect(await app.call('legacy.import', {})).toEqual({ imported: 1, blocked: null }) + const after = (await app.call('state.get')) as { projects: { id: string }[]; importBlocked: string | null } + expect(after.projects.map((p) => p.id)).toEqual(['2026-11-06-a-wall-clip']) + expect(after.importBlocked).toBeNull() + await app.quit() + expect(await app.stderr()).toContain('could not list') + } finally { + chmodSync(projects, 0o755) + } + }) + + test('reads no old transcript on a launch after the import', async () => { + const dir = mkdtempSync(join(tmpdir(), 'm3d-import-')) + const projects = join(dir, 'projects') + cpSync(CLIP, join(projects, '2026-11-06-a-wall-clip'), { recursive: true }) + const first = spawnSidecar(join(dir, 'state'), projects) + await first.next() + await first.quit() + const transcript = join(projects, '2026-11-06-a-wall-clip', '.m3d', 'transcript.jsonl') + rmSync(transcript) + mkdirSync(transcript) + const second = spawnSidecar(join(dir, 'state'), projects) + expect(((await second.call('chat.open', { project: '2026-11-06-a-wall-clip' })) as ChatView).entries).toHaveLength(5) + await second.quit() + expect(await second.stderr()).not.toContain('2026-11-06-a-wall-clip') + }) +}) + +describe('the update gate', () => { + function scratch() { + const dir = mkdtempSync(join(tmpdir(), 'm3d-gate-')) + const bin = join(dir, 'bin') + mkdirSync(bin) + writeFileSync(join(bin, 'uv'), '#!/bin/sh\n[ "$1" = run ] && shift\nexec "$@"\n') + chmodSync(join(bin, 'uv'), 0o755) + return { data: join(dir, 'state'), projects: join(dir, 'projects'), env: { PATH: `${bin}:${process.env.PATH}` } } + } + + test('holds the pass from before a turn commits until its work is done, and a send is refused while an update installs', async () => { + const where = scratch() + const app = spawnSidecar(where.data, where.projects, new Map(), { script: 'models', env: where.env }) + await app.next() + await app.call('settings.set', { model: { provider: 'faux', modelId: 'faux-a' } }) + expect(app.gate.requests).toEqual([]) + + const { project } = await app.sendDraft('new', 'please wait') + expect(app.gate.requests).toEqual([true]) + const started = join(where.projects, project, 'started') + while (!existsSync(started)) await Bun.sleep(25) + expect(app.gate.requests).toEqual([true]) + writeFileSync(join(where.projects, project, 'go'), '') + await app.until((event) => event.type === 'chat' && (event.entries as ChatEntry[]).some((entry) => entry.kind === 'assistant')) + while (app.gate.requests.at(-1) !== false) await Bun.sleep(25) + expect(app.gate.requests).toEqual([true, false]) + + app.gate.installing = true + const { rev } = (await app.call('drafts.set', { key: project, text: 'make it red', attachments: [] })) as { rev: number } + await expect(app.call('chat.send', { key: project, rev })).rejects.toThrow('An update is installing.') + const view = (await app.call('chat.open', { project })) as ChatView + expect(view.entries.filter((entry) => entry.kind === 'user')).toHaveLength(1) + expect(((await app.call('state.get')) as { drafts: Record }).drafts[project]).toMatchObject({ text: 'make it red', attachments: [] }) + await app.quit() + }) + + test('after a crash, the sidecar holds the pass before the cut-off work resumes, and leaves the work alone while an update installs', async () => { + const where = scratch() + const first = spawnSidecar(where.data, where.projects, new Map(), { script: 'models', env: where.env }) + await first.next() + await first.call('settings.set', { model: { provider: 'faux', modelId: 'faux-a' } }) + const { project } = await first.sendDraft('new', 'please wait') + const started = join(where.projects, project, 'started') + while (!existsSync(started)) await Bun.sleep(25) + first.child.kill('SIGKILL') + await first.child.exited + rmSync(started) + + const installing = spawnSidecar(where.data, where.projects, new Map(), { script: 'models', env: where.env }) + installing.gate.installing = true + await installing.until((event) => event.type === 'ready') + await Bun.sleep(1_500) + // It asks for the pass again each second while the update installs, and never starts the work without it. + expect(installing.gate.requests.length).toBeGreaterThan(0) + expect(installing.gate.requests.every((busy) => busy)).toBe(true) + expect(existsSync(started)).toBe(false) + await installing.quit() + + const after = spawnSidecar(where.data, where.projects, new Map(), { script: 'models', env: where.env }) + await after.until((event) => event.type === 'ready') + expect(after.gate.requests).toEqual([true]) + while (!existsSync(started)) await Bun.sleep(25) + await after.quit() + }, 30_000) + + test('after an install that failed, the work a crash cut off goes on once the gate opens again', async () => { + const where = scratch() + const first = spawnSidecar(where.data, where.projects, new Map(), { script: 'models', env: where.env }) + await first.next() + await first.call('settings.set', { model: { provider: 'faux', modelId: 'faux-a' } }) + const { project } = await first.sendDraft('new', 'please wait') + const started = join(where.projects, project, 'started') + while (!existsSync(started)) await Bun.sleep(25) + first.child.kill('SIGKILL') + await first.child.exited + rmSync(started) + + const app = spawnSidecar(where.data, where.projects, new Map(), { script: 'models', env: where.env }) + app.gate.installing = true + await app.until((event) => event.type === 'ready') + await Bun.sleep(1_500) + expect(existsSync(started)).toBe(false) + app.gate.installing = false + while (!existsSync(started)) await Bun.sleep(25) + await app.quit() + }, 30_000) + + test('a tool that waited on a sign-in goes on only with the pass, so an install that started meanwhile holds it', async () => { + const where = scratch() + const app = spawnSidecar(where.data, where.projects, new Map(), { script: 'astra', env: where.env }) + await app.next() + await app.call('settings.set', { model: { provider: 'faux', modelId: 'faux-a' } }) + const { project } = await app.sendDraft('new', 'go') + await app.until((event) => event.type === 'chat' && event.waiting !== null) + app.gate.installing = true + await app.call('auth.setKey', { provider: 'openai-codex', key: 'astra-key' }) + await Bun.sleep(2_000) + expect(existsSync(join(where.projects, project, 'astra.txt'))).toBe(false) + app.gate.installing = false + await app.until((event) => event.type === 'chat' && (event.entries as ChatEntry[]).some((entry) => entry.kind === 'assistant' && entry.text === 'parent saw: astra drew it'), 30_000) + await app.quit() + }, 40_000) + + /** A turn whose Astra tool signed in while an update installs, so it waits for the pass. */ + async function waitingForPass() { + const where = scratch() + const keychain = new Map() + const app = spawnSidecar(where.data, where.projects, keychain, { script: 'astra', env: where.env }) + await app.next() + await app.call('settings.set', { model: { provider: 'faux', modelId: 'faux-a' } }) + const { project } = await app.sendDraft('new', 'go') + await app.until((event) => event.type === 'chat' && event.waiting !== null) + app.gate.installing = true + await app.call('auth.setKey', { provider: 'openai-codex', key: 'astra-key' }) + await Bun.sleep(1_500) + return { where, keychain, app, project } + } + + test('a stop ends a tool that waits for the pass after its sign-in', async () => { + const { where, app, project } = await waitingForPass() + await app.call('chat.stop', { project }) + await app.until((event) => event.type === 'chat' && event.project === project && event.running === false, 5_000) + expect(existsSync(join(where.projects, project, 'astra.txt'))).toBe(false) + await app.quit() + }, 40_000) + + test('a quit leaves a tool that waits for the pass unfinished, so it goes on after the restart', async () => { + const { where, keychain, app, project } = await waitingForPass() + const quit = await Promise.race([app.quit().then(() => 'exited'), Bun.sleep(5_000).then(() => 'still running')]) + expect(quit).toBe('exited') + expect(existsSync(join(where.projects, project, 'astra.txt'))).toBe(false) + + const after = spawnSidecar(where.data, where.projects, keychain, { script: 'astra', env: where.env }) + await after.next() + const done = (entries: ChatEntry[]) => entries.some((entry) => entry.kind === 'assistant' && entry.text === 'parent saw: astra drew it') + if (!done(((await after.call('chat.open', { project })) as ChatView).entries)) { + await after.until((event) => event.type === 'chat' && done(event.entries as ChatEntry[]), 30_000) + } + await after.quit() + }, 60_000) + + test('gives the pass back while Astra waits on a sign-in, even when a tool beside it already finished', async () => { + const where = scratch() + const app = spawnSidecar(where.data, where.projects, new Map(), { script: 'astra-and-read', env: where.env }) + await app.next() + await app.call('settings.set', { model: { provider: 'faux', modelId: 'faux-a' } }) + const { project } = await app.sendDraft('new', 'go') + await app.until((event) => event.type === 'chat' && event.waiting !== null) + const deadline = Date.now() + 5_000 + while (app.gate.requests.at(-1) !== false && Date.now() < deadline) await Bun.sleep(25) + expect(app.gate.requests.at(-1)).toBe(false) + await app.quit() + }, 30_000) + + test('gives the pass back while a turn waits on a sign-in, and takes it again when the turn goes on', async () => { + const where = scratch() + const app = spawnSidecar(where.data, where.projects, new Map(), { script: 'astra', env: where.env }) + await app.next() + await app.call('settings.set', { model: { provider: 'faux', modelId: 'faux-a' } }) + const { project } = await app.sendDraft('new', 'go') + await app.until((event) => event.type === 'chat' && event.waiting !== null) + const deadline = Date.now() + 5_000 + while (app.gate.requests.at(-1) !== false && Date.now() < deadline) await Bun.sleep(25) + expect(app.gate.requests).toEqual([true, false]) + + await app.call('auth.setKey', { provider: 'openai-codex', key: 'astra-key' }) + await app.until((event) => event.type === 'chat' && (event.entries as ChatEntry[]).some((entry) => entry.kind === 'assistant' && entry.text === 'parent saw: astra drew it')) + while (app.gate.requests.at(-1) !== false) await Bun.sleep(25) + expect(app.gate.requests.slice(2)).toContain(true) + await app.quit() + }, 30_000) +}) diff --git a/sidecar/test/tools.test.ts b/sidecar/test/tools.test.ts index 68d060e5..2992315e 100644 --- a/sidecar/test/tools.test.ts +++ b/sidecar/test/tools.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test' -import { appendFileSync, chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { appendFileSync, chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { logFollower, once, operationId, tailOf } from '../src/agent' @@ -20,14 +20,18 @@ function scratch(env: Record = {}) { const start = (where: ReturnType, script: string, version?: string) => spawnSidecar(where.data, where.projects, new Map(), { script, env: where.env, ...(version === undefined ? {} : { version }) }) -async function newProject(app: App) { +/** Sends a first message from the start screen, which makes its project, and answers that project. */ +async function firstSend(app: App, text = 'go') { await app.call('settings.set', { model: { provider: 'faux', modelId: 'faux-a' } }) - const { id } = (await app.call('projects.create', { title: 'A cube' })) as { id: string } - await app.call('chat.open', { project: id }) - return id + return (await app.sendDraft('new', text)).project } -const send = (app: App, project: string, requestId: string, text = 'go') => app.call('chat.send', { project, requestId, text, attachments: [] }) +/** A project whose first message every script answers with `made`, for a test that sets up its folder first. */ +async function newProject(app: App) { + const project = await firstSend(app, 'make the project') + await answered(app, project, 'made') + return project +} /** Waits until the chat holds this answer, already committed or in a later change. */ async function answered(app: App, project: string, text: string) { @@ -71,8 +75,7 @@ describe('the run tool', () => { const where = scratch({ M3D_SECRET: 'leak' }) const app = start(where, 'env') await app.next() - const project = await newProject(app) - await send(app, project, 'r1') + const project = await firstSend(app) await answered(app, project, 'exit code 0\nsecret=none op=set\n') // One operation: its receipt, its log, and its exit code. const files = readdirSync(join(where.projects, project, '.m3d', 'ops')) @@ -84,8 +87,7 @@ describe('the run tool', () => { const where = scratch() const app = start(where, 'slow') await app.next() - const project = await newProject(app) - await send(app, project, 'r1') + const project = await firstSend(app) await answered(app, project, 'timed') expect((await toolResults(app, project))[0]).toMatchObject({ name: 'run', failed: true }) await Bun.sleep(2500) @@ -97,8 +99,7 @@ describe('the run tool', () => { const where = scratch() const app = start(where, 'orphan') await app.next() - const project = await newProject(app) - await send(app, project, 'r1') + const project = await firstSend(app) while (!existsSync(join(where.projects, project, 'started'))) await Bun.sleep(25) app.child.kill('SIGKILL') await app.child.exited @@ -110,8 +111,7 @@ describe('the run tool', () => { const where = scratch() const first = start(where, 'quick') await first.next() - const project = await newProject(first) - await send(first, project, 'r1') + const project = await firstSend(first) while (!existsSync(join(where.projects, project, 'started'))) await Bun.sleep(10) first.child.kill('SIGKILL') await first.child.exited @@ -125,6 +125,74 @@ describe('the run tool', () => { }, 30_000) }) +describe('the check before a send', () => { + /** A uv on PATH that knows its version, and finds a Python only when `python` is true. */ + function toolchain(python: boolean) { + const dir = mkdtempSync(join(tmpdir(), 'm3d-uv-')) + writeFileSync(join(dir, 'uv'), `#!/bin/sh\n[ "$1" = --version ] && { echo "uv 0.9.0"; exit 0; }\n[ "$1 $2" = "python find" ] && { ${python ? 'echo /usr/bin/python3; exit 0' : 'exit 2'}; }\nexit 1\n`) + chmodSync(join(dir, 'uv'), 0o755) + return `${dir}:/usr/bin:/bin` + } + // A home of its own, so a uv the machine has in ~/.local/bin is not found. + const check = async (path: string, key: boolean, home = mkdtempSync(join(tmpdir(), 'm3d-home-'))) => { + const dir = mkdtempSync(join(tmpdir(), 'm3d-check-')) + const app = spawnSidecar(join(dir, 'state'), join(dir, 'projects'), new Map(), { env: { PATH: path, HOME: home } }) + await app.next() + if (key) await app.call('auth.setKey', { provider: 'anthropic', key: 'token' }) + const needs = (await app.call('setup.check', {})) as { kind: string; fix: string }[] + await app.quit() + return needs + } + + test('names a missing sign-in and a missing uv, each with its fix', async () => { + // An empty folder as PATH, so no uv the machine has is found. + const needs = await check(mkdtempSync(join(tmpdir(), 'm3d-path-')), false) + expect(needs.map((need) => need.kind)).toEqual(['login', 'uv']) + expect(needs.every((need) => need.fix.length > 0)).toBe(true) + }) + + test("finds a uv its installer put in ~/.local/bin, which the app's PATH from before the install lacks", async () => { + const home = mkdtempSync(join(tmpdir(), 'm3d-home-')) + mkdirSync(join(home, '.local')) + renameSync(toolchain(true).split(':')[0] ?? '', join(home, '.local', 'bin')) + expect(await check('/usr/bin:/bin', true, home)).toEqual([]) + }) + + test('a check that hangs ends at its limit, and names what it could not find', async () => { + const dir = mkdtempSync(join(tmpdir(), 'm3d-uv-')) + writeFileSync(join(dir, 'uv'), '#!/bin/sh\n[ "$1" = --version ] && { echo "uv 0.9.0"; exit 0; }\nexec sleep 60\n') + chmodSync(join(dir, 'uv'), 0o755) + const state = mkdtempSync(join(tmpdir(), 'm3d-check-')) + const app = spawnSidecar(join(state, 'state'), join(state, 'projects'), new Map(), { + env: { PATH: `${dir}:/usr/bin:/bin`, HOME: mkdtempSync(join(tmpdir(), 'm3d-home-')) }, + }) + await app.next() + await app.call('auth.setKey', { provider: 'anthropic', key: 'token' }) + const started = Date.now() + const needs = (await app.call('setup.check', {})) as { kind: string }[] + expect(Date.now() - started).toBeLessThan(10_000) + expect(needs.map((need) => need.kind)).toEqual(['python']) + await app.quit() + }, 30_000) + + test('names a missing Python once uv is there', async () => { + expect((await check(toolchain(false), true)).map((need) => need.kind)).toEqual(['python']) + }) + + test('names a model whose provider the app does not offer', async () => { + const dir = mkdtempSync(join(tmpdir(), 'm3d-check-')) + const app = spawnSidecar(join(dir, 'state'), join(dir, 'projects'), new Map(), { env: { PATH: toolchain(true) } }) + await app.next() + await app.call('settings.set', { model: { provider: 'openai', modelId: 'gpt-6' } }) + expect(((await app.call('setup.check', {})) as { kind: string }[]).map((need) => need.kind)).toEqual(['model']) + await app.quit() + }) + + test('has nothing to name when the sign-in, uv, and Python are all there', async () => { + expect(await check(toolchain(true), true)).toEqual([]) + }) +}) + describe("a command's log", () => { test('is followed from where the last read stopped, and a long burst or a recovery gets only its end', () => { const log = join(mkdtempSync(join(tmpdir(), 'm3d-log-')), 'op.json.log') @@ -160,7 +228,7 @@ describe('the file tools', () => { symlinkSync(join(outside, 'made-outside.txt'), join(where.projects, project, 'dangling')) // A link to a file not made yet, through `linked` and then up: the system climbs from where `linked` leads. symlinkSync('linked/../climbed.txt', join(where.projects, project, 'climb')) - await send(app, project, 'r1') + await app.sendDraft(project, 'go') await answered(app, project, 'tried') const results = await toolResults(app, project) expect(results.map((r) => [r.name, r.failed])).toEqual([['read', true], ['write', true], ['edit', true], ['write', true], ['write', true]]) @@ -174,17 +242,25 @@ describe('the file tools', () => { }) describe('a picture sent with a message', () => { - test('never reads a file outside the project through a link', async () => { + test('is never copied through a link out of the project, and a link left in its place is never read', async () => { const where = scratch() const app = start(where, 'models') await app.next() const project = await newProject(app) + const photo = join(where.dir, 'photo.jpg') + writeFileSync(photo, 'jpg') + const outside = join(where.dir, 'outside') + mkdirSync(outside) + symlinkSync(outside, join(where.projects, project, 'refs')) + const rev = ((await app.call('drafts.set', { key: project, text: 'like this', attachments: [photo] })) as { rev: number }).rev + await expect(app.call('chat.send', { key: project, rev })).rejects.toThrow('refs/ leads outside the project folder') + expect(readdirSync(outside)).toEqual([]) + + rmSync(join(where.projects, project, 'refs')) writeFileSync(join(where.dir, 'secret.jpg'), 'secret') - mkdirSync(join(where.projects, project, 'refs'), { recursive: true }) - symlinkSync(join(where.dir, 'secret.jpg'), join(where.projects, project, 'refs', 'photo.jpg')) - await expect(app.call('chat.send', { project, requestId: 'r1', text: 'like this', attachments: ['refs/photo.jpg'] })).rejects.toThrow( - 'is not a file in the project folder', - ) + mkdirSync(join(where.projects, project, 'refs', `draft-${rev}`), { recursive: true }) + symlinkSync(join(where.dir, 'secret.jpg'), join(where.projects, project, 'refs', `draft-${rev}`, 'photo.jpg')) + await expect(app.call('chat.send', { key: project, rev })).rejects.toThrow('is not a file in the project folder') await app.quit() }) @@ -193,12 +269,13 @@ describe('a picture sent with a message', () => { const app = start(where, 'models') await app.next() const project = await newProject(app) - const photo = join(where.projects, project, '..photo.png') + const photo = join(where.dir, '..photo.png') writeFileSync(photo, 'png') - await app.call('chat.send', { project, requestId: 'r1', text: 'like this', attachments: ['..photo.png'] }) + const { rev } = await app.sendDraft(project, 'like this', [photo]) await answered(app, project, 'answered by faux-a') + expect(readdirSync(join(where.projects, project, 'refs', `draft-${rev}`))).toEqual(['..photo.png']) rmSync(photo) - await app.call('chat.send', { project, requestId: 'r1', text: 'like this', attachments: ['..photo.png'] }) + await app.call('chat.send', { key: project, rev }) await app.quit() }) @@ -207,10 +284,9 @@ describe('a picture sent with a message', () => { const app = start(where, 'models') await app.next() const project = await newProject(app) - expect(Bun.spawnSync(['mkfifo', join(where.projects, project, 'pipe.png')]).exitCode).toBe(0) - await expect(app.call('chat.send', { project, requestId: 'r1', text: 'like this', attachments: ['pipe.png'] })).rejects.toThrow( - 'is not a file in the project folder', - ) + const pipe = join(where.dir, 'pipe.png') + expect(Bun.spawnSync(['mkfifo', pipe]).exitCode).toBe(0) + await expect(app.sendDraft(project, 'like this', [pipe])).rejects.toThrow("A picture in this message can't be read: pipe.png. Take it out and send again.") await app.quit() }) }) @@ -220,8 +296,7 @@ describe('Astra', () => { const where = scratch() const app = start(where, 'astra') await app.next() - const project = await newProject(app) - await send(app, project, 'r1') + const project = await firstSend(app) const waiting = await app.until((event) => event.type === 'chat' && event.waiting !== null) expect(waiting.waiting).toEqual({ kind: 'login', @@ -241,9 +316,8 @@ describe('Astra', () => { const where = scratch() const app = start(where, 'astra') await app.next() - const project = await newProject(app) await app.call('auth.setKey', { provider: 'openai-codex', key: 'refused' }) - await send(app, project, 'r1') + const project = await firstSend(app) const waiting = await app.until((event) => event.type === 'chat' && event.waiting !== null, 30_000) expect(waiting.waiting).toMatchObject({ kind: 'login', provider: 'openai-codex' }) expect(waiting.running).toBe(true) @@ -258,9 +332,8 @@ describe('Astra', () => { const where = scratch({ M3D_ASTRA_GATE: gate }) const app = start(where, 'astra') await app.next() - const project = await newProject(app) await app.call('auth.setKey', { provider: 'openai-codex', key: 'refused' }) - await send(app, project, 'r1') + const project = await firstSend(app) while (!existsSync(join(gate, 'asked'))) await Bun.sleep(25) await app.call('auth.setKey', { provider: 'openai-codex', key: 'astra-key' }) writeFileSync(join(gate, 'release'), '') @@ -273,8 +346,7 @@ describe('Astra', () => { const keychain = new Map() const first = spawnSidecar(where.data, where.projects, keychain, { script: 'astra', env: where.env }) await first.next() - const project = await newProject(first) - await send(first, project, 'r1') + const project = await firstSend(first) await first.until((event) => event.type === 'chat' && event.waiting !== null) first.child.kill('SIGKILL') await first.child.exited @@ -294,8 +366,7 @@ describe('a restart', () => { const where = scratch() const first = start(where, 'fails-after') await first.next() - const project = await newProject(first) - await send(first, project, 'r1') + const project = await firstSend(first) while (!existsSync(join(where.projects, project, 'started'))) await Bun.sleep(25) first.child.kill('SIGKILL') await first.child.exited @@ -313,9 +384,7 @@ describe('a restart', () => { const first = start(where, 'models', '1.0.0') await first.next() await first.call('settings.set', { model: { provider: 'anthropic', modelId: 'claude-opus-5-5' } }) - const { id: project } = (await first.call('projects.create', { title: 'A cube' })) as { id: string } - await first.call('chat.open', { project }) - await send(first, project, 'r1') + const { project } = await first.sendDraft('new', 'go') await first.until((event) => event.type === 'chat' && event.waiting !== null) await first.quit() @@ -330,8 +399,7 @@ describe('a restart', () => { const where = scratch() const first = start(where, 'models', '1.0.0') await first.next() - const project = await newProject(first) - await send(first, project, 'r1', 'please wait') + const project = await firstSend(first, 'please wait') while (!existsSync(join(where.projects, project, 'started'))) await Bun.sleep(25) first.child.kill('SIGKILL') await first.child.exited diff --git a/sidecar/test/waiting.test.ts b/sidecar/test/waiting.test.ts index fed44648..b2084aa8 100644 --- a/sidecar/test/waiting.test.ts +++ b/sidecar/test/waiting.test.ts @@ -44,9 +44,7 @@ describe('a turn whose provider is not signed in', () => { const keychain = new Map() const first = spawnSidecar(join(dir, 'state'), join(dir, 'projects'), keychain) await first.next() - const { id: project } = (await first.call('projects.create', { title: 'A cube' })) as { id: string } - await first.call('chat.open', { project }) - await first.call('chat.send', { project, requestId: 'r1', text: 'A 20 mm cube', attachments: [] }) + const { project } = await first.sendDraft('new', 'A 20 mm cube') const waiting = await first.until((event) => event.type === 'chat' && event.waiting !== null) expect(waiting.waiting).toEqual({ kind: 'login', @@ -82,10 +80,8 @@ describe('a turn whose provider is not signed in', () => { const dir = mkdtempSync(join(tmpdir(), 'm3d-wait-')) const app = spawnSidecar(join(dir, 'state'), join(dir, 'projects'), new Map()) await app.next() - const { id: project } = (await app.call('projects.create', { title: 'A cube' })) as { id: string } - await app.call('chat.open', { project }) await app.call('auth.setKey', { provider: 'anthropic', key: 'old-token', baseUrl: proxy.url }) - await app.call('chat.send', { project, requestId: 'r1', text: 'A 20 mm cube', attachments: [] }) + const { project } = await app.sendDraft('new', 'A 20 mm cube') while (proxy.seen.length === 0) await Bun.sleep(10) await app.call('auth.setKey', { provider: 'anthropic', key: 'new-token', baseUrl: proxy.url }) await Bun.sleep(200) @@ -100,6 +96,70 @@ describe('a turn whose provider is not signed in', () => { proxy.stop() }, 20_000) + test('a turn refused after a newer login was saved goes on with it only with the update pass, and once a failed install opens the gate', async () => { + const release = Promise.withResolvers() + const proxy = anthropicProxy('Hello after the update', { token: 'old-token', release: release.promise }) + const dir = mkdtempSync(join(tmpdir(), 'm3d-wait-')) + const app = spawnSidecar(join(dir, 'state'), join(dir, 'projects'), new Map()) + await app.next() + await app.call('auth.setKey', { provider: 'anthropic', key: 'old-token', baseUrl: proxy.url }) + const { project } = await app.sendDraft('new', 'A 20 mm cube') + while (proxy.seen.length === 0) await Bun.sleep(10) + await app.call('auth.setKey', { provider: 'anthropic', key: 'new-token', baseUrl: proxy.url }) + // An update starts installing while the old request is still out. + app.gate.installing = true + release.resolve() + const waiting = await app.until((event) => event.type === 'chat' && event.waiting !== null) + expect(waiting.waiting).toMatchObject({ kind: 'login', provider: 'anthropic' }) + await Bun.sleep(500) + expect(proxy.seen).toHaveLength(1) + // The install fails and the gate opens: the turn goes on by itself, with no new sign-in. + app.gate.installing = false + const answered = await app.until( + (event) => event.type === 'chat' && (event.entries as ChatEntry[]).some((entry) => entry.kind === 'assistant'), + 10_000, + ) + expect((answered.entries as ChatEntry[]).find((entry) => entry.kind === 'assistant')).toMatchObject({ text: 'Hello after the update' }) + expect(proxy.seen.map((call) => call.authorization)).toEqual(['Bearer old-token', 'Bearer new-token']) + await app.quit() + proxy.stop() + }, 30_000) + + test('a sign-in that waits out an installing update holds up neither the start nor a quit, and goes on later', async () => { + const proxy = anthropicProxy('Hello after the install', { token: 'old-token', release: Promise.resolve() }) + const dir = mkdtempSync(join(tmpdir(), 'm3d-wait-')) + const keychain = new Map() + const start = () => spawnSidecar(join(dir, 'state'), join(dir, 'projects'), keychain) + const first = start() + await first.next() + await first.call('auth.setKey', { provider: 'anthropic', key: 'old-token', baseUrl: proxy.url }) + const { project } = await first.sendDraft('new', 'A 20 mm cube') + await first.until((event) => event.type === 'chat' && event.waiting !== null) + // A new login is saved while an update installs, so the turn waits for the pass. + first.gate.installing = true + await first.call('auth.setKey', { provider: 'anthropic', key: 'new-token', baseUrl: proxy.url }) + await first.quit() + // The app starts again with the update still installing, and quits at once. + const second = start() + second.gate.installing = true + await second.next() + await second.call('chat.open', { project }) + const quit = Date.now() + await second.quit() + expect(Date.now() - quit).toBeLessThan(3000) + expect(proxy.seen).toHaveLength(1) + // With the gate open, the turn goes on with the new login, with no new sign-in. + const third = start() + await third.next() + await third.until( + (event) => event.type === 'chat' && (event.entries as ChatEntry[]).some((entry) => entry.kind === 'assistant'), + 10_000, + ) + expect(proxy.seen.map((call) => call.authorization)).toEqual(['Bearer old-token', 'Bearer new-token']) + await third.quit() + proxy.stop() + }, 40_000) + test('a turn that failed with no login goes on when one is saved while the failure is read', async () => { const proxy = anthropicProxy('Hello after the slow read') const dir = mkdtempSync(join(tmpdir(), 'm3d-wait-')) @@ -107,16 +167,14 @@ describe('a turn whose provider is not signed in', () => { let holding = true const held: (() => void)[] = [] const app = spawnSidecar(join(dir, 'state'), join(dir, 'projects'), new Map(), { - gate: (request, answer) => (holding && request.method === 'keychain.get' ? held.push(answer) : answer()), + hold: (request, answer) => (holding && request.method === 'keychain.get' ? held.push(answer) : answer()), }) await app.next() - const { id: project } = (await app.call('projects.create', { title: 'A cube' })) as { id: string } - await app.call('chat.open', { project }) const nextRead = async (count: number) => { while (held.length < count) await Bun.sleep(10) } // The turn's request finds no login and fails. Its failure is read, and that read asks the Keychain again. - await app.call('chat.send', { project, requestId: 'r1', text: 'A 20 mm cube', attachments: [] }) + const { project } = await app.sendDraft('new', 'A 20 mm cube') await nextRead(1) held[0]?.() await nextRead(2) @@ -139,9 +197,7 @@ describe('a turn whose provider is not signed in', () => { const dir = mkdtempSync(join(tmpdir(), 'm3d-wait-')) const app = spawnSidecar(join(dir, 'state'), join(dir, 'projects'), new Map()) await app.next() - const { id: project } = (await app.call('projects.create', { title: 'A cube' })) as { id: string } - await app.call('chat.open', { project }) - await app.call('chat.send', { project, requestId: 'r1', text: 'A 20 mm cube', attachments: [] }) + const { project } = await app.sendDraft('new', 'A 20 mm cube') await app.until((event) => event.type === 'chat' && event.waiting !== null) await app.call('chat.stop', { project }) await app.call('auth.setKey', { provider: 'anthropic', key: 'new-token', baseUrl: proxy.url }) @@ -158,10 +214,8 @@ describe('a turn whose provider is not signed in', () => { const keychain = new Map() const first = spawnSidecar(join(dir, 'state'), join(dir, 'projects'), keychain) await first.next() - const { id: project } = (await first.call('projects.create', { title: 'A cube' })) as { id: string } - await first.call('chat.open', { project }) await first.call('auth.setKey', { provider: 'anthropic', key: 'old-token', baseUrl: proxy.url }) - await first.call('chat.send', { project, requestId: 'r1', text: 'A 20 mm cube', attachments: [] }) + const { project } = await first.sendDraft('new', 'A 20 mm cube') await first.until((event) => event.type === 'chat' && event.waiting !== null) await first.quit() const second = spawnSidecar(join(dir, 'state'), join(dir, 'projects'), keychain) @@ -184,10 +238,8 @@ describe('a turn whose provider is not signed in', () => { const dir = mkdtempSync(join(tmpdir(), 'm3d-wait-')) const app = spawnSidecar(join(dir, 'state'), join(dir, 'projects'), new Map()) await app.next() - const { id: project } = (await app.call('projects.create', { title: 'A cube' })) as { id: string } - await app.call('chat.open', { project }) await app.call('auth.setKey', { provider: 'anthropic', key: 'old-token', baseUrl: proxy.url }) - await app.call('chat.send', { project, requestId: 'r1', text: 'A 20 mm cube', attachments: [] }) + const { project } = await app.sendDraft('new', 'A 20 mm cube') const waiting = await app.until((event) => event.type === 'chat' && event.waiting !== null) expect(waiting.waiting).toMatchObject({ kind: 'login', provider: 'anthropic' }) await Bun.sleep(500) diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 5bcf3c1e..a129c7b6 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -94,15 +94,14 @@ const APP_COMMANDS: &[&str] = &[ "clear_bambu_studio", "update_check", "update_install", - "studio_projects", - "studio_create_project", - "studio_transcript", - "studio_send", - "studio_cancel", "studio_files", "studio_import_file", "studio_save_bytes", "studio_reveal", "studio_open_in_bambu", + "studio_flush_started", + "studio_flush_failed", + "studio_flushed", + "studio_open_files_privacy", "sidecar", ]; diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 7f181967..3ddd8371 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -87,16 +87,15 @@ "allow-clear-bambu-studio", "allow-update-check", "allow-update-install", - "allow-studio-projects", - "allow-studio-create-project", - "allow-studio-transcript", - "allow-studio-send", - "allow-studio-cancel", "allow-studio-files", "allow-studio-import-file", "allow-studio-save-bytes", "allow-studio-reveal", "allow-studio-open-in-bambu", + "allow-studio-flush-started", + "allow-studio-flush-failed", + "allow-studio-flushed", + "allow-studio-open-files-privacy", "allow-sidecar" ] } diff --git a/src-tauri/gen/schemas/acl-manifests.json b/src-tauri/gen/schemas/acl-manifests.json index 33e598e0..c755ecb7 100644 --- a/src-tauri/gen/schemas/acl-manifests.json +++ b/src-tauri/gen/schemas/acl-manifests.json @@ -1 +1 @@ -{"__app-acl__":{"default_permission":null,"permissions":{"allow-add-printer-config":{"identifier":"allow-add-printer-config","description":"Enables the add_printer_config command without any pre-configured scope.","commands":{"allow":["add_printer_config"],"deny":[]}},"allow-add-to-queue":{"identifier":"allow-add-to-queue","description":"Enables the add_to_queue command without any pre-configured scope.","commands":{"allow":["add_to_queue"],"deny":[]}},"allow-agent-cancel":{"identifier":"allow-agent-cancel","description":"Enables the agent_cancel command without any pre-configured scope.","commands":{"allow":["agent_cancel"],"deny":[]}},"allow-agent-clear-api-key":{"identifier":"allow-agent-clear-api-key","description":"Enables the agent_clear_api_key command without any pre-configured scope.","commands":{"allow":["agent_clear_api_key"],"deny":[]}},"allow-agent-history":{"identifier":"allow-agent-history","description":"Enables the agent_history command without any pre-configured scope.","commands":{"allow":["agent_history"],"deny":[]}},"allow-agent-new-conversation":{"identifier":"allow-agent-new-conversation","description":"Enables the agent_new_conversation command without any pre-configured scope.","commands":{"allow":["agent_new_conversation"],"deny":[]}},"allow-agent-send":{"identifier":"allow-agent-send","description":"Enables the agent_send command without any pre-configured scope.","commands":{"allow":["agent_send"],"deny":[]}},"allow-agent-set-api-key":{"identifier":"allow-agent-set-api-key","description":"Enables the agent_set_api_key command without any pre-configured scope.","commands":{"allow":["agent_set_api_key"],"deny":[]}},"allow-agent-set-base-url":{"identifier":"allow-agent-set-base-url","description":"Enables the agent_set_base_url command without any pre-configured scope.","commands":{"allow":["agent_set_base_url"],"deny":[]}},"allow-agent-set-model":{"identifier":"allow-agent-set-model","description":"Enables the agent_set_model command without any pre-configured scope.","commands":{"allow":["agent_set_model"],"deny":[]}},"allow-agent-status":{"identifier":"allow-agent-status","description":"Enables the agent_status command without any pre-configured scope.","commands":{"allow":["agent_status"],"deny":[]}},"allow-bambu-studio-status":{"identifier":"allow-bambu-studio-status","description":"Enables the bambu_studio_status command without any pre-configured scope.","commands":{"allow":["bambu_studio_status"],"deny":[]}},"allow-choose-bambu-studio":{"identifier":"allow-choose-bambu-studio","description":"Enables the choose_bambu_studio command without any pre-configured scope.","commands":{"allow":["choose_bambu_studio"],"deny":[]}},"allow-clear-bambu-studio":{"identifier":"allow-clear-bambu-studio","description":"Enables the clear_bambu_studio command without any pre-configured scope.","commands":{"allow":["clear_bambu_studio"],"deny":[]}},"allow-connect-printer":{"identifier":"allow-connect-printer","description":"Enables the connect_printer command without any pre-configured scope.","commands":{"allow":["connect_printer"],"deny":[]}},"allow-connect-printer-by-config":{"identifier":"allow-connect-printer-by-config","description":"Enables the connect_printer_by_config command without any pre-configured scope.","commands":{"allow":["connect_printer_by_config"],"deny":[]}},"allow-delete-credential":{"identifier":"allow-delete-credential","description":"Enables the delete_credential command without any pre-configured scope.","commands":{"allow":["delete_credential"],"deny":[]}},"allow-delete-library-model":{"identifier":"allow-delete-library-model","description":"Enables the delete_library_model command without any pre-configured scope.","commands":{"allow":["delete_library_model"],"deny":[]}},"allow-delete-print-history-item":{"identifier":"allow-delete-print-history-item","description":"Enables the delete_print_history_item command without any pre-configured scope.","commands":{"allow":["delete_print_history_item"],"deny":[]}},"allow-delete-printer-config":{"identifier":"allow-delete-printer-config","description":"Enables the delete_printer_config command without any pre-configured scope.","commands":{"allow":["delete_printer_config"],"deny":[]}},"allow-design-approve":{"identifier":"allow-design-approve","description":"Enables the design_approve command without any pre-configured scope.","commands":{"allow":["design_approve"],"deny":[]}},"allow-design-build":{"identifier":"allow-design-build","description":"Enables the design_build command without any pre-configured scope.","commands":{"allow":["design_build"],"deny":[]}},"allow-design-cancel":{"identifier":"allow-design-cancel","description":"Enables the design_cancel command without any pre-configured scope.","commands":{"allow":["design_cancel"],"deny":[]}},"allow-design-export":{"identifier":"allow-design-export","description":"Enables the design_export command without any pre-configured scope.","commands":{"allow":["design_export"],"deny":[]}},"allow-design-get":{"identifier":"allow-design-get","description":"Enables the design_get command without any pre-configured scope.","commands":{"allow":["design_get"],"deny":[]}},"allow-design-lineage":{"identifier":"allow-design-lineage","description":"Enables the design_lineage command without any pre-configured scope.","commands":{"allow":["design_lineage"],"deny":[]}},"allow-design-list":{"identifier":"allow-design-list","description":"Enables the design_list command without any pre-configured scope.","commands":{"allow":["design_list"],"deny":[]}},"allow-design-preview":{"identifier":"allow-design-preview","description":"Enables the design_preview command without any pre-configured scope.","commands":{"allow":["design_preview"],"deny":[]}},"allow-design-record-print":{"identifier":"allow-design-record-print","description":"Enables the design_record_print command without any pre-configured scope.","commands":{"allow":["design_record_print"],"deny":[]}},"allow-disconnect-printer":{"identifier":"allow-disconnect-printer","description":"Enables the disconnect_printer command without any pre-configured scope.","commands":{"allow":["disconnect_printer"],"deny":[]}},"allow-download-model":{"identifier":"allow-download-model","description":"Enables the download_model command without any pre-configured scope.","commands":{"allow":["download_model"],"deny":[]}},"allow-get-app-state":{"identifier":"allow-get-app-state","description":"Enables the get_app_state command without any pre-configured scope.","commands":{"allow":["get_app_state"],"deny":[]}},"allow-get-credential":{"identifier":"allow-get-credential","description":"Enables the get_credential command without any pre-configured scope.","commands":{"allow":["get_credential"],"deny":[]}},"allow-get-designs-dir":{"identifier":"allow-get-designs-dir","description":"Enables the get_designs_dir command without any pre-configured scope.","commands":{"allow":["get_designs_dir"],"deny":[]}},"allow-get-library-models":{"identifier":"allow-get-library-models","description":"Enables the get_library_models command without any pre-configured scope.","commands":{"allow":["get_library_models"],"deny":[]}},"allow-get-print-history":{"identifier":"allow-get-print-history","description":"Enables the get_print_history command without any pre-configured scope.","commands":{"allow":["get_print_history"],"deny":[]}},"allow-get-printer-configs":{"identifier":"allow-get-printer-configs","description":"Enables the get_printer_configs command without any pre-configured scope.","commands":{"allow":["get_printer_configs"],"deny":[]}},"allow-get-printer-status":{"identifier":"allow-get-printer-status","description":"Enables the get_printer_status command without any pre-configured scope.","commands":{"allow":["get_printer_status"],"deny":[]}},"allow-get-queue":{"identifier":"allow-get-queue","description":"Enables the get_queue command without any pre-configured scope.","commands":{"allow":["get_queue"],"deny":[]}},"allow-get-settings":{"identifier":"allow-get-settings","description":"Enables the get_settings command without any pre-configured scope.","commands":{"allow":["get_settings"],"deny":[]}},"allow-has-credential":{"identifier":"allow-has-credential","description":"Enables the has_credential command without any pre-configured scope.","commands":{"allow":["has_credential"],"deny":[]}},"allow-list-profiles":{"identifier":"allow-list-profiles","description":"Enables the list_profiles command without any pre-configured scope.","commands":{"allow":["list_profiles"],"deny":[]}},"allow-makerworld-back":{"identifier":"allow-makerworld-back","description":"Enables the makerworld_back command without any pre-configured scope.","commands":{"allow":["makerworld_back"],"deny":[]}},"allow-makerworld-forward":{"identifier":"allow-makerworld-forward","description":"Enables the makerworld_forward command without any pre-configured scope.","commands":{"allow":["makerworld_forward"],"deny":[]}},"allow-makerworld-reload":{"identifier":"allow-makerworld-reload","description":"Enables the makerworld_reload command without any pre-configured scope.","commands":{"allow":["makerworld_reload"],"deny":[]}},"allow-mcp-rotate-token":{"identifier":"allow-mcp-rotate-token","description":"Enables the mcp_rotate_token command without any pre-configured scope.","commands":{"allow":["mcp_rotate_token"],"deny":[]}},"allow-mcp-set-enabled":{"identifier":"allow-mcp-set-enabled","description":"Enables the mcp_set_enabled command without any pre-configured scope.","commands":{"allow":["mcp_set_enabled"],"deny":[]}},"allow-mcp-status":{"identifier":"allow-mcp-status","description":"Enables the mcp_status command without any pre-configured scope.","commands":{"allow":["mcp_status"],"deny":[]}},"allow-mcp-token":{"identifier":"allow-mcp-token","description":"Enables the mcp_token command without any pre-configured scope.","commands":{"allow":["mcp_token"],"deny":[]}},"allow-navigate-makerworld":{"identifier":"allow-navigate-makerworld","description":"Enables the navigate_makerworld command without any pre-configured scope.","commands":{"allow":["navigate_makerworld"],"deny":[]}},"allow-open-library-model":{"identifier":"allow-open-library-model","description":"Enables the open_library_model command without any pre-configured scope.","commands":{"allow":["open_library_model"],"deny":[]}},"allow-openscad-check-installed":{"identifier":"allow-openscad-check-installed","description":"Enables the openscad_check_installed command without any pre-configured scope.","commands":{"allow":["openscad_check_installed"],"deny":[]}},"allow-openscad-extract-params":{"identifier":"allow-openscad-extract-params","description":"Enables the openscad_extract_params command without any pre-configured scope.","commands":{"allow":["openscad_extract_params"],"deny":[]}},"allow-openscad-render":{"identifier":"allow-openscad-render","description":"Enables the openscad_render command without any pre-configured scope.","commands":{"allow":["openscad_render"],"deny":[]}},"allow-pause-print":{"identifier":"allow-pause-print","description":"Enables the pause_print command without any pre-configured scope.","commands":{"allow":["pause_print"],"deny":[]}},"allow-probe-camera":{"identifier":"allow-probe-camera","description":"Enables the probe_camera command without any pre-configured scope.","commands":{"allow":["probe_camera"],"deny":[]}},"allow-read-model-file":{"identifier":"allow-read-model-file","description":"Enables the read_model_file command without any pre-configured scope.","commands":{"allow":["read_model_file"],"deny":[]}},"allow-read-text-file":{"identifier":"allow-read-text-file","description":"Enables the read_text_file command without any pre-configured scope.","commands":{"allow":["read_text_file"],"deny":[]}},"allow-remove-from-queue":{"identifier":"allow-remove-from-queue","description":"Enables the remove_from_queue command without any pre-configured scope.","commands":{"allow":["remove_from_queue"],"deny":[]}},"allow-report-makerworld-import-attempt":{"identifier":"allow-report-makerworld-import-attempt","description":"Enables the report_makerworld_import_attempt command without any pre-configured scope.","commands":{"allow":["report_makerworld_import_attempt"],"deny":[]}},"allow-report-makerworld-page":{"identifier":"allow-report-makerworld-page","description":"Enables the report_makerworld_page command without any pre-configured scope.","commands":{"allow":["report_makerworld_page"],"deny":[]}},"allow-resume-print":{"identifier":"allow-resume-print","description":"Enables the resume_print command without any pre-configured scope.","commands":{"allow":["resume_print"],"deny":[]}},"allow-search-library-models":{"identifier":"allow-search-library-models","description":"Enables the search_library_models command without any pre-configured scope.","commands":{"allow":["search_library_models"],"deny":[]}},"allow-search-makerworld":{"identifier":"allow-search-makerworld","description":"Enables the search_makerworld command without any pre-configured scope.","commands":{"allow":["search_makerworld"],"deny":[]}},"allow-set-active-view":{"identifier":"allow-set-active-view","description":"Enables the set_active_view command without any pre-configured scope.","commands":{"allow":["set_active_view"],"deny":[]}},"allow-set-default-printer":{"identifier":"allow-set-default-printer","description":"Enables the set_default_printer command without any pre-configured scope.","commands":{"allow":["set_default_printer"],"deny":[]}},"allow-sidecar":{"identifier":"allow-sidecar","description":"Enables the sidecar command without any pre-configured scope.","commands":{"allow":["sidecar"],"deny":[]}},"allow-slice-model":{"identifier":"allow-slice-model","description":"Enables the slice_model command without any pre-configured scope.","commands":{"allow":["slice_model"],"deny":[]}},"allow-start-print":{"identifier":"allow-start-print","description":"Enables the start_print command without any pre-configured scope.","commands":{"allow":["start_print"],"deny":[]}},"allow-store-credential":{"identifier":"allow-store-credential","description":"Enables the store_credential command without any pre-configured scope.","commands":{"allow":["store_credential"],"deny":[]}},"allow-studio-cancel":{"identifier":"allow-studio-cancel","description":"Enables the studio_cancel command without any pre-configured scope.","commands":{"allow":["studio_cancel"],"deny":[]}},"allow-studio-create-project":{"identifier":"allow-studio-create-project","description":"Enables the studio_create_project command without any pre-configured scope.","commands":{"allow":["studio_create_project"],"deny":[]}},"allow-studio-files":{"identifier":"allow-studio-files","description":"Enables the studio_files command without any pre-configured scope.","commands":{"allow":["studio_files"],"deny":[]}},"allow-studio-import-file":{"identifier":"allow-studio-import-file","description":"Enables the studio_import_file command without any pre-configured scope.","commands":{"allow":["studio_import_file"],"deny":[]}},"allow-studio-open-in-bambu":{"identifier":"allow-studio-open-in-bambu","description":"Enables the studio_open_in_bambu command without any pre-configured scope.","commands":{"allow":["studio_open_in_bambu"],"deny":[]}},"allow-studio-projects":{"identifier":"allow-studio-projects","description":"Enables the studio_projects command without any pre-configured scope.","commands":{"allow":["studio_projects"],"deny":[]}},"allow-studio-reveal":{"identifier":"allow-studio-reveal","description":"Enables the studio_reveal command without any pre-configured scope.","commands":{"allow":["studio_reveal"],"deny":[]}},"allow-studio-save-bytes":{"identifier":"allow-studio-save-bytes","description":"Enables the studio_save_bytes command without any pre-configured scope.","commands":{"allow":["studio_save_bytes"],"deny":[]}},"allow-studio-send":{"identifier":"allow-studio-send","description":"Enables the studio_send command without any pre-configured scope.","commands":{"allow":["studio_send"],"deny":[]}},"allow-studio-transcript":{"identifier":"allow-studio-transcript","description":"Enables the studio_transcript command without any pre-configured scope.","commands":{"allow":["studio_transcript"],"deny":[]}},"allow-switch-printer":{"identifier":"allow-switch-printer","description":"Enables the switch_printer command without any pre-configured scope.","commands":{"allow":["switch_printer"],"deny":[]}},"allow-sync-makerworld-webview":{"identifier":"allow-sync-makerworld-webview","description":"Enables the sync_makerworld_webview command without any pre-configured scope.","commands":{"allow":["sync_makerworld_webview"],"deny":[]}},"allow-update-check":{"identifier":"allow-update-check","description":"Enables the update_check command without any pre-configured scope.","commands":{"allow":["update_check"],"deny":[]}},"allow-update-install":{"identifier":"allow-update-install","description":"Enables the update_install command without any pre-configured scope.","commands":{"allow":["update_install"],"deny":[]}},"allow-update-printer-config":{"identifier":"allow-update-printer-config","description":"Enables the update_printer_config command without any pre-configured scope.","commands":{"allow":["update_printer_config"],"deny":[]}},"allow-update-settings":{"identifier":"allow-update-settings","description":"Enables the update_settings command without any pre-configured scope.","commands":{"allow":["update_settings"],"deny":[]}},"allow-write-text-file":{"identifier":"allow-write-text-file","description":"Enables the write_text_file command without any pre-configured scope.","commands":{"allow":["write_text_file"],"deny":[]}},"deny-add-printer-config":{"identifier":"deny-add-printer-config","description":"Denies the add_printer_config command without any pre-configured scope.","commands":{"allow":[],"deny":["add_printer_config"]}},"deny-add-to-queue":{"identifier":"deny-add-to-queue","description":"Denies the add_to_queue command without any pre-configured scope.","commands":{"allow":[],"deny":["add_to_queue"]}},"deny-agent-cancel":{"identifier":"deny-agent-cancel","description":"Denies the agent_cancel command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_cancel"]}},"deny-agent-clear-api-key":{"identifier":"deny-agent-clear-api-key","description":"Denies the agent_clear_api_key command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_clear_api_key"]}},"deny-agent-history":{"identifier":"deny-agent-history","description":"Denies the agent_history command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_history"]}},"deny-agent-new-conversation":{"identifier":"deny-agent-new-conversation","description":"Denies the agent_new_conversation command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_new_conversation"]}},"deny-agent-send":{"identifier":"deny-agent-send","description":"Denies the agent_send command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_send"]}},"deny-agent-set-api-key":{"identifier":"deny-agent-set-api-key","description":"Denies the agent_set_api_key command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_set_api_key"]}},"deny-agent-set-base-url":{"identifier":"deny-agent-set-base-url","description":"Denies the agent_set_base_url command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_set_base_url"]}},"deny-agent-set-model":{"identifier":"deny-agent-set-model","description":"Denies the agent_set_model command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_set_model"]}},"deny-agent-status":{"identifier":"deny-agent-status","description":"Denies the agent_status command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_status"]}},"deny-bambu-studio-status":{"identifier":"deny-bambu-studio-status","description":"Denies the bambu_studio_status command without any pre-configured scope.","commands":{"allow":[],"deny":["bambu_studio_status"]}},"deny-choose-bambu-studio":{"identifier":"deny-choose-bambu-studio","description":"Denies the choose_bambu_studio command without any pre-configured scope.","commands":{"allow":[],"deny":["choose_bambu_studio"]}},"deny-clear-bambu-studio":{"identifier":"deny-clear-bambu-studio","description":"Denies the clear_bambu_studio command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_bambu_studio"]}},"deny-connect-printer":{"identifier":"deny-connect-printer","description":"Denies the connect_printer command without any pre-configured scope.","commands":{"allow":[],"deny":["connect_printer"]}},"deny-connect-printer-by-config":{"identifier":"deny-connect-printer-by-config","description":"Denies the connect_printer_by_config command without any pre-configured scope.","commands":{"allow":[],"deny":["connect_printer_by_config"]}},"deny-delete-credential":{"identifier":"deny-delete-credential","description":"Denies the delete_credential command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_credential"]}},"deny-delete-library-model":{"identifier":"deny-delete-library-model","description":"Denies the delete_library_model command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_library_model"]}},"deny-delete-print-history-item":{"identifier":"deny-delete-print-history-item","description":"Denies the delete_print_history_item command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_print_history_item"]}},"deny-delete-printer-config":{"identifier":"deny-delete-printer-config","description":"Denies the delete_printer_config command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_printer_config"]}},"deny-design-approve":{"identifier":"deny-design-approve","description":"Denies the design_approve command without any pre-configured scope.","commands":{"allow":[],"deny":["design_approve"]}},"deny-design-build":{"identifier":"deny-design-build","description":"Denies the design_build command without any pre-configured scope.","commands":{"allow":[],"deny":["design_build"]}},"deny-design-cancel":{"identifier":"deny-design-cancel","description":"Denies the design_cancel command without any pre-configured scope.","commands":{"allow":[],"deny":["design_cancel"]}},"deny-design-export":{"identifier":"deny-design-export","description":"Denies the design_export command without any pre-configured scope.","commands":{"allow":[],"deny":["design_export"]}},"deny-design-get":{"identifier":"deny-design-get","description":"Denies the design_get command without any pre-configured scope.","commands":{"allow":[],"deny":["design_get"]}},"deny-design-lineage":{"identifier":"deny-design-lineage","description":"Denies the design_lineage command without any pre-configured scope.","commands":{"allow":[],"deny":["design_lineage"]}},"deny-design-list":{"identifier":"deny-design-list","description":"Denies the design_list command without any pre-configured scope.","commands":{"allow":[],"deny":["design_list"]}},"deny-design-preview":{"identifier":"deny-design-preview","description":"Denies the design_preview command without any pre-configured scope.","commands":{"allow":[],"deny":["design_preview"]}},"deny-design-record-print":{"identifier":"deny-design-record-print","description":"Denies the design_record_print command without any pre-configured scope.","commands":{"allow":[],"deny":["design_record_print"]}},"deny-disconnect-printer":{"identifier":"deny-disconnect-printer","description":"Denies the disconnect_printer command without any pre-configured scope.","commands":{"allow":[],"deny":["disconnect_printer"]}},"deny-download-model":{"identifier":"deny-download-model","description":"Denies the download_model command without any pre-configured scope.","commands":{"allow":[],"deny":["download_model"]}},"deny-get-app-state":{"identifier":"deny-get-app-state","description":"Denies the get_app_state command without any pre-configured scope.","commands":{"allow":[],"deny":["get_app_state"]}},"deny-get-credential":{"identifier":"deny-get-credential","description":"Denies the get_credential command without any pre-configured scope.","commands":{"allow":[],"deny":["get_credential"]}},"deny-get-designs-dir":{"identifier":"deny-get-designs-dir","description":"Denies the get_designs_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["get_designs_dir"]}},"deny-get-library-models":{"identifier":"deny-get-library-models","description":"Denies the get_library_models command without any pre-configured scope.","commands":{"allow":[],"deny":["get_library_models"]}},"deny-get-print-history":{"identifier":"deny-get-print-history","description":"Denies the get_print_history command without any pre-configured scope.","commands":{"allow":[],"deny":["get_print_history"]}},"deny-get-printer-configs":{"identifier":"deny-get-printer-configs","description":"Denies the get_printer_configs command without any pre-configured scope.","commands":{"allow":[],"deny":["get_printer_configs"]}},"deny-get-printer-status":{"identifier":"deny-get-printer-status","description":"Denies the get_printer_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_printer_status"]}},"deny-get-queue":{"identifier":"deny-get-queue","description":"Denies the get_queue command without any pre-configured scope.","commands":{"allow":[],"deny":["get_queue"]}},"deny-get-settings":{"identifier":"deny-get-settings","description":"Denies the get_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["get_settings"]}},"deny-has-credential":{"identifier":"deny-has-credential","description":"Denies the has_credential command without any pre-configured scope.","commands":{"allow":[],"deny":["has_credential"]}},"deny-list-profiles":{"identifier":"deny-list-profiles","description":"Denies the list_profiles command without any pre-configured scope.","commands":{"allow":[],"deny":["list_profiles"]}},"deny-makerworld-back":{"identifier":"deny-makerworld-back","description":"Denies the makerworld_back command without any pre-configured scope.","commands":{"allow":[],"deny":["makerworld_back"]}},"deny-makerworld-forward":{"identifier":"deny-makerworld-forward","description":"Denies the makerworld_forward command without any pre-configured scope.","commands":{"allow":[],"deny":["makerworld_forward"]}},"deny-makerworld-reload":{"identifier":"deny-makerworld-reload","description":"Denies the makerworld_reload command without any pre-configured scope.","commands":{"allow":[],"deny":["makerworld_reload"]}},"deny-mcp-rotate-token":{"identifier":"deny-mcp-rotate-token","description":"Denies the mcp_rotate_token command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_rotate_token"]}},"deny-mcp-set-enabled":{"identifier":"deny-mcp-set-enabled","description":"Denies the mcp_set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_set_enabled"]}},"deny-mcp-status":{"identifier":"deny-mcp-status","description":"Denies the mcp_status command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_status"]}},"deny-mcp-token":{"identifier":"deny-mcp-token","description":"Denies the mcp_token command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_token"]}},"deny-navigate-makerworld":{"identifier":"deny-navigate-makerworld","description":"Denies the navigate_makerworld command without any pre-configured scope.","commands":{"allow":[],"deny":["navigate_makerworld"]}},"deny-open-library-model":{"identifier":"deny-open-library-model","description":"Denies the open_library_model command without any pre-configured scope.","commands":{"allow":[],"deny":["open_library_model"]}},"deny-openscad-check-installed":{"identifier":"deny-openscad-check-installed","description":"Denies the openscad_check_installed command without any pre-configured scope.","commands":{"allow":[],"deny":["openscad_check_installed"]}},"deny-openscad-extract-params":{"identifier":"deny-openscad-extract-params","description":"Denies the openscad_extract_params command without any pre-configured scope.","commands":{"allow":[],"deny":["openscad_extract_params"]}},"deny-openscad-render":{"identifier":"deny-openscad-render","description":"Denies the openscad_render command without any pre-configured scope.","commands":{"allow":[],"deny":["openscad_render"]}},"deny-pause-print":{"identifier":"deny-pause-print","description":"Denies the pause_print command without any pre-configured scope.","commands":{"allow":[],"deny":["pause_print"]}},"deny-probe-camera":{"identifier":"deny-probe-camera","description":"Denies the probe_camera command without any pre-configured scope.","commands":{"allow":[],"deny":["probe_camera"]}},"deny-read-model-file":{"identifier":"deny-read-model-file","description":"Denies the read_model_file command without any pre-configured scope.","commands":{"allow":[],"deny":["read_model_file"]}},"deny-read-text-file":{"identifier":"deny-read-text-file","description":"Denies the read_text_file command without any pre-configured scope.","commands":{"allow":[],"deny":["read_text_file"]}},"deny-remove-from-queue":{"identifier":"deny-remove-from-queue","description":"Denies the remove_from_queue command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_from_queue"]}},"deny-report-makerworld-import-attempt":{"identifier":"deny-report-makerworld-import-attempt","description":"Denies the report_makerworld_import_attempt command without any pre-configured scope.","commands":{"allow":[],"deny":["report_makerworld_import_attempt"]}},"deny-report-makerworld-page":{"identifier":"deny-report-makerworld-page","description":"Denies the report_makerworld_page command without any pre-configured scope.","commands":{"allow":[],"deny":["report_makerworld_page"]}},"deny-resume-print":{"identifier":"deny-resume-print","description":"Denies the resume_print command without any pre-configured scope.","commands":{"allow":[],"deny":["resume_print"]}},"deny-search-library-models":{"identifier":"deny-search-library-models","description":"Denies the search_library_models command without any pre-configured scope.","commands":{"allow":[],"deny":["search_library_models"]}},"deny-search-makerworld":{"identifier":"deny-search-makerworld","description":"Denies the search_makerworld command without any pre-configured scope.","commands":{"allow":[],"deny":["search_makerworld"]}},"deny-set-active-view":{"identifier":"deny-set-active-view","description":"Denies the set_active_view command without any pre-configured scope.","commands":{"allow":[],"deny":["set_active_view"]}},"deny-set-default-printer":{"identifier":"deny-set-default-printer","description":"Denies the set_default_printer command without any pre-configured scope.","commands":{"allow":[],"deny":["set_default_printer"]}},"deny-sidecar":{"identifier":"deny-sidecar","description":"Denies the sidecar command without any pre-configured scope.","commands":{"allow":[],"deny":["sidecar"]}},"deny-slice-model":{"identifier":"deny-slice-model","description":"Denies the slice_model command without any pre-configured scope.","commands":{"allow":[],"deny":["slice_model"]}},"deny-start-print":{"identifier":"deny-start-print","description":"Denies the start_print command without any pre-configured scope.","commands":{"allow":[],"deny":["start_print"]}},"deny-store-credential":{"identifier":"deny-store-credential","description":"Denies the store_credential command without any pre-configured scope.","commands":{"allow":[],"deny":["store_credential"]}},"deny-studio-cancel":{"identifier":"deny-studio-cancel","description":"Denies the studio_cancel command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_cancel"]}},"deny-studio-create-project":{"identifier":"deny-studio-create-project","description":"Denies the studio_create_project command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_create_project"]}},"deny-studio-files":{"identifier":"deny-studio-files","description":"Denies the studio_files command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_files"]}},"deny-studio-import-file":{"identifier":"deny-studio-import-file","description":"Denies the studio_import_file command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_import_file"]}},"deny-studio-open-in-bambu":{"identifier":"deny-studio-open-in-bambu","description":"Denies the studio_open_in_bambu command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_open_in_bambu"]}},"deny-studio-projects":{"identifier":"deny-studio-projects","description":"Denies the studio_projects command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_projects"]}},"deny-studio-reveal":{"identifier":"deny-studio-reveal","description":"Denies the studio_reveal command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_reveal"]}},"deny-studio-save-bytes":{"identifier":"deny-studio-save-bytes","description":"Denies the studio_save_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_save_bytes"]}},"deny-studio-send":{"identifier":"deny-studio-send","description":"Denies the studio_send command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_send"]}},"deny-studio-transcript":{"identifier":"deny-studio-transcript","description":"Denies the studio_transcript command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_transcript"]}},"deny-switch-printer":{"identifier":"deny-switch-printer","description":"Denies the switch_printer command without any pre-configured scope.","commands":{"allow":[],"deny":["switch_printer"]}},"deny-sync-makerworld-webview":{"identifier":"deny-sync-makerworld-webview","description":"Denies the sync_makerworld_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["sync_makerworld_webview"]}},"deny-update-check":{"identifier":"deny-update-check","description":"Denies the update_check command without any pre-configured scope.","commands":{"allow":[],"deny":["update_check"]}},"deny-update-install":{"identifier":"deny-update-install","description":"Denies the update_install command without any pre-configured scope.","commands":{"allow":[],"deny":["update_install"]}},"deny-update-printer-config":{"identifier":"deny-update-printer-config","description":"Denies the update_printer_config command without any pre-configured scope.","commands":{"allow":[],"deny":["update_printer_config"]}},"deny-update-settings":{"identifier":"deny-update-settings","description":"Denies the update_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["update_settings"]}},"deny-write-text-file":{"identifier":"deny-write-text-file","description":"Denies the write_text_file command without any pre-configured scope.","commands":{"allow":[],"deny":["write_text_file"]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-exit":{"identifier":"allow-exit","description":"Enables the exit command without any pre-configured scope.","commands":{"allow":["exit"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-exit":{"identifier":"deny-exit","description":"Denies the exit command without any pre-configured scope.","commands":{"allow":[],"deny":["exit"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-icon-with-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-fullscreen-on-monitor":{"identifier":"allow-set-fullscreen-on-monitor","description":"Enables the set_fullscreen_on_monitor command without any pre-configured scope.","commands":{"allow":["set_fullscreen_on_monitor"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-fullscreen-on-monitor":{"identifier":"deny-set-fullscreen-on-monitor","description":"Denies the set_fullscreen_on_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen_on_monitor"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-ask","allow-confirm","allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope.","commands":{"allow":["ask"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope.","commands":{"allow":["confirm"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope.","commands":{"allow":[],"deny":["ask"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope.","commands":{"allow":[],"deny":["confirm"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null},"log":{"default_permission":{"identifier":"default","description":"Allows the log command","permissions":["allow-log"]},"permissions":{"allow-log":{"identifier":"allow-log","description":"Enables the log command without any pre-configured scope.","commands":{"allow":["log"],"deny":[]}},"deny-log":{"identifier":"deny-log","description":"Denies the log command without any pre-configured scope.","commands":{"allow":[],"deny":["log"]}}},"permission_sets":{},"global_scope_schema":null},"notification":{"default_permission":{"identifier":"default","description":"This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n","permissions":["allow-is-permission-granted","allow-request-permission","allow-notify","allow-register-action-types","allow-register-listener","allow-cancel","allow-get-pending","allow-remove-active","allow-get-active","allow-check-permissions","allow-show","allow-batch","allow-list-channels","allow-delete-channel","allow-create-channel","allow-permission-state"]},"permissions":{"allow-batch":{"identifier":"allow-batch","description":"Enables the batch command without any pre-configured scope.","commands":{"allow":["batch"],"deny":[]}},"allow-cancel":{"identifier":"allow-cancel","description":"Enables the cancel command without any pre-configured scope.","commands":{"allow":["cancel"],"deny":[]}},"allow-check-permissions":{"identifier":"allow-check-permissions","description":"Enables the check_permissions command without any pre-configured scope.","commands":{"allow":["check_permissions"],"deny":[]}},"allow-create-channel":{"identifier":"allow-create-channel","description":"Enables the create_channel command without any pre-configured scope.","commands":{"allow":["create_channel"],"deny":[]}},"allow-delete-channel":{"identifier":"allow-delete-channel","description":"Enables the delete_channel command without any pre-configured scope.","commands":{"allow":["delete_channel"],"deny":[]}},"allow-get-active":{"identifier":"allow-get-active","description":"Enables the get_active command without any pre-configured scope.","commands":{"allow":["get_active"],"deny":[]}},"allow-get-pending":{"identifier":"allow-get-pending","description":"Enables the get_pending command without any pre-configured scope.","commands":{"allow":["get_pending"],"deny":[]}},"allow-is-permission-granted":{"identifier":"allow-is-permission-granted","description":"Enables the is_permission_granted command without any pre-configured scope.","commands":{"allow":["is_permission_granted"],"deny":[]}},"allow-list-channels":{"identifier":"allow-list-channels","description":"Enables the list_channels command without any pre-configured scope.","commands":{"allow":["list_channels"],"deny":[]}},"allow-notify":{"identifier":"allow-notify","description":"Enables the notify command without any pre-configured scope.","commands":{"allow":["notify"],"deny":[]}},"allow-permission-state":{"identifier":"allow-permission-state","description":"Enables the permission_state command without any pre-configured scope.","commands":{"allow":["permission_state"],"deny":[]}},"allow-register-action-types":{"identifier":"allow-register-action-types","description":"Enables the register_action_types command without any pre-configured scope.","commands":{"allow":["register_action_types"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-active":{"identifier":"allow-remove-active","description":"Enables the remove_active command without any pre-configured scope.","commands":{"allow":["remove_active"],"deny":[]}},"allow-request-permission":{"identifier":"allow-request-permission","description":"Enables the request_permission command without any pre-configured scope.","commands":{"allow":["request_permission"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"deny-batch":{"identifier":"deny-batch","description":"Denies the batch command without any pre-configured scope.","commands":{"allow":[],"deny":["batch"]}},"deny-cancel":{"identifier":"deny-cancel","description":"Denies the cancel command without any pre-configured scope.","commands":{"allow":[],"deny":["cancel"]}},"deny-check-permissions":{"identifier":"deny-check-permissions","description":"Denies the check_permissions command without any pre-configured scope.","commands":{"allow":[],"deny":["check_permissions"]}},"deny-create-channel":{"identifier":"deny-create-channel","description":"Denies the create_channel command without any pre-configured scope.","commands":{"allow":[],"deny":["create_channel"]}},"deny-delete-channel":{"identifier":"deny-delete-channel","description":"Denies the delete_channel command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_channel"]}},"deny-get-active":{"identifier":"deny-get-active","description":"Denies the get_active command without any pre-configured scope.","commands":{"allow":[],"deny":["get_active"]}},"deny-get-pending":{"identifier":"deny-get-pending","description":"Denies the get_pending command without any pre-configured scope.","commands":{"allow":[],"deny":["get_pending"]}},"deny-is-permission-granted":{"identifier":"deny-is-permission-granted","description":"Denies the is_permission_granted command without any pre-configured scope.","commands":{"allow":[],"deny":["is_permission_granted"]}},"deny-list-channels":{"identifier":"deny-list-channels","description":"Denies the list_channels command without any pre-configured scope.","commands":{"allow":[],"deny":["list_channels"]}},"deny-notify":{"identifier":"deny-notify","description":"Denies the notify command without any pre-configured scope.","commands":{"allow":[],"deny":["notify"]}},"deny-permission-state":{"identifier":"deny-permission-state","description":"Denies the permission_state command without any pre-configured scope.","commands":{"allow":[],"deny":["permission_state"]}},"deny-register-action-types":{"identifier":"deny-register-action-types","description":"Denies the register_action_types command without any pre-configured scope.","commands":{"allow":[],"deny":["register_action_types"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-active":{"identifier":"deny-remove-active","description":"Denies the remove_active command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_active"]}},"deny-request-permission":{"identifier":"deny-request-permission","description":"Denies the request_permission command without any pre-configured scope.","commands":{"allow":[],"deny":["request_permission"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}}},"permission_sets":{},"global_scope_schema":null},"opener":{"default_permission":{"identifier":"default","description":"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer","permissions":["allow-open-url","allow-reveal-item-in-dir","allow-default-urls"]},"permissions":{"allow-default-urls":{"identifier":"allow-default-urls","description":"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.","commands":{"allow":[],"deny":[]},"scope":{"allow":[{"url":"mailto:*"},{"url":"tel:*"},{"url":"http://*"},{"url":"https://*"}]}},"allow-open-path":{"identifier":"allow-open-path","description":"Enables the open_path command without any pre-configured scope.","commands":{"allow":["open_path"],"deny":[]}},"allow-open-url":{"identifier":"allow-open-url","description":"Enables the open_url command without any pre-configured scope.","commands":{"allow":["open_url"],"deny":[]}},"allow-reveal-item-in-dir":{"identifier":"allow-reveal-item-in-dir","description":"Enables the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":["reveal_item_in_dir"],"deny":[]}},"deny-open-path":{"identifier":"deny-open-path","description":"Denies the open_path command without any pre-configured scope.","commands":{"allow":[],"deny":["open_path"]}},"deny-open-url":{"identifier":"deny-open-url","description":"Denies the open_url command without any pre-configured scope.","commands":{"allow":[],"deny":["open_url"]}},"deny-reveal-item-in-dir":{"identifier":"deny-reveal-item-in-dir","description":"Denies the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["reveal_item_in_dir"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this url with, for example: firefox."},"url":{"description":"A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"","type":"string"}},"required":["url"],"type":"object"},{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this path with, for example: xdg-open."},"path":{"description":"A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"}},"required":["path"],"type":"object"}],"definitions":{"Application":{"anyOf":[{"description":"Open in default application.","type":"null"},{"description":"If true, allow open with any application.","type":"boolean"},{"description":"Allow specific application to open with.","type":"string"}],"description":"Opener scope application."}},"description":"Opener scope entry.","title":"OpenerScopeEntry"}},"updater":{"default_permission":{"identifier":"default","description":"This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n","permissions":["allow-check","allow-download","allow-install","allow-download-and-install"]},"permissions":{"allow-check":{"identifier":"allow-check","description":"Enables the check command without any pre-configured scope.","commands":{"allow":["check"],"deny":[]}},"allow-download":{"identifier":"allow-download","description":"Enables the download command without any pre-configured scope.","commands":{"allow":["download"],"deny":[]}},"allow-download-and-install":{"identifier":"allow-download-and-install","description":"Enables the download_and_install command without any pre-configured scope.","commands":{"allow":["download_and_install"],"deny":[]}},"allow-install":{"identifier":"allow-install","description":"Enables the install command without any pre-configured scope.","commands":{"allow":["install"],"deny":[]}},"deny-check":{"identifier":"deny-check","description":"Denies the check command without any pre-configured scope.","commands":{"allow":[],"deny":["check"]}},"deny-download":{"identifier":"deny-download","description":"Denies the download command without any pre-configured scope.","commands":{"allow":[],"deny":["download"]}},"deny-download-and-install":{"identifier":"deny-download-and-install","description":"Denies the download_and_install command without any pre-configured scope.","commands":{"allow":[],"deny":["download_and_install"]}},"deny-install":{"identifier":"deny-install","description":"Denies the install command without any pre-configured scope.","commands":{"allow":[],"deny":["install"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file +{"__app-acl__":{"default_permission":null,"permissions":{"allow-add-printer-config":{"identifier":"allow-add-printer-config","description":"Enables the add_printer_config command without any pre-configured scope.","commands":{"allow":["add_printer_config"],"deny":[]}},"allow-add-to-queue":{"identifier":"allow-add-to-queue","description":"Enables the add_to_queue command without any pre-configured scope.","commands":{"allow":["add_to_queue"],"deny":[]}},"allow-agent-cancel":{"identifier":"allow-agent-cancel","description":"Enables the agent_cancel command without any pre-configured scope.","commands":{"allow":["agent_cancel"],"deny":[]}},"allow-agent-clear-api-key":{"identifier":"allow-agent-clear-api-key","description":"Enables the agent_clear_api_key command without any pre-configured scope.","commands":{"allow":["agent_clear_api_key"],"deny":[]}},"allow-agent-history":{"identifier":"allow-agent-history","description":"Enables the agent_history command without any pre-configured scope.","commands":{"allow":["agent_history"],"deny":[]}},"allow-agent-new-conversation":{"identifier":"allow-agent-new-conversation","description":"Enables the agent_new_conversation command without any pre-configured scope.","commands":{"allow":["agent_new_conversation"],"deny":[]}},"allow-agent-send":{"identifier":"allow-agent-send","description":"Enables the agent_send command without any pre-configured scope.","commands":{"allow":["agent_send"],"deny":[]}},"allow-agent-set-api-key":{"identifier":"allow-agent-set-api-key","description":"Enables the agent_set_api_key command without any pre-configured scope.","commands":{"allow":["agent_set_api_key"],"deny":[]}},"allow-agent-set-base-url":{"identifier":"allow-agent-set-base-url","description":"Enables the agent_set_base_url command without any pre-configured scope.","commands":{"allow":["agent_set_base_url"],"deny":[]}},"allow-agent-set-model":{"identifier":"allow-agent-set-model","description":"Enables the agent_set_model command without any pre-configured scope.","commands":{"allow":["agent_set_model"],"deny":[]}},"allow-agent-status":{"identifier":"allow-agent-status","description":"Enables the agent_status command without any pre-configured scope.","commands":{"allow":["agent_status"],"deny":[]}},"allow-bambu-studio-status":{"identifier":"allow-bambu-studio-status","description":"Enables the bambu_studio_status command without any pre-configured scope.","commands":{"allow":["bambu_studio_status"],"deny":[]}},"allow-choose-bambu-studio":{"identifier":"allow-choose-bambu-studio","description":"Enables the choose_bambu_studio command without any pre-configured scope.","commands":{"allow":["choose_bambu_studio"],"deny":[]}},"allow-clear-bambu-studio":{"identifier":"allow-clear-bambu-studio","description":"Enables the clear_bambu_studio command without any pre-configured scope.","commands":{"allow":["clear_bambu_studio"],"deny":[]}},"allow-connect-printer":{"identifier":"allow-connect-printer","description":"Enables the connect_printer command without any pre-configured scope.","commands":{"allow":["connect_printer"],"deny":[]}},"allow-connect-printer-by-config":{"identifier":"allow-connect-printer-by-config","description":"Enables the connect_printer_by_config command without any pre-configured scope.","commands":{"allow":["connect_printer_by_config"],"deny":[]}},"allow-delete-credential":{"identifier":"allow-delete-credential","description":"Enables the delete_credential command without any pre-configured scope.","commands":{"allow":["delete_credential"],"deny":[]}},"allow-delete-library-model":{"identifier":"allow-delete-library-model","description":"Enables the delete_library_model command without any pre-configured scope.","commands":{"allow":["delete_library_model"],"deny":[]}},"allow-delete-print-history-item":{"identifier":"allow-delete-print-history-item","description":"Enables the delete_print_history_item command without any pre-configured scope.","commands":{"allow":["delete_print_history_item"],"deny":[]}},"allow-delete-printer-config":{"identifier":"allow-delete-printer-config","description":"Enables the delete_printer_config command without any pre-configured scope.","commands":{"allow":["delete_printer_config"],"deny":[]}},"allow-design-approve":{"identifier":"allow-design-approve","description":"Enables the design_approve command without any pre-configured scope.","commands":{"allow":["design_approve"],"deny":[]}},"allow-design-build":{"identifier":"allow-design-build","description":"Enables the design_build command without any pre-configured scope.","commands":{"allow":["design_build"],"deny":[]}},"allow-design-cancel":{"identifier":"allow-design-cancel","description":"Enables the design_cancel command without any pre-configured scope.","commands":{"allow":["design_cancel"],"deny":[]}},"allow-design-export":{"identifier":"allow-design-export","description":"Enables the design_export command without any pre-configured scope.","commands":{"allow":["design_export"],"deny":[]}},"allow-design-get":{"identifier":"allow-design-get","description":"Enables the design_get command without any pre-configured scope.","commands":{"allow":["design_get"],"deny":[]}},"allow-design-lineage":{"identifier":"allow-design-lineage","description":"Enables the design_lineage command without any pre-configured scope.","commands":{"allow":["design_lineage"],"deny":[]}},"allow-design-list":{"identifier":"allow-design-list","description":"Enables the design_list command without any pre-configured scope.","commands":{"allow":["design_list"],"deny":[]}},"allow-design-preview":{"identifier":"allow-design-preview","description":"Enables the design_preview command without any pre-configured scope.","commands":{"allow":["design_preview"],"deny":[]}},"allow-design-record-print":{"identifier":"allow-design-record-print","description":"Enables the design_record_print command without any pre-configured scope.","commands":{"allow":["design_record_print"],"deny":[]}},"allow-disconnect-printer":{"identifier":"allow-disconnect-printer","description":"Enables the disconnect_printer command without any pre-configured scope.","commands":{"allow":["disconnect_printer"],"deny":[]}},"allow-download-model":{"identifier":"allow-download-model","description":"Enables the download_model command without any pre-configured scope.","commands":{"allow":["download_model"],"deny":[]}},"allow-get-app-state":{"identifier":"allow-get-app-state","description":"Enables the get_app_state command without any pre-configured scope.","commands":{"allow":["get_app_state"],"deny":[]}},"allow-get-credential":{"identifier":"allow-get-credential","description":"Enables the get_credential command without any pre-configured scope.","commands":{"allow":["get_credential"],"deny":[]}},"allow-get-designs-dir":{"identifier":"allow-get-designs-dir","description":"Enables the get_designs_dir command without any pre-configured scope.","commands":{"allow":["get_designs_dir"],"deny":[]}},"allow-get-library-models":{"identifier":"allow-get-library-models","description":"Enables the get_library_models command without any pre-configured scope.","commands":{"allow":["get_library_models"],"deny":[]}},"allow-get-print-history":{"identifier":"allow-get-print-history","description":"Enables the get_print_history command without any pre-configured scope.","commands":{"allow":["get_print_history"],"deny":[]}},"allow-get-printer-configs":{"identifier":"allow-get-printer-configs","description":"Enables the get_printer_configs command without any pre-configured scope.","commands":{"allow":["get_printer_configs"],"deny":[]}},"allow-get-printer-status":{"identifier":"allow-get-printer-status","description":"Enables the get_printer_status command without any pre-configured scope.","commands":{"allow":["get_printer_status"],"deny":[]}},"allow-get-queue":{"identifier":"allow-get-queue","description":"Enables the get_queue command without any pre-configured scope.","commands":{"allow":["get_queue"],"deny":[]}},"allow-get-settings":{"identifier":"allow-get-settings","description":"Enables the get_settings command without any pre-configured scope.","commands":{"allow":["get_settings"],"deny":[]}},"allow-has-credential":{"identifier":"allow-has-credential","description":"Enables the has_credential command without any pre-configured scope.","commands":{"allow":["has_credential"],"deny":[]}},"allow-list-profiles":{"identifier":"allow-list-profiles","description":"Enables the list_profiles command without any pre-configured scope.","commands":{"allow":["list_profiles"],"deny":[]}},"allow-makerworld-back":{"identifier":"allow-makerworld-back","description":"Enables the makerworld_back command without any pre-configured scope.","commands":{"allow":["makerworld_back"],"deny":[]}},"allow-makerworld-forward":{"identifier":"allow-makerworld-forward","description":"Enables the makerworld_forward command without any pre-configured scope.","commands":{"allow":["makerworld_forward"],"deny":[]}},"allow-makerworld-reload":{"identifier":"allow-makerworld-reload","description":"Enables the makerworld_reload command without any pre-configured scope.","commands":{"allow":["makerworld_reload"],"deny":[]}},"allow-mcp-rotate-token":{"identifier":"allow-mcp-rotate-token","description":"Enables the mcp_rotate_token command without any pre-configured scope.","commands":{"allow":["mcp_rotate_token"],"deny":[]}},"allow-mcp-set-enabled":{"identifier":"allow-mcp-set-enabled","description":"Enables the mcp_set_enabled command without any pre-configured scope.","commands":{"allow":["mcp_set_enabled"],"deny":[]}},"allow-mcp-status":{"identifier":"allow-mcp-status","description":"Enables the mcp_status command without any pre-configured scope.","commands":{"allow":["mcp_status"],"deny":[]}},"allow-mcp-token":{"identifier":"allow-mcp-token","description":"Enables the mcp_token command without any pre-configured scope.","commands":{"allow":["mcp_token"],"deny":[]}},"allow-navigate-makerworld":{"identifier":"allow-navigate-makerworld","description":"Enables the navigate_makerworld command without any pre-configured scope.","commands":{"allow":["navigate_makerworld"],"deny":[]}},"allow-open-library-model":{"identifier":"allow-open-library-model","description":"Enables the open_library_model command without any pre-configured scope.","commands":{"allow":["open_library_model"],"deny":[]}},"allow-openscad-check-installed":{"identifier":"allow-openscad-check-installed","description":"Enables the openscad_check_installed command without any pre-configured scope.","commands":{"allow":["openscad_check_installed"],"deny":[]}},"allow-openscad-extract-params":{"identifier":"allow-openscad-extract-params","description":"Enables the openscad_extract_params command without any pre-configured scope.","commands":{"allow":["openscad_extract_params"],"deny":[]}},"allow-openscad-render":{"identifier":"allow-openscad-render","description":"Enables the openscad_render command without any pre-configured scope.","commands":{"allow":["openscad_render"],"deny":[]}},"allow-pause-print":{"identifier":"allow-pause-print","description":"Enables the pause_print command without any pre-configured scope.","commands":{"allow":["pause_print"],"deny":[]}},"allow-probe-camera":{"identifier":"allow-probe-camera","description":"Enables the probe_camera command without any pre-configured scope.","commands":{"allow":["probe_camera"],"deny":[]}},"allow-read-model-file":{"identifier":"allow-read-model-file","description":"Enables the read_model_file command without any pre-configured scope.","commands":{"allow":["read_model_file"],"deny":[]}},"allow-read-text-file":{"identifier":"allow-read-text-file","description":"Enables the read_text_file command without any pre-configured scope.","commands":{"allow":["read_text_file"],"deny":[]}},"allow-remove-from-queue":{"identifier":"allow-remove-from-queue","description":"Enables the remove_from_queue command without any pre-configured scope.","commands":{"allow":["remove_from_queue"],"deny":[]}},"allow-report-makerworld-import-attempt":{"identifier":"allow-report-makerworld-import-attempt","description":"Enables the report_makerworld_import_attempt command without any pre-configured scope.","commands":{"allow":["report_makerworld_import_attempt"],"deny":[]}},"allow-report-makerworld-page":{"identifier":"allow-report-makerworld-page","description":"Enables the report_makerworld_page command without any pre-configured scope.","commands":{"allow":["report_makerworld_page"],"deny":[]}},"allow-resume-print":{"identifier":"allow-resume-print","description":"Enables the resume_print command without any pre-configured scope.","commands":{"allow":["resume_print"],"deny":[]}},"allow-search-library-models":{"identifier":"allow-search-library-models","description":"Enables the search_library_models command without any pre-configured scope.","commands":{"allow":["search_library_models"],"deny":[]}},"allow-search-makerworld":{"identifier":"allow-search-makerworld","description":"Enables the search_makerworld command without any pre-configured scope.","commands":{"allow":["search_makerworld"],"deny":[]}},"allow-set-active-view":{"identifier":"allow-set-active-view","description":"Enables the set_active_view command without any pre-configured scope.","commands":{"allow":["set_active_view"],"deny":[]}},"allow-set-default-printer":{"identifier":"allow-set-default-printer","description":"Enables the set_default_printer command without any pre-configured scope.","commands":{"allow":["set_default_printer"],"deny":[]}},"allow-sidecar":{"identifier":"allow-sidecar","description":"Enables the sidecar command without any pre-configured scope.","commands":{"allow":["sidecar"],"deny":[]}},"allow-slice-model":{"identifier":"allow-slice-model","description":"Enables the slice_model command without any pre-configured scope.","commands":{"allow":["slice_model"],"deny":[]}},"allow-start-print":{"identifier":"allow-start-print","description":"Enables the start_print command without any pre-configured scope.","commands":{"allow":["start_print"],"deny":[]}},"allow-store-credential":{"identifier":"allow-store-credential","description":"Enables the store_credential command without any pre-configured scope.","commands":{"allow":["store_credential"],"deny":[]}},"allow-studio-files":{"identifier":"allow-studio-files","description":"Enables the studio_files command without any pre-configured scope.","commands":{"allow":["studio_files"],"deny":[]}},"allow-studio-flush-failed":{"identifier":"allow-studio-flush-failed","description":"Enables the studio_flush_failed command without any pre-configured scope.","commands":{"allow":["studio_flush_failed"],"deny":[]}},"allow-studio-flush-started":{"identifier":"allow-studio-flush-started","description":"Enables the studio_flush_started command without any pre-configured scope.","commands":{"allow":["studio_flush_started"],"deny":[]}},"allow-studio-flushed":{"identifier":"allow-studio-flushed","description":"Enables the studio_flushed command without any pre-configured scope.","commands":{"allow":["studio_flushed"],"deny":[]}},"allow-studio-import-file":{"identifier":"allow-studio-import-file","description":"Enables the studio_import_file command without any pre-configured scope.","commands":{"allow":["studio_import_file"],"deny":[]}},"allow-studio-open-files-privacy":{"identifier":"allow-studio-open-files-privacy","description":"Enables the studio_open_files_privacy command without any pre-configured scope.","commands":{"allow":["studio_open_files_privacy"],"deny":[]}},"allow-studio-open-in-bambu":{"identifier":"allow-studio-open-in-bambu","description":"Enables the studio_open_in_bambu command without any pre-configured scope.","commands":{"allow":["studio_open_in_bambu"],"deny":[]}},"allow-studio-reveal":{"identifier":"allow-studio-reveal","description":"Enables the studio_reveal command without any pre-configured scope.","commands":{"allow":["studio_reveal"],"deny":[]}},"allow-studio-save-bytes":{"identifier":"allow-studio-save-bytes","description":"Enables the studio_save_bytes command without any pre-configured scope.","commands":{"allow":["studio_save_bytes"],"deny":[]}},"allow-switch-printer":{"identifier":"allow-switch-printer","description":"Enables the switch_printer command without any pre-configured scope.","commands":{"allow":["switch_printer"],"deny":[]}},"allow-sync-makerworld-webview":{"identifier":"allow-sync-makerworld-webview","description":"Enables the sync_makerworld_webview command without any pre-configured scope.","commands":{"allow":["sync_makerworld_webview"],"deny":[]}},"allow-update-check":{"identifier":"allow-update-check","description":"Enables the update_check command without any pre-configured scope.","commands":{"allow":["update_check"],"deny":[]}},"allow-update-install":{"identifier":"allow-update-install","description":"Enables the update_install command without any pre-configured scope.","commands":{"allow":["update_install"],"deny":[]}},"allow-update-printer-config":{"identifier":"allow-update-printer-config","description":"Enables the update_printer_config command without any pre-configured scope.","commands":{"allow":["update_printer_config"],"deny":[]}},"allow-update-settings":{"identifier":"allow-update-settings","description":"Enables the update_settings command without any pre-configured scope.","commands":{"allow":["update_settings"],"deny":[]}},"allow-write-text-file":{"identifier":"allow-write-text-file","description":"Enables the write_text_file command without any pre-configured scope.","commands":{"allow":["write_text_file"],"deny":[]}},"deny-add-printer-config":{"identifier":"deny-add-printer-config","description":"Denies the add_printer_config command without any pre-configured scope.","commands":{"allow":[],"deny":["add_printer_config"]}},"deny-add-to-queue":{"identifier":"deny-add-to-queue","description":"Denies the add_to_queue command without any pre-configured scope.","commands":{"allow":[],"deny":["add_to_queue"]}},"deny-agent-cancel":{"identifier":"deny-agent-cancel","description":"Denies the agent_cancel command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_cancel"]}},"deny-agent-clear-api-key":{"identifier":"deny-agent-clear-api-key","description":"Denies the agent_clear_api_key command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_clear_api_key"]}},"deny-agent-history":{"identifier":"deny-agent-history","description":"Denies the agent_history command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_history"]}},"deny-agent-new-conversation":{"identifier":"deny-agent-new-conversation","description":"Denies the agent_new_conversation command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_new_conversation"]}},"deny-agent-send":{"identifier":"deny-agent-send","description":"Denies the agent_send command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_send"]}},"deny-agent-set-api-key":{"identifier":"deny-agent-set-api-key","description":"Denies the agent_set_api_key command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_set_api_key"]}},"deny-agent-set-base-url":{"identifier":"deny-agent-set-base-url","description":"Denies the agent_set_base_url command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_set_base_url"]}},"deny-agent-set-model":{"identifier":"deny-agent-set-model","description":"Denies the agent_set_model command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_set_model"]}},"deny-agent-status":{"identifier":"deny-agent-status","description":"Denies the agent_status command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_status"]}},"deny-bambu-studio-status":{"identifier":"deny-bambu-studio-status","description":"Denies the bambu_studio_status command without any pre-configured scope.","commands":{"allow":[],"deny":["bambu_studio_status"]}},"deny-choose-bambu-studio":{"identifier":"deny-choose-bambu-studio","description":"Denies the choose_bambu_studio command without any pre-configured scope.","commands":{"allow":[],"deny":["choose_bambu_studio"]}},"deny-clear-bambu-studio":{"identifier":"deny-clear-bambu-studio","description":"Denies the clear_bambu_studio command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_bambu_studio"]}},"deny-connect-printer":{"identifier":"deny-connect-printer","description":"Denies the connect_printer command without any pre-configured scope.","commands":{"allow":[],"deny":["connect_printer"]}},"deny-connect-printer-by-config":{"identifier":"deny-connect-printer-by-config","description":"Denies the connect_printer_by_config command without any pre-configured scope.","commands":{"allow":[],"deny":["connect_printer_by_config"]}},"deny-delete-credential":{"identifier":"deny-delete-credential","description":"Denies the delete_credential command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_credential"]}},"deny-delete-library-model":{"identifier":"deny-delete-library-model","description":"Denies the delete_library_model command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_library_model"]}},"deny-delete-print-history-item":{"identifier":"deny-delete-print-history-item","description":"Denies the delete_print_history_item command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_print_history_item"]}},"deny-delete-printer-config":{"identifier":"deny-delete-printer-config","description":"Denies the delete_printer_config command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_printer_config"]}},"deny-design-approve":{"identifier":"deny-design-approve","description":"Denies the design_approve command without any pre-configured scope.","commands":{"allow":[],"deny":["design_approve"]}},"deny-design-build":{"identifier":"deny-design-build","description":"Denies the design_build command without any pre-configured scope.","commands":{"allow":[],"deny":["design_build"]}},"deny-design-cancel":{"identifier":"deny-design-cancel","description":"Denies the design_cancel command without any pre-configured scope.","commands":{"allow":[],"deny":["design_cancel"]}},"deny-design-export":{"identifier":"deny-design-export","description":"Denies the design_export command without any pre-configured scope.","commands":{"allow":[],"deny":["design_export"]}},"deny-design-get":{"identifier":"deny-design-get","description":"Denies the design_get command without any pre-configured scope.","commands":{"allow":[],"deny":["design_get"]}},"deny-design-lineage":{"identifier":"deny-design-lineage","description":"Denies the design_lineage command without any pre-configured scope.","commands":{"allow":[],"deny":["design_lineage"]}},"deny-design-list":{"identifier":"deny-design-list","description":"Denies the design_list command without any pre-configured scope.","commands":{"allow":[],"deny":["design_list"]}},"deny-design-preview":{"identifier":"deny-design-preview","description":"Denies the design_preview command without any pre-configured scope.","commands":{"allow":[],"deny":["design_preview"]}},"deny-design-record-print":{"identifier":"deny-design-record-print","description":"Denies the design_record_print command without any pre-configured scope.","commands":{"allow":[],"deny":["design_record_print"]}},"deny-disconnect-printer":{"identifier":"deny-disconnect-printer","description":"Denies the disconnect_printer command without any pre-configured scope.","commands":{"allow":[],"deny":["disconnect_printer"]}},"deny-download-model":{"identifier":"deny-download-model","description":"Denies the download_model command without any pre-configured scope.","commands":{"allow":[],"deny":["download_model"]}},"deny-get-app-state":{"identifier":"deny-get-app-state","description":"Denies the get_app_state command without any pre-configured scope.","commands":{"allow":[],"deny":["get_app_state"]}},"deny-get-credential":{"identifier":"deny-get-credential","description":"Denies the get_credential command without any pre-configured scope.","commands":{"allow":[],"deny":["get_credential"]}},"deny-get-designs-dir":{"identifier":"deny-get-designs-dir","description":"Denies the get_designs_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["get_designs_dir"]}},"deny-get-library-models":{"identifier":"deny-get-library-models","description":"Denies the get_library_models command without any pre-configured scope.","commands":{"allow":[],"deny":["get_library_models"]}},"deny-get-print-history":{"identifier":"deny-get-print-history","description":"Denies the get_print_history command without any pre-configured scope.","commands":{"allow":[],"deny":["get_print_history"]}},"deny-get-printer-configs":{"identifier":"deny-get-printer-configs","description":"Denies the get_printer_configs command without any pre-configured scope.","commands":{"allow":[],"deny":["get_printer_configs"]}},"deny-get-printer-status":{"identifier":"deny-get-printer-status","description":"Denies the get_printer_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_printer_status"]}},"deny-get-queue":{"identifier":"deny-get-queue","description":"Denies the get_queue command without any pre-configured scope.","commands":{"allow":[],"deny":["get_queue"]}},"deny-get-settings":{"identifier":"deny-get-settings","description":"Denies the get_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["get_settings"]}},"deny-has-credential":{"identifier":"deny-has-credential","description":"Denies the has_credential command without any pre-configured scope.","commands":{"allow":[],"deny":["has_credential"]}},"deny-list-profiles":{"identifier":"deny-list-profiles","description":"Denies the list_profiles command without any pre-configured scope.","commands":{"allow":[],"deny":["list_profiles"]}},"deny-makerworld-back":{"identifier":"deny-makerworld-back","description":"Denies the makerworld_back command without any pre-configured scope.","commands":{"allow":[],"deny":["makerworld_back"]}},"deny-makerworld-forward":{"identifier":"deny-makerworld-forward","description":"Denies the makerworld_forward command without any pre-configured scope.","commands":{"allow":[],"deny":["makerworld_forward"]}},"deny-makerworld-reload":{"identifier":"deny-makerworld-reload","description":"Denies the makerworld_reload command without any pre-configured scope.","commands":{"allow":[],"deny":["makerworld_reload"]}},"deny-mcp-rotate-token":{"identifier":"deny-mcp-rotate-token","description":"Denies the mcp_rotate_token command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_rotate_token"]}},"deny-mcp-set-enabled":{"identifier":"deny-mcp-set-enabled","description":"Denies the mcp_set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_set_enabled"]}},"deny-mcp-status":{"identifier":"deny-mcp-status","description":"Denies the mcp_status command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_status"]}},"deny-mcp-token":{"identifier":"deny-mcp-token","description":"Denies the mcp_token command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_token"]}},"deny-navigate-makerworld":{"identifier":"deny-navigate-makerworld","description":"Denies the navigate_makerworld command without any pre-configured scope.","commands":{"allow":[],"deny":["navigate_makerworld"]}},"deny-open-library-model":{"identifier":"deny-open-library-model","description":"Denies the open_library_model command without any pre-configured scope.","commands":{"allow":[],"deny":["open_library_model"]}},"deny-openscad-check-installed":{"identifier":"deny-openscad-check-installed","description":"Denies the openscad_check_installed command without any pre-configured scope.","commands":{"allow":[],"deny":["openscad_check_installed"]}},"deny-openscad-extract-params":{"identifier":"deny-openscad-extract-params","description":"Denies the openscad_extract_params command without any pre-configured scope.","commands":{"allow":[],"deny":["openscad_extract_params"]}},"deny-openscad-render":{"identifier":"deny-openscad-render","description":"Denies the openscad_render command without any pre-configured scope.","commands":{"allow":[],"deny":["openscad_render"]}},"deny-pause-print":{"identifier":"deny-pause-print","description":"Denies the pause_print command without any pre-configured scope.","commands":{"allow":[],"deny":["pause_print"]}},"deny-probe-camera":{"identifier":"deny-probe-camera","description":"Denies the probe_camera command without any pre-configured scope.","commands":{"allow":[],"deny":["probe_camera"]}},"deny-read-model-file":{"identifier":"deny-read-model-file","description":"Denies the read_model_file command without any pre-configured scope.","commands":{"allow":[],"deny":["read_model_file"]}},"deny-read-text-file":{"identifier":"deny-read-text-file","description":"Denies the read_text_file command without any pre-configured scope.","commands":{"allow":[],"deny":["read_text_file"]}},"deny-remove-from-queue":{"identifier":"deny-remove-from-queue","description":"Denies the remove_from_queue command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_from_queue"]}},"deny-report-makerworld-import-attempt":{"identifier":"deny-report-makerworld-import-attempt","description":"Denies the report_makerworld_import_attempt command without any pre-configured scope.","commands":{"allow":[],"deny":["report_makerworld_import_attempt"]}},"deny-report-makerworld-page":{"identifier":"deny-report-makerworld-page","description":"Denies the report_makerworld_page command without any pre-configured scope.","commands":{"allow":[],"deny":["report_makerworld_page"]}},"deny-resume-print":{"identifier":"deny-resume-print","description":"Denies the resume_print command without any pre-configured scope.","commands":{"allow":[],"deny":["resume_print"]}},"deny-search-library-models":{"identifier":"deny-search-library-models","description":"Denies the search_library_models command without any pre-configured scope.","commands":{"allow":[],"deny":["search_library_models"]}},"deny-search-makerworld":{"identifier":"deny-search-makerworld","description":"Denies the search_makerworld command without any pre-configured scope.","commands":{"allow":[],"deny":["search_makerworld"]}},"deny-set-active-view":{"identifier":"deny-set-active-view","description":"Denies the set_active_view command without any pre-configured scope.","commands":{"allow":[],"deny":["set_active_view"]}},"deny-set-default-printer":{"identifier":"deny-set-default-printer","description":"Denies the set_default_printer command without any pre-configured scope.","commands":{"allow":[],"deny":["set_default_printer"]}},"deny-sidecar":{"identifier":"deny-sidecar","description":"Denies the sidecar command without any pre-configured scope.","commands":{"allow":[],"deny":["sidecar"]}},"deny-slice-model":{"identifier":"deny-slice-model","description":"Denies the slice_model command without any pre-configured scope.","commands":{"allow":[],"deny":["slice_model"]}},"deny-start-print":{"identifier":"deny-start-print","description":"Denies the start_print command without any pre-configured scope.","commands":{"allow":[],"deny":["start_print"]}},"deny-store-credential":{"identifier":"deny-store-credential","description":"Denies the store_credential command without any pre-configured scope.","commands":{"allow":[],"deny":["store_credential"]}},"deny-studio-files":{"identifier":"deny-studio-files","description":"Denies the studio_files command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_files"]}},"deny-studio-flush-failed":{"identifier":"deny-studio-flush-failed","description":"Denies the studio_flush_failed command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_flush_failed"]}},"deny-studio-flush-started":{"identifier":"deny-studio-flush-started","description":"Denies the studio_flush_started command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_flush_started"]}},"deny-studio-flushed":{"identifier":"deny-studio-flushed","description":"Denies the studio_flushed command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_flushed"]}},"deny-studio-import-file":{"identifier":"deny-studio-import-file","description":"Denies the studio_import_file command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_import_file"]}},"deny-studio-open-files-privacy":{"identifier":"deny-studio-open-files-privacy","description":"Denies the studio_open_files_privacy command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_open_files_privacy"]}},"deny-studio-open-in-bambu":{"identifier":"deny-studio-open-in-bambu","description":"Denies the studio_open_in_bambu command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_open_in_bambu"]}},"deny-studio-reveal":{"identifier":"deny-studio-reveal","description":"Denies the studio_reveal command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_reveal"]}},"deny-studio-save-bytes":{"identifier":"deny-studio-save-bytes","description":"Denies the studio_save_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_save_bytes"]}},"deny-switch-printer":{"identifier":"deny-switch-printer","description":"Denies the switch_printer command without any pre-configured scope.","commands":{"allow":[],"deny":["switch_printer"]}},"deny-sync-makerworld-webview":{"identifier":"deny-sync-makerworld-webview","description":"Denies the sync_makerworld_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["sync_makerworld_webview"]}},"deny-update-check":{"identifier":"deny-update-check","description":"Denies the update_check command without any pre-configured scope.","commands":{"allow":[],"deny":["update_check"]}},"deny-update-install":{"identifier":"deny-update-install","description":"Denies the update_install command without any pre-configured scope.","commands":{"allow":[],"deny":["update_install"]}},"deny-update-printer-config":{"identifier":"deny-update-printer-config","description":"Denies the update_printer_config command without any pre-configured scope.","commands":{"allow":[],"deny":["update_printer_config"]}},"deny-update-settings":{"identifier":"deny-update-settings","description":"Denies the update_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["update_settings"]}},"deny-write-text-file":{"identifier":"deny-write-text-file","description":"Denies the write_text_file command without any pre-configured scope.","commands":{"allow":[],"deny":["write_text_file"]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-exit":{"identifier":"allow-exit","description":"Enables the exit command without any pre-configured scope.","commands":{"allow":["exit"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-exit":{"identifier":"deny-exit","description":"Denies the exit command without any pre-configured scope.","commands":{"allow":[],"deny":["exit"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-icon-with-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-fullscreen-on-monitor":{"identifier":"allow-set-fullscreen-on-monitor","description":"Enables the set_fullscreen_on_monitor command without any pre-configured scope.","commands":{"allow":["set_fullscreen_on_monitor"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-fullscreen-on-monitor":{"identifier":"deny-set-fullscreen-on-monitor","description":"Denies the set_fullscreen_on_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen_on_monitor"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-ask","allow-confirm","allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope.","commands":{"allow":["ask"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope.","commands":{"allow":["confirm"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope.","commands":{"allow":[],"deny":["ask"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope.","commands":{"allow":[],"deny":["confirm"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null},"log":{"default_permission":{"identifier":"default","description":"Allows the log command","permissions":["allow-log"]},"permissions":{"allow-log":{"identifier":"allow-log","description":"Enables the log command without any pre-configured scope.","commands":{"allow":["log"],"deny":[]}},"deny-log":{"identifier":"deny-log","description":"Denies the log command without any pre-configured scope.","commands":{"allow":[],"deny":["log"]}}},"permission_sets":{},"global_scope_schema":null},"notification":{"default_permission":{"identifier":"default","description":"This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n","permissions":["allow-is-permission-granted","allow-request-permission","allow-notify","allow-register-action-types","allow-register-listener","allow-cancel","allow-get-pending","allow-remove-active","allow-get-active","allow-check-permissions","allow-show","allow-batch","allow-list-channels","allow-delete-channel","allow-create-channel","allow-permission-state"]},"permissions":{"allow-batch":{"identifier":"allow-batch","description":"Enables the batch command without any pre-configured scope.","commands":{"allow":["batch"],"deny":[]}},"allow-cancel":{"identifier":"allow-cancel","description":"Enables the cancel command without any pre-configured scope.","commands":{"allow":["cancel"],"deny":[]}},"allow-check-permissions":{"identifier":"allow-check-permissions","description":"Enables the check_permissions command without any pre-configured scope.","commands":{"allow":["check_permissions"],"deny":[]}},"allow-create-channel":{"identifier":"allow-create-channel","description":"Enables the create_channel command without any pre-configured scope.","commands":{"allow":["create_channel"],"deny":[]}},"allow-delete-channel":{"identifier":"allow-delete-channel","description":"Enables the delete_channel command without any pre-configured scope.","commands":{"allow":["delete_channel"],"deny":[]}},"allow-get-active":{"identifier":"allow-get-active","description":"Enables the get_active command without any pre-configured scope.","commands":{"allow":["get_active"],"deny":[]}},"allow-get-pending":{"identifier":"allow-get-pending","description":"Enables the get_pending command without any pre-configured scope.","commands":{"allow":["get_pending"],"deny":[]}},"allow-is-permission-granted":{"identifier":"allow-is-permission-granted","description":"Enables the is_permission_granted command without any pre-configured scope.","commands":{"allow":["is_permission_granted"],"deny":[]}},"allow-list-channels":{"identifier":"allow-list-channels","description":"Enables the list_channels command without any pre-configured scope.","commands":{"allow":["list_channels"],"deny":[]}},"allow-notify":{"identifier":"allow-notify","description":"Enables the notify command without any pre-configured scope.","commands":{"allow":["notify"],"deny":[]}},"allow-permission-state":{"identifier":"allow-permission-state","description":"Enables the permission_state command without any pre-configured scope.","commands":{"allow":["permission_state"],"deny":[]}},"allow-register-action-types":{"identifier":"allow-register-action-types","description":"Enables the register_action_types command without any pre-configured scope.","commands":{"allow":["register_action_types"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-active":{"identifier":"allow-remove-active","description":"Enables the remove_active command without any pre-configured scope.","commands":{"allow":["remove_active"],"deny":[]}},"allow-request-permission":{"identifier":"allow-request-permission","description":"Enables the request_permission command without any pre-configured scope.","commands":{"allow":["request_permission"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"deny-batch":{"identifier":"deny-batch","description":"Denies the batch command without any pre-configured scope.","commands":{"allow":[],"deny":["batch"]}},"deny-cancel":{"identifier":"deny-cancel","description":"Denies the cancel command without any pre-configured scope.","commands":{"allow":[],"deny":["cancel"]}},"deny-check-permissions":{"identifier":"deny-check-permissions","description":"Denies the check_permissions command without any pre-configured scope.","commands":{"allow":[],"deny":["check_permissions"]}},"deny-create-channel":{"identifier":"deny-create-channel","description":"Denies the create_channel command without any pre-configured scope.","commands":{"allow":[],"deny":["create_channel"]}},"deny-delete-channel":{"identifier":"deny-delete-channel","description":"Denies the delete_channel command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_channel"]}},"deny-get-active":{"identifier":"deny-get-active","description":"Denies the get_active command without any pre-configured scope.","commands":{"allow":[],"deny":["get_active"]}},"deny-get-pending":{"identifier":"deny-get-pending","description":"Denies the get_pending command without any pre-configured scope.","commands":{"allow":[],"deny":["get_pending"]}},"deny-is-permission-granted":{"identifier":"deny-is-permission-granted","description":"Denies the is_permission_granted command without any pre-configured scope.","commands":{"allow":[],"deny":["is_permission_granted"]}},"deny-list-channels":{"identifier":"deny-list-channels","description":"Denies the list_channels command without any pre-configured scope.","commands":{"allow":[],"deny":["list_channels"]}},"deny-notify":{"identifier":"deny-notify","description":"Denies the notify command without any pre-configured scope.","commands":{"allow":[],"deny":["notify"]}},"deny-permission-state":{"identifier":"deny-permission-state","description":"Denies the permission_state command without any pre-configured scope.","commands":{"allow":[],"deny":["permission_state"]}},"deny-register-action-types":{"identifier":"deny-register-action-types","description":"Denies the register_action_types command without any pre-configured scope.","commands":{"allow":[],"deny":["register_action_types"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-active":{"identifier":"deny-remove-active","description":"Denies the remove_active command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_active"]}},"deny-request-permission":{"identifier":"deny-request-permission","description":"Denies the request_permission command without any pre-configured scope.","commands":{"allow":[],"deny":["request_permission"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}}},"permission_sets":{},"global_scope_schema":null},"opener":{"default_permission":{"identifier":"default","description":"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer","permissions":["allow-open-url","allow-reveal-item-in-dir","allow-default-urls"]},"permissions":{"allow-default-urls":{"identifier":"allow-default-urls","description":"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.","commands":{"allow":[],"deny":[]},"scope":{"allow":[{"url":"mailto:*"},{"url":"tel:*"},{"url":"http://*"},{"url":"https://*"}]}},"allow-open-path":{"identifier":"allow-open-path","description":"Enables the open_path command without any pre-configured scope.","commands":{"allow":["open_path"],"deny":[]}},"allow-open-url":{"identifier":"allow-open-url","description":"Enables the open_url command without any pre-configured scope.","commands":{"allow":["open_url"],"deny":[]}},"allow-reveal-item-in-dir":{"identifier":"allow-reveal-item-in-dir","description":"Enables the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":["reveal_item_in_dir"],"deny":[]}},"deny-open-path":{"identifier":"deny-open-path","description":"Denies the open_path command without any pre-configured scope.","commands":{"allow":[],"deny":["open_path"]}},"deny-open-url":{"identifier":"deny-open-url","description":"Denies the open_url command without any pre-configured scope.","commands":{"allow":[],"deny":["open_url"]}},"deny-reveal-item-in-dir":{"identifier":"deny-reveal-item-in-dir","description":"Denies the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["reveal_item_in_dir"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this url with, for example: firefox."},"url":{"description":"A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"","type":"string"}},"required":["url"],"type":"object"},{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this path with, for example: xdg-open."},"path":{"description":"A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"}},"required":["path"],"type":"object"}],"definitions":{"Application":{"anyOf":[{"description":"Open in default application.","type":"null"},{"description":"If true, allow open with any application.","type":"boolean"},{"description":"Allow specific application to open with.","type":"string"}],"description":"Opener scope application."}},"description":"Opener scope entry.","title":"OpenerScopeEntry"}},"updater":{"default_permission":{"identifier":"default","description":"This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n","permissions":["allow-check","allow-download","allow-install","allow-download-and-install"]},"permissions":{"allow-check":{"identifier":"allow-check","description":"Enables the check command without any pre-configured scope.","commands":{"allow":["check"],"deny":[]}},"allow-download":{"identifier":"allow-download","description":"Enables the download command without any pre-configured scope.","commands":{"allow":["download"],"deny":[]}},"allow-download-and-install":{"identifier":"allow-download-and-install","description":"Enables the download_and_install command without any pre-configured scope.","commands":{"allow":["download_and_install"],"deny":[]}},"allow-install":{"identifier":"allow-install","description":"Enables the install command without any pre-configured scope.","commands":{"allow":["install"],"deny":[]}},"deny-check":{"identifier":"deny-check","description":"Denies the check command without any pre-configured scope.","commands":{"allow":[],"deny":["check"]}},"deny-download":{"identifier":"deny-download","description":"Denies the download command without any pre-configured scope.","commands":{"allow":[],"deny":["download"]}},"deny-download-and-install":{"identifier":"deny-download-and-install","description":"Denies the download_and_install command without any pre-configured scope.","commands":{"allow":[],"deny":["download_and_install"]}},"deny-install":{"identifier":"deny-install","description":"Denies the install command without any pre-configured scope.","commands":{"allow":[],"deny":["install"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file diff --git a/src-tauri/gen/schemas/capabilities.json b/src-tauri/gen/schemas/capabilities.json index 1b69c0bb..ca491b65 100644 --- a/src-tauri/gen/schemas/capabilities.json +++ b/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"default":{"identifier":"default","description":"Capability for the local main application webview","local":true,"webviews":["main"],"permissions":["core:default","log:default","opener:default","notification:default","dialog:default","allow-get-app-state","allow-set-active-view","allow-sync-makerworld-webview","allow-navigate-makerworld","allow-search-makerworld","allow-makerworld-back","allow-makerworld-forward","allow-makerworld-reload","allow-download-model","allow-report-makerworld-page","allow-report-makerworld-import-attempt","allow-connect-printer","allow-disconnect-printer","allow-get-printer-status","allow-probe-camera","allow-pause-print","allow-resume-print","allow-start-print","allow-list-profiles","allow-slice-model","allow-read-model-file","allow-read-text-file","allow-write-text-file","allow-add-to-queue","allow-get-queue","allow-remove-from-queue","allow-get-print-history","allow-mcp-status","allow-mcp-set-enabled","allow-mcp-token","allow-mcp-rotate-token","allow-design-build","allow-design-cancel","allow-design-list","allow-design-lineage","allow-design-get","allow-design-preview","allow-design-approve","allow-design-export","allow-design-record-print","allow-agent-status","allow-agent-set-api-key","allow-agent-clear-api-key","allow-agent-set-model","allow-agent-set-base-url","allow-agent-history","allow-agent-new-conversation","allow-agent-send","allow-agent-cancel","allow-delete-print-history-item","allow-get-library-models","allow-search-library-models","allow-delete-library-model","allow-open-library-model","allow-openscad-check-installed","allow-openscad-extract-params","allow-openscad-render","allow-get-designs-dir","allow-store-credential","allow-get-credential","allow-delete-credential","allow-has-credential","allow-add-printer-config","allow-update-printer-config","allow-delete-printer-config","allow-get-printer-configs","allow-set-default-printer","allow-connect-printer-by-config","allow-switch-printer","allow-get-settings","allow-update-settings","allow-bambu-studio-status","allow-choose-bambu-studio","allow-clear-bambu-studio","allow-update-check","allow-update-install","allow-studio-projects","allow-studio-create-project","allow-studio-transcript","allow-studio-send","allow-studio-cancel","allow-studio-files","allow-studio-import-file","allow-studio-save-bytes","allow-studio-reveal","allow-studio-open-in-bambu","allow-sidecar"]},"makerworld-remote":{"identifier":"makerworld-remote","description":"The remote MakerWorld webview may only report the page it shows and the result of an import. It cannot call any other app command.","remote":{"urls":["https://makerworld.com/*","https://*.makerworld.com/*"]},"local":false,"webviews":["makerworld"],"permissions":["allow-report-makerworld-page","allow-report-makerworld-import-attempt"]}} \ No newline at end of file +{"default":{"identifier":"default","description":"Capability for the local main application webview","local":true,"webviews":["main"],"permissions":["core:default","log:default","opener:default","notification:default","dialog:default","allow-get-app-state","allow-set-active-view","allow-sync-makerworld-webview","allow-navigate-makerworld","allow-search-makerworld","allow-makerworld-back","allow-makerworld-forward","allow-makerworld-reload","allow-download-model","allow-report-makerworld-page","allow-report-makerworld-import-attempt","allow-connect-printer","allow-disconnect-printer","allow-get-printer-status","allow-probe-camera","allow-pause-print","allow-resume-print","allow-start-print","allow-list-profiles","allow-slice-model","allow-read-model-file","allow-read-text-file","allow-write-text-file","allow-add-to-queue","allow-get-queue","allow-remove-from-queue","allow-get-print-history","allow-mcp-status","allow-mcp-set-enabled","allow-mcp-token","allow-mcp-rotate-token","allow-design-build","allow-design-cancel","allow-design-list","allow-design-lineage","allow-design-get","allow-design-preview","allow-design-approve","allow-design-export","allow-design-record-print","allow-agent-status","allow-agent-set-api-key","allow-agent-clear-api-key","allow-agent-set-model","allow-agent-set-base-url","allow-agent-history","allow-agent-new-conversation","allow-agent-send","allow-agent-cancel","allow-delete-print-history-item","allow-get-library-models","allow-search-library-models","allow-delete-library-model","allow-open-library-model","allow-openscad-check-installed","allow-openscad-extract-params","allow-openscad-render","allow-get-designs-dir","allow-store-credential","allow-get-credential","allow-delete-credential","allow-has-credential","allow-add-printer-config","allow-update-printer-config","allow-delete-printer-config","allow-get-printer-configs","allow-set-default-printer","allow-connect-printer-by-config","allow-switch-printer","allow-get-settings","allow-update-settings","allow-bambu-studio-status","allow-choose-bambu-studio","allow-clear-bambu-studio","allow-update-check","allow-update-install","allow-studio-files","allow-studio-import-file","allow-studio-save-bytes","allow-studio-reveal","allow-studio-open-in-bambu","allow-studio-flush-started","allow-studio-flush-failed","allow-studio-flushed","allow-studio-open-files-privacy","allow-sidecar"]},"makerworld-remote":{"identifier":"makerworld-remote","description":"The remote MakerWorld webview may only report the page it shows and the result of an import. It cannot call any other app command.","remote":{"urls":["https://makerworld.com/*","https://*.makerworld.com/*"]},"local":false,"webviews":["makerworld"],"permissions":["allow-report-makerworld-page","allow-report-makerworld-import-attempt"]}} \ No newline at end of file diff --git a/src-tauri/gen/schemas/desktop-schema.json b/src-tauri/gen/schemas/desktop-schema.json index cc98244b..3aec35cc 100644 --- a/src-tauri/gen/schemas/desktop-schema.json +++ b/src-tauri/gen/schemas/desktop-schema.json @@ -765,22 +765,28 @@ "markdownDescription": "Enables the store_credential command without any pre-configured scope." }, { - "description": "Enables the studio_cancel command without any pre-configured scope.", + "description": "Enables the studio_files command without any pre-configured scope.", "type": "string", - "const": "allow-studio-cancel", - "markdownDescription": "Enables the studio_cancel command without any pre-configured scope." + "const": "allow-studio-files", + "markdownDescription": "Enables the studio_files command without any pre-configured scope." }, { - "description": "Enables the studio_create_project command without any pre-configured scope.", + "description": "Enables the studio_flush_failed command without any pre-configured scope.", "type": "string", - "const": "allow-studio-create-project", - "markdownDescription": "Enables the studio_create_project command without any pre-configured scope." + "const": "allow-studio-flush-failed", + "markdownDescription": "Enables the studio_flush_failed command without any pre-configured scope." }, { - "description": "Enables the studio_files command without any pre-configured scope.", + "description": "Enables the studio_flush_started command without any pre-configured scope.", "type": "string", - "const": "allow-studio-files", - "markdownDescription": "Enables the studio_files command without any pre-configured scope." + "const": "allow-studio-flush-started", + "markdownDescription": "Enables the studio_flush_started command without any pre-configured scope." + }, + { + "description": "Enables the studio_flushed command without any pre-configured scope.", + "type": "string", + "const": "allow-studio-flushed", + "markdownDescription": "Enables the studio_flushed command without any pre-configured scope." }, { "description": "Enables the studio_import_file command without any pre-configured scope.", @@ -789,16 +795,16 @@ "markdownDescription": "Enables the studio_import_file command without any pre-configured scope." }, { - "description": "Enables the studio_open_in_bambu command without any pre-configured scope.", + "description": "Enables the studio_open_files_privacy command without any pre-configured scope.", "type": "string", - "const": "allow-studio-open-in-bambu", - "markdownDescription": "Enables the studio_open_in_bambu command without any pre-configured scope." + "const": "allow-studio-open-files-privacy", + "markdownDescription": "Enables the studio_open_files_privacy command without any pre-configured scope." }, { - "description": "Enables the studio_projects command without any pre-configured scope.", + "description": "Enables the studio_open_in_bambu command without any pre-configured scope.", "type": "string", - "const": "allow-studio-projects", - "markdownDescription": "Enables the studio_projects command without any pre-configured scope." + "const": "allow-studio-open-in-bambu", + "markdownDescription": "Enables the studio_open_in_bambu command without any pre-configured scope." }, { "description": "Enables the studio_reveal command without any pre-configured scope.", @@ -812,18 +818,6 @@ "const": "allow-studio-save-bytes", "markdownDescription": "Enables the studio_save_bytes command without any pre-configured scope." }, - { - "description": "Enables the studio_send command without any pre-configured scope.", - "type": "string", - "const": "allow-studio-send", - "markdownDescription": "Enables the studio_send command without any pre-configured scope." - }, - { - "description": "Enables the studio_transcript command without any pre-configured scope.", - "type": "string", - "const": "allow-studio-transcript", - "markdownDescription": "Enables the studio_transcript command without any pre-configured scope." - }, { "description": "Enables the switch_printer command without any pre-configured scope.", "type": "string", @@ -1287,22 +1281,28 @@ "markdownDescription": "Denies the store_credential command without any pre-configured scope." }, { - "description": "Denies the studio_cancel command without any pre-configured scope.", + "description": "Denies the studio_files command without any pre-configured scope.", "type": "string", - "const": "deny-studio-cancel", - "markdownDescription": "Denies the studio_cancel command without any pre-configured scope." + "const": "deny-studio-files", + "markdownDescription": "Denies the studio_files command without any pre-configured scope." }, { - "description": "Denies the studio_create_project command without any pre-configured scope.", + "description": "Denies the studio_flush_failed command without any pre-configured scope.", "type": "string", - "const": "deny-studio-create-project", - "markdownDescription": "Denies the studio_create_project command without any pre-configured scope." + "const": "deny-studio-flush-failed", + "markdownDescription": "Denies the studio_flush_failed command without any pre-configured scope." }, { - "description": "Denies the studio_files command without any pre-configured scope.", + "description": "Denies the studio_flush_started command without any pre-configured scope.", "type": "string", - "const": "deny-studio-files", - "markdownDescription": "Denies the studio_files command without any pre-configured scope." + "const": "deny-studio-flush-started", + "markdownDescription": "Denies the studio_flush_started command without any pre-configured scope." + }, + { + "description": "Denies the studio_flushed command without any pre-configured scope.", + "type": "string", + "const": "deny-studio-flushed", + "markdownDescription": "Denies the studio_flushed command without any pre-configured scope." }, { "description": "Denies the studio_import_file command without any pre-configured scope.", @@ -1311,16 +1311,16 @@ "markdownDescription": "Denies the studio_import_file command without any pre-configured scope." }, { - "description": "Denies the studio_open_in_bambu command without any pre-configured scope.", + "description": "Denies the studio_open_files_privacy command without any pre-configured scope.", "type": "string", - "const": "deny-studio-open-in-bambu", - "markdownDescription": "Denies the studio_open_in_bambu command without any pre-configured scope." + "const": "deny-studio-open-files-privacy", + "markdownDescription": "Denies the studio_open_files_privacy command without any pre-configured scope." }, { - "description": "Denies the studio_projects command without any pre-configured scope.", + "description": "Denies the studio_open_in_bambu command without any pre-configured scope.", "type": "string", - "const": "deny-studio-projects", - "markdownDescription": "Denies the studio_projects command without any pre-configured scope." + "const": "deny-studio-open-in-bambu", + "markdownDescription": "Denies the studio_open_in_bambu command without any pre-configured scope." }, { "description": "Denies the studio_reveal command without any pre-configured scope.", @@ -1334,18 +1334,6 @@ "const": "deny-studio-save-bytes", "markdownDescription": "Denies the studio_save_bytes command without any pre-configured scope." }, - { - "description": "Denies the studio_send command without any pre-configured scope.", - "type": "string", - "const": "deny-studio-send", - "markdownDescription": "Denies the studio_send command without any pre-configured scope." - }, - { - "description": "Denies the studio_transcript command without any pre-configured scope.", - "type": "string", - "const": "deny-studio-transcript", - "markdownDescription": "Denies the studio_transcript command without any pre-configured scope." - }, { "description": "Denies the switch_printer command without any pre-configured scope.", "type": "string", diff --git a/src-tauri/gen/schemas/linux-schema.json b/src-tauri/gen/schemas/linux-schema.json index cc98244b..3aec35cc 100644 --- a/src-tauri/gen/schemas/linux-schema.json +++ b/src-tauri/gen/schemas/linux-schema.json @@ -765,22 +765,28 @@ "markdownDescription": "Enables the store_credential command without any pre-configured scope." }, { - "description": "Enables the studio_cancel command without any pre-configured scope.", + "description": "Enables the studio_files command without any pre-configured scope.", "type": "string", - "const": "allow-studio-cancel", - "markdownDescription": "Enables the studio_cancel command without any pre-configured scope." + "const": "allow-studio-files", + "markdownDescription": "Enables the studio_files command without any pre-configured scope." }, { - "description": "Enables the studio_create_project command without any pre-configured scope.", + "description": "Enables the studio_flush_failed command without any pre-configured scope.", "type": "string", - "const": "allow-studio-create-project", - "markdownDescription": "Enables the studio_create_project command without any pre-configured scope." + "const": "allow-studio-flush-failed", + "markdownDescription": "Enables the studio_flush_failed command without any pre-configured scope." }, { - "description": "Enables the studio_files command without any pre-configured scope.", + "description": "Enables the studio_flush_started command without any pre-configured scope.", "type": "string", - "const": "allow-studio-files", - "markdownDescription": "Enables the studio_files command without any pre-configured scope." + "const": "allow-studio-flush-started", + "markdownDescription": "Enables the studio_flush_started command without any pre-configured scope." + }, + { + "description": "Enables the studio_flushed command without any pre-configured scope.", + "type": "string", + "const": "allow-studio-flushed", + "markdownDescription": "Enables the studio_flushed command without any pre-configured scope." }, { "description": "Enables the studio_import_file command without any pre-configured scope.", @@ -789,16 +795,16 @@ "markdownDescription": "Enables the studio_import_file command without any pre-configured scope." }, { - "description": "Enables the studio_open_in_bambu command without any pre-configured scope.", + "description": "Enables the studio_open_files_privacy command without any pre-configured scope.", "type": "string", - "const": "allow-studio-open-in-bambu", - "markdownDescription": "Enables the studio_open_in_bambu command without any pre-configured scope." + "const": "allow-studio-open-files-privacy", + "markdownDescription": "Enables the studio_open_files_privacy command without any pre-configured scope." }, { - "description": "Enables the studio_projects command without any pre-configured scope.", + "description": "Enables the studio_open_in_bambu command without any pre-configured scope.", "type": "string", - "const": "allow-studio-projects", - "markdownDescription": "Enables the studio_projects command without any pre-configured scope." + "const": "allow-studio-open-in-bambu", + "markdownDescription": "Enables the studio_open_in_bambu command without any pre-configured scope." }, { "description": "Enables the studio_reveal command without any pre-configured scope.", @@ -812,18 +818,6 @@ "const": "allow-studio-save-bytes", "markdownDescription": "Enables the studio_save_bytes command without any pre-configured scope." }, - { - "description": "Enables the studio_send command without any pre-configured scope.", - "type": "string", - "const": "allow-studio-send", - "markdownDescription": "Enables the studio_send command without any pre-configured scope." - }, - { - "description": "Enables the studio_transcript command without any pre-configured scope.", - "type": "string", - "const": "allow-studio-transcript", - "markdownDescription": "Enables the studio_transcript command without any pre-configured scope." - }, { "description": "Enables the switch_printer command without any pre-configured scope.", "type": "string", @@ -1287,22 +1281,28 @@ "markdownDescription": "Denies the store_credential command without any pre-configured scope." }, { - "description": "Denies the studio_cancel command without any pre-configured scope.", + "description": "Denies the studio_files command without any pre-configured scope.", "type": "string", - "const": "deny-studio-cancel", - "markdownDescription": "Denies the studio_cancel command without any pre-configured scope." + "const": "deny-studio-files", + "markdownDescription": "Denies the studio_files command without any pre-configured scope." }, { - "description": "Denies the studio_create_project command without any pre-configured scope.", + "description": "Denies the studio_flush_failed command without any pre-configured scope.", "type": "string", - "const": "deny-studio-create-project", - "markdownDescription": "Denies the studio_create_project command without any pre-configured scope." + "const": "deny-studio-flush-failed", + "markdownDescription": "Denies the studio_flush_failed command without any pre-configured scope." }, { - "description": "Denies the studio_files command without any pre-configured scope.", + "description": "Denies the studio_flush_started command without any pre-configured scope.", "type": "string", - "const": "deny-studio-files", - "markdownDescription": "Denies the studio_files command without any pre-configured scope." + "const": "deny-studio-flush-started", + "markdownDescription": "Denies the studio_flush_started command without any pre-configured scope." + }, + { + "description": "Denies the studio_flushed command without any pre-configured scope.", + "type": "string", + "const": "deny-studio-flushed", + "markdownDescription": "Denies the studio_flushed command without any pre-configured scope." }, { "description": "Denies the studio_import_file command without any pre-configured scope.", @@ -1311,16 +1311,16 @@ "markdownDescription": "Denies the studio_import_file command without any pre-configured scope." }, { - "description": "Denies the studio_open_in_bambu command without any pre-configured scope.", + "description": "Denies the studio_open_files_privacy command without any pre-configured scope.", "type": "string", - "const": "deny-studio-open-in-bambu", - "markdownDescription": "Denies the studio_open_in_bambu command without any pre-configured scope." + "const": "deny-studio-open-files-privacy", + "markdownDescription": "Denies the studio_open_files_privacy command without any pre-configured scope." }, { - "description": "Denies the studio_projects command without any pre-configured scope.", + "description": "Denies the studio_open_in_bambu command without any pre-configured scope.", "type": "string", - "const": "deny-studio-projects", - "markdownDescription": "Denies the studio_projects command without any pre-configured scope." + "const": "deny-studio-open-in-bambu", + "markdownDescription": "Denies the studio_open_in_bambu command without any pre-configured scope." }, { "description": "Denies the studio_reveal command without any pre-configured scope.", @@ -1334,18 +1334,6 @@ "const": "deny-studio-save-bytes", "markdownDescription": "Denies the studio_save_bytes command without any pre-configured scope." }, - { - "description": "Denies the studio_send command without any pre-configured scope.", - "type": "string", - "const": "deny-studio-send", - "markdownDescription": "Denies the studio_send command without any pre-configured scope." - }, - { - "description": "Denies the studio_transcript command without any pre-configured scope.", - "type": "string", - "const": "deny-studio-transcript", - "markdownDescription": "Denies the studio_transcript command without any pre-configured scope." - }, { "description": "Denies the switch_printer command without any pre-configured scope.", "type": "string", diff --git a/src-tauri/resources/studio-plugin/.claude-plugin/plugin.json b/src-tauri/resources/studio-plugin/.claude-plugin/plugin.json deleted file mode 100644 index 79a62e5e..00000000 --- a/src-tauri/resources/studio-plugin/.claude-plugin/plugin.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "materialize-studio", - "version": "0.1.0", - "description": "The workflow Materialize 3D's agent follows to turn a request into a print-ready 3MF. Ships inside the app." -} diff --git a/src-tauri/resources/studio-plugin/skills/fabricate/SKILL.md b/src-tauri/resources/studio-plugin/skills/fabricate/SKILL.md deleted file mode 100644 index d550e911..00000000 --- a/src-tauri/resources/studio-plugin/skills/fabricate/SKILL.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -name: fabricate -description: Turn a request for a printed part into params, a checked model, a preview, an HTML guide, and a print-ready 3MF for a Bambu Lab P2S. Use for every job in Materialize 3D. ---- - -# Fabricate a part - -The end of every job is a print-ready 3MF file. Everything else supports that. Work in the project folder, which is your working directory. - -## Steps - -1. **Parameters.** Write the design as numbers in `params.json`. Mark a measurement you do not know as `"TBD"` with your best estimate beside it, so the person does not have to measure before they see anything. Ask only for what you cannot estimate. -2. **Model and fit checks.** Write Python build scripts in `build/` that read `params.json`. Build the parts with manifold3d (`uv run --with manifold3d --with numpy python build/model.py`). Check that the parts are manifold, that parts that should not touch do not overlap, and that clearances match the parameters. Say what you checked and the numbers. -3. **Preview.** Export the parts to STL (or GLB) in the project folder so the person can turn them in the viewer. When they ask to see a model before the 3MF, this is the step they mean. -4. **HTML guide.** Write a self-contained HTML page: a technical drawing with dimensions, an interactive three.js assembly guide, or both, as the job needs. -5. **Slice to 3MF.** Slice with the Bambu Studio CLI for the P2S and write print-ready 3MF files into `print/`. - - On macOS the CLI is `/Applications/BambuStudio.app/Contents/MacOS/BambuStudio`. Its system presets are in `~/Library/Application Support/BambuStudio/system/BBL`, or `/Applications/BambuStudio.app/Contents/Resources/profiles/BBL` before Bambu Studio has been opened once. - - The CLI does not follow a preset's `inherits` chain, so merge each preset with its parents into a full JSON file first. - - Default presets: machine `Bambu Lab P2S 0.4 nozzle`, process `0.20mm Standard @BBL P2S`, filament `Bambu PETG Basic @BBL P2S 0.4 nozzle`, unless the person names others. - - Check that the bed is 256 x 256 mm after the merge. - - If Bambu Studio is not installed on this machine, say so plainly and leave the STL files ready. - -Keep each step's script, so a change to `params.json` rebuilds everything. - -## Astra, the GPT helper - -You can hand a task to Astra through the codex CLI. Astra suits drawings and second opinions. Run it from the project folder: - -``` -codex exec --skip-git-repo-check --ephemeral --sandbox workspace-write "" -``` - -Add `-i ` to give Astra a picture. Astra cannot see this chat, so the task must say everything it needs. Check what Astra writes before you show it. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 58b04025..2097d711 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -74,8 +74,8 @@ pub fn run() { .manage(OpenScadService::default()) .manage(agent::commands::AgentTurns::default()) .manage(updater::PendingUpdate::default()) - .manage(studio::Turns::default()) .manage(sidecar::Sidecar::default()) + .manage(studio::quit::QuitFlush::default()) .invoke_handler(tauri::generate_handler![ get_app_state, set_active_view, @@ -153,16 +153,15 @@ pub fn run() { studio_commands::clear_bambu_studio, updater::update_check, updater::update_install, - studio::studio_projects, - studio::studio_create_project, - studio::turn::studio_transcript, - studio::turn::studio_send, - studio::turn::studio_cancel, studio::files::studio_files, studio::files::studio_import_file, studio::files::studio_save_bytes, studio::files::studio_reveal, studio::files::studio_open_in_bambu, + studio::quit::studio_flush_started, + studio::quit::studio_flush_failed, + studio::quit::studio_flushed, + studio::quit::studio_open_files_privacy, sidecar::sidecar, ]) .setup(|app| { @@ -343,12 +342,27 @@ pub fn run() { }) .build(app_context()) .expect("error while building tauri application") - .run(|app, event| { - // A turn running when the app quits would keep changing its project with no window to show it. - if let tauri::RunEvent::Exit = event { - app.state::().stop_all(); - app.state::().shutdown(); + .run(|app, event| match event { + // A normal quit, or the main window's close, first lets the window save the text it has not saved yet. A + // restart for an update cannot be held, so it goes on as before. + tauri::RunEvent::ExitRequested { api, code, .. } if code != Some(tauri::RESTART_EXIT_CODE) => { + if studio::quit::QuitFlush::hold(app, move |app| app.exit(code.unwrap_or(0))) { + api.prevent_exit(); + } + } + tauri::RunEvent::WindowEvent { label, event: tauri::WindowEvent::CloseRequested { api, .. }, .. } if label == "main" => { + let closing = label.clone(); + if studio::quit::QuitFlush::hold(app, move |app| { + if let Some(window) = app.get_webview_window(&closing) { + let _ = window.destroy(); + } + }) { + api.prevent_close(); + } } + // The sidecar finishes its commits and exits before the app does. + tauri::RunEvent::Exit => app.state::().shutdown(), + _ => {} }); } diff --git a/src-tauri/src/sidecar.rs b/src-tauri/src/sidecar.rs index 07c07754..24cdd5d4 100644 --- a/src-tauri/src/sidecar.rs +++ b/src-tauri/src/sidecar.rs @@ -6,11 +6,11 @@ //! (`sidecar/src/protocol.ts`). Rust never opens the sidecar's store: it only names its folder. use std::collections::HashMap; +use std::future::Future; use std::path::PathBuf; use std::process::Stdio; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::future::Future; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, OnceLock}; use std::time::{Duration, Instant}; use serde::de::DeserializeOwned; @@ -21,6 +21,8 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::sync::{mpsc, oneshot, watch, Notify, OnceCell}; use crate::credentials; +use crate::fabrication::build_gate::{BuildGate, OwnedRunning}; +use crate::state::AppState; use crate::studio; /// The one event the window listens to. The sidecar's own events and the engine's state both arrive here. @@ -57,6 +59,16 @@ pub struct Sidecar { kill: Notify, /// Each slice by its operation id, running or done, for as long as the app runs. slices: Operations, + /// The update gate's pass, held while the sidecar has work in flight, and the sidecar run it belongs to. + pass: Mutex, +} + +#[derive(Default)] +struct Pass { + /// How many sidecar runs ended. A run's requests carry the count from its start, so a request still on its way + /// when its run ended never takes the pass. + run: u64, + held: Option>, } /// Outside work by operation id: one result each, shared by every request that names the id. @@ -74,6 +86,35 @@ where cell.get_or_init(work).await.clone() } +/// The gate inside the app's state, so the sidecar's pass can own its way to it. +struct StateGate(Arc); + +impl std::ops::Deref for StateGate { + type Target = BuildGate; + fn deref(&self) -> &BuildGate { + &self.0.builds + } +} + +/// The pass that holds an update off while the sidecar works. The updater restarts the app only once no build +/// and no sidecar work holds one. While an update installs, the gate is closed and the pass, and with it a send, +/// is refused. +fn take_pass(state: Arc) -> Result, String> { + BuildGate::enter_owned(StateGate(state)) + .ok_or_else(|| "An update is installing, and the app restarts when it is done. Send this again after it opens.".to_owned()) +} + +/// Runs `work` under a pass of its own. Work Rust does for the sidecar can outlive the sidecar run that asked for it, +/// as a slice does after a crash, so the run's pass cannot hold the update off for it. +async fn with_own_pass(state: Arc, work: F) -> Result +where + F: FnOnce() -> Fut, + Fut: Future>, +{ + let _pass = take_pass(state)?; + work().await +} + impl Default for Sidecar { fn default() -> Self { Self { @@ -84,11 +125,25 @@ impl Default for Sidecar { alive: watch::Sender::new(false), kill: Notify::new(), slices: Mutex::default(), + pass: Mutex::default(), } } } impl Sidecar { + /// The run a sidecar process that starts now belongs to. + fn run_started(&self) -> u64 { + self.pass.lock().unwrap_or_else(|e| e.into_inner()).run + } + + /// Work a sidecar was doing when it ended no longer holds an update off, and its requests still on their way take + /// nothing. Its replacement asks again for work it resumes. + fn run_ended(&self) { + let mut pass = self.pass.lock().unwrap_or_else(|e| e.into_inner()); + pass.run += 1; + pass.held = None; + } + /// Fails every call the sidecar that just exited will never answer. fn drop_calls(&self) { let calls = std::mem::take(&mut *self.calls.lock().unwrap_or_else(|e| e.into_inner())); @@ -170,10 +225,107 @@ fn binary_path() -> Result { Ok(dir.join(if cfg!(windows) { format!("{BINARY}.exe") } else { BINARY.to_owned() })) } +/// How long the login shell may take to tell its PATH before the app goes on without it. +const LOGIN_SHELL_DEADLINE: std::time::Duration = std::time::Duration::from_secs(5); + +/// The PATH of the person's login shell. An app opened from Finder starts with +/// only the system folders, where `uv` is not. +fn login_path() -> &'static str { + static PATH: OnceLock = OnceLock::new(); + PATH.get_or_init(|| { + let current = std::env::var("PATH").unwrap_or_default(); + let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_owned()); + match shell_path(&shell, LOGIN_SHELL_DEADLINE) { + Some(path) if current.is_empty() => path, + Some(path) => format!("{path}:{current}"), + None => current, + } + }) +} + +/// The PATH `shell` reports as a login shell, or none when it fails, says nothing, or does not end within +/// `deadline`. A shell whose startup files hang is ended with all it started, so the sidecar and the runner still +/// start. +fn shell_path(shell: &str, deadline: std::time::Duration) -> Option { + let marker = "__M3D_PATH__"; + let end = std::time::Instant::now() + deadline; + let mut command = std::process::Command::new(shell); + command + .args(["-ilc", &format!("printf '{marker}%s{marker}' \"$PATH\"")]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + // The shell leads a session of its own, so the deadline ends what its startup files started too. A session, not + // only a group: a shell in a background group of the terminal the app started from stops before its startup + // files, and one in a session of its own has no terminal to stop for. + #[cfg(unix)] + // SAFETY: setsid is async-signal-safe and only makes the child a session leader before it runs the shell. + unsafe { + std::os::unix::process::CommandExt::pre_exec(&mut command, || { + if libc::setsid() == -1 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + let mut child = command.spawn().ok()?; + let mut stdout = child.stdout.take()?; + // What the shell printed so far. A program its startup files started, such as an agent, can keep stdout open long + // after the shell ends, so the reader is not waited on for its end. Once the lookup is done the reader stops at its + // next read and closes the pipe, so a program that keeps writing is never read again. + let printed = std::sync::Arc::new(Mutex::new(Vec::new())); + let done = std::sync::Arc::new(AtomicBool::new(false)); + let (closed, eof) = std::sync::mpsc::channel::<()>(); + std::thread::spawn({ + let printed = printed.clone(); + let done = done.clone(); + move || { + let mut chunk = [0u8; 4096]; + while let Ok(n @ 1..) = std::io::Read::read(&mut stdout, &mut chunk) { + if done.load(Ordering::Relaxed) { + break; + } + printed.lock().unwrap_or_else(|e| e.into_inner()).extend_from_slice(&chunk[..n]); + } + let _ = closed.send(()); + } + }); + // Startup files can send stdout elsewhere and go on running, so the shell's end has the deadline too. + let ended = loop { + match child.try_wait() { + Ok(Some(_)) => break true, + Ok(None) if std::time::Instant::now() < end => std::thread::sleep(std::time::Duration::from_millis(20)), + _ => break false, + } + }; + // The shell has ended, so all it printed is in the pipe: a short wait lets the reader take it. + let _ = eof.recv_timeout(std::time::Duration::from_millis(100)); + let out = ended.then(|| std::mem::take(&mut *printed.lock().unwrap_or_else(|e| e.into_inner()))); + done.store(true, Ordering::Relaxed); + if !ended { + #[cfg(unix)] + if let Ok(group) = i32::try_from(child.id()) { + // SAFETY: killpg only sends a signal, to the group this shell leads, as its session leader, and has not been + // reaped from. + unsafe { libc::killpg(group, libc::SIGKILL) }; + } + let _ = child.kill(); + let _ = child.wait(); + } + let out = out?; + // The PATH is what the shell printed between the two markers. A program its startup files left running can write + // after it, so nothing past the second marker counts. + String::from_utf8_lossy(&out) + .split_once(marker) + .and_then(|(_, rest)| rest.split_once(marker)) + .map(|(path, _)| path.trim().to_owned()) + .filter(|path| !path.is_empty()) +} + /// The only variables the sidecar gets. Its tools need a PATH that finds `uv` and a HOME for /// uv's cache. The person's agent and inference settings (`ANTHROPIC_API_KEY` and the like) stay out. fn kept_env() -> Vec<(&'static str, String)> { - let mut env = vec![("PATH", studio::turn::login_path().to_owned())]; + let mut env = vec![("PATH", login_path().to_owned())]; for name in ["HOME", "USER", "LANG", "TMPDIR"] { if let Ok(value) = std::env::var(name) { env.push((name, value)); @@ -211,6 +363,7 @@ async fn run(app: &AppHandle) -> Result { .map_err(|e| format!("starting {}: {e}", exe.display()))?; let sidecar = app.state::(); sidecar.alive.send_replace(true); + let run = sidecar.run_started(); let mut stdin = child.stdin.take().expect("piped stdin"); let (input, mut lines_in) = mpsc::unbounded_channel::(); @@ -243,7 +396,7 @@ async fn run(app: &AppHandle) -> Result { tokio::select! { line = lines.next_line() => match line { Ok(Some(line)) => { - if dispatch(app, &answers, &line) { + if dispatch(app, run, &answers, &line) { if let Some(input) = pending.take() { // A quit that came while this sidecar started has already stopped the link: it must not // reopen it. @@ -280,6 +433,7 @@ async fn run(app: &AppHandle) -> Result { writer.abort(); let _ = writer.await; sidecar.drop_calls(); + sidecar.run_ended(); // A sidecar can close its stdout and still hang, so the wait still answers a kill. let status = loop { tokio::select! { @@ -318,7 +472,7 @@ enum Outbound { } /// Handles one line from the sidecar, and says whether it was the sidecar's ready event. -fn dispatch(app: &AppHandle, answers: &mpsc::WeakUnboundedSender, line: &str) -> bool { +fn dispatch(app: &AppHandle, run: u64, answers: &mpsc::WeakUnboundedSender, line: &str) -> bool { let message = match serde_json::from_str::(line) { Ok(message) => message, Err(e) => { @@ -346,7 +500,7 @@ fn dispatch(app: &AppHandle, answers: &mpsc::WeakUnboundedSender, line: let app = app.clone(); let answers = answers.clone(); tauri::async_runtime::spawn(async move { - let answer = match answer(&app, &method, params).await { + let answer = match answer(&app, run, &method, params).await { Ok(value) => json!({ "kind": "answer", "id": id, "ok": true, "value": value }), Err(error) => json!({ "kind": "answer", "id": id, "ok": false, "error": error }), }; @@ -372,6 +526,26 @@ struct SliceCall { request: studio::slice::Request, } +#[derive(Deserialize)] +struct Busy { + busy: bool, +} + +/// Takes the update gate's pass for the sidecar run `run`, or gives it back. Taking it again while held changes +/// nothing. A run that ended changes nothing either. +fn set_busy(sidecar: &Sidecar, state: &Arc, run: u64, busy: bool) -> Result<(), String> { + let mut pass = sidecar.pass.lock().unwrap_or_else(|e| e.into_inner()); + if pass.run != run { + return Err("This engine has exited.".to_owned()); + } + match (busy, pass.held.is_some()) { + (true, false) => pass.held = Some(take_pass(state.clone())?), + (false, true) => pass.held = None, + _ => {} + } + Ok(()) +} + #[derive(Deserialize)] struct Account { account: String, @@ -395,8 +569,8 @@ fn keychain_item(account: &str) -> Result { } } -/// Answers one request from the sidecar. Errors name what failed and never carry a secret. -async fn answer(app: &AppHandle, method: &str, params: Value) -> Result { +/// Answers one request from the sidecar run `run`. Errors name what failed and never carry a secret. +async fn answer(app: &AppHandle, run: u64, method: &str, params: Value) -> Result { match method { "keychain.get" => { let Account { account } = parse(method, params)?; @@ -413,14 +587,25 @@ async fn answer(app: &AppHandle, method: &str, params: Value) -> Result { + let Busy { busy } = parse(method, params)?; + set_busy(&app.state::(), app.state::>().inner(), run, busy)?; + Ok(Value::Null) + } "slice" => { let SliceCall { operation, request } = parse(method, params)?; let cache = app.path().app_cache_dir().map_err(|e| format!("finding the app's cache folder: {e}"))?.join("bambu-presets"); - once_per_operation(&app.state::().slices, operation, || async move { - let sliced = tauri::async_runtime::spawn_blocking(move || studio::slice::slice(&request, &cache)) - .await - .map_err(|e| format!("the slice task failed: {e}"))??; - serde_json::to_value(sliced).map_err(|e| e.to_string()) + // The pass comes first, so a refusal while an update installs is not kept as the operation's result, and + // the same slice can run once the gate opens. + let state = app.state::>().inner().clone(); + let sidecar = app.state::(); + with_own_pass(state, || { + once_per_operation(&sidecar.slices, operation, || async move { + let sliced = tauri::async_runtime::spawn_blocking(move || studio::slice::slice(&request, &cache)) + .await + .map_err(|e| format!("the slice task failed: {e}"))??; + serde_json::to_value(sliced).map_err(|e| e.to_string()) + }) }) .await } @@ -455,6 +640,75 @@ pub async fn sidecar(sidecar: State<'_, Sidecar>, method: String, params: Value) mod tests { use super::*; + #[cfg(unix)] + #[test] + fn a_login_shell_that_hangs_is_ended_at_its_deadline() { + let dir = tempfile::tempdir().unwrap(); + let shell = dir.path().join("slow-shell"); + std::fs::write(&shell, "#!/bin/sh\nsleep 30\n").unwrap(); + let mut perms = std::fs::metadata(&shell).unwrap().permissions(); + std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755); + std::fs::set_permissions(&shell, perms).unwrap(); + let started = std::time::Instant::now(); + assert_eq!(shell_path(shell.to_str().unwrap(), std::time::Duration::from_millis(300)), None); + assert!(started.elapsed() < std::time::Duration::from_secs(5), "took {:?}", started.elapsed()); + + // Startup files that send stdout elsewhere and then hang end at the deadline too. + std::fs::write(&shell, "#!/bin/sh\nexec >/dev/null\nsleep 30\n").unwrap(); + let started = std::time::Instant::now(); + assert_eq!(shell_path(shell.to_str().unwrap(), std::time::Duration::from_millis(300)), None); + assert!(started.elapsed() < std::time::Duration::from_secs(5), "took {:?}", started.elapsed()); + assert!(shell_path("/bin/sh", std::time::Duration::from_secs(5)).is_some_and(|path| !path.is_empty())); + } + + #[cfg(unix)] + #[test] + fn a_login_shell_that_leaves_a_program_holding_its_output_still_gives_its_path_at_once() { + let dir = tempfile::tempdir().unwrap(); + let shell = dir.path().join("agent-shell"); + // Startup files that start an agent, which keeps the shell's stdout open after the shell ends. + std::fs::write(&shell, "#!/bin/sh +sleep 30 & +printf '__M3D_PATH__/opt/tools/bin__M3D_PATH__' +").unwrap(); + let mut perms = std::fs::metadata(&shell).unwrap().permissions(); + std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755); + std::fs::set_permissions(&shell, perms).unwrap(); + let started = std::time::Instant::now(); + assert_eq!(shell_path(shell.to_str().unwrap(), std::time::Duration::from_secs(5)).as_deref(), Some("/opt/tools/bin")); + assert!(started.elapsed() < std::time::Duration::from_secs(2), "took {:?}", started.elapsed()); + } + + #[cfg(unix)] + #[test] + fn a_program_that_keeps_writing_to_the_login_shells_output_is_not_read_after_the_lookup() { + let dir = tempfile::tempdir().unwrap(); + let shell = dir.path().join("noisy-shell"); + let pid = dir.path().join("pid"); + std::fs::write(&shell, format!("#!/bin/sh\nyes &\necho $! > {}\nprintf '__M3D_PATH__/opt/tools/bin__M3D_PATH__'\n", pid.display())).unwrap(); + let mut perms = std::fs::metadata(&shell).unwrap().permissions(); + std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755); + std::fs::set_permissions(&shell, perms).unwrap(); + assert_eq!(shell_path(shell.to_str().unwrap(), std::time::Duration::from_secs(5)).as_deref(), Some("/opt/tools/bin")); + let pid = std::fs::read_to_string(&pid).unwrap().trim().parse::().unwrap(); + // Once nothing reads the pipe, the next write ends the writer. An ended writer may wait as a zombie until the + // system reaps it, so that counts as ended too. + let ended = || { + let state = std::process::Command::new("ps").args(["-o", "stat=", "-p", &pid.to_string()]).output().expect("ps"); + let state = String::from_utf8_lossy(&state.stdout); + state.trim().is_empty() || state.trim_start().starts_with('Z') + }; + let gone = (0..50).any(|_| { + std::thread::sleep(std::time::Duration::from_millis(100)); + ended() + }); + if !gone { + // SAFETY: ends the test's own writer, which outlived the lookup. + unsafe { libc::kill(pid, libc::SIGKILL) }; + } + assert!(gone, "the lookup still read the output of a program its shell left running"); + } + #[test] fn the_sidecar_reaches_only_inference_items_in_the_keychain() { assert_eq!(keychain_item("anthropic").as_deref(), Ok("inference.anthropic")); @@ -494,6 +748,29 @@ mod tests { assert_eq!(runs.load(Ordering::SeqCst), 2); } + /// A slice that outlives its sidecar run keeps the update off until it ends, and none starts while one installs. + #[tokio::test] + async fn work_under_its_own_pass_holds_an_update_until_it_ends() { + const SETTLE: Duration = Duration::from_millis(50); + let state = Arc::new(AppState::default()); + let sidecar = Sidecar::default(); + set_busy(&sidecar, &state, 0, true).expect("the sidecar takes the pass"); + let (finish, finished) = oneshot::channel::<()>(); + let slice = tokio::spawn(with_own_pass(state.clone(), || async move { + finished.await.map_err(|e| e.to_string()) + })); + tokio::time::sleep(SETTLE).await; + sidecar.run_ended(); + let mut close = std::pin::pin!(state.builds.close_when_idle(|_| {})); + assert!(tokio::time::timeout(SETTLE, &mut close).await.is_err(), "the update waits for the slice its sidecar left"); + finish.send(()).expect("the slice is still running"); + slice.await.expect("the slice task").expect("the slice"); + let closed = tokio::time::timeout(SETTLE, &mut close).await.expect("the update goes ahead once the slice ends"); + let refused = with_own_pass(state.clone(), || async { Ok(()) }).await.expect_err("no slice starts while the update installs"); + assert!(refused.contains("update is installing"), "{refused}"); + drop(closed); + } + #[test] fn a_slice_call_carries_its_operation_id() { let call: SliceCall = @@ -502,6 +779,40 @@ mod tests { assert!(serde_json::from_value::(json!({ "project": "p", "inputs": ["a.stl"], "output": "out.3mf" })).is_err()); } + /// The pass a busy sidecar takes holds an update off until it gives it back, and while the update installs + /// the pass, and with it a send, is refused. + #[tokio::test] + async fn an_update_waits_for_a_busy_sidecar_and_refuses_it_work_while_it_installs() { + const SETTLE: Duration = Duration::from_millis(50); + let state = Arc::new(AppState::default()); + let sidecar = Sidecar::default(); + let run = sidecar.run_started(); + set_busy(&sidecar, &state, run, true).expect("the sidecar takes the pass"); + set_busy(&sidecar, &state, run, true).expect("asking again while it holds the pass changes nothing"); + let mut close = std::pin::pin!(state.builds.close_when_idle(|_| {})); + assert!(tokio::time::timeout(SETTLE, &mut close).await.is_err(), "the update waits while the sidecar is busy"); + set_busy(&sidecar, &state, run, false).expect("the sidecar gives the pass back"); + let closed = tokio::time::timeout(SETTLE, &mut close).await.expect("the update goes ahead once the sidecar is idle"); + let refused = set_busy(&sidecar, &state, run, true).expect_err("work is refused while the update installs"); + assert!(refused.contains("update is installing"), "{refused}"); + drop(closed); + set_busy(&sidecar, &state, run, true).expect("a failed install lets the sidecar work again"); + } + + /// A busy request from a sidecar that already exited, answered after its exit gave the pass back, takes + /// nothing, so an idle replacement never leaves an update waiting. + #[tokio::test] + async fn a_sidecar_that_exited_cannot_take_the_pass() { + let state = Arc::new(AppState::default()); + let sidecar = Sidecar::default(); + let exited = sidecar.run_started(); + sidecar.run_ended(); + let _replacement = sidecar.run_started(); + set_busy(&sidecar, &state, exited, true).expect_err("the exited sidecar's request is refused"); + let close = state.builds.close_when_idle(|_| {}); + let _closed = tokio::time::timeout(Duration::from_millis(50), close).await.expect("the update goes ahead"); + } + #[test] fn a_protocol_line_parses_into_its_kind() { let request: Outbound = serde_json::from_str(r#"{"kind":"request","id":3,"method":"keychain.get","params":{"account":"anthropic"}}"#).expect("request"); diff --git a/src-tauri/src/studio/lean.rs b/src-tauri/src/studio/lean.rs deleted file mode 100644 index 4b248400..00000000 --- a/src-tauri/src/studio/lean.rs +++ /dev/null @@ -1,374 +0,0 @@ -//! The agent's own environment. A turn runs on the person's inference login -//! and nothing else of their coding setup: no user settings, hooks, plugins, -//! skills, CLAUDE.md, or MCP servers, and for Astra no AGENTS.md, skills, or -//! MCP servers either. The workflow ships inside the app as a plugin. - -use std::collections::HashMap; -use std::path::{Path, PathBuf}; - -use serde::Deserialize; - -/// The only keys taken from the person's Claude Code settings: how inference -/// is reached. A token is passed to the agent's process and never stored. -const CLAUDE_INFERENCE_KEYS: [&str; 5] = - ["ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN", "API_TIMEOUT_MS"]; - -/// The only variables the agent's process keeps from the app's own environment. -/// The app may have been started from a shell full of the person's setup, such -/// as `CLAUDE_CONFIG_DIR` or `CLAUDE_ENV_FILE`, and none of that may pass. -/// `CLAUDE_CONFIG_DIR` stays because it picks which Claude login the keychain -/// holds. The flags still keep that folder's settings, CLAUDE.md, and memory out. -pub const KEPT_ENV: [&str; 10] = ["HOME", "USER", "LOGNAME", "SHELL", "TMPDIR", "LANG", "LC_ALL", "LC_CTYPE", "TZ", "CLAUDE_CONFIG_DIR"]; - -/// Set for the agent's process. `--setting-sources project` leaves out user -/// settings but not memory: without these the person's `~/.claude/CLAUDE.md` -/// (and every file it imports) and auto-memory still reach the agent. -pub const NO_MEMORY_ENV: [(&str, &str); 2] = [("CLAUDE_CODE_DISABLE_CLAUDE_MDS", "1"), ("CLAUDE_CODE_DISABLE_AUTO_MEMORY", "1")]; - -#[derive(Debug, Default, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ClaudeSettings { - #[serde(default)] - env: HashMap, - api_key_helper: Option, -} - -/// How the agent reaches Claude, read from `~/.claude/settings.json`. -#[derive(Debug, Default)] -pub struct ClaudeInference { - /// Environment for the agent's process. - pub env: Vec<(String, String)>, - /// A command that prints a key, passed with `--settings`. It names a program, not a secret. - pub api_key_helper: Option, -} - -impl ClaudeInference { - /// The inference keys come from the settings, then from the app's own environment. - /// With none of them the agent uses the normal keychain login. - pub fn read(home: &Path) -> Result { - let path = std::env::var_os("CLAUDE_CONFIG_DIR").map_or_else(|| home.join(".claude"), PathBuf::from).join("settings.json"); - let settings: ClaudeSettings = match std::fs::read_to_string(&path) { - // Only the position: an error's own text can quote a value, and a value can be a token. - Ok(text) => serde_json::from_str(&text) - .map_err(|e| format!("{} is not valid settings JSON (line {}, column {})", path.display(), e.line(), e.column()))?, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => ClaudeSettings::default(), - Err(e) => return Err(format!("{}: {e}", path.display())), - }; - let mut inference = Self::from_settings(settings); - for key in CLAUDE_INFERENCE_KEYS { - if !inference.env.iter().any(|(k, _)| k == key) { - if let Some(value) = std::env::var_os(key).and_then(|v| v.into_string().ok()) { - inference.env.push((key.to_owned(), value)); - } - } - } - Ok(inference) - } - - fn from_settings(mut settings: ClaudeSettings) -> Self { - let env = CLAUDE_INFERENCE_KEYS - .iter() - .filter_map(|&key| { - let value = settings.env.remove(key)?; - Some((key.to_owned(), value.as_str().map_or_else(|| value.to_string(), str::to_owned))) - }) - .collect(); - Self { env, api_key_helper: settings.api_key_helper } - } - - /// The Claude Code arguments that leave out the person's setup and add the app's plugin. - pub fn args(&self, plugin: &Path) -> Vec { - let mut args = vec![ - // Settings from the project folder only. User settings, hooks, and plugins stay out. - "--setting-sources".to_owned(), - "project".to_owned(), - // The app's own MCP servers, none yet. The person's servers stay out. - "--strict-mcp-config".to_owned(), - "--mcp-config".to_owned(), - r#"{"mcpServers":{}}"#.to_owned(), - "--plugin-dir".to_owned(), - plugin.display().to_string(), - ]; - if let Some(helper) = &self.api_key_helper { - args.push("--settings".to_owned()); - args.push(serde_json::json!({ "apiKeyHelper": helper }).to_string()); - } - args - } -} - -/// The provider keys Astra's config keeps. Anything else, such as an inline -/// bearer token or header, stays out, so no secret is written. The provider -/// reads its key with `auth.command` or from the `env_key` variable instead. -const PROVIDER_KEYS: [&str; 9] = [ - "name", - "base_url", - "wire_api", - "env_key", - "env_http_headers", - "request_max_retries", - "stream_max_retries", - "stream_idle_timeout_ms", - "auth", -]; -const PROVIDER_AUTH_KEYS: [&str; 5] = ["command", "args", "cwd", "timeout_ms", "refresh_interval_ms"]; - -/// Writes Astra's own `CODEX_HOME` and returns it. Its `config.toml` holds only -/// the model and the cliproxy provider from `~/.codex/config.toml`, reads no -/// AGENTS.md, and turns off every skill under `~/.agents/skills`, which codex -/// would otherwise load. With no cliproxy provider the home has no way to reach -/// a model, so Astra fails and says so instead of running on the person's setup. -pub fn astra_home(home: &Path, app_data: &Path) -> Result { - let user_config = home.join(".codex").join("config.toml"); - // Astra runs only when the agent asks for it, so a codex config it cannot read - // never stops a turn. Astra then has no model and says so when it is called. - let user: toml::Table = match std::fs::read_to_string(&user_config) { - // Only the position: an error's own text quotes the line, and a line can hold a token. - Ok(text) => text.parse().unwrap_or_else(|e: toml::de::Error| { - let at = e.span().map_or(String::new(), |span| format!(" at byte {}", span.start)); - log::warn!("studio: {} is not valid TOML{at}, so Astra cannot reach a model", user_config.display()); - toml::Table::new() - }), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => toml::Table::new(), - Err(e) => { - log::warn!("studio: cannot read {}: {e}, so Astra cannot reach a model", user_config.display()); - toml::Table::new() - } - }; - let AstraConfig { config, secrets } = astra_config(&user, &host_skills(home), has_value); - if !config.contains_key("model_providers") { - log::warn!("studio: ~/.codex/config.toml has no cliproxy provider, so Astra cannot reach a model"); - } - let env = provider_env_names(&config); - let dir = app_data.join("astra"); - std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?; - remove_abandoned(&dir); - // Written whole and then renamed, so an Astra starting meanwhile reads the old file or the new one. - let staged = dir.join(format!("config.toml.{}", uuid::Uuid::new_v4())); - let text = toml::to_string(&config).map_err(|e| format!("Astra config: {e}"))?; - if let Err(e) = write_private(&staged, &text).and_then(|()| std::fs::rename(&staged, dir.join("config.toml"))) { - let _ = std::fs::remove_file(&staged); - return Err(format!("{}: {e}", dir.display())); - } - Ok(AstraHome { dir, env, secrets }) -} - -/// Removes staged configs a stopped app left before it could rename them. One -/// a minute old or more is not another turn's write in progress. -fn remove_abandoned(dir: &Path) { - let Ok(entries) = std::fs::read_dir(dir) else { return }; - let cutoff = std::time::SystemTime::now() - std::time::Duration::from_secs(60); - for entry in entries.filter_map(Result::ok) { - let staged = entry.file_name().to_string_lossy().starts_with("config.toml."); - let old = entry.metadata().and_then(|m| m.modified()).is_ok_and(|modified| modified < cutoff); - if staged && old { - let _ = std::fs::remove_file(entry.path()); - } - } -} - -/// Astra's home, and the variables its provider reads its key from, which the -/// agent's process keeps when the app has them. -pub struct AstraHome { - pub dir: PathBuf, - /// Variables the provider reads, passed from the app's own environment when it has them. - pub env: Vec, - /// Inline credentials from the person's config, passed by value and never written. - pub secrets: Vec<(String, String)>, -} - -/// The copied provider can name a key command's arguments, so the file is the -/// person's alone, like the config it came from. -fn write_private(path: &Path, text: &str) -> std::io::Result<()> { - let mut options = std::fs::OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); - std::io::Write::write_all(&mut options.open(path)?, text.as_bytes()) -} - -/// The variable names the provider reads: `env_key` and the values of `env_http_headers`. -fn provider_env_names(config: &toml::Table) -> Vec { - let Some(provider) = config.get("model_providers").and_then(|p| p.get("cliproxy")) else { return Vec::new() }; - let key = provider.get("env_key").and_then(toml::Value::as_str).map(str::to_owned); - let headers = provider.get("env_http_headers").and_then(toml::Value::as_table).into_iter().flat_map(|table| table.values().filter_map(toml::Value::as_str).map(str::to_owned)); - key.into_iter().chain(headers).filter(|name| !name.starts_with("M3D_ASTRA_")).collect() -} - -/// Astra's config, and the values of any inline credentials the provider had. -/// A credential written into the person's config (`experimental_bearer_token`, -/// or `http_headers`) is never copied. The new config names an environment -/// variable for it, and the value reaches Astra only through the process. -struct AstraConfig { - config: toml::Table, - secrets: Vec<(String, String)>, -} - -/// `env_set` says whether a variable is set in the app's environment, which -/// decides whether an environment-backed header wins over a static one. -fn astra_config(user: &toml::Table, skills: &[PathBuf], env_set: impl Fn(&str) -> bool) -> AstraConfig { - let keep = |table: &toml::Table, keys: &[&str]| -> toml::Table { - table.iter().filter(|(key, _)| keys.contains(&key.as_str())).map(|(key, value)| (key.clone(), value.clone())).collect() - }; - let mut config = toml::Table::new(); - let mut secrets = Vec::new(); - if let Some(model) = user.get("model") { - config.insert("model".to_owned(), model.clone()); - } - // No AGENTS.md from the project folder or any folder above it. - config.insert("project_doc_max_bytes".to_owned(), 0.into()); - if let Some(provider) = user.get("model_providers").and_then(|p| p.get("cliproxy")).and_then(toml::Value::as_table) { - let source = provider; - let mut provider = keep(source, &PROVIDER_KEYS); - if let Some(auth) = provider.get("auth").and_then(toml::Value::as_table) { - let auth = keep(auth, &PROVIDER_AUTH_KEYS); - provider.insert("auth".to_owned(), auth.into()); - } - if let Some(token) = source.get("experimental_bearer_token").and_then(toml::Value::as_str) { - provider.insert("env_key".to_owned(), ASTRA_BEARER_VAR.into()); - secrets.push((ASTRA_BEARER_VAR.to_owned(), token.to_owned())); - } - if let Some(headers) = source.get("http_headers").and_then(toml::Value::as_table) { - let mut named = provider.get("env_http_headers").and_then(toml::Value::as_table).cloned().unwrap_or_default(); - for (index, (header, value)) in headers.iter().enumerate() { - let Some(value) = value.as_str() else { continue }; - // Codex sends an environment-backed header over a static one when its variable is set, so that mapping stays. - if named.get(header).and_then(toml::Value::as_str).is_some_and(|var| env_set(var)) { - continue; - } - let var = format!("{ASTRA_HEADER_VAR}{index}"); - named.insert(header.clone(), var.clone().into()); - secrets.push((var, value.to_owned())); - } - provider.insert("env_http_headers".to_owned(), named.into()); - } - config.insert("model_provider".to_owned(), "cliproxy".into()); - config.insert("model_providers".to_owned(), toml::Table::from_iter([("cliproxy".to_owned(), provider.into())]).into()); - } - let off: Vec = skills - .iter() - .map(|path| toml::Table::from_iter([("path".to_owned(), path.display().to_string().into()), ("enabled".to_owned(), false.into())]).into()) - .collect(); - config.insert("skills".to_owned(), toml::Table::from_iter([("config".to_owned(), toml::Value::Array(off))]).into()); - AstraConfig { config, secrets } -} - -/// A variable counts only with a value: Codex falls back to a static header -/// when the environment one is empty or blank, so the app does too. -fn has_value(var: &str) -> bool { - std::env::var(var).is_ok_and(|value| !value.trim().is_empty()) -} - -/// The variables that carry an inline provider credential to Astra's process. -const ASTRA_BEARER_VAR: &str = "M3D_ASTRA_BEARER_TOKEN"; -const ASTRA_HEADER_VAR: &str = "M3D_ASTRA_HEADER_"; - -/// Every `SKILL.md` under `~/.agents/skills`, at any depth, as codex finds them. -fn host_skills(home: &Path) -> Vec { - walkdir::WalkDir::new(home.join(".agents").join("skills")) - .follow_links(true) - .into_iter() - .filter_map(Result::ok) - .filter(|entry| entry.file_type().is_file() && entry.file_name() == "SKILL.md") - .map(walkdir::DirEntry::into_path) - .collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn only_the_inference_keys_and_key_helper_leave_the_claude_settings() { - let settings: ClaudeSettings = serde_json::from_str( - r#"{"env":{"ANTHROPIC_BASE_URL":"https://proxy","ANTHROPIC_AUTH_TOKEN":"t","API_TIMEOUT_MS":60000,"LIFEOS_DIR":"/x"}, - "apiKeyHelper":"/usr/local/bin/key","hooks":{"Stop":[]}}"#, - ) - .expect("settings"); - let inference = ClaudeInference::from_settings(settings); - assert_eq!( - inference.env, - [("ANTHROPIC_BASE_URL", "https://proxy"), ("ANTHROPIC_AUTH_TOKEN", "t"), ("API_TIMEOUT_MS", "60000")].map(|(k, v)| (k.to_owned(), v.to_owned())) - ); - let args = inference.args(Path::new("/app/studio-plugin")); - assert!(args.windows(2).any(|w| w == ["--setting-sources", "project"])); - assert!(args.windows(2).any(|w| w == ["--plugin-dir", "/app/studio-plugin"])); - assert!(args.contains(&r#"{"apiKeyHelper":"/usr/local/bin/key"}"#.to_owned())); - } - - #[test] - fn an_environment_backed_header_keeps_winning_when_its_variable_is_set() { - let user: toml::Table = r#" - [model_providers.cliproxy] - base_url = "https://mini:8317/v1" - http_headers = { Authorization = "Bearer static" } - env_http_headers = { Authorization = "PROXY_AUTH" } - "# - .parse() - .expect("toml"); - let set = astra_config(&user, &[], |var| var == "PROXY_AUTH"); - assert_eq!(set.config["model_providers"]["cliproxy"]["env_http_headers"]["Authorization"].as_str(), Some("PROXY_AUTH")); - assert!(set.secrets.is_empty(), "the static value is not used when the variable is set"); - let unset = astra_config(&user, &[], |_| false); - let var = unset.config["model_providers"]["cliproxy"]["env_http_headers"]["Authorization"].as_str().expect("var").to_owned(); - assert_eq!(unset.secrets, [(var, "Bearer static".to_owned())], "without the variable, the static value is sent"); - } - - #[test] - fn a_blank_environment_header_does_not_hide_the_static_one() { - let var = format!("M3D_TEST_HEADER_{}", std::process::id()); - // SAFETY: a variable no other test reads. - unsafe { std::env::set_var(&var, " ") }; - assert!(!has_value(&var), "a blank value counts as unset"); - unsafe { std::env::set_var(&var, "Bearer env") }; - assert!(has_value(&var)); - unsafe { std::env::remove_var(&var) }; - assert!(!has_value(&var)); - } - - #[test] - fn a_codex_config_astra_cannot_read_never_stops_a_turn() { - let home = tempfile::tempdir().expect("home"); - let data = tempfile::tempdir().expect("data"); - std::fs::create_dir_all(home.path().join(".codex")).expect("codex dir"); - std::fs::write(home.path().join(".codex/config.toml"), "model = \"unterminated").expect("config"); - let astra = astra_home(home.path(), data.path()).expect("the turn goes on"); - let config: toml::Table = std::fs::read_to_string(astra.dir.join("config.toml")).expect("written").parse().expect("toml"); - assert!(config.get("model_providers").is_none(), "Astra has no model to reach"); - } - - #[test] - fn astra_keeps_the_model_and_provider_and_turns_off_host_skills() { - let user: toml::Table = r#" - model = "gpt-6.1-sol" - model_provider = "cliproxy" - [mcp_servers.lifeos] - command = "lifeos" - [model_providers.cliproxy] - base_url = "https://mini:8317/v1" - wire_api = "responses" - experimental_bearer_token = "secret-token" - http_headers = { Authorization = "Bearer secret-token" } - [model_providers.cliproxy.auth] - command = "cat" - args = ["/home/o/.config/codex/cliproxy-api-key"] - "# - .parse() - .expect("toml"); - let AstraConfig { config, secrets } = astra_config(&user, &[PathBuf::from("/home/o/.agents/skills/a/SKILL.md")], |_| false); - assert_eq!(config["model"].as_str(), Some("gpt-6.1-sol")); - assert_eq!(config["model_providers"]["cliproxy"]["auth"]["command"].as_str(), Some("cat")); - assert!(!toml::to_string(&config).expect("toml").contains("secret-token"), "no inline secret is copied"); - // The credentials still reach Astra, by value through its process. - let cliproxy = &config["model_providers"]["cliproxy"]; - let bearer = cliproxy["env_key"].as_str().expect("env_key"); - let header = cliproxy["env_http_headers"]["Authorization"].as_str().expect("header var"); - assert!(secrets.contains(&(bearer.to_owned(), "secret-token".to_owned()))); - assert!(secrets.contains(&(header.to_owned(), "Bearer secret-token".to_owned()))); - assert!(config.get("mcp_servers").is_none(), "the person's MCP servers stay out"); - assert_eq!(config["project_doc_max_bytes"].as_integer(), Some(0)); - assert_eq!(config["skills"]["config"][0]["enabled"].as_bool(), Some(false)); - let no_proxy = astra_config(&"model = \"gpt\"".parse().expect("toml"), &[], |_| false).config; - assert!(no_proxy.get("model_providers").is_none(), "with no cliproxy provider Astra reaches no model"); - } -} diff --git a/src-tauri/src/studio/mod.rs b/src-tauri/src/studio/mod.rs index 4df9b614..c5bbb913 100644 --- a/src-tauri/src/studio/mod.rs +++ b/src-tauri/src/studio/mod.rs @@ -1,47 +1,19 @@ -//! The chat studio: one project folder per chat, a Claude Code agent that works -//! in it, and the files it writes. -//! -//! The agent is the local `claude` CLI, so it runs on the person's own login and -//! carries their skills, tools, and settings. Each turn is one `claude -p` run -//! in the project folder; later turns resume the same session. +//! The chat studio's native edges: the project folders the agent works in, the +//! files it writes, the preview's navigation guard, and the slice step. The +//! chats themselves live in the sidecar's store (`crate::sidecar`). pub mod files; -mod lean; +pub mod quit; pub mod slice; -pub mod turn; -use std::path::{Path, PathBuf}; - -use serde::{Deserialize, Serialize}; +use std::path::PathBuf; pub use files::register_protocol; -pub use turn::Turns; -/// Hidden folder in each project for the app's own records. +/// Hidden folder in each project for the app's own records: converted photos, and a chat from before the store. +/// The preview and the file list never show it. const META_DIR: &str = ".m3d"; -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ProjectMeta { - title: String, - created_ms: i64, - /// The Claude Code session every turn after the first resumes. - session_id: String, - /// The session was started by the lean agent. A session from before carries - /// the person's coding setup in its history, so it is never resumed. - #[serde(default)] - lean: bool, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct Project { - id: String, - title: String, - created_ms: i64, - path: PathBuf, -} - /// Whether the main window, or a preview frame in it, may go to `url`. The /// app's own pages and project files may load. The internet may not, so a /// preview page cannot carry project data out by navigating its frame there. @@ -78,100 +50,10 @@ pub(crate) fn project_dir(id: &str) -> Result { Ok(projects_root()?.join(id)) } -fn read_meta(dir: &Path) -> Result { - let path = dir.join(META_DIR).join("project.json"); - let text = std::fs::read_to_string(&path).map_err(|e| format!("reading {}: {e}", path.display()))?; - serde_json::from_str(&text).map_err(|e| format!("parsing {}: {e}", path.display())) -} - -fn write_meta(dir: &Path, meta: &ProjectMeta) -> Result<(), String> { - let text = serde_json::to_string_pretty(meta).map_err(|e| e.to_string())?; - std::fs::write(dir.join(META_DIR).join("project.json"), text).map_err(|e| format!("writing project record: {e}")) -} - -/// A project from before the lean agent gets a new session, so its next turn -/// starts without the person's setup. Its transcript and files stay. -fn lean_session(dir: &Path, mut meta: ProjectMeta) -> Result { - if meta.lean { - return Ok(meta); - } - meta.session_id = uuid::Uuid::new_v4().to_string(); - meta.lean = true; - match std::fs::remove_file(dir.join(META_DIR).join("session-started")) { - Ok(()) => {} - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => return Err(format!("resetting the session: {e}")), - } - write_meta(dir, &meta)?; - Ok(meta) -} - -fn slug(title: &str) -> String { - let mut out = String::new(); - for c in title.chars() { - if c.is_ascii_alphanumeric() { - out.push(c.to_ascii_lowercase()); - } else if !out.ends_with('-') && !out.is_empty() { - out.push('-'); - } - if out.len() >= 40 { - break; - } - } - let out = out.trim_end_matches('-').to_owned(); - if out.is_empty() { "project".to_owned() } else { out } -} - -/// Every project, newest first. A folder without the app's record is not a project. -#[tauri::command] -pub fn studio_projects() -> Result, String> { - let root = projects_root()?; - let entries = match std::fs::read_dir(&root) { - Ok(entries) => entries, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), - Err(e) => return Err(format!("reading {}: {e}", root.display())), - }; - let mut projects: Vec = entries - .filter_map(Result::ok) - .filter_map(|entry| { - let path = entry.path(); - let meta = read_meta(&path).ok()?; - Some(Project { id: entry.file_name().to_string_lossy().into_owned(), title: meta.title, created_ms: meta.created_ms, path }) - }) - .collect(); - projects.sort_by(|a, b| b.created_ms.cmp(&a.created_ms)); - Ok(projects) -} - -/// Makes a new project folder named after the first request. -#[tauri::command] -pub fn studio_create_project(title: String) -> Result { - let root = projects_root()?; - std::fs::create_dir_all(&root).map_err(|e| format!("creating {}: {e}", root.display()))?; - let now = chrono::Local::now(); - let base = format!("{}-{}", now.format("%Y-%m-%d"), slug(&title)); - let (id, path) = (1..) - .map(|n| if n == 1 { base.clone() } else { format!("{base}-{n}") }) - .map(|id| (id.clone(), root.join(id))) - .find(|(_, path)| !path.exists()) - .expect("an unused folder name"); - std::fs::create_dir_all(path.join(META_DIR)).map_err(|e| format!("creating {}: {e}", path.display()))?; - let title: String = title.lines().next().unwrap_or_default().chars().take(80).collect(); - let meta = ProjectMeta { title: title.clone(), created_ms: now.timestamp_millis(), session_id: uuid::Uuid::new_v4().to_string(), lean: true }; - write_meta(&path, &meta)?; - Ok(Project { id, title, created_ms: meta.created_ms, path }) -} - #[cfg(test)] mod tests { use super::*; - #[test] - fn slug_keeps_words_and_drops_the_rest() { - assert_eq!(slug("A wall mount for my UGREEN dock!"), "a-wall-mount-for-my-ugreen-dock"); - assert_eq!(slug(" "), "project"); - } - #[test] fn the_window_stays_on_the_app_and_project_files() { let may = |url: &str| may_navigate(&url.parse().expect("url")); @@ -182,19 +64,6 @@ mod tests { assert!(!may("http://192.168.1.5/")); } - #[test] - fn a_project_from_before_the_lean_agent_starts_a_new_session_once() { - let dir = tempfile::tempdir().expect("tempdir"); - std::fs::create_dir_all(dir.path().join(META_DIR)).expect("meta dir"); - std::fs::write(dir.path().join(META_DIR).join("session-started"), b"").expect("started"); - let old: ProjectMeta = serde_json::from_str(r#"{"title":"Clip","createdMs":1,"sessionId":"old"}"#).expect("old record"); - let meta = lean_session(dir.path(), old).expect("lean"); - assert_ne!(meta.session_id, "old"); - assert!(!dir.path().join(META_DIR).join("session-started").exists(), "the next turn starts its session instead of resuming"); - let again = lean_session(dir.path(), read_meta(dir.path()).expect("read")).expect("lean"); - assert_eq!(again.session_id, meta.session_id, "a lean session is kept"); - } - #[test] fn project_ids_are_plain_folder_names() { assert!(project_dir("../etc").is_err()); diff --git a/src-tauri/src/studio/quit.rs b/src-tauri/src/studio/quit.rs new file mode 100644 index 00000000..b7b8d5d9 --- /dev/null +++ b/src-tauri/src/studio/quit.rs @@ -0,0 +1,216 @@ +//! A normal quit, or closing the window, waits for the window to save what the person typed but has not saved yet. +//! The window saves its draft a moment after typing pauses, so text typed just before a quit would be lost without +//! this. The window says at once that it is saving, and then the quit waits for it to finish, as a send on its way +//! must end first. A window that could not save a draft calls the quit off, so the app stays open with the draft. A +//! window that does not answer, as one that is gone, holds the quit only briefly. A restart for an update cannot be +//! held: Tauri ignores a hold on it, and the update waits for idle. + +use std::sync::Mutex; +use std::time::Duration; + +use tauri::{AppHandle, Emitter, Manager, Runtime, State}; +use tokio::sync::Notify; + +/// The event that asks the window to save its draft now. +const FLUSH: &str = "studio://flush"; + +/// How long a quit waits for the window to answer. A window that does not answer, as one that is gone, never holds it +/// longer. +const FLUSH_WAIT: Duration = Duration::from_millis(1500); + +/// How long a quit waits for a window that said it is saving. Long enough for a send on its way and every save, and a +/// ceiling only so a save that never ends cannot keep the app open forever. +const FLUSH_HOLD: Duration = Duration::from_secs(60); + +/// What runs once the window has saved: the quit, or the window's close, each held for the save. +type Held = Box; + +/// Where the save before a quit stands. +#[derive(Default)] +enum Stage { + #[default] + Idle, + /// The window was asked. Each quit or close that came meanwhile waits here for the same save. + Saving(Vec), + /// The window saved, or the wait ended. A quit or close now goes through. + Saved, +} + +#[derive(Default)] +pub struct QuitFlush { + stage: Mutex, + /// The window started saving. + started: Notify, + saved: Notify, + /// The window could not save a draft. + failed: Notify, +} + +/// What the window answered. +enum Answer { + Started, + Saved, + Failed, +} + +impl QuitFlush { + /// Holds a quit or a close until the window has saved its draft, and then runs `then`. The first one asks the + /// window. One that comes while it saves waits for the same save. Returns whether it held, so the caller stops the + /// quit or close for now; once the window has saved, nothing is held. + pub fn hold(app: &AppHandle, then: impl FnOnce(AppHandle) + Send + 'static) -> bool { + let flush = app.state::(); + let mut stage = flush.stage.lock().unwrap_or_else(|e| e.into_inner()); + let handle = app.clone(); + let held: Held = Box::new(move || then(handle)); + match &mut *stage { + Stage::Saved => return false, + Stage::Saving(waiting) => { + waiting.push(held); + return true; + } + Stage::Idle => *stage = Stage::Saving(vec![held]), + } + drop(stage); + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let saved = if app.emit(FLUSH, ()).is_ok() { + let state = app.state::(); + let flush: &QuitFlush = &state; + let answer = |started: bool| async move { + tokio::select! { + _ = flush.saved.notified() => Answer::Saved, + _ = flush.failed.notified() => Answer::Failed, + _ = flush.started.notified(), if !started => Answer::Started, + } + }; + match tokio::time::timeout(FLUSH_WAIT, answer(false)).await { + // The window is saving: wait until it is done, or says it could not save. + Ok(Answer::Started) => !matches!(tokio::time::timeout(FLUSH_HOLD, answer(true)).await, Ok(Answer::Failed)), + Ok(Answer::Failed) => false, + Ok(Answer::Saved) | Err(_) => true, + } + } else { + true + }; + // A draft the window could not save calls the quit or close off, so nothing is lost, and the next one asks + // the window again. + let next = if saved { Stage::Saved } else { Stage::Idle }; + let held = match std::mem::replace(&mut *app.state::().stage.lock().unwrap_or_else(|e| e.into_inner()), next) { + Stage::Saving(held) => held, + Stage::Idle | Stage::Saved => Vec::new(), + }; + if saved { + for then in held { + then(); + } + } + }); + true + } +} + +/// The window's first answer: it is saving, so the quit waits for it. +#[tauri::command] +pub fn studio_flush_started(flush: State<'_, QuitFlush>) { + flush.started.notify_one(); +} + +/// The window's last answer when a draft could not be saved: the quit or close is called off. +#[tauri::command] +pub fn studio_flush_failed(flush: State<'_, QuitFlush>) { + flush.failed.notify_one(); +} + +/// The window's last answer: everything is saved. +#[tauri::command] +pub fn studio_flushed(flush: State<'_, QuitFlush>) { + flush.saved.notify_one(); +} + +/// Opens the Files and Folders pane of macOS Privacy settings, where the person lets the app read Documents. +#[tauri::command] +pub fn studio_open_files_privacy() -> Result<(), String> { + #[cfg(target_os = "macos")] + { + std::process::Command::new("/usr/bin/open") + .arg("x-apple.systempreferences:com.apple.preference.security?Privacy_FilesAndFolders") + .status() + .map_err(|e| format!("opening Privacy settings: {e}")) + .and_then(|status| if status.success() { Ok(()) } else { Err(format!("opening Privacy settings ended with {status}")) }) + } + #[cfg(not(target_os = "macos"))] + { + Err("Privacy settings for folders are a macOS setting.".to_owned()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_quit_asks_the_window_once_and_goes_on_without_an_answer() { + let app = tauri::test::mock_app(); + app.manage(QuitFlush::default()); + let (done, finished) = std::sync::mpsc::channel(); + let (first, second) = (done.clone(), done.clone()); + assert!(QuitFlush::hold(app.handle(), move |_| first.send("close").unwrap()), "the close waits for the window"); + // A quit while the window still saves waits for the same save, then goes on too. + assert!(QuitFlush::hold(app.handle(), move |_| second.send("quit").unwrap()), "the quit waits for that save"); + // No window answers in the mock, so the wait ends at its limit. + let mut ran = [ + finished.recv_timeout(FLUSH_WAIT + Duration::from_secs(2)).expect("the close goes on"), + finished.recv_timeout(Duration::from_secs(1)).expect("the quit goes on"), + ]; + ran.sort(); + assert_eq!(ran, ["close", "quit"]); + assert!(!QuitFlush::hold(app.handle(), move |_| done.send("again").unwrap()), "once saved, a quit goes through"); + } + + #[test] + fn a_window_that_started_saving_holds_the_quit_past_the_first_wait_until_it_is_done() { + let app = tauri::test::mock_app(); + app.manage(QuitFlush::default()); + let (done, finished) = std::sync::mpsc::channel(); + let started = std::time::Instant::now(); + assert!(QuitFlush::hold(app.handle(), move |_| done.send(()).unwrap())); + std::thread::sleep(Duration::from_millis(50)); + studio_flush_started(app.state::()); + // A save that takes longer than the first wait, as during a slow send, still holds the quit. + assert!(finished.recv_timeout(FLUSH_WAIT + Duration::from_millis(500)).is_err(), "the quit waited for the window"); + studio_flushed(app.state::()); + finished.recv_timeout(Duration::from_secs(1)).expect("the quit goes on once the window is done"); + assert!(started.elapsed() > FLUSH_WAIT, "held past the first wait, after {:?}", started.elapsed()); + } + + #[test] + fn a_save_that_failed_cancels_the_quit_and_a_later_quit_asks_again() { + let app = tauri::test::mock_app(); + app.manage(QuitFlush::default()); + let (done, finished) = std::sync::mpsc::channel(); + let again = done.clone(); + assert!(QuitFlush::hold(app.handle(), move |_| done.send(()).unwrap())); + std::thread::sleep(Duration::from_millis(50)); + studio_flush_started(app.state::()); + studio_flush_failed(app.state::()); + assert!(finished.recv_timeout(Duration::from_secs(1)).is_err(), "the app stays open"); + // A later quit holds and asks the window again, as nothing was saved. + std::thread::sleep(Duration::from_millis(100)); + assert!(QuitFlush::hold(app.handle(), move |_| again.send(()).unwrap()), "the next quit waits for the window"); + studio_flushed(app.state::()); + finished.recv_timeout(FLUSH_WAIT).expect("the next quit goes on once the window saved"); + } + + #[test] + fn the_windows_answer_ends_the_wait_at_once() { + let app = tauri::test::mock_app(); + app.manage(QuitFlush::default()); + let (done, finished) = std::sync::mpsc::channel(); + let started = std::time::Instant::now(); + assert!(QuitFlush::hold(app.handle(), move |_| done.send(()).unwrap())); + std::thread::sleep(Duration::from_millis(50)); + studio_flushed(app.state::()); + finished.recv_timeout(FLUSH_WAIT).expect("the quit goes on"); + assert!(started.elapsed() < FLUSH_WAIT, "the answer ended the wait, after {:?}", started.elapsed()); + } +} diff --git a/src-tauri/src/studio/system_prompt.md b/src-tauri/src/studio/system_prompt.md deleted file mode 100644 index 4d9586e6..00000000 --- a/src-tauri/src/studio/system_prompt.md +++ /dev/null @@ -1,22 +0,0 @@ -# Materialize 3D - -You are working inside Materialize 3D, a desktop app. Ossie talks to you here to design parts he prints on his Bambu Lab P2S. The end of every job is a print-ready 3MF file. Everything else supports that. - -Your working directory is this chat's project folder. The app shows Ossie every file you write here: - -- An HTML file renders as a page in the app's preview panel, scripts included. Inline your scripts (for example three.js) or put them beside the page with relative paths. The preview blocks the internet, so a page loads only files in this folder and code inlined in it. -- An STL, 3MF, GLB, or glTF file opens in a 3D orbit viewer. -- PNG, JPG, and SVG files show as pictures. -- The newest file opens on its own, so write the file you want Ossie to look at last. - -Pictures Ossie drops into the chat are saved in `refs/` and attached to his message. Use them as references for shape and measurements. - -## How a job goes - -Follow the `materialize-studio:fabricate` skill for every job. It ships with this app and holds the steps: parameters, model and fit checks, preview, HTML guide, and the slice to 3MF, plus how to hand a task to Astra, the GPT helper. - -## Rules - -- Do not use the old Materialize MCP server or its build, approve, or export tools. Work with files in this folder. -- Keep your messages short and plain. Lead with what changed and what Ossie should look at. -- Never invent measurements for parts Ossie owns. Estimate, mark it TBD, and say so. diff --git a/src-tauri/src/studio/turn.rs b/src-tauri/src/studio/turn.rs deleted file mode 100644 index ef9527bd..00000000 --- a/src-tauri/src/studio/turn.rs +++ /dev/null @@ -1,830 +0,0 @@ -//! One chat turn: a `claude -p` run in the project folder whose stream-json -//! events go to the window and into the project's transcript. - -use std::collections::{BTreeSet, HashMap}; -use std::io::Write as _; -use std::path::{Path, PathBuf}; -use std::process::Stdio; -use std::sync::{Arc, Mutex, OnceLock}; - -use base64::Engine as _; -use serde_json::{json, Value}; -use tauri::{AppHandle, Emitter, Manager, State}; -use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; -use tokio_util::sync::CancellationToken; - -use super::{files, lean, lean_session, project_dir, read_meta, META_DIR}; -use crate::fabrication::build_gate::{BuildGate, OwnedRunning}; -use crate::state::AppState; - -const SYSTEM_PROMPT: &str = include_str!("system_prompt.md"); -const MODEL: &str = "claude-opus-5-5"; -pub const EVENT: &str = "studio://event"; - -/// The variable each agent runs with, which its tools inherit. A reopen after a -/// crash finds what a turn left by this tag, because the agent that started the -/// tools is gone by then. -const TAG_VAR: &str = "M3D_TURN"; - -/// A running turn: Stop cancels it, and quitting the app ends its processes. -struct Turn { - token: CancellationToken, - tag: String, - /// The agent's pid while it is unreaped, 0 before it starts and after. The - /// turn takes this lock to clear it before reaping, and quitting holds it - /// while ending the turn, so the pid is never used after it could be reused. - leader: Arc>, -} - -/// The running turn of each project, so a second send waits and Stop can end it. -#[derive(Default)] -pub struct Turns(Mutex>); - -impl Turns { - /// Ends every running agent and its tools. Called as the app exits, when - /// there is no time left to wait on the turns' own tasks. - pub fn stop_all(&self) { - let Ok(running) = self.0.lock() else { return }; - let tags: Vec = running.values().inspect(|turn| turn.token.cancel()).map(|turn| turn.tag.clone()).collect(); - // Held until the end, so no turn reaps its agent while its pid is in use here. - let leaders: Vec<_> = running.values().filter_map(|turn| turn.leader.lock().ok()).collect(); - let pids: Vec = leaders.iter().map(|leader| **leader).collect(); - end_turn(&pids, &tags, &BTreeSet::new()); - } - - /// The groups of every running agent but this project's, which this turn must never signal. - fn other_agents(&self, project: &str) -> BTreeSet { - let Ok(running) = self.0.lock() else { return BTreeSet::new() }; - running - .iter() - .filter(|(name, _)| name.as_str() != project) - .filter_map(|(_, turn)| turn.leader.lock().ok().map(|pid| *pid as i32)) - .filter(|&group| group > 0) - .collect() - } -} - -/// One process in a `ps` reading. -#[cfg(unix)] -struct Process { - pid: i32, - parent: i32, - group: i32, - /// False for a process that has exited but is not yet reaped (a zombie). - running: bool, -} - -/// Every process, with its parent and group. `ps` shows these for every process, -/// the system's own programs included. -#[cfg(unix)] -fn processes() -> Vec { - let Ok(out) = std::process::Command::new("ps").args(["-axo", "pid=,ppid=,pgid=,stat="]).output() else { return Vec::new() }; - String::from_utf8_lossy(&out.stdout) - .lines() - .filter_map(|line| { - let mut words = line.split_whitespace(); - Some(Process { - pid: words.next()?.parse().ok()?, - parent: words.next()?.parse().ok()?, - group: words.next()?.parse().ok()?, - running: !words.next()?.starts_with('Z'), - }) - }) - .collect() -} - -/// The process groups that hold a process carrying one of these turn tags. -/// macOS hides the environment of its own programs (sh, bash, zsh, sleep, tail), -/// so there only tools such as python, uv, node, and Bambu Studio are found this way. -#[cfg(unix)] -fn tagged_groups(tags: &[String]) -> Vec { - if tags.is_empty() { - return Vec::new(); - } - let args: &[&str] = if cfg!(target_os = "macos") { &["-axE", "-ww", "-o", "pgid=,command="] } else { &["axeww", "-o", "pgid=,command="] }; - let Ok(out) = std::process::Command::new("ps").args(args).output() else { return Vec::new() }; - let marks: Vec = tags.iter().map(|tag| format!("{TAG_VAR}={tag}")).collect(); - String::from_utf8_lossy(&out.stdout) - .lines() - // A whole word, as `ps` prints each variable, not any text that contains it. - .filter(|line| line.split_whitespace().any(|word| marks.iter().any(|mark| word == mark))) - .filter_map(|line| line.split_whitespace().next()?.parse().ok()) - .collect() -} - -/// The groups of these agents and of every process under them. This covers a -/// shell tool that Claude Code started in a group of its own, whose environment -/// macOS hides. Only safe while each leader is unreaped. -#[cfg(unix)] -fn tree_groups(table: &[Process], leaders: &[u32]) -> BTreeSet { - let mut tree: BTreeSet = leaders.iter().filter(|&&pid| pid > 0).map(|&pid| pid as i32).collect(); - loop { - let more: Vec = table.iter().filter(|p| tree.contains(&p.parent) && !tree.contains(&p.pid)).map(|p| p.pid).collect(); - if more.is_empty() { - break; - } - tree.extend(more); - } - table.iter().filter(|p| tree.contains(&p.pid)).map(|p| p.group).collect() -} - -/// Ends a turn's processes: SIGTERM to every group it owns, then SIGKILL after -/// three seconds to whatever still runs. A group is the turn's when it is in the -/// agent's live tree or holds a process with the turn's tag, and never when it is -/// the app's group or in `exclude` (other turns' agents). The tree is read before -/// any signal, while every leader is unreaped, so a tool that loses its agent -/// stays on the list. Blocking: for Stop, the end of every turn, the app's exit, -/// and a reopen after a crash. -/// -/// Known limits (issue #192), because macOS gives an app no way to own a process tree: a tool -/// Claude Code leaves running when it exits by itself, and a tool that survives a -/// crash of both the app and the agent, are found only by their tag, and macOS -/// hides the tag of its own programs (sh, bash, zsh, sleep, tail). -fn end_turn(leaders: &[u32], tags: &[String], exclude: &BTreeSet) { - #[cfg(unix)] - { - // SAFETY: getpgrp only reads this process's own group. - let own = unsafe { libc::getpgrp() }; - let ours = |group: &i32| *group > 1 && *group != own && !exclude.contains(group); - let read = |table: &[Process]| -> BTreeSet { tree_groups(table, leaders).into_iter().chain(tagged_groups(tags)).filter(ours).collect() }; - // A group that is already gone is the outcome we want, so the result is not read. - let signal = |groups: &mut dyn Iterator, sig: libc::c_int| { - for group in groups { - // SAFETY: killpg only signals a group this turn owns. - let _ = unsafe { libc::killpg(group, sig) }; - } - }; - let table = processes(); - if table.is_empty() { - // No `ps` to read: end at least each agent's own group, whose pid is still its own. - signal(&mut leaders.iter().map(|&pid| pid as i32).filter(ours), libc::SIGTERM); - return; - } - let mut known = read(&table); - if known.is_empty() { - return; - } - signal(&mut known.iter().copied(), libc::SIGTERM); - for _ in 0..30 { - let table = processes(); - let fresh = read(&table); - signal(&mut fresh.difference(&known).copied(), libc::SIGTERM); - known.extend(fresh); - if !table.iter().any(|p| p.running && known.contains(&p.group)) { - return; - } - std::thread::sleep(std::time::Duration::from_millis(100)); - } - let table = processes(); - signal(&mut known.iter().copied().filter(|group| table.iter().any(|p| p.running && p.group == *group)), libc::SIGKILL); - } -} - -/// Waits up to five seconds for an agent whose output ended to exit by itself, -/// without reaping it, so its pid stays its own while the turn is ended. -#[cfg(unix)] -fn wait_for_exit(pid: u32) { - for _ in 0..50 { - if !processes().iter().any(|p| p.pid == pid as i32 && p.running) { - return; - } - std::thread::sleep(std::time::Duration::from_millis(100)); - } -} - -/// Names the running turn's tag while it runs, so a reopen after a crash can find what it left. -fn tag_path(dir: &Path) -> PathBuf { - dir.join(META_DIR).join("agent.turn") -} - -/// After a crash, an agent or its tools can outlive the app. The tag file names -/// the turn, and every process still carrying that tag is stopped. The agent is -/// gone by then, so its process tree cannot be walked. Known limit: a tool that -/// clears its environment, or on macOS a group made only of the system's own -/// programs (sh, bash, zsh, sleep), shows no tag and survives a crash of both -/// the app and the agent. Stop, the end of a turn, and quitting the app read the -/// live tree instead, so they end those tools. -fn stop_survivor(dir: &Path) { - let path = tag_path(dir); - // Only a whole tag counts. Anything else could match other turns' processes. - let tag = std::fs::read_to_string(&path).ok().and_then(|text| uuid::Uuid::parse_str(text.trim()).ok()); - if let Some(tag) = tag { - log::warn!("studio: stopping processes left from a turn before the app closed"); - end_turn(&[], &[tag.to_string()], &BTreeSet::new()); - } - let _ = std::fs::remove_file(&path); -} - -fn transcript_path(dir: &Path) -> PathBuf { - dir.join(META_DIR).join("transcript.jsonl") -} - -/// Reads the transcript. A last line without its newline is a write still in -/// progress, or one the app never finished because it stopped. Readers skip it. -/// The one writer (`repair`) cuts it off: the turn that owns the file, or the -/// reconcile when no turn runs. -fn read_transcript(dir: &Path, repair: bool) -> Result, String> { - let path = transcript_path(dir); - // Bytes first: a write cut inside a multibyte character is not valid UTF-8 until it is cut off. - let mut bytes = match std::fs::read(&path) { - Ok(bytes) => bytes, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), - Err(e) => return Err(format!("{}: {e}", path.display())), - }; - if bytes.last().is_some_and(|&b| b != b'\n') { - bytes.truncate(bytes.iter().rposition(|&b| b == b'\n').map_or(0, |end| end + 1)); - if repair { - std::fs::write(&path, &bytes).map_err(|e| format!("{}: {e}", path.display()))?; - } - } - let text = String::from_utf8(bytes).map_err(|e| format!("{}: {e}", path.display()))?; - text.lines().map(|line| serde_json::from_str(line).map_err(|e| format!("{}: {e}", path.display()))).collect() -} - -/// The project's transcript, one JSON event per line. A line's index is its -/// `seq`, which lets the window join a loaded history with live events without -/// a gap or a repeat. -struct Transcript { - dir: PathBuf, - next: usize, -} - -impl Transcript { - fn open(dir: &Path) -> Result { - Ok(Self { dir: dir.to_owned(), next: read_transcript(dir, true)?.len() }) - } - - fn write(&mut self, event: &Value) -> Result { - let path = transcript_path(&self.dir); - let mut file = std::fs::OpenOptions::new().create(true).append(true).open(&path).map_err(|e| format!("{}: {e}", path.display()))?; - writeln!(file, "{event}").map_err(|e| format!("{}: {e}", path.display()))?; - self.next += 1; - Ok(self.next - 1) - } - - /// Writes the event, then sends it to the window with its `seq`. - fn record(&mut self, app: &AppHandle, project: &str, event: Value) -> Result<(), String> { - let seq = self.write(&event)?; - let _ = app.emit(EVENT, json!({ "project": project, "seq": seq, "event": event })); - Ok(()) - } -} - -/// A turn whose user message has no end event was cut off when the app closed. -fn has_open_turn(events: &[Value]) -> bool { - events.iter().fold(false, |open, event| match event["type"].as_str() { - Some("m3d_user") => true, - Some("m3d_turn_end") => false, - _ => open, - }) -} - -/// Everything said in the project so far, in order. A turn the app was closed -/// during is ended here, so the chat never waits on a process that is gone. -#[tauri::command] -pub fn studio_transcript(turns: State<'_, Turns>, project: String) -> Result, String> { - let dir = project_dir(&project)?; - let running = turns.0.lock().map_err(|e| e.to_string())?; - let idle = !running.contains_key(&project); - let mut events = read_transcript(&dir, idle)?; - if idle && has_open_turn(&events) { - stop_survivor(&dir); - let end = json!({ "type": "m3d_turn_end", "ok": false, "error": "This turn stopped when the app closed." }); - Transcript { dir, next: events.len() }.write(&end)?; - events.push(end); - } - Ok(events) -} - -/// The app's data folder, or a verification run's own. -fn app_data_dir(app: &AppHandle) -> Result { - #[cfg(feature = "e2e")] - if let Some(dir) = std::env::var_os("M3D_E2E_DATA_DIR") { - return Ok(PathBuf::from(dir)); - } - app.path().app_data_dir().map_err(|e| format!("app data folder: {e}")) -} - -/// How long the login shell may take to tell its PATH before the app goes on without it. -const LOGIN_SHELL_DEADLINE: std::time::Duration = std::time::Duration::from_secs(5); - -/// The PATH of the person's login shell. An app opened from Finder starts with -/// only the system folders, where `claude`, `codex`, and `uv` are not. -pub(crate) fn login_path() -> &'static str { - static PATH: OnceLock = OnceLock::new(); - PATH.get_or_init(|| { - let current = std::env::var("PATH").unwrap_or_default(); - let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_owned()); - match shell_path(&shell, LOGIN_SHELL_DEADLINE) { - Some(path) if current.is_empty() => path, - Some(path) => format!("{path}:{current}"), - None => current, - } - }) -} - -/// The PATH `shell` reports as a login shell, or none when it fails, says nothing, or does not end within -/// `deadline`. A shell whose startup files hang is ended with all it started, so the sidecar and the runner still -/// start. -fn shell_path(shell: &str, deadline: std::time::Duration) -> Option { - let marker = "__M3D_PATH__"; - let end = std::time::Instant::now() + deadline; - let mut command = std::process::Command::new(shell); - command - .args(["-ilc", &format!("printf '{marker}%s' \"$PATH\"")]) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()); - // The shell leads a session of its own, so the deadline ends what its startup files started too. A session, not - // only a group: a shell in a background group of the terminal the app started from stops before its startup - // files, and one in a session of its own has no terminal to stop for. - #[cfg(unix)] - // SAFETY: setsid is async-signal-safe and only makes the child a session leader before it runs the shell. - unsafe { - std::os::unix::process::CommandExt::pre_exec(&mut command, || { - if libc::setsid() == -1 { - return Err(std::io::Error::last_os_error()); - } - Ok(()) - }); - } - let mut child = command.spawn().ok()?; - let mut stdout = child.stdout.take()?; - let (sent, said) = std::sync::mpsc::channel(); - std::thread::spawn(move || { - let mut out = Vec::new(); - let _ = std::io::Read::read_to_end(&mut stdout, &mut out); - let _ = sent.send(out); - }); - let out = said.recv_timeout(deadline).ok(); - // Startup files can send stdout elsewhere and go on running, so the shell's end has the same deadline. - let ended = loop { - match child.try_wait() { - Ok(Some(_)) => break true, - Ok(None) if std::time::Instant::now() < end => std::thread::sleep(std::time::Duration::from_millis(20)), - _ => break false, - } - }; - if !ended { - #[cfg(unix)] - if let Ok(group) = i32::try_from(child.id()) { - // SAFETY: killpg only sends a signal, to the group this shell leads, as its session leader, and has not been - // reaped from. - unsafe { libc::killpg(group, libc::SIGKILL) }; - } - let _ = child.kill(); - let _ = child.wait(); - } - let out = out?; - String::from_utf8_lossy(&out).rsplit_once(marker).map(|(_, path)| path.trim().to_owned()).filter(|path| !path.is_empty()) -} - -/// The image the model gets for a picture in `refs/`. Claude reads JPEG, PNG, -/// GIF, and WebP up to about 5 MB, so on macOS a HEIC photo or a large one is -/// sent as a smaller JPEG made with `sips`. The original stays in `refs/`. -fn image_block(dir: &Path, rel: &str) -> Result { - let path = dir.join(rel); - let ext = path.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).unwrap_or_default(); - let size = std::fs::metadata(&path).map_err(|e| format!("{rel}: {e}"))?.len(); - let readable = matches!(ext.as_str(), "jpg" | "jpeg" | "png" | "gif" | "webp"); - let send_path = if cfg!(target_os = "macos") && (!readable || size > 3_500_000) { - let out = dir.join(META_DIR).join("send").join(format!("{}.jpg", rel.replace('/', "_"))); - std::fs::create_dir_all(out.parent().expect("has parent")).map_err(|e| e.to_string())?; - let status = std::process::Command::new("sips") - .args(["-s", "format", "jpeg", "-Z", "2000"]) - .arg(&path) - .arg("--out") - .arg(&out) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map_err(|e| format!("sips: {e}"))?; - if !status.success() { - return Err(format!("could not convert {rel} to JPEG")); - } - out - } else if readable { - path - } else { - return Err(format!("{rel} is not a JPEG, PNG, GIF, or WebP picture")); - }; - let media_type = match send_path.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).as_deref() { - Some("png") => "image/png", - Some("gif") => "image/gif", - Some("webp") => "image/webp", - _ => "image/jpeg", - }; - let bytes = std::fs::read(&send_path).map_err(|e| format!("{}: {e}", send_path.display()))?; - let data = base64::engine::general_purpose::STANDARD.encode(bytes); - Ok(json!({ "type": "image", "source": { "type": "base64", "media_type": media_type, "data": data } })) -} - -/// Hook chatter is noise in the chat; everything else is kept. -fn keep(event: &Value) -> bool { - !(event["type"] == "system" && event["subtype"] != "init") -} - -/// Starts a turn. Returns once the agent is running; its events arrive on -/// `studio://event` and end with `m3d_turn_end`. -#[tauri::command] -pub fn studio_send(app: AppHandle, turns: State<'_, Turns>, project: String, text: String, images: Vec) -> Result<(), String> { - let dir = project_dir(&project)?; - let meta = lean_session(&dir, read_meta(&dir)?)?; - let mut content: Vec = images.iter().map(|rel| image_block(&dir, rel)).collect::>()?; - let mut prompt = text.clone(); - if !images.is_empty() { - prompt.push_str(&format!("\n\n(Pictures attached, saved in this folder as: {})", images.join(", "))); - } - content.push(json!({ "type": "text", "text": prompt })); - let message = json!({ "type": "user", "message": { "role": "user", "content": content } }); - - // The agent's own environment: the person's inference login, the app's plugin, and nothing else of their setup. - let home = dirs::home_dir().ok_or("no home folder")?; - let inference = lean::ClaudeInference::read(&home)?; - let plugin = app.path().resource_dir().map_err(|e| format!("app resources: {e}"))?.join("studio-plugin"); - let app_data = app_data_dir(&app)?; - - // Taken before anything is written and dropped after the last write, on every path. - let pass = take_pass(app.state::>().inner().clone())?; - let token = CancellationToken::new(); - let tag = uuid::Uuid::new_v4().to_string(); - let leader = Arc::new(Mutex::new(0)); - { - let mut running = turns.0.lock().map_err(|e| e.to_string())?; - if running.contains_key(&project) { - return Err("The agent is still working on the last message.".to_owned()); - } - running.insert(project.clone(), Turn { token: token.clone(), tag: tag.clone(), leader: leader.clone() }); - } - // A turn that cannot write its first line never started, so it gives its place back. - let mut transcript = match Transcript::open(&dir) - .and_then(|mut t| t.record(&app, &project, json!({ "type": "m3d_user", "text": text, "images": images })).map(|()| t)) - { - Ok(transcript) => transcript, - Err(e) => { - turns.0.lock().map_err(|e| e.to_string())?.remove(&project); - return Err(e); - } - }; - - let started = dir.join(META_DIR).join("session-started"); - let session_flag = if started.exists() { "--resume" } else { "--session-id" }; - let model = std::env::var("M3D_AGENT_MODEL").unwrap_or_else(|_| MODEL.to_owned()); - let mut command = tokio::process::Command::new("claude"); - command - .args(["-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose"]) - .args(["--model", &model, "--permission-mode", "bypassPermissions"]) - .args(["--append-system-prompt", SYSTEM_PROMPT]) - .args([session_flag, &meta.session_id]) - .args(inference.args(&plugin)) - .current_dir(&dir) - // Only what the agent needs: a home, a shell, the locale, the login PATH, - // the inference keys, and Astra's own home. Nothing else of the app's environment. - .env_clear() - .envs(lean::KEPT_ENV.iter().filter_map(|&key| std::env::var_os(key).map(|value| (key, value)))) - .env("PATH", login_path()) - .envs(inference.env.iter().map(|(key, value)| (key, value))) - .envs(lean::NO_MEMORY_ENV) - .env(TAG_VAR, &tag) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .kill_on_drop(true); - // Its own process group, apart from the app's, so signals to it never reach the app. - #[cfg(unix)] - command.process_group(0); - - tauri::async_runtime::spawn(async move { - let before: HashMap = files::list(&dir).unwrap_or_default().into_iter().map(|f| (f.rel.clone(), f.modified_ms)).collect(); - // Astra's home walks the person's skills folder, so it is made here, off the main thread. - // If it cannot be made, Astra still gets the app's folder and so has no model, never the person's setup. - let astra_dir = app_data.join("astra"); - let astra = tauri::async_runtime::spawn_blocking(move || lean::astra_home(&home, &app_data)) - .await - .map_err(|e| format!("preparing Astra: {e}")) - .and_then(|made| made) - .unwrap_or_else(|error| { - log::warn!("studio: {error}, so Astra cannot reach a model this turn"); - lean::AstraHome { dir: astra_dir, env: Vec::new(), secrets: Vec::new() } - }); - let mut command = command; - // Astra, the codex helper, finds its home through the agent's environment, plus the variables its provider reads. - command - .env("CODEX_HOME", &astra.dir) - .envs(astra.env.iter().filter_map(|key| std::env::var_os(key).map(|value| (key, value)))) - .envs(astra.secrets.iter().map(|(key, value)| (key, value))); - let outcome = run(&app, &project, &mut transcript, &started, command, message, token, &tag, &leader).await; - let changed: Vec = files::list(&dir) - .unwrap_or_default() - .into_iter() - .filter(|f| before.get(&f.rel) != Some(&f.modified_ms) && !f.rel.starts_with("refs/")) - .map(|f| f.rel) - .collect(); - let mut events = Vec::new(); - if !changed.is_empty() { - events.push(json!({ "type": "m3d_files", "files": changed })); - } - events.push(match outcome { - Ok(()) => json!({ "type": "m3d_turn_end", "ok": true }), - Err(error) => json!({ "type": "m3d_turn_end", "ok": false, "error": error }), - }); - for event in events { - if let Err(e) = transcript.record(&app, &project, event) { - log::error!("studio: transcript write failed: {e}"); - } - } - if let Some(turns) = app.try_state::() { - if let Ok(mut running) = turns.0.lock() { - running.remove(&project); - } - } - // The turn's last line is written, so the updater may go ahead. - drop(pass); - }); - Ok(()) -} - -/// The gate inside the app's state, so a turn's pass can own its way to it. -struct StateGate(Arc); - -impl std::ops::Deref for StateGate { - type Target = BuildGate; - fn deref(&self) -> &BuildGate { - &self.0.builds - } -} - -/// A turn's build pass. The updater restarts the app only once no build and no -/// turn holds one. A turn takes it before its first transcript line and drops it -/// after its last, so a restart never cuts a turn's work or its record short. -/// While an update installs, the gate is closed and a new turn is refused. -fn take_pass(state: Arc) -> Result, String> { - BuildGate::enter_owned(StateGate(state)) - .ok_or_else(|| "An update is installing, and the app restarts when it is done. Send this again after it opens.".to_owned()) -} - -async fn run( - app: &AppHandle, - project: &str, - transcript: &mut Transcript, - started: &Path, - mut command: tokio::process::Command, - message: Value, - token: CancellationToken, - tag: &str, - leader: &Mutex, -) -> Result<(), String> { - // Written before the agent starts and removed when this run ends, so a crash leaves it behind. - let tag_file = tag_path(&transcript.dir); - // Written whole or not at all, so a crash never leaves a partial tag. - let staged = tag_file.with_extension("turn.new"); - std::fs::write(&staged, tag).and_then(|()| std::fs::rename(&staged, &tag_file)).map_err(|e| format!("{}: {e}", tag_file.display()))?; - let _tag_file = RemoveOnDrop(tag_file); - let mut child = command.spawn().map_err(|e| format!("could not start the claude CLI: {e}. Install Claude Code and sign in."))?; - let pid = child.id().unwrap_or(0); - *leader.lock().map_err(|e| e.to_string())? = pid; - - let mut stderr = child.stderr.take().expect("piped stderr"); - let stderr_task = tokio::spawn(async move { - let mut text = String::new(); - let _ = stderr.read_to_string(&mut text).await; - text - }); - let outcome = drive(app, project, transcript, started, &mut child, message, &token).await; - - // However the turn ended, nothing of it keeps running, and this happens before - // the tag file goes. The agent is not reaped yet, so its pid is still its own. - let finished = matches!(outcome, Ok(_)); - let exclude = app.try_state::().map(|turns| turns.other_agents(project)).unwrap_or_default(); - let tags = vec![tag.to_owned()]; - let _ = tauri::async_runtime::spawn_blocking(move || { - #[cfg(unix)] - if finished { - wait_for_exit(pid); - } - end_turn(&[pid], &tags, &exclude); - }) - .await; - // Whatever the process reading found, the agent itself does not outlive its - // turn. A kill without a wait leaves it unreaped, so its pid stays its own. - let _ = child.start_kill(); - // Cleared before reaping, under the lock quitting holds while it ends turns, - // so no one uses the pid once it could belong to someone else. - if let Ok(mut pid) = leader.lock() { - *pid = 0; - } - let status = child.wait().await.map_err(|e| format!("waiting for claude: {e}")); - let result_error = outcome?; - let status = status?; - let stderr = stderr_task.await.unwrap_or_default(); - if let Some(error) = result_error { - return Err(error); - } - if !status.success() { - let tail: String = stderr.trim().chars().rev().take(600).collect::>().into_iter().rev().collect(); - return Err(if tail.is_empty() { format!("claude exited with {status}") } else { tail }); - } - Ok(()) -} - -/// Sends the message and records the agent's events until its output ends or -/// it is stopped. Returns the error the agent reported, if any. It never reaps -/// the agent: `run` does that after the turn's processes are ended. -async fn drive( - app: &AppHandle, - project: &str, - transcript: &mut Transcript, - started: &Path, - child: &mut tokio::process::Child, - message: Value, - token: &CancellationToken, -) -> Result, String> { - let mut stdin = child.stdin.take().expect("piped stdin"); - let line = format!("{message}\n"); - // A Stop or a quit that came while the agent was starting ends it here. - tokio::select! { - () = token.cancelled() => return Err("Stopped.".to_owned()), - written = stdin.write_all(line.as_bytes()) => written.map_err(|e| format!("writing to claude: {e}"))?, - } - drop(stdin); - - let mut lines = BufReader::new(child.stdout.take().expect("piped stdout")).lines(); - let mut result_error: Option = None; - loop { - let line = tokio::select! { - () = token.cancelled() => return Err("Stopped.".to_owned()), - line = lines.next_line() => line.map_err(|e| format!("reading claude output: {e}"))?, - }; - let Some(line) = line else { return Ok(result_error) }; - let Ok(event) = serde_json::from_str::(&line) else { continue }; - if event["type"] == "system" && event["subtype"] == "init" { - let _ = std::fs::write(started, b""); - } - if event["type"] == "result" && event["is_error"] == true { - result_error = Some(event["result"].as_str().unwrap_or("The agent stopped with an error.").to_owned()); - } - if keep(&event) { - transcript.record(app, project, event)?; - } - } -} - -struct RemoveOnDrop(PathBuf); - -impl Drop for RemoveOnDrop { - fn drop(&mut self) { - let _ = std::fs::remove_file(&self.0); - } -} - -/// Stops the project's running turn. -#[tauri::command] -pub fn studio_cancel(turns: State<'_, Turns>, project: String) -> Result<(), String> { - if let Some(turn) = turns.0.lock().map_err(|e| e.to_string())?.get(&project) { - turn.token.cancel(); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[cfg(unix)] - #[test] - fn a_login_shell_that_hangs_is_ended_at_its_deadline() { - let dir = tempfile::tempdir().unwrap(); - let shell = dir.path().join("slow-shell"); - std::fs::write(&shell, "#!/bin/sh\nsleep 30\n").unwrap(); - let mut perms = std::fs::metadata(&shell).unwrap().permissions(); - std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755); - std::fs::set_permissions(&shell, perms).unwrap(); - let started = std::time::Instant::now(); - assert_eq!(shell_path(shell.to_str().unwrap(), std::time::Duration::from_millis(300)), None); - assert!(started.elapsed() < std::time::Duration::from_secs(5), "took {:?}", started.elapsed()); - - // Startup files that send stdout elsewhere and then hang end at the deadline too. - std::fs::write(&shell, "#!/bin/sh\nexec >/dev/null\nsleep 30\n").unwrap(); - let started = std::time::Instant::now(); - assert_eq!(shell_path(shell.to_str().unwrap(), std::time::Duration::from_millis(300)), None); - assert!(started.elapsed() < std::time::Duration::from_secs(5), "took {:?}", started.elapsed()); - assert!(shell_path("/bin/sh", std::time::Duration::from_secs(5)).is_some_and(|path| !path.is_empty())); - } - - const SETTLE: std::time::Duration = std::time::Duration::from_millis(50); - - /// The pass `studio_send` takes holds an update off until the turn drops it, - /// and while the update installs a new turn is refused before it writes anything. - #[tokio::test] - async fn an_update_waits_for_a_turns_pass_and_a_turn_is_refused_while_it_installs() { - let state = Arc::new(AppState::default()); - let pass = take_pass(state.clone()).expect("a turn starts"); - let mut close = std::pin::pin!(state.builds.close_when_idle(|_| {})); - assert!(tokio::time::timeout(SETTLE, &mut close).await.is_err(), "the update waits while the turn holds its pass"); - drop(pass); - let closed = tokio::time::timeout(SETTLE, &mut close).await.expect("the update goes ahead once the turn is done"); - let refused = take_pass(state.clone()).err().expect("a turn is refused while the update installs"); - assert!(refused.contains("update is installing")); - drop(closed); - assert!(take_pass(state.clone()).is_ok(), "a failed install lets turns start again"); - } - - #[test] - fn a_reopened_transcript_numbers_lines_on_and_knows_an_unfinished_turn() { - let dir = tempfile::tempdir().expect("tempdir"); - std::fs::create_dir_all(dir.path().join(META_DIR)).expect("meta dir"); - let mut first = Transcript::open(dir.path()).expect("open"); - assert_eq!(first.write(&json!({ "type": "m3d_user", "text": "a clip" })).expect("write"), 0); - assert_eq!(first.write(&json!({ "type": "assistant" })).expect("write"), 1); - - let mut reopened = Transcript::open(dir.path()).expect("reopen"); - assert!(has_open_turn(&read_transcript(dir.path(), false).expect("read"))); - assert_eq!(reopened.write(&json!({ "type": "m3d_turn_end", "ok": true })).expect("write"), 2); - assert!(!has_open_turn(&read_transcript(dir.path(), false).expect("read"))); - } - - #[test] - fn a_write_cut_off_by_a_crash_is_dropped_and_the_next_line_numbers_on() { - let dir = tempfile::tempdir().expect("tempdir"); - std::fs::create_dir_all(dir.path().join(META_DIR)).expect("meta dir"); - std::fs::write(transcript_path(dir.path()), "{\"type\":\"m3d_user\"}\n{\"type\":\"assi").expect("write"); - assert_eq!(read_transcript(dir.path(), false).expect("read").len(), 1); - assert!(std::fs::read_to_string(transcript_path(dir.path())).expect("raw").ends_with("assi"), "a reader leaves a write in progress alone"); - let mut transcript = Transcript::open(dir.path()).expect("open"); - assert_eq!(transcript.write(&json!({ "type": "m3d_turn_end", "ok": false })).expect("write"), 1); - assert_eq!(read_transcript(dir.path(), false).expect("read").len(), 2); - } - - /// The Stop path, with no tag: the agent starts `sleep` in a group of its - /// own, as Claude Code does for each tool. `sleep` is a system program, - /// so on macOS only the walk of the live process tree can find it. - #[cfg(unix)] - #[test] - fn a_shell_tool_in_its_own_group_is_ended_through_the_live_tree() { - use std::os::unix::process::CommandExt as _; - // perl, not `sh -c 'set -m'`: dash ignores job control without a terminal, so it never moves the tool. - let mut agent = std::process::Command::new("perl") - .args(["-e", r#"if (fork == 0) { setpgrp(0, 0); exec "sleep", "30" } wait"#]) - .process_group(0) - .spawn() - .expect("perl"); - let leader = agent.id(); - let tool = (0..50) - .find_map(|_| { - let found = processes().into_iter().find(|p| p.parent == leader as i32 && p.group == p.pid); // forked first, then moved to its own group - if found.is_none() { - std::thread::sleep(std::time::Duration::from_millis(100)); - } - found - }) - .expect("the agent started its tool"); - assert_ne!(tool.group, leader as i32, "the tool runs in a group of its own"); - end_turn(&[leader], &[], &BTreeSet::new()); - let _ = agent.wait(); - // SAFETY: signal 0 only asks whether the process exists. - let gone = (0..30).any(|_| unsafe { libc::kill(tool.pid, 0) } != 0 || { - std::thread::sleep(std::time::Duration::from_millis(100)); - false - }); - assert!(gone, "the tool outside the agent's group was ended"); - } - - #[cfg(unix)] - #[test] - fn after_a_crash_a_tagged_tool_is_ended_and_an_untagged_one_is_not() { - use std::os::unix::process::CommandExt as _; - let tag = uuid::Uuid::new_v4().to_string(); - let spawn = |tagged: bool| { - // python3, not sleep: macOS hides the environment of its own system programs such as sleep. - let mut command = std::process::Command::new("python3"); - command.args(["-c", "import time; time.sleep(30)"]).process_group(0); - if tagged { - command.env(TAG_VAR, &tag); - } - command.spawn().expect("sleep") - }; - let (mut tool, mut other) = (spawn(true), spawn(false)); - // `ps` shows a new process's environment only once it has started, which can lag the spawn. - let seen = (0..50).any(|_| !tagged_groups(std::slice::from_ref(&tag)).is_empty() || { - std::thread::sleep(std::time::Duration::from_millis(100)); - false - }); - assert!(seen, "ps never showed the tagged process"); - end_turn(&[], &[tag.clone()], &BTreeSet::new()); - assert!(tool.wait().expect("wait").code().is_none(), "the tagged tool was killed by a signal"); - assert!(other.try_wait().expect("try_wait").is_none(), "the untagged process still runs"); - other.kill().expect("kill"); - let _ = other.wait(); - } - - #[test] - fn a_write_cut_inside_a_multibyte_character_is_dropped_too() { - let dir = tempfile::tempdir().expect("tempdir"); - std::fs::create_dir_all(dir.path().join(META_DIR)).expect("meta dir"); - let mut bytes = b"{\"type\":\"m3d_user\"}\n{\"text\":\"".to_vec(); - bytes.extend_from_slice(&"\u{8bbe}".as_bytes()[..2]); - std::fs::write(transcript_path(dir.path()), bytes).expect("write"); - assert_eq!(read_transcript(dir.path(), false).expect("read").len(), 1); - assert_eq!(Transcript::open(dir.path()).expect("open").next, 1); - } -} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index b081d688..b845b371 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -44,8 +44,7 @@ "resources": { "../LICENSE": "LICENSE", "../THIRD_PARTY_NOTICES.md": "THIRD_PARTY_NOTICES.md", - "resources/bambu/AGPL-3.0.txt": "AGPL-3.0.txt", - "resources/studio-plugin/": "studio-plugin/" + "resources/bambu/AGPL-3.0.txt": "AGPL-3.0.txt" }, "macOS": { "minimumSystemVersion": "26.0", diff --git a/src/studio/Chat.tsx b/src/studio/Chat.tsx index c0c4b9db..4dfcd001 100644 --- a/src/studio/Chat.tsx +++ b/src/studio/Chat.tsx @@ -5,13 +5,21 @@ import { FileActions } from './RightPanel' import type { ChatItem } from './transcript' /** A picture waiting to be sent: a dropped file on disk, or pasted bytes. */ -export type Attachment = - | { kind: 'path'; name: string; path: string } - | { kind: 'bytes'; name: string; bytes: Uint8Array; previewUrl: string } +/** + * A picture waiting to go with the next message: a file on disk, so a draft can keep it. A pasted picture is first kept + * in a file of the sidecar's, and shows its preview while the window has it. + */ +export type Attachment = { name: string; path: string; previewUrl?: string } + +/** The picture a draft keeps at `path`. */ +export const savedAttachment = (path: string): Attachment => ({ name: path.split(/[\\/]/).pop() ?? path, path }) + +/** Frees a pasted picture's preview once it is sent, removed, or left. */ +/** A picture pasted into the composer, before it is kept in a file. */ +export type PastedPicture = { name: string; file: File } -/** Frees a pasted picture's preview once it is sent or removed. */ export function releaseAttachment(attachment: Attachment): void { - if (attachment.kind === 'bytes') URL.revokeObjectURL(attachment.previewUrl) + if (attachment.previewUrl !== undefined) URL.revokeObjectURL(attachment.previewUrl) } function inline(text: string): ReactNode[] { @@ -91,8 +99,6 @@ export function ChatView({ project, items, running, onPreview, onError }: ChatVi ))} ) - case 'error': - return

{item.text}

} })} {running &&

Working…

} @@ -106,6 +112,8 @@ interface ComposerProps { onText: (text: string) => void attachments: Attachment[] onAttach: (files: Attachment[]) => void + /** Pictures pasted into the composer, with their previews, for the screen they were pasted on to keep. */ + onPaste: (pictures: PastedPicture[]) => void onRemove: (index: number) => void /** Sends the text and pictures. The owner clears them only once the send succeeds. */ onSend: () => void @@ -117,28 +125,25 @@ interface ComposerProps { autoFocus?: boolean } -export function Composer({ text, onText, attachments, onAttach, onRemove, onSend, onStop, busy, running, placeholder, autoFocus }: ComposerProps) { +export function Composer({ text, onText, attachments, onAttach, onPaste, onRemove, onSend, onStop, busy, running, placeholder, autoFocus }: ComposerProps) { const empty = !text.trim() && attachments.length === 0 - const paste = async (event: React.ClipboardEvent) => { + const paste = (event: React.ClipboardEvent) => { const pictures = [...event.clipboardData.files].filter((f) => f.type.startsWith('image/')) if (pictures.length === 0) return event.preventDefault() - onAttach( - await Promise.all( - pictures.map(async (file, i) => ({ - kind: 'bytes' as const, - name: file.name && file.name !== 'image.png' ? file.name : `pasted-${Date.now()}-${i}.${file.type.split('/')[1] ?? 'png'}`, - bytes: new Uint8Array(await file.arrayBuffer()), - previewUrl: URL.createObjectURL(file), - })), - ), + // Handed over at once, before any wait, so the pictures stay with the screen they were pasted on. + onPaste( + pictures.map((file, i) => ({ + name: file.name && file.name !== 'image.png' ? file.name : `pasted-${Date.now()}-${i}.${file.type.split('/')[1] ?? 'png'}`, + file, + })), ) } const pick = async () => { const path = await pickFile({ filters: [{ name: 'Pictures', extensions: ['png', 'jpg', 'jpeg', 'heic', 'webp', 'gif'] }] }) - if (path) onAttach([{ kind: 'path', name: path.split(/[\\/]/).pop() ?? path, path }]) + if (path) onAttach([savedAttachment(path)]) } return ( @@ -147,7 +152,7 @@ export function Composer({ text, onText, attachments, onAttach, onRemove, onSend
    {attachments.map((a, i) => (
  • - {a.kind === 'bytes' ? {a.name} : {a.name}} + {a.previewUrl !== undefined ? {a.name} : {a.name}}
  • ))} diff --git a/src/studio/StudioApp.test.tsx b/src/studio/StudioApp.test.tsx index 9cab6440..a5b3517c 100644 --- a/src/studio/StudioApp.test.tsx +++ b/src/studio/StudioApp.test.tsx @@ -1,123 +1,1310 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' -import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' -const { invokeMock, listeners } = vi.hoisted(() => ({ invokeMock: vi.fn(), listeners: [] as ((event: { payload: unknown }) => void)[] })) +type Listener = (event: { payload: unknown }) => void +const { invokeMock, listeners } = vi.hoisted(() => ({ + invokeMock: vi.fn(), + listeners: { sidecar: [] as Listener[], drop: [] as Listener[], flush: [] as Listener[] }, +})) vi.mock('@tauri-apps/api/core', () => ({ invoke: invokeMock })) vi.mock('@tauri-apps/api/event', () => ({ - listen: (_name: string, handler: (event: { payload: unknown }) => void) => { - listeners.push(handler) + listen: (name: string, listener: Listener) => { + ;(name === 'studio://flush' ? listeners.flush : listeners.sidecar).push(listener) return Promise.resolve(() => {}) }, })) +vi.mock('@tauri-apps/plugin-opener', () => ({ openUrl: () => Promise.resolve() })) vi.mock('@tauri-apps/api/webview', () => ({ - getCurrentWebview: () => ({ onDragDropEvent: vi.fn().mockResolvedValue(() => {}) }), + getCurrentWebview: () => ({ + onDragDropEvent: (listener: Listener) => { + listeners.drop.push(listener) + return Promise.resolve(() => {}) + }, + }), })) +import type { Project } from '../../sidecar/src/protocol' import StudioApp from './StudioApp' -// jsdom has no layout, so the chat's scroll to its newest line is a no-op here. +// jsdom has no layout, so the chat's scroll to its newest line is a no-op here, and no object URLs for previews. Element.prototype.scrollIntoView = vi.fn() +URL.createObjectURL = vi.fn(() => 'blob:preview') +URL.revokeObjectURL = vi.fn() -const projectB = { id: '2026-11-06-b', title: 'Project B', createdMs: 1, path: '/p/b' } +const projectA = { id: '2026-11-06-a', conversation: 2, title: 'Project A', createdMs: 2 } +const projectB = { id: '2026-11-06-b', conversation: 3, title: 'Project B', createdMs: 1 } +const view = (project: string, more: object = {}) => ({ project, entries: [], running: false, waiting: null, turn: null, seq: 1, ...more }) +const settings = { model: { provider: 'anthropic', modelId: 'claude-opus-5-5' }, thinking: 'high', printer: {} } afterEach(() => { cleanup() invokeMock.mockReset() - listeners.length = 0 + listeners.sidecar.length = 0 + listeners.drop.length = 0 + listeners.flush.length = 0 }) -function mockStudio(overrides: Record Promise>) { - invokeMock.mockImplementation((command: string) => { - if (command in overrides) return overrides[command]() - switch (command) { - case 'studio_projects': - return Promise.resolve([projectB]) - case 'studio_transcript': - case 'studio_files': - return Promise.resolve([]) - case 'update_check': - return Promise.resolve({ status: 'upToDate' }) - case 'sidecar': +/** An event from the sidecar, as Rust forwards it to the window. */ +const emit = (event: object) => act(() => listeners.sidecar.forEach((listener) => listener({ payload: event }))) + +const prompt = () => screen.getByTestId('prompt') as HTMLTextAreaElement +const type = (text: string) => fireEvent.change(prompt(), { target: { value: text } }) +const drop = (paths: string[]) => act(async () => listeners.drop.forEach((listener) => listener({ payload: { type: 'drop', paths } }))) +const pause = (ms: number) => act(() => new Promise((resolve) => setTimeout(resolve, ms))) + +type Params = Record +/** A call's own answer from the mocked sidecar, which an override may hold, fail, or pass on. */ +type Calls = Record Promise) => Promise> +type StoredDraft = { text: string; attachments: string[]; rev: number } + +/** The revisions the mocked sidecar gives saved drafts, growing as the real one's do. */ +let draftRevs = 0 + +const CHANGED = 'The draft changed before it went out. Send it again.' +const LOST = 'The engine restarted before it answered. Try again.' + +/** Holds each call until `release`, then answers as the mocked sidecar would. */ +function held() { + let release = () => {} + return { + call: (_: Params, real: () => Promise) => new Promise((resolve) => (release = () => resolve(real()))), + release: () => act(async () => release()), + } +} + +/** A send whose first answer is lost after the sidecar sent it, as when the engine restarts. Later sends answer. */ +function lostOnce() { + let sends = 0 + return (_: Params, real: () => Promise) => (sends++ === 0 ? real().then(() => Promise.reject(LOST)) : real()) +} + +/** + * The sidecar as the window sees it: one `sidecar` command, by method. It keeps the drafts the window saves, and sends + * one by its revision as the real sidecar does: a start screen send makes the project for that revision, or finds the + * one it made, and moves the start draft there only when it is that revision. A send already made changes nothing. A + * send removes the draft it sent. It answers with what it keeps, for a test to read. + */ +function mockSidecar(overrides: Calls = {}, projects: Project[] = [projectB], drafts: Record = {}) { + const store = { + projects: [...projects], + drafts: Object.fromEntries( + Object.entries(drafts).map(([key, draft]) => [key, { text: draft.text, attachments: draft.attachments ?? [], rev: draft.rev ?? ++draftRevs }]), + ) as Record, + madeFor: new Map(), + /** Each message that went out. */ + sent: [] as { project: string; rev: number; text: string; attachments: string[] }[], + } + const send = (key: string, rev: number): Promise => { + let project = key === 'new' ? store.madeFor.get(rev) : store.projects.find((p) => p.id === key) + if (project === undefined) { + const start = store.drafts.new + if (key !== 'new' || start?.rev !== rev) return Promise.reject(CHANGED) + const title = start.text.split('\n')[0]?.trim() || 'New part' + project = { id: `2026-11-06-${title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`, conversation: 10 + store.projects.length, title, createdMs: 10 + store.projects.length } + store.projects.push(project) + store.madeFor.set(rev, project) + store.drafts[project.id] = start + delete store.drafts.new + } + const id = project.id + if (store.sent.some((message) => message.project === id && message.rev === rev)) return Promise.resolve({ project }) + const draft = store.drafts[id] + if (draft?.rev !== rev) return Promise.reject(CHANGED) + store.sent.push({ project: id, rev, text: draft.text, attachments: draft.attachments }) + delete store.drafts[id] + return Promise.resolve({ project }) + } + const defaults = (method: string, params: Params): Promise => { + switch (method) { + case 'app.ping': return Promise.resolve({ version: '1.2.3' }) + case 'state.get': + return Promise.resolve(JSON.parse(JSON.stringify({ setup: { step: 'ready', done: true }, settings, projects: store.projects, drafts: store.drafts, importBlocked: null }))) + case 'chat.open': + return Promise.resolve(view(String(params.project))) + case 'setup.check': + return Promise.resolve([]) + case 'drafts.set': { + const rev = ++draftRevs + const draft = { text: String(params.text), attachments: params.attachments as string[], rev } + if (draft.text === '' && draft.attachments.length === 0) delete store.drafts[String(params.key)] + else store.drafts[String(params.key)] = draft + return Promise.resolve({ rev }) + } + case 'chat.send': + return send(String(params.key), Number(params.rev)) default: - return Promise.resolve(undefined) + return Promise.resolve(null) } + } + invokeMock.mockImplementation((command: string, args: { method: string; params: Params }) => { + if (command === 'update_check') return Promise.resolve({ status: 'upToDate' }) + if (command === 'studio_files') return Promise.resolve([]) + if (command !== 'sidecar') return Promise.resolve(undefined) + const real = () => defaults(args.method, args.params) + const override = overrides[args.method] + return override ? override(args.params, real) : real() }) + return store } +const sidecarCalls = (method: string) => + invokeMock.mock.calls.filter(([command, args]) => command === 'sidecar' && args.method === method).map(([, args]) => args.params) +const sends = () => sidecarCalls('chat.send') +const saves = (key?: string) => sidecarCalls('drafts.set').filter((save) => key === undefined || save.key === key) +const last = (items: T[]) => items[items.length - 1] + describe('StudioApp', () => { it('shows the engine version the sidecar answers with', async () => { - mockStudio({}) + mockSidecar() render() expect((await screen.findByText('Engine 1.2.3')).dataset.testid).toBe('engine') - expect(invokeMock).toHaveBeenCalledWith('sidecar', { method: 'app.ping', params: {} }) }) it('keeps an engine stop that arrives while its version is still on the way', async () => { let answerPing: (value: unknown) => void = () => {} - mockStudio({ sidecar: () => new Promise((resolve) => (answerPing = resolve)) }) + mockSidecar({ 'app.ping': () => new Promise((resolve) => (answerPing = resolve)) }) render() - await waitFor(() => expect(invokeMock).toHaveBeenCalledWith('sidecar', { method: 'app.ping', params: {} })) - await act(async () => listeners.forEach((listener) => listener({ payload: { type: 'engine', state: 'stopped', detail: 'The engine stopped.' } }))) + await waitFor(() => expect(sidecarCalls('app.ping')).toHaveLength(1)) + await emit({ type: 'engine', state: 'stopped', detail: 'The engine stopped.' }) await act(async () => answerPing({ version: '1.2.3' })) expect(screen.getByTestId('engine').textContent).toBe('The engine stopped.') }) it('leaves the project the person moved to alone when a slow send from the start screen finishes', async () => { - let finishCreate: (project: unknown) => void = () => {} - invokeMock.mockImplementation((command: string) => { - switch (command) { - case 'studio_projects': - return Promise.resolve([projectB]) - case 'studio_create_project': - return new Promise((resolve) => (finishCreate = resolve)) - case 'studio_transcript': - case 'studio_files': - return Promise.resolve([]) - case 'update_check': - return Promise.resolve({ status: 'upToDate' }) - default: - return Promise.resolve(undefined) - } - }) + const send = held() + mockSidecar({ 'chat.send': send.call }) render() await screen.findByText('Project B') - fireEvent.change(screen.getByTestId('prompt'), { target: { value: 'A clip for my desk' } }) + type('A clip for my desk') fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sends()).toEqual([{ key: 'new', rev: expect.any(Number) }])) fireEvent.click(screen.getByText('Project B')) await screen.findByRole('banner') - fireEvent.change(screen.getByTestId('prompt'), { target: { value: 'draft for B' } }) - - await act(async () => finishCreate({ id: '2026-11-06-a-clip', title: 'A clip for my desk', createdMs: 2, path: '/p/a' })) - await waitFor(() => expect(invokeMock).toHaveBeenCalledWith('studio_send', expect.objectContaining({ project: '2026-11-06-a-clip' }))) + type('draft for B') + await send.release() + const sidebar = within(screen.getByRole('navigation', { name: 'Projects' })) + await waitFor(() => expect(sidebar.getByText('A clip for my desk')).toBeTruthy()) expect(screen.getByRole('banner').textContent).toBe('Project B') - expect((screen.getByTestId('prompt') as HTMLTextAreaElement).value).toBe('draft for B') + expect(prompt().value).toBe('draft for B') }) - it("keeps a slow send's failure with the project it was sent to", async () => { + it("keeps a slow send's failure with the project it was sent to, and sending again names the same revision", async () => { let failSend: (reason: string) => void = () => {} - const projectA = { id: '2026-11-06-a', title: 'Project A', createdMs: 2, path: '/p/a' } - mockStudio({ - studio_projects: () => Promise.resolve([projectA, projectB]), - studio_send: () => new Promise((_, reject) => (failSend = reject)), - }) + let tries = 0 + mockSidecar({ 'chat.send': (_, real) => (++tries === 1 ? new Promise((_, reject) => (failSend = reject)) : real()) }, [projectA, projectB]) render() fireEvent.click(await screen.findByText('Project A')) await screen.findByRole('banner') - fireEvent.change(screen.getByTestId('prompt'), { target: { value: 'A clip for my desk' } }) + type('A clip for my desk') fireEvent.click(screen.getByTestId('send')) - await waitFor(() => expect(invokeMock).toHaveBeenCalledWith('studio_send', expect.objectContaining({ project: projectA.id }))) + await waitFor(() => expect(sends()).toHaveLength(1)) fireEvent.click(screen.getByText('Project B')) await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project B')) - await act(async () => failSend('The agent could not start.')) + await act(async () => failSend(LOST)) expect(screen.queryByRole('alert')).toBeNull() fireEvent.click(screen.getByText('Project A')) - expect((await screen.findByRole('alert')).textContent).toContain('The agent could not start.') + expect((await screen.findByRole('alert')).textContent).toContain('The engine restarted before it answered.') + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sends()).toHaveLength(2)) + const [first, second] = sends() + expect(second).toEqual(first) + expect(saves()).toHaveLength(1) + // The send went through this time, so its failure is no longer shown. + await waitFor(() => expect(screen.queryByRole('alert')).toBeNull()) + }) + + it('keeps a project made while the saved state was still on its way', async () => { + let finishState: (state: unknown) => void = () => {} + mockSidecar({ 'state.get': () => new Promise((resolve) => (finishState = resolve)) }) + render() + type('A clip for my desk') + await waitFor(() => expect((screen.getByTestId('send') as HTMLButtonElement).disabled).toBe(false)) + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('A clip for my desk')) + await act(async () => finishState({ setup: { step: 'ready', done: true }, settings, projects: [projectB], drafts: {} })) + const sidebar = within(screen.getByRole('navigation', { name: 'Projects' })) + expect(sidebar.getByText('A clip for my desk')).toBeTruthy() + expect(sidebar.getByText('Project B')).toBeTruthy() + }) + + it('shows what a waiting turn needs, and a proxy sign-in goes to the sidecar', async () => { + const waiting = { kind: 'login', provider: 'anthropic', name: 'Anthropic', fix: 'Sign in to Anthropic. The message picks up where it stopped.' } + mockSidecar({ 'chat.open': (params) => Promise.resolve(view(String(params.project), { waiting })) }) + render() + fireEvent.click(await screen.findByText('Project B')) + expect((await screen.findByTestId('waiting')).textContent).toContain('Sign in to Anthropic. The message picks up where it stopped.') + fireEvent.change(screen.getByLabelText('API key or token'), { target: { value: 'proxy-token' } }) + fireEvent.change(screen.getByLabelText('Proxy URL'), { target: { value: 'http://127.0.0.1:8317' } }) + fireEvent.click(screen.getByText('Save')) + await waitFor(() => expect(sidecarCalls('auth.setKey')).toEqual([{ provider: 'anthropic', key: 'proxy-token', baseUrl: 'http://127.0.0.1:8317' }])) + }) + + it('saves the draft once typing pauses, and fills the composer with it when the project opens', async () => { + mockSidecar({}, [projectB], { [projectB.id]: { text: 'make it red' } }) + render() + fireEvent.click(await screen.findByText('Project B')) + await waitFor(() => expect(prompt().value).toBe('make it red')) + type('make it blue') + await waitFor(() => expect(saves()).toEqual([{ key: projectB.id, text: 'make it blue', attachments: [] }])) + }) + + it('sends again after a lost answer under the same revision, with no new save', async () => { + const store = mockSidecar({ 'chat.send': lostOnce() }) + render() + fireEvent.click(await screen.findByText('Project B')) + await screen.findByRole('banner') + await drop(['/Users/me/photo.jpg']) + type('A clip like this') + fireEvent.click(screen.getByTestId('send')) + await screen.findByRole('alert') + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sends()).toHaveLength(2)) + const [first, second] = sends() + expect(second).toEqual(first) + expect(saves()).toEqual([{ key: projectB.id, text: 'A clip like this', attachments: ['/Users/me/photo.jpg'] }]) + expect(store.sent).toEqual([{ project: projectB.id, rev: first?.rev, text: 'A clip like this', attachments: ['/Users/me/photo.jpg'] }]) + }) + + it("keeps a draft's pictures, and brings them back when the project opens again", async () => { + mockSidecar() + const first = render() + fireEvent.click(await screen.findByText('Project B')) + await screen.findByRole('banner') + await drop(['/Users/me/photo.jpg']) + await waitFor(() => expect(last(saves())).toEqual({ key: projectB.id, text: '', attachments: ['/Users/me/photo.jpg'] })) + first.unmount() + + mockSidecar({}, [projectB], { [projectB.id]: { text: 'like this', attachments: ['/Users/me/photo.jpg'] } }) + render() + fireEvent.click(await screen.findByText('Project B')) + await screen.findByRole('banner') + expect(prompt().value).toBe('like this') + expect(screen.getByTestId('attachments').textContent).toContain('photo.jpg') + }) + + it('saves the text still waiting to be saved when the app quits, then says it is done', async () => { + mockSidecar() + render() + fireEvent.click(await screen.findByText('Project B')) + await screen.findByRole('banner') + type('half a thought') + await act(async () => listeners.flush.forEach((listener) => listener({ payload: null }))) + await waitFor(() => expect(invokeMock.mock.calls.some(([command]) => command === 'studio_flushed')).toBe(true)) + expect(saves()).toEqual([{ key: projectB.id, text: 'half a thought', attachments: [] }]) + }) + + it('keeps Send off until the agent can run, and names the fix', async () => { + let needs: unknown[] = [{ kind: 'uv', fix: 'Install uv, then check again.' }] + mockSidecar({ 'setup.check': () => Promise.resolve(needs) }) + render() + fireEvent.click(await screen.findByText('Project B')) + expect((await screen.findByTestId('readiness')).textContent).toContain('Install uv, then check again.') + type('A clip') + expect((screen.getByTestId('send') as HTMLButtonElement).disabled).toBe(true) + needs = [] + fireEvent.click(screen.getByText('Check again')) + await waitFor(() => expect(screen.queryByTestId('readiness')).toBeNull()) + expect((screen.getByTestId('send') as HTMLButtonElement).disabled).toBe(false) + }) + + it('asks for the sign-in a provider needs before a send, and checks again once it is done', async () => { + let needs: unknown[] = [{ kind: 'login', provider: 'anthropic', name: 'Anthropic', fix: 'Sign in to Anthropic before you send.' }] + mockSidecar({ 'setup.check': () => Promise.resolve(needs) }) + render() + fireEvent.click(await screen.findByText('Project B')) + const notice = await screen.findByTestId('readiness') + expect(notice.textContent).toContain('Sign in to Anthropic before you send.') + expect(screen.getByLabelText('API key or token')).toBeTruthy() + needs = [] + await emit({ type: 'login', provider: 'anthropic', step: 'done' }) + await waitFor(() => expect(screen.queryByTestId('readiness')).toBeNull()) + }) + + it('says when macOS keeps the old chats from the app, and imports them once the person allows it', async () => { + let blocked: string | null = '/Users/me/Documents/Materialize 3D' + mockSidecar({ + 'state.get': () => Promise.resolve({ setup: { step: 'ready', done: true }, settings, projects: blocked === null ? [projectA, projectB] : [projectB], drafts: {}, importBlocked: blocked }), + 'legacy.import': () => { + blocked = null + return Promise.resolve({ imported: 1, blocked: null }) + }, + }) + render() + expect((await screen.findByTestId('import-blocked')).textContent).toContain('Documents') + fireEvent.click(screen.getByText('Try again')) + await waitFor(() => expect(screen.queryByTestId('import-blocked')).toBeNull()) + expect(await screen.findByText('Project A')).toBeTruthy() + }) + + it('checks again when the engine is ready, so a check that failed during a restart does not keep Send off', async () => { + let checks = 0 + mockSidecar({ 'setup.check': () => (++checks === 1 ? Promise.reject('The engine restarted before it answered.') : Promise.resolve([])) }) + render() + fireEvent.click(await screen.findByText('Project B')) + await screen.findByRole('banner') + type('A clip') + expect((screen.getByTestId('send') as HTMLButtonElement).disabled).toBe(true) + await emit({ type: 'ready', version: '1.2.3' }) + await waitFor(() => expect((screen.getByTestId('send') as HTMLButtonElement).disabled).toBe(false)) + }) + + it('never brings a sent picture back in the next draft', async () => { + const send = held() + mockSidecar({ 'chat.send': send.call }, [projectA, projectB]) + render() + fireEvent.click(await screen.findByText('Project B')) + await screen.findByRole('banner') + await drop(['/Users/me/photo.jpg']) + type('Like this') + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sends()).toHaveLength(1)) + type('And thinner') + await send.release() + await pause(500) + expect(last(saves())).toEqual({ key: projectB.id, text: 'And thinner', attachments: [] }) + fireEvent.click(screen.getByText('Project A')) + fireEvent.click(screen.getByText('Project B')) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project B')) + expect(screen.queryByTestId('attachments')).toBeNull() + }) + + it('keeps a pasted picture on the screen it was pasted on, even when the person leaves before it is kept', async () => { + let finishPicture: (path: unknown) => void = () => {} + mockSidecar({ 'drafts.picture': () => new Promise((resolve) => (finishPicture = resolve)) }, [projectA, projectB]) + render() + fireEvent.click(await screen.findByText('Project A')) + await screen.findByRole('banner') + const file = new File(['png'], 'shot.png', { type: 'image/png' }) + fireEvent.paste(prompt(), { clipboardData: { files: [file] } }) + await waitFor(() => expect(sidecarCalls('drafts.picture')).toHaveLength(1)) + fireEvent.click(screen.getByText('Project B')) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project B')) + await act(async () => finishPicture('/state/pictures/1/shot.png')) + expect(screen.queryByTestId('attachments')).toBeNull() + // It lands in the draft of the screen it was pasted on, and is there when the person goes back. + await waitFor(() => expect(saves()).toContainEqual({ key: projectA.id, text: '', attachments: ['/state/pictures/1/shot.png'] })) + fireEvent.click(screen.getByText('Project A')) + await waitFor(() => expect(screen.getByLabelText('Remove shot.png')).toBeTruthy()) + }) + + it('keeps a paste that lands after the person left in that screen\'s draft, beside the text typed there', async () => { + let finishPicture: (path: unknown) => void = () => {} + mockSidecar({ 'drafts.picture': () => new Promise((resolve) => (finishPicture = resolve)) }, [projectA, projectB]) + render() + fireEvent.click(await screen.findByText('Project A')) + await screen.findByRole('banner') + type('like this') + fireEvent.paste(prompt(), { clipboardData: { files: [new File(['png'], 'shot.png', { type: 'image/png' })] } }) + await waitFor(() => expect(sidecarCalls('drafts.picture')).toHaveLength(1)) + fireEvent.click(screen.getByText('Project B')) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project B')) + await act(async () => finishPicture('/state/pictures/1/shot.png')) + await waitFor(() => expect(last(saves(projectA.id))).toEqual({ key: projectA.id, text: 'like this', attachments: ['/state/pictures/1/shot.png'] })) + fireEvent.click(screen.getByText('Project A')) + await waitFor(() => expect(screen.getByLabelText('Remove shot.png')).toBeTruthy()) + expect(prompt().value).toBe('like this') + }) + + it("brings the start screen's saved pictures back even when the person typed before the saved state arrived", async () => { + let finishState: (state: unknown) => void = () => {} + mockSidecar({ 'state.get': () => new Promise((resolve) => (finishState = resolve)) }) + render() + type('a hook') + await act(async () => + finishState({ setup: { step: 'ready', done: true }, settings, projects: [projectB], drafts: { new: { text: 'older', attachments: ['/Users/me/hook.jpg'], rev: 1 } }, importBlocked: null }), + ) + expect(screen.getByTestId('attachments').textContent).toContain('hook.jpg') + expect(prompt().value).toBe('a hook') + await waitFor(() => expect(last(saves())).toEqual({ key: 'new', text: 'a hook', attachments: ['/Users/me/hook.jpg'] })) + }) + + it("leaves another project's same picture alone when a send goes through", async () => { + const send = held() + mockSidecar({ 'chat.send': send.call }, [projectA, projectB]) + render() + fireEvent.click(await screen.findByText('Project A')) + await screen.findByRole('banner') + await drop(['/Users/me/photo.jpg']) + type('Like this') + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sends()).toHaveLength(1)) + fireEvent.click(screen.getByText('Project B')) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project B')) + await drop(['/Users/me/photo.jpg']) + await send.release() + await pause(500) + expect(last(saves(projectB.id))).toEqual({ key: projectB.id, text: '', attachments: ['/Users/me/photo.jpg'] }) + }) + + it('keeps a newer saved draft that reads the same as the sent text', async () => { + const send = held() + mockSidecar({ 'chat.send': send.call }, [projectA, projectB]) + render() + fireEvent.click(await screen.findByText('Project B')) + await screen.findByRole('banner') + type('Make it red') + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sends()).toHaveLength(1)) + type('Make it blue') + type('Make it red') + await drop(['/Users/me/new.jpg']) + await pause(500) + await send.release() + fireEvent.click(screen.getByText('Project A')) + fireEvent.click(screen.getByText('Project B')) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project B')) + expect(prompt().value).toBe('Make it red') + expect(screen.getByTestId('attachments').textContent).toContain('new.jpg') + }) + + it('keeps a paste on its screen even when the person leaves while its bytes are read', async () => { + mockSidecar({ 'drafts.picture': () => Promise.resolve('/state/pictures/1/shot.png') }, [projectA, projectB]) + render() + fireEvent.click(await screen.findByText('Project A')) + await screen.findByRole('banner') + let finishRead: (bytes: ArrayBuffer) => void = () => {} + const file = new File(['png'], 'shot.png', { type: 'image/png' }) + file.arrayBuffer = () => new Promise((resolve) => (finishRead = resolve)) + fireEvent.paste(prompt(), { clipboardData: { files: [file] } }) + fireEvent.click(screen.getByText('Project B')) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project B')) + await act(async () => finishRead(new ArrayBuffer(3))) + await pause(50) + expect(screen.queryByTestId('attachments')).toBeNull() + }) + + it("brings the start screen's saved pictures back when the person typed and paused before the saved state arrived", async () => { + let finishState: (state: unknown) => void = () => {} + mockSidecar({ 'state.get': () => new Promise((resolve) => (finishState = resolve)) }) + render() + type('a hook') + await pause(500) + await act(async () => + finishState({ setup: { step: 'ready', done: true }, settings, projects: [projectB], drafts: { new: { text: 'older', attachments: ['/Users/me/hook.jpg'], rev: 1 } }, importBlocked: null }), + ) + expect(screen.getByTestId('attachments').textContent).toContain('hook.jpg') + await waitFor(() => expect(last(saves())).toEqual({ key: 'new', text: 'a hook', attachments: ['/Users/me/hook.jpg'] })) + }) + + it('takes the sent pictures out of the composer when the person left and came back before the send went through', async () => { + const send = held() + mockSidecar({ 'chat.send': send.call }, [projectA, projectB]) + render() + fireEvent.click(await screen.findByText('Project A')) + await screen.findByRole('banner') + await drop(['/Users/me/photo.jpg']) + type('Like this') + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sends()).toHaveLength(1)) + fireEvent.click(screen.getByText('Project B')) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project B')) + fireEvent.click(screen.getByText('Project A')) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project A')) + await send.release() + expect(screen.queryByTestId('attachments')).toBeNull() + }) + + it('sends the revision the draft was saved as, and names nothing else', async () => { + mockSidecar() + render() + fireEvent.click(await screen.findByText('Project B')) + await screen.findByRole('banner') + type('Make it red') + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sends()).toHaveLength(1)) + expect(sends()).toEqual([{ key: projectB.id, rev: draftRevs }]) + }) + + it('keeps the composer and the saved draft the same when a picture was added during a send', async () => { + const send = held() + mockSidecar({ 'chat.send': send.call }, [projectA, projectB]) + render() + fireEvent.click(await screen.findByText('Project B')) + await screen.findByRole('banner') + type('Make it red') + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sends()).toHaveLength(1)) + await drop(['/Users/me/new.jpg']) + await pause(500) + await send.release() + const shown = { text: prompt().value, pictures: screen.getByTestId('attachments').textContent } + fireEvent.click(screen.getByText('Project A')) + fireEvent.click(screen.getByText('Project B')) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project B')) + expect({ text: prompt().value, pictures: screen.getByTestId('attachments').textContent }).toEqual(shown) + }) + + it('clears a picture-only first message from the start screen the person went back to', async () => { + const send = held() + mockSidecar({ 'chat.send': send.call }) + render() + await screen.findByText('Project B') + await drop(['/Users/me/photo.jpg']) + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sends()).toHaveLength(1)) + fireEvent.click(screen.getByText('Project B')) + fireEvent.click(screen.getByTestId('new-project')) + await send.release() + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('New part')) + fireEvent.click(screen.getByTestId('new-project')) + expect(screen.queryByTestId('attachments')).toBeNull() + }) + + // The cases of #205: each is a way a window that named its own messages sent one twice, took a new one for an old + // one, or lost or kept a draft by mistake. + + it('sends pictures restored by moving away and back as the same message: the same revision, with no new save', async () => { + const store = mockSidecar({ 'chat.send': lostOnce() }, [projectA, projectB], { + [projectA.id]: { text: 'A clip like this', attachments: ['/Users/me/pic.jpg'], rev: 7 }, + }) + render() + fireEvent.click(await screen.findByText('Project A')) + await waitFor(() => expect(prompt().value).toBe('A clip like this')) + fireEvent.click(screen.getByTestId('send')) + await screen.findByRole('alert') + fireEvent.click(screen.getByText('Project B')) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project B')) + fireEvent.click(screen.getByText('Project A')) + await waitFor(() => expect(prompt().value).toBe('A clip like this')) + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sends()).toHaveLength(2)) + expect(sends()).toEqual([ + { key: projectA.id, rev: 7 }, + { key: projectA.id, rev: 7 }, + ]) + expect(saves()).toEqual([]) + expect(store.sent).toHaveLength(1) + }) + + it('leaves no draft in the store when the person left the start screen before its first send finished', async () => { + const send = held() + const store = mockSidecar({ 'chat.send': send.call }) + render() + await screen.findByText('Project B') + type('A clip') + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sends()).toHaveLength(1)) + fireEvent.click(screen.getByText('Project B')) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project B')) + await send.release() + await pause(500) + expect(store.drafts).toEqual({}) + expect(saves('new')).toEqual([{ key: 'new', text: 'A clip', attachments: [] }]) + fireEvent.click(screen.getByTestId('new-project')) + expect(prompt().value).toBe('') + }) + + it('sends a picture taken out and put back as a new message, under a new revision', async () => { + mockSidecar({ 'chat.send': lostOnce() }, [projectA, projectB], { [projectA.id]: { text: 'A clip like this', attachments: ['/Users/me/pic.jpg'], rev: 7 } }) + render() + fireEvent.click(await screen.findByText('Project A')) + await waitFor(() => expect(prompt().value).toBe('A clip like this')) + fireEvent.click(screen.getByTestId('send')) + await screen.findByRole('alert') + fireEvent.click(screen.getByLabelText('Remove pic.jpg')) + await drop(['/Users/me/pic.jpg']) + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sends()).toHaveLength(2)) + expect(last(saves())).toEqual({ key: projectA.id, text: 'A clip like this', attachments: ['/Users/me/pic.jpg'] }) + expect(sends()[1]).toEqual({ key: projectA.id, rev: draftRevs }) + expect(draftRevs).not.toBe(7) + }) + + it('fails a send whose draft save fails, and sends nothing under the older revision', async () => { + let tries = 0 + mockSidecar( + { 'chat.send': lostOnce(), 'drafts.set': (_, real) => (++tries === 2 || tries === 3 ? Promise.reject('The engine is starting.') : real()) }, + [projectA, projectB], + ) + render() + fireEvent.click(await screen.findByText('Project A')) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project A')) + type('A clip for my desk') + fireEvent.click(screen.getByTestId('send')) + await screen.findByRole('alert') + type('A clip for my shelf') + // The pause after typing saves the change, and that save fails. So does the one the send makes. + await waitFor(() => expect(saves()).toHaveLength(2)) + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(saves()).toHaveLength(3)) + await pause(50) + expect(sends()).toHaveLength(1) + expect(screen.getByRole('alert').textContent).toContain('The engine is starting.') + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sends()).toHaveLength(2)) + expect(last(saves())).toEqual({ key: projectA.id, text: 'A clip for my shelf', attachments: [] }) + expect(sends()[1]).toEqual({ key: projectA.id, rev: draftRevs }) + }) + + it('keeps a start draft changed while its first send runs as a newer draft, exactly as it is, and never sends it', async () => { + const send = held() + const store = mockSidecar({ 'chat.send': send.call }) + render() + await screen.findByText('Project B') + await drop(['/Users/me/photo.jpg']) + type('A clip') + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sends()).toHaveLength(1)) + type('A hook') + fireEvent.click(screen.getByText('Project B')) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project B')) + expect(saves('new')).toEqual([{ key: 'new', text: 'A clip', attachments: ['/Users/me/photo.jpg'] }]) + await send.release() + await waitFor(() => expect(store.drafts.new).toMatchObject({ text: 'A hook', attachments: ['/Users/me/photo.jpg'] })) + expect(store.drafts.new?.rev).toBeGreaterThan(Number(sends()[0]?.rev)) + expect(sends()).toHaveLength(1) + expect(store.sent.map((message) => message.text)).toEqual(['A clip']) + expect(Object.keys(store.drafts)).toEqual(['new']) + fireEvent.click(screen.getByTestId('new-project')) + expect(prompt().value).toBe('A hook') + expect(screen.getByTestId('attachments').textContent).toContain('photo.jpg') + }) + + it('sends a picture-only first message again after a lost answer to the same project, once', async () => { + const store = mockSidecar({ 'chat.send': lostOnce() }) + render() + await screen.findByText('Project B') + await drop(['/Users/me/pic.jpg']) + fireEvent.click(screen.getByTestId('send')) + await screen.findByRole('alert') + fireEvent.click(screen.getByText('Project B')) + fireEvent.click(screen.getByTestId('new-project')) + await waitFor(() => expect(screen.getByTestId('attachments').textContent).toContain('pic.jpg')) + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sends()).toHaveLength(2)) + const [first, second] = sends() + expect(second).toEqual(first) + expect(saves()).toHaveLength(1) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('New part')) + expect(store.projects.filter((p) => p.title === 'New part')).toHaveLength(1) + expect(store.sent).toHaveLength(1) + }) + + it('never saves one screen twice at once when Send comes while an autosave is on its way, and sends an unchanged message once', async () => { + const save = held() + const store = mockSidecar({ 'drafts.set': save.call }, [projectA, projectB]) + render() + fireEvent.click(await screen.findByText('Project B')) + await screen.findByRole('banner') + type('Make it red') + await waitFor(() => expect(saves()).toHaveLength(1)) + fireEvent.click(screen.getByTestId('send')) + await pause(50) + expect(saves()).toHaveLength(1) + await save.release() + await waitFor(() => expect(sends()).toEqual([{ key: projectB.id, rev: draftRevs }])) + await pause(500) + expect(saves()).toHaveLength(1) + expect(store.sent).toHaveLength(1) + }) + + it('sends the message the person clicked Send on, even when they type more while an earlier autosave is still on its way', async () => { + let releaseFirst: () => void = () => {} + let calls = 0 + const store = mockSidecar( + { + 'drafts.set': (_, real) => (++calls === 1 ? new Promise((resolve) => (releaseFirst = () => resolve(real()))) : real()), + }, + [projectA, projectB], + ) + render() + fireEvent.click(await screen.findByText('Project B')) + await screen.findByRole('banner') + type('Make it red') + await waitFor(() => expect(saves()).toHaveLength(1)) + fireEvent.click(screen.getByTestId('send')) + type('Make it blue') + await act(async () => releaseFirst()) + await waitFor(() => expect(store.sent).toHaveLength(1)) + expect(store.sent[0]).toMatchObject({ text: 'Make it red' }) + // The text typed after the click is the next message: it stays in the composer, and in the store. + await pause(600) + expect(prompt().value).toBe('Make it blue') + expect(store.drafts[projectB.id]).toMatchObject({ text: 'Make it blue' }) + }) + + it('sends pictures restored from a late saved state under a new revision, never as a repeat of a sent one', async () => { + let finishState: (state: unknown) => void = () => {} + const store = mockSidecar({ 'state.get': () => new Promise((resolve) => (finishState = resolve)), 'chat.send': lostOnce() }) + render() + type('a hook') + await pause(500) + fireEvent.click(screen.getByTestId('send')) + await screen.findByRole('alert') + await act(async () => + finishState({ setup: { step: 'ready', done: true }, settings, projects: [projectB], drafts: { new: { text: 'older', attachments: ['/Users/me/hook.jpg'], rev: 1 } }, importBlocked: null }), + ) + expect(screen.getByTestId('attachments').textContent).toContain('hook.jpg') + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sends()).toHaveLength(2)) + const [first, second] = sends() + expect(second?.rev).not.toBe(first?.rev) + expect(last(saves('new'))).toEqual({ key: 'new', text: 'a hook', attachments: ['/Users/me/hook.jpg'] }) + expect(last(store.sent)).toMatchObject({ rev: second?.rev, attachments: ['/Users/me/hook.jpg'] }) + }) + + it('takes a picture-only first message to its project and leaves the start screen empty, after a relaunch too', async () => { + const store = mockSidecar() + const first = render() + await screen.findByText('Project B') + await drop(['/Users/me/photo.jpg']) + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('New part')) + expect(screen.queryByTestId('attachments')).toBeNull() + fireEvent.click(screen.getByTestId('new-project')) + expect(screen.queryByTestId('attachments')).toBeNull() + await pause(500) + expect(store.drafts).toEqual({}) + first.unmount() + + mockSidecar({}, store.projects, store.drafts) + render() + await screen.findByText('New part') + expect(screen.queryByTestId('attachments')).toBeNull() + expect(prompt().value).toBe('') + }) + + it('brings back an unsent start picture from a first saved state that arrives after a text send', async () => { + let answerState: () => void = () => {} + const store = mockSidecar( + { + 'state.get': (_, real) => { + const before = real() + return new Promise((resolve) => (answerState = () => resolve(before))) + }, + }, + [projectB], + { new: { text: '', attachments: ['/Users/me/a.jpg'] } }, + ) + render() + await screen.findByText('Engine 1.2.3') + type('A clip') + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(store.sent).toHaveLength(1)) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('A clip')) + await act(async () => answerState()) + fireEvent.click(screen.getByTestId('new-project')) + // a.jpg was saved on the start screen but never shown or sent, so it is still the start screen's. + await waitFor(() => expect(screen.getByTestId('attachments').textContent).toContain('a.jpg')) + }) + + it('never brings back a sent start picture from a saved state that was read before the send', async () => { + let reads = 0 + let answerLate: () => void = () => {} + const store = mockSidecar( + { + 'state.get': (_, real) => { + // macOS keeps the old chats from the app at first, so the window offers to import them again. + if (++reads === 1) return real().then((state) => ({ ...(state as object), importBlocked: '/Users/me/Documents/Materialize 3D' })) + if (reads !== 2) return real() + // The state the import retry reads now, answered only after the send went through. + const before = real() + return new Promise((resolve) => (answerLate = () => resolve(before))) + }, + 'legacy.import': () => Promise.resolve({ imported: 0, blocked: null }), + }, + [projectB], + { new: { text: '', attachments: ['/Users/me/pic.jpg'] } }, + ) + render() + await waitFor(() => expect(screen.getByTestId('attachments').textContent).toContain('pic.jpg')) + fireEvent.click(await screen.findByText('Try again')) + await waitFor(() => expect(reads).toBe(2)) + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(store.sent).toHaveLength(1)) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('New part')) + await act(async () => answerLate()) + await pause(500) + expect(store.drafts.new).toBeUndefined() + fireEvent.click(screen.getByTestId('new-project')) + expect(screen.queryByTestId('attachments')).toBeNull() + }) + + it('saves every screen before a quit goes on, retrying a save the engine restart refused once the engine is back', async () => { + let engineBack = false + let releaseB: () => void = () => {} + const store = mockSidecar( + { + 'drafts.set': (params, real) => { + if (params.key === projectA.id && !engineBack) return Promise.reject('The engine restarted before it answered. Try again.') + if (params.key === projectB.id) return new Promise((resolve) => (releaseB = () => resolve(real()))) + return real() + }, + }, + [projectA, projectB], + ) + render() + fireEvent.click(await screen.findByText('Project A')) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project A')) + type('a hook') + fireEvent.click(screen.getByText('Project B')) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project B')) + type('a shelf') + await act(async () => listeners.flush.forEach((listener) => listener({ payload: null }))) + await act(async () => releaseB()) + await pause(50) + const said = (command: string) => invokeMock.mock.calls.some(([name]) => name === command) + expect(said('studio_flushed')).toBe(false) + engineBack = true + await emit({ type: 'ready', version: '1.2.3' }) + await waitFor(() => expect(said('studio_flushed')).toBe(true)) + expect(store.drafts[projectA.id]).toMatchObject({ text: 'a hook' }) + expect(store.drafts[projectB.id]).toMatchObject({ text: 'a shelf' }) + }) + + it('saves text typed during a send that started while a quit waited to retry, before the quit goes on', async () => { + let engineBack = false + const sending = held() + const store = mockSidecar( + { + 'drafts.set': (_, real) => (engineBack ? real() : Promise.reject('The engine restarted before it answered. Try again.')), + 'chat.send': sending.call, + }, + [projectA, projectB], + ) + render() + fireEvent.click(await screen.findByText('Project A')) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project A')) + type('a hook') + await act(async () => listeners.flush.forEach((listener) => listener({ payload: null }))) + await pause(50) + // The engine is back before its ready event reaches the window. The person sends, then types the next message. + engineBack = true + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sends()).toHaveLength(1)) + type('a hook, in black') + await emit({ type: 'ready', version: '1.2.3' }) + await pause(50) + const said = (command: string) => invokeMock.mock.calls.some(([name]) => name === command) + expect(said('studio_flushed')).toBe(false) + await sending.release() + await waitFor(() => expect(said('studio_flushed')).toBe(true)) + expect(store.drafts[projectA.id]).toMatchObject({ text: 'a hook, in black' }) + }) + + it('saves text typed during a send clicked while the quit waited for a save already on its way', async () => { + let releaseSave: () => void = () => {} + let saves = 0 + const sending = held() + const store = mockSidecar( + { + 'drafts.set': (_, real) => (++saves === 1 ? new Promise((resolve) => (releaseSave = () => resolve(real()))) : real()), + 'chat.send': sending.call, + }, + [projectA, projectB], + ) + render() + fireEvent.click(await screen.findByText('Project A')) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project A')) + type('a hook') + // The pause after typing starts the save, and the quit comes while it is on its way. + await waitFor(() => expect(sidecarCalls('drafts.set')).toHaveLength(1)) + await act(async () => listeners.flush.forEach((listener) => listener({ payload: null }))) + fireEvent.click(screen.getByTestId('send')) + type('a hook, in black') + await act(async () => releaseSave()) + await pause(50) + const said = (command: string) => invokeMock.mock.calls.some(([name]) => name === command) + expect(said('studio_flushed')).toBe(false) + await sending.release() + await waitFor(() => expect(said('studio_flushed')).toBe(true)) + expect(store.drafts[projectA.id]).toMatchObject({ text: 'a hook, in black' }) + }) + + it('keeps the app open and says why when a draft still cannot be saved at a quit', async () => { + mockSidecar( + { 'drafts.set': (params, real) => (params.key === projectA.id ? Promise.reject('The engine stopped.') : real()) }, + [projectA, projectB], + ) + render() + fireEvent.click(await screen.findByText('Project A')) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project A')) + type('a hook') + await act(async () => listeners.flush.forEach((listener) => listener({ payload: null }))) + const said = (command: string) => invokeMock.mock.calls.some(([name]) => name === command) + await waitFor(() => expect(said('studio_flush_failed')).toBe(true), { timeout: 15_000 }) + expect(said('studio_flushed')).toBe(false) + expect(screen.getByRole('alert').textContent).toContain('could not be saved') + }, 20_000) + + it('waits for a pasted picture still being kept before it says a quit may go on, and saves it with the draft', async () => { + let keep: () => void = () => {} + const store = mockSidecar({ 'drafts.picture': () => new Promise((resolve) => (keep = () => resolve('/state/pictures/1/shot.png'))) }, [projectA, projectB]) + render() + fireEvent.click(await screen.findByText('Project A')) + await screen.findByRole('banner') + fireEvent.paste(prompt(), { clipboardData: { files: [new File(['png'], 'shot.png', { type: 'image/png' })] } }) + await waitFor(() => expect(sidecarCalls('drafts.picture')).toHaveLength(1)) + await act(async () => listeners.flush.forEach((listener) => listener({ payload: null }))) + await pause(50) + const flushed = () => invokeMock.mock.calls.some(([command]) => command === 'studio_flushed') + expect(flushed()).toBe(false) + await act(async () => keep()) + await waitFor(() => expect(flushed()).toBe(true)) + expect(store.drafts[projectA.id]).toMatchObject({ attachments: ['/state/pictures/1/shot.png'] }) + }) + + it('saves the draft a first send made for its project during a quit before it says the quit may go on', async () => { + const sending = held() + const store = mockSidecar({ 'chat.send': sending.call }) + render() + await screen.findByText('Project B') + type('A knob') + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sends()).toHaveLength(1)) + type('A knob, and a dial') + await act(async () => listeners.flush.forEach((listener) => listener({ payload: null }))) + await sending.release() + const flushed = () => invokeMock.mock.calls.some(([command]) => command === 'studio_flushed') + await waitFor(() => expect(flushed()).toBe(true)) + const project = store.projects.find((p) => p.title === 'A knob') + expect(project).toBeDefined() + expect(store.drafts[project?.id ?? '']).toMatchObject({ text: 'A knob, and a dial' }) + }) + + it('tells Rust at once that it is saving when a quit asks, so Rust holds the quit until the window is done', async () => { + const save = held() + mockSidecar({ 'drafts.set': save.call }) + render() + fireEvent.click(await screen.findByText('Project B')) + await screen.findByRole('banner') + type('half a thought') + await act(async () => listeners.flush.forEach((listener) => listener({ payload: null }))) + const said = (command: string) => invokeMock.mock.calls.some(([name]) => name === command) + expect(said('studio_flush_started')).toBe(true) + expect(said('studio_flushed')).toBe(false) + await save.release() + await waitFor(() => expect(said('studio_flushed')).toBe(true)) + }) + + it('reads the saved state again when the engine comes back, if the first read failed', async () => { + let reads = 0 + mockSidecar({ 'state.get': (_, real) => (++reads === 1 ? Promise.reject('The engine stopped.') : real()) }, [projectA, projectB]) + render() + await waitFor(() => expect(reads).toBe(1)) + expect(screen.queryByText('Project A')).toBeNull() + await emit({ type: 'ready', version: '1.2.3' }) + expect(await screen.findByText('Project A')).toBeTruthy() + }) + + it('keeps a picture the person took out and put back while its send was on its way', async () => { + const sending = held() + mockSidecar({ 'chat.send': sending.call }, [projectA, projectB], { [projectA.id]: { text: 'A clip like this', attachments: ['/Users/me/pic.jpg'] } }) + render() + fireEvent.click(await screen.findByText('Project A')) + await waitFor(() => expect(prompt().value).toBe('A clip like this')) + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sends()).toHaveLength(1)) + fireEvent.click(screen.getByLabelText('Remove pic.jpg')) + await act(async () => listeners.drop.forEach((listener) => listener({ payload: { type: 'drop', paths: ['/Users/me/pic.jpg'] } }))) + await sending.release() + await pause(100) + // The person chose it again, so it is part of the next message. + expect(screen.getByTestId('attachments').textContent).toContain('pic.jpg') + }) + + it('waits for a draft save already on its way before it says a quit may go on', async () => { + const save = held() + mockSidecar({ 'drafts.set': save.call }) + render() + fireEvent.click(await screen.findByText('Project B')) + await screen.findByRole('banner') + type('half a thought') + // The pause after typing starts the save, which the sidecar has not answered yet. + await waitFor(() => expect(saves()).toHaveLength(1)) + await act(async () => listeners.flush.forEach((listener) => listener({ payload: null }))) + await pause(50) + const flushed = () => invokeMock.mock.calls.some(([command]) => command === 'studio_flushed') + expect(flushed()).toBe(false) + await save.release() + await waitFor(() => expect(flushed()).toBe(true)) + }) + + it('keeps text typed while the draft before a send was still being saved, and saves it once the send is done', async () => { + let finishSave = () => {} + let tries = 0 + mockSidecar({ 'drafts.set': (_, real) => (++tries === 1 ? new Promise((resolve) => (finishSave = () => resolve(real()))) : real()) }) + render() + fireEvent.click(await screen.findByText('Project B')) + await screen.findByRole('banner') + type('Make it red') + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(saves()).toHaveLength(1)) + type('Make it blue') + await pause(500) + expect(saves()).toHaveLength(1) + await act(async () => finishSave()) + await waitFor(() => expect(sends()).toEqual([{ key: projectB.id, rev: expect.any(Number) }])) + await waitFor(() => expect(last(saves())).toEqual({ key: projectB.id, text: 'Make it blue', attachments: [] })) + expect(prompt().value).toBe('Make it blue') + }) + + it("keeps a picture added while a first message was on its way, as its project's draft", async () => { + const send = held() + mockSidecar({ 'chat.send': send.call }) + render() + await screen.findByText('Project B') + type('A clip') + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sends()).toHaveLength(1)) + await drop(['/Users/me/later.jpg']) + await send.release() + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('A clip')) + expect(screen.getByTestId('attachments').textContent).toContain('later.jpg') + await waitFor(() => expect(saves('2026-11-06-a-clip')).toEqual([{ key: '2026-11-06-a-clip', text: 'A clip', attachments: ['/Users/me/later.jpg'] }])) + }) + + it('keeps chat changes that arrive while the chat opens', async () => { + let finishOpen: (view: unknown) => void = () => {} + mockSidecar({ 'chat.open': () => new Promise((resolve) => (finishOpen = resolve)) }) + render() + fireEvent.click(await screen.findByText('Project B')) + await screen.findByRole('banner') + const reply = { id: 2, kind: 'assistant', text: 'The clip is ready.', calls: [] } + await emit({ type: 'chat', ...view(projectB.id, { entries: [reply], seq: 3 }) }) + const asked = { id: 1, kind: 'user', text: 'A clip', images: [] } + await act(async () => finishOpen(view(projectB.id, { entries: [asked], running: true, seq: 2 }))) + expect(screen.getByText('The clip is ready.')).toBeTruthy() + expect(screen.getByText('A clip')).toBeTruthy() + expect(screen.queryByTestId('working')).toBeNull() + }) + + it('reads the open chat again when the engine starts again, so a turn that ended before the crash shows as ended', async () => { + let opens = 0 + mockSidecar({ + 'chat.open': (params) => { + opens += 1 + return Promise.resolve(view(String(params.project), opens === 1 ? { running: true, seq: 1 } : { running: false, seq: 5 })) + }, + }) + render() + fireEvent.click(await screen.findByText('Project B')) + await screen.findByTestId('working') + await emit({ type: 'engine', state: 'restarting' }) + await emit({ type: 'ready', version: '1.2.3' }) + await waitFor(() => expect(screen.queryByTestId('working')).toBeNull()) + expect(sidecarCalls('chat.open')).toHaveLength(2) + }) + + it('keeps the newer chat when an older load answers after the reload an engine restart made', async () => { + const asked = { id: 1, kind: 'user', text: 'A clip', images: [] } + const reply = { id: 2, kind: 'assistant', text: 'The clip is ready.', calls: [] } + let finishFirst: (view: unknown) => void = () => {} + let opens = 0 + mockSidecar({ + 'chat.open': (params) => + ++opens === 1 + ? new Promise((resolve) => (finishFirst = resolve)) + : Promise.resolve(view(String(params.project), { entries: [asked, reply], running: false, seq: 5 })), + }) + render() + fireEvent.click(await screen.findByText('Project B')) + await screen.findByRole('banner') + await emit({ type: 'ready', version: '1.2.3' }) + await screen.findByText('The clip is ready.') + await act(async () => finishFirst(view(projectB.id, { entries: [asked], running: true, seq: 1 }))) + expect(screen.getByText('The clip is ready.')).toBeTruthy() + expect(screen.queryByTestId('working')).toBeNull() + }) + + it("clears a sent project's saved draft even when the person typed in another project meanwhile", async () => { + const send = held() + mockSidecar({ 'chat.send': send.call }, [projectA, projectB]) + render() + fireEvent.click(await screen.findByText('Project B')) + await screen.findByRole('banner') + type('Make it red') + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sends()).toHaveLength(1)) + fireEvent.click(screen.getByText('Project A')) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project A')) + type('something for A') + await send.release() + fireEvent.click(screen.getByText('Project B')) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project B')) + expect(prompt().value).toBe('') + }) + + it('keeps text typed during a send as the next message, even when it reads the same as the sent one', async () => { + const send = held() + mockSidecar({ 'chat.send': send.call }) + render() + fireEvent.click(await screen.findByText('Project B')) + await screen.findByRole('banner') + type('Make it red') + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sends()).toHaveLength(1)) + type('Make it blue') + type('Make it red') + await send.release() + expect(prompt().value).toBe('Make it red') + }) + + it('never shows a turn as working again when a change read before the open chat arrives after it', async () => { + let finishOpen: (view: unknown) => void = () => {} + mockSidecar({ 'chat.open': () => new Promise((resolve) => (finishOpen = resolve)) }) + render() + fireEvent.click(await screen.findByText('Project B')) + await screen.findByRole('banner') + const asked = { id: 1, kind: 'user', text: 'A clip', images: [] } + await emit({ type: 'chat', ...view(projectB.id, { entries: [asked], running: true, seq: 2 }) }) + const reply = { id: 2, kind: 'assistant', text: 'The clip is ready.', calls: [] } + await act(async () => finishOpen(view(projectB.id, { entries: [asked, reply], running: false, seq: 3 }))) + expect(screen.getByText('The clip is ready.')).toBeTruthy() + expect(screen.queryByTestId('working')).toBeNull() + }) + + it('saves a draft at once when the person moves to another project', async () => { + mockSidecar({}, [projectA, projectB]) + render() + fireEvent.click(await screen.findByText('Project B')) + await screen.findByRole('banner') + type('make it blue') + fireEvent.click(screen.getByText('Project A')) + expect(saves()).toEqual([{ key: projectB.id, text: 'make it blue', attachments: [] }]) + }) + + it('keeps text typed while a send goes through as the draft', async () => { + const send = held() + mockSidecar({ 'chat.send': send.call }, [projectA, projectB]) + render() + fireEvent.click(await screen.findByText('Project B')) + await screen.findByRole('banner') + type('make it red') + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sends()).toHaveLength(1)) + type('make it blue') + await send.release() + fireEvent.click(screen.getByText('Project A')) + fireEvent.click(screen.getByText('Project B')) + await waitFor(() => expect(prompt().value).toBe('make it blue')) + }) + + it('keeps what the person typed on the start screen when the saved state arrives', async () => { + let finishState: (state: unknown) => void = () => {} + mockSidecar({ 'state.get': () => new Promise((resolve) => (finishState = resolve)) }) + render() + type('A clip for my desk') + await act(async () => finishState({ setup: { step: 'ready', done: true }, settings, projects: [projectB], drafts: { new: { text: 'an older draft', attachments: [], rev: 1 } } })) + expect(prompt().value).toBe('A clip for my desk') + }) + + it('leaves the start screen empty after its message was sent', async () => { + mockSidecar() + render() + await screen.findByText('Project B') + type('A clip for my desk') + await waitFor(() => expect(saves()).toEqual([{ key: 'new', text: 'A clip for my desk', attachments: [] }])) + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sends()).toHaveLength(1)) + fireEvent.click(screen.getByTestId('new-project')) + expect(prompt().value).toBe('') + }) + + it("keeps text typed during a first send as its project's draft, and never saves it on the start screen", async () => { + const send = held() + mockSidecar({ 'chat.send': send.call }) + render() + await screen.findByText('Project B') + type('A clip for my desk') + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sends()).toHaveLength(1)) + type('second message') + await pause(500) + await send.release() + const made = '2026-11-06-a-clip-for-my-desk' + await waitFor(() => expect(saves(made)).toEqual([{ key: made, text: 'second message', attachments: [] }])) + expect(saves('new')).toEqual([{ key: 'new', text: 'A clip for my desk', attachments: [] }]) + fireEvent.click(screen.getByText('Project B')) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project B')) + fireEvent.click(screen.getByText('A clip for my desk')) + await waitFor(() => expect(prompt().value).toBe('second message')) + }) + + it('never puts a sent message back on the start screen when text typed during it was taken back', async () => { + const send = held() + mockSidecar({ 'chat.send': send.call }) + render() + await screen.findByText('Project B') + type('A clip for my desk') + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sends()).toHaveLength(1)) + type('A clip for my desk, and') + type('A clip for my desk') + await send.release() + await pause(600) + expect(saves('new')).toEqual([{ key: 'new', text: 'A clip for my desk', attachments: [] }]) + fireEvent.click(screen.getByTestId('new-project')) + expect(prompt().value).toBe('') + }) + + it('leaves the start screen empty when the person cleared it before the saved state arrived', async () => { + let finishState: (state: unknown) => void = () => {} + mockSidecar({ 'state.get': () => new Promise((resolve) => (finishState = resolve)) }) + render() + type('A clip for my desk') + type('') + await act(async () => finishState({ setup: { step: 'ready', done: true }, settings, projects: [projectB], drafts: { new: { text: 'an older draft', attachments: [], rev: 1 } } })) + expect(prompt().value).toBe('') + }) + + it("keeps each project's unsent message, so sending it again after another project's send names its revision", async () => { + let tries = 0 + mockSidecar({ 'chat.send': (_, real) => (++tries === 1 ? Promise.reject(LOST) : real()) }, [projectA, projectB]) + render() + fireEvent.click(await screen.findByText('Project A')) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project A')) + type('A clip for my desk') + fireEvent.click(screen.getByTestId('send')) + await screen.findByRole('alert') + fireEvent.click(screen.getByText('Project B')) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project B')) + type('make it blue') + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sends()).toHaveLength(2)) + fireEvent.click(screen.getByText('Project A')) + await waitFor(() => expect(prompt().value).toBe('A clip for my desk')) + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sends()).toHaveLength(3)) + const [first, , third] = sends() + expect(third).toEqual(first) + expect(saves(projectA.id)).toHaveLength(1) + }) + + it('shows the code a device sign-in asks for, with its page', async () => { + mockSidecar() + render() + await screen.findByText('Project B') + await emit({ type: 'login', provider: 'openai-codex', step: 'open', url: 'https://auth.openai.com/codex/device', code: 'ABCD-1234' }) + const ask = screen.getByTestId('sign-in') + expect(ask.textContent).toContain('ABCD-1234') + expect(ask.textContent).toContain('https://auth.openai.com/codex/device') + await emit({ type: 'login', provider: 'openai-codex', step: 'done' }) + expect(screen.queryByTestId('sign-in')).toBeNull() + }) + + it('answers what a sign-in asks in the window: a choice by its id, and typed text', async () => { + mockSidecar() + render() + await screen.findByText('Project B') + await emit({ type: 'login', provider: 'anthropic', step: 'ask', kind: 'select', message: 'Which account?', options: [{ id: 'max', label: 'Claude Max' }] }) + fireEvent.click(screen.getByText('Claude Max')) + await emit({ type: 'login', provider: 'anthropic', step: 'ask', kind: 'manual_code', message: 'Paste the code' }) + fireEvent.change(screen.getByLabelText('Paste the code'), { target: { value: 'code#state' } }) + fireEvent.click(screen.getByText('Answer')) + await waitFor(() => + expect(sidecarCalls('auth.answer')).toEqual([ + { provider: 'anthropic', text: 'max' }, + { provider: 'anthropic', text: 'code#state' }, + ]), + ) + expect(screen.queryByTestId('sign-in')).toBeNull() }) }) diff --git a/src/studio/StudioApp.tsx b/src/studio/StudioApp.tsx index 4961fc8c..00fd8893 100644 --- a/src/studio/StudioApp.tsx +++ b/src/studio/StudioApp.tsx @@ -1,9 +1,12 @@ import { getCurrentWebview } from '@tauri-apps/api/webview' +import { openUrl } from '@tauri-apps/plugin-opener' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { autoOpens, onSidecarEvent, sidecar, studio, type Project, type ProjectFile, type StudioEvent } from './api' -import { ChatView, Composer, releaseAttachment, type Attachment } from './Chat' +import { type AppState, type Draft, type LoginEvent, type Need, type Project, START, type ChatView as View } from '../../sidecar/src/protocol' +import { autoOpens, onQuitFlush, onSidecarEvent, sidecar, studio, type ProjectFile } from './api' +import { ChatView, Composer, releaseAttachment, savedAttachment, type PastedPicture } from './Chat' import RightPanel, { type Tab } from './RightPanel' import { buildChat } from './transcript' +import { ImportBlockedBanner, ReadinessBanner, SignInAsk, WaitingBanner } from './Waiting' import { UpdateBanner } from '../components/UpdateBanner' import { checkForUpdatesAtLaunch, useUpdateEvents } from '../stores/updates' import './studio.css' @@ -16,9 +19,54 @@ const SUGGESTIONS = [ const PICTURE = /\.(png|jpe?g|heic|heif|webp|gif)$/i -/** Adds a live event at its transcript line. One the loaded history already holds is a repeat. */ -function appendEvent(events: StudioEvent[], seq: number, event: StudioEvent): StudioEvent[] { - return seq < events.length ? events : [...events, event] +/** How long typing pauses before the draft is saved. */ +const DRAFT_PAUSE_MS = 400 + +const EMPTY: Draft = { text: '', attachments: [] } + +/** How often a quit tries to save a draft, and how long it waits between tries for the engine to come back. */ +const QUIT_SAVE_TRIES = 4 +const QUIT_RETRY_MS = 3000 + +/** + * A screen's unsent message as the window holds it. The sidecar owns the draft and the send: the window only knows + * whether the screen changed since its last completed save, and that save's revision, which a send names. + */ +type Screen = { + draft: Draft + /** The last save that completed: the draft it saved, and its revision. None while the store holds no draft. */ + saved?: { draft: Draft; rev: number } + /** The save on its way. A screen has one at a time. */ + saving?: Promise + /** The send on its way. Nothing else saves the screen meanwhile, as that would replace the draft it sends. */ + sending?: Promise + /** + * The pictures that send carries, but for any the person took out since. A sent picture leaves the next draft when + * the send goes through, unless the person took it out and chose it again. + */ + sendingPictures?: Set +} + +/** Whether the screen changed since its last completed save. Each change makes a new draft object. */ +const changed = (screen: Screen) => screen.draft !== (screen.saved?.draft ?? EMPTY) + +/** + * A chat with a change applied. Entries only grow, so each one is kept. The running state comes from whichever was read + * last, so a change read before the open chat, which can arrive after it, never puts back a turn that already ended. + */ +function merge(view: View, change: View): View { + const byId = new Map(view.entries.map((entry) => [entry.id, entry])) + for (const entry of change.entries) byId.set(entry.id, entry) + const entries = [...byId.values()].sort((a, b) => a.id - b.id) + if (change.seq < view.seq) return { ...view, entries } + return { ...view, entries, running: change.running, waiting: change.waiting, turn: change.turn, seq: change.seq } +} + +/** A pasted picture's bytes as base64, to cross the sidecar's pipe. */ +function toBase64(bytes: Uint8Array): string { + let binary = '' + for (let i = 0; i < bytes.length; i += 0x8000) binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000)) + return btoa(binary) } function newestAutoOpen(files: ProjectFile[]): ProjectFile | undefined { @@ -28,68 +76,306 @@ function newestAutoOpen(files: ProjectFile[]): ProjectFile | undefined { export default function StudioApp() { const [projects, setProjects] = useState([]) const [current, setCurrent] = useState(null) - const [events, setEvents] = useState([]) + const [view, setView] = useState(null) const [files, setFiles] = useState([]) const [selected, setSelected] = useState(null) const [tab, setTab] = useState('preview') - const [attachments, setAttachments] = useState([]) const [error, setError] = useState(null) /** A failed send's error, kept with the project it was sent to and shown only there. */ const [sendErrors, setSendErrors] = useState>({}) - /** The unsent message. It belongs to the open project and stays until a send succeeds. */ - const [text, setText] = useState('') + /** The open screen's draft, as its composer shows it. */ + const [shown, setShown] = useState(EMPTY) const [sending, setSending] = useState(false) const [engine, setEngine] = useState('Engine starting') + /** What a sign-in needs from the person: the code to enter on its page, or an answer. */ + const [signIn, setSignIn] = useState | null>(null) + /** What the agent needs before a send, from the sidecar's check. Null until the check answers, and Send waits. */ + const [needs, setNeeds] = useState(null) + /** The folder the chats from before the store could not be read from, when macOS keeps it from the app. */ + const [importBlocked, setImportBlocked] = useState(null) const sendingNow = useRef(false) const currentId = useRef(null) - /** Bumped on every switch, so a slow history load for a project already left is dropped. */ + /** Bumped on every switch, so a slow load for a project already left is dropped. */ const loadRequest = useRef(0) - /** Live events that arrive while the open project's history is loading. */ - const pending = useRef<{ seq: number; event: StudioEvent }[] | null>(null) /** The newest file time already shown, so only newer files take over the preview. */ const lastSeen = useRef(0) + /** + * Each screen the window changed or filled from the saved state, by project id, or `START` for the start screen. + * Typing saves its screen once typing pauses, and at once when the person leaves it. A send saves first if the + * screen changed, then names the revision. A draft keeps its pictures as the paths of their files. + */ + const screens = useRef>({}) + /** The open screen's save that waits for typing to pause. */ + const pending = useRef<{ key: string; timer: number } | null>(null) + /** What waits for the engine's next start, as a quit's save the engine refused while it restarted. */ + const readyWaiters = useRef<(() => void)[]>([]) + /** Pastes whose pictures are still being kept, so a quit waits for them. */ + const pastes = useRef(new Set>()) + /** The previews of pasted pictures, by path, freed when the window closes. */ + const previews = useRef(new Map()) + /** Whether the person changed the start screen's pictures, so its saved ones no longer fill it. */ + const startPicturesTouched = useRef(false) + /** The project whose chat view is loaded. Chat changes for the open project wait in `early` until it is. */ + const loaded = useRef(null) + const early = useRef([]) + /** Counts chat loads, so only the newest load of the open chat replaces its view, not an older one that answers late. */ + const loads = useRef(0) - const chat = useMemo(() => buildChat(events, current?.path ?? ''), [events, current?.path]) + const items = useMemo(() => buildChat(view?.entries ?? [], view?.running ?? true), [view?.entries, view?.running]) + const running = view?.running ?? false + const attachments = shown.attachments.map((path) => ({ ...savedAttachment(path), previewUrl: previews.current.get(path) })) useUpdateEvents() useEffect(() => { void checkForUpdatesAtLaunch() }, []) + const openKey = () => currentId.current ?? START + const draftOf = (key: string) => screens.current[key]?.draft ?? EMPTY + + /** Puts a draft on a screen, and in the composer if that screen is open. */ + const put = (key: string, draft: Draft) => { + ;(screens.current[key] ??= { draft }).draft = draft + if (key === openKey()) setShown(draft) + } + + /** Empties a screen whose draft the store no longer holds. */ + const clear = (key: string) => { + put(key, EMPTY) + delete screens.current[key].saved + } + + /** + * Saves a screen until the store holds what it shows, one save at a time: a save wanted while one runs waits for it, + * then saves the newest draft. While the screen's send is on its way, only that send saves it, and it saves `going`, + * the draft taken at the click, so text typed while an earlier save finished stays the next message. Answers the last + * completed save. + */ + const flush = async (key: string, going?: Draft) => { + const screen = screens.current[key] + if (screen === undefined) return undefined + while (screen.saving !== undefined) await screen.saving + const wanted = going === undefined ? (screen.sending === undefined && changed(screen) ? screen.draft : undefined) : going + if (wanted !== undefined && wanted !== screen.saved?.draft) { + const draft = wanted + const save = sidecar('drafts.set', { key, ...draft }).then(({ rev }) => { + screen.saved = { draft, rev } + }) + const done = () => { + screen.saving = undefined + } + screen.saving = save.then(done, done) + await save + } + return screen.saved + } + + const autosave = (key: string) => void flush(key).catch((err: unknown) => setError(String(err))) + + const clearPause = () => { + if (pending.current !== null) window.clearTimeout(pending.current.timer) + pending.current = null + } + + /** Changes the open screen's draft from its composer, and saves it once typing pauses. */ + const edit = (draft: Draft) => { + const key = openKey() + put(key, draft) + clearPause() + const timer = window.setTimeout(() => { + pending.current = null + autosave(key) + }, DRAFT_PAUSE_MS) + pending.current = { key, timer } + } + + /** Takes the app's saved state: projects, drafts, and whether the old chats could be read. */ + const takeState = (state: AppState) => { + // The saved state fills only the screens this window has not changed, since what it changed is newer. + for (const [key, saved] of Object.entries(state.drafts)) { + const local = screens.current[key] + if (local === undefined) { + const draft: Draft = { text: saved.text, attachments: saved.attachments } + screens.current[key] = { draft, saved: { draft, rev: saved.rev } } + if (key === openKey()) setShown(draft) + } else if (key === START && !startPicturesTouched.current && local.draft.attachments.length === 0 && saved.attachments.length > 0) { + // The person only typed here before the saved state came, so its pictures come back. That changes the draft. + put(START, { ...local.draft, attachments: saved.attachments }) + autosave(START) + } + } + takeProjects(state) + } + + /** Takes the saved projects, and whether the old chats could be read. */ + const takeProjects = (state: AppState) => { + // A project made while this was on its way is newer than the saved list, so it stays. + setProjects((made) => [...made.filter((p) => !state.projects.some((saved) => saved.id === p.id)), ...state.projects]) + setImportBlocked(state.importBlocked) + } + + /** Asks the sidecar what the agent still needs before a send. */ + const checkReady = () => { + void sidecar('setup.check', {}) + .then(setNeeds) + .catch((err: unknown) => setError(String(err))) + } + + // An import retry brings old chats in as projects. Its saved state may be read before a send the person makes + // meanwhile, so it takes the projects only, never drafts, which this window already holds. + const retryImport = () => { + void sidecar('legacy.import', {}) + .then(() => sidecar('state.get', {})) + .then(takeProjects) + .catch((err: unknown) => setError(String(err))) + } + useEffect(() => { - // The listener is in place before the ping, so no change is missed, and a ping that answers late never hides one. - let changed = false + // The first read of the saved state. If the engine stopped before it answered, it is read again once the engine is + // back, so the projects and drafts still come. + let stateRead = false + const readState = () => + sidecar('state.get', {}) + .then((state) => { + stateRead = true + takeState(state) + }) + .catch((err: unknown) => setError(String(err))) + void readState() + checkReady() + // The listener is in place before the ping, so no engine change is missed, and a ping that answers late never + // hides one. + let engineChanged = false const unlisten = onSidecarEvent((event) => { - if (event.type !== 'ready' && event.type !== 'engine') return - changed = true - setEngine(event.type === 'ready' ? `Engine ${event.version}` : event.state === 'stopped' ? event.detail : 'Engine restarting') + switch (event.type) { + case 'ready': { + engineChanged = true + setEngine(`Engine ${event.version}`) + // A check that failed while the engine started again is asked again, so Send does not stay off. + checkReady() + for (const wake of readyWaiters.current.splice(0)) wake() + if (!stateRead) void readState() + // An engine that started again holds what a crash cut off from the window, such as a turn's last entry, so + // the open chat is read again. The changes that arrive meanwhile are kept, as when it first opened. + const project = currentId.current + if (project !== null) { + loaded.current = null + early.current = [] + void load(project, loadRequest.current).catch((err: unknown) => setError(String(err))) + } + break + } + case 'engine': + engineChanged = true + setEngine(event.state === 'stopped' ? event.detail : 'Engine restarting') + break + case 'chat': + if (event.project !== currentId.current) break + if (loaded.current !== event.project) { + early.current.push(event) + break + } + setView((now) => now && merge(now, event)) + break + case 'login': + if (event.step === 'open') { + void openUrl(event.url).catch((err: unknown) => setError(String(err))) + setSignIn(event.code === undefined ? null : event) + } + if (event.step === 'ask') setSignIn(event) + if (event.step === 'done') { + setSignIn(null) + checkReady() + } + if (event.step === 'failed') { + setSignIn(null) + setError(event.detail) + } + break + } }) unlisten .then(() => sidecar('app.ping', {})) - .then(({ version }) => changed || setEngine(`Engine ${version}`)) - .catch((err: unknown) => changed || setEngine(String(err))) + .then(({ version }) => engineChanged || setEngine(`Engine ${version}`)) + .catch((err: unknown) => engineChanged || setEngine(String(err))) return () => { void unlisten.then((stop) => stop()) } }, []) + useEffect(() => () => previews.current.forEach((url) => URL.revokeObjectURL(url)), []) + useEffect(() => () => window.clearTimeout(pending.current?.timer), []) + + const type = (text: string) => edit({ ...draftOf(openKey()), text }) + + /** Changes the open screen's pictures, and saves them with its draft. */ + const setPictures = (paths: string[]) => { + if (openKey() === START) startPicturesTouched.current = true + const sending = screens.current[openKey()]?.sendingPictures + for (const path of draftOf(openKey()).attachments) if (!paths.includes(path)) sending?.delete(path) + edit({ ...draftOf(openKey()), attachments: paths }) + } + + /** + * Keeps pasted pictures in files, then adds them to the screen they were pasted on: to its composer if the person is + * there, or else to its draft, saved as any change is. + */ + const pastePictures = (pasted: PastedPicture[]) => { + const key = openKey() + const landing = Promise.all( + pasted.map(async ({ name, file }) => ({ + path: await sidecar('drafts.picture', { name, data: toBase64(new Uint8Array(await file.arrayBuffer())) }), + file, + })), + ) + .then((kept) => { + for (const { path, file } of kept) previews.current.set(path, URL.createObjectURL(file)) + const paths = [...draftOf(key).attachments, ...kept.map(({ path }) => path)] + if (openKey() === key) { + setPictures(paths) + return + } + if (key === START) startPicturesTouched.current = true + put(key, { ...draftOf(key), attachments: paths }) + autosave(key) + }) + .catch((err: unknown) => setError(String(err))) + pastes.current.add(landing) + void landing.finally(() => pastes.current.delete(landing)) + } + + // A quit or the window's close waits for pasted pictures still being kept and each send on its way, then saves every + // screen, those a send just made included. Rust holds the quit meanwhile. A save the engine refused, as while it + // restarts, is tried again once the engine is back. A draft that still cannot be saved calls the quit off. useEffect(() => { - studio.projects().then(setProjects).catch((err: unknown) => setError(String(err))) - const unlisten = studio.onEvent((project, seq, event) => { - if (project !== currentId.current) return - if (pending.current) pending.current.push({ seq, event }) - else setEvents((prev) => appendEvent(prev, seq, event)) + const unlisten = onQuitFlush(async () => { + clearPause() + await Promise.allSettled([...pastes.current]) + // It is done only when no screen sends and each holds what the store holds. A send or an edit that came while it + // saved, so a screen skipped or changed meanwhile, means another round. A save that failed is tried again once + // the engine is back, a few times, before the quit is called off. + for (let failures = 0; ; ) { + await Promise.allSettled(Object.values(screens.current).map((screen) => screen.sending)) + const saves = await Promise.allSettled(Object.keys(screens.current).map((key) => flush(key))) + const failed = saves.some((save) => save.status === 'rejected') + const settled = Object.values(screens.current).every((screen) => screen.sending === undefined && !changed(screen)) + if (!failed && settled) return + if (!failed) continue + if (++failures === QUIT_SAVE_TRIES) { + setError('A draft could not be saved, so Materialize 3D stayed open. Quit again once the engine is back.') + throw new Error('a draft could not be saved') + } + await new Promise((wake) => { + readyWaiters.current.push(wake) + window.setTimeout(wake, QUIT_RETRY_MS) + }) + } }) return () => { void unlisten.then((stop) => stop()) } }, []) - // Pasted pictures still waiting when the studio closes give their previews back. - const unsent = useRef(attachments) - unsent.current = attachments - useEffect(() => () => unsent.current.forEach(releaseAttachment), []) - // Pictures dropped anywhere on the window join the next message. useEffect(() => { const unlisten = getCurrentWebview().onDragDropEvent(({ payload }) => { @@ -99,7 +385,7 @@ export default function StudioApp() { setError('Drop a picture: PNG, JPG, HEIC, WebP, or GIF.') return } - setAttachments((prev) => [...prev, ...pictures.map((path) => ({ kind: 'path' as const, name: path.split(/[\\/]/).pop() ?? path, path }))]) + setPictures([...draftOf(openKey()).attachments, ...pictures]) }) return () => { void unlisten.then((stop) => stop()) @@ -122,93 +408,99 @@ export default function StudioApp() { useEffect(() => { if (!current) return void refreshFiles(current.id).catch((err: unknown) => setError(String(err))) - if (!chat.running) return + if (!running) return const timer = window.setInterval(() => { void refreshFiles(current.id).catch((err: unknown) => setError(String(err))) }, 1500) return () => window.clearInterval(timer) - }, [current, chat.running, refreshFiles]) - - /** Opens a project, or the start screen. A move the person makes drops the unsent message, so it never lands in another project. */ - const show = (project: Project | null, history: StudioEvent[], keepDraft = false) => { - if (!keepDraft) { - setText('') - attachments.forEach(releaseAttachment) - setAttachments([]) - } + }, [current, running, refreshFiles]) + + /** Shows a project, or the start screen, with its draft. The screen left is saved at once. */ + const show = (project: Project | null) => { + clearPause() + autosave(openKey()) loadRequest.current += 1 - pending.current = null currentId.current = project?.id ?? null + setShown(draftOf(openKey())) + loaded.current = null + early.current = [] lastSeen.current = 0 setCurrent(project) - setEvents(history) + setView(null) setFiles([]) setSelected(null) setTab('preview') setError(null) } + /** Loads the open project's chat, with the changes that arrived while it loaded. */ + const load = async (project: string, request: number) => { + const load = ++loads.current + const opened = await sidecar('chat.open', { project }) + if (request !== loadRequest.current || load !== loads.current) return + setView(early.current.reduce(merge, opened)) + early.current = [] + loaded.current = project + } + const openProject = async (project: Project) => { - show(project, []) + show(project) const request = loadRequest.current - pending.current = [] try { - const history = await studio.transcript(project.id) - if (request !== loadRequest.current) return - const live = pending.current - pending.current = null - setEvents(live.reduce((events, { seq, event }) => appendEvent(events, seq, event), history)) + await load(project.id, request) } catch (err) { - if (request !== loadRequest.current) return - pending.current = null - setError(String(err)) + if (request === loadRequest.current) setError(String(err)) } } + /** + * Sends the open screen's draft by its revision: saved first if it changed, and a save that fails ends the send. The + * sidecar makes the project for a first message, copies the pictures, and removes the draft it sent. + */ const send = async () => { - const message = text.trim() - if ((!message && attachments.length === 0) || sendingNow.current || chat.running) return + const key = openKey() + const screen = screens.current[key] + if (screen === undefined || (screen.draft.text.trim() === '' && screen.draft.attachments.length === 0) || sendingNow.current || running || notReady) return sendingNow.current = true setSending(true) - // The view this send belongs to. A move to another project or the start screen - // while it runs changes `loadRequest`, and then the send leaves that view alone. - let view = loadRequest.current - let sentTo: string | null = null + clearPause() + let done = () => {} + screen.sending = new Promise((resolve) => (done = resolve)) try { - let project = current - if (!project) { - project = await studio.createProject(message || 'New part') - setProjects((prev) => [project!, ...prev]) - if (loadRequest.current === view) { - show(project, [], true) - view = loadRequest.current - } + screen.sendingPictures = new Set(screen.draft.attachments) + const sent = await flush(key, screen.draft) + if (sent === undefined) return + const { project } = await sidecar('chat.send', { key, rev: sent.rev }) + setSendErrors(({ [project.id]: _, ...rest }) => rest) + setProjects((prev) => (prev.some((p) => p.id === project.id) ? prev : [project, ...prev])) + // What changed since the save that went out is the next message. The pictures that went out leave it, but for one + // the person took out and chose again during the send. + const outgoing = screen.sendingPictures ?? new Set() + const next: Draft = { ...screen.draft, attachments: screen.draft.attachments.filter((path) => !outgoing.has(path)) } + const unchanged = screen.draft === sent.draft + if (key === START && openKey() === START) { + // The person is still where the first message went from, so they go to its project, and what they changed + // here meanwhile goes with them as its draft. + clear(START) + if (!unchanged) put(project.id, next) + void openProject(project) + autosave(project.id) + } else if (unchanged) { + clear(key) + } else if (key !== START && next.attachments.length !== screen.draft.attachments.length) { + put(key, next) } - const id = project.id - sentTo = id - const outgoing = attachments - const images = await Promise.all( - outgoing.map((a) => (a.kind === 'path' ? studio.importFile(id, a.path) : studio.saveBytes(id, a.name, a.bytes))), - ) - await studio.send(id, message, images) - // Text typed while this was sending is a new message, so only the sent text is cleared, - // and only in the view that sent it. - if (loadRequest.current === view) setText((now) => (now.trim() === message ? '' : now)) - // A picture added while these imported waits for the next message. - setAttachments((prev) => prev.filter((a) => !outgoing.includes(a))) - outgoing.forEach(releaseAttachment) } catch (err) { const message = String(err) - if (sentTo !== null) { - const project = sentTo - setSendErrors((prev) => ({ ...prev, [project]: message })) - } else if (loadRequest.current === view) { - // No project was made, so the error belongs to the start screen, if it is still open. - setError(message) - } + if (key !== START) setSendErrors((prev) => ({ ...prev, [key]: message })) + else if (openKey() === START) setError(message) } finally { + delete screen.sending + delete screen.sendingPictures + done() sendingNow.current = false setSending(false) + autosave(key) } } @@ -218,29 +510,36 @@ export default function StudioApp() { if (current) setSendErrors(({ [current.id]: _, ...rest }) => rest) } + // Send waits for the check, and stays off while the agent lacks a sign-in, uv, or Python. + const notReady = needs === null || needs.length > 0 const composer = (placeholder: string, autoFocus: boolean) => ( - setAttachments((prev) => [...prev, ...more])} - onRemove={(index) => { - releaseAttachment(attachments[index]) - setAttachments((prev) => prev.filter((_, i) => i !== index)) - }} - onSend={() => void send()} - onStop={current ? () => void studio.cancel(current.id) : undefined} - busy={sending || chat.running} - running={chat.running} - placeholder={placeholder} - autoFocus={autoFocus} - /> + <> + {needs !== null && } + setPictures([...draftOf(openKey()).attachments, ...more.map((a) => a.path)])} + onPaste={pastePictures} + onRemove={(index) => { + releaseAttachment(attachments[index]) + previews.current.delete(shown.attachments[index]) + setPictures(draftOf(openKey()).attachments.filter((_, i) => i !== index)) + }} + onSend={() => void send()} + onStop={current ? () => void sidecar('chat.stop', { project: current.id }).catch((err: unknown) => setError(String(err))) : undefined} + busy={sending || running || notReady} + running={running} + placeholder={placeholder} + autoFocus={autoFocus} + /> + ) return (
    )} + {importBlocked !== null && } + {signIn && ( + { + setSignIn(null) + void sidecar('auth.answer', { provider: signIn.provider, text: answer }).catch((err: unknown) => setError(String(err))) + }} + /> + )} {current ? ( <>
    {current.title}
    + {view?.turn && ( +

    + {view.turn.model.modelId} +

    + )} + {view?.waiting && } { setSelected(rel) setTab('preview') @@ -286,7 +602,7 @@ export default function StudioApp() { {composer('Describe the part. Paste a link or drop a photo if you have one.', true)}
    {SUGGESTIONS.map((s) => ( - ))} diff --git a/src/studio/Waiting.tsx b/src/studio/Waiting.tsx new file mode 100644 index 00000000..1135f07a --- /dev/null +++ b/src/studio/Waiting.tsx @@ -0,0 +1,149 @@ +import { useState } from 'react' +import type { LoginEvent, Need, ProviderId, Waiting } from '../../sidecar/src/protocol' +import { openFilesPrivacy, sidecar } from './api' + +/** Providers a key or a proxy can sign in to, and those with a subscription sign-in. */ +const KEYS: readonly string[] = ['anthropic', 'opencode-go'] +const OAUTH: Record = { anthropic: 'Sign in with Claude', 'openai-codex': 'Sign in with ChatGPT' } + +/** + * Why the open chat's turn stopped, and the one thing that lets it go on: a sign-in, or a retry. Once the fix is in, + * the turn picks up where it stopped on its own. + */ +export function WaitingBanner({ project, waiting, onError }: { project: string; waiting: Waiting; onError: (message: string) => void }) { + const run = (work: Promise) => void work.catch((err: unknown) => onError(String(err))) + + if (waiting.kind === 'error') { + return ( +
    + {waiting.detail} + +
    + ) + } + return ( +
    + {waiting.fix} + +
    + ) +} + +/** The ways to sign in to `provider`: its subscription sign-in, and a key or a proxy where it takes one. */ +function SignInControls({ provider, onError }: { provider: ProviderId; onError: (message: string) => void }) { + const [key, setKey] = useState('') + const [baseUrl, setBaseUrl] = useState('') + const run = (work: Promise) => void work.catch((err: unknown) => onError(String(err))) + return ( + <> + {OAUTH[provider] !== undefined && ( + + )} + {KEYS.includes(provider) && ( +
    { + event.preventDefault() + run(sidecar('auth.setKey', { provider, key, ...(baseUrl === '' ? {} : { baseUrl }) })) + }} + > + setKey(e.target.value)} aria-label="API key or token" /> + {provider === 'anthropic' && ( + setBaseUrl(e.target.value)} aria-label="Proxy URL" /> + )} + +
    + )} + + ) +} + +/** + * What the agent needs before a message can go out, with the one thing that gives it: a sign-in, or uv or a Python + * to install. Send stays off until the check finds nothing missing. + */ +export function ReadinessBanner({ needs, onCheck, onError }: { needs: Need[]; onCheck: () => void; onError: (message: string) => void }) { + const [need] = needs + if (need === undefined) return null + return ( +
    + {need.fix} + {need.kind === 'login' && } + +
    + ) +} + +/** + * The chats from before the store sit in a folder macOS keeps from the app until the person allows it. Says how, opens + * the setting, and imports them once the person tries again. + */ +export function ImportBlockedBanner({ folder, onRetry, onError }: { folder: string; onRetry: () => void; onError: (message: string) => void }) { + return ( +
    + + Your earlier chats are in {folder}, and macOS has not let Materialize 3D read that folder. In Privacy & Security, open + Files & Folders and turn on Documents Folder for Materialize 3D, then try again. + + + +
    + ) +} + +/** What a sign-in needs from the person: the code to enter on the page it opened, or an answer to its question. */ +export function SignInAsk({ ask, onAnswer }: { ask: Extract; onAnswer: (text: string) => void }) { + const [text, setText] = useState('') + if (ask.step === 'open') { + return ( +
    + + Enter the code {ask.code} at {ask.url} + +
    + ) + } + return ( +
    + {ask.kind === 'select' ? ( + <> + {ask.message} + {ask.options?.map((option) => ( + + ))} + + ) : ( +
    { + event.preventDefault() + onAnswer(text) + }} + > + setText(e.target.value)} + /> + +
    + )} +
    + ) +} diff --git a/src/studio/api.ts b/src/studio/api.ts index 85736458..84fa84d7 100644 --- a/src/studio/api.ts +++ b/src/studio/api.ts @@ -9,12 +9,22 @@ export const sidecar = (method: M, params: Methods[M]['params' export const onSidecarEvent = (handler: (event: SidecarEvent) => void) => listen('sidecar://event', ({ payload }) => handler(payload)) -export interface Project { - id: string - title: string - createdMs: number - path: string -} +/** + * Rust asks for this as the app quits or the window closes. The window says at once that it is saving, so Rust holds + * the quit for as long as that takes. Then it says it is done, or that a draft could not be saved, which calls the + * quit off. + */ +export const onQuitFlush = (save: () => Promise) => + listen('studio://flush', () => { + void invoke('studio_flush_started') + void save().then( + () => invoke('studio_flushed'), + () => invoke('studio_flush_failed'), + ) + }) + +/** Opens the macOS setting that lets the app read Documents. */ +export const openFilesPrivacy = () => invoke('studio_open_files_privacy') export interface ProjectFile { rel: string @@ -22,24 +32,13 @@ export interface ProjectFile { modifiedMs: number } -/** One line of the project transcript: a Claude Code stream-json event, or one of the app's own `m3d_*` events. */ -export type StudioEvent = { type: string } & Record - +/** The project folder's own files: the native edges Rust keeps. */ export const studio = { - projects: () => invoke('studio_projects'), - createProject: (title: string) => invoke('studio_create_project', { title }), - transcript: (project: string) => invoke('studio_transcript', { project }), - send: (project: string, text: string, images: string[]) => invoke('studio_send', { project, text, images }), - cancel: (project: string) => invoke('studio_cancel', { project }), files: (project: string) => invoke('studio_files', { project }), - importFile: (project: string, source: string) => invoke('studio_import_file', { project, source }), saveBytes: (project: string, name: string, bytes: Uint8Array) => invoke('studio_save_bytes', bytes, { headers: { 'm3d-project': project, 'm3d-name': encodeURIComponent(name) } }), reveal: (project: string, rel: string) => invoke('studio_reveal', { project, rel }), openInBambu: (project: string, rel: string) => invoke('studio_open_in_bambu', { project, rel }), - /** `seq` is the event's line in the project transcript, so history and live events join without gaps or repeats. */ - onEvent: (handler: (project: string, seq: number, event: StudioEvent) => void) => - listen<{ project: string; seq: number; event: StudioEvent }>('studio://event', ({ payload }) => handler(payload.project, payload.seq, payload.event)), } /** diff --git a/src/studio/studio.css b/src/studio/studio.css index 13cf0092..b9a0a811 100644 --- a/src/studio/studio.css +++ b/src/studio/studio.css @@ -525,3 +525,26 @@ pre { font-size: 12px; color: var(--s-muted); } + +.chat-model { + margin: 0 20px; + font-size: 12px; + color: var(--s-muted); +} + +.waiting { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin: 8px 20px; + padding: 8px 12px; + border: 1px solid var(--s-line); + background: var(--s-panel); + font-size: 13px; +} + +.waiting form { + display: flex; + gap: 6px; +} diff --git a/src/studio/transcript.test.ts b/src/studio/transcript.test.ts index 03b3f255..20fc0a66 100644 --- a/src/studio/transcript.test.ts +++ b/src/studio/transcript.test.ts @@ -1,51 +1,67 @@ import { describe, expect, it } from 'vitest' import { buildChat } from './transcript' -const project = '/Users/o/Documents/Materialize 3D/2026-11-06-dock-mount' - describe('buildChat', () => { - it('folds each tool step into one line with its result, and names Astra', () => { - const chat = buildChat( - [ - { type: 'm3d_user', text: 'A dock mount', images: ['refs/arm.jpg'] }, - { - type: 'assistant', - message: { - content: [ - { type: 'text', text: 'Writing the parameters.' }, - { type: 'tool_use', id: 't1', name: 'Write', input: { file_path: `${project}/params.json`, content: '{}' } }, - { type: 'tool_use', id: 't2', name: 'Bash', input: { command: 'codex exec -c model_provider=cliproxy "draw it"', description: 'Astra draws the part' } }, - ], - }, - }, - { type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 't1', content: 'ok' }] } }, - { type: 'assistant', parent_tool_use_id: 't9', message: { content: [{ type: 'text', text: 'inner helper chatter' }] } }, - { type: 'm3d_files', files: ['params.json', 'drawing.html', 'build/astra.log', 'print/clip.3mf'] }, - ], - project, - ) - - expect(chat.running).toBe(true) - expect(chat.items).toEqual([ + it('folds each tool step into one line with its result, and ends the turn with the files it wrote', () => { + const items = buildChat([ + { id: 1, kind: 'user', text: 'A dock mount', images: ['refs/arm.jpg'] }, + { + id: 2, + kind: 'assistant', + text: 'Writing the parameters.', + calls: [ + { id: 'c1', name: 'write', args: { path: 'params.json', content: '{}' } }, + { id: 'c2', name: 'astra', args: { task: 'Draw it\nas an HTML page' } }, + { id: 'c3', name: 'slice', args: { inputs: ['clip.stl'], output: 'print/clip.3mf' } }, + { id: 'c4', name: 'write', args: { path: 'drawing.html', content: '' } }, + ], + }, + { id: 3, kind: 'tool', callId: 'c1', name: 'write', text: 'ok', failed: false }, + { id: 4, kind: 'tool', callId: 'c2', name: 'astra', text: 'drew it', failed: false }, + { id: 5, kind: 'tool', callId: 'c3', name: 'slice', text: 'Wrote print/clip.3mf', failed: false }, + { id: 6, kind: 'tool', callId: 'c4', name: 'write', text: 'no space left', failed: true }, + { id: 7, kind: 'assistant', text: 'Done.', calls: [] }, + { id: 8, kind: 'user', text: 'thanks', images: [] }, + ]) + expect(items).toEqual([ { kind: 'user', text: 'A dock mount', images: ['refs/arm.jpg'] }, { kind: 'text', text: 'Writing the parameters.' }, - expect.objectContaining({ kind: 'tool', label: 'Wrote', summary: 'params.json', result: 'ok', failed: false }), - expect.objectContaining({ kind: 'tool', label: 'Astra (GPT)', summary: 'Astra draws the part' }), - { kind: 'files', files: ['print/clip.3mf', 'drawing.html'] }, + { kind: 'tool', id: 'c1', label: 'Wrote', summary: 'params.json', detail: 'params.json', result: 'ok', failed: false }, + { kind: 'tool', id: 'c2', label: 'Astra (GPT)', summary: 'Draw it', detail: 'Draw it\nas an HTML page', result: 'drew it', failed: false }, + expect.objectContaining({ kind: 'tool', id: 'c3', label: 'Sliced', summary: 'print/clip.3mf', failed: false }), + { kind: 'tool', id: 'c4', label: 'Wrote', summary: 'drawing.html', detail: 'drawing.html', result: 'no space left', failed: true }, + { kind: 'text', text: 'Done.' }, + { kind: 'files', files: ['print/clip.3mf'] }, + { kind: 'user', text: 'thanks', images: [] }, + ]) + }) + + it('shows a step still running without a result, and a chat from before the store as it was', () => { + const items = buildChat([ + { id: 1, kind: 'imported', role: 'user', text: 'A clip', images: [] }, + { id: 2, kind: 'imported', role: 'tool', name: 'Bash', text: 'Build the clip', result: 'clip.stl written', failed: false }, + { id: 3, kind: 'imported', role: 'assistant', text: 'Here it is.' }, + { id: 4, kind: 'user', text: 'Thicker', images: [] }, + { id: 5, kind: 'assistant', text: '', calls: [{ id: 'c1', name: 'run', args: { args: ['run', 'python', 'build/model.py'] } }] }, + ]) + expect(items).toEqual([ + { kind: 'user', text: 'A clip', images: [] }, + { kind: 'tool', id: 'imported-2', label: 'Ran', summary: 'Build the clip', detail: 'Build the clip', result: 'clip.stl written', failed: false }, + { kind: 'text', text: 'Here it is.' }, + { kind: 'user', text: 'Thicker', images: [] }, + { kind: 'tool', id: 'c1', label: 'Ran', summary: 'uv run python build/model.py', detail: 'uv run python build/model.py' }, ]) }) - it('ends the turn, marks its unfinished step stopped, and shows its error', () => { - const chat = buildChat( - [ - { type: 'm3d_user', text: 'hi' }, - { type: 'assistant', message: { content: [{ type: 'tool_use', id: 't1', name: 'Bash', input: { command: 'python3 build/slice.py' } }] } }, - { type: 'm3d_turn_end', ok: false, error: 'Stopped.' }, - ], - project, - ) - expect(chat.running).toBe(false) - expect(chat.items[1]).toEqual(expect.objectContaining({ kind: 'tool', failed: true, result: 'Stopped before it finished.' })) - expect(chat.items[chat.items.length - 1]).toEqual({ kind: 'error', text: 'Stopped.' }) + it('shows a step left without a result as stopped once its turn no longer runs', () => { + const entries = [ + { id: 1, kind: 'user' as const, text: 'Thicker', images: [] }, + { id: 2, kind: 'assistant' as const, text: '', calls: [{ id: 'c1', name: 'run', args: { args: ['run', 'python', 'build/model.py'] } }] }, + ] + expect(buildChat(entries, false)[1]).toMatchObject({ kind: 'tool', id: 'c1', result: 'It stopped before it finished.', failed: true }) + expect(buildChat(entries, true)[1]).not.toHaveProperty('result') + // A later message starts a new turn, which cannot run the earlier step again. + const later = [...entries, { id: 3, kind: 'user' as const, text: 'Thinner', images: [] }] + expect(buildChat(later, true)[1]).toMatchObject({ kind: 'tool', id: 'c1', result: 'It stopped before it finished.', failed: true }) }) }) diff --git a/src/studio/transcript.ts b/src/studio/transcript.ts index 26faf5fb..0997a489 100644 --- a/src/studio/transcript.ts +++ b/src/studio/transcript.ts @@ -1,137 +1,110 @@ -import { autoOpens, is3mf, type StudioEvent } from './api' +import type { ChatEntry } from '../../sidecar/src/protocol' +import { autoOpens, is3mf } from './api' export type ChatItem = | { kind: 'user'; text: string; images: string[] } | { kind: 'text'; text: string } | { kind: 'tool'; id: string; label: string; summary: string; detail: string; result?: string; failed?: boolean } | { kind: 'files'; files: string[] } - | { kind: 'error'; text: string } -export interface Chat { - items: ChatItem[] - running: boolean -} - -interface ContentBlock { - type: string - text?: string - id?: string - name?: string - input?: Record - tool_use_id?: string - content?: string | { type: string; text?: string }[] - is_error?: boolean -} +type ToolItem = Extract const str = (value: unknown) => (typeof value === 'string' ? value : '') -/** The folder-relative form of a path the agent used, so a step reads `build/model.py`, not the full path. */ -function short(path: string, projectPath: string): string { - return path.startsWith(projectPath + '/') ? path.slice(projectPath.length + 1) : path -} - -function describeTool(name: string, input: Record, projectPath: string): { label: string; summary: string } { - const path = short(str(input.file_path ?? input.path ?? input.notebook_path), projectPath) - const command = str(input.command) +/** One line for a tool step: what it did, and the file or command it did it with. */ +function describe(name: string, args: Record): { label: string; summary: string; detail: string } { + const path = str(args.path) switch (name) { - case 'Bash': - return command.includes('codex exec') - ? { label: 'Astra (GPT)', summary: str(input.description) || 'Handed a task to Astra' } - : { label: 'Ran', summary: str(input.description) || command.split('\n')[0] } - case 'Write': - return { label: 'Wrote', summary: path } - case 'Edit': - case 'MultiEdit': - return { label: 'Edited', summary: path } - case 'Read': - return { label: 'Read', summary: path } - case 'Glob': - case 'Grep': - return { label: 'Searched', summary: str(input.pattern) } - case 'WebFetch': - return { label: 'Opened', summary: str(input.url) } - case 'WebSearch': - return { label: 'Searched the web', summary: str(input.query) } - case 'Skill': - return { label: 'Used skill', summary: str(input.skill) } - case 'Agent': - case 'Task': - return { label: 'Helper', summary: str(input.description) } - case 'TodoWrite': - return { label: 'Planned', summary: 'Updated the step list' } + case 'read': + return { label: 'Read', summary: path, detail: path } + case 'write': + return { label: 'Wrote', summary: path, detail: path } + case 'edit': + return { label: 'Edited', summary: path, detail: path } + case 'run': { + const command = ['uv', ...(Array.isArray(args.args) ? args.args.map(str) : [])].join(' ') + return { label: 'Ran', summary: command, detail: command } + } + case 'slice': + return { label: 'Sliced', summary: str(args.output), detail: JSON.stringify(args, null, 2) } + case 'astra': + return { label: 'Astra (GPT)', summary: str(args.task).split('\n')[0] ?? '', detail: str(args.task) } default: - return { label: name.replace(/^mcp__/, '').replace(/__/g, ' '), summary: '' } + return { label: name, summary: '', detail: JSON.stringify(args, null, 2) } } } -function resultText(content: ContentBlock['content']): string { - if (typeof content === 'string') return content - return (content ?? []).map((block) => block.text ?? `[${block.type}]`).join('\n') +/** The labels steps had in chats from before the store, which ran Claude Code's own tools. */ +const OLD_LABELS: Record = { Bash: 'Ran', Write: 'Wrote', Edit: 'Edited', MultiEdit: 'Edited', Read: 'Read', Skill: 'Used skill', Agent: 'Helper', Task: 'Helper' } + +/** The file a step wrote that the person may want to look at, once its result came back without an error. */ +function written(name: string, args: Record): string | undefined { + if (name === 'write' || name === 'edit') return str(args.path) || undefined + if (name === 'slice') return str(args.output) || undefined + return undefined } -/** Folds a project transcript into chat items: one line per tool step, with its result attached. */ -export function buildChat(events: StudioEvent[], projectPath: string): Chat { +/** + * Committed chat entries as chat items: one line per tool step with its result attached, and after each turn a card + * of the files it wrote that open in the preview, print files first. A step left without a result, as a stop or a + * crash leaves it, shows as stopped once a later message starts a new turn, or once its own turn no longer `running`. + */ +export function buildChat(entries: ChatEntry[], running = true): ChatItem[] { const items: ChatItem[] = [] - const tools = new Map>() - let running = false - - for (const event of events) { - // A helper agent's inner steps stay inside its one line. - if (event.parent_tool_use_id) continue - switch (event.type) { - case 'm3d_user': - items.push({ kind: 'user', text: str(event.text), images: (event.images as string[] | undefined) ?? [] }) - running = true - break - case 'assistant': { - const blocks = ((event.message as { content?: ContentBlock[] } | undefined)?.content ?? []) - for (const block of blocks) { - if (block.type === 'text' && block.text?.trim()) { - items.push({ kind: 'text', text: block.text }) - } else if (block.type === 'tool_use' && block.id && block.name) { - const input = block.input ?? {} - const tool = { - kind: 'tool' as const, - id: block.id, - ...describeTool(block.name, input, projectPath), - detail: str(input.command) || JSON.stringify(input, null, 2), - } - tools.set(block.id, tool) - items.push(tool) - } - } + const tools = new Map() + let files: string[] = [] + // A step with no result that a later message or the end of its turn leaves behind was stopped, as a stop or a crash + // leaves it. It cannot run again. + const stopUnfinished = () => { + for (const { item } of tools.values()) { + if (item.result !== undefined) continue + item.result = 'It stopped before it finished.' + item.failed = true + } + } + const endTurn = () => { + const shown = [...new Set(files)].filter(autoOpens).sort((a, b) => Number(is3mf(b)) - Number(is3mf(a))) + if (shown.length > 0) items.push({ kind: 'files', files: shown }) + files = [] + } + for (const entry of entries) { + switch (entry.kind) { + case 'user': + endTurn() + stopUnfinished() + items.push({ kind: 'user', text: entry.text, images: entry.images }) break - } - case 'user': { - const blocks = ((event.message as { content?: ContentBlock[] | string } | undefined)?.content ?? []) - if (typeof blocks === 'string') break - for (const block of blocks) { - const tool = block.type === 'tool_result' && block.tool_use_id ? tools.get(block.tool_use_id) : undefined - if (tool) { - tool.result = resultText(block.content) - tool.failed = block.is_error === true - } + case 'assistant': + if (entry.text.trim() !== '') items.push({ kind: 'text', text: entry.text }) + for (const call of entry.calls) { + const args = (call.args ?? {}) as Record + const item: ToolItem = { kind: 'tool', id: call.id, ...describe(call.name, args) } + tools.set(call.id, { item, writes: written(call.name, args) }) + items.push(item) } break - } - case 'm3d_files': { - // The card holds what Ossie looks at or prints, print files first. Scripts and logs stay in the Files tab. - const files = ((event.files as string[] | undefined) ?? []).filter(autoOpens).sort((a, b) => Number(is3mf(b)) - Number(is3mf(a))) - if (files.length > 0) items.push({ kind: 'files', files }) + case 'tool': { + const call = tools.get(entry.callId) + if (call === undefined) break + call.item.result = entry.text + call.item.failed = entry.failed + if (!entry.failed && call.writes !== undefined) files.push(call.writes) break } - case 'm3d_turn_end': - running = false - // A step with no result when its turn ends was stopped with it. - for (const tool of tools.values()) { - if (tool.result === undefined) { - tool.result = 'Stopped before it finished.' - tool.failed = true - } + case 'imported': + if (entry.role === 'user') { + endTurn() + stopUnfinished() + items.push({ kind: 'user', text: entry.text, images: entry.images }) + } else if (entry.role === 'assistant') { + items.push({ kind: 'text', text: entry.text }) + } else { + items.push({ kind: 'tool', id: `imported-${entry.id}`, label: OLD_LABELS[entry.name] ?? entry.name, summary: entry.text, detail: entry.text, result: entry.result, failed: entry.failed }) } - if (event.ok === false) items.push({ kind: 'error', text: str(event.error) }) break } } - return { items, running } + endTurn() + if (!running) stopUnfinished() + return items }