rewritable 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -407,7 +407,7 @@ window.ANCHORABLE_TAGS = ANCHORABLE_TAGS; // expose for tests
407
407
  // ─── Storage ────────────────────────────────────────────────────────
408
408
  const RWA = {
409
409
  DB:'rwa_'+DOC_UUID, KEY:'self',
410
- DOC:'rwa_doc', UNDO:'rwa_undo', HIST:'rwa_hist', FSA:'rwa_fsa', STATE:'rwa_state', VAULT:'rwa_vault',
410
+ DOC:'rwa_doc', UNDO:'rwa_undo', HIST:'rwa_hist', FSA:'rwa_fsa', STATE:'rwa_state', VAULT:'rwa_vault', SOURCES:'rwa_sources', HOOK_LOG:'rwa_hook_log',
411
411
  UNDO_CAP:10, HIST_CAP:1000, NUDGE_THRESHOLD:5,
412
412
  K_API:'rwa_apikey', K_MODEL:'rwa_model', K_BACKEND:'rwa_backend',
413
413
  // Per-backend base-URL overrides for local OpenAI-compatible servers.
@@ -486,7 +486,7 @@ try {
486
486
  const canonLF = s => s == null ? '' : String(s).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
487
487
 
488
488
  let _db;
489
- const REQUIRED_STORES = [RWA.DOC, RWA.UNDO, RWA.HIST, RWA.FSA, RWA.STATE, RWA.VAULT];
489
+ const REQUIRED_STORES = [RWA.DOC, RWA.UNDO, RWA.HIST, RWA.FSA, RWA.STATE, RWA.VAULT, RWA.SOURCES, RWA.HOOK_LOG];
490
490
  // User-declared store registry. Persisted to RWA.STATE so declarations survive
491
491
  // reload. The openDB() upgrade handler creates declared stores on every bump.
492
492
  // Populated at bootstrap via loadUserStoreDecls(); mutated by runtime.db.open.
@@ -924,6 +924,15 @@ function emitRuntimeEvent(event, payload) {
924
924
  for (const cb of [...runtimeEvents[event]]) {
925
925
  try { cb(payload); } catch (_) { /* swallow user-cb errors */ }
926
926
  }
927
+ // I8 (v0.9 §9) — fire installed hooks for the mapped lifecycle event. 'modify' (an edit commit,
928
+ // carrying instruction/lensMeta) → on-commit; 'mode' → on-mode-change (with the previous mode).
929
+ // Fire-and-forget: wrapped so a firing failure can never propagate into the commit/mode path.
930
+ try {
931
+ if (typeof fireHooks === 'function') {
932
+ if (event === 'modify') fireHooks('on-commit', { event: 'on-commit', instruction: payload && payload.instruction, lensMeta: payload && payload.lensMeta });
933
+ else if (event === 'mode') { const previous = _lastHookMode; _lastHookMode = payload && payload.mode; fireHooks('on-mode-change', { event: 'on-mode-change', mode: payload && payload.mode, previous }); }
934
+ }
935
+ } catch (_) { /* hook firing never breaks the emitter */ }
927
936
  }
928
937
 
929
938
  // === Cross-container bus (workspace-presence subset) ========================
@@ -1329,9 +1338,21 @@ function workspacePeerKnown(peer, known) {
1329
1338
  return !!(peer && ((peer.uuid && known.has('uuid:' + peer.uuid)) || (peer.file && known.has('file:' + peer.file))));
1330
1339
  }
1331
1340
 
1341
+ function safeWorkspaceHref(url) {
1342
+ // peer.url arrives over the public presence bus from another page in the origin,
1343
+ // so it is untrusted. escRuntimeHtml blocks attribute breakout but NOT a
1344
+ // `javascript:`/`data:` scheme, which would execute on click. Resolve and accept
1345
+ // only navigable web/file schemes; fall back to '#' for anything else or unparsable.
1346
+ try {
1347
+ const u = new URL(url, location.href);
1348
+ if (u.protocol === 'http:' || u.protocol === 'https:' || u.protocol === 'file:') return u.href;
1349
+ } catch (_) {}
1350
+ return '#';
1351
+ }
1352
+
1332
1353
  function workspacePresenceCard(peer, known) {
1333
1354
  const isKnown = workspacePeerKnown(peer, known);
1334
- const href = peer.url || '#';
1355
+ const href = safeWorkspaceHref(peer.url);
1335
1356
  const aff = peer.affordances && peer.affordances.length ? peer.affordances.map(a => a.kind).join(', ') : 'baseline';
1336
1357
  return '<a class="rwa-ws-card rwa-ws-live-card" href="' + escRuntimeHtml(href) + '">' +
1337
1358
  '<span class="rwa-ws-kind">' + escRuntimeHtml(peer.kind || 'document') + '</span>' +
@@ -5098,11 +5119,30 @@ window.submitLens = submitLens;
5098
5119
  // workspaces (audit R7) can carry richer identifiers (e.g. signed source
5099
5120
  // keys per the action-spec) without changing the rwa_hist schema.
5100
5121
  function getActiveActor() {
5122
+ // I12 — when a multi-agent role is active, commits attribute to it (agents:${role}); the role is
5123
+ // the actor of record (rendered as a history badge). Else fall back to the backend/model string.
5124
+ if (activeAgentRole) return 'agents:' + activeAgentRole;
5101
5125
  const backend = sessionStorage.getItem(RWA.K_BACKEND) || 'openrouter';
5102
5126
  if (backend === 'bridge') return 'bridge:claude-p';
5103
5127
  if (backend === 'bridge-session') return 'bridge:claude-session';
5104
5128
  return sessionStorage.getItem(RWA.K_MODEL) || RWA.MODEL;
5105
5129
  }
5130
+ // I12 — the modify() system prompt, keyed by the active agent's role. An active (verified) agent's
5131
+ // system_prompt swaps the per-kind FRAMING; the shared SYSTEM_PROMPT_RULES (tool protocol, frozen-
5132
+ // zone rules, data-rwa-id) are always appended so editing still works. No agent → the singleton.
5133
+ function resolveSystemPrompt() {
5134
+ if (activeAgentRole) {
5135
+ const rec = Array.from(installedAgents.values()).find(a => a.role === activeAgentRole && a.verified);
5136
+ if (rec && typeof rec.manifest.system_prompt === 'string') return rec.manifest.system_prompt + '\n' + SYSTEM_PROMPT_RULES;
5137
+ }
5138
+ return SYSTEM_PROMPT;
5139
+ }
5140
+ // I12 — the role-scoped vault gate: exact vault:<ns> membership in the agent's vault_namespace_set
5141
+ // (mirrors _skVaultAllowed against the agent record instead of the skill's permissions).
5142
+ function _agVaultAllowed(agent, ns) {
5143
+ const set = (agent && Array.isArray(agent.vault_namespace_set)) ? agent.vault_namespace_set : [];
5144
+ return set.indexOf('vault:' + ns) !== -1;
5145
+ }
5106
5146
 
5107
5147
  // Resolve the user-selected backend into a transport config.
5108
5148
  // kind:'openai_compat' covers openrouter/ollama/lmstudio — same wire protocol,
@@ -6304,6 +6344,17 @@ let activeView = null;
6304
6344
  // runtimeDescribe()/runtimeListSkills(). The agent can never write the zone (it is
6305
6345
  // data-rwa-frozen); only the runtime rewrites it (increment 7, registry-aware commit).
6306
6346
  const installedSkills = new Map();
6347
+ // I12 (v0.9 §12) — installed AGENT registry. Map<agentId, {agentId, role, verified, manifest,
6348
+ // envelope}>. Populated at boot from the frozen #rwa-agents zone (readTrustworthyAgents). Identity
6349
+ // is the author key; activeAgentRole is the in-tab active role (null = none; not persisted — boot
6350
+ // starts with no agent active per §12).
6351
+ const installedAgents = new Map();
6352
+ let activeAgentRole = null;
6353
+ // I8 (v0.9 §9) — hook firing state. activeHooks holds skillIds currently executing (re-entrancy
6354
+ // guard, Inv 23); _lastHookMode tracks the prior mode for on-mode-change's `previous`; _hookSeq
6355
+ // makes rwa_hook_log keys unique within a millisecond.
6356
+ const activeHooks = new Set();
6357
+ let _lastHookMode = null, _hookSeq = 0;
6307
6358
 
6308
6359
  const RWA_VIEW_RESERVED_IDS = ['rwa-doc-mount', 'rwa-lens', 'rwa-runtime'];
6309
6360
  const RWA_VIEW_RESERVED_MARKERS = ['rwa:frozen:begin', 'rwa:frozen:end', 'data-rwa-frozen'];
@@ -6475,6 +6526,62 @@ async function _skVerify(env) {
6475
6526
  return !!(await crypto.subtle.verify({ name: 'Ed25519' }, key, _skFromB64(sig), await _skSigningMessage(skill, skill.code)));
6476
6527
  } catch (_) { return false; } // Ed25519 unsupported / bad bytes → verified:false (honest)
6477
6528
  }
6529
+ // ── I12 (v0.9 §12) — rwa-agent/1 canon (LIVE mirror of cli/src/skill-manifest.mjs canonicalAgent/
6530
+ // agentSigningMessage/agentId/verifyAgentEnvelope/validateAgentInstall). An agent is a role-scoped,
6531
+ // signed identity (role + system_prompt + vault_namespace_set; NO code). Same canon as the CLI so a
6532
+ // seed-live verify == the CLI-static verify for the same bytes.
6533
+ const _AG_ROLE_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
6534
+ function _agCanonicalAgent(a) { a = a || {}; return JSON.stringify({ author_pubkey: a.author_pubkey ?? null, description: a.description ?? null, role: a.role ?? null, system_prompt: a.system_prompt ?? null, vault_namespace_set: Array.isArray(a.vault_namespace_set) ? a.vault_namespace_set : [], version: a.version ?? null }); }
6535
+ async function _agSigningMessage(a) { return _skSha256(_skUtf8(_agCanonicalAgent(a))); }
6536
+ async function _agAgentId(role, pubkey) { return _skB64url(await _skSha256(_skConcat(_skUtf8(String(role)), _skNUL, _skUtf8(String(pubkey))))); }
6537
+ async function _agVerify(env) {
6538
+ const sig = env && env.signature;
6539
+ if (!sig) return false;
6540
+ const agent = env.agent || {};
6541
+ try {
6542
+ const key = await crypto.subtle.importKey('raw', _skFromB64(agent.author_pubkey), { name: 'Ed25519' }, false, ['verify']);
6543
+ return !!(await crypto.subtle.verify({ name: 'Ed25519' }, key, _skFromB64(sig), await _agSigningMessage(agent)));
6544
+ } catch (_) { return false; }
6545
+ }
6546
+ function _agPromptInjectionRisk(s) { const p = String(s ?? ''); return p.includes('`') || p.includes('${') || /<\/?DOC>/i.test(p); }
6547
+ function _agValidateInstall(agent, signed) {
6548
+ agent = agent || {}; const errors = [];
6549
+ if (/\0/.test(String(agent.role == null ? '' : agent.role))) errors.push('invalid_agent_id');
6550
+ if (typeof agent.role !== 'string' || !_AG_ROLE_RE.test(agent.role)) errors.push('invalid_role');
6551
+ if (typeof agent.system_prompt !== 'string' || _agPromptInjectionRisk(agent.system_prompt)) errors.push('agent_prompt_injection_risk');
6552
+ const set = agent.vault_namespace_set;
6553
+ if (set != null && !Array.isArray(set)) errors.push('invalid_permission');
6554
+ for (const p of (Array.isArray(set) ? set : [])) {
6555
+ try { if (_skParsePermission(p).tier !== 'vault') errors.push('invalid_permission'); }
6556
+ catch (e) { errors.push(/unknown_permission_tier/.test(e.message) ? 'unknown_permission_tier' : 'invalid_permission'); }
6557
+ }
6558
+ if (!signed) errors.push('unsigned_agent');
6559
+ return { ok: errors.length === 0, errors };
6560
+ }
6561
+ // §12 inter-agent bus message shape (mirror of cli validateAgentMessage/agentMessage). Data-model
6562
+ // only; the request→response choreography is the conductor's job, correlated by id.
6563
+ function _agValidateMessage(m) {
6564
+ m = m || {}; const errors = [];
6565
+ if (m.type !== 'request' && m.type !== 'response') errors.push('invalid_type');
6566
+ if (typeof m.id !== 'string' || !m.id) errors.push('invalid_id');
6567
+ if (typeof m.from_role !== 'string' || !_AG_ROLE_RE.test(m.from_role)) errors.push('invalid_from_role');
6568
+ if (typeof m.to_role !== 'string' || !_AG_ROLE_RE.test(m.to_role)) errors.push('invalid_to_role');
6569
+ if (!('payload' in m)) errors.push('missing_payload');
6570
+ return { ok: errors.length === 0, errors };
6571
+ }
6572
+ function _agBuildMessage(type, fromRole, toRole, payload, id) {
6573
+ const m = { type, id, from_role: fromRole, to_role: toRole, payload };
6574
+ const v = _agValidateMessage(m);
6575
+ if (!v.ok) throw new RwaEditError('invalid_agent_message', null, { errors: v.errors });
6576
+ return m;
6577
+ }
6578
+ // runtime.agents.message — convenience builder from the ACTIVE role. A request mints a fresh
6579
+ // correlation id; a response echoes the request's id (pass it as `id`). The caller publishes it
6580
+ // over the bus (a conductor skill with bus:agents:* permission), per §12.
6581
+ function runtimeAgentMessage(type, toRole, payload, id) {
6582
+ if (!activeAgentRole) throw new RwaEditError('no_active_agent', null, {});
6583
+ return _agBuildMessage(type, activeAgentRole, toRole, payload, id || (type === 'request' ? crypto.randomUUID() : undefined));
6584
+ }
6478
6585
  // Locate the inner HTML of the agent-unreachable <div data-rwa-frozen id="rwa-skills">
6479
6586
  // in the DOC TEXT (boot has the doc string before render). Only the frozen zone is
6480
6587
  // trusted; base64 envelopes contain no </div> so a flat scan is safe.
@@ -6510,6 +6617,87 @@ async function readTrustworthySkills(doc) {
6510
6617
  }
6511
6618
  return out;
6512
6619
  }
6620
+ // I12 (v0.9 §12) — the frozen #rwa-agents zone (coexists with #rwa-skills). Same strict
6621
+ // frozen-attribute read as _skExtractZone so an agent-committed lookalike div can't forge an agent.
6622
+ function _agExtractZone(doc) {
6623
+ const open = /<div\b[^>]*\bid="rwa-agents"[^>]*>/i.exec(String(doc || ''));
6624
+ if (!open || !tagHasFrozenAttr(open[0])) return null;
6625
+ const start = open.index + open[0].length;
6626
+ const end = doc.indexOf('</div>', start);
6627
+ return end < 0 ? null : doc.slice(start, end);
6628
+ }
6629
+ // Parse installed agents from the frozen zone, re-verifying each signature. Soft-fail. Returns a
6630
+ // Map<agentId, {agentId, role, verified, manifest, envelope}>. Mirrors readTrustworthySkills.
6631
+ async function readTrustworthyAgents(doc) {
6632
+ const out = new Map();
6633
+ const zone = _agExtractZone(doc);
6634
+ if (!zone) return out;
6635
+ const blocks = zone.matchAll(/<script\s+type="application\/rwa-agent\+json">([\s\S]*?)<\/script>/g);
6636
+ for (const m of blocks) {
6637
+ let env;
6638
+ try { env = JSON.parse(new TextDecoder().decode(_skFromB64(m[1].trim()))); } catch (_) { continue; }
6639
+ const agent = env && env.agent;
6640
+ if (!agent || typeof agent.role !== 'string') continue;
6641
+ const verified = await _agVerify(env);
6642
+ const id = await _agAgentId(agent.role, agent.author_pubkey);
6643
+ out.set(id, { agentId: id, role: agent.role, verified, manifest: agent, envelope: env });
6644
+ }
6645
+ return out;
6646
+ }
6647
+ // I5 (v0.9 §4) — per-author source index (rwa_sources, keyed by public key):
6648
+ // { pubkey, count, first_seen, name_history:[{ name, first_seen_at_name }] }
6649
+ // name_history is APPEND-ONLY and holds one entry per DISTINCT name a key has published, in first-
6650
+ // seen order (Invariant 22). It anchors author identity across renames: the install dialog shows a
6651
+ // same-key name change ("previously published [old]"). DATES are best-effort — the frozen-zone bytes
6652
+ // carry no timestamps (determinism), so a cleared-IDB reload reconciles names from the in-file
6653
+ // manifests with the reconcile time as first_seen_at_name. Identity itself is always the key.
6654
+ async function _skSourceGet(pubkey) {
6655
+ if (!pubkey) return null;
6656
+ try { return (await idbGet(RWA.SOURCES, pubkey)) || null; } catch (_) { return null; }
6657
+ }
6658
+ // Append (name, now) for a key, creating the record if absent. Idempotent per distinct name.
6659
+ async function _skSourceRecord(pubkey, name, at) {
6660
+ if (!pubkey || !name) return;
6661
+ const now = at || Date.now();
6662
+ let rec = await _skSourceGet(pubkey);
6663
+ if (!rec) rec = { pubkey, count: 0, first_seen: now, name_history: [] };
6664
+ if (!Array.isArray(rec.name_history)) rec.name_history = [];
6665
+ if (!rec.name_history.some(e => e.name === name)) rec.name_history.push({ name, first_seen_at_name: now });
6666
+ rec.count = (rec.count | 0) + 1;
6667
+ try { await idbPut(RWA.SOURCES, rec, pubkey); } catch (_) { /* best-effort; never blocks an install */ }
6668
+ }
6669
+ // Boot reconcile: ensure every (pubkey, name) present in the live registry has a name_history entry.
6670
+ // Rebuilds from the in-file manifests so an IDB-cleared reload still knows the recorded renames.
6671
+ async function runtimeBuildSourceIndex() {
6672
+ const now = Date.now(), byKey = new Map();
6673
+ for (const s of installedSkills.values()) {
6674
+ const pk = s.manifest && s.manifest.author_pubkey;
6675
+ if (!pk || !s.name) continue;
6676
+ if (!byKey.has(pk)) byKey.set(pk, new Set());
6677
+ byKey.get(pk).add(s.name);
6678
+ }
6679
+ for (const [pk, names] of byKey) {
6680
+ let rec = await _skSourceGet(pk);
6681
+ if (!rec) rec = { pubkey: pk, count: 0, first_seen: now, name_history: [] };
6682
+ if (!Array.isArray(rec.name_history)) rec.name_history = [];
6683
+ const known = new Set(rec.name_history.map(e => e.name));
6684
+ let changed = false;
6685
+ for (const nm of names) if (!known.has(nm)) { rec.name_history.push({ name: nm, first_seen_at_name: now }); changed = true; }
6686
+ if ((rec.count | 0) < names.size) { rec.count = names.size; changed = true; }
6687
+ if (changed) { try { await idbPut(RWA.SOURCES, rec, pk); } catch (_) {} }
6688
+ }
6689
+ }
6690
+ // What prior names has this author (key) published, other than the incoming one? Returns the prior
6691
+ // names (newest-first) + the most-recent prior, for the dialog's rename note. A rename surfaces only
6692
+ // when the incoming name is genuinely NEW for the key (so a same-name re-install / I10 update is not
6693
+ // a "rename").
6694
+ async function _skNameChange(pubkey, name) {
6695
+ const rec = await _skSourceGet(pubkey);
6696
+ const hist = (rec && Array.isArray(rec.name_history)) ? rec.name_history : [];
6697
+ const priorNames = hist.filter(e => e.name !== name).map(e => ({ name: e.name, date: e.first_seen_at_name })).reverse();
6698
+ const isRename = priorNames.length > 0 && !hist.some(e => e.name === name);
6699
+ return { priorNames, nameChange: isRename ? { prev: priorNames[0] } : null };
6700
+ }
6513
6701
  function runtimeListSkills() {
6514
6702
  return Array.from(installedSkills.values()).map(s => ({ skillId: s.skillId, kind: s.kind, name: s.name, verified: s.verified }));
6515
6703
  }
@@ -6583,6 +6771,25 @@ function _skVaultAllowed(skill, ns) {
6583
6771
  const perms = (skill.manifest && Array.isArray(skill.manifest.permissions)) ? skill.manifest.permissions : [];
6584
6772
  return perms.indexOf('vault:' + ns) !== -1;
6585
6773
  }
6774
+ // §5 (I1) — the bridge's per-call bus gate: exact-string bus:<topic> match, no wildcards.
6775
+ function _skBusAllowed(skill, topic) {
6776
+ const perms = (skill.manifest && Array.isArray(skill.manifest.permissions)) ? skill.manifest.permissions : [];
6777
+ return perms.indexOf('bus:' + topic) !== -1;
6778
+ }
6779
+ // §6 (I3) — the bridge's per-call fs gate: the (already traversal-checked) path must fall under
6780
+ // a declared fsa:<scope> subtree (left-anchored prefix, no wildcards).
6781
+ function _skFsAllowed(skill, path) {
6782
+ const perms = (skill.manifest && Array.isArray(skill.manifest.permissions)) ? skill.manifest.permissions : [];
6783
+ for (const p of perms) {
6784
+ if (String(p).startsWith('fsa:')) { const scope = String(p).slice(4); if (path === scope || path.startsWith(scope + '/')) return true; }
6785
+ }
6786
+ return false;
6787
+ }
6788
+ // §7 (I4) — the bridge's per-call idb gate: exact-string idb:<store> match, no wildcards.
6789
+ function _skIdbAllowed(skill, store) {
6790
+ const perms = (skill.manifest && Array.isArray(skill.manifest.permissions)) ? skill.manifest.permissions : [];
6791
+ return perms.indexOf('idb:' + store) !== -1;
6792
+ }
6586
6793
 
6587
6794
  // ─── Install dialog (shannon, v0.8 §1) — the trust anchor. Mirrors of the cli
6588
6795
  // consent helpers (permissionToProse/compoundRisk/capabilityScan/levenshtein). ──────
@@ -6601,12 +6808,32 @@ function _skPermProse(perm) {
6601
6808
  if (v === '*') return 'Read and write credentials stored under ANY vault namespace — every credential you have stored. Use only for vault administration.';
6602
6809
  return 'Read and write credentials stored under ' + v + '.';
6603
6810
  }
6811
+ if (s.startsWith('bus:')) {
6812
+ return 'Send and receive messages on the "' + s.slice(4) + '" channel shared with other rewritables on this machine.';
6813
+ }
6814
+ if (s.startsWith('fsa:')) {
6815
+ return 'Read and write files under "' + s.slice(4) + '" in this document\'s private storage.';
6816
+ }
6817
+ if (s.startsWith('idb:')) {
6818
+ return 'Read and write the "' + s.slice(4) + '" data store in this document\'s database.';
6819
+ }
6820
+ if (s.startsWith('hook:')) {
6821
+ const ev = s.slice(5);
6822
+ const when = ev === 'on-commit' ? 'every time the document is saved' : ev === 'on-open' ? 'every time the document opens' : ev === 'on-mode-change' ? 'every time you switch modes' : ev;
6823
+ return 'Run automatically ' + when + ' (no network or credential access).';
6824
+ }
6604
6825
  return s;
6605
6826
  }
6606
6827
  function _skCompoundRisk(perms) {
6607
6828
  perms = Array.isArray(perms) ? perms : [];
6608
- if (perms.some(p => String(p).startsWith('vault:')) && perms.some(p => String(p).startsWith('network:')))
6829
+ const has = (t) => perms.some(p => String(p).startsWith(t + ':'));
6830
+ const hasVault = has('vault'), hasNetwork = has('network'), hasBus = has('bus'), hasFsa = has('fsa'), hasIdb = has('idb');
6831
+ if (hasVault && hasNetwork)
6609
6832
  return 'This skill can both read your stored credentials AND make network requests. A skill with this combination can send credentials to its allowed destination — intentionally or by mistake. Install only if you fully trust this author.';
6833
+ if (hasBus && (hasVault || hasNetwork))
6834
+ return 'This skill can message other rewritables on this machine AND ' + (hasVault ? 'read your stored credentials' : 'make network requests') + '. Together these let it coordinate a multi-step action across your workspace — intentionally or by mistake. Install only if you fully trust this author.';
6835
+ if ((hasFsa || hasIdb) && (hasNetwork || hasVault || hasBus))
6836
+ return 'This skill can ' + (hasFsa ? 'read and write files in this document' : 'read and write this document\'s stored data') + ' AND ' + (hasNetwork ? 'make network requests' : hasVault ? 'read your stored credentials' : 'message other rewritables on this machine') + '. Together these let it move your local data off this document — intentionally or by mistake. Install only if you fully trust this author.';
6610
6837
  return null;
6611
6838
  }
6612
6839
  function _skCapabilityScan(code) {
@@ -6635,6 +6862,26 @@ function _skLevenshtein(a, b) {
6635
6862
  for (let i = 1; i <= m; i++) { const cur = [i]; for (let j = 1; j <= n; j++) cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)); prev = cur; }
6636
6863
  return prev[n];
6637
6864
  }
6865
+ // I5 (v0.9 §4) — Unicode-confusable skeleton. NFKC + toLowerCase fold case, fullwidth forms,
6866
+ // ligatures, and mathematical-alphanumeric letters to ASCII; this baked table folds the
6867
+ // CROSS-SCRIPT homoglyphs NFKC leaves alone (Cyrillic, Greek, Armenian, a few Latin-extended).
6868
+ // CURATED, not the full UTS #39 confusables.txt: every entry maps a non-ASCII glyph that renders
6869
+ // ~identically to an ASCII letter. ASCII→ASCII is NEVER folded (legit "tool"/"toml" stay distinct).
6870
+ // Byte-mirror of cli/src/skill-manifest.mjs CONFUSABLES. Keys are post-NFKC-lowercase codepoints.
6871
+ const _SK_CONFUSABLES = {
6872
+ 'а': 'a', 'е': 'e', 'о': 'o', 'р': 'p', 'с': 'c',
6873
+ 'у': 'y', 'х': 'x', 'к': 'k', 'ѕ': 's', 'і': 'i',
6874
+ 'ј': 'j', 'ԁ': 'd', 'һ': 'h', 'ԛ': 'q', 'ԝ': 'w',
6875
+ 'ѵ': 'v', 'ӏ': 'l', 'ɠ': 'g',
6876
+ 'α': 'a', 'ο': 'o', 'ρ': 'p', 'ε': 'e', 'ι': 'i',
6877
+ 'κ': 'k', 'ν': 'v', 'υ': 'u', 'χ': 'x', 'τ': 't',
6878
+ 'ϲ': 'c', 'ϳ': 'j',
6879
+ 'օ': 'o', 'ո': 'n',
6880
+ 'ı': 'i', 'ɑ': 'a', 'ɡ': 'g',
6881
+ };
6882
+ function _skNormalize(s) { return String(s == null ? '' : s).normalize('NFKC').toLowerCase(); }
6883
+ function _skSkeleton(s) { let out = ''; for (const ch of _skNormalize(s)) out += (_SK_CONFUSABLES[ch] || ch); return out; }
6884
+ function _skSkeletonDistance(a, b) { return _skLevenshtein(_skSkeleton(a), _skSkeleton(b)); }
6638
6885
  // §3.4 install gates (mirror cli validateInstall).
6639
6886
  const _SK_VAULT_NS = /^[a-z0-9_](?:[a-z0-9_-]{0,62}[a-z0-9_])?$/;
6640
6887
  // §4 permission grammar (mirror cli parsePermission) — validates the VALUE, not
@@ -6658,6 +6905,32 @@ function _skParsePermission(p) {
6658
6905
  if (value.length > 64 || !_SK_VAULT_NS.test(value)) throw new Error(`invalid vault namespace: ${value}`);
6659
6906
  return { tier, value };
6660
6907
  }
6908
+ if (tier === 'bus') {
6909
+ // §5 (I1): topic 1–96 chars, start alphanumeric, charset [A-Za-z0-9:_./%-], NOT a
6910
+ // runtime-reserved prefix (rwa_/rwa:/skills:/workspace:). Mirror of cli parsePermission.
6911
+ if (!value || value.length > 96 || !/^[A-Za-z0-9][A-Za-z0-9:_./%-]*$/.test(value) || /^(?:rwa[:_]|skills:|workspace:)/.test(value))
6912
+ throw new Error(`invalid bus topic: ${value}`);
6913
+ return { tier, value };
6914
+ }
6915
+ if (tier === 'fsa') {
6916
+ // §6 (I3): relative OPFS scope — lowercase [a-z0-9_/-], start+end alphanumeric/underscore,
6917
+ // ≤128 chars, no '.'/'..' (excluded by charset), not _rwa/-prefixed. Mirror of cli.
6918
+ if (!value || value.length > 128 || /^_rwa(?:\/|$)/.test(value) || !/^[a-z0-9_](?:[a-z0-9_/-]*[a-z0-9_])?$/.test(value))
6919
+ throw new Error(`invalid fsa scope: ${value}`);
6920
+ return { tier, value };
6921
+ }
6922
+ if (tier === 'idb') {
6923
+ // §7 (I4): store ^[A-Za-z0-9_][A-Za-z0-9_-]{0,62}$ (≤64 octets, no wildcards); never rwa_*
6924
+ // (reserved) or rwa_vault — distinct subcodes so the dialog can explain. Mirror of cli.
6925
+ if (/^rwa_/.test(value)) throw new Error(value === 'rwa_vault' ? 'idb_vault_store_forbidden' : 'idb_reserved_store');
6926
+ if (!/^[A-Za-z0-9_][A-Za-z0-9_-]{0,62}$/.test(value)) throw new Error(`invalid idb store: ${value}`);
6927
+ return { tier, value };
6928
+ }
6929
+ if (tier === 'hook') {
6930
+ // §9 (I8): lifecycle event, exact enum, no wildcards. Unknown event → unknown_permission_tier.
6931
+ if (value === 'on-commit' || value === 'on-open' || value === 'on-mode-change') return { tier, value };
6932
+ throw new Error(`unknown_permission_tier: hook event ${value}`);
6933
+ }
6661
6934
  throw new Error(`unknown_permission_tier: ${tier}`);
6662
6935
  }
6663
6936
  function _skValidateInstall(skill, vr) {
@@ -6672,11 +6945,19 @@ function _skValidateInstall(skill, vr) {
6672
6945
  // F7: validate each permission's VALUE via the grammar, not just the tier.
6673
6946
  for (const p of perms) {
6674
6947
  try { _skParsePermission(p); }
6675
- catch (e) { errors.push(/unknown_permission_tier/.test(e.message) ? 'unknown_permission_tier' : 'invalid_permission'); }
6948
+ catch (e) {
6949
+ const m = e.message;
6950
+ if (/unknown_permission_tier/.test(m)) errors.push('unknown_permission_tier');
6951
+ else if (m === 'idb_reserved_store' || m === 'idb_vault_store_forbidden') errors.push(m); // §7 distinct subcodes
6952
+ else errors.push('invalid_permission');
6953
+ }
6676
6954
  }
6677
6955
  if (skill.kind === 'compute' && perms.length > 0) errors.push('compute_with_permissions');
6956
+ // §9 (I8): a hook is compute-only — only hook:<event> perms; any other tier → compute_with_permissions.
6957
+ if (skill.kind === 'hook' && perms.some(p => { try { return _skParsePermission(p).tier !== 'hook'; } catch (_) { return false; } })) errors.push('compute_with_permissions');
6678
6958
  if (!vr.signed && perms.length > 0) errors.push('unsigned_with_permissions');
6679
- if (skill.kind === 'tool' && !vr.verified) errors.push('unsigned_capability');
6959
+ // Tools AND hooks carry capability (a hook runs autonomously) → must be signed+verified.
6960
+ if ((skill.kind === 'tool' || skill.kind === 'hook') && !vr.verified) errors.push('unsigned_capability');
6680
6961
  return { ok: errors.length === 0, errors };
6681
6962
  }
6682
6963
  // §1 — the structured trust info the dialog renders.
@@ -6684,22 +6965,54 @@ async function runtimeReviewSkill(envelope) {
6684
6965
  const skill = (envelope && envelope.skill) || {};
6685
6966
  const perms = Array.isArray(skill.permissions) ? skill.permissions : [];
6686
6967
  const vr = { signed: !!(envelope && envelope.signature), verified: await _skVerify(envelope) };
6687
- let lookalike = null;
6968
+ let levMatch = null, skelMatch = null;
6688
6969
  for (const s of installedSkills.values()) {
6970
+ if (!s.manifest || s.manifest.author_pubkey === skill.author_pubkey) continue; // same author ≠ impersonation
6689
6971
  const d = _skLevenshtein(s.name, skill.name);
6690
6972
  // A DIFFERENT key with the same/near name is impersonation. Exact-name (d===0) ALWAYS fires —
6691
6973
  // it's the strongest spoof and the §3.3 case lookalike is responsible for; a near miss (1–2 edits)
6692
6974
  // fires only when both names are long enough that 1–2 edits is "similar", not "unrelated".
6693
6975
  const exact = d === 0, near = d >= 1 && d <= 2 && String(skill.name).length >= 4 && String(s.name).length >= 4;
6694
- if (s.manifest && s.manifest.author_pubkey !== skill.author_pubkey && (exact || near)) { lookalike = s.name; break; }
6976
+ if (!levMatch && (exact || near)) levMatch = s.name;
6977
+ // I5 — Unicode-confusable (skeleton) match. Fires only when confusable folding COLLAPSED a real
6978
+ // byte difference (sd < normalized-Levenshtein) — i.e. a cross-script homoglyph that renders
6979
+ // identically — NOT on an honest ASCII near-miss (where sd === ld). That distinction is what lets
6980
+ // an exact ASCII name from a different key WARN (Invariant 10) while a homoglyph BLOCKS.
6981
+ if (!skelMatch) {
6982
+ const sd = _skSkeletonDistance(s.name, skill.name);
6983
+ const ld = _skLevenshtein(_skNormalize(s.name), _skNormalize(skill.name));
6984
+ if (sd <= 1 && sd < ld) skelMatch = s.name;
6985
+ }
6695
6986
  }
6987
+ // skeleton (homoglyph) takes precedence over a plain Levenshtein warn; a signed skeleton match is
6988
+ // a hard BLOCK (the skill carries capability to escalate), an unsigned one only warns.
6989
+ const lookalike = skelMatch || levMatch;
6990
+ const lookalikeKind = skelMatch ? 'skeleton' : (levMatch ? 'levenshtein' : null);
6991
+ const lookalikeBlock = !!(skelMatch && vr.signed);
6992
+ // I10 (Shape C) — the update delta. If this skillId is already installed, surface the
6993
+ // permission change (added/removed, order-invariant case-sensitive set ops) so the dialog
6994
+ // can show it and require explicit re-affirmation — no silent permission escalation on update.
6995
+ let update = { isUpdate: false, added: [], removed: [], changed: false };
6996
+ try {
6997
+ const prevS = installedSkills.get(await _skSkillId(skill.name, skill.author_pubkey));
6998
+ if (prevS) {
6999
+ const oldPerms = (prevS.manifest && Array.isArray(prevS.manifest.permissions)) ? prevS.manifest.permissions : [];
7000
+ const oldSet = new Set(oldPerms), newSet = new Set(perms);
7001
+ const added = perms.filter(p => !oldSet.has(p)), removed = oldPerms.filter(p => !newSet.has(p));
7002
+ update = { isUpdate: true, added: added.map(p => ({ perm: p, prose: _skPermProse(p) })), removed: removed.map(p => ({ perm: p, prose: _skPermProse(p) })), changed: added.length > 0 || removed.length > 0 };
7003
+ }
7004
+ } catch (_) { /* a malformed name/id can't match an install → treat as fresh; gates reject it downstream */ }
7005
+ // I5 — per-author name_history: surface a same-key rename so identity reads across name changes.
7006
+ const nameInfo = await _skNameChange(skill.author_pubkey, skill.name);
6696
7007
  return {
6697
7008
  name: skill.name, version: skill.version, kind: skill.kind,
6698
7009
  purpose: skill.description || '(no description provided)',
6699
7010
  author_pubkey: skill.author_pubkey, signed: vr.signed, verified: vr.verified,
6700
7011
  permissions: perms.map(p => ({ perm: p, prose: _skPermProse(p) })),
6701
7012
  compoundRisk: _skCompoundRisk(perms), scanNotes: _skCapabilityScan(skill.code),
6702
- lookalike, gates: _skValidateInstall(skill, vr),
7013
+ lookalike, lookalikeKind, lookalikeBlock,
7014
+ priorNames: nameInfo.priorNames, nameChange: nameInfo.nameChange,
7015
+ gates: _skValidateInstall(skill, vr), update,
6703
7016
  };
6704
7017
  }
6705
7018
  // §7 — serialize the registry into the frozen #rwa-skills zone. CANONICAL: sorted by
@@ -6739,6 +7052,10 @@ function _skSkillsRegion() {
6739
7052
  async function runtimeInstallSkill(envelope) {
6740
7053
  const review = await runtimeReviewSkill(envelope);
6741
7054
  if (!review.gates.ok) return { ok: false, errors: review.gates.errors };
7055
+ // I5 — a signed skill whose name skeleton-folds to a DIFFERENT author's installed skill is a
7056
+ // homoglyph impersonation: refuse before any code is registered (Invariant 21). Unsigned skills
7057
+ // can't escalate, so review.lookalikeBlock is false for them — they only warn in the dialog.
7058
+ if (review.lookalikeBlock) return { ok: false, errors: ['lookalike_skeleton_blocked'] };
6742
7059
  const forbidden = _skCodeForbidden((envelope.skill || {}).code);
6743
7060
  if (forbidden) return { ok: false, errors: [forbidden] };
6744
7061
  const skill = envelope.skill, id = await _skSkillId(skill.name, skill.author_pubkey);
@@ -6750,6 +7067,9 @@ async function runtimeInstallSkill(envelope) {
6750
7067
  if (prev) installedSkills.set(id, prev); else installedSkills.delete(id);
6751
7068
  return { ok: false, errors: [(e && e.code) || (e && e.message) || 'persist_failed'] };
6752
7069
  }
7070
+ // I5 — record this (key, name) in the per-author name_history (best-effort; never fails the
7071
+ // install). On a same-key rename the new name is appended; identity stays anchored on the key.
7072
+ await _skSourceRecord(skill.author_pubkey, skill.name);
6753
7073
  return { ok: true, skillId: id };
6754
7074
  }
6755
7075
  // §7 — remove a skill + persist the emptied/updated zone (same rollback discipline).
@@ -6765,6 +7085,126 @@ async function runtimeUninstallSkill(skillId) {
6765
7085
  }
6766
7086
  return { ok: true };
6767
7087
  }
7088
+ // ── I12 (v0.9 §12) — agent registry ops. The #rwa-agents zone is built/committed exactly like
7089
+ // #rwa-skills (skillId-sorted base64 envelopes, runtimeRegionCommit reachability:'frozen'); the two
7090
+ // zones coexist and the region primitive commits each atomically. insertAt is the no-zone fallback
7091
+ // (a fresh skill-host has no agents zone yet → the first install appends one).
7092
+ function buildAgentZone(agents) {
7093
+ const blocks = Array.from(agents.values())
7094
+ .filter(a => a.envelope)
7095
+ .sort((a, b) => (a.agentId < b.agentId ? -1 : a.agentId > b.agentId ? 1 : 0))
7096
+ .map(a => '<script type="application/rwa-agent+json">' + _skB64(_skUtf8(JSON.stringify(a.envelope))) + '<\/script>')
7097
+ .join('');
7098
+ return '<div data-rwa-frozen id="rwa-agents">' + blocks + '</div>';
7099
+ }
7100
+ function _agAgentsRegion() {
7101
+ return {
7102
+ frozenId: 'rwa-agents',
7103
+ select(doc) {
7104
+ const open = /<div\b[^>]*\bid="rwa-agents"[^>]*>/i.exec(doc);
7105
+ if (!open) return null;
7106
+ const close = doc.indexOf('</div>', open.index + open[0].length);
7107
+ return close < 0 ? null : [open.index, close + 6];
7108
+ },
7109
+ insertAt(doc) { return doc.length; },
7110
+ build() { return buildAgentZone(installedAgents); },
7111
+ };
7112
+ }
7113
+ function runtimeListAgents() {
7114
+ return Array.from(installedAgents.values()).map(a => ({ role: a.role, author_pubkey: a.manifest && a.manifest.author_pubkey, verified: a.verified }));
7115
+ }
7116
+ function runtimeAgentActive() {
7117
+ if (!activeAgentRole) return null;
7118
+ const rec = Array.from(installedAgents.values()).find(a => a.role === activeAgentRole);
7119
+ return rec ? { role: rec.role, author_pubkey: rec.manifest && rec.manifest.author_pubkey } : null;
7120
+ }
7121
+ // Switch the active role. The trust anchor is the signature: an unverified (tampered) agent can be
7122
+ // installed but NEVER activated (unverified_agent), so a tampered prompt can't drive modify().
7123
+ function runtimeSetActiveAgent(role) {
7124
+ if (role == null) { activeAgentRole = null; return; }
7125
+ const rec = Array.from(installedAgents.values()).find(a => a.role === role);
7126
+ if (!rec) throw new RwaEditError('agent_not_found', null, { role });
7127
+ if (!rec.verified) throw new RwaEditError('unverified_agent', null, { role });
7128
+ activeAgentRole = role;
7129
+ }
7130
+ // §12 — validate + verify + register an agent, then persist the #rwa-agents zone (same discipline as
7131
+ // runtimeInstallSkill: rollback the in-memory map on a persist failure). A signed-but-unverified
7132
+ // agent registers (verified:false) but can't activate.
7133
+ async function runtimeInstallAgent(envelope) {
7134
+ const agent = (envelope && envelope.agent) || {};
7135
+ const signed = !!(envelope && envelope.signature);
7136
+ const gate = _agValidateInstall(agent, signed);
7137
+ if (!gate.ok) return { ok: false, errors: gate.errors };
7138
+ const verified = await _agVerify(envelope);
7139
+ const id = await _agAgentId(agent.role, agent.author_pubkey);
7140
+ const prev = installedAgents.get(id);
7141
+ installedAgents.set(id, { agentId: id, role: agent.role, verified, manifest: agent, envelope });
7142
+ try {
7143
+ await runtimeRegionCommit({ regions: [_agAgentsRegion()], actor: prev ? 'agent:update' : 'agent:install', reachability: 'frozen' });
7144
+ } catch (e) {
7145
+ if (prev) installedAgents.set(id, prev); else installedAgents.delete(id);
7146
+ return { ok: false, errors: [(e && e.code) || (e && e.message) || 'persist_failed'] };
7147
+ }
7148
+ return { ok: true, agentId: id, verified };
7149
+ }
7150
+ async function runtimeUninstallAgent(agentId) {
7151
+ const prev = installedAgents.get(agentId);
7152
+ if (!prev) return { ok: false, errors: ['not_installed'] };
7153
+ installedAgents.delete(agentId);
7154
+ if (activeAgentRole && prev.role === activeAgentRole && !Array.from(installedAgents.values()).some(a => a.role === activeAgentRole)) activeAgentRole = null;
7155
+ try {
7156
+ await runtimeRegionCommit({ regions: [_agAgentsRegion()], actor: 'agent:uninstall', reachability: 'frozen' });
7157
+ } catch (e) {
7158
+ installedAgents.set(agentId, prev);
7159
+ return { ok: false, errors: [(e && e.code) || (e && e.message) || 'persist_failed'] };
7160
+ }
7161
+ return { ok: true };
7162
+ }
7163
+ // ── I8 (v0.9 §9) — hook firing. Installed `hook` skills that declare hook:<event> run (compute-only,
7164
+ // no bridge) when the mapped lifecycle event fires. Fire-and-forget: never blocks/throws into the
7165
+ // emitter (Inv 24); deterministic skillId order; re-entrancy-guarded (Inv 23); every run logged to
7166
+ // rwa_hook_log (Inv 25). A hook must be verified (boot re-check) to fire.
7167
+ function _hooksForEvent(hookEvent) {
7168
+ return Array.from(installedSkills.values())
7169
+ .filter(s => s && s.kind === 'hook' && s.verified && Array.isArray(s.manifest && s.manifest.permissions) && s.manifest.permissions.indexOf('hook:' + hookEvent) !== -1)
7170
+ .sort((a, b) => (a.skillId < b.skillId ? -1 : a.skillId > b.skillId ? 1 : 0));
7171
+ }
7172
+ function _hookLog(entry) {
7173
+ return idbPut(RWA.HOOK_LOG, entry, entry.ts + '-' + (_hookSeq++)).catch(() => {});
7174
+ }
7175
+ function fireHooks(hookEvent, input) {
7176
+ for (const h of _hooksForEvent(hookEvent)) {
7177
+ if (activeHooks.has(h.skillId)) continue; // a hook can't re-fire its own event
7178
+ activeHooks.add(h.skillId);
7179
+ const t0 = Date.now(), sid = h.skillId;
7180
+ let p; try { p = Promise.resolve(runtimeInvokeSkill(sid, input)); } catch (e) { p = Promise.reject(e); }
7181
+ p.then(
7182
+ result => _hookLog({ skillId: sid, event: hookEvent, ts: t0, input, result, duration: Date.now() - t0 }),
7183
+ err => _hookLog({ skillId: sid, event: hookEvent, ts: t0, input, error: String((err && err.message) || err), duration: Date.now() - t0 })
7184
+ ).then(() => activeHooks.delete(sid), () => activeHooks.delete(sid));
7185
+ }
7186
+ }
7187
+ // The audit trail. getAll in key (≈ chronological) order; never throws.
7188
+ async function runtimeHookLog() {
7189
+ try {
7190
+ const db = await openDB();
7191
+ return await new Promise((res) => { const tx = db.transaction(RWA.HOOK_LOG); const r = tx.objectStore(RWA.HOOK_LOG).getAll(); r.onsuccess = () => res(r.result || []); tx.onerror = () => res([]); });
7192
+ } catch (_) { return []; }
7193
+ }
7194
+ // Boot prune: drop entries older than 30d, then cap to the 1000 most recent.
7195
+ async function _hookLogPrune() {
7196
+ try {
7197
+ const db = await openDB();
7198
+ const read = (fn) => new Promise((res) => { const tx = db.transaction(RWA.HOOK_LOG); const r = tx.objectStore(RWA.HOOK_LOG)[fn](); r.onsuccess = () => res(r.result || []); tx.onerror = () => res([]); });
7199
+ const keys = await read('getAllKeys'), vals = await read('getAll');
7200
+ const cutoff = Date.now() - 30 * 24 * 3600 * 1000;
7201
+ const pairs = keys.map((k, i) => ({ k, ts: (vals[i] && vals[i].ts) || 0 }));
7202
+ const fresh = pairs.filter(p => p.ts >= cutoff);
7203
+ const drop = pairs.filter(p => p.ts < cutoff).map(p => p.k)
7204
+ .concat(fresh.length > 1000 ? fresh.sort((a, b) => a.ts - b.ts).slice(0, fresh.length - 1000).map(p => p.k) : []);
7205
+ for (const k of drop) await idbDel(RWA.HOOK_LOG, k);
7206
+ } catch (_) {}
7207
+ }
6768
7208
  // §1 — the install dialog (trust anchor). Renders the review and resolves with the user's choice.
6769
7209
  function showSkillInstallDialog(envelope) {
6770
7210
  return runtimeReviewSkill(envelope).then(rv => new Promise(resolve => {
@@ -6777,11 +7217,45 @@ function showSkillInstallDialog(envelope) {
6777
7217
  const authorHtml = rv.signed
6778
7218
  ? (rv.verified ? 'Signed by key <code>' + _skEsc(String(rv.author_pubkey).slice(0, 12)) + '…</code>' : '⚠ Signature does NOT verify — the code may have been modified.')
6779
7219
  : '⚠ Unsigned — the runtime can\'t verify the author or that the code is unmodified.';
7220
+ // I10 (Shape C) — on an update whose permissions changed, surface the delta and make the
7221
+ // affirm button explicitly about the NEW permissions. No silent escalation: the install
7222
+ // call still fires only on the click below.
7223
+ const upd = rv.update || { isUpdate: false, changed: false, added: [], removed: [] };
7224
+ const updHtml = (upd.isUpdate && upd.changed)
7225
+ ? '<div style="background:#fef3c7;border-radius:8px;padding:10px 12px;margin:.8em 0"><strong>You already installed this skill. This version changes what it can do:</strong>'
7226
+ + (upd.added.length ? '<p style="margin:.6em 0 .2em;color:#b45309"><strong>+ Newly requested permissions</strong></p><ul style="margin:.2em 0;padding-left:1.2em">' + upd.added.map(p => '<li>' + _skEsc(p.prose) + '</li>').join('') + '</ul>' : '')
7227
+ + (upd.removed.length ? '<p style="margin:.6em 0 .2em;color:#6b7280"><strong>− No longer requested</strong></p><ul style="margin:.2em 0;padding-left:1.2em;color:#6b7280">' + upd.removed.map(p => '<li>' + _skEsc(p.prose) + '</li>').join('') + '</ul>' : '')
7228
+ + '</div>'
7229
+ : '';
7230
+ const affirmText = (upd.isUpdate && upd.changed) ? 'I have reviewed the new permissions and want to update this skill'
7231
+ : upd.isUpdate ? 'Update this skill'
7232
+ : 'I have reviewed this skill and want to install it';
7233
+ // I5 — tiered lookalike notice. A signed homoglyph (lookalikeBlock) is a hard impersonation
7234
+ // block (red, install suppressed); an unsigned homoglyph (kind:'skeleton') is a severe warning;
7235
+ // an honest ASCII near-miss (kind:'levenshtein') is the existing amber name-collision warning.
7236
+ const lookalikeHtml = rv.lookalikeBlock
7237
+ ? '<p style="background:#fee2e2;border:1px solid #ef4444;border-radius:8px;padding:8px 10px;margin:.6em 0">⚠️ This name uses characters that look identical to <strong>' + _skEsc(rv.lookalike) + '</strong> from a DIFFERENT author. This is a known impersonation pattern, so installation is blocked. Identity is the key, not the name.</p>'
7238
+ : rv.lookalikeKind === 'skeleton'
7239
+ ? '<p style="background:#fef3c7;border-radius:8px;padding:8px 10px;margin:.6em 0">⚠️ This name uses characters that look identical to <strong>' + _skEsc(rv.lookalike) + '</strong>, which you installed from a DIFFERENT key. Review the key carefully — identity is the key, not the name.</p>'
7240
+ : rv.lookalike
7241
+ ? '<p style="background:#fef3c7;border-radius:8px;padding:8px 10px;margin:.6em 0">⚠ This name closely matches <strong>' + _skEsc(rv.lookalike) + '</strong>, which you installed from a DIFFERENT key. The author is identified by the key, not the name.</p>'
7242
+ : '';
7243
+ // I5 — same-key rename note (informational, non-permission). Suppressed under a homoglyph block
7244
+ // (impersonation supersedes). Anchors identity on the key: "the author is identified by the key".
7245
+ const nc = rv.nameChange;
7246
+ const nameChangeHtml = (nc && nc.prev && !rv.lookalikeBlock)
7247
+ ? '<p style="background:#eff6ff;border-radius:8px;padding:8px 10px;margin:.6em 0">ℹ️ This author previously published a skill named <strong>' + _skEsc(nc.prev.name) + '</strong>'
7248
+ + (typeof nc.prev.date === 'number' ? ' on ' + _skEsc(new Date(nc.prev.date).toLocaleDateString()) : '')
7249
+ + '. The current name is <strong>' + _skEsc(rv.name) + '</strong>. The author is identified by the key, not the name.</p>'
7250
+ : '';
7251
+ const canInstall = rv.gates.ok && !rv.lookalikeBlock;
6780
7252
  card.innerHTML =
6781
7253
  '<h2 style="margin:0 0 .2em;font-size:1.25rem">Install ' + _skEsc(rv.name) + '?</h2>' +
6782
7254
  '<p style="margin:.2em 0 1em;color:#444"><strong>What it claims to do:</strong> ' + _skEsc(rv.purpose) + '</p>' +
6783
7255
  '<p style="margin:.2em 0"><strong>Author.</strong> ' + authorHtml + '</p>' +
6784
- (rv.lookalike ? '<p style="background:#fef3c7;border-radius:8px;padding:8px 10px;margin:.6em 0">⚠ This name closely matches <strong>' + _skEsc(rv.lookalike) + '</strong>, which you installed from a DIFFERENT key. The author is identified by the key, not the name.</p>' : '') +
7256
+ lookalikeHtml +
7257
+ nameChangeHtml +
7258
+ updHtml +
6785
7259
  '<p style="margin:1em 0 .2em"><strong>What it can do on your machine</strong></p>' + permsHtml +
6786
7260
  (rv.compoundRisk ? '<p style="background:#fee2e2;border-radius:8px;padding:8px 10px;margin:.6em 0">⚠ ' + _skEsc(rv.compoundRisk) + '</p>' : '') +
6787
7261
  (rv.scanNotes.length ? '<p style="margin:1em 0 .2em"><strong>Notes from the runtime\'s code review</strong></p><ul style="margin:.2em 0;padding-left:1.2em;color:#555">' + rv.scanNotes.map(n => '<li>' + _skEsc(n) + '</li>').join('') + '</ul>' : '') +
@@ -6789,7 +7263,7 @@ function showSkillInstallDialog(envelope) {
6789
7263
  (rv.gates.ok ? '' : '<p style="background:#fee2e2;border-radius:8px;padding:8px 10px;margin:.6em 0">Cannot install: ' + _skEsc(rv.gates.errors.join(', ')) + '</p>') +
6790
7264
  '<div style="display:flex;gap:10px;margin-top:1.2em;justify-content:flex-end">' +
6791
7265
  '<button data-act="cancel" style="padding:9px 16px;border:1px solid #ccc;border-radius:10px;background:#fff;cursor:pointer">Cancel</button>' +
6792
- (rv.gates.ok ? '<button data-act="install" style="padding:9px 16px;border:none;border-radius:10px;background:var(--gray-900,#111);color:#fff;cursor:pointer">I have reviewed this skill and want to install it</button>' : '') +
7266
+ (canInstall ? '<button data-act="install" style="padding:9px 16px;border:none;border-radius:10px;background:var(--gray-900,#111);color:#fff;cursor:pointer">' + _skEsc(affirmText) + '</button>' : '') +
6793
7267
  '</div>';
6794
7268
  overlay.appendChild(card); document.body.appendChild(overlay);
6795
7269
  const close = (r) => { overlay.remove(); resolve(r); };
@@ -6797,10 +7271,49 @@ function showSkillInstallDialog(envelope) {
6797
7271
  const ib = card.querySelector('[data-act=install]'); if (ib) ib.onclick = async () => close(await runtimeInstallSkill(envelope));
6798
7272
  }));
6799
7273
  }
6800
- // §1.3 — install trigger: pick a .rwa-skill.json, parse, show the dialog.
7274
+ // I12 (v0.9 §12) the AGENT install dialog (SHOULD): role + author key + vault namespaces +
7275
+ // a system-prompt preview (≤200 chars). The install button is offered only for a VERIFIED agent
7276
+ // whose record passes the gates; a tampered (unverified) or gate-failing record shows the reason
7277
+ // and suppresses install. The trust anchor is the key, not the role.
7278
+ function showAgentInstallDialog(envelope) {
7279
+ const agent = (envelope && envelope.agent) || {};
7280
+ const signed = !!(envelope && envelope.signature);
7281
+ return Promise.resolve(_agVerify(envelope)).then(verified => new Promise(resolve => {
7282
+ const gates = _agValidateInstall(agent, signed);
7283
+ const prev = document.getElementById('rwa-agent-install'); if (prev) prev.remove();
7284
+ const overlay = document.createElement('div'); overlay.id = 'rwa-agent-install';
7285
+ overlay.style.cssText = 'position:fixed;inset:0;z-index:99999;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;';
7286
+ const card = document.createElement('div');
7287
+ card.style.cssText = 'background:#fff;max-width:560px;width:92%;max-height:86vh;overflow:auto;border-radius:18px;padding:26px 28px;font:14px/1.5 var(--font-ui,system-ui);box-shadow:0 12px 48px rgba(0,0,0,.28);';
7288
+ const nsSet = Array.isArray(agent.vault_namespace_set) ? agent.vault_namespace_set : [];
7289
+ const nsHtml = nsSet.length ? '<ul style="margin:.4em 0;padding-left:1.2em">' + nsSet.map(n => '<li>' + _skEsc(n) + '</li>').join('') + '</ul>' : '<p style="color:#666">No credential namespaces — this role reads/writes no vault entries.</p>';
7290
+ const promptPreview = String(agent.system_prompt == null ? '' : agent.system_prompt).slice(0, 200);
7291
+ const authorHtml = signed
7292
+ ? (verified ? 'Signed by key <code>' + _skEsc(String(agent.author_pubkey).slice(0, 12)) + '…</code>' : '⚠ Signature does NOT verify — this record was modified. It cannot be activated.')
7293
+ : '⚠ Unsigned — agents must be signed; this cannot be installed.';
7294
+ const canInstall = gates.ok && verified;
7295
+ card.innerHTML =
7296
+ '<h2 style="margin:0 0 .2em;font-size:1.25rem">Install agent role “' + _skEsc(agent.role) + '”?</h2>' +
7297
+ '<p style="margin:.2em 0 1em;color:#444"><strong>Purpose.</strong> ' + _skEsc(agent.description || '(no description provided)') + '</p>' +
7298
+ '<p style="margin:.2em 0"><strong>Author.</strong> ' + authorHtml + ' <em>The author is identified by the key, not the role name.</em></p>' +
7299
+ '<p style="margin:1em 0 .2em"><strong>Credentials this role can reach</strong></p>' + nsHtml +
7300
+ '<p style="margin:1em 0 .2em"><strong>Role instructions (preview)</strong></p><pre style="white-space:pre-wrap;background:#f6f6f6;border-radius:8px;padding:8px 10px;margin:.2em 0;color:#333;font:12px/1.5 var(--font-mono,monospace)">' + _skEsc(promptPreview) + (String(agent.system_prompt || '').length > 200 ? '…' : '') + '</pre>' +
7301
+ '<p style="margin:1.2em 0 .4em;color:#444"><strong>An active role drives edits with its own instructions and reaches only its declared credentials.</strong> The runtime shows you what the role <em>can</em> do, not whether it <em>should</em>.</p>' +
7302
+ (gates.ok ? '' : '<p style="background:#fee2e2;border-radius:8px;padding:8px 10px;margin:.6em 0">Cannot install: ' + _skEsc(gates.errors.join(', ')) + '</p>') +
7303
+ '<div style="display:flex;gap:10px;margin-top:1.2em;justify-content:flex-end">' +
7304
+ '<button data-act="cancel" style="padding:9px 16px;border:1px solid #ccc;border-radius:10px;background:#fff;cursor:pointer">Cancel</button>' +
7305
+ (canInstall ? '<button data-act="install" style="padding:9px 16px;border:none;border-radius:10px;background:var(--gray-900,#111);color:#fff;cursor:pointer">I have reviewed this role and want to install it</button>' : '') +
7306
+ '</div>';
7307
+ overlay.appendChild(card); document.body.appendChild(overlay);
7308
+ const close = (r) => { overlay.remove(); resolve(r); };
7309
+ card.querySelector('[data-act=cancel]').onclick = () => close({ ok: false, errors: ['cancelled'] });
7310
+ const ib = card.querySelector('[data-act=install]'); if (ib) ib.onclick = async () => close(await runtimeInstallAgent(envelope));
7311
+ }));
7312
+ }
7313
+ // §1.3 / §12 — install trigger: pick a .rwa-skill.json OR .rwa-agent.json, parse, dispatch by format.
6801
7314
  function runtimePromptInstall() {
6802
- const inp = document.createElement('input'); inp.type = 'file'; inp.accept = '.rwa-skill.json,application/json,.json';
6803
- inp.onchange = () => { const f = inp.files && inp.files[0]; if (!f) return; const rd = new FileReader(); rd.onload = () => { let env; try { env = JSON.parse(rd.result); } catch (_) { setStatus && setStatus('err', 'invalid .rwa-skill.json'); return; } showSkillInstallDialog(env); }; rd.readAsText(f); };
7315
+ const inp = document.createElement('input'); inp.type = 'file'; inp.accept = '.rwa-skill.json,.rwa-agent.json,application/json,.json';
7316
+ inp.onchange = () => { const f = inp.files && inp.files[0]; if (!f) return; const rd = new FileReader(); rd.onload = () => { let env; try { env = JSON.parse(rd.result); } catch (_) { setStatus && setStatus('err', 'invalid skill/agent JSON'); return; } if (env && env.format === 'rwa-agent/1') showAgentInstallDialog(env); else showSkillInstallDialog(env); }; rd.readAsText(f); };
6804
7317
  inp.click();
6805
7318
  }
6806
7319
 
@@ -6839,6 +7352,9 @@ const SKILL_WORKER_PROLOGUE = `(function(){
6839
7352
  function _installBridge(){
6840
7353
  RUNTIME.fetch=function(url,opts){ return _bridge('bridge:fetch',{ url:String(url), opts:_serializeOpts(opts) }); };
6841
7354
  RUNTIME.vault={ get:function(ns,k){ return _bridge('bridge:vault',{op:'get',ns:ns,key:k}); }, set:function(ns,k,v){ return _bridge('bridge:vault',{op:'set',ns:ns,key:k,val:v}); }, has:function(ns,k){ return _bridge('bridge:vault',{op:'has',ns:ns,key:k}); } };
7355
+ RUNTIME.bus={ publish:function(topic,message){ return _bridge('bridge:bus:publish',{ topic:String(topic), message:message }); } };
7356
+ RUNTIME.fs={ read:function(p){ return _bridge('bridge:fs',{op:'read',path:String(p)}); }, write:function(p,d){ return _bridge('bridge:fs',{op:'write',path:String(p),data:d}); }, del:function(p){ return _bridge('bridge:fs',{op:'del',path:String(p)}); }, list:function(p){ return _bridge('bridge:fs',{op:'list',path:String(p)}); } };
7357
+ RUNTIME.db={ get:function(s,k){ return _bridge('bridge:idb',{op:'get',store:String(s),key:k}); }, put:function(s,k,v){ return _bridge('bridge:idb',{op:'put',store:String(s),key:k,value:v}); }, del:function(s,k){ return _bridge('bridge:idb',{op:'del',store:String(s),key:k}); }, all:function(s){ return _bridge('bridge:idb',{op:'all',store:String(s)}); } };
6842
7358
  }
6843
7359
  self.onmessage=function(e){
6844
7360
  var msg=e.data; if(!msg) return;
@@ -6858,9 +7374,18 @@ const SKILL_WORKER_PROLOGUE = `(function(){
6858
7374
  // bridged fetch/vault with per-call origin/namespace enforcement on THIS (main) thread.
6859
7375
  // Per-invocation identity_tag binds responses; 5s timeout; spawn -> invoke -> terminate.
6860
7376
  // (Vault: increment 8. The worker-scoped CSP that walls remote import() is the frozen-<head> 7b meta.)
6861
- function runtimeInvokeSkill(skillId, input) {
7377
+ function runtimeInvokeSkill(skillId, input, opts) {
6862
7378
  const skill = installedSkills.get(skillId);
6863
7379
  if (!skill) return Promise.reject(new Error('skill_not_found'));
7380
+ // I12 — run under a role: vault ops gate on the AGENT's vault_namespace_set (the role is the
7381
+ // capability boundary for this invoke). Resolve BEFORE any Worker spawns so a bad role fails fast.
7382
+ let agentRec = null;
7383
+ const agentRole = opts && opts.agentRole;
7384
+ if (agentRole != null) {
7385
+ agentRec = Array.from(installedAgents.values()).find(a => a.role === agentRole) || null;
7386
+ if (!agentRec) return Promise.reject(new Error('agent_not_found'));
7387
+ if (!agentRec.verified) return Promise.reject(new Error('unverified_agent'));
7388
+ }
6864
7389
  const isTool = skill.kind === 'tool';
6865
7390
  // §3.4/Inv 20 gate at the capability boundary: a `tool` that did not re-verify from the bytes
6866
7391
  // (unsigned, or signed-then-tampered → boot re-verify flips verified:false) must NOT reach the
@@ -6903,7 +7428,10 @@ function runtimeInvokeSkill(skillId, input) {
6903
7428
  }
6904
7429
  if (msg.type === 'bridge:vault') {
6905
7430
  const pl = msg.payload || {};
6906
- if (!_skVaultAllowed(skill, pl.ns)) { reply(msg.id, false, { error: 'vault_namespace_denied' }); return; } // §6 per-call gate
7431
+ // I12 under an active role the AGENT's vault_namespace_set is the gate (role-scoped);
7432
+ // otherwise the skill's own vault: permissions. Either way an out-of-scope ns is denied.
7433
+ const vaultOk = agentRec ? _agVaultAllowed(agentRec.manifest, pl.ns) : _skVaultAllowed(skill, pl.ns);
7434
+ if (!vaultOk) { reply(msg.id, false, { error: 'vault_namespace_denied' }); return; } // §6 per-call gate
6907
7435
  try {
6908
7436
  if (pl.op === 'get') reply(msg.id, true, { result: await runtimeVaultGet(pl.ns, pl.key) });
6909
7437
  else if (pl.op === 'has') reply(msg.id, true, { result: await runtimeVaultHas(pl.ns, pl.key) });
@@ -6912,6 +7440,55 @@ function runtimeInvokeSkill(skillId, input) {
6912
7440
  } catch (e) { reply(msg.id, false, { error: String(e && e.message || 'vault_error') }); }
6913
7441
  return;
6914
7442
  }
7443
+ if (msg.type === 'bridge:bus:publish') {
7444
+ const pl = msg.payload || {};
7445
+ if (!_skBusAllowed(skill, pl.topic)) { reply(msg.id, false, { error: 'bus_topic_denied' }); return; } // §5 per-call gate
7446
+ // Inv 23: bound the payload (structured-clone-safe + ≤65536 bytes via the JSON proxy)
7447
+ // so a skill can't flood subscribers. A non-serializable payload fails closed.
7448
+ let enc; try { enc = JSON.stringify(pl.message); } catch (_) { reply(msg.id, false, { error: 'bus_error' }); return; }
7449
+ if (enc != null && enc.length > 65536) { reply(msg.id, false, { error: 'bus_error' }); return; }
7450
+ try { runtimeBusPublish(pl.topic, pl.message); reply(msg.id, true, { result: true }); }
7451
+ catch (_) { reply(msg.id, false, { error: 'bus_error' }); }
7452
+ return;
7453
+ }
7454
+ if (msg.type === 'bridge:fs') {
7455
+ const pl = msg.payload || {}, op = pl.op, p = pl.path;
7456
+ // Reject traversal/invalid paths BEFORE the scope check (mirror of assertUserFsPath); a
7457
+ // declared scope is a left-anchored prefix, so a '..'-free path is safe to literal-match.
7458
+ if (typeof p !== 'string' || !p || p.startsWith('/') || p.startsWith('_rwa/') || p.split('/').some(s => s === '.' || s === '..')) { reply(msg.id, false, { error: 'fs_path_denied' }); return; }
7459
+ if (!_skFsAllowed(skill, p)) { reply(msg.id, false, { error: 'fs_permission_denied' }); return; } // §6 per-call gate
7460
+ try {
7461
+ let result;
7462
+ if (op === 'read') result = await runtimeFsRead(p);
7463
+ else if (op === 'write') { await runtimeFsWrite(p, pl.data); result = true; }
7464
+ else if (op === 'del') { await runtimeFsDel(p); result = true; }
7465
+ else if (op === 'list') result = await runtimeFsList(p);
7466
+ else { reply(msg.id, false, { error: 'fs_path_invalid' }); return; }
7467
+ reply(msg.id, true, { result });
7468
+ } catch (e) {
7469
+ const m = String(e && e.message || ''), nm = e && e.name;
7470
+ const code = (nm === 'QuotaExceededError' || /quota/i.test(m)) ? 'fs_quota_exceeded'
7471
+ : (nm === 'SecurityError' || /OPFS is not available|not supported/i.test(m)) ? 'fs_unsupported'
7472
+ : (nm === 'NotFoundError' || /no file at/i.test(m)) ? 'fs_path_not_found'
7473
+ : 'fs_write_failed';
7474
+ reply(msg.id, false, { error: code });
7475
+ }
7476
+ return;
7477
+ }
7478
+ if (msg.type === 'bridge:idb') {
7479
+ const pl = msg.payload || {}, op = pl.op, store = pl.store;
7480
+ if (!_skIdbAllowed(skill, store)) { reply(msg.id, false, { error: 'idb_store_denied' }); return; } // §7 per-call gate
7481
+ try {
7482
+ let result;
7483
+ if (op === 'get') result = await runtimeDbGet(store, pl.key);
7484
+ else if (op === 'put') { await runtimeDbPut(store, pl.key, pl.value); result = true; }
7485
+ else if (op === 'del') { await runtimeDbDel(store, pl.key); result = true; }
7486
+ else if (op === 'all') result = await runtimeDbAll(store);
7487
+ else { reply(msg.id, false, { error: 'invalid_argument' }); return; }
7488
+ reply(msg.id, true, { result });
7489
+ } catch (e) { reply(msg.id, false, { error: String(e && e.message || 'idb_error') }); }
7490
+ return;
7491
+ }
6915
7492
  };
6916
7493
  w.onerror = (e) => finish(reject, new Error('runtime_error: ' + (e.message || '')));
6917
7494
  w.postMessage({ type: 'init', identity_tag, bridged: isTool });
@@ -6958,6 +7535,15 @@ function runtimeDescribe() {
6958
7535
  affordances.push({ kind: s.kind, name: s.name, skillId: s.skillId, provenance: 'installed', verified: s.verified || false });
6959
7536
  seen.add(key);
6960
7537
  }
7538
+ // I12/SD-04 — union INSTALLED agents (kind:'agent', name:role). Same shape as the static
7539
+ // parseAgentZone projection so live == static (cross-surface affordance convergence).
7540
+ for (const a of installedAgents.values()) {
7541
+ if (!a || typeof a.role !== 'string') continue;
7542
+ const key = 'agent\0' + a.role;
7543
+ if (seen.has(key)) continue;
7544
+ affordances.push({ kind: 'agent', name: a.role, agentId: a.agentId, provenance: 'installed', verified: a.verified || false });
7545
+ seen.add(key);
7546
+ }
6961
7547
  return {
6962
7548
  rwa: 'self-description/1',
6963
7549
  source: 'live',
@@ -7382,7 +7968,7 @@ async function modify(instr, lensMeta = null, opts = null) {
7382
7968
  const frozenZones = extractFrozenZones(cur);
7383
7969
 
7384
7970
  const messages = [
7385
- { role: 'system', content: SYSTEM_PROMPT },
7971
+ { role: 'system', content: resolveSystemPrompt() },
7386
7972
  { role: 'user', content: buildUserPrompt(instr, cur, frozenZones) }
7387
7973
  ];
7388
7974
 
@@ -7569,7 +8155,7 @@ async function modifyViaBridge(instr, lensMeta = null) {
7569
8155
  const vOpts = { assets: vimg.assets, orphans: vimg.orphans };
7570
8156
  const frozenZones = extractFrozenZones(cur);
7571
8157
 
7572
- const prompt = SYSTEM_PROMPT + '\n\n' + buildUserPrompt(instr, cur, frozenZones) +
8158
+ const prompt = resolveSystemPrompt() + '\n\n' + buildUserPrompt(instr, cur, frozenZones) +
7573
8159
  '\n\nThis backend is single-shot (no tool-calling protocol). Output ONLY a single JSON envelope as your last response, no markdown fences, no commentary, no preamble. The envelope MUST be one of these three exact shapes:\n\n' +
7574
8160
  '{"tool":"apply_dsl_plan","envelope":{"version":"rwa-edit-dsl/1","ops":[/* per the rules above */]}}\n\n' +
7575
8161
  '{"tool":"apply_edits","envelope":{"version":"rwa-edit/1","edits":[{"find":"...","replace":"..."}]}}\n\n' +
@@ -8105,6 +8691,19 @@ document.addEventListener('keydown', e => {
8105
8691
  const skMap = await readTrustworthySkills(doc);
8106
8692
  installedSkills.clear();
8107
8693
  skMap.forEach((v, k) => installedSkills.set(k, v));
8694
+ // I12 — rebuild the agent registry from the frozen #rwa-agents zone (re-verify each). No agent
8695
+ // is active by default; an unverified (tampered) agent is registered but can't activate.
8696
+ const agMap = await readTrustworthyAgents(doc);
8697
+ installedAgents.clear();
8698
+ agMap.forEach((v, k) => installedAgents.set(k, v));
8699
+ // I5 — reconcile the per-author name_history (rwa_sources) from the in-file manifests, so a
8700
+ // fresh load (or an IDB-cleared one) still knows the renames recorded in the frozen zone.
8701
+ await runtimeBuildSourceIndex();
8702
+ // I8 — seed the mode tracker for on-mode-change's `previous`, prune the hook log, then fire
8703
+ // on-open hooks (fire-and-forget so a hook can never slow first paint; results logged).
8704
+ _lastHookMode = rwaMode;
8705
+ await _hookLogPrune();
8706
+ fireHooks('on-open', { event: 'on-open', docUuid: DOC_UUID });
8108
8707
  } catch (_) { /* no installed skills */ }
8109
8708
  await _vaultReimportSession().catch(() => {}); // v0.8 §6 — restore an unlocked vault within the tab session
8110
8709
  // Spec §7: expose the public runtime API. Only constructed on the success
@@ -8148,6 +8747,8 @@ document.addEventListener('keydown', e => {
8148
8747
  showInstallDialog: showSkillInstallDialog, // v0.8 §1 — render the consent dialog for an envelope → resolves with the choice
8149
8748
  promptInstall: runtimePromptInstall, // v0.8 §1.3 — pick a .rwa-skill.json → show the dialog
8150
8749
  vault: { get: runtimeVaultGet, set: runtimeVaultSet, has: runtimeVaultHas, namespaces: runtimeVaultNamespaces, unlock: runtimeVaultUnlock, lock: runtimeVaultLock, isLocked: runtimeVaultIsLocked }, // v0.8 §6
8750
+ agents: { list: runtimeListAgents, active: runtimeAgentActive, setActive: runtimeSetActiveAgent, install: runtimeInstallAgent, uninstall: runtimeUninstallAgent, message: runtimeAgentMessage, showInstallDialog: showAgentInstallDialog }, // v0.9 §12 — multi-agent roles
8751
+ hookLog: runtimeHookLog, // v0.9 §9 — the hook audit trail (rwa_hook_log)
8151
8752
  };
8152
8753
  // `status` is a getter so each read returns a fresh snapshot of
8153
8754
  // dirty/fsa/storage; an enumerable data prop would let stale references