WIP: Claude Code progress on #17

Automated salvage commit — agent session ended (exit 124).
Work in progress, may need continuation.
This commit is contained in:
Alexander Whitestone
2026-03-23 16:30:13 -04:00
parent cbeaa61083
commit 448bce87df
5 changed files with 403 additions and 13 deletions

View File

@@ -15,6 +15,8 @@ let _canvas = null;
let _camera = null;
let _timmyGroup = null;
let _applySlap = null;
let _meterObjects = [];
let _meterCallback = null;
const _raycaster = new THREE.Raycaster();
const _pointer = new THREE.Vector2();
@@ -33,6 +35,11 @@ export function registerSlapTarget(timmyGroup, applyFn) {
_applySlap = applyFn;
}
export function registerMeterTarget(objects, onClickFn) {
_meterObjects = objects || [];
_meterCallback = onClickFn;
}
export function initInteraction(camera, renderer) {
_camera = camera;
_canvas = renderer.domElement;
@@ -49,9 +56,11 @@ export function disposeInteraction() {
_canvas.removeEventListener('pointerdown', _onPointerDown, { capture: true });
_canvas = null;
}
_camera = null;
_timmyGroup = null;
_applySlap = null;
_camera = null;
_timmyGroup = null;
_applySlap = null;
_meterObjects = [];
_meterCallback = null;
}
// ── Internal ──────────────────────────────────────────────────────────────────
@@ -71,6 +80,17 @@ function _distPx(sx, sy, screenPos) {
return Math.sqrt(dx * dx + dy * dy);
}
function _hitTestMeter(clientX, clientY) {
if (!_meterObjects.length || !_meterCallback || !_camera || !_canvas) return false;
const rect = _canvas.getBoundingClientRect();
_pointer.x = ((clientX - rect.left) / rect.width) * 2 - 1;
_pointer.y = ((clientY - rect.top) / rect.height) * -2 + 1;
_raycaster.setFromCamera(_pointer, _camera);
const hits = _raycaster.intersectObjects(_meterObjects, false);
if (hits.length > 0) { _meterCallback(); return true; }
return false;
}
function _hitTestTimmy(clientX, clientY) {
if (!_timmyGroup || !_applySlap || !_camera || !_canvas) return false;
@@ -135,7 +155,8 @@ function _onPointerDown(event) {
const dt = Date.now() - _tapStart;
const moved = Math.hypot(upEvent.clientX - _tapX, upEvent.clientY - _tapY);
if (dt < 220 && moved < 18) {
// Short tap — try slap then inspect
// Short tap — try meter, then slap, then inspect
if (_hitTestMeter(upEvent.clientX, upEvent.clientY)) return;
if (!_hitTestTimmy(upEvent.clientX, upEvent.clientY)) {
_tryAgentInspect(upEvent.clientX, upEvent.clientY);
}
@@ -150,6 +171,17 @@ function _onPointerDown(event) {
// Cast from screen center in FPS mode
_pointer.set(0, 0);
_raycaster.setFromCamera(_pointer, _camera);
// Check power meter first
if (_meterObjects.length && _meterCallback) {
const meterHits = _raycaster.intersectObjects(_meterObjects, false);
if (meterHits.length > 0) {
_meterCallback();
event.stopImmediatePropagation();
return;
}
}
if (_timmyGroup && _applySlap) {
const hits = _raycaster.intersectObject(_timmyGroup, true);
if (hits.length > 0) {

View File

@@ -7,10 +7,10 @@ import {
} from './agents.js';
import { initEffects, updateEffects, disposeEffects } from './effects.js';
import { initUI, updateUI } from './ui.js';
import { initInteraction, disposeInteraction, registerSlapTarget } from './interaction.js';
import { initInteraction, disposeInteraction, registerSlapTarget, registerMeterTarget } from './interaction.js';
import { initWebSocket, getConnectionState, getJobCount } from './websocket.js';
import { initPaymentPanel } from './payment.js';
import { initSessionPanel } from './session.js';
import { initSessionPanel, openSessionPanel, syncPowerMeterState } from './session.js';
import { initNostrIdentity } from './nostr-identity.js';
import { warmup as warmupEdgeWorker, onReady as onEdgeWorkerReady } from './edge-worker-client.js';
import { setEdgeWorkerReady } from './ui.js';
@@ -18,6 +18,14 @@ import { initTimmyId } from './timmy-id.js';
import { AGENT_DEFS } from './agent-defs.js';
import { initNavigation, updateNavigation, disposeNavigation } from './navigation.js';
import { initHudLabels, updateHudLabels, disposeHudLabels } from './hud-labels.js';
import {
initPowerMeter,
updatePowerMeter,
disposePowerMeter,
getPowerMeterObjects,
triggerPowerMeterClick,
onPowerMeterClick,
} from './power-meter.js';
let running = false;
let canvas = null;
@@ -29,6 +37,7 @@ function buildWorld(firstInit, stateSnapshot) {
initEffects(scene);
initAgents(scene);
initPowerMeter(scene, renderer);
if (stateSnapshot) applyAgentStates(stateSnapshot);
@@ -38,6 +47,10 @@ function buildWorld(firstInit, stateSnapshot) {
initInteraction(camera, renderer);
registerSlapTarget(getTimmyGroup(), applySlap);
// Power meter click → open session panel
onPowerMeterClick(openSessionPanel);
registerMeterTarget(getPowerMeterObjects(), triggerPowerMeterClick);
// AR floating labels
initHudLabels(camera, AGENT_DEFS, TIMMY_WORLD_POS);
@@ -50,6 +63,9 @@ function buildWorld(firstInit, stateSnapshot) {
warmupEdgeWorker();
onEdgeWorkerReady(() => setEdgeWorkerReady());
void initTimmyId();
} else {
// On scene rebuild, re-apply session state to the fresh meter
syncPowerMeterState();
}
const ac = new AbortController();
@@ -81,6 +97,7 @@ function buildWorld(firstInit, stateSnapshot) {
updateEffects(now);
updateAgents(now);
updatePowerMeter(now, camera, renderer);
updateUI({
fps: currentFps,
agentCount: getAgentCount(),
@@ -120,6 +137,7 @@ function teardown({ scene, renderer, ac }) {
disposeNavigation();
disposeInteraction();
disposeHudLabels();
disposePowerMeter();
disposeEffects();
disposeAgents();
disposeWorld(renderer, scene);

View File

@@ -0,0 +1,296 @@
/**
* power-meter.js — 3D Session Power Meter
*
* A glowing orb positioned in the Workshop scene that fills proportionally to
* the session balance. Pulses on payment received, drains on job deduction.
* Clicking the meter (or its HUD label) opens the session panel.
*
* Public API:
* initPowerMeter(scene, renderer)
* updatePowerMeter(time, camera, renderer)
* setPowerMeterBalance(sats, maxSats)
* pulsePowerMeter()
* drainPowerMeter()
* showPowerMeter(visible)
* getPowerMeterObjects() — for raycasting in interaction.js
* triggerPowerMeterClick() — called by interaction.js on hit
* onPowerMeterClick(callback)
* disposePowerMeter()
*/
import * as THREE from 'three';
// Scene position: right side, near desk level, visible from default camera
const METER_POS = new THREE.Vector3(5.0, 1.0, -1.0);
const SPHERE_R = 0.52;
// Fill colors: empty → mid → full
const COLOR_EMPTY = new THREE.Color(0xff3300);
const COLOR_MID = new THREE.Color(0xffaa00);
const COLOR_FULL = new THREE.Color(0x00ffcc);
const COLOR_PULSE = new THREE.Color(0xffffff);
const COLOR_DRAIN = new THREE.Color(0xff2200);
let _scene = null;
let _group = null;
let _fillMesh = null;
let _clipPlane = null;
let _glowLight = null;
let _hudEl = null;
let _ringMesh = null;
let _fillLevel = 0.0; // currently rendered fill (01), smoothed
let _targetFill = 0.0; // target, set from balance
let _currentSats = 0;
let _visible = false;
// Pulse / drain animation timers (decay each frame)
let _pulseT = 0.0;
let _drainT = 0.0;
const _raycastObjects = [];
let _clickCallback = null;
const _projVec = new THREE.Vector3();
// ── Init ──────────────────────────────────────────────────────────────────────
export function initPowerMeter(scene, renderer) {
_scene = scene;
renderer.localClippingEnabled = true;
_group = new THREE.Group();
_group.position.copy(METER_POS);
_group.visible = false;
scene.add(_group);
// Clipping plane: show fill from bottom up.
// Plane normal (0,-1,0), constant c → keeps points where -y + c ≥ 0 → y ≤ c.
// At fillLevel f: c = R*(2f-1). f=0 → c=-R (nothing shown), f=1 → c=R (all shown).
_clipPlane = new THREE.Plane(new THREE.Vector3(0, -1, 0), -SPHERE_R);
// Outer shell — transparent glass orb
const outerGeo = new THREE.SphereGeometry(SPHERE_R, 24, 18);
const outerMat = new THREE.MeshStandardMaterial({
color: 0x8899cc,
transparent: true,
opacity: 0.13,
roughness: 0.05,
metalness: 0.4,
side: THREE.FrontSide,
});
const outerMesh = new THREE.Mesh(outerGeo, outerMat);
_group.add(outerMesh);
_raycastObjects.push(outerMesh);
// Equatorial ring glow
const ringGeo = new THREE.TorusGeometry(SPHERE_R, 0.022, 8, 44);
const ringMat = new THREE.MeshBasicMaterial({
color: 0x44aaff,
transparent: true,
opacity: 0.35,
});
_ringMesh = new THREE.Mesh(ringGeo, ringMat);
_ringMesh.rotation.x = Math.PI / 2;
_group.add(_ringMesh);
// Fill sphere — clipped from bottom
const fillGeo = new THREE.SphereGeometry(SPHERE_R * 0.91, 24, 18);
const fillMat = new THREE.MeshStandardMaterial({
color: COLOR_EMPTY.clone(),
emissive: COLOR_EMPTY.clone(),
emissiveIntensity: 0.5,
roughness: 0.25,
metalness: 0.1,
clippingPlanes: [_clipPlane],
clipShadows: true,
});
_fillMesh = new THREE.Mesh(fillGeo, fillMat);
_group.add(_fillMesh);
_raycastObjects.push(_fillMesh);
// Lightning bolt icon below the orb
_buildBolt(_group);
// Point light — intensity tied to fill level
_glowLight = new THREE.PointLight(COLOR_EMPTY.clone(), 0, 5);
_group.add(_glowLight);
// HTML overlay for balance text (pointer-events: auto so it's clickable)
_hudEl = document.createElement('div');
_hudEl.id = 'power-meter-hud';
_hudEl.style.cssText = [
'position:fixed;transform:translate(-50%,0)',
'z-index:20;text-align:center;cursor:pointer',
'font-family:Courier New,monospace;font-size:11px;letter-spacing:1px',
'color:#ffe566;text-shadow:0 0 8px #ffaa0088',
'background:rgba(0,0,0,0.6);padding:2px 8px;border-radius:4px',
'border:1px solid rgba(255,200,0,0.3)',
'display:none;user-select:none',
].join(';');
_hudEl.addEventListener('click', () => { if (_clickCallback) _clickCallback(); });
document.body.appendChild(_hudEl);
}
function _buildBolt(group) {
const mat = new THREE.MeshBasicMaterial({ color: 0xffcc00 });
// Upper segment — tilted box
const top = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.2, 0.055), mat);
top.position.set(0.03, -(SPHERE_R + 0.17), 0);
top.rotation.z = -0.32;
group.add(top);
// Lower segment
const bot = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.2, 0.055), mat.clone());
bot.position.set(-0.03, -(SPHERE_R + 0.40), 0);
bot.rotation.z = -0.32;
group.add(bot);
}
// ── Per-frame update ──────────────────────────────────────────────────────────
export function updatePowerMeter(time, camera, renderer) {
if (!_group || !_fillMesh) return;
const t = time * 0.001; // seconds
// Smooth fill animation (faster when far away)
const diff = _targetFill - _fillLevel;
_fillLevel += diff * 0.07;
// Update clip plane
_clipPlane.constant = SPHERE_R * (2 * _fillLevel - 1);
// Base fill color
const col = _lerpFillColor(_fillLevel);
// Pulse overlay (payment received — bright white-yellow flash)
if (_pulseT > 0) {
col.lerp(COLOR_PULSE, _pulseT * 0.8);
_pulseT = Math.max(0, _pulseT - 0.035);
}
// Drain overlay (job cost — red flash)
if (_drainT > 0) {
col.lerp(COLOR_DRAIN, _drainT * 0.55);
_drainT = Math.max(0, _drainT - 0.045);
}
_fillMesh.material.color.copy(col);
_fillMesh.material.emissive.copy(col);
// Pulsing emissive glow
const baseEmit = 0.25 + _fillLevel * 0.6;
const pulseGlow = Math.sin(t * 2.2) * 0.08 + _pulseT * 1.8;
_fillMesh.material.emissiveIntensity = baseEmit + pulseGlow;
// Ring color matches fill
if (_ringMesh) _ringMesh.material.color.copy(col);
// Point light
_glowLight.color.copy(col);
_glowLight.intensity = _visible
? (0.4 + _fillLevel * 2.2 + _pulseT * 3.5)
: 0;
// Gentle sway
_group.rotation.y = Math.sin(t * 0.45) * 0.07;
// Project HUD overlay to screen
if (_visible && _hudEl && camera && renderer) {
_projVec.copy(_group.position);
_projVec.y -= SPHERE_R + 0.55; // place label below bolt icon
_projVec.project(camera);
if (_projVec.z < 1.0) {
const W = renderer.domElement.clientWidth || window.innerWidth;
const H = renderer.domElement.clientHeight || window.innerHeight;
const sx = (_projVec.x * 0.5 + 0.5) * W;
const sy = (_projVec.y * -0.5 + 0.5) * H;
_hudEl.style.left = `${sx}px`;
_hudEl.style.top = `${sy}px`;
_hudEl.style.display = '';
} else {
_hudEl.style.display = 'none';
}
}
}
function _lerpFillColor(f) {
const c = new THREE.Color();
if (f < 0.5) {
c.lerpColors(COLOR_EMPTY, COLOR_MID, f * 2);
} else {
c.lerpColors(COLOR_MID, COLOR_FULL, (f - 0.5) * 2);
}
return c;
}
// ── Public API ────────────────────────────────────────────────────────────────
/**
* Update balance and recompute fill ratio.
* @param {number} sats current balance
* @param {number} maxSats session deposit max (denominator)
*/
export function setPowerMeterBalance(sats, maxSats) {
_currentSats = sats;
_targetFill = maxSats > 0 ? Math.max(0, Math.min(1, sats / maxSats)) : 0;
if (_hudEl) _hudEl.textContent = `${sats}`;
}
/** Bright flash — call when a payment is received. */
export function pulsePowerMeter() {
_pulseT = 1.0;
}
/** Red flash — call when a job cost is deducted. */
export function drainPowerMeter() {
_drainT = 1.0;
}
/** Show or hide the meter based on session active state. */
export function showPowerMeter(visible) {
_visible = visible;
if (_group) _group.visible = visible;
if (_hudEl) {
if (!visible) _hudEl.style.display = 'none';
// If visible, updatePowerMeter will set display on next frame
}
}
/** Returns meshes used for click raycasting in interaction.js. */
export function getPowerMeterObjects() {
return _raycastObjects;
}
/** Called by interaction.js when the meter is clicked in FPS mode. */
export function triggerPowerMeterClick() {
if (_clickCallback) _clickCallback();
}
/** Register callback that opens the session panel. */
export function onPowerMeterClick(cb) {
_clickCallback = cb;
}
export function disposePowerMeter() {
if (_group) {
_group.traverse(obj => {
if (obj.geometry) obj.geometry.dispose();
if (obj.material) {
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
mats.forEach(m => m.dispose());
}
});
_scene?.remove(_group);
_group = null;
}
_hudEl?.remove();
_hudEl = null;
_fillMesh = null;
_ringMesh = null;
_glowLight = null;
_clipPlane = null;
_raycastObjects.length = 0;
}

View File

@@ -16,6 +16,12 @@ import { setSpeechBubble, setMood } from './agents.js';
import { appendSystemMessage, setSessionSendHandler, setInputBarSessionMode } from './ui.js';
import { getOrRefreshToken } from './nostr-identity.js';
import { sentiment } from './edge-worker-client.js';
import {
setPowerMeterBalance,
pulsePowerMeter,
drainPowerMeter,
showPowerMeter,
} from './power-meter.js';
const API = '/api';
const LS_KEY = 'timmy_session_v1';
@@ -33,6 +39,7 @@ let _pollTimer = null;
let _inFlight = false;
let _selectedSats = 500; // deposit amount selection
let _topupSats = 500; // topup amount selection
let _sessionMaxSats = 500; // max sats for fill ratio (initial deposit)
// ── Public API ────────────────────────────────────────────────────────────────
@@ -105,6 +112,18 @@ export function isSessionActive() {
return _sessionState === 'active' || _sessionState === 'paused';
}
export function openSessionPanel() {
_openPanel();
}
/** Re-sync power meter after scene rebuild. */
export function syncPowerMeterState() {
showPowerMeter(isSessionActive());
if (isSessionActive()) {
setPowerMeterBalance(_balanceSats, _sessionMaxSats);
}
}
// Called by ui.js when user submits the input bar while session is active
export async function sessionSendHandler(text) {
if (!_sessionId || !_macaroon || _inFlight) return;
@@ -156,6 +175,7 @@ export async function sessionSendHandler(text) {
_sessionState = _balanceSats < MIN_BALANCE ? 'paused' : 'active';
_saveToStorage();
_applySessionUI();
drainPowerMeter();
const reply = data.result || data.reason || '…';
setSpeechBubble(reply);
@@ -285,11 +305,13 @@ function _startDepositPolling() {
const data = await res.json();
if (data.state === 'active') {
_macaroon = data.macaroon;
_balanceSats = data.balanceSats;
_sessionState = 'active';
_macaroon = data.macaroon;
_balanceSats = data.balanceSats;
_sessionMaxSats = data.balanceSats; // initial deposit becomes the max
_sessionState = 'active';
_saveToStorage();
_applySessionUI();
pulsePowerMeter();
_closePanel(); // panel auto-closes; user types in input bar
appendSystemMessage(`Session active — ${_balanceSats} sats`);
return;
@@ -401,11 +423,13 @@ function _startTopupPolling() {
const data = await res.json();
if (data.balanceSats > prevBalance || data.state === 'active') {
_sessionMaxSats = Math.max(_sessionMaxSats, data.balanceSats);
_balanceSats = data.balanceSats;
_macaroon = data.macaroon || _macaroon;
_sessionState = data.state === 'active' ? 'active' : _sessionState;
_saveToStorage();
_applySessionUI();
pulsePowerMeter();
_setStep('active');
_updateActiveStep();
_setStatus('active', `⚡ Topped up! ${_balanceSats} sats`, '#22aa66');
@@ -486,6 +510,12 @@ function _applySessionUI() {
// Show low-balance overlay whenever session exists but balance is too low
const lowBal = anyActive && _balanceSats < MIN_BALANCE;
// 3D power meter
showPowerMeter(anyActive);
if (anyActive) {
setPowerMeterBalance(_balanceSats, _sessionMaxSats);
}
// HUD balance: "Balance: X sats ⚡ Top Up"
const $hud = document.getElementById('session-hud');
if ($hud) {
@@ -526,11 +556,13 @@ function _updateActiveStep() {
}
function _clearSession() {
_sessionId = null;
_macaroon = null;
_balanceSats = 0;
_sessionState = null;
_sessionId = null;
_macaroon = null;
_balanceSats = 0;
_sessionState = null;
_sessionMaxSats = 500;
localStorage.removeItem(LS_KEY);
showPowerMeter(false);
setInputBarSessionMode(false);
setSessionSendHandler(null);
const $hud = document.getElementById('session-hud');

View File

@@ -2,6 +2,7 @@ import { setAgentState, setSpeechBubble, applyAgentStates, setMood } from './age
import { appendSystemMessage, appendDebateMessage, showCostTicker, updateCostTicker } from './ui.js';
import { sentiment } from './edge-worker-client.js';
import { setLabelState } from './hud-labels.js';
import { setPowerMeterBalance, pulsePowerMeter, drainPowerMeter } from './power-meter.js';
function resolveWsUrl() {
const explicit = import.meta.env.VITE_WS_URL;
@@ -151,6 +152,17 @@ function handleMessage(msg) {
break;
}
case 'session_balance_update': {
// Server-pushed balance update (e.g. after payment confirmed or job deducted)
if (typeof msg.balanceSats === 'number') {
const maxSats = msg.maxSats ?? 0;
setPowerMeterBalance(msg.balanceSats, maxSats);
if (msg.reason === 'payment') pulsePowerMeter();
if (msg.reason === 'deduction') drainPowerMeter();
}
break;
}
case 'agent_count':
case 'visitor_count':
break;