Skip to content

feat(inbox): open threads after compaction/resume, reply_to checks, chat-or-Inbox rule, notes refreshed at start - #2246

Merged
backnotprop merged 14 commits into
mainfrom
feat/inbox-compaction-instructions
Oct 27, 2026
Merged

backnotprop merged 14 commits into
mainfrom
feat/inbox-compaction-instructions

Conversation

@backnotprop

@backnotprop backnotprop commented Oct 27, 2026 •

Copy link
Copy Markdown
Owner

Why

After a context compaction, an agent kept a thread id from its own summary and sent seven messages into the wrong Inbox thread (the misfiled-thread AAR, 2026-10-27). The Inbox filed each one where it was told. Separately, the owner asked that every rule an agent needs reaches it with no setup, and that installed notes update by themselves. Then, from a meta-analysis: every Inbox text decided chat-or-Inbox by whether the person "is here", which an agent cannot observe. An agent raised an Inbox decision again as a blocking question in the chat once the person typed there.

What changed

  • The session's open threads, from the Inbox. After a compaction (Claude Code, Pi) and on a resume (Claude Code), the session gets its open threads (inboxSessionThreadsText): one header line, at most 5 rows, then where the rest is (More: read_thread with no id …; search …). This comes from a read-only peek (_meta inbox-peek; health reports peek: true). There is no list from an older Inbox, and no error after 1.5 s.
  • reply_to checks.
    • INBOX_REPLY_TO_CHECK is in the list header.
    • The reply_to field and the wake note say "never an id from memory".
    • When reply_to/thread names a thread with person messages the session has not read, send_message's result adds a "You last wrote in this thread at …" line (not_read_since).
  • Chat or Inbox (new rule; replaces "when the person isn't here"). A decision in the Inbox stays there. When the person writes in the chat, the agent says what waits and links the thread (redundancy is fine). It never also asks it in the chat with a blocking question tool (AskUserQuestion or similar). The same wording is used in every channel. A chat question is only for something new the person raised in the chat that needs no pictures or files, or when they ask. This is in the agent tool's lead and the server instructions (INBOX_CHANNEL_RULE), the reminder (INBOX_REMINDER_TEXT, _NO_WAKE, so after a compaction too), the thread list header (INBOX_SESSION_THREADS_CHANNEL), plannotator inbox --help ("Chat or Inbox"), the skill and note v3.
  • Every rule through channels that update by themselves:
    • the wake note, read_thread and search leads;
    • the asked_by / include_resolved descriptions;
    • the Recommended: line in body;
    • the server instructions.
      These all fit within the 2,048-character caps: instructions 2,042, agent tool 1,910 (about 2,029 with the Board's card line). The Pi tool cost was re-measured: 9,298 bytes. --help also gains the reply_to check: take the id from the Inbox, never from memory.
  • Note v3 and auto-refresh. refreshInboxNotes runs at every Inbox start, so an update reaches installed notes when the Inbox restarts onto it. It works deterministically on the existing marker rules:
    • It finds exactly one start line before exactly one end line; anything else is damaged and left untouched.
    • It replaces the block only when the block is byte for byte an earlier Plannotator version and the one its record names.
    • It never touches a block the person edited or removed, a file with no record, a read-only file, a file with other hard links (a dotfiles copy), or a file that changed (bytes or mode) between the read and the write.
    • It keeps CRLF, the BOM and the file mode. A symlink is written at its target and stays a link. The write is atomic. A shared file is written once.
    • agent-notes.json is written before the file and put back if the file's write fails. If the records can't be written, the note isn't written either.
    • Every version's note text is literal. The SHA-256 of the v1, v2 and v3 blocks is pinned in a test, so a released note can't change without a new version.
    • Tests cover every case: packages/server/inbox-agent-notes-refresh.test.ts.
  • send_message's result hints are built as a list, so this PR and the recommended-marker PR merge cleanly in either order (verified both ways).

Audit: which channel carries each rule, per host

Hosts:

  • CC = Claude Code (tool on by default).
  • Pi and OC2 = OpenCode 2: the tool comes from the Settings switch.
  • OC1 = OpenCode 1: the tool, with no wake.
  • Codex/others = MCP server after the one-time connect step.
Rule Tool / field text (every request) Server instructions Session-start reminder After compaction / resume Wake (reply as a turn) Send result
Chat or Inbox lead: CC, Pi, OC1, OC2 Codex/others CC, Pi, OC1, OC2 reminder + thread header: CC (both), Pi (compaction) n/a —
reply_to from the Inbox, never memory reply_to field: all hosts; wake note: CC, Pi, OC2 all MCP hosts — header check: CC, Pi instruction line: CC, Pi, OC2, Codex not-read line: all hosts
Recommended: line, never in a label body field: CC, Pi, OC1, OC2; send_message guide: Codex/others — — — — nudge (other PR): all hosts, server-side
Name a thread the person started thread field: all — — — INBOX_NAME_THREAD_LINE: CC, Pi, OC2, Codex namingLine; name_this_thread in read_thread / wait_for_reply: all
:::question format body / send_message description: all pointer: all MCP — — — —
Find threads (list, search) read_thread / search leads, asked_by, include_resolved: all all MCP — "More:" line: CC, Pi closing line: CC, Pi, OC2, Codex —

Where a host has no automatic channel:

  • OpenCode 1, OC2 and Codex: no compaction or resume list. They get the rules from the field descriptions on every request.
  • OpenCode 1: no wake.
  • Codex: has no tool until the one-time connect step. Whether Codex surfaces MCP server instructions is unverified.
  • The note: opt-in, and carries nothing that is not also above.

Live checks still owed

  • The guarded SessionStart "skipped" line. The harness can't tell it apart, because next.trace lists non-matching links as bypassed.
  • The env-reset ordering: whether the compact command hook runs before the next turn.step/turn.start.
  • Whether session.compact reaches the mod under the real guard.
  • The first Inbox start on this build refreshes an installed v1 note to v3. Seen in tests and the real-start test; not yet watched on a real machine.

Tests

  • New: inbox-agent-notes-refresh.test.ts, agent-link-session-threads.test.ts, session-threads-text.test.ts; register / mod cases.
  • The refresh tests also cover: hard links, an unwritable agent-notes.json, the mode changing, a record naming v1 over a v2 block, and the pinned hashes.
  • The reminder tests compare against the exported constants and check what each line must carry.
  • Typecheck is clean, and builds ran in order.
  • The only failures under full load were timing or port flakes (mod/inbox.test.ts real processes, live-proxy-node-port), which pass alone.
  • The mod harness passes 62/62.

Approved by the owner in the Inbox, 2026-10-27

…m the Inbox, and check reply_to

The misfiled-thread AAR (2026-10-27): an agent carried a thread id across a
context compaction and replied into the wrong thread.

- After a compaction (Claude Code mod: session.compact refreshes
  PLANNOTATOR_INBOX_REMINDER for the existing SessionStart compact hook; Pi:
  the reminder re-sent after a compaction) and on a Claude Code resume, the
  session gets up to 5 of its open threads, read from the Inbox with a peek
  that makes no project and counts as no read. Bounded at 1.5 s, never starts
  the Inbox.
- INBOX_REPLY_TO_CHECK in reply_to and read_thread descriptions; send_message
  adds one line when the session has not read the thread for 3 h or ever.
- Agent note v2 adds the check and the Recommended: line; v1 blocks upgrade in
  place on re-add, even with the record lost; edited blocks are kept.
- Tool cost re-measured on Pi (8,964 bytes).
…-read line

- Health advertises peek: true; the mod and agent-link read the thread list
  only from an Inbox that says so (an older one would make a project for every
  folder that compacts or resumes).
- classic.SessionStart is matched on source resume; a string additionalContext
  from another handler is kept.
- The thread list leaves PLANNOTATOR_INBOX_REMINDER at the next main-loop step
  or turn, so ids and subjects do not linger in Bash children's environment.
- The not-read line only for a message that names its thread (reply_to or a
  thread name), and worded "You last wrote in this thread at" when the
  session's own message was its latest contact.
- Upgrading a shared note file refreshes every record naming that file.
- Subjects drop zero-width and bidi characters and fold C1 controls.
- Docs: hooks.json's description did change; the above.
…d_thread list, search); health's peek in the mobile ADR
…nstalled notes refreshed at start

- Wake note: reply_to set to that reply id, never an id from memory. Server
  instructions: reply_to from the Inbox, never from memory; read_thread with
  asked_by anyone lists every thread. read_thread and search leads, asked_by
  and include_resolved described. body: the recommendation goes on the
  Recommended: line, never in a choice label.
- refreshInboxNotes at every Inbox start: an installed note that is byte for
  byte an earlier version (and its record's) is replaced in place; edited,
  removed, damaged, read-only, record-less or concurrently changed files are
  left alone; CRLF, BOM, mode and symlinks kept; shared files written once.
- send_message's hints built as a list, so the recommended-in-label nudge
  branch and this one merge in either order.
- Tool cost re-measured on Pi (9,227 bytes).
…uctions

# Conflicts:
#	packages/server/inbox-mcp.ts
… the person seems to be here

The owner, 2026-10-27: an agent raised an Inbox decision again as a blocking
question in the chat when the person typed there. A decision in the Inbox
stays there; in the chat the agent says what waits and links the thread, but
never asks it again with a blocking question tool (AskUserQuestion or the
like); a chat question only for something new raised in the chat that needs
no pictures or files, or when the person asks.

- INBOX_CHANNEL_RULE in the agent tool's lead and the server instructions
  (2,027 of 2,048), the reminder (INBOX_REMINDER_TEXT, _NO_WAKE; after a
  compaction too), INBOX_SESSION_THREADS_CHANNEL in the thread list, the
  --help "Chat or Inbox" section and the skill.
- Note v3 with the rule in place of v1's presence lines; v2 (never released)
  frozen beside v1 so refreshInboxNotes still takes it.
- Wake note and get_guide_brief's lead shortened to fit; Pi tool cost
  re-measured (9,283 bytes).
…ard links left alone, records before the file, one channel-rule wording

- Note v3's text is all literals (no shared constant); a test pins the
  SHA-256 of the v1, v2 and v3 blocks, so a released note cannot change
  without a new version.
- refreshInboxNotes leaves a file with other hard links alone
  (hard-linked), writes agent-notes.json before the file and puts it back
  if the file's write fails (refused, nothing written, when the records
  cannot be written).
- One wording: 'when the person writes in the chat' and 'never also ask it
  in the chat with a blocking question tool (AskUserQuestion or similar)' in
  the tool lead, server instructions (2,042 of 2,048), reminder, thread
  list, note, skill and --help.
- --help's Threads: take reply_to from the Inbox, never from memory, plus
  INBOX_REPLY_TO_CHECK.
- Reminder tests compare against the constants and check what the lines
  must carry; tests for the record naming v1 over a v2 block, the mode
  changing between read and write, and an unwritable agent-notes.json.
- Docs: the block's v3 marker, the refresh's new cases. Pi tool cost
  9,298 bytes.
…earlier block, so the next start repairs it

refreshInboxNotes writes the record before the note; a crash between the two
(or a failed put-back) left the record naming this version over a v1/v2
block, which then read as the person's edit forever: never refreshed,
kept-edited on add, 'you edited it' on uninstall. ownedBy: a record naming
this version owns any earlier block exactly as Plannotator wrote it (only
Plannotator puts a record ahead of its note), in refresh, add, status and
uninstall. Tests reproduce the state: the next start repairs it, add
updates it, and an edited note under such a record stays the person's.
…-instructions

# Conflicts:
#	packages/core/inbox-types.ts
@backnotprop
backnotprop merged commit 85b2ce5 into main Oct 27, 2026
32 checks passed
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.

1 participant