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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ duck. Check direct inspector controls, the connect box, and stale-feed recovery.
| `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/hub.mjs` | Optional standalone readonly sqlite `agent_runs` adapter |
| `src/hub-inventory.mjs` | Shared `/v1/tasks` inventory when `AUTOHUB_HUB_BASE` is configured; replaces local discovery |
| `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 |
Expand Down
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,35 @@ Optional fields add task identity, the original request, timestamps, activity, w

The local process can also populate `/agents` without you writing a server:

For a shared AutoHub inventory, start the feed with:

```sh
AUTOHUB_HUB_BASE=http://127.0.0.1:8767 npm start
```

For a persistent setting in this checkout, add this line to its local `.env`:

```dotenv
AUTOHUB_HUB_BASE=http://127.0.0.1:8767
```

Then use plain `npm start`. Both `npm start` and `npm run once` load `.env`
when present; existing shell variables take precedence. The file is ignored by
Git, and no global shell export is needed. With no Hub setting, standalone
behavior remains the default. Direct `node src/feed.mjs` launches use the
process environment; add `--env-file-if-exists=.env` before the script path
when launching that way.

Set `AUTOHUB_HUB_TOKEN` in `.env` or the server environment if that Hub requires a bearer
token. In this mode CottageCode follows every `/v1/tasks` cursor page and reads
canonical task detail, timelines, checklist data, and control capabilities.
It does not open SQLite or scan Claude transcripts. The Hub owns discovery;
CottageCode keeps its existing towns, cottages, inspectors, and message receipts.
If the Hub is unavailable, the last complete feed stays visible as stale.
Credentials and raw upstream errors are never returned to the browser.

Without `AUTOHUB_HUB_BASE`, the standalone adapters remain:

1. Readonly SQLite at `AGENT_DB_PATH` (an `agent_runs` table), if set
2. Claude Code session JSONL under `~/.claude/projects` (or `CLAUDE_PROJECTS_DIR`)
3. Cached, read-only GitHub metadata through an authenticated `gh` CLI
Expand Down
24 changes: 24 additions & 0 deletions docs/COTTAGE_FEED.md
Original file line number Diff line number Diff line change
Expand Up @@ -354,13 +354,20 @@ Replay steps through recorded milestones and highlights their cottages. It does
| Variable | Default | Behavior |
|---|---|---|
| `CLAUDE_PROJECTS_DIR` | `~/.claude/projects` | Directory scanned for local session JSONL. |
| `AUTOHUB_HUB_BASE` | Unset | Select shared Hub inventory mode, e.g. `http://127.0.0.1:8767` (optional `/v1`). Replaces local SQLite inventory and Claude scanning; follows all `/v1/tasks` pages and uses canonical ids for detail/activity/control. |
| `AUTOHUB_HUB_TOKEN` | Unset | Optional server-side bearer token for shared inventory, timelines, and explicit user messages. Never published. Falls back to `COTTAGE_HUB_TOKEN` if absent. |
| `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`. 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. |

`npm start` and `npm run once` optionally load the checkout’s Git-ignored `.env`.
A local `AUTOHUB_HUB_BASE=http://127.0.0.1:8767` setting persists Hub mode across
launches without a global shell export. Existing process variables take precedence,
and an absent `.env` preserves standalone defaults.

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.

GitHub requests run in the background, at most 3 at a time, with a 30-second refresh interval and failure backoff from 30 seconds to 5 minutes. Failed refreshes retain old observations marked stale. Cold snapshots can contain unknown PRs while those queries finish. Existing custom JSON feeds provide their own PR metadata; the browser does not run `gh` against them.
Expand Down Expand Up @@ -437,3 +444,20 @@ npx --yes http-server . -p 9999 --cors
```

**Pause feed** stops browser refreshes and the demo simulator. It never writes back to the feed or pauses real agents. Walking and direct inspector controls remain available.

### Shared Hub inventory mode

The Node process caches successful inventory reads for five seconds; it follows
all cursor pages before replacing a snapshot. Failed pages keep the previous
complete snapshot and mark the feed stale. `id`, `parent`, and `taskId` retain
Hub canonical task identities, including observed external sessions. Their
`sessionId` stays separate. Town labels still derive from recorded project
paths; labels and worktree slugs never determine task identity or control.

The existing local activity route refreshes `/v1/tasks/:id` and reads the Hub
`/timeline` and `/todo` routes. Source `thinking` events remain excluded from
public journals. Explicit Send rechecks detail and the live `controlTargetId`
and capabilities; a null target is read-only. Replies also use Hub's optional
`expected_attention_version` guard when supplied by detail. The existing
receipt ledger and uncertain-delivery behavior remain in effect. No new cancel
or autonomous messaging behavior is added.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@
"cottagecode": "./src/feed.mjs"
},
"scripts": {
"start": "node src/feed.mjs",
"once": "node src/feed.mjs --once",
"start": "node --env-file-if-exists=.env src/feed.mjs",
"once": "node --env-file-if-exists=.env src/feed.mjs --once",
"build:demo": "node scripts/build-demo.mjs",
"deploy:demo": "node scripts/deploy-demo.mjs",
"test": "node --test",
Expand Down
22 changes: 16 additions & 6 deletions src/activity.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,8 @@ export function pageActivity(events, { after, before, limit = 100, source = "non

/** AutoHub only supports backwards cursors, so retain safe events for forward polls. */
export function createHubTimelineReader({
baseUrl = process.env.COTTAGE_HUB_URL || "",
token = process.env.COTTAGE_HUB_TOKEN || "",
baseUrl = process.env.AUTOHUB_HUB_BASE || process.env.COTTAGE_HUB_URL || "",
token = process.env.AUTOHUB_HUB_TOKEN || process.env.COTTAGE_HUB_TOKEN || "",
fetchFn = globalThis.fetch,
now = Date.now,
ttl = 1500,
Expand Down Expand Up @@ -162,6 +162,9 @@ export function createHubTimelineReader({
const old = cache.get(taskId);
if (old && !options.before && now() - old.checkedAt < ttl) return old;
const entry = old ? { ...old } : { events: [], hasOlder: false, checkedAt: 0, source: "none" };
// Historical pages are request-local windows; they must neither compete
// with the recent-event cap nor evict the cursor used by forward polling.
if (options.before) entry.events = [];
let before = options.before || "";
try {
for (let page = 0; page < 4; page++) {
Expand Down Expand Up @@ -191,8 +194,10 @@ export function createHubTimelineReader({
// Never copy fetch errors: their messages may include deployment credentials.
entry.checkedAt = now();
}
cache.set(taskId, entry);
if (cache.size > 200) cache.delete(cache.keys().next().value);
if (!options.before) {
cache.set(taskId, entry);
if (cache.size > 200) cache.delete(cache.keys().next().value);
}
return entry;
}

Expand All @@ -204,9 +209,14 @@ export function createHubTimelineReader({
const key = `${taskId}\n${options.before || ""}`;
if (!inFlight.has(key)) inFlight.set(key, refresh(taskId, options).finally(() => inFlight.delete(key)));
const [entry, checkpointTodos] = await Promise.all([inFlight.get(key), readTodos(taskId)]);
const result = pageActivity(entry.events, { ...options, source: entry.source });
// The upstream query already applied `before`, so its anchor is outside
// this window. Apply only the local display limit to that result.
const pageOptions = options.before ? { ...options, before: undefined, after: undefined } : options;
const result = pageActivity(entry.events, { ...pageOptions, source: entry.source });
if (options.before) result.cursor = result.events[0]?.id || options.before;
if (!options.after && entry.hasOlder) result.hasMore = true;
let todos = latestTodos(entry.events, { source: entry.source });
const todoEntry = options.before ? cache.get(taskId) || entry : entry;
let todos = latestTodos(todoEntry.events, { source: todoEntry.source });
if (todos && entry.stale) todos = { ...todos, stale: true };
const checkpointWins = checkpointTodos && (!todos ||
(checkpointTodos.updatedAt !== null
Expand Down
28 changes: 23 additions & 5 deletions src/feed.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { join, dirname, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { repoOf, worktreeOf, townName } from "./towns.mjs";
import { readHubAgents, shortModel } from "./hub.mjs";
import { createHubInventoryReader } from "./hub-inventory.mjs";
import { classifyOccupancy, inferPr, lettersOf, liveCostOf, stampOccupancy } from "./occupancy.mjs";
import { createTranscriptReader } from "./transcripts.mjs";
import { createHubTimelineReader, pageActivity, mergeActivityEvents } from "./activity.mjs";
Expand Down Expand Up @@ -219,8 +220,13 @@ function newestTodos(current, incoming) {

export function createFeed({
scanClaude = createClaudeScanner(), readHub = readHubAgents, enrich = enrichAgents,
hubInventory = createHubInventoryReader(),
resolveRepos = createRepoResolver(), timeline = createHubTimelineReader(), now = Date.now,
} = {}) {
if (hubInventory.configured) {
scanClaude = async () => ({ok:true,agents:[],sessions:[]});
readHub = () => hubInventory.read();
}
let cache = [];
let claude = { agents: [], sessions: [] };
let hub = { agents: [], keys: new Set(), links: new Map() };
Expand All @@ -240,7 +246,7 @@ export function createFeed({
if (claude.stale) nextErrors.push(claude.error || "Some transcripts are temporarily unavailable");
} else nextErrors.push("Local transcripts are temporarily unavailable");
if (results[1].status === "fulfilled" && results[1].value.ok !== false) hub = results[1].value;
else nextErrors.push("Hub database is temporarily unavailable");
else nextErrors.push(hubInventory.configured ? "Hub inventory is temporarily unavailable" : "Hub database is temporarily unavailable");

const localById = new Map(claude.agents.map(agent => [agent.id, agent]));
const previousById = new Map(cache.map(agent => [agent.id, agent]));
Expand Down Expand Up @@ -418,21 +424,33 @@ export function createFeed({
return doScan();
},
async getActivity(id, options = {}) {
const agent = cache.find(cottage => cottage.id === id);
let agent = cache.find(cottage => cottage.id === id);
if (!agent) return null;
let refreshedDetail = false, detailFailed = false;
const activityResponse = page => detailFailed ? {
...page, stale: true, error: "Hub task detail temporarily unavailable",
inputRequest: page.inputRequest ? {...page.inputRequest,stale:true} : null,
Comment on lines +431 to +432

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply stale input evidence before enabling Send

When task detail fails but the inventory list remains healthy, this response correctly marks the cached input request stale, yet the browser's ensureActivity() consumes only the page and todos and never applies data.inputRequest to the agent used by capabilityFor(). Fresh evidence after the earlier fix is that the newly returned stale marker is therefore ignored: the conversation form remains enabled and every attempted Send is rejected only after the server repeats the failing detail check. Propagate this input state or include the activity cache's stale state when computing the browser capability.

Useful? React with 👍 / 👎.

...(page.cursorReset && !page.events?.length ? {cursorReset:false,cursor:options.after || options.before || null} : {}),
} : page;
const inputForActivity = () => refreshedDetail ? normalizeInputRequest(agent.inputRequest) : currentInputRequest(agent);
if (hubInventory.configured && typeof hubInventory.detail === "function") {
// Detail serves this activity request; only scans replace enriched inventory.
try { agent = { ...agent, ...await hubInventory.detail(id) }; refreshedDetail = true; }
catch { detailFailed = true; }
}
const local = activity.get(id) || [];
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, inputRequest: currentInputRequest(agent), ...(stale ? { stale: true } : {}) };
return activityResponse({ ...pageActivity(mergeActivityEvents([], local), { ...options, source: "claude-transcript" }), todos, inputRequest: inputForActivity(), ...(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, inputRequest: currentInputRequest(agent) };
return activityResponse({ ...result, todos, inputRequest: inputForActivity() });
}
return { ...pageActivity([], options), todos: normalizeTodos(agent.todos), inputRequest: currentInputRequest(agent), unavailable: true };
return activityResponse({ ...pageActivity([], options), todos: normalizeTodos(agent.todos), inputRequest: inputForActivity(), unavailable: true });
},
};
}
Expand Down
Loading
Loading