Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,14 @@ Compatibility is documented in release notes, not encoded in the version string.
- The `nextcloud_bridge` block in a generated `profile.yml` described itself as
turning "a Nextcloud folder" into a trigger source. It answers questions from
a Talk room; the comment now says so.
- Deleting an artifact from the gallery, and the dispatch worker's retention
sweep, no longer show up as agent actions in the web terminal's activity
strip. Only mutations the agent actually performed are reported.
- The python executor tools now reject `execution_mode` values other than
`readonly` and `readwrite`. An unrecognized spelling used to slip past both
write gates and run write-pattern code even with
`control_system.writes_enabled=false`. The deployment-level kill switch also
now covers `execute_file`, which previously had no such check.

## [2026.8.0]

Expand Down
13 changes: 0 additions & 13 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,19 +54,6 @@ export default [
},
},

// (4c) max-lines ratchet on panel-manager.js alone. It sits right at the 450
// cap above while three in-flight tasks (wire-glow-call-sites,
// strip-verbs-and-labels, badge-ack-restore) all land agent-visibility code
// in it. Splitting the module mid-flight would be more destabilizing than the
// raised ceiling; the split is earmarked for the polish pass, and this block
// goes away with it. Scoped to the one file so nothing else drifts upward.
{
files: ['src/osprey/interfaces/web_terminal/static/js/panel-manager.js'],
rules: {
'max-lines': ['error', { max: 550, skipComments: true, skipBlankLines: true }],
},
},

// (5) Root config files: node globals.
{
files: ['vitest.config.js', 'eslint.config.js'],
Expand Down
6 changes: 5 additions & 1 deletion src/osprey/interfaces/artifacts/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,7 @@ def create_app(workspace_root: Path | None = None) -> FastAPI:
from osprey.stores.artifact_store import (
ArtifactEntry,
ArtifactStore,
artifact_mutation_actor,
register_artifact_delete_listener,
register_artifact_listener,
unregister_artifact_delete_listener,
Expand Down Expand Up @@ -850,7 +851,10 @@ async def serve_file(artifact_id: str, filename: str):

@app.delete("/api/artifacts/{artifact_id}")
async def delete_artifact(artifact_id: str):
deleted = store.delete_entry(artifact_id)
# This delete is a person clicking in the gallery, not the agent —
# tag it so store listeners don't report it as agent activity.
with artifact_mutation_actor("human"):
deleted = store.delete_entry(artifact_id)
if not deleted:
raise HTTPException(status_code=404, detail=f"Artifact {artifact_id} not found")
return {"status": "ok", "artifact_id": artifact_id}
Expand Down
120 changes: 120 additions & 0 deletions src/osprey/interfaces/web_terminal/static/js/panel-agent-attention.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/* OSPREY Web Terminal — Agent attention on the panel rail.

The two ways a rail entry signals agent activity: a transient glow for
"the agent just touched this", and a persistent badge for "this panel has
activity the operator has not seen". Everything here anchors on the rail
element bound once via initAgentAttention().

A badge must outlive the page: the server's history ring is re-read on every
SSE open (restoreAgentBadges) and any panel activity the operator has not
seen re-badges its entry. "Seen" is an ACKNOWLEDGMENT — the server ts of the
newest badge the operator cleared by surfacing that panel, kept per panel in
localStorage under `agent-ack:<panelId>`.

Only SERVER timestamps are ever stored or compared here. The ring's `ts` is
the web-terminal process's clock; a browser clock skewed ahead of it would
permanently suppress real badges, and one skewed behind would resurrect
cleared ones on every reconnect. So a badge whose frame carried no ts leaves
the stored ack untouched rather than substituting Date.now(). */

import { fetchJSON } from './api.js';
import { getEntry, setEntryAttention } from './panel-rail.js';
import { flashElement } from '/design-system/js/highlight.js';

/** @type {HTMLElement} */
let railEl = /** @type {HTMLElement} */ (/** @type {unknown} */ (null));

/**
* Bind the rail element every glow/badge call anchors on. Call once, as soon
* as the rail exists and before any SSE frame can arrive.
* @param {HTMLElement} el
*/
export function initAgentAttention(el) { railEl = el; }

/**
* Transient agent glow on a rail entry — flash only, never the persistent
* badge (that is agent_activity's job via badgePanelActivity). No-op for ids
* without an entry.
* @param {string} panelId
*/
export function flashAgentGlow(panelId) { const entry = getEntry(railEl, panelId); if (entry) flashElement(entry); }

/** Newest badge-causing server ts seen this page lifetime, per panel.
* @type {Map<string, number>} */
const badgeTs = new Map();

const ACK_KEY_PREFIX = 'agent-ack:';

/**
* Record a badge's server ts, keeping the newest. The history ring replays a
* panel's older events alongside its newest, so this must not walk backwards.
* @param {string} panelId
* @param {number} [ts]
*/
function noteBadgeTs(panelId, ts) {
if (typeof ts !== 'number') return;
const prev = badgeTs.get(panelId);
if (prev === undefined || ts > prev) badgeTs.set(panelId, ts);
}

/**
* The acknowledged server ts for a panel. A panel that was never acknowledged
* (and a storage read that is denied or corrupt) has seen nothing, so it reads
* as -Infinity: an unknown ack RESTORES a badge, it never suppresses one.
* @param {string} panelId
* @returns {number}
*/
function ackedTs(panelId) {
let raw = null;
try { raw = localStorage.getItem(ACK_KEY_PREFIX + panelId); } catch { return -Infinity; }
const ts = raw === null ? NaN : Number(raw);
return Number.isFinite(ts) ? ts : -Infinity;
}

/**
* Clear a panel's badge and acknowledge it up to that badge's own server ts,
* so a reload does not bring it back. With no ts on record the badge is still
* cleared but the stored ack is left exactly as it was (see the module note).
* @param {string} panelId
*/
export function clearBadge(panelId) {
setEntryAttention(railEl, panelId, false);
const ts = badgeTs.get(panelId);
if (ts === undefined) return;
try { localStorage.setItem(ACK_KEY_PREFIX + panelId, String(ts)); } catch { /* storage denied */ }
}

/**
* Badge a panel's rail entry for a live agent_activity frame, remembering the
* badge's server ts so clearing it can acknowledge exactly this event and the
* reload restore can skip it.
* @param {string} panelId
* @param {number} [ts]
* @returns {boolean} true when the rail anchored the badge; false means the
* caller still owns the frame (no entry for this id).
*/
export function badgePanelActivity(panelId, ts) {
if (!setEntryAttention(railEl, panelId, true, ts)) return false;
noteBadgeTs(panelId, ts);
return true;
}

/**
* Re-badge rail entries for panel activity this operator has not acknowledged.
* Runs after the membership resync on every SSE open — including the first, so
* a reload restores badges — which is also why it runs after it: an entry that
* the resync delta just added can then carry a badge.
*
* Only 'panel'-kind rows with a ts can badge anything; the strip owns the rest
* and history there is its own concern (the seam is not replayed).
*/
export async function restoreAgentBadges() {
let body = null;
try { body = await fetchJSON('/api/agent-activity/recent'); } catch { return; }
for (const ev of (body?.events || [])) {
const target = ev?.target;
if (target?.kind !== 'panel' || !target.panel || typeof ev.ts !== 'number') continue;
if (ev.ts <= ackedTs(target.panel)) continue;
badgePanelActivity(target.panel, ev.ts);
}
}
123 changes: 9 additions & 114 deletions src/osprey/interfaces/web_terminal/static/js/panel-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,15 @@ import {
import { initDockSync, withEchoSuppressed, setTileCloseHandler, setTileFocusHandler } from './dock-sync.js';
import { initRailDrag, railDragStart, railDragEnd } from './rail-drag.js';
import { startHealthPolling as startPolling } from './panel-health.js';
import { updateStatusBar } from './panel-status-bar.js';
import { openTerminalPanel, closeTerminalPanel } from './dock-workspace.js';
import { initRailThemeCoupling } from './rail-position.js';
import { flashElement } from '/design-system/js/highlight.js';
import {
createRail, addEntry, removeEntry, getEntry, setActive,
setEntryEnabled, setEntryAttention,
createRail, addEntry, removeEntry, setActive, setEntryEnabled,
} from './panel-rail.js';
import {
initAgentAttention, flashAgentGlow, clearBadge, badgePanelActivity, restoreAgentBadges,
} from './panel-agent-attention.js';

// ---- Types ----

Expand Down Expand Up @@ -180,6 +182,7 @@ export async function initPanelManager(panelId) {
railEl = /** @type {HTMLElement} */ (document.getElementById('panel-rail'));
contentEl = /** @type {HTMLElement} */ (containerEl.querySelector('#panel-content') || containerEl.querySelector('.panel-content'));
if (!railEl || !contentEl) return;
initAgentAttention(railEl);

// Hand the iframe adapter its fallback mount host. When the dockview shell is
// up, panel iframes live in the adapter's overlay layer instead (dockview
Expand Down Expand Up @@ -465,14 +468,10 @@ export async function initPanelManager(panelId) {

} else if (data.type === 'agent_activity' && data.target) {
// kind 'panel' with a live rail entry → persistent badge + glow via
// setEntryAttention; everything the rail cannot anchor falls through
// badgePanelActivity; everything the rail cannot anchor falls through
// to the activity-strip seam (no-op until a handler registers).
const t = data.target;
if (t.kind === 'panel' && t.panel && setEntryAttention(railEl, t.panel, true, data.ts)) {
// Remember the badge's server ts so clearing it can acknowledge
// exactly this event and the reload restore can skip it.
noteBadgeTs(t.panel, data.ts);
} else {
if (!(t.kind === 'panel' && t.panel && badgePanelActivity(t.panel, data.ts))) {
onAgentActivity(data);
}
}
Expand Down Expand Up @@ -569,93 +568,6 @@ async function resyncPanelState() {

// ---- Rail Rendering ----

/**
* Transient agent glow on a rail entry — flash only, never the persistent
* badge (that is agent_activity's job via setEntryAttention). No-op for ids
* without an entry.
* @param {string} panelId
*/
function flashAgentGlow(panelId) { const entry = getEntry(railEl, panelId); if (entry) flashElement(entry); }

// ---- Agent-attention badges across reloads ----
//
// A badge must outlive the page: the server's history ring is re-read on every
// SSE open (restoreAgentBadges) and any panel activity the operator has not
// seen re-badges its entry. "Seen" is an ACKNOWLEDGMENT — the server ts of the
// newest badge the operator cleared by surfacing that panel, kept per panel in
// localStorage under `agent-ack:<panelId>`.
//
// Only SERVER timestamps are ever stored or compared here. The ring's `ts` is
// the web-terminal process's clock; a browser clock skewed ahead of it would
// permanently suppress real badges, and one skewed behind would resurrect
// cleared ones on every reconnect. So a badge whose frame carried no ts leaves
// the stored ack untouched rather than substituting Date.now().

/** Newest badge-causing server ts seen this page lifetime, per panel.
* @type {Map<string, number>} */
const badgeTs = new Map();

const ACK_KEY_PREFIX = 'agent-ack:';

/**
* Record a badge's server ts, keeping the newest. The history ring replays a
* panel's older events alongside its newest, so this must not walk backwards.
* @param {string} panelId
* @param {number} [ts]
*/
function noteBadgeTs(panelId, ts) {
if (typeof ts !== 'number') return;
const prev = badgeTs.get(panelId);
if (prev === undefined || ts > prev) badgeTs.set(panelId, ts);
}

/**
* The acknowledged server ts for a panel. A panel that was never acknowledged
* (and a storage read that is denied or corrupt) has seen nothing, so it reads
* as -Infinity: an unknown ack RESTORES a badge, it never suppresses one.
* @param {string} panelId
* @returns {number}
*/
function ackedTs(panelId) {
let raw = null;
try { raw = localStorage.getItem(ACK_KEY_PREFIX + panelId); } catch { return -Infinity; }
const ts = raw === null ? NaN : Number(raw);
return Number.isFinite(ts) ? ts : -Infinity;
}

/**
* Clear a panel's badge and acknowledge it up to that badge's own server ts,
* so a reload does not bring it back. With no ts on record the badge is still
* cleared but the stored ack is left exactly as it was (see the section note).
* @param {string} panelId
*/
function clearBadge(panelId) {
setEntryAttention(railEl, panelId, false);
const ts = badgeTs.get(panelId);
if (ts === undefined) return;
try { localStorage.setItem(ACK_KEY_PREFIX + panelId, String(ts)); } catch { /* storage denied */ }
}

/**
* Re-badge rail entries for panel activity this operator has not acknowledged.
* Runs after the membership resync on every SSE open — including the first, so
* a reload restores badges — which is also why it runs after it: an entry that
* the resync delta just added can then carry a badge.
*
* Only 'panel'-kind rows with a ts can badge anything; the strip owns the rest
* and history there is its own concern (the seam is not replayed).
*/
async function restoreAgentBadges() {
let body = null;
try { body = await fetchJSON('/api/agent-activity/recent'); } catch { return; }
for (const ev of (body?.events || [])) {
const target = ev?.target;
if (target?.kind !== 'panel' || !target.panel || typeof ev.ts !== 'number') continue;
if (ev.ts <= ackedTs(target.panel)) continue;
if (setEntryAttention(railEl, target.panel, true, ev.ts)) noteBadgeTs(target.panel, ev.ts);
}
}

/**
* Interaction closures handed to every rail render/append call. Routing
* activation and close through here keeps a human click/"×" and an agent MCP
Expand Down Expand Up @@ -927,7 +839,7 @@ function ensureActivePanel() {
* @param {boolean} wasHealthy
*/
function onHealthSettled(panel, wasHealthy) {
updateStatusBar(panel);
updateStatusBar(panel, panelState[panel.id]);
if (panelState[panel.id].healthy && !wasHealthy) {
setEntryEnabled(railEl, panel.id, true);
ensureActivePanel();
Expand All @@ -952,23 +864,6 @@ function assumeHealthy(panel) {
setEntryEnabled(railEl, panel.id, true);
}

/** @param {Panel} panel */
function updateStatusBar(panel) {
if (!panel.statusBarId) return;

const statusItem = document.getElementById(panel.statusBarId);
if (!statusItem) return;

const state = panelState[panel.id];
if (state.url) {
statusItem.style.display = '';
const dot = statusItem.querySelector('.status-dot');
if (dot) {
dot.className = 'status-dot' + (state.healthy ? ' live' : ' error');
}
}
}

/**
* Broadcast the current UI mode to every panel iframe — the hub-role fan-out
* the header toggle fires after it swaps <html data-ui-mode>. Mirrors
Expand Down
31 changes: 31 additions & 0 deletions src/osprey/interfaces/web_terminal/static/js/panel-status-bar.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// @ts-check
/* OSPREY Web Terminal — Status-bar health readout.
*
* The one DOM consequence of a health poll settling that lives outside the
* rail: panels with a `statusBarId` mirror their healthy flag onto the status
* bar's dot. Kept out of panel-health.js on purpose — that module owns timing
* and fetch only, with no DOM knowledge.
*/

/** @typedef {import('./panel-catalog.js').Panel} Panel */

/**
* Reflect a panel's health on its status-bar item, if it has one. Hidden until
* the panel's config has loaded (`state.url` set), then dot class live/error.
* @param {Panel} panel
* @param {{url: string | null, healthy: boolean}} state
*/
export function updateStatusBar(panel, state) {
if (!panel.statusBarId) return;

const statusItem = document.getElementById(panel.statusBarId);
if (!statusItem) return;

if (state.url) {
statusItem.style.display = '';
const dot = statusItem.querySelector('.status-dot');
if (dot) {
dot.className = 'status-dot' + (state.healthy ? ' live' : ' error');
}
}
}
Loading
Loading