Skip to content

Messages, permission levels and conversation history (#643, #652, #657) - #664

Merged
jerpint merged 27 commits into
mainfrom
softie/merge-ali-stack
Nov 4, 2026
Merged

jerpint merged 27 commits into
mainfrom
softie/merge-ali-stack

Conversation

@woltspace-jerpint

Copy link
Copy Markdown
Contributor

This lands three of @asharakeh's PRs together, with merge commits so their history and authorship are kept:

Merge it with "Create a merge commit", not squash, so #643, #652 and #657 show as merged and git revert -m 1 can undo all three at once.

What we did on top

Review: two independent reviews of the combined branch found no blocker. Both upgraded a 0.5-shaped lodge.db through 5, 6 and 7, checked rollback on failure, checked that 0.5 refuses the upgraded db without writing to it, and checked backup and restore. The follow-ups they found (a launch marker that can stick, deleting an archived wolt that still has mail, user docs and a changelog entry for the breaking floor) come in a follow-up PR after this one.

woltspace-asharakeh Bot and others added 27 commits October 29, 2026 19:09
Adds a durable per-agent inbox that the lodge's chat connectors, IWCL and
terminal sends admit into, and a waker that tells the agent through its
engine's own input path instead of pasting into a live session. Off unless
woltspace.json sets inbox.enabled; with it off every path behaves as before.
A hint now says who the queued mail is from ("from pal-a, your human"),
never what it says. A busy engine no longer waits for its turn's end:
Claude gets the hint at its next tool step through a PostToolUse hook
(added context, nothing typed), Codex through turn/steer into its newest
turn while that turn is still in progress. Anything else, or a steer the
turn refuses, still hints at the turn's end.
…he inbox edges

- Inbox off: no launch marker is written and none holds a stop, resume,
  reconcile or adoption (as before markers). Inbox on: any failed spawn,
  paste or new window clears its attempt when tmux answers that the
  session does not exist (TmuxSessionRuntime.session_exists); a tmux that
  did not answer leaves it unknown.
- A Codex steer ends at the deliver budget however many notifications
  arrive: pending/busy before the steer was sent, unknown/timeout after.
- Rearm refuses a name that is not a wolt before taking any lock, and any
  request carrying a session's inbox token.
- A hint names "your human" first, never counted in "and N more"; other
  sender names keep only the wolt-name charset.
- A hint is not placed when the session's attempt or locator changed after
  the wake read it.
- Inbox request bodies are capped (413 too_large) before parsing.
- Clients that send an inbox token never follow a redirect.
… the Inbox page

A right-hand Inbox pill on wolt sites, the same pill as the site menu's, with
the lodge-wide queued count. Its drawer lists the mail still waiting (queued,
being read, held) with sender, time and full plain text, filtered to the
site's wolt by default or to all wolts. The lodge Inbox page shows the same
list, all wolts first; each wolt card links to its site and filters the list.

One new read: Store.messages, a paged query that returns no key, claim,
reader or route, served by GET /inbox/messages (refused to a session's inbox
token, never cached). Viewing never fetches, acknowledges, releases or wakes.
With the inbox on, a launch now records how its sessions hear of mail:
a pasted body-free hint by default, or the engine's native hooks with
inbox.native_hints true. Any other value refuses the launch before it
creates a row or retires a locator. The mode rides in the launch marker
and is published with the attempt; a live reuse keeps it, a relaunch
chooses again. Paste mode prepares no native wiring.

The waker pastes only after the agent takes input and nobody typed for
two seconds (at most five per pass), then re-reads the senders and pastes
through sessions.hint_live_agent, which checks the exact attempt, runtime,
mode and pane under the agent's lifecycle lock and the paste lock. A
changed session is not pasted into; a failed paste is unknown and never
retried. A wake that starts a session counts its fetch instruction as that
attempt's hint, so the start's pickup does not paste a second one.
Until now a session had two modes, prompt and auto, and every engine ran
"auto" with its skip-every-check flag. "auto" now means the engine's own
automatic mode, and the old behaviour has its own name, "bypass".

- One table in harnesses.py (POLICY_FLAGS) gives each engine's flags per
  level, and per isolation where they differ. A level an engine does not
  list is refused (LevelNotOffered, a 400 on the lodge spawn route), at
  spawn and again in the command builders; nothing falls back to another
  level. Codex Auto is host only (its sandbox cannot start in the
  container); opencode and hermes have no Ask flag, so their own settings
  decide and the label says so; Pi offers Bypass only.
- A stored policy has its own version (RECORD_VERSION 2), separate from
  the host grant's (POLICY_VERSION stays 1, so grants are untouched).
  ExecutionPolicy.from_record reads version 1 "auto" (and a bare string or
  no policy) as bypass and refuses an unknown version.
- Migration 6 rewrites every stored session policy to version 2 and is
  breaking: an older build refuses the file.
- Defaults are unchanged in effect: Bypass where it was "auto", Ask on a
  host without a grant. One grant allows both Auto and Bypass, and its
  wording says so.
- Capabilities report record_version and the levels each engine offers.
  The CLI has --policy, the TUI a level picker, and both list a version 1
  auto record as "bypass*".
Codex's approval policy leaves it to the model when to ask, and the only
values this Codex accepts are on-request and never. Only its sandbox makes
it ask, for what the sandbox would block, and in the box container that
sandbox cannot start. Run there with on-request and full access, the model
ran a command without asking. So the container offers Codex Bypass only,
and the refusal says why; on the host the level is labelled
"Ask (sandbox escalations)", in capabilities, the refusal text and the
TUI picker.
Creating a wolt with a level its engine does not offer scaffolded the wolt
first and was refused only when its session started: a 409, the folder left
behind, and a retry with the same name taken. The create route now checks
the level, or the default a new wolt gets, before anything is written, and
answers 400. Capabilities name the default engine, so the TUI's create
picker offers only that engine's levels.
woltspace.json "sessions": {"default_execution_policy": "auto"} makes every
session that asks for no level (the bot, wolf, IWCL and lodge spawns) start
on that level instead of Bypass, where the engine offers it here and, for
Auto or Bypass on the host, the wolt's grant exists. Elsewhere the built-in
default applies. A level asked for by name still wins; unset changes nothing.
Supervisor.prepare() writes os.environ directly (WOLTSPACE_ISOLATION and
more), and monkeypatch.delenv() of an unset key leaves nothing to undo.
TestEnvFileLoading and TestContainerEntrypoint prepared host layouts, so
every later test ran as "host": test_boot_context_cap[pi] then resolved
Ask with no grant, which pi does not offer, and failed only in the full
CI run.
# Conflicts:
#	container/lib/lodge_db.py
With conversation_log.enabled in woltspace.json, each new inbox admission
also writes its exact payload to conversation_log in the same durable
write, so a message and its copy commit together or not at all. A dedup
retry returns before the archive and adds nothing; ack drops only the
operational body. The archive keeps at most 128 MiB of text and 80,000
rows lodge-wide, evicting the oldest rows in the same write, with the
totals in conversation_usage.

Migration 7 is additive and has no foreign keys and no inbox prefix, so
the restore guard treats it as passive data; backup's known schema moves
to 7 (it was still 5, below the floor 6 that migration 6 sets).

A reader reads its mailbox's history with the new inbox history verb:
rows it sent or received, matched by mailbox id, newest first, in pages
bounded by rows and by serialized bytes, with an omission marker that
moves the cursor past a row larger than the page.
With conversation history on, notify writes the outgoing text (after any
speech fallback wording) as an unknown attempt in a durable write, in a
worker thread, before the sender runs; if that write fails nothing is
sent. The row is tied to the calling session's open mailbox, also when
an explicit route clears the routing session. A provider success marks
it accepted; a failed outcome write only logs, so the result stands and
nothing is resent, and an error or a cancel leaves it unknown.
The flag, what is kept and when, the history command and its scope, the
limits and what they do not bound, and how backup and restore treat it.
max_bytes sums the record objects' own compact JSON, not the array or
the rest of the reply, so the CLI's whole output can be larger. A
cancel during the send leaves the attempt unknown; a cancel while the
outcome is written may still end accepted, as the worker finishes.
Wording only.
A lodge with the inbox on keeps acknowledged mail for 30 days, so every
backup taken since it went live was refused at restore (safe_state_present).

Restore now keeps every inbox row: acknowledged keys still dedupe, pending
mail is still owed, sequences continue. The disarm write revokes every
reader (as before), puts mail claimed by the old lodge's readers back in
the queue with a 'restored' event, and clears each mailbox's wake state.
The restore report counts the mail and says to keep the old lodge stopped,
since restored pending mail may run again.

A layout this restore cannot read, a schema floor it does not know, and
usage charged without mail are still refused (safe_state_unknown).
# Conflicts:
#	container/lib/lodge_db.py
# Conflicts:
#	src/woltspace/backup.py
@woltspace-jerpint
woltspace-jerpint Bot requested a review from jerpint as a code owner November 3, 2026 12:38
@jerpint
jerpint merged commit 1f5c2f3 into main Nov 4, 2026
3 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