mailbox: say it is queued, and ping the human who is not looking (RFC 003 §7.11)

Two changes, neither touching the no-triggerTurn decision, which stands.

B — a delivery note in the message itself. The mid-session text explained how to
CLOSE an ask and never said when it would be SEEN, so the only reader who needed
that fact — the human watching an idle session — was the one not told. It now
says: this is a queued message, nothing woke the agent, any message starts the
turn that handles it, and the agent is not ignoring the ask, it is not running.
Costs nothing and changes no behaviour; it converts "why is it ignoring me?" into
"right, I nudge it". Deliberately NOT added to the wake-up injection, where a
turn is already starting and the note would be false.

A — a notification at poll time, because B only helps someone already looking and
the case that loses an ask is nobody looking. MEMPALACE_MAILBOX_NOTIFY: unset →
in-TUI ctx.ui.notify, the surface session_start already uses; =desktop →
additionally a terminal-native notification (Kitty OSC 99, else OSC 777), reusing
the detection the fleet's own notify.ts already proves in this harness; =0/off →
silent. The desktop path is how a ping escapes a container with no notify-send,
no DBus and no host access: the escape sequence is written to stdout and
interpreted by the terminal emulator on the human's own machine. Opt-in because
writing raw escapes is a behaviour change on a shared machine, not because it is
unreliable — say the word and the default flips.

Placement and firing conditions are deliberate: the notify call sits AFTER
sendMessage so a ping can never be the only thing that happened, and it fires
only when something is due — the same condition as delivery. A notification on
an empty poll would train its reader to ignore it, which is the failure this
whole feature exists to reverse. The wording names the nudge ("send any message
to handle") because "you have mail" without "press a key" reproduces exactly the
confusion B fixes.

Tested: 12 cases. Mode parsing (unset/empty/desktop/DESKTOP-with-space/0/off/1),
escape hygiene, and the OSC invariant that matters — a hostile title or body
containing ";" or a BEL cannot forge an OSC field or terminate the sequence
early, which is worth asserting because event bodies arrive from other machines.
ctx.hasUI is checked and the notify call is wrapped, since the UI can be gone by
the time an unawaited poll resolves. Syntax checked with node --strip-types.
This commit is contained in:
2026-08-26 23:56:21 +02:00
parent 982b001001
commit a92c75d070
3 changed files with 117 additions and 4 deletions
+9 -2
View File
@@ -319,7 +319,14 @@ The mid-session poll delivers with `deliverAs: "steer"` and **deliberately no `t
**Measured 2026-08-26:** a delivery landed in a session window at ~20:50Z and sat there, visibly unreacted-to, until the operator asked "do I have to nudge you for you to read it?" — which is precisely the question this design produces. The delay is not the agent choosing to ignore its mailbox; between the poll and the next turn there is no inference at all.
The consequence is a **UX gap, not a defect**: from the outside, an inbound ask plus an idle agent reads as neglect. The delivered text explains how to *close* an ask but says nothing about *when* it will be seen — so the one reader who needs that fact, the human watching the window, is the one not told. Fixes that do not wake a model: say the queued-until-next-turn semantics in the delivered text itself; notify at poll time (`ctx.ui.notify` / desktop toast) so the human knows a nudge is worthwhile; or an opt-in `MEMPALACE_MAILBOX_TRIGGER=1`, default off, for anyone who does want a turn started.
The consequence is a **UX gap, not a defect**: from the outside, an inbound ask plus an idle agent reads as neglect. The delivered text explained how to *close* an ask but said nothing about *when* it would be seen — so the one reader who needed that fact, the human watching the window, was the one not told.
**✅ Addressed 2026-08-26 (toolkit `extensions/pi/mempalace.ts`), without touching the no-`triggerTurn` decision.** Two changes, in the order their value is realised:
1. **A delivery note in the message itself** — it says this is a queued message, that nothing woke the agent, and that any message starts the turn that will handle it. Costs nothing, changes no behaviour, and converts "why is it ignoring me?" into "right, I nudge it."
2. **A notification at poll time**, because the note only reaches someone already looking, and the case that actually loses an ask is nobody looking. `MEMPALACE_MAILBOX_NOTIFY` unset → in-TUI `ctx.ui.notify` (the surface `session_start` already uses); `=desktop` → additionally a terminal-native notification (Kitty `OSC 99`, else `OSC 777`), which is how a ping escapes a container with no `notify-send`, DBus or host access — the escape sequence is interpreted by the terminal emulator on the human's machine; `=0` → silent. Title and body are stripped of `;` and control bytes so a payload cannot forge an OSC field or terminate the sequence early. It fires only when something is due, because a ping on an empty poll trains its reader to ignore it.
Still deliberately **not** done: `MEMPALACE_MAILBOX_TRIGGER=1` (opt-in, default off) to start a turn on arrival. Worth revisiting once notification is proven in daily use — but a machine that auto-turns on inbound events writes to a shared log with nobody watching, and §7.1 (no idempotency guard) is the reason to be slow about it.
### 7.12 The mailbox is an obligation channel, so a report addressed to you is never delivered
@@ -358,7 +365,7 @@ Replication exists in code — `version_vector()`, `list_ops()`, `apply_remote_e
5. **Should `GET /logstream/events` exist?** A read-only HTTP tail would let non-MCP consumers (dashboards, CI) follow a stream without an MCP client. Today they must hold SSE or speak MCP.
6. **Undocumented limits.** The 64 KiB `metadata` cap and the lowercase-`type` regex are enforced server-side but absent from the tool schemas (§3.1). Document, or relax.
7. **Agent-name registry.** Nothing prevents two devices sharing one `from_agent` (§7.7). A warning at append time would be cheap.
8. **Does an arriving ask deserve a notification, and does it ever deserve a turn?** (§7.11) The no-`triggerTurn` decision stands; the open part is the cheap half — stating the queued-until-next-turn semantics in the delivered text, and whether to notify the human at poll time so a nudge is known to be worthwhile.
8. ~~**Does an arriving ask deserve a notification?**~~ **Done 2026-08-26** — delivery note plus `MEMPALACE_MAILBOX_NOTIFY` (§7.11). What stays open is the narrower question: should `desktop` be the *default* rather than opt-in, and does an arriving ask ever deserve a turn (`MEMPALACE_MAILBOX_TRIGGER`)?
9. **Is there a delivery path for news rather than obligations?** (§7.12) Today the only delivered shape is a directed open ask. Options: leave it pull-only and rely on the paired drawer, or give informational events a distinct low-priority surface at wake-up ("3 reports since your last session") separate from the owed set.
### 9.1 Retention — decided direction (2026-08-26)
+33 -1
View File
@@ -316,6 +316,37 @@ waits for the agent to think of asking. Two delivery points, both fail-silent:
`triggerTurn` — waking the model on inbound fleet traffic is a much larger
behavioural change than auto-delivery.
**Which means a human is the trigger, and the mailbox now says so.** Measured
2026-08-26 on two devices: a delivery lands, the agent is idle, nothing happens,
and the operator asks *"do I have to nudge you for you to read this?"*. Yes —
because between the poll and the next turn no inference is running. The old text
explained how to *close* an ask and never said when it would be *seen*, so the
only reader who needed that fact was the one not told. Two additions, neither of
which touches the no-`triggerTurn` decision:
- **A delivery note in the message itself** — states that this is a queued
message, that nothing woke the agent, and that any message starts the turn
that handles it. Free, and aimed at the human reading the window.
- **A notification at poll time**, because the note only helps someone who is
already looking, and the case that loses an ask is nobody looking:
| `MEMPALACE_MAILBOX_NOTIFY` | Behaviour |
|---|---|
| *unset* (default) | in-TUI `ctx.ui.notify`, the same surface `session_start` already uses |
| `desktop` | additionally a terminal-native notification — Kitty `OSC 99`, else `OSC 777` (iTerm2, WezTerm, Ghostty, rxvt-unicode) |
| `0` / `off` | silent; mailbox still delivers |
The `desktop` path is how a notification escapes a container without
`notify-send`, DBus or any host access: the escape sequence is written to stdout
and interpreted by the terminal emulator on the human's own machine. It is
opt-in because writing raw escapes is a behaviour change on a shared machine,
not because it is unreliable. Title and body are stripped of `;` and control
bytes, so a payload can neither forge an OSC field nor end the sequence early.
It fires **only when something is due** — the same condition as the delivery
itself. A ping on an empty poll would train its reader to ignore it, which is
the failure this whole feature exists to reverse.
Owed-ness is **derived, never read off a field**, because `event_ack` appends and
`status` is written once: a directed `open` event matches the mailbox query
*forever*, answered or not. Two calls (`to_agent=<me> status=open`, and
@@ -340,7 +371,8 @@ ask demonstrably reaches no owed set, giving that documented anti-pattern teeth.
Gated on `MEMPALACE_PI_DEVICE` **and** `MEMPALACE_REMOTE_URL` (the same pair as
the stamper, since an unstamped client has no address to be reached at), and
disabled outright with `MEMPALACE_MAILBOX=0`. Inert on a solitary palace: no
disabled outright with `MEMPALACE_MAILBOX=0` (notifications alone with
`MEMPALACE_MAILBOX_NOTIFY=0`). Inert on a solitary palace: no
calls, no injection. Delivery is the mechanism; the *norms* — what a reply owes,
and that only a terminal event closes a thread — remain normative in the
*consumer* skill (`~/.agents/skills/mempalace/SKILL.md`). **This file documents
+75 -1
View File
@@ -1138,6 +1138,20 @@ export default async function mempalaceExtension(pi: ExtensionAPI) {
"(applied/superseded/failed/blocked). An ack alone does NOT clear it, and neither " +
"does claimed/ready — those deliberately keep it visible as taken-but-unfinished.";
// Written for the ONE reader the rest of this text ignores: the human watching
// the window. Measured 2026-08-26 on two devices — a delivery lands, the agent
// is idle, nothing happens, and the operator asks "do I have to nudge you for
// you to read this?". Yes, and it is by design (see the deliverAs comment
// below): at agent_settled no inference is running, so the text is queued for
// the next turn. The message explained how to CLOSE an ask but never when it
// would be SEEN, so the person who needed that fact was the only one not told.
// Cheapest possible fix, and it changes no behaviour: say so in the text.
const QUEUED_NOTE =
"DELIVERY NOTE — this is a QUEUED message, not an action: it arrived while this " +
"agent was idle, so no model call was made and nothing woke it. It is read at the " +
"start of the next turn. If you are the human watching and want it handled now, " +
"send any message to start a turn; the agent is not ignoring the ask, it is not running.";
// Mid-session mailbox. Tier 1 (the wake-up injection below) owns the first
// look; this exists because arrivals are bursty and correlate with our own
// activity — measured 2026-08-26, 11 of 22 events on this log landed inside a
@@ -1149,7 +1163,51 @@ export default async function mempalaceExtension(pi: ExtensionAPI) {
// the owed set holds something not already surfaced. Re-announcing the same
// ask every poll is precisely how a channel teaches its reader to ignore it,
// which is the failure this whole feature exists to reverse.
pi.on("agent_settled", async () => {
// --- A: tell the human at poll time (RFC 003 §7.11) -----------------------
//
// B (the QUEUED_NOTE above) fixes the confusion of someone who IS looking at
// the window. It does nothing for the case that actually loses an ask: nobody
// is looking. The delivered text is queued for a turn that only a human starts,
// so an ask can wait indefinitely on an idle session while its recipient makes
// coffee. A notification at poll time is the only part of this feature that
// reaches a person who is not watching.
//
// Two surfaces, deliberately split by risk:
// default in-TUI ctx.ui.notify, exactly as session_start already does.
// Zero new output channels; visible only if you are looking.
// =desktop additionally emit a terminal-native notification, reusing
// the detection the fleet's own notify.ts already proves in
// this harness (Kitty OSC 99 / Windows toast / OSC 777). This
// escapes the container without notify-send, DBus or any host
// access: the escape sequence is interpreted by the terminal
// emulator on the human's machine. Opt-in because writing raw
// escapes to stdout is a behaviour change on shared machines,
// not because it is unreliable.
// =0 silent, for anyone who wants the mailbox without pings.
//
// Fired only when something is actually due, i.e. the same condition as the
// delivery itself: a notification that fires on an empty poll would teach its
// reader to ignore it, which is the failure this whole feature exists to undo.
const mailboxNotifyMode = (process.env.MEMPALACE_MAILBOX_NOTIFY ?? "").trim().toLowerCase();
const mailboxNotifyEnabled = mailboxNotifyMode !== "0" && mailboxNotifyMode !== "off";
const mailboxNotifyDesktop = mailboxNotifyMode === "desktop";
/** Terminal-native notification. Best-effort; never throws into a handler. */
const notifyTerminal = (title: string, body: string): void => {
try {
const esc = (s: string): string => s.replace(/[\x00-\x1f\x7f;]/g, " ");
if (process.env.KITTY_WINDOW_ID) {
process.stdout.write(`\x1b]99;i=1:d=0;${esc(title)}\x1b\\`);
process.stdout.write(`\x1b]99;i=1:p=body;${esc(body)}\x1b\\`);
return;
}
process.stdout.write(`\x1b]777;notify;${esc(title)};${esc(body)}\x07`);
} catch {
/* a notification is never worth an exception */
}
};
pi.on("agent_settled", async (_event, ctx) => {
if (!mailboxEnabled || !available) return;
if (!wokeUp) return; // the wake-up injection has not run yet; it does look #1
if (Date.now() - lastMailboxPollAt < mailboxPollMs) return;
@@ -1173,6 +1231,7 @@ export default async function mempalaceExtension(pi: ExtensionAPI) {
content:
`MemPalace logstream mailbox: ${due.length} directed ask(s) addressed to ` +
`"${mailboxAddress}" with no terminal reply from this device. ${OWED_HOWTO}\n\n` +
`${QUEUED_NOTE}\n\n` +
formatOwed(due),
display: true,
},
@@ -1185,6 +1244,21 @@ export default async function mempalaceExtension(pi: ExtensionAPI) {
// than auto-delivery and is not what was approved.
{ deliverAs: "steer" },
);
// AFTER the queueing call, so a notification can never be the only
// thing that happened: the message is in the thread first, then we
// point at it. Wording names the nudge explicitly, because "you have
// mail" without "press a key" reproduces the exact confusion B fixes.
if (mailboxNotifyEnabled) {
const summary =
`${due.length} directed ask(s) queued for ${mailboxAddress} — ` +
`send any message to handle`;
try {
if (ctx?.hasUI) ctx.ui.notify(`MemPalace mailbox: ${summary}`, "info");
} catch {
/* ignore: UI may be gone by the time the poll resolves */
}
if (mailboxNotifyDesktop) notifyTerminal("MemPalace mailbox", summary);
}
} catch {
/* best-effort delivery: never break a turn over a mailbox read */
}