rewritable 0.8.1 → 0.10.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) ========================
@@ -5110,11 +5119,30 @@ window.submitLens = submitLens;
5110
5119
  // workspaces (audit R7) can carry richer identifiers (e.g. signed source
5111
5120
  // keys per the action-spec) without changing the rwa_hist schema.
5112
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;
5113
5125
  const backend = sessionStorage.getItem(RWA.K_BACKEND) || 'openrouter';
5114
5126
  if (backend === 'bridge') return 'bridge:claude-p';
5115
5127
  if (backend === 'bridge-session') return 'bridge:claude-session';
5116
5128
  return sessionStorage.getItem(RWA.K_MODEL) || RWA.MODEL;
5117
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
+ }
5118
5146
 
5119
5147
  // Resolve the user-selected backend into a transport config.
5120
5148
  // kind:'openai_compat' covers openrouter/ollama/lmstudio — same wire protocol,
@@ -6316,6 +6344,17 @@ let activeView = null;
6316
6344
  // runtimeDescribe()/runtimeListSkills(). The agent can never write the zone (it is
6317
6345
  // data-rwa-frozen); only the runtime rewrites it (increment 7, registry-aware commit).
6318
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;
6319
6358
 
6320
6359
  const RWA_VIEW_RESERVED_IDS = ['rwa-doc-mount', 'rwa-lens', 'rwa-runtime'];
6321
6360
  const RWA_VIEW_RESERVED_MARKERS = ['rwa:frozen:begin', 'rwa:frozen:end', 'data-rwa-frozen'];
@@ -6387,22 +6426,59 @@ function runtimeSetView(name) {
6387
6426
  sessionStorage.setItem(rwaViewKey(), '');
6388
6427
  } else {
6389
6428
  const spec = providers.view;
6390
- if (!spec || spec.name !== name) throw new Error('no registered view named ' + name);
6391
- validateViewOutput(spec.render(currentDocCache, viewCtx()), spec); // throws → never activates
6392
- releaseAnchor();
6393
- if (rwaMode !== 'document') {
6394
- hideEditTransients();
6395
- closeRuntimePanels();
6396
- rwaMode = 'document';
6397
- emitRuntimeEvent('mode', { mode: rwaMode });
6429
+ if (spec && spec.name === name) {
6430
+ validateViewOutput(spec.render(currentDocCache, viewCtx()), spec); // throws → never activates
6431
+ releaseAnchor();
6432
+ if (rwaMode !== 'document') {
6433
+ hideEditTransients();
6434
+ closeRuntimePanels();
6435
+ rwaMode = 'document';
6436
+ emitRuntimeEvent('mode', { mode: rwaMode });
6437
+ }
6438
+ activeView = spec;
6439
+ sessionStorage.setItem(rwaViewKey(), name);
6440
+ } else {
6441
+ // I7 (v0.9 §8) — an INSTALLED view skill (by skillId or name). Resolve SYNCHRONOUSLY so an
6442
+ // unknown name still throws (unchanged behavior); only a genuine installed view goes async.
6443
+ // Its render() runs in a Worker, so we invoke once, validate the returned HTML main-side (same
6444
+ // contract as a first-party view — no <script>, no reserved ids), and activate a SNAPSHOT
6445
+ // overlay whose sync render() returns the cached HTML. A view never commits (read-only);
6446
+ // re-activate to refresh (auto-refresh-on-change is the deferred `observe` opt-in).
6447
+ const rec = installedSkills.get(name) || Array.from(installedSkills.values()).find(s => s.name === name && s.kind === 'view');
6448
+ if (!rec || rec.kind !== 'view' || !(rec.manifest && rec.manifest.output && rec.manifest.output.kind === 'html-render')) throw new Error('no registered view named ' + name);
6449
+ runtimeActivateInstalledView(rec);
6450
+ return;
6398
6451
  }
6399
- activeView = spec;
6400
- sessionStorage.setItem(rwaViewKey(), name);
6401
6452
  }
6402
6453
  if (typeof syncModeChrome === 'function') syncModeChrome();
6403
6454
  if (typeof syncViewChrome === 'function') syncViewChrome();
6404
6455
  getDoc().then(d => renderDoc(canonLF(d)));
6405
6456
  }
6457
+ async function runtimeActivateInstalledView(rec) {
6458
+ try {
6459
+ const d = canonLF(await getDoc());
6460
+ const html = String(await runtimeInvokeSkill(rec.skillId, { doc: d, ctx: viewCtx() }));
6461
+ validateViewOutput(html, { name: rec.name }); // throws → never activates (fail-loud, same as first-party)
6462
+ releaseAnchor();
6463
+ if (rwaMode !== 'document') { hideEditTransients(); closeRuntimePanels(); rwaMode = 'document'; emitRuntimeEvent('mode', { mode: rwaMode }); }
6464
+ activeView = { name: rec.name, label: rec.name, skillId: rec.skillId, __provenance: 'installed', __html: html, render() { return this.__html; } };
6465
+ if (typeof syncModeChrome === 'function') syncModeChrome();
6466
+ if (typeof syncViewChrome === 'function') syncViewChrome();
6467
+ renderDoc(d);
6468
+ } catch (e) {
6469
+ setStatus('err', '✗ ' + ((e && e.message) || 'view failed'));
6470
+ }
6471
+ }
6472
+ // I7 (v0.9 §8) — invoke an INSTALLED edit-surface skill: its run() returns an rwa-edit/1 envelope
6473
+ // (a deterministic, model-free transform), which is applied through the SAME validated commit path
6474
+ // the agent/lens use (frozen-zone + structural-shape guards, one ⌘Z), attributed to the skill.
6475
+ async function runtimeInvokeEditSurface(skillId, input) {
6476
+ const rec = installedSkills.get(skillId);
6477
+ if (!rec || rec.kind !== 'edit-surface') throw new Error('not an edit-surface skill');
6478
+ const envelope = await runtimeInvokeSkill(skillId, input || {});
6479
+ if (!envelope || typeof envelope !== 'object' || envelope.version !== 'rwa-edit/1') throw new Error('invalid_transform_output');
6480
+ return runtimeApplyEnvelope(envelope, { surface: 'skill:edit-surface', actor: 'skill:transform:' + String(skillId).slice(0, 8) });
6481
+ }
6406
6482
  function rwaViewKey() { return 'rwa_view_active_' + DOC_UUID; }
6407
6483
  function rwaSlideKey() { return 'rwa_view_slide_' + DOC_UUID; }
6408
6484
 
@@ -6487,6 +6563,62 @@ async function _skVerify(env) {
6487
6563
  return !!(await crypto.subtle.verify({ name: 'Ed25519' }, key, _skFromB64(sig), await _skSigningMessage(skill, skill.code)));
6488
6564
  } catch (_) { return false; } // Ed25519 unsupported / bad bytes → verified:false (honest)
6489
6565
  }
6566
+ // ── I12 (v0.9 §12) — rwa-agent/1 canon (LIVE mirror of cli/src/skill-manifest.mjs canonicalAgent/
6567
+ // agentSigningMessage/agentId/verifyAgentEnvelope/validateAgentInstall). An agent is a role-scoped,
6568
+ // signed identity (role + system_prompt + vault_namespace_set; NO code). Same canon as the CLI so a
6569
+ // seed-live verify == the CLI-static verify for the same bytes.
6570
+ const _AG_ROLE_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
6571
+ 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 }); }
6572
+ async function _agSigningMessage(a) { return _skSha256(_skUtf8(_agCanonicalAgent(a))); }
6573
+ async function _agAgentId(role, pubkey) { return _skB64url(await _skSha256(_skConcat(_skUtf8(String(role)), _skNUL, _skUtf8(String(pubkey))))); }
6574
+ async function _agVerify(env) {
6575
+ const sig = env && env.signature;
6576
+ if (!sig) return false;
6577
+ const agent = env.agent || {};
6578
+ try {
6579
+ const key = await crypto.subtle.importKey('raw', _skFromB64(agent.author_pubkey), { name: 'Ed25519' }, false, ['verify']);
6580
+ return !!(await crypto.subtle.verify({ name: 'Ed25519' }, key, _skFromB64(sig), await _agSigningMessage(agent)));
6581
+ } catch (_) { return false; }
6582
+ }
6583
+ function _agPromptInjectionRisk(s) { const p = String(s ?? ''); return p.includes('`') || p.includes('${') || /<\/?DOC>/i.test(p); }
6584
+ function _agValidateInstall(agent, signed) {
6585
+ agent = agent || {}; const errors = [];
6586
+ if (/\0/.test(String(agent.role == null ? '' : agent.role))) errors.push('invalid_agent_id');
6587
+ if (typeof agent.role !== 'string' || !_AG_ROLE_RE.test(agent.role)) errors.push('invalid_role');
6588
+ if (typeof agent.system_prompt !== 'string' || _agPromptInjectionRisk(agent.system_prompt)) errors.push('agent_prompt_injection_risk');
6589
+ const set = agent.vault_namespace_set;
6590
+ if (set != null && !Array.isArray(set)) errors.push('invalid_permission');
6591
+ for (const p of (Array.isArray(set) ? set : [])) {
6592
+ try { if (_skParsePermission(p).tier !== 'vault') errors.push('invalid_permission'); }
6593
+ catch (e) { errors.push(/unknown_permission_tier/.test(e.message) ? 'unknown_permission_tier' : 'invalid_permission'); }
6594
+ }
6595
+ if (!signed) errors.push('unsigned_agent');
6596
+ return { ok: errors.length === 0, errors };
6597
+ }
6598
+ // §12 inter-agent bus message shape (mirror of cli validateAgentMessage/agentMessage). Data-model
6599
+ // only; the request→response choreography is the conductor's job, correlated by id.
6600
+ function _agValidateMessage(m) {
6601
+ m = m || {}; const errors = [];
6602
+ if (m.type !== 'request' && m.type !== 'response') errors.push('invalid_type');
6603
+ if (typeof m.id !== 'string' || !m.id) errors.push('invalid_id');
6604
+ if (typeof m.from_role !== 'string' || !_AG_ROLE_RE.test(m.from_role)) errors.push('invalid_from_role');
6605
+ if (typeof m.to_role !== 'string' || !_AG_ROLE_RE.test(m.to_role)) errors.push('invalid_to_role');
6606
+ if (!('payload' in m)) errors.push('missing_payload');
6607
+ return { ok: errors.length === 0, errors };
6608
+ }
6609
+ function _agBuildMessage(type, fromRole, toRole, payload, id) {
6610
+ const m = { type, id, from_role: fromRole, to_role: toRole, payload };
6611
+ const v = _agValidateMessage(m);
6612
+ if (!v.ok) throw new RwaEditError('invalid_agent_message', null, { errors: v.errors });
6613
+ return m;
6614
+ }
6615
+ // runtime.agents.message — convenience builder from the ACTIVE role. A request mints a fresh
6616
+ // correlation id; a response echoes the request's id (pass it as `id`). The caller publishes it
6617
+ // over the bus (a conductor skill with bus:agents:* permission), per §12.
6618
+ function runtimeAgentMessage(type, toRole, payload, id) {
6619
+ if (!activeAgentRole) throw new RwaEditError('no_active_agent', null, {});
6620
+ return _agBuildMessage(type, activeAgentRole, toRole, payload, id || (type === 'request' ? crypto.randomUUID() : undefined));
6621
+ }
6490
6622
  // Locate the inner HTML of the agent-unreachable <div data-rwa-frozen id="rwa-skills">
6491
6623
  // in the DOC TEXT (boot has the doc string before render). Only the frozen zone is
6492
6624
  // trusted; base64 envelopes contain no </div> so a flat scan is safe.
@@ -6522,6 +6654,124 @@ async function readTrustworthySkills(doc) {
6522
6654
  }
6523
6655
  return out;
6524
6656
  }
6657
+ // I12 (v0.9 §12) — the frozen #rwa-agents zone (coexists with #rwa-skills). Same strict
6658
+ // frozen-attribute read as _skExtractZone so an agent-committed lookalike div can't forge an agent.
6659
+ function _agExtractZone(doc) {
6660
+ const open = /<div\b[^>]*\bid="rwa-agents"[^>]*>/i.exec(String(doc || ''));
6661
+ if (!open || !tagHasFrozenAttr(open[0])) return null;
6662
+ const start = open.index + open[0].length;
6663
+ const end = doc.indexOf('</div>', start);
6664
+ return end < 0 ? null : doc.slice(start, end);
6665
+ }
6666
+ // Parse installed agents from the frozen zone, re-verifying each signature. Soft-fail. Returns a
6667
+ // Map<agentId, {agentId, role, verified, manifest, envelope}>. Mirrors readTrustworthySkills.
6668
+ async function readTrustworthyAgents(doc) {
6669
+ const out = new Map();
6670
+ const zone = _agExtractZone(doc);
6671
+ if (!zone) return out;
6672
+ const blocks = zone.matchAll(/<script\s+type="application\/rwa-agent\+json">([\s\S]*?)<\/script>/g);
6673
+ for (const m of blocks) {
6674
+ let env;
6675
+ try { env = JSON.parse(new TextDecoder().decode(_skFromB64(m[1].trim()))); } catch (_) { continue; }
6676
+ const agent = env && env.agent;
6677
+ if (!agent || typeof agent.role !== 'string') continue;
6678
+ const verified = await _agVerify(env);
6679
+ const id = await _agAgentId(agent.role, agent.author_pubkey);
6680
+ out.set(id, { agentId: id, role: agent.role, verified, manifest: agent, envelope: env });
6681
+ }
6682
+ return out;
6683
+ }
6684
+ // I5 (v0.9 §4) — per-author source index (rwa_sources, keyed by public key):
6685
+ // { pubkey, count, first_seen, name_history:[{ name, first_seen_at_name }] }
6686
+ // name_history is APPEND-ONLY and holds one entry per DISTINCT name a key has published, in first-
6687
+ // seen order (Invariant 22). It anchors author identity across renames: the install dialog shows a
6688
+ // same-key name change ("previously published [old]"). DATES are best-effort — the frozen-zone bytes
6689
+ // carry no timestamps (determinism), so a cleared-IDB reload reconciles names from the in-file
6690
+ // manifests with the reconcile time as first_seen_at_name. Identity itself is always the key.
6691
+ async function _skSourceGet(pubkey) {
6692
+ if (!pubkey) return null;
6693
+ try { return (await idbGet(RWA.SOURCES, pubkey)) || null; } catch (_) { return null; }
6694
+ }
6695
+ // I6 (v0.9 §11) — TOFU author identity. Fingerprint = sha256(pubkey).hex[:16] (mirrors the
6696
+ // service's skillFingerprint). The per-author install count + first_seen come from rwa_sources (I5),
6697
+ // so the dialog can say "first time seeing this author" vs "trusted, N installs".
6698
+ async function _skFingerprint(pubkey) {
6699
+ const h = await _skSha256(_skUtf8(String(pubkey)));
6700
+ let s = ''; for (const b of h) s += b.toString(16).padStart(2, '0');
6701
+ return s.slice(0, 16);
6702
+ }
6703
+ async function _skTofu(pubkey) {
6704
+ const rec = await _skSourceGet(pubkey);
6705
+ const installs = (rec && rec.count) || 0;
6706
+ return { fingerprint: await _skFingerprint(pubkey), firstTime: installs === 0, installs };
6707
+ }
6708
+ // I6 (v0.9 §11) — marketplace discovery (opt-in network, like the ↗ share panel). discover →
6709
+ // GET /skills/index (paginated/filterable). fetch → GET /skills/index/:id → the full envelope,
6710
+ // VERIFIED client-side (WebCrypto Ed25519 via _skVerify) before any install; a revoked skill returns
6711
+ // {revoked:true}. The index only informs — install still runs the dialog + gates (the trust anchor).
6712
+ const SKILLS_INDEX_DEFAULT = 'https://rewritable.ikangai.com';
6713
+ async function runtimeDiscoverSkills(opts) {
6714
+ opts = opts || {};
6715
+ const base = (opts.baseUrl || SKILLS_INDEX_DEFAULT).replace(/\/+$/, '');
6716
+ const qs = new URLSearchParams();
6717
+ for (const k of ['kind', 'author', 'search', 'verified_only', 'page', 'limit']) if (opts[k] != null) qs.set(k, String(opts[k]));
6718
+ const res = await fetch(base + '/skills/index' + (qs.toString() ? '?' + qs.toString() : ''));
6719
+ if (!res.ok) throw new Error('discover_failed:' + res.status);
6720
+ return res.json(); // { entries, total, page, limit }
6721
+ }
6722
+ async function runtimeFetchSkillFromIndex(skillId, opts) {
6723
+ opts = opts || {};
6724
+ const base = (opts.baseUrl || SKILLS_INDEX_DEFAULT).replace(/\/+$/, '');
6725
+ const res = await fetch(base + '/skills/index/' + encodeURIComponent(skillId));
6726
+ if (res.status === 410) return { revoked: true, envelope: null, verified: false };
6727
+ if (!res.ok) throw new Error('fetch_failed:' + res.status);
6728
+ const data = await res.json();
6729
+ const verified = await _skVerify(data.envelope); // client-side: never trust the index's `verified`
6730
+ return { envelope: data.envelope, metadata: data.metadata, verified, revoked: false };
6731
+ }
6732
+ // Append (name, now) for a key, creating the record if absent. Idempotent per distinct name.
6733
+ async function _skSourceRecord(pubkey, name, at) {
6734
+ if (!pubkey || !name) return;
6735
+ const now = at || Date.now();
6736
+ let rec = await _skSourceGet(pubkey);
6737
+ if (!rec) rec = { pubkey, count: 0, first_seen: now, name_history: [] };
6738
+ if (!Array.isArray(rec.name_history)) rec.name_history = [];
6739
+ if (!rec.name_history.some(e => e.name === name)) rec.name_history.push({ name, first_seen_at_name: now });
6740
+ rec.count = (rec.count | 0) + 1;
6741
+ try { await idbPut(RWA.SOURCES, rec, pubkey); } catch (_) { /* best-effort; never blocks an install */ }
6742
+ }
6743
+ // Boot reconcile: ensure every (pubkey, name) present in the live registry has a name_history entry.
6744
+ // Rebuilds from the in-file manifests so an IDB-cleared reload still knows the recorded renames.
6745
+ async function runtimeBuildSourceIndex() {
6746
+ const now = Date.now(), byKey = new Map();
6747
+ for (const s of installedSkills.values()) {
6748
+ const pk = s.manifest && s.manifest.author_pubkey;
6749
+ if (!pk || !s.name) continue;
6750
+ if (!byKey.has(pk)) byKey.set(pk, new Set());
6751
+ byKey.get(pk).add(s.name);
6752
+ }
6753
+ for (const [pk, names] of byKey) {
6754
+ let rec = await _skSourceGet(pk);
6755
+ if (!rec) rec = { pubkey: pk, count: 0, first_seen: now, name_history: [] };
6756
+ if (!Array.isArray(rec.name_history)) rec.name_history = [];
6757
+ const known = new Set(rec.name_history.map(e => e.name));
6758
+ let changed = false;
6759
+ for (const nm of names) if (!known.has(nm)) { rec.name_history.push({ name: nm, first_seen_at_name: now }); changed = true; }
6760
+ if ((rec.count | 0) < names.size) { rec.count = names.size; changed = true; }
6761
+ if (changed) { try { await idbPut(RWA.SOURCES, rec, pk); } catch (_) {} }
6762
+ }
6763
+ }
6764
+ // What prior names has this author (key) published, other than the incoming one? Returns the prior
6765
+ // names (newest-first) + the most-recent prior, for the dialog's rename note. A rename surfaces only
6766
+ // when the incoming name is genuinely NEW for the key (so a same-name re-install / I10 update is not
6767
+ // a "rename").
6768
+ async function _skNameChange(pubkey, name) {
6769
+ const rec = await _skSourceGet(pubkey);
6770
+ const hist = (rec && Array.isArray(rec.name_history)) ? rec.name_history : [];
6771
+ const priorNames = hist.filter(e => e.name !== name).map(e => ({ name: e.name, date: e.first_seen_at_name })).reverse();
6772
+ const isRename = priorNames.length > 0 && !hist.some(e => e.name === name);
6773
+ return { priorNames, nameChange: isRename ? { prev: priorNames[0] } : null };
6774
+ }
6525
6775
  function runtimeListSkills() {
6526
6776
  return Array.from(installedSkills.values()).map(s => ({ skillId: s.skillId, kind: s.kind, name: s.name, verified: s.verified }));
6527
6777
  }
@@ -6590,11 +6840,91 @@ async function runtimeVaultNamespaces() {
6590
6840
  for (const k of Object.keys(rec.entries)) { const i = k.indexOf('\0'); if (i > 0) set.add(k.slice(0, i)); }
6591
6841
  return Array.from(set);
6592
6842
  }
6843
+ // ── I13 (v0.9 §14) — portable vault EXPORT/IMPORT (offline; escrow + account service deferred to
6844
+ // v1). A version-tagged, self-contained `rwa-vault-export/1` file: selected namespaces re-encrypted
6845
+ // under a SEPARATE transport passphrase (PBKDF2-200k + AES-256-GCM, per-namespace salt + check), so
6846
+ // it decrypts on another machine with only the passphrase — no server. The machine-local vault stays
6847
+ // the default; this travels ONLY on an explicit user action. Requires the vault unlocked (to read
6848
+ // plaintext to re-wrap). Never logs the passphrase. CLI/Worker have no access (UI/runtime action only).
6849
+ async function runtimeVaultExport(passphrase, namespaces) {
6850
+ if (!_vaultKey) throw new Error('vault_locked');
6851
+ if (!passphrase) throw new Error('vault_bad_passphrase');
6852
+ const rec = await _vaultLoadRec();
6853
+ const allNs = await runtimeVaultNamespaces();
6854
+ const sel = (Array.isArray(namespaces) && namespaces.length) ? namespaces.filter(n => allNs.includes(n)) : allNs;
6855
+ const out = { rwa: 'rwa-vault-export/1', containerUuid: DOC_UUID, exportedAt: Date.now(), namespaces: sel, entries: {} };
6856
+ for (const ns of sel) {
6857
+ const salt = _skB64(crypto.getRandomValues(new Uint8Array(16)));
6858
+ const ekey = await _vaultDeriveKey(passphrase, salt);
6859
+ const enc = async (text) => { const iv = crypto.getRandomValues(new Uint8Array(12)); const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, ekey, _skUtf8(text))); return { iv: _skB64(iv), ct: _skB64(ct) }; };
6860
+ const items = [];
6861
+ for (const k of Object.keys(rec.entries)) {
6862
+ const i = k.indexOf('\0'); if (i <= 0 || k.slice(0, i) !== ns) continue;
6863
+ const plain = await _vaultDec(rec.entries[k]); // decrypt under the LOCAL vault key…
6864
+ items.push(Object.assign({ key: k.slice(i + 1) }, await enc(plain))); // …re-encrypt under the export key
6865
+ }
6866
+ out.entries[ns] = { salt, check: await enc('rwa-vault-export-ok'), items };
6867
+ }
6868
+ return out;
6869
+ }
6870
+ async function runtimeVaultImport(exportObj, passphrase, opts) {
6871
+ opts = opts || {};
6872
+ if (!_vaultKey) throw new Error('vault_locked'); // need the local key to re-wrap imported items
6873
+ if (!exportObj || exportObj.rwa !== 'rwa-vault-export/1' || !exportObj.entries || typeof exportObj.entries !== 'object') throw new Error('account_export_malformed');
6874
+ const rec = await _vaultLoadRec();
6875
+ const result = { imported: 0, skipped: 0, namespaces: [], containerMismatch: exportObj.containerUuid !== DOC_UUID };
6876
+ for (const ns of Object.keys(exportObj.entries)) {
6877
+ const e = exportObj.entries[ns];
6878
+ if (!e || typeof e.salt !== 'string' || !Array.isArray(e.items)) throw new Error('account_export_malformed');
6879
+ const ekey = await _vaultDeriveKey(passphrase, e.salt);
6880
+ const dec = async (entry) => new TextDecoder().decode(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: _skFromB64(entry.iv) }, ekey, _skFromB64(entry.ct)));
6881
+ if (e.check) { try { await dec(e.check); } catch (_) { throw new Error('vault_decrypt_failed'); } } // wrong passphrase fails here, before any write
6882
+ for (const it of e.items) {
6883
+ let plain; try { plain = await dec(it); } catch (_) { throw new Error('vault_decrypt_failed'); }
6884
+ const dk = ns + '\0' + it.key;
6885
+ if (rec.entries[dk] && !opts.overwrite) { result.skipped++; continue; } // don't clobber without explicit overwrite
6886
+ rec.entries[dk] = await _vaultEnc(plain); // re-encrypt under the LOCAL vault key (usable immediately)
6887
+ result.imported++;
6888
+ }
6889
+ result.namespaces.push(ns);
6890
+ }
6891
+ try { await idbPut(RWA.VAULT, rec); } catch (_) { throw new Error('vault_storage_error'); }
6892
+ return result;
6893
+ }
6894
+ // I13 — live-only account identity (opt-in, sessionStorage rwa_account; default null). Never stamped
6895
+ // into the file; never exposed to skill code (UI/describe only). Escrow/account-service deferred to v1.
6896
+ function runtimeAccountIdentity() {
6897
+ let raw = null; try { raw = sessionStorage.getItem('rwa_account'); } catch (_) { return null; }
6898
+ if (!raw) return null;
6899
+ try { const a = JSON.parse(raw); return a && a.mode ? { mode: a.mode, accountId: a.accountId || null, lastSync: a.lastSync || null } : null; } catch (_) { return null; }
6900
+ }
6593
6901
  // §6 — the bridge's per-call vault gate (mirror of cli/src/skill-manifest.mjs vaultNamespaceAllowed).
6594
6902
  function _skVaultAllowed(skill, ns) {
6595
6903
  const perms = (skill.manifest && Array.isArray(skill.manifest.permissions)) ? skill.manifest.permissions : [];
6596
6904
  return perms.indexOf('vault:' + ns) !== -1;
6597
6905
  }
6906
+ // §5 (I1) — the bridge's per-call bus gate: exact-string bus:<topic> match, no wildcards.
6907
+ function _skBusAllowed(skill, topic) {
6908
+ const perms = (skill.manifest && Array.isArray(skill.manifest.permissions)) ? skill.manifest.permissions : [];
6909
+ return perms.indexOf('bus:' + topic) !== -1;
6910
+ }
6911
+ // §5 (I1b) — per-message subscribe filter. true for all today; I12 wires a peer-allowlist here
6912
+ // (defense-in-depth — a declared bus: perm can't be further runtime-restricted; Shape B holds).
6913
+ function _skBusMessageAllowed(_skill, _envelope) { return true; }
6914
+ // §6 (I3) — the bridge's per-call fs gate: the (already traversal-checked) path must fall under
6915
+ // a declared fsa:<scope> subtree (left-anchored prefix, no wildcards).
6916
+ function _skFsAllowed(skill, path) {
6917
+ const perms = (skill.manifest && Array.isArray(skill.manifest.permissions)) ? skill.manifest.permissions : [];
6918
+ for (const p of perms) {
6919
+ if (String(p).startsWith('fsa:')) { const scope = String(p).slice(4); if (path === scope || path.startsWith(scope + '/')) return true; }
6920
+ }
6921
+ return false;
6922
+ }
6923
+ // §7 (I4) — the bridge's per-call idb gate: exact-string idb:<store> match, no wildcards.
6924
+ function _skIdbAllowed(skill, store) {
6925
+ const perms = (skill.manifest && Array.isArray(skill.manifest.permissions)) ? skill.manifest.permissions : [];
6926
+ return perms.indexOf('idb:' + store) !== -1;
6927
+ }
6598
6928
 
6599
6929
  // ─── Install dialog (shannon, v0.8 §1) — the trust anchor. Mirrors of the cli
6600
6930
  // consent helpers (permissionToProse/compoundRisk/capabilityScan/levenshtein). ──────
@@ -6613,12 +6943,32 @@ function _skPermProse(perm) {
6613
6943
  if (v === '*') return 'Read and write credentials stored under ANY vault namespace — every credential you have stored. Use only for vault administration.';
6614
6944
  return 'Read and write credentials stored under ' + v + '.';
6615
6945
  }
6946
+ if (s.startsWith('bus:')) {
6947
+ return 'Send and receive messages on the "' + s.slice(4) + '" channel shared with other rewritables on this machine.';
6948
+ }
6949
+ if (s.startsWith('fsa:')) {
6950
+ return 'Read and write files under "' + s.slice(4) + '" in this document\'s private storage.';
6951
+ }
6952
+ if (s.startsWith('idb:')) {
6953
+ return 'Read and write the "' + s.slice(4) + '" data store in this document\'s database.';
6954
+ }
6955
+ if (s.startsWith('hook:')) {
6956
+ const ev = s.slice(5);
6957
+ 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;
6958
+ return 'Run automatically ' + when + ' (no network or credential access).';
6959
+ }
6616
6960
  return s;
6617
6961
  }
6618
6962
  function _skCompoundRisk(perms) {
6619
6963
  perms = Array.isArray(perms) ? perms : [];
6620
- if (perms.some(p => String(p).startsWith('vault:')) && perms.some(p => String(p).startsWith('network:')))
6964
+ const has = (t) => perms.some(p => String(p).startsWith(t + ':'));
6965
+ const hasVault = has('vault'), hasNetwork = has('network'), hasBus = has('bus'), hasFsa = has('fsa'), hasIdb = has('idb');
6966
+ if (hasVault && hasNetwork)
6621
6967
  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.';
6968
+ if (hasBus && (hasVault || hasNetwork))
6969
+ 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.';
6970
+ if ((hasFsa || hasIdb) && (hasNetwork || hasVault || hasBus))
6971
+ 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.';
6622
6972
  return null;
6623
6973
  }
6624
6974
  function _skCapabilityScan(code) {
@@ -6647,6 +6997,26 @@ function _skLevenshtein(a, b) {
6647
6997
  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; }
6648
6998
  return prev[n];
6649
6999
  }
7000
+ // I5 (v0.9 §4) — Unicode-confusable skeleton. NFKC + toLowerCase fold case, fullwidth forms,
7001
+ // ligatures, and mathematical-alphanumeric letters to ASCII; this baked table folds the
7002
+ // CROSS-SCRIPT homoglyphs NFKC leaves alone (Cyrillic, Greek, Armenian, a few Latin-extended).
7003
+ // CURATED, not the full UTS #39 confusables.txt: every entry maps a non-ASCII glyph that renders
7004
+ // ~identically to an ASCII letter. ASCII→ASCII is NEVER folded (legit "tool"/"toml" stay distinct).
7005
+ // Byte-mirror of cli/src/skill-manifest.mjs CONFUSABLES. Keys are post-NFKC-lowercase codepoints.
7006
+ const _SK_CONFUSABLES = {
7007
+ 'а': 'a', 'е': 'e', 'о': 'o', 'р': 'p', 'с': 'c',
7008
+ 'у': 'y', 'х': 'x', 'к': 'k', 'ѕ': 's', 'і': 'i',
7009
+ 'ј': 'j', 'ԁ': 'd', 'һ': 'h', 'ԛ': 'q', 'ԝ': 'w',
7010
+ 'ѵ': 'v', 'ӏ': 'l', 'ɠ': 'g',
7011
+ 'α': 'a', 'ο': 'o', 'ρ': 'p', 'ε': 'e', 'ι': 'i',
7012
+ 'κ': 'k', 'ν': 'v', 'υ': 'u', 'χ': 'x', 'τ': 't',
7013
+ 'ϲ': 'c', 'ϳ': 'j',
7014
+ 'օ': 'o', 'ո': 'n',
7015
+ 'ı': 'i', 'ɑ': 'a', 'ɡ': 'g',
7016
+ };
7017
+ function _skNormalize(s) { return String(s == null ? '' : s).normalize('NFKC').toLowerCase(); }
7018
+ function _skSkeleton(s) { let out = ''; for (const ch of _skNormalize(s)) out += (_SK_CONFUSABLES[ch] || ch); return out; }
7019
+ function _skSkeletonDistance(a, b) { return _skLevenshtein(_skSkeleton(a), _skSkeleton(b)); }
6650
7020
  // §3.4 install gates (mirror cli validateInstall).
6651
7021
  const _SK_VAULT_NS = /^[a-z0-9_](?:[a-z0-9_-]{0,62}[a-z0-9_])?$/;
6652
7022
  // §4 permission grammar (mirror cli parsePermission) — validates the VALUE, not
@@ -6670,6 +7040,32 @@ function _skParsePermission(p) {
6670
7040
  if (value.length > 64 || !_SK_VAULT_NS.test(value)) throw new Error(`invalid vault namespace: ${value}`);
6671
7041
  return { tier, value };
6672
7042
  }
7043
+ if (tier === 'bus') {
7044
+ // §5 (I1): topic 1–96 chars, start alphanumeric, charset [A-Za-z0-9:_./%-], NOT a
7045
+ // runtime-reserved prefix (rwa_/rwa:/skills:/workspace:). Mirror of cli parsePermission.
7046
+ if (!value || value.length > 96 || !/^[A-Za-z0-9][A-Za-z0-9:_./%-]*$/.test(value) || /^(?:rwa[:_]|skills:|workspace:)/.test(value))
7047
+ throw new Error(`invalid bus topic: ${value}`);
7048
+ return { tier, value };
7049
+ }
7050
+ if (tier === 'fsa') {
7051
+ // §6 (I3): relative OPFS scope — lowercase [a-z0-9_/-], start+end alphanumeric/underscore,
7052
+ // ≤128 chars, no '.'/'..' (excluded by charset), not _rwa/-prefixed. Mirror of cli.
7053
+ if (!value || value.length > 128 || /^_rwa(?:\/|$)/.test(value) || !/^[a-z0-9_](?:[a-z0-9_/-]*[a-z0-9_])?$/.test(value))
7054
+ throw new Error(`invalid fsa scope: ${value}`);
7055
+ return { tier, value };
7056
+ }
7057
+ if (tier === 'idb') {
7058
+ // §7 (I4): store ^[A-Za-z0-9_][A-Za-z0-9_-]{0,62}$ (≤64 octets, no wildcards); never rwa_*
7059
+ // (reserved) or rwa_vault — distinct subcodes so the dialog can explain. Mirror of cli.
7060
+ if (/^rwa_/.test(value)) throw new Error(value === 'rwa_vault' ? 'idb_vault_store_forbidden' : 'idb_reserved_store');
7061
+ if (!/^[A-Za-z0-9_][A-Za-z0-9_-]{0,62}$/.test(value)) throw new Error(`invalid idb store: ${value}`);
7062
+ return { tier, value };
7063
+ }
7064
+ if (tier === 'hook') {
7065
+ // §9 (I8): lifecycle event, exact enum, no wildcards. Unknown event → unknown_permission_tier.
7066
+ if (value === 'on-commit' || value === 'on-open' || value === 'on-mode-change') return { tier, value };
7067
+ throw new Error(`unknown_permission_tier: hook event ${value}`);
7068
+ }
6673
7069
  throw new Error(`unknown_permission_tier: ${tier}`);
6674
7070
  }
6675
7071
  function _skValidateInstall(skill, vr) {
@@ -6684,11 +7080,26 @@ function _skValidateInstall(skill, vr) {
6684
7080
  // F7: validate each permission's VALUE via the grammar, not just the tier.
6685
7081
  for (const p of perms) {
6686
7082
  try { _skParsePermission(p); }
6687
- catch (e) { errors.push(/unknown_permission_tier/.test(e.message) ? 'unknown_permission_tier' : 'invalid_permission'); }
7083
+ catch (e) {
7084
+ const m = e.message;
7085
+ if (/unknown_permission_tier/.test(m)) errors.push('unknown_permission_tier');
7086
+ else if (m === 'idb_reserved_store' || m === 'idb_vault_store_forbidden') errors.push(m); // §7 distinct subcodes
7087
+ else errors.push('invalid_permission');
7088
+ }
6688
7089
  }
6689
7090
  if (skill.kind === 'compute' && perms.length > 0) errors.push('compute_with_permissions');
7091
+ // §9 (I8): a hook is compute-only — only hook:<event> perms; any other tier → compute_with_permissions.
7092
+ if (skill.kind === 'hook' && perms.some(p => { try { return _skParsePermission(p).tier !== 'hook'; } catch (_) { return false; } })) errors.push('compute_with_permissions');
7093
+ // §8 (I7): view/edit-surface are zero-capability DOM authors — reject any permission + require a
7094
+ // matching typed output contract (view → html-render, edit-surface → dom-transform).
7095
+ if (skill.kind === 'view' || skill.kind === 'edit-surface') {
7096
+ if (perms.length > 0) errors.push('output_skill_with_permissions');
7097
+ const want = skill.kind === 'view' ? 'html-render' : 'dom-transform';
7098
+ if (!skill.output || skill.output.kind !== want) errors.push('invalid_output_kind');
7099
+ }
6690
7100
  if (!vr.signed && perms.length > 0) errors.push('unsigned_with_permissions');
6691
- if (skill.kind === 'tool' && !vr.verified) errors.push('unsigned_capability');
7101
+ // Tools AND hooks carry capability (a hook runs autonomously) → must be signed+verified.
7102
+ if ((skill.kind === 'tool' || skill.kind === 'hook') && !vr.verified) errors.push('unsigned_capability');
6692
7103
  return { ok: errors.length === 0, errors };
6693
7104
  }
6694
7105
  // §1 — the structured trust info the dialog renders.
@@ -6696,22 +7107,56 @@ async function runtimeReviewSkill(envelope) {
6696
7107
  const skill = (envelope && envelope.skill) || {};
6697
7108
  const perms = Array.isArray(skill.permissions) ? skill.permissions : [];
6698
7109
  const vr = { signed: !!(envelope && envelope.signature), verified: await _skVerify(envelope) };
6699
- let lookalike = null;
7110
+ let levMatch = null, skelMatch = null;
6700
7111
  for (const s of installedSkills.values()) {
7112
+ if (!s.manifest || s.manifest.author_pubkey === skill.author_pubkey) continue; // same author ≠ impersonation
6701
7113
  const d = _skLevenshtein(s.name, skill.name);
6702
7114
  // A DIFFERENT key with the same/near name is impersonation. Exact-name (d===0) ALWAYS fires —
6703
7115
  // it's the strongest spoof and the §3.3 case lookalike is responsible for; a near miss (1–2 edits)
6704
7116
  // fires only when both names are long enough that 1–2 edits is "similar", not "unrelated".
6705
7117
  const exact = d === 0, near = d >= 1 && d <= 2 && String(skill.name).length >= 4 && String(s.name).length >= 4;
6706
- if (s.manifest && s.manifest.author_pubkey !== skill.author_pubkey && (exact || near)) { lookalike = s.name; break; }
7118
+ if (!levMatch && (exact || near)) levMatch = s.name;
7119
+ // I5 — Unicode-confusable (skeleton) match. Fires only when confusable folding COLLAPSED a real
7120
+ // byte difference (sd < normalized-Levenshtein) — i.e. a cross-script homoglyph that renders
7121
+ // identically — NOT on an honest ASCII near-miss (where sd === ld). That distinction is what lets
7122
+ // an exact ASCII name from a different key WARN (Invariant 10) while a homoglyph BLOCKS.
7123
+ if (!skelMatch) {
7124
+ const sd = _skSkeletonDistance(s.name, skill.name);
7125
+ const ld = _skLevenshtein(_skNormalize(s.name), _skNormalize(skill.name));
7126
+ if (sd <= 1 && sd < ld) skelMatch = s.name;
7127
+ }
6707
7128
  }
7129
+ // skeleton (homoglyph) takes precedence over a plain Levenshtein warn; a signed skeleton match is
7130
+ // a hard BLOCK (the skill carries capability to escalate), an unsigned one only warns.
7131
+ const lookalike = skelMatch || levMatch;
7132
+ const lookalikeKind = skelMatch ? 'skeleton' : (levMatch ? 'levenshtein' : null);
7133
+ const lookalikeBlock = !!(skelMatch && vr.signed);
7134
+ // I10 (Shape C) — the update delta. If this skillId is already installed, surface the
7135
+ // permission change (added/removed, order-invariant case-sensitive set ops) so the dialog
7136
+ // can show it and require explicit re-affirmation — no silent permission escalation on update.
7137
+ let update = { isUpdate: false, added: [], removed: [], changed: false };
7138
+ try {
7139
+ const prevS = installedSkills.get(await _skSkillId(skill.name, skill.author_pubkey));
7140
+ if (prevS) {
7141
+ const oldPerms = (prevS.manifest && Array.isArray(prevS.manifest.permissions)) ? prevS.manifest.permissions : [];
7142
+ const oldSet = new Set(oldPerms), newSet = new Set(perms);
7143
+ const added = perms.filter(p => !oldSet.has(p)), removed = oldPerms.filter(p => !newSet.has(p));
7144
+ 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 };
7145
+ }
7146
+ } catch (_) { /* a malformed name/id can't match an install → treat as fresh; gates reject it downstream */ }
7147
+ // I5 — per-author name_history: surface a same-key rename so identity reads across name changes.
7148
+ const nameInfo = await _skNameChange(skill.author_pubkey, skill.name);
7149
+ // I6 — TOFU author identity (fingerprint + per-author install count) for the install dialog.
7150
+ const tofu = await _skTofu(skill.author_pubkey);
6708
7151
  return {
6709
7152
  name: skill.name, version: skill.version, kind: skill.kind,
6710
7153
  purpose: skill.description || '(no description provided)',
6711
7154
  author_pubkey: skill.author_pubkey, signed: vr.signed, verified: vr.verified,
6712
7155
  permissions: perms.map(p => ({ perm: p, prose: _skPermProse(p) })),
6713
7156
  compoundRisk: _skCompoundRisk(perms), scanNotes: _skCapabilityScan(skill.code),
6714
- lookalike, gates: _skValidateInstall(skill, vr),
7157
+ lookalike, lookalikeKind, lookalikeBlock,
7158
+ priorNames: nameInfo.priorNames, nameChange: nameInfo.nameChange, tofu,
7159
+ gates: _skValidateInstall(skill, vr), update,
6715
7160
  };
6716
7161
  }
6717
7162
  // §7 — serialize the registry into the frozen #rwa-skills zone. CANONICAL: sorted by
@@ -6751,6 +7196,10 @@ function _skSkillsRegion() {
6751
7196
  async function runtimeInstallSkill(envelope) {
6752
7197
  const review = await runtimeReviewSkill(envelope);
6753
7198
  if (!review.gates.ok) return { ok: false, errors: review.gates.errors };
7199
+ // I5 — a signed skill whose name skeleton-folds to a DIFFERENT author's installed skill is a
7200
+ // homoglyph impersonation: refuse before any code is registered (Invariant 21). Unsigned skills
7201
+ // can't escalate, so review.lookalikeBlock is false for them — they only warn in the dialog.
7202
+ if (review.lookalikeBlock) return { ok: false, errors: ['lookalike_skeleton_blocked'] };
6754
7203
  const forbidden = _skCodeForbidden((envelope.skill || {}).code);
6755
7204
  if (forbidden) return { ok: false, errors: [forbidden] };
6756
7205
  const skill = envelope.skill, id = await _skSkillId(skill.name, skill.author_pubkey);
@@ -6762,6 +7211,10 @@ async function runtimeInstallSkill(envelope) {
6762
7211
  if (prev) installedSkills.set(id, prev); else installedSkills.delete(id);
6763
7212
  return { ok: false, errors: [(e && e.code) || (e && e.message) || 'persist_failed'] };
6764
7213
  }
7214
+ // I5 — record this (key, name) in the per-author name_history (best-effort; never fails the
7215
+ // install). On a same-key rename the new name is appended; identity stays anchored on the key.
7216
+ await _skSourceRecord(skill.author_pubkey, skill.name);
7217
+ _skEvictPool(id); // I2 — an install/update may change the code → drop any stale pooled Workers
6765
7218
  return { ok: true, skillId: id };
6766
7219
  }
6767
7220
  // §7 — remove a skill + persist the emptied/updated zone (same rollback discipline).
@@ -6769,6 +7222,7 @@ async function runtimeUninstallSkill(skillId) {
6769
7222
  const prev = installedSkills.get(skillId);
6770
7223
  if (!prev) return { ok: false, errors: ['not_installed'] };
6771
7224
  installedSkills.delete(skillId);
7225
+ _skEvictPool(skillId); // I2 — drop any pooled Workers for the removed skill
6772
7226
  try {
6773
7227
  await runtimeRegionCommit({ regions: [_skSkillsRegion()], actor: 'skill:uninstall', reachability: 'frozen' });
6774
7228
  } catch (e) {
@@ -6777,6 +7231,126 @@ async function runtimeUninstallSkill(skillId) {
6777
7231
  }
6778
7232
  return { ok: true };
6779
7233
  }
7234
+ // ── I12 (v0.9 §12) — agent registry ops. The #rwa-agents zone is built/committed exactly like
7235
+ // #rwa-skills (skillId-sorted base64 envelopes, runtimeRegionCommit reachability:'frozen'); the two
7236
+ // zones coexist and the region primitive commits each atomically. insertAt is the no-zone fallback
7237
+ // (a fresh skill-host has no agents zone yet → the first install appends one).
7238
+ function buildAgentZone(agents) {
7239
+ const blocks = Array.from(agents.values())
7240
+ .filter(a => a.envelope)
7241
+ .sort((a, b) => (a.agentId < b.agentId ? -1 : a.agentId > b.agentId ? 1 : 0))
7242
+ .map(a => '<script type="application/rwa-agent+json">' + _skB64(_skUtf8(JSON.stringify(a.envelope))) + '<\/script>')
7243
+ .join('');
7244
+ return '<div data-rwa-frozen id="rwa-agents">' + blocks + '</div>';
7245
+ }
7246
+ function _agAgentsRegion() {
7247
+ return {
7248
+ frozenId: 'rwa-agents',
7249
+ select(doc) {
7250
+ const open = /<div\b[^>]*\bid="rwa-agents"[^>]*>/i.exec(doc);
7251
+ if (!open) return null;
7252
+ const close = doc.indexOf('</div>', open.index + open[0].length);
7253
+ return close < 0 ? null : [open.index, close + 6];
7254
+ },
7255
+ insertAt(doc) { return doc.length; },
7256
+ build() { return buildAgentZone(installedAgents); },
7257
+ };
7258
+ }
7259
+ function runtimeListAgents() {
7260
+ return Array.from(installedAgents.values()).map(a => ({ role: a.role, author_pubkey: a.manifest && a.manifest.author_pubkey, verified: a.verified }));
7261
+ }
7262
+ function runtimeAgentActive() {
7263
+ if (!activeAgentRole) return null;
7264
+ const rec = Array.from(installedAgents.values()).find(a => a.role === activeAgentRole);
7265
+ return rec ? { role: rec.role, author_pubkey: rec.manifest && rec.manifest.author_pubkey } : null;
7266
+ }
7267
+ // Switch the active role. The trust anchor is the signature: an unverified (tampered) agent can be
7268
+ // installed but NEVER activated (unverified_agent), so a tampered prompt can't drive modify().
7269
+ function runtimeSetActiveAgent(role) {
7270
+ if (role == null) { activeAgentRole = null; return; }
7271
+ const rec = Array.from(installedAgents.values()).find(a => a.role === role);
7272
+ if (!rec) throw new RwaEditError('agent_not_found', null, { role });
7273
+ if (!rec.verified) throw new RwaEditError('unverified_agent', null, { role });
7274
+ activeAgentRole = role;
7275
+ }
7276
+ // §12 — validate + verify + register an agent, then persist the #rwa-agents zone (same discipline as
7277
+ // runtimeInstallSkill: rollback the in-memory map on a persist failure). A signed-but-unverified
7278
+ // agent registers (verified:false) but can't activate.
7279
+ async function runtimeInstallAgent(envelope) {
7280
+ const agent = (envelope && envelope.agent) || {};
7281
+ const signed = !!(envelope && envelope.signature);
7282
+ const gate = _agValidateInstall(agent, signed);
7283
+ if (!gate.ok) return { ok: false, errors: gate.errors };
7284
+ const verified = await _agVerify(envelope);
7285
+ const id = await _agAgentId(agent.role, agent.author_pubkey);
7286
+ const prev = installedAgents.get(id);
7287
+ installedAgents.set(id, { agentId: id, role: agent.role, verified, manifest: agent, envelope });
7288
+ try {
7289
+ await runtimeRegionCommit({ regions: [_agAgentsRegion()], actor: prev ? 'agent:update' : 'agent:install', reachability: 'frozen' });
7290
+ } catch (e) {
7291
+ if (prev) installedAgents.set(id, prev); else installedAgents.delete(id);
7292
+ return { ok: false, errors: [(e && e.code) || (e && e.message) || 'persist_failed'] };
7293
+ }
7294
+ return { ok: true, agentId: id, verified };
7295
+ }
7296
+ async function runtimeUninstallAgent(agentId) {
7297
+ const prev = installedAgents.get(agentId);
7298
+ if (!prev) return { ok: false, errors: ['not_installed'] };
7299
+ installedAgents.delete(agentId);
7300
+ if (activeAgentRole && prev.role === activeAgentRole && !Array.from(installedAgents.values()).some(a => a.role === activeAgentRole)) activeAgentRole = null;
7301
+ try {
7302
+ await runtimeRegionCommit({ regions: [_agAgentsRegion()], actor: 'agent:uninstall', reachability: 'frozen' });
7303
+ } catch (e) {
7304
+ installedAgents.set(agentId, prev);
7305
+ return { ok: false, errors: [(e && e.code) || (e && e.message) || 'persist_failed'] };
7306
+ }
7307
+ return { ok: true };
7308
+ }
7309
+ // ── I8 (v0.9 §9) — hook firing. Installed `hook` skills that declare hook:<event> run (compute-only,
7310
+ // no bridge) when the mapped lifecycle event fires. Fire-and-forget: never blocks/throws into the
7311
+ // emitter (Inv 24); deterministic skillId order; re-entrancy-guarded (Inv 23); every run logged to
7312
+ // rwa_hook_log (Inv 25). A hook must be verified (boot re-check) to fire.
7313
+ function _hooksForEvent(hookEvent) {
7314
+ return Array.from(installedSkills.values())
7315
+ .filter(s => s && s.kind === 'hook' && s.verified && Array.isArray(s.manifest && s.manifest.permissions) && s.manifest.permissions.indexOf('hook:' + hookEvent) !== -1)
7316
+ .sort((a, b) => (a.skillId < b.skillId ? -1 : a.skillId > b.skillId ? 1 : 0));
7317
+ }
7318
+ function _hookLog(entry) {
7319
+ return idbPut(RWA.HOOK_LOG, entry, entry.ts + '-' + (_hookSeq++)).catch(() => {});
7320
+ }
7321
+ function fireHooks(hookEvent, input) {
7322
+ for (const h of _hooksForEvent(hookEvent)) {
7323
+ if (activeHooks.has(h.skillId)) continue; // a hook can't re-fire its own event
7324
+ activeHooks.add(h.skillId);
7325
+ const t0 = Date.now(), sid = h.skillId;
7326
+ let p; try { p = Promise.resolve(runtimeInvokeSkill(sid, input)); } catch (e) { p = Promise.reject(e); }
7327
+ p.then(
7328
+ result => _hookLog({ skillId: sid, event: hookEvent, ts: t0, input, result, duration: Date.now() - t0 }),
7329
+ err => _hookLog({ skillId: sid, event: hookEvent, ts: t0, input, error: String((err && err.message) || err), duration: Date.now() - t0 })
7330
+ ).then(() => activeHooks.delete(sid), () => activeHooks.delete(sid));
7331
+ }
7332
+ }
7333
+ // The audit trail. getAll in key (≈ chronological) order; never throws.
7334
+ async function runtimeHookLog() {
7335
+ try {
7336
+ const db = await openDB();
7337
+ 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([]); });
7338
+ } catch (_) { return []; }
7339
+ }
7340
+ // Boot prune: drop entries older than 30d, then cap to the 1000 most recent.
7341
+ async function _hookLogPrune() {
7342
+ try {
7343
+ const db = await openDB();
7344
+ 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([]); });
7345
+ const keys = await read('getAllKeys'), vals = await read('getAll');
7346
+ const cutoff = Date.now() - 30 * 24 * 3600 * 1000;
7347
+ const pairs = keys.map((k, i) => ({ k, ts: (vals[i] && vals[i].ts) || 0 }));
7348
+ const fresh = pairs.filter(p => p.ts >= cutoff);
7349
+ const drop = pairs.filter(p => p.ts < cutoff).map(p => p.k)
7350
+ .concat(fresh.length > 1000 ? fresh.sort((a, b) => a.ts - b.ts).slice(0, fresh.length - 1000).map(p => p.k) : []);
7351
+ for (const k of drop) await idbDel(RWA.HOOK_LOG, k);
7352
+ } catch (_) {}
7353
+ }
6780
7354
  // §1 — the install dialog (trust anchor). Renders the review and resolves with the user's choice.
6781
7355
  function showSkillInstallDialog(envelope) {
6782
7356
  return runtimeReviewSkill(envelope).then(rv => new Promise(resolve => {
@@ -6789,11 +7363,47 @@ function showSkillInstallDialog(envelope) {
6789
7363
  const authorHtml = rv.signed
6790
7364
  ? (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.')
6791
7365
  : '⚠ Unsigned — the runtime can\'t verify the author or that the code is unmodified.';
7366
+ // I10 (Shape C) — on an update whose permissions changed, surface the delta and make the
7367
+ // affirm button explicitly about the NEW permissions. No silent escalation: the install
7368
+ // call still fires only on the click below.
7369
+ const upd = rv.update || { isUpdate: false, changed: false, added: [], removed: [] };
7370
+ const updHtml = (upd.isUpdate && upd.changed)
7371
+ ? '<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>'
7372
+ + (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>' : '')
7373
+ + (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>' : '')
7374
+ + '</div>'
7375
+ : '';
7376
+ const affirmText = (upd.isUpdate && upd.changed) ? 'I have reviewed the new permissions and want to update this skill'
7377
+ : upd.isUpdate ? 'Update this skill'
7378
+ : 'I have reviewed this skill and want to install it';
7379
+ // I5 — tiered lookalike notice. A signed homoglyph (lookalikeBlock) is a hard impersonation
7380
+ // block (red, install suppressed); an unsigned homoglyph (kind:'skeleton') is a severe warning;
7381
+ // an honest ASCII near-miss (kind:'levenshtein') is the existing amber name-collision warning.
7382
+ const lookalikeHtml = rv.lookalikeBlock
7383
+ ? '<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>'
7384
+ : rv.lookalikeKind === 'skeleton'
7385
+ ? '<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>'
7386
+ : rv.lookalike
7387
+ ? '<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>'
7388
+ : '';
7389
+ // I5 — same-key rename note (informational, non-permission). Suppressed under a homoglyph block
7390
+ // (impersonation supersedes). Anchors identity on the key: "the author is identified by the key".
7391
+ const nc = rv.nameChange;
7392
+ const nameChangeHtml = (nc && nc.prev && !rv.lookalikeBlock)
7393
+ ? '<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>'
7394
+ + (typeof nc.prev.date === 'number' ? ' on ' + _skEsc(new Date(nc.prev.date).toLocaleDateString()) : '')
7395
+ + '. The current name is <strong>' + _skEsc(rv.name) + '</strong>. The author is identified by the key, not the name.</p>'
7396
+ : '';
7397
+ const canInstall = rv.gates.ok && !rv.lookalikeBlock;
6792
7398
  card.innerHTML =
6793
7399
  '<h2 style="margin:0 0 .2em;font-size:1.25rem">Install ' + _skEsc(rv.name) + '?</h2>' +
6794
7400
  '<p style="margin:.2em 0 1em;color:#444"><strong>What it claims to do:</strong> ' + _skEsc(rv.purpose) + '</p>' +
6795
7401
  '<p style="margin:.2em 0"><strong>Author.</strong> ' + authorHtml + '</p>' +
6796
- (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>' : '') +
7402
+ // I6 TOFU author identity: the key fingerprint + whether you've installed from this author before.
7403
+ (rv.tofu ? '<p style="margin:.2em 0;color:#555">🔑 Author fingerprint: <code>' + _skEsc(rv.tofu.fingerprint) + '</code>. ' + (rv.tofu.firstTime ? 'First time seeing this author.' : 'Trusted — ' + rv.tofu.installs + ' previous install' + (rv.tofu.installs === 1 ? '' : 's') + '.') + '</p>' : '') +
7404
+ lookalikeHtml +
7405
+ nameChangeHtml +
7406
+ updHtml +
6797
7407
  '<p style="margin:1em 0 .2em"><strong>What it can do on your machine</strong></p>' + permsHtml +
6798
7408
  (rv.compoundRisk ? '<p style="background:#fee2e2;border-radius:8px;padding:8px 10px;margin:.6em 0">⚠ ' + _skEsc(rv.compoundRisk) + '</p>' : '') +
6799
7409
  (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>' : '') +
@@ -6801,7 +7411,7 @@ function showSkillInstallDialog(envelope) {
6801
7411
  (rv.gates.ok ? '' : '<p style="background:#fee2e2;border-radius:8px;padding:8px 10px;margin:.6em 0">Cannot install: ' + _skEsc(rv.gates.errors.join(', ')) + '</p>') +
6802
7412
  '<div style="display:flex;gap:10px;margin-top:1.2em;justify-content:flex-end">' +
6803
7413
  '<button data-act="cancel" style="padding:9px 16px;border:1px solid #ccc;border-radius:10px;background:#fff;cursor:pointer">Cancel</button>' +
6804
- (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>' : '') +
7414
+ (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>' : '') +
6805
7415
  '</div>';
6806
7416
  overlay.appendChild(card); document.body.appendChild(overlay);
6807
7417
  const close = (r) => { overlay.remove(); resolve(r); };
@@ -6809,10 +7419,49 @@ function showSkillInstallDialog(envelope) {
6809
7419
  const ib = card.querySelector('[data-act=install]'); if (ib) ib.onclick = async () => close(await runtimeInstallSkill(envelope));
6810
7420
  }));
6811
7421
  }
6812
- // §1.3 — install trigger: pick a .rwa-skill.json, parse, show the dialog.
7422
+ // I12 (v0.9 §12) the AGENT install dialog (SHOULD): role + author key + vault namespaces +
7423
+ // a system-prompt preview (≤200 chars). The install button is offered only for a VERIFIED agent
7424
+ // whose record passes the gates; a tampered (unverified) or gate-failing record shows the reason
7425
+ // and suppresses install. The trust anchor is the key, not the role.
7426
+ function showAgentInstallDialog(envelope) {
7427
+ const agent = (envelope && envelope.agent) || {};
7428
+ const signed = !!(envelope && envelope.signature);
7429
+ return Promise.resolve(_agVerify(envelope)).then(verified => new Promise(resolve => {
7430
+ const gates = _agValidateInstall(agent, signed);
7431
+ const prev = document.getElementById('rwa-agent-install'); if (prev) prev.remove();
7432
+ const overlay = document.createElement('div'); overlay.id = 'rwa-agent-install';
7433
+ overlay.style.cssText = 'position:fixed;inset:0;z-index:99999;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;';
7434
+ const card = document.createElement('div');
7435
+ 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);';
7436
+ const nsSet = Array.isArray(agent.vault_namespace_set) ? agent.vault_namespace_set : [];
7437
+ 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>';
7438
+ const promptPreview = String(agent.system_prompt == null ? '' : agent.system_prompt).slice(0, 200);
7439
+ const authorHtml = signed
7440
+ ? (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.')
7441
+ : '⚠ Unsigned — agents must be signed; this cannot be installed.';
7442
+ const canInstall = gates.ok && verified;
7443
+ card.innerHTML =
7444
+ '<h2 style="margin:0 0 .2em;font-size:1.25rem">Install agent role “' + _skEsc(agent.role) + '”?</h2>' +
7445
+ '<p style="margin:.2em 0 1em;color:#444"><strong>Purpose.</strong> ' + _skEsc(agent.description || '(no description provided)') + '</p>' +
7446
+ '<p style="margin:.2em 0"><strong>Author.</strong> ' + authorHtml + ' <em>The author is identified by the key, not the role name.</em></p>' +
7447
+ '<p style="margin:1em 0 .2em"><strong>Credentials this role can reach</strong></p>' + nsHtml +
7448
+ '<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>' +
7449
+ '<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>' +
7450
+ (gates.ok ? '' : '<p style="background:#fee2e2;border-radius:8px;padding:8px 10px;margin:.6em 0">Cannot install: ' + _skEsc(gates.errors.join(', ')) + '</p>') +
7451
+ '<div style="display:flex;gap:10px;margin-top:1.2em;justify-content:flex-end">' +
7452
+ '<button data-act="cancel" style="padding:9px 16px;border:1px solid #ccc;border-radius:10px;background:#fff;cursor:pointer">Cancel</button>' +
7453
+ (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>' : '') +
7454
+ '</div>';
7455
+ overlay.appendChild(card); document.body.appendChild(overlay);
7456
+ const close = (r) => { overlay.remove(); resolve(r); };
7457
+ card.querySelector('[data-act=cancel]').onclick = () => close({ ok: false, errors: ['cancelled'] });
7458
+ const ib = card.querySelector('[data-act=install]'); if (ib) ib.onclick = async () => close(await runtimeInstallAgent(envelope));
7459
+ }));
7460
+ }
7461
+ // §1.3 / §12 — install trigger: pick a .rwa-skill.json OR .rwa-agent.json, parse, dispatch by format.
6813
7462
  function runtimePromptInstall() {
6814
- const inp = document.createElement('input'); inp.type = 'file'; inp.accept = '.rwa-skill.json,application/json,.json';
6815
- 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); };
7463
+ const inp = document.createElement('input'); inp.type = 'file'; inp.accept = '.rwa-skill.json,.rwa-agent.json,application/json,.json';
7464
+ 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); };
6816
7465
  inp.click();
6817
7466
  }
6818
7467
 
@@ -6845,12 +7494,15 @@ const SKILL_WORKER_PROLOGUE = `(function(){
6845
7494
  'use strict';
6846
7495
  var REMOVE = ['importScripts','Worker','SharedWorker','ServiceWorkerContainer','XMLHttpRequest','WebSocket','EventSource','indexedDB','eval','Function','fetch','WebAssembly'];
6847
7496
  for (var i=0;i<REMOVE.length;i++){ try { Object.defineProperty(self, REMOVE[i], { value: undefined, writable: false, configurable: false }); } catch(_e){} }
6848
- var IDENTITY=null, BRIDGED=false, _seq=0, _pending=new Map(), RUNTIME={};
7497
+ var IDENTITY=null, BRIDGED=false, _seq=0, _pending=new Map(), RUNTIME={}, _subs={};
6849
7498
  function _bridge(type, payload){ return new Promise(function(res,rej){ var id=++_seq; _pending.set(id,{res:res,rej:rej}); self.postMessage({ type:type, id:id, identity_tag:IDENTITY, payload:payload }); }); }
6850
7499
  function _serializeOpts(o){ if(!o||typeof o!=='object') return undefined; var out={}; if(o.method) out.method=String(o.method); if(typeof o.body==='string') out.body=o.body; if(o.headers&&typeof o.headers==='object') out.headers=o.headers; return out; }
6851
7500
  function _installBridge(){
6852
7501
  RUNTIME.fetch=function(url,opts){ return _bridge('bridge:fetch',{ url:String(url), opts:_serializeOpts(opts) }); };
6853
7502
  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}); } };
7503
+ RUNTIME.bus={ publish:function(topic,message){ return _bridge('bridge:bus:publish',{ topic:String(topic), message:message }); }, subscribe:function(topic,cb){ var t=String(topic); return _bridge('bridge:bus:subscribe',{ topic:t }).then(function(){ (_subs[t]=_subs[t]||[]).push(cb); return function(){ var a=_subs[t]||[], i=a.indexOf(cb); if(i>=0) a.splice(i,1); try{ _bridge('bridge:bus:unsubscribe',{ topic:t }); }catch(_e){} }; }); } };
7504
+ 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)}); } };
7505
+ 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)}); } };
6854
7506
  }
6855
7507
  self.onmessage=function(e){
6856
7508
  var msg=e.data; if(!msg) return;
@@ -6862,17 +7514,101 @@ const SKILL_WORKER_PROLOGUE = `(function(){
6862
7514
  .catch(function(err){ self.postMessage({ type:'result', id:msg.id, identity_tag:IDENTITY, ok:false, error:String(err&&err.message||err) }); });
6863
7515
  return;
6864
7516
  }
7517
+ if(msg.type==='bus:message'){ var env=msg.envelope||{}, subs=_subs[env.topic]||[]; for(var i=0;i<subs.length;i++){ try{ subs[i](env); }catch(_e){} } return; } // I1b — deliver to skill-side subscribers
7518
+ if(msg.type==='shutdown'){ try{ self.postMessage({ type:'shutdown_ack', identity_tag:null }); }catch(_e){} return; } // I2 — pool drain handshake
6865
7519
  };
6866
7520
  })();
6867
7521
  `;
6868
7522
 
7523
+ // ── I2 (v0.9 §10) — optional compute-Worker POOL. DISABLED BY DEFAULT: only an explicit
7524
+ // poolingHint {pooling:'enabled'} on a COMPUTE skill (no role) takes this path; every other invoke
7525
+ // (tools, agent-role, no-hint) rides the byte-unchanged spawn→invoke→terminate path below. Pooled
7526
+ // Workers are compute-only (bridgeless, same worker-scoped CSP), keyed by skillId+code-hash (a code
7527
+ // change evicts the pool), bounded by an idle timeout + a hard cap, and drained on shutdown. Per-
7528
+ // invocation isolation is unchanged: each invoke re-inits a fresh identity_tag and races the 5s
7529
+ // timeout; a timeout/error terminates the Worker (never returns it to the pool). Statelessness is
7530
+ // the author's responsibility (Inv 25; no global reset between invokes — documented "pool only if pure").
7531
+ const SKILL_POOLS = new Map(); // skillId → { codeHash, idle:[Worker], lastUsed:Map<Worker,ts> }
7532
+ const SKILL_POOL_CAP = Math.min(4, (typeof navigator !== 'undefined' && navigator.hardwareConcurrency) || 1);
7533
+ let SKILL_POOL_IDLE_MS = 60000; // mutable so the browser proof can shorten it
7534
+ async function _skCodeHash(skillId, code) {
7535
+ return _skB64url(await _skSha256(_skConcat(_skUtf8(String(skillId)), _skNUL, _skUtf8(String(code || '')))));
7536
+ }
7537
+ function _skSpawnComputeWorker(skill) {
7538
+ const blob = new Blob([SKILL_WORKER_PROLOGUE + '\n' + String(skill.code || '')], { type: 'text/javascript' });
7539
+ const url = URL.createObjectURL(blob);
7540
+ const w = new Worker(url); w.__rwaUrl = url; return w;
7541
+ }
7542
+ function _skKillWorker(w) { try { w.terminate(); } catch (_) {} try { URL.revokeObjectURL(w.__rwaUrl); } catch (_) {} }
7543
+ function _skEnforcePoolCap(pool) {
7544
+ while (pool.idle.length > SKILL_POOL_CAP) {
7545
+ let oi = 0; for (let i = 1; i < pool.idle.length; i++) if ((pool.lastUsed.get(pool.idle[i]) || 0) < (pool.lastUsed.get(pool.idle[oi]) || 0)) oi = i;
7546
+ const [w] = pool.idle.splice(oi, 1); pool.lastUsed.delete(w); _skKillWorker(w); // evict oldest-idle
7547
+ }
7548
+ }
7549
+ function _skEvictPool(skillId) { // code-hash may change on install/update/uninstall → drop stale Workers
7550
+ const pool = SKILL_POOLS.get(skillId);
7551
+ if (pool) { pool.idle.forEach(_skKillWorker); SKILL_POOLS.delete(skillId); }
7552
+ }
7553
+ function _skPoolEvictIdle() { // background sweep: terminate Workers idle ≥ SKILL_POOL_IDLE_MS
7554
+ const now = Date.now();
7555
+ for (const pool of SKILL_POOLS.values())
7556
+ pool.idle = pool.idle.filter(w => { if (now - (pool.lastUsed.get(w) || 0) >= SKILL_POOL_IDLE_MS) { pool.lastUsed.delete(w); _skKillWorker(w); return false; } return true; });
7557
+ }
7558
+ async function _skPoolShutdown() { // send shutdown, 500ms grace, terminate (idempotent)
7559
+ const all = [];
7560
+ for (const pool of SKILL_POOLS.values()) for (const w of pool.idle) all.push(w);
7561
+ for (const w of all) { try { w.postMessage({ type: 'shutdown', identity_tag: null }); } catch (_) {} }
7562
+ await new Promise(r => setTimeout(r, 500));
7563
+ for (const w of all) _skKillWorker(w);
7564
+ SKILL_POOLS.clear();
7565
+ }
7566
+ function runtimePoolStats() {
7567
+ const pools = {}; let live = 0;
7568
+ for (const [id, pool] of SKILL_POOLS) { pools[id] = pool.idle.length; live += pool.idle.length; }
7569
+ return { live, cap: SKILL_POOL_CAP, idleMs: SKILL_POOL_IDLE_MS, pools };
7570
+ }
7571
+ // Run ONE invocation on a pooled compute Worker (reuse-or-spawn). Success → return to pool; timeout
7572
+ // or error → terminate (never pool). Compute skills have no bridge, so a pooled Worker only ever
7573
+ // emits `result` — the onmessage here is the full message contract for the pooled path.
7574
+ async function _skPooledInvoke(skillId, skill, input) {
7575
+ const codeHash = await _skCodeHash(skillId, skill.code);
7576
+ let pool = SKILL_POOLS.get(skillId);
7577
+ if (pool && pool.codeHash !== codeHash) { _skEvictPool(skillId); pool = null; }
7578
+ if (!pool) { pool = { codeHash, idle: [], lastUsed: new Map() }; SKILL_POOLS.set(skillId, pool); }
7579
+ const w = pool.idle.pop() || _skSpawnComputeWorker(skill);
7580
+ const tag = crypto.randomUUID();
7581
+ return new Promise((resolve, reject) => {
7582
+ let settled = false;
7583
+ const done = (fn, arg, keep) => {
7584
+ if (settled) return; settled = true; clearTimeout(timer); w.onmessage = null; w.onerror = null;
7585
+ if (keep) { pool.lastUsed.set(w, Date.now()); pool.idle.push(w); _skEnforcePoolCap(pool); }
7586
+ else { pool.lastUsed.delete(w); _skKillWorker(w); }
7587
+ fn(arg);
7588
+ };
7589
+ const timer = setTimeout(() => done(reject, new Error('timeout'), false), 5000); // per-invocation, not per-tenure
7590
+ w.onmessage = (e) => { const m = e.data; if (!m || m.identity_tag !== tag) return; if (m.type === 'result') m.ok ? done(resolve, m.result, true) : done(reject, new Error(m.error || 'runtime_error'), false); };
7591
+ w.onerror = () => done(reject, new Error('runtime_error'), false);
7592
+ w.postMessage({ type: 'init', identity_tag: tag, bridged: false }); // re-init each invoke: fresh tag, never bridged
7593
+ w.postMessage({ type: 'invoke', id: 1, input });
7594
+ });
7595
+ }
6869
7596
  // §5a — invoke an installed skill in an isolated Worker. compute = bridgeless; tool =
6870
7597
  // bridged fetch/vault with per-call origin/namespace enforcement on THIS (main) thread.
6871
7598
  // Per-invocation identity_tag binds responses; 5s timeout; spawn -> invoke -> terminate.
6872
7599
  // (Vault: increment 8. The worker-scoped CSP that walls remote import() is the frozen-<head> 7b meta.)
6873
- function runtimeInvokeSkill(skillId, input) {
7600
+ function runtimeInvokeSkill(skillId, input, opts) {
6874
7601
  const skill = installedSkills.get(skillId);
6875
7602
  if (!skill) return Promise.reject(new Error('skill_not_found'));
7603
+ // I12 — run under a role: vault ops gate on the AGENT's vault_namespace_set (the role is the
7604
+ // capability boundary for this invoke). Resolve BEFORE any Worker spawns so a bad role fails fast.
7605
+ let agentRec = null;
7606
+ const agentRole = opts && opts.agentRole;
7607
+ if (agentRole != null) {
7608
+ agentRec = Array.from(installedAgents.values()).find(a => a.role === agentRole) || null;
7609
+ if (!agentRec) return Promise.reject(new Error('agent_not_found'));
7610
+ if (!agentRec.verified) return Promise.reject(new Error('unverified_agent'));
7611
+ }
6876
7612
  const isTool = skill.kind === 'tool';
6877
7613
  // §3.4/Inv 20 gate at the capability boundary: a `tool` that did not re-verify from the bytes
6878
7614
  // (unsigned, or signed-then-tampered → boot re-verify flips verified:false) must NOT reach the
@@ -6884,6 +7620,9 @@ function runtimeInvokeSkill(skillId, input) {
6884
7620
  // skills (which never passed through runtimeInstallSkill's gate), every kind.
6885
7621
  const forbidden = _skCodeForbidden(skill.code);
6886
7622
  if (forbidden) return Promise.reject(new Error(forbidden));
7623
+ // I2 — opt-in compute-Worker pool. Only a compute skill with an explicit pooling hint and no
7624
+ // agent-role takes the warm path; everything else falls through to the byte-unchanged fresh spawn.
7625
+ if (opts && opts.pooling === 'enabled' && skill.kind === 'compute' && !agentRole) return _skPooledInvoke(skillId, skill, input);
6887
7626
  const identity_tag = crypto.randomUUID();
6888
7627
  const blob = new Blob([SKILL_WORKER_PROLOGUE + '\n' + String(skill.code || '')], { type: 'text/javascript' });
6889
7628
  const url = URL.createObjectURL(blob);
@@ -6892,8 +7631,9 @@ function runtimeInvokeSkill(skillId, input) {
6892
7631
  // F6: cancel an in-flight bridge fetch when the 5s timeout (or any settle)
6893
7632
  // fires, so a slow request doesn't keep running after the skill is finished.
6894
7633
  const _skAc = (typeof AbortController !== 'undefined') ? new AbortController() : null;
7634
+ const busSubs = []; // I1b — active bus subscriptions for THIS invoke; torn down on settle
6895
7635
  return new Promise((resolve, reject) => {
6896
- const finish = (fn, arg) => { if (settled) return; settled = true; clearTimeout(timer); if (_skAc) { try { _skAc.abort(); } catch (_) {} } try { w.terminate(); } catch (_) {} URL.revokeObjectURL(url); fn(arg); };
7636
+ const finish = (fn, arg) => { if (settled) return; settled = true; clearTimeout(timer); if (_skAc) { try { _skAc.abort(); } catch (_) {} } for (const u of busSubs) { try { u(); } catch (_) {} } try { w.terminate(); } catch (_) {} URL.revokeObjectURL(url); fn(arg); };
6897
7637
  const timer = setTimeout(() => finish(reject, new Error('timeout')), 5000);
6898
7638
  const reply = (id, ok, extra) => w.postMessage(Object.assign({ type: 'bridge:response', id, identity_tag, ok }, extra));
6899
7639
  w.onmessage = async (e) => {
@@ -6915,7 +7655,10 @@ function runtimeInvokeSkill(skillId, input) {
6915
7655
  }
6916
7656
  if (msg.type === 'bridge:vault') {
6917
7657
  const pl = msg.payload || {};
6918
- if (!_skVaultAllowed(skill, pl.ns)) { reply(msg.id, false, { error: 'vault_namespace_denied' }); return; } // §6 per-call gate
7658
+ // I12 under an active role the AGENT's vault_namespace_set is the gate (role-scoped);
7659
+ // otherwise the skill's own vault: permissions. Either way an out-of-scope ns is denied.
7660
+ const vaultOk = agentRec ? _agVaultAllowed(agentRec.manifest, pl.ns) : _skVaultAllowed(skill, pl.ns);
7661
+ if (!vaultOk) { reply(msg.id, false, { error: 'vault_namespace_denied' }); return; } // §6 per-call gate
6919
7662
  try {
6920
7663
  if (pl.op === 'get') reply(msg.id, true, { result: await runtimeVaultGet(pl.ns, pl.key) });
6921
7664
  else if (pl.op === 'has') reply(msg.id, true, { result: await runtimeVaultHas(pl.ns, pl.key) });
@@ -6924,6 +7667,71 @@ function runtimeInvokeSkill(skillId, input) {
6924
7667
  } catch (e) { reply(msg.id, false, { error: String(e && e.message || 'vault_error') }); }
6925
7668
  return;
6926
7669
  }
7670
+ if (msg.type === 'bridge:bus:publish') {
7671
+ const pl = msg.payload || {};
7672
+ if (!_skBusAllowed(skill, pl.topic)) { reply(msg.id, false, { error: 'bus_topic_denied' }); return; } // §5 per-call gate
7673
+ // Inv 23: bound the payload (structured-clone-safe + ≤65536 bytes via the JSON proxy)
7674
+ // so a skill can't flood subscribers. A non-serializable payload fails closed.
7675
+ let enc; try { enc = JSON.stringify(pl.message); } catch (_) { reply(msg.id, false, { error: 'bus_error' }); return; }
7676
+ if (enc != null && enc.length > 65536) { reply(msg.id, false, { error: 'bus_error' }); return; }
7677
+ try { runtimeBusPublish(pl.topic, pl.message); reply(msg.id, true, { result: true }); }
7678
+ catch (_) { reply(msg.id, false, { error: 'bus_error' }); }
7679
+ return;
7680
+ }
7681
+ if (msg.type === 'bridge:bus:subscribe') {
7682
+ const pl = msg.payload || {};
7683
+ if (!_skBusAllowed(skill, pl.topic)) { reply(msg.id, false, { error: 'bus_topic_denied' }); return; } // §5 per-call gate
7684
+ // I1b — forward each allowed envelope to the Worker until it settles (finish() tears these
7685
+ // down). The 5s timeout bounds the subscribe CALL (the ok reply), not the subscription.
7686
+ try {
7687
+ const unsub = runtimeBusSubscribe(pl.topic, (env) => {
7688
+ if (!_skBusMessageAllowed(skill, env)) return;
7689
+ try { w.postMessage({ type: 'bus:message', id: msg.id, identity_tag, envelope: { topic: env.topic, from: env.from, at: env.at, message: env.message } }); } catch (_) {}
7690
+ });
7691
+ busSubs.push(unsub);
7692
+ reply(msg.id, true, { result: true });
7693
+ } catch (_) { reply(msg.id, false, { error: 'bus_error' }); }
7694
+ return;
7695
+ }
7696
+ if (msg.type === 'bridge:bus:unsubscribe') { reply(msg.id, true, { result: true }); return; } // MVP: precise teardown deferred; finish() unsubscribes all on settle
7697
+ if (msg.type === 'bridge:fs') {
7698
+ const pl = msg.payload || {}, op = pl.op, p = pl.path;
7699
+ // Reject traversal/invalid paths BEFORE the scope check (mirror of assertUserFsPath); a
7700
+ // declared scope is a left-anchored prefix, so a '..'-free path is safe to literal-match.
7701
+ 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; }
7702
+ if (!_skFsAllowed(skill, p)) { reply(msg.id, false, { error: 'fs_permission_denied' }); return; } // §6 per-call gate
7703
+ try {
7704
+ let result;
7705
+ if (op === 'read') result = await runtimeFsRead(p);
7706
+ else if (op === 'write') { await runtimeFsWrite(p, pl.data); result = true; }
7707
+ else if (op === 'del') { await runtimeFsDel(p); result = true; }
7708
+ else if (op === 'list') result = await runtimeFsList(p);
7709
+ else { reply(msg.id, false, { error: 'fs_path_invalid' }); return; }
7710
+ reply(msg.id, true, { result });
7711
+ } catch (e) {
7712
+ const m = String(e && e.message || ''), nm = e && e.name;
7713
+ const code = (nm === 'QuotaExceededError' || /quota/i.test(m)) ? 'fs_quota_exceeded'
7714
+ : (nm === 'SecurityError' || /OPFS is not available|not supported/i.test(m)) ? 'fs_unsupported'
7715
+ : (nm === 'NotFoundError' || /no file at/i.test(m)) ? 'fs_path_not_found'
7716
+ : 'fs_write_failed';
7717
+ reply(msg.id, false, { error: code });
7718
+ }
7719
+ return;
7720
+ }
7721
+ if (msg.type === 'bridge:idb') {
7722
+ const pl = msg.payload || {}, op = pl.op, store = pl.store;
7723
+ if (!_skIdbAllowed(skill, store)) { reply(msg.id, false, { error: 'idb_store_denied' }); return; } // §7 per-call gate
7724
+ try {
7725
+ let result;
7726
+ if (op === 'get') result = await runtimeDbGet(store, pl.key);
7727
+ else if (op === 'put') { await runtimeDbPut(store, pl.key, pl.value); result = true; }
7728
+ else if (op === 'del') { await runtimeDbDel(store, pl.key); result = true; }
7729
+ else if (op === 'all') result = await runtimeDbAll(store);
7730
+ else { reply(msg.id, false, { error: 'invalid_argument' }); return; }
7731
+ reply(msg.id, true, { result });
7732
+ } catch (e) { reply(msg.id, false, { error: String(e && e.message || 'idb_error') }); }
7733
+ return;
7734
+ }
6927
7735
  };
6928
7736
  w.onerror = (e) => finish(reject, new Error('runtime_error: ' + (e.message || '')));
6929
7737
  w.postMessage({ type: 'init', identity_tag, bridged: isTool });
@@ -6970,6 +7778,15 @@ function runtimeDescribe() {
6970
7778
  affordances.push({ kind: s.kind, name: s.name, skillId: s.skillId, provenance: 'installed', verified: s.verified || false });
6971
7779
  seen.add(key);
6972
7780
  }
7781
+ // I12/SD-04 — union INSTALLED agents (kind:'agent', name:role). Same shape as the static
7782
+ // parseAgentZone projection so live == static (cross-surface affordance convergence).
7783
+ for (const a of installedAgents.values()) {
7784
+ if (!a || typeof a.role !== 'string') continue;
7785
+ const key = 'agent\0' + a.role;
7786
+ if (seen.has(key)) continue;
7787
+ affordances.push({ kind: 'agent', name: a.role, agentId: a.agentId, provenance: 'installed', verified: a.verified || false });
7788
+ seen.add(key);
7789
+ }
6973
7790
  return {
6974
7791
  rwa: 'self-description/1',
6975
7792
  source: 'live',
@@ -6984,6 +7801,9 @@ function runtimeDescribe() {
6984
7801
  // undo-only — there is no redo (re-write-able-spec Invariant 7).
6985
7802
  baseline: { edit: ['lens'], tools: ['apply_dsl_plan', 'apply_edits', 'replace_document'], export: ['html', 'print'], history: ['undo'] },
6986
7803
  activeView: activeView ? activeView.name : null,
7804
+ // I13 (v0.9 §14) — opt-in account identity, LIVE-only (never stamped into the file). null unless
7805
+ // the user linked an account this session (sessionStorage rwa_account); machine-local is the default.
7806
+ accountIdentity: runtimeAccountIdentity(),
6987
7807
  };
6988
7808
  }
6989
7809
 
@@ -7394,7 +8214,7 @@ async function modify(instr, lensMeta = null, opts = null) {
7394
8214
  const frozenZones = extractFrozenZones(cur);
7395
8215
 
7396
8216
  const messages = [
7397
- { role: 'system', content: SYSTEM_PROMPT },
8217
+ { role: 'system', content: resolveSystemPrompt() },
7398
8218
  { role: 'user', content: buildUserPrompt(instr, cur, frozenZones) }
7399
8219
  ];
7400
8220
 
@@ -7581,7 +8401,7 @@ async function modifyViaBridge(instr, lensMeta = null) {
7581
8401
  const vOpts = { assets: vimg.assets, orphans: vimg.orphans };
7582
8402
  const frozenZones = extractFrozenZones(cur);
7583
8403
 
7584
- const prompt = SYSTEM_PROMPT + '\n\n' + buildUserPrompt(instr, cur, frozenZones) +
8404
+ const prompt = resolveSystemPrompt() + '\n\n' + buildUserPrompt(instr, cur, frozenZones) +
7585
8405
  '\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' +
7586
8406
  '{"tool":"apply_dsl_plan","envelope":{"version":"rwa-edit-dsl/1","ops":[/* per the rules above */]}}\n\n' +
7587
8407
  '{"tool":"apply_edits","envelope":{"version":"rwa-edit/1","edits":[{"find":"...","replace":"..."}]}}\n\n' +
@@ -8117,6 +8937,19 @@ document.addEventListener('keydown', e => {
8117
8937
  const skMap = await readTrustworthySkills(doc);
8118
8938
  installedSkills.clear();
8119
8939
  skMap.forEach((v, k) => installedSkills.set(k, v));
8940
+ // I12 — rebuild the agent registry from the frozen #rwa-agents zone (re-verify each). No agent
8941
+ // is active by default; an unverified (tampered) agent is registered but can't activate.
8942
+ const agMap = await readTrustworthyAgents(doc);
8943
+ installedAgents.clear();
8944
+ agMap.forEach((v, k) => installedAgents.set(k, v));
8945
+ // I5 — reconcile the per-author name_history (rwa_sources) from the in-file manifests, so a
8946
+ // fresh load (or an IDB-cleared one) still knows the renames recorded in the frozen zone.
8947
+ await runtimeBuildSourceIndex();
8948
+ // I8 — seed the mode tracker for on-mode-change's `previous`, prune the hook log, then fire
8949
+ // on-open hooks (fire-and-forget so a hook can never slow first paint; results logged).
8950
+ _lastHookMode = rwaMode;
8951
+ await _hookLogPrune();
8952
+ fireHooks('on-open', { event: 'on-open', docUuid: DOC_UUID });
8120
8953
  } catch (_) { /* no installed skills */ }
8121
8954
  await _vaultReimportSession().catch(() => {}); // v0.8 §6 — restore an unlocked vault within the tab session
8122
8955
  // Spec §7: expose the public runtime API. Only constructed on the success
@@ -8154,12 +8987,18 @@ document.addEventListener('keydown', e => {
8154
8987
  describe: runtimeDescribe, // rwa-identity/1 — what this container is + can do
8155
8988
  listSkills: runtimeListSkills, // v0.8 §8 — installed skills (provenance:'installed')
8156
8989
  invokeSkill: runtimeInvokeSkill, // v0.8 §5a — run a skill in an isolated Worker
8990
+ invokeEditSurface: runtimeInvokeEditSurface, // v0.9 §8 (I7) — run an edit-surface skill → apply its rwa-edit/1 transform
8991
+ poolStats: runtimePoolStats, // v0.9 §10 (I2) — compute-Worker pool observability {live,cap,idleMs,pools}
8157
8992
  reviewSkill: runtimeReviewSkill, // v0.8 §1 — structured trust info for the install dialog
8158
8993
  installSkill: runtimeInstallSkill, // v0.8 §1/§7 — gates + verify + register + persist to the frozen zone (survives reload)
8159
8994
  uninstallSkill: runtimeUninstallSkill, // v0.8 §7 — remove + persist
8160
8995
  showInstallDialog: showSkillInstallDialog, // v0.8 §1 — render the consent dialog for an envelope → resolves with the choice
8161
8996
  promptInstall: runtimePromptInstall, // v0.8 §1.3 — pick a .rwa-skill.json → show the dialog
8162
- vault: { get: runtimeVaultGet, set: runtimeVaultSet, has: runtimeVaultHas, namespaces: runtimeVaultNamespaces, unlock: runtimeVaultUnlock, lock: runtimeVaultLock, isLocked: runtimeVaultIsLocked }, // v0.8 §6
8997
+ discoverSkills: runtimeDiscoverSkills, // v0.9 §11 (I6) GET the marketplace index (opt-in network)
8998
+ fetchSkillFromIndex: runtimeFetchSkillFromIndex, // v0.9 §11 (I6) — fetch + client-side-verify an indexed skill
8999
+ vault: { get: runtimeVaultGet, set: runtimeVaultSet, has: runtimeVaultHas, namespaces: runtimeVaultNamespaces, unlock: runtimeVaultUnlock, lock: runtimeVaultLock, isLocked: runtimeVaultIsLocked, export: runtimeVaultExport, import: runtimeVaultImport }, // v0.8 §6 + v0.9 §14 (I13) portable export/import
9000
+ agents: { list: runtimeListAgents, active: runtimeAgentActive, setActive: runtimeSetActiveAgent, install: runtimeInstallAgent, uninstall: runtimeUninstallAgent, message: runtimeAgentMessage, showInstallDialog: showAgentInstallDialog }, // v0.9 §12 — multi-agent roles
9001
+ hookLog: runtimeHookLog, // v0.9 §9 — the hook audit trail (rwa_hook_log)
8163
9002
  };
8164
9003
  // `status` is a getter so each read returns a fresh snapshot of
8165
9004
  // dirty/fsa/storage; an enumerable data prop would let stale references
@@ -8175,6 +9014,10 @@ document.addEventListener('keydown', e => {
8175
9014
  configurable: false,
8176
9015
  });
8177
9016
  startWorkspacePresence();
9017
+ // I2 (v0.9 §10) — background idle eviction (every 30s, terminate compute Workers idle ≥ idleMs)
9018
+ // + drain the pool on unload (shutdown handshake + 500ms grace). No-op until a pooled invoke runs.
9019
+ setInterval(_skPoolEvictIdle, 30000);
9020
+ window.addEventListener('pagehide', () => { _skPoolShutdown(); });
8178
9021
  // §5.10: the presentation render mode ships ONLY for presentation
8179
9022
  // containers. For every other kind this block is skipped entirely —
8180
9023
  // activeView stays null, no provider is registered, no chrome is built,