Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
181ae2b
chore(oat): scaffold brainstorm-visual-companion-v6
tkstang Jun 26, 2026
857d4d0
chore(oat): quick-start discovery and plan for brainstorm-visual-comp…
tkstang Jun 26, 2026
413a713
chore(oat): record plan review artifact
tkstang Jun 28, 2026
933acbf
chore(oat): record plan review findings and apply artifact edits (plan)
tkstang Jun 28, 2026
f8d992d
chore(oat): record plan review artifact
tkstang Jun 28, 2026
59761ca
chore(oat): initialize implementation run (tier 1, ceiling sonnet, hi…
tkstang Jul 2, 2026
a563f83
feat(p01-t01): port v6 visual companion server with session auth
tkstang Jul 2, 2026
7b43b76
feat(p01-t02): port v6 companion client UI and OAT frame branding
tkstang Jul 2, 2026
248e43b
feat(p01-t03): adapt v6 start-server for OAT paths and restart reuse
tkstang Jul 2, 2026
f94b191
feat(p01-t04): port v6 stop-server instance verification
tkstang Jul 2, 2026
43917d4
chore(oat): bookkeeping after p01 pass (+ fold v2 plan re-review into…
tkstang Jul 2, 2026
7b307de
docs(p02-t01): document v6 visual companion behavior in reference
tkstang Jul 2, 2026
d4f927a
docs(p02-t02): align brainstorm skill with v6 companion startup
tkstang Jul 2, 2026
d16bcae
chore(oat): bookkeeping after p02 pass
tkstang Jul 2, 2026
8159499
test(p03-t01): cover v6 visual companion auth and restart behavior
tkstang Jul 2, 2026
0468a94
chore(p03-t02): record v6 port provenance and bump public packages
tkstang Jul 2, 2026
4bb3c53
test(p03-t01): make /files/ traversal+dotfile assertions real and add…
tkstang Jul 2, 2026
1445b57
chore(oat): bookkeeping after p03 pass (1 fix iteration)
tkstang Jul 2, 2026
a0c05bd
chore(oat): bookkeeping after p04 skip; tasks complete, awaiting fina…
tkstang Jul 2, 2026
e192b51
chore(oat): final review passed; implementation complete
tkstang Jul 2, 2026
3532d0d
chore(brainstorm-visual-companion-v6): mark docs updated (no doc delta)
tkstang Jul 3, 2026
a084e50
docs(brainstorm-visual-companion-v6): add project summary
tkstang Jul 3, 2026
c481e90
chore(brainstorm-visual-companion-v6): mark pr_open (#125)
tkstang Jul 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .agents/skills/oat-brainstorm/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: oat-brainstorm
version: 1.1.0
version: 1.2.0
description: Use when the user explicitly invokes the `brainstorm` verb, including `/oat-brainstorm`, "let's brainstorm", "brainstorm this", "can we brainstorm X", or "help me brainstorm X". For ambiguous exploratory phrasing ("I've been thinking", "what if", "help me think through"), do NOT auto-enter; respond conversationally and offer mode only after ≥2 sustained exploratory turns. Do NOT use for review, debug, PR, status, implementation, or active-workflow questions.
disable-model-invocation: false
user-invocable: true
Expand Down Expand Up @@ -198,7 +198,11 @@ command -v node >/dev/null 2>&1 && echo "available" || echo "missing"

Wait for the user's response.

- **Accept** → start the visual companion via `${SKILL_DIR}/scripts/start-server.sh` and read `${SKILL_DIR}/references/visual-companion.md` for the detailed usage guide before serving any content. Set `VISUAL_COMPANION = "active"`. Persistence paths follow the bundled `start-server.sh` resolution (repo-scope `.oat/brainstorm/<session>/`, user-scope `~/.oat/brainstorm/<session>/`, or `--project-dir <path>` override).
- **Accept** → start the visual companion via `${SKILL_DIR}/scripts/start-server.sh --open` (pass `--open` now that the user has explicitly approved the companion — it auto-opens the keyed URL in their browser on the first screen) and read `${SKILL_DIR}/references/visual-companion.md` for the detailed usage guide before serving any content. Set `VISUAL_COMPANION = "active"`. Persistence paths follow the bundled `start-server.sh` resolution (repo-scope `.oat/brainstorm/<session>/`, user-scope `~/.oat/brainstorm/<session>/`, or `--project-dir <path>` override).

**Preserve the keyed URL.** The startup JSON's `url` field carries the session key as `?key=...`. Always hand the user that exact `url` value — never strip the query string — and re-share the full keyed URL any time you remind them where to look; a bare `http://localhost:<port>/` is rejected by the server. See `references/visual-companion.md` for the full usage loop.

**Restart semantics.** If the server needs to restart mid-session (it exited, was stopped, or the idle timeout fired — 4 hours by default), re-invoke `start-server.sh` with the **same** `--project-dir`/repo-context path resolution used the first time. That reuses the previously bound port and session key, so an already-open browser tab reconnects on its own and no new URL needs to be issued. `--open` is not required on a mid-session restart; only pass it again if you need to force the browser open.

**Resolving `${SKILL_DIR}`:** the brainstorm pack defaults to user scope, so a fresh install puts the bundled scripts under `~/.agents/skills/oat-brainstorm/`, not the current repo. Resolve the loaded skill directory before invoking any script. In order:
1. If your provider exposes the loaded skill's resolved path (for example via a `${SKILL_DIR}` environment variable, `Skill` invocation context, or equivalent), use it directly.
Expand Down
40 changes: 33 additions & 7 deletions .agents/skills/oat-brainstorm/references/visual-companion.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,25 +40,41 @@ The bundled `start-server.sh` resolves session paths through OAT-managed prefixe

OAT's repo-root `.gitignore` already covers `.oat/` so repo-scoped sessions are not tracked by default.

The same resolved root also stores `.last-port` and `.last-token` (sibling to the session directories). See **Session Authentication and the Companion URL** below for how those get reused on restart.

## Session Authentication and the Companion URL

Every request to the companion server requires the session key. `start-server.sh` embeds it in the returned URL as `?key=<session-key>` — always hand the user the **full URL from the JSON response, verbatim**. Do not strip or shorten the query string.

- **First load:** the keyed URL bootstraps the browser and mirrors the key into an `HttpOnly` session cookie, so subsequent same-origin requests (page assets, `/files/*`, the WebSocket upgrade) authenticate via the cookie without repeating the key in the address bar.
- **Unauthenticated requests** (missing/incorrect key and no valid cookie) get a `403` response — the server never serves content to an unkeyed request.
- **Restarting the server** with the same `--project-dir` (or the same repo/user-scope context) reuses the previously bound port and session key, persisted at `.last-port` / `.last-token` under the resolved OAT root (see Persistence Paths above). If a browser tab from the prior session is still open, it reconnects automatically and does not need a new URL — see **Connection Status and Reconnection** below. Only give the user a new URL if the port or key actually changed (for example, a different `--project-dir` or a fresh user-scope session with no prior state).

## Starting a Session

The scripts ship inside the skill directory. Invoke them via paths relative to the installed skill location (`.agents/skills/oat-brainstorm/scripts/` or the user-scope equivalent `~/.agents/skills/oat-brainstorm/scripts/`).

```bash
# Start server with project-dir override (paths land under that directory)
.agents/skills/oat-brainstorm/scripts/start-server.sh --project-dir /path/to/project
# Start server with project-dir override (paths land under that directory).
# Pass --open only after the user has already accepted the visual-companion
# offer — it auto-opens the keyed URL in their browser on the first screen.
.agents/skills/oat-brainstorm/scripts/start-server.sh --project-dir /path/to/project --open

# Returns: {"type":"server-started","port":52341,"url":"http://localhost:52341",
# Returns: {"type":"server-started","port":52341,
# "url":"http://localhost:52341/?key=3f9c1a7b2e...",
# "screen_dir":"/path/to/project/.oat/brainstorm/12345-1706000000/content",
# "state_dir":"/path/to/project/.oat/brainstorm/12345-1706000000/state"}
# "state_dir":"/path/to/project/.oat/brainstorm/12345-1706000000/state",
# "idle_timeout_ms":14400000}
```

Save `screen_dir` and `state_dir` from the response. Tell user to open the URL.
Save `screen_dir` and `state_dir` from the response. Tell the user to open the full `url` value exactly as returned — the `?key=...` suffix is required; a bare `http://localhost:<port>/` gets a `403`.

**Finding connection info:** The server writes its startup JSON to `$STATE_DIR/server-info`. If you launched the server in the background and didn't capture stdout, read that file to get the URL and port. Inside an OAT repo (without `--project-dir`), the session lives under `<repo-root>/.oat/brainstorm/`. Outside an OAT repo, check `~/.oat/brainstorm/`.

**Note:** Repo-scoped sessions persist under `.oat/brainstorm/` (covered by OAT's default `.gitignore` for `.oat/`). User-scope fallback sessions persist under `~/.oat/brainstorm/`. There is no `/tmp` default — sessions are always recoverable.

**Idle timeout:** the server shuts itself down after **4 hours** of inactivity by default (`idle_timeout_ms` in the startup JSON). Pass `--idle-timeout-minutes <n>` to `start-server.sh` to override — useful for deliberately short-lived sessions or for extending past 4 hours on a long-running brainstorm.

**Launching the server by platform:**

**Claude Code (macOS / Linux):**
Expand Down Expand Up @@ -108,10 +124,20 @@ If the URL is unreachable from your browser (common in remote/containerized setu

Use `--url-host` to control what hostname is printed in the returned URL JSON.

## Connection Status and Reconnection

The browser page shows a connection-status indicator (`connecting` / `connected` / `reconnecting` / `disconnected`) driven by the WebSocket link back to the server. This runs automatically in the client — no agent action needed:

- **Brief disconnects reconnect automatically** using exponential backoff.
- **Extended disconnects** (roughly 15+ seconds) show a "Companion paused" overlay telling the user the agent will bring it back. Expect this during a deliberate restart — it is not an error condition to react to.
- **After the server comes back** on the same port (for example, you restarted it with the same `--project-dir`/repo context per Session Authentication above), the page detects the recovery and reloads through the keyed bootstrap automatically, so the open tab keeps working without a new URL.

Because reconnection is automatic, you do not need to tell the user to refresh after a restart. Only send a new URL if the port or key actually changed.

## The Loop

1. **Check server is alive**, then **write HTML** to a new file in `screen_dir`:
- Before each write, check that `$STATE_DIR/server-info` exists. If it doesn't (or `$STATE_DIR/server-stopped` exists), the server has shut down — restart it with `start-server.sh` before continuing. The server auto-exits after 30 minutes of inactivity.
1. **Pre-flight alive check**, then **write HTML** to a new file in `screen_dir`:
- Before each write, check that `$STATE_DIR/server-info` exists. If it doesn't (or `$STATE_DIR/server-stopped` exists), the server has shut down — restart it with `start-server.sh` before continuing. Restarting with the same `--project-dir`/repo context reuses the last bound port and session key (see Session Authentication above), so an already-open browser tab reconnects on its own — see Connection Status and Reconnection. The server auto-exits after 4 hours of inactivity by default (override with `--idle-timeout-minutes`).
- Use semantic filenames: `platform.html`, `visual-style.html`, `layout.html`
- **Never reuse filenames** — each screen gets a fresh file
- Use Write tool — **never use cat/heredoc** (dumps noise into terminal)
Expand Down
32 changes: 18 additions & 14 deletions .agents/skills/oat-brainstorm/scripts/frame-template.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@
<html>
<head>
<meta charset="utf-8">
<title>Superpowers Brainstorming</title>
<title>OAT Brainstorm</title>
<style>
/*
* BRAINSTORM COMPANION FRAME TEMPLATE
*
* This template provides a consistent frame with:
* - OS-aware light/dark theming
* - Fixed header and selection indicator bar
* - Fixed header with connection status pill
* - Scrollable main content area
* - CSS helpers for common UI patterns
*
Expand Down Expand Up @@ -63,21 +63,25 @@
}

/* ===== FRAME STRUCTURE ===== */
.status { font-size: 0.7rem; color: var(--status-color, var(--success)); display: flex; align-items: center; gap: 0.4rem; justify-self: end; white-space: nowrap; line-height: 1; }
.status::before { content: ''; width: 6px; height: 6px; background: var(--status-color, var(--success)); border-radius: 50%; }

.main { flex: 1; overflow-y: auto; }
#claude-content { padding: 2rem; min-height: 100%; }

.header {
background: var(--bg-secondary);
padding: 0.5rem 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border);
padding: 0.5rem 1.5rem;
flex-shrink: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 1rem;
min-height: 42px;
}
.header h1 { font-size: 0.85rem; font-weight: 500; color: var(--text-secondary); }
.header .status { font-size: 0.7rem; color: var(--success); display: flex; align-items: center; gap: 0.4rem; }
.header .status::before { content: ''; width: 6px; height: 6px; background: var(--success); border-radius: 50%; }

.main { flex: 1; overflow-y: auto; }
#claude-content { padding: 2rem; min-height: 100%; }
.header h1 { font-size: 0.85rem; font-weight: 500; color: var(--text-secondary); justify-self: start; }
.header .status { grid-column: 2; line-height: 1; }

.indicator-bar {
background: var(--bg-secondary);
Expand Down Expand Up @@ -196,8 +200,8 @@
</head>
<body>
<div class="header">
<h1><a href="https://github.com/obra/superpowers" style="color: inherit; text-decoration: none;">Superpowers Brainstorming</a></h1>
<div class="status">Connected</div>
<h1>OAT Brainstorm</h1>
<div class="status">Connecting…</div>
</div>

<div class="main">
Expand Down
108 changes: 101 additions & 7 deletions .agents/skills/oat-brainstorm/scripts/helper.js
Original file line number Diff line number Diff line change
@@ -1,26 +1,120 @@
(function() {
const WS_URL = 'ws://' + window.location.host;
const MIN_RECONNECT_MS = 500;
const MAX_RECONNECT_MS = 30000;
const TOMBSTONE_AFTER_MS = 15000; // show the "paused" overlay after this long disconnected

// Pure: next backoff delay (doubles, capped). Exported for unit tests.
function nextReconnectDelay(current, max) {
return Math.min(current * 2, max);
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = { nextReconnectDelay, MIN_RECONNECT_MS, MAX_RECONNECT_MS, TOMBSTONE_AFTER_MS };
}

// Everything below is browser-only; bail out when loaded in Node (tests).
if (typeof window === 'undefined') return;

let ws = null;
let eventQueue = [];
let reconnectDelay = MIN_RECONNECT_MS;
let reconnectTimer = null;
let disconnectedSince = null;
let everConnected = false;
let tombstoneShown = false;

function sessionKey() {
try {
return window.sessionStorage && window.sessionStorage.getItem('brainstorm-session-key');
} catch (e) {}
return null;
}

function websocketUrl() {
const key = sessionKey();
return 'ws://' + window.location.host + (key ? '/?key=' + encodeURIComponent(key) : '');
}

function reloadAfterRecovery() {
const key = sessionKey();
if (key) {
window.location.replace('/?key=' + encodeURIComponent(key));
} else {
window.location.reload();
}
}

// Reflect connection state in the frame's status pill (absent on full-doc screens).
function setStatus(state) {
const el = document.querySelector('.status');
if (!el) return;
const map = {
connecting: ['Connecting…', 'var(--text-tertiary)'],
connected: ['Connected', 'var(--success)'],
reconnecting: ['Reconnecting…', 'var(--warning)'],
disconnected: ['Disconnected', 'var(--error)']
};
const [text, color] = map[state] || map.disconnected;
el.textContent = text;
el.style.setProperty('--status-color', color);
}

// Self-styled so it works on framed and full-document screens alike.
function showTombstone() {
if (tombstoneShown) return;
tombstoneShown = true;
const el = document.createElement('div');
el.id = 'bs-tombstone';
el.style.cssText = 'position:fixed;inset:0;z-index:99999;display:flex;' +
'align-items:center;justify-content:center;padding:2rem;text-align:center;' +
'background:rgba(20,20,22,0.92);color:#f5f5f7;font-family:system-ui,sans-serif';
el.innerHTML = '<div style="max-width:480px">' +
'<h2 style="margin:0 0 .5rem;font-weight:600">Companion paused</h2>' +
'<p style="margin:0;opacity:.85">This brainstorm companion has stopped. ' +
'Ask your coding agent to bring it back — this page reconnects automatically.</p></div>';
if (document.body) document.body.appendChild(el);
}

function connect() {
ws = new WebSocket(WS_URL);
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
setStatus(everConnected ? 'reconnecting' : 'connecting');
ws = new WebSocket(websocketUrl());

ws.onopen = () => {
const recovered = tombstoneShown;
everConnected = true;
disconnectedSince = null;
reconnectDelay = MIN_RECONNECT_MS;
tombstoneShown = false;
setStatus('connected');
eventQueue.forEach(e => ws.send(JSON.stringify(e)));
eventQueue = [];
// Recovered from a tombstoned outage (e.g. the server restarted on the same
// port) — reload through the keyed bootstrap when possible so the cookie is
// refreshed before the visible URL returns to bare /.
if (recovered) reloadAfterRecovery();
};

ws.onmessage = (msg) => {
const data = JSON.parse(msg.data);
if (data.type === 'reload') {
window.location.reload();
}
let data;
try { data = JSON.parse(msg.data); } catch (e) { return; }
if (data.type === 'reload') window.location.reload();
};

ws.onclose = () => {
setTimeout(connect, 1000);
ws = null;
if (disconnectedSince === null) disconnectedSince = Date.now();
if (Date.now() - disconnectedSince >= TOMBSTONE_AFTER_MS) {
setStatus('disconnected');
showTombstone();
} else {
setStatus('reconnecting');
}
reconnectTimer = setTimeout(connect, reconnectDelay);
reconnectDelay = nextReconnectDelay(reconnectDelay, MAX_RECONNECT_MS);
};

// Let onclose own reconnection so we don't schedule it twice.
ws.onerror = () => { try { ws.close(); } catch (e) {} };
}

function sendEvent(event) {
Expand Down
Loading