Skip to content

feat(overseer): make AGENT_NOTIFY_SUMMARY contract invisible to humans#81

Open
heavygee wants to merge 148 commits into
feat/overseer-readonly-entityfrom
feat/overseer-contract-invisible
Open

feat(overseer): make AGENT_NOTIFY_SUMMARY contract invisible to humans#81
heavygee wants to merge 148 commits into
feat/overseer-readonly-entityfrom
feat/overseer-contract-invisible

Conversation

@heavygee

Copy link
Copy Markdown
Owner

Summary

The AGENT_NOTIFY_SUMMARY machine 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 strict startsWith match 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. 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; 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 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

  • Kept the existing AGENT_NOTIFY_SUMMARY token (no estate-wide rename) - collapse-normalized detection handles the observed dup-drop foible. ASCII, fail-safe.
  • Dropped the input-side store/deliver decouple in favor of strip-at-egress (lower risk; no change to agent message delivery).
  • Local CLI/Ink terminal is the agent printing to its own tty - out of scope for the remote-render fix.

Test plan

  • 16 new shared tests (collapseRepeats, matchNotifySummaryLine correct+SUMARY+reject, stripAgentContract prefix/trailing/corrupted/quote/empty, round-trip). 52/52 shared green.
  • cli typecheck clean; web/hub changed files typecheck clean.
  • Soup rebuild --verify (isolation typecheck failures are pre-existing base/cross-layer debt: useSSE.ts on feat(overseer): read-only Overseer entity + 7 query tools + voice route #56, modelErrorCopy, scratchlist serviceTier - resolve in full soup).
  • Operator dogfood on :3006: agent reply clean, operator prompt clean, inbox still populates (incl. corrupted-Cursor turns now recovered).

Stacked on #56 (feat/overseer-readonly-entity). Carries inherited garden content pending the stack-wide garden rebase for clean upstream PR.

Made with Cursor

heavygee and others added 30 commits June 13, 2026 17:39
…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>
Stacks on tiann#942 SessionImportPicker: AgentSessionImportDialog with codex|cursor
tabs, cursor row metadata + refusal UX, unified toolbar import affordance.
Claude import keeps its separate icon from tiann#942.

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>
heavygee and others added 28 commits June 20, 2026 22:48
…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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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) }],

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 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) }],

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 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) }],

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 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])) {

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 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 👍 / 👎.

@heavygee heavygee force-pushed the feat/overseer-readonly-entity branch from 8045059 to 4cb90c2 Compare July 14, 2026 13:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants