File Links Plugin
The file_links plugin makes any file the agent generates or modifies discoverable, previewable, and downloadable from the web UI. It also lets users browse the configured repos_dir and inspect what changed during a conversation.
It is enabled by default. Disable it via disabled_plugins: [file_links] in codumentor.yaml.
See also: API Routes, UI Components, Plugin Dependencies.
What Users See
| Surface | Where | What it does |
|---|---|---|
x-file-links-card | Transcript, per plugin.file_links.file_written event | Shows filename, size, an icon for the file kind, Download + Open in browser buttons |
x-file-links-header-button | Chat header ("Files") | Always-available entry point to the repo browser; collapses into the ⋮ menu when the header is tight |
x-file-links-browser | Page at /plugins/file_links/repos | Repo selector + tree on the left, preview on the right; This conversation ↔ All files toggle |
How a Tool Reports a File Write
Tools do not need to call any file_links API. The plugin subscribes to onToolInvokeEnd (priority 70) and watches a configurable allow-list of tools. Whenever one of those tools fires, the plugin extracts the target path from the payload, resolves it via safe_resolve_repo_file, stat()s the result, and:
- Appends
{repo, path, size, kind, tool, ts}toscratchpad["local_files_modified"][conversation_id]. - Emits
plugin.file_links.file_writtento the UI event emitter so the card appears live.
The default allow-list is write_file, replace_in_file, doc_save, client_fs_copy_from_repo. Override via args.tracked_tools:
plugins:
- module: codumentor.plugins.file_links
class: FileLinksPlugin
args:
tracked_tools: ["write_file", "replace_in_file", "doc_save", "MyCustomWriteTool"]
Most tools carry the path under file_path (or path / filename). client_fs_copy_from_repo is special — it reads from a server-side repo file and pushes it to the user's browser-attached folder, so the tracker records the server-side source_path. New write-style tools that use a different arg name should be added to _TOOL_PATH_KEYS in local_tracker.py.
If a tool fires the hook but the resulting path does not exist on the host (e.g. it lives only inside a not-yet-copied-back isolated overlay), the plugin logs a warning and skips the entry. No phantom rows appear in the scratchpad.
The scratchpad is in-memory and volatile. The persistent source of truth for "which files did this conversation touch" is the plugin.file_links.file_written event stream stored in ui_event_store — /tree?scope=conversation reads from there, so resumed sessions and post-restart loads still surface prior writes. The plugin does not seed from git status — files the user has uncommitted that the agent never touched are intentionally excluded from the "this conversation" view.
URL Surface
All routes are auth-required (the host adds require_plugin_auth at mount time) and never leak absolute filesystem paths.
| Route | Purpose | Notes |
|---|---|---|
GET /ui/plugins/file_links/repos | List configured repos | Returns [{name, single_repo_mode}] |
GET /ui/plugins/file_links/download?repo=&path= | Attachment download | 100 MB cap; ?force=1 overrides |
GET /ui/plugins/file_links/view?repo=&path= | Inline preview | 5 MB cap; Content-Disposition: inline; MIME-aware; binary → 415; adds `X-File-State: clean\ |
GET /ui/plugins/file_links/tree?repo=&path=&scope=&conversation_id= | Directory listing | scope=conversation filters to paths the file_links tracker recorded for this conversation; scope=all lists the directory and annotates each entry with its current git_status |
repo may be omitted in single-repo mode; the plugin uses the configured sole repo. The path resolution goes through safe_resolve_repo_file, which rejects traversal, absolute-path injection, and symlinks that escape the repo root.
Legacy /ui/plugins/doc_editor/download
Doc_editor's pre-Phase-5 download endpoint stays mounted forever as a 308 redirect to /ui/plugins/file_links/download. It carries Sunset (RFC 8594) and Deprecation: true headers so external tooling can detect the migration. Old transcripts continue to work without rebuild.
Browser Page
/plugins/file_links/repos is registered through the standard plugin pages mechanism, so it routes via App.tsx's /plugins/:pluginId/* entry. The page state (selected repo and path) is carried in the URL hash:
/plugins/file_links/repos#?repo=alpha&path=docs/report.md
so deep links (e.g. from a card's Open in browser button) restore the selected file and load the preview directly.
Disabling
disabled_plugins: [file_links]
When disabled, register() is a no-op: no hooks subscribe, no routes are exposed, no UI cards render. The doc_editor 308 alias still works because doc_editor mounts its own routes — but the redirect target then 404s, so plan to keep file_links enabled in any deployment that retains old transcripts.
Where Things Live
src/codumentor/plugins/file_links/
├── __init__.py
├── plugin.py # FileLinksPlugin: register(), api_router(), ui_manifest()
├── routes.py # /repos, /download, /view, /tree
└── local_tracker.py # LocalFileTracker.on_tool_invoke_end
ui/plugins/file_links/
├── manifest.json
├── package.json
├── vite.config.ts
└── src/
├── index.ts # x-file-links-card, x-file-links-header-button, x-file-links-browser
└── index.test.ts