Job control
Use this reference when an agent needs to launch, observe or stop asynchronous work. For shell scripting, start with Automation. For client requests, start with Integrations: model-facing tools and AppWire methods are different interfaces.
This is the exact contract for the shipped runtime on main. The detailed sections below preserve tool parameters, ownership rules and terminal outcomes.
Choose a lifetime
| Need | Choose | Completion and cleanup |
|---|---|---|
| A short shell command | Foreground shell mode | Waits inline, but long work can promote to a managed job. Inspect the returned status and exit code. |
| Work while the agent continues | Background shell mode | Returns a job_... ID. Completion notifies the owner. Session close stops managed processes. |
| A service that must survive the session | Detached shell mode | Returns a PID, not a managed job. The caller owns logs and shutdown. No job notifications or Evener stop control. |
| Another agent's conversation | delegate | Returns a stable dlg_... ID. A completed run leaves the delegate idle and available for another message. |
For background work, keep the returned ID, continue independent work, then wait for the completion notification. Use job_status or job_list once to orient yourself when necessary, not in a polling loop. A status read is not delivery of a delegate's result. Read retained shell bytes with job:<job_id> and delegate history with its session transcript_ref.
flowchart LR
Start[Launch managed work] --> ID[Retain typed identity]
ID --> Work[Do independent work]
Work --> Notice[Receive completion]
Notice --> Check[Inspect outcome and evidence]Stopping uses job_stop(target=...). A delegate stop includes its subtree; shell child stopping is controlled separately. A stop request and terminal settlement are different observations: use a bounded wait when you need to know that stopping finished. Never substitute a shell job ID for a delegate ID.
Watches observe a source or a timer; they do not make output retention infinite. Watch frames can coalesce while the receiver is busy. Clear timers you no longer need, and read retained evidence when a frame alone cannot answer the question. Keep application-owned logs outside session scratch when a detached process must outlive the session.
Summary
Evener exposes one unified list/status/control view over two typed resource families. A shell job is asynchronous process work owned by a session and identified by job_.... A delegate is a durable child conversation owned by the root delegate-tree controller and identified by dlg_.... They share orientation tools without sharing lifecycle authority or identity.
The session activity APIs expose scoped, paged reads of delegates, shell jobs and receiver-owned watches for clients. They project the authorities in this document; they do not start, stop, resume or acknowledge work. Navigation carries compact summaries, while activity details come from those session reads.
Two public resource types are in scope:
shell: a background-capable invocation of the existing shell/bash command tool.delegate: a stable subagent resource with one child conversation and private run generations. It has no activation JobRecord or publicjob_id.
The default model-facing posture is:
Shell
mode="foreground"(default) waits inline; a long command may promote to a session-ownedjob_....mode="background"launches that shell job immediately.mode="detached"disowns the process and returns only its PID.delegatecreates one durabledlg_...and returns after its metadata and initial input are durable; creation has nomax_wait_ms. Rely on typed notifications plus one-offjob_status(target=...)/job_listorientation. Read shell bytes withjob:<job_id>and delegate history with its sessiontranscript_ref. Do not poll.
Shell start registration records background evidence for explicit background launches and foreground promotion before acknowledging the handoff. That evidence survives terminal outcomes, forwarding and journal reconstruction. Retained inline foreground output and a foreground runtime limit do not establish background eligibility. Detached processes have no managed job record. Older unmarked records retain their diagnostic history and output without claiming background execution.
The shipped model intentionally does not expose:
job_killjob_ackwait_jobclose_agent- external
agent_id
Stopping is handled by job_stop(target=...). Retention is policy-based, not model-acknowledgement-based. Waiting, when needed, is bounded — max_wait_ms on delegate_send / job_stop, or mode on shell — not a separate wait tool. Reads never wait.
Model-facing guidance requirements
This reference contract is not itself the runtime system prompt, but the following guidance must be reflected in the tool descriptions and in Evener's Background jobs system-prompt section. These bullets are normative for model-facing documentation because they shape whether agents use jobs correctly:
- Shell commands run in
mode="foreground"by default and return inline output for quick commands. Setmode="background"to launch-and-return as a session-owned job; foreground commands that exceed the session-default wait are promoted to durable background jobs and return ajob_id. Setmode="detached"only to immediately disown a process that does not need Evener job visibility, output, notification, or stop control; it returns only a PID. - Delegate creation returns after one stable
delegate_idand its initial input are durable. It does not acceptmax_wait_msand does not expose a run handle. - Use
delegateto start a new delegate conversation:promptis the brief andtask_listseeds its task list, one item per step. Sessions start clean by default; usefork_context=trueonly when the assignment requires the parent's full context and history. It returnsdlg_..., child/session transcript metadata, status, and resumability—neverjob_.... - Use
delegate_sendfor follow-up: a running delegate is steered; an idle, resumable delegate starts its next private run through the same call. - Use
delegate_send(to=<delegate_id>)for follow-up on a durable child conversation; it reaches only your own delegates. A delegate reaches its parent only throughcommunicate:end_turn=falsefor an update,end_turn=truefor observer completion and its final report.delegate_sendrejectscaller,job_id,transcript_ref, and unsupported runtime aliases. - A delegate's
communicate(end_turn=false)is an update its parent receives and may be woken by: delegates send one only when the parent needs to know something or act, never as narration, and finish withcommunicate(end_turn=true). - When an observer readiness delegate result includes
watching:trueandwatches, the observer is watching. Continue with the planned watched action and use the later observercommunicate(end_turn=true)as the callback. - After starting a background job, continue useful work or respond to the user. Do not immediately wait, poll
job_list, or loop onjob_status. - Evener injects typed terminal attention:
<job-notification>for owner shell jobs and<delegate-notification delegate_id="dlg_..." kind="report">for direct delegates. Your delegates handle their own children's attention. - Use
job_watchwhen a condition should notify the watcher.sourceis typed:self, grantedparent, a stabledlg_..., or a visible shelljob_.... Delivery is implicit to the creating session. Use output/progress triggers for shell sources and event filters for session/delegate sources. - Use
job_status(target=...)for metadata-only orientation. A shell target reports process/job fields andjob:<job_id>; a delegate target reports the stable aggregate and child sessiontranscript_refwithout terminal packet contents or notification acknowledgement. - Use the delegate's session
transcript_reffor its full conversation. Ajob:ref is shell output only. - Use
job_stoponly when the intent is to cancel or stop work. It does not delete output/history and it does not acknowledge results. - Use
job_listto recover or inspect job inventory, not to wait for completion. Branch primarily onstatus; treatreasonas diagnostic detail except for documented operational cases such asruntime_lost,run_timeout, andstop_pending.
Tool descriptions should avoid these phrases because they train bad behavior:
- Do not describe
job_watchas subscribing to job completion. Say: “When output/event/progress conditions happen, deliver a bounded watch notification/frame to the watcher that created the watch.” - Do not describe a job-output read as a way to wait. Say: “Completion is notification-driven; do not poll this waiting for a job to finish.”
- Do not describe
job_stopas cleanup. Say: “Request cancellation/stop; retained history/output remains.” - Do not say
delegateresumes an old agent. Say: “Start a fresh stable delegate conversation.” - Do not say
delegate_sendhas a separate idle-policy knob. Say: “Send a follow-up message to a delegate_id; running delegates receive it live, while idle delegates start their next private run through the same call when resumable.” - Do not say
job_watchsends arbitrary unbounded transcript context. Say: “Deliver bounded frame/excerpt metadata when the watch condition is met.” - Do not say a
job:transcript ref reads a delegate. Say: “job:reads shell output; use the stable delegate's sessiontranscript_reffor its conversation.”
Choosing a wait primitive
Several tools can wait, but they wait on different things. Pick by intent, and do not poll.
| Intent | Use |
|---|---|
| Run a command and use its output | shell — foreground; promoted to a background job if it exceeds the session timeout |
| Launch a long command without waiting | shell(mode="background") — returns a job_id immediately |
| Disown a command that must outlive this session | shell(mode="detached") — returns only a PID; no job tools apply |
| Start a delegate | delegate(...) — returns its durable dlg_... immediately; completion is notification-driven |
| Learn when a backgrounded job finishes | the automatic terminal notification — nothing to call |
| Learn when a job's output contains X | job_watch(operation="create", source=<job_id>, output_match=X) to be notified |
| Re-observe progress on a long job | job_watch(operation="create", source=<job_id>, progress_interval_ms=N) (running targets only) |
| Wake yourself later | job_watch(operation="create", after_seconds=N) or repeat_seconds=N, with a note; source defaults to self |
| Resume an idle delegate and wait for its answer | delegate_send(to=<delegate_id>, message=..., max_wait_ms=N) |
| Steer a running delegate | delegate_send(to=<delegate_id>, message=...) — returns on delivery; max_wait_ms is ignored and reported as wait_ignored_reason |
There is no "steer a running delegate and wait for its next reply" primitive: a live steer returns on delivery. Let the run finish and read the delegate's session transcript_ref, or resume its next run with delegate_send(to=<delegate_id>, message=..., max_wait_ms=N) once it is idle.
Terminal targets differ by watch type: only an output_match-only job_watch supports terminal catch-up (a one-shot scan of retained output on an already-terminal job). events, progress_interval_ms, and every watches require a running target and reject a terminal one with target_terminal. Use transcript reads for retained terminal output evidence.
Vocabulary
| Term | Meaning |
|---|---|
| Session | A durable Evener conversation with transcript state. |
| Shell job | One asynchronous process unit owned by a session. |
job_id | Durable opaque shell identity (job_...). Use it for shell reads, control, watches, and notifications. |
| Delegate | One stable child conversation with private run generations. |
delegate_id | Durable delegate control identity (dlg_...). Use it for send/status/stop/watch. |
watch_id | Durable opaque identifier for one watch configuration. Use it for job_watch inspect/clear. |
| Parent session | The session containing the job or turn that caused another session/job to exist. |
| Owner session | The session whose runtime/job manager owns the job. |
| Visible session | A session that may list/control the job because the job was forwarded, e.g. parent visibility into nested jobs. |
| Parent job | A shell job that caused another shell job, when shell ancestry exists. |
| Parent delegate | The dlg_... that owns a delegate-created shell job. |
| Delegate session | The child Evener session owned by a stable delegate. |
| Transcript ref | A safe reference to an Evener session transcript, e.g. local:<sessionID>. |
| Notification | Metadata injected into a visible session to tell the agent that a job reached a lifecycle/progress event. |
| Output | Bounded textual/log content associated with a job. |
A delegate's runs are private controller generations, not jobs. Each delegate invocation creates a new child conversation and one stable dlg_... resource. delegate_send steers its open generation or starts the next generation when idle/resumable. Observer sidecars use communicate(end_turn=true) as the callback.
For shell work, owner_session_id names the process-owning session. A nested shell may have shell-to-shell parent_job_id; a delegate-owned shell carries parent_delegate_id=dlg_.... parent_job_id never encodes delegate lineage.
Clients may present shell jobs and delegates in one items collection, but must branch on type and id; only shell rows carry job_id.
Design principles
- One view, typed authorities. Shell work uses JobRecords; delegates use one stable aggregate fold. They share list/status/stop entry points, not lifecycle storage.
- Handles are purpose-specific.
job_ididentifies a shell job;delegate_ididentifies a stable delegate;watch_ididentifies a watch. Session IDs and transcript refs identify transcripts, not control targets. - Defaults match common use. Shell work is foreground by default with promotion on timeout. Delegate creation durably starts independent work and returns its stable identity without a creation wait knob.
- Notifications replace waiting. The parent should not poll or block just to discover completion.
- Output reads are non-consuming. Reading job output never acknowledges, consumes, hides, or deletes a result.
- No model-facing ack. Retention is automatic and policy-based.
- No model-facing kill.
job_stopis the single model-facing stop primitive; forceful cleanup is an implementation detail when needed. - Provider-free restart. Shell runtime loss is reconciled from shell evidence; stable delegates are folded/rearmed without constructing a Session or calling a provider.
- Nested shell jobs are supported; nested delegation is allowance-gated. Subagents may start shell jobs. A subagent may itself delegate only while its
delegation_allowanceis non-zero. By default a delegate is granted one level below its creator, so it can delegate in turn until the chain reaches a leaf; a creator that passesdelegation_allowance: 0makes a leaf, and an observer sidecar created that way must not delegate. See the delegation-allowance amendment below. - Delegate creation and follow-up are separate.
delegatestarts a new delegate conversation;delegate_sendfollows up on an existingdelegate_id. - Watches are watcher-owned.
job_watchdefines conditions over a source's output/events/progress; when a condition is met it delivers a bounded notification/frame to the watcher that created the watch. - Observers are composed, not special. An observer is a delegate granted
watch_parent:true, a child-createdjob_watch(source="parent"), and an observer result throughcommunicate(end_turn=true). - Transcript tools stay separate.
job:is shell output; delegate child transcripts remain readable through session refs.
Delegation allowance (recursive delegation)
delegate accepts an optional delegation_allowance integer. Absent means the default grant: one level below the creator's own allowance (max(0, own-1)), so a delegate can delegate in turn until the chain reaches a leaf. An explicit 0 means a leaf delegate that cannot itself delegate; absent and 0 are therefore distinct, unlike the strict-zero rule used elsewhere on the job-control surface. There is no minimum/maximum/default keyword on the schema property.
The grant rule. A session may grant a child a delegation_allowance strictly less than its own allowance, so the chain always shortens and allowance 0 is a leaf. A grant >= the granter's own allowance is rejected with invalid_request: delegation_allowance must be less than your own allowance (<A>); valid grants: <range>, where <A> is the granter's allowance and <range> enumerates the grantable values (0 at allowance 1, otherwise 0..<A-1>). A session's own allowance is persisted in the delegate restore descriptor, so it survives restore. job_list reports delegation_allowance only when it exceeds 1; lower values are omitted, so absence does not distinguish allowance 0 from 1.
Availability matrix (allowance-gated). Whether a child receives the delegation surface is governed by its granted allowance, not by a fixed depth gate. At allowance 0 the child is a leaf: it does not receive delegate, agent-type listings that require delegation are filtered out of its prompt, and its system prompt shows the leaf limits block. job_watch is present at every allowance — a session that can run jobs can always watch its own jobs — with each cross-session source authorizing itself (parent requires the watch_parent:true grant; a concrete job ID must belong to the watching session or an authorized live descendant). At allowance > 0 the child receives delegate (added to the default surface for an untyped child; a typed agent gets it only if its tool list names it), may grant onward allowances strictly smaller than its own, is told its allowance in its prompt, and sees the delegation + background-jobs prompt sections. A typed agent's tool list governs what the child gets; allowance governs whether the delegation tools are grantable at all — allowance never injects tools into a type that does not list them.
Depth is the operator's knob. A root session's allowance equals MaxSubagentDepth (default 2). Under defaults the root's allowance is 2, so its delegates receive allowance 1 by default and may delegate once more; their delegates are leaves. An operator caps the whole tree by lowering MaxSubagentDepth (1 makes every delegate a leaf), and a creator caps one branch by passing a smaller delegation_allowance or 0.
Job identity and visibility
Evener mints globally unique-enough job_... values for shell jobs and stable dlg_... values for delegates. Each identity is used unchanged across its typed list, status, watch, stop, notification, and transcript surfaces.
The model-facing API must not expose two competing job handles such as owner_job_id and parent_visible_job_id. If an implementation internally maps child-owned IDs into parent-visible records, that mapping must be durable and invisible to the model-facing tools. The only accepted job handle is the parent-visible job_id returned or listed by Evener.
Job-control tools accept purpose-specific handles, not transcript_ref. delegate_send accepts dlg_...; job_status and job_stop accept typed target values (job_... or dlg_...); job_watch(operation="create") accepts self, granted parent, job_..., or dlg_...; inspect/clear accepts watch_id. Transcript tools accept refs, with job:<job_id> reserved for shell output.
Status and reason model
Shell rows use running, completed, failed, command_exited_nonzero, command_killed, cancelled, or stopped as their lifecycle status. A stable delegate's current status is only running or idle; its prior generation is summarized separately in last_outcome with completed, failed, exhausted, cancelled, or stopped.
| Value | Surface | Meaning | Normative reasons | Owner attention |
|---|---|---|---|---|
running | shell or delegate status | A shell has a live or believed-live process, or a delegate has an open generation. | stop_pending, foreground_timeout for shell | progress/match as configured |
idle | delegate status | No delegate generation is processing; resumability is separate metadata. | usually null | none by lifecycle alone |
completed | shell status or delegate last_outcome | Work ended normally. | exit_zero for shell; otherwise usually null | typed terminal attention |
failed | shell status or delegate last_outcome | For a shell, the job system's own machinery failed (launch, supervision, reaping, finalization); for a delegate last_outcome, the delegate's run itself failed. | start_failed, wait_failed, finalize_failed, forward_failed, missing_terminal, terminal_error, runtime_lost as applicable | typed terminal attention |
command_exited_nonzero | shell status | The supervised command exited nonzero. The job machinery launched, supervised, and reaped it fine — the COMMAND is what failed. | exit_nonzero | typed terminal attention |
command_killed | shell status | The supervised command died on a signal. | killed_by_signal: SIGNAL | typed terminal attention |
exhausted | delegate last_outcome | A delegate generation reached its turn or tool-round budget. | turn_budget_exhausted, tool_round_budget_exhausted | delegate terminal packet |
cancelled | shell status or delegate last_outcome | Evener intentionally stopped work and confirmed cancellation. | stopped_by_parent | typed terminal attention |
stopped | shell status or delegate last_outcome | Work did not complete and Evener cannot attribute it to normal failure or confirmed cancellation. | runtime_lost, cancelled, run_timeout | typed terminal attention |
Validation, lookup, and routing errors are synchronous tool errors and create neither a shell JobRecord nor a delegate generation. Canonical codes include invalid_request, permission_required, target_not_found, target_not_messageable, target_terminal, target_not_resumable, target_not_watchable, and not_controllable.
status is the primary machine branch field. reason is optional diagnostic metadata. Branch first on shell status or delegate status/last outcome, then consult reason for documented operational cases such as runtime_lost, run_timeout, and stop_pending. Keep this portable vocabulary small; other diagnostics belong in free-text diagnostic or error fields.
cancelled is intentional, confirmed stop. stopped is work Evener cannot attribute to normal failure or confirmed cancellation: a runtime timeout, a lost runtime, or a cancellation it could not confirm. Restart reconciles a shell as stopped/runtime_lost and a lost stable delegate generation as failed/runtime_lost, after which the delegate is idle. not_controllable is a synchronous routing/control error, not a terminal status reason.
The following diagram is the shell lifecycle; delegate lifecycle is the two-state running/idle model above.
stateDiagram-v2
[*] --> running: job_started
running --> completed: success / exit 0
running --> command_exited_nonzero: exit nonzero
running --> command_killed: killed_by_signal
running --> failed: error / denied
running --> cancelled: job_stop confirmed
running --> stopped: runtime_lost / cancelled / run_timeout
completed --> [*]
command_exited_nonzero --> [*]
command_killed --> [*]
failed --> [*]
cancelled --> [*]
stopped --> [*]Model-facing tools
Jump to: Shell · delegate · delegate_send · job_watch · job_status · job_list · job_stop.
Existing shell/bash tool
The existing shell/bash tool becomes job-capable. It is the shell job creation surface; there is no separate shell_job tool.
Canonical foreground shape for ordinary commands:
{
"command": "make test",
"description": "run tests"
}Shell defaults to mode="foreground" because most shell calls are short and decision-producing. It waits up to the session command timeout (120s standard), then promotes the command to a durable background job if it is still running. Set mode="background" to launch-and-return immediately for deliberate background work that terminates on its own, such as a long build or test run the agent should not wait on inline. A background job is session-owned and dies with the session — a server or any other process that must keep running belongs in mode="detached", which immediately disowns the process; it returns only a PID and cannot be discovered or controlled through job tools.
Launch-and-return (immediate background) shape:
{
"command": "npm test",
"mode": "background",
"description": "run the test suite"
}Compatibility example with an explicit process-runtime cap (not an advertised model-facing parameter):
{
"command": "go test ./...",
"max_runtime_ms": 900000,
"description": "run tests"
}Defaults and timeout semantics:
mode="foreground"(the default): the call runs the command in the foreground and waits up to the session command timeout (120s in stock provider profiles) for it to finish.- A foreground shell command that completes within that wait returns inline output and is ephemeral by default: no durable
job_idis required and it does not appear injob_list. - A foreground shell command still running at the session command timeout is promoted to a durable background job: Evener returns the current bounded output/status with a
job_id, and the normal terminal notification remains armed for the eventual terminal state. mode="background": Evener starts the command and returns ajob_idimmediately without waiting. The terminal notification fires when it finishes.mode="detached": Evener starts a disowned process and returns only its PID immediately. It is not a job, so Evener neither retains output nor sends notifications nor offers job control for it. It also gets no session scratch: the child's environment dropsEVENER_SCRATCH_DIR, because the scratch is deleted when the root session is archived and the disowned process may outlive that, andTMPDIRnames the world-usable session temp container instead, or nothing when that container could not be provisioned. Give a detached command absolute paths for its output and for any file it reads.max_runtime_msis an optional process runtime limit for shell jobs. If the process is still running aftermax_runtime_ms, Evener stops it and finalizes the job asstoppedwith reasonrun_timeout. It bounds how long the process may run, distinct from the foreground wait.- Omitted or zero
max_runtime_mssets no process-runtime deadline for streaming shell jobs, whether foreground, promoted, or background. Session shutdown and explicit cancellation can still stop session-owned jobs. The model-facing shell schema no longer exposesmax_runtime_ms; the parser still accepts it for compatibility. - A shell command that completes before the tool returns does not inject a terminal notification; the terminal result is already in the tool result. Return field
timed_out, when present, means the foreground wait expired; it never means the process hitmax_runtime_ms. - To learn when a launch-and-return command reaches a state (e.g. a server printing "ready"), use
job_watch(operation="create", source=<job_id>, output_match=...)and continue from the notification — an output-match watch catch-up-scans a job's retained output even if it already finished.
Foreground-wait bounds:
- The foreground wait is the session command timeout (
DefaultCommandTimeoutMS, 120000 in stock provider profiles), clamped toMaxCommandTimeoutMS. Shell has no per-call wait parameter;modeselects foreground (wait, then promote), session-owned background, or immediate detached return. - These values belong to the internal session Config (
default_command_timeout_msandmax_command_timeout_ms), not job-watch policy or per-call wait arguments. Provider profiles supply the normal default; the session fallback before profile selection is 10000 ms. These field names are not advertised CLI options.
Compatibility runtime-limit bounds:
max_runtime_msis the process-killing deadline and must be distinct from the foreground wait (the session command timeout), which only bounds how long the call waits before promoting to background.- Positive
max_runtime_msvalues below1000are raised to1000ms. The parser applies no upper clamp;MaxCommandTimeoutMSclamps the foreground wait, not this runtime limit. - Negative values are rejected before launch.
- A non-streaming execution environment cannot promote or background a command. Its buffered foreground path uses the session command timeout as a process deadline, shortened by a positive
max_runtime_mswhen smaller. Detached execution bypasses the job runtime timer.
Ephemeral foreground terminal return shape:
{
"type": "shell",
"mode": "foreground",
"status": "completed",
"reason": "exit_zero",
"timed_out": false,
"exit_code": 0,
"output": "bounded output text",
"truncated": false
}Explicit background return shape:
{
"job_id": "job_...",
"type": "shell",
"mode": "background",
"status": "running",
"reason": null,
"timed_out": false
}Foreground timeout / promotion return shape:
{
"job_id": "job_...",
"type": "shell",
"mode": "background",
"status": "running",
"reason": "foreground_timeout",
"timed_out": true,
"wait_elapsed_ms": 120000,
"output": "bounded output text",
"truncated": false
}wait_elapsed_ms is how long the foreground wait actually blocked before the promotion. It lets the agent's pacing arithmetic use the real wait (the session command timeout that expired) rather than the duration the command was asked to run, which can be much longer.
Shell sandbox denials are final for that call: they do not open an approval card or create an awaiting_permission job. The current escalation allowlist covers only read_file, write_file, and edit_file, where a one-file grant can permit a retry. Shell/kernel denials cannot use that grant. Inspect the denial and sandbox policy rather than waiting for a shell approval event.
delegate
delegate starts a new stable delegate resource and child conversation. It does not resume or steer an existing delegate. Follow-up uses delegate_send with the returned delegate_id. To start an observer sidecar, set watch_parent:true; the child can then observe its immediate parent with job_watch(source="parent") and report through communicate(end_turn=true).
Delegates start with a clean session by default. Put the relevant facts, decisions, and excerpts in the assignment whenever that gives the child enough context. Use fork_context:true only for work that requires the parent's full context and conversation history. The option requires the same model and provider, including the model selected by an agent role.
A fork takes a fixed copy of the recorded conversation before the unfinished tool round. Completed tool exchanges and images remain available; compaction summaries determine the working context just as they do in the parent, while the earlier conversation remains in the child's transcript. Later parent messages are not added to that snapshot. Resuming the delegate uses its own saved transcript.
The child keeps its own assignment, role, tools, sandbox, and delegation allowance. Parent usage, pending deliveries, client mutation IDs, and server continuation handles are not adopted. The assignment must still state what the child owns, how to verify the result, and what to report back.
Canonical background shape:
{
"prompt": "Investigate the failing parser test and report findings.",
"agent_type": "explorer",
"model": "openai/gpt-5.5",
"reasoning_effort": "high"
}Full target shape (no max_wait_ms):
{
"prompt": "Investigate the failing parser test and report findings.",
"task_list": [
{"title": "Reproduce", "prompt": "Run the parser test suite and capture the failing case verbatim."},
{"title": "Report", "prompt": "Report the failing input, the stack, and the smallest change that would fix it.", "type": "research"}
],
"agent_type": "explorer",
"model": "openai/gpt-5.5",
"reasoning_effort": "high",
"watch_parent": false,
"fork_context": false,
"result_schema": {
"type": "object",
"properties": {
"summary": {"type": "string"},
"files": {"type": "array", "items": {"type": "string"}}
},
"required": ["summary"]
}
}Defaults:
- Each
delegatecall creates one newdlg_...resource and child session. - Creation returns after the descriptor and initial input are durable. It does not wait for a model result and rejects
max_wait_ms. delegatedoes not accepttarget,mode,job_id,delegate_id, ortranscript_reffor continuation.watch_parent:truegrants only the created child permission to observe the immediate parent withjob_watch(source="parent"). It is not transitive and does not grantdelegate.- Delegates have no model-facing
max_runtime_ms. Use explicit stop control when cancellation is needed. result_schema, when supplied, is a JSON Schema-like contract for terminal packets from the initial and resumed runs. Validation, explicit JSON null, and structured-result failure reasons survive in the stable aggregate and client projections.- Delegate interaction is turn-based. A delegate that needs more input should finish with a request for that input; the parent follows up with
delegate_send. Mid-turn interactive input/awaiting-input notifications are not guaranteed. A generation whoselast_outcome.status="completed"ended normally; that does not by itself assert task success. Inspect its canonical terminal packet or conversation for the task-specific result. - An observer that installs watches reports readiness through its terminal packet; the stable watch remains active and later frames resume the same
dlg_....
Create return shape:
{
"delegate_id": "dlg_...",
"child_session_id": "01JCHILD...",
"type": "delegate",
"status": "running",
"resumable": true,
"transcript_ref": "local:01JCHILD...",
"model": "openai/gpt-5.5"
}delegate_id is the only control identity. child_session_id is diagnostic lineage, and transcript_ref is the read handle. A start failure after stable identity mint returns that same identity plus status/reason/error; it never creates a fallback activation JobRecord.
delegate_send
delegate_send sends a message to one of your durable child delegate conversations by delegate_id. It reaches only your own delegates: a delegate reaches its parent with communicate (end_turn=false for an update, end_turn=true for its report), and to="caller" is rejected with an error that says so, at the root as well as in a delegate.
Target shape:
{
"to": "dlg_prior_or_running_delegate",
"message": "Check whether the serializer has the same issue.",
"max_wait_ms": 120000
}Core target resolution:
| Target | Meaning |
|---|---|
dlg_... | A durable delegate conversation owned by this session. |
delegate_send rejects caller with an error that points at communicate. It rejects job_id handles because they identify shell work, not delegates. It also rejects transcript_ref handles and unsupported runtime or legacy aliases such as main or watched.
Semantics:
- If
toidentifies a running or currently-driven delegate, Evener injects the message into that exact generation. The returnactionissteered. The injected steering turn has sourceparent, and the delegate's model reads it after the lineMessage from your parent agent:; clients label it "From parent". A message that starts an idle delegate opens its next generation as user input, as the original brief does. - If
toidentifies an idle/resumable delegate, Evener starts its next private generation in the same child conversation. The returnactionisstarted. - If
tois ajob_id,transcript_ref, unknown/unauthorized delegate, non-resumable delegate, or unsupported legacy alias, the call fails synchronously without minting another identity or JobRecord. - One delegate has at most one open run. Admission, steering, stop, and terminal settlement are serialized by the stable controller aggregate.
- Target state is resolved atomically at delivery time. A race between terminal/running state and the tool call is resolved by the observed state at delivery.
max_wait_msunset (or0) on an idle start returns after admission. A positive value waits for that newly started generation's terminal packet up to the bounded wait. A live steer always returns on delivery; a supplied wait is ignored explicitly throughwait_ignored_reason.max_wait_msapplies to concrete delegate targets.
Watch-origin observer completion uses communicate(end_turn=true). A delegate sends a non-terminal update without ending its turn with communicate(end_turn=false); an update does not replace the observer callback or final result.
stateDiagram-v2
[*] --> ValidateTarget: delegate_send(to)
ValidateTarget --> Error: unknown / unauthorized / wrong handle
ValidateTarget --> TargetRunning: delegate running
ValidateTarget --> TargetIdle: delegate idle
TargetRunning --> MessageSameRun: inject guidance
TargetIdle --> NewRunSameDelegate: resumable
TargetIdle --> Error: not_resumable / session_busy
MessageSameRun --> [*]
NewRunSameDelegate --> [*]
Error --> [*]Return shape when messaging a running target:
{
"delegate_id": "dlg_...",
"type": "delegate",
"status": "running",
"reason": null,
"running_in_background": true,
"timed_out": false,
"action": "steered"
}A live steer carries no transcript_ref — the steer branch never resolves one, and the field is omitted rather than sent empty (agent/delegate_runtime.go#delegateRuntime.send, agent/session_tools_jobs.go#delegateSendResult). delegate_id is the only identity a steered result carries.
Return shape when starting an idle delegate's next run:
{
"delegate_id": "dlg_...",
"type": "delegate",
"status": "running",
"reason": null,
"running_in_background": true,
"timed_out": false,
"action": "started",
"transcript_ref": "local:01JCHILD..."
}Once a resumed generation is dispatched its transcript_ref is set and stable for that generation, so delegate_id + transcript_ref together are the result's identity whenever a transcript_ref is present at all.
delegate_send accepts a delegate ID owned by the caller. Other targets, including shell job_id handles, return the bare tool error not_controllable: delegate, with no result JSON or target ID in the error.
- Results identify the delegate with
delegate_idand, once available,transcript_ref. They contain no job ID fields. - Starting an idle delegate returns
action: "started". With a positivemax_wait_ms, a reply received within the wait returnsaction: "completed",running_in_background: false,output, andstructured_result. If the wait expires first, the result keepsaction: "started"and addstimed_out: truewithout reply content. Only the wait has ended; the delegate is still running. max_wait_msis silently capped at 60000 ms. Delegate creation accepts no wait argument and rejects it withinvalid_request: delegate creation does not accept max_wait_ms.
job_watch
job_watch creates, lists, inspects, or clears standing triggers. A watch is owned by the session that creates it. Create names the observed source; when a condition matches, Evener delivers a bounded notification/frame back to that watcher. There is no model-facing send object.
Operations:
operation="create"requiressource, except timers default an omitted source toself.operation="list"takes no source or watch ID.operation="inspect"requireswatch_id.operation="clear"requireswatch_id.
Sources:
| Source | Meaning | Authorization |
|---|---|---|
self | This session's public events | Always allowed, subject to loop guards |
parent | Immediate parent session's public events | Only inside a child created with delegate(watch_parent:true) |
dlg_... | A stable direct delegate's bounded public events | Allowed when the delegate is controllable by this session; binds its current generation and ends with it, recreate for the next run |
job_... | A concrete shell job's output/progress/events | Allowed when the job is owned by this session or a live descendant session |
job_watch is not a completion subscription: shell terminal notifications and direct-delegate terminal packets are automatic.
Output-match shape:
{
"operation": "create",
"source": "job_...",
"output_match": "(?i)(ready|blocked|needs input)"
}Periodic-progress shape:
{
"operation": "create",
"source": "job_...",
"progress_interval_ms": 300000
}Filtered parent observer shape:
{
"operation": "create",
"source": "parent",
"events": ["assistant.tool"],
"event_filter": {
"tool_name": "read_file",
"status": "ok"
}
}Clear an existing watch:
{
"operation": "clear",
"watch_id": "watch_..."
}Trigger fields:
output_matchis level-triggered: it fires once at attach if the shell job's already-retained output contains a match, then again as appended output matches the regex. It requires a concretejob_...source;output_matchon a session/delegate source failsinvalid_request.progress_interval_msfires periodically with bounded progress/excerpt metadata even if no match occurred. It is a separate progress trigger, not an event-frame modifier. On a session sourceprogress_interval_msis refused; a timer usesrepeat_seconds.eventsselects session/job event kinds to include in the watch frame.events: ["*"]means all visible event kinds allowed by caller permissions and filtering. The shipped vocabulary isassistant.tool,communicate, andjob.notification. Plain assistant prose is an internal transcript/UI event and is not watchable throughjob_watch.event_filternarrows event watches before any delivery is recorded. In v1 it applies only toevents: ["assistant.tool"]and supports exacttool_nameplusstatus("ok"or"error"). Non-matching events do not create a delivery, pending row, notification, or observer wake. Assistant-tool frames include the resultingstatusplus the original toolarguments_json, so an observer can usually decide from the delivered frame before using audit tools.everygates event delivery:every: Nfires on each Nth occurrence of the watched event kind, for exampleevents: ["communicate"], every: 3for every third result-tool message.every: 1is the semantic default and reads as unset, whatevereventscontains.every > 1is valid only wheneventsnames exactly one concrete kind; supplying it with zero, multiple, or wildcard ("*") kinds failsinvalid_request.- Session event watches that set
eventsuseevent_filterand optionaleveryfor precision. Combining sessioneventswithprogress_interval_msfailsinvalid_requestbecause periodic progress is a different trigger mode.
For cross-session session sources such as parent, omitting trigger fields means deliver all bounded public watch frames for that source. A stable delegate source records its dlg_... identity and binds the exact current private generation, so a later run cannot be mistaken for the watched one. For shell jobs and self, create still requires a meaningful output/event/progress condition.
Observer/sidecar pattern:
1. Parent starts a child with delegate(watch_parent:true).
2. The child creates job_watch(operation="create", source="parent", ...).
3. Matching parent frames are delivered to that child.
4. The child sends updates with communicate(end_turn=false) while it watches and reports its findings with communicate(end_turn=true).
5. The parent treats that communicate as the observer callback and finishes from it.Observer sidecars are composition, not a separate model-facing observer tool. Use delegate_send(to=<delegate_id>) for parent-to-child follow-up and communicate(end_turn=false) for a non-terminal update to the parent. Use communicate(end_turn=true) for observer completion and final results.
Practical observer guidance for agents:
- Start the observer with
watch_parent:truewhen it needs to observe the parent. - Inside the observer, call
job_watch(source:"parent"); omit trigger fields for the default bounded parent frame stream, or addevents/event_filterfor precision. - When the readiness delegate result carries
watching:trueandwatches, treat the observer as watching and perform the planned watched action. - Send an update with
communicate(end_turn=false)only when the parent needs to know something or act: it reaches the parent and may wake it. - Report the observer result with
communicate(end_turn=true). That terminal communicate is the callback to the parent. The parent receives it as that observer delegate's ordinary terminal frame — a<delegate-notification delegate_id="dlg_..." kind="report">block carrying the result packet — which arms delegate attention and wakes the parent. - Keep observer instructions narrow and frame-driven. Tell the observer what frame fields to read (
watch_id,delivery_id, typed source identity, event kind/status/arguments, and optional excerpt), what action to take, and when to stop. - A watch-origin observer delegate's terminal
communicate(end_turn=true)is delivered to the parent as that generation's owner notification; there is no separate observer-callback frame and no suppression of the owner notification. Other typed terminal notifications remain lifecycle confirmations, not additional watch frames. Clear long-lived session watches before continuing a free-form conversation when later acknowledgements should not themselves be observed.
Rules:
- Every notification-armed shell job and every direct delegate terminal packet emits its own typed owner attention, subject to durable duplicate suppression.
job_watchis unrelated to that terminal notification. job_watchonly adds extra notifications or frames while the watched source is active/visible.- For an already-terminal concrete job that still has retained output, an
output_match-onlyjob_watch(noeventsorprogress_interval_ms) performs a one-shot catch-up scan of that retained output — the same windowed scan and the same window bound as a live watch's attach scan — instead of installing a live watch: it returnsterminal_catchup=truewithwatching=false,fired=trueand a frame/notification on a match orfired=falseon none, and the terminalstatus. Any other condition on a terminal target still fails synchronously withtarget_terminal(nothing can ever fire). Unknown or no-longer-retained concrete job targets still fail synchronously withtarget_not_found. job_watch(operation="clear", watch_id=...)does not stop the watched job. Clearing is bywatch_id, is idempotent, and returns a no-op success (watching:false) when the watch is already inactive, unknown, or was auto-removed because the source reached terminal state. The caller never has to reconstruct the original source/condition identity.job_watchfails synchronously withsource_not_watchableortarget_not_watchablefor sources the caller is not allowed to observe. A concrete descendant job can be watched by forwarding the watch install to the live owning descendant while keeping the ancestor as receiver. Parent, sibling, unrelated, closed, or non-live sources are not watchable unless explicitly granted.- A watcher may observe the events it generates itself. A
selfsource on a self-generated kind (assistant.tool,communicate, including via["*"]), delivering back to the same session, installs and returnswatching:true; nothing is rejected at creation for being a potential feedback loop. The loop is bounded at runtime instead, by three mechanisms: a self-influenced frame is prefixed with a gradient notice telling the receiving turn how deep it stands in its own influence (↳ this turn responded to your last message., sharpening to↳ you're ~N exchanges deep responding to your own influence — consider disengaging.); a send descending from too many delivered self-influenced priors is dropped asrunawayinstead of delivered; and the per-watch delivery budget below auto-clears a watch that keeps firing. Parent-source sidecars are cross-session and are not self-influenced by this rule at all. - Within a live runtime, watches expire when a concrete watched shell reaches terminal. A stable delegate watch ends with the exact generation it bound; its terminal frame is ordered before the end notice. Session-level watches remain active until their configured scope ends or the session closes. Restore follows the separate journal and restart rules below.
- A structured terminal shell notification names
read_transcript(transcript_ref="job:<job_id>"). Delegate attention namesdelegate_idand the child session transcript ref, never a job read. References do not widen list/status/stop/watch scope. SessionMeta.ObservedByis append-only, deduplicated UI metadata for hub auto-open. A worker is never recorded as observing itself; metadata failures never change or delay delivery.- The watch journal preserves registration configuration, pending/coalesced send deliveries, acknowledgements, and end records. On job-manager restore, pending sends are recovered, but active watch registrations end with
runtime_lost; future observation and timer ticks require a new registration. This includes saved declarative repeating timers. Callback closures remain process-local. A send generation that has already spoken can omit an individual restart-end notice; its pending deliveries still follow the durable-send recovery path. Journal durability does not imply that an active watch is rearmed. An agreed recovery outcome exists but is not implemented: a restart does not yet re-establish an active watch, and the owner is told the watch ended rather than left to assume it still runs. - Already-fired parent-watch frames are durable until delivered, replaced by a newer frame for the same durable key, evicted by watch cleanup, or dropped with a caller-visible diagnostic on hard/non-resumable failure. The durable key includes the
watch_id, visible session, configured watch source/target, receiver identity, resolved watched identity, and watch generation. job_watch(operation="clear", watch_id=...)is the model-facing unwatch operation; there is no separate unwatch tool.
Registration and timers
Non-timer watches allow one active configuration per (watcher_session_id, source identity, receiver identity). An identical create is idempotent. Changing the conditions for that same identity replaces the existing watch and reports replaced_existing=true.
Timers are additive: every timer create adds an independent watch, even with identical arguments. To replace a timer, clear its watch_id and create another.
Output matching
output_matchis a Go/RE2 regular expression over the watched job's retained output at attach and then output appended while the watch is active.- Matching uses the raw byte stream through a rolling 4096-byte window, not assembled lines. Output needs no newline, and chunk boundaries do not lose matches. A single match may be at most 4096 bytes; longer matches are not reported. The excerpt is the line where the match begins, capped at 4096 bytes and newline-free.
- Each occurrence fires once, even if its matched span changes as the window moves. A second token inside an already-reported open-ended match counts as part of that match, not a new occurrence. Prefer
READYover.*READY.*when every occurrence matters. - Regex matching is case-sensitive by default; use inline flags such as
(?i)or(?i:plugh)for case-insensitive matching. - Go/RE2 syntax is leftmost-first and excludes backreferences/lookaround.
.does not match newline unless(?s)is used. - Patterns use multiline mode:
^and$anchor at newlines and at the scan window's edges. Thus^ready$can fire beforereadyhas a newline, and^can match within a line longer than the window. Attach scans use a fixed stride; live scans follow writes. Their different edges can produce different anchored matches. Do not use$as proof that the output line is complete. - CRLF output is handled by treating the
\rof a\r\npair as a line end, so$works on Windows-style output. RE2 defines^and$by the same byte, so on a CRLF stream a bare^$also sees one empty line per line ending, and a pattern matching a literal\r\ncannot match. Match line content, not line terminators. - Invalid regexes fail synchronously at watch creation time.
- Preview-window eviction must not lose matches in output retained at attach or successfully appended while watching, including tokens across the attach boundary. The scan-window and regex limits above still apply.
Delivery and limits
- Event frames and output excerpts are bounded and filtered before notification or observer delivery. Implementations may apply redaction/scrubbing for cross-session or observer delivery, but this contract does not promise perfect secret detection; callers must not treat frames as guaranteed secret-free.
- Concrete jobs support
progress_interval_ms, clamped to 1000–3600000. Omitted or zero means no periodic progress; negative values failinvalid_request. Session event watches useevents/event_filter, not periodic progress. - Timers use
selfwithafter_seconds(60–86400) orrepeat_seconds(60–3600). Out-of-range values fail rather than clamp; zero or null means absent. Each source job manager may hold at most eight live timers. - Each source job manager may hold at most 32 live watches in total, including timers. Creating a new watch at that cap fails
invalid_requestwithtoo many watches (32 live); clear one first. Reconfiguring an existing non-timer key does not grow the set. Receiver-visible watch lists can exceed 32 because they also include watches held by descendant source managers; the cap applies separately to each manager. - An optional
notelabels any watch and accompanies every fire. - Integer arguments to
job_watch(every,progress_interval_ms,after_seconds,repeat_seconds) must be integral JSON numbers; strings and fractional numbers failinvalid_requestrather than being silently ignored. - Match/event/progress notifications are batched/throttled. Multiple triggers may be coalesced. For parent-watch observer frames, coalescing is latest-frame-wins by durable key and must not turn a matched condition into silence: Evener either delivers the current pending frame, replaces it with a newer pending frame for the same key, or emits a caller-visible diagnostic for hard failure.
- Replacing a pending frame releases its claim on the receiver, so the old frame no longer holds up stopping the receiver or closing its tree. An in-flight delivery keeps its claim until it finishes. If replaced, it stands down without an error, unless the receiver already took the frame and only acknowledgement remains to retry.
- Each watch configuration has a model-facing delivery budget of 50 condition fires: output matches and event frames. Periodic ticks, timer or job progress, count as deliveries but never trip the budget, so the
deliveriescountjob_listreports can run past the fires the budget counts. - A watch that exhausts its budget is auto-cleared with a final notification giving its match count. Re-arm with a tighter condition: higher
everyor narroweroutput_match. - Terminal notification ordering: flush any queued watch notification/frame for a concrete job, then deliver the terminal notification. Both are facts about the same completion, so they arrive in one notification turn, watch settlement first.
- If no watch condition is supplied (
output_match,events,progress_interval_ms,after_seconds, orrepeat_seconds) foroperation="create", the tool fails unless the source is a granted cross-session session source such asparent, where the default is the bounded public frame stream.
Return shape:
{
"watch_id": "watch_...",
"source": "job_...",
"watching": true,
"output_match": "(?i)(ready|blocked|needs input)",
"events": ["communicate", "assistant.tool", "job.notification"],
"progress_interval_ms": 300000,
"replaced_existing": false,
"fired": false
}replaced_existing and fired are always present, explicitly false when they did not happen — fired is true only for an attach scan or terminal catch-up that matched (§7.1).
The create result's one-line text names the trigger it installed: a job progress watch reads progress_interval_ms 300000ms, a timer reads after 600s or every 300s, and any watch's note follows as note: ....
stateDiagram-v2
[*] --> Unwatched
Unwatched --> Watched: job_watch(operation=create, source, condition)
Watched --> Watched: duplicate idempotent / replacement update
Watched --> ConditionMet: output / event / progress trigger
ConditionMet --> NotifyWatcher: deliver bounded frame/notification
NotifyWatcher --> Watched: delivered/coalesced
Watched --> Expired: concrete job terminal / scope ends
Expired --> [*]job_status
job_status inspects one typed resource by required target: shell job_... or stable delegate dlg_.... It is metadata-only orientation: identity, lifecycle, timing, resumability, and the transcript ref that names evidence. It does not return a delegate terminal packet or acknowledge delegate attention.
Canonical return shape:
{
"job_id": "job_...",
"kind": "shell",
"status": "running",
"phase": "process_running",
"reason": null,
"resumable": null,
"running_for_ms": 12000,
"quiet_for_ms": 4000,
"started_at": "...",
"ended_at": null,
"last_event_at": "...",
"transcript_ref": "job:job_...",
"exit_code": null
}Canonical behavior:
- A shell returns
kind="shell", observable processphase,job:<job_id>, output/exit metadata, and the shell status/reason model. - A delegate returns
id="dlg_...",type="delegate", public lifecycle status, descriptor metadata, run/latest-activity timing, resumability, and its child sessiontranscript_ref.last_outcomecarries the latest terminal metadata. - Quiet/running timing is a supervision hint, not proof of a stall.
- Completion is notification-driven.
job_statusis for orientation and recovery, not for waiting: it never blocks, and calling it in a loop is the polling anti-pattern. - Reading an owner's terminal shell status consumes that shell's pending terminal notification. Delegate status never consumes its terminal packet; exact transcript-backed attention is consumed only by a successful notification turn.
- An unknown or unauthorized typed target fails synchronously and points the caller back to
job_list.
Stable delegate example:
{
"id": "dlg_...",
"type": "delegate",
"status": "running",
"task": "Investigate parser failures",
"agent_type": "explorer",
"resumable": true,
"transcript_ref": "local:01JCHILD...",
"run_started_at": "...",
"latest_activity_at": "...",
"running_for_ms": 12000,
"quiet_for_ms": 4000
}Reading job output
Shell output is read with the generic transcript reader: read_transcript(transcript_ref="job:<job_id>"). There is no separate output tool. Delegate conversations are read with their session transcript_ref; no job:<dlg_...> or activation-output view exists.
Canonical target shape:
{
"transcript_ref": "job:job_...",
"format": "markdown"
}Canonical behavior:
- The read is a snapshot. It never waits, so there is no
max_wait_msand no way to turn a read into a wait; terminal notifications andjob_watchare how conditions reach a caller. - With no explicit operation, a
job:ref returns the bounded markdown view.offset_bytesselects a fixed 16 KiB raw page in lifetime coordinates, andoutput_matchplus optionalcontext_linesperforms a bounded RE2 search of retained complete lines. Returned continuations advance either operation. - An explicit job
formatother thanmarkdownis rejected. Explicitformat:"markdown"is a neutral no-op in the default, paging, and search views.rangeandexpand_turnare session-only. Search is retrospective evidence; usejob_watch(output_match=...)when a future match should wake the owner. - Reads are non-consuming and non-acknowledging.
- The markdown envelope carries
transcript_ref,format,content_type, the renderedcontent, and boundedmeta. Page/search envelopes reporttotal_bytes,retained_start_bytes, andjob_status; missing/pruned bytes fail withoutput_unavailable. contentfor a shell job is a# Shell Job <job_id>heading, then- status:,- command:,- total_bytes:(the job's lifetime output byte count),- dropped_bytes:when bytes were permanently evicted past the retention cap, then the retained output in a fenced block.- The window is the tail of retained output, and the envelope is bounded. An oversized render keeps a head and a tail with an elision marker between them (
… N characters elided from this oversized turn; additional output is not available from this transcript view …) and setsmeta.truncated. That is a rendering bound, not an eviction — only- dropped_bytes:means bytes are gone. - The read must work for terminal durable shell jobs after the live runtime is gone, as long as the job record and output file are retained.
An exact job: reference reads durable local state. Evener resolves the owner encoded in the job ID in the current project first, then searches sibling project buckets under the local state home when that layout supports them. A flat state directory has no sibling-project search. The owner need not be live or in the caller's descendant tree.
The job journal and output must still be retained. Missing or corrupt records, lookup limits, ambiguous sibling matches, and pruned output can prevent a read. This read-only lookup does not widen job-control or live-tree enumeration scope.
job_list
job_list returns one unified items view over shell JobRecords and stable delegate aggregates, optionally filtered by status/type.
Target shape:
{
"status": ["running", "idle", "completed", "failed", "command_exited_nonzero", "command_killed", "exhausted", "cancelled", "stopped"],
"type": ["shell", "delegate"],
"limit": 50,
"include_nested": false
}Rules:
- Default
include_nested=false. - Default
limit=50; maximumlimit=100. Omitted uses the default. Values above the maximum are clamped downward. Values<=0failinvalid_request. - Results are sorted by latest activity descending, tie-broken by typed
id. - Use
job_listto recover knownjob_.../dlg_...identities and inspect durable state. Do not call it repeatedly to wait for completion. - The owning session can list its own jobs.
- A parent session may list nested jobs owned by delegate child sessions only when those jobs have been forwarded into parent-visible durable job records.
- Delegate items come directly from the stable aggregate and contain
id=dlg_..., descriptor/task/model/lineage fields, status/outcome/timing, resumability, exhaustion diagnostics, and childtranscript_ref; they contain no activation job aliases.delegate_senduses the same resumability state. - Shell items contain both
idandjob_id=job_..., process/output metadata, and typedparent_delegate_idwhen a delegate owns the shell.
The model-facing text uses single-line previews of names and descriptions (or the shell command when no description is present). Each preview is at most 200 Unicode code points, including its omission indicator. Whitespace is collapsed only after taking that bounded prefix. The renderer shrinks previews further to fit the registered character budget, normally 20,000, while keeping selected identities, ordering, pagination, fixed metadata and watch information intact. It does not change the structured items, stored descriptors or full briefs. Use the existing detail and transcript reads for complete evidence, subject to their own output limits.
If the fixed content alone exceeds the character budget, the renderer omits previews and leaves truncation and full-output recovery to the tool registry. Line-limit overrides still apply. A nonpositive registered character limit removes the page cap, but keeps the 200-code-point preview bound; configuration overrides accept only positive limits. The registry's repeat-call guidance is appended after output limiting and can exceed the page budget.
Good inventory example:
{
"status": ["running"],
"limit": 20
}Bad polling anti-example:
Bad: job_list -> job_list -> job_list waiting for a job to finish.Return shape:
{
"items": [
{
"id": "dlg_...",
"type": "delegate",
"status": "running",
"description": "Investigate parser test",
"owner_session_id": "01JPARENT...",
"transcript_ref": "local:01JCHILD...",
"resumable": true,
"started_at": "...",
"last_activity": "..."
}
],
"count": 1,
"watches": [
{
"id": "watch_1",
"source": "job_...",
"condition": "output_match: ready",
"deliveries": 0,
"created_at": "..."
}
],
"recent_watches": [
{
"id": "watch_0",
"source": "job_...",
"condition": "output_match: ready",
"deliveries": 2,
"end_reason": "auto_removed_terminal",
"ended_at": "..."
}
],
"delegation_allowance": 2
}Rows are lean for scanning: inapplicable fields are omitted, visible_to_session_id stays internal, and owner_session_id matters mostly in tree walks. Detail comes from job_status(target=...), then the typed transcript ref (job: for shell, session ref for delegate).
delegation_allowancereports the calling session's current recursive-delegation budget: the largest value it may grant a child is one less (see Delegation allowance). It is omitted when<= 1(a leaf with nodelegatetool, or a budget that can only grant0— a no-op knob) and present when the session can actually fan out, so an agent sees a meaningful budget without re-reading its system prompt.watchesenumerates the session's currently active watch configurations (the same setjob_watchinstalls), so an agent can re-orient on what it is already watching without re-deriving it. Each entry carries a stableid(preserved across an idempotent re-configure; a replacement gets a freshid), the publicsource, a one-lineconditionsummary of the watch's trigger (output_match: <regex>,progress_interval_ms,after_seconds: Norrepeat_seconds: N, oreventswith an optionalevery N, plus anote:part for any watch that carries one;inspectandlistrender the note through that summary, which bounds the note at the same 2,048 characters storage keeps, so a stored note always shows in full),deliveries(model-facing deliveries so far; the budget counts the condition fires among them), andcreated_at. Receiver-owned watches are visible to the receiver, not to the descendant manager that physically observes the source. Drain-only residue from already-terminal watched jobs is not listed.watchesrides with the result when non-empty (omitted from the lean scan when there are none); it is not subject to the job list's size bounding.recent_watchesis a bounded, latest-first ring of watches that have left the active set, so a watch that fired and then disappeared stays legible (it is not a watch vanishing into ambiguity). Each entry carries the sameid/source/condition/deliveriesplusend_reason—auto_removed_terminal(the watched job went terminal),cleared(job_watch(operation="clear", watch_id=...)),replaced(a different configuration superseded it),fired(a one-shot timer retired by its only fire),budget_exhausted(it hit the per-watch delivery budget), orjob_manager_closed(the owning job manager shut down — session teardown — while the watch was still installed) — andended_at.runtime_lostis a durable end reason too, written at restart for active watches that were not restored, but it never reaches this ring. Combined withdeliveries, this distinguishes a watch that fired before it was removed from one that never fired, and both from a watch that was never installed (absent from both lists). Receiver-owned history is visible to the receiver, not the physical source owner. It is omitted from the lean scan when empty. The ring is a debugging aid, not a durable audit log, and does not survive process restart.
description is optional display metadata. For shell jobs it comes from the shell tool's description argument. Delegate descriptions derive from the stable descriptor/task.
last_activity is the most recent parent-observable activity timestamp. A running delegate that is working silently stays at its run start until it emits fresh activity, which is the signal the quiet watchdog acts on. A per-action "current action" field is intentionally absent.
total_bytes is the job's lifetime output byte count: the live so-far count for a running job, the final count once terminal. It carries the same name in the shell result and in a job: transcript read's - total_bytes: line, so the count is identical across every surface.
command is shell-only and omitted for delegates.
exit_code is the process's own exit status only for a job that exited on its own (completed/command_exited_nonzero). A command_killed, cancelled, stopped, or run_timeout job was signalled rather than exiting cleanly, so it has no real exit status: exit_code is -1 (a sentinel, not a shell code). Interpret a job that did not exit on its own from its status + reason, never from exit_code.
job_list returns a collection. Status/stop operate on one typed target.
job_stop
job_stop requests cancellation of a shell job or stable delegate by typed target. Use it only when the desired outcome is to stop work; it does not acknowledge results, delete history, or free retained output.
Target shape:
{
"target": "job_... or dlg_...",
"max_wait_ms": 5000,
"include_children": false
}Semantics:
max_wait_msunset (or0): the call returns promptly after the stop request is signalled, with whatever status the stop has reached. With a positivemax_wait_ms, the tool performs one bounded wait of up to that many ms for the stop to finalize.max_wait_msis the caller-visible wait budget after Evener sends the stop request. It is not a runtime limit and does not delete the job if it expires. A positivemax_wait_msis clamped to 1000–60000 ms. Zero or omission means return promptly; negative values failinvalid_request.- For shell jobs, signal the process/process group where supported.
- For a delegate, fence the whole stable subtree, cancel its active run and descendant model/shell work, and discard queued deliveries that cannot commit below the stop fence.
- If graceful stop needs internal forceful cleanup after timeout, that is an implementation detail of
job_stop; there is no separate model-facingjob_kill. - Implementations may continue escalation asynchronously after returning
running/stop_pending; terminal notification remains armed and must report the eventual final state. If an implementation completes escalation before returning, it must still stay within the caller-visible wait budget or returnrunning/stop_pending. job_stopmust actually signal/abort the runtime before finalizing as stopped/cancelled.- Stopping does not delete output, transcript, or durable job records.
- Stopping does not require or imply acknowledgement.
- If a shell job already completed before stop lands, return its actual terminal status.
- If shell stop is confirmed, terminal status is
cancelledwith reasonstopped_by_parent. - If no live shell handle remains and cancellation cannot be confirmed, terminal status is
stoppedwith reasonruntime_lost. - If a shell is still running after timeout, status remains
runningwith reasonstop_pending, and a later terminal notification remains guaranteed. - A shell return classifies the result in
outcome:cancelled_by_request(the stop cancelled a live job),already_terminal(the job had already finished before the stop),completed_during_stop(the job finished on its own as the stop landed), orstop_requested(still finalizing, e.g. reasonstop_pending).previous_statusreports the status the job held immediately before the stop signal, so a race between completion and cancellation is unambiguous. - A stable delegate stop records its admission-time lifecycle and preserves that classification across same-target retries and provider-free restart. Incomplete at
max_wait_msreturns currentstatus="running",reason="stop_pending", andoutcome="stop_requested". Completed stop returns currentstatus="idle"; admission while active returnsprevious_status="running"andoutcome="cancelled_by_request", while admission when already idle/no-work returnsprevious_status="idle"andoutcome="already_idle". already_idleis a result classification, not a fast path around the common durable stop request and completion fence.- For a shell job,
job_stoptargets exactly the suppliedjob_idand is not recursive by default; passinclude_children=trueonly when the user intends to cancel visible active nested jobs too. - For a
dlg_...target, recursive stop is inherent andinclude_childrencannot weaken it. The controller fences one exact stable subtree, joins accepted starts/receipts, cancels descendants postorder, and records the durable subtree-stop completion before releasing retained worktree evidence.
Delegate stop (always cascades into the stable subtree):
{
"target": "dlg_coordinator"
}Stopping a delegate cancels current private generations and descendant shell jobs without inventing activation records. Already-terminal descendants are harmless; Session Close still tears down resident sessions recursively.
Shell stop with include_children:
{
"target": "job_parent_shell",
"include_children": true
}For a shell job this also stops its visible active nested jobs.
Return shape:
{
"id": "dlg_...",
"type": "delegate",
"status": "idle",
"reason": "stopped_by_parent",
"previous_status": "running",
"outcome": "cancelled_by_request",
"requested_by": "root session sess_...",
"resumable": true,
"scratch_path": "/abs/path/to/scratch",
"worktree": {"path": "/abs/path/to/worktree", "branch": "delegate/dlg_...", "head_sha": "...", "ahead_commits": 0, "dirty": false}
}requested_by, resumable, not_resumable_reason, scratch_path, and worktree are cancellation provenance for an externally stopped delegate (kata tpb0): who requested the stop, whether this exact delegate resource can still be resumed (the same classification job_status reports), and whatever partial scratch/worktree evidence its run loop had already gathered before being cancelled. The scratch directory at scratch_path stays after the delegate finishes; it is deleted when the root session is archived or deleted. All are omitted for a shell job_stop. requested_by is reported on every delegate stop, including one that has not completed yet; resumable, not_resumable_reason, scratch_path, and worktree are read from the settled delegate and are omitted until the stop completes (status/outcome still running/stop_requested). job_stop never infers or performs a retry/resume itself — the parent decides what to do with this evidence.
stateDiagram-v2
[*] --> StopRequested: job_stop
StopRequested --> AlreadyTerminal: job already terminal
StopRequested --> Signalled: signal/cancel delivered
Signalled --> Cancelled: runtime confirms stop
Signalled --> RunningPending: timeout, escalation may continue async
Signalled --> Stopped: live handle lost / unconfirmed
AlreadyTerminal --> [*]
Cancelled --> [*]
RunningPending --> [*]
Stopped --> [*]V1 tool availability
The v1 model-facing tool matrix is:
| Caller context | Available job tools | Notes |
|---|---|---|
| Root session | shell, delegate, job_watch, delegate_send, job_status, job_list, job_stop | Root may create delegates and watches, and message its direct delegate conversations by delegate_id. |
| Delegate/subagent session | shell, job_watch, delegate_send, job_status, job_list, job_stop | Delegates may start shell jobs and watch their own jobs at any allowance. delegate is allowance-gated; job_watch's cross-session sources authorize themselves, with the separate watch_parent:true grant exposing job_watch(source="parent") to observer leaves. Concrete delegate_id targets are scoped to the session's own direct delegates at every level — a coordinator may message its own worker delegate by delegate_id, but not an arbitrary descendant's delegate (which fails not_controllable). |
| Root session, interactive only | ask_user | Not a job-control tool, but the same root/delegate split governs it: never available to a non-interactive root (--non-interactive, one-shot evener <prompt>) or to any delegate/subagent — root-only, hard-enforced; grant_tools rejects an explicit attempt to grant it. A one-shot run with --ask-responder <command> offers it anyway, for that process only (the session stays non-interactive in what it saves, and sandbox escalation stays off): when its turn ends on pending questions, Evener runs the command with {"questions": [...]} as JSON on stdin and resumes with its stdout as the reply, for up to a fixed number of rounds. A responder failure ends the run with the question unanswered. --ask-responder refuses --resume, --resume-with and --resume-last. |
Shell job output is not in this matrix because it is not a job tool: read_transcript is available to every session, including a leaf delegate, and an exact job:<job_id> ref reads any uniquely resolved shell job persisted under the local Evener state home. Delegate conversation history uses the delegate session's transcript_ref. This does not widen job_list, job_status, job_stop, job_watch, or delegate_send scope.
While a session is awaiting an ask_user reply, its autonomous job notifications are held rather than delivered, and drain at the turn boundary that follows the user's reply.
Tool availability is part of the model-facing contract. If an implementation narrows these permissions for policy reasons, it must make that visible in tool availability or tool descriptions rather than failing late with surprising generic errors.
This matrix is the job-tool slice of the broader effective-capability policy (how a child's whole tool set is narrowed, visibility vs execution, grant_tools). For that and the other cross-cutting subagent runtime contracts (lifecycle hooks, helper isolation, lineage), see subagent-runtime-contracts.md.
Removed / intentionally absent tools
No wait_job
There is no direct replacement for wait_job, and no read waits. Normal completion discovery comes from automatic terminal notifications. If a caller needs to be told about a condition rather than a completion, job_watch delivers it; nothing else blocks.
No job_ack
There is no model-facing acknowledgement tool. It is unclear when the model should call it, and it creates unnecessary cognitive load.
Retention is policy-based:
- durable job metadata is retained at least as long as the owning session transcript;
- output bytes are capped per job;
- old output may be pruned under session/state retention policies;
- the durable job record outlives its output bytes:
job_statusandjob_liststill answer for a job whose output was pruned; - never require model acknowledgement to make progress.
Notification delivery may have internal delivered/undelivered bookkeeping, but it is not a model tool.
No job_read_output
There is no dedicated job-output tool. A job's output is evidence, and it is read with the same reader as every other piece of evidence: read_transcript(transcript_ref="job:<job_id>") (see "Reading job output"). One reader means one resolution chain, so a job that can be listed and a job that can be read cannot drift apart.
What went with the dedicated tool, deliberately:
- the blocking read. There was a
max_wait_msthat waited for new output, terminal state, or agrepmatch. No read blocks now. Completion arrives as a notification; a condition on watched output isjob_watch(output_match=...). - a second search surface. Retained-output RE2 search lives on
read_transcript(output_match=...);job_watch(output_match=...)remains the trigger-side answer for future bytes. - line-index paging.
head_lines/tail_lines/from_line+line_countare gone. Exact continuation usesoffset_bytesin lifetime byte coordinates. - an opaque
output_statusenum. Markdown reports total/dropped bytes; page/search envelopes report total and retained-start offsets plus whether the shell is running or terminal. The shell tool's inline result retains its own output-status signal.
No job_kill
job_stop is the single model-facing stop primitive. If an implementation must escalate from graceful stop to forceful process cleanup, it does so inside job_stop according to documented timeout/escalation policy.
No close_agent or agent_id
Delegates are controlled by their stable dlg_... ID. Their conversations are read through transcript_ref. There is no activation-job handle, separate agent_id namespace, or model-facing close operation for child sessions.
Legacy Evener surface mapping
The stable resource model removes the former activation-specific control plane. Replacement mapping:
| Legacy concept | Stable replacement | Semantic difference |
|---|---|---|
spawn_agent(blocking=false) | delegate | starts a new delegate conversation and returns its stable delegate_id |
spawn_agent(blocking=true) | delegate, then await its automatic completion notification | creation has no inline-wait option |
resume_agent / send_input | delegate_send(to=<delegate_id>, message=...) | steers if running; starts or resumes the delegate's next job automatically when idle |
wait / wait_job | no direct replacement; wait for the automatic terminal notification | no read blocks; job_watch is the only way to be told about a condition |
cancel_agent / job_kill | job_stop(target=<delegate_id>) | recursively stops current work while retaining the delegate resource |
close_agent | none | retention automatic; transcript remains accessible |
subagent_output | read_transcript(transcript_ref=<delegate transcript_ref>) | the conversation is the durable evidence |
list_agents | job_list(type=["delegate"]) | one row per stable delegate, never per generation |
Durable job records
A durable job record exists for promoted shell jobs, including within-bound completions whose output cannot ride inline per the complete-or-handle invariant. Foreground shell commands that complete inline with output that fits in the tool result are ephemeral and need not create durable job records. Delegate generations never create job records. A shell job record contains:
{
"job_id": "job_...",
"type": "shell",
"status": "running|completed|command_exited_nonzero|command_killed|failed|cancelled|stopped",
"reason": null,
"description": "...",
"command": "...",
"parent_session_id": "01J...",
"owner_session_id": "01J...",
"visible_to_session_id": "01J...",
"parent_job_id": "job_...",
"parent_delegate_id": "dlg_...",
"origin_turn_id": "...",
"origin_tool_call_id": "...",
"started_at": "...",
"ended_at": "...",
"exit_code": 0,
"output_path": "...",
"output_bytes": 0,
"terminal_generation": null,
"terminal_notification_state": "not_armed|pending|delivered|consumed"
}A kept within-bound completion arms no completion notification: its tool reply already told the caller the job ended. Once that reply is durable in the transcript the job's notification is recorded consumed, the same settlement a terminal job_status read gets, so a restore never re-announces it. If the process stops before the reply is durable, the record is still not_armed and the restore arms its notification.
parent_job_id links shell-to-shell work. parent_delegate_id names the stable delegate that caused a shell job; a shell never points at a private delegate generation. Fields that do not apply are omitted.
Retained output start offsets and detailed output availability may be stored with the output store rather than duplicated into the model-facing job record. The invariant is durable reconstruction, not that every storage-level field appears in job_list.
Durable reconstruction invariants
Evener persists shell-job history as append-only session events. The contract is that Evener can reconstruct:
- shell-job identity and type;
- parent/owner/visible session identity;
- typed parent shell-job or stable-delegate linkage;
- lifecycle status/reason;
- start/end times;
- shell exit code when meaningful;
- output byte count, internal/UI retained start offset, and output availability;
- terminal-notification dedupe and delivery state.
Non-normative event names that can satisfy this contract:
job_started
job_output_delta optional if output is independently stored
job_finished canonical terminal event, including stopped/runtime_lost
job_notification_pending
job_notification_delivered
job_notification_consumedThe first canonical terminal durable record/event for a job defines terminal_generation. A duplicate reconstructed terminal write for the same job must not create a new terminal_generation. Implementations may use a durable event ID, monotonic sequence, or equivalent stable identity, but the identity must be stable across restart and visible-session forwarding.
Stable delegate lifecycle, descriptor, lineage, resumability, transcript reference, last outcome, and ordered owner delivery live in the root delegates.jsonl journal. The stable controller folds that journal directly; it never reconstructs a delegate through a job record. Receiver transcripts are the sole durable authority for pending delegate/watch/shell attention. Startup fails closed with legacy_delegate_state or legacy_delegate_watch_state rather than translating legacy activation rows.
job_list combines the stable delegate fold with durable shell-job state and overlays in-memory state for currently running work.
Output storage
Each job has a bounded output file under the owning session's state directory. The exact path is implementation-specific, but conceptually:
.evener/sessions/<session_id>/jobs/<job_id>.logRules:
- Output is capped per job.
- Output appends are serialized enough to avoid corruption.
- The storage layer supports bounded offset/tail/search reads and reports truncation and byte offsets. The model-facing reader exposes a bounded tail by default, exact 16 KiB pages by
offset_bytes, and bounded retained-line search byoutput_match(see "Reading job output"). - Output files are retained according to session/job retention policy.
- A delegate's conversation is read through its own
transcript_ref; it has no job-output file. - Parent-visible nested job output must be readable through the parent's own
job:<job_id>read, either by mirroring output into the parent job store or by durable routing metadata. This routing is not model-visible.
Notifications
Shell terminal notifications and direct-delegate terminal packets are automatic. A model should not subscribe merely to learn that owned work finished. Shell notifications are armed when a shell call returns before terminal state. Delegate creation always returns after durable admission, so its generation's terminal packet is delivered later unless an idle-starting delegate_send waiter receives it inline. Steering an already-running delegate does not arm a second packet. An observer's communicate(end_turn=true) result arrives in its ordinary owner report packet; there is no separate observer callback and no suppression of that packet. See observer guidance.
Shell notification example:
<job-notification job_id="job_..." event="completed" job_type="shell" status="completed" reason="exit_zero" output_bytes="12345">
Job job_... completed. Output is available through read_transcript(transcript_ref="job:job_...") if needed.
excerpt:
<bounded 8000-character shell-output tail>
[excerpt truncated]
</job-notification>Direct-delegate completion uses the stable identity and canonical bounded terminal packet:
<delegate-notification delegate_id="dlg_..." name="..." kind="report">{packet JSON}</delegate-notification>kind="report" marks the generation's terminal packet, whatever its outcome. A mid-work update (communicate(end_turn=false)) is <delegate-notification delegate_id="dlg_..." name="..." kind="update"> with the delegate's message as the body, < escaped so it can neither close the frame nor open another. Clients decode the body's entities once, so a literal entity the delegate types, such as &, reads as the character it names; job output excerpts share this remainder. The name attribute appears only for a named delegate. The quiet watchdog's frame carries neither name nor kind. A delegate frame never carries job_id or job_type="delegate". The full conversation stays available through the delegate's transcript_ref. A delegate_send reply that carries an update ahead of its result lists it in earlier_results with action: "update" and the message as output.
A running delegate's communicate(end_turn=false) is an update for its owner. Evener records it in the delegate's journal and delivers it through the same ordered queue and receiver-transcript attention as the terminal packet.
- An idle owner is woken, and its notification turn takes everything pending at once. A running owner sees the update at its next model boundary; an owner waiting on that delegate in
delegate_send(max_wait_ms)gets it in the reply'searlier_results, ahead of the report. An owner awaiting anask_userreply gets it after the reply. - One delegate's updates arrive in the order sent, before that generation's report, and survive restart like any pending attention.
- The
communicateresult carriesdelivery:delivered(in the owner's transcript),queued(durable and waiting behind the owner's turn or an earlier delivery of the same delegate), ornot_deliveredwith areason(the update could not be recorded, the delegate is stopping or its report is being settled, its run is stale, or the session has no route to a parent agent). A parent that is gone usually showsqueued, since the update is durable in the delegate's journal. A delegate parked in its finalization drain after its terminalcommunicatecan still send updates; they reach the owner ahead of its report. Delivery never fails the call: the message is already in the delegate's transcript. - Updates never feed Activity's
reportPreview. - A delegate parked in its finalization drain can run further turns: an update from a delegate it owns wakes it. A turn that ends with a terminal
communicategives a newer report, and the newer report is the one the parent receives and Activity previews; the earlier report stays in the delegate's transcript. A turn that ends without a terminalcommunicate, including one whose model returns bare text, leaves the earlier report standing as the delegate's completed report. - The same holds for a one-shot
evenerrun: while it drains background work, its answer is the root's latest terminalcommunicate. A drain turn driven by a completion that reports again replaces the answer. A drain turn that ends in bare text or an empty response leaves the earlier answer standing and the run succeeds. Background-job announcement turns keep their own rule: a report made on one does not replace the answer unless a completion arrives during that turn. - A root's
communicate(end_turn=false)is unchanged: a checkpoint for its human partner.
When a shell excerpt contains the complete output, the body says Complete output below. instead of nudging a redundant read. Otherwise it points to job:<job_id>.
Rules:
- Shell notifications carry concrete
job_id,job_type="shell", lifecycle event/status/reason, output bytes, and exit code when known. Their bounded excerpt is the retained output tail. Watch notifications continue to useevent="watch"andjob_type="watch", with typed source identity. - Direct-delegate notifications carry
delegate_idand the bounded canonical terminal packet folded in the stable aggregate.job_status(dlg_...)remains metadata-only and never consumes that packet. - Attention renders only in its owner's turns. A parent drives an idle child so that child processes its own shell/watch attention; the parent renders only its own shells and direct delegates. There is no forwarded delegate JobRecord.
- A delegate whose turn has ended while its generation waits on its own delegates is idle, and an update from one of them wakes it: the wait runs a turn that presents the update and consumes it, so no further generation starts for it. A report from one of them gets no turn of its own: it waits for the generation's end, when the next attention generation takes it with everything else pending, unless an update's turn presents it first.
- Receiver transcripts are the sole durable attention authority. Attention is appended and fsynced by exact private ID before a process-local wake. A successful notification turn appends and fsyncs the matching consumed marker; failure leaves it pending for retry. Restart folds and re-arms those IDs provider-free, and job-tree drain includes them until consumed or discarded.
- If an owner is permanently unreachable, unresolved attention transfers idempotently to the nearest reachable ancestor only after the ancestor append fsyncs. A stopping subtree durably discards its own unresolved attention.
- If the owner is mid-turn or awaiting an
ask_userreply, attention waits for the next safe boundary. Wakes coalesce, but committed entries do not vanish. - Attention a turn already showed the model is consumed with that turn. When a settled model request of a root turn, or of a delegate turn running in its generation, carried an attention entry, the turn's successful end appends the consumed marker, so the entry starts no further turn or attention generation. This holds whether the attention was armed before the turn began or during it. Attention appended after the turn's last request was built keeps its own wake.
- Duplicate terminal attention is suppressed by its durable source identity.
- Watch wake-ups and observer frames are opt-in through
job_watch. - Evener supervises each running stable delegate with a built-in quiet watchdog. Ten minutes without parent-observable activity emits one owner attention keyed by
delegate_id, and it repeats once per further ten-minute window while the delegate stays silent and running. Fresh activity resets the baseline, so a progressing delegate never fires. The watchdog only reports—it never steers, resumes, or stops the delegate. - Notification delivery state is internal; there is no
job_ack. - The
end_turn=truewarning naming still-running jobs depends on whether the session outlives the turn. Where it does, the warning keeps promising each job reports separately on completion. Where the process exits with the turn (a one-shotevenerrun, and any session it delegates to), it says instead that a job which keeps running is killed at exit rather than reported on later, because promising a later report there is simply false.
Shell terminal delivery keeps its existing durable pending/delivered/consumed state and dedupe key. Stable delegate packet delivery is ordered in the delegate aggregate, while the exact receiver-transcript ID prevents duplicate model input if a crash lands between receiver commit and source acknowledgement.
stateDiagram-v2
[*] --> Armed: asynchronous shell return or stable delegate generation
[*] --> NotArmed: synchronous terminal return
Armed --> Pending: terminal event observed + durable pending
Pending --> Delivered: inject notification + durably mark delivered
Delivered --> [*]
NotArmed --> [*]
Pending --> Pending: restart before delivery (replay pending)
Delivered --> Delivered: duplicate suppressedRestart behavior
Shell jobs and delegate generations do not auto-resume after an Evener process restart. Reconciliation is provider-free:
- Fold the root stable-delegate journal and each shell store without creating a Session or model runtime.
- Finalize shell records still marked running as
stopped/runtime_lostonce, preserving their established terminal notification contract. - Finalize a stable delegate's lost running generation as
failed/runtime_lost, leaving the resource idle and resumable when its durable descriptor and transcript still permit restore. - Rebuild ordered delegate delivery and pending receiver-transcript attention by exact durable ID; acknowledge already-committed receiver entries and re-arm unresolved work.
- Restore a child runtime lazily only when
delegate_sendor a selected pending attention drive requires it.
Notification example:
<job-notification job_id="job_..." event="stopped" job_type="shell" status="stopped" reason="runtime_lost">
Job job_... stopped. Output is available through read_transcript(transcript_ref="job:job_...") if needed.
</job-notification>This is not command failure. It is supervision loss.
Restart loss is supervision loss, not shell command failure. A stable delegate remains addressable by dlg_...; missing or pruned retained state makes it non-resumable instead of triggering best-effort replay. Legacy delegate job rows or job-addressed watch rows fail startup with legacy_delegate_state or legacy_delegate_watch_state before new work is admitted.
stateDiagram-v2
[*] --> Fold: restore root state without provider
Fold --> ShellLost: running shell record
Fold --> DelegateLost: running stable generation
Fold --> IdleDelegate: resumable stable resource
ShellLost --> ShellStopped: stopped/runtime_lost
DelegateLost --> DelegateIdle: failed/runtime_lost
ShellStopped --> AttentionPending
DelegateIdle --> AttentionPending
AttentionPending --> AttentionConsumed: owner turn commits exact marker
IdleDelegate --> [*]
AttentionConsumed --> [*]Graceful shutdown is the deliberate counterpart. Root close fences the stable delegate tree, signals live runtimes and owned shells, waits for exact process and durable-terminal receipts, closes children post-order, and only then closes the delegate store. Work that cannot reach its durable terminal boundary is left for the provider-free restart reconciliation above; teardown never treats a released goroutine as proof of terminal persistence.
Nested jobs
Delegates may start shell jobs. They may also create stable child delegates while their delegation_allowance is non-zero, which is the default one level below the creator; a creator passes delegation_allowance: 0 to make a leaf, such as an observer sidecar that must not delegate. Stable delegate lineage stays in the delegate controller. Shell lineage is typed: parent_job_id links shell-to-shell work and parent_delegate_id names the stable delegate that launched a shell.
Rules:
- Every shell job has an owner session. Every delegate has one stable parent edge in the delegate tree.
- A nested shell records the typed shell job or stable delegate that caused it.
- Parent-visible job lists may include nested jobs when
include_nested=true. This is the one-hop view: the parent's own store, which holds its owned jobs plus the records forwarded up one level from its direct children. job_list(include_descendants=true)walks the live descendant tree at read time instead of one hop. It returns the caller's own jobs plus every live descendant's jobs, reading each descendant's job store independently under its own lock (no lock is held across the recursion). Each row carriesowner_session_idand adepthannotation:depthis the live-walk distance to the store the row was surfaced from —0for the caller's own store,1for a direct child, and so on. The dedupe rule below applies across the whole walk: a forwarded copy of ajob_idwhose owner is reached live during the walk is suppressed in favor of that owner's record (so each job appears once, at its real owner's depth). The walk is live-only: it recurses only into live child sessions. A dead or terminated descendant contributes just the terminal forwarded copy that survives in an ancestor store (at that ancestor's depth); the walk does not reopen the gone session's store to dig deeper — resume the descendant to inspect its subtree. Defaultjob_listandinclude_nestedsemantics are unchanged;include_descendantsis additive.- Exact
job:<job_id>reads use durable local-state lookup, including sibling project buckets where supported; the owner need not be live. See job-output reads. This is broader thanjob_listenumeration and does not grant control over the job. - For any parent-visible nested shell, the parent-visible
job_idis its only job-control handle. A delegate is controlled only by its stabledlg_...ID. - Job IDs must be globally unique enough that parent job tools do not need string namespacing or a separate owner/visible ID choice.
- Shell notifications,
job_list,job_status,job_watch,job_stop, andjob:<job_id>use the same shell ID. Stable delegate status, watch, stop, anddelegate_sendusedelegate_id; its conversation usestranscript_ref. - Shell jobs created by subagents are visible to the parent through forwarded durable job events.
- Dedupe rule: the shell owner's durable record is authoritative over a forwarded copy. Stable delegates have no forwarded job copy; the controller fold is their sole lifecycle authority.
- Terminal attention is owner-scoped. A delegate renders its own shell and direct-child-delegate attention; an ancestor is not interrupted about a descendant's children. The ancestor retains on-demand descendant visibility.
job_watchremains for extra output/event/progress frames and observers. - Parent
job_stopon a nested job routes to the owning session/runtime if live. - If routing is unavailable after restart, Evener reports terminal
stopped/runtime_lostaccording to restart reconciliation. - If routing fails while the owner runtime is believed live, an active control attempt may fail or finalize according to the status matrix by failing synchronously with
not_controllable. - For a shell job,
job_stopis not recursive by default;job_stop(shell_job_id, include_children=true)recursively stops visible active nested jobs. job_stop(target=dlg_...)always cascades into the stable delegate subtree;include_childrenhas no effect. The controller fences that subtree before signalling runtimes, owned shells, watch/delivery work, and exact receipts. Delegates remain retained and resumable after the stop unless a separate durable disposal condition closed resumability.job_stopon a non-direct descendant — a grandchild-or-deeper job the caller neither owns nor reaches through a direct child it owns the job through — fails synchronously withnot_controllable. The error names the owning descendant session and the caller's direct delegate that controls that subtree, guiding the caller to stop that direct delegate (which cascade-stops the subtree) rather than silently routing a control attempt the caller is not entitled to make.
Shipped nested shell support:
- subagents can start shell jobs;
- those jobs record
parent_delegate_id, orparent_job_idwhen another shell caused them; - the parent can list/output/watch/stop them through the parent-visible
job_id; delegate conversations are messaged separately throughdelegate_id; - nested delegates are stable resources in the same controller tree, allowance-gated by the delegation-allowance amendment.
Example flow:
1. Parent creates stable delegate dlg_A.
2. Delegate dlg_A starts shell job job_B.
3. Parent sees job_B in job_list(include_nested=true) with parent_delegate_id="dlg_A".
4. Parent reads shell output with read_transcript(transcript_ref="job:job_B").
5. Parent stops just job_B with job_stop(target="job_B"), or stops the stable
subtree with job_stop(target="dlg_A").
6. Stopping dlg_A fences the delegate and recursively stops job_B and all live
descendant work before the subtree-stop operation completes.flowchart TD
StableDelegate -->|starts shell with ParentDelegateID| ChildShellJob
ChildShellJob -->|shell event forwarded| ParentVisible
ParentVisible -->|read_transcript job:job_id| ParentCanRead
ParentVisible -->|job_watch stable dlg or shell job| ParentCanWatch
StableDelegate -->|job_stop target=dlg recursively fences| ParentCanStopObserver and sidecar composition
Observer sidecars are a v1 Evener composition pattern. Claude Monitor covers only the basic stream-notification profile; Evener also supports sidecars that receive bounded event/output frames and report back through the normal result surface. Evener does not need a separate observer-comment command or a Sprout-style raw handle model. The shipped composition uses existing job primitives:
- Start a sidecar with
delegate(watch_parent:true, ...); this creates a stable delegate with a durable, non-transitive parent-watch grant. - Inside that child, configure
job_watch(operation="create", source="parent", ...). - Evener delivers matching bounded event/output frames to the child that created the watch.
- The sidecar responds with
communicate(end_turn=true, ...)when it has useful commentary or advice. - The caller receives that generation's
<delegate-notification>frame carrying the observer's result packet, is woken by it, and continues from it. Follow-upjob_list,job_statusorjob:transcript reads are audit/diagnostic evidence after the callback, not the callback mechanism itself.
This makes observer behavior a composition of two primitives:
delegate create observer with watch_parent
job_watch source-owned condition -> bounded frame to watcher
communicate observer callback -> parent resumes from the resultSafety and behavior rules:
- Watch frames are bounded and filtered. Redaction may be applied for cross-session or observer delivery, but frames are not guaranteed secret-free.
- Observer/sidecar telemetry should be excluded from frames by default to avoid feedback loops.
- Observer advice is runtime-originated commentary, not user instruction.
- Observer failures should not fail the watched session; they produce diagnostics or warnings. A failed observer-frame delivery must surface as a caller-visible diagnostic notification rather than silently dropping the matched condition.
- Watch frames to busy sidecars are retried from durable latest-frame-wins pending state. Hard/non-resumable delivery failure drops the pending frame only after emitting a caller-visible diagnostic.
- Access control is source-resolution based:
parentrequiresdelegate(watch_parent:true), concrete jobs must be owned by this session or a live descendant, and unrelated sessions are rejected. - Broad unbounded transcript watches are not part of v1; watch a concrete
job_id,self, or a grantedparentsession source. - Same-watch feedback is causal, and it is classified rather than suppressed: watch-originated events carry provenance, so an event whose provenance already holds this watch's
(watch_id, generation)is recognised as the watch's own echo. It still fires and still delivers — marked self-influenced, with the gradient notice on the frame — and the runaway fuse is what stops a runaway. The classification is scoped to the current generation, so a re-arm reads as fresh, and a later top-level user input starts with fresh provenance. - An observer that needs the output of a job a frame named uses
read_transcript(transcript_ref="job:<job_id>"), the call in the frame's trailingread with:line. Exact local job refs are resolved independently of observer metadata; the frame still carries the event payload (status, reason, exit code, output bytes), and readability does not widen transcript discovery, listing, or control access.
Relationship to transcript tools
Transcript tools remain separate:
find_session_transcripts
read_transcriptUse job tools for lifecycle and control. Use transcript tools to read evidence: a stable delegate's transcript_ref names its conversation, while job:<job_id> names retained shell output only. job_list and direct-delegate notifications expose the delegate transcript reference when known.
Decision table:
| Need | Tool |
|---|---|
| Did the job finish? | Wait for automatic notification, or check job_list once when recovering state |
| Shell stdout/stderr | read_transcript(transcript_ref="job:<job_id>") |
| Delegate report, conversation, and tool history | read_transcript(transcript_ref=<delegate transcript_ref>) |
| Trigger observer/sidecar review | Parent: delegate(watch_parent:true); observer: job_watch(operation="create", source="parent", ...); callback: communicate(end_turn=true) |
| Start a fresh delegate conversation | delegate(...) |
| Follow up on an existing delegate conversation | delegate_send(to=<delegate_id>, message=...) |
| Start an idle delegate's next turn | delegate_send(to=<delegate_id>, message=...) |
Canonical model-facing delegate conversation field is transcript_ref. child_session_id is descriptive metadata, never a control identity, and there is no delegate job ID to reconcile with either field.
Anti-patterns
Tool descriptions and prompts should warn against:
- polling
job_listfor completion; - using
job_watchas a terminal completion subscription; - re-reading a job's output in a loop to discover that it finished;
- expecting shell processes or delegate generations to auto-resume after process restart;
- using job output as a replacement for delegate transcripts;
- passing
transcript_refto job-control tools or usingdelegateto resume an existing conversation instead ofdelegate_send(to=dlg_...); - stopping a job when the intent is only to inspect output;
- stopping a job as cleanup or acknowledgement;
- assuming a nested job is hidden from the parent;
- expecting
job_stopto delete durable history.
V1 non-goals
V1 does not define multi-job barriers, any-of/all-of watches, or named job groups. Agents coordinate multiple background jobs through individual terminal notifications and job_list recovery. Fan-in/barrier coordination is the likely first future coordination extension if heavy parallel workflows need less manual state tracking, but it remains out of v1 until that surface is deliberately designed.
Nested delegation is allowance-gated: a delegate may create a child only while its delegation_allowance is non-zero. The default grant is one level below the creator, and MaxSubagentDepth bounds the whole tree. Shell jobs are not messageable; long-running REPL stdin is outside this contract.
Capacity and discovery requirements
Evener bounds concurrent delegate generations and observer/attention work across the stable tree. Shell-process concurrency remains a separate standing gap; it is not silently counted as delegate capacity.
Tree-wide running-delegate cap. Concurrent delegate generations share one tree-wide counter. The root configures it with --max-concurrent-delegates (max_concurrent_delegate_turns, wire maxConcurrentDelegateTurns), default 50. Starting reservation holds a slot before external construction, a successful start transfers it to the running generation, and abort or terminal finish releases it. An idle stable delegate holds no slot. Attention drives use the separate drive budget below. At capacity:
delegateor an idle-startingdelegate_sendfails synchronously with the establishedtree_at_capacitydiagnostic; and- an attention drive does not launch that pass, leaving its transcript IDs pending for a later boundary.
Attention-drive budget. A second tree-wide counter, cap 8, bounds concurrent attention generations so notification maintenance cannot starve user fan-out. A drive times out after 5 minutes and a still-pending child is re-driven at the established paced boundary. job_list and status report spawn and drive occupancy; the legacy word jobs in that diagnostic counts delegate-generation holders, not public delegate JobRecords.
Restart rebuilds both counters from provider-free reconciliation; cold delegates hold no running slot.
Retained terminal runtimes. --max-retained-terminal (config max_retained_terminal) retains its public spelling, default 2048, and fail-loud behavior. It bounds resident quiescent terminal child-runtime subtrees, not stable history. On delegate admission or cold restore, the controller claims enough exact reclaimable idle subtrees, closes them post-order, and clears only matching runtime pointers. Reclamation never deletes or rewrites the stable aggregate, descriptor, transcript, outcome, lineage, watches, delivery state, or resumability.
An idle-release timer can also release a quiescent delegate's resident subtree before the capacity backstop is needed. Release checks generation and lifecycle safety gates; a refused attempt can leave the runtime resident and retry later. The timer is not a deadline guarantee. Releasing runtime memory preserves the durable delegate and its resumability.
job_list windowing. job_list paginates the newest-first listing with limit (default 50, max 100) and offset (default 0); a truncated or offset listing reports its window and the full filtered total (showing 51-100 of 257 jobs.) so a page size can never be mistaken for a system cap.
delegate.agent_type values must be discoverable by the model. This can be done through the tool description/system prompt, a future discovery tool, or a session context section. If no discovery tool exists, the delegate tool description must enumerate the valid agent types available in the current session.
job_watch.events event-kind values must be discoverable by the same mechanisms. If no discovery tool exists, the job_watch tool description or session context must enumerate the event kinds available in the current session, or document that agents should use events:["*"] plus filtering when targeted event names are unavailable.
Shipped recursion and owner attention
The tree counter, include_descendants, owner-scoped attention, and recursive stable stop are shipped. Recursion beyond direct delegates is on by default within MaxSubagentDepth: each delegate is granted one level below its creator unless the creator passes a smaller delegation_allowance or 0.
A session renders only attention for work it owns. Parent-driven child turns preserve that rule while retaining ancestor inspection through job_list(include_descendants=true). Direct delegate completion is the parent's own stable-resource attention; descendant shells and delegates remain their direct owner's attention.
Why drive-down, not a flat session scheduler
Drive-down uses existing parent-owned lifecycle machinery: serve.go wakes the root, and each parent starts one child's EntryNotification turn at a safe loop boundary. The receiver transcript, not a flat scheduler or queue file, remains the durable authority. This preserves post-order close and exact drain joins; a cross-process child requirement or demonstrated parent-cadence correctness failure would require a separate scheduler design.
Implementation notes that are not part of the contract
The implementation may choose specific package names, event encodings, output paths, batching windows, and retention limits. Those details belong in implementation specs unless they affect model-facing behavior.
The contract-level requirements are:
- stable
dlg_...resources for delegate lifecycle and opaquejob_...handles for shell work; - foreground shell default with promotion to a durable background job on timeout; delegate creation returns after durable admission and has no
max_wait_ms; - typed automatic shell and direct-delegate terminal attention with durable no-loss/dedupe semantics;
job_watchfor source-owned condition-triggered notifications and v1 observer sidecar event/frame delivery;- bounded shell-output inspection through the
job:<job_id>transcript read and delegate conversation reads throughtranscript_ref; - durable listing/history;
- clear stopped/runtime-lost restart behavior;
- observer/sidecar composition through
delegate(watch_parent:true)+job_watch(source:"parent")+communicate(end_turn:true); - nested shell jobs from subagents;
- no model-facing
wait,ack,kill,close_agent, oragent_id.