feat(overseer): make AGENT_NOTIFY_SUMMARY contract invisible to humans#81
feat(overseer): make AGENT_NOTIFY_SUMMARY contract invisible to humans#81heavygee wants to merge 148 commits into
Conversation
…dated piggyback (tiann#893) Promotes scratchlist persistence from per-device localStorage to a hub- backed typed table so entries follow the operator across devices. v1 panel UI / FUE / shortcut / styling are deliberately unchanged - this is a backend + sync-layer feature. Hub side - New `session_scratchlist` typed table (sessionId, entryId, text, createdAt, updatedAt) with composite PK and FK ON DELETE CASCADE from sessions. Schema bumped V9 -> V10; idempotent migration added to the legacy + step ladders. - REST CRUD under `/api/sessions/:id/scratchlist[/:entryId]`, all routed through the existing `requireSessionFromParam` guard so namespace / ownership enforcement is identical to other session-scoped routes. - Per-session 200-entry cap enforced on POST. Duplicate entryId reported idempotently (200) so the migration retry path is safe. - `SessionPatchSchema` extended with `scratchlistUpdatedAt?: number`; every successful mutation emits a `session-updated` SSE patch with the token. (Following operator's piggyback decision; aligns with the parallel tiann#884 patch-shape extension.) Web side - Hub becomes source of truth via TanStack Query (`queryKeys.scratchlist(sessionId)`); localStorage demoted to offline cache. Add / delete / update mutations are optimistic with rollback on error. - Silent first-load migration: existing localStorage entries are pushed to the hub preserving id + createdAt, and a one-time banner (mirroring `CursorMigrationBanner`) tells the operator their notes are now in the hub. Banner dismissal is per-session and persistent. - SSE handler queues a `scratchlist` invalidation when the patch carries `scratchlistUpdatedAt`, so cross-device + cross-tab updates land within an SSE round-trip. - Delete-session confirm copy now includes a count of scratchlist entries that will be cascade-deleted. Out of scope (separate tracking issue tiann#894): "delete with summarize-and- migrate" UX flow. Tests - Hub: V9->V10 migration (fresh + multi-hop legacy + idempotent reopen + cascade-delete), `ScratchlistStore` CRUD + ordering, REST routes (happy path + 400/403/404/409), SyncEngine SSE emission. - Web: hook covers initial fetch, optimistic add/delete/update with rollback, localStorage migration + banner, cap enforcement, local-only reorder. Banner component renders only on `'completed'`. - Existing Playwright e2e (10 tests, panel UI regression) all pass unchanged. Co-authored-by: Cursor <cursoragent@cursor.com>
The session-row attention dots and the future-scheduled clock icon used
plain `title=""` attributes which gave only a one-word label ("Permission
required"). Replace those with hover/focus-revealed tooltips that name
*which* tools are blocking, count background tasks, surface the
"updated Nm ago" timestamp, and explain the pending schedule.
To make per-tool copy possible without an extra round trip,
`SessionSummary` now carries a structured slice of the pending tool
requests, capped at `PENDING_REQUEST_SUMMARY_CAP = 5` oldest-first:
pendingRequests: Array<{ id; kind; tool; since }>
`pendingRequestsCount` remains the authoritative total;
`pendingRequestKinds` is still derived from the FULL request set so a
single `'input'` request beyond the cap still surfaces its kind on the
session row.
The tooltip primitive (`HoverTooltip`) is a CSS-driven reveal — no
portal, no positioning JS — so it composes cheaply inside the existing
session-row `<button>` and stays out of the way on touch devices, which
keep getting the same `aria-label` the old `title=""` attribute provided
to screen readers.
Test coverage: shared derivation + cap + tie-break + full-set kind
behaviour; web tooltip render across all four attention kinds plus
mixed-kind overflow suppression and aria-label exposure.
Co-authored-by: Cursor <cursoragent@cursor.com>
Two real data-correctness paths the bot caught on the initial review.
1. Migration partial-failure data loss
The migration loop swallowed each failed POST and still wrote the
`migrated` flag, while the offline-cache effect mirrored the
(partial) hub state back into `hapi.scratchlist.v1.<sessionId>` -
so a transient error or cap rejection could leave entries neither
on the hub nor in localStorage. Fix:
- Track failed entries during migration and persist them back to
localStorage; do NOT advance the flag if any entry failed, so a
future mount retries.
- Gate the offline-cache effect on the migration flag. Pre-
migration, localStorage holds the v1 entries the migration
reads; mirroring an empty hub fetch over them was the wipe.
- Drop the "skip migration when hub is non-empty" gate. Combined
with the duplicate-idempotent POST short-circuit (below), a
retry against a session that another device already populated
is a safe union.
2. Duplicate POST returned 409 at cap
The route checked `count >= SCRATCHLIST_MAX_ENTRIES` BEFORE asking
the store whether the supplied `entryId` already existed, so an
idempotent migration retry against a 200-row session returned 409
instead of 200. Fix: check duplicate first via a new
`SyncEngine.getScratchlistEntry`, return the existing row with 200,
and only run the cap check for genuinely new ids.
Tests added:
- hub/routes: at-cap + duplicate entryId returns 200 (not 409); at-cap
+ new entryId still 409.
- web/hook: partial-failure persists the failed entries back to
localStorage and leaves the flag unset; offline-cache effect does
not wipe pre-migration localStorage.
Co-authored-by: Cursor <cursoragent@cursor.com>
Surfaces the smart-relative time the entry was last saved on every scratchlist row, mirroring the bucketing used in the session list: just-now -> Nm -> Nh -> Nd -> absolute date. Implementation: - Extract the existing `formatRelativeTime` helper out of SessionList into `web/src/lib/relative-time.ts` so the panel can reuse the same buckets and i18n keys (no copy-paste drift between surfaces). Also add `formatAbsoluteDateTime` for the precise-stamp tooltip line. - Add `updatedAt?: number` to the local `ScratchlistEntry` shape. v1-only callers stay valid (the field is optional and `isEntry` now accepts rows that omit it). The hub hook forwards the hub's `updatedAt` so the indicator reflects edits, not just creation. - New `EntryAgeIndicator` component: clock SVG in the same style as the existing action icons, rendered inside both panel surfaces (the older `ScratchlistList` and the drawer variant). Falls back to `createdAt` when `updatedAt` is missing (legacy v1 rows during the migration window) and renders nothing if neither timestamp is usable. - Tooltip carries the relative bucket plus the absolute timestamp on a second line; aria-label carries the relative bucket only so screen readers stay terse. - Mirror `updatedAt` into the localStorage offline cache so an offline reload still has accurate ages. Tests: - `relative-time.test.ts`: bucket math, seconds-vs-ms detection, non-finite guard. - `ScratchlistPanel.test.tsx`: indicator renders with the right smart-relative bucket, falls back to `createdAt` when `updatedAt` is absent, and renders nothing when both timestamps are zero. Co-authored-by: Cursor <cursoragent@cursor.com>
…iann#896) The POST /api/sessions/:id/scratchlist body validator left `entryId` unbounded (`z.string().min(1)`), but that string is persisted as part of the SQLite primary key. An authenticated/direct client could grow the table and its index well beyond the intended scratchlist limits by submitting oversized keys. Adds `SCRATCHLIST_MAX_ENTRY_ID_LENGTH = 128` (comfortably fits a UUID's 36 chars plus any prefix scheme we might layer on later) and applies `.max(...)` to the optional `entryId` in `ScratchlistEntryCreateRequestSchema`. Anything longer is rejected with 400 before the row hits SQLite. Test pins the new behavior: a 129-char id returns 400 and never reaches the engine. Co-authored-by: Cursor <cursoragent@cursor.com>
…dismissed (HAPI Bot, PR tiann#896) The previous state machine swallowed the migration banner if the operator reloaded the page before clicking dismiss: the migration flag was set on success, and on remount the init logic mapped a flag-set/dismiss-not-set session to 'pre-migrated', a state the banner explicitly refuses to render. Net effect: a migrated session never prompted for affirmative dismissal. Fixes: - Drop the 'pre-migrated' state. The dismissal flag is now the only signal that suppresses the banner; the migration flag alone means 'banner shows until dismissed' (now or after a reload). - Sessions that had nothing to migrate (no v1 entries in localStorage) pre-emptively write BOTH flags - migrated AND dismissed - so the bot's banner-stickiness fix doesn't surface a banner that has nothing to announce on freshly-created v2 sessions. Tests: - New `reload-before-dismiss leaves the banner visible` test pins the fix end-to-end: mount #1 migrates -> 'completed', unmount, mount #2 on the same session reads the localStorage flags and stays 'completed'. - New `opts fresh sessions out of the banner pre-emptively` test pins the no-v1-entries shortcut. - Existing `does not re-migrate on a mount where the migrated flag is already set` updated to assert 'completed' (not the dropped 'pre-migrated'). - Existing `skips migration when localStorage is empty` updated to assert the new 'dismissed' status + the banner-dismissed flag. - Banner test for the 'pre-migrated -> nothing' case removed (the state no longer exists). Co-authored-by: Cursor <cursoragent@cursor.com>
Two operator-feedback fixes on the new session-list HoverTooltip:
1. Tooltip background was bg-[var(--app-bg)] - the same variable as the
session row underneath - so the tooltip looked translucent and the row
text bled through. Switch to bg-[var(--app-secondary-bg)] (#2C2C2E
dark / #f3f4f6 light, both opaque) and bump shadow-md -> shadow-lg.
Telegram-themed clients still pick up tg-theme-secondary-bg-color so
the tooltip stays on-theme.
2. The 'unread' attention dot tooltip rendered 'New activity / Updated 5m
ago', but the relative-time pill ('5m ago') is already on the right
edge of the same session row. The tooltip body just duplicated info.
Render only the title for the unread case; drop the
session.tooltip.unread.body i18n key from en + zh-CN.
The other tooltip kinds (permission/input list tools, background lists
task count) keep their bodies - those facts are not visible elsewhere on
the row.
Co-authored-by: Cursor <cursoragent@cursor.com>
When HAPI's ACP client connects to Cursor without the proprietary `parameterizedModelPicker` capability, Cursor returns one wire id per base and HAPI builds a flat picker. The launcher pins `default[]` via `setConfigOption` on spawn and clears `session.model` to undefined. SessionChat only forwarded `selectedModelBase` to HappyComposer in dual mode, so the flat-mode picker fell back to `model === option.value` where `null === 'auto'` failed on every row, leaving the Default radio empty even though that's what ACP is actually running. `resolveSessionCursorBaseSelectValue` already handles flat mode via its early return (`picker.wireId ?? 'auto'`). Drop the `mode === 'dual'` gate so flat mode also gets a defined value, which matches the 'auto'-valued Default row. Closes #46 Co-authored-by: Cursor <cursoragent@cursor.com>
PWA manifest now declares a `share_target` so Android Chrome surfaces
HAPI in the system share sheet for any app (Photos, Files, browser).
Pipeline on share:
1. Service worker intercepts POST /share, parses the multipart payload
(title/text/url + N files), persists it in IndexedDB under a
transfer id, and 303-redirects to /share?id=<id>. The 303 forces
Chrome to convert the POST into a GET so the SPA route mounts.
2. New /share route loads the transfer, previews the content, and
lets the user pick a recent active session (top 5 by activeAt) or
a "+ New session". Tapping a session stashes the transfer id in
sessionStorage and navigates to /sessions/:id.
3. SessionChat mounts a ShareSeedConsumer once the AssistantRuntime
is up; it consumes the pending transfer once per mount, seeds
composer text + per-file attachments via the existing
attachmentAdapter, then deletes the IDB row so a refresh of the
session page does not replay the upload.
The whole feature reuses the existing /sessions/:id/upload endpoint;
no hub or shared changes.
Limitations (also disclosed in the PR body):
- PWA must be installed; Android Chrome only registers share_target
on install. iOS Safari ignores the manifest field entirely.
- File MIME accept list is broad (`*/*` fallback); some Chrome
versions still filter despite this.
Tests:
- shareTransfer.test.ts (8) covers payload parse, multi-file order,
type fallback, ingest redirect shape and error propagation.
- sharePendingState.test.ts (3) covers atomic consume + overwrite.
Closes: pending upstream issue (filed before PR per intake doc).
Co-authored-by: Cursor <cursoragent@cursor.com>
In soup context (driver-manifest layer order), feat/companion-fcm-push-api is layer 1 and bumps SCHEMA_VERSION 9->10 with fcm_devices. v2 was developed against upstream/main where SCHEMA_VERSION=9 and bumped to 10 with session_scratchlist. Two layers writing the same v10 collide. This branch (used as a soup-only layer; do NOT submit upstream as PR; operator-fork PR tiann#896 stays at v9->v10) renumbers scratchlist's bump to v10->v11: - SCHEMA_VERSION: 10 -> 11 - buildStepMigrations: stepMigrations[9] -> stepMigrations[10] - migrateFromV9ToV10 -> migrateFromV10ToV11 - migration-v10.test.ts -> migration-v11.test.ts (with V*->V** copy updates) Note: this branch is NOT standalone-testable. The migration-ladder gap at stepMigrations[9] exists because this branch alone does not include feat/companion-fcm-push-api's fcm_devices migration. Tests pass only inside the soup integration tree where both layers are merged together. Co-authored-by: Cursor <cursoragent@cursor.com>
…ing them Pairs with PR tiann#928 (KillMode=process). When the runner systemd unit uses KillMode=process, agent children survive runner restart - they're spawned `detached: true` (cli/src/runner/run.ts:454) so they keep running after the runner exits. On runner cold start they eventually emit a heartbeat / spawn-complete webhook reporting their sessionId and PID. Before this change the runner saw "no tracked session for this PID" and killed the orphan as defense-in-depth against same-runner-instance ghost sessions. That defense was correct when the only orphan source was a timed-out spawn whose tree-kill had already fired; it's wrong when the orphan source is "different runner lifetime, KillMode=process let the child survive". Differentiate via process liveness: - Dead PID → case 2 (timeout already handled it). Log + no-op. - Alive PID → case 3 (post-restart survivor). Adopt: build a TrackedSession entry without childProcess and store it in pidToTrackedSession. Subsequent webhooks update normally; stopSession (line 659) falls into the no-childProcess branch and uses killProcess(pid). Closes the tiann#929 (Tier B) re-attach scenario for runner-only restarts where children survive. The hub-down / full-reboot scenario where children are dead is intentionally out of scope for this iteration - that needs un-archive + respawn-with-resume which is a bigger semantic change. Soup-only for now; upstream PR held until tiann#928 lands. Note on PID recycling: this implementation trusts the webhook's self-reported sessionId. PID recycling between runner restart and webhook delivery would be a low-probability collision; v2 should cross-check via process start-time or cmdline pattern. AI-disclosure (CONTRIBUTING.md): drafted with claude-opus-4.7 in the backup-agent role during a fork-side post-mortem of the cascade outage chain that led to PRs tiann#928 and tiann#929. Co-authored-by: Cursor <cursoragent@cursor.com>
…t, drop top-5 cap
The /share picker was visually re-shuffling under the operator's finger
as SSE events rolled in: every session metadata patch refreshed the
React Query cache, the useMemo recomputed, and items reordered (often
within a second of opening the share sheet). Sort key was activeAt,
which heartbeats every few seconds while a session is connected,
making the noise floor even higher.
Three changes:
- Snapshot the active-session list once when sessions finish loading
via useState + a deferred useEffect. The picker is a one-shot
interaction; closing the share sheet and re-sharing produces a
fresh snapshot, so freezing for the duration of the picker view is
the right trade.
- Sort by updatedAt desc to match SessionList's canonical "most
recent interaction first" order. updatedAt only moves on
user-meaningful events, not heartbeats.
- Drop the TOP_SESSIONS=5 cap. The picker is already inside an
app-scroll-y container, so showing all active sessions and letting
the operator scroll matches the operator's mental model better
than an arbitrary truncation.
Per operator dogfood report: "list of recent sessions is constantly
updating; should be just a scrollable list, from most recent
interaction to not."
Co-authored-by: Cursor <cursoragent@cursor.com>
Migrates the XR Garden VR interface from a standalone fork into the daily driver web app as a lazy-loaded route at /garden. Garden features included: - Spatial agent orbs (R3F/WebXR) with gaze dwell focus and sticky attention - Per-backend voice bridge (ElevenLabs, Gemini Live, Qwen) with sticky focus - PCM-RMS audio level visualization (input + output rings on orbs) - Per-orb voice persona (deterministic djb2 hash -> voice ID) - Engine dropdown in VR HUD - Platonic solid shape changes by session state - Seen-state sync: viewing a session in flat HAPI marks it seen in Garden - VR entry seeds attention from flat web unseen state Infrastructure: - R3F/Three.js deps added to web/package.json - IWER WebXR emulator stubbed in production builds (saves ~6-8 MB) - @bufbuild/protobuf ^2.10.2 override for IWER dev/test compatibility - vite.config.ts -> function form for mode-conditional IWER aliasing - vitest.config.ts handles function-shaped vite config - All 1078 tests pass Retired: garden-web.service (hapi-hub.service now serves /garden directly) Co-authored-by: Cursor <cursoragent@cursor.com>
Garden layer merges last in the daily driver manifest, after feat/pwa-share-target. Resolves router (/share + /garden) and vite.config (share_target manifest + IWER stub) so hapi-driver-rebuild can merge without hand-editing the read-only driver tree. Co-authored-by: Cursor <cursoragent@cursor.com>
SessionSummary gained pendingRequests[] from feat/session-list-rich-tooltips (layer 7 in the daily manifest). Garden test makeSession() helpers need the field so soup typecheck passes after manifest merge. Co-authored-by: Cursor <cursoragent@cursor.com>
Bun's hoisted @pmndrs/xr path breaks TS2742 on exported inferred xrStore when the soup runs strict typecheck across packages. Co-authored-by: Cursor <cursoragent@cursor.com>
Mirror the Codex session importer for Claude Code: scan ~/.claude/projects transcripts, map user/assistant(text/thinking/tool_use)/tool_result records to Hapi imported messages, persist via the shared store/sync engine, and expose /api/claude/status|sessions|sync-session plus a web import dialog. Extract the flavor-agnostic import engine into transcriptImport.ts so Codex and Claude share session-target selection, dedupe, persistence and event emission (single source of truth); each flavor keeps only its scanner, parser and adapter. Codex behavior and routes are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When resuming an imported Claude session still held open by a running background/interactive agent, 'claude --resume' is rejected (currently running as a background agent), the error was swallowed to {}, and the launcher retried forever. Now: detect liveness via 'claude agents --json' and branch a copy with --fork-session (recording forkedFrom) instead of taking over; logger serializes Error to {name,message,stack}; launcher classifies unrecoverable resume errors and stops retry (MAX_LAUNCH_RETRIES=3), surfacing the real reason. Dead sessions fall through to plain --resume (unchanged); codex and other flavors untouched.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mount a global XR launcher in the app shell: WebXR-capable browsers get a sessions-toolbar button and floating XR affordance; Garden loads lazily as an overlay. /garden deep-links open the overlay and return to /sessions. Co-authored-by: Cursor <cursoragent@cursor.com>
The direct-import path (importSingleSession) wrote rows via addMessage, stamping created_at/invoked_at with import time, so imported Claude/Codex sessions sorted as "active today" and lost their original message timeline (review finding on tiann#942). - Carry each JSONL record's top-level timestamp through import data (new ImportedMessage wrapper + shared parseImportedTimestamp) for both Claude and Codex parsers. - Insert via copyMessageToSession with createdAt/invokedAt = original timestamp (fallback to file mtime). - Back-fill the newly-created imported session's updatedAt to real last activity (setImportedSessionActivity, new-session path only); existing/online sessions keep forward-only activity semantics untouched. - New unit tests (Claude + Codex): timestamp preservation + mtime fallback. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
getLiveAgentKind() runs 'claude agents --json', which honors CLAUDE_CONFIG_DIR / HAPI_CLAUDE_PATH. It ran before opts.claudeEnvVars were applied to process.env, so when a session supplies those overrides the liveness probe queried the default Claude config, missed the live agent, and the launch then resumed against the overridden config and hit the occupied-session path instead of forking (review finding on tiann#942). Move the env application above the fork decision so probe and launch share the same Claude env. Adds a regression test asserting the override is visible to getLiveAgentKind. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Standalone bun script that walks every legacy chat at ~/.cursor/chats/<wsh>/<uuid>/store.db, stages an isolated $HOME/$HAPI_HOME, copies the store + synthesizes meta.json, and drives agent acp initialize + session/load. Surfaces a per-chat outcome class matching the upstream Cursor import refusal contract (verify_load_failed, verify_timeout, corrupted_store, ...). Doubles as a regression harness operators can re-run when cursor-agent updates its on-disk schema. Produces CSV at the configured path. Probe shape mirrors hub/src/cursor/acpVerifyProbe.ts (introduced by tiann#844). Script is intentionally self-contained so it remains runnable even before the import endpoints land. Co-authored-by: Cursor <cursoragent@cursor.com>
Adds /api/cursor/importable-sessions + /api/cursor/import endpoints mirroring the codex import shape from tiann#796, with strict ACP-only refusal contract enforced before any HAPI row is created. A maintainer-side audit ran 391/391 = 100% pass on a real chat library before this code shipped; the strictness is theoretical-cost-only in practice. Architecture: parallel /cursor/* endpoints alongside /codex/*, shared types under hub/src/web/routes/_agentImport/types.ts. The cursorImporter wraps the existing cursorLegacyMigrator verify-probe pattern. Legacy stream-json stores are transplanted to the ACP location only after agent acp initialize + session/load both succeed. ACP-format stores are imported as-is. Refusal cases (verify_load_failed, missing_on_disk_store, target_already_exists, already_imported, agent_binary_not_found, verify_timeout, corrupted_store, ambiguous_legacy_store, internal_error) leave disk and DB state untouched. 20 unit tests cover discovery, every refusal branch, both happy paths, and route shape. Real agent acp integration coverage lives in the existing cursorLegacyMigratorIntegration harness. Refs tiann#732 Co-authored-by: Cursor <cursoragent@cursor.com>
Click rendered mermaid blocks in chat to open a zoomable full-screen viewer. Re-renders from source in the modal with the current theme. Closes tiann#737. Co-authored-by: Cursor <cursoragent@cursor.com>
Auto-scale diagrams to fill the viewer instead of opening at intrinsic mermaid size. Reset returns to fit; zoom label is relative to fit (100%). Co-authored-by: Cursor <cursoragent@cursor.com>
Use visualViewport for fit scale, full-screen pan layer, and a floating toolbar so the diagram can use the whole display. Co-authored-by: Cursor <cursoragent@cursor.com>
Second mermaid.render on open often left a 0×0 SVG while fit scale was computed from the loading placeholder. Reuse the inline SVG in the modal and measure viewBox with retried fit-to-screen. Co-authored-by: Cursor <cursoragent@cursor.com>
Inlining the same mermaid markup twice duplicates element ids and breaks url(#ref) resolution in the modal copy. Prefix ids and hrefs for lightbox only. Co-authored-by: Cursor <cursoragent@cursor.com>
…tion # Conflicts: # hub/src/fcm/fcmNotificationChannel.test.ts
…ation # Conflicts: # hub/src/socket/handlers/cli/sessionHandlers.ts # shared/src/schemas.ts
# Conflicts: # hub/src/store/index.ts # hub/src/store/types.ts
Persist overseer events in SQLite v11 (events, event_links, FTS5), record from assistant notify summaries with hub fallbacks, expose GET /api/system-events, and add a read-only settings debug pane. Includes db-prep v11→v10 downgrade and Playwright fixture smoke. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Soup stacking: scratchlist and FCM layers also claim v11. Idempotent CREATE IF NOT EXISTS for all three substrates avoids rerere dropping tables when overseer merges last.
Events tables must not own SCHEMA_VERSION — ensureOverseerEventsSchema runs on every Store boot so v11-stamped soup DBs self-heal. Add events/event_links to REQUIRED_TABLES, fix content-storing FTS delete/update triggers, and extend db-prep with full soup v11 downgrade plus --drop-overseer-events. Co-authored-by: Cursor <cursoragent@cursor.com>
events.related_session_id FK blocked DELETE /sessions and reopen merge (delete old row). Detach on intentional delete; repoint to new session id on mergeSessions so overseer audit trail survives reopen/resume id swap. Co-authored-by: Cursor <cursoragent@cursor.com>
…tone (#22) Events embed payload.session (id, tag, name, project, flavor) at write time so audit rows stay self-describing after hard-delete. deleteSession snapshots identity into init-gated deleted_sessions before detach. Co-authored-by: Cursor <cursoragent@cursor.com>
Add init-gated inbox_items schema, per-session promotion from attention events, coarse-rank/oldest-within ordering, operator-action logging, REST + settings debug pane stacked on the #22 events substrate. Co-authored-by: Cursor <cursoragent@cursor.com>
…-promoted
The hub silence sweep (checkStaleSessions) emitted `stale` events with
attention_candidate=1, so every idle session auto-promoted into the operator
inbox. On a fleet with N persistent-but-idle agents this floods the inbox with
N identical "No agent output for 30 minutes" items - the "narrating log file"
failure mode the framing doc explicitly warns against ("under-surface rather
than over-surface", build-sequence Step 2.5 risk).
Inferred silence is ambient awareness, not an operator-action signal
(operator_action_required was already 0). Flip attention_candidate to 0 so the
event is still recorded - the Overseer/replay harness can synthesize a coalesced
"N sessions idle" view when it judges it worth attention - but it no longer
auto-floods the inbox. A worker that EXPLICITLY self-reports `stalled` keeps
attention_candidate=1 via deriveAttentionCandidate; only this inferred sweep
becomes captured-only.
Belongs to the events-substrate sweep (#22); branched off the inbox-substrate
tip (#23) to be soup-able without disturbing the live #22/#23 worktrees.
Co-authored-by: Cursor <cursoragent@cursor.com>
Overseer is now a real conversational entity in the hub with a stable identity, system prompt, and a dedicated voice surface (consumes the existing stt/tts voice substrate; does not reimplement it). It is inform-only at this stage - no dispatch, no confirm, no state mutation. 7 read-only tools, all unit-tested against the live substrate: query_events, query_inbox, get_session_state, get_session_recent_output, get_worker_health, explain_priority (reuses stored reason_for_priority), list_active_workers. Worker health derives reported/observed/inferred state per contracts §2. Voice conversation turns are written back to the events stream as event_type=convo_turn for provenance. Store: additive queryEvents (project/sourceKind/severity/time filters) and inbox status/category filters - no changes to existing query shapes. Protocol layer (shared/src/overseerEntity.ts): identity, tool catalog with zod arg schemas, system prompt, worker-state derivation helpers, convo_turn builder. 24 new tests pass. Co-authored-by: Cursor <cursoragent@cursor.com>
Hub-observed http(s) URLs from message ingest become captured-only link_seen events with artifact_refs kind:url. Session Log can filter via existing GET /api/system-events?sessionId=. Co-authored-by: Cursor <cursoragent@cursor.com>
First subtle product expression of Overseer in session chrome: a Log control next to Outline that lists durable system events for related_session_id = this session (complete even when the transcript window is truncated). Outline stays as the loaded-message TOC. Links tab filters via GET /api/system-events?eventType=link_seen and surfaces artifact_refs kind:url from the hub URL scoop (#22). No fleet inbox in the outline slot; no standalone session-log PR. Co-authored-by: Cursor <cursoragent@cursor.com>
…Session Log All Hub-inferred 30m silence is ambient state (derive from last activity), not a durable event — stop writing stale rows. Session Log All excludes link_seen and historical stale; URLs live only under the Links tab. Co-authored-by: Cursor <cursoragent@cursor.com>
…gs About Replace tip's monolithic settings/index with upstream tiann#1027 nested category routes. Mount EventsDebugControls and InboxDebugControls on About (alongside Companion pairing) — proper tip fix instead of soup-only Events glue. Co-authored-by: Cursor <cursoragent@cursor.com>
Session Log All already excludes stale; apply the same view filter to the About Events debug list so leftover hub-silence rows from before C stop- persist do not keep showing up in operator UI. Co-authored-by: Cursor <cursoragent@cursor.com>
Use shared relative-time (tooltip = absolute). Drop per-row provenance and the LINK_SEEN badge-on-Links-tab noise. Links rows are a single compact clickable host/path label — not "Link seen: …" plus a second URL. Co-authored-by: Cursor <cursoragent@cursor.com>
The machine-only notify contract rides in-band (works across every agent flavor) but polluted the human view on two channels: the injected inline contract prefix on operator messages, and the trailing AGENT_NOTIFY_SUMMARY line on agent replies. Empirical probe of 354k stored messages: Cursor corrupts its own token ~13.6% of turns (AGENT_NOTIFY_SUMMARY -> AGENT_NOTIFY_SUMARY, dropping one of the doubled M); Claude/Codex: 0. The strict startsWith match double-failed on those - the line neither stripped (leaked to UI) nor parsed (no inbox item). - shared/messages.ts: collapseRepeats() + matchNotifySummaryLine(), a corruption-tolerant detector shared by parse and strip. extractNotifySummary now recovers the ~780 corrupted Cursor turns. - shared/overseerEvents.ts: stripAgentContract() removes the leading contract prefix block and the trailing (collapse-normalized) summary line. Render-only; the raw text stays in the store so the overseer event/inbox pipeline is unaffected. - web/assistant-runtime.ts: strip at the block->message conversion for both agent-text (trailing line) and user-text (leading prefix). Render and copy both derive from the stripped content. - hub/fcm: strip the raw-text fallback body so short replies do not leak the marker into push notifications (parsed summary path still reads raw). Tests: 16 new (collapseRepeats, matchNotifySummaryLine correct+SUMARY+reject, stripAgentContract prefix/trailing/corrupted/quote/empty, round-trip). 52/52 shared green. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ff5f384007
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // sees the machine contract. The raw text stays in the store for the | ||
| // overseer event/inbox pipeline. copyText derives from this content, | ||
| // so the clipboard is clean too. | ||
| content: [{ type: 'text', text: stripAgentContract(block.text) }], |
There was a problem hiding this comment.
Strip contract from nested task messages
This only strips blocks that are converted into top-level assistant-ui messages. Subagent/Task traces are reduced into toolBlock.children and then rendered directly from block.text in HappyNestedBlockList, bypassing toThreadMessageLike; when a Task/Agent/CodexAgent child response ends with AGENT_NOTIFY_SUMMARY (or the SUMARY variant), opening the task details still shows the machine contract to the human. Strip before embedding child ChatBlocks or in the nested renderer as well.
Useful? React with 👍 / 👎.
| // non-Cursor flavors the hub prepends an inline contract prefix to | ||
| // the stored operator message (#20); stripAgentContract removes that | ||
| // leading block. No-op when absent. | ||
| content: [{ type: 'text', text: stripAgentContract(block.text) }], |
There was a problem hiding this comment.
Strip user prefix before building outline labels
Stripping the stored user text only inside toThreadMessageLike is too late for the conversation outline: SessionChat builds outline items from reconciled.blocks, and buildConversationOutline labels them from raw block.text. For non-Cursor sessions where the hub stores the inline contract prefix on operator messages, opening the Outline still shows labels starting with the machine instruction instead of the user's prompt. Apply the same stripping before outline generation or strip in the outline label path.
Useful? React with 👍 / 👎.
| // non-Cursor flavors the hub prepends an inline contract prefix to | ||
| // the stored operator message (#20); stripAgentContract removes that | ||
| // leading block. No-op when absent. | ||
| content: [{ type: 'text', text: stripAgentContract(block.text) }], |
There was a problem hiding this comment.
Keep user-authored summary lines visible
Using the same stripAgentContract on user messages removes any prompt whose last line is a bare AGENT_NOTIFY_SUMMARY {...} line. When an operator is asking about or testing this contract, the stored/sent text is still present but the web render and copy action silently omit that final line; the user side only needs the leading injected prefix removed, so use role-specific stripping instead of stripping trailing notify summaries from user-authored content.
Useful? React with 👍 / 👎.
| const lines = out.split('\n') | ||
| let lastIdx = lines.length - 1 | ||
| while (lastIdx >= 0 && lines[lastIdx].trim() === '') lastIdx -= 1 | ||
| if (lastIdx >= 0 && matchNotifySummaryLine(lines[lastIdx])) { |
There was a problem hiding this comment.
Strip malformed notify attempts too
This only removes a trailing marker when matchNotifySummaryLine accepts it, so malformed attempts such as AGENT_NOTIFY_SUMMARY {"status":"done" or AGENT_NOTIFY_SUMMARY not-json still render in the top-level web message and FCM fallback. The hub already treats those lines as machine-contract validation failures via detectMalformedNotifySummaryLine, so the human-facing stripper should remove the token line before/independent of JSON parseability.
Useful? React with 👍 / 👎.
8045059 to
4cb90c2
Compare
Summary
The
AGENT_NOTIFY_SUMMARYmachine contract rides in-band (works across every agent flavor) but polluted the human view on two channels: the injected inline contract prefix on operator messages, and the trailing summary line on agent replies.Empirical probe (354k stored messages): Cursor corrupts its own token ~13.6% of turns (
AGENT_NOTIFY_SUMMARY->AGENT_NOTIFY_SUMARY, dropping one of the doubled M); Claude/Codex: 0. The strictstartsWithmatch double-failed on those - the line neither stripped (leaked to UI) nor parsed (no inbox item).Changes
shared/messages.ts:collapseRepeats()+matchNotifySummaryLine()- a corruption-tolerant detector shared by parse and strip.extractNotifySummarynow recovers the ~780 corrupted Cursor turns.shared/overseerEvents.ts:stripAgentContract()removes the leading contract-prefix block and the trailing (collapse-normalized) summary line. Render-only; raw text stays in the store so the overseer event/inbox pipeline is unaffected.web/assistant-runtime.ts: strip at the block->message conversion for bothagent-text(trailing line) anduser-text(leading prefix). Render and copy both derive from stripped content.hub/fcm: strip the raw-text fallback body so short replies do not leak the marker into push notifications (the parsed-summary path still reads raw).Design notes
AGENT_NOTIFY_SUMMARYtoken (no estate-wide rename) - collapse-normalized detection handles the observed dup-drop foible. ASCII, fail-safe.Test plan
--verify(isolation typecheck failures are pre-existing base/cross-layer debt:useSSE.tson feat(overseer): read-only Overseer entity + 7 query tools + voice route #56,modelErrorCopy, scratchlistserviceTier- resolve in full soup).Stacked on #56 (
feat/overseer-readonly-entity). Carries inherited garden content pending the stack-wide garden rebase for clean upstream PR.Made with Cursor