Two updates to AGENTS.md after the files-overlay rewrite: 1. The "canonical pattern" reference at the bottom of the inline-vs- routed modals section pointed at files-overlay.js lines ~599-664. That file is gone. Updated the reference to point at the new single-purpose location: editor.js's document-level click listener and the routedSaveClicked / routedReplaceClicked / routedDeleteClicked functions. 2. Added a "Files overlay: module layout" subsection right after the modals one. Names the four modules under static/js/files-overlay/, what each owns, the window.__filesOverlay action-registry contract, and the recipe for adding a new file-row action. Future agents touching the file manager hit a one-paragraph orientation instead of re-deriving the layout from git log. No behavior change. The "Modals: inline vs routed" decision tree itself stays accurate post-rewrite: editor is routed; new-folder, delete-confirm, conflict stay inline. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
12 KiB
AGENTS.md
Guidance for coding agents working in this repository.
Mission
Build left4me according to the two implementation plans:
docs/superpowers/plans/2026-04-22-l4d2-host-lib-v1.mddocs/superpowers/plans/2026-04-23-l4d2-web-app-v1.md
Do not invent architecture outside these plans unless explicitly requested.
Current Project State
l4d2host/andl4d2web/implementation directories exist.- Implementation plans remain the source of truth for contract changes and task sequencing.
Non-Negotiable Constraints
Workspace and tools
- Do not use git worktrees.
- Repo is a uv workspace; Python is pinned to 3.13 via
.python-version. After fresh checkout: installuv(brew install uv/curl -LsSf https://astral.sh/uv/install.sh | sh), thendirenv allow(oruv syncdirectly). See README Local development for details.
Modals: inline vs routed
Two coexisting modal mechanisms, one module (l4d2web/l4d2web/static/js/modals.js). When adding a new modal, decide which pipeline it belongs to:
Inline modal — the dialog markup is pre-rendered into the page HTML. Content is whatever's already there; the JS just calls showModal() / close() on a specific <dialog> by id. Use when:
- It's a confirmation (delete, overwrite, reset)
- It's a transient prompt mid-flow (conflict resolution during upload)
- It's a form whose URL state would be noise (rename, new-folder, new-server)
- The content has no standalone-page equivalent
Hooks: <button data-inline-modal-open="<dialog-id>"> opens; <button data-inline-modal-close> inside the dialog closes; Esc and backdrop click also close. Programmatic: window.modals.openInline(idOrEl) / window.modals.closeInline(idOrEl).
Routed modal — content is server-rendered from a URL and lands in the persistent <dialog id="modal-container"> slot. URL gains ?modal=<path>, refresh + share + back/forward all work. Use when:
- The content has standalone-page meaning (editor, detail view, settings panel)
- "Share this view" or "refresh-stays-here" matters
- The URL state earns its keep
Hooks: <a data-routed-modal href="<path>"> opens (full-page nav fallback if JS fails); <button data-routed-modal-dismiss> inside the swapped content closes. Programmatic: window.modals.openRouted(path) / window.modals.closeRouted().
Conventions for routed-modal templates (templates that {% extends base_layout %}, where base_layout resolves to _modal_partial.html for HX-Modal: 1 requests and base.html otherwise — see app.py:inject_base_layout):
- The outermost element of
{% block content %}is a<div>, NOT a<dialog>. The persistent slot inbase.htmlalready provides top-layer + backdrop + focus-trap + Esc-to-close semantics. Nested<dialog>collapses to 2 px in every browser. - Close buttons use
data-routed-modal-dismiss(NOT the inline-modal attribute).modals.jsdelegates at document level. - Form-bearing content needs document-level event delegation for submit/save/delete, gated on
event.target.closest("#modal-content"). Direct binding to elements in the swapped-in fragment only works in standalone mode — HTMX-swapped content arrives as fresh DOM nodes with no listeners attached. Seestatic/js/files-overlay/editor.js's document-level click listener + theroutedSaveClicked/routedReplaceClicked/routedDeleteClickedfunctions for the canonical pattern (readdata-*attributes from the swapped DOM, NOT from JS state set during open). - CSS classes targeting modal chrome are scoped to the outer slot —
dialog.modal, div.modalincomponents.css. The inner content div should NOT carryclass="modal modal-wide"(the outer dialog owns chrome; otherwise both paint card-in-a-card).
Reference: docs/superpowers/specs/2026-05-17-url-addressable-modals-design.md (design + verification matrix) and the plan errata at the top of docs/superpowers/plans/2026-05-17-url-addressable-modals.md.
Files overlay: module layout
The file-manager JS for files-type overlays is split across four
modules under l4d2web/l4d2web/static/js/files-overlay/, all loaded
with defer from templates/overlay_detail.html. They cooperate via
the window.__filesOverlay action registry that core.js sets up:
core.js— manager-element detection (.files-managerguard), derived state (overlayId,baseUrl,treeRoot,csrfToken), shared helpers (joinPath,parentOf,basename,humanSize,fetchJson,postJson,postForm,refreshFolder,findRowByPath,cssEscape,scheduleRefresh), and the document-level click listener that dispatches[data-action]clicks through__filesOverlay.handleAction(op, path, actionEl)into per-feature handlers.editor.js— URL-addressable editor only. Handles the new-file route (/files/new?at=...), edit route for text + binary (/files/edit?path=...), and the save / replace / delete delegated click handlers scoped to#modal-content. Registers"new-file"and"edit"into the registry.dialogs.js— the three inline<dialog>modals (new-folder, delete-confirm, conflict). Module-scope state per dialog (one delegated listener each, no clone-and-rebind). ExposesaskConflict(path) → Promise<"overwrite"|"keep-both"|"cancel">on__filesOverlayfor use by editor.js + uploads.js. Registers"new-folder"and"delete"into the registry.uploads.js— upload queue (concurrency 3, XHR-based progress,data-upload-iddelegated cancel), drag-drop ontreeRoot(direct-bound — 5 coordinated events share highlight state), and the"zip"registry handler. ExposeswithCollisionSuffix(path) → suffixedPathfor the upload + save conflict paths. Drag-drop ontreeRootis the only direct-bound listener block in the four modules; everything else is document-level delegation (see escape-hatch comments in-source).
When adding a new file-row action, the contract is:
- Render the
<button data-action="my-op" data-target-path="...">intemplates/_overlay_file_node.html(gated on the right capability flag). - Pick the module that owns the action and register a handler:
fo.registerHandler("my-op", (path, actionEl) => { ... }). - The dispatch wiring in
core.jstakes care of catching the click and calling the handler. No new listeners needed.
Dev server and filesystem paths
- Production paths (
/var/lib/left4me,/usr/local/lib/systemd/system,/usr/local/libexec/left4me,/etc/left4me) exist only on Linux deploy hosts. Never create or write to these on a developer machine. They are referenced inl4d2host/l4d2host/paths.pyand the spec only as the production layout. - For local dev, always use
scripts/dev-server.py. It setsLEFT4ME_ROOT=./.tmp/dev-server, runs migrations, seeds demo content (admin + blueprint + script overlay + files overlay), and starts Flask on port 5051. Reset state withrm -rf .tmp/dev-serverthen re-run. Never invokeflask rundirectly — that leavesLEFT4ME_ROOTunset and the app falls back to the production/var/lib/left4me, which on macOS surfaces as "route returns 404 / empty modal / file not found" and can be mistaken for a code bug. - All ephemeral dev state lives under
.tmp/(gitignored). Use$TMPDIRonly for transient files outside the repo. Do NOT use/tmp,~/Library/Application Support, or any system path for project state — only.tmp/(project-local) or$TMPDIR(sandbox-blessed). - Symptom-to-cause translation: if a route returns 404 or behaves as if the filesystem is empty, the first diagnosis is "
LEFT4ME_ROOTis wrong" (defaulted to the production path), not "code bug." Restart viascripts/dev-server.py.
Planning artifacts
- Design specs live in
docs/superpowers/specs/asYYYY-MM-DD-<topic>-design.md. - Implementation plans live in
docs/superpowers/plans/asYYYY-MM-DD-<topic>.md(suffix the topic with-v1/-v2/etc. if a plan is versioned). - Commit both to git as soon as the user approves them.
- Do not leave specs or plans outside this repo. The
~/.claude/plans/<slug>.mdplan-mode scratch file is acceptable while plan mode is open; the persisted artifact must end up underdocs/superpowers/and be committed.
Naming and boundaries
- Use
l4d2naming consistently. - Keep host library and web app as separate components.
- Do not collapse them into one package.
Host library (l4d2host / l4d2ctl)
- Exposed CLI write command set is fixed:
installinitialize <name> -f <spec.yaml>start <name>stop <name>delete <name>
- CLI read commands are allowed for web/host boundary consistency:
status <name> --jsonlogs <name> --lines <n> --follow/--no-follow
- Runtime paths are rooted at
LEFT4ME_ROOT, defaulting to/var/lib/left4me. - Deployment/config management owns global units under
/usr/local/lib/systemd/systemand privileged helpers under/usr/local/libexec/left4me. - Overlay directories are populated by the web app (workshop downloads, managed-global refresh). The host library only mounts them.
- Fail-fast subprocess behavior; pass raw stderr; propagate return code.
- No lock manager, no rollback, no preflight runtime checks.
- Delete missing instance/runtime dirs must succeed (no-op).
- Read APIs required for web app integration:
get_instance_status(name)stream_instance_logs(name, lines=200, follow=True)
Web app (l4d2-web-app)
- Flask + server-rendered templates + vendored HTMX.
- No external frontend framework/dependencies.
- Custom CSS with tokenized, consistent link and accent colors.
- Local username/password auth and
adminflag. - Persist command logs in
job_logstable (retain indefinitely). - Desired vs actual server state model.
- Live logs in UI for both jobs and servers.
- Web app host operations go through
l4d2ctlvia a host command client, not directl4d2hostimports. - Blueprint semantics (locked):
- private per user in v1
- live-linked to servers
- no server-level overrides
- deleting in-use blueprint is blocked
- updates apply on next action
- servers can reassign blueprint anytime
Delivery Workflow
- Read both plan files fully before coding.
- Execute plan tasks in order.
- Keep changes scoped to one task at a time.
- Run task-level tests before moving forward.
- Do not claim completion without command evidence.
- Keep docs updated when behavior/contracts change.
Verification Expectations
Before claiming success on any step, run the relevant command and report actual output status.
Typical commands (once components exist):
pytest l4d2host/tests -qpytest l4d2web/tests -q
Change Control
- If a requested change conflicts with this file, follow explicit user instruction.
- If plans and code diverge, update plans or flag the mismatch clearly.
End-to-end tests
The Playwright-based browser tests under l4d2web/tests/e2e/ need a
chromium binary, fetched on first setup:
uv run playwright install chromium
Always invoke as uv run pytest -m e2e ... (excluded from the default
fast suite via the e2e marker). Other forms crash Chromium under the
macOS sandbox; only this exact invocation is exempt.
Editor bundle (CodeMirror 6)
The in-browser code editor on the blueprint config / overlay script /
files-modal textareas is bundled from l4d2web/scripts/editor-src/
via esbuild and committed pre-built to
l4d2web/l4d2web/static/vendor/editor.bundle.js. Source lives under
l4d2web/scripts/editor-src/; design and plan at
docs/superpowers/specs/2026-05-17-textarea-editor-v2-design.md and
docs/superpowers/plans/2026-05-17-textarea-editor-v2.md.
Rebuild after editing the source:
./l4d2web/scripts/build-editor.sh
Requires node + npm locally. The script overrides the npm cache to
$TMPDIR/npm-cache (set NPM_CACHE to override) to dodge root-owned
files in ~/.npm/_cacache/ from older npm versions. Commit the
regenerated editor.bundle.js, editor.bundle.css, and
editor.bundle.sha256 alongside any source change.
Regenerate the autocomplete vocab from ./cvar_list (live L4D2
cvarlist dump committed at repo root) after replacing the dump:
./l4d2web/scripts/build-vocab.py