Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
9c25aa0
Capture coverage: Copilot, Gemini CLI and Cursor observe; clipboard w…
KAVentures Sep 28, 2026
90bee15
Merge branch 'fix/capture-correctness' into feat/capture-coverage
KAVentures Sep 28, 2026
abdbe37
Merge branch 'fix/capture-correctness' into feat/capture-coverage
KAVentures Sep 28, 2026
f8bb48c
Merge branch 'fix/capture-correctness' into feat/capture-coverage
KAVentures Sep 28, 2026
0b4802c
Merge branch 'fix/capture-correctness' into feat/capture-coverage
KAVentures Sep 28, 2026
bb0b3eb
Manual setup: Cursor gets its own note (hooks), not the path-token note
KAVentures Sep 28, 2026
2850161
Test: ephemeral history keeps Cursor and Copilot sessions across turns
KAVentures Sep 28, 2026
9dfdf44
Test isolation: ephemeral-retention test uses its own data folder
KAVentures Sep 28, 2026
8cae059
Make Cursor observation truly non-blocking
KAVentures Sep 28, 2026
f2ceee1
Tighten structural web-agent run detection
KAVentures Sep 28, 2026
7b96de6
Release capture coverage as v0.99.0
KAVentures Sep 28, 2026
80eb60a
Align Python package version for v0.99.0
KAVentures Sep 28, 2026
675bb95
Align MCP bundle version for v0.99.0
KAVentures Sep 28, 2026
a4dc84c
Align Python agent SDK version for v0.99.0
KAVentures Sep 28, 2026
bd66dd0
Align Node agent SDK version for v0.99.0
KAVentures Sep 28, 2026
6f072e2
Expect v0.99.0 release version
KAVentures Sep 28, 2026
f9a965f
Harden OTLP preset ownership and token rotation
KAVentures Sep 28, 2026
5e59260
Preserve unknown Gemini tool outcomes
KAVentures Sep 28, 2026
4b49eb4
Reject truncated or concatenated OTLP gzip bodies
KAVentures Sep 28, 2026
00cb11a
Add capture coverage hardening regressions
KAVentures Sep 28, 2026
b2d5111
Merge latest capture-correctness lineage into coverage
KAVentures Sep 28, 2026
f2b465e
Align structural web-agent tests with conservative composer detection
KAVentures Sep 28, 2026
deabddc
Fix structural busy-state test stand-in
KAVentures Sep 28, 2026
2f0e763
Carry Observe-aware spooling into coverage branch
KAVentures Sep 28, 2026
a0779b2
Carry Observe-off spool regression into coverage branch
KAVentures Sep 28, 2026
48dc626
Map Gemini session and agent lifecycle boundaries
KAVentures Sep 28, 2026
b7c8648
Test Gemini session and agent lifecycle mapping
KAVentures Sep 28, 2026
edd9acb
Detect Copilot OTel environment overrides before setup
KAVentures Sep 28, 2026
293c08e
Test Copilot effective OTel override detection
KAVentures Sep 28, 2026
3b94165
Scope web-agent structural signals to conversation surfaces
KAVentures Sep 28, 2026
1a6a72e
Test scoped structural web-agent detection
KAVentures Sep 28, 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
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.98.0
0.99.0
2 changes: 1 addition & 1 deletion adapters/_agent_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ def post_agent_events(events: list[dict], *, timeout: float = 0.75, spool: bool
return {"status": "ignored", "received": 0}
try:
framework = str(events[0].get("framework") or "") if isinstance(events[0], dict) else ""
channel = "claude_code_hooks" if framework == "claude-code" else "agent_events"
channel = {"claude-code": "claude_code_hooks", "cursor": "cursor_hooks"}.get(framework, "agent_events")
return post_json("/agent-ingest/v1/events", {"events": events}, timeout=timeout, channel=channel)
except HTTPError as exc:
if exc.code < 500 or not spool:
Expand Down
133 changes: 133 additions & 0 deletions adapters/cursor_hook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
from __future__ import annotations

"""Cursor hook bridge for structural OpenWorkGraph evidence.

Cursor runs hooks synchronously and reads their stdout. The foreground hook
therefore does only privacy-safe projection, writes Cursor's non-blocking reply,
then hands the already-allowlisted structural events to a detached helper process.
Network delivery/spooling never sits on Cursor's synchronous hook path.

Raw prompt text, tool arguments/results, workspace paths and error text are never
passed to the helper. Any failure still exits 0.
"""

import json
import os
import subprocess
import sys

from adapters.claude_code_hook import PROJECT_ROOT, _shell_quote
from shared.cursor_hook_adapter import SUPPORTED_EVENTS, cursor_hook_to_agent_events

OWG_MARKER = "adapters.cursor_hook"
HOOK_TIMEOUT_SECONDS = 5
MAX_DELIVERY_BYTES = 256_000


def hook_command(command: str | None = None) -> str:
python = _shell_quote(command or sys.executable)
root = _shell_quote(PROJECT_ROOT)
if os.name == "nt":
return f"cd /d {root} && {python} -m {OWG_MARKER}"
return f"cd {root} && {python} -m {OWG_MARKER}"


def hooks_fragment(command: str | None = None) -> dict:
handler = {"command": hook_command(command), "timeout": HOOK_TIMEOUT_SECONDS}
return {"version": 1, "hooks": {event: [dict(handler)] for event in SUPPORTED_EVENTS}}


def _reply(hook: str) -> None:
sys.stdout.write(json.dumps({"continue": True} if hook == "beforeSubmitPrompt" else {}))
sys.stdout.flush()


def _debug_notice() -> None:
if os.getenv("OWG_AGENT_ADAPTER_DEBUG", "").strip() == "1":
print("OpenWorkGraph Cursor hook skipped one event", file=sys.stderr)


def _deliver(events: list[dict]) -> int:
"""Detached helper entrypoint; input is already privacy-safe structural data."""
try:
from adapters._agent_client import post_agent_events

post_agent_events(events, timeout=0.5)
except Exception:
_debug_notice()
return 0


def _spawn_delivery(events: list[dict]) -> None:
if not events:
return
try:
raw = json.dumps(events, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
except Exception:
return
if len(raw) > MAX_DELIVERY_BYTES:
return

kwargs: dict = {
"cwd": PROJECT_ROOT,
"stdin": subprocess.PIPE,
"stdout": subprocess.DEVNULL,
"stderr": subprocess.DEVNULL,
"close_fds": True,
}
if os.name == "nt":
kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
else:
kwargs["start_new_session"] = True

try:
child = subprocess.Popen([sys.executable, "-m", OWG_MARKER, "--deliver"], **kwargs)
if child.stdin is not None:
child.stdin.write(raw)
child.stdin.close()
except Exception:
# Observation must never make Cursor depend on OpenWorkGraph.
try:
child.kill() # type: ignore[name-defined]
except Exception:
pass


def main(argv: list[str] | None = None) -> int:
argv = list(argv or [])
if "--print-hooks" in argv:
print(json.dumps(hooks_fragment(), indent=2))
return 0

if "--deliver" in argv:
try:
raw = sys.stdin.buffer.read(MAX_DELIVERY_BYTES + 1)
if len(raw) > MAX_DELIVERY_BYTES:
return 0
value = json.loads(raw.decode("utf-8"))
events = [item for item in value if isinstance(item, dict)] if isinstance(value, list) else []
except Exception:
events = []
return _deliver(events)

hook = ""
try:
raw = sys.stdin.buffer.read(2_000_001)
payload = json.loads(raw.decode("utf-8")) if len(raw) <= 2_000_000 else {}
hook = str(payload.get("hook_event_name") or "") if isinstance(payload, dict) else ""
except Exception:
payload = {}

# Cursor's decision is returned before any network/spool work. Projection is
# local and allowlisted; delivery happens in a separate process.
_reply(hook)
try:
events = cursor_hook_to_agent_events(payload)
_spawn_delivery(events)
except Exception:
_debug_notice()
return 0


if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
91 changes: 81 additions & 10 deletions browser_extension/agent_surface_adapters.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@
{key:'lovable', name:'Lovable', hosts:['lovable.dev']},
{key:'gemini', name:'Gemini', hosts:['gemini.google.com']},
];
// Structural signals come first and work in any UI language. Busy/send signals
// are scoped to the conversation/composer surface when structure is available,
// so an unrelated spinner or feedback form elsewhere on the page cannot start
// an agent run. English labels remain only a compatibility fallback.
const BUSY_SELECTOR='[aria-busy="true"],[data-is-streaming="true"],button[data-testid*="stop" i]';
const SEND_TESTID=/(^|[-_])(send|submit)([-_]|$)/i;
const STOP_TESTID=/(^|[-_])(stop|cancel)([-_]|$)/i;
const COMPOSER_TESTID=/(composer|prompt|chat[-_]?input|message[-_]?input)/i;
const COMPOSER_CONTAINER_SELECTOR='[data-testid*="composer" i],[data-testid*="prompt" i],[data-testid*="chat-input" i],[data-testid*="message-input" i]';
const COMPOSER_INPUT_SELECTOR='textarea,[contenteditable="true"],[role="textbox"]';
const STOP_RE=/^(stop|cancel)( generating| response| task| run)?$/i;
const SEND_RE=/^(send|submit|ask|run|build|generate)( prompt| message| request)?$/i;
const APPROVE_RE=/^(allow|approve|confirm|continue|accept)$/i;
Expand All @@ -20,23 +30,83 @@
if(!el)return '';
return String(el.getAttribute?.('aria-label')||el.getAttribute?.('title')||el.textContent||'').replace(/\s+/g,' ').trim().slice(0,80);
}
function buttonLike(el){return !!el?.closest?.('button,[role="button"],input[type="submit"]');}
function controlOf(el){return el?.closest?.('button,[role="button"],input[type="submit"]')||null;}
function buttonLike(el){return !!controlOf(el);}
function testId(el){return String(el?.getAttribute?.('data-testid')||'');}
function usable(el){
if(!el)return false;
return el.hidden!==true&&String(el.getAttribute?.('aria-hidden')||'').toLowerCase()!=='true'&&el.disabled!==true;
}
function isComposer(el){
if(!el||typeof el.closest!=='function')return false;
const tag=String(el.tagName||'').toLowerCase();
return tag==='textarea'||el.isContentEditable===true||String(el.getAttribute?.('contenteditable')||'')==='true'
||String(el.getAttribute?.('role')||'')==='textbox';
}
function formHasComposer(form){
if(!form||typeof form.querySelectorAll!=='function')return false;
return [...form.querySelectorAll(COMPOSER_INPUT_SELECTOR)].some(isComposer);
}
function mainHasComposer(main){return !!main&&typeof main.querySelectorAll==='function'&&[...main.querySelectorAll(COMPOSER_INPUT_SELECTOR)].some(isComposer);}
function composerSurfaceOf(el){
if(!el||typeof el.closest!=='function')return null;
const marked=el.closest(COMPOSER_CONTAINER_SELECTOR);
if(marked)return marked;
const form=el.closest('form');
if(form&&formHasComposer(form))return form;
const main=el.closest('main,[role="main"]');
if(main&&mainHasComposer(main))return main;
return null;
}
function structurallyComposer(el){return isComposer(el)&&!!composerSurfaceOf(el);}
function surfaceRoots(doc){
if(!doc||typeof doc.querySelectorAll!=='function')return [];
const roots=[];
for(const composer of doc.querySelectorAll(COMPOSER_INPUT_SELECTOR)){
if(!isComposer(composer))continue;
const root=composerSurfaceOf(composer);
if(root&&!roots.includes(root))roots.push(root);
}
return roots;
}
function controlInComposerSurface(control){return !!composerSurfaceOf(control);}
function hasBusyState(doc){
if(!doc)return false;
if(doc.querySelector('[aria-busy="true"]'))return true;
return [...doc.querySelectorAll('button,[role="button"]')].some(el=>STOP_RE.test(accessibleName(el)));
// Never treat a page-global aria-busy region as an agent run. Prefer the
// nearest structural conversation surface around a real composer.
for(const root of surfaceRoots(doc)){
if(typeof root.querySelectorAll==='function'&&[...root.querySelectorAll(BUSY_SELECTOR)].some(usable))return true;
}
// Compatibility fallback: a literal English stop control was the old signal.
return [...doc.querySelectorAll('button,[role="button"]')].some(el=>usable(el)&&STOP_RE.test(accessibleName(el)));
}
// Enter is considered a send only in a structurally identified message
// composer. This avoids treating unrelated textareas/editors on agent sites as
// new runs.
function isComposerSend(ev){
return ev?.key==='Enter'&&!ev.shiftKey&&!ev.altKey&&!ev.ctrlKey&&!ev.metaKey&&!ev.isComposing&&structurallyComposer(ev.target);
}
function hasVisibleErrorState(doc){return !!doc?.querySelector?.('[role="alert"][aria-live], [role="alert"]');}
function hasApprovalState(doc){
if(!doc)return false;
for(const root of doc.querySelectorAll('dialog,[role="dialog"]')){
if([...root.querySelectorAll('button,[role="button"]')].some(el=>APPROVE_RE.test(accessibleName(el))))return true;
if([...root.querySelectorAll('button,[role="button"]')].some(el=>usable(el)&&APPROVE_RE.test(accessibleName(el))))return true;
}
return false;
}
function isSendControl(el){const control=buttonLike(el);return !!control&&SEND_RE.test(accessibleName(control));}
function isStopControl(el){const control=buttonLike(el);return !!control&&STOP_RE.test(accessibleName(control));}
function isApprovalControl(el){const control=buttonLike(el);return !!control&&APPROVE_RE.test(accessibleName(control))&&!!control.closest('dialog,[role="dialog"]');}
function isSendControl(el){
const control=controlOf(el);if(!control||!usable(control))return false;
if(SEND_TESTID.test(testId(control))&&controlInComposerSurface(control))return true;
const type=String(control.getAttribute?.('type')||'').toLowerCase();
if(type==='submit'&&control.form&&formHasComposer(control.form))return true;
return SEND_RE.test(accessibleName(control));
}
function isStopControl(el){
const control=controlOf(el);if(!control||!usable(control))return false;
if(STOP_TESTID.test(testId(control))&&controlInComposerSurface(control))return true;
return STOP_RE.test(accessibleName(control));
}
function isApprovalControl(el){const control=controlOf(el);return !!control&&usable(control)&&APPROVE_RE.test(accessibleName(control))&&!!control.closest('dialog,[role="dialog"]');}
function safeRunId(){
try{return 'web-'+crypto.randomUUID().replaceAll('-','');}catch(_){return 'web-'+Date.now().toString(36)+Math.random().toString(36).slice(2,12);}
}
Expand Down Expand Up @@ -84,14 +154,15 @@
if(runId&&isStopControl(ev.target)){finish('agent_run_cancelled','stop_control');return;}
if(runId&&isApprovalControl(ev.target)){sendLifecycle(provider,'agent_approval_received',runId,'approval_control');approvalSent=true;}
},true);
doc.addEventListener('submit',()=>{begin('form_submit');setTimeout(sample,0);},true);
doc.addEventListener('submit',ev=>{if(formHasComposer(ev.target)){begin('form_submit');setTimeout(sample,0);}},true);
doc.addEventListener('keydown',ev=>{if(isComposerSend(ev)){begin('composer_enter');setTimeout(sample,0);}},true);
const observer=new MutationObserver(()=>sample());
const root=doc.documentElement||doc;observer.observe(root,{subtree:true,childList:true,attributes:true,attributeFilter:['aria-busy','aria-live','role','open','disabled']});
const root=doc.documentElement||doc;observer.observe(root,{subtree:true,childList:true,attributes:true,attributeFilter:['aria-busy','aria-live','role','open','disabled','hidden','aria-hidden','data-is-streaming','data-testid']});
setInterval(()=>{if(!doc.hidden)sample();},1500);
sample();
}

globalThis.__OWG_AGENT_SURFACE_ADAPTERS_FOR_TESTS__={PROVIDERS,providerForHost,accessibleName,hasBusyState,hasVisibleErrorState,hasApprovalState,isSendControl,isStopControl,isApprovalControl,structuralPayload};
globalThis.__OWG_AGENT_SURFACE_ADAPTERS_FOR_TESTS__={PROVIDERS,providerForHost,accessibleName,hasBusyState,hasVisibleErrorState,hasApprovalState,isSendControl,isStopControl,isApprovalControl,isComposerSend,structuralPayload};
if(typeof document!=='undefined'&&typeof MutationObserver!=='undefined'){
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',()=>start(),{once:true});else start();
}
Expand Down
4 changes: 2 additions & 2 deletions browser_extension/manifest.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"manifest_version": 3,
"name": "Workflow Observer Browser Sensor",
"version": "1.12.0",
"version_name": "1.12.0-v94-agent-lifecycle",
"version": "1.13.0",
"version_name": "1.13.0-v99-structural-agent-detection",
"description": "Local-only structural browser telemetry for OpenWorkGraph. Query values/fragments, typed field values, clipboard contents, filenames, file contents, prompts and model responses are not collected.",
"incognito": "not_allowed",
"permissions": ["tabs", "webNavigation", "storage", "alarms"],
Expand Down
Loading
Loading