Documentation
¶
Overview ¶
Package assistant is the cockpit's own conversation: one conversation with an installed coder that is bound to the cockpit instead of to a project.
A conversation is a real provider session driven in non-interactive mode. The cockpit keeps the readable transcript, the conversation context lives in the provider's own session and is resumed by id on every turn.
One substrate, one binding. The substrate is the conversation, its store, the streaming and the process handling; the binding is what makes it the assistant: its own store paths, a working directory of its own, the memory the user can see and edit, and the jobs it steers. The working directory deliberately sits next to the cockpit state, never on top of it: the state directory holds webhook URLs and push keys, and no coder gets those as its default file scope.
This package must not import internal/coder. The coder package refers to assistant.Runner for its optional conversation capability, so an import in the other direction would close a cycle. Everything this package needs from the coder side arrives through the small interfaces in runner.go, wired in main.
<state-dir>/assistant/assistant.json the conversation index <state-dir>/assistant/conversations/<id>.json one transcript <state-dir>/assistant/workspace the working directory <state-dir>/assistant/workspace/memory one markdown file per fact <state-dir>/assistant/workspace/user-upload/<id> what a message carried
Index ¶
- Constants
- Variables
- func ContextWindow(coderID, model, tier string) int
- func DoneWhenLine(doneWhen string) string
- func IsCheckSession(name string) bool
- func JobsPath(stateDir string) string
- func LooksLikeLogin(text string) bool
- func MediaKind(name string) string
- func New(stateDir string, coders Coders, cockpit Cockpit) (*Service, *Workspace, error)
- func Paths(stateDir string) (index, conversations, uploads string)
- func RunPaths(stateDir string) (index, dir string)
- func SessionName(title string) string
- func SortJobs(list []Job)
- func TruncateTask(raw string) (string, string)
- func UnreadableLine(line []byte) string
- func ValidID(id string) bool
- func ValidateDoneWhen(raw string) (string, error)
- type Activity
- type AdoptedCheck
- type Attachment
- type Cockpit
- type CoderInfo
- type Coders
- type Command
- type ContextUsage
- type Conversation
- type Draft
- type Entry
- type Event
- type EventKind
- type Job
- type JobState
- type JobStore
- type LineHandler
- type Message
- type Parser
- type Projects
- type Role
- type Run
- type RunKind
- type RunRecord
- type RunStore
- type Runner
- type Service
- func (s *Service) Cancel(id string) error
- func (s *Service) Coders() []CoderInfo
- func (s *Service) Current() (Conversation, bool)
- func (s *Service) Delete(id string) error
- func (s *Service) Discard(conversationID, messageID string) error
- func (s *Service) Draft(id string) (Draft, error)
- func (s *Service) Get(id string) (Conversation, error)
- func (s *Service) LastAnswer(id string) (Message, bool)
- func (s *Service) List() []Summary
- func (s *Service) Open(coderID string) (Conversation, error)
- func (s *Service) ReapUploads(grace time.Duration)
- func (s *Service) Recover() []AdoptedCheck
- func (s *Service) Rename(id, rawTitle string) error
- func (s *Service) Reserved(coderID, sessionID string) bool
- func (s *Service) Retry(id string) (Run, error)
- func (s *Service) RunUploadReaper(interval, grace time.Duration)
- func (s *Service) Running(id string) bool
- func (s *Service) SaveDraft(id, text string, attachments []Attachment) (Draft, bool, error)
- func (s *Service) Search(word string) []Summary
- func (s *Service) Send(id, prompt string, attachments []Attachment) (Run, error)
- func (s *Service) SetHooks(onChange func(), onDone func(conversationID string))
- func (s *Service) SetRenderer(render func(string) (string, error))
- func (s *Service) Subscribe(id string) (StreamEvent, bool, <-chan StreamEvent, func())
- func (s *Service) Transcript(id string, entries, budget int) (Conversation, int, error)
- func (s *Service) Transfer(id string, terminals Terminals) (string, error)
- func (s *Service) UploadDir(id string) (string, error)
- type Sessions
- type State
- type Status
- type Store
- type StreamEvent
- type Summary
- type Terminals
- type TurnRequest
- type Verdict
- type WakeNote
- type Watcher
- func (w *Watcher) Forget(terminal string)
- func (w *Watcher) Get(terminal string) (Job, bool)
- func (w *Watcher) Handle(terminal string)
- func (w *Watcher) Heartbeat()
- func (w *Watcher) List() []Job
- func (w *Watcher) Marks() (steered map[string]bool, doneWhens map[string]string)
- func (w *Watcher) NoteAssistantInput(terminal string)
- func (w *Watcher) OnStateChange(fn func(project string))
- func (w *Watcher) Recover(adopted []AdoptedCheck)
- func (w *Watcher) Release(terminal string) error
- func (w *Watcher) RunHeartbeat(interval time.Duration)
- func (w *Watcher) Steer(spec Job) (Job, error)
- func (w *Watcher) SweepExpired()
- type Workspace
- func (s *Workspace) CockpitCommand(name string) string
- func (s *Workspace) DeleteMemory(slug string) error
- func (s *Workspace) Memory() []Entry
- func (s *Workspace) MemoryEntry(slug string) (Entry, error)
- func (s *Workspace) ProjectNameFor(string) string
- func (s *Workspace) ResolveWorkspaceFile(rel string) (string, error)
- func (s *Workspace) SaveMemory(slug, title, body string) (Entry, error)
- func (s *Workspace) SaveUpload(dir, name string, src io.Reader) (Attachment, error)
- func (s *Workspace) Sync() error
- func (s *Workspace) ValidatePath(raw string) (string, error)
- func (s *Workspace) Workspace() string
Constants ¶
const ( // FrameStart opens a generation. Clients reset their buffer on it. FrameStart = "start" // FrameDelta appends assistant text. FrameDelta = "delta" // FrameHTML carries the answer so far, rendered on the server. Markdown is // only additive as long as nothing is half open (a fence, a table, an // emphasis), so the browser never parses the stream itself: it shows the // raw text as it arrives and replaces it with the rendered prefix whenever // one of these lands, which also keeps model output out of any client side // parser. FrameHTML = "html" // FrameTool reports that the provider is working with a tool. FrameTool = "tool" // FrameEnd closes a generation, carrying the final message state. FrameEnd = "end" // FrameMessage announces a message that appeared without a generation the // page was following: a wake writing its report, or a message that queued // while a turn ran. The client pulls that one message and appends or // replaces it, and touches nothing else, because a chat answer may be // streaming at the same time. FrameMessage = "message" // FrameGone announces a message that was removed while it waited, so every // open page drops its bubble. FrameGone = "gone" // FramePing proves the stream is alive to a browser that cannot see the SSE // keepalive, which is a comment and fires no event. Silence in the middle // of an answer is the normal case, thinking sends nothing at all, so only a // missing life sign says the socket died. It carries nothing, the client // stamps it and drops it, and it never travels the hub: the stream handler // writes it on a beat of its own. FramePing = "ping" )
Stream frame kinds. Every frame carries the run and message id it belongs to, so a stream that outlived a cancelled turn can never write into the message of a newer retry.
const ( // MaxPromptBytes is the largest accepted user message. MaxPromptBytes = 32 << 10 // MaxResponseBytes is the largest accepted assistant answer. MaxResponseBytes = 1 << 20 // MaxTitleRunes bounds a conversation title. MaxTitleRunes = 120 // MaxQueuedMessages bounds what may wait while a turn runs. The flush joins // every waiting message into one prompt, and that prompt has to stay within // what a process argument accepts. MaxQueuedMessages = 20 // DefaultTitle names a conversation that has not seen a prompt yet. DefaultTitle = "New conversation" )
Limits the feature enforces before a prompt reaches a process and while a response is parsed. The prompt bound keeps the argv within what execve accepts, the output bound keeps one runaway answer from growing the state file without limit.
const BlockSeparator = "\n\n"
BlockSeparator stands between two text blocks of one turn. A turn that works with tools answers in several blocks, what it says before a call and the answer after it, and every runner hands those over as one stream of deltas that is appended as it arrives, so without it the end of one block and the start of the next are welded into one word. It is the blank line markdown needs between two blocks, and a runner writes it only where the provider's own output says a block ended: never in front of a turn's first block, never behind its last, and never guessed from the text that is already there.
const ContextTierLong = "long_context"
ContextTierLong is the wide context tier a coder may put a session on. It is copilot's name for it, and the only tier name that changes a window.
const MaxMemoryBytes = 16 << 10
MaxMemoryBytes bounds one memory entry. The memory is read on every turn, so a runaway file would be paid for again and again.
const MaxSessionNameBytes = 80
MaxSessionNameBytes bounds the name a provider session is created with. A conversation title is written for the page and may be long; copilot refuses a name over 100 characters, so a turn hands over a name both CLIs take.
const Name = "Assistant"
Name is what the surface is called in the UI.
const TranscriptEntriesShown = 6
TranscriptEntriesShown is how many messages a transcript reading keeps by default: enough to see where a conversation stands, small enough to stay a fraction of the answer that carries it.
const TranscriptMessageRunes = 600
TranscriptMessageRunes is how many runes one message may cost in a capped transcript reading. Callers pass it as the budget; zero lifts the cap, for the one answer that needs a message whole.
Variables ¶
var ErrBusy = errors.New("The coders are busy with other conversations right now. Try again in a moment.")
ErrBusy is returned when the global generation limit is reached.
var ErrNotLoggedIn = errors.New("The coder is not logged in on this machine. Start it once in a terminal, log in there, and send this again.")
ErrNotLoggedIn is what a parser returns when its CLI never got going because nobody logged it in on this machine. The sentence lives here and not next to a coder on purpose: a parser answers whether it happened, this package owns what the user reads about it, so no CLI's own wording leaks into a conversation.
Functions ¶
func ContextWindow ¶
ContextWindow resolves how much this coder's model holds under this tier, 0 when the table says nothing about it. An empty tier means the standard one, which is what a session runs under unless the coder put it on a wider tier. The lookup is case insensitive and ignores a date suffix, so a dated release like `claude-haiku-4-5-20251001` finds the entry its family has.
func DoneWhenLine ¶
DoneWhenLine is what a report or a list shows as a job's done-when. A job from the page may carry none: then the session's own task decides, and every surface says exactly that instead of an empty line.
func IsCheckSession ¶
IsCheckSession reports whether a stored provider session was a check's own. Used at startup to sweep what a hard restart left behind: nothing running answers to these names, because a live check keeps its session reserved and invisible for as long as it runs.
func LooksLikeLogin ¶
LooksLikeLogin reports whether text reads like a CLI that was never logged in. It is exported because every coder answers that question about its own output, and the pattern behind the answer stays one.
func New ¶
New wires the assistant over the installed coders and returns both halves: the conversation service that drives the conversations and the assistant service that owns workspace and memory. cockpit describes how a turn can look at the cockpit itself, it may be empty.
func Paths ¶
Paths returns the assistant's store locations for a state directory. The inspection commands build the same store to know which provider sessions belong to a conversation, so the layout lives in one place.
func RunPaths ¶
RunPaths returns the register and the directory of raw output files for a state directory.
func SessionName ¶
SessionName cuts a conversation title down to a provider session name, on a rune boundary so a multi byte title never breaks in the middle of a character.
func SortJobs ¶
func SortJobs(list []Job)
SortJobs puts the jobs that still wake the assistant first, then the newest. Exported because every list of jobs is read in this order, the page, the conversation and `dev-cockpit assistant job-list`, and one order means one function.
func TruncateTask ¶
TruncateTask cuts a task to its bound and says so. The task only describes, it may be a whole briefing, so a long one is stored cut instead of refused, but the cut must not stay silent. The second return is the notice for the caller, empty when nothing was cut. Like ValidateDoneWhen it is the one rule set: Steer applies it, and the routes that answer a caller take the notice from the same function instead of copying the bound and the sentence.
func UnreadableLine ¶
UnreadableLine shortens a raw output line for the server log. A parser that meets a line it cannot decode logs it through this and reads on, so the log says what arrived without carrying a whole answer, and the turn's outcome stays with the records the parser does evaluate.
func ValidateDoneWhen ¶
ValidateDoneWhen normalizes a done-when and refuses what a job cannot store: an empty one, and one over the bound. Lines survive, a done-when may be a list with one check per line, so only the line endings and the edges are normalized; folding to one line is a display concern of the places that need one. It is the one rule set: Steer applies it, and whoever wants to check a done-when before anything else exists (the coder create route, so a refused done-when cannot leave a running coder without its job) calls the same function instead of copying the bound and the message.
Types ¶
type Activity ¶
type Activity struct {
// Text is what the session last did, newest last.
Text string
// Finished says its turn is over: it is waiting, not working.
Finished bool
// Screen says Text is the terminal picture rather than a recorded
// conversation. Then the reading carries the coder's input line, and the
// prompt has to say so.
Screen bool
}
Activity is what the coder answered about a steered session: what it last did and whether it is still doing it. Why it stopped is deliberately absent. A dialog waiting for an answer, a context window with no room left and a turn that is simply over are the same picture from here, and the difference is not a flag anybody can set reliably: it is a screen that has to be read. The check reads it.
type AdoptedCheck ¶
type AdoptedCheck struct {
Terminal string
// Context is what the check found before it started, carried through the
// register so the job is judged the same way whether or not a restart
// happened in between.
Context checkContext
// contains filtered or unexported fields
}
AdoptedCheck is a check that outlived the server. The watcher takes it from here: it is the one that knows the job the check belongs to.
type Attachment ¶
type Attachment struct {
Name string `json:"name"`
// Path is the absolute host path. It stays in the transcript so a later
// turn can point the coder at the same file again.
Path string `json:"path"`
// Media classifies the file for the browser: image, video, audio or file.
Media string `json:"media"`
Size int64 `json:"size"`
}
Attachment is one file a prompt carries. The cockpit stores it inside the conversation's own files directory and hands the coder the absolute path, so a coder that can look at images gets a real file instead of a copy of the bytes.
type Cockpit ¶
type Cockpit struct {
Executable string
StateDir string
ProjectsDir string
// Version is what this build calls itself, a release tag or a dev build
// with its commit. Named in the instructions, so an answer about the
// software is about the software that is actually running.
Version string
// RepoURL is the web page of the repository this software lives in, a
// full URL. The instructions name it, so a question about the
// implementation has somewhere to go when the source is not on the
// machine it answers from.
RepoURL string
}
Cockpit is how a turn looks at the cockpit itself: the absolute path of the running binary plus the arguments that point its read only inspection commands at this instance's data. Passing the resolved path means the assistant never depends on where the binary sits or on PATH.
type Coders ¶
type Coders interface {
Available() []CoderInfo
}
Coders resolves the coders that can answer a turn of this installation. Implemented outside this package, see the package comment on the import direction.
type Command ¶
Command is the process one turn runs: a program and its arguments, never a shell line. A prompt travels in the argv, so it can never become a command.
type ContextUsage ¶
type ContextUsage struct {
// Model is what answered the turn, as the provider named it. It is kept so
// a reading can be understood later, and so the window can be resolved
// again when the table grows.
Model string `json:"model,omitempty"`
// Tier is the context tier the coder had the session on, empty for a coder
// without tiers. It is kept for the same reason as the model: it is half of
// what the window is looked up by.
Tier string `json:"tier,omitempty"`
// Tokens is what the context held at the end of the turn.
Tokens int `json:"tokens"`
// Window is how much that model's context holds, zero when unknown.
Window int `json:"window"`
}
ContextUsage is how full a coder's context window stood at the end of one turn. It is a per turn reading, not a running total: after a compact the tokens drop, and the value simply follows.
func (ContextUsage) Known ¶
func (u ContextUsage) Known() bool
Known reports whether this reading can be shown as a percentage.
func (ContextUsage) Percent ¶
func (u ContextUsage) Percent() int
Percent is how full the window stands, 0 when the reading says nothing. A window that is over full reads as 100: the provider is the one that knows where its own limit sits, and a number above it would only look broken.
func (ContextUsage) PercentIn ¶
func (u ContextUsage) PercentIn(coderID string) int
PercentIn is Percent for a reading whose window was never resolved. What the turn reported is the model and the tokens, and those never change; how large that model's window is, is a lookup, so a reading taken while the table still said nothing about the model fills in by itself once it does, without waiting for another turn.
type Conversation ¶
type Conversation struct {
Summary
// NativeSessionID is the provider session this conversation drives. It equals the
// conversation id, which is UUID shaped so it also works as a tmux session name
// once the conversation is transferred.
NativeSessionID string `json:"nativeSessionId"`
// TransferredSessionID is the coder terminal that took over, set once.
TransferredSessionID string `json:"transferredSessionId,omitempty"`
UpdatedAt time.Time `json:"updatedAt"`
Messages []Message `json:"messages"`
// Draft is what was typed into the composer and not sent yet. It belongs to
// the conversation, not to the browser that typed it, so the same words are
// there after a page change and on the next device.
Draft Draft `json:"draft,omitempty"`
// Context is how full the coder's context window stood at the end of the
// last turn that reported it. It lives on the conversation, not on a
// message: it describes the whole conversation as it stands, and a reader
// coming back has to see the number without a turn running.
Context *ContextUsage `json:"context,omitempty"`
}
Conversation is one complete conversation with its transcript.
func (Conversation) Idle ¶
func (c Conversation) Idle() bool
Idle reports whether the conversation currently has no unfinished assistant turn.
func (Conversation) Last ¶
func (c Conversation) Last() (Message, bool)
Last returns the final message of the transcript.
type Draft ¶
type Draft struct {
Text string `json:"text,omitempty"`
Attachments []Attachment `json:"attachments,omitempty"`
UpdatedAt time.Time `json:"updatedAt,omitempty"`
}
Draft is an unsent prompt with the files that were already uploaded for it. The files travel with the text, otherwise coming back shows a message whose attachments are gone.
type Event ¶
type Event struct {
Kind EventKind
Text string
Err error
// Usage carries the context reading of an EventUsage and is nil otherwise.
Usage *ContextUsage
}
Event is one structured message from a provider run. The channel closing without an EventError means the turn completed.
type EventKind ¶
type EventKind string
EventKind classifies a runner event.
const ( // EventDelta carries assistant text to append. EventDelta EventKind = "delta" // EventTool marks that the provider started working with a tool. It // carries no arguments, the UI only shows that something is happening. EventTool EventKind = "tool" // EventUsage reports how full the coder's context window stands. It arrives // at most once per turn, at its end, because that is when the provider says // it; a later one replaces an earlier one. EventUsage EventKind = "usage" // EventError ends the turn with a curated, user facing message. EventError EventKind = "error" )
type Job ¶
type Job struct {
// Terminal is the coder session, which is also the id its news arrives
// under, so a signal resolves to a job with one lookup.
Terminal string `json:"terminal"`
Name string `json:"name"`
Project string `json:"project"`
CoderID string `json:"coderId"`
// Task and DoneWhen are the job itself: one thing to do and the one
// criterion that decides it is done. A job that needs a second step gets it
// the same way the first one arrived, from the assistant a DONE woke.
Task string `json:"task"`
DoneWhen string `json:"doneWhen"`
State JobState `json:"state"`
Wakes int `json:"wakes"`
MaxWakes int `json:"maxWakes"`
CreatedAt time.Time `json:"createdAt"`
ExpiresAt time.Time `json:"expiresAt"`
UpdatedAt time.Time `json:"updatedAt"`
// Note is the one line the last check left, and NoteAt when that was. A
// check without news writes nothing else, so this is where the user sees
// that the job is alive.
Note string `json:"note,omitempty"`
NoteAt time.Time `json:"noteAt,omitempty"`
// LastAssistantInputAt is when this terminal last got input from an
// assistant turn. The standstill rule reads it: a check that reports
// WORKING on a coder that stood still must really have sent it something.
LastAssistantInputAt time.Time `json:"lastAssistantInputAt,omitempty"`
// LastWakeAt is when the last check ran. The heartbeat reads it: its own
// quiet window keeps a paid check from being followed by another look
// right away.
LastWakeAt time.Time `json:"lastWakeAt,omitempty"`
// CheckingSince is set while a check is running, so the page can say so and
// nobody has to read a counter to know. A restart leaves it standing, and
// the watcher picks those jobs up when it comes back.
CheckingSince time.Time `json:"checkingSince,omitempty"`
// Silent counts the checks in a row that came back without a verdict: a
// crash, a time limit, an answer with no words. Those cost no wake, so
// something has to stop a job from retrying them forever.
Silent int `json:"silent,omitempty"`
// ActivityDigest and ActivityAt are what the coder last looked like and when
// that changed. A picture that stops changing is how the heartbeat tells a
// coder that is working from one that is stuck without being able to say so.
ActivityDigest string `json:"activityDigest,omitempty"`
ActivityAt time.Time `json:"activityAt,omitempty"`
}
Job is one steered job.
type JobState ¶
type JobState string
JobState is where a job stands.
const ( // JobSteering is a job that still wakes the assistant. JobSteering JobState = "steering" // JobDone is a job whose criterion was met. JobDone JobState = "done" // JobBlocked is a job that cannot go on without the user. JobBlocked JobState = "blocked" // JobExpired is a job that ran out of wakes or out of time. JobExpired JobState = "expired" // JobStopped is a job the user or the assistant called off. JobStopped JobState = "stopped" )
type JobStore ¶
type JobStore struct {
// contains filtered or unexported fields
}
JobStore persists the jobs. One file, read through on every call like every other state file, so a job survives a restart and two processes cannot hold different ideas about it.
func NewJobStore ¶
NewJobStore returns the store for a state directory.
func (*JobStore) PruneTerminals ¶
PruneTerminals drops the jobs of terminals that are not in keep. The startup restore calls it with what its pass found, the same place and the same reason the notifications are pruned there: a session that was deleted leaves an entry that resolves to nothing forever, and every read of the store pays for parsing it. Open jobs stay whatever their terminal does. A job that ends has to say so to the user, and ending a job whose terminal is gone is the heartbeat's, with a report; dropping it here would be the one ending nobody hears. Returns how many entries were removed.
func (*JobStore) Update ¶
Update changes one job in place, under the same lock that reads it. Both the watcher and the input route write to the same record while a check runs, and each of them owns different fields: the assistant's last input belongs to the input route, the counters and the state to the check. Read, change and write in two separate lock sections would let the later writer put back what it read before the other one wrote, and what disappears that way is either a paid check or the send that was meant to get the coder going again.
change decides whether its change stands: false leaves the file alone, which is what a caller wants once it sees, under this lock, that the job is not the one it meant to write to any more. The reported bool is whether the job was written, so a caller that has to decide something after the write asks that one question instead of two.
type LineHandler ¶
LineHandler consumes one structured output line. Returning an error ends the turn with that error as the user facing message.
type Message ¶
type Message struct {
ID string `json:"id"`
Role Role `json:"role"`
Content string `json:"content"`
Attachments []Attachment `json:"attachments,omitempty"`
CreatedAt time.Time `json:"createdAt"`
// RunID ties an assistant message to the generation that produced it, so a
// stale stream cannot write into a newer retry.
RunID string `json:"runId,omitempty"`
State State `json:"state"`
// Error is a curated, user facing sentence. Provider stderr, argv and
// paths never reach it.
Error string `json:"error,omitempty"`
// Wake marks a message a check wrote, so the page can show where it came
// from. A check's prompt is never stored, only what it concluded, which is
// why nothing in a transcript can look like something the user said.
Wake *WakeNote `json:"wake,omitempty"`
}
Message is one turn half, a user prompt or an assistant answer.
type Parser ¶
type Parser interface {
// Line consumes one structured output line. Returning an error ends the
// turn with that error as the user facing message.
Line(line []byte) error
// Finish is called when the process ended, and reports what is wrong when
// the record that closes a turn never arrived.
Finish() error
// Diagnose names the error of a failed turn out of everything this parser
// saw, plus the end of standard error. Returning nil keeps err as it is.
// It is called once, only for a turn that failed, and only after the
// output was read to its end and the event channel is closed, so nothing
// else touches the parser any more and there is no race on its state.
// Every parser has to answer it, that is the point of it being part of the
// interface: a new coder has to say what its CLI does when it never gets
// going at all, which is where the exit code would have been the obvious
// signal and is not available (it is not kept, and a turn adopted after a
// restart has none left to get).
Diagnose(err error, stderr string) error
}
Parser turns one provider's output into events. Implemented next to each coder, because only they know the shape of their CLI's output.
type Projects ¶
type Projects interface {
ValidatePath(raw string) (string, error)
ProjectNameFor(path string) string
}
Projects validates the project directory a conversation is bound to.
type Run ¶
type Run struct {
RunID string
MessageID string
ReplacedID string
// UserMessageID names the prompt that was just written into the transcript.
// The page that sent it already shows it, and the same message is announced
// on the stream for the other pages, so this is how the sender recognizes
// the announcement of its own message instead of showing it twice.
UserMessageID string
// Title is the conversation title after this turn, so a page that derived its
// title from the first prompt can show it without a reload.
Title string
// Queued says no generation started: the message waits in the transcript
// until the running turn ends and the server flushes the queue.
Queued bool
}
Run identifies a started generation for the browser. ReplacedID names the message a retry dropped, so the page can remove that bubble instead of showing the failed turn next to its replacement.
type RunKind ¶
type RunKind string
RunKind separates the two turns that exist. They differ in what finishing means: a chat turn writes into the message it already has, a check hands its verdict to the watcher, which decides whether the user hears about it at all.
type RunRecord ¶
type RunRecord struct {
ID string `json:"id"`
Kind RunKind `json:"kind"`
// Conversation and MessageID name what the turn writes into. A chat turn
// has its placeholder message already and names the conversation it belongs
// to; a check carries no conversation, only the id its report will get, so
// writing that report twice is impossible and it lands wherever the user is
// when it comes back.
Conversation string `json:"conversation,omitempty"`
MessageID string `json:"messageId"`
// CoderID picks the runner that can read this output, SessionID the
// provider session the turn drives. Both are needed to attach: the parser
// belongs to the coder, and a check keeps its session reserved until it is
// over.
CoderID string `json:"coderId"`
SessionID string `json:"sessionId"`
// Terminal and Context are what a check needs to be concluded by whoever
// finds it: the terminal its job steers, and what the watcher saw before it
// started.
Terminal string `json:"terminal,omitempty"`
Context checkContext `json:"context,omitempty"`
// Output is the provider's raw output, Errors its standard error.
Output string `json:"output"`
Errors string `json:"errors"`
// PID is the turn process, the one that holds the turn's lock while the
// provider runs. It is what a kill signals and what a self update reaps,
// but whether the turn still runs is decided by Lock alone.
PID int `json:"pid"`
// Lock is the file whose exclusive lock the turn process inherited before it
// started. As long as something holds it, the turn is running.
Lock string `json:"lock,omitempty"`
// Processed is how far the output file was turned into events. A file that
// is shorter than this was replaced under a running turn, and then it is not
// the answer to this prompt any more.
Processed int64 `json:"processed"`
StartedAt time.Time `json:"startedAt"`
// Deadline is when this turn is killed. A check has one, a chat turn runs
// as long as the user lets it.
Deadline time.Time `json:"deadline,omitempty"`
// Cancelled marks a turn the user stopped. It is written before the process
// is killed, so a stop that races a restart is still read as a stop and not
// as a coder that died.
Cancelled bool `json:"cancelled,omitempty"`
}
RunRecord is one turn as it exists outside this process.
type RunStore ¶
type RunStore struct {
// contains filtered or unexported fields
}
RunStore persists the register. One file, read through on every call like every other state file, so the entry a dying process wrote is the entry the next one finds.
func NewRunStore ¶
NewRunStore returns the register for a state directory.
func NewRunStoreAt ¶
NewRunStoreAt returns a register over explicit paths, so the layout stays in one place.
func (*RunStore) Delete ¶
Delete removes one entry and the raw output it describes. The transcript holds the answer by then, and a file nobody reads any more is only disk.
func (*RunStore) Files ¶
Files returns the paths a new turn lives in: its output, its standard error, and its lock. The directory is created here, because a turn that cannot write its output must not be started at all.
func (*RunStore) Sweep ¶
func (s *RunStore) Sweep()
Sweep removes raw output files no entry points at any more. A process killed between writing the file and registering it would otherwise leave it behind forever.
type Runner ¶
type Runner interface {
// Command builds the process for one turn.
Command(req TurnRequest) (Command, error)
// Parse returns a fresh parser for this coder's raw output. It is called
// again when a turn is picked up after a restart, so it may not depend on
// anything but its arguments.
Parse(sessionID string, events chan<- Event) Parser
// SessionExists reports whether the provider still holds this session.
SessionExists(sessionID string) bool
// DeleteSession removes the provider side conversation.
DeleteSession(sessionID string) error
}
Runner describes one provider CLI in non-interactive mode. Implementations live next to their coder (internal/coder/<coder>/assistant.go) and are the only place that knows provider flags and output shapes.
A runner does not own the process. It says what to run and how to read what comes back, and this package starts it detached, keeps its raw output on disk and reads it, which is what lets a turn outlive the server: a process this server never started is attached with the same two calls.
type Service ¶
type Service struct {
// contains filtered or unexported fields
}
Service owns conversation state, provider processes and the per conversation streams.
func (*Service) Cancel ¶
Cancel stops the running generation, keeping the partial answer. The stop is written down before the process is killed, so a restart that happens in between still reads it as a stop.
func (*Service) Current ¶
func (s *Service) Current() (Conversation, bool)
Current is the conversation the assistant is in right now. One is live at a time, so nothing that acts has to carry an id: a check reports here, a job is listed here, and the page opens here.
func (*Service) Delete ¶
Delete removes a conversation. An active conversation takes its provider session with it, a transferred one does not: the coder terminal owns that session now.
func (*Service) Discard ¶
Discard removes one waiting message before the queue flushed it. The decision falls under the service lock, the same one the flush holds: a message is either still waiting and comes out, or it already went and the caller hears so.
func (*Service) Draft ¶
Draft is what the composer of a conversation holds right now. It is its own read so a device catching up pulls the draft, not the transcript.
func (*Service) Get ¶
func (s *Service) Get(id string) (Conversation, error)
Get loads one conversation.
func (*Service) LastAnswer ¶
LastAnswer returns the newest assistant message of a conversation. The notification for a finished turn is raised right after that turn ended, so this is the message it is about: it names the answer in the entry and lets the entry link straight at it.
func (*Service) Open ¶
func (s *Service) Open(coderID string) (Conversation, error)
Open is the live conversation, started when there is none. coderID picks the coder for a conversation that has to be created and is empty for "whichever one is live".
An untouched conversation is reused instead of leaving a trail of empty ones behind: pressing new twice is one conversation, and a check that arrives before the user ever opened the page writes into the one they will find.
func (*Service) ReapUploads ¶
ReapUploads removes uploads nothing points at, neither a message nor a draft. A file is stored the moment it is picked, before the message that carries it exists, so a composer that is closed instead of sent leaves it behind and nothing else ever collects it: deleting a conversation takes its whole directory, and a sent file is referenced forever.
grace keeps a file that is waiting for its message: only uploads older than that are candidates, so a reap can never race a composer the user is still filling.
func (*Service) Recover ¶
func (s *Service) Recover() []AdoptedCheck
Recover picks up the turns a previous server left behind and is what makes a restart cost nothing. Every one of them is decided the same way: is the process still writing, and does its output still hold what was already read. A turn that is alive is followed on, a turn that ended in the meantime is read to its end and settled, and only a turn whose output is gone becomes an interrupted answer.
It must run after everything the service publishes through is wired, and after the local API socket of this process is bound: an adopted check acts while it runs.
func (*Service) Reserved ¶
Reserved reports whether a provider session belongs to an active conversation. The coder managers ask before listing a session, so a conversation never shows up as a ghost resumable coder. A transferred conversation releases its reservation, and its session then appears exactly once, as the coder terminal that owns it.
func (*Service) Retry ¶
Retry runs the last prompt again after a failed, cancelled or interrupted turn. It is always explicit: a turn that may already have been charged is never resent on its own.
func (*Service) RunUploadReaper ¶
RunUploadReaper reaps on an interval until the process ends. Never returns, run it on a goroutine.
func (*Service) SaveDraft ¶
SaveDraft stores what the composer holds without sending it and says whether that changed anything. A repeated save writes nothing and announces nothing, so a long transcript is not rewritten for a keystroke and the other devices are not woken for a draft that already is what they hold.
func (*Service) Search ¶
Search returns the conversation index, newest activity first, narrowed to the conversations where a word appears in the title or in a message, compared case insensitively. An empty word returns the whole index. The title is answered from the index alone, the message match loads the transcript; nothing here writes.
func (*Service) Send ¶
func (s *Service) Send(id, prompt string, attachments []Attachment) (Run, error)
Send appends a prompt and starts a generation. Attachments are already on disk when they arrive here, the prompt only points the coder at them.
While a turn runs the message queues instead: it goes into the transcript as a waiting entry, and the end of the turn flushes everything waiting as one new turn. The decision falls under the service lock, the same one the turn's end takes to stop counting as running, so a send racing that end either sees the run and queues, or sees the freed conversation and starts. A send that finds older messages still waiting queues behind them even when nothing runs, so the order of what was typed is the order of what goes out.
func (*Service) SetHooks ¶
SetHooks registers the coarse change publisher and the completion notification. Both run outside the service lock.
func (*Service) SetRenderer ¶
SetRenderer installs the Markdown renderer used while an answer streams. The browser never parses model output itself, it only shows what this produced.
func (*Service) Subscribe ¶
func (s *Service) Subscribe(id string) (StreamEvent, bool, <-chan StreamEvent, func())
Subscribe attaches to the conversation's stream and returns the in-flight state.
func (*Service) Transcript ¶
Transcript returns one conversation windowed to its last entries, each message cut to budget runes with a note saying how much of it is shown. entries zero or less means the default window, budget zero or less keeps every message whole. The second return is how many older messages the window dropped. It only reads, the stored transcript stays as it is.
type Sessions ¶
type Sessions interface {
Activity(coderID, terminal string) (Activity, error)
// Running reports whether the job's terminal still exists at all. A job
// whose terminal is gone can never be met, and asking a coder that is not
// there costs a paid turn to learn what the cockpit already knows.
Running(coderID, terminal string) bool
}
Sessions is how a check reaches the coder of the job it checks. It asks the coder, not the terminal: a screen carries the coder's input line with whatever draft stands in it, and a draft is not a message from anybody. What a session really did is the coder's own knowledge, so the coder answers, and how it answers depends on the provider. Implemented on the coder side, wired in main.
type State ¶
type State string
State is the delivery state of one message.
const ( StateComplete State = "complete" StateStreaming State = "streaming" StateCancelled State = "cancelled" StateFailed State = "failed" StateInterrupted State = "interrupted" // StateQueued marks a user message waiting for the running turn to end. It // sits in the transcript, deletable until the server flushes every waiting // message as one new turn. StateQueued State = "queued" )
type Status ¶
type Status string
Status is the lifecycle state of a conversation.
const ( // StatusActive marks a conversation that still owns its provider session. StatusActive Status = "active" // StatusTransferred marks a conversation whose provider session was handed to a // coder terminal. The transcript stays readable, the composer does not. StatusTransferred Status = "transferred" // StatusArchived marks a conversation that a newer one replaced. The // transcript stays readable, its provider session is gone: nothing can // continue it, and the cockpit holds the whole conversation on disk. StatusArchived Status = "archived" )
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
Store persists the conversation index and one file per transcript:
<index-path> index, no messages <dir>/<conversation-id>.json one transcript <upload-root>/<conversation-id>/<name> what a prompt carried
Index and transcripts go through internal/statefile, so reads pick up outside changes, writes are atomic and a corrupt file is quarantined instead of overwritten.
func NewStore ¶
NewStore returns the assistant's store for a state directory, the layout Paths describes.
func NewStoreAt ¶
NewStoreAt returns a store over explicit paths, so the layout stays in one place: the caller decides where index, transcripts and uploads live.
func (*Store) Load ¶
func (s *Store) Load(id string) (Conversation, bool)
Load reads one transcript. A missing or unreadable transcript reports false without touching the index: the entry stays visible and recoverable instead of disappearing behind a silent self-heal.
func (*Store) Save ¶
func (s *Store) Save(c Conversation)
Save writes the transcript and refreshes its index entry.
func (*Store) UploadDir ¶
UploadDir is where the uploads of one conversation live. It is created on demand by the upload path, never here.
func (*Store) UploadRoot ¶
UploadRoot is the directory holding one upload subdirectory per conversation.
type StreamEvent ¶
type StreamEvent struct {
Kind string `json:"kind"`
RunID string `json:"runId,omitempty"`
MessageID string `json:"messageId,omitempty"`
Text string `json:"text,omitempty"`
HTML string `json:"html,omitempty"`
State string `json:"state,omitempty"`
Error string `json:"error,omitempty"`
// Context rides the end frame and is how full the coder's context window
// stands, in percent. It is left out when the turn reported nothing or the
// model's window is unknown, and the page then leaves its ring as it is.
Context int `json:"context,omitempty"`
}
StreamEvent is one frame on a conversation's own SSE stream. Conversation text never goes to the app wide event bus, only the coarse conversations event does.
type Summary ¶
type Summary struct {
ID string `json:"id"`
Title string `json:"title"`
CoderID string `json:"coderId"`
ProjectPath string `json:"projectPath"`
Status Status `json:"status"`
CreatedAt time.Time `json:"createdAt"`
LastMessageAt time.Time `json:"lastMessageAt"`
// Preview is the opening of the last assistant answer, bounded for the list.
Preview string `json:"preview"`
// Unfinished marks a conversation whose last turn did not complete, so the list can
// show it without loading the transcript.
Unfinished bool `json:"unfinished"`
// MessageCount is the number of stored messages, used for the list subtitle.
MessageCount int `json:"messageCount"`
}
Summary is one index entry. It deliberately carries no messages: the list page renders from the index alone, so opening it never loads a transcript.
type Terminals ¶
type Terminals interface {
// ResumeReserved starts a terminal on an existing provider session and
// returns its identifier.
ResumeReserved(coderID, sessionID, projectPath, title string) (string, error)
// Stop kills a terminal again, used to roll a failed transfer back.
Stop(coderID, terminalID string) error
}
Terminals promotes a conversation's provider session into a coder terminal. The web layer implements it over the coder managers.
type TurnRequest ¶
type TurnRequest struct {
// SessionID is the provider session this turn belongs to. The first turn
// creates it, every later turn resumes it.
SessionID string
// Resume selects resume over create. The service decides it by asking the
// runner whether the session already exists, so a failed first turn that
// still wrote provider state cannot make the retry collide.
Resume bool
// Title names the provider session on creation, so a transferred conversation
// shows up as a coder terminal with the conversation's title.
Title string
// Workdir is the validated project directory the process runs in.
Workdir string
Prompt string
}
TurnRequest is one prompt handed to a provider CLI.
type Verdict ¶
type Verdict string
Verdict is what a wake concluded.
const ( VerdictDone Verdict = "done" VerdictBlocked Verdict = "blocked" VerdictWorking Verdict = "working" VerdictNothing Verdict = "nothing" // VerdictExpired is not something a check answers. It is what the watcher // records when a job runs out of checks or out of time, so the message that // says nobody is steering any more looks like the other reports. VerdictExpired Verdict = "expired" )
type WakeNote ¶
type WakeNote struct {
Terminal string `json:"terminal"`
// Name is the coder's name as the job carried it when this report was
// written. It is written down instead of looked up later because the job
// is gone by then, or worse, the terminal is steered again and the lookup
// answers with its successor. An older report simply carries none.
Name string `json:"name,omitempty"`
// Project travels with the name and for the same reason, so a report says
// where that coder worked without asking anybody.
Project string `json:"project,omitempty"`
Verdict string `json:"verdict"`
}
WakeNote says which terminal a check was about and what it concluded.
type Watcher ¶
type Watcher struct {
// contains filtered or unexported fields
}
Watcher turns a coder's news into a check by the assistant. It owns the gates that decide whether a wake may happen at all, so the cost of the feature is visible in one place.
func NewWatcher ¶
NewWatcher wires the watcher. There is no global switch: steering is explicit per job, so a job that exists is a job that wakes.
func (*Watcher) Forget ¶
Forget removes the job of a terminal that is gone for good. Release is the end of a job whose terminal stays: it leaves the entry standing, so the user can see what happened to it. A deleted session has nothing left to show it next to, its entry would be read on every look at the store forever, and nothing can ever move it again, so it goes. A check that is still running is killed like on a release, for the same reason: nobody pays for its answer.
func (*Watcher) Handle ¶
Handle is one signal from a terminal. Every reason not to wake is checked in one place, so the cost of this feature never depends on what a prompt decides, and the repeat run of a check asks the same questions again.
func (*Watcher) Heartbeat ¶
func (w *Watcher) Heartbeat()
Heartbeat is one pass over the open jobs. The first thing it does is end the jobs whose time or budget is up: that report used to need a signal, and a job that ran out is exactly the one that has none left to give.
func (*Watcher) Marks ¶
Marks is what the pages render about the jobs: which terminals an open job holds right now, and the stored criterion of the closed ones, which is what the steer dialog offers as its prefill when a terminal is steered again.
func (*Watcher) NoteAssistantInput ¶
NoteAssistantInput records that an assistant turn wrote into this terminal. The user's inputs are none of the job's business: steering is ownership, and only steer and release change it. What is recorded here serves the checks themselves. A terminal nobody steers is ignored, so this costs one lookup on the input path.
func (*Watcher) OnStateChange ¶
OnStateChange registers who hears about a change of ownership: steer, release, reopen, and every verdict that closes a job. Called with the job's project so a page can refresh just that project's fragments.
func (*Watcher) Recover ¶
func (w *Watcher) Recover(adopted []AdoptedCheck)
Recover picks up what a restart left behind. A check whose process outlived the restart is handed over by the service and simply carried on, and only the jobs whose check really died are checked again: the signal that started that check is spent, its inbox file is gone, and nothing will ever repeat it, so the job is the only one who knows. That is why a running check is written on the job at all.
The provider session such a dead check left behind is swept by the caller, which owns the coders, see IsCheckSession.
func (*Watcher) Release ¶
Release calls a job off. It stays visible in that state, so the user can see what happened to it instead of finding it gone. A check that is running on the job right now is killed: release takes the actor away, and a dead check writes nothing and costs nothing more.
func (*Watcher) RunHeartbeat ¶
RunHeartbeat looks at the open jobs for as long as the process lives. Blocks; run it in a goroutine.
func (*Watcher) Steer ¶
Steer starts a job on a terminal. The criterion may be empty: the check then judges against the task the session itself was given, and every place that shows the criterion says so (DoneWhenLine). Whether empty is allowed is the caller's rule, not this one's: the page may leave it out, the assistant's own coder-steer command may not, and the jobs handler enforces that at the door. A criterion over its bound is refused, never cut: it is what decides that a job is done, and a check against a criterion that ends mid-sentence judges against half a sentence without anybody knowing.
func (*Watcher) SweepExpired ¶
func (w *Watcher) SweepExpired()
SweepExpired ends every job whose time or budget is used up. It is the same report a signal would have produced, written without one: a job ends most often by going quiet, and every gate used to sit behind a signal, so the one ending nobody can hear was the one that mattered. The heartbeat runs this first on every pass, which is what makes that report happen without a signal.
type Workspace ¶
type Workspace struct {
// contains filtered or unexported fields
}
Workspace owns the assistant's directories and its memory. The conversations themselves live in the Service built by New.
func (*Workspace) CockpitCommand ¶
CockpitCommand spells out one of the cockpit's own commands the way a turn has to call it: the absolute path of the binary that is running, the assistant command group, and the directories of this instance. A bare `dev-cockpit` depends on a PATH a turn does not control, and on a machine with several instances it would reach whichever one owns the default state directory, which is the wrong cockpit and possibly somebody else's terminal.
func (*Workspace) DeleteMemory ¶
DeleteMemory drops one entry and rebuilds the instruction files.
func (*Workspace) MemoryEntry ¶
MemoryEntry loads one entry.
func (*Workspace) ProjectNameFor ¶
ProjectNameFor implements conversation.Projects. The assistant has no project, and an empty name keeps the project badge off its pages.
func (*Workspace) ResolveWorkspaceFile ¶
ResolveWorkspaceFile turns a path an answer mentions into an absolute path inside the workspace. Anything pointing outside is refused, so a rendered answer can never link to a file the assistant does not own.
func (*Workspace) SaveMemory ¶
SaveMemory writes one entry and rebuilds the instruction files. A new entry takes its file name from the title, an existing one keeps its name so the links the assistant wrote to it stay valid.
func (*Workspace) SaveUpload ¶
SaveUpload stores one file of a message and classifies it for the browser.
func (*Workspace) Sync ¶
Sync rebuilds the generated instruction files from the memory directory. It writes only on a real change, so an unchanged memory does not touch the files a coder watches.
func (*Workspace) ValidatePath ¶
ValidatePath implements conversation.Projects. The assistant is bound to its workspace and to nothing else, so an empty binding resolves to it and any other path is refused.