Documentation
¶
Overview ¶
Package supervise is the supervision core shared by Relayer's front ends.
It holds what decides whether a byte reaches a supervised agent: the prompts a run is waiting on, the policy's automatic decisions and their serialisation per session, human decisions and free-text lines, the fail-closed audit journal, and the display-safe form in which all of it is shown. A front end brings the transport — Wails events on the desktop, a websocket on the gateway — and nothing that bears on delivery.
The desktop kept this state machine in its Wails bridge, and the gateway a second, diverging copy of it. The code here is the desktop's, moved without a change in behaviour, so the desktop's tests pinned it before either front end was changed to rely on it. The rules it has kept since differ from the desktop's of v0.8.7 in these ways:
- A session takes one write at a time, and the claim a decision takes on it is released only when that decision's write returns, even when the agent withdraws the prompt meanwhile: the prompt is shown answered at once, and the next automatic prompt is considered once the write is over.
- An agent's status follows its prompts: a Start or a Restart that kept a prompt the new process raised shows the agent waiting, not running, and a prompt raised while a Stop or a Restart holds the agent leaves it stopping.
- The exit of a process a replacement already superseded is journaled with its real outcome, failed when it failed, and the reason process_exit_stale (audit.ReasonProcessExitStale), which the telemetry does not read as the end of the running session.
- A human answer the adapter cannot encode never reached the agent, since the runtime encodes an answer before it writes any of it. Its delivery is journaled fallback_unsupported, the prompt goes back to the operator pending, no longer automatic and without that answer among those it offers, the caller gets ErrUnsupportedDecision, and nothing is frozen.
- A notice's Details is the prompt's display-safe summary, the one its View shows, never the adapter's own: a notification leaves the machine.
- The core decides from the policy's evaluation as the engine returned it, never from the bounded and redacted form a View shows, and journals the policy's decision entries under the rule that made them. It asks the policy again just before it journals a decision: a prompt the policy no longer answers automatically, or would now answer another way, such as one that reached a limit while it waited behind other answers, goes to the operator, and a second policy_evaluated entry, ask, gives the new reason.
- The policy never answers a repeat. A prompt it would answer automatically whose Signature is that of a prompt of its session whose answer, the policy's or a human's, is being written or was written less than Options.RepeatWindow ago (DefaultRepeatWindow, two seconds, unless set) is asked instead: journaled and shown with the reason repeat_after_delivery, the policy's proposal kept, and notified. The guard applies before the prompt's evaluation is journaled and again just before the policy's decision. It is a backstop for an adapter that reads an answered question again under a new ID; the cost is that a genuine question asked twice within the window goes to the operator.
- What the core shows reaches the sink in the order of the state changes it reports, one call at a time. Each change queues what it shows under the core's lock, and the queue is shown in order, outside that lock, by one goroutine at a time, before the operation that queued it returns. Each goroutine used to show its own change once it had released the lock, and two goroutines reached the sink in either order: a prompt could be shown delivering after it was shown delivered, and an agent running after it was shown exited.
- A decision or a line says who asked for it, an Actor: the person, their role and their connection. The journal names them on that person's decision entry, on its delivery entry however it ends (Operator, and the operator, role and conn_id metadata) and on their line's entries, whose closed shape takes only the Operator. The policy's entries, and those the core writes on its own, never name anybody. The desktop passes the zero Actor and its journal is what it always was. An Actor whose role may only watch, RoleViewer or any role but RoleOperator, is refused with ErrReadOnlyActor before anything else: nothing is claimed, written, journaled or shown. A typed answer is always sent as text and journaled ask, an empty one is refused before any claim or entry, and a chosen one is only allow or deny, and only one the adapter offers, or it is refused with nothing changed.
- The core knows who holds a session's terminal, the hand (SetHolder), and the policy answers nothing on a session somebody holds. The holder types into the agent directly, and raw keystrokes never resolve the runtime's pending prompt: an automatic answer to a prompt the holder answered by hand would be a second answer. Taking the hand turns every prompt of the session the policy would answer, and whose answer is not already being written, into an ask for the reason operator_attached, journaled as a second policy_evaluated entry; a prompt raised while the hand is held is asked before its evaluation is journaled. Releasing the hand never makes a prompt automatic again. A line is refused while anybody holds the hand; a person's decision is not, since the hand governs the terminal and not supervision. The hand changes only when the front end says so, never with the process, and SetHolder never waits on the journal nor calls the sink on its caller's goroutine, so a front end may call it under a lock of its own. It replaces SetAttached, which only recorded that somebody held the terminal.
- A session takes one write at a time, raw keystrokes included. Admit admits the keystrokes a terminal's holder types, which reach the agent without the policy and are never journaled, only from the connection that holds the hand (ErrNotHolder otherwise, and from everybody while nobody does), never during a drain, after the journal failed, on a frozen session or a process stopped, stopping or starting, and never while a decision, a line or other keystrokes are being written (ErrDecisionInFlight). While they are admitted, a person's answer and a line on the session are refused, and the policy's answer waits for their release. AdmitRun is the run-wide admission a terminal resize takes, the desktop's Admit renamed. RecordAudit journals a front end's own entry, such as the gateway's attach_started, through the core's fail-closed path: an entry the journal refuses freezes the run. It takes only the attach, control and recording kinds (ErrUnsupportedEntry otherwise), and never calls the sink on its caller's goroutine, so a front end may journal under a lock its sink takes.
- A write whose outcome is uncertain freezes its session whatever became of the prompt it answered: a prompt the agent withdrew while its answer was being written left the session writable, and the next answer followed the uncertain one.
- A process that exits while something is written to its terminal leaves the session to that write until it returns, and a Start is refused (ErrDecisionInFlight) while an answer, a line or the holder's keystrokes are still being written; a Stop and a Restart are refused the same way while keystrokes are. The exit used to release an answer's claim at once, and the replacement's first answer could be written beside it.
- The policy answers nothing the hand may have touched. A hand taken and released again while a prompt was being taken in leaves the prompt asked, as if still held, since its holder may have typed the answer; and the policy's last check before its decision asks about the hand as the first did, so no automatic answer starts once SetHolder returned.
- The holder's keystrokes count as an answer for the repeat guard: once they are written, every prompt of the session pending or being taken in is taken as answered then, and a repeat of one within the window is asked. A repeat of a prompt the holder answered by typing was the policy's to answer once the hand was released.
- A chosen answer is taken only if the prompt still offers it, not only the adapter: an answer the adapter could not encode, which the core took off the prompt, was accepted again from a stale screen.
- A prompt the policy denies, on its own or but for one of its limits, that goes to the operator all the same, for the hand, a repeat, a limit or an answer the adapter could not encode, offers deny alone: offered every answer, it let any operator allow what a deny rule refuses. A typed answer is still sent as typed.
- A prompt the policy was to answer, handed back to the operator after it was detected, is notified like any prompt that waits on a person: a limit, a repeat or another answer found at the policy's last check, or an answer no adapter could encode. It waited in silence, and a limit, which exists to bring a person in, stalled the agent. A prompt the hand asks is not notified, since its holder is at the terminal, nor is a person's own answer handed back to them.
- A withdrawal that arrives while its prompt is still being taken in, by the event loop or a reconciliation, leaves a tombstone: the prompt is set aside instead of pending, and its withdrawal journaled after the entries that took it in. It was lost, and the prompt waited on the operator for a question the agent no longer asked. A pending prompt is marked as going before its withdrawal is journaled, and nobody acts on it from then on: the hand leaves it alone, a person's answer is refused as stale (ErrDecisionStale), and the policy does not claim it, so that nothing about it is journaled after the entry that says it is gone.
- A view carries the MCP tool call its prompt asks about, in the form it may be shown (View.ToolCall): none for a prompt whose text must not be shown, and otherwise its names and parameter values redacted, each value as the assignment it is, and bounded. The web gateway shows it to every client, viewers included, and showed the arguments as the agent printed them. It is not part of a view's JSON, which stays the desktop's.
- A front end is told when a session's current process ended, after the core shows the agent exited (Sink.Lifecycle, PhaseEnded): on its own, stopped, or with the tmux session Relayer lost. The web gateway announces the process's finished recording on it, which it did only for a lost tmux session. The exit of a process a replacement already superseded reports nothing.
The package deliberately depends only on the adapter, audit, policy, session and terminal vocabularies and the standard library (imports_test.go enforces it): no notifier, no telemetry, no network and no user interface toolkit.
Index ¶
- Constants
- Variables
- func IsTerminalReport(data []byte) bool
- func SafeDisplayError(err error) string
- type Actor
- type Agent
- type AgentSpec
- type Engine
- type EvaluationView
- type Notice
- type NoticeKind
- type NoticeSeverity
- type Options
- type Phase
- type SafeError
- type Sink
- type State
- type Status
- type Supervisor
- func (s *Supervisor) Admit(sessionID, connID string) (release func(), err error)
- func (s *Supervisor) AdmitReport(sessionID, connID string) (release func(), err error)
- func (s *Supervisor) AdmitRun() (release func(), admitted bool)
- func (s *Supervisor) Agent(sessionID string) (Agent, bool)
- func (s *Supervisor) BeginDrain()
- func (s *Supervisor) Draining() bool
- func (s *Supervisor) Handle(message session.Event)
- func (s *Supervisor) RecordAudit(entry audit.Entry) error
- func (s *Supervisor) RestartSession(runID, sessionID string) error
- func (s *Supervisor) SetHolder(sessionID, connID string)
- func (s *Supervisor) StartSession(runID, sessionID string) error
- func (s *Supervisor) State() State
- func (s *Supervisor) StopSession(runID, sessionID string) error
- func (s *Supervisor) SubmitAutomaticDecision(runID, sessionID, eventID, decision string, actor Actor) error
- func (s *Supervisor) SubmitDecision(runID, sessionID, eventID, manualInput string, actor Actor) error
- func (s *Supervisor) SubmitLine(runID, sessionID, line string, actor Actor) error
- func (s *Supervisor) Wait()
- type View
Constants ¶
const ( // RoleOperator may answer prompts and send lines. RoleOperator = "operator" // RoleViewer may only watch: every operation that acts on an agent // refuses it with ErrReadOnlyActor. RoleViewer = "viewer" )
The roles an Actor may have. They are the web gateway's own words, so that it passes a connection's role through unchanged.
const DefaultRepeatWindow = 2 * time.Second
DefaultRepeatWindow is the RepeatWindow of a run whose options set none.
const ReasonOperatorAttached = "operator_attached"
ReasonOperatorAttached is the reason of a prompt the policy would have answered automatically on a session whose terminal someone holds: it is asked, never answered, and it stays asked once the terminal is released.
The holder types into the agent directly, and raw keystrokes never reach the runtime's pending prompt: an answer typed by hand leaves the prompt pending under the same ID, and an automatic answer to it would type a second one. Only the core's own writes resolve a prompt, so a prompt the holder may have answered is never the policy's again.
const ReasonRepeatAfterDelivery = "repeat_after_delivery"
ReasonRepeatAfterDelivery is the reason of a prompt the policy would have answered automatically but that repeats a prompt of its session whose answer is being written, or was written less than RepeatWindow ago: it is asked, never answered. An adapter that reads an answered question again, from its echo or a repaint, raises such a repeat under a new ID, and answering it types a second answer into an agent that already consumed the first.
const ReasonTypedAtTerminal = "typed_at_terminal"
ReasonTypedAtTerminal is the reason of a prompt that was shown while its terminal's holder typed into it: it is the terminal's, answered neither by the policy nor by a person through the core, until the agent withdraws it or its process ends.
Raw keystrokes never resolve the runtime's prompt, and the core cannot tell whether they answered it. When they did, the prompt stayed pending under the same ID until the agent withdrew it, and an answer given to it meanwhile, by a click on its card, was typed into whatever the agent asked next, and journaled as the answer to the prompt the holder had already answered. Its holder answers such a prompt by typing, as they may have already.
const ReasonTypedOverPolicyDeny = "typed_over_policy_deny"
ReasonTypedOverPolicyDeny is the reason of a person's decision entry that answered, by typing, a prompt the policy would have denied, on an adapter that encodes no deny: the journal says the person answered what the policy refused, whatever they typed.
Variables ¶
var ( ErrDecisionStale = errors.New("request is no longer the awaited event") ErrDecisionInFlight = errors.New("a decision is already in progress for this agent") ErrEmptyDecision = errors.New("an empty answer is not a decision") ErrUnsupportedDecision = errors.New("answer cannot be encoded for this request") ErrDeliveryUncertain = errors.New("delivery state is indeterminate, stop the session before further input") ErrLineInFlight = errors.New("a line is already being delivered to this agent") ErrLinePromptPending = errors.New("a supervision request must be answered before free-text input") ErrLineInvalid = errors.New("invalid line") ErrLineUnsupported = errors.New("backend does not support free-text input") ErrRuntimeStopped = errors.New("the Relayer engine is stopped") ErrRunStale = errors.New("this Relayer run is no longer active") ErrAgentUnknown = errors.New("unknown agent for this run") ErrAgentStillRunning = errors.New("the agent process is still running") )
These are the refusals of the supervision core. Their text is the one the desktop has always returned across its bridge, which is why front ends that predate the core alias them rather than declare their own: a caller tests them with errors.Is, and the sentence an operator reads is produced where it is displayed.
var ErrAnswerInvalid = errors.New("a typed answer must be one line of text, with no control characters and no more than 4096 bytes")
ErrAnswerInvalid refuses a typed answer that is not one line of text: it holds a control character, CR, LF and escape included, is not valid UTF-8, or is longer than a line may be. It is checked before anything is claimed, journaled or shown, as an empty answer is.
var ErrDenyOnly = errors.New("the policy denies this request: only its deny is accepted")
ErrDenyOnly refuses a typed answer to a prompt the policy denies that went to a person all the same, because somebody holds the terminal, a limit was reached or it repeats an answer just written: such a prompt takes the adapter's deny alone. Typed text is whatever the adapter reads it as, its accept included. Where the adapter encodes no deny, typed text is the only answer there is, and it is taken, journaled as ReasonTypedOverPolicyDeny.
var ErrNotHolder = errors.New("this connection does not hold the session's terminal")
ErrNotHolder refuses a raw write to a session's terminal from a connection that does not hold its hand, including any connection while nobody does.
var ErrReadOnlyActor = errors.New("permission denied: this role is read-only")
ErrReadOnlyActor refuses a decision or a line asked for by an Actor whose role may only watch. It is checked before anything else, so the request changes nothing and journals nothing, whatever else is wrong with it.
var ErrTypedAtTerminal = errors.New("keys were typed at this request's terminal while it was shown: answer it at the terminal")
ErrTypedAtTerminal refuses an answer to a prompt that was shown while its terminal's holder typed into it (ReasonTypedAtTerminal): the keystrokes may have answered it, and an answer given now would reach whatever the agent asks next. It is answered at the terminal.
var ErrUnsupportedEntry = errors.New("this entry is not one a front end journals through the core")
ErrUnsupportedEntry refuses an entry a front end asked the core to journal (RecordAudit) whose kind is not one a front end writes: only who took or let go of a terminal, how its control changed hands, and the lifecycle of a recording. Every other kind is the core's own, or the runtime's.
Functions ¶
func IsTerminalReport ¶ added in v0.8.9
IsTerminalReport reports whether data is nothing but replies a terminal sends by itself, never keys a person pressed: focus reports (CSI I and CSI O, sent while the program asked for them with DECSET 1004), cursor position reports (CSI row ; column R, the answer to CSI 6 n), device status reports (CSI 0 n) and device attributes (CSI ? … c and CSI > … c, the answers to CSI c). None of them can answer a question: an agent reads them as the terminal's, not as the person's. Anything else, a key, a paste, a mouse report, is typing, and an empty write is not a report.
The gateway admits such a write with AdmitReport rather than Admit, so a browser terminal that reports its focus on its own does not make the prompts it shows the terminal's.
func SafeDisplayError ¶
SafeDisplayError bounds and redacts an error for display.
Types ¶
type Actor ¶ added in v0.8.9
Actor is who asked the core for a decision or a line: the person, the role their front end gave them, and the connection they acted from.
The desktop has one operator, at the machine, and passes the zero Actor: its journal names nobody, exactly as it always has. The web gateway serves several people at once, some of whom may only watch, and names each connection's signed-in identity, its role and its connection ID. The journal keeps them on the entries of a person's decision, of its delivery and of their lines; the connection is what ties an answer to the attach and control records around it, since one identity may be signed in from several tabs.
type Agent ¶
type Agent struct {
AgentSpec
Status string
Running bool
Attached bool
InputFrozen bool
ExitCode *int
}
Agent is what the core knows of one agent's process: the state that decides whether a prompt is taken in, a decision delivered or a line sent. Its output and the rest of what a front end displays are the front end's own. Attached reports that somebody holds the session's terminal (SetHolder).
type AgentSpec ¶
AgentSpec is the fixed identity of one supervised agent for a run: what the journal names it by. It carries no command line, environment or output.
type Engine ¶
type Engine interface {
Evaluate(adapters.Event) policy.Evaluation
SupportedDecisions(adapters.Event) []adapters.Decision
ApplyDecision(ctx context.Context, sessionID string, event adapters.Event, decision adapters.Decision, manualInput string) error
PendingEvent(ctx context.Context, sessionID string) (*adapters.Event, error)
SendLine(ctx context.Context, sessionID, line string) error
StopAgent(ctx context.Context, agentID string) error
StartAgent(ctx context.Context, agentID string) error
RestartAgent(ctx context.Context, agentID string) error
MarkProcessExited(agentID string) bool
// RecordAudit is synchronous and fail-closed: when it returns an error the
// core sends nothing more for the whole run.
RecordAudit(audit.Entry) error
}
Engine is what the core needs of a run's runtime: the policy, the adapters' encoders, the backends' writes, the agents' lifecycle and the journal. *app.DesktopRuntime satisfies it.
type EvaluationView ¶
type EvaluationView struct {
Action string `json:"action"`
ProposedAction string `json:"proposedAction"`
RuleName string `json:"ruleName,omitempty"`
Reason string `json:"reason"`
Automatic bool `json:"automatic"`
DryRun bool `json:"dryRun"`
}
EvaluationView is the display-safe form of a policy evaluation.
type Notice ¶
type Notice struct {
Kind NoticeKind
Severity NoticeSeverity
AgentName string
SessionID string
EventID string
Reason string
// Details is the prompt's display-safe summary, the one its View shows:
// bounded, redacted, and a fixed text for a prompt whose text must not be
// shown. A front end may send it anywhere a View may go.
Details string
}
Notice is a request to tell the operator about a prompt. The front end picks the transport and the wording of the title.
type NoticeKind ¶
type NoticeKind string
NoticeKind says why the operator is notified.
const ( // NoticePendingDecision: a prompt waits for a human. NoticePendingDecision NoticeKind = "pending_decision" // NoticeGuardrailBlocked: a guardrail decided a prompt, automatic or not. NoticeGuardrailBlocked NoticeKind = "guardrail_blocked" )
type NoticeSeverity ¶
type NoticeSeverity string
NoticeSeverity is how urgently the operator is notified.
const ( SeverityWarning NoticeSeverity = "warning" SeverityCritical NoticeSeverity = "critical" )
type Options ¶
type Options struct {
// RunID names the run in every view, status and error, and is the run an
// operation must address.
RunID string
// Agents are the run's agents, all running when the run starts.
Agents []AgentSpec
// Sink receives what the core shows; nil discards it.
Sink Sink
// RepeatWindow is how long after an answer is written a prompt of the
// same session with the same Signature is taken for its repeat, which
// the policy never answers. Zero or less means DefaultRepeatWindow: the
// guard cannot be turned off.
RepeatWindow time.Duration
// Now is the clock that times RepeatWindow; nil means time.Now. The core
// calls it from several goroutines at once, outside its own lock: it must
// be safe for concurrent use.
Now func() time.Time
}
Options configure the supervisor of one run.
type Phase ¶
type Phase string
Phase is a change of a session's process reported through Sink.Lifecycle.
const PhaseEnded Phase = "ended"
PhaseEnded: the session's current process ended, on its own or stopped, or Relayer lost the terminal it ran in (a legacy exit). What the front end kept of that process, such as its recording, is complete. It is reported after the core shows the agent exited. The exit of a process a replacement already superseded reports nothing: the session's process is the replacement, whose recording goes on.
const PhaseStarted Phase = "started"
PhaseStarted: a new process replaced the session's previous one. Its output belongs to the previous process and does not carry over. It is reported before the core shows the agent running, so a front end that drops the output on it never shows the new process with its predecessor's output.
type SafeError ¶
type SafeError struct {
RunID string `json:"runID"`
Code string `json:"code"`
Message string `json:"message"`
SessionID string `json:"sessionID,omitempty"`
Timestamp string `json:"timestamp"`
}
SafeError is a failure as it may be shown: a fixed code and message. It has the fields and the JSON of the desktop's SafeErrorEvent.
type Sink ¶
type Sink interface {
// Prompt shows a prompt, or a change of its delivery state.
Prompt(View)
// Status reports a session's status or, with Scope "audit", that the
// journal failed.
Status(Status)
// Error reports a failure by a fixed code and message, never by an
// error's own text.
Error(SafeError)
// Refresh asks the front end to read the session's bounded output again,
// before what the core shows next.
Refresh(sessionID string)
// Lifecycle reports a change of the session's process that the front
// end's own state has to follow.
Lifecycle(sessionID string, phase Phase)
// Notify asks the front end to tell the operator; how is its own choice.
Notify(Notice)
}
Sink receives what the core has to show, in the order of the state changes it reports: what one change shows is queued with the change, under the core's lock, and the queue is shown in order by one goroutine at a time, never while the core's lock is held. The sink is therefore never called by two goroutines at once, and a prompt is never shown delivering after it was shown delivered. A call is made on the goroutine that queued it, or on another that was showing the queue meanwhile; either way, an operation's calls are made before it returns, as the desktop always made them.
A sink may read the core (State, Agent, Draining), but must not call an operation that changes it, and should not block: the goroutine calling it may be delivering a decision, and the other goroutines that show something wait for it. A front end may hold a lock of its own while it reads the core or calls SetHolder, AdmitRun, Admit, the release Admit returns, RecordAudit or BeginDrain, none of which calls the sink on the caller's goroutine. It must not hold a lock its sink takes while it calls an operation that changes the core, which may show what another goroutine queued, nor while it calls Wait, which waits for goroutines that show things.
type Status ¶
type Status struct {
RunID string `json:"runID"`
Scope string `json:"scope"`
Status string `json:"status"`
SessionID string `json:"sessionID,omitempty"`
// ClearedBefore, when set, says the backend dropped every prompt of the
// session detected before this time (RFC 3339): the session ended, or a
// new process replaced it. Clients drop the same prompts; a status without
// it, such as a stream error on a live session, leaves them answerable.
ClearedBefore string `json:"clearedBefore,omitempty"`
}
Status is a session-scoped status, or the audit-scoped one that reports a journal failure. It has the fields and the JSON of the desktop's StatusEvent.
type Supervisor ¶
type Supervisor struct {
// contains filtered or unexported fields
}
Supervisor is the supervision state machine of one run generation. A run that stops is drained and dropped with its supervisor; the next run gets a new one, so nothing of a run's prompts, answers or freezes outlives it.
func New ¶
New returns the supervisor of one run, with every agent running. ctx is the run's own context: cancelling it interrupts the writes in flight when the run stops.
func (*Supervisor) Admit ¶
func (s *Supervisor) Admit(sessionID, connID string) (release func(), err error)
Admit admits one raw write to a session's terminal: keystrokes its holder types, which reach the agent without the policy and are never journaled. They are bounded instead. Only the connection that holds the session's hand (SetHolder) may write, so every raw byte falls within a hand the front end journaled taking; a terminal nobody holds takes none. And the write takes the session's one write slot, the one a decision or a line takes: it is refused while one of those is being written, and while it is admitted they are refused (ErrDecisionInFlight) or, for the policy's answers, wait for it. Whatever the policy would answer is asked anyway while the hand is held, but the hand may be released while a write is still admitted.
Admit refuses, in this order: a run that drains (ErrRuntimeStopped), a journal that failed (ErrAuditUnavailable), a connection that does not hold the hand (ErrNotHolder), a session frozen by an uncertain write (ErrDeliveryUncertain), a session whose process is stopped, stopping or starting (ErrLineUnavailable), and a session already being written to (ErrDecisionInFlight). It journals nothing and shows nothing.
release must be called once the write has returned; calling it again does nothing. It frees the session's slot and, on another goroutine, considers the automatic answer that waited for it: neither Admit nor release calls the sink on the caller's goroutine. A drain waits for every admitted write.
func (*Supervisor) AdmitReport ¶ added in v0.8.9
func (s *Supervisor) AdmitReport(sessionID, connID string) (release func(), err error)
AdmitReport admits a raw write that is only the terminal's own replies, which IsTerminalReport recognises: a focus report, a cursor position report, the terminal's identification. It is admitted as Admit admits keystrokes, in the same order and through the same write slot, but it is not typing: the prompts of the session stay answerable, and it answers nothing for the repeat guard. A browser terminal sends such replies by itself: the ConPTY of every Windows session asks for focus reports in its first bytes, so taking an agent's terminal sent a focus report at once, and every prompt shown on it became the terminal's although nobody had typed.
func (*Supervisor) AdmitRun ¶ added in v0.8.9
func (s *Supervisor) AdmitRun() (release func(), admitted bool)
AdmitRun admits one backend write the core does not make itself and no session's hand governs, such as a terminal resize, through the gate a drain closes and then waits on: no write of a run is still in progress when its runtime closes. It admits nothing once the run drains or its journal has failed. release must be called once, when the write has returned.
func (*Supervisor) Agent ¶
func (s *Supervisor) Agent(sessionID string) (Agent, bool)
Agent returns one agent's state. Like State, it may be called under a front end's own lock.
func (*Supervisor) BeginDrain ¶
func (s *Supervisor) BeginDrain()
BeginDrain stops the run taking anything in: no write is admitted, no automatic decision is scheduled, and every operation refuses with ErrRuntimeStopped. What was already admitted carries on to its journaled outcome, which Wait waits for.
func (*Supervisor) Draining ¶
func (s *Supervisor) Draining() bool
Draining reports whether BeginDrain was called: the run takes nothing more in.
func (*Supervisor) Handle ¶
func (s *Supervisor) Handle(message session.Event)
Handle takes in one event of the run's session stream: a prompt, its withdrawal, a backend stream error or a legacy exit. OutputAvailable stays with the front end, which coalesces output refreshes; the core ignores it. Events are handled synchronously, on the caller's goroutine.
func (*Supervisor) RecordAudit ¶ added in v0.8.9
func (s *Supervisor) RecordAudit(entry audit.Entry) error
RecordAudit journals an entry the core does not write itself, such as the gateway's attach_started, through the path the core's own entries take: an entry the journal refuses freezes the run, as the refusal of any of the core's does, and the caller gets ErrAuditUnavailable. Once the journal has failed, nothing more is written and every entry gets ErrAuditUnavailable. A front end that must not act unless its record is journaled, such as handing over a terminal, acts only once RecordAudit returned nil.
Only a front end's own kinds are taken: who took or let go of a terminal (attach_started, attach_finished), how its control changed hands (the control_ kinds) and the lifecycle of a recording (the recording_ kinds). Any other is refused with ErrUnsupportedEntry before anything else, with nothing journaled: the core's own entries say what the core did, and a decision entry a front end wrote reset the policy's count of consecutive automatic decisions.
RecordAudit never calls the sink on its caller's goroutine: the journal's failure is shown on another, which a drain waits for. A front end may call it under a lock of its own, one its sink takes included, and take the hand under the same lock once the entry is journaled. Shown on the caller, the failure deadlocked such a front end the first time the journal refused an entry, and never while it worked.
func (*Supervisor) RestartSession ¶
func (s *Supervisor) RestartSession(runID, sessionID string) error
RestartSession transactionally stops then starts one agent in place. An unconfirmed stop never produces a replacement process; a failed start leaves the agent down with its identity locked for an explicit retry. It is refused, as a Stop is, while anything is being written to the session: the old process's keystrokes could otherwise reach its replacement.
func (*Supervisor) SetHolder ¶ added in v0.8.9
func (s *Supervisor) SetHolder(sessionID, connID string)
SetHolder records which connection holds the session's terminal, the hand: connID, or nobody when connID is empty. The hand is the front end's; the core changes it only when told, whatever the session's process does, so the two always agree on who holds it. Agent.Attached reports that somebody does.
While somebody holds the hand:
- every prompt of the session the policy would answer automatically, and whose answer is not already being written, is asked instead, for the reason operator_attached, the policy's proposal and rule kept. Each is journaled as a second policy_evaluated entry, ask, and shown again;
- a prompt the session raises is asked the same way before its evaluation is journaled, and is notified like any prompt that waits on a person;
- a prompt that was being taken in when the hand was taken is asked the same way, even when the hand was released again before the prompt was pending: its holder may have typed the answer meanwhile;
- a prompt whose answer the policy already claimed is checked for the hand again just before the policy's decision is journaled, and asked if somebody holds it then; an answer already journaled goes on;
- a line is refused (ErrLineUnavailable): it would interleave with the holder's keystrokes;
- a person's decision is still accepted, from anyone who may act: the hand governs the terminal, not supervision. Only a prompt that was shown while the holder's keystrokes were written is refused to them, since the keystrokes may have answered it (ReasonTypedAtTerminal).
Releasing the hand never makes a prompt automatic again: the holder may have answered it by typing, which the runtime never learns. A prompt the session raises after the release is the policy's as usual.
SetHolder does not block: it changes the state at once, so that no automatic answer starts once it returns, and journals and shows what it changed on another goroutine, never on the caller's. A front end may call it while it holds a lock of its own, including one its sink takes.
func (*Supervisor) StartSession ¶
func (s *Supervisor) StartSession(runID, sessionID string) error
StartSession launches a fresh process for one stopped or exited agent under its unchanged identity and plan specification, without interrupting the other agents of the run. It is refused (ErrDecisionInFlight) while a write to the previous process has not returned, an answer, a line or the holder's keystrokes: what it still sends would reach the replacement, and the replacement's first answer would be written beside it. A process that exits during a write leaves the session to that write, which releases it when it returns.
func (*Supervisor) State ¶
func (s *Supervisor) State() State
State returns the agents, the pending prompts in display order and whether the journal failed, read together. It never reaches the sink, so a front end may call it under its own lock.
func (*Supervisor) StopSession ¶
func (s *Supervisor) StopSession(runID, sessionID string) error
StopSession strictly stops one agent's process while its siblings keep running. Its prompts stay until the process's exit arrives. It is refused while an answer or a line is being written to the session (ErrLineInFlight for a line, ErrDecisionInFlight for an answer): the write's outcome, which is journaled, would be lost with the process.
It is taken while the holder's keystrokes are being written. A Stop writes nothing to the agent, keystrokes are never journaled, and their write is not bounded: an agent that reads nothing fills its terminal's input buffer and blocks the write, which then held the session's slot and refused the Stop, the one action that ends the agent, and with it the write. The session stays the keystrokes' until they return: a Start or a Restart is refused meanwhile, since their bytes could reach the replacement.
A Stop is taken once the journal has failed, too: nothing more is written to an agent then, and stopping one is how an operator acts on that. The journal's gate, closed by the failure as by a drain, refused it with ErrRuntimeStopped, and only stopping the whole run was left. It is counted so that a drain still waits for it.
func (*Supervisor) SubmitAutomaticDecision ¶
func (s *Supervisor) SubmitAutomaticDecision(runID, sessionID, eventID, decision string, actor Actor) error
SubmitAutomaticDecision relays an answer the adapter encodes itself, so the operator does not have to know the keystroke a given CLI expects.
The set of answers a given occurrence accepts is reported on the event, and the adapter is asked again here: a decision that arrived from a stale interface must be refused by the core rather than by the screen that offered it. Only allow and deny are answers here, and only when the adapter offers them for this occurrence and the prompt still offers them: an answer the core took off the prompt, one the adapter could not encode when it was tried, is not taken again. Anything else is ErrUnsupportedDecision, with nothing changed and nothing journaled. actor is who chose it; a read-only one is refused before anything else.
func (*Supervisor) SubmitDecision ¶
func (s *Supervisor) SubmitDecision(runID, sessionID, eventID, manualInput string, actor Actor) error
SubmitDecision relays a manual value to the exact canonical occurrence. The value is never copied into application state, events, errors or audit data. It is always sent as typed text, DecisionManual, and journaled ask: only the adapter knows what the bytes mean. actor is who typed it; a read-only one is refused before anything else, and an empty value before any claim or entry.
The value is one line of text, as a line is (ErrAnswerInvalid otherwise, before any claim or entry): valid UTF-8, no control character and no more than a line's bytes. The adapter appends its own terminator. Only a NUL byte was refused, by the adapters, so a typed answer carried CR, LF, escape sequences and control keys of any length: several answers, or keystrokes of any kind, written through the one path that needs no hand, while a line, which is refused while anybody holds the terminal, was held to a single line. The journal records a typed answer as asked, never its bytes, so what it had carried said nothing.
func (*Supervisor) SubmitLine ¶
func (s *Supervisor) SubmitLine(runID, sessionID, line string, actor Actor) error
SubmitLine sends one ordinary application line to a detached, running session. The line crosses this method only as a call argument: it is never copied into core state, events, errors or audit entries. actor is who sent it, whom the line's entries name; a read-only one is refused before anything else.
func (*Supervisor) Wait ¶
func (s *Supervisor) Wait()
Wait returns once every admitted write, and every automatic decision the core started, has finished. It follows BeginDrain; the runtime's journal must stay open until it returns.
type View ¶
type View struct {
RunID string `json:"runID"`
ID string `json:"id"`
SessionID string `json:"sessionID"`
AgentID string `json:"agentID"`
Adapter string `json:"adapter"`
Type string `json:"type"`
Summary string `json:"summary"`
Sensitive bool `json:"sensitive"`
Risk string `json:"risk"`
Timestamp string `json:"timestamp"`
Evaluation EvaluationView `json:"evaluation"`
DeliveryStatus string `json:"deliveryStatus"`
// Decisions are the semantic answers this event's own adapter can encode,
// probed per event rather than assumed per adapter. An interface that
// offered an Allow button the adapter has no verified bytes for would be
// promising a delivery that fails at the last step. The core only ever
// takes answers away: one the adapter could not encode when it was tried,
// and every answer but deny when the policy denies the prompt but it goes
// to the operator all the same. An answer not offered is refused.
Decisions []string `json:"decisions"`
// contains filtered or unexported fields
}
View is the display-safe form of one supervised prompt: what a front end may put on screen or send to a browser. Its JSON is the desktop's SupervisionEvent, field for field. It has no field for terminal input, adapter matches or raw backend text, and its summary, rule and reason have been bounded and redacted.
func (View) ToolCall ¶ added in v0.8.9
ToolCall is the MCP tool call the prompt asks about, as it may be shown to anyone who may see the prompt, or nil. A prompt whose text must not be shown carries none, and a call's names and parameter values are redacted as the journal redacts text and bounded. The result is the caller's own copy.