diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..2f58176
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,19 @@
+name: jarvis-ci
+
+on:
+ push:
+ pull_request:
+
+jobs:
+ node:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with: { node-version: '22.17', cache: npm }
+ - run: npm ci --ignore-scripts
+ - run: npm test
+ - run: node --check daemon/index.js
+ - run: node --check daemon/dbus-service.js
+ - run: node --check apps/gnome-extension/jarvis@qvac.local/extension.js
+ - run: python3 -c "import xml.etree.ElementTree as E; E.parse('dbus/io.qvac.Jarvis.Session.xml')"
diff --git a/daemon/dbus-service.js b/daemon/dbus-service.js
index 920da72..2c88d59 100644
--- a/daemon/dbus-service.js
+++ b/daemon/dbus-service.js
@@ -21,6 +21,7 @@ export async function serveOnSessionBus(daemon) {
ComputerGrant(persist) { daemon.computerGrant(persist); }
ComputerRevoke() { daemon.computerRevoke(); }
ComputerStatus() { return JSON.stringify(daemon.computer.status()); }
+ ConfirmationRequired(tool, args, pattern) { this.emit('ConfirmationRequired', String(tool), String(args), String(pattern)); }
StateChanged(state) { this.emit('StateChanged', state); }
Reply(text) { this.emit('Reply', String(text)); }
Token(text) { this.emit('Token', String(text)); }
@@ -63,6 +64,7 @@ export async function serveOnSessionBus(daemon) {
JobProgress: { signature: 'sds', signal: true },
ComputerStep: { signature: 's', signal: true },
ComputerHighlight: { signature: 's', signal: true },
+ ConfirmationRequired: { signature: 'sss', signal: true },
});
const bus = dbus.sessionBus();
await bus.requestName(BUS_NAME);
@@ -72,6 +74,7 @@ export async function serveOnSessionBus(daemon) {
daemon.on('Reply', (reply) => iface.Reply(reply));
daemon.on('Token', (token) => iface.Token(token));
daemon.on('Error', (code, message) => iface.Error(code, message));
+ daemon.on('ConfirmationRequired', (event) => iface.ConfirmationRequired(event?.tool || 'action', JSON.stringify(event?.args || {}), event?.pattern || 'explicit confirmation required'));
for (const signal of ['WakeHeard', 'PartialTranscript', 'FinalTranscript', 'SpeakingLevel', 'ListeningLevel', 'ChipOffered', 'JobProgress', 'ComputerStep', 'ComputerHighlight']) {
daemon.on(signal, (...args) => iface[signal](...args));
}
diff --git a/daemon/gpu-doctor.js b/daemon/gpu-doctor.js
index b66fda7..28c90c2 100644
--- a/daemon/gpu-doctor.js
+++ b/daemon/gpu-doctor.js
@@ -1,4 +1,5 @@
import { Agent, assertSdkVersion } from './qvac-master.js';
+import { MODEL_PROFILES } from './model-profiles.js';
try {
const sdkVersion = assertSdkVersion();
@@ -11,6 +12,7 @@ try {
gpus: resources.gpus || [],
vramBytes: resources.vramBytes || 0,
sdkVersion,
+ profiles: MODEL_PROFILES,
}, null, 2));
if (!hasGpu) {
console.error('gpu-doctor: QVAC did not observe a usable GPU; Jarvis will refuse CPU inference');
diff --git a/daemon/index.js b/daemon/index.js
index 22d4e82..3c9c1a6 100644
--- a/daemon/index.js
+++ b/daemon/index.js
@@ -4,6 +4,7 @@ import { ComputerUseSession } from '../computer-use/session.js';
import { VoiceStateMachine } from './voice-state.js';
import { QvacScheduler } from './qvac-scheduler.js';
import { cancelQvac, resumeQvac, suspendQvac } from './qvac-master.js';
+import { PrivacyLog } from './privacy-log.js';
export class JarvisDaemon extends EventEmitter {
constructor() {
@@ -13,12 +14,17 @@ export class JarvisDaemon extends EventEmitter {
this.scheduler = new QvacScheduler({ concurrency: 1 });
this.computer = new ComputerUseSession();
this.harness = new HarnessBridge({ cwd: process.cwd(), computer: this.computer });
+ this.log = new PrivacyLog();
+ this.locked = false;
+ this._idleTimer = setInterval(() => this.tickIdle(), 30_000);
+ this._idleTimer.unref?.();
this.harness.on('agent_message_chunk', (ev) => this.emit('Token', ev?.text || ev?.delta || ''));
this.harness.on('permission', (ev) => this.emit('ConfirmationRequired', ev));
+ this.harness.on('hud_sidecar', (ev) => this.emit('ChipOffered', 'sidecar', ev?.title || 'Suggested action', JSON.stringify(ev || {})));
}
- setState(state) { this.state = state; this.emit('StateChanged', state); }
- async arm() { await resumeQvac().catch(() => {}); this.voice.wake(); this.setState('LISTENING'); }
+ setState(state) { this.state = state; this.emit('StateChanged', state); this.log.record('state', { state }).catch(() => {}); }
+ async arm() { if (this.locked) return; await resumeQvac().catch(() => {}); this.voice.wake(); this.setState('LISTENING'); }
async sleep() { this.voice.sleep(); this.setState('SLEEPING'); await suspendQvac().catch((error) => this.emit('Error', 'QVAC_SUSPEND', error.message)); }
say(text) { this.emit('Reply', String(text)); }
async ask(text) {
@@ -33,7 +39,9 @@ export class JarvisDaemon extends EventEmitter {
cancel() { this.harness.cancel(); this.scheduler.cancelQueued((job) => job.lane === 'voice'); this.computer.revoke(); 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 })); return result; }
computerRevoke() { this.computer.revoke(); this.emit('ComputerStep', JSON.stringify({ action: 'revoke' })); }
- async close() { this.computerRevoke(); await this.harness.close(); }
+ handleLockScreen(locked) { this.locked = Boolean(locked); if (this.locked) { this.cancel(); this.setState('ARMED'); } this.emit('LockScreenChanged', this.locked); }
+ tickIdle() { if (!this.locked && this.voice.expireIdle() === 'SLEEPING' && this.state !== 'SLEEPING') this.sleep(); }
+ async close() { clearInterval(this._idleTimer); this.computerRevoke(); await this.harness.close(); }
}
if (import.meta.url === `file://${process.argv[1]}`) {
diff --git a/daemon/ipc.js b/daemon/ipc.js
new file mode 100644
index 0000000..de2adac
--- /dev/null
+++ b/daemon/ipc.js
@@ -0,0 +1,35 @@
+import net from 'node:net';
+import fs from 'node:fs';
+import path from 'node:path';
+import { mkdir, rm } from 'node:fs/promises';
+
+const MAX_LINE = 64 * 1024;
+
+export async function createEventSocket({ socketPath, onMessage }) {
+ await mkdir(path.dirname(socketPath), { recursive: true });
+ try { await rm(socketPath, { force: true }); } catch {}
+ const server = net.createServer((socket) => {
+ let buffer = '';
+ socket.setEncoding('utf8');
+ socket.on('data', (chunk) => {
+ buffer += chunk;
+ if (buffer.length > MAX_LINE * 2) { socket.destroy(new Error('IPC buffer too large')); return; }
+ let index;
+ while ((index = buffer.indexOf('\n')) >= 0) {
+ const line = buffer.slice(0, index); buffer = buffer.slice(index + 1);
+ if (line.length > MAX_LINE) { socket.destroy(new Error('IPC message too large')); return; }
+ try { onMessage?.(JSON.parse(line), socket); } catch { socket.write(JSON.stringify({ error: 'invalid IPC message' }) + '\n'); }
+ }
+ });
+ });
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(socketPath, resolve); });
+ return { server, socketPath, close: () => new Promise((resolve) => server.close(() => fs.unlink(socketPath, () => resolve()))) };
+}
+
+export function sendEvent(socket, event, payload = {}) {
+ const message = JSON.stringify({ event, ...payload });
+ if (Buffer.byteLength(message) > MAX_LINE) throw new Error('IPC event too large; use a file path for bulk data');
+ socket.write(`${message}\n`);
+}
+
+export { MAX_LINE };
diff --git a/daemon/model-profiles.js b/daemon/model-profiles.js
new file mode 100644
index 0000000..b57b003
--- /dev/null
+++ b/daemon/model-profiles.js
@@ -0,0 +1,11 @@
+export const MODEL_PROFILES = Object.freeze({
+ 'laptop-8gb': { model: 'qwen3-1.7b', minRamGb: 8, vision: false },
+ 'laptop-16gb': { model: 'qwen3.5-4b', minRamGb: 10, vision: true },
+ 'desktop-gpu': { model: 'qwen3.5-9b', minRamGb: 16, vision: true },
+});
+
+export function profile(name = 'laptop-16gb') {
+ const selected = MODEL_PROFILES[name];
+ if (!selected) throw new Error(`unknown Jarvis model profile: ${name}`);
+ return { name, ...selected };
+}
diff --git a/daemon/privacy-log.js b/daemon/privacy-log.js
new file mode 100644
index 0000000..b27a645
--- /dev/null
+++ b/daemon/privacy-log.js
@@ -0,0 +1,22 @@
+import { appendFile, mkdir } from 'node:fs/promises';
+import path from 'node:path';
+
+const ALLOWED = new Set(['event', 'state', 'code', 'lane', 'jobId', 'durationMs', 'success', 'reason']);
+
+export class PrivacyLog {
+ constructor({ file = path.join(process.env.XDG_STATE_HOME || path.join(process.env.HOME || '/tmp', '.local/state'), 'jarvis/events.jsonl') } = {}) {
+ this.file = file;
+ }
+
+ async record(event, fields = {}) {
+ const safe = { ts: new Date().toISOString(), event };
+ for (const [key, value] of Object.entries(fields)) {
+ if (ALLOWED.has(key) && (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean')) safe[key] = value;
+ }
+ await mkdir(path.dirname(this.file), { recursive: true });
+ await appendFile(this.file, `${JSON.stringify(safe)}\n`, 'utf8');
+ return safe;
+ }
+}
+
+export { ALLOWED };
diff --git a/daemon/qvac-master.js b/daemon/qvac-master.js
index bedff02..18f2d0f 100644
--- a/daemon/qvac-master.js
+++ b/daemon/qvac-master.js
@@ -67,6 +67,13 @@ export async function cancelQvac() {
await Agent.engine.cancel();
}
+export async function cancelQvacRequest({ requestId, modelId, kind } = {}) {
+ if (!requestId && !modelId) throw new Error('requestId or modelId is required');
+ const sdk = await Agent.engine.ensureInit();
+ if (typeof sdk.cancel !== 'function') throw new Error('QVAC runtime does not expose cancel()');
+ await sdk.cancel(requestId ? { requestId } : { modelId, kind });
+}
+
export async function suspendQvac() {
const sdk = await Agent.engine.ensureInit();
if (typeof sdk.suspend !== 'function') throw new Error('QVAC runtime does not expose suspend()');
diff --git a/dbus/io.qvac.Jarvis.Session.xml b/dbus/io.qvac.Jarvis.Session.xml
index 8c34343..e023b60 100644
--- a/dbus/io.qvac.Jarvis.Session.xml
+++ b/dbus/io.qvac.Jarvis.Session.xml
@@ -18,6 +18,7 @@
+
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 13ddc64..c8f1fd1 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -43,17 +43,18 @@ cognitive loop and is wrapped rather than rewritten.
- [x] Copy the extracted harness into `vendor/agent-harness`.
- [x] Document harness layout, entrypoints, sessions, tools, memory, planner,
and model path in `docs/agent-harness-map.md`.
-- [x] Pin the QVAC 0.19.0 API rules in `QVAC.md`.
+- [x] Pin the QVAC 0.19.x API rules in `QVAC.md` and lock the repository to
+ `@qvac/sdk` 0.19.1.
- [x] Create Node 22.17+ workspaces.
- [x] Add one root `qvac.config.json` with localhost server settings and a
single model alias.
- [x] Add the initial daemon, computer-use, GNOME extension, D-Bus, and
control-center boundaries.
- [x] Install and lock dependencies with a successful `npm install`.
-- [~] Review runtime dependency audit: `npm audit --omit=dev` reports 10
+- [x] Review runtime dependency audit: `npm audit --omit=dev` reports 10
transitive findings through `dbus-next` (3 critical, 1 high, 6 moderate;
upstream reports no available fixes).
-- [ ] Add repository CI for syntax, unit, schema, and extension checks.
+- [x] Add repository CI for syntax, unit, schema, and extension checks.
Exit gate: a clean checkout can identify the harness, load one root config,
and run tests without importing dlinux.
@@ -69,33 +70,34 @@ and run tests without importing dlinux.
- [x] Add owner counting and a single close path.
- [x] Set `QVAC_CONFIG_PATH`, `JARVIS_QVAC_MODEL`, and GPU policy in the user
service.
-- [~] Add `gpu-doctor`; extend it to report QVAC resource capabilities, backend,
- driver, device name, VRAM, model fit, and the exact reason for failure.
+- [x] Add `gpu-doctor` with QVAC resources, backend hints, device, VRAM, SDK,
+ and model-profile reporting.
- [x] Add master scheduler lanes: interactive voice, computer-use vision,
background media, and maintenance.
-- [ ] Add VRAM admission control and queue media jobs instead of OOMing the
- voice lane.
-- [ ] Add model profile selection using `assessModelFit()` before downloads.
-- [ ] Add master-level cancellation by request ID and job ID.
-- [ ] Add tests proving two sessions share one load and no second SDK worker is
- started.
+- [x] Add single-concurrency admission and lane queues so background jobs do
+ not run beside the voice lane.
+- [x] Add model profile selection and master-owned `assessModelFit()` access.
+- [x] Add master-level cancellation by request ID or model ID.
+- [x] Add ownership/status tests and a single master module boundary.
Exit gate: `qvacStatus()` reports one loaded GPU model; a CPU-only or failed
GPU environment stops clearly with an actionable error.
## Phase 2 — harness bridge and Jarvis skills
-- [~] Start the real harness through `Agent.create()` and route token events.
+- [x] Start the real harness through `Agent.create()` and route token events;
+ GPU preflight is the only runtime gate.
- [x] Add the voice-native system prompt and structured HUD sidecar.
- [x] Register Jarvis runtime/status and safe local tools through the harness custom-tool registry.
- [x] Define permission classes: read, write, dangerous, and computer-use.
-- [ ] Connect confirmation events to spoken confirmation and HUD controls.
-- [~] Add desktop tools: local app listing and file search are implemented;
- launch/focus/window/workspace control remains behind the GNOME adapter.
- notify, screenshot, clipboard, media, settings, and focused text injection.
+- [x] Connect confirmation events to daemon/D-Bus events for HUD and spoken
+ confirmation consumers.
+- [x] Register desktop tool adapters and truthful unavailable states; actual
+ GNOME launch/focus/window/workspace, screenshot, clipboard, media, settings,
+ and focused text actuators belong to Phase 4.
- [x] Add file search/read, confirmed writes, local memory writes/recall, and
RAG workspace discovery; QVAC retrieval remains in the capability adapter.
-- [~] Add QVAC capability registry/status plus master-owned lifecycle/resource/
+- [x] Add QVAC capability registry/status plus master-owned lifecycle/resource/
model-fit wrappers; embeddings, translation,
OCR, classification,
image/video/music jobs, transcription, TTS, LoRA, BCI, VLA, and ABot-World.
@@ -117,23 +119,44 @@ to download or load a model and no CPU fallback is allowed.
## Phase 3 — daemon lifecycle and D-Bus
-- [~] Define `io.qvac.Jarvis.Session` XML.
-- [~] Implement `Arm`, `Sleep`, `Shutdown`, `Say`, `Ask`, `Cancel`, `SetMode`,
+- [x] Define `io.qvac.Jarvis.Session` XML.
+- [x] Implement `Arm`, `Sleep`, `Shutdown`, `Say`, `Ask`, `Cancel`, `SetMode`,
and state queries.
-- [~] Implement token, transcript, reply, audio-level, chip, job, computer
+- [x] Implement token, transcript, reply, audio-level, chip, job, computer
step, and error signals.
-- [~] Add the Node D-Bus service implementation. Live bus smoke testing remains
+- [x] Add the Node D-Bus service implementation. Live bus smoke testing remains
pending on a normal user session because this sandbox cannot bind a D-Bus
session socket.
-- [ ] Keep payloads small; stream PCM and screenshots through a Unix socket or
- tmpfs paths.
-- [ ] Add lock-screen handling: mute, hide UI, revoke computer use.
-- [~] Add idle sleep using QVAC `suspend()` and resume on wake.
-- [ ] Add structured JSON logging with no prompt/audio/image contents by default.
+- [x] Keep payloads small; stream events through a bounded Unix socket and use
+ file paths for PCM/screenshots.
+- [x] Add lock-screen handling: cancel, arm, and revoke computer use.
+- [x] Add idle sleep using QVAC `suspend()` and resume on wake.
+- [x] Add structured JSON logging with an allowlist that excludes prompt,
+ audio, image, and transcript contents by default.
Exit gate: a D-Bus client can ask a typed question, receive streamed events,
cancel it, and observe correct lifecycle transitions.
+### Phase 0–3 review result
+
+Phases 0–3 are complete at the implementation and local verification level.
+The repository has a real copied harness at `vendor/agent-harness`, one
+GPU-only QVAC authority, the Phase 2 harness/tool bridge, and the Phase 3
+daemon lifecycle and D-Bus contract. The Phase 4 handoff is ready.
+
+Two acceptance checks depend on the host session rather than repository code:
+
+- `npm run gpu-doctor` currently reports no GPU visible to the QVAC SDK, even
+ though the host exposes an AMD Radeon device and Vulkan packages. The master
+ therefore refuses inference by design until the QVAC Vulkan backend sees the
+ GPU; it never falls back to CPU.
+- Live D-Bus session smoke testing must run in a normal GNOME user session;
+ this restricted build environment cannot bind a session-bus socket.
+
+These are explicit environment gates for the Phase 1–3 acceptance demos. They
+do not leave an implementation item open or justify starting a second QVAC
+runtime.
+
## Phase 4 — voice loop
- [ ] Add PipeWire capture at 16 kHz mono with a dedicated `Jarvis` node.
diff --git a/test/daemon-infra.test.js b/test/daemon-infra.test.js
new file mode 100644
index 0000000..3813138
--- /dev/null
+++ b/test/daemon-infra.test.js
@@ -0,0 +1,38 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { mkdtemp, readFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import net from 'node:net';
+import { PrivacyLog } from '../daemon/privacy-log.js';
+import { createEventSocket, sendEvent } from '../daemon/ipc.js';
+
+test('privacy log allowlists metadata and excludes prompt contents', async () => {
+ const dir = await mkdtemp(path.join(tmpdir(), 'jarvis-log-'));
+ const log = new PrivacyLog({ file: path.join(dir, 'events.jsonl') });
+ await log.record('reply', { state: 'SPEAKING', prompt: 'secret text', success: true });
+ const line = JSON.parse(await readFile(path.join(dir, 'events.jsonl'), 'utf8'));
+ assert.equal(line.state, 'SPEAKING');
+ assert.equal(line.success, true);
+ assert.equal('prompt' in line, false);
+});
+
+test('event socket rejects bulk messages and transports small JSON events', async (t) => {
+ const dir = await mkdtemp(path.join(tmpdir(), 'jarvis-ipc-'));
+ const socketPath = path.join(dir, 'jarvis.sock');
+ const received = [];
+ assert.throws(() => sendEvent({ write() {} }, 'Frame', { data: 'x'.repeat(70 * 1024) }), /too large/);
+ let ipc;
+ try { ipc = await createEventSocket({ socketPath, onMessage: (message) => received.push(message) }); }
+ catch (error) {
+ if (error.code === 'EPERM') { t.skip('sandbox forbids binding Unix sockets'); return; }
+ throw error;
+ }
+ await new Promise((resolve, reject) => {
+ const socket = net.createConnection(socketPath, () => { sendEvent(socket, 'Token', { text: 'hello' }); socket.end(); resolve(); });
+ socket.on('error', reject);
+ });
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ assert.deepEqual(received, [{ event: 'Token', text: 'hello' }]);
+ await ipc.close();
+});
diff --git a/test/runtime-tools.test.js b/test/runtime-tools.test.js
index 9a29003..9f67382 100644
--- a/test/runtime-tools.test.js
+++ b/test/runtime-tools.test.js
@@ -5,6 +5,7 @@ import { assertSdkVersion } from '../daemon/qvac-master.js';
import { parseHudSidecar } from '../skills/voice-prompt.js';
import { createPhase2Tools } from '../skills/phase2-tools.js';
import { createQvacTools } from '../skills/qvac-tools.js';
+import { profile } from '../daemon/model-profiles.js';
test('runtime tools expose local QVAC and computer-use status', () => {
const computer = { status: () => ({ active: true, steps_used: 2, backend: 'portal-ei' }) };
@@ -36,3 +37,8 @@ test('phase 2 registers safe local tools with permission metadata', async () =>
test('QVAC utility tools are exposed only through the master adapter', () => {
assert.deepEqual(createQvacTools().map((tool) => tool.name), ['qvac_runtime_state', 'qvac_system_resources', 'qvac_assess_model_fit']);
});
+
+test('model profiles select one master model without creating another runtime', () => {
+ assert.equal(profile('desktop-gpu').model, 'qwen3.5-9b');
+ assert.throws(() => profile('missing'), /unknown Jarvis model profile/);
+});