diff --git a/computer-use/observer.js b/computer-use/observer.js
index dd8d196..92b2178 100644
--- a/computer-use/observer.js
+++ b/computer-use/observer.js
@@ -21,8 +21,8 @@ function timed(promise, ms, label) {
}
export class DesktopObserver {
- constructor({ screenshot = new PortalScreenshot(), normalizer = new FrameNormalizer(), atspi = new AtspiProvider(), shell = new ShellProvider(), ocr, vision, tmpDir = '/tmp/jarvis-cu', timeouts = {} } = {}) {
- this.screenshot = screenshot; this.normalizer = normalizer; this.atspi = atspi; this.shell = shell; this.ocr = ocr; this.vision = vision; this.tmpDir = tmpDir;
+ constructor({ screenshot = new PortalScreenshot(), normalizer = new FrameNormalizer(), atspi = new AtspiProvider(), shell = new ShellProvider(), framebuffer, ocr, vision, tmpDir = '/tmp/jarvis-cu', timeouts = {} } = {}) {
+ this.screenshot = screenshot; this.normalizer = normalizer; this.atspi = atspi; this.shell = shell; this.framebuffer = framebuffer; this.ocr = ocr; this.vision = vision; this.tmpDir = tmpDir;
this.lastTree = [];
this.timeouts = { screenshot: timeouts.screenshot ?? 8000, tree: timeouts.tree ?? 5000, shell: timeouts.shell ?? 2000, ocr: timeouts.ocr ?? 8000, vision: timeouts.vision ?? 8000 };
}
@@ -34,8 +34,15 @@ export class DesktopObserver {
}
async _captureFrame() {
- const rawPath = await this.screenshot.capture(path.join(this.tmpDir, `observe-${Date.now()}.png`));
- return this.normalizer.normalize(rawPath);
+ const dest = path.join(this.tmpDir, `observe-${Date.now()}.png`);
+ if (this.framebuffer?.capture) {
+ const rawPath = await this.framebuffer.capture(dest);
+ const frame = await this.normalizer.normalize(rawPath);
+ return { ...frame, source: 'pipewire' };
+ }
+ const rawPath = await this.screenshot.capture(dest);
+ const frame = await this.normalizer.normalize(rawPath);
+ return { ...frame, source: 'screenshot' };
}
async _shellSnapshot() {
@@ -49,7 +56,7 @@ export class DesktopObserver {
const unavailable = [];
const note = (label) => (error) => { unavailable.push(`${label}: ${error.message}`); return null; };
const [frame, shell, tree] = await Promise.all([
- timed(this._captureFrame(), this.timeouts.screenshot, 'screenshot').catch(note('screenshot')),
+ timed(this._captureFrame(), this.timeouts.screenshot, 'frame').catch(note('frame')),
timed(this._shellSnapshot(), this.timeouts.shell, 'Shell helper').catch(note('Shell helper')),
includeTree ? this.tree().catch((error) => { unavailable.push(`AT-SPI: ${error.message}`); return []; }) : Promise.resolve([]),
]);
@@ -60,6 +67,7 @@ export class DesktopObserver {
windows: windows.windows || [],
tree,
screenshot_path: frame?.path || null,
+ frame_source: frame?.source || null,
ocr_blocks: [],
vision_hint: null,
unavailable,
@@ -75,7 +83,9 @@ export class DesktopObserver {
const node = ref ? this.lastTree.find((item) => item.ref === ref) : null;
const target = rect || node?.rect;
if (!target) throw new Error('rect or current tree ref is required');
- const raw = await this.screenshot.capture(path.join(this.tmpDir, `zoom-${Date.now()}.png`));
+ const raw = this.framebuffer?.capture
+ ? await this.framebuffer.capture(path.join(this.tmpDir, `zoom-${Date.now()}.png`))
+ : await this.screenshot.capture(path.join(this.tmpDir, `zoom-${Date.now()}.png`));
const frame = await this.normalizer.normalize(raw, path.join(this.tmpDir, `zoom-${Date.now()}.webp`), target);
return { rect: target, screenshot_path: frame.path };
}
diff --git a/computer-use/portal-input.js b/computer-use/portal-input.js
index 879f9bc..6c2e4f0 100644
--- a/computer-use/portal-input.js
+++ b/computer-use/portal-input.js
@@ -1,22 +1,23 @@
import { spawn } from 'node:child_process';
import path from 'node:path';
-/** Wayland input boundary. The helper owns portal consent and the EIS fd;
- * Node sends only bounded JSON actions and never uses hidden uinput. */
+/** Wayland input and ScreenCast boundary. The helper owns portal consent, the
+ * EIS fd, and the PipeWire remote; Node sends only bounded JSON actions. */
export class PortalInputBackend {
constructor({ command = process.env.JARVIS_EI_HELPER || path.resolve(new URL('./py/portal_remote_desktop.py', import.meta.url).pathname), python = 'python3', spawnImpl = spawn, timeoutMs = 120_000 } = {}) {
this.command = command; this.python = python; this.spawnImpl = spawnImpl; this.timeoutMs = timeoutMs;
- this.process = null; this.available = false; this._grant = null;
+ this.process = null; this.available = false; this.screen = false; this._grant = null; this._frameWait = null;
}
- grant({ persist = false, monitors = 'focused' } = {}) {
+ grant({ persist = false, monitors = 'focused', mode = 'act' } = {}) {
if (this._grant) return this._grant;
- const child = this.process = this.spawnImpl(this.python, [this.command], { stdio: ['pipe', 'pipe', 'pipe'] });
+ const child = this.process = this.spawnImpl(this.python, [this.command], { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, JARVIS_CU_MODE: mode } });
this._grant = new Promise((resolve, reject) => {
let buffer = ''; let settled = false;
const timer = setTimeout(() => fail(new Error('portal input consent timed out')), this.timeoutMs);
const fail = (error) => {
clearTimeout(timer);
- if (this.process === child) { this.available = false; this.process = null; this._grant = null; }
+ if (this.process === child) { this.available = false; this.screen = false; this.process = null; this._grant = null; }
+ this._rejectFrame(error);
if (!settled) { settled = true; reject(error); }
child.kill('SIGTERM');
};
@@ -24,7 +25,8 @@ export class PortalInputBackend {
child.on('error', fail);
child.once('close', () => {
clearTimeout(timer);
- if (this.process === child) { this.available = false; this.process = null; this._grant = null; }
+ if (this.process === child) { this.available = false; this.screen = false; this.process = null; this._grant = null; }
+ this._rejectFrame(new Error('portal helper exited'));
if (!settled) { settled = true; reject(new Error('portal input helper exited before readiness')); }
});
child.stdin?.on('error', fail);
@@ -37,10 +39,15 @@ export class PortalInputBackend {
const line = buffer.slice(0, index); buffer = buffer.slice(index + 1);
let event;
try { event = JSON.parse(line); } catch { continue; }
- if (event.type === 'error') { fail(new Error(event.reason || 'portal input unavailable')); return; }
+ if (event.type === 'error') {
+ if (!settled) { fail(new Error(event.reason || 'portal input unavailable')); return; }
+ this._rejectFrame(new Error(event.reason || 'portal helper error'));
+ continue;
+ }
+ if (event.type === 'frame') { this._resolveFrame(event.path || event); continue; }
if (event.type === 'ready' && this.process === child && !settled) {
- clearTimeout(timer); settled = true; this.available = true;
- resolve({ restore_token_present: Boolean(event.restore_token_present), monitors, backend: event.backend || 'portal-ei' });
+ clearTimeout(timer); settled = true; this.available = true; this.screen = Boolean(event.screen);
+ resolve({ restore_token_present: Boolean(event.restore_token_present), monitors, backend: event.backend || 'portal-ei', screen: this.screen });
}
}
});
@@ -48,5 +55,19 @@ export class PortalInputBackend {
return this._grant;
}
send(action) { if (!this.available || !this.process?.stdin?.writable) throw new Error('portal EIS input backend is unavailable'); this.process.stdin.write(`${JSON.stringify(action)}\n`); }
- revoke() { this._cancelGrant?.(); this._cancelGrant = null; this.available = false; this.process = null; this._grant = null; }
+ captureFrame(output) {
+ if (!this.available || !this.process?.stdin?.writable) throw new Error('PipeWire ScreenCast is unavailable until desktop access is granted');
+ if (this._frameWait) throw new Error('a PipeWire frame grab is already in flight');
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => this._rejectFrame(new Error('PipeWire frame timed out')), 8000);
+ this._frameWait = {
+ resolve: (value) => { clearTimeout(timer); resolve(value); },
+ reject: (error) => { clearTimeout(timer); reject(error); },
+ };
+ try { this.send({ type: 'frame', path: output }); } catch (error) { this._rejectFrame(error); }
+ });
+ }
+ _resolveFrame(path) { const wait = this._frameWait; this._frameWait = null; wait?.resolve(path); }
+ _rejectFrame(error) { const wait = this._frameWait; this._frameWait = null; wait?.reject(error); }
+ revoke() { this._cancelGrant?.(); this._cancelGrant = null; this.available = false; this.screen = false; this.process = null; this._grant = null; }
}
diff --git a/computer-use/py/portal_remote_desktop.py b/computer-use/py/portal_remote_desktop.py
index 0486ab8..6321189 100644
--- a/computer-use/py/portal_remote_desktop.py
+++ b/computer-use/py/portal_remote_desktop.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-"""RemoteDesktop consent broker with a built-in libei injector."""
+"""RemoteDesktop + ScreenCast broker: EIS input and a live PipeWire frame buffer."""
import json
import os
import select
@@ -17,8 +17,10 @@ except Exception as exc:
raise SystemExit(1)
from libei_sender import LibeiSender
+from pw_framebuffer import PipeWireFrameBuffer
BTN = {'left': 0x110, 'right': 0x111, 'middle': 0x112}
+MODE = os.environ.get('JARVIS_CU_MODE', 'act')
def log_error(reason):
@@ -26,14 +28,21 @@ def log_error(reason):
bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
-proxy = Gio.DBusProxy.new_sync(
- bus, Gio.DBusProxyFlags.NONE, None,
- 'org.freedesktop.portal.Desktop', '/org/freedesktop/portal/desktop',
- 'org.freedesktop.portal.RemoteDesktop', None,
-)
-def request(method, signature, values, timeout_ms=120000):
+def portal_proxy(iface):
+ return Gio.DBusProxy.new_sync(
+ bus, Gio.DBusProxyFlags.NONE, None,
+ 'org.freedesktop.portal.Desktop', '/org/freedesktop/portal/desktop',
+ iface, None,
+ )
+
+
+remote = portal_proxy('org.freedesktop.portal.RemoteDesktop')
+screencast = portal_proxy('org.freedesktop.portal.ScreenCast')
+
+
+def request(proxy, method, signature, values, timeout_ms=120000):
request_path = proxy.call_sync(method, GLib.Variant(signature, values), Gio.DBusCallFlags.NONE, timeout_ms, None).unpack()[0]
loop = GLib.MainLoop()
result = {'code': 1, 'results': {}}
@@ -50,10 +59,10 @@ def request(method, signature, values, timeout_ms=120000):
return result['results']
-def connect_to_eis(session):
+def unix_fd(proxy, method, session):
incoming = Gio.UnixFDList.new()
variant, outgoing = proxy.call_with_unix_fd_list_sync(
- 'ConnectToEIS',
+ method,
GLib.Variant('(oa{sv})', (session, {})),
Gio.DBusCallFlags.NONE,
15000,
@@ -70,11 +79,22 @@ def connect_to_eis(session):
fd = unpacked[0] if isinstance(unpacked, (tuple, list)) else unpacked
if isinstance(fd, int) and fd >= 0:
return fd
- raise RuntimeError('ConnectToEIS returned no file descriptor')
+ raise RuntimeError(f'{method} returned no file descriptor')
def notify(method, signature, values):
- proxy.call_sync(method, GLib.Variant(signature, values), Gio.DBusCallFlags.NONE, 5000, None)
+ remote.call_sync(method, GLib.Variant(signature, values), Gio.DBusCallFlags.NONE, 5000, None)
+
+
+def stream_node_id(streams):
+ if not streams:
+ raise RuntimeError('ScreenCast Start returned no PipeWire streams')
+ first = streams[0]
+ if isinstance(first, (list, tuple)):
+ return int(first[0])
+ if isinstance(first, dict):
+ return int(first.get('node_id') or first.get('id'))
+ return int(first)
class PortalNotify:
@@ -115,6 +135,30 @@ class PortalNotify:
raise RuntimeError(f'unsupported portal notify action {kind} {name}')
+class SessionHandler:
+ def __init__(self, input_handler, framebuffer):
+ self.input_handler = input_handler
+ self.framebuffer = framebuffer
+
+ def dispatch(self):
+ if hasattr(self.input_handler, 'dispatch'):
+ self.input_handler.dispatch()
+
+ def handle(self, action):
+ if action.get('type') == 'frame':
+ if not self.framebuffer:
+ raise RuntimeError('PipeWire ScreenCast is not attached')
+ path = action.get('path')
+ if not path:
+ raise RuntimeError('frame path is required')
+ self.framebuffer.capture(path)
+ print(json.dumps({'type': 'frame', 'path': path, 'source': 'pipewire'}), flush=True)
+ return
+ if self.input_handler is None:
+ raise RuntimeError('desktop input is observe-only')
+ self.input_handler.handle(action)
+
+
def run_loop(handler, extra_fd=None):
stdin_fd = sys.stdin.fileno()
while True:
@@ -140,58 +184,93 @@ def run_loop(handler, extra_fd=None):
log_error(exc)
+def attach_screencast():
+ token = f'jarvissc{GLib.get_real_time()}'
+ session = request(screencast, 'CreateSession', '(a{sv})', ({'session_handle_token': GLib.Variant('s', token)},))['session_handle']
+ request(screencast, 'SelectSources', '(oa{sv})', (session, {
+ 'types': GLib.Variant('u', 1),
+ 'multiple': GLib.Variant('b', False),
+ 'cursor_mode': GLib.Variant('u', 2),
+ 'persist_mode': GLib.Variant('u', 2),
+ }))
+ return session
+
+
+def start_framebuffer(sc_session):
+ results = request(screencast, 'Start', '(osa{sv})', (sc_session, '', {}))
+ node_id = stream_node_id(results.get('streams') or [])
+ fd = unix_fd(screencast, 'OpenPipeWireRemote', sc_session)
+ return PipeWireFrameBuffer(fd, node_id)
+
+
injector = None
sender = None
+framebuffer = None
session = None
try:
- token = f'jarvis{GLib.get_real_time()}'
- session = request('CreateSession', '(a{sv})', ({'session_handle_token': GLib.Variant('s', token)},))['session_handle']
- request('SelectDevices', '(oa{sv})', (session, {'types': GLib.Variant('u', 7), 'persist_mode': GLib.Variant('u', 2)}))
- request('Start', '(osa{sv})', (session, '', {}))
- backend = 'none'
+ sc_session = attach_screencast()
+ input_handler = None
extra_fd = None
- handler = None
+ backend = 'none'
eis_error = None
+ frame_error = None
+ if MODE != 'observe':
+ token = f'jarvisrd{GLib.get_real_time()}'
+ session = request(remote, 'CreateSession', '(a{sv})', ({'session_handle_token': GLib.Variant('s', token)},))['session_handle']
+ request(remote, 'SelectDevices', '(oa{sv})', (session, {'types': GLib.Variant('u', 7), 'persist_mode': GLib.Variant('u', 2)}))
+ request(remote, 'Start', '(osa{sv})', (session, '', {}))
+ try:
+ eis_fd = unix_fd(remote, 'ConnectToEIS', session)
+ sender = LibeiSender(eis_fd)
+ if not sender.wait_ready():
+ raise RuntimeError('libei connected but no pointer or keyboard device appeared')
+ input_handler = sender
+ extra_fd = sender.fd
+ backend = 'portal-ei'
+ except Exception as exc:
+ eis_error = str(exc)
+ if sender:
+ try:
+ sender.close()
+ except Exception:
+ pass
+ sender = None
+ if input_handler is None and os.environ.get('JARVIS_LIBEI_BRIDGE'):
+ injector = subprocess.Popen(os.environ['JARVIS_LIBEI_BRIDGE'], shell=True, stdin=subprocess.PIPE, text=True)
+
+ class Bridge:
+ def handle(self, action):
+ injector.stdin.write(json.dumps(action) + '\n')
+ injector.stdin.flush()
+
+ input_handler = Bridge()
+ backend = 'portal-ei'
+ if input_handler is None:
+ input_handler = PortalNotify(session)
+ backend = 'portal-notify'
try:
- eis_fd = connect_to_eis(session)
- sender = LibeiSender(eis_fd)
- if not sender.wait_ready():
- raise RuntimeError('libei connected but no pointer or keyboard device appeared')
- handler = sender
- extra_fd = sender.fd
- backend = 'portal-ei'
+ framebuffer = start_framebuffer(sc_session)
except Exception as exc:
- eis_error = str(exc)
- if sender:
- try:
- sender.close()
- except Exception:
- pass
- sender = None
- if handler is None and os.environ.get('JARVIS_LIBEI_BRIDGE'):
- injector = subprocess.Popen(os.environ['JARVIS_LIBEI_BRIDGE'], shell=True, stdin=subprocess.PIPE, text=True)
-
- class Bridge:
- def handle(self, action):
- injector.stdin.write(json.dumps(action) + '\n')
- injector.stdin.flush()
-
- handler = Bridge()
- backend = 'portal-ei'
- if handler is None:
- handler = PortalNotify(session)
- backend = 'portal-notify'
+ frame_error = str(exc)
+ framebuffer = None
print(json.dumps({
'type': 'ready',
- 'session': session,
+ 'session': session or sc_session,
'restore_token_present': True,
'backend': backend,
+ 'screen': framebuffer is not None,
'eis_error': eis_error,
+ 'frame_error': frame_error,
}), flush=True)
- run_loop(handler, extra_fd)
+ run_loop(SessionHandler(input_handler, framebuffer), extra_fd)
except Exception as exc:
log_error(exc)
finally:
+ if framebuffer:
+ try:
+ framebuffer.close()
+ except Exception:
+ pass
if sender:
try:
sender.close()
diff --git a/computer-use/py/pw_framebuffer.py b/computer-use/py/pw_framebuffer.py
new file mode 100644
index 0000000..545681a
--- /dev/null
+++ b/computer-use/py/pw_framebuffer.py
@@ -0,0 +1,57 @@
+"""Pull one RGB frame from a portal PipeWire ScreenCast stream."""
+from PIL import Image
+
+
+class PipeWireFrameBuffer:
+ def __init__(self, fd, node_id):
+ import gi
+ gi.require_version('Gst', '1.0')
+ from gi.repository import Gst
+ Gst.init(None)
+ self.Gst = Gst
+ self.pipeline = Gst.parse_launch(
+ f'pipewiresrc fd={int(fd)} path={int(node_id)} always-copy=true do-timestamp=true '
+ 'client-name=jarvis ! videoconvert ! video/x-raw,format=RGB ! '
+ 'appsink name=sink max-buffers=1 drop=true sync=false enable-last-sample=true'
+ )
+ self.sink = self.pipeline.get_by_name('sink')
+ if not self.sink:
+ raise RuntimeError('GStreamer appsink missing')
+ self.pipeline.set_state(Gst.State.PLAYING)
+ change, state, _pending = self.pipeline.get_state(5 * Gst.SECOND)
+ if change == Gst.StateChangeReturn.FAILURE or state != Gst.State.PLAYING:
+ self.close()
+ raise RuntimeError('PipeWire ScreenCast pipeline failed to play')
+
+ def capture(self, path, timeout_ms=4000):
+ sample = self.sink.emit('try-pull-sample', timeout_ms * self.Gst.MSECOND)
+ if sample is None:
+ sample = self.sink.get_property('last-sample')
+ if sample is None:
+ raise RuntimeError('no PipeWire frame yet')
+ buf = sample.get_buffer()
+ caps = sample.get_caps().get_structure(0)
+ width = int(caps.get_value('width'))
+ height = int(caps.get_value('height'))
+ ok, mapped = buf.map(self.Gst.MapFlags.READ)
+ if not ok:
+ raise RuntimeError('could not map PipeWire frame')
+ try:
+ data = bytes(mapped.data)
+ finally:
+ buf.unmap(mapped)
+ stride = max(width * 3, len(data) // max(1, height))
+ if stride != width * 3:
+ rows = [data[i * stride:i * stride + width * 3] for i in range(height)]
+ data = b''.join(rows)
+ Image.frombytes('RGB', (width, height), data[:width * height * 3]).save(path)
+ return path
+
+ def close(self):
+ if getattr(self, 'pipeline', None):
+ try:
+ self.pipeline.set_state(self.Gst.State.NULL)
+ except Exception:
+ pass
+ self.pipeline = None
+ self.sink = None
diff --git a/daemon/harness-bridge.js b/daemon/harness-bridge.js
index 231aa4c..2e3cb3d 100644
--- a/daemon/harness-bridge.js
+++ b/daemon/harness-bridge.js
@@ -35,7 +35,7 @@ export class HarnessBridge extends EventEmitter {
...createPhase9GatewayTool(),
...tools,
],
- builtinTools: ['read_file', 'list_dir', 'grep', 'run_terminal_cmd', 'web_fetch', 'web_search'],
+ builtinTools: ['read_file', 'list_dir', 'grep', 'run_terminal_cmd', 'web_fetch', 'fetch_page', 'google_search', 'web_search', 'wiki_search', 'hn_search', 'code_search'],
webFetch: true,
permissionMode,
origin: 'jarvis-qvac',
diff --git a/daemon/index.js b/daemon/index.js
index 163e6b4..b7988ef 100644
--- a/daemon/index.js
+++ b/daemon/index.js
@@ -35,7 +35,11 @@ export class JarvisDaemon extends EventEmitter {
this.computer = new ComputerUseSession({ audit: this.audit, stepsMax: this.settings.computerSteps, grantMinutes: this.settings.computerGrantMinutes, mode: this.settings.computerMode });
this.input = new PortalInputBackend();
this.perception = new QvacPerception();
- this.observer = new DesktopObserver({ normalizer: new FrameNormalizer({ maxLongEdge: this.settings.screenshotMaxEdge, quality: this.settings.screenshotQuality }), ocr: (image) => this.perception.ocr(image) });
+ this.observer = new DesktopObserver({
+ normalizer: new FrameNormalizer({ maxLongEdge: this.settings.screenshotMaxEdge, quality: this.settings.screenshotQuality }),
+ ocr: (image) => this.perception.ocr(image),
+ framebuffer: { capture: (output) => this.input.captureFrame(output) },
+ });
this.actuator = new ComputerActuator({ session: this.computer, input: this.input, find: ({ ref }) => this.observer.lastTree.filter((node) => node.ref === ref), atspiAction: (target, action) => this.observer.atspi.action(target, action), highlight: async (target, action) => this.emit('ComputerHighlight', JSON.stringify({ rect: target?.rect || null, label: `${action} ${target?.name || ''}` })) , audit: this.audit });
this.harness = new HarnessBridge({ cwd: process.cwd(), computer: this.computer, observer: this.observer, actuator: this.actuator, fsAccess: this.settings.fsAccess });
this.log = new PrivacyLog();
@@ -185,7 +189,7 @@ export class JarvisDaemon extends EventEmitter {
await this.voiceLoop.ensureAsr?.();
}
cancel() { this._askGeneration += 1; this.voiceLoop?.interrupt?.(); this.harness.cancel(); this.scheduler.cancelQueued((job) => job.lane === 'voice'); this.computerRevoke(); this.voice.cancel(); this.setState('ARMED'); cancelQvac().catch((error) => this.emit('Error', 'QVAC_CANCEL', error.message)); }
- computerGrant(persist = false) { const result = this.computer.grant({ persist }); this.emit('ComputerStep', JSON.stringify({ action: 'grant', ...result })); if (this.settings.computerMode === 'observe') return result; this.input.grant({ persist }).then((backend) => { this.computer.setBackend(backend.backend); this.emit('ComputerStep', JSON.stringify({ action: 'backend', ...backend })); }).catch((error) => this.emit('Error', 'CU_GRANT', error.message)); return result; }
+ computerGrant(persist = false) { const result = this.computer.grant({ persist }); this.emit('ComputerStep', JSON.stringify({ action: 'grant', ...result })); this.input.grant({ persist, mode: this.settings.computerMode }).then((backend) => { this.computer.setBackend(backend.backend); this.emit('ComputerStep', JSON.stringify({ action: 'backend', ...backend })); }).catch((error) => this.emit('Error', 'CU_GRANT', error.message)); return result; }
computerRevoke() { this.input.revoke(); this.computer.revoke(); this.emit('ComputerStep', JSON.stringify({ action: 'revoke' })); }
startVoice() {
if (this._voiceStarting) return this._voiceStarting;
diff --git a/docs/computer-use.md b/docs/computer-use.md
index e4dfae7..815dfbc 100644
--- a/docs/computer-use.md
+++ b/docs/computer-use.md
@@ -41,9 +41,11 @@ sequenceDiagram
The primary Wayland input path is XDG RemoteDesktop plus EIS/libei. After
portal consent, Jarvis opens `ConnectToEIS` and injects pointer/keyboard
-events through a built-in libei sender. `JARVIS_LIBEI_BRIDGE` is an optional
-override. If EIS is unavailable, the helper falls back to portal Notify
-methods instead of refusing readiness. Screenshot frames are downscaled for vision,
+events through a built-in libei sender. Observation reads the live PipeWire
+ScreenCast stream (the compositor frame buffer), not the Screenshot portal.
+`JARVIS_LIBEI_BRIDGE` is an optional override. If EIS is unavailable, the helper
+falls back to portal Notify methods instead of refusing readiness. Frames are
+downscaled for vision,
kept in temporary storage, and removed on revoke unless trace retention is
enabled.
diff --git a/skills/computer-observe.js b/skills/computer-observe.js
index 398dd73..3413883 100644
--- a/skills/computer-observe.js
+++ b/skills/computer-observe.js
@@ -4,7 +4,7 @@ const inactive = (computer) => !computer?.status?.().active ? { unavailable: 'co
export function createComputerObserveTools({ computer, observer } = {}) {
const guard = () => { const result = inactive(computer); if (result) throw new Error(result.unavailable); };
return [
- { name: 'cu_observe', permission: PERMISSION, description: 'Observe the local desktop through the portal, AT-SPI, and the accessibility tree. OCR is off unless include_ocr is true.', parameters: { type: 'object', properties: { include_tree: { type: 'boolean' }, include_ocr: { type: 'boolean' }, include_vision: { type: 'boolean' } } }, execute: async ({ include_tree = true, include_ocr = false, include_vision = false } = {}) => { guard(); return observer.observe({ includeTree: include_tree, includeOcr: include_ocr, includeVision: include_vision }); } },
+ { name: 'cu_observe', permission: PERMISSION, description: 'Read the live PipeWire ScreenCast frame buffer from the granted desktop session, plus AT-SPI. This is not the Screenshot portal. OCR is off unless include_ocr is true.', parameters: { type: 'object', properties: { include_tree: { type: 'boolean' }, include_ocr: { type: 'boolean' }, include_vision: { type: 'boolean' } } }, execute: async ({ include_tree = true, include_ocr = false, include_vision = false } = {}) => { guard(); return observer.observe({ includeTree: include_tree, includeOcr: include_ocr, includeVision: include_vision }); } },
{ name: 'cu_zoom', permission: PERMISSION, description: 'Request a higher resolution local crop by rectangle or current AT-SPI ref.', parameters: { type: 'object', properties: { rect: { type: 'array', items: { type: 'number' } }, ref: { type: 'string' } } }, execute: async (args) => { guard(); return observer.zoom(args); } },
{ name: 'cu_tree', permission: PERMISSION, description: 'Return visible AT-SPI roles, names, states, bounds, and per-step refs.', parameters: { type: 'object', properties: { app: { type: 'string' }, focused_only: { type: 'boolean' }, max_nodes: { type: 'number' } } }, execute: async ({ focused_only = true, max_nodes = 400 } = {}) => { guard(); return observer.tree({ focusedOnly: focused_only, maxNodes: max_nodes }); } },
{ name: 'cu_find', permission: PERMISSION, description: 'Find a visible desktop element by accessible name/role.', parameters: { type: 'object', properties: { query: { type: 'string' }, role: { type: 'string' } }, required: ['query'] }, execute: async (args) => { guard(); return observer.find(args); } },
diff --git a/skills/voice-prompt.js b/skills/voice-prompt.js
index 4ed4631..91c1f84 100644
--- a/skills/voice-prompt.js
+++ b/skills/voice-prompt.js
@@ -9,11 +9,11 @@ Tool names, tool arguments, paths, and U R L strings use ordinary spelling. Only
Thinking is private. After thoughts, call a tool or speak the answer. Do not stop in thoughts or say you will search later.
Ground desktop, file, memory, model, and network claims in a tool result. Do not invent limits the tools did not report.
-This computer can reach the internet. web_search and web_fetch are unrestricted and do not wait for confirmation. Never say you will use a tool. Call the tool instead of announcing it. Use web_search to find pages. After web_search, call web_fetch on one real http or https page from the hits, then speak the answer. DuckDuckGo redirect links are not an answer. For this computer's public I P, call web_fetch on https://ifconfig.me/ip first. Use web_fetch for any website or I P lookup page. Do not use curl, wget, or run_terminal_cmd for websites. The shell blocks public H T T P; that is not a network outage. If a shell result says HTTP access is not allowed, call web_fetch or web_search next and answer from that result. Never say the network is unavailable unless web_fetch or web_search itself failed.
+This computer can reach the internet. web_search, google_search, fetch_page, web_fetch, wiki_search, hn_search, and code_search are unrestricted and do not wait for confirmation. Never say you will use a tool. Call the tool instead of announcing it. Use web_search to find pages. Default engine auto tries several backends. If it fails, call web_search again with engine set to bing, jina, wikipedia, duckduckgo, or google. After web_search, call fetch_page on one real http or https page from the hits, then speak the answer. Use web_fetch for raw pages and this computer's public I P at https://ifconfig.me/ip. Use wiki_search, hn_search, or code_search when the question is about Wikipedia, Hacker News, GitHub, npm, or M D N. Redirect links are not an answer. Do not use curl, wget, or run_terminal_cmd for websites. The shell blocks public H T T P; that is not a network outage. If a shell result says HTTP access is not allowed, call web_fetch or web_search next and answer from that result. Never say the network is unavailable unless web_fetch or web_search itself failed.
File tools may read any path they accept. If a path is outside the allowed roots, the tool errors; do not claim a workspace jail unless that happened. Writes, including fs_write and overwrite, still need confirmation.
-Computer use requires an explicit user grant from Settings, Computer use, Allow now, or Grant desktop in the tray. After a grant, call cu_observe to see the screen. Do not wait for a libei injector. Never click or type while it is inactive, locked, or revoked. Never ask for passwords or credentials.
+Computer use requires an explicit user grant from Settings, Computer use, Allow now, or Grant desktop in the tray. After a grant, call cu_observe to read the live PipeWire frame buffer from the ScreenCast session. Do not take a screenshot. Do not wait for a libei injector. Never click or type while it is inactive, locked, or revoked. Never ask for passwords or credentials.
Destructive actions require confirmation in both the heads-up display and spoken conversation.
Prefer structured tools and accessibility references over coordinates.
diff --git a/test/computer-use.test.js b/test/computer-use.test.js
index 0ac8fb7..048f064 100644
--- a/test/computer-use.test.js
+++ b/test/computer-use.test.js
@@ -56,7 +56,7 @@ test('computer use refuses password targets and unconfirmed dangerous keys', asy
test('libei sender binds bitmask capabilities from libei.h', () => {
const pyDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../computer-use/py');
- const compiled = spawnSync('python3', ['-m', 'py_compile', 'libei_sender.py', 'portal_remote_desktop.py', 'portal_screenshot.py'], { cwd: pyDir, encoding: 'utf8' });
+ const compiled = spawnSync('python3', ['-m', 'py_compile', 'libei_sender.py', 'portal_remote_desktop.py', 'portal_screenshot.py', 'pw_framebuffer.py'], { cwd: pyDir, encoding: 'utf8' });
assert.equal(compiled.status, 0, compiled.stderr);
const caps = spawnSync('python3', ['-c', 'from libei_sender import CAP_POINTER, CAP_KEYBOARD, CAP_BUTTON, F_KEYS; assert CAP_POINTER == 1 and CAP_KEYBOARD == 4 and CAP_BUTTON == 32 and F_KEYS["f11"] == 87'], { cwd: pyDir, encoding: 'utf8' });
assert.equal(caps.status, 0, caps.stderr);
diff --git a/test/daemon.test.js b/test/daemon.test.js
index 3ee9b4a..e1df688 100644
--- a/test/daemon.test.js
+++ b/test/daemon.test.js
@@ -80,7 +80,12 @@ test('harness bridge caps voice shell chaining', () => {
'grep',
'run_terminal_cmd',
'web_fetch',
+ 'fetch_page',
+ 'google_search',
'web_search',
+ 'wiki_search',
+ 'hn_search',
+ 'code_search',
]);
assert.equal(bridge.options.webFetch, true);
});
diff --git a/test/observer.test.js b/test/observer.test.js
index a9429c2..81ef720 100644
--- a/test/observer.test.js
+++ b/test/observer.test.js
@@ -11,6 +11,7 @@ test('Phase 6 observer returns stable refs and ranked semantic matches', async (
});
const bundle = await observer.observe({ includeOcr: false });
assert.equal(bundle.screenshot_path, '/tmp/frame.webp'); assert.equal(bundle.tree[0].ref, 'r1');
+ assert.equal(bundle.frame_source, 'screenshot');
assert.equal((await observer.find({ query: 'night light' }))[0].ref, 'r1');
const zoom = await observer.zoom({ ref: 'r1' }); assert.deepEqual(zoom.rect, [10, 20, 30, 30]);
});
@@ -31,6 +32,21 @@ test('observer returns when the Shell helper never connects', async () => {
assert.match(bundle.unavailable.join(' '), /Shell helper/);
});
+test('observer reads the PipeWire frame buffer instead of the Screenshot portal', async () => {
+ let shots = 0;
+ const observer = new DesktopObserver({
+ screenshot: { capture: async () => { shots += 1; return '/tmp/portal.png'; } },
+ framebuffer: { capture: async () => '/tmp/pw.png' },
+ normalizer: { normalize: async (input) => ({ path: `${input}.webp` }) },
+ shell: { windows: async () => ({ available: true, windows: [] }), focused: async () => null },
+ atspi: { tree: async () => [] },
+ });
+ const bundle = await observer.observe({ includeTree: false, includeOcr: false });
+ assert.equal(shots, 0);
+ assert.equal(bundle.frame_source, 'pipewire');
+ assert.equal(bundle.screenshot_path, '/tmp/pw.png.webp');
+});
+
test('observer returns after a hung screenshot instead of staying on THINKING', async () => {
const observer = new DesktopObserver({
screenshot: { capture: () => new Promise(() => {}) },
@@ -42,11 +58,11 @@ test('observer returns after a hung screenshot instead of staying on THINKING',
const bundle = await observer.observe({ includeTree: false, includeOcr: false });
assert.ok(Date.now() - started < 1000);
assert.equal(bundle.screenshot_path, null);
- assert.match(bundle.unavailable.join(' '), /screenshot/);
+ assert.match(bundle.unavailable.join(' '), /frame/);
});
test('Phase 6 observer reports portal failure instead of inventing a frame', async () => {
const observer = new DesktopObserver({ screenshot: { capture: async () => { throw new Error('no session bus'); } }, shell: { windows: async () => ({ available: false, windows: [] }), focused: async () => null }, atspi: { tree: async () => [] } });
const bundle = await observer.observe({ includeTree: false, includeOcr: false });
- assert.equal(bundle.screenshot_path, null); assert.match(bundle.unavailable[0], /screenshot/);
+ assert.equal(bundle.screenshot_path, null); assert.match(bundle.unavailable[0], /frame/);
});
diff --git a/test/review-regressions.test.js b/test/review-regressions.test.js
index 91d6233..4173640 100644
--- a/test/review-regressions.test.js
+++ b/test/review-regressions.test.js
@@ -111,6 +111,31 @@ test('portal helper reports a notify fallback as ready', async () => {
assert.equal((await grant).backend, 'portal-notify');
});
+test('portal helper returns a PipeWire frame without ending the grant', async () => {
+ const child = helper(); let written = '';
+ child.stdin.write = (chunk) => { written += String(chunk); };
+ const input = new PortalInputBackend({ spawnImpl: () => child });
+ const grant = input.grant();
+ child.stdout.emit('data', '{"type":"ready","backend":"portal-ei","screen":true}\n');
+ assert.equal((await grant).screen, true);
+ const frame = input.captureFrame('/tmp/pw.png');
+ assert.match(written, /"type":"frame"/);
+ child.stdout.emit('data', '{"type":"frame","path":"/tmp/pw.png","source":"pipewire"}\n');
+ assert.equal(await frame, '/tmp/pw.png');
+ assert.equal(input.available, true);
+});
+
+test('a PipeWire frame error does not revoke desktop access', async () => {
+ const child = helper(); const input = new PortalInputBackend({ spawnImpl: () => child });
+ const grant = input.grant();
+ child.stdout.emit('data', '{"type":"ready","backend":"portal-notify","screen":false}\n');
+ await grant;
+ const frame = input.captureFrame('/tmp/pw.png');
+ child.stdout.emit('data', '{"type":"error","reason":"no PipeWire frame yet"}\n');
+ await assert.rejects(frame, /no PipeWire frame/);
+ assert.equal(input.available, true);
+});
+
test('portal grants wait for a complete readiness message and clear on exit', async () => {
const child = helper(); const input = new PortalInputBackend({ spawnImpl: () => child });
const grant = input.grant();
diff --git a/test/runtime-tools.test.js b/test/runtime-tools.test.js
index ddabd13..b7c6536 100644
--- a/test/runtime-tools.test.js
+++ b/test/runtime-tools.test.js
@@ -54,11 +54,108 @@ test('web_fetch times out instead of hanging the turn', async () => {
}
});
+test('google_search parses lite HTML and falls back to DuckDuckGo then Bing', async () => {
+ const require = createRequire(import.meta.url);
+ const tools = require('../vendor/agent-harness/agent/tools.js');
+ const parsed = tools.parseGoogleHits(
+ '