Background work and owner-addressed delivery
Mina starts a fictional habitat check, then moves to another conversation before the result arrives. Her goal is to keep the result with its origin. Her obstacle is a tempting implementation:
“When the promise finishes, call
sendMessage().”
That sends to the runtime’s current session, not necessarily the original owner.
Mina separates work lifetime, result storage, delivery admission and agent wake-up.
Milestone: managed timers for a local reminder
Start with a small timer that only notifies the operator. It does not pretend to be a durable job.
Complete TypeScript exercise—save as clock-note.ts:
import type { ExtensionAPI, ExtensionContext } from "@oh-my-pi/pi-coding-agent";
export default function clockNote(pi: ExtensionAPI): void {
let timer: Timer | undefined;
function clear(ctx: ExtensionContext): void {
if (timer !== undefined) ctx.clearTimer(timer);
timer = undefined;
}
pi.registerCommand("clock-note", {
description: "Schedule or stop a one-second local reminder",
async handler(args, ctx) {
clear(ctx);
if (args.trim() === "stop") {
ctx.ui.notify("Reminder stopped.");
return;
}
if (args.trim() && args.trim() !== "start") {
ctx.ui.notify("Use /clock-note start or /clock-note stop.", "warning");
return;
}
const owner = ctx.sessionManager.getSessionId();
timer = ctx.setTimeout(() => {
timer = undefined;
if (ctx.sessionManager.getSessionId() !== owner) return;
pi.logger.debug("Workbook reminder fired");
if (ctx.hasUI) ctx.ui.notify("One-second workbook reminder.");
}, 1000);
ctx.ui.notify("Reminder scheduled.");
},
});
pi.on("session_before_switch", (_event, ctx) => clear(ctx));
pi.on("session_before_branch", (_event, ctx) => clear(ctx));
pi.on("session_before_tree", (_event, ctx) => clear(ctx));
pi.on("session_shutdown", (_event, ctx) => clear(ctx));
}
Human OMP slash commands—after loading:
/clock-note start
/clock-note stop
Expected checkpoint: start produces one reminder if the host stays alive long enough; stop or a navigation attempt clears it.
Managed timers:
- contain synchronous callback throws and rejected native promises;
- report failures through the extension error channel;
- are unref’d, so they do not keep the process alive alone;
- are cleared on shutdown;
- can be cleared individually with
clearTimer().
Clearing a timer does not cancel asynchronous work that its callback already started. That work needs its own abort controller and stale-result checks.
Managed timers do not automatically register jobs in getAsyncJobSnapshot(). That snapshot reports the host’s session-owned async jobs, or null when no applicable manager is available.
Exercise: remove the explicit pre-switch clear and start a reminder before /new.
Answer: the runner/binding can survive /new; automatic shutdown cleanup is not a session-switch reset. Design the intended lifetime explicitly.
Ordinary messages: choose the effect deliberately
| API | Intended use | Important semantics |
|---|---|---|
appendEntry(type, data) | Extension state | Not sent to the model |
sendMessage(payload, options) | Custom conversation/context message | content participates in model context; details is metadata |
sendUserMessage(content, options) | User-style prompt or queued user message | Does not dispatch slash commands or expand prompt templates |
captureSessionTarget() + deliverMessage() | Owner-addressed result delivery | Persistent anchor, admission checks and deduplication receipt |
sendMessage() and sendUserMessage() return void through ExtensionAPI. Awaiting them does not produce a durable acknowledgement.
For custom messages:
- Idle, no trigger: append to conversation/session without starting a turn.
- Streaming, default delivery: queue as steering.
- Streaming,
followUp: queue behind the current work. - Streaming,
nextTurn: hold as hidden next-turn context rather than an editable pending-message chip. triggerTurn: true: request a turn; hosts may defer it.- Idle
nextTurnwithout a trigger appends immediately in this implementation; it is not a separate durable outbox.
For sendUserMessage():
- omitted
deliverAsstarts prompt flow when idle and steers while streaming; - explicit
steerorfollowUpenqueues through that queue, without synchronously starting a prompt; - host queue-drain behavior may subsequently resume work;
nextTurnis not an option on this API.
display: false hides presentation, not model visibility.
The old repository reload-tool example queues "/reload-runtime" through sendUserMessage(). Current AgentSession.sendUserMessage() explicitly skips command handling. Do not teach that as a supported way for a model to invoke a human command.
Milestone: capture once, deliver to that owner
This complete exercise demonstrates immediate capture/delivery and a retained retry identity. It is not a durable background job engine.
It requires:
- an initialized extension runtime;
- a persistent session;
- enough known model context capacity for admission.
No inference is requested because triggerTurn is false. Without a model/capacity snapshot, context_full can be a correct deferred result.
Complete TypeScript exercise—save as owner-note.ts:
import type { ExtensionAPI, ExtensionSessionTarget } from "@oh-my-pi/pi-coding-agent";
export default function ownerNote(pi: ExtensionAPI): void {
const namespace = "workbook.owner-note";
let pending: {
target: ExtensionSessionTarget;
deliveryId: string;
content: string;
} | undefined;
let busy = false;
pi.registerCommand("owner-note", {
description: "Capture and deliver a local note: new <text> | retry | status",
async handler(args, ctx) {
const input = args.trim();
if (input === "status") {
ctx.ui.notify(pending ? `Pending delivery: ${pending.deliveryId}` : "No pending delivery.");
return;
}
if (busy) throw new Error("A delivery operation is already in progress.");
busy = true;
try {
if (input.startsWith("new ")) {
if (pending) throw new Error("Retry the existing pending delivery before creating another.");
const content = input.slice(4).trim();
if (!content) throw new Error("A nonblank note is required.");
const requestId = crypto.randomUUID();
const target = await pi.captureSessionTarget({
namespace,
requestId,
data: { kind: "fictional-workbook-note" },
});
pending = { target, deliveryId: requestId, content };
} else if (input !== "retry") {
throw new Error("Use new <text>, retry, or status.");
}
if (!pending) throw new Error("No captured note is waiting for delivery.");
const receipt = await pi.deliverMessage(
{ customType: namespace, content: pending.content, display: true },
{
target: pending.target,
namespace,
deliveryId: pending.deliveryId,
triggerTurn: false,
},
);
ctx.ui.notify(
`${receipt.state}; reason=${receipt.reason ?? "none"}; wake=${receipt.wake}`,
receipt.state === "deferred" ? "warning" : "info",
);
if (receipt.state !== "deferred") pending = undefined;
} finally {
busy = false;
}
},
});
}
Human OMP slash commands—after loading with a persistent session:
/owner-note new The fictional marsh check is complete.
/owner-note status
/owner-note retry
Use retry only if the previous operation retained a pending item. A successfully committed item is cleared by this small example.
The note’s retry state is binding-local. If the process exits before delivery, this exercise has no durable outbox from which to recover it. A real background system must persist the target, stable delivery ID and complete body with its job.
Capture contract
captureSessionTarget({ namespace, requestId, data? }) appends an anchor and returns:
sessionId;sessionFile;anchorEntryId.
Identifiers must be nonempty. data must be structured-cloneable. Capture requires persistence and can fail during a conflicting transition.
Capture once before launching work. Repeated calls with the same request ID are not an instruction to find and reuse an earlier anchor.
Receipt contract
deliverMessage(payload, { target, namespace, deliveryId, triggerTurn }) returns:
| Receipt field | Meaning |
|---|---|
deliveryId | Your stable delivery identity |
state: "deferred" | No new body appended by this attempt |
state: "committed" | Body persisted and published to the owner’s live context |
state: "already_committed" | Existing entry reused; no duplicate body |
entryId | Committed journal entry, when available |
reason | Admission or wake condition |
wake | not_scheduled, scheduled or pending |
Reasons are:
owner_inactive;branch_inactive;session_busy;context_full;host_defers_turn.
Inspect state and wake separately. A committed body can still have a pending wake.
A scheduled wake is not proof that the model has replied.
Ownership and deduplication
The coordinator checks:
- the original session identity and file lineage;
- the anchor on the active branch;
- reset boundaries;
- busy session work;
- context capacity.
A recorded move can preserve owner identity. A fork creates a new session ID and does not inherit delivery authority. Navigating away from the anchor or crossing a reset boundary can make the branch inactive.
A namespace/delivery-ID retry with different content, owner or anchor throws a conflict. The hash represents typed content segments, not every presentation field in the payload; keep the entire payload stable rather than expecting changed details to update an existing delivery.
Text is split into blocks of at most 65,536 UTF-16 code units without splitting surrogate pairs. The coordinator does not truncate or compact the result to make it fit.
details["omp.delivery"] is reserved for delivery identity.
Already-compacted messages are not resurrected. Retrying a delivery can repair a pending/failed wake without adding another body, but a later successful assistant entry suppresses another wake.
Observed contract tests: real session persistence and coordinator scenarios covered duplicate delivery, conflict, failed persistence, owner transitions, large complete bodies and wake retry. Some lifecycle tests used a mock model to test the agent loop. That is not live-provider evidence.
What changes next?
For a real service job, Mina needs an additional durable job/outbox store and retry policy. No public ExtensionAPI.startJob() or magic shared durable backend exists in this inventory.
A useful new tool should then expose:
- a stable job ID;
inspect/queryfor state;- a bounded, cancellable
wait; diagnosefor a deferred owner, unavailable service or pending wake;- explicit action retry rules.
Independent processes writing the same transcript need an external owner lock. Delivery serialization is session-local.
Exercise: a receipt says state: "committed", reason: "host_defers_turn", wake: "pending". Should the worker resend under a new delivery ID?
Answer: no. Retain the same identity. The body is already committed; the outstanding issue is consumption, not missing storage.
Source, snapshot 2026-08-29: packages/coding-agent/src/extensibility/extensions/managed-timers.ts; packages/coding-agent/src/session/extension-delivery.ts, ExtensionDeliveryCoordinator; packages/coding-agent/src/session/agent-session.ts, message APIs and async snapshots; packages/coding-agent/test/extension-delivery.test.ts, extension-delivery-lifecycle.test.ts.
Extensions inside those boundaries · Source chapter: extensions/background-work-and-owner-addressed-delivery. Original evidence remains scoped to its recorded snapshot.