From 2b630ac000921eecece76124f082edc9a2c5850c Mon Sep 17 00:00:00 2001
From: Kinvectum <134240819+KAVentures@users.noreply.github.com>
Date: Sun, 27 Sep 2026 11:32:45 +0200
Subject: [PATCH] Connections follow-ups: restart detection, hide disconnected
history, more apps
- Detect when an app has not restarted since OpenWorkGraph changed its
config (Codex engine for Observe, Claude Desktop for Context) and tell
the user to quit and reopen it. The ChatGPT app keeps its Codex engine
running for days, so Codex Observe silently never started exporting.
Install time is recorded, falling back to the newest managed backup.
- Agents tab hides stored runs from agents whose Observe is off or
removed (server-side hide_disconnected filter shared by both Agents
scripts), with a toggle to show them and a note saying what is hidden.
Nothing is deleted.
- Add GitHub Copilot CLI, Kiro and Amazon Q Developer; label VS Code as
'VS Code + GitHub Copilot'. Explain that cloud apps (ChatGPT, Lovable,
Microsoft 365 Copilot) need a tunnel.
- Codex Context no longer claims it needs a restart: Codex reloads MCP
servers for new chats.
---
dashboard/agent_control_plane.js | 5 +-
dashboard/agent_observability_v090.js | 14 ++-
dashboard/connections.js | 15 ++-
dashboard/v0571_polish.js | 4 +-
docs/CONNECTIONS.md | 9 +-
server/agent_execution_trace_routes.py | 28 ++++-
server/connections.py | 149 ++++++++++++++++++++++-
tests/js/connections.test.mjs | 17 +++
tests/test_connections_followups_v092.py | 140 +++++++++++++++++++++
9 files changed, 370 insertions(+), 11 deletions(-)
create mode 100644 tests/test_connections_followups_v092.py
diff --git a/dashboard/agent_control_plane.js b/dashboard/agent_control_plane.js
index 33763e5f..f9134164 100644
--- a/dashboard/agent_control_plane.js
+++ b/dashboard/agent_control_plane.js
@@ -71,8 +71,11 @@
if(!panel||document.querySelector('#agent-control-intro'))return;
const intro=document.createElement('div');
intro.id='agent-control-intro';intro.className='card';
- intro.innerHTML=`
Agent observation Runs appear below when a native hook, tracing processor, OpenTelemetry exporter or custom structural adapter actually sends evidence to this local observer. MCP context access and agent observation are separate connections.
Set up an agent `;
+ intro.innerHTML=``;
panel.insertBefore(intro,panel.firstChild);
+ const history=intro.querySelector('#showDisconnectedAgents');
+ try{history.checked=localStorage.getItem('owg_show_disconnected_agents')==='1';}catch(_){}
+ history.onchange=()=>{try{localStorage.setItem('owg_show_disconnected_agents',history.checked?'1':'0');}catch(_){}window.refreshAgentObservability?.(true);window.refreshAgentDashboard?.(true);};
intro.querySelector('#openAgentSetup').onclick=()=>{window.activateTab?.('connect');setTimeout(()=>document.querySelector('#agent-observation-setup')?.scrollIntoView({behavior:'smooth',block:'start'}),0);};
}
diff --git a/dashboard/agent_observability_v090.js b/dashboard/agent_observability_v090.js
index 81a0c7b9..2ab0de3c 100644
--- a/dashboard/agent_observability_v090.js
+++ b/dashboard/agent_observability_v090.js
@@ -1,4 +1,6 @@
(() => {
+ // Shared with the other Agents-tab script: both must request the same run list.
+ const agentHistoryParam=()=>{try{return localStorage.getItem('owg_show_disconnected_agents')==='1'?'':'&hide_disconnected=true';}catch(_){return '&hide_disconnected=true';}};
let latest=null;
let loading=false;
let patchScheduled=false;
@@ -139,9 +141,17 @@
note.textContent=`${base}${base?' ':''}0 = observed zero. — = this integration cannot observe that signal; it does not mean zero runtime activity.`;
}
+ function patchHiddenNote(){
+ const note=document.querySelector('#agentHiddenNote');if(!note)return;
+ const names={'claude-code':'Claude Code','codex':'Codex'};
+ const hidden=(latest.hidden_frameworks||[]).map(f=>names[f]||f);
+ note.textContent=hidden.length?`Past runs from ${hidden.join(' and ')} are hidden because Observe is off for ${hidden.length>1?'them':'it'}. Nothing was deleted.`:'';
+ }
+
function patch(){
patchScheduled=false;
if(!latest)return;
+ patchHiddenNote();
const runs=Array.isArray(latest.executions)?latest.executions:[];
patchMetrics(runs);patchReports(runs);patchRunRows(runs);patchNote();
}
@@ -157,11 +167,13 @@
loading=true;
try{
await window.__owgAuthReady;
- const response=await fetch('/v1/agent-execution-traces?limit=50&evidence_limit=25000&max_events_per_execution=100',{cache:'no-store'});
+ const response=await fetch('/v1/agent-execution-traces?limit=50&evidence_limit=25000&max_events_per_execution=100'+agentHistoryParam(),{cache:'no-store'});
if(response.ok){latest=await response.json();schedulePatch();}
}finally{loading=false;}
}
+ window.refreshAgentObservability=force=>{latest=null;return refresh(force);};
+
function install(){
document.querySelector('#tab-agents')?.addEventListener('click',()=>setTimeout(()=>refresh(true),0));
document.querySelector('#agentRefreshButton')?.addEventListener('click',()=>setTimeout(()=>refresh(true),30));
diff --git a/dashboard/connections.js b/dashboard/connections.js
index 2000bce8..2d461af0 100644
--- a/dashboard/connections.js
+++ b/dashboard/connections.js
@@ -50,7 +50,7 @@
if(!card){
card=document.createElement('div');
card.id='owgConnections';card.className='card';
- card.innerHTML=`Connections Context lets an app read the work context you allow. Observe lets OpenWorkGraph record how an agent runs (never prompts, responses, tool arguments or results). Flip a switch to turn either on or off. The first time sets the app up (with a backup of its settings); after that, on/off is instant.
For agents and scripts (same switches, JSON output; works from any folder) Copy
… `;
+ card.innerHTML=`Connections Context lets an app read the work context you allow. Observe lets OpenWorkGraph record how an agent runs (never prompts, responses, tool arguments or results). Flip a switch to turn either on or off. The first time sets the app up (with a backup of its settings); after that, on/off is instant.
Cloud apps (ChatGPT, Lovable, Microsoft 365 Copilot) run on their servers, so they can't reach OpenWorkGraph on this computer directly. They need a secure tunnel: how to connect a cloud app
For agents and scripts (same switches, JSON output; works from any folder) Copy
… `;
panel.insertBefore(card,panel.firstChild);
}
// Everything the table already covers moves into one collapsed section.
@@ -81,7 +81,20 @@
return `
`;
}
+ function restartLine(client){
+ // The app loads this config only at startup and has not restarted since.
+ for(const [kind,label] of [['observe','Observe'],['mcp','Context']]){
+ const r=client[kind]?.restart_needed;if(!r)continue;
+ const since=new Date(r.running_since);
+ const when=Number.isFinite(since.getTime())?since.toLocaleDateString(undefined,{month:'short',day:'numeric'}):'';
+ const quit=/Mac/i.test(navigator.platform||'')?' (⌘Q)':'';
+ return `Quit and reopen ${esc(r.app)}${quit} to finish turning on ${label}${when?` (running since ${esc(when)})`:''}
`;
+ }
+ return '';
+ }
+
function subline(client){
+ const restart=restartLine(client);if(restart)return restart;
const msg=lastMessage[client.id];
if(msg)return `${esc(msg.text)}
`;
const errors=[client.mcp,client.observe].map(x=>x&&x.error).filter(Boolean);
diff --git a/dashboard/v0571_polish.js b/dashboard/v0571_polish.js
index 4d5f1c80..5d89901a 100644
--- a/dashboard/v0571_polish.js
+++ b/dashboard/v0571_polish.js
@@ -1,4 +1,6 @@
(() => {
+ // Shared with the other Agents-tab script: both must request the same run list.
+ const agentHistoryParam=()=>{try{return localStorage.getItem('owg_show_disconnected_agents')==='1'?'':'&hide_disconnected=true';}catch(_){return '&hide_disconnected=true';}};
const KNOWN = [
[/mail\.google\.com|\bgmail\b/i, 'Gmail'],
[/docs\.google\.com\/spreadsheets|google sheets|\bsheets\b/i, 'Google Sheets'],
@@ -275,7 +277,7 @@
agentLoading=true;
try {
await window.__owgAuthReady;
- const response=await fetch('/v1/agent-execution-traces?limit=50&evidence_limit=25000&max_events_per_execution=100',{cache:'no-store'});
+ const response=await fetch('/v1/agent-execution-traces?limit=50&evidence_limit=25000&max_events_per_execution=100'+agentHistoryParam(),{cache:'no-store'});
if (!response.ok) throw new Error('agent traces unavailable');
renderAgentDashboard(await response.json());
} catch (_) {
diff --git a/docs/CONNECTIONS.md b/docs/CONNECTIONS.md
index f1d7b518..ef7d6253 100644
--- a/docs/CONNECTIONS.md
+++ b/docs/CONNECTIONS.md
@@ -13,11 +13,14 @@ OpenWorkGraph has one list of AI apps, on the dashboard's **Connect** tab under
| Claude Desktop | ✓ | – | `claude_desktop_config.json` in Claude's app-support folder |
| Codex | ✓ | ✓ OTel traces | `~/.codex/config.toml` (or `$CODEX_HOME`) |
| Cursor | ✓ | – | `~/.cursor/mcp.json` |
-| VS Code | ✓ | – | `mcp.json` in VS Code's user folder |
+| VS Code + GitHub Copilot | ✓ | – | `mcp.json` in VS Code's user folder (Copilot agent mode uses it) |
| Windsurf | ✓ | – | `~/.codeium/windsurf/mcp_config.json` |
| Gemini CLI | ✓ | – | `~/.gemini/settings.json` |
+| GitHub Copilot CLI | ✓ | – | `~/.copilot/mcp-config.json` (or `$COPILOT_HOME`) |
+| Kiro | ✓ | – | `~/.kiro/settings/mcp.json` |
+| Amazon Q Developer | ✓ | – | `~/.aws/amazonq/mcp.json` |
-ChatGPT (cloud), other MCP apps and custom agents (OpenAI Agents SDK, OpenTelemetry, custom events) are under **More ways to connect**.
+**Cloud apps** (ChatGPT, Lovable, Microsoft 365 Copilot) run on their own servers and accept only remote HTTP MCP servers, so they cannot reach OpenWorkGraph on this computer directly. They need a secure tunnel to the optional local HTTP endpoint (or an organization Gateway). Other MCP apps and custom agents (OpenAI Agents SDK, OpenTelemetry, custom events) are under **More ways to connect**.
## How the switches behave
@@ -27,6 +30,8 @@ ChatGPT (cloud), other MCP apps and custom agents (OpenAI Agents SDK, OpenTeleme
- It never duplicates its own entry.
- It refuses (and changes nothing) if the file is not valid JSON/TOML, or if you already have a hand-written entry it should not overwrite.
- **Off / on again.** This only flips a switch that OpenWorkGraph checks on every MCP tool call and every incoming agent event. It takes effect **immediately**, with no restart, and the config stays in place.
+- **Restart needed.** Some apps load this config only when they start. The ChatGPT app (Codex engine) does this for Observe, and Claude Desktop does it for Context. If the app has been running since before OpenWorkGraph changed its config, the row says so ("Quit and reopen the ChatGPT app (⌘Q)…"). Opening a new chat is not enough.
+- **Agents tab history.** Runs from an agent whose Observe is off or removed are hidden by default. Tick *Show past runs from agents whose Observe is off* to see them. Nothing is deleted.
- **Remove** deletes OpenWorkGraph's entry from the app's config file (with a backup).
- **Context also needs AI access.** The dashboard's **AI access** master switch resets to OFF each time OpenWorkGraph starts. Turning a Context switch on also turns AI access on for the current run.
diff --git a/server/agent_execution_trace_routes.py b/server/agent_execution_trace_routes.py
index c11ce89c..ae648ed4 100644
--- a/server/agent_execution_trace_routes.py
+++ b/server/agent_execution_trace_routes.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import json
from typing import Any
from fastapi import APIRouter, HTTPException, Request
@@ -18,6 +19,19 @@ def _require_agent_report_read(request: Request) -> None:
raise HTTPException(status_code=401, detail="API or dashboard authentication required")
+def _row_framework(row: dict[str, Any]) -> str:
+ if str(row.get("source") or "") != "agent":
+ return ""
+ meta = row.get("metadata")
+ if meta is None and row.get("metadata_json"):
+ try:
+ meta = json.loads(row["metadata_json"])
+ except (TypeError, ValueError):
+ return ""
+ agent = meta.get("agent") if isinstance(meta, dict) else None
+ return str((agent or {}).get("framework") or "") if isinstance(agent, dict) else ""
+
+
@router.get("/v1/agent-execution-traces")
def get_agent_execution_traces(
request: Request,
@@ -27,14 +41,25 @@ def get_agent_execution_traces(
evidence_limit: int = 25_000,
limit: int = 20,
max_events_per_execution: int = 100,
+ hide_disconnected: bool = False,
) -> dict[str, Any]:
- """Return privacy-safe ordered structural traces for observed agent runs."""
+ """Return privacy-safe ordered structural traces for observed agent runs.
+
+ ``hide_disconnected`` omits stored runs from agents whose Observe connection
+ is currently off or removed (history is kept, only the view is filtered).
+ """
_require_agent_report_read(request)
+ hidden: set[str] = set()
try:
raw = load_recent_evidence(
limit=max(1, min(int(evidence_limit), 100_000)),
since=since,
)
+ if hide_disconnected:
+ from .connections import hidden_frameworks
+ hidden = hidden_frameworks()
+ if hidden:
+ raw = [row for row in raw if _row_framework(row) not in hidden]
payload = agent_execution_traces(
raw,
family_key=family_key,
@@ -48,6 +73,7 @@ def get_agent_execution_traces(
return {
**payload,
"evidence_rows_considered": len(raw),
+ "hidden_frameworks": sorted(hidden),
"evidence_is_canonical": True,
"read_only": True,
"writes_performed": False,
diff --git a/server/connections.py b/server/connections.py
index 1ad705a7..dc6977f1 100644
--- a/server/connections.py
+++ b/server/connections.py
@@ -80,6 +80,8 @@ def _json_entry(client: str, style: str) -> dict[str, Any]:
return {"type": "stdio", "command": command, "args": args, "env": {}}
if style == "vscode":
return {"type": "stdio", "command": command, "args": args}
+ if style == "copilot_cli":
+ return {"type": "local", "command": command, "args": args, "env": {}, "tools": ["*"]}
return {"command": command, "args": args}
@@ -202,18 +204,103 @@ class Target:
install: Callable[[], dict[str, Any]]
remove: Callable[[], dict[str, Any]]
applies: str # when a fresh install takes effect
+ # Long-running apps load MCP/telemetry config only at startup. When set,
+ # a matching process that started before the install is reported as
+ # "restart needed" instead of silently waiting.
+ process: Callable[[str, list[str]], str] | None = None
-def _json_target(path_fn: Callable[[], Path], key: str, client: str, style: str, applies: str) -> Target:
+def _json_target(path_fn: Callable[[], Path], key: str, client: str, style: str, applies: str,
+ process: Callable[[str, list[str]], str] | None = None) -> Target:
return Target(
path=path_fn,
installed=lambda: _json_mcp_installed(path_fn(), key),
install=lambda: _json_mcp_install(path_fn(), key, _json_entry(client, style)),
remove=lambda: _json_mcp_remove(path_fn(), key),
applies=applies,
+ process=process,
)
+# --- restart detection ----------------------------------------------------------
+
+def _codex_process(exe: str, cmdline: list[str]) -> str:
+ """Return a human app name if this is a Codex engine process, else ''."""
+ first = cmdline[0] if cmdline else exe
+ if Path(first).name.lower() not in {"codex", "codex.exe"}:
+ return ""
+ return "the ChatGPT app" if "ChatGPT.app" in first else "Codex"
+
+
+def _claude_desktop_process(exe: str, cmdline: list[str]) -> str:
+ first = cmdline[0] if cmdline else exe
+ if first.endswith("Claude.app/Contents/MacOS/Claude") or (
+ "AnthropicClaude" in first and Path(first).name.lower() == "claude.exe"
+ ):
+ return "Claude Desktop"
+ return ""
+
+
+_PROCESS_CACHE: dict[str, Any] = {"at": 0.0, "procs": []}
+
+
+def _processes() -> list[tuple[str, list[str], float]]:
+ """(exe, cmdline, start time) for running processes, cached briefly."""
+ import time
+ now = time.monotonic()
+ if now - _PROCESS_CACHE["at"] < 4.0:
+ return _PROCESS_CACHE["procs"]
+ procs: list[tuple[str, list[str], float]] = []
+ try:
+ import psutil
+ for proc in psutil.process_iter(["exe", "cmdline", "create_time"]):
+ try:
+ info = proc.info
+ procs.append((str(info.get("exe") or ""), [str(x) for x in (info.get("cmdline") or [])],
+ float(info.get("create_time") or 0)))
+ except Exception:
+ continue
+ except Exception:
+ procs = []
+ _PROCESS_CACHE.update(at=now, procs=procs)
+ return procs
+
+
+def _running_since(match: Callable[[str, list[str]], str]) -> tuple[float, str] | None:
+ """Earliest start time of a running process the matcher recognizes."""
+ found: tuple[float, str] | None = None
+ for exe, cmdline, started in _processes():
+ try:
+ name = match(exe, cmdline)
+ except Exception:
+ continue
+ if name and started and (found is None or started < found[0]):
+ found = (started, name)
+ return found
+
+
+def _installed_at(client_id: str, kind: str, path: Path) -> float | None:
+ recorded = ((_read_switches().get("installed_at") or {}).get(kind) or {}).get(client_id)
+ backups = [p.stat().st_mtime for p in path.parent.glob(f"{path.name}.owg-backup-*") if p.exists()]
+ candidates = [float(recorded)] if recorded else []
+ candidates += [max(backups)] if backups else []
+ return max(candidates) if candidates else None
+
+
+def _restart_needed(client_id: str, kind: str, target: Target) -> dict[str, Any] | None:
+ if target.process is None:
+ return None
+ installed_at = _installed_at(client_id, kind, target.path())
+ running = _running_since(target.process)
+ if installed_at is None or running is None or running[0] >= installed_at:
+ return None
+ from datetime import datetime, timezone
+ return {
+ "app": running[1],
+ "running_since": datetime.fromtimestamp(running[0], timezone.utc).isoformat(),
+ }
+
+
def _claude_observe() -> Target:
from adapters.claude_code_hook import settings_fragment
return Target(
@@ -237,6 +324,7 @@ def install() -> dict[str, Any]:
install=install,
remove=writer.codex_disconnect,
applies="the next time Codex starts",
+ process=_codex_process,
)
@@ -250,6 +338,11 @@ class Client:
observe_unavailable: str = ""
+def _copilot_home() -> Path:
+ override = os.getenv("COPILOT_HOME", "").strip()
+ return Path(override) if override and not os.getenv("OWG_CONNECTIONS_HOME") else _home() / ".copilot"
+
+
def _claude_code_json() -> Path:
override = os.getenv("OWG_CLAUDE_CODE_MCP_PATH", "").strip()
return Path(override) if override else _home() / ".claude.json"
@@ -262,16 +355,18 @@ def _claude_code_json() -> Path:
_claude_observe),
Client("claude_desktop", "Claude Desktop", lambda: _app_support("Claude"),
_json_target(lambda: _app_support("Claude", "claude_desktop_config.json"), "mcpServers",
- "claude_desktop", "plain", "after you quit and reopen Claude Desktop"),
+ "claude_desktop", "plain", "after you quit and reopen Claude Desktop",
+ _claude_desktop_process),
None, "Claude Desktop has no execution hooks to observe."),
Client("codex", "Codex", _codex_home,
- Target(_codex_mcp_path, _codex_mcp_installed, _codex_mcp_install, _codex_mcp_remove, "the next time Codex starts"),
+ Target(_codex_mcp_path, _codex_mcp_installed, _codex_mcp_install, _codex_mcp_remove,
+ "in new Codex chats"),
_codex_observe),
Client("cursor", "Cursor", lambda: _home() / ".cursor",
_json_target(lambda: _home() / ".cursor" / "mcp.json", "mcpServers", "cursor", "plain",
"after Cursor reloads its MCP servers"),
None, "Cursor does not expose an execution trace to observe."),
- Client("vscode", "VS Code", lambda: _app_support("Code", "User"),
+ Client("vscode", "VS Code + GitHub Copilot", lambda: _app_support("Code", "User"),
_json_target(lambda: _app_support("Code", "User", "mcp.json"), "servers", "vscode", "vscode",
"after VS Code reloads its MCP servers"),
None, "VS Code does not expose an execution trace to observe."),
@@ -283,6 +378,18 @@ def _claude_code_json() -> Path:
_json_target(lambda: _home() / ".gemini" / "settings.json", "mcpServers", "gemini_cli", "plain",
"in new Gemini CLI sessions"),
None, "Gemini CLI observation is not supported yet."),
+ Client("copilot_cli", "GitHub Copilot CLI", _copilot_home,
+ _json_target(lambda: _copilot_home() / "mcp-config.json", "mcpServers", "copilot_cli",
+ "copilot_cli", "in new Copilot CLI sessions"),
+ None, "Copilot CLI observation is not supported yet."),
+ Client("kiro", "Kiro", lambda: _home() / ".kiro",
+ _json_target(lambda: _home() / ".kiro" / "settings" / "mcp.json", "mcpServers", "kiro", "plain",
+ "after Kiro reloads its MCP servers"),
+ None, "Kiro observation is not supported yet."),
+ Client("amazon_q", "Amazon Q Developer", lambda: _home() / ".aws" / "amazonq",
+ _json_target(lambda: _home() / ".aws" / "amazonq" / "mcp.json", "mcpServers", "amazon_q", "plain",
+ "in new Amazon Q Developer sessions"),
+ None, "Amazon Q Developer observation is not supported yet."),
]
}
@@ -338,6 +445,15 @@ def _write_switch(client_id: str, kind: str, enabled: bool) -> None:
_CACHE.update(mtime=mtime, data=json.loads(json.dumps(data)))
+def _record_install(client_id: str, kind: str) -> None:
+ import time
+ with _LOCK:
+ data = json.loads(json.dumps(_read_switches()))
+ data.setdefault("installed_at", {}).setdefault(kind, {})[client_id] = time.time()
+ writer._atomic_write(_switch_path(), json.dumps(data, indent=2) + "\n")
+ _CACHE.update(mtime=_switch_path().stat().st_mtime_ns, data=data)
+
+
def is_enabled(client_id: str | None, kind: str) -> bool:
"""Runtime switch; unknown clients and never-touched switches default to on."""
if not client_id:
@@ -346,6 +462,26 @@ def is_enabled(client_id: str | None, kind: str) -> bool:
return True if value is None else bool(value)
+# Agent frameworks whose observation is controlled by a client switch.
+FRAMEWORK_CLIENTS = {"claude-code": "claude_code", "codex": "codex"}
+
+
+def observation_active(client_id: str) -> bool:
+ """True when observation is both installed and switched on for this client."""
+ client = CLIENTS.get(client_id)
+ if client is None or client.observe is None:
+ return True
+ try:
+ return is_enabled(client_id, "observe") and bool(client.observe().installed())
+ except writer.ConfigConflict:
+ return False
+
+
+def hidden_frameworks() -> set[str]:
+ """Frameworks whose stored history the Agents view hides by default."""
+ return {fw for fw, cid in FRAMEWORK_CLIENTS.items() if not observation_active(cid)}
+
+
# --- public operations ----------------------------------------------------------
def _kind_status(client: Client, kind: str) -> dict[str, Any]:
@@ -367,6 +503,10 @@ def _kind_status(client: Client, kind: str) -> dict[str, Any]:
}
if error:
status["error"] = error
+ if installed and enabled:
+ restart = _restart_needed(client.id, kind, target)
+ if restart:
+ status["restart_needed"] = restart
return status
@@ -416,6 +556,7 @@ def change(client_id: str, action: str, kinds: tuple[str, ...] = KINDS) -> dict[
if not target.installed():
result = target.install()
result["takes_effect"] = target.applies
+ _record_install(client.id, kind)
_write_switch(client.id, kind, True)
elif action == "off":
_write_switch(client.id, kind, False)
diff --git a/tests/js/connections.test.mjs b/tests/js/connections.test.mjs
index dbbd2d16..a0b702d2 100644
--- a/tests/js/connections.test.mjs
+++ b/tests/js/connections.test.mjs
@@ -35,3 +35,20 @@ test('conflicts fall back to manual setup without claiming success',()=>{
assert.match(source,/manual_setup_required/);
assert.match(source,/Nothing was changed/);
});
+
+test('both Agents-tab scripts request the same history-filtered run list',()=>{
+ const read=f=>fs.readFileSync(path.join(here,'..','..','dashboard',f),'utf8');
+ for(const f of ['agent_observability_v090.js','v0571_polish.js']){
+ const src=read(f);
+ assert.match(src,/max_events_per_execution=100'\+agentHistoryParam\(\)/,f);
+ assert.match(src,/owg_show_disconnected_agents/,f);
+ assert.match(src,/hide_disconnected=true/,f);
+ }
+ assert.match(read('agent_control_plane.js'),/Show past runs from agents whose Observe is off/);
+});
+
+test('restart-needed and cloud-app guidance are shown',()=>{
+ assert.match(source,/restart_needed/);
+ assert.match(source,/Quit and reopen/);
+ assert.match(source,/Lovable/);
+});
diff --git a/tests/test_connections_followups_v092.py b/tests/test_connections_followups_v092.py
new file mode 100644
index 00000000..cef91460
--- /dev/null
+++ b/tests/test_connections_followups_v092.py
@@ -0,0 +1,140 @@
+from __future__ import annotations
+
+import json
+import os
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+import pytest
+
+from server import connections
+
+
+ROOT = Path(__file__).resolve().parents[1]
+CHATGPT_CODEX = "/Applications/ChatGPT.app/Contents/Resources/codex"
+
+
+@pytest.fixture
+def home(tmp_path, monkeypatch):
+ home = tmp_path / "home"
+ home.mkdir()
+ monkeypatch.setenv("OWG_CONNECTIONS_HOME", str(home))
+ monkeypatch.setenv("WORKFLOW_OBSERVER_DATA", str(tmp_path / "data"))
+ for var in ("OWG_CLAUDE_SETTINGS_PATH", "OWG_CODEX_CONFIG_PATH", "OWG_CLAUDE_CODE_MCP_PATH", "CODEX_HOME", "COPILOT_HOME"):
+ monkeypatch.delenv(var, raising=False)
+ connections._CACHE.update(mtime=None, data={})
+ connections._PROCESS_CACHE.update(at=0.0, procs=[])
+ return home
+
+
+def _fake_processes(monkeypatch, procs):
+ monkeypatch.setattr(connections, "_processes", lambda: procs)
+
+
+@pytest.mark.parametrize("client_id,relpath,entry_type", [
+ ("copilot_cli", ".copilot/mcp-config.json", "local"),
+ ("kiro", ".kiro/settings/mcp.json", None),
+ ("amazon_q", ".aws/amazonq/mcp.json", None),
+])
+def test_new_mcp_clients_write_their_native_format(home, client_id, relpath, entry_type):
+ connections.change(client_id, "on", ("mcp",))
+ entry = json.loads((home / relpath).read_text())["mcpServers"]["openworkgraph"]
+ assert entry["args"][-2:] == ["--client", client_id]
+ assert entry.get("type") == entry_type
+ if client_id == "copilot_cli":
+ assert entry["tools"] == ["*"] and entry["env"] == {}
+
+
+def test_codex_observe_reports_restart_until_engine_restarts(home, monkeypatch):
+ (home / ".codex").mkdir()
+ (home / ".codex" / "config.toml").write_text('model = "x"\n')
+ _fake_processes(monkeypatch, [(CHATGPT_CODEX, [CHATGPT_CODEX, "app-server"], time.time() - 86400)])
+
+ status = connections.change("codex", "on")
+ assert status["observe"]["restart_needed"]["app"] == "the ChatGPT app"
+ # Context is reloaded per chat by Codex, so it never asks for a restart.
+ assert "restart_needed" not in status["mcp"]
+
+ _fake_processes(monkeypatch, [(CHATGPT_CODEX, [CHATGPT_CODEX, "app-server"], time.time() + 5)])
+ assert "restart_needed" not in connections.client_status("codex")["observe"]
+
+ _fake_processes(monkeypatch, [])
+ assert "restart_needed" not in connections.client_status("codex")["observe"]
+
+
+def test_restart_uses_backup_time_for_installs_made_before_tracking(home, monkeypatch):
+ # Installs from older versions have no recorded time; the newest backup
+ # (written right before every managed change) stands in for it.
+ config = home / ".codex" / "config.toml"
+ config.parent.mkdir()
+ config.write_text('model = "x"\n')
+ connections.change("codex", "on", ("observe",))
+ data = json.loads((home.parent / "data" / "connections.json").read_text())
+ data.pop("installed_at")
+ (home.parent / "data" / "connections.json").write_text(json.dumps(data))
+ connections._CACHE.update(mtime=None, data={})
+ _fake_processes(monkeypatch, [("", [CHATGPT_CODEX], time.time() - 3600)])
+ assert list(config.parent.glob("config.toml.owg-backup-*"))
+ assert connections.client_status("codex")["observe"]["restart_needed"]
+
+
+def test_claude_desktop_matcher_ignores_helpers_and_claude_code():
+ main = "/Applications/Claude.app/Contents/MacOS/Claude"
+ helper = "/Applications/Claude.app/Contents/Frameworks/Claude Helper.app/Contents/MacOS/Claude Helper"
+ assert connections._claude_desktop_process(main, [main]) == "Claude Desktop"
+ assert connections._claude_desktop_process(helper, [helper]) == ""
+ assert connections._claude_desktop_process("/usr/local/bin/claude", ["claude"]) == ""
+ assert connections._codex_process("", ["/usr/local/bin/codex", "exec"]) == "Codex"
+ assert connections._codex_process("", ["/Applications/ChatGPT.app/Contents/Resources/codex-code-mode-host"]) == ""
+
+
+def test_hidden_frameworks_follow_observe_switch(home):
+ assert connections.hidden_frameworks() == {"claude-code", "codex"} # nothing installed
+ connections.change("claude_code", "on", ("observe",))
+ assert connections.hidden_frameworks() == {"codex"}
+ connections.change("claude_code", "off", ("observe",))
+ assert "claude-code" in connections.hidden_frameworks()
+
+
+def test_agent_traces_hide_disconnected_history_only_when_asked(tmp_path):
+ env = os.environ.copy()
+ env.update({
+ "WORKFLOW_OBSERVER_DATA": str(tmp_path / "data"),
+ "WORKFLOW_OBSERVER_AUTH_DIR": str(tmp_path / "auth"),
+ "WORKFLOW_OBSERVER_MODE": "observe",
+ "OWG_CONNECTIONS_HOME": str(tmp_path / "home"),
+ })
+ (tmp_path / "home").mkdir()
+ code = r'''
+from datetime import datetime, timezone
+from fastapi.testclient import TestClient
+from server.local_auth import ensure_api_token
+from server.agent_auth import ensure_agent_ingest_token
+import server.agent_dashboard_control_plane
+from server.secure_app import app
+
+auth={'Authorization':f'Bearer {ensure_api_token()}'}
+agent={'Authorization':f'Bearer {ensure_agent_ingest_token()}'}
+now=datetime.now(timezone.utc).isoformat()
+def event(eid, framework, name):
+ return {'event_id':eid,'observed_at':now,'agent_name':name,'provider':'x','framework':framework,
+ 'operation':'tool_call','status':'success','observation_level':'native_trace','run_id':eid,
+ 'trace_id':eid,'span_id':'','tool_name':'Bash','tool_category':'shell','duration_seconds':0.0}
+with TestClient(app) as client:
+ client.post('/v1/connections',headers=auth,json={'client':'claude_code','kind':'observe','action':'on'})
+ r=client.post('/agent-ingest/v1/events',headers=agent,json={'events':[event('c1','claude-code','Claude Code'),event('x1','custom','My Agent')]})
+ assert r.status_code==200, r.text
+ client.post('/v1/connections',headers=auth,json={'client':'claude_code','kind':'observe','action':'off'})
+ url='/v1/agent-execution-traces?limit=50&evidence_limit=25000&max_events_per_execution=5'
+ names=lambda p:sorted({(x.get('agent') or {}).get('name') for x in p['executions']})
+ everything=client.get(url,headers=auth).json()
+ assert names(everything)==['Claude Code','My Agent'], names(everything)
+ filtered=client.get(url+'&hide_disconnected=true',headers=auth).json()
+ assert names(filtered)==['My Agent'], names(filtered)
+ assert 'claude-code' in filtered['hidden_frameworks']
+'''
+ result = subprocess.run([sys.executable, "-c", code], cwd=ROOT, env=env, text=True,
+ capture_output=True, timeout=90)
+ assert result.returncode == 0, f"stdout={result.stdout}\nstderr={result.stderr}"