Files
NCBM 0e8b0f8bd4 docs: describe deletes that go to the trash
A new page, `doc/trash.md`, covers what a delete does now: where a path lands on
each platform, what is still deleted outright (an explicit `permanent=true`, a
host that cleared `[trash]`, scratch paths under the OS temp dir, the
`copy_path` / `move_path` overwrite, the temporary-workspace cleanup), what
happens when a trashing is refused — the file stays and the error says how to
delete it on purpose — and the two things the trash cannot help with: a shell
`rm` and anything a plugin or MCP server does on its own.

`[trash]` joins the example config and the README snippet, the README gains the
one-paragraph version, `AGENTS.md` the tool/config notes (including the confirm
wording a delete now uses), and the default tool playbook tells the model that
`delete_path` is undoable and what `permanent=true` costs.
2026-10-21 20:25:27 +08:00

101 lines
4.8 KiB
Markdown

# Deletes and the system trash
`delete_path` is the agent's only way to remove a path, and it is tagged
`YOLO`: under `--yes` / `/yolo on` it runs with no confirmation at all. So the
default has to be recoverable — a delete moves the path into the desktop's own
trash, where the file manager the human already uses can put it back.
```text
deleted file notes.md (moved to system trash)
deleted directory build (moved to system trash)
deleted file notes.md # permanent=true
```
`permanent=true` is the old behaviour, chosen on purpose. `[trash] enabled =
false` makes every delete that way.
## Where a path goes
| Platform | Trash |
|---|---|
| Linux / BSD | The freedesktop trash specification: `$XDG_DATA_HOME/Trash` (`~/.local/share/Trash` by default), or a per-volume trash when the path is on another filesystem |
| macOS | The Finder's trash (`FSMoveObjectToTrashSync`; `NSFileManager` when pyobjc is installed) |
| Windows | The Recycle Bin (`IFileOperation` with pywin32, else `SHFileOperation`) |
The platform work is [send2trash](https://github.com/arsenetar/send2trash), a
required dependency because a missing safety net that fails silently is worse
than no safety net. It is pure Python over `ctypes` — nothing to compile, no
runtime dependencies.
Nothing is renamed away piecemeal: the whole path moves into the trash in one
`rename`, so a large directory costs nothing to delete (across filesystems,
where the volume has no trash of its own, the fallback is a move into the home
trash, which does copy). A path trashed twice keeps both copies (`dup.txt`,
then `dup 1.txt`).
## What is deleted outright
| Case | Why |
|---|---|
| `permanent=true` | The call said so |
| `[trash] enabled = false` | The host said so |
| A path under the OS temporary directory | Scratch space: nobody restores it, and a trash on the `/tmp` volume would only collect it. The same rule that `new_temporary_workspace` cleans up with |
| `new_temporary_workspace` cleanup at chat exit | Same reason |
| `copy_path` / `move_path` with `overwrite=true` | The destination is being replaced by an explicit call; the source content is the point of the operation |
## When the trash is unavailable
A trashing that fails is reported, never downgraded to a deletion — the human
was told the path is recoverable, so guessing would be worse than stopping:
```text
error: cannot move to trash: Permission denied (use permanent=true)
error: cannot move to trash: send2trash is not installed (use permanent=true)
```
The usual causes are a path on a filesystem with no `.Trash` directory that the
user cannot create one on (network mounts, some FUSE filesystems, other
people's volumes), a parent directory without write permission, and a broken
install. In every case the path is untouched and the error names the way
forward.
## Restoring
Restore from the desktop's own trash — the file manager's "Trash" / "Recycle
Bin", which is where a human would look anyway. plyngent does not offer a
`restore` tool: `send2trash` does not report where a path landed, and the
platform trash formats differ. A delete therefore reports that the path is in
the trash, not where.
## What the trash does not cover
- **A shell command.** `run_argv(["rm", "-rf", …])`, a script, or anything run
through a PTY reaches the filesystem directly; the command denylist and the
soft confirm of interpreter runs are the only gates there, and `rm` does not
use the trash.
- **Plugins and MCP servers.** They bring their own handlers.
- **Writes.** `write_file` with `overwrite=true` replaces a file's contents in
place — it never removes a path. Only `delete_path`, the `copy_path` /
`move_path` overwrite, and the temporary-workspace cleanup remove paths.
## Privacy
A trashed path is still on disk until the trash is emptied — that is the point
of it. `[agent].path_denylist` still runs first, so a denied path is never
trashed either, and a trashed file has left the workspace (it is no longer in a
`git status`, and no longer readable by a path tool).
## Platform limits worth knowing
- **Windows**: the shell deletes some things outright even when the call asked
for the Recycle Bin — empty folders (a folder only reaches the bin with
content), files above the bin's size limit for that drive, and paths on
network shares. That is Windows' behaviour, not a fallback here.
- **Linux**: a path on another volume needs a trash directory there
(`volume/.Trash/$UID` with the sticky bit, or a creatable `volume/.Trash-$UID`).
Without one the file is moved into the home trash instead, which crosses a
filesystem boundary and therefore copies.
- **macOS**: without pyobjc the library calls the older
`FSMoveObjectToTrashSync`; installing `send2trash[nativeLib]` switches it to
`NSFileManager`. Both trash; neither needs anything from plyngent.