openvibers/openvibe.link Commits
Link's window: the daemon reports the CLIs and the Codes tasks here (#130)
Changes
@@@ -101,10 +101,10 @@ plainly which role it could not honour. The local configuration always wins: a rturns on a feature you turned off here.**Link's own window:** the tray's **Open Link** (or `openvibe-node panel`) opens a small page on the machine itself--at `127.0.0.1` that shows what runs here, what this computer allows, your shares and what happened, and lets you pause--everything, stop one thing, turn a capability off, share a port, or send files (not in this version). It is served--only on loopback, with a per-run token in the address fragment that every API call must send; see--[docs/panel.md](docs/panel.md) for the security rules.++at `127.0.0.1` that shows what runs here, what this computer allows, your shares, the coding-agent CLIs and the Codes++tasks this machine ran today, and what happened, and lets you pause everything, stop one thing, turn a capability off,++share a port, or send files (not in this version). It is served only on loopback, with a per-run token in the address++fragment that every API call must send; see [docs/panel.md](docs/panel.md) for the security rules.Docs: [protocol](docs/protocol.md) (the wire format with OpenVibe.Bot), [plugins](docs/plugins.md) (the driverprotocol, the bundled drivers, and where Actor's computer-control plugin attaches), [install](docs/install.md),@@@ -31,9 +31,10 @@ A restart makes a new port and a new token: an old link stops working.| Call | What it does | Today ||---|---|---|--| `GET /panel/api/state` | Everything the page shows | Machine, running (waiting approvals, jobs, connected shares, desks), capabilities, shares, activity |++| `GET /panel/api/state` | Everything the page shows | Machine (with its paired id), running (waiting approvals, jobs, connected shares, desks), capabilities, shares, Codes (the agent CLIs here and the worker's tasks today), activity || `POST /panel/api/pause` `{ "paused": true }` | The local kill switch, the same latch as `openvibe-node stop` / the tray | Real || `POST /panel/api/running/:id/stop` | Stops one running thing | Waiting approvals (deny), jobs (cancel), shares (end) |++| `POST /panel/api/codes/tasks/:id/stop` | Stops a local Codes task | Real: cancels the Run job in the worker, the same way Codes' own stop does; `409` when it is not running here || `POST /panel/api/capabilities/:id` `{ "on": false }` | Turns a capability off here | Real; persisted in `<state>/panel.json` || `POST /panel/api/shares` `{ "port": 3000 }` | Shares a port through Connect | Real for a share Connect already picked for this computer || `DELETE /panel/api/shares/:port` | Ends that share | Real |@@@ -44,6 +45,12 @@ A restart makes a new port and a new token: an old link stops working.`gpu` (no GPU detection), and `earnings_today`. `activity` is the Node's own record of what it saw (an approvalwaiting or answered, a pause, a share, a capability), newest first, at most 30.++`codes` is the desktop's Codes module: `agents` are the coding CLIs on this machine, read the way OpenVibe.Codes reads++them (installed on PATH, signed in by the CLI's own login file, the version the binary reports); `tasks` are the jobs++the Codes worker ran here today, kept in `<state>/codes-tasks.json` so an ended task still shows after a restart. A++folder under the home directory is written as `~/…`, and a task's link is only ever++`https://openvibe.codes/tasks/<id>`.++## CapabilitiesEach card is a permission switch the daemon already has, from the `computer` plugin's `describe` (`terminal`, `files`,@@@ -94,8 +94,13 @@ func (n *Node) job(r protocol.JobRequest) {n.log.Info("job accepted", "id", r.Job.ID, "class", r.Job.Class)}n.send(answer)-- if accepted && n.worker != nil {-- n.worker.Launch(r.Job.ID)++ if accepted {++ if n.codes != nil {++ n.codes.accepted(r.Job)++ }++ if n.worker != nil {++ n.worker.Launch(r.Job.ID)++ }}}@@@ -103,6 +108,12 @@ func (n *Node) job(r protocol.JobRequest) {func (n *Node) sendJobFrame(m protocol.Message) {if e, ok := m.(protocol.JobExit); ok {n.jobs.ended(e)++ if n.codes != nil {++ n.codes.ended(e)++ }++ }++ if s, ok := m.(protocol.JobStarted); ok && n.codes != nil {++ n.codes.started(s.ID, s.StartedMS)}n.send(m)}@@@ -66,6 +66,8 @@ type Options struct {LinkBackoffMin time.DurationVideoLoopback boolLatchPoll time.Duration++ // CodesAgents replaces the probe of this machine's coding-agent CLIs (for tests).++ CodesAgents func() []panel.CodesAgent}// Node is a running device agent.@@@ -144,6 +146,9 @@ type Node struct {panelOff map[string]boolpanelFirst *panelFirstRunactivity []panel.Activity++ // codes is what the Codes worker ran here today; codesAgents caches the coding-agent CLIs the window shows.++ codes *codesTaskState++ codesAgents codesAgentsCache// shares keeps OpenVibe Connect shares up and lets the window add or end one.shares *shareSet}@@@ -189,6 +194,9 @@ func New(opt Options) (*Node, error) {n := &Node{opt: opt, log: opt.Log, latch: latch, dedup: safety.NewDedup(4096, 10*time.Minute),jobs: newJobs(nil), telemetry: map[string]map[string]any{}, telemMS: 500, approvals: map[string]Approval{}, inputs: map[string]bool{},panelOff: panelOff, panelFirst: panelFirst}++ home, _ := os.UserHomeDir()++ n.codes = loadCodesTasks(opt.Paths.StateDir, home, opt.Log)++ n.codesAgents.fn = opt.CodesAgentsn.limits = safety.Merge(protocol.Limits{}, opt.Config.Limits.MaxSpeed, opt.Config.Limits.MaxTurn, opt.Config.Limits.MaxCommandMS)var specs []plugins.Spec@@@ -21,6 +21,7 @@ import ("github.com/OpenVibers/OpenVibe.Node/internal/fakebot""github.com/OpenVibers/OpenVibe.Node/internal/link""github.com/OpenVibers/OpenVibe.Node/internal/localctl"++ "github.com/OpenVibers/OpenVibe.Node/internal/panel""github.com/OpenVibers/OpenVibe.Node/internal/plugins""github.com/OpenVibers/OpenVibe.Node/internal/protocol""github.com/OpenVibers/OpenVibe.Node/internal/safety"@@@ -102,7 +103,8 @@ func startWith(t *testing.T, prep func(*fakebot.Server), ready bool) *env {n, err := New(Options{Config: cfg, Paths: paths, Creds: creds, Version: "test", HeartbeatInterval: 100 * time.Millisecond,LinkBackoffMin: 50 * time.Millisecond, VideoLoopback: true, LatchPoll: 50 * time.Millisecond,-- Log: slog.New(slog.NewTextHandler(io.Discard, nil))})++ Log: slog.New(slog.NewTextHandler(io.Discard, nil)),++ CodesAgents: func() []panel.CodesAgent { return nil }})if err != nil {t.Fatal(err)}@@@ -127,6 +127,7 @@ func (n *Node) startPanel(ctx context.Context) {Share: n.shareAdd,Unshare: n.shareRemove,FirstRun: n.applyFirstRun,++ CodesStop: n.panelStopCodesTask,Log: n.log,})if err != nil {@@@ -164,6 +165,7 @@ func (n *Node) panelState() panel.State {host, _ := os.Hostname()out := panel.State{Machine: &panel.Machine{++ ID: machineID(st),Name: host,OS: runtime.GOOS,Online: st.Link.Connected,@@@ -173,6 +175,7 @@ func (n *Node) panelState() panel.State {Running: n.panelRunning(st),Capabilities: n.panelCapabilities(st),Shares: n.panelShares(st),++ Codes: n.panelCodes(),}n.mu.Lock()out.Activity = append([]panel.Activity(nil), n.activity...)@@@ -181,6 +184,14 @@ func (n *Node) panelState() panel.State {return out}++// machineID is the id Codes preselects this computer by: the paired Bot device, or the Network principal before that.++func machineID(st Status) string {++ if st.DeviceID != "" {++ return st.DeviceID++ }++ return st.Principal++}++// panelRunning lists what the tray already shows as running or waiting: approvals, jobs, connected shares and desks.func (n *Node) panelRunning(st Status) []panel.Running {out := []panel.Running{}nnew file mode 100644@@@ -0,0 +1,552 @@++package node++++// The Codes half of Link's window: the coding-agent CLIs on this machine, and the Codes tasks its worker runs. Both++// are local facts. The agents are read the way OpenVibe.Codes reads them (on PATH, signed in by the CLI's own login++// file, the version the binary reports); the tasks are the jobs the Codes worker accepted here, kept under the Node's++// state directory so a task that ended today is still shown after a restart. Nothing here reads a token or a key: a++// login file is only checked for a usable session, never reported.++++import (++ "context"++ "encoding/json"++ "errors"++ "log/slog"++ "net/url"++ "os"++ "os/exec"++ "path/filepath"++ "regexp"++ "sort"++ "strings"++ "sync"++ "time"++ "unicode/utf8"++++ "github.com/OpenVibers/OpenVibe.Node/internal/panel"++ "github.com/OpenVibers/OpenVibe.Node/internal/protocol"++)++++const (++ // codesArtifact is the OpenVibe.Codes job runner's artifact name: the jobs that are coding tasks.++ codesArtifact = "openvibe-codes"++ // codesTasksName is the file that keeps the Codes tasks this worker ran, under the Node's state directory.++ codesTasksName = "codes-tasks.json"++ // codesTaskMax bounds what that file holds; the window lists what happened today, newest first.++ codesTaskMax = 50++ // codesTitleMax is the design's title: the first line of the prompt, up to 80 characters.++ codesTitleMax = 80++ // codesRefresh is how often the agent chips are probed. A login does not change in seconds, and each probe asks++ // the installed binaries for their version.++ codesRefresh = time.Minute++ // codesURL is the only origin a task link may have.++ codesURL = "https://openvibe.codes/tasks/"++)++++// codesAgentSpec is one coding-agent CLI on the machine, and how OpenVibe.Codes finds it: the binary on PATH, the++// login file its CLI writes, and the command that signs it in. A CLI with no login file (dsh reads its provider++// settings from the machine) is ready once its binary is there.++type codesAgentSpec struct {++ ID string++ Name string++ Bin string++ Login string++ Hint string++}++++// codesAgentSpecs are the CLIs the desktop's Codes module drives (domains plan §13.1).++var codesAgentSpecs = []codesAgentSpec{++ {"claude-code", "Claude Code", "claude", ".claude/.credentials.json", "claude auth login"},++ {"codex", "Codex", "codex", ".codex/auth.json", "codex login"},++ {"opencode", "OpenCode", "opencode", ".local/share/opencode/auth.json", "opencode auth login"},++ // Gemini CLI signs in from inside the CLI with /auth; `gemini` is the command that starts it.++ {"gemini", "Gemini CLI", "gemini", ".gemini/oauth_creds.json", "gemini"},++ // dsh keeps its model settings in its profile, not in a plan login: installed is as much as is known here.++ {"dsh", "DeepSeek Harness", "dsh", "", ""},++}++++// codesProbe is what detection needs from the machine, so a test can fake it.++type codesProbe struct {++ Home string++ LookPath func(string) (string, error)++ ReadFile func(string) ([]byte, error)++ Version func(string) string++ // OpenCodeAuth asks OpenCode itself whether it holds a credential. OpenCode moved its logins from auth.json into++ // its own store, so the file is not the truth on every version. known is false when the CLI cannot answer.++ OpenCodeAuth func(bin string) (signedIn, known bool)++}++++var codesVersionNumber = regexp.MustCompile(`\d+\.\d+(?:[.\-+][0-9A-Za-z.\-]+)*`)++++// detectCodesAgents reads the CLIs: missing when its binary is not on PATH, signin when it is there but its login++// file holds no usable session, ready otherwise. A version is the first number the binary's own --version reports++// (never read from a file of ours).++func detectCodesAgents(p codesProbe) []panel.CodesAgent {++ if p.Home == "" {++ p.Home, _ = os.UserHomeDir()++ }++ if p.LookPath == nil {++ p.LookPath = exec.LookPath++ }++ if p.ReadFile == nil {++ p.ReadFile = os.ReadFile++ }++ if p.Version == nil {++ p.Version = func(bin string) string { return codesVersion(bin) }++ }++ out := make([]panel.CodesAgent, 0, len(codesAgentSpecs))++ for _, spec := range codesAgentSpecs {++ a := panel.CodesAgent{ID: spec.ID, Name: spec.Name, State: "missing"}++ bin, err := p.LookPath(spec.Bin)++ if err != nil || bin == "" {++ // A per-user install in ~/.local/bin is not on a service's PATH; that is still the CLI on this machine.++ candidate := filepath.Join(p.Home, ".local", "bin", spec.Bin)++ if fi, statErr := os.Stat(candidate); statErr == nil && !fi.IsDir() {++ bin = candidate++ }++ }++ if bin == "" {++ out = append(out, a)++ continue++ }++ a.State = "ready"++ if spec.Login != "" {++ raw, readErr := p.ReadFile(filepath.Join(p.Home, filepath.FromSlash(spec.Login)))++ signed := readErr == nil && codesSignedIn(raw)++ if spec.ID == "opencode" && p.OpenCodeAuth != nil {++ if byCLI, known := p.OpenCodeAuth(bin); known {++ signed = byCLI++ }++ }++ if !signed {++ a.State, a.Hint = "signin", spec.Hint++ }++ }++ if p.Version != nil {++ a.Version = p.Version(bin)++ }++ out = append(out, a)++ }++ return out++}++++// codesSignedIn says whether a CLI's login file holds a usable session. It reads the same keys OpenVibe.Codes' logins++// probe reads (and Claude Code's own nested claudeAiOauth object) and never reports the file's content.++func codesSignedIn(raw []byte) bool {++ var login map[string]any++ if json.Unmarshal(raw, &login) != nil || login == nil {++ return false++ }++ if usableSession(login) {++ return true++ }++ // Claude Code 2.x keeps the plan session nested: { "claudeAiOauth": { "accessToken": …, "refreshToken": … } }.++ if nested, ok := login["claudeAiOauth"].(map[string]any); ok && usableSession(nested) {++ return true++ }++ return false++}++++func usableSession(login map[string]any) bool {++ for _, key := range []string{"access_token", "accessToken", "api_key", "apiKey", "refresh_token", "refreshToken", "token", "tokens", "account"} {++ switch v := login[key].(type) {++ case nil:++ continue++ case string:++ if strings.TrimSpace(v) != "" {++ return true++ }++ case map[string]any:++ if len(v) > 0 {++ return true++ }++ case []any:++ if len(v) > 0 {++ return true++ }++ default:++ return true++ }++ }++ return false++}++++// codesVersion asks the binary about itself, with a deadline: a CLI that does not answer must not hold the window.++func codesVersion(bin string) string {++ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)++ defer cancel()++ out, err := exec.CommandContext(ctx, bin, "--version").CombinedOutput()++ if err != nil && len(out) == 0 {++ return ""++ }++ line := strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0])++ if m := codesVersionNumber.FindString(line); m != "" {++ line = m++ }++ if len(line) > 40 {++ line = line[:40]++ }++ return line++}++++// openCodeAuth asks OpenCode's own store, because a signed-in OpenCode keeps its credential in opencode.db, not in++// auth.json. Only whether a credential is stored is read; the answer itself never leaves this function.++func openCodeAuth(bin string) (signedIn, known bool) {++ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)++ defer cancel()++ out, err := exec.CommandContext(ctx, bin, "auth", "list").CombinedOutput()++ if err != nil && len(out) == 0 {++ return false, false++ }++ for _, line := range strings.Split(string(out), "\n") {++ if strings.Contains(strings.ToLower(line), "stored") {++ return true, true++ }++ }++ return false, true++}++++// codesAgentsCache keeps the last probe, so a window polled every three seconds does not run five binaries each time.++type codesAgentsCache struct {++ mu sync.Mutex++ at time.Time++ agents []panel.CodesAgent++ fn func() []panel.CodesAgent // a test's fake, or nil for the machine++}++++func (c *codesAgentsCache) latest(real func() []panel.CodesAgent) []panel.CodesAgent {++ c.mu.Lock()++ defer c.mu.Unlock()++ fn := c.fn++ if fn == nil {++ fn = real++ }++ if c.at.IsZero() || time.Since(c.at) >= codesRefresh {++ c.agents, c.at = fn(), time.Now()++ }++ return append([]panel.CodesAgent(nil), c.agents...)++}++++// codesTaskRecord is one task the Codes worker ran here, as it is kept between runs.++type codesTaskRecord struct {++ ID string `json:"id"`++ Job string `json:"job,omitempty"`++ Title string `json:"title,omitempty"`++ Folder string `json:"folder,omitempty"`++ Agent string `json:"agent,omitempty"`++ State string `json:"state"`++ StartedAt time.Time `json:"started_at,omitempty"`++ UpdatedAt time.Time `json:"updated_at,omitempty"`++}++++type codesTasksFile struct {++ Tasks []*codesTaskRecord `json:"tasks"`++}++++// codesTaskState is the Codes worker's local record: the tasks it runs or ran today.++type codesTaskState struct {++ mu sync.Mutex++ dir string++ home string++ log *slog.Logger++ tasks []*codesTaskRecord++}++++// loadCodesTasks reads today's tasks. A task left running or waiting by a previous process is stopped: the job that++// held it did not survive the restart.++func loadCodesTasks(dir, home string, log *slog.Logger) *codesTaskState {++ s := &codesTaskState{dir: dir, home: home, log: log}++ if dir == "" {++ return s++ }++ raw, err := os.ReadFile(filepath.Join(dir, codesTasksName))++ if err != nil {++ return s++ }++ var f codesTasksFile++ if json.Unmarshal(raw, &f) != nil {++ return s++ }++ now := time.Now().UTC()++ for _, t := range f.Tasks {++ if t == nil || !validCodesTaskID(t.ID) || !codesTaskStates[t.State] {++ continue++ }++ if t.State == "running" || t.State == "waiting" {++ t.State, t.UpdatedAt = "stopped", now++ }++ s.tasks = append(s.tasks, t)++ }++ s.pruneLocked(now)++ return s++}++++func (s *codesTaskState) accepted(j protocol.Job) {++ if !codesJob(j) {++ return++ }++ var args struct {++ TaskID string `json:"task_id"`++ Prompt string `json:"prompt"`++ Folder string `json:"folder"`++ Agent string `json:"agent"`++ }++ _ = json.Unmarshal(j.Args, &args)++ id := j.ID++ if codesContractTaskID.MatchString(args.TaskID) {++ id = args.TaskID++ }++ now := time.Now().UTC()++ s.mu.Lock()++ defer s.mu.Unlock()++ t := s.findLocked(id)++ if t == nil {++ t = &codesTaskRecord{ID: id, StartedAt: now}++ s.tasks = append(s.tasks, t)++ }++ t.Job = j.ID++ t.State = "waiting"++ t.UpdatedAt = now++ if title := codesTitle(args.Prompt); title != "" {++ t.Title = title++ }++ if folder := cleanCodesFolder(args.Folder); folder != "" {++ t.Folder = folder++ }++ if agent := cleanCodesAgent(args.Agent); agent != "" {++ t.Agent = agent++ }++ s.saveLocked(now)++}++++func (s *codesTaskState) started(jobID string, ms int64) {++ s.mu.Lock()++ defer s.mu.Unlock()++ t := s.byJobLocked(jobID)++ if t == nil {++ return++ }++ t.State = "running"++ if ms > 0 {++ t.StartedAt = time.UnixMilli(ms).UTC()++ }++ t.UpdatedAt = time.Now().UTC()++ s.saveLocked(t.UpdatedAt)++}++++func (s *codesTaskState) ended(e protocol.JobExit) {++ s.mu.Lock()++ defer s.mu.Unlock()++ t := s.byJobLocked(e.ID)++ if t == nil {++ return++ }++ t.State = codesEndState(e)++ t.UpdatedAt = time.Now().UTC()++ s.saveLocked(t.UpdatedAt)++}++++// runningJob is the live worker job for a task, by the task's id or by its job id. Only running or waiting tasks are++// still this machine's to stop.++func (s *codesTaskState) runningJob(id string) (string, bool) {++ s.mu.Lock()++ defer s.mu.Unlock()++ for _, t := range s.tasks {++ if (t.ID == id || t.Job == id) && (t.State == "running" || t.State == "waiting") && t.Job != "" {++ return t.Job, true++ }++ }++ return "", false++}++++// list is what the window shows: today's tasks, newest first, the folder with the home directory as ~.++func (s *codesTaskState) list() []panel.CodesTask {++ s.mu.Lock()++ defer s.mu.Unlock()++ sort.SliceStable(s.tasks, func(i, j int) bool { return codesTaskTime(s.tasks[i]).After(codesTaskTime(s.tasks[j])) })++ out := make([]panel.CodesTask, 0, len(s.tasks))++ for _, t := range s.tasks {++ ct := panel.CodesTask{ID: t.ID, Title: t.Title, Folder: shortHome(t.Folder, s.home), Agent: t.Agent,++ State: t.State, StartedAt: t.StartedAt}++ if strings.HasPrefix(t.ID, "tsk_") {++ ct.URL = codesURL + url.PathEscape(t.ID)++ }++ out = append(out, ct)++ }++ return out++}++++func (s *codesTaskState) findLocked(id string) *codesTaskRecord {++ for _, t := range s.tasks {++ if t.ID == id {++ return t++ }++ }++ return nil++}++++func (s *codesTaskState) byJobLocked(jobID string) *codesTaskRecord {++ for _, t := range s.tasks {++ if t.Job == jobID {++ return t++ }++ }++ return nil++}++++// pruneLocked keeps what runs now and what ran today (by when it started or last changed), newest first, at most++// codesTaskMax. It does not save: its callers do.++func (s *codesTaskState) pruneLocked(now time.Time) {++ kept := s.tasks[:0]++ for _, t := range s.tasks {++ if t.State == "running" || t.State == "waiting" || sameCodesDay(t.StartedAt, now) || sameCodesDay(t.UpdatedAt, now) {++ kept = append(kept, t)++ }++ }++ sort.SliceStable(kept, func(i, j int) bool { return codesTaskTime(kept[i]).After(codesTaskTime(kept[j])) })++ if len(kept) > codesTaskMax {++ kept = kept[:codesTaskMax]++ }++ s.tasks = kept++}++++func sameCodesDay(a, b time.Time) bool {++ a, b = a.Local(), b.Local()++ ay, am, ad := a.Date()++ by, bm, bd := b.Date()++ return ay == by && am == bm && ad == bd++}++++func (s *codesTaskState) saveLocked(now time.Time) {++ s.pruneLocked(now)++ if s.dir == "" {++ return++ }++ f := filepath.Join(s.dir, codesTasksName)++ b, err := json.MarshalIndent(codesTasksFile{Tasks: s.tasks}, "", " ")++ if err != nil {++ s.warn("Codes tasks not saved", err)++ return++ }++ if err := os.MkdirAll(s.dir, 0o700); err != nil {++ s.warn("Codes tasks not saved", err)++ return++ }++ tmp := f + ".tmp"++ if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil {++ s.warn("Codes tasks not saved", err)++ return++ }++ if err := os.Rename(tmp, f); err != nil {++ s.warn("Codes tasks not saved", err)++ }++}++++func (s *codesTaskState) warn(msg string, err error) {++ if s.log != nil {++ s.log.Warn(msg, "err", err)++ }++}++++var codesTaskStates = map[string]bool{"running": true, "waiting": true, "done": true, "failed": true, "stopped": true}++var codesTaskID = regexp.MustCompile(`^[A-Za-z0-9_.:-]{1,120}$`)++var codesContractTaskID = regexp.MustCompile(`^tsk_[0-9A-HJKMNP-TV-Z]{26}$`)++++func validCodesTaskID(id string) bool { return codesTaskID.MatchString(id) }++++func codesJob(j protocol.Job) bool {++ return j.Artifact != nil && j.Artifact.Name == codesArtifact++}++++func codesEndState(e protocol.JobExit) string {++ switch e.Reason {++ case protocol.ExitStopped, protocol.ExitCancelled:++ return "stopped"++ case protocol.ExitExited:++ if e.Code != nil && *e.Code == 0 {++ return "done"++ }++ }++ return "failed"++}++++// codesTitle is the first line of the prompt, at most 80 characters. The prompt itself stays in the job.++func codesTitle(prompt string) string {++ line := strings.TrimSpace(strings.SplitN(strings.ReplaceAll(prompt, "\r\n", "\n"), "\n", 2)[0])++ line = strings.Join(strings.Fields(line), " ")++ if utf8.RuneCountInString(line) > codesTitleMax {++ r := []rune(line)++ line = string(r[:codesTitleMax])++ }++ return line++}++++func cleanCodesFolder(folder string) string {++ folder = strings.TrimSpace(folder)++ if folder == "" || len(folder) > 4096 || strings.ContainsAny(folder, "\x00\r\n") {++ return ""++ }++ return folder++}++++func cleanCodesAgent(agent string) string {++ agent = strings.TrimSpace(agent)++ if len(agent) > 40 {++ agent = agent[:40]++ }++ return agent++}++++// shortHome shows a folder under the person's home as ~/…; another folder is left as it is.++func shortHome(folder, home string) string {++ if folder == "" || home == "" || home == string(filepath.Separator) {++ return folder++ }++ home = filepath.Clean(home)++ if home == string(filepath.Separator) {++ return folder++ }++ clean := filepath.Clean(folder)++ if clean == home {++ return "~"++ }++ if strings.HasPrefix(clean, home+string(filepath.Separator)) {++ return "~" + clean[len(home):]++ }++ return folder++}++++func codesTaskTime(t *codesTaskRecord) time.Time {++ if !t.StartedAt.IsZero() {++ return t.StartedAt++ }++ return t.UpdatedAt++}++++// panelCodes is the window's Codes section: the CLIs here and the tasks this worker ran today.++func (n *Node) panelCodes() *panel.Codes {++ c := &panel.Codes{Agents: n.codesAgents.latest(n.probeCodesAgents)}++ if n.codes != nil {++ c.Tasks = n.codes.list()++ }++ return c++}++++// probeCodesAgents is the real detection: this machine's home and PATH, the binaries' own --version.++func (n *Node) probeCodesAgents() []panel.CodesAgent {++ home, _ := os.UserHomeDir()++ return detectCodesAgents(codesProbe{Home: home, LookPath: exec.LookPath, ReadFile: os.ReadFile,++ Version: codesVersion, OpenCodeAuth: openCodeAuth})++}++++// panelStopCodesTask stops the local run the way Codes' own stop does: cancel its Run job here, which kills the++// worker's process and makes its job_exit say cancelled. A task that is not running on this computer is refused.++func (n *Node) panelStopCodesTask(id string) error {++ if n.codes == nil {++ return errors.New("that task is not running on this computer")++ }++ jobID, ok := n.codes.runningJob(id)++ if !ok || n.worker == nil || !n.worker.Cancel(jobID) {++ return errors.New("that task is not running on this computer")++ }++ return nil++}nnew file mode 100644@@@ -0,0 +1,179 @@++package node++++import (++ "encoding/json"++ "errors"++ "os"++ "path/filepath"++ "strings"++ "testing"++ "time"++++ "github.com/OpenVibers/OpenVibe.Node/internal/panel"++ "github.com/OpenVibers/OpenVibe.Node/internal/protocol"++)++++// The three states the window shows, from a fake machine: ready with a version, sign in with the exact command, and++// not installed.++func TestDetectCodesAgentsReportsReadySignInAndMissing(t *testing.T) {++ home := t.TempDir()++ login := filepath.Join(home, ".claude", ".credentials.json")++ if err := os.MkdirAll(filepath.Dir(login), 0o700); err != nil {++ t.Fatal(err)++ }++ if err := os.WriteFile(login, []byte(`{"claudeAiOauth":{"accessToken":"not-a-real-token","refreshToken":"also-not-real"}}`), 0o600); err != nil {++ t.Fatal(err)++ }++ probe := codesProbe{++ Home: home,++ LookPath: func(bin string) (string, error) {++ switch bin {++ case "claude", "codex", "dsh":++ return "/usr/bin/" + bin, nil++ }++ return "", errors.New("not on PATH")++ },++ ReadFile: os.ReadFile,++ Version: func(string) string { return "1.2.3" },++ }++ got := map[string]panel.CodesAgent{}++ for _, a := range detectCodesAgents(probe) {++ got[a.ID] = a++ }++ if a := got["claude-code"]; a.State != "ready" || a.Version != "1.2.3" || a.Hint != "" {++ t.Fatalf("claude-code: %+v", a)++ }++ if a := got["codex"]; a.State != "signin" || a.Hint != "codex login" {++ t.Fatalf("codex: %+v", a)++ }++ if a := got["opencode"]; a.State != "missing" {++ t.Fatalf("opencode: %+v", a)++ }++ if a := got["gemini"]; a.State != "missing" {++ t.Fatalf("gemini: %+v", a)++ }++ // dsh has no plan login of its own: installed is the fact.++ if a := got["dsh"]; a.State != "ready" || a.Hint != "" {++ t.Fatalf("dsh: %+v", a)++ }++}++++// A login file that is there but has no usable session is sign-in, never ready.++func TestCodesLoginFileWithoutASessionIsSignIn(t *testing.T) {++ if codesSignedIn([]byte(`{}`)) || codesSignedIn([]byte(`not json`)) || codesSignedIn([]byte(`{"access_token":""}`)) {++ t.Fatal("an empty login was taken as signed in")++ }++ if !codesSignedIn([]byte(`{"refresh_token":"x"}`)) || !codesSignedIn([]byte(`{"tokens":{"a":"b"}}`)) ||++ !codesSignedIn([]byte(`{"claudeAiOauth":{"accessToken":"x"}}`)) {++ t.Fatal("a usable login was not recognised")++ }++}++++func TestCodesFolderUsesTildeAndTheTitleIsTheFirstLine(t *testing.T) {++ home := t.TempDir()++ dir := t.TempDir()++ s := loadCodesTasks(dir, home, nil)++ args, err := json.Marshal(map[string]any{++ "task_id": "tsk_01M6Q1Z8V4T7R2N5K3B9C6D0EF",++ "prompt": strings.Repeat("A very long first line ", 8) + "\nsecond line",++ "folder": filepath.Join(home, "code", "site"),++ "agent": "claude-code",++ })++ if err != nil {++ t.Fatal(err)++ }++ s.accepted(protocol.Job{ID: "job_1", Artifact: &protocol.Artifact{Name: codesArtifact}, Args: args})++ tasks := s.list()++ if len(tasks) != 1 {++ t.Fatalf("tasks: %+v", tasks)++ }++ got := tasks[0]++ if got.Folder != filepath.Join("~", "code", "site") {++ t.Fatalf("folder %q", got.Folder)++ }++ if len([]rune(got.Title)) != codesTitleMax || strings.Contains(got.Title, "second line") {++ t.Fatalf("title %q", got.Title)++ }++ if got.Agent != "claude-code" || got.State != "waiting" || got.StartedAt.IsZero() {++ t.Fatalf("task %+v", got)++ }++ if got.URL != "https://openvibe.codes/tasks/tsk_01M6Q1Z8V4T7R2N5K3B9C6D0EF" {++ t.Fatalf("url %q", got.URL)++ }++ if shortHome(home, home) != "~" || shortHome("/elsewhere/x", home) != "/elsewhere/x" {++ t.Fatal("shortHome changed a folder outside the home")++ }++}++++func TestCodesTaskGoesFromWaitingToRunningToDoneAndStays(t *testing.T) {++ dir := t.TempDir()++ s := loadCodesTasks(dir, "/home/alex", nil)++ args, _ := json.Marshal(map[string]any{"task_id": "tsk_01M6Q1Z8V4T7R2N5K3B9C6D0EF", "prompt": "Fix the test"})++ s.accepted(protocol.Job{ID: "job_1", Artifact: &protocol.Artifact{Name: codesArtifact}, Args: args})++ if job, ok := s.runningJob("tsk_01M6Q1Z8V4T7R2N5K3B9C6D0EF"); !ok || job != "job_1" {++ t.Fatalf("waiting task is not stoppable: %q %v", job, ok)++ }++ started := time.Date(2026, 11, 7, 10, 2, 0, 0, time.UTC)++ s.started("job_1", started.UnixMilli())++ if got := s.list()[0]; got.State != "running" || !got.StartedAt.Equal(started) {++ t.Fatalf("running: %+v", got)++ }++ zero := 0++ s.ended(protocol.JobExit{ID: "job_1", Reason: protocol.ExitExited, Code: &zero})++ if got := s.list()[0]; got.State != "done" {++ t.Fatalf("done: %+v", got)++ }++ if _, ok := s.runningJob("tsk_01M6Q1Z8V4T7R2N5K3B9C6D0EF"); ok {++ t.Fatal("a done task is still stoppable")++ }++ // The record survives a restart, and a task left running by the old process is stopped, not left working.++ again := loadCodesTasks(dir, "/home/alex", nil)++ if got := again.list(); len(got) != 1 || got[0].State != "done" {++ t.Fatalf("reloaded: %+v", got)++ }++ again.accepted(protocol.Job{ID: "job_2", Artifact: &protocol.Artifact{Name: codesArtifact}, Args: args})++ if got := again.list()[0]; got.State != "waiting" {++ t.Fatalf("a second job for the task did not restart it: %+v", got)++ }++ restarted := loadCodesTasks(dir, "/home/alex", nil)++ if got := restarted.list()[0]; got.State != "stopped" {++ t.Fatalf("left-running task: %+v", got)++ }++}++++func TestCodesTaskEndStatesAndJobFilter(t *testing.T) {++ dir := t.TempDir()++ s := loadCodesTasks(dir, "/home/alex", nil)++ args, _ := json.Marshal(map[string]any{"prompt": "x"})++ s.accepted(protocol.Job{ID: "job_other", Artifact: &protocol.Artifact{Name: "thumbnail"}})++ s.accepted(protocol.Job{ID: "job_stop", Artifact: &protocol.Artifact{Name: codesArtifact}, Args: args})++ if len(s.list()) != 1 || s.list()[0].ID != "job_stop" {++ t.Fatalf("a job that is not a Codes task was recorded: %+v", s.list())++ }++ s.ended(protocol.JobExit{ID: "job_stop", Reason: protocol.ExitStopped})++ if got := s.list()[0].State; got != "stopped" {++ t.Fatalf("stopped: %q", got)++ }++ s.accepted(protocol.Job{ID: "job_fail", Artifact: &protocol.Artifact{Name: codesArtifact}, Args: args})++ one := 1++ s.ended(protocol.JobExit{ID: "job_fail", Reason: protocol.ExitExited, Code: &one})++ if got := s.list()[0].State; got != "failed" {++ t.Fatalf("failed: %q", got)++ }++}++++func TestStopCodesTaskRefusesWhatIsNotRunningHere(t *testing.T) {++ if err := (&Node{}).panelStopCodesTask("tsk_01M6Q1Z8V4T7R2N5K3B9C6D0EF"); err == nil {++ t.Fatal("a task was stopped with no worker")++ }++ s := loadCodesTasks(t.TempDir(), "/home/alex", nil)++ args, _ := json.Marshal(map[string]any{"task_id": "tsk_01M6Q1Z8V4T7R2N5K3B9C6D0EF", "prompt": "x"})++ s.accepted(protocol.Job{ID: "job_1", Artifact: &protocol.Artifact{Name: codesArtifact}, Args: args})++ n := &Node{codes: s}++ if err := n.panelStopCodesTask("tsk_01M6Q1Z8V4T7R2N5K3B9C6D0EF"); err == nil {++ t.Fatal("a task was stopped with no worker")++ }++ if err := n.panelStopCodesTask("tsk_unknown"); err == nil {++ t.Fatal("an unknown task was stopped")++ }++}@@@ -15,12 +15,14 @@ import ("github.com/OpenVibers/OpenVibe.Node/internal/config""github.com/OpenVibers/OpenVibe.Node/internal/connect""github.com/OpenVibers/OpenVibe.Node/internal/localctl"++ "github.com/OpenVibers/OpenVibe.Node/internal/panel""github.com/OpenVibers/OpenVibe.Node/internal/protocol")func panelTestNode(t *testing.T, paths config.Paths) *Node {t.Helper()-- n, err := New(Options{Config: &config.Config{}, Paths: paths, Log: slog.New(slog.NewTextHandler(io.Discard, nil))})++ n, err := New(Options{Config: &config.Config{}, Paths: paths, Log: slog.New(slog.NewTextHandler(io.Discard, nil)),++ CodesAgents: func() []panel.CodesAgent { return nil }})if err != nil {t.Fatal(err)}@@@ -107,6 +109,15 @@ func TestCapabilitySwitchIsARealRestriction(t *testing.T) {}}++func TestMachineIDIsThePairedDevice(t *testing.T) {++ if got := machineID(Status{DeviceID: "dev_1", Principal: "nod_1"}); got != "dev_1" {++ t.Fatalf("device id: %q", got)++ }++ if got := machineID(Status{Principal: "nod_1"}); got != "nod_1" {++ t.Fatalf("principal: %q", got)++ }++}++func TestPanelStateComesFromTheNode(t *testing.T) {dir, err := os.MkdirTemp("", "ovp")if err != nil {@@@ -114,6 +125,9 @@ func TestPanelStateComesFromTheNode(t *testing.T) {}defer os.RemoveAll(dir)n := panelTestNode(t, config.HomePaths(dir))++ n.codesAgents.fn = func() []panel.CodesAgent {++ return []panel.CodesAgent{{ID: "codex", Name: "Codex", State: "signin", Hint: "codex login"}}++ }if err := n.panelPause(true); err != nil {t.Fatal(err)}@@@ -121,6 +135,9 @@ func TestPanelStateComesFromTheNode(t *testing.T) {if st.Machine == nil || st.Machine.Name == "" || !st.Machine.Paused {t.Fatalf("machine: %+v", st.Machine)}++ if st.Codes == nil || len(st.Codes.Agents) != 1 || st.Codes.Agents[0].Hint != "codex login" {++ t.Fatalf("codes: %+v", st.Codes)++ }if len(st.Activity) == 0 || !strings.Contains(st.Activity[0].Text, "stopped") {t.Fatalf("activity: %+v", st.Activity)}@@@ -37,6 +37,8 @@ var ErrNotImplemented = errors.New("not implemented in this version of Link")// Machine is what the window's header shows.type Machine struct {++ // ID is the machine's own id (known after pairing), so "New task on this computer" can preselect it.++ ID string `json:"id,omitempty"`Name string `json:"name"`OS string `json:"os,omitempty"`Online bool `json:"online"`@@@ -168,7 +170,10 @@ type Options struct {// FirstRun records the answer to "What's this computer for?" and turns on what each use says. A skip only stops// the question.FirstRun func(uses []string, skip bool) error-- Log *slog.Logger++ // CodesStop stops the local Codes task with this id the way Codes' own stop does. It returns an error when the++ // task is not running on this computer.++ CodesStop func(id string) error++ Log *slog.Logger}// Server is one running panel server.@@@ -311,6 +316,21 @@ func (s *Server) api() http.Handler {return}writeJSON(w, http.StatusOK, map[string]string{"stopped": id})++ case strings.HasPrefix(path, "codes/tasks/") && strings.HasSuffix(path, "/stop") && r.Method == http.MethodPost:++ id := strings.TrimSuffix(strings.TrimPrefix(path, "codes/tasks/"), "/stop")++ if id == "" || strings.Contains(id, "/") {++ http.NotFound(w, r)++ return++ }++ if s.opt.CodesStop == nil {++ writeJSON(w, http.StatusServiceUnavailable, errBody("the panel cannot stop this task"))++ return++ }++ if err := s.opt.CodesStop(id); err != nil {++ writeJSON(w, http.StatusConflict, errBody(err.Error()))++ return++ }++ writeJSON(w, http.StatusOK, map[string]string{"stopped": id})case strings.HasPrefix(path, "capabilities/") && r.Method == http.MethodPost:id := strings.TrimPrefix(path, "capabilities/")if id == "" || strings.Contains(id, "/") {@@@ -145,7 +145,7 @@ func TestStateShapeMatchesTheDesign(t *testing.T) {}now := time.Date(2026, 11, 7, 10, 2, 0, 0, time.UTC)full := State{-- Machine: &Machine{Name: "Workshop desktop", OS: "linux", Online: true, Paused: false, NeedsYou: 1},++ Machine: &Machine{ID: "dev_1", Name: "Workshop desktop", OS: "linux", Online: true, Paused: false, NeedsYou: 1},Running: []Running{{ID: "r_1", Kind: "agent", Title: "t", Detail: "d", StartedAt: now}},Capabilities: []Capability{{ID: "files", Name: "n", Who: "w", On: true}},Shares: []Share{{Port: 3000, URL: "https://x", ExpiresAt: now}},@@@ -240,6 +240,45 @@ func TestStopCapabilityAndSharesReachTheDaemon(t *testing.T) {}}++func TestMachineCarriesItsOwnID(t *testing.T) {++ srv := testServer(t, Options{State: func() State { return State{Machine: &Machine{ID: "dev_1", Name: "here"}} }})++ w := do(t, srv, http.MethodGet, "/panel/api/state", testToken, "", "")++ if w.Code != http.StatusOK {++ t.Fatalf("state: %d %s", w.Code, w.Body)++ }++ var st State++ if err := json.Unmarshal(w.Body.Bytes(), &st); err != nil || st.Machine == nil || st.Machine.ID != "dev_1" {++ t.Fatalf("machine: %v %s", err, w.Body)++ }++}++++func TestCodesTaskStopNeedsTheTokenAndARunningTask(t *testing.T) {++ var stopped string++ srv := testServer(t, Options{CodesStop: func(id string) error {++ if id != "tsk_running" {++ return errors.New("that task is not running on this computer")++ }++ stopped = id++ return nil++ }})++ path := "/panel/api/codes/tasks/tsk_running/stop"++ if w := do(t, srv, http.MethodPost, path, "", "", ""); w.Code != http.StatusForbidden {++ t.Fatalf("no token: %d", w.Code)++ }++ if w := do(t, srv, http.MethodPost, path, testToken, "https://evil.example", ""); w.Code != http.StatusForbidden {++ t.Fatalf("wrong origin: %d", w.Code)++ }++ if w := do(t, srv, http.MethodPost, "/panel/api/codes/tasks/tsk_unknown/stop", testToken, srv.Origin(), ""); w.Code != http.StatusConflict {++ t.Fatalf("unknown task: %d %s", w.Code, w.Body)++ }++ if w := do(t, srv, http.MethodPost, path, testToken, srv.Origin(), ""); w.Code != http.StatusOK {++ t.Fatalf("running task: %d %s", w.Code, w.Body)++ }++ if stopped != "tsk_running" {++ t.Fatalf("stopped %q", stopped)++ }++}++func TestFirstRunIsRecordedAfterTokenAndUseChecks(t *testing.T) {var calls intvar gotUses []string@@@ -17,6 +17,7 @@ fragment: `/panel/#t=<token>`. The page sends that token as `X-Link-Panel` on ev| `GET /panel/api/state` | — | Everything the window shows (below). Polled every 3 s while the window is visible. || `POST /panel/api/pause` | `{ "paused": true }` | The local kill switch: it stops everything at once and always wins over any remote request. || `POST /panel/api/running/:id/stop` | — | Stops one running thing. |++| `POST /panel/api/codes/tasks/:id/stop` | — | Stops the local Codes task the window lists. A task that is not running on this computer is refused (`409`). || `POST /panel/api/capabilities/:id` | `{ "on": false }` | Turns a capability on or off. Turning one on is the consent step. || `POST /panel/api/shares` | `{ "port": 3000 }` | Shares a local port through Connect, behind the person's sign-in. || `DELETE /panel/api/shares/:port` | — | Ends that share. |@@@ -29,18 +30,26 @@ Every key is optional. A section with nothing in it is left out; the window neve```json{-- "machine": { "name": "Workshop desktop", "os": "linux", "online": true, "paused": false, "needs_you": 1 },++ "machine": { "id": "dev_01M6Q1Z8V4T7R2N5K3B9C6D0EF", "name": "Workshop desktop", "os": "linux", "online": true, "paused": false, "needs_you": 1 },"running": [{ "id": "r_1", "kind": "agent", "title": "Vibe is comparing three hosting plans", "detail": "Browser", "started_at": "2026-11-07T10:02:00Z" }],"capabilities": [{ "id": "browser", "name": "A browser for your agents", "who": "Your agents, after you approve each task", "on": true }],"shares": [{ "port": 3000, "url": "https://dev--alex.openvibe.website", "expires_at": "2026-11-07T22:00:00Z" }],"devices": [{ "name": "Workshop desktop", "kind": "desktop", "online": true, "here": true }],"gpu": { "name": "RTX 3090", "vram_gb": 24, "models": [{ "name": "Qwen2.5 14B", "tps": 41, "loaded": true }] },"earnings_today": { "coins": 120 },++ "codes": {++ "agents": [{ "id": "claude-code", "name": "Claude Code", "state": "ready", "version": "2.1.3" }, { "id": "codex", "name": "Codex", "state": "signin", "hint": "codex login" }],++ "tasks": [{ "id": "tsk_01M6Q1Z8V4T7R2N5K3B9C6D0EF", "title": "Make every page work at 320 px", "folder": "~/code/openvibe-site", "agent": "Claude Code", "state": "running", "started_at": "2026-11-07T09:52:00Z", "url": "https://openvibe.codes/tasks/tsk_01M6Q1Z8V4T7R2N5K3B9C6D0EF" }]++ },"activity": [{ "at": "2026-11-07T10:02:00Z", "text": "Vibe opened a browser to compare hosting plans" }]}```++- `machine.id` is the machine's own id after pairing, so "New task on this computer" can preselect it.- `running[].kind` is `agent`, `desk`, `share` or `model`.++- `codes.agents[].state` is `ready`, `signin` (then `hint` is the CLI's own sign-in command) or `missing`.++- `codes.tasks[]` are the Codes jobs this worker ran today: `state` is `running`, `waiting`, `done`, `failed` or++ `stopped`; `folder` shows the home directory as `~`; `url` is only ever `https://openvibe.codes/tasks/<id>`.- `machine.needs_you` counts what waits for the person: it puts a dot on the ring.- `first_run: true` shows the "What's this computer for?" question at the top, until it is answered or skipped.- The ring shows:@@@ -1,6 +1,7 @@{"first_run": true,"machine": {++ "id": "dev_01M6Q1Z8V4T7R2N5K3B9C6D0EF","name": "Workshop desktop","os": "linux","online": true,@@@ -123,6 +124,12 @@"id": "gemini","name": "Gemini CLI","state": "missing"++ },++ {++ "id": "dsh",++ "name": "DeepSeek Harness",++ "state": "ready",++ "version": "0.2.1"}],"tasks": [
@@ -101,10 +101,10 @@ plainly which role it could not honour. The local configuration always wins: a r
@@ -101,10 +101,10 @@ plainly which role it could not honour. The local configuration always wins: a r
turns on a feature you turned off here.
turns on a feature you turned off here.
**Link's own window:** the tray's **Open Link** (or `openvibe-node panel`) opens a small page on the machine itself
**Link's own window:** the tray's **Open Link** (or `openvibe-node panel`) opens a small page on the machine itself
-at `127.0.0.1` that shows what runs here, what this computer allows, your shares and what happened, and lets you pause
-everything, stop one thing, turn a capability off, share a port, or send files (not in this version). It is served
-only on loopback, with a per-run token in the address fragment that every API call must send; see
-[docs/panel.md](docs/panel.md) for the security rules.
+at `127.0.0.1` that shows what runs here, what this computer allows, your shares, the coding-agent CLIs and the Codes
+tasks this machine ran today, and what happened, and lets you pause everything, stop one thing, turn a capability off,
+share a port, or send files (not in this version). It is served only on loopback, with a per-run token in the address
+fragment that every API call must send; see [docs/panel.md](docs/panel.md) for the security rules.
Docs: [protocol](docs/protocol.md) (the wire format with OpenVibe.Bot), [plugins](docs/plugins.md) (the driver
Docs: [protocol](docs/protocol.md) (the wire format with OpenVibe.Bot), [plugins](docs/plugins.md) (the driver
protocol, the bundled drivers, and where Actor's computer-control plugin attaches), [install](docs/install.md),
protocol, the bundled drivers, and where Actor's computer-control plugin attaches), [install](docs/install.md),
@@ -31,9 +31,10 @@ A restart makes a new port and a new token: an old link stops working.
@@ -31,9 +31,10 @@ A restart makes a new port and a new token: an old link stops working.
| Call | What it does | Today |
| Call | What it does | Today |
|---|---|---|
|---|---|---|
-| `GET /panel/api/state` | Everything the page shows | Machine, running (waiting approvals, jobs, connected shares, desks), capabilities, shares, activity |
+| `GET /panel/api/state` | Everything the page shows | Machine (with its paired id), running (waiting approvals, jobs, connected shares, desks), capabilities, shares, Codes (the agent CLIs here and the worker's tasks today), activity |
| `POST /panel/api/pause` `{ "paused": true }` | The local kill switch, the same latch as `openvibe-node stop` / the tray | Real |
| `POST /panel/api/pause` `{ "paused": true }` | The local kill switch, the same latch as `openvibe-node stop` / the tray | Real |
| `POST /panel/api/running/:id/stop` | Stops one running thing | Waiting approvals (deny), jobs (cancel), shares (end) |
| `POST /panel/api/running/:id/stop` | Stops one running thing | Waiting approvals (deny), jobs (cancel), shares (end) |
+| `POST /panel/api/codes/tasks/:id/stop` | Stops a local Codes task | Real: cancels the Run job in the worker, the same way Codes' own stop does; `409` when it is not running here |
| `POST /panel/api/capabilities/:id` `{ "on": false }` | Turns a capability off here | Real; persisted in `<state>/panel.json` |
| `POST /panel/api/capabilities/:id` `{ "on": false }` | Turns a capability off here | Real; persisted in `<state>/panel.json` |
| `POST /panel/api/shares` `{ "port": 3000 }` | Shares a port through Connect | Real for a share Connect already picked for this computer |
| `POST /panel/api/shares` `{ "port": 3000 }` | Shares a port through Connect | Real for a share Connect already picked for this computer |
| `DELETE /panel/api/shares/:port` | Ends that share | Real |
| `DELETE /panel/api/shares/:port` | Ends that share | Real |
@@ -44,6 +45,12 @@ A restart makes a new port and a new token: an old link stops working.
@@ -44,6 +45,12 @@ A restart makes a new port and a new token: an old link stops working.
`gpu` (no GPU detection), and `earnings_today`. `activity` is the Node's own record of what it saw (an approval
`gpu` (no GPU detection), and `earnings_today`. `activity` is the Node's own record of what it saw (an approval
waiting or answered, a pause, a share, a capability), newest first, at most 30.
waiting or answered, a pause, a share, a capability), newest first, at most 30.
+`codes` is the desktop's Codes module: `agents` are the coding CLIs on this machine, read the way OpenVibe.Codes reads
+them (installed on PATH, signed in by the CLI's own login file, the version the binary reports); `tasks` are the jobs
+the Codes worker ran here today, kept in `<state>/codes-tasks.json` so an ended task still shows after a restart. A
+folder under the home directory is written as `~/…`, and a task's link is only ever
+`https://openvibe.codes/tasks/<id>`.
+
## Capabilities
## Capabilities
Each card is a permission switch the daemon already has, from the `computer` plugin's `describe` (`terminal`, `files`,
Each card is a permission switch the daemon already has, from the `computer` plugin's `describe` (`terminal`, `files`,
@@ -94,8 +94,13 @@ func (n *Node) job(r protocol.JobRequest) {
@@ -94,8 +94,13 @@ func (n *Node) job(r protocol.JobRequest) {
n.log.Info("job accepted", "id", r.Job.ID, "class", r.Job.Class)
n.log.Info("job accepted", "id", r.Job.ID, "class", r.Job.Class)
}
}
n.send(answer)
n.send(answer)
- if accepted && n.worker != nil {
- n.worker.Launch(r.Job.ID)
+ if accepted {
+ if n.codes != nil {
+ n.codes.accepted(r.Job)
+ }
+ if n.worker != nil {
+ n.worker.Launch(r.Job.ID)
+ }
}
}
}
}
@@ -103,6 +108,12 @@ func (n *Node) job(r protocol.JobRequest) {
@@ -103,6 +108,12 @@ func (n *Node) job(r protocol.JobRequest) {
func (n *Node) sendJobFrame(m protocol.Message) {
func (n *Node) sendJobFrame(m protocol.Message) {
if e, ok := m.(protocol.JobExit); ok {
if e, ok := m.(protocol.JobExit); ok {
n.jobs.ended(e)
n.jobs.ended(e)
+ if n.codes != nil {
+ n.codes.ended(e)
+ }
+ }
+ if s, ok := m.(protocol.JobStarted); ok && n.codes != nil {
+ n.codes.started(s.ID, s.StartedMS)
}
}
n.send(m)
n.send(m)
}
}
@@ -66,6 +66,8 @@ type Options struct {
@@ -66,6 +66,8 @@ type Options struct {
LinkBackoffMin time.Duration
LinkBackoffMin time.Duration
VideoLoopback bool
VideoLoopback bool
LatchPoll time.Duration
LatchPoll time.Duration
+ // CodesAgents replaces the probe of this machine's coding-agent CLIs (for tests).
+ CodesAgents func() []panel.CodesAgent
}
}
// Node is a running device agent.
// Node is a running device agent.
@@ -144,6 +146,9 @@ type Node struct {
@@ -144,6 +146,9 @@ type Node struct {
panelOff map[string]bool
panelOff map[string]bool
panelFirst *panelFirstRun
panelFirst *panelFirstRun
activity []panel.Activity
activity []panel.Activity
+ // codes is what the Codes worker ran here today; codesAgents caches the coding-agent CLIs the window shows.
+ codes *codesTaskState
+ codesAgents codesAgentsCache
// shares keeps OpenVibe Connect shares up and lets the window add or end one.
// shares keeps OpenVibe Connect shares up and lets the window add or end one.
shares *shareSet
shares *shareSet
}
}
@@ -189,6 +194,9 @@ func New(opt Options) (*Node, error) {
@@ -189,6 +194,9 @@ func New(opt Options) (*Node, error) {
n := &Node{opt: opt, log: opt.Log, latch: latch, dedup: safety.NewDedup(4096, 10*time.Minute),
n := &Node{opt: opt, log: opt.Log, latch: latch, dedup: safety.NewDedup(4096, 10*time.Minute),
jobs: newJobs(nil), telemetry: map[string]map[string]any{}, telemMS: 500, approvals: map[string]Approval{}, inputs: map[string]bool{},
jobs: newJobs(nil), telemetry: map[string]map[string]any{}, telemMS: 500, approvals: map[string]Approval{}, inputs: map[string]bool{},
panelOff: panelOff, panelFirst: panelFirst}
panelOff: panelOff, panelFirst: panelFirst}
+ home, _ := os.UserHomeDir()
+ n.codes = loadCodesTasks(opt.Paths.StateDir, home, opt.Log)
+ n.codesAgents.fn = opt.CodesAgents
n.limits = safety.Merge(protocol.Limits{}, opt.Config.Limits.MaxSpeed, opt.Config.Limits.MaxTurn, opt.Config.Limits.MaxCommandMS)
n.limits = safety.Merge(protocol.Limits{}, opt.Config.Limits.MaxSpeed, opt.Config.Limits.MaxTurn, opt.Config.Limits.MaxCommandMS)
var specs []plugins.Spec
var specs []plugins.Spec
@@ -21,6 +21,7 @@ import (
@@ -21,6 +21,7 @@ import (
"github.com/OpenVibers/OpenVibe.Node/internal/fakebot"
"github.com/OpenVibers/OpenVibe.Node/internal/fakebot"
"github.com/OpenVibers/OpenVibe.Node/internal/link"
"github.com/OpenVibers/OpenVibe.Node/internal/link"
"github.com/OpenVibers/OpenVibe.Node/internal/localctl"
"github.com/OpenVibers/OpenVibe.Node/internal/localctl"
+ "github.com/OpenVibers/OpenVibe.Node/internal/panel"
"github.com/OpenVibers/OpenVibe.Node/internal/plugins"
"github.com/OpenVibers/OpenVibe.Node/internal/plugins"
"github.com/OpenVibers/OpenVibe.Node/internal/protocol"
"github.com/OpenVibers/OpenVibe.Node/internal/protocol"
"github.com/OpenVibers/OpenVibe.Node/internal/safety"
"github.com/OpenVibers/OpenVibe.Node/internal/safety"
@@ -102,7 +103,8 @@ func startWith(t *testing.T, prep func(*fakebot.Server), ready bool) *env {
@@ -102,7 +103,8 @@ func startWith(t *testing.T, prep func(*fakebot.Server), ready bool) *env {
n, err := New(Options{Config: cfg, Paths: paths, Creds: creds, Version: "test", HeartbeatInterval: 100 * time.Millisecond,
n, err := New(Options{Config: cfg, Paths: paths, Creds: creds, Version: "test", HeartbeatInterval: 100 * time.Millisecond,
LinkBackoffMin: 50 * time.Millisecond, VideoLoopback: true, LatchPoll: 50 * time.Millisecond,
LinkBackoffMin: 50 * time.Millisecond, VideoLoopback: true, LatchPoll: 50 * time.Millisecond,
- Log: slog.New(slog.NewTextHandler(io.Discard, nil))})
+ Log: slog.New(slog.NewTextHandler(io.Discard, nil)),
+ CodesAgents: func() []panel.CodesAgent { return nil }})
if err != nil {
if err != nil {
t.Fatal(err)
t.Fatal(err)
}
}
@@ -127,6 +127,7 @@ func (n *Node) startPanel(ctx context.Context) {
@@ -127,6 +127,7 @@ func (n *Node) startPanel(ctx context.Context) {
Share: n.shareAdd,
Share: n.shareAdd,
Unshare: n.shareRemove,
Unshare: n.shareRemove,
FirstRun: n.applyFirstRun,
FirstRun: n.applyFirstRun,
+ CodesStop: n.panelStopCodesTask,
Log: n.log,
Log: n.log,
})
})
if err != nil {
if err != nil {
@@ -164,6 +165,7 @@ func (n *Node) panelState() panel.State {
@@ -164,6 +165,7 @@ func (n *Node) panelState() panel.State {
host, _ := os.Hostname()
host, _ := os.Hostname()
out := panel.State{
out := panel.State{
Machine: &panel.Machine{
Machine: &panel.Machine{
+ ID: machineID(st),
Name: host,
Name: host,
OS: runtime.GOOS,
OS: runtime.GOOS,
Online: st.Link.Connected,
Online: st.Link.Connected,
@@ -173,6 +175,7 @@ func (n *Node) panelState() panel.State {
@@ -173,6 +175,7 @@ func (n *Node) panelState() panel.State {
Running: n.panelRunning(st),
Running: n.panelRunning(st),
Capabilities: n.panelCapabilities(st),
Capabilities: n.panelCapabilities(st),
Shares: n.panelShares(st),
Shares: n.panelShares(st),
+ Codes: n.panelCodes(),
}
}
n.mu.Lock()
n.mu.Lock()
out.Activity = append([]panel.Activity(nil), n.activity...)
out.Activity = append([]panel.Activity(nil), n.activity...)
@@ -181,6 +184,14 @@ func (n *Node) panelState() panel.State {
@@ -181,6 +184,14 @@ func (n *Node) panelState() panel.State {
return out
return out
}
}
+// machineID is the id Codes preselects this computer by: the paired Bot device, or the Network principal before that.
+func machineID(st Status) string {
+ if st.DeviceID != "" {
+ return st.DeviceID
+ }
+ return st.Principal
+}
+
// panelRunning lists what the tray already shows as running or waiting: approvals, jobs, connected shares and desks.
// panelRunning lists what the tray already shows as running or waiting: approvals, jobs, connected shares and desks.
func (n *Node) panelRunning(st Status) []panel.Running {
func (n *Node) panelRunning(st Status) []panel.Running {
out := []panel.Running{}
out := []panel.Running{}
new file mode 100644
new file mode 100644
@@ -0,0 +1,552 @@
@@ -0,0 +1,552 @@
+package node
+
+// The Codes half of Link's window: the coding-agent CLIs on this machine, and the Codes tasks its worker runs. Both
+// are local facts. The agents are read the way OpenVibe.Codes reads them (on PATH, signed in by the CLI's own login
+// file, the version the binary reports); the tasks are the jobs the Codes worker accepted here, kept under the Node's
+// state directory so a task that ended today is still shown after a restart. Nothing here reads a token or a key: a
+// login file is only checked for a usable session, never reported.
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "log/slog"
+ "net/url"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+ "unicode/utf8"
+
+ "github.com/OpenVibers/OpenVibe.Node/internal/panel"
+ "github.com/OpenVibers/OpenVibe.Node/internal/protocol"
+)
+
+const (
+ // codesArtifact is the OpenVibe.Codes job runner's artifact name: the jobs that are coding tasks.
+ codesArtifact = "openvibe-codes"
+ // codesTasksName is the file that keeps the Codes tasks this worker ran, under the Node's state directory.
+ codesTasksName = "codes-tasks.json"
+ // codesTaskMax bounds what that file holds; the window lists what happened today, newest first.
+ codesTaskMax = 50
+ // codesTitleMax is the design's title: the first line of the prompt, up to 80 characters.
+ codesTitleMax = 80
+ // codesRefresh is how often the agent chips are probed. A login does not change in seconds, and each probe asks
+ // the installed binaries for their version.
+ codesRefresh = time.Minute
+ // codesURL is the only origin a task link may have.
+ codesURL = "https://openvibe.codes/tasks/"
+)
+
+// codesAgentSpec is one coding-agent CLI on the machine, and how OpenVibe.Codes finds it: the binary on PATH, the
+// login file its CLI writes, and the command that signs it in. A CLI with no login file (dsh reads its provider
+// settings from the machine) is ready once its binary is there.
+type codesAgentSpec struct {
+ ID string
+ Name string
+ Bin string
+ Login string
+ Hint string
+}
+
+// codesAgentSpecs are the CLIs the desktop's Codes module drives (domains plan §13.1).
+var codesAgentSpecs = []codesAgentSpec{
+ {"claude-code", "Claude Code", "claude", ".claude/.credentials.json", "claude auth login"},
+ {"codex", "Codex", "codex", ".codex/auth.json", "codex login"},
+ {"opencode", "OpenCode", "opencode", ".local/share/opencode/auth.json", "opencode auth login"},
+ // Gemini CLI signs in from inside the CLI with /auth; `gemini` is the command that starts it.
+ {"gemini", "Gemini CLI", "gemini", ".gemini/oauth_creds.json", "gemini"},
+ // dsh keeps its model settings in its profile, not in a plan login: installed is as much as is known here.
+ {"dsh", "DeepSeek Harness", "dsh", "", ""},
+}
+
+// codesProbe is what detection needs from the machine, so a test can fake it.
+type codesProbe struct {
+ Home string
+ LookPath func(string) (string, error)
+ ReadFile func(string) ([]byte, error)
+ Version func(string) string
+ // OpenCodeAuth asks OpenCode itself whether it holds a credential. OpenCode moved its logins from auth.json into
+ // its own store, so the file is not the truth on every version. known is false when the CLI cannot answer.
+ OpenCodeAuth func(bin string) (signedIn, known bool)
+}
+
+var codesVersionNumber = regexp.MustCompile(`\d+\.\d+(?:[.\-+][0-9A-Za-z.\-]+)*`)
+
+// detectCodesAgents reads the CLIs: missing when its binary is not on PATH, signin when it is there but its login
+// file holds no usable session, ready otherwise. A version is the first number the binary's own --version reports
+// (never read from a file of ours).
+func detectCodesAgents(p codesProbe) []panel.CodesAgent {
+ if p.Home == "" {
+ p.Home, _ = os.UserHomeDir()
+ }
+ if p.LookPath == nil {
+ p.LookPath = exec.LookPath
+ }
+ if p.ReadFile == nil {
+ p.ReadFile = os.ReadFile
+ }
+ if p.Version == nil {
+ p.Version = func(bin string) string { return codesVersion(bin) }
+ }
+ out := make([]panel.CodesAgent, 0, len(codesAgentSpecs))
+ for _, spec := range codesAgentSpecs {
+ a := panel.CodesAgent{ID: spec.ID, Name: spec.Name, State: "missing"}
+ bin, err := p.LookPath(spec.Bin)
+ if err != nil || bin == "" {
+ // A per-user install in ~/.local/bin is not on a service's PATH; that is still the CLI on this machine.
+ candidate := filepath.Join(p.Home, ".local", "bin", spec.Bin)
+ if fi, statErr := os.Stat(candidate); statErr == nil && !fi.IsDir() {
+ bin = candidate
+ }
+ }
+ if bin == "" {
+ out = append(out, a)
+ continue
+ }
+ a.State = "ready"
+ if spec.Login != "" {
+ raw, readErr := p.ReadFile(filepath.Join(p.Home, filepath.FromSlash(spec.Login)))
+ signed := readErr == nil && codesSignedIn(raw)
+ if spec.ID == "opencode" && p.OpenCodeAuth != nil {
+ if byCLI, known := p.OpenCodeAuth(bin); known {
+ signed = byCLI
+ }
+ }
+ if !signed {
+ a.State, a.Hint = "signin", spec.Hint
+ }
+ }
+ if p.Version != nil {
+ a.Version = p.Version(bin)
+ }
+ out = append(out, a)
+ }
+ return out
+}
+
+// codesSignedIn says whether a CLI's login file holds a usable session. It reads the same keys OpenVibe.Codes' logins
+// probe reads (and Claude Code's own nested claudeAiOauth object) and never reports the file's content.
+func codesSignedIn(raw []byte) bool {
+ var login map[string]any
+ if json.Unmarshal(raw, &login) != nil || login == nil {
+ return false
+ }
+ if usableSession(login) {
+ return true
+ }
+ // Claude Code 2.x keeps the plan session nested: { "claudeAiOauth": { "accessToken": …, "refreshToken": … } }.
+ if nested, ok := login["claudeAiOauth"].(map[string]any); ok && usableSession(nested) {
+ return true
+ }
+ return false
+}
+
+func usableSession(login map[string]any) bool {
+ for _, key := range []string{"access_token", "accessToken", "api_key", "apiKey", "refresh_token", "refreshToken", "token", "tokens", "account"} {
+ switch v := login[key].(type) {
+ case nil:
+ continue
+ case string:
+ if strings.TrimSpace(v) != "" {
+ return true
+ }
+ case map[string]any:
+ if len(v) > 0 {
+ return true
+ }
+ case []any:
+ if len(v) > 0 {
+ return true
+ }
+ default:
+ return true
+ }
+ }
+ return false
+}
+
+// codesVersion asks the binary about itself, with a deadline: a CLI that does not answer must not hold the window.
+func codesVersion(bin string) string {
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ out, err := exec.CommandContext(ctx, bin, "--version").CombinedOutput()
+ if err != nil && len(out) == 0 {
+ return ""
+ }
+ line := strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0])
+ if m := codesVersionNumber.FindString(line); m != "" {
+ line = m
+ }
+ if len(line) > 40 {
+ line = line[:40]
+ }
+ return line
+}
+
+// openCodeAuth asks OpenCode's own store, because a signed-in OpenCode keeps its credential in opencode.db, not in
+// auth.json. Only whether a credential is stored is read; the answer itself never leaves this function.
+func openCodeAuth(bin string) (signedIn, known bool) {
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ out, err := exec.CommandContext(ctx, bin, "auth", "list").CombinedOutput()
+ if err != nil && len(out) == 0 {
+ return false, false
+ }
+ for _, line := range strings.Split(string(out), "\n") {
+ if strings.Contains(strings.ToLower(line), "stored") {
+ return true, true
+ }
+ }
+ return false, true
+}
+
+// codesAgentsCache keeps the last probe, so a window polled every three seconds does not run five binaries each time.
+type codesAgentsCache struct {
+ mu sync.Mutex
+ at time.Time
+ agents []panel.CodesAgent
+ fn func() []panel.CodesAgent // a test's fake, or nil for the machine
+}
+
+func (c *codesAgentsCache) latest(real func() []panel.CodesAgent) []panel.CodesAgent {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ fn := c.fn
+ if fn == nil {
+ fn = real
+ }
+ if c.at.IsZero() || time.Since(c.at) >= codesRefresh {
+ c.agents, c.at = fn(), time.Now()
+ }
+ return append([]panel.CodesAgent(nil), c.agents...)
+}
+
+// codesTaskRecord is one task the Codes worker ran here, as it is kept between runs.
+type codesTaskRecord struct {
+ ID string `json:"id"`
+ Job string `json:"job,omitempty"`
+ Title string `json:"title,omitempty"`
+ Folder string `json:"folder,omitempty"`
+ Agent string `json:"agent,omitempty"`
+ State string `json:"state"`
+ StartedAt time.Time `json:"started_at,omitempty"`
+ UpdatedAt time.Time `json:"updated_at,omitempty"`
+}
+
+type codesTasksFile struct {
+ Tasks []*codesTaskRecord `json:"tasks"`
+}
+
+// codesTaskState is the Codes worker's local record: the tasks it runs or ran today.
+type codesTaskState struct {
+ mu sync.Mutex
+ dir string
+ home string
+ log *slog.Logger
+ tasks []*codesTaskRecord
+}
+
+// loadCodesTasks reads today's tasks. A task left running or waiting by a previous process is stopped: the job that
+// held it did not survive the restart.
+func loadCodesTasks(dir, home string, log *slog.Logger) *codesTaskState {
+ s := &codesTaskState{dir: dir, home: home, log: log}
+ if dir == "" {
+ return s
+ }
+ raw, err := os.ReadFile(filepath.Join(dir, codesTasksName))
+ if err != nil {
+ return s
+ }
+ var f codesTasksFile
+ if json.Unmarshal(raw, &f) != nil {
+ return s
+ }
+ now := time.Now().UTC()
+ for _, t := range f.Tasks {
+ if t == nil || !validCodesTaskID(t.ID) || !codesTaskStates[t.State] {
+ continue
+ }
+ if t.State == "running" || t.State == "waiting" {
+ t.State, t.UpdatedAt = "stopped", now
+ }
+ s.tasks = append(s.tasks, t)
+ }
+ s.pruneLocked(now)
+ return s
+}
+
+func (s *codesTaskState) accepted(j protocol.Job) {
+ if !codesJob(j) {
+ return
+ }
+ var args struct {
+ TaskID string `json:"task_id"`
+ Prompt string `json:"prompt"`
+ Folder string `json:"folder"`
+ Agent string `json:"agent"`
+ }
+ _ = json.Unmarshal(j.Args, &args)
+ id := j.ID
+ if codesContractTaskID.MatchString(args.TaskID) {
+ id = args.TaskID
+ }
+ now := time.Now().UTC()
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ t := s.findLocked(id)
+ if t == nil {
+ t = &codesTaskRecord{ID: id, StartedAt: now}
+ s.tasks = append(s.tasks, t)
+ }
+ t.Job = j.ID
+ t.State = "waiting"
+ t.UpdatedAt = now
+ if title := codesTitle(args.Prompt); title != "" {
+ t.Title = title
+ }
+ if folder := cleanCodesFolder(args.Folder); folder != "" {
+ t.Folder = folder
+ }
+ if agent := cleanCodesAgent(args.Agent); agent != "" {
+ t.Agent = agent
+ }
+ s.saveLocked(now)
+}
+
+func (s *codesTaskState) started(jobID string, ms int64) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ t := s.byJobLocked(jobID)
+ if t == nil {
+ return
+ }
+ t.State = "running"
+ if ms > 0 {
+ t.StartedAt = time.UnixMilli(ms).UTC()
+ }
+ t.UpdatedAt = time.Now().UTC()
+ s.saveLocked(t.UpdatedAt)
+}
+
+func (s *codesTaskState) ended(e protocol.JobExit) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ t := s.byJobLocked(e.ID)
+ if t == nil {
+ return
+ }
+ t.State = codesEndState(e)
+ t.UpdatedAt = time.Now().UTC()
+ s.saveLocked(t.UpdatedAt)
+}
+
+// runningJob is the live worker job for a task, by the task's id or by its job id. Only running or waiting tasks are
+// still this machine's to stop.
+func (s *codesTaskState) runningJob(id string) (string, bool) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ for _, t := range s.tasks {
+ if (t.ID == id || t.Job == id) && (t.State == "running" || t.State == "waiting") && t.Job != "" {
+ return t.Job, true
+ }
+ }
+ return "", false
+}
+
+// list is what the window shows: today's tasks, newest first, the folder with the home directory as ~.
+func (s *codesTaskState) list() []panel.CodesTask {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ sort.SliceStable(s.tasks, func(i, j int) bool { return codesTaskTime(s.tasks[i]).After(codesTaskTime(s.tasks[j])) })
+ out := make([]panel.CodesTask, 0, len(s.tasks))
+ for _, t := range s.tasks {
+ ct := panel.CodesTask{ID: t.ID, Title: t.Title, Folder: shortHome(t.Folder, s.home), Agent: t.Agent,
+ State: t.State, StartedAt: t.StartedAt}
+ if strings.HasPrefix(t.ID, "tsk_") {
+ ct.URL = codesURL + url.PathEscape(t.ID)
+ }
+ out = append(out, ct)
+ }
+ return out
+}
+
+func (s *codesTaskState) findLocked(id string) *codesTaskRecord {
+ for _, t := range s.tasks {
+ if t.ID == id {
+ return t
+ }
+ }
+ return nil
+}
+
+func (s *codesTaskState) byJobLocked(jobID string) *codesTaskRecord {
+ for _, t := range s.tasks {
+ if t.Job == jobID {
+ return t
+ }
+ }
+ return nil
+}
+
+// pruneLocked keeps what runs now and what ran today (by when it started or last changed), newest first, at most
+// codesTaskMax. It does not save: its callers do.
+func (s *codesTaskState) pruneLocked(now time.Time) {
+ kept := s.tasks[:0]
+ for _, t := range s.tasks {
+ if t.State == "running" || t.State == "waiting" || sameCodesDay(t.StartedAt, now) || sameCodesDay(t.UpdatedAt, now) {
+ kept = append(kept, t)
+ }
+ }
+ sort.SliceStable(kept, func(i, j int) bool { return codesTaskTime(kept[i]).After(codesTaskTime(kept[j])) })
+ if len(kept) > codesTaskMax {
+ kept = kept[:codesTaskMax]
+ }
+ s.tasks = kept
+}
+
+func sameCodesDay(a, b time.Time) bool {
+ a, b = a.Local(), b.Local()
+ ay, am, ad := a.Date()
+ by, bm, bd := b.Date()
+ return ay == by && am == bm && ad == bd
+}
+
+func (s *codesTaskState) saveLocked(now time.Time) {
+ s.pruneLocked(now)
+ if s.dir == "" {
+ return
+ }
+ f := filepath.Join(s.dir, codesTasksName)
+ b, err := json.MarshalIndent(codesTasksFile{Tasks: s.tasks}, "", " ")
+ if err != nil {
+ s.warn("Codes tasks not saved", err)
+ return
+ }
+ if err := os.MkdirAll(s.dir, 0o700); err != nil {
+ s.warn("Codes tasks not saved", err)
+ return
+ }
+ tmp := f + ".tmp"
+ if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil {
+ s.warn("Codes tasks not saved", err)
+ return
+ }
+ if err := os.Rename(tmp, f); err != nil {
+ s.warn("Codes tasks not saved", err)
+ }
+}
+
+func (s *codesTaskState) warn(msg string, err error) {
+ if s.log != nil {
+ s.log.Warn(msg, "err", err)
+ }
+}
+
+var codesTaskStates = map[string]bool{"running": true, "waiting": true, "done": true, "failed": true, "stopped": true}
+var codesTaskID = regexp.MustCompile(`^[A-Za-z0-9_.:-]{1,120}$`)
+var codesContractTaskID = regexp.MustCompile(`^tsk_[0-9A-HJKMNP-TV-Z]{26}$`)
+
+func validCodesTaskID(id string) bool { return codesTaskID.MatchString(id) }
+
+func codesJob(j protocol.Job) bool {
+ return j.Artifact != nil && j.Artifact.Name == codesArtifact
+}
+
+func codesEndState(e protocol.JobExit) string {
+ switch e.Reason {
+ case protocol.ExitStopped, protocol.ExitCancelled:
+ return "stopped"
+ case protocol.ExitExited:
+ if e.Code != nil && *e.Code == 0 {
+ return "done"
+ }
+ }
+ return "failed"
+}
+
+// codesTitle is the first line of the prompt, at most 80 characters. The prompt itself stays in the job.
+func codesTitle(prompt string) string {
+ line := strings.TrimSpace(strings.SplitN(strings.ReplaceAll(prompt, "\r\n", "\n"), "\n", 2)[0])
+ line = strings.Join(strings.Fields(line), " ")
+ if utf8.RuneCountInString(line) > codesTitleMax {
+ r := []rune(line)
+ line = string(r[:codesTitleMax])
+ }
+ return line
+}
+
+func cleanCodesFolder(folder string) string {
+ folder = strings.TrimSpace(folder)
+ if folder == "" || len(folder) > 4096 || strings.ContainsAny(folder, "\x00\r\n") {
+ return ""
+ }
+ return folder
+}
+
+func cleanCodesAgent(agent string) string {
+ agent = strings.TrimSpace(agent)
+ if len(agent) > 40 {
+ agent = agent[:40]
+ }
+ return agent
+}
+
+// shortHome shows a folder under the person's home as ~/…; another folder is left as it is.
+func shortHome(folder, home string) string {
+ if folder == "" || home == "" || home == string(filepath.Separator) {
+ return folder
+ }
+ home = filepath.Clean(home)
+ if home == string(filepath.Separator) {
+ return folder
+ }
+ clean := filepath.Clean(folder)
+ if clean == home {
+ return "~"
+ }
+ if strings.HasPrefix(clean, home+string(filepath.Separator)) {
+ return "~" + clean[len(home):]
+ }
+ return folder
+}
+
+func codesTaskTime(t *codesTaskRecord) time.Time {
+ if !t.StartedAt.IsZero() {
+ return t.StartedAt
+ }
+ return t.UpdatedAt
+}
+
+// panelCodes is the window's Codes section: the CLIs here and the tasks this worker ran today.
+func (n *Node) panelCodes() *panel.Codes {
+ c := &panel.Codes{Agents: n.codesAgents.latest(n.probeCodesAgents)}
+ if n.codes != nil {
+ c.Tasks = n.codes.list()
+ }
+ return c
+}
+
+// probeCodesAgents is the real detection: this machine's home and PATH, the binaries' own --version.
+func (n *Node) probeCodesAgents() []panel.CodesAgent {
+ home, _ := os.UserHomeDir()
+ return detectCodesAgents(codesProbe{Home: home, LookPath: exec.LookPath, ReadFile: os.ReadFile,
+ Version: codesVersion, OpenCodeAuth: openCodeAuth})
+}
+
+// panelStopCodesTask stops the local run the way Codes' own stop does: cancel its Run job here, which kills the
+// worker's process and makes its job_exit say cancelled. A task that is not running on this computer is refused.
+func (n *Node) panelStopCodesTask(id string) error {
+ if n.codes == nil {
+ return errors.New("that task is not running on this computer")
+ }
+ jobID, ok := n.codes.runningJob(id)
+ if !ok || n.worker == nil || !n.worker.Cancel(jobID) {
+ return errors.New("that task is not running on this computer")
+ }
+ return nil
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,179 @@
@@ -0,0 +1,179 @@
+package node
+
+import (
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/OpenVibers/OpenVibe.Node/internal/panel"
+ "github.com/OpenVibers/OpenVibe.Node/internal/protocol"
+)
+
+// The three states the window shows, from a fake machine: ready with a version, sign in with the exact command, and
+// not installed.
+func TestDetectCodesAgentsReportsReadySignInAndMissing(t *testing.T) {
+ home := t.TempDir()
+ login := filepath.Join(home, ".claude", ".credentials.json")
+ if err := os.MkdirAll(filepath.Dir(login), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(login, []byte(`{"claudeAiOauth":{"accessToken":"not-a-real-token","refreshToken":"also-not-real"}}`), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ probe := codesProbe{
+ Home: home,
+ LookPath: func(bin string) (string, error) {
+ switch bin {
+ case "claude", "codex", "dsh":
+ return "/usr/bin/" + bin, nil
+ }
+ return "", errors.New("not on PATH")
+ },
+ ReadFile: os.ReadFile,
+ Version: func(string) string { return "1.2.3" },
+ }
+ got := map[string]panel.CodesAgent{}
+ for _, a := range detectCodesAgents(probe) {
+ got[a.ID] = a
+ }
+ if a := got["claude-code"]; a.State != "ready" || a.Version != "1.2.3" || a.Hint != "" {
+ t.Fatalf("claude-code: %+v", a)
+ }
+ if a := got["codex"]; a.State != "signin" || a.Hint != "codex login" {
+ t.Fatalf("codex: %+v", a)
+ }
+ if a := got["opencode"]; a.State != "missing" {
+ t.Fatalf("opencode: %+v", a)
+ }
+ if a := got["gemini"]; a.State != "missing" {
+ t.Fatalf("gemini: %+v", a)
+ }
+ // dsh has no plan login of its own: installed is the fact.
+ if a := got["dsh"]; a.State != "ready" || a.Hint != "" {
+ t.Fatalf("dsh: %+v", a)
+ }
+}
+
+// A login file that is there but has no usable session is sign-in, never ready.
+func TestCodesLoginFileWithoutASessionIsSignIn(t *testing.T) {
+ if codesSignedIn([]byte(`{}`)) || codesSignedIn([]byte(`not json`)) || codesSignedIn([]byte(`{"access_token":""}`)) {
+ t.Fatal("an empty login was taken as signed in")
+ }
+ if !codesSignedIn([]byte(`{"refresh_token":"x"}`)) || !codesSignedIn([]byte(`{"tokens":{"a":"b"}}`)) ||
+ !codesSignedIn([]byte(`{"claudeAiOauth":{"accessToken":"x"}}`)) {
+ t.Fatal("a usable login was not recognised")
+ }
+}
+
+func TestCodesFolderUsesTildeAndTheTitleIsTheFirstLine(t *testing.T) {
+ home := t.TempDir()
+ dir := t.TempDir()
+ s := loadCodesTasks(dir, home, nil)
+ args, err := json.Marshal(map[string]any{
+ "task_id": "tsk_01M6Q1Z8V4T7R2N5K3B9C6D0EF",
+ "prompt": strings.Repeat("A very long first line ", 8) + "\nsecond line",
+ "folder": filepath.Join(home, "code", "site"),
+ "agent": "claude-code",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ s.accepted(protocol.Job{ID: "job_1", Artifact: &protocol.Artifact{Name: codesArtifact}, Args: args})
+ tasks := s.list()
+ if len(tasks) != 1 {
+ t.Fatalf("tasks: %+v", tasks)
+ }
+ got := tasks[0]
+ if got.Folder != filepath.Join("~", "code", "site") {
+ t.Fatalf("folder %q", got.Folder)
+ }
+ if len([]rune(got.Title)) != codesTitleMax || strings.Contains(got.Title, "second line") {
+ t.Fatalf("title %q", got.Title)
+ }
+ if got.Agent != "claude-code" || got.State != "waiting" || got.StartedAt.IsZero() {
+ t.Fatalf("task %+v", got)
+ }
+ if got.URL != "https://openvibe.codes/tasks/tsk_01M6Q1Z8V4T7R2N5K3B9C6D0EF" {
+ t.Fatalf("url %q", got.URL)
+ }
+ if shortHome(home, home) != "~" || shortHome("/elsewhere/x", home) != "/elsewhere/x" {
+ t.Fatal("shortHome changed a folder outside the home")
+ }
+}
+
+func TestCodesTaskGoesFromWaitingToRunningToDoneAndStays(t *testing.T) {
+ dir := t.TempDir()
+ s := loadCodesTasks(dir, "/home/alex", nil)
+ args, _ := json.Marshal(map[string]any{"task_id": "tsk_01M6Q1Z8V4T7R2N5K3B9C6D0EF", "prompt": "Fix the test"})
+ s.accepted(protocol.Job{ID: "job_1", Artifact: &protocol.Artifact{Name: codesArtifact}, Args: args})
+ if job, ok := s.runningJob("tsk_01M6Q1Z8V4T7R2N5K3B9C6D0EF"); !ok || job != "job_1" {
+ t.Fatalf("waiting task is not stoppable: %q %v", job, ok)
+ }
+ started := time.Date(2026, 11, 7, 10, 2, 0, 0, time.UTC)
+ s.started("job_1", started.UnixMilli())
+ if got := s.list()[0]; got.State != "running" || !got.StartedAt.Equal(started) {
+ t.Fatalf("running: %+v", got)
+ }
+ zero := 0
+ s.ended(protocol.JobExit{ID: "job_1", Reason: protocol.ExitExited, Code: &zero})
+ if got := s.list()[0]; got.State != "done" {
+ t.Fatalf("done: %+v", got)
+ }
+ if _, ok := s.runningJob("tsk_01M6Q1Z8V4T7R2N5K3B9C6D0EF"); ok {
+ t.Fatal("a done task is still stoppable")
+ }
+ // The record survives a restart, and a task left running by the old process is stopped, not left working.
+ again := loadCodesTasks(dir, "/home/alex", nil)
+ if got := again.list(); len(got) != 1 || got[0].State != "done" {
+ t.Fatalf("reloaded: %+v", got)
+ }
+ again.accepted(protocol.Job{ID: "job_2", Artifact: &protocol.Artifact{Name: codesArtifact}, Args: args})
+ if got := again.list()[0]; got.State != "waiting" {
+ t.Fatalf("a second job for the task did not restart it: %+v", got)
+ }
+ restarted := loadCodesTasks(dir, "/home/alex", nil)
+ if got := restarted.list()[0]; got.State != "stopped" {
+ t.Fatalf("left-running task: %+v", got)
+ }
+}
+
+func TestCodesTaskEndStatesAndJobFilter(t *testing.T) {
+ dir := t.TempDir()
+ s := loadCodesTasks(dir, "/home/alex", nil)
+ args, _ := json.Marshal(map[string]any{"prompt": "x"})
+ s.accepted(protocol.Job{ID: "job_other", Artifact: &protocol.Artifact{Name: "thumbnail"}})
+ s.accepted(protocol.Job{ID: "job_stop", Artifact: &protocol.Artifact{Name: codesArtifact}, Args: args})
+ if len(s.list()) != 1 || s.list()[0].ID != "job_stop" {
+ t.Fatalf("a job that is not a Codes task was recorded: %+v", s.list())
+ }
+ s.ended(protocol.JobExit{ID: "job_stop", Reason: protocol.ExitStopped})
+ if got := s.list()[0].State; got != "stopped" {
+ t.Fatalf("stopped: %q", got)
+ }
+ s.accepted(protocol.Job{ID: "job_fail", Artifact: &protocol.Artifact{Name: codesArtifact}, Args: args})
+ one := 1
+ s.ended(protocol.JobExit{ID: "job_fail", Reason: protocol.ExitExited, Code: &one})
+ if got := s.list()[0].State; got != "failed" {
+ t.Fatalf("failed: %q", got)
+ }
+}
+
+func TestStopCodesTaskRefusesWhatIsNotRunningHere(t *testing.T) {
+ if err := (&Node{}).panelStopCodesTask("tsk_01M6Q1Z8V4T7R2N5K3B9C6D0EF"); err == nil {
+ t.Fatal("a task was stopped with no worker")
+ }
+ s := loadCodesTasks(t.TempDir(), "/home/alex", nil)
+ args, _ := json.Marshal(map[string]any{"task_id": "tsk_01M6Q1Z8V4T7R2N5K3B9C6D0EF", "prompt": "x"})
+ s.accepted(protocol.Job{ID: "job_1", Artifact: &protocol.Artifact{Name: codesArtifact}, Args: args})
+ n := &Node{codes: s}
+ if err := n.panelStopCodesTask("tsk_01M6Q1Z8V4T7R2N5K3B9C6D0EF"); err == nil {
+ t.Fatal("a task was stopped with no worker")
+ }
+ if err := n.panelStopCodesTask("tsk_unknown"); err == nil {
+ t.Fatal("an unknown task was stopped")
+ }
+}
@@ -15,12 +15,14 @@ import (
@@ -15,12 +15,14 @@ import (
"github.com/OpenVibers/OpenVibe.Node/internal/config"
"github.com/OpenVibers/OpenVibe.Node/internal/config"
"github.com/OpenVibers/OpenVibe.Node/internal/connect"
"github.com/OpenVibers/OpenVibe.Node/internal/connect"
"github.com/OpenVibers/OpenVibe.Node/internal/localctl"
"github.com/OpenVibers/OpenVibe.Node/internal/localctl"
+ "github.com/OpenVibers/OpenVibe.Node/internal/panel"
"github.com/OpenVibers/OpenVibe.Node/internal/protocol"
"github.com/OpenVibers/OpenVibe.Node/internal/protocol"
)
)
func panelTestNode(t *testing.T, paths config.Paths) *Node {
func panelTestNode(t *testing.T, paths config.Paths) *Node {
t.Helper()
t.Helper()
- n, err := New(Options{Config: &config.Config{}, Paths: paths, Log: slog.New(slog.NewTextHandler(io.Discard, nil))})
+ n, err := New(Options{Config: &config.Config{}, Paths: paths, Log: slog.New(slog.NewTextHandler(io.Discard, nil)),
+ CodesAgents: func() []panel.CodesAgent { return nil }})
if err != nil {
if err != nil {
t.Fatal(err)
t.Fatal(err)
}
}
@@ -107,6 +109,15 @@ func TestCapabilitySwitchIsARealRestriction(t *testing.T) {
@@ -107,6 +109,15 @@ func TestCapabilitySwitchIsARealRestriction(t *testing.T) {
}
}
}
}
+func TestMachineIDIsThePairedDevice(t *testing.T) {
+ if got := machineID(Status{DeviceID: "dev_1", Principal: "nod_1"}); got != "dev_1" {
+ t.Fatalf("device id: %q", got)
+ }
+ if got := machineID(Status{Principal: "nod_1"}); got != "nod_1" {
+ t.Fatalf("principal: %q", got)
+ }
+}
+
func TestPanelStateComesFromTheNode(t *testing.T) {
func TestPanelStateComesFromTheNode(t *testing.T) {
dir, err := os.MkdirTemp("", "ovp")
dir, err := os.MkdirTemp("", "ovp")
if err != nil {
if err != nil {
@@ -114,6 +125,9 @@ func TestPanelStateComesFromTheNode(t *testing.T) {
@@ -114,6 +125,9 @@ func TestPanelStateComesFromTheNode(t *testing.T) {
}
}
defer os.RemoveAll(dir)
defer os.RemoveAll(dir)
n := panelTestNode(t, config.HomePaths(dir))
n := panelTestNode(t, config.HomePaths(dir))
+ n.codesAgents.fn = func() []panel.CodesAgent {
+ return []panel.CodesAgent{{ID: "codex", Name: "Codex", State: "signin", Hint: "codex login"}}
+ }
if err := n.panelPause(true); err != nil {
if err := n.panelPause(true); err != nil {
t.Fatal(err)
t.Fatal(err)
}
}
@@ -121,6 +135,9 @@ func TestPanelStateComesFromTheNode(t *testing.T) {
@@ -121,6 +135,9 @@ func TestPanelStateComesFromTheNode(t *testing.T) {
if st.Machine == nil || st.Machine.Name == "" || !st.Machine.Paused {
if st.Machine == nil || st.Machine.Name == "" || !st.Machine.Paused {
t.Fatalf("machine: %+v", st.Machine)
t.Fatalf("machine: %+v", st.Machine)
}
}
+ if st.Codes == nil || len(st.Codes.Agents) != 1 || st.Codes.Agents[0].Hint != "codex login" {
+ t.Fatalf("codes: %+v", st.Codes)
+ }
if len(st.Activity) == 0 || !strings.Contains(st.Activity[0].Text, "stopped") {
if len(st.Activity) == 0 || !strings.Contains(st.Activity[0].Text, "stopped") {
t.Fatalf("activity: %+v", st.Activity)
t.Fatalf("activity: %+v", st.Activity)
}
}
@@ -37,6 +37,8 @@ var ErrNotImplemented = errors.New("not implemented in this version of Link")
@@ -37,6 +37,8 @@ var ErrNotImplemented = errors.New("not implemented in this version of Link")
// Machine is what the window's header shows.
// Machine is what the window's header shows.
type Machine struct {
type Machine struct {
+ // ID is the machine's own id (known after pairing), so "New task on this computer" can preselect it.
+ ID string `json:"id,omitempty"`
Name string `json:"name"`
Name string `json:"name"`
OS string `json:"os,omitempty"`
OS string `json:"os,omitempty"`
Online bool `json:"online"`
Online bool `json:"online"`
@@ -168,7 +170,10 @@ type Options struct {
@@ -168,7 +170,10 @@ type Options struct {
// FirstRun records the answer to "What's this computer for?" and turns on what each use says. A skip only stops
// FirstRun records the answer to "What's this computer for?" and turns on what each use says. A skip only stops
// the question.
// the question.
FirstRun func(uses []string, skip bool) error
FirstRun func(uses []string, skip bool) error
- Log *slog.Logger
+ // CodesStop stops the local Codes task with this id the way Codes' own stop does. It returns an error when the
+ // task is not running on this computer.
+ CodesStop func(id string) error
+ Log *slog.Logger
}
}
// Server is one running panel server.
// Server is one running panel server.
@@ -311,6 +316,21 @@ func (s *Server) api() http.Handler {
@@ -311,6 +316,21 @@ func (s *Server) api() http.Handler {
return
return
}
}
writeJSON(w, http.StatusOK, map[string]string{"stopped": id})
writeJSON(w, http.StatusOK, map[string]string{"stopped": id})
+ case strings.HasPrefix(path, "codes/tasks/") && strings.HasSuffix(path, "/stop") && r.Method == http.MethodPost:
+ id := strings.TrimSuffix(strings.TrimPrefix(path, "codes/tasks/"), "/stop")
+ if id == "" || strings.Contains(id, "/") {
+ http.NotFound(w, r)
+ return
+ }
+ if s.opt.CodesStop == nil {
+ writeJSON(w, http.StatusServiceUnavailable, errBody("the panel cannot stop this task"))
+ return
+ }
+ if err := s.opt.CodesStop(id); err != nil {
+ writeJSON(w, http.StatusConflict, errBody(err.Error()))
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]string{"stopped": id})
case strings.HasPrefix(path, "capabilities/") && r.Method == http.MethodPost:
case strings.HasPrefix(path, "capabilities/") && r.Method == http.MethodPost:
id := strings.TrimPrefix(path, "capabilities/")
id := strings.TrimPrefix(path, "capabilities/")
if id == "" || strings.Contains(id, "/") {
if id == "" || strings.Contains(id, "/") {
@@ -145,7 +145,7 @@ func TestStateShapeMatchesTheDesign(t *testing.T) {
@@ -145,7 +145,7 @@ func TestStateShapeMatchesTheDesign(t *testing.T) {
}
}
now := time.Date(2026, 11, 7, 10, 2, 0, 0, time.UTC)
now := time.Date(2026, 11, 7, 10, 2, 0, 0, time.UTC)
full := State{
full := State{
- Machine: &Machine{Name: "Workshop desktop", OS: "linux", Online: true, Paused: false, NeedsYou: 1},
+ Machine: &Machine{ID: "dev_1", Name: "Workshop desktop", OS: "linux", Online: true, Paused: false, NeedsYou: 1},
Running: []Running{{ID: "r_1", Kind: "agent", Title: "t", Detail: "d", StartedAt: now}},
Running: []Running{{ID: "r_1", Kind: "agent", Title: "t", Detail: "d", StartedAt: now}},
Capabilities: []Capability{{ID: "files", Name: "n", Who: "w", On: true}},
Capabilities: []Capability{{ID: "files", Name: "n", Who: "w", On: true}},
Shares: []Share{{Port: 3000, URL: "https://x", ExpiresAt: now}},
Shares: []Share{{Port: 3000, URL: "https://x", ExpiresAt: now}},
@@ -240,6 +240,45 @@ func TestStopCapabilityAndSharesReachTheDaemon(t *testing.T) {
@@ -240,6 +240,45 @@ func TestStopCapabilityAndSharesReachTheDaemon(t *testing.T) {
}
}
}
}
+func TestMachineCarriesItsOwnID(t *testing.T) {
+ srv := testServer(t, Options{State: func() State { return State{Machine: &Machine{ID: "dev_1", Name: "here"}} }})
+ w := do(t, srv, http.MethodGet, "/panel/api/state", testToken, "", "")
+ if w.Code != http.StatusOK {
+ t.Fatalf("state: %d %s", w.Code, w.Body)
+ }
+ var st State
+ if err := json.Unmarshal(w.Body.Bytes(), &st); err != nil || st.Machine == nil || st.Machine.ID != "dev_1" {
+ t.Fatalf("machine: %v %s", err, w.Body)
+ }
+}
+
+func TestCodesTaskStopNeedsTheTokenAndARunningTask(t *testing.T) {
+ var stopped string
+ srv := testServer(t, Options{CodesStop: func(id string) error {
+ if id != "tsk_running" {
+ return errors.New("that task is not running on this computer")
+ }
+ stopped = id
+ return nil
+ }})
+ path := "/panel/api/codes/tasks/tsk_running/stop"
+ if w := do(t, srv, http.MethodPost, path, "", "", ""); w.Code != http.StatusForbidden {
+ t.Fatalf("no token: %d", w.Code)
+ }
+ if w := do(t, srv, http.MethodPost, path, testToken, "https://evil.example", ""); w.Code != http.StatusForbidden {
+ t.Fatalf("wrong origin: %d", w.Code)
+ }
+ if w := do(t, srv, http.MethodPost, "/panel/api/codes/tasks/tsk_unknown/stop", testToken, srv.Origin(), ""); w.Code != http.StatusConflict {
+ t.Fatalf("unknown task: %d %s", w.Code, w.Body)
+ }
+ if w := do(t, srv, http.MethodPost, path, testToken, srv.Origin(), ""); w.Code != http.StatusOK {
+ t.Fatalf("running task: %d %s", w.Code, w.Body)
+ }
+ if stopped != "tsk_running" {
+ t.Fatalf("stopped %q", stopped)
+ }
+}
+
func TestFirstRunIsRecordedAfterTokenAndUseChecks(t *testing.T) {
func TestFirstRunIsRecordedAfterTokenAndUseChecks(t *testing.T) {
var calls int
var calls int
var gotUses []string
var gotUses []string
@@ -17,6 +17,7 @@ fragment: `/panel/#t=<token>`. The page sends that token as `X-Link-Panel` on ev
@@ -17,6 +17,7 @@ fragment: `/panel/#t=<token>`. The page sends that token as `X-Link-Panel` on ev
| `GET /panel/api/state` | — | Everything the window shows (below). Polled every 3 s while the window is visible. |
| `GET /panel/api/state` | — | Everything the window shows (below). Polled every 3 s while the window is visible. |
| `POST /panel/api/pause` | `{ "paused": true }` | The local kill switch: it stops everything at once and always wins over any remote request. |
| `POST /panel/api/pause` | `{ "paused": true }` | The local kill switch: it stops everything at once and always wins over any remote request. |
| `POST /panel/api/running/:id/stop` | — | Stops one running thing. |
| `POST /panel/api/running/:id/stop` | — | Stops one running thing. |
+| `POST /panel/api/codes/tasks/:id/stop` | — | Stops the local Codes task the window lists. A task that is not running on this computer is refused (`409`). |
| `POST /panel/api/capabilities/:id` | `{ "on": false }` | Turns a capability on or off. Turning one on is the consent step. |
| `POST /panel/api/capabilities/:id` | `{ "on": false }` | Turns a capability on or off. Turning one on is the consent step. |
| `POST /panel/api/shares` | `{ "port": 3000 }` | Shares a local port through Connect, behind the person's sign-in. |
| `POST /panel/api/shares` | `{ "port": 3000 }` | Shares a local port through Connect, behind the person's sign-in. |
| `DELETE /panel/api/shares/:port` | — | Ends that share. |
| `DELETE /panel/api/shares/:port` | — | Ends that share. |
@@ -29,18 +30,26 @@ Every key is optional. A section with nothing in it is left out; the window neve
@@ -29,18 +30,26 @@ Every key is optional. A section with nothing in it is left out; the window neve
```json
```json
{
{
- "machine": { "name": "Workshop desktop", "os": "linux", "online": true, "paused": false, "needs_you": 1 },
+ "machine": { "id": "dev_01M6Q1Z8V4T7R2N5K3B9C6D0EF", "name": "Workshop desktop", "os": "linux", "online": true, "paused": false, "needs_you": 1 },
"running": [{ "id": "r_1", "kind": "agent", "title": "Vibe is comparing three hosting plans", "detail": "Browser", "started_at": "2026-11-07T10:02:00Z" }],
"running": [{ "id": "r_1", "kind": "agent", "title": "Vibe is comparing three hosting plans", "detail": "Browser", "started_at": "2026-11-07T10:02:00Z" }],
"capabilities": [{ "id": "browser", "name": "A browser for your agents", "who": "Your agents, after you approve each task", "on": true }],
"capabilities": [{ "id": "browser", "name": "A browser for your agents", "who": "Your agents, after you approve each task", "on": true }],
"shares": [{ "port": 3000, "url": "https://dev--alex.openvibe.website", "expires_at": "2026-11-07T22:00:00Z" }],
"shares": [{ "port": 3000, "url": "https://dev--alex.openvibe.website", "expires_at": "2026-11-07T22:00:00Z" }],
"devices": [{ "name": "Workshop desktop", "kind": "desktop", "online": true, "here": true }],
"devices": [{ "name": "Workshop desktop", "kind": "desktop", "online": true, "here": true }],
"gpu": { "name": "RTX 3090", "vram_gb": 24, "models": [{ "name": "Qwen2.5 14B", "tps": 41, "loaded": true }] },
"gpu": { "name": "RTX 3090", "vram_gb": 24, "models": [{ "name": "Qwen2.5 14B", "tps": 41, "loaded": true }] },
"earnings_today": { "coins": 120 },
"earnings_today": { "coins": 120 },
+ "codes": {
+ "agents": [{ "id": "claude-code", "name": "Claude Code", "state": "ready", "version": "2.1.3" }, { "id": "codex", "name": "Codex", "state": "signin", "hint": "codex login" }],
+ "tasks": [{ "id": "tsk_01M6Q1Z8V4T7R2N5K3B9C6D0EF", "title": "Make every page work at 320 px", "folder": "~/code/openvibe-site", "agent": "Claude Code", "state": "running", "started_at": "2026-11-07T09:52:00Z", "url": "https://openvibe.codes/tasks/tsk_01M6Q1Z8V4T7R2N5K3B9C6D0EF" }]
+ },
"activity": [{ "at": "2026-11-07T10:02:00Z", "text": "Vibe opened a browser to compare hosting plans" }]
"activity": [{ "at": "2026-11-07T10:02:00Z", "text": "Vibe opened a browser to compare hosting plans" }]
}
}
```
```
+- `machine.id` is the machine's own id after pairing, so "New task on this computer" can preselect it.
- `running[].kind` is `agent`, `desk`, `share` or `model`.
- `running[].kind` is `agent`, `desk`, `share` or `model`.
+- `codes.agents[].state` is `ready`, `signin` (then `hint` is the CLI's own sign-in command) or `missing`.
+- `codes.tasks[]` are the Codes jobs this worker ran today: `state` is `running`, `waiting`, `done`, `failed` or
+ `stopped`; `folder` shows the home directory as `~`; `url` is only ever `https://openvibe.codes/tasks/<id>`.
- `machine.needs_you` counts what waits for the person: it puts a dot on the ring.
- `machine.needs_you` counts what waits for the person: it puts a dot on the ring.
- `first_run: true` shows the "What's this computer for?" question at the top, until it is answered or skipped.
- `first_run: true` shows the "What's this computer for?" question at the top, until it is answered or skipped.
- The ring shows:
- The ring shows:
@@ -1,6 +1,7 @@
@@ -1,6 +1,7 @@
{
{
"first_run": true,
"first_run": true,
"machine": {
"machine": {
+ "id": "dev_01M6Q1Z8V4T7R2N5K3B9C6D0EF",
"name": "Workshop desktop",
"name": "Workshop desktop",
"os": "linux",
"os": "linux",
"online": true,
"online": true,
@@ -123,6 +124,12 @@
@@ -123,6 +124,12 @@
"id": "gemini",
"id": "gemini",
"name": "Gemini CLI",
"name": "Gemini CLI",
"state": "missing"
"state": "missing"
+ },
+ {
+ "id": "dsh",
+ "name": "DeepSeek Harness",
+ "state": "ready",
+ "version": "0.2.1"
}
}
],
],
"tasks": [
"tasks": [