diff --git a/AGENTS.md b/AGENTS.md index dc105cb..9d3fdb9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,9 +32,11 @@ duck. Check direct inspector controls, the connect box, and stale-feed recovery. | `src/observatory.mjs` | Walking, interiors, inspector, journals, filters, wildlife interactions | | `src/interiors.mjs` / `src/world.mjs` | Seeded rooms/residents, walkability, stable plots, explicit paths/handoffs | | `src/feed-client.mjs` | Browser feed normalization and incremental activity merging | +| `src/interaction.mjs` / `src/conversation.mjs` | Door crossings and browser message capability checks | | `src/feed.mjs` | HTTP server, snapshot cache, adapter integration, activity routes | | `src/transcripts.mjs` / `src/activity.mjs` | Incremental transcript parsing and public activity; optional Hub timeline | | `src/hub.mjs` | Optional readonly sqlite `agent_runs` adapter | +| `src/todos.mjs` / `src/messages.mjs` | Explicit checklist snapshots and user-submitted AutoHub messages | | `src/pr.mjs` / `src/github.mjs` | Shared PR classifier/counts and cached read-only GitHub observations | | `src/occupancy.mjs` | live / recent / settled, letters, conservative PR identity inference | | `src/history.mjs` / `src/sound.mjs` | Bounded browser-local milestones/replay and opt-in Web Audio | @@ -47,12 +49,18 @@ Keep task identity and task start separate from session identity and session start. Missing data stays unavailable. Preserve genuine requests separately from assistant updates; never export raw private thinking blocks. A missing PR is unknown, not confirmed none. Shared PRs count once, and stale or conflicting -evidence cannot show ready. External integrations remain read-only. +evidence cannot show ready. GitHub and transcript adapters remain read-only. +Task writes require an explicit user Send through a supported messaging route; +never send autonomous test messages to real agents. Recheck identity, status, +and transport before sending. Preserve receipt deduplication across restarts; +uncertain delivery must not be retried automatically. Todo lists come only from +structured snapshots, never prose inference. Seed interiors from stable cottage/task identity. Feed updates must preserve visited rooms and cottage positions. Only explicit relationships and recorded handoffs create paths and couriers. Sound starts off. Scrapbook replay is limited -to locally observed history. +to locally observed history. Walk into doors to enter/leave; E talks to a nearby +resident or inspects an object. Direct buttons and Escape remain available. Naming lock (also in `.cursor/rules/naming.mdc`): diff --git a/README.md b/README.md index 9b2d757..99e8689 100644 --- a/README.md +++ b/README.md @@ -35,16 +35,20 @@ Click the map to give it keyboard focus. | Control | Action | |---|---| | Arrow keys or WASD | Walk around town or inside a cottage | -| E or Enter | Interact with a nearby door, bench, noticeboard, or room object | +| Walk into a doorway | Enter the cottage or walk back outside; no interaction key needed | +| E or Enter | Talk to a nearby agent, or use a bench, noticeboard, or room object | | Escape | Leave the cottage or bench while the map has focus | | Click a cottage or its roster button | Open its inspector without walking there | | Enter cottage / Leave cottage | Visit or exit using buttons | -| Click a room object or inspector tab | Read the request, clock, journal, PR desk, or shelves | +| Click a resident / Talk to agent | Open their conversation and recorded activity | +| Click a room object or inspector tab | Read the request, clock, journal, to-do list, PR desk, or shelves | | Click a parcel | Open its PR when a URL is available | Rooms and residents are generated from cottage and task identity. Return visits keep the furniture in place. A new explicit task gets a new home. HubTown sets the cozy base with switchboards and pigeonholes. AppTown fills its desks with computers, phones and charging leads. MemTown lines its walls with bookshelves. VaultTown is a clock-and-lock repair shop, with pendulums, key racks and scattered repair tools. Unknown projects get neutral homes. -Walk near a duck and it'll head for the pond. Cats and a goose roam too. **Sound starts off.** Enable it for footsteps, doors, nearby agent bleeps, and status alerts, with separate ambience and alert switches. Reduced-motion preferences are respected. +Walk near a duck and it'll head for the pond. Cats and a goose roam too. **Sound starts off.** Enable it for footsteps, doors, a little wordless murmur when you talk to a resident, and status alerts, with separate ambience and alert switches. Reduced-motion preferences are respected. + +The PR desk has a changing status light, icon, and label. The **To-do** tab displays explicit task checklists when available; it never guesses a plan from progress prose. **Talk to agent** shows emitted updates alongside a message composer. Drafts and journal position survive feed refreshes. Messages require a supported connection and an explicit **Send**; unsupported sessions explain why sending is unavailable. Use **Follow from bench** to keep one cottage selected as its work updates. The noticeboard lists changes observed since the previous visit. The scrapbook stores up to 1,600 milestones per feed in this browser, including result snippets and artifact links. Replay steps through that recorded history; it cannot reconstruct unobserved work or expired source logs. @@ -80,7 +84,7 @@ Optional fields add task identity, the original request, timestamps, activity, w ![Jack's letter pile listing blocked cottages](docs/img/letters-queue.png) -**Pause feed** freezes browser refreshes and the demo simulator. It does not pause real agents. CottageCode never starts tasks, edits PRs, or merges them. +**Pause feed** freezes browser refreshes and the demo simulator. It does not pause real agents. PR operations stay in the existing workflow. The only task write is a message you explicitly send through a supported connection. ## 🧰 Bundled adapters @@ -94,6 +98,8 @@ The bundled empty feed opens the demo until local cottages appear. `/?demo=1` se Activity comes from local transcripts first. Set `COTTAGE_HUB_URL` to read AutoHub's task timeline when local activity is unavailable, and `COTTAGE_HUB_TOKEN` if that server requires a bearer token. The token stays in the Node process. Journals expose emitted progress summaries, tool labels, and result previews. Raw private thinking blocks are excluded. +With `AGENT_DB_PATH` and `COTTAGE_HUB_URL` configured, supported AutoHub tasks also accept typed guidance while running in tmux, or replies while waiting for input. Each send rechecks the actual task first. Direct sessions do not accept mid-task redirection; transcript-only cottages and completed tasks have no message route. AutoHub may resume an existing waiting task after your reply. Delivery receipts distinguish submission from an agent answer; uncertain sends are never automatically retried. The local receipt ledger stores request hashes and outcomes, without message text or credentials. + GitHub observations refresh in the background every 30 seconds, with bounded concurrency and backoff on failure. PR identity comes from an explicit link or an unambiguous repository and non-default branch. Missing access stays unknown. Set `COTTAGE_GITHUB=0` to disable GitHub queries. ```bash @@ -128,7 +134,7 @@ Feed failures retain the last snapshot and mark it stale. Reconnecting restores | Merged / Closed | Completed merge or closed without merging | | Unknown | Missing, conflicting, or unverified state | -PR filters count shared pull requests once. The review desk shows the source, verification time, head, labels, and any uncertainty. Stale data or a known head change cannot declare a PR ready. +PR filters count shared pull requests once. The review desk shows the source, verification time, head, labels, and any uncertainty. Its lamp and symbol follow the same evidence as the outdoor parcel. Stale data or a known head change cannot declare a PR ready. Settled cottages stay hidden until **settled (N)** is enabled. Once observed, cottages with outstanding PRs stay on the map after execution finishes. diff --git a/docs/COTTAGE_FEED.md b/docs/COTTAGE_FEED.md index 4b26c15..94d0e1f 100644 --- a/docs/COTTAGE_FEED.md +++ b/docs/COTTAGE_FEED.md @@ -31,7 +31,7 @@ An empty custom feed stays empty. The bundled same-origin `/agents` endpoint has The browser polls every 1.5 seconds. HTTP failures keep the last snapshot from the same endpoint, marked stale; they never substitute another feed. The Node adapters refresh every 2 seconds. GitHub enrichment runs on its own slower cache. -CORS: the browser fetches the URL entered in the connect box. Serve `Access-Control-Allow-Origin: *` (or the CottageCode origin) for feeds and activity endpoints on another host. The bundled server accepts read-only requests and defaults to `127.0.0.1:8787`. +CORS: the browser fetches the URL entered in the connect box. Serve `Access-Control-Allow-Origin: *` (or the CottageCode origin) for feeds and activity endpoints on another host. The bundled server defaults to `127.0.0.1:8787` and permits its `/agents` and activity routes only to a Townmap loaded from that same origin; it does not provide CORS access to its local agent data. To use a separately hosted Townmap, point it at a separate feed/activity service that allows the Townmap origin, or host that service and the Townmap together. Message writes require this server's own origin, JSON, and the explicit message header described below. ## Cottage object @@ -67,6 +67,8 @@ Full shape (everything else is optional): | `activity` | string | Short live line under the status chip. | | `activityUrl` | string | Absolute or relative HTTP(S) activity endpoint, resolved against the feed URL. | | `events` | array | Optional inline activity events. When supplied, including `[]`, this takes precedence over `activityUrl`. | +| `todos` | object \| null | Latest explicit checklist snapshot; null is unavailable, an empty `items` array is known empty. | +| `conversation` | object | Explicit message capability, route, freshness, or an unavailable reason. | | `activitySource` | string | Source label for inline events. Falls back to the cottage's `source`, then the feed/demo label. | | `source` | string | Cottage adapter label, such as `claude` or `hub`. | | `worktree` | string | Short worktree / checkout label. | @@ -147,6 +149,62 @@ The bundled activity cache retains up to 2,000 events per task, with text previe Send progress or reasoning **summaries that the agent emitted**, tool actions, and results. The bundled adapters exclude raw `thinking` and `redacted_thinking` blocks. Tool labels use the tool name and a short description/path; they do not dump entire argument objects. Public text and result previews still contain source content. +### To-do lists + +Supply a full snapshot in cottage `todos`, or as `todos` on an activity response: + +```json +{ + "items": [ + { "id": "inspect", "text": "Inspect the current reconnect behavior", "status": "completed" }, + { "id": "verify", "text": "Verify reconnect and failure recovery", "status": "in_progress" } + ], + "source": "transcript:TodoWrite", + "updatedAt": 1789387200000, + "stale": false +} +``` + +Statuses are `pending`, `in_progress`, `completed`, or `cancelled`. Stable item IDs are preferred; missing IDs are derived from item text. A snapshot replaces the previous list, including explicit `{ "items": [] }`. Null means the source cannot provide a list. The browser displays the newest dated snapshot from the cottage and activity response. Missing dates remain unavailable. Lists are limited to 100 entries, with 500-character text previews and an optional `truncated` marker. + +The bundled parser accepts structured `TodoWrite` and `update_plan` inputs, Codex `todo_list` records, and explicitly supplied plan arrays. AutoHub supplies saved `todo` checkpoints, structured timeline records, or `GET /v1/tasks/:id/todo`. Flattened timeline descriptions and assistant prose are never reconstructed into checklists. `/activity.todos` describes the latest retained snapshot independently of forward or older-event pagination. + +### Conversations + +E near a resident, clicking the resident, or the **Talk to agent** button opens their recorded activity and message composer. The greeting is locally synthesized nonspeech, enabled only with sound. No response or reasoning is fabricated. Drafts are kept in page memory per feed/cottage/task and preserved during refreshes; they are not saved across page reloads. + +Observing an agent does not imply permission or capability to steer it. A message-capable feed explicitly supplies: + +```json +{ + "available": true, + "mode": "redirect", + "messageUrl": "/agents/bolt-1/messages", + "source": "autohub", + "checkedAt": 1789387200000 +} +``` + +`mode` is `redirect` for guidance to a running agent, or `respond` for a task waiting for input. `taskId` is required. Capability checks expire after two minutes; stale feeds disable sending. `messageUrl` resolves against the connected feed URL and must share its origin. Unavailable connections supply `{ "available": false, "reason": "…" }`. Legacy feeds need no changes to remain useful. + +Only an explicit form submission sends: + +```text +POST /agents/:id/messages +Content-Type: application/json +X-CottageCode-Request: user-message + +{"taskId":"task-42","message":"Please explain the API tradeoffs.","requestId":"unique-request-id"} +``` + +Messages contain 1–8,000 characters. Request IDs contain 8–100 letters, digits, hyphens, or underscores. The browser uses a fresh UUID per message. A successful response is `{ "ok": true, "delivery": "submitted" | "accepted", "requestId": "…" }`; submission to a terminal is distinct from an agent answering. Responses stream through the existing activity source when available. Errors may return `delivery: "not_sent"` for a definite rejection or `"unconfirmed"` when a write may have happened. Neither the browser nor bundled service retries uncertain delivery automatically. + +The bundled server derives targets from its own Hub database snapshot, then verifies `GET /v1/tasks/:id` immediately before sending. It uses `/redirect` only for known logical tasks running in a supported tmux transport, and `/respond` only for logical tasks waiting for input. AutoHub retains its authorization checks. The interface does not redirect direct sessions, dispatch new tasks, or resume completed work. A reply may resume the existing waiting task through AutoHub's normal response handler. + +Bundled message writes require a loopback socket connection, a matching browser Origin and an IP-address or `localhost` host, plus the JSON/header contract above. Binding the viewer to `0.0.0.0` does not grant remote clients messaging authority. Cross-origin viewing remains supported; sending to the bundled adapter requires opening CottageCode locally at that adapter's origin. Custom remote message routes must provide their own authentication, authorization, idempotency, and CORS policy; the browser sends no cookies or credentials to them. The bundled bearer token stays server-side. + +The service appends a durable request reservation before contacting AutoHub. Its local ledger stores a request ID, SHA-256 target/message hash, timestamp, and outcome, without message text or credentials. Reusing a request ID returns the recorded outcome; a reservation with no final outcome is unconfirmed and cannot repeat the write, including after restart. An unreadable ledger fails before sending. The default ledger is private to this local service; do not share it among concurrent service processes. + ### Pull requests Old `{ "number", "url", "title", "state" }` objects continue to work. Omitting `pr` means **unknown**. Explicit `{ "state": "none" }` means a confirmed absence. A PR mentioned in prose provides a possible identity only; even the word "merged" does not establish its state. @@ -227,6 +285,8 @@ Known themes map HubTown to `hub`, AppTown to `app`, MemTown to `memory`, and Va Themes decorate the whole room: AppTown has device racks, computers and phones; MemTown fills available wall spans with bookshelves; VaultTown has a collection of clocks, locks and repair tools. These decorations keep the authored walking routes and operational object positions intact. +Walking across a door threshold enters or exits automatically. E talks to a nearby host or inspects an object. The review desk's light, symbol, and label update from the same conservative PR classifier as the outdoor dispatch stand; stale readiness never receives a gold light. These activity changes never reseed the room. + The noticeboard and scrapbook use browser `localStorage`, separated by feed endpoint and demo mode. They retain the latest 1,600 milestones and bounded last-observed task/PR states. A return visit compares the new snapshot to saved observations and timestamps changes when they are observed. First observations do not invent past task completions or review transitions. If storage is unavailable, recording continues in memory for that page. Replay steps through recorded milestones and highlights their cottages. It does not reconstruct activity from before observation began, beyond retained history, or while the page was absent. Original source timestamps remain available in the activity journal when the source supplies them. @@ -239,8 +299,9 @@ Replay steps through recorded milestones and highlights their cottages. It does | `AGENT_DB_PATH` | Unset | Optional readonly SQLite database containing `agent_runs`. | | `AGENT_STALE_THRESHOLD_MS` | `900000` | Hub running-task inactivity threshold, in milliseconds. | | `COTTAGE_GITHUB` | Enabled | Set to `0` to disable GitHub enrichment. | -| `COTTAGE_HUB_URL` | Unset | AutoHub API base URL, optionally ending in `/v1`. Used when a Hub cottage has no local activity. | -| `COTTAGE_HUB_TOKEN` | Unset | Optional server-side bearer token for the Hub timeline. Never returned to the browser. | +| `COTTAGE_HUB_URL` | Unset | AutoHub API base URL, optionally ending in `/v1`. Reads timelines/checklists and enables supported user-submitted task messages. | +| `COTTAGE_HUB_TOKEN` | Unset | Optional server-side bearer token for Hub requests. Never returned to the browser. | +| `COTTAGE_MESSAGE_LEDGER` | `~/.cottagecode/message-receipts.jsonl` | Private local request hashes and delivery outcomes, used to prevent duplicate writes. | GitHub enrichment uses the authenticated local `gh` CLI with read-only `pr view`, `pr list`, and `repo view` calls. It accepts an explicit GitHub.com PR link or exact repository/number. Branch discovery requires an exact repository and a non-default branch with one unambiguous same-repository PR match. The local adapter can resolve `owner/repo` from a worktree's GitHub `origin`; town names never supply repository identity. diff --git a/docs/superpowers/specs/2026-09-14-playable-observatory-design.md b/docs/superpowers/specs/2026-09-14-playable-observatory-design.md index 43459dc..24fd128 100644 --- a/docs/superpowers/specs/2026-09-14-playable-observatory-design.md +++ b/docs/superpowers/specs/2026-09-14-playable-observatory-design.md @@ -6,7 +6,7 @@ Approved by Jack on 2026-09-14. CottageCode remains a zero-dependency Node viewe The map exposes PR stages independently of agent activity: no PR, opened, babysit active, waiting for Codex, waiting for CI, blocked, ready to merge, merged, closed, and unknown. Dispatch stands and deduplicated counts share one classifier. Open PRs remain visible when an agent finishes. Live labels and structured receipts provide evidence; stale/conflicting evidence cannot declare readiness. This viewer never merges or controls agents. -Arrow keys/WASD move Jack when the map is focused. E interacts and enters; Escape exits. Clicking and accessible controls provide the same diagnostics. Roofs lift and the camera transitions to cutaway rooms; leaving restores the doorway. Polls preserve cottage positions and interior identity. +Arrow keys/WASD move Jack when the map is focused. Walking into doors enters and exits; E talks to nearby agents or inspects objects, and Escape also exits. Clicking and accessible controls provide the same diagnostics. Roofs lift and the camera transitions to cutaway rooms; leaving restores the doorway. Polls preserve cottage positions and interior identity. These door controls follow the user's September 15 refinement. Every room includes the original request, separate task/session clocks, a live activity workbench, PR review desk, and artifact shelves. Display actual progress summaries, tool activity, and outcomes. Missing data is explicit. Outside bubbles use the same activity. diff --git a/scripts/browser-smoke.mjs b/scripts/browser-smoke.mjs index 538e322..91891f1 100644 --- a/scripts/browser-smoke.mjs +++ b/scripts/browser-smoke.mjs @@ -11,13 +11,15 @@ import {pageActivity} from '../src/activity.mjs'; const run=promisify(execFile),page='cottagecode-browser-smoke'; const stamp=Date.now(),pr={number:123,url:'https://github.com/example/observatory/pull/123',state:'open',labels:['babysit:ready'],headSha:'head-one',source:'browser-fixture',checkedAt:stamp}; let agents=[ - {id:'host',taskId:'task-one',name:'Hazel',town:'HubTown',status:'working',task:'Verify the observatory',originalAsk:'Keep the original request pinned while progress arrives.',taskStartedAt:stamp-130000,sessionStartedAt:stamp-600000,updatedAt:stamp,activityUrl:'/agents/host/activity',pr}, + {id:'host',taskId:'task-one',name:'Hazel',town:'HubTown',status:'working',task:'Verify the observatory',originalAsk:'Keep the original request pinned while progress arrives.',taskStartedAt:stamp-130000,sessionStartedAt:stamp-600000,updatedAt:stamp,activityUrl:'/agents/host/activity',pr,todos:{source:'browser-fixture',updatedAt:stamp,items:[{id:'one',text:'Keep the request visible',status:'completed'},{id:'two',text:'Verify doors and conversations',status:'in_progress'}]}}, {id:'neighbor',taskId:'task-neighbor',name:'Fern',town:'AppTown',status:'done',task:'Shared PR companion',pr:{...pr},endedAt:stamp-60000}, ]; let events=Array.from({length:130},(_,i)=>({id:'event-'+i,timestamp:stamp-130000+i*1000,kind:i%3?'progress':'tool',text:'Recorded observation '+i+' with enough detail to make this journal scroll.'})); let handoffs=[],stale=false,fail=false; const feed={snapshot:()=>{if(fail)throw new Error('Simulated source outage');return {source:'browser-fixture',agents,stale,relationships:[{from:'HubTown',to:'AppTown',label:'Explicit test contract'}],handoffs};},getActivity:async(id,options)=>pageActivity(events,{...options,source:'browser-fixture'})}; -const app=createFeedServer(feed),html=await readFile(new URL('../src/town.html',import.meta.url),'utf8'); +const sent=[];let unconfirmed=false; +const messages={capability:(agent,{stale})=>agent.id==='host'&&!stale?{available:true,mode:'redirect',source:'browser-fixture',messageUrl:'/agents/host/messages',checkedAt:Date.now()}:{available:false,reason:'This fixture only observes the other cottages.'},send:async(agent,payload)=>{sent.push({agentId:agent.id,...payload});return unconfirmed?{status:409,body:{ok:false,delivery:'unconfirmed'}}:{status:200,body:{ok:true,delivery:'submitted',requestId:payload.requestId}};}}; +const app=createFeedServer(feed,{messages}),html=await readFile(new URL('../src/town.html',import.meta.url),'utf8'); // Emulate the browser preference before scene modules load, without changing macOS settings. const preference=''; const server=createServer((req,res)=>{if(req.url==='/reduced-motion'){res.setHeader('content-type','text/html');res.end(html.replace('',''+preference));}else app.emit('request',req,res);}); @@ -65,15 +67,57 @@ try{ assert.match(await evaluate('document.querySelector(\'.pr-summary\').textContent'),/Ready to merge/); assert.match(await evaluate('document.querySelector(\'.pr-summary a\').href'),/pull\/123$/); await key('Escape');assert.equal((await state()).mode,'town'); - await key('e');assert.equal((await state()).mode,'room'); + await key('e');assert.equal((await state()).mode,'town','E must not operate doors'); + await key('ArrowUp',250);assert.equal((await state()).mode,'room'); assert.equal((await state()).roomSeed,first.roomSeed); + await key('ArrowDown',300);assert.equal((await state()).mode,'town','Walking into the interior doorway exits'); + await key('ArrowUp',250);assert.equal((await state()).mode,'room','Returning to the doorway enters without E'); + await click('[data-action="tab:todos"]'); + assert.match(await evaluate('document.querySelector(\'.todo-list\').textContent'),/Verify doors and conversations/); + // Walk through the actual room collision system to the host, then use E. + await evaluate(`(async()=>{ + const {createInterior,isWalkable}=await import('/modules/interiors.mjs'),s=window.cottageState(),room=createInterior(s.agents.find(a=>a.id===s.scene.interiorId)); + const start=s.scene.roomPlayer,step=4,queue=[{x:start.x,y:start.y,key:'0,0'}],parents=new Map([['0,0',null]]);let goal=null; + for(let i=0;i154||!isWalkable(room,n.x,n.y))continue;parents.set(key,{key:p.key,point:n});queue.push(n);} + } + if(!goal)throw new Error('Resident cannot be reached');const path=[];while(parents.get(goal)){const entry=parents.get(goal);path.unshift(entry.point);goal=entry.key;} + const canvas=document.getElementById('room-canvas');canvas.focus(); + for(const target of path){let p=window.cottageObservatory.state.roomPlayer;const horizontal=Math.abs(target.x-p.x)>Math.abs(target.y-p.y),axis=horizontal?'x':'y',sign=Math.sign(target[axis]-p[axis]);if(Math.abs(target[axis]-p[axis])<1)continue;const key=horizontal?(sign>0?'ArrowRight':'ArrowLeft'):(sign>0?'ArrowDown':'ArrowUp');canvas.dispatchEvent(new KeyboardEvent('keydown',{key,bubbles:true}));const end=performance.now()+800;while(sign*(target[axis]-window.cottageObservatory.state.roomPlayer[axis])>0&&performance.now()=30)throw new Error('Did not reach host'); + })()`); + await key('e');assert.equal((await state()).tab,'talk');assert.equal((await state()).talking,true);assert.equal((await state()).sound,false); + await browser('snapshot'); + await browser('fill',['--fields',JSON.stringify({'#agent-message':'Please keep the cottage furniture stable.'})]); + await evaluate('const input=document.getElementById(\'agent-message\');input.focus();input.setSelectionRange(7,11);'); + events.push({id:'while-composing',timestamp:Date.now(),kind:'progress',text:'Fixture update during a composed message.'}); + await until('!!document.querySelector(\'[data-event="while-composing"]\')','Conversation did not stream'); + assert.equal(await evaluate('document.getElementById(\'agent-message\').value'),'Please keep the cottage furniture stable.'); + assert.deepEqual(await evaluate('[document.activeElement.id,document.activeElement.selectionStart,document.activeElement.selectionEnd]'),['agent-message',7,11]); + const standing=(await state()).roomPlayer;await evaluate('document.getElementById(\'agent-message\').dispatchEvent(new KeyboardEvent(\'keydown\',{key:\'w\',bubbles:true}))');assert.deepEqual((await state()).roomPlayer,standing); + assert.equal(sent.length,0,'Drafting must never send automatically'); + await click('[data-action="send-message"]');await until('document.getElementById(\'message-status\').textContent.includes(\'Submitted\')','Message receipt did not appear'); + assert.equal(sent.length,1);assert.equal(sent[0].message,'Please keep the cottage furniture stable.');assert.equal(sent[0].taskId,'task-one'); + assert.equal(await evaluate('document.getElementById(\'agent-message\').value'),''); + await click('#sound-toggle');await key('e');assert.equal((await state()).talking,true);await click('#sound-toggle'); + unconfirmed=true; + await browser('fill',['--fields',JSON.stringify({'#agent-message':'A fixture message with uncertain delivery.'})]); + await click('[data-action="send-message"]');await until('document.getElementById(\'message-status\').textContent.includes(\'unconfirmed\')','Uncertain delivery did not remain explicit'); + assert.equal(sent.length,2);assert.equal(await evaluate('document.querySelector(\'[data-action="send-message"]\').disabled'),true); + await evaluate('document.getElementById(\'agent-conversation\').dispatchEvent(new Event(\'submit\',{bubbles:true,cancelable:true}))');assert.equal(sent.length,2,'An uncertain message must not be resubmitted');unconfirmed=false; + agents[0].todos={source:'browser-fixture',updatedAt:Date.now(),items:[]}; + await click('[data-action="tab:todos"]');await until('document.getElementById(\'panel\').textContent.includes(\'list is empty\')','Explicit empty checklist did not clear the old list'); + agents[0].todos=null;await until('document.getElementById(\'panel\').textContent.includes(\'not supplied a to-do list\')','Unknown checklist appeared empty'); + pass('automatic doors, E conversations, opt-in murmur, real checklist, preserved drafts and explicit fixture delivery'); const plotBefore=await evaluate('window.cottageState().plots.find(p=>p.id===\'host\')'); agents.push({...agents[0],id:'apprentice',name:'Pip',parent:'host',taskId:'apprentice-one'}); handoffs=[{id:'handoff-one',from:'HubTown',to:'AppTown',agentId:'host',timestamp:Date.now(),text:'Recorded contract handed to Fern.'}]; await until('window.cottageObservatory.state.apprentices===1&&window.cottageObservatory.state.couriers===1','Arrival or courier did not appear'); const plotAfter=await evaluate('window.cottageState().plots.find(p=>p.id===\'host\')'); assert.equal(plotAfter.x,plotBefore.x);assert.equal(plotAfter.y,plotBefore.y);assert.equal((await state()).roomSeed,first.roomSeed); - pass('keyboard exit/reentry, stable room and plot, apprentice arrival and recorded courier'); + pass('stable room and plot, apprentice arrival and recorded courier'); stale=true; await until('window.cottageObservatory.state.feedStale&&!document.querySelector(\'[data-pr="ready"]\')','Stale feed still advertised readiness'); stale=false;await until('!!document.querySelector(\'[data-pr="ready"]\')','Fresh feed did not recover readiness'); diff --git a/src/activity.mjs b/src/activity.mjs index 85ffc23..24a3a5c 100644 --- a/src/activity.mjs +++ b/src/activity.mjs @@ -1,10 +1,12 @@ /** Public, bounded activity records. Private transcript thinking is never exported. */ +import { normalizeTodos, todosFromEvent, latestTodos } from "./todos.mjs"; const MAX_EVENTS = 2000; const KINDS = new Map([ ["request", "request"], ["user_message", "request"], ["progress", "progress"], ["assistant_message", "progress"], ["summary", "summary"], ["reasoning_summary", "summary"], ["plan", "summary"], ["question", "status"], + ["todo_list", "summary"], ["todos", "summary"], ["plan_update", "summary"], ["tool", "tool"], ["tool_call", "tool"], ["tool_use", "tool"], ["result", "result"], ["tool_result", "result"], ["status", "status"], ["attempt_started", "status"], ["attempt_completed", "status"], @@ -39,7 +41,8 @@ export function normalizeActivityEvent(value) { const body = value.text ?? value.detail ?? value.output_preview ?? ""; const text = (typeof body === "string" ? body : "").trim(); const title = typeof value.title === "string" ? value.title.trim() : ""; - const label = text || title; + const todos = todosFromEvent(value); + const label = text || title || (todos ? "Task checklist updated" : ""); if (!label) return null; const event = { id: value.id.slice(0, 400), @@ -47,6 +50,7 @@ export function normalizeActivityEvent(value) { kind, text: label.slice(0, 1200), }; + if (todos) event.todos = todos; const url = safeActivityUrl(value.url); if (url) event.url = url; if (kind === "handoff") { @@ -111,12 +115,49 @@ export function createHubTimelineReader({ } = {}) { const cache = new Map(); const inFlight = new Map(); + const todoCache = new Map(); + const todoInFlight = new Map(); let base = null; try { const candidate = new URL(baseUrl); if (["http:", "https:"].includes(candidate.protocol) && !candidate.username && !candidate.password) base = candidate; } catch { /* Unconfigured source stays explicitly unavailable. */ } + function endpoint(taskId, suffix) { + const url = new URL(base.href); + const root = url.pathname.replace(/\/$/, "").replace(/\/v1$/, ""); + url.pathname = `${root}/v1/tasks/${encodeURIComponent(taskId)}/${suffix}`; + url.search = ""; + return url; + } + function requestOptions() { + return { + headers: { accept: "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}) }, + signal: AbortSignal.timeout(4000), redirect: "error", + }; + } + + async function readTodos(taskId) { + if (!base || typeof taskId !== "string" || !taskId.trim() || taskId.length > 400 || + [".", ".."].includes(taskId) || /[\u0000-\u001f\u007f]/.test(taskId)) return null; + const old = todoCache.get(taskId); + if (old && now() - old.checkedAt < ttl) return old.todos; + if (!todoInFlight.has(taskId)) todoInFlight.set(taskId, (async () => { + let todos = old?.todos || null; + try { + const response = await fetchFn(endpoint(taskId, "todo"), requestOptions()); + if (!response.ok) throw new Error("todo_unavailable"); + const body = await response.json(); + const incoming = normalizeTodos(body.todos ?? body.list, { source: "hub:todo", updatedAt: body.createdAt }); + todos = incoming || (todos ? { ...todos, stale: true } : null); + } catch { if (todos) todos = { ...todos, stale: true }; } + todoCache.set(taskId, { todos, checkedAt: now() }); + if (todoCache.size > 200) todoCache.delete(todoCache.keys().next().value); + return todos; + })().finally(() => todoInFlight.delete(taskId))); + return todoInFlight.get(taskId); + } + async function refresh(taskId, options) { const old = cache.get(taskId); if (old && !options.before && now() - old.checkedAt < ttl) return old; @@ -124,17 +165,10 @@ export function createHubTimelineReader({ let before = options.before || ""; try { for (let page = 0; page < 4; page++) { - const url = new URL(base.href); - const root = url.pathname.replace(/\/$/, "").replace(/\/v1$/, ""); - url.pathname = `${root}/v1/tasks/${encodeURIComponent(taskId)}/timeline`; - url.search = ""; + const url = endpoint(taskId, "timeline"); url.searchParams.set("limit", "500"); if (before) url.searchParams.set("before", before); - const response = await fetchFn(url, { - headers: { accept: "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}) }, - signal: AbortSignal.timeout(4000), - redirect: "error", - }); + const response = await fetchFn(url, requestOptions()); if (!response.ok) throw new Error(`Timeline unavailable (HTTP ${response.status})`); const body = await response.json(); if (!Array.isArray(body.events)) throw new Error("Timeline returned an invalid event list"); @@ -164,14 +198,22 @@ export function createHubTimelineReader({ return { configured: Boolean(base), + readTodos, async read(taskId, options = {}) { - if (!base || !taskId) return { ...pageActivity([], options), unavailable: true }; + if (!base || !taskId) return { ...pageActivity([], options), todos: null, unavailable: true }; const key = `${taskId}\n${options.before || ""}`; if (!inFlight.has(key)) inFlight.set(key, refresh(taskId, options).finally(() => inFlight.delete(key))); - const entry = await inFlight.get(key); + const [entry, checkpointTodos] = await Promise.all([inFlight.get(key), readTodos(taskId)]); const result = pageActivity(entry.events, { ...options, source: entry.source }); if (!options.after && entry.hasOlder) result.hasMore = true; - return { ...result, checkedAt: entry.checkedAt, ...(entry.stale ? { stale: true, error: entry.error } : {}) }; + let todos = latestTodos(entry.events, { source: entry.source }); + if (todos && entry.stale) todos = { ...todos, stale: true }; + const checkpointWins = checkpointTodos && (!todos || + (checkpointTodos.updatedAt !== null + ? todos.updatedAt === null || checkpointTodos.updatedAt >= todos.updatedAt + : todos.updatedAt === null)); + if (checkpointWins) todos = checkpointTodos; + return { ...result, todos, checkedAt: entry.checkedAt, ...(entry.stale ? { stale: true, error: entry.error } : {}) }; }, }; } diff --git a/src/conversation.mjs b/src/conversation.mjs new file mode 100644 index 0000000..90b53a3 --- /dev/null +++ b/src/conversation.mjs @@ -0,0 +1,18 @@ +/** Browser-safe capability validation. A connected feed explicitly opts in to + * messages on its own origin; observing a transcript never implies control. */ +export const MAX_MESSAGE_LENGTH = 8000; +export function conversationCapability(agent, endpoint, {stale = false, now = Date.now()} = {}) { + const capability = agent?.conversation; + const unavailable = reason => ({available:false,reason}); + if (stale) return unavailable('The feed is stale. Reconnect before sending a message.'); + if (!capability?.available) return unavailable(capability?.reason || 'This source provides activity only. It has no connected route for messages.'); + if (typeof capability.messageUrl !== 'string' || !capability.messageUrl.trim()) return unavailable('The feed has not supplied a message route.'); + if (!agent.taskId || !['redirect','respond'].includes(capability.mode)) return unavailable('The feed has not identified a supported task conversation.'); + const checkedAt = typeof capability.checkedAt === 'number' ? capability.checkedAt : Date.parse(capability.checkedAt); + if (!Number.isFinite(checkedAt) || now - checkedAt > 120000 || checkedAt > now + 30000) return unavailable('Message availability needs a fresh check from the task source.'); + try { + const base = new URL(endpoint),url = new URL(capability.messageUrl,base); + if (!['http:','https:'].includes(url.protocol) || url.origin !== base.origin || url.username || url.password) return unavailable('The message route must belong to the connected feed.'); + return {...capability,available:true,url:url.href}; + } catch { return unavailable('The feed has not supplied a valid message route.'); } +} diff --git a/src/feed.mjs b/src/feed.mjs index ca61ec9..fc0d36c 100644 --- a/src/feed.mjs +++ b/src/feed.mjs @@ -14,6 +14,8 @@ import { createTranscriptReader } from "./transcripts.mjs"; import { createHubTimelineReader, pageActivity, mergeActivityEvents } from "./activity.mjs"; import { enrichAgents } from "./github.mjs"; import { hasOutstandingPr } from "./pr.mjs"; +import { normalizeTodos } from "./todos.mjs"; +import { createHubMessenger, acceptsMessageOrigin } from "./messages.mjs"; const HERE = dirname(fileURLToPath(import.meta.url)); const PROJECTS = process.env.CLAUDE_PROJECTS_DIR || join(homedir(), ".claude", "projects"); @@ -66,6 +68,7 @@ export function toAgents(sessions, { now = Date.now(), windowMs = 12 * 3600e3 } taskStartedAt: session.taskStartedAt, sessionStartedAt: session.firstTs, activityUrl: `/agents/${encodeURIComponent(session.id)}/activity`, + todos: normalizeTodos(session.todos), worktree, worktreePath: session.cwd || "", result: !session.turnOpen ? session.lastText : "", pr: transcriptPr(session), @@ -97,6 +100,7 @@ export function toAgents(sessions, { now = Date.now(), windowMs = 12 * 3600e3 } originalAskTruncated: child.originalAskTruncated, taskStartedAt: child.taskStartedAt, sessionStartedAt: child.startTs, activityUrl: `/agents/${encodeURIComponent(id)}/activity`, + todos: normalizeTodos(child.todos), activity: child.lastTool || child.lastText || "Working", model: shortModel(child.model || session.model), result: !child.open ? child.lastText : "", @@ -196,6 +200,15 @@ function sortCottages(list) { }); } +/** Prefer evidence with a comparable newer clock; an undated snapshot cannot regress a dated one. */ +function newestTodos(current, incoming) { + if (!incoming) return current || null; + if (!current) return incoming; + if (current.updatedAt !== null && incoming.updatedAt === null) return current; + if (incoming.updatedAt !== null && (current.updatedAt === null || incoming.updatedAt >= current.updatedAt)) return incoming; + return current; +} + export function createFeed({ scanClaude = createClaudeScanner(), readHub = readHubAgents, enrich = enrichAgents, resolveRepos = createRepoResolver(), timeline = createHubTimelineReader(), now = Date.now, @@ -204,6 +217,7 @@ export function createFeed({ let claude = { agents: [], sessions: [] }; let hub = { agents: [], keys: new Set(), links: new Map() }; let activity = new Map(); + const remoteTodos = new Map(); let lastSuccessAt = null; let source = "none"; let stale = false; @@ -230,7 +244,7 @@ export function createFeed({ const combined = hub.agents.map(agent => { const keys = hub.links?.get(agent.id) || new Set([agent.id, agent.sessionId].filter(Boolean)); const local = [...keys].map(key => localById.get(key)).find(Boolean); - if (!local) return agent; + if (!local) return { ...agent }; const hubHasExplicitTask = Boolean(agent.taskId); const sameExplicitTask = hubHasExplicitTask && Boolean(local.taskId) && String(agent.taskId) === String(local.taskId); @@ -247,6 +261,15 @@ export function createFeed({ if (sameExplicitTask || agent.originalAskSource === "session") suppressedLocalIds.add(local.id); const events = [...keys].flatMap(key => nextActivity.get(key) || []).filter(event => !agent.taskId || !agent.taskStartedAt || event.timestamp === null || event.timestamp >= agent.taskStartedAt); + const localTodos = normalizeTodos(local.todos); + const localTaskId = typeof local.taskId === "string" && local.taskId.trim(); + const currentTaskTodos = localTodos && (!agent.taskId || + (localTaskId ? localTaskId === agent.taskId : agent.taskStartedAt && localTodos.updatedAt && localTodos.updatedAt >= agent.taskStartedAt)) ? localTodos : null; + const hubTodos = normalizeTodos(agent.todos); + const localTodosAreCurrent = currentTaskTodos && (!hubTodos || + (currentTaskTodos.updatedAt !== null && (hubTodos.updatedAt === null || currentTaskTodos.updatedAt >= hubTodos.updatedAt)) || + (currentTaskTodos.updatedAt === null && hubTodos.updatedAt === null)); + const todos = localTodosAreCurrent ? currentTaskTodos : hubTodos; nextActivity.set(agent.id, mergeActivityEvents([], events)); return { ...agent, @@ -256,12 +279,19 @@ export function createFeed({ sessionStartedAt: local.sessionStartedAt || agent.sessionStartedAt, activity: local.activity || agent.activity, lastLine: local.lastLine || agent.lastLine, + todos, }; }); - for (const agent of claude.agents) if (!suppressedLocalIds.has(agent.id)) combined.push(agent); + for (const agent of claude.agents) if (!suppressedLocalIds.has(agent.id)) combined.push({ ...agent }); const seen = new Set(combined.map(agent => agent.id)); // A live PR outlives a transcript window or DB scan window. for (const old of cache) if (!seen.has(old.id) && hasOutstandingPr(old, now())) combined.push({ ...old, status: "offline" }); + for (const agent of combined) { + const stored = remoteTodos.get(`${agent.id}\n${agent.taskId || ""}\n${agent.sessionId || ""}`); + const supplied = normalizeTodos(agent.todos); + agent.todos = newestTodos(stored, supplied); + if (agent.todos && nextErrors.length) agent.todos = { ...agent.todos, stale: true }; + } let enriched = combined; try { enriched = await enrich(await resolveRepos(combined)); } catch { nextErrors.push("PR metadata is temporarily unavailable"); } @@ -286,6 +316,24 @@ export function createFeed({ letters: lettersOf(cache).length, liveCost: liveCostOf(cache), }; } + + function rememberActivityTodos(agent, value) { + const identity = cottage => `${cottage.id}\n${cottage.taskId || ""}\n${cottage.sessionId || ""}`; + const key = identity(agent); + // A scan may replace the cached object while the read is in flight. Apply + // the result to the current object only when its task/session still match. + const currentAgent = cache.find(cottage => identity(cottage) === key) || agent; + const incoming = normalizeTodos(value); + const current = normalizeTodos(currentAgent.todos); + const todos = newestTodos(current, incoming); + if (todos) { + agent.todos = currentAgent.todos = todos; + remoteTodos.set(key, todos); + if (remoteTodos.size > 200) remoteTodos.delete(remoteTodos.keys().next().value); + } + return todos; + } + return { snapshot, scan() { @@ -301,9 +349,18 @@ export function createFeed({ const agent = cache.find(cottage => cottage.id === id); if (!agent) return null; const local = activity.get(id) || []; - if (local.length) return { ...pageActivity(mergeActivityEvents([], local), { ...options, source: "claude-transcript" }), ...(stale ? { stale: true } : {}) }; - if (agent.source === "hub" && timeline.configured) return timeline.read(agent.id, options); - return { ...pageActivity([], options), unavailable: true }; + if (local.length) { + const todos = agent.source === "hub" && timeline.configured && typeof timeline.readTodos === "function" + ? rememberActivityTodos(agent, await timeline.readTodos(agent.id)) + : normalizeTodos(agent.todos); + return { ...pageActivity(mergeActivityEvents([], local), { ...options, source: "claude-transcript" }), todos, ...(stale ? { stale: true } : {}) }; + } + if (agent.source === "hub" && timeline.configured) { + const result = await timeline.read(agent.id, options); + const todos = rememberActivityTodos(agent, result.todos); + return { ...result, todos }; + } + return { ...pageActivity([], options), todos: normalizeTodos(agent.todos), unavailable: true }; }, }; } @@ -319,17 +376,42 @@ function isSameOriginRequest(req) { } catch { return false; } } -export function createFeedServer(feed, { directory = HERE } = {}) { +export function createFeedServer(feed, { directory = HERE, messages = createHubMessenger() } = {}) { return createServer(async (req, res) => { const headers = { "cache-control": "no-store" }; const json = (status, body) => { res.writeHead(status, { ...headers, "content-type": "application/json" }); res.end(JSON.stringify(body)); }; try { const url = new URL(req.url, "http://localhost"); + const messageRoute = url.pathname.match(/^\/agents\/([^/]+)\/messages$/); const isAgentRoute = url.pathname === "/agents" || /^\/agents\/[^/]+\/activity$/.test(url.pathname); if (isAgentRoute && !isSameOriginRequest(req)) return json(403, { error: "Cross-origin agent access is not allowed" }); - if (req.method === "OPTIONS") { res.writeHead(204, { ...headers, "access-control-allow-methods": "GET, OPTIONS" }); return res.end(); } + if (req.method === "OPTIONS") { res.writeHead(204, { ...headers, "access-control-allow-methods": "GET, POST, OPTIONS" }); return res.end(); } + if (req.method === "POST" && messageRoute) { + if (!acceptsMessageOrigin(req)) return json(403, { error: "Messages require a local connection from this CottageCode origin.", delivery: "not_sent" }); + if (Number(req.headers["content-length"]) > 65536) { req.resume(); return json(413, { error: "Message too large", delivery: "not_sent" }); } + const chunks = []; let length = 0; + for await (const chunk of req) { length += chunk.length; if (length > 65536) return json(413, { error: "Message too large", delivery: "not_sent" }); chunks.push(chunk); } + let payload; + try { payload = JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch { return json(400, { error: "Invalid message JSON", delivery: "not_sent" }); } + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return json(400, { error: "Invalid message", delivery: "not_sent" }); + if (feed.scan) await feed.scan(); + const snapshot = feed.snapshot(), agent = snapshot.agents.find(agent => agent.id === decodeURIComponent(messageRoute[1])); + if (!agent) { + // A browser retry can outlive a just-finished or refreshed cottage. + // Consult only the exact durable receipt for this route and payload; + // no task is fetched or message is sent when the cottage is absent. + const receipt = await messages.priorReceipt?.(decodeURIComponent(messageRoute[1]), payload); + if (receipt) return json(receipt.status, receipt.body); + return json(404, { error: "Cottage not found", delivery: "not_sent" }); + } + const sent = await messages.send(agent, payload, { stale: !!snapshot.stale, checkedAt: snapshot.checkedAt }); + return json(sent.status, sent.body); + } if (req.method !== "GET" && req.method !== "HEAD") return json(405, { error: "Read-only endpoint" }); - if (url.pathname === "/agents") return json(200, feed.snapshot()); + if (url.pathname === "/agents") { + const snapshot = feed.snapshot(); + return json(200, { ...snapshot, agents: snapshot.agents.map(agent => ({ ...agent, conversation: messages.capability(agent, { stale: !!snapshot.stale, checkedAt: snapshot.checkedAt }) })) }); + } const match = url.pathname.match(/^\/agents\/([^/]+)\/activity$/); if (match) { const after = url.searchParams.get("after") || ""; diff --git a/src/hub.mjs b/src/hub.mjs index e13c899..7009537 100644 --- a/src/hub.mjs +++ b/src/hub.mjs @@ -8,6 +8,7 @@ import { existsSync } from "node:fs"; import { DatabaseSync } from "node:sqlite"; import { townName, worktreeOf } from "./towns.mjs"; import { timestampMs } from "./activity.mjs"; +import { normalizeTodos } from "./todos.mjs"; import { hasOutstandingPr } from "./pr.mjs"; import { classifyOccupancy, @@ -210,6 +211,15 @@ export function toCottage(row, now = Date.now()) { const explicitRequest = ctx.originalAsk || requestSpec.request || ctx.customerRequest?.text || ctx.customerRequest || ctx.originalTask; const original = typeof explicitRequest === "string" ? explicitRequest.trim() : !externalSession ? String(row.task || "").trim() : ""; const originalAsk = original && !isEmptyResult(original) && !isWorktreeSlug(original) ? original.slice(0, 32768) : ""; + const suppliedTodos = normalizeTodos(ctx.todos ?? ctx.cottage?.todos ?? durableResult.todos, { source: "hub:context", updatedAt: ctx.todosUpdatedAt }); + const checkpointTodos = normalizeTodos(row.todo_snapshot, { source: "hub:checkpoint", updatedAt: row.todo_updated_at }); + const checkpointWins = checkpointTodos && (!suppliedTodos || ( + checkpointTodos.updatedAt !== null + ? suppliedTodos.updatedAt === null || checkpointTodos.updatedAt >= suppliedTodos.updatedAt + : suppliedTodos.updatedAt === null + )); + const todos = checkpointWins ? checkpointTodos : suppliedTodos; + const execution = ctx.lifecycle?.execution || {}; const attention = row.attention_message ? String(row.attention_message).replace(/\s+/g, " ").trim().slice(0, 240) : ""; @@ -242,6 +252,14 @@ export function toCottage(row, now = Date.now()) { taskStartedAt: externalSession ? null : timestampMs(row.started_at), sessionStartedAt: timestampMs(ctx.sessionStartedAt || ctx.lifecycle?.sessionStartedAt) || (externalSession ? started || null : null), activityUrl: `/agents/${encodeURIComponent(row.id)}/activity`, + todos, + conversationTarget: { + taskId: row.id, + taskStatus: String(row.status || "unknown"), + recordKind: ["logical_task", "external_session"].includes(row.record_kind) ? row.record_kind : "unknown", + transport: ["tmux", "direct"].includes(execution.sessionMode) ? execution.sessionMode : "unknown", + supportsRedirection: typeof execution.supportsRedirection === "boolean" ? execution.supportsRedirection : null, + }, repo: [ctx.githubAutoJackRequest?.repo, ctx.repo, ctx.repository].find(value => typeof value === "string" && /^[\w.-]+\/[\w.-]+$/.test(value)) || "", defaultBranch: typeof ctx.defaultBranch === "string" ? ctx.defaultBranch : "", worktree, @@ -387,12 +405,22 @@ export function readHubAgents({ limit = 80, dbPath = process.env.AGENT_DB_PATH | const keys = new Set(); const links = new Map(); + const checkpointColumns = new Set(db.prepare("PRAGMA table_info(agent_checkpoints)").all().map(column => column.name)); + const todoQuery = ["run_id", "kind", "data", "created_at"].every(name => checkpointColumns.has(name)) + ? db.prepare("SELECT data, created_at FROM agent_checkpoints WHERE run_id = ? AND kind = 'todo' ORDER BY created_at DESC LIMIT 1") + : null; const agents = retained.map(({ row, cottage }) => { const ctx = parseContext(row.context); const related = hubDedupKeys(row, ctx); links.set(row.id, related); - for (const key of related) keys.add(key); - return cottage; + for (const k of related) keys.add(k); + const checkpoint = todoQuery?.get(row.id); + if (checkpoint) { + const data = parseContext(checkpoint.data); + row.todo_snapshot = data.todos ?? data.list; + row.todo_updated_at = checkpoint.created_at; + } + return checkpoint ? toCottage(row, now) : cottage; }); return { ok: true, dbPath, agents, keys, links }; } catch (err) { diff --git a/src/interaction.mjs b/src/interaction.mjs new file mode 100644 index 0000000..2e19fe5 --- /dev/null +++ b/src/interaction.mjs @@ -0,0 +1,31 @@ +/** Door thresholds sit just outside collision bounds. Only crossing toward the + * doorway changes scenes, so holding a key cannot bounce Jack back through it. */ +export function cottageDoors(plots, visible = () => true) { + return plots.flatMap(plot => [ + ...(visible(plot.agent) ? [{id: plot.agent.id, x: plot.x + 27, y: plot.y + 70, width: 18}] : []), + ...(plot.kids || []).filter(kid => visible(kid.agent)).map(kid => ({id: kid.agent.id, x: kid.x + 8, y: kid.y + 21, width: 14})), + ]); +} + +/** A resident is interactable only while their own cottage still has a + * rendered plot. Actors survive scene refreshes for animation continuity, so + * the plot list is the authoritative visibility boundary. */ +export function residentTargets(actors, plots) { + const rendered = new Set((plots || []) + .filter(plot => plot?.agent && plot.hidden !== true && plot.rendered !== false && plot.visible !== false) + .map(plot => plot.agent.id)); + return [...(actors || [])].flatMap(([id, actor]) => + rendered.has(id) && !actor?.indoors ? [{id, x:actor.x + 5, y:actor.y + 14}] : []); +} + +export function crossedDoor(from, to, doors, direction = 'in') { + const dy = to.y - from.y; + if (!Number.isFinite(dy) || (direction === 'out' ? dy <= 0 : dy >= 0)) return null; + for (const door of doors) { + const fraction = (door.y - from.y) / dy; + if (fraction < 0 || fraction > 1) continue; + const x = from.x + (to.x - from.x) * fraction; + if (Math.abs(x - door.x) <= door.width / 2) return door; + } + return null; +} diff --git a/src/interiors.mjs b/src/interiors.mjs index 318ff54..9cf3b10 100644 --- a/src/interiors.mjs +++ b/src/interiors.mjs @@ -15,6 +15,25 @@ const SKINS = ['#f2c899', '#dfaa78', '#c38d61', '#a96e49', '#845639', '#61412f'] const HAIR = ['#483227', '#75513a', '#ab7547', '#d3b273', '#cecbc1', '#353137']; const CLOTHES = ['#5d8597', '#6b9672', '#c08563', '#9a789c', '#ba9b58', '#6c8c8c']; +const REVIEW_SIGNALS = Object.freeze({ + none: { label: 'No PR yet', shortLabel: 'NO PR', color: '#a5aa98', shape: 'tray' }, + open: { label: 'PR opened', shortLabel: 'OPEN', color: '#dfad68', shape: 'parcel' }, + active: { label: 'Babysitting', shortLabel: 'FIXING', color: '#87b4d8', shape: 'hammer' }, + 'waiting-codex': { label: 'Waiting for Codex review', shortLabel: 'REVIEW', color: '#bd9bd9', shape: 'magnifier' }, + 'waiting-ci': { label: 'Waiting for CI', shortLabel: 'CI', color: '#83bfb9', shape: 'hourglass' }, + blocked: { label: 'Review blocked', shortLabel: 'BLOCK', color: '#e78773', shape: 'exclamation' }, + ready: { label: 'Ready to merge', shortLabel: 'READY', color: '#f5d66b', shape: 'star' }, + merged: { label: 'PR merged', shortLabel: 'MERGED', color: '#9dc58a', shape: 'check' }, + closed: { label: 'PR closed without merge', shortLabel: 'CLOSED', color: '#ada4ab', shape: 'cross' }, + unknown: { label: 'PR state unverified', shortLabel: 'CHECK?', color: '#91a0ab', shape: 'question' }, +}); + +/** Use the shared evidence rules: stale, conflicting, or changed-head ready is never gold. */ +export function reviewSignal(pr, now = Date.now()) { + const stage = prStage(pr, now); + return { stage, ...REVIEW_SIGNALS[stage] }; +} + const PALETTES = [ { wall: '#eee0bc', wallShade: '#d5c39a', floor: '#bc925f', board: '#c49b6a', grain: '#a67d51', rug: '#77928b', rugDark: '#526d68', trim: '#e1c89b' }, { wall: '#e3e5c9', wallShade: '#c1c9ab', floor: '#b7895d', board: '#c3956a', grain: '#9e744f', rug: '#ae7568', rugDark: '#85544c', trim: '#f0d49b' }, @@ -641,10 +660,57 @@ function parcel(p, x, y, stage, time) { } } -function review(ctx, object, room, time, stage) { +function drawSignalIcon(p, x, y, shape, color) { + if (shape === 'tray') { + p(x, y + 3, 1, 3, color); p(x + 6, y + 3, 1, 3, color); p(x, y + 6, 7, 1, color); + } else if (shape === 'parcel') { + frame(p, x, y + 1, 7, 6, color); + p(x + 3, y + 2, 1, 4, '#34433b'); + p(x + 1, y + 4, 5, 1, '#34433b'); + } else if (shape === 'hammer') { + p(x + 1, y, 6, 3, color); p(x + 3, y + 2, 2, 5, color); + } else if (shape === 'magnifier') { + frame(p, x, y, 5, 5, '#34433b', color); + p(x + 4, y + 4, 2, 2, color); p(x + 6, y + 6, 1, 1, color); + } else if (shape === 'hourglass') { + p(x, y, 7, 1, color); p(x, y + 6, 7, 1, color); + p(x + 1, y + 1, 5, 1, color); p(x + 2, y + 2, 3, 1, color); + p(x + 3, y + 3, 1, 1, color); p(x + 2, y + 4, 3, 1, color); p(x + 1, y + 5, 5, 1, color); + } else if (shape === 'exclamation') { + p(x + 2, y, 3, 4, color); p(x + 2, y + 6, 3, 1, color); + } else if (shape === 'star') { + p(x + 3, y, 1, 7, color); p(x, y + 3, 7, 1, color); p(x + 1, y + 1, 5, 5, color); + p(x + 2, y + 2, 3, 3, '#fff0b8'); + } else if (shape === 'check') { + p(x, y + 3, 2, 2, color); p(x + 1, y + 4, 2, 2, color); + p(x + 2, y + 5, 2, 2, color); p(x + 3, y + 3, 2, 2, color); p(x + 5, y + 1, 2, 2, color); + } else if (shape === 'cross') { + for (let i = 0; i < 7; i++) { p(x + i, y + i, 1, 1, color); p(x + 6 - i, y + i, 1, 1, color); } + } else { + p(x + 1, y, 5, 1, color); p(x + 5, y + 1, 2, 2, color); + p(x + 3, y + 3, 3, 1, color); p(x + 3, y + 4, 1, 1, color); p(x + 3, y + 6, 1, 1, color); + } +} + +function reviewLamp(ctx, object, signal) { const p = painter(ctx); const { x, y, w } = object; - desk(p, object, room.palette.rugDark); + // The light is attached to the existing desk, leaving its collision footprint + // untouched. A word and a distinct pixel icon accompany every color. + p(x + 5, y - 3, 2, 8, WOOD_DARK); + p(x + w - 8, y - 3, 2, 8, WOOD_DARK); + frame(p, x + 2, y - 12, w - 4, 11, '#34433b'); + p(x + 3, y - 11, w - 6, 1, signal.color); + drawSignalIcon(p, x + 5, y - 10, signal.shape, signal.color); + smallText(ctx, signal.shortLabel, x + 15, y - 9, signal.color, 5); + p(x + 3, y - 1, w - 6, 3, signal.color + '28'); +} + +function review(ctx, object, room, time, signal) { + const p = painter(ctx); + const { x, y, w } = object; + const stage = signal.stage; + desk(p, object, signal.color); p(x + 6, y + 1, 16, 15, '#e2d4b7'); p(x + 5, y, 16, 14, PAPER); for (let row = 0; row < 4; row++) p(x + 8, y + 3 + row * 2, 9 - (row % 2) * 2, 1, '#c4b494'); @@ -667,6 +733,7 @@ function review(ctx, object, room, time, stage) { p(x + 3, y + 18, 18, 1, '#dfc694'); } parcel(p, x + w - 22, y + 2, stage, time); + reviewLamp(ctx, object, signal); } function shelf(ctx, object, room, agent, stage) { @@ -855,13 +922,13 @@ function drawExit(p, room) { } /** Draw a 12×18 resident about a foot-center point. Animation time is milliseconds. */ -export function renderResident(ctx, x, y, resident = {}, { time = 0, walking = false, scale = 1 } = {}) { +export function renderResident(ctx, x, y, resident = {}, { time = 0, walking = false, scale = 1, talking = false, reduce = false } = {}) { const skin = resident.skin || SKINS[0]; const hair = resident.hair || HAIR[0]; const clothing = resident.clothing || CLOTHES[0]; const trousers = resident.trousers || '#4c5963'; const accent = resident.accent || '#dcc795'; - const step = walking ? Math.floor(time / 150) % 2 : 0; + const step = walking && !reduce ? Math.floor(time / 150) % 2 : 0; ctx.save(); ctx.translate(Math.round(x), Math.round(y)); ctx.scale(scale, scale); @@ -965,11 +1032,31 @@ export function renderResident(ctx, x, y, resident = {}, { time = 0, walking = f draw(4, -4, 2, 3, '#a87a56'); draw(3, -5, 2, 2, skin); } + if (talking) { + // A small raised hand and a mouth movement acknowledge Jack without moving + // the resident or fabricating any text. Reduced motion holds a still pose. + const syllable = !reduce && Math.floor(time / 150) % 2; + draw(0, -9, 2, syllable ? 2 : 1, '#674934'); + draw(-6, -8, 2, 3, skin); + draw(-5, -6, 2, 2, clothing); + } ctx.restore(); } +function conversationBubble(p, room, time, reduce) { + const x = Math.max(16, Math.min(room.width - 38, room.resident.x - 12)); + const y = Math.max(16, room.resident.y - 36); + frame(p, x, y, 28, 14, '#fff1cc', '#594c38'); + p(x + 9, y + 13, 5, 3, '#594c38'); + p(x + 10, y + 13, 3, 2, '#fff1cc'); + for (let i = 0; i < 3; i++) { + const lift = !reduce && Math.floor(time / 180) % 3 === i ? 1 : 0; + p(x + 6 + i * 7, y + 6 - lift, 3, 3, '#75603f'); + } +} + /** Render into a 240×176 coordinate space; the caller owns camera/scaling. */ -export function renderInterior(ctx, room, { time = 0, agent = {}, player = null, selectedObject = null, reduce = false } = {}) { +export function renderInterior(ctx, room, { time = 0, agent = {}, player = null, selectedObject = null, reduce = false, talking = false } = {}) { const frameTime = reduce ? 0 : time; ctx.save(); ctx.imageSmoothingEnabled = false; @@ -978,14 +1065,15 @@ export function renderInterior(ctx, room, { time = 0, agent = {}, player = null, drawWallAccents(p, room, frameTime); drawRug(p, room); drawExit(p, room); - const stage = prStage(agent.pr); + const signal = reviewSignal(agent.pr); + const stage = signal.stage; const layers = room.objects.filter((object) => object.id !== 'exit').map((object) => ({ y: object.y + object.h, object })); layers.push({ y: room.resident.y, resident: room.resident }); if (player) layers.push({ y: player.y, player }); layers.sort((a, b) => a.y - b.y); for (const layer of layers) { if (layer.resident) { - renderResident(ctx, room.resident.x, room.resident.y, room.resident, { time: frameTime }); + renderResident(ctx, room.resident.x, room.resident.y, room.resident, { time: frameTime, talking, reduce }); } else if (layer.player) { renderResident(ctx, player.x, player.y, { skin: SKINS[0], hair: HAIR[0], clothing: '#345670', trousers: '#46505c', headgear: 'cap', accent: '#ce6554', @@ -1003,7 +1091,7 @@ export function renderInterior(ctx, room, { time = 0, agent = {}, player = null, if (object.id === 'request') request(ctx, object); else if (object.id === 'clock') clock(ctx, object, agent); else if (object.id === 'workbench') workbench(ctx, object, room, frameTime, agent); - else if (object.id === 'review') review(ctx, object, room, frameTime, stage); + else if (object.id === 'review') review(ctx, object, room, frameTime, signal); else if (object.id === 'shelf') shelf(ctx, object, room, agent, stage); else if (object.id === 'theme') themeFurniture(ctx, object, room); else if (object.id === 'hearth') hearth(p, object, frameTime, room); @@ -1011,6 +1099,7 @@ export function renderInterior(ctx, room, { time = 0, agent = {}, player = null, else if (object.id === 'plant') plant(p, object); } } + if (talking) conversationBubble(p, room, frameTime, reduce); const selectedId = typeof selectedObject === 'object' ? selectedObject?.id : selectedObject; const selected = room.objects.find((object) => object.id === selectedId); if (selected) { diff --git a/src/messages.mjs b/src/messages.mjs new file mode 100644 index 0000000..25ca60b --- /dev/null +++ b/src/messages.mjs @@ -0,0 +1,142 @@ +/** Explicit user messages through supported AutoHub task routes. GitHub and + * transcript adapters remain read-only. Never infer a terminal from a name. */ +import {createHash} from 'node:crypto'; +import {mkdir,readFile,open} from 'node:fs/promises'; +import {homedir} from 'node:os'; +import {dirname,join} from 'node:path'; +import {isIP} from 'node:net'; +import {MAX_MESSAGE_LENGTH} from './conversation.mjs'; + +const result=(status,body)=>({status,body}); +const rejected=(status,error)=>result(status,{ok:false,delivery:'not_sent',error}); +const unknown=()=>result(409,{ok:false,delivery:'unconfirmed',error:'Delivery is unconfirmed. Check the task before sending this message again.'}); +const parse=value=>{try{return typeof value==='string'?JSON.parse(value):value||{};}catch{return {};}}; +function hubBase(raw){try{const url=new URL(raw);if(!['http:','https:'].includes(url.protocol)||url.username||url.password)return null;url.search='';url.hash='';url.pathname=url.pathname.replace(/\/$/,'').replace(/\/v1$/,'')+'/v1/';return url;}catch{return null;}} + +export function acceptsMessageOrigin(req){ + try{ + const peer=String(req.socket?.remoteAddress||'').replace(/^::ffff:/,''); + if(peer!=='::1'&&!(isIP(peer)===4&&peer.startsWith('127.')))return false; + const expected=new URL('http://'+req.headers.host),host=expected.hostname.replace(/^\[|\]$/g,''); + return (host==='localhost'||!!isIP(host))&&req.headers.origin===expected.origin&& + req.headers['x-cottagecode-request']==='user-message'&& + /^application\/json(?:\s*;|$)/i.test(req.headers['content-type']||''); + }catch{return false;} +} + +export function createHubMessenger({ + baseUrl=process.env.COTTAGE_HUB_URL||'',token=process.env.COTTAGE_HUB_TOKEN||'', + fetchImpl=globalThis.fetch,now=Date.now,timeoutMs=8000, + ledgerPath=process.env.COTTAGE_MESSAGE_LEDGER||join(homedir(),'.cottagecode','message-receipts.jsonl'), +}={}){ + const base=hubBase(baseUrl),entries=new Map();let loaded=false,queue=Promise.resolve(); + const receiptHash=(cottageId,taskId,message)=>createHash('sha256').update(JSON.stringify([cottageId,taskId,message])).digest('hex'); + async function priorReceipt(cottageId,payload){ + const message=typeof payload?.message==='string'?payload.message.trim():''; + if(!cottageId||!message||message.length>MAX_MESSAGE_LENGTH||typeof payload?.requestId!=='string'||!/^[a-zA-Z0-9_-]{8,100}$/.test(payload.requestId))return null; + try{await load();}catch{return rejected(503,'The message receipt ledger is unavailable. Nothing was sent.');} + const previous=entries.get(payload.requestId); + if(!previous||previous.hash!==receiptHash(cottageId,payload.taskId,message))return null; + return previous.response||unknown(); + } + function capability(agent,{stale=false,checkedAt=now()}={}){ + const unavailable=reason=>({available:false,reason,source:'autohub',checkedAt}); + if(!base)return unavailable('Messaging needs a configured AutoHub connection. This source currently provides activity only.'); + if(stale||!checkedAt||now()-checkedAt>120000)return unavailable('The task feed is stale. Reconnect before sending.'); + const target=agent.conversationTarget; + if(agent.source!=='hub'||target?.recordKind!=='logical_task'||!agent.taskId||target.taskId!==agent.taskId)return unavailable('This observed session has no supported task messaging route.'); + let mode=''; + if(['awaiting_input','needs_input'].includes(target.taskStatus))mode='respond'; + else if(target.taskStatus==='running'&&target.transport==='tmux'&&target.supportsRedirection===true)mode='redirect'; + else return unavailable(['completed','failed','cancelled','interrupted'].includes(target.taskStatus)?'This task has finished. Start any follow-up in its original workflow.':target.transport==='direct'?'This direct session does not support mid-task messages.':'This task has no supported live message route.'); + return {available:true,mode,source:'autohub',checkedAt,messageUrl:'/agents/'+encodeURIComponent(agent.id)+'/messages'}; + } + async function load(){ + if(loaded)return; + if(ledgerPath){ + try{ + const text=await readFile(ledgerPath,'utf8'),endsWithNewline=text.endsWith('\n'),lines=text.split('\n'); + const last=endsWithNewline?lines.length-1:lines.length; + for(let index=0;indexMAX_MESSAGE_LENGTH)return rejected(400,'Write a message of 1–'+MAX_MESSAGE_LENGTH+' characters.'); + if(typeof payload.requestId!=='string'||!/^[a-zA-Z0-9_-]{8,100}$/.test(payload.requestId))return rejected(400,'A unique message request ID is required.'); + if(!agent)return rejected(409,'The task changed. Reopen its cottage before sending.'); + // A retry can arrive after this cottage advances to a new task. Its receipt + // belongs to the task identity in the original payload, so inspect it before + // treating the current cottage as changed. + const hash=receiptHash(agent.id,payload.taskId,message); + try{await load();}catch{return rejected(503,'The message receipt ledger is unavailable. Nothing was sent.');} + const previous=entries.get(payload.requestId); + if(previous){ + if(previous.hash!==hash)return rejected(409,'That message request ID belongs to a different message.'); + return previous.response||unknown(); + } + if(payload.taskId!==agent.taskId)return rejected(409,'The task changed. Reopen its cottage before sending.'); + const supported=capability(agent,meta);if(!supported.available)return rejected(409,supported.reason); + const path='tasks/'+encodeURIComponent(agent.taskId); + try{ + const response=await request(path); + if(!response.ok)return rejected(502,'AutoHub could not verify the current task. Nothing was sent.'); + const task=await response.json(),context=parse(task.context),execution=context.lifecycle?.execution||{}; + const current={...agent,conversationTarget:{taskId:task.id,taskStatus:task.status,recordKind:task.recordKind,transport:execution.sessionMode,supportsRedirection:execution.supportsRedirection}}; + if(task.id!==agent.taskId||task.isStale||task.archived)return rejected(409,'The task is no longer available for messages.'); + const verified=capability(current); + if(!verified.available||verified.mode!==supported.mode|| (supported.mode==='respond'&&task.canRespond===false))return rejected(409,'The task’s input state changed. Refresh before sending.'); + }catch{return rejected(502,'AutoHub could not verify the current task. Nothing was sent.');} + const entry={id:payload.requestId,hash,at:now(),response:null}; + try{await remember(entry);}catch{return rejected(503,'The message receipt could not be saved. Nothing was sent.');} + let receipt; + try{ + const response=await request(path+'/'+supported.mode,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(supported.mode==='redirect'?{instruction:message}:{response:message})}); + const body=await response.json(); + if(response.ok&&((supported.mode==='redirect'&&body.ok===true&&body.taskId===agent.taskId)||(supported.mode==='respond'&&body.id===agent.taskId))){ + receipt=result(200,{ok:true,delivery:supported.mode==='redirect'?'submitted':'accepted',requestId:payload.requestId,source:'autohub',timestamp:now()}); + }else if(response.status===401 || + (response.status===403&&body.error==='owner_approval_required') || + (response.status===404&&['task_not_found','Task not found'].includes(body.error)) || + (response.status===409&&['no_running_session','not_awaiting_input'].includes(body.error)) || + (response.status===400&&body.error==='instruction_required')){ + // Only known pre-delivery errors are safe to retry. AutoHub can return + // already_answered/cancellation_in_progress after affecting the task. + // Omit upstream bodies, which can + // contain task context or terminal paths not intended for the browser. + receipt=rejected(response.status,response.status===403?'AutoHub requires an authorized owner response. Nothing was sent.':response.status===401?'AutoHub authentication failed. Nothing was sent.':'AutoHub declined the message because the task or input state changed.'); + }else receipt=unknown(); + }catch{receipt=unknown();} + try{await remember({...entry,response:receipt});}catch{return unknown();} + return receipt; + } + return {capability,priorReceipt, + send(agent,payload,meta={}){ + // The durable reservation precedes the only write request. Repeated Send, + // a timeout, or a process restart cannot replay an uncertain instruction. + const work=queue.then(()=>sendNow(agent,payload,meta));queue=work.catch(()=>{});return work; + }, + }; +} diff --git a/src/observatory.mjs b/src/observatory.mjs index 9da2a44..1a646ba 100644 --- a/src/observatory.mjs +++ b/src/observatory.mjs @@ -4,6 +4,9 @@ import {movePoint,inside,normalizeRelationships,normalizedHandoffs} from './worl import {createHistory} from './history.mjs'; import {createSound} from './sound.mjs'; import {activityAddress,mergeActivity,elapsedMs,validTime} from './feed-client.mjs'; +import {cottageDoors,crossedDoor} from './interaction.mjs'; +import {normalizeTodos} from './todos.mjs'; +import {conversationCapability,MAX_MESSAGE_LENGTH} from './conversation.mjs'; export const STAGES={ none:{label:'No PR',color:'#a3a99d',symbol:'—'}, @@ -48,22 +51,31 @@ export function activityJournalPresentation(cache={}){ if(cache.stale)return {state:'stale',text:source+' · stale — '+String(cache.error||'connection interrupted')}; return {state:'live',text:source+' · live activity'}; } +export function activityCacheFor(cache,a={}){ + const sessionIdentity=a.sessionId||a.sessionStartedAt||null; + const identity=JSON.stringify([a.taskId||null,sessionIdentity,a.activityUrl||null]); + if(cache?.identity===identity)return cache; + return {identity,events:[],source:'none',cursor:null,hasMore:false,stale:false,unavailable:false}; +} const link=(url,text)=>safeUrl(url)?''+esc(text)+'':''; function nearRect(p,r){return Math.hypot(p.x-Math.max(r.x,Math.min(p.x,r.x+r.w)),p.y-Math.max(r.y,Math.min(p.y,r.y+r.h)));} export function createObservatory(api){ const {canvas}=api,panel=$('panel'),viewport=$('map-viewport'),roomCanvas=$('room-canvas'),roomCtx=roomCanvas.getContext('2d'); const sound=createSound(),keys=new Set(),rooms=new Map(),activity=new Map(),inflight=new Map(),activityLines=new Map(); + const conversations=new Map(); let mode='town',selected=null,interiorId=null,room=null,roomPlayer=null,player=null,returnTo=null,tab='overview',prFilter=null; let followId=null,lastFrame=0,transition=1,latestAgents=[],relationships=[],handoffs=[],couriers=[],apprentices=[],knownKids=new Set(); let sourceKey='',history=null,historyInitialized=false,stageSignature='',rosterSignature='',panelKey='',lastHint='',replayIndex=-1,replaying=false,replayTimer=0,replayEvents=[]; let lastHealthCheck=0,selectedObject=null,board=null,feedStale=false,historyView='since'; + let talkingId=null,talkingUntil=0; let reduce=matchMedia('(prefers-reduced-motion: reduce)').matches; matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change',e=>{reduce=e.matches;if(reduce)transition=1;}); let storage=null;try{storage=localStorage;}catch{} const byId=id=>latestAgents.find(a=>a.id===id); const plotFor=id=>{for(const p of api.getPlots()){if(p.agent.id===id)return p;const k=p.kids?.find(k=>k.agent.id===id);if(k)return {...k,agent:k.agent,parentPlot:p};}return null;}; const roomFor=a=>{const key=a.id+'|'+(a.taskId||'');if(!rooms.has(key))rooms.set(key,createInterior(a));return rooms.get(key);}; + const conversationFor=a=>{const key=sourceKey+'|'+a.id+'|'+(a.taskId||'');if(!conversations.has(key))conversations.set(key,{text:'',phase:'idle',notice:'',sent:[],requestId:null,payload:''});return conversations.get(key);}; const visible=a=>!prFilter||prStage(a.pr)===prFilter; const hint=text=>{if(text!==lastHint){$('scene-status').textContent=text;lastHint=text;}}; function placePlayer(){ @@ -108,24 +120,31 @@ export function createObservatory(api){ } function interact(){ if(mode==='room'){ - const nearest=room.objects.filter(o=>o.interactable).sort((a,b)=>nearRect(roomPlayer,a)-nearRect(roomPlayer,b))[0]; - if(nearest&&nearRect(roomPlayer,nearest)<30){if(nearest.id==='exit')leave();else inspect(nearest.id);} + if(distance(roomPlayer,room.resident)<30){talk(interiorId);return;} + const nearest=room.objects.filter(o=>o.interactable&&o.id!=='exit').sort((a,b)=>nearRect(roomPlayer,a)-nearRect(roomPlayer,b))[0]; + if(nearest&&nearRect(roomPlayer,nearest)<30)inspect(nearest.id); return; } if(!player)return; + const resident=(api.getResidents?.()||[]).filter(r=>visible(byId(r.id)||{})).sort((a,b)=>distance(player,a)-distance(player,b))[0]; + if(resident&&distance(player,resident)<30){talk(resident.id);return;} const targets=[]; for(const p of api.getPlots()){ if(visible(p.agent)){ - targets.push({kind:'door',id:p.agent.id,x:p.x+27,y:p.y+75}); targets.push({kind:'bench',id:p.agent.id,x:p.x+35,y:p.y+105}); } - for(const k of p.kids||[])if(visible(k.agent))targets.push({kind:'door',id:k.agent.id,x:k.x+8,y:k.y+20}); } if(board)targets.push({kind:'board',...board}); const near=targets.sort((a,b)=>distance(player,a)-distance(player,b))[0]; - if(near&&distance(player,near)<31){ - if(near.kind==='board')showHistory('board');else if(near.kind==='bench')follow(near.id);else enter(near.id); - }else if(selected){hint('Walk up to a door or bench, or use “Enter cottage” in the inspector.');} + if(near&&distance(player,near)<(near.kind==='bench'?20:31)){ + if(near.kind==='board')showHistory('board');else if(near.kind==='bench')follow(near.id); + }else hint('Walk into a doorway to enter. Press E near an agent to talk.'); + } + function talk(id){ + const a=byId(id);if(!a)return; + if(mode==='board'||mode==='scrapbook')setMode('town'); + selected=id;tab='talk';selectedObject=null;talkingId=id;talkingUntil=performance.now()+1500; + api.select(id);sound.murmur({seed:roomFor(a).seed});ensureActivity(a);renderPanel(id); } function inspect(id){ selectedObject=id;tab=({request:'request',clock:'overview',workbench:'journal',review:'review',shelf:'artifacts'})[id]||'overview'; @@ -152,9 +171,16 @@ export function createObservatory(api){ let dy=(keys.has('ArrowDown')||keys.has('s')?1:0)-(keys.has('ArrowUp')||keys.has('w')?1:0); if(!dx&&!dy)return false; const n=Math.hypot(dx,dy);dx/=n;dy/=n;followId=null; - if(mode==='room')roomPlayer=movePoint(roomPlayer,dx*68*dt,dy*68*dt,(x,y)=>isWalkable(room,x,y)); + if(mode==='room'){ + const next={x:roomPlayer.x+dx*68*dt,y:roomPlayer.y+dy*68*dt}; + if(crossedDoor(roomPlayer,next,[{x:room.door.x,y:157,width:26}],'out')){leave();return true;} + roomPlayer=movePoint(roomPlayer,dx*68*dt,dy*68*dt,(x,y)=>isWalkable(room,x,y)); + } else{ - placePlayer();player=movePoint(player,dx*88*dt,dy*88*dt,walkable);scrollToPlayer(); + placePlayer(); + const next={x:player.x+dx*88*dt,y:player.y+dy*88*dt},door=crossedDoor(player,next,cottageDoors(api.getPlots(),visible)); + if(door){enter(door.id);return true;} + player=movePoint(player,dx*88*dt,dy*88*dt,walkable);scrollToPlayer(); } sound.play('step');return true; } @@ -175,11 +201,14 @@ export function createObservatory(api){ if(!room||transition<1)return; const r=roomCanvas.getBoundingClientRect(),scale=Math.min(r.width/room.width,r.height/room.height); const p={x:(e.clientX-r.left-(r.width-room.width*scale)/2)/scale,y:(e.clientY-r.top-(r.height-room.height*scale)/2)/scale}; + if(inside(p,{x:room.resident.x-9,y:room.resident.y-21,w:18,h:24})){talk(interiorId);return;} const obj=room.objects.find(o=>o.interactable&&inside(p,o)); if(obj){if(obj.id==='exit')leave();else inspect(obj.id);} }); function handleClick(e){ const r=canvas.getBoundingClientRect(),p={x:(e.clientX-r.left)*canvas.width/r.width,y:(e.clientY-r.top)*canvas.height/r.height}; + const resident=(api.getResidents?.()||[]).find(a=>visible(byId(a.id)||{})&&inside(p,{x:a.x-7,y:a.y-18,w:14,h:20})); + if(resident){talk(resident.id);return true;} for(const plot of api.getPlots()){ if(!visible(plot.agent))continue; if(inside(p,{x:plot.x-18,y:plot.y+46,w:18,h:30})){ @@ -197,6 +226,9 @@ export function createObservatory(api){ const anchor=old&&[...old.querySelectorAll('[data-event]')].find(e=>e.offsetTop+e.offsetHeight>old.offsetTop+scroll); const anchorId=anchor?.dataset.event,anchorOffset=anchor&&old?anchor.getBoundingClientRect().top-old.getBoundingClientRect().top:0; const active=document.activeElement,focus=same&&panel.contains(active)?active.dataset.action:null,journalFocused=same&&active===old; + const composing=same&&active?.id==='agent-message',selection=composing?[active.selectionStart,active.selectionEnd,active.scrollTop]:null; + const todosOpen=same&&panel.querySelector('.talk-todos')?.open; + const todoScroll=same?panel.querySelector('.todo-list')?.scrollTop:0; panel.innerHTML=html;panelKey=key; const log=panel.querySelector('.journal'); if(log&&same)log.scrollTop=atEnd?log.scrollHeight:scroll; @@ -204,12 +236,15 @@ export function createObservatory(api){ if(log&&!same)log.scrollTop=log.scrollHeight; if(journalFocused)log?.focus({preventScroll:true}); if(focus)[...panel.querySelectorAll('[data-action]')].find(node=>node.dataset.action===focus)?.focus({preventScroll:true}); + if(composing){const input=$('agent-message');if(input){input.focus({preventScroll:true});input.setSelectionRange(selection[0],selection[1]);input.scrollTop=selection[2];}} + if(todosOpen&&panel.querySelector('.talk-todos'))panel.querySelector('.talk-todos').open=true; + if(todoScroll&&panel.querySelector('.todo-list'))panel.querySelector('.todo-list').scrollTop=todoScroll; } function cacheFor(a){ - const identity=(a.taskId||'')+'|'+(a.activityUrl||''); - if(activity.get(a.id)?.identity!==identity){ + const previous=activity.get(a.id),cache=activityCacheFor(previous,a); + if(cache!==previous){ inflight.get(a.id)?.abort();inflight.delete(a.id); - activity.set(a.id,{identity,events:[],source:'none',cursor:null,hasMore:false,stale:false,unavailable:false}); + activity.set(a.id,cache); } return activity.get(a.id); } @@ -236,6 +271,7 @@ export function createObservatory(api){ const data=await res.json(); if(epoch!==sourceKey||activity.get(a.id)!==cache)return; applyActivityPage(cache,data,{older}); + if(Object.hasOwn(data,'todos'))cache.todos=normalizeTodos(data.todos); recordActivity(a,data.events); for(const e of data.events)if(e.kind==='handoff'&&e.from&&e.to)appendHandoff(e); }catch(err){if(epoch===sourceKey&&activity.get(a.id)===cache){cache.stale=true;cache.error=err.name==='AbortError'?'Activity request timed out':err.message;}} @@ -249,13 +285,59 @@ export function createObservatory(api){ const events=activity.get(a.id)?.events||a.events||[]; return [...events].reverse().find(e=>['progress','summary','tool','result'].includes(e.kind))?.text||a.activity||a.lastLine||''; } + function todosHtml(a,cache){ + const supplied=normalizeTodos(a.todos),journal=normalizeTodos(cache.todos); + const todos=journal&&(!supplied||(journal.updatedAt||0)>(supplied.updatedAt||0))?journal:supplied||journal; + if(!todos)return '

This source has not supplied a to-do list. Check the journal for recorded progress.

'; + const done=todos.items.filter(item=>item.status==='completed').length; + const labels={pending:'To do',in_progress:'In progress',completed:'Done',cancelled:'Cancelled'}; + return '

'+done+' / '+todos.items.length+' done · '+esc(todos.source||'Task source')+' · '+esc(clock(todos.updatedAt))+(todos.stale||cache.stale||feedStale?' · stale':'')+'

'+(todos.items.length?'
    '+todos.items.map(item=>'
  • '+labels[item.status]+''+esc(item.text)+'
  • ').join('')+'
':'

The agent’s list is empty.

')+(todos.truncated?'

Showing the first 100 items supplied.

':''); + } + function talkHtml(a,cache){ + const conversation=conversationFor(a),capability=conversationCapability(a,api.getEndpoint(),{stale:feedStale}); + const pending=conversation.phase==='sending',uncertain=conversation.phase==='unconfirmed'; + const enabled=capability.available&&!pending&&!uncertain&&!!conversation.text.trim()&&conversation.text.trim()!==conversation.uncertainPayload; + const receipts=conversation.sent.map(message=>'
  • '+esc(message.text)+'

    '+esc(clock(message.timestamp))+' · '+esc(message.label)+'
  • ').join(''); + return '

    A word with '+esc(a.name)+'

    '+(sound.enabled?'Your host answers with a little murmur.':'Enable sound to hear your host’s little murmur.')+' Activity below comes from the task’s recorded updates.

    '+ + '

    '+esc(capability.available?(capability.mode==='respond'?'Your reply will go to the task waiting for input.':'Your message will be submitted to the running agent’s terminal.')+' · '+(capability.source||'Connected feed'):capability.reason)+'

    '+esc(conversation.notice)+'

    '+ + (receipts?'
      '+receipts+'
    ':'')+ + '
    Agent’s to-do list'+todosHtml(a,cache)+'
    '+ + '

    From the workbench

    '+button('older','Earlier entries',cache.hasMore?'':'disabled')+'

    '+esc(cache.source)+(cache.stale?' · stale — '+esc(cache.error||'connection interrupted'):' · recorded activity')+'

      '+eventHtml(cache.events)+'
    '; + } + async function sendMessage(){ + const a=byId(interiorId||selected);if(!a)return; + const conversation=conversationFor(a),capability=conversationCapability(a,api.getEndpoint(),{stale:feedStale}); + if(!capability.available||['sending','unconfirmed'].includes(conversation.phase)||!conversation.text.trim()||conversation.text.trim()===conversation.uncertainPayload)return; + const text=conversation.text.trim();if(text.length>MAX_MESSAGE_LENGTH)return; + conversation.requestId ||= crypto.randomUUID();conversation.payload=text;conversation.phase='sending';conversation.notice='Submitting your message…'; + const epoch=sourceKey,controller=new AbortController(),timeout=setTimeout(()=>controller.abort(),20000);renderPanel(selected); + try{ + const response=await fetch(capability.url,{method:'POST',credentials:'omit',redirect:'error',signal:controller.signal,headers:{'content-type':'application/json','x-cottagecode-request':'user-message'},body:JSON.stringify({taskId:a.taskId,message:text,requestId:conversation.requestId})}); + const result=await response.json(); + if(result.delivery==='unconfirmed')throw new Error('Delivery is unconfirmed. Check the task before sending this message again.'); + if(!response.ok){ + if(result.delivery!=='not_sent')throw new Error('Delivery is unconfirmed. Check the task before sending again.'); + conversation.phase='error';conversation.notice=String(result.error||'The source declined this message.');conversation.requestId=null;return; + } + if(!result.ok||!['submitted','accepted'].includes(result.delivery))throw new Error('The source did not confirm delivery. Check the task before sending again.'); + const label=result.delivery==='submitted'?'Submitted to agent terminal':'Accepted by task source'; + conversation.sent.push({text,timestamp:Date.now(),label});conversation.sent=conversation.sent.slice(-20); + conversation.text='';conversation.requestId=null;conversation.phase='sent';conversation.notice=label+'. Replies appear in the recorded activity when the source supplies them.'; + if(epoch===sourceKey)ensureActivity(a); + }catch(error){conversation.phase='unconfirmed';conversation.uncertainPayload=text;conversation.notice=error.name==='AbortError'?'Delivery is unconfirmed after a timeout. Check the task before sending again.':error.message||'Delivery is unconfirmed. Check the task before sending again.';} + finally{clearTimeout(timeout);if(epoch===sourceKey&&(selected===a.id||interiorId===a.id))renderPanel(selected);} + } function renderPanel(id){ selected=id; if(mode==='board'||mode==='scrapbook'){renderHistory();return true;} const a=byId(mode==='room'?interiorId:id);if(!a)return false; const pr=normalizePr(a.pr),stage=pr.stage,s=STAGES[stage],cache=cacheFor(a); let content=''; - if(tab==='request'){ + if(tab==='talk'){ + content=talkHtml(a,cache); + }else if(tab==='todos'){ + content='

    The task checklist

    '+todosHtml(a,cache); + }else if(tab==='request'){ content='

    Pinned request

    '+(a.originalAskSource==='session'?'First request recorded in this session. Current task boundaries are unavailable.':'Original request supplied by the task source.')+'

    '+esc(a.originalAsk||'The feed has not supplied the original request.')+'
    '; }else if(tab==='journal'){ const presentation=activityJournalPresentation(cache); @@ -271,9 +353,9 @@ export function createObservatory(api){ }else{ content='

    Today’s work

    '+esc(a.task&&a.task!=='-'?a.task:'Task description unavailable')+'

    '+esc(latestLine(a)||'No current activity supplied.')+'
    Task started
    '+esc(clock(a.taskStartedAt))+'
    Elapsed
    '+esc(elapsed(a))+'
    Session started
    '+esc(clock(a.sessionStartedAt))+'
    Last signal
    '+esc(clock(a.updatedAt))+'
    Model
    '+esc(a.model||'Unavailable')+'
    Branch
    '+esc(a.branch||'Unavailable')+'

    Pinned request

    '+esc(a.originalAsk?a.originalAsk.slice(0,230)+(a.originalAsk.length>230?'…':''):'Original request not supplied.')+'

    '+button('tab:request','Read the pinned note'); } - const nav=[['overview','Clock'],['request','Request'],['journal','Journal'],['review','PR desk'],['artifacts','Shelves']].map(([key,label])=>button('tab:'+key,label,'aria-pressed="'+(tab===key)+'"')).join(''); + const nav=[['overview','Clock'],['request','Request'],['journal','Journal'],['todos','To-do'],['review','PR desk'],['artifacts','Shelves']].map(([key,label])=>button('tab:'+key,label,'aria-pressed="'+(tab===key)+'"')).join(''); const header='
    '+esc(a.town)+' · '+(mode==='room'?'INSIDE':'COTTAGE')+'

    '+esc(a.name)+'

    '+esc(a.status)+''+s.symbol+' '+s.label+'
    '; - const actions='
    '+button(mode==='room'?'leave':'enter',mode==='room'?'Leave cottage ↗':'Enter cottage ↗')+button('follow',followId===a.id?'Leave bench':'Follow from bench')+handoffAction(a.handoffUrl)+'
    '; + const actions='
    '+button(mode==='room'?'leave':'enter',mode==='room'?'Leave cottage ↗':'Enter cottage ↗')+button('talk','Talk to '+esc(a.name),'aria-pressed="'+(tab==='talk')+'"')+button('follow',followId===a.id?'Leave bench':'Follow from bench')+handoffAction(a.handoffUrl)+'
    '; const worktree=a.worktreePath?'
    '+button('copy','Copy worktree path')+(a.worktreePath.startsWith('/')?'Open worktree':'')+'
    ':''; replacePanel(header+actions+''+content+worktree,(mode==='room'?interiorId:id)+'|'+(a.taskId||'')+':'+tab); return true; @@ -294,13 +376,21 @@ export function createObservatory(api){ } panel.addEventListener('input',e=>{ if(e.target.id==='replay-range'){replaying=false;replayEvents=history.events();replayIndex=Number(e.target.value);showReplay();} + if(e.target.id==='agent-message'){ + const a=byId(interiorId||selected);if(!a)return; + const conversation=conversationFor(a);conversation.text=e.target.value; + if(conversation.phase!=='sending'&&conversation.text.trim()!==conversation.payload){conversation.phase='idle';conversation.requestId=null;conversation.notice='';} + const send=panel.querySelector('[data-action="send-message"]');if(send)send.disabled=!conversationCapability(a,api.getEndpoint(),{stale:feedStale}).available||!conversation.text.trim()||['sending','unconfirmed'].includes(conversation.phase)||conversation.text.trim()===conversation.uncertainPayload; + } }); + panel.addEventListener('submit',e=>{if(e.target.id==='agent-conversation'){e.preventDefault();sendMessage();}}); panel.addEventListener('click',async e=>{ const action=e.target.closest('[data-action]')?.dataset.action;if(!action)return; if(action==='enter')enter(selected); else if(action==='leave')leave(); else if(action==='follow')follow(interiorId||selected); - else if(action.startsWith('tab:')){tab=action.slice(4);selectedObject=({request:'request',journal:'workbench',review:'review',artifacts:'shelf',overview:'clock'})[tab];renderPanel(selected);if(tab==='journal')ensureActivity(byId(interiorId||selected));} + else if(action==='talk')talk(interiorId||selected); + else if(action.startsWith('tab:')){tab=action.slice(4);selectedObject=({request:'request',journal:'workbench',review:'review',artifacts:'shelf',overview:'clock'})[tab];renderPanel(selected);if(['journal','todos'].includes(tab))ensureActivity(byId(interiorId||selected));} else if(action==='older')await ensureActivity(byId(interiorId||selected),{older:true}); else if(action==='copy'){try{await navigator.clipboard.writeText(byId(interiorId||selected).worktreePath);e.target.textContent='Copied';}catch{e.target.textContent='Copy unavailable';}} else if(action==='handoff'){const url=safeHttpsUrl(byId(interiorId||selected)?.handoffUrl);if(url)window.open(url,'_blank','noopener,noreferrer');} @@ -413,7 +503,7 @@ export function createObservatory(api){ const p=plotFor(replay.agentId);if(p){ctx.strokeStyle='#ffe296';ctx.lineWidth=3;ctx.strokeRect(p.x-5,p.y-12,64,91);ctx.font='8px "Silkscreen",monospace';ctx.fillStyle='#ffe296';ctx.fillText('RECORDED',p.x-5,p.y-17);} } if(followId)hint('On the bench with '+(byId(followId)?.name||'your agent')+' · Move or press Escape to leave'); - else if(mode==='town')hint('Arrow keys / WASD to walk · E at a door, bench or noticeboard · Click a cottage to inspect'); + else if(mode==='town')hint('Arrow keys / WASD to walk · Walk into doors · E to talk or use a bench · Click to inspect'); } function draw(time){ const dt=Math.min(.05,lastFrame?time-lastFrame:1/60);lastFrame=time; @@ -428,11 +518,11 @@ export function createObservatory(api){ const scale=Math.min(width/room.width,height/room.height),eased=1-Math.pow(1-transition,3); roomCtx.save();roomCtx.translate(width/2,height/2);roomCtx.scale(scale*(.83+.17*eased),scale*(.83+.17*eased));roomCtx.translate(-room.width/2,-room.height/2); roomCtx.globalAlpha=eased; - renderInterior(roomCtx,room,{time:time*1000,agent:byId(interiorId)||{},player:{...roomPlayer,walking:moving},selectedObject,reduce}); + renderInterior(roomCtx,room,{time:time*1000,agent:byId(interiorId)||{},player:{...roomPlayer,walking:moving},selectedObject,reduce,talking:talkingId===interiorId&&performance.now()o.interactable).sort((a,b)=>nearRect(roomPlayer,a)-nearRect(roomPlayer,b))[0]; - hint(near&&nearRect(roomPlayer,near)<30?'E · '+near.label+' / click any object · Escape to leave':'Walk around your host’s cottage · Click an object to inspect · Escape to leave'); + const near=room.objects.filter(o=>o.interactable&&o.id!=='exit').sort((a,b)=>nearRect(roomPlayer,a)-nearRect(roomPlayer,b))[0]; + hint(distance(roomPlayer,room.resident)<30?'E · Talk to '+(byId(interiorId)?.name||'your host')+' · Walk out through the door to leave':near&&nearRect(roomPlayer,near)<30?'E · '+near.label+' · Walk out through the door to leave':'Walk around your host’s cottage · E near your host to talk · Walk into the doorway to leave'); }else drawTown(api.ctx,time,dt,moving); if(replaying){replayTimer+=dt;if(replayTimer>1.8){replayTimer=0;replayIndex++;if(replayIndex>=replayEvents.length){replaying=false;replayIndex=replayEvents.length-1;renderHistory();}showReplay();}} } @@ -451,9 +541,10 @@ export function createObservatory(api){ if(p.agent.pr?.number){ctx.fillStyle='#253729';ctx.fillText('#'+p.agent.pr.number,x-2,y+26);} else if(stage==='none'){ctx.fillStyle='#253729';ctx.font='6px "Silkscreen",monospace';ctx.fillText('NO PR',x-4,y+26);} } - return {update,draw,renderPanel,handleClick,enter,leave,follow,focusCottage,drawDispatch,latestLine,visible, + return {update,draw,renderPanel,handleClick,enter,leave,follow,focusCottage,drawDispatch,latestLine,visible,talk, resident:a=>roomFor(a).resident,sound, + isTalking:id=>talkingId===id&&performance.now(){}); + if(!contextFactory&&!Constructor){enabled=false;return false;} + try{ + context ||= contextFactory?contextFactory():new Constructor(); + await context.resume(); + if(version===enableVersion&&context.state!=='running')enabled=false; + }catch{if(version===enableVersion)enabled=false;} + }else{ + // Suspended oscillators must not resume old syllables when sound returns. + stopChannel();last.clear(); + if(context)await context.suspend().catch(()=>{}); + } return enabled; } - function play(kind,{distance=0,pan=0}={}){ - const isAlert=['ready','blocked','done'].includes(kind); - if(!enabled||!context||context.state!=='running'||(isAlert?!alerts:!ambience)) return false; + function settings(kind,{distance=0,pan=0}={},cooldown=.8){ + const channel=['ready','blocked','done'].includes(kind)?'alerts':'ambience'; + if(!enabled||!context||context.state!=='running'||(channel==='alerts'?!alerts:!ambience))return null; + const attenuation=Number.isFinite(distance)?Math.max(0,1-Math.max(0,distance)/220):0; + if(!attenuation)return null; const now=context.currentTime; - if(now-(last.get(kind)??-10)<(kind==='step'?.18:.8))return false; - last.set(kind,now); - const volume=Math.max(0,1-distance/220)*.045; - if(!volume)return false; + if(now-(last.get(kind)??-Infinity)finish(voice);return n;}}; + } + function play(kind,options={}){ + const config=settings(kind,options,kind==='step'?.18:.8);if(!config)return false; + const {now,volume,pan,channel}=config; const notes={step:[120,.025,'triangle'],door:[180,.15,'triangle'],quack:[390,.12,'square'],splash:[720,.12,'sine'], chirp:[880,.09,'sine'],talk:[440,.05,'square'],ready:[660,.3,'sine'],blocked:[220,.25,'triangle'],done:[520,.22,'sine']}; const [frequency,duration,type]=notes[kind]||notes.chirp; - const oscillator=context.createOscillator(),gain=context.createGain(),panner=context.createStereoPanner(); - oscillator.type=type;oscillator.frequency.setValueAtTime(frequency,now); - oscillator.frequency.exponentialRampToValueAtTime(kind==='ready'?frequency*1.5:frequency*.55,now+duration); - gain.gain.setValueAtTime(volume,now);gain.gain.exponentialRampToValueAtTime(.0001,now+duration); - panner.pan.value=Math.max(-1,Math.min(1,pan)); - oscillator.connect(gain).connect(panner).connect(context.destination); - oscillator.start(now);oscillator.stop(now+duration+.02); - oscillator.onended=()=>{oscillator.disconnect();gain.disconnect();panner.disconnect();};return true; - } - return {enable,play,get enabled(){return enabled;},set ambience(v){ambience=!!v;},set alerts(v){alerts=!!v;}}; + const tracked=voiceFor(channel); + try{ + const oscillator=tracked.source(context.createOscillator()),gain=tracked.node(context.createGain()),panner=tracked.node(context.createStereoPanner()); + oscillator.type=type;oscillator.frequency.setValueAtTime(frequency,now); + oscillator.frequency.exponentialRampToValueAtTime(kind==='ready'?frequency*1.5:frequency*.55,now+duration); + gain.gain.setValueAtTime(volume,now);gain.gain.exponentialRampToValueAtTime(.0001,now+duration); + panner.pan.value=pan; + oscillator.connect(gain).connect(panner).connect(context.destination); + oscillator.start(now);oscillator.stop(now+duration+.02);last.set(kind,now);return true; + }catch{stop(tracked.voice);return false;} + } + function murmur({seed=0,distance=0,pan=0}={}){ + const config=settings('murmur',{distance,pan},1.5);if(!config)return false; + const {now,volume,channel}=config; + // The seed determines a character's little melodic phrase. These vowel-like + // tones are nonspeech: there is no text, recording, or hidden agent reasoning. + let state=2166136261; + for(const character of String(seed)){state=Math.imul(state^character.codePointAt(0),16777619)>>>0;} + const random=()=>{state=(Math.imul(state,1664525)+1013904223)>>>0;return state/4294967296;}; + const pitch=155+random()*115,count=5+Math.floor(random()*3); + const melody=[0,2,5,7,9,-2,4],vowels=[[350,900],[500,1250],[700,1550],[430,1900],[560,1650]]; + const tracked=voiceFor(channel); + try{ + const oscillator=tracked.source(context.createOscillator()); + const formantA=tracked.node(context.createBiquadFilter()),formantB=tracked.node(context.createBiquadFilter()); + const upperGain=tracked.node(context.createGain()),envelope=tracked.node(context.createGain()); + const softener=tracked.node(context.createBiquadFilter()),panner=tracked.node(context.createStereoPanner()); + oscillator.type='sawtooth'; + formantA.type='bandpass';formantA.Q.value=4; + formantB.type='bandpass';formantB.Q.value=5; + upperGain.gain.value=.4; + softener.type='lowpass';softener.frequency.value=2700;softener.Q.value=.6; + panner.pan.value=config.pan; + oscillator.connect(formantA).connect(envelope); + oscillator.connect(formantB).connect(upperGain).connect(envelope); + envelope.connect(softener).connect(panner).connect(context.destination); + envelope.gain.setValueAtTime(.0001,now); + let onset=now; + for(let i=0;i= 1577836800000 ? ms : null; +} +function hash(value) { + let n = 2166136261; + for (const char of value) n = Math.imul(n ^ char.charCodeAt(0), 16777619); + return (n >>> 0).toString(36); +} +function object(value) { + if (typeof value === "string" && value.length <= 128 * 1024) { + try { value = JSON.parse(value); } catch { return null; } + } + return value && typeof value === "object" && !Array.isArray(value) ? value : null; +} + +/** null means no trustworthy snapshot; {items:[]} is an explicit cleared list. */ +export function normalizeTodos(value, { source = "", updatedAt = null } = {}) { + const snapshot = Array.isArray(value) ? { items: value } : object(value); + if (!snapshot || !Array.isArray(snapshot.items)) return null; + const items = []; + const ids = new Map(); + let truncated = snapshot.items.length > MAX_TODOS || snapshot.truncated === true; + for (const item of snapshot.items.slice(0, MAX_TODOS)) { + if (!item || typeof item !== "object" || Array.isArray(item)) return null; + const body = text(item.text ?? item.content ?? item.step ?? item.title); + let status = text(item.status).toLowerCase(); + if (status === "done") status = "completed"; + if (!status && typeof item.completed === "boolean") status = item.completed ? "completed" : "pending"; + if (!body || !STATUSES.has(status)) return null; + const explicitId = text(item.id); + const base = explicitId && explicitId.length <= 160 ? explicitId : `todo-${hash(body)}`; + const count = (ids.get(base) || 0) + 1; + ids.set(base, count); + items.push({ id: count === 1 ? base : `${base}-${count}`, text: body.slice(0, MAX_TEXT), status }); + if (body.length > MAX_TEXT) truncated = true; + } + return { + items, + source: (text(source) || text(snapshot.source) || "feed").slice(0, 100), + updatedAt: time(updatedAt) ?? time(snapshot.updatedAt), + ...(snapshot.stale === true ? { stale: true } : {}), + ...(truncated ? { truncated: true } : {}), + }; +} + +export function todosFromTool(name, input, options = {}) { + const tool = text(name).split(/__|[.:/]/).at(-1)?.toLowerCase(); + const args = object(input); + if (!args) return null; + if (tool === "todowrite") return normalizeTodos(args.todos, { ...options, source: options.source || "transcript:TodoWrite" }); + if (tool === "update_plan") return normalizeTodos(args.plan, { ...options, source: options.source || "transcript:update_plan" }); + return null; +} + +/** Accept plan arrays and complete tool arguments, never flattened plan previews. */ +export function todosFromEvent(event, { source = "" } = {}) { + if (!event || typeof event !== "object" || ["thinking", "redacted_thinking"].includes(event.kind)) return null; + const updatedAt = event.timestamp ?? event.ts; + if (event.todos !== undefined) return normalizeTodos(event.todos, { source, updatedAt }); + if (["plan", "todo_list", "todos", "plan_update"].includes(event.kind)) { + const raw = event.items ?? event.plan; + if (raw !== undefined) return normalizeTodos(raw, { source: source || "timeline:plan", updatedAt }); + } + if (["tool", "tool_call", "tool_use"].includes(event.kind)) + return todosFromTool(event.tool || event.name || event.title, event.input ?? event.arguments ?? event.input_preview, { source, updatedAt }); + return null; +} + +/** Events are supplied in source order. Explicit empty snapshots supersede older work. */ +export function latestTodos(events, { source = "", initial = null } = {}) { + let latest = normalizeTodos(initial); + for (const event of events || []) { + const candidate = todosFromEvent(event, { source }); + if (!candidate) continue; + if (candidate.updatedAt !== null && latest?.updatedAt !== null && latest?.updatedAt > candidate.updatedAt) continue; + latest = candidate; + } + return latest; +} diff --git a/src/town.html b/src/town.html index 3710e2d..0ffb5b5 100644 --- a/src/town.html +++ b/src/town.html @@ -134,6 +134,25 @@ .journal-event:before{content:"";position:absolute;width:5px;height:5px;left:-3px;top:4px;background:#adc79a;border-radius:50%} .event-head{display:flex;gap:6px;justify-content:space-between;font-size:10px;color:#96a99e} .journal-event p{font:12px ui-monospace,monospace;line-height:1.55;margin:6px 0;white-space:pre-wrap;overflow-wrap:anywhere;color:#d0ddd4} + #agent-conversation{margin:14px 0;padding:12px;background:#1c2929;border:1px solid #596854;border-radius:3px} + #agent-conversation label{font-size:12px;color:#e4dbbd;display:block;margin-bottom:8px} + #agent-message{box-sizing:border-box;width:100%;resize:vertical;min-height:85px;max-height:300px;border:1px solid #637a6b;border-radius:3px;background:#263733;color:#eceddd;padding:9px;font:13px ui-sans-serif,system-ui;line-height:1.5} + #agent-message::placeholder{color:#a5b7ab} + #agent-message:focus-visible{outline:2px solid #f2d896;outline-offset:3px} + #message-status{font-size:12px;color:#e7d4a2;margin-bottom:0;overflow-wrap:anywhere} + #message-status:empty{display:none} + .todo-list,.sent-messages{list-style:none;padding:0;margin:12px 0;max-height:38vh;overflow:auto} + .todo-list li{display:flex;gap:10px;padding:10px 0;border-bottom:1px solid #43554b;font-size:12px;line-height:1.5;overflow-wrap:anywhere} + .todo-list small{display:block;font-size:10px;color:#b0c4b6;margin-bottom:2px} + .todo-mark{font-size:16px;color:#d5c38d;flex:none} + [data-todo-status="completed"] .todo-mark{color:#a7d398} + [data-todo-status="in_progress"] .todo-mark{color:#94c4e7} + [data-todo-status="cancelled"]{color:#a9b6b0} + .talk-todos{border-block:1px solid #43554b;padding:10px 0;margin:16px 0;font-size:12px} + .talk-todos summary{cursor:pointer;color:#dddbbf} + .sent-messages li{background:#354034;border-left:2px solid #bfb17d;padding:8px 11px;margin:8px 0;overflow-wrap:anywhere} + .sent-messages p{font-size:12px;white-space:pre-wrap;margin:0 0 6px} + .sent-messages small{font-size:10px;color:#b9c9b6} .pr-summary{border:1px solid var(--pr-color);border-radius:3px;padding:14px;color:var(--pr-color);margin-top:10px} .pr-summary p{font-size:12px;color:#c9d4cd;margin:8px 0} .review-reason{background:#4b4030;color:#f0d5a3!important;padding:10px;margin:10px 0} @@ -180,9 +199,9 @@

    CottageCode

    - +
    -

    Arrow keys / WASD to walk · E to interact · Click a cottage to inspect

    +

    Arrow keys / WASD to walk · Walk into doors · E to talk · Click to inspect

    smoke = working idle diff --git a/src/town.mjs b/src/town.mjs index 0a3fe94..5ccd50c 100644 --- a/src/town.mjs +++ b/src/town.mjs @@ -5,6 +5,7 @@ import { createObservatory } from "./observatory.mjs"; import { normalizeCottage } from "./feed-client.mjs"; import { blankFeedState, createLatestRefresh, readCurrentFeed, snapshotForEndpoint } from "./live-feed.mjs"; import { lettersOf } from "./occupancy.mjs"; +import { residentTargets } from "./interaction.mjs"; /* ======================================================================= @@ -157,6 +158,11 @@ const LINES = { offline:["- shut down cleanly"] }; const pick = a => a[Math.floor(Math.random()*a.length)]; +const demoTodos=(a,stamp)=>({source:'demo',updatedAt:stamp,items:[ + {id:'read',text:'Read the existing behavior and the pinned request',status:'completed'}, + {id:'change',text:a.task,status:['done','offline'].includes(a.status)?'completed':a.status==='idle'?'pending':'in_progress'}, + {id:'check',text:'Verify the result and leave a reviewable handoff',status:a.status==='done'?'completed':'pending'} +]}); const DEMO_META={source:"demo",relationships:[ {id:"hub-app",from:"HubTown",to:"AppTown",label:"Application API"}, @@ -205,6 +211,7 @@ const SIM = (() => { a.originalAsk="Please "+a.task+". Check the existing behavior, make the smallest useful change, and leave a clear result I can review."; a.taskStartedAt=a.startedAt;a.sessionStartedAt=a.startedAt-8*60000;a.updatedAt=now; a.endedAt=['done','offline'].includes(a.status)?Math.max(a.startedAt,now-20*60000):0; + a.todos=demoTodos(a,now); a.events=[ {id:a.id+":request",timestamp:a.startedAt,kind:"request",text:a.originalAsk}, {id:a.id+":read",timestamp:a.startedAt+12000,kind:"progress",text:"I’m reading the existing implementation and checking the task requirements."}, @@ -233,6 +240,7 @@ const SIM = (() => { if(demoBeat===5){ const parent=agents[0],stamp=Date.now(),id="demo-apprentice"; agents.push({...parent,id,name:"Pip",parent:parent.id,status:"working",occupancy:"live",endedAt:0,taskId:"demo-task-pip",task:"Check the webhook edge cases",originalAsk:"Please test the webhook edge cases while Bolt finishes the queue changes.",taskStartedAt:stamp,startedAt:stamp,sessionStartedAt:stamp,updatedAt:stamp,tokens:0,cost:0,events:[{id:id+":arrival",timestamp:stamp,kind:"request",text:"Please test the webhook edge cases while Bolt finishes the queue changes."}]}); + agents.at(-1).todos=demoTodos(agents.at(-1),stamp); } if(demoBeat%14===6){ const stamp=Date.now(),event={id:"demo-handoff-"+stamp,timestamp:stamp,kind:"handoff",from:"HubTown",to:"AppTown",agentId:"a0",name:"Bolt",text:"Demo handoff: the queue contract is ready for AppTown’s interface work."}; @@ -255,6 +263,7 @@ const SIM = (() => { if(s !== a.status){ a.status = s; a.lastLine = pick(LINES[s]); a.activity = pick(ACTIVITY[s]); a.endedAt=['done','offline'].includes(s)?Date.now():0; + a.todos=demoTodos(a,Date.now()); } } else if(Math.random() > 0.8){ a.lastLine = pick(LINES[a.status]); } const last=a.events.at(-1); @@ -272,6 +281,7 @@ const SIM = (() => { const a = agents.find(x=>x.id===id); if(!a) return; a.status = status; a.lastLine = pick(LINES[status]); a.activity = pick(ACTIVITY[status]); a.endedAt=['done','offline'].includes(status)?Date.now():0; + a.todos=demoTodos(a,Date.now()); if(status==="offline") a.task = "-"; }, setLive(v){ live = v; } @@ -1516,7 +1526,7 @@ function draw(){ const atBench = working && dist < 2; const step = reduce ? 0 : (moving ? (Math.floor(t*6 + p.x) % 2) : 0); const bob = (atBench && !reduce) ? (Math.floor(t*7) % 2) : 0; - if(observatory)renderResident(ctx,Math.round(a.x)+5,Math.round(a.y)+bob+14,observatory.resident(ag),{time:t*1000,walking:!!step,scale:1}); + if(observatory)renderResident(ctx,Math.round(a.x)+5,Math.round(a.y)+bob+14,observatory.resident(ag),{time:t*1000,walking:!!step,scale:1,talking:observatory.isTalking(ag.id),reduce}); else drawPerson(Math.round(a.x),Math.round(a.y)+bob,townStyle(ag).roof,step); if(atBench && !reduce) drawSparks(p.x+35, p.y+HOUSE_H+3, p.x); if(ag.status === "blocked" || ag.status === "done"){ @@ -1771,7 +1781,7 @@ document.getElementById("connect").onclick = ()=>{ if(ENDPOINT!==null){ activeFeedAbort?.abort();feedRevision++;clearFeedState(); } - ENDPOINT=null;stableLayout.reset();layoutSignature="";feedNote("Back on the demo townmap.");return refresh({latest:true}); + ENDPOINT=null;builtInDemo=true;stableLayout.reset();layoutSignature="";feedNote("Back on the demo townmap.");return refresh({latest:true}); } if(!isAllowedFeedUrl(v)){ feedNote("feed URL must be http(s). data: and other schemes are blocked.", "#e2504a"); @@ -1810,6 +1820,7 @@ const refresh=createLatestRefresh(async ()=>{ observatory=createObservatory({ canvas:cv,ctx,getAgents:()=>agents,getPlots:()=>plots,getEndpoint:()=>ENDPOINT,getSourceKey:()=>feedNamespace(ENDPOINT,builtInDemo), + getResidents:()=>residentTargets(actors,plots), getWorld:()=>({width:W,height:H,solids:sceneSolids,ponds:scenePonds,districts:sceneDistricts,roadX:VERT_ROAD,roadYs:HORZ_ROADS,jack:jackPlot,showSettled}), select(id){selectedId=id;renderPanel();},drawJack }); diff --git a/src/transcripts.mjs b/src/transcripts.mjs index 12fc4df..f572d79 100644 --- a/src/transcripts.mjs +++ b/src/transcripts.mjs @@ -3,6 +3,7 @@ import { open, stat } from "node:fs/promises"; import { createHash } from "node:crypto"; import { StringDecoder } from "node:string_decoder"; import { normalizeActivityEvent, timestampMs } from "./activity.mjs"; +import { normalizeTodos, todosFromTool } from "./todos.mjs"; // Existing estimated prices; these are not a billing source of truth. const PRICE = { @@ -17,7 +18,7 @@ export function blankSession(path = "") { path, id: "", slug: "", cwd: "", branch: "", model: "", version: "", entrypoint: "", firstTs: null, lastTs: null, taskId: null, taskStartedAt: null, originalAsk: "", originalAskSource: "session", originalAskTruncated: false, - pr: null, finalization: null, + pr: null, finalization: null, todos: null, tokens: 0, cost: 0, lastText: "", lastTool: "", lastSkill: "", turnOpen: false, endedAt: null, events: [], eventMap: new Map(), sidechains: new Map(), uuidRoot: new Map(), seen: new Set(), usageByMessage: new Map(), @@ -70,6 +71,18 @@ function addEvents(target, line, ts) { const incoming = []; const base = eventBase(line); const blocks = blocksOf(line.message?.content); + const suppliedTodos = normalizeTodos(line.todos, { source: "transcript:todos", updatedAt: ts }); + if (suppliedTodos) incoming.push({ id: `${base}:todos`, timestamp: ts, kind: "summary", text: "Task checklist updated", todos: suppliedTodos }); + const codexCall = line.type === "response_item" && line.payload?.type === "function_call" ? line.payload : null; + if (codexCall) { + const todos = todosFromTool(codexCall.name, codexCall.arguments, { source: "codex:update_plan", updatedAt: ts }); + if (todos) incoming.push({ id: `${base}:plan`, timestamp: ts, kind: "tool", text: "Update plan", todos }); + } + const codexItem = line.item || line.params?.item; + if (["item.completed", "item/completed"].includes(line.type || line.method) && codexItem?.type === "todo_list") { + const todos = normalizeTodos(codexItem.items ?? codexItem.todos, { source: "codex:todo_list", updatedAt: ts }); + if (todos) incoming.push({ id: `${base}:plan`, timestamp: ts, kind: "summary", text: "Task checklist updated", todos }); + } const request = genuineRequest(line); if (request) incoming.push({ id: `${base}:request`, timestamp: ts, kind: "request", text: request }); blocks.forEach((block, index) => { @@ -80,7 +93,8 @@ function addEvents(target, line, ts) { } else if (line.type === "assistant" && ["summary", "reasoning_summary"].includes(block.type)) { incoming.push({ ...event, kind: "summary", text: block.text || block.summary || "" }); } else if (line.type === "assistant" && block.type === "tool_use") { - incoming.push({ ...event, kind: "tool", text: toolLabel(block) }); + const todos = todosFromTool(block.name, block.input, { source: `claude-transcript:${block.name}`, updatedAt: ts }); + incoming.push({ ...event, kind: "tool", text: toolLabel(block), ...(todos ? { todos } : {}) }); } else if (line.type === "user" && block.type === "tool_result") { const result = textOf(block.content); incoming.push({ ...event, kind: "result", text: `${block.is_error ? "Tool failed" : "Tool result"}${result ? `: ${result}` : ""}` }); @@ -90,6 +104,7 @@ function addEvents(target, line, ts) { for (const raw of incoming) { const event = normalizeActivityEvent(raw); if (!event) continue; + if (event.todos && (!target.todos?.updatedAt || !event.todos.updatedAt || event.todos.updatedAt >= target.todos.updatedAt)) target.todos = event.todos; const old = target.eventMap.get(event.id); if (old) Object.assign(old, event); else { target.events.push(event); target.eventMap.set(event.id, event); } @@ -138,6 +153,7 @@ function trackPrMetadata(target, line) { export function applyLine(session, line) { if (!line || typeof line !== "object") return; + if (line.isSidechain && !line.uuid) return; // Only exact record duplicates are skipped; streamed revisions may share message ids. const fingerprint = createHash("sha256").update(JSON.stringify(line)).digest("hex"); if (session.seen.has(fingerprint)) return; @@ -177,6 +193,7 @@ export function applyLine(session, line) { target.endedAt = null; target.pr = null; target.finalization = null; + target.todos = null; target.tokens = 0; target.cost = 0; target.usageByMessage.clear(); diff --git a/test/activity.test.mjs b/test/activity.test.mjs index 53fc626..e5d6646 100644 --- a/test/activity.test.mjs +++ b/test/activity.test.mjs @@ -79,6 +79,51 @@ test("an explicit task boundary resets task-scoped usage accounting", () => { assert.equal(session.tokens, 5); }); +test("explicit todo snapshots stay isolated by task and child, and empty means cleared", () => { + const session = blankSession(); + const todo = (content, status = "pending") => ({ type: "tool_use", name: "TodoWrite", input: { todos: [{ content, status }] } }); + applyLine(session, line("assistant", [todo("Parent work", "in_progress")], 1, { taskId: "parent-task" })); + assert.equal(session.todos.items[0].text, "Parent work"); + assert.equal(session.todos.source, "claude-transcript:TodoWrite"); + assert.equal(session.todos.updatedAt, start + 1000); + applyLine(session, line("assistant", [todo("Child work")], 2, { isSidechain: true, uuid: "child-todos" })); + assert.equal(session.sidechains.get("child-todos").todos.items[0].text, "Child work"); + assert.equal(session.todos.items[0].text, "Parent work"); + applyLine(session, line("assistant", [todo("Unidentified child")], 3, { isSidechain: true, uuid: undefined })); + assert.equal(session.todos.items[0].text, "Parent work"); + applyLine(session, line("assistant", [{ type: "tool_use", name: "TodoWrite", input: { todos: [] } }], 4)); + assert.deepEqual(session.todos.items, []); + assert.deepEqual(session.events.at(-1).todos.items, []); + applyLine(session, line("user", "A new explicit task", 5, { taskId: "new-task" })); + assert.equal(session.todos, null); + applyLine(session, line("assistant", "- [x] Everything is done", 6)); + assert.equal(session.todos, null); +}); + +test("Codex update_plan and completed todo_list records preserve structured task state", () => { + const session = blankSession(); + applyLine(session, { type: "response_item", timestamp: new Date(start).toISOString(), sessionId: "codex-session", payload: { + type: "function_call", name: "update_plan", arguments: JSON.stringify({ plan: [{ step: "Inspect the handler", status: "in_progress" }] }), + } }); + const id = session.todos.items[0].id; + assert.equal(session.todos.items[0].status, "in_progress"); + applyLine(session, { type: "item.completed", timestamp: new Date(start + 1000).toISOString(), item: { + id: "todo-update", type: "todo_list", items: [{ text: "Inspect the handler", completed: true }], + } }); + assert.equal(session.todos.items[0].id, id); + assert.equal(session.todos.items[0].status, "completed"); + assert.equal(session.todos.source, "codex:todo_list"); +}); + +test("activity preserves structured plan arrays without parsing flattened checklist prose", () => { + const structured = normalizeActivityEvent({ id: "plan", kind: "plan", items: [{ step: "Run tests", status: "pending" }], ts: start }); + assert.equal(structured.todos.items[0].text, "Run tests"); + assert.equal(structured.timestamp, start); + assert.equal(normalizeActivityEvent({ id: "preview", kind: "plan", detail: "Run tests (completed)" }).todos, undefined); + const tool = normalizeActivityEvent({ id: "tool-plan", kind: "tool_call", tool: "TodoWrite", title: "TodoWrite", input_preview: JSON.stringify({ todos: [{ content: "Run tests", status: "completed" }] }), ts: start }); + assert.equal(tool.todos.items[0].status, "completed"); +}); + test("public activity drops private thought blocks and retains explicit summaries", () => { const session = blankSession(); const record = line("assistant", [ @@ -190,6 +235,7 @@ test("Hub timeline forwards only safe records, supports increments, and retains const timeline = createHubTimelineReader({ baseUrl: "http://hub.local/v1", token: "server-only-test-token", now: () => clock, fetchFn: async (url, options) => { + if (url.pathname.endsWith("/todo")) return { ok: true, json: async () => ({ list: null }) }; calls++; assert.equal(url.pathname, "/v1/tasks/agent-one/timeline"); assert.equal(options.headers.authorization, "Bearer server-only-test-token"); @@ -216,6 +262,110 @@ test("Hub timeline forwards only safe records, supports increments, and retains assert.doesNotMatch(JSON.stringify(failed), /server-only-test-token|internal thought/); }); +test("Hub structured plan snapshots remain available when an incremental page has no new events", async () => { + const timeline = createHubTimelineReader({ baseUrl: "http://hub.local", now: () => start, + fetchFn: async url => ({ ok: true, json: async () => url.pathname.endsWith("/todo") ? { list: null } : { + source: "codex", events: [{ id: "plan-one", kind: "plan", items: [{ step: "Run regression tests", status: "in_progress" }], ts: start }], has_more: false, + } }), + }); + const first = await timeline.read("one"); + assert.equal(first.todos.items[0].status, "in_progress"); + assert.equal(first.todos.source, "hub:codex"); + const after = await timeline.read("one", { after: first.cursor }); + assert.deepEqual(after.events, []); + assert.equal(after.todos.items[0].text, "Run regression tests"); +}); + +test("a dated timeline checklist remains preferred to an undated todo checkpoint", async () => { + let clock = start; + let state = "dated"; + const timeline = createHubTimelineReader({ baseUrl: "http://hub.local", now: () => clock, + fetchFn: async url => { + if (url.pathname.endsWith("/todo")) return { ok: true, json: async () => ({ + list: { items: [{ title: state === "dated" ? "Undated checkpoint" : "Undated replacement checkpoint", status: "pending" }] }, + }) }; + return { ok: true, json: async () => ({ source: "codex", has_more: false, events: [{ + id: `plan-${state}`, kind: "plan", items: [{ step: state === "dated" ? "Dated timeline plan" : "Undated timeline plan", status: "in_progress" }], + ...(state === "dated" ? { ts: start } : {}), + }] }) }; + }, + }); + + const first = await timeline.read("one"); + assert.equal(first.todos.items[0].text, "Dated timeline plan"); + clock += 2_000; + state = "undated"; + const second = await timeline.read("one"); + assert.equal(second.todos.items[0].text, "Undated replacement checkpoint"); +}); + +test("Hub dedicated todo snapshots preserve source time, explicit empty, and stale fallback", async () => { + let clock = start; + let state = "populated"; + const timeline = createHubTimelineReader({ baseUrl: "http://hub.local", now: () => clock, + fetchFn: async url => { + if (url.pathname.endsWith("/todo")) { + if (state === "error") throw new Error("temporary failure"); + return { ok: true, json: async () => ({ list: { items: state === "empty" ? [] : [{ id: "one", title: "Check audio", status: "done" }], updatedAt: start - 5000 }, createdAt: clock }) }; + } + return { ok: true, json: async () => ({ source: "codex", events: [{ id: "flat-plan", kind: "plan", detail: "Old flattened plan (pending)", ts: start - 10000 }], has_more: false }) }; + }, + }); + const first = await timeline.read("one"); + assert.equal(first.todos.source, "hub:todo"); + assert.equal(first.todos.updatedAt, start); + assert.equal(first.todos.items[0].status, "completed"); + clock += 2000; state = "error"; + const stale = await timeline.read("one"); + assert.equal(stale.todos.stale, true); + assert.equal(stale.todos.updatedAt, start); + assert.equal(stale.stale, undefined, "an optional todo failure does not mark a healthy activity stream stale"); + clock += 2000; state = "empty"; + const cleared = await timeline.read("one"); + assert.deepEqual(cleared.todos.items, []); + assert.equal(cleared.todos.updatedAt, clock); + assert.equal(cleared.todos.stale, undefined); +}); + +test("dedicated Hub todo reads are independently cached and never fetch a timeline", async () => { + let clock = start; + let state = "populated"; + const calls = []; + const timeline = createHubTimelineReader({ baseUrl: "http://hub.local/v1", token: "test-server-token", now: () => clock, + fetchFn: async (url, options) => { + calls.push(url.pathname); + assert.equal(options.headers.authorization, "Bearer test-server-token"); + assert.equal(options.redirect, "error"); + assert.equal(options.method, undefined, "the dedicated read uses GET"); + if (state === "error") throw new Error("test-server-token must stay private"); + return { ok: true, json: async () => ({ list: { items: state === "empty" ? [] : [{ title: "Check the work", status: "pending" }] }, createdAt: clock }) }; + }, + }); + const [first, concurrent] = await Promise.all([timeline.readTodos("one"), timeline.readTodos("one")]); + assert.deepEqual(concurrent, first); + assert.equal(first.source, "hub:todo"); + assert.equal(first.updatedAt, start); + assert.deepEqual(calls, ["/v1/tasks/one/todo"]); + await timeline.readTodos("one"); + assert.equal(calls.length, 1); + clock += 2000; state = "error"; + const stale = await timeline.readTodos("one"); + assert.equal(stale.stale, true); + assert.equal(stale.updatedAt, start); + assert.doesNotMatch(JSON.stringify(stale), /test-server-token/); + assert.equal(await timeline.readTodos("two"), null, "another task cannot inherit the cached checklist"); + clock += 2000; state = "empty"; + const cleared = await timeline.readTodos("one"); + assert.deepEqual(cleared.items, []); + assert.equal(cleared.stale, undefined); + assert.equal(cleared.updatedAt, clock); + const count = calls.length; + for (const taskId of [undefined, null, "", " ", "..", ".", 42, {}, "bad\nidentity"]) + assert.equal(await timeline.readTodos(taskId), null); + assert.equal(calls.length, count); + assert.equal(await createHubTimelineReader({ baseUrl: "", fetchFn: () => assert.fail("Unconfigured reads cannot fetch") }).readTodos("one"), null); +}); + test("unconfigured and unsupported Hub timelines have explicit unavailable results", async () => { const missing = await createHubTimelineReader({ baseUrl: "" }).read("one"); assert.equal(missing.source, "none"); diff --git a/test/conversation.test.mjs b/test/conversation.test.mjs new file mode 100644 index 0000000..b9d4611 --- /dev/null +++ b/test/conversation.test.mjs @@ -0,0 +1,13 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {conversationCapability} from '../src/conversation.mjs'; +const now=Date.now(),endpoint='http://127.0.0.1:8787/agents'; +const agent={taskId:'task-1',conversation:{available:true,mode:'redirect',checkedAt:now,messageUrl:'/agents/one/messages'}}; +test('messaging requires explicit, fresh task capability on the connected feed origin',()=>{ + assert.equal(conversationCapability(agent,endpoint,{now}).url,'http://127.0.0.1:8787/agents/one/messages'); + for(const overrides of [{available:false},{mode:'resume'},{checkedAt:now-121000},{checkedAt:now+60000},{checkedAt:null},{messageUrl:undefined},{messageUrl:''},{messageUrl:'https://other.example/messages'},{messageUrl:'http://user:password@127.0.0.1:8787/messages'},{messageUrl:'javascript:alert(1)'}]) + assert.equal(conversationCapability({...agent,conversation:{...agent.conversation,...overrides}},endpoint,{now}).available,false); + assert.equal(conversationCapability(agent,endpoint,{stale:true,now}).available,false); + assert.equal(conversationCapability({...agent,taskId:null},endpoint,{now}).available,false); + assert.equal(conversationCapability({taskId:'observed'},endpoint,{now}).available,false); +}); diff --git a/test/feed.test.mjs b/test/feed.test.mjs index bb1523f..18be1b8 100644 --- a/test/feed.test.mjs +++ b/test/feed.test.mjs @@ -34,6 +34,29 @@ test("feed cottage request and task/session clocks remain distinct", () => { assert.equal(unknown.status, "offline"); }); +test("feed and incremental activity expose the same explicit latest todo snapshot", async () => { + const session = parseTranscript(JSON.stringify({ type: "assistant", sessionId: "todo-session", timestamp: new Date(now).toISOString(), message: { + content: [{ type: "tool_use", name: "TodoWrite", input: { todos: [{ content: "Run tests", status: "in_progress" }] } }], + } })); + let failed = false; + const feed = createFeed({ ...feedOptions, scanClaude: async () => { + if (failed) throw new Error("Temporary scan failure"); + return { ok: true, agents: toAgents([session], { now }), sessions: [session] }; + } }); + await feed.scan(); + assert.equal(feed.snapshot().agents[0].todos.items[0].text, "Run tests"); + const journal = await feed.getActivity("todo-session"); + const incremental = await feed.getActivity("todo-session", { after: journal.cursor }); + assert.deepEqual(incremental.events, []); + assert.deepEqual(incremental.todos, feed.snapshot().agents[0].todos); + failed = true; + await feed.scan(); + assert.equal(feed.snapshot().agents[0].todos.stale, true); + failed = false; + await feed.scan(); + assert.equal(feed.snapshot().agents[0].todos.stale, undefined); +}); + test("transcript PRs remain unknown until explicit identity or absence evidence is available", () => { const [unknown] = toAgents([sampleSession()], { now }); assert.equal(unknown.pr.state, "unknown"); @@ -87,6 +110,26 @@ test("Hub logical tasks expose genuine original requests and durable PR receipts assert.equal(context.finalization, undefined, "mapping must not mutate the supplied context"); }); +test("a dated context todo snapshot stays ahead of an undated checkpoint", () => { + const agent = toCottage({ + id: "hub-run", record_kind: "logical_task", status: "running", task: "Current task", + context: { todos: { items: [{ id: "context", text: "Dated context checklist", status: "in_progress" }], updatedAt: now } }, + todo_snapshot: JSON.stringify({ items: [{ id: "checkpoint", text: "Undated checkpoint checklist", status: "pending" }] }), + todo_updated_at: "not a timestamp", + }, now); + + assert.equal(agent.todos.items[0].text, "Dated context checklist"); + assert.equal(agent.todos.updatedAt, now); + + const undated = toCottage({ + id: "hub-run-undated", record_kind: "logical_task", status: "running", task: "Current task", + context: { todos: { items: [{ id: "context", text: "Undated context checklist", status: "in_progress" }] } }, + todo_snapshot: JSON.stringify({ items: [{ id: "checkpoint", text: "Undated checkpoint checklist", status: "pending" }] }), + todo_updated_at: "not a timestamp", + }, now); + assert.equal(undated.todos.items[0].text, "Undated checkpoint checklist"); +}); + test("Hub external sessions never treat an assistant status label as the original ask", () => { const agent = toCottage({ id: "observed", record_kind: "external_session", task: "Latest assistant response", started_at: "2026-09-14 10:00:00", context: {}, status: "running" }, now); assert.equal(agent.originalAsk, ""); @@ -98,6 +141,58 @@ test("Hub external sessions never treat an assistant status label as the origina assert.equal(missing.taskStartedAt, null); }); +test("Hub conversation targets expose factual identity and transport without inventing support", () => { + const base = { id: "hub-run", record_kind: "logical_task", status: "running", context: { lifecycle: { execution: { sessionMode: "tmux", supportsRedirection: true, tmuxSession: "private-runtime-session" } } } }; + assert.deepEqual(toCottage(base, now).conversationTarget, { taskId: "hub-run", taskStatus: "running", recordKind: "logical_task", transport: "tmux", supportsRedirection: true }); + const direct = toCottage({ ...base, context: { lifecycle: { execution: { sessionMode: "direct", supportsRedirection: false } } } }, now); + assert.equal(direct.conversationTarget.transport, "direct"); + assert.equal(direct.conversationTarget.supportsRedirection, false); + const unknown = toCottage({ id: "observed-session", status: "completed", context: {} }, now); + assert.deepEqual(unknown.conversationTarget, { taskId: "observed-session", taskStatus: "completed", recordKind: "unknown", transport: "unknown", supportsRedirection: null }); +}); + +test("local session todos from a previous logical task do not leak into a newer Hub task", async () => { + const local = { ...sampleSession(), todos: { items: [{ id: "old", text: "Previous task", status: "completed" }], source: "fixture", updatedAt: now - 3600000 } }; + const hubAgent = toCottage({ id: "new-run", record_kind: "logical_task", session_id: "session-1", started_at: now - 1000, status: "running", task: "New task", context: {} }, now); + const feed = createFeed({ ...feedOptions, + scanClaude: async () => ({ ok: true, agents: toAgents([local], { now }), sessions: [local] }), + readHub: () => ({ ...emptyHub(), agents: [hubAgent], keys: new Set(["session-1"]), links: new Map([["new-run", new Set(["session-1"])]]) }), + }); + await feed.scan(); + assert.equal(feed.snapshot().agents[0].todos, null); +}); + +test("a mismatched explicit local task ID cannot donate todos by timestamp", async () => { + const local = { ...sampleSession(), taskId: "local-run", todos: { items: [{ id: "wrong-task", text: "Do not show this", status: "pending" }], source: "fixture", updatedAt: now } }; + const hubAgent = toCottage({ id: "hub-run", record_kind: "logical_task", session_id: "session-1", started_at: now - 1000, status: "running", task: "Current Hub task", context: {} }, now); + const feed = createFeed({ ...feedOptions, + scanClaude: async () => ({ ok: true, agents: toAgents([local], { now }), sessions: [local] }), + readHub: () => ({ ...emptyHub(), agents: [hubAgent], keys: new Set(["session-1"]), links: new Map([["hub-run", new Set(["session-1"])]] ) }), + }); + await feed.scan(); + assert.equal(feed.snapshot().agents[0].todos, null); +}); + +test("a dated Hub todo snapshot remains preferred over an undated matching local snapshot", async () => { + const local = { + ...sampleSession(), taskId: "hub-run", + todos: { items: [{ id: "local", text: "Undated local checklist", status: "pending" }], source: "fixture" }, + }; + const hubAgent = toCottage({ + id: "hub-run", record_kind: "logical_task", session_id: "session-1", started_at: now - 1_000, + status: "running", task: "Current Hub task", context: {}, + todo_snapshot: JSON.stringify({ items: [{ id: "hub", text: "Dated Hub checklist", status: "in_progress" }] }), + todo_updated_at: new Date(now).toISOString(), + }, now); + const feed = createFeed({ ...feedOptions, + scanClaude: async () => ({ ok: true, agents: toAgents([local], { now }), sessions: [local] }), + readHub: () => ({ ...emptyHub(), agents: [hubAgent], keys: new Set(["session-1"]), links: new Map([["hub-run", new Set(["session-1"])]] ) }), + }); + + await feed.scan(); + assert.equal(feed.snapshot().agents[0].todos.items[0].text, "Dated Hub checklist"); +}); + test("scan failures retain the last live snapshot and mark it stale, then recover", async () => { let failing = false; const session = sampleSession(); @@ -183,6 +278,121 @@ test("configured Hub timeline is used when a local transcript is unavailable", a assert.equal(await feed.getActivity("missing"), null); }); +test("remote todo observations survive subsequent feed polls and explicit clearing", async () => { + let items = [{ id: "one", text: "Inspect results", status: "pending" }]; + let clock = now; + const feed = createFeed({ ...feedOptions, + scanClaude: async () => ({ ok: true, agents: [], sessions: [] }), + readHub: () => ({ ...emptyHub(), agents: [{ id: "hub-run", taskId: "hub-run", name: "Hub", source: "hub", status: "working", todos: null }] }), + timeline: { configured: true, read: async () => ({ events: [], source: "hub:codex", todos: { items, source: "hub:todo", updatedAt: clock }, hasMore: false, cursor: null }) }, + }); + await feed.scan(); + assert.equal(feed.snapshot().agents[0].todos, null); + await feed.getActivity("hub-run"); + await feed.scan(); + assert.equal(feed.snapshot().agents[0].todos.items[0].text, "Inspect results"); + items = []; clock++; + await feed.getActivity("hub-run"); + await feed.scan(); + assert.deepEqual(feed.snapshot().agents[0].todos.items, []); +}); + +test("dated remote todo observations survive an undated Hub scan", async () => { + const dated = { items: [{ id: "dated", text: "Dated remote checklist", status: "in_progress" }], source: "hub:todo", updatedAt: now }; + let supplied = null; + const feed = createFeed({ ...feedOptions, + scanClaude: async () => ({ ok: true, agents: [], sessions: [] }), + readHub: () => ({ ...emptyHub(), agents: [{ id: "hub-run", taskId: "hub-run", name: "Hub", source: "hub", status: "working", todos: supplied }] }), + timeline: { configured: true, read: async () => ({ events: [], source: "hub:codex", todos: dated, hasMore: false, cursor: null }) }, + }); + + await feed.scan(); + await feed.getActivity("hub-run"); + supplied = { items: [{ id: "undated", text: "Undated Hub checklist", status: "pending" }], source: "hub:context" }; + await feed.scan(); + assert.equal(feed.snapshot().agents[0].todos.items[0].text, "Dated remote checklist"); + + supplied = { items: [], source: "hub:context", updatedAt: now + 1_000 }; + await feed.scan(); + assert.deepEqual(feed.snapshot().agents[0].todos.items, [], "a newer explicit empty snapshot remains a valid clear"); +}); + +test("undated activity todos cannot replace a dated cottage checklist", async () => { + const dated = { items: [{ id: "dated", text: "Dated cottage checklist", status: "in_progress" }], source: "hub:context", updatedAt: now }; + let incoming = { items: [{ id: "undated", text: "Undated activity checklist", status: "pending" }], source: "hub:todo" }; + const feed = createFeed({ ...feedOptions, + scanClaude: async () => ({ ok: true, agents: [], sessions: [] }), + readHub: () => ({ ...emptyHub(), agents: [{ id: "hub-run", taskId: "hub-run", name: "Hub", source: "hub", status: "working", todos: dated }] }), + timeline: { configured: true, read: async () => ({ events: [], source: "hub:codex", todos: incoming, hasMore: false, cursor: null }) }, + }); + + await feed.scan(); + const first = await feed.getActivity("hub-run"); + assert.equal(first.todos.items[0].text, "Dated cottage checklist"); + assert.equal(feed.snapshot().agents[0].todos.items[0].text, "Dated cottage checklist"); + + incoming = { items: [], source: "hub:todo", updatedAt: now + 1_000 }; + const cleared = await feed.getActivity("hub-run"); + assert.deepEqual(cleared.todos.items, [], "a newer explicit empty activity result clears the checklist"); + assert.deepEqual(feed.snapshot().agents[0].todos.items, []); +}); + +test("Hub cottages with local journals refresh dedicated todos without reading the remote timeline", async () => { + const session = { ...sampleSession(), taskId: "hub-run", taskStartedAt: now - 20_000 }; + const hubAgent = toCottage({ id: "hub-run", record_kind: "logical_task", session_id: "session-1", started_at: now - 20000, status: "running", task: "Current task", context: {} }, now); + let todos = { items: [{ id: "one", text: "Inspect results", status: "pending" }], source: "hub:todo", updatedAt: now }; + const calls = []; + const feed = createFeed({ ...feedOptions, + scanClaude: async () => ({ ok: true, agents: toAgents([session], { now }), sessions: [session] }), + readHub: () => ({ ...emptyHub(), agents: [hubAgent], keys: new Set(["session-1"]), links: new Map([["hub-run", new Set(["session-1"])]]) }), + timeline: { configured: true, read: () => assert.fail("Local journals must not fetch a full remote timeline"), readTodos: async id => { calls.push(id); return todos; } }, + }); + await feed.scan(); + assert.equal(feed.snapshot().agents[0].todos, null); + const journal = await feed.getActivity("hub-run"); + assert.equal(journal.source, "claude-transcript"); + assert.deepEqual(journal.events.map(event => event.kind), ["request", "progress"]); + assert.equal(journal.todos.items[0].text, "Inspect results"); + assert.deepEqual(feed.snapshot().agents[0].todos, journal.todos); + await feed.scan(); + assert.deepEqual(feed.snapshot().agents[0].todos, journal.todos); + todos = { ...todos, stale: true }; + const staleChecklist = await feed.getActivity("hub-run", { after: journal.cursor }); + assert.deepEqual(staleChecklist.events, []); + assert.equal(staleChecklist.todos.stale, true); + assert.equal(staleChecklist.stale, undefined, "todo failures do not mark the local journal stale"); + todos = { items: [], source: "hub:todo", updatedAt: now + 1000 }; + const cleared = await feed.getActivity("hub-run", { after: journal.cursor }); + assert.deepEqual(cleared.todos.items, []); + assert.equal(cleared.todos.stale, undefined); + await feed.scan(); + assert.deepEqual(feed.snapshot().agents[0].todos.items, []); + todos = { items: [{ id: "old", text: "Earlier work", status: "pending" }], source: "hub:todo", updatedAt: now - 1000 }; + assert.deepEqual((await feed.getActivity("hub-run")).todos.items, [], "older remote snapshots cannot undo a later clear"); + assert.deepEqual(calls, ["hub-run", "hub-run", "hub-run", "hub-run"]); +}); + +test("pending todo reads only update the matching cottage task and session identity", async () => { + const session = { ...sampleSession(), taskId: "hub-run", taskStartedAt: now - 20_000 }; + let hubAgent = toCottage({ id: "hub-run", record_kind: "logical_task", session_id: "session-1", started_at: now - 20000, status: "running", task: "Current task", context: {} }, now); + let release; + const pending = new Promise(resolve => { release = resolve; }); + const feed = createFeed({ ...feedOptions, + scanClaude: async () => ({ ok: true, agents: toAgents([session], { now }), sessions: [session] }), + readHub: () => ({ ...emptyHub(), agents: [hubAgent], keys: new Set(["session-1"]), links: new Map([["hub-run", new Set(["session-1"])]]) }), + timeline: { configured: true, read: () => assert.fail("Local journals have priority"), readTodos: async () => pending }, + }); + await feed.scan(); + const request = feed.getActivity("hub-run"); + await feed.scan(); + release({ items: [{ text: "Task one work", status: "pending" }], source: "hub:todo", updatedAt: now }); + await request; + assert.equal(feed.snapshot().agents[0].todos.items[0].text, "Task one work", "a scan during the read must not lose the current snapshot update"); + hubAgent = { ...hubAgent, taskId: "task-two", sessionId: "session-two" }; + await feed.scan(); + assert.equal(feed.snapshot().agents[0].todos, null, "the persisted snapshot cannot cross task/session identities"); +}); + test("remote repository lookup is exact, read-only, and cached", async () => { assert.equal(repoFromRemote("git@github.com:owner/project.git"), "owner/project"); assert.equal(repoFromRemote("https://github.com/owner/project.git"), "owner/project"); @@ -319,6 +529,14 @@ test("Hub database adapter accepts older schemas without optional task columns", assert.equal(result.agents[0].originalAsk, "Original request"); assert.equal(result.agents[0].taskStartedAt, null); assert.equal(result.links.get("old-task").has("old-task"), true); + const writable = new DatabaseSync(path); + writable.exec("CREATE TABLE agent_checkpoints (id INTEGER PRIMARY KEY, run_id TEXT, kind TEXT, data TEXT, created_at TEXT)"); + writable.prepare("INSERT INTO agent_checkpoints(run_id,kind,data,created_at) VALUES (?,?,?,?)").run("old-task", "todo", JSON.stringify({ list: { items: [{ id: "todo-1", title: "Check the result", status: "done" }], updatedAt: now - 10000 } }), new Date(now).toISOString()); + writable.close(); + const withTodos = readHubAgents({ dbPath: path }).agents[0].todos; + assert.equal(withTodos.items[0].status, "completed"); + assert.equal(withTodos.source, "hub:checkpoint"); + assert.equal(withTodos.updatedAt, now); const retainedPr = result.agents.find(agent => agent.id === "historic-pr"); assert.equal(retainedPr.pr.number, 72); assert.equal(retainedPr.pr.url, "https://github.com/owner/repo/pull/72"); diff --git a/test/interaction.test.mjs b/test/interaction.test.mjs new file mode 100644 index 0000000..cdb4e87 --- /dev/null +++ b/test/interaction.test.mjs @@ -0,0 +1,45 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {cottageDoors, crossedDoor, residentTargets} from '../src/interaction.mjs'; + +const plots = [{x:100,y:200,agent:{id:'host'},kids:[{x:160,y:220,agent:{id:'kid'}}]}]; +test('walking into the front threshold enters cottages and apprentice sheds', () => { + const doors = cottageDoors(plots); + assert.equal(crossedDoor({x:127,y:276},{x:127,y:268},doors)?.id,'host'); + assert.equal(crossedDoor({x:168,y:242},{x:168,y:239},doors)?.id,'kid'); + assert.equal(crossedDoor({x:127,y:276},{x:127,y:220},doors)?.id,'host','a long frame cannot skip a door'); + assert.equal(crossedDoor({x:127,y:276},{x:127,y:268},cottageDoors(plots,a=>a.id==='kid')),null); +}); +test('doors ignore sideways movement, walking away, and crossings beyond the door jamb', () => { + const doors = cottageDoors(plots); + for (const [from,to] of [ + [{x:110,y:276},{x:140,y:276}], + [{x:127,y:268},{x:127,y:276}], + [{x:145,y:276},{x:145,y:268}], + [{x:127,y:260},{x:127,y:250}], + ]) assert.equal(crossedDoor(from,to,doors),null); + assert.equal(crossedDoor({x:100,y:280},{x:160,y:260},doors)?.id,'host','diagonal entry uses the crossing point'); +}); +test('the interior exit needs outward movement, preventing immediate reentry loops', () => { + const exit = [{id:'exit',x:120,y:157,width:26}]; + assert.equal(crossedDoor({x:120,y:154},{x:120,y:159},exit,'out')?.id,'exit'); + assert.equal(crossedDoor({x:120,y:154},{x:120,y:150},exit,'out'),null,'walking inward from spawn stays indoors'); + assert.equal(crossedDoor({x:100,y:154},{x:100,y:159},exit,'out'),null); + assert.equal(crossedDoor({x:127,y:276},{x:127,y:284},cottageDoors(plots)),null,'walking outward after exit stays outside'); +}); +test('only actors with a currently rendered cottage remain conversation targets', () => { + const actors=new Map([ + ['shown',{x:10,y:20,indoors:false}], + ['settled',{x:30,y:40,indoors:false}], + ['hidden',{x:50,y:60,indoors:false}], + ['unrendered',{x:70,y:80,indoors:false}], + ['inside',{x:90,y:100,indoors:true}], + ]); + const plots=[ + {agent:{id:'shown'}}, + {agent:{id:'hidden'},hidden:true}, + {agent:{id:'unrendered'},rendered:false}, + {agent:{id:'inside'}}, + ]; + assert.deepEqual(residentTargets(actors,plots),[{id:'shown',x:15,y:34}]); +}); diff --git a/test/interiors.test.mjs b/test/interiors.test.mjs index 2433287..353fdde 100644 --- a/test/interiors.test.mjs +++ b/test/interiors.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { createInterior, isWalkable, renderInterior, renderResident } from '../src/interiors.mjs'; +import { createInterior, isWalkable, renderInterior, renderResident, reviewSignal } from '../src/interiors.mjs'; const agent = { id: 'bolt-1', taskId: 'task-42', name: 'Bolt', town: 'HubTown', status: 'working' }; const required = ['request', 'clock', 'workbench', 'review', 'shelf', 'exit']; @@ -182,3 +182,73 @@ test('reduced motion freezes scene animation while resident sprites support scal renderResident(scaled, 120, 100, room.resident, { scale: 2, time: 400, walking: true }); assert.equal(scaled.depth, 0); }); + +function prFor(stage, now) { + if (stage === 'unknown') return undefined; + if (stage === 'none') return { state: 'none', source: 'feed', checkedAt: now }; + return { + number: 42, url: 'https://github.com/example/cottage/pull/42', + state: ['merged', 'closed'].includes(stage) ? stage : 'open', + source: 'github', checkedAt: now, headSha: 'abc123', reviewedHeadSha: 'abc123', + labels: ['open', 'merged', 'closed'].includes(stage) ? [] : ['babysit:' + stage], + }; +} + +test('every PR stage has an accurate, visibly rendered light with its own icon', () => { + const now = Date.now(); + const room = createInterior(agent); + const before = structuredClone(room); + const shapes = new Set(); + for (const stage of ['none', 'open', 'active', 'waiting-codex', 'waiting-ci', 'blocked', 'ready', 'merged', 'closed', 'unknown']) { + const pr = prFor(stage, now); + const signal = reviewSignal(pr, now); + assert.equal(signal.stage, stage); + assert.ok(signal.label && signal.shortLabel && signal.color && signal.shape); + shapes.add(signal.shape); + const ctx = recordingContext(); + renderInterior(ctx, room, { agent: { ...agent, pr }, reduce: true }); + // A dynamic drawer light plus the signal plaque/icon use this exact color. + assert.ok(ctx.rects.filter((rect) => rect[4] === signal.color).length >= 3, stage); + assert.deepEqual(room, before, `${stage} changed the room instead of its live signal`); + } + assert.equal(shapes.size, 10, 'stage must also be readable without relying on color'); +}); + +test('stale, conflicting, draft and changed-head readiness never light the ready signal', () => { + const now = Date.now(); + const ready = prFor('ready', now); + const readyColor = reviewSignal(ready, now).color; + const cases = [ + { ...ready, stale: true }, + { ...ready, checkedAt: now - 120001 }, + { ...ready, checkedAt: null }, + { ...ready, labels: ['babysit:ready', 'babysit:active'] }, + { ...ready, headSha: 'new-head' }, + { ...ready, isDraft: true }, + { ...ready, reviewState: 'blocked' }, + ]; + for (const pr of cases) { + const signal = reviewSignal(pr, now); + assert.equal(signal.stage, 'unknown'); + assert.equal(signal.shape, 'question'); + assert.notEqual(signal.color, readyColor); + } +}); + +test('conversation pose and bubble leave the room intact and freeze under reduced motion', () => { + const room = createInterior({ ...agent, town: 'AppTown' }); + const before = structuredClone(room); + const idle = recordingContext(); + const talking = recordingContext(); + const later = recordingContext(); + renderInterior(idle, room, { agent, reduce: true }); + renderInterior(talking, room, { agent, talking: true, reduce: true, time: 0 }); + renderInterior(later, room, { agent, talking: true, reduce: true, time: 9999 }); + assert.ok(talking.rects.length > idle.rects.length, 'speaking has a visible acknowledgement'); + assert.deepEqual(talking.rects, later.rects); + assert.deepEqual(room, before); + const a = recordingContext(), b = recordingContext(); + renderResident(a, 100, 100, room.resident, { talking: true, walking: true, reduce: true, time: 0 }); + renderResident(b, 100, 100, room.resident, { talking: true, walking: true, reduce: true, time: 170 }); + assert.deepEqual(a.rects, b.rects); +}); diff --git a/test/messages.test.mjs b/test/messages.test.mjs new file mode 100644 index 0000000..0fe4ce9 --- /dev/null +++ b/test/messages.test.mjs @@ -0,0 +1,162 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {appendFile,mkdtemp,readFile,rm} from 'node:fs/promises'; +import {join} from 'node:path'; +import {tmpdir} from 'node:os'; +import {once} from 'node:events'; +import {request as httpRequest} from 'node:http'; +import {createHubMessenger,acceptsMessageOrigin} from '../src/messages.mjs'; +import {createFeedServer} from '../src/feed.mjs'; + +const now=Date.now(); +const agent={id:'cottage-1',taskId:'task-1',source:'hub',conversationTarget:{taskId:'task-1',taskStatus:'running',recordKind:'logical_task',transport:'tmux',supportsRedirection:true}}; +const message={taskId:'task-1',message:'Please explain the tradeoffs before changing the API.',requestId:'message-1234'}; +const task={id:'task-1',status:'running',recordKind:'logical_task',context:{lifecycle:{execution:{sessionMode:'tmux',supportsRedirection:true}}}}; +const response=(body,status=200)=>new Response(JSON.stringify(body),{status,headers:{'content-type':'application/json'}}); +function fixture({current=task,reply={ok:true,taskId:'task-1'},status=200,throws=false,...options}={}){ + const calls=[]; + const messenger=createHubMessenger({baseUrl:'http://hub.test/v1',token:'fixture-token',now:()=>now,ledgerPath:null, + fetchImpl:async(url,init)=>{calls.push({url:String(url),...init});if(init.method==='POST'){if(throws)throw new Error('Socket closed after write');return response(reply,status);}return response(current);},...options}); + return {messenger,calls}; +} +test('only supported logical task transports advertise messages; terminal, unknown, and stale tasks do not',()=>{ + const {messenger}=fixture(); + assert.equal(messenger.capability(agent).mode,'redirect'); + assert.equal(messenger.capability({...agent,conversationTarget:{...agent.conversationTarget,taskStatus:'needs_input',transport:'direct'}}).mode,'respond'); + for(const overrides of [{recordKind:'external_session'},{recordKind:'unknown'},{taskStatus:'completed'},{taskStatus:'queued'},{taskStatus:'failed'},{transport:'direct'},{transport:'unknown'},{supportsRedirection:false},{supportsRedirection:null},{taskId:'other'}]) + assert.equal(messenger.capability({...agent,conversationTarget:{...agent.conversationTarget,...overrides}}).available,false,JSON.stringify(overrides)); + assert.equal(messenger.capability(agent,{stale:true}).available,false); + assert.equal(messenger.capability(agent,{checkedAt:now-121000}).available,false); + assert.equal(createHubMessenger({baseUrl:''}).capability(agent).available,false); +}); +test('explicit messages verify current task then submit exact text, with concurrent duplicate protection',async()=>{ + const {messenger,calls}=fixture(); + const [one,two]=await Promise.all([messenger.send(agent,message),messenger.send(agent,message)]); + assert.equal(one.body.delivery,'submitted');assert.deepEqual(two,one);assert.equal(calls.length,2); + assert.equal(calls[0].url,'http://hub.test/v1/tasks/task-1'); + assert.equal(calls[1].url,'http://hub.test/v1/tasks/task-1/redirect'); + assert.deepEqual(JSON.parse(calls[1].body),{instruction:message.message}); + assert.equal(calls[1].headers.authorization,'Bearer fixture-token');assert.equal(calls[1].redirect,'error'); + assert.equal(JSON.stringify(one).includes('fixture-token'),false); + assert.equal((await messenger.send(agent,{...message,message:'A different ask'})).status,409);assert.equal(calls.length,2); +}); +test('waiting input uses respond and never invokes resume or dispatch',async()=>{ + const waiting={...agent,conversationTarget:{...agent.conversationTarget,taskStatus:'awaiting_input'}}; + const {messenger,calls}=fixture({current:{...task,status:'awaiting_input',canRespond:true},reply:{id:'task-1',status:'running'},status:202}); + assert.equal((await messenger.send(waiting,message)).body.delivery,'accepted'); + assert.equal(calls[1].url,'http://hub.test/v1/tasks/task-1/respond'); + assert.deepEqual(JSON.parse(calls[1].body),{response:message.message}); +}); +test('fresh validation rejects changed task states and direct sessions before any write',async()=>{ + for(const current of [{...task,status:'completed'},{...task,id:'other'},{...task,isStale:true},{...task,archived:true},{...task,recordKind:'external_session'},{...task,context:{lifecycle:{execution:{sessionMode:'direct'}}}},{...task,context:{lifecycle:{execution:{sessionMode:'tmux'}}}}]){ + const {messenger,calls}=fixture({current}); + assert.equal((await messenger.send(agent,message)).body.delivery,'not_sent'); + assert.equal(calls.filter(call=>call.method==='POST').length,0); + } + const {messenger,calls}=fixture(); + for(const invalid of [{...message,taskId:'wrong'},{...message,message:''},{...message,message:'x'.repeat(8001)},{...message,requestId:'../'}])assert.notEqual((await messenger.send(agent,invalid)).status,200); + assert.equal(calls.length,0); +}); +test('uncertain deliveries and successful receipts survive restart without replaying a message',async()=>{ + const directory=await mkdtemp(join(tmpdir(),'cottage-messages-')),ledgerPath=join(directory,'receipts.jsonl'); + try{ + const first=fixture({throws:true,ledgerPath}); + assert.equal((await first.messenger.send(agent,message)).body.delivery,'unconfirmed'); + const second=fixture({ledgerPath}); + assert.equal((await second.messenger.send(agent,message)).body.delivery,'unconfirmed');assert.equal(second.calls.length,0); + const next={...message,requestId:'another-message'}; + assert.equal((await second.messenger.send(agent,next)).body.delivery,'submitted'); + const third=fixture({ledgerPath});assert.equal((await third.messenger.send(agent,next)).body.delivery,'submitted');assert.equal(third.calls.length,0); + const stored=await readFile(ledgerPath,'utf8');assert.equal(stored.includes(message.message),false);assert.equal(stored.includes('fixture-token'),false); + }finally{await rm(directory,{recursive:true,force:true});} +}); +test('a prior receipt is returned when the cottage has advanced to another task',async()=>{ + const directory=await mkdtemp(join(tmpdir(),'cottage-messages-')),ledgerPath=join(directory,'receipts.jsonl'); + try{ + const first=fixture({ledgerPath}); + assert.equal((await first.messenger.send(agent,message)).body.delivery,'submitted'); + const advanced={...agent,taskId:'task-2',conversationTarget:{...agent.conversationTarget,taskId:'task-2'}}; + const retry=fixture({ledgerPath}); + assert.equal((await retry.messenger.send(advanced,message)).body.delivery,'submitted'); + assert.equal(retry.calls.length,0,'a durable receipt is checked using the original payload task before current task validation'); + }finally{await rm(directory,{recursive:true,force:true});} +}); +test('HTTP retries return durable success and unconfirmed receipts after the cottage disappears',async()=>{ + for(const {throws,delivery,status} of [{throws:false,delivery:'submitted',status:200},{throws:true,delivery:'unconfirmed',status:409}]){ + const directory=await mkdtemp(join(tmpdir(),'cottage-messages-')),ledgerPath=join(directory,'receipts.jsonl'); + try{ + const first=fixture({ledgerPath,throws}); + assert.equal((await first.messenger.send(agent,message)).body.delivery,delivery); + const retry=fixture({ledgerPath}); + const server=createFeedServer({snapshot:()=>({agents:[],checkedAt:now,stale:false}),getActivity:async()=>null},{messages:retry.messenger}); + server.listen(0,'127.0.0.1');await once(server,'listening');const base='http://127.0.0.1:'+server.address().port; + try{ + const response=await fetch(base+'/agents/cottage-1/messages',{method:'POST',headers:{origin:base,'content-type':'application/json','x-cottagecode-request':'user-message'},body:JSON.stringify(message)}); + assert.equal(response.status,status);assert.equal((await response.json()).delivery,delivery); + assert.equal(retry.calls.length,0,'a missing cottage must still honor an existing receipt without verifying or replaying the task'); + }finally{server.closeAllConnections();await new Promise(resolve=>server.close(resolve));} + }finally{await rm(directory,{recursive:true,force:true});} + } +}); +test('an interrupted final receipt record preserves prior message reservations',async()=>{ + const directory=await mkdtemp(join(tmpdir(),'cottage-messages-')),ledgerPath=join(directory,'receipts.jsonl'); + try{ + const first=fixture({throws:true,ledgerPath}); + assert.equal((await first.messenger.send(agent,message)).body.delivery,'unconfirmed'); + await appendFile(ledgerPath,'{"id":"interrupted'); + const restarted=fixture({ledgerPath}); + assert.equal((await restarted.messenger.send(agent,message)).body.delivery,'unconfirmed'); + assert.equal(restarted.calls.length,0,'a prior reservation must still prevent a replay'); + const next={...message,requestId:'after-interruption'}; + assert.equal((await restarted.messenger.send(agent,next)).body.delivery,'submitted'); + const final=fixture({ledgerPath}); + assert.equal((await final.messenger.send(agent,next)).body.delivery,'submitted'); + assert.equal(final.calls.length,0,'a completed receipt remains deduplicated after recovery'); + }finally{await rm(directory,{recursive:true,force:true});} +}); +test('upstream rejection is distinct from uncertainty and omits private error context',async()=>{ + const {messenger}=fixture({status:403,reply:{error:'owner_approval_required',task:{private:'do-not-return'}}}); + const sent=await messenger.send(agent,message);assert.equal(sent.status,403);assert.equal(sent.body.delivery,'not_sent');assert.match(sent.body.error,/owner/);assert.equal(JSON.stringify(sent).includes('do-not-return'),false); + assert.equal((await fixture({status:500}).messenger.send(agent,message)).body.delivery,'unconfirmed'); + for(const error of ['cancellation_in_progress','already_answered']){ + const waiting={...agent,conversationTarget:{...agent.conversationTarget,taskStatus:'awaiting_input'}}; + const {messenger,calls}=fixture({current:{...task,status:'awaiting_input'},status:409,reply:{error}}); + assert.equal((await messenger.send(waiting,message)).body.delivery,'unconfirmed'); + assert.equal((await messenger.send(waiting,message)).body.delivery,'unconfirmed');assert.equal(calls.length,2,'an ambiguous conflict must not repeat the write'); + } +}); +test('message origin guard rejects cross-origin, absent Origin, and rebinding hostnames',()=>{ + const headers={host:'127.0.0.1:8787',origin:'http://127.0.0.1:8787','content-type':'application/json','x-cottagecode-request':'user-message'}; + const socket={remoteAddress:'127.0.0.1'}; + assert.equal(acceptsMessageOrigin({headers,socket}),true); + for(const remoteAddress of ['::1','::ffff:127.0.0.1'])assert.equal(acceptsMessageOrigin({headers,socket:{remoteAddress}}),true); + for(const remoteAddress of ['192.168.1.5','::ffff:192.168.1.5','',undefined])assert.equal(acceptsMessageOrigin({headers,socket:{remoteAddress}}),false); + for(const extra of [{origin:'https://example.com'},{origin:undefined},{host:'evil.example:8787',origin:'http://evil.example:8787'},{'content-type':'text/plain'},{'x-cottagecode-request':undefined}])assert.equal(acceptsMessageOrigin({headers:{...headers,...extra},socket}),false); +}); +test('HTTP message chunks preserve Unicode split across multibyte boundaries',async()=>{ + let received; + const server=createFeedServer({snapshot:()=>({agents:[agent]}),getActivity:async()=>null},{messages:{capability:()=>({available:false}),send:async(a,payload)=>{received=payload;return {status:200,body:{ok:true,delivery:'submitted'}};}}}); + server.listen(0,'127.0.0.1');await once(server,'listening');const origin='http://127.0.0.1:'+server.address().port; + try{ + const payload={...message,message:'Hello 🦆 — Grüß dich!'},bytes=Buffer.from(JSON.stringify(payload)),cut=bytes.indexOf(Buffer.from('🦆'))+2; + await new Promise((resolve,reject)=>{ + const req=httpRequest(origin+'/agents/cottage-1/messages',{method:'POST',headers:{origin,'content-type':'application/json','x-cottagecode-request':'user-message'}},res=>{res.resume();res.on('end',()=>res.statusCode===200?resolve():reject(new Error('HTTP '+res.statusCode)));}); + req.on('error',reject);req.write(bytes.subarray(0,cut));setTimeout(()=>req.end(bytes.subarray(cut)),10); + }); + assert.equal(received.message,payload.message); + }finally{server.closeAllConnections();await new Promise(resolve=>server.close(resolve));} +}); +test('HTTP server exposes capability and accepts only explicit same-origin message posts',async()=>{ + const {messenger,calls}=fixture(),feed={snapshot:()=>({agents:[agent],checkedAt:now,stale:false}),getActivity:async()=>null}; + const server=createFeedServer(feed,{messages:messenger});server.listen(0,'127.0.0.1');await once(server,'listening'); + const base='http://127.0.0.1:'+server.address().port; + const post=(body,headers={})=>fetch(base+'/agents/cottage-1/messages',{method:'POST',headers:{'content-type':'application/json',...headers},body:JSON.stringify(body)}); + try{ + assert.equal((await (await fetch(base+'/agents')).json()).agents[0].conversation.available,true); + assert.equal((await post(message)).status,403); + assert.equal((await post(message,{origin:'https://other.example','x-cottagecode-request':'user-message'})).status,403); + assert.equal(calls.length,0); + const sent=await post(message,{origin:base,'x-cottagecode-request':'user-message'});assert.equal(sent.status,200);assert.equal((await sent.json()).delivery,'submitted'); + assert.equal((await fetch(base+'/agents',{method:'POST'})).status,405); + }finally{server.closeAllConnections();await new Promise(resolve=>server.close(resolve));} +}); diff --git a/test/observatory.test.mjs b/test/observatory.test.mjs index 9a5adc3..76648ca 100644 --- a/test/observatory.test.mjs +++ b/test/observatory.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import {activityJournalPresentation,applyActivityPage,button,handoffAction,appendInlineHandoffs} from '../src/observatory.mjs'; +import {activityJournalPresentation,activityCacheFor,applyActivityPage,button,handoffAction,appendInlineHandoffs} from '../src/observatory.mjs'; test('history jump actions escape untrusted cottage identifiers in button markup',()=>{ const html=button('jump:agent" onfocus="alert(1)','Open cottage'); @@ -37,3 +37,19 @@ test('unavailable activity is retained and presented apart from an empty live jo assert.equal(cache.unavailable,false); assert.deepEqual(activityJournalPresentation(cache),{state:'live',text:'hub timeline · live activity'}); }); + +test('activity cache clears task artifacts when a session-only cottage advances to a new session',()=>{ + const first={taskId:null,sessionId:'session-one',activityUrl:'/agents/cottage/activity'}; + const cache=activityCacheFor(null,first); + cache.events=[{id:'old',text:'Earlier task'}]; + cache.todos={items:[{id:'old-todo',text:'Earlier task checklist',status:'pending'}]}; + cache.cursor='old'; + + assert.equal(activityCacheFor(cache,{...first,task:'Updated same task'}),cache,'a same-session refresh keeps journal state and scroll anchors'); + + const next=activityCacheFor(cache,{...first,sessionId:'session-two'}); + assert.notEqual(next,cache); + assert.deepEqual(next.events,[]); + assert.equal(next.todos,undefined,'an omitted new-session todo snapshot cannot reuse the prior task checklist'); + assert.equal(next.cursor,null); +}); diff --git a/test/sound.test.mjs b/test/sound.test.mjs new file mode 100644 index 0000000..f5ce530 --- /dev/null +++ b/test/sound.test.mjs @@ -0,0 +1,146 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createSound } from '../src/sound.mjs'; + +class Parameter { + value = 0; + events = []; + setValueAtTime(value, time) { this.record('set', value, time); } + linearRampToValueAtTime(value, time) { this.record('linear', value, time); } + exponentialRampToValueAtTime(value, time) { assert.ok(value > 0); this.record('exponential', value, time); } + record(kind, value, time) { assert.ok(Number.isFinite(value) && Number.isFinite(time)); this.events.push([kind, value, time]); } +} +class Node { + connections = []; + disconnects = 0; + connect(node) { this.connections.push(node); return node; } + disconnect() { this.disconnects++; this.connections = []; } +} +class Oscillator extends Node { + frequency = new Parameter(); + stops = []; + start(time) { this.startedAt = time; } + stop(time) { this.stops.push(time); } + end() { this.onended?.(); } +} +class FakeContext { + currentTime = 10; + state = 'suspended'; + nodes = []; + destination = {}; + async resume() { this.state = 'running'; } + async suspend() { this.state = 'suspended'; } + add(node) { this.nodes.push(node); return node; } + createOscillator() { return this.add(new Oscillator()); } + createGain() { return this.add(Object.assign(new Node(), { gain: new Parameter() })); } + createStereoPanner() { return this.add(Object.assign(new Node(), { pan: new Parameter() })); } + createBiquadFilter() { return this.add(Object.assign(new Node(), { frequency: new Parameter(), Q: new Parameter() })); } +} +function fixture() { + const context = new FakeContext(); + let constructions = 0; + const sound = createSound({ contextFactory: () => { constructions++; return context; } }); + return { context, sound, get constructions() { return constructions; } }; +} + +test('murmurs require opt-in and disable stops and disconnects every live node', async () => { + const f = fixture(); + assert.equal(f.sound.murmur({ seed: 'Bolt' }), false); + assert.equal(f.sound.play('door'), false); + assert.equal(f.constructions, 0, 'sound off must not create or resume a context'); + assert.equal(await f.sound.enable(true), true); + assert.equal(f.sound.murmur({ seed: 'Bolt' }), true); + assert.equal(f.context.nodes.length, 7); + const source = f.context.nodes.find((n) => n instanceof Oscillator); + assert.ok(source.stops[0] > f.context.currentTime); + assert.equal(await f.sound.enable(false), false); + assert.equal(source.stops.at(-1), f.context.currentTime); + assert.ok(f.context.nodes.every((n) => n.disconnects === 1 && n.connections.length === 0)); + assert.equal(source.onended, null); + assert.equal(await f.sound.enable(true), true); + assert.equal(f.constructions, 1); + assert.equal(f.context.nodes.length, 7, 're-enabling cannot revive old syllables'); + assert.equal(f.sound.murmur({ seed: 'Bolt' }), true, 'a new greeting is allowed after re-enabling'); +}); + +test('murmur schedules a deterministic multi-syllable formant phrase with bounded duration', async () => { + const a = fixture(), b = fixture(), c = fixture(); + await Promise.all([a.sound.enable(true), b.sound.enable(true), c.sound.enable(true)]); + a.sound.murmur({ seed: 'resident-42' }); + b.sound.murmur({ seed: 'resident-42' }); + c.sound.murmur({ seed: 'resident-43' }); + const sourceA = a.context.nodes.find((n) => n instanceof Oscillator); + const sourceB = b.context.nodes.find((n) => n instanceof Oscillator); + const sourceC = c.context.nodes.find((n) => n instanceof Oscillator); + assert.equal(sourceA.type, 'sawtooth'); + assert.deepEqual(sourceA.frequency.events, sourceB.frequency.events); + assert.notDeepEqual(sourceA.frequency.events, sourceC.frequency.events); + const syllables = sourceA.frequency.events.filter(([kind]) => kind === 'set'); + assert.ok(syllables.length >= 5 && syllables.length <= 7); + assert.ok(sourceA.stops[0] - sourceA.startedAt > .6 && sourceA.stops[0] - sourceA.startedAt < 1.5); + assert.equal(a.context.nodes.filter((n) => n.type === 'bandpass').length, 2); + assert.equal(a.context.nodes.filter((n) => n.type === 'lowpass').length, 1); + assert.ok(a.context.nodes.filter((n) => n.type === 'bandpass').every((n) => n.frequency.events.length === syllables.length * 2)); + sourceA.end(); + assert.ok(a.context.nodes.every((n) => n.disconnects === 1)); + sourceA.end(); + assert.ok(a.context.nodes.every((n) => n.disconnects === 1), 'ended cleanup is idempotent'); +}); + +test('murmur attenuation, panning and cooldown prevent noisy repeats', async () => { + const { context, sound } = fixture(); + await sound.enable(true); + assert.equal(sound.murmur({ distance: 220 }), false); + assert.equal(sound.murmur({ distance: Infinity }), false); + assert.equal(context.nodes.length, 0); + assert.equal(sound.murmur({ seed: 'A', distance: 110, pan: 4 }), true); + const panner = context.nodes.find((n) => n.pan); + assert.equal(panner.pan.value, 1); + const envelope = context.nodes.find((n) => n.gain?.events.length > 0); + const peaks = envelope.gain.events.filter(([kind]) => kind === 'linear').map(([, value]) => value); + assert.ok(peaks.every((value) => value > 0 && value <= .045 * .5 * .75)); + assert.equal(sound.murmur({ seed: 'B' }), false, 'different residents share the greeting cooldown'); + context.nodes.find((n) => n instanceof Oscillator).end(); + context.currentTime += 1.5; + assert.equal(sound.murmur({ seed: 'B', pan: -4 }), true); + assert.equal(context.nodes.filter((n) => n.pan).at(-1).pan.value, -1); +}); + +test('ambience and alert switches stop only their own channels', async () => { + const { context, sound } = fixture(); + await sound.enable(true); + assert.equal(sound.play('ready'), true); + const alertNodes = [...context.nodes]; + assert.equal(sound.murmur({ seed: 'A' }), true); + const murmurNodes = context.nodes.slice(alertNodes.length); + sound.ambience = false; + assert.ok(murmurNodes.every((n) => n.disconnects === 1)); + assert.ok(alertNodes.every((n) => n.disconnects === 0)); + assert.equal(sound.murmur(), false); + assert.equal(sound.play('door'), false); + sound.alerts = false; + assert.ok(alertNodes.every((n) => n.disconnects === 1)); + sound.ambience = true; + context.currentTime += 2; + assert.equal(sound.play('ready'), false); + assert.equal(sound.murmur(), true); + await sound.enable(false); + assert.ok(context.nodes.every((n) => n.disconnects === 1)); +}); + +test('audio initialization and partial voice failures return false without leaking nodes', async () => { + const failedContext = new FakeContext(); + failedContext.resume = async () => { throw new Error('Audio unavailable'); }; + const disabled = createSound({ contextFactory: () => failedContext }); + assert.equal(await disabled.enable(true), false); + assert.equal(disabled.murmur(), false); + assert.equal(failedContext.nodes.length, 0); + const constructionFailure = createSound({ contextFactory: () => { throw new Error('No device'); } }); + assert.equal(await constructionFailure.enable(true), false); + const { context, sound } = fixture(); + await sound.enable(true); + context.createBiquadFilter = () => { throw new Error('Node failure'); }; + assert.equal(sound.murmur(), false); + assert.ok(context.nodes.every((n) => n.disconnects === 1)); + assert.ok(context.nodes.every((n) => n.onended === null)); +}); diff --git a/test/todos.test.mjs b/test/todos.test.mjs new file mode 100644 index 0000000..89c8ace --- /dev/null +++ b/test/todos.test.mjs @@ -0,0 +1,54 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { MAX_TODOS, normalizeTodos, todosFromTool, todosFromEvent, latestTodos } from "../src/todos.mjs"; + +const now = Date.parse("2026-09-15T10:00:00Z"); +const item = (text = "Inspect the handler", status = "pending") => ({ text, status }); + +test("unknown checklist differs from an explicitly empty one", () => { + for (const value of [null, undefined, {}, "", "- [ ] Read the code", [{ text: "No explicit status" }], [item("", "pending")], [item("Hidden state", "unknown")]]) assert.equal(normalizeTodos(value), null); + assert.deepEqual(normalizeTodos([], { source: "fixture", updatedAt: now }), { items: [], source: "fixture", updatedAt: now }); + assert.equal(normalizeTodos({ items: [], updatedAt: "0" }).updatedAt, null); +}); + +test("TodoWrite, update_plan, and Codex todo_list retain their explicit statuses", () => { + const claude = todosFromTool("TodoWrite", { todos: [{ content: "Read the handler", activeForm: "Reading the handler", status: "in_progress" }] }, { updatedAt: now }); + assert.equal(claude.items[0].text, "Read the handler"); + assert.equal(claude.items[0].status, "in_progress"); + assert.equal(claude.source, "transcript:TodoWrite"); + assert.equal(claude.updatedAt, now); + const codex = todosFromTool("functions.update_plan", JSON.stringify({ plan: [{ step: "Run tests", status: "completed" }] })); + assert.equal(codex.items[0].status, "completed"); + const completed = todosFromEvent({ kind: "todo_list", items: [{ text: "First", completed: true }, { text: "Second", completed: false }], timestamp: now }); + assert.deepEqual(completed.items.map(todo => todo.status), ["completed", "pending"]); + assert.equal(normalizeTodos({ items: [{ title: "Hub task", status: "done" }] }).items[0].status, "completed"); + assert.equal(normalizeTodos({ items: [item("Cancelled", "cancelled")] }).items[0].status, "cancelled"); +}); + +test("checklist IDs survive status changes and remain unique, with bounded content", () => { + const pending = normalizeTodos([item("Same text")]); + const completed = normalizeTodos([item("Same text", "completed")]); + assert.equal(pending.items[0].id, completed.items[0].id); + const duplicate = normalizeTodos([item("Same text"), item("Same text")]); + assert.notEqual(duplicate.items[0].id, duplicate.items[1].id); + const large = normalizeTodos(Array.from({ length: MAX_TODOS + 10 }, (_, i) => item(`Step ${i} ${"x".repeat(550)}`))); + assert.equal(large.items.length, MAX_TODOS); + assert.equal(large.items[0].text.length, 500); + assert.equal(large.truncated, true); +}); + +test("prose and private thinking never become checklists", () => { + assert.equal(todosFromEvent({ kind: "plan", detail: "Read files (pending)\nRun tests (completed)" }), null); + assert.equal(todosFromTool("Bash", { todos: [item()] }), null); + assert.equal(todosFromEvent({ kind: "thinking", todos: [item()] }), null); + assert.equal(todosFromEvent({ kind: "tool_call", tool: "TodoWrite", input_preview: '{"todos":[' }), null); + assert.equal(todosFromTool("update_plan", { plan: "- [ ] Run tests" }), null); +}); + +test("later snapshots replace rather than merge and older pages cannot regress a dated list", () => { + const first = { kind: "plan", items: [item()], timestamp: now }; + const cleared = { kind: "summary", todos: { items: [], source: "tool", updatedAt: now + 1000 } }; + assert.deepEqual(latestTodos([first, cleared]).items, []); + assert.deepEqual(latestTodos([first], { initial: cleared.todos }).items, []); + assert.deepEqual(latestTodos([{ kind: "progress", text: "Completed everything" }], { initial: { items: [item()], source: "tool", updatedAt: now } }).items[0].status, "pending"); +});