Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .claude/skills/e2e-verify/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,13 @@ The GAME line: `game-state`, `hud-actions`, `logic-nodes`, `collectibles-v2`,
`scene-folders` and the `game-loop-v2/v3/v4` acceptance suites, plus (R3a)
`sdk-game-seams` (78 since 29: onChange/freeRegion/setNodesData) — the module-facing seams api.game/peerVars/flow/playerPosition, the
`{replicate:false}` local pulse, the round-aware `ctx.trigger`, and the counterfactual
that the migrated collectible pieces are GONE from core. `module-toolbox` covers the
that the migrated collectible pieces are GONE from core. The cloudApi v3.1 seams (1.15.1) are
`dial-metadata` (43: the join dial's `cloud` metadata + `authProvider.decide` against a
stubbed peer, then two real peers over signaling — needs PEER_CONFIG for its last section;
NOTE a knock's conn IS closed at once like every pending request's, the CARD is the state) and
`ai-presets` (45: `api.aiPresets` create/update/remove, both activation guards with their
counterfactuals, `setMeshJobStatus` rendering under a fake RUNNING job pushed into
`s.meshJobs.meshJobs`). `module-toolbox` covers the
toolbox seams incl. the `sidebar: false` opt-out and openToolbox/closeToolbox/
toggleToolbox. **`trigger-log-sync`** (56, three peers) covers DEVX #18 - the handshake
reply for the trigger log, and the epoch that keeps arriving history readable while making
Expand Down
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,32 @@
per release, newest first. HTML comments like this one are stripped before
rendering, so maintainer notes stay out of the user-facing window. -->

## 1.15.1 — Knock, and a key you never typed 🔑

### 🚪 Gated cloud rooms work end to end

- 🚪 **A room with a code, or a knock with your name.** Joining a cloud room now carries what
the room asks for: the right code lets you straight in, a wrong one is declined at once, and
a knock shows the host *"Ada wants to join Amber Mesa"* on the approval card. (The plugin
shipped this with 1.15.0 and fell back to the plain card until the engine could carry it.)

### ⚡ Hosted AI, zero configuration

- ⚡ **Signed in? The assistant can be set up for you.** The cloud plugin can now add a hosted
provider to Settings ▸ AI — no key, URL or model to type. A provider you configured yourself
always stays the selected one; the hosted row sits beside it to switch to. (Experimental —
it appears only while our hardware is reachable.)
- ⏳ **"You are #3 in queue for the hosted AI."** The mesh-job card shows your place in the
hosted queue while a text-to-3D job waits.

### 🧩 For plugin authors

- 🧩 **cloudApi v3.1**, all additive (no hooks-version bump): `connectToPeer(peerId, cloudMeta)`
rides plain JSON on the join dial, `authProvider.decide(peerId, cloudMeta)` answers
admit / deny / `{label}`, `cloudApi.dialMeta` is the probe; `api.aiPresets.{userHas, seed}`
own one plugin-managed provider preset per domain, `api.setMeshJobStatus(fn)` is the line
under the mesh-job card.

## 1.15.0 — Where everyone is 👥

### 👥 One session, and you can see who is in which scene (roadmap #22, the last three rounds)
Expand Down
154 changes: 103 additions & 51 deletions CLAUDE.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions check-baseline.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"comment": "27-I: the svelte-check floor, read ONLY by scripts/check-ratchet.cjs. It used to be hardcoded in release.yml's shell block, where it went stale (362 while the tree measured 359). Ratchet it DOWN whenever a change legitimately removes errors - that is the project convention, and --update does it in one command.",
"errors": 341,
"errors": 336,
"warnings": 47,
"measured": "2026-09-17"
"measured": "2026-09-20"
}
2 changes: 1 addition & 1 deletion src/components/menu/ConnectInfoDrawer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@
<ul class="cxd-toast-list">
{#each $pendingApprovals as a (a.peerId)}
<li class="cxd-toast cxd-live" data-kind="request">
<div class="cxd-toast-text">Connection request from <span class="cxd-mono">{String(a.peerId).toUpperCase()}</span></div>
<div class="cxd-toast-text">Connection request from <span class="cxd-mono">{String(a.peerId).toUpperCase()}</span>{#if a.label}<span class="cxd-knock"> — {a.label}</span>{/if}</div>
<div class="cxd-live-actions">
{#if $rolesInfo}
<button class="cxd-approve" onclick={() => approveRequest(a, null)} title="Approve as viewer">View only</button>
Expand Down
18 changes: 18 additions & 0 deletions src/components/menu/MeshJobsCard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,20 @@
// a small stack near the top-right — one card per active/finished job with a
// progress bar, cancel (while running) and dismiss (when finished).
import { meshJobs, cancelMeshJob, dismissMeshJob } from '$lib/ai/meshJobs';
// v3.1 (roadmap 29 G-3): a cloud plugin's own line under the status row — the
// hosted queue position. Null without a plugin; see cloudHooks.meshJobStatus.
import { meshJobStatus } from '$lib/cloudHooks';

/** the plugin's line, re-read when the fn is (re)set OR the job list changes — the
* second argument is unused on purpose: it is the dependency (the `screensFor`
* idiom, a `get()`-style read inside a helper registers nothing). */
function pluginLine(fn: (() => string | null) | null, _jobs: unknown): string {
try {
return (fn && fn()) || '';
} catch {
return '';
}
}

const RUNNING = new Set(['submitting', 'running', 'importing']);
function label(status: string) {
Expand Down Expand Up @@ -38,6 +52,10 @@
<span class="min-w-0 flex-1 truncate text-[11px] text-red-300/80" title={job.error}>{job.error}</span>
{/if}
</div>
{#if RUNNING.has(job.status)}
{@const line = pluginLine($meshJobStatus, $meshJobs)}
{#if line}<div class="mesh-job-plugin-line text-[11px] text-gray-400">{line}</div>{/if}
{/if}
{#if RUNNING.has(job.status)}
<div class="mt-1 h-1 w-full overflow-hidden rounded-sm bg-gray-700">
{#if job.progress != null}
Expand Down
7 changes: 7 additions & 0 deletions src/components/menu/Toasts.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,8 @@ style="z-index: var(--z-toast); pointer-events: none;"
<div class="tp-toast-text">
Connection request <span class="cxreq-id">{String(approval.peerId).slice(0, 6).toUpperCase()}</span>
</div>
<!-- 29: a cloud plugin's label for the knock ("Ada wants to join Amber Mesa") -->
{#if approval.label}<div class="cxreq-label">{approval.label}</div>{/if}
<!-- 27-E: how long they have been waiting. An EXPIRED card stays approvable —
a missed request is worse than a stale card, and approving still just
dials back; if they gave up, that dial answers with peer-unavailable,
Expand Down Expand Up @@ -731,6 +733,11 @@ style="z-index: var(--z-toast-low); pointer-events: none;"
font-size: 11px;
opacity: 0.65;
}
.cxreq-label {
margin-top: 2px;
font-size: 12px;
overflow-wrap: anywhere;
}
.cxreq-age.expired {
opacity: 0.9;
color: #fbbf24;
Expand Down
7 changes: 6 additions & 1 deletion src/lib/ai/meshProviders.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ import { safeStorage } from '../safeStorage';
* @property {string} [assetProxy] meshy: CORS proxy for the GLB download — Meshy's
* assets CDN sends no Access-Control-Allow-Origin, so browsers can't fetch the
* result directly. Blank = the build-time VITE_ASSET_PROXY default (meshy.js).
* @property {string} [managedBy] cloudApi v3.1 (roadmap 29 G-3): the tag of the cloud
* plugin that OWNS this entry (`api.aiPresets.seed`). Absent = the user's own (see
* ai/providers.js — same rule, same reason).
*/

/**
Expand Down Expand Up @@ -121,7 +124,9 @@ export function addMeshProvider(config) {
...(config.workflowJson !== undefined ? { workflowJson: config.workflowJson } : {}),
...(config.outputNodeId !== undefined ? { outputNodeId: String(config.outputNodeId).trim() } : {}),
...(config.mode !== undefined ? { mode: config.mode } : {}),
...(config.assetProxy !== undefined ? { assetProxy: String(config.assetProxy).trim() } : {})
...(config.assetProxy !== undefined ? { assetProxy: String(config.assetProxy).trim() } : {}),
// v3.1: a plugin-managed preset keeps its tag
...(typeof config.managedBy === 'string' && config.managedBy ? { managedBy: config.managedBy } : {})
};
const list = [...get(meshProviders), entry];
meshProviders.set(list);
Expand Down
6 changes: 6 additions & 0 deletions src/lib/ai/providers.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ import { safeStorage } from '../safeStorage';
* @property {string[]} [models] model ids the endpoint reported on the last
* successful Test connection (GET /models) — Settings' model-picker suggestions.
* Persisted so the picker still works after a reload without re-fetching.
* @property {string} [managedBy] cloudApi v3.1 (roadmap 29 G-3): the tag of the cloud
* plugin that OWNS this entry (`api.aiPresets.seed`). ABSENT = the user's own — every
* entry saved before the seam existed reads as theirs with no migration, and a preset
* is untouchable-by-construction for anything but its own tag.
*/

/**
Expand Down Expand Up @@ -164,6 +168,8 @@ export function addAiProvider(config) {
if (Array.isArray(config.models) && config.models.length) {
entry.models = config.models.map(String).slice(0, 500);
}
// v3.1: a plugin-managed preset keeps its tag (the whitelist above used to drop it)
if (typeof config.managedBy === 'string' && config.managedBy) entry.managedBy = config.managedBy;
const list = [...get(aiProviders), entry];
aiProviders.set(list);
persistProviders(list);
Expand Down
15 changes: 15 additions & 0 deletions src/lib/cloudHooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ export function hasCapabilityProvider() {
* is a synchronous lookup against it). Returns false/undefined = defer to the
* normal whitelist+approval flow.
* onPeerConnect(peerId): void — observe accepted connections (identity binding).
* decide(peerId, cloudMeta): 'admit' | 'deny' | {label} — 29 (rooms access). When
* present it replaces `authorize`: `cloudMeta` is the joiner's dial data
* (cloudApi.connectToPeer's 2nd arg, null when absent); 'deny' refuses with no
* approval card; an object is the normal card, carrying `label`.
* @type {any}
*/
let authProvider = null;
Expand Down Expand Up @@ -191,6 +195,17 @@ export const CLOUD_HOOKS_VERSION = 3;
* @type {import('svelte/store').Writable<{name: string, version: string} | null>} */
export const cloudPluginInfo = writable(null);

/**
* v3.1 (roadmap 29 G-3, hosted AI): ONE extra status line under each mesh-job card,
* e.g. "You are #3 in queue for the hosted AI". The plugin installs a FUNCTION
* (`api.setMeshJobStatus(fn)`); `fn()` → string | null, null renders nothing. It is a
* function rather than a string so the plugin owns the wording and the moment: core
* re-reads it whenever the store is SET (a function is never `===` its last value in
* svelte's equality check, so setting the same fn again is a poke) and whenever the
* job list changes. Null = no plugin, the card is byte-identical.
* @type {import('svelte/store').Writable<(() => string | null) | null>} */
export const meshJobStatus = writable(null);

/**
* 21-G5 (F7): CROSS-SCENE PRESENCE, the rolesInfo-bridge shape one domain over. The
* rooms plugin publishes who is in the project's OTHER rooms/scenes and core renders
Expand Down
129 changes: 126 additions & 3 deletions src/lib/cloudPlugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ import {
import { currentLevel } from './levels';
import { myPlayMode, peerPlayModes } from './gamePresence';
import { safeStorage } from './safeStorage';
// v3.1 (roadmap 29 G-3): the hosted-AI preset seam. Both provider modules are LEAVES
// (svelte/store + safeStorage), so a static edge from here closes nothing.
import { aiProviders, aiActiveProvider, aiEnabled, addAiProvider, updateAiProvider, removeAiProvider, setAiActiveProvider, setAiEnabled } from './ai/providers';
import { meshProviders, meshActiveProvider, meshGenEnabled, addMeshProvider, updateMeshProvider, removeMeshProvider, setMeshActiveProvider, setMeshGenEnabled } from './ai/meshProviders';
import { meshJobStatus } from './cloudHooks';

// 28-A (roadmap #28, publish · play · remix): the seams below reach cycle-sensitive
// modules — sessions is history-family, cameraBookmarks imports objectActions, playMode is
Expand Down Expand Up @@ -90,12 +95,115 @@ export async function startCloudPlugin() {
}
}

/**
* v3.1 (roadmap 29 G-3, hosted AI) — ONE plugin-managed provider preset, in BOTH
* provider lists. Core keeps ownership of the stores: the plugin describes one entry
* per domain and never sees the others' keys. `tag` marks the plugin's own entry
* (`managedBy` on the config), so a user-created provider — which carries no tag — is
* untouchable by construction rather than by care.
*
* ACTIVATION takes TWO guards, both required: `activate.<domain>` (the caller's
* permission — the plugin passes !userHas) AND the slot being empty or already ours.
* Either alone leaves a hole: the first misses "they have a provider but it is not the
* selected one", the second misses "they just deleted the selected one". And one
* trap the lane brief spelled out: `addAiProvider`/`addMeshProvider` make the FIRST
* provider active whenever the slot is empty, so a seed that is NOT allowed to
* activate has to put the previous pointer back, or guard 1 leaks through the add.
*
* `enable: true` also flips the master aiEnabled / meshGenEnabled, and ONLY when our
* entry is the active one (the plugin gates that to once per device on its side).
* Switching a feature on for somebody is a bigger liberty than offering them a row.
*
* `null` for `ai` or `mesh` REMOVES that entry (the store's own remove re-points the
* active slot at the first remaining provider, exactly as a user delete does).
*/
const aiPresets = {
/** does the user have a provider of their OWN (the tagged entry excluded)?
* @param {string} tag @returns {{ai: boolean, mesh: boolean}} */
userHas: (tag) => ({
ai: get(aiProviders).some((p) => p.managedBy !== tag),
mesh: get(meshProviders).some((p) => p.managedBy !== tag)
}),
/**
* Create / update / remove the managed entry in aiProviders + meshProviders.
* @param {string} tag
* @param {{ai?: any, mesh?: any, activate?: {ai?: boolean, mesh?: boolean}, enable?: boolean}} opts
* @returns {{ai: {id: string, active: boolean}, mesh: {id: string, active: boolean}, enabled: {ai: boolean, mesh: boolean}}}
*/
seed(tag, { ai, mesh, activate, enable } = {}) {
if (typeof tag !== 'string' || !tag) throw new Error('aiPresets.seed: a non-empty tag is required');
const mayActivate = { ai: activate?.ai !== false, mesh: activate?.mesh !== false };
const out = { ai: { id: '', active: false }, mesh: { id: '', active: false }, enabled: { ai: false, mesh: false } };

out.ai.id = seedOne(tag, ai, mayActivate.ai, {
list: aiProviders,
active: aiActiveProvider,
add: (/** @type {any} */ cfg) => addAiProvider({ ...cfg, preset: 'custom' }),
update: updateAiProvider,
remove: removeAiProvider,
setActive: setAiActiveProvider
});
out.ai.active = !!out.ai.id && get(aiActiveProvider) === out.ai.id;
if (enable && out.ai.active && !get(aiEnabled)) {
setAiEnabled(true);
out.enabled.ai = true;
}

out.mesh.id = seedOne(tag, mesh, mayActivate.mesh, {
list: meshProviders,
active: meshActiveProvider,
add: (/** @type {any} */ cfg) => addMeshProvider({ kind: 'comfyui', ...cfg }),
update: updateMeshProvider,
remove: removeMeshProvider,
setActive: setMeshActiveProvider
});
out.mesh.active = !!out.mesh.id && get(meshActiveProvider) === out.mesh.id;
if (enable && out.mesh.active && !get(meshGenEnabled)) {
setMeshGenEnabled(true);
out.enabled.mesh = true;
}
return out;
}
};

/**
* One domain of `aiPresets.seed`. Returns the managed entry's id, or '' when there is
* none (removed, or nothing offered). @param {string} tag @param {any} cfg
* @param {boolean} mayActivate @param {any} s the domain's store + writers
* @returns {string}
*/
function seedOne(tag, cfg, mayActivate, s) {
const existing = get(s.list).find((/** @type {any} */ p) => p.managedBy === tag) || null;
if (!cfg || typeof cfg !== 'object') {
if (existing) s.remove(existing.id);
return '';
}
// the plugin describes FIELDS; the identity (id) and the ownership (managedBy) are
// core's to assign, so a config carrying either cannot re-point an entry
const fields = { ...cfg };
delete fields.id;
delete fields.managedBy;
let id = existing?.id || '';
if (existing) {
s.update(id, { ...fields, managedBy: tag });
} else {
const before = get(s.active);
id = s.add({ ...fields, managedBy: tag });
// the store activates a first provider on its own — undo that when the caller
// may not activate (guard 1 would otherwise leak through the add)
if (!mayActivate && get(s.active) !== before) s.setActive(before);
}
const active = get(s.active);
if (mayActivate && (!active || active === id)) s.setActive(id);
return id;
}

/**
* The API surface handed to a cloud plugin's `register(api)`. Deliberately small
* and stable: peer hooks, UI mount points, and a couple of context accessors.
* @returns {any}
*/
function makeCloudApi() {
export function makeCloudApi() {
return {
/** contract version — bump when the surface changes incompatibly.
* v2 (roadmap #14 PM): + mountProfile, mountConnectDrawer.
Expand Down Expand Up @@ -131,8 +239,13 @@ function makeCloudApi() {
/** the id of the peer whose session we joined, or null when WE are the host —
* lets the plugin make the session host the roles authority (admin) — v2.1 */
sessionHost: () => get(sessionHost),
/** dial a peer through the normal request flow (join a room) — v2 */
connectToPeer: (/** @type {string} */ peerId) => requestConnect(peerId),
/** dial a peer through the normal request flow (join a room) — v2. 29: an optional
* `cloudMeta` (plain JSON, ≤ 1 KB) rides the join dial to the host's auth hook
* (`decide(peerId, cloudMeta)`) — a knock's name, a room-code proof. */
connectToPeer: (/** @type {string} */ peerId, /** @type {any} */ cloudMeta) => requestConnect(peerId, cloudMeta),
/** 29: this engine carries `cloudMeta` on dials and consults `authProvider.decide`
* (admit / deny / knock-with-label). Absent on older engines — probe it. */
dialMeta: true,

// --- plugin message channel (replicate the plugin's own state) ---
/** broadcast a cloud message to all peers (roles, room announces) */
Expand Down Expand Up @@ -332,6 +445,16 @@ function makeCloudApi() {
* (logout). Core never learns what is behind the provider. */
setCommunityProvider: (/** @type {any} */ provider) => setCommunityProvider(provider),

// --- v3.1 (roadmap 29 G-3): hosted AI ---
/** ONE plugin-managed provider preset in Settings ▸ AI — `userHas(tag)` +
* `seed(tag, {ai, mesh, activate, enable})`; see `aiPresets` above. Additive and
* typeof-probed: no CLOUD_HOOKS_VERSION bump, an older engine simply has none. */
aiPresets,
/** an extra status line under each mesh-job card ("You are #3 in queue"). Pass
* `fn() → string | null` (null renders nothing) or null to remove it; call it
* again whenever the line may have changed — every set is a poke. */
setMeshJobStatus: (/** @type {any} */ fn) => meshJobStatus.set(typeof fn === 'function' ? fn : null),

// --- utilities ---
toast: showToast
};
Expand Down
Loading
Loading