# LPRA 0.2.0 Workspace Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** One-page panel shell where terminals and file managers (on machines and SSH hosts) live as panes in browser-session tabs that survive moving between Машины, Терминал and Настройки, with drag and drop of files between panes. **Architecture:** The server keeps rendering the existing templates, but in a fragment mode (`?frag=1`) without the page chrome; one shell page (`app.html`) holds the header and three views, and a small router (`app.js`) swaps views with `history.pushState`, loads fragments, intercepts their forms and mounts their scripts. The terminal view is created once and only hidden, so its sessions stay open. The terminal page's code splits into a workspace (tabs, layouts, menu) and pane types: terminal (`termpane.js`) and files (`filepane.js`, pulled out of `files.html`). Files on SSH hosts are done by the agent through shell commands over SSH, as the existing tar transfer does. **Tech Stack:** Go 1.26 (server, agent; `golang.org/x/crypto/ssh` already in go.mod), plain browser JavaScript without a build step (`internal/server/web/static/*.js`), Go `html/template`, node (`node --test`) for the pure JS helpers, Chrome DevTools (MCP) on the local stand for UI checks. **Spec:** `docs/superpowers/specs/2026-10-15-workspace-design.md` ## Global Constraints - Version `0.2.0` in `VERSION.txt` (set in the last task). - No new Go or JS dependencies. Files over SSH use shell commands over the existing `golang.org/x/crypto/ssh` client; no SFTP library. - No build step for the front end: plain scripts under `internal/server/web/static/`, served with `{{asset "name.js"}}` (content-hashed URLs). - All UI text in Russian, in the tone of the existing panel; code comments in English, in the style of the surrounding code. - Workspace state lives only in the page's memory: one per browser tab, gone on reload, kept across Машины ↔ Терминал ↔ Настройки. - `beforeunload` asks only while a terminal or SSH terminal session is alive. - Old URLs `/agents/{id}/terminal` and `/agents/{id}/files` keep working (they open the shell with a tab of that kind). - Roles: a non-admin sees and opens only machines given to them; «Настройки» is shown to admins only. - Commits go straight to `main`, no attribution lines in commit messages, `dist.zip` and `deploy/nginx.conf.example` are never staged; stage files by name. - Local stand for checks: scratchpad `r/` (`./server -addr 127.0.0.1:28491 -db lpra.db -tunnel "" -insecure-cookies`, agent `HOST=http://127.0.0.1:28491 STATE_PATH=$PWD/state.json TUNNEL_ADDR=off ./agent`, admin `adm`/`Passw0rd12345`, agent id `75108f8d676069f9`, test sshd on `127.0.0.1:28622`, user `t`, password `secret`). Start the agent with `env -u TERM_SESSION_ID -u TERM_PROGRAM HISTFILE=/dev/null`, and type ` unset HISTFILE` first in any test shell. ## Review Focus 1. A form inside a fragment that the server answers with a redirect to `/login` (session expired) — expected: the whole page goes to `/login`, not a login form inside the settings area. Test: Task 2, step "session expiry". 2. Paths with spaces, quotes and `$` dropped into a terminal or used by SSH file operations — expected: quoted so the shell sees one literal path. Tests: Task 1 (`shellQuote`), Task 6 (`shq` round trip). 3. Two file panes on the same machine in one tab — expected: each keeps its own folder, selection and buttons. Test: Task 5, step "two panes". 4. Closing a tab with an SSH file pane — expected: the agent closes that SSH connection at once, not after the idle timeout. Test: Task 7, step "close releases". 5. A machine dialog (Статус) closed while its live graph runs — expected: its timers stop (no requests after closing). Test: Task 9, step "unmount stops timers". --- ## File Structure | file | responsibility | |---|---| | `internal/server/web/templates/layout.html` (new) | `{{define "head"}}`, `{{define "topbar"}}`, `{{define "assets"}}` shared by pages | | `internal/server/web/templates/app.html` (new) | the shell: header, three views, the machine dialog, the scripts | | `internal/server/frag.go` (new) | fragment mode: `isFrag(r)`, `renderPage(w, r, name, data)` | | `internal/server/frag_test.go` (new) | tests of the fragment mode and shell routes | | `internal/server/web/static/app.js` (new) | router, fragment loader, form interception, module registry, `beforeunload` | | `internal/server/web/static/wsutil.js` (new) | pure helpers: `shellQuote`, `dropAction`, `fmtSize`; exported for node tests | | `internal/server/web/static/wsutil.test.js` (new) | `node --test` for `wsutil.js` | | `internal/server/web/static/workspace.js` (new, from `terminal.js`) | tabs, layouts, splits, pane and tab dragging, «+» menu | | `internal/server/web/static/termpane.js` (new, from `terminal.js`) | the `Session` class (terminal, SSH terminal), clipboard keys | | `internal/server/web/static/filepane.js` (new, from `files.html`) | `FilePane` class, its data sources (machine, SSH) | | `internal/server/web/static/fileviews.js` (new, from `files.html`) | shared file dialogs: viewer/editor, hex, properties, move/copy, uploads | | `internal/server/web/static/dnd.js` (new) | dragging files between panes and into terminals, the drop dialog | | `internal/server/web/static/machine.js` (new) | the machine dialog (Порты, Статус, Изменить fragments) | | `internal/server/web/static/pages.js` (new) | page modules: machines list, settings copy buttons and install code | | `internal/agent/sshfs.go` (new) | SSH file sessions and `sshfs.*` tasks | | `internal/agent/sshfs_test.go` (new) | tests of stat parsing and command building | | `internal/server/sshfs.go` (new) | server side of `sshfs.*`: open, ops, download, upload, audit | | `internal/proto/proto.go` | new task names and argument types | | removed at the end | `templates/terminal.html`, `templates/files.html` (their markup moves into `app.html` and the JS files above), `static/terminal.js` | --- ### Task 1: Pure helpers with node tests **Files:** - Create: `internal/server/web/static/wsutil.js` - Create: `internal/server/web/static/wsutil.test.js` **Interfaces:** - Produces: `window.WSUtil = {shellQuote(path: string): string, shellQuoteAll(paths: string[]): string, dropAction(item, target): {kind: 'transfer'|'paste'|'deny'|'none', reason?: string}, fmtSize(n: number): string}`. `item = {source: {kind: 'agent'|'ssh', id: string, label: string}, paths: string[], dir: string}`; `target = {kind: 'files'|'folder'|'term', source: {kind, id, label}, dir?: string}`. Two sources are «the same place» when `kind` and `id` are equal (for SSH, `id` is `"|user@host:port"`). - [ ] **Step 1: Write the failing tests** `internal/server/web/static/wsutil.test.js`: ```js 'use strict'; const test = require('node:test'); const assert = require('node:assert'); const U = require('./wsutil.js'); test('shellQuote keeps a path one literal word', () => { assert.strictEqual(U.shellQuote('/srv/a b'), "'/srv/a b'"); assert.strictEqual(U.shellQuote("/x/it's"), "'/x/it'\\''s'"); assert.strictEqual(U.shellQuote('/x/$HOME;rm'), "'/x/$HOME;rm'"); assert.strictEqual(U.shellQuoteAll(['/a', '/b c']), "'/a' '/b c'"); }); const m1 = {kind: 'agent', id: 'm1', label: 'web'}; const m2 = {kind: 'agent', id: 'm2', label: 'db'}; const s1 = {kind: 'ssh', id: 'm1|root@h:22', label: 'root@h'}; const item = {source: m1, paths: ['/etc/x'], dir: '/etc'}; test('dropAction: files into a file pane is a transfer', () => { assert.deepStrictEqual(U.dropAction(item, {kind: 'files', source: m2, dir: '/tmp'}), {kind: 'transfer'}); assert.deepStrictEqual(U.dropAction(item, {kind: 'folder', source: m1, dir: '/var'}), {kind: 'transfer'}); }); test('dropAction: into its own folder does nothing', () => { assert.deepStrictEqual(U.dropAction(item, {kind: 'files', source: m1, dir: '/etc'}), {kind: 'none'}); }); test('dropAction: terminal of the same place pastes, of another is denied', () => { assert.deepStrictEqual(U.dropAction(item, {kind: 'term', source: m1}), {kind: 'paste'}); const d = U.dropAction(item, {kind: 'term', source: m2}); assert.strictEqual(d.kind, 'deny'); assert.strictEqual(d.reason, 'Нельзя: файл на web, а терминал на db'); assert.strictEqual(U.dropAction({source: s1, paths: ['/a'], dir: '/'}, {kind: 'term', source: s1}).kind, 'paste'); }); test('fmtSize', () => { assert.strictEqual(U.fmtSize(10), '10 B'); assert.strictEqual(U.fmtSize(2048), '2.0 K'); }); ``` - [ ] **Step 2: Run them to see them fail** Run: `cd internal/server/web/static && node --test wsutil.test.js` Expected: FAIL, `Cannot find module './wsutil.js'`. - [ ] **Step 3: Write `wsutil.js`** ```js // Pure helpers of the workspace: quoting paths for a shell, what a dropped // file does where it lands, sizes. No DOM here, so node can test them. (function (root) { 'use strict'; function shellQuote(p) { return "'" + String(p).replace(/'/g, "'\\''") + "'"; } function shellQuoteAll(ps) { return ps.map(shellQuote).join(' '); } const same = (a, b) => a.kind === b.kind && a.id === b.id; function dropAction(item, target) { if (target.kind === 'term') { return same(item.source, target.source) ? {kind: 'paste'} : {kind: 'deny', reason: 'Нельзя: файл на ' + item.source.label + ', а терминал на ' + target.source.label}; } if (same(item.source, target.source) && target.dir === item.dir) return {kind: 'none'}; return {kind: 'transfer'}; } function fmtSize(n) { if (n < 1024) return n + ' B'; if (n < 1048576) return (n / 1024).toFixed(1) + ' K'; if (n < 1073741824) return (n / 1048576).toFixed(1) + ' M'; return (n / 1073741824).toFixed(1) + ' G'; } const api = {shellQuote, shellQuoteAll, dropAction, fmtSize}; if (typeof module !== 'undefined') module.exports = api; else root.WSUtil = api; })(this); ``` - [ ] **Step 4: Run the tests** Run: `cd internal/server/web/static && node --test wsutil.test.js` Expected: PASS, 5 tests. - [ ] **Step 5: Keep the test file out of the served assets** `staticHandler` serves everything under `web/static`. In `internal/server/server.go` find `staticHandler` and make it answer 404 for names ending in `.test.js`: ```go if strings.HasSuffix(r.URL.Path, ".test.js") { http.NotFound(w, r) return } ``` Run: `go build ./... && go vet ./internal/server` Expected: no output. - [ ] **Step 6: Commit** ```bash git add internal/server/web/static/wsutil.js internal/server/web/static/wsutil.test.js internal/server/server.go git commit -m "feat: workspace helpers - quoting paths, what a dropped file does, with node tests" ``` --- ### Task 2: Shell page, fragment mode, router **Files:** - Create: `internal/server/web/templates/layout.html`, `internal/server/web/templates/app.html`, `internal/server/frag.go`, `internal/server/frag_test.go`, `internal/server/web/static/app.js`, `internal/server/web/static/pages.js` - Modify: `internal/server/web/templates/dashboard.html`, `settings.html`, `edit.html`, `forwards.html`, `status.html` (use the `layout` blocks, wrap content in `{{define "-body"}}`), `internal/server/handlers_panel.go` (handlers call `renderPage`), `internal/server/server.go` (routes `/`, `/terminal`, `/settings`) **Interfaces:** - Produces (Go): `func isFrag(r *http.Request) bool`; `func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, name string, data map[string]any)` — with `?frag=1` executes template `name+"-body"`, otherwise executes `app.html` with `data["View"] = name` and the body pre-rendered into `data["Body"] template.HTML`. - Produces (JS): `window.App = {go(url: string), load(area: Element, url: string): Promise, register(name: string, mod: {mount(root: Element), unmount?()})}`; each fragment root has `data-module=""`; `App.load` calls the module's `mount(root)` after inserting and the previous module's `unmount()` before replacing. - [ ] **Step 1: Write the failing Go test** `internal/server/frag_test.go`: ```go package server import ( "net/http" "net/http/httptest" "strings" "testing" ) func TestFragmentHasNoChrome(t *testing.T) { s, cookie := newTestServer(t) // helper below req := httptest.NewRequest(http.MethodGet, "/settings?frag=1", nil) req.AddCookie(cookie) w := httptest.NewRecorder() s.Handler().ServeHTTP(w, req) body := w.Body.String() if w.Code != 200 || strings.Contains(body, " {{end}} {{define "topbar"}}
LPRA · Удалённый доступ
{{end}} ``` - [ ] **Step 4: Turn each page into a body block** For `dashboard.html`, `settings.html`, `edit.html`, `forwards.html`, `status.html`: 1. Cut everything between `` (after the `
…
`) and the page scripts into `{{define "-body"}}
…
{{end}}` (`` = `dashboard`, `settings`, `edit`, `forwards`, `status`). 2. Move the page `