diff --git a/experiments/command-center/README.md b/experiments/command-center/README.md new file mode 100644 index 0000000..56a541e --- /dev/null +++ b/experiments/command-center/README.md @@ -0,0 +1,97 @@ +# CottageCode command center lab + +Three perspectives on the same observed village: **Townwatch** is a midnight Townmap, **Dispatch** is a working ledger, and **Threads** traces explicit relationships. The evidence drawer preserves the same task identity in every view. + +Start the existing feed first (`npm start`, port **8787**), then start this isolated zero-dependency lab: + +```sh +node experiments/command-center/server.mjs --port 8790 +``` + +Open [the live village](http://127.0.0.1:8790/). The lab binds to `127.0.0.1`; it does not replace the existing town or expose a general proxy. + +- [Townwatch](http://127.0.0.1:8790/?view=townwatch) +- [Dispatch](http://127.0.0.1:8790/?view=dispatch) +- [Threads](http://127.0.0.1:8790/?view=threads) +- [Illustrative scenario](http://127.0.0.1:8790/?view=townwatch&scenario=1) + +The conspicuous source strip switches between live and illustrative data. URL parameters preserve the current perspective and source. Live is the default. Live reads `/agents` every five seconds after the prior request completes; failure retains the last observation with a stale banner, disables sending, and never substitutes fictional work. The illustrative scenario freezes its authored observation, never polls, and only produces a local response receipt. A simulated response does not change source status or pretend an agent resumed. + +## Reading and navigating + +Select a town in the rail (or the compact mobile selector). Townwatch's Attention, Time, Delivery, and Owner lenses change annotations without changing task ordering. The attention strip prioritizes available input, then lets you cycle through other recorded attention. Attention includes blocked deliveries and reports; it is not a count of answerable questions. + +Dispatch independently shows execution, public activity, harness/model, task/session clocks, observation, and PR evidence. Filters use shared model semantics. Ready totals count unique PRs, including when several cottages share a shipment. Arrival order remains stable across refreshes. + +Threads draws only recorded issue, parent, task, PR and dependency relationships. Shared PRs have one endpoint. The illustrative dependency arrow reads “required by” from upstream PR to dependent PR. On narrow screens, choose a cottage to read a vertical trace, including shared shipments and dependencies. Missing issue links have an explicit explanation. + +| Key | Action | +| --- | --- | +| `1`, `2`, `3` | Townwatch, Dispatch, Threads | +| `Cmd/Ctrl+K` | Search all task titles, towns, cottage/task identifiers and PRs | +| `J`, `K` | Inspect next / previous visible cottage (first / last if none selected) | +| `↑`, `↓`, `Enter` in search | Move through matches and inspect the current result | +| `Escape` | Close search or evidence drawer | + +Shortcuts do not interfere with editable controls. All actions also have visible controls. PR badges expose a compact accessible evidence preview on hover or focus, without moving focus or selecting the task. Click or Enter opens the full receipt. Escape closes search explicitly, including while its search field has focus. Reduced motion removes ambient smoke movement. There is no sound or native fullscreen request. + +## Evidence and responses + +The drawer shows public activity, separate task/session/observation/progress clocks, source, recorded input and supplied options, original request, public journal, recorded relationships, and detailed PR evidence. Long text has an exact excerpt with full expansion. Missing fields remain unavailable. Task and session starts do not substitute for each other; observing a task is not evidence of progress. When the source supplies execution duration, the drawer labels it separately as Recorded execution; it never substitutes for wall-clock task age. Quiet requires explicit progress telemetry at least 15 minutes old and is not a stall diagnosis. Unknown harnesses are never inferred from model names or Hub provenance. + +Readiness follows the shared PR model: Hub remains authoritative, including its last verified state during a feed outage; stale or conflicting standalone observations cannot become ready. PR evidence separates stage, authority, source, verification time, freshness, checks, current/reviewed heads, reason and labels. Original links accept only safe HTTP/HTTPS URLs. There are no merge controls. + +Messages require an explicit **Send message**. The UI retains the task/question snapshot associated with each draft. A changed question locks the preserved draft until the user reviews and explicitly adopts the current input. `createReplyClient` then independently rereads the feed, validates task identity/status, exact question version, current capability and route, and saves a durable receipt reservation before submission. Uncertain delivery remains locked across reloads; there is no automatic retry. Composer eligibility uses the same `isTerminalForMessage` guard as submission: an explicit current `terminal: false` takes precedence over a historical end timestamp; explicit terminal states remain blocked. Visual working windows follow current execution status independently. Source-only tasks show the exact unavailable reason. Draft text is held in page memory; delivery guards use local storage. Closing the drawer or ordinary polling preserves drafts; reloading the page does not preserve draft text. + +Tests must never send to real agents. Use the illustrative source to exercise responses, or inject mock transports into the reply-client tests. + +## Validation + +```sh +node --check experiments/command-center/app.mjs +node --test test/command-center.test.mjs test/command-center-server.test.mjs +npm test +``` + +Browser acceptance covers all three perspectives at desktop and narrow widths, keyboard navigation, source switching, exact recorded input, PR evidence, a local scenario response, preserved live drafts, stale recovery, and source-only capability reasons. Screenshot artifacts belong outside Git. Public journals currently show inline feed events only; fetching separate `activityUrl` history is a follow-up capability. + +## Integrated original town + +The approved integration is now in `src/`: the original walking town owns the +camera, rooms, polling, journals and replies, with the command surfaces added. +To run the integrated worktree's feed and telemetry adapter on its own port, +configure its local `.env` as described in the main README, then run: + +```sh +npm start -- --port 8791 +``` + +Open [the integrated live town](http://127.0.0.1:8791/) or +[the original sample village with command controls](http://127.0.0.1:8791/?demo=1). +This runs `src/feed.mjs` directly, including the worktree's Hub inventory and +telemetry adapter. No port-8787 upstream is needed. For frontend-only comparisons, +`node experiments/command-center/server.mjs --port 8791 --town` still serves the +production modules through a restricted proxy to port 8787; that mode does not +exercise this worktree's feed adapter. The older port-8790 lab remains an +exploration artifact. Its model and reply helpers re-export the shared modules. + +The November 5 refinement keeps the original town visible and walkable while +Dispatch or Threads opens as a workbench beneath it. Drag **More detail / More +town** to change the balance immediately. The neighbourhood heartbeat rail +focuses a single project and its evidence; **All towns** restores the wider +scope. **Read the village** explains the working-ground lights, decorative +tradesfolk, and one-parcel-per-PR shipment/return carts. Carts represent newly +observed merge/close events, not historical backfill or deployment claims. + +The live-data refinement keeps every canonical Hub dashboard resident in town, +including settled-looking tasks that still need attention. Project pulses show +attention and ready shipments together. Initial packing uses the available +window width; resizing does not move established cottages. **Visit activity** +returns to the street at normal zoom and visits the best available working or +attention cottage within the selected project. + +Expand **Village activity** for recent, older, and unreported tool coverage. +Miners mean observed read/search tools; workshops mean other recent tool use, +including generic shell calls. Neither means the individual call is still +running. Missing telemetry never creates work animations. The freshness label +reports the last successful feed check, separately from tool-event age. diff --git a/experiments/command-center/app.mjs b/experiments/command-center/app.mjs new file mode 100644 index 0000000..3fce29c --- /dev/null +++ b/experiments/command-center/app.mjs @@ -0,0 +1,416 @@ +import {fleetView, durationLabel, safeUrl} from './model.mjs'; +import {createScenario} from './scenario.mjs'; +import {createReplyClient, isTerminalForMessage} from './replies.mjs'; +import {draftVersion,reconcileDraft} from './drafts.mjs'; +import {inputRequestVersion} from '/src/input-request.mjs'; + +const $ = selector => document.querySelector(selector); +const escape = value => String(value ?? '').replace(/[&<>"']/g, char => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[char])); +const endpoint = new URL('/agents', location.href).href; +const replies = createReplyClient({endpoint}); +const params = new URLSearchParams(location.search); +const views = ['townwatch', 'dispatch', 'threads']; +const state = { + view: views.includes(params.get('view')) ? params.get('view') : 'townwatch', + scenario: params.get('scenario') === '1', snapshot: null, fleet: fleetView({agents:[]}), + project: '', selected: null, lens: 'Attention', filter: 'all', attentionIndex: 0, + stale: false, error: '', loading: true, fetchedAt: null, epoch: 0, + order: new Map(), projectOrder: new Map(), drafts: new Map(), receipts: new Map(), threadTask: null, +}; +let pollTimer; +let drawerIdentity = null; +let returnFocus = null; +let searchIndex = 0; +let previewTimer; +let previewOwner = null; +const age = value => durationLabel(value); +const date = value => { const d = new Date(value); return value && Number.isFinite(d.getTime()) ? d.toLocaleString([], {month:'short',day:'numeric',hour:'2-digit',minute:'2-digit',second:'2-digit'}) : 'Unavailable'; }; +const stageNames = {'waiting-codex':'Awaiting review','waiting-ci':'Awaiting checks',active:'In review',ready:'Ready',blocked:'Blocked',merged:'Merged',closed:'Closed',unknown:'Unknown',none:'No PR recorded',open:'Open'}; +const stage = value => stageNames[value] || 'Unknown'; +const sourceName = task => typeof task.raw.source === 'string' ? task.raw.source : (task.raw.source?.name || 'Unavailable'); +const owner = task => task.harness === 'Unknown' ? 'Harness unavailable' : task.harness; +const short = (text, length = 180) => String(text || '').length > length ? `${String(text).slice(0,length).trimEnd()}…` : String(text || ''); +// Visual execution follows canonical status; the delivery guard is deliberately stricter. +const displayTerminal = task => Boolean(task.raw.terminal || ['done','completed','offline','cancelled','failed','interrupted'].includes(task.status)); +const terminal = task => isTerminalForMessage(task.raw); +const canReply = task => task.capability.available && !terminal(task); +const version = draftVersion; +const taskById = id => state.fleet.tasks.find(task => task.id === id); +const scoped = () => state.fleet.tasks.filter(task => !state.project || task.project === state.project); +const prGroup = task => state.fleet.prs.find(pr => pr.key === task.prKey); +function counts(tasks) { + const ids = new Set(tasks.map(task => task.id)); + return {tasks: tasks.length, working:tasks.filter(t=>t.status==='working').length, attention:tasks.filter(t=>t.attention).length, + quiet:tasks.filter(t=>t.quiet).length, ready:state.fleet.prs.filter(pr=>pr.stage==='ready'&&pr.taskIds.some(id=>ids.has(id))).length}; +} +function visibleTasks() { + const tasks = scoped(); + return state.view !== 'dispatch' ? tasks : tasks.filter(task => state.filter === 'all' || + (state.filter === 'attention' && task.attention) || (state.filter === 'working' && task.status === 'working') || + (state.filter === 'quiet' && task.quiet) || (state.filter === 'ready' && prGroup(task)?.stage === 'ready')); +} +function stateMark(task) { + if (task.input) return {symbol:'?', label:'Recorded input', tone:'input'}; + if (task.status === 'blocked' || task.prStage === 'blocked') return {symbol:'!', label:task.status === 'blocked'?'Blocked':'PR blocked', tone:'blocked'}; + if (['done','completed'].includes(task.status)) return {symbol:'✓',label:task.status,tone:'done'}; + if (['failed','interrupted'].includes(task.status)) return {symbol:'×',label:task.status,tone:'blocked'}; + if (task.status==='cancelled') return {symbol:'—',label:task.status,tone:''}; + return {symbol:task.status === 'working'?'◒':'○', label:task.status, tone:''}; +} +function link(url, label, className='source-link') { + const valid = safeUrl(url); + if(state.scenario && valid && new URL(valid).hostname==='example.invalid')return `${escape(label)} · illustrative reference`; + return valid ? `${escape(label)} ↗` : ''; +} +function cottage(task, tiny=false) { + const hash = [...task.id].reduce((sum,c)=>sum+c.charCodeAt(0),0); + const roofs = ['#64788b','#7f798b','#6c8586','#7b756a','#627d91']; + const roof = roofs[hash % roofs.length]; + return ``; +} +function syncURL() { + const url = new URL(location.href); url.searchParams.set('view',state.view); + if(state.scenario) url.searchParams.set('scenario','1'); else url.searchParams.delete('scenario'); + history.replaceState(null,'',url); +} +function updateModel() { + // Scenario is a frozen authored observation, including its capability evidence. + state.fleet = fleetView(state.snapshot || {agents:[]}, {now:state.scenario ? state.snapshot.checkedAt : Date.now(),stale:state.stale,endpoint}); + for(const task of state.fleet.tasks) { + if(!state.order.has(task.id)) state.order.set(task.id,state.order.size); + if(!state.projectOrder.has(task.project)) state.projectOrder.set(task.project,state.projectOrder.size); + const draft = state.drafts.get(task.id); + if(draft) reconcileDraft(draft,task); + } + state.fleet.tasks.sort((a,b)=>state.order.get(a.id)-state.order.get(b.id)); + state.fleet.projects.sort((a,b)=>state.projectOrder.get(a.id)-state.projectOrder.get(b.id)); +} +function focusedControl(root) { + const active=document.activeElement; + if(!active || !root.contains(active))return null; + if(active.id)return `#${CSS.escape(active.id)}`; + for(const name of ['project','select','pr','lens','filter','next-attention','close-drawer','review-draft','send','option']) { + if(active.hasAttribute(`data-${name}`))return `[data-${name}="${CSS.escape(active.getAttribute(`data-${name}`))}"]`; + } + if(active.tagName==='SUMMARY'&&active.parentElement.dataset.detail)return `details[data-detail="${CSS.escape(active.parentElement.dataset.detail)}"] > summary`; + return null; +} +function restoreControl(root,selector) {if(selector)root.querySelector(selector)?.focus({preventScroll:true});} +function renderChrome() { + const railFocus=focusedControl($('#towns')); + document.body.classList.toggle('illustrative',state.scenario); + document.title = `CottageCode · ${state.view[0].toUpperCase()+state.view.slice(1)}`; + document.querySelectorAll('[data-view]').forEach(button => button.setAttribute('aria-current',String(button.dataset.view===state.view))); + $('#source-mark').textContent = state.scenario ? '◇ ILLUSTRATIVE SCENARIO' : '● LIVE FEED'; + $('#source-description').textContent = state.scenario ? 'Fictional cottages. Every response stays on this page.' : 'Your local village, as observed.'; + $('#source-switch').textContent = state.scenario ? 'Return to live village →' : 'Explore illustrative scenario →'; + $('#freshness').textContent = state.scenario ? 'Fictional evidence' : state.loading ? 'Connecting…' : state.stale ? 'Source unavailable' : `Fetched ${age(Date.now()-state.fetchedAt)} ago`; + $('#connection').hidden = !state.error; + $('#connection').innerHTML = state.error ? `${escape(state.error)} ` : ''; + const townButton = (id,name,count,attention) => ``; + $('#towns').innerHTML = townButton('','All towns',state.fleet.counts.tasks,state.fleet.counts.attention) + state.fleet.projects.map(p=>townButton(p.id,p.name,p.count,p.attention)).join(''); + restoreControl($('#towns'),railFocus); + $('#town-select').innerHTML = `` + state.fleet.projects.map(p=>``).join(''); +} +function renderHeading() { + const c = counts(scoped()); + const titles = {townwatch:['THE NIGHT ATLAS','Townwatch','A small light for every piece of work.'],dispatch:['THE WORKING LEDGER','Dispatch','Read the work. Find the next useful move.'],threads:['THE RELATIONSHIP ATLAS','Threads','Recorded paths from request to delivery.']}; + const [caption,title,description] = titles[state.view]; + $('#view-heading').innerHTML = `
${caption}

${title}

${description} ${state.project?escape(state.project):`Across ${state.fleet.projects.length} towns.`}

${c.working}Working
${c.attention}Attention
${c.ready}Ready PR${c.ready===1?'':'s'}
`; +} +function attentionStrip() { + const tasks = scoped().filter(t=>t.attention).sort((a,b)=>Number(Boolean(b.input&&canReply(b)))-Number(Boolean(a.input&&canReply(a)))); + if(!tasks.length) return `

No recorded attention in this scope.

`; + const task = tasks[state.attentionIndex % tasks.length]; + const text = task.input?.prompt || task.pr.reason || task.activity || 'Inspect recorded evidence'; + return `
`; +} +function annotation(task) { + if(state.lens==='Time') return `Task ${age(task.taskAgeMs)}${task.quiet?` · Quiet ${age(task.progressAgeMs)}`:''}`; + if(state.lens==='Delivery') return `${task.pr.number?`PR #${task.pr.number} · `:''}${stage(task.prStage)}`; + if(state.lens==='Owner') return `${owner(task)}${task.model!=='Unknown'?` · ${task.model}`:''}`; + if(task.input) return canReply(task) ? '✉ Answer available' : '✉ Recorded input'; + if(task.prStage==='blocked') return '! PR blocked'; + if(task.status==='blocked') return '! Blocked'; + if(task.prStage==='ready') return '✓ PR ready'; + return `${stateMark(task).symbol} ${task.status}${task.quiet?' · Quiet':''}`; +} +function renderTownwatch() { + const tasks = scoped(); + const projects = state.fleet.projects.filter(p=>!state.project||p.id===state.project); + return `${attentionStrip()}
TOWNMAP ${tasks.length} COTTAGES
${['Attention','Time','Delivery','Owner'].map(lens=>``).join('')}
${projects.map((project,index)=>`

${escape(project.name)}

${String(index+1).padStart(2,'0')} / ${project.count} RESIDENT${project.count===1?'':'S'}
${tasks.filter(t=>t.project===project.id).map(task=>{ + const mark=stateMark(task); const badge=task.input?'?':mark.tone==='blocked'?'!':task.prStage==='ready'?'✓':''; + return ``; + }).join('')}
`).join('')}
▪Working windows   !Blocked   ✓Ready PRPositions stay put. The lens changes the reading.
`; +} +function prBadge(task) { + return ``; +} +function renderDispatch() { + const c=counts(scoped()); const rows=visibleTasks(); + return `
${[['all','All',c.tasks],['attention','Attention',c.attention],['working','Working',c.working],['quiet','Quiet',c.quiet],['ready','Ready PRs',c.ready]].map(([id,label,n])=>``).join('')}

${state.filter==='quiet'?'Quiet = no explicit recorded progress for 15 minutes or more. Execution may still be working.':'Task time, session time and observed activity tell different stories. PR totals count shared shipments once.'}

${rows.length?rows.map(task=>{ + const mark=stateMark(task); + return `
${mark.symbol}

${escape(task.activity || 'Public activity unavailable.')}

${task.input?`

✉ ${escape(short(task.input.prompt,250))}

`:''}
${escape(owner(task))}${escape(task.model==='Unknown'?'Model unavailable':task.model)}${escape(task.project)} · ${escape(task.status)}
Task ${age(task.taskAgeMs)}
Session ${age(task.sessionAgeMs)}
Observed ${task.observedAgeMs===null?'unavailable':age(task.observedAgeMs)+' ago'}
${task.quiet?`
Quiet ${age(task.progressAgeMs)}
`:''}
${prBadge(task)}
`; + }).join(''):`

No cottages in this view

Try another filter or town.

`}
`; +} +function graphData(tasks) { + const nodes=new Map(); + for(const task of tasks) { + nodes.set(`task:${task.id}`,{id:`task:${task.id}`,kind:'task',task,title:task.title,caption:owner(task)}); + for(const issue of task.issues)nodes.set(`issue:${issue.url}`,{id:`issue:${issue.url}`,kind:'issue',issue,title:issue.title,caption:issue.number?`ISSUE #${issue.number}`:'RECORDED ISSUE'}); + if(task.prKey) {const group=prGroup(task);nodes.set(`pr:${task.prKey}`,{id:`pr:${task.prKey}`,kind:'pr',group,task,title:group.pr.title||`Pull request #${group.pr.number}`,caption:stage(group.stage)});} + } + const edges=state.fleet.edges.filter(edge=>nodes.has(edge.from)&&nodes.has(edge.to)); + return {nodes,edges}; +} +function relatedNodes(edges,selected) { + const seen=new Set(selected?[`task:${selected}`]:[]); + // Show the explicitly connected path, including shared shipments and dependencies. + for(let i=0;i<4;i++)for(const edge of edges)if(seen.has(edge.from)||seen.has(edge.to)){seen.add(edge.from);seen.add(edge.to);} + return seen; +} +function nodeHTML(node,styleText,selected) { + const common=`class="graph-node ${node.kind}-node ${selected?'selected':''}" style="${styleText}"`; + if(node.kind==='issue' && state.scenario){const task=state.fleet.tasks.find(t=>t.issues.some(i=>i.url===node.issue.url));return ``;} + if(node.kind==='issue')return `${escape(node.caption)} ↗${escape(node.title)}`; + const isPR=node.kind==='pr'; + return ``; +} +function renderThreads() { + const tasks=scoped(), {nodes,edges}=graphData(tasks); + const active=tasks.find(t=>t.id===state.threadTask)||tasks.find(t=>t.id===state.selected)||tasks[0]; + const selected=relatedNodes(edges,state.selected||active?.id); + const columns={issue:[],task:[],pr:[]}; + for(const node of nodes.values())columns[node.kind].push(node); + const positions=new Map(); + columns.task.forEach((n,i)=>positions.set(n.id,{x:31,y:25+i*132,width:37})); + for(const kind of ['issue','pr']) { + let previous=-107; + columns[kind].forEach(node=>{ + const related=edges.filter(e=>e.from===node.id||e.to===node.id).map(e=>positions.get(e.from===node.id?e.to:e.from)?.y).filter(Number.isFinite); + const wanted=related.length?related.reduce((a,b)=>a+b,0)/related.length:previous+132; + const y=Math.max(wanted,previous+132);previous=y;positions.set(node.id,{x:kind==='issue'?0:73,y,width:kind==='issue'?26:27}); + }); + } + const height=Math.max(500,...[...positions.values()].map(p=>p.y+130)); + const graphWidth=Math.max(300,$('#scene').clientWidth); + const pathHTML=edges.map(edge=>{ + const from=positions.get(edge.from),to=positions.get(edge.to); const same=from.x===to.x; + const leftSide=same&&from.x===73; + const x1=(leftSide?from.x:from.x+from.width)*graphWidth/100,x2=(same?(leftSide?to.x:to.x+to.width):to.x)*graphWidth/100; + const bend=x1+(leftSide?-26:28); + const y1=from.y+45,y2=to.y+45; const highlighted=selected.has(edge.from)&&selected.has(edge.to); + const d=same?`M${x1} ${y1} C${bend} ${y1},${bend} ${y2},${x2} ${y2}`:`M${x1} ${y1} C${(x1+x2)/2} ${y1},${(x1+x2)/2} ${y2},${x2} ${y2}`; + return `${escape(edge.kind==='depends-on'?'required by':edge.kind)}`; + }).join(''); + return `
Only recorded relationships connect these cottages.${edges.length} paths · ${columns.pr.length} distinct PRs
ISSUESCOTTAGESPULL REQUESTS
${pathHTML}${!columns.issue.length?'

Issue links are not recorded in this feed.

':''}${[...nodes.values()].map(node=>{const p=positions.get(node.id);return nodeHTML(node,`left:${p.x}%;top:${p.y}px`,selected.has(node.id));}).join('')}
${renderTrace(active,nodes,edges)}`; +} +function renderTrace(active,nodes,edges) { + if(!active)return ''; + const direct=edges.filter(e=>e.from===`task:${active.id}`||e.to===`task:${active.id}`); + const traceNode=(node,relation)=>{ + if(!node)return ''; + if(node.kind==='issue'&&state.scenario)return `

↓ ${escape(relation)}

${escape(node.caption)}${escape(node.title)} · illustrative reference
`; + if(node.kind==='issue')return `

↓ ${escape(relation)}

${escape(node.caption)}${escape(node.title)} ↗`; + return `

↓ ${escape(relation)}

`; + }; + const incoming=direct.filter(e=>e.to===`task:${active.id}`),outgoing=direct.filter(e=>e.from===`task:${active.id}`); + const shared=active.prKey ? prGroup(active) : null; + const dependency=edges.filter(e=>e.kind==='depends-on'&&(e.from===`pr:${active.prKey}`||e.to===`pr:${active.prKey}`)); + return `
UPSTREAM / RECORDED ORIGINS
${incoming.length?incoming.map(e=>traceNode(nodes.get(e.from),e.kind)).join(''):'

No incoming issue or parent links recorded.

'}
COTTAGE
DOWNSTREAM / RECORDED DELIVERY
${outgoing.length?outgoing.map(e=>traceNode(nodes.get(e.to),e.kind)).join(''):'

No outgoing relationships recorded.

'}${shared&&shared.taskIds.length>1?`

Also linked to

${shared.taskIds.filter(id=>id!==active.id).map(id=>taskById(id)).filter(Boolean).map(t=>``).join('')}`:''}${dependency.map(e=>traceNode(nodes.get(e.from===`pr:${active.prKey}`?e.to:e.from),e.from===`pr:${active.prKey}`?`PR #${active.pr.number} is required by`:`PR #${active.pr.number} depends on`)).join('')}
`; +} +function renderScene() { + $('.workspace').classList.toggle('has-drawer',Boolean(state.selected)); + const sceneFocus=focusedControl($('#scene')); + hidePreview(); + renderHeading(); + if(!state.fleet.tasks.length) $('#scene').innerHTML=`

${state.loading?'Opening the village…':state.error?'The live source is unavailable':'No current cottages recorded'}

${state.loading?'Reading your local feed.':state.error?'Reconnect to read the live village, or explicitly explore the illustrative scenario.':'This feed has no live or recent work to display. Missing information stays unavailable.'}

`; + else $('#scene').innerHTML=state.view==='townwatch'?renderTownwatch():state.view==='dispatch'?renderDispatch():renderThreads(); + restoreControl($('#scene'),sceneFocus); +} +function draftFor(task) { + if(!state.drafts.has(task.id))state.drafts.set(task.id,{text:'',version:version(task),raw:structuredClone(task.raw),changed:false}); + return state.drafts.get(task.id); +} +function receiptFor(task) {return state.scenario ? state.receipts.get(task.id) || {phase:'idle',notice:''} : replies.state(task.raw.taskId);} +function replyLocked(task,receipt) { + return ['checking','sending','uncertain'].includes(receipt.phase) || (receipt.phase==='sent' && (state.scenario || (task.input && task.capability.mode==='respond' && receipt.inputVersion===inputRequestVersion(task.raw.inputRequest)))); +} +function inputHTML(task) { + const request=task.input, can=canReply(task), draft=draftFor(task),receipt=receiptFor(task); + const title = request && can && task.capability.mode==='respond' ? 'Needs your answer' : 'Recorded input'; + const original=typeof task.raw.inputRequest==='string'?task.raw.inputRequest:task.raw.inputRequest?.prompt || task.raw.inputRequest?.question || task.raw.inputRequest?.text || request?.prompt; + const options=request?.questions.flatMap(q=>q.options || [])||[]; + const full=original || request?.prompt || ''; + const detailed=request?.detail || ''; + const readonlyReason=terminal(task)?'The source records this task as ended. Continue in its original workflow.':task.capability.reason; + return `${request?`

${title}

${escape(short(request.prompt,400))}

${full.length>400||detailed||request.questions.length>1?`
Read full recorded input
${escape(full)}${detailed&&detailed!==full?'\n\n'+escape(detailed):''}${request.questions.length>1?'\n\n'+request.questions.map(q=>escape(q.prompt)).join('\n\n'):''}
`:''}

${escape(request.kind)} · ${escape(request.source)} · ${date(request.updatedAt)}

`:can?'

Message this cottage

':'

Conversation

'} + ${!can&&options.length?`
${options.map(option=>`
${escape(option.label)}${option.description?`${escape(option.description)}`:''}
`).join('')}
`:''} + ${can?`
${options.length?`
${options.map((option,index)=>``).join('')}
`:''}${draft.changed?'
The task or recorded input changed while you were drafting. Your draft is preserved. Review the current evidence before using it.
':''}

${state.scenario?'Simulation only. This cannot contact an agent or change its status.':'Explicit Send rechecks the current task, question and message route.'}

`:`

${escape(readonlyReason || 'A message route is unavailable.')}

`} + ${receipt.notice?`

${escape(receipt.notice)}

`:''}`; +} +function evidenceHTML(task) { + const pr=task.pr, group=prGroup(task); + return `

Delivery evidence ${pr.number?`/ #${pr.number}`:''}

Stage
${escape(stage(task.prStage))}
Authority
${escape(pr.authority || 'Standalone observation')}
Source
${escape(pr.source || 'Unavailable')}
Verified
${date(pr.checkedAt)}
Freshness
${pr.checkedAt?pr.stale?'Marked stale':'Source has not marked evidence stale':'Unavailable'}${task.sourceStale&&pr.authority==='hub'?' · Last Hub state retained during feed outage':''}
Checks
${task.ci.total?`${task.ci.passed}/${task.ci.total} passed · ${escape(task.ci.state)}`:'Unavailable'}
Current head
${escape(pr.headSha || 'Unavailable')}
Reviewed head
${escape(pr.reviewedHeadSha || 'Unavailable')}
Reason
${escape(pr.reason || 'No reason supplied.')}
Labels
${pr.labels.length?escape(pr.labels.join(' · ')):'Unavailable'}
${group&&group.taskIds.length>1?`
Shared PR
${group.taskIds.length} cottages · one shipment
Fleet stage: ${escape(stage(group.stage))}
`:''}
${pr.checks.length?``:''}${link(pr.url,'Open original PR')}`; +} +function relationshipsHTML(task) { + const edges=state.fleet.edges.filter(e=>e.from===`task:${task.id}`||e.to===`task:${task.id}`||task.prKey&&(e.from===`pr:${task.prKey}`||e.to===`pr:${task.prKey}`)); + const nodeText=id=>{ + if(id.startsWith('task:')){const t=taskById(id.slice(5));return t?``:escape(id);} + if(id.startsWith('pr:')){const pr=state.fleet.prs.find(p=>`pr:${p.key}`===id);return pr?`PR #${pr.pr.number}`:escape(id);} + const issue=state.fleet.tasks.flatMap(t=>t.issues).find(i=>`issue:${i.url}`===id);return issue?link(issue.url,issue.title,''):escape(id); + }; + return edges.length?``:'

No relationships recorded for this cottage.

'; +} +function renderDrawer({force=false}={}) { + const drawer=$('#drawer'),task=taskById(state.selected); + drawer.hidden=!state.selected; + $('.workspace').classList.toggle('has-drawer',Boolean(state.selected)); + if(!state.selected){drawerIdentity=null;return;} + if(!task){drawer.innerHTML='
EVIDENCE

This cottage is no longer in the current feed.

Its draft is preserved locally in this page. Select a current cottage to continue.

';return;} + const active=document.activeElement; + const drawerFocus=focusedControl(drawer); + const editing=active?.id==='reply-text'; + const start=editing?active.selectionStart:null,end=editing?active.selectionEnd:null; + const oldScroll=drawer.scrollTop; + const expanded=[...drawer.querySelectorAll('details[open]')].map(d=>d.dataset.detail); + const same=drawerIdentity===task.id; + const mark=stateMark(task),draft=state.drafts.get(task.id); + // Keep the exact textarea node during an ordinary poll, preserving focus/IME/undo. + const keepComposer=!force&&same&&editing&&!draft?.changed?drawer.querySelector('.composer'):null; + if(keepComposer)keepComposer.remove(); + drawer.innerHTML=`
COTTAGE / EVIDENCE

${escape(task.title)}

${mark.symbol}${escape(task.status)}${task.quiet?`Quiet ${age(task.progressAgeMs)}`:''}

Latest public activity

${escape(short(task.activity || 'No public activity supplied.',700))}

${task.activity.length>700?`
Read complete activity
${escape(task.activity)}
`:''}
Task age
${age(task.taskAgeMs)}
Session age
${age(task.sessionAgeMs)}
${Number.isFinite(task.recordedExecutionMs)?`
Recorded execution
${age(task.recordedExecutionMs)}
`:''}
Observed
${task.observedAgeMs===null?'Unavailable':age(task.observedAgeMs)+' ago'}
Last explicit progress
${task.progressAgeMs===null?'Unavailable':age(task.progressAgeMs)+' ago'}

Observed ${date(task.raw.updatedAt)}. Observation is not a progress signal.${task.quiet?' Quiet describes missing recent progress telemetry, not a stalled task.':''}

${inputHTML(task)}
${evidenceHTML(task)}

Original request

${escape(short(task.raw.originalAsk || task.raw.request || 'The original request is unavailable.',500))}
${String(task.raw.originalAsk||task.raw.request||'').length>500?`
Read full request
${escape(task.raw.originalAsk||task.raw.request)}
`:''}${link(task.raw.url || task.raw.sourceUrl || task.raw.taskUrl,'Open original task')}

Cottage: ${escape(task.id)}
Task: ${escape(task.raw.taskId || 'Unavailable')}

Recorded relationships

${relationshipsHTML(task)}
${task.events.length?`

Public journal

${task.events.slice(-8).map((event,i)=>`
${escape(event.kind)} · ${date(event.timestamp)}
${escape(event.text)}
`).join('')}
`:''}
`; + if(keepComposer&&drawer.querySelector('.composer')) { + drawer.querySelector('.composer').replaceWith(keepComposer); + const receipt=receiptFor(task);keepComposer.querySelector('[data-send]').disabled=replyLocked(task,receipt)||!draftFor(task).text.trim(); + } + if(same){for(const d of drawer.querySelectorAll('details'))if(expanded.includes(d.dataset.detail))d.open=true;drawer.scrollTop=oldScroll;} + else drawer.scrollTop=0; + drawerIdentity=task.id; + if(!editing&&same)restoreControl(drawer,drawerFocus); + if(editing&&same){const textarea=$('#reply-text');if(textarea&&!textarea.disabled){textarea.focus({preventScroll:true});if(start!==null)textarea.setSelectionRange(start,end);}} +} +function render() {renderChrome();renderScene();renderDrawer();} +function selectTask(id,{pr=false,focus=true}={}) { + if(!taskById(id))return; + returnFocus=document.activeElement; + state.selected=id;state.threadTask=id; + renderScene();renderDrawer(); + if(pr){$('#delivery-evidence')?.scrollIntoView({block:'nearest'});$('#delivery-evidence')?.focus({preventScroll:true});} + else if(focus)$('#drawer [data-close-drawer]')?.focus({preventScroll:true}); +} +function closeDrawer(){state.selected=null;renderScene();renderDrawer();if(returnFocus?.isConnected)returnFocus.focus({preventScroll:true});else $('#canvas').focus({preventScroll:true});} +function setView(view){state.view=view;syncURL();render();} +function setProject(project){state.project=project;state.attentionIndex=0;render();} +function searchMatches() { + const term=$('#search-input').value.trim().toLocaleLowerCase(); + return state.fleet.tasks.filter(task=>[task.title,task.project,task.id,task.raw.taskId,task.pr.number?`#${task.pr.number}`:'',task.pr.url].some(value=>String(value||'').toLocaleLowerCase().includes(term))); +} +function search() { + const results=searchMatches().slice(0,60); + searchIndex=Math.max(0,Math.min(searchIndex,results.length-1)); + $('#search-results').innerHTML=results.length?results.map((task,index)=>``).join(''):'

No cottages match this search.

'; +} +function openSearch(){hidePreview();searchIndex=0;$('#search-dialog').showModal();search();$('#search-input').focus();} +async function poll(epoch=state.epoch) { + if(state.scenario||epoch!==state.epoch)return; + try { + const response=await fetch(endpoint,{credentials:'omit',cache:'no-store',signal:AbortSignal.timeout(12000)}); + if(!response.ok)throw new Error(`Source returned ${response.status}`); + const snapshot=await response.json(); + if(!Array.isArray(snapshot)&&!Array.isArray(snapshot?.agents))throw new Error('Invalid feed'); + if(state.scenario||epoch!==state.epoch)return; + state.snapshot=snapshot;state.stale=Boolean(snapshot.stale);state.error=state.stale?'The source reports a stale snapshot. Last observed work is retained.':'';state.loading=false;state.fetchedAt=Date.now(); + } catch { + if(state.scenario||epoch!==state.epoch)return; + state.stale=true;state.loading=false;state.error=state.snapshot?'Live feed unavailable. Showing the last observed snapshot; message sending is paused.':'Live feed unavailable. No illustrative work has been substituted.'; + } + updateModel();render(); + if(!state.scenario&&epoch===state.epoch)pollTimer=setTimeout(()=>poll(epoch),5000); +} +function switchSource() { + clearTimeout(pollTimer);state.epoch++;state.scenario=!state.scenario; + state.snapshot=state.scenario?createScenario():null;state.selected=null;state.project='';state.stale=false;state.error='';state.loading=!state.scenario;state.order.clear();state.projectOrder.clear();state.attentionIndex=0;state.threadTask=null; + syncURL();updateModel();render();if(!state.scenario)poll(); +} +async function send() { + const task=taskById(state.selected);if(!task)return; + const draft=draftFor(task);if(draft.changed||version(task)!==draft.version||!draft.text.trim()||!task.capability.available||terminal(task)||replyLocked(task,receiptFor(task)))return; + if(state.scenario){state.receipts.set(task.id,{phase:'sent',notice:'Local scenario response recorded. No agent was contacted; the source status is unchanged.'});renderDrawer({force:true});$('#announcer').textContent='Local scenario response recorded.';return;} + const sending=replies.send(draft.raw,draft.text); + renderDrawer({force:true}); + const receipt=await sending; + renderDrawer({force:true});$('#announcer').textContent=receipt.notice; +} + +document.addEventListener('click',event=>{ + const button=event.target.closest('button');if(!button)return; + if(button.dataset.view)setView(button.dataset.view); + else if(button.hasAttribute('data-project'))setProject(button.dataset.project); + else if(button.dataset.lens){state.lens=button.dataset.lens;renderScene();} + else if(button.dataset.filter){state.filter=button.dataset.filter;renderScene();} + else if(button.dataset.select)selectTask(button.dataset.select); + else if(button.dataset.pr)selectTask(button.dataset.pr,{pr:true}); + else if(button.dataset.searchSelect){$('#search-dialog').close();state.project='';state.filter='all';selectTask(button.dataset.searchSelect);renderChrome();renderHeading();} + else if(button.hasAttribute('data-next-attention')){state.attentionIndex++;renderScene();} + else if(button.hasAttribute('data-close-drawer'))closeDrawer(); + else if(button.hasAttribute('data-retry')){clearTimeout(pollTimer);state.epoch++;poll();} + else if(button.hasAttribute('data-send'))send(); + else if(button.hasAttribute('data-review-draft')){const task=taskById(state.selected);const draft=draftFor(task);draft.raw=structuredClone(task.raw);draft.version=version(task);draft.changed=false;renderDrawer({force:true});} + else if(button.hasAttribute('data-option')){const task=taskById(state.selected),options=task.input.questions.flatMap(q=>q.options||[]);const draft=draftFor(task);draft.text=options[Number(button.dataset.option)]?.label||'';renderDrawer({force:true});$('#reply-text')?.focus();} +}); +$('#source-switch').addEventListener('click',switchSource); +$('#town-select').addEventListener('change',event=>setProject(event.target.value)); +$('#search-open').addEventListener('click',openSearch); +$('#search-close').addEventListener('click',()=>$('#search-dialog').close()); +$('#search-input').addEventListener('input',()=>{searchIndex=0;search();}); +document.addEventListener('change',event=>{if(event.target.id==='trace-task'){state.threadTask=event.target.value;renderScene();}}); +document.addEventListener('input',event=>{ + if(event.target.id==='reply-text'){const task=taskById(state.selected);if(!task)return;const draft=draftFor(task);draft.text=event.target.value;const button=$('[data-send]');if(button)button.disabled=draft.changed||!draft.text.trim()||replyLocked(task,receiptFor(task));} +}); +document.addEventListener('keydown',event=>{ + if((event.metaKey||event.ctrlKey)&&event.key.toLowerCase()==='k'){event.preventDefault();if(!$('#search-dialog').open)openSearch();return;} + if(event.key==='Escape'){ + if($('#search-dialog').open){event.preventDefault();event.stopPropagation();$('#search-dialog').close();return;} + hidePreview();if(state.selected){event.preventDefault();closeDrawer();}return; + } + if($('#search-dialog').open&&event.target.id==='search-input') { + const results=searchMatches().slice(0,60); + if(['ArrowDown','ArrowUp'].includes(event.key)&&results.length){event.preventDefault();searchIndex=(searchIndex+(event.key==='ArrowDown'?1:-1)+results.length)%results.length;search();$('#search-results .active')?.scrollIntoView({block:'nearest'});return;} + if(event.key==='Enter'&&results.length){event.preventDefault();$('#search-results .active')?.click();return;} + } + if(event.target.closest('input,textarea,select,[contenteditable="true"]')||event.metaKey||event.ctrlKey||event.altKey||$('#search-dialog').open)return; + if(['1','2','3'].includes(event.key)){event.preventDefault();setView(views[Number(event.key)-1]);} + if(['j','k'].includes(event.key.toLowerCase())){const tasks=visibleTasks();if(!tasks.length)return;event.preventDefault();const i=tasks.findIndex(t=>t.id===state.selected),delta=event.key.toLowerCase()==='j'?1:-1;selectTask(tasks[i<0?(delta>0?0:tasks.length-1):(i+delta+tasks.length)%tasks.length].id);} +}); +function hidePreview() { + clearTimeout(previewTimer); + $('#pr-preview').hidden=true; + previewOwner?.removeAttribute('aria-describedby');previewOwner=null; +} +function showPreview(button) { + const task=taskById(button.dataset.pr);if(!task)return; + clearTimeout(previewTimer); + if(previewOwner&&previewOwner!==button)previewOwner.removeAttribute('aria-describedby'); + previewOwner=button;button.setAttribute('aria-describedby','pr-preview'); + const preview=$('#pr-preview'),pr=task.pr; + preview.innerHTML=`
DELIVERY PREVIEW
${pr.number?`PR #${pr.number}`:'PR evidence'} ${escape(stage(task.prStage))}
Authority
${escape(pr.authority||'Standalone observation')}
Verified
${date(pr.checkedAt)}
Checks
${task.ci.total?`${task.ci.passed}/${task.ci.total} passed · ${escape(task.ci.state)}`:'Unavailable'}
Head
${escape(short(pr.headSha||'Unavailable',28))}

${escape(short(pr.reason||'No reason supplied.',240))}

${state.scenario?'Illustrative evidence · ':''}Click or press Enter for full evidence.`; + preview.hidden=false; + const rect=button.getBoundingClientRect(),width=preview.offsetWidth,height=preview.offsetHeight; + preview.style.left=`${Math.max(10,Math.min(rect.left,innerWidth-width-10))}px`; + preview.style.top=`${Math.max(10,rect.bottom+8+height{const button=event.target.closest?.('[data-pr]');if(button)showPreview(button);else hidePreview();}); +document.addEventListener('pointerover',event=>{const button=event.target.closest?.('[data-pr]');if(button)showPreview(button);else if(event.target.closest?.('#pr-preview'))clearTimeout(previewTimer);}); +document.addEventListener('pointerout',event=>{ + if(event.target.closest?.('[data-pr],#pr-preview')&&!event.relatedTarget?.closest?.('[data-pr],#pr-preview'))previewTimer=setTimeout(()=>{if(!document.activeElement?.matches('[data-pr]'))hidePreview();},120); +}); +document.addEventListener('scroll',hidePreview,{capture:true,passive:true}); +let sceneWidth=0; +new ResizeObserver(entries=>{ + const width=entries[0].contentRect.width; + if(Math.abs(width-sceneWidth)>1){sceneWidth=width;if(state.view==='threads'&&state.fleet.tasks.length)renderScene();} +}).observe($('#scene')); +window.addEventListener('storage',event=>{if(event.key?.startsWith('cottagecode:command-center:reply:'))renderDrawer({force:true});}); +if(state.scenario){state.snapshot=createScenario();state.loading=false;updateModel();} +syncURL();render();if(!state.scenario)poll(); diff --git a/experiments/command-center/drafts.mjs b/experiments/command-center/drafts.mjs new file mode 100644 index 0000000..1b920c1 --- /dev/null +++ b/experiments/command-center/drafts.mjs @@ -0,0 +1,15 @@ +import {inputRequestVersion} from '../../src/input-request.mjs'; +import {isTerminalForMessage} from '../../src/command-replies.mjs'; + +export function draftVersion(task) { + const raw=task?.raw||{}; + return JSON.stringify([raw.taskId||task?.id||'',raw.status||'',isTerminalForMessage(raw),inputRequestVersion(raw.inputRequest)||'no-input', + raw.conversation?.mode||task?.capability?.mode||'',raw.conversation?.messageUrl||task?.capability?.url||'']); +} + +export function reconcileDraft(draft,task) { + const version=draftVersion(task); + if(draft.version!==version){draft.changed=true;return draft;} + if(!draft.changed)draft.raw=structuredClone(task.raw); + return draft; +} diff --git a/experiments/command-center/index.html b/experiments/command-center/index.html new file mode 100644 index 0000000..b1c2a5f --- /dev/null +++ b/experiments/command-center/index.html @@ -0,0 +1,24 @@ + + +CottageCode · Command center + + +
+ CottageCodeFIELD NOTES / 01 + +
Connecting…
+
+
○ LIVE FEEDYour local village, as observed.
+ +
+ +
+
+ +
+
OBSERVE · UNDERSTAND · RESPOND123 perspective · JK cottages · ESC closeMade for the work between the windows.
+

Search across all towns. Select a result to inspect its evidence.

+ +
+ + diff --git a/experiments/command-center/server.mjs b/experiments/command-center/server.mjs new file mode 100644 index 0000000..9f7516e --- /dev/null +++ b/experiments/command-center/server.mjs @@ -0,0 +1,65 @@ +/** A loopback-only prototype harness; no arbitrary destination or generic proxy. */ +import {createServer} from 'node:http'; +import {readFile} from 'node:fs/promises'; +import {pathToFileURL} from 'node:url'; + +const localHosts=new Set(['127.0.0.1','localhost','[::1]']); +const staticFiles=new Map([['/','index.html'],['/model.mjs','model.mjs'],['/scenario.mjs','scenario.mjs'],['/replies.mjs','replies.mjs'],['/drafts.mjs','drafts.mjs'],['/app.mjs','app.mjs'],['/style.css','style.css']]); +const MAX_BODY=32*1024; +export function createLabServer({upstream='http://127.0.0.1:8787',fetchImpl=globalThis.fetch,town=false}={}){ + const target=new URL(upstream); + if(target.protocol!=='http:'||!localHosts.has(target.hostname)||target.username||target.password||!['','/'].includes(target.pathname)||target.search||target.hash)throw new Error('The upstream must be a fixed loopback HTTP origin.'); + const server=createServer(async(req,res)=>{ + const json=(status,body)=>{res.writeHead(status,{'content-type':'application/json','cache-control':'no-store','x-content-type-options':'nosniff'});res.end(JSON.stringify(body));}; + try{ + const host=new URL(`http://${req.headers.host}`); + const port=server.address()?.port; + if(!localHosts.has(host.hostname)||Number(host.port||80)!==port)return json(403,{error:'Use the local lab address.'}); + // Check raw segments before URL parsing can normalize traversal away. + if(!req.url?.startsWith('/')||req.url.startsWith('//')||req.url.includes('\\')||/%(?:2f|5c|25)/i.test(req.url.split('?')[0])||req.url.split('?')[0].split('/').some(segment=>{try{return ['.','..'].includes(decodeURIComponent(segment));}catch{return true;}}))return json(400,{error:'Invalid local route.'}); + const url=new URL(req.url,host); + const rawPath=req.url.split('?')[0]; + const agentRoute=/^\/agents\/([^/]+)\/(activity|messages)$/.exec(rawPath); + let routeAction='',routeId=''; + if(agentRoute){try{routeId=decodeURIComponent(agentRoute[1]);}catch{return json(400,{error:'Invalid local route.'});} + if(/^[A-Za-z0-9_.:-]+$/.test(routeId))routeAction=agentRoute[2];} + const activity=routeAction==='activity',messages=routeAction==='messages',agents=url.pathname==='/agents'; + const source=/^\/src\/[a-zA-Z0-9_-]+\.mjs$/.test(url.pathname),module=town&&/^\/modules\/[a-zA-Z0-9_-]+\.mjs$/.test(url.pathname),audio=town&&/^\/modules\/audio\/[a-zA-Z0-9_-]+\.mp3$/.test(url.pathname),file=staticFiles.get(url.pathname); + if(!agents&&!activity&&!messages&&!source&&!module&&!audio&&!file)return json(400,{error:'This route is not available in the lab.'}); + if(req.method!==(messages?'POST':'GET'))return json(405,{error:'This method is not available for the route.'}); + const allowedQuery=activity?new Set(['after','before','limit']):url.pathname==='/'?new Set(town?['demo']:['view','scenario']):new Set(); + if([...url.searchParams.keys()].some(key=>!allowedQuery.has(key)))return json(400,{error:'Unsupported query parameter.'}); + if(url.pathname==='/'&&[...url.searchParams].some(([key,value])=>url.searchParams.getAll(key).length!==1||!(key==='view'?['townwatch','dispatch','threads']:['0','1']).includes(value)))return json(400,{error:'Unsupported view selection.'}); + if(file||source||module||audio){ + const location=town&&url.pathname==='/'?new URL('../../src/town.html',import.meta.url):(module||audio)?new URL('../../src/'+url.pathname.slice(9),import.meta.url):source?new URL('../../src/'+url.pathname.slice(5),import.meta.url):new URL(file,import.meta.url); + try{const contents=await readFile(location);res.writeHead(200,{'content-type':audio?'audio/mpeg':url.pathname.endsWith('.css')?'text/css; charset=utf-8':url.pathname==='/'?'text/html; charset=utf-8':'text/javascript; charset=utf-8','cache-control':'no-store','x-content-type-options':'nosniff'});return res.end(contents);}catch(error){if(error.code==='ENOENT')return json(404,{error:'The lab asset is not available.'});throw error;} + } + let body; + if(messages){ + if(req.headers.origin!==host.origin||req.headers['x-cottagecode-request']!=='user-message'||!/^application\/json(?:\s*;|$)/i.test(req.headers['content-type']||'')||req.headers['sec-fetch-site']==='cross-site')return json(403,{ok:false,delivery:'not_sent',error:'Messages require an explicit local Send.'}); + if(Number(req.headers['content-length'])>MAX_BODY)return json(413,{ok:false,delivery:'not_sent',error:'The message payload is too large.'}); + let bytes=0;const chunks=[]; + for await(const chunk of req){bytes+=chunk.length;if(bytes>MAX_BODY)return json(413,{ok:false,delivery:'not_sent',error:'The message payload is too large.'});chunks.push(chunk);} + body=Buffer.concat(chunks).toString('utf8'); + try{const parsed=JSON.parse(body);if(!parsed||typeof parsed!=='object'||Array.isArray(parsed))throw new Error();}catch{return json(400,{ok:false,delivery:'not_sent',error:'Send a JSON message object.'});} + } + const response=await fetchImpl(new URL(url.pathname+url.search,target),{method:req.method,redirect:'manual',signal:AbortSignal.timeout(15_000),headers:{accept:'application/json',...(messages?{'content-type':'application/json','origin':target.origin,'x-cottagecode-request':'user-message'}:{})},...(messages?{body}:{})}); + if(response.status>=300&&response.status<400)return json(502,{error:'The local source returned a redirect.'}); + const responseBody=await response.text(); + res.writeHead(response.status,{'content-type':'application/json','cache-control':'no-store','x-content-type-options':'nosniff'});res.end(responseBody); + }catch{return json(502,{error:'The local source is unavailable. No illustrative data has been substituted.'});} + }); + // Even an embedding caller cannot accidentally expose the proxy on the LAN. + const listen=server.listen.bind(server); + server.listen=(port=8790,host,callback)=>{ + if(typeof port==='object')throw new Error('Use a numeric port; this lab binds loopback only.'); + if(typeof host==='string'&&!localHosts.has(host))throw new Error('The lab binds loopback only.'); + return listen(port,'127.0.0.1',typeof host==='function'?host:callback); + }; + return server; +} +if(process.argv[1]&&pathToFileURL(process.argv[1]).href===import.meta.url){ + const index=process.argv.indexOf('--port'),port=index<0?8790:Number(process.argv[index+1]); + if(!Number.isInteger(port)||port<1||port>65535)throw new Error('Use --port with a port between 1 and 65535.'); + createLabServer({town:process.argv.includes('--town')}).listen(port,()=>process.stdout.write(`CottageCode command center: http://127.0.0.1:${port}\n`)); +} diff --git a/experiments/command-center/style.css b/experiments/command-center/style.css new file mode 100644 index 0000000..70c21d8 --- /dev/null +++ b/experiments/command-center/style.css @@ -0,0 +1,9 @@ +:root{--ink:#101923;--terrain:#142431;--paper:#f2eee3;--muted:#b4c3cc;--faint:#8fa3ae;--rule:#334653;--brass:#e2be79;--amber:#f1ad82;--coral:#ef8c82;--mint:#9fd6b6;--blue:#a9c9dc;--lilac:#c1b5e0;--document:#1b2c38;font:14px/1.45 ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--paper);background:var(--ink);font-synthesis:none}*{box-sizing:border-box}body{margin:0}button,input,textarea,select{font:inherit}button,a,input,textarea,select{ -webkit-tap-highlight-color:transparent}button{color:inherit;cursor:pointer;border:0;background:transparent}button:disabled{opacity:.55;cursor:default}button:hover:not(:disabled){color:var(--brass)}a{color:var(--brass);text-decoration:none}a:hover{text-decoration:underline}button:focus-visible,a:focus-visible,summary:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible{outline:2px solid var(--brass);outline-offset:3px}button,input,select{min-height:42px}h1,h2,h3,p{margin:0}h1{font-size:30px;font-weight:650;letter-spacing:-1px;line-height:1.2}h2{font-size:22px;font-weight:600;letter-spacing:-.4px}h3{font-weight:600}kbd,.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-variant-numeric:tabular-nums}kbd{font-size:10px;color:var(--faint)}.muted{color:var(--muted)}.eyebrow{font-size:10px;font-weight:600;letter-spacing:1.7px;color:var(--faint)}.topbar{height:72px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--rule);padding:0 24px;gap:20px}.brand{display:flex;align-items:center;gap:10px;color:var(--paper);font-size:19px;font-weight:650;letter-spacing:-.6px;white-space:nowrap}.brand:hover{text-decoration:none}.brand-house{font-size:33px;line-height:1;color:var(--brass)}.edition{font:9px/1.1 ui-monospace,monospace;letter-spacing:1.2px;max-width:55px;color:var(--faint);margin-left:12px}.perspectives{display:flex;gap:28px;height:100%}.perspectives button{position:relative;font-size:13px;font-weight:550;padding:0;color:var(--muted)}.perspectives button[aria-current=true]{color:var(--paper)}.perspectives button[aria-current=true]:after{content:"";height:2px;background:var(--brass);position:absolute;bottom:0;left:0;right:18px}.perspectives kbd{margin-left:9px}.source-tools{display:flex;align-items:center;gap:18px;font-size:11px;color:var(--muted)}#search-open{font-size:25px;display:flex;align-items:center;gap:9px}.provenance{min-height:41px;border-bottom:1px solid #263a47;display:flex;justify-content:space-between;align-items:center;padding:0 25px;background:#12212b;font-size:11px;gap:12px}.provenance>div{display:flex;gap:18px;align-items:center}.provenance button{font-size:11px;min-height:40px;color:var(--muted)}#source-mark{font-size:10px;font-weight:650;letter-spacing:1.2px;color:var(--mint)}#source-description{color:var(--muted)}.illustrative .provenance{background:#2b2922;border-bottom-color:#66583d}.illustrative #source-mark{color:var(--brass)}.illustrative #source-description{color:#d5c7aa}#connection{padding:12px 24px;color:var(--amber);background:#342820;border-bottom:1px solid #705744;font-size:12px}#connection button{text-decoration:underline;margin-left:12px}.workspace{display:grid;grid-template-columns:172px minmax(0,1fr);min-height:calc(100vh - 146px);align-items:start}.workspace.has-drawer{grid-template-columns:172px minmax(0,1fr) 370px}.town-rail{padding:29px 12px 24px 18px;border-right:1px solid #293b47;align-self:stretch;display:flex;flex-direction:column;min-height:650px}.town-rail>.eyebrow{font-size:9px;letter-spacing:1.1px;padding-left:9px;margin-bottom:22px}.town-link{width:100%;display:grid;grid-template-columns:1fr auto;text-align:left;align-items:center;padding:9px 10px;min-height:57px;border-left:2px solid transparent;margin-bottom:4px;font-size:12px}.town-link.active{border-color:var(--brass);background:#d7bb7b0c}.town-link span:nth-child(2){font-family:ui-monospace,monospace;color:var(--faint);font-size:11px}.town-link small{grid-column:1/-1;display:block;color:var(--amber);font-size:10px}.rail-foot{margin-top:auto;padding:80px 10px 0;color:var(--faint)}.compass{display:block;font-size:40px;color:#637d8b;margin-bottom:16px}.rail-foot p{font-family:Georgia,serif;font-size:17px;line-height:1.5;color:#a8b8c0}.rail-foot>span:last-child{display:block;font-size:7px;letter-spacing:1.2px;margin-top:22px}.mobile-town{display:none}main{min-width:0;padding:30px 30px 40px;background:var(--terrain);align-self:stretch}#view-heading{display:flex;align-items:end;justify-content:space-between;gap:20px;margin-bottom:25px}.view-intro>.eyebrow{margin-bottom:9px;color:var(--brass);font-size:9px}.view-intro p{margin-top:9px;color:var(--muted);font-size:12px}.summary{display:flex;gap:27px}.summary div{display:flex;flex-direction:column;gap:3px}.summary b{font-size:24px;line-height:1.2;font-weight:400;font-variant-numeric:tabular-nums}.summary span{color:var(--muted);font-size:10px}.summary .attention{color:var(--amber)}.summary .ready{color:var(--mint)}.attention-strip{border-top:1px solid #786447;border-bottom:1px solid #786447;display:flex;align-items:center;gap:16px;padding:13px 14px;background:#bba06a08;margin-bottom:24px;min-height:66px}.attention-strip .letter{color:var(--amber);font-size:22px}.attention-strip .strip-meta{font-size:10px;color:var(--amber);letter-spacing:.5px}.attention-strip p{font-size:13px;margin-top:3px;max-width:680px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.strip-open{text-align:left;flex:1;padding:0;min-width:0}.strip-next{font-size:21px;flex-shrink:0}.lens-row,.filter-row{display:flex;align-items:center;gap:4px;flex-wrap:wrap}.lens-row{justify-content:space-between;border-bottom:1px solid var(--rule);padding-bottom:9px}.lens-row .eyebrow{font-size:9px}.lens-options{display:flex;gap:3px}.lens-options button,.filter-row button{font-size:11px;padding:0 12px;min-height:36px;border-bottom:1px solid transparent;color:var(--muted)}.lens-options button.active,.filter-row button.active{color:var(--brass);border-color:var(--brass)}.atlas{position:relative;background:radial-gradient(ellipse at 20% 35%,#1d384443,transparent 60%),radial-gradient(circle at 84% 22%,#89a6b513 1px,transparent 1.7px);background-size:auto,41px 47px;min-height:460px;padding:17px 0 10px}.district{position:relative;padding:17px 0 23px;border-bottom:1px dashed #53708140}.district:last-child{border-bottom:0}.district-heading{display:flex;align-items:center;gap:12px;margin-bottom:12px}.district-heading h2{font-family:Georgia,serif;font-weight:400;font-size:20px;letter-spacing:.1px}.district-heading span{font-family:ui-monospace,monospace;font-size:9px;color:var(--faint)}.district-heading:before{content:"";width:12px;height:2px;background:var(--brass);opacity:.6}.cottage-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px 14px;position:relative}.cottage-grid:before{content:"";position:absolute;height:7px;border-block:1px solid #53687145;background:#52616b13;left:6%;right:6%;top:83px;pointer-events:none}.cottage{position:relative;display:flex;flex-direction:column;align-items:center;text-align:center;min-width:0;padding:0 8px 10px;isolation:isolate;border-bottom:1px solid transparent}.cottage.selected{border-color:var(--brass);background:linear-gradient(transparent,#e2be7908)}.house-scene{position:relative;width:112px;height:100px;display:grid;place-items:center}.house{width:84px;height:84px;image-rendering:pixelated;overflow:visible}.house .window{fill:#57717b}.house.working .window{fill:#e1b37c}.house .smoke{display:none}.house.working .smoke{display:block;animation:smoke 5s linear infinite;transform-origin:50% 50%}.cottage .task-symbol{position:absolute;right:12px;top:10px;border:1px solid currentColor;background:var(--terrain);height:20px;min-width:20px;display:grid;place-items:center;font:11px ui-monospace,monospace}.task-symbol.needs-input{color:var(--amber)}.task-symbol.blocked{color:var(--coral)}.task-symbol.ready{color:var(--mint)}.cottage-title{font-size:12px;line-height:1.45;font-weight:550;min-height:35px;max-width:180px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;color:var(--paper)}.cottage-annotation{font-size:10px;line-height:1.6;color:var(--muted);margin-top:5px;max-width:180px}.cottage-annotation.attention{color:var(--amber)}.atlas-legend{display:flex;justify-content:space-between;gap:16px;flex-wrap:wrap;font-size:10px;color:var(--faint);border-top:1px solid var(--rule);padding-top:14px}.legend-symbol{display:inline-block;margin-right:5px}.legend-symbol.working{color:#e1b37c}.legend-symbol.blocked{color:var(--coral)}.legend-symbol.ready{color:var(--mint)}.filter-row{border-block:1px solid var(--rule);padding:6px 0;margin-bottom:8px}.filter-row button{padding-inline:13px}.filter-row b{font:10px ui-monospace,monospace;margin-left:7px;color:var(--faint)}.view-note{font-size:11px;color:var(--faint);margin:12px 0 16px}.ledger-head,.ledger-row{display:grid;grid-template-columns:minmax(190px,2.2fr) minmax(95px,1fr) minmax(115px,.9fr) minmax(108px,.9fr);gap:20px;align-items:center}.ledger-head{font-size:9px;letter-spacing:1.4px;color:var(--faint);padding:18px 14px 12px}.ledger-row{min-height:99px;border-top:1px solid var(--rule);border-left:2px solid transparent;padding:17px 12px;position:relative}.ledger-row:hover{background:#ffffff03}.ledger-row.selected{background:#e2be7908;border-left-color:var(--brass)}.ledger-work{display:flex;gap:12px;align-items:start;min-width:0}.ledger-work>.state-icon{margin-top:6px}.state-icon{color:var(--blue);font:14px ui-monospace,monospace;flex-shrink:0}.state-icon.blocked{color:var(--coral)}.state-icon.input{color:var(--amber)}.state-icon.done{color:var(--mint)}.task-title{text-align:left;font-weight:550;font-size:14px;min-height:0;padding:0;line-height:1.45;display:block}.ledger-work p{color:var(--muted);font-size:11px;margin-top:5px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.ledger-work .recorded{color:var(--amber);padding-left:9px;border-left:1px solid #a3815c;margin-top:10px;font-size:12px;-webkit-line-clamp:3}.owner-cell{font-size:12px}.owner-cell small{display:block;color:var(--faint);font-size:10px;margin-top:4px}.clocks-cell{font-size:10px;color:var(--muted);line-height:1.9;font-variant-numeric:tabular-nums}.clocks-cell span{display:inline-block;min-width:43px;color:var(--faint)}.clocks-cell small{font-size:9px}.pr-badge{font:11px/1.6 ui-monospace,monospace;text-align:left;padding:0;min-height:44px}.pr-badge small{display:block;font-family:ui-sans-serif,system-ui;font-size:10px;color:var(--muted)}.pr-badge.ready,.status.ready{color:var(--mint)}.pr-badge.blocked,.status.blocked{color:var(--coral)}.pr-badge.waiting-codex,.status.waiting-codex{color:var(--lilac)}.status{font-size:11px;color:var(--blue);display:inline-flex;align-items:center;gap:6px}.empty{padding:48px 24px;border:1px dashed var(--rule);color:var(--muted);font-size:13px;text-align:center}.empty h2{color:var(--paper);margin-bottom:8px}.threads-intro{border-block:1px solid var(--rule);padding:13px 0;display:flex;justify-content:space-between;color:var(--muted);font-size:11px;gap:16px}.graph{position:relative;min-height:520px;margin-top:18px}.graph-head{display:grid;grid-template-columns:26% 37% 27%;justify-content:space-between;gap:20px;border-bottom:1px solid var(--rule);padding:0 0 15px;font-size:9px;letter-spacing:1.4px;color:var(--faint)}.graph-body{position:relative;min-height:500px}.graph-lines{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;overflow:visible}.graph-lines path{fill:none;stroke:#536b7b;stroke-width:1.25}.graph-lines path.selected{stroke:var(--brass);stroke-width:2}.graph-lines text{fill:var(--faint);font:8px ui-monospace,monospace;paint-order:stroke;stroke:var(--terrain);stroke-width:4px;stroke-linejoin:round}.graph-lines text.selected{fill:var(--brass)}.graph-node{position:absolute;text-align:left;border:1px solid var(--rule);border-radius:5px;background:#192b38;width:26%;min-height:74px;padding:12px;display:block;z-index:1}.graph-node.task-node{width:37%;background:#1b2e3b}.graph-node.pr-node{width:27%}.graph-node.selected{border-color:var(--brass);background:#27343b}.graph-node .node-kind{font:9px ui-monospace,monospace;color:var(--brass);display:block;margin-bottom:7px}.graph-node strong{font-size:12px;font-weight:500;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.graph-node small{font-size:10px;color:var(--muted);display:block;margin-top:6px}.graph-placeholder{position:absolute;width:25%;left:0;top:30px;font-size:12px;color:var(--muted);padding:16px;border-left:1px solid var(--rule)}.trace{display:none}.drawer{position:sticky;top:0;max-height:100vh;overflow-y:auto;background:var(--document);border-left:1px solid #48606d;align-self:start;min-height:calc(100vh - 146px)}.drawer-head{padding:18px 22px 0;position:sticky;top:0;background:var(--document);z-index:2;display:flex;justify-content:space-between;align-items:center}.drawer-head button{font-size:22px}.drawer-head .back-label{font-size:11px;margin-right:12px;color:var(--muted)}.drawer-content{padding:15px 23px 32px}.drawer .identity h2{font-size:22px;line-height:1.3;overflow-wrap:anywhere}.identity .byline{font-size:11px;color:var(--muted);margin-top:10px;line-height:1.7}.identity .state-line{display:flex;gap:14px;align-items:center;margin-top:12px;font-size:11px}.drawer section{margin-top:25px;padding-top:21px;border-top:1px solid #415661}.drawer section h3{font-size:10px;letter-spacing:1.35px;text-transform:uppercase;color:var(--muted);margin-bottom:13px}.activity-copy{font-size:13px;line-height:1.65;white-space:pre-wrap;overflow-wrap:anywhere}.data-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px 18px;margin-top:19px}.data-grid dt{font-size:10px;color:var(--faint);margin-bottom:4px}.data-grid dd{margin:0;font-size:12px;font-variant-numeric:tabular-nums}.drawer .input-section{border-top-color:#9d8058}.input-section h3{color:var(--amber)}.question{font-size:15px;line-height:1.6;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--paper)}details{margin-top:10px}summary{color:var(--brass);font-size:11px;cursor:pointer;padding:6px 0;min-height:32px}.full-text{font-size:12px;line-height:1.65;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--muted);max-height:420px;overflow-y:auto;padding-right:5px}.input-source{font-size:10px;color:var(--faint);margin-top:12px}.options{display:flex;flex-direction:column;gap:7px;margin-top:15px}.option{border:1px solid #7e6e54;text-align:left;padding:9px 12px;font-size:12px;border-radius:4px;min-height:42px}.option small{display:block;color:var(--muted);font-size:10px;margin-top:3px}.composer label{display:block;font-size:11px;color:var(--muted);margin:15px 0 8px}.composer textarea{display:block;width:100%;min-height:98px;resize:vertical;background:#11222d;border:1px solid #576975;border-radius:4px;color:var(--paper);padding:11px;font-size:13px;line-height:1.5}.send{margin-top:12px;background:var(--brass);color:#18232b;padding:0 15px;border-radius:4px;font-size:12px;font-weight:600}.send:hover:not(:disabled){color:#18232b;background:#eed098}.capability-note,.reply-notice{font-size:12px;line-height:1.6;color:var(--muted);margin-top:15px}.reply-notice{color:var(--brass)}.draft-warning{background:#e7b56c0c;border-left:2px solid var(--amber);padding:10px;font-size:12px;color:var(--amber);margin-top:12px}.draft-warning button{text-decoration:underline;font-size:11px}.receipt{font-size:12px;border-left:2px solid var(--mint);padding-left:12px;margin-top:14px;color:var(--mint)}.evidence-list{margin:0;display:grid;grid-template-columns:75px minmax(0,1fr);gap:10px;font-size:11px;line-height:1.5}.evidence-list dt{color:var(--faint)}.evidence-list dd{margin:0;overflow-wrap:anywhere}.check-list{list-style:none;padding:0;margin:15px 0 0;font-size:11px;color:var(--muted)}.check-list li{padding:7px 0;border-bottom:1px solid #334955;display:flex;gap:10px;justify-content:space-between}.source-link{display:inline-block;font-size:12px;margin-top:14px}.relationship-list{list-style:none;margin:0;padding:0}.relationship-list li{padding:8px 0;border-bottom:1px solid var(--rule);font-size:11px;color:var(--muted)}.relationship-list button{text-align:left;font-size:12px;padding:0;color:var(--brass)}footer{min-height:33px;border-top:1px solid var(--rule);padding:8px 23px;display:flex;justify-content:space-between;gap:20px;color:var(--faint);font-size:9px}footer>span:first-child{font-size:8px;letter-spacing:1.2px}footer kbd{border:1px solid var(--rule);padding:1px 3px;margin-right:3px;font-size:8px}footer i{font-style:normal;padding:0 6px}dialog{color:var(--paper);background:var(--document);border:1px solid #617481;border-radius:8px;width:min(620px,calc(100% - 32px));max-height:75vh;padding:22px;box-shadow:0 25px 90px #0008}dialog::backdrop{background:#071019c9;backdrop-filter:blur(5px)}.search-top{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}.search-top label{font-size:18px}.search-top button{font-size:25px}#search-input{width:100%;background:#11212c;color:var(--paper);border:1px solid #586e7c;border-radius:4px;padding:12px}dialog>.muted{font-size:11px;margin:12px 0}#search-results{max-height:45vh;overflow:auto}.search-result{width:100%;display:block;text-align:left;padding:13px 8px;border-top:1px solid var(--rule);font-size:13px}.search-result small{display:block;color:var(--faint);font-size:11px;margin-top:4px}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.skip{position:fixed;top:-60px;left:20px;padding:12px;background:var(--brass);color:var(--ink);z-index:100}.skip:focus{top:5px}[hidden]{display:none!important}@keyframes smoke{0%{transform:translate(0,3px);opacity:.1}50%{opacity:.5}100%{transform:translate(3px,-5px);opacity:0}} +@media(min-width:1500px){.cottage-grid{grid-template-columns:repeat(5,minmax(0,1fr))}.atlas{padding-inline:12px}}@media(max-width:1200px){.edition{display:none}.topbar{gap:14px}.perspectives{gap:18px}.workspace.has-drawer{grid-template-columns:145px minmax(0,1fr) 340px}.workspace{grid-template-columns:145px minmax(0,1fr)}main{padding:25px 22px}.summary{gap:18px}.has-drawer #view-heading{align-items:start;flex-direction:column;gap:16px}.has-drawer .summary{gap:24px}.has-drawer .summary div{flex-direction:row;align-items:baseline;gap:8px}.has-drawer .summary b{font-size:18px}.has-drawer .ledger-head{display:none}.has-drawer .ledger-row{grid-template-columns:1fr 1fr;gap:10px 20px}.has-drawer .ledger-work{grid-column:1/-1}.has-drawer .clocks-cell{grid-column:1/-1;grid-row:3}.has-drawer .clocks-cell div{display:inline-block;margin-right:10px}.has-drawer .cottage-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.has-drawer .graph-head{font-size:7px;letter-spacing:.5px}.has-drawer .graph-node{padding:8px}.has-drawer .graph-node strong{font-size:10px}.has-drawer .graph-node small{font-size:9px}.source-tools{gap:8px}.town-rail{padding-left:10px}.town-rail>.eyebrow{font-size:8px}.ledger-head,.ledger-row{gap:14px;grid-template-columns:minmax(180px,2fr) minmax(80px,.85fr) minmax(100px,.8fr) minmax(95px,.8fr)}footer>span:last-child{display:none}} +@media(max-width:900px){.brand{font-size:17px}.topbar{padding-inline:16px}.source-tools #freshness{display:none}.perspectives{gap:16px}.summary{gap:15px}.workspace,.workspace.has-drawer{grid-template-columns:125px minmax(0,1fr)}.has-drawer .drawer{position:fixed;right:0;top:113px;bottom:0;width:370px;z-index:10;box-shadow:-20px 0 70px #07131e99}.town-rail>.eyebrow{font-size:7px}.town-link{font-size:11px;padding-inline:7px}#view-heading{align-items:start;flex-direction:column;gap:17px}.summary div{flex-direction:row;align-items:baseline;gap:8px}.summary b{font-size:20px}.cottage-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.ledger-head{display:none}.ledger-row{grid-template-columns:1fr 1fr;gap:10px}.ledger-work{grid-column:1/-1}.clocks-cell{grid-column:1/-1;grid-row:3}.clocks-cell div{display:inline-block;margin-right:10px}.pr-badge{justify-self:end}.ledger-row .owner-cell{padding-left:26px}.graph-node{padding:9px}.graph-node strong{font-size:11px}.graph-head{font-size:8px;letter-spacing:.5px}} +@media(max-width:760px){.topbar{height:111px;display:grid;grid-template-columns:1fr auto;gap:0 16px;padding:0 16px}.brand{font-size:19px}.brand-house{font-size:31px}.perspectives{grid-row:2;grid-column:1/-1;justify-content:space-between;height:45px;gap:0}.perspectives button{font-size:13px;min-width:95px}.perspectives button[aria-current=true]:after{right:0}.source-tools{grid-column:2;grid-row:1}.source-tools #freshness{display:block;font-size:10px}.provenance{padding:7px 16px;align-items:flex-start;gap:9px}.provenance>div{align-items:flex-start;flex-direction:column;gap:4px;max-width:65%}#source-description{font-size:10px}.provenance button{font-size:10px;min-height:38px;text-align:right;max-width:130px}.workspace,.workspace.has-drawer{display:block;min-height:calc(100vh - 208px)}.town-rail{display:none}.mobile-town{display:flex;align-items:center;gap:12px;padding:8px 16px;border-bottom:1px solid var(--rule);font-size:11px;color:var(--muted)}.mobile-town select{background:transparent;border:0;color:var(--paper);flex:1;min-height:34px;max-width:100%}main{padding:24px 16px 32px}h1{font-size:28px}#view-heading{gap:18px;margin-bottom:21px}.view-intro p{font-size:12px}.summary{width:100%;justify-content:space-between;max-width:430px}.summary b{font-size:22px}.summary span{font-size:10px}.attention-strip{gap:12px;padding:12px 10px;margin-bottom:20px}.attention-strip p{font-size:12px}.lens-row{gap:7px}.lens-options button{padding-inline:9px;font-size:11px}.cottage-grid,.has-drawer .cottage-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px 16px}.cottage-title{font-size:13px;max-width:205px}.cottage-annotation{font-size:11px}.house-scene{height:105px}.house{width:90px;height:90px}.cottage-grid:before{top:88px}.district{padding:22px 0}.district-heading h2{font-size:22px}.atlas-legend{font-size:10px}.ledger-row,.has-drawer .ledger-row{padding:17px 8px;grid-template-columns:minmax(0,1fr) auto}.ledger-work{gap:11px}.ledger-work p{font-size:12px}.ledger-work .recorded{font-size:13px;-webkit-line-clamp:4}.task-title{font-size:15px}.clocks-cell,.has-drawer .clocks-cell{font-size:10px;padding-left:25px}.owner-cell{font-size:11px}.owner-cell small{font-size:10px}.filter-row{gap:0}.filter-row button{padding-inline:10px;font-size:11px}.filter-row b{margin-left:5px}.has-drawer .drawer{position:fixed;inset:111px 0 0;width:100%;max-height:calc(100dvh - 111px);z-index:20;border-left:0;min-height:0;box-shadow:none}.drawer-head{padding:12px 22px 0}.drawer-content{max-width:650px;margin:auto;padding-top:10px}.drawer .identity h2{font-size:24px}.drawer-content .data-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.drawer .question{font-size:16px}.graph{display:none}.trace{display:block;margin-top:20px}.trace-select{display:block;width:100%;color:var(--paper);background:var(--ink);border:1px solid var(--rule);padding:10px;max-width:100%;font-size:12px}.trace-section{padding:22px 0 0 18px;border-left:1px solid var(--rule);margin-left:8px}.trace-section>.eyebrow{margin-bottom:12px}.trace-card{display:block;width:100%;text-align:left;padding:15px;border:1px solid var(--rule);background:#1b2e3b;border-radius:4px;font-size:13px;margin-bottom:10px}.trace-card.selected{border-color:var(--brass)}.trace-card small{display:block;margin-top:6px;color:var(--muted);font-size:11px}.trace-caption{font-size:11px;color:var(--brass);padding:0 0 12px}.threads-intro{font-size:11px}.threads-intro>span:last-child{display:none}footer{padding:10px 16px;font-size:8px}footer>span:first-child{display:none}.source-tools kbd{display:none}#search-open{font-size:26px}.full-text{max-height:none}} +@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important;scroll-behavior:auto!important}} +/* The atlas packs districts together; the scenery is continuous between them. */ +@media(min-width:761px){.atlas{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 30px;align-items:start;padding-top:7px}.district{padding:17px 0 14px;min-width:0}.district-heading{margin-bottom:3px}.district-heading h2{font-size:19px}.cottage-grid,.has-drawer .cottage-grid{grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.cottage{padding-inline:3px;padding-bottom:9px}.house-scene{height:82px;width:92px}.house{width:77px;height:77px}.cottage-title{font-size:11.5px;line-height:1.4;min-height:33px}.cottage-annotation{font-size:9px;margin-top:4px;line-height:1.5}.cottage-grid:before{top:69px}.cottage .task-symbol{right:5px;top:5px}.atlas-legend{margin-top:15px}.atlas:has(>.district:only-child){grid-template-columns:minmax(0,1fr)}.atlas:has(>.district:only-child) .cottage-grid{grid-template-columns:repeat(4,minmax(0,1fr))}.atlas:has(>.district:only-child) .cottage-title{font-size:12px;max-width:180px}.atlas:has(>.district:only-child) .cottage-annotation{font-size:10px}} +.search-result.active{background:#e2be7910;box-shadow:inset 2px 0 var(--brass);color:var(--paper)} +.pr-preview{position:fixed;z-index:35;width:min(320px,calc(100vw - 20px));padding:16px;background:#203442;border:1px solid #6c7b7f;border-radius:5px;box-shadow:0 12px 36px #060f1866;pointer-events:auto}.pr-preview>.eyebrow{font-size:9px;color:var(--brass);margin-bottom:10px}.pr-preview>strong{font-size:14px;display:flex;justify-content:space-between;gap:14px}.pr-preview dl{display:grid;grid-template-columns:62px minmax(0,1fr);gap:6px 12px;font-size:11px;margin:13px 0}.pr-preview dt{color:var(--faint)}.pr-preview dd{margin:0;overflow-wrap:anywhere}.pr-preview p{font-size:12px;line-height:1.5;color:var(--muted);overflow-wrap:anywhere}.pr-preview>small{display:block;font-size:10px;color:var(--brass);margin-top:12px} diff --git a/test/command-center-drafts.test.mjs b/test/command-center-drafts.test.mjs new file mode 100644 index 0000000..7eeca40 --- /dev/null +++ b/test/command-center-drafts.test.mjs @@ -0,0 +1,38 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {draftVersion,reconcileDraft} from '../experiments/command-center/drafts.mjs'; +import {createReplyClient} from '../experiments/command-center/replies.mjs'; + +const now=Date.parse('2026-11-08T12:00:00Z'),endpoint='http://127.0.0.1:8790/agents'; +const question=(extra={})=>({id:'cottage-1',taskId:'task-1',status:'blocked',inputRequest:{id:'q1',kind:'question',prompt:'Continue?'},conversation:{available:true,mode:'respond',checkedAt:now,messageUrl:'/agents/cottage-1/messages'},...extra}); +const view=(raw,at)=>({id:raw.id,raw,capability:{available:true,mode:raw.conversation.mode,url:new URL(raw.conversation.messageUrl,endpoint).href}}); +const draftFor=task=>({text:'Yes, continue.',version:draftVersion(task),raw:structuredClone(task.raw),changed:false}); +const memory=()=>{const values=new Map();return {getItem:key=>values.get(key)??null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};}; +const json=(body,status=200)=>new Response(JSON.stringify(body),{status,headers:{'content-type':'application/json'}}); + +test('same evidence refreshes expired checkedAt without discarding a draft and send succeeds',async()=>{ + const shown=view(question(),now),draft=draftFor(shown),freshRaw=question({conversation:{...question().conversation,checkedAt:now+121000}}),fresh=view(freshRaw,now+121000); + reconcileDraft(draft,fresh);assert.equal(draft.changed,false);assert.equal(draft.raw.conversation.checkedAt,now+121000);assert.equal(draft.text,'Yes, continue.'); + let posts=0;const client=createReplyClient({endpoint,storage:memory(),now:()=>now+121000,fetchImpl:async(url,options)=>{ + if(options?.method==='POST'){posts++;return json({ok:true,delivery:'accepted',requestId:JSON.parse(options.body).requestId});} + return json({agents:[freshRaw]}); + }}); + assert.equal((await client.send(draft.raw,draft.text)).phase,'sent');assert.equal(posts,1); +}); + +test('semantic task and route changes require review and preserve prior raw evidence and text',()=>{ + const base=view(question(),now),changes=[ + question({status:'working'}),question({inputRequest:{id:'q2',kind:'question',prompt:'Different?'}}), + question({taskId:'task-2'}),question({terminal:true}),question({conversation:{...question().conversation,mode:'redirect',messageUrl:'/agents/cottage-1/messages'}}), + question({conversation:{...question().conversation,messageUrl:'/agents/cottage-1/other'}}), + ]; + for(const changedRaw of changes){const draft=draftFor(base),original=draft.raw;reconcileDraft(draft,view(changedRaw,now+121000));assert.equal(draft.changed,true);assert.equal(draft.raw,original);assert.equal(draft.text,'Yes, continue.');} +}); + +test('explicit review adoption accepts changed evidence and a later fresh snapshot refreshes it',()=>{ + const old=view(question(),now),draft=draftFor(old),changed=view(question({status:'working'}),now+121000); + reconcileDraft(draft,changed);assert.equal(draft.changed,true); + draft.raw=structuredClone(changed.raw);draft.version=draftVersion(changed);draft.changed=false; + const refreshed=view(question({status:'working',conversation:{...question().conversation,checkedAt:now+242000}}),now+242000); + reconcileDraft(draft,refreshed);assert.equal(draft.changed,false);assert.equal(draft.raw.conversation.checkedAt,now+242000);assert.equal(draft.text,'Yes, continue.'); +}); diff --git a/test/command-center-server.test.mjs b/test/command-center-server.test.mjs new file mode 100644 index 0000000..2cb6d1f --- /dev/null +++ b/test/command-center-server.test.mjs @@ -0,0 +1,71 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createServer} from 'node:http'; +import {once} from 'node:events'; +import {createLabServer} from '../experiments/command-center/server.mjs'; +async function listen(server){server.listen(0,'127.0.0.1');await once(server,'listening');return `http://127.0.0.1:${server.address().port}`;} +async function fixture(t){const seen=[];const upstream=createServer(async(req,res)=>{let body='';for await(const chunk of req)body+=chunk;seen.push({url:req.url,method:req.method,origin:req.headers.origin,request:req.headers['x-cottagecode-request'],body});res.setHeader('content-type','application/json');res.end(JSON.stringify({ok:true,agents:[],route:req.url}));});const upstreamUrl=await listen(upstream);const server=createLabServer({upstream:upstreamUrl});const url=await listen(server);t.after(()=>{server.closeAllConnections();upstream.closeAllConnections();return Promise.all([new Promise(r=>server.close(r)),new Promise(r=>upstream.close(r))]);});return {url,upstreamUrl,seen,server};} +const headers=origin=>({origin,'content-type':'application/json','x-cottagecode-request':'user-message'}); +test('view links accept only known root perspectives and scenario values',async t=>{ + const {url,seen}=await fixture(t);const base=await fetch(url+'/'),status=base.status,body=await base.text(); + for(const query of ['view=townwatch','view=dispatch','view=threads&scenario=1','scenario=0']){ + const response=await fetch(url+'/?'+query);assert.equal(response.status,status);assert.equal(await response.text(),body); + } + for(const path of ['/?view=terminal','/?scenario=yes','/?view=townwatch&view=threads','/?upstream=elsewhere','/agents?view=threads','/model.mjs?scenario=1'])assert.equal((await fetch(url+path)).status,400,path); + assert.equal(seen.length,0); +}); +test('lab proxies only exact feed/activity routes to the constructor upstream',async t=>{ + const {url,seen}=await fixture(t);assert.equal((await fetch(url+'/agents')).status,200);assert.equal((await fetch(url+'/agents/cottage-1/activity?before=cursor&limit=10')).status,200); + assert.deepEqual(seen.map(r=>r.url),['/agents','/agents/cottage-1/activity?before=cursor&limit=10']); + for(const path of ['/v1/tasks','/agents/one/unknown','/agents?upstream=http://evil.invalid','/agents/one/activity?url=http://evil.invalid','/agents/a%2Fb/activity','/agents/a%252Fb/activity'])assert.equal((await fetch(url+path)).status,400,path); + assert.equal(seen.length,2); +}); +test('cross-origin, missing intent, unsupported methods and oversized writes never reach upstream',async t=>{ + const {url,seen}=await fixture(t);const path=url+'/agents/cottage-1/messages'; + assert.equal((await fetch(path,{method:'POST',headers:headers('https://evil.invalid'),body:'{}'})).status,403); + assert.equal((await fetch(path,{method:'POST',headers:{origin:url,'content-type':'application/json'},body:'{}'})).status,403); + for(const [method,route] of [['PUT','/agents'],['POST','/agents'],['GET','/agents/cottage-1/messages'],['DELETE','/agents/cottage-1/messages']])assert.equal((await fetch(url+route,{method})).status,405); + assert.equal((await fetch(path,{method:'POST',headers:headers(url),body:JSON.stringify({message:'x'.repeat(40_000)})})).status,413);assert.equal(seen.length,0); +}); +test('explicit local message proxy preserves validation headers for upstream authority',async t=>{ + const {url,upstreamUrl,seen}=await fixture(t);const body=JSON.stringify({taskId:'fictional',message:'stub only',requestId:'stub-only-1234'}); + assert.equal((await fetch(url+'/agents/cottage-1/messages',{method:'POST',headers:headers(url),body})).status,200); + assert.equal(seen.length,1);assert.equal(seen[0].origin,upstreamUrl);assert.equal(seen[0].request,'user-message');assert.equal(seen[0].body,body); +}); +test('static routes only expose safe known modules and no filesystem traversal',async t=>{ + const {url,seen}=await fixture(t);assert.equal((await fetch(url+'/model.mjs')).status,200);assert.equal((await fetch(url+'/drafts.mjs')).status,200);assert.equal((await fetch(url+'/src/pr.mjs')).status,200); + for(const route of ['/src/../package.json','/src/%2e%2e%2fpackage.json','/server.mjs','/.git/config'])assert.ok([400,404].includes((await fetch(url+route)).status),route);assert.equal(seen.length,0); +}); +test('upstream is fixed loopback only and redirect responses are not followed',async t=>{ + assert.throws(()=>createLabServer({upstream:'https://evil.invalid'}));let seen=0; + const server=createLabServer({fetchImpl:async(_,options)=>{seen++;assert.equal(options.redirect,'manual');return new Response('',{status:302,headers:{location:'https://evil.invalid'}});}});const url=await listen(server);t.after(()=>new Promise(r=>server.close(r))); + assert.equal((await fetch(url+'/agents')).status,502);assert.equal(seen,1); +}); +test('embedding callers cannot expose the lab on a non-loopback interface',()=>{ + const server=createLabServer();assert.throws(()=>server.listen(8790,'0.0.0.0'),/loopback/);assert.throws(()=>server.listen({port:8790,host:'0.0.0.0'}),/loopback/); +}); + +test('town preview serves the original scene and production modules without widening the proxy',async t=>{ + const server=createLabServer({town:true});const url=await listen(server);t.after(()=>new Promise(r=>server.close(r))); + const response=await fetch(url+'/?demo=1');assert.equal(response.status,200);assert.match(await response.text(),/id="town"/); + assert.equal((await fetch(url+'/modules/command-model.mjs')).status,200); + for(const route of ['/?scenario=1','/modules/audio/../../package.json','/modules/.env','/v1/tasks'])assert.equal((await fetch(url+route)).status,400,route); +}); + +test('encoded-colon child IDs preserve the advertised route for activity and explicit messages',async t=>{ + const {url,upstreamUrl,seen}=await fixture(t),route='/agents/session%3Aroot1234'; + const activity=await fetch(url+route+'/activity?before=cursor&limit=10'); + assert.equal(activity.status,200);assert.deepEqual(seen.map(row=>row.url),[route+'/activity?before=cursor&limit=10']); + const body=JSON.stringify({taskId:'session:root1234',message:'Stub only',requestId:'stub-child-1234'}); + const response=await fetch(url+route+'/messages',{method:'POST',headers:headers(url),body}); + assert.equal(response.status,200);assert.deepEqual(seen.map(row=>row.url),[route+'/activity?before=cursor&limit=10',route+'/messages']); + assert.equal(seen[1].method,'POST');assert.equal(seen[1].origin,upstreamUrl); + assert.equal(seen[1].request,'user-message');assert.equal(seen[1].body,body); +}); + +test('encoded child route separators, traversal, double encoding and malformed escapes never reach upstream',async t=>{ + const {url,seen}=await fixture(t); + for(const suffix of ['a%2Fb','a%5Cb','a%252Fb','%2e','%2e%2e','bad%']) + assert.equal((await fetch(url+`/agents/${suffix}/activity`)).status,400,suffix); + assert.equal(seen.length,0); +});