openvibers/openvibe.link Commits

Link's window: the daemon reports the CLIs and the Codes tasks here (#130)

OP OpenVibers committed 6 days ago · d15d3790b7 · 13 files, +874 −12

Codes agents: detect Claude Code, Codex, OpenCode, Gemini CLI and dsh the way OpenVibe.Codes does (on PATH, signed in by the CLI's own login or its auth list, the version the binary reports), with the exact sign-in command for a sign-in state.

Codes tasks: the worker records the Codes jobs it accepts in <state>/codes-tasks.json, so the window shows today's tasks with the first line of the prompt, the folder as ~/..., the agent and running, waiting, done, failed or stopped; a tsk_ task links only to openvibe.codes.

Stop: POST /panel/api/codes/tasks/:id/stop keeps the panel token and Origin checks, cancels the local Run job as Codes' own stop does, and answers 409 when the task is not running here. Machine gains its paired device id so New task can preselect it.

Changes

ddiff --git a/README.md b/README.md
iindex ce39f66..7bf0161 100644
---- a/README.md
++++ b/README.md
@@@ -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.
**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
protocol, the bundled drivers, and where Actor's computer-control plugin attaches), [install](docs/install.md),
ddiff --git a/docs/panel.md b/docs/panel.md
iindex 28db687..ecee4e8 100644
---- a/docs/panel.md
++++ b/docs/panel.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 approval
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
Each card is a permission switch the daemon already has, from the `computer` plugin's `describe` (`terminal`, `files`,
ddiff --git a/internal/node/jobs.go b/internal/node/jobs.go
iindex cc61074..90014f0 100644
---- a/internal/node/jobs.go
++++ b/internal/node/jobs.go
@@@ -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)
}
ddiff --git a/internal/node/node.go b/internal/node/node.go
iindex 8282f77..13ac52c 100644
---- a/internal/node/node.go
++++ b/internal/node/node.go
@@@ -66,6 +66,8 @@ type Options struct {
LinkBackoffMin time.Duration
VideoLoopback bool
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.
@@@ -144,6 +146,9 @@ type Node struct {
panelOff map[string]bool
panelFirst *panelFirstRun
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 *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.CodesAgents
n.limits = safety.Merge(protocol.Limits{}, opt.Config.Limits.MaxSpeed, opt.Config.Limits.MaxTurn, opt.Config.Limits.MaxCommandMS)
var specs []plugins.Spec
ddiff --git a/internal/node/node_test.go b/internal/node/node_test.go
iindex 9dc4e89..6808a9a 100644
---- a/internal/node/node_test.go
++++ b/internal/node/node_test.go
@@@ -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)
}
ddiff --git a/internal/node/panel.go b/internal/node/panel.go
iindex bd0def8..9e62494 100644
---- a/internal/node/panel.go
++++ b/internal/node/panel.go
@@@ -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{}
ddiff --git a/internal/node/panel_codes.go b/internal/node/panel_codes.go
nnew file mode 100644
iindex 0000000..a2f1468
---- /dev/null
++++ b/internal/node/panel_codes.go
@@@ -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
++}
ddiff --git a/internal/node/panel_codes_test.go b/internal/node/panel_codes_test.go
nnew file mode 100644
iindex 0000000..8b3d0e9
---- /dev/null
++++ b/internal/node/panel_codes_test.go
@@@ -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")
++ }
++}
ddiff --git a/internal/node/panel_test.go b/internal/node/panel_test.go
iindex 6b4a1f6..c156ab8 100644
---- a/internal/node/panel_test.go
++++ b/internal/node/panel_test.go
@@@ -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)
}
ddiff --git a/internal/panel/panel.go b/internal/panel/panel.go
iindex 5634520..0f050ce 100644
---- a/internal/panel/panel.go
++++ b/internal/panel/panel.go
@@@ -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, "/") {
ddiff --git a/internal/panel/panel_test.go b/internal/panel/panel_test.go
iindex fec80f2..c7763e3 100644
---- a/internal/panel/panel_test.go
++++ b/internal/panel/panel_test.go
@@@ -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 int
var gotUses []string
ddiff --git a/internal/panel/ui/README.md b/internal/panel/ui/README.md
iindex 708177d..22bc6da 100644
---- a/internal/panel/ui/README.md
++++ b/internal/panel/ui/README.md
@@@ -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:
ddiff --git a/internal/panel/ui/testdata/state.json b/internal/panel/ui/testdata/state.json
iindex 06cb12c..ed1b818 100644
---- a/internal/panel/ui/testdata/state.json
++++ b/internal/panel/ui/testdata/state.json
@@@ -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": [