Skip to content
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

- **One liveness answer: a released lease whose session still runs is idle, not live and not offline (T699).** `agentmbx agents` read the session binding while `mbx_agents` and the send receipt read the identity lease, so an OpenCode MCP eviction released the lease and the surfaces disagreed for hours: the CLI said `live`, the receipt said `offline (released)`. A single `agentLiveness` answer (src/liveness.ts) now composes the lease, the live session row and the serve kind, and every surface uses it. A released or expired lease whose session still runs reads as `idle` with its reason (claimable, and wakeable when the T688 shared service or a registered T659 poller can start its next turn, which re-claims the mailbox). `agentmbx agents` prints that state plus a trailing reason column and keeps shell senders visible as `live`; `mbx_agents` and `mbx_identity list` follow; a send reports `live-next-prompt`/`live-wake` instead of `offline`. The 30-minute lease idle TTL and the receipt's 3-minute heartbeat rule are unchanged.

- **An OpenCode v2 resume note reaches the model instead of being silently dropped (T700).** OpenCode 2.x drives the plugin's default `setup(ctx)` path. It marks a turn at `session.execution.started`, before the first tool, so the busy-turn guard treated every tool as mid-turn and the revisit notice could only fall back; at the turn's end the plugin admitted it with `resume:false`, whose `msg_` receipt can arrive without any `session_message` (T544), and then discarded the pending copy. The first tool of a turn now places the note even after the v2 start event, and a kept note is only dropped after it actually lands in a tool result: a stop no longer acknowledges a receipt as delivery, and `resume:true` is not used because starting a turn on an idle TUI is not a resume note's job. A fresh conversation's start note keeps its queued admission, which the host materialises as the conversation begins.

## 0.5.48 (2026-10-17)

- **Claude SessionStart fork keeps the parent row, and a live holder is named without a takeover suggestion (T185).** A `source: fork` start skips bind and the parent-row delete and prints only the mailbox-context fallback. A session whose name is held live by another session prints that `held by live` reason and does not suggest takeover.
Expand Down
9 changes: 7 additions & 2 deletions dist/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { dispatchWakes, hasWakeAuthority, humanPromptKey, inboxCommand, isHumanP
import { kimiMultiHost } from "./kimi-web.js";
import { bindInstruction, issueBindTicket } from "./bind-ticket.js";
import { activityKey, identityAvailability, parseActivity } from "./identity-availability.js";
import { agentDirectoryStates } from "./liveness.js";
import { fillCleoPresence } from "./cleo-presence.js";
import { identityLeaseStatus, inspectLeaseProcess } from "./identity-leases.js";
import { AUTO_NAME_RE, identityProjects, linkedKey, projectOf, recordSessionHint, registeredIdentity, removeStaleProjectBindings, staleProjectBindings } from "./registry.js";
Expand Down Expand Up @@ -1040,8 +1041,12 @@ async function run(argv) {
});
}
case "agents": {
const live = node.liveAgents();
return node.agents().forEach((a) => console.log(`${a.name}@${a.host}\t${a.host === node.host ? (live.has(a.name) ? "live" : "offline") : "remote"}\t${a.role ?? ""}\t${a.cli ?? ""}\t${a.last_seen ?? ""}\t${a.description ?? ""}`));
// T699: the same liveness answer as mbx_agents and the send receipt, with its reason as a trailing column.
const states = agentDirectoryStates(node);
return node.agents().forEach((a) => {
const local = states.get(a.name);
return console.log(`${a.name}@${a.host}\t${a.host === node.host ? local?.state ?? "offline" : "remote"}\t${a.role ?? ""}\t${a.cli ?? ""}\t${a.last_seen ?? ""}\t${a.description ?? ""}\t${a.host === node.host ? local?.reason ?? "" : ""}`);
});
}
case "status": {
if (o.json && str("schema") === STATUS_V2_SCHEMA) {
Expand Down
36 changes: 26 additions & 10 deletions dist/identity-status.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { inspectLeaseProcess } from "./identity-leases.js";
import { activityKey, holderProviderView, identityAvailability, parseActivity, parseProviderRecord } from "./identity-availability.js";
import { activityKey, holderProviderView, parseActivity, parseProviderRecord } from "./identity-availability.js";
import { livenessSnapshot } from "./liveness.js";
import { procTable, recordedStartMatches } from "./proc.js";
import { notSlot } from "./project-slot.js";
import { resolveProject } from "./project-key.js";
Expand All @@ -20,6 +21,7 @@ export function listIdentityStatus(home, options = {}) {
throw new Error("mailbox host configuration is invalid");
const db = new DatabaseSync(path, { readOnly: true });
const rows = new Map(), leases = [], conflicts = new Set(), activityByName = new Map();
const sessionRows = [];
const providerByHolder = new Map();
// T656: server process start by pid, and the server pids each (cli, real session) binding names — read in the tx below.
const serverStartByPid = new Map();
Expand Down Expand Up @@ -133,6 +135,8 @@ export function listIdentityStatus(home, options = {}) {
}
if (schema >= 2)
leases.push(...db.prepare("SELECT * FROM identity_leases").all());
for (const r of db.prepare("SELECT agent,cli,session_id,pid,pid_start,updated_at FROM sessions ORDER BY updated_at DESC").all())
sessionRows.push(r);
db.exec("COMMIT");
}
finally {
Expand Down Expand Up @@ -169,26 +173,38 @@ export function listIdentityStatus(home, options = {}) {
}
return holderProviderView((options.processTable ?? procTable)(), lease.holder_pid, caller.providerPid, recorded, evidence, caller.canonicalHarness);
};
// T699: one liveness answer for the whole inventory, from this snapshot: the caller's own lease is held by it,
// every other name gets the shared answer (a released lease whose session still runs is idle, not held or absent).
const evidenceByPid = new Map();
for (const lease of leases) {
if (lease.released_at !== null || now - lease.heartbeat_at >= lease.idle_ttl)
continue;
try {
evidenceByPid.set(lease.holder_pid, inspect(lease.holder_pid));
}
catch { /* failed inspection is unknown */ }
}
const evidenceOf = (pid) => evidenceByPid.get(pid) ?? { alive: null, start: null };
const callerLease = options.caller ? leases.find((l) => l.cli === options.caller.cli && namesCallerSession(l, options.caller)) : undefined;
const caller = options.caller ? { ...options.caller, ...(callerLease ? sameSessionProvider(callerLease, options.caller) : {}) } : undefined;
const liveness = livenessSnapshot({
names: leases.map((l) => l.name), leases, sessions: sessionRows, now,
activity: (name) => activityByName.get(name) ?? null, conflicts, evidence: evidenceOf,
...(caller ? { caller } : {}), scope: home,
});
for (const lease of leases) {
const item = row(lease.name);
activity(item, lease.heartbeat_at);
activity(item, lease.released_at);
let evidence = { alive: null, start: null };
if (lease.released_at === null && now - lease.heartbeat_at < lease.idle_ttl) {
try {
evidence = inspect(lease.holder_pid);
}
catch { /* failed inspection is unknown */ }
}
const evidence = evidenceOf(lease.holder_pid);
// The caller's own lease (its MCP server is the holder process) is simply held by it: not "an older process of this
// same session", and not something it should claim again (T316).
// A shared transport (Codex, OpenCode) serves many conversations from one process, so the process alone is not the
// caller: the lease must also name this conversation's session.
const own = options.caller?.pid !== undefined && lease.released_at === null && lease.holder_pid === options.caller.pid
&& lease.cli === options.caller.cli && namesCallerSession(lease, options.caller) && evidence.alive !== false;
const a = own ? { state: "live", claimable: false, reason: "held by this session" }
: identityAvailability({ lease, evidence, activity: activityByName.get(lease.name) ?? null, conflict: conflicts.has(lease.name), now,
caller: options.caller && { ...options.caller, ...sameSessionProvider(lease, options.caller) } });
: liveness.get(lease.name) ?? { state: "offline", claimable: true, reason: "no session has held it under a lease" };
item.state = a.state === "live" ? (a.claimable ? "idle" : "held") : a.state === "idle" ? "idle" : a.state === "unknown" ? "unknown" : a.state === "conflict" ? "conflict" : "available";
item.claimable = a.claimable;
item.reason = a.reason;
Expand Down
177 changes: 177 additions & 0 deletions dist/liveness.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
// T699: one answer to "is this mailbox being served, and how?" for `mbx_agents`, `mbx_identity list`,
// `agentmbx agents` and the send receipt.
//
// Liveness is not claimability. The lease still decides who may claim a name (identityAvailability), but a
// released lease is not the same thing as an absent session: OpenCode evicts an idle MCP about hourly and
// releases the lease while the serve keeps hosting the conversation (T659). That mailbox is idle — claimable,
// no holder right now, but wakeable and seen on the session's next turn — not "live" (nothing is serving it)
// and not "offline" (the session is still there). Both wrong answers came from asking different questions:
// `agentmbx agents` read the session binding, the lease-based surfaces read identity_leases.
//
// The reason text is deliberately built from evidence every process can read (lease row, session row, process
// table, serve kind), never from this process's wake-queue registry, so every surface says the same thing.
import { activityKey, identityAvailability, parseActivity } from "./identity-availability.js";
import { identityLeaseStatus, inspectLeaseProcesses } from "./identity-leases.js";
import { sessionRowLive } from "./node.js";
import { wakeBindingsByAgent } from "./opencode-poller.js";
const iso = (ms) => new Date(ms).toISOString();
const sameTarget = (b, s) => !!s && s.cli === b.cli && s.session_id === b.session_id && s.pid === b.pid;
/**
* The one mapping from lease availability + a live binding to a liveness answer. Pure: callers pass the evidence
* they already read, so the same rows always produce the same token and reason.
*/
export function livenessOf(o) {
const a = identityAvailability(o);
if (a.state === "conflict")
return { state: "conflict", reason: a.reason, claimable: false };
if (a.state === "live" || a.state === "unknown") {
return { state: a.state, reason: a.reason, claimable: a.claimable, ...(a.takeover ? { takeover: a.takeover } : {}), ...(a.coUse ? { coUse: a.coUse } : {}) };
}
if (a.state === "idle") {
return { state: "idle", idleKind: "held", reason: a.reason, claimable: true, ...(a.takeover ? { takeover: a.takeover } : {}) };
}
// `offline` (a released or expired lease) and `free` (no lease) are idle only while the same session still runs.
if (o.binding) {
const serve = sameTarget(o.binding, o.serve) ? o.serve : null;
return {
state: "idle", idleKind: "released", binding: o.binding, claimable: true,
wakeable: serve?.wakeable === true,
reason: releasedIdleReason(o.lease, a.reason, o.binding, serve),
};
}
return { state: "offline", reason: a.reason, claimable: a.claimable, ...(a.takeover ? { takeover: a.takeover } : {}) };
}
function releasedIdleReason(lease, expiredReason, b, serve) {
const why = !lease ? "no current lease"
: lease.released_at !== null
? `${lease.release_reason && lease.release_reason !== "released" ? `expired (${lease.release_reason})` : "released"} ${iso(lease.released_at)}`
: expiredReason;
const host = serve ? (serve.kind === "service" ? ", shared service" : ", standalone serve") : "";
return `${why}; its ${b.cli} session ${b.session_id} is still running (pid ${b.pid}${host}), so it is idle: mail waits for its next turn`;
}
/** The live binding of `lease`'s session when there is one, else the newest live binding of this name. */
function pickBinding(lease, rows, now) {
const usable = rows.filter((r) => !r.session_id.startsWith("mcp-") && sessionRowLive({ pid: r.pid, pid_start: r.pid_start, updated_at: r.updated_at }, now));
const exact = lease ? usable.find((r) => r.cli === lease.cli && r.session_id === lease.session_id) : undefined;
const row = exact ?? usable[0];
return row && row.pid ? { cli: row.cli, session_id: row.session_id, pid: row.pid } : null;
}
/** How long a wake verdict may be reused. Receipt pages ask again per message; one binding cannot change that fast. */
const WAKE_MEMO_MS = 1_000;
const WAKE_MEMO_MAX = 512;
const wakeMemo = new Map();
const rowKey = (r) => `${r.cli}\0${r.session_id}\0${r.pid ?? ""}`;
/**
* The one liveness mapping for a whole page of names: bindings and wake verdicts are derived here, so the agent
* list, the roster and the send receipt cannot drift apart again. The wake verdict is reused for one second so a
* receipt page costs one process read, not one per line.
*/
export function livenessSnapshot(o) {
const unique = [...new Set(o.names)];
const leases = new Map(o.leases.map((r) => [r.name, r]));
const rowsByName = new Map();
for (const r of o.sessions)
rowsByName.set(r.agent, [...(rowsByName.get(r.agent) ?? []), r]);
const bindings = new Map();
for (const name of unique)
bindings.set(name, pickBinding(leases.get(name), rowsByName.get(name) ?? [], o.now));
// A wake verdict is only meaningful when no current lease serves the name: release, expiry or no lease at all.
const candidates = new Map();
for (const name of unique) {
const lease = leases.get(name), binding = bindings.get(name);
if (!binding)
continue;
if (lease && identityLeaseStatus(lease, o.now, o.evidence(lease.holder_pid)).state !== "expired")
continue;
candidates.set(name, (rowsByName.get(name) ?? []).filter((r) => r.cli === binding.cli && r.session_id === binding.session_id));
}
const serves = new Map();
const fresh = new Map();
for (const [name, rows] of candidates) {
const lease = leases.get(name), key = `${o.scope ?? ""}\0${lease?.token ?? ""}\0${lease?.released_at ?? ""}\0${rows.map(rowKey).join(",")}`;
const memo = wakeMemo.get(name);
if (memo && memo.key === key && o.now - memo.at < WAKE_MEMO_MS)
serves.set(name, memo.value);
else
fresh.set(name, rows);
}
if (fresh.size) {
const found = wakeBindingsByAgent(new Map(fresh), o.host, o.waiting);
for (const name of fresh.keys()) {
const value = found.get(name) ?? null;
const lease = leases.get(name);
const key = `${o.scope ?? ""}\0${lease?.token ?? ""}\0${lease?.released_at ?? ""}\0${(fresh.get(name) ?? []).map(rowKey).join(",")}`;
wakeMemo.set(name, { key, at: o.now, value });
serves.set(name, value);
}
if (wakeMemo.size > WAKE_MEMO_MAX)
for (const k of wakeMemo.keys())
if (wakeMemo.size > WAKE_MEMO_MAX)
wakeMemo.delete(k);
}
const out = new Map();
for (const name of unique) {
const lease = leases.get(name);
out.set(name, livenessOf({
lease, evidence: lease ? o.evidence(lease.holder_pid) : { alive: null, start: null },
activity: o.activity(name), conflict: o.conflicts.has(name), now: o.now,
...(o.caller ? { caller: o.caller } : {}),
binding: bindings.get(name), serve: serves.get(name) ?? null,
}));
}
return out;
}
/** The same liveness answer for many names, read from one store (and one process snapshot). */
export function agentLivenessBatch(node, names, o = {}) {
const now = o.now ?? Date.now();
const unique = [...new Set(names)];
if (!unique.length)
return new Map();
const marks = unique.map(() => "?").join(",");
const leases = node.store.db.prepare(`SELECT * FROM identity_leases WHERE name IN (${marks})`).all(...unique);
const sessions = node.store.db.prepare(`SELECT agent,cli,session_id,pid,pid_start,updated_at FROM sessions WHERE agent IN (${marks}) ORDER BY updated_at DESC`).all(...unique);
const holders = leases.flatMap((l) => l.released_at === null ? [l.holder_pid] : []);
const evidence = new Map();
if (o.inspect)
for (const pid of holders)
evidence.set(pid, o.inspect(pid));
else if (holders.length)
for (const [pid, e] of inspectLeaseProcesses(holders))
evidence.set(pid, e);
return livenessSnapshot({
names: unique, leases, sessions, now,
activity: (name) => parseActivity(node.store.get(activityKey(name))),
conflicts: new Set(unique.filter((name) => node.store.get(`identity-conflict:${name}`) !== undefined)),
evidence: (pid) => evidence.get(pid) ?? { alive: null, start: null },
host: o.host, waiting: o.waiting, scope: node.home,
});
}
/** The same answer for one name; every surface uses this or the batch, never its own rule. */
export function agentLiveness(node, name, o = {}) {
return agentLivenessBatch(node, [name], o).get(name) ?? { state: "offline", reason: "no session has held it under a lease", claimable: true };
}
/**
* The `agentmbx agents` display for this host's directory: local names carry the shared liveness token and reason.
* A shell participant (`agentmbx send --as`) has no session and no lease; it stays visible as live for two hours
* after its last send, exactly as before (T699 decision).
*/
export function agentDirectoryStates(node, o = {}) {
const now = o.now ?? Date.now();
const records = node.agents();
const local = records.filter((a) => a.host === node.host);
const answers = agentLivenessBatch(node, local.map((a) => a.name), o);
const since = new Date(now - 2 * 3_600_000).toISOString();
const bound = new Set(node.store.db.prepare("SELECT DISTINCT agent FROM sessions").all().map((r) => r.agent));
const out = new Map();
for (const a of local) {
const l = answers.get(a.name);
// A shell participant (--as) never bound a session; it stays visible as live for two hours after its last send.
if (a.cli === "cli" && a.last_seen !== null && a.last_seen > since && !bound.has(a.name) && l?.state === "offline") {
const mins = Math.max(0, Math.round((now - Date.parse(a.last_seen)) / 60_000));
out.set(a.name, { state: "live", reason: `shell sender, last seen ${mins} min ago` });
}
else if (l)
out.set(a.name, { state: l.state, reason: l.reason });
}
return out;
}
Loading
Loading